Passed
Push — twig ( 58b23d )
by Arnaud
05:08
created

Extension::excerpt()   A

Complexity

Conditions 2
Paths 2

Size

Total Lines 10
Code Lines 6

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 2
eloc 6
nc 2
nop 3
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\Builder;
17
use Cecil\Collection\CollectionInterface;
18
use Cecil\Collection\Page\Collection as PagesCollection;
19
use Cecil\Collection\Page\Page;
20
use Cecil\Config;
21
use Cecil\Exception\Exception;
22
use Cecil\Util;
23
use Cocur\Slugify\Bridge\Twig\SlugifyExtension;
24
use Cocur\Slugify\Slugify;
25
use MatthiasMullie\Minify;
26
use ScssPhp\ScssPhp\Compiler;
27
28
/**
29
 * Class Twig\Extension.
30
 */
31
class Extension extends SlugifyExtension
32
{
33
    /** @var Builder */
34
    protected $builder;
35
    /** @var Config */
36
    protected $config;
37
    /** @var Slugify */
38
    private static $slugifier;
39
40
    /**
41
     * @param Builder $builder
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, 'createUrl']),
76
            new \Twig\TwigFilter('minify', [$this, 'minify']),
77
            new \Twig\TwigFilter('to_css', [$this, 'toCss']),
78
            new \Twig\TwigFilter('html', [$this, 'createHtmlElement']),
79
            new \Twig\TwigFilter('inline', [$this, 'getContent']),
80
            new \Twig\TwigFilter('minify_css', [$this, 'minifyCss']),
81
            new \Twig\TwigFilter('minify_js', [$this, 'minifyJs']),
82
            new \Twig\TwigFilter('scss_to_css', [$this, 'scssToCss']),
83
            new \Twig\TwigFilter('sass_to_css', [$this, 'scssToCss']),
84
            new \Twig\TwigFilter('resize', [$this, 'resize']),
85
            // content
86
            new \Twig\TwigFilter('slugify', [$this, 'slugifyFilter']),
87
            new \Twig\TwigFilter('excerpt', [$this, 'excerpt']),
88
            new \Twig\TwigFilter('excerpt_html', [$this, 'excerptHtml']),
89
            // deprecated
90
            new \Twig\TwigFilter('filterBy', [$this, 'filterBy'], ['deprecated' => true, 'alternative' => 'filter_by']),
91
            new \Twig\TwigFilter('sortByTitle', [$this, 'sortByTitle'], ['deprecated' => true, 'alternative' => 'sort_by_title']),
92
            new \Twig\TwigFilter('sortByWeight', [$this, 'sortByWeight'], ['deprecated' => true, 'alternative' => 'sort_by_weight']),
93
            new \Twig\TwigFilter('sortByDate', [$this, 'sortByDate'], ['deprecated' => true, 'alternative' => 'sort_by_date']),
94
            new \Twig\TwigFilter('minifyCSS', [$this, 'minifyCss'], ['deprecated' => true, 'alternative' => 'minifyCss']),
95
            new \Twig\TwigFilter('minifyJS', [$this, 'minifyJs'], ['deprecated' => true, 'alternative' => 'minifyJs']),
96
            new \Twig\TwigFilter('SCSStoCSS', [$this, 'scssToCss'], ['deprecated' => true, 'alternative' => 'scss_to_css']),
97
            new \Twig\TwigFilter('excerptHtml', [$this, 'excerptHtml'], ['deprecated' => true, 'alternative' => 'excerpt_html']),
98
            new \Twig\TwigFilter('urlize', [$this, 'slugifyFilter'], ['deprecated' => true, 'alternative' => 'slugify']),
99
        ];
100
    }
101
102
    /**
103
     * {@inheritdoc}
104
     */
105
    public function getFunctions()
106
    {
107
        return [
108
            // assets
109
            new \Twig\TwigFunction('url', [$this, 'createUrl']),
110
            new \Twig\TwigFunction('asset', [$this, 'asset']),
111
            new \Twig\TwigFunction('hash', [$this, 'hashFile']),
112
            // content
113
            new \Twig\TwigFunction('readtime', [$this, 'readtime']),
114
            // others
115
            new \Twig\TwigFunction('getenv', [$this, 'getEnv']),
116
            // deprecated
117
            new \Twig\TwigFunction('minify', [$this, 'minify'], ['deprecated' => true, 'alternative' => 'minify filter']),
118
            new \Twig\TwigFunction('toCSS', [$this, 'toCss'], ['deprecated' => true, 'alternative' => 'to_css filter']),
119
        ];
120
    }
121
122
    /**
123
     * Filters by Section.
124
     *
125
     * Alias of `filterBy('section', $value)`.
126
     *
127
     * @param PagesCollection $pages
128
     * @param string          $section
129
     *
130
     * @return CollectionInterface
131
     */
132
    public function filterBySection(PagesCollection $pages, string $section): CollectionInterface
133
    {
134
        return $this->filterBy($pages, 'section', $section);
135
    }
136
137
    /**
138
     * Filters by variable's name/value.
139
     *
140
     * @param PagesCollection $pages
141
     * @param string          $variable
142
     * @param string          $value
143
     *
144
     * @return CollectionInterface
145
     */
146
    public function filterBy(PagesCollection $pages, string $variable, string $value): CollectionInterface
147
    {
148
        $filteredPages = $pages->filter(function (Page $page) use ($variable, $value) {
149
            $notVirtual = false;
150
            if (!$page->isVirtual()) {
151
                $notVirtual = true;
152
            }
153
            // is a dedicated getter exists?
154
            $method = 'get'.ucfirst($variable);
155
            if (method_exists($page, $method) && $page->$method() == $value) {
156
                return $notVirtual && true;
157
            }
158
            if ($page->getVariable($variable) == $value) {
159
                return $notVirtual && true;
160
            }
161
        });
162
163
        return $filteredPages;
164
    }
165
166
    /**
167
     * Sorts by title.
168
     *
169
     * @param \Traversable $collection
170
     *
171
     * @return array
172
     */
173
    public function sortByTitle(\Traversable $collection): array
174
    {
175
        $collection = iterator_to_array($collection);
176
        array_multisort(array_keys($collection), SORT_NATURAL | SORT_FLAG_CASE, $collection);
1 ignored issue
show
Bug introduced by
array_keys($collection) cannot be passed to array_multisort() as the parameter $arr 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

176
        array_multisort(/** @scrutinizer ignore-type */ array_keys($collection), SORT_NATURAL | SORT_FLAG_CASE, $collection);
Loading history...
177
178
        return $collection;
179
    }
180
181
    /**
182
     * Sorts by weight.
183
     *
184
     * @param \Traversable $collection
185
     *
186
     * @return array
187
     */
188
    public function sortByWeight(\Traversable $collection): array
189
    {
190
        $callback = function ($a, $b) {
191
            if (!isset($a['weight'])) {
192
                return 1;
193
            }
194
            if (!isset($b['weight'])) {
195
                return -1;
196
            }
197
            if ($a['weight'] == $b['weight']) {
198
                return 0;
199
            }
200
201
            return ($a['weight'] < $b['weight']) ? -1 : 1;
202
        };
203
204
        $collection = iterator_to_array($collection);
205
        usort($collection, $callback);
206
207
        return $collection;
208
    }
209
210
    /**
211
     * Sorts by date.
212
     *
213
     * @param \Traversable $collection
214
     *
215
     * @return array
216
     */
217
    public function sortByDate(\Traversable $collection): array
218
    {
219
        $callback = function ($a, $b) {
220
            if (!isset($a['date'])) {
221
                return -1;
222
            }
223
            if (!isset($b['date'])) {
224
                return 1;
225
            }
226
            if ($a['date'] == $b['date']) {
227
                return 0;
228
            }
229
230
            return ($a['date'] > $b['date']) ? -1 : 1;
231
        };
232
233
        $collection = iterator_to_array($collection);
234
        usort($collection, $callback);
235
236
        return $collection;
237
    }
238
239
    /**
240
     * Creates an URL.
241
     *
242
     * $options[
243
     *     'canonical' => true,
244
     *     'addhash'   => false,
245
     *     'format'    => 'json',
246
     * ];
247
     *
248
     * @param Page|Asset|string|null $value
249
     * @param array|null             $options
250
     *
251
     * @return mixed
252
     */
253
    public function createUrl($value = null, $options = null)
254
    {
255
        $baseurl = (string) $this->config->get('baseurl');
256
        $hash = md5((string) $this->config->get('time'));
257
        $base = '';
258
259
        // handles options
260
        $canonical = null;
261
        $addhash = false;
262
        $format = null;
263
        extract(is_array($options) ? $options : []);
264
265
        // set baseurl
266
        if ((bool) $this->config->get('canonicalurl') || $canonical === true) {
267
            $base = rtrim($baseurl, '/');
268
        }
269
        if ($canonical === false) {
270
            $base = '';
271
        }
272
273
        // value is empty: url()
274
        if (empty($value) || $value == '/') {
275
            return $base.'/';
276
        }
277
278
        // value is a Page item
279
        if ($value instanceof Page) {
280
            if (!$format) {
281
                $format = $value->getVariable('output');
282
                if (is_array($value->getVariable('output'))) {
283
                    $format = $value->getVariable('output')[0];
284
                }
285
                if (!$format) {
286
                    $format = 'html';
287
                }
288
            }
289
            $url = $value->getUrl($format, $this->config);
290
            $url = $base.'/'.ltrim($url, '/');
291
292
            return $url;
293
        }
294
295
        // value is an Asset object
296
        if ($value instanceof Asset) {
297
            $asset = $value;
298
            $url = $asset['path'];
299
            if ($addhash) {
300
                $url .= '?'.$hash;
301
            }
302
            $url = $base.'/'.ltrim($url, '/');
303
            $asset['path'] = $url;
304
305
            return $asset;
306
        }
307
308
        // value is an external URL
309
        if (Util::isExternalUrl($value)) {
310
            $url = $value;
311
312
            return $url;
313
        }
314
315
        // value is a string
316
        $value = Util::joinPath($value);
317
318
        // value is (certainly) a path to a ressource (ie: 'path/file.pdf')
319
        if (false !== strpos($value, '.')) {
320
            $url = $value;
321
            if ($addhash) {
322
                $url .= '?'.$hash;
323
            }
324
            $url = $base.'/'.ltrim($url, '/');
325
326
            return $url;
327
        }
328
329
        // others cases
330
        $url = $base.'/'.$value;
331
332
        // value is a page ID (ie: 'path/my-page')
333
        try {
334
            $pageId = $this->slugifyFilter($value);
335
            $page = $this->builder->getPages()->get($pageId);
336
            $url = $this->createUrl($page, $options);
337
        } catch (\DomainException $e) {
338
            // nothing to do
339
        }
340
341
        return $url;
342
    }
343
344
    /**
345
     * Manages assets (CSS, JS and images).
346
     *
347
     * @param string     $path    File path (relative from static/ dir).
348
     * @param array|null $options
349
     *
350
     * @return Asset
351
     */
352
    public function asset(string $path, array $options = null): Asset
353
    {
354
        return new Asset($this->builder, $path, $options);
355
    }
356
357
    /**
358
     * Minifying an asset (CSS or JS).
359
     * ie: minify('css/style.css').
360
     *
361
     * @param string|Asset $asset
362
     *
363
     * @throws Exception
364
     *
365
     * @return Asset
366
     */
367
    public function minify($asset): Asset
368
    {
369
        if (!$asset instanceof Asset) {
370
            $asset = new Asset($this->builder, $asset);
371
        }
372
373
        $oldPath = $asset['path'];
374
        $asset['path'] = \sprintf('%s.min.%s', substr($asset['path'], 0, -strlen('.'.$asset['ext'])), $asset['ext']);
375
376
        $cache = new Cache($this->builder, 'assets');
377
        $cacheKey = $cache->createKeyFromAsset($asset);
378
        if (!$cache->has($cacheKey)) {
379
            switch ($asset['ext']) {
380
                case 'css':
381
                    $minifier = new Minify\CSS($asset['content']);
382
                    break;
383
                case 'js':
384
                    $minifier = new Minify\JS($asset['content']);
385
                    break;
386
                default:
387
                    throw new Exception(sprintf('%s() error: not able to process "%s"', __FUNCTION__, $asset));
388
            }
389
            $asset['content'] = $minifier->minify();
390
            $cache->set($cacheKey, $asset['content']);
391
        }
392
        $asset['content'] = $cache->get($cacheKey, $asset['content']);
393
394
        // save?
395
        if (!$this->builder->getBuildOptions()['dry-run']) {
396
            Util::getFS()->dumpFile(Util::joinFile($this->config->getOutputPath(), $asset['path']), $asset['content']);
397
            Util::getFS()->remove(Util::joinFile($this->config->getOutputPath(), $oldPath));
398
        }
399
400
        return $asset;
401
    }
402
403
    /**
404
     * Compiles a SCSS asset.
405
     *
406
     * @param string|Asset $asset
407
     *
408
     * @throws Exception
409
     *
410
     * @return Asset
411
     */
412
    public function toCss($asset): Asset
413
    {
414
        if (!$asset instanceof Asset) {
415
            $asset = new Asset($this->builder, $asset);
416
        }
417
418
        if ($asset['ext'] != 'scss') {
419
            throw new Exception(sprintf('%s() error: not able to process "%s"', __FUNCTION__, $asset));
420
        }
421
422
        $oldPath = $asset['path'];
423
        $asset['path'] = preg_replace('/scss/m', 'css', $asset['path']);
424
        $asset['ext'] = 'css';
425
426
        $cache = new Cache($this->builder, 'assets');
427
        $cacheKey = $cache->createKeyFromAsset($asset);
428
        if (!$cache->has($cacheKey)) {
429
            $scssPhp = new Compiler();
430
            $variables = $this->config->get('assets.sass.variables') ?? [];
431
            $scssDir = $this->config->get('assets.sass.dir') ?? [];
432
            $themes = $this->config->getTheme() ?? [];
433
            foreach ($scssDir as $dir) {
434
                $scssPhp->addImportPath(Util::joinPath($this->config->getStaticPath(), $dir));
435
                $scssPhp->addImportPath(Util::joinPath(dirname($asset['file']), $dir));
436
                foreach ($themes as $theme) {
437
                    $scssPhp->addImportPath(Util::joinPath($this->config->getThemeDirPath($theme, "static/$dir")));
438
                }
439
            }
440
            $scssPhp->setVariables($variables);
441
            $scssPhp->setFormatter('ScssPhp\ScssPhp\Formatter\\'.ucfirst($this->config->get('assets.sass.style')));
442
            $asset['content'] = $scssPhp->compile($asset['content']);
443
            $cache->set($cacheKey, $asset['content']);
444
        }
445
446
        $asset['content'] = $cache->get($cacheKey, $asset['content']);
447
448
        // save?
449
        if (!$this->builder->getBuildOptions()['dry-run']) {
450
            Util::getFS()->dumpFile(Util::joinFile($this->config->getOutputPath(), $asset['path']), $asset['content']);
451
            Util::getFS()->remove(Util::joinFile($this->config->getOutputPath(), $oldPath));
452
        }
453
454
        return $asset;
455
    }
456
457
    /**
458
     * Resizes an image.
459
     *
460
     * @param string $path Image path (relative from static/ dir or external).
461
     * @param int    $size Image new size (width).
462
     *
463
     * @return string
464
     */
465
    public function resize(string $path, int $size): string
466
    {
467
        return (new Image($this->builder))->resize($path, $size);
468
    }
469
470
    /**
471
     * Hashing an asset with sha384.
472
     * Useful for SRI (Subresource Integrity).
473
     *
474
     * @see https://developer.mozilla.org/fr/docs/Web/Security/Subresource_Integrity
475
     *
476
     * @param string|Asset $path
477
     *
478
     * @return string|null
479
     */
480
    public function hashFile($asset): ?string
481
    {
482
        if (!$asset instanceof Asset) {
483
            $asset = new Asset($this->builder, $asset);
484
        }
485
486
        return sprintf('sha384-%s', base64_encode(hash('sha384', $asset['content'], true)));
487
    }
488
489
    /**
490
     * Minifying a CSS string.
491
     *
492
     * @param string $value
493
     *
494
     * @return string
495
     */
496
    public function minifyCss(string $value): string
497
    {
498
        $cache = new Cache($this->builder, 'assets');
499
        $cacheKey = $cache->createKeyFromValue($value);
500
        if (!$cache->has($cacheKey)) {
501
            $minifier = new Minify\CSS($value);
502
            $value = $minifier->minify();
503
            $cache->set($cacheKey, $value);
504
        }
505
506
        return $cache->get($cacheKey, $value);
507
    }
508
509
    /**
510
     * Minifying a JavaScript string.
511
     *
512
     * @param string $value
513
     *
514
     * @return string
515
     */
516
    public function minifyJs(string $value): string
517
    {
518
        $cache = new Cache($this->builder, 'assets');
519
        $cacheKey = $cache->createKeyFromValue($value);
520
        if (!$cache->has($cacheKey)) {
521
            $minifier = new Minify\JS($value);
522
            $value = $minifier->minify();
523
            $cache->set($cacheKey, $value);
524
        }
525
526
        return $cache->get($cacheKey, $value);
527
    }
528
529
    /**
530
     * Compiles a SCSS string.
531
     *
532
     * @param string $value
533
     *
534
     * @return string
535
     */
536
    public function scssToCss(string $value): string
537
    {
538
        $cache = new Cache($this->builder, 'assets');
539
        $cacheKey = $cache->createKeyFromValue($value);
540
        if (!$cache->has($cacheKey)) {
541
            $scss = new Compiler();
542
            $value = $scss->compile($value);
543
            $cache->set($cacheKey, $value);
544
        }
545
546
        return $cache->get($cacheKey, $value);
547
    }
548
549
    /**
550
     * Creates an HTML element from an asset.
551
     *
552
     * @return string
553
     */
554
    public function createHtmlElement(Asset $asset): string
555
    {
556
        if ($asset['type'] == 'image') {
557
            $title = array_key_exists('title', $asset['attributs']) ? $asset['attributs']['title'] : null;
0 ignored issues
show
Bug introduced by
It seems like $asset['attributs'] can also be of type null; however, parameter $search of array_key_exists() does only seem to accept array, 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

557
            $title = array_key_exists('title', /** @scrutinizer ignore-type */ $asset['attributs']) ? $asset['attributs']['title'] : null;
Loading history...
558
            $alt = array_key_exists('alt', $asset['attributs']) ? $asset['attributs']['alt'] : null;
559
560
            return \sprintf(
561
                '<img src="%s"%s%s>',
562
                $asset['path'],
563
                !is_null($title) ? \sprintf(' title="%s"', $title) : '',
564
                !is_null($alt) ? \sprintf(' alt="%s"', $alt) : ''
565
            );
566
        }
567
568
        switch ($asset['ext']) {
569
            case 'css':
570
                return \sprintf('<link rel="stylesheet" href="%s">', $asset['path']);
571
            case 'js':
572
                return \sprintf('<script src="%s"></script>', $asset['path']);
573
        }
574
575
        throw new Exception(\sprintf('%s is available with CSS, JS and images files only.', '"html" filter'));
576
    }
577
578
    /**
579
     * Returns the content of an Asset.
580
     *
581
     * @param Asset $asset
582
     *
583
     * @return string
584
     */
585
    public function getContent(Asset $asset): string
586
    {
587
        if (is_null($asset['content'])) {
588
            throw new Exception(\sprintf('%s is available with CSS et JS files only.', '"inline" filter'));
589
        }
590
591
        return $asset['content'];
592
    }
593
594
    /**
595
     * Reads $length first characters of a string and adds a suffix.
596
     *
597
     * @param string|null $string
598
     * @param int         $length
599
     * @param string      $suffix
600
     *
601
     * @return string|null
602
     */
603
    public function excerpt(string $string = null, int $length = 450, string $suffix = ' …'): ?string
604
    {
605
        $string = str_replace('</p>', '<br /><br />', $string);
606
        $string = trim(strip_tags($string, '<br>'), '<br />');
607
        if (mb_strlen($string) > $length) {
608
            $string = mb_substr($string, 0, $length);
609
            $string .= $suffix;
610
        }
611
612
        return $string;
613
    }
614
615
    /**
616
     * Reads characters before '<!-- excerpt|break -->'.
617
     *
618
     * @param string|null $string
619
     *
620
     * @return string|null
621
     */
622
    public function excerptHtml(string $string = null): ?string
623
    {
624
        // https://regex101.com/r/Xl7d5I/3
625
        $pattern = '(.*)(<!--[[:blank:]]?(excerpt|break)[[:blank:]]?-->)(.*)';
626
        preg_match('/'.$pattern.'/is', $string, $matches);
627
        if (empty($matches)) {
628
            return $string;
629
        }
630
631
        return trim($matches[1]);
632
    }
633
634
    /**
635
     * Calculates estimated time to read a text.
636
     *
637
     * @param string|null $text
638
     *
639
     * @return string
640
     */
641
    public function readtime(string $text = null): string
642
    {
643
        $words = str_word_count(strip_tags($text));
644
        $min = floor($words / 200);
645
        if ($min === 0) {
646
            return '1';
647
        }
648
649
        return (string) $min;
650
    }
651
652
    /**
653
     * Gets the value of an environment variable.
654
     *
655
     * @param string $var
656
     *
657
     * @return string|null
658
     */
659
    public function getEnv(string $var): ?string
660
    {
661
        return getenv($var) ?: null;
662
    }
663
}
664