Completed
Push — resourceloader ( e91ace...fc8821 )
by Sam
08:32
created

Requirements_Backend::unblock()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 4
Code Lines 2

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 1
eloc 2
nc 1
nop 1
dl 0
loc 4
rs 10
c 0
b 0
f 0
1
<?php
2
3
namespace SilverStripe\View;
4
5
use Exception;
6
use InvalidArgumentException;
7
use SilverStripe\Core\Manifest\ResourceLoader;
8
use SilverStripe\Assets\File;
9
use SilverStripe\Assets\Storage\GeneratedAssetHandler;
10
use SilverStripe\Control\Director;
11
use SilverStripe\Control\HTTPResponse;
12
use SilverStripe\Core\Config\Config;
13
use SilverStripe\Core\Convert;
14
use SilverStripe\Core\Injector\Injectable;
15
use SilverStripe\Dev\Debug;
16
use SilverStripe\Dev\Deprecation;
17
use SilverStripe\i18n\i18n;
18
19
class Requirements_Backend
20
{
21
    use Injectable;
22
23
    /**
24
     * Whether to add caching query params to the requests for file-based requirements.
25
     * Eg: themes/myTheme/js/main.js?m=123456789. The parameter is a timestamp generated by
26
     * filemtime. This has the benefit of allowing the browser to cache the URL infinitely,
27
     * while automatically busting this cache every time the file is changed.
28
     *
29
     * @var bool
30
     */
31
    protected $suffixRequirements = true;
32
33
    /**
34
     * Whether to combine CSS and JavaScript files
35
     *
36
     * @var bool|null
37
     */
38
    protected $combinedFilesEnabled = null;
39
40
    /**
41
     * Determine if files should be combined automatically on dev mode.
42
     *
43
     * By default combined files will not be combined except in test or
44
     * live environments. Turning this on will allow for pre-combining of files in development mode.
45
     *
46
     * @config
47
     * @var bool
48
     */
49
    private static $combine_in_dev = false;
50
51
    /**
52
     * Paths to all required JavaScript files relative to docroot
53
     *
54
     * @var array
55
     */
56
    protected $javascript = array();
57
58
    /**
59
     * Map of included scripts to array of contained files.
60
     * To be used alongside front-end combination mechanisms.
61
     *
62
     * @var array Map of providing filepath => array(provided filepaths)
63
     */
64
    protected $providedJavascript = array();
65
66
    /**
67
     * Paths to all required CSS files relative to the docroot.
68
     *
69
     * @var array
70
     */
71
    protected $css = array();
72
73
    /**
74
     * All custom javascript code that is inserted into the page's HTML
75
     *
76
     * @var array
77
     */
78
    protected $customScript = array();
79
80
    /**
81
     * All custom CSS rules which are inserted directly at the bottom of the HTML <head> tag
82
     *
83
     * @var array
84
     */
85
    protected $customCSS = array();
86
87
    /**
88
     * All custom HTML markup which is added before the closing <head> tag, e.g. additional
89
     * metatags.
90
     *
91
     * @var array
92
     */
93
    protected $customHeadTags = array();
94
95
    /**
96
     * Remembers the file paths or uniquenessIDs of all Requirements cleared through
97
     * {@link clear()}, so that they can be restored later.
98
     *
99
     * @var array
100
     */
101
    protected $disabled = array();
102
103
    /**
104
     * The file paths (relative to docroot) or uniquenessIDs of any included requirements which
105
     * should be blocked when executing {@link inlcudeInHTML()}. This is useful, for example,
106
     * to block scripts included by a superclass without having to override entire functions and
107
     * duplicate a lot of code.
108
     *
109
     * Use {@link unblock()} or {@link unblock_all()} to revert changes.
110
     *
111
     * @var array
112
     */
113
    protected $blocked = array();
114
115
    /**
116
     * A list of combined files registered via {@link combine_files()}. Keys are the output file
117
     * names, values are lists of input files.
118
     *
119
     * @var array
120
     */
121
    protected $combinedFiles = array();
122
123
    /**
124
     * Use the injected minification service to minify any javascript file passed to {@link combine_files()}.
125
     *
126
     * @var bool
127
     */
128
    protected $minifyCombinedFiles = false;
129
130
    /**
131
     * Whether or not file headers should be written when combining files
132
     *
133
     * @var boolean
134
     */
135
    protected $writeHeaderComment = true;
136
137
    /**
138
     * Where to save combined files. By default they're placed in assets/_combinedfiles, however
139
     * this may be an issue depending on your setup, especially for CSS files which often contain
140
     * relative paths.
141
     *
142
     * @var string
143
     */
144
    protected $combinedFilesFolder = null;
145
146
    /**
147
     * Put all JavaScript includes at the bottom of the template before the closing <body> tag,
148
     * rather than the default behaviour of placing them at the end of the <head> tag. This means
149
     * script downloads won't block other HTTP requests, which can be a performance improvement.
150
     *
151
     * @var bool
152
     */
153
    public $writeJavascriptToBody = true;
154
155
    /**
156
     * Force the JavaScript to the bottom of the page, even if there's a script tag in the body already
157
     *
158
     * @var boolean
159
     */
160
    protected $forceJSToBottom = false;
161
162
    /**
163
     * Configures the default prefix for combined files.
164
     *
165
     * This defaults to `_combinedfiles`, and is the folder within the configured asset backend that
166
     * combined files will be stored in. If using a backend shared with other systems, it is usually
167
     * necessary to distinguish combined files from other assets.
168
     *
169
     * @config
170
     * @var string
171
     */
172
    private static $default_combined_files_folder = '_combinedfiles';
173
174
    /**
175
     * Flag to include the hash in the querystring instead of the filename for combined files.
176
     *
177
     * By default the `<hash>` of the source files is appended to the end of the combined file
178
     * (prior to the file extension). If combined files are versioned in source control or running
179
     * in a distributed environment (such as one where the newest version of a file may not always be
180
     * immediately available) then it may sometimes be necessary to disable this. When this is set to true,
181
     * the hash will instead be appended via a querystring parameter to enable cache busting, but not in
182
     * the filename itself. I.e. `assets/_combinedfiles/name.js?m=<hash>`
183
     *
184
     * @config
185
     * @var bool
186
     */
187
    private static $combine_hash_querystring = false;
188
189
    /**
190
     * @var GeneratedAssetHandler
191
     */
192
    protected $assetHandler = null;
193
194
    /**
195
     * @var Requirements_Minifier
196
     */
197
    protected $minifier = null;
198
199
    /**
200
     * Gets the backend storage for generated files
201
     *
202
     * @return GeneratedAssetHandler
203
     */
204
    public function getAssetHandler()
205
    {
206
        return $this->assetHandler;
207
    }
208
209
    /**
210
     * Set a new asset handler for this backend
211
     *
212
     * @param GeneratedAssetHandler $handler
213
     */
214
    public function setAssetHandler(GeneratedAssetHandler $handler)
215
    {
216
        $this->assetHandler = $handler;
217
    }
218
219
    /**
220
     * Gets the minification service for this backend
221
     *
222
     * @deprecated 4.0..5.0
223
     * @return Requirements_Minifier
224
     */
225
    public function getMinifier()
226
    {
227
        return $this->minifier;
228
    }
229
230
    /**
231
     * Set a new minification service for this backend
232
     *
233
     * @param Requirements_Minifier $minifier
234
     */
235
    public function setMinifier(Requirements_Minifier $minifier = null)
236
    {
237
        $this->minifier = $minifier;
238
    }
239
240
    /**
241
     * Enable or disable the combination of CSS and JavaScript files
242
     *
243
     * @param bool $enable
244
     */
245
    public function setCombinedFilesEnabled($enable)
246
    {
247
        $this->combinedFilesEnabled = (bool)$enable;
248
    }
249
250
    /**
251
     * Check if header comments are written
252
     *
253
     * @return bool
254
     */
255
    public function getWriteHeaderComment()
256
    {
257
        return $this->writeHeaderComment;
258
    }
259
260
    /**
261
     * Flag whether header comments should be written for each combined file
262
     *
263
     * @param bool $write
264
     * @return $this
265
     */
266
    public function setWriteHeaderComment($write)
267
    {
268
        $this->writeHeaderComment = $write;
269
        return $this;
270
    }
271
272
    /**
273
     * Set the folder to save combined files in. By default they're placed in _combinedfiles,
274
     * however this may be an issue depending on your setup, especially for CSS files which often
275
     * contain relative paths.
276
     *
277
     * This must not include any 'assets' prefix
278
     *
279
     * @param string $folder
280
     */
281
    public function setCombinedFilesFolder($folder)
282
    {
283
        $this->combinedFilesFolder = $folder;
284
    }
285
286
    /**
287
     * Retrieve the combined files folder prefix
288
     *
289
     * @return string
290
     */
291
    public function getCombinedFilesFolder()
292
    {
293
        if ($this->combinedFilesFolder) {
294
            return $this->combinedFilesFolder;
295
        }
296
        return Config::inst()->get(__CLASS__, 'default_combined_files_folder');
297
    }
298
299
    /**
300
     * Set whether to add caching query params to the requests for file-based requirements.
301
     * Eg: themes/myTheme/js/main.js?m=123456789. The parameter is a timestamp generated by
302
     * filemtime. This has the benefit of allowing the browser to cache the URL infinitely,
303
     * while automatically busting this cache every time the file is changed.
304
     *
305
     * @param bool
306
     */
307
    public function setSuffixRequirements($var)
308
    {
309
        $this->suffixRequirements = $var;
310
    }
311
312
    /**
313
     * Check whether we want to suffix requirements
314
     *
315
     * @return bool
316
     */
317
    public function getSuffixRequirements()
318
    {
319
        return $this->suffixRequirements;
320
    }
321
322
    /**
323
     * Set whether you want to write the JS to the body of the page rather than at the end of the
324
     * head tag.
325
     *
326
     * @param bool
327
     * @return $this
328
     */
329
    public function setWriteJavascriptToBody($var)
330
    {
331
        $this->writeJavascriptToBody = $var;
332
        return $this;
333
    }
334
335
    /**
336
     * Check whether you want to write the JS to the body of the page rather than at the end of the
337
     * head tag.
338
     *
339
     * @return bool
340
     */
341
    public function getWriteJavascriptToBody()
342
    {
343
        return $this->writeJavascriptToBody;
344
    }
345
346
    /**
347
     * Forces the JavaScript requirements to the end of the body, right before the closing tag
348
     *
349
     * @param bool
350
     * @return $this
351
     */
352
    public function setForceJSToBottom($var)
353
    {
354
        $this->forceJSToBottom = $var;
355
        return $this;
356
    }
357
358
    /**
359
     * Check if the JavaScript requirements are written to the end of the body, right before the closing tag
360
     *
361
     * @return bool
362
     */
363
    public function getForceJSToBottom()
364
    {
365
        return $this->forceJSToBottom;
366
    }
367
368
    /**
369
     * Check if minify files should be combined
370
     *
371
     * @return bool
372
     */
373
    public function getMinifyCombinedFiles()
374
    {
375
        return $this->minifyCombinedFiles;
376
    }
377
378
    /**
379
     * Set if combined files should be minified
380
     *
381
     * @param bool $minify
382
     * @return $this
383
     */
384
    public function setMinifyCombinedFiles($minify)
385
    {
386
        $this->minifyCombinedFiles = $minify;
387
        return $this;
388
    }
389
390
    /**
391
     * Register the given JavaScript file as required.
392
     *
393
     * @param string $file Relative to docroot
394
     * @param array $options List of options. Available options include:
395
     * - 'provides' : List of scripts files included in this file
396
     * - 'async' : Boolean value to set async attribute to script tag
397
     * - 'defer' : Boolean value to set defer attribute to script tag
398
     * - 'type' : Override script type= value.
399
     */
400
    public function javascript($file, $options = array())
401
    {
402
        // Get type
403
        $type = null;
404
        if (isset($this->javascript[$file]['type'])) {
405
            $type = $this->javascript[$file]['type'];
406
        }
407
        if (isset($options['type'])) {
408
            $type = $options['type'];
409
        }
410
411
        // make sure that async/defer is set if it is set once even if file is included multiple times
412
        $async = (
413
            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...
414
            || (
415
                isset($this->javascript[$file])
416
                && isset($this->javascript[$file]['async'])
417
                && $this->javascript[$file]['async'] == true
418
            )
419
        );
420
        $defer = (
421
            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...
422
            || (
423
                isset($this->javascript[$file])
424
                && isset($this->javascript[$file]['defer'])
425
                && $this->javascript[$file]['defer'] == true
426
            )
427
        );
428
        $this->javascript[$file] = array(
429
            'async' => $async,
430
            'defer' => $defer,
431
            'type' => $type,
432
        );
433
434
        // Record scripts included in this file
435
        if (isset($options['provides'])) {
436
            $this->providedJavascript[$file] = array_values($options['provides']);
437
        }
438
    }
439
440
    /**
441
     * Remove a javascript requirement
442
     *
443
     * @param string $file
444
     */
445
    protected function unsetJavascript($file)
446
    {
447
        unset($this->javascript[$file]);
448
    }
449
450
    /**
451
     * Gets all scripts that are already provided by prior scripts.
452
     * This follows these rules:
453
     *  - Files will not be considered provided if they are separately
454
     *    included prior to the providing file.
455
     *  - Providing files can be blocked, and don't provide anything
456
     *  - Provided files can't be blocked (you need to block the provider)
457
     *  - If a combined file includes files that are provided by prior
458
     *    scripts, then these should be excluded from the combined file.
459
     *  - If a combined file includes files that are provided by later
460
     *    scripts, then these files should be included in the combined
461
     *    file, but we can't block the later script either (possible double
462
     *    up of file).
463
     *
464
     * @return array Array of provided files (map of $path => $path)
465
     */
466
    public function getProvidedScripts()
467
    {
468
        $providedScripts = array();
469
        $includedScripts = array();
470
        foreach ($this->javascript as $script => $options) {
471
            // Ignore scripts that are explicitly blocked
472
            if (isset($this->blocked[$script])) {
473
                continue;
474
            }
475
            // At this point, the file is included.
476
            // This might also be combined at this point, potentially.
477
            $includedScripts[$script] = true;
478
479
            // Record any files this provides, EXCEPT those already included by now
480
            if (isset($this->providedJavascript[$script])) {
481
                foreach ($this->providedJavascript[$script] as $provided) {
482
                    if (!isset($includedScripts[$provided])) {
483
                        $providedScripts[$provided] = $provided;
484
                    }
485
                }
486
            }
487
        }
488
        return $providedScripts;
489
    }
490
491
    /**
492
     * Returns an array of required JavaScript, excluding blocked
493
     * and duplicates of provided files.
494
     *
495
     * @return array
496
     */
497
    public function getJavascript()
498
    {
499
        return array_diff_key(
500
            $this->javascript,
501
            $this->getBlocked(),
502
            $this->getProvidedScripts()
503
        );
504
    }
505
506
    /**
507
     * Gets all javascript, including blocked files. Unwraps the array into a non-associative list
508
     *
509
     * @return array Indexed array of javascript files
510
     */
511
    protected function getAllJavascript()
512
    {
513
        return $this->javascript;
514
    }
515
516
    /**
517
     * Register the given JavaScript code into the list of requirements
518
     *
519
     * @param string $script The script content as a string (without enclosing <script> tag)
520
     * @param string $uniquenessID A unique ID that ensures a piece of code is only added once
521
     */
522
    public function customScript($script, $uniquenessID = null)
523
    {
524
        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...
525
            $this->customScript[$uniquenessID] = $script;
526
        } else {
527
            $this->customScript[] = $script;
528
        }
529
    }
530
531
    /**
532
     * Return all registered custom scripts
533
     *
534
     * @return array
535
     */
536
    public function getCustomScripts()
537
    {
538
        return array_diff_key($this->customScript, $this->blocked);
539
    }
540
541
    /**
542
     * Register the given CSS styles into the list of requirements
543
     *
544
     * @param string $script CSS selectors as a string (without enclosing <style> tag)
545
     * @param string $uniquenessID A unique ID that ensures a piece of code is only added once
546
     */
547
    public function customCSS($script, $uniquenessID = null)
548
    {
549
        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...
550
            $this->customCSS[$uniquenessID] = $script;
551
        } else {
552
            $this->customCSS[] = $script;
553
        }
554
    }
555
556
    /**
557
     * Return all registered custom CSS
558
     *
559
     * @return array
560
     */
561
    public function getCustomCSS()
562
    {
563
        return array_diff_key($this->customCSS, $this->blocked);
564
    }
565
566
    /**
567
     * Add the following custom HTML code to the <head> section of the page
568
     *
569
     * @param string $html Custom HTML code
570
     * @param string $uniquenessID A unique ID that ensures a piece of code is only added once
571
     */
572
    public function insertHeadTags($html, $uniquenessID = null)
573
    {
574
        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...
575
            $this->customHeadTags[$uniquenessID] = $html;
576
        } else {
577
            $this->customHeadTags[] = $html;
578
        }
579
    }
580
581
    /**
582
     * Return all custom head tags
583
     *
584
     * @return array
585
     */
586
    public function getCustomHeadTags()
587
    {
588
        return array_diff_key($this->customHeadTags, $this->blocked);
589
    }
590
591
    /**
592
     * Include the content of the given JavaScript file in the list of requirements. Dollar-sign
593
     * variables will be interpolated with values from $vars similar to a .ss template.
594
     *
595
     * @param string $file The template file to load, relative to docroot
596
     * @param string[] $vars The array of variables to interpolate.
597
     * @param string $uniquenessID A unique ID that ensures a piece of code is only added once
598
     */
599
    public function javascriptTemplate($file, $vars, $uniquenessID = null)
600
    {
601
        $script = file_get_contents(Director::getAbsFile($file));
602
        $search = array();
603
        $replace = array();
604
605
        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...
606
            foreach ($vars as $k => $v) {
607
                $search[] = '$' . $k;
608
                $replace[] = str_replace("\\'", "'", Convert::raw2js($v));
609
            }
610
        }
611
612
        $script = str_replace($search, $replace, $script);
613
        $this->customScript($script, $uniquenessID);
614
    }
615
616
    /**
617
     * Register the given stylesheet into the list of requirements.
618
     *
619
     * @param string $file The CSS file to load, relative to site root
620
     * @param string $media Comma-separated list of media types to use in the link tag
621
     *                      (e.g. 'screen,projector')
622
     */
623
    public function css($file, $media = null)
624
    {
625
        $this->css[$file] = array(
626
            "media" => $media
627
        );
628
    }
629
630
    /**
631
     * Remove a css requirement
632
     *
633
     * @param string $file
634
     */
635
    protected function unsetCSS($file)
636
    {
637
        unset($this->css[$file]);
638
    }
639
640
    /**
641
     * Get the list of registered CSS file requirements, excluding blocked files
642
     *
643
     * @return array Associative array of file to spec
644
     */
645
    public function getCSS()
646
    {
647
        return array_diff_key($this->css, $this->blocked);
648
    }
649
650
    /**
651
     * Gets all CSS files requirements, including blocked
652
     *
653
     * @return array Associative array of file to spec
654
     */
655
    protected function getAllCSS()
656
    {
657
        return $this->css;
658
    }
659
660
    /**
661
     * Gets the list of all blocked files
662
     *
663
     * @return array
664
     */
665
    public function getBlocked()
666
    {
667
        return $this->blocked;
668
    }
669
670
    /**
671
     * Clear either a single or all requirements
672
     *
673
     * Caution: Clearing single rules added via customCSS and customScript only works if you
674
     * originally specified a $uniquenessID.
675
     *
676
     * @param string|int $fileOrID
677
     */
678
    public function clear($fileOrID = null)
679
    {
680
        if ($fileOrID) {
681
            foreach (array('javascript', 'css', 'customScript', 'customCSS', 'customHeadTags') as $type) {
682
                if (isset($this->{$type}[$fileOrID])) {
683
                    $this->disabled[$type][$fileOrID] = $this->{$type}[$fileOrID];
684
                    unset($this->{$type}[$fileOrID]);
685
                }
686
            }
687
        } else {
688
            $this->disabled['javascript'] = $this->javascript;
689
            $this->disabled['css'] = $this->css;
690
            $this->disabled['customScript'] = $this->customScript;
691
            $this->disabled['customCSS'] = $this->customCSS;
692
            $this->disabled['customHeadTags'] = $this->customHeadTags;
693
694
            $this->javascript = array();
695
            $this->css = array();
696
            $this->customScript = array();
697
            $this->customCSS = array();
698
            $this->customHeadTags = array();
699
        }
700
    }
701
702
    /**
703
     * Restore requirements cleared by call to Requirements::clear
704
     */
705
    public function restore()
706
    {
707
        $this->javascript = $this->disabled['javascript'];
708
        $this->css = $this->disabled['css'];
709
        $this->customScript = $this->disabled['customScript'];
710
        $this->customCSS = $this->disabled['customCSS'];
711
        $this->customHeadTags = $this->disabled['customHeadTags'];
712
    }
713
714
    /**
715
     * Block inclusion of a specific file
716
     *
717
     * The difference between this and {@link clear} is that the calling order does not matter;
718
     * {@link clear} must be called after the initial registration, whereas {@link block} can be
719
     * used in advance. This is useful, for example, to block scripts included by a superclass
720
     * without having to override entire functions and duplicate a lot of code.
721
     *
722
     * Note that blocking should be used sparingly because it's hard to trace where an file is
723
     * being blocked from.
724
     *
725
     * @param string|int $fileOrID
726
     */
727
    public function block($fileOrID)
728
    {
729
        $this->blocked[$fileOrID] = $fileOrID;
730
    }
731
732
    /**
733
     * Remove an item from the block list
734
     *
735
     * @param string|int $fileOrID
736
     */
737
    public function unblock($fileOrID)
738
    {
739
        unset($this->blocked[$fileOrID]);
740
    }
741
742
    /**
743
     * Removes all items from the block list
744
     */
745
    public function unblockAll()
746
    {
747
        $this->blocked = array();
748
    }
749
750
    /**
751
     * Update the given HTML content with the appropriate include tags for the registered
752
     * requirements. Needs to receive a valid HTML/XHTML template in the $content parameter,
753
     * including a head and body tag.
754
     *
755
     * @param string $content HTML content that has already been parsed from the $templateFile
756
     *                             through {@link SSViewer}
757
     * @return string HTML content augmented with the requirements tags
758
     */
759
    public function includeInHTML($content)
760
    {
761
        if (func_num_args() > 1) {
762
            Deprecation::notice(
763
                '5.0',
764
                '$templateFile argument is deprecated. includeInHTML takes a sole $content parameter now.'
765
            );
766
            $content = func_get_arg(1);
767
        }
768
769
        // Skip if content isn't injectable, or there is nothing to inject
770
        $tagsAvailable = preg_match('#</head\b#', $content);
771
        $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...
772
        if (!$tagsAvailable || !$hasFiles) {
773
            return $content;
774
        }
775
        $requirements = '';
776
        $jsRequirements = '';
777
778
        // Combine files - updates $this->javascript and $this->css
779
        $this->processCombinedFiles();
780
781
        // Script tags for js links
782
        foreach ($this->getJavascript() as $file => $attributes) {
783
            // Build html attributes
784
            $htmlAttributes = [
785
                'type' => isset($attributes['type']) ? $attributes['type'] : "application/javascript",
786
                'src' => $this->pathForFile($file),
787
            ];
788
            if (!empty($attributes['async'])) {
789
                $htmlAttributes['async'] = 'async';
790
            }
791
            if (!empty($attributes['defer'])) {
792
                $htmlAttributes['defer'] = 'defer';
793
            }
794
            $jsRequirements .= HTML::createTag('script', $htmlAttributes);
795
            $jsRequirements .= "\n";
796
        }
797
798
        // Add all inline JavaScript *after* including external files they might rely on
799
        foreach ($this->getCustomScripts() as $script) {
800
            $jsRequirements .= HTML::createTag(
801
                'script',
802
                [ 'type' => 'application/javascript' ],
803
                "//<![CDATA[\n{$script}\n//]]>"
804
            );
805
            $jsRequirements .= "\n";
806
        }
807
808
        // CSS file links
809
        foreach ($this->getCSS() as $file => $params) {
810
            $htmlAttributes = [
811
                'rel' => 'stylesheet',
812
                'type' => 'text/css',
813
                'href' => $this->pathForFile($file),
814
            ];
815
            if (!empty($params['media'])) {
816
                $htmlAttributes['media'] = $params['media'];
817
            }
818
            $requirements .= HTML::createTag('link', $htmlAttributes);
819
            $requirements .= "\n";
820
        }
821
822
        // Literal custom CSS content
823
        foreach ($this->getCustomCSS() as $css) {
824
            $requirements .= HTML::createTag('style', ['type' => 'text/css'], "\n{$css}\n");
825
            $requirements .= "\n";
826
        }
827
828
        foreach ($this->getCustomHeadTags() as $customHeadTag) {
829
            $requirements .= "{$customHeadTag}\n";
830
        }
831
832
        // Inject CSS  into body
833
        $content = $this->insertTagsIntoHead($requirements, $content);
834
835
        // Inject scripts
836
        if ($this->getForceJSToBottom()) {
837
            $content = $this->insertScriptsAtBottom($jsRequirements, $content);
838
        } elseif ($this->getWriteJavascriptToBody()) {
839
            $content = $this->insertScriptsIntoBody($jsRequirements, $content);
840
        } else {
841
            $content = $this->insertTagsIntoHead($jsRequirements, $content);
842
        }
843
        return $content;
844
    }
845
846
    /**
847
     * Given a block of HTML, insert the given scripts at the bottom before
848
     * the closing </body> tag
849
     *
850
     * @param string $jsRequirements String containing one or more javascript <script /> tags
851
     * @param string $content HTML body
852
     * @return string Merged HTML
853
     */
854
    protected function insertScriptsAtBottom($jsRequirements, $content)
855
    {
856
        // Forcefully put the scripts at the bottom of the body instead of before the first
857
        // script tag.
858
        $content = preg_replace(
859
            '/(<\/body[^>]*>)/i',
860
            $this->escapeReplacement($jsRequirements) . '\\1',
861
            $content
862
        );
863
        return $content;
864
    }
865
866
    /**
867
     * Given a block of HTML, insert the given scripts inside the <body></body>
868
     *
869
     * @param string $jsRequirements String containing one or more javascript <script /> tags
870
     * @param string $content HTML body
871
     * @return string Merged HTML
872
     */
873
    protected function insertScriptsIntoBody($jsRequirements, $content)
874
    {
875
        // If your template already has script tags in the body, then we try to put our script
876
        // tags just before those. Otherwise, we put it at the bottom.
877
        $bodyTagPosition = stripos($content, '<body');
878
        $scriptTagPosition = stripos($content, '<script', $bodyTagPosition);
879
880
        $commentTags = array();
881
        $canWriteToBody = ($scriptTagPosition !== false)
882
            &&
883
            // Check that the script tag is not inside a html comment tag
884
            !(
885
                preg_match('/.*(?|(<!--)|(-->))/U', $content, $commentTags, 0, $scriptTagPosition)
886
                &&
887
                $commentTags[1] == '-->'
888
            );
889
890
        if ($canWriteToBody) {
891
            // Insert content before existing script tags
892
            $content = substr($content, 0, $scriptTagPosition)
893
                . $jsRequirements
894
                . substr($content, $scriptTagPosition);
895
        } else {
896
            // Insert content at bottom of page otherwise
897
            $content = $this->insertScriptsAtBottom($jsRequirements, $content);
898
        }
899
900
        return $content;
901
    }
902
903
    /**
904
     * Given a block of HTML, insert the given code inside the <head></head> block
905
     *
906
     * @param string $jsRequirements String containing one or more html tags
907
     * @param string $content HTML body
908
     * @return string Merged HTML
909
     */
910
    protected function insertTagsIntoHead($jsRequirements, $content)
911
    {
912
        $content = preg_replace(
913
            '/(<\/head>)/i',
914
            $this->escapeReplacement($jsRequirements) . '\\1',
915
            $content
916
        );
917
        return $content;
918
    }
919
920
    /**
921
     * Safely escape a literal string for use in preg_replace replacement
922
     *
923
     * @param string $replacement
924
     * @return string
925
     */
926
    protected function escapeReplacement($replacement)
927
    {
928
        return addcslashes($replacement, '\\$');
929
    }
930
931
    /**
932
     * Attach requirements inclusion to X-Include-JS and X-Include-CSS headers on the given
933
     * HTTP Response
934
     *
935
     * @param HTTPResponse $response
936
     */
937
    public function includeInResponse(HTTPResponse $response)
938
    {
939
        $this->processCombinedFiles();
940
        $jsRequirements = array();
941
        $cssRequirements = array();
942
943
        foreach ($this->getJavascript() as $file => $attributes) {
944
            $path = $this->pathForFile($file);
945
            if ($path) {
946
                $jsRequirements[] = str_replace(',', '%2C', $path);
947
            }
948
        }
949
950
        if (count($jsRequirements)) {
951
            $response->addHeader('X-Include-JS', implode(',', $jsRequirements));
952
        }
953
954
        foreach ($this->getCSS() as $file => $params) {
955
            $path = $this->pathForFile($file);
956
            if ($path) {
957
                $path = str_replace(',', '%2C', $path);
958
                $cssRequirements[] = isset($params['media']) ? "$path:##:$params[media]" : $path;
959
            }
960
        }
961
962
        if (count($cssRequirements)) {
963
            $response->addHeader('X-Include-CSS', implode(',', $cssRequirements));
964
        }
965
    }
966
967
    /**
968
     * Add i18n files from the given javascript directory. SilverStripe expects that the given
969
     * directory will contain a number of JavaScript files named by language: en_US.js, de_DE.js,
970
     * etc.
971
     *
972
     * @param string $langDir The JavaScript lang directory, relative to the site root, e.g.,
973
     *                         'framework/javascript/lang'
974
     * @param bool $return Return all relative file paths rather than including them in
975
     *                         requirements
976
     *
977
     * @return array|null All relative files if $return is true, or null otherwise
978
     */
979
    public function add_i18n_javascript($langDir, $return = false)
980
    {
981
        $files = array();
982
        $base = Director::baseFolder() . '/';
983
984
        if (substr($langDir, -1) != '/') {
985
            $langDir .= '/';
986
        }
987
988
        $candidates = array(
989
            'en.js',
990
            'en_US.js',
991
            i18n::getData()->langFromLocale(i18n::config()->get('default_locale')) . '.js',
992
            i18n::config()->get('default_locale') . '.js',
993
            i18n::getData()->langFromLocale(i18n::get_locale()) . '.js',
994
            i18n::get_locale() . '.js',
995
        );
996
        foreach ($candidates as $candidate) {
997
            if (file_exists($base . DIRECTORY_SEPARATOR . $langDir . $candidate)) {
998
                $files[] = $langDir . $candidate;
999
            }
1000
        }
1001
1002
        if ($return) {
1003
            return $files;
1004
        } else {
1005
            foreach ($files as $file) {
1006
                $this->javascript($file);
1007
            }
1008
            return null;
1009
        }
1010
    }
1011
1012
    /**
1013
     * Finds the path for specified file
1014
     *
1015
     * @param string $fileOrUrl
1016
     * @return string|bool
1017
     */
1018
    protected function pathForFile($fileOrUrl)
1019
    {
1020
        // Since combined urls could be root relative, treat them as urls here.
1021
        if (preg_match('{^(//)|(http[s]?:)}', $fileOrUrl) || Director::is_root_relative_url($fileOrUrl)) {
1022
            return $fileOrUrl;
1023
        } elseif (Director::fileExists($fileOrUrl)) {
1024
            $filePath = preg_replace('/\?.*/', '', Director::baseFolder() . '/' . $fileOrUrl);
1025
            $prefix = Director::baseURL();
1026
            $mtimesuffix = "";
1027
            $suffix = '';
1028
            if ($this->getSuffixRequirements()) {
1029
                $mtimesuffix = "?m=" . filemtime($filePath);
1030
                $suffix = '&';
1031
            }
1032
            if (strpos($fileOrUrl, '?') !== false) {
1033
                if (strlen($suffix) == 0) {
1034
                    $suffix = '?';
1035
                }
1036
                $suffix .= substr($fileOrUrl, strpos($fileOrUrl, '?') + 1);
1037
                $fileOrUrl = substr($fileOrUrl, 0, strpos($fileOrUrl, '?'));
1038
            } else {
1039
                $suffix = '';
1040
            }
1041
            return "{$prefix}{$fileOrUrl}{$mtimesuffix}{$suffix}";
1042
        } else {
1043
            throw new InvalidArgumentException("File {$fileOrUrl} does not exist");
1044
        }
1045
    }
1046
1047
    /**
1048
     * Concatenate several css or javascript files into a single dynamically generated file. This
1049
     * increases performance by fewer HTTP requests.
1050
     *
1051
     * The combined file is regenerated based on every file modification time. Optionally a
1052
     * rebuild can be triggered by appending ?flush=1 to the URL.
1053
     *
1054
     * All combined files will have a comment on the start of each concatenated file denoting their
1055
     * original position.
1056
     *
1057
     * CAUTION: You're responsible for ensuring that the load order for combined files is
1058
     * retained - otherwise combining JavaScript files can lead to functional errors in the
1059
     * JavaScript logic, and combining CSS can lead to incorrect inheritance. You can also
1060
     * only include each file once across all includes and combinations in a single page load.
1061
     *
1062
     * CAUTION: Combining CSS Files discards any "media" information.
1063
     *
1064
     * Example for combined JavaScript:
1065
     * <code>
1066
     * Requirements::combine_files(
1067
     *    'foobar.js',
1068
     *    array(
1069
     *        'mysite/javascript/foo.js',
1070
     *        'mysite/javascript/bar.js',
1071
     *    ),
1072
     *    array(
1073
     *        'async' => true,
1074
     *        'defer' => true,
1075
     *    )
1076
     * );
1077
     * </code>
1078
     *
1079
     * Example for combined CSS:
1080
     * <code>
1081
     * Requirements::combine_files(
1082
     *    'foobar.css',
1083
     *    array(
1084
     *        'mysite/javascript/foo.css',
1085
     *        'mysite/javascript/bar.css',
1086
     *    ),
1087
     *    array(
1088
     *        'media' => 'print',
1089
     *    )
1090
     * );
1091
     * </code>
1092
     *
1093
     * @param string $combinedFileName Filename of the combined file relative to docroot
1094
     * @param array $files Array of filenames relative to docroot
1095
     * @param array $options Array of options for combining files. Available options are:
1096
     * - 'media' : If including CSS Files, you can specify a media type
1097
     * - 'async' : If including JavaScript Files, boolean value to set async attribute to script tag
1098
     * - 'defer' : If including JavaScript Files, boolean value to set defer attribute to script tag
1099
     */
1100
    public function combineFiles($combinedFileName, $files, $options = array())
1101
    {
1102
        if (is_string($options)) {
1103
            Deprecation::notice('4.0', 'Parameter media is deprecated. Use options array instead.');
1104
            $options = array('media' => $options);
1105
        }
1106
        // Skip this combined files if already included
1107
        if (isset($this->combinedFiles[$combinedFileName])) {
1108
            return;
1109
        }
1110
1111
        // Add all files to necessary type list
1112
        $paths = array();
1113
        $combinedType = null;
1114
        foreach ($files as $file) {
1115
            // Get file details
1116
            list($path, $type) = $this->parseCombinedFile($file);
1117
            if ($type === 'javascript') {
1118
                $type = 'js';
1119
            }
1120
            if ($combinedType && $type && $combinedType !== $type) {
1121
                throw new InvalidArgumentException(
1122
                    "Cannot mix js and css files in same combined file {$combinedFileName}"
1123
                );
1124
            }
1125
            switch ($type) {
1126
                case 'css':
1127
                    $this->css($path, (isset($options['media']) ? $options['media'] : null));
1128
                    break;
1129
                case 'js':
1130
                    $this->javascript($path, $options);
1131
                    break;
1132
                default:
1133
                    throw new InvalidArgumentException("Invalid combined file type: {$type}");
1134
            }
1135
            $combinedType = $type;
1136
            $paths[] = $path;
1137
        }
1138
1139
        // Duplicate check
1140
        foreach ($this->combinedFiles as $existingCombinedFilename => $combinedItem) {
1141
            $existingFiles = $combinedItem['files'];
1142
            $duplicates = array_intersect($existingFiles, $paths);
1143
            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...
1144
                throw new InvalidArgumentException(sprintf(
1145
                    "Requirements_Backend::combine_files(): Already included file(s) %s in combined file '%s'",
1146
                    implode(',', $duplicates),
1147
                    $existingCombinedFilename
1148
                ));
1149
            }
1150
        }
1151
1152
        $this->combinedFiles[$combinedFileName] = array(
1153
            'files' => $paths,
1154
            'type' => $combinedType,
1155
            'options' => $options,
1156
        );
1157
    }
1158
1159
    /**
1160
     * Return path and type of given combined file
1161
     *
1162
     * @param string|array $file Either a file path, or an array spec
1163
     * @return array array with two elements, path and type of file
1164
     */
1165
    protected function parseCombinedFile($file)
1166
    {
1167
        // Array with path and type keys
1168
        if (is_array($file) && isset($file['path']) && isset($file['type'])) {
1169
            return array($file['path'], $file['type']);
1170
        }
1171
1172
        // Extract value from indexed array
1173
        if (is_array($file)) {
1174
            $path = array_shift($file);
1175
1176
            // See if there's a type specifier
1177
            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...
1178
                $type = array_shift($file);
1179
                return array($path, $type);
1180
            }
1181
1182
            // Otherwise convent to string
1183
            $file = $path;
1184
        }
1185
1186
        $type = File::get_file_extension($file);
1187
        return array($file, $type);
1188
    }
1189
1190
    /**
1191
     * Return all combined files; keys are the combined file names, values are lists of
1192
     * associative arrays with 'files', 'type', and 'media' keys for details about this
1193
     * combined file.
1194
     *
1195
     * @return array
1196
     */
1197
    public function getCombinedFiles()
1198
    {
1199
        return array_diff_key($this->combinedFiles, $this->blocked);
1200
    }
1201
1202
    /**
1203
     * Includes all combined files, including blocked ones
1204
     *
1205
     * @return array
1206
     */
1207
    protected function getAllCombinedFiles()
1208
    {
1209
        return $this->combinedFiles;
1210
    }
1211
1212
    /**
1213
     * Clears all combined files
1214
     */
1215
    public function deleteAllCombinedFiles()
1216
    {
1217
        $combinedFolder = $this->getCombinedFilesFolder();
1218
        if ($combinedFolder) {
1219
            $this->getAssetHandler()->removeContent($combinedFolder);
1220
        }
1221
    }
1222
1223
    /**
1224
     * Clear all registered CSS and JavaScript file combinations
1225
     */
1226
    public function clearCombinedFiles()
1227
    {
1228
        $this->combinedFiles = array();
1229
    }
1230
1231
    /**
1232
     * Do the heavy lifting involved in combining the combined files.
1233
     */
1234
    public function processCombinedFiles()
1235
    {
1236
        // Check if combining is enabled
1237
        if (!$this->getCombinedFilesEnabled()) {
1238
            return;
1239
        }
1240
1241
        // Before scripts are modified, detect files that are provided by preceding ones
1242
        $providedScripts = $this->getProvidedScripts();
1243
1244
        // Process each combined files
1245
        foreach ($this->getAllCombinedFiles() as $combinedFile => $combinedItem) {
1246
            $fileList = $combinedItem['files'];
1247
            $type = $combinedItem['type'];
1248
            $options = $combinedItem['options'];
1249
1250
            // Generate this file, unless blocked
1251
            $combinedURL = null;
1252
            if (!isset($this->blocked[$combinedFile])) {
1253
                // Filter files for blocked / provided
1254
                $filteredFileList = array_diff(
1255
                    $fileList,
1256
                    $this->getBlocked(),
1257
                    $providedScripts
1258
                );
1259
                $combinedURL = $this->getCombinedFileURL($combinedFile, $filteredFileList, $type);
1260
            }
1261
1262
            // Replace all existing files, injecting the combined file at the position of the first item
1263
            // in order to preserve inclusion order.
1264
            // Note that we iterate across blocked files in order to get the correct order, and validate
1265
            // that the file is included in the correct location (regardless of which files are blocked).
1266
            $included = false;
1267
            switch ($type) {
1268
                case 'css': {
1269
                    $newCSS = array(); // Assoc array of css file => spec
1270
                    foreach ($this->getAllCSS() as $css => $spec) {
1271
                        if (!in_array($css, $fileList)) {
1272
                            $newCSS[$css] = $spec;
1273
                        } 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...
1274
                            $newCSS[$combinedURL] = array('media' => (isset($options['media']) ? $options['media'] : null));
1275
                            $included = true;
1276
                        }
1277
                        // If already included, or otherwise blocked, then don't add into CSS
1278
                    }
1279
                    $this->css = $newCSS;
1280
                    break;
1281
                }
1282
                case 'js': {
1283
                    // Assoc array of file => attributes
1284
                    $newJS = array();
1285
                    foreach ($this->getAllJavascript() as $script => $attributes) {
1286
                        if (!in_array($script, $fileList)) {
1287
                            $newJS[$script] = $attributes;
1288
                        } 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...
1289
                            $newJS[$combinedURL] = $options;
1290
                            $included = true;
1291
                        }
1292
                        // If already included, or otherwise blocked, then don't add into scripts
1293
                    }
1294
                    $this->javascript = $newJS;
1295
                    break;
1296
                }
1297
            }
1298
        }
1299
    }
1300
1301
    /**
1302
     * Given a set of files, combine them (as necessary) and return the url
1303
     *
1304
     * @param string $combinedFile Filename for this combined file
1305
     * @param array $fileList List of files to combine
1306
     * @param string $type Either 'js' or 'css'
1307
     * @return string|null URL to this resource, if there are files to combine
1308
     * @throws Exception
1309
     */
1310
    protected function getCombinedFileURL($combinedFile, $fileList, $type)
1311
    {
1312
        // Skip empty lists
1313
        if (empty($fileList)) {
1314
            return null;
1315
        }
1316
1317
        // Generate path (Filename)
1318
        $hashQuerystring = Config::inst()->get(static::class, 'combine_hash_querystring');
1319
        if (!$hashQuerystring) {
1320
            $combinedFile = $this->hashedCombinedFilename($combinedFile, $fileList);
1321
        }
1322
        $combinedFileID = File::join_paths($this->getCombinedFilesFolder(), $combinedFile);
1323
1324
        // Send file combination request to the backend, with an optional callback to perform regeneration
1325
        $minify = $this->getMinifyCombinedFiles();
1326
        if ($minify && !$this->minifier) {
1327
            throw new Exception(
1328
                sprintf(
1329
                    <<<MESSAGE
1330
Cannot minify files without a minification service defined.
1331
Set %s::minifyCombinedFiles to false, or inject a %s service on
1332
%s.properties.minifier
1333
MESSAGE
1334
                    ,
1335
                    __CLASS__,
1336
                    Requirements_Minifier::class,
1337
                    __CLASS__
1338
                )
1339
            );
1340
        }
1341
1342
        $combinedURL = $this
1343
            ->getAssetHandler()
1344
            ->getContentURL(
1345
                $combinedFileID,
1346
                function () use ($fileList, $minify, $type) {
1347
                    // Physically combine all file content
1348
                    $combinedData = '';
1349
                    $base = Director::baseFolder() . '/';
1350
                    foreach ($fileList as $file) {
1351
                        $fileContent = file_get_contents($base . $file);
1352
                        // Use configured minifier
1353
                        if ($minify) {
1354
                            $fileContent = $this->minifier->minify($fileContent, $type, $file);
1355
                        }
1356
1357
                        if ($this->writeHeaderComment) {
1358
                            // Write a header comment for each file for easier identification and debugging.
1359
                            $combinedData .= "/****** FILE: $file *****/\n";
1360
                        }
1361
                        $combinedData .= $fileContent . "\n";
1362
                    }
1363
                    return $combinedData;
1364
                }
1365
            );
1366
1367
        // If the name isn't hashed, we will need to append the querystring m= parameter instead
1368
        // Since url won't be automatically suffixed, add it in here
1369
        if ($hashQuerystring && $this->getSuffixRequirements()) {
1370
            $hash = $this->hashOfFiles($fileList);
1371
            $q = stripos($combinedURL, '?') === false ? '?' : '&';
1372
            $combinedURL .= "{$q}m={$hash}";
1373
        }
1374
1375
        return $combinedURL;
1376
    }
1377
1378
    /**
1379
     * Given a filename and list of files, generate a new filename unique to these files
1380
     *
1381
     * @param string $combinedFile
1382
     * @param array $fileList
1383
     * @return string
1384
     */
1385
    protected function hashedCombinedFilename($combinedFile, $fileList)
1386
    {
1387
        $name = pathinfo($combinedFile, PATHINFO_FILENAME);
1388
        $hash = $this->hashOfFiles($fileList);
1389
        $extension = File::get_file_extension($combinedFile);
1390
        return $name . '-' . substr($hash, 0, 7) . '.' . $extension;
1391
    }
1392
1393
    /**
1394
     * Check if combined files are enabled
1395
     *
1396
     * @return bool
1397
     */
1398
    public function getCombinedFilesEnabled()
1399
    {
1400
        if (isset($this->combinedFilesEnabled)) {
1401
            return $this->combinedFilesEnabled;
1402
        }
1403
1404
        // Non-dev sites are always combined
1405
        if (!Director::isDev()) {
1406
            return true;
1407
        }
1408
1409
        // Fallback to default
1410
        return Config::inst()->get(__CLASS__, 'combine_in_dev');
1411
    }
1412
1413
    /**
1414
     * For a given filelist, determine some discriminating value to determine if
1415
     * any of these files have changed.
1416
     *
1417
     * @param array $fileList List of files
1418
     * @return string SHA1 bashed file hash
1419
     */
1420
    protected function hashOfFiles($fileList)
1421
    {
1422
        // Get hash based on hash of each file
1423
        $base = Director::baseFolder() . '/';
1424
        $hash = '';
1425
        foreach ($fileList as $file) {
1426
            if (file_exists($base . $file)) {
1427
                $hash .= sha1_file($base . $file);
1428
            } else {
1429
                throw new InvalidArgumentException("Combined file {$file} does not exist");
1430
            }
1431
        }
1432
        return sha1($hash);
1433
    }
1434
1435
    /**
1436
     * Registers the given themeable stylesheet as required.
1437
     *
1438
     * A CSS file in the current theme path name 'themename/css/$name.css' is first searched for,
1439
     * and it that doesn't exist and the module parameter is set then a CSS file with that name in
1440
     * the module is used.
1441
     *
1442
     * @param string $name The name of the file - eg '/css/File.css' would have the name 'File'
1443
     * @param string $media Comma-separated list of media types to use in the link tag
1444
     *                       (e.g. 'screen,projector')
1445
     */
1446
    public function themedCSS($name, $media = null)
1447
    {
1448
        if (substr($name, -4) !== '.css') {
1449
            $name .= '.css';
1450
        }
1451
1452
        $path = ResourceLoader::inst()->getRelativeResourcePath("css/$name", SSViewer::get_themes());
1453
        if (!$path) {
0 ignored issues
show
Bug Best Practice introduced by
The expression $path of type string|null is loosely compared to false; 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...
1454
            $path =  ResourceLoader::inst()->getRelativeResourcePath($name, SSViewer::get_themes());
1455
        }
1456
        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...
1457
            $this->css($path, $media);
1458
        } else {
1459
            throw new \InvalidArgumentException(
1460
                "The css file doesn't exist. Please check if the file $name.css exists in any context or search for "
1461
                . "themedCSS references calling this file in your templates."
1462
            );
1463
        }
1464
    }
1465
1466
    /**
1467
     * Registers the given themeable javascript as required.
1468
     *
1469
     * A javascript file in the current theme path name 'themename/javascript/$name.js' is first searched for,
1470
     * and it that doesn't exist and the module parameter is set then a javascript file with that name in
1471
     * the module is used.
1472
     *
1473
     * @param string $name The name of the file - eg '/js/File.js' would have the name 'File'
1474
     * @param string $type Comma-separated list of types to use in the script tag
1475
     *                       (e.g. 'text/javascript,text/ecmascript')
1476
     */
1477
    public function themedJavascript($name, $type = null)
1478
    {
1479
        if (substr($name, -3) !== '.js') {
1480
            $name .= '.js';
1481
        }
1482
1483
        $path = ResourceLoader::inst()->getRelativeResourcePath("javascript/$name", SSViewer::get_themes());
1484
        if (!$path) {
0 ignored issues
show
Bug Best Practice introduced by
The expression $path of type string|null is loosely compared to false; 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...
1485
            $path =  ResourceLoader::inst()->getRelativeResourcePath($name, SSViewer::get_themes());
1486
        }
1487
        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...
1488
            $opts = [];
1489
            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...
1490
                $opts['type'] = $type;
1491
            }
1492
            $this->javascript($path, $opts);
1493
        } else {
1494
            throw new \InvalidArgumentException(
1495
                "The javascript file doesn't exist. Please check if the file $name.js exists in any "
1496
                . "context or search for themedJavascript references calling this file in your templates."
1497
            );
1498
        }
1499
    }
1500
1501
    /**
1502
     * Output debugging information.
1503
     */
1504
    public function debug()
1505
    {
1506
        Debug::show($this->javascript);
1507
        Debug::show($this->css);
1508
        Debug::show($this->customCSS);
1509
        Debug::show($this->customScript);
1510
        Debug::show($this->customHeadTags);
1511
        Debug::show($this->combinedFiles);
1512
    }
1513
}
1514