Passed
Pull Request — master (#1676)
by Arnaud
09:49 queued 04:14
created

Core::markdownToToc()   A

Complexity

Conditions 2
Paths 4

Size

Total Lines 13
Code Lines 8

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 6
CRAP Score 2.0625

Importance

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

195
        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

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