Passed
Push — master ( dfd7ad...48777e )
by Arnaud
12:36 queued 06:46
created

Asset::doCompile()   C

Complexity

Conditions 11
Paths 31

Size

Total Lines 71
Code Lines 51

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 38
CRAP Score 13.7424

Importance

Changes 1
Bugs 0 Features 0
Metric Value
cc 11
eloc 51
c 1
b 0
f 0
nc 31
nop 0
dl 0
loc 71
ccs 38
cts 53
cp 0.717
crap 13.7424
rs 6.9224

How to fix   Long Method    Complexity   

Long Method

Small methods make your code easier to understand, in particular if combined with a good name. Besides, if your method is small, finding a good name is usually much easier.

For example, if you find yourself adding comments to a method's body, this is usually a good sign to extract the commented part to a new method, and use the comment as a starting point when coming up with a good name for this new method.

Commonly applied refactorings include:

1
<?php
2
3
declare(strict_types=1);
4
5
/*
6
 * This file is part of Cecil.
7
 *
8
 * Copyright (c) Arnaud Ligny <[email protected]>
9
 *
10
 * For the full copyright and license information, please view the LICENSE
11
 * file that was distributed with this source code.
12
 */
13
14
namespace Cecil\Assets;
15
16
use Cecil\Assets\Image\Optimizer;
17
use Cecil\Builder;
18
use Cecil\Collection\Page\Page;
19
use Cecil\Config;
20
use Cecil\Exception\ConfigException;
21
use Cecil\Exception\RuntimeException;
22
use Cecil\Url;
23
use Cecil\Util;
24
use MatthiasMullie\Minify;
25
use ScssPhp\ScssPhp\Compiler;
26
use ScssPhp\ScssPhp\OutputStyle;
27
use wapmorgan\Mp3Info\Mp3Info;
28
29
class Asset implements \ArrayAccess
30
{
31
    public const IMAGE_THUMB = 'thumbnails';
32
33
    /** @var Builder */
34
    protected $builder;
35
36
    /** @var Config */
37
    protected $config;
38
39
    /** @var array */
40
    protected $data = [];
41
42
    /** @var array Cache tags */
43
    protected $cacheTags = [];
44
45
    /**
46
     * Creates an Asset from a file path, an array of files path or an URL.
47
     * Options:
48
     * [
49
     *     'filename' => <string>,
50
     *     'leading_slash' => <bool>
51
     *     'ignore_missing' => <bool>,
52
     *     'fingerprint' => <bool>,
53
     *     'minify' => <bool>,
54
     *     'optimize' => <bool>,
55
     *     'fallback' => <string>,
56
     *     'useragent' => <string>,
57
     * ]
58
     *
59
     * @param Builder      $builder
60
     * @param string|array $paths
61
     * @param array|null   $options
62
     *
63
     * @throws RuntimeException
64
     */
65 1
    public function __construct(Builder $builder, string|array $paths, array|null $options = null)
66
    {
67 1
        $this->builder = $builder;
68 1
        $this->config = $builder->getConfig();
69 1
        $paths = \is_array($paths) ? $paths : [$paths];
70
        // checks path(s)
71 1
        array_walk($paths, function ($path) {
72
            // must be a string
73 1
            if (!\is_string($path)) {
74
                throw new RuntimeException(\sprintf('The path of an asset must be a string ("%s" given).', \gettype($path)));
75
            }
76
            // can't be empty
77 1
            if (empty($path)) {
78
                throw new RuntimeException('The path of an asset can\'t be empty.');
79
            }
80
            // can't be relative
81 1
            if (substr($path, 0, 2) == '..') {
82
                throw new RuntimeException(\sprintf('The path of asset "%s" is wrong: it must be directly relative to `assets` or `static` directory, or a remote URL.', $path));
83
            }
84 1
        });
85 1
        $this->data = [
86 1
            'file'     => '',    // absolute file path
87 1
            'files'    => [],    // array of absolute files path
88 1
            'missing'  => false, // if file not found but missing allowed: 'missing' is true
89 1
            '_path'    => '',    // original path
90 1
            'path'     => '',    // public path
91 1
            'url'      => null,  // URL if it's a remote file
92 1
            'ext'      => '',    // file extension
93 1
            'type'     => '',    // file type (e.g.: image, audio, video, etc.)
94 1
            'subtype'  => '',    // file media type (e.g.: image/png, audio/mp3, etc.)
95 1
            'size'     => 0,     // file size (in bytes)
96 1
            'width'    => 0,     // image width (in pixels)
97 1
            'height'   => 0,     // image height (in pixels)
98 1
            'exif'     => [],    // image exif data
99 1
            'content'  => '',    // file content
100 1
            'hash'     => '',    // file content hash (md5)
101 1
        ];
102
103
        // handles options
104 1
        $options = array_merge(
105 1
            [
106 1
                'filename'       => '',
107 1
                'leading_slash'  => true,
108 1
                'ignore_missing' => false,
109 1
                'fingerprint'    => $this->config->isEnabled('assets.fingerprint'),
110 1
                'minify'         => $this->config->isEnabled('assets.minify'),
111 1
                'optimize'       => $this->config->isEnabled('assets.images.optimize'),
112 1
                'fallback'       => '',
113 1
                'useragent'      => (string) $this->config->get('assets.remote.useragent.default'),
114 1
            ],
115 1
            \is_array($options) ? $options : []
116 1
        );
117
118
        // cache
119 1
        $cache = new Cache($this->builder, 'assets');
120 1
        $locateCacheKey = \sprintf('%s_locate__%s__%s', $options['filename'] ?: implode('_', $paths), $this->builder->getBuilId(), $this->builder->getVersion());
121
122
        // locate file(s) and get content
123 1
        if (!$cache->has($locateCacheKey)) {
124 1
            $pathsCount = \count($paths);
125 1
            for ($i = 0; $i < $pathsCount; $i++) {
126
                try {
127 1
                    $this->data['missing'] = false;
128 1
                    $locate = $this->locateFile($paths[$i], $options['fallback'], $options['useragent']);
129 1
                    $file = $locate['file'];
130 1
                    $path = $locate['path'];
131 1
                    $type = Util\File::getMediaType($file)[0];
132 1
                    if ($i > 0) { // bundle
133 1
                        if ($type != $this->data['type']) {
134
                            throw new RuntimeException(\sprintf('Asset bundle type error (%s != %s).', $type, $this->data['type']));
135
                        }
136
                    }
137 1
                    $this->data['file'] = $file;
138 1
                    $this->data['files'][] = $file;
139 1
                    $this->data['path'] = $path;
140 1
                    $this->data['url'] = Util\File::isRemote($paths[$i]) ? $paths[$i] : null;
141 1
                    $this->data['ext'] = Util\File::getExtension($file);
142 1
                    $this->data['type'] = $type;
143 1
                    $this->data['subtype'] = Util\File::getMediaType($file)[1];
144 1
                    $this->data['size'] += filesize($file);
145 1
                    $this->data['content'] .= Util\File::fileGetContents($file);
146 1
                    $this->data['hash'] = hash('md5', $this->data['content']);
147
                    // bundle default filename
148 1
                    $filename = $options['filename'];
149 1
                    if ($pathsCount > 1 && empty($filename)) {
150 1
                        switch ($this->data['ext']) {
151 1
                            case 'scss':
152 1
                            case 'css':
153 1
                                $filename = 'styles.css';
154 1
                                break;
155 1
                            case 'js':
156 1
                                $filename = 'scripts.js';
157 1
                                break;
158
                            default:
159
                                throw new RuntimeException(\sprintf('Asset bundle supports %s files only.', '.scss, .css and .js'));
160
                        }
161
                    }
162
                    // apply bundle filename to path
163 1
                    if (!empty($filename)) {
164 1
                        $this->data['path'] = $filename;
165
                    }
166
                    // add leading slash
167 1
                    if ($options['leading_slash']) {
168 1
                        $this->data['path'] = '/' . ltrim($this->data['path'], '/');
169
                    }
170 1
                    $this->data['_path'] = $this->data['path'];
171 1
                } catch (RuntimeException $e) {
172 1
                    if ($options['ignore_missing']) {
173 1
                        $this->data['missing'] = true;
174 1
                        continue;
175
                    }
176
                    throw new RuntimeException(
177
                        \sprintf('Can\'t handle asset "%s".', $paths[$i]),
178
                        previous: $e
179
                    );
180
                }
181
            }
182 1
            $cache->set($locateCacheKey, $this->data);
183
        }
184 1
        $this->data = $cache->get($locateCacheKey);
185
186
        // missing
187 1
        if ($this->data['missing']) {
188 1
            return;
189
        }
190
191
        // cache
192 1
        $cache = new Cache($this->builder, 'assets');
193
        // create cache tags from options
194 1
        $this->cacheTags = $options;
195
        // remove some cache tags
196 1
        unset($this->cacheTags['optimize'], $this->cacheTags['ignore_missing'], $this->cacheTags['fallback'], $this->cacheTags['useragent']);
197
        // optimize images?
198 1
        $optimize = false;
199 1
        if ($options['optimize'] && $this->data['type'] == 'image' && !$this->isImageInCdn()) {
200 1
            $optimize = true;
201 1
            $quality = (int) $this->config->get('assets.images.quality');
202 1
            $this->cacheTags['quality'] = $quality;
203
        }
204 1
        $cacheKey = $cache->createKeyFromAsset($this, $this->cacheTags);
205 1
        if (!$cache->has($cacheKey)) {
206
            // image: width, height and exif
207 1
            if ($this->data['type'] == 'image') {
208 1
                $this->data['width'] = $this->getWidth();
209 1
                $this->data['height'] = $this->getHeight();
210 1
                if ($this->data['subtype'] == 'image/jpeg') {
211 1
                    $this->data['exif'] = Util\File::readExif($this->data['file']);
212
                }
213
            }
214
            // fingerprinting
215 1
            if ($options['fingerprint']) {
216
                $this->doFingerprint();
217
            }
218
            // compiling Sass files
219 1
            $this->doCompile();
220
            // minifying (CSS and JavScript files)
221 1
            if ($options['minify']) {
222
                $this->doMinify();
223
            }
224 1
            $cache->set($cacheKey, $this->data, $this->config->get('cache.assets.ttl'));
225 1
            $this->builder->getLogger()->debug(\sprintf('Asset cached: "%s"', $this->data['path']));
226
            // optimizing images files (in cache directory)
227 1
            if ($optimize) {
228 1
                $this->optimize($cache->getContentFilePathname($this->data['path']), $this->data['path'], $quality);
229
            }
230
        }
231 1
        $this->data = $cache->get($cacheKey);
232
    }
233
234
    /**
235
     * Returns path.
236
     */
237 1
    public function __toString(): string
238
    {
239 1
        $this->save();
240
241 1
        if ($this->isImageInCdn()) {
242
            return $this->buildImageCdnUrl();
243
        }
244
245 1
        if ($this->builder->getConfig()->isEnabled('canonicalurl')) {
246
            return (string) new Url($this->builder, $this->data['path'], ['canonical' => true]);
247
        }
248
249 1
        return $this->data['path'];
250
    }
251
252
    /**
253
     * Compiles a SCSS + cache.
254
     *
255
     * @throws RuntimeException
256
     */
257 1
    public function compile(): self
258
    {
259 1
        $this->cacheTags['compile'] = true;
260 1
        $cache = new Cache($this->builder, 'assets');
261 1
        $cacheKey = $cache->createKeyFromAsset($this, $this->cacheTags);
262 1
        if (!$cache->has($cacheKey)) {
263 1
            $this->doCompile();
264 1
            $cache->set($cacheKey, $this->data, $this->config->get('cache.assets.ttl'));
265
        }
266 1
        $this->data = $cache->get($cacheKey);
267
268 1
        return $this;
269
    }
270
271
    /**
272
     * Minifying a CSS or a JS.
273
     */
274 1
    public function minify(): self
275
    {
276 1
        $this->cacheTags['minify'] = true;
277 1
        $cache = new Cache($this->builder, 'assets');
278 1
        $cacheKey = $cache->createKeyFromAsset($this, $this->cacheTags);
279 1
        if (!$cache->has($cacheKey)) {
280 1
            $this->doMinify();
281 1
            $cache->set($cacheKey, $this->data, $this->config->get('cache.assets.ttl'));
282
        }
283 1
        $this->data = $cache->get($cacheKey);
284
285 1
        return $this;
286
    }
287
288
    /**
289
     * Add hash to the file name + cache.
290
     */
291 1
    public function fingerprint(): self
292
    {
293 1
        $this->cacheTags['fingerprint'] = true;
294 1
        $cache = new Cache($this->builder, 'assets');
295 1
        $cacheKey = $cache->createKeyFromAsset($this, $this->cacheTags);
296 1
        if (!$cache->has($cacheKey)) {
297 1
            $this->doFingerprint();
298 1
            $cache->set($cacheKey, $this->data, $this->config->get('cache.assets.ttl'));
299
        }
300 1
        $this->data = $cache->get($cacheKey);
301
302 1
        return $this;
303
    }
304
305
    /**
306
     * Scales down an image to a new $width.
307
     *
308
     * @throws RuntimeException
309
     */
310 1
    public function resize(int $width): self
311
    {
312 1
        $this->checkImage();
313
314
        // if the image is already smaller than the requested width, return it
315 1
        if ($width >= $this->data['width']) {
316 1
            return $this;
317
        }
318
319 1
        $assetResized = clone $this;
320 1
        $assetResized->data['width'] = $width;
321
322 1
        if ($this->isImageInCdn()) {
323
            $assetResized->data['height'] = round($this->data['height'] / ($this->data['width'] / $width));
324
325
            return $assetResized; // returns asset with the new dimensions only: CDN do the rest of the job
326
        }
327
328 1
        $quality = (int) $this->config->get('assets.images.quality');
329
330 1
        $cache = new Cache($this->builder, 'assets');
331 1
        $this->cacheTags['quality'] = $quality;
332 1
        $this->cacheTags['width'] = $width;
333 1
        $cacheKey = $cache->createKeyFromAsset($assetResized, $this->cacheTags);
334 1
        if (!$cache->has($cacheKey)) {
335 1
            $assetResized->data['content'] = Image::resize($assetResized, $width, $quality);
336 1
            $assetResized->data['path'] = '/' . Util::joinPath(
337 1
                (string) $this->config->get('assets.target'),
338 1
                self::IMAGE_THUMB,
339 1
                (string) $width,
340 1
                $assetResized->data['path']
341 1
            );
342 1
            $assetResized->data['path'] = $this->deduplicateThumbPath($assetResized->data['path']);
343 1
            $assetResized->data['height'] = $assetResized->getHeight();
344 1
            $assetResized->data['size'] = \strlen($assetResized->data['content']);
345
346 1
            $cache->set($cacheKey, $assetResized->data, $this->config->get('cache.assets.ttl'));
347 1
            $this->builder->getLogger()->debug(\sprintf('Asset resized: "%s" (%sx)', $assetResized->data['path'], $width));
348
        }
349 1
        $assetResized->data = $cache->get($cacheKey);
350
351 1
        return $assetResized;
352
    }
353
354
    /**
355
     * Crops the image to the specified width and height, keeping the specified position.
356
     *
357
     * @throws RuntimeException
358
     */
359 1
    public function cover(int $width, int $height, string $position = 'center'): self
360
    {
361 1
        $this->checkImage();
362
363 1
        $assetResized = clone $this;
364 1
        $assetResized->data['width'] = $width;
365 1
        $assetResized->data['height'] = $height;
366
367 1
        $quality = (int) $this->config->get('assets.images.quality');
368
369 1
        $cache = new Cache($this->builder, 'assets');
370 1
        $this->cacheTags['quality'] = $quality;
371 1
        $this->cacheTags['width'] = $width;
372 1
        $this->cacheTags['height'] = $height;
373 1
        $cacheKey = $cache->createKeyFromAsset($assetResized, $this->cacheTags);
374 1
        if (!$cache->has($cacheKey)) {
375 1
            $assetResized->data['content'] = Image::cover($assetResized, $width, $height, $position, $quality);
376 1
            $assetResized->data['path'] = '/' . Util::joinPath(
377 1
                (string) $this->config->get('assets.target'),
378 1
                self::IMAGE_THUMB,
379 1
                (string) $width . 'x' . (string) $height,
380 1
                $assetResized->data['path']
381 1
            );
382 1
            $assetResized->data['path'] = $this->deduplicateThumbPath($assetResized->data['path']);
383 1
            $assetResized->data['size'] = \strlen($assetResized->data['content']);
384
385 1
            $cache->set($cacheKey, $assetResized->data, $this->config->get('cache.assets.ttl'));
386 1
            $this->builder->getLogger()->debug(\sprintf('Asset resized: "%s" (%sx%s)', $assetResized->data['path'], $width, $height));
387
        }
388 1
        $assetResized->data = $cache->get($cacheKey);
389
390 1
        return $assetResized;
391
    }
392
393
    /**
394
     * Converts an image asset to $format format.
395
     *
396
     * @throws RuntimeException
397
     */
398 1
    public function convert(string $format, ?int $quality = null): self
399
    {
400 1
        if ($this->data['type'] != 'image') {
401
            throw new RuntimeException(\sprintf('Not able to convert "%s" (%s) to %s: not an image.', $this->data['path'], $this->data['type'], $format));
402
        }
403
404 1
        if ($quality === null) {
405 1
            $quality = (int) $this->config->get('assets.images.quality');
406
        }
407
408 1
        $asset = clone $this;
409 1
        $asset['ext'] = $format;
410 1
        $asset->data['subtype'] = "image/$format";
411
412 1
        if ($this->isImageInCdn()) {
413
            return $asset; // returns the asset with the new extension only: CDN do the rest of the job
414
        }
415
416 1
        $cache = new Cache($this->builder, 'assets');
417 1
        $this->cacheTags['quality'] = $quality;
418 1
        if ($this->data['width']) {
419 1
            $this->cacheTags['width'] = $this->data['width'];
420
        }
421 1
        $cacheKey = $cache->createKeyFromAsset($asset, $this->cacheTags);
422 1
        if (!$cache->has($cacheKey)) {
423 1
            $asset->data['content'] = Image::convert($asset, $format, $quality);
424
            $asset->data['path'] = preg_replace('/\.' . $this->data['ext'] . '$/m', ".$format", $this->data['path']);
425
            $asset->data['size'] = \strlen($asset->data['content']);
426
            $cache->set($cacheKey, $asset->data, $this->config->get('cache.assets.ttl'));
427
            $this->builder->getLogger()->debug(\sprintf('Asset converted: "%s" (%s -> %s)', $asset->data['path'], $this->data['ext'], $format));
428
        }
429
        $asset->data = $cache->get($cacheKey);
430
431
        return $asset;
432
    }
433
434
    /**
435
     * Converts an image asset to WebP format.
436
     *
437
     * @throws RuntimeException
438
     */
439
    public function webp(?int $quality = null): self
440
    {
441
        return $this->convert('webp', $quality);
442
    }
443
444
    /**
445
     * Converts an image asset to AVIF format.
446
     *
447
     * @throws RuntimeException
448
     */
449
    public function avif(?int $quality = null): self
450
    {
451
        return $this->convert('avif', $quality);
452
    }
453
454
    /**
455
     * Implements \ArrayAccess.
456
     */
457 1
    #[\ReturnTypeWillChange]
458
    public function offsetSet($offset, $value): void
459
    {
460 1
        if (!\is_null($offset)) {
461 1
            $this->data[$offset] = $value;
462
        }
463
    }
464
465
    /**
466
     * Implements \ArrayAccess.
467
     */
468 1
    #[\ReturnTypeWillChange]
469
    public function offsetExists($offset): bool
470
    {
471 1
        return isset($this->data[$offset]);
472
    }
473
474
    /**
475
     * Implements \ArrayAccess.
476
     */
477
    #[\ReturnTypeWillChange]
478
    public function offsetUnset($offset): void
479
    {
480
        unset($this->data[$offset]);
481
    }
482
483
    /**
484
     * Implements \ArrayAccess.
485
     */
486 1
    #[\ReturnTypeWillChange]
487
    public function offsetGet($offset)
488
    {
489 1
        return isset($this->data[$offset]) ? $this->data[$offset] : null;
490
    }
491
492
    /**
493
     * Hashing content of an asset with the specified algo, sha384 by default.
494
     * Used for SRI (Subresource Integrity).
495
     *
496
     * @see https://developer.mozilla.org/fr/docs/Web/Security/Subresource_Integrity
497
     */
498 1
    public function getIntegrity(string $algo = 'sha384'): string
499
    {
500 1
        return \sprintf('%s-%s', $algo, base64_encode(hash($algo, $this->data['content'], true)));
501
    }
502
503
    /**
504
     * Returns MP3 file infos.
505
     *
506
     * @see https://github.com/wapmorgan/Mp3Info
507
     */
508 1
    public function getAudio(): Mp3Info
509
    {
510 1
        if ($this->data['type'] !== 'audio') {
511
            throw new RuntimeException(\sprintf('Not able to get audio infos of "%s".', $this->data['path']));
512
        }
513
514 1
        return new Mp3Info($this->data['file']);
515
    }
516
517
    /**
518
     * Returns MP4 file infos.
519
     *
520
     * @see https://github.com/clwu88/php-read-mp4info
521
     */
522 1
    public function getVideo(): array
523
    {
524 1
        if ($this->data['type'] !== 'video') {
525
            throw new RuntimeException(\sprintf('Not able to get video infos of "%s".', $this->data['path']));
526
        }
527
528 1
        return (array) \Clwu\Mp4::getInfo($this->data['file']);
529
    }
530
531
    /**
532
     * Returns the Data URL (encoded in Base64).
533
     *
534
     * @throws RuntimeException
535
     */
536 1
    public function dataurl(): string
537
    {
538 1
        if ($this->data['type'] == 'image' && !Image::isSVG($this)) {
539 1
            return Image::getDataUrl($this, (int) $this->config->get('assets.images.quality'));
540
        }
541
542 1
        return \sprintf('data:%s;base64,%s', $this->data['subtype'], base64_encode($this->data['content']));
543
    }
544
545
    /**
546
     * Adds asset path to the list of assets to save.
547
     *
548
     * @throws RuntimeException
549
     */
550 1
    public function save(): void
551
    {
552 1
        if ($this->data['missing']) {
553 1
            return;
554
        }
555
556 1
        $cache = new Cache($this->builder, 'assets');
557 1
        if (empty($this->data['path']) || !Util\File::getFS()->exists($cache->getContentFilePathname($this->data['path']))) {
558
            throw new RuntimeException(
559
                \sprintf('Can\'t add "%s" to assets list. Please clear cache and retry.', $this->data['path'])
560
            );
561
        }
562
563 1
        $this->builder->addAsset($this->data['path']);
564
    }
565
566
    /**
567
     * Is the asset an image and is it in CDN?
568
     */
569 1
    public function isImageInCdn(): bool
570
    {
571
        if (
572 1
            $this->data['type'] == 'image'
573 1
            && $this->config->isEnabled('assets.images.cdn')
574 1
            && $this->data['ext'] != 'ico'
575 1
            && (Image::isSVG($this) && $this->config->isEnabled('assets.images.cdn.svg'))
576
        ) {
577
            return true;
578
        }
579
        // handle remote image?
580 1
        if ($this->data['url'] !== null && $this->config->isEnabled('assets.images.cdn.remote')) {
581
            return true;
582
        }
583
584 1
        return false;
585
    }
586
587
    /**
588
     * Builds a relative path from a URL.
589
     * Used for remote files.
590
     */
591 1
    public static function buildPathFromUrl(string $url): string
592
    {
593 1
        $host = parse_url($url, PHP_URL_HOST);
594 1
        $path = parse_url($url, PHP_URL_PATH);
595 1
        $query = parse_url($url, PHP_URL_QUERY);
596 1
        $ext = pathinfo(parse_url($url, PHP_URL_PATH), PATHINFO_EXTENSION);
597
598
        // Google Fonts hack
599 1
        if (Util\Str::endsWith($path, '/css') || Util\Str::endsWith($path, '/css2')) {
600 1
            $ext = 'css';
601
        }
602
603 1
        return Page::slugify(\sprintf('%s%s%s%s', $host, self::sanitize($path), $query ? "-$query" : '', $query && $ext ? ".$ext" : ''));
604
    }
605
606
    /**
607
     * Replaces some characters by '_'.
608
     */
609 1
    public static function sanitize(string $string): string
610
    {
611 1
        return str_replace(['<', '>', ':', '"', '\\', '|', '?', '*'], '_', $string);
612
    }
613
614
    /**
615
     * Compiles a SCSS.
616
     *
617
     * @throws RuntimeException
618
     */
619 1
    protected function doCompile(): self
620
    {
621
        // abort if not a SCSS file
622 1
        if ($this->data['ext'] != 'scss') {
623 1
            return $this;
624
        }
625 1
        $scssPhp = new Compiler();
626
        // import paths
627 1
        $importDir = [];
628 1
        $importDir[] = Util::joinPath($this->config->getStaticPath());
629 1
        $importDir[] = Util::joinPath($this->config->getAssetsPath());
630 1
        $scssDir = (array) $this->config->get('assets.compile.import');
631 1
        $themes = $this->config->getTheme() ?? [];
632 1
        foreach ($scssDir as $dir) {
633 1
            $importDir[] = Util::joinPath($this->config->getStaticPath(), $dir);
634 1
            $importDir[] = Util::joinPath($this->config->getAssetsPath(), $dir);
635 1
            $importDir[] = Util::joinPath(\dirname($this->data['file']), $dir);
636 1
            foreach ($themes as $theme) {
637 1
                $importDir[] = Util::joinPath($this->config->getThemeDirPath($theme, "static/$dir"));
638 1
                $importDir[] = Util::joinPath($this->config->getThemeDirPath($theme, "assets/$dir"));
639
            }
640
        }
641 1
        $scssPhp->setQuietDeps(true);
642 1
        $scssPhp->setImportPaths(array_unique($importDir));
643
        // adds source map
644 1
        if ($this->builder->isDebug() && $this->config->isEnabled('assets.compile.sourcemap')) {
645
            $importDir = [];
646
            $assetDir = (string) $this->config->get('assets.dir');
647
            $assetDirPos = strrpos($this->data['file'], DIRECTORY_SEPARATOR . $assetDir . DIRECTORY_SEPARATOR);
648
            $fileRelPath = substr($this->data['file'], $assetDirPos + 8);
649
            $filePath = Util::joinFile($this->config->getOutputPath(), $fileRelPath);
650
            $importDir[] = \dirname($filePath);
651
            foreach ($scssDir as $dir) {
652
                $importDir[] = Util::joinFile($this->config->getOutputPath(), $dir);
653
            }
654
            $scssPhp->setImportPaths(array_unique($importDir));
655
            $scssPhp->setSourceMap(Compiler::SOURCE_MAP_INLINE);
656
            $scssPhp->setSourceMapOptions([
657
                'sourceMapBasepath' => Util::joinPath($this->config->getOutputPath()),
658
                'sourceRoot'        => '/',
659
            ]);
660
        }
661
        // defines output style
662 1
        $outputStyles = ['expanded', 'compressed'];
663 1
        $outputStyle = strtolower((string) $this->config->get('assets.compile.style'));
664 1
        if (!\in_array($outputStyle, $outputStyles)) {
665
            throw new ConfigException(\sprintf('"%s" value must be "%s".', 'assets.compile.style', implode('" or "', $outputStyles)));
666
        }
667 1
        $scssPhp->setOutputStyle($outputStyle == 'compressed' ? OutputStyle::COMPRESSED : OutputStyle::EXPANDED);
668
        // set variables
669 1
        $variables = $this->config->get('assets.compile.variables');
670 1
        if (!empty($variables)) {
671 1
            $variables = array_map('ScssPhp\ScssPhp\ValueConverter::parseValue', $variables);
672 1
            $scssPhp->replaceVariables($variables);
673
        }
674
        // debug
675 1
        if ($this->builder->isDebug()) {
676 1
            $scssPhp->setQuietDeps(false);
677 1
            $this->builder->getLogger()->debug(\sprintf("SCSS compiler imported paths:\n%s", Util\Str::arrayToList(array_unique($importDir))));
678
        }
679
        // update data
680 1
        $this->data['path'] = preg_replace('/sass|scss/m', 'css', $this->data['path']);
681 1
        $this->data['ext'] = 'css';
682 1
        $this->data['type'] = 'text';
683 1
        $this->data['subtype'] = 'text/css';
684 1
        $this->data['content'] = $scssPhp->compileString($this->data['content'])->getCss();
685 1
        $this->data['size'] = \strlen($this->data['content']);
686
687 1
        $this->builder->getLogger()->debug(\sprintf('Asset compiled: "%s"', $this->data['path']));
688
689 1
        return $this;
690
    }
691
692
    /**
693
     * Minifying a CSS or a JS + cache.
694
     *
695
     * @throws RuntimeException
696
     */
697 1
    protected function doMinify(): self
698
    {
699
        // in debug mode: disable minify to preserve inline source map
700 1
        if ($this->builder->isDebug() && $this->config->isEnabled('assets.compile.sourcemap')) {
701
            return $this;
702
        }
703
        // abord if not a CSS or JS file
704 1
        if ($this->data['ext'] != 'css' && $this->data['ext'] != 'js') {
705
            return $this;
706
        }
707
        // abort if already minified
708 1
        if (substr($this->data['path'], -8) == '.min.css' || substr($this->data['path'], -7) == '.min.js') {
709
            return $this;
710
        }
711
        // compile SCSS files
712 1
        if ($this->data['ext'] == 'scss') {
713
            $this->compile();
714
        }
715 1
        switch ($this->data['ext']) {
716 1
            case 'css':
717 1
                $minifier = new Minify\CSS($this->data['content']);
718 1
                break;
719 1
            case 'js':
720 1
                $minifier = new Minify\JS($this->data['content']);
721 1
                break;
722
            default:
723
                throw new RuntimeException(\sprintf('Not able to minify "%s".', $this->data['path']));
724
        }
725 1
        $this->data['content'] = $minifier->minify();
726 1
        $this->data['size'] = \strlen($this->data['content']);
727
728 1
        $this->builder->getLogger()->debug(\sprintf('Asset minified: "%s"', $this->data['path']));
729
730 1
        return $this;
731
    }
732
733
    /**
734
     * Add hash to the file name.
735
     */
736 1
    protected function doFingerprint(): self
737
    {
738 1
        $hash = hash('md5', $this->data['content']);
739 1
        $this->data['path'] = preg_replace(
740 1
            '/\.' . $this->data['ext'] . '$/m',
741 1
            ".$hash." . $this->data['ext'],
742 1
            $this->data['path']
743 1
        );
744 1
        $this->builder->getLogger()->debug(\sprintf('Asset fingerprinted: "%s"', $this->data['path']));
745
746 1
        return $this;
747
    }
748
749
    /**
750
     * Returns local file path and updated path, or throw an exception.
751
     * If $fallback path is set, it will be used if the remote file is not found.
752
     *
753
     * Try to locate the file in:
754
     *   (1. remote file)
755
     *   1. assets
756
     *   2. themes/<theme>/assets
757
     *   3. static
758
     *   4. themes/<theme>/static
759
     *
760
     * @throws RuntimeException
761
     */
762 1
    private function locateFile(string $path, ?string $fallback = null, ?string $userAgent = null): array
763
    {
764
        // remote file
765 1
        if (Util\File::isRemote($path)) {
766
            try {
767 1
                $content = $this->getRemoteFileContent($path, $userAgent);
768 1
                $path = self::buildPathFromUrl($path);
769 1
                $cache = new Cache($this->builder, 'assets/remote');
770 1
                if (!$cache->has($path)) {
771 1
                    $cache->set($path, [
772 1
                        'content' => $content,
773 1
                        'path'    => $path,
774 1
                    ], $this->config->get('cache.assets.remote.ttl'));
775
                }
776 1
                return [
777 1
                    'file' => $cache->getContentFilePathname($path),
778 1
                    'path' => $path,
779 1
                ];
780 1
            } catch (RuntimeException $e) {
781 1
                if (empty($fallback)) {
782
                    throw new RuntimeException($e->getMessage());
783
                }
784 1
                $path = $fallback;
785
            }
786
        }
787
788
        // checks in assets/
789 1
        $file = Util::joinFile($this->config->getAssetsPath(), $path);
790 1
        if (Util\File::getFS()->exists($file)) {
791 1
            return [
792 1
                'file' => $file,
793 1
                'path' => $path,
794 1
            ];
795
        }
796
797
        // checks in each themes/<theme>/assets/
798 1
        foreach ($this->config->getTheme() ?? [] as $theme) {
799 1
            $file = Util::joinFile($this->config->getThemeDirPath($theme, 'assets'), $path);
800 1
            if (Util\File::getFS()->exists($file)) {
801 1
                return [
802 1
                    'file' => $file,
803 1
                    'path' => $path,
804 1
                ];
805
            }
806
        }
807
808
        // checks in static/
809 1
        $file = Util::joinFile($this->config->getStaticPath(), $path);
810 1
        if (Util\File::getFS()->exists($file)) {
811 1
            return [
812 1
                'file' => $file,
813 1
                'path' => $path,
814 1
            ];
815
        }
816
817
        // checks in each themes/<theme>/static/
818 1
        foreach ($this->config->getTheme() ?? [] as $theme) {
819 1
            $file = Util::joinFile($this->config->getThemeDirPath($theme, 'static'), $path);
820 1
            if (Util\File::getFS()->exists($file)) {
821 1
                return [
822 1
                    'file' => $file,
823 1
                    'path' => $path,
824 1
                ];
825
            }
826
        }
827
828 1
        throw new RuntimeException(\sprintf('Can\'t locate file "%s".', $path));
829
    }
830
831
    /**
832
     * Try to get remote file content.
833
     * Returns file content or throw an exception.
834
     *
835
     * @throws RuntimeException
836
     */
837 1
    private function getRemoteFileContent(string $path, ?string $userAgent = null): string
838
    {
839 1
        if (!Util\File::isRemoteExists($path)) {
840 1
            throw new RuntimeException(\sprintf('Can\'t get remote file "%s".', $path));
841
        }
842 1
        if (false === $content = Util\File::fileGetContents($path, $userAgent)) {
843
            throw new RuntimeException(\sprintf('Can\'t get content of remote file "%s".', $path));
844
        }
845 1
        if (\strlen($content) <= 1) {
846
            throw new RuntimeException(\sprintf('Remote file "%s" is empty.', $path));
847
        }
848
849 1
        return $content;
850
    }
851
852
    /**
853
     * Optimizing $filepath image.
854
     * Returns the new file size.
855
     */
856 1
    private function optimize(string $filepath, string $path, int $quality): int
857
    {
858 1
        $message = \sprintf('Asset not optimized: "%s"', $path);
859 1
        $sizeBefore = filesize($filepath);
860 1
        Optimizer::create($quality)->optimize($filepath);
861 1
        $sizeAfter = filesize($filepath);
862 1
        if ($sizeAfter < $sizeBefore) {
863
            $message = \sprintf(
864
                'Asset optimized: "%s" (%s Ko -> %s Ko)',
865
                $path,
866
                ceil($sizeBefore / 1000),
867
                ceil($sizeAfter / 1000)
868
            );
869
        }
870 1
        $this->builder->getLogger()->debug($message);
871
872 1
        return $sizeAfter;
873
    }
874
875
    /**
876
     * Returns the width of an image/SVG.
877
     *
878
     * @throws RuntimeException
879
     */
880 1
    private function getWidth(): int
881
    {
882 1
        if ($this->data['type'] != 'image') {
883
            return 0;
884
        }
885 1
        if (Image::isSVG($this) && false !== $svg = Image::getSvgAttributes($this)) {
886 1
            return (int) $svg->width;
887
        }
888 1
        if (false === $size = $this->getImageSize()) {
889
            throw new RuntimeException(\sprintf('Not able to get width of "%s".', $this->data['path']));
890
        }
891
892 1
        return $size[0];
893
    }
894
895
    /**
896
     * Returns the height of an image/SVG.
897
     *
898
     * @throws RuntimeException
899
     */
900 1
    private function getHeight(): int
901
    {
902 1
        if ($this->data['type'] != 'image') {
903
            return 0;
904
        }
905 1
        if (Image::isSVG($this) && false !== $svg = Image::getSvgAttributes($this)) {
906 1
            return (int) $svg->height;
907
        }
908 1
        if (false === $size = $this->getImageSize()) {
909
            throw new RuntimeException(\sprintf('Not able to get height of "%s".', $this->data['path']));
910
        }
911
912 1
        return $size[1];
913
    }
914
915
    /**
916
     * Returns image size informations.
917
     *
918
     * @see https://www.php.net/manual/function.getimagesize.php
919
     *
920
     * @return array|false
921
     */
922 1
    private function getImageSize()
923
    {
924 1
        if (!$this->data['type'] == 'image') {
925
            return false;
926
        }
927
928
        try {
929 1
            if (false === $size = getimagesizefromstring($this->data['content'])) {
930 1
                return false;
931
            }
932
        } catch (\Exception $e) {
933
            throw new RuntimeException(\sprintf('Handling asset "%s" failed: "%s"', $this->data['path'], $e->getMessage()));
934
        }
935
936 1
        return $size;
937
    }
938
939
    /**
940
     * Builds CDN image URL.
941
     */
942
    private function buildImageCdnUrl(): string
943
    {
944
        return str_replace(
945
            [
946
                '%account%',
947
                '%image_url%',
948
                '%width%',
949
                '%quality%',
950
                '%format%',
951
            ],
952
            [
953
                $this->config->get('assets.images.cdn.account') ?? '',
954
                ltrim($this->data['url'] ?? (string) new Url($this->builder, $this->data['path'], ['canonical' => $this->config->get('assets.images.cdn.canonical') ?? true]), '/'),
955
                $this->data['width'],
956
                (int) $this->config->get('assets.images.quality'),
957
                $this->data['ext'],
958
            ],
959
            (string) $this->config->get('assets.images.cdn.url')
960
        );
961
    }
962
963
    /**
964
     * Checks if the asset is not missing and is typed as an image.
965
     *
966
     * @throws RuntimeException
967
     */
968 1
    private function checkImage(): void
969
    {
970 1
        if ($this->data['missing']) {
971
            throw new RuntimeException(\sprintf('Not able to resize "%s": file not found.', $this->data['path']));
972
        }
973 1
        if ($this->data['type'] != 'image') {
974
            throw new RuntimeException(\sprintf('Not able to resize "%s": not an image.', $this->data['path']));
975
        }
976
    }
977
978
    /**
979
     * Remove redondant '/thumbnails/<width>/' in the path.
980
     */
981 1
    private function deduplicateThumbPath(string $path): string
982
    {
983
        // https://regex101.com/r/rDRWnL/1
984 1
        $pattern = '/(' . self::IMAGE_THUMB . '\/\d+(x\d+){0,1})\/' . self::IMAGE_THUMB . '\/\d+\/(.*)/i';
985
986 1
        return preg_replace($pattern, '$1/$3', $path);
987
    }
988
}
989