Passed
Pull Request — master (#2148)
by Arnaud
11:21 queued 04:41
created

Config::getAssetsRemotePath()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 3
Code Lines 1

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 2
CRAP Score 1

Importance

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