Passed
Push — analysis-d0NGJp ( 67d94c )
by Arnaud
05:13 queued 17s
created

Extension::readtime()   A

Complexity

Conditions 2
Paths 2

Size

Total Lines 9
Code Lines 5

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 2
eloc 5
nc 2
nop 1
dl 0
loc 9
rs 10
c 0
b 0
f 0
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
            // deprecated
95
            new \Twig\TwigFilter(
96
                'filterBySection',
97
                [$this, 'filterBySection'],
98
                ['deprecated' => true, 'alternative' => 'filter_by']
99
            ),
100
            new \Twig\TwigFilter(
101
                'filterBy',
102
                [$this, 'filterBy'],
103
                ['deprecated' => true, 'alternative' => 'filter_by']
104
            ),
105
            new \Twig\TwigFilter(
106
                'sortByTitle',
107
                [$this, 'sortByTitle'],
108
                ['deprecated' => true, 'alternative' => 'sort_by_title']
109
            ),
110
            new \Twig\TwigFilter(
111
                'sortByWeight',
112
                [$this, 'sortByWeight'],
113
                ['deprecated' => true, 'alternative' => 'sort_by_weight']
114
            ),
115
            new \Twig\TwigFilter(
116
                'sortByDate',
117
                [$this, 'sortByDate'],
118
                ['deprecated' => true, 'alternative' => 'sort_by_date']
119
            ),
120
            new \Twig\TwigFilter(
121
                'minifyCSS',
122
                [$this, 'minifyCss'],
123
                ['deprecated' => true, 'alternative' => 'minifyCss']
124
            ),
125
            new \Twig\TwigFilter(
126
                'minifyJS',
127
                [$this, 'minifyJs'],
128
                ['deprecated' => true, 'alternative' => 'minifyJs']
129
            ),
130
            new \Twig\TwigFilter(
131
                'SCSStoCSS',
132
                [$this, 'scssToCss'],
133
                ['deprecated' => true, 'alternative' => 'scss_to_css']
134
            ),
135
            new \Twig\TwigFilter(
136
                'excerptHtml',
137
                [$this, 'excerptHtml'],
138
                ['deprecated' => true, 'alternative' => 'excerpt_html']
139
            ),
140
            new \Twig\TwigFilter(
141
                'urlize',
142
                [$this, 'slugifyFilter'],
143
                ['deprecated' => true, 'alternative' => 'slugify']
144
            ),
145
        ];
146
    }
147
148
    /**
149
     * {@inheritdoc}
150
     */
151
    public function getFunctions()
152
    {
153
        return [
154
            // assets
155
            new \Twig\TwigFunction('url', [$this, 'url']),
156
            new \Twig\TwigFunction('asset', [$this, 'asset']),
157
            new \Twig\TwigFunction('integrity', [$this, 'integrity']),
158
            // content
159
            new \Twig\TwigFunction('readtime', [$this, 'readtime']),
160
            // others
161
            new \Twig\TwigFunction('getenv', [$this, 'getEnv']),
162
            // deprecated
163
            new \Twig\TwigFunction(
164
                'minify',
165
                [$this, 'minify'],
166
                ['deprecated' => true, 'alternative' => 'minify filter']
167
            ),
168
            new \Twig\TwigFunction(
169
                'toCSS',
170
                [$this, 'toCss'],
171
                ['deprecated' => true, 'alternative' => 'to_css filter']
172
            ),
173
            new \Twig\TwigFunction(
174
                'hash',
175
                [$this, 'integrity'],
176
                ['deprecated' => true, 'alternative' => 'integrity']
177
            ),
178
        ];
179
    }
180
181
    /**
182
     * Filters by Section.
183
     * Alias of `filterBy('section', $value)`.
184
     *
185
     * @param PagesCollection $pages
186
     * @param string          $section
187
     *
188
     * @return CollectionInterface
189
     */
190
    public function filterBySection(PagesCollection $pages, string $section): CollectionInterface
191
    {
192
        return $this->filterBy($pages, 'section', $section);
193
    }
194
195
    /**
196
     * Filters by variable's name/value.
197
     *
198
     * @param PagesCollection $pages
199
     * @param string          $variable
200
     * @param string          $value
201
     *
202
     * @return CollectionInterface
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
     * @param \Traversable $collection
228
     *
229
     * @return array
230
     */
231
    public function sortByTitle(\Traversable $collection): array
232
    {
233
        $collection = iterator_to_array($collection);
234
        array_multisort(array_keys($collection), SORT_NATURAL | SORT_FLAG_CASE, $collection);
0 ignored issues
show
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

234
        array_multisort(array_keys($collection), /** @scrutinizer ignore-type */ SORT_NATURAL | SORT_FLAG_CASE, $collection);
Loading history...
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

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

573
        preg_match('/'.$pattern.'/is', /** @scrutinizer ignore-type */ $string, $matches);
Loading history...
574
        if (empty($matches)) {
575
            return $string;
576
        }
577
578
        return trim($matches[1]);
579
    }
580
581
    /**
582
     * Converts a Markdown string to HTML.
583
     *
584
     * @param string|null $markdown
585
     *
586
     * @return string|null
587
     */
588
    public function markdownToHtml(string $markdown): ?string
589
    {
590
        try {
591
            $parsedown = new Parsedown($this->builder);
592
            $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...
593
        } catch (\Exception $e) {
594
            throw new Exception('"markdown_to_html" filter can not convert supplied Markdown.');
595
        }
596
597
        return $html;
598
    }
599
600
    /**
601
     * Converts a JSON string to an array.
602
     *
603
     * @param string|null $json
604
     *
605
     * @return array|null
606
     */
607
    public function jsonDecode(string $json): ?array
608
    {
609
        try {
610
            $array = json_decode($json, true);
611
            if ($array === null && json_last_error() !== JSON_ERROR_NONE) {
612
                throw new \Exception('Error');
613
            }
614
        } catch (\Exception $e) {
615
            throw new Exception('"json_decode" filter can not parse supplied JSON.');
616
        }
617
618
        return $array;
619
    }
620
621
    /**
622
     * Split a string into an array using a regular expression.
623
     *
624
     * @param string|null $value
625
     * @param string      $pattern
626
     * @param int         $limit
627
     *
628
     * @return array|null
629
     */
630
    public function pregSplit(string $value, string $pattern, int $limit = 0): ?array
631
    {
632
        try {
633
            $array = preg_split($pattern, $value, $limit);
634
            if ($array === false) {
635
                throw new \Exception('Error');
636
            }
637
        } catch (\Exception $e) {
638
            throw new Exception('"preg_split" filter can not split supplied string.');
639
        }
640
641
        return $array;
642
    }
643
644
    /**
645
     * Calculates estimated time to read a text.
646
     *
647
     * @param string|null $text
648
     *
649
     * @return string
650
     */
651
    public function readtime(string $text = null): string
652
    {
653
        $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

653
        $words = str_word_count(strip_tags(/** @scrutinizer ignore-type */ $text));
Loading history...
654
        $min = floor($words / 200);
655
        if ($min === 0) {
656
            return '1';
657
        }
658
659
        return (string) $min;
660
    }
661
662
    /**
663
     * Gets the value of an environment variable.
664
     *
665
     * @param string $var
666
     *
667
     * @return string|null
668
     */
669
    public function getEnv(string $var): ?string
670
    {
671
        return getenv($var) ?: null;
672
    }
673
}
674