Passed
Push — cdn ( 660ee7...61c813 )
by Arnaud
03:38
created

Asset::__construct()   F

Complexity

Conditions 25
Paths 166

Size

Total Lines 131
Code Lines 93

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 80
CRAP Score 27.4605

Importance

Changes 8
Bugs 5 Features 1
Metric Value
cc 25
eloc 93
c 8
b 5
f 1
nc 166
nop 3
dl 0
loc 131
ccs 80
cts 95
cp 0.8421
crap 27.4605
rs 3.6166

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\RuntimeException;
21
use Cecil\Util;
22
use Intervention\Image\ImageManagerStatic as ImageManager;
23
use MatthiasMullie\Minify;
24
use ScssPhp\ScssPhp\Compiler;
25
use wapmorgan\Mp3Info\Mp3Info;
26
27
class Asset implements \ArrayAccess
28
{
29
    /** @var Builder */
30
    protected $builder;
31
32
    /** @var Config */
33
    protected $config;
34
35
    /** @var array */
36
    protected $data = [];
37
38
    /** @var bool */
39
    protected $fingerprinted = false;
40
41
    /** @var bool */
42
    protected $compiled = false;
43
44
    /** @var bool */
45
    protected $minified = false;
46
47
    /** @var bool */
48
    protected $optimize = false;
49
    /** @var bool */
50
    protected $optimized = false;
51
52
    /** @var bool */
53
    protected $ignore_missing = false;
54
55
    /**
56
     * Creates an Asset from a file path, an array of files path or an URL.
57
     *
58
     * @param Builder      $builder
59
     * @param string|array $paths
60
     * @param array|null   $options e.g.: ['fingerprint' => true, 'minify' => true, 'filename' => '', 'ignore_missing' => false]
61
     *
62
     * @throws RuntimeException
63
     */
64 1
    public function __construct(Builder $builder, $paths, array $options = null)
65
    {
66 1
        $this->builder = $builder;
67 1
        $this->config = $builder->getConfig();
68 1
        $paths = is_array($paths) ? $paths : [$paths];
69 1
        array_walk($paths, function ($path) {
70 1
            if (!is_string($path)) {
71
                throw new RuntimeException(\sprintf('The path to an asset must be a string (%s given).', gettype($path)));
72
            }
73 1
            if (empty($path)) {
74
                throw new RuntimeException('The path to an asset can\'t be empty.');
75
            }
76 1
            if (substr($path, 0, 2) == '..') {
77
                throw new RuntimeException(\sprintf('The path to asset "%s" is wrong: it must be directly relative to "assets" or "static" directory, or a remote URL.', $path));
78
            }
79 1
        });
80 1
        $this->data = [
81 1
            'file'           => '',    // absolute file path
82 1
            'files'          => [],    // array of files path (if bundle)
83 1
            'filename'       => '',    // filename
84 1
            'path_source'    => '',    // public path to the file, before transformations
85 1
            'path'           => '',    // public path to the file, after transformations
86 1
            'url'            => null,  // URL of a remote image
87 1
            'missing'        => false, // if file not found, but missing ollowed 'missing' is true
88 1
            'ext'            => '',    // file extension
89 1
            'type'           => '',    // file type (e.g.: image, audio, video, etc.)
90 1
            'subtype'        => '',    // file media type (e.g.: image/png, audio/mp3, etc.)
91 1
            'size'           => 0,     // file size (in bytes)
92 1
            'content_source' => '',    // file content, before transformations
93 1
            'content'        => '',    // file content, after transformations
94 1
            'width'          => 0,     // width (in pixels) in case of an image
95 1
            'height'         => 0,     // height (in pixels) in case of an image
96 1
            'exif'           => [],    // exif data
97 1
        ];
98
99
        // handles options
100 1
        $fingerprint = (bool) $this->config->get('assets.fingerprint.enabled');
101 1
        $minify = (bool) $this->config->get('assets.minify.enabled');
102 1
        $optimize = (bool) $this->config->get('assets.images.optimize.enabled');
103 1
        $filename = '';
104 1
        $ignore_missing = false;
105 1
        $remote_fallback = null;
106 1
        $force_slash = true;
107 1
        extract(is_array($options) ? $options : [], EXTR_IF_EXISTS);
108 1
        $this->ignore_missing = $ignore_missing;
109
110
        // fill data array with file(s) informations
111 1
        $cache = new Cache($this->builder, (string) $this->builder->getConfig()->get('cache.assets.dir'));
112 1
        $cacheKey = \sprintf('%s__%s', implode('_', $paths), $this->builder->getVersion());
113 1
        if (!$cache->has($cacheKey)) {
114 1
            $pathsCount = count($paths);
115 1
            $file = [];
116 1
            for ($i = 0; $i < $pathsCount; $i++) {
117
                // loads file(s)
118 1
                $file[$i] = $this->loadFile($paths[$i], $ignore_missing, $remote_fallback, $force_slash);
119
                // bundle: same type/ext only
120 1
                if ($i > 0) {
121 1
                    if ($file[$i]['type'] != $file[$i - 1]['type']) {
122
                        throw new RuntimeException(\sprintf('Asset bundle type error (%s != %s).', $file[$i]['type'], $file[$i - 1]['type']));
123
                    }
124 1
                    if ($file[$i]['ext'] != $file[$i - 1]['ext']) {
125
                        throw new RuntimeException(\sprintf('Asset bundle extension error (%s != %s).', $file[$i]['ext'], $file[$i - 1]['ext']));
126
                    }
127
                }
128
                // missing allowed = empty path
129 1
                if ($file[$i]['missing']) {
130 1
                    $this->data['missing'] = true;
131 1
                    $this->data['path'] = $file[$i]['path'];
132
133 1
                    continue;
134
                }
135
                // set data
136 1
                $this->data['size'] += $file[$i]['size'];
137 1
                $this->data['content_source'] .= $file[$i]['content'];
138 1
                $this->data['content'] .= $file[$i]['content'];
139 1
                if ($i == 0) {
140 1
                    $this->data['file'] = $file[$i]['filepath'];
141 1
                    $this->data['filename'] = $file[$i]['path'];
142 1
                    $this->data['path_source'] = $file[$i]['path'];
143 1
                    $this->data['path'] = $file[$i]['path'];
144 1
                    if (!empty($filename)) { /** @phpstan-ignore-line */
145 1
                        $this->data['path'] = '/' . ltrim($filename, '/');
146
                    }
147 1
                    $this->data['url'] = $file[$i]['url'];
148 1
                    $this->data['ext'] = $file[$i]['ext'];
149 1
                    $this->data['type'] = $file[$i]['type'];
150 1
                    $this->data['subtype'] = $file[$i]['subtype'];
151 1
                    if ($this->data['type'] == 'image') {
152 1
                        $this->data['width'] = $this->getWidth();
153 1
                        $this->data['height'] = $this->getHeight();
154 1
                        if ($this->data['subtype'] == 'jpeg') {
155
                            $this->data['exif'] = Util\File::readExif($file[$i]['filepath']);
156
                        }
157
                    }
158
                }
159
                // bundle files path
160 1
                $this->data['files'][] = $file[$i]['filepath'];
161
            }
162
            // bundle: define path
163 1
            if ($pathsCount > 1 && empty($filename)) { /** @phpstan-ignore-line */
164
                switch ($this->data['ext']) {
165
                    case 'scss':
166
                    case 'css':
167
                        $this->data['path'] = '/styles.' . $file[0]['ext'];
168
                        break;
169
                    case 'js':
170
                        $this->data['path'] = '/scripts.' . $file[0]['ext'];
171
                        break;
172
                    default:
173
                        throw new RuntimeException(\sprintf('Asset bundle supports "%s" files only.', '.scss, .css and .js'));
174
                }
175
            }
176 1
            $cache->set($cacheKey, $this->data);
177
        }
178 1
        $this->data = $cache->get($cacheKey);
179
180
        // fingerprinting
181 1
        if ($fingerprint) {
182 1
            $this->fingerprint();
183
        }
184
        // compiling (Sass files)
185 1
        if ((bool) $this->config->get('assets.compile.enabled')) {
186 1
            $this->compile();
187
        }
188
        // minifying (CSS and JavScript files)
189 1
        if ($minify) {
190 1
            $this->minify();
191
        }
192
        // optimizing (images files)
193 1
        if ($optimize) {
194 1
            $this->optimize = true;
195
        }
196
    }
197
198
    /**
199
     * Returns path.
200
     *
201
     * @throws RuntimeException
202
     */
203 1
    public function __toString(): string
204
    {
205
        try {
206 1
            $this->save();
207
        } catch (\Exception $e) {
208
            $this->builder->getLogger()->error($e->getMessage());
209
        }
210
211 1
        if ($this->isImageInCdn()) {
212
            return $this->buildImageCdnUrl();
213
        }
214
215
        if ($this->builder->getConfig()->get('canonicalurl')) {
216
            return (string) new Url($this->builder, $this->data['path'], ['canonical' => true]);
217
        }
218
219
        return $this->data['path'];
220
    }
221
222
    /**
223
     * Fingerprints a file.
224
     */
225
    public function fingerprint(): self
226
    {
227
        if ($this->fingerprinted) {
228
            return $this;
229
        }
230
231 1
        $fingerprint = hash('md5', $this->data['content_source']);
232
        $this->data['path'] = preg_replace(
233
            '/\.' . $this->data['ext'] . '$/m',
234
            ".$fingerprint." . $this->data['ext'],
235 1
            $this->data['path']
236
        );
237
238
        $this->fingerprinted = true;
239
240
        return $this;
241 1
    }
242
243 1
    /**
244 1
     * Compiles a SCSS.
245
     *
246
     * @throws RuntimeException
247 1
     */
248 1
    public function compile(): self
249 1
    {
250 1
        if ($this->compiled) {
251 1
            return $this;
252 1
        }
253
254 1
        if ($this->data['ext'] != 'scss') {
255
            return $this;
256 1
        }
257
258
        $cache = new Cache($this->builder, (string) $this->builder->getConfig()->get('cache.assets.dir'));
259
        $cacheKey = $cache->createKeyFromAsset($this, ['compiled']);
260
        if (!$cache->has($cacheKey)) {
261
            $scssPhp = new Compiler();
262
            $importDir = [];
263
            $importDir[] = Util::joinPath($this->config->getStaticPath());
264 1
            $importDir[] = Util::joinPath($this->config->getAssetsPath());
265
            $scssDir = $this->config->get('assets.compile.import') ?? [];
266 1
            $themes = $this->config->getTheme() ?? [];
267 1
            foreach ($scssDir as $dir) {
268
                $importDir[] = Util::joinPath($this->config->getStaticPath(), $dir);
269
                $importDir[] = Util::joinPath($this->config->getAssetsPath(), $dir);
270 1
                $importDir[] = Util::joinPath(dirname($this->data['file']), $dir);
271 1
                foreach ($themes as $theme) {
272
                    $importDir[] = Util::joinPath($this->config->getThemeDirPath($theme, "static/$dir"));
273
                    $importDir[] = Util::joinPath($this->config->getThemeDirPath($theme, "assets/$dir"));
274 1
                }
275 1
            }
276 1
            $scssPhp->setImportPaths(array_unique($importDir));
277 1
            // source map
278 1
            if ($this->builder->isDebug() && (bool) $this->config->get('assets.compile.sourcemap')) {
279 1
                $importDir = [];
280 1
                $assetDir = (string) $this->config->get('assets.dir');
281 1
                $assetDirPos = strrpos($this->data['file'], DIRECTORY_SEPARATOR . $assetDir . DIRECTORY_SEPARATOR);
282 1
                $fileRelPath = substr($this->data['file'], $assetDirPos + 8);
283 1
                $filePath = Util::joinFile($this->config->getOutputPath(), $fileRelPath);
284 1
                $importDir[] = dirname($filePath);
285 1
                foreach ($scssDir as $dir) {
286 1
                    $importDir[] = Util::joinFile($this->config->getOutputPath(), $dir);
287 1
                }
288 1
                $scssPhp->setImportPaths(array_unique($importDir));
289 1
                $scssPhp->setSourceMap(Compiler::SOURCE_MAP_INLINE);
290
                $scssPhp->setSourceMapOptions([
291
                    'sourceMapBasepath' => Util::joinPath($this->config->getOutputPath()),
292 1
                    'sourceRoot'        => '/',
293
                ]);
294 1
            }
295
            // output style
296
            $outputStyles = ['expanded', 'compressed'];
297
            $outputStyle = strtolower((string) $this->config->get('assets.compile.style'));
298
            if (!in_array($outputStyle, $outputStyles)) {
299
                throw new RuntimeException(\sprintf('Scss output style "%s" doesn\'t exists.', $outputStyle));
300
            }
301
            $scssPhp->setOutputStyle($outputStyle);
302
            // variables
303
            $variables = $this->config->get('assets.compile.variables') ?? [];
304
            if (!empty($variables)) {
305
                $variables = array_map('ScssPhp\ScssPhp\ValueConverter::parseValue', $variables);
306
                $scssPhp->replaceVariables($variables);
307
            }
308
            // update data
309
            $this->data['path'] = preg_replace('/sass|scss/m', 'css', $this->data['path']);
310
            $this->data['ext'] = 'css';
311
            $this->data['type'] = 'text';
312 1
            $this->data['subtype'] = 'text/css';
313 1
            $this->data['content'] = $scssPhp->compileString($this->data['content'])->getCss();
314 1
            $this->data['size'] = strlen($this->data['content']);
315
            $this->compiled = true;
316
            $cache->set($cacheKey, $this->data);
317 1
        }
318
        $this->data = $cache->get($cacheKey);
319 1
320 1
        return $this;
321 1
    }
322 1
323
    /**
324
     * Minifying a CSS or a JS.
325 1
     *
326 1
     * @throws RuntimeException
327 1
     */
328 1
    public function minify(): self
329 1
    {
330 1
        // disable minify to preserve inline source map
331 1
        if ($this->builder->isDebug() && (bool) $this->config->get('assets.compile.sourcemap')) {
332 1
            return $this;
333
        }
334 1
335
        if ($this->minified) {
336 1
            return $this;
337
        }
338
339
        if ($this->data['ext'] == 'scss') {
340
            $this->compile();
341
        }
342
343
        if ($this->data['ext'] != 'css' && $this->data['ext'] != 'js') {
344 1
            return $this;
345
        }
346
347 1
        if (substr($this->data['path'], -8) == '.min.css' || substr($this->data['path'], -7) == '.min.js') {
348
            $this->minified;
349
350
            return $this;
351 1
        }
352 1
353
        $cache = new Cache($this->builder, (string) $this->builder->getConfig()->get('cache.assets.dir'));
354
        $cacheKey = $cache->createKeyFromAsset($this, ['minified']);
355 1
        if (!$cache->has($cacheKey)) {
356
            switch ($this->data['ext']) {
357
                case 'css':
358
                    $minifier = new Minify\CSS($this->data['content']);
359 1
                    break;
360 1
                case 'js':
361
                    $minifier = new Minify\JS($this->data['content']);
362
                    break;
363 1
                default:
364 1
                    throw new RuntimeException(\sprintf('Not able to minify "%s"', $this->data['path']));
365
            }
366 1
            $this->data['path'] = preg_replace(
367
                '/\.' . $this->data['ext'] . '$/m',
368
                '.min.' . $this->data['ext'],
369 1
                $this->data['path']
370 1
            );
371 1
            $this->data['content'] = $minifier->minify();
372 1
            $this->data['size'] = strlen($this->data['content']);
373 1
            $this->minified = true;
374 1
            $cache->set($cacheKey, $this->data);
375 1
        }
376 1
        $this->data = $cache->get($cacheKey);
377 1
378 1
        return $this;
379
    }
380
381
    /**
382 1
     * Optimizing an image.
383 1
     */
384 1
    public function optimize(string $filepath): self
385 1
    {
386 1
        if ($this->data['type'] != 'image') {
387 1
            return $this;
388 1
        }
389 1
390 1
        $quality = $this->config->get('assets.images.quality') ?? 75;
391
        $cache = new Cache($this->builder, (string) $this->builder->getConfig()->get('cache.assets.dir'));
392 1
        $tags = ["q$quality", 'optimized'];
393
        if ($this->data['width']) {
394 1
            array_unshift($tags, "{$this->data['width']}x");
395
        }
396
        $cacheKey = $cache->createKeyFromAsset($this, $tags);
397
        if (!$cache->has($cacheKey)) {
398
            $message = $this->data['path'];
399
            $sizeBefore = filesize($filepath);
400 1
            Optimizer::create($quality)->optimize($filepath);
401
            $sizeAfter = filesize($filepath);
402 1
            if ($sizeAfter < $sizeBefore) {
403 1
                $message = \sprintf(
404
                    '%s (%s Ko -> %s Ko)',
405
                    $message,
406 1
                    ceil($sizeBefore / 1000),
407 1
                    ceil($sizeAfter / 1000)
408 1
                );
409 1
            }
410 1
            $this->data['content'] = Util\File::fileGetContents($filepath);
411
            $this->data['size'] = $sizeAfter;
412 1
            $cache->set($cacheKey, $this->data);
413 1
            $this->builder->getLogger()->debug(\sprintf('Asset "%s" optimized', $message));
414 1
        }
415 1
        $this->data = $cache->get($cacheKey);
416 1
        $this->optimized = true;
417 1
418 1
        return $this;
419
    }
420
421
    /**
422
     * Resizes an image with a new $width.
423
     *
424
     * @throws RuntimeException
425
     */
426 1
    public function resize(int $width): self
427 1
    {
428 1
        if ($this->data['missing']) {
429 1
            throw new RuntimeException(\sprintf('Not able to resize "%s": file not found', $this->data['path']));
430
        }
431 1
        if ($this->data['type'] != 'image') {
432 1
            throw new RuntimeException(\sprintf('Not able to resize "%s": not an image', $this->data['path']));
433
        }
434 1
        if ($width >= $this->data['width']) {
435
            return $this;
436
        }
437
438
        $assetResized = clone $this;
439
        $assetResized->data['width'] = $width;
440
441
        $quality = $this->config->get('assets.images.quality');
442 1
        $cache = new Cache($this->builder, (string) $this->builder->getConfig()->get('cache.assets.dir'));
443
        $cacheKey = $cache->createKeyFromAsset($assetResized, ["{$width}x", "q$quality"]);
444 1
        if (!$cache->has($cacheKey)) {
445
            if ($assetResized->data['type'] !== 'image') {
446
                throw new RuntimeException(\sprintf('Not able to resize "%s"', $assetResized->data['path']));
447 1
            }
448
            if (!extension_loaded('gd')) {
449
                throw new RuntimeException('GD extension is required to use images resize.');
450 1
            }
451
452
            try {
453
                $img = ImageManager::make($assetResized->data['content_source']);
454 1
                $img->resize($width, null, function (\Intervention\Image\Constraint $constraint) {
455 1
                    $constraint->aspectRatio();
456
                    $constraint->upsize();
457 1
                });
458 1
            } catch (\Exception $e) {
459 1
                throw new RuntimeException(\sprintf('Not able to resize image "%s": %s', $assetResized->data['path'], $e->getMessage()));
460 1
            }
461 1
            $assetResized->data['path'] = '/' . Util::joinPath(
462
                (string) $this->config->get('assets.target'),
463
                (string) $this->config->get('assets.images.resize.dir'),
464 1
                (string) $width,
465
                $assetResized->data['path']
466
            );
467
468
            try {
469 1
                $assetResized->data['content'] = (string) $img->encode($assetResized->data['ext'], $quality);
470 1
                $assetResized->data['height'] = $assetResized->getHeight();
471 1
                $assetResized->data['size'] = strlen($assetResized->data['content']);
472 1
            } catch (\Exception $e) {
473 1
                throw new RuntimeException(\sprintf('Not able to encode image "%s": %s', $assetResized->data['path'], $e->getMessage()));
474
            }
475
476
            $cache->set($cacheKey, $assetResized->data);
477 1
        }
478 1
        $assetResized->data = $cache->get($cacheKey);
479 1
480 1
        return $assetResized;
481 1
    }
482 1
483
    /**
484
     * Converts an image asset to WebP format.
485 1
     *
486 1
     * @throws RuntimeException
487 1
     */
488
    public function webp(?int $quality = null): self
489
    {
490
        if ($this->data['type'] !== 'image') {
491
            throw new RuntimeException(\sprintf('can\'t convert "%s" (%s) to WebP: it\'s not an image file.', $this->data['path'], $this->data['type']));
492 1
        }
493
494 1
        if ($quality === null) {
495
            $quality = (int) $this->config->get('assets.images.quality') ?? 75;
496 1
        }
497
498
        $assetWebp = clone $this;
499
        $format = 'webp';
500
        $image = ImageManager::make($assetWebp['content']);
501
        $assetWebp['content'] = (string) $image->encode($format, $quality);
502
        $assetWebp['path'] = preg_replace('/\.' . $this->data['ext'] . '$/m', ".$format", $this->data['path']);
503
        $assetWebp['ext'] = $format;
504 1
        $assetWebp['subtype'] = "image/$format";
505
        $assetWebp['size'] = strlen($assetWebp['content']);
0 ignored issues
show
Bug introduced by
It seems like $assetWebp['content'] can also be of type null; however, parameter $string of strlen() does only seem to accept string, maybe add an additional type check? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

505
        $assetWebp['size'] = strlen(/** @scrutinizer ignore-type */ $assetWebp['content']);
Loading history...
506 1
507
        return $assetWebp;
508
    }
509
510 1
    /**
511 1
     * Implements \ArrayAccess.
512
     */
513
    #[\ReturnTypeWillChange]
514 1
    public function offsetSet($offset, $value): void
515 1
    {
516 1
        if (!is_null($offset)) {
517 1
            $this->data[$offset] = $value;
518
        }
519
    }
520
521
    /**
522
     * Implements \ArrayAccess.
523
     */
524
    #[\ReturnTypeWillChange]
525
    public function offsetExists($offset): bool
526
    {
527
        return isset($this->data[$offset]);
528
    }
529
530
    /**
531
     * Implements \ArrayAccess.
532
     */
533
    #[\ReturnTypeWillChange]
534
    public function offsetUnset($offset): void
535
    {
536
        unset($this->data[$offset]);
537
    }
538
539
    /**
540
     * Implements \ArrayAccess.
541 1
     */
542
    #[\ReturnTypeWillChange]
543 1
    public function offsetGet($offset)
544
    {
545
        return isset($this->data[$offset]) ? $this->data[$offset] : null;
546
    }
547
548
    /**
549
     * Hashing content of an asset with the specified algo, sha384 by default.
550
     * Used for SRI (Subresource Integrity).
551
     *
552
     * @see https://developer.mozilla.org/fr/docs/Web/Security/Subresource_Integrity
553
     */
554
    public function getIntegrity(string $algo = 'sha384'): string
555
    {
556
        return \sprintf('%s-%s', $algo, base64_encode(hash($algo, $this->data['content'], true)));
557
    }
558
559 1
    /**
560
     * Returns MP3 file infos.
561 1
     *
562
     * @see https://github.com/wapmorgan/Mp3Info
563
     */
564
    public function getAudio(): Mp3Info
565
    {
566
        if ($this->data['type'] !== 'audio') {
567
            throw new RuntimeException(\sprintf('Not able to get audio infos of "%s"', $this->data['path']));
568
        }
569
570 1
        return new Mp3Info($this->data['file']);
571
    }
572 1
573
    /**
574
     * Returns the data URL of an image.
575
     *
576
     * @throws RuntimeException
577
     */
578
    public function dataurl(): string
579
    {
580
        if ($this->data['type'] !== 'image') {
581
            throw new RuntimeException(\sprintf('Can\'t get data URL of "%s"', $this->data['path']));
582
        }
583
584
        return (string) ImageManager::make($this->data['content'])->encode('data-url', $this->config->get('assets.images.quality'));
585
    }
586
587
    /**
588
     * Saves file.
589
     * Note: a file from `static/` with the same name will NOT be overridden.
590
     *
591
     * @throws RuntimeException
592
     */
593
    public function save(): void
594 1
    {
595
        $filepath = Util::joinFile($this->config->getOutputPath(), $this->data['path']);
596 1
        if (!$this->builder->getBuildOptions()['dry-run'] && !Util\File::getFS()->exists($filepath)) {
597
            try {
598
                Util\File::getFS()->dumpFile($filepath, $this->data['content']);
599
                $this->builder->getLogger()->debug(\sprintf('Asset "%s" saved', $this->data['path']));
600 1
                if ($this->optimize) {
601
                    $this->optimize($filepath);
602
                }
603
            } catch (\Symfony\Component\Filesystem\Exception\IOException $e) {
604
                if (!$this->ignore_missing) {
605
                    throw new RuntimeException(\sprintf('Can\'t save asset "%s"', $filepath));
606
                }
607
            }
608
        }
609 1
    }
610
611 1
    /**
612 1
     * Is Asset is an image in CDN.
613
     *
614 1
     * @return boolean
615 1
     */
616 1
    public function isImageInCdn()
617 1
    {
618
        return $this->data['type'] == 'image' && !$this->isSVG() && (bool) $this->config->get('assets.images.cdn.enabled');
619
    }
620
621
    /**
622
     * Load file data.
623
     *
624
     * @throws RuntimeException
625
     */
626
    private function loadFile(string $path, bool $ignore_missing = false, ?string $remote_fallback = null, bool $force_slash = true): array
627
    {
628
        $file = [
629
            'url' => null,
630
        ];
631
632 1
        if (false === $filePath = $this->findFile($path, $remote_fallback)) {
633
            if ($ignore_missing) {
634 1
                $file['path'] = $path;
635
                $file['missing'] = true;
636
637
                return $file;
638
            }
639
640
            throw new RuntimeException(\sprintf('Asset file "%s" doesn\'t exist', $path));
641
        }
642 1
643
        if (Util\Url::isUrl($path)) {
644 1
            $file['url'] = $path;
645 1
            $path = Util\File::getFS()->makePathRelative($filePath, $this->config->getCacheAssetsRemotePath());
646 1
            $path = Util::joinPath((string) $this->config->get('assets.target'), $path);
647
            $force_slash = true;
648 1
        }
649 1
        if ($force_slash) {
650 1
            $path = '/' . ltrim($path, '/');
651 1
        }
652
653 1
        list($type, $subtype) = Util\File::getMimeType($filePath);
654
        $content = Util\File::fileGetContents($filePath);
655
656 1
        $file['filepath'] = $filePath;
657
        $file['path'] = $path;
658
        $file['ext'] = pathinfo($path)['extension'] ?? '';
659 1
        $file['type'] = $type;
660 1
        $file['subtype'] = $subtype;
661 1
        $file['size'] = filesize($filePath);
662 1
        $file['content'] = $content;
663 1
        $file['missing'] = false;
664
665 1
        return $file;
666 1
    }
667
668
    /**
669 1
     * Try to find the file:
670 1
     *   1. remote (if $path is a valid URL)
671
     *   2. in static/
672 1
     *   3. in themes/<theme>/static/
673 1
     * Returns local file path or false if file don't exists.
674 1
     *
675 1
     * @throws RuntimeException
676 1
     *
677 1
     * @return string|false
678 1
     */
679 1
    private function findFile(string $path, ?string $remote_fallback = null)
680
    {
681 1
        // in case of remote file: save it and returns cached file path
682
        if (Util\Url::isUrl($path)) {
683
            $url = $path;
684
            $urlHost = parse_url($path, PHP_URL_HOST);
685
            $urlPath = parse_url($path, PHP_URL_PATH);
686
            $urlQuery = parse_url($path, PHP_URL_QUERY);
687
            $extension = pathinfo(parse_url($url, PHP_URL_PATH), PATHINFO_EXTENSION);
688
            // Google Fonts hack
689
            if (strpos($urlPath, '/css') !== false) {
690
                $extension = 'css';
691
            }
692
            $relativePath = Page::slugify(\sprintf(
693
                '%s%s%s%s',
694
                $urlHost,
695 1
                $this->sanitize($urlPath),
696
                $urlQuery ? "-$urlQuery" : '',
697
                $urlQuery && $extension ? " . $extension" : ''
698 1
            ));
699 1
            $filePath = Util::joinFile($this->config->getCacheAssetsRemotePath(), $relativePath);
700 1
            if (!file_exists($filePath)) {
701 1
                if (!Util\Url::isRemoteFileExists($url)) {
702 1
                    // is there a fallback in assets/
703 1
                    if ($remote_fallback) {
704
                        $filePath = Util::joinFile($this->config->getAssetsPath(), $remote_fallback);
705 1
                        if (Util\File::getFS()->exists($filePath)) {
706 1
                            return $filePath;
707
                        }
708 1
                    }
709 1
710 1
                    return false;
711 1
                }
712 1
                if (false === $content = Util\File::fileGetContents($url, true)) {
713 1
                    return false;
714 1
                }
715 1
                if (strlen($content) <= 1) {
716 1
                    throw new RuntimeException(\sprintf('Asset at "%s" is empty', $url));
717 1
                }
718
                Util\File::getFS()->dumpFile($filePath, $content);
719 1
            }
720 1
721 1
            return $filePath;
722
        }
723
724
        // checks in assets/
725
        $filePath = Util::joinFile($this->config->getAssetsPath(), $path);
726 1
        if (Util\File::getFS()->exists($filePath)) {
727
            return $filePath;
728 1
        }
729
730
        // checks in each themes/<theme>/assets/
731 1
        foreach ($this->config->getTheme() as $theme) {
732
            $filePath = Util::joinFile($this->config->getThemeDirPath($theme, 'assets'), $path);
733
            if (Util\File::getFS()->exists($filePath)) {
734 1
                return $filePath;
735
            }
736
        }
737 1
738
        // checks in static/
739
        $filePath = Util::joinFile($this->config->getStaticTargetPath(), $path);
740
        if (Util\File::getFS()->exists($filePath)) {
741 1
            return $filePath;
742 1
        }
743 1
744
        // checks in each themes/<theme>/static/
745
        foreach ($this->config->getTheme() as $theme) {
746
            $filePath = Util::joinFile($this->config->getThemeDirPath($theme, 'static'), $path);
747 1
            if (Util\File::getFS()->exists($filePath)) {
748 1
                return $filePath;
749 1
            }
750
        }
751
752
        return false;
753
    }
754
755 1
    /**
756 1
     * Returns the width of an image/SVG.
757 1
     *
758
     * @throws RuntimeException
759
     */
760
    private function getWidth(): int
761 1
    {
762 1
        if ($this->data['type'] != 'image') {
763 1
            return 0;
764 1
        }
765
        if ($this->isSVG() && false !== $svg = $this->getSvgAttributes()) {
766
            return (int) $svg->width;
767
        }
768 1
        if (false === $size = $this->getImageSize()) {
769
            throw new RuntimeException(\sprintf('Not able to get width of "%s"', $this->data['path']));
770
        }
771
772
        return $size[0];
773
    }
774
775
    /**
776 1
     * Returns the height of an image/SVG.
777
     *
778 1
     * @throws RuntimeException
779
     */
780
    private function getHeight(): int
781 1
    {
782 1
        if ($this->data['type'] != 'image') {
783
            return 0;
784 1
        }
785
        if ($this->isSVG() && false !== $svg = $this->getSvgAttributes()) {
786
            return (int) $svg->height;
787
        }
788 1
        if (false === $size = $this->getImageSize()) {
789
            throw new RuntimeException(\sprintf('Not able to get height of "%s"', $this->data['path']));
790
        }
791
792
        return $size[1];
793
    }
794
795
    /**
796 1
     * Returns image size informations.
797
     *
798 1
     * @see https://www.php.net/manual/function.getimagesize.php
799
     *
800
     * @return array|false
801 1
     */
802 1
    private function getImageSize()
803
    {
804 1
        if (!$this->data['type'] == 'image') {
805
            return false;
806
        }
807
808 1
        try {
809
            if (false === $size = getimagesizefromstring($this->data['content'])) {
810
                return false;
811
            }
812
        } catch (\Exception $e) {
813
            throw new RuntimeException(\sprintf('Handling asset "%s" failed: "%s"', $this->data['path_source'], $e->getMessage()));
814
        }
815
816
        return $size;
817
    }
818 1
819
    /**
820 1
     * Returns true if asset is a SVG.
821
     */
822
    private function isSVG(): bool
823
    {
824
        return in_array($this->data['subtype'], ['image/svg', 'image/svg+xml']) || $this->data['ext'] == 'svg';
825 1
    }
826 1
827
    /**
828
     * Returns SVG attributes.
829
     *
830
     * @return \SimpleXMLElement|false
831
     */
832 1
    private function getSvgAttributes()
833
    {
834
        if (false === $xml = simplexml_load_string($this->data['content_source'])) {
835
            return false;
836
        }
837
838 1
        return $xml->attributes();
839
    }
840 1
841
    /**
842
     * Replaces some characters by '_'.
843
     */
844
    private function sanitize(string $string): string
845
    {
846
        return str_replace(['<', '>', ':', '"', '\\', '|', '?', '*'], '_', $string);
847
    }
848 1
849
    /**
850 1
     * Builds CDN image URL.
851
     */
852
    private function buildImageCdnUrl(): string
853
    {
854 1
        return str_replace(
855
            [
856
                '%account%',
857
                '%image_url%',
858
                '%width%',
859
                '%quality%',
860 1
                '%format%',
861
            ],
862 1
            [
863
                $this->config->get('assets.images.cdn.account'),
864
                $this->data['url'] ?? (string) new Url($this->builder, $this->data['path'], ['canonical' => true]),
865
                $this->data['width'],
866
                $this->config->get('assets.images.quality') ?? 75,
867
                $this->data['ext'],
868
            ],
869
            (string) $this->config->get('assets.images.cdn.url')
870
        );
871
    }
872
}
873