Completed
Pull Request — master (#94)
by Gino
03:01
created

CSS::combineImports()   B

Complexity

Conditions 6
Paths 12

Size

Total Lines 131
Code Lines 21

Duplication

Lines 0
Ratio 0 %

Importance

Changes 7
Bugs 4 Features 3
Metric Value
c 7
b 4
f 3
dl 0
loc 131
rs 8.1463
cc 6
eloc 21
nc 12
nop 2

How to fix   Long Method   

Long Method

Small methods make your code easier to understand, in particular if combined with a good name. Besides, if your method is small, finding a good name is usually much easier.

For example, if you find yourself adding comments to a method's body, this is usually a good sign to extract the commented part to a new method, and use the comment as a starting point when coming up with a good name for this new method.

Commonly applied refactorings include:

1
<?php
2
3
namespace MatthiasMullie\Minify;
4
5
use MatthiasMullie\PathConverter\Converter;
6
7
/**
8
 * CSS minifier.
9
 *
10
 * Please report bugs on https://github.com/matthiasmullie/minify/issues
11
 *
12
 * @author Matthias Mullie <[email protected]>
13
 * @author Tijs Verkoyen <[email protected]>
14
 * @copyright Copyright (c) 2012, Matthias Mullie. All rights reserved.
15
 * @license MIT License
16
 */
17
class CSS extends Minify
18
{
19
    /**
20
     * @var int
21
     */
22
    protected $maxImportSize = 5;
23
24
    /**
25
     * @var string[]
26
     */
27
    protected $importExtensions = array(
28
        'gif' => 'data:image/gif',
29
        'png' => 'data:image/png',
30
        'jpe' => 'data:image/jpeg',
31
        'jpg' => 'data:image/jpeg',
32
        'jpeg' => 'data:image/jpeg',
33
        'svg' => 'data:image/svg+xml',
34
        'woff' => 'data:application/x-font-woff',
35
        'tif' => 'image/tiff',
36
        'tiff' => 'image/tiff',
37
        'xbm' => 'image/x-xbitmap',
38
    );
39
40
    /**
41
     * Set the maximum size if files to be imported.
42
     *
43
     * Files larger than this size (in kB) will not be imported into the CSS.
44
     * Importing files into the CSS as data-uri will save you some connections,
45
     * but we should only import relatively small decorative images so that our
46
     * CSS file doesn't get too bulky.
47
     *
48
     * @param int $size Size in kB
49
     */
50
    public function setMaxImportSize($size)
51
    {
52
        $this->maxImportSize = $size;
53
    }
54
55
    /**
56
     * Set the type of extensions to be imported into the CSS (to save network
57
     * connections).
58
     * Keys of the array should be the file extensions & respective values
59
     * should be the data type.
60
     *
61
     * @param string[] $extensions Array of file extensions
62
     */
63
    public function setImportExtensions(array $extensions)
64
    {
65
        $this->importExtensions = $extensions;
66
    }
67
68
    /**
69
     * Move any import statements to the top.
70
     *
71
     * @param $content string Nearly finished CSS content
72
     *
73
     * @return string
74
     */
75
    protected function moveImportsToTop($content)
76
    {
77
        if (preg_match_all('/@import[^;]+;/', $content, $matches)) {
78
79
            // remove from content
80
            foreach ($matches[0] as $import) {
81
                $content = str_replace($import, '', $content);
82
            }
83
84
            // add to top
85
            $content = implode('', $matches[0]).$content;
86
        };
87
88
        return $content;
89
    }
90
91
    /**
92
     * Combine CSS from import statements.
93
     *
94
     * @import's will be loaded and their content merged into the original file,
95
     * to save HTTP requests.
96
     *
97
     * @param string $source  The file to combine imports for.
98
     * @param string $content The CSS content to combine imports for.
99
     *
100
     * @return string
101
     */
102
    protected function combineImports($source, $content)
103
    {
104
        $importRegexes = array(
105
            // @import url(xxx)
106
            '/
107
            # import statement
108
            @import
109
110
            # whitespace
111
            \s+
112
113
                # open url()
114
                url\(
115
116
                    # (optional) open path enclosure
117
                    (?P<quotes>["\']?)
118
119
                        # fetch path
120
                        (?P<path>
121
122
                            # do not fetch data uris or external sources
123
                            (?!(
124
                                ["\']?
125
                                (data|https?):
126
                            ))
127
128
                            .+?
129
                        )
130
131
                    # (optional) close path enclosure
132
                    (?P=quotes)
133
134
                # close url()
135
                \)
136
137
                # (optional) trailing whitespace
138
                \s*
139
140
                # (optional) media statement(s)
141
                (?P<media>[^;]*)
142
143
                # (optional) trailing whitespace
144
                \s*
145
146
            # (optional) closing semi-colon
147
            ;?
148
149
            /ix',
150
151
            // @import 'xxx'
152
            '/
153
154
            # import statement
155
            @import
156
157
            # whitespace
158
            \s+
159
160
                # open path enclosure
161
                (?P<quotes>["\'])
162
163
                    # fetch path
164
                    (?P<path>
165
166
                        # do not fetch data uris or external sources
167
                        (?!(
168
                            ["\']?
169
                            (data|https?):
170
                        ))
171
172
                        .+?
173
                    )
174
175
                # close path enclosure
176
                (?P=quotes)
177
178
                # (optional) trailing whitespace
179
                \s*
180
181
                # (optional) media statement(s)
182
                (?P<media>[^;]*)
183
184
                # (optional) trailing whitespace
185
                \s*
186
187
            # (optional) closing semi-colon
188
            ;?
189
190
            /ix',
191
        );
192
193
        // find all relative imports in css
194
        $matches = array();
195
        foreach ($importRegexes as $importRegex) {
196
            if (preg_match_all($importRegex, $content, $regexMatches, PREG_SET_ORDER)) {
197
                $matches = array_merge($matches, $regexMatches);
198
            }
199
        }
200
201
        $search = array();
202
        $replace = array();
203
204
        // loop the matches
205
        foreach ($matches as $match) {
206
            // get the path for the file that will be imported
207
            $importPath = dirname($source).'/'.$match['path'];
208
209
            // only replace the import with the content if we can grab the
210
            // content of the file
211
            if ($this->canImportFile($importPath)) {
212
                // grab referenced file & minify it (which may include importing
213
                // yet other @import statements recursively)
214
                $minifier = new static($importPath);
215
                $importContent = $minifier->execute($source);
216
217
                // check if this is only valid for certain media
218
                if (!empty($match['media'])) {
219
                    $importContent = '@media '.$match['media'].'{'.$importContent.'}';
220
                }
221
222
                // add to replacement array
223
                $search[] = $match[0];
224
                $replace[] = $importContent;
225
            }
226
        }
227
228
        // replace the import statements
229
        $content = str_replace($search, $replace, $content);
230
231
        return $content;
232
    }
233
234
    /**
235
     * Import files into the CSS, base64-ized.
236
     *
237
     * @url(image.jpg) images will be loaded and their content merged into the
238
     * original file, to save HTTP requests.
239
     *
240
     * @param string $source  The file to import files for.
241
     * @param string $content The CSS content to import files for.
242
     *
243
     * @return string
244
     */
245
    protected function importFiles($source, $content)
246
    {
247
        $extensions = array_keys($this->importExtensions);
248
        $regex = '/url\((["\']?)((?!["\']?data:).*?\.('.implode('|', $extensions).'))\\1\)/i';
249
        if ($extensions && preg_match_all($regex, $content, $matches, PREG_SET_ORDER)) {
0 ignored issues
show
Bug Best Practice introduced by
The expression $extensions of type integer[] 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
            $search = array();
251
            $replace = array();
252
253
            // loop the matches
254
            foreach ($matches as $match) {
255
                // get the path for the file that will be imported
256
                $path = $match[2];
257
                $path = dirname($source).'/'.$path;
258
                $extension = $match[3];
259
260
                // only replace the import with the content if we're able to get
261
                // the content of the file, and it's relatively small
262
                $import = strlen($path) < PHP_MAXPATHLEN;
263
                $import = $import && file_exists($path);
264
                $import = $import && is_file($path);
265
                $import = $import && filesize($path) <= $this->maxImportSize * 1024;
266
                if (!$import) {
267
                    continue;
268
                }
269
270
                // grab content && base64-ize
271
                $importContent = $this->load($path);
272
                $importContent = base64_encode($importContent);
273
274
                // build replacement
275
                $search[] = $match[0];
276
                $replace[] = 'url('.$this->importExtensions[$extension].';base64,'.$importContent.')';
277
            }
278
279
            // replace the import statements
280
            $content = str_replace($search, $replace, $content);
281
        }
282
283
        return $content;
284
    }
285
286
    /**
287
     * Minify the data.
288
     * Perform CSS optimizations.
289
     *
290
     * @param string[optional] $path Path to write the data to.
291
     *
292
     * @return string The minified data.
293
     */
294
    public function execute($path = null)
295
    {
296
        $content = '';
297
298
        // loop files
299
        foreach ($this->data as $source => $css) {
300
            /*
301
             * Let's first take out strings & comments, since we can't just remove
302
             * whitespace anywhere. If whitespace occurs inside a string, we should
303
             * leave it alone. E.g.:
304
             * p { content: "a   test" }
305
             */
306
            $this->extractStrings();
307
            $this->stripComments();
308
            $css = $this->replace($css);
309
310
            $css = $this->stripWhitespace($css);
311
            $css = $this->shortenHex($css);
312
            $css = $this->shortenZeroes($css);
313
            $css = $this->stripEmptyTags($css);
314
315
            // restore the string we've extracted earlier
316
            $css = $this->restoreExtractedData($css);
317
318
            $source = $source ?: '';
319
            $css = $this->combineImports($source, $css);
320
            $css = $this->importFiles($source, $css);
321
322
            /*
323
             * If we'll save to a new path, we'll have to fix the relative paths
324
             * to be relative no longer to the source file, but to the new path.
325
             * If we don't write to a file, fall back to same path so no
326
             * conversion happens (because we still want it to go through most
327
             * of the move code...)
328
             */
329
            $converter = new Converter($source, $path ?: $source);
330
            $css = $this->move($converter, $css);
331
332
            // combine css
333
            $content .= $css;
334
        }
335
336
        $content = $this->moveImportsToTop($content);
337
338
        return $content;
339
    }
340
341
    /**
342
     * Moving a css file should update all relative urls.
343
     * Relative references (e.g. ../images/image.gif) in a certain css file,
344
     * will have to be updated when a file is being saved at another location
345
     * (e.g. ../../images/image.gif, if the new CSS file is 1 folder deeper).
346
     *
347
     * @param Converter $converter Relative path converter
348
     * @param string    $content   The CSS content to update relative urls for.
349
     *
350
     * @return string
351
     */
352
    protected function move(Converter $converter, $content)
353
    {
354
        /*
355
         * Relative path references will usually be enclosed by url(). @import
356
         * is an exception, where url() is not necessary around the path (but is
357
         * allowed).
358
         * This *could* be 1 regular expression, where both regular expressions
359
         * in this array are on different sides of a |. But we're using named
360
         * patterns in both regexes, the same name on both regexes. This is only
361
         * possible with a (?J) modifier, but that only works after a fairly
362
         * recent PCRE version. That's why I'm doing 2 separate regular
363
         * expressions & combining the matches after executing of both.
364
         */
365
        $relativeRegexes = array(
366
            // url(xxx)
367
            '/
368
            # open url()
369
            url\(
370
371
                \s*
372
373
                # open path enclosure
374
                (?P<quotes>["\'])?
375
376
                    # fetch path
377
                    (?P<path>
378
379
                        # do not fetch data uris or external sources
380
                        (?!(
381
                            \s?
382
                            ["\']?
383
                            (data|https?):
384
                        ))
385
386
                        .+?
387
                    )
388
389
                # close path enclosure
390
                (?(quotes)(?P=quotes))
391
392
                \s*
393
394
            # close url()
395
            \)
396
397
            /ix',
398
399
            // @import "xxx"
400
            '/
401
            # import statement
402
            @import
403
404
            # whitespace
405
            \s+
406
407
                # we don\'t have to check for @import url(), because the
408
                # condition above will already catch these
409
410
                # open path enclosure
411
                (?P<quotes>["\'])
412
413
                    # fetch path
414
                    (?P<path>
415
416
                        # do not fetch data uris or external sources
417
                        (?!(
418
                            ["\']?
419
                            (data|https?):
420
                        ))
421
422
                        .+?
423
                    )
424
425
                # close path enclosure
426
                (?P=quotes)
427
428
            /ix',
429
        );
430
431
        // find all relative urls in css
432
        $matches = array();
433
        foreach ($relativeRegexes as $relativeRegex) {
434
            if (preg_match_all($relativeRegex, $content, $regexMatches, PREG_SET_ORDER)) {
435
                $matches = array_merge($matches, $regexMatches);
436
            }
437
        }
438
439
        $search = array();
440
        $replace = array();
441
442
        // loop all urls
443
        foreach ($matches as $match) {
444
            // determine if it's a url() or an @import match
445
            $type = (strpos($match[0], '@import') === 0 ? 'import' : 'url');
446
447
            // attempting to interpret GET-params makes no sense, so let's discard them for awhile
448
            $params = strrchr($match['path'], '?');
449
            $url = $params ? substr($match['path'], 0, -strlen($params)) : $match['path'];
450
451
            // fix relative url
452
            $url = $converter->convert($url);
453
454
            // now that the path has been converted, re-apply GET-params
455
            $url .= $params;
456
457
            // build replacement
458
            $search[] = $match[0];
459
            if ($type == 'url') {
460
                $replace[] = 'url('.$url.')';
461
            } elseif ($type == 'import') {
462
                $replace[] = '@import "'.$url.'"';
463
            }
464
        }
465
466
        // replace urls
467
        $content = str_replace($search, $replace, $content);
468
469
        return $content;
470
    }
471
472
    /**
473
     * Shorthand hex color codes.
474
     * #FF0000 -> #F00.
475
     *
476
     * @param string $content The CSS content to shorten the hex color codes for.
477
     *
478
     * @return string
479
     */
480
    protected function shortenHex($content)
481
    {
482
        $content = preg_replace('/(?<![\'"])#([0-9a-z])\\1([0-9a-z])\\2([0-9a-z])\\3(?![\'"])/i', '#$1$2$3', $content);
483
484
        return $content;
485
    }
486
487
    /**
488
     * Shorthand 0 values to plain 0, instead of e.g. -0em.
489
     *
490
     * @param string $content The CSS content to shorten the zero values for.
491
     *
492
     * @return string
493
     */
494
    protected function shortenZeroes($content)
495
    {
496
        // reusable bits of code throughout these regexes:
497
        // before & after are used to make sure we don't match lose unintended
498
        // 0-like values (e.g. in #000, or in http://url/1.0)
499
        // units can be stripped from 0 values, or used to recognize non 0
500
        // values (where wa may be able to strip a .0 suffix)
501
        $before = '(?<=[:(, ])';
502
        $after = '(?=[ ,);}])';
503
        $units = '(em|ex|%|px|cm|mm|in|pt|pc|ch|rem|vh|vw|vmin|vmax|vm)';
504
505
        // strip units after zeroes (0px -> 0)
506
        // NOTE: it should be safe to remove all units for a 0 value, but in
507
        // practice, Webkit (especially Safari) seems to stumble over at least
508
        // 0%, potentially other units as well. Only stripping 'px' for now.
509
        // @see https://github.com/matthiasmullie/minify/issues/60
510
        $content = preg_replace('/'.$before.'(-?0*(\.0+)?)(?<=0)px'.$after.'/', '\\1', $content);
511
512
        // strip 0-digits (.0 -> 0)
513
        $content = preg_replace('/'.$before.'\.0+'.$units.'?'.$after.'/', '0\\1', $content);
514
        // strip trailing 0: 50.10 -> 50.1, 50.10px -> 50.1px
515
        $content = preg_replace('/'.$before.'(-?[0-9]+\.[0-9]+)0+'.$units.'?'.$after.'/', '\\1\\2', $content);
516
        // strip trailing 0: 50.00 -> 50, 50.00px -> 50px
517
        $content = preg_replace('/'.$before.'(-?[0-9]+)\.0+'.$units.'?'.$after.'/', '\\1\\2', $content);
518
        // strip leading 0: 0.1 -> .1, 01.1 -> 1.1
519
        $content = preg_replace('/'.$before.'(-?)0+([0-9]*\.[0-9]+)'.$units.'?'.$after.'/', '\\1\\2\\3', $content);
520
521
        // strip negative zeroes (-0 -> 0) & truncate zeroes (00 -> 0)
522
        $content = preg_replace('/'.$before.'-?0+'.$units.'?'.$after.'/', '0\\1', $content);
523
524
        return $content;
525
    }
526
527
    /**
528
     * Strip comments from source code.
529
     *
530
     * @param string $content
531
     *
532
     * @return string
533
     */
534
    protected function stripEmptyTags($content)
535
    {
536
        return preg_replace('/(^|\})[^\{\}]+\{\s*\}/', '\\1', $content);
537
    }
538
539
    /**
540
     * Strip comments from source code.
541
     */
542
    protected function stripComments()
543
    {
544
        $this->registerPattern('/\/\*.*?\*\//s', '');
545
    }
546
547
    /**
548
     * Strip whitespace.
549
     *
550
     * @param string $content The CSS content to strip the whitespace for.
551
     *
552
     * @return string
553
     */
554
    protected function stripWhitespace($content)
555
    {
556
        // remove leading & trailing whitespace
557
        $content = preg_replace('/^\s*/m', '', $content);
558
        $content = preg_replace('/\s*$/m', '', $content);
559
560
        // replace newlines with a single space
561
        $content = preg_replace('/\s+/', ' ', $content);
562
563
        // remove whitespace around meta characters
564
        // inspired by stackoverflow.com/questions/15195750/minify-compress-css-with-regex
565
        $content = preg_replace('/\s*([\*$~^|]?+=|[{};,>~]|!important\b)\s*/', '$1', $content);
566
        $content = preg_replace('/([\[(:])\s+/', '$1', $content);
567
        $content = preg_replace('/\s+([\]\)])/', '$1', $content);
568
        $content = preg_replace('/\s+(:)(?![^\}]*\{)/', '$1', $content);
569
570
        // whitespace around + and - can only be stripped in selectors, like
571
        // :nth-child(3+2n), not in things like calc(3px + 2px) or shorthands
572
        // like 3px -2px
573
        $content = preg_replace('/\s*([+-])\s*(?=[^}]*{)/', '$1', $content);
574
575
        // remove semicolon/whitespace followed by closing bracket
576
        $content = str_replace(';}', '}', $content);
577
578
        return trim($content);
579
    }
580
}
581