Passed
Push — refactor ( ca8638...786d9c )
by Arnaud
10:46 queued 07:07
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\Asset;
17
use Cecil\Asset\Image;
18
use Cecil\Builder;
19
use Cecil\Cache;
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
            new \Twig\TwigTest('image_large', [$this, 'isImageLarge']),
177 1
            new \Twig\TwigTest('image_square', [$this, 'isImageSquare']),
178 1
        ];
179
    }
180
181
    /**
182
     * Filters by Section.
183
     */
184
    public function filterBySection(PagesCollection $pages, string $section): CollectionInterface
185
    {
186
        return $this->filterBy($pages, 'section', $section);
187
    }
188
189
    /**
190
     * Filters a pages collection by variable's name/value.
191
     */
192 1
    public function filterBy(PagesCollection $pages, string $variable, string $value): CollectionInterface
193
    {
194 1
        $filteredPages = $pages->filter(function (Page $page) use ($variable, $value) {
195
            // is a dedicated getter exists?
196 1
            $method = 'get' . ucfirst($variable);
197 1
            if (method_exists($page, $method) && $page->$method() == $value) {
198
                return $page->getType() == Type::PAGE->value && !$page->isVirtual() && true;
199
            }
200
            // or a classic variable
201 1
            if ($page->getVariable($variable) == $value) {
202 1
                return $page->getType() == Type::PAGE->value && !$page->isVirtual() && true;
203
            }
204 1
        });
205
206 1
        return $filteredPages;
207
    }
208
209
    /**
210
     * Sorts a collection by title.
211
     */
212 1
    public function sortByTitle(\Traversable $collection): array
213
    {
214 1
        $sort = \SORT_ASC;
215
216 1
        $collection = iterator_to_array($collection);
217 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

217
        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

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