Minify.php 14 KB

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