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

Cecilapp / Cecil / 14087181724

26 Mar 2025 03:27PM UTC coverage: 82.954%. First build
14087181724

Pull #2144

github

web-flow
Merge 2a434abbe into eed522174
Pull Request #2144: refactor: configuration options logic

97 of 111 new or added lines in 20 files covered. (87.39%)

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

72.26
/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 Dflydev\DotAccessData\Data;
18

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

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

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

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

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

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

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

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

58
        // import config array if provided
59
        if ($config !== null) {
1✔
60
            $this->import($config);
1✔
61
        }
62
    }
63

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

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

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

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

108
        return $default;
1✔
109
    }
110

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

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

138
        return $default;
1✔
139
    }
140

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

154
        return false;
1✔
155
    }
156

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

169
        return $this;
1✔
170
    }
171

172
    /**
173
     * Get the source directory.
174
     */
175
    public function getSourceDir(): string
176
    {
177
        if ($this->sourceDir === null) {
1✔
NEW
178
            return getcwd();
×
179
        }
180

181
        return $this->sourceDir;
1✔
182
    }
183

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

196
        return $this;
1✔
197
    }
198

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

208
        return $this->destinationDir;
1✔
209
    }
210

211
    /*
212
     * Path helpers.
213
     */
214

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

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

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

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

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

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

264
        return $section;
1✔
265
    }
266

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

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

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

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

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

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

310
        return $path;
1✔
311
    }
312

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

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

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

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

346
            return $cacheDir;
×
347
        }
348

349
        return Util::joinFile($this->getDestinationDir(), (string) $this->get('cache.dir'));
1✔
350
    }
351

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

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

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

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

384
    /*
385
     * Output helpers.
386
     */
387

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

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

401
        return $properties[$name] ?? null;
1✔
402
    }
403

404
    /*
405
     * Assets helpers.
406
     */
407

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

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

426
    /*
427
     * Theme helpers.
428
     */
429

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

440
            return [$themes];
×
441
        }
442

443
        return null;
×
444
    }
445

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

460
            return true;
1✔
461
        }
462

463
        return false;
×
464
    }
465

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

475
    /*
476
     * Language helpers.
477
     */
478

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

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

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

498
        $this->languages = $languages;
1✔
499

500
        return $this->languages;
1✔
501
    }
502

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

518
            return $this->get('language.code');
×
519
        }
520

521
        return $this->get('language');
1✔
522
    }
523

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

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

537
        return $index;
1✔
538
    }
539

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

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

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

555
        return $properties[$code];
1✔
556
    }
557

558
    /*
559
     * Cache helpers.
560
     */
561

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

573
        return false;
1✔
574
    }
575

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

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

599
        if ($filteredValue !== null) {
1✔
600
            return $filteredValue;
1✔
601
        }
602

603
        return $value;
1✔
604
    }
605

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

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