Completed
Push — webpack ( cbeb9d...3103f1 )
by Ingo
08:28
created

Requirements_Backend::setWriteHeaderComment()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 5
Code Lines 3

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 1
eloc 3
nc 1
nop 1
dl 0
loc 5
rs 9.4285
c 0
b 0
f 0
1
<?php
2
3
namespace SilverStripe\View;
4
5
use InvalidArgumentException;
6
use SilverStripe\Assets\File;
7
use SilverStripe\Assets\Storage\GeneratedAssetHandler;
8
use SilverStripe\Control\Director;
9
use SilverStripe\Control\HTTPResponse;
10
use SilverStripe\Core\Config\Config;
11
use SilverStripe\Core\Convert;
12
use SilverStripe\Core\Injector\Injectable;
13
use SilverStripe\Core\Injector\Injector;
14
use SilverStripe\Dev\Debug;
15
use SilverStripe\Dev\Deprecation;
16
use SilverStripe\Dev\SapphireTest;
17
use SilverStripe\i18n\i18n;
18
19
class Requirements_Backend
20
{
21
	use Injectable;
22
23
	/**
24
	 * Whether to add caching query params to the requests for file-based requirements.
25
	 * Eg: themes/myTheme/js/main.js?m=123456789. The parameter is a timestamp generated by
26
	 * filemtime. This has the benefit of allowing the browser to cache the URL infinitely,
27
	 * while automatically busting this cache every time the file is changed.
28
	 *
29
	 * @var bool
30
	 */
31
	protected $suffixRequirements = true;
32
33
	/**
34
	 * Whether to combine CSS and JavaScript files
35
	 *
36
	 * @var bool
37
	 */
38
	protected $combinedFilesEnabled = true;
39
40
	/**
41
	 * Determine if files should be combined automatically on dev mode.
42
	 *
43
	 * By default combined files will not be combined except in test or
44
	 * live environments. Turning this on will allow for pre-combining of files in development mode.
45
	 *
46
	 * @config
47
	 * @var bool
48
	 */
49
	private static $combine_in_dev = false;
50
51
	/**
52
	 * Paths to all required JavaScript files relative to docroot
53
	 *
54
	 * @var array
55
	 */
56
	protected $javascript = array();
57
58
	/**
59
	 * Map of included scripts to array of contained files.
60
	 * To be used alongside front-end combination mechanisms.
61
	 *
62
	 * @var array Map of providing filepath => array(provided filepaths)
63
	 */
64
	protected $providedJavascript = array();
65
66
	/**
67
	 * Paths to all required CSS files relative to the docroot.
68
	 *
69
	 * @var array
70
	 */
71
	protected $css = array();
72
73
	/**
74
	 * All custom javascript code that is inserted into the page's HTML
75
	 *
76
	 * @var array
77
	 */
78
	protected $customScript = array();
79
80
	/**
81
	 * All custom CSS rules which are inserted directly at the bottom of the HTML <head> tag
82
	 *
83
	 * @var array
84
	 */
85
	protected $customCSS = array();
86
87
	/**
88
	 * All custom HTML markup which is added before the closing <head> tag, e.g. additional
89
	 * metatags.
90
	 *
91
	 * @var array
92
	 */
93
	protected $customHeadTags = array();
94
95
	/**
96
	 * Remembers the file paths or uniquenessIDs of all Requirements cleared through
97
	 * {@link clear()}, so that they can be restored later.
98
	 *
99
	 * @var array
100
	 */
101
	protected $disabled = array();
102
103
	/**
104
	 * The file paths (relative to docroot) or uniquenessIDs of any included requirements which
105
	 * should be blocked when executing {@link inlcudeInHTML()}. This is useful, for example,
106
	 * to block scripts included by a superclass without having to override entire functions and
107
	 * duplicate a lot of code.
108
	 *
109
	 * Use {@link unblock()} or {@link unblock_all()} to revert changes.
110
	 *
111
	 * @var array
112
	 */
113
	protected $blocked = array();
114
115
	/**
116
	 * A list of combined files registered via {@link combine_files()}. Keys are the output file
117
	 * names, values are lists of input files.
118
	 *
119
	 * @var array
120
	 */
121
	protected $combinedFiles = array();
122
123
	/**
124
	 * Use the JSMin library to minify any javascript file passed to {@link combine_files()}.
125
	 *
126
	 * @var bool
127
	 */
128
	protected $minifyCombinedJSFiles = true;
129
130
	/**
131
	 * Whether or not file headers should be written when combining files
132
	 *
133
	 * @var boolean
134
	 */
135
	protected $writeHeaderComment = true;
136
137
	/**
138
	 * Where to save combined files. By default they're placed in assets/_combinedfiles, however
139
	 * this may be an issue depending on your setup, especially for CSS files which often contain
140
	 * relative paths.
141
	 *
142
	 * @var string
143
	 */
144
	protected $combinedFilesFolder = null;
145
146
	/**
147
	 * Put all JavaScript includes at the bottom of the template before the closing <body> tag,
148
	 * rather than the default behaviour of placing them at the end of the <head> tag. This means
149
	 * script downloads won't block other HTTP requests, which can be a performance improvement.
150
	 *
151
	 * @var bool
152
	 */
153
	public $writeJavascriptToBody = true;
154
155
	/**
156
	 * Force the JavaScript to the bottom of the page, even if there's a script tag in the body already
157
	 *
158
	 * @var boolean
159
	 */
160
	protected $forceJSToBottom = false;
161
162
	/**
163
	 * Configures the default prefix for combined files.
164
	 *
165
	 * This defaults to `_combinedfiles`, and is the folder within the configured asset backend that
166
	 * combined files will be stored in. If using a backend shared with other systems, it is usually
167
	 * necessary to distinguish combined files from other assets.
168
	 *
169
	 * @config
170
	 * @var string
171
	 */
172
	private static $default_combined_files_folder = '_combinedfiles';
173
174
	/**
175
	 * Flag to include the hash in the querystring instead of the filename for combined files.
176
	 *
177
	 * By default the `<hash>` of the source files is appended to the end of the combined file
178
	 * (prior to the file extension). If combined files are versioned in source control or running
179
	 * in a distributed environment (such as one where the newest version of a file may not always be
180
	 * immediately available) then it may sometimes be necessary to disable this. When this is set to true,
181
	 * the hash will instead be appended via a querystring parameter to enable cache busting, but not in
182
	 * the filename itself. I.e. `assets/_combinedfiles/name.js?m=<hash>`
183
	 *
184
	 * @config
185
	 * @var bool
186
	 */
187
	private static $combine_hash_querystring = false;
188
189
	/**
190
	 * @var GeneratedAssetHandler
191
	 */
192
	protected $assetHandler = null;
193
194
	/**
195
	 * Gets the backend storage for generated files
196
	 *
197
	 * @return GeneratedAssetHandler
198
	 */
199
	public function getAssetHandler()
200
	{
201
		return $this->assetHandler;
202
	}
203
204
	/**
205
	 * Set a new asset handler for this backend
206
	 *
207
	 * @param GeneratedAssetHandler $handler
208
	 */
209
	public function setAssetHandler(GeneratedAssetHandler $handler)
210
	{
211
		$this->assetHandler = $handler;
212
	}
213
214
	/**
215
	 * Enable or disable the combination of CSS and JavaScript files
216
	 *
217
	 * @param bool $enable
218
	 */
219
	public function setCombinedFilesEnabled($enable)
220
	{
221
		$this->combinedFilesEnabled = (bool)$enable;
222
	}
223
224
	/**
225
	 * Check if header comments are written
226
	 *
227
	 * @return bool
228
	 */
229
	public function getWriteHeaderComment()
230
	{
231
		return $this->writeHeaderComment;
232
	}
233
234
	/**
235
	 * Flag whether header comments should be written for each combined file
236
	 *
237
	 * @param bool $write
238
	 * @return $this
239
	 */
240
	public function setWriteHeaderComment($write)
241
	{
242
		$this->writeHeaderComment = $write;
243
		return $this;
244
	}
245
246
	/**
247
	 * Set the folder to save combined files in. By default they're placed in _combinedfiles,
248
	 * however this may be an issue depending on your setup, especially for CSS files which often
249
	 * contain relative paths.
250
	 *
251
	 * This must not include any 'assets' prefix
252
	 *
253
	 * @param string $folder
254
	 */
255
	public function setCombinedFilesFolder($folder)
256
	{
257
		$this->combinedFilesFolder = $folder;
258
	}
259
260
	/**
261
	 * Retrieve the combined files folder prefix
262
	 *
263
	 * @return string
264
	 */
265
	public function getCombinedFilesFolder()
266
	{
267
		if ($this->combinedFilesFolder) {
268
			return $this->combinedFilesFolder;
269
		}
270
		return Config::inst()->get(__CLASS__, 'default_combined_files_folder');
271
	}
272
273
	/**
274
	 * Set whether to add caching query params to the requests for file-based requirements.
275
	 * Eg: themes/myTheme/js/main.js?m=123456789. The parameter is a timestamp generated by
276
	 * filemtime. This has the benefit of allowing the browser to cache the URL infinitely,
277
	 * while automatically busting this cache every time the file is changed.
278
	 *
279
	 * @param bool
280
	 */
281
	public function setSuffixRequirements($var)
282
	{
283
		$this->suffixRequirements = $var;
284
	}
285
286
	/**
287
	 * Check whether we want to suffix requirements
288
	 *
289
	 * @return bool
290
	 */
291
	public function getSuffixRequirements()
292
	{
293
		return $this->suffixRequirements;
294
	}
295
296
	/**
297
	 * Set whether you want to write the JS to the body of the page rather than at the end of the
298
	 * head tag.
299
	 *
300
	 * @param bool
301
	 * @return $this
302
	 */
303
	public function setWriteJavascriptToBody($var)
304
	{
305
		$this->writeJavascriptToBody = $var;
306
		return $this;
307
	}
308
309
	/**
310
	 * Check whether you want to write the JS to the body of the page rather than at the end of the
311
	 * head tag.
312
	 *
313
	 * @return bool
314
	 */
315
	public function getWriteJavascriptToBody()
316
	{
317
		return $this->writeJavascriptToBody;
318
	}
319
320
	/**
321
	 * Forces the JavaScript requirements to the end of the body, right before the closing tag
322
	 *
323
	 * @param bool
324
	 * @return $this
325
	 */
326
	public function setForceJSToBottom($var)
327
	{
328
		$this->forceJSToBottom = $var;
329
		return $this;
330
	}
331
332
	/**
333
	 * Check if the JavaScript requirements are written to the end of the body, right before the closing tag
334
	 *
335
	 * @return bool
336
	 */
337
	public function getForceJSToBottom()
338
	{
339
		return $this->forceJSToBottom;
340
	}
341
342
	/**
343
	 * Check if minify js files should be combined
344
	 *
345
	 * @return bool
346
	 */
347
	public function getMinifyCombinedJSFiles()
348
	{
349
		return $this->minifyCombinedJSFiles;
350
	}
351
352
	/**
353
	 * Set if combined js files should be minified
354
	 *
355
	 * @param bool $minify
356
	 * @return $this
357
	 */
358
	public function setMinifyCombinedJSFiles($minify)
359
	{
360
		$this->minifyCombinedJSFiles = $minify;
361
		return $this;
362
	}
363
364
	/**
365
	 * Register the given JavaScript file as required.
366
	 *
367
	 * @param string $file Relative to docroot
368
	 * @param array $options List of options. Available options include:
369
	 * - 'provides' : List of scripts files included in this file
370
	 * - 'async' : Boolean value to set async attribute to script tag
371
	 * - 'defer' : Boolean value to set defer attribute to script tag
372
	 * - 'type' : Override script type= value.
373
	 */
374
	public function javascript($file, $options = array())
375
	{
376
		// Get type
377
		$type = null;
378
		if (isset($this->javascript[$file]['type'])) {
379
			$type = $this->javascript[$file]['type'];
380
		}
381
		if (isset($options['type'])) {
382
			$type = $options['type'];
383
		}
384
385
		// make sure that async/defer is set if it is set once even if file is included multiple times
386
		$async = (
387
			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...
388
			|| (
389
				isset($this->javascript[$file])
390
				&& isset($this->javascript[$file]['async'])
391
				&& $this->javascript[$file]['async'] == true
392
			)
393
		);
394
		$defer = (
395
			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...
396
			|| (
397
				isset($this->javascript[$file])
398
				&& isset($this->javascript[$file]['defer'])
399
				&& $this->javascript[$file]['defer'] == true
400
			)
401
		);
402
		$this->javascript[$file] = array(
403
			'async' => $async,
404
			'defer' => $defer,
405
			'type' => $type,
406
		);
407
408
		// Record scripts included in this file
409
		if (isset($options['provides'])) {
410
			$this->providedJavascript[$file] = array_values($options['provides']);
411
		}
412
413
	}
414
415
	/**
416
	 * Remove a javascript requirement
417
	 *
418
	 * @param string $file
419
	 */
420
	protected function unsetJavascript($file)
421
	{
422
		unset($this->javascript[$file]);
423
	}
424
425
	/**
426
	 * Gets all scripts that are already provided by prior scripts.
427
	 * This follows these rules:
428
	 *  - Files will not be considered provided if they are separately
429
	 *    included prior to the providing file.
430
	 *  - Providing files can be blocked, and don't provide anything
431
	 *  - Provided files can't be blocked (you need to block the provider)
432
	 *  - If a combined file includes files that are provided by prior
433
	 *    scripts, then these should be excluded from the combined file.
434
	 *  - If a combined file includes files that are provided by later
435
	 *    scripts, then these files should be included in the combined
436
	 *    file, but we can't block the later script either (possible double
437
	 *    up of file).
438
	 *
439
	 * @return array Array of provided files (map of $path => $path)
440
	 */
441
	public function getProvidedScripts()
442
	{
443
		$providedScripts = array();
444
		$includedScripts = array();
445
		foreach ($this->javascript as $script => $options) {
446
			// Ignore scripts that are explicitly blocked
447
			if (isset($this->blocked[$script])) {
448
				continue;
449
			}
450
			// At this point, the file is included.
451
			// This might also be combined at this point, potentially.
452
			$includedScripts[$script] = true;
453
454
			// Record any files this provides, EXCEPT those already included by now
455
			if (isset($this->providedJavascript[$script])) {
456
				foreach ($this->providedJavascript[$script] as $provided) {
457
					if (!isset($includedScripts[$provided])) {
458
						$providedScripts[$provided] = $provided;
459
					}
460
				}
461
			}
462
		}
463
		return $providedScripts;
464
	}
465
466
	/**
467
	 * Returns an array of required JavaScript, excluding blocked
468
	 * and duplicates of provided files.
469
	 *
470
	 * @return array
471
	 */
472
	public function getJavascript()
473
	{
474
		return array_diff_key(
475
			$this->javascript,
476
			$this->getBlocked(),
477
			$this->getProvidedScripts()
478
		);
479
	}
480
481
	/**
482
	 * Gets all javascript, including blocked files. Unwraps the array into a non-associative list
483
	 *
484
	 * @return array Indexed array of javascript files
485
	 */
486
	protected function getAllJavascript()
487
	{
488
		return $this->javascript;
489
	}
490
491
	/**
492
	 * Register the given JavaScript code into the list of requirements
493
	 *
494
	 * @param string $script The script content as a string (without enclosing <script> tag)
495
	 * @param string $uniquenessID A unique ID that ensures a piece of code is only added once
496
	 */
497
	public function customScript($script, $uniquenessID = null)
498
	{
499
		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...
500
			$this->customScript[$uniquenessID] = $script;
501
		} else {
502
			$this->customScript[] = $script;
503
		}
504
	}
505
506
	/**
507
	 * Return all registered custom scripts
508
	 *
509
	 * @return array
510
	 */
511
	public function getCustomScripts()
512
	{
513
		return array_diff_key($this->customScript, $this->blocked);
514
	}
515
516
	/**
517
	 * Register the given CSS styles into the list of requirements
518
	 *
519
	 * @param string $script CSS selectors as a string (without enclosing <style> tag)
520
	 * @param string $uniquenessID A unique ID that ensures a piece of code is only added once
521
	 */
522
	public function customCSS($script, $uniquenessID = null)
523
	{
524
		if ($uniquenessID) {
0 ignored issues
show
Bug Best Practice introduced by
The expression $uniquenessID of type string|null is loosely compared to true; this is ambiguous if the string can be empty. You might want to explicitly use !== null instead.

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

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

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

// It is often better to use strict comparison
'' === false // false
'' === null  // false
Loading history...
525
			$this->customCSS[$uniquenessID] = $script;
526
		} else {
527
			$this->customCSS[] = $script;
528
		}
529
	}
530
531
	/**
532
	 * Return all registered custom CSS
533
	 *
534
	 * @return array
535
	 */
536
	public function getCustomCSS()
537
	{
538
		return array_diff_key($this->customCSS, $this->blocked);
539
	}
540
541
	/**
542
	 * Add the following custom HTML code to the <head> section of the page
543
	 *
544
	 * @param string $html Custom HTML code
545
	 * @param string $uniquenessID A unique ID that ensures a piece of code is only added once
546
	 */
547
	public function insertHeadTags($html, $uniquenessID = null)
548
	{
549
		if ($uniquenessID) {
0 ignored issues
show
Bug Best Practice introduced by
The expression $uniquenessID of type string|null is loosely compared to true; this is ambiguous if the string can be empty. You might want to explicitly use !== null instead.

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

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

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

// It is often better to use strict comparison
'' === false // false
'' === null  // false
Loading history...
550
			$this->customHeadTags[$uniquenessID] = $html;
551
		} else {
552
			$this->customHeadTags[] = $html;
553
		}
554
	}
555
556
	/**
557
	 * Return all custom head tags
558
	 *
559
	 * @return array
560
	 */
561
	public function getCustomHeadTags()
562
	{
563
		return array_diff_key($this->customHeadTags, $this->blocked);
564
	}
565
566
	/**
567
	 * Include the content of the given JavaScript file in the list of requirements. Dollar-sign
568
	 * variables will be interpolated with values from $vars similar to a .ss template.
569
	 *
570
	 * @param string $file The template file to load, relative to docroot
571
	 * @param string[] $vars The array of variables to interpolate.
572
	 * @param string $uniquenessID A unique ID that ensures a piece of code is only added once
573
	 */
574
	public function javascriptTemplate($file, $vars, $uniquenessID = null)
575
	{
576
		$script = file_get_contents(Director::getAbsFile($file));
577
		$search = array();
578
		$replace = array();
579
580
		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...
581
			foreach ($vars as $k => $v) {
582
				$search[] = '$' . $k;
583
				$replace[] = str_replace("\\'", "'", Convert::raw2js($v));
584
			}
585
		}
586
587
		$script = str_replace($search, $replace, $script);
588
		$this->customScript($script, $uniquenessID);
589
	}
590
591
	/**
592
	 * Register the given stylesheet into the list of requirements.
593
	 *
594
	 * @param string $file The CSS file to load, relative to site root
595
	 * @param string $media Comma-separated list of media types to use in the link tag
596
	 *                      (e.g. 'screen,projector')
597
	 */
598
	public function css($file, $media = null)
599
	{
600
		$this->css[$file] = array(
601
			"media" => $media
602
		);
603
	}
604
605
	/**
606
	 * Remove a css requirement
607
	 *
608
	 * @param string $file
609
	 */
610
	protected function unsetCSS($file)
611
	{
612
		unset($this->css[$file]);
613
	}
614
615
	/**
616
	 * Get the list of registered CSS file requirements, excluding blocked files
617
	 *
618
	 * @return array Associative array of file to spec
619
	 */
620
	public function getCSS()
621
	{
622
		return array_diff_key($this->css, $this->blocked);
623
	}
624
625
	/**
626
	 * Gets all CSS files requirements, including blocked
627
	 *
628
	 * @return array Associative array of file to spec
629
	 */
630
	protected function getAllCSS()
631
	{
632
		return $this->css;
633
	}
634
635
	/**
636
	 * Gets the list of all blocked files
637
	 *
638
	 * @return array
639
	 */
640
	public function getBlocked()
641
	{
642
		return $this->blocked;
643
	}
644
645
	/**
646
	 * Clear either a single or all requirements
647
	 *
648
	 * Caution: Clearing single rules added via customCSS and customScript only works if you
649
	 * originally specified a $uniquenessID.
650
	 *
651
	 * @param string|int $fileOrID
652
	 */
653
	public function clear($fileOrID = null)
654
	{
655
		if ($fileOrID) {
656
			foreach (array('javascript', 'css', 'customScript', 'customCSS', 'customHeadTags') as $type) {
657
				if (isset($this->{$type}[$fileOrID])) {
658
					$this->disabled[$type][$fileOrID] = $this->{$type}[$fileOrID];
659
					unset($this->{$type}[$fileOrID]);
660
				}
661
			}
662
		} else {
663
			$this->disabled['javascript'] = $this->javascript;
664
			$this->disabled['css'] = $this->css;
665
			$this->disabled['customScript'] = $this->customScript;
666
			$this->disabled['customCSS'] = $this->customCSS;
667
			$this->disabled['customHeadTags'] = $this->customHeadTags;
668
669
			$this->javascript = array();
670
			$this->css = array();
671
			$this->customScript = array();
672
			$this->customCSS = array();
673
			$this->customHeadTags = array();
674
		}
675
	}
676
677
	/**
678
	 * Restore requirements cleared by call to Requirements::clear
679
	 */
680
	public function restore()
681
	{
682
		$this->javascript = $this->disabled['javascript'];
683
		$this->css = $this->disabled['css'];
684
		$this->customScript = $this->disabled['customScript'];
685
		$this->customCSS = $this->disabled['customCSS'];
686
		$this->customHeadTags = $this->disabled['customHeadTags'];
687
	}
688
689
	/**
690
	 * Block inclusion of a specific file
691
	 *
692
	 * The difference between this and {@link clear} is that the calling order does not matter;
693
	 * {@link clear} must be called after the initial registration, whereas {@link block} can be
694
	 * used in advance. This is useful, for example, to block scripts included by a superclass
695
	 * without having to override entire functions and duplicate a lot of code.
696
	 *
697
	 * Note that blocking should be used sparingly because it's hard to trace where an file is
698
	 * being blocked from.
699
	 *
700
	 * @param string|int $fileOrID
701
	 */
702
	public function block($fileOrID)
703
	{
704
		$this->blocked[$fileOrID] = $fileOrID;
705
	}
706
707
	/**
708
	 * Remove an item from the block list
709
	 *
710
	 * @param string|int $fileOrID
711
	 */
712
	public function unblock($fileOrID)
713
	{
714
		unset($this->blocked[$fileOrID]);
715
	}
716
717
	/**
718
	 * Removes all items from the block list
719
	 */
720
	public function unblockAll()
721
	{
722
		$this->blocked = array();
723
	}
724
725
	/**
726
	 * Update the given HTML content with the appropriate include tags for the registered
727
	 * requirements. Needs to receive a valid HTML/XHTML template in the $content parameter,
728
	 * including a head and body tag.
729
	 *
730
	 * @param string $content HTML content that has already been parsed from the $templateFile
731
	 *                             through {@link SSViewer}
732
	 * @return string HTML content augmented with the requirements tags
733
	 */
734
	public function includeInHTML($content)
735
	{
736
		if (func_num_args() > 1) {
737
			Deprecation::notice(
738
				'5.0',
739
				'$templateFile argument is deprecated. includeInHTML takes a sole $content parameter now.'
740
			);
741
			$content = func_get_arg(1);
742
		}
743
744
		// Skip if content isn't injectable, or there is nothing to inject
745
		$tagsAvailable = preg_match('#</head\b#', $content);
746
		$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...
747
		if (!$tagsAvailable || !$hasFiles) {
748
			return $content;
749
		}
750
		$requirements = '';
751
		$jsRequirements = '';
752
753
		// Combine files - updates $this->javascript and $this->css
754
		$this->processCombinedFiles();
755
756
		foreach ($this->getJavascript() as $file => $attributes) {
757
			$async = (isset($attributes['async']) && $attributes['async'] == true) ? " async" : "";
758
			$defer = (isset($attributes['defer']) && $attributes['defer'] == true) ? " defer" : "";
759
			$type = Convert::raw2att(isset($attributes['type']) ? $attributes['type'] : "application/javascript");
760
			$path = Convert::raw2att($this->pathForFile($file));
0 ignored issues
show
Bug introduced by
It seems like $this->pathForFile($file) targeting SilverStripe\View\Requir..._Backend::pathForFile() can also be of type boolean; however, SilverStripe\Core\Convert::raw2att() does only seem to accept array|string, maybe add an additional type check?

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

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

An additional type check may prevent trouble.

Loading history...
761
			if ($path) {
762
				$jsRequirements .= "<script type=\"{$type}\" src=\"{$path}\"{$async}{$defer}></script>";
763
			}
764
		}
765
766
		// Add all inline JavaScript *after* including external files they might rely on
767
		foreach ($this->getCustomScripts() as $script) {
768
			$jsRequirements .= "<script type=\"application/javascript\">//<![CDATA[\n";
769
			$jsRequirements .= "$script\n";
770
			$jsRequirements .= "//]]></script>";
771
		}
772
773 View Code Duplication
		foreach ($this->getCSS() as $file => $params) {
0 ignored issues
show
Duplication introduced by
This code seems to be duplicated across your project.

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

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

Loading history...
774
			$path = Convert::raw2att($this->pathForFile($file));
0 ignored issues
show
Bug introduced by
It seems like $this->pathForFile($file) targeting SilverStripe\View\Requir..._Backend::pathForFile() can also be of type boolean; however, SilverStripe\Core\Convert::raw2att() does only seem to accept array|string, maybe add an additional type check?

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

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

An additional type check may prevent trouble.

Loading history...
775
			if ($path) {
776
				$media = (isset($params['media']) && !empty($params['media']))
777
					? " media=\"{$params['media']}\"" : "";
778
				$requirements .= "<link rel=\"stylesheet\" type=\"text/css\" {$media} href=\"$path\" />\n";
779
			}
780
		}
781
782
		foreach ($this->getCustomCSS() as $css) {
783
			$requirements .= "<style type=\"text/css\">\n$css\n</style>\n";
784
		}
785
786
		foreach ($this->getCustomHeadTags() as $customHeadTag) {
787
			$requirements .= "$customHeadTag\n";
788
		}
789
790
		// Inject CSS  into body
791
		$content = $this->insertTagsIntoHead($requirements, $content);
792
793
		// Inject scripts
794
		if ($this->getForceJSToBottom()) {
795
			$content = $this->insertScriptsAtBottom($jsRequirements, $content);
796
		} elseif ($this->getWriteJavascriptToBody()) {
797
			$content = $this->insertScriptsIntoBody($jsRequirements, $content);
798
		} else {
799
			$content = $this->insertTagsIntoHead($jsRequirements, $content);
800
		}
801
		return $content;
802
	}
803
804
	/**
805
	 * Given a block of HTML, insert the given scripts at the bottom before
806
	 * the closing </body> tag
807
	 *
808
	 * @param string $jsRequirements String containing one or more javascript <script /> tags
809
	 * @param string $content HTML body
810
	 * @return string Merged HTML
811
	 */
812
	protected function insertScriptsAtBottom($jsRequirements, $content)
813
	{
814
		// Forcefully put the scripts at the bottom of the body instead of before the first
815
		// script tag.
816
		$content = preg_replace(
817
			'/(<\/body[^>]*>)/i',
818
			$this->escapeReplacement($jsRequirements) . '\\1',
819
			$content
820
		);
821
		return $content;
822
	}
823
824
	/**
825
	 * Given a block of HTML, insert the given scripts inside the <body></body>
826
	 *
827
	 * @param string $jsRequirements String containing one or more javascript <script /> tags
828
	 * @param string $content HTML body
829
	 * @return string Merged HTML
830
	 */
831
	protected function insertScriptsIntoBody($jsRequirements, $content)
832
	{
833
		// If your template already has script tags in the body, then we try to put our script
834
		// tags just before those. Otherwise, we put it at the bottom.
835
		$bodyTagPosition = stripos($content, '<body');
836
		$scriptTagPosition = stripos($content, '<script', $bodyTagPosition);
837
838
		$commentTags = array();
839
		$canWriteToBody = ($scriptTagPosition !== false)
840
			&&
841
			// Check that the script tag is not inside a html comment tag
842
			!(
843
				preg_match('/.*(?|(<!--)|(-->))/U', $content, $commentTags, 0, $scriptTagPosition)
844
				&&
845
				$commentTags[1] == '-->'
846
			);
847
848
		if ($canWriteToBody) {
849
			// Insert content before existing script tags
850
			$content = substr($content, 0, $scriptTagPosition)
851
				. $jsRequirements
852
				. substr($content, $scriptTagPosition);
853
		} else {
854
			// Insert content at bottom of page otherwise
855
			$content = $this->insertScriptsAtBottom($jsRequirements, $content);
856
		}
857
858
		return $content;
859
	}
860
861
	/**
862
	 * Given a block of HTML, insert the given code inside the <head></head> block
863
	 *
864
	 * @param string $jsRequirements String containing one or more html tags
865
	 * @param string $content HTML body
866
	 * @return string Merged HTML
867
	 */
868
	protected function insertTagsIntoHead($jsRequirements, $content)
869
	{
870
		$content = preg_replace(
871
			'/(<\/head>)/i',
872
			$this->escapeReplacement($jsRequirements) . '\\1',
873
			$content
874
		);
875
		return $content;
876
	}
877
878
	/**
879
	 * Safely escape a literal string for use in preg_replace replacement
880
	 *
881
	 * @param string $replacement
882
	 * @return string
883
	 */
884
	protected function escapeReplacement($replacement)
885
	{
886
		return addcslashes($replacement, '\\$');
887
	}
888
889
	/**
890
	 * Attach requirements inclusion to X-Include-JS and X-Include-CSS headers on the given
891
	 * HTTP Response
892
	 *
893
	 * @param HTTPResponse $response
894
	 */
895
	public function includeInResponse(HTTPResponse $response)
896
	{
897
		$this->processCombinedFiles();
898
		$jsRequirements = array();
899
		$cssRequirements = array();
900
901
		foreach ($this->getJavascript() as $file => $attributes) {
902
			$path = $this->pathForFile($file);
903
			if ($path) {
904
				$jsRequirements[] = str_replace(',', '%2C', $path);
905
			}
906
		}
907
908
		if (count($jsRequirements)) {
909
			$response->addHeader('X-Include-JS', implode(',', $jsRequirements));
910
		}
911
912 View Code Duplication
		foreach ($this->getCSS() as $file => $params) {
0 ignored issues
show
Duplication introduced by
This code seems to be duplicated across your project.

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

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

Loading history...
913
			$path = $this->pathForFile($file);
914
			if ($path) {
915
				$path = str_replace(',', '%2C', $path);
916
				$cssRequirements[] = isset($params['media']) ? "$path:##:$params[media]" : $path;
917
			}
918
		}
919
920
		if (count($cssRequirements)) {
921
			$response->addHeader('X-Include-CSS', implode(',', $cssRequirements));
922
		}
923
	}
924
925
	/**
926
	 * Add i18n files from the given javascript directory. SilverStripe expects that the given
927
	 * directory will contain a number of JavaScript files named by language: en_US.js, de_DE.js,
928
	 * etc.
929
	 *
930
	 * @param string $langDir The JavaScript lang directory, relative to the site root, e.g.,
931
	 *                         'framework/javascript/lang'
932
	 * @param bool $return Return all relative file paths rather than including them in
933
	 *                         requirements
934
	 *
935
	 * @return array|null All relative files if $return is true, or null otherwise
936
	 */
937
	public function add_i18n_javascript($langDir, $return = false)
938
	{
939
		$files = array();
940
		$base = Director::baseFolder() . '/';
941
942
		if (substr($langDir, -1) != '/') {
943
			$langDir .= '/';
944
		}
945
946
		$candidates = array(
947
			'en.js',
948
			'en_US.js',
949
			i18n::get_lang_from_locale(i18n::config()->default_locale) . '.js',
950
			i18n::config()->default_locale . '.js',
951
			i18n::get_lang_from_locale(i18n::get_locale()) . '.js',
952
			i18n::get_locale() . '.js',
953
		);
954
		foreach ($candidates as $candidate) {
955
			if (file_exists($base . DIRECTORY_SEPARATOR . $langDir . $candidate)) {
956
				$files[] = $langDir . $candidate;
957
			}
958
		}
959
960
		if ($return) {
961
			return $files;
962
		} else {
963
			foreach ($files as $file) {
964
				$this->javascript($file);
965
			}
966
			return null;
967
		}
968
	}
969
970
	/**
971
	 * Finds the path for specified file
972
	 *
973
	 * @param string $fileOrUrl
974
	 * @return string|bool
975
	 */
976
	protected function pathForFile($fileOrUrl)
977
	{
978
		// Since combined urls could be root relative, treat them as urls here.
979
		if (preg_match('{^(//)|(http[s]?:)}', $fileOrUrl) || Director::is_root_relative_url($fileOrUrl)) {
980
			return $fileOrUrl;
981
		} elseif (Director::fileExists($fileOrUrl)) {
982
			$filePath = preg_replace('/\?.*/', '', Director::baseFolder() . '/' . $fileOrUrl);
983
			$prefix = Director::baseURL();
984
			$mtimesuffix = "";
985
			$suffix = '';
986
			if ($this->getSuffixRequirements()) {
987
				$mtimesuffix = "?m=" . filemtime($filePath);
988
				$suffix = '&';
989
			}
990
			if (strpos($fileOrUrl, '?') !== false) {
991
				if (strlen($suffix) == 0) {
992
					$suffix = '?';
993
				}
994
				$suffix .= substr($fileOrUrl, strpos($fileOrUrl, '?') + 1);
995
				$fileOrUrl = substr($fileOrUrl, 0, strpos($fileOrUrl, '?'));
996
			} else {
997
				$suffix = '';
998
			}
999
			return "{$prefix}{$fileOrUrl}{$mtimesuffix}{$suffix}";
1000
		} else {
1001
			throw new InvalidArgumentException("File {$fileOrUrl} does not exist");
1002
		}
1003
	}
1004
1005
	/**
1006
	 * Concatenate several css or javascript files into a single dynamically generated file. This
1007
	 * increases performance by fewer HTTP requests.
1008
	 *
1009
	 * The combined file is regenerated based on every file modification time. Optionally a
1010
	 * rebuild can be triggered by appending ?flush=1 to the URL.
1011
	 *
1012
	 * All combined files will have a comment on the start of each concatenated file denoting their
1013
	 * original position.
1014
	 *
1015
	 * CAUTION: You're responsible for ensuring that the load order for combined files is
1016
	 * retained - otherwise combining JavaScript files can lead to functional errors in the
1017
	 * JavaScript logic, and combining CSS can lead to incorrect inheritance. You can also
1018
	 * only include each file once across all includes and combinations in a single page load.
1019
	 *
1020
	 * CAUTION: Combining CSS Files discards any "media" information.
1021
	 *
1022
	 * Example for combined JavaScript:
1023
	 * <code>
1024
	 * Requirements::combine_files(
1025
	 *    'foobar.js',
1026
	 *    array(
1027
	 *        'mysite/javascript/foo.js',
1028
	 *        'mysite/javascript/bar.js',
1029
	 *    ),
1030
	 *    array(
1031
	 *        'async' => true,
1032
	 *        'defer' => true,
1033
	 *    )
1034
	 * );
1035
	 * </code>
1036
	 *
1037
	 * Example for combined CSS:
1038
	 * <code>
1039
	 * Requirements::combine_files(
1040
	 *    'foobar.css',
1041
	 *    array(
1042
	 *        'mysite/javascript/foo.css',
1043
	 *        'mysite/javascript/bar.css',
1044
	 *    ),
1045
	 *    array(
1046
	 *        'media' => 'print',
1047
	 *    )
1048
	 * );
1049
	 * </code>
1050
	 *
1051
	 * @param string $combinedFileName Filename of the combined file relative to docroot
1052
	 * @param array $files Array of filenames relative to docroot
1053
	 * @param array $options Array of options for combining files. Available options are:
1054
	 * - 'media' : If including CSS Files, you can specify a media type
1055
	 * - 'async' : If including JavaScript Files, boolean value to set async attribute to script tag
1056
	 * - 'defer' : If including JavaScript Files, boolean value to set defer attribute to script tag
1057
	 */
1058
	public function combineFiles($combinedFileName, $files, $options = array())
1059
	{
1060
		if (is_string($options)) {
1061
			Deprecation::notice('4.0', 'Parameter media is deprecated. Use options array instead.');
1062
			$options = array('media' => $options);
1063
		}
1064
		// Skip this combined files if already included
1065
		if (isset($this->combinedFiles[$combinedFileName])) {
1066
			return;
1067
		}
1068
1069
		// Add all files to necessary type list
1070
		$paths = array();
1071
		$combinedType = null;
1072
		foreach ($files as $file) {
1073
			// Get file details
1074
			list($path, $type) = $this->parseCombinedFile($file);
1075
			if ($type === 'javascript') {
1076
				$type = 'js';
1077
			}
1078
			if ($combinedType && $type && $combinedType !== $type) {
1079
				throw new InvalidArgumentException(
1080
					"Cannot mix js and css files in same combined file {$combinedFileName}"
1081
				);
1082
			}
1083
			switch ($type) {
1084
				case 'css':
1085
					$this->css($path, (isset($options['media']) ? $options['media'] : null));
1086
					break;
1087
				case 'js':
1088
					$this->javascript($path, $options);
1089
					break;
1090
				default:
1091
					throw new InvalidArgumentException("Invalid combined file type: {$type}");
1092
			}
1093
			$combinedType = $type;
1094
			$paths[] = $path;
1095
		}
1096
1097
		// Duplicate check
1098
		foreach ($this->combinedFiles as $existingCombinedFilename => $combinedItem) {
1099
			$existingFiles = $combinedItem['files'];
1100
			$duplicates = array_intersect($existingFiles, $paths);
1101
			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...
1102
				throw new InvalidArgumentException(sprintf(
1103
					"Requirements_Backend::combine_files(): Already included file(s) %s in combined file '%s'",
1104
					implode(',', $duplicates),
1105
					$existingCombinedFilename
1106
				));
1107
			}
1108
		}
1109
1110
		$this->combinedFiles[$combinedFileName] = array(
1111
			'files' => $paths,
1112
			'type' => $combinedType,
1113
			'options' => $options,
1114
		);
1115
	}
1116
1117
	/**
1118
	 * Return path and type of given combined file
1119
	 *
1120
	 * @param string|array $file Either a file path, or an array spec
1121
	 * @return array array with two elements, path and type of file
1122
	 */
1123
	protected function parseCombinedFile($file)
1124
	{
1125
		// Array with path and type keys
1126
		if (is_array($file) && isset($file['path']) && isset($file['type'])) {
1127
			return array($file['path'], $file['type']);
1128
		}
1129
1130
		// Extract value from indexed array
1131
		if (is_array($file)) {
1132
			$path = array_shift($file);
1133
1134
			// See if there's a type specifier
1135
			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...
1136
				$type = array_shift($file);
1137
				return array($path, $type);
1138
			}
1139
1140
			// Otherwise convent to string
1141
			$file = $path;
1142
		}
1143
1144
		$type = File::get_file_extension($file);
1145
		return array($file, $type);
1146
	}
1147
1148
	/**
1149
	 * Return all combined files; keys are the combined file names, values are lists of
1150
	 * associative arrays with 'files', 'type', and 'media' keys for details about this
1151
	 * combined file.
1152
	 *
1153
	 * @return array
1154
	 */
1155
	public function getCombinedFiles()
1156
	{
1157
		return array_diff_key($this->combinedFiles, $this->blocked);
1158
	}
1159
1160
	/**
1161
	 * Includes all combined files, including blocked ones
1162
	 *
1163
	 * @return array
1164
	 */
1165
	protected function getAllCombinedFiles()
1166
	{
1167
		return $this->combinedFiles;
1168
	}
1169
1170
	/**
1171
	 * Clears all combined files
1172
	 */
1173
	public function deleteAllCombinedFiles()
1174
	{
1175
		$combinedFolder = $this->getCombinedFilesFolder();
1176
		if ($combinedFolder) {
1177
			$this->getAssetHandler()->removeContent($combinedFolder);
1178
		}
1179
	}
1180
1181
	/**
1182
	 * Clear all registered CSS and JavaScript file combinations
1183
	 */
1184
	public function clearCombinedFiles()
1185
	{
1186
		$this->combinedFiles = array();
1187
	}
1188
1189
	/**
1190
	 * Do the heavy lifting involved in combining the combined files.
1191
	 */
1192
	public function processCombinedFiles()
1193
	{
1194
		// Check if combining is enabled
1195
		if (!$this->getCombinedFilesEnabled()) {
1196
			return;
1197
		}
1198
1199
		// Before scripts are modified, detect files that are provided by preceding ones
1200
		$providedScripts = $this->getProvidedScripts();
1201
1202
		// Process each combined files
1203
		foreach ($this->getAllCombinedFiles() as $combinedFile => $combinedItem) {
1204
			$fileList = $combinedItem['files'];
1205
			$type = $combinedItem['type'];
1206
			$options = $combinedItem['options'];
1207
1208
			// Generate this file, unless blocked
1209
			$combinedURL = null;
1210
			if (!isset($this->blocked[$combinedFile])) {
1211
				// Filter files for blocked / provided
1212
				$filteredFileList = array_diff(
1213
					$fileList,
1214
					$this->getBlocked(),
1215
					$providedScripts
1216
				);
1217
				$combinedURL = $this->getCombinedFileURL($combinedFile, $filteredFileList, $type);
1218
			}
1219
1220
			// Replace all existing files, injecting the combined file at the position of the first item
1221
			// in order to preserve inclusion order.
1222
			// Note that we iterate across blocked files in order to get the correct order, and validate
1223
			// that the file is included in the correct location (regardless of which files are blocked).
1224
			$included = false;
1225
			switch ($type) {
1226
				case 'css': {
1227
					$newCSS = array(); // Assoc array of css file => spec
1228
					foreach ($this->getAllCSS() as $css => $spec) {
1229
						if (!in_array($css, $fileList)) {
1230
							$newCSS[$css] = $spec;
1231
						} 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...
1232
							$newCSS[$combinedURL] = array('media' => (isset($options['media']) ? $options['media'] : null));
1233
							$included = true;
1234
						}
1235
						// If already included, or otherwise blocked, then don't add into CSS
1236
					}
1237
					$this->css = $newCSS;
1238
					break;
1239
				}
1240
				case 'js': {
1241
					// Assoc array of file => attributes
1242
					$newJS = array();
1243
					foreach ($this->getAllJavascript() as $script => $attributes) {
1244
						if (!in_array($script, $fileList)) {
1245
							$newJS[$script] = $attributes;
1246
						} 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...
1247
							$newJS[$combinedURL] = $options;
1248
							$included = true;
1249
						}
1250
						// If already included, or otherwise blocked, then don't add into scripts
1251
					}
1252
					$this->javascript = $newJS;
1253
					break;
1254
				}
1255
			}
1256
		}
1257
	}
1258
1259
	/**
1260
	 * Given a set of files, combine them (as necessary) and return the url
1261
	 *
1262
	 * @param string $combinedFile Filename for this combined file
1263
	 * @param array $fileList List of files to combine
1264
	 * @param string $type Either 'js' or 'css'
1265
	 * @return string|null URL to this resource, if there are files to combine
1266
	 */
1267
	protected function getCombinedFileURL($combinedFile, $fileList, $type)
1268
	{
1269
		// Skip empty lists
1270
		if (empty($fileList)) {
1271
			return null;
1272
		}
1273
1274
		// Generate path (Filename)
1275
		$hashQuerystring = Config::inst()->get(static::class, 'combine_hash_querystring');
1276
		if (!$hashQuerystring) {
1277
			$combinedFile = $this->hashedCombinedFilename($combinedFile, $fileList);
1278
		}
1279
		$combinedFileID = File::join_paths($this->getCombinedFilesFolder(), $combinedFile);
1280
1281
		// Send file combination request to the backend, with an optional callback to perform regeneration
1282
		$minify = $this->getMinifyCombinedJSFiles();
1283
		$combinedURL = $this
1284
			->getAssetHandler()
1285
			->getContentURL(
1286
				$combinedFileID,
1287
				function () use ($fileList, $minify, $type) {
1288
					// Physically combine all file content
1289
					$combinedData = '';
1290
					$base = Director::baseFolder() . '/';
1291
					$minifier = Injector::inst()->get('SilverStripe\\View\\Requirements_Minifier');
1292
					foreach ($fileList as $file) {
1293
						$fileContent = file_get_contents($base . $file);
1294
						// Use configured minifier
1295
						if ($minify) {
1296
							$fileContent = $minifier->minify($fileContent, $type, $file);
1297
						}
1298
1299
						if ($this->writeHeaderComment) {
1300
							// Write a header comment for each file for easier identification and debugging.
1301
							$combinedData .= "/****** FILE: $file *****/\n";
1302
						}
1303
						$combinedData .= $fileContent . "\n";
1304
					}
1305
					return $combinedData;
1306
				}
1307
			);
1308
1309
		// If the name isn't hashed, we will need to append the querystring m= parameter instead
1310
		// Since url won't be automatically suffixed, add it in here
1311
		if ($hashQuerystring && $this->getSuffixRequirements()) {
1312
			$hash = $this->hashOfFiles($fileList);
1313
			$q = stripos($combinedURL, '?') === false ? '?' : '&';
1314
			$combinedURL .= "{$q}m={$hash}";
1315
		}
1316
1317
		return $combinedURL;
1318
	}
1319
1320
	/**
1321
	 * Given a filename and list of files, generate a new filename unique to these files
1322
	 *
1323
	 * @param string $combinedFile
1324
	 * @param array $fileList
1325
	 * @return string
1326
	 */
1327
	protected function hashedCombinedFilename($combinedFile, $fileList)
1328
	{
1329
		$name = pathinfo($combinedFile, PATHINFO_FILENAME);
1330
		$hash = $this->hashOfFiles($fileList);
1331
		$extension = File::get_file_extension($combinedFile);
1332
		return $name . '-' . substr($hash, 0, 7) . '.' . $extension;
1333
	}
1334
1335
	/**
1336
	 * Check if combined files are enabled
1337
	 *
1338
	 * @return bool
1339
	 */
1340
	public function getCombinedFilesEnabled()
1341
	{
1342
		if (!$this->combinedFilesEnabled) {
1343
			return false;
1344
		}
1345
1346
		// Tests should be combined
1347
		if (class_exists('SilverStripe\\Dev\\SapphireTest', false) && SapphireTest::is_running_test()) {
1348
			return true;
1349
		}
1350
1351
		// Check if specified via querystring
1352
		if (isset($_REQUEST['combine'])) {
1353
			return true;
1354
		}
1355
1356
		// Non-dev sites are always combined
1357
		if (!Director::isDev()) {
1358
			return true;
1359
		}
1360
1361
		// Fallback to default
1362
		return Config::inst()->get(__CLASS__, 'combine_in_dev');
1363
	}
1364
1365
	/**
1366
	 * For a given filelist, determine some discriminating value to determine if
1367
	 * any of these files have changed.
1368
	 *
1369
	 * @param array $fileList List of files
1370
	 * @return string SHA1 bashed file hash
1371
	 */
1372
	protected function hashOfFiles($fileList)
1373
	{
1374
		// Get hash based on hash of each file
1375
		$base = Director::baseFolder() . '/';
1376
		$hash = '';
1377
		foreach ($fileList as $file) {
1378
			if (file_exists($base . $file)) {
1379
				$hash .= sha1_file($base . $file);
1380
			} else {
1381
				throw new InvalidArgumentException("Combined file {$file} does not exist");
1382
			}
1383
		}
1384
		return sha1($hash);
1385
	}
1386
1387
	/**
1388
	 * Registers the given themeable stylesheet as required.
1389
	 *
1390
	 * A CSS file in the current theme path name 'themename/css/$name.css' is first searched for,
1391
	 * and it that doesn't exist and the module parameter is set then a CSS file with that name in
1392
	 * the module is used.
1393
	 *
1394
	 * @param string $name The name of the file - eg '/css/File.css' would have the name 'File'
1395
	 * @param string $media Comma-separated list of media types to use in the link tag
1396
	 *                       (e.g. 'screen,projector')
1397
	 */
1398
	public function themedCSS($name, $media = null)
1399
	{
1400
		$path = ThemeResourceLoader::instance()->findThemedCSS($name, SSViewer::get_themes());
1401
		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...
1402
			$this->css($path, $media);
1403
		} else {
1404
			throw new \InvalidArgumentException(
1405
				"The css file doesn't exists. Please check if the file $name.css exists in any context or search for "
1406
				. "themedCSS references calling this file in your templates."
1407
			);
1408
		}
1409
	}
1410
1411
	/**
1412
	 * Registers the given themeable javascript as required.
1413
	 *
1414
	 * A javascript file in the current theme path name 'themename/javascript/$name.js' is first searched for,
1415
	 * and it that doesn't exist and the module parameter is set then a javascript file with that name in
1416
	 * the module is used.
1417
	 *
1418
	 * @param string $name The name of the file - eg '/js/File.js' would have the name 'File'
1419
	 * @param string $type Comma-separated list of types to use in the script tag
1420
	 *                       (e.g. 'text/javascript,text/ecmascript')
1421
	 */
1422
	public function themedJavascript($name, $type = null)
1423
	{
1424
		$path = ThemeResourceLoader::instance()->findThemedJavascript($name, SSViewer::get_themes());
1425
		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...
1426
			$opts = [];
1427
			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...
1428
				$opts['type'] = $type;
1429
			}
1430
			$this->javascript($path, $opts);
1431
		} else {
1432
			throw new \InvalidArgumentException(
1433
				"The javascript file doesn't exists. Please check if the file $name.js exists in any "
1434
				. "context or search for themedJavascript references calling this file in your templates."
1435
			);
1436
		}
1437
	}
1438
1439
	/**
1440
	 * Output debugging information.
1441
	 */
1442
	public function debug()
1443
	{
1444
		Debug::show($this->javascript);
1445
		Debug::show($this->css);
1446
		Debug::show($this->customCSS);
1447
		Debug::show($this->customScript);
1448
		Debug::show($this->customHeadTags);
1449
		Debug::show($this->combinedFiles);
1450
	}
1451
1452
}
1453