Passed
Push — twig-filter ( 0c3952...7de61f )
by Arnaud
04:48
created

Extension::pregMatchAll()   A

Complexity

Conditions 3
Paths 3

Size

Total Lines 12
Code Lines 7

Duplication

Lines 0
Ratio 0 %

Importance

Changes 1
Bugs 0 Features 1
Metric Value
cc 3
eloc 7
c 1
b 0
f 1
nc 3
nop 3
dl 0
loc 12
rs 10
1
<?php
2
/**
3
 * This file is part of the Cecil/Cecil package.
4
 *
5
 * Copyright (c) Arnaud Ligny <[email protected]>
6
 *
7
 * For the full copyright and license information, please view the LICENSE
8
 * file that was distributed with this source code.
9
 */
10
11
namespace Cecil\Renderer\Twig;
12
13
use Cecil\Assets\Asset;
14
use Cecil\Assets\Cache;
15
use Cecil\Assets\Image;
16
use Cecil\Assets\Url;
17
use Cecil\Builder;
18
use Cecil\Collection\CollectionInterface;
19
use Cecil\Collection\Page\Collection as PagesCollection;
20
use Cecil\Collection\Page\Page;
21
use Cecil\Config;
22
use Cecil\Converter\Parsedown;
23
use Cecil\Exception\Exception;
24
use Cocur\Slugify\Bridge\Twig\SlugifyExtension;
25
use Cocur\Slugify\Slugify;
26
use MatthiasMullie\Minify;
27
use ScssPhp\ScssPhp\Compiler;
28
29
/**
30
 * Class Twig\Extension.
31
 */
32
class Extension extends SlugifyExtension
33
{
34
    /** @var Builder */
35
    protected $builder;
36
    /** @var Config */
37
    protected $config;
38
    /** @var Slugify */
39
    private static $slugifier;
40
41
    /**
42
     * @param Builder $builder
43
     */
44
    public function __construct(Builder $builder)
45
    {
46
        if (!self::$slugifier instanceof Slugify) {
47
            self::$slugifier = Slugify::create(['regexp' => Page::SLUGIFY_PATTERN]);
48
        }
49
50
        parent::__construct(self::$slugifier);
51
52
        $this->builder = $builder;
53
        $this->config = $this->builder->getConfig();
54
    }
55
56
    /**
57
     * {@inheritdoc}
58
     */
59
    public function getName()
60
    {
61
        return 'cecil';
62
    }
63
64
    /**
65
     * {@inheritdoc}
66
     */
67
    public function getFilters()
68
    {
69
        return [
70
            new \Twig\TwigFilter('filter_by', [$this, 'filterBy']),
71
            // sort
72
            new \Twig\TwigFilter('sort_by_title', [$this, 'sortByTitle']),
73
            new \Twig\TwigFilter('sort_by_weight', [$this, 'sortByWeight']),
74
            new \Twig\TwigFilter('sort_by_date', [$this, 'sortByDate']),
75
            // assets
76
            new \Twig\TwigFilter('url', [$this, 'url']),
77
            new \Twig\TwigFilter('html', [$this, 'html']),
78
            new \Twig\TwigFilter('inline', [$this, 'inline']),
79
            new \Twig\TwigFilter('fingerprint', [$this, 'fingerprint']),
80
            new \Twig\TwigFilter('to_css', [$this, 'toCss']),
81
            new \Twig\TwigFilter('minify', [$this, 'minify']),
82
            new \Twig\TwigFilter('minify_css', [$this, 'minifyCss']),
83
            new \Twig\TwigFilter('minify_js', [$this, 'minifyJs']),
84
            new \Twig\TwigFilter('scss_to_css', [$this, 'scssToCss']),
85
            new \Twig\TwigFilter('sass_to_css', [$this, 'scssToCss']),
86
            new \Twig\TwigFilter('resize', [$this, 'resize']),
87
            // content
88
            new \Twig\TwigFilter('slugify', [$this, 'slugifyFilter']),
89
            new \Twig\TwigFilter('excerpt', [$this, 'excerpt']),
90
            new \Twig\TwigFilter('excerpt_html', [$this, 'excerptHtml']),
91
            new \Twig\TwigFilter('markdown_to_html', [$this, 'markdownToHtml']),
92
            new \Twig\TwigFilter('json_decode', [$this, 'jsonDecode']),
93
            new \Twig\TwigFilter('preg_split', [$this, 'pregSplit']),
94
            new \Twig\TwigFilter('preg_match_all', [$this, 'pregMatchAll']),
95
            // deprecated
96
            new \Twig\TwigFilter(
97
                'filterBySection',
98
                [$this, 'filterBySection'],
99
                ['deprecated' => true, 'alternative' => 'filter_by']
100
            ),
101
            new \Twig\TwigFilter(
102
                'filterBy',
103
                [$this, 'filterBy'],
104
                ['deprecated' => true, 'alternative' => 'filter_by']
105
            ),
106
            new \Twig\TwigFilter(
107
                'sortByTitle',
108
                [$this, 'sortByTitle'],
109
                ['deprecated' => true, 'alternative' => 'sort_by_title']
110
            ),
111
            new \Twig\TwigFilter(
112
                'sortByWeight',
113
                [$this, 'sortByWeight'],
114
                ['deprecated' => true, 'alternative' => 'sort_by_weight']
115
            ),
116
            new \Twig\TwigFilter(
117
                'sortByDate',
118
                [$this, 'sortByDate'],
119
                ['deprecated' => true, 'alternative' => 'sort_by_date']
120
            ),
121
            new \Twig\TwigFilter(
122
                'minifyCSS',
123
                [$this, 'minifyCss'],
124
                ['deprecated' => true, 'alternative' => 'minifyCss']
125
            ),
126
            new \Twig\TwigFilter(
127
                'minifyJS',
128
                [$this, 'minifyJs'],
129
                ['deprecated' => true, 'alternative' => 'minifyJs']
130
            ),
131
            new \Twig\TwigFilter(
132
                'SCSStoCSS',
133
                [$this, 'scssToCss'],
134
                ['deprecated' => true, 'alternative' => 'scss_to_css']
135
            ),
136
            new \Twig\TwigFilter(
137
                'excerptHtml',
138
                [$this, 'excerptHtml'],
139
                ['deprecated' => true, 'alternative' => 'excerpt_html']
140
            ),
141
            new \Twig\TwigFilter(
142
                'urlize',
143
                [$this, 'slugifyFilter'],
144
                ['deprecated' => true, 'alternative' => 'slugify']
145
            ),
146
        ];
147
    }
148
149
    /**
150
     * {@inheritdoc}
151
     */
152
    public function getFunctions()
153
    {
154
        return [
155
            // assets
156
            new \Twig\TwigFunction('url', [$this, 'url']),
157
            new \Twig\TwigFunction('asset', [$this, 'asset']),
158
            new \Twig\TwigFunction('integrity', [$this, 'integrity']),
159
            // content
160
            new \Twig\TwigFunction('readtime', [$this, 'readtime']),
161
            // others
162
            new \Twig\TwigFunction('getenv', [$this, 'getEnv']),
163
            // deprecated
164
            new \Twig\TwigFunction(
165
                'minify',
166
                [$this, 'minify'],
167
                ['deprecated' => true, 'alternative' => 'minify filter']
168
            ),
169
            new \Twig\TwigFunction(
170
                'toCSS',
171
                [$this, 'toCss'],
172
                ['deprecated' => true, 'alternative' => 'to_css filter']
173
            ),
174
            new \Twig\TwigFunction(
175
                'hash',
176
                [$this, 'integrity'],
177
                ['deprecated' => true, 'alternative' => 'integrity']
178
            ),
179
        ];
180
    }
181
182
    /**
183
     * Filters by Section.
184
     * Alias of `filterBy('section', $value)`.
185
     *
186
     * @param PagesCollection $pages
187
     * @param string          $section
188
     *
189
     * @return CollectionInterface
190
     */
191
    public function filterBySection(PagesCollection $pages, string $section): CollectionInterface
192
    {
193
        return $this->filterBy($pages, 'section', $section);
194
    }
195
196
    /**
197
     * Filters by variable's name/value.
198
     *
199
     * @param PagesCollection $pages
200
     * @param string          $variable
201
     * @param string          $value
202
     *
203
     * @return CollectionInterface
204
     */
205
    public function filterBy(PagesCollection $pages, string $variable, string $value): CollectionInterface
206
    {
207
        $filteredPages = $pages->filter(function (Page $page) use ($variable, $value) {
208
            $notVirtual = false;
209
            if (!$page->isVirtual()) {
210
                $notVirtual = true;
211
            }
212
            // is a dedicated getter exists?
213
            $method = 'get'.ucfirst($variable);
214
            if (method_exists($page, $method) && $page->$method() == $value) {
215
                return $notVirtual && true;
216
            }
217
            if ($page->getVariable($variable) == $value) {
218
                return $notVirtual && true;
219
            }
220
        });
221
222
        return $filteredPages;
223
    }
224
225
    /**
226
     * Sorts by title.
227
     *
228
     * @param \Traversable $collection
229
     *
230
     * @return array
231
     */
232
    public function sortByTitle(\Traversable $collection): array
233
    {
234
        $collection = iterator_to_array($collection);
235
        array_multisort(array_keys($collection), 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

235
        array_multisort(/** @scrutinizer ignore-type */ array_keys($collection), SORT_NATURAL | SORT_FLAG_CASE, $collection);
Loading history...
Bug introduced by
Cecil\Renderer\Twig\SORT...rer\Twig\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

235
        array_multisort(array_keys($collection), /** @scrutinizer ignore-type */ SORT_NATURAL | SORT_FLAG_CASE, $collection);
Loading history...
236
237
        return $collection;
238
    }
239
240
    /**
241
     * Sorts by weight.
242
     *
243
     * @param \Traversable $collection
244
     *
245
     * @return array
246
     */
247
    public function sortByWeight(\Traversable $collection): array
248
    {
249
        $callback = function ($a, $b) {
250
            if (!isset($a['weight'])) {
251
                $a['weight'] = 0;
252
            }
253
            if (!isset($b['weight'])) {
254
                $a['weight'] = 0;
255
            }
256
            if ($a['weight'] == $b['weight']) {
257
                return 0;
258
            }
259
260
            return ($a['weight'] < $b['weight']) ? -1 : 1;
261
        };
262
263
        $collection = iterator_to_array($collection);
264
        usort($collection, $callback);
265
266
        return $collection;
267
    }
268
269
    /**
270
     * Sorts by date: the most recent first.
271
     *
272
     * @param \Traversable $collection
273
     *
274
     * @return array
275
     */
276
    public function sortByDate(\Traversable $collection): array
277
    {
278
        $callback = function ($a, $b) {
279
            if ($a['date'] == $b['date']) {
280
                return 0;
281
            }
282
283
            return ($a['date'] > $b['date']) ? -1 : 1;
284
        };
285
286
        $collection = iterator_to_array($collection);
287
        usort($collection, $callback);
288
289
        return $collection;
290
    }
291
292
    /**
293
     * Creates an URL.
294
     *
295
     * $options[
296
     *     'canonical' => true,
297
     *     'addhash'   => false,
298
     *     'format'    => 'json',
299
     * ];
300
     *
301
     * @param Page|Asset|string|null $value
302
     * @param array|null             $options
303
     *
304
     * @return mixed
305
     */
306
    public function url($value = null, array $options = null)
307
    {
308
        return new Url($this->builder, $value, $options);
309
    }
310
311
    /**
312
     * Creates an asset (CSS, JS, images, etc.).
313
     *
314
     * @param string|array $path    File path (relative from static/ dir).
315
     * @param array|null   $options
316
     *
317
     * @return Asset
318
     */
319
    public function asset($path, array $options = null): Asset
320
    {
321
        return new Asset($this->builder, $path, $options);
322
    }
323
324
    /**
325
     * Compiles a SCSS asset.
326
     *
327
     * @param string|Asset $asset
328
     *
329
     * @return Asset
330
     */
331
    public function toCss($asset): Asset
332
    {
333
        if (!$asset instanceof Asset) {
334
            $asset = new Asset($this->builder, $asset);
335
        }
336
337
        return $asset->compile();
338
    }
339
340
    /**
341
     * Minifying an asset (CSS or JS).
342
     *
343
     * @param string|Asset $asset
344
     *
345
     * @return Asset
346
     */
347
    public function minify($asset): Asset
348
    {
349
        if (!$asset instanceof Asset) {
350
            $asset = new Asset($this->builder, $asset);
351
        }
352
353
        return $asset->minify();
354
    }
355
356
    /**
357
     * Fingerprinting an asset.
358
     *
359
     * @param string|Asset $asset
360
     *
361
     * @return Asset
362
     */
363
    public function fingerprint($asset): Asset
364
    {
365
        if (!$asset instanceof Asset) {
366
            $asset = new Asset($this->builder, $asset);
367
        }
368
369
        return $asset->fingerprint();
370
    }
371
372
    /**
373
     * Resizes an image.
374
     *
375
     * @param string $path Image path (relative from static/ dir or external).
376
     * @param int    $size Image new size (width).
377
     *
378
     * @return string
379
     */
380
    public function resize(string $path, int $size): string
381
    {
382
        return (new Image($this->builder))
383
            ->load($path)
384
            ->resize($size);
385
    }
386
387
    /**
388
     * Hashing an asset with algo (sha384 by default).
389
     *
390
     * @param string|Asset $path
391
     * @param string       $algo
392
     *
393
     * @return string
394
     */
395
    public function integrity($asset, string $algo = 'sha384'): string
396
    {
397
        if (!$asset instanceof Asset) {
398
            $asset = new Asset($this->builder, $asset);
399
        }
400
401
        return $asset->getIntegrity($algo);
402
    }
403
404
    /**
405
     * Minifying a CSS string.
406
     *
407
     * @param string $value
408
     *
409
     * @return string
410
     */
411
    public function minifyCss(string $value): string
412
    {
413
        $cache = new Cache($this->builder);
414
        $cacheKey = $cache->createKeyFromString($value);
415
        if (!$cache->has($cacheKey)) {
416
            $minifier = new Minify\CSS($value);
417
            $value = $minifier->minify();
418
            $cache->set($cacheKey, $value);
419
        }
420
421
        return $cache->get($cacheKey, $value);
422
    }
423
424
    /**
425
     * Minifying a JavaScript string.
426
     *
427
     * @param string $value
428
     *
429
     * @return string
430
     */
431
    public function minifyJs(string $value): string
432
    {
433
        $cache = new Cache($this->builder);
434
        $cacheKey = $cache->createKeyFromString($value);
435
        if (!$cache->has($cacheKey)) {
436
            $minifier = new Minify\JS($value);
437
            $value = $minifier->minify();
438
            $cache->set($cacheKey, $value);
439
        }
440
441
        return $cache->get($cacheKey, $value);
442
    }
443
444
    /**
445
     * Compiles a SCSS string.
446
     *
447
     * @param string $value
448
     *
449
     * @return string
450
     */
451
    public function scssToCss(string $value): string
452
    {
453
        $cache = new Cache($this->builder);
454
        $cacheKey = $cache->createKeyFromString($value);
455
        if (!$cache->has($cacheKey)) {
456
            $scssPhp = new Compiler();
457
            $outputStyles = ['expanded', 'compressed'];
458
            $outputStyle = strtolower((string) $this->config->get('assets.compile.style'));
459
            if (!in_array($outputStyle, $outputStyles)) {
460
                throw new Exception(\sprintf('Scss output style "%s" doesn\'t exists.', $outputStyle));
461
            }
462
            $scssPhp->setOutputStyle($outputStyle);
463
            $variables = $this->config->get('assets.compile.variables') ?? [];
464
            if (!empty($variables)) {
465
                $variables = array_map('ScssPhp\ScssPhp\ValueConverter::parseValue', $variables);
466
                $scssPhp->replaceVariables($variables);
467
            }
468
            $value = $scssPhp->compileString($value)->getCss();
469
            $cache->set($cacheKey, $value);
470
        }
471
472
        return $cache->get($cacheKey, $value);
473
    }
474
475
    /**
476
     * Returns the HTML version of an asset.
477
     *
478
     * @param Asset      $asset
479
     * @param array|null $attributes
480
     * @param array|null $options
481
     *
482
     * $options[
483
     *     'preload' => true,
484
     * ];
485
     *
486
     * @return string
487
     */
488
    public function html(Asset $asset, array $attributes = null, array $options = null): string
489
    {
490
        $htmlAttributes = '';
491
        foreach ($attributes as $name => $value) {
492
            if (!empty($value)) {
493
                $htmlAttributes .= \sprintf(' %s="%s"', $name, $value);
494
            } else {
495
                $htmlAttributes .= \sprintf(' %s', $name);
496
            }
497
        }
498
499
        switch ($asset['ext']) {
500
            case 'css':
501
                if ($options['preload']) {
502
                    return \sprintf(
503
                        '<link href="%s" rel="preload" as="style" onload="this.onload=null;this.rel=\'stylesheet\'"%s>
504
                         <noscript><link rel="stylesheet" href="%1$s"%2$s></noscript>',
505
                        $this->url($asset['path'], $options),
506
                        $htmlAttributes
507
                    );
508
                }
509
510
                return \sprintf('<link rel="stylesheet" href="%s"%s>', $this->url($asset['path'], $options), $htmlAttributes);
511
            case 'js':
512
                return \sprintf('<script src="%s"%s></script>', $this->url($asset['path'], $options), $htmlAttributes);
513
        }
514
515
        if ($asset['type'] == 'image') {
516
            return \sprintf(
517
                '<img src="%s"%s>',
518
                $this->url($asset['path'], $options),
519
                $htmlAttributes
520
            );
521
        }
522
523
        throw new Exception(\sprintf('%s is available with CSS, JS and images files only.', '"html" filter'));
524
    }
525
526
    /**
527
     * Returns the content of an asset.
528
     *
529
     * @param Asset $asset
530
     *
531
     * @return string
532
     */
533
    public function inline(Asset $asset): string
534
    {
535
        if (is_null($asset['content'])) {
536
            throw new Exception(\sprintf('%s is available with CSS et JS files only.', '"inline" filter'));
537
        }
538
539
        return $asset['content'];
540
    }
541
542
    /**
543
     * Reads $length first characters of a string and adds a suffix.
544
     *
545
     * @param string|null $string
546
     * @param int         $length
547
     * @param string      $suffix
548
     *
549
     * @return string|null
550
     */
551
    public function excerpt(string $string = null, int $length = 450, string $suffix = ' …'): ?string
552
    {
553
        $string = str_replace('</p>', '<br /><br />', $string);
554
        $string = trim(strip_tags($string, '<br>'), '<br />');
555
        if (mb_strlen($string) > $length) {
556
            $string = mb_substr($string, 0, $length);
557
            $string .= $suffix;
558
        }
559
560
        return $string;
561
    }
562
563
    /**
564
     * Reads characters before '<!-- excerpt|break -->'.
565
     *
566
     * @param string|null $string
567
     *
568
     * @return string|null
569
     */
570
    public function excerptHtml(string $string = null): ?string
571
    {
572
        // https://regex101.com/r/Xl7d5I/3
573
        $pattern = '(.*)(<!--[[:blank:]]?(excerpt|break)[[:blank:]]?-->)(.*)';
574
        preg_match('/'.$pattern.'/is', $string, $matches);
0 ignored issues
show
Bug introduced by
It seems like $string can also be of type null; however, parameter $subject of preg_match() does only seem to accept string, maybe add an additional type check? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

574
        preg_match('/'.$pattern.'/is', /** @scrutinizer ignore-type */ $string, $matches);
Loading history...
575
        if (empty($matches)) {
576
            return $string;
577
        }
578
579
        return trim($matches[1]);
580
    }
581
582
    /**
583
     * Converts a Markdown string to HTML.
584
     *
585
     * @param string|null $markdown
586
     *
587
     * @return string|null
588
     */
589
    public function markdownToHtml(string $markdown): ?string
590
    {
591
        try {
592
            $parsedown = new Parsedown($this->builder);
593
            $html = $parsedown->text($markdown);
0 ignored issues
show
Bug introduced by
Are you sure the assignment to $html is correct as $parsedown->text($markdown) targeting ParsedownToC::text() seems to always return null.

This check looks for function or method calls that always return null and whose return value is assigned to a variable.

class A
{
    function getObject()
    {
        return null;
    }

}

$a = new A();
$object = $a->getObject();

The method getObject() can return nothing but null, so it makes no sense to assign that value to a variable.

The reason is most likely that a function or method is imcomplete or has been reduced for debug purposes.

Loading history...
594
        } catch (\Exception $e) {
595
            throw new Exception('"markdown_to_html" filter can not convert supplied Markdown.');
596
        }
597
598
        return $html;
599
    }
600
601
    /**
602
     * Converts a JSON string to an array.
603
     *
604
     * @param string|null $json
605
     *
606
     * @return array|null
607
     */
608
    public function jsonDecode(string $json): ?array
609
    {
610
        try {
611
            $array = json_decode($json, true);
612
            if ($array === null && json_last_error() !== JSON_ERROR_NONE) {
613
                throw new \Exception('Error');
614
            }
615
        } catch (\Exception $e) {
616
            throw new Exception('"json_decode" filter can not parse supplied JSON.');
617
        }
618
619
        return $array;
620
    }
621
622
    /**
623
     * Split a string into an array using a regular expression.
624
     *
625
     * @param string|null $value
626
     * @param string      $pattern
627
     * @param int         $limit
628
     *
629
     * @return array|null
630
     */
631
    public function pregSplit(string $value, string $pattern, int $limit = 0): ?array
632
    {
633
        try {
634
            $array = preg_split($pattern, $value, $limit);
635
            if ($array === false) {
636
                throw new \Exception('Error');
637
            }
638
        } catch (\Exception $e) {
639
            throw new Exception('"preg_split" filter can not split supplied string.');
640
        }
641
642
        return $array;
643
    }
644
645
    /**
646
     * Perform a regular expression match and return the group for all matches.
647
     *
648
     * @param string|null $value
649
     * @param string      $pattern
650
     * @param int         $group
651
     *
652
     * @return array|null
653
     */
654
    public function pregMatchAll(string $value, string $pattern, int $group = 0): ?array
655
    {
656
        try {
657
            $array = preg_match_all($pattern, $value, $matches, PREG_PATTERN_ORDER);
658
            if ($array === false) {
659
                throw new \Exception('Error');
660
            }
661
        } catch (\Exception $e) {
662
            throw new Exception('"preg_match_all" filter can not match in supplied string.');
663
        }
664
665
        return $matches[$group];
666
    }
667
668
    /**
669
     * Calculates estimated time to read a text.
670
     *
671
     * @param string|null $text
672
     *
673
     * @return string
674
     */
675
    public function readtime(string $text = null): string
676
    {
677
        $words = str_word_count(strip_tags($text));
0 ignored issues
show
Bug introduced by
It seems like $text can also be of type null; however, parameter $string of strip_tags() does only seem to accept string, maybe add an additional type check? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

677
        $words = str_word_count(strip_tags(/** @scrutinizer ignore-type */ $text));
Loading history...
678
        $min = floor($words / 200);
679
        if ($min === 0) {
680
            return '1';
681
        }
682
683
        return (string) $min;
684
    }
685
686
    /**
687
     * Gets the value of an environment variable.
688
     *
689
     * @param string $var
690
     *
691
     * @return string|null
692
     */
693
    public function getEnv(string $var): ?string
694
    {
695
        return getenv($var) ?: null;
696
    }
697
}
698