Passed
Pull Request — master (#2277)
by Arnaud
07:35 queued 03:05
created

Core::html()   F

Complexity

Conditions 13
Paths 580

Size

Total Lines 58
Code Lines 41

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 40
CRAP Score 13.0182

Importance

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

How to fix   Long Method    Complexity   

Long Method

Small methods make your code easier to understand, in particular if combined with a good name. Besides, if your method is small, finding a good name is usually much easier.

For example, if you find yourself adding comments to a method's body, this is usually a good sign to extract the commented part to a new method, and use the comment as a starting point when coming up with a good name for this new method.

Commonly applied refactorings include:

1
<?php
2
3
/**
4
 * This file is part of Cecil.
5
 *
6
 * (c) Arnaud Ligny <[email protected]>
7
 *
8
 * For the full copyright and license information, please view the LICENSE
9
 * file that was distributed with this source code.
10
 */
11
12
declare(strict_types=1);
13
14
namespace Cecil\Renderer\Extension;
15
16
use Cecil\Asset;
17
use Cecil\Asset\Image;
18
use Cecil\Builder;
19
use Cecil\Cache;
20
use Cecil\Collection\CollectionInterface;
21
use Cecil\Collection\Page\Collection as PagesCollection;
22
use Cecil\Collection\Page\Page;
23
use Cecil\Collection\Page\Type;
24
use Cecil\Config;
25
use Cecil\Converter\Parsedown;
26
use Cecil\Exception\ConfigException;
27
use Cecil\Exception\RuntimeException;
28
use Cecil\Url;
29
use Cecil\Util;
30
use Cecil\Util\Html;
31
use Cocur\Slugify\Bridge\Twig\SlugifyExtension;
32
use Cocur\Slugify\Slugify;
33
use Highlight\Highlighter;
34
use MatthiasMullie\Minify;
35
use ScssPhp\ScssPhp\Compiler;
36
use ScssPhp\ScssPhp\OutputStyle;
37
use Symfony\Component\VarDumper\Cloner\VarCloner;
38
use Symfony\Component\VarDumper\Dumper\HtmlDumper;
39
use Symfony\Component\Yaml\Exception\ParseException;
40
use Symfony\Component\Yaml\Yaml;
41
use Twig\DeprecatedCallableInfo;
42
43
/**
44
 * Core Twig extension.
45
 *
46
 * This extension provides various utility functions and filters for use in Twig templates,
47
 * including URL generation, asset management, content processing, and more.
48
 */
49
class Core extends SlugifyExtension
50
{
51
    /** @var Builder */
52
    protected $builder;
53
54
    /** @var Config */
55
    protected $config;
56
57
    /** @var Slugify */
58
    private static $slugifier;
59
60 1
    public function __construct(Builder $builder)
61
    {
62 1
        if (!self::$slugifier instanceof Slugify) {
63 1
            self::$slugifier = Slugify::create(['regexp' => Page::SLUGIFY_PATTERN]);
64
        }
65
66 1
        parent::__construct(self::$slugifier);
67
68 1
        $this->builder = $builder;
69 1
        $this->config = $builder->getConfig();
70
    }
71
72
    /**
73
     * {@inheritdoc}
74
     */
75
    public function getName(): string
76
    {
77
        return 'CoreExtension';
78
    }
79
80
    /**
81
     * {@inheritdoc}
82
     */
83 1
    public function getFunctions()
84
    {
85 1
        return [
86 1
            new \Twig\TwigFunction('url', [$this, 'url'], ['needs_context' => true]),
87
            // assets
88 1
            new \Twig\TwigFunction('asset', [$this, 'asset']),
89 1
            new \Twig\TwigFunction('html', [$this, 'html'], ['needs_context' => true]),
90 1
            new \Twig\TwigFunction('css', [$this, 'htmlCss'], ['needs_context' => true]),
91 1
            new \Twig\TwigFunction('js', [$this, 'htmlJs'], ['needs_context' => true]),
92 1
            new \Twig\TwigFunction('image', [$this, 'htmlImage'], ['needs_context' => true]),
93 1
            new \Twig\TwigFunction('audio', [$this, 'htmlAudio'], ['needs_context' => true]),
94 1
            new \Twig\TwigFunction('video', [$this, 'htmlVideo'], ['needs_context' => true]),
95 1
            new \Twig\TwigFunction('integrity', [$this, 'integrity']),
96 1
            new \Twig\TwigFunction('image_srcset', [$this, 'imageSrcset']),
97 1
            new \Twig\TwigFunction('image_sizes', [$this, 'imageSizes']),
98 1
            new \Twig\TwigFunction('image_from_url', [$this, 'htmlImageFromUrl'], ['needs_context' => true]),
99
            // content
100 1
            new \Twig\TwigFunction('readtime', [$this, 'readtime']),
101 1
            new \Twig\TwigFunction('hash', [$this, 'hash']),
102
            // others
103 1
            new \Twig\TwigFunction('getenv', [$this, 'getEnv']),
104 1
            new \Twig\TwigFunction('d', [$this, 'varDump'], ['needs_context' => true, 'needs_environment' => true]),
105
            // deprecated
106 1
            new \Twig\TwigFunction(
107 1
                'minify',
108 1
                [$this, 'minify'],
109 1
                ['deprecation_info' => new DeprecatedCallableInfo('', '', 'minify filter')]
110 1
            ),
111 1
            new \Twig\TwigFunction(
112 1
                'toCSS',
113 1
                [$this, 'toCss'],
114 1
                ['deprecation_info' => new DeprecatedCallableInfo('', '', 'to_css filter')]
115 1
            ),
116 1
        ];
117
    }
118
119
    /**
120
     * {@inheritdoc}
121
     */
122 1
    public function getFilters(): array
123
    {
124 1
        return [
125 1
            new \Twig\TwigFilter('url', [$this, 'url'], ['needs_context' => true]),
126
            // collections
127 1
            new \Twig\TwigFilter('sort_by_title', [$this, 'sortByTitle']),
128 1
            new \Twig\TwigFilter('sort_by_weight', [$this, 'sortByWeight']),
129 1
            new \Twig\TwigFilter('sort_by_date', [$this, 'sortByDate']),
130 1
            new \Twig\TwigFilter('filter_by', [$this, 'filterBy']),
131
            // assets
132 1
            new \Twig\TwigFilter('inline', [$this, 'inline']),
133 1
            new \Twig\TwigFilter('fingerprint', [$this, 'fingerprint']),
134 1
            new \Twig\TwigFilter('to_css', [$this, 'toCss']),
135 1
            new \Twig\TwigFilter('minify', [$this, 'minify']),
136 1
            new \Twig\TwigFilter('minify_css', [$this, 'minifyCss']),
137 1
            new \Twig\TwigFilter('minify_js', [$this, 'minifyJs']),
138 1
            new \Twig\TwigFilter('scss_to_css', [$this, 'scssToCss']),
139 1
            new \Twig\TwigFilter('sass_to_css', [$this, 'scssToCss']),
140 1
            new \Twig\TwigFilter('resize', [$this, 'resize']),
141 1
            new \Twig\TwigFilter('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 Asset to the given width or/and height.
371
     *
372
     * @param string|Asset $asset
373
     *
374
     * @return Asset
375
     */
376 1
    public function resize($asset, ?int $width = null, ?int $height = null): Asset
377
    {
378 1
        if (!$asset instanceof Asset) {
379
            $asset = new Asset($this->builder, $asset);
380
        }
381
382 1
        return $asset->resize($width, $height);
383
    }
384
385
    /**
386
     * Creates a maskable icon from an image asset.
387
     * The maskable icon is used for Progressive Web Apps (PWAs).
388
     *
389
     * @param string|Asset $asset
390
     *
391
     * @return Asset
392
     */
393
    public function maskable($asset, ?int $padding = null): Asset
394
    {
395
        if (!$asset instanceof Asset) {
396
            $asset = new Asset($this->builder, $asset);
397
        }
398
399
        return $asset->maskable($padding);
400
    }
401
402
    /**
403
     * Returns the data URL of an image.
404
     *
405
     * @param string|Asset $asset
406
     *
407
     * @return string
408
     */
409 1
    public function dataurl($asset): string
410
    {
411 1
        if (!$asset instanceof Asset) {
412
            $asset = new Asset($this->builder, $asset);
413
        }
414
415 1
        return $asset->dataurl();
416
    }
417
418
    /**
419
     * Hashing an asset with algo (sha384 by default).
420
     *
421
     * @param string|Asset $asset
422
     * @param string       $algo
423
     *
424
     * @return string
425
     */
426 1
    public function integrity($asset, string $algo = 'sha384'): string
427
    {
428 1
        if (!$asset instanceof Asset) {
429 1
            $asset = new Asset($this->builder, $asset);
430
        }
431
432 1
        return $asset->integrity($algo);
433
    }
434
435
    /**
436
     * Minifying a CSS string.
437
     */
438 1
    public function minifyCss(?string $value): string
439
    {
440 1
        $value = $value ?? '';
441
442 1
        if ($this->builder->isDebug()) {
443 1
            return $value;
444
        }
445
446
        $cache = new Cache($this->builder, 'assets');
447
        $cacheKey = $cache->createKeyFromValue(null, $value);
448
        if (!$cache->has($cacheKey)) {
449
            $minifier = new Minify\CSS($value);
450
            $value = $minifier->minify();
451
            $cache->set($cacheKey, $value, $this->config->get('cache.assets.ttl'));
452
        }
453
454
        return $cache->get($cacheKey, $value);
455
    }
456
457
    /**
458
     * Minifying a JavaScript string.
459
     */
460 1
    public function minifyJs(?string $value): string
461
    {
462 1
        $value = $value ?? '';
463
464 1
        if ($this->builder->isDebug()) {
465 1
            return $value;
466
        }
467
468
        $cache = new Cache($this->builder, 'assets');
469
        $cacheKey = $cache->createKeyFromValue(null, $value);
470
        if (!$cache->has($cacheKey)) {
471
            $minifier = new Minify\JS($value);
472
            $value = $minifier->minify();
473
            $cache->set($cacheKey, $value, $this->config->get('cache.assets.ttl'));
474
        }
475
476
        return $cache->get($cacheKey, $value);
477
    }
478
479
    /**
480
     * Compiles a SCSS string.
481
     *
482
     * @throws RuntimeException
483
     */
484 1
    public function scssToCss(?string $value): string
485
    {
486 1
        $value = $value ?? '';
487
488 1
        $cache = new Cache($this->builder, 'assets');
489 1
        $cacheKey = $cache->createKeyFromValue(null, $value);
490 1
        if (!$cache->has($cacheKey)) {
491 1
            $scssPhp = new Compiler();
492 1
            $outputStyles = ['expanded', 'compressed'];
493 1
            $outputStyle = strtolower((string) $this->config->get('assets.compile.style'));
494 1
            if (!\in_array($outputStyle, $outputStyles)) {
495
                throw new ConfigException(\sprintf('"%s" value must be "%s".', 'assets.compile.style', implode('" or "', $outputStyles)));
496
            }
497 1
            $scssPhp->setOutputStyle($outputStyle == 'compressed' ? OutputStyle::COMPRESSED : OutputStyle::EXPANDED);
498 1
            $variables = $this->config->get('assets.compile.variables');
499 1
            if (!empty($variables)) {
500 1
                $variables = array_map('ScssPhp\ScssPhp\ValueConverter::parseValue', $variables);
501 1
                $scssPhp->replaceVariables($variables);
502
            }
503 1
            $value = $scssPhp->compileString($value)->getCss();
504 1
            $cache->set($cacheKey, $value, $this->config->get('cache.assets.ttl'));
505
        }
506
507 1
        return $cache->get($cacheKey, $value);
508
    }
509
510
    /**
511
     * Creates the HTML element of an asset.
512
     *
513
     * @param array                                                                $context    Twig context
514
     * @param Asset|array<int,array{asset:Asset,attributes:?array<string,string>}> $assets     Asset or array of assets + attributes
515
     * @param array                                                                $attributes HTML attributes to add to the element
516
     * @param array                                                                $options    Options:
517
     * [
518
     *     'preload'    => false,
519
     *     'responsive' => false,
520
     *     'formats'    => [],
521
     * ];
522
     *
523
     * @return string HTML element
524
     *
525
     * @throws RuntimeException
526
     */
527 1
    public function html(array $context, Asset|array $assets, array $attributes = [], array $options = []): string
528
    {
529 1
        $html = array();
530 1
        if (!\is_array($assets)) {
0 ignored issues
show
introduced by
The condition is_array($assets) is always true.
Loading history...
531 1
            $assets = [['asset' => $assets, 'attributes' => null]];
532
        }
533 1
        foreach ($assets as $assetData) {
534 1
            $asset = $assetData['asset'];
535 1
            if (!$asset instanceof Asset) {
536
                $asset = new Asset($this->builder, $asset);
537
            }
538
            // be sure Asset file is saved
539 1
            $asset->save();
540
            // merge attributes
541 1
            $attr = $attributes;
542 1
            if ($assetData['attributes'] !== null) {
543 1
                $attr = $attributes + $assetData['attributes'];
544
            }
545
            // process by extension
546 1
            $attributes['as'] = $asset['type'];
547 1
            switch ($asset['ext']) {
548 1
                case 'css':
549 1
                    $html[] = $this->htmlCss($context, $asset, $attr, $options);
550 1
                    $attributes['as'] = 'style';
551 1
                    unset($attributes['defer']);
552 1
                    break;
553 1
                case 'js':
554 1
                    $html[] = $this->htmlJs($context, $asset, $attr, $options);
555 1
                    $attributes['as'] = $asset['script'];
556 1
                    break;
557
            }
558
            // process by MIME type
559 1
            switch ($asset['type']) {
560 1
                case 'image':
561 1
                    $html[] = $this->htmlImage($context, $asset, $attr, $options);
562 1
                    break;
563 1
                case 'audio':
564 1
                    $html[] = $this->htmlAudio($context, $asset, $attr, $options);
565 1
                    break;
566 1
                case 'video':
567 1
                    $html[] = $this->htmlVideo($context, $asset, $attr, $options);
568 1
                    break;
569
            }
570
            // preload
571 1
            if ($options['preload'] ?? false) {
572 1
                $attributes['type'] = $asset['subtype'];
573 1
                if (empty($attributes['crossorigin'])) {
574 1
                    $attributes['crossorigin'] = 'anonymous';
575
                }
576 1
                array_unshift($html, \sprintf('<link rel="preload" href="%s"%s>', $this->url($context, $asset, $options), self::htmlAttributes($attributes)));
577
            }
578 1
            unset($attr);
579
        }
580 1
        if (empty($html)) {
581
            throw new RuntimeException(\sprintf('%s failed to generate HTML element(s) for file(s) provided.', '"html" function'));
582
        }
583
584 1
        return implode("\n    ", $html);
585
    }
586
587
    /**
588
     * Builds the HTML link element of a CSS Asset.
589
     */
590 1
    public function htmlCss(array $context, Asset $asset, array $attributes = [], array $options = []): string
591
    {
592
        // simulate "defer" by using "preload" and "onload"
593 1
        if (isset($attributes['defer'])) {
594
            unset($attributes['defer']);
595
            return \sprintf(
596
                '<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>',
597
                $this->url($context, $asset, $options),
598
                self::htmlAttributes($attributes)
599
            );
600
        }
601
602 1
        return \sprintf('<link rel="stylesheet" href="%s"%s>', $this->url($context, $asset, $options), self::htmlAttributes($attributes));
603
    }
604
605
    /**
606
     * Builds the HTML script element of a JS Asset.
607
     */
608 1
    public function htmlJs(array $context, Asset $asset, array $attributes = [], array $options = []): string
609
    {
610 1
        return \sprintf('<script src="%s"%s></script>', $this->url($context, $asset, $options), self::htmlAttributes($attributes));
611
    }
612
613
    /**
614
     * Builds the HTML img element of an image Asset.
615
     */
616 1
    public function htmlImage(array $context, Asset $asset, array $attributes = [], array $options = []): string
617
    {
618 1
        $responsive = $options['responsive'] ?? $this->config->get('layouts.images.responsive');
619
620
        // build responsive attributes
621
        try {
622 1
            if ($responsive === true || $responsive == 'width') {
623 1
                $srcset = Image::buildHtmlSrcsetW($asset, $this->config->getAssetsImagesWidths());
624 1
                if (!empty($srcset)) {
625 1
                    $attributes['srcset'] = $srcset;
626
                }
627 1
                $attributes['sizes'] = Image::getHtmlSizes($attributes['class'] ?? '', $this->config->getAssetsImagesSizes());
628
                // prevent oversized images
629 1
                if ($asset['width'] > max($this->config->getAssetsImagesWidths())) {
630 1
                    $asset = $asset->resize(max($this->config->getAssetsImagesWidths()));
631
                }
632 1
            } elseif ($responsive == 'density') {
633 1
                $width1x = isset($attributes['width']) && $attributes['width'] > 0 ? (int) $attributes['width'] : $asset['width'];
634 1
                $srcset = Image::buildHtmlSrcsetX($asset, $width1x, $this->config->getAssetsImagesDensities());
635 1
                if (!empty($srcset)) {
636 1
                    $attributes['srcset'] = $srcset;
637
                }
638
            }
639
        } catch (\Exception $e) {
640
            $this->builder->getLogger()->warning($e->getMessage());
641
        }
642
643
        // create alternative formats (`<source>`)
644
        try {
645 1
            $formats = $options['formats'] ?? (array) $this->config->get('layouts.images.formats');
646 1
            if (\count($formats) > 0) {
647 1
                $source = '';
648 1
                foreach ($formats as $format) {
649
                    try {
650 1
                        $assetConverted = $asset->convert($format);
651
                        // responsive
652
                        if ($responsive === true || $responsive == 'width') {
653
                            $srcset = Image::buildHtmlSrcsetW($assetConverted, $this->config->getAssetsImagesWidths());
654
                            if (empty($srcset)) {
655
                                $source .= \sprintf("\n  <source type=\"image/$format\" srcset=\"%s\">", (string) $assetConverted);
656
                                continue;
657
                            }
658
                            $source .= \sprintf("\n  <source type=\"image/$format\" srcset=\"%s\" sizes=\"%s\">", $srcset, Image::getHtmlSizes($attributes['class'] ?? '', $this->config->getAssetsImagesSizes()));
659
                            continue;
660
                        }
661
                        if ($responsive == 'density') {
662
                            $width1x = isset($attributes['width']) && $attributes['width'] > 0 ? (int) $attributes['width'] : $asset['width'];
663
                            $srcset = Image::buildHtmlSrcsetX($assetConverted, $width1x, $this->config->getAssetsImagesDensities());
664
                            if (empty($srcset)) {
665
                                $srcset = (string) $assetConverted;
666
                            }
667
                            $source .= \sprintf("\n  <source type=\"image/$format\" srcset=\"%s\">", $srcset);
668
                            continue;
669
                        }
670
                        $source .= \sprintf("\n  <source type=\"image/$format\" srcset=\"%s\">", $assetConverted);
671 1
                    } catch (\Exception $e) {
672 1
                        $this->builder->getLogger()->warning($e->getMessage());
673 1
                        continue;
674
                    }
675
                }
676
            }
677
        } catch (\Exception $e) {
678
            $this->builder->getLogger()->warning($e->getMessage());
679
        }
680
681
        // create `<img>` element
682 1
        if (!isset($attributes['alt'])) {
683 1
            $attributes['alt'] = '';
684
        }
685 1
        if (isset($attributes['width']) && $attributes['width'] > 0) {
686 1
            $asset = $asset->resize((int) $attributes['width']);
687
        }
688 1
        if (!isset($attributes['width'])) {
689 1
            $attributes['width'] = $asset['width'] ?: '';
690
        }
691 1
        if (!isset($attributes['height'])) {
692 1
            $attributes['height'] = $asset['height'] ?: '';
693
        }
694 1
        $img = \sprintf('<img src="%s"%s>', $this->url($context, $asset, $options), self::htmlAttributes($attributes));
695
696
        // put `<source>` elements in `<picture>` if exists
697 1
        if (!empty($source)) {
698
            return \sprintf("<picture>%s\n  %s\n</picture>", $source, $img);
699
        }
700
701 1
        return $img;
702
    }
703
704
    /**
705
     * Builds the HTML audio element of an audio Asset.
706
     */
707 1
    public function htmlAudio(array $context, Asset $asset, array $attributes = [], array $options = []): string
708
    {
709 1
        if (empty($attributes)) {
710 1
            $attributes['controls'] = '';
711
        }
712
713 1
        return \sprintf('<audio%s src="%s" type="%s"></audio>', self::htmlAttributes($attributes), $this->url($context, $asset, $options), $asset['subtype']);
714
    }
715
716
    /**
717
     * Builds the HTML video element of a video Asset.
718
     */
719 1
    public function htmlVideo(array $context, Asset $asset, array $attributes = [], array $options = []): string
720
    {
721 1
        if (empty($attributes)) {
722 1
            $attributes['controls'] = '';
723
        }
724
725 1
        return \sprintf('<video%s><source src="%s" type="%s"></video>', self::htmlAttributes($attributes), $this->url($context, $asset, $options), $asset['subtype']);
726
    }
727
728
    /**
729
     * Builds the HTML img `srcset` (responsive) attribute of an image Asset, based on configured widths.
730
     *
731
     * @throws RuntimeException
732
     */
733 1
    public function imageSrcset(Asset $asset): string
734
    {
735 1
        return Image::buildHtmlSrcsetW($asset, $this->config->getAssetsImagesWidths(), true);
736
    }
737
738
    /**
739
     * Returns the HTML img `sizes` attribute based on a CSS class name.
740
     */
741 1
    public function imageSizes(string $class): string
742
    {
743 1
        return Image::getHtmlSizes($class, $this->config->getAssetsImagesSizes());
744
    }
745
746
    /**
747
     * Builds the HTML img element from a URL by extracting the image from meta tags.
748
     * Returns null if no image found.
749
     *
750
     * @throws RuntimeException
751
     */
752 1
    public function htmlImageFromUrl(array $context, string $url, array $attributes = [], array $options = []): ?string
753
    {
754 1
        if (false !== $html = Util\File::fileGetContents($url)) {
755 1
            $imageUrl = Util\Html::getImageFromMetaTags($html);
756 1
            if ($imageUrl !== null) {
757 1
                $asset = new Asset($this->builder, $imageUrl);
758
759 1
                return $this->htmlImage($context, $asset, $attributes, $options);
760
            }
761
        }
762
763 1
        return null;
764
    }
765
766
    /**
767
     * Converts an image Asset to WebP format.
768
     */
769
    public function webp(Asset $asset, ?int $quality = null): Asset
770
    {
771
        return $this->convert($asset, 'webp', $quality);
772
    }
773
774
    /**
775
     * Converts an image Asset to AVIF format.
776
     */
777
    public function avif(Asset $asset, ?int $quality = null): Asset
778
    {
779
        return $this->convert($asset, 'avif', $quality);
780
    }
781
782
    /**
783
     * Converts an image Asset to the given format.
784
     *
785
     * @throws RuntimeException
786
     */
787
    private function convert(Asset $asset, string $format, ?int $quality = null): Asset
788
    {
789
        if ($asset['subtype'] == "image/$format") {
790
            return $asset;
791
        }
792
        if (Image::isAnimatedGif($asset)) {
793
            throw new RuntimeException(\sprintf('Unable to convert the animated GIF "%s" to %s.', $asset['path'], $format));
794
        }
795
796
        try {
797
            return $asset->$format($quality);
798
        } catch (\Exception $e) {
799
            throw new RuntimeException(\sprintf('Unable to convert "%s" to %s (%s).', $asset['path'], $format, $e->getMessage()));
800
        }
801
    }
802
803
    /**
804
     * Returns the content of an asset.
805
     */
806 1
    public function inline(Asset $asset): string
807
    {
808 1
        return $asset['content'];
809
    }
810
811
    /**
812
     * Reads $length first characters of a string and adds a suffix.
813
     */
814 1
    public function excerpt(?string $string, int $length = 450, string $suffix = ' …'): string
815
    {
816 1
        $string = $string ?? '';
817
818 1
        $string = str_replace('</p>', '<br><br>', $string);
819 1
        $string = trim(strip_tags($string, '<br>'));
820 1
        if (mb_strlen($string) > $length) {
821 1
            $string = mb_substr($string, 0, $length);
822 1
            $string .= $suffix;
823
        }
824
825 1
        return $string;
826
    }
827
828
    /**
829
     * Reads characters before or after '<!-- separator -->'.
830
     * Options:
831
     *  - separator: string to use as separator (`excerpt|break` by default)
832
     *  - capture: part to capture, `before` or `after` the separator (`before` by default).
833
     */
834 1
    public function excerptHtml(?string $string, array $options = []): string
835
    {
836 1
        $string = $string ?? '';
837
838 1
        $separator = (string) $this->config->get('pages.body.excerpt.separator');
839 1
        $capture = (string) $this->config->get('pages.body.excerpt.capture');
840 1
        extract($options, EXTR_IF_EXISTS);
841
842
        // https://regex101.com/r/n9TWHF/1
843 1
        $pattern = '(.*)<!--[[:blank:]]?(' . $separator . ')[[:blank:]]?-->(.*)';
844 1
        preg_match('/' . $pattern . '/is', $string, $matches);
845
846 1
        if (empty($matches)) {
847
            return $string;
848
        }
849 1
        $result = trim($matches[1]);
850 1
        if ($capture == 'after') {
851 1
            $result = trim($matches[3]);
852
        }
853
        // removes footnotes and returns result
854 1
        return preg_replace('/<sup[^>]*>[^u]*<\/sup>/', '', $result);
855
    }
856
857
    /**
858
     * Converts a Markdown string to HTML.
859
     *
860
     * @throws RuntimeException
861
     */
862 1
    public function markdownToHtml(?string $markdown): ?string
863
    {
864 1
        $markdown = $markdown ?? '';
865
866
        try {
867 1
            $parsedown = new Parsedown($this->builder);
868 1
            $html = $parsedown->text($markdown);
869
        } catch (\Exception $e) {
870
            throw new RuntimeException(
871
                '"markdown_to_html" filter can not convert supplied Markdown.',
872
                previous: $e
873
            );
874
        }
875
876 1
        return $html;
877
    }
878
879
    /**
880
     * Extracts only headings matching the given `selectors` (h2, h3, etc.),
881
     * or those defined in config `pages.body.toc` if not specified.
882
     * The `format` parameter defines the output format: `html` or `json`.
883
     * The `url` parameter is used to build links to headings.
884
     *
885
     * @throws RuntimeException
886
     */
887 1
    public function markdownToToc(?string $markdown, $format = 'html', ?array $selectors = null, string $url = ''): ?string
888
    {
889 1
        $markdown = $markdown ?? '';
890 1
        $selectors = $selectors ?? (array) $this->config->get('pages.body.toc');
891
892
        try {
893 1
            $parsedown = new Parsedown($this->builder, ['selectors' => $selectors, 'url' => $url]);
894 1
            $parsedown->body($markdown);
895 1
            $return = $parsedown->contentsList($format);
896
        } catch (\Exception) {
897
            throw new RuntimeException('"toc" filter can not convert supplied Markdown.');
898
        }
899
900 1
        return $return;
901
    }
902
903
    /**
904
     * Converts a JSON string to an array.
905
     *
906
     * @throws RuntimeException
907
     */
908 1
    public function jsonDecode(?string $json): ?array
909
    {
910 1
        $json = $json ?? '';
911
912
        try {
913 1
            $array = json_decode($json, true);
914 1
            if ($array === null && json_last_error() !== JSON_ERROR_NONE) {
915 1
                throw new \Exception('JSON error.');
916
            }
917
        } catch (\Exception) {
918
            throw new RuntimeException('"json_decode" filter can not parse supplied JSON.');
919
        }
920
921 1
        return $array;
922
    }
923
924
    /**
925
     * Converts a YAML string to an array.
926
     *
927
     * @throws RuntimeException
928
     */
929 1
    public function yamlParse(?string $yaml): ?array
930
    {
931 1
        $yaml = $yaml ?? '';
932
933
        try {
934 1
            $array = Yaml::parse($yaml, Yaml::PARSE_DATETIME);
935 1
            if (!\is_array($array)) {
936 1
                throw new ParseException('YAML error.');
937
            }
938
        } catch (ParseException $e) {
939
            throw new RuntimeException(\sprintf('"yaml_parse" filter can not parse supplied YAML: %s', $e->getMessage()));
940
        }
941
942 1
        return $array;
943
    }
944
945
    /**
946
     * Split a string into an array using a regular expression.
947
     *
948
     * @throws RuntimeException
949
     */
950
    public function pregSplit(?string $value, string $pattern, int $limit = 0): ?array
951
    {
952
        $value = $value ?? '';
953
954
        try {
955
            $array = preg_split($pattern, $value, $limit);
956
            if ($array === false) {
957
                throw new RuntimeException('PREG split error.');
958
            }
959
        } catch (\Exception) {
960
            throw new RuntimeException('"preg_split" filter can not split supplied string.');
961
        }
962
963
        return $array;
964
    }
965
966
    /**
967
     * Perform a regular expression match and return the group for all matches.
968
     *
969
     * @throws RuntimeException
970
     */
971
    public function pregMatchAll(?string $value, string $pattern, int $group = 0): ?array
972
    {
973
        $value = $value ?? '';
974
975
        try {
976
            $array = preg_match_all($pattern, $value, $matches, PREG_PATTERN_ORDER);
977
            if ($array === false) {
978
                throw new RuntimeException('PREG match all error.');
979
            }
980
        } catch (\Exception) {
981
            throw new RuntimeException('"preg_match_all" filter can not match in supplied string.');
982
        }
983
984
        return $matches[$group];
985
    }
986
987
    /**
988
     * Calculates estimated time to read a text.
989
     */
990 1
    public function readtime(?string $text): string
991
    {
992 1
        $text = $text ?? '';
993
994 1
        $words = str_word_count(strip_tags($text));
995 1
        $min = floor($words / 200);
996 1
        if ($min === 0) {
997
            return '1';
998
        }
999
1000 1
        return (string) $min;
1001
    }
1002
1003
    /**
1004
     * Gets the value of an environment variable.
1005
     */
1006 1
    public function getEnv(?string $var): ?string
1007
    {
1008 1
        $var = $var ?? '';
1009
1010 1
        return getenv($var) ?: null;
1011
    }
1012
1013
    /**
1014
     * Dump variable (or Twig context).
1015
     */
1016 1
    public function varDump(\Twig\Environment $env, array $context, $var = null, ?array $options = null): void
1017
    {
1018 1
        if (!$env->isDebug()) {
1019
            return;
1020
        }
1021
1022 1
        if ($var === null) {
1023
            $var = array();
1024
            foreach ($context as $key => $value) {
1025
                if (!$value instanceof \Twig\Template && !$value instanceof \Twig\TemplateWrapper) {
1026
                    $var[$key] = $value;
1027
                }
1028
            }
1029
        }
1030
1031 1
        $cloner = new VarCloner();
1032 1
        $cloner->setMinDepth(3);
1033 1
        $dumper = new HtmlDumper();
1034 1
        $dumper->setTheme($options['theme'] ?? 'light');
1035
1036 1
        $data = $cloner->cloneVar($var)->withMaxDepth(3);
1037 1
        $dumper->dump($data, null, ['maxDepth' => 3]);
1038
    }
1039
1040
    /**
1041
     * Tests if a variable is an Asset.
1042
     */
1043 1
    public function isAsset($variable): bool
1044
    {
1045 1
        return $variable instanceof Asset;
1046
    }
1047
1048
    /**
1049
     * Tests if an image Asset is large enough to be used as a cover image.
1050
     * A large image is defined as having a width >= 600px and height >= 315px.
1051
     */
1052 1
    public function isImageLarge(Asset $asset): bool
1053
    {
1054 1
        return $asset['type'] == 'image' && $asset['width'] > $asset['height'] && $asset['width'] >= 600 && $asset['height'] >= 315;
1055
    }
1056
1057
    /**
1058
     * Tests if an image Asset is square.
1059
     * A square image is defined as having the same width and height.
1060
     */
1061 1
    public function isImageSquare(Asset $asset): bool
1062
    {
1063 1
        return $asset['type'] == 'image' && $asset['width'] == $asset['height'];
1064
    }
1065
1066
    /**
1067
     * Returns the dominant hex color of an image asset.
1068
     *
1069
     * @param string|Asset $asset
1070
     *
1071
     * @return string
1072
     */
1073 1
    public function dominantColor($asset): string
1074
    {
1075 1
        if (!$asset instanceof Asset) {
1076
            $asset = new Asset($this->builder, $asset);
1077
        }
1078
1079 1
        return Image::getDominantColor($asset);
1080
    }
1081
1082
    /**
1083
     * Returns a Low Quality Image Placeholder (LQIP) as data URL.
1084
     *
1085
     * @param string|Asset $asset
1086
     *
1087
     * @return string
1088
     */
1089 1
    public function lqip($asset): string
1090
    {
1091 1
        if (!$asset instanceof Asset) {
1092
            $asset = new Asset($this->builder, $asset);
1093
        }
1094
1095 1
        return Image::getLqip($asset);
1096
    }
1097
1098
    /**
1099
     * Converts an hexadecimal color to RGB.
1100
     *
1101
     * @throws RuntimeException
1102
     */
1103 1
    public function hexToRgb(?string $variable): array
1104
    {
1105 1
        $variable = $variable ?? '';
1106
1107 1
        if (!self::isHex($variable)) {
1108
            throw new RuntimeException(\sprintf('"%s" is not a valid hexadecimal value.', $variable));
1109
        }
1110 1
        $hex = ltrim($variable, '#');
1111 1
        if (\strlen($hex) == 3) {
1112
            $hex = $hex[0] . $hex[0] . $hex[1] . $hex[1] . $hex[2] . $hex[2];
1113
        }
1114 1
        $c = hexdec($hex);
1115
1116 1
        return [
1117 1
            'red'   => $c >> 16 & 0xFF,
1118 1
            'green' => $c >> 8 & 0xFF,
1119 1
            'blue'  => $c & 0xFF,
1120 1
        ];
1121
    }
1122
1123
    /**
1124
     * Split a string in multiple lines.
1125
     */
1126 1
    public function splitLine(?string $variable, int $max = 18): array
1127
    {
1128 1
        $variable = $variable ?? '';
1129
1130 1
        return preg_split("/.{0,{$max}}\K(\s+|$)/", $variable, 0, PREG_SPLIT_NO_EMPTY);
1131
    }
1132
1133
    /**
1134
     * Hashing an object, an array or a string (with algo, md5 by default).
1135
     */
1136 1
    public function hash(object|array|string $data, $algo = 'md5'): string
1137
    {
1138 1
        switch (\gettype($data)) {
1139 1
            case 'object':
1140 1
                return spl_object_hash($data);
1141
            case 'array':
1142
                return hash($algo, serialize($data));
1143
        }
1144
1145
        return hash($algo, $data);
1146
    }
1147
1148
    /**
1149
     * Converts a variable to an iterable (array).
1150
     */
1151 1
    public function iterable($value): array
1152
    {
1153 1
        if (\is_array($value)) {
1154 1
            return $value;
1155
        }
1156
        if (\is_string($value)) {
1157
            return [$value];
1158
        }
1159
        if ($value instanceof \Traversable) {
1160
            return iterator_to_array($value);
1161
        }
1162
        if ($value instanceof \stdClass) {
1163
            return (array) $value;
1164
        }
1165
        if (\is_object($value)) {
1166
            return [$value];
1167
        }
1168
        if (\is_int($value) || \is_float($value)) {
1169
            return [$value];
1170
        }
1171
        return [$value];
1172
    }
1173
1174
    /**
1175
     * Highlights a code snippet.
1176
     */
1177
    public function highlight(string $code, string $language): string
1178
    {
1179
        return (new Highlighter())->highlight($language, $code)->value;
1180
    }
1181
1182
    /**
1183
     * Returns an array with unique values.
1184
     */
1185 1
    public function unique(array $array): array
1186
    {
1187 1
        return array_intersect_key($array, array_unique(array_map('strtolower', $array), SORT_STRING));
1188
    }
1189
1190
    /**
1191
     * Is a hexadecimal color is valid?
1192
     */
1193 1
    private static function isHex(string $hex): bool
1194
    {
1195 1
        $valid = \is_string($hex);
1196 1
        $hex = ltrim($hex, '#');
1197 1
        $length = \strlen($hex);
1198 1
        $valid = $valid && ($length === 3 || $length === 6);
1199 1
        $valid = $valid && ctype_xdigit($hex);
1200
1201 1
        return $valid;
1202
    }
1203
1204
    /**
1205
     * Builds the HTML attributes string from an array.
1206
     */
1207 1
    private static function htmlAttributes(array $attributes): string
1208
    {
1209 1
        $htmlAttributes = '';
1210 1
        foreach ($attributes as $name => $value) {
1211 1
            $attribute = \sprintf(' %s="%s"', $name, $value);
1212 1
            if (empty($value)) {
1213 1
                $attribute = \sprintf(' %s', $name);
1214
            }
1215 1
            $htmlAttributes .= $attribute;
1216
        }
1217
1218 1
        return $htmlAttributes;
1219
    }
1220
}
1221