Passed
Push — master ( 789bf9...03fad7 )
by Arnaud
06:21
created

Core::html()   D

Complexity

Conditions 20
Paths 39

Size

Total Lines 94
Code Lines 56

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 42
CRAP Score 29.5651

Importance

Changes 1
Bugs 1 Features 0
Metric Value
cc 20
eloc 56
c 1
b 1
f 0
nc 39
nop 4
dl 0
loc 94
ccs 42
cts 59
cp 0.7119
crap 29.5651
rs 4.1666

How to fix   Long Method    Complexity   

Long Method

Small methods make your code easier to understand, in particular if combined with a good name. Besides, if your method is small, finding a good name is usually much easier.

For example, if you find yourself adding comments to a method's body, this is usually a good sign to extract the commented part to a new method, and use the comment as a starting point when coming up with a good name for this new method.

Commonly applied refactorings include:

1
<?php
2
3
/**
4
 * This file is part of Cecil.
5
 *
6
 * (c) Arnaud Ligny <[email protected]>
7
 *
8
 * For the full copyright and license information, please view the LICENSE
9
 * file that was distributed with this source code.
10
 */
11
12
declare(strict_types=1);
13
14
namespace Cecil\Renderer\Extension;
15
16
use Cecil\Assets\Asset;
17
use Cecil\Assets\Cache;
18
use Cecil\Assets\Image;
19
use Cecil\Builder;
20
use Cecil\Collection\CollectionInterface;
21
use Cecil\Collection\Page\Collection as PagesCollection;
22
use Cecil\Collection\Page\Page;
23
use Cecil\Collection\Page\Type;
24
use Cecil\Config;
25
use Cecil\Converter\Parsedown;
26
use Cecil\Exception\ConfigException;
27
use Cecil\Exception\RuntimeException;
28
use Cecil\Url;
29
use Cocur\Slugify\Bridge\Twig\SlugifyExtension;
30
use Cocur\Slugify\Slugify;
31
use Highlight\Highlighter;
32
use MatthiasMullie\Minify;
33
use ScssPhp\ScssPhp\Compiler;
34
use ScssPhp\ScssPhp\OutputStyle;
35
use Symfony\Component\VarDumper\Cloner\VarCloner;
36
use Symfony\Component\VarDumper\Dumper\HtmlDumper;
37
use Symfony\Component\Yaml\Exception\ParseException;
38
use Symfony\Component\Yaml\Yaml;
39
use Twig\DeprecatedCallableInfo;
40
41
/**
42
 * Core Twig extension.
43
 *
44
 * This extension provides various utility functions and filters for use in Twig templates,
45
 * including URL generation, asset management, content processing, and more.
46
 */
47
class Core extends SlugifyExtension
48
{
49
    /** @var Builder */
50
    protected $builder;
51
52
    /** @var Config */
53
    protected $config;
54
55
    /** @var Slugify */
56
    private static $slugifier;
57
58 1
    public function __construct(Builder $builder)
59
    {
60 1
        if (!self::$slugifier instanceof Slugify) {
61 1
            self::$slugifier = Slugify::create(['regexp' => Page::SLUGIFY_PATTERN]);
62
        }
63
64 1
        parent::__construct(self::$slugifier);
65
66 1
        $this->builder = $builder;
67 1
        $this->config = $builder->getConfig();
68
    }
69
70
    /**
71
     * {@inheritdoc}
72
     */
73
    public function getName(): string
74
    {
75
        return 'CoreExtension';
76
    }
77
78
    /**
79
     * {@inheritdoc}
80
     */
81 1
    public function getFunctions()
82
    {
83 1
        return [
84 1
            new \Twig\TwigFunction('url', [$this, 'url'], ['needs_context' => true]),
85
            // assets
86 1
            new \Twig\TwigFunction('asset', [$this, 'asset']),
87 1
            new \Twig\TwigFunction('html', [$this, 'html'], ['needs_context' => true]),
88 1
            new \Twig\TwigFunction('integrity', [$this, 'integrity']),
89 1
            new \Twig\TwigFunction('image_srcset', [$this, 'imageSrcset']),
90 1
            new \Twig\TwigFunction('image_sizes', [$this, 'imageSizes']),
91
            // content
92 1
            new \Twig\TwigFunction('readtime', [$this, 'readtime']),
93 1
            new \Twig\TwigFunction('hash', [$this, 'hash']),
94
            // others
95 1
            new \Twig\TwigFunction('getenv', [$this, 'getEnv']),
96 1
            new \Twig\TwigFunction('d', [$this, 'varDump'], ['needs_context' => true, 'needs_environment' => true]),
97
            // deprecated
98 1
            new \Twig\TwigFunction(
99 1
                'minify',
100 1
                [$this, 'minify'],
101 1
                ['deprecation_info' => new DeprecatedCallableInfo('', '', 'minify filter')]
102 1
            ),
103 1
            new \Twig\TwigFunction(
104 1
                'toCSS',
105 1
                [$this, 'toCss'],
106 1
                ['deprecation_info' => new DeprecatedCallableInfo('', '', 'to_css filter')]
107 1
            ),
108 1
        ];
109
    }
110
111
    /**
112
     * {@inheritdoc}
113
     */
114 1
    public function getFilters(): array
115
    {
116 1
        return [
117 1
            new \Twig\TwigFilter('url', [$this, 'url'], ['needs_context' => true]),
118
            // collections
119 1
            new \Twig\TwigFilter('sort_by_title', [$this, 'sortByTitle']),
120 1
            new \Twig\TwigFilter('sort_by_weight', [$this, 'sortByWeight']),
121 1
            new \Twig\TwigFilter('sort_by_date', [$this, 'sortByDate']),
122 1
            new \Twig\TwigFilter('filter_by', [$this, 'filterBy']),
123
            // assets
124 1
            new \Twig\TwigFilter('inline', [$this, 'inline']),
125 1
            new \Twig\TwigFilter('fingerprint', [$this, 'fingerprint']),
126 1
            new \Twig\TwigFilter('to_css', [$this, 'toCss']),
127 1
            new \Twig\TwigFilter('minify', [$this, 'minify']),
128 1
            new \Twig\TwigFilter('minify_css', [$this, 'minifyCss']),
129 1
            new \Twig\TwigFilter('minify_js', [$this, 'minifyJs']),
130 1
            new \Twig\TwigFilter('scss_to_css', [$this, 'scssToCss']),
131 1
            new \Twig\TwigFilter('sass_to_css', [$this, 'scssToCss']),
132 1
            new \Twig\TwigFilter('resize', [$this, 'resize']),
133 1
            new \Twig\TwigFilter('cover', [$this, 'cover']),
134 1
            new \Twig\TwigFilter('maskable', [$this, 'maskable']),
135 1
            new \Twig\TwigFilter('dataurl', [$this, 'dataurl']),
136 1
            new \Twig\TwigFilter('dominant_color', [$this, 'dominantColor']),
137 1
            new \Twig\TwigFilter('lqip', [$this, 'lqip']),
138 1
            new \Twig\TwigFilter('webp', [$this, 'webp']),
139 1
            new \Twig\TwigFilter('avif', [$this, 'avif']),
140
            // content
141 1
            new \Twig\TwigFilter('slugify', [$this, 'slugifyFilter']),
142 1
            new \Twig\TwigFilter('excerpt', [$this, 'excerpt']),
143 1
            new \Twig\TwigFilter('excerpt_html', [$this, 'excerptHtml']),
144 1
            new \Twig\TwigFilter('markdown_to_html', [$this, 'markdownToHtml']),
145 1
            new \Twig\TwigFilter('toc', [$this, 'markdownToToc']),
146 1
            new \Twig\TwigFilter('json_decode', [$this, 'jsonDecode']),
147 1
            new \Twig\TwigFilter('yaml_parse', [$this, 'yamlParse']),
148 1
            new \Twig\TwigFilter('preg_split', [$this, 'pregSplit']),
149 1
            new \Twig\TwigFilter('preg_match_all', [$this, 'pregMatchAll']),
150 1
            new \Twig\TwigFilter('hex_to_rgb', [$this, 'hexToRgb']),
151 1
            new \Twig\TwigFilter('splitline', [$this, 'splitLine']),
152 1
            new \Twig\TwigFilter('iterable', [$this, 'iterable']),
153 1
            new \Twig\TwigFilter('highlight', [$this, 'highlight']),
154
            // date
155 1
            new \Twig\TwigFilter('duration_to_iso8601', ['\Cecil\Util\Date', 'durationToIso8601']),
156
            // deprecated
157 1
            new \Twig\TwigFilter(
158 1
                'html',
159 1
                [$this, 'html'],
160 1
                [
161 1
                    'needs_context' => true,
162 1
                    'deprecation_info' => new DeprecatedCallableInfo('', '', 'html function')
163 1
                ]
164 1
            ),
165 1
        ];
166
    }
167
168
    /**
169
     * {@inheritdoc}
170
     */
171 1
    public function getTests()
172
    {
173 1
        return [
174 1
            new \Twig\TwigTest('asset', [$this, 'isAsset']),
175 1
        ];
176
    }
177
178
    /**
179
     * Filters by Section.
180
     */
181
    public function filterBySection(PagesCollection $pages, string $section): CollectionInterface
182
    {
183
        return $this->filterBy($pages, 'section', $section);
184
    }
185
186
    /**
187
     * Filters a pages collection by variable's name/value.
188
     */
189 1
    public function filterBy(PagesCollection $pages, string $variable, string $value): CollectionInterface
190
    {
191 1
        $filteredPages = $pages->filter(function (Page $page) use ($variable, $value) {
192
            // is a dedicated getter exists?
193 1
            $method = 'get' . ucfirst($variable);
194 1
            if (method_exists($page, $method) && $page->$method() == $value) {
195
                return $page->getType() == Type::PAGE->value && !$page->isVirtual() && true;
196
            }
197
            // or a classic variable
198 1
            if ($page->getVariable($variable) == $value) {
199 1
                return $page->getType() == Type::PAGE->value && !$page->isVirtual() && true;
200
            }
201 1
        });
202
203 1
        return $filteredPages;
204
    }
205
206
    /**
207
     * Sorts a collection by title.
208
     */
209 1
    public function sortByTitle(\Traversable $collection): array
210
    {
211 1
        $sort = \SORT_ASC;
212
213 1
        $collection = iterator_to_array($collection);
214 1
        array_multisort(array_keys(/** @scrutinizer ignore-type */ $collection), $sort, \SORT_NATURAL | \SORT_FLAG_CASE, $collection);
0 ignored issues
show
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

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

214
        array_multisort(/** @scrutinizer ignore-type */ array_keys(/** @scrutinizer ignore-type */ $collection), $sort, \SORT_NATURAL | \SORT_FLAG_CASE, $collection);
Loading history...
215
216 1
        return $collection;
217
    }
218
219
    /**
220
     * Sorts a collection by weight.
221
     *
222
     * @param \Traversable|array $collection
223
     */
224 1
    public function sortByWeight($collection): array
225
    {
226 1
        $callback = function ($a, $b) {
227 1
            if (!isset($a['weight'])) {
228 1
                $a['weight'] = 0;
229
            }
230 1
            if (!isset($b['weight'])) {
231
                $a['weight'] = 0;
232
            }
233 1
            if ($a['weight'] == $b['weight']) {
234 1
                return 0;
235
            }
236
237 1
            return $a['weight'] < $b['weight'] ? -1 : 1;
238 1
        };
239
240 1
        if (!\is_array($collection)) {
241 1
            $collection = iterator_to_array($collection);
242
        }
243 1
        usort(/** @scrutinizer ignore-type */ $collection, $callback);
244
245 1
        return $collection;
246
    }
247
248
    /**
249
     * Sorts by creation date (or 'updated' date): the most recent first.
250
     */
251 1
    public function sortByDate(\Traversable $collection, string $variable = 'date', bool $descTitle = false): array
252
    {
253 1
        $callback = function ($a, $b) use ($variable, $descTitle) {
254 1
            if ($a[$variable] == $b[$variable]) {
255
                // if dates are equal and "descTitle" is true
256 1
                if ($descTitle && (isset($a['title']) && isset($b['title']))) {
257
                    return strnatcmp($b['title'], $a['title']);
258
                }
259
260 1
                return 0;
261
            }
262
263 1
            return $a[$variable] > $b[$variable] ? -1 : 1;
264 1
        };
265
266 1
        $collection = iterator_to_array($collection);
267 1
        usort(/** @scrutinizer ignore-type */ $collection, $callback);
268
269 1
        return $collection;
270
    }
271
272
    /**
273
     * Creates an URL.
274
     *
275
     * $options[
276
     *     'canonical' => false,
277
     *     'format'    => 'html',
278
     *     'language'  => null,
279
     * ];
280
     *
281
     * @param array                  $context
282
     * @param Page|Asset|string|null $value
283
     * @param array|null             $options
284
     */
285 1
    public function url(array $context, $value = null, ?array $options = null): string
286
    {
287 1
        $optionsLang = [];
288 1
        $optionsLang['language'] = (string) $context['site']['language'];
289 1
        $options = array_merge($optionsLang, $options ?? []);
290
291 1
        return (new Url($this->builder, $value, $options))->getUrl();
292
    }
293
294
    /**
295
     * Creates an Asset (CSS, JS, images, etc.) from a path or an array of paths.
296
     *
297
     * @param string|array $path    File path or array of files path (relative from `assets/` or `static/` dir).
298
     * @param array|null   $options
299
     *
300
     * @return Asset
301
     */
302 1
    public function asset($path, array|null $options = null): Asset
303
    {
304 1
        if (!\is_string($path) && !\is_array($path)) {
305
            throw new RuntimeException(\sprintf('Argument of "%s()" must a string or an array.', \Cecil\Util::formatMethodName(__METHOD__)));
306
        }
307
308 1
        return new Asset($this->builder, $path, $options);
309
    }
310
311
    /**
312
     * Compiles a SCSS asset.
313
     *
314
     * @param string|Asset $asset
315
     *
316
     * @return Asset
317
     */
318 1
    public function toCss($asset): Asset
319
    {
320 1
        if (!$asset instanceof Asset) {
321
            $asset = new Asset($this->builder, $asset);
322
        }
323
324 1
        return $asset->compile();
325
    }
326
327
    /**
328
     * Minifying an asset (CSS or JS).
329
     *
330
     * @param string|Asset $asset
331
     *
332
     * @return Asset
333
     */
334 1
    public function minify($asset): Asset
335
    {
336 1
        if (!$asset instanceof Asset) {
337
            $asset = new Asset($this->builder, $asset);
338
        }
339
340 1
        return $asset->minify();
341
    }
342
343
    /**
344
     * Fingerprinting an asset.
345
     *
346
     * @param string|Asset $asset
347
     *
348
     * @return Asset
349
     */
350 1
    public function fingerprint($asset): Asset
351
    {
352 1
        if (!$asset instanceof Asset) {
353
            $asset = new Asset($this->builder, $asset);
354
        }
355
356 1
        return $asset->fingerprint();
357
    }
358
359
    /**
360
     * Resizes an image.
361
     *
362
     * @param string|Asset $asset
363
     *
364
     * @return Asset
365
     */
366 1
    public function resize($asset, int $size): Asset
367
    {
368 1
        if (!$asset instanceof Asset) {
369
            $asset = new Asset($this->builder, $asset);
370
        }
371
372 1
        return $asset->resize($size);
373
    }
374
375
    /**
376
     * Crops an image Asset to the given width and height, keeping the aspect ratio.
377
     *
378
     * @param string|Asset $asset
379
     *
380
     * @return Asset
381
     */
382
    public function cover($asset, int $width, int $height): Asset
383
    {
384
        if (!$asset instanceof Asset) {
385
            $asset = new Asset($this->builder, $asset);
386
        }
387
388
        return $asset->cover($width, $height);
389
    }
390
391
    /**
392
     * Creates a maskable icon from an image asset.
393
     * The maskable icon is used for Progressive Web Apps (PWAs).
394
     *
395
     * @param string|Asset $asset
396
     *
397
     * @return Asset
398
     */
399
    public function maskable($asset, ?int $padding = null): Asset
400
    {
401
        if (!$asset instanceof Asset) {
402
            $asset = new Asset($this->builder, $asset);
403
        }
404
405
        return $asset->maskable($padding);
406
    }
407
408
    /**
409
     * Returns the data URL of an image.
410
     *
411
     * @param string|Asset $asset
412
     *
413
     * @return string
414
     */
415 1
    public function dataurl($asset): string
416
    {
417 1
        if (!$asset instanceof Asset) {
418
            $asset = new Asset($this->builder, $asset);
419
        }
420
421 1
        return $asset->dataurl();
422
    }
423
424
    /**
425
     * Hashing an asset with algo (sha384 by default).
426
     *
427
     * @param string|Asset $asset
428
     * @param string       $algo
429
     *
430
     * @return string
431
     */
432 1
    public function integrity($asset, string $algo = 'sha384'): string
433
    {
434 1
        if (!$asset instanceof Asset) {
435 1
            $asset = new Asset($this->builder, $asset);
436
        }
437
438 1
        return $asset->getIntegrity($algo);
439
    }
440
441
    /**
442
     * Minifying a CSS string.
443
     */
444 1
    public function minifyCss(?string $value): string
445
    {
446 1
        $value = $value ?? '';
447
448 1
        if ($this->builder->isDebug()) {
449 1
            return $value;
450
        }
451
452
        $cache = new Cache($this->builder, 'assets');
453
        $cacheKey = $cache->createKeyFromValue(null, $value);
454
        if (!$cache->has($cacheKey)) {
455
            $minifier = new Minify\CSS($value);
456
            $value = $minifier->minify();
457
            $cache->set($cacheKey, $value, $this->config->get('cache.assets.ttl'));
458
        }
459
460
        return $cache->get($cacheKey, $value);
461
    }
462
463
    /**
464
     * Minifying a JavaScript string.
465
     */
466
    public function minifyJs(?string $value): string
467
    {
468
        $value = $value ?? '';
469
470
        if ($this->builder->isDebug()) {
471
            return $value;
472
        }
473
474
        $cache = new Cache($this->builder, 'assets');
475
        $cacheKey = $cache->createKeyFromValue(null, $value);
476
        if (!$cache->has($cacheKey)) {
477
            $minifier = new Minify\JS($value);
478
            $value = $minifier->minify();
479
            $cache->set($cacheKey, $value, $this->config->get('cache.assets.ttl'));
480
        }
481
482
        return $cache->get($cacheKey, $value);
483
    }
484
485
    /**
486
     * Compiles a SCSS string.
487
     *
488
     * @throws RuntimeException
489
     */
490 1
    public function scssToCss(?string $value): string
491
    {
492 1
        $value = $value ?? '';
493
494 1
        $cache = new Cache($this->builder, 'assets');
495 1
        $cacheKey = $cache->createKeyFromValue(null, $value);
496 1
        if (!$cache->has($cacheKey)) {
497 1
            $scssPhp = new Compiler();
498 1
            $outputStyles = ['expanded', 'compressed'];
499 1
            $outputStyle = strtolower((string) $this->config->get('assets.compile.style'));
500 1
            if (!\in_array($outputStyle, $outputStyles)) {
501
                throw new ConfigException(\sprintf('"%s" value must be "%s".', 'assets.compile.style', implode('" or "', $outputStyles)));
502
            }
503 1
            $scssPhp->setOutputStyle($outputStyle == 'compressed' ? OutputStyle::COMPRESSED : OutputStyle::EXPANDED);
504 1
            $variables = $this->config->get('assets.compile.variables');
505 1
            if (!empty($variables)) {
506 1
                $variables = array_map('ScssPhp\ScssPhp\ValueConverter::parseValue', $variables);
507 1
                $scssPhp->replaceVariables($variables);
508
            }
509 1
            $value = $scssPhp->compileString($value)->getCss();
510 1
            $cache->set($cacheKey, $value, $this->config->get('cache.assets.ttl'));
511
        }
512
513 1
        return $cache->get($cacheKey, $value);
514
    }
515
516
    /**
517
     * Creates the HTML element of an asset.
518
     *
519
     * $options[
520
     *     'preload'    => false,
521
     *     'responsive' => false,
522
     *     'formats'    => [],
523
     * ];
524
     *
525
     * @throws RuntimeException
526
     */
527 1
    public function html(array $context, Asset $asset, array $attributes = [], array $options = []): string
528
    {
529 1
        $htmlAttributes = '';
530 1
        $preload = false;
531 1
        $responsive = $this->config->isEnabled('layouts.images.responsive');
532 1
        $formats = (array) $this->config->get('layouts.images.formats');
533 1
        extract($options, EXTR_IF_EXISTS);
534
535
        // builds HTML attributes
536 1
        foreach ($attributes as $name => $value) {
537 1
            $attribute = \sprintf(' %s="%s"', $name, $value);
538 1
            if (!isset($value)) {
539
                $attribute = \sprintf(' %s', $name);
540
            }
541 1
            $htmlAttributes .= $attribute;
542
        }
543
544
        // be sure Asset file is saved
545 1
        $asset->save();
546
547
        // CSS or JavaScript
548 1
        switch ($asset['ext']) {
549 1
            case 'css':
550 1
                if ($preload) {
551
                    return \sprintf(
552
                        '<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>',
553
                        $this->url($context, $asset, $options),
554
                        $htmlAttributes
555
                    );
556
                }
557
558 1
                return \sprintf('<link rel="stylesheet" href="%s"%s>', $this->url($context, $asset, $options), $htmlAttributes);
559 1
            case 'js':
560 1
                return \sprintf('<script src="%s"%s></script>', $this->url($context, $asset, $options), $htmlAttributes);
561
        }
562
        // image
563 1
        if ($asset['type'] == 'image') {
564
            // responsive
565 1
            $sizes = '';
566
            if (
567 1
                $responsive && $srcset = Image::buildSrcset(
568 1
                    $asset,
569 1
                    $this->config->getAssetsImagesWidths()
570 1
                )
571
            ) {
572 1
                $htmlAttributes .= \sprintf(' srcset="%s"', $srcset);
573 1
                $sizes = Image::getSizes($attributes['class'] ?? '', $this->config->getAssetsImagesSizes());
574 1
                $htmlAttributes .= \sprintf(' sizes="%s"', $sizes);
575 1
                if ($asset['width'] > max($this->config->getAssetsImagesWidths())) {
576
                    $asset = $asset->resize(max($this->config->getAssetsImagesWidths()));
577
                }
578
            }
579
580
            // <img> element
581 1
            $img = \sprintf(
582 1
                '<img src="%s" width="' . ($asset['width'] ?: '') . '" height="' . ($asset['height'] ?: '') . '"%s>',
583 1
                $this->url($context, $asset, $options),
584 1
                $htmlAttributes
585 1
            );
586
587
            // multiple <source>?
588 1
            if (\count($formats) > 0) {
589 1
                $source = '';
590 1
                foreach ($formats as $format) {
591 1
                    if ($asset['subtype'] != "image/$format" && !Image::isAnimatedGif($asset)) {
592
                        try {
593 1
                            $assetConverted = $asset->convert($format);
594
                            // responsive?
595
                            if ($responsive && $srcset = Image::buildSrcset($assetConverted, $this->config->getAssetsImagesWidths())) {
596
                                // <source> element
597
                                $source .= \sprintf(
598
                                    "\n  <source type=\"image/$format\" srcset=\"%s\" sizes=\"%s\">",
599
                                    $srcset,
600
                                    $sizes
601
                                );
602
                                continue;
603
                            }
604
                            // <source> element
605
                            $source .= \sprintf("\n  <source type=\"image/$format\" srcset=\"%s\">", $assetConverted);
606 1
                        } catch (\Exception $e) {
607 1
                            $this->builder->getLogger()->error($e->getMessage());
608 1
                            continue;
609
                        }
610
                    }
611
                }
612 1
                if (!empty($source)) {
613
                    return \sprintf("<picture>%s\n  %s\n</picture>", $source, $img);
614
                }
615
            }
616
617 1
            return $img;
618
        }
619
620
        throw new RuntimeException(\sprintf('%s is available for CSS, JavaScript and images files only.', '"html" filter'));
621
    }
622
623
    /**
624
     * Builds the HTML img `srcset` (responsive) attribute of an image Asset.
625
     *
626
     * @throws RuntimeException
627
     */
628 1
    public function imageSrcset(Asset $asset): string
629
    {
630 1
        return Image::buildSrcset($asset, $this->config->getAssetsImagesWidths());
631
    }
632
633
    /**
634
     * Returns the HTML img `sizes` attribute based on a CSS class name.
635
     */
636 1
    public function imageSizes(string $class): string
637
    {
638 1
        return Image::getSizes($class, $this->config->getAssetsImagesSizes());
639
    }
640
641
    /**
642
     * Converts an image Asset to WebP format.
643
     */
644
    public function webp(Asset $asset, ?int $quality = null): Asset
645
    {
646
        return $this->convert($asset, 'webp', $quality);
647
    }
648
649
    /**
650
     * Converts an image Asset to AVIF format.
651
     */
652
    public function avif(Asset $asset, ?int $quality = null): Asset
653
    {
654
        return $this->convert($asset, 'avif', $quality);
655
    }
656
657
    /**
658
     * Converts an image Asset to the given format.
659
     *
660
     * @throws RuntimeException
661
     */
662
    private function convert(Asset $asset, string $format, ?int $quality = null): Asset
663
    {
664
        if ($asset['subtype'] == "image/$format") {
665
            return $asset;
666
        }
667
        if (Image::isAnimatedGif($asset)) {
668
            throw new RuntimeException(\sprintf('Can\'t convert the animated GIF "%s" to %s.', $asset['path'], $format));
669
        }
670
671
        try {
672
            return $asset->$format($quality);
673
        } catch (\Exception $e) {
674
            throw new RuntimeException(\sprintf('Can\'t convert "%s" to %s (%s).', $asset['path'], $format, $e->getMessage()));
675
        }
676
    }
677
678
    /**
679
     * Returns the content of an asset.
680
     */
681 1
    public function inline(Asset $asset): string
682
    {
683 1
        return $asset['content'];
684
    }
685
686
    /**
687
     * Reads $length first characters of a string and adds a suffix.
688
     */
689 1
    public function excerpt(?string $string, int $length = 450, string $suffix = ' …'): string
690
    {
691 1
        $string = $string ?? '';
692
693 1
        $string = str_replace('</p>', '<br><br>', $string);
694 1
        $string = trim(strip_tags($string, '<br>'));
695 1
        if (mb_strlen($string) > $length) {
696 1
            $string = mb_substr($string, 0, $length);
697 1
            $string .= $suffix;
698
        }
699
700 1
        return $string;
701
    }
702
703
    /**
704
     * Reads characters before or after '<!-- separator -->'.
705
     * Options:
706
     *  - separator: string to use as separator (`excerpt|break` by default)
707
     *  - capture: part to capture, `before` or `after` the separator (`before` by default).
708
     */
709 1
    public function excerptHtml(?string $string, array $options = []): string
710
    {
711 1
        $string = $string ?? '';
712
713 1
        $separator = (string) $this->config->get('pages.body.excerpt.separator');
714 1
        $capture = (string) $this->config->get('pages.body.excerpt.capture');
715 1
        extract($options, EXTR_IF_EXISTS);
716
717
        // https://regex101.com/r/n9TWHF/1
718 1
        $pattern = '(.*)<!--[[:blank:]]?(' . $separator . ')[[:blank:]]?-->(.*)';
719 1
        preg_match('/' . $pattern . '/is', $string, $matches);
720
721 1
        if (empty($matches)) {
722
            return $string;
723
        }
724 1
        $result = trim($matches[1]);
725 1
        if ($capture == 'after') {
726 1
            $result = trim($matches[3]);
727
        }
728
        // removes footnotes and returns result
729 1
        return preg_replace('/<sup[^>]*>[^u]*<\/sup>/', '', $result);
730
    }
731
732
    /**
733
     * Converts a Markdown string to HTML.
734
     *
735
     * @throws RuntimeException
736
     */
737 1
    public function markdownToHtml(?string $markdown): ?string
738
    {
739 1
        $markdown = $markdown ?? '';
740
741
        try {
742 1
            $parsedown = new Parsedown($this->builder);
743 1
            $html = $parsedown->text($markdown);
744
        } catch (\Exception $e) {
745
            throw new RuntimeException(
746
                '"markdown_to_html" filter can not convert supplied Markdown.',
747
                previous: $e
748
            );
749
        }
750
751 1
        return $html;
752
    }
753
754
    /**
755
     * Extract table of content of a Markdown string,
756
     * in the given format ("html" or "json", "html" by default).
757
     *
758
     * @throws RuntimeException
759
     */
760 1
    public function markdownToToc(?string $markdown, $format = 'html', ?array $selectors = null, string $url = ''): ?string
761
    {
762 1
        $markdown = $markdown ?? '';
763 1
        $selectors = $selectors ?? (array) $this->config->get('pages.body.toc');
764
765
        try {
766 1
            $parsedown = new Parsedown($this->builder, ['selectors' => $selectors, 'url' => $url]);
767 1
            $parsedown->body($markdown);
768 1
            $return = $parsedown->contentsList($format);
769
        } catch (\Exception) {
770
            throw new RuntimeException('"toc" filter can not convert supplied Markdown.');
771
        }
772
773 1
        return $return;
774
    }
775
776
    /**
777
     * Converts a JSON string to an array.
778
     *
779
     * @throws RuntimeException
780
     */
781 1
    public function jsonDecode(?string $json): ?array
782
    {
783 1
        $json = $json ?? '';
784
785
        try {
786 1
            $array = json_decode($json, true);
787 1
            if ($array === null && json_last_error() !== JSON_ERROR_NONE) {
788 1
                throw new \Exception('JSON error.');
789
            }
790
        } catch (\Exception) {
791
            throw new RuntimeException('"json_decode" filter can not parse supplied JSON.');
792
        }
793
794 1
        return $array;
795
    }
796
797
    /**
798
     * Converts a YAML string to an array.
799
     *
800
     * @throws RuntimeException
801
     */
802 1
    public function yamlParse(?string $yaml): ?array
803
    {
804 1
        $yaml = $yaml ?? '';
805
806
        try {
807 1
            $array = Yaml::parse($yaml, Yaml::PARSE_DATETIME);
808 1
            if (!\is_array($array)) {
809 1
                throw new ParseException('YAML error.');
810
            }
811
        } catch (ParseException $e) {
812
            throw new RuntimeException(\sprintf('"yaml_parse" filter can not parse supplied YAML: %s', $e->getMessage()));
813
        }
814
815 1
        return $array;
816
    }
817
818
    /**
819
     * Split a string into an array using a regular expression.
820
     *
821
     * @throws RuntimeException
822
     */
823
    public function pregSplit(?string $value, string $pattern, int $limit = 0): ?array
824
    {
825
        $value = $value ?? '';
826
827
        try {
828
            $array = preg_split($pattern, $value, $limit);
829
            if ($array === false) {
830
                throw new RuntimeException('PREG split error.');
831
            }
832
        } catch (\Exception) {
833
            throw new RuntimeException('"preg_split" filter can not split supplied string.');
834
        }
835
836
        return $array;
837
    }
838
839
    /**
840
     * Perform a regular expression match and return the group for all matches.
841
     *
842
     * @throws RuntimeException
843
     */
844
    public function pregMatchAll(?string $value, string $pattern, int $group = 0): ?array
845
    {
846
        $value = $value ?? '';
847
848
        try {
849
            $array = preg_match_all($pattern, $value, $matches, PREG_PATTERN_ORDER);
850
            if ($array === false) {
851
                throw new RuntimeException('PREG match all error.');
852
            }
853
        } catch (\Exception) {
854
            throw new RuntimeException('"preg_match_all" filter can not match in supplied string.');
855
        }
856
857
        return $matches[$group];
858
    }
859
860
    /**
861
     * Calculates estimated time to read a text.
862
     */
863 1
    public function readtime(?string $text): string
864
    {
865 1
        $text = $text ?? '';
866
867 1
        $words = str_word_count(strip_tags($text));
868 1
        $min = floor($words / 200);
869 1
        if ($min === 0) {
870
            return '1';
871
        }
872
873 1
        return (string) $min;
874
    }
875
876
    /**
877
     * Gets the value of an environment variable.
878
     */
879 1
    public function getEnv(?string $var): ?string
880
    {
881 1
        $var = $var ?? '';
882
883 1
        return getenv($var) ?: null;
884
    }
885
886
    /**
887
     * Dump variable (or Twig context).
888
     */
889 1
    public function varDump(\Twig\Environment $env, array $context, $var = null, ?array $options = null): void
890
    {
891 1
        if (!$env->isDebug()) {
892
            return;
893
        }
894
895 1
        if ($var === null) {
896
            $var = array();
897
            foreach ($context as $key => $value) {
898
                if (!$value instanceof \Twig\Template && !$value instanceof \Twig\TemplateWrapper) {
899
                    $var[$key] = $value;
900
                }
901
            }
902
        }
903
904 1
        $cloner = new VarCloner();
905 1
        $cloner->setMinDepth(3);
906 1
        $dumper = new HtmlDumper();
907 1
        $dumper->setTheme($options['theme'] ?? 'light');
908
909 1
        $data = $cloner->cloneVar($var)->withMaxDepth(3);
910 1
        $dumper->dump($data, null, ['maxDepth' => 3]);
911
    }
912
913
    /**
914
     * Tests if a variable is an Asset.
915
     */
916 1
    public function isAsset($variable): bool
917
    {
918 1
        return $variable instanceof Asset;
919
    }
920
921
    /**
922
     * Returns the dominant hex color of an image asset.
923
     *
924
     * @param string|Asset $asset
925
     *
926
     * @return string
927
     */
928 1
    public function dominantColor($asset): string
929
    {
930 1
        if (!$asset instanceof Asset) {
931
            $asset = new Asset($this->builder, $asset);
932
        }
933
934 1
        return Image::getDominantColor($asset);
935
    }
936
937
    /**
938
     * Returns a Low Quality Image Placeholder (LQIP) as data URL.
939
     *
940
     * @param string|Asset $asset
941
     *
942
     * @return string
943
     */
944 1
    public function lqip($asset): string
945
    {
946 1
        if (!$asset instanceof Asset) {
947
            $asset = new Asset($this->builder, $asset);
948
        }
949
950 1
        return Image::getLqip($asset);
951
    }
952
953
    /**
954
     * Converts an hexadecimal color to RGB.
955
     *
956
     * @throws RuntimeException
957
     */
958 1
    public function hexToRgb(?string $variable): array
959
    {
960 1
        $variable = $variable ?? '';
961
962 1
        if (!self::isHex($variable)) {
963
            throw new RuntimeException(\sprintf('"%s" is not a valid hexadecimal value.', $variable));
964
        }
965 1
        $hex = ltrim($variable, '#');
966 1
        if (\strlen($hex) == 3) {
967
            $hex = $hex[0] . $hex[0] . $hex[1] . $hex[1] . $hex[2] . $hex[2];
968
        }
969 1
        $c = hexdec($hex);
970
971 1
        return [
972 1
            'red'   => $c >> 16 & 0xFF,
973 1
            'green' => $c >> 8 & 0xFF,
974 1
            'blue'  => $c & 0xFF,
975 1
        ];
976
    }
977
978
    /**
979
     * Split a string in multiple lines.
980
     */
981 1
    public function splitLine(?string $variable, int $max = 18): array
982
    {
983 1
        $variable = $variable ?? '';
984
985 1
        return preg_split("/.{0,{$max}}\K(\s+|$)/", $variable, 0, PREG_SPLIT_NO_EMPTY);
986
    }
987
988
    /**
989
     * Hashing an object, an array or a string (with algo, md5 by default).
990
     */
991
    public function hash(object|array|string $data, $algo = 'md5'): string
992
    {
993
        switch (\gettype($data)) {
994
            case 'object':
995
                return spl_object_hash($data);
996
            case 'array':
997
                return hash($algo, serialize($data));
998
        }
999
1000
        return hash($algo, $data);
1001
    }
1002
1003
    /**
1004
     * Converts a variable to an iterable (array).
1005
     */
1006 1
    public function iterable($value): array
1007
    {
1008 1
        if (\is_array($value)) {
1009 1
            return $value;
1010
        }
1011
        if (\is_string($value)) {
1012
            return [$value];
1013
        }
1014
        if ($value instanceof \Traversable) {
1015
            return iterator_to_array($value);
1016
        }
1017
        if ($value instanceof \stdClass) {
1018
            return (array) $value;
1019
        }
1020
        if (\is_object($value)) {
1021
            return [$value];
1022
        }
1023
        if (\is_int($value) || \is_float($value)) {
1024
            return [$value];
1025
        }
1026
        return [$value];
1027
    }
1028
1029
    /**
1030
     * Highlights a code snippet.
1031
     */
1032
    public function highlight(string $code, string $language): string
1033
    {
1034
        return (new Highlighter())->highlight($language, $code)->value;
1035
    }
1036
1037
    /**
1038
     * Is a hexadecimal color is valid?
1039
     */
1040 1
    private static function isHex(string $hex): bool
1041
    {
1042 1
        $valid = \is_string($hex);
1043 1
        $hex = ltrim($hex, '#');
1044 1
        $length = \strlen($hex);
1045 1
        $valid = $valid && ($length === 3 || $length === 6);
1046 1
        $valid = $valid && ctype_xdigit($hex);
1047
1048 1
        return $valid;
1049
    }
1050
}
1051