Core::htmlImage()   C
last analyzed

Complexity

Conditions 14
Paths 119

Size

Total Lines 64
Code Lines 38

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 27
CRAP Score 19.71

Importance

Changes 2
Bugs 1 Features 0
Metric Value
cc 14
eloc 38
c 2
b 1
f 0
nc 119
nop 4
dl 0
loc 64
ccs 27
cts 39
cp 0.6923
crap 19.71
rs 6.1083

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