Asset::locateFile()   B
last analyzed

Complexity

Conditions 11
Paths 89

Size

Total Lines 68
Code Lines 41

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 45
CRAP Score 11.0012

Importance

Changes 1
Bugs 0 Features 0
Metric Value
cc 11
eloc 41
c 1
b 0
f 0
nc 89
nop 3
dl 0
loc 68
rs 7.3166
ccs 45
cts 46
cp 0.9783
crap 11.0012

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