Asset::__construct()   F
last analyzed

Complexity

Conditions 32
Paths 798

Size

Total Lines 170
Code Lines 115

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 109
CRAP Score 32.7896

Importance

Changes 7
Bugs 1 Features 0
Metric Value
cc 32
eloc 115
c 7
b 1
f 0
nc 798
nop 3
dl 0
loc 170
ccs 109
cts 120
cp 0.9083
crap 32.7896
rs 0.2244

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
/**
4
 * This file is part of Cecil.
5
 *
6
 * (c) Arnaud Ligny <[email protected]>
7
 *
8
 * For the full copyright and license information, please view the LICENSE
9
 * file that was distributed with this source code.
10
 */
11
12
declare(strict_types=1);
13
14
namespace Cecil\Assets;
15
16
use Cecil\Assets\Image\Optimizer;
17
use Cecil\Builder;
18
use Cecil\Collection\Page\Page;
19
use Cecil\Config;
20
use Cecil\Exception\ConfigException;
21
use Cecil\Exception\RuntimeException;
22
use Cecil\Url;
23
use Cecil\Util;
24
use MatthiasMullie\Minify;
25
use ScssPhp\ScssPhp\Compiler;
26
use ScssPhp\ScssPhp\OutputStyle;
27
use wapmorgan\Mp3Info\Mp3Info;
28
29
/**
30
 * Asset class.
31
 *
32
 * Represents an asset (file) in the Cecil project.
33
 * Handles file locating, content reading, compiling, minifying, fingerprinting,
34
 * resizing images, and more.
35
 */
36
class Asset implements \ArrayAccess
37
{
38
    public const IMAGE_THUMB = 'thumbnails';
39
40
    /** @var Builder */
41
    protected $builder;
42
43
    /** @var Config */
44
    protected $config;
45
46
    /** @var array */
47
    protected $data = [];
48
49
    /** @var array Cache tags */
50
    protected $cacheTags = [];
51
52
    /**
53
     * Creates an Asset from a file path, an array of files path or an URL.
54
     * Options:
55
     * [
56
     *     'filename' => <string>,
57
     *     'leading_slash' => <bool>
58
     *     'ignore_missing' => <bool>,
59
     *     'fingerprint' => <bool>,
60
     *     'minify' => <bool>,
61
     *     'optimize' => <bool>,
62
     *     'fallback' => <string>,
63
     *     'useragent' => <string>,
64
     * ]
65
     *
66
     * @param Builder      $builder
67
     * @param string|array $paths
68
     * @param array|null   $options
69
     *
70
     * @throws RuntimeException
71
     */
72 1
    public function __construct(Builder $builder, string|array $paths, array|null $options = null)
73
    {
74 1
        $this->builder = $builder;
75 1
        $this->config = $builder->getConfig();
76 1
        $paths = \is_array($paths) ? $paths : [$paths];
77
        // checks path(s)
78 1
        array_walk($paths, function ($path) {
79
            // must be a string
80 1
            if (!\is_string($path)) {
81
                throw new RuntimeException(\sprintf('The path of an asset must be a string ("%s" given).', \gettype($path)));
82
            }
83
            // can't be empty
84 1
            if (empty($path)) {
85
                throw new RuntimeException('The path of an asset can\'t be empty.');
86
            }
87
            // can't be relative
88 1
            if (substr($path, 0, 2) == '..') {
89
                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));
90
            }
91 1
        });
92 1
        $this->data = [
93 1
            'file'     => '',    // absolute file path
94 1
            'files'    => [],    // array of absolute files path
95 1
            'missing'  => false, // if file not found but missing allowed: 'missing' is true
96 1
            '_path'    => '',    // original path
97 1
            'path'     => '',    // public path
98 1
            'url'      => null,  // URL if it's a remote file
99 1
            'ext'      => '',    // file extension
100 1
            'type'     => '',    // file type (e.g.: image, audio, video, etc.)
101 1
            'subtype'  => '',    // file media type (e.g.: image/png, audio/mp3, etc.)
102 1
            'size'     => 0,     // file size (in bytes)
103 1
            'width'    => 0,     // image width (in pixels)
104 1
            'height'   => 0,     // image height (in pixels)
105 1
            'exif'     => [],    // image exif data
106 1
            'content'  => '',    // file content
107 1
            'hash'     => '',    // file content hash (md5)
108 1
        ];
109
110
        // handles options
111 1
        $options = array_merge(
112 1
            [
113 1
                'filename'       => '',
114 1
                'leading_slash'  => true,
115 1
                'ignore_missing' => false,
116 1
                'fingerprint'    => $this->config->isEnabled('assets.fingerprint'),
117 1
                'minify'         => $this->config->isEnabled('assets.minify'),
118 1
                'optimize'       => $this->config->isEnabled('assets.images.optimize'),
119 1
                'fallback'       => '',
120 1
                'useragent'      => (string) $this->config->get('assets.remote.useragent.default'),
121 1
            ],
122 1
            \is_array($options) ? $options : []
123 1
        );
124
125
        // cache
126 1
        $cache = new Cache($this->builder, 'assets');
127 1
        $locateCacheKey = \sprintf('%s_locate__%s__%s', $options['filename'] ?: implode('_', $paths), $this->builder->getBuildId(), $this->builder->getVersion());
128
129
        // locate file(s) and get content
130 1
        if (!$cache->has($locateCacheKey)) {
131 1
            $pathsCount = \count($paths);
132 1
            for ($i = 0; $i < $pathsCount; $i++) {
133
                try {
134 1
                    $this->data['missing'] = false;
135 1
                    $locate = $this->locateFile($paths[$i], $options['fallback'], $options['useragent']);
136 1
                    $file = $locate['file'];
137 1
                    $path = $locate['path'];
138 1
                    $type = Util\File::getMediaType($file)[0];
139 1
                    if ($i > 0) { // bundle
140 1
                        if ($type != $this->data['type']) {
141
                            throw new RuntimeException(\sprintf('Asset bundle type error (%s != %s).', $type, $this->data['type']));
142
                        }
143
                    }
144 1
                    $this->data['file'] = $file;
145 1
                    $this->data['files'][] = $file;
146 1
                    $this->data['path'] = $path;
147 1
                    $this->data['url'] = Util\File::isRemote($paths[$i]) ? $paths[$i] : null;
148 1
                    $this->data['ext'] = Util\File::getExtension($file);
149 1
                    $this->data['type'] = $type;
150 1
                    $this->data['subtype'] = Util\File::getMediaType($file)[1];
151 1
                    $this->data['size'] += filesize($file);
152 1
                    $this->data['content'] .= Util\File::fileGetContents($file);
153 1
                    $this->data['hash'] = hash('md5', $this->data['content']);
154
                    // bundle default filename
155 1
                    $filename = $options['filename'];
156 1
                    if ($pathsCount > 1 && empty($filename)) {
157 1
                        switch ($this->data['ext']) {
158 1
                            case 'scss':
159 1
                            case 'css':
160 1
                                $filename = 'styles.css';
161 1
                                break;
162 1
                            case 'js':
163 1
                                $filename = 'scripts.js';
164 1
                                break;
165
                            default:
166
                                throw new RuntimeException(\sprintf('Asset bundle supports %s files only.', '.scss, .css and .js'));
167
                        }
168
                    }
169
                    // apply bundle filename to path
170 1
                    if (!empty($filename)) {
171 1
                        $this->data['path'] = $filename;
172
                    }
173
                    // add leading slash
174 1
                    if ($options['leading_slash']) {
175 1
                        $this->data['path'] = '/' . ltrim($this->data['path'], '/');
176
                    }
177 1
                    $this->data['_path'] = $this->data['path'];
178 1
                } catch (RuntimeException $e) {
179 1
                    if ($options['ignore_missing']) {
180 1
                        $this->data['missing'] = true;
181 1
                        continue;
182
                    }
183
                    throw new RuntimeException(
184
                        \sprintf('Can\'t handle asset "%s".', $paths[$i]),
185
                        previous: $e
186
                    );
187
                }
188
            }
189 1
            $cache->set($locateCacheKey, $this->data);
190
        }
191 1
        $this->data = $cache->get($locateCacheKey);
192
193
        // missing
194 1
        if ($this->data['missing']) {
195 1
            return;
196
        }
197
198
        // cache
199 1
        $cache = new Cache($this->builder, 'assets');
200
        // create cache tags from options
201 1
        $this->cacheTags = $options;
202
        // remove unnecessary cache tags
203 1
        unset($this->cacheTags['optimize'], $this->cacheTags['ignore_missing'], $this->cacheTags['fallback'], $this->cacheTags['useragent']);
204 1
        if (!\in_array($this->data['ext'], ['css', 'js', 'scss'])) {
205 1
            unset($this->cacheTags['minify']);
206
        }
207
        // optimize image?
208 1
        $optimize = false;
209 1
        if ($options['optimize'] && $this->data['type'] == 'image' && !$this->isImageInCdn()) {
210 1
            $optimize = true;
211 1
            $quality = (int) $this->config->get('assets.images.quality');
212 1
            $this->cacheTags['quality'] = $quality;
213
        }
214 1
        $cacheKey = $cache->createKeyFromAsset($this, $this->cacheTags);
215 1
        if (!$cache->has($cacheKey)) {
216
            // fingerprinting
217 1
            if ($options['fingerprint']) {
218
                $this->doFingerprint();
219
            }
220
            // compiling Sass files
221 1
            $this->doCompile();
222
            // minifying (CSS and JavaScript files)
223 1
            if ($options['minify']) {
224
                $this->doMinify();
225
            }
226
            // get image width, height and exif
227 1
            if ($this->data['type'] == 'image') {
228 1
                $this->data['width'] = $this->getWidth();
229 1
                $this->data['height'] = $this->getHeight();
230 1
                if ($this->data['subtype'] == 'image/jpeg') {
231 1
                    $this->data['exif'] = Util\File::readExif($this->data['file']);
232
                }
233
            }
234 1
            $cache->set($cacheKey, $this->data, $this->config->get('cache.assets.ttl'));
235 1
            $this->builder->getLogger()->debug(\sprintf('Asset cached: "%s"', $this->data['path']));
236
            // optimizing images files (in cache directory)
237 1
            if ($optimize) {
238 1
                $this->optimize($cache->getContentFilePathname($this->data['path']), $this->data['path'], $quality);
239
            }
240
        }
241 1
        $this->data = $cache->get($cacheKey);
242
    }
243
244
    /**
245
     * Returns path.
246
     */
247 1
    public function __toString(): string
248
    {
249 1
        $this->save();
250
251 1
        if ($this->isImageInCdn()) {
252
            return $this->buildImageCdnUrl();
253
        }
254
255 1
        if ($this->builder->getConfig()->isEnabled('canonicalurl')) {
256
            return (string) new Url($this->builder, $this->data['path'], ['canonical' => true]);
257
        }
258
259 1
        return $this->data['path'];
260
    }
261
262
    /**
263
     * Compiles a SCSS + cache.
264
     *
265
     * @throws RuntimeException
266
     */
267 1
    public function compile(): self
268
    {
269 1
        $this->cacheTags['compile'] = true;
270 1
        $cache = new Cache($this->builder, 'assets');
271 1
        $cacheKey = $cache->createKeyFromAsset($this, $this->cacheTags);
272 1
        if (!$cache->has($cacheKey)) {
273 1
            $this->doCompile();
274 1
            $cache->set($cacheKey, $this->data, $this->config->get('cache.assets.ttl'));
275
        }
276 1
        $this->data = $cache->get($cacheKey);
277
278 1
        return $this;
279
    }
280
281
    /**
282
     * Minifying a CSS or a JS.
283
     */
284 1
    public function minify(): self
285
    {
286 1
        $this->cacheTags['minify'] = true;
287 1
        $cache = new Cache($this->builder, 'assets');
288 1
        $cacheKey = $cache->createKeyFromAsset($this, $this->cacheTags);
289 1
        if (!$cache->has($cacheKey)) {
290 1
            $this->doMinify();
291 1
            $cache->set($cacheKey, $this->data, $this->config->get('cache.assets.ttl'));
292
        }
293 1
        $this->data = $cache->get($cacheKey);
294
295 1
        return $this;
296
    }
297
298
    /**
299
     * Add hash to the file name + cache.
300
     */
301 1
    public function fingerprint(): self
302
    {
303 1
        $this->cacheTags['fingerprint'] = true;
304 1
        $cache = new Cache($this->builder, 'assets');
305 1
        $cacheKey = $cache->createKeyFromAsset($this, $this->cacheTags);
306 1
        if (!$cache->has($cacheKey)) {
307 1
            $this->doFingerprint();
308 1
            $cache->set($cacheKey, $this->data, $this->config->get('cache.assets.ttl'));
309
        }
310 1
        $this->data = $cache->get($cacheKey);
311
312 1
        return $this;
313
    }
314
315
    /**
316
     * Scales down an image to a new $width.
317
     *
318
     * @throws RuntimeException
319
     */
320 1
    public function resize(int $width): self
321
    {
322 1
        $this->checkImage();
323
324
        // if the image is already smaller than the requested width, return it
325 1
        if ($width >= $this->data['width']) {
326 1
            return $this;
327
        }
328
329 1
        $assetResized = clone $this;
330 1
        $assetResized->data['width'] = $width;
331
332 1
        if ($this->isImageInCdn()) {
333
            $assetResized->data['height'] = round($this->data['height'] / ($this->data['width'] / $width));
334
335
            return $assetResized; // returns asset with the new dimensions only: CDN do the rest of the job
336
        }
337
338 1
        $quality = (int) $this->config->get('assets.images.quality');
339
340 1
        $cache = new Cache($this->builder, 'assets');
341 1
        $assetResized->cacheTags['quality'] = $quality;
342 1
        $assetResized->cacheTags['width'] = $width;
343 1
        $cacheKey = $cache->createKeyFromAsset($assetResized, $assetResized->cacheTags);
344 1
        if (!$cache->has($cacheKey)) {
345 1
            $assetResized->data['content'] = Image::resize($assetResized, $width, $quality);
346 1
            $assetResized->data['path'] = '/' . Util::joinPath(
347 1
                (string) $this->config->get('assets.target'),
348 1
                self::IMAGE_THUMB,
349 1
                (string) $width,
350 1
                $assetResized->data['path']
351 1
            );
352 1
            $assetResized->data['path'] = $this->deduplicateThumbPath($assetResized->data['path']);
353 1
            $assetResized->data['height'] = $assetResized->getHeight();
354 1
            $assetResized->data['size'] = \strlen($assetResized->data['content']);
355
356 1
            $cache->set($cacheKey, $assetResized->data, $this->config->get('cache.assets.ttl'));
357 1
            $this->builder->getLogger()->debug(\sprintf('Asset resized: "%s" (%sx)', $assetResized->data['path'], $width));
358
        }
359 1
        $assetResized->data = $cache->get($cacheKey);
360
361 1
        return $assetResized;
362
    }
363
364
    /**
365
     * Crops the image to the specified width and height, keeping the specified position.
366
     *
367
     * @throws RuntimeException
368
     */
369
    public function cover(int $width, int $height): self
370
    {
371
        $this->checkImage();
372
373
        $assetResized = clone $this;
374
        $assetResized->data['width'] = $width;
375
        $assetResized->data['height'] = $height;
376
377
        $quality = (int) $this->config->get('assets.images.quality');
378
379
        $cache = new Cache($this->builder, 'assets');
380
        $assetResized->cacheTags['quality'] = $quality;
381
        $assetResized->cacheTags['width'] = $width;
382
        $assetResized->cacheTags['height'] = $height;
383
        $cacheKey = $cache->createKeyFromAsset($assetResized, $assetResized->cacheTags);
384
        if (!$cache->has($cacheKey)) {
385
            $assetResized->data['content'] = Image::cover($assetResized, $width, $height, $quality);
386
            $assetResized->data['path'] = '/' . Util::joinPath(
387
                (string) $this->config->get('assets.target'),
388
                self::IMAGE_THUMB,
389
                (string) $width . 'x' . (string) $height,
390
                $assetResized->data['path']
391
            );
392
            $assetResized->data['path'] = $this->deduplicateThumbPath($assetResized->data['path']);
393
            $assetResized->data['size'] = \strlen($assetResized->data['content']);
394
395
            $cache->set($cacheKey, $assetResized->data, $this->config->get('cache.assets.ttl'));
396
            $this->builder->getLogger()->debug(\sprintf('Asset resized: "%s" (%sx%s)', $assetResized->data['path'], $width, $height));
397
        }
398
        $assetResized->data = $cache->get($cacheKey);
399
400
        return $assetResized;
401
    }
402
403
    /**
404
     * Creates a maskable image (with a padding = 20%).
405
     *
406
     * @throws RuntimeException
407
     */
408
    public function maskable(?int $padding = null): self
409
    {
410
        $this->checkImage();
411
412
        if ($padding === null) {
413
            $padding = 20; // default padding
414
        }
415
416
        $assetMaskable = clone $this;
417
418
        $quality = (int) $this->config->get('assets.images.quality');
419
420
        $cache = new Cache($this->builder, 'assets');
421
        $assetMaskable->cacheTags['maskable'] = true;
422
        $cacheKey = $cache->createKeyFromAsset($assetMaskable, $assetMaskable->cacheTags);
423
        if (!$cache->has($cacheKey)) {
424
            $assetMaskable->data['content'] = Image::maskable($assetMaskable, $quality, $padding);
425
            $assetMaskable->data['path'] = '/' . Util::joinPath(
426
                (string) $this->config->get('assets.target'),
427
                'maskable',
428
                $assetMaskable->data['path']
429
            );
430
            $assetMaskable->data['size'] = \strlen($assetMaskable->data['content']);
431
432
            $cache->set($cacheKey, $assetMaskable->data, $this->config->get('cache.assets.ttl'));
433
            $this->builder->getLogger()->debug(\sprintf('Asset maskabled: "%s"', $assetMaskable->data['path']));
434
        }
435
        $assetMaskable->data = $cache->get($cacheKey);
436
437
        return $assetMaskable;
438
    }
439
440
    /**
441
     * Converts an image asset to $format format.
442
     *
443
     * @throws RuntimeException
444
     */
445 1
    public function convert(string $format, ?int $quality = null): self
446
    {
447 1
        if ($this->data['type'] != 'image') {
448
            throw new RuntimeException(\sprintf('Not able to convert "%s" (%s) to %s: not an image.', $this->data['path'], $this->data['type'], $format));
449
        }
450
451 1
        if ($quality === null) {
452 1
            $quality = (int) $this->config->get('assets.images.quality');
453
        }
454
455 1
        $asset = clone $this;
456 1
        $asset['ext'] = $format;
457 1
        $asset->data['subtype'] = "image/$format";
458
459 1
        if ($this->isImageInCdn()) {
460
            return $asset; // returns the asset with the new extension only: CDN do the rest of the job
461
        }
462
463 1
        $cache = new Cache($this->builder, 'assets');
464 1
        $this->cacheTags['quality'] = $quality;
465 1
        if ($this->data['width']) {
466 1
            $this->cacheTags['width'] = $this->data['width'];
467
        }
468 1
        $cacheKey = $cache->createKeyFromAsset($asset, $this->cacheTags);
469 1
        if (!$cache->has($cacheKey)) {
470 1
            $asset->data['content'] = Image::convert($asset, $format, $quality);
471
            $asset->data['path'] = preg_replace('/\.' . $this->data['ext'] . '$/m', ".$format", $this->data['path']);
472
            $asset->data['size'] = \strlen($asset->data['content']);
473
            $cache->set($cacheKey, $asset->data, $this->config->get('cache.assets.ttl'));
474
            $this->builder->getLogger()->debug(\sprintf('Asset converted: "%s" (%s -> %s)', $asset->data['path'], $this->data['ext'], $format));
475
        }
476
        $asset->data = $cache->get($cacheKey);
477
478
        return $asset;
479
    }
480
481
    /**
482
     * Converts an image asset to WebP format.
483
     *
484
     * @throws RuntimeException
485
     */
486
    public function webp(?int $quality = null): self
487
    {
488
        return $this->convert('webp', $quality);
489
    }
490
491
    /**
492
     * Converts an image asset to AVIF format.
493
     *
494
     * @throws RuntimeException
495
     */
496
    public function avif(?int $quality = null): self
497
    {
498
        return $this->convert('avif', $quality);
499
    }
500
501
    /**
502
     * Implements \ArrayAccess.
503
     */
504 1
    #[\ReturnTypeWillChange]
505
    public function offsetSet($offset, $value): void
506
    {
507 1
        if (!\is_null($offset)) {
508 1
            $this->data[$offset] = $value;
509
        }
510
    }
511
512
    /**
513
     * Implements \ArrayAccess.
514
     */
515 1
    #[\ReturnTypeWillChange]
516
    public function offsetExists($offset): bool
517
    {
518 1
        return isset($this->data[$offset]);
519
    }
520
521
    /**
522
     * Implements \ArrayAccess.
523
     */
524
    #[\ReturnTypeWillChange]
525
    public function offsetUnset($offset): void
526
    {
527
        unset($this->data[$offset]);
528
    }
529
530
    /**
531
     * Implements \ArrayAccess.
532
     */
533 1
    #[\ReturnTypeWillChange]
534
    public function offsetGet($offset)
535
    {
536 1
        return isset($this->data[$offset]) ? $this->data[$offset] : null;
537
    }
538
539
    /**
540
     * Hashing content of an asset with the specified algo, sha384 by default.
541
     * Used for SRI (Subresource Integrity).
542
     *
543
     * @see https://developer.mozilla.org/fr/docs/Web/Security/Subresource_Integrity
544
     */
545 1
    public function getIntegrity(string $algo = 'sha384'): string
546
    {
547 1
        return \sprintf('%s-%s', $algo, base64_encode(hash($algo, $this->data['content'], true)));
548
    }
549
550
    /**
551
     * Returns MP3 file infos.
552
     *
553
     * @see https://github.com/wapmorgan/Mp3Info
554
     */
555 1
    public function getAudio(): Mp3Info
556
    {
557 1
        if ($this->data['type'] !== 'audio') {
558
            throw new RuntimeException(\sprintf('Not able to get audio infos of "%s".', $this->data['path']));
559
        }
560
561 1
        return new Mp3Info($this->data['file']);
562
    }
563
564
    /**
565
     * Returns MP4 file infos.
566
     *
567
     * @see https://github.com/clwu88/php-read-mp4info
568
     */
569 1
    public function getVideo(): array
570
    {
571 1
        if ($this->data['type'] !== 'video') {
572
            throw new RuntimeException(\sprintf('Not able to get video infos of "%s".', $this->data['path']));
573
        }
574
575 1
        return (array) \Clwu\Mp4::getInfo($this->data['file']);
576
    }
577
578
    /**
579
     * Returns the Data URL (encoded in Base64).
580
     *
581
     * @throws RuntimeException
582
     */
583 1
    public function dataurl(): string
584
    {
585 1
        if ($this->data['type'] == 'image' && !Image::isSVG($this)) {
586 1
            return Image::getDataUrl($this, (int) $this->config->get('assets.images.quality'));
587
        }
588
589 1
        return \sprintf('data:%s;base64,%s', $this->data['subtype'], base64_encode($this->data['content']));
590
    }
591
592
    /**
593
     * Adds asset path to the list of assets to save.
594
     *
595
     * @throws RuntimeException
596
     */
597 1
    public function save(): void
598
    {
599 1
        if ($this->data['missing']) {
600 1
            return;
601
        }
602
603 1
        $cache = new Cache($this->builder, 'assets');
604 1
        if (empty($this->data['path']) || !Util\File::getFS()->exists($cache->getContentFilePathname($this->data['path']))) {
605
            throw new RuntimeException(
606
                \sprintf('Can\'t add "%s" to assets list. Please clear cache and retry.', $this->data['path'])
607
            );
608
        }
609
610 1
        $this->builder->addAsset($this->data['path']);
611
    }
612
613
    /**
614
     * Is the asset an image and is it in CDN?
615
     */
616 1
    public function isImageInCdn(): bool
617
    {
618
        if (
619 1
            $this->data['type'] == 'image'
620 1
            && $this->config->isEnabled('assets.images.cdn')
621 1
            && $this->data['ext'] != 'ico'
622 1
            && (Image::isSVG($this) && $this->config->isEnabled('assets.images.cdn.svg'))
623
        ) {
624
            return true;
625
        }
626
        // handle remote image?
627 1
        if ($this->data['url'] !== null && $this->config->isEnabled('assets.images.cdn.remote')) {
628
            return true;
629
        }
630
631 1
        return false;
632
    }
633
634
    /**
635
     * Builds a relative path from a URL.
636
     * Used for remote files.
637
     */
638 1
    public static function buildPathFromUrl(string $url): string
639
    {
640 1
        $host = parse_url($url, PHP_URL_HOST);
641 1
        $path = parse_url($url, PHP_URL_PATH);
642 1
        $query = parse_url($url, PHP_URL_QUERY);
643 1
        $ext = pathinfo(parse_url($url, PHP_URL_PATH), PATHINFO_EXTENSION);
644
645
        // Google Fonts hack
646 1
        if (Util\Str::endsWith($path, '/css') || Util\Str::endsWith($path, '/css2')) {
647 1
            $ext = 'css';
648
        }
649
650 1
        return Page::slugify(\sprintf('%s%s%s%s', $host, self::sanitize($path), $query ? "-$query" : '', $query && $ext ? ".$ext" : ''));
651
    }
652
653
    /**
654
     * Replaces some characters by '_'.
655
     */
656 1
    public static function sanitize(string $string): string
657
    {
658 1
        return str_replace(['<', '>', ':', '"', '\\', '|', '?', '*'], '_', $string);
659
    }
660
661
    /**
662
     * Compiles a SCSS.
663
     *
664
     * @throws RuntimeException
665
     */
666 1
    protected function doCompile(): self
667
    {
668
        // abort if not a SCSS file
669 1
        if ($this->data['ext'] != 'scss') {
670 1
            return $this;
671
        }
672 1
        $scssPhp = new Compiler();
673
        // import paths
674 1
        $importDir = [];
675 1
        $importDir[] = Util::joinPath($this->config->getStaticPath());
676 1
        $importDir[] = Util::joinPath($this->config->getAssetsPath());
677 1
        $scssDir = (array) $this->config->get('assets.compile.import');
678 1
        $themes = $this->config->getTheme() ?? [];
679 1
        foreach ($scssDir as $dir) {
680 1
            $importDir[] = Util::joinPath($this->config->getStaticPath(), $dir);
681 1
            $importDir[] = Util::joinPath($this->config->getAssetsPath(), $dir);
682 1
            $importDir[] = Util::joinPath(\dirname($this->data['file']), $dir);
683 1
            foreach ($themes as $theme) {
684 1
                $importDir[] = Util::joinPath($this->config->getThemeDirPath($theme, "static/$dir"));
685 1
                $importDir[] = Util::joinPath($this->config->getThemeDirPath($theme, "assets/$dir"));
686
            }
687
        }
688 1
        $scssPhp->setQuietDeps(true);
689 1
        $scssPhp->setImportPaths(array_unique($importDir));
690
        // adds source map
691 1
        if ($this->builder->isDebug() && $this->config->isEnabled('assets.compile.sourcemap')) {
692
            $importDir = [];
693
            $assetDir = (string) $this->config->get('assets.dir');
694
            $assetDirPos = strrpos($this->data['file'], DIRECTORY_SEPARATOR . $assetDir . DIRECTORY_SEPARATOR);
695
            $fileRelPath = substr($this->data['file'], $assetDirPos + 8);
696
            $filePath = Util::joinFile($this->config->getOutputPath(), $fileRelPath);
697
            $importDir[] = \dirname($filePath);
698
            foreach ($scssDir as $dir) {
699
                $importDir[] = Util::joinFile($this->config->getOutputPath(), $dir);
700
            }
701
            $scssPhp->setImportPaths(array_unique($importDir));
702
            $scssPhp->setSourceMap(Compiler::SOURCE_MAP_INLINE);
703
            $scssPhp->setSourceMapOptions([
704
                'sourceMapBasepath' => Util::joinPath($this->config->getOutputPath()),
705
                'sourceRoot'        => '/',
706
            ]);
707
        }
708
        // defines output style
709 1
        $outputStyles = ['expanded', 'compressed'];
710 1
        $outputStyle = strtolower((string) $this->config->get('assets.compile.style'));
711 1
        if (!\in_array($outputStyle, $outputStyles)) {
712
            throw new ConfigException(\sprintf('"%s" value must be "%s".', 'assets.compile.style', implode('" or "', $outputStyles)));
713
        }
714 1
        $scssPhp->setOutputStyle($outputStyle == 'compressed' ? OutputStyle::COMPRESSED : OutputStyle::EXPANDED);
715
        // set variables
716 1
        $variables = $this->config->get('assets.compile.variables');
717 1
        if (!empty($variables)) {
718 1
            $variables = array_map('ScssPhp\ScssPhp\ValueConverter::parseValue', $variables);
719 1
            $scssPhp->replaceVariables($variables);
720
        }
721
        // debug
722 1
        if ($this->builder->isDebug()) {
723 1
            $scssPhp->setQuietDeps(false);
724 1
            $this->builder->getLogger()->debug(\sprintf("SCSS compiler imported paths:\n%s", Util\Str::arrayToList(array_unique($importDir))));
725
        }
726
        // update data
727 1
        $this->data['path'] = preg_replace('/sass|scss/m', 'css', $this->data['path']);
728 1
        $this->data['ext'] = 'css';
729 1
        $this->data['type'] = 'text';
730 1
        $this->data['subtype'] = 'text/css';
731 1
        $this->data['content'] = $scssPhp->compileString($this->data['content'])->getCss();
732 1
        $this->data['size'] = \strlen($this->data['content']);
733
734 1
        $this->builder->getLogger()->debug(\sprintf('Asset compiled: "%s"', $this->data['path']));
735
736 1
        return $this;
737
    }
738
739
    /**
740
     * Minifying a CSS or a JS + cache.
741
     *
742
     * @throws RuntimeException
743
     */
744 1
    protected function doMinify(): self
745
    {
746
        // compile SCSS files
747 1
        if ($this->data['ext'] == 'scss') {
748
            $this->doCompile();
749
        }
750
        // abort if already minified
751 1
        if (substr($this->data['path'], -8) == '.min.css' || substr($this->data['path'], -7) == '.min.js') {
752
            return $this;
753
        }
754
        // abord if not a CSS or JS file
755 1
        if (!\in_array($this->data['ext'], ['css', 'js'])) {
756
            return $this;
757
        }
758
        // in debug mode: disable minify to preserve inline source map
759 1
        if ($this->builder->isDebug() && $this->config->isEnabled('assets.compile.sourcemap')) {
760
            return $this;
761
        }
762 1
        switch ($this->data['ext']) {
763 1
            case 'css':
764 1
                $minifier = new Minify\CSS($this->data['content']);
765 1
                break;
766 1
            case 'js':
767 1
                $minifier = new Minify\JS($this->data['content']);
768 1
                break;
769
            default:
770
                throw new RuntimeException(\sprintf('Not able to minify "%s".', $this->data['path']));
771
        }
772 1
        $this->data['content'] = $minifier->minify();
773 1
        $this->data['size'] = \strlen($this->data['content']);
774
775 1
        $this->builder->getLogger()->debug(\sprintf('Asset minified: "%s"', $this->data['path']));
776
777 1
        return $this;
778
    }
779
780
    /**
781
     * Add hash to the file name.
782
     */
783 1
    protected function doFingerprint(): self
784
    {
785 1
        $hash = hash('md5', $this->data['content']);
786 1
        $this->data['path'] = preg_replace(
787 1
            '/\.' . $this->data['ext'] . '$/m',
788 1
            ".$hash." . $this->data['ext'],
789 1
            $this->data['path']
790 1
        );
791 1
        $this->builder->getLogger()->debug(\sprintf('Asset fingerprinted: "%s"', $this->data['path']));
792
793 1
        return $this;
794
    }
795
796
    /**
797
     * Returns local file path and updated path, or throw an exception.
798
     * If $fallback path is set, it will be used if the remote file is not found.
799
     *
800
     * Try to locate the file in:
801
     *   (1. remote file)
802
     *   1. assets
803
     *   2. themes/<theme>/assets
804
     *   3. static
805
     *   4. themes/<theme>/static
806
     *
807
     * @throws RuntimeException
808
     */
809 1
    private function locateFile(string $path, ?string $fallback = null, ?string $userAgent = null): array
810
    {
811
        // remote file
812 1
        if (Util\File::isRemote($path)) {
813
            try {
814 1
                $content = $this->getRemoteFileContent($path, $userAgent);
815 1
                $path = self::buildPathFromUrl($path);
816 1
                $cache = new Cache($this->builder, 'assets/remote');
817 1
                if (!$cache->has($path)) {
818 1
                    $cache->set($path, [
819 1
                        'content' => $content,
820 1
                        'path'    => $path,
821 1
                    ], $this->config->get('cache.assets.remote.ttl'));
822
                }
823 1
                return [
824 1
                    'file' => $cache->getContentFilePathname($path),
825 1
                    'path' => $path,
826 1
                ];
827 1
            } catch (RuntimeException $e) {
828 1
                if (empty($fallback)) {
829
                    throw new RuntimeException($e->getMessage());
830
                }
831 1
                $path = $fallback;
832
            }
833
        }
834
835
        // checks in assets/
836 1
        $file = Util::joinFile($this->config->getAssetsPath(), $path);
837 1
        if (Util\File::getFS()->exists($file)) {
838 1
            return [
839 1
                'file' => $file,
840 1
                'path' => $path,
841 1
            ];
842
        }
843
844
        // checks in each themes/<theme>/assets/
845 1
        foreach ($this->config->getTheme() ?? [] as $theme) {
846 1
            $file = Util::joinFile($this->config->getThemeDirPath($theme, 'assets'), $path);
847 1
            if (Util\File::getFS()->exists($file)) {
848 1
                return [
849 1
                    'file' => $file,
850 1
                    'path' => $path,
851 1
                ];
852
            }
853
        }
854
855
        // checks in static/
856 1
        $file = Util::joinFile($this->config->getStaticPath(), $path);
857 1
        if (Util\File::getFS()->exists($file)) {
858 1
            return [
859 1
                'file' => $file,
860 1
                'path' => $path,
861 1
            ];
862
        }
863
864
        // checks in each themes/<theme>/static/
865 1
        foreach ($this->config->getTheme() ?? [] as $theme) {
866 1
            $file = Util::joinFile($this->config->getThemeDirPath($theme, 'static'), $path);
867 1
            if (Util\File::getFS()->exists($file)) {
868 1
                return [
869 1
                    'file' => $file,
870 1
                    'path' => $path,
871 1
                ];
872
            }
873
        }
874
875 1
        throw new RuntimeException(\sprintf('Can\'t locate file "%s".', $path));
876
    }
877
878
    /**
879
     * Try to get remote file content.
880
     * Returns file content or throw an exception.
881
     *
882
     * @throws RuntimeException
883
     */
884 1
    private function getRemoteFileContent(string $path, ?string $userAgent = null): string
885
    {
886 1
        if (!Util\File::isRemoteExists($path)) {
887 1
            throw new RuntimeException(\sprintf('Can\'t get remote file "%s".', $path));
888
        }
889 1
        if (false === $content = Util\File::fileGetContents($path, $userAgent)) {
890
            throw new RuntimeException(\sprintf('Can\'t get content of remote file "%s".', $path));
891
        }
892 1
        if (\strlen($content) <= 1) {
893
            throw new RuntimeException(\sprintf('Remote file "%s" is empty.', $path));
894
        }
895
896 1
        return $content;
897
    }
898
899
    /**
900
     * Optimizing $filepath image.
901
     * Returns the new file size.
902
     */
903 1
    private function optimize(string $filepath, string $path, int $quality): int
904
    {
905 1
        $message = \sprintf('Asset not optimized: "%s"', $path);
906 1
        $sizeBefore = filesize($filepath);
907 1
        Optimizer::create($quality)->optimize($filepath);
908 1
        $sizeAfter = filesize($filepath);
909 1
        if ($sizeAfter < $sizeBefore) {
910
            $message = \sprintf(
911
                'Asset optimized: "%s" (%s Ko -> %s Ko)',
912
                $path,
913
                ceil($sizeBefore / 1000),
914
                ceil($sizeAfter / 1000)
915
            );
916
        }
917 1
        $this->builder->getLogger()->debug($message);
918
919 1
        return $sizeAfter;
920
    }
921
922
    /**
923
     * Returns the width of an image/SVG.
924
     *
925
     * @throws RuntimeException
926
     */
927 1
    private function getWidth(): int
928
    {
929 1
        if ($this->data['type'] != 'image') {
930
            return 0;
931
        }
932 1
        if (Image::isSVG($this) && false !== $svg = Image::getSvgAttributes($this)) {
933 1
            return (int) $svg->width;
934
        }
935 1
        if (false === $size = $this->getImageSize()) {
936
            throw new RuntimeException(\sprintf('Not able to get width of "%s".', $this->data['path']));
937
        }
938
939 1
        return $size[0];
940
    }
941
942
    /**
943
     * Returns the height of an image/SVG.
944
     *
945
     * @throws RuntimeException
946
     */
947 1
    private function getHeight(): int
948
    {
949 1
        if ($this->data['type'] != 'image') {
950
            return 0;
951
        }
952 1
        if (Image::isSVG($this) && false !== $svg = Image::getSvgAttributes($this)) {
953 1
            return (int) $svg->height;
954
        }
955 1
        if (false === $size = $this->getImageSize()) {
956
            throw new RuntimeException(\sprintf('Not able to get height of "%s".', $this->data['path']));
957
        }
958
959 1
        return $size[1];
960
    }
961
962
    /**
963
     * Returns image size informations.
964
     *
965
     * @see https://www.php.net/manual/function.getimagesize.php
966
     *
967
     * @return array|false
968
     */
969 1
    private function getImageSize()
970
    {
971 1
        if (!$this->data['type'] == 'image') {
972
            return false;
973
        }
974
975
        try {
976 1
            if (false === $size = getimagesizefromstring($this->data['content'])) {
977 1
                return false;
978
            }
979
        } catch (\Exception $e) {
980
            throw new RuntimeException(\sprintf('Handling asset "%s" failed: "%s"', $this->data['path'], $e->getMessage()));
981
        }
982
983 1
        return $size;
984
    }
985
986
    /**
987
     * Builds CDN image URL.
988
     */
989
    private function buildImageCdnUrl(): string
990
    {
991
        return str_replace(
992
            [
993
                '%account%',
994
                '%image_url%',
995
                '%width%',
996
                '%quality%',
997
                '%format%',
998
            ],
999
            [
1000
                $this->config->get('assets.images.cdn.account') ?? '',
1001
                ltrim($this->data['url'] ?? (string) new Url($this->builder, $this->data['path'], ['canonical' => $this->config->get('assets.images.cdn.canonical') ?? true]), '/'),
1002
                $this->data['width'],
1003
                (int) $this->config->get('assets.images.quality'),
1004
                $this->data['ext'],
1005
            ],
1006
            (string) $this->config->get('assets.images.cdn.url')
1007
        );
1008
    }
1009
1010
    /**
1011
     * Checks if the asset is not missing and is typed as an image.
1012
     *
1013
     * @throws RuntimeException
1014
     */
1015 1
    private function checkImage(): void
1016
    {
1017 1
        if ($this->data['missing']) {
1018
            throw new RuntimeException(\sprintf('Not able to resize "%s": file not found.', $this->data['path']));
1019
        }
1020 1
        if ($this->data['type'] != 'image') {
1021
            throw new RuntimeException(\sprintf('Not able to resize "%s": not an image.', $this->data['path']));
1022
        }
1023
    }
1024
1025
    /**
1026
     * Remove redondant '/thumbnails/<width(xheight)>/' in the path.
1027
     */
1028 1
    private function deduplicateThumbPath(string $path): string
1029
    {
1030
        // https://regex101.com/r/1HXJmw/1
1031 1
        $pattern = '/(' . self::IMAGE_THUMB . '\/\d+(x\d+){0,1}\/)(' . self::IMAGE_THUMB . '\/\d+(x\d+){0,1}\/)(.*)/i';
1032
1033 1
        if (null === $result = preg_replace($pattern, '$1$5', $path)) {
1034
            return $path;
1035
        }
1036
1037 1
        return $result;
1038
    }
1039
}
1040