Passed
Push — cache ( 90404a...dd49de )
by Arnaud
03:49
created

Asset::locateFile()   D

Complexity

Conditions 20
Paths 50

Size

Total Lines 82
Code Lines 47

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 20
eloc 47
c 0
b 0
f 0
nc 50
nop 2
dl 0
loc 82
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\ConfigException;
21
use Cecil\Exception\RuntimeException;
22
use Cecil\Util;
23
use MatthiasMullie\Minify;
24
use ScssPhp\ScssPhp\Compiler;
25
use ScssPhp\ScssPhp\OutputStyle;
26
use wapmorgan\Mp3Info\Mp3Info;
27
28
class Asset implements \ArrayAccess
29
{
30
    /** @var Builder */
31
    protected $builder;
32
33
    /** @var Config */
34
    protected $config;
35
36
    /** @var array */
37
    protected $data = [];
38
39
    /** @var bool */
40
    protected $fingerprinted = false;
41
42
    /** @var bool */
43
    protected $compiled = false;
44
45
    /** @var bool */
46
    protected $minified = false;
47
48
    /** @var bool */
49
    protected $ignore_missing = false;
50
51
    /**
52
     * Creates an Asset from a file path, an array of files path or an URL.
53
     *
54
     * @param Builder      $builder
55
     * @param string|array $paths
56
     * @param array|null   $options e.g.: ['fingerprint' => true, 'minify' => true, 'filename' => '', 'ignore_missing' => false]
57
     *
58
     * @throws RuntimeException
59
     */
60
    public function __construct(Builder $builder, string|array $paths, array|null $options = null)
61
    {
62
        $this->builder = $builder;
63
        $this->config = $builder->getConfig();
64
        $paths = \is_array($paths) ? $paths : [$paths];
65
        array_walk($paths, function ($path) {
66
            if (!\is_string($path)) {
67
                throw new RuntimeException(\sprintf('The path of an asset must be a string ("%s" given).', \gettype($path)));
68
            }
69
            if (empty($path)) {
70
                throw new RuntimeException('The path of an asset can\'t be empty.');
71
            }
72
            if (substr($path, 0, 2) == '..') {
73
                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));
74
            }
75
        });
76
        $this->data = [
77
            'file'           => '',    // absolute file path
78
            'files'          => [],    // bundle: array of files path
79
            'filename'       => '',    // bundle: filename
80
            'path'           => '',    // public path to the file
81
            'url'            => null,  // URL if it's a remote file
82
            'missing'        => false, // if file not found but missing allowed: 'missing' is true
83
            'ext'            => '',    // file extension
84
            'type'           => '',    // file type (e.g.: image, audio, video, etc.)
85
            'subtype'        => '',    // file media type (e.g.: image/png, audio/mp3, etc.)
86
            'size'           => 0,     // file size (in bytes)
87
            'width'          => 0,     // image width (in pixels)
88
            'height'         => 0,     // image height (in pixels)
89
            'exif'           => [],    // exif data
90
            'content'        => '',    // file content
91
        ];
92
93
        // handles options
94
        $fingerprint = (bool) $this->config->get('assets.fingerprint.enabled');
95
        $minify = (bool) $this->config->get('assets.minify.enabled');
96
        $optimize = (bool) $this->config->get('assets.images.optimize.enabled');
97
        $filename = '';
98
        $ignore_missing = false;
99
        $remote_fallback = null;
100
        $force_slash = true;
101
        extract(\is_array($options) ? $options : [], EXTR_IF_EXISTS);
102
        $this->ignore_missing = $ignore_missing;
103
104
        // fill data array with file(s) informations
105
        $cache = new Cache($this->builder, (string) $this->builder->getConfig()->get('cache.assets.dir'));
106
        $cacheKey = \sprintf('%s__%s', $filename ?: implode('_', $paths), $this->builder->getVersion());
107
        if (!$cache->has($cacheKey)) {
108
            $pathsCount = \count($paths);
109
            $file = [];
110
            for ($i = 0; $i < $pathsCount; $i++) {
111
                // loads file(s)
112
                $file[$i] = $this->loadFile($paths[$i], $ignore_missing, $remote_fallback, $force_slash);
113
                // bundle: same type only
114
                if ($i > 0) {
115
                    if ($file[$i]['type'] != $file[$i - 1]['type']) {
116
                        throw new RuntimeException(\sprintf('Asset bundle type error (%s != %s).', $file[$i]['type'], $file[$i - 1]['type']));
117
                    }
118
                }
119
                // missing allowed = empty path
120
                if ($file[$i]['missing']) {
121
                    $this->data['missing'] = true;
122
                    $this->data['path'] = $file[$i]['path'];
123
124
                    continue;
125
                }
126
                // set data
127
                $this->data['content'] .= $file[$i]['content'];
128
                $this->data['size'] += $file[$i]['size'];
129
                if ($i == 0) {
130
                    $this->data['file'] = $file[$i]['filepath'];
131
                    $this->data['filename'] = $file[$i]['path'];
132
                    $this->data['path'] = $file[$i]['path'];
133
                    $this->data['url'] = $file[$i]['url'];
134
                    $this->data['ext'] = $file[$i]['ext'];
135
                    $this->data['type'] = $file[$i]['type'];
136
                    $this->data['subtype'] = $file[$i]['subtype'];
137
                    // image: width, height and exif
138
                    if ($this->data['type'] == 'image') {
139
                        $this->data['width'] = $this->getWidth();
140
                        $this->data['height'] = $this->getHeight();
141
                        if ($this->data['subtype'] == 'image/jpeg') {
142
                            $this->data['exif'] = Util\File::readExif($file[$i]['filepath']);
143
                        }
144
                    }
145
                    // bundle default filename
146
                    if ($pathsCount > 1 && empty($filename)) {
147
                        switch ($this->data['ext']) {
148
                            case 'scss':
149
                            case 'css':
150
                                $filename = '/styles.css';
151
                                break;
152
                            case 'js':
153
                                $filename = '/scripts.js';
154
                                break;
155
                            default:
156
                                throw new RuntimeException(\sprintf('Asset bundle supports %s files only.', '.scss, .css and .js'));
157
                        }
158
                    }
159
                    // bundle filename and path
160
                    if (!empty($filename)) {
161
                        $this->data['filename'] = $filename;
162
                        $this->data['path'] = '/' . ltrim($filename, '/');
163
                    }
164
                }
165
                // bundle files path
166
                $this->data['files'][] = $file[$i]['filepath'];
167
            }
168
            $cache->set($cacheKey, $this->data);
169
            $this->builder->getLogger()->debug(\sprintf('Asset created: "%s"', $this->data['path']));
170
            // optimizing images files
171
            if ($optimize && $this->data['type'] == 'image') {
172
                $this->optimize($cache->getContentFilePathname($this->data['path']), $this->data['path']);
173
            }
174
        }
175
        $this->data = $cache->get($cacheKey);
176
177
        // fingerprinting
178
        if ($fingerprint) {
179
            $this->fingerprint();
180
        }
181
        // compiling (Sass files)
182
        if ((bool) $this->config->get('assets.compile.enabled')) {
183
            $this->compile();
184
        }
185
        // minifying (CSS and JavScript files)
186
        if ($minify) {
187
            $this->minify();
188
        }
189
    }
190
191
    /**
192
     * Returns path.
193
     *
194
     * @throws RuntimeException
195
     */
196
    public function __toString(): string
197
    {
198
        try {
199
            $this->save();
200
        } catch (RuntimeException $e) {
201
            $this->builder->getLogger()->error($e->getMessage());
202
        }
203
204
        if ($this->isImageInCdn()) {
205
            return $this->buildImageCdnUrl();
206
        }
207
208
        if ($this->builder->getConfig()->get('canonicalurl')) {
209
            return (string) new Url($this->builder, $this->data['path'], ['canonical' => true]);
210
        }
211
212
        return $this->data['path'];
213
    }
214
215
    /**
216
     * Fingerprints a file.
217
     */
218
    public function fingerprint(): self
219
    {
220
        if ($this->fingerprinted) {
221
            return $this;
222
        }
223
224
        $fingerprint = hash('md5', $this->data['content']);
225
        $this->data['path'] = preg_replace(
226
            '/\.' . $this->data['ext'] . '$/m',
227
            ".$fingerprint." . $this->data['ext'],
228
            $this->data['path']
229
        );
230
231
        $this->fingerprinted = true;
232
233
        return $this;
234
    }
235
236
    /**
237
     * Compiles a SCSS.
238
     *
239
     * @throws RuntimeException
240
     */
241
    public function compile(): self
242
    {
243
        if ($this->compiled) {
244
            return $this;
245
        }
246
247
        if ($this->data['ext'] != 'scss') {
248
            return $this;
249
        }
250
251
        $cache = new Cache($this->builder, (string) $this->builder->getConfig()->get('cache.assets.dir'));
252
        $cacheKey = $cache->createKeyFromAsset($this, ['compiled']);
253
        if (!$cache->has($cacheKey)) {
254
            $scssPhp = new Compiler();
255
            // import paths
256
            $importDir = [];
257
            $importDir[] = Util::joinPath($this->config->getStaticPath());
258
            $importDir[] = Util::joinPath($this->config->getAssetsPath());
259
            $scssDir = (array) $this->config->get('assets.compile.import');
260
            $themes = $this->config->getTheme() ?? [];
261
            foreach ($scssDir as $dir) {
262
                $importDir[] = Util::joinPath($this->config->getStaticPath(), $dir);
263
                $importDir[] = Util::joinPath($this->config->getAssetsPath(), $dir);
264
                $importDir[] = Util::joinPath(\dirname($this->data['file']), $dir);
265
                foreach ($themes as $theme) {
266
                    $importDir[] = Util::joinPath($this->config->getThemeDirPath($theme, "static/$dir"));
267
                    $importDir[] = Util::joinPath($this->config->getThemeDirPath($theme, "assets/$dir"));
268
                }
269
            }
270
            $scssPhp->setQuietDeps(true);
271
            $scssPhp->setImportPaths(array_unique($importDir));
272
            // source map
273
            if ($this->builder->isDebug() && (bool) $this->config->get('assets.compile.sourcemap')) {
274
                $importDir = [];
275
                $assetDir = (string) $this->config->get('assets.dir');
276
                $assetDirPos = strrpos($this->data['file'], DIRECTORY_SEPARATOR . $assetDir . DIRECTORY_SEPARATOR);
277
                $fileRelPath = substr($this->data['file'], $assetDirPos + 8);
278
                $filePath = Util::joinFile($this->config->getOutputPath(), $fileRelPath);
279
                $importDir[] = \dirname($filePath);
280
                foreach ($scssDir as $dir) {
281
                    $importDir[] = Util::joinFile($this->config->getOutputPath(), $dir);
282
                }
283
                $scssPhp->setImportPaths(array_unique($importDir));
284
                $scssPhp->setSourceMap(Compiler::SOURCE_MAP_INLINE);
285
                $scssPhp->setSourceMapOptions([
286
                    'sourceMapBasepath' => Util::joinPath($this->config->getOutputPath()),
287
                    'sourceRoot'        => '/',
288
                ]);
289
            }
290
            // output style
291
            $outputStyles = ['expanded', 'compressed'];
292
            $outputStyle = strtolower((string) $this->config->get('assets.compile.style'));
293
            if (!\in_array($outputStyle, $outputStyles)) {
294
                throw new ConfigException(\sprintf('"%s" value must be "%s".', 'assets.compile.style', implode('" or "', $outputStyles)));
295
            }
296
            $scssPhp->setOutputStyle($outputStyle == 'compressed' ? OutputStyle::COMPRESSED : OutputStyle::EXPANDED);
297
            // variables
298
            $variables = $this->config->get('assets.compile.variables');
299
            if (!empty($variables)) {
300
                $variables = array_map('ScssPhp\ScssPhp\ValueConverter::parseValue', $variables);
301
                $scssPhp->replaceVariables($variables);
302
            }
303
            // debug
304
            if ($this->builder->isDebug()) {
305
                $scssPhp->setQuietDeps(false);
306
                $this->builder->getLogger()->debug(\sprintf("SCSS compiler imported paths:\n%s", Util\Str::arrayToList(array_unique($importDir))));
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
            $this->data['subtype'] = 'text/css';
313
            $this->data['content'] = $scssPhp->compileString($this->data['content'])->getCss();
314
            $this->data['size'] = \strlen($this->data['content']);
315
            $this->compiled = true;
316
            $cache->set($cacheKey, $this->data);
317
            $this->builder->getLogger()->debug(\sprintf('Asset compiled: "%s"', $this->data['path']));
318
        }
319
        $this->data = $cache->get($cacheKey);
320
321
        return $this;
322
    }
323
324
    /**
325
     * Minifying a CSS or a JS.
326
     *
327
     * @throws RuntimeException
328
     */
329
    public function minify(): self
330
    {
331
        // disable minify to preserve inline source map
332
        if ($this->builder->isDebug() && (bool) $this->config->get('assets.compile.sourcemap')) {
333
            return $this;
334
        }
335
336
        if ($this->minified) {
337
            return $this;
338
        }
339
340
        if ($this->data['ext'] == 'scss') {
341
            $this->compile();
342
        }
343
344
        if ($this->data['ext'] != 'css' && $this->data['ext'] != 'js') {
345
            return $this;
346
        }
347
348
        if (substr($this->data['path'], -8) == '.min.css' || substr($this->data['path'], -7) == '.min.js') {
349
            $this->minified = true;
350
351
            return $this;
352
        }
353
354
        $cache = new Cache($this->builder, (string) $this->builder->getConfig()->get('cache.assets.dir'));
355
        $cacheKey = $cache->createKeyFromAsset($this, ['minified']);
356
        if (!$cache->has($cacheKey)) {
357
            switch ($this->data['ext']) {
358
                case 'css':
359
                    $minifier = new Minify\CSS($this->data['content']);
360
                    break;
361
                case 'js':
362
                    $minifier = new Minify\JS($this->data['content']);
363
                    break;
364
                default:
365
                    throw new RuntimeException(\sprintf('Not able to minify "%s".', $this->data['path']));
366
            }
367
            $this->data['path'] = preg_replace(
368
                '/\.' . $this->data['ext'] . '$/m',
369
                '.min.' . $this->data['ext'],
370
                $this->data['path']
371
            );
372
            $this->data['content'] = $minifier->minify();
373
            $this->data['size'] = \strlen($this->data['content']);
374
            $this->minified = true;
375
            $cache->set($cacheKey, $this->data);
376
            $this->builder->getLogger()->debug(\sprintf('Asset minified: "%s"', $this->data['path']));
377
        }
378
        $this->data = $cache->get($cacheKey);
379
380
        return $this;
381
    }
382
383
    /**
384
     * Optimizing $filepath image.
385
     * Returns the new file size.
386
     */
387
    public function optimize(string $filepath, string $path): int
388
    {
389
        $quality = $this->config->get('assets.images.quality');
390
        $message = \sprintf('Asset processed: "%s"', $path);
391
        $sizeBefore = filesize($filepath);
392
        Optimizer::create($quality)->optimize($filepath);
0 ignored issues
show
Bug introduced by
It seems like $quality can also be of type null; however, parameter $quality of Cecil\Assets\Image\Optimizer::create() does only seem to accept integer, 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

392
        Optimizer::create(/** @scrutinizer ignore-type */ $quality)->optimize($filepath);
Loading history...
393
        $sizeAfter = filesize($filepath);
394
        if ($sizeAfter < $sizeBefore) {
395
            $message = \sprintf(
396
                'Asset optimized: "%s" (%s Ko -> %s Ko)',
397
                $path,
398
                ceil($sizeBefore / 1000),
399
                ceil($sizeAfter / 1000)
400
            );
401
        }
402
        $this->builder->getLogger()->debug($message);
403
404
        return $sizeAfter;
405
    }
406
407
    /**
408
     * Resizes an image with a new $width.
409
     *
410
     * @throws RuntimeException
411
     */
412
    public function resize(int $width): self
413
    {
414
        if ($this->data['missing']) {
415
            throw new RuntimeException(\sprintf('Not able to resize "%s": file not found.', $this->data['path']));
416
        }
417
        if ($this->data['type'] != 'image') {
418
            throw new RuntimeException(\sprintf('Not able to resize "%s": not an image.', $this->data['path']));
419
        }
420
        if ($width >= $this->data['width']) {
421
            return $this;
422
        }
423
424
        $assetResized = clone $this;
425
        $assetResized->data['width'] = $width;
426
427
        if ($this->isImageInCdn()) {
428
            return $assetResized; // returns asset with the new width only: CDN do the rest of the job
429
        }
430
431
        $quality = $this->config->get('assets.images.quality');
432
        $cache = new Cache($this->builder, (string) $this->builder->getConfig()->get('cache.assets.dir'));
433
        $cacheKey = $cache->createKeyFromAsset($assetResized, ["{$width}x", "q$quality"]);
434
        if (!$cache->has($cacheKey)) {
435
            $assetResized->data['content'] = Image::resize($assetResized, $width, $quality);
0 ignored issues
show
Bug introduced by
It seems like $quality can also be of type null; however, parameter $quality of Cecil\Assets\Image::resize() does only seem to accept integer, 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

435
            $assetResized->data['content'] = Image::resize($assetResized, $width, /** @scrutinizer ignore-type */ $quality);
Loading history...
436
            $assetResized->data['path'] = '/' . Util::joinPath(
437
                (string) $this->config->get('assets.target'),
438
                (string) $this->config->get('assets.images.resize.dir'),
439
                (string) $width,
440
                $assetResized->data['path']
441
            );
442
            $assetResized->data['height'] = $assetResized->getHeight();
443
            $assetResized->data['size'] = \strlen($assetResized->data['content']);
444
445
            $cache->set($cacheKey, $assetResized->data);
446
            $this->builder->getLogger()->debug(\sprintf('Asset resized: "%s" (%sx)', $assetResized->data['path'], $width));
447
        }
448
        $assetResized->data = $cache->get($cacheKey);
449
450
        return $assetResized;
451
    }
452
453
    /**
454
     * Converts an image asset to $format format.
455
     *
456
     * @throws RuntimeException
457
     */
458
    public function convert(string $format, ?int $quality = null): self
459
    {
460
        if ($this->data['type'] != 'image') {
461
            throw new RuntimeException(\sprintf('Not able to convert "%s" (%s) to %s: not an image.', $this->data['path'], $this->data['type'], $format));
462
        }
463
464
        if ($quality === null) {
465
            $quality = (int) $this->config->get('assets.images.quality');
466
        }
467
468
        $asset = clone $this;
469
        $asset['ext'] = $format;
470
471
        if ($this->isImageInCdn()) {
472
            return $asset; // returns the asset with the new extension only: CDN do the rest of the job
473
        }
474
475
        $cache = new Cache($this->builder, (string) $this->builder->getConfig()->get('cache.assets.dir'));
476
        $tags = ["q$quality"];
477
        if ($this->data['width']) {
478
            array_unshift($tags, "{$this->data['width']}x");
479
        }
480
        $cacheKey = $cache->createKeyFromAsset($asset, $tags);
481
        if (!$cache->has($cacheKey)) {
482
            $asset->data['content'] = Image::convert($asset, $format, $quality);
483
            $asset->data['path'] = preg_replace('/\.' . $this->data['ext'] . '$/m', ".$format", $this->data['path']);
484
            $asset->data['subtype'] = "image/$format";
485
            $asset->data['size'] = \strlen($asset->data['content']);
486
            $cache->set($cacheKey, $asset->data);
487
            $this->builder->getLogger()->debug(\sprintf('Asset converted: "%s" (%s -> %s)', $asset->data['path'], $this->data['ext'], $format));
488
        }
489
        $asset->data = $cache->get($cacheKey);
490
491
        return $asset;
492
    }
493
494
    /**
495
     * Converts an image asset to WebP format.
496
     *
497
     * @throws RuntimeException
498
     */
499
    public function webp(?int $quality = null): self
500
    {
501
        return $this->convert('webp', $quality);
502
    }
503
504
    /**
505
     * Converts an image asset to AVIF format.
506
     *
507
     * @throws RuntimeException
508
     */
509
    public function avif(?int $quality = null): self
510
    {
511
        return $this->convert('avif', $quality);
512
    }
513
514
    /**
515
     * Implements \ArrayAccess.
516
     */
517
    #[\ReturnTypeWillChange]
518
    public function offsetSet($offset, $value): void
519
    {
520
        if (!\is_null($offset)) {
521
            $this->data[$offset] = $value;
522
        }
523
    }
524
525
    /**
526
     * Implements \ArrayAccess.
527
     */
528
    #[\ReturnTypeWillChange]
529
    public function offsetExists($offset): bool
530
    {
531
        return isset($this->data[$offset]);
532
    }
533
534
    /**
535
     * Implements \ArrayAccess.
536
     */
537
    #[\ReturnTypeWillChange]
538
    public function offsetUnset($offset): void
539
    {
540
        unset($this->data[$offset]);
541
    }
542
543
    /**
544
     * Implements \ArrayAccess.
545
     */
546
    #[\ReturnTypeWillChange]
547
    public function offsetGet($offset)
548
    {
549
        return isset($this->data[$offset]) ? $this->data[$offset] : null;
550
    }
551
552
    /**
553
     * Hashing content of an asset with the specified algo, sha384 by default.
554
     * Used for SRI (Subresource Integrity).
555
     *
556
     * @see https://developer.mozilla.org/fr/docs/Web/Security/Subresource_Integrity
557
     */
558
    public function getIntegrity(string $algo = 'sha384'): string
559
    {
560
        return \sprintf('%s-%s', $algo, base64_encode(hash($algo, $this->data['content'], true)));
561
    }
562
563
    /**
564
     * Returns MP3 file infos.
565
     *
566
     * @see https://github.com/wapmorgan/Mp3Info
567
     */
568
    public function getAudio(): Mp3Info
569
    {
570
        if ($this->data['type'] !== 'audio') {
571
            throw new RuntimeException(\sprintf('Not able to get audio infos of "%s".', $this->data['path']));
572
        }
573
574
        return new Mp3Info($this->data['file']);
575
    }
576
577
    /**
578
     * Returns MP4 file infos.
579
     *
580
     * @see https://github.com/clwu88/php-read-mp4info
581
     */
582
    public function getVideo(): array
583
    {
584
        if ($this->data['type'] !== 'video') {
585
            throw new RuntimeException(\sprintf('Not able to get video infos of "%s".', $this->data['path']));
586
        }
587
588
        return (array) \Clwu\Mp4::getInfo($this->data['file']);
589
    }
590
591
    /**
592
     * Returns the Data URL (encoded in Base64).
593
     *
594
     * @throws RuntimeException
595
     */
596
    public function dataurl(): string
597
    {
598
        if ($this->data['type'] == 'image' && !Image::isSVG($this)) {
599
            return Image::getDataUrl($this, $this->config->get('assets.images.quality'));
0 ignored issues
show
Bug introduced by
It seems like $this->config->get('assets.images.quality') can also be of type null; however, parameter $quality of Cecil\Assets\Image::getDataUrl() does only seem to accept integer, 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

599
            return Image::getDataUrl($this, /** @scrutinizer ignore-type */ $this->config->get('assets.images.quality'));
Loading history...
600
        }
601
602
        return \sprintf('data:%s;base64,%s', $this->data['subtype'], base64_encode($this->data['content']));
603
    }
604
605
    /**
606
     * Saves file.
607
     * Note: a file from `static/` with the same name will NOT be overridden.
608
     *
609
     * @throws RuntimeException
610
     */
611
    public function save(): void
612
    {
613
        $filepath = Util::joinFile($this->config->getOutputPath(), $this->data['path']);
614
        if (!$this->builder->getBuildOptions()['dry-run'] && !Util\File::getFS()->exists($filepath)) {
615
            try {
616
                Util\File::getFS()->dumpFile($filepath, $this->data['content']);
617
            } catch (\Symfony\Component\Filesystem\Exception\IOException) {
618
                if (!$this->ignore_missing) {
619
                    throw new RuntimeException(\sprintf('Can\'t save asset "%s" to output.', $filepath));
620
                }
621
            }
622
        }
623
    }
624
625
    /**
626
     * Is Asset is an image in CDN.
627
     *
628
     * @return bool
629
     */
630
    public function isImageInCdn()
631
    {
632
        if ($this->data['type'] != 'image' || (bool) $this->config->get('assets.images.cdn.enabled') !== true || (Image::isSVG($this) && (bool) $this->config->get('assets.images.cdn.svg') !== true)) {
633
            return false;
634
        }
635
        // remote image?
636
        if ($this->data['url'] !== null && (bool) $this->config->get('assets.images.cdn.remote') !== true) {
637
            return false;
638
        }
639
640
        return true;
641
    }
642
643
    /**
644
     * Load file data and store theme in $file array.
645
     *
646
     * @throws RuntimeException
647
     *
648
     * @return string[]
649
     */
650
    private function loadFile(string $path, bool $ignore_missing = false, ?string $remote_fallback = null, bool $force_slash = true): array
651
    {
652
        $file = [
653
            'url'      => null,
654
            'filepath' => null,
655
            'path'     => null,
656
            'ext'      => null,
657
            'type'     => null,
658
            'subtype'  => null,
659
            'size'     => null,
660
            'content'  => null,
661
            'missing'  => false,
662
        ];
663
664
        // try to find file locally and returns the file path
665
        try {
666
            $filePath = $this->locateFile($path, $remote_fallback);
667
        } catch (RuntimeException $e) {
668
            if ($ignore_missing) {
669
                $file['path'] = $path;
670
                $file['missing'] = true;
671
672
                return $file;
673
            }
674
675
            throw new RuntimeException(\sprintf('Can\'t load asset file "%s" (%s).', $path, $e->getMessage()));
676
        }
677
678
        // in case of an URL, the file is already stored in cache
679
        if (Util\Url::isUrl($path)) {
680
            $file['url'] = $path;
681
            $path = Util::joinPath(
682
                (string) $this->config->get('assets.target'),
683
                Util\File::getFS()->makePathRelative($filePath, $this->config->getCacheAssetsFilesPath())
684
            );
685
            // trick: remote_fallback file is in assets/, not in cache/assets/files/
686
            if (substr(Util\File::getFS()->makePathRelative($filePath, $this->config->getCacheAssetsFilesPath()), 0, 2) == '..') {
687
                $path = Util::joinPath(
688
                    (string) $this->config->get('assets.target'),
689
                    Util\File::getFS()->makePathRelative($filePath, $this->config->getAssetsPath())
690
                );
691
            }
692
            $force_slash = true;
693
        }
694
695
        // force leading slash?
696
        if ($force_slash) {
697
            $path = '/' . ltrim($path, '/');
698
        }
699
700
        // get content and content type
701
        $content = Util\File::fileGetContents($filePath);
702
        list($type, $subtype) = Util\File::getMediaType($filePath);
703
704
        $file['filepath'] = $filePath;
705
        $file['path'] = $path;
706
        $file['ext'] = pathinfo($path)['extension'] ?? '';
707
        $file['type'] = $type;
708
        $file['subtype'] = $subtype;
709
        $file['size'] = filesize($filePath);
710
        $file['content'] = $content;
711
        $file['missing'] = false;
712
713
        return $file;
714
    }
715
716
    /**
717
     * Try to locate the file:
718
     *   1. remotely (if $path is a valid URL)
719
     *   2. in static|assets/
720
     *   3. in themes/<theme>/static|assets/
721
     * Returns local file path or throw an exception.
722
     *
723
     * @return string local file path
724
     *
725
     * @throws RuntimeException
726
     */
727
    private function locateFile(string $path, ?string $remote_fallback = null): string
728
    {
729
        // in case of a remote file: save it locally and returns its path
730
        if (Util\Url::isUrl($path)) {
731
            $url = $path;
732
            $urlHost = parse_url($path, PHP_URL_HOST);
733
            $urlPath = parse_url($path, PHP_URL_PATH);
734
            $urlQuery = parse_url($path, PHP_URL_QUERY);
735
            $extension = pathinfo(parse_url($url, PHP_URL_PATH), PATHINFO_EXTENSION);
736
            // Google Fonts hack
737
            if (Util\Str::endsWith($urlPath, '/css') || Util\Str::endsWith($urlPath, '/css2')) {
738
                $extension = 'css';
739
            }
740
            $relativePath = Page::slugify(\sprintf(
741
                '%s%s%s%s',
742
                $urlHost,
743
                $this->sanitize($urlPath),
744
                $urlQuery ? "-$urlQuery" : '',
745
                $urlQuery && $extension ? ".$extension" : ''
746
            ));
747
            $filePath = Util::joinFile($this->config->getCacheAssetsFilesPath(), $relativePath);
748
            // save file in cache
749
            if (!file_exists($filePath)) {
750
                try {
751
                    if (!Util\Url::isRemoteFileExists($url)) {
752
                        throw new RuntimeException(\sprintf('File "%s" doesn\'t exists', $url));
753
                    }
754
                    if (false === $content = Util\File::fileGetContents($url, true)) {
755
                        throw new RuntimeException(\sprintf('Can\'t get content of file "%s".', $url));
756
                    }
757
                    if (\strlen($content) <= 1) {
758
                        throw new RuntimeException(\sprintf('File "%s" is empty.', $url));
759
                    }
760
                } catch (RuntimeException $e) {
761
                    // if there is a fallback in assets/ returns it
762
                    if ($remote_fallback) {
763
                        $filePath = Util::joinFile($this->config->getAssetsPath(), $remote_fallback);
764
                        if (Util\File::getFS()->exists($filePath)) {
765
                            return $filePath;
766
                        }
767
768
                        throw new RuntimeException(\sprintf('Fallback file "%s" doesn\'t exists.', $filePath));
769
                    }
770
771
                    throw new RuntimeException($e->getMessage());
772
                }
773
                // store file in cache
774
                Util\File::getFS()->dumpFile($filePath, $content);
775
            }
776
777
            return $filePath;
778
        }
779
780
        // checks in assets/
781
        $filePath = Util::joinFile($this->config->getAssetsPath(), $path);
782
        if (Util\File::getFS()->exists($filePath)) {
783
            return $filePath;
784
        }
785
786
        // checks in each themes/<theme>/assets/
787
        foreach ($this->config->getTheme() ?? [] as $theme) {
788
            $filePath = Util::joinFile($this->config->getThemeDirPath($theme, 'assets'), $path);
789
            if (Util\File::getFS()->exists($filePath)) {
790
                return $filePath;
791
            }
792
        }
793
794
        // checks in static/
795
        $filePath = Util::joinFile($this->config->getStaticTargetPath(), $path);
796
        if (Util\File::getFS()->exists($filePath)) {
797
            return $filePath;
798
        }
799
800
        // checks in each themes/<theme>/static/
801
        foreach ($this->config->getTheme() ?? [] as $theme) {
802
            $filePath = Util::joinFile($this->config->getThemeDirPath($theme, 'static'), $path);
803
            if (Util\File::getFS()->exists($filePath)) {
804
                return $filePath;
805
            }
806
        }
807
808
        throw new RuntimeException(\sprintf('Can\'t find file "%s".', $path));
809
    }
810
811
    /**
812
     * Returns the width of an image/SVG.
813
     *
814
     * @throws RuntimeException
815
     */
816
    private function getWidth(): int
817
    {
818
        if ($this->data['type'] != 'image') {
819
            return 0;
820
        }
821
        if (Image::isSVG($this) && false !== $svg = Image::getSvgAttributes($this)) {
822
            return (int) $svg->width;
823
        }
824
        if (false === $size = $this->getImageSize()) {
825
            throw new RuntimeException(\sprintf('Not able to get width of "%s".', $this->data['path']));
826
        }
827
828
        return $size[0];
829
    }
830
831
    /**
832
     * Returns the height of an image/SVG.
833
     *
834
     * @throws RuntimeException
835
     */
836
    private function getHeight(): int
837
    {
838
        if ($this->data['type'] != 'image') {
839
            return 0;
840
        }
841
        if (Image::isSVG($this) && false !== $svg = Image::getSvgAttributes($this)) {
842
            return (int) $svg->height;
843
        }
844
        if (false === $size = $this->getImageSize()) {
845
            throw new RuntimeException(\sprintf('Not able to get height of "%s".', $this->data['path']));
846
        }
847
848
        return $size[1];
849
    }
850
851
    /**
852
     * Returns image size informations.
853
     *
854
     * @see https://www.php.net/manual/function.getimagesize.php
855
     *
856
     * @return array|false
857
     */
858
    private function getImageSize()
859
    {
860
        if (!$this->data['type'] == 'image') {
861
            return false;
862
        }
863
864
        try {
865
            if (false === $size = getimagesizefromstring($this->data['content'])) {
866
                return false;
867
            }
868
        } catch (\Exception $e) {
869
            throw new RuntimeException(\sprintf('Handling asset "%s" failed: "%s"', $this->data['path'], $e->getMessage()));
870
        }
871
872
        return $size;
873
    }
874
875
    /**
876
     * Replaces some characters by '_'.
877
     */
878
    private function sanitize(string $string): string
879
    {
880
        return str_replace(['<', '>', ':', '"', '\\', '|', '?', '*'], '_', $string);
881
    }
882
883
    /**
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')]), '/'),
899
                $this->data['width'],
900
                $this->config->get('assets.images.quality'),
901
                $this->data['ext'],
902
            ],
903
            (string) $this->config->get('assets.images.cdn.url')
904
        );
905
    }
906
}
907