Passed
Push — feature/filter-fingerprint ( 17af62 )
by Arnaud
04:45
created

Extension::markdownToHtml()   A

Complexity

Conditions 2
Paths 3

Size

Total Lines 10
Code Lines 6

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 2
eloc 6
nc 3
nop 1
dl 0
loc 10
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('markdown_to_html', [$this, 'markdownToHtml']),
80
            new \Twig\TwigFilter('json_decode', [$this, 'jsonDecode']),
81
            new \Twig\TwigFilter('fingerprint', [$this, 'fingerprint']),
82
            new \Twig\TwigFilter('to_css', [$this, 'toCss']),
83
            new \Twig\TwigFilter('minify', [$this, 'minify']),
84
            new \Twig\TwigFilter('minify_css', [$this, 'minifyCss']),
85
            new \Twig\TwigFilter('minify_js', [$this, 'minifyJs']),
86
            new \Twig\TwigFilter('scss_to_css', [$this, 'scssToCss']),
87
            new \Twig\TwigFilter('sass_to_css', [$this, 'scssToCss']),
88
            new \Twig\TwigFilter('resize', [$this, 'resize']),
89
            // content
90
            new \Twig\TwigFilter('slugify', [$this, 'slugifyFilter']),
91
            new \Twig\TwigFilter('excerpt', [$this, 'excerpt']),
92
            new \Twig\TwigFilter('excerpt_html', [$this, 'excerptHtml']),
93
            // deprecated
94
            new \Twig\TwigFilter(
95
                'filterBySection',
96
                [$this, 'filterBySection'],
97
                ['deprecated' => true, 'alternative' => 'filter_by']
98
            ),
99
            new \Twig\TwigFilter(
100
                'filterBy',
101
                [$this, 'filterBy'],
102
                ['deprecated' => true, 'alternative' => 'filter_by']
103
            ),
104
            new \Twig\TwigFilter(
105
                'sortByTitle',
106
                [$this, 'sortByTitle'],
107
                ['deprecated' => true, 'alternative' => 'sort_by_title']
108
            ),
109
            new \Twig\TwigFilter(
110
                'sortByWeight',
111
                [$this, 'sortByWeight'],
112
                ['deprecated' => true, 'alternative' => 'sort_by_weight']
113
            ),
114
            new \Twig\TwigFilter(
115
                'sortByDate',
116
                [$this, 'sortByDate'],
117
                ['deprecated' => true, 'alternative' => 'sort_by_date']
118
            ),
119
            new \Twig\TwigFilter(
120
                'minifyCSS',
121
                [$this, 'minifyCss'],
122
                ['deprecated' => true, 'alternative' => 'minifyCss']
123
            ),
124
            new \Twig\TwigFilter(
125
                'minifyJS',
126
                [$this, 'minifyJs'],
127
                ['deprecated' => true, 'alternative' => 'minifyJs']
128
            ),
129
            new \Twig\TwigFilter(
130
                'SCSStoCSS',
131
                [$this, 'scssToCss'],
132
                ['deprecated' => true, 'alternative' => 'scss_to_css']
133
            ),
134
            new \Twig\TwigFilter(
135
                'excerptHtml',
136
                [$this, 'excerptHtml'],
137
                ['deprecated' => true, 'alternative' => 'excerpt_html']
138
            ),
139
            new \Twig\TwigFilter(
140
                'urlize',
141
                [$this, 'slugifyFilter'],
142
                ['deprecated' => true, 'alternative' => 'slugify']
143
            ),
144
        ];
145
    }
146
147
    /**
148
     * {@inheritdoc}
149
     */
150
    public function getFunctions()
151
    {
152
        return [
153
            // assets
154
            new \Twig\TwigFunction('url', [$this, 'url']),
155
            new \Twig\TwigFunction('asset', [$this, 'asset']),
156
            new \Twig\TwigFunction('integrity', [$this, 'integrity']),
157
            // content
158
            new \Twig\TwigFunction('readtime', [$this, 'readtime']),
159
            // others
160
            new \Twig\TwigFunction('getenv', [$this, 'getEnv']),
161
            // deprecated
162
            new \Twig\TwigFunction(
163
                'minify',
164
                [$this, 'minify'],
165
                ['deprecated' => true, 'alternative' => 'minify filter']
166
            ),
167
            new \Twig\TwigFunction(
168
                'toCSS',
169
                [$this, 'toCss'],
170
                ['deprecated' => true, 'alternative' => 'to_css filter']
171
            ),
172
            new \Twig\TwigFunction(
173
                'hash',
174
                [$this, 'integrity'],
175
                ['deprecated' => true, 'alternative' => 'integrity']
176
            ),
177
        ];
178
    }
179
180
    /**
181
     * Filters by Section.
182
     * Alias of `filterBy('section', $value)`.
183
     *
184
     * @param PagesCollection $pages
185
     * @param string          $section
186
     *
187
     * @return CollectionInterface
188
     */
189
    public function filterBySection(PagesCollection $pages, string $section): CollectionInterface
190
    {
191
        return $this->filterBy($pages, 'section', $section);
192
    }
193
194
    /**
195
     * Filters by variable's name/value.
196
     *
197
     * @param PagesCollection $pages
198
     * @param string          $variable
199
     * @param string          $value
200
     *
201
     * @return CollectionInterface
202
     */
203
    public function filterBy(PagesCollection $pages, string $variable, string $value): CollectionInterface
204
    {
205
        $filteredPages = $pages->filter(function (Page $page) use ($variable, $value) {
206
            $notVirtual = false;
207
            if (!$page->isVirtual()) {
208
                $notVirtual = true;
209
            }
210
            // is a dedicated getter exists?
211
            $method = 'get'.ucfirst($variable);
212
            if (method_exists($page, $method) && $page->$method() == $value) {
213
                return $notVirtual && true;
214
            }
215
            if ($page->getVariable($variable) == $value) {
216
                return $notVirtual && true;
217
            }
218
        });
219
220
        return $filteredPages;
221
    }
222
223
    /**
224
     * Sorts by title.
225
     *
226
     * @param \Traversable $collection
227
     *
228
     * @return array
229
     */
230
    public function sortByTitle(\Traversable $collection): array
231
    {
232
        $collection = iterator_to_array($collection);
233
        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

233
        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

233
        array_multisort(/** @scrutinizer ignore-type */ array_keys($collection), SORT_NATURAL | SORT_FLAG_CASE, $collection);
Loading history...
234
235
        return $collection;
236
    }
237
238
    /**
239
     * Sorts by weight.
240
     *
241
     * @param \Traversable $collection
242
     *
243
     * @return array
244
     */
245
    public function sortByWeight(\Traversable $collection): array
246
    {
247
        $callback = function ($a, $b) {
248
            if (!isset($a['weight'])) {
249
                $a['weight'] = 0;
250
            }
251
            if (!isset($b['weight'])) {
252
                $a['weight'] = 0;
253
            }
254
            if ($a['weight'] == $b['weight']) {
255
                return 0;
256
            }
257
258
            return ($a['weight'] < $b['weight']) ? -1 : 1;
259
        };
260
261
        $collection = iterator_to_array($collection);
262
        usort($collection, $callback);
263
264
        return $collection;
265
    }
266
267
    /**
268
     * Sorts by date: the most recent first.
269
     *
270
     * @param \Traversable $collection
271
     *
272
     * @return array
273
     */
274
    public function sortByDate(\Traversable $collection): array
275
    {
276
        $callback = function ($a, $b) {
277
            if ($a['date'] == $b['date']) {
278
                return 0;
279
            }
280
281
            return ($a['date'] > $b['date']) ? -1 : 1;
282
        };
283
284
        $collection = iterator_to_array($collection);
285
        usort($collection, $callback);
286
287
        return $collection;
288
    }
289
290
    /**
291
     * Creates an URL.
292
     *
293
     * $options[
294
     *     'canonical' => true,
295
     *     'addhash'   => false,
296
     *     'format'    => 'json',
297
     * ];
298
     *
299
     * @param Page|Asset|string|null $value
300
     * @param array|null             $options
301
     *
302
     * @return mixed
303
     */
304
    public function url($value = null, array $options = null)
305
    {
306
        return new Url($this->builder, $value, $options);
307
    }
308
309
    /**
310
     * Creates an asset (CSS, JS, images, etc.).
311
     *
312
     * @param string|array $path    File path (relative from static/ dir).
313
     * @param array|null   $options
314
     *
315
     * @return Asset
316
     */
317
    public function asset($path, array $options = null): Asset
318
    {
319
        return new Asset($this->builder, $path, $options);
320
    }
321
322
    /**
323
     * Fingerprinting an asset.
324
     *
325
     * @param string|Asset $asset
326
     *
327
     * @return Asset
328
     */
329
    public function fingerprint($asset): Asset
330
    {
331
        if (!$asset instanceof Asset) {
332
            $asset = new Asset($this->builder, $asset);
333
        }
334
335
        return $asset->fingerprint();
336
    }
337
338
    /**
339
     * Minifying an asset (CSS or JS).
340
     *
341
     * @param string|Asset $asset
342
     *
343
     * @return Asset
344
     */
345
    public function minify($asset): Asset
346
    {
347
        if (!$asset instanceof Asset) {
348
            $asset = new Asset($this->builder, $asset);
349
        }
350
351
        return $asset->minify();
352
    }
353
354
    /**
355
     * Compiles a SCSS asset.
356
     *
357
     * @param string|Asset $asset
358
     *
359
     * @return Asset
360
     */
361
    public function toCss($asset): Asset
362
    {
363
        if (!$asset instanceof Asset) {
364
            $asset = new Asset($this->builder, $asset);
365
        }
366
367
        return $asset->compile();
368
    }
369
370
    /**
371
     * Resizes an image.
372
     *
373
     * @param string $path Image path (relative from static/ dir or external).
374
     * @param int    $size Image new size (width).
375
     *
376
     * @return string
377
     */
378
    public function resize(string $path, int $size): string
379
    {
380
        return (new Image($this->builder))
381
            ->load($path)
382
            ->resize($size);
383
    }
384
385
    /**
386
     * Hashing an asset with algo (sha384 by default).
387
     *
388
     * @param string|Asset $path
389
     * @param string       $algo
390
     *
391
     * @return string
392
     */
393
    public function integrity($asset, string $algo = 'sha384'): string
394
    {
395
        if (!$asset instanceof Asset) {
396
            $asset = new Asset($this->builder, $asset);
397
        }
398
399
        return $asset->getIntegrity($algo);
400
    }
401
402
    /**
403
     * Minifying a CSS string.
404
     *
405
     * @param string $value
406
     *
407
     * @return string
408
     */
409
    public function minifyCss(string $value): string
410
    {
411
        $cache = new Cache($this->builder, 'assets');
412
        $cacheKey = $cache->createKeyFromValue($value);
413
        if (!$cache->has($cacheKey)) {
414
            $minifier = new Minify\CSS($value);
415
            $value = $minifier->minify();
416
            $cache->set($cacheKey, $value);
417
        }
418
419
        return $cache->get($cacheKey, $value);
420
    }
421
422
    /**
423
     * Minifying a JavaScript string.
424
     *
425
     * @param string $value
426
     *
427
     * @return string
428
     */
429
    public function minifyJs(string $value): string
430
    {
431
        $cache = new Cache($this->builder, 'assets');
432
        $cacheKey = $cache->createKeyFromValue($value);
433
        if (!$cache->has($cacheKey)) {
434
            $minifier = new Minify\JS($value);
435
            $value = $minifier->minify();
436
            $cache->set($cacheKey, $value);
437
        }
438
439
        return $cache->get($cacheKey, $value);
440
    }
441
442
    /**
443
     * Compiles a SCSS string.
444
     *
445
     * @param string $value
446
     *
447
     * @return string
448
     */
449
    public function scssToCss(string $value): string
450
    {
451
        $cache = new Cache($this->builder, 'assets');
452
        $cacheKey = $cache->createKeyFromValue($value);
453
        if (!$cache->has($cacheKey)) {
454
            $scssPhp = new Compiler();
455
            $outputStyles = ['expanded', 'compressed'];
456
            $outputStyle = strtolower((string) $this->config->get('assets.compile.style'));
457
            if (!in_array($outputStyle, $outputStyles)) {
458
                throw new Exception(\sprintf('Scss output style "%s" doesn\'t exists.', $outputStyle));
459
            }
460
            $scssPhp->setOutputStyle($outputStyle);
461
            $scssPhp->setVariables($this->config->get('assets.compile.variables') ?? []);
0 ignored issues
show
Deprecated Code introduced by
The function ScssPhp\ScssPhp\Compiler::setVariables() has been deprecated: Use "addVariables" or "replaceVariables" instead. ( Ignorable by Annotation )

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

461
            /** @scrutinizer ignore-deprecated */ $scssPhp->setVariables($this->config->get('assets.compile.variables') ?? []);

This function has been deprecated. The supplier of the function has supplied an explanatory message.

The explanatory message should give you some clue as to whether and when the function will be removed and what other function to use instead.

Loading history...
462
            $value = $scssPhp->compile($value);
0 ignored issues
show
Deprecated Code introduced by
The function ScssPhp\ScssPhp\Compiler::compile() has been deprecated: Use {@see compileString} instead. ( Ignorable by Annotation )

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

462
            $value = /** @scrutinizer ignore-deprecated */ $scssPhp->compile($value);

This function has been deprecated. The supplier of the function has supplied an explanatory message.

The explanatory message should give you some clue as to whether and when the function will be removed and what other function to use instead.

Loading history...
463
            $cache->set($cacheKey, $value);
464
        }
465
466
        return $cache->get($cacheKey, $value);
467
    }
468
469
    /**
470
     * Returns the HTML version of an asset.
471
     *
472
     * @param Asset      $asset
473
     * @param array|null $attributes
474
     * @param array|null $options
475
     *
476
     * $options[
477
     *     'preload' => true,
478
     * ];
479
     *
480
     * @return string
481
     */
482
    public function html(Asset $asset, array $attributes = null, array $options = null): string
483
    {
484
        $htmlAttributes = '';
485
        foreach ($attributes as $name => $value) {
486
            if (!empty($value)) {
487
                $htmlAttributes .= \sprintf(' %s="%s"', $name, $value);
488
            } else {
489
                $htmlAttributes .= \sprintf(' %s', $name);
490
            }
491
        }
492
493
        switch ($asset['ext']) {
494
            case 'css':
495
                if ($options['preload']) {
496
                    return \sprintf(
497
                        '<link href="%s" rel="preload" as="style" onload="this.onload=null;this.rel=\'stylesheet\'"%s>
498
                         <noscript><link rel="stylesheet" href="%1$s"%2$s></noscript>',
499
                        $this->url($asset['path'], $options),
500
                        $htmlAttributes
501
                    );
502
                }
503
504
                return \sprintf('<link rel="stylesheet" href="%s"%s>', $this->url($asset['path'], $options), $htmlAttributes);
505
            case 'js':
506
                return \sprintf('<script src="%s"%s></script>', $this->url($asset['path'], $options), $htmlAttributes);
507
        }
508
509
        if ($asset['type'] == 'image') {
510
            return \sprintf(
511
                '<img src="%s"%s>',
512
                $this->url($asset['path'], $options),
513
                $htmlAttributes
514
            );
515
        }
516
517
        throw new Exception(\sprintf('%s is available with CSS, JS and images files only.', '"html" filter'));
518
    }
519
520
    /**
521
     * Returns the content of an asset.
522
     *
523
     * @param Asset $asset
524
     *
525
     * @return string
526
     */
527
    public function inline(Asset $asset): string
528
    {
529
        if (is_null($asset['content'])) {
530
            throw new Exception(\sprintf('%s is available with CSS et JS files only.', '"inline" filter'));
531
        }
532
533
        return $asset['content'];
534
    }
535
536
    /**
537
     * Reads $length first characters of a string and adds a suffix.
538
     *
539
     * @param string|null $string
540
     * @param int         $length
541
     * @param string      $suffix
542
     *
543
     * @return string|null
544
     */
545
    public function excerpt(string $string = null, int $length = 450, string $suffix = ' …'): ?string
546
    {
547
        $string = str_replace('</p>', '<br /><br />', $string);
548
        $string = trim(strip_tags($string, '<br>'), '<br />');
549
        if (mb_strlen($string) > $length) {
550
            $string = mb_substr($string, 0, $length);
551
            $string .= $suffix;
552
        }
553
554
        return $string;
555
    }
556
557
    /**
558
     * Reads characters before '<!-- excerpt|break -->'.
559
     *
560
     * @param string|null $string
561
     *
562
     * @return string|null
563
     */
564
    public function excerptHtml(string $string = null): ?string
565
    {
566
        // https://regex101.com/r/Xl7d5I/3
567
        $pattern = '(.*)(<!--[[:blank:]]?(excerpt|break)[[:blank:]]?-->)(.*)';
568
        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

568
        preg_match('/'.$pattern.'/is', /** @scrutinizer ignore-type */ $string, $matches);
Loading history...
569
        if (empty($matches)) {
570
            return $string;
571
        }
572
573
        return trim($matches[1]);
574
    }
575
576
    /**
577
     * Converts a Markdown string to HTML.
578
     *
579
     * @param string|null $markdown
580
     *
581
     * @return string|null
582
     */
583
    public function markdownToHtml(string $markdown): ?string
584
    {
585
        try {
586
            $parsedown = new Parsedown($this->builder);
587
            $html = $parsedown->text($markdown);
588
        } catch (\Exception $e) {
589
            throw new Exception('"markdown_to_html" filter can not convert supplied Markdown.');
590
        }
591
592
        return $html;
593
    }
594
595
    /**
596
     * Converts a JSON string to an array.
597
     *
598
     * @param string|null $json
599
     *
600
     * @return array|null
601
     */
602
    public function jsonDecode(string $json): ?array
603
    {
604
        try {
605
            $array = json_decode($json, true);
606
            if ($array === null && json_last_error() !== JSON_ERROR_NONE) {
607
                throw new \Exception('Error');
608
            }
609
        } catch (\Exception $e) {
610
            throw new Exception('"json_decode" filter can not parse supplied JSON.');
611
        }
612
613
        return $array;
614
    }
615
616
    /**
617
     * Calculates estimated time to read a text.
618
     *
619
     * @param string|null $text
620
     *
621
     * @return string
622
     */
623
    public function readtime(string $text = null): string
624
    {
625
        $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

625
        $words = str_word_count(strip_tags(/** @scrutinizer ignore-type */ $text));
Loading history...
626
        $min = floor($words / 200);
627
        if ($min === 0) {
628
            return '1';
629
        }
630
631
        return (string) $min;
632
    }
633
634
    /**
635
     * Gets the value of an environment variable.
636
     *
637
     * @param string $var
638
     *
639
     * @return string|null
640
     */
641
    public function getEnv(string $var): ?string
642
    {
643
        return getenv($var) ?: null;
644
    }
645
}
646