Passed
Push — feat/twig ( cd3e01...41f5a1 )
by Arnaud
03:58
created

Extension::hexToRgb()   A

Complexity

Conditions 3
Paths 3

Size

Total Lines 15
Code Lines 10

Duplication

Lines 0
Ratio 0 %

Importance

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

231
        array_multisort(array_keys($collection), /** @scrutinizer ignore-type */ 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

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