Core::html()   D
last analyzed

Complexity

Conditions 18
Paths 39

Size

Total Lines 93
Code Lines 55

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 41
CRAP Score 26.1581

Importance

Changes 2
Bugs 1 Features 0
Metric Value
cc 18
eloc 55
c 2
b 1
f 0
nc 39
nop 4
dl 0
loc 93
ccs 41
cts 58
cp 0.7069
crap 26.1581
rs 4.8666

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
            // if responsive is enabled
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 formats (`<source>`)?
591 1
            if (\count($formats) > 0) {
592 1
                $source = '';
593 1
                foreach ($formats as $format) {
594
                    try {
595 1
                        $assetConverted = $asset->convert($format);
596
                        // if responsive is enabled
597
                        if ($responsive && $srcset = Image::buildHtmlSrcset($assetConverted, $this->config->getAssetsImagesWidths())) {
598
                            // `<source>` element
599
                            $source .= \sprintf(
600
                                "\n  <source type=\"image/$format\" srcset=\"%s\" sizes=\"%s\">",
601
                                $srcset,
602
                                $sizes
603
                            );
604
                            continue;
605
                        }
606
                        // `<source>` element
607
                        $source .= \sprintf("\n  <source type=\"image/$format\" srcset=\"%s\">", $assetConverted);
608 1
                    } catch (\Exception $e) {
609 1
                        $this->builder->getLogger()->error($e->getMessage());
610 1
                        continue;
611
                    }
612
                }
613
                // put `<source>` in `<picture>`
614 1
                if (!empty($source)) {
615
                    return \sprintf("<picture>%s\n  %s\n</picture>", $source, $img);
616
                }
617
            }
618
619 1
            return $img;
620
        }
621
622
        throw new RuntimeException(\sprintf('%s is available for CSS, JavaScript and images files only.', '"html" filter'));
623
    }
624
625
    /**
626
     * Builds the HTML img `srcset` (responsive) attribute of an image Asset.
627
     *
628
     * @throws RuntimeException
629
     */
630 1
    public function imageSrcset(Asset $asset): string
631
    {
632 1
        return Image::buildHtmlSrcset($asset, $this->config->getAssetsImagesWidths());
633
    }
634
635
    /**
636
     * Returns the HTML img `sizes` attribute based on a CSS class name.
637
     */
638 1
    public function imageSizes(string $class): string
639
    {
640 1
        return Image::getHtmlSizes($class, $this->config->getAssetsImagesSizes());
641
    }
642
643
    /**
644
     * Converts an image Asset to WebP format.
645
     */
646
    public function webp(Asset $asset, ?int $quality = null): Asset
647
    {
648
        return $this->convert($asset, 'webp', $quality);
649
    }
650
651
    /**
652
     * Converts an image Asset to AVIF format.
653
     */
654
    public function avif(Asset $asset, ?int $quality = null): Asset
655
    {
656
        return $this->convert($asset, 'avif', $quality);
657
    }
658
659
    /**
660
     * Converts an image Asset to the given format.
661
     *
662
     * @throws RuntimeException
663
     */
664
    private function convert(Asset $asset, string $format, ?int $quality = null): Asset
665
    {
666
        if ($asset['subtype'] == "image/$format") {
667
            return $asset;
668
        }
669
        if (Image::isAnimatedGif($asset)) {
670
            throw new RuntimeException(\sprintf('Can\'t convert the animated GIF "%s" to %s.', $asset['path'], $format));
671
        }
672
673
        try {
674
            return $asset->$format($quality);
675
        } catch (\Exception $e) {
676
            throw new RuntimeException(\sprintf('Can\'t convert "%s" to %s (%s).', $asset['path'], $format, $e->getMessage()));
677
        }
678
    }
679
680
    /**
681
     * Returns the content of an asset.
682
     */
683 1
    public function inline(Asset $asset): string
684
    {
685 1
        return $asset['content'];
686
    }
687
688
    /**
689
     * Reads $length first characters of a string and adds a suffix.
690
     */
691 1
    public function excerpt(?string $string, int $length = 450, string $suffix = ' …'): string
692
    {
693 1
        $string = $string ?? '';
694
695 1
        $string = str_replace('</p>', '<br><br>', $string);
696 1
        $string = trim(strip_tags($string, '<br>'));
697 1
        if (mb_strlen($string) > $length) {
698 1
            $string = mb_substr($string, 0, $length);
699 1
            $string .= $suffix;
700
        }
701
702 1
        return $string;
703
    }
704
705
    /**
706
     * Reads characters before or after '<!-- separator -->'.
707
     * Options:
708
     *  - separator: string to use as separator (`excerpt|break` by default)
709
     *  - capture: part to capture, `before` or `after` the separator (`before` by default).
710
     */
711 1
    public function excerptHtml(?string $string, array $options = []): string
712
    {
713 1
        $string = $string ?? '';
714
715 1
        $separator = (string) $this->config->get('pages.body.excerpt.separator');
716 1
        $capture = (string) $this->config->get('pages.body.excerpt.capture');
717 1
        extract($options, EXTR_IF_EXISTS);
718
719
        // https://regex101.com/r/n9TWHF/1
720 1
        $pattern = '(.*)<!--[[:blank:]]?(' . $separator . ')[[:blank:]]?-->(.*)';
721 1
        preg_match('/' . $pattern . '/is', $string, $matches);
722
723 1
        if (empty($matches)) {
724
            return $string;
725
        }
726 1
        $result = trim($matches[1]);
727 1
        if ($capture == 'after') {
728 1
            $result = trim($matches[3]);
729
        }
730
        // removes footnotes and returns result
731 1
        return preg_replace('/<sup[^>]*>[^u]*<\/sup>/', '', $result);
732
    }
733
734
    /**
735
     * Converts a Markdown string to HTML.
736
     *
737
     * @throws RuntimeException
738
     */
739 1
    public function markdownToHtml(?string $markdown): ?string
740
    {
741 1
        $markdown = $markdown ?? '';
742
743
        try {
744 1
            $parsedown = new Parsedown($this->builder);
745 1
            $html = $parsedown->text($markdown);
746
        } catch (\Exception $e) {
747
            throw new RuntimeException(
748
                '"markdown_to_html" filter can not convert supplied Markdown.',
749
                previous: $e
750
            );
751
        }
752
753 1
        return $html;
754
    }
755
756
    /**
757
     * Extract table of content of a Markdown string,
758
     * in the given format ("html" or "json", "html" by default).
759
     *
760
     * @throws RuntimeException
761
     */
762 1
    public function markdownToToc(?string $markdown, $format = 'html', ?array $selectors = null, string $url = ''): ?string
763
    {
764 1
        $markdown = $markdown ?? '';
765 1
        $selectors = $selectors ?? (array) $this->config->get('pages.body.toc');
766
767
        try {
768 1
            $parsedown = new Parsedown($this->builder, ['selectors' => $selectors, 'url' => $url]);
769 1
            $parsedown->body($markdown);
770 1
            $return = $parsedown->contentsList($format);
771
        } catch (\Exception) {
772
            throw new RuntimeException('"toc" filter can not convert supplied Markdown.');
773
        }
774
775 1
        return $return;
776
    }
777
778
    /**
779
     * Converts a JSON string to an array.
780
     *
781
     * @throws RuntimeException
782
     */
783 1
    public function jsonDecode(?string $json): ?array
784
    {
785 1
        $json = $json ?? '';
786
787
        try {
788 1
            $array = json_decode($json, true);
789 1
            if ($array === null && json_last_error() !== JSON_ERROR_NONE) {
790 1
                throw new \Exception('JSON error.');
791
            }
792
        } catch (\Exception) {
793
            throw new RuntimeException('"json_decode" filter can not parse supplied JSON.');
794
        }
795
796 1
        return $array;
797
    }
798
799
    /**
800
     * Converts a YAML string to an array.
801
     *
802
     * @throws RuntimeException
803
     */
804 1
    public function yamlParse(?string $yaml): ?array
805
    {
806 1
        $yaml = $yaml ?? '';
807
808
        try {
809 1
            $array = Yaml::parse($yaml, Yaml::PARSE_DATETIME);
810 1
            if (!\is_array($array)) {
811 1
                throw new ParseException('YAML error.');
812
            }
813
        } catch (ParseException $e) {
814
            throw new RuntimeException(\sprintf('"yaml_parse" filter can not parse supplied YAML: %s', $e->getMessage()));
815
        }
816
817 1
        return $array;
818
    }
819
820
    /**
821
     * Split a string into an array using a regular expression.
822
     *
823
     * @throws RuntimeException
824
     */
825
    public function pregSplit(?string $value, string $pattern, int $limit = 0): ?array
826
    {
827
        $value = $value ?? '';
828
829
        try {
830
            $array = preg_split($pattern, $value, $limit);
831
            if ($array === false) {
832
                throw new RuntimeException('PREG split error.');
833
            }
834
        } catch (\Exception) {
835
            throw new RuntimeException('"preg_split" filter can not split supplied string.');
836
        }
837
838
        return $array;
839
    }
840
841
    /**
842
     * Perform a regular expression match and return the group for all matches.
843
     *
844
     * @throws RuntimeException
845
     */
846
    public function pregMatchAll(?string $value, string $pattern, int $group = 0): ?array
847
    {
848
        $value = $value ?? '';
849
850
        try {
851
            $array = preg_match_all($pattern, $value, $matches, PREG_PATTERN_ORDER);
852
            if ($array === false) {
853
                throw new RuntimeException('PREG match all error.');
854
            }
855
        } catch (\Exception) {
856
            throw new RuntimeException('"preg_match_all" filter can not match in supplied string.');
857
        }
858
859
        return $matches[$group];
860
    }
861
862
    /**
863
     * Calculates estimated time to read a text.
864
     */
865 1
    public function readtime(?string $text): string
866
    {
867 1
        $text = $text ?? '';
868
869 1
        $words = str_word_count(strip_tags($text));
870 1
        $min = floor($words / 200);
871 1
        if ($min === 0) {
872
            return '1';
873
        }
874
875 1
        return (string) $min;
876
    }
877
878
    /**
879
     * Gets the value of an environment variable.
880
     */
881 1
    public function getEnv(?string $var): ?string
882
    {
883 1
        $var = $var ?? '';
884
885 1
        return getenv($var) ?: null;
886
    }
887
888
    /**
889
     * Dump variable (or Twig context).
890
     */
891 1
    public function varDump(\Twig\Environment $env, array $context, $var = null, ?array $options = null): void
892
    {
893 1
        if (!$env->isDebug()) {
894
            return;
895
        }
896
897 1
        if ($var === null) {
898
            $var = array();
899
            foreach ($context as $key => $value) {
900
                if (!$value instanceof \Twig\Template && !$value instanceof \Twig\TemplateWrapper) {
901
                    $var[$key] = $value;
902
                }
903
            }
904
        }
905
906 1
        $cloner = new VarCloner();
907 1
        $cloner->setMinDepth(3);
908 1
        $dumper = new HtmlDumper();
909 1
        $dumper->setTheme($options['theme'] ?? 'light');
910
911 1
        $data = $cloner->cloneVar($var)->withMaxDepth(3);
912 1
        $dumper->dump($data, null, ['maxDepth' => 3]);
913
    }
914
915
    /**
916
     * Tests if a variable is an Asset.
917
     */
918 1
    public function isAsset($variable): bool
919
    {
920 1
        return $variable instanceof Asset;
921
    }
922
923
    /**
924
     * Tests if an image Asset is large enough to be used as a cover image.
925
     * A large image is defined as having a width >= 600px and height >= 315px.
926
     */
927 1
    public function isImageLarge(Asset $asset): bool
928
    {
929 1
        return $asset['type'] == 'image' && $asset['width'] > $asset['height'] && $asset['width'] >= 600 && $asset['height'] >= 315;
930
    }
931
932
    /**
933
     * Tests if an image Asset is square.
934
     * A square image is defined as having the same width and height.
935
     */
936 1
    public function isImageSquare(Asset $asset): bool
937
    {
938 1
        return $asset['type'] == 'image' && $asset['width'] == $asset['height'];
939
    }
940
941
    /**
942
     * Returns the dominant hex color of an image asset.
943
     *
944
     * @param string|Asset $asset
945
     *
946
     * @return string
947
     */
948 1
    public function dominantColor($asset): string
949
    {
950 1
        if (!$asset instanceof Asset) {
951
            $asset = new Asset($this->builder, $asset);
952
        }
953
954 1
        return Image::getDominantColor($asset);
955
    }
956
957
    /**
958
     * Returns a Low Quality Image Placeholder (LQIP) as data URL.
959
     *
960
     * @param string|Asset $asset
961
     *
962
     * @return string
963
     */
964 1
    public function lqip($asset): string
965
    {
966 1
        if (!$asset instanceof Asset) {
967
            $asset = new Asset($this->builder, $asset);
968
        }
969
970 1
        return Image::getLqip($asset);
971
    }
972
973
    /**
974
     * Converts an hexadecimal color to RGB.
975
     *
976
     * @throws RuntimeException
977
     */
978 1
    public function hexToRgb(?string $variable): array
979
    {
980 1
        $variable = $variable ?? '';
981
982 1
        if (!self::isHex($variable)) {
983
            throw new RuntimeException(\sprintf('"%s" is not a valid hexadecimal value.', $variable));
984
        }
985 1
        $hex = ltrim($variable, '#');
986 1
        if (\strlen($hex) == 3) {
987
            $hex = $hex[0] . $hex[0] . $hex[1] . $hex[1] . $hex[2] . $hex[2];
988
        }
989 1
        $c = hexdec($hex);
990
991 1
        return [
992 1
            'red'   => $c >> 16 & 0xFF,
993 1
            'green' => $c >> 8 & 0xFF,
994 1
            'blue'  => $c & 0xFF,
995 1
        ];
996
    }
997
998
    /**
999
     * Split a string in multiple lines.
1000
     */
1001 1
    public function splitLine(?string $variable, int $max = 18): array
1002
    {
1003 1
        $variable = $variable ?? '';
1004
1005 1
        return preg_split("/.{0,{$max}}\K(\s+|$)/", $variable, 0, PREG_SPLIT_NO_EMPTY);
1006
    }
1007
1008
    /**
1009
     * Hashing an object, an array or a string (with algo, md5 by default).
1010
     */
1011 1
    public function hash(object|array|string $data, $algo = 'md5'): string
1012
    {
1013 1
        switch (\gettype($data)) {
1014 1
            case 'object':
1015 1
                return spl_object_hash($data);
1016
            case 'array':
1017
                return hash($algo, serialize($data));
1018
        }
1019
1020
        return hash($algo, $data);
1021
    }
1022
1023
    /**
1024
     * Converts a variable to an iterable (array).
1025
     */
1026 1
    public function iterable($value): array
1027
    {
1028 1
        if (\is_array($value)) {
1029 1
            return $value;
1030
        }
1031
        if (\is_string($value)) {
1032
            return [$value];
1033
        }
1034
        if ($value instanceof \Traversable) {
1035
            return iterator_to_array($value);
1036
        }
1037
        if ($value instanceof \stdClass) {
1038
            return (array) $value;
1039
        }
1040
        if (\is_object($value)) {
1041
            return [$value];
1042
        }
1043
        if (\is_int($value) || \is_float($value)) {
1044
            return [$value];
1045
        }
1046
        return [$value];
1047
    }
1048
1049
    /**
1050
     * Highlights a code snippet.
1051
     */
1052
    public function highlight(string $code, string $language): string
1053
    {
1054
        return (new Highlighter())->highlight($language, $code)->value;
1055
    }
1056
1057
    /**
1058
     * Returns an array with unique values.
1059
     */
1060 1
    public function unique(array $array): array
1061
    {
1062 1
        return array_intersect_key($array, array_unique(array_map('strtolower', $array), SORT_STRING));
1063
    }
1064
1065
    /**
1066
     * Is a hexadecimal color is valid?
1067
     */
1068 1
    private static function isHex(string $hex): bool
1069
    {
1070 1
        $valid = \is_string($hex);
1071 1
        $hex = ltrim($hex, '#');
1072 1
        $length = \strlen($hex);
1073 1
        $valid = $valid && ($length === 3 || $length === 6);
1074 1
        $valid = $valid && ctype_xdigit($hex);
1075
1076 1
        return $valid;
1077
    }
1078
}
1079