Completed
Push — resource-url-generator ( 79ebbf...bee9e6 )
by Sam
08:22
created

Requirements_Backend::processCombinedFiles()   C

Complexity

Conditions 15
Paths 22

Size

Total Lines 66
Code Lines 37

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 15
eloc 37
nc 22
nop 0
dl 0
loc 66
rs 5.8663
c 0
b 0
f 0

How to fix   Long Method    Complexity   

Long Method

Small methods make your code easier to understand, in particular if combined with a good name. Besides, if your method is small, finding a good name is usually much easier.

For example, if you find yourself adding comments to a method's body, this is usually a good sign to extract the commented part to a new method, and use the comment as a starting point when coming up with a good name for this new method.

Commonly applied refactorings include:

1
<?php
2
3
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\ResourceURLGenerator;
16
use SilverStripe\Core\Manifest\ModuleLoader;
17
use SilverStripe\Dev\Debug;
18
use SilverStripe\Dev\Deprecation;
19
use SilverStripe\i18n\i18n;
20
21
class Requirements_Backend
22
{
23
    use Injectable;
24
25
    /**
26
     * Whether to add caching query params to the requests for file-based requirements.
27
     * Eg: themes/myTheme/js/main.js?m=123456789. The parameter is a timestamp generated by
28
     * filemtime. This has the benefit of allowing the browser to cache the URL infinitely,
29
     * while automatically busting this cache every time the file is changed.
30
     *
31
     * @var bool
32
     */
33
    protected $suffixRequirements = true;
34
35
    /**
36
     * Whether to combine CSS and JavaScript files
37
     *
38
     * @var bool|null
39
     */
40
    protected $combinedFilesEnabled = null;
41
42
    /**
43
     * Determine if files should be combined automatically on dev mode.
44
     *
45
     * By default combined files will not be combined except in test or
46
     * live environments. Turning this on will allow for pre-combining of files in development mode.
47
     *
48
     * @config
49
     * @var bool
50
     */
51
    private static $combine_in_dev = false;
52
53
    /**
54
     * Paths to all required JavaScript files relative to docroot
55
     *
56
     * @var array
57
     */
58
    protected $javascript = array();
59
60
    /**
61
     * Map of included scripts to array of contained files.
62
     * To be used alongside front-end combination mechanisms.
63
     *
64
     * @var array Map of providing filepath => array(provided filepaths)
65
     */
66
    protected $providedJavascript = array();
67
68
    /**
69
     * Paths to all required CSS files relative to the docroot.
70
     *
71
     * @var array
72
     */
73
    protected $css = array();
74
75
    /**
76
     * All custom javascript code that is inserted into the page's HTML
77
     *
78
     * @var array
79
     */
80
    protected $customScript = array();
81
82
    /**
83
     * All custom CSS rules which are inserted directly at the bottom of the HTML <head> tag
84
     *
85
     * @var array
86
     */
87
    protected $customCSS = array();
88
89
    /**
90
     * All custom HTML markup which is added before the closing <head> tag, e.g. additional
91
     * metatags.
92
     *
93
     * @var array
94
     */
95
    protected $customHeadTags = array();
96
97
    /**
98
     * Remembers the file paths or uniquenessIDs of all Requirements cleared through
99
     * {@link clear()}, so that they can be restored later.
100
     *
101
     * @var array
102
     */
103
    protected $disabled = array();
104
105
    /**
106
     * The file paths (relative to docroot) or uniquenessIDs of any included requirements which
107
     * should be blocked when executing {@link inlcudeInHTML()}. This is useful, for example,
108
     * to block scripts included by a superclass without having to override entire functions and
109
     * duplicate a lot of code.
110
     *
111
     * Use {@link unblock()} or {@link unblock_all()} to revert changes.
112
     *
113
     * @var array
114
     */
115
    protected $blocked = array();
116
117
    /**
118
     * A list of combined files registered via {@link combine_files()}. Keys are the output file
119
     * names, values are lists of input files.
120
     *
121
     * @var array
122
     */
123
    protected $combinedFiles = array();
124
125
    /**
126
     * Use the injected minification service to minify any javascript file passed to {@link combine_files()}.
127
     *
128
     * @var bool
129
     */
130
    protected $minifyCombinedFiles = false;
131
132
    /**
133
     * Whether or not file headers should be written when combining files
134
     *
135
     * @var boolean
136
     */
137
    protected $writeHeaderComment = true;
138
139
    /**
140
     * Where to save combined files. By default they're placed in assets/_combinedfiles, however
141
     * this may be an issue depending on your setup, especially for CSS files which often contain
142
     * relative paths.
143
     *
144
     * @var string
145
     */
146
    protected $combinedFilesFolder = null;
147
148
    /**
149
     * Put all JavaScript includes at the bottom of the template before the closing <body> tag,
150
     * rather than the default behaviour of placing them at the end of the <head> tag. This means
151
     * script downloads won't block other HTTP requests, which can be a performance improvement.
152
     *
153
     * @var bool
154
     */
155
    public $writeJavascriptToBody = true;
156
157
    /**
158
     * Force the JavaScript to the bottom of the page, even if there's a script tag in the body already
159
     *
160
     * @var boolean
161
     */
162
    protected $forceJSToBottom = false;
163
164
    /**
165
     * Configures the default prefix for combined files.
166
     *
167
     * This defaults to `_combinedfiles`, and is the folder within the configured asset backend that
168
     * combined files will be stored in. If using a backend shared with other systems, it is usually
169
     * necessary to distinguish combined files from other assets.
170
     *
171
     * @config
172
     * @var string
173
     */
174
    private static $default_combined_files_folder = '_combinedfiles';
175
176
    /**
177
     * Flag to include the hash in the querystring instead of the filename for combined files.
178
     *
179
     * By default the `<hash>` of the source files is appended to the end of the combined file
180
     * (prior to the file extension). If combined files are versioned in source control or running
181
     * in a distributed environment (such as one where the newest version of a file may not always be
182
     * immediately available) then it may sometimes be necessary to disable this. When this is set to true,
183
     * the hash will instead be appended via a querystring parameter to enable cache busting, but not in
184
     * the filename itself. I.e. `assets/_combinedfiles/name.js?m=<hash>`
185
     *
186
     * @config
187
     * @var bool
188
     */
189
    private static $combine_hash_querystring = false;
190
191
    /**
192
     * @var GeneratedAssetHandler
193
     */
194
    protected $assetHandler = null;
195
196
    /**
197
     * @var Requirements_Minifier
198
     */
199
    protected $minifier = null;
200
201
    /**
202
     * Gets the backend storage for generated files
203
     *
204
     * @return GeneratedAssetHandler
205
     */
206
    public function getAssetHandler()
207
    {
208
        return $this->assetHandler;
209
    }
210
211
    /**
212
     * Set a new asset handler for this backend
213
     *
214
     * @param GeneratedAssetHandler $handler
215
     */
216
    public function setAssetHandler(GeneratedAssetHandler $handler)
217
    {
218
        $this->assetHandler = $handler;
219
    }
220
221
    /**
222
     * Gets the minification service for this backend
223
     *
224
     * @deprecated 4.0..5.0
225
     * @return Requirements_Minifier
226
     */
227
    public function getMinifier()
228
    {
229
        return $this->minifier;
230
    }
231
232
    /**
233
     * Set a new minification service for this backend
234
     *
235
     * @param Requirements_Minifier $minifier
236
     */
237
    public function setMinifier(Requirements_Minifier $minifier = null)
238
    {
239
        $this->minifier = $minifier;
240
    }
241
242
    /**
243
     * Enable or disable the combination of CSS and JavaScript files
244
     *
245
     * @param bool $enable
246
     */
247
    public function setCombinedFilesEnabled($enable)
248
    {
249
        $this->combinedFilesEnabled = (bool)$enable;
250
    }
251
252
    /**
253
     * Check if header comments are written
254
     *
255
     * @return bool
256
     */
257
    public function getWriteHeaderComment()
258
    {
259
        return $this->writeHeaderComment;
260
    }
261
262
    /**
263
     * Flag whether header comments should be written for each combined file
264
     *
265
     * @param bool $write
266
     * @return $this
267
     */
268
    public function setWriteHeaderComment($write)
269
    {
270
        $this->writeHeaderComment = $write;
271
        return $this;
272
    }
273
274
    /**
275
     * Set the folder to save combined files in. By default they're placed in _combinedfiles,
276
     * however this may be an issue depending on your setup, especially for CSS files which often
277
     * contain relative paths.
278
     *
279
     * This must not include any 'assets' prefix
280
     *
281
     * @param string $folder
282
     */
283
    public function setCombinedFilesFolder($folder)
284
    {
285
        $this->combinedFilesFolder = $folder;
286
    }
287
288
    /**
289
     * Retrieve the combined files folder prefix
290
     *
291
     * @return string
292
     */
293
    public function getCombinedFilesFolder()
294
    {
295
        if ($this->combinedFilesFolder) {
296
            return $this->combinedFilesFolder;
297
        }
298
        return Config::inst()->get(__CLASS__, 'default_combined_files_folder');
299
    }
300
301
    /*
302
     * Set whether to add caching query params to the requests for file-based requirements.
303
     * Eg: themes/myTheme/js/main.js?m=123456789. The parameter is a timestamp generated by
304
     * filemtime. This has the benefit of allowing the browser to cache the URL infinitely,
305
     * while automatically busting this cache every time the file is changed.
306
     *
307
     * @param bool
308
     */
309
    public function setSuffixRequirements($var)
310
    {
311
        $this->suffixRequirements = $var;
312
    }
313
314
    /**
315
     * Check whether we want to suffix requirements
316
     *
317
     * @return bool
318
     */
319
    public function getSuffixRequirements()
320
    {
321
        return $this->suffixRequirements;
322
    }
323
324
    /**
325
     * Set whether you want to write the JS to the body of the page rather than at the end of the
326
     * head tag.
327
     *
328
     * @param bool
329
     * @return $this
330
     */
331
    public function setWriteJavascriptToBody($var)
332
    {
333
        $this->writeJavascriptToBody = $var;
334
        return $this;
335
    }
336
337
    /**
338
     * Check whether you want to write the JS to the body of the page rather than at the end of the
339
     * head tag.
340
     *
341
     * @return bool
342
     */
343
    public function getWriteJavascriptToBody()
344
    {
345
        return $this->writeJavascriptToBody;
346
    }
347
348
    /**
349
     * Forces the JavaScript requirements to the end of the body, right before the closing tag
350
     *
351
     * @param bool
352
     * @return $this
353
     */
354
    public function setForceJSToBottom($var)
355
    {
356
        $this->forceJSToBottom = $var;
357
        return $this;
358
    }
359
360
    /**
361
     * Check if the JavaScript requirements are written to the end of the body, right before the closing tag
362
     *
363
     * @return bool
364
     */
365
    public function getForceJSToBottom()
366
    {
367
        return $this->forceJSToBottom;
368
    }
369
370
    /**
371
     * Check if minify files should be combined
372
     *
373
     * @return bool
374
     */
375
    public function getMinifyCombinedFiles()
376
    {
377
        return $this->minifyCombinedFiles;
378
    }
379
380
    /**
381
     * Set if combined files should be minified
382
     *
383
     * @param bool $minify
384
     * @return $this
385
     */
386
    public function setMinifyCombinedFiles($minify)
387
    {
388
        $this->minifyCombinedFiles = $minify;
389
        return $this;
390
    }
391
392
    /**
393
     * Register the given JavaScript file as required.
394
     *
395
     * @param string $file Either relative to docroot or in the form "vendor/package:resource"
396
     * @param array $options List of options. Available options include:
397
     * - 'provides' : List of scripts files included in this file
398
     * - 'async' : Boolean value to set async attribute to script tag
399
     * - 'defer' : Boolean value to set defer attribute to script tag
400
     * - 'type' : Override script type= value.
401
     */
402
    public function javascript($file, $options = array())
403
    {
404
        // String of the form vendor/package:resource. Excludes "http://bla" as that's an absolute URL
405
        if (preg_match('#([^ ]*/[^ ]*) *: *([^ ]*)#', $file, $matches)) {
406
            list(, $module, $resource) = $matches;
407
            $moduleObj = ModuleLoader::getModule($module);
408
            if (!$moduleObj) {
409
                throw new \InvalidArgumentException("Can't find module '$module'");
410
            }
411
            $file = $moduleObj->getRelativeResourcePath($resource);
412
        }
413
414
        // Get type
415
        $type = null;
416
        if (isset($this->javascript[$file]['type'])) {
417
            $type = $this->javascript[$file]['type'];
418
        }
419
        if (isset($options['type'])) {
420
            $type = $options['type'];
421
        }
422
423
        // make sure that async/defer is set if it is set once even if file is included multiple times
424
        $async = (
425
            isset($options['async']) && isset($options['async']) == true
0 ignored issues
show
Coding Style Best Practice introduced by
It seems like you are loosely comparing two booleans. Considering using the strict comparison === instead.

When comparing two booleans, it is generally considered safer to use the strict comparison operator.

Loading history...
426
            || (
427
                isset($this->javascript[$file])
428
                && isset($this->javascript[$file]['async'])
429
                && $this->javascript[$file]['async'] == true
430
            )
431
        );
432
        $defer = (
433
            isset($options['defer']) && isset($options['defer']) == true
0 ignored issues
show
Coding Style Best Practice introduced by
It seems like you are loosely comparing two booleans. Considering using the strict comparison === instead.

When comparing two booleans, it is generally considered safer to use the strict comparison operator.

Loading history...
434
            || (
435
                isset($this->javascript[$file])
436
                && isset($this->javascript[$file]['defer'])
437
                && $this->javascript[$file]['defer'] == true
438
            )
439
        );
440
        $this->javascript[$file] = array(
441
            'async' => $async,
442
            'defer' => $defer,
443
            'type' => $type,
444
        );
445
446
        // Record scripts included in this file
447
        if (isset($options['provides'])) {
448
            $this->providedJavascript[$file] = array_values($options['provides']);
449
        }
450
    }
451
452
    /**
453
     * Remove a javascript requirement
454
     *
455
     * @param string $file
456
     */
457
    protected function unsetJavascript($file)
458
    {
459
        unset($this->javascript[$file]);
460
    }
461
462
    /**
463
     * Gets all scripts that are already provided by prior scripts.
464
     * This follows these rules:
465
     *  - Files will not be considered provided if they are separately
466
     *    included prior to the providing file.
467
     *  - Providing files can be blocked, and don't provide anything
468
     *  - Provided files can't be blocked (you need to block the provider)
469
     *  - If a combined file includes files that are provided by prior
470
     *    scripts, then these should be excluded from the combined file.
471
     *  - If a combined file includes files that are provided by later
472
     *    scripts, then these files should be included in the combined
473
     *    file, but we can't block the later script either (possible double
474
     *    up of file).
475
     *
476
     * @return array Array of provided files (map of $path => $path)
477
     */
478
    public function getProvidedScripts()
479
    {
480
        $providedScripts = array();
481
        $includedScripts = array();
482
        foreach ($this->javascript as $script => $options) {
483
            // Ignore scripts that are explicitly blocked
484
            if (isset($this->blocked[$script])) {
485
                continue;
486
            }
487
            // At this point, the file is included.
488
            // This might also be combined at this point, potentially.
489
            $includedScripts[$script] = true;
490
491
            // Record any files this provides, EXCEPT those already included by now
492
            if (isset($this->providedJavascript[$script])) {
493
                foreach ($this->providedJavascript[$script] as $provided) {
494
                    if (!isset($includedScripts[$provided])) {
495
                        $providedScripts[$provided] = $provided;
496
                    }
497
                }
498
            }
499
        }
500
        return $providedScripts;
501
    }
502
503
    /**
504
     * Returns an array of required JavaScript, excluding blocked
505
     * and duplicates of provided files.
506
     *
507
     * @return array
508
     */
509
    public function getJavascript()
510
    {
511
        return array_diff_key(
512
            $this->javascript,
513
            $this->getBlocked(),
514
            $this->getProvidedScripts()
515
        );
516
    }
517
518
    /**
519
     * Gets all javascript, including blocked files. Unwraps the array into a non-associative list
520
     *
521
     * @return array Indexed array of javascript files
522
     */
523
    protected function getAllJavascript()
524
    {
525
        return $this->javascript;
526
    }
527
528
    /**
529
     * Register the given JavaScript code into the list of requirements
530
     *
531
     * @param string $script The script content as a string (without enclosing <script> tag)
532
     * @param string $uniquenessID A unique ID that ensures a piece of code is only added once
533
     */
534
    public function customScript($script, $uniquenessID = null)
535
    {
536
        if ($uniquenessID) {
0 ignored issues
show
Bug Best Practice introduced by
The expression $uniquenessID of type string|null is loosely compared to true; this is ambiguous if the string can be empty. You might want to explicitly use !== null instead.

In PHP, under loose comparison (like ==, or !=, or switch conditions), values of different types might be equal.

For string values, the empty string '' is a special case, in particular the following results might be unexpected:

''   == false // true
''   == null  // true
'ab' == false // false
'ab' == null  // false

// It is often better to use strict comparison
'' === false // false
'' === null  // false
Loading history...
537
            $this->customScript[$uniquenessID] = $script;
538
        } else {
539
            $this->customScript[] = $script;
540
        }
541
    }
542
543
    /**
544
     * Return all registered custom scripts
545
     *
546
     * @return array
547
     */
548
    public function getCustomScripts()
549
    {
550
        return array_diff_key($this->customScript, $this->blocked);
551
    }
552
553
    /**
554
     * Register the given CSS styles into the list of requirements
555
     *
556
     * @param string $script CSS selectors as a string (without enclosing <style> tag)
557
     * @param string $uniquenessID A unique ID that ensures a piece of code is only added once
558
     */
559
    public function customCSS($script, $uniquenessID = null)
560
    {
561
        if ($uniquenessID) {
0 ignored issues
show
Bug Best Practice introduced by
The expression $uniquenessID of type string|null is loosely compared to true; this is ambiguous if the string can be empty. You might want to explicitly use !== null instead.

In PHP, under loose comparison (like ==, or !=, or switch conditions), values of different types might be equal.

For string values, the empty string '' is a special case, in particular the following results might be unexpected:

''   == false // true
''   == null  // true
'ab' == false // false
'ab' == null  // false

// It is often better to use strict comparison
'' === false // false
'' === null  // false
Loading history...
562
            $this->customCSS[$uniquenessID] = $script;
563
        } else {
564
            $this->customCSS[] = $script;
565
        }
566
    }
567
568
    /**
569
     * Return all registered custom CSS
570
     *
571
     * @return array
572
     */
573
    public function getCustomCSS()
574
    {
575
        return array_diff_key($this->customCSS, $this->blocked);
576
    }
577
578
    /**
579
     * Add the following custom HTML code to the <head> section of the page
580
     *
581
     * @param string $html Custom HTML code
582
     * @param string $uniquenessID A unique ID that ensures a piece of code is only added once
583
     */
584
    public function insertHeadTags($html, $uniquenessID = null)
585
    {
586
        if ($uniquenessID) {
0 ignored issues
show
Bug Best Practice introduced by
The expression $uniquenessID of type string|null is loosely compared to true; this is ambiguous if the string can be empty. You might want to explicitly use !== null instead.

In PHP, under loose comparison (like ==, or !=, or switch conditions), values of different types might be equal.

For string values, the empty string '' is a special case, in particular the following results might be unexpected:

''   == false // true
''   == null  // true
'ab' == false // false
'ab' == null  // false

// It is often better to use strict comparison
'' === false // false
'' === null  // false
Loading history...
587
            $this->customHeadTags[$uniquenessID] = $html;
588
        } else {
589
            $this->customHeadTags[] = $html;
590
        }
591
    }
592
593
    /**
594
     * Return all custom head tags
595
     *
596
     * @return array
597
     */
598
    public function getCustomHeadTags()
599
    {
600
        return array_diff_key($this->customHeadTags, $this->blocked);
601
    }
602
603
    /**
604
     * Include the content of the given JavaScript file in the list of requirements. Dollar-sign
605
     * variables will be interpolated with values from $vars similar to a .ss template.
606
     *
607
     * @param string $file The template file to load, relative to docroot
608
     * @param string[] $vars The array of variables to interpolate.
609
     * @param string $uniquenessID A unique ID that ensures a piece of code is only added once
610
     */
611
    public function javascriptTemplate($file, $vars, $uniquenessID = null)
612
    {
613
        $script = file_get_contents(Director::getAbsFile($file));
614
        $search = array();
615
        $replace = array();
616
617
        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...
618
            foreach ($vars as $k => $v) {
619
                $search[] = '$' . $k;
620
                $replace[] = str_replace("\\'", "'", Convert::raw2js($v));
621
            }
622
        }
623
624
        $script = str_replace($search, $replace, $script);
625
        $this->customScript($script, $uniquenessID);
626
    }
627
628
    /**
629
     * Register the given stylesheet into the list of requirements.
630
     *
631
     * @param string $file The CSS file to load, relative to site root
632
     * @param string $media Comma-separated list of media types to use in the link tag
633
     *                      (e.g. 'screen,projector')
634
     */
635
    public function css($file, $media = null)
636
    {
637
        // String of the form vendor/package:resource. Excludes "http://bla" as that's an absolute URL
638
        if (preg_match('#([^ ]*/[^ ]*) *: *([^ ]*)#', $file, $matches)) {
639
            $moduleObj = ModuleLoader::getModule($module);
0 ignored issues
show
Bug introduced by
The variable $module does not exist. Did you forget to declare it?

This check marks access to variables or properties that have not been declared yet. While PHP has no explicit notion of declaring a variable, accessing it before a value is assigned to it is most likely a bug.

Loading history...
640
            if (!$moduleObj) {
641
                throw new \InvalidArgumentException("Can't find module '$module'");
642
            }
643
            $file = $moduleObj->getRelativeResourcePath($resource);
0 ignored issues
show
Bug introduced by
The variable $resource does not exist. Did you forget to declare it?

This check marks access to variables or properties that have not been declared yet. While PHP has no explicit notion of declaring a variable, accessing it before a value is assigned to it is most likely a bug.

Loading history...
644
        }
645
646
        $this->css[$file] = array(
647
            "media" => $media
648
        );
649
    }
650
651
    /**
652
     * Remove a css requirement
653
     *
654
     * @param string $file
655
     */
656
    protected function unsetCSS($file)
657
    {
658
        unset($this->css[$file]);
659
    }
660
661
    /**
662
     * Get the list of registered CSS file requirements, excluding blocked files
663
     *
664
     * @return array Associative array of file to spec
665
     */
666
    public function getCSS()
667
    {
668
        return array_diff_key($this->css, $this->blocked);
669
    }
670
671
    /**
672
     * Gets all CSS files requirements, including blocked
673
     *
674
     * @return array Associative array of file to spec
675
     */
676
    protected function getAllCSS()
677
    {
678
        return $this->css;
679
    }
680
681
    /**
682
     * Gets the list of all blocked files
683
     *
684
     * @return array
685
     */
686
    public function getBlocked()
687
    {
688
        return $this->blocked;
689
    }
690
691
    /**
692
     * Clear either a single or all requirements
693
     *
694
     * Caution: Clearing single rules added via customCSS and customScript only works if you
695
     * originally specified a $uniquenessID.
696
     *
697
     * @param string|int $fileOrID
698
     */
699
    public function clear($fileOrID = null)
700
    {
701
        if ($fileOrID) {
702
            foreach (array('javascript', 'css', 'customScript', 'customCSS', 'customHeadTags') as $type) {
703
                if (isset($this->{$type}[$fileOrID])) {
704
                    $this->disabled[$type][$fileOrID] = $this->{$type}[$fileOrID];
705
                    unset($this->{$type}[$fileOrID]);
706
                }
707
            }
708
        } else {
709
            $this->disabled['javascript'] = $this->javascript;
710
            $this->disabled['css'] = $this->css;
711
            $this->disabled['customScript'] = $this->customScript;
712
            $this->disabled['customCSS'] = $this->customCSS;
713
            $this->disabled['customHeadTags'] = $this->customHeadTags;
714
715
            $this->javascript = array();
716
            $this->css = array();
717
            $this->customScript = array();
718
            $this->customCSS = array();
719
            $this->customHeadTags = array();
720
        }
721
    }
722
723
    /**
724
     * Restore requirements cleared by call to Requirements::clear
725
     */
726
    public function restore()
727
    {
728
        $this->javascript = $this->disabled['javascript'];
729
        $this->css = $this->disabled['css'];
730
        $this->customScript = $this->disabled['customScript'];
731
        $this->customCSS = $this->disabled['customCSS'];
732
        $this->customHeadTags = $this->disabled['customHeadTags'];
733
    }
734
735
    /**
736
     * Block inclusion of a specific file
737
     *
738
     * The difference between this and {@link clear} is that the calling order does not matter;
739
     * {@link clear} must be called after the initial registration, whereas {@link block} can be
740
     * used in advance. This is useful, for example, to block scripts included by a superclass
741
     * without having to override entire functions and duplicate a lot of code.
742
     *
743
     * Note that blocking should be used sparingly because it's hard to trace where an file is
744
     * being blocked from.
745
     *
746
     * @param string|int $fileOrID
747
     */
748
    public function block($fileOrID)
749
    {
750
        $this->blocked[$fileOrID] = $fileOrID;
751
    }
752
753
    /**
754
     * Remove an item from the block list
755
     *
756
     * @param string|int $fileOrID
757
     */
758
    public function unblock($fileOrID)
759
    {
760
        unset($this->blocked[$fileOrID]);
761
    }
762
763
    /**
764
     * Removes all items from the block list
765
     */
766
    public function unblockAll()
767
    {
768
        $this->blocked = array();
769
    }
770
771
    /**
772
     * Update the given HTML content with the appropriate include tags for the registered
773
     * requirements. Needs to receive a valid HTML/XHTML template in the $content parameter,
774
     * including a head and body tag.
775
     *
776
     * @param string $content HTML content that has already been parsed from the $templateFile
777
     *                             through {@link SSViewer}
778
     * @return string HTML content augmented with the requirements tags
779
     */
780
    public function includeInHTML($content)
781
    {
782
        if (func_num_args() > 1) {
783
            Deprecation::notice(
784
                '5.0',
785
                '$templateFile argument is deprecated. includeInHTML takes a sole $content parameter now.'
786
            );
787
            $content = func_get_arg(1);
788
        }
789
790
        // Skip if content isn't injectable, or there is nothing to inject
791
        $tagsAvailable = preg_match('#</head\b#', $content);
792
        $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->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->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->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...
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...
793
        if (!$tagsAvailable || !$hasFiles) {
794
            return $content;
795
        }
796
        $requirements = '';
797
        $jsRequirements = '';
798
799
        // Combine files - updates $this->javascript and $this->css
800
        $this->processCombinedFiles();
801
802
        // Script tags for js links
803
        foreach ($this->getJavascript() as $file => $attributes) {
804
            // Build html attributes
805
            $htmlAttributes = [
806
                'type' => isset($attributes['type']) ? $attributes['type'] : "application/javascript",
807
                'src' => $this->pathForFile($file),
808
            ];
809
            if (!empty($attributes['async'])) {
810
                $htmlAttributes['async'] = 'async';
811
            }
812
            if (!empty($attributes['defer'])) {
813
                $htmlAttributes['defer'] = 'defer';
814
            }
815
            $jsRequirements .= HTML::createTag('script', $htmlAttributes);
816
            $jsRequirements .= "\n";
817
        }
818
819
        // Add all inline JavaScript *after* including external files they might rely on
820
        foreach ($this->getCustomScripts() as $script) {
821
            $jsRequirements .= HTML::createTag(
822
                'script',
823
                [ 'type' => 'application/javascript' ],
824
                "//<![CDATA[\n{$script}\n//]]>"
825
            );
826
            $jsRequirements .= "\n";
827
        }
828
829
        // CSS file links
830
        foreach ($this->getCSS() as $file => $params) {
831
            $htmlAttributes = [
832
                'rel' => 'stylesheet',
833
                'type' => 'text/css',
834
                'href' => $this->pathForFile($file),
835
            ];
836
            if (!empty($params['media'])) {
837
                $htmlAttributes['media'] = $params['media'];
838
            }
839
            $requirements .= HTML::createTag('link', $htmlAttributes);
840
            $requirements .= "\n";
841
        }
842
843
        // Literal custom CSS content
844
        foreach ($this->getCustomCSS() as $css) {
845
            $requirements .= HTML::createTag('style', ['type' => 'text/css'], "\n{$css}\n");
846
            $requirements .= "\n";
847
        }
848
849
        foreach ($this->getCustomHeadTags() as $customHeadTag) {
850
            $requirements .= "{$customHeadTag}\n";
851
        }
852
853
        // Inject CSS  into body
854
        $content = $this->insertTagsIntoHead($requirements, $content);
855
856
        // Inject scripts
857
        if ($this->getForceJSToBottom()) {
858
            $content = $this->insertScriptsAtBottom($jsRequirements, $content);
859
        } elseif ($this->getWriteJavascriptToBody()) {
860
            $content = $this->insertScriptsIntoBody($jsRequirements, $content);
861
        } else {
862
            $content = $this->insertTagsIntoHead($jsRequirements, $content);
863
        }
864
        return $content;
865
    }
866
867
    /**
868
     * Given a block of HTML, insert the given scripts at the bottom before
869
     * the closing </body> tag
870
     *
871
     * @param string $jsRequirements String containing one or more javascript <script /> tags
872
     * @param string $content HTML body
873
     * @return string Merged HTML
874
     */
875
    protected function insertScriptsAtBottom($jsRequirements, $content)
876
    {
877
        // Forcefully put the scripts at the bottom of the body instead of before the first
878
        // script tag.
879
        $content = preg_replace(
880
            '/(<\/body[^>]*>)/i',
881
            $this->escapeReplacement($jsRequirements) . '\\1',
882
            $content
883
        );
884
        return $content;
885
    }
886
887
    /**
888
     * Given a block of HTML, insert the given scripts inside the <body></body>
889
     *
890
     * @param string $jsRequirements String containing one or more javascript <script /> tags
891
     * @param string $content HTML body
892
     * @return string Merged HTML
893
     */
894
    protected function insertScriptsIntoBody($jsRequirements, $content)
895
    {
896
        // If your template already has script tags in the body, then we try to put our script
897
        // tags just before those. Otherwise, we put it at the bottom.
898
        $bodyTagPosition = stripos($content, '<body');
899
        $scriptTagPosition = stripos($content, '<script', $bodyTagPosition);
900
901
        $commentTags = array();
902
        $canWriteToBody = ($scriptTagPosition !== false)
903
            &&
904
            // Check that the script tag is not inside a html comment tag
905
            !(
906
                preg_match('/.*(?|(<!--)|(-->))/U', $content, $commentTags, 0, $scriptTagPosition)
907
                &&
908
                $commentTags[1] == '-->'
909
            );
910
911
        if ($canWriteToBody) {
912
            // Insert content before existing script tags
913
            $content = substr($content, 0, $scriptTagPosition)
914
                . $jsRequirements
915
                . substr($content, $scriptTagPosition);
916
        } else {
917
            // Insert content at bottom of page otherwise
918
            $content = $this->insertScriptsAtBottom($jsRequirements, $content);
919
        }
920
921
        return $content;
922
    }
923
924
    /**
925
     * Given a block of HTML, insert the given code inside the <head></head> block
926
     *
927
     * @param string $jsRequirements String containing one or more html tags
928
     * @param string $content HTML body
929
     * @return string Merged HTML
930
     */
931
    protected function insertTagsIntoHead($jsRequirements, $content)
932
    {
933
        $content = preg_replace(
934
            '/(<\/head>)/i',
935
            $this->escapeReplacement($jsRequirements) . '\\1',
936
            $content
937
        );
938
        return $content;
939
    }
940
941
    /**
942
     * Safely escape a literal string for use in preg_replace replacement
943
     *
944
     * @param string $replacement
945
     * @return string
946
     */
947
    protected function escapeReplacement($replacement)
948
    {
949
        return addcslashes($replacement, '\\$');
950
    }
951
952
    /**
953
     * Attach requirements inclusion to X-Include-JS and X-Include-CSS headers on the given
954
     * HTTP Response
955
     *
956
     * @param HTTPResponse $response
957
     */
958
    public function includeInResponse(HTTPResponse $response)
959
    {
960
        $this->processCombinedFiles();
961
        $jsRequirements = array();
962
        $cssRequirements = array();
963
964
        foreach ($this->getJavascript() as $file => $attributes) {
965
            $path = $this->pathForFile($file);
966
            if ($path) {
967
                $jsRequirements[] = str_replace(',', '%2C', $path);
968
            }
969
        }
970
971
        if (count($jsRequirements)) {
972
            $response->addHeader('X-Include-JS', implode(',', $jsRequirements));
973
        }
974
975
        foreach ($this->getCSS() as $file => $params) {
976
            $path = $this->pathForFile($file);
977
            if ($path) {
978
                $path = str_replace(',', '%2C', $path);
979
                $cssRequirements[] = isset($params['media']) ? "$path:##:$params[media]" : $path;
980
            }
981
        }
982
983
        if (count($cssRequirements)) {
984
            $response->addHeader('X-Include-CSS', implode(',', $cssRequirements));
985
        }
986
    }
987
988
    /**
989
     * Add i18n files from the given javascript directory. SilverStripe expects that the given
990
     * directory will contain a number of JavaScript files named by language: en_US.js, de_DE.js,
991
     * etc.
992
     *
993
     * @param string $langDir The JavaScript lang directory, relative to the site root, e.g.,
994
     *                         'framework/javascript/lang'
995
     * @param bool $return Return all relative file paths rather than including them in
996
     *                         requirements
997
     *
998
     * @return array|null All relative files if $return is true, or null otherwise
999
     */
1000
    public function add_i18n_javascript($langDir, $return = false)
1001
    {
1002
        $files = array();
1003
        $base = Director::baseFolder() . '/';
1004
1005
        if (substr($langDir, -1) != '/') {
1006
            $langDir .= '/';
1007
        }
1008
1009
        $candidates = array(
1010
            'en.js',
1011
            'en_US.js',
1012
            i18n::getData()->langFromLocale(i18n::config()->get('default_locale')) . '.js',
1013
            i18n::config()->get('default_locale') . '.js',
1014
            i18n::getData()->langFromLocale(i18n::get_locale()) . '.js',
1015
            i18n::get_locale() . '.js',
1016
        );
1017
        foreach ($candidates as $candidate) {
1018
            if (file_exists($base . DIRECTORY_SEPARATOR . $langDir . $candidate)) {
1019
                $files[] = $langDir . $candidate;
1020
            }
1021
        }
1022
1023
        if ($return) {
1024
            return $files;
1025
        } else {
1026
            foreach ($files as $file) {
1027
                $this->javascript($file);
1028
            }
1029
            return null;
1030
        }
1031
    }
1032
1033
    /**
1034
     * Finds the path for specified file
1035
     *
1036
     * @param string $fileOrUrl
1037
     * @return string|bool
1038
     */
1039
    protected function pathForFile($fileOrUrl)
1040
    {
1041
        // Since combined urls could be root relative, treat them as urls here.
1042
        if (preg_match('{^(//)|(http[s]?:)}', $fileOrUrl) || Director::is_root_relative_url($fileOrUrl)) {
1043
            return $fileOrUrl;
1044
        } else {
1045
            return Injector::inst()->get(ResourceURLGenerator::class)->urlForResource($fileOrUrl);
1046
        }
1047
    }
1048
1049
    /**
1050
     * Concatenate several css or javascript files into a single dynamically generated file. This
1051
     * increases performance by fewer HTTP requests.
1052
     *
1053
     * The combined file is regenerated based on every file modification time. Optionally a
1054
     * rebuild can be triggered by appending ?flush=1 to the URL.
1055
     *
1056
     * All combined files will have a comment on the start of each concatenated file denoting their
1057
     * original position.
1058
     *
1059
     * CAUTION: You're responsible for ensuring that the load order for combined files is
1060
     * retained - otherwise combining JavaScript files can lead to functional errors in the
1061
     * JavaScript logic, and combining CSS can lead to incorrect inheritance. You can also
1062
     * only include each file once across all includes and combinations in a single page load.
1063
     *
1064
     * CAUTION: Combining CSS Files discards any "media" information.
1065
     *
1066
     * Example for combined JavaScript:
1067
     * <code>
1068
     * Requirements::combine_files(
1069
     *    'foobar.js',
1070
     *    array(
1071
     *        'mysite/javascript/foo.js',
1072
     *        'mysite/javascript/bar.js',
1073
     *    ),
1074
     *    array(
1075
     *        'async' => true,
1076
     *        'defer' => true,
1077
     *    )
1078
     * );
1079
     * </code>
1080
     *
1081
     * Example for combined CSS:
1082
     * <code>
1083
     * Requirements::combine_files(
1084
     *    'foobar.css',
1085
     *    array(
1086
     *        'mysite/javascript/foo.css',
1087
     *        'mysite/javascript/bar.css',
1088
     *    ),
1089
     *    array(
1090
     *        'media' => 'print',
1091
     *    )
1092
     * );
1093
     * </code>
1094
     *
1095
     * @param string $combinedFileName Filename of the combined file relative to docroot
1096
     * @param array $files Array of filenames relative to docroot
1097
     * @param array $options Array of options for combining files. Available options are:
1098
     * - 'media' : If including CSS Files, you can specify a media type
1099
     * - 'async' : If including JavaScript Files, boolean value to set async attribute to script tag
1100
     * - 'defer' : If including JavaScript Files, boolean value to set defer attribute to script tag
1101
     */
1102
    public function combineFiles($combinedFileName, $files, $options = array())
1103
    {
1104
        if (is_string($options)) {
1105
            Deprecation::notice('4.0', 'Parameter media is deprecated. Use options array instead.');
1106
            $options = array('media' => $options);
1107
        }
1108
        // Skip this combined files if already included
1109
        if (isset($this->combinedFiles[$combinedFileName])) {
1110
            return;
1111
        }
1112
1113
        // Add all files to necessary type list
1114
        $paths = array();
1115
        $combinedType = null;
1116
        foreach ($files as $file) {
1117
            // Get file details
1118
            list($path, $type) = $this->parseCombinedFile($file);
1119
            if ($type === 'javascript') {
1120
                $type = 'js';
1121
            }
1122
            if ($combinedType && $type && $combinedType !== $type) {
1123
                throw new InvalidArgumentException(
1124
                    "Cannot mix js and css files in same combined file {$combinedFileName}"
1125
                );
1126
            }
1127
            switch ($type) {
1128
                case 'css':
1129
                    $this->css($path, (isset($options['media']) ? $options['media'] : null));
1130
                    break;
1131
                case 'js':
1132
                    $this->javascript($path, $options);
1133
                    break;
1134
                default:
1135
                    throw new InvalidArgumentException("Invalid combined file type: {$type}");
1136
            }
1137
            $combinedType = $type;
1138
            $paths[] = $path;
1139
        }
1140
1141
        // Duplicate check
1142
        foreach ($this->combinedFiles as $existingCombinedFilename => $combinedItem) {
1143
            $existingFiles = $combinedItem['files'];
1144
            $duplicates = array_intersect($existingFiles, $paths);
1145
            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...
1146
                throw new InvalidArgumentException(sprintf(
1147
                    "Requirements_Backend::combine_files(): Already included file(s) %s in combined file '%s'",
1148
                    implode(',', $duplicates),
1149
                    $existingCombinedFilename
1150
                ));
1151
            }
1152
        }
1153
1154
        $this->combinedFiles[$combinedFileName] = array(
1155
            'files' => $paths,
1156
            'type' => $combinedType,
1157
            'options' => $options,
1158
        );
1159
    }
1160
1161
    /**
1162
     * Return path and type of given combined file
1163
     *
1164
     * @param string|array $file Either a file path, or an array spec
1165
     * @return array array with two elements, path and type of file
1166
     */
1167
    protected function parseCombinedFile($file)
1168
    {
1169
        // Array with path and type keys
1170
        if (is_array($file) && isset($file['path']) && isset($file['type'])) {
1171
            return array($file['path'], $file['type']);
1172
        }
1173
1174
        // Extract value from indexed array
1175
        if (is_array($file)) {
1176
            $path = array_shift($file);
1177
1178
            // See if there's a type specifier
1179
            if ($file) {
0 ignored issues
show
Bug Best Practice introduced by
The expression $file 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...
1180
                $type = array_shift($file);
1181
                return array($path, $type);
1182
            }
1183
1184
            // Otherwise convent to string
1185
            $file = $path;
1186
        }
1187
1188
        $type = File::get_file_extension($file);
1189
        return array($file, $type);
1190
    }
1191
1192
    /**
1193
     * Return all combined files; keys are the combined file names, values are lists of
1194
     * associative arrays with 'files', 'type', and 'media' keys for details about this
1195
     * combined file.
1196
     *
1197
     * @return array
1198
     */
1199
    public function getCombinedFiles()
1200
    {
1201
        return array_diff_key($this->combinedFiles, $this->blocked);
1202
    }
1203
1204
    /**
1205
     * Includes all combined files, including blocked ones
1206
     *
1207
     * @return array
1208
     */
1209
    protected function getAllCombinedFiles()
1210
    {
1211
        return $this->combinedFiles;
1212
    }
1213
1214
    /**
1215
     * Clears all combined files
1216
     */
1217
    public function deleteAllCombinedFiles()
1218
    {
1219
        $combinedFolder = $this->getCombinedFilesFolder();
1220
        if ($combinedFolder) {
1221
            $this->getAssetHandler()->removeContent($combinedFolder);
1222
        }
1223
    }
1224
1225
    /**
1226
     * Clear all registered CSS and JavaScript file combinations
1227
     */
1228
    public function clearCombinedFiles()
1229
    {
1230
        $this->combinedFiles = array();
1231
    }
1232
1233
    /**
1234
     * Do the heavy lifting involved in combining the combined files.
1235
     */
1236
    public function processCombinedFiles()
1237
    {
1238
        // Check if combining is enabled
1239
        if (!$this->getCombinedFilesEnabled()) {
1240
            return;
1241
        }
1242
1243
        // Before scripts are modified, detect files that are provided by preceding ones
1244
        $providedScripts = $this->getProvidedScripts();
1245
1246
        // Process each combined files
1247
        foreach ($this->getAllCombinedFiles() as $combinedFile => $combinedItem) {
1248
            $fileList = $combinedItem['files'];
1249
            $type = $combinedItem['type'];
1250
            $options = $combinedItem['options'];
1251
1252
            // Generate this file, unless blocked
1253
            $combinedURL = null;
1254
            if (!isset($this->blocked[$combinedFile])) {
1255
                // Filter files for blocked / provided
1256
                $filteredFileList = array_diff(
1257
                    $fileList,
1258
                    $this->getBlocked(),
1259
                    $providedScripts
1260
                );
1261
                $combinedURL = $this->getCombinedFileURL($combinedFile, $filteredFileList, $type);
1262
            }
1263
1264
            // Replace all existing files, injecting the combined file at the position of the first item
1265
            // in order to preserve inclusion order.
1266
            // Note that we iterate across blocked files in order to get the correct order, and validate
1267
            // that the file is included in the correct location (regardless of which files are blocked).
1268
            $included = false;
1269
            switch ($type) {
1270
                case 'css': {
1271
                    $newCSS = array(); // Assoc array of css file => spec
1272
                    foreach ($this->getAllCSS() as $css => $spec) {
1273
                        if (!in_array($css, $fileList)) {
1274
                            $newCSS[$css] = $spec;
1275
                        } elseif (!$included && $combinedURL) {
0 ignored issues
show
Bug Best Practice introduced by
The expression $combinedURL of type null|string is loosely compared to true; this is ambiguous if the string can be empty. You might want to explicitly use !== null instead.

In PHP, under loose comparison (like ==, or !=, or switch conditions), values of different types might be equal.

For string values, the empty string '' is a special case, in particular the following results might be unexpected:

''   == false // true
''   == null  // true
'ab' == false // false
'ab' == null  // false

// It is often better to use strict comparison
'' === false // false
'' === null  // false
Loading history...
1276
                            $newCSS[$combinedURL] = array('media' => (isset($options['media']) ? $options['media'] : null));
1277
                            $included = true;
1278
                        }
1279
                        // If already included, or otherwise blocked, then don't add into CSS
1280
                    }
1281
                    $this->css = $newCSS;
1282
                    break;
1283
                }
1284
                case 'js': {
1285
                    // Assoc array of file => attributes
1286
                    $newJS = array();
1287
                    foreach ($this->getAllJavascript() as $script => $attributes) {
1288
                        if (!in_array($script, $fileList)) {
1289
                            $newJS[$script] = $attributes;
1290
                        } elseif (!$included && $combinedURL) {
0 ignored issues
show
Bug Best Practice introduced by
The expression $combinedURL of type null|string is loosely compared to true; this is ambiguous if the string can be empty. You might want to explicitly use !== null instead.

In PHP, under loose comparison (like ==, or !=, or switch conditions), values of different types might be equal.

For string values, the empty string '' is a special case, in particular the following results might be unexpected:

''   == false // true
''   == null  // true
'ab' == false // false
'ab' == null  // false

// It is often better to use strict comparison
'' === false // false
'' === null  // false
Loading history...
1291
                            $newJS[$combinedURL] = $options;
1292
                            $included = true;
1293
                        }
1294
                        // If already included, or otherwise blocked, then don't add into scripts
1295
                    }
1296
                    $this->javascript = $newJS;
1297
                    break;
1298
                }
1299
            }
1300
        }
1301
    }
1302
1303
    /**
1304
     * Given a set of files, combine them (as necessary) and return the url
1305
     *
1306
     * @param string $combinedFile Filename for this combined file
1307
     * @param array $fileList List of files to combine
1308
     * @param string $type Either 'js' or 'css'
1309
     * @return string|null URL to this resource, if there are files to combine
1310
     * @throws Exception
1311
     */
1312
    protected function getCombinedFileURL($combinedFile, $fileList, $type)
1313
    {
1314
        // Skip empty lists
1315
        if (empty($fileList)) {
1316
            return null;
1317
        }
1318
1319
        // Generate path (Filename)
1320
        $hashQuerystring = Config::inst()->get(static::class, 'combine_hash_querystring');
1321
        if (!$hashQuerystring) {
1322
            $combinedFile = $this->hashedCombinedFilename($combinedFile, $fileList);
1323
        }
1324
        $combinedFileID = File::join_paths($this->getCombinedFilesFolder(), $combinedFile);
1325
1326
        // Send file combination request to the backend, with an optional callback to perform regeneration
1327
        $minify = $this->getMinifyCombinedFiles();
1328
        if ($minify && !$this->minifier) {
1329
            throw new Exception(
1330
                sprintf(
1331
                    <<<MESSAGE
1332
Cannot minify files without a minification service defined.
1333
Set %s::minifyCombinedFiles to false, or inject a %s service on
1334
%s.properties.minifier
1335
MESSAGE
1336
                    ,
1337
                    __CLASS__,
1338
                    Requirements_Minifier::class,
1339
                    __CLASS__
1340
                )
1341
            );
1342
        }
1343
1344
        $combinedURL = $this
1345
            ->getAssetHandler()
1346
            ->getContentURL(
1347
                $combinedFileID,
1348
                function () use ($fileList, $minify, $type) {
1349
                    // Physically combine all file content
1350
                    $combinedData = '';
1351
                    $base = Director::baseFolder() . '/';
1352
                    foreach ($fileList as $file) {
1353
                        $fileContent = file_get_contents($base . $file);
1354
                        // Use configured minifier
1355
                        if ($minify) {
1356
                            $fileContent = $this->minifier->minify($fileContent, $type, $file);
1357
                        }
1358
1359
                        if ($this->writeHeaderComment) {
1360
                            // Write a header comment for each file for easier identification and debugging.
1361
                            $combinedData .= "/****** FILE: $file *****/\n";
1362
                        }
1363
                        $combinedData .= $fileContent . "\n";
1364
                    }
1365
                    return $combinedData;
1366
                }
1367
            );
1368
1369
        // If the name isn't hashed, we will need to append the querystring m= parameter instead
1370
        // Since url won't be automatically suffixed, add it in here
1371
        if ($hashQuerystring && $this->getSuffixRequirements()) {
1372
            $hash = $this->hashOfFiles($fileList);
1373
            $q = stripos($combinedURL, '?') === false ? '?' : '&';
1374
            $combinedURL .= "{$q}m={$hash}";
1375
        }
1376
1377
        return $combinedURL;
1378
    }
1379
1380
    /**
1381
     * Given a filename and list of files, generate a new filename unique to these files
1382
     *
1383
     * @param string $combinedFile
1384
     * @param array $fileList
1385
     * @return string
1386
     */
1387
    protected function hashedCombinedFilename($combinedFile, $fileList)
1388
    {
1389
        $name = pathinfo($combinedFile, PATHINFO_FILENAME);
1390
        $hash = $this->hashOfFiles($fileList);
1391
        $extension = File::get_file_extension($combinedFile);
1392
        return $name . '-' . substr($hash, 0, 7) . '.' . $extension;
1393
    }
1394
1395
    /**
1396
     * Check if combined files are enabled
1397
     *
1398
     * @return bool
1399
     */
1400
    public function getCombinedFilesEnabled()
1401
    {
1402
        if (isset($this->combinedFilesEnabled)) {
1403
            return $this->combinedFilesEnabled;
1404
        }
1405
1406
        // Non-dev sites are always combined
1407
        if (!Director::isDev()) {
1408
            return true;
1409
        }
1410
1411
        // Fallback to default
1412
        return Config::inst()->get(__CLASS__, 'combine_in_dev');
1413
    }
1414
1415
    /**
1416
     * For a given filelist, determine some discriminating value to determine if
1417
     * any of these files have changed.
1418
     *
1419
     * @param array $fileList List of files
1420
     * @return string SHA1 bashed file hash
1421
     */
1422
    protected function hashOfFiles($fileList)
1423
    {
1424
        // Get hash based on hash of each file
1425
        $base = Director::baseFolder() . '/';
1426
        $hash = '';
1427
        foreach ($fileList as $file) {
1428
            if (file_exists($base . $file)) {
1429
                $hash .= sha1_file($base . $file);
1430
            } else {
1431
                throw new InvalidArgumentException("Combined file {$file} does not exist");
1432
            }
1433
        }
1434
        return sha1($hash);
1435
    }
1436
1437
    /**
1438
     * Registers the given themeable stylesheet as required.
1439
     *
1440
     * A CSS file in the current theme path name 'themename/css/$name.css' is first searched for,
1441
     * and it that doesn't exist and the module parameter is set then a CSS file with that name in
1442
     * the module is used.
1443
     *
1444
     * @param string $name The name of the file - eg '/css/File.css' would have the name 'File'
1445
     * @param string $media Comma-separated list of media types to use in the link tag
1446
     *                       (e.g. 'screen,projector')
1447
     */
1448
    public function themedCSS($name, $media = null)
1449
    {
1450
        $path = ThemeResourceLoader::inst()->findThemedCSS($name, SSViewer::get_themes());
1451
        if ($path) {
0 ignored issues
show
Bug Best Practice introduced by
The expression $path of type string|null is loosely compared to true; this is ambiguous if the string can be empty. You might want to explicitly use !== null instead.

In PHP, under loose comparison (like ==, or !=, or switch conditions), values of different types might be equal.

For string values, the empty string '' is a special case, in particular the following results might be unexpected:

''   == false // true
''   == null  // true
'ab' == false // false
'ab' == null  // false

// It is often better to use strict comparison
'' === false // false
'' === null  // false
Loading history...
1452
            $this->css($path, $media);
1453
        } else {
1454
            throw new \InvalidArgumentException(
1455
                "The css file doesn't exist. Please check if the file $name.css exists in any context or search for "
1456
                . "themedCSS references calling this file in your templates."
1457
            );
1458
        }
1459
    }
1460
1461
    /**
1462
     * Registers the given themeable javascript as required.
1463
     *
1464
     * A javascript file in the current theme path name 'themename/javascript/$name.js' is first searched for,
1465
     * and it that doesn't exist and the module parameter is set then a javascript file with that name in
1466
     * the module is used.
1467
     *
1468
     * @param string $name The name of the file - eg '/js/File.js' would have the name 'File'
1469
     * @param string $type Comma-separated list of types to use in the script tag
1470
     *                       (e.g. 'text/javascript,text/ecmascript')
1471
     */
1472
    public function themedJavascript($name, $type = null)
1473
    {
1474
        $path = ThemeResourceLoader::inst()->findThemedJavascript($name, SSViewer::get_themes());
1475
        if ($path) {
0 ignored issues
show
Bug Best Practice introduced by
The expression $path of type string|null is loosely compared to true; this is ambiguous if the string can be empty. You might want to explicitly use !== null instead.

In PHP, under loose comparison (like ==, or !=, or switch conditions), values of different types might be equal.

For string values, the empty string '' is a special case, in particular the following results might be unexpected:

''   == false // true
''   == null  // true
'ab' == false // false
'ab' == null  // false

// It is often better to use strict comparison
'' === false // false
'' === null  // false
Loading history...
1476
            $opts = [];
1477
            if ($type) {
0 ignored issues
show
Bug Best Practice introduced by
The expression $type of type string|null is loosely compared to true; this is ambiguous if the string can be empty. You might want to explicitly use !== null instead.

In PHP, under loose comparison (like ==, or !=, or switch conditions), values of different types might be equal.

For string values, the empty string '' is a special case, in particular the following results might be unexpected:

''   == false // true
''   == null  // true
'ab' == false // false
'ab' == null  // false

// It is often better to use strict comparison
'' === false // false
'' === null  // false
Loading history...
1478
                $opts['type'] = $type;
1479
            }
1480
            $this->javascript($path, $opts);
1481
        } else {
1482
            throw new \InvalidArgumentException(
1483
                "The javascript file doesn't exist. Please check if the file $name.js exists in any "
1484
                . "context or search for themedJavascript references calling this file in your templates."
1485
            );
1486
        }
1487
    }
1488
1489
    /**
1490
     * Output debugging information.
1491
     */
1492
    public function debug()
1493
    {
1494
        Debug::show($this->javascript);
1495
        Debug::show($this->css);
1496
        Debug::show($this->customCSS);
1497
        Debug::show($this->customScript);
1498
        Debug::show($this->customHeadTags);
1499
        Debug::show($this->combinedFiles);
1500
    }
1501
}
1502