Passed
Pull Request — master (#2211)
by
unknown
12:15 queued 06:30
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 1
            new \Twig\TwigFilter('unique', [$this, 'unique']),
155
            // date
156 1
            new \Twig\TwigFilter('duration_to_iso8601', ['\Cecil\Util\Date', 'durationToIso8601']),
157
            // deprecated
158 1
            new \Twig\TwigFilter(
159 1
                'html',
160 1
                [$this, 'html'],
161 1
                [
162 1
                    'needs_context' => true,
163 1
                    'deprecation_info' => new DeprecatedCallableInfo('', '', 'html function')
164 1
                ]
165 1
            ),
166 1
        ];
167
    }
168
169
    /**
170
     * {@inheritdoc}
171
     */
172 1
    public function getTests()
173
    {
174 1
        return [
175 1
            new \Twig\TwigTest('asset', [$this, 'isAsset']),
176 1
        ];
177
    }
178
179
    /**
180
     * Filters by Section.
181
     */
182
    public function filterBySection(PagesCollection $pages, string $section): CollectionInterface
183
    {
184
        return $this->filterBy($pages, 'section', $section);
185
    }
186
187
    /**
188
     * Filters a pages collection by variable's name/value.
189
     */
190 1
    public function filterBy(PagesCollection $pages, string $variable, string $value): CollectionInterface
191
    {
192 1
        $filteredPages = $pages->filter(function (Page $page) use ($variable, $value) {
193
            // is a dedicated getter exists?
194 1
            $method = 'get' . ucfirst($variable);
195 1
            if (method_exists($page, $method) && $page->$method() == $value) {
196
                return $page->getType() == Type::PAGE->value && !$page->isVirtual() && true;
197
            }
198
            // or a classic variable
199 1
            if ($page->getVariable($variable) == $value) {
200 1
                return $page->getType() == Type::PAGE->value && !$page->isVirtual() && true;
201
            }
202 1
        });
203
204 1
        return $filteredPages;
205
    }
206
207
    /**
208
     * Sorts a collection by title.
209
     */
210 1
    public function sortByTitle(\Traversable $collection): array
211
    {
212 1
        $sort = \SORT_ASC;
213
214 1
        $collection = iterator_to_array($collection);
215 1
        array_multisort(array_keys(/** @scrutinizer ignore-type */ $collection), $sort, \SORT_NATURAL | \SORT_FLAG_CASE, $collection);
0 ignored issues
show
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

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

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