Passed
Push — 8.x-dev ( ef2645...dca549 )
by Arnaud
03:23
created

Asset::__construct()   D

Complexity

Conditions 26
Paths 80

Size

Total Lines 133
Code Lines 94

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 84
CRAP Score 27.0497

Importance

Changes 8
Bugs 5 Features 1
Metric Value
cc 26
eloc 94
c 8
b 5
f 1
nc 80
nop 3
dl 0
loc 133
ccs 84
cts 95
cp 0.8842
crap 27.0497
rs 4.1666

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