Passed
Pull Request — master (#2235)
by Arnaud
09:02 queued 04:01
created

Core::splitLine()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 5
Code Lines 2

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 3
CRAP Score 1

Importance

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

217
        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

217
        array_multisort(array_keys(/** @scrutinizer ignore-type */ $collection), $sort, /** @scrutinizer ignore-type */ \SORT_NATURAL | \SORT_FLAG_CASE, $collection);
Loading history...
218
219 1
        return $collection;
220
    }
221
222
    /**
223
     * Sorts a collection by weight.
224
     *
225
     * @param \Traversable|array $collection
226
     */
227 1
    public function sortByWeight($collection): array
228
    {
229 1
        $callback = function ($a, $b) {
230 1
            if (!isset($a['weight'])) {
231 1
                $a['weight'] = 0;
232
            }
233 1
            if (!isset($b['weight'])) {
234
                $a['weight'] = 0;
235
            }
236 1
            if ($a['weight'] == $b['weight']) {
237 1
                return 0;
238
            }
239
240 1
            return $a['weight'] < $b['weight'] ? -1 : 1;
241 1
        };
242
243 1
        if (!\is_array($collection)) {
244 1
            $collection = iterator_to_array($collection);
245
        }
246 1
        usort(/** @scrutinizer ignore-type */ $collection, $callback);
247
248 1
        return $collection;
249
    }
250
251
    /**
252
     * Sorts by creation date (or 'updated' date): the most recent first.
253
     */
254 1
    public function sortByDate(\Traversable $collection, string $variable = 'date', bool $descTitle = false): array
255
    {
256 1
        $callback = function ($a, $b) use ($variable, $descTitle) {
257 1
            if ($a[$variable] == $b[$variable]) {
258
                // if dates are equal and "descTitle" is true
259 1
                if ($descTitle && (isset($a['title']) && isset($b['title']))) {
260
                    return strnatcmp($b['title'], $a['title']);
261
                }
262
263 1
                return 0;
264
            }
265
266 1
            return $a[$variable] > $b[$variable] ? -1 : 1;
267 1
        };
268
269 1
        $collection = iterator_to_array($collection);
270 1
        usort(/** @scrutinizer ignore-type */ $collection, $callback);
271
272 1
        return $collection;
273
    }
274
275
    /**
276
     * Creates an URL.
277
     *
278
     * $options[
279
     *     'canonical' => false,
280
     *     'format'    => 'html',
281
     *     'language'  => null,
282
     * ];
283
     *
284
     * @param array                  $context
285
     * @param Page|Asset|string|null $value
286
     * @param array|null             $options
287
     */
288 1
    public function url(array $context, $value = null, ?array $options = null): string
289
    {
290 1
        $optionsLang = [];
291 1
        $optionsLang['language'] = (string) $context['site']['language'];
292 1
        $options = array_merge($optionsLang, $options ?? []);
293
294 1
        return (new Url($this->builder, $value, $options))->getUrl();
295
    }
296
297
    /**
298
     * Creates an Asset (CSS, JS, images, etc.) from a path or an array of paths.
299
     *
300
     * @param string|array $path    File path or array of files path (relative from `assets/` or `static/` dir).
301
     * @param array|null   $options
302
     *
303
     * @return Asset
304
     */
305 1
    public function asset($path, array|null $options = null): Asset
306
    {
307 1
        if (!\is_string($path) && !\is_array($path)) {
308
            throw new RuntimeException(\sprintf('Argument of "%s()" must a string or an array.', \Cecil\Util::formatMethodName(__METHOD__)));
309
        }
310
311 1
        return new Asset($this->builder, $path, $options);
312
    }
313
314
    /**
315
     * Compiles a SCSS asset.
316
     *
317
     * @param string|Asset $asset
318
     *
319
     * @return Asset
320
     */
321 1
    public function toCss($asset): Asset
322
    {
323 1
        if (!$asset instanceof Asset) {
324
            $asset = new Asset($this->builder, $asset);
325
        }
326
327 1
        return $asset->compile();
328
    }
329
330
    /**
331
     * Minifying an asset (CSS or JS).
332
     *
333
     * @param string|Asset $asset
334
     *
335
     * @return Asset
336
     */
337 1
    public function minify($asset): Asset
338
    {
339 1
        if (!$asset instanceof Asset) {
340
            $asset = new Asset($this->builder, $asset);
341
        }
342
343 1
        return $asset->minify();
344
    }
345
346
    /**
347
     * Fingerprinting an asset.
348
     *
349
     * @param string|Asset $asset
350
     *
351
     * @return Asset
352
     */
353 1
    public function fingerprint($asset): Asset
354
    {
355 1
        if (!$asset instanceof Asset) {
356
            $asset = new Asset($this->builder, $asset);
357
        }
358
359 1
        return $asset->fingerprint();
360
    }
361
362
    /**
363
     * Resizes an image.
364
     *
365
     * @param string|Asset $asset
366
     *
367
     * @return Asset
368
     */
369 1
    public function resize($asset, int $size): Asset
370
    {
371 1
        if (!$asset instanceof Asset) {
372
            $asset = new Asset($this->builder, $asset);
373
        }
374
375 1
        return $asset->resize($size);
376
    }
377
378
    /**
379
     * Crops an image Asset to the given width and height, keeping the aspect ratio.
380
     *
381
     * @param string|Asset $asset
382
     *
383
     * @return Asset
384
     */
385 1
    public function cover($asset, int $width, int $height): Asset
386
    {
387 1
        if (!$asset instanceof Asset) {
388
            $asset = new Asset($this->builder, $asset);
389
        }
390
391 1
        return $asset->cover($width, $height);
392
    }
393
394
    /**
395
     * Creates a maskable icon from an image asset.
396
     * The maskable icon is used for Progressive Web Apps (PWAs).
397
     *
398
     * @param string|Asset $asset
399
     *
400
     * @return Asset
401
     */
402
    public function maskable($asset, ?int $padding = null): Asset
403
    {
404
        if (!$asset instanceof Asset) {
405
            $asset = new Asset($this->builder, $asset);
406
        }
407
408
        return $asset->maskable($padding);
409
    }
410
411
    /**
412
     * Returns the data URL of an image.
413
     *
414
     * @param string|Asset $asset
415
     *
416
     * @return string
417
     */
418 1
    public function dataurl($asset): string
419
    {
420 1
        if (!$asset instanceof Asset) {
421
            $asset = new Asset($this->builder, $asset);
422
        }
423
424 1
        return $asset->dataurl();
425
    }
426
427
    /**
428
     * Hashing an asset with algo (sha384 by default).
429
     *
430
     * @param string|Asset $asset
431
     * @param string       $algo
432
     *
433
     * @return string
434
     */
435 1
    public function integrity($asset, string $algo = 'sha384'): string
436
    {
437 1
        if (!$asset instanceof Asset) {
438 1
            $asset = new Asset($this->builder, $asset);
439
        }
440
441 1
        return $asset->getIntegrity($algo);
442
    }
443
444
    /**
445
     * Minifying a CSS string.
446
     */
447 1
    public function minifyCss(?string $value): string
448
    {
449 1
        $value = $value ?? '';
450
451 1
        if ($this->builder->isDebug()) {
452 1
            return $value;
453
        }
454
455
        $cache = new Cache($this->builder, 'assets');
456
        $cacheKey = $cache->createKeyFromValue(null, $value);
457
        if (!$cache->has($cacheKey)) {
458
            $minifier = new Minify\CSS($value);
459
            $value = $minifier->minify();
460
            $cache->set($cacheKey, $value, $this->config->get('cache.assets.ttl'));
461
        }
462
463
        return $cache->get($cacheKey, $value);
464
    }
465
466
    /**
467
     * Minifying a JavaScript string.
468
     */
469 1
    public function minifyJs(?string $value): string
470
    {
471 1
        $value = $value ?? '';
472
473 1
        if ($this->builder->isDebug()) {
474 1
            return $value;
475
        }
476
477
        $cache = new Cache($this->builder, 'assets');
478
        $cacheKey = $cache->createKeyFromValue(null, $value);
479
        if (!$cache->has($cacheKey)) {
480
            $minifier = new Minify\JS($value);
481
            $value = $minifier->minify();
482
            $cache->set($cacheKey, $value, $this->config->get('cache.assets.ttl'));
483
        }
484
485
        return $cache->get($cacheKey, $value);
486
    }
487
488
    /**
489
     * Compiles a SCSS string.
490
     *
491
     * @throws RuntimeException
492
     */
493 1
    public function scssToCss(?string $value): string
494
    {
495 1
        $value = $value ?? '';
496
497 1
        $cache = new Cache($this->builder, 'assets');
498 1
        $cacheKey = $cache->createKeyFromValue(null, $value);
499 1
        if (!$cache->has($cacheKey)) {
500 1
            $scssPhp = new Compiler();
501 1
            $outputStyles = ['expanded', 'compressed'];
502 1
            $outputStyle = strtolower((string) $this->config->get('assets.compile.style'));
503 1
            if (!\in_array($outputStyle, $outputStyles)) {
504
                throw new ConfigException(\sprintf('"%s" value must be "%s".', 'assets.compile.style', implode('" or "', $outputStyles)));
505
            }
506 1
            $scssPhp->setOutputStyle($outputStyle == 'compressed' ? OutputStyle::COMPRESSED : OutputStyle::EXPANDED);
507 1
            $variables = $this->config->get('assets.compile.variables');
508 1
            if (!empty($variables)) {
509 1
                $variables = array_map('ScssPhp\ScssPhp\ValueConverter::parseValue', $variables);
510 1
                $scssPhp->replaceVariables($variables);
511
            }
512 1
            $value = $scssPhp->compileString($value)->getCss();
513 1
            $cache->set($cacheKey, $value, $this->config->get('cache.assets.ttl'));
514
        }
515
516 1
        return $cache->get($cacheKey, $value);
517
    }
518
519
    /**
520
     * Creates the HTML element of an asset.
521
     *
522
     * @param array                                              $context    Twig context
523
     * @param Asset|array<int,array{asset:string,media:?string}> $assets     Asset or array of assets and associated media query
524
     * @param array                                              $attributes HTML attributes to add to the element
525
     * @param array                                              $options    Options:
526
     * [
527
     *     'preload'    => false,
528
     *     'responsive' => false,
529
     *     'formats'    => [],
530
     * ];
531
     *
532
     * @return string HTML element
533
     *
534
     * @throws RuntimeException
535
     */
536 1
    public function html(array $context, Asset|array $assets, array $attributes = [], array $options = []): string
537
    {
538 1
        $html = array();
539 1
        if (!\is_array($assets)) {
0 ignored issues
show
introduced by
The condition is_array($assets) is always true.
Loading history...
540 1
            $assets = [['asset' => $assets, 'media' => null]];
541
        }
542 1
        foreach ($assets as $assetData) {
543 1
            $asset = $assetData['asset'];
544 1
            $media = $assetData['media'] ?? null;
545 1
            if (!$asset instanceof Asset) {
546
                $asset = new Asset($this->builder, $asset);
547
            }
548
            // media attribute
549 1
            if ($media !== null) {
550 1
                $attributes['media'] = $media;
551
            }
552
            // be sure Asset file is saved
553 1
            $asset->save();
554
            // CSS or JavaScript
555 1
            switch ($asset['ext']) {
556 1
                case 'css':
557 1
                    $html[] = $this->htmlCss($context, $asset, $attributes, $options);
558 1
                    break;
559 1
                case 'js':
560 1
                    $html[] = $this->htmlJs($context, $asset, $attributes, $options);
561
            }
562
            // image
563 1
            if ($asset['type'] == 'image') {
564 1
                $html[] = $this->htmlImage($context, $asset, $attributes, $options);
565
            }
566 1
            unset($attributes['media']);
567
        }
568
569 1
        return implode(PHP_EOL, $html);
570
571
        throw new RuntimeException(\sprintf('%s is available for CSS, JavaScript and image files only.', '"html" filter'));
0 ignored issues
show
Unused Code introduced by
ThrowNode is not reachable.

This check looks for unreachable code. It uses sophisticated control flow analysis techniques to find statements which will never be executed.

Unreachable code is most often the result of return, die or exit statements that have been added for debug purposes.

function fx() {
    try {
        doSomething();
        return true;
    }
    catch (\Exception $e) {
        return false;
    }

    return false;
}

In the above example, the last return false will never be executed, because a return statement has already been met in every possible execution path.

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