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