Completed
Push — master ( 17ddfa...362143 )
by Ingo
15:55 queued 07:54
created

Requirements_Backend::getAllJavascript()   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 InvalidArgumentException;
6
use Exception;
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\Dev\Debug;
15
use SilverStripe\Dev\Deprecation;
16
use SilverStripe\Dev\SapphireTest;
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
37
     */
38
    protected $combinedFilesEnabled = true;
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
        foreach ($this->getJavascript() as $file => $attributes) {
782
            $async = (isset($attributes['async']) && $attributes['async'] == true) ? " async" : "";
783
            $defer = (isset($attributes['defer']) && $attributes['defer'] == true) ? " defer" : "";
784
            $type = Convert::raw2att(isset($attributes['type']) ? $attributes['type'] : "application/javascript");
785
            $path = Convert::raw2att($this->pathForFile($file));
0 ignored issues
show
Bug introduced by
It seems like $this->pathForFile($file) targeting SilverStripe\View\Requir..._Backend::pathForFile() can also be of type boolean; however, SilverStripe\Core\Convert::raw2att() does only seem to accept array|string, maybe add an additional type check?

This check looks at variables that are passed out again to other methods.

If the outgoing method call has stricter type requirements than the method itself, an issue is raised.

An additional type check may prevent trouble.

Loading history...
786
            if ($path) {
787
                $jsRequirements .= "<script type=\"{$type}\" src=\"{$path}\"{$async}{$defer}></script>";
788
            }
789
        }
790
791
        // Add all inline JavaScript *after* including external files they might rely on
792
        foreach ($this->getCustomScripts() as $script) {
793
            $jsRequirements .= "<script type=\"application/javascript\">//<![CDATA[\n";
794
            $jsRequirements .= "$script\n";
795
            $jsRequirements .= "//]]></script>";
796
        }
797
798
        foreach ($this->getCSS() as $file => $params) {
799
            $path = Convert::raw2att($this->pathForFile($file));
0 ignored issues
show
Bug introduced by
It seems like $this->pathForFile($file) targeting SilverStripe\View\Requir..._Backend::pathForFile() can also be of type boolean; however, SilverStripe\Core\Convert::raw2att() does only seem to accept array|string, maybe add an additional type check?

This check looks at variables that are passed out again to other methods.

If the outgoing method call has stricter type requirements than the method itself, an issue is raised.

An additional type check may prevent trouble.

Loading history...
800
            if ($path) {
801
                $media = (isset($params['media']) && !empty($params['media']))
802
                    ? " media=\"{$params['media']}\"" : "";
803
                $requirements .= "<link rel=\"stylesheet\" type=\"text/css\" {$media} href=\"$path\" />\n";
804
            }
805
        }
806
807
        foreach ($this->getCustomCSS() as $css) {
808
            $requirements .= "<style type=\"text/css\">\n$css\n</style>\n";
809
        }
810
811
        foreach ($this->getCustomHeadTags() as $customHeadTag) {
812
            $requirements .= "$customHeadTag\n";
813
        }
814
815
        // Inject CSS  into body
816
        $content = $this->insertTagsIntoHead($requirements, $content);
817
818
        // Inject scripts
819
        if ($this->getForceJSToBottom()) {
820
            $content = $this->insertScriptsAtBottom($jsRequirements, $content);
821
        } elseif ($this->getWriteJavascriptToBody()) {
822
            $content = $this->insertScriptsIntoBody($jsRequirements, $content);
823
        } else {
824
            $content = $this->insertTagsIntoHead($jsRequirements, $content);
825
        }
826
        return $content;
827
    }
828
829
    /**
830
     * Given a block of HTML, insert the given scripts at the bottom before
831
     * the closing </body> tag
832
     *
833
     * @param string $jsRequirements String containing one or more javascript <script /> tags
834
     * @param string $content HTML body
835
     * @return string Merged HTML
836
     */
837
    protected function insertScriptsAtBottom($jsRequirements, $content)
838
    {
839
        // Forcefully put the scripts at the bottom of the body instead of before the first
840
        // script tag.
841
        $content = preg_replace(
842
            '/(<\/body[^>]*>)/i',
843
            $this->escapeReplacement($jsRequirements) . '\\1',
844
            $content
845
        );
846
        return $content;
847
    }
848
849
    /**
850
     * Given a block of HTML, insert the given scripts inside the <body></body>
851
     *
852
     * @param string $jsRequirements String containing one or more javascript <script /> tags
853
     * @param string $content HTML body
854
     * @return string Merged HTML
855
     */
856
    protected function insertScriptsIntoBody($jsRequirements, $content)
857
    {
858
        // If your template already has script tags in the body, then we try to put our script
859
        // tags just before those. Otherwise, we put it at the bottom.
860
        $bodyTagPosition = stripos($content, '<body');
861
        $scriptTagPosition = stripos($content, '<script', $bodyTagPosition);
862
863
        $commentTags = array();
864
        $canWriteToBody = ($scriptTagPosition !== false)
865
            &&
866
            // Check that the script tag is not inside a html comment tag
867
            !(
868
                preg_match('/.*(?|(<!--)|(-->))/U', $content, $commentTags, 0, $scriptTagPosition)
869
                &&
870
                $commentTags[1] == '-->'
871
            );
872
873
        if ($canWriteToBody) {
874
            // Insert content before existing script tags
875
            $content = substr($content, 0, $scriptTagPosition)
876
                . $jsRequirements
877
                . substr($content, $scriptTagPosition);
878
        } else {
879
            // Insert content at bottom of page otherwise
880
            $content = $this->insertScriptsAtBottom($jsRequirements, $content);
881
        }
882
883
        return $content;
884
    }
885
886
    /**
887
     * Given a block of HTML, insert the given code inside the <head></head> block
888
     *
889
     * @param string $jsRequirements String containing one or more html tags
890
     * @param string $content HTML body
891
     * @return string Merged HTML
892
     */
893
    protected function insertTagsIntoHead($jsRequirements, $content)
894
    {
895
        $content = preg_replace(
896
            '/(<\/head>)/i',
897
            $this->escapeReplacement($jsRequirements) . '\\1',
898
            $content
899
        );
900
        return $content;
901
    }
902
903
    /**
904
     * Safely escape a literal string for use in preg_replace replacement
905
     *
906
     * @param string $replacement
907
     * @return string
908
     */
909
    protected function escapeReplacement($replacement)
910
    {
911
        return addcslashes($replacement, '\\$');
912
    }
913
914
    /**
915
     * Attach requirements inclusion to X-Include-JS and X-Include-CSS headers on the given
916
     * HTTP Response
917
     *
918
     * @param HTTPResponse $response
919
     */
920
    public function includeInResponse(HTTPResponse $response)
921
    {
922
        $this->processCombinedFiles();
923
        $jsRequirements = array();
924
        $cssRequirements = array();
925
926
        foreach ($this->getJavascript() as $file => $attributes) {
927
            $path = $this->pathForFile($file);
928
            if ($path) {
929
                $jsRequirements[] = str_replace(',', '%2C', $path);
930
            }
931
        }
932
933
        if (count($jsRequirements)) {
934
            $response->addHeader('X-Include-JS', implode(',', $jsRequirements));
935
        }
936
937
        foreach ($this->getCSS() as $file => $params) {
938
            $path = $this->pathForFile($file);
939
            if ($path) {
940
                $path = str_replace(',', '%2C', $path);
941
                $cssRequirements[] = isset($params['media']) ? "$path:##:$params[media]" : $path;
942
            }
943
        }
944
945
        if (count($cssRequirements)) {
946
            $response->addHeader('X-Include-CSS', implode(',', $cssRequirements));
947
        }
948
    }
949
950
    /**
951
     * Add i18n files from the given javascript directory. SilverStripe expects that the given
952
     * directory will contain a number of JavaScript files named by language: en_US.js, de_DE.js,
953
     * etc.
954
     *
955
     * @param string $langDir The JavaScript lang directory, relative to the site root, e.g.,
956
     *                         'framework/javascript/lang'
957
     * @param bool $return Return all relative file paths rather than including them in
958
     *                         requirements
959
     *
960
     * @return array|null All relative files if $return is true, or null otherwise
961
     */
962
    public function add_i18n_javascript($langDir, $return = false)
963
    {
964
        $files = array();
965
        $base = Director::baseFolder() . '/';
966
967
        if (substr($langDir, -1) != '/') {
968
            $langDir .= '/';
969
        }
970
971
        $candidates = array(
972
            'en.js',
973
            'en_US.js',
974
            i18n::getData()->langFromLocale(i18n::config()->default_locale) . '.js',
975
            i18n::config()->default_locale . '.js',
976
            i18n::getData()->langFromLocale(i18n::get_locale()) . '.js',
977
            i18n::get_locale() . '.js',
978
        );
979
        foreach ($candidates as $candidate) {
980
            if (file_exists($base . DIRECTORY_SEPARATOR . $langDir . $candidate)) {
981
                $files[] = $langDir . $candidate;
982
            }
983
        }
984
985
        if ($return) {
986
            return $files;
987
        } else {
988
            foreach ($files as $file) {
989
                $this->javascript($file);
990
            }
991
            return null;
992
        }
993
    }
994
995
    /**
996
     * Finds the path for specified file
997
     *
998
     * @param string $fileOrUrl
999
     * @return string|bool
1000
     */
1001
    protected function pathForFile($fileOrUrl)
1002
    {
1003
        // Since combined urls could be root relative, treat them as urls here.
1004
        if (preg_match('{^(//)|(http[s]?:)}', $fileOrUrl) || Director::is_root_relative_url($fileOrUrl)) {
1005
            return $fileOrUrl;
1006
        } elseif (Director::fileExists($fileOrUrl)) {
1007
            $filePath = preg_replace('/\?.*/', '', Director::baseFolder() . '/' . $fileOrUrl);
1008
            $prefix = Director::baseURL();
1009
            $mtimesuffix = "";
1010
            $suffix = '';
1011
            if ($this->getSuffixRequirements()) {
1012
                $mtimesuffix = "?m=" . filemtime($filePath);
1013
                $suffix = '&';
1014
            }
1015
            if (strpos($fileOrUrl, '?') !== false) {
1016
                if (strlen($suffix) == 0) {
1017
                    $suffix = '?';
1018
                }
1019
                $suffix .= substr($fileOrUrl, strpos($fileOrUrl, '?') + 1);
1020
                $fileOrUrl = substr($fileOrUrl, 0, strpos($fileOrUrl, '?'));
1021
            } else {
1022
                $suffix = '';
1023
            }
1024
            return "{$prefix}{$fileOrUrl}{$mtimesuffix}{$suffix}";
1025
        } else {
1026
            throw new InvalidArgumentException("File {$fileOrUrl} does not exist");
1027
        }
1028
    }
1029
1030
    /**
1031
     * Concatenate several css or javascript files into a single dynamically generated file. This
1032
     * increases performance by fewer HTTP requests.
1033
     *
1034
     * The combined file is regenerated based on every file modification time. Optionally a
1035
     * rebuild can be triggered by appending ?flush=1 to the URL.
1036
     *
1037
     * All combined files will have a comment on the start of each concatenated file denoting their
1038
     * original position.
1039
     *
1040
     * CAUTION: You're responsible for ensuring that the load order for combined files is
1041
     * retained - otherwise combining JavaScript files can lead to functional errors in the
1042
     * JavaScript logic, and combining CSS can lead to incorrect inheritance. You can also
1043
     * only include each file once across all includes and combinations in a single page load.
1044
     *
1045
     * CAUTION: Combining CSS Files discards any "media" information.
1046
     *
1047
     * Example for combined JavaScript:
1048
     * <code>
1049
     * Requirements::combine_files(
1050
     *    'foobar.js',
1051
     *    array(
1052
     *        'mysite/javascript/foo.js',
1053
     *        'mysite/javascript/bar.js',
1054
     *    ),
1055
     *    array(
1056
     *        'async' => true,
1057
     *        'defer' => true,
1058
     *    )
1059
     * );
1060
     * </code>
1061
     *
1062
     * Example for combined CSS:
1063
     * <code>
1064
     * Requirements::combine_files(
1065
     *    'foobar.css',
1066
     *    array(
1067
     *        'mysite/javascript/foo.css',
1068
     *        'mysite/javascript/bar.css',
1069
     *    ),
1070
     *    array(
1071
     *        'media' => 'print',
1072
     *    )
1073
     * );
1074
     * </code>
1075
     *
1076
     * @param string $combinedFileName Filename of the combined file relative to docroot
1077
     * @param array $files Array of filenames relative to docroot
1078
     * @param array $options Array of options for combining files. Available options are:
1079
     * - 'media' : If including CSS Files, you can specify a media type
1080
     * - 'async' : If including JavaScript Files, boolean value to set async attribute to script tag
1081
     * - 'defer' : If including JavaScript Files, boolean value to set defer attribute to script tag
1082
     */
1083
    public function combineFiles($combinedFileName, $files, $options = array())
1084
    {
1085
        if (is_string($options)) {
1086
            Deprecation::notice('4.0', 'Parameter media is deprecated. Use options array instead.');
1087
            $options = array('media' => $options);
1088
        }
1089
        // Skip this combined files if already included
1090
        if (isset($this->combinedFiles[$combinedFileName])) {
1091
            return;
1092
        }
1093
1094
        // Add all files to necessary type list
1095
        $paths = array();
1096
        $combinedType = null;
1097
        foreach ($files as $file) {
1098
            // Get file details
1099
            list($path, $type) = $this->parseCombinedFile($file);
1100
            if ($type === 'javascript') {
1101
                $type = 'js';
1102
            }
1103
            if ($combinedType && $type && $combinedType !== $type) {
1104
                throw new InvalidArgumentException(
1105
                    "Cannot mix js and css files in same combined file {$combinedFileName}"
1106
                );
1107
            }
1108
            switch ($type) {
1109
                case 'css':
1110
                    $this->css($path, (isset($options['media']) ? $options['media'] : null));
1111
                    break;
1112
                case 'js':
1113
                    $this->javascript($path, $options);
1114
                    break;
1115
                default:
1116
                    throw new InvalidArgumentException("Invalid combined file type: {$type}");
1117
            }
1118
            $combinedType = $type;
1119
            $paths[] = $path;
1120
        }
1121
1122
        // Duplicate check
1123
        foreach ($this->combinedFiles as $existingCombinedFilename => $combinedItem) {
1124
            $existingFiles = $combinedItem['files'];
1125
            $duplicates = array_intersect($existingFiles, $paths);
1126
            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...
1127
                throw new InvalidArgumentException(sprintf(
1128
                    "Requirements_Backend::combine_files(): Already included file(s) %s in combined file '%s'",
1129
                    implode(',', $duplicates),
1130
                    $existingCombinedFilename
1131
                ));
1132
            }
1133
        }
1134
1135
        $this->combinedFiles[$combinedFileName] = array(
1136
            'files' => $paths,
1137
            'type' => $combinedType,
1138
            'options' => $options,
1139
        );
1140
    }
1141
1142
    /**
1143
     * Return path and type of given combined file
1144
     *
1145
     * @param string|array $file Either a file path, or an array spec
1146
     * @return array array with two elements, path and type of file
1147
     */
1148
    protected function parseCombinedFile($file)
1149
    {
1150
        // Array with path and type keys
1151
        if (is_array($file) && isset($file['path']) && isset($file['type'])) {
1152
            return array($file['path'], $file['type']);
1153
        }
1154
1155
        // Extract value from indexed array
1156
        if (is_array($file)) {
1157
            $path = array_shift($file);
1158
1159
            // See if there's a type specifier
1160
            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...
1161
                $type = array_shift($file);
1162
                return array($path, $type);
1163
            }
1164
1165
            // Otherwise convent to string
1166
            $file = $path;
1167
        }
1168
1169
        $type = File::get_file_extension($file);
1170
        return array($file, $type);
1171
    }
1172
1173
    /**
1174
     * Return all combined files; keys are the combined file names, values are lists of
1175
     * associative arrays with 'files', 'type', and 'media' keys for details about this
1176
     * combined file.
1177
     *
1178
     * @return array
1179
     */
1180
    public function getCombinedFiles()
1181
    {
1182
        return array_diff_key($this->combinedFiles, $this->blocked);
1183
    }
1184
1185
    /**
1186
     * Includes all combined files, including blocked ones
1187
     *
1188
     * @return array
1189
     */
1190
    protected function getAllCombinedFiles()
1191
    {
1192
        return $this->combinedFiles;
1193
    }
1194
1195
    /**
1196
     * Clears all combined files
1197
     */
1198
    public function deleteAllCombinedFiles()
1199
    {
1200
        $combinedFolder = $this->getCombinedFilesFolder();
1201
        if ($combinedFolder) {
1202
            $this->getAssetHandler()->removeContent($combinedFolder);
1203
        }
1204
    }
1205
1206
    /**
1207
     * Clear all registered CSS and JavaScript file combinations
1208
     */
1209
    public function clearCombinedFiles()
1210
    {
1211
        $this->combinedFiles = array();
1212
    }
1213
1214
    /**
1215
     * Do the heavy lifting involved in combining the combined files.
1216
     */
1217
    public function processCombinedFiles()
1218
    {
1219
        // Check if combining is enabled
1220
        if (!$this->getCombinedFilesEnabled()) {
1221
            return;
1222
        }
1223
1224
        // Before scripts are modified, detect files that are provided by preceding ones
1225
        $providedScripts = $this->getProvidedScripts();
1226
1227
        // Process each combined files
1228
        foreach ($this->getAllCombinedFiles() as $combinedFile => $combinedItem) {
1229
            $fileList = $combinedItem['files'];
1230
            $type = $combinedItem['type'];
1231
            $options = $combinedItem['options'];
1232
1233
            // Generate this file, unless blocked
1234
            $combinedURL = null;
1235
            if (!isset($this->blocked[$combinedFile])) {
1236
                // Filter files for blocked / provided
1237
                $filteredFileList = array_diff(
1238
                    $fileList,
1239
                    $this->getBlocked(),
1240
                    $providedScripts
1241
                );
1242
                $combinedURL = $this->getCombinedFileURL($combinedFile, $filteredFileList, $type);
1243
            }
1244
1245
            // Replace all existing files, injecting the combined file at the position of the first item
1246
            // in order to preserve inclusion order.
1247
            // Note that we iterate across blocked files in order to get the correct order, and validate
1248
            // that the file is included in the correct location (regardless of which files are blocked).
1249
            $included = false;
1250
            switch ($type) {
1251
                case 'css': {
0 ignored issues
show
Coding Style introduced by
case statements should be defined using a colon.

As per the PSR-2 coding standard, case statements should not be wrapped in curly braces. There is no need for braces, since each case is terminated by the next break.

There is also the option to use a semicolon instead of a colon, this is discouraged because many programmers do not even know it works and the colon is universal between programming languages.

switch ($expr) {
    case "A": { //wrong
        doSomething();
        break;
    }
    case "B"; //wrong
        doSomething();
        break;
    case "C": //right
        doSomething();
        break;
}

To learn more about the PSR-2 coding standard, please refer to the PHP-Fig.

Loading history...
1252
                    $newCSS = array(); // Assoc array of css file => spec
1253
                    foreach ($this->getAllCSS() as $css => $spec) {
1254
                        if (!in_array($css, $fileList)) {
1255
                            $newCSS[$css] = $spec;
1256
                        } 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...
1257
                            $newCSS[$combinedURL] = array('media' => (isset($options['media']) ? $options['media'] : null));
1258
                            $included = true;
1259
                        }
1260
                        // If already included, or otherwise blocked, then don't add into CSS
1261
                    }
1262
                    $this->css = $newCSS;
1263
                    break;
1264
                }
1265
                case 'js': {
0 ignored issues
show
Coding Style introduced by
case statements should be defined using a colon.

As per the PSR-2 coding standard, case statements should not be wrapped in curly braces. There is no need for braces, since each case is terminated by the next break.

There is also the option to use a semicolon instead of a colon, this is discouraged because many programmers do not even know it works and the colon is universal between programming languages.

switch ($expr) {
    case "A": { //wrong
        doSomething();
        break;
    }
    case "B"; //wrong
        doSomething();
        break;
    case "C": //right
        doSomething();
        break;
}

To learn more about the PSR-2 coding standard, please refer to the PHP-Fig.

Loading history...
1266
                    // Assoc array of file => attributes
1267
                    $newJS = array();
1268
                    foreach ($this->getAllJavascript() as $script => $attributes) {
1269
                        if (!in_array($script, $fileList)) {
1270
                            $newJS[$script] = $attributes;
1271
                        } 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...
1272
                            $newJS[$combinedURL] = $options;
1273
                            $included = true;
1274
                        }
1275
                        // If already included, or otherwise blocked, then don't add into scripts
1276
                    }
1277
                    $this->javascript = $newJS;
1278
                    break;
1279
                }
1280
            }
1281
        }
1282
    }
1283
1284
    /**
1285
     * Given a set of files, combine them (as necessary) and return the url
1286
     *
1287
     * @param string $combinedFile Filename for this combined file
1288
     * @param array $fileList List of files to combine
1289
     * @param string $type Either 'js' or 'css'
1290
     * @return string|null URL to this resource, if there are files to combine
1291
     */
1292
    protected function getCombinedFileURL($combinedFile, $fileList, $type)
1293
    {
1294
        // Skip empty lists
1295
        if (empty($fileList)) {
1296
            return null;
1297
        }
1298
1299
        // Generate path (Filename)
1300
        $hashQuerystring = Config::inst()->get(static::class, 'combine_hash_querystring');
1301
        if (!$hashQuerystring) {
1302
            $combinedFile = $this->hashedCombinedFilename($combinedFile, $fileList);
1303
        }
1304
        $combinedFileID = File::join_paths($this->getCombinedFilesFolder(), $combinedFile);
1305
1306
        // Send file combination request to the backend, with an optional callback to perform regeneration
1307
        $minify = $this->getMinifyCombinedFiles();
1308
        if ($minify && !$this->minifier) {
1309
            throw new Exception(
1310
                sprintf(
1311
                    'Cannot minify files without a minification service defined.
1312
        			Set %s::minifyCombinedFiles to false, or inject a %s service on 
1313
        			%s.properties.minifier',
1314
                    __CLASS__,
1315
                    Requirements_Minifier::class,
1316
                    __CLASS__
1317
                )
1318
            );
1319
        }
1320
1321
        $combinedURL = $this
1322
            ->getAssetHandler()
1323
            ->getContentURL(
1324
                $combinedFileID,
1325
                function () use ($fileList, $minify, $type) {
1326
                    // Physically combine all file content
1327
                    $combinedData = '';
1328
                    $base = Director::baseFolder() . '/';
1329
                    foreach ($fileList as $file) {
1330
                        $fileContent = file_get_contents($base . $file);
1331
                        // Use configured minifier
1332
                        if ($minify) {
1333
                            $fileContent = $this->minifier->minify($fileContent, $type, $file);
1334
                        }
1335
1336
                        if ($this->writeHeaderComment) {
1337
                            // Write a header comment for each file for easier identification and debugging.
1338
                            $combinedData .= "/****** FILE: $file *****/\n";
1339
                        }
1340
                        $combinedData .= $fileContent . "\n";
1341
                    }
1342
                    return $combinedData;
1343
                }
1344
            );
1345
1346
        // If the name isn't hashed, we will need to append the querystring m= parameter instead
1347
        // Since url won't be automatically suffixed, add it in here
1348
        if ($hashQuerystring && $this->getSuffixRequirements()) {
1349
            $hash = $this->hashOfFiles($fileList);
1350
            $q = stripos($combinedURL, '?') === false ? '?' : '&';
1351
            $combinedURL .= "{$q}m={$hash}";
1352
        }
1353
1354
        return $combinedURL;
1355
    }
1356
1357
    /**
1358
     * Given a filename and list of files, generate a new filename unique to these files
1359
     *
1360
     * @param string $combinedFile
1361
     * @param array $fileList
1362
     * @return string
1363
     */
1364
    protected function hashedCombinedFilename($combinedFile, $fileList)
1365
    {
1366
        $name = pathinfo($combinedFile, PATHINFO_FILENAME);
1367
        $hash = $this->hashOfFiles($fileList);
1368
        $extension = File::get_file_extension($combinedFile);
1369
        return $name . '-' . substr($hash, 0, 7) . '.' . $extension;
1370
    }
1371
1372
    /**
1373
     * Check if combined files are enabled
1374
     *
1375
     * @return bool
1376
     */
1377
    public function getCombinedFilesEnabled()
0 ignored issues
show
Coding Style introduced by
getCombinedFilesEnabled uses the super-global variable $_REQUEST which is generally not recommended.

Instead of super-globals, we recommend to explicitly inject the dependencies of your class. This makes your code less dependent on global state and it becomes generally more testable:

// Bad
class Router
{
    public function generate($path)
    {
        return $_SERVER['HOST'].$path;
    }
}

// Better
class Router
{
    private $host;

    public function __construct($host)
    {
        $this->host = $host;
    }

    public function generate($path)
    {
        return $this->host.$path;
    }
}

class Controller
{
    public function myAction(Request $request)
    {
        // Instead of
        $page = isset($_GET['page']) ? intval($_GET['page']) : 1;

        // Better (assuming you use the Symfony2 request)
        $page = $request->query->get('page', 1);
    }
}
Loading history...
1378
    {
1379
        if (!$this->combinedFilesEnabled) {
1380
            return false;
1381
        }
1382
1383
        // Tests should be combined
1384
        if (class_exists('SilverStripe\\Dev\\SapphireTest', false) && SapphireTest::is_running_test()) {
1385
            return true;
1386
        }
1387
1388
        // Check if specified via querystring
1389
        if (isset($_REQUEST['combine'])) {
1390
            return true;
1391
        }
1392
1393
        // Non-dev sites are always combined
1394
        if (!Director::isDev()) {
1395
            return true;
1396
        }
1397
1398
        // Fallback to default
1399
        return Config::inst()->get(__CLASS__, 'combine_in_dev');
1400
    }
1401
1402
    /**
1403
     * For a given filelist, determine some discriminating value to determine if
1404
     * any of these files have changed.
1405
     *
1406
     * @param array $fileList List of files
1407
     * @return string SHA1 bashed file hash
1408
     */
1409
    protected function hashOfFiles($fileList)
1410
    {
1411
        // Get hash based on hash of each file
1412
        $base = Director::baseFolder() . '/';
1413
        $hash = '';
1414
        foreach ($fileList as $file) {
1415
            if (file_exists($base . $file)) {
1416
                $hash .= sha1_file($base . $file);
1417
            } else {
1418
                throw new InvalidArgumentException("Combined file {$file} does not exist");
1419
            }
1420
        }
1421
        return sha1($hash);
1422
    }
1423
1424
    /**
1425
     * Registers the given themeable stylesheet as required.
1426
     *
1427
     * A CSS file in the current theme path name 'themename/css/$name.css' is first searched for,
1428
     * and it that doesn't exist and the module parameter is set then a CSS file with that name in
1429
     * the module is used.
1430
     *
1431
     * @param string $name The name of the file - eg '/css/File.css' would have the name 'File'
1432
     * @param string $media Comma-separated list of media types to use in the link tag
1433
     *                       (e.g. 'screen,projector')
1434
     */
1435
    public function themedCSS($name, $media = null)
1436
    {
1437
        $path = ThemeResourceLoader::instance()->findThemedCSS($name, SSViewer::get_themes());
1438
        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...
1439
            $this->css($path, $media);
1440
        } else {
1441
            throw new \InvalidArgumentException(
1442
                "The css file doesn't exists. Please check if the file $name.css exists in any context or search for "
1443
                . "themedCSS references calling this file in your templates."
1444
            );
1445
        }
1446
    }
1447
1448
    /**
1449
     * Registers the given themeable javascript as required.
1450
     *
1451
     * A javascript file in the current theme path name 'themename/javascript/$name.js' is first searched for,
1452
     * and it that doesn't exist and the module parameter is set then a javascript file with that name in
1453
     * the module is used.
1454
     *
1455
     * @param string $name The name of the file - eg '/js/File.js' would have the name 'File'
1456
     * @param string $type Comma-separated list of types to use in the script tag
1457
     *                       (e.g. 'text/javascript,text/ecmascript')
1458
     */
1459
    public function themedJavascript($name, $type = null)
1460
    {
1461
        $path = ThemeResourceLoader::instance()->findThemedJavascript($name, SSViewer::get_themes());
1462
        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...
1463
            $opts = [];
1464
            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...
1465
                $opts['type'] = $type;
1466
            }
1467
            $this->javascript($path, $opts);
1468
        } else {
1469
            throw new \InvalidArgumentException(
1470
                "The javascript file doesn't exists. Please check if the file $name.js exists in any "
1471
                . "context or search for themedJavascript references calling this file in your templates."
1472
            );
1473
        }
1474
    }
1475
1476
    /**
1477
     * Output debugging information.
1478
     */
1479
    public function debug()
1480
    {
1481
        Debug::show($this->javascript);
1482
        Debug::show($this->css);
1483
        Debug::show($this->customCSS);
1484
        Debug::show($this->customScript);
1485
        Debug::show($this->customHeadTags);
1486
        Debug::show($this->combinedFiles);
1487
    }
1488
}
1489