Passed
Push — configuration ( 7167e0...d80bcc )
by Arnaud
04:02
created

Config::loadFile()   A

Complexity

Conditions 4
Paths 4

Size

Total Lines 12
Code Lines 8

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 3
CRAP Score 7.9062

Importance

Changes 1
Bugs 0 Features 0
Metric Value
cc 4
eloc 8
c 1
b 0
f 0
nc 4
nop 1
dl 0
loc 12
ccs 3
cts 8
cp 0.375
crap 7.9062
rs 10
1
<?php
2
3
declare(strict_types=1);
4
5
/*
6
 * This file is part of Cecil.
7
 *
8
 * Copyright (c) Arnaud Ligny <[email protected]>
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\Exception\RuntimeException;
18
use Dflydev\DotAccessData\Data;
19
use Symfony\Component\Yaml\Exception\ParseException;
20
use Symfony\Component\Yaml\Yaml;
21
22
/**
23
 * Class Config.
24
 */
25
class Config
26
{
27
    /** Configuration is a Data object. */
28
    protected Data $data;
29
30
    /** Default configuration is a Data object. */
31
    protected Data $default;
32
33
    /** Source directory. */
34
    protected string $sourceDir;
35
36
    /** Destination directory. */
37
    protected string $destinationDir;
38
39
    /** Languages list as array. */
40
    protected ?array $languages = null;
41
42
    public const PRESERVE = 0;
43
    public const REPLACE = 1;
44
    public const MERGE = 2;
45
    public const LANG_CODE_PATTERN = '([a-z]{2}(-[A-Z]{2})?)'; // "fr" or "fr-FR"
46
    public const LANG_LOCALE_PATTERN = '[a-z]{2}(_[A-Z]{2})?(_[A-Z]{2})?'; // "fr" or "fr_FR" or "no_NO_NY"
47
48 1
    /**
49
     * Build the Config object with the default config + the optional given array.
50
     */
51 1
    public function __construct(?array $config = null)
52 1
    {
53
        // default configuration
54 1
        $defaultConfigFile = Util\File::getRealPath('../config/default.php');
55 1
        $this->default = new Data(include $defaultConfigFile);
56
        // base configuration
57 1
        $baseConfigFile = Util\File::getRealPath('../config/base.php');
58 1
        $this->data = new Data(include $baseConfigFile);
59
        // import config array if provided
60
        if ($config !== null) {
61
            $this->import($config);
62
        }
63
    }
64
65
    /**
66 1
     * Imports (and validate) configuration.
67
     * The mode can be: Config::PRESERVE, Config::REPLACE or Config::MERGE.
68 1
     */
69 1
    public function import(array $config, $mode = self::MERGE): void
70 1
    {
71
        $this->data->import($config, $mode);
72
        $this->setFromEnv(); // override configuration with environment variables
73
        $this->validate();   // validate configuration
74
    }
75
76
    /**
77
     * Get configuration as an array.
78
     */
79
    public function export(): array
80
    {
81
        return $this->data->export();
82
    }
83
84
    /**
85
     * Load and parse a YAML file.
86
     */
87
    public function loadFile(string $file): array
88 1
    {
89
        if (!Util\File::getFS()->exists($file)) {
90 1
            throw new ConfigException(\sprintf('File "%s" does not exist.', $file));
91
        }
92 1
        if (false === $fileContent = Util\File::fileGetContents($file)) {
93
            throw new ConfigException(\sprintf('Can\'t read file "%s".', $file));
94
        }
95
        try {
96
            return Yaml::parse($fileContent, Yaml::PARSE_DATETIME);
97
        } catch (ParseException $e) {
98
            throw new ConfigException(\sprintf('"%s" parsing error: %s', $file, $e->getMessage()));
99
        }
100
    }
101
102 1
    /**
103 1
     * Is configuration's key exists?
104
     *
105
     * @param string $key      Configuration key
106 1
     * @param string $language Language code (optional)
107
     * @param bool   $fallback Set to false to not return the value in the default language as fallback
108
     */
109
    public function has(string $key, ?string $language = null, bool $fallback = true): bool
110
    {
111
        $default = $this->default->has($key);
112
113
        if ($language !== null) {
114
            $langIndex = $this->getLanguageIndex($language);
115
            $keyLang = "languages.$langIndex.config.$key";
116
            if ($this->data->has($keyLang)) {
117
                return true;
118 1
            }
119
            if ($language !== $this->getLanguageDefault() && $fallback === false) {
120 1
                return $default;
121 1
            }
122 1
        }
123 1
        if ($this->data->has($key)) {
124 1
            return true;
125 1
        }
126
127 1
        return $default;
128 1
    }
129
130
    /**
131 1
     * Get the value of a configuration's key.
132 1
     *
133
     * @param string $key      Configuration key
134
     * @param string $language Language code (optional)
135 1
     * @param bool   $fallback Set to false to not return the value in the default language as fallback
136
     *
137
     * @return mixed|null
138
     */
139
    public function get(string $key, ?string $language = null, bool $fallback = true)
140
    {
141
        $default = $this->default->has($key) ? $this->default->get($key) : null;
142 1
        if ($language !== null) {
143
            $langIndex = $this->getLanguageIndex($language);
144 1
            $keyLang = "languages.$langIndex.config.$key";
145 1
            if ($this->data->has($keyLang)) {
146
                return $this->data->get($keyLang);
147 1
            }
148
            if ($language !== $this->getLanguageDefault() && $fallback === false) {
149
                return $default;
150
            }
151 1
        }
152
        if ($this->data->has($key)) {
153
            return $this->data->get($key);
154
        }
155
156
        return $default;
157
    }
158
159 1
    /**
160
     * Is an option is enabled?
161 1
     * Checks if the key is set to `false` or if subkey `enabled` is set to `false`.
162
     */
163
    public function isEnabled(string $key, ?string $language = null, bool $fallback = true): bool
164 1
    {
165
        if ($this->has("$key.enabled", $language, $fallback) && $this->get("$key.enabled", $language, $fallback) === true) {
166 1
            return true;
167
        }
168
        if ($this->has($key, $language, $fallback) && $this->get($key, $language, $fallback) !== false) {
169
            return true;
170
        }
171
172 1
        return false;
173
    }
174 1
175
    /**
176
     * Set the source directory.
177
     *
178 1
     * @throws \InvalidArgumentException
179
     */
180
    public function setSourceDir(string $sourceDir): self
181
    {
182
        if (!is_dir($sourceDir)) {
183
            throw new \InvalidArgumentException(\sprintf('The directory "%s" is not a valid source.', $sourceDir));
184
        }
185
        $this->sourceDir = $sourceDir;
186 1
187
        return $this;
188 1
    }
189
190
    /**
191 1
     * Get the source directory.
192
     */
193 1
    public function getSourceDir(): string
194
    {
195
        if ($this->sourceDir === null) {
196
            return getcwd();
197
        }
198
199 1
        return $this->sourceDir;
200
    }
201 1
202
    /**
203
     * Set the destination directory.
204
     *
205 1
     * @throws \InvalidArgumentException
206
     */
207
    public function setDestinationDir(string $destinationDir): self
208
    {
209
        if (!is_dir($destinationDir)) {
210
            throw new \InvalidArgumentException(\sprintf('The directory "%s" is not a valid destination.', $destinationDir));
211
        }
212
        $this->destinationDir = $destinationDir;
213
214
        return $this;
215 1
    }
216
217 1
    /**
218
     * Get the destination directory.
219
     */
220
    public function getDestinationDir(): string
221
    {
222
        if ($this->destinationDir === null) {
223 1
            return $this->getSourceDir();
224
        }
225 1
226
        return $this->destinationDir;
227
    }
228
229
    /*
230
     * Path helpers.
231 1
     */
232
233 1
    /**
234
     * Returns the path of the pages directory.
235
     */
236
    public function getPagesPath(): string
237
    {
238
        return Util::joinFile($this->getSourceDir(), (string) $this->get('pages.dir'));
239 1
    }
240
241 1
    /**
242
     * Returns the path of the output directory.
243
     */
244
    public function getOutputPath(): string
245
    {
246
        return Util::joinFile($this->getDestinationDir(), (string) $this->get('output.dir'));
247 1
    }
248
249 1
    /**
250
     * Returns the path of the data directory.
251
     */
252
    public function getDataPath(): string
253
    {
254
        return Util::joinFile($this->getSourceDir(), (string) $this->get('data.dir'));
255 1
    }
256
257 1
    /**
258
     * Returns the path of templates directory.
259
     */
260
    public function getLayoutsPath(): string
261 1
    {
262
        return Util::joinFile($this->getSourceDir(), (string) $this->get('layouts.dir'));
263
    }
264
265
    /**
266
     * Returns the path of internal templates directory.
267 1
     */
268
    public function getLayoutsInternalPath(): string
269 1
    {
270
        return __DIR__ . '/../resources/layouts';
271
    }
272
273
    /**
274
     * Returns the layout for a section.
275 1
     */
276
    public function getLayoutSection(?string $section): ?string
277 1
    {
278
        if ($layout = $this->get('layouts.sections')[$section] ?? null) {
279
            return $layout;
280
        }
281
282
        return $section;
283 1
    }
284
285 1
    /**
286
     * Returns the path of translations directory.
287
     */
288
    public function getTranslationsPath(): string
289
    {
290
        return Util::joinFile($this->getSourceDir(), (string) $this->get('layouts.translations.dir'));
291 1
    }
292
293 1
    /**
294
     * Returns the path of internal translations directory.
295
     */
296
    public function getTranslationsInternalPath(): string
297
    {
298
        return Util\File::getRealPath('../resources/translations');
299 1
    }
300
301 1
    /**
302 1
     * Returns the path of themes directory.
303
     */
304
    public function getThemesPath(): string
305
    {
306 1
        return Util::joinFile($this->getSourceDir(), 'themes');
307
    }
308
309
    /**
310
     * Returns the path of static files directory.
311
     */
312 1
    public function getStaticPath(): string
313
    {
314 1
        return Util::joinFile($this->getSourceDir(), (string) $this->get('static.dir'));
315
    }
316
317
    /**
318
     * Returns the path of static files directory, with a target.
319
     */
320
    public function getStaticTargetPath(): string
321
    {
322 1
        $path = $this->getStaticPath();
323
        if (!empty($this->get('static.target'))) {
324 1
            $path = substr($path, 0, -\strlen((string) $this->get('static.target')));
325
        }
326
327
        return $path;
328
    }
329
330
    /**
331
     * Returns the path of assets files directory.
332 1
     */
333
    public function getAssetsPath(): string
334 1
    {
335
        return Util::joinFile($this->getSourceDir(), (string) $this->get('assets.dir'));
336
    }
337 1
338
    /**
339
     * Returns the path of remote assets files directory (in cache).
340
     *
341
     * @return string
342
     */
343
    public function getAssetsRemotePath(): string
344 1
    {
345
        return Util::joinFile($this->getCacheAssetsPath(), 'remote');
346
    }
347
348
    /**
349
     * Returns cache path.
350 1
     *
351
     * @throws ConfigException
352 1
     */
353
    public function getCachePath(): string
354
    {
355
        if (empty((string) $this->get('cache.dir'))) {
356
            throw new ConfigException(\sprintf('The cache directory (`%s`) is not defined.', 'cache.dir'));
357
        }
358 1
        if ($this->isCacheDirIsAbsolute()) {
359
            $cacheDir = Util::joinFile((string) $this->get('cache.dir'), 'cecil');
360 1
            Util\File::getFS()->mkdir($cacheDir);
361
362
            return $cacheDir;
363
        }
364
365
        return Util::joinFile($this->getDestinationDir(), (string) $this->get('cache.dir'));
366 1
    }
367
368 1
    /**
369
     * Returns cache path of templates.
370
     */
371
    public function getCacheTemplatesPath(): string
372
    {
373
        return Util::joinFile($this->getCachePath(), 'templates');
374 1
    }
375
376 1
    /**
377
     * Returns cache path of translations.
378
     */
379
    public function getCacheTranslationsPath(): string
380
    {
381
        return Util::joinFile($this->getCachePath(), 'translations');
382
    }
383
384
    /**
385
     * Returns cache path of assets.
386
     */
387
    public function getCacheAssetsPath(): string
388 1
    {
389
        return Util::joinFile($this->getCachePath(), 'assets');
390 1
    }
391 1
392
    /**
393
     * Returns cache path of assets files.
394
     */
395 1
    public function getCacheAssetsFilesPath(): string
396
    {
397
        return Util::joinFile($this->getCacheAssetsPath(), 'files');
398
    }
399
400
    /*
401
     * Output helpers.
402
     */
403
404
    /**
405
     * Returns the property value of an output format.
406 1
     *
407
     * @throws ConfigException
408 1
     */
409
    public function getOutputFormatProperty(string $name, string $property): string|array|null
410
    {
411
        $properties = array_column((array) $this->get('output.formats'), $property, 'name');
412
        if (empty($properties)) {
413
            throw new ConfigException(\sprintf('Property "%s" is not defined for format "%s".', $property, $name));
414
        }
415 1
416
        return $properties[$name] ?? null;
417 1
    }
418
419
    /*
420
     * Assets helpers.
421
     */
422
423
    /**
424
     * Returns asset image widths.
425
     * Default: [480, 640, 768, 1024, 1366, 1600, 1920].
426
     */
427 1
    public function getAssetsImagesWidths(): array
428
    {
429 1
        return $this->get('assets.images.responsive.widths') ?? [480, 640, 768, 1024, 1366, 1600, 1920];
430 1
    }
431 1
432
    /**
433
     * Returns asset image sizes.
434
     * Default: ['default' => '100vw'].
435
     */
436
    public function getAssetsImagesSizes(): array
437
    {
438
        return $this->get('assets.images.responsive.sizes') ?? ['default' => '100vw'];
439
    }
440
441
    /*
442
     * Theme helpers.
443
     */
444
445 1
    /**
446
     * Returns theme(s) as an array.
447 1
     */
448 1
    public function getTheme(): ?array
449 1
    {
450
        if ($themes = $this->get('theme')) {
451
            if (\is_array($themes)) {
452
                return $themes;
453
            }
454 1
455
            return [$themes];
456
        }
457
458
        return null;
459
    }
460
461
    /**
462
     * Has a (valid) theme(s)?
463
     *
464 1
     * @throws ConfigException
465
     */
466 1
    public function hasTheme(): bool
467
    {
468
        if ($themes = $this->getTheme()) {
469
            foreach ($themes as $theme) {
470
                if (!Util\File::getFS()->exists($this->getThemeDirPath($theme, 'layouts')) && !Util\File::getFS()->exists(Util::joinFile($this->getThemesPath(), $theme, 'config.yml'))) {
471
                    throw new ConfigException(\sprintf('Theme "%s" not found. Did you forgot to install it?', $theme));
472
                }
473
            }
474
475
            return true;
476
        }
477
478 1
        return false;
479
    }
480 1
481 1
    /**
482
     * Returns the path of a specific theme's directory.
483 1
     * ("layouts" by default).
484 1
     */
485 1
    public function getThemeDirPath(string $theme, string $dir = 'layouts'): string
486 1
    {
487
        return Util::joinFile($this->getThemesPath(), $theme, $dir);
488
    }
489 1
490
    /*
491 1
     * Language helpers.
492
     */
493
494
    /**
495
     * Returns an array of available languages.
496
     *
497
     * @throws ConfigException
498
     */
499 1
    public function getLanguages(): array
500
    {
501 1
        if ($this->languages !== null) {
502
            return $this->languages;
503
        }
504 1
        $languages = array_filter((array) $this->get('languages'), function ($language) {
505
            return !(isset($language['enabled']) && $language['enabled'] === false);
506
        });
507
        if (!\is_int(array_search($this->getLanguageDefault(), array_column($languages, 'code')))) {
508
            throw new ConfigException(\sprintf('The default language "%s" is not listed in "languages".', $this->getLanguageDefault()));
509
        }
510
        $this->languages = $languages;
511
512 1
        return $this->languages;
513
    }
514
515
    /**
516
     * Returns the default language code (ie: "en", "fr-FR", etc.).
517
     *
518
     * @throws ConfigException
519
     */
520 1
    public function getLanguageDefault(): string
521
    {
522 1
        if (!$this->get('language')) {
523 1
            throw new ConfigException('There is no default "language" key.');
524
        }
525
        if (\is_array($this->get('language'))) {
526
            if (!$this->get('language.code')) {
527 1
                throw new ConfigException('There is no "language.code" key.');
528
            }
529
530
            return $this->get('language.code');
531
        }
532
533
        return $this->get('language');
534
    }
535 1
536
    /**
537 1
     * Returns a language code index.
538 1
     *
539 1
     * @throws ConfigException
540
     */
541
    public function getLanguageIndex(string $code): int
542
    {
543 1
        $array = array_column($this->getLanguages(), 'code');
544
        if (false === $index = array_search($code, $array)) {
545
            throw new ConfigException(\sprintf('The language code "%s" is not defined.', $code));
546
        }
547
548
        return $index;
549
    }
550
551
    /**
552
     * Returns the property value of a (specified or the default) language.
553
     *
554 1
     * @throws ConfigException
555
     */
556 1
    public function getLanguageProperty(string $property, ?string $code = null): string
557 1
    {
558
        $code = $code ?? $this->getLanguageDefault();
559
        $properties = array_column($this->getLanguages(), $property, 'code');
560
        if (empty($properties)) {
561 1
            throw new ConfigException(\sprintf('Property "%s" is not defined for language "%s".', $property, $code));
562
        }
563
564
        return $properties[$code];
565
    }
566
567
    /*
568
     * Cache helpers.
569
     */
570
571 1
    /**
572
     * Is cache dir is absolute to system files
573 1
     * or relative to project destination?
574 1
     */
575 1
    public function isCacheDirIsAbsolute(): bool
576
    {
577
        $path = (string) $this->get('cache.dir');
578
        if (Util::joinFile($path) == realpath(Util::joinFile($path))) {
579
            return true;
580
        }
581
582
        return false;
583
    }
584
585
    /*
586
     * Private functions.
587 1
     */
588
589 1
    /**
590
     * Set configuration from environment variables starting with "CECIL_".
591 1
     */
592 1
    private function setFromEnv(): void
593
    {
594
        foreach (getenv() as $key => $value) {
595 1
            if (str_starts_with($key, 'CECIL_')) {
596
                $this->data->set(str_replace(['cecil_', '_'], ['', '.'], strtolower($key)), $this->castSetValue($value));
597
            }
598
        }
599
    }
600
601
    /**
602
     * Casts boolean value given to set() as string.
603 1
     *
604
     * @param mixed $value
605
     *
606 1
     * @return bool|mixed
607
     */
608
    private function castSetValue($value)
609
    {
610 1
        $filteredValue = filter_var($value, FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE);
611 1
612
        if ($filteredValue !== null) {
613
            return $filteredValue;
614 1
        }
615
616
        return $value;
617
    }
618
619
    /**
620 1
     * Validate the configuration.
621 1
     *
622 1
     * @throws ConfigException
623 1
     */
624
    private function validate(): void
625
    {
626
        // default language must be valid
627
        if (!preg_match('/^' . Config::LANG_CODE_PATTERN . '$/', $this->getLanguageDefault())) {
628
            throw new ConfigException(\sprintf('Default language code "%s" is not valid (e.g.: "language: fr-FR").', $this->getLanguageDefault()));
629
        }
630
        // if language is set then the locale is required and must be valid
631
        foreach ((array) $this->get('languages') as $lang) {
632
            if (!isset($lang['locale'])) {
633
                throw new ConfigException('A language locale is not defined.');
634
            }
635
            if (!preg_match('/^' . Config::LANG_LOCALE_PATTERN . '$/', $lang['locale'])) {
636 1
                throw new ConfigException(\sprintf('The language locale "%s" is not valid (e.g.: "locale: fr_FR").', $lang['locale']));
637
            }
638
        }
639
640
        // check for deprecated options
641
        $deprecatedConfigFile = Util\File::getRealPath('../config/deprecated.php');
642
        $deprecatedConfig = require $deprecatedConfigFile;
643
        array_walk($deprecatedConfig, function ($to, $from) {
644
            if ($this->has($from)) {
645
                if (empty($to)) {
646
                    throw new ConfigException("Option `$from` is deprecated and must be removed.");
647
                }
648
                $path = explode(':', $to);
649
                $step = 0;
650
                $formatedPath = '';
651
                foreach ($path as $fragment) {
652
                    $step = $step + 2;
653
                    $formatedPath .= "$fragment:\n" . str_pad(' ', $step);
654
                }
655
                throw new ConfigException("Option `$from` must be moved to:\n```\n$formatedPath\n```");
656
            }
657
        });
658
    }
659
}
660