Passed
Push — master ( 819544...82e7ac )
by
unknown
05:15
created

Core::html()   F

Complexity

Conditions 13
Paths 580

Size

Total Lines 58
Code Lines 41

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 40
CRAP Score 13.0182

Importance

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

How to fix   Long Method    Complexity   

Long Method

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

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

Commonly applied refactorings include:

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