SiteCombiner::site_css_minify()   B
last analyzed

Complexity

Conditions 7
Paths 6

Size

Total Lines 39
Code Lines 16

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 7
eloc 16
nc 6
nop 1
dl 0
loc 39
rs 8.8333
c 0
b 0
f 0
1
<?php
2
3
/**
4
 * Used to combine CSS and JS files in to a single compressed file
5
 *
6
 * @package   ElkArte Forum
7
 * @copyright ElkArte Forum contributors
8
 * @license   BSD http://opensource.org/licenses/BSD-3-Clause (see accompanying LICENSE.txt file)
9
 *
10
 * @version 2.0 Beta 1
11
 */
12
13
namespace ElkArte\Helper;
14
15
use Wikimedia\Minify\JavaScriptMinifier;
0 ignored issues
show
Bug introduced by
The type Wikimedia\Minify\JavaScriptMinifier was not found. Maybe you did not declare it correctly or list all dependencies?

The issue could also be caused by a filter entry in the build configuration. If the path has been excluded in your configuration, e.g. excluded_paths: ["lib/*"], you can move it to the dependency path list as follows:

filter:
    dependency_paths: ["lib/*"]

For further information see https://scrutinizer-ci.com/docs/tools/php/php-scrutinizer/#list-dependency-paths

Loading history...
16
17
/**
18
 * Used to combine CSS or JS files in to a single file
19
 *
20
 * What it does:
21
 *
22
 * - Checks if the files have changed, and if so, rebuilds the amalgamation
23
 * - Calls minification classes to reduce size of CSS and JS files, saving bandwidth
24
 */
25
class SiteCombiner
26
{
27
	/** @var array Holds all the files contents that we have joined in to one */
28
	private $_combine_files = [];
29
30
	/** @var string Holds the file name of our newly created file */
31
	private $_archive_name;
32
33
	/** @var string Holds the file names of the files in the compilation */
34
	private $_archive_filenames;
35
36
	/** @var string Holds the comment line to add at the start of the compressed compilation */
37
	private $_archive_header;
38
39
	/** @var string Holds the file data of the combined files */
40
	private $_cache = [];
41
42
	/** @var string Holds the file data of pre minimized files */
43
	private $_min_cache = [];
44
45
	/** @var string Holds the minified data of the combined files */
46
	private $_minified_cache;
47
48
	/** @var string The stale parameter added to the url */
49
	private $_archive_stale = CACHE_STALE;
50
51
	/** @var bool The parameter to indicate if minification should be run */
52
	private $_minify;
53
54
	/** @var string[] All files that was not possible to combine */
55
	private $_spares = [];
56
57
	/** @var \ElkArte\Helper\FileFunctions */
58
	private $fileFunc;
59
60
	/**
61
	 * Nothing much to do but start
62
	 *
63
	 * @param string $_archive_dir
64
	 * @param string $_archive_url
65
	 * @param bool $minimize
66
	 */
67
	public function __construct(private $_archive_dir, private $_archive_url, $minimize = true)
68
	{
69
		$this->_minify = $minimize ?? true;
70
		$this->fileFunc = FileFunctions::instance();
71
	}
72
73
	/**
74
	 * Combine JavaScript files in to a single file to save requests
75
	 *
76
	 * @param array $files array created by loadJavascriptFile() function
77
	 * @param bool $do_deferred true combines files with deferred tag, false combine other
78
	 *
79
	 * @return bool|string
80
	 */
81
	public function site_js_combine($files, $do_deferred)
82
	{
83
		if (!$this->_validRequest($files))
84
		{
85
			// Anything is spare
86
			$this->_addSpare($files);
87
88
			return false;
89
		}
90
91
		// Get the filenames and last modified time for this batch
92
		foreach ($files as $id => $file)
93
		{
94
			$load = (!$do_deferred && empty($file['options']['defer'])) || ($do_deferred && !empty($file['options']['defer']));
95
96
			// Get the ones that we would load locally, so we can merge them
97
			if (!$load)
98
			{
99
				continue;
100
			}
101
102
			if (!empty($file['options']['local']) && $this->_addFile($file['options']))
103
			{
104
				continue;
105
			}
106
107
			$this->_addSpare([$id => $file]);
108
		}
109
110
		// Nothing to combine
111
		if (count($this->_combine_files) === 0)
112
		{
113
			return true;
114
		}
115
116
		// Create an archive name
117
		$this->_buildName('.js');
118
119
		// No archive file, or a stale one, creates a new compilation
120
		if ($this->_isStale())
121
		{
122
			// Our buddies will be needed for this to work.
123
			require_once(SUBSDIR . '/Package.subs.php');
124
125
			$this->_archive_header = "/*!\n * " . $this->_archive_filenames . "\n */\n";
126
			$this->_combineFiles('js');
127
128
			// Minify, or not, these files
129
			$this->_minified_cache = $this->_minify ? $this->jsMinify($this->_cache) : trim($this->_cache);
130
131
			// Combined any pre minimized + our string
132
			$this->_minified_cache = $this->_min_cache . "\n" . $this->_minified_cache;
133
134
			// And save them for future users
135
			$this->_saveFiles();
136
		}
137
138
		// Return the name for inclusion in the output
139
		return $this->_archive_url . '/' . $this->_archive_name . $this->_archive_stale;
140
	}
141
142
	/**
143
	 * Checks if directory exists/writable and we have files
144
	 *
145
	 * @param $files
146
	 * @return bool
147
	 */
148
	private function _validRequest($files): bool
149
	{
150
		// No files or missing we are done
151
		if (empty($files))
152
		{
153
			return false;
154
		}
155
156
		// Directory not writable then we are done
157
		return $this->_validDestination();
158
	}
159
160
	/**
161
	 * Tests if the destination directory exists and is writable
162
	 *
163
	 * @return bool
164
	 */
165
	protected function _validDestination(): bool
166
	{
167
		return $this->fileFunc->isDir($this->_archive_dir) && $this->fileFunc->isWritable($this->_archive_dir);
168
	}
169
170
	/**
171
	 * Adds files to the spare list
172
	 *
173
	 * @param array $files
174
	 */
175
	protected function _addSpare($files): void
176
	{
177
		foreach ($files as $id => $file)
178
		{
179
			$this->_spares[$id] = $file;
180
		}
181
	}
182
183
	/**
184
	 * Add all the file parameters to the $_combine_files array
185
	 *
186
	 * What it does:
187
	 *
188
	 * - If the file has a 'stale' option defined, it will be added to the
189
	 *   $_stales array as well to be used later
190
	 * - Tags any files that are pre-minimized by filename matching .min.js
191
	 *
192
	 * @param string[] $options An array with all the passed file options:
193
	 * - dir
194
	 * - basename
195
	 * - file
196
	 * - url
197
	 * - stale (optional)
198
	 *
199
	 * @return bool
200
	 */
201
	private function _addFile($options): bool
202
	{
203
		if (!isset($options['dir']))
204
		{
205
			return false;
206
		}
207
208
		$filename = $options['dir'] . $options['basename'];
209
		if (!$this->fileFunc->fileExists($filename))
210
		{
211
			return false;
212
		}
213
214
		$this->_combine_files[$options['basename']] = [
215
			'file' => $filename,
216
			'basename' => $options['basename'],
217
			'url' => $options['url'],
218
			'filemtime' => filemtime($filename),
219
			'minimized' => strpos($options['basename'], '.min.js') || str_contains($options['basename'], '.min.css'),
220
		];
221
222
		return true;
223
	}
224
225
	/**
226
	 * Creates a new archive name
227
	 *
228
	 * @param string $type - should be one of '.js' or '.css'
229
	 */
230
	private function _buildName($type): void
231
	{
232
		global $settings;
233
234
		// Create this groups archive name
235
		$this->_archive_filenames = '';
236
		foreach ($this->_combine_files as $file)
237
		{
238
			$this->_archive_filenames .= $file['basename'] . ' ';
239
		}
240
241
		// Add in the actual theme url to make the sha1 unique to this hive
242
		$this->_archive_filenames = $settings['actual_theme_url'] . '/' . trim($this->_archive_filenames);
243
		$this->_archive_name = 'hive-' . sha1($this->_archive_filenames) . $type;
244
245
		// Create a cache stale for this hive ?x12345
246
		if (!empty($this->_combine_files))
247
		{
248
			$stale = max(array_column($this->_combine_files, 'filemtime'));
249
			$this->_archive_stale = '?x' . $stale;
250
		}
251
	}
252
253
	/**
254
	 * Determines if the existing combined file is stale
255
	 *
256
	 * - If any date of the files that make up the archive is newer than the archive, it's considered stale
257
	 */
258
	private function _isStale(): bool
259
	{
260
		// If any files in the archive are newer than the archive file itself, then the archive is stale
261
		$filemtime = $this->fileFunc->fileExists($this->_archive_dir . '/' . $this->_archive_name) ? filemtime($this->_archive_dir . '/' . $this->_archive_name) : 0;
262
263
		foreach ($this->_combine_files as $file)
264
		{
265
			if ($file['filemtime'] > $filemtime)
266
			{
267
				return true;
268
			}
269
		}
270
271
		return false;
272
	}
273
274
	/**
275
	 * Reads each files contents in to the _combine_files array
276
	 *
277
	 * What it does:
278
	 *
279
	 * - For each file, loads its contents in to the content key
280
	 * - If the file is CSS will convert some common relative links to the
281
	 * location of the hive
282
	 *
283
	 * @param string $type one of CSS or JS
284
	 */
285
	private function _combineFiles($type): void
286
	{
287
		// Remove any old cache file(s)
288
		$this->fileFunc->delete($this->_archive_dir . '/' . $this->_archive_name);
289
		$this->fileFunc->delete($this->_archive_dir . '/' . $this->_archive_name . '.gz');
290
291
		$_cache = [];
292
		$_min_cache = [];
293
294
		// Read in all the data so we can process
295
		foreach ($this->_combine_files as $key => $file)
296
		{
297
			if (!$this->fileFunc->fileExists($file['file']))
298
			{
299
				continue;
300
			}
301
302
			$tempfile = trim(file_get_contents($file['file']));
303
			$tempfile = (str_ends_with($tempfile, '}()')) ? $tempfile . ';' : $tempfile;
304
			$this->_combine_files[$key]['content'] = $tempfile;
305
306
			// CSS needs relative locations converted for the moved hive to work
307
			// @todo needs to be smarter, based on "new" cache location
308
			if ($type === 'css')
309
			{
310
				$tempfile = str_replace(['../../images', '../../webfonts', '../../scripts'], [$file['url'] . '/images', $file['url'] . '/webfonts', $file['url'] . '/scripts'], $tempfile);
311
			}
312
313
			// Add the file to the correct array for processing
314
			if ($file['minimized'] === false)
315
			{
316
				$_cache[] = $tempfile;
317
			}
318
			else
319
			{
320
				$_min_cache[] = $tempfile;
321
			}
322
		}
323
324
		// Build out our combined file strings
325
		$this->_cache = implode("\n", $_cache);
326
		$this->_min_cache = implode("\n", $_min_cache);
327
		unset($_cache, $_min_cache);
328
	}
329
330
	/**
331
	 * Takes a js file and compresses it to save space
332
	 *
333
	 * What it does:
334
	 *
335
	 * - Use JavaScriptMinifier
336
	 * - Failing that will return original uncompressed file
337
	 */
338
	public function jsMinify($js)
339
	{
340
		$fetch_data = JavaScriptMinifier::minify($js);
341
342
		// If we have nothing to return, use the original data
343
		return ($fetch_data === false || trim($fetch_data) === '') ? $this->_cache : $fetch_data;
344
	}
345
346
	/**
347
	 * Save a compilation file
348
	 */
349
	private function _saveFiles(): void
350
	{
351
		// Add in the file header if available
352
		if (!empty($this->_archive_header))
353
		{
354
			$this->_minified_cache = $this->_archive_header . $this->_minified_cache;
355
		}
356
357
		// Save the hive, or a nest, or a conglomeration. Like it was grown
358
		file_put_contents($this->_archive_dir . '/' . $this->_archive_name, $this->_minified_cache, LOCK_EX);
359
	}
360
361
	/**
362
	 * Minify individual javascript files
363
	 *
364
	 * @param array $files array created by loadJavascriptFile() function
365
	 *
366
	 * @return array
367
	 */
368
	public function site_js_minify($files): array
369
	{
370
		if (!$this->_validRequest($files))
371
		{
372
			return $files;
373
		}
374
375
		// Build the cache filename, check for changes, minify when requested
376
		require_once(SUBSDIR . '/Package.subs.php');
377
		foreach ($files as $id => $file)
378
		{
379
			// Clean start
380
			$this->_combine_files = [];
381
			$file['options']['minurl'] = $file['filename'];
382
383
			// Skip the ones that we would not load locally
384
			if (empty($file['options']['local']) || !$this->_addFile($file['options']))
385
			{
386
				continue;
387
			}
388
389
			// Get a file cache name and modification data
390
			$this->_buildName('.js');
391
392
			// Fresh version required?
393
			if ($this->_isStale())
394
			{
395
				$this->_combineFiles('js');
396
397
				$this->_minified_cache = empty($this->_min_cache) ? trim($this->jsMinify($this->_cache)) : $this->_min_cache;
398
399
				$this->_saveFiles();
400
			}
401
402
			$file['options']['minurl'] = $this->_archive_url . '/' . $this->_archive_name . $this->_archive_stale;
403
			$files[$id]['options'] = $file['options'];
404
			$files[$id]['filename'] = $file['options']['minurl'];
405
		}
406
407
		// Return the array for inclusion in the output
408
		return $files;
409
	}
410
411
	/**
412
	 * Combine CSS files in to a single file
413
	 *
414
	 * @param string[] $files
415
	 *
416
	 * @return bool|string
417
	 */
418
	public function site_css_combine($files)
419
	{
420
		if (!$this->_validRequest($files))
421
		{
422
			// Everything is spare
423
			$this->_addSpare($files);
424
425
			return false;
426
		}
427
428
		// Get the filenames and last modified time for this batch
429
		foreach ($files as $id => $file)
430
		{
431
			// Get the ones that we would load locally so we can merge them
432
			if (empty($file['options']['local']) || !$this->_addFile($file['options']))
433
			{
434
				$this->_addSpare([$id => $file]);
435
			}
436
		}
437
438
		// Nothing to do so return
439
		if (count($this->_combine_files) === 0)
440
		{
441
			return true;
442
		}
443
444
		// Create the CSS archive name
445
		$this->_buildName('.css');
446
447
		// No file, or a stale one, so we create a new CSS compilation
448
		if ($this->_isStale())
449
		{
450
			$this->_archive_header = "/*\n *" . $this->_archive_filenames . "\n */\n";
451
			$this->_combineFiles('css');
452
453
			// Compress CSS
454
			$this->_minified_cache = $this->_minify ? $this->cssMinify($this->_cache) : trim($this->_cache);
455
456
			// Combine in any pre minimized CSS files to our string
457
			$this->_minified_cache .= "\n" . $this->_min_cache;
458
459
			$this->_saveFiles();
460
		}
461
462
		// Return the name
463
		return $this->_archive_url . '/' . $this->_archive_name . $this->_archive_stale;
464
	}
465
466
	/**
467
	 * Takes a CSS file and compresses it to save space
468
	 *
469
	 * What it does:
470
	 *
471
	 * - Applies fast whitespace and comment removal
472
	 *
473
	 * @param string $css data to minify
474
	 * @return string Minified CSS data
475
	 */
476
	public function cssMinify(string $css = ''): string
477
	{
478
		// Simple fast whitespace and comment removal from Wikimedia CSSMin.php
479
		return trim(
480
			str_replace(
481
				[ '; ', ': ', ' {', '{ ', ', ', '} ', ';}', '( ', ' )', '[ ', ' ]' ],
482
				[ ';', ':', '{', '{', ',', '}', '}', '(', ')', '[', ']' ],
483
				preg_replace( [ '/\s+/', '/\/\*.*?\*\//s' ], [ ' ', '' ], $css )
484
			)
485
		);
486
	}
487
488
	/**
489
	 * Minify individual CSS files
490
	 *
491
	 * @param array $files array created by loadJavascriptFile() function
492
	 *
493
	 * @return array
494
	 */
495
	public function site_css_minify($files): array
496
	{
497
		if (!$this->_validRequest($files))
498
		{
499
			return $files;
500
		}
501
502
		// Build the cache filename, check for changes, minify when needed
503
		foreach ($files as $id => $file)
504
		{
505
			// Clean start
506
			$this->_combine_files = [];
507
			$file['options']['minurl'] = $file['filename'];
508
509
			// Skip the ones that we would not load locally
510
			if (empty($file['options']['local']) || !$this->_addFile($file['options']))
511
			{
512
				continue;
513
			}
514
515
			// Get a file cache name and modification data
516
			$this->_buildName('.css');
517
518
			// Fresh version required?
519
			if ($this->_isStale())
520
			{
521
				$this->_combineFiles('css');
522
				$this->_minified_cache = empty($this->_min_cache) ? trim($this->cssMinify($this->_cache)) : $this->_min_cache;
523
524
				$this->_saveFiles();
525
			}
526
527
			$file['options']['minurl'] = $this->_archive_url . '/' . $this->_archive_name . $this->_archive_stale;
528
			$files[$id]['options'] = $file['options'];
529
			$files[$id]['filename'] = $file['options']['minurl'];
530
		}
531
532
		// Return the array for inclusion in the output
533
		return $files;
534
	}
535
536
	/**
537
	 * Returns the info of the files that were not combined
538
	 *
539
	 * @return string[]
540
	 */
541
	public function getSpares(): array
542
	{
543
		return $this->_spares;
544
	}
545
546
	/**
547
	 * Deletes the CSS hives from the cache.
548
	 */
549
	public function removeCssHives(): bool
550
	{
551
		return $this->_removeHives('css');
552
	}
553
554
	/**
555
	 * Deletes hives from the cache based on extension.
556
	 *
557
	 * @param string $ext
558
	 *
559
	 * @return bool
560
	 */
561
	protected function _removeHives($ext): bool
562
	{
563
		$path = $this->_archive_dir . '/hive-*.' . $ext;
564
565
		$glob = new \GlobIterator($path, \FilesystemIterator::SKIP_DOTS);
566
		$fileFunc = FileFunctions::instance();
567
		$return = true;
568
569
		foreach ($glob as $file)
570
		{
571
			$return = $return && $fileFunc->delete($file->getPathname());
572
		}
573
574
		return $return;
575
	}
576
577
	/**
578
	 * Deletes the JS hives from the cache.
579
	 */
580
	public function removeJsHives(): bool
581
	{
582
		return $this->_removeHives('js');
583
	}
584
}
585