Passed
Push — analysis-m4el67 ( b93bb3 )
by Arnaud
05:22 queued 23s
created

Asset::loadFile()   B

Complexity

Conditions 6
Paths 6

Size

Total Lines 42
Code Lines 27

Duplication

Lines 0
Ratio 0 %

Importance

Changes 1
Bugs 0 Features 0
Metric Value
cc 6
eloc 27
c 1
b 0
f 0
nc 6
nop 2
dl 0
loc 42
rs 8.8657
1
<?php
2
/**
3
 * This file is part of the Cecil/Cecil package.
4
 *
5
 * Copyright (c) Arnaud Ligny <[email protected]>
6
 *
7
 * For the full copyright and license information, please view the LICENSE
8
 * file that was distributed with this source code.
9
 */
10
11
namespace Cecil\Assets;
12
13
use Cecil\Builder;
14
use Cecil\Collection\Page\Page;
15
use Cecil\Config;
16
use Cecil\Exception\Exception;
17
use Cecil\Util;
18
use MatthiasMullie\Minify;
19
use ScssPhp\ScssPhp\Compiler;
20
use wapmorgan\Mp3Info\Mp3Info;
21
22
class Asset implements \ArrayAccess
23
{
24
    /** @var Builder */
25
    protected $builder;
26
    /** @var Config */
27
    protected $config;
28
    /** @var array */
29
    protected $data = [];
30
    /** @var bool */
31
    protected $fingerprinted = false;
32
    /** @var bool */
33
    protected $compiled = false;
34
    /** @var bool */
35
    protected $minified = false;
36
    /** @var bool */
37
    protected $ignore_missing = false;
38
39
    /**
40
     * Creates an Asset from file(s) path.
41
     *
42
     * $options[
43
     *     'fingerprint'    => true,
44
     *     'minify'         => true,
45
     *     'filename'       => '',
46
     *     'ignore_missing' => false,
47
     * ];
48
     *
49
     * @param Builder      $builder
50
     * @param string|array $paths
51
     * @param array|null   $options
52
     */
53
    public function __construct(Builder $builder, $paths, array $options = null)
54
    {
55
        $this->builder = $builder;
56
        $this->config = $builder->getConfig();
57
        $paths = is_array($paths) ? $paths : [$paths];
58
59
        // handles options
60
        $fingerprint = (bool) $this->config->get('assets.fingerprint.enabled');
61
        $minify = (bool) $this->config->get('assets.minify.enabled');
62
        $filename = '';
63
        $ignore_missing = false;
64
        extract(is_array($options) ? $options : [], EXTR_IF_EXISTS);
65
        $this->ignore_missing = $ignore_missing;
66
67
        // fill data array with file(s) informations
68
        $cache = new Cache($this->builder, 'assets');
69
        $cacheKey = sprintf('%s.ser', implode('_', $paths));
70
        if (!$cache->has($cacheKey)) {
71
            $file = [];
72
            $pathsCount = count($paths);
73
            for ($i = 0; $i < $pathsCount; $i++) {
74
                // loads file(s)
75
                $file[$i] = $this->loadFile($paths[$i], $ignore_missing);
76
                // bundle: same type/ext only
77
                if ($i > 0) {
78
                    if ($file[$i]['type'] != $file[$i - 1]['type']) {
79
                        throw new Exception(\sprintf('Asset bundle type error (%s != %s).', $file[$i]['type'], $file[$i - 1]['type']));
80
                    }
81
                    if ($file[$i]['ext'] != $file[$i - 1]['ext']) {
82
                        throw new Exception(\sprintf('Asset bundle extension error (%s != %s).', $file[$i]['ext'], $file[$i - 1]['ext']));
83
                    }
84
                }
85
                // missing allowed = empty path
86
                if ($file[$i]['missing']) {
87
                    $this->data['path'] = '';
88
89
                    continue;
90
                }
91
                // set data
92
                if ($i == 0) {
93
                    $this->data['file'] = $file[$i]['filepath']; // should be an array of files in case of bundle?
94
                    $this->data['filename'] = $file[$i]['path'];
95
                    $this->data['path'] = $file[$i]['path'];
96
                    if (!empty($filename)) {
97
                        $this->data['path'] = '/'.ltrim($filename, '/');
98
                    }
99
                    $this->data['ext'] = $file[$i]['ext'];
100
                    $this->data['type'] = $file[$i]['type'];
101
                    $this->data['subtype'] = $file[$i]['subtype'];
102
                }
103
                $this->data['size'] += $file[$i]['size'];
104
                $this->data['source'] .= $file[$i]['content'];
105
                $this->data['content'] .= $file[$i]['content'];
106
            }
107
            // bundle: define path
108
            if ($pathsCount > 1) {
109
                if (empty($filename)) {
1 ignored issue
show
introduced by
The condition empty($filename) is always true.
Loading history...
110
                    switch ($this->data['ext']) {
111
                        case 'scss':
112
                        case 'css':
113
                            $this->data['path'] = '/styles.'.$file[0]['ext'];
114
                            break;
115
                        case 'js':
116
                            $this->data['path'] = '/scripts.'.$file[0]['ext'];
117
                            break;
118
                        default:
119
                            throw new Exception(\sprintf('Asset bundle supports "%s" files only.', 'scss, css and js'));
120
                    }
121
                }
122
            }
123
            $cache->set($cacheKey, $this->data);
124
        }
125
        $this->data = $cache->get($cacheKey);
126
127
        // fingerprinting
128
        if ($fingerprint) {
129
            $this->fingerprint();
130
        }
131
        // compiling
132
        if ((bool) $this->config->get('assets.compile.enabled')) {
133
            $this->compile();
134
        }
135
        // minifying
136
        if ($minify) {
137
            $this->minify();
138
        }
139
    }
140
141
    /**
142
     * Returns path.
143
     *
144
     * @return string
145
     */
146
    public function __toString(): string
147
    {
148
        try {
149
            $this->save();
150
        } catch (Exception $e) {
151
            $this->builder->getLogger()->error($e->getMessage());
152
        }
153
154
        return $this->data['path'];
155
    }
156
157
    /**
158
     * Fingerprints a file.
159
     *
160
     * @return self
161
     */
162
    public function fingerprint(): self
163
    {
164
        if ($this->fingerprinted) {
165
            return $this;
166
        }
167
168
        $fingerprint = hash('md5', $this->data['source']);
169
        $this->data['path'] = preg_replace(
170
            '/\.'.$this->data['ext'].'$/m',
171
            ".$fingerprint.".$this->data['ext'],
172
            $this->data['path']
173
        );
174
175
        $this->fingerprinted = true;
176
177
        return $this;
178
    }
179
180
    /**
181
     * Compiles a SCSS.
182
     *
183
     * @return self
184
     */
185
    public function compile(): self
186
    {
187
        if ($this->compiled) {
188
            return $this;
189
        }
190
191
        if ($this->data['ext'] != 'scss') {
192
            return $this;
193
        }
194
195
        $cache = new Cache($this->builder, 'assets');
196
        $cacheKey = $cache->createKeyFromAsset($this, 'compiled');
197
        if (!$cache->has($cacheKey)) {
198
            $scssPhp = new Compiler();
199
            // import path
200
            $scssPhp->addImportPath(Util::joinPath($this->config->getStaticPath()));
201
            $scssDir = $this->config->get('assets.compile.import') ?? [];
202
            $themes = $this->config->getTheme() ?? [];
203
            foreach ($scssDir as $dir) {
204
                $scssPhp->addImportPath(Util::joinPath($this->config->getStaticPath(), $dir));
205
                $scssPhp->addImportPath(Util::joinPath(dirname($this->data['file']), $dir));
206
                foreach ($themes as $theme) {
207
                    $scssPhp->addImportPath(Util::joinPath($this->config->getThemeDirPath($theme, "static/$dir")));
208
                }
209
            }
210
            // source map
211
            if ($this->builder->isDebug() && (bool) $this->config->get('assets.compile.sourcemap')) {
212
                $importDir = [];
213
                $staticDir = (string) $this->config->get('static.dir');
214
                $staticDirPos = strrpos($this->data['file'], DIRECTORY_SEPARATOR.$staticDir.DIRECTORY_SEPARATOR);
215
                $fileRelPath = substr($this->data['file'], $staticDirPos + 8);
216
                $filePath = Util::joinFile($this->config->getOutputPath(), $this->config->get('static.target') ?? '', $fileRelPath);
217
                $importDir[] = dirname($filePath);
218
                foreach ($scssDir as $dir) {
219
                    $importDir[] = Util::joinFile($this->config->getOutputPath(), $this->config->get('static.target') ?? '', $dir);
220
                }
221
                $scssPhp->setImportPaths(array_unique($importDir));
222
                $scssPhp->setSourceMap(Compiler::SOURCE_MAP_INLINE);
223
                $scssPhp->setSourceMapOptions([
224
                    'sourceMapBasepath' => Util::joinPath($this->config->getOutputPath()),
225
                    'sourceRoot'        => '/',
226
                ]);
227
            }
228
            // output style
229
            $outputStyles = ['expanded', 'compressed'];
230
            $outputStyle = strtolower((string) $this->config->get('assets.compile.style'));
231
            if (!in_array($outputStyle, $outputStyles)) {
232
                throw new Exception(\sprintf('Scss output style "%s" doesn\'t exists.', $outputStyle));
233
            }
234
            $scssPhp->setOutputStyle($outputStyle);
235
            // variables
236
            $variables = $this->config->get('assets.compile.variables') ?? [];
237
            if (!empty($variables)) {
238
                $variables = array_map('ScssPhp\ScssPhp\ValueConverter::parseValue', $variables);
239
                $scssPhp->replaceVariables($variables);
240
            }
241
            // update data
242
            $this->data['path'] = preg_replace('/sass|scss/m', 'css', $this->data['path']);
243
            $this->data['ext'] = 'css';
244
            $this->data['content'] = $scssPhp->compileString($this->data['content'])->getCss();
245
            $this->compiled = true;
246
            $cache->set($cacheKey, $this->data);
247
        }
248
        $this->data = $cache->get($cacheKey);
249
250
        return $this;
251
    }
252
253
    /**
254
     * Minifying a CSS or a JS.
255
     *
256
     * @return self
257
     */
258
    public function minify(): self
259
    {
260
        // disable minify for sourcemap
261
        if ($this->builder->isDebug() && (bool) $this->config->get('assets.compile.sourcemap')) {
262
            return $this;
263
        }
264
265
        if ($this->minified) {
266
            return $this;
267
        }
268
269
        if ($this->data['ext'] == 'scss') {
270
            $this->compile();
271
        }
272
273
        if ($this->data['ext'] != 'css' && $this->data['ext'] != 'js') {
274
            return $this;
275
        }
276
277
        if (substr($this->data['path'], -8) == '.min.css' || substr($this->data['path'], -7) == '.min.js') {
278
            $this->minified;
279
280
            return $this;
281
        }
282
283
        $cache = new Cache($this->builder, 'assets');
284
        $cacheKey = $cache->createKeyFromAsset($this, 'minified');
285
        if (!$cache->has($cacheKey)) {
286
            switch ($this->data['ext']) {
287
                case 'css':
288
                    $minifier = new Minify\CSS($this->data['content']);
289
                    break;
290
                case 'js':
291
                    $minifier = new Minify\JS($this->data['content']);
292
                    break;
293
                default:
294
                    throw new Exception(sprintf('Not able to minify "%s"', $this->data['path']));
295
            }
296
            $this->data['path'] = preg_replace(
297
                '/\.'.$this->data['ext'].'$/m',
298
                '.min.'.$this->data['ext'],
299
                $this->data['path']
300
            );
301
            $this->data['content'] = $minifier->minify();
302
            $this->minified = true;
303
            $cache->set($cacheKey, $this->data);
304
        }
305
        $this->data = $cache->get($cacheKey);
306
307
        return $this;
308
    }
309
310
    /**
311
     * Implements \ArrayAccess.
312
     */
313
    public function offsetSet($offset, $value)
314
    {
315
        if (!is_null($offset)) {
316
            $this->data[$offset] = $value;
317
        }
318
    }
319
320
    /**
321
     * Implements \ArrayAccess.
322
     */
323
    public function offsetExists($offset)
324
    {
325
        return isset($this->data[$offset]);
326
    }
327
328
    /**
329
     * Implements \ArrayAccess.
330
     */
331
    public function offsetUnset($offset)
332
    {
333
        unset($this->data[$offset]);
334
    }
335
336
    /**
337
     * Implements \ArrayAccess.
338
     */
339
    public function offsetGet($offset)
340
    {
341
        return isset($this->data[$offset]) ? $this->data[$offset] : null;
342
    }
343
344
    /**
345
     * Hashing content of an asset with the specified algo, sha384 by default.
346
     * Used for SRI (Subresource Integrity).
347
     *
348
     * @see https://developer.mozilla.org/fr/docs/Web/Security/Subresource_Integrity
349
     *
350
     * @return string
351
     */
352
    public function getIntegrity(string $algo = 'sha384'): string
353
    {
354
        return \sprintf('%s-%s', $algo, base64_encode(hash($algo, $this->data['content'], true)));
355
    }
356
357
    /**
358
     * Returns the width of an image.
359
     *
360
     * @return false|int
361
     */
362
    public function getWidth()
363
    {
364
        if (false === $size = $this->getImageSize()) {
365
            return false;
366
        }
367
368
        return $size[0];
369
    }
370
371
    /**
372
     * Returns the height of an image.
373
     *
374
     * @return false|int
375
     */
376
    public function getHeight()
377
    {
378
        if (false === $size = $this->getImageSize()) {
379
            return false;
380
        }
381
382
        return $size[1];
383
    }
384
385
    /**
386
     * Returns MP3 file infos.
387
     *
388
     * @see https://github.com/wapmorgan/Mp3Info
389
     *
390
     * @return Mp3Info
391
     */
392
    public function getAudio(): Mp3Info
393
    {
394
        return new Mp3Info($this->data['file']);
395
    }
396
397
    /**
398
     * Saves file.
399
     * Note: a file from `static/` with the same name will NOT be overridden.
400
     *
401
     * @throws Exception
402
     *
403
     * @return void
404
     */
405
    public function save(): void
406
    {
407
        $filepath = Util::joinFile($this->config->getOutputPath(), $this->data['path']);
408
        if (!$this->builder->getBuildOptions()['dry-run']
409
            && !Util\File::getFS()->exists($filepath)
410
        ) {
411
            try {
412
                Util\File::getFS()->dumpFile($filepath, $this->data['content']);
413
                $this->builder->getLogger()->debug(sprintf('Save asset "%s"', $this->data['path']));
414
            } catch (\Symfony\Component\Filesystem\Exception\IOException $e) {
415
                if (!$this->ignore_missing) {
416
                    throw new Exception(\sprintf('Can\'t save asset "%s"', $this->data['path']));
417
                }
418
            }
419
        }
420
    }
421
422
    /**
423
     * Load file data.
424
     *
425
     * @param string $path           Relative path or URL.
426
     * @param bool   $ignore_missing Don't throw exception if file is missing.
427
     *
428
     * @return array
429
     */
430
    private function loadFile(string $path, bool $ignore_missing = false): array
431
    {
432
        $file = [];
433
434
        if (false === $filePath = $this->findFile($path)) {
435
            if ($ignore_missing) {
436
                $file['missing'] = true;
437
438
                return $file;
439
            }
440
441
            throw new Exception(sprintf('Asset file "%s" doesn\'t exist.', $path));
442
        }
443
444
        if (Util\Url::isUrl($path)) {
445
            $urlHost = parse_url($path, PHP_URL_HOST);
446
            $urlPath = parse_url($path, PHP_URL_PATH);
447
            $urlQuery = parse_url($path, PHP_URL_QUERY);
448
            $path = Util::joinPath('assets', $urlHost, $urlPath);
449
            if (!empty($urlQuery)) {
450
                $path = Util::joinPath($path, Page::slugify($urlQuery));
451
                // Google Fonts hack
452
                if (strpos($urlPath, '/css') !== false) {
453
                    $path .= '.css';
454
                }
455
            }
456
        }
457
        $path = '/'.ltrim($path, '/');
458
459
        $pathinfo = pathinfo($path);
460
        list($type, $subtype) = Util\File::getMimeType($filePath);
461
        $content = Util\File::fileGetContents($filePath);
462
463
        $file['filepath'] = $filePath;
464
        $file['path'] = $path;
465
        $file['ext'] = $pathinfo['extension'];
466
        $file['type'] = $type;
467
        $file['subtype'] = $subtype;
468
        $file['size'] = filesize($filePath);
469
        $file['content'] = $content;
470
471
        return $file;
472
    }
473
474
    /**
475
     * Try to find the file:
476
     *   1. remote (if $path is a valid URL)
477
     *   2. in static/
478
     *   3. in themes/<theme>/static/
479
     * Returns local file path or false if file don't exists.
480
     *
481
     * @param string $path
482
     *
483
     * @return string|false
484
     */
485
    private function findFile(string $path)
486
    {
487
        // in case of remote file: save it and returns cached file path
488
        if (Util\Url::isUrl($path)) {
489
            $url = $path;
490
            $relativePath = Page::slugify(sprintf('%s%s-%s', parse_url($url, PHP_URL_HOST), parse_url($url, PHP_URL_PATH), parse_url($url, PHP_URL_QUERY)));
491
            $filePath = Util::joinFile($this->config->getCacheAssetsPath(), $relativePath);
492
            if (!file_exists($filePath)) {
493
                if (!Util\Url::isRemoteFileExists($url)) {
494
                    return false;
495
                }
496
                if (false === $content = Util\File::fileGetContents($url, true)) {
497
                    return false;
498
                }
499
                if (strlen($content) <= 1) {
500
                    throw new Exception(sprintf('Asset at "%s" is empty.', $url));
501
                }
502
                Util\File::getFS()->dumpFile($filePath, $content);
503
            }
504
505
            return $filePath;
506
        }
507
508
        // checks in static/
509
        $filePath = Util::joinFile($this->config->getStaticPath(), $path);
510
        if (Util\File::getFS()->exists($filePath)) {
511
            return $filePath;
512
        }
513
514
        // checks in each themes/<theme>/static/
515
        foreach ($this->config->getTheme() as $theme) {
516
            $filePath = Util::joinFile($this->config->getThemeDirPath($theme, 'static'), $path);
517
            if (Util\File::getFS()->exists($filePath)) {
518
                return $filePath;
519
            }
520
        }
521
522
        return false;
523
    }
524
525
    /**
526
     * Returns image size informations.
527
     *
528
     * See https://www.php.net/manual/function.getimagesize.php
529
     *
530
     * @return false|array
531
     */
532
    private function getImageSize()
533
    {
534
        if (!$this->data['type'] == 'image') {
535
            return false;
536
        }
537
538
        if (false === $size = getimagesizefromstring($this->data['content'])) {
539
            return false;
540
        }
541
542
        return $size;
543
    }
544
}
545