Differences Between: [Versions 310 and 403] [Versions 311 and 403] [Versions 39 and 403] [Versions 400 and 403] [Versions 401 and 403]
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 * We can't "just" run some regular expressions against JavaScript: it's a 265 * complex language. E.g. having an occurrence of // xyz would be a comment, 266 * unless it's used within a string. Of you could have something that looks 267 * like a 'string', but inside a comment. 268 * The only way to accurately replace these pieces is to traverse the JS one 269 * character at a time and try to find whatever starts first. 270 * 271 * @param string $content The content to replace patterns in 272 * 273 * @return string The (manipulated) content 274 */ 275 protected function replace($content) 276 { 277 $contentLength = strlen($content); 278 $output = ''; 279 $processedOffset = 0; 280 $positions = array_fill(0, count($this->patterns), -1); 281 $matches = array(); 282 283 while ($processedOffset < $contentLength) { 284 // find first match for all patterns 285 foreach ($this->patterns as $i => $pattern) { 286 list($pattern, $replacement) = $pattern; 287 288 // we can safely ignore patterns for positions we've unset earlier, 289 // because we know these won't show up anymore 290 if (array_key_exists($i, $positions) == false) { 291 continue; 292 } 293 294 // no need to re-run matches that are still in the part of the 295 // content that hasn't been processed 296 if ($positions[$i] >= $processedOffset) { 297 continue; 298 } 299 300 $match = null; 301 if (preg_match($pattern, $content, $match, PREG_OFFSET_CAPTURE, $processedOffset)) { 302 $matches[$i] = $match; 303 304 // we'll store the match position as well; that way, we 305 // don't have to redo all preg_matches after changing only 306 // the first (we'll still know where those others are) 307 $positions[$i] = $match[0][1]; 308 } else { 309 // if the pattern couldn't be matched, there's no point in 310 // executing it again in later runs on this same content; 311 // ignore this one until we reach end of content 312 unset($matches[$i], $positions[$i]); 313 } 314 } 315 316 // no more matches to find: everything's been processed, break out 317 if (!$matches) { 318 // output the remaining content 319 $output .= substr($content, $processedOffset); 320 break; 321 } 322 323 // see which of the patterns actually found the first thing (we'll 324 // only want to execute that one, since we're unsure if what the 325 // other found was not inside what the first found) 326 $matchOffset = min($positions); 327 $firstPattern = array_search($matchOffset, $positions); 328 $match = $matches[$firstPattern]; 329 330 // execute the pattern that matches earliest in the content string 331 list(, $replacement) = $this->patterns[$firstPattern]; 332 333 // add the part of the input between $processedOffset and the first match; 334 // that content wasn't matched by anything 335 $output .= substr($content, $processedOffset, $matchOffset - $processedOffset); 336 // add the replacement for the match 337 $output .= $this->executeReplacement($replacement, $match); 338 // advance $processedOffset past the match 339 $processedOffset = $matchOffset + strlen($match[0][0]); 340 } 341 342 return $output; 343 } 344 345 /** 346 * If $replacement is a callback, execute it, passing in the match data. 347 * If it's a string, just pass it through. 348 * 349 * @param string|callable $replacement Replacement value 350 * @param array $match Match data, in PREG_OFFSET_CAPTURE form 351 * 352 * @return string 353 */ 354 protected function executeReplacement($replacement, $match) 355 { 356 if (!is_callable($replacement)) { 357 return $replacement; 358 } 359 // convert $match from the PREG_OFFSET_CAPTURE form to the form the callback expects 360 foreach ($match as &$matchItem) { 361 $matchItem = $matchItem[0]; 362 } 363 364 return $replacement($match); 365 } 366 367 /** 368 * Strings are a pattern we need to match, in order to ignore potential 369 * code-like content inside them, but we just want all of the string 370 * content to remain untouched. 371 * 372 * This method will replace all string content with simple STRING# 373 * placeholder text, so we've rid all strings from characters that may be 374 * misinterpreted. Original string content will be saved in $this->extracted 375 * and after doing all other minifying, we can restore the original content 376 * via restoreStrings(). 377 * 378 * @param string[optional] $chars 379 * @param string[optional] $placeholderPrefix 380 */ 381 protected function extractStrings($chars = '\'"', $placeholderPrefix = '') 382 { 383 // PHP only supports $this inside anonymous functions since 5.4 384 $minifier = $this; 385 $callback = function ($match) use ($minifier, $placeholderPrefix) { 386 // check the second index here, because the first always contains a quote 387 if ($match[2] === '') { 388 /* 389 * Empty strings need no placeholder; they can't be confused for 390 * anything else anyway. 391 * But we still needed to match them, for the extraction routine 392 * to skip over this particular string. 393 */ 394 return $match[0]; 395 } 396 397 $count = count($minifier->extracted); 398 $placeholder = $match[1] . $placeholderPrefix . $count . $match[1]; 399 $minifier->extracted[$placeholder] = $match[1] . $match[2] . $match[1]; 400 401 return $placeholder; 402 }; 403 404 /* 405 * The \\ messiness explained: 406 * * Don't count ' or " as end-of-string if it's escaped (has backslash 407 * in front of it) 408 * * Unless... that backslash itself is escaped (another leading slash), 409 * in which case it's no longer escaping the ' or " 410 * * So there can be either no backslash, or an even number 411 * * multiply all of that times 4, to account for the escaping that has 412 * to be done to pass the backslash into the PHP string without it being 413 * considered as escape-char (times 2) and to get it in the regex, 414 * escaped (times 2) 415 */ 416 $this->registerPattern('/([' . $chars . '])(.*?(?<!\\\\)(\\\\\\\\)*+)\\1/s', $callback); 417 } 418 419 /** 420 * This method will restore all extracted data (strings, regexes) that were 421 * replaced with placeholder text in extract*(). The original content was 422 * saved in $this->extracted. 423 * 424 * @param string $content 425 * 426 * @return string 427 */ 428 protected function restoreExtractedData($content) 429 { 430 if (!$this->extracted) { 431 // nothing was extracted, nothing to restore 432 return $content; 433 } 434 435 $content = strtr($content, $this->extracted); 436 437 $this->extracted = array(); 438 439 return $content; 440 } 441 442 /** 443 * Check if the path is a regular file and can be read. 444 * 445 * @param string $path 446 * 447 * @return bool 448 */ 449 protected function canImportFile($path) 450 { 451 $parsed = parse_url($path); 452 if ( 453 // file is elsewhere 454 isset($parsed['host']) || 455 // file responds to queries (may change, or need to bypass cache) 456 isset($parsed['query']) 457 ) { 458 return false; 459 } 460 461 return strlen($path) < PHP_MAXPATHLEN && @is_file($path) && is_readable($path); 462 } 463 464 /** 465 * Attempts to open file specified by $path for writing. 466 * 467 * @param string $path The path to the file 468 * 469 * @return resource Specifier for the target file 470 * 471 * @throws IOException 472 */ 473 protected function openFileForWriting($path) 474 { 475 if ($path === '' || ($handler = @fopen($path, 'w')) === false) { 476 throw new IOException('The file "' . $path . '" could not be opened for writing. Check if PHP has enough permissions.'); 477 } 478 479 return $handler; 480 } 481 482 /** 483 * Attempts to write $content to the file specified by $handler. $path is used for printing exceptions. 484 * 485 * @param resource $handler The resource to write to 486 * @param string $content The content to write 487 * @param string $path The path to the file (for exception printing only) 488 * 489 * @throws IOException 490 */ 491 protected function writeToFile($handler, $content, $path = '') 492 { 493 if ( 494 !is_resource($handler) || 495 ($result = @fwrite($handler, $content)) === false || 496 ($result < strlen($content)) 497 ) { 498 throw new IOException('The file "' . $path . '" could not be written to. Check your disk space and file permissions.'); 499 } 500 } 501 502 protected static function str_replace_first($search, $replace, $subject) 503 { 504 $pos = strpos($subject, $search); 505 if ($pos !== false) { 506 return substr_replace($subject, $replace, $pos, strlen($search)); 507 } 508 509 return $subject; 510 } 511 }
title
Description
Body
title
Description
Body
title
Description
Body
title
Body