Passed
Push — html-function ( ff685a...d3d147 )
by Arnaud
04:02
created

Core::htmlAttributes()   A

Complexity

Conditions 3
Paths 3

Size

Total Lines 12
Code Lines 7

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 0
CRAP Score 12

Importance

Changes 0
Metric Value
cc 3
eloc 7
nc 3
nop 1
dl 0
loc 12
ccs 0
cts 5
cp 0
crap 12
rs 10
c 0
b 0
f 0
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:string,media:?string}> $assets     Asset or array of assets and associated media query
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 1
     * ];
531
     *
532 1
     * @return string HTML element
533 1
     *
534 1
     * @throws RuntimeException
535 1
     */
536 1
    public function html(array $context, Asset|array $assets, array $attributes = [], array $options = []): string
537
    {
538
        $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, 'media' => null]];
541 1
        }
542
        foreach ($assets as $assetData) {
543
            $asset = $assetData['asset'];
544 1
            $media = $assetData['media'] ?? null;
545
            if (!$asset instanceof Asset) {
546
                $asset = new Asset($this->builder, $asset);
547
            }
548 1
            // media attribute
549
            if ($media !== null) {
550
                $attributes['media'] = $media;
551 1
            }
552 1
            // be sure Asset file is saved
553 1
            $asset->save();
554
            // CSS or JavaScript
555
            switch ($asset['ext']) {
556
                case 'css':
557
                    $html[] = $this->htmlCss($context, $asset, $attributes, $options);
558
                    break;
559
                case 'js':
560
                    $html[] = $this->htmlJs($context, $asset, $attributes, $options);
561 1
            }
562 1
            // image
563 1
            if ($asset['type'] == 'image') {
564
                $html[] = $this->htmlImage($context, $asset, $attributes, $options);
565
            }
566 1
            unset($attributes['media']);
567
        }
568 1
569
        return implode(PHP_EOL, $html);
570 1
571 1
        throw new RuntimeException(\sprintf('%s is available for CSS, JavaScript and image files only.', '"html" filter'));
0 ignored issues
show
Unused Code introduced by
ThrowNode is not reachable.

This check looks for unreachable code. It uses sophisticated control flow analysis techniques to find statements which will never be executed.

Unreachable code is most often the result of return, die or exit statements that have been added for debug purposes.

function fx() {
    try {
        doSomething();
        return true;
    }
    catch (\Exception $e) {
        return false;
    }

    return false;
}

In the above example, the last return false will never be executed, because a return statement has already been met in every possible execution path.

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