Passed
Push — configuration ( 7e88fe...e82eaf )
by Arnaud
04:10
created

Asset::buildPathFromUrl()   A

Complexity

Conditions 6
Paths 2

Size

Total Lines 19
Code Lines 13

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 2
CRAP Score 24.432

Importance

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