Compressor::loadCss()   B
last analyzed

Complexity

Conditions 6
Paths 16

Size

Total Lines 37
Code Lines 15

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
dl 0
loc 37
rs 8.439
c 0
b 0
f 0
cc 6
eloc 15
nc 16
nop 3
1
<?php
2
3
/**
4
 * @package Compressor
5
 * @author Iurii Makukh <[email protected]>
6
 * @copyright Copyright (c) 2018, Iurii Makukh <[email protected]>
7
 * @license https://www.gnu.org/licenses/gpl-3.0.en.html GPL-3.0-or-later
8
 */
9
10
namespace gplcart\modules\compressor\helpers;
11
12
use InvalidArgumentException;
13
use RuntimeException;
14
use UnexpectedValueException;
15
16
/**
17
 * Aggregates and minifies CSS/JS files
18
 * Inspired by Drupal's aggregator
19
 */
20
class Compressor
21
{
22
23
    /**
24
     * Base URL path
25
     * @var string
26
     */
27
    protected $base;
28
29
    /**
30
     * Base path for CSS url() attribute
31
     * @var string
32
     */
33
    protected $base_css;
34
35
    /**
36
     * Whether to optimize a CSS file
37
     * @var bool
38
     */
39
    protected $optimize_css;
40
41
    /**
42
     * Sets base URL path
43
     * @param $url
44
     * @return $this
45
     */
46
    public function setBase($url)
47
    {
48
        $this->base = $url;
49
        return $this;
50
    }
51
52
    /**
53
     * Aggregate an array of scripts into one compressed file
54
     * @param array $files
55
     * @param string $directory
56
     * @return string
57
     */
58
    public function getJs(array $files, $directory)
59
    {
60
        $key = $this->getKey($files);
61
62
        $filename = "js_$key.js";
63
        $uri = "$directory/$filename";
64
65
        if (file_exists($uri)) {
66
            return $uri;
67
        }
68
69
        $data = '';
70
71
        foreach ($files as $file) {
72
            $contents = file_get_contents($file);
73
            $contents .= ";\n";
74
            $data .= $contents;
75
        }
76
77
        $this->write($directory, $filename, $data);
78
        return $uri;
79
    }
80
81
    /**
82
     * Aggregate an array of stylesheets into one compressed file
83
     * @param array $files
84
     * @param string $directory
85
     * @return string
86
     * @throws InvalidArgumentException
87
     */
88
    public function getCss(array $files, $directory)
89
    {
90
        $key = $this->getKey($files);
91
        $filename = "css_$key.css";
92
        $uri = "$directory/$filename";
93
94
        if (file_exists($uri)) {
95
            return $uri;
96
        }
97
98
        if (!isset($this->base)) {
99
            throw new InvalidArgumentException('Base URL is not set');
100
        }
101
102
        $data = '';
103
104
        foreach ($files as $file) {
105
106
            $contents = $this->loadCss($file, true);
107
108
            // Build the base URL of this CSS file: start with the full URL.
109
            $url = "{$this->base}$file";
110
111
            // Move to the parent.
112
            $url = substr($url, 0, strrpos($url, '/'));
113
114
            $this->buildPathCss(null, "$url/");
115
116
            // Anchor all paths in the CSS with its base URL, ignoring external and absolute paths.
117
            $pattern = '/url\(\s*[\'"]?(?![a-z]+:|\/+)([^\'")]+)[\'"]?\s*\)/i';
118
            $data .= preg_replace_callback($pattern, array($this, 'buildPathCss'), $contents);
119
        }
120
121
        // Per the W3C specification at http://www.w3.org/TR/REC-CSS2/cascade.html#at-import,
122
        // @import rules must proceed any other style, so we move those to the top.
123
        $regexp = '/@import[^;]+;/i';
124
        preg_match_all($regexp, $data, $matches);
125
126
        $data = preg_replace($regexp, '', $data);
127
        $data = implode('', $matches[0]) . $data;
128
129
        $this->write($directory, $filename, $data);
130
        return $uri;
131
    }
132
133
    /**
134
     * Processes the contents of a stylesheet for aggregation.
135
     * @param string $contents
136
     * @param bool $optimize
137
     * @return string
138
     * @todo remove second argument
139
     */
140
    protected function processCss($contents, $optimize = false)
141
    {
142
        // Remove multiple charset declarations for standards compliance (and fixing Safari problems).
143
        $contents = preg_replace('/^@charset\s+[\'"](\S*?)\b[\'"];/i', '', $contents);
144
145
        if ($optimize) {
146
            $contents = $this->optimizeCss($contents);
147
        }
148
149
        // Replaces @import commands with the actual stylesheet content.
150
        // This happens recursively but omits external files.
151
        $pattern = '/@import\s*(?:url\(\s*)?[\'"]?(?![a-z]+:)(?!\/\/)([^\'"\()]+)[\'"]?\s*\)?\s*;/';
152
        return preg_replace_callback($pattern, array($this, 'prepareCss'), $contents);
153
    }
154
155
    /**
156
     * Loads stylesheets recursively and returns contents with corrected paths.
157
     * @param array $matches
158
     * @return string
159
     */
160
    protected function prepareCss($matches)
161
    {
162
        $filename = $matches[1];
163
164
        // Load the imported stylesheet and replace @import commands in there as well.
165
        $file = $this->loadCss($filename, null, false);
166
167
        // Determine the file's directory.
168
        $directory = dirname($filename);
169
170
        // If the file is in the current directory, make sure '.' doesn't appear in
171
        // the url() path.
172
        $directory = $directory == '.' ? '' : $directory . '/';
173
174
        // Alter all internal url() paths. Leave external paths alone. We don't need
175
        // to normalize absolute paths here (i.e. remove folder/... segments) because
176
        // that will be done later.
177
        $pattern = '/url\(\s*([\'"]?)(?![a-z]+:|\/+)([^\'")]+)([\'"]?)\s*\)/i';
178
179
        return preg_replace($pattern, 'url(\1' . $directory . '\2\3)', $file);
180
    }
181
182
    /**
183
     * Loads the stylesheet and resolves all @import commands.
184
     * @param string $file
185
     * @param null|boolean $optimize
186
     * @param bool $reset_basepath
187
     * @return string
188
     * @throws UnexpectedValueException
189
     */
190
    public function loadCss($file, $optimize = null, $reset_basepath = true)
191
    {
192
        if ($reset_basepath) {
193
            $this->base_css = '';
194
        }
195
196
        // Store the value of $optimize_css for preg_replace_callback with nested
197
        // @import loops.
198
        if (isset($optimize)) {
199
            $this->optimize_css = $optimize;
200
        }
201
202
        // Stylesheets are relative one to each other. Start by adding a base path
203
        // prefix provided by the parent stylesheet (if necessary).
204
        if ($this->base_css && strpos($file, '://') === false) {
205
            $file = "{$this->base_css}/$file";
206
        }
207
208
        // Store the parent base path to restore it later.
209
        $parent_base_path = $this->base_css;
210
211
        // Set the current base path to process possible child imports.
212
        $this->base_css = dirname($file);
213
214
        // Load the CSS stylesheet
215
        $contents = file_get_contents($file);
216
217
        if ($contents === false) {
218
            throw new UnexpectedValueException("Failed to read file $file");
219
        }
220
221
        $result = $this->processCss($contents, $this->optimize_css);
222
223
        // Restore the parent base path as the file and its childen are processed.
224
        $this->base_css = $parent_base_path;
225
        return $result;
226
    }
227
228
    /**
229
     * Performs some safe CSS optimizations
230
     * @param string $contents
231
     * @return string
232
     */
233
    public function optimizeCss($contents)
234
    {
235
        // Regexp to match comment blocks.
236
        $comment = '/\*[^*]*\*+(?:[^/*][^*]*\*+)*/';
237
238
        // Regexp to match double quoted strings.
239
        $double_quot = '"[^"\\\\]*(?:\\\\.[^"\\\\]*)*"';
240
241
        // Regexp to match single quoted strings.
242
        $single_quot = "'[^'\\\\]*(?:\\\\.[^'\\\\]*)*'";
243
244
        // Strip all comment blocks, but keep double/single quoted strings.
245
        $contents = preg_replace(
246
            "<($double_quot|$single_quot)|$comment>Ss", "$1", $contents
247
        );
248
249
        // Remove certain whitespace.
250
        // There are different conditions for removing leading and trailing
251
        // whitespace.
252
        // @see http://php.net/manual/regexp.reference.subpatterns.php
253
        $contents = preg_replace('<
254
                # Strip leading and trailing whitespace.
255
                \s*([@{};,])\s*
256
                # Strip only leading whitespace from:
257
                # - Closing parenthesis: Retain "@media (bar) and foo".
258
                | \s+([\)])
259
                # Strip only trailing whitespace from:
260
                # - Opening parenthesis: Retain "@media (bar) and foo".
261
                # - Colon: Retain :pseudo-selectors.
262
                | ([\(:])\s+
263
                >xS',
264
            // Only one of the three capturing groups will match, so its reference
265
            // will contain the wanted value and the references for the
266
            // two non-matching groups will be replaced with empty strings.
267
            '$1$2$3', $contents
268
        );
269
270
        // End the file with a new line.
271
        $contents = trim($contents);
272
        $contents .= "\n";
273
274
        return $contents;
275
    }
276
277
    /**
278
     * Prefixes all paths within a CSS file
279
     * @param array|null $matches
280
     * @param null|string $basepath
281
     * @return string
282
     */
283
    protected function buildPathCss($matches, $basepath = null)
284
    {
285
        // Store base path for preg_replace_callback.
286
        if (isset($basepath)) {
287
            $this->base_css = $basepath;
288
        }
289
290
        // Prefix with base and remove '../' segments where possible.
291
        $path = $this->base_css . $matches[1];
292
293
        $last = '';
294
295
        while ($path != $last) {
296
            $last = $path;
297
            $path = preg_replace('`(^|/)(?!\.\./)([^/]+)/\.\./`', '$1', $path);
298
        }
299
300
        return 'url(' . $path . ')';
301
    }
302
303
    /**
304
     * Creates a unique key from an array of files
305
     * @param array $files
306
     * @return string
307
     */
308
    protected function getKey(array $files)
309
    {
310
        return md5(json_encode($files));
311
    }
312
313
    /**
314
     * Writes an aggregated data to a file
315
     * @param string $directory
316
     * @param string $filename
317
     * @param string $data
318
     * @throws RuntimeException
319
     */
320
    protected function write($directory, $filename, $data)
321
    {
322
        if (!file_exists($directory) && !mkdir($directory, 0775, true)) {
323
            throw new RuntimeException("Failed to create directory $directory");
324
        }
325
326
        $file = "$directory/$filename";
327
328
        if (file_put_contents($file, $data) === false) {
329
            throw new RuntimeException("Failed to write to file $file");
330
        }
331
    }
332
333
}
334