Completed
Pull Request — master (#202)
by
unknown
01:07
created

Minify::minify()   A

Complexity

Conditions 2
Paths 2

Size

Total Lines 11
Code Lines 5

Duplication

Lines 0
Ratio 0 %

Importance

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