Requirements_Backend::includeInHTML()   F
last analyzed

Complexity

Conditions 23
Paths > 20000

Size

Total Lines 97
Code Lines 58

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 23
eloc 58
c 0
b 0
f 0
nc 71290
nop 1
dl 0
loc 97
rs 0

How to fix   Long Method    Complexity   

Long Method

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

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

Commonly applied refactorings include:

1
<?php
2
3
namespace SilverStripe\View;
4
5
use Exception;
6
use InvalidArgumentException;
7
use SilverStripe\Assets\File;
8
use SilverStripe\Assets\Storage\GeneratedAssetHandler;
9
use SilverStripe\Control\Director;
10
use SilverStripe\Control\HTTPResponse;
11
use SilverStripe\Core\Config\Config;
12
use SilverStripe\Core\Convert;
13
use SilverStripe\Core\Injector\Injectable;
14
use SilverStripe\Core\Injector\Injector;
15
use SilverStripe\Core\Manifest\ModuleResourceLoader;
16
use SilverStripe\Core\Manifest\ResourceURLGenerator;
17
use SilverStripe\Core\Path;
18
use SilverStripe\Dev\Debug;
19
use SilverStripe\Dev\Deprecation;
20
use SilverStripe\i18n\i18n;
21
use SilverStripe\ORM\FieldType\DBField;
22
23
class Requirements_Backend
24
{
25
    use Injectable;
26
27
    /**
28
     * Whether to add caching query params to the requests for file-based requirements.
29
     * Eg: themes/myTheme/js/main.js?m=123456789. The parameter is a timestamp generated by
30
     * filemtime. This has the benefit of allowing the browser to cache the URL infinitely,
31
     * while automatically busting this cache every time the file is changed.
32
     *
33
     * @var bool
34
     */
35
    protected $suffixRequirements = true;
36
37
    /**
38
     * Whether to combine CSS and JavaScript files
39
     *
40
     * @var bool|null
41
     */
42
    protected $combinedFilesEnabled = null;
43
44
    /**
45
     * Determine if files should be combined automatically on dev mode.
46
     *
47
     * By default combined files will not be combined except in test or
48
     * live environments. Turning this on will allow for pre-combining of files in development mode.
49
     *
50
     * @config
51
     * @var bool
52
     */
53
    private static $combine_in_dev = false;
54
55
    /**
56
     * Paths to all required JavaScript files relative to docroot
57
     *
58
     * @var array
59
     */
60
    protected $javascript = array();
61
62
    /**
63
     * Map of included scripts to array of contained files.
64
     * To be used alongside front-end combination mechanisms.
65
     *
66
     * @var array Map of providing filepath => array(provided filepaths)
67
     */
68
    protected $providedJavascript = array();
69
70
    /**
71
     * Paths to all required CSS files relative to the docroot.
72
     *
73
     * @var array
74
     */
75
    protected $css = array();
76
77
    /**
78
     * All custom javascript code that is inserted into the page's HTML
79
     *
80
     * @var array
81
     */
82
    protected $customScript = array();
83
84
    /**
85
     * All custom CSS rules which are inserted directly at the bottom of the HTML `<head>` tag
86
     *
87
     * @var array
88
     */
89
    protected $customCSS = array();
90
91
    /**
92
     * All custom HTML markup which is added before the closing `<head>` tag, e.g. additional
93
     * metatags.
94
     *
95
     * @var array
96
     */
97
    protected $customHeadTags = array();
98
99
    /**
100
     * Remembers the file paths or uniquenessIDs of all Requirements cleared through
101
     * {@link clear()}, so that they can be restored later.
102
     *
103
     * @var array
104
     */
105
    protected $disabled = array();
106
107
    /**
108
     * The file paths (relative to docroot) or uniquenessIDs of any included requirements which
109
     * should be blocked when executing {@link inlcudeInHTML()}. This is useful, for example,
110
     * to block scripts included by a superclass without having to override entire functions and
111
     * duplicate a lot of code.
112
     *
113
     * Use {@link unblock()} or {@link unblock_all()} to revert changes.
114
     *
115
     * @var array
116
     */
117
    protected $blocked = array();
118
119
    /**
120
     * A list of combined files registered via {@link combine_files()}. Keys are the output file
121
     * names, values are lists of input files.
122
     *
123
     * @var array
124
     */
125
    protected $combinedFiles = array();
126
127
    /**
128
     * Use the injected minification service to minify any javascript file passed to {@link combine_files()}.
129
     *
130
     * @var bool
131
     */
132
    protected $minifyCombinedFiles = false;
133
134
    /**
135
     * Whether or not file headers should be written when combining files
136
     *
137
     * @var boolean
138
     */
139
    protected $writeHeaderComment = true;
140
141
    /**
142
     * Where to save combined files. By default they're placed in assets/_combinedfiles, however
143
     * this may be an issue depending on your setup, especially for CSS files which often contain
144
     * relative paths.
145
     *
146
     * @var string
147
     */
148
    protected $combinedFilesFolder = null;
149
150
    /**
151
     * Put all JavaScript includes at the bottom of the template before the closing `<body>` tag,
152
     * rather than the default behaviour of placing them at the end of the `<head>` tag. This means
153
     * script downloads won't block other HTTP requests, which can be a performance improvement.
154
     *
155
     * @var bool
156
     */
157
    public $writeJavascriptToBody = true;
158
159
    /**
160
     * Force the JavaScript to the bottom of the page, even if there's a script tag in the body already
161
     *
162
     * @var boolean
163
     */
164
    protected $forceJSToBottom = false;
165
166
    /**
167
     * Configures the default prefix for combined files.
168
     *
169
     * This defaults to `_combinedfiles`, and is the folder within the configured asset backend that
170
     * combined files will be stored in. If using a backend shared with other systems, it is usually
171
     * necessary to distinguish combined files from other assets.
172
     *
173
     * @config
174
     * @var string
175
     */
176
    private static $default_combined_files_folder = '_combinedfiles';
177
178
    /**
179
     * Flag to include the hash in the querystring instead of the filename for combined files.
180
     *
181
     * By default the `<hash>` of the source files is appended to the end of the combined file
182
     * (prior to the file extension). If combined files are versioned in source control or running
183
     * in a distributed environment (such as one where the newest version of a file may not always be
184
     * immediately available) then it may sometimes be necessary to disable this. When this is set to true,
185
     * the hash will instead be appended via a querystring parameter to enable cache busting, but not in
186
     * the filename itself. I.e. `assets/_combinedfiles/name.js?m=<hash>`
187
     *
188
     * @config
189
     * @var bool
190
     */
191
    private static $combine_hash_querystring = false;
192
193
    /**
194
     * @var GeneratedAssetHandler
195
     */
196
    protected $assetHandler = null;
197
198
    /**
199
     * @var Requirements_Minifier
200
     */
201
    protected $minifier = null;
202
203
    /**
204
     * Gets the backend storage for generated files
205
     *
206
     * @return GeneratedAssetHandler
207
     */
208
    public function getAssetHandler()
209
    {
210
        return $this->assetHandler;
211
    }
212
213
    /**
214
     * Set a new asset handler for this backend
215
     *
216
     * @param GeneratedAssetHandler $handler
217
     */
218
    public function setAssetHandler(GeneratedAssetHandler $handler)
219
    {
220
        $this->assetHandler = $handler;
221
    }
222
223
    /**
224
     * Gets the minification service for this backend
225
     *
226
     * @deprecated 4.0.0:5.0.0
227
     * @return Requirements_Minifier
228
     */
229
    public function getMinifier()
230
    {
231
        return $this->minifier;
232
    }
233
234
    /**
235
     * Set a new minification service for this backend
236
     *
237
     * @param Requirements_Minifier $minifier
238
     */
239
    public function setMinifier(Requirements_Minifier $minifier = null)
240
    {
241
        $this->minifier = $minifier;
242
    }
243
244
    /**
245
     * Enable or disable the combination of CSS and JavaScript files
246
     *
247
     * @param bool $enable
248
     */
249
    public function setCombinedFilesEnabled($enable)
250
    {
251
        $this->combinedFilesEnabled = (bool)$enable;
252
    }
253
254
    /**
255
     * Check if header comments are written
256
     *
257
     * @return bool
258
     */
259
    public function getWriteHeaderComment()
260
    {
261
        return $this->writeHeaderComment;
262
    }
263
264
    /**
265
     * Flag whether header comments should be written for each combined file
266
     *
267
     * @param bool $write
268
     * @return $this
269
     */
270
    public function setWriteHeaderComment($write)
271
    {
272
        $this->writeHeaderComment = $write;
273
        return $this;
274
    }
275
276
    /**
277
     * Set the folder to save combined files in. By default they're placed in _combinedfiles,
278
     * however this may be an issue depending on your setup, especially for CSS files which often
279
     * contain relative paths.
280
     *
281
     * This must not include any 'assets' prefix
282
     *
283
     * @param string $folder
284
     */
285
    public function setCombinedFilesFolder($folder)
286
    {
287
        $this->combinedFilesFolder = $folder;
288
    }
289
290
    /**
291
     * Retrieve the combined files folder prefix
292
     *
293
     * @return string
294
     */
295
    public function getCombinedFilesFolder()
296
    {
297
        if ($this->combinedFilesFolder) {
298
            return $this->combinedFilesFolder;
299
        }
300
        return Config::inst()->get(__CLASS__, 'default_combined_files_folder');
301
    }
302
303
    /**
304
     * Set whether to add caching query params to the requests for file-based requirements.
305
     * Eg: themes/myTheme/js/main.js?m=123456789. The parameter is a timestamp generated by
306
     * filemtime. This has the benefit of allowing the browser to cache the URL infinitely,
307
     * while automatically busting this cache every time the file is changed.
308
     *
309
     * @param bool
310
     */
311
    public function setSuffixRequirements($var)
312
    {
313
        $this->suffixRequirements = $var;
314
    }
315
316
    /**
317
     * Check whether we want to suffix requirements
318
     *
319
     * @return bool
320
     */
321
    public function getSuffixRequirements()
322
    {
323
        return $this->suffixRequirements;
324
    }
325
326
    /**
327
     * Set whether you want to write the JS to the body of the page rather than at the end of the
328
     * head tag.
329
     *
330
     * @param bool
331
     * @return $this
332
     */
333
    public function setWriteJavascriptToBody($var)
334
    {
335
        $this->writeJavascriptToBody = $var;
336
        return $this;
337
    }
338
339
    /**
340
     * Check whether you want to write the JS to the body of the page rather than at the end of the
341
     * head tag.
342
     *
343
     * @return bool
344
     */
345
    public function getWriteJavascriptToBody()
346
    {
347
        return $this->writeJavascriptToBody;
348
    }
349
350
    /**
351
     * Forces the JavaScript requirements to the end of the body, right before the closing tag
352
     *
353
     * @param bool
354
     * @return $this
355
     */
356
    public function setForceJSToBottom($var)
357
    {
358
        $this->forceJSToBottom = $var;
359
        return $this;
360
    }
361
362
    /**
363
     * Check if the JavaScript requirements are written to the end of the body, right before the closing tag
364
     *
365
     * @return bool
366
     */
367
    public function getForceJSToBottom()
368
    {
369
        return $this->forceJSToBottom;
370
    }
371
372
    /**
373
     * Check if minify files should be combined
374
     *
375
     * @return bool
376
     */
377
    public function getMinifyCombinedFiles()
378
    {
379
        return $this->minifyCombinedFiles;
380
    }
381
382
    /**
383
     * Set if combined files should be minified
384
     *
385
     * @param bool $minify
386
     * @return $this
387
     */
388
    public function setMinifyCombinedFiles($minify)
389
    {
390
        $this->minifyCombinedFiles = $minify;
391
        return $this;
392
    }
393
394
    /**
395
     * Register the given JavaScript file as required.
396
     *
397
     * @param string $file Either relative to docroot or in the form "vendor/package:resource"
398
     * @param array $options List of options. Available options include:
399
     * - 'provides' : List of scripts files included in this file
400
     * - 'async' : Boolean value to set async attribute to script tag
401
     * - 'defer' : Boolean value to set defer attribute to script tag
402
     * - 'type' : Override script type= value.
403
     * - 'integrity' : SubResource Integrity hash
404
     * - 'crossorigin' : Cross-origin policy for the resource
405
     */
406
    public function javascript($file, $options = array())
407
    {
408
        $file = ModuleResourceLoader::singleton()->resolvePath($file);
409
410
        // Get type
411
        $type = null;
412
        if (isset($this->javascript[$file]['type'])) {
413
            $type = $this->javascript[$file]['type'];
414
        }
415
        if (isset($options['type'])) {
416
            $type = $options['type'];
417
        }
418
419
        // make sure that async/defer is set if it is set once even if file is included multiple times
420
        $async = (
421
            isset($options['async']) && $options['async']
422
            || (
423
                isset($this->javascript[$file])
424
                && isset($this->javascript[$file]['async'])
425
                && $this->javascript[$file]['async']
426
            )
427
        );
428
        $defer = (
429
            isset($options['defer']) && $options['defer']
430
            || (
431
                isset($this->javascript[$file])
432
                && isset($this->javascript[$file]['defer'])
433
                && $this->javascript[$file]['defer']
434
            )
435
        );
436
        $integrity = $options['integrity'] ?? null;
437
        $crossorigin = $options['crossorigin'] ?? null;
438
439
        $this->javascript[$file] = array(
440
            'async' => $async,
441
            'defer' => $defer,
442
            'type' => $type,
443
            'integrity' => $integrity,
444
            'crossorigin' => $crossorigin,
445
        );
446
447
        // Record scripts included in this file
448
        if (isset($options['provides'])) {
449
            $this->providedJavascript[$file] = array_values($options['provides']);
450
        }
451
    }
452
453
    /**
454
     * Remove a javascript requirement
455
     *
456
     * @param string $file
457
     */
458
    protected function unsetJavascript($file)
459
    {
460
        unset($this->javascript[$file]);
461
    }
462
463
    /**
464
     * Gets all scripts that are already provided by prior scripts.
465
     * This follows these rules:
466
     *  - Files will not be considered provided if they are separately
467
     *    included prior to the providing file.
468
     *  - Providing files can be blocked, and don't provide anything
469
     *  - Provided files can't be blocked (you need to block the provider)
470
     *  - If a combined file includes files that are provided by prior
471
     *    scripts, then these should be excluded from the combined file.
472
     *  - If a combined file includes files that are provided by later
473
     *    scripts, then these files should be included in the combined
474
     *    file, but we can't block the later script either (possible double
475
     *    up of file).
476
     *
477
     * @return array Array of provided files (map of $path => $path)
478
     */
479
    public function getProvidedScripts()
480
    {
481
        $providedScripts = array();
482
        $includedScripts = array();
483
        foreach ($this->javascript as $script => $options) {
484
            // Ignore scripts that are explicitly blocked
485
            if (isset($this->blocked[$script])) {
486
                continue;
487
            }
488
            // At this point, the file is included.
489
            // This might also be combined at this point, potentially.
490
            $includedScripts[$script] = true;
491
492
            // Record any files this provides, EXCEPT those already included by now
493
            if (isset($this->providedJavascript[$script])) {
494
                foreach ($this->providedJavascript[$script] as $provided) {
495
                    if (!isset($includedScripts[$provided])) {
496
                        $providedScripts[$provided] = $provided;
497
                    }
498
                }
499
            }
500
        }
501
        return $providedScripts;
502
    }
503
504
    /**
505
     * Returns an array of required JavaScript, excluding blocked
506
     * and duplicates of provided files.
507
     *
508
     * @return array
509
     */
510
    public function getJavascript()
511
    {
512
        return array_diff_key(
513
            $this->javascript,
514
            $this->getBlocked(),
515
            $this->getProvidedScripts()
516
        );
517
    }
518
519
    /**
520
     * Gets all javascript, including blocked files. Unwraps the array into a non-associative list
521
     *
522
     * @return array Indexed array of javascript files
523
     */
524
    protected function getAllJavascript()
525
    {
526
        return $this->javascript;
527
    }
528
529
    /**
530
     * Register the given JavaScript code into the list of requirements
531
     *
532
     * @param string $script The script content as a string (without enclosing `<script>` tag)
533
     * @param string $uniquenessID A unique ID that ensures a piece of code is only added once
534
     */
535
    public function customScript($script, $uniquenessID = null)
536
    {
537
        if ($uniquenessID) {
538
            $this->customScript[$uniquenessID] = $script;
539
        } else {
540
            $this->customScript[] = $script;
541
        }
542
    }
543
544
    /**
545
     * Return all registered custom scripts
546
     *
547
     * @return array
548
     */
549
    public function getCustomScripts()
550
    {
551
        return array_diff_key($this->customScript, $this->blocked);
552
    }
553
554
    /**
555
     * Register the given CSS styles into the list of requirements
556
     *
557
     * @param string $script CSS selectors as a string (without enclosing `<style>` tag)
558
     * @param string $uniquenessID A unique ID that ensures a piece of code is only added once
559
     */
560
    public function customCSS($script, $uniquenessID = null)
561
    {
562
        if ($uniquenessID) {
563
            $this->customCSS[$uniquenessID] = $script;
564
        } else {
565
            $this->customCSS[] = $script;
566
        }
567
    }
568
569
    /**
570
     * Return all registered custom CSS
571
     *
572
     * @return array
573
     */
574
    public function getCustomCSS()
575
    {
576
        return array_diff_key($this->customCSS, $this->blocked);
577
    }
578
579
    /**
580
     * Add the following custom HTML code to the `<head>` section of the page
581
     *
582
     * @param string $html Custom HTML code
583
     * @param string $uniquenessID A unique ID that ensures a piece of code is only added once
584
     */
585
    public function insertHeadTags($html, $uniquenessID = null)
586
    {
587
        if ($uniquenessID) {
588
            $this->customHeadTags[$uniquenessID] = $html;
589
        } else {
590
            $this->customHeadTags[] = $html;
591
        }
592
    }
593
594
    /**
595
     * Return all custom head tags
596
     *
597
     * @return array
598
     */
599
    public function getCustomHeadTags()
600
    {
601
        return array_diff_key($this->customHeadTags, $this->blocked);
602
    }
603
604
    /**
605
     * Include the content of the given JavaScript file in the list of requirements. Dollar-sign
606
     * variables will be interpolated with values from $vars similar to a .ss template.
607
     *
608
     * @param string $file The template file to load, relative to docroot
609
     * @param string[] $vars The array of variables to interpolate.
610
     * @param string $uniquenessID A unique ID that ensures a piece of code is only added once
611
     */
612
    public function javascriptTemplate($file, $vars, $uniquenessID = null)
613
    {
614
        $file = ModuleResourceLoader::singleton()->resolvePath($file);
615
        $absolutePath = Director::getAbsFile($file);
616
        if (!file_exists($absolutePath)) {
617
            throw new InvalidArgumentException("Javascript template file {$file} does not exist");
618
        }
619
620
        $script = file_get_contents($absolutePath);
621
        $search = array();
622
        $replace = array();
623
624
        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...
625
            foreach ($vars as $k => $v) {
626
                $search[] = '$' . $k;
627
                $replace[] = str_replace("\\'", "'", Convert::raw2js($v));
628
            }
629
        }
630
631
        $script = str_replace($search, $replace, $script);
632
        $this->customScript($script, $uniquenessID);
633
    }
634
635
    /**
636
     * Register the given stylesheet into the list of requirements.
637
     *
638
     * @param string $file The CSS file to load, relative to site root
639
     * @param string $media Comma-separated list of media types to use in the link tag
640
     *                      (e.g. 'screen,projector')
641
     * @param array $options List of options. Available options include:
642
     * - 'integrity' : SubResource Integrity hash
643
     * - 'crossorigin' : Cross-origin policy for the resource
644
     */
645
    public function css($file, $media = null, $options = [])
646
    {
647
        $file = ModuleResourceLoader::singleton()->resolvePath($file);
648
649
        $integrity = $options['integrity'] ?? null;
650
        $crossorigin = $options['crossorigin'] ?? null;
651
652
        $this->css[$file] = [
653
            "media" => $media,
654
            "integrity" => $integrity,
655
            "crossorigin" => $crossorigin,
656
        ];
657
    }
658
659
    /**
660
     * Remove a css requirement
661
     *
662
     * @param string $file
663
     */
664
    protected function unsetCSS($file)
665
    {
666
        unset($this->css[$file]);
667
    }
668
669
    /**
670
     * Get the list of registered CSS file requirements, excluding blocked files
671
     *
672
     * @return array Associative array of file to spec
673
     */
674
    public function getCSS()
675
    {
676
        return array_diff_key($this->css, $this->blocked);
677
    }
678
679
    /**
680
     * Gets all CSS files requirements, including blocked
681
     *
682
     * @return array Associative array of file to spec
683
     */
684
    protected function getAllCSS()
685
    {
686
        return $this->css;
687
    }
688
689
    /**
690
     * Gets the list of all blocked files
691
     *
692
     * @return array
693
     */
694
    public function getBlocked()
695
    {
696
        return $this->blocked;
697
    }
698
699
    /**
700
     * Clear either a single or all requirements
701
     *
702
     * Caution: Clearing single rules added via customCSS and customScript only works if you
703
     * originally specified a $uniquenessID.
704
     *
705
     * @param string|int $fileOrID
706
     */
707
    public function clear($fileOrID = null)
708
    {
709
        $types = [
710
            'javascript',
711
            'css',
712
            'customScript',
713
            'customCSS',
714
            'customHeadTags',
715
            'combinedFiles',
716
        ];
717
        foreach ($types as $type) {
718
            if ($fileOrID) {
719
                if (isset($this->{$type}[$fileOrID])) {
720
                    $this->disabled[$type][$fileOrID] = $this->{$type}[$fileOrID];
721
                    unset($this->{$type}[$fileOrID]);
722
                }
723
            } else {
724
                $this->disabled[$type] = $this->{$type};
725
                $this->{$type} = [];
726
            }
727
        }
728
    }
729
730
    /**
731
     * Restore requirements cleared by call to Requirements::clear
732
     */
733
    public function restore()
734
    {
735
        $types = [
736
            'javascript',
737
            'css',
738
            'customScript',
739
            'customCSS',
740
            'customHeadTags',
741
            'combinedFiles',
742
        ];
743
        foreach ($types as $type) {
744
            $this->{$type} = $this->disabled[$type];
745
        }
746
    }
747
748
    /**
749
     * Block inclusion of a specific file
750
     *
751
     * The difference between this and {@link clear} is that the calling order does not matter;
752
     * {@link clear} must be called after the initial registration, whereas {@link block} can be
753
     * used in advance. This is useful, for example, to block scripts included by a superclass
754
     * without having to override entire functions and duplicate a lot of code.
755
     *
756
     * Note that blocking should be used sparingly because it's hard to trace where an file is
757
     * being blocked from.
758
     *
759
     * @param string|int $fileOrID Relative path from webroot, module resource reference or
760
     *                             requirement API ID
761
     */
762
    public function block($fileOrID)
763
    {
764
        if (is_string($fileOrID)) {
765
            $fileOrID = ModuleResourceLoader::singleton()->resolvePath($fileOrID);
766
        }
767
        $this->blocked[$fileOrID] = $fileOrID;
768
    }
769
770
    /**
771
     * Remove an item from the block list
772
     *
773
     * @param string|int $fileOrID
774
     */
775
    public function unblock($fileOrID)
776
    {
777
        unset($this->blocked[$fileOrID]);
778
    }
779
780
    /**
781
     * Removes all items from the block list
782
     */
783
    public function unblockAll()
784
    {
785
        $this->blocked = array();
786
    }
787
788
    /**
789
     * Update the given HTML content with the appropriate include tags for the registered
790
     * requirements. Needs to receive a valid HTML/XHTML template in the $content parameter,
791
     * including a head and body tag.
792
     *
793
     * @param string $content HTML content that has already been parsed from the $templateFile
794
     *                             through {@link SSViewer}
795
     * @return string HTML content augmented with the requirements tags
796
     */
797
    public function includeInHTML($content)
798
    {
799
        if (func_num_args() > 1) {
800
            Deprecation::notice(
801
                '5.0',
802
                '$templateFile argument is deprecated. includeInHTML takes a sole $content parameter now.'
803
            );
804
            $content = func_get_arg(1);
805
        }
806
807
        // Skip if content isn't injectable, or there is nothing to inject
808
        $tagsAvailable = preg_match('#</head\b#', $content);
809
        $hasFiles = $this->css || $this->javascript || $this->customCSS || $this->customScript || $this->customHeadTags;
0 ignored issues
show
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->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...
810
        if (!$tagsAvailable || !$hasFiles) {
811
            return $content;
812
        }
813
        $requirements = '';
814
        $jsRequirements = '';
815
816
        // Combine files - updates $this->javascript and $this->css
817
        $this->processCombinedFiles();
818
819
        // Script tags for js links
820
        foreach ($this->getJavascript() as $file => $attributes) {
821
            // Build html attributes
822
            $htmlAttributes = [
823
                'type' => isset($attributes['type']) ? $attributes['type'] : "application/javascript",
824
                'src' => $this->pathForFile($file),
825
            ];
826
            if (!empty($attributes['async'])) {
827
                $htmlAttributes['async'] = 'async';
828
            }
829
            if (!empty($attributes['defer'])) {
830
                $htmlAttributes['defer'] = 'defer';
831
            }
832
            if (!empty($attributes['integrity'])) {
833
                $htmlAttributes['integrity'] = $attributes['integrity'];
834
            }
835
            if (!empty($attributes['crossorigin'])) {
836
                $htmlAttributes['crossorigin'] = $attributes['crossorigin'];
837
            }
838
            $jsRequirements .= HTML::createTag('script', $htmlAttributes);
839
            $jsRequirements .= "\n";
840
        }
841
842
        // Add all inline JavaScript *after* including external files they might rely on
843
        foreach ($this->getCustomScripts() as $script) {
844
            $jsRequirements .= HTML::createTag(
845
                'script',
846
                [ 'type' => 'application/javascript' ],
847
                "//<![CDATA[\n{$script}\n//]]>"
848
            );
849
            $jsRequirements .= "\n";
850
        }
851
852
        // CSS file links
853
        foreach ($this->getCSS() as $file => $params) {
854
            $htmlAttributes = [
855
                'rel' => 'stylesheet',
856
                'type' => 'text/css',
857
                'href' => $this->pathForFile($file),
858
            ];
859
            if (!empty($params['media'])) {
860
                $htmlAttributes['media'] = $params['media'];
861
            }
862
            if (!empty($params['integrity'])) {
863
                $htmlAttributes['integrity'] = $params['integrity'];
864
            }
865
            if (!empty($params['crossorigin'])) {
866
                $htmlAttributes['crossorigin'] = $params['crossorigin'];
867
            }
868
            $requirements .= HTML::createTag('link', $htmlAttributes);
869
            $requirements .= "\n";
870
        }
871
872
        // Literal custom CSS content
873
        foreach ($this->getCustomCSS() as $css) {
874
            $requirements .= HTML::createTag('style', ['type' => 'text/css'], "\n{$css}\n");
875
            $requirements .= "\n";
876
        }
877
878
        foreach ($this->getCustomHeadTags() as $customHeadTag) {
879
            $requirements .= "{$customHeadTag}\n";
880
        }
881
882
        // Inject CSS  into body
883
        $content = $this->insertTagsIntoHead($requirements, $content);
884
885
        // Inject scripts
886
        if ($this->getForceJSToBottom()) {
887
            $content = $this->insertScriptsAtBottom($jsRequirements, $content);
888
        } elseif ($this->getWriteJavascriptToBody()) {
889
            $content = $this->insertScriptsIntoBody($jsRequirements, $content);
890
        } else {
891
            $content = $this->insertTagsIntoHead($jsRequirements, $content);
892
        }
893
        return $content;
894
    }
895
896
    /**
897
     * Given a block of HTML, insert the given scripts at the bottom before
898
     * the closing `</body>` tag
899
     *
900
     * @param string $jsRequirements String containing one or more javascript `<script />` tags
901
     * @param string $content HTML body
902
     * @return string Merged HTML
903
     */
904
    protected function insertScriptsAtBottom($jsRequirements, $content)
905
    {
906
        // Forcefully put the scripts at the bottom of the body instead of before the first
907
        // script tag.
908
        $content = preg_replace(
909
            '/(<\/body[^>]*>)/i',
910
            $this->escapeReplacement($jsRequirements) . '\\1',
911
            $content
912
        );
913
        return $content;
914
    }
915
916
    /**
917
     * Given a block of HTML, insert the given scripts inside the `<body></body>`
918
     *
919
     * @param string $jsRequirements String containing one or more javascript `<script />` tags
920
     * @param string $content HTML body
921
     * @return string Merged HTML
922
     */
923
    protected function insertScriptsIntoBody($jsRequirements, $content)
924
    {
925
        // If your template already has script tags in the body, then we try to put our script
926
        // tags just before those. Otherwise, we put it at the bottom.
927
        $bodyTagPosition = stripos($content, '<body');
928
        $scriptTagPosition = stripos($content, '<script', $bodyTagPosition);
929
930
        $commentTags = array();
931
        $canWriteToBody = ($scriptTagPosition !== false)
932
            &&
933
            // Check that the script tag is not inside a html comment tag
934
            !(
935
                preg_match('/.*(?|(<!--)|(-->))/U', $content, $commentTags, 0, $scriptTagPosition)
936
                &&
937
                $commentTags[1] == '-->'
938
            );
939
940
        if ($canWriteToBody) {
941
            // Insert content before existing script tags
942
            $content = substr($content, 0, $scriptTagPosition)
943
                . $jsRequirements
944
                . substr($content, $scriptTagPosition);
945
        } else {
946
            // Insert content at bottom of page otherwise
947
            $content = $this->insertScriptsAtBottom($jsRequirements, $content);
948
        }
949
950
        return $content;
951
    }
952
953
    /**
954
     * Given a block of HTML, insert the given code inside the `<head></head>` block
955
     *
956
     * @param string $jsRequirements String containing one or more html tags
957
     * @param string $content HTML body
958
     * @return string Merged HTML
959
     */
960
    protected function insertTagsIntoHead($jsRequirements, $content)
961
    {
962
        $content = preg_replace(
963
            '/(<\/head>)/i',
964
            $this->escapeReplacement($jsRequirements) . '\\1',
965
            $content
966
        );
967
        return $content;
968
    }
969
970
    /**
971
     * Safely escape a literal string for use in preg_replace replacement
972
     *
973
     * @param string $replacement
974
     * @return string
975
     */
976
    protected function escapeReplacement($replacement)
977
    {
978
        return addcslashes($replacement, '\\$');
979
    }
980
981
    /**
982
     * Attach requirements inclusion to X-Include-JS and X-Include-CSS headers on the given
983
     * HTTP Response
984
     *
985
     * @param HTTPResponse $response
986
     */
987
    public function includeInResponse(HTTPResponse $response)
988
    {
989
        $this->processCombinedFiles();
990
        $jsRequirements = array();
991
        $cssRequirements = array();
992
993
        foreach ($this->getJavascript() as $file => $attributes) {
994
            $path = $this->pathForFile($file);
995
            if ($path) {
996
                $jsRequirements[] = str_replace(',', '%2C', $path);
997
            }
998
        }
999
1000
        if (count($jsRequirements)) {
1001
            $response->addHeader('X-Include-JS', implode(',', $jsRequirements));
1002
        }
1003
1004
        foreach ($this->getCSS() as $file => $params) {
1005
            $path = $this->pathForFile($file);
1006
            if ($path) {
1007
                $path = str_replace(',', '%2C', $path);
1008
                $cssRequirements[] = isset($params['media']) ? "$path:##:$params[media]" : $path;
1009
            }
1010
        }
1011
1012
        if (count($cssRequirements)) {
1013
            $response->addHeader('X-Include-CSS', implode(',', $cssRequirements));
1014
        }
1015
    }
1016
1017
    /**
1018
     * Add i18n files from the given javascript directory. SilverStripe expects that the given
1019
     * directory will contain a number of JavaScript files named by language: en_US.js, de_DE.js,
1020
     * etc.
1021
     *
1022
     * @param string $langDir The JavaScript lang directory, relative to the site root, e.g.,
1023
     *                         'framework/javascript/lang'
1024
     * @param bool $return Return all relative file paths rather than including them in
1025
     *                         requirements
1026
     *
1027
     * @return array|null All relative files if $return is true, or null otherwise
1028
     */
1029
    public function add_i18n_javascript($langDir, $return = false)
1030
    {
1031
        $langDir = ModuleResourceLoader::singleton()->resolvePath($langDir);
1032
1033
        $files = array();
1034
        $candidates = array(
1035
            'en',
1036
            'en_US',
1037
            i18n::getData()->langFromLocale(i18n::config()->get('default_locale')),
1038
            i18n::config()->get('default_locale'),
1039
            i18n::getData()->langFromLocale(i18n::get_locale()),
1040
            i18n::get_locale(),
1041
            strtolower(DBField::create_field('Locale', i18n::get_locale())->RFC1766()),
0 ignored issues
show
Bug introduced by
The method RFC1766() does not exist on SilverStripe\ORM\FieldType\DBField. Since you implemented __call, consider adding a @method annotation. ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-call  annotation

1041
            strtolower(DBField::create_field('Locale', i18n::get_locale())->/** @scrutinizer ignore-call */ RFC1766()),
Loading history...
1042
            strtolower(DBField::create_field('Locale', i18n::config()->get('default_locale'))->RFC1766())
1043
        );
1044
1045
        $candidates = array_map(
1046
            function ($candidate) {
1047
                return $candidate . '.js';
1048
            },
1049
            $candidates
1050
        );
1051
1052
        foreach ($candidates as $candidate) {
1053
            $relativePath = Path::join($langDir, $candidate);
1054
            $absolutePath = Director::getAbsFile($relativePath);
1055
            if (file_exists($absolutePath)) {
1056
                $files[] = $relativePath;
1057
            }
1058
        }
1059
1060
        if ($return) {
1061
            return $files;
1062
        }
1063
1064
        foreach ($files as $file) {
1065
            $this->javascript($file);
1066
        }
1067
        return null;
1068
    }
1069
1070
    /**
1071
     * Finds the path for specified file
1072
     *
1073
     * @param string $fileOrUrl
1074
     * @return string|bool
1075
     */
1076
    protected function pathForFile($fileOrUrl)
1077
    {
1078
        // Since combined urls could be root relative, treat them as urls here.
1079
        if (preg_match('{^(//)|(http[s]?:)}', $fileOrUrl) || Director::is_root_relative_url($fileOrUrl)) {
1080
            return $fileOrUrl;
1081
        } else {
1082
            return Injector::inst()->get(ResourceURLGenerator::class)->urlForResource($fileOrUrl);
1083
        }
1084
    }
1085
1086
    /**
1087
     * Concatenate several css or javascript files into a single dynamically generated file. This
1088
     * increases performance by fewer HTTP requests.
1089
     *
1090
     * The combined file is regenerated based on every file modification time. Optionally a
1091
     * rebuild can be triggered by appending ?flush=1 to the URL.
1092
     *
1093
     * All combined files will have a comment on the start of each concatenated file denoting their
1094
     * original position.
1095
     *
1096
     * CAUTION: You're responsible for ensuring that the load order for combined files is
1097
     * retained - otherwise combining JavaScript files can lead to functional errors in the
1098
     * JavaScript logic, and combining CSS can lead to incorrect inheritance. You can also
1099
     * only include each file once across all includes and combinations in a single page load.
1100
     *
1101
     * CAUTION: Combining CSS Files discards any "media" information.
1102
     *
1103
     * Example for combined JavaScript:
1104
     * <code>
1105
     * Requirements::combine_files(
1106
     *    'foobar.js',
1107
     *    array(
1108
     *        'mysite/javascript/foo.js',
1109
     *        'mysite/javascript/bar.js',
1110
     *    ),
1111
     *    array(
1112
     *        'async' => true,
1113
     *        'defer' => true,
1114
     *    )
1115
     * );
1116
     * </code>
1117
     *
1118
     * Example for combined CSS:
1119
     * <code>
1120
     * Requirements::combine_files(
1121
     *    'foobar.css',
1122
     *    array(
1123
     *        'mysite/javascript/foo.css',
1124
     *        'mysite/javascript/bar.css',
1125
     *    ),
1126
     *    array(
1127
     *        'media' => 'print',
1128
     *    )
1129
     * );
1130
     * </code>
1131
     *
1132
     * @param string $combinedFileName Filename of the combined file relative to docroot
1133
     * @param array $files Array of filenames relative to docroot
1134
     * @param array $options Array of options for combining files. Available options are:
1135
     * - 'media' : If including CSS Files, you can specify a media type
1136
     * - 'async' : If including JavaScript Files, boolean value to set async attribute to script tag
1137
     * - 'defer' : If including JavaScript Files, boolean value to set defer attribute to script tag
1138
     */
1139
    public function combineFiles($combinedFileName, $files, $options = array())
1140
    {
1141
        if (is_string($options)) {
0 ignored issues
show
introduced by
The condition is_string($options) is always false.
Loading history...
1142
            Deprecation::notice('4.0', 'Parameter media is deprecated. Use options array instead.');
1143
            $options = array('media' => $options);
1144
        }
1145
        // Skip this combined files if already included
1146
        if (isset($this->combinedFiles[$combinedFileName])) {
1147
            return;
1148
        }
1149
1150
        // Add all files to necessary type list
1151
        $paths = array();
1152
        $combinedType = null;
1153
        foreach ($files as $file) {
1154
            // Get file details
1155
            list($path, $type) = $this->parseCombinedFile($file);
1156
            if ($type === 'javascript') {
1157
                $type = 'js';
1158
            }
1159
            if ($combinedType && $type && $combinedType !== $type) {
1160
                throw new InvalidArgumentException(
1161
                    "Cannot mix js and css files in same combined file {$combinedFileName}"
1162
                );
1163
            }
1164
            switch ($type) {
1165
                case 'css':
1166
                    $this->css($path, (isset($options['media']) ? $options['media'] : null), $options);
1167
                    break;
1168
                case 'js':
1169
                    $this->javascript($path, $options);
1170
                    break;
1171
                default:
1172
                    throw new InvalidArgumentException("Invalid combined file type: {$type}");
1173
            }
1174
            $combinedType = $type;
1175
            $paths[] = $path;
1176
        }
1177
1178
        // Duplicate check
1179
        foreach ($this->combinedFiles as $existingCombinedFilename => $combinedItem) {
1180
            $existingFiles = $combinedItem['files'];
1181
            $duplicates = array_intersect($existingFiles, $paths);
1182
            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...
1183
                throw new InvalidArgumentException(sprintf(
1184
                    "Requirements_Backend::combine_files(): Already included file(s) %s in combined file '%s'",
1185
                    implode(',', $duplicates),
1186
                    $existingCombinedFilename
1187
                ));
1188
            }
1189
        }
1190
1191
        $this->combinedFiles[$combinedFileName] = array(
1192
            'files' => $paths,
1193
            'type' => $combinedType,
1194
            'options' => $options,
1195
        );
1196
    }
1197
1198
    /**
1199
     * Return path and type of given combined file
1200
     *
1201
     * @param string|array $file Either a file path, or an array spec
1202
     * @return array array with two elements, path and type of file
1203
     */
1204
    protected function parseCombinedFile($file)
1205
    {
1206
        // Array with path and type keys
1207
        if (is_array($file) && isset($file['path']) && isset($file['type'])) {
1208
            return array($file['path'], $file['type']);
1209
        }
1210
1211
        // Extract value from indexed array
1212
        if (is_array($file)) {
1213
            $path = array_shift($file);
1214
1215
            // See if there's a type specifier
1216
            if ($file) {
1217
                $type = array_shift($file);
1218
                return array($path, $type);
1219
            }
1220
1221
            // Otherwise convent to string
1222
            $file = $path;
1223
        }
1224
1225
        $type = File::get_file_extension($file);
1226
        return array($file, $type);
1227
    }
1228
1229
    /**
1230
     * Return all combined files; keys are the combined file names, values are lists of
1231
     * associative arrays with 'files', 'type', and 'media' keys for details about this
1232
     * combined file.
1233
     *
1234
     * @return array
1235
     */
1236
    public function getCombinedFiles()
1237
    {
1238
        return array_diff_key($this->combinedFiles, $this->blocked);
1239
    }
1240
1241
    /**
1242
     * Includes all combined files, including blocked ones
1243
     *
1244
     * @return array
1245
     */
1246
    protected function getAllCombinedFiles()
1247
    {
1248
        return $this->combinedFiles;
1249
    }
1250
1251
    /**
1252
     * Clears all combined files
1253
     */
1254
    public function deleteAllCombinedFiles()
1255
    {
1256
        $combinedFolder = $this->getCombinedFilesFolder();
1257
        if ($combinedFolder) {
1258
            $this->getAssetHandler()->removeContent($combinedFolder);
1259
        }
1260
    }
1261
1262
    /**
1263
     * Clear all registered CSS and JavaScript file combinations
1264
     */
1265
    public function clearCombinedFiles()
1266
    {
1267
        $this->combinedFiles = array();
1268
    }
1269
1270
    /**
1271
     * Do the heavy lifting involved in combining the combined files.
1272
     */
1273
    public function processCombinedFiles()
1274
    {
1275
        // Check if combining is enabled
1276
        if (!$this->getCombinedFilesEnabled()) {
1277
            return;
1278
        }
1279
1280
        // Before scripts are modified, detect files that are provided by preceding ones
1281
        $providedScripts = $this->getProvidedScripts();
1282
1283
        // Process each combined files
1284
        foreach ($this->getAllCombinedFiles() as $combinedFile => $combinedItem) {
1285
            $fileList = $combinedItem['files'];
1286
            $type = $combinedItem['type'];
1287
            $options = $combinedItem['options'];
1288
1289
            // Generate this file, unless blocked
1290
            $combinedURL = null;
1291
            if (!isset($this->blocked[$combinedFile])) {
1292
                // Filter files for blocked / provided
1293
                $filteredFileList = array_diff(
1294
                    $fileList,
1295
                    $this->getBlocked(),
1296
                    $providedScripts
1297
                );
1298
                $combinedURL = $this->getCombinedFileURL($combinedFile, $filteredFileList, $type);
1299
            }
1300
1301
            // Replace all existing files, injecting the combined file at the position of the first item
1302
            // in order to preserve inclusion order.
1303
            // Note that we iterate across blocked files in order to get the correct order, and validate
1304
            // that the file is included in the correct location (regardless of which files are blocked).
1305
            $included = false;
1306
            switch ($type) {
1307
                case 'css':
1308
                    $newCSS = []; // Assoc array of css file => spec
1309
                    foreach ($this->getAllCSS() as $css => $spec) {
1310
                        if (!in_array($css, $fileList)) {
1311
                            $newCSS[$css] = $spec;
1312
                        } elseif (!$included && $combinedURL) {
1313
                            $newCSS[$combinedURL] = [
1314
                                'media' => $options['media'] ?? null,
1315
                                'integrity' => $options['integrity'] ?? null,
1316
                                'crossorigin' => $options['crossorigin'] ?? null,
1317
                            ];
1318
                            $included = true;
1319
                        }
1320
                        // If already included, or otherwise blocked, then don't add into CSS
1321
                    }
1322
                    $this->css = $newCSS;
1323
                    break;
1324
                case 'js':
1325
                    // Assoc array of file => attributes
1326
                    $newJS = array();
1327
                    foreach ($this->getAllJavascript() as $script => $attributes) {
1328
                        if (!in_array($script, $fileList)) {
1329
                            $newJS[$script] = $attributes;
1330
                        } elseif (!$included && $combinedURL) {
1331
                            $newJS[$combinedURL] = $options;
1332
                            $included = true;
1333
                        }
1334
                        // If already included, or otherwise blocked, then don't add into scripts
1335
                    }
1336
                    $this->javascript = $newJS;
1337
                    break;
1338
            }
1339
        }
1340
    }
1341
1342
    /**
1343
     * Given a set of files, combine them (as necessary) and return the url
1344
     *
1345
     * @param string $combinedFile Filename for this combined file
1346
     * @param array $fileList List of files to combine
1347
     * @param string $type Either 'js' or 'css'
1348
     * @return string|null URL to this resource, if there are files to combine
1349
     * @throws Exception
1350
     */
1351
    protected function getCombinedFileURL($combinedFile, $fileList, $type)
1352
    {
1353
        // Skip empty lists
1354
        if (empty($fileList)) {
1355
            return null;
1356
        }
1357
1358
        // Generate path (Filename)
1359
        $hashQuerystring = Config::inst()->get(static::class, 'combine_hash_querystring');
1360
        if (!$hashQuerystring) {
1361
            $combinedFile = $this->hashedCombinedFilename($combinedFile, $fileList);
1362
        }
1363
        $combinedFileID = File::join_paths($this->getCombinedFilesFolder(), $combinedFile);
1364
1365
        // Send file combination request to the backend, with an optional callback to perform regeneration
1366
        $minify = $this->getMinifyCombinedFiles();
1367
        if ($minify && !$this->minifier) {
1368
            throw new Exception(
1369
                sprintf(
1370
                    <<<MESSAGE
1371
Cannot minify files without a minification service defined.
1372
Set %s::minifyCombinedFiles to false, or inject a %s service on
1373
%s.properties.minifier
1374
MESSAGE
1375
                    ,
1376
                    __CLASS__,
1377
                    Requirements_Minifier::class,
1378
                    __CLASS__
1379
                )
1380
            );
1381
        }
1382
1383
        $combinedURL = $this
1384
            ->getAssetHandler()
1385
            ->getContentURL(
1386
                $combinedFileID,
1387
                function () use ($fileList, $minify, $type) {
1388
                    // Physically combine all file content
1389
                    $combinedData = '';
1390
                    foreach ($fileList as $file) {
1391
                        $filePath = Director::getAbsFile($file);
1392
                        if (!file_exists($filePath)) {
1393
                            throw new InvalidArgumentException("Combined file {$file} does not exist");
1394
                        }
1395
                        $fileContent = file_get_contents($filePath);
1396
                        // Use configured minifier
1397
                        if ($minify) {
1398
                            $fileContent = $this->minifier->minify($fileContent, $type, $file);
1399
                        }
1400
1401
                        if ($this->writeHeaderComment) {
1402
                            // Write a header comment for each file for easier identification and debugging.
1403
                            $combinedData .= "/****** FILE: $file *****/\n";
1404
                        }
1405
                        $combinedData .= $fileContent . "\n";
1406
                    }
1407
                    return $combinedData;
1408
                }
1409
            );
1410
1411
        // If the name isn't hashed, we will need to append the querystring m= parameter instead
1412
        // Since url won't be automatically suffixed, add it in here
1413
        if ($hashQuerystring && $this->getSuffixRequirements()) {
1414
            $hash = $this->hashOfFiles($fileList);
1415
            $q = stripos($combinedURL, '?') === false ? '?' : '&';
1416
            $combinedURL .= "{$q}m={$hash}";
1417
        }
1418
1419
        return $combinedURL;
1420
    }
1421
1422
    /**
1423
     * Given a filename and list of files, generate a new filename unique to these files
1424
     *
1425
     * @param string $combinedFile
1426
     * @param array $fileList
1427
     * @return string
1428
     */
1429
    protected function hashedCombinedFilename($combinedFile, $fileList)
1430
    {
1431
        $name = pathinfo($combinedFile, PATHINFO_FILENAME);
1432
        $hash = $this->hashOfFiles($fileList);
1433
        $extension = File::get_file_extension($combinedFile);
1434
        return $name . '-' . substr($hash, 0, 7) . '.' . $extension;
1435
    }
1436
1437
    /**
1438
     * Check if combined files are enabled
1439
     *
1440
     * @return bool
1441
     */
1442
    public function getCombinedFilesEnabled()
1443
    {
1444
        if (isset($this->combinedFilesEnabled)) {
1445
            return $this->combinedFilesEnabled;
1446
        }
1447
1448
        // Non-dev sites are always combined
1449
        if (!Director::isDev()) {
1450
            return true;
1451
        }
1452
1453
        // Fallback to default
1454
        return Config::inst()->get(__CLASS__, 'combine_in_dev');
1455
    }
1456
1457
    /**
1458
     * For a given filelist, determine some discriminating value to determine if
1459
     * any of these files have changed.
1460
     *
1461
     * @param array $fileList List of files
1462
     * @return string SHA1 bashed file hash
1463
     */
1464
    protected function hashOfFiles($fileList)
1465
    {
1466
        // Get hash based on hash of each file
1467
        $hash = '';
1468
        foreach ($fileList as $file) {
1469
            $absolutePath = Director::getAbsFile($file);
1470
            if (file_exists($absolutePath)) {
1471
                $hash .= sha1_file($absolutePath);
1472
            } else {
1473
                throw new InvalidArgumentException("Combined file {$file} does not exist");
1474
            }
1475
        }
1476
        return sha1($hash);
1477
    }
1478
1479
    /**
1480
     * Registers the given themeable stylesheet as required.
1481
     *
1482
     * A CSS file in the current theme path name 'themename/css/$name.css' is first searched for,
1483
     * and it that doesn't exist and the module parameter is set then a CSS file with that name in
1484
     * the module is used.
1485
     *
1486
     * @param string $name The name of the file - eg '/css/File.css' would have the name 'File'
1487
     * @param string $media Comma-separated list of media types to use in the link tag
1488
     *                       (e.g. 'screen,projector')
1489
     */
1490
    public function themedCSS($name, $media = null)
1491
    {
1492
        $path = ThemeResourceLoader::inst()->findThemedCSS($name, SSViewer::get_themes());
1493
        if ($path) {
1494
            $this->css($path, $media);
1495
        } else {
1496
            throw new InvalidArgumentException(
1497
                "The css file doesn't exist. Please check if the file $name.css exists in any context or search for "
1498
                . "themedCSS references calling this file in your templates."
1499
            );
1500
        }
1501
    }
1502
1503
    /**
1504
     * Registers the given themeable javascript as required.
1505
     *
1506
     * A javascript file in the current theme path name 'themename/javascript/$name.js' is first searched for,
1507
     * and it that doesn't exist and the module parameter is set then a javascript file with that name in
1508
     * the module is used.
1509
     *
1510
     * @param string $name The name of the file - eg '/js/File.js' would have the name 'File'
1511
     * @param string $type Comma-separated list of types to use in the script tag
1512
     *                       (e.g. 'text/javascript,text/ecmascript')
1513
     */
1514
    public function themedJavascript($name, $type = null)
1515
    {
1516
        $path = ThemeResourceLoader::inst()->findThemedJavascript($name, SSViewer::get_themes());
1517
        if ($path) {
1518
            $opts = [];
1519
            if ($type) {
1520
                $opts['type'] = $type;
1521
            }
1522
            $this->javascript($path, $opts);
1523
        } else {
1524
            throw new InvalidArgumentException(
1525
                "The javascript file doesn't exist. Please check if the file $name.js exists in any "
1526
                . "context or search for themedJavascript references calling this file in your templates."
1527
            );
1528
        }
1529
    }
1530
1531
    /**
1532
     * Output debugging information.
1533
     */
1534
    public function debug()
1535
    {
1536
        Debug::show($this->javascript);
1537
        Debug::show($this->css);
1538
        Debug::show($this->customCSS);
1539
        Debug::show($this->customScript);
1540
        Debug::show($this->customHeadTags);
1541
        Debug::show($this->combinedFiles);
1542
    }
1543
}
1544