Passed
Push — nested-sections ( 2d40b2...7dbf34 )
by Arnaud
12:03 queued 05:37
created

Core::html()   C

Complexity

Conditions 17
Paths 66

Size

Total Lines 83
Code Lines 48

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 31
CRAP Score 29.8423

Importance

Changes 0
Metric Value
cc 17
eloc 48
c 0
b 0
f 0
nc 66
nop 4
dl 0
loc 83
ccs 31
cts 48
cp 0.6458
crap 29.8423
rs 5.2166

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

196
        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

196
        array_multisort(array_keys(/** @scrutinizer ignore-type */ $collection), $sort, /** @scrutinizer ignore-type */ \SORT_NATURAL | \SORT_FLAG_CASE, $collection);
Loading history...
197
198
        return $collection;
199
    }
200 1
201
    /**
202 1
     * Sorts a collection by weight.
203 1
     *
204 1
     * @param \Traversable|array $collection
205
     */
206
    public function sortByWeight($collection): array
207
    {
208
        $callback = function ($a, $b) {
209
            if (!isset($a['weight'])) {
210
                $a['weight'] = 0;
211
            }
212
            if (!isset($b['weight'])) {
213
                $a['weight'] = 0;
214
            }
215
            if ($a['weight'] == $b['weight']) {
216
                return 0;
217
            }
218 1
219
            return $a['weight'] < $b['weight'] ? -1 : 1;
220 1
        };
221
222 1
        if (!\is_array($collection)) {
223 1
            $collection = iterator_to_array($collection);
224
        }
225
        usort(/** @scrutinizer ignore-type */ $collection, $callback);
226
227 1
        return $collection;
228 1
    }
229
230 1
    /**
231
     * Sorts by creation date (or 'updated' date): the most recent first.
232 1
     */
233
    public function sortByDate(\Traversable $collection, string $variable = 'date', bool $descTitle = false): array
234
    {
235
        $callback = function ($a, $b) use ($variable, $descTitle) {
236
            if ($a[$variable] == $b[$variable]) {
237
                // if dates are equal and "descTitle" is true
238
                if ($descTitle && (isset($a['title']) && isset($b['title']))) {
239
                    return strnatcmp($b['title'], $a['title']);
240
                }
241
242
                return 0;
243
            }
244
245
            return $a[$variable] > $b[$variable] ? -1 : 1;
246
        };
247
248
        $collection = iterator_to_array($collection);
249
        usort(/** @scrutinizer ignore-type */ $collection, $callback);
250
251 1
        return $collection;
252
    }
253 1
254 1
    /**
255 1
     * Creates an URL.
256
     *
257 1
     * $options[
258
     *     'canonical' => false,
259
     *     'format'    => 'html',
260 1
     *     'language'  => null,
261 1
     * ];
262
     *
263
     * @param array                  $context
264 1
     * @param Page|Asset|string|null $value
265 1
     * @param array|null             $options
266
     */
267 1
    public function url(array $context, $value = null, array $options = null): string
268 1
    {
269
        $optionsLang = array();
270 1
        $optionsLang['language'] = $context['site']['language'];
271
        $options = array_merge($optionsLang, $options ?? []);
272
273
        return (new Url($this->builder, $value, $options))->getUrl();
274
    }
275
276 1
    /**
277
     * Creates an Asset (CSS, JS, images, etc.) from a path or an array of paths.
278 1
     *
279 1
     * @param string|array $path    File path or array of files path (relative from `assets/` or `static/` dir).
280
     * @param array|null   $options
281 1
     *
282
     * @return Asset
283
     */
284
    public function asset($path, array|null $options = null): Asset
285 1
    {
286
        if (!\is_string($path) && !\is_array($path)) {
287
            throw new RuntimeException(sprintf('Argument of "%s()" must a string or an array.', \Cecil\Util::formatMethodName(__METHOD__)));
288 1
        }
289 1
290
        return new Asset($this->builder, $path, $options);
291 1
    }
292 1
293
    /**
294 1
     * Compiles a SCSS asset.
295
     *
296
     * @param string|Asset $asset
297
     *
298
     * @return Asset
299
     */
300
    public function toCss($asset): Asset
301
    {
302
        if (!$asset instanceof Asset) {
303
            $asset = new Asset($this->builder, $asset);
304
        }
305
306
        return $asset->compile();
307
    }
308
309 1
    /**
310
     * Minifying an asset (CSS or JS).
311 1
     *
312
     * @param string|Asset $asset
313
     *
314
     * @return Asset
315
     */
316
    public function minify($asset): Asset
317
    {
318
        if (!$asset instanceof Asset) {
319
            $asset = new Asset($this->builder, $asset);
320
        }
321
322 1
        return $asset->minify();
323
    }
324 1
325
    /**
326
     * Fingerprinting an asset.
327
     *
328
     * @param string|Asset $asset
329
     *
330
     * @return Asset
331
     */
332
    public function fingerprint($asset): Asset
333
    {
334 1
        if (!$asset instanceof Asset) {
335
            $asset = new Asset($this->builder, $asset);
336 1
        }
337
338
        return $asset->fingerprint();
339
    }
340 1
341
    /**
342
     * Resizes an image.
343
     *
344
     * @param string|Asset $asset
345
     *
346
     * @return Asset
347
     */
348
    public function resize($asset, int $size): Asset
349
    {
350 1
        if (!$asset instanceof Asset) {
351
            $asset = new Asset($this->builder, $asset);
352 1
        }
353
354
        return $asset->resize($size);
355
    }
356 1
357
    /**
358
     * Returns the data URL of an image.
359
     *
360
     * @param string|Asset $asset
361
     *
362
     * @return string
363
     */
364
    public function dataurl($asset): string
365
    {
366 1
        if (!$asset instanceof Asset) {
367
            $asset = new Asset($this->builder, $asset);
368 1
        }
369
370
        return $asset->dataurl();
371
    }
372 1
373
    /**
374
     * Hashing an asset with algo (sha384 by default).
375
     *
376
     * @param string|Asset $asset
377
     * @param string       $algo
378
     *
379
     * @return string
380
     */
381
    public function integrity($asset, string $algo = 'sha384'): string
382 1
    {
383
        if (!$asset instanceof Asset) {
384 1
            $asset = new Asset($this->builder, $asset);
385
        }
386
387
        return $asset->getIntegrity($algo);
388 1
    }
389
390
    /**
391
     * Minifying a CSS string.
392
     */
393
    public function minifyCss(?string $value): string
394
    {
395
        $value = $value ?? '';
396
397
        if ($this->builder->isDebug()) {
398 1
            return $value;
399
        }
400 1
401
        $cache = new Cache($this->builder);
402
        $cacheKey = $cache->createKeyFromString($value);
403
        if (!$cache->has($cacheKey)) {
404 1
            $minifier = new Minify\CSS($value);
405
            $value = $minifier->minify();
406
            $cache->set($cacheKey, $value);
407
        }
408
409
        return $cache->get($cacheKey, $value);
410
    }
411
412
    /**
413
     * Minifying a JavaScript string.
414
     */
415 1
    public function minifyJs(?string $value): string
416
    {
417 1
        $value = $value ?? '';
418 1
419
        if ($this->builder->isDebug()) {
420
            return $value;
421 1
        }
422
423
        $cache = new Cache($this->builder);
424
        $cacheKey = $cache->createKeyFromString($value);
425
        if (!$cache->has($cacheKey)) {
426
            $minifier = new Minify\JS($value);
427 1
            $value = $minifier->minify();
428
            $cache->set($cacheKey, $value);
429 1
        }
430
431 1
        return $cache->get($cacheKey, $value);
432 1
    }
433
434
    /**
435
     * Compiles a SCSS string.
436
     *
437
     * @throws RuntimeException
438
     */
439
    public function scssToCss(?string $value): string
440
    {
441
        $value = $value ?? '';
442
443
        $cache = new Cache($this->builder);
444
        $cacheKey = $cache->createKeyFromString($value);
445
        if (!$cache->has($cacheKey)) {
446
            $scssPhp = new Compiler();
447
            $outputStyles = ['expanded', 'compressed'];
448
            $outputStyle = strtolower((string) $this->config->get('assets.compile.style'));
449 1
            if (!\in_array($outputStyle, $outputStyles)) {
450
                throw new ConfigException(sprintf('"%s" value must be "%s".', 'assets.compile.style', implode('" or "', $outputStyles)));
451 1
            }
452
            $scssPhp->setOutputStyle($outputStyle);
453 1
            $variables = $this->config->get('assets.compile.variables') ?? [];
454 1
            if (!empty($variables)) {
455
                $variables = array_map('ScssPhp\ScssPhp\ValueConverter::parseValue', $variables);
456
                $scssPhp->replaceVariables($variables);
457
            }
458
            $value = $scssPhp->compileString($value)->getCss();
459
            $cache->set($cacheKey, $value);
460
        }
461
462
        return $cache->get($cacheKey, $value);
463
    }
464
465
    /**
466
     * Creates the HTML element of an asset.
467
     *
468
     * $options[
469
     *     'preload'    => false,
470
     *     'responsive' => false,
471
     *     'webp'       => false,
472
     * ];
473
     *
474
     * @throws RuntimeException
475
     */
476
    public function html(array $context, Asset $asset, array $attributes = [], array $options = []): string
477
    {
478
        $htmlAttributes = '';
479
        $preload = false;
480
        $responsive = (bool) $this->config->get('assets.images.responsive.enabled') ?? false;
481
        $webp = (bool) $this->config->get('assets.images.webp.enabled') ?? false;
482
        extract($options, EXTR_IF_EXISTS);
483
484
        // builds HTML attributes
485
        foreach ($attributes as $name => $value) {
486
            $attribute = sprintf(' %s="%s"', $name, $value);
487
            if (!isset($value)) {
488
                $attribute = sprintf(' %s', $name);
489
            }
490
            $htmlAttributes .= $attribute;
491
        }
492
493
        // be sure Asset file is saved
494
        $asset->save();
495
496
        // CSS or JavaScript
497
        switch ($asset['ext']) {
498
            case 'css':
499
                if ($preload) {
500
                    return sprintf(
501
                        '<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>',
502
                        $this->url($context, $asset, $options),
503
                        $htmlAttributes
504
                    );
505
                }
506
507
                return sprintf('<link rel="stylesheet" href="%s"%s>', $this->url($context, $asset, $options), $htmlAttributes);
508
            case 'js':
509
                return sprintf('<script src="%s"%s></script>', $this->url($context, $asset, $options), $htmlAttributes);
510 1
        }
511
        // image
512 1
        if ($asset['type'] == 'image') {
513 1
            // responsive
514 1
            $sizes = '';
515 1
            if (
516 1
                $responsive && $srcset = Image::buildSrcset(
517
                    $asset,
518
                    $this->config->getAssetsImagesWidths()
519 1
                )
520 1
            ) {
521 1
                $htmlAttributes .= sprintf(' srcset="%s"', $srcset);
522
                $sizes = Image::getSizes($attributes['class'] ?? '', $this->config->getAssetsImagesSizes());
523
                $htmlAttributes .= sprintf(' sizes="%s"', $sizes);
524 1
            }
525
526
            // <img> element
527
            $img = sprintf(
528 1
                '<img src="%s" width="' . ($asset['width'] ?: '') . '" height="' . ($asset['height'] ?: '') . '"%s>',
529
                $this->url($context, $asset, $options),
530
                $htmlAttributes
531 1
            );
532 1
533 1
            // WebP conversion?
534 1
            if ($webp && $asset['subtype'] != 'image/webp' && !Image::isAnimatedGif($asset)) {
535 1
                try {
536 1
                    $assetWebp = $asset->webp();
537 1
                    // <source> element
538 1
                    $source = sprintf('<source type="image/webp" srcset="%s">', $assetWebp);
539
                    // responsive
540
                    if ($responsive && $srcset = Image::buildSrcset($assetWebp, $this->config->getAssetsImagesWidths())) {
541 1
                        // <source> element
542 1
                        $source = sprintf(
543 1
                            '<source type="image/webp" srcset="%s" sizes="%s">',
544
                            $srcset,
545
                            $sizes
546 1
                        );
547
                    }
548 1
549
                    return sprintf("<picture>\n  %s\n  %s\n</picture>", $source, $img);
550 1
                } catch (\Exception $e) {
551 1
                    $this->builder->getLogger()->debug($e->getMessage());
552 1
                }
553 1
            }
554
555 1
            return $img;
556 1
        }
557 1
558
        throw new RuntimeException(sprintf('%s is available for CSS, JavaScript and images files only.', '"html" filter'));
559
    }
560
561 1
    /**
562 1
     * Builds the HTML img `srcset` (responsive) attribute of an image Asset.
563 1
     *
564 1
     * @throws RuntimeException
565 1
     */
566
    public function imageSrcset(Asset $asset): string
567
    {
568 1
        return Image::buildSrcset($asset, $this->config->getAssetsImagesWidths());
569
    }
570 1
571
    /**
572
     * Returns the HTML img `sizes` attribute based on a CSS class name.
573
     */
574
    public function imageSizes(string $class): string
575
    {
576
        return Image::getSizes($class, $this->config->getAssetsImagesSizes());
577
    }
578
579
    /**
580
     * Converts an image Asset to WebP format.
581
     *
582
     * @throws RuntimeException
583
     */
584
    public function webp(Asset $asset, ?int $quality = null): Asset
585
    {
586
        if ($asset['subtype'] == 'image/webp') {
587
            return $asset;
588 1
        }
589 1
        if (Image::isAnimatedGif($asset)) {
590
            throw new RuntimeException(sprintf('Can\'t convert the animated GIF "%s" to WebP.', $asset['path']));
591
        }
592
593 1
        try {
594
            return $asset->webp($quality);
595
        } catch (\Exception $e) {
596
            throw new RuntimeException(sprintf('Can\'t convert "%s" to WebP (%s).', $asset['path'], $e->getMessage()));
597
        }
598
    }
599
600
    /**
601
     * Returns the content of an asset.
602
     */
603
    public function inline(Asset $asset): string
604 1
    {
605
        return $asset['content'];
606 1
    }
607
608
    /**
609
     * Reads $length first characters of a string and adds a suffix.
610
     */
611
    public function excerpt(?string $string, int $length = 450, string $suffix = ' …'): string
612 1
    {
613
        $string = $string ?? '';
614 1
615
        $string = str_replace('</p>', '<br /><br />', $string);
616
        $string = trim(strip_tags($string, '<br>'), '<br />');
617
        if (mb_strlen($string) > $length) {
618
            $string = mb_substr($string, 0, $length);
619
            $string .= $suffix;
620
        }
621
622
        return $string;
623
    }
624
625
    /**
626
     * Reads characters before or after '<!-- separator -->'.
627
     * Options:
628
     *  - separator: string to use as separator (`excerpt|break` by default)
629
     *  - capture: part to capture, `before` or `after` the separator (`before` by default).
630
     */
631
    public function excerptHtml(?string $string, array $options = []): string
632
    {
633
        $string = $string ?? '';
634
635
        $separator = (string) $this->config->get('pages.body.excerpt.separator');
636
        $capture = (string) $this->config->get('pages.body.excerpt.capture');
637
        extract($options, EXTR_IF_EXISTS);
638
639
        // https://regex101.com/r/n9TWHF/1
640 1
        $pattern = '(.*)<!--[[:blank:]]?(' . $separator . ')[[:blank:]]?-->(.*)';
641
        preg_match('/' . $pattern . '/is', $string, $matches);
642 1
643
        if (empty($matches)) {
644
            return $string;
645
        }
646
        $result = trim($matches[1]);
647
        if ($capture == 'after') {
648
            $result = trim($matches[3]);
649
        }
650
        // removes footnotes and returns result
651
        return preg_replace('/<sup[^>]*>[^u]*<\/sup>/', '', $result);
652
    }
653
654
    /**
655
     * Converts a Markdown string to HTML.
656
     *
657
     * @throws RuntimeException
658
     */
659
    public function markdownToHtml(?string $markdown): ?string
660
    {
661
        $markdown = $markdown ?? '';
662
663
        try {
664
            $parsedown = new Parsedown($this->builder);
665
            $html = $parsedown->text($markdown);
666
        } catch (\Exception) {
667
            throw new RuntimeException('"markdown_to_html" filter can not convert supplied Markdown.');
668 1
        }
669
670 1
        return $html;
671
    }
672 1
673 1
    /**
674 1
     * Extract table of content of a Markdown string,
675
     * in the given format ("html" or "json", "html" by default).
676
     *
677 1
     * @throws RuntimeException
678 1
     */
679
    public function markdownToToc(?string $markdown, $format = 'html', $url = ''): ?string
680 1
    {
681 1
        $markdown = $markdown ?? '';
682
683 1
        try {
684 1
            $parsedown = new Parsedown($this->builder, ['selectors' => ['h2'], 'url' => $url]);
685 1
            $parsedown->body($markdown);
686
            $return = $parsedown->contentsList($format);
687
        } catch (\Exception) {
688 1
            throw new RuntimeException('"toc" filter can not convert supplied Markdown.');
689
        }
690
691
        return $return;
692
    }
693
694
    /**
695
     * Converts a JSON string to an array.
696 1
     *
697
     * @throws RuntimeException
698 1
     */
699
    public function jsonDecode(?string $json): ?array
700
    {
701 1
        $json = $json ?? '';
702 1
703
        try {
704
            $array = json_decode($json, true);
705
            if ($array === null && json_last_error() !== JSON_ERROR_NONE) {
706
                throw new \Exception('JSON error.');
707 1
            }
708
        } catch (\Exception) {
709
            throw new RuntimeException('"json_decode" filter can not parse supplied JSON.');
710
        }
711
712
        return $array;
713
    }
714
715
    /**
716
     * Converts a YAML string to an array.
717
     *
718
     * @throws RuntimeException
719
     */
720
    public function yamlParse(?string $yaml): ?array
721
    {
722
        $yaml = $yaml ?? '';
723
724
        try {
725
            $array = Yaml::parse($yaml, Yaml::PARSE_DATETIME);
726
            if (!\is_array($array)) {
727
                throw new ParseException('YAML error.');
728
            }
729
        } catch (ParseException $e) {
730
            throw new RuntimeException(sprintf('"yaml_parse" filter can not parse supplied YAML: %s', $e->getMessage()));
731
        }
732
733
        return $array;
734
    }
735
736 1
    /**
737
     * Split a string into an array using a regular expression.
738 1
     *
739
     * @throws RuntimeException
740
     */
741 1
    public function pregSplit(?string $value, string $pattern, int $limit = 0): ?array
742 1
    {
743 1
        $value = $value ?? '';
744
745
        try {
746
            $array = preg_split($pattern, $value, $limit);
747
            if ($array === false) {
748
                throw new RuntimeException('PREG split error.');
749 1
            }
750
        } catch (\Exception) {
751
            throw new RuntimeException('"preg_split" filter can not split supplied string.');
752
        }
753
754
        return $array;
755
    }
756
757 1
    /**
758
     * Perform a regular expression match and return the group for all matches.
759 1
     *
760
     * @throws RuntimeException
761
     */
762 1
    public function pregMatchAll(?string $value, string $pattern, int $group = 0): ?array
763 1
    {
764 1
        $value = $value ?? '';
765
766
        try {
767
            $array = preg_match_all($pattern, $value, $matches, PREG_PATTERN_ORDER);
768
            if ($array === false) {
769
                throw new RuntimeException('PREG match all error.');
770 1
            }
771
        } catch (\Exception) {
772
            throw new RuntimeException('"preg_match_all" filter can not match in supplied string.');
773
        }
774
775
        return $matches[$group];
776
    }
777
778
    /**
779
     * Calculates estimated time to read a text.
780
     */
781
    public function readtime(?string $text): string
782
    {
783
        $text = $text ?? '';
784
785
        $words = str_word_count(strip_tags($text));
786
        $min = floor($words / 200);
787
        if ($min === 0) {
788
            return '1';
789
        }
790
791
        return (string) $min;
792
    }
793
794
    /**
795
     * Gets the value of an environment variable.
796
     */
797
    public function getEnv(?string $var): ?string
798
    {
799
        $var = $var ?? '';
800
801
        return getenv($var) ?: null;
802
    }
803
804
    /**
805
     * Dump variable (or Twig context).
806
     */
807
    public function varDump(\Twig\Environment $env, array $context, $var = null, ?array $options = null): void
808
    {
809
        if (!$env->isDebug()) {
810
            return;
811
        }
812
813
        if ($var === null) {
814
            $var = array();
815
            foreach ($context as $key => $value) {
816
                if (!$value instanceof \Twig\Template && !$value instanceof \Twig\TemplateWrapper) {
817
                    $var[$key] = $value;
818
                }
819
            }
820
        }
821
822
        $cloner = new VarCloner();
823
        $cloner->setMinDepth(4);
824
        $dumper = new HtmlDumper();
825
        $dumper->setTheme($options['theme'] ?? 'light');
826
827
        $data = $cloner->cloneVar($var)->withMaxDepth(4);
828
        $dumper->dump($data, null, ['maxDepth' => 4]);
829
    }
830
831
    /**
832
     * Tests if a variable is an Asset.
833
     */
834 1
    public function isAsset($variable): bool
835
    {
836 1
        return $variable instanceof Asset;
837
    }
838 1
839
    /**
840
     * Returns the dominant hex color of an image asset.
841
     *
842
     * @param string|Asset $asset
843
     *
844
     * @return string
845
     */
846
    public function dominantColor($asset): string
847
    {
848
        if (!$asset instanceof Asset) {
849
            $asset = new Asset($this->builder, $asset);
850
        }
851
852
        return Image::getDominantColor($asset);
853
    }
854
855
    /**
856 1
     * Returns a Low Quality Image Placeholder (LQIP) as data URL.
857
     *
858 1
     * @param string|Asset $asset
859
     *
860
     * @return string
861
     */
862 1
    public function lqip($asset): string
863
    {
864
        if (!$asset instanceof Asset) {
865
            $asset = new Asset($this->builder, $asset);
866
        }
867
868
        return Image::getLqip($asset);
869
    }
870
871
    /**
872 1
     * Converts an hexadecimal color to RGB.
873
     *
874 1
     * @throws RuntimeException
875
     */
876
    public function hexToRgb(?string $variable): array
877
    {
878 1
        $variable = $variable ?? '';
879
880
        if (!self::isHex($variable)) {
881
            throw new RuntimeException(sprintf('"%s" is not a valid hexadecimal value.', $variable));
882
        }
883
        $hex = ltrim($variable, '#');
884
        if (\strlen($hex) == 3) {
885
            $hex = $hex[0] . $hex[0] . $hex[1] . $hex[1] . $hex[2] . $hex[2];
886
        }
887
        $c = hexdec($hex);
888
889
        return [
890
            'red'   => $c >> 16 & 0xFF,
891
            'green' => $c >> 8 & 0xFF,
892
            'blue'  => $c & 0xFF,
893
        ];
894
    }
895
896
    /**
897
     * Split a string in multiple lines.
898
     */
899
    public function splitLine(?string $variable, int $max = 18): array
900
    {
901
        $variable = $variable ?? '';
902
903
        return preg_split("/.{0,{$max}}\K(\s+|$)/", $variable, 0, PREG_SPLIT_NO_EMPTY);
904
    }
905
906
    /**
907
     * Is a hexadecimal color is valid?
908
     */
909
    private static function isHex(string $hex): bool
910
    {
911
        $valid = \is_string($hex);
912
        $hex = ltrim($hex, '#');
913
        $length = \strlen($hex);
914
        $valid = $valid && ($length === 3 || $length === 6);
915
        $valid = $valid && ctype_xdigit($hex);
916
917
        return $valid;
918
    }
919
}
920