Passed
Push — master ( 1a1f95...135ed3 )
by
unknown
17:29 queued 10:57
created

Core::html()   F

Complexity

Conditions 12
Paths 436

Size

Total Lines 55
Code Lines 38

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 37
CRAP Score 12.0193

Importance

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

How to fix   Long Method    Complexity   

Long Method

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

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

Commonly applied refactorings include:

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

221
        array_multisort(array_keys(/** @scrutinizer ignore-type */ $collection), $sort, /** @scrutinizer ignore-type */ \SORT_NATURAL | \SORT_FLAG_CASE, $collection);
Loading history...
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

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