Passed
Push — master ( b061c6...dc3f7e )
by Arnaud
05:08
created

Core::html()   C

Complexity

Conditions 11
Paths 292

Size

Total Lines 50
Code Lines 34

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 33
CRAP Score 11.0225

Importance

Changes 2
Bugs 1 Features 0
Metric Value
cc 11
eloc 34
c 2
b 1
f 0
nc 292
nop 4
dl 0
loc 50
ccs 33
cts 35
cp 0.9429
crap 11.0225
rs 5.3333

How to fix   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
            $as = $asset['type'];
560 1
            switch ($asset['ext']) {
561 1
                case 'css':
562 1
                    $html[] = $this->htmlCss($context, $asset, $attr, $options);
563 1
                    $as = 'style';
564 1
                    break;
565 1
                case 'js':
566 1
                    $html[] = $this->htmlJs($context, $asset, $attr, $options);
567 1
                    $as = 'script';
568 1
                    break;
569
            }
570
            // process by MIME type
571 1
            switch ($asset['type']) {
572 1
                case 'image':
573 1
                    $html[] = $this->htmlImage($context, $asset, $attr, $options);
574 1
                    break;
575 1
                case 'video':
576 1
                    $html[] = $this->htmlVideo($context, $asset, $attr, $options);
577 1
                    break;
578
            }
579
            // preload
580 1
            if ($options['preload'] ?? false) {
581 1
                array_unshift($html, \sprintf('<link rel="preload" href="%s" as="%s"%s>', $this->url($context, $asset, $options), $as, self::htmlAttributes($attributes)));
582
            }
583 1
            unset($attr);
584
        }
585 1
        if (empty($html)) {
586
            throw new RuntimeException(\sprintf('%s is available for CSS, JavaScript and image files only.', '"html" filter'));
587
        }
588
589 1
        return implode(PHP_EOL, $html);
590
    }
591
592
    /**
593
     * Builds the HTML link element of a CSS Asset.
594
     */
595 1
    public function htmlCss(array $context, Asset $asset, array $attributes = [], array $options = []): string
596
    {
597 1
        return \sprintf('<link rel="stylesheet" href="%s"%s>', $this->url($context, $asset, $options), self::htmlAttributes($attributes));
598
    }
599
600
    /**
601
     * Builds the HTML script element of a JS Asset.
602
     */
603 1
    public function htmlJs(array $context, Asset $asset, array $attributes = [], array $options = []): string
604
    {
605 1
        return \sprintf('<script src="%s"%s></script>', $this->url($context, $asset, $options), self::htmlAttributes($attributes));
606
    }
607
608
    /**
609
     * Builds the HTML img element of an image Asset.
610
     */
611 1
    public function htmlImage(array $context, Asset $asset, array $attributes = [], array $options = []): string
612
    {
613 1
        $htmlAttributes = self::htmlAttributes($attributes);
614 1
        if (!isset($attributes['alt'])) {
615 1
            $htmlAttributes .= ' alt=""';
616
        }
617 1
        $responsive = $options['responsive'] ?? $this->config->get('layouts.images.responsive');
618
619
        // build responsive attributes
620
        try {
621 1
            if ($responsive === true || $responsive == 'width') {
622 1
                $srcset = Image::buildHtmlSrcsetW($asset, $this->config->getAssetsImagesWidths());
623 1
                $htmlAttributes .= \sprintf(' srcset="%s" sizes="%s"', $srcset, Image::getHtmlSizes($attributes['class'] ?? '', $this->config->getAssetsImagesSizes()));
624
                // prevent oversized images
625 1
                if ($asset['width'] > max($this->config->getAssetsImagesWidths())) {
626
                    $asset = $asset->resize(max($this->config->getAssetsImagesWidths()));
627
                }
628
            }
629 1
            if ($responsive == 'density') {
630 1
                $width1x = isset($attributes['width']) && $attributes['width'] > 0 ? (int) $attributes['width'] : $asset['width'];
631 1
                $srcset = Image::buildHtmlSrcsetX($asset, $width1x, $this->config->getAssetsImagesDensities());
632 1
                $htmlAttributes .= \sprintf(' srcset="%s"', $srcset);
633
            }
634
        } catch (\Exception $e) {
635
            $this->builder->getLogger()->warning($e->getMessage());
636
        }
637
638
        // create alternative formats (`<source>`)
639
        try {
640 1
            $formats = $options['formats'] ?? (array) $this->config->get('layouts.images.formats');
641 1
            if (\count($formats) > 0) {
642 1
                $source = '';
643 1
                foreach ($formats as $format) {
644
                    try {
645 1
                        $assetConverted = $asset->convert($format);
646
                        // responsive
647
                        if ($responsive === true || $responsive == 'width') {
648
                            $srcset = Image::buildHtmlSrcsetW($assetConverted, $this->config->getAssetsImagesWidths());
649
                            $source .= \sprintf("\n  <source type=\"image/$format\" srcset=\"%s\" sizes=\"%s\">", $srcset, Image::getHtmlSizes($attributes['class'] ?? '', $this->config->getAssetsImagesSizes()));
650
                            continue;
651
                        }
652
                        if ($responsive == 'density') {
653
                            $width1x = isset($attributes['width']) && $attributes['width'] > 0 ? (int) $attributes['width'] : $asset['width'];
654
                            $srcset = Image::buildHtmlSrcsetX($assetConverted, $width1x, $this->config->getAssetsImagesDensities());
655
                            $source .= \sprintf("\n  <source type=\"image/$format\" srcset=\"%s\">", $srcset);
656
                            continue;
657
                        }
658
                        $source .= \sprintf("\n  <source type=\"image/$format\" srcset=\"%s\">", $assetConverted);
659 1
                    } catch (\Exception $e) {
660 1
                        $this->builder->getLogger()->warning($e->getMessage());
661 1
                        continue;
662
                    }
663
                }
664
            }
665
        } catch (\Exception $e) {
666
            $this->builder->getLogger()->warning($e->getMessage());
667
        }
668
669
        // create `<img>` element
670 1
        if (isset($attributes['width']) && $attributes['width'] > 0) {
671 1
            $asset = $asset->resize((int) $attributes['width']);
672
        }
673 1
        if (!isset($attributes['width'])) {
674 1
            $htmlAttributes .= \sprintf(' width="%s"', $asset['width'] ?: '');
675
        }
676 1
        $htmlAttributes .= \sprintf(' height="%s"', $asset['height'] ?: '');
677 1
        $img = \sprintf('<img src="%s"%s>', $this->url($context, $asset, $options), $htmlAttributes);
678
679
        // put `<source>` elements in `<picture>` if exists
680 1
        if (!empty($source)) {
681
            return \sprintf("<picture>%s\n  %s\n</picture>", $source, $img);
682
        }
683
684 1
        return $img;
685
    }
686
687
    /**
688
     * Builds the HTML video element of a video Asset.
689
     */
690 1
    public function htmlVideo(array $context, Asset $asset, array $attributes = [], array $options = []): string
691
    {
692 1
        if (empty($attributes)) {
693 1
            $attributes['controls'] = '';
694
        }
695
696 1
        return \sprintf('<video%s><source src="%s" type="%s"></video>', self::htmlAttributes($attributes), $this->url($context, $asset, $options), $asset['subtype']);
697
    }
698
699
    /**
700
     * Builds the HTML img `srcset` (responsive) attribute of an image Asset, based on configured widths.
701
     *
702
     * @throws RuntimeException
703
     */
704 1
    public function imageSrcset(Asset $asset): string
705
    {
706 1
        return Image::buildHtmlSrcsetW($asset, $this->config->getAssetsImagesWidths(), true);
707
    }
708
709
    /**
710
     * Returns the HTML img `sizes` attribute based on a CSS class name.
711
     */
712 1
    public function imageSizes(string $class): string
713
    {
714 1
        return Image::getHtmlSizes($class, $this->config->getAssetsImagesSizes());
715
    }
716
717
    /**
718
     * Converts an image Asset to WebP format.
719
     */
720
    public function webp(Asset $asset, ?int $quality = null): Asset
721
    {
722
        return $this->convert($asset, 'webp', $quality);
723
    }
724
725
    /**
726
     * Converts an image Asset to AVIF format.
727
     */
728
    public function avif(Asset $asset, ?int $quality = null): Asset
729
    {
730
        return $this->convert($asset, 'avif', $quality);
731
    }
732
733
    /**
734
     * Converts an image Asset to the given format.
735
     *
736
     * @throws RuntimeException
737
     */
738
    private function convert(Asset $asset, string $format, ?int $quality = null): Asset
739
    {
740
        if ($asset['subtype'] == "image/$format") {
741
            return $asset;
742
        }
743
        if (Image::isAnimatedGif($asset)) {
744
            throw new RuntimeException(\sprintf('Unable to convert the animated GIF "%s" to %s.', $asset['path'], $format));
745
        }
746
747
        try {
748
            return $asset->$format($quality);
749
        } catch (\Exception $e) {
750
            throw new RuntimeException(\sprintf('Unable to convert "%s" to %s (%s).', $asset['path'], $format, $e->getMessage()));
751
        }
752
    }
753
754
    /**
755
     * Returns the content of an asset.
756
     */
757 1
    public function inline(Asset $asset): string
758
    {
759 1
        return $asset['content'];
760
    }
761
762
    /**
763
     * Reads $length first characters of a string and adds a suffix.
764
     */
765 1
    public function excerpt(?string $string, int $length = 450, string $suffix = ' …'): string
766
    {
767 1
        $string = $string ?? '';
768
769 1
        $string = str_replace('</p>', '<br><br>', $string);
770 1
        $string = trim(strip_tags($string, '<br>'));
771 1
        if (mb_strlen($string) > $length) {
772 1
            $string = mb_substr($string, 0, $length);
773 1
            $string .= $suffix;
774
        }
775
776 1
        return $string;
777
    }
778
779
    /**
780
     * Reads characters before or after '<!-- separator -->'.
781
     * Options:
782
     *  - separator: string to use as separator (`excerpt|break` by default)
783
     *  - capture: part to capture, `before` or `after` the separator (`before` by default).
784
     */
785 1
    public function excerptHtml(?string $string, array $options = []): string
786
    {
787 1
        $string = $string ?? '';
788
789 1
        $separator = (string) $this->config->get('pages.body.excerpt.separator');
790 1
        $capture = (string) $this->config->get('pages.body.excerpt.capture');
791 1
        extract($options, EXTR_IF_EXISTS);
792
793
        // https://regex101.com/r/n9TWHF/1
794 1
        $pattern = '(.*)<!--[[:blank:]]?(' . $separator . ')[[:blank:]]?-->(.*)';
795 1
        preg_match('/' . $pattern . '/is', $string, $matches);
796
797 1
        if (empty($matches)) {
798
            return $string;
799
        }
800 1
        $result = trim($matches[1]);
801 1
        if ($capture == 'after') {
802 1
            $result = trim($matches[3]);
803
        }
804
        // removes footnotes and returns result
805 1
        return preg_replace('/<sup[^>]*>[^u]*<\/sup>/', '', $result);
806
    }
807
808
    /**
809
     * Converts a Markdown string to HTML.
810
     *
811
     * @throws RuntimeException
812
     */
813 1
    public function markdownToHtml(?string $markdown): ?string
814
    {
815 1
        $markdown = $markdown ?? '';
816
817
        try {
818 1
            $parsedown = new Parsedown($this->builder);
819 1
            $html = $parsedown->text($markdown);
820
        } catch (\Exception $e) {
821
            throw new RuntimeException(
822
                '"markdown_to_html" filter can not convert supplied Markdown.',
823
                previous: $e
824
            );
825
        }
826
827 1
        return $html;
828
    }
829
830
    /**
831
     * Extract table of content of a Markdown string,
832
     * in the given format ("html" or "json", "html" by default).
833
     *
834
     * @throws RuntimeException
835
     */
836 1
    public function markdownToToc(?string $markdown, $format = 'html', ?array $selectors = null, string $url = ''): ?string
837
    {
838 1
        $markdown = $markdown ?? '';
839 1
        $selectors = $selectors ?? (array) $this->config->get('pages.body.toc');
840
841
        try {
842 1
            $parsedown = new Parsedown($this->builder, ['selectors' => $selectors, 'url' => $url]);
843 1
            $parsedown->body($markdown);
844 1
            $return = $parsedown->contentsList($format);
845
        } catch (\Exception) {
846
            throw new RuntimeException('"toc" filter can not convert supplied Markdown.');
847
        }
848
849 1
        return $return;
850
    }
851
852
    /**
853
     * Converts a JSON string to an array.
854
     *
855
     * @throws RuntimeException
856
     */
857 1
    public function jsonDecode(?string $json): ?array
858
    {
859 1
        $json = $json ?? '';
860
861
        try {
862 1
            $array = json_decode($json, true);
863 1
            if ($array === null && json_last_error() !== JSON_ERROR_NONE) {
864 1
                throw new \Exception('JSON error.');
865
            }
866
        } catch (\Exception) {
867
            throw new RuntimeException('"json_decode" filter can not parse supplied JSON.');
868
        }
869
870 1
        return $array;
871
    }
872
873
    /**
874
     * Converts a YAML string to an array.
875
     *
876
     * @throws RuntimeException
877
     */
878 1
    public function yamlParse(?string $yaml): ?array
879
    {
880 1
        $yaml = $yaml ?? '';
881
882
        try {
883 1
            $array = Yaml::parse($yaml, Yaml::PARSE_DATETIME);
884 1
            if (!\is_array($array)) {
885 1
                throw new ParseException('YAML error.');
886
            }
887
        } catch (ParseException $e) {
888
            throw new RuntimeException(\sprintf('"yaml_parse" filter can not parse supplied YAML: %s', $e->getMessage()));
889
        }
890
891 1
        return $array;
892
    }
893
894
    /**
895
     * Split a string into an array using a regular expression.
896
     *
897
     * @throws RuntimeException
898
     */
899
    public function pregSplit(?string $value, string $pattern, int $limit = 0): ?array
900
    {
901
        $value = $value ?? '';
902
903
        try {
904
            $array = preg_split($pattern, $value, $limit);
905
            if ($array === false) {
906
                throw new RuntimeException('PREG split error.');
907
            }
908
        } catch (\Exception) {
909
            throw new RuntimeException('"preg_split" filter can not split supplied string.');
910
        }
911
912
        return $array;
913
    }
914
915
    /**
916
     * Perform a regular expression match and return the group for all matches.
917
     *
918
     * @throws RuntimeException
919
     */
920
    public function pregMatchAll(?string $value, string $pattern, int $group = 0): ?array
921
    {
922
        $value = $value ?? '';
923
924
        try {
925
            $array = preg_match_all($pattern, $value, $matches, PREG_PATTERN_ORDER);
926
            if ($array === false) {
927
                throw new RuntimeException('PREG match all error.');
928
            }
929
        } catch (\Exception) {
930
            throw new RuntimeException('"preg_match_all" filter can not match in supplied string.');
931
        }
932
933
        return $matches[$group];
934
    }
935
936
    /**
937
     * Calculates estimated time to read a text.
938
     */
939 1
    public function readtime(?string $text): string
940
    {
941 1
        $text = $text ?? '';
942
943 1
        $words = str_word_count(strip_tags($text));
944 1
        $min = floor($words / 200);
945 1
        if ($min === 0) {
946
            return '1';
947
        }
948
949 1
        return (string) $min;
950
    }
951
952
    /**
953
     * Gets the value of an environment variable.
954
     */
955 1
    public function getEnv(?string $var): ?string
956
    {
957 1
        $var = $var ?? '';
958
959 1
        return getenv($var) ?: null;
960
    }
961
962
    /**
963
     * Dump variable (or Twig context).
964
     */
965 1
    public function varDump(\Twig\Environment $env, array $context, $var = null, ?array $options = null): void
966
    {
967 1
        if (!$env->isDebug()) {
968
            return;
969
        }
970
971 1
        if ($var === null) {
972
            $var = array();
973
            foreach ($context as $key => $value) {
974
                if (!$value instanceof \Twig\Template && !$value instanceof \Twig\TemplateWrapper) {
975
                    $var[$key] = $value;
976
                }
977
            }
978
        }
979
980 1
        $cloner = new VarCloner();
981 1
        $cloner->setMinDepth(3);
982 1
        $dumper = new HtmlDumper();
983 1
        $dumper->setTheme($options['theme'] ?? 'light');
984
985 1
        $data = $cloner->cloneVar($var)->withMaxDepth(3);
986 1
        $dumper->dump($data, null, ['maxDepth' => 3]);
987
    }
988
989
    /**
990
     * Tests if a variable is an Asset.
991
     */
992 1
    public function isAsset($variable): bool
993
    {
994 1
        return $variable instanceof Asset;
995
    }
996
997
    /**
998
     * Tests if an image Asset is large enough to be used as a cover image.
999
     * A large image is defined as having a width >= 600px and height >= 315px.
1000
     */
1001 1
    public function isImageLarge(Asset $asset): bool
1002
    {
1003 1
        return $asset['type'] == 'image' && $asset['width'] > $asset['height'] && $asset['width'] >= 600 && $asset['height'] >= 315;
1004
    }
1005
1006
    /**
1007
     * Tests if an image Asset is square.
1008
     * A square image is defined as having the same width and height.
1009
     */
1010 1
    public function isImageSquare(Asset $asset): bool
1011
    {
1012 1
        return $asset['type'] == 'image' && $asset['width'] == $asset['height'];
1013
    }
1014
1015
    /**
1016
     * Returns the dominant hex color of an image asset.
1017
     *
1018
     * @param string|Asset $asset
1019
     *
1020
     * @return string
1021
     */
1022 1
    public function dominantColor($asset): string
1023
    {
1024 1
        if (!$asset instanceof Asset) {
1025
            $asset = new Asset($this->builder, $asset);
1026
        }
1027
1028 1
        return Image::getDominantColor($asset);
1029
    }
1030
1031
    /**
1032
     * Returns a Low Quality Image Placeholder (LQIP) as data URL.
1033
     *
1034
     * @param string|Asset $asset
1035
     *
1036
     * @return string
1037
     */
1038 1
    public function lqip($asset): string
1039
    {
1040 1
        if (!$asset instanceof Asset) {
1041
            $asset = new Asset($this->builder, $asset);
1042
        }
1043
1044 1
        return Image::getLqip($asset);
1045
    }
1046
1047
    /**
1048
     * Converts an hexadecimal color to RGB.
1049
     *
1050
     * @throws RuntimeException
1051
     */
1052 1
    public function hexToRgb(?string $variable): array
1053
    {
1054 1
        $variable = $variable ?? '';
1055
1056 1
        if (!self::isHex($variable)) {
1057
            throw new RuntimeException(\sprintf('"%s" is not a valid hexadecimal value.', $variable));
1058
        }
1059 1
        $hex = ltrim($variable, '#');
1060 1
        if (\strlen($hex) == 3) {
1061
            $hex = $hex[0] . $hex[0] . $hex[1] . $hex[1] . $hex[2] . $hex[2];
1062
        }
1063 1
        $c = hexdec($hex);
1064
1065 1
        return [
1066 1
            'red'   => $c >> 16 & 0xFF,
1067 1
            'green' => $c >> 8 & 0xFF,
1068 1
            'blue'  => $c & 0xFF,
1069 1
        ];
1070
    }
1071
1072
    /**
1073
     * Split a string in multiple lines.
1074
     */
1075 1
    public function splitLine(?string $variable, int $max = 18): array
1076
    {
1077 1
        $variable = $variable ?? '';
1078
1079 1
        return preg_split("/.{0,{$max}}\K(\s+|$)/", $variable, 0, PREG_SPLIT_NO_EMPTY);
1080
    }
1081
1082
    /**
1083
     * Hashing an object, an array or a string (with algo, md5 by default).
1084
     */
1085 1
    public function hash(object|array|string $data, $algo = 'md5'): string
1086
    {
1087 1
        switch (\gettype($data)) {
1088 1
            case 'object':
1089 1
                return spl_object_hash($data);
1090
            case 'array':
1091
                return hash($algo, serialize($data));
1092
        }
1093
1094
        return hash($algo, $data);
1095
    }
1096
1097
    /**
1098
     * Converts a variable to an iterable (array).
1099
     */
1100 1
    public function iterable($value): array
1101
    {
1102 1
        if (\is_array($value)) {
1103 1
            return $value;
1104
        }
1105
        if (\is_string($value)) {
1106
            return [$value];
1107
        }
1108
        if ($value instanceof \Traversable) {
1109
            return iterator_to_array($value);
1110
        }
1111
        if ($value instanceof \stdClass) {
1112
            return (array) $value;
1113
        }
1114
        if (\is_object($value)) {
1115
            return [$value];
1116
        }
1117
        if (\is_int($value) || \is_float($value)) {
1118
            return [$value];
1119
        }
1120
        return [$value];
1121
    }
1122
1123
    /**
1124
     * Highlights a code snippet.
1125
     */
1126
    public function highlight(string $code, string $language): string
1127
    {
1128
        return (new Highlighter())->highlight($language, $code)->value;
1129
    }
1130
1131
    /**
1132
     * Returns an array with unique values.
1133
     */
1134 1
    public function unique(array $array): array
1135
    {
1136 1
        return array_intersect_key($array, array_unique(array_map('strtolower', $array), SORT_STRING));
1137
    }
1138
1139
    /**
1140
     * Is a hexadecimal color is valid?
1141
     */
1142 1
    private static function isHex(string $hex): bool
1143
    {
1144 1
        $valid = \is_string($hex);
1145 1
        $hex = ltrim($hex, '#');
1146 1
        $length = \strlen($hex);
1147 1
        $valid = $valid && ($length === 3 || $length === 6);
1148 1
        $valid = $valid && ctype_xdigit($hex);
1149
1150 1
        return $valid;
1151
    }
1152
1153
    /**
1154
     * Builds the HTML attributes string from an array.
1155
     */
1156 1
    private static function htmlAttributes(array $attributes): string
1157
    {
1158 1
        $htmlAttributes = '';
1159 1
        foreach ($attributes as $name => $value) {
1160 1
            $attribute = \sprintf(' %s="%s"', $name, $value);
1161 1
            if (!isset($value)) {
1162
                $attribute = \sprintf(' %s', $name);
1163
            }
1164 1
            $htmlAttributes .= $attribute;
1165
        }
1166
1167 1
        return $htmlAttributes;
1168
    }
1169
}
1170