Completed
Pull Request — master (#325)
by
unknown
01:13
created

Minify::minify()   A

Complexity

Conditions 2
Paths 2

Size

Total Lines 11

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
dl 0
loc 11
rs 9.9
c 0
b 0
f 0
cc 2
nc 2
nop 1
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
     * Path from where resources like images, fonts etc. can be imported.
53
     * Minify will be default look for resources based on its path defined in CSS,
54
     * if your structure of files is different because of different folder structure,
55
     * you can specify base directory.
56
     * @var string
57
     */
58
    protected $resourcesBasePath = "";
59
60
    /**
61
     * Init the minify class - optionally, code may be passed along already.
62
     */
63
    public function __construct(/* $data = null, ... */)
64
    {
65
        // it's possible to add the source through the constructor as well ;)
66
        if (func_num_args()) {
67
            call_user_func_array(array($this, 'add'), func_get_args());
68
        }
69
    }
70
71
    /**
72
     * Add a file or straight-up code to be minified.
73
     *
74
     * @param string|string[] $data
75
     *
76
     * @return static
77
     */
78
    public function add($data /* $data = null, ... */)
79
    {
80
        // bogus "usage" of parameter $data: scrutinizer warns this variable is
81
        // not used (we're using func_get_args instead to support overloading),
82
        // but it still needs to be defined because it makes no sense to have
83
        // this function without argument :)
84
        $args = array($data) + func_get_args();
85
86
        // this method can be overloaded
87
        foreach ($args as $data) {
88
            if (is_array($data)) {
89
                call_user_func_array(array($this, 'add'), $data);
90
                continue;
91
            }
92
93
            // redefine var
94
            $data = (string) $data;
95
96
            // load data
97
            $value = $this->load($data);
98
            $key = ($data != $value) ? $data : count($this->data);
99
100
            // replace CR linefeeds etc.
101
            // @see https://github.com/matthiasmullie/minify/pull/139
102
            $value = str_replace(array("\r\n", "\r"), "\n", $value);
103
104
            // store data
105
            $this->data[$key] = $value;
106
        }
107
108
        return $this;
109
    }
110
111
    /**
112
     * Add a file to be minified.
113
     *
114
     * @param string|string[] $data
115
     *
116
     * @return static
117
     *
118
     * @throws IOException
119
     */
120
    public function addFile($data /* $data = null, ... */)
121
    {
122
        // bogus "usage" of parameter $data: scrutinizer warns this variable is
123
        // not used (we're using func_get_args instead to support overloading),
124
        // but it still needs to be defined because it makes no sense to have
125
        // this function without argument :)
126
        $args = array($data) + func_get_args();
127
128
        // this method can be overloaded
129
        foreach ($args as $path) {
130
            if (is_array($path)) {
131
                call_user_func_array(array($this, 'addFile'), $path);
132
                continue;
133
            }
134
135
            // redefine var
136
            $path = (string) $path;
137
138
            // check if we can read the file
139
            if (!$this->canImportFile($path)) {
140
                throw new IOException('The file "'.$path.'" could not be opened for reading. Check if PHP has enough permissions.');
141
            }
142
143
            $this->add($path);
144
        }
145
146
        return $this;
147
    }
148
149
    /**
150
     * Minify the data & (optionally) saves it to a file.
151
     *
152
     * @param string[optional] $path Path to write the data to
153
     *
154
     * @return string The minified data
155
     */
156
    public function minify($path = null)
157
    {
158
        $content = $this->execute($path);
159
160
        // save to path
161
        if ($path !== null) {
162
            $this->save($content, $path);
163
        }
164
165
        return $content;
166
    }
167
168
    /**
169
     * Minify & gzip the data & (optionally) saves it to a file.
170
     *
171
     * @param string[optional] $path  Path to write the data to
172
     * @param int[optional]    $level Compression level, from 0 to 9
173
     *
174
     * @return string The minified & gzipped data
175
     */
176
    public function gzip($path = null, $level = 9)
177
    {
178
        $content = $this->execute($path);
179
        $content = gzencode($content, $level, FORCE_GZIP);
180
181
        // save to path
182
        if ($path !== null) {
183
            $this->save($content, $path);
184
        }
185
186
        return $content;
187
    }
188
189
    /**
190
     * Minify the data & write it to a CacheItemInterface object.
191
     *
192
     * @param CacheItemInterface $item Cache item to write the data to
193
     *
194
     * @return CacheItemInterface Cache item with the minifier data
195
     */
196
    public function cache(CacheItemInterface $item)
197
    {
198
        $content = $this->execute();
199
        $item->set($content);
200
201
        return $item;
202
    }
203
204
    /**
205
     * Path from where resources like images, fonts etc. can be imported.
206
     * Minify will be default look for resources based on its path defined in CSS,
207
     * if your structure of files is different because of different folder structure,
208
     * you can specify base directory.
209
     * @var string
210
     */
211
    public function setResourcesBasePath(string $path)
212
    {
213
        $this->resourcesBasePath = $path;
214
    }
215
216
    /**
217
     * Minify the data.
218
     *
219
     * @param string[optional] $path Path to write the data to
220
     *
221
     * @return string The minified data
222
     */
223
    abstract public function execute($path = null);
224
225
    /**
226
     * Load data.
227
     *
228
     * @param string $data Either a path to a file or the content itself
229
     *
230
     * @return string
231
     */
232
    protected function load($data)
233
    {
234
        // check if the data is a file
235
        if ($this->canImportFile($data)) {
236
            $data = file_get_contents($data);
237
238
            // strip BOM, if any
239
            if (substr($data, 0, 3) == "\xef\xbb\xbf") {
240
                $data = substr($data, 3);
241
            }
242
        }
243
244
        return $data;
245
    }
246
247
    /**
248
     * Save to file.
249
     *
250
     * @param string $content The minified data
251
     * @param string $path    The path to save the minified data to
252
     *
253
     * @throws IOException
254
     */
255
    protected function save($content, $path)
256
    {
257
        $handler = $this->openFileForWriting($path);
258
259
        $this->writeToFile($handler, $content);
260
261
        @fclose($handler);
0 ignored issues
show
Security Best Practice introduced by
It seems like you do not handle an error condition here. This can introduce security issues, and is generally not recommended.

If you suppress an error, we recommend checking for the error condition explicitly:

// For example instead of
@mkdir($dir);

// Better use
if (@mkdir($dir) === false) {
    throw new \RuntimeException('The directory '.$dir.' could not be created.');
}
Loading history...
262
    }
263
264
    /**
265
     * Register a pattern to execute against the source content.
266
     *
267
     * @param string          $pattern     PCRE pattern
268
     * @param string|callable $replacement Replacement value for matched pattern
269
     */
270
    protected function registerPattern($pattern, $replacement = '')
271
    {
272
        // study the pattern, we'll execute it more than once
273
        $pattern .= 'S';
274
275
        $this->patterns[] = array($pattern, $replacement);
276
    }
277
278
    /**
279
     * We can't "just" run some regular expressions against JavaScript: it's a
280
     * complex language. E.g. having an occurrence of // xyz would be a comment,
281
     * unless it's used within a string. Of you could have something that looks
282
     * like a 'string', but inside a comment.
283
     * The only way to accurately replace these pieces is to traverse the JS one
284
     * character at a time and try to find whatever starts first.
285
     *
286
     * @param string $content The content to replace patterns in
287
     *
288
     * @return string The (manipulated) content
289
     */
290
    protected function replace($content)
291
    {
292
        $processed = '';
293
        $positions = array_fill(0, count($this->patterns), -1);
294
        $matches = array();
295
296
        while ($content) {
297
            // find first match for all patterns
298
            foreach ($this->patterns as $i => $pattern) {
299
                list($pattern, $replacement) = $pattern;
300
301
                // we can safely ignore patterns for positions we've unset earlier,
302
                // because we know these won't show up anymore
303
                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...
304
                    continue;
305
                }
306
307
                // no need to re-run matches that are still in the part of the
308
                // content that hasn't been processed
309
                if ($positions[$i] >= 0) {
310
                    continue;
311
                }
312
313
                $match = null;
314
                if (preg_match($pattern, $content, $match, PREG_OFFSET_CAPTURE)) {
315
                    $matches[$i] = $match;
316
317
                    // we'll store the match position as well; that way, we
318
                    // don't have to redo all preg_matches after changing only
319
                    // the first (we'll still know where those others are)
320
                    $positions[$i] = $match[0][1];
321
                } else {
322
                    // if the pattern couldn't be matched, there's no point in
323
                    // executing it again in later runs on this same content;
324
                    // ignore this one until we reach end of content
325
                    unset($matches[$i], $positions[$i]);
326
                }
327
            }
328
329
            // no more matches to find: everything's been processed, break out
330
            if (!$matches) {
0 ignored issues
show
Bug Best Practice introduced by
The expression $matches of type array is implicitly converted to a boolean; are you sure this is intended? If so, consider using empty($expr) instead to make it clear that you intend to check for an array without elements.

This check marks implicit conversions of arrays to boolean values in a comparison. While in PHP an empty array is considered to be equal (but not identical) to false, this is not always apparent.

Consider making the comparison explicit by using empty(..) or ! empty(...) instead.

Loading history...
331
                $processed .= $content;
332
                break;
333
            }
334
335
            // see which of the patterns actually found the first thing (we'll
336
            // only want to execute that one, since we're unsure if what the
337
            // other found was not inside what the first found)
338
            $discardLength = min($positions);
339
            $firstPattern = array_search($discardLength, $positions);
340
            $match = $matches[$firstPattern][0][0];
341
342
            // execute the pattern that matches earliest in the content string
343
            list($pattern, $replacement) = $this->patterns[$firstPattern];
344
            $replacement = $this->replacePattern($pattern, $replacement, $content);
345
346
            // figure out which part of the string was unmatched; that's the
347
            // part we'll execute the patterns on again next
348
            $content = (string) substr($content, $discardLength);
349
            $unmatched = (string) substr($content, strpos($content, $match) + strlen($match));
350
351
            // move the replaced part to $processed and prepare $content to
352
            // again match batch of patterns against
353
            $processed .= substr($replacement, 0, strlen($replacement) - strlen($unmatched));
354
            $content = $unmatched;
355
356
            // first match has been replaced & that content is to be left alone,
357
            // the next matches will start after this replacement, so we should
358
            // fix their offsets
359
            foreach ($positions as $i => $position) {
360
                $positions[$i] -= $discardLength + strlen($match);
361
            }
362
        }
363
364
        return $processed;
365
    }
366
367
    /**
368
     * This is where a pattern is matched against $content and the matches
369
     * are replaced by their respective value.
370
     * This function will be called plenty of times, where $content will always
371
     * move up 1 character.
372
     *
373
     * @param string          $pattern     Pattern to match
374
     * @param string|callable $replacement Replacement value
375
     * @param string          $content     Content to match pattern against
376
     *
377
     * @return string
378
     */
379
    protected function replacePattern($pattern, $replacement, $content)
380
    {
381
        if (is_callable($replacement)) {
382
            return preg_replace_callback($pattern, $replacement, $content, 1, $count);
383
        } else {
384
            return preg_replace($pattern, $replacement, $content, 1, $count);
385
        }
386
    }
387
388
    /**
389
     * Strings are a pattern we need to match, in order to ignore potential
390
     * code-like content inside them, but we just want all of the string
391
     * content to remain untouched.
392
     *
393
     * This method will replace all string content with simple STRING#
394
     * placeholder text, so we've rid all strings from characters that may be
395
     * misinterpreted. Original string content will be saved in $this->extracted
396
     * and after doing all other minifying, we can restore the original content
397
     * via restoreStrings().
398
     *
399
     * @param string[optional] $chars
400
     * @param string[optional] $placeholderPrefix
401
     */
402
    protected function extractStrings($chars = '\'"', $placeholderPrefix = '')
403
    {
404
        // PHP only supports $this inside anonymous functions since 5.4
405
        $minifier = $this;
406
        $callback = function ($match) use ($minifier, $placeholderPrefix) {
407
            // check the second index here, because the first always contains a quote
408
            if ($match[2] === '') {
409
                /*
410
                 * Empty strings need no placeholder; they can't be confused for
411
                 * anything else anyway.
412
                 * But we still needed to match them, for the extraction routine
413
                 * to skip over this particular string.
414
                 */
415
                return $match[0];
416
            }
417
418
            $count = count($minifier->extracted);
419
            $placeholder = $match[1].$placeholderPrefix.$count.$match[1];
420
            $minifier->extracted[$placeholder] = $match[1].$match[2].$match[1];
421
422
            return $placeholder;
423
        };
424
425
        /*
426
         * The \\ messiness explained:
427
         * * Don't count ' or " as end-of-string if it's escaped (has backslash
428
         * in front of it)
429
         * * Unless... that backslash itself is escaped (another leading slash),
430
         * in which case it's no longer escaping the ' or "
431
         * * So there can be either no backslash, or an even number
432
         * * multiply all of that times 4, to account for the escaping that has
433
         * to be done to pass the backslash into the PHP string without it being
434
         * considered as escape-char (times 2) and to get it in the regex,
435
         * escaped (times 2)
436
         */
437
        $this->registerPattern('/(['.$chars.'])(.*?(?<!\\\\)(\\\\\\\\)*+)\\1/s', $callback);
438
    }
439
440
    /**
441
     * This method will restore all extracted data (strings, regexes) that were
442
     * replaced with placeholder text in extract*(). The original content was
443
     * saved in $this->extracted.
444
     *
445
     * @param string $content
446
     *
447
     * @return string
448
     */
449
    protected function restoreExtractedData($content)
450
    {
451
        if (!$this->extracted) {
0 ignored issues
show
Bug Best Practice introduced by
The expression $this->extracted of type string[] is implicitly converted to a boolean; are you sure this is intended? If so, consider using empty($expr) instead to make it clear that you intend to check for an array without elements.

This check marks implicit conversions of arrays to boolean values in a comparison. While in PHP an empty array is considered to be equal (but not identical) to false, this is not always apparent.

Consider making the comparison explicit by using empty(..) or ! empty(...) instead.

Loading history...
452
            // nothing was extracted, nothing to restore
453
            return $content;
454
        }
455
456
        $content = strtr($content, $this->extracted);
457
458
        $this->extracted = array();
459
460
        return $content;
461
    }
462
463
    /**
464
     * Check if the path is a regular file and can be read.
465
     *
466
     * @param string $path
467
     *
468
     * @return bool
469
     */
470
    protected function canImportFile($path)
471
    {
472
        $parsed = parse_url($path);
473
        if (
474
            // file is elsewhere
475
            isset($parsed['host']) ||
476
            // file responds to queries (may change, or need to bypass cache)
477
            isset($parsed['query'])
478
        ) {
479
            return false;
480
        }
481
482
        return strlen($path) < PHP_MAXPATHLEN && @is_file($path) && is_readable($path);
483
    }
484
485
    /**
486
     * Attempts to open file specified by $path for writing.
487
     *
488
     * @param string $path The path to the file
489
     *
490
     * @return resource Specifier for the target file
491
     *
492
     * @throws IOException
493
     */
494
    protected function openFileForWriting($path)
495
    {
496
        if (($handler = @fopen($path, 'w')) === false) {
497
            throw new IOException('The file "'.$path.'" could not be opened for writing. Check if PHP has enough permissions.');
498
        }
499
500
        return $handler;
501
    }
502
503
    /**
504
     * Attempts to write $content to the file specified by $handler. $path is used for printing exceptions.
505
     *
506
     * @param resource $handler The resource to write to
507
     * @param string   $content The content to write
508
     * @param string   $path    The path to the file (for exception printing only)
509
     *
510
     * @throws IOException
511
     */
512
    protected function writeToFile($handler, $content, $path = '')
513
    {
514
        if (($result = @fwrite($handler, $content)) === false || ($result < strlen($content))) {
515
            throw new IOException('The file "'.$path.'" could not be written to. Check your disk space and file permissions.');
516
        }
517
    }
518
}
519