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