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

Cecilapp / Cecil / 14083098325

26 Mar 2025 12:19PM UTC coverage: 82.954%. First build
14083098325

Pull #2144

github

web-flow
Merge 8bef1c5e6 into 6b5943544
Pull Request #2144: refactor: configuration options logic

86 of 99 new or added lines in 20 files covered. (86.87%)

2988 of 3602 relevant lines covered (82.95%)

0.83 hits per line

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

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

3
declare(strict_types=1);
4

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

14
namespace Cecil;
15

16
use Cecil\Exception\ConfigException;
17
use Cecil\Util\Platform;
18
use Dflydev\DotAccessData\Data;
19

20
/**
21
 * Class Config.
22
 */
23
class Config
24
{
25
    /** Configuration is a Data object. */
26
    protected Data $data;
27

28
    /** Default configuration is a Data object. */
29
    protected Data $default;
30

31
    /** Source directory. */
32
    protected string $sourceDir;
33

34
    /** Destination directory. */
35
    protected string $destinationDir;
36

37
    /** Languages list as array. */
38
    protected ?array $languages = null;
39

40
    public const PRESERVE = 0;
41
    public const REPLACE = 1;
42
    public const MERGE = 2;
43
    public const LANG_CODE_PATTERN = '([a-z]{2}(-[A-Z]{2})?)'; // "fr" or "fr-FR"
44
    public const LANG_LOCALE_PATTERN = '[a-z]{2}(_[A-Z]{2})?(_[A-Z]{2})?'; // "fr" or "fr_FR" or "no_NO_NY"
45

46
    /**
47
     * Build the Config object with the default config + the optional given array.
48
     */
49
    public function __construct(?array $config = null)
50
    {
51
        // default configuration
52
        $defaultConfigFile = Util\File::getRealPath('../config/default.php');
1✔
53
        $this->default = new Data(include $defaultConfigFile);
1✔
54

55
        // base configuration
56
        $baseConfigFile = Util\File::getRealPath('../config/base.php');
1✔
57
        $this->data = new Data(include $baseConfigFile);
1✔
58

59
        // import config
60
        $this->import($config ?? []);
1✔
61
    }
62

63
    /**
64
     * Imports (and validate) configuration.
65
     * The mode can be: Config::PRESERVE, Config::REPLACE or Config::MERGE.
66
     */
67
    public function import(array $config, $mode = self::MERGE): void
68
    {
69
        $this->data->import($config, $mode);
1✔
70
        $this->setFromEnv(); // override configuration with environment variables
1✔
71
        $this->validate();   // validate configuration
1✔
72
    }
73

74
    /**
75
     * Get configuration as an array.
76
     */
77
    public function export(): array
78
    {
79
        return $this->data->export();
×
80
    }
81

82
    /**
83
     * Is configuration's key exists?
84
     *
85
     * @param string $key      Configuration key
86
     * @param string $language Language code (optional)
87
     * @param bool   $fallback Set to false to not return the value in the default language as fallback
88
     */
89
    public function has(string $key, ?string $language = null, bool $fallback = true): bool
90
    {
91
        $default = $this->default->has($key);
1✔
92

93
        if ($language !== null) {
1✔
94
            $langIndex = $this->getLanguageIndex($language);
×
95
            $keyLang = "languages.$langIndex.config.$key";
×
96
            if ($this->data->has($keyLang)) {
×
97
                return true;
×
98
            }
99
            if ($language !== $this->getLanguageDefault() && $fallback === false) {
×
100
                return $default;
×
101
            }
102
        }
103
        if ($this->data->has($key)) {
1✔
104
            return true;
1✔
105
        }
106

107
        return $default;
1✔
108
    }
109

110
    /**
111
     * Get the value of a configuration's key.
112
     *
113
     * @param string $key      Configuration key
114
     * @param string $language Language code (optional)
115
     * @param bool   $fallback Set to false to not return the value in the default language as fallback
116
     *
117
     * @return mixed|null
118
     */
119
    public function get(string $key, ?string $language = null, bool $fallback = true)
120
    {
121
        $default = $this->default->has($key) ? $this->default->get($key) : null;
1✔
122

123
        if ($language !== null) {
1✔
124
            $langIndex = $this->getLanguageIndex($language);
1✔
125
            $keyLang = "languages.$langIndex.config.$key";
1✔
126
            if ($this->data->has($keyLang)) {
1✔
127
                return $this->data->get($keyLang);
1✔
128
            }
129
            if ($language !== $this->getLanguageDefault() && $fallback === false) {
1✔
130
                return $default;
1✔
131
            }
132
        }
133
        if ($this->data->has($key)) {
1✔
134
            return $this->data->get($key);
1✔
135
        }
136

137
        return $default;
1✔
138
    }
139

140
    /**
141
     * Is an option is enabled?
142
     * Checks if the key is set to `false` or if subkey `enabled` is set to `false`.
143
     */
144
    public function isEnabled(string $key, ?string $language = null, bool $fallback = true): bool
145
    {
146
        if ($this->has($key, $language, $fallback) && $this->get($key, $language, $fallback) !== false) {
1✔
147
            return true;
1✔
148
        }
149
        if ($this->has("$key.enabled", $language, $fallback) && $this->get("$key.enabled", $language, $fallback) === false) {
1✔
NEW
150
            return false;
×
151
        }
152

153
        return false;
1✔
154
    }
155

156
    /**
157
     * Set the source directory.
158
     *
159
     * @throws \InvalidArgumentException
160
     */
161
    public function setSourceDir(?string $sourceDir = null): self
162
    {
163
        if ($sourceDir === null) {
1✔
164
            $sourceDir = getcwd();
1✔
165
        }
166
        if (!is_dir($sourceDir)) {
1✔
167
            throw new \InvalidArgumentException(\sprintf('The directory "%s" is not a valid source.', $sourceDir));
×
168
        }
169
        $this->sourceDir = $sourceDir;
1✔
170

171
        return $this;
1✔
172
    }
173

174
    /**
175
     * Get the source directory.
176
     */
177
    public function getSourceDir(): string
178
    {
179
        return $this->sourceDir;
1✔
180
    }
181

182
    /**
183
     * Set the destination directory.
184
     *
185
     * @throws \InvalidArgumentException
186
     */
187
    public function setDestinationDir(?string $destinationDir = null): self
188
    {
189
        if ($destinationDir === null) {
1✔
190
            $destinationDir = $this->sourceDir;
1✔
191
        }
192
        if (!is_dir($destinationDir)) {
1✔
193
            throw new \InvalidArgumentException(\sprintf('The directory "%s" is not a valid destination.', $destinationDir));
×
194
        }
195
        $this->destinationDir = $destinationDir;
1✔
196

197
        return $this;
1✔
198
    }
199

200
    /**
201
     * Get the destination directory.
202
     */
203
    public function getDestinationDir(): string
204
    {
205
        return $this->destinationDir;
1✔
206
    }
207

208
    /*
209
     * Path helpers.
210
     */
211

212
    /**
213
     * Returns the path of the pages directory.
214
     */
215
    public function getPagesPath(): string
216
    {
217
        return Util::joinFile($this->getSourceDir(), (string) $this->get('pages.dir'));
1✔
218
    }
219

220
    /**
221
     * Returns the path of the output directory.
222
     */
223
    public function getOutputPath(): string
224
    {
225
        return Util::joinFile($this->getDestinationDir(), (string) $this->get('output.dir'));
1✔
226
    }
227

228
    /**
229
     * Returns the path of the data directory.
230
     */
231
    public function getDataPath(): string
232
    {
233
        return Util::joinFile($this->getSourceDir(), (string) $this->get('data.dir'));
1✔
234
    }
235

236
    /**
237
     * Returns the path of templates directory.
238
     */
239
    public function getLayoutsPath(): string
240
    {
241
        return Util::joinFile($this->getSourceDir(), (string) $this->get('layouts.dir'));
1✔
242
    }
243

244
    /**
245
     * Returns the path of internal templates directory.
246
     */
247
    public function getLayoutsInternalPath(): string
248
    {
249
        return __DIR__ . '/../resources/layouts';
1✔
250
    }
251

252
    /**
253
     * Returns the layout for a section.
254
     */
255
    public function getLayoutSection(?string $section): ?string
256
    {
257
        if ($layout = $this->get('layouts.sections')[$section] ?? null) {
1✔
258
            return $layout;
×
259
        }
260

261
        return $section;
1✔
262
    }
263

264
    /**
265
     * Returns the path of translations directory.
266
     */
267
    public function getTranslationsPath(): string
268
    {
269
        return Util::joinFile($this->getSourceDir(), (string) $this->get('layouts.translations.dir'));
1✔
270
    }
271

272
    /**
273
     * Returns the path of internal translations directory.
274
     */
275
    public function getTranslationsInternalPath(): string
276
    {
277
        return Util\File::getRealPath('../resources/translations');
1✔
278
    }
279

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

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

296
    /**
297
     * Returns the path of static files directory, with a target.
298
     */
299
    public function getStaticTargetPath(): string
300
    {
301
        $path = $this->getStaticPath();
1✔
302

303
        if (!empty($this->get('static.target'))) {
1✔
304
            $path = substr($path, 0, -\strlen((string) $this->get('static.target')));
×
305
        }
306

307
        return $path;
1✔
308
    }
309

310
    /**
311
     * Returns the path of assets files directory.
312
     */
313
    public function getAssetsPath(): string
314
    {
315
        return Util::joinFile($this->getSourceDir(), (string) $this->get('assets.dir'));
1✔
316
    }
317

318
    /**
319
     * Returns the path of remote assets files directory (in cache).
320
     *
321
     * @return string
322
     */
323
    public function getAssetsRemotePath(): string
324
    {
325
        return Util::joinFile($this->getCacheAssetsPath(), 'remote');
1✔
326
    }
327

328
    /**
329
     * Returns cache path.
330
     *
331
     * @throws ConfigException
332
     */
333
    public function getCachePath(): string
334
    {
335
        if (empty((string) $this->get('cache.dir'))) {
1✔
NEW
336
            throw new ConfigException(\sprintf('The cache directory (`%s`) is not defined.', 'cache.dir'));
×
337
        }
338

339
        if ($this->isCacheDirIsAbsolute()) {
1✔
340
            $cacheDir = Util::joinFile((string) $this->get('cache.dir'), 'cecil');
×
341
            Util\File::getFS()->mkdir($cacheDir);
×
342

343
            return $cacheDir;
×
344
        }
345

346
        return Util::joinFile($this->getDestinationDir(), (string) $this->get('cache.dir'));
1✔
347
    }
348

349
    /**
350
     * Returns cache path of templates.
351
     */
352
    public function getCacheTemplatesPath(): string
353
    {
354
        return Util::joinFile($this->getCachePath(), 'templates');
1✔
355
    }
356

357
    /**
358
     * Returns cache path of translations.
359
     */
360
    public function getCacheTranslationsPath(): string
361
    {
362
        return Util::joinFile($this->getCachePath(), 'translations');
1✔
363
    }
364

365
    /**
366
     * Returns cache path of assets.
367
     */
368
    public function getCacheAssetsPath(): string
369
    {
370
        return Util::joinFile($this->getCachePath(), 'assets');
1✔
371
    }
372

373
    /**
374
     * Returns cache path of assets files.
375
     */
376
    public function getCacheAssetsFilesPath(): string
377
    {
378
        return Util::joinFile($this->getCacheAssetsPath(), 'files');
1✔
379
    }
380

381
    /*
382
     * Output helpers.
383
     */
384

385
    /**
386
     * Returns the property value of an output format.
387
     *
388
     * @throws ConfigException
389
     */
390
    public function getOutputFormatProperty(string $name, string $property): string|array|null
391
    {
392
        $properties = array_column((array) $this->get('output.formats'), $property, 'name');
1✔
393

394
        if (empty($properties)) {
1✔
395
            throw new ConfigException(\sprintf('Property "%s" is not defined for format "%s".', $property, $name));
×
396
        }
397

398
        return $properties[$name] ?? null;
1✔
399
    }
400

401
    /*
402
     * Assets helpers.
403
     */
404

405
    /**
406
     * Returns asset image widths.
407
     * Default: [480, 640, 768, 1024, 1366, 1600, 1920].
408
     */
409
    public function getAssetsImagesWidths(): array
410
    {
411
        return $this->get('assets.images.responsive.widths') ?? [480, 640, 768, 1024, 1366, 1600, 1920];
1✔
412
    }
413

414
    /**
415
     * Returns asset image sizes.
416
     * Default: ['default' => '100vw'].
417
     */
418
    public function getAssetsImagesSizes(): array
419
    {
420
        return $this->get('assets.images.responsive.sizes') ?? ['default' => '100vw'];
1✔
421
    }
422

423
    /*
424
     * Theme helpers.
425
     */
426

427
    /**
428
     * Returns theme(s) as an array.
429
     */
430
    public function getTheme(): ?array
431
    {
432
        if ($themes = $this->get('theme')) {
1✔
433
            if (\is_array($themes)) {
1✔
434
                return $themes;
1✔
435
            }
436

437
            return [$themes];
×
438
        }
439

440
        return null;
×
441
    }
442

443
    /**
444
     * Has a (valid) theme(s)?
445
     *
446
     * @throws ConfigException
447
     */
448
    public function hasTheme(): bool
449
    {
450
        if ($themes = $this->getTheme()) {
1✔
451
            foreach ($themes as $theme) {
1✔
452
                if (!Util\File::getFS()->exists($this->getThemeDirPath($theme, 'layouts')) && !Util\File::getFS()->exists(Util::joinFile($this->getThemesPath(), $theme, 'config.yml'))) {
1✔
453
                    throw new ConfigException(\sprintf('Theme "%s" not found. Did you forgot to install it?', $theme));
×
454
                }
455
            }
456

457
            return true;
1✔
458
        }
459

460
        return false;
×
461
    }
462

463
    /**
464
     * Returns the path of a specific theme's directory.
465
     * ("layouts" by default).
466
     */
467
    public function getThemeDirPath(string $theme, string $dir = 'layouts'): string
468
    {
469
        return Util::joinFile($this->getThemesPath(), $theme, $dir);
1✔
470
    }
471

472
    /*
473
     * Language helpers.
474
     */
475

476
    /**
477
     * Returns an array of available languages.
478
     *
479
     * @throws ConfigException
480
     */
481
    public function getLanguages(): array
482
    {
483
        if ($this->languages !== null) {
1✔
484
            return $this->languages;
1✔
485
        }
486

487
        $languages = array_filter((array) $this->get('languages'), function ($language) {
1✔
488
            return !(isset($language['enabled']) && $language['enabled'] === false);
1✔
489
        });
1✔
490

491
        if (!\is_int(array_search($this->getLanguageDefault(), array_column($languages, 'code')))) {
1✔
492
            throw new ConfigException(\sprintf('The default language "%s" is not listed in "languages".', $this->getLanguageDefault()));
×
493
        }
494

495
        $this->languages = $languages;
1✔
496

497
        return $this->languages;
1✔
498
    }
499

500
    /**
501
     * Returns the default language code (ie: "en", "fr-FR", etc.).
502
     *
503
     * @throws ConfigException
504
     */
505
    public function getLanguageDefault(): string
506
    {
507
        if (!$this->get('language')) {
1✔
508
            throw new ConfigException('There is no default "language" key.');
×
509
        }
510
        if (\is_array($this->get('language'))) {
1✔
511
            if (!$this->get('language.code')) {
×
512
                throw new ConfigException('There is no "language.code" key.');
×
513
            }
514

515
            return $this->get('language.code');
×
516
        }
517

518
        return $this->get('language');
1✔
519
    }
520

521
    /**
522
     * Returns a language code index.
523
     *
524
     * @throws ConfigException
525
     */
526
    public function getLanguageIndex(string $code): int
527
    {
528
        $array = array_column($this->getLanguages(), 'code');
1✔
529

530
        if (false === $index = array_search($code, $array)) {
1✔
531
            throw new ConfigException(\sprintf('The language code "%s" is not defined.', $code));
×
532
        }
533

534
        return $index;
1✔
535
    }
536

537
    /**
538
     * Returns the property value of a (specified or the default) language.
539
     *
540
     * @throws ConfigException
541
     */
542
    public function getLanguageProperty(string $property, ?string $code = null): string
543
    {
544
        $code = $code ?? $this->getLanguageDefault();
1✔
545

546
        $properties = array_column($this->getLanguages(), $property, 'code');
1✔
547

548
        if (empty($properties)) {
1✔
549
            throw new ConfigException(\sprintf('Property "%s" is not defined for language "%s".', $property, $code));
×
550
        }
551

552
        return $properties[$code];
1✔
553
    }
554

555
    /*
556
     * Cache helpers.
557
     */
558

559
    /**
560
     * Is cache dir is absolute to system files
561
     * or relative to project destination?
562
     */
563
    public function isCacheDirIsAbsolute(): bool
564
    {
565
        $path = (string) $this->get('cache.dir');
1✔
566
        if (Util::joinFile($path) == realpath(Util::joinFile($path))) {
1✔
567
            return true;
×
568
        }
569

570
        return false;
1✔
571
    }
572

573
    /**
574
     * Set configuration from environment variables starting with "CECIL_".
575
     */
576
    private function setFromEnv(): void
577
    {
578
        foreach (getenv() as $key => $value) {
1✔
579
            if (str_starts_with($key, 'CECIL_')) {
1✔
580
                $this->data->set(str_replace(['cecil_', '_'], ['', '.'], strtolower($key)), $this->castSetValue($value));
1✔
581
            }
582
        }
583
    }
584

585
    /**
586
     * Casts boolean value given to set() as string.
587
     *
588
     * @param mixed $value
589
     *
590
     * @return bool|mixed
591
     */
592
    private function castSetValue($value)
593
    {
594
        $filteredValue = filter_var($value, FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE);
1✔
595

596
        if ($filteredValue !== null) {
1✔
597
            return $filteredValue;
1✔
598
        }
599

600
        return $value;
1✔
601
    }
602

603
    /**
604
     * Validate the configuration.
605
     *
606
     * @throws ConfigException
607
     */
608
    private function validate(): void
609
    {
610
        // default language must be valid
611
        if (!preg_match('/^' . Config::LANG_CODE_PATTERN . '$/', $this->getLanguageDefault())) {
1✔
612
            throw new ConfigException(\sprintf('Default language code "%s" is not valid (e.g.: "language: fr-FR").', $this->getLanguageDefault()));
×
613
        }
614
        // if language is set then the locale is required and must be valid
615
        foreach ((array) $this->get('languages') as $lang) {
1✔
616
            if (!isset($lang['locale'])) {
1✔
617
                throw new ConfigException('A language locale is not defined.');
×
618
            }
619
            if (!preg_match('/^' . Config::LANG_LOCALE_PATTERN . '$/', $lang['locale'])) {
1✔
620
                throw new ConfigException(\sprintf('The language locale "%s" is not valid (e.g.: "locale: fr_FR").', $lang['locale']));
×
621
            }
622
        }
623

624
        // check for deprecated options
625
        $deprecatedConfigFile = Util\File::getRealPath('../config/deprecated.php');
1✔
626
        $deprecatedConfig = require $deprecatedConfigFile;
1✔
627
        array_walk($deprecatedConfig, function ($to, $from) {
1✔
628
            if ($this->has($from)) {
1✔
NEW
629
                if (empty($to)) {
×
NEW
630
                    throw new ConfigException("Option `$from` is deprecated and must be removed.");
×
631
                }
632
                $path = explode(':', $to);
×
633
                $step = 0;
×
634
                $formatedPath = '';
×
635
                foreach ($path as $fragment) {
×
636
                    $step = $step + 2;
×
637
                    $formatedPath .= "$fragment:\n" . str_pad(' ', $step);
×
638
                }
NEW
639
                throw new ConfigException("Option `$from` must be moved to:\n```\n$formatedPath\n```");
×
640
            }
641
        });
1✔
642
    }
643
}
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