Completed
Push — master ( e67db6...a9efe4 )
by Damian
10:26
created

Requirements_Backend::includeInResponse()   C

Complexity

Conditions 8
Paths 48

Size

Total Lines 28
Code Lines 17

Duplication

Lines 0
Ratio 0 %
Metric Value
dl 0
loc 28
rs 5.3846
cc 8
eloc 17
nc 48
nop 1
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
	 */
114
	public static function javascript($file) {
115
		self::backend()->javascript($file);
116
	}
117
118
	/**
119
	 * Register the given JavaScript code into the list of requirements
120
	 *
121
	 * @param string     $script       The script content as a string (without enclosing <script> tag)
122
	 * @param string|int $uniquenessID A unique ID that ensures a piece of code is only added once
123
	 */
124
	public static function customScript($script, $uniquenessID = null) {
125
		self::backend()->customScript($script, $uniquenessID);
126
	}
127
128
	/**
129
	 * Return all registered custom scripts
130
	 *
131
	 * @return array
132
	 */
133
	public static function get_custom_scripts() {
134
		return self::backend()->getCustomScripts();
135
	}
136
137
	/**
138
	 * Register the given CSS styles into the list of requirements
139
	 *
140
	 * @param string     $script       CSS selectors as a string (without enclosing <style> tag)
141
	 * @param string|int $uniquenessID A unique ID that ensures a piece of code is only added once
142
	 */
143
	public static function customCSS($script, $uniquenessID = null) {
144
		self::backend()->customCSS($script, $uniquenessID);
145
	}
146
147
	/**
148
	 * Add the following custom HTML code to the <head> section of the page
149
	 *
150
	 * @param string     $html         Custom HTML code
151
	 * @param string|int $uniquenessID A unique ID that ensures a piece of code is only added once
152
	 */
153
	public static function insertHeadTags($html, $uniquenessID = null) {
154
		self::backend()->insertHeadTags($html, $uniquenessID);
155
	}
156
157
	/**
158
	 * Include the content of the given JavaScript file in the list of requirements. Dollar-sign
159
	 * variables will be interpolated with values from $vars similar to a .ss template.
160
	 *
161
	 * @param string         $file         The template file to load, relative to docroot
162
	 * @param string[]|int[] $vars         The array of variables to interpolate.
163
	 * @param string|int     $uniquenessID A unique ID that ensures a piece of code is only added once
164
	 */
165
	public static function javascriptTemplate($file, $vars, $uniquenessID = null) {
166
		self::backend()->javascriptTemplate($file, $vars, $uniquenessID);
167
	}
168
169
	/**
170
	 * Register the given stylesheet into the list of requirements.
171
	 *
172
	 * @param string $file  The CSS file to load, relative to site root
173
	 * @param string $media Comma-separated list of media types to use in the link tag
174
	 *                      (e.g. 'screen,projector')
175
	 */
176
	public static function css($file, $media = null) {
177
		self::backend()->css($file, $media);
178
	}
179
180
	/**
181
	 * Registers the given themeable stylesheet as required.
182
	 *
183
	 * A CSS file in the current theme path name 'themename/css/$name.css' is first searched for,
184
	 * and it that doesn't exist and the module parameter is set then a CSS file with that name in
185
	 * the module is used.
186
	 *
187
	 * @param string $name   The name of the file - eg '/css/File.css' would have the name 'File'
188
	 * @param string $module The module to fall back to if the css file does not exist in the
189
	 *                       current theme.
190
	 * @param string $media  Comma-separated list of media types to use in the link tag
191
	 *                       (e.g. 'screen,projector')
192
	 */
193
	public static function themedCSS($name, $module = null, $media = null) {
194
		return self::backend()->themedCSS($name, $module, $media);
195
	}
196
197
	/**
198
	 * Clear either a single or all requirements
199
	 *
200
	 * Caution: Clearing single rules added via customCSS and customScript only works if you
201
	 * originally specified a $uniquenessID.
202
	 *
203
	 * @param string|int $fileOrID
204
	 */
205
	public static function clear($fileOrID = null) {
206
		self::backend()->clear($fileOrID);
207
	}
208
209
	/**
210
	 * Restore requirements cleared by call to Requirements::clear
211
	 */
212
	public static function restore() {
213
		self::backend()->restore();
214
	}
215
216
	/**
217
	 * Block inclusion of a specific file
218
	 *
219
	 * The difference between this and {@link clear} is that the calling order does not matter;
220
	 * {@link clear} must be called after the initial registration, whereas {@link block} can be
221
	 * used in advance. This is useful, for example, to block scripts included by a superclass
222
	 * without having to override entire functions and duplicate a lot of code.
223
	 *
224
	 * Note that blocking should be used sparingly because it's hard to trace where an file is
225
	 * being blocked from.
226
	 *
227
	 * @param string|int $fileOrID
228
	 */
229
	public static function block($fileOrID) {
230
		self::backend()->block($fileOrID);
231
	}
232
233
	/**
234
	 * Remove an item from the block list
235
	 *
236
	 * @param string|int $fileOrID
237
	 */
238
	public static function unblock($fileOrID) {
239
		self::backend()->unblock($fileOrID);
240
	}
241
242
	/**
243
	 * Removes all items from the block list
244
	 */
245
	public static function unblock_all() {
246
		self::backend()->unblockAll();
247
	}
248
249
	/**
250
	 * Update the given HTML content with the appropriate include tags for the registered
251
	 * requirements. Needs to receive a valid HTML/XHTML template in the $content parameter,
252
	 * including a head and body tag.
253
	 *
254
	 * @param string $templateFile No longer used, only retained for compatibility
255
	 * @param string $content      HTML content that has already been parsed from the $templateFile
256
	 *                             through {@link SSViewer}
257
	 * @return string HTML content augmented with the requirements tags
258
	 */
259
	public static function includeInHTML($templateFile, $content) {
260
		return self::backend()->includeInHTML($templateFile, $content);
261
	}
262
263
	/**
264
	 * Attach requirements inclusion to X-Include-JS and X-Include-CSS headers on the given
265
	 * HTTP Response
266
	 *
267
	 * @param SS_HTTPResponse $response
268
	 */
269
	public static function include_in_response(SS_HTTPResponse $response) {
270
		return self::backend()->includeInResponse($response);
271
	}
272
273
	/**
274
	 * Add i18n files from the given javascript directory. SilverStripe expects that the given
275
	 * directory will contain a number of JavaScript files named by language: en_US.js, de_DE.js,
276
	 * etc.
277
	 *
278
	 * @param string $langDir  The JavaScript lang directory, relative to the site root, e.g.,
279
	 *                         'framework/javascript/lang'
280
	 * @param bool   $return   Return all relative file paths rather than including them in
281
	 *                         requirements
282
	 * @param bool   $langOnly Only include language files, not the base libraries
283
	 *
284
	 * @return array
285
	 */
286
	public static function add_i18n_javascript($langDir, $return = false, $langOnly = false) {
287
		return self::backend()->add_i18n_javascript($langDir, $return, $langOnly);
288
	}
289
290
	/**
291
	 * Concatenate several css or javascript files into a single dynamically generated file. This
292
	 * increases performance by fewer HTTP requests.
293
	 *
294
	 * The combined file is regenerated based on every file modification time. Optionally a
295
	 * rebuild can be triggered by appending ?flush=1 to the URL.
296
	 *
297
	 * All combined files will have a comment on the start of each concatenated file denoting their
298
	 * original position.
299
	 *
300
	 * CAUTION: You're responsible for ensuring that the load order for combined files is
301
	 * retained - otherwise combining JavaScript files can lead to functional errors in the
302
	 * JavaScript logic, and combining CSS can lead to incorrect inheritance. You can also
303
	 * only include each file once across all includes and comibinations in a single page load.
304
	 *
305
	 * CAUTION: Combining CSS Files discards any "media" information.
306
	 *
307
	 * Example for combined JavaScript:
308
	 * <code>
309
	 * Requirements::combine_files(
310
	 *  'foobar.js',
311
	 *  array(
312
	 *        'mysite/javascript/foo.js',
313
	 *        'mysite/javascript/bar.js',
314
	 *    )
315
	 * );
316
	 * </code>
317
	 *
318
	 * Example for combined CSS:
319
	 * <code>
320
	 * Requirements::combine_files(
321
	 *  'foobar.css',
322
	 *    array(
323
	 *        'mysite/javascript/foo.css',
324
	 *        'mysite/javascript/bar.css',
325
	 *    )
326
	 * );
327
	 * </code>
328
	 *
329
	 * @param string $combinedFileName Filename of the combined file relative to docroot
330
	 * @param array  $files            Array of filenames relative to docroot
331
	 * @param string $media
332
	 *
333
	 * @return bool|void
334
	 */
335
	public static function combine_files($combinedFileName, $files, $media = null) {
336
		self::backend()->combineFiles($combinedFileName, $files, $media);
337
	}
338
339
	/**
340
	 * Return all combined files; keys are the combined file names, values are lists of
341
	 * associative arrays with 'files', 'type', and 'media' keys for details about this
342
	 * combined file.
343
	 *
344
	 * @return array
345
	 */
346
	public static function get_combine_files() {
347
		return self::backend()->getCombinedFiles();
348
	}
349
350
	/**
351
	 * Deletes all generated combined files in the configured combined files directory,
352
	 * but doesn't delete the directory itself
353
	 */
354
	public static function delete_all_combined_files() {
355
		return self::backend()->deleteAllCombinedFiles();
356
	}
357
358
	/**
359
	 * Re-sets the combined files definition. See {@link Requirements_Backend::clear_combined_files()}
360
	 */
361
	public static function clear_combined_files() {
362
		self::backend()->clearCombinedFiles();
363
	}
364
365
	/**
366
	 * Do the heavy lifting involved in combining the combined files.
367
 	 */
368
	public static function process_combined_files() {
369
		return self::backend()->processCombinedFiles();
370
	}
371
372
	/**
373
	 * Set whether you want to write the JS to the body of the page rather than at the end of the
374
	 * head tag.
375
	 *
376
	 * @return bool
377
	 */
378
	public static function get_write_js_to_body() {
379
		return self::backend()->getWriteJavascriptToBody();
380
	}
381
382
	/**
383
	 * Set whether you want to write the JS to the body of the page rather than at the end of the
384
	 * head tag.
385
	 *
386
	 * @param bool
387
	 */
388
	public static function set_write_js_to_body($var) {
389
		self::backend()->setWriteJavascriptToBody($var);
390
	}
391
392
	/**
393
	 * Get whether to force the JavaScript to end of the body. Useful if you use inline script tags
394
	 * that don't rely on scripts included via {@link Requirements::javascript()).
395
	 *
396
	 * @return bool
397
	 */
398
	public static function get_force_js_to_bottom() {
399
		return self::backend()->getForceJSToBottom();
400
	}
401
402
	/**
403
	 * Set whether to force the JavaScript to end of the body. Useful if you use inline script tags
404
	 * that don't rely on scripts included via {@link Requirements::javascript()).
405
	 *
406
	 * @param bool $var If true, force the JavaScript to be included at the bottom of the page
407
	 */
408
	public static function set_force_js_to_bottom($var) {
409
		self::backend()->setForceJSToBottom($var);
410
	}
411
412
	/**
413
	 * Check if JS minification is enabled
414
	 *
415
	 * @return bool
416
	 */
417
	public static function get_minify_combined_js_files() {
418
		return self::backend()->getMinifyCombinedJSFiles();
419
	}
420
421
	/**
422
	 * Enable or disable js minification
423
	 *
424
	 * @param bool $minify
425
	 */
426
	public static function set_minify_combined_js_files($minify) {
427
		self::backend()->setMinifyCombinedJSFiles($minify);
428
	}
429
430
	/**
431
	 * Check if header comments are written
432
	 *
433
	 * @return bool
434
	 */
435
	public static function get_write_header_comments() {
436
		return self::backend()->getWriteHeaderComment();
437
	}
438
439
	/**
440
	 * Flag whether header comments should be written for each combined file
441
	 *
442
	 * @param bool $write
443
	 */
444
	public function set_write_header_comments($write) {
445
		self::backend()->setWriteHeaderComment($write);
446
	}
447
448
449
	/**
450
	 * Output debugging information
451
	 */
452
	public static function debug() {
453
		return self::backend()->debug();
454
	}
455
456
}
457
458
/**
459
 * @package framework
460
 * @subpackage view
461
 */
462
class Requirements_Backend
463
{
464
465
	/**
466
	 * Whether to add caching query params to the requests for file-based requirements.
467
	 * Eg: themes/myTheme/js/main.js?m=123456789. The parameter is a timestamp generated by
468
	 * filemtime. This has the benefit of allowing the browser to cache the URL infinitely,
469
	 * while automatically busting this cache every time the file is changed.
470
	 *
471
	 * @var bool
472
	 */
473
	protected $suffixRequirements = true;
474
475
	/**
476
	 * Whether to combine CSS and JavaScript files
477
	 *
478
	 * @var bool
479
	 */
480
	protected $combinedFilesEnabled = true;
481
482
	/**
483
	 * Determine if files should be combined automatically on dev mode.
484
	 *
485
	 * By default combined files will not be combined except in test or
486
	 * live environments. Turning this on will allow for pre-combining of files in development mode.
487
	 *
488
	 * @config
489
	 * @var bool
490
	 */
491
	private static $combine_in_dev = false;
492
493
	/**
494
	 * Paths to all required JavaScript files relative to docroot
495
	 *
496
	 * @var array
497
	 */
498
	protected $javascript = array();
499
500
	/**
501
	 * Paths to all required CSS files relative to the docroot.
502
	 *
503
	 * @var array
504
	 */
505
	protected $css = array();
506
507
	/**
508
	 * All custom javascript code that is inserted into the page's HTML
509
	 *
510
	 * @var array
511
	 */
512
	protected $customScript = array();
513
514
	/**
515
	 * All custom CSS rules which are inserted directly at the bottom of the HTML <head> tag
516
	 *
517
	 * @var array
518
	 */
519
	protected $customCSS = array();
520
521
	/**
522
	 * All custom HTML markup which is added before the closing <head> tag, e.g. additional
523
	 * metatags.
524
	 *
525
	 * @var array
526
	 */
527
	protected $customHeadTags = array();
528
529
	/**
530
	 * Remembers the file paths or uniquenessIDs of all Requirements cleared through
531
	 * {@link clear()}, so that they can be restored later.
532
	 *
533
	 * @var array
534
	 */
535
	protected $disabled = array();
536
537
	/**
538
	 * The file paths (relative to docroot) or uniquenessIDs of any included requirements which
539
	 * should be blocked when executing {@link inlcudeInHTML()}. This is useful, for example,
540
	 * to block scripts included by a superclass without having to override entire functions and
541
	 * duplicate a lot of code.
542
	 *
543
	 * Use {@link unblock()} or {@link unblock_all()} to revert changes.
544
	 *
545
	 * @var array
546
	 */
547
	protected $blocked = array();
548
549
	/**
550
	 * A list of combined files registered via {@link combine_files()}. Keys are the output file
551
	 * names, values are lists of input files.
552
	 *
553
	 * @var array
554
	 */
555
	protected $combinedFiles = array();
556
557
	/**
558
	 * Use the JSMin library to minify any javascript file passed to {@link combine_files()}.
559
	 *
560
	 * @var bool
561
	 */
562
	protected $minifyCombinedJSFiles = true;
563
564
	/**
565
	 * Whether or not file headers should be written when combining files
566
	 *
567
	 * @var boolean
568
	 */
569
	protected $writeHeaderComment = true;
570
571
	/**
572
	 * Where to save combined files. By default they're placed in assets/_combinedfiles, however
573
	 * this may be an issue depending on your setup, especially for CSS files which often contain
574
	 * relative paths.
575
	 *
576
	 * @var string
577
	 */
578
	protected $combinedFilesFolder = null;
579
580
	/**
581
	 * Put all JavaScript includes at the bottom of the template before the closing <body> tag,
582
	 * rather than the default behaviour of placing them at the end of the <head> tag. This means
583
	 * script downloads won't block other HTTP requests, which can be a performance improvement.
584
	 *
585
	 * @var bool
586
	 */
587
	public $writeJavascriptToBody = true;
588
589
	/**
590
	 * Force the JavaScript to the bottom of the page, even if there's a script tag in the body already
591
	 *
592
	 * @var boolean
593
	 */
594
	protected $forceJSToBottom = false;
595
596
	/**
597
	 * Configures the default prefix for combined files.
598
	 *
599
	 * This defaults to `_combinedfiles`, and is the folder within the configured asset backend that
600
	 * combined files will be stored in. If using a backend shared with other systems, it is usually
601
	 * necessary to distinguish combined files from other assets.
602
	 *
603
	 * @config
604
	 * @var string
605
	 */
606
	private static $default_combined_files_folder = '_combinedfiles';
607
608
	/**
609
	 * Flag to include the hash in the querystring instead of the filename for combined files.
610
	 *
611
	 * By default the `<hash>` of the source files is appended to the end of the combined file
612
	 * (prior to the file extension). If combined files are versioned in source control or running
613
	 * in a distributed environment (such as one where the newest version of a file may not always be
614
	 * immediately available) then it may sometimes be necessary to disable this. When this is set to true,
615
	 * the hash will instead be appended via a querystring parameter to enable cache busting, but not in
616
	 * the filename itself. I.e. `assets/_combinedfiles/name.js?m=<hash>`
617
	 *
618
	 * @config
619
	 * @var bool
620
	 */
621
	private static $combine_hash_querystring = false;
622
623
	/**
624
	 * @var GeneratedAssetHandler
625
	 */
626
	protected $assetHandler = null;
627
628
	/**
629
	 * Gets the backend storage for generated files
630
	 *
631
	 * @return GeneratedAssetHandler
632
	 */
633
	public function getAssetHandler() {
634
		return $this->assetHandler;
635
	}
636
637
	/**
638
	 * Set a new asset handler for this backend
639
	 *
640
	 * @param GeneratedAssetHandler $handler
641
	 */
642
	public function setAssetHandler(GeneratedAssetHandler $handler) {
643
		$this->assetHandler = $handler;
644
	}
645
646
	/**
647
	 * Enable or disable the combination of CSS and JavaScript files
648
	 *
649
	 * @param bool $enable
650
	 */
651
	public function setCombinedFilesEnabled($enable) {
652
		$this->combinedFilesEnabled = (bool) $enable;
653
	}
654
655
	/**
656
	 * Check if header comments are written
657
	 *
658
	 * @return bool
659
	 */
660
	public function getWriteHeaderComment() {
661
		return $this->writeHeaderComment;
662
	}
663
664
	/**
665
	 * Flag whether header comments should be written for each combined file
666
	 *
667
	 * @param bool $write
668
	 * @return $this
669
	 */
670
	public function setWriteHeaderComment($write) {
671
		$this->writeHeaderComment = $write;
672
		return $this;
673
	}
674
675
	/**
676
	 * Set the folder to save combined files in. By default they're placed in _combinedfiles,
677
	 * however this may be an issue depending on your setup, especially for CSS files which often
678
	 * contain relative paths.
679
	 *
680
	 * This must not include any 'assets' prefix
681
	 *
682
	 * @param string $folder
683
	 */
684
	public function setCombinedFilesFolder($folder) {
685
		$this->combinedFilesFolder = $folder;
686
	}
687
688
	/**
689
	 * Retrieve the combined files folder prefix
690
	 *
691
	 * @return string
692
	 */
693
	public function getCombinedFilesFolder() {
694
		if($this->combinedFilesFolder) {
695
			return $this->combinedFilesFolder;
696
		}
697
		return Config::inst()->get(__CLASS__, 'default_combined_files_folder');
698
	}
699
700
	/**
701
	 * Set whether to add caching query params to the requests for file-based requirements.
702
	 * Eg: themes/myTheme/js/main.js?m=123456789. The parameter is a timestamp generated by
703
	 * filemtime. This has the benefit of allowing the browser to cache the URL infinitely,
704
	 * while automatically busting this cache every time the file is changed.
705
	 *
706
	 * @param bool
707
	 */
708
	public function setSuffixRequirements($var) {
709
		$this->suffixRequirements = $var;
710
	}
711
712
	/**
713
	 * Check whether we want to suffix requirements
714
	 *
715
	 * @return bool
716
	 */
717
	public function getSuffixRequirements() {
718
		return $this->suffixRequirements;
719
	}
720
721
	/**
722
	 * Set whether you want to write the JS to the body of the page rather than at the end of the
723
	 * head tag.
724
	 *
725
	 * @param bool
726
	 * @return $this
727
	 */
728
	public function setWriteJavascriptToBody($var) {
729
		$this->writeJavascriptToBody = $var;
730
		return $this;
731
	}
732
733
	/**
734
	 * Check whether you want to write the JS to the body of the page rather than at the end of the
735
	 * head tag.
736
	 *
737
	 * @return bool
738
	 */
739
	public function getWriteJavascriptToBody() {
740
		return $this->writeJavascriptToBody;
741
	}
742
743
	/**
744
	 * Forces the JavaScript requirements to the end of the body, right before the closing tag
745
	 *
746
	 * @param bool
747
	 * @return $this
748
	 */
749
	public function setForceJSToBottom($var) {
750
		$this->forceJSToBottom = $var;
751
		return $this;
752
	}
753
754
	/**
755
	 * Check if the JavaScript requirements are written to the end of the body, right before the closing tag
756
	 *
757
	 * @return bool
758
	 */
759
	public function getForceJSToBottom() {
760
		return $this->forceJSToBottom;
761
	}
762
763
	/**
764
	 * Check if minify js files should be combined
765
	 *
766
	 * @return bool
767
	 */
768
	public function getMinifyCombinedJSFiles() {
769
		return $this->minifyCombinedJSFiles;
770
	}
771
772
	/**
773
	 * Set if combined js files should be minified
774
	 *
775
	 * @param bool $minify
776
	 * @return $this
777
	 */
778
	public function setMinifyCombinedJSFiles($minify) {
779
		$this->minifyCombinedJSFiles = $minify;
780
		return $this;
781
	}
782
783
	/**
784
	 * Register the given JavaScript file as required.
785
	 *
786
	 * @param string $file Relative to docroot
787
	 */
788
	public function javascript($file) {
789
		$this->javascript[$file] = true;
790
	}
791
792
	/**
793
	 * Remove a javascript requirement
794
	 *
795
	 * @param string $file
796
	 */
797
	protected function unsetJavascript($file) {
798
		unset($this->javascript[$file]);
799
	}
800
801
	/**
802
	 * Returns an array of required JavaScript, excluding blocked
803
	 *
804
	 * @return array
805
	 */
806
	public function getJavascript() {
807
		return array_keys(array_diff_key($this->javascript, $this->blocked));
808
	}
809
810
	/**
811
	 * Gets all javascript, including blocked files. Unwraps the array into a non-associative list
812
	 *
813
	 * @return array Indexed array of javascript files
814
	 */
815
	protected function getAllJavascript() {
816
		return array_keys($this->javascript);
817
	}
818
819
	/**
820
	 * Register the given JavaScript code into the list of requirements
821
	 *
822
	 * @param string $script The script content as a string (without enclosing <script> tag)
823
	 * @param string $uniquenessID A unique ID that ensures a piece of code is only added once
824
	 */
825
	public function customScript($script, $uniquenessID = null) {
826
		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...
827
			$this->customScript[$uniquenessID] = $script;
828
		} else {
829
			$this->customScript[] = $script;
830
		}
831
832
		$script .= "\n";
833
	}
834
835
	/**
836
	 * Return all registered custom scripts
837
	 *
838
	 * @return array
839
	 */
840
	public function getCustomScripts() {
841
		return array_diff_key($this->customScript, $this->blocked);
842
	}
843
844
	/**
845
	 * Register the given CSS styles into the list of requirements
846
	 *
847
	 * @param string $script CSS selectors as a string (without enclosing <style> tag)
848
	 * @param string $uniquenessID A unique ID that ensures a piece of code is only added once
849
	 */
850
	public function customCSS($script, $uniquenessID = null) {
851
		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...
852
			$this->customCSS[$uniquenessID] = $script;
853
		} else {
854
			$this->customCSS[] = $script;
855
		}
856
	}
857
858
	/**
859
	 * Return all registered custom CSS
860
	 *
861
	 * @return array
862
	 */
863
	public function getCustomCSS() {
864
		return array_diff_key($this->customCSS, $this->blocked);
865
	}
866
867
	/**
868
	 * Add the following custom HTML code to the <head> section of the page
869
	 *
870
	 * @param string $html Custom HTML code
871
	 * @param string $uniquenessID A unique ID that ensures a piece of code is only added once
872
	 */
873
	public function insertHeadTags($html, $uniquenessID = null) {
874
		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...
875
			$this->customHeadTags[$uniquenessID] = $html;
876
		} else {
877
			$this->customHeadTags[] = $html;
878
		}
879
	}
880
881
	/**
882
	 * Return all custom head tags
883
	 *
884
	 * @return array
885
	 */
886
	public function getCustomHeadTags() {
887
		return array_diff_key($this->customHeadTags, $this->blocked);
888
	}
889
890
	/**
891
	 * Include the content of the given JavaScript file in the list of requirements. Dollar-sign
892
	 * variables will be interpolated with values from $vars similar to a .ss template.
893
	 *
894
	 * @param string $file The template file to load, relative to docroot
895
	 * @param string[] $vars The array of variables to interpolate.
896
	 * @param string $uniquenessID A unique ID that ensures a piece of code is only added once
897
	 */
898
	public function javascriptTemplate($file, $vars, $uniquenessID = null) {
899
		$script = file_get_contents(Director::getAbsFile($file));
900
		$search = array();
901
		$replace = array();
902
903
		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...
904
			$search[] = '$' . $k;
905
			$replace[] = str_replace("\\'","'", Convert::raw2js($v));
906
		}
907
908
		$script = str_replace($search, $replace, $script);
909
		$this->customScript($script, $uniquenessID);
910
	}
911
912
	/**
913
	 * Register the given stylesheet into the list of requirements.
914
	 *
915
	 * @param string $file The CSS file to load, relative to site root
916
	 * @param string $media Comma-separated list of media types to use in the link tag
917
	 *                      (e.g. 'screen,projector')
918
	 */
919
	public function css($file, $media = null) {
920
		$this->css[$file] = array(
921
			"media" => $media
922
		);
923
	}
924
925
	/**
926
	 * Remove a css requirement
927
	 *
928
	 * @param string $file
929
	 */
930
	protected function unsetCSS($file) {
931
		unset($this->css[$file]);
932
	}
933
934
	/**
935
	 * Get the list of registered CSS file requirements, excluding blocked files
936
	 *
937
	 * @return array Associative array of file to spec
938
	 */
939
	public function getCSS() {
940
		return array_diff_key($this->css, $this->blocked);
941
	}
942
943
	/**
944
	 * Gets all CSS files requirements, including blocked
945
	 *
946
	 * @return array Associative array of file to spec
947
	 */
948
	protected function getAllCSS() {
949
		return $this->css;
950
	}
951
952
	/**
953
	 * Gets the list of all blocked files
954
	 *
955
	 * @return array
956
	 */
957
	public function getBlocked() {
958
		return $this->blocked;
959
	}
960
961
	/**
962
	 * Clear either a single or all requirements
963
	 *
964
	 * Caution: Clearing single rules added via customCSS and customScript only works if you
965
	 * originally specified a $uniquenessID.
966
	 *
967
	 * @param string|int $fileOrID
968
	 */
969
	public function clear($fileOrID = null) {
970
		if($fileOrID) {
971
			foreach(array('javascript','css', 'customScript', 'customCSS', 'customHeadTags') as $type) {
972
				if(isset($this->{$type}[$fileOrID])) {
973
					$this->disabled[$type][$fileOrID] = $this->{$type}[$fileOrID];
974
					unset($this->{$type}[$fileOrID]);
975
				}
976
			}
977
		} else {
978
			$this->disabled['javascript'] = $this->javascript;
979
			$this->disabled['css'] = $this->css;
980
			$this->disabled['customScript'] = $this->customScript;
981
			$this->disabled['customCSS'] = $this->customCSS;
982
			$this->disabled['customHeadTags'] = $this->customHeadTags;
983
984
			$this->javascript = array();
985
			$this->css = array();
986
			$this->customScript = array();
987
			$this->customCSS = array();
988
			$this->customHeadTags = array();
989
		}
990
	}
991
992
	/**
993
	 * Restore requirements cleared by call to Requirements::clear
994
	 */
995
	public function restore() {
996
		$this->javascript = $this->disabled['javascript'];
997
		$this->css = $this->disabled['css'];
998
		$this->customScript = $this->disabled['customScript'];
999
		$this->customCSS = $this->disabled['customCSS'];
1000
		$this->customHeadTags = $this->disabled['customHeadTags'];
1001
	}
1002
1003
	/**
1004
	 * Block inclusion of a specific file
1005
	 *
1006
	 * The difference between this and {@link clear} is that the calling order does not matter;
1007
	 * {@link clear} must be called after the initial registration, whereas {@link block} can be
1008
	 * used in advance. This is useful, for example, to block scripts included by a superclass
1009
	 * without having to override entire functions and duplicate a lot of code.
1010
	 *
1011
	 * Note that blocking should be used sparingly because it's hard to trace where an file is
1012
	 * being blocked from.
1013
	 *
1014
	 * @param string|int $fileOrID
1015
	 */
1016
	public function block($fileOrID) {
1017
		$this->blocked[$fileOrID] = $fileOrID;
1018
	}
1019
1020
	/**
1021
	 * Remove an item from the block list
1022
	 *
1023
	 * @param string|int $fileOrID
1024
	 */
1025
	public function unblock($fileOrID) {
1026
		unset($this->blocked[$fileOrID]);
1027
	}
1028
1029
	/**
1030
	 * Removes all items from the block list
1031
	 */
1032
	public function unblockAll() {
1033
		$this->blocked = array();
1034
	}
1035
1036
	/**
1037
	 * Update the given HTML content with the appropriate include tags for the registered
1038
	 * requirements. Needs to receive a valid HTML/XHTML template in the $content parameter,
1039
	 * including a head and body tag.
1040
	 *
1041
	 * @param string $templateFile No longer used, only retained for compatibility
1042
	 * @param string $content      HTML content that has already been parsed from the $templateFile
1043
	 *                             through {@link SSViewer}
1044
	 * @return string HTML content augmented with the requirements tags
1045
	 */
1046
	public function includeInHTML($templateFile, $content) {
0 ignored issues
show
Unused Code introduced by
The parameter $templateFile is not used and could be removed.

This check looks from parameters that have been defined for a function or method, but which are not used in the method body.

Loading history...
1047
		if(
1048
			(strpos($content, '</head>') !== false || strpos($content, '</head ') !== false)
1049
			&& ($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...
1050
		) {
1051
			$requirements = '';
1052
			$jsRequirements = '';
1053
1054
			// Combine files - updates $this->javascript and $this->css
1055
			$this->processCombinedFiles();
1056
1057
			foreach($this->getJavascript() as $file) {
1058
				$path = Convert::raw2xml($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::raw2xml() 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...
1059
				if($path) {
1060
					$jsRequirements .= "<script type=\"text/javascript\" src=\"$path\"></script>\n";
1061
				}
1062
			}
1063
1064
			// Add all inline JavaScript *after* including external files they might rely on
1065
			foreach($this->getCustomScripts() as $script) {
1066
				$jsRequirements .= "<script type=\"text/javascript\">\n//<![CDATA[\n";
1067
				$jsRequirements .= "$script\n";
1068
				$jsRequirements .= "\n//]]>\n</script>\n";
1069
			}
1070
1071
			foreach($this->getCSS() as $file => $params) {
1072
				$path = Convert::raw2xml($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::raw2xml() 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...
1073
				if($path) {
1074
					$media = (isset($params['media']) && !empty($params['media']))
1075
						? " media=\"{$params['media']}\"" : "";
1076
					$requirements .= "<link rel=\"stylesheet\" type=\"text/css\"{$media} href=\"$path\" />\n";
1077
				}
1078
			}
1079
1080
			foreach($this->getCustomCSS() as $css) {
1081
				$requirements .= "<style type=\"text/css\">\n$css\n</style>\n";
1082
			}
1083
1084
			foreach($this->getCustomHeadTags() as $customHeadTag) {
1085
				$requirements .= "$customHeadTag\n";
1086
			}
1087
1088
			if ($this->getForceJSToBottom()) {
1089
				// Remove all newlines from code to preserve layout
1090
				$jsRequirements = preg_replace('/>\n*/', '>', $jsRequirements);
1091
1092
				// Forcefully put the scripts at the bottom of the body instead of before the first
1093
				// script tag.
1094
				$content = preg_replace("/(<\/body[^>]*>)/i", $jsRequirements . "\\1", $content);
1095
1096
				// Put CSS at the bottom of the head
1097
				$content = preg_replace("/(<\/head>)/i", $requirements . "\\1", $content);
1098
			} elseif($this->getWriteJavascriptToBody()) {
1099
				// Remove all newlines from code to preserve layout
1100
				$jsRequirements = preg_replace('/>\n*/', '>', $jsRequirements);
1101
1102
				// If your template already has script tags in the body, then we try to put our script
1103
				// tags just before those. Otherwise, we put it at the bottom.
1104
				$p2 = stripos($content, '<body');
1105
				$p1 = stripos($content, '<script', $p2);
1106
1107
				$commentTags = array();
1108
				$canWriteToBody = ($p1 !== false)
1109
					&&
1110
					// Check that the script tag is not inside a html comment tag
1111
					!(
1112
						preg_match('/.*(?|(<!--)|(-->))/U', $content, $commentTags, 0, $p1)
1113
						&&
1114
						$commentTags[1] == '-->'
1115
					);
1116
1117
				if($canWriteToBody) {
1118
					$content = substr($content,0,$p1) . $jsRequirements . substr($content,$p1);
1119
				} else {
1120
					$content = preg_replace("/(<\/body[^>]*>)/i", $jsRequirements . "\\1", $content);
1121
				}
1122
1123
				// Put CSS at the bottom of the head
1124
				$content = preg_replace("/(<\/head>)/i", $requirements . "\\1", $content);
1125
			} else {
1126
				$content = preg_replace("/(<\/head>)/i", $requirements . "\\1", $content);
1127
				$content = preg_replace("/(<\/head>)/i", $jsRequirements . "\\1", $content);
1128
			}
1129
		}
1130
1131
		return $content;
1132
	}
1133
1134
	/**
1135
	 * Attach requirements inclusion to X-Include-JS and X-Include-CSS headers on the given
1136
	 * HTTP Response
1137
	 *
1138
	 * @param SS_HTTPResponse $response
1139
	 */
1140
	public function includeInResponse(SS_HTTPResponse $response) {
1141
		$this->processCombinedFiles();
1142
		$jsRequirements = array();
1143
		$cssRequirements = array();
1144
1145
		foreach($this->getJavascript() as $file) {
1146
			$path = $this->pathForFile($file);
1147
			if($path) {
1148
				$jsRequirements[] = str_replace(',', '%2C', $path);
1149
			}
1150
		}
1151
		
1152
		if(count($jsRequirements)) {
1153
			$response->addHeader('X-Include-JS', implode(',', $jsRequirements));
1154
		}
1155
1156
		foreach($this->getCSS() as $file => $params) {
1157
			$path = $this->pathForFile($file);
1158
			if($path) {
1159
				$path = str_replace(',', '%2C', $path);
1160
				$cssRequirements[] = isset($params['media']) ? "$path:##:$params[media]" : $path;
1161
			}
1162
		}
1163
1164
		if(count($cssRequirements)) {
1165
			$response->addHeader('X-Include-CSS', implode(',', $cssRequirements));
1166
		}
1167
	}
1168
1169
	/**
1170
	 * Add i18n files from the given javascript directory. SilverStripe expects that the given
1171
	 * directory will contain a number of JavaScript files named by language: en_US.js, de_DE.js,
1172
	 * etc.
1173
	 *
1174
	 * @param string $langDir  The JavaScript lang directory, relative to the site root, e.g.,
1175
	 *                         'framework/javascript/lang'
1176
	 * @param bool   $return   Return all relative file paths rather than including them in
1177
	 *                         requirements
1178
	 * @param bool   $langOnly Only include language files, not the base libraries
1179
	 *
1180
	 * @return array
1181
	 */
1182
	public function add_i18n_javascript($langDir, $return = false, $langOnly = false) {
1183
		$files = array();
1184
		$base = Director::baseFolder() . '/';
1185
		if(i18n::config()->js_i18n) {
1186
			// Include i18n.js even if no languages are found.  The fact that
1187
			// add_i18n_javascript() was called indicates that the methods in
1188
			// here are needed.
1189
			if(!$langOnly) $files[] = FRAMEWORK_DIR . '/javascript/i18n.js';
1190
1191
			if(substr($langDir,-1) != '/') $langDir .= '/';
1192
1193
			$candidates = array(
1194
				'en.js',
1195
				'en_US.js',
1196
				i18n::get_lang_from_locale(i18n::default_locale()) . '.js',
1197
				i18n::default_locale() . '.js',
1198
				i18n::get_lang_from_locale(i18n::get_locale()) . '.js',
1199
				i18n::get_locale() . '.js',
1200
			);
1201
			foreach($candidates as $candidate) {
1202
				if(file_exists($base . DIRECTORY_SEPARATOR . $langDir . $candidate)) {
1203
					$files[] = $langDir . $candidate;
1204
				}
1205
			}
1206
		} else {
1207
			// Stub i18n implementation for when i18n is disabled.
1208
			if(!$langOnly) {
1209
				$files[] = FRAMEWORK_DIR . '/javascript/i18nx.js';
1210
			}
1211
		}
1212
1213
		if($return) {
1214
			return $files;
1215
		} else {
1216
			foreach($files as $file) {
1217
				$this->javascript($file);
1218
			}
1219
		}
1220
	}
1221
1222
	/**
1223
	 * Finds the path for specified file
1224
	 *
1225
	 * @param string $fileOrUrl
1226
	 * @return string|bool
1227
	 */
1228
	protected function pathForFile($fileOrUrl) {
1229
		// Since combined urls could be root relative, treat them as urls here.
1230
		if(preg_match('{^(//)|(http[s]?:)}', $fileOrUrl) || Director::is_root_relative_url($fileOrUrl)) {
1231
			return $fileOrUrl;
1232
		} elseif(Director::fileExists($fileOrUrl)) {
1233
			$filePath = preg_replace('/\?.*/', '', Director::baseFolder() . '/' . $fileOrUrl);
1234
			$prefix = Director::baseURL();
1235
			$mtimesuffix = "";
1236
			$suffix = '';
1237
			if($this->getSuffixRequirements()) {
1238
				$mtimesuffix = "?m=" . filemtime($filePath);
1239
				$suffix = '&';
1240
			}
1241
			if(strpos($fileOrUrl, '?') !== false) {
1242
				if (strlen($suffix) == 0) {
1243
					$suffix = '?';
1244
				}
1245
				$suffix .= substr($fileOrUrl, strpos($fileOrUrl, '?')+1);
1246
				$fileOrUrl = substr($fileOrUrl, 0, strpos($fileOrUrl, '?'));
1247
			} else {
1248
				$suffix = '';
1249
			}
1250
			return "{$prefix}{$fileOrUrl}{$mtimesuffix}{$suffix}";
1251
		} else {
1252
			return false;
1253
		}
1254
	}
1255
1256
	/**
1257
	 * Concatenate several css or javascript files into a single dynamically generated file. This
1258
	 * increases performance by fewer HTTP requests.
1259
	 *
1260
	 * The combined file is regenerated based on every file modification time. Optionally a
1261
	 * rebuild can be triggered by appending ?flush=1 to the URL.
1262
	 *
1263
	 * All combined files will have a comment on the start of each concatenated file denoting their
1264
	 * original position.
1265
	 *
1266
	 * CAUTION: You're responsible for ensuring that the load order for combined files is
1267
	 * retained - otherwise combining JavaScript files can lead to functional errors in the
1268
	 * JavaScript logic, and combining CSS can lead to incorrect inheritance. You can also
1269
	 * only include each file once across all includes and combinations in a single page load.
1270
	 *
1271
	 * CAUTION: Combining CSS Files discards any "media" information.
1272
	 *
1273
	 * Example for combined JavaScript:
1274
	 * <code>
1275
	 * Requirements::combine_files(
1276
	 *  'foobar.js',
1277
	 *  array(
1278
	 *        'mysite/javascript/foo.js',
1279
	 *        'mysite/javascript/bar.js',
1280
	 *    )
1281
	 * );
1282
	 * </code>
1283
	 *
1284
	 * Example for combined CSS:
1285
	 * <code>
1286
	 * Requirements::combine_files(
1287
	 *  'foobar.css',
1288
	 *    array(
1289
	 *        'mysite/javascript/foo.css',
1290
	 *        'mysite/javascript/bar.css',
1291
	 *    )
1292
	 * );
1293
	 * </code>
1294
	 *
1295
	 * @param string $combinedFileName Filename of the combined file relative to docroot
1296
	 * @param array $files Array of filenames relative to docroot
1297
	 * @param string $media If including CSS Files, you can specify a media type
1298
	 */
1299
	public function combineFiles($combinedFileName, $files, $media = null) {
1300
		// Skip this combined files if already included
1301
		if(isset($this->combinedFiles[$combinedFileName])) {
1302
			return;
1303
		}
1304
1305
		// Add all files to necessary type list
1306
		$paths = array();
1307
		$combinedType = null;
1308
		foreach($files as $file) {
1309
			// Get file details
1310
			list($path, $type) = $this->parseCombinedFile($file);
1311
			if($type === 'javascript') {
1312
				$type = 'js';
1313
			}
1314
			if($combinedType && $type && $combinedType !== $type) {
1315
				throw new InvalidArgumentException(
1316
					"Cannot mix js and css files in same combined file {$combinedFileName}"
1317
				);
1318
			}
1319
			switch($type) {
1320
				case 'css':
1321
					$this->css($path, $media);
1322
					break;
1323
				case 'js':
1324
					$this->javascript($path);
1325
					break;
1326
				default:
1327
					throw new InvalidArgumentException("Invalid combined file type: {$type}");
1328
			}
1329
			$combinedType = $type;
1330
			$paths[] = $path;
1331
		}
1332
1333
		// Duplicate check
1334
		foreach($this->combinedFiles as $existingCombinedFilename => $combinedItem) {
1335
			$existingFiles = $combinedItem['files'];
1336
			$duplicates = array_intersect($existingFiles, $paths);
1337
			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...
1338
				throw new InvalidArgumentException(sprintf(
1339
					"Requirements_Backend::combine_files(): Already included file(s) %s in combined file '%s'",
1340
					implode(',', $duplicates),
1341
					$existingCombinedFilename
1342
				));
1343
			}
1344
		}
1345
1346
		$this->combinedFiles[$combinedFileName] = array(
1347
			'files' => $paths,
1348
			'type' => $combinedType,
1349
			'media' => $media
1350
		);
1351
	}
1352
1353
	/**
1354
	 * Return path and type of given combined file
1355
	 *
1356
	 * @param string|array $file Either a file path, or an array spec
1357
	 * @return array array with two elements, path and type of file
1358
	 */
1359
	protected function parseCombinedFile($file) {
1360
		// Array with path and type keys
1361
		if(is_array($file) && isset($file['path']) && isset($file['type'])) {
1362
			return array($file['path'], $file['type']);
1363
		}
1364
1365
		// Extract value from indexed array
1366
		if(is_array($file)) {
1367
			$path = array_shift($file);
1368
1369
			// See if there's a type specifier
1370
			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...
1371
				$type = array_shift($file);
1372
				return array($path, $type);
1373
			}
1374
1375
			// Otherwise convent to string
1376
			$file = $path;
1377
		}
1378
1379
		$type = File::get_file_extension($file);
1380
		return array($file, $type);
1381
	}
1382
1383
	/**
1384
	 * Return all combined files; keys are the combined file names, values are lists of
1385
	 * associative arrays with 'files', 'type', and 'media' keys for details about this
1386
	 * combined file.
1387
	 *
1388
	 * @return array
1389
	 */
1390
	public function getCombinedFiles() {
1391
		return array_diff_key($this->combinedFiles, $this->blocked);
1392
	}
1393
1394
	/**
1395
	 * Includes all combined files, including blocked ones
1396
	 *
1397
	 * @return type
1398
	 */
1399
	protected function getAllCombinedFiles() {
1400
		return $this->combinedFiles;
1401
	}
1402
1403
	/**
1404
	 * Clears all combined files
1405
	 */
1406
	public function deleteAllCombinedFiles() {
1407
		$combinedFolder = $this->getCombinedFilesFolder();
1408
		if($combinedFolder) {
1409
			$this->getAssetHandler()->removeContent($combinedFolder);
1410
		}
1411
	}
1412
1413
	/**
1414
	 * Clear all registered CSS and JavaScript file combinations
1415
	 */
1416
	public function clearCombinedFiles() {
1417
		$this->combinedFiles = array();
1418
	}
1419
1420
	/**
1421
	 * Do the heavy lifting involved in combining the combined files.
1422
	 */
1423
	public function processCombinedFiles() {
1424
		// Check if combining is enabled
1425
		if(!$this->getCombinedFilesEnabled()) {
1426
			return;
1427
		}
1428
1429
		// Process each combined files
1430
		foreach($this->getAllCombinedFiles() as $combinedFile => $combinedItem) {
1431
			$fileList = $combinedItem['files'];
1432
			$type = $combinedItem['type'];
1433
			$media = $combinedItem['media'];
1434
1435
			// Generate this file, unless blocked
1436
			$combinedURL = null;
1437
			if(!isset($this->blocked[$combinedFile])) {
1438
				$combinedURL = $this->getCombinedFileURL($combinedFile, $fileList, $type);
1439
			}
1440
1441
			// Replace all existing files, injecting the combined file at the position of the first item
1442
			// in order to preserve inclusion order.
1443
			// Note that we iterate across blocked files in order to get the correct order, and validate
1444
			// that the file is included in the correct location (regardless of which files are blocked).
1445
			$included = false;
1446
			switch($type) {
1447
				case 'css': {
0 ignored issues
show
Coding Style introduced by
case statements should not use curly braces.

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...
1448
					$newCSS = array(); // Assoc array of css file => spec
1449
					foreach($this->getAllCSS() as $css => $spec) {
1450
						if(!in_array($css, $fileList)) {
1451
							$newCSS[$css] = $spec;
1452
						} elseif(!$included && $combinedURL) {
0 ignored issues
show
Bug Best Practice introduced by
The expression $combinedURL 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...
1453
							$newCSS[$combinedURL] = array('media' => $media);
1454
							$included = true;
1455
						}
1456
						// If already included, or otherwise blocked, then don't add into CSS
1457
					}
1458
					$this->css = $newCSS;
1459
					break;
1460
				}
1461
				case 'js': {
0 ignored issues
show
Coding Style introduced by
case statements should not use curly braces.

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...
1462
					// Assoc array of file => true
1463
					$newJS = array();
1464
					foreach($this->getAllJavascript() as $script) {
1465
						if(!in_array($script, $fileList)) {
1466
							$newJS[$script] = true;
1467
						} elseif(!$included && $combinedURL) {
0 ignored issues
show
Bug Best Practice introduced by
The expression $combinedURL 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...
1468
							$newJS[$combinedURL] = true;
1469
							$included = true;
1470
						}
1471
						// If already included, or otherwise blocked, then don't add into scripts
1472
					}
1473
					$this->javascript = $newJS;
1474
					break;
1475
				}
1476
			}
1477
		}
1478
	}
1479
1480
	/**
1481
	 * Given a set of files, combine them (as necessary) and return the url
1482
	 *
1483
	 * @param string $combinedFile Filename for this combined file
1484
	 * @param array $fileList List of files to combine
1485
	 * @param string $type Either 'js' or 'css'
1486
	 * @return string URL to this resource
1487
	 */
1488
	protected function getCombinedFileURL($combinedFile, $fileList, $type) {
1489
		// Filter blocked files
1490
		$fileList = array_diff($fileList, $this->getBlocked());
1491
1492
		// Generate path (Filename)
1493
		$hashQuerystring = Config::inst()->get(static::class, 'combine_hash_querystring');
1494
		if(!$hashQuerystring) {
1495
			$combinedFile = $this->hashedCombinedFilename($combinedFile, $fileList);
1496
		}
1497
		$combinedFileID =  File::join_paths($this->getCombinedFilesFolder(), $combinedFile);
1498
1499
		// Send file combination request to the backend, with an optional callback to perform regeneration
1500
		$minify = $this->getMinifyCombinedJSFiles();
1501
		$combinedURL = $this
1502
			->getAssetHandler()
1503
			->getContentURL(
1504
				$combinedFileID,
1505
				function() use ($fileList, $minify, $type) {
1506
					// Physically combine all file content
1507
					$combinedData = '';
1508
					$base = Director::baseFolder() . '/';
1509
					$minifier = Injector::inst()->get('Requirements_Minifier');
1510
					foreach($fileList as $file) {
1511
						$fileContent = file_get_contents($base . $file);
1512
						// Use configured minifier
1513
						if($minify) {
1514
							$fileContent = $minifier->minify($fileContent, $type, $file);
1515
						}
1516
1517
						if ($this->writeHeaderComment) {
1518
							// Write a header comment for each file for easier identification and debugging.
1519
							$combinedData .= "/****** FILE: $file *****/\n";
1520
						}
1521
						$combinedData .= $fileContent . "\n";
1522
					}
1523
					return $combinedData;
1524
				}
1525
			);
1526
1527
		// If the name isn't hashed, we will need to append the querystring m= parameter instead
1528
		// Since url won't be automatically suffixed, add it in here
1529
		if($hashQuerystring && $this->getSuffixRequirements()) {
1530
			$hash = $this->hashOfFiles($fileList);
1531
			$q = stripos($combinedURL, '?') === false ? '?' : '&';
1532
			$combinedURL .= "{$q}m={$hash}";
1533
		}
1534
1535
		return $combinedURL;
1536
	}
1537
1538
	/**
1539
	 * Given a filename and list of files, generate a new filename unique to these files
1540
	 *
1541
	 * @param string $name
0 ignored issues
show
Bug introduced by
There is no parameter named $name. Was it maybe removed?

This check looks for PHPDoc comments describing methods or function parameters that do not exist on the corresponding method or function.

Consider the following example. The parameter $italy is not defined by the method finale(...).

/**
 * @param array $germany
 * @param array $island
 * @param array $italy
 */
function finale($germany, $island) {
    return "2:1";
}

The most likely cause is that the parameter was removed, but the annotation was not.

Loading history...
1542
	 * @param array $files
0 ignored issues
show
Bug introduced by
There is no parameter named $files. Was it maybe removed?

This check looks for PHPDoc comments describing methods or function parameters that do not exist on the corresponding method or function.

Consider the following example. The parameter $italy is not defined by the method finale(...).

/**
 * @param array $germany
 * @param array $island
 * @param array $italy
 */
function finale($germany, $island) {
    return "2:1";
}

The most likely cause is that the parameter was removed, but the annotation was not.

Loading history...
1543
	 * @return string
1544
	 */
1545
	protected function hashedCombinedFilename($combinedFile, $fileList) {
1546
		$name = pathinfo($combinedFile, PATHINFO_FILENAME);
1547
		$hash = $this->hashOfFiles($fileList);
1548
		$extension = File::get_file_extension($combinedFile);
1549
		return $name . '-' . substr($hash, 0, 7) . '.' . $extension;
1550
	}
1551
1552
	/**
1553
	 * Check if combined files are enabled
1554
	 *
1555
	 * @return bool
1556
	 */
1557
	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...
1558
		if(!$this->combinedFilesEnabled) {
1559
			return false;
1560
		}
1561
1562
		// Tests should be combined
1563
		if(class_exists('SapphireTest', false) && SapphireTest::is_running_test()) {
1564
			return true;
1565
		}
1566
1567
		// Check if specified via querystring
1568
		if(isset($_REQUEST['combine'])) {
1569
			return true;
1570
		}
1571
1572
		// Non-dev sites are always combined
1573
		if(!Director::isDev()) {
1574
			return true;
1575
		}
1576
1577
		// Fallback to default
1578
		return Config::inst()->get(__CLASS__, 'combine_in_dev');
1579
	}
1580
1581
	/**
1582
	 * For a given filelist, determine some discriminating value to determine if
1583
	 * any of these files have changed.
1584
	 *
1585
	 * @param array $fileList List of files
1586
	 * @return string SHA1 bashed file hash
1587
	 */
1588
	protected function hashOfFiles($fileList) {
1589
		// Get hash based on hash of each file
1590
		$base = Director::baseFolder() . '/';
1591
		$hash = '';
1592
		foreach($fileList as $file) {
1593
			if(file_exists($base . $file)) {
1594
				$hash .= sha1_file($base . $file);
1595
			} else {
1596
				throw new InvalidArgumentException("Combined file {$file} does not exist");
1597
			}
1598
		}
1599
		return sha1($hash);
1600
	}
1601
1602
	/**
1603
	 * Registers the given themeable stylesheet as required.
1604
	 *
1605
	 * A CSS file in the current theme path name 'themename/css/$name.css' is first searched for,
1606
	 * and it that doesn't exist and the module parameter is set then a CSS file with that name in
1607
	 * the module is used.
1608
	 *
1609
	 * @param string $name   The name of the file - eg '/css/File.css' would have the name 'File'
1610
	 * @param string $module The module to fall back to if the css file does not exist in the
1611
	 *                       current theme.
1612
	 * @param string $media  Comma-separated list of media types to use in the link tag
1613
	 *                       (e.g. 'screen,projector')
1614
	 */
1615
	public function themedCSS($name, $module = null, $media = null) {
1616
		$theme = SSViewer::get_theme_folder();
1617
		$project = project();
1618
		$absbase = BASE_PATH . DIRECTORY_SEPARATOR;
1619
		$abstheme = $absbase . $theme;
1620
		$absproject = $absbase . $project;
1621
		$css = "/css/$name.css";
1622
1623
		if(file_exists($absproject . $css)) {
1624
			$this->css($project . $css, $media);
1625
		} 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...
1626
			$this->css($theme . '_' . $module . $css, $media);
1627
		} elseif(file_exists($abstheme . $css)) {
1628
			$this->css($theme . $css, $media);
1629
		} 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...
1630
			$this->css($module . $css, $media);
1631
		}
1632
	}
1633
1634
	/**
1635
	 * Output debugging information.
1636
	 */
1637
	public function debug() {
1638
		Debug::show($this->javascript);
1639
		Debug::show($this->css);
1640
		Debug::show($this->customCSS);
1641
		Debug::show($this->customScript);
1642
		Debug::show($this->customHeadTags);
1643
		Debug::show($this->combinedFiles);
1644
	}
1645
1646
}
1647
1648
/**
1649
 * Provides an abstract interface for minifying content
1650
 */
1651
interface Requirements_Minifier {
1652
1653
	/**
1654
	 * Minify the given content
1655
	 *
1656
	 * @param string $content
1657
	 * @param string $type Either js or css
1658
	 * @param string $filename Name of file to display in case of error
1659
	 * @return string minified content
1660
	 */
1661
	public function minify($content, $type, $filename);
1662
}
1663