Passed
Push — feat/sass-source-map ( 60b891...190e4f )
by Arnaud
05:06 queued 26s
created

Asset   F

Complexity

Total Complexity 79

Size/Duplication

Total Lines 510
Duplicated Lines 0 %

Importance

Changes 16
Bugs 1 Features 0
Metric Value
eloc 223
c 16
b 1
f 0
dl 0
loc 510
rs 2.08
wmc 79

17 Methods

Rating   Name   Duplication   Size   Complexity  
A fingerprint() 0 16 2
D __construct() 0 85 19
A __toString() 0 9 2
A loadFile() 0 32 4
B compile() 0 65 11
C minify() 0 50 12
A offsetGet() 0 3 2
A getHeight() 0 7 2
A getAudio() 0 3 1
A getImageSize() 0 11 3
B findFile() 0 38 9
A getWidth() 0 7 2
A offsetExists() 0 3 1
A getIntegrity() 0 3 1
A offsetUnset() 0 3 1
A save() 0 12 5
A offsetSet() 0 4 2

How to fix   Complexity   

Complex Class

Complex classes like Asset often do a lot of different things. To break such a class down, we need to identify a cohesive component within that class. A common approach to find such a component is to look for fields/methods that share the same prefixes, or suffixes.

Once you have determined the fields that belong together, you can apply the Extract Class refactoring. If the component makes sense as a sub-class, Extract Subclass is also a candidate, and is often faster.

While breaking up the class, it is a good idea to analyze how other classes use Asset, and based on these observations, apply Extract Interface, too.

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