Asset::doCompile()   C
last analyzed

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