Core::html()   F
last analyzed

Complexity

Conditions 13
Paths 580

Size

Total Lines 58
Code Lines 41

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 40
CRAP Score 13.0182

Importance

Changes 3
Bugs 1 Features 0
Metric Value
cc 13
eloc 41
c 3
b 1
f 0
nc 580
nop 4
dl 0
loc 58
ccs 40
cts 42
cp 0.9524
crap 13.0182
rs 3.0333

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 Cecil\Util;
30
use Cecil\Util\Html;
31
use Cocur\Slugify\Bridge\Twig\SlugifyExtension;
32
use Cocur\Slugify\Slugify;
33
use Highlight\Highlighter;
34
use MatthiasMullie\Minify;
35
use ScssPhp\ScssPhp\Compiler;
36
use ScssPhp\ScssPhp\OutputStyle;
37
use Symfony\Component\VarDumper\Cloner\VarCloner;
38
use Symfony\Component\VarDumper\Dumper\HtmlDumper;
39
use Symfony\Component\Yaml\Exception\ParseException;
40
use Symfony\Component\Yaml\Yaml;
41
use Twig\DeprecatedCallableInfo;
42
43
/**
44
 * Core Twig extension.
45
 *
46
 * This extension provides various utility functions and filters for use in Twig templates,
47
 * including URL generation, asset management, content processing, and more.
48
 */
49
class Core extends SlugifyExtension
50
{
51
    /** @var Builder */
52
    protected $builder;
53
54
    /** @var Config */
55
    protected $config;
56
57
    /** @var Slugify */
58
    private static $slugifier;
59
60 1
    public function __construct(Builder $builder)
61
    {
62 1
        if (!self::$slugifier instanceof Slugify) {
63 1
            self::$slugifier = Slugify::create(['regexp' => Page::SLUGIFY_PATTERN]);
64
        }
65
66 1
        parent::__construct(self::$slugifier);
67
68 1
        $this->builder = $builder;
69 1
        $this->config = $builder->getConfig();
70
    }
71
72
    /**
73
     * {@inheritdoc}
74
     */
75
    public function getName(): string
76
    {
77
        return 'CoreExtension';
78
    }
79
80
    /**
81
     * {@inheritdoc}
82
     */
83 1
    public function getFunctions()
84
    {
85 1
        return [
86 1
            new \Twig\TwigFunction('url', [$this, 'url'], ['needs_context' => true]),
87
            // assets
88 1
            new \Twig\TwigFunction('asset', [$this, 'asset']),
89 1
            new \Twig\TwigFunction('html', [$this, 'html'], ['needs_context' => true]),
90 1
            new \Twig\TwigFunction('css', [$this, 'htmlCss'], ['needs_context' => true]),
91 1
            new \Twig\TwigFunction('js', [$this, 'htmlJs'], ['needs_context' => true]),
92 1
            new \Twig\TwigFunction('image', [$this, 'htmlImage'], ['needs_context' => true]),
93 1
            new \Twig\TwigFunction('audio', [$this, 'htmlAudio'], ['needs_context' => true]),
94 1
            new \Twig\TwigFunction('video', [$this, 'htmlVideo'], ['needs_context' => true]),
95 1
            new \Twig\TwigFunction('integrity', [$this, 'integrity']),
96 1
            new \Twig\TwigFunction('image_srcset', [$this, 'imageSrcset']),
97 1
            new \Twig\TwigFunction('image_sizes', [$this, 'imageSizes']),
98 1
            new \Twig\TwigFunction('image_from_url', [$this, 'htmlImageFromUrl'], ['needs_context' => true]),
99
            // content
100 1
            new \Twig\TwigFunction('readtime', [$this, 'readtime']),
101 1
            new \Twig\TwigFunction('hash', [$this, 'hash']),
102
            // others
103 1
            new \Twig\TwigFunction('getenv', [$this, 'getEnv']),
104 1
            new \Twig\TwigFunction('d', [$this, 'varDump'], ['needs_context' => true, 'needs_environment' => true]),
105
            // deprecated
106 1
            new \Twig\TwigFunction(
107 1
                'minify',
108 1
                [$this, 'minify'],
109 1
                ['deprecation_info' => new DeprecatedCallableInfo('', '', 'minify filter')]
110 1
            ),
111 1
            new \Twig\TwigFunction(
112 1
                'toCSS',
113 1
                [$this, 'toCss'],
114 1
                ['deprecation_info' => new DeprecatedCallableInfo('', '', 'to_css filter')]
115 1
            ),
116 1
        ];
117
    }
118
119
    /**
120
     * {@inheritdoc}
121
     */
122 1
    public function getFilters(): array
123
    {
124 1
        return [
125 1
            new \Twig\TwigFilter('url', [$this, 'url'], ['needs_context' => true]),
126
            // collections
127 1
            new \Twig\TwigFilter('sort_by_title', [$this, 'sortByTitle']),
128 1
            new \Twig\TwigFilter('sort_by_weight', [$this, 'sortByWeight']),
129 1
            new \Twig\TwigFilter('sort_by_date', [$this, 'sortByDate']),
130 1
            new \Twig\TwigFilter('filter_by', [$this, 'filterBy']),
131
            // assets
132 1
            new \Twig\TwigFilter('inline', [$this, 'inline']),
133 1
            new \Twig\TwigFilter('fingerprint', [$this, 'fingerprint']),
134 1
            new \Twig\TwigFilter('to_css', [$this, 'toCss']),
135 1
            new \Twig\TwigFilter('minify', [$this, 'minify']),
136 1
            new \Twig\TwigFilter('minify_css', [$this, 'minifyCss']),
137 1
            new \Twig\TwigFilter('minify_js', [$this, 'minifyJs']),
138 1
            new \Twig\TwigFilter('scss_to_css', [$this, 'scssToCss']),
139 1
            new \Twig\TwigFilter('sass_to_css', [$this, 'scssToCss']),
140 1
            new \Twig\TwigFilter('resize', [$this, 'resize']),
141 1
            new \Twig\TwigFilter('cover', [$this, 'cover']),
142 1
            new \Twig\TwigFilter('maskable', [$this, 'maskable']),
143 1
            new \Twig\TwigFilter('dataurl', [$this, 'dataurl']),
144 1
            new \Twig\TwigFilter('dominant_color', [$this, 'dominantColor']),
145 1
            new \Twig\TwigFilter('lqip', [$this, 'lqip']),
146 1
            new \Twig\TwigFilter('webp', [$this, 'webp']),
147 1
            new \Twig\TwigFilter('avif', [$this, 'avif']),
148
            // content
149 1
            new \Twig\TwigFilter('slugify', [$this, 'slugifyFilter']),
150 1
            new \Twig\TwigFilter('excerpt', [$this, 'excerpt']),
151 1
            new \Twig\TwigFilter('excerpt_html', [$this, 'excerptHtml']),
152 1
            new \Twig\TwigFilter('markdown_to_html', [$this, 'markdownToHtml']),
153 1
            new \Twig\TwigFilter('toc', [$this, 'markdownToToc']),
154 1
            new \Twig\TwigFilter('json_decode', [$this, 'jsonDecode']),
155 1
            new \Twig\TwigFilter('yaml_parse', [$this, 'yamlParse']),
156 1
            new \Twig\TwigFilter('preg_split', [$this, 'pregSplit']),
157 1
            new \Twig\TwigFilter('preg_match_all', [$this, 'pregMatchAll']),
158 1
            new \Twig\TwigFilter('hex_to_rgb', [$this, 'hexToRgb']),
159 1
            new \Twig\TwigFilter('splitline', [$this, 'splitLine']),
160 1
            new \Twig\TwigFilter('iterable', [$this, 'iterable']),
161 1
            new \Twig\TwigFilter('highlight', [$this, 'highlight']),
162 1
            new \Twig\TwigFilter('unique', [$this, 'unique']),
163
            // date
164 1
            new \Twig\TwigFilter('duration_to_iso8601', ['\Cecil\Util\Date', 'durationToIso8601']),
165
            // deprecated
166 1
            new \Twig\TwigFilter(
167 1
                'html',
168 1
                [$this, 'html'],
169 1
                [
170 1
                    'needs_context' => true,
171 1
                    'deprecation_info' => new DeprecatedCallableInfo('', '', 'html function')
172 1
                ]
173 1
            ),
174 1
        ];
175
    }
176
177
    /**
178
     * {@inheritdoc}
179
     */
180 1
    public function getTests()
181
    {
182 1
        return [
183 1
            new \Twig\TwigTest('asset', [$this, 'isAsset']),
184 1
            new \Twig\TwigTest('image_large', [$this, 'isImageLarge']),
185 1
            new \Twig\TwigTest('image_square', [$this, 'isImageSquare']),
186 1
        ];
187
    }
188
189
    /**
190
     * Filters by Section.
191
     */
192
    public function filterBySection(PagesCollection $pages, string $section): CollectionInterface
193
    {
194
        return $this->filterBy($pages, 'section', $section);
195
    }
196
197
    /**
198
     * Filters a pages collection by variable's name/value.
199
     */
200 1
    public function filterBy(PagesCollection $pages, string $variable, string $value): CollectionInterface
201
    {
202 1
        $filteredPages = $pages->filter(function (Page $page) use ($variable, $value) {
203
            // is a dedicated getter exists?
204 1
            $method = 'get' . ucfirst($variable);
205 1
            if (method_exists($page, $method) && $page->$method() == $value) {
206
                return $page->getType() == Type::PAGE->value && !$page->isVirtual() && true;
207
            }
208
            // or a classic variable
209 1
            if ($page->getVariable($variable) == $value) {
210 1
                return $page->getType() == Type::PAGE->value && !$page->isVirtual() && true;
211
            }
212 1
        });
213
214 1
        return $filteredPages;
215
    }
216
217
    /**
218
     * Sorts a collection by title.
219
     */
220 1
    public function sortByTitle(\Traversable $collection): array
221
    {
222 1
        $sort = \SORT_ASC;
223
224 1
        $collection = iterator_to_array($collection);
225 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

225
        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

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