Completed
Push — resource-url-generator ( 79ebbf )
by Sam
09:20
created

Requirements_Backend::getWriteJavascriptToBody()   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 0
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\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 you want to write the JS to the body of the page rather than at the end of the
303
     * head tag.
304
     *
305
     * @param bool
306
     * @return $this
307
     */
308
    public function setWriteJavascriptToBody($var)
309
    {
310
        $this->writeJavascriptToBody = $var;
311
        return $this;
312
    }
313
314
    /**
315
     * Check whether you want to write the JS to the body of the page rather than at the end of the
316
     * head tag.
317
     *
318
     * @return bool
319
     */
320
    public function getWriteJavascriptToBody()
321
    {
322
        return $this->writeJavascriptToBody;
323
    }
324
325
    /**
326
     * Forces the JavaScript requirements to the end of the body, right before the closing tag
327
     *
328
     * @param bool
329
     * @return $this
330
     */
331
    public function setForceJSToBottom($var)
332
    {
333
        $this->forceJSToBottom = $var;
334
        return $this;
335
    }
336
337
    /**
338
     * Check if the JavaScript requirements are written to the end of the body, right before the closing tag
339
     *
340
     * @return bool
341
     */
342
    public function getForceJSToBottom()
343
    {
344
        return $this->forceJSToBottom;
345
    }
346
347
    /**
348
     * Check if minify files should be combined
349
     *
350
     * @return bool
351
     */
352
    public function getMinifyCombinedFiles()
353
    {
354
        return $this->minifyCombinedFiles;
355
    }
356
357
    /**
358
     * Set if combined files should be minified
359
     *
360
     * @param bool $minify
361
     * @return $this
362
     */
363
    public function setMinifyCombinedFiles($minify)
364
    {
365
        $this->minifyCombinedFiles = $minify;
366
        return $this;
367
    }
368
369
    /**
370
     * Register the given JavaScript file as required.
371
     *
372
     * @param string $file Either relative to docroot or in the form "vendor/package:resource"
373
     * @param array $options List of options. Available options include:
374
     * - 'provides' : List of scripts files included in this file
375
     * - 'async' : Boolean value to set async attribute to script tag
376
     * - 'defer' : Boolean value to set defer attribute to script tag
377
     * - 'type' : Override script type= value.
378
     */
379
    public function javascript($file, $options = array())
380
    {
381 View Code Duplication
        if (strpos($file, ':') !== false) {
0 ignored issues
show
Duplication introduced by
This code seems to be duplicated across your project.

Duplicated code is one of the most pungent code smells. If you need to duplicate the same code in three or more different places, we strongly encourage you to look into extracting the code into a single class or operation.

You can also find more detailed suggestions in the “Code” section of your repository.

Loading history...
382
            list($module, $resource) = preg_split('/ *: */', $file, 2);
383
            $file = ModuleLoader::getModule($module)->getRelativeResourcePath($resource);
384
        }
385
386
        // Get type
387
        $type = null;
388
        if (isset($this->javascript[$file]['type'])) {
389
            $type = $this->javascript[$file]['type'];
390
        }
391
        if (isset($options['type'])) {
392
            $type = $options['type'];
393
        }
394
395
        // make sure that async/defer is set if it is set once even if file is included multiple times
396
        $async = (
397
            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...
398
            || (
399
                isset($this->javascript[$file])
400
                && isset($this->javascript[$file]['async'])
401
                && $this->javascript[$file]['async'] == true
402
            )
403
        );
404
        $defer = (
405
            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...
406
            || (
407
                isset($this->javascript[$file])
408
                && isset($this->javascript[$file]['defer'])
409
                && $this->javascript[$file]['defer'] == true
410
            )
411
        );
412
        $this->javascript[$file] = array(
413
            'async' => $async,
414
            'defer' => $defer,
415
            'type' => $type,
416
        );
417
418
        // Record scripts included in this file
419
        if (isset($options['provides'])) {
420
            $this->providedJavascript[$file] = array_values($options['provides']);
421
        }
422
    }
423
424
    /**
425
     * Remove a javascript requirement
426
     *
427
     * @param string $file
428
     */
429
    protected function unsetJavascript($file)
430
    {
431
        unset($this->javascript[$file]);
432
    }
433
434
    /**
435
     * Gets all scripts that are already provided by prior scripts.
436
     * This follows these rules:
437
     *  - Files will not be considered provided if they are separately
438
     *    included prior to the providing file.
439
     *  - Providing files can be blocked, and don't provide anything
440
     *  - Provided files can't be blocked (you need to block the provider)
441
     *  - If a combined file includes files that are provided by prior
442
     *    scripts, then these should be excluded from the combined file.
443
     *  - If a combined file includes files that are provided by later
444
     *    scripts, then these files should be included in the combined
445
     *    file, but we can't block the later script either (possible double
446
     *    up of file).
447
     *
448
     * @return array Array of provided files (map of $path => $path)
449
     */
450
    public function getProvidedScripts()
451
    {
452
        $providedScripts = array();
453
        $includedScripts = array();
454
        foreach ($this->javascript as $script => $options) {
455
            // Ignore scripts that are explicitly blocked
456
            if (isset($this->blocked[$script])) {
457
                continue;
458
            }
459
            // At this point, the file is included.
460
            // This might also be combined at this point, potentially.
461
            $includedScripts[$script] = true;
462
463
            // Record any files this provides, EXCEPT those already included by now
464
            if (isset($this->providedJavascript[$script])) {
465
                foreach ($this->providedJavascript[$script] as $provided) {
466
                    if (!isset($includedScripts[$provided])) {
467
                        $providedScripts[$provided] = $provided;
468
                    }
469
                }
470
            }
471
        }
472
        return $providedScripts;
473
    }
474
475
    /**
476
     * Returns an array of required JavaScript, excluding blocked
477
     * and duplicates of provided files.
478
     *
479
     * @return array
480
     */
481
    public function getJavascript()
482
    {
483
        return array_diff_key(
484
            $this->javascript,
485
            $this->getBlocked(),
486
            $this->getProvidedScripts()
487
        );
488
    }
489
490
    /**
491
     * Gets all javascript, including blocked files. Unwraps the array into a non-associative list
492
     *
493
     * @return array Indexed array of javascript files
494
     */
495
    protected function getAllJavascript()
496
    {
497
        return $this->javascript;
498
    }
499
500
    /**
501
     * Register the given JavaScript code into the list of requirements
502
     *
503
     * @param string $script The script content as a string (without enclosing <script> tag)
504
     * @param string $uniquenessID A unique ID that ensures a piece of code is only added once
505
     */
506
    public function customScript($script, $uniquenessID = null)
507
    {
508
        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...
509
            $this->customScript[$uniquenessID] = $script;
510
        } else {
511
            $this->customScript[] = $script;
512
        }
513
    }
514
515
    /**
516
     * Return all registered custom scripts
517
     *
518
     * @return array
519
     */
520
    public function getCustomScripts()
521
    {
522
        return array_diff_key($this->customScript, $this->blocked);
523
    }
524
525
    /**
526
     * Register the given CSS styles into the list of requirements
527
     *
528
     * @param string $script CSS selectors as a string (without enclosing <style> tag)
529
     * @param string $uniquenessID A unique ID that ensures a piece of code is only added once
530
     */
531
    public function customCSS($script, $uniquenessID = null)
532
    {
533
        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...
534
            $this->customCSS[$uniquenessID] = $script;
535
        } else {
536
            $this->customCSS[] = $script;
537
        }
538
    }
539
540
    /**
541
     * Return all registered custom CSS
542
     *
543
     * @return array
544
     */
545
    public function getCustomCSS()
546
    {
547
        return array_diff_key($this->customCSS, $this->blocked);
548
    }
549
550
    /**
551
     * Add the following custom HTML code to the <head> section of the page
552
     *
553
     * @param string $html Custom HTML code
554
     * @param string $uniquenessID A unique ID that ensures a piece of code is only added once
555
     */
556
    public function insertHeadTags($html, $uniquenessID = null)
557
    {
558
        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...
559
            $this->customHeadTags[$uniquenessID] = $html;
560
        } else {
561
            $this->customHeadTags[] = $html;
562
        }
563
    }
564
565
    /**
566
     * Return all custom head tags
567
     *
568
     * @return array
569
     */
570
    public function getCustomHeadTags()
571
    {
572
        return array_diff_key($this->customHeadTags, $this->blocked);
573
    }
574
575
    /**
576
     * Include the content of the given JavaScript file in the list of requirements. Dollar-sign
577
     * variables will be interpolated with values from $vars similar to a .ss template.
578
     *
579
     * @param string $file The template file to load, relative to docroot
580
     * @param string[] $vars The array of variables to interpolate.
581
     * @param string $uniquenessID A unique ID that ensures a piece of code is only added once
582
     */
583
    public function javascriptTemplate($file, $vars, $uniquenessID = null)
584
    {
585
        $script = file_get_contents(Director::getAbsFile($file));
586
        $search = array();
587
        $replace = array();
588
589
        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...
590
            foreach ($vars as $k => $v) {
591
                $search[] = '$' . $k;
592
                $replace[] = str_replace("\\'", "'", Convert::raw2js($v));
593
            }
594
        }
595
596
        $script = str_replace($search, $replace, $script);
597
        $this->customScript($script, $uniquenessID);
598
    }
599
600
    /**
601
     * Register the given stylesheet into the list of requirements.
602
     *
603
     * @param string $file The CSS file to load, relative to site root
604
     * @param string $media Comma-separated list of media types to use in the link tag
605
     *                      (e.g. 'screen,projector')
606
     */
607
    public function css($file, $media = null)
608
    {
609 View Code Duplication
        if (strpos($file, ':') !== false) {
0 ignored issues
show
Duplication introduced by
This code seems to be duplicated across your project.

Duplicated code is one of the most pungent code smells. If you need to duplicate the same code in three or more different places, we strongly encourage you to look into extracting the code into a single class or operation.

You can also find more detailed suggestions in the “Code” section of your repository.

Loading history...
610
            list($module, $resource) = preg_split('/ *: */', $file, 2);
611
            $file = ModuleLoader::getModule($module)->getRelativeResourcePath($resource);
612
        }
613
614
        $this->css[$file] = array(
615
            "media" => $media
616
        );
617
    }
618
619
    /**
620
     * Remove a css requirement
621
     *
622
     * @param string $file
623
     */
624
    protected function unsetCSS($file)
625
    {
626
        unset($this->css[$file]);
627
    }
628
629
    /**
630
     * Get the list of registered CSS file requirements, excluding blocked files
631
     *
632
     * @return array Associative array of file to spec
633
     */
634
    public function getCSS()
635
    {
636
        return array_diff_key($this->css, $this->blocked);
637
    }
638
639
    /**
640
     * Gets all CSS files requirements, including blocked
641
     *
642
     * @return array Associative array of file to spec
643
     */
644
    protected function getAllCSS()
645
    {
646
        return $this->css;
647
    }
648
649
    /**
650
     * Gets the list of all blocked files
651
     *
652
     * @return array
653
     */
654
    public function getBlocked()
655
    {
656
        return $this->blocked;
657
    }
658
659
    /**
660
     * Clear either a single or all requirements
661
     *
662
     * Caution: Clearing single rules added via customCSS and customScript only works if you
663
     * originally specified a $uniquenessID.
664
     *
665
     * @param string|int $fileOrID
666
     */
667
    public function clear($fileOrID = null)
668
    {
669
        if ($fileOrID) {
670
            foreach (array('javascript', 'css', 'customScript', 'customCSS', 'customHeadTags') as $type) {
671
                if (isset($this->{$type}[$fileOrID])) {
672
                    $this->disabled[$type][$fileOrID] = $this->{$type}[$fileOrID];
673
                    unset($this->{$type}[$fileOrID]);
674
                }
675
            }
676
        } else {
677
            $this->disabled['javascript'] = $this->javascript;
678
            $this->disabled['css'] = $this->css;
679
            $this->disabled['customScript'] = $this->customScript;
680
            $this->disabled['customCSS'] = $this->customCSS;
681
            $this->disabled['customHeadTags'] = $this->customHeadTags;
682
683
            $this->javascript = array();
684
            $this->css = array();
685
            $this->customScript = array();
686
            $this->customCSS = array();
687
            $this->customHeadTags = array();
688
        }
689
    }
690
691
    /**
692
     * Restore requirements cleared by call to Requirements::clear
693
     */
694
    public function restore()
695
    {
696
        $this->javascript = $this->disabled['javascript'];
697
        $this->css = $this->disabled['css'];
698
        $this->customScript = $this->disabled['customScript'];
699
        $this->customCSS = $this->disabled['customCSS'];
700
        $this->customHeadTags = $this->disabled['customHeadTags'];
701
    }
702
703
    /**
704
     * Block inclusion of a specific file
705
     *
706
     * The difference between this and {@link clear} is that the calling order does not matter;
707
     * {@link clear} must be called after the initial registration, whereas {@link block} can be
708
     * used in advance. This is useful, for example, to block scripts included by a superclass
709
     * without having to override entire functions and duplicate a lot of code.
710
     *
711
     * Note that blocking should be used sparingly because it's hard to trace where an file is
712
     * being blocked from.
713
     *
714
     * @param string|int $fileOrID
715
     */
716
    public function block($fileOrID)
717
    {
718
        $this->blocked[$fileOrID] = $fileOrID;
719
    }
720
721
    /**
722
     * Remove an item from the block list
723
     *
724
     * @param string|int $fileOrID
725
     */
726
    public function unblock($fileOrID)
727
    {
728
        unset($this->blocked[$fileOrID]);
729
    }
730
731
    /**
732
     * Removes all items from the block list
733
     */
734
    public function unblockAll()
735
    {
736
        $this->blocked = array();
737
    }
738
739
    /**
740
     * Update the given HTML content with the appropriate include tags for the registered
741
     * requirements. Needs to receive a valid HTML/XHTML template in the $content parameter,
742
     * including a head and body tag.
743
     *
744
     * @param string $content HTML content that has already been parsed from the $templateFile
745
     *                             through {@link SSViewer}
746
     * @return string HTML content augmented with the requirements tags
747
     */
748
    public function includeInHTML($content)
749
    {
750
        if (func_num_args() > 1) {
751
            Deprecation::notice(
752
                '5.0',
753
                '$templateFile argument is deprecated. includeInHTML takes a sole $content parameter now.'
754
            );
755
            $content = func_get_arg(1);
756
        }
757
758
        // Skip if content isn't injectable, or there is nothing to inject
759
        $tagsAvailable = preg_match('#</head\b#', $content);
760
        $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...
761
        if (!$tagsAvailable || !$hasFiles) {
762
            return $content;
763
        }
764
        $requirements = '';
765
        $jsRequirements = '';
766
767
        // Combine files - updates $this->javascript and $this->css
768
        $this->processCombinedFiles();
769
770
        // Script tags for js links
771
        foreach ($this->getJavascript() as $file => $attributes) {
772
            // Build html attributes
773
            $htmlAttributes = [
774
                'type' => isset($attributes['type']) ? $attributes['type'] : "application/javascript",
775
                'src' => $this->pathForFile($file),
776
            ];
777
            if (!empty($attributes['async'])) {
778
                $htmlAttributes['async'] = 'async';
779
            }
780
            if (!empty($attributes['defer'])) {
781
                $htmlAttributes['defer'] = 'defer';
782
            }
783
            $jsRequirements .= HTML::createTag('script', $htmlAttributes);
784
            $jsRequirements .= "\n";
785
        }
786
787
        // Add all inline JavaScript *after* including external files they might rely on
788
        foreach ($this->getCustomScripts() as $script) {
789
            $jsRequirements .= HTML::createTag(
790
                'script',
791
                [ 'type' => 'application/javascript' ],
792
                "//<![CDATA[\n{$script}\n//]]>"
793
            );
794
            $jsRequirements .= "\n";
795
        }
796
797
        // CSS file links
798
        foreach ($this->getCSS() as $file => $params) {
799
            $htmlAttributes = [
800
                'rel' => 'stylesheet',
801
                'type' => 'text/css',
802
                'href' => $this->pathForFile($file),
803
            ];
804
            if (!empty($params['media'])) {
805
                $htmlAttributes['media'] = $params['media'];
806
            }
807
            $requirements .= HTML::createTag('link', $htmlAttributes);
808
            $requirements .= "\n";
809
        }
810
811
        // Literal custom CSS content
812
        foreach ($this->getCustomCSS() as $css) {
813
            $requirements .= HTML::createTag('style', ['type' => 'text/css'], "\n{$css}\n");
814
            $requirements .= "\n";
815
        }
816
817
        foreach ($this->getCustomHeadTags() as $customHeadTag) {
818
            $requirements .= "{$customHeadTag}\n";
819
        }
820
821
        // Inject CSS  into body
822
        $content = $this->insertTagsIntoHead($requirements, $content);
823
824
        // Inject scripts
825
        if ($this->getForceJSToBottom()) {
826
            $content = $this->insertScriptsAtBottom($jsRequirements, $content);
827
        } elseif ($this->getWriteJavascriptToBody()) {
828
            $content = $this->insertScriptsIntoBody($jsRequirements, $content);
829
        } else {
830
            $content = $this->insertTagsIntoHead($jsRequirements, $content);
831
        }
832
        return $content;
833
    }
834
835
    /**
836
     * Given a block of HTML, insert the given scripts at the bottom before
837
     * the closing </body> tag
838
     *
839
     * @param string $jsRequirements String containing one or more javascript <script /> tags
840
     * @param string $content HTML body
841
     * @return string Merged HTML
842
     */
843
    protected function insertScriptsAtBottom($jsRequirements, $content)
844
    {
845
        // Forcefully put the scripts at the bottom of the body instead of before the first
846
        // script tag.
847
        $content = preg_replace(
848
            '/(<\/body[^>]*>)/i',
849
            $this->escapeReplacement($jsRequirements) . '\\1',
850
            $content
851
        );
852
        return $content;
853
    }
854
855
    /**
856
     * Given a block of HTML, insert the given scripts inside the <body></body>
857
     *
858
     * @param string $jsRequirements String containing one or more javascript <script /> tags
859
     * @param string $content HTML body
860
     * @return string Merged HTML
861
     */
862
    protected function insertScriptsIntoBody($jsRequirements, $content)
863
    {
864
        // If your template already has script tags in the body, then we try to put our script
865
        // tags just before those. Otherwise, we put it at the bottom.
866
        $bodyTagPosition = stripos($content, '<body');
867
        $scriptTagPosition = stripos($content, '<script', $bodyTagPosition);
868
869
        $commentTags = array();
870
        $canWriteToBody = ($scriptTagPosition !== false)
871
            &&
872
            // Check that the script tag is not inside a html comment tag
873
            !(
874
                preg_match('/.*(?|(<!--)|(-->))/U', $content, $commentTags, 0, $scriptTagPosition)
875
                &&
876
                $commentTags[1] == '-->'
877
            );
878
879
        if ($canWriteToBody) {
880
            // Insert content before existing script tags
881
            $content = substr($content, 0, $scriptTagPosition)
882
                . $jsRequirements
883
                . substr($content, $scriptTagPosition);
884
        } else {
885
            // Insert content at bottom of page otherwise
886
            $content = $this->insertScriptsAtBottom($jsRequirements, $content);
887
        }
888
889
        return $content;
890
    }
891
892
    /**
893
     * Given a block of HTML, insert the given code inside the <head></head> block
894
     *
895
     * @param string $jsRequirements String containing one or more html tags
896
     * @param string $content HTML body
897
     * @return string Merged HTML
898
     */
899
    protected function insertTagsIntoHead($jsRequirements, $content)
900
    {
901
        $content = preg_replace(
902
            '/(<\/head>)/i',
903
            $this->escapeReplacement($jsRequirements) . '\\1',
904
            $content
905
        );
906
        return $content;
907
    }
908
909
    /**
910
     * Safely escape a literal string for use in preg_replace replacement
911
     *
912
     * @param string $replacement
913
     * @return string
914
     */
915
    protected function escapeReplacement($replacement)
916
    {
917
        return addcslashes($replacement, '\\$');
918
    }
919
920
    /**
921
     * Attach requirements inclusion to X-Include-JS and X-Include-CSS headers on the given
922
     * HTTP Response
923
     *
924
     * @param HTTPResponse $response
925
     */
926
    public function includeInResponse(HTTPResponse $response)
927
    {
928
        $this->processCombinedFiles();
929
        $jsRequirements = array();
930
        $cssRequirements = array();
931
932
        foreach ($this->getJavascript() as $file => $attributes) {
933
            $path = $this->pathForFile($file);
934
            if ($path) {
935
                $jsRequirements[] = str_replace(',', '%2C', $path);
936
            }
937
        }
938
939
        if (count($jsRequirements)) {
940
            $response->addHeader('X-Include-JS', implode(',', $jsRequirements));
941
        }
942
943
        foreach ($this->getCSS() as $file => $params) {
944
            $path = $this->pathForFile($file);
945
            if ($path) {
946
                $path = str_replace(',', '%2C', $path);
947
                $cssRequirements[] = isset($params['media']) ? "$path:##:$params[media]" : $path;
948
            }
949
        }
950
951
        if (count($cssRequirements)) {
952
            $response->addHeader('X-Include-CSS', implode(',', $cssRequirements));
953
        }
954
    }
955
956
    /**
957
     * Add i18n files from the given javascript directory. SilverStripe expects that the given
958
     * directory will contain a number of JavaScript files named by language: en_US.js, de_DE.js,
959
     * etc.
960
     *
961
     * @param string $langDir The JavaScript lang directory, relative to the site root, e.g.,
962
     *                         'framework/javascript/lang'
963
     * @param bool $return Return all relative file paths rather than including them in
964
     *                         requirements
965
     *
966
     * @return array|null All relative files if $return is true, or null otherwise
967
     */
968
    public function add_i18n_javascript($langDir, $return = false)
969
    {
970
        $files = array();
971
        $base = Director::baseFolder() . '/';
972
973
        if (substr($langDir, -1) != '/') {
974
            $langDir .= '/';
975
        }
976
977
        $candidates = array(
978
            'en.js',
979
            'en_US.js',
980
            i18n::getData()->langFromLocale(i18n::config()->get('default_locale')) . '.js',
981
            i18n::config()->get('default_locale') . '.js',
982
            i18n::getData()->langFromLocale(i18n::get_locale()) . '.js',
983
            i18n::get_locale() . '.js',
984
        );
985
        foreach ($candidates as $candidate) {
986
            if (file_exists($base . DIRECTORY_SEPARATOR . $langDir . $candidate)) {
987
                $files[] = $langDir . $candidate;
988
            }
989
        }
990
991
        if ($return) {
992
            return $files;
993
        } else {
994
            foreach ($files as $file) {
995
                $this->javascript($file);
996
            }
997
            return null;
998
        }
999
    }
1000
1001
    /**
1002
     * Finds the path for specified file
1003
     *
1004
     * @param string $fileOrUrl
1005
     * @return string|bool
1006
     */
1007
    protected function pathForFile($fileOrUrl)
1008
    {
1009
        // Since combined urls could be root relative, treat them as urls here.
1010
        if (preg_match('{^(//)|(http[s]?:)}', $fileOrUrl) || Director::is_root_relative_url($fileOrUrl)) {
1011
            return $fileOrUrl;
1012
        } else {
1013
            return Injector::inst()->get(ResourceURLGenerator::class)->urlForResource($fileOrUrl);
1014
        }
1015
    }
1016
1017
    /**
1018
     * Concatenate several css or javascript files into a single dynamically generated file. This
1019
     * increases performance by fewer HTTP requests.
1020
     *
1021
     * The combined file is regenerated based on every file modification time. Optionally a
1022
     * rebuild can be triggered by appending ?flush=1 to the URL.
1023
     *
1024
     * All combined files will have a comment on the start of each concatenated file denoting their
1025
     * original position.
1026
     *
1027
     * CAUTION: You're responsible for ensuring that the load order for combined files is
1028
     * retained - otherwise combining JavaScript files can lead to functional errors in the
1029
     * JavaScript logic, and combining CSS can lead to incorrect inheritance. You can also
1030
     * only include each file once across all includes and combinations in a single page load.
1031
     *
1032
     * CAUTION: Combining CSS Files discards any "media" information.
1033
     *
1034
     * Example for combined JavaScript:
1035
     * <code>
1036
     * Requirements::combine_files(
1037
     *    'foobar.js',
1038
     *    array(
1039
     *        'mysite/javascript/foo.js',
1040
     *        'mysite/javascript/bar.js',
1041
     *    ),
1042
     *    array(
1043
     *        'async' => true,
1044
     *        'defer' => true,
1045
     *    )
1046
     * );
1047
     * </code>
1048
     *
1049
     * Example for combined CSS:
1050
     * <code>
1051
     * Requirements::combine_files(
1052
     *    'foobar.css',
1053
     *    array(
1054
     *        'mysite/javascript/foo.css',
1055
     *        'mysite/javascript/bar.css',
1056
     *    ),
1057
     *    array(
1058
     *        'media' => 'print',
1059
     *    )
1060
     * );
1061
     * </code>
1062
     *
1063
     * @param string $combinedFileName Filename of the combined file relative to docroot
1064
     * @param array $files Array of filenames relative to docroot
1065
     * @param array $options Array of options for combining files. Available options are:
1066
     * - 'media' : If including CSS Files, you can specify a media type
1067
     * - 'async' : If including JavaScript Files, boolean value to set async attribute to script tag
1068
     * - 'defer' : If including JavaScript Files, boolean value to set defer attribute to script tag
1069
     */
1070
    public function combineFiles($combinedFileName, $files, $options = array())
1071
    {
1072
        if (is_string($options)) {
1073
            Deprecation::notice('4.0', 'Parameter media is deprecated. Use options array instead.');
1074
            $options = array('media' => $options);
1075
        }
1076
        // Skip this combined files if already included
1077
        if (isset($this->combinedFiles[$combinedFileName])) {
1078
            return;
1079
        }
1080
1081
        // Add all files to necessary type list
1082
        $paths = array();
1083
        $combinedType = null;
1084
        foreach ($files as $file) {
1085
            // Get file details
1086
            list($path, $type) = $this->parseCombinedFile($file);
1087
            if ($type === 'javascript') {
1088
                $type = 'js';
1089
            }
1090
            if ($combinedType && $type && $combinedType !== $type) {
1091
                throw new InvalidArgumentException(
1092
                    "Cannot mix js and css files in same combined file {$combinedFileName}"
1093
                );
1094
            }
1095
            switch ($type) {
1096
                case 'css':
1097
                    $this->css($path, (isset($options['media']) ? $options['media'] : null));
1098
                    break;
1099
                case 'js':
1100
                    $this->javascript($path, $options);
1101
                    break;
1102
                default:
1103
                    throw new InvalidArgumentException("Invalid combined file type: {$type}");
1104
            }
1105
            $combinedType = $type;
1106
            $paths[] = $path;
1107
        }
1108
1109
        // Duplicate check
1110
        foreach ($this->combinedFiles as $existingCombinedFilename => $combinedItem) {
1111
            $existingFiles = $combinedItem['files'];
1112
            $duplicates = array_intersect($existingFiles, $paths);
1113
            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...
1114
                throw new InvalidArgumentException(sprintf(
1115
                    "Requirements_Backend::combine_files(): Already included file(s) %s in combined file '%s'",
1116
                    implode(',', $duplicates),
1117
                    $existingCombinedFilename
1118
                ));
1119
            }
1120
        }
1121
1122
        $this->combinedFiles[$combinedFileName] = array(
1123
            'files' => $paths,
1124
            'type' => $combinedType,
1125
            'options' => $options,
1126
        );
1127
    }
1128
1129
    /**
1130
     * Return path and type of given combined file
1131
     *
1132
     * @param string|array $file Either a file path, or an array spec
1133
     * @return array array with two elements, path and type of file
1134
     */
1135
    protected function parseCombinedFile($file)
1136
    {
1137
        // Array with path and type keys
1138
        if (is_array($file) && isset($file['path']) && isset($file['type'])) {
1139
            return array($file['path'], $file['type']);
1140
        }
1141
1142
        // Extract value from indexed array
1143
        if (is_array($file)) {
1144
            $path = array_shift($file);
1145
1146
            // See if there's a type specifier
1147
            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...
1148
                $type = array_shift($file);
1149
                return array($path, $type);
1150
            }
1151
1152
            // Otherwise convent to string
1153
            $file = $path;
1154
        }
1155
1156
        $type = File::get_file_extension($file);
1157
        return array($file, $type);
1158
    }
1159
1160
    /**
1161
     * Return all combined files; keys are the combined file names, values are lists of
1162
     * associative arrays with 'files', 'type', and 'media' keys for details about this
1163
     * combined file.
1164
     *
1165
     * @return array
1166
     */
1167
    public function getCombinedFiles()
1168
    {
1169
        return array_diff_key($this->combinedFiles, $this->blocked);
1170
    }
1171
1172
    /**
1173
     * Includes all combined files, including blocked ones
1174
     *
1175
     * @return array
1176
     */
1177
    protected function getAllCombinedFiles()
1178
    {
1179
        return $this->combinedFiles;
1180
    }
1181
1182
    /**
1183
     * Clears all combined files
1184
     */
1185
    public function deleteAllCombinedFiles()
1186
    {
1187
        $combinedFolder = $this->getCombinedFilesFolder();
1188
        if ($combinedFolder) {
1189
            $this->getAssetHandler()->removeContent($combinedFolder);
1190
        }
1191
    }
1192
1193
    /**
1194
     * Clear all registered CSS and JavaScript file combinations
1195
     */
1196
    public function clearCombinedFiles()
1197
    {
1198
        $this->combinedFiles = array();
1199
    }
1200
1201
    /**
1202
     * Do the heavy lifting involved in combining the combined files.
1203
     */
1204
    public function processCombinedFiles()
1205
    {
1206
        // Check if combining is enabled
1207
        if (!$this->getCombinedFilesEnabled()) {
1208
            return;
1209
        }
1210
1211
        // Before scripts are modified, detect files that are provided by preceding ones
1212
        $providedScripts = $this->getProvidedScripts();
1213
1214
        // Process each combined files
1215
        foreach ($this->getAllCombinedFiles() as $combinedFile => $combinedItem) {
1216
            $fileList = $combinedItem['files'];
1217
            $type = $combinedItem['type'];
1218
            $options = $combinedItem['options'];
1219
1220
            // Generate this file, unless blocked
1221
            $combinedURL = null;
1222
            if (!isset($this->blocked[$combinedFile])) {
1223
                // Filter files for blocked / provided
1224
                $filteredFileList = array_diff(
1225
                    $fileList,
1226
                    $this->getBlocked(),
1227
                    $providedScripts
1228
                );
1229
                $combinedURL = $this->getCombinedFileURL($combinedFile, $filteredFileList, $type);
1230
            }
1231
1232
            // Replace all existing files, injecting the combined file at the position of the first item
1233
            // in order to preserve inclusion order.
1234
            // Note that we iterate across blocked files in order to get the correct order, and validate
1235
            // that the file is included in the correct location (regardless of which files are blocked).
1236
            $included = false;
1237
            switch ($type) {
1238
                case 'css': {
1239
                    $newCSS = array(); // Assoc array of css file => spec
1240
                    foreach ($this->getAllCSS() as $css => $spec) {
1241
                        if (!in_array($css, $fileList)) {
1242
                            $newCSS[$css] = $spec;
1243
                        } 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...
1244
                            $newCSS[$combinedURL] = array('media' => (isset($options['media']) ? $options['media'] : null));
1245
                            $included = true;
1246
                        }
1247
                        // If already included, or otherwise blocked, then don't add into CSS
1248
                    }
1249
                    $this->css = $newCSS;
1250
                    break;
1251
                }
1252
                case 'js': {
1253
                    // Assoc array of file => attributes
1254
                    $newJS = array();
1255
                    foreach ($this->getAllJavascript() as $script => $attributes) {
1256
                        if (!in_array($script, $fileList)) {
1257
                            $newJS[$script] = $attributes;
1258
                        } 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...
1259
                            $newJS[$combinedURL] = $options;
1260
                            $included = true;
1261
                        }
1262
                        // If already included, or otherwise blocked, then don't add into scripts
1263
                    }
1264
                    $this->javascript = $newJS;
1265
                    break;
1266
                }
1267
            }
1268
        }
1269
    }
1270
1271
    /**
1272
     * Given a set of files, combine them (as necessary) and return the url
1273
     *
1274
     * @param string $combinedFile Filename for this combined file
1275
     * @param array $fileList List of files to combine
1276
     * @param string $type Either 'js' or 'css'
1277
     * @return string|null URL to this resource, if there are files to combine
1278
     * @throws Exception
1279
     */
1280
    protected function getCombinedFileURL($combinedFile, $fileList, $type)
1281
    {
1282
        // Skip empty lists
1283
        if (empty($fileList)) {
1284
            return null;
1285
        }
1286
1287
        // Generate path (Filename)
1288
        $hashQuerystring = Config::inst()->get(static::class, 'combine_hash_querystring');
1289
        if (!$hashQuerystring) {
1290
            $combinedFile = $this->hashedCombinedFilename($combinedFile, $fileList);
1291
        }
1292
        $combinedFileID = File::join_paths($this->getCombinedFilesFolder(), $combinedFile);
1293
1294
        // Send file combination request to the backend, with an optional callback to perform regeneration
1295
        $minify = $this->getMinifyCombinedFiles();
1296
        if ($minify && !$this->minifier) {
1297
            throw new Exception(
1298
                sprintf(
1299
                    <<<MESSAGE
1300
Cannot minify files without a minification service defined.
1301
Set %s::minifyCombinedFiles to false, or inject a %s service on
1302
%s.properties.minifier
1303
MESSAGE
1304
                    ,
1305
                    __CLASS__,
1306
                    Requirements_Minifier::class,
1307
                    __CLASS__
1308
                )
1309
            );
1310
        }
1311
1312
        $combinedURL = $this
1313
            ->getAssetHandler()
1314
            ->getContentURL(
1315
                $combinedFileID,
1316
                function () use ($fileList, $minify, $type) {
1317
                    // Physically combine all file content
1318
                    $combinedData = '';
1319
                    $base = Director::baseFolder() . '/';
1320
                    foreach ($fileList as $file) {
1321
                        $fileContent = file_get_contents($base . $file);
1322
                        // Use configured minifier
1323
                        if ($minify) {
1324
                            $fileContent = $this->minifier->minify($fileContent, $type, $file);
1325
                        }
1326
1327
                        if ($this->writeHeaderComment) {
1328
                            // Write a header comment for each file for easier identification and debugging.
1329
                            $combinedData .= "/****** FILE: $file *****/\n";
1330
                        }
1331
                        $combinedData .= $fileContent . "\n";
1332
                    }
1333
                    return $combinedData;
1334
                }
1335
            );
1336
1337
        // If the name isn't hashed, we will need to append the querystring m= parameter instead
1338
        // Since url won't be automatically suffixed, add it in here
1339
        if ($hashQuerystring) {
1340
            $hash = $this->hashOfFiles($fileList);
1341
            $q = stripos($combinedURL, '?') === false ? '?' : '&';
1342
            $combinedURL .= "{$q}m={$hash}";
1343
        }
1344
1345
        return $combinedURL;
1346
    }
1347
1348
    /**
1349
     * Given a filename and list of files, generate a new filename unique to these files
1350
     *
1351
     * @param string $combinedFile
1352
     * @param array $fileList
1353
     * @return string
1354
     */
1355
    protected function hashedCombinedFilename($combinedFile, $fileList)
1356
    {
1357
        $name = pathinfo($combinedFile, PATHINFO_FILENAME);
1358
        $hash = $this->hashOfFiles($fileList);
1359
        $extension = File::get_file_extension($combinedFile);
1360
        return $name . '-' . substr($hash, 0, 7) . '.' . $extension;
1361
    }
1362
1363
    /**
1364
     * Check if combined files are enabled
1365
     *
1366
     * @return bool
1367
     */
1368
    public function getCombinedFilesEnabled()
1369
    {
1370
        if (isset($this->combinedFilesEnabled)) {
1371
            return $this->combinedFilesEnabled;
1372
        }
1373
1374
        // Non-dev sites are always combined
1375
        if (!Director::isDev()) {
1376
            return true;
1377
        }
1378
1379
        // Fallback to default
1380
        return Config::inst()->get(__CLASS__, 'combine_in_dev');
1381
    }
1382
1383
    /**
1384
     * For a given filelist, determine some discriminating value to determine if
1385
     * any of these files have changed.
1386
     *
1387
     * @param array $fileList List of files
1388
     * @return string SHA1 bashed file hash
1389
     */
1390
    protected function hashOfFiles($fileList)
1391
    {
1392
        // Get hash based on hash of each file
1393
        $base = Director::baseFolder() . '/';
1394
        $hash = '';
1395
        foreach ($fileList as $file) {
1396
            if (file_exists($base . $file)) {
1397
                $hash .= sha1_file($base . $file);
1398
            } else {
1399
                throw new InvalidArgumentException("Combined file {$file} does not exist");
1400
            }
1401
        }
1402
        return sha1($hash);
1403
    }
1404
1405
    /**
1406
     * Registers the given themeable stylesheet as required.
1407
     *
1408
     * A CSS file in the current theme path name 'themename/css/$name.css' is first searched for,
1409
     * and it that doesn't exist and the module parameter is set then a CSS file with that name in
1410
     * the module is used.
1411
     *
1412
     * @param string $name The name of the file - eg '/css/File.css' would have the name 'File'
1413
     * @param string $media Comma-separated list of media types to use in the link tag
1414
     *                       (e.g. 'screen,projector')
1415
     */
1416
    public function themedCSS($name, $media = null)
1417
    {
1418
        $path = ThemeResourceLoader::inst()->findThemedCSS($name, SSViewer::get_themes());
1419
        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...
1420
            $this->css($path, $media);
1421
        } else {
1422
            throw new \InvalidArgumentException(
1423
                "The css file doesn't exist. Please check if the file $name.css exists in any context or search for "
1424
                . "themedCSS references calling this file in your templates."
1425
            );
1426
        }
1427
    }
1428
1429
    /**
1430
     * Registers the given themeable javascript as required.
1431
     *
1432
     * A javascript file in the current theme path name 'themename/javascript/$name.js' is first searched for,
1433
     * and it that doesn't exist and the module parameter is set then a javascript file with that name in
1434
     * the module is used.
1435
     *
1436
     * @param string $name The name of the file - eg '/js/File.js' would have the name 'File'
1437
     * @param string $type Comma-separated list of types to use in the script tag
1438
     *                       (e.g. 'text/javascript,text/ecmascript')
1439
     */
1440
    public function themedJavascript($name, $type = null)
1441
    {
1442
        $path = ThemeResourceLoader::inst()->findThemedJavascript($name, SSViewer::get_themes());
1443
        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...
1444
            $opts = [];
1445
            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...
1446
                $opts['type'] = $type;
1447
            }
1448
            $this->javascript($path, $opts);
1449
        } else {
1450
            throw new \InvalidArgumentException(
1451
                "The javascript file doesn't exist. Please check if the file $name.js exists in any "
1452
                . "context or search for themedJavascript references calling this file in your templates."
1453
            );
1454
        }
1455
    }
1456
1457
    /**
1458
     * Output debugging information.
1459
     */
1460
    public function debug()
1461
    {
1462
        Debug::show($this->javascript);
1463
        Debug::show($this->css);
1464
        Debug::show($this->customCSS);
1465
        Debug::show($this->customScript);
1466
        Debug::show($this->customHeadTags);
1467
        Debug::show($this->combinedFiles);
1468
    }
1469
}
1470