Core::html()   F
last analyzed

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

236
        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

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