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