Completed
Push — master ( 6ccfbb...0bd627 )
by Sam
15:08 queued 03:22
created

Requirements_Backend::getMinifyCombinedJSFiles()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 3
Code Lines 2

Duplication

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

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

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

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

Loading history...
1583
					$newCSS = array(); // Assoc array of css file => spec
1584
					foreach($this->getAllCSS() as $css => $spec) {
1585
						if(!in_array($css, $fileList)) {
1586
							$newCSS[$css] = $spec;
1587
						} 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...
1588
							$newCSS[$combinedURL] = array('media' => $media);
1589
							$included = true;
1590
						}
1591
						// If already included, or otherwise blocked, then don't add into CSS
1592
					}
1593
					$this->css = $newCSS;
1594
					break;
1595
				}
1596
				case 'js': {
0 ignored issues
show
Coding Style introduced by
CASE statements must be defined using a colon

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

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

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

Loading history...
1597
					// Assoc array of file => true
1598
					$newJS = array();
1599
					foreach($this->getAllJavascript() as $script) {
1600
						if(!in_array($script, $fileList)) {
1601
							$newJS[$script] = true;
1602
						} 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...
1603
							$newJS[$combinedURL] = true;
1604
							$included = true;
1605
						}
1606
						// If already included, or otherwise blocked, then don't add into scripts
1607
					}
1608
					$this->javascript = $newJS;
1609
					break;
1610
				}
1611
			}
1612
		}
1613
	}
1614
1615
	/**
1616
	 * Given a set of files, combine them (as necessary) and return the url
1617
	 *
1618
	 * @param string $combinedFile Filename for this combined file
1619
	 * @param array $fileList List of files to combine
1620
	 * @param string $type Either 'js' or 'css'
1621
	 * @return string|null URL to this resource, if there are files to combine
1622
	 */
1623
	protected function getCombinedFileURL($combinedFile, $fileList, $type) {
1624
		// Skip empty lists
1625
		if(empty($fileList)) {
1626
			return null;
1627
		}
1628
1629
		// Generate path (Filename)
1630
		$hashQuerystring = Config::inst()->get(static::class, 'combine_hash_querystring');
1631
		if(!$hashQuerystring) {
1632
			$combinedFile = $this->hashedCombinedFilename($combinedFile, $fileList);
1633
			}
1634
		$combinedFileID =  File::join_paths($this->getCombinedFilesFolder(), $combinedFile);
1635
1636
		// Send file combination request to the backend, with an optional callback to perform regeneration
1637
		$minify = $this->getMinifyCombinedJSFiles();
1638
		$combinedURL = $this
1639
			->getAssetHandler()
1640
			->getContentURL(
1641
				$combinedFileID,
1642
				function() use ($fileList, $minify, $type) {
1643
					// Physically combine all file content
1644
					$combinedData = '';
1645
					$base = Director::baseFolder() . '/';
1646
					$minifier = Injector::inst()->get('Requirements_Minifier');
1647
					foreach($fileList as $file) {
1648
						$fileContent = file_get_contents($base . $file);
1649
						// Use configured minifier
1650
						if($minify) {
1651
							$fileContent = $minifier->minify($fileContent, $type, $file);
1652
			}
1653
1654
						if ($this->writeHeaderComment) {
1655
							// Write a header comment for each file for easier identification and debugging.
1656
							$combinedData .= "/****** FILE: $file *****/\n";
1657
					}
1658
						$combinedData .= $fileContent . "\n";
1659
				}
1660
					return $combinedData;
1661
			}
1662
			);
1663
1664
		// If the name isn't hashed, we will need to append the querystring m= parameter instead
1665
		// Since url won't be automatically suffixed, add it in here
1666
		if($hashQuerystring && $this->getSuffixRequirements()) {
1667
			$hash = $this->hashOfFiles($fileList);
1668
			$q = stripos($combinedURL, '?') === false ? '?' : '&';
1669
			$combinedURL .= "{$q}m={$hash}";
1670
		}
1671
1672
		return $combinedURL;
1673
	}
1674
1675
	/**
1676
	 * Given a filename and list of files, generate a new filename unique to these files
1677
	 *
1678
	 * @param string $combinedFile
1679
	 * @param array $fileList
1680
	 * @return string
1681
	 */
1682
	protected function hashedCombinedFilename($combinedFile, $fileList) {
1683
		$name = pathinfo($combinedFile, PATHINFO_FILENAME);
1684
		$hash = $this->hashOfFiles($fileList);
1685
		$extension = File::get_file_extension($combinedFile);
1686
		return $name . '-' . substr($hash, 0, 7) . '.' . $extension;
1687
	}
1688
1689
	/**
1690
	 * Check if combined files are enabled
1691
	 *
1692
	 * @return bool
1693
	 */
1694
	public function getCombinedFilesEnabled() {
0 ignored issues
show
Coding Style introduced by
getCombinedFilesEnabled uses the super-global variable $_REQUEST which is generally not recommended.

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

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

// Better
class Router
{
    private $host;

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

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

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

        // Better (assuming you use the Symfony2 request)
        $page = $request->query->get('page', 1);
    }
}
Loading history...
1695
		if(!$this->combinedFilesEnabled) {
1696
			return false;
1697
		}
1698
1699
		// Tests should be combined
1700
		if(class_exists('SapphireTest', false) && SapphireTest::is_running_test()) {
1701
			return true;
1702
		}
1703
1704
		// Check if specified via querystring
1705
		if(isset($_REQUEST['combine'])) {
1706
			return true;
1707
		}
1708
1709
		// Non-dev sites are always combined
1710
		if(!Director::isDev()) {
1711
			return true;
1712
		}
1713
1714
		// Fallback to default
1715
		return Config::inst()->get(__CLASS__, 'combine_in_dev');
1716
	}
1717
1718
	/**
1719
	 * For a given filelist, determine some discriminating value to determine if
1720
	 * any of these files have changed.
1721
	 *
1722
	 * @param array $fileList List of files
1723
	 * @return string SHA1 bashed file hash
1724
	 */
1725
	protected function hashOfFiles($fileList) {
1726
		// Get hash based on hash of each file
1727
		$base = Director::baseFolder() . '/';
1728
		$hash = '';
1729
		foreach($fileList as $file) {
1730
			if(file_exists($base . $file)) {
1731
				$hash .= sha1_file($base . $file);
1732
			} else {
1733
				throw new InvalidArgumentException("Combined file {$file} does not exist");
1734
			}
1735
		}
1736
		return sha1($hash);
1737
	}
1738
1739
	/**
1740
	 * Registers the given themeable stylesheet as required.
1741
	 *
1742
	 * A CSS file in the current theme path name 'themename/css/$name.css' is first searched for,
1743
	 * and it that doesn't exist and the module parameter is set then a CSS file with that name in
1744
	 * the module is used.
1745
	 *
1746
	 * @param string $name   The name of the file - eg '/css/File.css' would have the name 'File'
1747
	 * @param string $module The module to fall back to if the css file does not exist in the
1748
	 *                       current theme.
1749
	 * @param string $media  Comma-separated list of media types to use in the link tag
1750
	 *                       (e.g. 'screen,projector')
1751
	 */
1752
	public function themedCSS($name, $module = null, $media = null) {
1753
		$theme = SSViewer::get_theme_folder();
1754
		$project = project();
1755
		$absbase = BASE_PATH . DIRECTORY_SEPARATOR;
1756
		$abstheme = $absbase . $theme;
1757
		$absproject = $absbase . $project;
1758
		$css = "/css/$name.css";
1759
1760
		if(file_exists($absproject . $css)) {
1761
			$this->css($project . $css, $media);
1762
		} elseif($module && file_exists($abstheme . '_' . $module.$css)) {
0 ignored issues
show
Bug Best Practice introduced by
The expression $module 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...
1763
			$this->css($theme . '_' . $module . $css, $media);
1764
		} elseif(file_exists($abstheme . $css)) {
1765
			$this->css($theme . $css, $media);
1766
		} elseif($module) {
0 ignored issues
show
Bug Best Practice introduced by
The expression $module 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...
1767
			$this->css($module . $css, $media);
1768
		}
1769
	}
1770
1771
	/**
1772
	 * Output debugging information.
1773
	 */
1774
	public function debug() {
1775
		Debug::show($this->javascript);
1776
		Debug::show($this->css);
1777
		Debug::show($this->customCSS);
1778
		Debug::show($this->customScript);
1779
		Debug::show($this->customHeadTags);
1780
		Debug::show($this->combinedFiles);
1781
	}
1782
1783
}
1784
1785
/**
1786
 * Provides an abstract interface for minifying content
1787
 */
1788
interface Requirements_Minifier {
1789
1790
	/**
1791
	 * Minify the given content
1792
	 *
1793
	 * @param string $content
1794
	 * @param string $type Either js or css
1795
	 * @param string $filename Name of file to display in case of error
1796
	 * @return string minified content
1797
	 */
1798
	public function minify($content, $type, $filename);
1799
}
1800