Passed
Push — assets ( a30644...e272f9 )
by Arnaud
02:30
created

Extension::toCss()   B

Complexity

Conditions 7
Paths 10

Size

Total Lines 46
Code Lines 27

Duplication

Lines 0
Ratio 0 %

Importance

Changes 2
Bugs 0 Features 0
Metric Value
cc 7
eloc 27
c 2
b 0
f 0
nc 10
nop 1
dl 0
loc 46
rs 8.5546
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('filterBySection', [$this, 'filterBySection']),
70
            new \Twig\TwigFilter('filterBy', [$this, 'filterBy']),
71
            new \Twig\TwigFilter('sortByTitle', [$this, 'sortByTitle']),
72
            new \Twig\TwigFilter('sortByWeight', [$this, 'sortByWeight']),
73
            new \Twig\TwigFilter('sortByDate', [$this, 'sortByDate']),
74
            new \Twig\TwigFilter('urlize', [$this, 'slugifyFilter']),
75
            new \Twig\TwigFilter('url', [$this, 'createUrl']),
76
            new \Twig\TwigFilter('minify', [$this, 'minify']),
77
            new \Twig\TwigFilter('minifyCSS', [$this, 'minifyCss']),
78
            new \Twig\TwigFilter('minifyJS', [$this, 'minifyJs']),
79
            new \Twig\TwigFilter('SCSStoCSS', [$this, 'scssToCss']),
80
            new \Twig\TwigFilter('excerpt', [$this, 'excerpt']),
81
            new \Twig\TwigFilter('excerptHtml', [$this, 'excerptHtml']),
82
            new \Twig\TwigFilter('resize', [$this, 'resize']),
83
        ];
84
    }
85
86
    /**
87
     * {@inheritdoc}
88
     */
89
    public function getFunctions()
90
    {
91
        return [
92
            new \Twig\TwigFunction('url', [$this, 'createUrl']),
93
            new \Twig\TwigFunction('minify', [$this, 'minify']),
94
            new \Twig\TwigFunction('readtime', [$this, 'readtime']),
95
            new \Twig\TwigFunction('toCSS', [$this, 'toCss']),
96
            new \Twig\TwigFunction('hash', [$this, 'hashFile']),
97
            new \Twig\TwigFunction('getenv', [$this, 'getEnv']),
98
            new \Twig\TwigFunction('asset', [$this, 'asset']),
99
        ];
100
    }
101
102
    /**
103
     * Filters by Section.
104
     *
105
     * Alias of `filterBy('section', $value)`.
106
     *
107
     * @param PagesCollection $pages
108
     * @param string          $section
109
     *
110
     * @return CollectionInterface
111
     */
112
    public function filterBySection(PagesCollection $pages, string $section): CollectionInterface
113
    {
114
        return $this->filterBy($pages, 'section', $section);
115
    }
116
117
    /**
118
     * Filters by variable's name/value.
119
     *
120
     * @param PagesCollection $pages
121
     * @param string          $variable
122
     * @param string          $value
123
     *
124
     * @return CollectionInterface
125
     */
126
    public function filterBy(PagesCollection $pages, string $variable, string $value): CollectionInterface
127
    {
128
        $filteredPages = $pages->filter(function (Page $page) use ($variable, $value) {
129
            $notVirtual = false;
130
            if (!$page->isVirtual()) {
131
                $notVirtual = true;
132
            }
133
            // is a dedicated getter exists?
134
            $method = 'get'.ucfirst($variable);
135
            if (method_exists($page, $method) && $page->$method() == $value) {
136
                return $notVirtual && true;
137
            }
138
            if ($page->getVariable($variable) == $value) {
139
                return $notVirtual && true;
140
            }
141
        });
142
143
        return $filteredPages;
144
    }
145
146
    /**
147
     * Sorts by title.
148
     *
149
     * @param \Traversable $collection
150
     *
151
     * @return array
152
     */
153
    public function sortByTitle(\Traversable $collection): array
154
    {
155
        $collection = iterator_to_array($collection);
156
        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

156
        array_multisort(/** @scrutinizer ignore-type */ array_keys($collection), SORT_NATURAL | SORT_FLAG_CASE, $collection);
Loading history...
157
158
        return $collection;
159
    }
160
161
    /**
162
     * Sorts by weight.
163
     *
164
     * @param \Traversable $collection
165
     *
166
     * @return array
167
     */
168
    public function sortByWeight(\Traversable $collection): array
169
    {
170
        $callback = function ($a, $b) {
171
            if (!isset($a['weight'])) {
172
                return 1;
173
            }
174
            if (!isset($b['weight'])) {
175
                return -1;
176
            }
177
            if ($a['weight'] == $b['weight']) {
178
                return 0;
179
            }
180
181
            return ($a['weight'] < $b['weight']) ? -1 : 1;
182
        };
183
184
        $collection = iterator_to_array($collection);
185
        usort($collection, $callback);
186
187
        return $collection;
188
    }
189
190
    /**
191
     * Sorts by date.
192
     *
193
     * @param \Traversable $collection
194
     *
195
     * @return array
196
     */
197
    public function sortByDate(\Traversable $collection): array
198
    {
199
        $callback = function ($a, $b) {
200
            if (!isset($a['date'])) {
201
                return -1;
202
            }
203
            if (!isset($b['date'])) {
204
                return 1;
205
            }
206
            if ($a['date'] == $b['date']) {
207
                return 0;
208
            }
209
210
            return ($a['date'] > $b['date']) ? -1 : 1;
211
        };
212
213
        $collection = iterator_to_array($collection);
214
        usort($collection, $callback);
215
216
        return $collection;
217
    }
218
219
    /**
220
     * Creates an URL.
221
     *
222
     * $options[
223
     *     'canonical' => null,
224
     *     'addhash'   => true,
225
     *     'format'    => 'json',
226
     * ];
227
     *
228
     * @param Page|string|null $value
229
     * @param array|bool|null  $options
230
     *
231
     * @return string|null
232
     */
233
    public function createUrl($value = null, $options = null): ?string
234
    {
235
        $baseurl = (string) $this->config->get('baseurl');
236
        $hash = md5((string) $this->config->get('time'));
237
        $base = '';
238
        // handles options
239
        $canonical = null;
240
        $addhash = false;
241
        $format = null;
242
        extract(is_array($options) ? $options : []);
243
244
        // set baseurl
245
        if ((bool) $this->config->get('canonicalurl') || $canonical === true) {
246
            $base = rtrim($baseurl, '/');
247
        }
248
        if ($canonical === false) {
249
            $base = '';
250
        }
251
252
        // value is a Page item
253
        if ($value instanceof Page) {
254
            if (!$format) {
255
                $format = $value->getVariable('output');
256
                if (is_array($value->getVariable('output'))) {
257
                    $format = $value->getVariable('output')[0];
258
                }
259
                if (!$format) {
260
                    $format = 'html';
261
                }
262
            }
263
            $url = $value->getUrl($format, $this->config);
264
            $url = $base.'/'.ltrim($url, '/');
265
266
            return $url;
267
        }
268
269
        // value is an external URL
270
        if ($value !== null) {
271
            if (Util::isExternalUrl($value)) {
272
                $url = $value;
273
274
                return $url;
275
            }
276
        }
277
278
        // value is a string
279
        if (!is_null($value)) {
280
            // value is an external URL
281
            if (Util::isExternalUrl($value)) {
282
                $url = $value;
283
284
                return $url;
285
            }
286
            $value = Util::joinPath($value);
287
        }
288
289
        // value is a ressource URL (ie: 'path/style.css')
290
        if (false !== strpos($value, '.')) {
291
            $url = $value;
292
            if ($addhash) {
293
                $url .= '?'.$hash;
294
            }
295
            $url = $base.'/'.ltrim($url, '/');
296
297
            return $url;
298
        }
299
300
        // others cases
301
        $url = $base.'/';
302
        if (!empty($value) && $value != '/') {
303
            $url = $base.'/'.$value;
304
305
            // value is a page ID (ie: 'path/my-page')
306
            try {
307
                $pageId = $this->slugifyFilter($value);
308
                $page = $this->builder->getPages()->get($pageId);
309
                $url = $this->createUrl($page, $options);
310
            } catch (\DomainException $e) {
311
                // nothing to do
312
            }
313
        }
314
315
        return $url;
316
    }
317
318
    /**
319
     * Manages assets (CSS, JS and images).
320
     *
321
     * @param string     $path    File path (relative from static/ dir).
322
     * @param array|null $options
323
     *
324
     * @return Asset
325
     */
326
    public function asset(string $path, array $options = null): Asset
327
    {
328
        return new Asset($this->builder, $path, $options);
329
    }
330
331
    /**
332
     * Minifying an asset (CSS or JS).
333
     * ie: minify('css/style.css').
334
     *
335
     * @param string|Asset $asset
336
     *
337
     * @throws Exception
338
     *
339
     * @return Asset
340
     */
341
    public function minify($asset): Asset
342
    {
343
        if (!$asset instanceof Asset) {
344
            $asset = new Asset($this->builder, $asset);
345
        }
346
347
        $oldPath = $asset['path'];
348
        $asset['path'] = \sprintf('%s.min.%s', substr($asset['path'], 0, -strlen('.'.$asset['ext'])), $asset['ext']);
349
350
        $cache = new Cache($this->builder, 'assets');
351
        $cacheKey = $asset['path'];
352
        if (!$cache->has($cacheKey)) {
353
            switch ($asset['ext']) {
354
                case 'css':
355
                    $minifier = new Minify\CSS($asset['content']);
356
                    break;
357
                case 'js':
358
                    $minifier = new Minify\JS($asset['content']);
359
                    break;
360
                default:
361
                    throw new Exception(sprintf('%s() error: not able to process "%s"', __FUNCTION__, $asset));
362
            }
363
            $minified = $minifier->minify();
364
            $asset['content'] = $minified;
365
            $cache->set($cacheKey, $asset['content']);
366
        }
367
        $asset['content'] = $cache->get($cacheKey, $asset['content']);
368
369
        // save?
370
        if (!$this->builder->getBuildOptions()['dry-run']) {
371
            Util::getFS()->dumpFile(Util::joinFile($this->config->getOutputPath(), $asset['path']), $asset['content']);
372
            Util::getFS()->remove(Util::joinFile($this->config->getOutputPath(), $oldPath));
373
        }
374
375
        return $asset;
376
377
        throw new Exception(sprintf('%s() error: "%s" doesn\'t exist', __FUNCTION__, $asset));
0 ignored issues
show
Unused Code introduced by
ThrowNode is not reachable.

This check looks for unreachable code. It uses sophisticated control flow analysis techniques to find statements which will never be executed.

Unreachable code is most often the result of return, die or exit statements that have been added for debug purposes.

function fx() {
    try {
        doSomething();
        return true;
    }
    catch (\Exception $e) {
        return false;
    }

    return false;
}

In the above example, the last return false will never be executed, because a return statement has already been met in every possible execution path.

Loading history...
378
    }
379
380
    /**
381
     * Compiles a SCSS asset.
382
     *
383
     * @param string|Asset $path
384
     *
385
     * @throws Exception
386
     *
387
     * @return Asset
388
     */
389
    public function toCss($asset): Asset
390
    {
391
        if (!$asset instanceof Asset) {
392
            $asset = new Asset($this->builder, $asset);
393
        }
394
395
        if ($asset['ext'] != 'scss') {
396
            throw new Exception(sprintf('%s() error: not able to process "%s"', __FUNCTION__, $asset));
397
        }
398
399
        $oldPath = $asset['path'];
400
        $asset['path'] = preg_replace('/scss/m', 'css', $asset['path']);
401
        $asset['ext'] = 'css';
402
403
        $cache = new Cache($this->builder, 'assets');
404
        $cacheKey = $asset['path'];
405
        if (!$cache->has($cacheKey)) {
406
            $scssPhp = new Compiler();
407
            $scssDir = ['/', '/sass', '/scss'];
408
            $themes = array_reverse($this->config->getTheme());
0 ignored issues
show
Bug introduced by
It seems like $this->config->getTheme() can also be of type null; however, parameter $array of array_reverse() 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

408
            $themes = array_reverse(/** @scrutinizer ignore-type */ $this->config->getTheme());
Loading history...
409
            foreach ($scssDir as $value) {
410
                foreach ($themes as $theme) {
411
                    $scssPhp->addImportPath($this->config->getThemeDirPath($theme, 'static/sass'));
412
                }
413
                $scssPhp->addImportPath(dirname($asset['file']).$value);
414
                $scssPhp->addImportPath($this->config->getStaticPath().$value);
415
            }
416
            $asset['content'] = $scssPhp->compile($asset['content']);
417
418
            // DEBUG
419
            dd($scssPhp->getParsedFiles());
420
421
            $cache->set($cacheKey, $asset['content']);
422
        }
423
424
        $asset['content'] = $cache->get($cacheKey, $asset['content']);
425
426
        // save?
427
        if (!$this->builder->getBuildOptions()['dry-run']) {
428
            Util::getFS()->dumpFile(Util::joinFile($this->config->getOutputPath(), $asset['path']), $asset['content']);
429
            Util::getFS()->remove(Util::joinFile($this->config->getOutputPath(), $oldPath));
430
        }
431
432
        return $asset;
433
434
        throw new Exception(sprintf('%s() error: "%s" doesn\'t exist', __FUNCTION__, $asset));
0 ignored issues
show
Unused Code introduced by
ThrowNode is not reachable.

This check looks for unreachable code. It uses sophisticated control flow analysis techniques to find statements which will never be executed.

Unreachable code is most often the result of return, die or exit statements that have been added for debug purposes.

function fx() {
    try {
        doSomething();
        return true;
    }
    catch (\Exception $e) {
        return false;
    }

    return false;
}

In the above example, the last return false will never be executed, because a return statement has already been met in every possible execution path.

Loading history...
435
    }
436
437
    /**
438
     * Resizes an image.
439
     *
440
     * @param string $path Image path (relative from static/ dir or external).
441
     * @param int    $size Image new size (width).
442
     *
443
     * @return string
444
     */
445
    public function resize(string $path, int $size): string
446
    {
447
        return (new Image($this->builder))->resize($path, $size);
448
    }
449
450
    /**
451
     * Hashing an asset with sha384.
452
     * Useful for SRI (Subresource Integrity).
453
     *
454
     * @see https://developer.mozilla.org/fr/docs/Web/Security/Subresource_Integrity
455
     *
456
     * @param string|Asset $path
457
     *
458
     * @return string|null
459
     */
460
    public function hashFile($asset): ?string
461
    {
462
        if (!$asset instanceof Asset) {
463
            $asset = new Asset($this->builder, $asset);
464
        }
465
466
        return sprintf('sha384-%s', base64_encode(hash('sha384', $asset['content'], true)));
467
    }
468
469
    /**
470
     * Minifying a CSS string.
471
     *
472
     * @param string $value
473
     *
474
     * @return string
475
     */
476
    public function minifyCss(string $value): string
477
    {
478
        $minifier = new Minify\CSS($value);
479
480
        return $minifier->minify();
481
    }
482
483
    /**
484
     * Minifying a JavaScript string.
485
     *
486
     * @param string $value
487
     *
488
     * @return string
489
     */
490
    public function minifyJs(string $value): string
491
    {
492
        $minifier = new Minify\JS($value);
493
494
        return $minifier->minify();
495
    }
496
497
    /**
498
     * Compiles a SCSS string.
499
     *
500
     * @param string $value
501
     *
502
     * @return string
503
     */
504
    public function scssToCss(string $value): string
505
    {
506
        $scss = new Compiler();
507
508
        return $scss->compile($value);
509
    }
510
511
    /**
512
     * Reads $length first characters of a string and adds a suffix.
513
     *
514
     * @param string|null $string
515
     * @param int         $length
516
     * @param string      $suffix
517
     *
518
     * @return string|null
519
     */
520
    public function excerpt(string $string = null, int $length = 450, string $suffix = ' …'): ?string
521
    {
522
        $string = str_replace('</p>', '<br /><br />', $string);
523
        $string = trim(strip_tags($string, '<br>'), '<br />');
524
        if (mb_strlen($string) > $length) {
525
            $string = mb_substr($string, 0, $length);
526
            $string .= $suffix;
527
        }
528
529
        return $string;
530
    }
531
532
    /**
533
     * Reads characters before '<!-- excerpt|break -->'.
534
     *
535
     * @param string|null $string
536
     *
537
     * @return string|null
538
     */
539
    public function excerptHtml(string $string = null): ?string
540
    {
541
        // https://regex101.com/r/Xl7d5I/3
542
        $pattern = '(.*)(<!--[[:blank:]]?(excerpt|break)[[:blank:]]?-->)(.*)';
543
        preg_match('/'.$pattern.'/is', $string, $matches);
544
        if (empty($matches)) {
545
            return $string;
546
        }
547
548
        return trim($matches[1]);
549
    }
550
551
    /**
552
     * Calculates estimated time to read a text.
553
     *
554
     * @param string|null $text
555
     *
556
     * @return string
557
     */
558
    public function readtime(string $text = null): string
559
    {
560
        $words = str_word_count(strip_tags($text));
561
        $min = floor($words / 200);
562
        if ($min === 0) {
563
            return '1';
564
        }
565
566
        return (string) $min;
567
    }
568
569
    /**
570
     * Gets the value of an environment variable.
571
     *
572
     * @param string $var
573
     *
574
     * @return string|null
575
     */
576
    public function getEnv(string $var): ?string
577
    {
578
        return getenv($var) ?: null;
579
    }
580
}
581