Completed
Pull Request — master (#237)
by
unknown
04:35
created

Minify::openFileForWriting()   A

Complexity

Conditions 2
Paths 2

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