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

Asset::locateFile()   C

Complexity

Conditions 15
Paths 30

Size

Total Lines 64
Code Lines 34

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 27
CRAP Score 18.5156

Importance

Changes 3
Bugs 0 Features 0
Metric Value
cc 15
eloc 34
c 3
b 0
f 0
nc 30
nop 2
dl 0
loc 64
ccs 27
cts 36
cp 0.75
crap 18.5156
rs 5.9166

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
     *
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