[TASK] Use file_get_contents instead of getUrl()
[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 = array())
112 {
113 $filesToIncludeByType = array('all' => array());
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'], array($this->targetDirectory))
125 )
126 ) {
127 $type = isset($fileOptions['media']) ? strtolower($fileOptions['media']) : 'all';
128 if (!isset($filesToIncludeByType[$type])) {
129 $filesToIncludeByType[$type] = array();
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 = array(
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, array($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 = array();
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']] = array();
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 = array(
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(array($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 = array();
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 = array();
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, 3) === '../') {
433 $file = GeneralUtility::resolveBackPath(PATH_typo3 . $filename);
434 } else {
435 $file = PATH_site . ltrim($filename, '/');
436 }
437
438 // check if the file exists, and if so, return the path relative to TYPO3_mainDir
439 if (is_file($file)) {
440 $mainDirDepth = substr_count(TYPO3_mainDir, '/');
441 return str_repeat('../', $mainDirDepth) . str_replace(PATH_site, '', $file);
442 }
443 // none of above conditions were met, fallback to default behaviour
444 return $filename;
445 }
446
447 /**
448 * Decides whether a file comes from one of the baseDirectories
449 *
450 * @param string $filename Filename
451 * @param array $baseDirectories Base directories
452 * @return bool File belongs to a base directory or not
453 */
454 protected function checkBaseDirectory($filename, array $baseDirectories)
455 {
456 foreach ($baseDirectories as $baseDirectory) {
457 // check, if $filename starts with base directory
458 if (GeneralUtility::isFirstPartOfStr($filename, $baseDirectory)) {
459 return true;
460 }
461 }
462 return false;
463 }
464
465 /**
466 * Fixes the relative paths inside of url() references in CSS files
467 *
468 * @param string $contents Data to process
469 * @param string $oldDir Directory of the original file, relative to TYPO3_mainDir
470 * @return string Processed data
471 */
472 protected function cssFixRelativeUrlPaths($contents, $oldDir)
473 {
474 $newDir = '../../../' . $oldDir;
475 // Replace "url()" paths
476 if (stripos($contents, 'url') !== false) {
477 $regex = '/url(\\(\\s*["\']?(?!\\/)([^"\']+)["\']?\\s*\\))/iU';
478 $contents = $this->findAndReplaceUrlPathsByRegex($contents, $regex, $newDir, '(\'|\')');
479 }
480 // Replace "@import" paths
481 if (stripos($contents, '@import') !== false) {
482 $regex = '/@import\\s*(["\']?(?!\\/)([^"\']+)["\']?)/i';
483 $contents = $this->findAndReplaceUrlPathsByRegex($contents, $regex, $newDir, '"|"');
484 }
485 return $contents;
486 }
487
488 /**
489 * Finds and replaces all URLs by using a given regex
490 *
491 * @param string $contents Data to process
492 * @param string $regex Regex used to find URLs in content
493 * @param string $newDir Path to prepend to the original file
494 * @param string $wrap Wrap around replaced values
495 * @return string Processed data
496 */
497 protected function findAndReplaceUrlPathsByRegex($contents, $regex, $newDir, $wrap = '|')
498 {
499 $matches = array();
500 $replacements = array();
501 $wrap = explode('|', $wrap);
502 preg_match_all($regex, $contents, $matches);
503 foreach ($matches[2] as $matchCount => $match) {
504 // remove '," or white-spaces around
505 $match = trim($match, '\'" ');
506 // we must not rewrite paths containing ":" or "url(", e.g. data URIs (see RFC 2397)
507 if (strpos($match, ':') === false && !preg_match('/url\\s*\\(/i', $match)) {
508 $newPath = GeneralUtility::resolveBackPath($newDir . $match);
509 $replacements[$matches[1][$matchCount]] = $wrap[0] . $newPath . $wrap[1];
510 }
511 }
512 // replace URL paths in content
513 if (!empty($replacements)) {
514 $contents = str_replace(array_keys($replacements), array_values($replacements), $contents);
515 }
516 return $contents;
517 }
518
519 /**
520 * Moves @charset, @import and @namespace statements to the top of
521 * the content, because they must occur before all other CSS rules
522 *
523 * @param string $contents Data to process
524 * @return string Processed data
525 */
526 protected function cssFixStatements($contents)
527 {
528 $matches = array();
529 $comment = LF . '/* moved by compressor */' . LF;
530 // nothing to do, so just return contents
531 if (stripos($contents, '@charset') === false && stripos($contents, '@import') === false && stripos($contents, '@namespace') === false) {
532 return $contents;
533 }
534 $regex = '/@(charset|import|namespace)\\s*(url)?\\s*\\(?\\s*["\']?[^"\'\\)]+["\']?\\s*\\)?\\s*;/i';
535 preg_match_all($regex, $contents, $matches);
536 if (!empty($matches[0])) {
537 // Ensure correct order of @charset, @namespace and @import
538 $charset = '';
539 $namespaces = [];
540 $imports = [];
541 foreach ($matches[1] as $index => $keyword) {
542 switch ($keyword) {
543 case 'charset':
544 if (empty($charset)) {
545 $charset = $matches[0][$index];
546 }
547 break;
548 case 'namespace':
549 $namespaces[] = $matches[0][$index];
550 break;
551 case 'import':
552 $imports[] = $matches[0][$index];
553 break;
554 }
555 }
556
557 $namespaces = !empty($namespaces) ? implode('', $namespaces) . $comment : '';
558 $imports = !empty($imports) ? implode('', $imports) . $comment : '';
559 // remove existing statements
560 $contents = str_replace($matches[0], '', $contents);
561 // add statements to the top of contents in the order they occur in original file
562 $contents =
563 $charset
564 . $comment
565 . $namespaces
566 . $imports
567 . trim($contents);
568 }
569 return $contents;
570 }
571
572 /**
573 * Writes $contents into file $filename together with a gzipped version into $filename.gz
574 *
575 * @param string $filename Target filename
576 * @param string $contents File contents
577 * @return void
578 */
579 protected function writeFileAndCompressed($filename, $contents)
580 {
581 // write uncompressed file
582 GeneralUtility::writeFile(PATH_site . $filename, $contents);
583 if ($this->createGzipped) {
584 // create compressed version
585 GeneralUtility::writeFile(PATH_site . $filename . '.gzip', gzencode($contents, $this->gzipCompressionLevel));
586 }
587 }
588
589 /**
590 * Decides whether a client can deal with gzipped content or not and returns the according file name,
591 * based on HTTP_ACCEPT_ENCODING
592 *
593 * @param string $filename File name
594 * @return string $filename suffixed with '.gzip' or not - dependent on HTTP_ACCEPT_ENCODING
595 */
596 protected function returnFileReference($filename)
597 {
598 // if the client accepts gzip and we can create gzipped files, we give him compressed versions
599 if ($this->createGzipped && strpos(GeneralUtility::getIndpEnv('HTTP_ACCEPT_ENCODING'), 'gzip') !== false) {
600 $filename .= '.gzip';
601 }
602 return PathUtility::getRelativePath($this->rootPath, PATH_site) . $filename;
603 }
604
605 /**
606 * Retrieves an external file and stores it locally.
607 *
608 * @param string $url
609 * @return string Temporary local filename for the externally-retrieved file
610 */
611 protected function retrieveExternalFile($url)
612 {
613 $externalContent = GeneralUtility::getUrl($url);
614 $filename = $this->targetDirectory . 'external-' . md5($url);
615 // write only if file does not exist and md5 of the content is not the same as fetched one
616 if (!file_exists(PATH_site . $filename)
617 && (md5($externalContent) !== md5(file_get_contents(PATH_site . $filename)))
618 ) {
619 GeneralUtility::writeFile(PATH_site . $filename, $externalContent);
620 }
621 return $filename;
622 }
623
624 /**
625 * Compress a CSS string by removing comments and whitespace characters
626 *
627 * @param string $contents
628 * @return string
629 */
630 protected function compressCssString($contents)
631 {
632 // Perform some safe CSS optimizations.
633 // Regexp to match comment blocks.
634 $comment = '/\*[^*]*\*+(?:[^/*][^*]*\*+)*/';
635 // Regexp to match double quoted strings.
636 $double_quot = '"[^"\\\\]*(?:\\\\.[^"\\\\]*)*"';
637 // Regexp to match single quoted strings.
638 $single_quot = "'[^'\\\\]*(?:\\\\.[^'\\\\]*)*'";
639 // Strip all comment blocks, but keep double/single quoted strings.
640 $contents = preg_replace(
641 "<($double_quot|$single_quot)|$comment>Ss",
642 '$1',
643 $contents
644 );
645 // Remove certain whitespace.
646 // There are different conditions for removing leading and trailing
647 // whitespace.
648 // @see http://php.net/manual/regexp.reference.subpatterns.php
649 $contents = preg_replace('<
650 # Strip leading and trailing whitespace.
651 \s*([@{};,])\s*
652 # Strip only leading whitespace from:
653 # - Closing parenthesis: Retain "@media (bar) and foo".
654 | \s+([\)])
655 # Strip only trailing whitespace from:
656 # - Opening parenthesis: Retain "@media (bar) and foo".
657 # - Colon: Retain :pseudo-selectors.
658 | ([\(:])\s+
659 >xS',
660 // Only one of the three capturing groups will match, so its reference
661 // will contain the wanted value and the references for the
662 // two non-matching groups will be replaced with empty strings.
663 '$1$2$3',
664 $contents
665 );
666 // End the file with a new line.
667 $contents = trim($contents);
668 // Ensure file ends in newline.
669 $contents .= LF;
670 return $contents;
671 }
672 }