• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

Cecilapp / Cecil / 15776123410

20 Jun 2025 09:44AM UTC coverage: 82.623%. Remained the same
15776123410

push

github

ArnaudLigny
doc: update API doc

3119 of 3775 relevant lines covered (82.62%)

0.83 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

70.73
/src/Config.php
1
<?php
2

3
/**
4
 * This file is part of Cecil.
5
 *
6
 * (c) Arnaud Ligny <arnaud@ligny.fr>
7
 *
8
 * For the full copyright and license information, please view the LICENSE
9
 * file that was distributed with this source code.
10
 */
11

12
declare(strict_types=1);
13

14
namespace Cecil;
15

16
use Cecil\Exception\ConfigException;
17
use Dflydev\DotAccessData\Data;
18
use Symfony\Component\Yaml\Exception\ParseException;
19
use Symfony\Component\Yaml\Yaml;
20

21
/**
22
 * Configuration class.
23
 *
24
 * This class is used to manage the configuration of the application.
25
 * It allows to import, export, validate and access configuration data.
26
 * It also provides methods to handle paths, languages, themes, and cache.
27
 */
28
class Config
29
{
30
    public const IMPORT_PRESERVE = 0;
31
    public const IMPORT_REPLACE = 1;
32
    public const IMPORT_MERGE = 2;
33
    public const LANG_CODE_PATTERN = '([a-z]{2}(-[A-Z]{2})?)'; // "fr" or "fr-FR"
34
    public const LANG_LOCALE_PATTERN = '[a-z]{2}(_[A-Z]{2})?(_[A-Z]{2})?'; // "fr" or "fr_FR" or "no_NO_NY"
35

36
    /**
37
     * Configuration is a Data object.
38
     * This allows to use dot notation to access configuration keys.
39
     * For example: $config->get('key.subkey') or $config->has('key.subkey').
40
     * @var Data
41
     */
42
    protected Data $data;
43
    /**
44
     * Default configuration.
45
     */
46
    protected Data $default;
47
    /**
48
     * Source directory.
49
     * This is the directory where the source files are located.
50
     * It is used to resolve relative paths in the configuration.
51
     * If not set, it defaults to the current working directory.
52
     * @var string|null
53
     */
54
    protected ?string $sourceDir = null;
55
    /**
56
     * Destination directory.
57
     * This is the directory where the output files will be generated.
58
     * It is used to resolve relative paths in the configuration.
59
     * If not set, it defaults to the source directory.
60
     * @var string|null
61
     */
62
    protected ?string $destinationDir = null;
63
    /**
64
     * Languages list as array.
65
     * This is used to store the languages defined in the configuration.
66
     * It is initialized to null and will be populated when the languages are requested.
67
     * @var array|null
68
     * @see Config::getLanguages()
69
     * @see Config::getLanguageDefault()
70
     */
71
    protected ?array $languages = null;
72

73
    /**
74
     * Build the Config object with the default config + the optional given array.
75
     */
76
    public function __construct(?array $config = null)
77
    {
78
        // default configuration
79
        $defaultConfigFile = Util\File::getRealPath('../config/default.php');
1✔
80
        $this->default = new Data(include $defaultConfigFile);
1✔
81
        // base configuration
82
        $baseConfigFile = Util\File::getRealPath('../config/base.php');
1✔
83
        $this->data = new Data(include $baseConfigFile);
1✔
84
        // import config array if provided
85
        if ($config !== null) {
1✔
86
            $this->import($config);
1✔
87
        }
88
    }
89

90
    /**
91
     * Imports (and validate) configuration.
92
     * The mode can be:
93
     * - Config::IMPORT_PRESERVE: preserves existing configuration and adds new keys.
94
     * - Config::IMPORT_REPLACE: replaces existing configuration with new keys.
95
     * - Config::IMPORT_MERGE: merges existing configuration with new keys, overriding existing keys.
96
     * @param array $config Configuration array to import
97
     * @param int   $mode   Import mode (default: Config::IMPORT_MERGE)
98
     */
99
    public function import(array $config, int $mode = self::IMPORT_MERGE): void
100
    {
101
        $this->data->import($config, $mode);
1✔
102
        $this->setFromEnv(); // override configuration with environment variables
1✔
103
        $this->validate();   // validate configuration
1✔
104
    }
105

106
    /**
107
     * Get configuration as an array.
108
     */
109
    public function export(): array
110
    {
111
        return $this->data->export();
×
112
    }
113

114
    /**
115
     * Loads and parse a YAML file.
116
     */
117
    public static function loadFile(string $file, bool $ignore = false): array
118
    {
119
        if (!Util\File::getFS()->exists($file)) {
1✔
120
            if ($ignore) {
1✔
121
                return [];
1✔
122
            }
123
            throw new ConfigException(\sprintf('File "%s" does not exist.', $file));
×
124
        }
125
        if (false === $fileContent = Util\File::fileGetContents($file)) {
1✔
126
            throw new ConfigException(\sprintf('Can\'t read file "%s".', $file));
×
127
        }
128
        try {
129
            return Yaml::parse($fileContent, Yaml::PARSE_DATETIME) ?? [];
1✔
130
        } catch (ParseException $e) {
×
131
            throw new ConfigException(\sprintf('"%s" parsing error: %s', $file, $e->getMessage()));
×
132
        }
133
    }
134

135
    /**
136
     * Is configuration's key exists?
137
     *
138
     * @param string $key      Configuration key
139
     * @param string $language Language code (optional)
140
     * @param bool   $fallback Set to false to not return the value in the default language as fallback
141
     */
142
    public function has(string $key, ?string $language = null, bool $fallback = true): bool
143
    {
144
        $default = $this->default->has($key);
1✔
145

146
        if ($language !== null) {
1✔
147
            $langIndex = $this->getLanguageIndex($language);
×
148
            $keyLang = "languages.$langIndex.config.$key";
×
149
            if ($this->data->has($keyLang)) {
×
150
                return true;
×
151
            }
152
            if ($language !== $this->getLanguageDefault() && $fallback === false) {
×
153
                return $default;
×
154
            }
155
        }
156
        if ($this->data->has($key)) {
1✔
157
            return true;
1✔
158
        }
159

160
        return $default;
1✔
161
    }
162

163
    /**
164
     * Get the value of a configuration's key.
165
     *
166
     * @param string $key      Configuration key
167
     * @param string $language Language code (optional)
168
     * @param bool   $fallback Set to false to not return the value in the default language as fallback
169
     *
170
     * @return mixed|null
171
     */
172
    public function get(string $key, ?string $language = null, bool $fallback = true)
173
    {
174
        $default = $this->default->has($key) ? $this->default->get($key) : null;
1✔
175
        if ($language !== null) {
1✔
176
            $langIndex = $this->getLanguageIndex($language);
1✔
177
            $keyLang = "languages.$langIndex.config.$key";
1✔
178
            if ($this->data->has($keyLang)) {
1✔
179
                return $this->data->get($keyLang);
1✔
180
            }
181
            if ($language !== $this->getLanguageDefault() && $fallback === false) {
1✔
182
                return $default;
1✔
183
            }
184
        }
185
        if ($this->data->has($key)) {
1✔
186
            return $this->data->get($key);
1✔
187
        }
188

189
        return $default;
1✔
190
    }
191

192
    /**
193
     * Is an option is enabled?
194
     * Checks if the key is set to `false` or if subkey `enabled` is set to `false`.
195
     */
196
    public function isEnabled(string $key, ?string $language = null, bool $fallback = true): bool
197
    {
198
        if ($this->has($key, $language, $fallback) && $this->get($key, $language, $fallback) === true) {
1✔
199
            return true;
1✔
200
        }
201
        if ($this->has("$key.enabled", $language, $fallback)) {
1✔
202
            if ($this->get("$key.enabled", $language, $fallback) === true) {
1✔
203
                return true;
1✔
204
            }
205
            return false;
×
206
        }
207
        if ($this->has($key, $language, $fallback) && $this->get($key, $language, $fallback) !== false) {
1✔
208
            return true;
1✔
209
        }
210

211
        return false;
1✔
212
    }
213

214
    /**
215
     * Set the source directory.
216
     *
217
     * @throws \InvalidArgumentException
218
     */
219
    public function setSourceDir(string $sourceDir): self
220
    {
221
        if (!is_dir($sourceDir)) {
1✔
222
            throw new \InvalidArgumentException(\sprintf('The directory "%s" is not a valid source.', $sourceDir));
×
223
        }
224
        $this->sourceDir = $sourceDir;
1✔
225

226
        return $this;
1✔
227
    }
228

229
    /**
230
     * Get the source directory.
231
     */
232
    public function getSourceDir(): string
233
    {
234
        if ($this->sourceDir === null) {
1✔
235
            return getcwd();
1✔
236
        }
237

238
        return $this->sourceDir;
1✔
239
    }
240

241
    /**
242
     * Set the destination directory.
243
     *
244
     * @throws \InvalidArgumentException
245
     */
246
    public function setDestinationDir(string $destinationDir): self
247
    {
248
        if (!is_dir($destinationDir)) {
1✔
249
            throw new \InvalidArgumentException(\sprintf('The directory "%s" is not a valid destination.', $destinationDir));
×
250
        }
251
        $this->destinationDir = $destinationDir;
1✔
252

253
        return $this;
1✔
254
    }
255

256
    /**
257
     * Get the destination directory.
258
     */
259
    public function getDestinationDir(): string
260
    {
261
        if ($this->destinationDir === null) {
1✔
262
            return $this->getSourceDir();
×
263
        }
264

265
        return $this->destinationDir;
1✔
266
    }
267

268
    /*
269
     * Path helpers.
270
     */
271

272
    /**
273
     * Returns the path of the pages directory.
274
     */
275
    public function getPagesPath(): string
276
    {
277
        return Util::joinFile($this->getSourceDir(), (string) $this->get('pages.dir'));
1✔
278
    }
279

280
    /**
281
     * Returns the path of the output directory.
282
     */
283
    public function getOutputPath(): string
284
    {
285
        return Util::joinFile($this->getDestinationDir(), (string) $this->get('output.dir'));
1✔
286
    }
287

288
    /**
289
     * Returns the path of the data directory.
290
     */
291
    public function getDataPath(): string
292
    {
293
        return Util::joinFile($this->getSourceDir(), (string) $this->get('data.dir'));
1✔
294
    }
295

296
    /**
297
     * Returns the path of templates directory.
298
     */
299
    public function getLayoutsPath(): string
300
    {
301
        return Util::joinFile($this->getSourceDir(), (string) $this->get('layouts.dir'));
1✔
302
    }
303

304
    /**
305
     * Returns the path of internal templates directory.
306
     */
307
    public function getLayoutsInternalPath(): string
308
    {
309
        return __DIR__ . '/../resources/layouts';
1✔
310
    }
311

312
    /**
313
     * Returns the layout for a section.
314
     */
315
    public function getLayoutSection(?string $section): ?string
316
    {
317
        if ($layout = $this->get('layouts.sections')[$section] ?? null) {
1✔
318
            return $layout;
×
319
        }
320

321
        return $section;
1✔
322
    }
323

324
    /**
325
     * Returns the path of translations directory.
326
     */
327
    public function getTranslationsPath(): string
328
    {
329
        return Util::joinFile($this->getSourceDir(), (string) $this->get('layouts.translations.dir'));
1✔
330
    }
331

332
    /**
333
     * Returns the path of internal translations directory.
334
     */
335
    public function getTranslationsInternalPath(): string
336
    {
337
        return Util\File::getRealPath('../resources/translations');
1✔
338
    }
339

340
    /**
341
     * Returns the path of themes directory.
342
     */
343
    public function getThemesPath(): string
344
    {
345
        return Util::joinFile($this->getSourceDir(), 'themes');
1✔
346
    }
347

348
    /**
349
     * Returns the path of static files directory.
350
     */
351
    public function getStaticPath(): string
352
    {
353
        return Util::joinFile($this->getSourceDir(), (string) $this->get('static.dir'));
1✔
354
    }
355

356
    /**
357
     * Returns the path of assets files directory.
358
     */
359
    public function getAssetsPath(): string
360
    {
361
        return Util::joinFile($this->getSourceDir(), (string) $this->get('assets.dir'));
1✔
362
    }
363

364
    /**
365
     * Returns the path of remote assets files directory (in cache).
366
     *
367
     * @return string
368
     */
369
    public function getAssetsRemotePath(): string
370
    {
371
        return Util::joinFile($this->getCacheAssetsPath(), 'remote');
×
372
    }
373

374
    /**
375
     * Returns cache path.
376
     *
377
     * @throws ConfigException
378
     */
379
    public function getCachePath(): string
380
    {
381
        if (empty((string) $this->get('cache.dir'))) {
1✔
382
            throw new ConfigException(\sprintf('The cache directory (`%s`) is not defined.', 'cache.dir'));
×
383
        }
384
        if ($this->isCacheDirIsAbsolute()) {
1✔
385
            $cacheDir = Util::joinFile((string) $this->get('cache.dir'), 'cecil');
×
386
            Util\File::getFS()->mkdir($cacheDir);
×
387

388
            return $cacheDir;
×
389
        }
390

391
        return Util::joinFile($this->getDestinationDir(), (string) $this->get('cache.dir'));
1✔
392
    }
393

394
    /**
395
     * Returns cache path of templates.
396
     */
397
    public function getCacheTemplatesPath(): string
398
    {
399
        return Util::joinFile($this->getCachePath(), 'templates');
1✔
400
    }
401

402
    /**
403
     * Returns cache path of translations.
404
     */
405
    public function getCacheTranslationsPath(): string
406
    {
407
        return Util::joinFile($this->getCachePath(), 'translations');
1✔
408
    }
409

410
    /**
411
     * Returns cache path of assets.
412
     */
413
    public function getCacheAssetsPath(): string
414
    {
415
        return Util::joinFile($this->getCachePath(), 'assets');
×
416
    }
417

418
    /*
419
     * Output helpers.
420
     */
421

422
    /**
423
     * Returns the property value of an output format.
424
     *
425
     * @throws ConfigException
426
     */
427
    public function getOutputFormatProperty(string $name, string $property): string|array|null
428
    {
429
        $properties = array_column((array) $this->get('output.formats'), $property, 'name');
1✔
430
        if (empty($properties)) {
1✔
431
            throw new ConfigException(\sprintf('Property "%s" is not defined for format "%s".', $property, $name));
×
432
        }
433

434
        return $properties[$name] ?? null;
1✔
435
    }
436

437
    /*
438
     * Assets helpers.
439
     */
440

441
    /**
442
     * Returns asset image widths.
443
     */
444
    public function getAssetsImagesWidths(): array
445
    {
446
        return $this->get('assets.images.responsive.widths');
1✔
447
    }
448

449
    /**
450
     * Returns asset image sizes.
451
     */
452
    public function getAssetsImagesSizes(): array
453
    {
454
        return $this->get('assets.images.responsive.sizes');
1✔
455
    }
456

457
    /*
458
     * Theme helpers.
459
     */
460

461
    /**
462
     * Returns theme(s) as an array.
463
     */
464
    public function getTheme(): ?array
465
    {
466
        if ($themes = $this->get('theme')) {
1✔
467
            if (\is_array($themes)) {
1✔
468
                return $themes;
1✔
469
            }
470

471
            return [$themes];
×
472
        }
473

474
        return null;
×
475
    }
476

477
    /**
478
     * Has a (valid) theme(s)?
479
     *
480
     * @throws ConfigException
481
     */
482
    public function hasTheme(): bool
483
    {
484
        if ($themes = $this->getTheme()) {
1✔
485
            foreach ($themes as $theme) {
1✔
486
                if (!Util\File::getFS()->exists($this->getThemeDirPath($theme, 'layouts')) && !Util\File::getFS()->exists(Util::joinFile($this->getThemesPath(), $theme, 'config.yml'))) {
1✔
487
                    throw new ConfigException(\sprintf('Theme "%s" not found. Did you forgot to install it?', $theme));
×
488
                }
489
            }
490

491
            return true;
1✔
492
        }
493

494
        return false;
×
495
    }
496

497
    /**
498
     * Returns the path of a specific theme's directory.
499
     * ("layouts" by default).
500
     */
501
    public function getThemeDirPath(string $theme, string $dir = 'layouts'): string
502
    {
503
        return Util::joinFile($this->getThemesPath(), $theme, $dir);
1✔
504
    }
505

506
    /*
507
     * Language helpers.
508
     */
509

510
    /**
511
     * Returns an array of available languages.
512
     *
513
     * @throws ConfigException
514
     */
515
    public function getLanguages(): array
516
    {
517
        if ($this->languages !== null) {
1✔
518
            return $this->languages;
1✔
519
        }
520
        $languages = array_filter((array) $this->get('languages'), function ($language) {
1✔
521
            return !(isset($language['enabled']) && $language['enabled'] === false);
1✔
522
        });
1✔
523
        if (!\is_int(array_search($this->getLanguageDefault(), array_column($languages, 'code')))) {
1✔
524
            throw new ConfigException(\sprintf('The default language "%s" is not listed in "languages".', $this->getLanguageDefault()));
×
525
        }
526
        $this->languages = $languages;
1✔
527

528
        return $this->languages;
1✔
529
    }
530

531
    /**
532
     * Returns the default language code (ie: "en", "fr-FR", etc.).
533
     *
534
     * @throws ConfigException
535
     */
536
    public function getLanguageDefault(): string
537
    {
538
        if (!$this->get('language')) {
1✔
539
            throw new ConfigException('There is no default "language" key.');
×
540
        }
541
        if (\is_array($this->get('language'))) {
1✔
542
            if (!$this->get('language.code')) {
×
543
                throw new ConfigException('There is no "language.code" key.');
×
544
            }
545

546
            return $this->get('language.code');
×
547
        }
548

549
        return $this->get('language');
1✔
550
    }
551

552
    /**
553
     * Returns a language code index.
554
     *
555
     * @throws ConfigException
556
     */
557
    public function getLanguageIndex(string $code): int
558
    {
559
        $array = array_column($this->getLanguages(), 'code');
1✔
560
        if (false === $index = array_search($code, $array)) {
1✔
561
            throw new ConfigException(\sprintf('The language code "%s" is not defined.', $code));
×
562
        }
563

564
        return $index;
1✔
565
    }
566

567
    /**
568
     * Returns the property value of a (specified or the default) language.
569
     *
570
     * @throws ConfigException
571
     */
572
    public function getLanguageProperty(string $property, ?string $code = null): string
573
    {
574
        $code = $code ?? $this->getLanguageDefault();
1✔
575
        $properties = array_column($this->getLanguages(), $property, 'code');
1✔
576
        if (empty($properties)) {
1✔
577
            throw new ConfigException(\sprintf('Property "%s" is not defined for language "%s".', $property, $code));
×
578
        }
579

580
        return $properties[$code];
1✔
581
    }
582

583
    /*
584
     * Cache helpers.
585
     */
586

587
    /**
588
     * Is cache dir is absolute to system files
589
     * or relative to project destination?
590
     */
591
    public function isCacheDirIsAbsolute(): bool
592
    {
593
        $path = (string) $this->get('cache.dir');
1✔
594
        if (Util::joinFile($path) == realpath(Util::joinFile($path))) {
1✔
595
            return true;
×
596
        }
597

598
        return false;
1✔
599
    }
600

601
    /*
602
     * Private functions.
603
     */
604

605
    /**
606
     * Set configuration from environment variables starting with "CECIL_".
607
     */
608
    private function setFromEnv(): void
609
    {
610
        foreach (getenv() as $key => $value) {
1✔
611
            if (str_starts_with($key, 'CECIL_')) {
1✔
612
                $this->data->set(str_replace(['cecil_', '_'], ['', '.'], strtolower($key)), $this->castSetValue($value));
1✔
613
            }
614
        }
615
    }
616

617
    /**
618
     * Casts boolean value given to set() as string.
619
     *
620
     * @param mixed $value
621
     *
622
     * @return bool|mixed
623
     */
624
    private function castSetValue($value)
625
    {
626
        $filteredValue = filter_var($value, FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE);
1✔
627

628
        if ($filteredValue !== null) {
1✔
629
            return $filteredValue;
1✔
630
        }
631

632
        return $value;
1✔
633
    }
634

635
    /**
636
     * Validate the configuration.
637
     *
638
     * @throws ConfigException
639
     */
640
    private function validate(): void
641
    {
642
        // default language must be valid
643
        if (!preg_match('/^' . Config::LANG_CODE_PATTERN . '$/', $this->getLanguageDefault())) {
1✔
644
            throw new ConfigException(\sprintf('Default language code "%s" is not valid (e.g.: "language: fr-FR").', $this->getLanguageDefault()));
×
645
        }
646
        // if language is set then the locale is required and must be valid
647
        foreach ((array) $this->get('languages') as $lang) {
1✔
648
            if (!isset($lang['locale'])) {
1✔
649
                throw new ConfigException('A language locale is not defined.');
×
650
            }
651
            if (!preg_match('/^' . Config::LANG_LOCALE_PATTERN . '$/', $lang['locale'])) {
1✔
652
                throw new ConfigException(\sprintf('The language locale "%s" is not valid (e.g.: "locale: fr_FR").', $lang['locale']));
×
653
            }
654
        }
655

656
        // check for deprecated options
657
        $deprecatedConfigFile = Util\File::getRealPath('../config/deprecated.php');
1✔
658
        $deprecatedConfig = require $deprecatedConfigFile;
1✔
659
        array_walk($deprecatedConfig, function ($to, $from) {
1✔
660
            if ($this->has($from)) {
1✔
661
                if (empty($to)) {
×
662
                    throw new ConfigException("Option `$from` is deprecated and must be removed.");
×
663
                }
664
                $path = explode(':', $to);
×
665
                $step = 0;
×
666
                $formatedPath = '';
×
667
                foreach ($path as $fragment) {
×
668
                    $step = $step + 2;
×
669
                    $formatedPath .= "$fragment:\n" . str_pad(' ', $step);
×
670
                }
671
                $formatedPath = trim($formatedPath);
×
672
                throw new ConfigException("Option `$from` must be moved to:\n```\n$formatedPath\n```");
×
673
            }
674
        });
1✔
675
    }
676
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc