Passed
Pull Request — master (#2148)
by Arnaud
11:21 queued 04:41
created

Asset::loadFile()   B

Complexity

Conditions 6
Paths 8

Size

Total Lines 64
Code Lines 41

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 39
CRAP Score 6.0527

Importance

Changes 1
Bugs 0 Features 0
Metric Value
cc 6
eloc 41
c 1
b 0
f 0
nc 8
nop 4
dl 0
loc 64
ccs 39
cts 44
cp 0.8864
crap 6.0527
rs 8.6417

How to fix   Long Method   

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