Minify.php 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434
  1. <?php
  2. namespace MatthiasMullie\Minify;
  3. use MatthiasMullie\Minify\Exceptions\IOException;
  4. use Psr\Cache\CacheItemInterface;
  5. /**
  6. * Abstract minifier class.
  7. *
  8. * Please report bugs on https://github.com/matthiasmullie/minify/issues
  9. *
  10. * @author Matthias Mullie <minify@mullie.eu>
  11. * @copyright Copyright (c) 2012, Matthias Mullie. All rights reserved
  12. * @license MIT License
  13. */
  14. abstract class Minify
  15. {
  16. /**
  17. * The data to be minified.
  18. *
  19. * @var string[]
  20. */
  21. protected $data = array();
  22. /**
  23. * Array of patterns to match.
  24. *
  25. * @var string[]
  26. */
  27. protected $patterns = array();
  28. /**
  29. * This array will hold content of strings and regular expressions that have
  30. * been extracted from the JS source code, so we can reliably match "code",
  31. * without having to worry about potential "code-like" characters inside.
  32. *
  33. * @var string[]
  34. */
  35. public $extracted = array();
  36. /**
  37. * Init the minify class - optionally, code may be passed along already.
  38. */
  39. public function __construct(/* $data = null, ... */)
  40. {
  41. // it's possible to add the source through the constructor as well ;)
  42. if (func_num_args()) {
  43. call_user_func_array(array($this, 'add'), func_get_args());
  44. }
  45. }
  46. /**
  47. * Add a file or straight-up code to be minified.
  48. *
  49. * @param string|string[] $data
  50. *
  51. * @return static
  52. */
  53. public function add($data /* $data = null, ... */)
  54. {
  55. // bogus "usage" of parameter $data: scrutinizer warns this variable is
  56. // not used (we're using func_get_args instead to support overloading),
  57. // but it still needs to be defined because it makes no sense to have
  58. // this function without argument :)
  59. $args = array($data) + func_get_args();
  60. // this method can be overloaded
  61. foreach ($args as $data) {
  62. if (is_array($data)) {
  63. call_user_func_array(array($this, 'add'), $data);
  64. continue;
  65. }
  66. // redefine var
  67. $data = (string) $data;
  68. // load data
  69. $value = $this->load($data);
  70. $key = ($data != $value) ? $data : count($this->data);
  71. // replace CR linefeeds etc.
  72. // @see https://github.com/matthiasmullie/minify/pull/139
  73. $value = str_replace(array("\r\n", "\r"), "\n", $value);
  74. // store data
  75. $this->data[$key] = $value;
  76. }
  77. return $this;
  78. }
  79. /**
  80. * Minify the data & (optionally) saves it to a file.
  81. *
  82. * @param string[optional] $path Path to write the data to
  83. *
  84. * @return string The minified data
  85. */
  86. public function minify($path = null)
  87. {
  88. $content = $this->execute($path);
  89. // save to path
  90. if ($path !== null) {
  91. $this->save($content, $path);
  92. }
  93. return $content;
  94. }
  95. /**
  96. * Minify & gzip the data & (optionally) saves it to a file.
  97. *
  98. * @param string[optional] $path Path to write the data to
  99. * @param int[optional] $level Compression level, from 0 to 9
  100. *
  101. * @return string The minified & gzipped data
  102. */
  103. public function gzip($path = null, $level = 9)
  104. {
  105. $content = $this->execute($path);
  106. $content = gzencode($content, $level, FORCE_GZIP);
  107. // save to path
  108. if ($path !== null) {
  109. $this->save($content, $path);
  110. }
  111. return $content;
  112. }
  113. /**
  114. * Minify the data & write it to a CacheItemInterface object.
  115. *
  116. * @param CacheItemInterface $item Cache item to write the data to
  117. *
  118. * @return CacheItemInterface Cache item with the minifier data
  119. */
  120. public function cache(CacheItemInterface $item)
  121. {
  122. $content = $this->execute();
  123. $item->set($content);
  124. return $item;
  125. }
  126. /**
  127. * Minify the data.
  128. *
  129. * @param string[optional] $path Path to write the data to
  130. *
  131. * @return string The minified data
  132. */
  133. abstract public function execute($path = null);
  134. /**
  135. * Load data.
  136. *
  137. * @param string $data Either a path to a file or the content itself
  138. *
  139. * @return string
  140. */
  141. protected function load($data)
  142. {
  143. // check if the data is a file
  144. if ($this->canImportFile($data)) {
  145. $data = file_get_contents($data);
  146. // strip BOM, if any
  147. if (substr($data, 0, 3) == "\xef\xbb\xbf") {
  148. $data = substr($data, 3);
  149. }
  150. }
  151. return $data;
  152. }
  153. /**
  154. * Save to file.
  155. *
  156. * @param string $content The minified data
  157. * @param string $path The path to save the minified data to
  158. *
  159. * @throws IOException
  160. */
  161. protected function save($content, $path)
  162. {
  163. $handler = $this->openFileForWriting($path);
  164. $this->writeToFile($handler, $content);
  165. @fclose($handler);
  166. }
  167. /**
  168. * Register a pattern to execute against the source content.
  169. *
  170. * @param string $pattern PCRE pattern
  171. * @param string|callable $replacement Replacement value for matched pattern
  172. */
  173. protected function registerPattern($pattern, $replacement = '')
  174. {
  175. // study the pattern, we'll execute it more than once
  176. $pattern .= 'S';
  177. $this->patterns[] = array($pattern, $replacement);
  178. }
  179. /**
  180. * We can't "just" run some regular expressions against JavaScript: it's a
  181. * complex language. E.g. having an occurrence of // xyz would be a comment,
  182. * unless it's used within a string. Of you could have something that looks
  183. * like a 'string', but inside a comment.
  184. * The only way to accurately replace these pieces is to traverse the JS one
  185. * character at a time and try to find whatever starts first.
  186. *
  187. * @param string $content The content to replace patterns in
  188. *
  189. * @return string The (manipulated) content
  190. */
  191. protected function replace($content)
  192. {
  193. $processed = '';
  194. $positions = array_fill(0, count($this->patterns), -1);
  195. $matches = array();
  196. while ($content) {
  197. // find first match for all patterns
  198. foreach ($this->patterns as $i => $pattern) {
  199. list($pattern, $replacement) = $pattern;
  200. // no need to re-run matches that are still in the part of the
  201. // content that hasn't been processed
  202. if ($positions[$i] >= 0) {
  203. continue;
  204. }
  205. $match = null;
  206. if (preg_match($pattern, $content, $match)) {
  207. $matches[$i] = $match;
  208. // we'll store the match position as well; that way, we
  209. // don't have to redo all preg_matches after changing only
  210. // the first (we'll still know where those others are)
  211. $positions[$i] = strpos($content, $match[0]);
  212. } else {
  213. // if the pattern couldn't be matched, there's no point in
  214. // executing it again in later runs on this same content;
  215. // ignore this one until we reach end of content
  216. unset($matches[$i]);
  217. $positions[$i] = strlen($content);
  218. }
  219. }
  220. // no more matches to find: everything's been processed, break out
  221. if (!$matches) {
  222. $processed .= $content;
  223. break;
  224. }
  225. // see which of the patterns actually found the first thing (we'll
  226. // only want to execute that one, since we're unsure if what the
  227. // other found was not inside what the first found)
  228. $discardLength = min($positions);
  229. $firstPattern = array_search($discardLength, $positions);
  230. $match = $matches[$firstPattern][0];
  231. // execute the pattern that matches earliest in the content string
  232. list($pattern, $replacement) = $this->patterns[$firstPattern];
  233. $replacement = $this->replacePattern($pattern, $replacement, $content);
  234. // figure out which part of the string was unmatched; that's the
  235. // part we'll execute the patterns on again next
  236. $content = substr($content, $discardLength);
  237. $unmatched = (string) substr($content, strpos($content, $match) + strlen($match));
  238. // move the replaced part to $processed and prepare $content to
  239. // again match batch of patterns against
  240. $processed .= substr($replacement, 0, strlen($replacement) - strlen($unmatched));
  241. $content = $unmatched;
  242. // first match has been replaced & that content is to be left alone,
  243. // the next matches will start after this replacement, so we should
  244. // fix their offsets
  245. foreach ($positions as $i => $position) {
  246. $positions[$i] -= $discardLength + strlen($match);
  247. }
  248. }
  249. return $processed;
  250. }
  251. /**
  252. * This is where a pattern is matched against $content and the matches
  253. * are replaced by their respective value.
  254. * This function will be called plenty of times, where $content will always
  255. * move up 1 character.
  256. *
  257. * @param string $pattern Pattern to match
  258. * @param string|callable $replacement Replacement value
  259. * @param string $content Content to match pattern against
  260. *
  261. * @return string
  262. */
  263. protected function replacePattern($pattern, $replacement, $content)
  264. {
  265. if (is_callable($replacement)) {
  266. return preg_replace_callback($pattern, $replacement, $content, 1, $count);
  267. } else {
  268. return preg_replace($pattern, $replacement, $content, 1, $count);
  269. }
  270. }
  271. /**
  272. * Strings are a pattern we need to match, in order to ignore potential
  273. * code-like content inside them, but we just want all of the string
  274. * content to remain untouched.
  275. *
  276. * This method will replace all string content with simple STRING#
  277. * placeholder text, so we've rid all strings from characters that may be
  278. * misinterpreted. Original string content will be saved in $this->extracted
  279. * and after doing all other minifying, we can restore the original content
  280. * via restoreStrings().
  281. *
  282. * @param string[optional] $chars
  283. */
  284. protected function extractStrings($chars = '\'"')
  285. {
  286. // PHP only supports $this inside anonymous functions since 5.4
  287. $minifier = $this;
  288. $callback = function ($match) use ($minifier) {
  289. // check the second index here, because the first always contains a quote
  290. if ($match[2] === '') {
  291. /*
  292. * Empty strings need no placeholder; they can't be confused for
  293. * anything else anyway.
  294. * But we still needed to match them, for the extraction routine
  295. * to skip over this particular string.
  296. */
  297. return $match[0];
  298. }
  299. $count = count($minifier->extracted);
  300. $placeholder = $match[1].$count.$match[1];
  301. $minifier->extracted[$placeholder] = $match[1].$match[2].$match[1];
  302. return $placeholder;
  303. };
  304. /*
  305. * The \\ messiness explained:
  306. * * Don't count ' or " as end-of-string if it's escaped (has backslash
  307. * in front of it)
  308. * * Unless... that backslash itself is escaped (another leading slash),
  309. * in which case it's no longer escaping the ' or "
  310. * * So there can be either no backslash, or an even number
  311. * * multiply all of that times 4, to account for the escaping that has
  312. * to be done to pass the backslash into the PHP string without it being
  313. * considered as escape-char (times 2) and to get it in the regex,
  314. * escaped (times 2)
  315. */
  316. $this->registerPattern('/(['.$chars.'])(.*?(?<!\\\\)(\\\\\\\\)*+)\\1/s', $callback);
  317. }
  318. /**
  319. * This method will restore all extracted data (strings, regexes) that were
  320. * replaced with placeholder text in extract*(). The original content was
  321. * saved in $this->extracted.
  322. *
  323. * @param string $content
  324. *
  325. * @return string
  326. */
  327. protected function restoreExtractedData($content)
  328. {
  329. if (!$this->extracted) {
  330. // nothing was extracted, nothing to restore
  331. return $content;
  332. }
  333. $content = strtr($content, $this->extracted);
  334. $this->extracted = array();
  335. return $content;
  336. }
  337. /**
  338. * Check if the path is a regular file and can be read.
  339. *
  340. * @param string $path
  341. *
  342. * @return bool
  343. */
  344. protected function canImportFile($path)
  345. {
  346. return strlen($path) < PHP_MAXPATHLEN && @is_file($path) && is_readable($path);
  347. }
  348. /**
  349. * Attempts to open file specified by $path for writing.
  350. *
  351. * @param string $path The path to the file
  352. *
  353. * @return resource Specifier for the target file
  354. *
  355. * @throws IOException
  356. */
  357. protected function openFileForWriting($path)
  358. {
  359. if (($handler = @fopen($path, 'w')) === false) {
  360. throw new IOException('The file "'.$path.'" could not be opened for writing. Check if PHP has enough permissions.');
  361. }
  362. return $handler;
  363. }
  364. /**
  365. * Attempts to write $content to the file specified by $handler. $path is used for printing exceptions.
  366. *
  367. * @param resource $handler The resource to write to
  368. * @param string $content The content to write
  369. * @param string $path The path to the file (for exception printing only)
  370. *
  371. * @throws IOException
  372. */
  373. protected function writeToFile($handler, $content, $path = '')
  374. {
  375. if (($result = @fwrite($handler, $content)) === false || ($result < strlen($content))) {
  376. throw new IOException('The file "'.$path.'" could not be written to. Check your disk space and file permissions.');
  377. }
  378. }
  379. }