• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

Cecilapp / Cecil / 6601071202

22 Oct 2023 02:05AM UTC coverage: 82.242% (-0.02%) from 82.266%
6601071202

push

github

ArnaudLigny
fix: d() function without arg

2 of 2 new or added lines in 1 file covered. (100.0%)

2825 of 3435 relevant lines covered (82.24%)

0.82 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

67.89
/src/Renderer/Extension/Core.php
1
<?php
2

3
declare(strict_types=1);
4

5
/*
6
 * This file is part of Cecil.
7
 *
8
 * Copyright (c) Arnaud Ligny <arnaud@ligny.fr>
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
    public function __construct(Builder $builder)
52
    {
53
        if (!self::$slugifier instanceof Slugify) {
1✔
54
            self::$slugifier = Slugify::create(['regexp' => Page::SLUGIFY_PATTERN]);
1✔
55
        }
56

57
        parent::__construct(self::$slugifier);
1✔
58

59
        $this->builder = $builder;
1✔
60
        $this->config = $builder->getConfig();
1✔
61
    }
62

63
    /**
64
     * {@inheritdoc}
65
     */
66
    public function getName(): string
67
    {
68
        return 'CoreExtension';
×
69
    }
70

71
    /**
72
     * {@inheritdoc}
73
     */
74
    public function getFunctions()
75
    {
76
        return [
1✔
77
            new \Twig\TwigFunction('url', [$this, 'url']),
1✔
78
            // assets
79
            new \Twig\TwigFunction('asset', [$this, 'asset']),
1✔
80
            new \Twig\TwigFunction('integrity', [$this, 'integrity']),
1✔
81
            new \Twig\TwigFunction('image_srcset', [$this, 'imageSrcset']),
1✔
82
            new \Twig\TwigFunction('image_sizes', [$this, 'imageSizes']),
1✔
83
            // content
84
            new \Twig\TwigFunction('readtime', [$this, 'readtime']),
1✔
85
            // others
86
            new \Twig\TwigFunction('getenv', [$this, 'getEnv']),
1✔
87
            new \Twig\TwigFunction('d', [$this, 'varDump'], ['needs_context' => true, 'needs_environment' => true]),
1✔
88
            // deprecated
89
            new \Twig\TwigFunction(
1✔
90
                'hash',
1✔
91
                [$this, 'integrity'],
1✔
92
                ['deprecated' => true, 'alternative' => 'integrity']
1✔
93
            ),
1✔
94
            new \Twig\TwigFunction(
1✔
95
                'minify',
1✔
96
                [$this, 'minify'],
1✔
97
                ['deprecated' => true, 'alternative' => 'minify filter']
1✔
98
            ),
1✔
99
            new \Twig\TwigFunction(
1✔
100
                'toCSS',
1✔
101
                [$this, 'toCss'],
1✔
102
                ['deprecated' => true, 'alternative' => 'to_css filter']
1✔
103
            ),
1✔
104
        ];
1✔
105
    }
106

107
    /**
108
     * {@inheritdoc}
109
     */
110
    public function getFilters(): array
111
    {
112
        return [
1✔
113
            new \Twig\TwigFilter('url', [$this, 'url']),
1✔
114
            // collections
115
            new \Twig\TwigFilter('sort_by_title', [$this, 'sortByTitle']),
1✔
116
            new \Twig\TwigFilter('sort_by_weight', [$this, 'sortByWeight']),
1✔
117
            new \Twig\TwigFilter('sort_by_date', [$this, 'sortByDate']),
1✔
118
            new \Twig\TwigFilter('filter_by', [$this, 'filterBy']),
1✔
119
            // assets
120
            new \Twig\TwigFilter('html', [$this, 'html']),
1✔
121
            new \Twig\TwigFilter('inline', [$this, 'inline']),
1✔
122
            new \Twig\TwigFilter('fingerprint', [$this, 'fingerprint']),
1✔
123
            new \Twig\TwigFilter('to_css', [$this, 'toCss']),
1✔
124
            new \Twig\TwigFilter('minify', [$this, 'minify']),
1✔
125
            new \Twig\TwigFilter('minify_css', [$this, 'minifyCss']),
1✔
126
            new \Twig\TwigFilter('minify_js', [$this, 'minifyJs']),
1✔
127
            new \Twig\TwigFilter('scss_to_css', [$this, 'scssToCss']),
1✔
128
            new \Twig\TwigFilter('sass_to_css', [$this, 'scssToCss']),
1✔
129
            new \Twig\TwigFilter('resize', [$this, 'resize']),
1✔
130
            new \Twig\TwigFilter('dataurl', [$this, 'dataurl']),
1✔
131
            new \Twig\TwigFilter('dominant_color', [$this, 'dominantColor']),
1✔
132
            new \Twig\TwigFilter('lqip', [$this, 'lqip']),
1✔
133
            new \Twig\TwigFilter('webp', [$this, 'webp']),
1✔
134
            // content
135
            new \Twig\TwigFilter('slugify', [$this, 'slugifyFilter']),
1✔
136
            new \Twig\TwigFilter('excerpt', [$this, 'excerpt']),
1✔
137
            new \Twig\TwigFilter('excerpt_html', [$this, 'excerptHtml']),
1✔
138
            new \Twig\TwigFilter('markdown_to_html', [$this, 'markdownToHtml']),
1✔
139
            new \Twig\TwigFilter('toc', [$this, 'markdownToToc']),
1✔
140
            new \Twig\TwigFilter('json_decode', [$this, 'jsonDecode']),
1✔
141
            new \Twig\TwigFilter('yaml_parse', [$this, 'yamlParse']),
1✔
142
            new \Twig\TwigFilter('preg_split', [$this, 'pregSplit']),
1✔
143
            new \Twig\TwigFilter('preg_match_all', [$this, 'pregMatchAll']),
1✔
144
            new \Twig\TwigFilter('hex_to_rgb', [$this, 'hexToRgb']),
1✔
145
            new \Twig\TwigFilter('splitline', [$this, 'splitLine']),
1✔
146
            // deprecated
147
            new \Twig\TwigFilter(
1✔
148
                'filterBySection',
1✔
149
                [$this, 'filterBySection'],
1✔
150
                ['deprecated' => true, 'alternative' => 'filter_by']
1✔
151
            ),
1✔
152
            new \Twig\TwigFilter(
1✔
153
                'filterBy',
1✔
154
                [$this, 'filterBy'],
1✔
155
                ['deprecated' => true, 'alternative' => 'filter_by']
1✔
156
            ),
1✔
157
            new \Twig\TwigFilter(
1✔
158
                'sortByTitle',
1✔
159
                [$this, 'sortByTitle'],
1✔
160
                ['deprecated' => true, 'alternative' => 'sort_by_title']
1✔
161
            ),
1✔
162
            new \Twig\TwigFilter(
1✔
163
                'sortByWeight',
1✔
164
                [$this, 'sortByWeight'],
1✔
165
                ['deprecated' => true, 'alternative' => 'sort_by_weight']
1✔
166
            ),
1✔
167
            new \Twig\TwigFilter(
1✔
168
                'sortByDate',
1✔
169
                [$this, 'sortByDate'],
1✔
170
                ['deprecated' => true, 'alternative' => 'sort_by_date']
1✔
171
            ),
1✔
172
            new \Twig\TwigFilter(
1✔
173
                'minifyCSS',
1✔
174
                [$this, 'minifyCss'],
1✔
175
                ['deprecated' => true, 'alternative' => 'minifyCss']
1✔
176
            ),
1✔
177
            new \Twig\TwigFilter(
1✔
178
                'minifyJS',
1✔
179
                [$this, 'minifyJs'],
1✔
180
                ['deprecated' => true, 'alternative' => 'minifyJs']
1✔
181
            ),
1✔
182
            new \Twig\TwigFilter(
1✔
183
                'SCSStoCSS',
1✔
184
                [$this, 'scssToCss'],
1✔
185
                ['deprecated' => true, 'alternative' => 'scss_to_css']
1✔
186
            ),
1✔
187
            new \Twig\TwigFilter(
1✔
188
                'excerptHtml',
1✔
189
                [$this, 'excerptHtml'],
1✔
190
                ['deprecated' => true, 'alternative' => 'excerpt_html']
1✔
191
            ),
1✔
192
            new \Twig\TwigFilter(
1✔
193
                'urlize',
1✔
194
                [$this, 'slugifyFilter'],
1✔
195
                ['deprecated' => true, 'alternative' => 'slugify']
1✔
196
            ),
1✔
197
        ];
1✔
198
    }
199

200
    /**
201
     * {@inheritdoc}
202
     */
203
    public function getTests()
204
    {
205
        return [
1✔
206
            new \Twig\TwigTest('asset', [$this, 'isAsset']),
1✔
207
        ];
1✔
208
    }
209

210
    /**
211
     * Filters by Section.
212
     */
213
    public function filterBySection(PagesCollection $pages, string $section): CollectionInterface
214
    {
215
        return $this->filterBy($pages, 'section', $section);
×
216
    }
217

218
    /**
219
     * Filters a pages collection by variable's name/value.
220
     */
221
    public function filterBy(PagesCollection $pages, string $variable, string $value): CollectionInterface
222
    {
223
        $filteredPages = $pages->filter(function (Page $page) use ($variable, $value) {
1✔
224
            // is a dedicated getter exists?
225
            $method = 'get' . ucfirst($variable);
1✔
226
            if (method_exists($page, $method) && $page->$method() == $value) {
1✔
227
                return $page->getType() == Type::PAGE() && !$page->isVirtual() && true;
×
228
            }
229
            // or a classic variable
230
            if ($page->getVariable($variable) == $value) {
1✔
231
                return $page->getType() == Type::PAGE() && !$page->isVirtual() && true;
1✔
232
            }
233
        });
1✔
234

235
        return $filteredPages;
1✔
236
    }
237

238
    /**
239
     * Sorts a collection by title.
240
     */
241
    public function sortByTitle(\Traversable $collection): array
242
    {
243
        $sort = \SORT_ASC;
1✔
244

245
        $collection = iterator_to_array($collection);
1✔
246
        array_multisort(array_keys(/** @scrutinizer ignore-type */ $collection), $sort, \SORT_NATURAL | \SORT_FLAG_CASE, $collection);
1✔
247

248
        return $collection;
1✔
249
    }
250

251
    /**
252
     * Sorts a collection by weight.
253
     */
254
    public function sortByWeight(\Traversable $collection): array
255
    {
256
        $callback = function ($a, $b) {
1✔
257
            if (!isset($a['weight'])) {
1✔
258
                $a['weight'] = 0;
1✔
259
            }
260
            if (!isset($b['weight'])) {
1✔
261
                $a['weight'] = 0;
×
262
            }
263
            if ($a['weight'] == $b['weight']) {
1✔
264
                return 0;
1✔
265
            }
266

267
            return $a['weight'] < $b['weight'] ? -1 : 1;
1✔
268
        };
1✔
269

270
        $collection = iterator_to_array($collection);
1✔
271
        usort(/** @scrutinizer ignore-type */ $collection, $callback);
1✔
272

273
        return $collection;
1✔
274
    }
275

276
    /**
277
     * Sorts by creation date (or 'updated' date): the most recent first.
278
     */
279
    public function sortByDate(\Traversable $collection, string $variable = 'date', bool $descTitle = false): array
280
    {
281
        $callback = function ($a, $b) use ($variable, $descTitle) {
1✔
282
            if ($a[$variable] == $b[$variable]) {
1✔
283
                // if dates are equal and "descTitle" is true
284
                if ($descTitle && (isset($a['title']) && isset($b['title']))) {
1✔
285
                    return strnatcmp($b['title'], $a['title']);
×
286
                }
287

288
                return 0;
1✔
289
            }
290

291
            return $a[$variable] > $b[$variable] ? -1 : 1;
1✔
292
        };
1✔
293

294
        $collection = iterator_to_array($collection);
1✔
295
        usort(/** @scrutinizer ignore-type */ $collection, $callback);
1✔
296

297
        return $collection;
1✔
298
    }
299

300
    /**
301
     * Creates an URL.
302
     *
303
     * $options[
304
     *     'canonical' => false,
305
     *     'format'    => 'html',
306
     *     'language'  => null,
307
     * ];
308
     *
309
     * @param Page|Asset|string|null $value
310
     * @param array|null             $options
311
     */
312
    public function url($value = null, array $options = null): string
313
    {
314
        return (new Url($this->builder, $value, $options))->getUrl();
1✔
315
    }
316

317
    /**
318
     * Creates an Asset (CSS, JS, images, etc.) from a path or an array of paths.
319
     *
320
     * @param string|array $path    File path or array of files path (relative from `assets/` or `static/` dir).
321
     * @param array|null   $options
322
     *
323
     * @return Asset
324
     */
325
    public function asset($path, array $options = null): Asset
326
    {
327
        return new Asset($this->builder, $path, $options);
1✔
328
    }
329

330
    /**
331
     * Compiles a SCSS asset.
332
     *
333
     * @param string|Asset $asset
334
     *
335
     * @return Asset
336
     */
337
    public function toCss($asset): Asset
338
    {
339
        if (!$asset instanceof Asset) {
1✔
340
            $asset = new Asset($this->builder, $asset);
×
341
        }
342

343
        return $asset->compile();
1✔
344
    }
345

346
    /**
347
     * Minifying an asset (CSS or JS).
348
     *
349
     * @param string|Asset $asset
350
     *
351
     * @return Asset
352
     */
353
    public function minify($asset): Asset
354
    {
355
        if (!$asset instanceof Asset) {
1✔
356
            $asset = new Asset($this->builder, $asset);
×
357
        }
358

359
        return $asset->minify();
1✔
360
    }
361

362
    /**
363
     * Fingerprinting an asset.
364
     *
365
     * @param string|Asset $asset
366
     *
367
     * @return Asset
368
     */
369
    public function fingerprint($asset): Asset
370
    {
371
        if (!$asset instanceof Asset) {
1✔
372
            $asset = new Asset($this->builder, $asset);
×
373
        }
374

375
        return $asset->fingerprint();
1✔
376
    }
377

378
    /**
379
     * Resizes an image.
380
     *
381
     * @param string|Asset $asset
382
     *
383
     * @return Asset
384
     */
385
    public function resize($asset, int $size): Asset
386
    {
387
        if (!$asset instanceof Asset) {
1✔
388
            $asset = new Asset($this->builder, $asset);
×
389
        }
390

391
        return $asset->resize($size);
1✔
392
    }
393

394
    /**
395
     * Returns the data URL of an image.
396
     *
397
     * @param string|Asset $asset
398
     *
399
     * @return string
400
     */
401
    public function dataurl($asset): string
402
    {
403
        if (!$asset instanceof Asset) {
1✔
404
            $asset = new Asset($this->builder, $asset);
×
405
        }
406

407
        return $asset->dataurl();
1✔
408
    }
409

410
    /**
411
     * Hashing an asset with algo (sha384 by default).
412
     *
413
     * @param string|Asset $asset
414
     * @param string       $algo
415
     *
416
     * @return string
417
     */
418
    public function integrity($asset, string $algo = 'sha384'): string
419
    {
420
        if (!$asset instanceof Asset) {
1✔
421
            $asset = new Asset($this->builder, $asset);
1✔
422
        }
423

424
        return $asset->getIntegrity($algo);
1✔
425
    }
426

427
    /**
428
     * Minifying a CSS string.
429
     */
430
    public function minifyCss(?string $value): string
431
    {
432
        $value = $value ?? '';
1✔
433

434
        if ($this->builder->isDebug()) {
1✔
435
            return $value;
1✔
436
        }
437

438
        $cache = new Cache($this->builder);
×
439
        $cacheKey = $cache->createKeyFromString($value);
×
440
        if (!$cache->has($cacheKey)) {
×
441
            $minifier = new Minify\CSS($value);
×
442
            $value = $minifier->minify();
×
443
            $cache->set($cacheKey, $value);
×
444
        }
445

446
        return $cache->get($cacheKey, $value);
×
447
    }
448

449
    /**
450
     * Minifying a JavaScript string.
451
     */
452
    public function minifyJs(?string $value): string
453
    {
454
        $value = $value ?? '';
1✔
455

456
        if ($this->builder->isDebug()) {
1✔
457
            return $value;
1✔
458
        }
459

460
        $cache = new Cache($this->builder);
×
461
        $cacheKey = $cache->createKeyFromString($value);
×
462
        if (!$cache->has($cacheKey)) {
×
463
            $minifier = new Minify\JS($value);
×
464
            $value = $minifier->minify();
×
465
            $cache->set($cacheKey, $value);
×
466
        }
467

468
        return $cache->get($cacheKey, $value);
×
469
    }
470

471
    /**
472
     * Compiles a SCSS string.
473
     *
474
     * @throws RuntimeException
475
     */
476
    public function scssToCss(?string $value): string
477
    {
478
        $value = $value ?? '';
×
479

480
        $cache = new Cache($this->builder);
×
481
        $cacheKey = $cache->createKeyFromString($value);
×
482
        if (!$cache->has($cacheKey)) {
×
483
            $scssPhp = new Compiler();
×
484
            $outputStyles = ['expanded', 'compressed'];
×
485
            $outputStyle = strtolower((string) $this->config->get('assets.compile.style'));
×
486
            if (!\in_array($outputStyle, $outputStyles)) {
×
487
                throw new RuntimeException(sprintf('Scss output style "%s" doesn\'t exists.', $outputStyle));
×
488
            }
489
            $scssPhp->setOutputStyle($outputStyle);
×
490
            $variables = $this->config->get('assets.compile.variables') ?? [];
×
491
            if (!empty($variables)) {
×
492
                $variables = array_map('ScssPhp\ScssPhp\ValueConverter::parseValue', $variables);
×
493
                $scssPhp->replaceVariables($variables);
×
494
            }
495
            $value = $scssPhp->compileString($value)->getCss();
×
496
            $cache->set($cacheKey, $value);
×
497
        }
498

499
        return $cache->get($cacheKey, $value);
×
500
    }
501

502
    /**
503
     * Creates the HTML element of an asset.
504
     *
505
     * $options[
506
     *     'preload'    => false,
507
     *     'responsive' => false,
508
     *     'webp'       => false,
509
     * ];
510
     *
511
     * @throws RuntimeException
512
     */
513
    public function html(Asset $asset, array $attributes = [], array $options = []): string
514
    {
515
        $htmlAttributes = '';
1✔
516
        $preload = false;
1✔
517
        $responsive = (bool) $this->config->get('assets.images.responsive.enabled') ?? false;
1✔
518
        $webp = (bool) $this->config->get('assets.images.webp.enabled') ?? false;
1✔
519
        extract($options, EXTR_IF_EXISTS);
1✔
520

521
        // builds HTML attributes
522
        foreach ($attributes as $name => $value) {
1✔
523
            $attribute = sprintf(' %s="%s"', $name, $value);
1✔
524
            if (empty($value)) {
1✔
525
                $attribute = sprintf(' %s', $name);
×
526
            }
527
            $htmlAttributes .= $attribute;
1✔
528
        }
529

530
        // be sure Asset file is saved
531
        $asset->save();
1✔
532

533
        // CSS or JavaScript
534
        switch ($asset['ext']) {
1✔
535
            case 'css':
1✔
536
                if ($preload) {
1✔
537
                    return sprintf(
1✔
538
                        '<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>',
1✔
539
                        $this->url($asset, $options),
1✔
540
                        $htmlAttributes
1✔
541
                    );
1✔
542
                }
543

544
                return sprintf('<link rel="stylesheet" href="%s"%s>', $this->url($asset, $options), $htmlAttributes);
1✔
545
            case 'js':
1✔
546
                return sprintf('<script src="%s"%s></script>', $this->url($asset, $options), $htmlAttributes);
1✔
547
        }
548
        // image
549
        if ($asset['type'] == 'image') {
1✔
550
            // responsive
551
            $sizes = '';
1✔
552
            if (
553
                $responsive && $srcset = Image::buildSrcset(
1✔
554
                    $asset,
1✔
555
                    $this->config->getAssetsImagesWidths()
1✔
556
                )
1✔
557
            ) {
558
                $htmlAttributes .= sprintf(' srcset="%s"', $srcset);
1✔
559
                $sizes = Image::getSizes($attributes['class'] ?? '', $this->config->getAssetsImagesSizes());
1✔
560
                $htmlAttributes .= sprintf(' sizes="%s"', $sizes);
1✔
561
            }
562

563
            // <img> element
564
            $img = sprintf(
1✔
565
                '<img src="%s" width="' . ($asset['width'] ?: '') . '" height="' . ($asset['height'] ?: '') . '"%s>',
1✔
566
                $this->url($asset, $options),
1✔
567
                $htmlAttributes
1✔
568
            );
1✔
569

570
            // WebP conversion?
571
            if ($webp && $asset['subtype'] != 'image/webp' && !Image::isAnimatedGif($asset)) {
1✔
572
                try {
573
                    $assetWebp = $asset->webp();
1✔
574
                    // <source> element
575
                    $source = sprintf('<source type="image/webp" srcset="%s">', $assetWebp);
1✔
576
                    // responsive
577
                    if ($responsive) {
1✔
578
                        $srcset = Image::buildSrcset(
1✔
579
                            $assetWebp,
1✔
580
                            $this->config->getAssetsImagesWidths()
1✔
581
                        ) ?: (string) $assetWebp;
1✔
582
                        // <source> element
583
                        $source = sprintf(
1✔
584
                            '<source type="image/webp" srcset="%s" sizes="%s">',
1✔
585
                            $srcset,
1✔
586
                            $sizes
1✔
587
                        );
1✔
588
                    }
589

590
                    return sprintf("<picture>\n  %s\n  %s\n</picture>", $source, $img);
1✔
591
                } catch (\Exception $e) {
×
592
                    $this->builder->getLogger()->debug($e->getMessage());
×
593
                }
594
            }
595

596
            return $img;
×
597
        }
598

599
        throw new RuntimeException(sprintf('%s is available for CSS, JavaScript and images files only.', '"html" filter'));
×
600
    }
601

602
    /**
603
     * Builds the HTML img `srcset` (responsive) attribute of an image Asset.
604
     *
605
     * @throws RuntimeException
606
     */
607
    public function imageSrcset(Asset $asset): string
608
    {
609
        return Image::buildSrcset($asset, $this->config->getAssetsImagesWidths());
1✔
610
    }
611

612
    /**
613
     * Returns the HTML img `sizes` attribute based on a CSS class name.
614
     */
615
    public function imageSizes(string $class): string
616
    {
617
        return Image::getSizes($class, $this->config->getAssetsImagesWidths());
1✔
618
    }
619

620
    /**
621
     * Converts an image Asset to WebP format.
622
     *
623
     * @throws RuntimeException
624
     */
625
    public function webp(Asset $asset, ?int $quality = null): Asset
626
    {
627
        if ($asset['subtype'] == 'image/webp') {
×
628
            return $asset;
×
629
        }
630
        if (Image::isAnimatedGif($asset)) {
×
631
            throw new RuntimeException(sprintf('Can\'t convert the animated GIF "%s" to WebP.', $asset['path']));
×
632
        }
633

634
        try {
635
            return $asset->webp($quality);
×
636
        } catch (\Exception $e) {
×
637
            throw new RuntimeException(sprintf('Can\'t convert "%s" to WebP (%s).', $asset['path'], $e->getMessage()));
×
638
        }
639
    }
640

641
    /**
642
     * Returns the content of an asset.
643
     */
644
    public function inline(Asset $asset): string
645
    {
646
        return $asset['content'];
1✔
647
    }
648

649
    /**
650
     * Reads $length first characters of a string and adds a suffix.
651
     */
652
    public function excerpt(?string $string, int $length = 450, string $suffix = ' …'): string
653
    {
654
        $string = $string ?? '';
×
655

656
        $string = str_replace('</p>', '<br /><br />', $string);
×
657
        $string = trim(strip_tags($string, '<br>'), '<br />');
×
658
        if (mb_strlen($string) > $length) {
×
659
            $string = mb_substr($string, 0, $length);
×
660
            $string .= $suffix;
×
661
        }
662

663
        return $string;
×
664
    }
665

666
    /**
667
     * Reads characters before or after '<!-- separator -->'.
668
     * Options:
669
     *  - separator: string to use as separator (`excerpt|break` by default)
670
     *  - capture: part to capture, `before` or `after` the separator (`before` by default).
671
     */
672
    public function excerptHtml(?string $string, array $options = []): string
673
    {
674
        $string = $string ?? '';
1✔
675

676
        $separator = (string) $this->config->get('body.excerpt.separator');
1✔
677
        $capture = (string) $this->config->get('body.excerpt.capture');
1✔
678
        extract($options, EXTR_IF_EXISTS);
1✔
679

680
        // https://regex101.com/r/n9TWHF/1
681
        $pattern = '(.*)<!--[[:blank:]]?(' . $separator . ')[[:blank:]]?-->(.*)';
1✔
682
        preg_match('/' . $pattern . '/is', $string, $matches);
1✔
683

684
        if (empty($matches)) {
1✔
685
            return $string;
×
686
        }
687
        $result = trim($matches[1]);
1✔
688
        if ($capture == 'after') {
1✔
689
            $result = trim($matches[3]);
1✔
690
        }
691
        // removes footnotes and returns result
692
        return preg_replace('/<sup[^>]*>[^u]*<\/sup>/', '', $result);
1✔
693
    }
694

695
    /**
696
     * Converts a Markdown string to HTML.
697
     *
698
     * @throws RuntimeException
699
     */
700
    public function markdownToHtml(?string $markdown): ?string
701
    {
702
        $markdown = $markdown ?? '';
1✔
703

704
        try {
705
            $parsedown = new Parsedown($this->builder);
1✔
706
            $html = $parsedown->text($markdown);
1✔
707
        } catch (\Exception $e) {
×
708
            throw new RuntimeException('"markdown_to_html" filter can not convert supplied Markdown.');
×
709
        }
710

711
        return $html;
1✔
712
    }
713

714
    /**
715
     * Extract table of content of a Markdown string,
716
     * in the given format ("html" or "json", "html" by default).
717
     *
718
     * @throws RuntimeException
719
     */
720
    public function markdownToToc(?string $markdown, $format = 'html', $url = ''): ?string
721
    {
722
        $markdown = $markdown ?? '';
×
723

724
        try {
725
            $parsedown = new Parsedown($this->builder, ['selectors' => ['h2'], 'url' => $url]);
×
726
            $parsedown->body($markdown);
×
727
            $return = $parsedown->contentsList($format);
×
728
        } catch (\Exception $e) {
×
729
            throw new RuntimeException('"toc" filter can not convert supplied Markdown.');
×
730
        }
731

732
        return $return;
×
733
    }
734

735
    /**
736
     * Converts a JSON string to an array.
737
     *
738
     * @throws RuntimeException
739
     */
740
    public function jsonDecode(?string $json): ?array
741
    {
742
        $json = $json ?? '';
1✔
743

744
        try {
745
            $array = json_decode($json, true);
1✔
746
            if ($array === null && json_last_error() !== JSON_ERROR_NONE) {
1✔
747
                throw new \Exception('JSON error.');
1✔
748
            }
749
        } catch (\Exception $e) {
×
750
            throw new RuntimeException('"json_decode" filter can not parse supplied JSON.');
×
751
        }
752

753
        return $array;
1✔
754
    }
755

756
    /**
757
     * Converts a YAML string to an array.
758
     *
759
     * @throws RuntimeException
760
     */
761
    public function yamlParse(?string $yaml): ?array
762
    {
763
        $yaml = $yaml ?? '';
1✔
764

765
        try {
766
            $array = Yaml::parse($yaml);
1✔
767
            if (!\is_array($array)) {
1✔
768
                throw new ParseException('YAML error.');
1✔
769
            }
770
        } catch (ParseException $e) {
×
771
            throw new RuntimeException(sprintf('"yaml_parse" filter can not parse supplied YAML: %s', $e->getMessage()));
×
772
        }
773

774
        return $array;
1✔
775
    }
776

777
    /**
778
     * Split a string into an array using a regular expression.
779
     *
780
     * @throws RuntimeException
781
     */
782
    public function pregSplit(?string $value, string $pattern, int $limit = 0): ?array
783
    {
784
        $value = $value ?? '';
×
785

786
        try {
787
            $array = preg_split($pattern, $value, $limit);
×
788
            if ($array === false) {
×
789
                throw new RuntimeException('PREG split error.');
×
790
            }
791
        } catch (\Exception $e) {
×
792
            throw new RuntimeException('"preg_split" filter can not split supplied string.');
×
793
        }
794

795
        return $array;
×
796
    }
797

798
    /**
799
     * Perform a regular expression match and return the group for all matches.
800
     *
801
     * @throws RuntimeException
802
     */
803
    public function pregMatchAll(?string $value, string $pattern, int $group = 0): ?array
804
    {
805
        $value = $value ?? '';
×
806

807
        try {
808
            $array = preg_match_all($pattern, $value, $matches, PREG_PATTERN_ORDER);
×
809
            if ($array === false) {
×
810
                throw new RuntimeException('PREG match all error.');
×
811
            }
812
        } catch (\Exception $e) {
×
813
            throw new RuntimeException('"preg_match_all" filter can not match in supplied string.');
×
814
        }
815

816
        return $matches[$group];
×
817
    }
818

819
    /**
820
     * Calculates estimated time to read a text.
821
     */
822
    public function readtime(?string $text): string
823
    {
824
        $text = $text ?? '';
×
825

826
        $words = str_word_count(strip_tags($text));
×
827
        $min = floor($words / 200);
×
828
        if ($min === 0) {
×
829
            return '1';
×
830
        }
831

832
        return (string) $min;
×
833
    }
834

835
    /**
836
     * Gets the value of an environment variable.
837
     */
838
    public function getEnv(?string $var): ?string
839
    {
840
        $var = $var ?? '';
1✔
841

842
        return getenv($var) ?: null;
1✔
843
    }
844

845
    /**
846
     * Dump variable (or Twig context).
847
     */
848
    public function varDump(\Twig\Environment $env, array $context, $var = null, ?array $options = null): void
849
    {
850
        if (!$env->isDebug()) {
1✔
851
            return;
×
852
        }
853

854
        if ($var === null) {
1✔
855
            $var = array();
×
856
            foreach ($context as $key => $value) {
×
857
                if (!$value instanceof \Twig\Template && !$value instanceof \Twig\TemplateWrapper) {
×
858
                    $var[$key] = $value;
×
859
                }
860
            }
861
        }
862

863
        $cloner = new VarCloner();
1✔
864
        $cloner->setMinDepth(4);
1✔
865
        $dumper = new HtmlDumper();
1✔
866
        $dumper->setTheme($options['theme'] ?? 'light');
1✔
867

868
        $data = $cloner->cloneVar($var)->withMaxDepth(4);
1✔
869
        $dumper->dump($data, null, ['maxDepth' => 4]);
1✔
870
    }
871

872
    /**
873
     * Tests if a variable is an Asset.
874
     */
875
    public function isAsset($variable): bool
876
    {
877
        return $variable instanceof Asset;
×
878
    }
879

880
    /**
881
     * Returns the dominant hex color of an image asset.
882
     *
883
     * @param string|Asset $asset
884
     *
885
     * @return string
886
     */
887
    public function dominantColor($asset): string
888
    {
889
        if (!$asset instanceof Asset) {
1✔
890
            $asset = new Asset($this->builder, $asset);
×
891
        }
892

893
        return Image::getDominantColor($asset);
1✔
894
    }
895

896
    /**
897
     * Returns a Low Quality Image Placeholder (LQIP) as data URL.
898
     *
899
     * @param string|Asset $asset
900
     *
901
     * @return string
902
     */
903
    public function lqip($asset): string
904
    {
905
        if (!$asset instanceof Asset) {
1✔
906
            $asset = new Asset($this->builder, $asset);
×
907
        }
908

909
        return Image::getLqip($asset);
1✔
910
    }
911

912
    /**
913
     * Converts an hexadecimal color to RGB.
914
     *
915
     * @throws RuntimeException
916
     */
917
    public function hexToRgb(?string $variable): array
918
    {
919
        $variable = $variable ?? '';
×
920

921
        if (!self::isHex($variable)) {
×
922
            throw new RuntimeException(sprintf('"%s" is not a valid hexadecimal value.', $variable));
×
923
        }
924
        $hex = ltrim($variable, '#');
×
925
        if (\strlen($hex) == 3) {
×
926
            $hex = $hex[0] . $hex[0] . $hex[1] . $hex[1] . $hex[2] . $hex[2];
×
927
        }
928
        $c = hexdec($hex);
×
929

930
        return [
×
931
            'red'   => $c >> 16 & 0xFF,
×
932
            'green' => $c >> 8 & 0xFF,
×
933
            'blue'  => $c & 0xFF,
×
934
        ];
×
935
    }
936

937
    /**
938
     * Split a string in multiple lines.
939
     */
940
    public function splitLine(?string $variable, int $max = 18): array
941
    {
942
        $variable = $variable ?? '';
×
943

944
        return preg_split("/.{0,{$max}}\K(\s+|$)/", $variable, 0, PREG_SPLIT_NO_EMPTY);
×
945
    }
946

947
    /**
948
     * Is a hexadecimal color is valid?
949
     */
950
    private static function isHex(string $hex): bool
951
    {
952
        $valid = \is_string($hex);
×
953
        $hex = ltrim($hex, '#');
×
954
        $length = \strlen($hex);
×
955
        $valid = $valid && ($length === 3 || $length === 6);
×
956
        $valid = $valid && ctype_xdigit($hex);
×
957

958
        return $valid;
×
959
    }
960
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc