Completed
Push — 3 ( d27970...2b05d8 )
by Luke
21s
created

Requirements   A

Complexity

Total Complexity 36

Size/Duplication

Total Lines 414
Duplicated Lines 0 %

Coupling/Cohesion

Components 1
Dependencies 2

Importance

Changes 0
Metric Value
dl 0
loc 414
rs 9.52
c 0
b 0
f 0
wmc 36
lcom 1
cbo 2

34 Methods

Rating   Name   Duplication   Size   Complexity  
A flush() 0 6 2
A set_combined_files_enabled() 0 3 1
A get_combined_files_enabled() 0 3 1
A set_combined_files_folder() 0 3 1
A set_suffix_requirements() 0 3 1
A get_suffix_requirements() 0 3 1
A backend() 0 6 2
A set_backend() 0 3 1
A javascript() 0 3 1
A customScript() 0 3 1
A get_custom_scripts() 0 3 1
A customCSS() 0 3 1
A insertHeadTags() 0 3 1
A javascriptTemplate() 0 3 1
A css() 0 3 1
A themedCSS() 0 3 1
A themedJavascript() 0 3 1
A clear() 0 3 1
A restore() 0 3 1
A block() 0 3 1
A unblock() 0 3 1
A unblock_all() 0 3 1
A includeInHTML() 0 3 1
A include_in_response() 0 3 1
A add_i18n_javascript() 0 3 1
A combine_files() 0 3 1
A get_combine_files() 0 3 1
A delete_combined_files() 0 3 1
A delete_all_combined_files() 0 3 1
A clear_combined_files() 0 3 1
A process_combined_files() 0 3 1
A set_write_js_to_body() 0 3 1
A set_force_js_to_bottom() 0 3 1
A debug() 0 3 1
1
<?php
2
3
/**
4
 * Requirements tracker for JavaScript and CSS.
5
 *
6
 * @package framework
7
 * @subpackage view
8
 */
9
class Requirements implements Flushable {
10
11
	/**
12
	 * Flag whether combined files should be deleted on flush.
13
	 *
14
	 * By default all combined files are deleted on flush. If combined files are stored in source control,
15
	 * and thus updated manually, you might want to turn this on to disable this behaviour.
16
	 *
17
	 * @config
18
	 * @var bool
19
	 */
20
	private static $disable_flush_combined = false;
21
22
	/**
23
	 * Triggered early in the request when a flush is requested
24
	 */
25
	public static function flush() {
26
		$disabled = Config::inst()->get(__CLASS__, 'disable_flush_combined');
27
		if(!$disabled) {
28
			self::delete_all_combined_files();
29
		}
30
	}
31
32
	/**
33
	 * Enable combining of css/javascript files.
34
	 *
35
	 * @param bool $enable
36
	 */
37
	public static function set_combined_files_enabled($enable) {
38
		self::backend()->set_combined_files_enabled($enable);
39
	}
40
41
	/**
42
	 * Checks whether combining of css/javascript files is enabled.
43
	 *
44
	 * @return bool
45
	 */
46
	public static function get_combined_files_enabled() {
47
		return self::backend()->get_combined_files_enabled();
48
	}
49
50
	/**
51
	 * Set the relative folder e.g. 'assets' for where to store combined files
52
	 *
53
	 * @param string $folder Path to folder
54
	 */
55
	public static function set_combined_files_folder($folder) {
56
		self::backend()->setCombinedFilesFolder($folder);
57
	}
58
59
	/**
60
	 * Set whether to add caching query params to the requests for file-based requirements.
61
	 * Eg: themes/myTheme/js/main.js?m=123456789. The parameter is a timestamp generated by
62
	 * filemtime. This has the benefit of allowing the browser to cache the URL infinitely,
63
	 * while automatically busting this cache every time the file is changed.
64
	 *
65
	 * @param bool
66
	 */
67
	public static function set_suffix_requirements($var) {
68
		self::backend()->set_suffix_requirements($var);
69
	}
70
71
	/**
72
	 * Check whether we want to suffix requirements
73
	 *
74
	 * @return bool
75
	 */
76
	public static function get_suffix_requirements() {
77
		return self::backend()->get_suffix_requirements();
78
	}
79
80
	/**
81
	 * Instance of the requirements for storage. You can create your own backend to change the
82
	 * default JS and CSS inclusion behaviour.
83
	 *
84
	 * @var Requirements_Backend
85
	 */
86
	private static $backend = null;
87
88
	public static function backend() {
89
		if(!self::$backend) {
90
			self::$backend = new Requirements_Backend();
91
		}
92
		return self::$backend;
93
	}
94
95
	/**
96
	 * Setter method for changing the Requirements backend
97
	 *
98
	 * @param Requirements_Backend $backend
99
	 */
100
	public static function set_backend(Requirements_Backend $backend) {
101
		self::$backend = $backend;
102
	}
103
104
	/**
105
	 * Register the given JavaScript file as required.
106
	 *
107
	 * @param string $file Relative to docroot
108
	 */
109
	public static function javascript($file) {
110
		self::backend()->javascript($file);
111
	}
112
113
	/**
114
	 * Register the given JavaScript code into the list of requirements
115
	 *
116
	 * @param string     $script       The script content as a string (without enclosing <script> tag)
117
	 * @param string|int $uniquenessID A unique ID that ensures a piece of code is only added once
118
	 */
119
	public static function customScript($script, $uniquenessID = null) {
120
		self::backend()->customScript($script, $uniquenessID);
121
	}
122
123
	/**
124
	 * Return all registered custom scripts
125
	 *
126
	 * @return array
127
	 */
128
	public static function get_custom_scripts() {
129
		return self::backend()->get_custom_scripts();
130
	}
131
132
	/**
133
	 * Register the given CSS styles into the list of requirements
134
	 *
135
	 * @param string     $script       CSS selectors as a string (without enclosing <style> tag)
136
	 * @param string|int $uniquenessID A unique ID that ensures a piece of code is only added once
137
	 */
138
	public static function customCSS($script, $uniquenessID = null) {
139
		self::backend()->customCSS($script, $uniquenessID);
140
	}
141
142
	/**
143
	 * Add the following custom HTML code to the <head> section of the page
144
	 *
145
	 * @param string     $html         Custom HTML code
146
	 * @param string|int $uniquenessID A unique ID that ensures a piece of code is only added once
147
	 */
148
	public static function insertHeadTags($html, $uniquenessID = null) {
149
		self::backend()->insertHeadTags($html, $uniquenessID);
150
	}
151
152
	/**
153
	 * Include the content of the given JavaScript file in the list of requirements. Dollar-sign
154
	 * variables will be interpolated with values from $vars similar to a .ss template.
155
	 *
156
	 * @param string         $file         The template file to load, relative to docroot
157
	 * @param string[]|int[] $vars         The array of variables to interpolate.
158
	 * @param string|int     $uniquenessID A unique ID that ensures a piece of code is only added once
159
	 */
160
	public static function javascriptTemplate($file, $vars, $uniquenessID = null) {
161
		self::backend()->javascriptTemplate($file, $vars, $uniquenessID);
162
	}
163
164
	/**
165
	 * Register the given stylesheet into the list of requirements.
166
	 *
167
	 * @param string $file  The CSS file to load, relative to site root
168
	 * @param string $media Comma-separated list of media types to use in the link tag
169
	 *                      (e.g. 'screen,projector')
170
	 */
171
	public static function css($file, $media = null) {
172
		self::backend()->css($file, $media);
173
	}
174
175
	/**
176
	 * Registers the given themeable stylesheet as required.
177
	 *
178
	 * A CSS file in the current theme path name 'themename/css/$name.css' is first searched for,
179
	 * and it that doesn't exist and the module parameter is set then a CSS file with that name in
180
	 * the module is used.
181
	 *
182
	 * @param string $name   The name of the file - eg '/css/File.css' would have the name 'File'
183
	 * @param string $module The module to fall back to if the css file does not exist in the
184
	 *                       current theme.
185
	 * @param string $media  Comma-separated list of media types to use in the link tag
186
	 *                       (e.g. 'screen,projector')
187
	 */
188
	public static function themedCSS($name, $module = null, $media = null) {
189
		return self::backend()->themedCSS($name, $module, $media);
190
	}
191
192
	/**
193
	 * Registers the given themeable javascript as required.
194
	 *
195
	 * A javascript file in the current theme path name 'themename/javascript/$name.js' is first searched for,
196
	 * and it that doesn't exist and the module parameter is set then a javascript file with that name in
197
	 * the module is used.
198
	 *
199
	 * @param string $name   The name of the file - eg '/javascript/File.js' would have the name 'File'
200
	 * @param string $module The module to fall back to if the javascript file does not exist in the
201
	 *                       current theme.
202
	 * @param string $type  Comma-separated list of types to use in the script tag
203
	 *                       (e.g. 'text/javascript,text/ecmascript')
204
	 */
205
	public static function themedJavascript($name, $module = null, $type = null) {
206
		return self::backend()->themedJavascript($name, $module, $type);
207
	}
208
209
	/**
210
	 * Clear either a single or all requirements
211
	 *
212
	 * Caution: Clearing single rules added via customCSS and customScript only works if you
213
	 * originally specified a $uniquenessID.
214
	 *
215
	 * @param string|int $fileOrID
216
	 */
217
	public static function clear($fileOrID = null) {
218
		self::backend()->clear($fileOrID);
219
	}
220
221
	/**
222
	 * Restore requirements cleared by call to Requirements::clear
223
	 */
224
	public static function restore() {
225
		self::backend()->restore();
226
	}
227
228
	/**
229
	 * Block inclusion of a specific file
230
	 *
231
	 * The difference between this and {@link clear} is that the calling order does not matter;
232
	 * {@link clear} must be called after the initial registration, whereas {@link block} can be
233
	 * used in advance. This is useful, for example, to block scripts included by a superclass
234
	 * without having to override entire functions and duplicate a lot of code.
235
	 *
236
	 * Note that blocking should be used sparingly because it's hard to trace where an file is
237
	 * being blocked from.
238
	 *
239
	 * @param string|int $fileOrID
240
	 */
241
	public static function block($fileOrID) {
242
		self::backend()->block($fileOrID);
243
	}
244
245
	/**
246
	 * Remove an item from the block list
247
	 *
248
	 * @param string|int $fileOrID
249
	 */
250
	public static function unblock($fileOrID) {
251
		self::backend()->unblock($fileOrID);
252
	}
253
254
	/**
255
	 * Removes all items from the block list
256
	 */
257
	public static function unblock_all() {
258
		self::backend()->unblock_all();
259
	}
260
261
	/**
262
	 * Update the given HTML content with the appropriate include tags for the registered
263
	 * requirements. Needs to receive a valid HTML/XHTML template in the $content parameter,
264
	 * including a head and body tag.
265
	 *
266
	 * @param string $templateFile No longer used, only retained for compatibility
267
	 * @param string $content      HTML content that has already been parsed from the $templateFile
268
	 *                             through {@link SSViewer}
269
	 * @return string HTML content augmented with the requirements tags
270
	 */
271
	public static function includeInHTML($templateFile, $content) {
272
		return self::backend()->includeInHTML($templateFile, $content);
273
	}
274
275
	/**
276
	 * Attach requirements inclusion to X-Include-JS and X-Include-CSS headers on the given
277
	 * HTTP Response
278
	 *
279
	 * @param SS_HTTPResponse $response
280
	 */
281
	public static function include_in_response(SS_HTTPResponse $response) {
282
		return self::backend()->include_in_response($response);
283
	}
284
285
	/**
286
	 * Add i18n files from the given javascript directory. SilverStripe expects that the given
287
	 * directory will contain a number of JavaScript files named by language: en_US.js, de_DE.js,
288
	 * etc.
289
	 *
290
	 * @param string $langDir  The JavaScript lang directory, relative to the site root, e.g.,
291
	 *                         'framework/javascript/lang'
292
	 * @param bool   $return   Return all relative file paths rather than including them in
293
	 *                         requirements
294
	 * @param bool   $langOnly Only include language files, not the base libraries
295
	 *
296
	 * @return array
297
	 */
298
	public static function add_i18n_javascript($langDir, $return = false, $langOnly = false) {
299
		return self::backend()->add_i18n_javascript($langDir, $return, $langOnly);
300
	}
301
302
	/**
303
	 * Concatenate several css or javascript files into a single dynamically generated file. This
304
	 * increases performance by fewer HTTP requests.
305
	 *
306
	 * The combined file is regenerated based on every file modification time. Optionally a
307
	 * rebuild can be triggered by appending ?flush=1 to the URL. If all files to be combined are
308
	 * JavaScript, we use the external JSMin library to minify the JavaScript.
309
	 *
310
	 * All combined files will have a comment on the start of each concatenated file denoting their
311
	 * original position. For easier debugging, we only minify JavaScript if not in development
312
	 * mode ({@link Director::isDev()}).
313
	 *
314
	 * CAUTION: You're responsible for ensuring that the load order for combined files is
315
	 * retained - otherwise combining JavaScript files can lead to functional errors in the
316
	 * JavaScript logic, and combining CSS can lead to incorrect inheritance. You can also
317
	 * only include each file once across all includes and comibinations in a single page load.
318
	 *
319
	 * CAUTION: Combining CSS Files discards any "media" information.
320
	 *
321
	 * Example for combined JavaScript:
322
	 * <code>
323
	 * Requirements::combine_files(
324
	 *  'foobar.js',
325
	 *  array(
326
	 *        'mysite/javascript/foo.js',
327
	 *        'mysite/javascript/bar.js',
328
	 *    )
329
	 * );
330
	 * </code>
331
	 *
332
	 * Example for combined CSS:
333
	 * <code>
334
	 * Requirements::combine_files(
335
	 *  'foobar.css',
336
	 *    array(
337
	 *        'mysite/javascript/foo.css',
338
	 *        'mysite/javascript/bar.css',
339
	 *    )
340
	 * );
341
	 * </code>
342
	 *
343
	 * @param string $combinedFileName Filename of the combined file relative to docroot
344
	 * @param array  $files            Array of filenames relative to docroot
345
	 * @param string $media
346
	 *
347
	 * @return bool|void
348
	 */
349
	public static function combine_files($combinedFileName, $files, $media = null) {
350
		self::backend()->combine_files($combinedFileName, $files, $media);
351
	}
352
353
	/**
354
	 * Return all combined files; keys are the combined file names, values are lists of
355
	 * files being combined.
356
	 *
357
	 * @return array
358
	 */
359
	public static function get_combine_files() {
360
		return self::backend()->get_combine_files();
361
	}
362
363
	/**
364
	 * Delete all dynamically generated combined files from the filesystem
365
	 *
366
	 * @param string $combinedFileName If left blank, all combined files are deleted.
367
	 */
368
	public static function delete_combined_files($combinedFileName = null) {
369
		return self::backend()->delete_combined_files($combinedFileName);
370
	}
371
372
	/**
373
	 * Deletes all generated combined files in the configured combined files directory,
374
	 * but doesn't delete the directory itself
375
	 */
376
	public static function delete_all_combined_files() {
377
		return self::backend()->delete_all_combined_files();
378
	}
379
380
	/**
381
	 * Re-sets the combined files definition. See {@link Requirements_Backend::clear_combined_files()}
382
	 */
383
	public static function clear_combined_files() {
384
		self::backend()->clear_combined_files();
385
	}
386
387
	/**
388
	 * Do the heavy lifting involved in combining (and, in the case of JavaScript minifying) the
389
	 * combined files.
390
 	 */
391
	public static function process_combined_files() {
392
		return self::backend()->process_combined_files();
393
	}
394
395
	/**
396
	 * Set whether you want to write the JS to the body of the page rather than at the end of the
397
	 * head tag.
398
	 *
399
	 * @param bool
400
	 */
401
	public static function set_write_js_to_body($var) {
402
		self::backend()->set_write_js_to_body($var);
403
	}
404
405
	/**
406
	 * Set whether to force the JavaScript to end of the body. Useful if you use inline script tags
407
	 * that don't rely on scripts included via {@link Requirements::javascript()).
408
	 *
409
	 * @param boolean $var If true, force the JavaScript to be included at the bottom of the page
410
	 */
411
	public static function set_force_js_to_bottom($var) {
412
		self::backend()->set_force_js_to_bottom($var);
413
	}
414
415
	/**
416
	 * Output debugging information
417
	 */
418
	public static function debug() {
419
		return self::backend()->debug();
420
	}
421
422
}
423
424
/**
425
 * @package framework
426
 * @subpackage view
427
 */
428
class Requirements_Backend {
429
430
	/**
431
	 * Whether to add caching query params to the requests for file-based requirements.
432
	 * Eg: themes/myTheme/js/main.js?m=123456789. The parameter is a timestamp generated by
433
	 * filemtime. This has the benefit of allowing the browser to cache the URL infinitely,
434
	 * while automatically busting this cache every time the file is changed.
435
	 *
436
	 * @var bool
437
	 */
438
	protected $suffix_requirements = true;
439
440
	/**
441
	 * Whether to combine CSS and JavaScript files
442
	 *
443
	 * @var bool
444
	 */
445
	protected $combined_files_enabled = true;
446
447
	/**
448
	 * Paths to all required JavaScript files relative to docroot
449
	 *
450
	 * @var array $javascript
451
	 */
452
	protected $javascript = array();
453
454
	/**
455
	 * Paths to all required CSS files relative to the docroot.
456
	 *
457
	 * @var array $css
458
	 */
459
	protected $css = array();
460
461
	/**
462
	 * All custom javascript code that is inserted into the page's HTML
463
	 *
464
	 * @var array $customScript
465
	 */
466
	protected $customScript = array();
467
468
	/**
469
	 * All custom CSS rules which are inserted directly at the bottom of the HTML <head> tag
470
	 *
471
	 * @var array $customCSS
472
	 */
473
	protected $customCSS = array();
474
475
	/**
476
	 * All custom HTML markup which is added before the closing <head> tag, e.g. additional
477
	 * metatags.
478
	 */
479
	protected $customHeadTags = array();
480
481
	/**
482
	 * Remembers the file paths or uniquenessIDs of all Requirements cleared through
483
	 * {@link clear()}, so that they can be restored later.
484
	 *
485
	 * @var array $disabled
486
	 */
487
	protected $disabled = array();
488
489
	/**
490
	 * The file paths (relative to docroot) or uniquenessIDs of any included requirements which
491
	 * should be blocked when executing {@link inlcudeInHTML()}. This is useful, for example,
492
	 * to block scripts included by a superclass without having to override entire functions and
493
	 * duplicate a lot of code.
494
	 *
495
	 * Use {@link unblock()} or {@link unblock_all()} to revert changes.
496
	 *
497
	 * @var array $blocked
498
	 */
499
	protected $blocked = array();
500
501
	/**
502
	 * A list of combined files registered via {@link combine_files()}. Keys are the output file
503
	 * names, values are lists of input files.
504
	 *
505
	 * @var array $combine_files
506
	 */
507
	public $combine_files = array();
508
509
	/**
510
	 * Use the JSMin library to minify any javascript file passed to {@link combine_files()}.
511
	 *
512
	 * @var bool
513
	 */
514
	public $combine_js_with_jsmin = true;
515
516
	/**
517
	 * Whether or not file headers should be written when combining files
518
	 *
519
	 * @var boolean
520
	 */
521
	public $write_header_comment = true;
522
523
	/**
524
	 * Where to save combined files. By default they're placed in assets/_combinedfiles, however
525
	 * this may be an issue depending on your setup, especially for CSS files which often contain
526
	 * relative paths.
527
	 *
528
	 * @var string
529
	 */
530
	protected $combinedFilesFolder = null;
531
532
	/**
533
	 * Put all JavaScript includes at the bottom of the template before the closing <body> tag,
534
	 * rather than the default behaviour of placing them at the end of the <head> tag. This means
535
	 * script downloads won't block other HTTP requests, which can be a performance improvement.
536
	 *
537
	 * @var bool
538
	 */
539
	public $write_js_to_body = true;
540
541
	/**
542
	 * Force the JavaScript to the bottom of the page, even if there's a script tag in the body already
543
	 *
544
	 * @var boolean
545
	 */
546
	protected $force_js_to_bottom = false;
547
548
	/**
549
	 * Enable or disable the combination of CSS and JavaScript files
550
	 *
551
	 * @param $enable
552
	 */
553
	public function set_combined_files_enabled($enable) {
554
		$this->combined_files_enabled = (bool) $enable;
555
	}
556
557
	/**
558
	 * Check whether file combination is enabled.
559
	 *
560
	 * @return bool
561
	 */
562
	public function get_combined_files_enabled() {
563
		return $this->combined_files_enabled;
564
	}
565
566
	/**
567
	 * Set the folder to save combined files in. By default they're placed in assets/_combinedfiles,
568
	 * however this may be an issue depending on your setup, especially for CSS files which often
569
	 * contain relative paths.
570
	 *
571
	 * @param string $folder
572
	 */
573
	public function setCombinedFilesFolder($folder) {
574
		$this->combinedFilesFolder = $folder;
575
	}
576
577
	/**
578
	 * @return string Folder relative to the webroot
579
	 */
580
	public function getCombinedFilesFolder() {
581
		return ($this->combinedFilesFolder) ? $this->combinedFilesFolder : ASSETS_DIR . '/_combinedfiles';
582
	}
583
584
	/**
585
	 * Set whether to add caching query params to the requests for file-based requirements.
586
	 * Eg: themes/myTheme/js/main.js?m=123456789. The parameter is a timestamp generated by
587
	 * filemtime. This has the benefit of allowing the browser to cache the URL infinitely,
588
	 * while automatically busting this cache every time the file is changed.
589
	 *
590
	 * @param bool
591
	 */
592
	public function set_suffix_requirements($var) {
593
		$this->suffix_requirements = $var;
594
	}
595
596
	/**
597
	 * Check whether we want to suffix requirements
598
	 *
599
	 * @return bool
600
	 */
601
	public function get_suffix_requirements() {
602
		return $this->suffix_requirements;
603
	}
604
605
	/**
606
	 * Set whether you want to write the JS to the body of the page rather than at the end of the
607
	 * head tag.
608
	 *
609
	 * @param bool
610
	 */
611
	public function set_write_js_to_body($var) {
612
		$this->write_js_to_body = $var;
613
	}
614
615
	/**
616
	 * Forces the JavaScript requirements to the end of the body, right before the closing tag
617
	 *
618
	 * @param bool
619
	 */
620
	public function set_force_js_to_bottom($var) {
621
		$this->force_js_to_bottom = $var;
622
	}
623
624
	/**
625
	 * Register the given JavaScript file as required.
626
	 *
627
	 * @param string $file Relative to docroot
628
	 */
629
	public function javascript($file) {
630
		$this->javascript[$file] = true;
631
	}
632
633
	/**
634
	 * Returns an array of all required JavaScript
635
	 *
636
	 * @return array
637
	 */
638
	public function get_javascript() {
639
		return array_keys(array_diff_key($this->javascript, $this->blocked));
640
	}
641
642
	/**
643
	 * Register the given JavaScript code into the list of requirements
644
	 *
645
	 * @param string     $script       The script content as a string (without enclosing <script> tag)
646
	 * @param string|int $uniquenessID A unique ID that ensures a piece of code is only added once
647
	 */
648
	public function customScript($script, $uniquenessID = null) {
649
		if($uniquenessID) $this->customScript[$uniquenessID] = $script;
650
		else $this->customScript[] = $script;
651
652
		$script .= "\n";
653
	}
654
655
	/**
656
	 * Return all registered custom scripts
657
	 *
658
	 * @return array
659
	 */
660
	public function get_custom_scripts() {
661
		$requirements = "";
662
663
		if($this->customScript) {
0 ignored issues
show
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...
664
			foreach($this->customScript as $script) {
665
				$requirements .= "$script\n";
666
			}
667
		}
668
669
		return $requirements;
670
	}
671
672
	/**
673
	 * Register the given CSS styles into the list of requirements
674
	 *
675
	 * @param string     $script       CSS selectors as a string (without enclosing <style> tag)
676
	 * @param string|int $uniquenessID A unique ID that ensures a piece of code is only added once
677
	 */
678
	public function customCSS($script, $uniquenessID = null) {
679
		if($uniquenessID) $this->customCSS[$uniquenessID] = $script;
680
		else $this->customCSS[] = $script;
681
	}
682
683
	/**
684
	 * Add the following custom HTML code to the <head> section of the page
685
	 *
686
	 * @param string     $html         Custom HTML code
687
	 * @param string|int $uniquenessID A unique ID that ensures a piece of code is only added once
688
	 */
689
	public function insertHeadTags($html, $uniquenessID = null) {
690
		if($uniquenessID) $this->customHeadTags[$uniquenessID] = $html;
691
		else $this->customHeadTags[] = $html;
692
	}
693
694
	/**
695
	 * Include the content of the given JavaScript file in the list of requirements. Dollar-sign
696
	 * variables will be interpolated with values from $vars similar to a .ss template.
697
	 *
698
	 * @param string         $file         The template file to load, relative to docroot
699
	 * @param string[]|int[] $vars         The array of variables to interpolate.
700
	 * @param string|int     $uniquenessID A unique ID that ensures a piece of code is only added once
701
	 */
702
	public function javascriptTemplate($file, $vars, $uniquenessID = null) {
703
		$script = file_get_contents(Director::getAbsFile($file));
704
		$search = array();
705
		$replace = array();
706
707
		if($vars) foreach($vars as $k => $v) {
0 ignored issues
show
Bug Best Practice introduced by
The expression $vars of type array<string|integer> 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...
708
			$search[] = '$' . $k;
709
			$replace[] = str_replace("\\'","'", Convert::raw2js($v));
710
		}
711
712
		$script = str_replace($search, $replace, $script);
713
		$this->customScript($script, $uniquenessID);
714
	}
715
716
	/**
717
	 * Register the given stylesheet into the list of requirements.
718
	 *
719
	 * @param string $file  The CSS file to load, relative to site root
720
	 * @param string $media Comma-separated list of media types to use in the link tag
721
	 *                      (e.g. 'screen,projector')
722
	 */
723
	public function css($file, $media = null) {
724
		$this->css[$file] = array(
725
			"media" => $media
726
		);
727
	}
728
729
	/**
730
	 * Get the list of registered CSS file requirements, excluding blocked files
731
	 *
732
	 * @return array
733
	 */
734
	public function get_css() {
735
		return array_diff_key($this->css, $this->blocked);
736
	}
737
738
	/**
739
	 * Clear either a single or all requirements
740
	 *
741
	 * Caution: Clearing single rules added via customCSS and customScript only works if you
742
	 * originally specified a $uniquenessID.
743
	 *
744
	 * @param string|int $fileOrID
745
	 */
746
	public function clear($fileOrID = null) {
747
		$types = array(
748
			'javascript',
749
			'css',
750
			'customScript',
751
			'customCSS',
752
			'customHeadTags',
753
			'combine_files',
754
		);
755
		foreach ($types as $type) {
756
			if ($fileOrID) {
757
				if (isset($this->{$type}[$fileOrID])) {
758
					$this->disabled[$type][$fileOrID] = $this->{$type}[$fileOrID];
759
					unset($this->{$type}[$fileOrID]);
760
				}
761
			} else {
762
				$this->disabled[$type] = $this->{$type};
763
				$this->{$type} = array();
764
			}
765
		}
766
	}
767
768
	/**
769
	 * Restore requirements cleared by call to Requirements::clear
770
	 */
771
	public function restore() {
772
		$types = array(
773
			'javascript',
774
			'css',
775
			'customScript',
776
			'customCSS',
777
			'customHeadTags',
778
			'combine_files',
779
		);
780
		foreach ($types as $type) {
781
			$this->{$type} = $this->disabled[$type];
782
		}
783
	}
784
	/**
785
	 * Block inclusion of a specific file
786
	 *
787
	 * The difference between this and {@link clear} is that the calling order does not matter;
788
	 * {@link clear} must be called after the initial registration, whereas {@link block} can be
789
	 * used in advance. This is useful, for example, to block scripts included by a superclass
790
	 * without having to override entire functions and duplicate a lot of code.
791
	 *
792
	 * Note that blocking should be used sparingly because it's hard to trace where an file is
793
	 * being blocked from.
794
	 *
795
	 * @param string|int $fileOrID
796
	 */
797
	public function block($fileOrID) {
798
		$this->blocked[$fileOrID] = $fileOrID;
799
	}
800
801
	/**
802
	 * Remove an item from the block list
803
	 *
804
	 * @param string|int $fileOrID
805
	 */
806
	public function unblock($fileOrID) {
807
		if(isset($this->blocked[$fileOrID])) unset($this->blocked[$fileOrID]);
808
	}
809
810
	/**
811
	 * Removes all items from the block list
812
	 */
813
	public function unblock_all() {
814
		$this->blocked = array();
815
	}
816
817
	/**
818
	 * Update the given HTML content with the appropriate include tags for the registered
819
	 * requirements. Needs to receive a valid HTML/XHTML template in the $content parameter,
820
	 * including a head and body tag.
821
	 *
822
	 * @param string $templateFile No longer used, only retained for compatibility
823
	 * @param string $content      HTML content that has already been parsed from the $templateFile
824
	 *                             through {@link SSViewer}
825
	 * @return string HTML content augmented with the requirements tags
826
	 */
827
	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...
828
		if(
829
			(strpos($content, '</head>') !== false || strpos($content, '</head ') !== false)
830
			&& ($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...
831
		) {
832
			$requirements = '';
833
			$jsRequirements = '';
834
835
			// Combine files - updates $this->javascript and $this->css
836
			$this->process_combined_files();
837
838
			foreach(array_diff_key($this->javascript,$this->blocked) as $file => $dummy) {
839
				$path = Convert::raw2xml($this->path_for_file($file));
0 ignored issues
show
Bug introduced by
It seems like $this->path_for_file($file) targeting Requirements_Backend::path_for_file() 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...
840
				if($path) {
841
					$jsRequirements .= "<script type=\"text/javascript\" src=\"$path\"></script>\n";
842
				}
843
			}
844
845
			// Add all inline JavaScript *after* including external files they might rely on
846
			if($this->customScript) {
0 ignored issues
show
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...
847
				foreach(array_diff_key($this->customScript,$this->blocked) as $script) {
848
					$jsRequirements .= "<script type=\"text/javascript\">\n//<![CDATA[\n";
849
					$jsRequirements .= "$script\n";
850
					$jsRequirements .= "\n//]]>\n</script>\n";
851
				}
852
			}
853
854
			foreach(array_diff_key($this->css,$this->blocked) as $file => $params) {
855
				$path = Convert::raw2xml($this->path_for_file($file));
0 ignored issues
show
Bug introduced by
It seems like $this->path_for_file($file) targeting Requirements_Backend::path_for_file() 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...
856
				if($path) {
857
					$media = (isset($params['media']) && !empty($params['media']))
858
						? " media=\"{$params['media']}\"" : "";
859
					$requirements .= "<link rel=\"stylesheet\" type=\"text/css\"{$media} href=\"$path\" />\n";
860
				}
861
			}
862
863
			foreach(array_diff_key($this->customCSS, $this->blocked) as $css) {
864
				$requirements .= "<style type=\"text/css\">\n$css\n</style>\n";
865
			}
866
867
			foreach(array_diff_key($this->customHeadTags,$this->blocked) as $customHeadTag) {
868
				$requirements .= "$customHeadTag\n";
869
			}
870
871
			$replacements = array();
872
			if ($this->force_js_to_bottom) {
873
				$jsRequirements = $this->removeNewlinesFromCode($jsRequirements);
874
875
				// Forcefully put the scripts at the bottom of the body instead of before the first
876
				// script tag.
877
				$replacements["/(<\/body[^>]*>)/i"] = $this->escapeReplacement($jsRequirements) . "\\1";
878
879
				// Put CSS at the bottom of the head
880
				$replacements["/(<\/head>)/i"] = $this->escapeReplacement($requirements) . "\\1";
881
			} elseif ($this->write_js_to_body) {
882
				$jsRequirements = $this->removeNewlinesFromCode($jsRequirements);
883
884
				// If your template already has script tags in the body, then we try to put our script
885
				// tags just before those. Otherwise, we put it at the bottom.
886
				$p2 = stripos($content, '<body');
887
				$p1 = stripos($content, '<script', $p2);
888
889
				$commentTags = array();
890
				$canWriteToBody = ($p1 !== false)
891
					&&
892
					// Check that the script tag is not inside a html comment tag
893
					!(
894
						preg_match('/.*(?|(<!--)|(-->))/U', $content, $commentTags, 0, $p1)
895
						&&
896
						$commentTags[1] == '-->'
897
					);
898
899
				if ($canWriteToBody) {
900
					$content = substr($content, 0, $p1) . $jsRequirements . substr($content, $p1);
901
				} else {
902
					$replacements["/(<\/body[^>]*>)/i"] = $this->escapeReplacement($jsRequirements) . "\\1";
903
				}
904
905
				// Put CSS at the bottom of the head
906
				$replacements["/(<\/head>)/i"] = $this->escapeReplacement($requirements) . "\\1";
907
			} else {
908
				// Put CSS and Javascript together before the closing head tag
909
				$replacements["/(<\/head>)/i"] = $this->escapeReplacement($requirements . $jsRequirements) . "\\1";
910
			}
911
912
			if (!empty($replacements)) {
913
				// Replace everything at once (only once)
914
				$content = preg_replace(array_keys($replacements), array_values($replacements), $content, 1);
915
			}
916
		}
917
918
		return $content;
919
	}
920
921
	/**
922
	 * Remove all newlines from code to preserve layout
923
	 *
924
	 * @param  string $code
925
	 * @return string
926
	 */
927
	protected function removeNewlinesFromCode($code) {
928
		return preg_replace('/>\n*/', '>', $code);
929
	}
930
931
	/**
932
	 * Safely escape a literal string for use in preg_replace replacement
933
	 *
934
	 * @param string $replacement
935
	 * @return string
936
	 */
937
	protected function escapeReplacement($replacement) {
938
		return addcslashes($replacement, '\\$');
939
	}
940
941
	/**
942
	 * Attach requirements inclusion to X-Include-JS and X-Include-CSS headers on the given
943
	 * HTTP Response
944
	 *
945
	 * @param SS_HTTPResponse $response
946
	 */
947
	public function include_in_response(SS_HTTPResponse $response) {
948
		$this->process_combined_files();
949
		$jsRequirements = array();
950
		$cssRequirements = array();
951
952
		foreach(array_diff_key($this->javascript, $this->blocked) as $file => $dummy) {
953
			$path = $this->path_for_file($file);
954
			if($path) {
955
				$jsRequirements[] = str_replace(',', '%2C', $path);
956
			}
957
		}
958
959
		$response->addHeader('X-Include-JS', implode(',', $jsRequirements));
960
961
		foreach(array_diff_key($this->css,$this->blocked) as $file => $params) {
962
			$path = $this->path_for_file($file);
963
			if($path) {
964
				$path = str_replace(',', '%2C', $path);
965
				$cssRequirements[] = isset($params['media']) ? "$path:##:$params[media]" : $path;
966
			}
967
		}
968
969
		$response->addHeader('X-Include-CSS', implode(',', $cssRequirements));
970
	}
971
972
	/**
973
	 * Add i18n files from the given javascript directory. SilverStripe expects that the given
974
	 * directory will contain a number of JavaScript files named by language: en_US.js, de_DE.js,
975
	 * etc.
976
	 *
977
	 * @param string $langDir  The JavaScript lang directory, relative to the site root, e.g.,
978
	 *                         'framework/javascript/lang'
979
	 * @param bool   $return   Return all relative file paths rather than including them in
980
	 *                         requirements
981
	 * @param bool   $langOnly Only include language files, not the base libraries
982
	 *
983
	 * @return array
984
	 */
985
	public function add_i18n_javascript($langDir, $return = false, $langOnly = false) {
986
		$files = array();
987
		$base = Director::baseFolder() . '/';
988
		if(i18n::config()->js_i18n) {
989
			// Include i18n.js even if no languages are found.  The fact that
990
			// add_i18n_javascript() was called indicates that the methods in
991
			// here are needed.
992
			if(!$langOnly) $files[] = FRAMEWORK_DIR . '/javascript/i18n.js';
993
994
			if(substr($langDir,-1) != '/') $langDir .= '/';
995
996
			$candidates = array(
997
				'en.js',
998
				'en_US.js',
999
				i18n::get_lang_from_locale(i18n::default_locale()) . '.js',
0 ignored issues
show
Deprecated Code introduced by
The method i18n::default_locale() has been deprecated with message: since version 4.0; Use the "i18n.default_locale" config setting instead

This method has been deprecated. The supplier of the class has supplied an explanatory message.

The explanatory message should give you some clue as to whether and when the method will be removed from the class and what other method or class to use instead.

Loading history...
1000
				i18n::default_locale() . '.js',
0 ignored issues
show
Deprecated Code introduced by
The method i18n::default_locale() has been deprecated with message: since version 4.0; Use the "i18n.default_locale" config setting instead

This method has been deprecated. The supplier of the class has supplied an explanatory message.

The explanatory message should give you some clue as to whether and when the method will be removed from the class and what other method or class to use instead.

Loading history...
1001
				i18n::get_lang_from_locale(i18n::get_locale()) . '.js',
1002
				i18n::get_locale() . '.js',
1003
			);
1004
			foreach($candidates as $candidate) {
1005
				if(file_exists($base . DIRECTORY_SEPARATOR . $langDir . $candidate)) {
1006
					$files[] = $langDir . $candidate;
1007
				}
1008
			}
1009
		} else {
1010
			// Stub i18n implementation for when i18n is disabled.
1011
			if(!$langOnly) $files[] = FRAMEWORK_DIR . '/javascript/i18nx.js';
1012
		}
1013
1014
		if($return) {
1015
			return $files;
1016
		} else {
1017
			foreach($files as $file) $this->javascript($file);
1018
		}
1019
	}
1020
1021
	/**
1022
	 * Finds the path for specified file
1023
	 *
1024
	 * @param string $fileOrUrl
1025
	 * @return string|bool
1026
	 */
1027
	protected function path_for_file($fileOrUrl) {
1028
		if(preg_match('{^//|http[s]?}', $fileOrUrl)) {
1029
			return $fileOrUrl;
1030
		} elseif(Director::fileExists($fileOrUrl)) {
1031
			$filePath = preg_replace('/\?.*/', '', Director::baseFolder() . '/' . $fileOrUrl);
1032
			$prefix = Director::baseURL();
1033
			$mtimesuffix = "";
1034
			$suffix = '';
1035
			if($this->suffix_requirements) {
1036
				$mtimesuffix = "?m=" . filemtime($filePath);
1037
				$suffix = '&';
1038
			}
1039
			if(strpos($fileOrUrl, '?') !== false) {
1040
				if (strlen($suffix) == 0) {
1041
					$suffix = '?';
1042
				}
1043
				$suffix .= substr($fileOrUrl, strpos($fileOrUrl, '?')+1);
1044
				$fileOrUrl = substr($fileOrUrl, 0, strpos($fileOrUrl, '?'));
1045
			} else {
1046
				$suffix = '';
1047
			}
1048
			return "{$prefix}{$fileOrUrl}{$mtimesuffix}{$suffix}";
1049
		} else {
1050
			return false;
1051
		}
1052
	}
1053
1054
	/**
1055
	 * Concatenate several css or javascript files into a single dynamically generated file. This
1056
	 * increases performance by fewer HTTP requests.
1057
	 *
1058
	 * The combined file is regenerated based on every file modification time. Optionally a
1059
	 * rebuild can be triggered by appending ?flush=1 to the URL. If all files to be combined are
1060
	 * JavaScript, we use the external JSMin library to minify the JavaScript. This can be
1061
	 * controlled using {@link $combine_js_with_jsmin}.
1062
	 *
1063
	 * All combined files will have a comment on the start of each concatenated file denoting their
1064
	 * original position. For easier debugging, we only minify JavaScript if not in development
1065
	 * mode ({@link Director::isDev()}).
1066
	 *
1067
	 * CAUTION: You're responsible for ensuring that the load order for combined files is
1068
	 * retained - otherwise combining JavaScript files can lead to functional errors in the
1069
	 * JavaScript logic, and combining CSS can lead to incorrect inheritance. You can also
1070
	 * only include each file once across all includes and combinations in a single page load.
1071
	 *
1072
	 * CAUTION: Combining CSS Files discards any "media" information.
1073
	 *
1074
	 * Example for combined JavaScript:
1075
	 * <code>
1076
	 * Requirements::combine_files(
1077
	 *  'foobar.js',
1078
	 *  array(
1079
	 *        'mysite/javascript/foo.js',
1080
	 *        'mysite/javascript/bar.js',
1081
	 *    )
1082
	 * );
1083
	 * </code>
1084
	 *
1085
	 * Example for combined CSS:
1086
	 * <code>
1087
	 * Requirements::combine_files(
1088
	 *  'foobar.css',
1089
	 *    array(
1090
	 *        'mysite/javascript/foo.css',
1091
	 *        'mysite/javascript/bar.css',
1092
	 *    )
1093
	 * );
1094
	 * </code>
1095
	 *
1096
	 * @param string $combinedFileName Filename of the combined file relative to docroot
1097
	 * @param array  $files            Array of filenames relative to docroot
1098
	 * @param string $media
1099
	 *
1100
	 * @return bool|void
1101
	 */
1102
	public function combine_files($combinedFileName, $files, $media = null) {
1103
		// duplicate check
1104
		foreach($this->combine_files as $_combinedFileName => $_files) {
1105
			$duplicates = array_intersect($_files, $files);
1106
			if($duplicates && $combinedFileName != $_combinedFileName) {
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...
1107
				user_error("Requirements_Backend::combine_files(): Already included files " . implode(',', $duplicates)
1108
					. " in combined file '{$_combinedFileName}'", E_USER_NOTICE);
1109
				return false;
1110
			}
1111
		}
1112
		foreach($files as $index=>$file) {
1113
			if(is_array($file)) {
1114
				// Either associative array path=>path type=>type or numeric 0=>path 1=>type
1115
				// Otherwise, assume path is the first item
1116
				if (isset($file['type']) && in_array($file['type'], array('css', 'javascript', 'js'))) {
1117
					switch ($file['type']) {
1118
						case 'css':
1119
							$this->css($file['path'], $media);
1120
							break;
1121
						default:
1122
							$this->javascript($file['path']);
1123
							break;
1124
					}
1125
					$files[$index] = $file['path'];
1126
				} elseif (isset($file[1]) && in_array($file[1], array('css', 'javascript', 'js'))) {
1127
					switch ($file[1]) {
1128
						case 'css':
1129
							$this->css($file[0], $media);
1130
							break;
1131
						default:
1132
							$this->javascript($file[0]);
1133
							break;
1134
					}
1135
					$files[$index] = $file[0];
1136
				} else {
1137
					$file = array_shift($file);
1138
				}
1139
			}
1140
			if (!is_array($file)) {
1141
				if(substr($file, -2) == 'js') {
1142
					$this->javascript($file);
1143
				} elseif(substr($file, -3) == 'css') {
1144
					$this->css($file, $media);
1145
				} else {
1146
					user_error("Requirements_Backend::combine_files(): Couldn't guess file type for file '$file', "
1147
						. "please specify by passing using an array instead.", E_USER_NOTICE);
1148
				}
1149
			}
1150
		}
1151
		$this->combine_files[$combinedFileName] = $files;
1152
	}
1153
1154
	/**
1155
	 * Return all combined files; keys are the combined file names, values are lists of
1156
	 * files being combined.
1157
	 *
1158
	 * @return array
1159
	 */
1160
	public function get_combine_files() {
1161
		return $this->combine_files;
1162
	}
1163
1164
	/**
1165
	 * Delete all dynamically generated combined files from the filesystem
1166
	 *
1167
	 * @param string $combinedFileName If left blank, all combined files are deleted.
1168
	 */
1169
	public function delete_combined_files($combinedFileName = null) {
1170
		$combinedFiles = ($combinedFileName) ? array($combinedFileName => null) : $this->combine_files;
1171
		$combinedFolder = ($this->getCombinedFilesFolder()) ?
1172
			(Director::baseFolder() . '/' . $this->combinedFilesFolder) : Director::baseFolder();
1173
		foreach($combinedFiles as $combinedFile => $sourceItems) {
1174
			$filePath = $combinedFolder . '/' . $combinedFile;
1175
			if(file_exists($filePath)) {
1176
				unlink($filePath);
1177
			}
1178
		}
1179
	}
1180
1181
	/**
1182
	 * Deletes all generated combined files in the configured combined files directory,
1183
	 * but doesn't delete the directory itself.
1184
	 */
1185
	public function delete_all_combined_files() {
1186
		$combinedFolder = $this->getCombinedFilesFolder();
1187
		if(!$combinedFolder) return false;
1188
1189
		$path = Director::baseFolder() . '/' . $combinedFolder;
1190
		if(file_exists($path)) {
1191
			Filesystem::removeFolder($path, true);
1192
		}
1193
	}
1194
1195
	/**
1196
	 * Clear all registered CSS and JavaScript file combinations
1197
	 */
1198
	public function clear_combined_files() {
1199
		$this->combine_files = array();
1200
	}
1201
1202
	/**
1203
	 * Do the heavy lifting involved in combining (and, in the case of JavaScript minifying) the
1204
	 * combined files.
1205
	 */
1206
	public function process_combined_files() {
0 ignored issues
show
Coding Style introduced by
process_combined_files 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...
1207
		// The class_exists call prevents us loading SapphireTest.php (slow) just to know that
1208
		// SapphireTest isn't running :-)
1209
		if(class_exists('SapphireTest', false)) $runningTest = SapphireTest::is_running_test();
1210
		else $runningTest = false;
1211
1212
		if((Director::isDev() && !$runningTest && !isset($_REQUEST['combine'])) || !$this->combined_files_enabled) {
1213
			return;
1214
		}
1215
1216
		// Make a map of files that could be potentially combined
1217
		$combinerCheck = array();
1218
		foreach($this->combine_files as $combinedFile => $sourceItems) {
1219
			foreach($sourceItems as $sourceItem) {
1220
				if(isset($combinerCheck[$sourceItem]) && $combinerCheck[$sourceItem] != $combinedFile){
1221
					user_error("Requirements_Backend::process_combined_files - file '$sourceItem' appears in two " .
1222
						"combined files:" .	" '{$combinerCheck[$sourceItem]}' and '$combinedFile'", E_USER_WARNING);
1223
				}
1224
				$combinerCheck[$sourceItem] = $combinedFile;
1225
1226
			}
1227
		}
1228
1229
		// Work out the relative URL for the combined files from the base folder
1230
		$combinedFilesFolder = ($this->getCombinedFilesFolder()) ? ($this->getCombinedFilesFolder() . '/') : '';
1231
1232
		// Figure out which ones apply to this request
1233
		$combinedFiles = array();
1234
		$newJSRequirements = array();
1235
		$newCSSRequirements = array();
1236
		foreach($this->javascript as $file => $dummy) {
1237
			if(isset($combinerCheck[$file])) {
1238
				$newJSRequirements[$combinedFilesFolder . $combinerCheck[$file]] = true;
1239
				$combinedFiles[$combinerCheck[$file]] = true;
1240
			} else {
1241
				$newJSRequirements[$file] = true;
1242
			}
1243
		}
1244
1245
		foreach($this->css as $file => $params) {
1246
			if(isset($combinerCheck[$file])) {
1247
				// Inherit the parameters from the last file in the combine set.
1248
				$newCSSRequirements[$combinedFilesFolder . $combinerCheck[$file]] = $params;
1249
				$combinedFiles[$combinerCheck[$file]] = true;
1250
			} else {
1251
				$newCSSRequirements[$file] = $params;
1252
			}
1253
		}
1254
1255
		// Process the combined files
1256
		$base = Director::baseFolder() . '/';
1257
		foreach(array_diff_key($combinedFiles, $this->blocked) as $combinedFile => $dummy) {
1258
			$fileList = $this->combine_files[$combinedFile];
1259
			$combinedFilePath = $base . $combinedFilesFolder . '/' . $combinedFile;
1260
1261
1262
			// Make the folder if necessary
1263
			if(!file_exists(dirname($combinedFilePath))) {
1264
				Filesystem::makeFolder(dirname($combinedFilePath));
1265
			}
1266
1267
			// If the file isn't writeable, don't even bother trying to make the combined file and return. The
1268
			// files will be included individually instead. This is a complex test because is_writable fails
1269
			// if the file doesn't exist yet.
1270
			if((file_exists($combinedFilePath) && !is_writable($combinedFilePath))
1271
				|| (!file_exists($combinedFilePath) && !is_writable(dirname($combinedFilePath)))
1272
			) {
1273
				user_error("Requirements_Backend::process_combined_files(): Couldn't create '$combinedFilePath'",
1274
					E_USER_WARNING);
1275
				return false;
1276
			}
1277
1278
			// Determine if we need to build the combined include
1279
			if(file_exists($combinedFilePath)) {
1280
				// file exists, check modification date of every contained file
1281
				$srcLastMod = 0;
1282
				foreach($fileList as $file) {
1283
					if(file_exists($base . $file)) {
1284
						$srcLastMod = max(filemtime($base . $file), $srcLastMod);
1285
					}
1286
				}
1287
				$refresh = $srcLastMod > filemtime($combinedFilePath);
1288
			} else {
1289
				// File doesn't exist, or refresh was explicitly required
1290
				$refresh = true;
1291
			}
1292
1293
			if(!$refresh) continue;
1294
1295
			$failedToMinify = false;
1296
			$combinedData = "";
1297
			foreach(array_diff($fileList, $this->blocked) as $file) {
1298
				$fileContent = file_get_contents($base . $file);
1299
1300
				try{
1301
					$fileContent = $this->minifyFile($file, $fileContent);
1302
				}catch(Exception $e){
1303
					$failedToMinify = true;
1304
				}
1305
1306
				if ($this->write_header_comment) {
1307
					// Write a header comment for each file for easier identification and debugging. The semicolon between each file is required for jQuery to be combined properly and protects against unterminated statements.
1308
					$combinedData .= "/****** FILE: $file *****/\n";
1309
				}
1310
1311
				$combinedData .= $fileContent . "\n";
1312
			}
1313
1314
			$successfulWrite = false;
1315
			$fh = fopen($combinedFilePath, 'wb');
1316
			if($fh) {
1317
				if(fwrite($fh, $combinedData) == strlen($combinedData)) $successfulWrite = true;
1318
				fclose($fh);
1319
				unset($fh);
1320
			}
1321
1322
			if($failedToMinify){
1323
				// Failed to minify, use unminified files instead. This warning is raised at the end to allow code execution
1324
				// to complete in case this warning is caught inside a try-catch block.
1325
				user_error('Failed to minify '.$file.', exception: '.$e->getMessage(), E_USER_WARNING);
0 ignored issues
show
Bug introduced by
The variable $file does not seem to be defined for all execution paths leading up to this point.

If you define a variable conditionally, it can happen that it is not defined for all execution paths.

Let’s take a look at an example:

function myFunction($a) {
    switch ($a) {
        case 'foo':
            $x = 1;
            break;

        case 'bar':
            $x = 2;
            break;
    }

    // $x is potentially undefined here.
    echo $x;
}

In the above example, the variable $x is defined if you pass “foo” or “bar” as argument for $a. However, since the switch statement has no default case statement, if you pass any other value, the variable $x would be undefined.

Available Fixes

  1. Check for existence of the variable explicitly:

    function myFunction($a) {
        switch ($a) {
            case 'foo':
                $x = 1;
                break;
    
            case 'bar':
                $x = 2;
                break;
        }
    
        if (isset($x)) { // Make sure it's always set.
            echo $x;
        }
    }
    
  2. Define a default value for the variable:

    function myFunction($a) {
        $x = ''; // Set a default which gets overridden for certain paths.
        switch ($a) {
            case 'foo':
                $x = 1;
                break;
    
            case 'bar':
                $x = 2;
                break;
        }
    
        echo $x;
    }
    
  3. Add a value for the missing path:

    function myFunction($a) {
        switch ($a) {
            case 'foo':
                $x = 1;
                break;
    
            case 'bar':
                $x = 2;
                break;
    
            // We add support for the missing case.
            default:
                $x = '';
                break;
        }
    
        echo $x;
    }
    
Loading history...
Bug introduced by
The variable $e does not seem to be defined for all execution paths leading up to this point.

If you define a variable conditionally, it can happen that it is not defined for all execution paths.

Let’s take a look at an example:

function myFunction($a) {
    switch ($a) {
        case 'foo':
            $x = 1;
            break;

        case 'bar':
            $x = 2;
            break;
    }

    // $x is potentially undefined here.
    echo $x;
}

In the above example, the variable $x is defined if you pass “foo” or “bar” as argument for $a. However, since the switch statement has no default case statement, if you pass any other value, the variable $x would be undefined.

Available Fixes

  1. Check for existence of the variable explicitly:

    function myFunction($a) {
        switch ($a) {
            case 'foo':
                $x = 1;
                break;
    
            case 'bar':
                $x = 2;
                break;
        }
    
        if (isset($x)) { // Make sure it's always set.
            echo $x;
        }
    }
    
  2. Define a default value for the variable:

    function myFunction($a) {
        $x = ''; // Set a default which gets overridden for certain paths.
        switch ($a) {
            case 'foo':
                $x = 1;
                break;
    
            case 'bar':
                $x = 2;
                break;
        }
    
        echo $x;
    }
    
  3. Add a value for the missing path:

    function myFunction($a) {
        switch ($a) {
            case 'foo':
                $x = 1;
                break;
    
            case 'bar':
                $x = 2;
                break;
    
            // We add support for the missing case.
            default:
                $x = '';
                break;
        }
    
        echo $x;
    }
    
Loading history...
1326
			}
1327
1328
			// Unsuccessful write - just include the regular JS files, rather than the combined one
1329
			if(!$successfulWrite) {
1330
				user_error("Requirements_Backend::process_combined_files(): Couldn't create '$combinedFilePath'",
1331
					E_USER_WARNING);
1332
				continue;
1333
			}
1334
		}
1335
1336
		// Note: Alters the original information, which means you can't call this method repeatedly - it will behave
1337
		// differently on the subsequent calls
1338
		$this->javascript = $newJSRequirements;
1339
		$this->css = $newCSSRequirements;
1340
	}
1341
1342
	/**
1343
	 * Minify the given $content according to the file type indicated in $filename
1344
	 *
1345
	 * @param string $filename
1346
	 * @param string $content
1347
	 * @return string
1348
	 */
1349
	protected function minifyFile($filename, $content) {
1350
		// if we have a javascript file and jsmin is enabled, minify the content
1351
		$isJS = stripos($filename, '.js');
1352
		if($isJS && $this->combine_js_with_jsmin) {
1353
			require_once('thirdparty/jsmin/jsmin.php');
1354
1355
			increase_time_limit_to();
1356
			$content = JSMin::minify($content);
1357
		}
1358
		$content .= ($isJS ? ';' : '') . "\n";
1359
		return $content;
1360
	}
1361
1362
	/**
1363
	 * Registers the given themeable stylesheet as required.
1364
	 *
1365
	 * A CSS file in the current theme path name 'themename/css/$name.css' is first searched for,
1366
	 * and it that doesn't exist and the module parameter is set then a CSS file with that name in
1367
	 * the module is used.
1368
	 *
1369
	 * @param string $name   The name of the file - eg '/css/File.css' would have the name 'File'
1370
	 * @param string $module The module to fall back to if the css file does not exist in the
1371
	 *                       current theme.
1372
	 * @param string $media  Comma-separated list of media types to use in the link tag
1373
	 *                       (e.g. 'screen,projector')
1374
	 */
1375
	public function themedCSS($name, $module = null, $media = null) {
1376
		$theme = SSViewer::get_theme_folder();
1377
		$project = project();
1378
		$absbase = BASE_PATH . DIRECTORY_SEPARATOR;
1379
		$abstheme = $absbase . $theme;
1380
		$absproject = $absbase . $project;
1381
		$css = "/css/$name.css";
1382
1383
		if(file_exists($absproject . $css)) {
1384
			$this->css($project . $css, $media);
1385
		} 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...
1386
			$this->css($theme . '_' . $module . $css, $media);
1387
		} elseif(file_exists($abstheme . $css)) {
1388
			$this->css($theme . $css, $media);
1389
		} 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...
1390
			$this->css($module . $css, $media);
1391
		}
1392
	}
1393
1394
	/**
1395
	 * Registers the given themeable javascript as required.
1396
	 *
1397
	 * A javascript file in the current theme path name 'themename/javascript/$name.js' is first searched for,
1398
	 * and it that doesn't exist and the module parameter is set then a javascript file with that name in
1399
	 * the module is used.
1400
	 *
1401
	 * @param string $name   The name of the file - eg '/js/File.js' would have the name 'File'
1402
	 * @param string $module The module to fall back to if the javascript file does not exist in the
1403
	 *                       current theme.
1404
	 * @param string $type  Comma-separated list of types to use in the script tag
1405
	 *                       (e.g. 'text/javascript,text/ecmascript')
1406
	 */
1407
	public function themedJavascript($name, $module = null, $type = null) {
0 ignored issues
show
Unused Code introduced by
The parameter $type 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...
1408
		$theme = SSViewer::get_theme_folder();
1409
		$project = project();
1410
		$absbase = BASE_PATH . DIRECTORY_SEPARATOR;
1411
		$abstheme = $absbase . $theme;
1412
		$absproject = $absbase . $project;
1413
		$js = "/javascript/$name.js";
1414
1415
		if(file_exists($absproject . $js)) {
1416
			$this->javascript($project . $js);
1417
		} elseif($module && file_exists($abstheme . '_' . $module.$js)) {
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...
1418
			$this->javascript($theme . '_' . $module . $js);
1419
		} elseif(file_exists($abstheme . $js)) {
1420
			$this->javascript($theme . $js);
1421
		} 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...
1422
			$this->javascript($module . $js);
1423
		}
1424
	}
1425
1426
	/**
1427
	 * Output debugging information.
1428
	 */
1429
	public function debug() {
1430
		Debug::show($this->javascript);
1431
		Debug::show($this->css);
1432
		Debug::show($this->customCSS);
1433
		Debug::show($this->customScript);
1434
		Debug::show($this->customHeadTags);
1435
		Debug::show($this->combine_files);
1436
	}
1437
1438
}
1439