Completed
Push — master ( 089afe...099f5e )
by Matthias
02:23
created

Minify::add()   B

Complexity

Conditions 4
Paths 4

Size

Total Lines 26
Code Lines 10

Duplication

Lines 0
Ratio 0 %

Importance

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