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