Yaffs site version 1.1
[yaffs-website] / web / modules / contrib / advagg / advagg_js_minify / src / Asset / JsOptimizer.php
1 <?php
2
3 namespace Drupal\advagg_js_minify\Asset;
4
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;
12
13 /**
14  * Optimizes a JavaScript asset.
15  */
16 class JsOptimizer implements AssetOptimizerInterface {
17
18   /**
19    * The minify cache.
20    *
21    * @var \Drupal\Core\Cache\CacheBackendInterface
22    */
23   protected $cache;
24
25   /**
26    * A config object for the advagg css minify configuration.
27    *
28    * @var \Drupal\Core\Config\Config
29    */
30   protected $config;
31
32   /**
33    * A config object for the advagg configuration.
34    *
35    * @var \Drupal\Core\Config\Config
36    */
37   protected $advaggConfig;
38
39   /**
40    * The AdvAgg file status state information storage service.
41    *
42    * @var \Drupal\Core\State\StateInterface
43    */
44   protected $advaggFiles;
45
46   /**
47    * Module handler service.
48    *
49    * @var \Drupal\Core\Extension\ModuleHandlerInterface
50    */
51   protected $moduleHandler;
52
53   /**
54    * Logger service.
55    *
56    * @var \Psr\Log\LoggerInterface
57    */
58   protected $logger;
59
60   /**
61    * Construct the optimizer instance.
62    *
63    * @param \Drupal\Core\Cache\CacheBackendInterface $minify_cache
64    *   The 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
70    *   The module handler.
71    * @param \Psr\Log\LoggerInterface $logger
72    *   The logger service.
73    */
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;
81   }
82
83   /**
84    * Generate the js minify configuration.
85    *
86    * @return array
87    *   Array($options, $description, $compressors, $functions).
88    */
89   public function getConfiguration() {
90     // Set the defaults.
91     $description = '';
92     $options = [
93       0 => t('Disabled'),
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'),
99     ];
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.');
103     }
104     else {
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/',
107       ]);
108     }
109
110     $minifiers = [
111       1 => 'jsminplus',
112       2 => 'packer',
113       4 => 'jshrink',
114       5 => 'jsqueeze',
115     ];
116     if (function_exists('jsmin')) {
117       $minifiers[3] = 'jsmin';
118     }
119
120     $functions = [
121       1 => [$this, 'minifyJsminplus'],
122       2 => [$this, 'minifyJspacker'],
123       3 => [$this, 'minifyJsmin'],
124       4 => [$this, 'minifyJshrink'],
125       5 => [$this, 'minifyJsqueeze'],
126     ];
127
128     // Allow for other modules to alter this list.
129     $options_desc = [$options, $description];
130
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;
134
135     return [$options, $description, $minifiers, $functions];
136   }
137
138   /**
139    * {@inheritdoc}
140    */
141   public function optimize(array $js_asset) {
142     if ($js_asset['type'] !== 'file') {
143       throw new \Exception('Only file JavaScript assets can be optimized.');
144     }
145     if ($js_asset['type'] === 'file' && !$js_asset['preprocess']) {
146       throw new \Exception('Only file JavaScript assets with preprocessing enabled can be optimized.');
147     }
148
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);
154     }
155
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']);
159     }
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']];
165       }
166     }
167
168     // Do nothing if js file minification is disabled.
169     if (empty($minifier) || $this->advaggConfig->get('cache_level') < 0) {
170       return $data;
171     }
172
173     // Do not re-minify if the file is already minified.
174     $semicolon_count = substr_count($data, ';');
175     if ($minifier != 2
176       && $semicolon_count > 10
177       && $semicolon_count > (substr_count($data, "\n", strpos($data, ';')) * 5)
178       ) {
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. */";
182       }
183       return $data;
184     }
185
186     $data_original = $data;
187     $before = strlen($data);
188
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;
195     }
196     else {
197       // Use the minifier.
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]);
203         }
204       }
205       else {
206         return $data;
207       }
208
209       // Ensure that $data ends with ; or }.
210       if (strpbrk(substr(trim($data), -1), ';})') === FALSE) {
211         $data = trim($data) . ';';
212       }
213
214       // Cache minified data for at least 1 week.
215       $this->cache->set($cid, $data, REQUEST_TIME + (86400 * 7), ['advagg_js', $info['filename_hash']]);
216
217       // Make sure minification ratios are good.
218       $after = strlen($data);
219       $ratio = 0;
220       if ($before != 0) {
221         $ratio = ($before - $after) / $before;
222       }
223
224       // Make sure the returned string is not empty or has a VERY high
225       // minification ratio.
226       if (empty($data)
227         || empty($ratio)
228         || $ratio < 0
229         || $ratio > $this->config->get('ratio_max')
230       ) {
231         $data = $data_original;
232       }
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. */";
236       }
237     }
238     return $data;
239   }
240
241   /**
242    * Processes the contents of a javascript asset for cleanup.
243    *
244    * @param string $contents
245    *   The contents of the javascript asset.
246    *
247    * @return string
248    *   Contents of the javascript asset.
249    */
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);
253
254     return $contents;
255   }
256
257   /**
258    * Minify a JS string using jsmin.
259    *
260    * @param string $contents
261    *   Javascript string.
262    * @param array $asset
263    *   An asset.
264    */
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);
270       return;
271     }
272
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-
275     // byte characters.
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);
279       return;
280     }
281
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);
285
286     $minified = jsmin($contents);
287
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);
293       return;
294     }
295
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']]));
303     }
304     else {
305       $contents = $minified;
306     }
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']]));
312     }
313   }
314
315   /**
316    * Minify a JS string using jsmin+.
317    *
318    * @param string $contents
319    *   Javascript string.
320    * @param array $asset
321    *   An asset.
322    * @param bool $log_errors
323    *   FALSE to disable logging to watchdog on failure.
324    */
325   public function minifyJsminplus(&$contents, array $asset, $log_errors = TRUE) {
326     $contents_before = $contents;
327
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);
334       }
335     }
336     ob_start();
337     try {
338       // JSMin+ the contents of the aggregated file.
339       $contents = \JSMinPlus::minify($contents);
340
341       // Capture any output from JSMinPlus.
342       $error = trim(ob_get_contents());
343       if (!empty($error)) {
344         throw new \Exception($error);
345       }
346     }
347     catch (\Exception $e) {
348       // Log exception thrown by JSMin+ and roll back to uncompressed content.
349       if ($log_errors) {
350         $this->logger->warning($e->getMessage() . '<pre>' . $contents_before . '</pre>', []);
351       }
352       $contents = $contents_before;
353     }
354     ob_end_clean();
355   }
356
357   /**
358    * Minify a JS string using packer.
359    *
360    * @param string $contents
361    *   Javascript string.
362    * @param array $asset
363    *   An asset.
364    */
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';
369     }
370
371     // Add semicolons to the end of lines if missing.
372     $contents = str_replace("}\n", "};\n", $contents);
373     $contents = str_replace("\nfunction", ";\nfunction", $contents);
374
375     $packer = new \JavaScriptPacker($contents, 62, TRUE, FALSE);
376     $contents = $packer->pack();
377   }
378
379   /**
380    * Minify a JS string using jshrink.
381    *
382    * @param string $contents
383    *   Javascript string.
384    * @param array $asset
385    *   An asset.
386    * @param bool $log_errors
387    *   FALSE to disable logging to watchdog on failure.
388    */
389   public function minifyJshrink(&$contents, array $asset, $log_errors = TRUE) {
390     $contents_before = $contents;
391
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);
398       }
399     }
400     ob_start();
401     try {
402       // JShrink the contents of the aggregated file.
403       $contents = \JShrink\Minifier::minify($contents, ['flaggedComments' => FALSE]);
404
405       // Capture any output from JShrink.
406       $error = trim(ob_get_contents());
407       if (!empty($error)) {
408         throw new \Exception($error);
409       }
410     }
411     catch (\Exception $e) {
412       // Log the JShrink exception and rollback to uncompressed content.
413       if ($log_errors) {
414         $this->logger->warning($e->getMessage() . '<pre>' . $contents_before . '</pre>', []);
415       }
416       $contents = $contents_before;
417     }
418     ob_end_clean();
419   }
420
421   /**
422    * Minify a JS string using jsqueeze.
423    *
424    * @param string $contents
425    *   Javascript string.
426    * @param array $asset
427    *   An asset.
428    * @param bool $log_errors
429    *   FALSE to disable logging to watchdog on failure.
430    */
431   public function minifyJsqueeze(&$contents, array $asset, $log_errors = TRUE) {
432     $contents_before = $contents;
433
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);
440       }
441     }
442     ob_start();
443     try {
444       // Minify the contents of the aggregated file.
445       $jz = new \Patchwork\JSqueeze();
446       $contents = $jz->squeeze(
447         $contents,
448         TRUE,
449         !\Drupal::config('advagg_js_minify.settings')->get('add_license'),
450         FALSE
451       );
452
453       // Capture any output from JSqueeze.
454       $error = trim(ob_get_contents());
455       if (!empty($error)) {
456         throw new \Exception($error);
457       }
458     }
459     catch (\Exception $e) {
460       // Log the JSqueeze exception and rollback to uncompressed content.
461       if ($log_errors) {
462         $this->logger->warning('JSqueeze error, skipping file. ' . $e->getMessage() . '<pre>' . $contents_before . '</pre>', []);
463       }
464       $contents = $contents_before;
465     }
466     ob_end_clean();
467   }
468
469   /**
470    * Checks if string contains multibyte characters.
471    *
472    * @param string $string
473    *   String to check.
474    *
475    * @return bool
476    *   TRUE if string contains multibyte character.
477    */
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)) {
483       return FALSE;
484     }
485
486     return TRUE;
487   }
488
489 }