609ec79f10689d7dab16720f0951fe07697df161
[Packages/TYPO3.CMS.git] / typo3 / sysext / core / Classes / Resource / ResourceCompressor.php
1 <?php
2 namespace TYPO3\CMS\Core\Resource;
3
4 /*
5 * This file is part of the TYPO3 CMS project.
6 *
7 * It is free software; you can redistribute it and/or modify it under
8 * the terms of the GNU General Public License, either version 2
9 * of the License, or any later version.
10 *
11 * For the full copyright and license information, please read the
12 * LICENSE.txt file that was distributed with this source code.
13 *
14 * The TYPO3 project - inspiring people to share!
15 */
16
17 use TYPO3\CMS\Core\Utility\GeneralUtility;
18 use TYPO3\CMS\Core\Utility\MathUtility;
19 use TYPO3\CMS\Core\Utility\PathUtility;
20
21 /**
22 * Compressor
23 * This merges and compresses CSS and JavaScript files of the TYPO3 Backend.
24 */
25 class ResourceCompressor
26 {
27 /**
28 * @var string
29 */
30 protected $targetDirectory = 'typo3temp/compressor/';
31
32 /**
33 * @var string
34 */
35 protected $relativePath = '';
36
37 /**
38 * @var string
39 */
40 protected $rootPath = '';
41
42 /**
43 * @var string
44 */
45 protected $backPath = '';
46
47 /**
48 * gzipped versions are only created if $TYPO3_CONF_VARS[TYPO3_MODE]['compressionLevel'] is set
49 *
50 * @var bool
51 */
52 protected $createGzipped = false;
53
54 /**
55 * @var int
56 */
57 protected $gzipCompressionLevel = -1;
58
59 protected $htaccessTemplate = '<FilesMatch "\\.(js|css)(\\.gzip)?$">
60 <IfModule mod_expires.c>
61 ExpiresActive on
62 ExpiresDefault "access plus 7 days"
63 </IfModule>
64 FileETag MTime Size
65 </FilesMatch>';
66
67 /**
68 * Constructor
69 */
70 public function __construct()
71 {
72 // we check for existence of our targetDirectory
73 if (!is_dir(PATH_site . $this->targetDirectory)) {
74 GeneralUtility::mkdir(PATH_site . $this->targetDirectory);
75 }
76 // if enabled, we check whether we should auto-create the .htaccess file
77 if ($GLOBALS['TYPO3_CONF_VARS']['SYS']['generateApacheHtaccess']) {
78 // check whether .htaccess exists
79 $htaccessPath = PATH_site . $this->targetDirectory . '.htaccess';
80 if (!file_exists($htaccessPath)) {
81 GeneralUtility::writeFile($htaccessPath, $this->htaccessTemplate);
82 }
83 }
84 // decide whether we should create gzipped versions or not
85 $compressionLevel = $GLOBALS['TYPO3_CONF_VARS'][TYPO3_MODE]['compressionLevel'];
86 // we need zlib for gzencode()
87 if (extension_loaded('zlib') && $compressionLevel) {
88 $this->createGzipped = true;
89 // $compressionLevel can also be TRUE
90 if (MathUtility::canBeInterpretedAsInteger($compressionLevel)) {
91 $this->gzipCompressionLevel = (int)$compressionLevel;
92 }
93 }
94 $this->setInitialPaths();
95 }
96
97 /**
98 * Sets initial values for paths.
99 *
100 * @return void
101 */
102 public function setInitialPaths()
103 {
104 $this->setInitialRelativePath();
105 $this->setInitialRootPath();
106 }
107
108 /**
109 * Sets absolute path to working directory
110 *
111 * @return void
112 */
113 protected function setInitialRootPath()
114 {
115 $rootPath = TYPO3_MODE === 'BE' ? PATH_typo3 : PATH_site;
116 $this->setRootPath($rootPath);
117 }
118
119 /**
120 * Sets relative path to PATH_site
121 *
122 * @return void
123 */
124 protected function setInitialRelativePath()
125 {
126 $relativePath = TYPO3_MODE === 'BE' ? '../' : '';
127 $this->setRelativePath($relativePath);
128 }
129
130 /**
131 * Sets relative path to PATH_site
132 *
133 * @param string $relativePath Relative path to site root
134 * @return void
135 */
136 public function setRelativePath($relativePath)
137 {
138 if (is_string($relativePath)) {
139 $this->relativePath = $relativePath;
140 }
141 }
142
143 /**
144 * Sets absolute path to working directory
145 *
146 * @param string $rootPath Absolute path
147 * @return void
148 */
149 public function setRootPath($rootPath)
150 {
151 if (is_string($rootPath)) {
152 $this->rootPath = $rootPath;
153 }
154 }
155
156 /**
157 * Sets relative back path
158 *
159 * @param string $backPath Back path
160 * @return void
161 */
162 public function setBackPath($backPath)
163 {
164 if (is_string($backPath)) {
165 $this->backPath = $backPath;
166 }
167 }
168
169 /**
170 * Concatenates the Stylesheet files
171 *
172 * Options:
173 * baseDirectories If set, only include files below one of the base directories
174 *
175 * @param array $cssFiles CSS files to process
176 * @param array $options Additional options
177 * @return array CSS files
178 */
179 public function concatenateCssFiles(array $cssFiles, array $options = array())
180 {
181 $filesToIncludeByType = array('all' => array());
182 foreach ($cssFiles as $key => $fileOptions) {
183 // no concatenation allowed for this file, so continue
184 if (!empty($fileOptions['excludeFromConcatenation'])) {
185 continue;
186 }
187 // we remove BACK_PATH from $filename, so make it relative to root path
188 $filenameFromMainDir = $this->getFilenameFromMainDir($fileOptions['file']);
189 // if $options['baseDirectories'] set, we only include files below these directories
190 if (
191 !isset($options['baseDirectories'])
192 || $this->checkBaseDirectory(
193 $filenameFromMainDir, array_merge($options['baseDirectories'], array($this->targetDirectory))
194 )
195 ) {
196 $type = isset($fileOptions['media']) ? strtolower($fileOptions['media']) : 'all';
197 if (!isset($filesToIncludeByType[$type])) {
198 $filesToIncludeByType[$type] = array();
199 }
200 if ($fileOptions['forceOnTop']) {
201 array_unshift($filesToIncludeByType[$type], $filenameFromMainDir);
202 } else {
203 $filesToIncludeByType[$type][] = $filenameFromMainDir;
204 }
205 // remove the file from the incoming file array
206 unset($cssFiles[$key]);
207 }
208 }
209 if (!empty($filesToIncludeByType)) {
210 foreach ($filesToIncludeByType as $mediaOption => $filesToInclude) {
211 if (empty($filesToInclude)) {
212 continue;
213 }
214 $targetFile = $this->createMergedCssFile($filesToInclude);
215 $targetFileRelative = $this->relativePath . $targetFile;
216 $concatenatedOptions = array(
217 'file' => $targetFileRelative,
218 'rel' => 'stylesheet',
219 'media' => $mediaOption,
220 'compress' => true,
221 'excludeFromConcatenation' => true,
222 'forceOnTop' => false,
223 'allWrap' => ''
224 );
225 // place the merged stylesheet on top of the stylesheets
226 $cssFiles = array_merge($cssFiles, array($targetFileRelative => $concatenatedOptions));
227 }
228 }
229 return $cssFiles;
230 }
231
232 /**
233 * Concatenates the JavaScript files
234 *
235 * @param array $jsFiles JavaScript files to process
236 * @return array JS files
237 */
238 public function concatenateJsFiles(array $jsFiles)
239 {
240 $filesToInclude = array();
241 foreach ($jsFiles as $key => $fileOptions) {
242 // invalid section found or no concatenation allowed, so continue
243 if (empty($fileOptions['section']) || !empty($fileOptions['excludeFromConcatenation'])) {
244 continue;
245 }
246 if (!isset($filesToInclude[$fileOptions['section']])) {
247 $filesToInclude[$fileOptions['section']] = array();
248 }
249 // we remove BACK_PATH from $filename, so make it relative to root path
250 $filenameFromMainDir = $this->getFilenameFromMainDir($fileOptions['file']);
251 if ($fileOptions['forceOnTop']) {
252 array_unshift($filesToInclude[$fileOptions['section']], $filenameFromMainDir);
253 } else {
254 $filesToInclude[$fileOptions['section']][] = $filenameFromMainDir;
255 }
256 // remove the file from the incoming file array
257 unset($jsFiles[$key]);
258 }
259 if (!empty($filesToInclude)) {
260 foreach ($filesToInclude as $section => $files) {
261 $targetFile = $this->createMergedJsFile($files);
262 $targetFileRelative = $this->relativePath . $targetFile;
263 $concatenatedOptions = array(
264 'file' => $targetFileRelative,
265 'type' => 'text/javascript',
266 'section' => $section,
267 'compress' => true,
268 'excludeFromConcatenation' => true,
269 'forceOnTop' => false,
270 'allWrap' => ''
271 );
272 // place the merged javascript on top of the JS files
273 $jsFiles = array_merge(array($targetFileRelative => $concatenatedOptions), $jsFiles);
274 }
275 }
276 return $jsFiles;
277 }
278
279 /**
280 * Creates a merged CSS file
281 *
282 * @param array $filesToInclude Files which should be merged, paths relative to root path
283 * @return mixed Filename of the merged file
284 */
285 protected function createMergedCssFile(array $filesToInclude)
286 {
287 return $this->createMergedFile($filesToInclude, 'css');
288 }
289
290 /**
291 * Creates a merged JS file
292 *
293 * @param array $filesToInclude Files which should be merged, paths relative to root path
294 * @return mixed Filename of the merged file
295 */
296 protected function createMergedJsFile(array $filesToInclude)
297 {
298 return $this->createMergedFile($filesToInclude, 'js');
299 }
300
301 /**
302 * Creates a merged file with given file type
303 *
304 * @param array $filesToInclude Files which should be merged, paths relative to root path
305 * @param string $type File type
306 *
307 * @throws \InvalidArgumentException
308 * @return mixed Filename of the merged file
309 */
310 protected function createMergedFile(array $filesToInclude, $type = 'css')
311 {
312 // Get file type
313 $type = strtolower(trim($type, '. '));
314 if (empty($type)) {
315 throw new \InvalidArgumentException('No valid file type given for files to be merged.', 1308957498);
316 }
317 // we add up the filenames, filemtimes and filsizes to later build a checksum over
318 // it and include it in the temporary file name
319 $unique = '';
320 foreach ($filesToInclude as $key => $filename) {
321 if (GeneralUtility::isValidUrl($filename)) {
322 // check if it is possibly a local file with fully qualified URL
323 if (GeneralUtility::isOnCurrentHost($filename) &&
324 GeneralUtility::isFirstPartOfStr(
325 $filename,
326 GeneralUtility::getIndpEnv('TYPO3_SITE_URL')
327 )
328 ) {
329 // attempt to turn it into a local file path
330 $localFilename = substr($filename, strlen(GeneralUtility::getIndpEnv('TYPO3_SITE_URL')));
331 if (@is_file(GeneralUtility::resolveBackPath($this->rootPath . $localFilename))) {
332 $filesToInclude[$key] = $localFilename;
333 } else {
334 $filesToInclude[$key] = $this->retrieveExternalFile($filename);
335 }
336 } else {
337 $filesToInclude[$key] = $this->retrieveExternalFile($filename);
338 }
339 $filename = $filesToInclude[$key];
340 }
341 $filenameAbsolute = GeneralUtility::resolveBackPath($this->rootPath . $filename);
342 if (@file_exists($filenameAbsolute)) {
343 $fileStatus = stat($filenameAbsolute);
344 $unique .= $filenameAbsolute . $fileStatus['mtime'] . $fileStatus['size'];
345 } else {
346 $unique .= $filenameAbsolute;
347 }
348 }
349 $targetFile = $this->targetDirectory . 'merged-' . md5($unique) . '.' . $type;
350 // if the file doesn't already exist, we create it
351 if (!file_exists((PATH_site . $targetFile))) {
352 $concatenated = '';
353 // concatenate all the files together
354 foreach ($filesToInclude as $filename) {
355 $contents = GeneralUtility::getUrl(GeneralUtility::resolveBackPath($this->rootPath . $filename));
356 // only fix paths if files aren't already in typo3temp (already processed)
357 if ($type === 'css' && !GeneralUtility::isFirstPartOfStr($filename, $this->targetDirectory)) {
358 $contents = $this->cssFixRelativeUrlPaths($contents, PathUtility::dirname($filename) . '/');
359 }
360 $concatenated .= LF . $contents;
361 }
362 // move @charset, @import and @namespace statements to top of new file
363 if ($type === 'css') {
364 $concatenated = $this->cssFixStatements($concatenated);
365 }
366 GeneralUtility::writeFile(PATH_site . $targetFile, $concatenated);
367 }
368 return $targetFile;
369 }
370
371 /**
372 * Compress multiple css files
373 *
374 * @param array $cssFiles The files to compress (array key = filename), relative to requested page
375 * @return array The CSS files after compression (array key = new filename), relative to requested page
376 */
377 public function compressCssFiles(array $cssFiles)
378 {
379 $filesAfterCompression = array();
380 foreach ($cssFiles as $key => $fileOptions) {
381 // if compression is enabled
382 if ($fileOptions['compress']) {
383 $filename = $this->compressCssFile($fileOptions['file']);
384 $fileOptions['compress'] = false;
385 $fileOptions['file'] = $filename;
386 $filesAfterCompression[$filename] = $fileOptions;
387 } else {
388 $filesAfterCompression[$key] = $fileOptions;
389 }
390 }
391 return $filesAfterCompression;
392 }
393
394 /**
395 * Compresses a CSS file
396 *
397 * Options:
398 * baseDirectories If set, only include files below one of the base directories
399 *
400 * removes comments and whitespaces
401 * Adopted from https://github.com/drupal/drupal/blob/8.0.x/core/lib/Drupal/Core/Asset/CssOptimizer.php
402 *
403 * @param string $filename Source filename, relative to requested page
404 * @return string Compressed filename, relative to requested page
405 */
406 public function compressCssFile($filename)
407 {
408 // generate the unique name of the file
409 $filenameAbsolute = GeneralUtility::resolveBackPath($this->rootPath . $this->getFilenameFromMainDir($filename));
410 if (@file_exists($filenameAbsolute)) {
411 $fileStatus = stat($filenameAbsolute);
412 $unique = $filenameAbsolute . $fileStatus['mtime'] . $fileStatus['size'];
413 } else {
414 $unique = $filenameAbsolute;
415 }
416
417 $pathinfo = PathUtility::pathinfo($filename);
418 $targetFile = $this->targetDirectory . $pathinfo['filename'] . '-' . md5($unique) . '.css';
419 // only create it, if it doesn't exist, yet
420 if (!file_exists((PATH_site . $targetFile)) || $this->createGzipped && !file_exists((PATH_site . $targetFile . '.gzip'))) {
421 $contents = $this->compressCssString(GeneralUtility::getUrl($filenameAbsolute));
422 if (strpos($filename, $this->targetDirectory) === false) {
423 $contents = $this->cssFixRelativeUrlPaths($contents, PathUtility::dirname($filename) . '/');
424 }
425 $this->writeFileAndCompressed($targetFile, $contents);
426 }
427 return $this->relativePath . $this->returnFileReference($targetFile);
428 }
429
430 /**
431 * Compress multiple javascript files
432 *
433 * @param array $jsFiles The files to compress (array key = filename), relative to requested page
434 * @return array The js files after compression (array key = new filename), relative to requested page
435 */
436 public function compressJsFiles(array $jsFiles)
437 {
438 $filesAfterCompression = array();
439 foreach ($jsFiles as $fileName => $fileOptions) {
440 // If compression is enabled
441 if ($fileOptions['compress']) {
442 $compressedFilename = $this->compressJsFile($fileOptions['file']);
443 $fileOptions['compress'] = false;
444 $fileOptions['file'] = $compressedFilename;
445 $filesAfterCompression[$compressedFilename] = $fileOptions;
446 } else {
447 $filesAfterCompression[$fileName] = $fileOptions;
448 }
449 }
450 return $filesAfterCompression;
451 }
452
453 /**
454 * Compresses a javascript file
455 *
456 * @param string $filename Source filename, relative to requested page
457 * @return string Filename of the compressed file, relative to requested page
458 */
459 public function compressJsFile($filename)
460 {
461 // generate the unique name of the file
462 $filenameAbsolute = GeneralUtility::resolveBackPath($this->rootPath . $this->getFilenameFromMainDir($filename));
463 if (@file_exists($filenameAbsolute)) {
464 $fileStatus = stat($filenameAbsolute);
465 $unique = $filenameAbsolute . $fileStatus['mtime'] . $fileStatus['size'];
466 } else {
467 $unique = $filenameAbsolute;
468 }
469 $pathinfo = PathUtility::pathinfo($filename);
470 $targetFile = $this->targetDirectory . $pathinfo['filename'] . '-' . md5($unique) . '.js';
471 // only create it, if it doesn't exist, yet
472 if (!file_exists((PATH_site . $targetFile)) || $this->createGzipped && !file_exists((PATH_site . $targetFile . '.gzip'))) {
473 $contents = GeneralUtility::getUrl($filenameAbsolute);
474 $this->writeFileAndCompressed($targetFile, $contents);
475 }
476 return $this->relativePath . $this->returnFileReference($targetFile);
477 }
478
479 /**
480 * Finds the relative path to a file, relative to the root path.
481 *
482 * @param string $filename the name of the file
483 * @return string the path to the file relative to the root path
484 */
485 protected function getFilenameFromMainDir($filename)
486 {
487 // if BACK_PATH is empty return $filename
488 if (empty($this->backPath)) {
489 return $filename;
490 }
491 // if the file exists in the root path, just return the $filename
492 if (strpos($filename, $this->backPath) === 0) {
493 $file = str_replace($this->backPath, '', $filename);
494 if (is_file(GeneralUtility::resolveBackPath($this->rootPath . $file))) {
495 return $file;
496 }
497 }
498 // if the file is from a special TYPO3 internal directory, add the missing typo3/ prefix
499 if (is_file(realpath(PATH_site . TYPO3_mainDir . $filename))) {
500 $filename = TYPO3_mainDir . $filename;
501 }
502 // build the file path relatively to the PATH_site
503 $backPath = str_replace(TYPO3_mainDir, '', $this->backPath);
504 $file = str_replace($backPath, '', $filename);
505 if (substr($file, 0, 3) === '../') {
506 $file = GeneralUtility::resolveBackPath(PATH_typo3 . $file);
507 } else {
508 $file = PATH_site . $file;
509 }
510 // check if the file exists, and if so, return the path relative to TYPO3_mainDir
511 if (is_file($file)) {
512 $mainDirDepth = substr_count(TYPO3_mainDir, '/');
513 return str_repeat('../', $mainDirDepth) . str_replace(PATH_site, '', $file);
514 }
515 // none of above conditions were met, fallback to default behaviour
516 return substr($filename, strlen($this->backPath));
517 }
518
519 /**
520 * Decides whether a file comes from one of the baseDirectories
521 *
522 * @param string $filename Filename
523 * @param array $baseDirectories Base directories
524 * @return bool File belongs to a base directory or not
525 */
526 protected function checkBaseDirectory($filename, array $baseDirectories)
527 {
528 foreach ($baseDirectories as $baseDirectory) {
529 // check, if $filename starts with base directory
530 if (GeneralUtility::isFirstPartOfStr($filename, $baseDirectory)) {
531 return true;
532 }
533 }
534 return false;
535 }
536
537 /**
538 * Fixes the relative paths inside of url() references in CSS files
539 *
540 * @param string $contents Data to process
541 * @param string $oldDir Directory of the original file, relative to TYPO3_mainDir
542 * @return string Processed data
543 */
544 protected function cssFixRelativeUrlPaths($contents, $oldDir)
545 {
546 $mainDir = TYPO3_MODE === 'BE' ? TYPO3_mainDir : '';
547 $newDir = '../../' . $mainDir . $oldDir;
548 // Replace "url()" paths
549 if (stripos($contents, 'url') !== false) {
550 $regex = '/url(\\(\\s*["\']?(?!\\/)([^"\']+)["\']?\\s*\\))/iU';
551 $contents = $this->findAndReplaceUrlPathsByRegex($contents, $regex, $newDir, '(\'|\')');
552 }
553 // Replace "@import" paths
554 if (stripos($contents, '@import') !== false) {
555 $regex = '/@import\\s*(["\']?(?!\\/)([^"\']+)["\']?)/i';
556 $contents = $this->findAndReplaceUrlPathsByRegex($contents, $regex, $newDir, '"|"');
557 }
558 return $contents;
559 }
560
561 /**
562 * Finds and replaces all URLs by using a given regex
563 *
564 * @param string $contents Data to process
565 * @param string $regex Regex used to find URLs in content
566 * @param string $newDir Path to prepend to the original file
567 * @param string $wrap Wrap around replaced values
568 * @return string Processed data
569 */
570 protected function findAndReplaceUrlPathsByRegex($contents, $regex, $newDir, $wrap = '|')
571 {
572 $matches = array();
573 $replacements = array();
574 $wrap = explode('|', $wrap);
575 preg_match_all($regex, $contents, $matches);
576 foreach ($matches[2] as $matchCount => $match) {
577 // remove '," or white-spaces around
578 $match = trim($match, '\'" ');
579 // we must not rewrite paths containing ":" or "url(", e.g. data URIs (see RFC 2397)
580 if (strpos($match, ':') === false && !preg_match('/url\\s*\\(/i', $match)) {
581 $newPath = GeneralUtility::resolveBackPath($newDir . $match);
582 $replacements[$matches[1][$matchCount]] = $wrap[0] . $newPath . $wrap[1];
583 }
584 }
585 // replace URL paths in content
586 if (!empty($replacements)) {
587 $contents = str_replace(array_keys($replacements), array_values($replacements), $contents);
588 }
589 return $contents;
590 }
591
592 /**
593 * Moves @charset, @import and @namespace statements to the top of
594 * the content, because they must occur before all other CSS rules
595 *
596 * @param string $contents Data to process
597 * @return string Processed data
598 */
599 protected function cssFixStatements($contents)
600 {
601 $matches = array();
602 $comment = LF . '/* moved by compressor */' . LF;
603 // nothing to do, so just return contents
604 if (stripos($contents, '@charset') === false && stripos($contents, '@import') === false && stripos($contents, '@namespace') === false) {
605 return $contents;
606 }
607 $regex = '/@(charset|import|namespace)\\s*(url)?\\s*\\(?\\s*["\']?[^"\'\\)]+["\']?\\s*\\)?\\s*;/i';
608 preg_match_all($regex, $contents, $matches);
609 if (!empty($matches[0])) {
610 // remove existing statements
611 $contents = str_replace($matches[0], '', $contents);
612 // add statements to the top of contents in the order they occur in original file
613 $contents = $comment . implode($comment, $matches[0]) . LF . trim($contents);
614 }
615 return $contents;
616 }
617
618 /**
619 * Writes $contents into file $filename together with a gzipped version into $filename.gz
620 *
621 * @param string $filename Target filename
622 * @param string $contents File contents
623 * @return void
624 */
625 protected function writeFileAndCompressed($filename, $contents)
626 {
627 // write uncompressed file
628 GeneralUtility::writeFile(PATH_site . $filename, $contents);
629 if ($this->createGzipped) {
630 // create compressed version
631 GeneralUtility::writeFile(PATH_site . $filename . '.gzip', gzencode($contents, $this->gzipCompressionLevel));
632 }
633 }
634
635 /**
636 * Decides whether a client can deal with gzipped content or not and returns the according file name,
637 * based on HTTP_ACCEPT_ENCODING
638 *
639 * @param string $filename File name
640 * @return string $filename suffixed with '.gzip' or not - dependent on HTTP_ACCEPT_ENCODING
641 */
642 protected function returnFileReference($filename)
643 {
644 // if the client accepts gzip and we can create gzipped files, we give him compressed versions
645 if ($this->createGzipped && strpos(GeneralUtility::getIndpEnv('HTTP_ACCEPT_ENCODING'), 'gzip') !== false) {
646 return $filename . '.gzip';
647 } else {
648 return $filename;
649 }
650 }
651
652 /**
653 * Retrieves an external file and stores it locally.
654 *
655 * @param string $url
656 * @return string Temporary local filename for the externally-retrieved file
657 */
658 protected function retrieveExternalFile($url)
659 {
660 $externalContent = GeneralUtility::getUrl($url);
661 $filename = $this->targetDirectory . 'external-' . md5($url);
662 // write only if file does not exist and md5 of the content is not the same as fetched one
663 if (!file_exists(PATH_site . $filename)
664 && (md5($externalContent) !== md5(GeneralUtility::getUrl(PATH_site . $filename)))
665 ) {
666 GeneralUtility::writeFile(PATH_site . $filename, $externalContent);
667 }
668 return $filename;
669 }
670
671 /**
672 * Compress a CSS string by removing comments and whitespace characters
673 *
674 * @param string $contents
675 * @return string
676 */
677 protected function compressCssString($contents)
678 {
679 // Remove multiple charset declarations for standards compliance (and fixing Safari problems).
680 $contents = preg_replace('/^@charset\s+[\'"](\S*?)\b[\'"];/i', '', $contents);
681 // Perform some safe CSS optimizations.
682 // Regexp to match comment blocks.
683 $comment = '/\*[^*]*\*+(?:[^/*][^*]*\*+)*/';
684 // Regexp to match double quoted strings.
685 $double_quot = '"[^"\\\\]*(?:\\\\.[^"\\\\]*)*"';
686 // Regexp to match single quoted strings.
687 $single_quot = "'[^'\\\\]*(?:\\\\.[^'\\\\]*)*'";
688 // Strip all comment blocks, but keep double/single quoted strings.
689 $contents = preg_replace(
690 "<($double_quot|$single_quot)|$comment>Ss",
691 '$1',
692 $contents
693 );
694 // Remove certain whitespace.
695 // There are different conditions for removing leading and trailing
696 // whitespace.
697 // @see http://php.net/manual/regexp.reference.subpatterns.php
698 $contents = preg_replace('<
699 # Strip leading and trailing whitespace.
700 \s*([@{};,])\s*
701 # Strip only leading whitespace from:
702 # - Closing parenthesis: Retain "@media (bar) and foo".
703 | \s+([\)])
704 # Strip only trailing whitespace from:
705 # - Opening parenthesis: Retain "@media (bar) and foo".
706 # - Colon: Retain :pseudo-selectors.
707 | ([\(:])\s+
708 >xS',
709 // Only one of the three capturing groups will match, so its reference
710 // will contain the wanted value and the references for the
711 // two non-matching groups will be replaced with empty strings.
712 '$1$2$3',
713 $contents
714 );
715 // End the file with a new line.
716 $contents = trim($contents);
717 // Ensure file ends in newline.
718 $contents .= LF;
719 return $contents;
720 }
721 }