Passed
Push — analysis-ZlNABb ( bef3b6 )
by Arnaud
09:04 queued 04:04
created

Extension::isAsset()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 3
Code Lines 1

Duplication

Lines 0
Ratio 0 %

Importance

Changes 1
Bugs 1 Features 0
Metric Value
cc 1
eloc 1
c 1
b 1
f 0
nc 1
nop 1
dl 0
loc 3
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\RuntimeException;
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
37
    /** @var Config */
38
    protected $config;
39
40
    /** @var Slugify */
41
    private static $slugifier;
42
43
    public function __construct(Builder $builder)
44
    {
45
        if (!self::$slugifier instanceof Slugify) {
46
            self::$slugifier = Slugify::create(['regexp' => Page::SLUGIFY_PATTERN]);
47
        }
48
49
        parent::__construct(self::$slugifier);
50
51
        $this->builder = $builder;
52
        $this->config = $this->builder->getConfig();
53
    }
54
55
    /**
56
     * {@inheritdoc}
57
     */
58
    public function getName()
59
    {
60
        return 'cecil';
61
    }
62
63
    /**
64
     * {@inheritdoc}
65
     */
66
    public function getFilters()
67
    {
68
        return [
69
            new \Twig\TwigFilter('filter_by', [$this, 'filterBy']),
70
            // sort
71
            new \Twig\TwigFilter('sort_by_title', [$this, 'sortByTitle']),
72
            new \Twig\TwigFilter('sort_by_weight', [$this, 'sortByWeight']),
73
            new \Twig\TwigFilter('sort_by_date', [$this, 'sortByDate']),
74
            // assets
75
            new \Twig\TwigFilter('url', [$this, 'url']),
76
            new \Twig\TwigFilter('html', [$this, 'html']),
77
            new \Twig\TwigFilter('inline', [$this, 'inline']),
78
            new \Twig\TwigFilter('fingerprint', [$this, 'fingerprint']),
79
            new \Twig\TwigFilter('to_css', [$this, 'toCss']),
80
            new \Twig\TwigFilter('minify', [$this, 'minify']),
81
            new \Twig\TwigFilter('minify_css', [$this, 'minifyCss']),
82
            new \Twig\TwigFilter('minify_js', [$this, 'minifyJs']),
83
            new \Twig\TwigFilter('scss_to_css', [$this, 'scssToCss']),
84
            new \Twig\TwigFilter('sass_to_css', [$this, 'scssToCss']),
85
            new \Twig\TwigFilter('resize', [$this, 'resize']),
86
            new \Twig\TwigFilter('dataurl', [$this, 'dataurl']),
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
     * {@inheritdoc}
184
     */
185
    public function getTests()
186
    {
187
        return [
188
            new \Twig\TwigTest('asset', [$this, 'isAsset']),
189
        ];
190
    }
191
192
    /**
193
     * Filters by Section.
194
     * Alias of `filterBy('section', $value)`.
195
     */
196
    public function filterBySection(PagesCollection $pages, string $section): CollectionInterface
197
    {
198
        return $this->filterBy($pages, 'section', $section);
199
    }
200
201
    /**
202
     * Filters by variable's name/value.
203
     */
204
    public function filterBy(PagesCollection $pages, string $variable, string $value): CollectionInterface
205
    {
206
        $filteredPages = $pages->filter(function (Page $page) use ($variable, $value) {
207
            $notVirtual = false;
208
            if (!$page->isVirtual()) {
209
                $notVirtual = true;
210
            }
211
            // is a dedicated getter exists?
212
            $method = 'get'.ucfirst($variable);
213
            if (method_exists($page, $method) && $page->$method() == $value) {
214
                return $notVirtual && true;
215
            }
216
            if ($page->getVariable($variable) == $value) {
217
                return $notVirtual && true;
218
            }
219
        });
220
221
        return $filteredPages;
222
    }
223
224
    /**
225
     * Sorts by title.
226
     */
227
    public function sortByTitle(\Traversable $collection): array
228
    {
229
        $collection = iterator_to_array($collection);
230
        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

230
        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

230
        array_multisort(array_keys($collection), /** @scrutinizer ignore-type */ SORT_NATURAL | SORT_FLAG_CASE, $collection);
Loading history...
231
232
        return $collection;
233
    }
234
235
    /**
236
     * Sorts by weight.
237
     */
238
    public function sortByWeight(\Traversable $collection): array
239
    {
240
        $callback = function ($a, $b) {
241
            if (!isset($a['weight'])) {
242
                $a['weight'] = 0;
243
            }
244
            if (!isset($b['weight'])) {
245
                $a['weight'] = 0;
246
            }
247
            if ($a['weight'] == $b['weight']) {
248
                return 0;
249
            }
250
251
            return ($a['weight'] < $b['weight']) ? -1 : 1;
252
        };
253
254
        $collection = iterator_to_array($collection);
255
        usort($collection, $callback);
256
257
        return $collection;
258
    }
259
260
    /**
261
     * Sorts by date: the most recent first.
262
     */
263
    public function sortByDate(\Traversable $collection): array
264
    {
265
        $callback = function ($a, $b) {
266
            if ($a['date'] == $b['date']) {
267
                return 0;
268
            }
269
270
            return ($a['date'] > $b['date']) ? -1 : 1;
271
        };
272
273
        $collection = iterator_to_array($collection);
274
        usort($collection, $callback);
275
276
        return $collection;
277
    }
278
279
    /**
280
     * Creates an URL.
281
     *
282
     * $options[
283
     *     'canonical' => true,
284
     *     'addhash'   => false,
285
     *     'format'    => 'json',
286
     * ];
287
     *
288
     * @param Page|Asset|string|null $value
289
     * @param array|null             $options
290
     *
291
     * @return mixed
292
     */
293
    public function url($value = null, array $options = null)
294
    {
295
        return new Url($this->builder, $value, $options);
296
    }
297
298
    /**
299
     * Creates an asset (CSS, JS, images, etc.).
300
     *
301
     * @param string|array $path    File path (relative from static/ dir).
302
     * @param array|null   $options
303
     *
304
     * @return Asset
305
     */
306
    public function asset($path, array $options = null): Asset
307
    {
308
        return new Asset($this->builder, $path, $options);
309
    }
310
311
    /**
312
     * Compiles a SCSS asset.
313
     *
314
     * @param string|Asset $asset
315
     *
316
     * @return Asset
317
     */
318
    public function toCss($asset): Asset
319
    {
320
        if (!$asset instanceof Asset) {
321
            $asset = new Asset($this->builder, $asset);
322
        }
323
324
        return $asset->compile();
325
    }
326
327
    /**
328
     * Minifying an asset (CSS or JS).
329
     *
330
     * @param string|Asset $asset
331
     *
332
     * @return Asset
333
     */
334
    public function minify($asset): Asset
335
    {
336
        if (!$asset instanceof Asset) {
337
            $asset = new Asset($this->builder, $asset);
338
        }
339
340
        return $asset->minify();
341
    }
342
343
    /**
344
     * Fingerprinting an asset.
345
     *
346
     * @param string|Asset $asset
347
     *
348
     * @return Asset
349
     */
350
    public function fingerprint($asset): Asset
351
    {
352
        if (!$asset instanceof Asset) {
353
            $asset = new Asset($this->builder, $asset);
354
        }
355
356
        return $asset->fingerprint();
357
    }
358
359
    /**
360
     * Resizes an image.
361
     *
362
     * @param string|Asset $asset
363
     *
364
     * @return Asset
365
     */
366
    public function resize($asset, int $size): Asset
367
    {
368
        if (!$asset instanceof Asset) {
369
            $asset = new Asset($this->builder, $asset);
370
        }
371
372
        return $asset->resize($size);
373
    }
374
375
    /**
376
     * Returns the data URL of an image.
377
     *
378
     * @param string|Asset $asset
379
     *
380
     * @return string
381
     */
382
    public function dataurl($asset): string
383
    {
384
        if (!$asset instanceof Asset) {
385
            $asset = new Asset($this->builder, $asset);
386
        }
387
388
        return $asset->dataurl();
389
    }
390
391
    /**
392
     * Hashing an asset with algo (sha384 by default).
393
     *
394
     * @param string|Asset $path
395
     * @param string       $algo
396
     *
397
     * @return string
398
     */
399
    public function integrity($asset, string $algo = 'sha384'): string
400
    {
401
        if (!$asset instanceof Asset) {
402
            $asset = new Asset($this->builder, $asset);
403
        }
404
405
        return $asset->getIntegrity($algo);
406
    }
407
408
    /**
409
     * Minifying a CSS string.
410
     */
411
    public function minifyCss(string $value): string
412
    {
413
        if ($this->builder->isDebug()) {
414
            return $value;
415
        }
416
417
        $cache = new Cache($this->builder);
418
        $cacheKey = $cache->createKeyFromString($value);
419
        if (!$cache->has($cacheKey)) {
420
            $minifier = new Minify\CSS($value);
421
            $value = $minifier->minify();
422
            $cache->set($cacheKey, $value);
423
        }
424
425
        return $cache->get($cacheKey, $value);
426
    }
427
428
    /**
429
     * Minifying a JavaScript string.
430
     */
431
    public function minifyJs(string $value): string
432
    {
433
        if ($this->builder->isDebug()) {
434
            return $value;
435
        }
436
437
        $cache = new Cache($this->builder);
438
        $cacheKey = $cache->createKeyFromString($value);
439
        if (!$cache->has($cacheKey)) {
440
            $minifier = new Minify\JS($value);
441
            $value = $minifier->minify();
442
            $cache->set($cacheKey, $value);
443
        }
444
445
        return $cache->get($cacheKey, $value);
446
    }
447
448
    /**
449
     * Compiles a SCSS 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 RuntimeException(\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
     * $options[
479
     *     'preload'    => false,
480
     *     'responsive' => false,
481
     * ];
482
     *
483
     * @throws RuntimeException
484
     */
485
    public function html(Asset $asset, array $attributes = [], array $options = []): string
486
    {
487
        $htmlAttributes = '';
488
        $preload = false;
489
        $responsive = $this->config->get('assets.images.responsive.enabled') ?? false;
490
        $webp = $this->config->get('assets.images.webp.enabled') ?? false;
491
        extract($options, EXTR_IF_EXISTS);
492
493
        foreach ($attributes as $name => $value) {
494
            $attribute = \sprintf(' %s="%s"', $name, $value);
495
            if (empty($value)) {
496
                $attribute = \sprintf(' %s', $name);
497
            }
498
            $htmlAttributes .= $attribute;
499
        }
500
501
        switch ($asset['ext']) {
502
            case 'css':
503
                if ($preload) {
504
                    return \sprintf(
505
                        '<link href="%s" rel="preload" as="style" onload="this.onload=null;this.rel=\'stylesheet\'"%s>
506
                         <noscript><link rel="stylesheet" href="%1$s"%2$s></noscript>',
507
                        $this->url($asset['path'], $options),
508
                        $htmlAttributes
509
                    );
510
                }
511
512
                return \sprintf('<link rel="stylesheet" href="%s"%s>', $this->url($asset['path'], $options), $htmlAttributes);
513
            case 'js':
514
                return \sprintf('<script src="%s"%s></script>', $this->url($asset['path'], $options), $htmlAttributes);
515
        }
516
517
        if ($asset['type'] == 'image') {
518
            if ($responsive && $srcset = Image::getSrcset(
519
                $asset,
520
                $this->config->get('assets.images.responsive.width.steps') ?? 5,
521
                $this->config->get('assets.images.responsive.width.min') ?? 320,
522
                $this->config->get('assets.images.responsive.width.max') ?? 1280
523
            )) {
524
                $htmlAttributes .= \sprintf(' srcset="%s"', $srcset);
525
                $htmlAttributes .= \sprintf(' sizes="%s"', $this->config->get('assets.images.responsive.sizes.default') ?? '100vw');
526
            }
527
528
            $img = \sprintf(
529
                '<img src="%s" width="'.($asset->getWidth() ?: 0).'" height="'.($asset->getHeight() ?: 0).'"%s>',
530
                $this->url($asset['path'], $options),
531
                $htmlAttributes
532
            );
533
534
            if ($webp) {
535
                $assetWebp = Image::convertTopWebp($asset, $this->config->get('assets.images.quality') ?? 85);
536
                $srcset = Image::getSrcset(
537
                    $assetWebp,
538
                    $this->config->get('assets.images.responsive.width.steps') ?? 5,
539
                    $this->config->get('assets.images.responsive.width.min') ?? 320,
540
                    $this->config->get('assets.images.responsive.width.max') ?? 1280
541
                ) ?: (string) $assetWebp;
542
                $source = \sprintf(
543
                    '<source type="image/webp" srcset="%s" sizes="%s">',
544
                    $srcset,
545
                    $this->config->get('assets.images.responsive.sizes.default') ?? '100vw'
546
                );
547
548
                return \sprintf("<picture>\n  %s\n  %s\n</picture>", $source, $img);
549
            }
550
551
            return $img;
552
        }
553
554
        throw new RuntimeException(\sprintf('%s is available with CSS, JS and images files only.', '"html" filter'));
555
    }
556
557
    /**
558
     * Returns the content of an asset.
559
     *
560
     * @throws RuntimeException
561
     */
562
    public function inline(Asset $asset): string
563
    {
564
        if (is_null($asset['content'])) {
565
            throw new RuntimeException(\sprintf('%s is available with CSS et JS files only.', '"inline" filter'));
566
        }
567
568
        return $asset['content'];
569
    }
570
571
    /**
572
     * Reads $length first characters of a string and adds a suffix.
573
     */
574
    public function excerpt(string $string = null, int $length = 450, string $suffix = ' …'): ?string
575
    {
576
        $string = str_replace('</p>', '<br /><br />', $string);
577
        $string = trim(strip_tags($string, '<br>'), '<br />');
578
        if (mb_strlen($string) > $length) {
579
            $string = mb_substr($string, 0, $length);
580
            $string .= $suffix;
581
        }
582
583
        return $string;
584
    }
585
586
    /**
587
     * Reads characters before '<!-- excerpt|break -->'.
588
     * Options:
589
     *  - separator: string to use as separator
590
     *  - capture: string to capture, 'before' (default) or 'after'.
591
     */
592
    public function excerptHtml(string $string, array $options = []): string
593
    {
594
        $separator = 'excerpt|break';
595
        $capture = 'before';
596
        extract($options, EXTR_IF_EXISTS);
597
598
        // https://regex101.com/r/n9TWHF/1
599
        $pattern = '(.*)<!--[[:blank:]]?('.$separator.')[[:blank:]]?-->(.*)';
600
        preg_match('/'.$pattern.'/is', $string, $matches);
601
602
        if (empty($matches)) {
603
            return $string;
604
        }
605
        if ($capture == 'after') {
606
            return trim($matches[3]);
607
        }
608
609
        return trim($matches[1]);
610
    }
611
612
    /**
613
     * Converts a Markdown string to HTML.
614
     *
615
     * @throws RuntimeException
616
     */
617
    public function markdownToHtml(string $markdown): ?string
618
    {
619
        try {
620
            $parsedown = new Parsedown($this->builder);
621
            $html = $parsedown->text($markdown);
622
        } catch (\Exception $e) {
623
            throw new RuntimeException('"markdown_to_html" filter can not convert supplied Markdown.');
624
        }
625
626
        return $html;
627
    }
628
629
    /**
630
     * Converts a JSON string to an array.
631
     *
632
     * @throws RuntimeException
633
     */
634
    public function jsonDecode(string $json): ?array
635
    {
636
        try {
637
            $array = json_decode($json, true);
638
            if ($array === null && json_last_error() !== JSON_ERROR_NONE) {
639
                throw new RuntimeException('JSON error.');
640
            }
641
        } catch (\Exception $e) {
642
            throw new RuntimeException('"json_decode" filter can not parse supplied JSON.');
643
        }
644
645
        return $array;
646
    }
647
648
    /**
649
     * Split a string into an array using a regular expression.
650
     *
651
     * @throws RuntimeException
652
     */
653
    public function pregSplit(string $value, string $pattern, int $limit = 0): ?array
654
    {
655
        try {
656
            $array = preg_split($pattern, $value, $limit);
657
            if ($array === false) {
658
                throw new RuntimeException('PREG split error.');
659
            }
660
        } catch (\Exception $e) {
661
            throw new RuntimeException('"preg_split" filter can not split supplied string.');
662
        }
663
664
        return $array;
665
    }
666
667
    /**
668
     * Perform a regular expression match and return the group for all matches.
669
     *
670
     * @throws RuntimeException
671
     */
672
    public function pregMatchAll(string $value, string $pattern, int $group = 0): ?array
673
    {
674
        try {
675
            $array = preg_match_all($pattern, $value, $matches, PREG_PATTERN_ORDER);
676
            if ($array === false) {
677
                throw new RuntimeException('PREG match all error.');
678
            }
679
        } catch (\Exception $e) {
680
            throw new RuntimeException('"preg_match_all" filter can not match in supplied string.');
681
        }
682
683
        return $matches[$group];
684
    }
685
686
    /**
687
     * Calculates estimated time to read a text.
688
     */
689
    public function readtime(string $text): string
690
    {
691
        $words = str_word_count(strip_tags($text));
692
        $min = floor($words / 200);
693
        if ($min === 0) {
694
            return '1';
695
        }
696
697
        return (string) $min;
698
    }
699
700
    /**
701
     * Gets the value of an environment variable.
702
     */
703
    public function getEnv(string $var): ?string
704
    {
705
        return getenv($var) ?: null;
706
    }
707
708
    /**
709
     * Tests if a variable is an Asset.
710
     */
711
    public function isAsset($variable): bool
712
    {
713
        return $variable instanceof Asset;
714
    }
715
}
716