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

Cecilapp / Cecil / 14583247094

21 Apr 2025 11:23PM UTC coverage: 83.669%. First build
14583247094

Pull #2148

github

web-flow
Merge 4875a1658 into bc7c717e4
Pull Request #2148: refactor: configuration and cache

342 of 405 new or added lines in 26 files covered. (84.44%)

3033 of 3625 relevant lines covered (83.67%)

0.84 hits per line

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

71.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
use Symfony\Component\Yaml\Exception\ParseException;
19
use Symfony\Component\Yaml\Yaml;
20

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

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

32
    /** Source directory. */
33
    protected ?string $sourceDir = null;
34

35
    /** Destination directory. */
36
    protected ?string $destinationDir = null;
37

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

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

47
    /**
48
     * Build the Config object with the default config + the optional given array.
49
     */
50
    public function __construct(?array $config = null)
51
    {
52
        // default configuration
53
        $defaultConfigFile = Util\File::getRealPath('../config/default.php');
1✔
54
        $this->default = new Data(include $defaultConfigFile);
1✔
55
        // base configuration
56
        $baseConfigFile = Util\File::getRealPath('../config/base.php');
1✔
57
        $this->data = new Data(include $baseConfigFile);
1✔
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
     * Loads and parse a YAML file.
85
     */
86
    public static function loadFile(string $file, bool $ignore = false): array
87
    {
88
        if (!Util\File::getFS()->exists($file)) {
1✔
89
            if ($ignore) {
1✔
90
                return [];
1✔
91
            }
NEW
92
            throw new ConfigException(\sprintf('File "%s" does not exist.', $file));
×
93
        }
94
        if (false === $fileContent = Util\File::fileGetContents($file)) {
1✔
NEW
95
            throw new ConfigException(\sprintf('Can\'t read file "%s".', $file));
×
96
        }
97
        try {
98
            return Yaml::parse($fileContent, Yaml::PARSE_DATETIME);
1✔
NEW
99
        } catch (ParseException $e) {
×
NEW
100
            throw new ConfigException(\sprintf('"%s" parsing error: %s', $file, $e->getMessage()));
×
101
        }
102
    }
103

104
    /**
105
     * Is configuration's key exists?
106
     *
107
     * @param string $key      Configuration key
108
     * @param string $language Language code (optional)
109
     * @param bool   $fallback Set to false to not return the value in the default language as fallback
110
     */
111
    public function has(string $key, ?string $language = null, bool $fallback = true): bool
112
    {
113
        $default = $this->default->has($key);
1✔
114

115
        if ($language !== null) {
1✔
116
            $langIndex = $this->getLanguageIndex($language);
×
117
            $keyLang = "languages.$langIndex.config.$key";
×
118
            if ($this->data->has($keyLang)) {
×
119
                return true;
×
120
            }
121
            if ($language !== $this->getLanguageDefault() && $fallback === false) {
×
122
                return $default;
×
123
            }
124
        }
125
        if ($this->data->has($key)) {
1✔
126
            return true;
1✔
127
        }
128

129
        return $default;
1✔
130
    }
131

132
    /**
133
     * Get the value of a configuration's key.
134
     *
135
     * @param string $key      Configuration key
136
     * @param string $language Language code (optional)
137
     * @param bool   $fallback Set to false to not return the value in the default language as fallback
138
     *
139
     * @return mixed|null
140
     */
141
    public function get(string $key, ?string $language = null, bool $fallback = true)
142
    {
143
        $default = $this->default->has($key) ? $this->default->get($key) : null;
1✔
144
        if ($language !== null) {
1✔
145
            $langIndex = $this->getLanguageIndex($language);
1✔
146
            $keyLang = "languages.$langIndex.config.$key";
1✔
147
            if ($this->data->has($keyLang)) {
1✔
148
                return $this->data->get($keyLang);
1✔
149
            }
150
            if ($language !== $this->getLanguageDefault() && $fallback === false) {
1✔
151
                return $default;
1✔
152
            }
153
        }
154
        if ($this->data->has($key)) {
1✔
155
            return $this->data->get($key);
1✔
156
        }
157

158
        return $default;
1✔
159
    }
160

161
    /**
162
     * Is an option is enabled?
163
     * Checks if the key is set to `false` or if subkey `enabled` is set to `false`.
164
     */
165
    public function isEnabled(string $key, ?string $language = null, bool $fallback = true): bool
166
    {
167
        if ($this->has($key, $language, $fallback) && $this->get($key, $language, $fallback) === true) {
1✔
168
            return true;
1✔
169
        }
170
        if ($this->has("$key.enabled", $language, $fallback)) {
1✔
171
            if ($this->get("$key.enabled", $language, $fallback) === true) {
1✔
172
                return true;
1✔
173
            }
NEW
174
            return false;
×
175
        }
176
        if ($this->has($key, $language, $fallback) && $this->get($key, $language, $fallback) !== false) {
1✔
177
            return true;
1✔
178
        }
179

180
        return false;
1✔
181
    }
182

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

195
        return $this;
1✔
196
    }
197

198
    /**
199
     * Get the source directory.
200
     */
201
    public function getSourceDir(): string
202
    {
203
        if ($this->sourceDir === null) {
1✔
204
            return getcwd();
1✔
205
        }
206

207
        return $this->sourceDir;
1✔
208
    }
209

210
    /**
211
     * Set the destination directory.
212
     *
213
     * @throws \InvalidArgumentException
214
     */
215
    public function setDestinationDir(string $destinationDir): self
216
    {
217
        if (!is_dir($destinationDir)) {
1✔
218
            throw new \InvalidArgumentException(\sprintf('The directory "%s" is not a valid destination.', $destinationDir));
×
219
        }
220
        $this->destinationDir = $destinationDir;
1✔
221

222
        return $this;
1✔
223
    }
224

225
    /**
226
     * Get the destination directory.
227
     */
228
    public function getDestinationDir(): string
229
    {
230
        if ($this->destinationDir === null) {
1✔
NEW
231
            return $this->getSourceDir();
×
232
        }
233

234
        return $this->destinationDir;
1✔
235
    }
236

237
    /*
238
     * Path helpers.
239
     */
240

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

249
    /**
250
     * Returns the path of the output directory.
251
     */
252
    public function getOutputPath(): string
253
    {
254
        return Util::joinFile($this->getDestinationDir(), (string) $this->get('output.dir'));
1✔
255
    }
256

257
    /**
258
     * Returns the path of the data directory.
259
     */
260
    public function getDataPath(): string
261
    {
262
        return Util::joinFile($this->getSourceDir(), (string) $this->get('data.dir'));
1✔
263
    }
264

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

273
    /**
274
     * Returns the path of internal templates directory.
275
     */
276
    public function getLayoutsInternalPath(): string
277
    {
278
        return __DIR__ . '/../resources/layouts';
1✔
279
    }
280

281
    /**
282
     * Returns the layout for a section.
283
     */
284
    public function getLayoutSection(?string $section): ?string
285
    {
286
        if ($layout = $this->get('layouts.sections')[$section] ?? null) {
1✔
287
            return $layout;
×
288
        }
289

290
        return $section;
1✔
291
    }
292

293
    /**
294
     * Returns the path of translations directory.
295
     */
296
    public function getTranslationsPath(): string
297
    {
298
        return Util::joinFile($this->getSourceDir(), (string) $this->get('layouts.translations.dir'));
1✔
299
    }
300

301
    /**
302
     * Returns the path of internal translations directory.
303
     */
304
    public function getTranslationsInternalPath(): string
305
    {
306
        return Util\File::getRealPath('../resources/translations');
1✔
307
    }
308

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

317
    /**
318
     * Returns the path of static files directory.
319
     */
320
    public function getStaticPath(): string
321
    {
322
        return Util::joinFile($this->getSourceDir(), (string) $this->get('static.dir'));
1✔
323
    }
324

325
    /**
326
     * Returns the path of static files directory, with a target.
327
     */
328
    public function getStaticTargetPath(): string
329
    {
330
        $path = $this->getStaticPath();
1✔
331
        if (!empty($this->get('static.target'))) {
1✔
332
            $path = substr($path, 0, -\strlen((string) $this->get('static.target')));
×
333
        }
334

335
        return $path;
1✔
336
    }
337

338
    /**
339
     * Returns the path of assets files directory.
340
     */
341
    public function getAssetsPath(): string
342
    {
343
        return Util::joinFile($this->getSourceDir(), (string) $this->get('assets.dir'));
1✔
344
    }
345

346
    /**
347
     * Returns the path of remote assets files directory (in cache).
348
     *
349
     * @return string
350
     */
351
    public function getAssetsRemotePath(): string
352
    {
NEW
353
        return Util::joinFile($this->getCacheAssetsPath(), 'remote');
×
354
    }
355

356
    /**
357
     * Returns cache path.
358
     *
359
     * @throws ConfigException
360
     */
361
    public function getCachePath(): string
362
    {
363
        if (empty((string) $this->get('cache.dir'))) {
1✔
NEW
364
            throw new ConfigException(\sprintf('The cache directory (`%s`) is not defined.', 'cache.dir'));
×
365
        }
366
        if ($this->isCacheDirIsAbsolute()) {
1✔
367
            $cacheDir = Util::joinFile((string) $this->get('cache.dir'), 'cecil');
×
368
            Util\File::getFS()->mkdir($cacheDir);
×
369

370
            return $cacheDir;
×
371
        }
372

373
        return Util::joinFile($this->getDestinationDir(), (string) $this->get('cache.dir'));
1✔
374
    }
375

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

384
    /**
385
     * Returns cache path of translations.
386
     */
387
    public function getCacheTranslationsPath(): string
388
    {
389
        return Util::joinFile($this->getCachePath(), 'translations');
1✔
390
    }
391

392
    /**
393
     * Returns cache path of assets.
394
     */
395
    public function getCacheAssetsPath(): string
396
    {
NEW
397
        return Util::joinFile($this->getCachePath(), 'assets');
×
398
    }
399

400
    /*
401
     * Output helpers.
402
     */
403

404
    /**
405
     * Returns the property value of an output format.
406
     *
407
     * @throws ConfigException
408
     */
409
    public function getOutputFormatProperty(string $name, string $property): string|array|null
410
    {
411
        $properties = array_column((array) $this->get('output.formats'), $property, 'name');
1✔
412
        if (empty($properties)) {
1✔
413
            throw new ConfigException(\sprintf('Property "%s" is not defined for format "%s".', $property, $name));
×
414
        }
415

416
        return $properties[$name] ?? null;
1✔
417
    }
418

419
    /*
420
     * Assets helpers.
421
     */
422

423
    /**
424
     * Returns asset image widths.
425
     */
426
    public function getAssetsImagesWidths(): array
427
    {
428
        return $this->get('assets.images.responsive.widths');
1✔
429
    }
430

431
    /**
432
     * Returns asset image sizes.
433
     */
434
    public function getAssetsImagesSizes(): array
435
    {
436
        return $this->get('assets.images.responsive.sizes');
1✔
437
    }
438

439
    /*
440
     * Theme helpers.
441
     */
442

443
    /**
444
     * Returns theme(s) as an array.
445
     */
446
    public function getTheme(): ?array
447
    {
448
        if ($themes = $this->get('theme')) {
1✔
449
            if (\is_array($themes)) {
1✔
450
                return $themes;
1✔
451
            }
452

453
            return [$themes];
×
454
        }
455

456
        return null;
×
457
    }
458

459
    /**
460
     * Has a (valid) theme(s)?
461
     *
462
     * @throws ConfigException
463
     */
464
    public function hasTheme(): bool
465
    {
466
        if ($themes = $this->getTheme()) {
1✔
467
            foreach ($themes as $theme) {
1✔
468
                if (!Util\File::getFS()->exists($this->getThemeDirPath($theme, 'layouts')) && !Util\File::getFS()->exists(Util::joinFile($this->getThemesPath(), $theme, 'config.yml'))) {
1✔
469
                    throw new ConfigException(\sprintf('Theme "%s" not found. Did you forgot to install it?', $theme));
×
470
                }
471
            }
472

473
            return true;
1✔
474
        }
475

476
        return false;
×
477
    }
478

479
    /**
480
     * Returns the path of a specific theme's directory.
481
     * ("layouts" by default).
482
     */
483
    public function getThemeDirPath(string $theme, string $dir = 'layouts'): string
484
    {
485
        return Util::joinFile($this->getThemesPath(), $theme, $dir);
1✔
486
    }
487

488
    /*
489
     * Language helpers.
490
     */
491

492
    /**
493
     * Returns an array of available languages.
494
     *
495
     * @throws ConfigException
496
     */
497
    public function getLanguages(): array
498
    {
499
        if ($this->languages !== null) {
1✔
500
            return $this->languages;
1✔
501
        }
502
        $languages = array_filter((array) $this->get('languages'), function ($language) {
1✔
503
            return !(isset($language['enabled']) && $language['enabled'] === false);
1✔
504
        });
1✔
505
        if (!\is_int(array_search($this->getLanguageDefault(), array_column($languages, 'code')))) {
1✔
506
            throw new ConfigException(\sprintf('The default language "%s" is not listed in "languages".', $this->getLanguageDefault()));
×
507
        }
508
        $this->languages = $languages;
1✔
509

510
        return $this->languages;
1✔
511
    }
512

513
    /**
514
     * Returns the default language code (ie: "en", "fr-FR", etc.).
515
     *
516
     * @throws ConfigException
517
     */
518
    public function getLanguageDefault(): string
519
    {
520
        if (!$this->get('language')) {
1✔
521
            throw new ConfigException('There is no default "language" key.');
×
522
        }
523
        if (\is_array($this->get('language'))) {
1✔
524
            if (!$this->get('language.code')) {
×
525
                throw new ConfigException('There is no "language.code" key.');
×
526
            }
527

528
            return $this->get('language.code');
×
529
        }
530

531
        return $this->get('language');
1✔
532
    }
533

534
    /**
535
     * Returns a language code index.
536
     *
537
     * @throws ConfigException
538
     */
539
    public function getLanguageIndex(string $code): int
540
    {
541
        $array = array_column($this->getLanguages(), 'code');
1✔
542
        if (false === $index = array_search($code, $array)) {
1✔
543
            throw new ConfigException(\sprintf('The language code "%s" is not defined.', $code));
×
544
        }
545

546
        return $index;
1✔
547
    }
548

549
    /**
550
     * Returns the property value of a (specified or the default) language.
551
     *
552
     * @throws ConfigException
553
     */
554
    public function getLanguageProperty(string $property, ?string $code = null): string
555
    {
556
        $code = $code ?? $this->getLanguageDefault();
1✔
557
        $properties = array_column($this->getLanguages(), $property, 'code');
1✔
558
        if (empty($properties)) {
1✔
559
            throw new ConfigException(\sprintf('Property "%s" is not defined for language "%s".', $property, $code));
×
560
        }
561

562
        return $properties[$code];
1✔
563
    }
564

565
    /*
566
     * Cache helpers.
567
     */
568

569
    /**
570
     * Is cache dir is absolute to system files
571
     * or relative to project destination?
572
     */
573
    public function isCacheDirIsAbsolute(): bool
574
    {
575
        $path = (string) $this->get('cache.dir');
1✔
576
        if (Util::joinFile($path) == realpath(Util::joinFile($path))) {
1✔
577
            return true;
×
578
        }
579

580
        return false;
1✔
581
    }
582

583
    /*
584
     * Private functions.
585
     */
586

587
    /**
588
     * Set configuration from environment variables starting with "CECIL_".
589
     */
590
    private function setFromEnv(): void
591
    {
592
        foreach (getenv() as $key => $value) {
1✔
593
            if (str_starts_with($key, 'CECIL_')) {
1✔
594
                $this->data->set(str_replace(['cecil_', '_'], ['', '.'], strtolower($key)), $this->castSetValue($value));
1✔
595
            }
596
        }
597
    }
598

599
    /**
600
     * Casts boolean value given to set() as string.
601
     *
602
     * @param mixed $value
603
     *
604
     * @return bool|mixed
605
     */
606
    private function castSetValue($value)
607
    {
608
        $filteredValue = filter_var($value, FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE);
1✔
609

610
        if ($filteredValue !== null) {
1✔
611
            return $filteredValue;
1✔
612
        }
613

614
        return $value;
1✔
615
    }
616

617
    /**
618
     * Validate the configuration.
619
     *
620
     * @throws ConfigException
621
     */
622
    private function validate(): void
623
    {
624
        // default language must be valid
625
        if (!preg_match('/^' . Config::LANG_CODE_PATTERN . '$/', $this->getLanguageDefault())) {
1✔
626
            throw new ConfigException(\sprintf('Default language code "%s" is not valid (e.g.: "language: fr-FR").', $this->getLanguageDefault()));
×
627
        }
628
        // if language is set then the locale is required and must be valid
629
        foreach ((array) $this->get('languages') as $lang) {
1✔
630
            if (!isset($lang['locale'])) {
1✔
631
                throw new ConfigException('A language locale is not defined.');
×
632
            }
633
            if (!preg_match('/^' . Config::LANG_LOCALE_PATTERN . '$/', $lang['locale'])) {
1✔
634
                throw new ConfigException(\sprintf('The language locale "%s" is not valid (e.g.: "locale: fr_FR").', $lang['locale']));
×
635
            }
636
        }
637

638
        // check for deprecated options
639
        $deprecatedConfigFile = Util\File::getRealPath('../config/deprecated.php');
1✔
640
        $deprecatedConfig = require $deprecatedConfigFile;
1✔
641
        array_walk($deprecatedConfig, function ($to, $from) {
1✔
642
            if ($this->has($from)) {
1✔
NEW
643
                if (empty($to)) {
×
NEW
644
                    throw new ConfigException("Option `$from` is deprecated and must be removed.");
×
645
                }
646
                $path = explode(':', $to);
×
647
                $step = 0;
×
648
                $formatedPath = '';
×
649
                foreach ($path as $fragment) {
×
650
                    $step = $step + 2;
×
651
                    $formatedPath .= "$fragment:\n" . str_pad(' ', $step);
×
652
                }
NEW
653
                throw new ConfigException("Option `$from` must be moved to:\n```\n$formatedPath\n```");
×
654
            }
655
        });
1✔
656
    }
657
}
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

© 2025 Coveralls, Inc