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