Passed
Pull Request — master (#2148)
by Arnaud
19:13 queued 13:21
created

Asset::locateFile()   B

Complexity

Conditions 11
Paths 89

Size

Total Lines 67
Code Lines 40

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 44
CRAP Score 11.0013

Importance

Changes 6
Bugs 0 Features 0
Metric Value
cc 11
eloc 40
c 6
b 0
f 0
nc 89
nop 2
dl 0
loc 67
ccs 44
cts 45
cp 0.9778
crap 11.0013
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
declare(strict_types=1);
4
5
/*
6
 * This file is part of Cecil.
7
 *
8
 * Copyright (c) Arnaud Ligny <[email protected]>
9
 *
10
 * For the full copyright and license information, please view the LICENSE
11
 * file that was distributed with this source code.
12
 */
13
14
namespace Cecil\Assets;
15
16
use Cecil\Assets\Image\Optimizer;
17
use Cecil\Builder;
18
use Cecil\Collection\Page\Page;
19
use Cecil\Config;
20
use Cecil\Exception\ConfigException;
21
use Cecil\Exception\RuntimeException;
22
use Cecil\Url;
23
use Cecil\Util;
24
use MatthiasMullie\Minify;
25
use ScssPhp\ScssPhp\Compiler;
26
use ScssPhp\ScssPhp\OutputStyle;
27
use wapmorgan\Mp3Info\Mp3Info;
28
29
class Asset implements \ArrayAccess
30
{
31
    /** @var Builder */
32
    protected $builder;
33
34
    /** @var Config */
35
    protected $config;
36
37
    /** @var array */
38
    protected $data = [];
39
40
    /** @var bool */
41
    protected $fingerprinted = false;
42
43
    /** @var bool */
44
    protected $compiled = false;
45
46
    /** @var bool */
47
    protected $minified = false;
48
49
    /**
50
     * Creates an Asset from a file path, an array of files path or an URL.
51
     * Options:
52
     * [
53
     *     'fingerprint' => <bool>,
54
     *     'minify' => <bool>,
55
     *     'optimize' => <bool>,
56
     *     'filename' => <string>,
57
     *     'ignore_missing' => <bool>,
58
     *     'fallback' => <string>,
59
     *     'leading_slash' => <bool>
60
     * ]
61
     *
62
     * @param Builder      $builder
63
     * @param string|array $paths
64
     * @param array|null   $options
65
     *
66
     * @throws RuntimeException
67
     */
68 1
    public function __construct(Builder $builder, string|array $paths, array|null $options = null)
69
    {
70 1
        $this->builder = $builder;
71 1
        $this->config = $builder->getConfig();
72 1
        $paths = \is_array($paths) ? $paths : [$paths];
73
        // checks path(s)
74 1
        array_walk($paths, function ($path) {
75
            // must be a string
76 1
            if (!\is_string($path)) {
77
                throw new RuntimeException(\sprintf('The path of an asset must be a string ("%s" given).', \gettype($path)));
78
            }
79
            // can't be empty
80 1
            if (empty($path)) {
81
                throw new RuntimeException('The path of an asset can\'t be empty.');
82
            }
83
            // can't be relative
84 1
            if (substr($path, 0, 2) == '..') {
85
                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));
86
            }
87 1
        });
88 1
        $this->data = [
89 1
            'file'     => '',    // absolute file path
90 1
            'files'    => [],    // array of absolute files path
91 1
            'path'     => '',    // public path
92 1
            'url'      => null,  // URL if it's a remote file
93 1
            'missing'  => false, // if file not found but missing allowed: 'missing' is true
94 1
            'ext'      => '',    // file extension
95 1
            'type'     => '',    // file type (e.g.: image, audio, video, etc.)
96 1
            'subtype'  => '',    // file media type (e.g.: image/png, audio/mp3, etc.)
97 1
            'size'     => 0,     // file size (in bytes)
98 1
            'width'    => 0,     // image width (in pixels)
99 1
            'height'   => 0,     // image height (in pixels)
100 1
            'exif'     => [],    // image exif data
101 1
            'content'  => '',    // file content
102 1
        ];
103
104
        // handles options
105 1
        $options = array_merge(
106 1
            [
107 1
                'fingerprint'    => $this->config->isEnabled('assets.fingerprint'),
108 1
                'minify'         => $this->config->isEnabled('assets.minify'),
109 1
                'optimize'       => $this->config->isEnabled('assets.images.optimize'),
110 1
                'filename'       => '',
111 1
                'ignore_missing' => false,
112 1
                'fallback'       => null,
113 1
                'leading_slash'  => true,
114 1
            ],
115 1
            \is_array($options) ? $options : []
116 1
        );
117
        // cache tags
118 1
        $tags = [];
119 1
        foreach ($options as $key => $value) {
120 1
            if (\is_bool($value) && $value === true) {
121 1
                $tags[] = $key;
122
            }
123 1
            if (\is_string($value) && !empty($value)) {
124 1
                $tags[] = $value;
125
            }
126
        }
127
        // DEBUG
128
        //echo implode('_', $tags) . "\n";
129
        //echo hash('crc32', implode('_', $tags)) . "\n";
130
        //die('debug');
131
132
        // cache
133
        //$cache = new Cache($this->builder, 'assets');
134
        //$cacheKey = $cache->createKeyFromAsset($this, $fingerprint ? ['fingerprinted'] : []);
135
136
        //$cacheKey = \sprintf('%s__%s', $filename ?: implode('_', $paths), $this->builder->getVersion());
137
138
        // locate file(s) and get content
139 1
        $pathsCount = \count($paths);
140 1
        for ($i = 0; $i < $pathsCount; $i++) {
141
            try {
142 1
                $this->data['missing'] = false;
143 1
                $locate = $this->locateFile($paths[$i], $options['fallback']);
144 1
                $file = $locate['file'];
145 1
                $path = $locate['path'];
146 1
                $type = Util\File::getMediaType($file)[0];
147 1
                if ($i > 0) { // bundle
148 1
                    if ($type != $this->data['type']) {
149
                        throw new RuntimeException(\sprintf('Asset bundle type error (%s != %s).', $type, $this->data['type']));
150
                    }
151
                }
152 1
                $this->data['file'] = $file;
153 1
                $this->data['files'][] = $file;
154 1
                $this->data['path'] = $path;
155 1
                $this->data['url'] = Util\File::isRemote($paths[$i]) ? $paths[$i] : null;
156 1
                $this->data['ext'] = Util\File::getExtension($file);
157 1
                $this->data['type'] = $type;
158 1
                $this->data['subtype'] = Util\File::getMediaType($file)[1];
159 1
                $this->data['size'] += filesize($file);
160 1
                $this->data['content'] .= Util\File::fileGetContents($file);
161
                // bundle default filename
162 1
                $filename = $options['filename'];
163 1
                if ($pathsCount > 1 && empty($filename)) {
164 1
                    switch ($this->data['ext']) {
165 1
                        case 'scss':
166 1
                        case 'css':
167 1
                            $filename = 'styles.css';
168 1
                            break;
169 1
                        case 'js':
170 1
                            $filename = 'scripts.js';
171 1
                            break;
172
                        default:
173
                            throw new RuntimeException(\sprintf('Asset bundle supports %s files only.', '.scss, .css and .js'));
174
                    }
175
                }
176
                // apply bundle filename to path
177 1
                if (!empty($filename)) {
178 1
                    $this->data['path'] = '/' . ltrim($filename, '/');
179
                }
180
                // force root slash
181 1
                if ($options['leading_slash']) {
182 1
                    $this->data['path'] = '/' . ltrim($this->data['path'], '/');
183
                }
184 1
            } catch (RuntimeException $e) {
185 1
                if ($options['ignore_missing']) {
186 1
                    $this->data['missing'] = true;
187 1
                    continue;
188
                }
189
                throw new RuntimeException(\sprintf('Can\'t handle asset "%s" (%s).', $paths[$i], $e->getMessage()));
190
            }
191
        }
192
193
        // missing
194 1
        if ($this->data['missing']) {
195 1
            return;
196
        }
197
198
        // cache
199 1
        $cache = new Cache($this->builder, 'assets');
200 1
        $cacheKey = $cache->createKeyFromAsset($this, $tags);
201 1
        if (!$cache->has($cacheKey)) {
202
            // image: width, height and exif
203 1
            if ($this->data['type'] == 'image') {
204 1
                $this->data['width'] = $this->getWidth();
205 1
                $this->data['height'] = $this->getHeight();
206 1
                if ($this->data['subtype'] == 'image/jpeg') {
207 1
                    $this->data['exif'] = Util\File::readExif($this->data['file']);
208
                }
209
            }
210
            // fingerprinting
211 1
            if ($options['fingerprint']) {
212 1
                $this->fingerprint();
213
            }
214 1
            $cache->set($cacheKey, $this->data);
215 1
            $this->builder->getLogger()->debug(\sprintf('Asset created: "%s"', $this->data['path']));
216
            // optimizing images files (in cache)
217 1
            if ($options['optimize'] && $this->data['type'] == 'image' && !$this->isImageInCdn()) {
218 1
                $this->optimize($cache->getContentFilePathname($this->data['path']), $this->data['path']);
219
            }
220
        }
221 1
        $this->data = $cache->get($cacheKey);
222
223
        // compiling Sass files
224 1
        $this->compile();
225
        // minifying (CSS and JavScript files)
226 1
        if ($options['minify']) {
227 1
            $this->minify();
228
        }
229
    }
230
231
    /**
232
     * Returns path.
233
     */
234 1
    public function __toString(): string
235
    {
236 1
        $this->save();
237
238 1
        if ($this->isImageInCdn()) {
239
            return $this->buildImageCdnUrl();
240
        }
241
242 1
        if ($this->builder->getConfig()->isEnabled('canonicalurl')) {
243
            return (string) new Url($this->builder, $this->data['path'], ['canonical' => true]);
244
        }
245
246 1
        return $this->data['path'];
247
    }
248
249
    /**
250
     * Compiles a SCSS.
251
     *
252
     * @throws RuntimeException
253
     */
254 1
    public function compile(): self
255
    {
256 1
        if ($this->compiled) {
257 1
            return $this;
258
        }
259
260 1
        if ($this->data['ext'] != 'scss') {
261 1
            return $this;
262
        }
263
264 1
        $cache = new Cache($this->builder, 'assets');
265 1
        $cacheKey = $cache->createKeyFromAsset($this, ['compiled']);
266 1
        if (!$cache->has($cacheKey)) {
267 1
            $scssPhp = new Compiler();
268
            // import paths
269 1
            $importDir = [];
270 1
            $importDir[] = Util::joinPath($this->config->getStaticPath());
271 1
            $importDir[] = Util::joinPath($this->config->getAssetsPath());
272 1
            $scssDir = (array) $this->config->get('assets.compile.import');
273 1
            $themes = $this->config->getTheme() ?? [];
274 1
            foreach ($scssDir as $dir) {
275 1
                $importDir[] = Util::joinPath($this->config->getStaticPath(), $dir);
276 1
                $importDir[] = Util::joinPath($this->config->getAssetsPath(), $dir);
277 1
                $importDir[] = Util::joinPath(\dirname($this->data['file']), $dir);
278 1
                foreach ($themes as $theme) {
279 1
                    $importDir[] = Util::joinPath($this->config->getThemeDirPath($theme, "static/$dir"));
280 1
                    $importDir[] = Util::joinPath($this->config->getThemeDirPath($theme, "assets/$dir"));
281
                }
282
            }
283 1
            $scssPhp->setQuietDeps(true);
284 1
            $scssPhp->setImportPaths(array_unique($importDir));
285
            // source map
286 1
            if ($this->builder->isDebug() && $this->config->isEnabled('assets.compile.sourcemap')) {
287
                $importDir = [];
288
                $assetDir = (string) $this->config->get('assets.dir');
289
                $assetDirPos = strrpos($this->data['file'], DIRECTORY_SEPARATOR . $assetDir . DIRECTORY_SEPARATOR);
290
                $fileRelPath = substr($this->data['file'], $assetDirPos + 8);
291
                $filePath = Util::joinFile($this->config->getOutputPath(), $fileRelPath);
292
                $importDir[] = \dirname($filePath);
293
                foreach ($scssDir as $dir) {
294
                    $importDir[] = Util::joinFile($this->config->getOutputPath(), $dir);
295
                }
296
                $scssPhp->setImportPaths(array_unique($importDir));
297
                $scssPhp->setSourceMap(Compiler::SOURCE_MAP_INLINE);
298
                $scssPhp->setSourceMapOptions([
299
                    'sourceMapBasepath' => Util::joinPath($this->config->getOutputPath()),
300
                    'sourceRoot'        => '/',
301
                ]);
302
            }
303
            // output style
304 1
            $outputStyles = ['expanded', 'compressed'];
305 1
            $outputStyle = strtolower((string) $this->config->get('assets.compile.style'));
306 1
            if (!\in_array($outputStyle, $outputStyles)) {
307
                throw new ConfigException(\sprintf('"%s" value must be "%s".', 'assets.compile.style', implode('" or "', $outputStyles)));
308
            }
309 1
            $scssPhp->setOutputStyle($outputStyle == 'compressed' ? OutputStyle::COMPRESSED : OutputStyle::EXPANDED);
310
            // variables
311 1
            $variables = $this->config->get('assets.compile.variables');
312 1
            if (!empty($variables)) {
313 1
                $variables = array_map('ScssPhp\ScssPhp\ValueConverter::parseValue', $variables);
314 1
                $scssPhp->replaceVariables($variables);
315
            }
316
            // debug
317 1
            if ($this->builder->isDebug()) {
318 1
                $scssPhp->setQuietDeps(false);
319 1
                $this->builder->getLogger()->debug(\sprintf("SCSS compiler imported paths:\n%s", Util\Str::arrayToList(array_unique($importDir))));
320
            }
321
            // update data
322 1
            $this->data['path'] = preg_replace('/sass|scss/m', 'css', $this->data['path']);
323 1
            $this->data['ext'] = 'css';
324 1
            $this->data['type'] = 'text';
325 1
            $this->data['subtype'] = 'text/css';
326 1
            $this->data['content'] = $scssPhp->compileString($this->data['content'])->getCss();
327 1
            $this->data['size'] = \strlen($this->data['content']);
328 1
            $cache->set($cacheKey, $this->data);
329 1
            $this->compiled = true;
330 1
            $this->builder->getLogger()->debug(\sprintf('Asset compiled: "%s"', $this->data['path']));
331
        }
332 1
        $this->data = $cache->get($cacheKey);
333
334 1
        return $this;
335
    }
336
337
    /**
338
     * Minifying a CSS or a JS.
339
     *
340
     * @throws RuntimeException
341
     */
342 1
    public function minify(): self
343
    {
344
        // in debug mode, disable minify to preserve inline source map
345 1
        if ($this->builder->isDebug() && $this->config->isEnabled('assets.compile.sourcemap')) {
346
            return $this;
347
        }
348
349 1
        if ($this->data['ext'] != 'css' && $this->data['ext'] != 'js') {
350
            return $this;
351
        }
352
353 1
        if (substr($this->data['path'], -8) == '.min.css' || substr($this->data['path'], -7) == '.min.js') {
354
            $this->minified = true;
355
        }
356
357 1
        if ($this->minified) {
358
            return $this;
359
        }
360
361 1
        if ($this->data['ext'] == 'scss') {
362
            $this->compile();
363
        }
364
365 1
        $cache = new Cache($this->builder, 'assets');
366 1
        $cacheKey = $cache->createKeyFromAsset($this, ['minified']);
367 1
        if (!$cache->has($cacheKey)) {
368 1
            switch ($this->data['ext']) {
369 1
                case 'css':
370 1
                    $minifier = new Minify\CSS($this->data['content']);
371 1
                    break;
372 1
                case 'js':
373 1
                    $minifier = new Minify\JS($this->data['content']);
374 1
                    break;
375
                default:
376
                    throw new RuntimeException(\sprintf('Not able to minify "%s".', $this->data['path']));
377
            }
378 1
            $this->data['content'] = $minifier->minify();
379 1
            $this->data['size'] = \strlen($this->data['content']);
380 1
            $cache->set($cacheKey, $this->data);
381 1
            $this->minified = true;
382 1
            $this->builder->getLogger()->debug(\sprintf('Asset minified: "%s"', $this->data['path']));
383
        }
384 1
        $this->data = $cache->get($cacheKey);
385
386 1
        return $this;
387
    }
388
389
    /**
390
     * Add hash to the file name.
391
     */
392 1
    public function fingerprint(): self
393
    {
394 1
        if ($this->fingerprinted) {
395
            return $this;
396
        }
397
398 1
        $cache = new Cache($this->builder, 'assets');
399 1
        $cacheKey = $cache->createKeyFromAsset($this, ['fingerprinted']);
400 1
        if (!$cache->has($cacheKey)) {
401 1
            $hash = hash('md5', $this->data['content']);
402 1
            $this->data['path'] = preg_replace(
403 1
                '/\.' . $this->data['ext'] . '$/m',
404 1
                ".$hash." . $this->data['ext'],
405 1
                $this->data['path']
406 1
            );
407 1
            $cache->set($cacheKey, $this->data);
408 1
            $this->fingerprinted = true;
409 1
            $this->builder->getLogger()->debug(\sprintf('Asset fingerprinted: "%s"', $this->data['path']));
410
        }
411 1
        $this->data = $cache->get($cacheKey);
412
413 1
        return $this;
414
    }
415
416
    /**
417
     * Optimizing $filepath image.
418
     * Returns the new file size.
419
     */
420 1
    public function optimize(string $filepath, string $path): int
421
    {
422 1
        $quality = (int) $this->config->get('assets.images.quality');
423 1
        $message = \sprintf('Asset processed: "%s"', $path);
424 1
        $sizeBefore = filesize($filepath);
425 1
        Optimizer::create($quality)->optimize($filepath);
426 1
        $sizeAfter = filesize($filepath);
427 1
        if ($sizeAfter < $sizeBefore) {
428
            $message = \sprintf(
429
                'Asset optimized: "%s" (%s Ko -> %s Ko)',
430
                $path,
431
                ceil($sizeBefore / 1000),
432
                ceil($sizeAfter / 1000)
433
            );
434
        }
435 1
        $this->builder->getLogger()->debug($message);
436
437 1
        return $sizeAfter;
438
    }
439
440
    /**
441
     * Resizes an image with a new $width.
442
     *
443
     * @throws RuntimeException
444
     */
445 1
    public function resize(int $width): self
446
    {
447 1
        if ($this->data['missing']) {
448
            throw new RuntimeException(\sprintf('Not able to resize "%s": file not found.', $this->data['path']));
449
        }
450 1
        if ($this->data['type'] != 'image') {
451
            throw new RuntimeException(\sprintf('Not able to resize "%s": not an image.', $this->data['path']));
452
        }
453 1
        if ($width >= $this->data['width']) {
454 1
            return $this;
455
        }
456
457 1
        $assetResized = clone $this;
458 1
        $assetResized->data['width'] = $width;
459
460 1
        if ($this->isImageInCdn()) {
461
            $assetResized->data['height'] = round($this->data['height'] / ($this->data['width'] / $width));
462
463
            return $assetResized; // returns asset with the new dimensions only: CDN do the rest of the job
464
        }
465
466 1
        $quality = (int) $this->config->get('assets.images.quality');
467 1
        $cache = new Cache($this->builder, 'assets');
468 1
        $cacheKey = $cache->createKeyFromAsset($assetResized, ["{$width}x", "q$quality"]);
469 1
        if (!$cache->has($cacheKey)) {
470 1
            $assetResized->data['content'] = Image::resize($assetResized, $width, $quality);
471 1
            $assetResized->data['path'] = '/' . Util::joinPath(
472 1
                (string) $this->config->get('assets.target'),
473 1
                'thumbnails',
474 1
                (string) $width,
475 1
                $assetResized->data['path']
476 1
            );
477 1
            $assetResized->data['height'] = $assetResized->getHeight();
478 1
            $assetResized->data['size'] = \strlen($assetResized->data['content']);
479
480 1
            $cache->set($cacheKey, $assetResized->data);
481 1
            $this->builder->getLogger()->debug(\sprintf('Asset resized: "%s" (%sx)', $assetResized->data['path'], $width));
482
        }
483 1
        $assetResized->data = $cache->get($cacheKey);
484
485 1
        return $assetResized;
486
    }
487
488
    /**
489
     * Converts an image asset to $format format.
490
     *
491
     * @throws RuntimeException
492
     */
493 1
    public function convert(string $format, ?int $quality = null): self
494
    {
495 1
        if ($this->data['type'] != 'image') {
496
            throw new RuntimeException(\sprintf('Not able to convert "%s" (%s) to %s: not an image.', $this->data['path'], $this->data['type'], $format));
497
        }
498
499 1
        if ($quality === null) {
500 1
            $quality = (int) $this->config->get('assets.images.quality');
501
        }
502
503 1
        $asset = clone $this;
504 1
        $asset['ext'] = $format;
505 1
        $asset->data['subtype'] = "image/$format";
506
507 1
        if ($this->isImageInCdn()) {
508
            return $asset; // returns the asset with the new extension only: CDN do the rest of the job
509
        }
510
511 1
        $cache = new Cache($this->builder, 'assets');
512 1
        $tags = ["q$quality"];
513 1
        if ($this->data['width']) {
514 1
            array_unshift($tags, "{$this->data['width']}x");
515
        }
516 1
        $cacheKey = $cache->createKeyFromAsset($asset, $tags);
517 1
        if (!$cache->has($cacheKey)) {
518 1
            $asset->data['content'] = Image::convert($asset, $format, $quality);
519
            $asset->data['path'] = preg_replace('/\.' . $this->data['ext'] . '$/m', ".$format", $this->data['path']);
520
            $asset->data['size'] = \strlen($asset->data['content']);
521
            $cache->set($cacheKey, $asset->data);
522
            $this->builder->getLogger()->debug(\sprintf('Asset converted: "%s" (%s -> %s)', $asset->data['path'], $this->data['ext'], $format));
523
        }
524
        $asset->data = $cache->get($cacheKey);
525
526
        return $asset;
527
    }
528
529
    /**
530
     * Converts an image asset to WebP format.
531
     *
532
     * @throws RuntimeException
533
     */
534
    public function webp(?int $quality = null): self
535
    {
536
        return $this->convert('webp', $quality);
537
    }
538
539
    /**
540
     * Converts an image asset to AVIF format.
541
     *
542
     * @throws RuntimeException
543
     */
544 1
    public function avif(?int $quality = null): self
545
    {
546 1
        return $this->convert('avif', $quality);
547
    }
548
549
    /**
550
     * Implements \ArrayAccess.
551
     */
552 1
    #[\ReturnTypeWillChange]
553
    public function offsetSet($offset, $value): void
554
    {
555 1
        if (!\is_null($offset)) {
556 1
            $this->data[$offset] = $value;
557
        }
558
    }
559
560
    /**
561
     * Implements \ArrayAccess.
562
     */
563 1
    #[\ReturnTypeWillChange]
564
    public function offsetExists($offset): bool
565
    {
566 1
        return isset($this->data[$offset]);
567
    }
568
569
    /**
570
     * Implements \ArrayAccess.
571
     */
572
    #[\ReturnTypeWillChange]
573
    public function offsetUnset($offset): void
574
    {
575
        unset($this->data[$offset]);
576
    }
577
578
    /**
579
     * Implements \ArrayAccess.
580
     */
581 1
    #[\ReturnTypeWillChange]
582
    public function offsetGet($offset)
583
    {
584 1
        return isset($this->data[$offset]) ? $this->data[$offset] : null;
585
    }
586
587
    /**
588
     * Hashing content of an asset with the specified algo, sha384 by default.
589
     * Used for SRI (Subresource Integrity).
590
     *
591
     * @see https://developer.mozilla.org/fr/docs/Web/Security/Subresource_Integrity
592
     */
593 1
    public function getIntegrity(string $algo = 'sha384'): string
594
    {
595 1
        return \sprintf('%s-%s', $algo, base64_encode(hash($algo, $this->data['content'], true)));
596
    }
597
598
    /**
599
     * Returns MP3 file infos.
600
     *
601
     * @see https://github.com/wapmorgan/Mp3Info
602
     */
603 1
    public function getAudio(): Mp3Info
604
    {
605 1
        if ($this->data['type'] !== 'audio') {
606
            throw new RuntimeException(\sprintf('Not able to get audio infos of "%s".', $this->data['path']));
607
        }
608
609 1
        return new Mp3Info($this->data['file']);
610
    }
611
612
    /**
613
     * Returns MP4 file infos.
614
     *
615
     * @see https://github.com/clwu88/php-read-mp4info
616
     */
617 1
    public function getVideo(): array
618
    {
619 1
        if ($this->data['type'] !== 'video') {
620
            throw new RuntimeException(\sprintf('Not able to get video infos of "%s".', $this->data['path']));
621
        }
622
623 1
        return (array) \Clwu\Mp4::getInfo($this->data['file']);
624
    }
625
626
    /**
627
     * Returns the Data URL (encoded in Base64).
628
     *
629
     * @throws RuntimeException
630
     */
631 1
    public function dataurl(): string
632
    {
633 1
        if ($this->data['type'] == 'image' && !Image::isSVG($this)) {
634 1
            return Image::getDataUrl($this, (int) $this->config->get('assets.images.quality'));
635
        }
636
637 1
        return \sprintf('data:%s;base64,%s', $this->data['subtype'], base64_encode($this->data['content']));
638
    }
639
640
    /**
641
     * Adds asset path to the list of assets to save.
642
     *
643
     * @throws RuntimeException
644
     */
645 1
    public function save(): void
646
    {
647 1
        if ($this->data['missing']) {
648 1
            return;
649
        }
650
651 1
        $cache = new Cache($this->builder, 'assets');
652 1
        if (!Util\File::getFS()->exists($cache->getContentFilePathname($this->data['path']))) {
653
            throw new RuntimeException(
654
                \sprintf('Can\'t add "%s" to assets list. Please clear cache and retry.', $this->data['path'])
655
            );
656
        }
657
658 1
        $this->builder->addAsset($this->data['path']);
659
    }
660
661
    /**
662
     * Is the asset an image and is it in CDN?
663
     */
664 1
    public function isImageInCdn(): bool
665
    {
666
        if (
667 1
            $this->data['type'] == 'image'
668 1
            && $this->config->isEnabled('assets.images.cdn')
669 1
            && $this->data['ext'] != 'ico'
670 1
            && (Image::isSVG($this) && $this->config->isEnabled('assets.images.cdn.svg'))
671
        ) {
672
            return true;
673
        }
674
        // handle remote image?
675 1
        if ($this->data['url'] !== null && $this->config->isEnabled('assets.images.cdn.remote')) {
676
            return true;
677
        }
678
679 1
        return false;
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
     * Returns local file path and updated path, or throw an exception.
711
     *
712
     * Try to locate the file in:
713
     *   (1. remote file)
714
     *   1. assets
715
     *   2. themes/<theme>/assets
716
     *   3. static
717
     *   4. themes/<theme>/static
718
     *
719
     * If $fallback is set, it will be used if the file is not found.
720
     *
721
     * @throws RuntimeException
722
     */
723 1
    private function locateFile(string $path, ?string $fallback = null): array
724
    {
725
        // remote file
726 1
        if (Util\File::isRemote($path)) {
727
            try {
728 1
                $content = $this->getRemoteFileContent($path);
729 1
                $path = self::buildPathFromUrl($path);
730 1
                $cache = new Cache($this->builder, 'assets/remote');
731 1
                if (!$cache->has($path)) {
732 1
                    $cache->set($path, [
733 1
                        'content' => $content,
734 1
                        'path'    => $path,
735 1
                    ], \DateInterval::createFromDateString('7 days'));
736
                }
737 1
                return [
738 1
                    'file' => $cache->getContentFilePathname($path),
739 1
                    'path' => $path,
740 1
                ];
741 1
            } catch (RuntimeException $e) {
742 1
                if ($fallback === null) {
743
                    throw new RuntimeException($e->getMessage(), previous: $e);
744
                }
745 1
                $path = $fallback;
746
            }
747
        }
748
749
        // checks in assets/
750 1
        $file = Util::joinFile($this->config->getAssetsPath(), $path);
751 1
        if (Util\File::getFS()->exists($file)) {
752 1
            return [
753 1
                'file' => $file,
754 1
                'path' => $path,
755 1
            ];
756
        }
757
758
        // checks in each themes/<theme>/assets/
759 1
        foreach ($this->config->getTheme() ?? [] as $theme) {
760 1
            $file = Util::joinFile($this->config->getThemeDirPath($theme, 'assets'), $path);
761 1
            if (Util\File::getFS()->exists($file)) {
762 1
                return [
763 1
                    'file' => $file,
764 1
                    'path' => $path,
765 1
                ];
766
            }
767
        }
768
769
        // checks in static/
770 1
        $file = Util::joinFile($this->config->getStaticTargetPath(), $path);
771 1
        if (Util\File::getFS()->exists($file)) {
772 1
            return [
773 1
                'file' => $file,
774 1
                'path' => $path,
775 1
            ];
776
        }
777
778
        // checks in each themes/<theme>/static/
779 1
        foreach ($this->config->getTheme() ?? [] as $theme) {
780 1
            $file = Util::joinFile($this->config->getThemeDirPath($theme, 'static'), $path);
781 1
            if (Util\File::getFS()->exists($file)) {
782 1
                return [
783 1
                    'file' => $file,
784 1
                    'path' => $path,
785 1
                ];
786
            }
787
        }
788
789 1
        throw new RuntimeException(\sprintf('Can\'t locate file "%s".', $path));
790
    }
791
792
    /**
793
     * Try to get remote file content.
794
     * Returns file content or throw an exception.
795
     *
796
     * @throws RuntimeException
797
     */
798 1
    private function getRemoteFileContent(string $path): string
799
    {
800
        try {
801 1
            if (!Util\File::isRemoteExists($path)) {
802 1
                throw new RuntimeException(\sprintf('Remote file "%s" doesn\'t exists', $path));
803
            }
804 1
            if (false === $content = Util\File::fileGetContents($path, true)) {
805
                throw new RuntimeException(\sprintf('Can\'t get content of remote file "%s".', $path));
806
            }
807 1
            if (\strlen($content) <= 1) {
808 1
                throw new RuntimeException(\sprintf('Remote file "%s" is empty.', $path));
809
            }
810 1
        } catch (RuntimeException $e) {
811 1
            throw new RuntimeException($e->getMessage());
812
        }
813
814 1
        return $content;
815
    }
816
817
    /**
818
     * Returns the width of an image/SVG.
819
     *
820
     * @throws RuntimeException
821
     */
822 1
    private function getWidth(): int
823
    {
824 1
        if ($this->data['type'] != 'image') {
825
            return 0;
826
        }
827 1
        if (Image::isSVG($this) && false !== $svg = Image::getSvgAttributes($this)) {
828 1
            return (int) $svg->width;
829
        }
830 1
        if (false === $size = $this->getImageSize()) {
831
            throw new RuntimeException(\sprintf('Not able to get width of "%s".', $this->data['path']));
832
        }
833
834 1
        return $size[0];
835
    }
836
837
    /**
838
     * Returns the height of an image/SVG.
839
     *
840
     * @throws RuntimeException
841
     */
842 1
    private function getHeight(): int
843
    {
844 1
        if ($this->data['type'] != 'image') {
845
            return 0;
846
        }
847 1
        if (Image::isSVG($this) && false !== $svg = Image::getSvgAttributes($this)) {
848 1
            return (int) $svg->height;
849
        }
850 1
        if (false === $size = $this->getImageSize()) {
851
            throw new RuntimeException(\sprintf('Not able to get height of "%s".', $this->data['path']));
852
        }
853
854 1
        return $size[1];
855
    }
856
857
    /**
858
     * Returns image size informations.
859
     *
860
     * @see https://www.php.net/manual/function.getimagesize.php
861
     *
862
     * @return array|false
863
     */
864 1
    private function getImageSize()
865
    {
866 1
        if (!$this->data['type'] == 'image') {
867
            return false;
868
        }
869
870
        try {
871 1
            if (false === $size = getimagesizefromstring($this->data['content'])) {
872 1
                return false;
873
            }
874
        } catch (\Exception $e) {
875
            throw new RuntimeException(\sprintf('Handling asset "%s" failed: "%s"', $this->data['path'], $e->getMessage()));
876
        }
877
878 1
        return $size;
879
    }
880
881
    /**
882
     * Builds CDN image URL.
883
     */
884
    private function buildImageCdnUrl(): string
885
    {
886
        return str_replace(
887
            [
888
                '%account%',
889
                '%image_url%',
890
                '%width%',
891
                '%quality%',
892
                '%format%',
893
            ],
894
            [
895
                $this->config->get('assets.images.cdn.account') ?? '',
896
                ltrim($this->data['url'] ?? (string) new Url($this->builder, $this->data['path'], ['canonical' => $this->config->get('assets.images.cdn.canonical') ?? true]), '/'),
897
                $this->data['width'],
898
                (int) $this->config->get('assets.images.quality'),
899
                $this->data['ext'],
900
            ],
901
            (string) $this->config->get('assets.images.cdn.url')
902
        );
903
    }
904
}
905