Passed
Pull Request — master (#2256)
by Arnaud
15:27 queued 09:22
created

Core::html()   F

Complexity

Conditions 12
Paths 436

Size

Total Lines 55
Code Lines 38

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 37
CRAP Score 12.0193

Importance

Changes 4
Bugs 2 Features 0
Metric Value
cc 12
eloc 38
c 4
b 2
f 0
nc 436
nop 4
dl 0
loc 55
ccs 37
cts 39
cp 0.9487
crap 12.0193
rs 3.5833

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('video', [$this, 'htmlVideo'], ['needs_context' => true]),
94 1
            new \Twig\TwigFunction('integrity', [$this, 'integrity']),
95 1
            new \Twig\TwigFunction('image_srcset', [$this, 'imageSrcset']),
96 1
            new \Twig\TwigFunction('image_sizes', [$this, 'imageSizes']),
97 1
            new \Twig\TwigFunction('image_from_url', [$this, 'htmlImageFromUrl'], ['needs_context' => true]),
98
            // content
99 1
            new \Twig\TwigFunction('readtime', [$this, 'readtime']),
100 1
            new \Twig\TwigFunction('hash', [$this, 'hash']),
101
            // others
102 1
            new \Twig\TwigFunction('getenv', [$this, 'getEnv']),
103 1
            new \Twig\TwigFunction('d', [$this, 'varDump'], ['needs_context' => true, 'needs_environment' => true]),
104
            // deprecated
105 1
            new \Twig\TwigFunction(
106 1
                'minify',
107 1
                [$this, 'minify'],
108 1
                ['deprecation_info' => new DeprecatedCallableInfo('', '', 'minify filter')]
109 1
            ),
110 1
            new \Twig\TwigFunction(
111 1
                'toCSS',
112 1
                [$this, 'toCss'],
113 1
                ['deprecation_info' => new DeprecatedCallableInfo('', '', 'to_css filter')]
114 1
            ),
115 1
        ];
116
    }
117
118
    /**
119
     * {@inheritdoc}
120
     */
121 1
    public function getFilters(): array
122
    {
123 1
        return [
124 1
            new \Twig\TwigFilter('url', [$this, 'url'], ['needs_context' => true]),
125
            // collections
126 1
            new \Twig\TwigFilter('sort_by_title', [$this, 'sortByTitle']),
127 1
            new \Twig\TwigFilter('sort_by_weight', [$this, 'sortByWeight']),
128 1
            new \Twig\TwigFilter('sort_by_date', [$this, 'sortByDate']),
129 1
            new \Twig\TwigFilter('filter_by', [$this, 'filterBy']),
130
            // assets
131 1
            new \Twig\TwigFilter('inline', [$this, 'inline']),
132 1
            new \Twig\TwigFilter('fingerprint', [$this, 'fingerprint']),
133 1
            new \Twig\TwigFilter('to_css', [$this, 'toCss']),
134 1
            new \Twig\TwigFilter('minify', [$this, 'minify']),
135 1
            new \Twig\TwigFilter('minify_css', [$this, 'minifyCss']),
136 1
            new \Twig\TwigFilter('minify_js', [$this, 'minifyJs']),
137 1
            new \Twig\TwigFilter('scss_to_css', [$this, 'scssToCss']),
138 1
            new \Twig\TwigFilter('sass_to_css', [$this, 'scssToCss']),
139 1
            new \Twig\TwigFilter('resize', [$this, 'resize']),
140 1
            new \Twig\TwigFilter('cover', [$this, 'cover']),
141 1
            new \Twig\TwigFilter('maskable', [$this, 'maskable']),
142 1
            new \Twig\TwigFilter('dataurl', [$this, 'dataurl']),
143 1
            new \Twig\TwigFilter('dominant_color', [$this, 'dominantColor']),
144 1
            new \Twig\TwigFilter('lqip', [$this, 'lqip']),
145 1
            new \Twig\TwigFilter('webp', [$this, 'webp']),
146 1
            new \Twig\TwigFilter('avif', [$this, 'avif']),
147
            // content
148 1
            new \Twig\TwigFilter('slugify', [$this, 'slugifyFilter']),
149 1
            new \Twig\TwigFilter('excerpt', [$this, 'excerpt']),
150 1
            new \Twig\TwigFilter('excerpt_html', [$this, 'excerptHtml']),
151 1
            new \Twig\TwigFilter('markdown_to_html', [$this, 'markdownToHtml']),
152 1
            new \Twig\TwigFilter('toc', [$this, 'markdownToToc']),
153 1
            new \Twig\TwigFilter('json_decode', [$this, 'jsonDecode']),
154 1
            new \Twig\TwigFilter('yaml_parse', [$this, 'yamlParse']),
155 1
            new \Twig\TwigFilter('preg_split', [$this, 'pregSplit']),
156 1
            new \Twig\TwigFilter('preg_match_all', [$this, 'pregMatchAll']),
157 1
            new \Twig\TwigFilter('hex_to_rgb', [$this, 'hexToRgb']),
158 1
            new \Twig\TwigFilter('splitline', [$this, 'splitLine']),
159 1
            new \Twig\TwigFilter('iterable', [$this, 'iterable']),
160 1
            new \Twig\TwigFilter('highlight', [$this, 'highlight']),
161 1
            new \Twig\TwigFilter('unique', [$this, 'unique']),
162
            // date
163 1
            new \Twig\TwigFilter('duration_to_iso8601', ['\Cecil\Util\Date', 'durationToIso8601']),
164
            // deprecated
165 1
            new \Twig\TwigFilter(
166 1
                'html',
167 1
                [$this, 'html'],
168 1
                [
169 1
                    'needs_context' => true,
170 1
                    'deprecation_info' => new DeprecatedCallableInfo('', '', 'html function')
171 1
                ]
172 1
            ),
173 1
        ];
174
    }
175
176
    /**
177
     * {@inheritdoc}
178
     */
179 1
    public function getTests()
180
    {
181 1
        return [
182 1
            new \Twig\TwigTest('asset', [$this, 'isAsset']),
183 1
            new \Twig\TwigTest('image_large', [$this, 'isImageLarge']),
184 1
            new \Twig\TwigTest('image_square', [$this, 'isImageSquare']),
185 1
        ];
186
    }
187
188
    /**
189
     * Filters by Section.
190
     */
191
    public function filterBySection(PagesCollection $pages, string $section): CollectionInterface
192
    {
193
        return $this->filterBy($pages, 'section', $section);
194
    }
195
196
    /**
197
     * Filters a pages collection by variable's name/value.
198
     */
199 1
    public function filterBy(PagesCollection $pages, string $variable, string $value): CollectionInterface
200
    {
201 1
        $filteredPages = $pages->filter(function (Page $page) use ($variable, $value) {
202
            // is a dedicated getter exists?
203 1
            $method = 'get' . ucfirst($variable);
204 1
            if (method_exists($page, $method) && $page->$method() == $value) {
205
                return $page->getType() == Type::PAGE->value && !$page->isVirtual() && true;
206
            }
207
            // or a classic variable
208 1
            if ($page->getVariable($variable) == $value) {
209 1
                return $page->getType() == Type::PAGE->value && !$page->isVirtual() && true;
210
            }
211 1
        });
212
213 1
        return $filteredPages;
214
    }
215
216
    /**
217
     * Sorts a collection by title.
218
     */
219 1
    public function sortByTitle(\Traversable $collection): array
220
    {
221 1
        $sort = \SORT_ASC;
222
223 1
        $collection = iterator_to_array($collection);
224 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

224
        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

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