Assets::setLoader()   A
last analyzed

Complexity

Conditions 1
Paths 1

Size

Total Lines 5
Code Lines 2

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 1
eloc 2
c 0
b 0
f 0
nc 1
nop 1
dl 0
loc 5
rs 10
1
<?php
2
/**
3
 * laravel-assets: asset management for Laravel 5
4
 *
5
 * Copyright (c) 2021 Greg Roach
6
 *
7
 * This program is free software: you can redistribute it and/or modify
8
 * it under the terms of the GNU General Public License as published by
9
 * the Free Software Foundation, either version 3 of the License, or
10
 * (at your option) any later version.
11
 *
12
 * This program is distributed in the hope that it will be useful,
13
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
14
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
15
 * GNU General Public License for more details.
16
 *
17
 * You should have received a copy of the GNU General Public License
18
 * along with this program. If not, see <https://www.gnu.org/licenses/>.
19
 */
20
21
namespace Fisharebest\LaravelAssets;
22
23
use Fisharebest\LaravelAssets\Commands\Purge;
24
use Fisharebest\LaravelAssets\Filters\FilterInterface;
25
use Fisharebest\LaravelAssets\Loaders\LoaderInterface;
26
use Fisharebest\LaravelAssets\Notifiers\NotifierInterface;
27
use InvalidArgumentException;
28
use League\Flysystem\FileExistsException;
29
use League\Flysystem\FileNotFoundException;
30
use League\Flysystem\Filesystem;
31
32
class Assets
33
{
34
    /**
35
     * Regular expression to match a CSS url
36
     */
37
    const REGEX_CSS = '/\.css$/i';
38
39
    /**
40
     * Regular expression to match a JS url
41
     */
42
    const REGEX_JS = '/\.js$/i';
43
44
    /**
45
     * Regular expression to match a minified CSS url
46
     */
47
    const REGEX_MINIFIED_CSS = '/[.-]min\.css$/i';
48
49
    /**
50
     * Regular expression to match a minified JS url
51
     */
52
    const REGEX_MINIFIED_JS = '/[.-]min\.js$/i';
53
54
    /**
55
     * Regular expression to match an external url
56
     */
57
    const REGEX_EXTERNAL_URL = '/^((https?:)?\/\/|data:)/i';
58
59
    /**
60
     * File type detection options
61
     */
62
    const TYPE_CSS  = 'css';
63
    const TYPE_JS   = 'js';
64
    const TYPE_AUTO = 'auto';
65
66
    /**
67
     * File group options.  Most sites will only use the default group.
68
     */
69
    const GROUP_DEFAULT = '';
70
71
    /**
72
     * Format HTML links using printf()
73
     */
74
    const FORMAT_CSS_LINK = '<link%s rel="stylesheet" href="%s">';
75
    const FORMAT_JS_LINK  = '<script%s src="%s"></script>';
76
77
    /**
78
     * Format inline assets using printf()
79
     */
80
    const FORMAT_CSS_INLINE = '<style>%s</style>';
81
    const FORMAT_JS_INLINE  = '<script>%s</script>';
82
83
    /**
84
     * Enable the pipeline and minify functions.
85
     *
86
     * @var bool
87
     */
88
    private $enabled;
89
90
    /**
91
     * Where do we read CSS files.
92
     *
93
     * @var string
94
     */
95
    private $css_source;
96
97
    /**
98
     * Where do we read JS files.
99
     *
100
     * @var string
101
     */
102
    private $js_source;
103
104
    /**
105
     * Where do we write CSS/JS files.
106
     *
107
     * @var string
108
     */
109
    private $destination;
110
111
    /**
112
     * Where does the client read CSS/JS files.
113
     *
114
     * @var string
115
     */
116
    private $destination_url;
117
118
    /**
119
     * How to process CSS files.
120
     *
121
     * @var FilterInterface[]
122
     */
123
    private $css_filters;
124
125
    /**
126
     * How to process JS files.
127
     *
128
     * @var FilterInterface[]
129
     */
130
    private $js_filters;
131
132
    /**
133
     * How to load external files.
134
     *
135
     * @var LoaderInterface
136
     */
137
    private $loader;
138
139
    /**
140
     * Do something when we create an asset file.
141
     *
142
     * @var NotifierInterface[]
143
     */
144
    private $notifiers;
145
146
    /**
147
     * Assets smaller than this will be rendered inline, saving an HTTP request.
148
     *
149
     * @var int
150
     */
151
    private $inline_threshold;
152
153
    /**
154
     * Create compressed version of assets, to support the NGINX gzip_static option.
155
     *
156
     * @var int
157
     */
158
    private $gzip_static;
159
160
    /**
161
     * Predefined sets of resources.  Can be nested to arbitrary depth.
162
     *
163
     * @var string[]|array[]
164
     */
165
    private $collections;
166
167
    /**
168
     * CSS assets to be processed
169
     *
170
     * @var string[][]
171
     */
172
    private $css_assets = array();
173
174
    /**
175
     * Javascript assets to be processed
176
     *
177
     * @var string[][]
178
     */
179
    private $js_assets = array();
180
181
    /**
182
     * The filesystem corresponding to our public path.
183
     *
184
     * @var Filesystem
185
     */
186
    private $public;
187
188
    /**
189
     * Create an asset manager.
190
     *
191
     * @param array      $config     The local config, merged with the default config
192
     * @param Filesystem $filesystem The public filesystem, where we read/write assets
193
     */
194
    public function __construct(array $config, Filesystem $filesystem)
195
    {
196
        $this
197
            ->setEnabled($config['enabled'])
198
            ->setCssSource($config['css_source'])
199
            ->setJsSource($config['js_source'])
200
            ->setDestination($config['destination'])
201
            ->setDestinationUrl($config['destination_url'])
202
            ->setCssFilters($config['css_filters'])
203
            ->setJsFilters($config['js_filters'])
204
            ->setLoader($config['loader'])
205
            ->setNotifiers($config['notifiers'])
206
            ->setInlineThreshold($config['inline_threshold'])
207
            ->setGzipStatic($config['gzip_static'])
208
            ->setCollections($config['collections']);
209
210
        $this->public = $filesystem;
211
    }
212
213
    /**
214
     * @param string $css_source
215
     *
216
     * @return Assets
217
     */
218
    public function setCssSource($css_source)
219
    {
220
        $this->css_source = trim($css_source, '/');
221
222
        return $this;
223
    }
224
225
    /**
226
     * @return string
227
     */
228
    public function getCssSource()
229
    {
230
        return $this->css_source;
231
    }
232
233
    /**
234
     * @param string $js_source
235
     *
236
     * @return Assets
237
     */
238
    public function setJsSource($js_source)
239
    {
240
        $this->js_source = trim($js_source, '/');
241
242
        return $this;
243
    }
244
245
    /**
246
     * @return string
247
     */
248
    public function getJsSource()
249
    {
250
        return $this->js_source;
251
    }
252
253
    /**
254
     * @param string $destination
255
     *
256
     * @return Assets
257
     */
258
    public function setDestination($destination)
259
    {
260
        $this->destination = trim($destination, '/');
261
262
        return $this;
263
    }
264
265
    /**
266
     * @return string
267
     */
268
    public function getDestination()
269
    {
270
        return $this->destination;
271
    }
272
273
    /**
274
     * An (optional) absolute URL for fetching generated assets.
275
     *
276
     * @param string $destination_url
277
     *
278
     * @return Assets
279
     */
280
    public function setDestinationUrl($destination_url)
281
    {
282
        $this->destination_url = rtrim($destination_url, '/');
283
284
        return $this;
285
    }
286
287
    /**
288
     * @return string
289
     */
290
    public function getDestinationUrl()
291
    {
292
        return $this->destination_url;
293
    }
294
295
    /**
296
     * @param FilterInterface[] $css_filters
297
     *
298
     * @return Assets
299
     */
300
    public function setCssFilters(array $css_filters)
301
    {
302
        $this->css_filters = $css_filters;
303
304
        return $this;
305
    }
306
307
    /**
308
     * @return FilterInterface[]
309
     */
310
    public function getCssFilters()
311
    {
312
        return $this->css_filters;
313
    }
314
315
    /**
316
     * @param FilterInterface[] $js_filters
317
     *
318
     * @return Assets
319
     */
320
    public function setJsFilters(array $js_filters)
321
    {
322
        $this->js_filters = $js_filters;
323
324
        return $this;
325
    }
326
327
    /**
328
     * @return FilterInterface[]
329
     */
330
    public function getJsFilters()
331
    {
332
        return $this->js_filters;
333
    }
334
335
    /**
336
     * @param LoaderInterface $loader
337
     *
338
     * @return Assets
339
     */
340
    public function setLoader($loader)
341
    {
342
        $this->loader = $loader;
343
344
        return $this;
345
    }
346
347
    /**
348
     * @return LoaderInterface
349
     */
350
    public function getLoader()
351
    {
352
        return $this->loader;
353
    }
354
355
    /**
356
     * @param NotifierInterface[] $notifiers
357
     *
358
     * @return Assets
359
     */
360
    public function setNotifiers(array $notifiers)
361
    {
362
        $this->notifiers = $notifiers;
363
364
        return $this;
365
    }
366
367
    /**
368
     * @return NotifierInterface[]
369
     */
370
    public function getNotifiers()
371
    {
372
        return $this->notifiers;
373
    }
374
375
    /**
376
     * @param boolean $enabled
377
     *
378
     * @return Assets
379
     */
380
    public function setEnabled($enabled)
381
    {
382
        $this->enabled = (bool) $enabled;
383
384
        return $this;
385
    }
386
387
    /**
388
     * @return boolean
389
     */
390
    public function isEnabled()
391
    {
392
        return $this->enabled;
393
    }
394
395
    /**
396
     * @param int $inline_threshold
397
     *
398
     * @return Assets
399
     */
400
    public function setInlineThreshold($inline_threshold)
401
    {
402
        $this->inline_threshold = (int) $inline_threshold;
403
404
        return $this;
405
    }
406
407
    /**
408
     * @return int
409
     */
410
    public function getInlineThreshold()
411
    {
412
        return $this->inline_threshold;
413
    }
414
415
    /**
416
     * @param int $gzip_static
417
     *
418
     * @return Assets
419
     */
420
    public function setGzipStatic($gzip_static)
421
    {
422
        $this->gzip_static = (int) $gzip_static;
423
424
        return $this;
425
    }
426
427
    /**
428
     * @return int
429
     */
430
    public function getGzipStatic()
431
    {
432
        return $this->gzip_static;
433
    }
434
435
    /**
436
     * @param array[]|string[] $collections
437
     *
438
     * @return Assets
439
     */
440
    public function setCollections($collections)
441
    {
442
        $this->collections = $collections;
443
444
        return $this;
445
    }
446
447
    /**
448
     * @return array[]|string[]
449
     */
450
    public function getCollections()
451
    {
452
        return $this->collections;
453
    }
454
455
    /**
456
     * Add one or more assets.
457
     *
458
     * @param string|string[] $asset A local filename, a remote URL or the name of a collection.
459
     * @param string          $type  Force a file type, "css" or "js", instead of using the extension.
460
     * @param string          $group Optionally split your assets into multiple groups, such as "head" and "body".
461
     *
462
     * @return Assets
463
     */
464
    public function add($asset, $type = self::TYPE_AUTO, $group = self::GROUP_DEFAULT)
465
    {
466
        $this->checkGroupExists($group);
467
468
        if (is_array($asset)) {
469
            foreach ($asset as $a) {
470
                $this->add($a, $type, $group);
471
            }
472
        } elseif ($type === self::TYPE_CSS || $type === self::TYPE_AUTO && preg_match(self::REGEX_CSS, $asset)) {
473
            if (!in_array($asset, $this->css_assets[$group], true)) {
474
                $this->css_assets[$group][] = $asset;
475
            }
476
        } elseif ($type === self::TYPE_JS || $type === self::TYPE_AUTO && preg_match(self::REGEX_JS, $asset)) {
477
            if (!in_array($asset, $this->js_assets[$group], true)) {
478
                $this->js_assets[$group][] = $asset;
479
            }
480
        } elseif (array_key_exists($asset, $this->collections)) {
481
            $this->add($this->collections[$asset], $type, $group);
482
        } else {
483
            throw new InvalidArgumentException('Unknown asset type: ' . $asset);
484
        }
485
486
        return $this;
487
    }
488
489
    /**
490
     * Render markup to load the CSS assets.
491
     *
492
     * @param string   $group      Optionally split your assets into multiple groups, such as "head" and "body".
493
     * @param string[] $attributes Optional attributes, such as ['media' => 'print']
494
     *
495
     * @return string
496
     * @throws FileExistsException
497
     * @throws FileNotFoundException
498
     */
499
    public function css($group = self::GROUP_DEFAULT, array $attributes = [])
500
    {
501
        $this->checkGroupExists($group);
502
503
        return $this->processAssets(
504
            $attributes,
505
            $this->css_assets[$group],
506
            '.css',
507
            $this->getCssSource(),
508
            $this->getCssFilters(),
509
            self::FORMAT_CSS_LINK,
510
            self::FORMAT_CSS_INLINE
511
        );
512
    }
513
514
    /**
515
     * Render markup to load the JS assets.
516
     *
517
     * @param string   $group      Optionally split your assets into multiple groups, such as "head" and "body".
518
     * @param string[] $attributes Optional attributes, such as ['async']
519
     *
520
     * @return string
521
     * @throws FileExistsException
522
     * @throws FileNotFoundException
523
     */
524
    public function js($group = self::GROUP_DEFAULT, array $attributes = [])
525
    {
526
        $this->checkGroupExists($group);
527
528
        return $this->processAssets(
529
            $attributes,
530
            $this->js_assets[$group],
531
            '.js',
532
            $this->getJsSource(),
533
            $this->getJsFilters(),
534
            self::FORMAT_JS_LINK,
535
            self::FORMAT_JS_INLINE
536
        );
537
    }
538
539
    /**
540
     * Is a URL absolute or relative?
541
     *
542
     * @param string $url
543
     *
544
     * @return bool
545
     */
546
    public function isAbsoluteUrl($url)
547
    {
548
        return preg_match(self::REGEX_EXTERNAL_URL, $url) === 1;
549
    }
550
551
    /**
552
     * Normalize a path, removing '.' and '..' folders. e.g.
553
     *
554
     * "a/b/./c/../../d" becomes "a/d"
555
     *
556
     * @param string $url
557
     *
558
     * @return string
559
     */
560
    public function normalizePath($url)
561
    {
562
        while (strpos($url, '/./') !== false) {
563
            $url = str_replace('/./', '/', $url);
564
        }
565
        while (strpos($url, '/../') !== false) {
566
            $url = preg_replace('/[^\/]+\/\.\.\//', '', $url, 1);
567
        }
568
569
        return $url;
570
    }
571
572
    /**
573
     * Create a relative path between two URLs.
574
     *
575
     * e.g. the relative path from "a/b/c" to "a/d" is "../../d"
576
     *
577
     * @param string $source
578
     * @param string $destination
579
     *
580
     * @return string
581
     */
582
    public function relativePath($source, $destination)
583
    {
584
        if ($source === '') {
585
            return $destination;
586
        }
587
588
        $parts1 = explode('/', $source);
589
        $parts2 = explode('/', $destination);
590
591
        while (!empty($parts1) && !empty($parts2) && $parts1[0] === $parts2[0]) {
592
            array_shift($parts1);
593
            array_shift($parts2);
594
        }
595
596
        return str_repeat('../', count($parts1)) . implode('/', $parts2);
597
    }
598
599
    /**
600
     * Purge generated assets older than a given number of days
601
     *
602
     * @param Purge $command
603
     *
604
     * @throws FileNotFoundException
605
     */
606
    public function purge(Purge $command)
607
    {
608
        $days      = (int) $command->option('days');
609
        $verbose   = (bool) $command->option('verbose');
610
        $files     = $this->public->listContents($this->getDestination(), true);
611
        $timestamp = time() - $days * 86400;
612
613
        foreach ($files as $file) {
614
            if ($this->needsPurge($file, $timestamp)) {
615
                $this->public->delete($file['path']);
616
                $command->info('Deleted: ' . $file['path']);
617
            } elseif ($verbose) {
618
                $command->info('Keeping: ' . $file['path']);
619
            }
620
        }
621
    }
622
623
    /**
624
     * Render markup to load the CSS or JS assets.
625
     *
626
     * @param string[]          $attributes    Optional attributes, such as ['async']
627
     * @param string[]          $assets        The files to be processed
628
     * @param string            $extension     ".css" or ".js"
629
     * @param string            $source_dir    The folder containing the source assets
630
     * @param FilterInterface[] $filters       How to process these assets
631
     * @param string            $format_link   Template for an HTML link to the asset
632
     * @param string            $format_inline Template for an inline asset
633
     *
634
     * @return string
635
     * @throws FileNotFoundException
636
     * @throws FileExistsException
637
     */
638
    private function processAssets(
639
        array $attributes,
640
        array $assets,
641
        $extension,
642
        $source_dir,
643
        $filters,
644
        $format_link,
645
        $format_inline
646
    ) {
647
        $hashes = [];
648
        $path   = $this->getDestination();
649
650
        foreach ($assets as $asset) {
651
            if ($this->isAbsoluteUrl($asset)) {
652
                $hash = $this->hash($asset);
653
            } else {
654
                $hash = $this->hash($asset . $this->public->getTimestamp($source_dir . '/' . $asset));
0 ignored issues
show
Bug introduced by
Are you sure $this->public->getTimest...rce_dir . '/' . $asset) of type false|integer can be used in concatenation? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

654
                $hash = $this->hash($asset . /** @scrutinizer ignore-type */ $this->public->getTimestamp($source_dir . '/' . $asset));
Loading history...
655
            }
656
            if (!$this->public->has($path . '/' . $hash . $extension)) {
657
                if ($this->isAbsoluteUrl($asset)) {
658
                    $data = $this->getLoader()->loadUrl($asset);
659
                } else {
660
                    $data = $this->public->read($source_dir . '/' . $asset);
661
                }
662
                foreach ($filters as $filter) {
663
                    $data = $filter->filter($data, $asset, $this);
664
                }
665
                $this->public->write($path . '/' . $hash . $extension, $data);
666
                $this->public->write($path . '/' . $hash . '.min' . $extension, $data);
667
            }
668
            $hashes[] = $hash;
669
        }
670
671
        // The file name of our pipelined asset.
672
        $hash       = $this->hash(implode('', $hashes));
673
        $asset_file = $path . '/' . $hash . '.min' . $extension;
674
675
        $this->concatenateFiles($path, $hashes, $hash, $extension);
676
        $this->concatenateFiles($path, $hashes, $hash, '.min' . $extension);
677
678
        $this->createGzip($asset_file);
679
680
        foreach ($this->notifiers as $notifier) {
681
            $notifier->created($asset_file);
682
        }
683
684
        if ($this->getDestinationUrl() === '') {
685
            $url = url($path);
686
        } else {
687
            $url = $this->getDestinationUrl();
688
        }
689
690
        if ($this->isEnabled()) {
691
            $inline_threshold = $this->getInlineThreshold();
692
            if ($inline_threshold > 0 && $this->public->getSize($asset_file) <= $inline_threshold) {
693
                return sprintf($format_inline, $this->public->read($asset_file));
0 ignored issues
show
Bug introduced by
It seems like $this->public->read($asset_file) can also be of type false; however, parameter $values of sprintf() does only seem to accept double|integer|string, 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

693
                return sprintf($format_inline, /** @scrutinizer ignore-type */ $this->public->read($asset_file));
Loading history...
694
            }
695
696
            return $this->htmlLinks($url, [$hash], '.min' . $extension, $format_link, $attributes);
697
        }
698
699
        return $this->htmlLinks($url, $hashes, $extension, $format_link, $attributes);
700
    }
701
702
    /**
703
     * Make sure that the specified group (i.e. array key) exists.
704
     *
705
     * @param string $group
706
     */
707
    private function checkGroupExists($group)
708
    {
709
        if (!array_key_exists($group, $this->css_assets)) {
710
            $this->css_assets[$group] = [];
711
        }
712
        if (!array_key_exists($group, $this->js_assets)) {
713
            $this->js_assets[$group] = [];
714
        }
715
    }
716
717
    /**
718
     * Concatenate a number of files.
719
     *
720
     * @param string   $path        subfolder containing assets to be combined
721
     * @param string[] $sources     Filenames (without extension) to be combined
722
     * @param string   $destination Filename (without extension) to be created
723
     * @param string   $extension   ".css", ".min.js", etc.
724
     *
725
     * @throws FileNotFoundException
726
     * @throws FileExistsException
727
     */
728
    private function concatenateFiles($path, $sources, $destination, $extension)
729
    {
730
        if (!$this->public->has($path . '/' . $destination . $extension)) {
731
            $data = '';
732
            foreach ($sources as $source) {
733
                $data .= $this->public->read($path . '/' . $source . $extension);
734
            }
735
            $this->public->write($path . '/' . $destination . $extension, $data);
736
        }
737
    }
738
739
    /**
740
     * Generate a hash, to use as a filename for generated assets.
741
     *
742
     * @param string $text
743
     *
744
     * @return string
745
     */
746
    private function hash($text)
747
    {
748
        return md5($text);
749
    }
750
751
    /**
752
     * Optionally create a .gz version of a file - to support the NGINX gzip_static option.
753
     *
754
     * @param string $path
755
     *
756
     * @throws FileNotFoundException
757
     * @throws FileExistsException
758
     */
759
    private function createGzip($path)
760
    {
761
        $gzip = $this->getGzipStatic();
762
763
        if ($gzip >= 1 && $gzip <= 9 && function_exists('gzcompress') && !$this->public->has($path . '.gz')) {
764
            $content    = $this->public->read($path);
765
            $content_gz = gzcompress($content, $gzip);
0 ignored issues
show
Bug introduced by
It seems like $content can also be of type false; however, parameter $data of gzcompress() does only seem to accept string, 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

765
            $content_gz = gzcompress(/** @scrutinizer ignore-type */ $content, $gzip);
Loading history...
766
            $this->public->write($path . '.gz', $content_gz);
767
        }
768
    }
769
770
    /**
771
     * Generate HTML links to a list of processed asset files.
772
     *
773
     * @param string   $url       path to the assets
774
     * @param string[] $hashes    base filename
775
     * @param string   $extension ".css", ".min.js", etc.
776
     * @param string   $format
777
     * @param string[] $attributes
778
     *
779
     * @return string
780
     */
781
    private function htmlLinks($url, $hashes, $extension, $format, $attributes)
782
    {
783
        $html_attributes = $this->convertAttributesToHtml($attributes);
784
785
        $html_links = '';
786
        foreach ($hashes as $asset) {
787
            $html_links .= sprintf($format, $html_attributes, $url . '/' . $asset . $extension);
788
        }
789
790
        return $html_links;
791
    }
792
793
    /**
794
     * Convert an array of attributes to HTML.
795
     *
796
     * @param string[] $attributes
797
     *
798
     * @return string
799
     */
800
    private function convertAttributesToHtml(array $attributes)
801
    {
802
        $html = '';
803
        foreach ($attributes as $key => $value) {
804
            if (is_int($key)) {
805
                $html .= ' ' . $value;
806
            } else {
807
                $html .= ' ' . $key . '="' . $value . '"';
808
            }
809
        }
810
811
        return $html;
812
    }
813
814
    /**
815
     * @param array $file
816
     * @param int   $timestamp
817
     *
818
     * @return bool
819
     */
820
    private function needsPurge(array $file, $timestamp)
821
    {
822
        $eligible = preg_match(self::REGEX_JS, $file['path']) || preg_match(self::REGEX_CSS, $file['path']);
823
824
        return $eligible && $file['timestamp'] <= $timestamp;
825
    }
826
}
827