Completed
Push — master ( b69482...79b86c )
by Damian
10:26
created

Requirements_Backend::processCombinedFiles()   C

Complexity

Conditions 14
Paths 20

Size

Total Lines 56
Code Lines 32

Duplication

Lines 0
Ratio 0 %
Metric Value
dl 0
loc 56
rs 6.6598
cc 14
eloc 32
nc 20
nop 0

How to fix   Long Method    Complexity   

Long Method

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

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

Commonly applied refactorings include:

1
<?php
2
3
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
		$response->addHeader('X-Include-JS', implode(',', $jsRequirements));
1153
1154
		foreach($this->getCSS() as $file => $params) {
1155
			$path = $this->pathForFile($file);
1156
			if($path) {
1157
				$path = str_replace(',', '%2C', $path);
1158
				$cssRequirements[] = isset($params['media']) ? "$path:##:$params[media]" : $path;
1159
			}
1160
		}
1161
1162
		$response->addHeader('X-Include-CSS', implode(',', $cssRequirements));
1163
	}
1164
1165
	/**
1166
	 * Add i18n files from the given javascript directory. SilverStripe expects that the given
1167
	 * directory will contain a number of JavaScript files named by language: en_US.js, de_DE.js,
1168
	 * etc.
1169
	 *
1170
	 * @param string $langDir  The JavaScript lang directory, relative to the site root, e.g.,
1171
	 *                         'framework/javascript/lang'
1172
	 * @param bool   $return   Return all relative file paths rather than including them in
1173
	 *                         requirements
1174
	 * @param bool   $langOnly Only include language files, not the base libraries
1175
	 *
1176
	 * @return array
1177
	 */
1178
	public function add_i18n_javascript($langDir, $return = false, $langOnly = false) {
1179
		$files = array();
1180
		$base = Director::baseFolder() . '/';
1181
		if(i18n::config()->js_i18n) {
1182
			// Include i18n.js even if no languages are found.  The fact that
1183
			// add_i18n_javascript() was called indicates that the methods in
1184
			// here are needed.
1185
			if(!$langOnly) $files[] = FRAMEWORK_DIR . '/javascript/i18n.js';
1186
1187
			if(substr($langDir,-1) != '/') $langDir .= '/';
1188
1189
			$candidates = array(
1190
				'en.js',
1191
				'en_US.js',
1192
				i18n::get_lang_from_locale(i18n::default_locale()) . '.js',
1193
				i18n::default_locale() . '.js',
1194
				i18n::get_lang_from_locale(i18n::get_locale()) . '.js',
1195
				i18n::get_locale() . '.js',
1196
			);
1197
			foreach($candidates as $candidate) {
1198
				if(file_exists($base . DIRECTORY_SEPARATOR . $langDir . $candidate)) {
1199
					$files[] = $langDir . $candidate;
1200
				}
1201
			}
1202
		} else {
1203
			// Stub i18n implementation for when i18n is disabled.
1204
			if(!$langOnly) {
1205
				$files[] = FRAMEWORK_DIR . '/javascript/i18nx.js';
1206
			}
1207
		}
1208
1209
		if($return) {
1210
			return $files;
1211
		} else {
1212
			foreach($files as $file) {
1213
				$this->javascript($file);
1214
			}
1215
		}
1216
	}
1217
1218
	/**
1219
	 * Finds the path for specified file
1220
	 *
1221
	 * @param string $fileOrUrl
1222
	 * @return string|bool
1223
	 */
1224
	protected function pathForFile($fileOrUrl) {
1225
		// Since combined urls could be root relative, treat them as urls here.
1226
		if(preg_match('{^(//)|(http[s]?:)}', $fileOrUrl) || Director::is_root_relative_url($fileOrUrl)) {
1227
			return $fileOrUrl;
1228
		} elseif(Director::fileExists($fileOrUrl)) {
1229
			$filePath = preg_replace('/\?.*/', '', Director::baseFolder() . '/' . $fileOrUrl);
1230
			$prefix = Director::baseURL();
1231
			$mtimesuffix = "";
1232
			$suffix = '';
1233
			if($this->getSuffixRequirements()) {
1234
				$mtimesuffix = "?m=" . filemtime($filePath);
1235
				$suffix = '&';
1236
			}
1237
			if(strpos($fileOrUrl, '?') !== false) {
1238
				if (strlen($suffix) == 0) {
1239
					$suffix = '?';
1240
				}
1241
				$suffix .= substr($fileOrUrl, strpos($fileOrUrl, '?')+1);
1242
				$fileOrUrl = substr($fileOrUrl, 0, strpos($fileOrUrl, '?'));
1243
			} else {
1244
				$suffix = '';
1245
			}
1246
			return "{$prefix}{$fileOrUrl}{$mtimesuffix}{$suffix}";
1247
		} else {
1248
			return false;
1249
		}
1250
	}
1251
1252
	/**
1253
	 * Concatenate several css or javascript files into a single dynamically generated file. This
1254
	 * increases performance by fewer HTTP requests.
1255
	 *
1256
	 * The combined file is regenerated based on every file modification time. Optionally a
1257
	 * rebuild can be triggered by appending ?flush=1 to the URL.
1258
	 *
1259
	 * All combined files will have a comment on the start of each concatenated file denoting their
1260
	 * original position.
1261
	 *
1262
	 * CAUTION: You're responsible for ensuring that the load order for combined files is
1263
	 * retained - otherwise combining JavaScript files can lead to functional errors in the
1264
	 * JavaScript logic, and combining CSS can lead to incorrect inheritance. You can also
1265
	 * only include each file once across all includes and combinations in a single page load.
1266
	 *
1267
	 * CAUTION: Combining CSS Files discards any "media" information.
1268
	 *
1269
	 * Example for combined JavaScript:
1270
	 * <code>
1271
	 * Requirements::combine_files(
1272
	 *  'foobar.js',
1273
	 *  array(
1274
	 *        'mysite/javascript/foo.js',
1275
	 *        'mysite/javascript/bar.js',
1276
	 *    )
1277
	 * );
1278
	 * </code>
1279
	 *
1280
	 * Example for combined CSS:
1281
	 * <code>
1282
	 * Requirements::combine_files(
1283
	 *  'foobar.css',
1284
	 *    array(
1285
	 *        'mysite/javascript/foo.css',
1286
	 *        'mysite/javascript/bar.css',
1287
	 *    )
1288
	 * );
1289
	 * </code>
1290
	 *
1291
	 * @param string $combinedFileName Filename of the combined file relative to docroot
1292
	 * @param array $files Array of filenames relative to docroot
1293
	 * @param string $media If including CSS Files, you can specify a media type
1294
	 */
1295
	public function combineFiles($combinedFileName, $files, $media = null) {
1296
		// Skip this combined files if already included
1297
		if(isset($this->combinedFiles[$combinedFileName])) {
1298
			return;
1299
		}
1300
1301
		// Add all files to necessary type list
1302
		$paths = array();
1303
		$combinedType = null;
1304
		foreach($files as $file) {
1305
			// Get file details
1306
			list($path, $type) = $this->parseCombinedFile($file);
1307
			if($type === 'javascript') {
1308
				$type = 'js';
1309
			}
1310
			if($combinedType && $type && $combinedType !== $type) {
1311
				throw new InvalidArgumentException(
1312
					"Cannot mix js and css files in same combined file {$combinedFileName}"
1313
				);
1314
			}
1315
			switch($type) {
1316
				case 'css':
1317
					$this->css($path, $media);
1318
					break;
1319
				case 'js':
1320
					$this->javascript($path);
1321
					break;
1322
				default:
1323
					throw new InvalidArgumentException("Invalid combined file type: {$type}");
1324
			}
1325
			$combinedType = $type;
1326
			$paths[] = $path;
1327
		}
1328
1329
		// Duplicate check
1330
		foreach($this->combinedFiles as $existingCombinedFilename => $combinedItem) {
1331
			$existingFiles = $combinedItem['files'];
1332
			$duplicates = array_intersect($existingFiles, $paths);
1333
			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...
1334
				throw new InvalidArgumentException(sprintf(
1335
					"Requirements_Backend::combine_files(): Already included file(s) %s in combined file '%s'",
1336
					implode(',', $duplicates),
1337
					$existingCombinedFilename
1338
				));
1339
			}
1340
		}
1341
1342
		$this->combinedFiles[$combinedFileName] = array(
1343
			'files' => $paths,
1344
			'type' => $combinedType,
1345
			'media' => $media
1346
		);
1347
	}
1348
1349
	/**
1350
	 * Return path and type of given combined file
1351
	 *
1352
	 * @param string|array $file Either a file path, or an array spec
1353
	 * @return array array with two elements, path and type of file
1354
	 */
1355
	protected function parseCombinedFile($file) {
1356
		// Array with path and type keys
1357
		if(is_array($file) && isset($file['path']) && isset($file['type'])) {
1358
			return array($file['path'], $file['type']);
1359
		}
1360
1361
		// Extract value from indexed array
1362
		if(is_array($file)) {
1363
			$path = array_shift($file);
1364
1365
			// See if there's a type specifier
1366
			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...
1367
				$type = array_shift($file);
1368
				return array($path, $type);
1369
			}
1370
1371
			// Otherwise convent to string
1372
			$file = $path;
1373
		}
1374
1375
		$type = File::get_file_extension($file);
1376
		return array($file, $type);
1377
	}
1378
1379
	/**
1380
	 * Return all combined files; keys are the combined file names, values are lists of
1381
	 * associative arrays with 'files', 'type', and 'media' keys for details about this
1382
	 * combined file.
1383
	 *
1384
	 * @return array
1385
	 */
1386
	public function getCombinedFiles() {
1387
		return array_diff_key($this->combinedFiles, $this->blocked);
1388
	}
1389
1390
	/**
1391
	 * Includes all combined files, including blocked ones
1392
	 *
1393
	 * @return type
1394
	 */
1395
	protected function getAllCombinedFiles() {
1396
		return $this->combinedFiles;
1397
	}
1398
1399
	/**
1400
	 * Clears all combined files
1401
	 */
1402
	public function deleteAllCombinedFiles() {
1403
		$combinedFolder = $this->getCombinedFilesFolder();
1404
		if($combinedFolder) {
1405
			$this->getAssetHandler()->removeContent($combinedFolder);
1406
		}
1407
	}
1408
1409
	/**
1410
	 * Clear all registered CSS and JavaScript file combinations
1411
	 */
1412
	public function clearCombinedFiles() {
1413
		$this->combinedFiles = array();
1414
	}
1415
1416
	/**
1417
	 * Do the heavy lifting involved in combining the combined files.
1418
	 */
1419
	public function processCombinedFiles() {
1420
		// Check if combining is enabled
1421
		if(!$this->getCombinedFilesEnabled()) {
1422
			return;
1423
		}
1424
1425
		// Process each combined files
1426
		foreach($this->getAllCombinedFiles() as $combinedFile => $combinedItem) {
1427
			$fileList = $combinedItem['files'];
1428
			$type = $combinedItem['type'];
1429
			$media = $combinedItem['media'];
1430
1431
			// Generate this file, unless blocked
1432
			$combinedURL = null;
1433
			if(!isset($this->blocked[$combinedFile])) {
1434
				$combinedURL = $this->getCombinedFileURL($combinedFile, $fileList, $type);
1435
			}
1436
1437
			// Replace all existing files, injecting the combined file at the position of the first item
1438
			// in order to preserve inclusion order.
1439
			// Note that we iterate across blocked files in order to get the correct order, and validate
1440
			// that the file is included in the correct location (regardless of which files are blocked).
1441
			$included = false;
1442
			switch($type) {
1443
				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...
1444
					$newCSS = array(); // Assoc array of css file => spec
1445
					foreach($this->getAllCSS() as $css => $spec) {
1446
						if(!in_array($css, $fileList)) {
1447
							$newCSS[$css] = $spec;
1448
						} 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...
1449
							$newCSS[$combinedURL] = array('media' => $media);
1450
							$included = true;
1451
						}
1452
						// If already included, or otherwise blocked, then don't add into CSS
1453
					}
1454
					$this->css = $newCSS;
1455
					break;
1456
				}
1457
				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...
1458
					// Assoc array of file => true
1459
					$newJS = array();
1460
					foreach($this->getAllJavascript() as $script) {
1461
						if(!in_array($script, $fileList)) {
1462
							$newJS[$script] = true;
1463
						} 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...
1464
							$newJS[$combinedURL] = true;
1465
							$included = true;
1466
						}
1467
						// If already included, or otherwise blocked, then don't add into scripts
1468
					}
1469
					$this->javascript = $newJS;
1470
					break;
1471
				}
1472
			}
1473
		}
1474
	}
1475
1476
	/**
1477
	 * Given a set of files, combine them (as necessary) and return the url
1478
	 *
1479
	 * @param string $combinedFile Filename for this combined file
1480
	 * @param array $fileList List of files to combine
1481
	 * @param string $type Either 'js' or 'css'
1482
	 * @return string URL to this resource
1483
	 */
1484
	protected function getCombinedFileURL($combinedFile, $fileList, $type) {
1485
		// Filter blocked files
1486
		$fileList = array_diff($fileList, $this->getBlocked());
1487
1488
		// Generate path (Filename)
1489
		$hashQuerystring = Config::inst()->get(static::class, 'combine_hash_querystring');
1490
		if(!$hashQuerystring) {
1491
			$combinedFile = $this->hashedCombinedFilename($combinedFile, $fileList);
1492
		}
1493
		$combinedFileID =  File::join_paths($this->getCombinedFilesFolder(), $combinedFile);
1494
1495
		// Send file combination request to the backend, with an optional callback to perform regeneration
1496
		$minify = $this->getMinifyCombinedJSFiles();
1497
		$combinedURL = $this
1498
			->getAssetHandler()
1499
			->getContentURL(
1500
				$combinedFileID,
1501
				function() use ($fileList, $minify, $type) {
1502
					// Physically combine all file content
1503
					$combinedData = '';
1504
					$base = Director::baseFolder() . '/';
1505
					$minifier = Injector::inst()->get('Requirements_Minifier');
1506
					foreach($fileList as $file) {
1507
						$fileContent = file_get_contents($base . $file);
1508
						// Use configured minifier
1509
						if($minify) {
1510
							$fileContent = $minifier->minify($fileContent, $type, $file);
1511
						}
1512
1513
						if ($this->writeHeaderComment) {
1514
							// Write a header comment for each file for easier identification and debugging.
1515
							$combinedData .= "/****** FILE: $file *****/\n";
1516
						}
1517
						$combinedData .= $fileContent . "\n";
1518
					}
1519
					return $combinedData;
1520
				}
1521
			);
1522
1523
		// If the name isn't hashed, we will need to append the querystring m= parameter instead
1524
		// Since url won't be automatically suffixed, add it in here
1525
		if($hashQuerystring && $this->getSuffixRequirements()) {
1526
			$hash = $this->hashOfFiles($fileList);
1527
			$q = stripos($combinedURL, '?') === false ? '?' : '&';
1528
			$combinedURL .= "{$q}m={$hash}";
1529
		}
1530
1531
		return $combinedURL;
1532
	}
1533
1534
	/**
1535
	 * Given a filename and list of files, generate a new filename unique to these files
1536
	 *
1537
	 * @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...
1538
	 * @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...
1539
	 * @return string
1540
	 */
1541
	protected function hashedCombinedFilename($combinedFile, $fileList) {
1542
		$name = pathinfo($combinedFile, PATHINFO_FILENAME);
1543
		$hash = $this->hashOfFiles($fileList);
1544
		$extension = File::get_file_extension($combinedFile);
1545
		return $name . '-' . substr($hash, 0, 7) . '.' . $extension;
1546
	}
1547
1548
	/**
1549
	 * Check if combined files are enabled
1550
	 *
1551
	 * @return bool
1552
	 */
1553
	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...
1554
		if(!$this->combinedFilesEnabled) {
1555
			return false;
1556
		}
1557
1558
		// Tests should be combined
1559
		if(class_exists('SapphireTest', false) && SapphireTest::is_running_test()) {
1560
			return true;
1561
		}
1562
1563
		// Check if specified via querystring
1564
		if(isset($_REQUEST['combine'])) {
1565
			return true;
1566
		}
1567
1568
		// Non-dev sites are always combined
1569
		if(!Director::isDev()) {
1570
			return true;
1571
		}
1572
1573
		// Fallback to default
1574
		return Config::inst()->get(__CLASS__, 'combine_in_dev');
1575
	}
1576
1577
	/**
1578
	 * For a given filelist, determine some discriminating value to determine if
1579
	 * any of these files have changed.
1580
	 *
1581
	 * @param array $fileList List of files
1582
	 * @return string SHA1 bashed file hash
1583
	 */
1584
	protected function hashOfFiles($fileList) {
1585
		// Get hash based on hash of each file
1586
		$base = Director::baseFolder() . '/';
1587
		$hash = '';
1588
		foreach($fileList as $file) {
1589
			if(file_exists($base . $file)) {
1590
				$hash .= sha1_file($base . $file);
1591
			} else {
1592
				throw new InvalidArgumentException("Combined file {$file} does not exist");
1593
			}
1594
		}
1595
		return sha1($hash);
1596
	}
1597
1598
	/**
1599
	 * Registers the given themeable stylesheet as required.
1600
	 *
1601
	 * A CSS file in the current theme path name 'themename/css/$name.css' is first searched for,
1602
	 * and it that doesn't exist and the module parameter is set then a CSS file with that name in
1603
	 * the module is used.
1604
	 *
1605
	 * @param string $name   The name of the file - eg '/css/File.css' would have the name 'File'
1606
	 * @param string $module The module to fall back to if the css file does not exist in the
1607
	 *                       current theme.
1608
	 * @param string $media  Comma-separated list of media types to use in the link tag
1609
	 *                       (e.g. 'screen,projector')
1610
	 */
1611
	public function themedCSS($name, $module = null, $media = null) {
1612
		$theme = SSViewer::get_theme_folder();
1613
		$project = project();
1614
		$absbase = BASE_PATH . DIRECTORY_SEPARATOR;
1615
		$abstheme = $absbase . $theme;
1616
		$absproject = $absbase . $project;
1617
		$css = "/css/$name.css";
1618
1619
		if(file_exists($absproject . $css)) {
1620
			$this->css($project . $css, $media);
1621
		} 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...
1622
			$this->css($theme . '_' . $module . $css, $media);
1623
		} elseif(file_exists($abstheme . $css)) {
1624
			$this->css($theme . $css, $media);
1625
		} 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...
1626
			$this->css($module . $css, $media);
1627
		}
1628
	}
1629
1630
	/**
1631
	 * Output debugging information.
1632
	 */
1633
	public function debug() {
1634
		Debug::show($this->javascript);
1635
		Debug::show($this->css);
1636
		Debug::show($this->customCSS);
1637
		Debug::show($this->customScript);
1638
		Debug::show($this->customHeadTags);
1639
		Debug::show($this->combinedFiles);
1640
	}
1641
1642
}
1643
1644
/**
1645
 * Provides an abstract interface for minifying content
1646
 */
1647
interface Requirements_Minifier {
1648
1649
	/**
1650
	 * Minify the given content
1651
	 *
1652
	 * @param string $content
1653
	 * @param string $type Either js or css
1654
	 * @param string $filename Name of file to display in case of error
1655
	 * @return string minified content
1656
	 */
1657
	public function minify($content, $type, $filename);
1658
}