Passed
Pull Request — master (#2180)
by Arnaud
04:49
created

Asset::doMinify()   B

Complexity

Conditions 10
Paths 9

Size

Total Lines 34
Code Lines 21

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 16
CRAP Score 11.3498

Importance

Changes 1
Bugs 0 Features 0
Metric Value
cc 10
eloc 21
c 1
b 0
f 0
nc 9
nop 0
dl 0
loc 34
ccs 16
cts 21
cp 0.7619
crap 11.3498
rs 7.6666

How to fix   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);
1 ignored issue
show
Comprehensibility Best Practice introduced by
The variable $quality does not seem to be defined for all execution paths leading up to this point.
Loading history...
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
                $this->deduplicateThumbPath($assetResized->data['path'])
339 1
            );
340 1
            $assetResized->data['height'] = $assetResized->getHeight();
341 1
            $assetResized->data['size'] = \strlen($assetResized->data['content']);
342
343 1
            $cache->set($cacheKey, $assetResized->data, $this->config->get('cache.assets.ttl'));
344 1
            $this->builder->getLogger()->debug(\sprintf('Asset resized: "%s" (%sx)', $assetResized->data['path'], $width));
345
        }
346 1
        $assetResized->data = $cache->get($cacheKey);
347
348 1
        return $assetResized;
349
    }
350
351
    /**
352
     * Crops the image to the specified width and height, keeping the specified position.
353
     *
354
     * @throws RuntimeException
355
     */
356 1
    public function cover(int $width, int $height, string $position = 'center'): self
357
    {
358 1
        $this->checkImage();
359
360 1
        $assetResized = clone $this;
361 1
        $assetResized->data['width'] = $width;
362 1
        $assetResized->data['height'] = $height;
363
364 1
        $quality = (int) $this->config->get('assets.images.quality');
365
366 1
        $cache = new Cache($this->builder, 'assets');
367 1
        $this->cacheTags['quality'] = $quality;
368 1
        $this->cacheTags['width'] = $width;
369 1
        $this->cacheTags['height'] = $height;
370 1
        $cacheKey = $cache->createKeyFromAsset($assetResized, $this->cacheTags);
371 1
        if (!$cache->has($cacheKey)) {
372 1
            $assetResized->data['content'] = Image::cover($assetResized, $width, $height, $position, $quality);
373 1
            $assetResized->data['path'] = '/' . Util::joinPath(
374 1
                (string) $this->config->get('assets.target'),
375 1
                self::IMAGE_THUMB,
376 1
                (string) $width . 'x' . (string) $height,
377 1
                $this->deduplicateThumbPath($assetResized->data['path'])
378 1
            );
379 1
            $assetResized->data['size'] = \strlen($assetResized->data['content']);
380
381 1
            $cache->set($cacheKey, $assetResized->data, $this->config->get('cache.assets.ttl'));
382 1
            $this->builder->getLogger()->debug(\sprintf('Asset resized: "%s" (%sx%s)', $assetResized->data['path'], $width, $height));
383
        }
384 1
        $assetResized->data = $cache->get($cacheKey);
385
386 1
        return $assetResized;
387
    }
388
389
    /**
390
     * Converts an image asset to $format format.
391
     *
392
     * @throws RuntimeException
393
     */
394 1
    public function convert(string $format, ?int $quality = null): self
395
    {
396 1
        if ($this->data['type'] != 'image') {
397
            throw new RuntimeException(\sprintf('Not able to convert "%s" (%s) to %s: not an image.', $this->data['path'], $this->data['type'], $format));
398
        }
399
400 1
        if ($quality === null) {
401 1
            $quality = (int) $this->config->get('assets.images.quality');
402
        }
403
404 1
        $asset = clone $this;
405 1
        $asset['ext'] = $format;
406 1
        $asset->data['subtype'] = "image/$format";
407
408 1
        if ($this->isImageInCdn()) {
409
            return $asset; // returns the asset with the new extension only: CDN do the rest of the job
410
        }
411
412 1
        $cache = new Cache($this->builder, 'assets');
413 1
        $this->cacheTags['quality'] = $quality;
414 1
        if ($this->data['width']) {
415 1
            $this->cacheTags['width'] = $this->data['width'];
416
        }
417 1
        $cacheKey = $cache->createKeyFromAsset($asset, $this->cacheTags);
418 1
        if (!$cache->has($cacheKey)) {
419 1
            $asset->data['content'] = Image::convert($asset, $format, $quality);
420
            $asset->data['path'] = preg_replace('/\.' . $this->data['ext'] . '$/m', ".$format", $this->data['path']);
421
            $asset->data['size'] = \strlen($asset->data['content']);
422
            $cache->set($cacheKey, $asset->data, $this->config->get('cache.assets.ttl'));
423
            $this->builder->getLogger()->debug(\sprintf('Asset converted: "%s" (%s -> %s)', $asset->data['path'], $this->data['ext'], $format));
424
        }
425
        $asset->data = $cache->get($cacheKey);
426
427
        return $asset;
428
    }
429
430
    /**
431
     * Converts an image asset to WebP format.
432
     *
433
     * @throws RuntimeException
434
     */
435
    public function webp(?int $quality = null): self
436
    {
437
        return $this->convert('webp', $quality);
438
    }
439
440
    /**
441
     * Converts an image asset to AVIF format.
442
     *
443
     * @throws RuntimeException
444
     */
445
    public function avif(?int $quality = null): self
446
    {
447
        return $this->convert('avif', $quality);
448
    }
449
450
    /**
451
     * Implements \ArrayAccess.
452
     */
453 1
    #[\ReturnTypeWillChange]
454
    public function offsetSet($offset, $value): void
455
    {
456 1
        if (!\is_null($offset)) {
457 1
            $this->data[$offset] = $value;
458
        }
459
    }
460
461
    /**
462
     * Implements \ArrayAccess.
463
     */
464 1
    #[\ReturnTypeWillChange]
465
    public function offsetExists($offset): bool
466
    {
467 1
        return isset($this->data[$offset]);
468
    }
469
470
    /**
471
     * Implements \ArrayAccess.
472
     */
473
    #[\ReturnTypeWillChange]
474
    public function offsetUnset($offset): void
475
    {
476
        unset($this->data[$offset]);
477
    }
478
479
    /**
480
     * Implements \ArrayAccess.
481
     */
482 1
    #[\ReturnTypeWillChange]
483
    public function offsetGet($offset)
484
    {
485 1
        return isset($this->data[$offset]) ? $this->data[$offset] : null;
486
    }
487
488
    /**
489
     * Hashing content of an asset with the specified algo, sha384 by default.
490
     * Used for SRI (Subresource Integrity).
491
     *
492
     * @see https://developer.mozilla.org/fr/docs/Web/Security/Subresource_Integrity
493
     */
494 1
    public function getIntegrity(string $algo = 'sha384'): string
495
    {
496 1
        return \sprintf('%s-%s', $algo, base64_encode(hash($algo, $this->data['content'], true)));
497
    }
498
499
    /**
500
     * Returns MP3 file infos.
501
     *
502
     * @see https://github.com/wapmorgan/Mp3Info
503
     */
504 1
    public function getAudio(): Mp3Info
505
    {
506 1
        if ($this->data['type'] !== 'audio') {
507
            throw new RuntimeException(\sprintf('Not able to get audio infos of "%s".', $this->data['path']));
508
        }
509
510 1
        return new Mp3Info($this->data['file']);
511
    }
512
513
    /**
514
     * Returns MP4 file infos.
515
     *
516
     * @see https://github.com/clwu88/php-read-mp4info
517
     */
518 1
    public function getVideo(): array
519
    {
520 1
        if ($this->data['type'] !== 'video') {
521
            throw new RuntimeException(\sprintf('Not able to get video infos of "%s".', $this->data['path']));
522
        }
523
524 1
        return (array) \Clwu\Mp4::getInfo($this->data['file']);
525
    }
526
527
    /**
528
     * Returns the Data URL (encoded in Base64).
529
     *
530
     * @throws RuntimeException
531
     */
532 1
    public function dataurl(): string
533
    {
534 1
        if ($this->data['type'] == 'image' && !Image::isSVG($this)) {
535 1
            return Image::getDataUrl($this, (int) $this->config->get('assets.images.quality'));
536
        }
537
538 1
        return \sprintf('data:%s;base64,%s', $this->data['subtype'], base64_encode($this->data['content']));
539
    }
540
541
    /**
542
     * Adds asset path to the list of assets to save.
543
     *
544
     * @throws RuntimeException
545
     */
546 1
    public function save(): void
547
    {
548 1
        if ($this->data['missing']) {
549 1
            return;
550
        }
551
552 1
        $cache = new Cache($this->builder, 'assets');
553 1
        if (empty($this->data['path']) || !Util\File::getFS()->exists($cache->getContentFilePathname($this->data['path']))) {
554
            throw new RuntimeException(
555
                \sprintf('Can\'t add "%s" to assets list. Please clear cache and retry.', $this->data['path'])
556
            );
557
        }
558
559 1
        $this->builder->addAsset($this->data['path']);
560
    }
561
562
    /**
563
     * Is the asset an image and is it in CDN?
564
     */
565 1
    public function isImageInCdn(): bool
566
    {
567
        if (
568 1
            $this->data['type'] == 'image'
569 1
            && $this->config->isEnabled('assets.images.cdn')
570 1
            && $this->data['ext'] != 'ico'
571 1
            && (Image::isSVG($this) && $this->config->isEnabled('assets.images.cdn.svg'))
572
        ) {
573
            return true;
574
        }
575
        // handle remote image?
576 1
        if ($this->data['url'] !== null && $this->config->isEnabled('assets.images.cdn.remote')) {
577
            return true;
578
        }
579
580 1
        return false;
581
    }
582
583
    /**
584
     * Builds a relative path from a URL.
585
     * Used for remote files.
586
     */
587 1
    public static function buildPathFromUrl(string $url): string
588
    {
589 1
        $host = parse_url($url, PHP_URL_HOST);
590 1
        $path = parse_url($url, PHP_URL_PATH);
591 1
        $query = parse_url($url, PHP_URL_QUERY);
592 1
        $ext = pathinfo(parse_url($url, PHP_URL_PATH), PATHINFO_EXTENSION);
593
594
        // Google Fonts hack
595 1
        if (Util\Str::endsWith($path, '/css') || Util\Str::endsWith($path, '/css2')) {
596 1
            $ext = 'css';
597
        }
598
599 1
        return Page::slugify(\sprintf('%s%s%s%s', $host, self::sanitize($path), $query ? "-$query" : '', $query && $ext ? ".$ext" : ''));
600
    }
601
602
    /**
603
     * Replaces some characters by '_'.
604
     */
605 1
    public static function sanitize(string $string): string
606
    {
607 1
        return str_replace(['<', '>', ':', '"', '\\', '|', '?', '*'], '_', $string);
608
    }
609
610
    /**
611
     * Compiles a SCSS.
612
     *
613
     * @throws RuntimeException
614
     */
615 1
    protected function doCompile(): self
616
    {
617
        // abort if not a SCSS file
618 1
        if ($this->data['ext'] != 'scss') {
619 1
            return $this;
620
        }
621 1
        $scssPhp = new Compiler();
622
        // import paths
623 1
        $importDir = [];
624 1
        $importDir[] = Util::joinPath($this->config->getStaticPath());
625 1
        $importDir[] = Util::joinPath($this->config->getAssetsPath());
626 1
        $scssDir = (array) $this->config->get('assets.compile.import');
627 1
        $themes = $this->config->getTheme() ?? [];
628 1
        foreach ($scssDir as $dir) {
629 1
            $importDir[] = Util::joinPath($this->config->getStaticPath(), $dir);
630 1
            $importDir[] = Util::joinPath($this->config->getAssetsPath(), $dir);
631 1
            $importDir[] = Util::joinPath(\dirname($this->data['file']), $dir);
632 1
            foreach ($themes as $theme) {
633 1
                $importDir[] = Util::joinPath($this->config->getThemeDirPath($theme, "static/$dir"));
634 1
                $importDir[] = Util::joinPath($this->config->getThemeDirPath($theme, "assets/$dir"));
635
            }
636
        }
637 1
        $scssPhp->setQuietDeps(true);
638 1
        $scssPhp->setImportPaths(array_unique($importDir));
639
        // adds source map
640 1
        if ($this->builder->isDebug() && $this->config->isEnabled('assets.compile.sourcemap')) {
641
            $importDir = [];
642
            $assetDir = (string) $this->config->get('assets.dir');
643
            $assetDirPos = strrpos($this->data['file'], DIRECTORY_SEPARATOR . $assetDir . DIRECTORY_SEPARATOR);
644
            $fileRelPath = substr($this->data['file'], $assetDirPos + 8);
645
            $filePath = Util::joinFile($this->config->getOutputPath(), $fileRelPath);
646
            $importDir[] = \dirname($filePath);
647
            foreach ($scssDir as $dir) {
648
                $importDir[] = Util::joinFile($this->config->getOutputPath(), $dir);
649
            }
650
            $scssPhp->setImportPaths(array_unique($importDir));
651
            $scssPhp->setSourceMap(Compiler::SOURCE_MAP_INLINE);
652
            $scssPhp->setSourceMapOptions([
653
                'sourceMapBasepath' => Util::joinPath($this->config->getOutputPath()),
654
                'sourceRoot'        => '/',
655
            ]);
656
        }
657
        // defines output style
658 1
        $outputStyles = ['expanded', 'compressed'];
659 1
        $outputStyle = strtolower((string) $this->config->get('assets.compile.style'));
660 1
        if (!\in_array($outputStyle, $outputStyles)) {
661
            throw new ConfigException(\sprintf('"%s" value must be "%s".', 'assets.compile.style', implode('" or "', $outputStyles)));
662
        }
663 1
        $scssPhp->setOutputStyle($outputStyle == 'compressed' ? OutputStyle::COMPRESSED : OutputStyle::EXPANDED);
664
        // set variables
665 1
        $variables = $this->config->get('assets.compile.variables');
666 1
        if (!empty($variables)) {
667 1
            $variables = array_map('ScssPhp\ScssPhp\ValueConverter::parseValue', $variables);
668 1
            $scssPhp->replaceVariables($variables);
669
        }
670
        // debug
671 1
        if ($this->builder->isDebug()) {
672 1
            $scssPhp->setQuietDeps(false);
673 1
            $this->builder->getLogger()->debug(\sprintf("SCSS compiler imported paths:\n%s", Util\Str::arrayToList(array_unique($importDir))));
674
        }
675
        // update data
676 1
        $this->data['path'] = preg_replace('/sass|scss/m', 'css', $this->data['path']);
677 1
        $this->data['ext'] = 'css';
678 1
        $this->data['type'] = 'text';
679 1
        $this->data['subtype'] = 'text/css';
680 1
        $this->data['content'] = $scssPhp->compileString($this->data['content'])->getCss();
681 1
        $this->data['size'] = \strlen($this->data['content']);
682
683 1
        $this->builder->getLogger()->debug(\sprintf('Asset compiled: "%s"', $this->data['path']));
684
685 1
        return $this;
686
    }
687
688
    /**
689
     * Minifying a CSS or a JS + cache.
690
     *
691
     * @throws RuntimeException
692
     */
693 1
    protected function doMinify(): self
694
    {
695
        // in debug mode: disable minify to preserve inline source map
696 1
        if ($this->builder->isDebug() && $this->config->isEnabled('assets.compile.sourcemap')) {
697
            return $this;
698
        }
699
        // abord if not a CSS or JS file
700 1
        if ($this->data['ext'] != 'css' && $this->data['ext'] != 'js') {
701
            return $this;
702
        }
703
        // abort if already minified
704 1
        if (substr($this->data['path'], -8) == '.min.css' || substr($this->data['path'], -7) == '.min.js') {
705
            return $this;
706
        }
707
        // compile SCSS files
708 1
        if ($this->data['ext'] == 'scss') {
709
            $this->compile();
710
        }
711 1
        switch ($this->data['ext']) {
712 1
            case 'css':
713 1
                $minifier = new Minify\CSS($this->data['content']);
714 1
                break;
715 1
            case 'js':
716 1
                $minifier = new Minify\JS($this->data['content']);
717 1
                break;
718
            default:
719
                throw new RuntimeException(\sprintf('Not able to minify "%s".', $this->data['path']));
720
        }
721 1
        $this->data['content'] = $minifier->minify();
722 1
        $this->data['size'] = \strlen($this->data['content']);
723
724 1
        $this->builder->getLogger()->debug(\sprintf('Asset minified: "%s"', $this->data['path']));
725
726 1
        return $this;
727
    }
728
729
    /**
730
     * Add hash to the file name.
731
     */
732 1
    protected function doFingerprint(): self
733
    {
734 1
        $hash = hash('md5', $this->data['content']);
735 1
        $this->data['path'] = preg_replace(
736 1
            '/\.' . $this->data['ext'] . '$/m',
737 1
            ".$hash." . $this->data['ext'],
738 1
            $this->data['path']
739 1
        );
740 1
        $this->builder->getLogger()->debug(\sprintf('Asset fingerprinted: "%s"', $this->data['path']));
741
742 1
        return $this;
743
    }
744
745
    /**
746
     * Returns local file path and updated path, or throw an exception.
747
     * If $fallback path is set, it will be used if the remote file is not found.
748
     *
749
     * Try to locate the file in:
750
     *   (1. remote file)
751
     *   1. assets
752
     *   2. themes/<theme>/assets
753
     *   3. static
754
     *   4. themes/<theme>/static
755
     *
756
     * @throws RuntimeException
757
     */
758 1
    private function locateFile(string $path, ?string $fallback = null, ?string $userAgent = null): array
759
    {
760
        // remote file
761 1
        if (Util\File::isRemote($path)) {
762
            try {
763 1
                $content = $this->getRemoteFileContent($path, $userAgent);
764 1
                $path = self::buildPathFromUrl($path);
765 1
                $cache = new Cache($this->builder, 'assets/remote');
766 1
                if (!$cache->has($path)) {
767 1
                    $cache->set($path, [
768 1
                        'content' => $content,
769 1
                        'path'    => $path,
770 1
                    ], $this->config->get('cache.assets.remote.ttl'));
771
                }
772 1
                return [
773 1
                    'file' => $cache->getContentFilePathname($path),
774 1
                    'path' => $path,
775 1
                ];
776 1
            } catch (RuntimeException $e) {
777 1
                if (empty($fallback)) {
778
                    throw new RuntimeException($e->getMessage());
779
                }
780 1
                $path = $fallback;
781
            }
782
        }
783
784
        // checks in assets/
785 1
        $file = Util::joinFile($this->config->getAssetsPath(), $path);
786 1
        if (Util\File::getFS()->exists($file)) {
787 1
            return [
788 1
                'file' => $file,
789 1
                'path' => $path,
790 1
            ];
791
        }
792
793
        // checks in each themes/<theme>/assets/
794 1
        foreach ($this->config->getTheme() ?? [] as $theme) {
795 1
            $file = Util::joinFile($this->config->getThemeDirPath($theme, 'assets'), $path);
796 1
            if (Util\File::getFS()->exists($file)) {
797 1
                return [
798 1
                    'file' => $file,
799 1
                    'path' => $path,
800 1
                ];
801
            }
802
        }
803
804
        // checks in static/
805 1
        $file = Util::joinFile($this->config->getStaticPath(), $path);
806 1
        if (Util\File::getFS()->exists($file)) {
807 1
            return [
808 1
                'file' => $file,
809 1
                'path' => $path,
810 1
            ];
811
        }
812
813
        // checks in each themes/<theme>/static/
814 1
        foreach ($this->config->getTheme() ?? [] as $theme) {
815 1
            $file = Util::joinFile($this->config->getThemeDirPath($theme, 'static'), $path);
816 1
            if (Util\File::getFS()->exists($file)) {
817 1
                return [
818 1
                    'file' => $file,
819 1
                    'path' => $path,
820 1
                ];
821
            }
822
        }
823
824 1
        throw new RuntimeException(\sprintf('Can\'t locate file "%s".', $path));
825
    }
826
827
    /**
828
     * Try to get remote file content.
829
     * Returns file content or throw an exception.
830
     *
831
     * @throws RuntimeException
832
     */
833 1
    private function getRemoteFileContent(string $path, ?string $userAgent = null): string
834
    {
835 1
        if (!Util\File::isRemoteExists($path)) {
836 1
            throw new RuntimeException(\sprintf('Can\'t get remote file "%s".', $path));
837
        }
838 1
        if (false === $content = Util\File::fileGetContents($path, $userAgent)) {
839
            throw new RuntimeException(\sprintf('Can\'t get content of remote file "%s".', $path));
840
        }
841 1
        if (\strlen($content) <= 1) {
842
            throw new RuntimeException(\sprintf('Remote file "%s" is empty.', $path));
843
        }
844
845 1
        return $content;
846
    }
847
848
    /**
849
     * Optimizing $filepath image.
850
     * Returns the new file size.
851
     */
852 1
    private function optimize(string $filepath, string $path, int $quality): int
853
    {
854 1
        $message = \sprintf('Asset processed: "%s"', $path);
855 1
        $sizeBefore = filesize($filepath);
856 1
        Optimizer::create($quality)->optimize($filepath);
857 1
        $sizeAfter = filesize($filepath);
858 1
        if ($sizeAfter < $sizeBefore) {
859
            $message = \sprintf(
860
                'Asset optimized: "%s" (%s Ko -> %s Ko)',
861
                $path,
862
                ceil($sizeBefore / 1000),
863
                ceil($sizeAfter / 1000)
864
            );
865
        }
866 1
        $this->builder->getLogger()->debug($message);
867
868 1
        return $sizeAfter;
869
    }
870
871
    /**
872
     * Returns the width of an image/SVG.
873
     *
874
     * @throws RuntimeException
875
     */
876 1
    private function getWidth(): int
877
    {
878 1
        if ($this->data['type'] != 'image') {
879
            return 0;
880
        }
881 1
        if (Image::isSVG($this) && false !== $svg = Image::getSvgAttributes($this)) {
882 1
            return (int) $svg->width;
883
        }
884 1
        if (false === $size = $this->getImageSize()) {
885
            throw new RuntimeException(\sprintf('Not able to get width of "%s".', $this->data['path']));
886
        }
887
888 1
        return $size[0];
889
    }
890
891
    /**
892
     * Returns the height of an image/SVG.
893
     *
894
     * @throws RuntimeException
895
     */
896 1
    private function getHeight(): int
897
    {
898 1
        if ($this->data['type'] != 'image') {
899
            return 0;
900
        }
901 1
        if (Image::isSVG($this) && false !== $svg = Image::getSvgAttributes($this)) {
902 1
            return (int) $svg->height;
903
        }
904 1
        if (false === $size = $this->getImageSize()) {
905
            throw new RuntimeException(\sprintf('Not able to get height of "%s".', $this->data['path']));
906
        }
907
908 1
        return $size[1];
909
    }
910
911
    /**
912
     * Returns image size informations.
913
     *
914
     * @see https://www.php.net/manual/function.getimagesize.php
915
     *
916
     * @return array|false
917
     */
918 1
    private function getImageSize()
919
    {
920 1
        if (!$this->data['type'] == 'image') {
921
            return false;
922
        }
923
924
        try {
925 1
            if (false === $size = getimagesizefromstring($this->data['content'])) {
926 1
                return false;
927
            }
928
        } catch (\Exception $e) {
929
            throw new RuntimeException(\sprintf('Handling asset "%s" failed: "%s"', $this->data['path'], $e->getMessage()));
930
        }
931
932 1
        return $size;
933
    }
934
935
    /**
936
     * Builds CDN image URL.
937
     */
938
    private function buildImageCdnUrl(): string
939
    {
940
        return str_replace(
941
            [
942
                '%account%',
943
                '%image_url%',
944
                '%width%',
945
                '%quality%',
946
                '%format%',
947
            ],
948
            [
949
                $this->config->get('assets.images.cdn.account') ?? '',
950
                ltrim($this->data['url'] ?? (string) new Url($this->builder, $this->data['path'], ['canonical' => $this->config->get('assets.images.cdn.canonical') ?? true]), '/'),
951
                $this->data['width'],
952
                (int) $this->config->get('assets.images.quality'),
953
                $this->data['ext'],
954
            ],
955
            (string) $this->config->get('assets.images.cdn.url')
956
        );
957
    }
958
959
    /**
960
     * Checks if the asset is not missing and is typed as an image.
961
     *
962
     * @throws RuntimeException
963
     */
964 1
    private function checkImage(): void
965
    {
966 1
        if ($this->data['missing']) {
967
            throw new RuntimeException(\sprintf('Not able to resize "%s": file not found.', $this->data['path']));
968
        }
969 1
        if ($this->data['type'] != 'image') {
970
            throw new RuntimeException(\sprintf('Not able to resize "%s": not an image.', $this->data['path']));
971
        }
972
    }
973
974
    /**
975
     * Remove the '/thumbnails/<width>/' part of the path if already exists.
976
     */
977 1
    private function deduplicateThumbPath(string $path): string
978
    {
979 1
        $pattern = '/(' . self::IMAGE_THUMB . ')\/(\d+)\/(.*)/i';
980 1
        $replacement = '$3';
981 1
        return preg_replace($pattern, $replacement, $path);
982
    }
983
}
984