Passed
Pull Request — master (#2277)
by Arnaud
08:31 queued 03:30
created

Asset::locateFile()   B

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