Test Failed
Pull Request — master (#2148)
by Arnaud
08:44 queued 03:50
created

Asset::__construct()   F

Complexity

Conditions 34
Paths 10

Size

Total Lines 162
Code Lines 105

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 98
CRAP Score 34.343

Importance

Changes 20
Bugs 0 Features 0
Metric Value
cc 34
eloc 105
nc 10
nop 3
dl 0
loc 162
ccs 98
cts 105
cp 0.9333
crap 34.343
rs 3.3333
c 20
b 0
f 0

How to fix   Long Method    Complexity   

Long Method

Small methods make your code easier to understand, in particular if combined with a good name. Besides, if your method is small, finding a good name is usually much easier.

For example, if you find yourself adding comments to a method's body, this is usually a good sign to extract the commented part to a new method, and use the comment as a starting point when coming up with a good name for this new method.

Commonly applied refactorings include:

1
<?php
2
3
declare(strict_types=1);
4
5
/*
6
 * This file is part of Cecil.
7
 *
8
 * Copyright (c) Arnaud Ligny <[email protected]>
9
 *
10
 * For the full copyright and license information, please view the LICENSE
11
 * file that was distributed with this source code.
12
 */
13
14
namespace Cecil\Assets;
15
16
use Cecil\Assets\Image\Optimizer;
17
use Cecil\Builder;
18
use Cecil\Collection\Page\Page;
19
use Cecil\Config;
20
use Cecil\Exception\ConfigException;
21
use Cecil\Exception\RuntimeException;
22
use Cecil\Url;
23
use Cecil\Util;
24
use MatthiasMullie\Minify;
25
use ScssPhp\ScssPhp\Compiler;
26
use ScssPhp\ScssPhp\OutputStyle;
27
use wapmorgan\Mp3Info\Mp3Info;
28
29
class Asset implements \ArrayAccess
30
{
31
    /** @var Builder */
32
    protected $builder;
33
34
    /** @var Config */
35
    protected $config;
36
37
    /** @var array */
38
    protected $data = [];
39
40
    /** @var bool */
41
    protected $fingerprinted = false;
42
43
    /** @var bool */
44
    protected $compiled = false;
45
46
    /** @var bool */
47
    protected $minified = false;
48
49
    /**
50
     * Creates an Asset from a file path, an array of files path or an URL.
51
     * Options:
52
     * [
53
     *     'fingerprint' => <bool>,
54
     *     'minify' => <bool>,
55
     *     'optimize' => <bool>,
56
     *     'filename' => <string>,
57
     *     'ignore_missing' => <bool>,
58
     *     'fallback' => <string>,
59
     *     'leading_slash' => <bool>
60
     * ]
61 1
     *
62
     * @param Builder      $builder
63 1
     * @param string|array $paths
64 1
     * @param array|null   $options
65 1
     *
66 1
     * @throws RuntimeException
67 1
     */
68
    public function __construct(Builder $builder, string|array $paths, array|null $options = null)
69
    {
70 1
        $this->builder = $builder;
71
        $this->config = $builder->getConfig();
72
        $paths = \is_array($paths) ? $paths : [$paths];
73 1
        // checks path(s)
74
        array_walk($paths, function ($path) {
75
            // must be a string
76 1
            if (!\is_string($path)) {
77 1
                throw new RuntimeException(\sprintf('The path of an asset must be a string ("%s" given).', \gettype($path)));
78 1
            }
79 1
            // can't be empty
80 1
            if (empty($path)) {
81 1
                throw new RuntimeException('The path of an asset can\'t be empty.');
82 1
            }
83 1
            // can't be relative
84 1
            if (substr($path, 0, 2) == '..') {
85 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));
86 1
            }
87 1
        });
88 1
        $this->data = [
89 1
            'file'     => '',    // absolute file path
90 1
            'files'    => [],    // array of absolute files path
91 1
            'path'     => '',    // public path
92 1
            '_path'    => '',    // public path before any modification
93
            'url'      => null,  // URL if it's a remote file
94
            'missing'  => false, // if file not found but missing allowed: 'missing' is true
95 1
            'ext'      => '',    // file extension
96 1
            'type'     => '',    // file type (e.g.: image, audio, video, etc.)
97 1
            'subtype'  => '',    // file media type (e.g.: image/png, audio/mp3, etc.)
98 1
            'size'     => 0,     // file size (in bytes)
99 1
            'width'    => 0,     // image width (in pixels)
100 1
            'height'   => 0,     // image height (in pixels)
101 1
            'exif'     => [],    // image exif data
102 1
            'content'  => '',    // file content
103
        ];
104
105 1
        // handles options
106 1
        $options = array_merge(
107 1
            [
108 1
                'fingerprint'    => $this->config->isEnabled('assets.fingerprint'),
109 1
                'minify'         => $this->config->isEnabled('assets.minify'),
110 1
                'optimize'       => $this->config->isEnabled('assets.images.optimize'),
111
                'filename'       => '',
112 1
                'ignore_missing' => false,
113
                'fallback'       => null,
114 1
                'leading_slash'    => true,
115 1
            ],
116
            \is_array($options) ? $options : []
117
        );
118
        // cache tags
119
        $tags = [];
120 1
        foreach ($options as $key => $value) {
121 1
            if (\is_bool($value) && $value === true) {
122 1
                $tags[] = $key;
123
            }
124 1
            if (\is_string($value) && !empty($value)) {
125
                $tags[] = $value;
126
            }
127 1
        }
128 1
        // DEBUG
129 1
        echo implode('_', $tags) . "\n";
130 1
        echo hash('crc32', implode('_', $tags)) . "\n";
131 1
        die('debug');
132 1
133 1
        // cache
134 1
        //$cache = new Cache($this->builder, 'assets');
135 1
        //$cacheKey = $cache->createKeyFromAsset($this, $fingerprint ? ['fingerprinted'] : []);
136 1
137
        //$cacheKey = \sprintf('%s__%s', $filename ?: implode('_', $paths), $this->builder->getVersion());
138 1
139 1
        // locate file(s) and get content
140 1
        $pathsCount = \count($paths);
0 ignored issues
show
Unused Code introduced by
$pathsCount = count($paths) is not reachable.

This check looks for unreachable code. It uses sophisticated control flow analysis techniques to find statements which will never be executed.

Unreachable code is most often the result of return, die or exit statements that have been added for debug purposes.

function fx() {
    try {
        doSomething();
        return true;
    }
    catch (\Exception $e) {
        return false;
    }

    return false;
}

In the above example, the last return false will never be executed, because a return statement has already been met in every possible execution path.

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