-
Notifications
You must be signed in to change notification settings - Fork 1
/
Copy pathclass.cb_min.php
496 lines (427 loc) · 17.9 KB
/
class.cb_min.php
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
<?php
/* This file is part of cbutil.
* Copyright © 2011-2012 stiftung kulturserver.de ggmbh <[email protected]>
*
* cbutil is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* cbutil is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with cbutil. If not, see <http://www.gnu.org/licenses/>.
*/
// Ensure that all required minifiers are available.
require_once '3rdparty/jsmin.php';
require_once 'class.cb_css_min.php';
/**
* Generic combiner/compressor/minifier for JavaScript and CSS (including
* automatic caching).
*
* @author Johannes Wüller <[email protected]>
*/
class CbMin {
// These represent the type of file that is about to be minified.
const JS = 0x1;
const CSS = 0x2;
// These are used for simple scanning of comment nesting.
const TOKEN_COMMENT_START = 0x1;
const TOKEN_COMMENT_END = 0x2;
const TOKEN_CONDITIONAL_COMPILATION_START = 0x4;
const TOKEN_CONDITIONAL_COMPILATION_END = 0x8;
/**
* Stores the mime-types associated with the various minified files.
*/
private static $mimeTypes = array(
self::JS => 'application/x-javascript',
self::CSS => 'text/css'
);
/**
* Stores the file extensions for the cached files that identify their type.
*/
private static $extensions = array(
self::JS => 'js',
self::CSS => 'css'
);
/**
* Maps token values to token constants. WARNING: The tokens are ordered by
* priority. The lower, the more important (they override in that order). Do
* not mess with that without thinking about it!
*/
private static $tokens = array(
'/*' => self::TOKEN_COMMENT_START,
'*/' => self::TOKEN_COMMENT_END,
'/*@cc_on' => self::TOKEN_CONDITIONAL_COMPILATION_START,
'/*@if' => self::TOKEN_CONDITIONAL_COMPILATION_START,
'/*@set' => self::TOKEN_CONDITIONAL_COMPILATION_START,
'@*/' => self::TOKEN_CONDITIONAL_COMPILATION_END
);
/**
* Stores the current configuration.
*/
private $options;
/**
* Creates a minifier.
*
* The following properties are available:
* property ----- type --- default --- description -----------------------
* cache.enabled boolean true wether caching should be used
* (should be disabled for debugging
* only)
* cache.create boolean true wether to create the cache
* directory (recursively) if it does
* not exist
* cache.dir string /tmp cache location (/tmp/cb_min is
* the default global cache location)
* minify boolean true wether to minify the concatenated
* files (gets automatically disabled
* on development servers unless
* explicitly specified)
* debug boolean false wether to include debug comments
* that identify each file after
* concatenation (gets automatically
* enabled on development servers
* unless explicitly specified)
*
* @param array $options list of properties
*/
public function __construct(array $options = array()) {
// Are we working on a development server?
$isDev = substr(php_uname('n'), 0, 3) === 'dev';
// Merge with defaults to obtain a complete configuration.
$this->options = array_merge(array(
'cache.enabled' => true,
'cache.create' => true,
'cache.dir' => '/tmp/cb_min',
'minify' => !$isDev,
'debug' => $isDev
), $options);
// Remove trailing slashes.
if ($this->options['cache.dir'] !== null) {
$this->options['cache.dir'] = rtrim($this->options['cache.dir'], DIRECTORY_SEPARATOR);
}
// We definitely need a cache dir if caching is enabled.
if ($this->options['cache.enabled'] && !is_dir($this->options['cache.dir'])) {
// Create the cache directory if we are allowed to do so. Spit an
// exception otherwise.
if (!$this->options['cache.create'] || !mkdir($this->options['cache.dir'], 755, true)) {
throw new CbMinException(sprintf("The caching directory `%s' does" .
" not exist or is unaccessible. Please check the file " .
"permissions.",
$this->options['cache.dir']));
}
}
}
/**
* Helper for easy serving. The filenames (without directory and extension)
* are fetched from the request URL. The suffix is automatically appended
* based on the served type. More complex serving approaches should use the
* regular interface. The URL should look like this:
*
* requested_script.php/a,b,c,...
*
* The directory is added in front and the file extension at the back of
* the names. The result could look like this:
*
* styles/a.css
* styles/b.css
* styles/c.css
*
* All of these files are then served regularly.
*
* @param integer $type type of the files
* @param string $directory
* @param array $minOptions
*/
public static function serveSimple($type, $directory = '', array $minOptions = array()) {
if (!isset(self::$extensions[$type])) {
throw new CbMinException("Unknown type.");
}
// Sanitize directory.
$directory = rtrim($directory, DIRECTORY_SEPARATOR);
if (!empty($directory)) {
$directory .= DIRECTORY_SEPARATOR;
}
// Parse URL to determine requested files and convert them to actual
// filenames.
$files = array();
foreach (explode(',', basename(rtrim($_SERVER['REQUEST_URI'], DIRECTORY_SEPARATOR))) as $filename) {
$files[] = sprintf('%s%s.%s', $directory, trim($filename), self::$extensions[$type]);
}
// Serve it.
$minifier = new CbMin($minOptions);
$minifier->serve($files, $type);
}
/**
* Delivers the minified files at once.
*
* @param array $files list of files
* @param integer $type type of the files
*/
public function serve(array $files, $type) {
// Remove all files that do not have the right suffix to prevent attacks.
foreach ($files as $key => $filename) {
$extension = strtolower(preg_replace('/^.*\.([a-z])$/i', '$1', $filename));
if (in_array($extension, array_values(self::$extensions))) {
unset($files[$key]);
}
}
// Ensure that we are not using a sparse array after cleanup.
$files = array_values($files);
// We need a valid type.
if (!isset(self::$mimeTypes[$type])) {
throw new CbMinException("Unknown type.");
}
// Serve the whole thing using the correct mime-type.
header('Content-Type: ' . self::$mimeTypes[$type] . ';charset=utf-8');
// Is there anything to do?
if (!empty($files)) {
$content = null;
// Do we have a cached version available?
if ($this->options['cache.enabled']) {
// Build identifier.
$cacheFile = $this->options['cache.dir'] . DIRECTORY_SEPARATOR . $this->id($files, $type);
// Check if the cache needs to be invalidated (this needs to be done
// if any of the combined files has been modified since the cache
// has been generated).
if (file_exists($cacheFile)) {
$cacheAge = filemtime($cacheFile);
foreach ($files as $filename) {
if (filemtime($filename) > $cacheAge) {
// Invalidate the cache (i.e. delete the cache file).
unlink($cacheFile);
break;
}
}
}
// Do not send anything (304) if the client has a cached version of
// the file (identified by etag). This is ignored if the file does
// not exist, since it does not make sense to cache a non-existant
// resource on the client-side.
if (file_exists($cacheFile)) {
$requestHeaders = apache_request_headers();
if (isset($requestHeaders['If-None-Match']) && $requestHeaders['If-None-Match'] == $this->etag($cacheFile)) {
header("HTTP/1.1 304 Not Modified");
// Abort any further action to prevent any output or further
// IO operations.
exit;
}
}
// Generate the cache if it is missing (or has been invalidated).
if (!file_exists($cacheFile)) {
// Process the input files.
$content = $this->build($files, $type);
// Save the processed files to the disc.
if (file_put_contents($cacheFile, $content) === false) {
throw new CbMinException(sprintf("Could not write caching " .
"file `%s'. Please check the file permissions.",
$cacheFile));
}
// Prepend the cache file path if we are debugging.
if ($this->options['debug']) {
$content = sprintf("/* created cache file: %s */\n\n", $cacheFile) . $content;
}
} else {
// There is a cached version. Let's use that.
$content = file_get_contents($cacheFile);
if ($this->options['debug']) {
$content = sprintf("/* loaded cache file: %s */\n\n", $cacheFile) . $content;
}
}
// Send an etag for caching.
if (!$this->options['debug']) {
header('ETag: ' . $this->etag($cacheFile));
}
} else {
// Caching is disabled. We need to do everything on the fly.
$content = $this->build($files, $type);
if ($this->options['debug']) {
$content = "/* built on the fly */\n\n" . $content;
}
}
// Deliver the whole thing.
echo $content;
exit;
}
}
/**
* Does the actual concatenation/compression/minification.
*
* @param array $files list of files
* @param integer $type type of the files
* @return processed file content
*/
private function build(array $files, $type) {
$content = '';
// Determine the absolute filesystem path for all files.
foreach ($files as $key => $filename) {
$absoluteFilename = realpath($filename);
if ($absoluteFilename === false) {
throw new CbMinException(sprintf("Could not determine absolute " .
"path for file `%s'. It probably does not exist.",
$filename));
}
$files[$key] = $absoluteFilename;
}
// Prepend content information if we are debugging.
if ($this->options['debug']) {
$content .= "/* \n" .
" * Combined files (in this order):\n";
// Find the length of the longest file path. We need this to be able to
// align all filenames properly in the next step.
$filenameLength = 0;
foreach ($files as $filename) {
$filenameLength = max($filenameLength, mb_strlen($filename));
}
// List all filenames in a table-structure.
foreach ($files as $filename) {
$content .= sprintf(" * %-" . $filenameLength . "s [%s]\n", $filename, date('d.m.Y H:i:s', filemtime($filename)));
}
$content .= " */\n";
}
// Concat (and minify) the content of all files.
foreach ($files as $filename) {
if ($this->options['debug']) {
$content .= sprintf("\n/* --- %s %s */\n", $filename, str_repeat('-', max(69 - strlen($filename), 3)));
}
$fileContent = file_get_contents($filename);
if ($fileContent === false) {
throw new CbMinException(sprintf("Could not read file `%s'. " .
"Please check the file permissions.",
$filename));
}
if ($this->options['minify']) {
// Do the actual minification.
switch ($type) {
case self::JS: $content .= trim(JSMin::minify($fileContent)); break;
case self::CSS: $content .= CbCssMin::minify($fileContent); break;
}
} else {
// No minification, just append.
// We may need to include line numbers in front of each line to make
// sure that they are easy to find.
if ($this->options['debug']) {
$content .= $this->addLineNumbers($fileContent);
} else {
// Nope, only plain files.
$content .= $fileContent;
}
}
$content .= "\n";
}
return $content;
}
/**
* Generates a unique identifier based on used files, type, minification and
* debugging settings. The identifier can be used as a filename.
*
* @param array $files list of files
* @param integer $type type of the files
* @return unique identifier
*/
private function id(array $files, $type) {
// Collect additional attributes.
$attributes = array();
if ($this->options['minify']) $attributes[] = 'min';
if ($this->options['debug']) $attributes[] = 'dbg';
// Build identifier.
return sprintf('%s%s.%s',
$this->hash($files),
!empty($attributes) ? '.' . implode('.', $attributes) : '',
self::$extensions[$type]);
}
/**
* Builds a unique identifier for the used input files. Order is important,
* so different order creates different identifiers.
*
* @param array $files list of files
* @return identifier
*/
private function hash(array $files) {
return md5(implode(';', array_map('realpath', $files)));
}
/**
* Generates a standard etag (like the one apache uses) for the given file.
* It consists out of inode, size and mtime.
*
* @param type $filename
*/
private function etag($filename) {
$stat = stat($filename);
return sprintf('"%x-%x-%s"',
$stat['ino'],
$stat['size'],
base_convert(sprintf('%016d', $stat['mtime']), 10, 16));
}
/**
* Adds line numbers to a code snippet.
*
* @param string $code
* @return string code
*/
private function addLineNumbers($code) {
$lines = explode("\n", $code);
$lineNumberDigitCount = mb_strlen(sprintf('%d', count($lines)));
$isInComment = false;
$isInConditionalCompilation = false;
foreach ($lines as $index => $line) {
// We only need to include a comment if we are not already in a
// comment. We use a similar visual indicator to avoid interrupting the
// visual flow. This makes it easier to scan the file.
$format = sprintf($isInComment ? '/+ %s +/' : '/* %s */', '%' . $lineNumberDigitCount . 'd');
$prefix = sprintf($format, $index + 1);
// Of course, there is a special case for IE: Conditional compilation.
// It allows for IE-only JavaScript (again, inside comments... who
// thinks of this kind of stuff?). Thus, it does not support our visual
// aid for file scanning in what looks like a comment. We need to
// completely disable non-whitespace additions in that case.
if ($isInConditionalCompilation) {
$prefix = str_repeat(' ', mb_strlen($prefix));
}
// Put everything together.
$lines[$index] = $prefix . ' ' . $line;
// Determine wether the tokens are balanced and remember it for the
// following lines. Luckily, JavaScript does not support multi-line
// strings. THAT would be tricky to parse safely. Comments are easy,
// due to their simple nature (no nesting, etc). Conditional
// compilation is a special case for IE, though.
foreach ($this->tokenize($line) as $token) {
switch ($token) {
case self::TOKEN_CONDITIONAL_COMPILATION_START: $isInConditionalCompilation = true; break;
case self::TOKEN_CONDITIONAL_COMPILATION_END: $isInConditionalCompilation = false; break;
case self::TOKEN_COMMENT_START: $isInComment = true; break;
case self::TOKEN_COMMENT_END: $isInComment = false; break;
}
}
}
return implode("\n", $lines);
}
/**
* Converts a string into a token list.
*
* @param string $string
* @return array tokens
*/
private function tokenize($string) {
$collectedTokens = array();
// Look out for all the tokens and store them by position.
foreach (self::$tokens as $value => $token) {
$pos = 0;
while (($pos = strpos($string, $value, $pos)) !== false) {
$collectedTokens[$pos] = $token;
$pos += mb_strlen($value);
}
}
// Get them in the correct order.
ksort($collectedTokens);
return array_values($collectedTokens);
}
}
/**
* Represents an exception that can occur
*/
class CbMinException extends Exception {}