Completed
Push — master ( 4ca8d1...57b381 )
by Matthias
01:40
created

Minify::save()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 8
Code Lines 4

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
dl 0
loc 8
rs 9.4285
c 0
b 0
f 0
cc 1
eloc 4
nc 1
nop 2
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);
0 ignored issues
show
Security Best Practice introduced by Matthias Mullie
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...
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
                // no need to re-run matches that are still in the part of the
243
                // content that hasn't been processed
244
                if ($positions[$i] >= 0) {
245
                    continue;
246
                }
247
248
                $match = null;
249
                if (preg_match($pattern, $content, $match, PREG_OFFSET_CAPTURE)) {
250
                    $matches[$i] = $match;
251
252
                    // we'll store the match position as well; that way, we
253
                    // don't have to redo all preg_matches after changing only
254
                    // the first (we'll still know where those others are)
255
                    $positions[$i] = $match[0][1];
256
                } else {
257
                    // if the pattern couldn't be matched, there's no point in
258
                    // executing it again in later runs on this same content;
259
                    // ignore this one until we reach end of content
260
                    unset($matches[$i]);
261
                    $positions[$i] = strlen($content);
262
                }
263
            }
264
265
            // no more matches to find: everything's been processed, break out
266
            if (!$matches) {
0 ignored issues
show
Bug Best Practice introduced by Matthias Mullie
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...
267
                $processed .= $content;
268
                break;
269
            }
270
271
            // see which of the patterns actually found the first thing (we'll
272
            // only want to execute that one, since we're unsure if what the
273
            // other found was not inside what the first found)
274
            $discardLength = min($positions);
275
            $firstPattern = array_search($discardLength, $positions);
276
            $match = $matches[$firstPattern][0][0];
277
278
            // execute the pattern that matches earliest in the content string
279
            list($pattern, $replacement) = $this->patterns[$firstPattern];
280
            $replacement = $this->replacePattern($pattern, $replacement, $content);
281
282
            // figure out which part of the string was unmatched; that's the
283
            // part we'll execute the patterns on again next
284
            $content = substr($content, $discardLength);
285
            $unmatched = (string) substr($content, strpos($content, $match) + strlen($match));
286
287
            // move the replaced part to $processed and prepare $content to
288
            // again match batch of patterns against
289
            $processed .= substr($replacement, 0, strlen($replacement) - strlen($unmatched));
290
            $content = $unmatched;
291
292
            // first match has been replaced & that content is to be left alone,
293
            // the next matches will start after this replacement, so we should
294
            // fix their offsets
295
            foreach ($positions as $i => $position) {
296
                $positions[$i] -= $discardLength + strlen($match);
297
            }
298
        }
299
300
        return $processed;
301
    }
302
303
    /**
304
     * This is where a pattern is matched against $content and the matches
305
     * are replaced by their respective value.
306
     * This function will be called plenty of times, where $content will always
307
     * move up 1 character.
308
     *
309
     * @param string          $pattern     Pattern to match
310
     * @param string|callable $replacement Replacement value
311
     * @param string          $content     Content to match pattern against
312
     *
313
     * @return string
314
     */
315
    protected function replacePattern($pattern, $replacement, $content)
316
    {
317
        if (is_callable($replacement)) {
318
            return preg_replace_callback($pattern, $replacement, $content, 1, $count);
319
        } else {
320
            return preg_replace($pattern, $replacement, $content, 1, $count);
321
        }
322
    }
323
324
    /**
325
     * Strings are a pattern we need to match, in order to ignore potential
326
     * code-like content inside them, but we just want all of the string
327
     * content to remain untouched.
328
     *
329
     * This method will replace all string content with simple STRING#
330
     * placeholder text, so we've rid all strings from characters that may be
331
     * misinterpreted. Original string content will be saved in $this->extracted
332
     * and after doing all other minifying, we can restore the original content
333
     * via restoreStrings().
334
     *
335
     * @param string[optional] $chars
336
     * @param string[optional] $placeholderPrefix
337
     */
338
    protected function extractStrings($chars = '\'"', $placeholderPrefix = '')
339
    {
340
        // PHP only supports $this inside anonymous functions since 5.4
341
        $minifier = $this;
342
        $callback = function ($match) use ($minifier, $placeholderPrefix) {
343
            // check the second index here, because the first always contains a quote
344
            if ($match[2] === '') {
345
                /*
346
                 * Empty strings need no placeholder; they can't be confused for
347
                 * anything else anyway.
348
                 * But we still needed to match them, for the extraction routine
349
                 * to skip over this particular string.
350
                 */
351
                return $match[0];
352
            }
353
354
            $count = count($minifier->extracted);
355
            $placeholder = $match[1].$placeholderPrefix.$count.$match[1];
356
            $minifier->extracted[$placeholder] = $match[1].$match[2].$match[1];
357
358
            return $placeholder;
359
        };
360
361
        /*
362
         * The \\ messiness explained:
363
         * * Don't count ' or " as end-of-string if it's escaped (has backslash
364
         * in front of it)
365
         * * Unless... that backslash itself is escaped (another leading slash),
366
         * in which case it's no longer escaping the ' or "
367
         * * So there can be either no backslash, or an even number
368
         * * multiply all of that times 4, to account for the escaping that has
369
         * to be done to pass the backslash into the PHP string without it being
370
         * considered as escape-char (times 2) and to get it in the regex,
371
         * escaped (times 2)
372
         */
373
        $this->registerPattern('/(['.$chars.'])(.*?(?<!\\\\)(\\\\\\\\)*+)\\1/s', $callback);
374
    }
375
376
    /**
377
     * This method will restore all extracted data (strings, regexes) that were
378
     * replaced with placeholder text in extract*(). The original content was
379
     * saved in $this->extracted.
380
     *
381
     * @param string $content
382
     *
383
     * @return string
384
     */
385
    protected function restoreExtractedData($content)
386
    {
387
        if (!$this->extracted) {
0 ignored issues
show
Bug Best Practice introduced by Matthias Mullie
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...
388
            // nothing was extracted, nothing to restore
389
            return $content;
390
        }
391
392
        $content = strtr($content, $this->extracted);
393
394
        $this->extracted = array();
395
396
        return $content;
397
    }
398
399
    /**
400
     * Check if the path is a regular file and can be read.
401
     *
402
     * @param string $path
403
     *
404
     * @return bool
405
     */
406
    protected function canImportFile($path)
407
    {
408
        $parsed = parse_url($path);
409
        if (
410
            // file is elsewhere
411
            isset($parsed['host']) ||
412
            // file responds to queries (may change, or need to bypass cache)
413
            isset($parsed['query'])
414
        ) {
415
            return false;
416
        }
417
418
        return strlen($path) < PHP_MAXPATHLEN && @is_file($path) && is_readable($path);
419
    }
420
421
    /**
422
     * Attempts to open file specified by $path for writing.
423
     *
424
     * @param string $path The path to the file
425
     *
426
     * @return resource Specifier for the target file
427
     *
428
     * @throws IOException
429
     */
430
    protected function openFileForWriting($path)
431
    {
432
        if (($handler = @fopen($path, 'w')) === false) {
433
            throw new IOException('The file "'.$path.'" could not be opened for writing. Check if PHP has enough permissions.');
434
        }
435
436
        return $handler;
437
    }
438
439
    /**
440
     * Attempts to write $content to the file specified by $handler. $path is used for printing exceptions.
441
     *
442
     * @param resource $handler The resource to write to
443
     * @param string   $content The content to write
444
     * @param string   $path    The path to the file (for exception printing only)
445
     *
446
     * @throws IOException
447
     */
448
    protected function writeToFile($handler, $content, $path = '')
449
    {
450
        if (($result = @fwrite($handler, $content)) === false || ($result < strlen($content))) {
451
            throw new IOException('The file "'.$path.'" could not be written to. Check your disk space and file permissions.');
452
        }
453
    }
454
}
455