Passed
Push — php-8.2 ( c584aa...c3c2f5 )
by Arnaud
03:55
created

Core::html()   F

Complexity

Conditions 12
Paths 436

Size

Total Lines 55
Code Lines 38

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 35
CRAP Score 12.0228

Importance

Changes 3
Bugs 1 Features 0
Metric Value
cc 12
eloc 38
c 3
b 1
f 0
nc 436
nop 4
dl 0
loc 55
ccs 35
cts 37
cp 0.9459
crap 12.0228
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 1
    private static $slugifier;
59
60 1
    public function __construct(Builder $builder)
61 1
    {
62
        if (!self::$slugifier instanceof Slugify) {
63
            self::$slugifier = Slugify::create(['regexp' => Page::SLUGIFY_PATTERN]);
64 1
        }
65
66 1
        parent::__construct(self::$slugifier);
67 1
68
        $this->builder = $builder;
69
        $this->config = $builder->getConfig();
70
    }
71
72
    /**
73
     * {@inheritdoc}
74
     */
75
    public function getName(): string
76
    {
77
        return 'CoreExtension';
78
    }
79
80
    /**
81 1
     * {@inheritdoc}
82
     */
83 1
    public function getFunctions()
84 1
    {
85
        return [
86 1
            new \Twig\TwigFunction('url', [$this, 'url'], ['needs_context' => true]),
87 1
            // 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
            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 1
            // 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
                ['deprecation_info' => new DeprecatedCallableInfo('', '', 'to_css filter')]
114
            ),
115
        ];
116
    }
117
118 1
    /**
119
     * {@inheritdoc}
120 1
     */
121 1
    public function getFilters(): array
122
    {
123 1
        return [
124 1
            new \Twig\TwigFilter('url', [$this, 'url'], ['needs_context' => true]),
125 1
            // collections
126 1
            new \Twig\TwigFilter('sort_by_title', [$this, 'sortByTitle']),
127
            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 1
            // 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
            new \Twig\TwigFilter('lqip', [$this, 'lqip']),
145 1
            new \Twig\TwigFilter('webp', [$this, 'webp']),
146 1
            new \Twig\TwigFilter('avif', [$this, 'avif']),
147 1
            // 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
            new \Twig\TwigFilter('iterable', [$this, 'iterable']),
160 1
            new \Twig\TwigFilter('highlight', [$this, 'highlight']),
161
            new \Twig\TwigFilter('unique', [$this, 'unique']),
162 1
            // date
163 1
            new \Twig\TwigFilter('duration_to_iso8601', ['\Cecil\Util\Date', 'durationToIso8601']),
164 1
            // 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
                ]
172
            ),
173
        ];
174
    }
175
176 1
    /**
177
     * {@inheritdoc}
178 1
     */
179 1
    public function getTests()
180 1
    {
181 1
        return [
182 1
            new \Twig\TwigTest('asset', [$this, 'isAsset']),
183
            new \Twig\TwigTest('image_large', [$this, 'isImageLarge']),
184
            new \Twig\TwigTest('image_square', [$this, 'isImageSquare']),
185
        ];
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 1
    /**
197
     * Filters a pages collection by variable's name/value.
198 1
     */
199
    public function filterBy(PagesCollection $pages, string $variable, string $value): CollectionInterface
200 1
    {
201 1
        $filteredPages = $pages->filter(function (Page $page) use ($variable, $value) {
202
            // is a dedicated getter exists?
203
            $method = 'get' . ucfirst($variable);
204
            if (method_exists($page, $method) && $page->$method() == $value) {
205 1
                return $page->getType() == Type::PAGE->value && !$page->isVirtual() && true;
206 1
            }
207
            // or a classic variable
208 1
            if ($page->getVariable($variable) == $value) {
209
                return $page->getType() == Type::PAGE->value && !$page->isVirtual() && true;
210 1
            }
211
        });
212
213
        return $filteredPages;
214
    }
215
216 1
    /**
217
     * Sorts a collection by title.
218 1
     */
219
    public function sortByTitle(\Traversable $collection): array
220 1
    {
221 1
        $sort = \SORT_ASC;
222
223 1
        $collection = iterator_to_array($collection);
224
        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
        return $collection;
227
    }
228
229
    /**
230
     * Sorts a collection by weight.
231 1
     *
232
     * @param \Traversable|array $collection
233 1
     */
234 1
    public function sortByWeight($collection): array
235 1
    {
236
        $callback = function ($a, $b) {
237 1
            if (!isset($a['weight'])) {
238
                $a['weight'] = 0;
239
            }
240 1
            if (!isset($b['weight'])) {
241 1
                $a['weight'] = 0;
242
            }
243
            if ($a['weight'] == $b['weight']) {
244 1
                return 0;
245 1
            }
246
247 1
            return $a['weight'] < $b['weight'] ? -1 : 1;
248 1
        };
249
250 1
        if (!\is_array($collection)) {
251
            $collection = iterator_to_array($collection);
252 1
        }
253
        usort(/** @scrutinizer ignore-type */ $collection, $callback);
254
255
        return $collection;
256
    }
257
258 1
    /**
259
     * Sorts by creation date (or 'updated' date): the most recent first.
260 1
     */
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
            if ($a[$variable] == $b[$variable]) {
265
                // if dates are equal and "descTitle" is true
266
                if ($descTitle && (isset($a['title']) && isset($b['title']))) {
267 1
                    return strnatcmp($b['title'], $a['title']);
268
                }
269
270 1
                return 0;
271 1
            }
272
273 1
            return $a[$variable] > $b[$variable] ? -1 : 1;
274 1
        };
275
276 1
        $collection = iterator_to_array($collection);
277
        usort(/** @scrutinizer ignore-type */ $collection, $callback);
278
279
        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 1
     * @param Page|Asset|string|null $value
293
     * @param array|null             $options
294 1
     */
295 1
    public function url(array $context, $value = null, ?array $options = null): string
296 1
    {
297
        $optionsLang = [];
298 1
        $optionsLang['language'] = (string) $context['site']['language'];
299
        $options = array_merge($optionsLang, $options ?? []);
300
301
        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 1
     *
310
     * @return Asset
311 1
     */
312
    public function asset($path, array|null $options = null): Asset
313
    {
314
        if (!\is_string($path) && !\is_array($path)) {
315 1
            throw new RuntimeException(\sprintf('Argument of "%s()" must a string or an array.', \Cecil\Util::formatMethodName(__METHOD__)));
316
        }
317
318
        return new Asset($this->builder, $path, $options);
319
    }
320
321
    /**
322
     * Compiles a SCSS asset.
323
     *
324
     * @param string|Asset $asset
325 1
     *
326
     * @return Asset
327 1
     */
328
    public function toCss($asset): Asset
329
    {
330
        if (!$asset instanceof Asset) {
331 1
            $asset = new Asset($this->builder, $asset);
332
        }
333
334
        return $asset->compile();
335
    }
336
337
    /**
338
     * Minifying an asset (CSS or JS).
339
     *
340
     * @param string|Asset $asset
341 1
     *
342
     * @return Asset
343 1
     */
344
    public function minify($asset): Asset
345
    {
346
        if (!$asset instanceof Asset) {
347 1
            $asset = new Asset($this->builder, $asset);
348
        }
349
350
        return $asset->minify();
351
    }
352
353
    /**
354
     * Fingerprinting an asset.
355
     *
356
     * @param string|Asset $asset
357 1
     *
358
     * @return Asset
359 1
     */
360
    public function fingerprint($asset): Asset
361
    {
362
        if (!$asset instanceof Asset) {
363 1
            $asset = new Asset($this->builder, $asset);
364
        }
365
366
        return $asset->fingerprint();
367
    }
368
369
    /**
370
     * Resizes an image.
371
     *
372
     * @param string|Asset $asset
373 1
     *
374
     * @return Asset
375 1
     */
376
    public function resize($asset, int $size): Asset
377
    {
378
        if (!$asset instanceof Asset) {
379 1
            $asset = new Asset($this->builder, $asset);
380
        }
381
382
        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 1
     *
390
     * @return Asset
391 1
     */
392
    public function cover($asset, int $width, int $height): Asset
393
    {
394
        if (!$asset instanceof Asset) {
395 1
            $asset = new Asset($this->builder, $asset);
396
        }
397
398
        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 1
     *
423
     * @return string
424 1
     */
425
    public function dataurl($asset): string
426
    {
427
        if (!$asset instanceof Asset) {
428 1
            $asset = new Asset($this->builder, $asset);
429
        }
430
431
        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 1
     *
440
     * @return string
441 1
     */
442 1
    public function integrity($asset, string $algo = 'sha384'): string
443
    {
444
        if (!$asset instanceof Asset) {
445 1
            $asset = new Asset($this->builder, $asset);
446
        }
447
448
        return $asset->getIntegrity($algo);
449
    }
450
451 1
    /**
452
     * Minifying a CSS string.
453 1
     */
454
    public function minifyCss(?string $value): string
455 1
    {
456 1
        $value = $value ?? '';
457
458
        if ($this->builder->isDebug()) {
459
            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 1
    /**
474
     * Minifying a JavaScript string.
475 1
     */
476
    public function minifyJs(?string $value): string
477 1
    {
478 1
        $value = $value ?? '';
479
480
        if ($this->builder->isDebug()) {
481
            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 1
     *
498
     * @throws RuntimeException
499 1
     */
500
    public function scssToCss(?string $value): string
501 1
    {
502 1
        $value = $value ?? '';
503 1
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
            $outputStyles = ['expanded', 'compressed'];
509
            $outputStyle = strtolower((string) $this->config->get('assets.compile.style'));
510 1
            if (!\in_array($outputStyle, $outputStyles)) {
511 1
                throw new ConfigException(\sprintf('"%s" value must be "%s".', 'assets.compile.style', implode('" or "', $outputStyles)));
512 1
            }
513 1
            $scssPhp->setOutputStyle($outputStyle == 'compressed' ? OutputStyle::COMPRESSED : OutputStyle::EXPANDED);
514 1
            $variables = $this->config->get('assets.compile.variables');
515
            if (!empty($variables)) {
516 1
                $variables = array_map('ScssPhp\ScssPhp\ValueConverter::parseValue', $variables);
517 1
                $scssPhp->replaceVariables($variables);
518
            }
519
            $value = $scssPhp->compileString($value)->getCss();
520 1
            $cache->set($cacheKey, $value, $this->config->get('cache.assets.ttl'));
521
        }
522
523
        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 1
     *
541
     * @throws RuntimeException
542 1
     */
543 1
    public function html(array $context, Asset|array $assets, array $attributes = [], array $options = []): string
544 1
    {
545
        $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 1
        }
549
        foreach ($assets as $assetData) {
550
            $asset = $assetData['asset'];
551
            if (!$asset instanceof Asset) {
552 1
                $asset = new Asset($this->builder, $asset);
553
            }
554 1
            // be sure Asset file is saved
555 1
            $asset->save();
556 1
            // merge attributes
557
            $attr = $attributes;
558
            if ($assetData['attributes'] !== null) {
559 1
                $attr = $attributes + $assetData['attributes'];
560 1
            }
561 1
            // 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
                    $html[] = $this->htmlJs($context, $asset, $attr, $options);
571
                    $attributes['as'] = $asset['script'];
572 1
                    break;
573 1
            }
574 1
            // 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
                case 'video':
580
                    $html[] = $this->htmlVideo($context, $asset, $attr, $options);
581 1
                    break;
582 1
            }
583 1
            // preload
584 1
            if ($options['preload'] ?? false) {
585
                $attributes['type'] = $asset['subtype'];
586 1
                if (empty($attributes['crossorigin'])) {
587
                    $attributes['crossorigin'] = 'anonymous';
588 1
                }
589
                array_unshift($html, \sprintf('<link rel="preload" href="%s"%s>', $this->url($context, $asset, $options), self::htmlAttributes($attributes)));
590 1
            }
591
            unset($attr);
592
        }
593
        if (empty($html)) {
594 1
            throw new RuntimeException(\sprintf('%s failed to generate HTML element(s) for file(s) provided.', '"html" function'));
595
        }
596
597
        return implode("\n    ", $html);
598
    }
599
600 1
    /**
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
        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 1
            );
613
        }
614
615
        return \sprintf('<link rel="stylesheet" href="%s"%s>', $this->url($context, $asset, $options), self::htmlAttributes($attributes));
616
    }
617
618 1
    /**
619
     * Builds the HTML script element of a JS Asset.
620 1
     */
621
    public function htmlJs(array $context, Asset $asset, array $attributes = [], array $options = []): string
622
    {
623
        return \sprintf('<script src="%s"%s></script>', $this->url($context, $asset, $options), self::htmlAttributes($attributes));
624
    }
625
626 1
    /**
627
     * Builds the HTML img element of an image Asset.
628 1
     */
629 1
    public function htmlImage(array $context, Asset $asset, array $attributes = [], array $options = []): string
630 1
    {
631
        $responsive = $options['responsive'] ?? $this->config->get('layouts.images.responsive');
632 1
633
        // build responsive attributes
634
        try {
635
            if ($responsive === true || $responsive == 'width') {
636 1
                $srcset = Image::buildHtmlSrcsetW($asset, $this->config->getAssetsImagesWidths());
637 1
                $attributes['srcset'] = $srcset;
638 1
                $attributes['sizes'] = Image::getHtmlSizes($attributes['class'] ?? '', $this->config->getAssetsImagesSizes());
639
                // prevent oversized images
640 1
                if ($asset['width'] > max($this->config->getAssetsImagesWidths())) {
641
                    $asset = $asset->resize(max($this->config->getAssetsImagesWidths()));
642
                }
643
            } elseif ($responsive == 'density') {
644 1
                $width1x = isset($attributes['width']) && $attributes['width'] > 0 ? (int) $attributes['width'] : $asset['width'];
645 1
                $srcset = Image::buildHtmlSrcsetX($asset, $width1x, $this->config->getAssetsImagesDensities());
646 1
                $attributes['srcset'] = $srcset;
647 1
            }
648
        } catch (\Exception $e) {
649
            $this->builder->getLogger()->warning($e->getMessage());
650
        }
651
652
        // create alternative formats (`<source>`)
653
        try {
654
            $formats = $options['formats'] ?? (array) $this->config->get('layouts.images.formats');
655 1
            if (\count($formats) > 0) {
656 1
                $source = '';
657 1
                foreach ($formats as $format) {
658 1
                    try {
659
                        $assetConverted = $asset->convert($format);
660 1
                        // responsive
661
                        if ($responsive === true || $responsive == 'width') {
662
                            $srcset = Image::buildHtmlSrcsetW($assetConverted, $this->config->getAssetsImagesWidths());
663
                            $source .= \sprintf("\n  <source type=\"image/$format\" srcset=\"%s\" sizes=\"%s\">", $srcset, Image::getHtmlSizes($attributes['class'] ?? '', $this->config->getAssetsImagesSizes()));
664
                            continue;
665
                        }
666
                        if ($responsive == 'density') {
667
                            $width1x = isset($attributes['width']) && $attributes['width'] > 0 ? (int) $attributes['width'] : $asset['width'];
668
                            $srcset = Image::buildHtmlSrcsetX($assetConverted, $width1x, $this->config->getAssetsImagesDensities());
669
                            $source .= \sprintf("\n  <source type=\"image/$format\" srcset=\"%s\">", $srcset);
670
                            continue;
671
                        }
672
                        $source .= \sprintf("\n  <source type=\"image/$format\" srcset=\"%s\">", $assetConverted);
673
                    } catch (\Exception $e) {
674 1
                        $this->builder->getLogger()->warning($e->getMessage());
675 1
                        continue;
676 1
                    }
677
                }
678
            }
679
        } catch (\Exception $e) {
680
            $this->builder->getLogger()->warning($e->getMessage());
681
        }
682
683
        // create `<img>` element
684
        if (!isset($attributes['alt'])) {
685 1
            $attributes['alt'] = '';
686 1
        }
687
        if (isset($attributes['width']) && $attributes['width'] > 0) {
688 1
            $asset = $asset->resize((int) $attributes['width']);
689 1
        }
690
        if (!isset($attributes['width'])) {
691 1
            $attributes['width'] = $asset['width'] ?: '';
692 1
        }
693
        $with = $attributes['width'];
694
        unset($attributes['width']);
695 1
        $attributes['width'] = $with;
696
        $attributes['height'] = $asset['height'] ?: '';
697
        $img = \sprintf('<img src="%s"%s>', $this->url($context, $asset, $options), self::htmlAttributes($attributes));
698
699 1
        // put `<source>` elements in `<picture>` if exists
700
        if (!empty($source)) {
701
            return \sprintf("<picture>%s\n  %s\n</picture>", $source, $img);
702
        }
703
704
        return $img;
705 1
    }
706
707 1
    /**
708 1
     * Builds the HTML video element of a video Asset.
709
     */
710
    public function htmlVideo(array $context, Asset $asset, array $attributes = [], array $options = []): string
711 1
    {
712
        if (empty($attributes)) {
713
            $attributes['controls'] = '';
714
        }
715
716
        return \sprintf('<video%s><source src="%s" type="%s"></video>', self::htmlAttributes($attributes), $this->url($context, $asset, $options), $asset['subtype']);
717
    }
718
719 1
    /**
720
     * Builds the HTML img `srcset` (responsive) attribute of an image Asset, based on configured widths.
721 1
     *
722
     * @throws RuntimeException
723
     */
724
    public function imageSrcset(Asset $asset): string
725
    {
726
        return Image::buildHtmlSrcsetW($asset, $this->config->getAssetsImagesWidths(), true);
727 1
    }
728
729 1
    /**
730
     * Returns the HTML img `sizes` attribute based on a CSS class name.
731
     */
732
    public function imageSizes(string $class): string
733
    {
734
        return Image::getHtmlSizes($class, $this->config->getAssetsImagesSizes());
735
    }
736
737
    /**
738
     * Builds the HTML img element from a URL by extracting the image from meta tags.
739
     * Returns null if no image found.
740
     *
741
     * @throws RuntimeException
742
     */
743
    public function htmlImageFromUrl(array $context, string $url, array $attributes = [], array $options = []): ?string
744
    {
745
        if (false !== $html = Util\File::fileGetContents($url)) {
746
            $imageUrl = Util\Html::getImageFromMetaTags($html);
747
            if ($imageUrl !== null) {
748
                $asset = new Asset($this->builder, $imageUrl);
749
750
                return $this->htmlImage($context, $asset, $attributes, $options);
751
            }
752
        }
753
754
        return null;
755
    }
756
757
    /**
758
     * Converts an image Asset to WebP format.
759
     */
760
    public function webp(Asset $asset, ?int $quality = null): Asset
761
    {
762
        return $this->convert($asset, 'webp', $quality);
763
    }
764
765
    /**
766
     * Converts an image Asset to AVIF format.
767
     */
768
    public function avif(Asset $asset, ?int $quality = null): Asset
769
    {
770
        return $this->convert($asset, 'avif', $quality);
771
    }
772 1
773
    /**
774 1
     * Converts an image Asset to the given format.
775
     *
776
     * @throws RuntimeException
777
     */
778
    private function convert(Asset $asset, string $format, ?int $quality = null): Asset
779
    {
780 1
        if ($asset['subtype'] == "image/$format") {
781
            return $asset;
782 1
        }
783
        if (Image::isAnimatedGif($asset)) {
784 1
            throw new RuntimeException(\sprintf('Unable to convert the animated GIF "%s" to %s.', $asset['path'], $format));
785 1
        }
786 1
787 1
        try {
788 1
            return $asset->$format($quality);
789
        } catch (\Exception $e) {
790
            throw new RuntimeException(\sprintf('Unable to convert "%s" to %s (%s).', $asset['path'], $format, $e->getMessage()));
791 1
        }
792
    }
793
794
    /**
795
     * Returns the content of an asset.
796
     */
797
    public function inline(Asset $asset): string
798
    {
799
        return $asset['content'];
800 1
    }
801
802 1
    /**
803
     * Reads $length first characters of a string and adds a suffix.
804 1
     */
805 1
    public function excerpt(?string $string, int $length = 450, string $suffix = ' …'): string
806 1
    {
807
        $string = $string ?? '';
808
809 1
        $string = str_replace('</p>', '<br><br>', $string);
810 1
        $string = trim(strip_tags($string, '<br>'));
811
        if (mb_strlen($string) > $length) {
812 1
            $string = mb_substr($string, 0, $length);
813
            $string .= $suffix;
814
        }
815 1
816 1
        return $string;
817 1
    }
818
819
    /**
820 1
     * Reads characters before or after '<!-- separator -->'.
821
     * Options:
822
     *  - separator: string to use as separator (`excerpt|break` by default)
823
     *  - capture: part to capture, `before` or `after` the separator (`before` by default).
824
     */
825
    public function excerptHtml(?string $string, array $options = []): string
826
    {
827
        $string = $string ?? '';
828 1
829
        $separator = (string) $this->config->get('pages.body.excerpt.separator');
830 1
        $capture = (string) $this->config->get('pages.body.excerpt.capture');
831
        extract($options, EXTR_IF_EXISTS);
832
833 1
        // https://regex101.com/r/n9TWHF/1
834 1
        $pattern = '(.*)<!--[[:blank:]]?(' . $separator . ')[[:blank:]]?-->(.*)';
835
        preg_match('/' . $pattern . '/is', $string, $matches);
836
837
        if (empty($matches)) {
838
            return $string;
839
        }
840
        $result = trim($matches[1]);
841
        if ($capture == 'after') {
842 1
            $result = trim($matches[3]);
843
        }
844
        // removes footnotes and returns result
845
        return preg_replace('/<sup[^>]*>[^u]*<\/sup>/', '', $result);
846
    }
847
848
    /**
849
     * Converts a Markdown string to HTML.
850
     *
851 1
     * @throws RuntimeException
852
     */
853 1
    public function markdownToHtml(?string $markdown): ?string
854 1
    {
855
        $markdown = $markdown ?? '';
856
857 1
        try {
858 1
            $parsedown = new Parsedown($this->builder);
859 1
            $html = $parsedown->text($markdown);
860
        } catch (\Exception $e) {
861
            throw new RuntimeException(
862
                '"markdown_to_html" filter can not convert supplied Markdown.',
863
                previous: $e
864 1
            );
865
        }
866
867
        return $html;
868
    }
869
870
    /**
871
     * Extract table of content of a Markdown string,
872 1
     * in the given format ("html" or "json", "html" by default).
873
     *
874 1
     * @throws RuntimeException
875
     */
876
    public function markdownToToc(?string $markdown, $format = 'html', ?array $selectors = null, string $url = ''): ?string
877 1
    {
878 1
        $markdown = $markdown ?? '';
879 1
        $selectors = $selectors ?? (array) $this->config->get('pages.body.toc');
880
881
        try {
882
            $parsedown = new Parsedown($this->builder, ['selectors' => $selectors, 'url' => $url]);
883
            $parsedown->body($markdown);
884
            $return = $parsedown->contentsList($format);
885 1
        } catch (\Exception) {
886
            throw new RuntimeException('"toc" filter can not convert supplied Markdown.');
887
        }
888
889
        return $return;
890
    }
891
892
    /**
893 1
     * Converts a JSON string to an array.
894
     *
895 1
     * @throws RuntimeException
896
     */
897
    public function jsonDecode(?string $json): ?array
898 1
    {
899 1
        $json = $json ?? '';
900 1
901
        try {
902
            $array = json_decode($json, true);
903
            if ($array === null && json_last_error() !== JSON_ERROR_NONE) {
904
                throw new \Exception('JSON error.');
905
            }
906 1
        } catch (\Exception) {
907
            throw new RuntimeException('"json_decode" filter can not parse supplied JSON.');
908
        }
909
910
        return $array;
911
    }
912
913
    /**
914
     * Converts a YAML string to an array.
915
     *
916
     * @throws RuntimeException
917
     */
918
    public function yamlParse(?string $yaml): ?array
919
    {
920
        $yaml = $yaml ?? '';
921
922
        try {
923
            $array = Yaml::parse($yaml, Yaml::PARSE_DATETIME);
924
            if (!\is_array($array)) {
925
                throw new ParseException('YAML error.');
926
            }
927
        } catch (ParseException $e) {
928
            throw new RuntimeException(\sprintf('"yaml_parse" filter can not parse supplied YAML: %s', $e->getMessage()));
929
        }
930
931
        return $array;
932
    }
933
934
    /**
935
     * Split a string into an array using a regular expression.
936
     *
937
     * @throws RuntimeException
938
     */
939
    public function pregSplit(?string $value, string $pattern, int $limit = 0): ?array
940
    {
941
        $value = $value ?? '';
942
943
        try {
944
            $array = preg_split($pattern, $value, $limit);
945
            if ($array === false) {
946
                throw new RuntimeException('PREG split error.');
947
            }
948
        } catch (\Exception) {
949
            throw new RuntimeException('"preg_split" filter can not split supplied string.');
950
        }
951
952
        return $array;
953
    }
954 1
955
    /**
956 1
     * Perform a regular expression match and return the group for all matches.
957
     *
958 1
     * @throws RuntimeException
959 1
     */
960 1
    public function pregMatchAll(?string $value, string $pattern, int $group = 0): ?array
961
    {
962
        $value = $value ?? '';
963
964 1
        try {
965
            $array = preg_match_all($pattern, $value, $matches, PREG_PATTERN_ORDER);
966
            if ($array === false) {
967
                throw new RuntimeException('PREG match all error.');
968
            }
969
        } catch (\Exception) {
970 1
            throw new RuntimeException('"preg_match_all" filter can not match in supplied string.');
971
        }
972 1
973
        return $matches[$group];
974 1
    }
975
976
    /**
977
     * Calculates estimated time to read a text.
978
     */
979
    public function readtime(?string $text): string
980 1
    {
981
        $text = $text ?? '';
982 1
983
        $words = str_word_count(strip_tags($text));
984
        $min = floor($words / 200);
985
        if ($min === 0) {
986 1
            return '1';
987
        }
988
989
        return (string) $min;
990
    }
991
992
    /**
993
     * Gets the value of an environment variable.
994
     */
995 1
    public function getEnv(?string $var): ?string
996 1
    {
997 1
        $var = $var ?? '';
998 1
999
        return getenv($var) ?: null;
1000 1
    }
1001 1
1002
    /**
1003
     * Dump variable (or Twig context).
1004
     */
1005
    public function varDump(\Twig\Environment $env, array $context, $var = null, ?array $options = null): void
1006
    {
1007 1
        if (!$env->isDebug()) {
1008
            return;
1009 1
        }
1010
1011
        if ($var === null) {
1012
            $var = array();
1013
            foreach ($context as $key => $value) {
1014
                if (!$value instanceof \Twig\Template && !$value instanceof \Twig\TemplateWrapper) {
1015
                    $var[$key] = $value;
1016 1
                }
1017
            }
1018 1
        }
1019
1020
        $cloner = new VarCloner();
1021
        $cloner->setMinDepth(3);
1022
        $dumper = new HtmlDumper();
1023
        $dumper->setTheme($options['theme'] ?? 'light');
1024
1025 1
        $data = $cloner->cloneVar($var)->withMaxDepth(3);
1026
        $dumper->dump($data, null, ['maxDepth' => 3]);
1027 1
    }
1028
1029
    /**
1030
     * Tests if a variable is an Asset.
1031
     */
1032
    public function isAsset($variable): bool
1033
    {
1034
        return $variable instanceof Asset;
1035
    }
1036
1037 1
    /**
1038
     * Tests if an image Asset is large enough to be used as a cover image.
1039 1
     * A large image is defined as having a width >= 600px and height >= 315px.
1040
     */
1041
    public function isImageLarge(Asset $asset): bool
1042
    {
1043 1
        return $asset['type'] == 'image' && $asset['width'] > $asset['height'] && $asset['width'] >= 600 && $asset['height'] >= 315;
1044
    }
1045
1046
    /**
1047
     * Tests if an image Asset is square.
1048
     * A square image is defined as having the same width and height.
1049
     */
1050
    public function isImageSquare(Asset $asset): bool
1051
    {
1052
        return $asset['type'] == 'image' && $asset['width'] == $asset['height'];
1053 1
    }
1054
1055 1
    /**
1056
     * Returns the dominant hex color of an image asset.
1057
     *
1058
     * @param string|Asset $asset
1059 1
     *
1060
     * @return string
1061
     */
1062
    public function dominantColor($asset): string
1063
    {
1064
        if (!$asset instanceof Asset) {
1065
            $asset = new Asset($this->builder, $asset);
1066
        }
1067 1
1068
        return Image::getDominantColor($asset);
1069 1
    }
1070
1071 1
    /**
1072
     * Returns a Low Quality Image Placeholder (LQIP) as data URL.
1073
     *
1074 1
     * @param string|Asset $asset
1075 1
     *
1076
     * @return string
1077
     */
1078 1
    public function lqip($asset): string
1079
    {
1080 1
        if (!$asset instanceof Asset) {
1081 1
            $asset = new Asset($this->builder, $asset);
1082 1
        }
1083 1
1084 1
        return Image::getLqip($asset);
1085
    }
1086
1087
    /**
1088
     * Converts an hexadecimal color to RGB.
1089
     *
1090 1
     * @throws RuntimeException
1091
     */
1092 1
    public function hexToRgb(?string $variable): array
1093
    {
1094 1
        $variable = $variable ?? '';
1095
1096
        if (!self::isHex($variable)) {
1097
            throw new RuntimeException(\sprintf('"%s" is not a valid hexadecimal value.', $variable));
1098
        }
1099
        $hex = ltrim($variable, '#');
1100 1
        if (\strlen($hex) == 3) {
1101
            $hex = $hex[0] . $hex[0] . $hex[1] . $hex[1] . $hex[2] . $hex[2];
1102 1
        }
1103 1
        $c = hexdec($hex);
1104 1
1105
        return [
1106
            'red'   => $c >> 16 & 0xFF,
1107
            'green' => $c >> 8 & 0xFF,
1108
            'blue'  => $c & 0xFF,
1109
        ];
1110
    }
1111
1112
    /**
1113
     * Split a string in multiple lines.
1114
     */
1115 1
    public function splitLine(?string $variable, int $max = 18): array
1116
    {
1117 1
        $variable = $variable ?? '';
1118 1
1119
        return preg_split("/.{0,{$max}}\K(\s+|$)/", $variable, 0, PREG_SPLIT_NO_EMPTY);
1120
    }
1121
1122
    /**
1123
     * Hashing an object, an array or a string (with algo, md5 by default).
1124
     */
1125
    public function hash(object|array|string $data, $algo = 'md5'): string
1126
    {
1127
        switch (\gettype($data)) {
1128
            case 'object':
1129
                return spl_object_hash($data);
1130
            case 'array':
1131
                return hash($algo, serialize($data));
1132
        }
1133
1134
        return hash($algo, $data);
1135
    }
1136
1137
    /**
1138
     * Converts a variable to an iterable (array).
1139
     */
1140
    public function iterable($value): array
1141
    {
1142
        if (\is_array($value)) {
1143
            return $value;
1144
        }
1145
        if (\is_string($value)) {
1146
            return [$value];
1147
        }
1148
        if ($value instanceof \Traversable) {
1149 1
            return iterator_to_array($value);
1150
        }
1151 1
        if ($value instanceof \stdClass) {
1152
            return (array) $value;
1153
        }
1154
        if (\is_object($value)) {
1155
            return [$value];
1156
        }
1157 1
        if (\is_int($value) || \is_float($value)) {
1158
            return [$value];
1159 1
        }
1160 1
        return [$value];
1161 1
    }
1162 1
1163 1
    /**
1164
     * Highlights a code snippet.
1165 1
     */
1166
    public function highlight(string $code, string $language): string
1167
    {
1168
        return (new Highlighter())->highlight($language, $code)->value;
1169
    }
1170
1171 1
    /**
1172
     * Returns an array with unique values.
1173 1
     */
1174 1
    public function unique(array $array): array
1175 1
    {
1176 1
        return array_intersect_key($array, array_unique(array_map('strtolower', $array), SORT_STRING));
1177 1
    }
1178
1179 1
    /**
1180
     * Is a hexadecimal color is valid?
1181
     */
1182 1
    private static function isHex(string $hex): bool
1183
    {
1184
        $valid = \is_string($hex);
1185
        $hex = ltrim($hex, '#');
1186
        $length = \strlen($hex);
1187
        $valid = $valid && ($length === 3 || $length === 6);
1188
        $valid = $valid && ctype_xdigit($hex);
1189
1190
        return $valid;
1191
    }
1192
1193
    /**
1194
     * Builds the HTML attributes string from an array.
1195
     */
1196
    private static function htmlAttributes(array $attributes): string
1197
    {
1198
        $htmlAttributes = '';
1199
        foreach ($attributes as $name => $value) {
1200
            $attribute = \sprintf(' %s="%s"', $name, $value);
1201
            if (empty($value)) {
1202
                $attribute = \sprintf(' %s', $name);
1203
            }
1204
            $htmlAttributes .= $attribute;
1205
        }
1206
1207
        return $htmlAttributes;
1208
    }
1209
}
1210