Issues (1686)

sources/ElkArte/Helper/SiteCombiner.php (1 issue)

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