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