Completed
Push — master ( c17796...052b15 )
by Damian
01:29
created

Requirements_Backend::themedJavascript()   A

Complexity

Conditions 3
Paths 3

Size

Total Lines 13
Code Lines 10

Duplication

Lines 0
Ratio 0 %

Importance

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