Completed
Push — master ( f6b0e7...97f118 )
by Matthias
01:54
created

src/Minify.php (1 issue)

Upgrade to new PHP Analysis Engine

These results are based on our legacy PHP analysis, consider migrating to our new PHP analysis engine instead. Learn more

1
<?php
2
/**
3
 * Abstract minifier class
4
 *
5
 * Please report bugs on https://github.com/matthiasmullie/minify/issues
6
 *
7
 * @author Matthias Mullie <[email protected]>
8
 * @copyright Copyright (c) 2012, Matthias Mullie. All rights reserved
9
 * @license MIT License
10
 */
11
namespace MatthiasMullie\Minify;
12
13
use MatthiasMullie\Minify\Exceptions\IOException;
14
use Psr\Cache\CacheItemInterface;
15
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 <[email protected]>
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();
34
35
    /**
36
     * Array of patterns to match.
37
     *
38
     * @var string[]
39
     */
40
    protected $patterns = array();
41
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();
50
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
    }
61
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();
76
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
            }
83
84
            // redefine var
85
            $data = (string) $data;
86
87
            // load data
88
            $value = $this->load($data);
89
            $key = ($data != $value) ? $data : count($this->data);
90
91
            // replace CR linefeeds etc.
92
            // @see https://github.com/matthiasmullie/minify/pull/139
93
            $value = str_replace(array("\r\n", "\r"), "\n", $value);
94
95
            // store data
96
            $this->data[$key] = $value;
97
        }
98
99
        return $this;
100
    }
101
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);
112
113
        // save to path
114
        if ($path !== null) {
115
            $this->save($content, $path);
116
        }
117
118
        return $content;
119
    }
120
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);
133
134
        // save to path
135
        if ($path !== null) {
136
            $this->save($content, $path);
137
        }
138
139
        return $content;
140
    }
141
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);
153
154
        return $item;
155
    }
156
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);
165
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);
178
179
            // strip BOM, if any
180
            if (substr($data, 0, 3) == "\xef\xbb\xbf") {
181
                $data = substr($data, 3);
182
            }
183
        }
184
185
        return $data;
186
    }
187
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);
199
200
        $this->writeToFile($handler, $content);
201
202
        @fclose($handler);
203
    }
204
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';
215
216
        $this->patterns[] = array($pattern, $replacement);
217
    }
218
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();
236
237
        while ($content) {
238
            // find first match for all patterns
239
            foreach ($this->patterns as $i => $pattern) {
240
                list($pattern, $replacement) = $pattern;
241
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) {
0 ignored issues
show
Coding Style Best Practice introduced by
It seems like you are loosely comparing two booleans. Considering using the strict comparison === instead.

When comparing two booleans, it is generally considered safer to use the strict comparison operator.

Loading history...
245
                    continue;
246
                }
247
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
                }
253
254
                $match = null;
255
                if (preg_match($pattern, $content, $match, PREG_OFFSET_CAPTURE)) {
256
                    $matches[$i] = $match;
257
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
            }
269
270
            // no more matches to find: everything's been processed, break out
271
            if (!$matches) {
272
                $processed .= $content;
273
                break;
274
            }
275
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];
282
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);
286
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));
291
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;
296
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
        }
304
305
        return $processed;
306
    }
307
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
    }
328
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
            }
358
359
            $count = count($minifier->extracted);
360
            $placeholder = $match[1].$placeholderPrefix.$count.$match[1];
361
            $minifier->extracted[$placeholder] = $match[1].$match[2].$match[1];
362
363
            return $placeholder;
364
        };
365
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
    }
380
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
        }
396
397
        $content = strtr($content, $this->extracted);
398
399
        $this->extracted = array();
400
401
        return $content;
402
    }
403
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
        }
422
423
        return strlen($path) < PHP_MAXPATHLEN && @is_file($path) && is_readable($path);
424
    }
425
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
        }
440
441
        return $handler;
442
    }
443
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
    }
459
}
460