Passed
Push — color ( 10794c )
by Arnaud
05:22 queued 01:34
created

Extension::dominantColor()   A

Complexity

Conditions 2
Paths 2

Size

Total Lines 7
Code Lines 3

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 2
eloc 3
nc 2
nop 1
dl 0
loc 7
rs 10
c 0
b 0
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\Renderer\Twig;
15
16
use Cecil\Assets\Asset;
17
use Cecil\Assets\Cache;
18
use Cecil\Assets\Image;
19
use Cecil\Assets\Url;
20
use Cecil\Builder;
21
use Cecil\Collection\CollectionInterface;
22
use Cecil\Collection\Page\Collection as PagesCollection;
23
use Cecil\Collection\Page\Page;
24
use Cecil\Collection\Page\Type;
25
use Cecil\Config;
26
use Cecil\Converter\Parsedown;
27
use Cecil\Exception\RuntimeException;
28
use Cocur\Slugify\Bridge\Twig\SlugifyExtension;
29
use Cocur\Slugify\Slugify;
30
use MatthiasMullie\Minify;
31
use ScssPhp\ScssPhp\Compiler;
32
use Symfony\Component\Yaml\Exception\ParseException;
33
use Symfony\Component\Yaml\Yaml;
34
35
/**
36
 * Class Twig\Extension.
37
 */
38
class Extension extends SlugifyExtension
39
{
40
    /** @var Builder */
41
    protected $builder;
42
43
    /** @var Config */
44
    protected $config;
45
46
    /** @var Slugify */
47
    private static $slugifier;
48
49
    public function __construct(Builder $builder)
50
    {
51
        if (!self::$slugifier instanceof Slugify) {
52
            self::$slugifier = Slugify::create(['regexp' => Page::SLUGIFY_PATTERN]);
53
        }
54
55
        parent::__construct(self::$slugifier);
56
57
        $this->builder = $builder;
58
        $this->config = $this->builder->getConfig();
59
    }
60
61
    /**
62
     * {@inheritdoc}
63
     */
64
    public function getName()
65
    {
66
        return 'cecil';
67
    }
68
69
    /**
70
     * {@inheritdoc}
71
     */
72
    public function getFilters()
73
    {
74
        return [
75
            new \Twig\TwigFilter('filter_by', [$this, 'filterBy']),
76
            // sort
77
            new \Twig\TwigFilter('sort_by_title', [$this, 'sortByTitle']),
78
            new \Twig\TwigFilter('sort_by_weight', [$this, 'sortByWeight']),
79
            new \Twig\TwigFilter('sort_by_date', [$this, 'sortByDate']),
80
            // assets
81
            new \Twig\TwigFilter('url', [$this, 'url']),
82
            new \Twig\TwigFilter('html', [$this, 'html']),
83
            new \Twig\TwigFilter('inline', [$this, 'inline']),
84
            new \Twig\TwigFilter('fingerprint', [$this, 'fingerprint']),
85
            new \Twig\TwigFilter('to_css', [$this, 'toCss']),
86
            new \Twig\TwigFilter('minify', [$this, 'minify']),
87
            new \Twig\TwigFilter('minify_css', [$this, 'minifyCss']),
88
            new \Twig\TwigFilter('minify_js', [$this, 'minifyJs']),
89
            new \Twig\TwigFilter('scss_to_css', [$this, 'scssToCss']),
90
            new \Twig\TwigFilter('sass_to_css', [$this, 'scssToCss']),
91
            new \Twig\TwigFilter('resize', [$this, 'resize']),
92
            new \Twig\TwigFilter('dataurl', [$this, 'dataurl']),
93
            new \Twig\TwigFilter('dominant_color', [$this, 'dominantColor']),
94
            // content
95
            new \Twig\TwigFilter('slugify', [$this, 'slugifyFilter']),
96
            new \Twig\TwigFilter('excerpt', [$this, 'excerpt']),
97
            new \Twig\TwigFilter('excerpt_html', [$this, 'excerptHtml']),
98
            new \Twig\TwigFilter('markdown_to_html', [$this, 'markdownToHtml']),
99
            new \Twig\TwigFilter('toc', [$this, 'markdownToToc']),
100
            new \Twig\TwigFilter('json_decode', [$this, 'jsonDecode']),
101
            new \Twig\TwigFilter('yaml_parse', [$this, 'yamlParse']),
102
            new \Twig\TwigFilter('preg_split', [$this, 'pregSplit']),
103
            new \Twig\TwigFilter('preg_match_all', [$this, 'pregMatchAll']),
104
            new \Twig\TwigFilter('hex_to_rgb', [$this, 'hexToRgb']),
105
            // deprecated
106
            new \Twig\TwigFilter(
107
                'filterBySection',
108
                [$this, 'filterBySection'],
109
                ['deprecated' => true, 'alternative' => 'filter_by']
110
            ),
111
            new \Twig\TwigFilter(
112
                'filterBy',
113
                [$this, 'filterBy'],
114
                ['deprecated' => true, 'alternative' => 'filter_by']
115
            ),
116
            new \Twig\TwigFilter(
117
                'sortByTitle',
118
                [$this, 'sortByTitle'],
119
                ['deprecated' => true, 'alternative' => 'sort_by_title']
120
            ),
121
            new \Twig\TwigFilter(
122
                'sortByWeight',
123
                [$this, 'sortByWeight'],
124
                ['deprecated' => true, 'alternative' => 'sort_by_weight']
125
            ),
126
            new \Twig\TwigFilter(
127
                'sortByDate',
128
                [$this, 'sortByDate'],
129
                ['deprecated' => true, 'alternative' => 'sort_by_date']
130
            ),
131
            new \Twig\TwigFilter(
132
                'minifyCSS',
133
                [$this, 'minifyCss'],
134
                ['deprecated' => true, 'alternative' => 'minifyCss']
135
            ),
136
            new \Twig\TwigFilter(
137
                'minifyJS',
138
                [$this, 'minifyJs'],
139
                ['deprecated' => true, 'alternative' => 'minifyJs']
140
            ),
141
            new \Twig\TwigFilter(
142
                'SCSStoCSS',
143
                [$this, 'scssToCss'],
144
                ['deprecated' => true, 'alternative' => 'scss_to_css']
145
            ),
146
            new \Twig\TwigFilter(
147
                'excerptHtml',
148
                [$this, 'excerptHtml'],
149
                ['deprecated' => true, 'alternative' => 'excerpt_html']
150
            ),
151
            new \Twig\TwigFilter(
152
                'urlize',
153
                [$this, 'slugifyFilter'],
154
                ['deprecated' => true, 'alternative' => 'slugify']
155
            ),
156
        ];
157
    }
158
159
    /**
160
     * {@inheritdoc}
161
     */
162
    public function getFunctions()
163
    {
164
        return [
165
            // assets
166
            new \Twig\TwigFunction('url', [$this, 'url']),
167
            new \Twig\TwigFunction('asset', [$this, 'asset']),
168
            new \Twig\TwigFunction('integrity', [$this, 'integrity']),
169
            // content
170
            new \Twig\TwigFunction('readtime', [$this, 'readtime']),
171
            // others
172
            new \Twig\TwigFunction('getenv', [$this, 'getEnv']),
173
            // deprecated
174
            new \Twig\TwigFunction(
175
                'minify',
176
                [$this, 'minify'],
177
                ['deprecated' => true, 'alternative' => 'minify filter']
178
            ),
179
            new \Twig\TwigFunction(
180
                'toCSS',
181
                [$this, 'toCss'],
182
                ['deprecated' => true, 'alternative' => 'to_css filter']
183
            ),
184
            new \Twig\TwigFunction(
185
                'hash',
186
                [$this, 'integrity'],
187
                ['deprecated' => true, 'alternative' => 'integrity']
188
            ),
189
        ];
190
    }
191
192
    /**
193
     * {@inheritdoc}
194
     */
195
    public function getTests()
196
    {
197
        return [
198
            new \Twig\TwigTest('asset', [$this, 'isAsset']),
199
        ];
200
    }
201
202
    /**
203
     * Filters by Section.
204
     * Alias of `filterBy('section', $value)`.
205
     */
206
    public function filterBySection(PagesCollection $pages, string $section): CollectionInterface
207
    {
208
        return $this->filterBy($pages, 'section', $section);
209
    }
210
211
    /**
212
     * Filters by variable's name/value.
213
     */
214
    public function filterBy(PagesCollection $pages, string $variable, string $value): CollectionInterface
215
    {
216
        $filteredPages = $pages->filter(function (Page $page) use ($variable, $value) {
217
            // is a dedicated getter exists?
218
            $method = 'get'.ucfirst($variable);
219
            if (method_exists($page, $method) && $page->$method() == $value) {
220
                return $page->getType() == Type::PAGE() && !$page->isVirtual() && true;
221
            }
222
            // or a classic variable
223
            if ($page->getVariable($variable) == $value) {
224
                return $page->getType() == Type::PAGE() && !$page->isVirtual() && true;
225
            }
226
        });
227
228
        return $filteredPages;
229
    }
230
231
    /**
232
     * Sorts by title.
233
     */
234
    public function sortByTitle(\Traversable $collection): array
235
    {
236
        $collection = iterator_to_array($collection);
237
        /** @var \array $collection */
238
        array_multisort(array_keys(/** @scrutinizer ignore-type */ $collection), \SORT_ASC, \SORT_NATURAL | \SORT_FLAG_CASE, $collection);
0 ignored issues
show
Bug introduced by
SORT_ASC cannot be passed to array_multisort() as the parameter $rest expects a reference. ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

238
        array_multisort(array_keys(/** @scrutinizer ignore-type */ $collection), /** @scrutinizer ignore-type */ \SORT_ASC, \SORT_NATURAL | \SORT_FLAG_CASE, $collection);
Loading history...
Bug introduced by
array_keys($collection) cannot be passed to array_multisort() as the parameter $array expects a reference. ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

238
        array_multisort(/** @scrutinizer ignore-type */ array_keys(/** @scrutinizer ignore-type */ $collection), \SORT_ASC, \SORT_NATURAL | \SORT_FLAG_CASE, $collection);
Loading history...
Bug introduced by
SORT_NATURAL | SORT_FLAG_CASE cannot be passed to array_multisort() as the parameter $rest expects a reference. ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

238
        array_multisort(array_keys(/** @scrutinizer ignore-type */ $collection), \SORT_ASC, /** @scrutinizer ignore-type */ \SORT_NATURAL | \SORT_FLAG_CASE, $collection);
Loading history...
239
240
        return $collection;
241
    }
242
243
    /**
244
     * Sorts by weight.
245
     */
246
    public function sortByWeight(\Traversable $collection): array
247
    {
248
        $callback = function ($a, $b) {
249
            if (!isset($a['weight'])) {
250
                $a['weight'] = 0;
251
            }
252
            if (!isset($b['weight'])) {
253
                $a['weight'] = 0;
254
            }
255
            if ($a['weight'] == $b['weight']) {
256
                return 0;
257
            }
258
259
            return ($a['weight'] < $b['weight']) ? -1 : 1;
260
        };
261
262
        $collection = iterator_to_array($collection);
263
        /** @var \array $collection */
264
        usort(/** @scrutinizer ignore-type */ $collection, $callback);
265
266
        return $collection;
267
    }
268
269
    /**
270
     * Sorts by creation date (or 'updated' date): the most recent first.
271
     */
272
    public function sortByDate(\Traversable $collection, string $variable = 'date'): array
273
    {
274
        $callback = function ($a, $b) use ($variable) {
275
            if ($a[$variable] == $b[$variable]) {
276
                return 0;
277
            }
278
279
            return ($a[$variable] > $b[$variable]) ? -1 : 1;
280
        };
281
282
        $collection = iterator_to_array($collection);
283
        /** @var \array $collection */
284
        usort(/** @scrutinizer ignore-type */ $collection, $callback);
285
286
        return $collection;
287
    }
288
289
    /**
290
     * Creates an URL.
291
     *
292
     * $options[
293
     *     'canonical' => true,
294
     *     'addhash'   => false,
295
     *     'format'    => 'json',
296
     * ];
297
     *
298
     * @param Page|Asset|string|null $value
299
     * @param array|null             $options
300
     *
301
     * @return mixed
302
     */
303
    public function url($value = null, array $options = null)
304
    {
305
        return new Url($this->builder, $value, $options);
306
    }
307
308
    /**
309
     * Creates an asset (CSS, JS, images, etc.).
310
     *
311
     * @param string|array $path    File path (relative from static/ dir).
312
     * @param array|null   $options
313
     *
314
     * @return Asset
315
     */
316
    public function asset($path, array $options = null): Asset
317
    {
318
        return new Asset($this->builder, $path, $options);
319
    }
320
321
    /**
322
     * Compiles a SCSS asset.
323
     *
324
     * @param string|Asset $asset
325
     *
326
     * @return Asset
327
     */
328
    public function toCss($asset): Asset
329
    {
330
        if (!$asset instanceof Asset) {
331
            $asset = new Asset($this->builder, $asset);
332
        }
333
334
        return $asset->compile();
335
    }
336
337
    /**
338
     * Minifying an asset (CSS or JS).
339
     *
340
     * @param string|Asset $asset
341
     *
342
     * @return Asset
343
     */
344
    public function minify($asset): Asset
345
    {
346
        if (!$asset instanceof Asset) {
347
            $asset = new Asset($this->builder, $asset);
348
        }
349
350
        return $asset->minify();
351
    }
352
353
    /**
354
     * Fingerprinting an asset.
355
     *
356
     * @param string|Asset $asset
357
     *
358
     * @return Asset
359
     */
360
    public function fingerprint($asset): Asset
361
    {
362
        if (!$asset instanceof Asset) {
363
            $asset = new Asset($this->builder, $asset);
364
        }
365
366
        return $asset->fingerprint();
367
    }
368
369
    /**
370
     * Resizes an image.
371
     *
372
     * @param string|Asset $asset
373
     *
374
     * @return Asset
375
     */
376
    public function resize($asset, int $size): Asset
377
    {
378
        if (!$asset instanceof Asset) {
379
            $asset = new Asset($this->builder, $asset);
380
        }
381
382
        return $asset->resize($size);
383
    }
384
385
    /**
386
     * Returns the data URL of an image.
387
     *
388
     * @param string|Asset $asset
389
     *
390
     * @return string
391
     */
392
    public function dataurl($asset): string
393
    {
394
        if (!$asset instanceof Asset) {
395
            $asset = new Asset($this->builder, $asset);
396
        }
397
398
        return $asset->dataurl();
399
    }
400
401
    /**
402
     * Hashing an asset with algo (sha384 by default).
403
     *
404
     * @param string|Asset $path
405
     * @param string       $algo
406
     *
407
     * @return string
408
     */
409
    public function integrity($asset, string $algo = 'sha384'): string
410
    {
411
        if (!$asset instanceof Asset) {
412
            $asset = new Asset($this->builder, $asset);
413
        }
414
415
        return $asset->getIntegrity($algo);
416
    }
417
418
    /**
419
     * Minifying a CSS string.
420
     */
421
    public function minifyCss(?string $value): string
422
    {
423
        $value = $value ?? '';
424
425
        if ($this->builder->isDebug()) {
426
            return $value;
427
        }
428
429
        $cache = new Cache($this->builder);
430
        $cacheKey = $cache->createKeyFromString($value);
431
        if (!$cache->has($cacheKey)) {
432
            $minifier = new Minify\CSS($value);
433
            $value = $minifier->minify();
434
            $cache->set($cacheKey, $value);
435
        }
436
437
        return $cache->get($cacheKey, $value);
438
    }
439
440
    /**
441
     * Minifying a JavaScript string.
442
     */
443
    public function minifyJs(?string $value): string
444
    {
445
        $value = $value ?? '';
446
447
        if ($this->builder->isDebug()) {
448
            return $value;
449
        }
450
451
        $cache = new Cache($this->builder);
452
        $cacheKey = $cache->createKeyFromString($value);
453
        if (!$cache->has($cacheKey)) {
454
            $minifier = new Minify\JS($value);
455
            $value = $minifier->minify();
456
            $cache->set($cacheKey, $value);
457
        }
458
459
        return $cache->get($cacheKey, $value);
460
    }
461
462
    /**
463
     * Compiles a SCSS string.
464
     *
465
     * @throws RuntimeException
466
     */
467
    public function scssToCss(?string $value): string
468
    {
469
        $value = $value ?? '';
470
471
        $cache = new Cache($this->builder);
472
        $cacheKey = $cache->createKeyFromString($value);
473
        if (!$cache->has($cacheKey)) {
474
            $scssPhp = new Compiler();
475
            $outputStyles = ['expanded', 'compressed'];
476
            $outputStyle = strtolower((string) $this->config->get('assets.compile.style'));
477
            if (!in_array($outputStyle, $outputStyles)) {
478
                throw new RuntimeException(\sprintf('Scss output style "%s" doesn\'t exists.', $outputStyle));
479
            }
480
            $scssPhp->setOutputStyle($outputStyle);
481
            $variables = $this->config->get('assets.compile.variables') ?? [];
482
            if (!empty($variables)) {
483
                $variables = array_map('ScssPhp\ScssPhp\ValueConverter::parseValue', $variables);
484
                $scssPhp->replaceVariables($variables);
485
            }
486
            $value = $scssPhp->compileString($value)->getCss();
487
            $cache->set($cacheKey, $value);
488
        }
489
490
        return $cache->get($cacheKey, $value);
491
    }
492
493
    /**
494
     * Returns the HTML version of an asset.
495
     *
496
     * $options[
497
     *     'preload'    => false,
498
     *     'responsive' => false,
499
     * ];
500
     *
501
     * @throws RuntimeException
502
     */
503
    public function html(Asset $asset, array $attributes = [], array $options = []): string
504
    {
505
        $htmlAttributes = '';
506
        $preload = false;
507
        $responsive = $this->config->get('assets.images.responsive.enabled') ?? false;
508
        $webp = $this->config->get('assets.images.webp.enabled') ?? false;
509
        extract($options, EXTR_IF_EXISTS);
510
511
        foreach ($attributes as $name => $value) {
512
            $attribute = \sprintf(' %s="%s"', $name, $value);
513
            if (empty($value)) {
514
                $attribute = \sprintf(' %s', $name);
515
            }
516
            $htmlAttributes .= $attribute;
517
        }
518
519
        $asset->save();
520
521
        /* CSS or JavaScript */
522
        switch ($asset['ext']) {
523
            case 'css':
524
                if ($preload) {
525
                    return \sprintf(
526
                        '<link href="%s" rel="preload" as="style" onload="this.onload=null;this.rel=\'stylesheet\'"%s><noscript><link rel="stylesheet" href="%1$s"%2$s></noscript>',
527
                        $this->url($asset, $options),
528
                        $htmlAttributes
529
                    );
530
                }
531
532
                return \sprintf('<link rel="stylesheet" href="%s"%s>', $this->url($asset, $options), $htmlAttributes);
533
            case 'js':
534
                return \sprintf('<script src="%s"%s></script>', $this->url($asset, $options), $htmlAttributes);
535
        }
536
537
        /* Image */
538
        if ($asset['type'] == 'image') {
539
            // responsive
540
            if ($responsive && $srcset = Image::buildSrcset(
541
                $asset,
542
                $this->config->get('assets.images.responsive.widths') ?? [480, 640, 768, 1024, 1366, 1600, 1920]
543
            )) {
544
                $htmlAttributes .= \sprintf(' srcset="%s"', $srcset);
545
                $htmlAttributes .= \sprintf(' sizes="%s"', $this->config->get('assets.images.responsive.sizes.default') ?? '100vw');
546
            }
547
548
            // <img>
549
            $img = \sprintf(
550
                '<img src="%s" width="'.($asset->getWidth() ?: 0).'" height="'.($asset->getHeight() ?: 0).'"%s>',
551
                $this->url($asset, $options),
552
                $htmlAttributes
553
            );
554
555
            // WebP transformation?
556
            if ($webp && !Image::isAnimatedGif($asset)) {
557
                try {
558
                    $assetWebp = Image::convertTopWebp($asset, $this->config->get('assets.images.quality') ?? 75);
559
                    // <source>
560
                    $source = \sprintf('<source type="image/webp" srcset="%s">', $assetWebp);
561
                    // responsive
562
                    if ($responsive) {
563
                        $srcset = Image::buildSrcset(
564
                            $assetWebp,
565
                            $this->config->get('assets.images.responsive.widths') ?? [480, 640, 768, 1024, 1366, 1600, 1920]
566
                        ) ?: (string) $assetWebp;
567
                        // <source>
568
                        $source = \sprintf(
569
                            '<source type="image/webp" srcset="%s" sizes="%s">',
570
                            $srcset,
571
                            $this->config->get('assets.images.responsive.sizes.default') ?? '100vw'
572
                        );
573
                    }
574
575
                    return \sprintf("<picture>\n  %s\n  %s\n</picture>", $source, $img);
576
                } catch (\Exception $e) {
577
                    $this->builder->getLogger()->debug($e->getMessage());
578
                }
579
            }
580
581
            return $img;
582
        }
583
584
        throw new RuntimeException(\sprintf('%s is available with CSS, JS and images files only.', '"html" filter'));
585
    }
586
587
    /**
588
     * Returns the content of an asset.
589
     */
590
    public function inline(Asset $asset): string
591
    {
592
        return $asset['content'];
593
    }
594
595
    /**
596
     * Reads $length first characters of a string and adds a suffix.
597
     */
598
    public function excerpt(?string $string, int $length = 450, string $suffix = ' …'): string
599
    {
600
        $string = $string ?? '';
601
602
        $string = str_replace('</p>', '<br /><br />', $string);
603
        $string = trim(strip_tags($string, '<br>'), '<br />');
604
        if (mb_strlen($string) > $length) {
605
            $string = mb_substr($string, 0, $length);
606
            $string .= $suffix;
607
        }
608
609
        return $string;
610
    }
611
612
    /**
613
     * Reads characters before '<!-- excerpt|break -->'.
614
     * Options:
615
     *  - separator: string to use as separator
616
     *  - capture: string to capture, 'before' (default) or 'after'.
617
     */
618
    public function excerptHtml(?string $string, array $options = []): string
619
    {
620
        $string = $string ?? '';
621
622
        $separator = 'excerpt|break';
623
        $capture = 'before';
624
        extract($options, EXTR_IF_EXISTS);
625
626
        // https://regex101.com/r/n9TWHF/1
627
        $pattern = '(.*)<!--[[:blank:]]?('.$separator.')[[:blank:]]?-->(.*)';
628
        preg_match('/'.$pattern.'/is', $string, $matches);
629
630
        if (empty($matches)) {
631
            return $string;
632
        }
633
        if ($capture == 'after') {
634
            return trim($matches[3]);
635
        }
636
        // remove footnotes
637
        return preg_replace('/<sup[^>]*>[^u]*<\/sup>/', '', trim($matches[1]));
638
    }
639
640
    /**
641
     * Converts a Markdown string to HTML.
642
     *
643
     * @throws RuntimeException
644
     */
645
    public function markdownToHtml(?string $markdown): ?string
646
    {
647
        $markdown = $markdown ?? '';
648
649
        try {
650
            $parsedown = new Parsedown($this->builder);
651
            $html = $parsedown->text($markdown);
652
        } catch (\Exception $e) {
653
            throw new RuntimeException('"markdown_to_html" filter can not convert supplied Markdown.');
654
        }
655
656
        return $html;
657
    }
658
659
    /**
660
     * Extract table of content of a Markdown string,
661
     * in the given format ("html" or "json", "html" by default).
662
     *
663
     * @throws RuntimeException
664
     */
665
    public function markdownToToc(?string $markdown, $format = 'html'): ?string
666
    {
667
        $markdown = $markdown ?? '';
668
669
        try {
670
            $parsedown = new Parsedown($this->builder, ['selectors' => ['h2']]);
671
            $parsedown->body($markdown);
672
            $return = $parsedown->contentsList($format);
673
        } catch (\Exception $e) {
674
            throw new RuntimeException('"toc" filter can not convert supplied Markdown.');
675
        }
676
677
        return $return;
678
    }
679
680
    /**
681
     * Converts a JSON string to an array.
682
     *
683
     * @throws RuntimeException
684
     */
685
    public function jsonDecode(?string $json): ?array
686
    {
687
        $json = $json ?? '';
688
689
        try {
690
            $array = json_decode($json, true);
691
            if ($array === null && json_last_error() !== JSON_ERROR_NONE) {
692
                throw new \Exception('JSON error.');
693
            }
694
        } catch (\Exception $e) {
695
            throw new RuntimeException('"json_decode" filter can not parse supplied JSON.');
696
        }
697
698
        return $array;
699
    }
700
701
    /**
702
     * Converts a YAML string to an array.
703
     *
704
     * @throws RuntimeException
705
     */
706
    public function yamlParse(?string $yaml): ?array
707
    {
708
        $yaml = $yaml ?? '';
709
710
        try {
711
            $array = Yaml::parse($yaml);
712
            if (!is_array($array)) {
713
                throw new ParseException('YAML error.');
714
            }
715
        } catch (ParseException $e) {
716
            throw new RuntimeException(\sprintf('"yaml_parse" filter can not parse supplied YAML: %s', $e->getMessage()));
717
        }
718
719
        return $array;
720
    }
721
722
    /**
723
     * Split a string into an array using a regular expression.
724
     *
725
     * @throws RuntimeException
726
     */
727
    public function pregSplit(?string $value, string $pattern, int $limit = 0): ?array
728
    {
729
        $value = $value ?? '';
730
731
        try {
732
            $array = preg_split($pattern, $value, $limit);
733
            if ($array === false) {
734
                throw new RuntimeException('PREG split error.');
735
            }
736
        } catch (\Exception $e) {
737
            throw new RuntimeException('"preg_split" filter can not split supplied string.');
738
        }
739
740
        return $array;
741
    }
742
743
    /**
744
     * Perform a regular expression match and return the group for all matches.
745
     *
746
     * @throws RuntimeException
747
     */
748
    public function pregMatchAll(?string $value, string $pattern, int $group = 0): ?array
749
    {
750
        $value = $value ?? '';
751
752
        try {
753
            $array = preg_match_all($pattern, $value, $matches, PREG_PATTERN_ORDER);
754
            if ($array === false) {
755
                throw new RuntimeException('PREG match all error.');
756
            }
757
        } catch (\Exception $e) {
758
            throw new RuntimeException('"preg_match_all" filter can not match in supplied string.');
759
        }
760
761
        return $matches[$group];
762
    }
763
764
    /**
765
     * Calculates estimated time to read a text.
766
     */
767
    public function readtime(?string $text): string
768
    {
769
        $text = $text ?? '';
770
771
        $words = str_word_count(strip_tags($text));
772
        $min = floor($words / 200);
773
        if ($min === 0) {
774
            return '1';
775
        }
776
777
        return (string) $min;
778
    }
779
780
    /**
781
     * Gets the value of an environment variable.
782
     */
783
    public function getEnv(?string $var): ?string
784
    {
785
        $var = $var ?? '';
786
787
        return getenv($var) ?: null;
788
    }
789
790
    /**
791
     * Tests if a variable is an Asset.
792
     */
793
    public function isAsset($variable): bool
794
    {
795
        return $variable instanceof Asset;
796
    }
797
798
    /**
799
     * Returns the dominant hex color of an image asset.
800
     *
801
     * @param string|Asset $path
802
     *
803
     * @return string
804
     */
805
    public function dominantColor($asset): string
806
    {
807
        if (!$asset instanceof Asset) {
808
            $asset = new Asset($this->builder, $asset);
809
        }
810
811
        return Image::getDominantColor($asset);
812
    }
813
814
    /**
815
     * Converts an hexadecimal color to RGB.
816
     *
817
     * @throws RuntimeException
818
     */
819
    public function hexToRgb(?string $variable): array
820
    {
821
        $variable = $variable ?? '';
822
823
        if (!self::isHex($variable)) {
824
            throw new RuntimeException(\sprintf('"%s" is not a valid hexadecimal value.', $variable));
825
        }
826
        $hex = ltrim($variable, '#');
827
        if (strlen($hex) == 3) {
828
            $hex = $hex[0].$hex[0].$hex[1].$hex[1].$hex[2].$hex[2];
829
        }
830
        $c = hexdec($hex);
831
832
        return [
833
            'red'   => $c >> 16 & 0xFF,
834
            'green' => $c >> 8 & 0xFF,
835
            'blue'  => $c & 0xFF,
836
        ];
837
    }
838
839
    /**
840
     * Is a hexadecimal color is valid?
841
     */
842
    private static function isHex(string $hex): bool
843
    {
844
        $valid = is_string($hex);
845
        $hex = ltrim($hex, '#');
846
        $length = strlen($hex);
847
        $valid = $valid && ($length === 3 || $length === 6);
848
        $valid = $valid && ctype_xdigit($hex);
849
850
        return $valid;
851
    }
852
}
853