Asset::locateFile()   B
last analyzed

Complexity

Conditions 11
Paths 89

Size

Total Lines 71
Code Lines 43

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 48
CRAP Score 11.001

Importance

Changes 2
Bugs 0 Features 0
Metric Value
cc 11
eloc 43
c 2
b 0
f 0
nc 89
nop 3
dl 0
loc 71
ccs 48
cts 49
cp 0.9796
crap 11.001
rs 7.3166

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