3 namespace Drupal\advagg_js_minify\Asset;
5 use Drupal\Component\Utility\Unicode;
6 use Drupal\Core\Asset\AssetOptimizerInterface;
7 use Drupal\Core\Cache\CacheBackendInterface;
8 use Drupal\Core\Config\ConfigFactoryInterface;
9 use Drupal\Core\Extension\ModuleHandlerInterface;
10 use Drupal\Core\State\StateInterface;
11 use Psr\Log\LoggerInterface;
14 * Optimizes a JavaScript asset.
16 class JsOptimizer implements AssetOptimizerInterface {
21 * @var \Drupal\Core\Cache\CacheBackendInterface
26 * A config object for the advagg css minify configuration.
28 * @var \Drupal\Core\Config\Config
33 * A config object for the advagg configuration.
35 * @var \Drupal\Core\Config\Config
37 protected $advaggConfig;
40 * The AdvAgg file status state information storage service.
42 * @var \Drupal\Core\State\StateInterface
44 protected $advaggFiles;
47 * Module handler service.
49 * @var \Drupal\Core\Extension\ModuleHandlerInterface
51 protected $moduleHandler;
56 * @var \Psr\Log\LoggerInterface
61 * Construct the optimizer instance.
63 * @param \Drupal\Core\Cache\CacheBackendInterface $minify_cache
65 * @param \Drupal\Core\Config\ConfigFactoryInterface $config_factory
66 * A config factory for retrieving required config objects.
67 * @param \Drupal\Core\State\StateInterface $advagg_files
68 * The AdvAgg file status state information storage service.
69 * @param \Drupal\Core\Extension\ModuleHandlerInterface $module_handler
71 * @param \Psr\Log\LoggerInterface $logger
74 public function __construct(CacheBackendInterface $minify_cache, ConfigFactoryInterface $config_factory, StateInterface $advagg_files, ModuleHandlerInterface $module_handler, LoggerInterface $logger) {
75 $this->cache = $minify_cache;
76 $this->config = $config_factory->get('advagg_js_minify.settings');
77 $this->advaggConfig = $config_factory->get('advagg.settings');
78 $this->advaggFiles = $advagg_files;
79 $this->moduleHandler = $module_handler;
80 $this->logger = $logger;
84 * Generate the js minify configuration.
87 * Array($options, $description, $compressors, $functions).
89 public function getConfiguration() {
94 1 => t('JSMin+ ~1300ms'),
95 // 2 => t('Packer ~500ms'),
96 // 3 is JSMin c extension.
97 4 => t('JShrink ~1000ms'),
98 5 => t('JSqueeze ~600ms'),
100 if (function_exists('jsmin')) {
101 $options[3] = t('JSMin ~2ms');
102 $description .= t('JSMin is the very fast C complied version. Recommend using it.');
105 $description .= t('You can use the much faster C version of JSMin (~2ms) by installing the <a href="@php_jsmin">JSMin PHP Extension</a> on this server.', [
106 '@php_jsmin' => 'https://github.com/sqmk/pecl-jsmin/',
116 if (function_exists('jsmin')) {
117 $minifiers[3] = 'jsmin';
121 1 => [$this, 'minifyJsminplus'],
122 2 => [$this, 'minifyJspacker'],
123 3 => [$this, 'minifyJsmin'],
124 4 => [$this, 'minifyJshrink'],
125 5 => [$this, 'minifyJsqueeze'],
128 // Allow for other modules to alter this list.
129 $options_desc = [$options, $description];
131 // Call hook_advagg_js_minify_configuration_alter().
132 $this->moduleHandler->alter('advagg_js_minify_configuration', $options_desc, $minifiers, $functions);
133 list($options, $description) = $options_desc;
135 return [$options, $description, $minifiers, $functions];
141 public function optimize(array $js_asset) {
142 if ($js_asset['type'] !== 'file') {
143 throw new \Exception('Only file JavaScript assets can be optimized.');
145 if ($js_asset['type'] === 'file' && !$js_asset['preprocess']) {
146 throw new \Exception('Only file JavaScript assets with preprocessing enabled can be optimized.');
149 // If a BOM is found, convert the file to UTF-8, then use substr() to
150 // remove the BOM from the result.
151 $data = file_get_contents($js_asset['data']);
152 if ($encoding = (Unicode::encodingFromBOM($data))) {
153 $data = Unicode::substr(Unicode::convertToUtf8($data, $encoding), 1);
156 // If no BOM is found, check for the charset attribute.
157 elseif (isset($js_asset['attributes']['charset'])) {
158 $data = Unicode::convertToUtf8($data, $js_asset['attributes']['charset']);
160 $minifier = $this->config->get('minifier');
161 if ($file_settings = $this->config->get('file_settings')) {
162 $file_settings = array_column($file_settings, 'minifier', 'path');
163 if (isset($file_settings[$js_asset['data']])) {
164 $minifier = $file_settings[$js_asset['data']];
168 // Do nothing if js file minification is disabled.
169 if (empty($minifier) || $this->advaggConfig->get('cache_level') < 0) {
173 // Do not re-minify if the file is already minified.
174 $semicolon_count = substr_count($data, ';');
176 && $semicolon_count > 10
177 && $semicolon_count > (substr_count($data, "\n", strpos($data, ';')) * 5)
179 if ($this->config->get('add_license')) {
180 $url = file_create_url($js_asset['data']);
181 $data = "/* Source and licensing information for the line(s) below can be found at $url. */\n" . $data . "\n/* Source and licensing information for the above line(s) can be found at $url. */";
186 $data_original = $data;
187 $before = strlen($data);
189 $info = $this->advaggFiles->get($js_asset['data']);
190 $cid = 'js_minify:' . $minifier . ':' . $info['filename_hash'];
191 $cid .= !empty($info['content_hash']) ? ':' . $info['content_hash'] : '';
192 $cached_data = $this->cache->get($cid);
193 if (!empty($cached_data->data)) {
194 $data = $cached_data->data;
198 list(, , , $functions) = $this->getConfiguration();
199 if (isset($functions[$minifier])) {
200 $run = $functions[$minifier];
201 if (is_callable($run)) {
202 call_user_func_array($run, [&$data, $js_asset]);
209 // Ensure that $data ends with ; or }.
210 if (strpbrk(substr(trim($data), -1), ';})') === FALSE) {
211 $data = trim($data) . ';';
214 // Cache minified data for at least 1 week.
215 $this->cache->set($cid, $data, REQUEST_TIME + (86400 * 7), ['advagg_js', $info['filename_hash']]);
217 // Make sure minification ratios are good.
218 $after = strlen($data);
221 $ratio = ($before - $after) / $before;
224 // Make sure the returned string is not empty or has a VERY high
225 // minification ratio.
229 || $ratio > $this->config->get('ratio_max')
231 $data = $data_original;
233 elseif ($this->config->get('add_license')) {
234 $url = file_create_url($js_asset['data']);
235 $data = "/* Source and licensing information for the line(s) below can be found at $url. */\n" . $data . "\n/* Source and licensing information for the above line(s) can be found at $url. */";
242 * Processes the contents of a javascript asset for cleanup.
244 * @param string $contents
245 * The contents of the javascript asset.
248 * Contents of the javascript asset.
250 public function clean($contents) {
251 // Remove JS source and source mapping urls or these may cause 404 errors.
252 $contents = preg_replace('/\/\/(#|@)\s(sourceURL|sourceMappingURL)=\s*(\S*?)\s*$/m', '', $contents);
258 * Minify a JS string using jsmin.
260 * @param string $contents
262 * @param array $asset
265 public function minifyJsmin(&$contents, array $asset) {
266 // Do not use jsmin() if the function can not be called.
267 if (!function_exists('jsmin')) {
268 $this->logger->notice(t('The jsmin function does not exist. Using JSqueeze.'), []);
269 $contents = $this->minifyJsqueeze($contents, $asset);
273 // Jsmin doesn't handle multi-byte characters before version 2, fall back to
274 // different minifier if jsmin version < 2 and $contents contains multi-
276 if (version_compare(phpversion('jsmin'), '2.0.0', '<') && $this->stringContainsMultibyteCharacters($contents)) {
277 $this->logger->notice('The currently installed jsmin version does not handle multibyte characters, you may consider to upgrade the jsmin extension. Using JSqueeze fallback.', []);
278 $contents = $this->minifyJsqueeze($contents, $asset);
282 // Jsmin may have errors (incorrectly determining EOLs) with mixed tabs
283 // and spaces. An example: jQuery.Cycle 3.0.3 - http://jquery.malsup.com/
284 $contents = str_replace("\t", " ", $contents);
286 $minified = jsmin($contents);
288 // Check for JSMin errors.
289 $error = jsmin_last_error_msg();
290 if ($error != 'No error') {
291 $this->logger->warning('JSMin had an error processing, usng JSqueeze fallback. Error details: ' . $error, []);
292 $contents = $this->minifyJsqueeze($contents, $asset);
296 // Under some unknown/rare circumstances, JSMin can add up to 5
297 // extraneous/wrong chars at the end of the string. Check and remove if
298 // necessary. The chars unfortunately vary in number and specific chars.
299 // Hence this is a poor quality check but should work.
300 if (ctype_cntrl(substr(trim($minified), -1)) || strpbrk(substr(trim($minified), -1), ';})') === FALSE) {
301 $contents = substr($minified, 0, strrpos($minified, ';'));
302 $this->logger->notice(t('JSMin had an error minifying: @file, correcting.', ['@file' => $asset['data']]));
305 $contents = $minified;
307 $semicolons = substr_count($contents, ';', strlen($contents) - 5);
308 if ($semicolons > 2) {
309 $start = substr($contents, 0, -5);
310 $contents = $start . preg_replace("/([;)}]*)([\w]*)([;)}]*)/", "$1$3", substr($contents, -5));
311 $this->logger->notice(t('JSMin had an error minifying file: @file, attempting to correct.', ['@file' => $asset['data']]));
316 * Minify a JS string using jsmin+.
318 * @param string $contents
320 * @param array $asset
322 * @param bool $log_errors
323 * FALSE to disable logging to watchdog on failure.
325 public function minifyJsminplus(&$contents, array $asset, $log_errors = TRUE) {
326 $contents_before = $contents;
328 // Only include jsminplus.inc if the JSMinPlus class doesn't exist.
329 if (!class_exists('\JSMinPlus')) {
330 include drupal_get_path('module', 'advagg_js_minify') . '/jsminplus.inc';
331 $nesting_level = ini_get('xdebug.max_nesting_level');
332 if (!empty($nesting_level) && $nesting_level < 200) {
333 ini_set('xdebug.max_nesting_level', 200);
338 // JSMin+ the contents of the aggregated file.
339 $contents = \JSMinPlus::minify($contents);
341 // Capture any output from JSMinPlus.
342 $error = trim(ob_get_contents());
343 if (!empty($error)) {
344 throw new \Exception($error);
347 catch (\Exception $e) {
348 // Log exception thrown by JSMin+ and roll back to uncompressed content.
350 $this->logger->warning($e->getMessage() . '<pre>' . $contents_before . '</pre>', []);
352 $contents = $contents_before;
358 * Minify a JS string using packer.
360 * @param string $contents
362 * @param array $asset
365 public function minifyJspacker(&$contents, array $asset) {
366 // Use Packer on the contents of the aggregated file.
367 if (!class_exists('\JavaScriptPacker')) {
368 include drupal_get_path('module', 'advagg_js_minify') . '/jspacker.inc';
371 // Add semicolons to the end of lines if missing.
372 $contents = str_replace("}\n", "};\n", $contents);
373 $contents = str_replace("\nfunction", ";\nfunction", $contents);
375 $packer = new \JavaScriptPacker($contents, 62, TRUE, FALSE);
376 $contents = $packer->pack();
380 * Minify a JS string using jshrink.
382 * @param string $contents
384 * @param array $asset
386 * @param bool $log_errors
387 * FALSE to disable logging to watchdog on failure.
389 public function minifyJshrink(&$contents, array $asset, $log_errors = TRUE) {
390 $contents_before = $contents;
392 // Only include jshrink.inc if the JShrink\Minifier class doesn't exist.
393 if (!class_exists('\JShrink\Minifier')) {
394 include drupal_get_path('module', 'advagg_js_minify') . '/jshrink.inc';
395 $nesting_level = ini_get('xdebug.max_nesting_level');
396 if (!empty($nesting_level) && $nesting_level < 200) {
397 ini_set('xdebug.max_nesting_level', 200);
402 // JShrink the contents of the aggregated file.
403 $contents = \JShrink\Minifier::minify($contents, ['flaggedComments' => FALSE]);
405 // Capture any output from JShrink.
406 $error = trim(ob_get_contents());
407 if (!empty($error)) {
408 throw new \Exception($error);
411 catch (\Exception $e) {
412 // Log the JShrink exception and rollback to uncompressed content.
414 $this->logger->warning($e->getMessage() . '<pre>' . $contents_before . '</pre>', []);
416 $contents = $contents_before;
422 * Minify a JS string using jsqueeze.
424 * @param string $contents
426 * @param array $asset
428 * @param bool $log_errors
429 * FALSE to disable logging to watchdog on failure.
431 public function minifyJsqueeze(&$contents, array $asset, $log_errors = TRUE) {
432 $contents_before = $contents;
434 // Only include jshrink.inc if the Patchwork\JSqueeze class doesn't exist.
435 if (!class_exists('\Patchwork\JSqueeze')) {
436 include drupal_get_path('module', 'advagg_js_minify') . '/jsqueeze.inc';
437 $nesting_level = ini_get('xdebug.max_nesting_level');
438 if (!empty($nesting_level) && $nesting_level < 200) {
439 ini_set('xdebug.max_nesting_level', 200);
444 // Minify the contents of the aggregated file.
445 $jz = new \Patchwork\JSqueeze();
446 $contents = $jz->squeeze(
449 !\Drupal::config('advagg_js_minify.settings')->get('add_license'),
453 // Capture any output from JSqueeze.
454 $error = trim(ob_get_contents());
455 if (!empty($error)) {
456 throw new \Exception($error);
459 catch (\Exception $e) {
460 // Log the JSqueeze exception and rollback to uncompressed content.
462 $this->logger->warning('JSqueeze error, skipping file. ' . $e->getMessage() . '<pre>' . $contents_before . '</pre>', []);
464 $contents = $contents_before;
470 * Checks if string contains multibyte characters.
472 * @param string $string
476 * TRUE if string contains multibyte character.
478 public function stringContainsMultibyteCharacters($string) {
479 // Check if there are multi-byte characters: If the UTF-8 encoded string has
480 // multibytes strlen() will return a byte-count greater than the actual
481 // character count, returned by drupal_strlen().
482 if (strlen($string) == drupal_strlen($string)) {