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