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