Passed
Pull Request — 4 (#10134)
by Sergey
13:21 queued 06:04
created

Requirements_Backend::resolveCSSReferences()   A

Complexity

Conditions 4
Paths 2

Size

Total Lines 24
Code Lines 19

Duplication

Lines 0
Ratio 0 %

Importance

Changes 1
Bugs 0 Features 0
Metric Value
cc 4
eloc 19
nc 2
nop 2
dl 0
loc 24
rs 9.6333
c 1
b 0
f 0
1
<?php
2
3
namespace SilverStripe\View;
4
5
use Exception;
6
use InvalidArgumentException;
7
use SilverStripe\Assets\File;
8
use SilverStripe\Assets\Storage\GeneratedAssetHandler;
9
use SilverStripe\Control\Director;
10
use SilverStripe\Control\HTTPResponse;
11
use SilverStripe\Core\Config\Config;
12
use SilverStripe\Core\Convert;
13
use SilverStripe\Core\Injector\Injectable;
14
use SilverStripe\Core\Injector\Injector;
15
use SilverStripe\Core\Manifest\ModuleResourceLoader;
16
use SilverStripe\Core\Manifest\ResourceURLGenerator;
17
use SilverStripe\Core\Path;
18
use SilverStripe\Dev\Debug;
19
use SilverStripe\Dev\Deprecation;
20
use SilverStripe\i18n\i18n;
21
use SilverStripe\ORM\FieldType\DBField;
22
23
class Requirements_Backend
24
{
25
    use Injectable;
26
27
    /**
28
     * Whether to add caching query params to the requests for file-based requirements.
29
     * Eg: themes/myTheme/js/main.js?m=123456789. The parameter is a timestamp generated by
30
     * filemtime. This has the benefit of allowing the browser to cache the URL infinitely,
31
     * while automatically busting this cache every time the file is changed.
32
     *
33
     * @var bool
34
     */
35
    protected $suffixRequirements = true;
36
37
    /**
38
     * Whether to combine CSS and JavaScript files
39
     *
40
     * @var bool|null
41
     */
42
    protected $combinedFilesEnabled = null;
43
44
    /**
45
     * Determine if files should be combined automatically on dev mode.
46
     *
47
     * By default combined files will not be combined except in test or
48
     * live environments. Turning this on will allow for pre-combining of files in development mode.
49
     *
50
     * @config
51
     * @var bool
52
     */
53
    private static $combine_in_dev = false;
54
55
    /**
56
     * Determine if relative urls in the combined files should be converted to absolute.
57
     *
58
     * By default combined files will be parsed for relative URLs to image/font assets and those
59
     * URLs will be changed to absolute to accomodate the fact that the combined css is placed
60
     * in a totally different folder than the source css files.
61
     *
62
     * Turn this off if you see some unexpected results.
63
     *
64
     * @config
65
     * @var bool
66
     */
67
    private static $resolve_relative_css_refs = true;
68
69
    /**
70
     * Paths to all required JavaScript files relative to docroot
71
     *
72
     * @var array
73
     */
74
    protected $javascript = [];
75
76
    /**
77
     * Map of included scripts to array of contained files.
78
     * To be used alongside front-end combination mechanisms.
79
     *
80
     * @var array Map of providing filepath => array(provided filepaths)
81
     */
82
    protected $providedJavascript = [];
83
84
    /**
85
     * Paths to all required CSS files relative to the docroot.
86
     *
87
     * @var array
88
     */
89
    protected $css = [];
90
91
    /**
92
     * All custom javascript code that is inserted into the page's HTML
93
     *
94
     * @var array
95
     */
96
    protected $customScript = [];
97
98
    /**
99
     * All custom CSS rules which are inserted directly at the bottom of the HTML `<head>` tag
100
     *
101
     * @var array
102
     */
103
    protected $customCSS = [];
104
105
    /**
106
     * All custom HTML markup which is added before the closing `<head>` tag, e.g. additional
107
     * metatags.
108
     *
109
     * @var array
110
     */
111
    protected $customHeadTags = [];
112
113
    /**
114
     * Remembers the file paths or uniquenessIDs of all Requirements cleared through
115
     * {@link clear()}, so that they can be restored later.
116
     *
117
     * @var array
118
     */
119
    protected $disabled = [];
120
121
    /**
122
     * The file paths (relative to docroot) or uniquenessIDs of any included requirements which
123
     * should be blocked when executing {@link inlcudeInHTML()}. This is useful, for example,
124
     * to block scripts included by a superclass without having to override entire functions and
125
     * duplicate a lot of code.
126
     *
127
     * Use {@link unblock()} or {@link unblock_all()} to revert changes.
128
     *
129
     * @var array
130
     */
131
    protected $blocked = [];
132
133
    /**
134
     * A list of combined files registered via {@link combine_files()}. Keys are the output file
135
     * names, values are lists of input files.
136
     *
137
     * @var array
138
     */
139
    protected $combinedFiles = [];
140
141
    /**
142
     * Use the injected minification service to minify any javascript file passed to {@link combine_files()}.
143
     *
144
     * @var bool
145
     */
146
    protected $minifyCombinedFiles = false;
147
148
    /**
149
     * Whether or not file headers should be written when combining files
150
     *
151
     * @var boolean
152
     */
153
    protected $writeHeaderComment = true;
154
155
    /**
156
     * Where to save combined files. By default they're placed in assets/_combinedfiles, however
157
     * this may be an issue depending on your setup, especially for CSS files which often contain
158
     * relative paths.
159
     *
160
     * @var string
161
     */
162
    protected $combinedFilesFolder = null;
163
164
    /**
165
     * Put all JavaScript includes at the bottom of the template before the closing `<body>` tag,
166
     * rather than the default behaviour of placing them at the end of the `<head>` tag. This means
167
     * script downloads won't block other HTTP requests, which can be a performance improvement.
168
     *
169
     * @var bool
170
     */
171
    public $writeJavascriptToBody = true;
172
173
    /**
174
     * Force the JavaScript to the bottom of the page, even if there's a script tag in the body already
175
     *
176
     * @var boolean
177
     */
178
    protected $forceJSToBottom = false;
179
180
    /**
181
     * Configures the default prefix for combined files.
182
     *
183
     * This defaults to `_combinedfiles`, and is the folder within the configured asset backend that
184
     * combined files will be stored in. If using a backend shared with other systems, it is usually
185
     * necessary to distinguish combined files from other assets.
186
     *
187
     * @config
188
     * @var string
189
     */
190
    private static $default_combined_files_folder = '_combinedfiles';
191
192
    /**
193
     * Flag to include the hash in the querystring instead of the filename for combined files.
194
     *
195
     * By default the `<hash>` of the source files is appended to the end of the combined file
196
     * (prior to the file extension). If combined files are versioned in source control or running
197
     * in a distributed environment (such as one where the newest version of a file may not always be
198
     * immediately available) then it may sometimes be necessary to disable this. When this is set to true,
199
     * the hash will instead be appended via a querystring parameter to enable cache busting, but not in
200
     * the filename itself. I.e. `assets/_combinedfiles/name.js?m=<hash>`
201
     *
202
     * @config
203
     * @var bool
204
     */
205
    private static $combine_hash_querystring = false;
206
207
    /**
208
     * @var GeneratedAssetHandler
209
     */
210
    protected $assetHandler = null;
211
212
    /**
213
     * @var Requirements_Minifier
214
     */
215
    protected $minifier = null;
216
217
    /**
218
     * Gets the backend storage for generated files
219
     *
220
     * @return GeneratedAssetHandler
221
     */
222
    public function getAssetHandler()
223
    {
224
        return $this->assetHandler;
225
    }
226
227
    /**
228
     * Set a new asset handler for this backend
229
     *
230
     * @param GeneratedAssetHandler $handler
231
     */
232
    public function setAssetHandler(GeneratedAssetHandler $handler)
233
    {
234
        $this->assetHandler = $handler;
235
    }
236
237
    /**
238
     * Gets the minification service for this backend
239
     *
240
     * @deprecated 4.0.0:5.0.0
241
     * @return Requirements_Minifier
242
     */
243
    public function getMinifier()
244
    {
245
        return $this->minifier;
246
    }
247
248
    /**
249
     * Set a new minification service for this backend
250
     *
251
     * @param Requirements_Minifier $minifier
252
     */
253
    public function setMinifier(Requirements_Minifier $minifier = null)
254
    {
255
        $this->minifier = $minifier;
256
    }
257
258
    /**
259
     * Enable or disable the combination of CSS and JavaScript files
260
     *
261
     * @param bool $enable
262
     */
263
    public function setCombinedFilesEnabled($enable)
264
    {
265
        $this->combinedFilesEnabled = (bool)$enable;
266
    }
267
268
    /**
269
     * Check if header comments are written
270
     *
271
     * @return bool
272
     */
273
    public function getWriteHeaderComment()
274
    {
275
        return $this->writeHeaderComment;
276
    }
277
278
    /**
279
     * Flag whether header comments should be written for each combined file
280
     *
281
     * @param bool $write
282
     * @return $this
283
     */
284
    public function setWriteHeaderComment($write)
285
    {
286
        $this->writeHeaderComment = $write;
287
        return $this;
288
    }
289
290
    /**
291
     * Set the folder to save combined files in. By default they're placed in _combinedfiles,
292
     * however this may be an issue depending on your setup, especially for CSS files which often
293
     * contain relative paths.
294
     *
295
     * This must not include any 'assets' prefix
296
     *
297
     * @param string $folder
298
     */
299
    public function setCombinedFilesFolder($folder)
300
    {
301
        $this->combinedFilesFolder = $folder;
302
    }
303
304
    /**
305
     * Retrieve the combined files folder prefix
306
     *
307
     * @return string
308
     */
309
    public function getCombinedFilesFolder()
310
    {
311
        if ($this->combinedFilesFolder) {
312
            return $this->combinedFilesFolder;
313
        }
314
        return Config::inst()->get(__CLASS__, 'default_combined_files_folder');
315
    }
316
317
    /**
318
     * Set whether to add caching query params to the requests for file-based requirements.
319
     * Eg: themes/myTheme/js/main.js?m=123456789. The parameter is a timestamp generated by
320
     * filemtime. This has the benefit of allowing the browser to cache the URL infinitely,
321
     * while automatically busting this cache every time the file is changed.
322
     *
323
     * @param bool $var
324
     */
325
    public function setSuffixRequirements($var)
326
    {
327
        $this->suffixRequirements = $var;
328
    }
329
330
    /**
331
     * Check whether we want to suffix requirements
332
     *
333
     * @return bool
334
     */
335
    public function getSuffixRequirements()
336
    {
337
        return $this->suffixRequirements;
338
    }
339
340
    /**
341
     * Set whether you want to write the JS to the body of the page rather than at the end of the
342
     * head tag.
343
     *
344
     * @param bool $var
345
     * @return $this
346
     */
347
    public function setWriteJavascriptToBody($var)
348
    {
349
        $this->writeJavascriptToBody = $var;
350
        return $this;
351
    }
352
353
    /**
354
     * Check whether you want to write the JS to the body of the page rather than at the end of the
355
     * head tag.
356
     *
357
     * @return bool
358
     */
359
    public function getWriteJavascriptToBody()
360
    {
361
        return $this->writeJavascriptToBody;
362
    }
363
364
    /**
365
     * Forces the JavaScript requirements to the end of the body, right before the closing tag
366
     *
367
     * @param bool $var
368
     * @return $this
369
     */
370
    public function setForceJSToBottom($var)
371
    {
372
        $this->forceJSToBottom = $var;
373
        return $this;
374
    }
375
376
    /**
377
     * Check if the JavaScript requirements are written to the end of the body, right before the closing tag
378
     *
379
     * @return bool
380
     */
381
    public function getForceJSToBottom()
382
    {
383
        return $this->forceJSToBottom;
384
    }
385
386
    /**
387
     * Check if minify files should be combined
388
     *
389
     * @return bool
390
     */
391
    public function getMinifyCombinedFiles()
392
    {
393
        return $this->minifyCombinedFiles;
394
    }
395
396
    /**
397
     * Set if combined files should be minified
398
     *
399
     * @param bool $minify
400
     * @return $this
401
     */
402
    public function setMinifyCombinedFiles($minify)
403
    {
404
        $this->minifyCombinedFiles = $minify;
405
        return $this;
406
    }
407
408
    /**
409
     * Register the given JavaScript file as required.
410
     *
411
     * @param string $file Either relative to docroot or in the form "vendor/package:resource"
412
     * @param array $options List of options. Available options include:
413
     * - 'provides' : List of scripts files included in this file
414
     * - 'async' : Boolean value to set async attribute to script tag
415
     * - 'defer' : Boolean value to set defer attribute to script tag
416
     * - 'type' : Override script type= value.
417
     * - 'integrity' : SubResource Integrity hash
418
     * - 'crossorigin' : Cross-origin policy for the resource
419
     */
420
    public function javascript($file, $options = [])
421
    {
422
        $file = ModuleResourceLoader::singleton()->resolvePath($file);
423
424
        // Get type
425
        $type = null;
426
        if (isset($this->javascript[$file]['type'])) {
427
            $type = $this->javascript[$file]['type'];
428
        }
429
        if (isset($options['type'])) {
430
            $type = $options['type'];
431
        }
432
433
        // make sure that async/defer is set if it is set once even if file is included multiple times
434
        $async = (
435
            isset($options['async']) && $options['async']
436
            || (
437
                isset($this->javascript[$file])
438
                && isset($this->javascript[$file]['async'])
439
                && $this->javascript[$file]['async']
440
            )
441
        );
442
        $defer = (
443
            isset($options['defer']) && $options['defer']
444
            || (
445
                isset($this->javascript[$file])
446
                && isset($this->javascript[$file]['defer'])
447
                && $this->javascript[$file]['defer']
448
            )
449
        );
450
        $integrity = $options['integrity'] ?? null;
451
        $crossorigin = $options['crossorigin'] ?? null;
452
453
        $this->javascript[$file] = [
454
            'async' => $async,
455
            'defer' => $defer,
456
            'type' => $type,
457
            'integrity' => $integrity,
458
            'crossorigin' => $crossorigin,
459
        ];
460
461
        // Record scripts included in this file
462
        if (isset($options['provides'])) {
463
            $this->providedJavascript[$file] = array_values($options['provides']);
464
        }
465
    }
466
467
    /**
468
     * Remove a javascript requirement
469
     *
470
     * @param string $file
471
     */
472
    protected function unsetJavascript($file)
473
    {
474
        unset($this->javascript[$file]);
475
    }
476
477
    /**
478
     * Gets all scripts that are already provided by prior scripts.
479
     * This follows these rules:
480
     *  - Files will not be considered provided if they are separately
481
     *    included prior to the providing file.
482
     *  - Providing files can be blocked, and don't provide anything
483
     *  - Provided files can't be blocked (you need to block the provider)
484
     *  - If a combined file includes files that are provided by prior
485
     *    scripts, then these should be excluded from the combined file.
486
     *  - If a combined file includes files that are provided by later
487
     *    scripts, then these files should be included in the combined
488
     *    file, but we can't block the later script either (possible double
489
     *    up of file).
490
     *
491
     * @return array Array of provided files (map of $path => $path)
492
     */
493
    public function getProvidedScripts()
494
    {
495
        $providedScripts = [];
496
        $includedScripts = [];
497
        foreach ($this->javascript as $script => $options) {
498
            // Ignore scripts that are explicitly blocked
499
            if (isset($this->blocked[$script])) {
500
                continue;
501
            }
502
            // At this point, the file is included.
503
            // This might also be combined at this point, potentially.
504
            $includedScripts[$script] = true;
505
506
            // Record any files this provides, EXCEPT those already included by now
507
            if (isset($this->providedJavascript[$script])) {
508
                foreach ($this->providedJavascript[$script] as $provided) {
509
                    if (!isset($includedScripts[$provided])) {
510
                        $providedScripts[$provided] = $provided;
511
                    }
512
                }
513
            }
514
        }
515
        return $providedScripts;
516
    }
517
518
    /**
519
     * Returns an array of required JavaScript, excluding blocked
520
     * and duplicates of provided files.
521
     *
522
     * @return array
523
     */
524
    public function getJavascript()
525
    {
526
        return array_diff_key(
527
            $this->javascript,
528
            $this->getBlocked(),
529
            $this->getProvidedScripts()
530
        );
531
    }
532
533
    /**
534
     * Gets all javascript, including blocked files. Unwraps the array into a non-associative list
535
     *
536
     * @return array Indexed array of javascript files
537
     */
538
    protected function getAllJavascript()
539
    {
540
        return $this->javascript;
541
    }
542
543
    /**
544
     * Register the given JavaScript code into the list of requirements
545
     *
546
     * @param string $script The script content as a string (without enclosing `<script>` tag)
547
     * @param string $uniquenessID A unique ID that ensures a piece of code is only added once
548
     */
549
    public function customScript($script, $uniquenessID = null)
550
    {
551
        if ($uniquenessID) {
552
            $this->customScript[$uniquenessID] = $script;
553
        } else {
554
            $this->customScript[] = $script;
555
        }
556
    }
557
558
    /**
559
     * Return all registered custom scripts
560
     *
561
     * @return array
562
     */
563
    public function getCustomScripts()
564
    {
565
        return array_diff_key($this->customScript, $this->blocked);
566
    }
567
568
    /**
569
     * Register the given CSS styles into the list of requirements
570
     *
571
     * @param string $script CSS selectors as a string (without enclosing `<style>` tag)
572
     * @param string $uniquenessID A unique ID that ensures a piece of code is only added once
573
     */
574
    public function customCSS($script, $uniquenessID = null)
575
    {
576
        if ($uniquenessID) {
577
            $this->customCSS[$uniquenessID] = $script;
578
        } else {
579
            $this->customCSS[] = $script;
580
        }
581
    }
582
583
    /**
584
     * Return all registered custom CSS
585
     *
586
     * @return array
587
     */
588
    public function getCustomCSS()
589
    {
590
        return array_diff_key($this->customCSS, $this->blocked);
591
    }
592
593
    /**
594
     * Add the following custom HTML code to the `<head>` section of the page
595
     *
596
     * @param string $html Custom HTML code
597
     * @param string $uniquenessID A unique ID that ensures a piece of code is only added once
598
     */
599
    public function insertHeadTags($html, $uniquenessID = null)
600
    {
601
        if ($uniquenessID) {
602
            $this->customHeadTags[$uniquenessID] = $html;
603
        } else {
604
            $this->customHeadTags[] = $html;
605
        }
606
    }
607
608
    /**
609
     * Return all custom head tags
610
     *
611
     * @return array
612
     */
613
    public function getCustomHeadTags()
614
    {
615
        return array_diff_key($this->customHeadTags, $this->blocked);
616
    }
617
618
    /**
619
     * Include the content of the given JavaScript file in the list of requirements. Dollar-sign
620
     * variables will be interpolated with values from $vars similar to a .ss template.
621
     *
622
     * @param string $file The template file to load, relative to docroot
623
     * @param string[] $vars The array of variables to interpolate.
624
     * @param string $uniquenessID A unique ID that ensures a piece of code is only added once
625
     */
626
    public function javascriptTemplate($file, $vars, $uniquenessID = null)
627
    {
628
        $file = ModuleResourceLoader::singleton()->resolvePath($file);
629
        $absolutePath = Director::getAbsFile($file);
630
        if (!file_exists($absolutePath)) {
631
            throw new InvalidArgumentException("Javascript template file {$file} does not exist");
632
        }
633
634
        $script = file_get_contents($absolutePath);
635
        $search = [];
636
        $replace = [];
637
638
        if ($vars) {
0 ignored issues
show
Bug Best Practice introduced by
The expression $vars of type string[] is implicitly converted to a boolean; are you sure this is intended? If so, consider using ! empty($expr) instead to make it clear that you intend to check for an array without elements.

This check marks implicit conversions of arrays to boolean values in a comparison. While in PHP an empty array is considered to be equal (but not identical) to false, this is not always apparent.

Consider making the comparison explicit by using empty(..) or ! empty(...) instead.

Loading history...
639
            foreach ($vars as $k => $v) {
640
                $search[] = '$' . $k;
641
                $replace[] = str_replace("\\'", "'", Convert::raw2js($v));
642
            }
643
        }
644
645
        $script = str_replace($search, $replace, $script);
646
        $this->customScript($script, $uniquenessID);
647
    }
648
649
    /**
650
     * Register the given stylesheet into the list of requirements.
651
     *
652
     * @param string $file The CSS file to load, relative to site root
653
     * @param string $media Comma-separated list of media types to use in the link tag
654
     *                      (e.g. 'screen,projector')
655
     * @param array $options List of options. Available options include:
656
     * - 'integrity' : SubResource Integrity hash
657
     * - 'crossorigin' : Cross-origin policy for the resource
658
     */
659
    public function css($file, $media = null, $options = [])
660
    {
661
        $file = ModuleResourceLoader::singleton()->resolvePath($file);
662
663
        $integrity = $options['integrity'] ?? null;
664
        $crossorigin = $options['crossorigin'] ?? null;
665
666
        $this->css[$file] = [
667
            "media" => $media,
668
            "integrity" => $integrity,
669
            "crossorigin" => $crossorigin,
670
        ];
671
    }
672
673
    /**
674
     * Remove a css requirement
675
     *
676
     * @param string $file
677
     */
678
    protected function unsetCSS($file)
679
    {
680
        unset($this->css[$file]);
681
    }
682
683
    /**
684
     * Get the list of registered CSS file requirements, excluding blocked files
685
     *
686
     * @return array Associative array of file to spec
687
     */
688
    public function getCSS()
689
    {
690
        return array_diff_key($this->css, $this->blocked);
691
    }
692
693
    /**
694
     * Gets all CSS files requirements, including blocked
695
     *
696
     * @return array Associative array of file to spec
697
     */
698
    protected function getAllCSS()
699
    {
700
        return $this->css;
701
    }
702
703
    /**
704
     * Gets the list of all blocked files
705
     *
706
     * @return array
707
     */
708
    public function getBlocked()
709
    {
710
        return $this->blocked;
711
    }
712
713
    /**
714
     * Clear either a single or all requirements
715
     *
716
     * Caution: Clearing single rules added via customCSS and customScript only works if you
717
     * originally specified a $uniquenessID.
718
     *
719
     * @param string|int $fileOrID
720
     */
721
    public function clear($fileOrID = null)
722
    {
723
        $types = [
724
            'javascript',
725
            'css',
726
            'customScript',
727
            'customCSS',
728
            'customHeadTags',
729
            'combinedFiles',
730
        ];
731
        foreach ($types as $type) {
732
            if ($fileOrID) {
733
                if (isset($this->{$type}[$fileOrID])) {
734
                    $this->disabled[$type][$fileOrID] = $this->{$type}[$fileOrID];
735
                    unset($this->{$type}[$fileOrID]);
736
                }
737
            } else {
738
                $this->disabled[$type] = $this->{$type};
739
                $this->{$type} = [];
740
            }
741
        }
742
    }
743
744
    /**
745
     * Restore requirements cleared by call to Requirements::clear
746
     */
747
    public function restore()
748
    {
749
        $types = [
750
            'javascript',
751
            'css',
752
            'customScript',
753
            'customCSS',
754
            'customHeadTags',
755
            'combinedFiles',
756
        ];
757
        foreach ($types as $type) {
758
            $this->{$type} = $this->disabled[$type];
759
        }
760
    }
761
762
    /**
763
     * Block inclusion of a specific file
764
     *
765
     * The difference between this and {@link clear} is that the calling order does not matter;
766
     * {@link clear} must be called after the initial registration, whereas {@link block} can be
767
     * used in advance. This is useful, for example, to block scripts included by a superclass
768
     * without having to override entire functions and duplicate a lot of code.
769
     *
770
     * Note that blocking should be used sparingly because it's hard to trace where an file is
771
     * being blocked from.
772
     *
773
     * @param string|int $fileOrID Relative path from webroot, module resource reference or
774
     *                             requirement API ID
775
     */
776
    public function block($fileOrID)
777
    {
778
        if (is_string($fileOrID)) {
779
            $fileOrID = ModuleResourceLoader::singleton()->resolvePath($fileOrID);
780
        }
781
        $this->blocked[$fileOrID] = $fileOrID;
782
    }
783
784
    /**
785
     * Remove an item from the block list
786
     *
787
     * @param string|int $fileOrID
788
     */
789
    public function unblock($fileOrID)
790
    {
791
        unset($this->blocked[$fileOrID]);
792
    }
793
794
    /**
795
     * Removes all items from the block list
796
     */
797
    public function unblockAll()
798
    {
799
        $this->blocked = [];
800
    }
801
802
    /**
803
     * Update the given HTML content with the appropriate include tags for the registered
804
     * requirements. Needs to receive a valid HTML/XHTML template in the $content parameter,
805
     * including a head and body tag.
806
     *
807
     * @param string $content HTML content that has already been parsed from the $templateFile
808
     *                             through {@link SSViewer}
809
     * @return string HTML content augmented with the requirements tags
810
     */
811
    public function includeInHTML($content)
812
    {
813
        if (func_num_args() > 1) {
814
            Deprecation::notice(
815
                '5.0',
816
                '$templateFile argument is deprecated. includeInHTML takes a sole $content parameter now.'
817
            );
818
            $content = func_get_arg(1);
819
        }
820
821
        // Skip if content isn't injectable, or there is nothing to inject
822
        $tagsAvailable = preg_match('#</head\b#', $content);
823
        $hasFiles = $this->css || $this->javascript || $this->customCSS || $this->customScript || $this->customHeadTags;
0 ignored issues
show
Bug Best Practice introduced by
The expression $this->css of type array is implicitly converted to a boolean; are you sure this is intended? If so, consider using ! empty($expr) instead to make it clear that you intend to check for an array without elements.

This check marks implicit conversions of arrays to boolean values in a comparison. While in PHP an empty array is considered to be equal (but not identical) to false, this is not always apparent.

Consider making the comparison explicit by using empty(..) or ! empty(...) instead.

Loading history...
Bug Best Practice introduced by
The expression $this->customCSS of type array is implicitly converted to a boolean; are you sure this is intended? If so, consider using ! empty($expr) instead to make it clear that you intend to check for an array without elements.

This check marks implicit conversions of arrays to boolean values in a comparison. While in PHP an empty array is considered to be equal (but not identical) to false, this is not always apparent.

Consider making the comparison explicit by using empty(..) or ! empty(...) instead.

Loading history...
Bug Best Practice introduced by
The expression $this->customHeadTags of type array is implicitly converted to a boolean; are you sure this is intended? If so, consider using ! empty($expr) instead to make it clear that you intend to check for an array without elements.

This check marks implicit conversions of arrays to boolean values in a comparison. While in PHP an empty array is considered to be equal (but not identical) to false, this is not always apparent.

Consider making the comparison explicit by using empty(..) or ! empty(...) instead.

Loading history...
Bug Best Practice introduced by
The expression $this->javascript of type array is implicitly converted to a boolean; are you sure this is intended? If so, consider using ! empty($expr) instead to make it clear that you intend to check for an array without elements.

This check marks implicit conversions of arrays to boolean values in a comparison. While in PHP an empty array is considered to be equal (but not identical) to false, this is not always apparent.

Consider making the comparison explicit by using empty(..) or ! empty(...) instead.

Loading history...
Bug Best Practice introduced by
The expression $this->customScript of type array is implicitly converted to a boolean; are you sure this is intended? If so, consider using ! empty($expr) instead to make it clear that you intend to check for an array without elements.

This check marks implicit conversions of arrays to boolean values in a comparison. While in PHP an empty array is considered to be equal (but not identical) to false, this is not always apparent.

Consider making the comparison explicit by using empty(..) or ! empty(...) instead.

Loading history...
824
        if (!$tagsAvailable || !$hasFiles) {
825
            return $content;
826
        }
827
        $requirements = '';
828
        $jsRequirements = '';
829
830
        // Combine files - updates $this->javascript and $this->css
831
        $this->processCombinedFiles();
832
833
        // Script tags for js links
834
        foreach ($this->getJavascript() as $file => $attributes) {
835
            // Build html attributes
836
            $htmlAttributes = [
837
                'type' => isset($attributes['type']) ? $attributes['type'] : "application/javascript",
838
                'src' => $this->pathForFile($file),
839
            ];
840
            if (!empty($attributes['async'])) {
841
                $htmlAttributes['async'] = 'async';
842
            }
843
            if (!empty($attributes['defer'])) {
844
                $htmlAttributes['defer'] = 'defer';
845
            }
846
            if (!empty($attributes['integrity'])) {
847
                $htmlAttributes['integrity'] = $attributes['integrity'];
848
            }
849
            if (!empty($attributes['crossorigin'])) {
850
                $htmlAttributes['crossorigin'] = $attributes['crossorigin'];
851
            }
852
            $jsRequirements .= HTML::createTag('script', $htmlAttributes);
853
            $jsRequirements .= "\n";
854
        }
855
856
        // Add all inline JavaScript *after* including external files they might rely on
857
        foreach ($this->getCustomScripts() as $script) {
858
            $jsRequirements .= HTML::createTag(
859
                'script',
860
                [ 'type' => 'application/javascript' ],
861
                "//<![CDATA[\n{$script}\n//]]>"
862
            );
863
            $jsRequirements .= "\n";
864
        }
865
866
        // CSS file links
867
        foreach ($this->getCSS() as $file => $params) {
868
            $htmlAttributes = [
869
                'rel' => 'stylesheet',
870
                'type' => 'text/css',
871
                'href' => $this->pathForFile($file),
872
            ];
873
            if (!empty($params['media'])) {
874
                $htmlAttributes['media'] = $params['media'];
875
            }
876
            if (!empty($params['integrity'])) {
877
                $htmlAttributes['integrity'] = $params['integrity'];
878
            }
879
            if (!empty($params['crossorigin'])) {
880
                $htmlAttributes['crossorigin'] = $params['crossorigin'];
881
            }
882
            $requirements .= HTML::createTag('link', $htmlAttributes);
883
            $requirements .= "\n";
884
        }
885
886
        // Literal custom CSS content
887
        foreach ($this->getCustomCSS() as $css) {
888
            $requirements .= HTML::createTag('style', ['type' => 'text/css'], "\n{$css}\n");
889
            $requirements .= "\n";
890
        }
891
892
        foreach ($this->getCustomHeadTags() as $customHeadTag) {
893
            $requirements .= "{$customHeadTag}\n";
894
        }
895
896
        // Inject CSS  into body
897
        $content = $this->insertTagsIntoHead($requirements, $content);
898
899
        // Inject scripts
900
        if ($this->getForceJSToBottom()) {
901
            $content = $this->insertScriptsAtBottom($jsRequirements, $content);
902
        } elseif ($this->getWriteJavascriptToBody()) {
903
            $content = $this->insertScriptsIntoBody($jsRequirements, $content);
904
        } else {
905
            $content = $this->insertTagsIntoHead($jsRequirements, $content);
906
        }
907
        return $content;
908
    }
909
910
    /**
911
     * Given a block of HTML, insert the given scripts at the bottom before
912
     * the closing `</body>` tag
913
     *
914
     * @param string $jsRequirements String containing one or more javascript `<script />` tags
915
     * @param string $content HTML body
916
     * @return string Merged HTML
917
     */
918
    protected function insertScriptsAtBottom($jsRequirements, $content)
919
    {
920
        // Forcefully put the scripts at the bottom of the body instead of before the first
921
        // script tag.
922
        $content = preg_replace(
923
            '/(<\/body[^>]*>)/i',
924
            $this->escapeReplacement($jsRequirements) . '\\1',
925
            $content
926
        );
927
        return $content;
928
    }
929
930
    /**
931
     * Given a block of HTML, insert the given scripts inside the `<body></body>`
932
     *
933
     * @param string $jsRequirements String containing one or more javascript `<script />` tags
934
     * @param string $content HTML body
935
     * @return string Merged HTML
936
     */
937
    protected function insertScriptsIntoBody($jsRequirements, $content)
938
    {
939
        // If your template already has script tags in the body, then we try to put our script
940
        // tags just before those. Otherwise, we put it at the bottom.
941
        $bodyTagPosition = stripos($content, '<body');
942
        $scriptTagPosition = stripos($content, '<script', $bodyTagPosition);
943
944
        $commentTags = [];
945
        $canWriteToBody = ($scriptTagPosition !== false)
946
            &&
947
            // Check that the script tag is not inside a html comment tag
948
            !(
949
                preg_match('/.*(?|(<!--)|(-->))/U', $content, $commentTags, 0, $scriptTagPosition)
950
                &&
951
                $commentTags[1] == '-->'
952
            );
953
954
        if ($canWriteToBody) {
955
            // Insert content before existing script tags
956
            $content = substr($content, 0, $scriptTagPosition)
957
                . $jsRequirements
958
                . substr($content, $scriptTagPosition);
959
        } else {
960
            // Insert content at bottom of page otherwise
961
            $content = $this->insertScriptsAtBottom($jsRequirements, $content);
962
        }
963
964
        return $content;
965
    }
966
967
    /**
968
     * Given a block of HTML, insert the given code inside the `<head></head>` block
969
     *
970
     * @param string $jsRequirements String containing one or more html tags
971
     * @param string $content HTML body
972
     * @return string Merged HTML
973
     */
974
    protected function insertTagsIntoHead($jsRequirements, $content)
975
    {
976
        $content = preg_replace(
977
            '/(<\/head>)/i',
978
            $this->escapeReplacement($jsRequirements) . '\\1',
979
            $content
980
        );
981
        return $content;
982
    }
983
984
    /**
985
     * Safely escape a literal string for use in preg_replace replacement
986
     *
987
     * @param string $replacement
988
     * @return string
989
     */
990
    protected function escapeReplacement($replacement)
991
    {
992
        return addcslashes($replacement, '\\$');
993
    }
994
995
    /**
996
     * Attach requirements inclusion to X-Include-JS and X-Include-CSS headers on the given
997
     * HTTP Response
998
     *
999
     * @param HTTPResponse $response
1000
     */
1001
    public function includeInResponse(HTTPResponse $response)
1002
    {
1003
        $this->processCombinedFiles();
1004
        $jsRequirements = [];
1005
        $cssRequirements = [];
1006
1007
        foreach ($this->getJavascript() as $file => $attributes) {
1008
            $path = $this->pathForFile($file);
1009
            if ($path) {
1010
                $jsRequirements[] = str_replace(',', '%2C', $path);
1011
            }
1012
        }
1013
1014
        if (count($jsRequirements)) {
1015
            $response->addHeader('X-Include-JS', implode(',', $jsRequirements));
1016
        }
1017
1018
        foreach ($this->getCSS() as $file => $params) {
1019
            $path = $this->pathForFile($file);
1020
            if ($path) {
1021
                $path = str_replace(',', '%2C', $path);
1022
                $cssRequirements[] = isset($params['media']) ? "$path:##:$params[media]" : $path;
1023
            }
1024
        }
1025
1026
        if (count($cssRequirements)) {
1027
            $response->addHeader('X-Include-CSS', implode(',', $cssRequirements));
1028
        }
1029
    }
1030
1031
    /**
1032
     * Add i18n files from the given javascript directory. SilverStripe expects that the given
1033
     * directory will contain a number of JavaScript files named by language: en_US.js, de_DE.js,
1034
     * etc.
1035
     *
1036
     * @param string $langDir The JavaScript lang directory, relative to the site root, e.g.,
1037
     *                         'framework/javascript/lang'
1038
     * @param bool $return Return all relative file paths rather than including them in
1039
     *                         requirements
1040
     *
1041
     * @return array|null All relative files if $return is true, or null otherwise
1042
     */
1043
    public function add_i18n_javascript($langDir, $return = false)
1044
    {
1045
        $langDir = ModuleResourceLoader::singleton()->resolvePath($langDir);
1046
1047
        $files = [];
1048
        $candidates = [
1049
            'en',
1050
            'en_US',
1051
            i18n::getData()->langFromLocale(i18n::config()->get('default_locale')),
1052
            i18n::config()->get('default_locale'),
1053
            i18n::getData()->langFromLocale(i18n::get_locale()),
1054
            i18n::get_locale(),
1055
            strtolower(DBField::create_field('Locale', i18n::get_locale())->RFC1766()),
0 ignored issues
show
Bug introduced by
The method RFC1766() does not exist on SilverStripe\ORM\FieldType\DBField. Since you implemented __call, consider adding a @method annotation. ( Ignorable by Annotation )

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

1055
            strtolower(DBField::create_field('Locale', i18n::get_locale())->/** @scrutinizer ignore-call */ RFC1766()),
Loading history...
1056
            strtolower(DBField::create_field('Locale', i18n::config()->get('default_locale'))->RFC1766())
1057
        ];
1058
1059
        $candidates = array_map(
1060
            function ($candidate) {
1061
                return $candidate . '.js';
1062
            },
1063
            $candidates
1064
        );
1065
1066
        foreach ($candidates as $candidate) {
1067
            $relativePath = Path::join($langDir, $candidate);
1068
            $absolutePath = Director::getAbsFile($relativePath);
1069
            if (file_exists($absolutePath)) {
1070
                $files[] = $relativePath;
1071
            }
1072
        }
1073
1074
        if ($return) {
1075
            return $files;
1076
        }
1077
1078
        foreach ($files as $file) {
1079
            $this->javascript($file);
1080
        }
1081
        return null;
1082
    }
1083
1084
    /**
1085
     * Finds the path for specified file
1086
     *
1087
     * @param string $fileOrUrl
1088
     * @return string|bool
1089
     */
1090
    protected function pathForFile($fileOrUrl)
1091
    {
1092
        // Since combined urls could be root relative, treat them as urls here.
1093
        if (preg_match('{^(//)|(http[s]?:)}', $fileOrUrl) || Director::is_root_relative_url($fileOrUrl)) {
1094
            return $fileOrUrl;
1095
        } else {
1096
            return Injector::inst()->get(ResourceURLGenerator::class)->urlForResource($fileOrUrl);
1097
        }
1098
    }
1099
1100
    /**
1101
     * Concatenate several css or javascript files into a single dynamically generated file. This
1102
     * increases performance by fewer HTTP requests.
1103
     *
1104
     * The combined file is regenerated based on every file modification time. Optionally a
1105
     * rebuild can be triggered by appending ?flush=1 to the URL.
1106
     *
1107
     * All combined files will have a comment on the start of each concatenated file denoting their
1108
     * original position.
1109
     *
1110
     * CAUTION: You're responsible for ensuring that the load order for combined files is
1111
     * retained - otherwise combining JavaScript files can lead to functional errors in the
1112
     * JavaScript logic, and combining CSS can lead to incorrect inheritance. You can also
1113
     * only include each file once across all includes and combinations in a single page load.
1114
     *
1115
     * CAUTION: Combining CSS Files discards any "media" information.
1116
     *
1117
     * Example for combined JavaScript:
1118
     * <code>
1119
     * Requirements::combine_files(
1120
     *    'foobar.js',
1121
     *    array(
1122
     *        'mysite/javascript/foo.js',
1123
     *        'mysite/javascript/bar.js',
1124
     *    ),
1125
     *    array(
1126
     *        'async' => true,
1127
     *        'defer' => true,
1128
     *    )
1129
     * );
1130
     * </code>
1131
     *
1132
     * Example for combined CSS:
1133
     * <code>
1134
     * Requirements::combine_files(
1135
     *    'foobar.css',
1136
     *    array(
1137
     *        'mysite/javascript/foo.css',
1138
     *        'mysite/javascript/bar.css',
1139
     *    ),
1140
     *    array(
1141
     *        'media' => 'print',
1142
     *    )
1143
     * );
1144
     * </code>
1145
     *
1146
     * @param string $combinedFileName Filename of the combined file relative to docroot
1147
     * @param array $files Array of filenames relative to docroot
1148
     * @param array $options Array of options for combining files. Available options are:
1149
     * - 'media' : If including CSS Files, you can specify a media type
1150
     * - 'async' : If including JavaScript Files, boolean value to set async attribute to script tag
1151
     * - 'defer' : If including JavaScript Files, boolean value to set defer attribute to script tag
1152
     */
1153
    public function combineFiles($combinedFileName, $files, $options = [])
1154
    {
1155
        if (is_string($options)) {
0 ignored issues
show
introduced by
The condition is_string($options) is always false.
Loading history...
1156
            Deprecation::notice('4.0', 'Parameter media is deprecated. Use options array instead.');
1157
            $options = ['media' => $options];
1158
        }
1159
        // Skip this combined files if already included
1160
        if (isset($this->combinedFiles[$combinedFileName])) {
1161
            return;
1162
        }
1163
1164
        // Add all files to necessary type list
1165
        $paths = [];
1166
        $combinedType = null;
1167
        foreach ($files as $file) {
1168
            // Get file details
1169
            list($path, $type) = $this->parseCombinedFile($file);
1170
            if ($type === 'javascript') {
1171
                $type = 'js';
1172
            }
1173
            if ($combinedType && $type && $combinedType !== $type) {
1174
                throw new InvalidArgumentException(
1175
                    "Cannot mix js and css files in same combined file {$combinedFileName}"
1176
                );
1177
            }
1178
            switch ($type) {
1179
                case 'css':
1180
                    $this->css($path, (isset($options['media']) ? $options['media'] : null), $options);
1181
                    break;
1182
                case 'js':
1183
                    $this->javascript($path, $options);
1184
                    break;
1185
                default:
1186
                    throw new InvalidArgumentException("Invalid combined file type: {$type}");
1187
            }
1188
            $combinedType = $type;
1189
            $paths[] = $path;
1190
        }
1191
1192
        // Duplicate check
1193
        foreach ($this->combinedFiles as $existingCombinedFilename => $combinedItem) {
1194
            $existingFiles = $combinedItem['files'];
1195
            $duplicates = array_intersect($existingFiles, $paths);
1196
            if ($duplicates) {
0 ignored issues
show
Bug Best Practice introduced by
The expression $duplicates of type array is implicitly converted to a boolean; are you sure this is intended? If so, consider using ! empty($expr) instead to make it clear that you intend to check for an array without elements.

This check marks implicit conversions of arrays to boolean values in a comparison. While in PHP an empty array is considered to be equal (but not identical) to false, this is not always apparent.

Consider making the comparison explicit by using empty(..) or ! empty(...) instead.

Loading history...
1197
                throw new InvalidArgumentException(sprintf(
1198
                    "Requirements_Backend::combine_files(): Already included file(s) %s in combined file '%s'",
1199
                    implode(',', $duplicates),
1200
                    $existingCombinedFilename
1201
                ));
1202
            }
1203
        }
1204
1205
        $this->combinedFiles[$combinedFileName] = [
1206
            'files' => $paths,
1207
            'type' => $combinedType,
1208
            'options' => $options,
1209
        ];
1210
    }
1211
1212
    /**
1213
     * Return path and type of given combined file
1214
     *
1215
     * @param string|array $file Either a file path, or an array spec
1216
     * @return array array with two elements, path and type of file
1217
     */
1218
    protected function parseCombinedFile($file)
1219
    {
1220
        // Array with path and type keys
1221
        if (is_array($file) && isset($file['path']) && isset($file['type'])) {
1222
            return [$file['path'], $file['type']];
1223
        }
1224
1225
        // Extract value from indexed array
1226
        if (is_array($file)) {
1227
            $path = array_shift($file);
1228
1229
            // See if there's a type specifier
1230
            if ($file) {
1231
                $type = array_shift($file);
1232
                return [$path, $type];
1233
            }
1234
1235
            // Otherwise convent to string
1236
            $file = $path;
1237
        }
1238
1239
        $type = File::get_file_extension($file);
1240
        return [$file, $type];
1241
    }
1242
1243
    /**
1244
     * Return all combined files; keys are the combined file names, values are lists of
1245
     * associative arrays with 'files', 'type', and 'media' keys for details about this
1246
     * combined file.
1247
     *
1248
     * @return array
1249
     */
1250
    public function getCombinedFiles()
1251
    {
1252
        return array_diff_key($this->combinedFiles, $this->blocked);
1253
    }
1254
1255
    /**
1256
     * Includes all combined files, including blocked ones
1257
     *
1258
     * @return array
1259
     */
1260
    protected function getAllCombinedFiles()
1261
    {
1262
        return $this->combinedFiles;
1263
    }
1264
1265
    /**
1266
     * Clears all combined files
1267
     */
1268
    public function deleteAllCombinedFiles()
1269
    {
1270
        $combinedFolder = $this->getCombinedFilesFolder();
1271
        if ($combinedFolder) {
1272
            $this->getAssetHandler()->removeContent($combinedFolder);
1273
        }
1274
    }
1275
1276
    /**
1277
     * Clear all registered CSS and JavaScript file combinations
1278
     */
1279
    public function clearCombinedFiles()
1280
    {
1281
        $this->combinedFiles = [];
1282
    }
1283
1284
    /**
1285
     * Do the heavy lifting involved in combining the combined files.
1286
     */
1287
    public function processCombinedFiles()
1288
    {
1289
        // Check if combining is enabled
1290
        if (!$this->getCombinedFilesEnabled()) {
1291
            return;
1292
        }
1293
1294
        // Before scripts are modified, detect files that are provided by preceding ones
1295
        $providedScripts = $this->getProvidedScripts();
1296
1297
        // Process each combined files
1298
        foreach ($this->getAllCombinedFiles() as $combinedFile => $combinedItem) {
1299
            $fileList = $combinedItem['files'];
1300
            $type = $combinedItem['type'];
1301
            $options = $combinedItem['options'];
1302
1303
            // Generate this file, unless blocked
1304
            $combinedURL = null;
1305
            if (!isset($this->blocked[$combinedFile])) {
1306
                // Filter files for blocked / provided
1307
                $filteredFileList = array_diff(
1308
                    $fileList,
1309
                    $this->getBlocked(),
1310
                    $providedScripts
1311
                );
1312
                $combinedURL = $this->getCombinedFileURL($combinedFile, $filteredFileList, $type);
1313
            }
1314
1315
            // Replace all existing files, injecting the combined file at the position of the first item
1316
            // in order to preserve inclusion order.
1317
            // Note that we iterate across blocked files in order to get the correct order, and validate
1318
            // that the file is included in the correct location (regardless of which files are blocked).
1319
            $included = false;
1320
            switch ($type) {
1321
                case 'css': {
1322
                    $newCSS = []; // Assoc array of css file => spec
1323
                    foreach ($this->getAllCSS() as $css => $spec) {
1324
                        if (!in_array($css, $fileList)) {
1325
                            $newCSS[$css] = $spec;
1326
                        } elseif (!$included && $combinedURL) {
1327
                            $newCSS[$combinedURL] = [
1328
                                'media' => $options['media'] ?? null,
1329
                                'integrity' => $options['integrity'] ?? null,
1330
                                'crossorigin' => $options['crossorigin'] ?? null,
1331
                            ];
1332
                            $included = true;
1333
                        }
1334
                        // If already included, or otherwise blocked, then don't add into CSS
1335
                    }
1336
                    $this->css = $newCSS;
1337
                    break;
1338
                }
1339
                case 'js': {
1340
                    // Assoc array of file => attributes
1341
                    $newJS = [];
1342
                    foreach ($this->getAllJavascript() as $script => $attributes) {
1343
                        if (!in_array($script, $fileList)) {
1344
                            $newJS[$script] = $attributes;
1345
                        } elseif (!$included && $combinedURL) {
1346
                            $newJS[$combinedURL] = $options;
1347
                            $included = true;
1348
                        }
1349
                        // If already included, or otherwise blocked, then don't add into scripts
1350
                    }
1351
                    $this->javascript = $newJS;
1352
                    break;
1353
                }
1354
            }
1355
        }
1356
    }
1357
1358
    /**
1359
     * Given a set of files, combine them (as necessary) and return the url
1360
     *
1361
     * @param string $combinedFile Filename for this combined file
1362
     * @param array $fileList List of files to combine
1363
     * @param string $type Either 'js' or 'css'
1364
     * @return string|null URL to this resource, if there are files to combine
1365
     * @throws Exception
1366
     */
1367
    protected function getCombinedFileURL($combinedFile, $fileList, $type)
1368
    {
1369
        // Skip empty lists
1370
        if (empty($fileList)) {
1371
            return null;
1372
        }
1373
1374
        // Generate path (Filename)
1375
        $hashQuerystring = Config::inst()->get(static::class, 'combine_hash_querystring');
1376
        if (!$hashQuerystring) {
1377
            $combinedFile = $this->hashedCombinedFilename($combinedFile, $fileList);
1378
        }
1379
        $combinedFileID = File::join_paths($this->getCombinedFilesFolder(), $combinedFile);
1380
1381
        // Send file combination request to the backend, with an optional callback to perform regeneration
1382
        $minify = $this->getMinifyCombinedFiles();
1383
        if ($minify && !$this->minifier) {
1384
            throw new Exception(
1385
                sprintf(
1386
                    <<<MESSAGE
1387
Cannot minify files without a minification service defined.
1388
Set %s::minifyCombinedFiles to false, or inject a %s service on
1389
%s.properties.minifier
1390
MESSAGE
1391
                    ,
1392
                    __CLASS__,
1393
                    Requirements_Minifier::class,
1394
                    __CLASS__
1395
                )
1396
            );
1397
        }
1398
1399
        $combinedURL = $this
1400
            ->getAssetHandler()
1401
            ->getContentURL(
1402
                $combinedFileID,
1403
                function () use ($fileList, $minify, $type) {
1404
                    // Physically combine all file content
1405
                    $combinedData = '';
1406
                    foreach ($fileList as $file) {
1407
                        $filePath = Director::getAbsFile($file);
1408
                        if (!file_exists($filePath)) {
1409
                            throw new InvalidArgumentException("Combined file {$file} does not exist");
1410
                        }
1411
                        $fileContent = file_get_contents($filePath);
1412
                        if ($type == 'css') {
1413
                            // resolve relative paths for css files
1414
                            $fileContent = $this->resolveCSSReferences($fileContent, $file);
1415
                        }
1416
                        // Use configured minifier
1417
                        if ($minify) {
1418
                            $fileContent = $this->minifier->minify($fileContent, $type, $file);
1419
                        }
1420
1421
                        if ($this->writeHeaderComment) {
1422
                            // Write a header comment for each file for easier identification and debugging.
1423
                            $combinedData .= "/****** FILE: $file *****/\n";
1424
                        }
1425
                        $combinedData .= $fileContent . "\n";
1426
                    }
1427
                    return $combinedData;
1428
                }
1429
            );
1430
1431
        // If the name isn't hashed, we will need to append the querystring m= parameter instead
1432
        // Since url won't be automatically suffixed, add it in here
1433
        if ($hashQuerystring && $this->getSuffixRequirements()) {
1434
            $hash = $this->hashOfFiles($fileList);
1435
            $q = stripos($combinedURL, '?') === false ? '?' : '&';
1436
            $combinedURL .= "{$q}m={$hash}";
1437
        }
1438
1439
        return $combinedURL;
1440
    }
1441
1442
    /**
1443
     * Resolves relative paths in CSS files which are lost when combining them
1444
     *
1445
     * @param string $content
1446
     * @param string $filePath
1447
     * @return string New content with paths resolved
1448
     */
1449
    protected function resolveCSSReferences($content, $filePath)
1450
    {
1451
        $doResolving = Config::inst()->get(__CLASS__, 'resolve_relative_css_refs');
1452
        if (!$doResolving) {
1453
            return $content;
1454
        }
1455
        $fileUrl = Injector::inst()->get(ResourceURLGenerator::class)->urlForResource($filePath);
1456
        $fileUrlDir = dirname($fileUrl);
1457
        $content = preg_replace_callback('#([\s\'"\(])((:?[\.]{1,2}/)+)#', function ($a) use ($fileUrlDir) {
1458
            [ $_, $prefix, $match ] = $a;
1459
            $full = $fileUrlDir . '/' . $match;
1460
            $full = preg_replace('#/{2,}#', '/', $full); // ensure there's no repeated slashes
1461
            while (strpos($full, './') > 0) {
1462
                $post = $full;
1463
                $post = preg_replace('#([^/\.]+)/\.\./#', '', $post); // erase 'something/../' with the predecessor
1464
                $post = preg_replace('#([^/\.]+)/\./#', '\\1/', $post); // erase './'
1465
                if ($post == $full) {
1466
                    break; // nothing changed
1467
                }
1468
                $full = $post;
1469
            }
1470
            return $prefix . $full;
1471
        }, $content);
1472
        return $content;
1473
    }
1474
1475
    /**
1476
     * Given a filename and list of files, generate a new filename unique to these files
1477
     *
1478
     * @param string $combinedFile
1479
     * @param array $fileList
1480
     * @return string
1481
     */
1482
    protected function hashedCombinedFilename($combinedFile, $fileList)
1483
    {
1484
        $name = pathinfo($combinedFile, PATHINFO_FILENAME);
1485
        $hash = $this->hashOfFiles($fileList);
1486
        $extension = File::get_file_extension($combinedFile);
1487
        return $name . '-' . substr($hash, 0, 7) . '.' . $extension;
0 ignored issues
show
Bug introduced by
Are you sure $name of type array|string 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

1487
        return /** @scrutinizer ignore-type */ $name . '-' . substr($hash, 0, 7) . '.' . $extension;
Loading history...
1488
    }
1489
1490
    /**
1491
     * Check if combined files are enabled
1492
     *
1493
     * @return bool
1494
     */
1495
    public function getCombinedFilesEnabled()
1496
    {
1497
        if (isset($this->combinedFilesEnabled)) {
1498
            return $this->combinedFilesEnabled;
1499
        }
1500
1501
        // Non-dev sites are always combined
1502
        if (!Director::isDev()) {
1503
            return true;
1504
        }
1505
1506
        // Fallback to default
1507
        return Config::inst()->get(__CLASS__, 'combine_in_dev');
1508
    }
1509
1510
    /**
1511
     * For a given filelist, determine some discriminating value to determine if
1512
     * any of these files have changed.
1513
     *
1514
     * @param array $fileList List of files
1515
     * @return string SHA1 bashed file hash
1516
     */
1517
    protected function hashOfFiles($fileList)
1518
    {
1519
        // Get hash based on hash of each file
1520
        $hash = '';
1521
        foreach ($fileList as $file) {
1522
            $absolutePath = Director::getAbsFile($file);
1523
            if (file_exists($absolutePath)) {
1524
                $hash .= sha1_file($absolutePath);
1525
            } else {
1526
                throw new InvalidArgumentException("Combined file {$file} does not exist");
1527
            }
1528
        }
1529
        return sha1($hash);
1530
    }
1531
1532
    /**
1533
     * Registers the given themeable stylesheet as required.
1534
     *
1535
     * A CSS file in the current theme path name 'themename/css/$name.css' is first searched for,
1536
     * and it that doesn't exist and the module parameter is set then a CSS file with that name in
1537
     * the module is used.
1538
     *
1539
     * @param string $name The name of the file - eg '/css/File.css' would have the name 'File'
1540
     * @param string $media Comma-separated list of media types to use in the link tag
1541
     *                       (e.g. 'screen,projector')
1542
     */
1543
    public function themedCSS($name, $media = null)
1544
    {
1545
        $path = ThemeResourceLoader::inst()->findThemedCSS($name, SSViewer::get_themes());
1546
        if ($path) {
1547
            $this->css($path, $media);
1548
        } else {
1549
            throw new InvalidArgumentException(
1550
                "The css file doesn't exist. Please check if the file $name.css exists in any context or search for "
1551
                . "themedCSS references calling this file in your templates."
1552
            );
1553
        }
1554
    }
1555
1556
    /**
1557
     * Registers the given themeable javascript as required.
1558
     *
1559
     * A javascript file in the current theme path name 'themename/javascript/$name.js' is first searched for,
1560
     * and it that doesn't exist and the module parameter is set then a javascript file with that name in
1561
     * the module is used.
1562
     *
1563
     * @param string $name The name of the file - eg '/js/File.js' would have the name 'File'
1564
     * @param string $type Comma-separated list of types to use in the script tag
1565
     *                       (e.g. 'text/javascript,text/ecmascript')
1566
     */
1567
    public function themedJavascript($name, $type = null)
1568
    {
1569
        $path = ThemeResourceLoader::inst()->findThemedJavascript($name, SSViewer::get_themes());
1570
        if ($path) {
1571
            $opts = [];
1572
            if ($type) {
1573
                $opts['type'] = $type;
1574
            }
1575
            $this->javascript($path, $opts);
1576
        } else {
1577
            throw new InvalidArgumentException(
1578
                "The javascript file doesn't exist. Please check if the file $name.js exists in any "
1579
                . "context or search for themedJavascript references calling this file in your templates."
1580
            );
1581
        }
1582
    }
1583
1584
    /**
1585
     * Output debugging information.
1586
     */
1587
    public function debug()
1588
    {
1589
        Debug::show($this->javascript);
1590
        Debug::show($this->css);
1591
        Debug::show($this->customCSS);
1592
        Debug::show($this->customScript);
1593
        Debug::show($this->customHeadTags);
1594
        Debug::show($this->combinedFiles);
1595
    }
1596
}
1597