Passed
Pull Request — master (#2178)
by Arnaud
09:25 queued 03:31
created

Asset::doCompile()   C

Complexity

Conditions 11
Paths 31

Size

Total Lines 71
Code Lines 51

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 38
CRAP Score 13.7424

Importance

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

How to fix   Long Method    Complexity   

Long Method

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

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

Commonly applied refactorings include:

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