Passed
Push — assets ( 44be96 )
by Arnaud
05:52
created

Extension::asset()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 3
Code Lines 1

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 1
eloc 1
c 0
b 0
f 0
nc 1
nop 2
dl 0
loc 3
rs 10
1
<?php
2
/**
3
 * This file is part of the Cecil/Cecil package.
4
 *
5
 * Copyright (c) Arnaud Ligny <[email protected]>
6
 *
7
 * For the full copyright and license information, please view the LICENSE
8
 * file that was distributed with this source code.
9
 */
10
11
namespace Cecil\Renderer\Twig;
12
13
use Cecil\Assets\Asset;
14
use Cecil\Assets\Image;
15
use Cecil\Builder;
16
use Cecil\Collection\CollectionInterface;
17
use Cecil\Collection\Page\Collection as PagesCollection;
18
use Cecil\Collection\Page\Page;
19
use Cecil\Config;
20
use Cecil\Exception\Exception;
21
use Cecil\Util;
22
use Cocur\Slugify\Bridge\Twig\SlugifyExtension;
23
use Cocur\Slugify\Slugify;
24
use MatthiasMullie\Minify;
25
use ScssPhp\ScssPhp\Compiler;
26
27
/**
28
 * Class Twig\Extension.
29
 */
30
class Extension extends SlugifyExtension
31
{
32
    /** @var Builder */
33
    protected $builder;
34
    /** @var Config */
35
    protected $config;
36
    /** @var Slugify */
37
    private static $slugifier;
38
39
    /**
40
     * @param Builder $builder
41
     */
42
    public function __construct(Builder $builder)
43
    {
44
        if (!self::$slugifier instanceof Slugify) {
45
            self::$slugifier = Slugify::create(['regexp' => Page::SLUGIFY_PATTERN]);
46
        }
47
48
        parent::__construct(self::$slugifier);
49
50
        $this->builder = $builder;
51
        $this->config = $this->builder->getConfig();
52
    }
53
54
    /**
55
     * {@inheritdoc}
56
     */
57
    public function getName()
58
    {
59
        return 'cecil';
60
    }
61
62
    /**
63
     * {@inheritdoc}
64
     */
65
    public function getFilters()
66
    {
67
        return [
68
            new \Twig\TwigFilter('filterBySection', [$this, 'filterBySection']),
69
            new \Twig\TwigFilter('filterBy', [$this, 'filterBy']),
70
            new \Twig\TwigFilter('sortByTitle', [$this, 'sortByTitle']),
71
            new \Twig\TwigFilter('sortByWeight', [$this, 'sortByWeight']),
72
            new \Twig\TwigFilter('sortByDate', [$this, 'sortByDate']),
73
            new \Twig\TwigFilter('urlize', [$this, 'slugifyFilter']),
74
            new \Twig\TwigFilter('url', [$this, 'createUrl']),
75
            new \Twig\TwigFilter('minify', [$this, 'minify']),
76
            new \Twig\TwigFilter('minifyCSS', [$this, 'minifyCss']),
77
            new \Twig\TwigFilter('minifyJS', [$this, 'minifyJs']),
78
            new \Twig\TwigFilter('SCSStoCSS', [$this, 'scssToCss']),
79
            new \Twig\TwigFilter('excerpt', [$this, 'excerpt']),
80
            new \Twig\TwigFilter('excerptHtml', [$this, 'excerptHtml']),
81
            new \Twig\TwigFilter('resize', [$this, 'resize']),
82
        ];
83
    }
84
85
    /**
86
     * {@inheritdoc}
87
     */
88
    public function getFunctions()
89
    {
90
        return [
91
            new \Twig\TwigFunction('url', [$this, 'createUrl']),
92
            new \Twig\TwigFunction('minify', [$this, 'minify']),
93
            new \Twig\TwigFunction('readtime', [$this, 'readtime']),
94
            new \Twig\TwigFunction('toCSS', [$this, 'toCss']),
95
            new \Twig\TwigFunction('hash', [$this, 'hashFile']),
96
            new \Twig\TwigFunction('getenv', [$this, 'getEnv']),
97
            new \Twig\TwigFunction('asset', [$this, 'asset']),
98
        ];
99
    }
100
101
    /**
102
     * Filters by Section.
103
     *
104
     * Alias of `filterBy('section', $value)`.
105
     *
106
     * @param PagesCollection $pages
107
     * @param string          $section
108
     *
109
     * @return CollectionInterface
110
     */
111
    public function filterBySection(PagesCollection $pages, string $section): CollectionInterface
112
    {
113
        return $this->filterBy($pages, 'section', $section);
114
    }
115
116
    /**
117
     * Filters by variable's name/value.
118
     *
119
     * @param PagesCollection $pages
120
     * @param string          $variable
121
     * @param string          $value
122
     *
123
     * @return CollectionInterface
124
     */
125
    public function filterBy(PagesCollection $pages, string $variable, string $value): CollectionInterface
126
    {
127
        $filteredPages = $pages->filter(function (Page $page) use ($variable, $value) {
128
            $notVirtual = false;
129
            if (!$page->isVirtual()) {
130
                $notVirtual = true;
131
            }
132
            // is a dedicated getter exists?
133
            $method = 'get'.ucfirst($variable);
134
            if (method_exists($page, $method) && $page->$method() == $value) {
135
                return $notVirtual && true;
136
            }
137
            if ($page->getVariable($variable) == $value) {
138
                return $notVirtual && true;
139
            }
140
        });
141
142
        return $filteredPages;
143
    }
144
145
    /**
146
     * Sorts by title.
147
     *
148
     * @param \Traversable $collection
149
     *
150
     * @return array
151
     */
152
    public function sortByTitle(\Traversable $collection): array
153
    {
154
        $collection = iterator_to_array($collection);
155
        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

155
        array_multisort(/** @scrutinizer ignore-type */ array_keys($collection), SORT_NATURAL | SORT_FLAG_CASE, $collection);
Loading history...
156
157
        return $collection;
158
    }
159
160
    /**
161
     * Sorts by weight.
162
     *
163
     * @param \Traversable $collection
164
     *
165
     * @return array
166
     */
167
    public function sortByWeight(\Traversable $collection): array
168
    {
169
        $callback = function ($a, $b) {
170
            if (!isset($a['weight'])) {
171
                return 1;
172
            }
173
            if (!isset($b['weight'])) {
174
                return -1;
175
            }
176
            if ($a['weight'] == $b['weight']) {
177
                return 0;
178
            }
179
180
            return ($a['weight'] < $b['weight']) ? -1 : 1;
181
        };
182
183
        $collection = iterator_to_array($collection);
184
        usort($collection, $callback);
185
186
        return $collection;
187
    }
188
189
    /**
190
     * Sorts by date.
191
     *
192
     * @param \Traversable $collection
193
     *
194
     * @return array
195
     */
196
    public function sortByDate(\Traversable $collection): array
197
    {
198
        $callback = function ($a, $b) {
199
            if (!isset($a['date'])) {
200
                return -1;
201
            }
202
            if (!isset($b['date'])) {
203
                return 1;
204
            }
205
            if ($a['date'] == $b['date']) {
206
                return 0;
207
            }
208
209
            return ($a['date'] > $b['date']) ? -1 : 1;
210
        };
211
212
        $collection = iterator_to_array($collection);
213
        usort($collection, $callback);
214
215
        return $collection;
216
    }
217
218
    /**
219
     * Creates an URL.
220
     *
221
     * $options[
222
     *     'canonical' => null,
223
     *     'addhash'   => true,
224
     *     'format'    => 'json',
225
     * ];
226
     *
227
     * @param Page|string|null $value
228
     * @param array|bool|null  $options
229
     *
230
     * @return string|null
231
     */
232
    public function createUrl($value = null, $options = null): ?string
233
    {
234
        $baseurl = (string) $this->config->get('baseurl');
235
        $hash = md5((string) $this->config->get('time'));
236
        $base = '';
237
        // handles options
238
        $canonical = null;
239
        $addhash = false;
240
        $format = null;
241
        extract(is_array($options) ? $options : []);
242
243
        // set baseurl
244
        if ((bool) $this->config->get('canonicalurl') || $canonical === true) {
245
            $base = rtrim($baseurl, '/');
246
        }
247
        if ($canonical === false) {
248
            $base = '';
249
        }
250
251
        // value is a Page item
252
        if ($value instanceof Page) {
253
            if (!$format) {
254
                $format = $value->getVariable('output');
255
                if (is_array($value->getVariable('output'))) {
256
                    $format = $value->getVariable('output')[0];
257
                }
258
                if (!$format) {
259
                    $format = 'html';
260
                }
261
            }
262
            $url = $value->getUrl($format, $this->config);
263
            $url = $base.'/'.ltrim($url, '/');
264
265
            return $url;
266
        }
267
268
        // value is an external URL
269
        if ($value !== null) {
270
            if (Util::isExternalUrl($value)) {
271
                $url = $value;
272
273
                return $url;
274
            }
275
        }
276
277
        // value is a string
278
        if (!is_null($value)) {
279
            // value is an external URL
280
            if (Util::isExternalUrl($value)) {
281
                $url = $value;
282
283
                return $url;
284
            }
285
            $value = Util::joinPath($value);
286
        }
287
288
        // value is a ressource URL (ie: 'path/style.css')
289
        if (false !== strpos($value, '.')) {
290
            $url = $value;
291
            if ($addhash) {
292
                $url .= '?'.$hash;
293
            }
294
            $url = $base.'/'.ltrim($url, '/');
295
296
            return $url;
297
        }
298
299
        // others cases
300
        $url = $base.'/';
301
        if (!empty($value) && $value != '/') {
302
            $url = $base.'/'.$value;
303
304
            // value is a page ID (ie: 'path/my-page')
305
            try {
306
                $pageId = $this->slugifyFilter($value);
307
                $page = $this->builder->getPages()->get($pageId);
308
                $url = $this->createUrl($page, $options);
309
            } catch (\DomainException $e) {
310
                // nothing to do
311
            }
312
        }
313
314
        return $url;
315
    }
316
317
    /**
318
     * Minifying a CSS or a JS file.
319
     *
320
     * ie: minify('css/style.css')
321
     *
322
     * @param string $path
323
     *
324
     * @throws Exception
325
     *
326
     * @return string
327
     */
328
    public function minify(string $path): string
329
    {
330
        $filePath = Util::joinFile($this->config->getOutputPath(), $path);
331
        $fileInfo = new \SplFileInfo($filePath);
332
        $fileExtension = $fileInfo->getExtension();
333
        // ie: minify('css/style.min.css')
334
        $pathMinified = \sprintf('%s.min.%s', substr($path, 0, -strlen(".$fileExtension")), $fileExtension);
335
        $filePathMinified = Util::joinFile($this->config->getOutputPath(), $pathMinified);
336
        if (is_file($filePathMinified)) {
337
            return $pathMinified;
338
        }
339
        if (is_file($filePath)) {
340
            switch ($fileExtension) {
341
                case 'css':
342
                    $minifier = new Minify\CSS($filePath);
343
                    break;
344
                case 'js':
345
                    $minifier = new Minify\JS($filePath);
346
                    break;
347
                default:
348
                    throw new Exception(sprintf('%s() error: not able to process "%s"', __FUNCTION__, $path));
349
            }
350
            Util::getFS()->mkdir(dirname($filePathMinified));
351
            $minifier->minify($filePathMinified);
352
353
            return $pathMinified;
354
        }
355
356
        throw new Exception(sprintf('%s() error: "%s" doesn\'t exist', __FUNCTION__, $path));
357
    }
358
359
    /**
360
     * Minifying CSS.
361
     *
362
     * @param string $value
363
     *
364
     * @return string
365
     */
366
    public function minifyCss(string $value): string
367
    {
368
        $minifier = new Minify\CSS($value);
369
370
        return $minifier->minify();
371
    }
372
373
    /**
374
     * Minifying JS.
375
     *
376
     * @param string $value
377
     *
378
     * @return string
379
     */
380
    public function minifyJs(string $value): string
381
    {
382
        $minifier = new Minify\JS($value);
383
384
        return $minifier->minify();
385
    }
386
387
    /**
388
     * Compiles a SCSS file to CSS.
389
     *
390
     * @param string $path
391
     *
392
     * @throws Exception
393
     *
394
     * @return string
395
     */
396
    public function toCss(string $path): string
397
    {
398
        $filePath = Util::joinFile($this->config->getOutputPath(), $path);
399
        $subPath = substr($path, 0, strrpos($path, '/'));
400
401
        if (is_file($filePath)) {
402
            $fileExtension = (new \SplFileInfo($filePath))->getExtension();
403
            switch ($fileExtension) {
404
                case 'scss':
405
                    $scssPhp = new Compiler();
406
                    $scssPhp->setImportPaths(Util::joinFile($this->config->getOutputPath(), $subPath));
407
                    $targetPath = preg_replace('/scss/m', 'css', $path);
408
409
                    // compiles if target file doesn't exists
410
                    if (!Util::getFS()->exists(Util::joinFile($this->config->getOutputPath(), $targetPath))) {
411
                        $scss = Util::fileGetContents($filePath);
412
                        if ($scss === false) {
413
                            throw new \Exception();
414
                        }
415
                        $css = $scssPhp->compile($scss);
416
                        Util::getFS()->dumpFile(Util::joinFile($this->config->getOutputPath(), $targetPath), $css);
417
                    }
418
419
                    return $targetPath;
420
                default:
421
                    throw new Exception(sprintf('%s() error: not able to process "%s"', __FUNCTION__, $path));
422
            }
423
        }
424
425
        throw new Exception(sprintf('%s() error: "%s" doesn\'t exist', __FUNCTION__, $path));
426
    }
427
428
    /**
429
     * Compiles SCSS string to CSS.
430
     *
431
     * @param string $value
432
     *
433
     * @return string
434
     */
435
    public function scssToCss(string $value): string
436
    {
437
        $scss = new Compiler();
438
439
        return $scss->compile($value);
440
    }
441
442
    /**
443
     * Reads $length first characters of a string and adds a suffix.
444
     *
445
     * @param string|null $string
446
     * @param int         $length
447
     * @param string      $suffix
448
     *
449
     * @return string|null
450
     */
451
    public function excerpt(string $string = null, int $length = 450, string $suffix = ' …'): ?string
452
    {
453
        $string = str_replace('</p>', '<br /><br />', $string);
454
        $string = trim(strip_tags($string, '<br>'), '<br />');
455
        if (mb_strlen($string) > $length) {
456
            $string = mb_substr($string, 0, $length);
457
            $string .= $suffix;
458
        }
459
460
        return $string;
461
    }
462
463
    /**
464
     * Reads characters before '<!-- excerpt|break -->'.
465
     *
466
     * @param string|null $string
467
     *
468
     * @return string|null
469
     */
470
    public function excerptHtml(string $string = null): ?string
471
    {
472
        // https://regex101.com/r/Xl7d5I/3
473
        $pattern = '(.*)(<!--[[:blank:]]?(excerpt|break)[[:blank:]]?-->)(.*)';
474
        preg_match('/'.$pattern.'/is', $string, $matches);
475
        if (empty($matches)) {
476
            return $string;
477
        }
478
479
        return trim($matches[1]);
480
    }
481
482
    /**
483
     * Calculates estimated time to read a text.
484
     *
485
     * @param string|null $text
486
     *
487
     * @return string
488
     */
489
    public function readtime(string $text = null): string
490
    {
491
        $words = str_word_count(strip_tags($text));
492
        $min = floor($words / 200);
493
        if ($min === 0) {
494
            return '1';
495
        }
496
497
        return (string) $min;
498
    }
499
500
    /**
501
     * Hashing a file with sha384.
502
     *
503
     * Useful for SRI (Subresource Integrity).
504
     *
505
     * @see https://developer.mozilla.org/fr/docs/Web/Security/Subresource_Integrity
506
     *
507
     * @param string $path
508
     *
509
     * @return string|null
510
     */
511
    public function hashFile(string $path): ?string
512
    {
513
        if (is_file($filePath = Util::joinFile($this->config->getOutputPath(), $path))) {
514
            $path = $filePath;
515
        }
516
517
        return sprintf('sha384-%s', base64_encode(hash_file('sha384', $path, true)));
518
    }
519
520
    /**
521
     * Gets the value of an environment variable.
522
     *
523
     * @param string $var
524
     *
525
     * @return string|null
526
     */
527
    public function getEnv(string $var): ?string
528
    {
529
        return getenv($var) ?: null;
530
    }
531
532
    /**
533
     * Resizes an image.
534
     *
535
     * @param string $path Image path (relative from static/ dir or external).
536
     * @param int    $size Image new size (width).
537
     *
538
     * @return string
539
     */
540
    public function resize(string $path, int $size): string
541
    {
542
        return (new Image($this->builder))->resize($path, $size);
543
    }
544
545
    /**
546
     * Manages assets (css, js and images).
547
     *
548
     * @param string     $path    File path (relative from static/ dir).
549
     * @param array|null $options
550
     *
551
     * @return Asset
552
     */
553
    public function asset(string $path, array $options = null): Asset
554
    {
555
        return new Asset($this->builder, $path, $options);
556
    }
557
}
558