Completed
Push — resource-url-generator ( 57b696...53dbe7 )
by Sam
11:41 queued 02:32
created

Requirements_Backend::pathForFile()   A

Complexity

Conditions 3
Paths 2

Size

Total Lines 9
Code Lines 5

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 3
eloc 5
nc 2
nop 1
dl 0
loc 9
rs 9.6666
c 0
b 0
f 0
1
<?php
2
3
namespace SilverStripe\View;
4
5
use Exception;
6
use InvalidArgumentException;
7
use SilverStripe\Assets\File;
8
use SilverStripe\Assets\Storage\GeneratedAssetHandler;
9
use SilverStripe\Control\Director;
10
use SilverStripe\Control\HTTPResponse;
11
use SilverStripe\Core\Config\Config;
12
use SilverStripe\Core\Convert;
13
use SilverStripe\Core\Injector\Injectable;
14
use SilverStripe\Core\Injector\Injector;
15
use SilverStripe\Core\Manifest\ResourceURLGenerator;
16
use SilverStripe\Core\Manifest\ModuleLoader;
17
use SilverStripe\Dev\Debug;
18
use SilverStripe\Dev\Deprecation;
19
use SilverStripe\i18n\i18n;
20
21
class Requirements_Backend
22
{
23
    use Injectable;
24
25
    /**
26
     * Whether to add caching query params to the requests for file-based requirements.
27
     * Eg: themes/myTheme/js/main.js?m=123456789. The parameter is a timestamp generated by
28
     * filemtime. This has the benefit of allowing the browser to cache the URL infinitely,
29
     * while automatically busting this cache every time the file is changed.
30
     *
31
     * @var bool
32
     */
33
    protected $suffixRequirements = true;
34
35
    /**
36
     * Whether to combine CSS and JavaScript files
37
     *
38
     * @var bool|null
39
     */
40
    protected $combinedFilesEnabled = null;
41
42
    /**
43
     * Determine if files should be combined automatically on dev mode.
44
     *
45
     * By default combined files will not be combined except in test or
46
     * live environments. Turning this on will allow for pre-combining of files in development mode.
47
     *
48
     * @config
49
     * @var bool
50
     */
51
    private static $combine_in_dev = false;
52
53
    /**
54
     * Paths to all required JavaScript files relative to docroot
55
     *
56
     * @var array
57
     */
58
    protected $javascript = array();
59
60
    /**
61
     * Map of included scripts to array of contained files.
62
     * To be used alongside front-end combination mechanisms.
63
     *
64
     * @var array Map of providing filepath => array(provided filepaths)
65
     */
66
    protected $providedJavascript = array();
67
68
    /**
69
     * Paths to all required CSS files relative to the docroot.
70
     *
71
     * @var array
72
     */
73
    protected $css = array();
74
75
    /**
76
     * All custom javascript code that is inserted into the page's HTML
77
     *
78
     * @var array
79
     */
80
    protected $customScript = array();
81
82
    /**
83
     * All custom CSS rules which are inserted directly at the bottom of the HTML <head> tag
84
     *
85
     * @var array
86
     */
87
    protected $customCSS = array();
88
89
    /**
90
     * All custom HTML markup which is added before the closing <head> tag, e.g. additional
91
     * metatags.
92
     *
93
     * @var array
94
     */
95
    protected $customHeadTags = array();
96
97
    /**
98
     * Remembers the file paths or uniquenessIDs of all Requirements cleared through
99
     * {@link clear()}, so that they can be restored later.
100
     *
101
     * @var array
102
     */
103
    protected $disabled = array();
104
105
    /**
106
     * The file paths (relative to docroot) or uniquenessIDs of any included requirements which
107
     * should be blocked when executing {@link inlcudeInHTML()}. This is useful, for example,
108
     * to block scripts included by a superclass without having to override entire functions and
109
     * duplicate a lot of code.
110
     *
111
     * Use {@link unblock()} or {@link unblock_all()} to revert changes.
112
     *
113
     * @var array
114
     */
115
    protected $blocked = array();
116
117
    /**
118
     * A list of combined files registered via {@link combine_files()}. Keys are the output file
119
     * names, values are lists of input files.
120
     *
121
     * @var array
122
     */
123
    protected $combinedFiles = array();
124
125
    /**
126
     * Use the injected minification service to minify any javascript file passed to {@link combine_files()}.
127
     *
128
     * @var bool
129
     */
130
    protected $minifyCombinedFiles = false;
131
132
    /**
133
     * Whether or not file headers should be written when combining files
134
     *
135
     * @var boolean
136
     */
137
    protected $writeHeaderComment = true;
138
139
    /**
140
     * Where to save combined files. By default they're placed in assets/_combinedfiles, however
141
     * this may be an issue depending on your setup, especially for CSS files which often contain
142
     * relative paths.
143
     *
144
     * @var string
145
     */
146
    protected $combinedFilesFolder = null;
147
148
    /**
149
     * Put all JavaScript includes at the bottom of the template before the closing <body> tag,
150
     * rather than the default behaviour of placing them at the end of the <head> tag. This means
151
     * script downloads won't block other HTTP requests, which can be a performance improvement.
152
     *
153
     * @var bool
154
     */
155
    public $writeJavascriptToBody = true;
156
157
    /**
158
     * Force the JavaScript to the bottom of the page, even if there's a script tag in the body already
159
     *
160
     * @var boolean
161
     */
162
    protected $forceJSToBottom = false;
163
164
    /**
165
     * Configures the default prefix for combined files.
166
     *
167
     * This defaults to `_combinedfiles`, and is the folder within the configured asset backend that
168
     * combined files will be stored in. If using a backend shared with other systems, it is usually
169
     * necessary to distinguish combined files from other assets.
170
     *
171
     * @config
172
     * @var string
173
     */
174
    private static $default_combined_files_folder = '_combinedfiles';
175
176
    /**
177
     * Flag to include the hash in the querystring instead of the filename for combined files.
178
     *
179
     * By default the `<hash>` of the source files is appended to the end of the combined file
180
     * (prior to the file extension). If combined files are versioned in source control or running
181
     * in a distributed environment (such as one where the newest version of a file may not always be
182
     * immediately available) then it may sometimes be necessary to disable this. When this is set to true,
183
     * the hash will instead be appended via a querystring parameter to enable cache busting, but not in
184
     * the filename itself. I.e. `assets/_combinedfiles/name.js?m=<hash>`
185
     *
186
     * @config
187
     * @var bool
188
     */
189
    private static $combine_hash_querystring = false;
190
191
    /**
192
     * @var GeneratedAssetHandler
193
     */
194
    protected $assetHandler = null;
195
196
    /**
197
     * @var Requirements_Minifier
198
     */
199
    protected $minifier = null;
200
201
    /**
202
     * Gets the backend storage for generated files
203
     *
204
     * @return GeneratedAssetHandler
205
     */
206
    public function getAssetHandler()
207
    {
208
        return $this->assetHandler;
209
    }
210
211
    /**
212
     * Set a new asset handler for this backend
213
     *
214
     * @param GeneratedAssetHandler $handler
215
     */
216
    public function setAssetHandler(GeneratedAssetHandler $handler)
217
    {
218
        $this->assetHandler = $handler;
219
    }
220
221
    /**
222
     * Gets the minification service for this backend
223
     *
224
     * @deprecated 4.0..5.0
225
     * @return Requirements_Minifier
226
     */
227
    public function getMinifier()
228
    {
229
        return $this->minifier;
230
    }
231
232
    /**
233
     * Set a new minification service for this backend
234
     *
235
     * @param Requirements_Minifier $minifier
236
     */
237
    public function setMinifier(Requirements_Minifier $minifier = null)
238
    {
239
        $this->minifier = $minifier;
240
    }
241
242
    /**
243
     * Enable or disable the combination of CSS and JavaScript files
244
     *
245
     * @param bool $enable
246
     */
247
    public function setCombinedFilesEnabled($enable)
248
    {
249
        $this->combinedFilesEnabled = (bool)$enable;
250
    }
251
252
    /**
253
     * Check if header comments are written
254
     *
255
     * @return bool
256
     */
257
    public function getWriteHeaderComment()
258
    {
259
        return $this->writeHeaderComment;
260
    }
261
262
    /**
263
     * Flag whether header comments should be written for each combined file
264
     *
265
     * @param bool $write
266
     * @return $this
267
     */
268
    public function setWriteHeaderComment($write)
269
    {
270
        $this->writeHeaderComment = $write;
271
        return $this;
272
    }
273
274
    /**
275
     * Set the folder to save combined files in. By default they're placed in _combinedfiles,
276
     * however this may be an issue depending on your setup, especially for CSS files which often
277
     * contain relative paths.
278
     *
279
     * This must not include any 'assets' prefix
280
     *
281
     * @param string $folder
282
     */
283
    public function setCombinedFilesFolder($folder)
284
    {
285
        $this->combinedFilesFolder = $folder;
286
    }
287
288
    /**
289
     * Retrieve the combined files folder prefix
290
     *
291
     * @return string
292
     */
293
    public function getCombinedFilesFolder()
294
    {
295
        if ($this->combinedFilesFolder) {
296
            return $this->combinedFilesFolder;
297
        }
298
        return Config::inst()->get(__CLASS__, 'default_combined_files_folder');
299
    }
300
301
    /**
302
     * Set whether 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 = $this->parseModuleResourceReference($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 string|null is loosely compared to true; this is ambiguous if the string can be empty. You might want to explicitly use !== null instead.

In PHP, under loose comparison (like ==, or !=, or switch conditions), values of different types might be equal.

For string values, the empty string '' is a special case, in particular the following results might be unexpected:

''   == false // true
''   == null  // true
'ab' == false // false
'ab' == null  // false

// It is often better to use strict comparison
'' === false // false
'' === null  // false
Loading history...
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 string|null is loosely compared to true; this is ambiguous if the string can be empty. You might want to explicitly use !== null instead.

In PHP, under loose comparison (like ==, or !=, or switch conditions), values of different types might be equal.

For string values, the empty string '' is a special case, in particular the following results might be unexpected:

''   == false // true
''   == null  // true
'ab' == false // false
'ab' == null  // false

// It is often better to use strict comparison
'' === false // false
'' === null  // false
Loading history...
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 string|null is loosely compared to true; this is ambiguous if the string can be empty. You might want to explicitly use !== null instead.

In PHP, under loose comparison (like ==, or !=, or switch conditions), values of different types might be equal.

For string values, the empty string '' is a special case, in particular the following results might be unexpected:

''   == false // true
''   == null  // true
'ab' == false // false
'ab' == null  // false

// It is often better to use strict comparison
'' === false // false
'' === null  // false
Loading history...
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
        $script = file_get_contents(Director::getAbsFile($file));
606
        $search = array();
607
        $replace = array();
608
609
        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...
610
            foreach ($vars as $k => $v) {
611
                $search[] = '$' . $k;
612
                $replace[] = str_replace("\\'", "'", Convert::raw2js($v));
613
            }
614
        }
615
616
        $script = str_replace($search, $replace, $script);
617
        $this->customScript($script, $uniquenessID);
618
    }
619
620
    /**
621
     * Register the given stylesheet into the list of requirements.
622
     *
623
     * @param string $file The CSS file to load, relative to site root
624
     * @param string $media Comma-separated list of media types to use in the link tag
625
     *                      (e.g. 'screen,projector')
626
     */
627
    public function css($file, $media = null)
628
    {
629
        $file = $this->parseModuleResourceReference($file);
630
631
        $this->css[$file] = array(
632
            "media" => $media
633
        );
634
    }
635
636
    /**
637
     * Convert a file of the form "vendor/package:resource" into a BASE_PATH-relative file
638
     * For other files, reutrn original value
639
     *
640
     * @param string $file
641
     * @return string
642
     */
643
    protected function parseModuleResourceReference($file) {
644
        // String of the form vendor/package:resource. Excludes "http://bla" as that's an absolute URL
645
        if (preg_match('#([^ ]*/[^ ]*) *: *([^ ]*)#', $file, $matches)) {
646
            list(, $module, $resource) = $matches;
647
            $moduleObj = ModuleLoader::getModule($module);
648
            if (!$moduleObj) {
649
                throw new \InvalidArgumentException("Can't find module '$module'");
650
            }
651
            return $moduleObj->getRelativeResourcePath($resource);
652
        }
653
        return $file;
654
    }
655
656
    /**
657
     * Remove a css requirement
658
     *
659
     * @param string $file
660
     */
661
    protected function unsetCSS($file)
662
    {
663
        unset($this->css[$file]);
664
    }
665
666
    /**
667
     * Get the list of registered CSS file requirements, excluding blocked files
668
     *
669
     * @return array Associative array of file to spec
670
     */
671
    public function getCSS()
672
    {
673
        return array_diff_key($this->css, $this->blocked);
674
    }
675
676
    /**
677
     * Gets all CSS files requirements, including blocked
678
     *
679
     * @return array Associative array of file to spec
680
     */
681
    protected function getAllCSS()
682
    {
683
        return $this->css;
684
    }
685
686
    /**
687
     * Gets the list of all blocked files
688
     *
689
     * @return array
690
     */
691
    public function getBlocked()
692
    {
693
        return $this->blocked;
694
    }
695
696
    /**
697
     * Clear either a single or all requirements
698
     *
699
     * Caution: Clearing single rules added via customCSS and customScript only works if you
700
     * originally specified a $uniquenessID.
701
     *
702
     * @param string|int $fileOrID
703
     */
704
    public function clear($fileOrID = null)
705
    {
706
        if ($fileOrID) {
707
            foreach (array('javascript', 'css', 'customScript', 'customCSS', 'customHeadTags') as $type) {
708
                if (isset($this->{$type}[$fileOrID])) {
709
                    $this->disabled[$type][$fileOrID] = $this->{$type}[$fileOrID];
710
                    unset($this->{$type}[$fileOrID]);
711
                }
712
            }
713
        } else {
714
            $this->disabled['javascript'] = $this->javascript;
715
            $this->disabled['css'] = $this->css;
716
            $this->disabled['customScript'] = $this->customScript;
717
            $this->disabled['customCSS'] = $this->customCSS;
718
            $this->disabled['customHeadTags'] = $this->customHeadTags;
719
720
            $this->javascript = array();
721
            $this->css = array();
722
            $this->customScript = array();
723
            $this->customCSS = array();
724
            $this->customHeadTags = array();
725
        }
726
    }
727
728
    /**
729
     * Restore requirements cleared by call to Requirements::clear
730
     */
731
    public function restore()
732
    {
733
        $this->javascript = $this->disabled['javascript'];
734
        $this->css = $this->disabled['css'];
735
        $this->customScript = $this->disabled['customScript'];
736
        $this->customCSS = $this->disabled['customCSS'];
737
        $this->customHeadTags = $this->disabled['customHeadTags'];
738
    }
739
740
    /**
741
     * Block inclusion of a specific file
742
     *
743
     * The difference between this and {@link clear} is that the calling order does not matter;
744
     * {@link clear} must be called after the initial registration, whereas {@link block} can be
745
     * used in advance. This is useful, for example, to block scripts included by a superclass
746
     * without having to override entire functions and duplicate a lot of code.
747
     *
748
     * Note that blocking should be used sparingly because it's hard to trace where an file is
749
     * being blocked from.
750
     *
751
     * @param string|int $fileOrID
752
     */
753
    public function block($fileOrID)
754
    {
755
        $this->blocked[$fileOrID] = $fileOrID;
756
    }
757
758
    /**
759
     * Remove an item from the block list
760
     *
761
     * @param string|int $fileOrID
762
     */
763
    public function unblock($fileOrID)
764
    {
765
        unset($this->blocked[$fileOrID]);
766
    }
767
768
    /**
769
     * Removes all items from the block list
770
     */
771
    public function unblockAll()
772
    {
773
        $this->blocked = array();
774
    }
775
776
    /**
777
     * Update the given HTML content with the appropriate include tags for the registered
778
     * requirements. Needs to receive a valid HTML/XHTML template in the $content parameter,
779
     * including a head and body tag.
780
     *
781
     * @param string $content HTML content that has already been parsed from the $templateFile
782
     *                             through {@link SSViewer}
783
     * @return string HTML content augmented with the requirements tags
784
     */
785
    public function includeInHTML($content)
786
    {
787
        if (func_num_args() > 1) {
788
            Deprecation::notice(
789
                '5.0',
790
                '$templateFile argument is deprecated. includeInHTML takes a sole $content parameter now.'
791
            );
792
            $content = func_get_arg(1);
793
        }
794
795
        // Skip if content isn't injectable, or there is nothing to inject
796
        $tagsAvailable = preg_match('#</head\b#', $content);
797
        $hasFiles = $this->css || $this->javascript || $this->customCSS || $this->customScript || $this->customHeadTags;
0 ignored issues
show
Bug Best Practice introduced by
The expression $this->css of type array is implicitly converted to a boolean; are you sure this is intended? If so, consider using ! empty($expr) instead to make it clear that you intend to check for an array without elements.

This check marks implicit conversions of arrays to boolean values in a comparison. While in PHP an empty array is considered to be equal (but not identical) to false, this is not always apparent.

Consider making the comparison explicit by using empty(..) or ! empty(...) instead.

Loading history...
Bug Best Practice introduced by
The expression $this->javascript of type array is implicitly converted to a boolean; are you sure this is intended? If so, consider using ! empty($expr) instead to make it clear that you intend to check for an array without elements.

This check marks implicit conversions of arrays to boolean values in a comparison. While in PHP an empty array is considered to be equal (but not identical) to false, this is not always apparent.

Consider making the comparison explicit by using empty(..) or ! empty(...) instead.

Loading history...
Bug Best Practice introduced by
The expression $this->customCSS of type array is implicitly converted to a boolean; are you sure this is intended? If so, consider using ! empty($expr) instead to make it clear that you intend to check for an array without elements.

This check marks implicit conversions of arrays to boolean values in a comparison. While in PHP an empty array is considered to be equal (but not identical) to false, this is not always apparent.

Consider making the comparison explicit by using empty(..) or ! empty(...) instead.

Loading history...
Bug Best Practice introduced by
The expression $this->customScript of type array is implicitly converted to a boolean; are you sure this is intended? If so, consider using ! empty($expr) instead to make it clear that you intend to check for an array without elements.

This check marks implicit conversions of arrays to boolean values in a comparison. While in PHP an empty array is considered to be equal (but not identical) to false, this is not always apparent.

Consider making the comparison explicit by using empty(..) or ! empty(...) instead.

Loading history...
Bug Best Practice introduced by
The expression $this->customHeadTags of type array is implicitly converted to a boolean; are you sure this is intended? If so, consider using ! empty($expr) instead to make it clear that you intend to check for an array without elements.

This check marks implicit conversions of arrays to boolean values in a comparison. While in PHP an empty array is considered to be equal (but not identical) to false, this is not always apparent.

Consider making the comparison explicit by using empty(..) or ! empty(...) instead.

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

This check marks implicit conversions of arrays to boolean values in a comparison. While in PHP an empty array is considered to be equal (but not identical) to false, this is not always apparent.

Consider making the comparison explicit by using empty(..) or ! empty(...) instead.

Loading history...
1185
                $type = array_shift($file);
1186
                return array($path, $type);
1187
            }
1188
1189
            // Otherwise convent to string
1190
            $file = $path;
1191
        }
1192
1193
        $type = File::get_file_extension($file);
1194
        return array($file, $type);
1195
    }
1196
1197
    /**
1198
     * Return all combined files; keys are the combined file names, values are lists of
1199
     * associative arrays with 'files', 'type', and 'media' keys for details about this
1200
     * combined file.
1201
     *
1202
     * @return array
1203
     */
1204
    public function getCombinedFiles()
1205
    {
1206
        return array_diff_key($this->combinedFiles, $this->blocked);
1207
    }
1208
1209
    /**
1210
     * Includes all combined files, including blocked ones
1211
     *
1212
     * @return array
1213
     */
1214
    protected function getAllCombinedFiles()
1215
    {
1216
        return $this->combinedFiles;
1217
    }
1218
1219
    /**
1220
     * Clears all combined files
1221
     */
1222
    public function deleteAllCombinedFiles()
1223
    {
1224
        $combinedFolder = $this->getCombinedFilesFolder();
1225
        if ($combinedFolder) {
1226
            $this->getAssetHandler()->removeContent($combinedFolder);
1227
        }
1228
    }
1229
1230
    /**
1231
     * Clear all registered CSS and JavaScript file combinations
1232
     */
1233
    public function clearCombinedFiles()
1234
    {
1235
        $this->combinedFiles = array();
1236
    }
1237
1238
    /**
1239
     * Do the heavy lifting involved in combining the combined files.
1240
     */
1241
    public function processCombinedFiles()
1242
    {
1243
        // Check if combining is enabled
1244
        if (!$this->getCombinedFilesEnabled()) {
1245
            return;
1246
        }
1247
1248
        // Before scripts are modified, detect files that are provided by preceding ones
1249
        $providedScripts = $this->getProvidedScripts();
1250
1251
        // Process each combined files
1252
        foreach ($this->getAllCombinedFiles() as $combinedFile => $combinedItem) {
1253
            $fileList = $combinedItem['files'];
1254
            $type = $combinedItem['type'];
1255
            $options = $combinedItem['options'];
1256
1257
            // Generate this file, unless blocked
1258
            $combinedURL = null;
1259
            if (!isset($this->blocked[$combinedFile])) {
1260
                // Filter files for blocked / provided
1261
                $filteredFileList = array_diff(
1262
                    $fileList,
1263
                    $this->getBlocked(),
1264
                    $providedScripts
1265
                );
1266
                $combinedURL = $this->getCombinedFileURL($combinedFile, $filteredFileList, $type);
1267
            }
1268
1269
            // Replace all existing files, injecting the combined file at the position of the first item
1270
            // in order to preserve inclusion order.
1271
            // Note that we iterate across blocked files in order to get the correct order, and validate
1272
            // that the file is included in the correct location (regardless of which files are blocked).
1273
            $included = false;
1274
            switch ($type) {
1275
                case 'css': {
1276
                    $newCSS = array(); // Assoc array of css file => spec
1277
                    foreach ($this->getAllCSS() as $css => $spec) {
1278
                        if (!in_array($css, $fileList)) {
1279
                            $newCSS[$css] = $spec;
1280
                        } 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...
1281
                            $newCSS[$combinedURL] = array('media' => (isset($options['media']) ? $options['media'] : null));
1282
                            $included = true;
1283
                        }
1284
                        // If already included, or otherwise blocked, then don't add into CSS
1285
                    }
1286
                    $this->css = $newCSS;
1287
                    break;
1288
                }
1289
                case 'js': {
1290
                    // Assoc array of file => attributes
1291
                    $newJS = array();
1292
                    foreach ($this->getAllJavascript() as $script => $attributes) {
1293
                        if (!in_array($script, $fileList)) {
1294
                            $newJS[$script] = $attributes;
1295
                        } 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...
1296
                            $newJS[$combinedURL] = $options;
1297
                            $included = true;
1298
                        }
1299
                        // If already included, or otherwise blocked, then don't add into scripts
1300
                    }
1301
                    $this->javascript = $newJS;
1302
                    break;
1303
                }
1304
            }
1305
        }
1306
    }
1307
1308
    /**
1309
     * Given a set of files, combine them (as necessary) and return the url
1310
     *
1311
     * @param string $combinedFile Filename for this combined file
1312
     * @param array $fileList List of files to combine
1313
     * @param string $type Either 'js' or 'css'
1314
     * @return string|null URL to this resource, if there are files to combine
1315
     * @throws Exception
1316
     */
1317
    protected function getCombinedFileURL($combinedFile, $fileList, $type)
1318
    {
1319
        // Skip empty lists
1320
        if (empty($fileList)) {
1321
            return null;
1322
        }
1323
1324
        // Generate path (Filename)
1325
        $hashQuerystring = Config::inst()->get(static::class, 'combine_hash_querystring');
1326
        if (!$hashQuerystring) {
1327
            $combinedFile = $this->hashedCombinedFilename($combinedFile, $fileList);
1328
        }
1329
        $combinedFileID = File::join_paths($this->getCombinedFilesFolder(), $combinedFile);
1330
1331
        // Send file combination request to the backend, with an optional callback to perform regeneration
1332
        $minify = $this->getMinifyCombinedFiles();
1333
        if ($minify && !$this->minifier) {
1334
            throw new Exception(
1335
                sprintf(
1336
                    <<<MESSAGE
1337
Cannot minify files without a minification service defined.
1338
Set %s::minifyCombinedFiles to false, or inject a %s service on
1339
%s.properties.minifier
1340
MESSAGE
1341
                    ,
1342
                    __CLASS__,
1343
                    Requirements_Minifier::class,
1344
                    __CLASS__
1345
                )
1346
            );
1347
        }
1348
1349
        $combinedURL = $this
1350
            ->getAssetHandler()
1351
            ->getContentURL(
1352
                $combinedFileID,
1353
                function () use ($fileList, $minify, $type) {
1354
                    // Physically combine all file content
1355
                    $combinedData = '';
1356
                    $base = Director::baseFolder() . '/';
1357
                    foreach ($fileList as $file) {
1358
                        $fileContent = file_get_contents($base . $file);
1359
                        // Use configured minifier
1360
                        if ($minify) {
1361
                            $fileContent = $this->minifier->minify($fileContent, $type, $file);
1362
                        }
1363
1364
                        if ($this->writeHeaderComment) {
1365
                            // Write a header comment for each file for easier identification and debugging.
1366
                            $combinedData .= "/****** FILE: $file *****/\n";
1367
                        }
1368
                        $combinedData .= $fileContent . "\n";
1369
                    }
1370
                    return $combinedData;
1371
                }
1372
            );
1373
1374
        // If the name isn't hashed, we will need to append the querystring m= parameter instead
1375
        // Since url won't be automatically suffixed, add it in here
1376
        if ($hashQuerystring && $this->getSuffixRequirements()) {
1377
            $hash = $this->hashOfFiles($fileList);
1378
            $q = stripos($combinedURL, '?') === false ? '?' : '&';
1379
            $combinedURL .= "{$q}m={$hash}";
1380
        }
1381
1382
        return $combinedURL;
1383
    }
1384
1385
    /**
1386
     * Given a filename and list of files, generate a new filename unique to these files
1387
     *
1388
     * @param string $combinedFile
1389
     * @param array $fileList
1390
     * @return string
1391
     */
1392
    protected function hashedCombinedFilename($combinedFile, $fileList)
1393
    {
1394
        $name = pathinfo($combinedFile, PATHINFO_FILENAME);
1395
        $hash = $this->hashOfFiles($fileList);
1396
        $extension = File::get_file_extension($combinedFile);
1397
        return $name . '-' . substr($hash, 0, 7) . '.' . $extension;
1398
    }
1399
1400
    /**
1401
     * Check if combined files are enabled
1402
     *
1403
     * @return bool
1404
     */
1405
    public function getCombinedFilesEnabled()
1406
    {
1407
        if (isset($this->combinedFilesEnabled)) {
1408
            return $this->combinedFilesEnabled;
1409
        }
1410
1411
        // Non-dev sites are always combined
1412
        if (!Director::isDev()) {
1413
            return true;
1414
        }
1415
1416
        // Fallback to default
1417
        return Config::inst()->get(__CLASS__, 'combine_in_dev');
1418
    }
1419
1420
    /**
1421
     * For a given filelist, determine some discriminating value to determine if
1422
     * any of these files have changed.
1423
     *
1424
     * @param array $fileList List of files
1425
     * @return string SHA1 bashed file hash
1426
     */
1427
    protected function hashOfFiles($fileList)
1428
    {
1429
        // Get hash based on hash of each file
1430
        $base = Director::baseFolder() . '/';
1431
        $hash = '';
1432
        foreach ($fileList as $file) {
1433
            if (file_exists($base . $file)) {
1434
                $hash .= sha1_file($base . $file);
1435
            } else {
1436
                throw new InvalidArgumentException("Combined file {$file} does not exist");
1437
            }
1438
        }
1439
        return sha1($hash);
1440
    }
1441
1442
    /**
1443
     * Registers the given themeable stylesheet as required.
1444
     *
1445
     * A CSS file in the current theme path name 'themename/css/$name.css' is first searched for,
1446
     * and it that doesn't exist and the module parameter is set then a CSS file with that name in
1447
     * the module is used.
1448
     *
1449
     * @param string $name The name of the file - eg '/css/File.css' would have the name 'File'
1450
     * @param string $media Comma-separated list of media types to use in the link tag
1451
     *                       (e.g. 'screen,projector')
1452
     */
1453
    public function themedCSS($name, $media = null)
1454
    {
1455
        $path = ThemeResourceLoader::inst()->findThemedCSS($name, SSViewer::get_themes());
1456
        if ($path) {
0 ignored issues
show
Bug Best Practice introduced by
The expression $path of type string|null is loosely compared to true; this is ambiguous if the string can be empty. You might want to explicitly use !== null instead.

In PHP, under loose comparison (like ==, or !=, or switch conditions), values of different types might be equal.

For string values, the empty string '' is a special case, in particular the following results might be unexpected:

''   == false // true
''   == null  // true
'ab' == false // false
'ab' == null  // false

// It is often better to use strict comparison
'' === false // false
'' === null  // false
Loading history...
1457
            $this->css($path, $media);
1458
        } else {
1459
            throw new \InvalidArgumentException(
1460
                "The css file doesn't exist. Please check if the file $name.css exists in any context or search for "
1461
                . "themedCSS references calling this file in your templates."
1462
            );
1463
        }
1464
    }
1465
1466
    /**
1467
     * Registers the given themeable javascript as required.
1468
     *
1469
     * A javascript file in the current theme path name 'themename/javascript/$name.js' is first searched for,
1470
     * and it that doesn't exist and the module parameter is set then a javascript file with that name in
1471
     * the module is used.
1472
     *
1473
     * @param string $name The name of the file - eg '/js/File.js' would have the name 'File'
1474
     * @param string $type Comma-separated list of types to use in the script tag
1475
     *                       (e.g. 'text/javascript,text/ecmascript')
1476
     */
1477
    public function themedJavascript($name, $type = null)
1478
    {
1479
        $path = ThemeResourceLoader::inst()->findThemedJavascript($name, SSViewer::get_themes());
1480
        if ($path) {
0 ignored issues
show
Bug Best Practice introduced by
The expression $path of type string|null is loosely compared to true; this is ambiguous if the string can be empty. You might want to explicitly use !== null instead.

In PHP, under loose comparison (like ==, or !=, or switch conditions), values of different types might be equal.

For string values, the empty string '' is a special case, in particular the following results might be unexpected:

''   == false // true
''   == null  // true
'ab' == false // false
'ab' == null  // false

// It is often better to use strict comparison
'' === false // false
'' === null  // false
Loading history...
1481
            $opts = [];
1482
            if ($type) {
0 ignored issues
show
Bug Best Practice introduced by
The expression $type of type string|null is loosely compared to true; this is ambiguous if the string can be empty. You might want to explicitly use !== null instead.

In PHP, under loose comparison (like ==, or !=, or switch conditions), values of different types might be equal.

For string values, the empty string '' is a special case, in particular the following results might be unexpected:

''   == false // true
''   == null  // true
'ab' == false // false
'ab' == null  // false

// It is often better to use strict comparison
'' === false // false
'' === null  // false
Loading history...
1483
                $opts['type'] = $type;
1484
            }
1485
            $this->javascript($path, $opts);
1486
        } else {
1487
            throw new \InvalidArgumentException(
1488
                "The javascript file doesn't exist. Please check if the file $name.js exists in any "
1489
                . "context or search for themedJavascript references calling this file in your templates."
1490
            );
1491
        }
1492
    }
1493
1494
    /**
1495
     * Output debugging information.
1496
     */
1497
    public function debug()
1498
    {
1499
        Debug::show($this->javascript);
1500
        Debug::show($this->css);
1501
        Debug::show($this->customCSS);
1502
        Debug::show($this->customScript);
1503
        Debug::show($this->customHeadTags);
1504
        Debug::show($this->combinedFiles);
1505
    }
1506
}
1507