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