PluginProbe
WP Super Minify • Minify, Compress and Cache HTML, CSS & JavaScript / trunk
WP Super Minify • Minify, Compress and Cache HTML, CSS & JavaScript vtrunk
trunk 1.0 1.1 1.2 1.3 1.3.1 1.3.2 1.4 1.5 1.5.1 1.6 2.0 2.0.1
wp-super-minify / includes / min / src / Minify.php

Minify.php in WP Super Minify • Minify, Compress and Cache HTML, CSS & JavaScript trunk, at includes/min/src/Minify.php

561 lines 17.5 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 /**
4 * Abstract minifier class.
5 *
6 * Please report bugs on https://github.com/matthiasmullie/minify/issues
7 *
8 * @author Matthias Mullie <minify@mullie.eu>
9 * @copyright Copyright (c) 2012, Matthias Mullie. All rights reserved
10 * @license MIT License
11 */
12
13 namespace MatthiasMullie\Minify;
14
15 use MatthiasMullie\Minify\Exceptions\IOException;
16 use Psr\Cache\CacheItemInterface;
17
18 /**
19 * Abstract minifier class.
20 *
21 * Please report bugs on https://github.com/matthiasmullie/minify/issues
22 *
23 * @author Matthias Mullie <minify@mullie.eu>
24 * @copyright Copyright (c) 2012, Matthias Mullie. All rights reserved
25 * @license MIT License
26 */
27 abstract class Minify
28 {
29 /**
30 * The data to be minified.
31 *
32 * @var string[]
33 */
34 protected $data = array();
35
36 /**
37 * Array of patterns to match.
38 *
39 * @var string[]
40 */
41 protected $patterns = array();
42
43 /**
44 * This array will hold content of strings and regular expressions that have
45 * been extracted from the JS source code, so we can reliably match "code",
46 * without having to worry about potential "code-like" characters inside.
47 *
48 * @internal
49 *
50 * @var string[]
51 */
52 public $extracted = array();
53
54 /**
55 * Init the minify class - optionally, code may be passed along already.
56 */
57 public function __construct(/* $data = null, ... */)
58 {
59 // it's possible to add the source through the constructor as well ;)
60 if (func_num_args()) {
61 call_user_func_array(array($this, 'add'), func_get_args());
62 }
63 }
64
65 /**
66 * Add a file or straight-up code to be minified.
67 *
68 * @param string|string[] $data
69 *
70 * @return static
71 */
72 public function add($data /* $data = null, ... */)
73 {
74 // bogus "usage" of parameter $data: scrutinizer warns this variable is
75 // not used (we're using func_get_args instead to support overloading),
76 // but it still needs to be defined because it makes no sense to have
77 // this function without argument :)
78 $args = array($data) + func_get_args();
79
80 // this method can be overloaded
81 foreach ($args as $data) {
82 if (is_array($data)) {
83 call_user_func_array(array($this, 'add'), $data);
84 continue;
85 }
86
87 // redefine var
88 $data = (string) $data;
89
90 // load data
91 $value = $this->load($data);
92 $key = ($data != $value) ? $data : count($this->data);
93
94 // replace CR linefeeds etc.
95 // @see https://github.com/matthiasmullie/minify/pull/139
96 $value = str_replace(array("\r\n", "\r"), "\n", $value);
97
98 // store data
99 $this->data[$key] = $value;
100 }
101
102 return $this;
103 }
104
105 /**
106 * Add a file to be minified.
107 *
108 * @param string|string[] $data
109 *
110 * @return static
111 *
112 * @throws IOException
113 */
114 public function addFile($data /* $data = null, ... */)
115 {
116 // bogus "usage" of parameter $data: scrutinizer warns this variable is
117 // not used (we're using func_get_args instead to support overloading),
118 // but it still needs to be defined because it makes no sense to have
119 // this function without argument :)
120 $args = array($data) + func_get_args();
121
122 // this method can be overloaded
123 foreach ($args as $path) {
124 if (is_array($path)) {
125 call_user_func_array(array($this, 'addFile'), $path);
126 continue;
127 }
128
129 // redefine var
130 $path = (string) $path;
131
132 // check if we can read the file
133 if (!$this->canImportFile($path)) {
134 throw new IOException('The file "' . $path . '" could not be opened for reading. Check if PHP has enough permissions.');
135 }
136
137 $this->add($path);
138 }
139
140 return $this;
141 }
142
143 /**
144 * Minify the data & (optionally) saves it to a file.
145 *
146 * @param string[optional] $path Path to write the data to
147 *
148 * @return string The minified data
149 */
150 public function minify($path = null)
151 {
152 $content = $this->execute($path);
153
154 // save to path
155 if ($path !== null) {
156 $this->save($content, $path);
157 }
158
159 return $content;
160 }
161
162 /**
163 * Minify & gzip the data & (optionally) saves it to a file.
164 *
165 * @param string[optional] $path Path to write the data to
166 * @param int[optional] $level Compression level, from 0 to 9
167 *
168 * @return string The minified & gzipped data
169 */
170 public function gzip($path = null, $level = 9)
171 {
172 $content = $this->execute($path);
173 $content = gzencode($content, $level, FORCE_GZIP);
174
175 // save to path
176 if ($path !== null) {
177 $this->save($content, $path);
178 }
179
180 return $content;
181 }
182
183 /**
184 * Minify the data & write it to a CacheItemInterface object.
185 *
186 * @param CacheItemInterface $item Cache item to write the data to
187 *
188 * @return CacheItemInterface Cache item with the minifier data
189 */
190 public function cache(CacheItemInterface $item)
191 {
192 $content = $this->execute();
193 $item->set($content);
194
195 return $item;
196 }
197
198 /**
199 * Minify the data.
200 *
201 * @param string[optional] $path Path to write the data to
202 *
203 * @return string The minified data
204 */
205 abstract public function execute($path = null);
206
207 /**
208 * Load data.
209 *
210 * @param string $data Either a path to a file or the content itself
211 *
212 * @return string
213 */
214 protected function load($data)
215 {
216 // check if the data is a file
217 if ($this->canImportFile($data)) {
218 $data = file_get_contents($data);
219
220 // strip BOM, if any
221 if (substr($data, 0, 3) == "\xef\xbb\xbf") {
222 $data = substr($data, 3);
223 }
224 }
225
226 return $data;
227 }
228
229 /**
230 * Save to file.
231 *
232 * @param string $content The minified data
233 * @param string $path The path to save the minified data to
234 *
235 * @throws IOException
236 */
237 protected function save($content, $path)
238 {
239 $handler = $this->openFileForWriting($path);
240
241 $this->writeToFile($handler, $content);
242
243 @fclose($handler);
244 }
245
246 /**
247 * Register a pattern to execute against the source content.
248 *
249 * If $replacement is a string, it must be plain text. Placeholders like $1 or \2 don't work.
250 * If you need that functionality, use a callback instead.
251 *
252 * @param string $pattern PCRE pattern
253 * @param string|callable $replacement Replacement value for matched pattern
254 */
255 protected function registerPattern($pattern, $replacement = '')
256 {
257 // study the pattern, we'll execute it more than once
258 $pattern .= 'S';
259
260 $this->patterns[] = array($pattern, $replacement);
261 }
262
263 /**
264 * Both JS and CSS use the same form of multi-line comment, so putting the common code here.
265 */
266 protected function stripMultilineComments()
267 {
268 // First extract comments we want to keep, so they can be restored later
269 // PHP only supports $this inside anonymous functions since 5.4
270 $minifier = $this;
271 $callback = function ($match) use ($minifier) {
272 $count = count($minifier->extracted);
273 $placeholder = '/*' . $count . '*/';
274 $minifier->extracted[$placeholder] = $match[0];
275
276 return $placeholder;
277 };
278 $this->registerPattern('/
279 # optional newline
280 \n?
281
282 # start comment
283 \/\*
284
285 # comment content
286 (?:
287 # either starts with an !
288 !
289 |
290 # or, after some number of characters which do not end the comment
291 (?:(?!\*\/).)*?
292
293 # there is either a @license or @preserve tag
294 @(?:license|preserve)
295 )
296
297 # then match to the end of the comment
298 .*?\*\/\n?
299
300 /ixs', $callback);
301
302 // Then strip all other comments
303 $this->registerPattern('/\/\*.*?\*\//s', '');
304 }
305
306 /**
307 * We can't "just" run some regular expressions against JavaScript: it's a
308 * complex language. E.g. having an occurrence of // xyz would be a comment,
309 * unless it's used within a string. Of you could have something that looks
310 * like a 'string', but inside a comment.
311 * The only way to accurately replace these pieces is to traverse the JS one
312 * character at a time and try to find whatever starts first.
313 *
314 * @param string $content The content to replace patterns in
315 *
316 * @return string The (manipulated) content
317 */
318 protected function replace($content)
319 {
320 $contentLength = strlen($content);
321 $output = '';
322 $processedOffset = 0;
323 $positions = array_fill(0, count($this->patterns), -1);
324 $matches = array();
325
326 while ($processedOffset < $contentLength) {
327 // find first match for all patterns
328 foreach ($this->patterns as $i => $pattern) {
329 list($pattern, $replacement) = $pattern;
330
331 // we can safely ignore patterns for positions we've unset earlier,
332 // because we know these won't show up anymore
333 if (array_key_exists($i, $positions) == false) {
334 continue;
335 }
336
337 // no need to re-run matches that are still in the part of the
338 // content that hasn't been processed
339 if ($positions[$i] >= $processedOffset) {
340 continue;
341 }
342
343 $match = null;
344 if (preg_match($pattern, $content, $match, PREG_OFFSET_CAPTURE, $processedOffset)) {
345 $matches[$i] = $match;
346
347 // we'll store the match position as well; that way, we
348 // don't have to redo all preg_matches after changing only
349 // the first (we'll still know where those others are)
350 $positions[$i] = $match[0][1];
351 } else {
352 // if the pattern couldn't be matched, there's no point in
353 // executing it again in later runs on this same content;
354 // ignore this one until we reach end of content
355 unset($matches[$i], $positions[$i]);
356 }
357 }
358
359 // no more matches to find: everything's been processed, break out
360 if (!$matches) {
361 // output the remaining content
362 $output .= substr($content, $processedOffset);
363 break;
364 }
365
366 // see which of the patterns actually found the first thing (we'll
367 // only want to execute that one, since we're unsure if what the
368 // other found was not inside what the first found)
369 $matchOffset = min($positions);
370 $firstPattern = array_search($matchOffset, $positions);
371 $match = $matches[$firstPattern];
372
373 // execute the pattern that matches earliest in the content string
374 list(, $replacement) = $this->patterns[$firstPattern];
375
376 // add the part of the input between $processedOffset and the first match;
377 // that content wasn't matched by anything
378 $output .= substr($content, $processedOffset, $matchOffset - $processedOffset);
379 // add the replacement for the match
380 $output .= $this->executeReplacement($replacement, $match);
381 // advance $processedOffset past the match
382 $processedOffset = $matchOffset + strlen($match[0][0]);
383 }
384
385 return $output;
386 }
387
388 /**
389 * If $replacement is a callback, execute it, passing in the match data.
390 * If it's a string, just pass it through.
391 *
392 * @param string|callable $replacement Replacement value
393 * @param array $match Match data, in PREG_OFFSET_CAPTURE form
394 *
395 * @return string
396 */
397 protected function executeReplacement($replacement, $match)
398 {
399 if (!is_callable($replacement)) {
400 return $replacement;
401 }
402 // convert $match from the PREG_OFFSET_CAPTURE form to the form the callback expects
403 foreach ($match as &$matchItem) {
404 $matchItem = $matchItem[0];
405 }
406
407 return $replacement($match);
408 }
409
410 /**
411 * Strings are a pattern we need to match, in order to ignore potential
412 * code-like content inside them, but we just want all of the string
413 * content to remain untouched.
414 *
415 * This method will replace all string content with simple STRING#
416 * placeholder text, so we've rid all strings from characters that may be
417 * misinterpreted. Original string content will be saved in $this->extracted
418 * and after doing all other minifying, we can restore the original content
419 * via restoreStrings().
420 *
421 * @param string[optional] $chars
422 * @param string[optional] $placeholderPrefix
423 */
424 protected function extractStrings($chars = '\'"', $placeholderPrefix = '')
425 {
426 // PHP only supports $this inside anonymous functions since 5.4
427 $minifier = $this;
428 $callback = function ($match) use ($minifier, $placeholderPrefix) {
429 // check the second index here, because the first always contains a quote
430 if ($match[2] === '') {
431 /*
432 * Empty strings need no placeholder; they can't be confused for
433 * anything else anyway.
434 * But we still needed to match them, for the extraction routine
435 * to skip over this particular string.
436 */
437 return $match[0];
438 }
439
440 $count = count($minifier->extracted);
441 $placeholder = $match[1] . $placeholderPrefix . $count . $match[1];
442 $minifier->extracted[$placeholder] = $match[1] . $match[2] . $match[1];
443
444 return $placeholder;
445 };
446
447 /*
448 * The \\ messiness explained:
449 * * Don't count ' or " as end-of-string if it's escaped (has backslash
450 * in front of it)
451 * * Unless... that backslash itself is escaped (another leading slash),
452 * in which case it's no longer escaping the ' or "
453 * * So there can be either no backslash, or an even number
454 * * multiply all of that times 4, to account for the escaping that has
455 * to be done to pass the backslash into the PHP string without it being
456 * considered as escape-char (times 2) and to get it in the regex,
457 * escaped (times 2)
458 */
459 $this->registerPattern('/([' . $chars . '])(.*?(?<!\\\\)(\\\\\\\\)*+)\\1/s', $callback);
460 }
461
462 /**
463 * This method will restore all extracted data (strings, regexes) that were
464 * replaced with placeholder text in extract*(). The original content was
465 * saved in $this->extracted.
466 *
467 * @param string $content
468 *
469 * @return string
470 */
471 protected function restoreExtractedData($content)
472 {
473 if (!$this->extracted) {
474 // nothing was extracted, nothing to restore
475 return $content;
476 }
477
478 $content = strtr($content, $this->extracted);
479
480 $this->extracted = array();
481
482 return $content;
483 }
484
485 /**
486 * Check if the path is a regular file and can be read.
487 *
488 * @param string $path
489 *
490 * @return bool
491 */
492 protected function canImportFile($path)
493 {
494 $parsed = parse_url($path);
495 if (
496 // file is elsewhere
497 isset($parsed['host'])
498 // file responds to queries (may change, or need to bypass cache)
499 || isset($parsed['query'])
500 ) {
501 return false;
502 }
503
504 try {
505 return strlen($path) < PHP_MAXPATHLEN && @is_file($path) && is_readable($path);
506 }
507 // catch openbasedir exceptions which are not caught by @ on is_file()
508 catch (\Exception $e) {
509 return false;
510 }
511 }
512
513 /**
514 * Attempts to open file specified by $path for writing.
515 *
516 * @param string $path The path to the file
517 *
518 * @return resource Specifier for the target file
519 *
520 * @throws IOException
521 */
522 protected function openFileForWriting($path)
523 {
524 if ($path === '' || ($handler = @fopen($path, 'w')) === false) {
525 throw new IOException('The file "' . $path . '" could not be opened for writing. Check if PHP has enough permissions.');
526 }
527
528 return $handler;
529 }
530
531 /**
532 * Attempts to write $content to the file specified by $handler. $path is used for printing exceptions.
533 *
534 * @param resource $handler The resource to write to
535 * @param string $content The content to write
536 * @param string $path The path to the file (for exception printing only)
537 *
538 * @throws IOException
539 */
540 protected function writeToFile($handler, $content, $path = '')
541 {
542 if (
543 !is_resource($handler)
544 || ($result = @fwrite($handler, $content)) === false
545 || ($result < strlen($content))
546 ) {
547 throw new IOException('The file "' . $path . '" could not be written to. Check your disk space and file permissions.');
548 }
549 }
550
551 protected static function str_replace_first($search, $replace, $subject)
552 {
553 $pos = strpos($subject, $search);
554 if ($pos !== false) {
555 return substr_replace($subject, $replace, $pos, strlen($search));
556 }
557
558 return $subject;
559 }
560 }
561