Passed
Push — feat ( 2cbfca...62e979 )
by Arnaud
03:42 queued 10s
created

Core::html()   C

Complexity

Conditions 17
Paths 66

Size

Total Lines 87
Code Lines 52

Duplication

Lines 0
Ratio 0 %

Importance

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

243
        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

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