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