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