Passed
Push — master ( 2c25f8...c0741a )
by
unknown
05:43
created

Asset::__construct()   F

Complexity

Conditions 32
Paths 798

Size

Total Lines 170
Code Lines 115

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 109
CRAP Score 32.7896

Importance

Changes 7
Bugs 1 Features 0
Metric Value
cc 32
eloc 115
c 7
b 1
f 0
nc 798
nop 3
dl 0
loc 170
ccs 109
cts 120
cp 0.9083
crap 32.7896
rs 0.2244

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