2 namespace TYPO3\CMS\Core\
Resource;
5 * This file is part of the TYPO3 CMS project.
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.
11 * For the full copyright and license information, please read the
12 * LICENSE.txt file that was distributed with this source code.
14 * The TYPO3 project - inspiring people to share!
17 use TYPO3\CMS\Core\Utility\GeneralUtility
;
18 use TYPO3\CMS\Core\Utility\MathUtility
;
19 use TYPO3\CMS\Core\Utility\PathUtility
;
23 * This merges and compresses CSS and JavaScript files of the TYPO3 Backend.
25 class ResourceCompressor
30 protected $targetDirectory = 'typo3temp/compressor/';
35 protected $relativePath = '';
40 protected $rootPath = '';
45 protected $backPath = '';
48 * gzipped versions are only created if $TYPO3_CONF_VARS[TYPO3_MODE]['compressionLevel'] is set
52 protected $createGzipped = false;
57 protected $gzipCompressionLevel = -1;
59 protected $htaccessTemplate = '<FilesMatch "\\.(js|css)(\\.gzip)?$">
60 <IfModule mod_expires.c>
62 ExpiresDefault "access plus 7 days"
70 public function __construct()
72 // we check for existence of our targetDirectory
73 if (!is_dir(PATH_site
. $this->targetDirectory
)) {
74 GeneralUtility
::mkdir(PATH_site
. $this->targetDirectory
);
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
);
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;
94 $this->setInitialPaths();
98 * Sets initial values for paths.
102 public function setInitialPaths()
104 $this->setInitialRelativePath();
105 $this->setInitialRootPath();
109 * Sets absolute path to working directory
113 protected function setInitialRootPath()
115 $rootPath = TYPO3_MODE
=== 'BE' ? PATH_typo3
: PATH_site
;
116 $this->setRootPath($rootPath);
120 * Sets relative path to PATH_site
124 protected function setInitialRelativePath()
126 $relativePath = TYPO3_MODE
=== 'BE' ?
'../' : '';
127 $this->setRelativePath($relativePath);
131 * Sets relative path to PATH_site
133 * @param string $relativePath Relative path to site root
136 public function setRelativePath($relativePath)
138 if (is_string($relativePath)) {
139 $this->relativePath
= $relativePath;
144 * Sets absolute path to working directory
146 * @param string $rootPath Absolute path
149 public function setRootPath($rootPath)
151 if (is_string($rootPath)) {
152 $this->rootPath
= $rootPath;
157 * Sets relative back path
159 * @param string $backPath Back path
162 public function setBackPath($backPath)
164 if (is_string($backPath)) {
165 $this->backPath
= $backPath;
170 * Concatenates the Stylesheet files
173 * baseDirectories If set, only include files below one of the base directories
175 * @param array $cssFiles CSS files to process
176 * @param array $options Additional options
177 * @return array CSS files
179 public function concatenateCssFiles(array $cssFiles, array $options = array())
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'])) {
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
191 !isset($options['baseDirectories'])
192 ||
$this->checkBaseDirectory(
193 $filenameFromMainDir, array_merge($options['baseDirectories'], array($this->targetDirectory
))
196 $type = isset($fileOptions['media']) ?
strtolower($fileOptions['media']) : 'all';
197 if (!isset($filesToIncludeByType[$type])) {
198 $filesToIncludeByType[$type] = array();
200 if ($fileOptions['forceOnTop']) {
201 array_unshift($filesToIncludeByType[$type], $filenameFromMainDir);
203 $filesToIncludeByType[$type][] = $filenameFromMainDir;
205 // remove the file from the incoming file array
206 unset($cssFiles[$key]);
209 if (!empty($filesToIncludeByType)) {
210 foreach ($filesToIncludeByType as $mediaOption => $filesToInclude) {
211 if (empty($filesToInclude)) {
214 $targetFile = $this->createMergedCssFile($filesToInclude);
215 $targetFileRelative = $this->relativePath
. $targetFile;
216 $concatenatedOptions = array(
217 'file' => $targetFileRelative,
218 'rel' => 'stylesheet',
219 'media' => $mediaOption,
221 'excludeFromConcatenation' => true,
222 'forceOnTop' => false,
225 // place the merged stylesheet on top of the stylesheets
226 $cssFiles = array_merge($cssFiles, array($targetFileRelative => $concatenatedOptions));
233 * Concatenates the JavaScript files
235 * @param array $jsFiles JavaScript files to process
236 * @return array JS files
238 public function concatenateJsFiles(array $jsFiles)
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'])) {
246 if (!isset($filesToInclude[$fileOptions['section']])) {
247 $filesToInclude[$fileOptions['section']] = array();
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);
254 $filesToInclude[$fileOptions['section']][] = $filenameFromMainDir;
256 // remove the file from the incoming file array
257 unset($jsFiles[$key]);
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,
268 'excludeFromConcatenation' => true,
269 'forceOnTop' => false,
272 // place the merged javascript on top of the JS files
273 $jsFiles = array_merge(array($targetFileRelative => $concatenatedOptions), $jsFiles);
280 * Creates a merged CSS file
282 * @param array $filesToInclude Files which should be merged, paths relative to root path
283 * @return mixed Filename of the merged file
285 protected function createMergedCssFile(array $filesToInclude)
287 return $this->createMergedFile($filesToInclude, 'css');
291 * Creates a merged JS file
293 * @param array $filesToInclude Files which should be merged, paths relative to root path
294 * @return mixed Filename of the merged file
296 protected function createMergedJsFile(array $filesToInclude)
298 return $this->createMergedFile($filesToInclude, 'js');
302 * Creates a merged file with given file type
304 * @param array $filesToInclude Files which should be merged, paths relative to root path
305 * @param string $type File type
307 * @throws \InvalidArgumentException
308 * @return mixed Filename of the merged file
310 protected function createMergedFile(array $filesToInclude, $type = 'css')
313 $type = strtolower(trim($type, '. '));
315 throw new \
InvalidArgumentException('No valid file type given for files to be merged.', 1308957498);
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
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(
326 GeneralUtility
::getIndpEnv('TYPO3_SITE_URL')
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;
334 $filesToInclude[$key] = $this->retrieveExternalFile($filename);
337 $filesToInclude[$key] = $this->retrieveExternalFile($filename);
339 $filename = $filesToInclude[$key];
341 $filenameAbsolute = GeneralUtility
::resolveBackPath($this->rootPath
. $filename);
342 if (@file_exists
($filenameAbsolute)) {
343 $fileStatus = stat($filenameAbsolute);
344 $unique .= $filenameAbsolute . $fileStatus['mtime'] . $fileStatus['size'];
346 $unique .= $filenameAbsolute;
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))) {
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) . '/');
360 $concatenated .= LF
. $contents;
362 // move @charset, @import and @namespace statements to top of new file
363 if ($type === 'css') {
364 $concatenated = $this->cssFixStatements($concatenated);
366 GeneralUtility
::writeFile(PATH_site
. $targetFile, $concatenated);
372 * Compress multiple css files
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
377 public function compressCssFiles(array $cssFiles)
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;
388 $filesAfterCompression[$key] = $fileOptions;
391 return $filesAfterCompression;
395 * Compresses a CSS file
398 * baseDirectories If set, only include files below one of the base directories
400 * removes comments and whitespaces
401 * Adopted from https://github.com/drupal/drupal/blob/8.0.x/core/lib/Drupal/Core/Asset/CssOptimizer.php
403 * @param string $filename Source filename, relative to requested page
404 * @return string Compressed filename, relative to requested page
406 public function compressCssFile($filename)
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'];
414 $unique = $filenameAbsolute;
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) . '/');
425 $this->writeFileAndCompressed($targetFile, $contents);
427 return $this->relativePath
. $this->returnFileReference($targetFile);
431 * Callback function for preg_replace
433 * @see compressCssFile
434 * @param array $matches
435 * @return string the compressed string
436 * @deprecated since TYPO3 CMS 7, will be removed in TYPO3 CMS 8, not in use anymore
438 public static function compressCssPregCallback($matches)
440 GeneralUtility
::logDeprecatedFunction();
442 // Group 1: Double quoted string.
444 } elseif ($matches[2]) {
445 // Group 2: Single quoted string.
447 } elseif ($matches[3]) {
448 // Group 3: Regular non-MacIE5-hack comment.
451 } elseif ($matches[4]) {
452 // Group 4: MacIE5-hack-type-1 comment.
456 } elseif ($matches[5]) {
457 // Group 5,6,7: MacIE5-hack-type-2 comment
458 $matches[6] = preg_replace('/\\s++([+>{};,)])/S', '$1', $matches[6]);
459 // Clean pre-punctuation.
460 $matches[6] = preg_replace('/([+>{}:;,(])\\s++/S', '$1', $matches[6]);
461 // Clean post-punctuation.
462 $matches[6] = preg_replace('/;?\\}/S', '}
464 // Add a touch of formatting.
466 /*T2\\*/' . $matches[6] . '
469 } elseif ($matches[8]) {
470 // Group 8: calc function (see http://www.w3.org/TR/2006/WD-css3-values-20060919/#calc)
471 return 'calc' . $matches[8];
472 } elseif (isset($matches[9])) {
473 // Group 9: Non-string, non-comment. Safe to clean whitespace here.
474 $matches[9] = preg_replace('/^\\s++/', '', $matches[9]);
475 // Strip all leading whitespace.
476 $matches[9] = preg_replace('/\\s++$/', '', $matches[9]);
477 // Strip all trailing whitespace.
478 $matches[9] = preg_replace('/\\s{2,}+/', ' ', $matches[9]);
479 // Consolidate multiple whitespace.
480 $matches[9] = preg_replace('/\\s++([+>{};,)])/S', '$1', $matches[9]);
481 // Clean pre-punctuation.
482 $matches[9] = preg_replace('/([+>{}:;,(])\\s++/S', '$1', $matches[9]);
483 // Clean post-punctuation.
484 $matches[9] = preg_replace('/;?\\}/S', '}
486 // Add a touch of formatting.
489 return $matches[0] . '
490 /* ERROR! Unexpected _proccess_css_minify() parameter */
495 * Compress multiple javascript files
497 * @param array $jsFiles The files to compress (array key = filename), relative to requested page
498 * @return array The js files after compression (array key = new filename), relative to requested page
500 public function compressJsFiles(array $jsFiles)
502 $filesAfterCompression = array();
503 foreach ($jsFiles as $fileName => $fileOptions) {
504 // If compression is enabled
505 if ($fileOptions['compress']) {
506 $compressedFilename = $this->compressJsFile($fileOptions['file']);
507 $fileOptions['compress'] = false;
508 $fileOptions['file'] = $compressedFilename;
509 $filesAfterCompression[$compressedFilename] = $fileOptions;
511 $filesAfterCompression[$fileName] = $fileOptions;
514 return $filesAfterCompression;
518 * Compresses a javascript file
520 * @param string $filename Source filename, relative to requested page
521 * @return string Filename of the compressed file, relative to requested page
523 public function compressJsFile($filename)
525 // generate the unique name of the file
526 $filenameAbsolute = GeneralUtility
::resolveBackPath($this->rootPath
. $this->getFilenameFromMainDir($filename));
527 if (@file_exists
($filenameAbsolute)) {
528 $fileStatus = stat($filenameAbsolute);
529 $unique = $filenameAbsolute . $fileStatus['mtime'] . $fileStatus['size'];
531 $unique = $filenameAbsolute;
533 $pathinfo = PathUtility
::pathinfo($filename);
534 $targetFile = $this->targetDirectory
. $pathinfo['filename'] . '-' . md5($unique) . '.js';
535 // only create it, if it doesn't exist, yet
536 if (!file_exists((PATH_site
. $targetFile)) ||
$this->createGzipped
&& !file_exists((PATH_site
. $targetFile . '.gzip'))) {
537 $contents = GeneralUtility
::getUrl($filenameAbsolute);
538 $this->writeFileAndCompressed($targetFile, $contents);
540 return $this->relativePath
. $this->returnFileReference($targetFile);
544 * Finds the relative path to a file, relative to the root path.
546 * @param string $filename the name of the file
547 * @return string the path to the file relative to the root path
549 protected function getFilenameFromMainDir($filename)
551 // if BACK_PATH is empty return $filename
552 if (empty($this->backPath
)) {
555 // if the file exists in the root path, just return the $filename
556 if (strpos($filename, $this->backPath
) === 0) {
557 $file = str_replace($this->backPath
, '', $filename);
558 if (is_file(GeneralUtility
::resolveBackPath($this->rootPath
. $file))) {
562 // if the file is from a special TYPO3 internal directory, add the missing typo3/ prefix
563 if (is_file(realpath(PATH_site
. TYPO3_mainDir
. $filename))) {
564 $filename = TYPO3_mainDir
. $filename;
566 // build the file path relatively to the PATH_site
567 $backPath = str_replace(TYPO3_mainDir
, '', $this->backPath
);
568 $file = str_replace($backPath, '', $filename);
569 if (substr($file, 0, 3) === '../') {
570 $file = GeneralUtility
::resolveBackPath(PATH_typo3
. $file);
572 $file = PATH_site
. $file;
574 // check if the file exists, and if so, return the path relative to TYPO3_mainDir
575 if (is_file($file)) {
576 $mainDirDepth = substr_count(TYPO3_mainDir
, '/');
577 return str_repeat('../', $mainDirDepth) . str_replace(PATH_site
, '', $file);
579 // none of above conditions were met, fallback to default behaviour
580 return substr($filename, strlen($this->backPath
));
584 * Decides whether a file comes from one of the baseDirectories
586 * @param string $filename Filename
587 * @param array $baseDirectories Base directories
588 * @return bool File belongs to a base directory or not
590 protected function checkBaseDirectory($filename, array $baseDirectories)
592 foreach ($baseDirectories as $baseDirectory) {
593 // check, if $filename starts with base directory
594 if (GeneralUtility
::isFirstPartOfStr($filename, $baseDirectory)) {
602 * Fixes the relative paths inside of url() references in CSS files
604 * @param string $contents Data to process
605 * @param string $oldDir Directory of the original file, relative to TYPO3_mainDir
606 * @return string Processed data
608 protected function cssFixRelativeUrlPaths($contents, $oldDir)
610 $mainDir = TYPO3_MODE
=== 'BE' ? TYPO3_mainDir
: '';
611 $newDir = '../../' . $mainDir . $oldDir;
612 // Replace "url()" paths
613 if (stripos($contents, 'url') !== false) {
614 $regex = '/url(\\(\\s*["\']?(?!\\/)([^"\']+)["\']?\\s*\\))/iU';
615 $contents = $this->findAndReplaceUrlPathsByRegex($contents, $regex, $newDir, '(\'|\')');
617 // Replace "@import" paths
618 if (stripos($contents, '@import') !== false) {
619 $regex = '/@import\\s*(["\']?(?!\\/)([^"\']+)["\']?)/i';
620 $contents = $this->findAndReplaceUrlPathsByRegex($contents, $regex, $newDir, '"|"');
626 * Finds and replaces all URLs by using a given regex
628 * @param string $contents Data to process
629 * @param string $regex Regex used to find URLs in content
630 * @param string $newDir Path to prepend to the original file
631 * @param string $wrap Wrap around replaced values
632 * @return string Processed data
634 protected function findAndReplaceUrlPathsByRegex($contents, $regex, $newDir, $wrap = '|')
637 $replacements = array();
638 $wrap = explode('|', $wrap);
639 preg_match_all($regex, $contents, $matches);
640 foreach ($matches[2] as $matchCount => $match) {
641 // remove '," or white-spaces around
642 $match = trim($match, '\'" ');
643 // we must not rewrite paths containing ":" or "url(", e.g. data URIs (see RFC 2397)
644 if (strpos($match, ':') === false && !preg_match('/url\\s*\\(/i', $match)) {
645 $newPath = GeneralUtility
::resolveBackPath($newDir . $match);
646 $replacements[$matches[1][$matchCount]] = $wrap[0] . $newPath . $wrap[1];
649 // replace URL paths in content
650 if (!empty($replacements)) {
651 $contents = str_replace(array_keys($replacements), array_values($replacements), $contents);
657 * Moves @charset, @import and @namespace statements to the top of
658 * the content, because they must occur before all other CSS rules
660 * @param string $contents Data to process
661 * @return string Processed data
663 protected function cssFixStatements($contents)
666 $comment = LF
. '/* moved by compressor */' . LF
;
667 // nothing to do, so just return contents
668 if (stripos($contents, '@charset') === false && stripos($contents, '@import') === false && stripos($contents, '@namespace') === false) {
671 $regex = '/@(charset|import|namespace)\\s*(url)?\\s*\\(?\\s*["\']?[^"\'\\)]+["\']?\\s*\\)?\\s*;/i';
672 preg_match_all($regex, $contents, $matches);
673 if (!empty($matches[0])) {
674 // remove existing statements
675 $contents = str_replace($matches[0], '', $contents);
676 // add statements to the top of contents in the order they occur in original file
677 $contents = $comment . implode($comment, $matches[0]) . LF
. trim($contents);
683 * Writes $contents into file $filename together with a gzipped version into $filename.gz
685 * @param string $filename Target filename
686 * @param string $contents File contents
689 protected function writeFileAndCompressed($filename, $contents)
691 // write uncompressed file
692 GeneralUtility
::writeFile(PATH_site
. $filename, $contents);
693 if ($this->createGzipped
) {
694 // create compressed version
695 GeneralUtility
::writeFile(PATH_site
. $filename . '.gzip', gzencode($contents, $this->gzipCompressionLevel
));
700 * Decides whether a client can deal with gzipped content or not and returns the according file name,
701 * based on HTTP_ACCEPT_ENCODING
703 * @param string $filename File name
704 * @return string $filename suffixed with '.gzip' or not - dependent on HTTP_ACCEPT_ENCODING
706 protected function returnFileReference($filename)
708 // if the client accepts gzip and we can create gzipped files, we give him compressed versions
709 if ($this->createGzipped
&& strpos(GeneralUtility
::getIndpEnv('HTTP_ACCEPT_ENCODING'), 'gzip') !== false) {
710 return $filename . '.gzip';
717 * Retrieves an external file and stores it locally.
720 * @return string Temporary local filename for the externally-retrieved file
722 protected function retrieveExternalFile($url)
724 $externalContent = GeneralUtility
::getUrl($url);
725 $filename = $this->targetDirectory
. 'external-' . md5($url);
726 // write only if file does not exist and md5 of the content is not the same as fetched one
727 if (!file_exists(PATH_site
. $filename)
728 && (md5($externalContent) !== md5(GeneralUtility
::getUrl(PATH_site
. $filename)))
730 GeneralUtility
::writeFile(PATH_site
. $filename, $externalContent);
736 * Compress a CSS string by removing comments and whitespace characters
738 * @param string $contents
741 protected function compressCssString($contents)
743 // Remove multiple charset declarations for standards compliance (and fixing Safari problems).
744 $contents = preg_replace('/^@charset\s+[\'"](\S*?)\b[\'"];/i', '', $contents);
745 // Perform some safe CSS optimizations.
746 // Regexp to match comment blocks.
747 $comment = '/\*[^*]*\*+(?:[^/*][^*]*\*+)*/';
748 // Regexp to match double quoted strings.
749 $double_quot = '"[^"\\\\]*(?:\\\\.[^"\\\\]*)*"';
750 // Regexp to match single quoted strings.
751 $single_quot = "'[^'\\\\]*(?:\\\\.[^'\\\\]*)*'";
752 // Strip all comment blocks, but keep double/single quoted strings.
753 $contents = preg_replace(
754 "<($double_quot|$single_quot)|$comment>Ss",
758 // Remove certain whitespace.
759 // There are different conditions for removing leading and trailing
761 // @see http://php.net/manual/regexp.reference.subpatterns.php
762 $contents = preg_replace('<
763 # Strip leading and trailing whitespace.
765 # Strip only leading whitespace from:
766 # - Closing parenthesis: Retain "@media (bar) and foo".
768 # Strip only trailing whitespace from:
769 # - Opening parenthesis: Retain "@media (bar) and foo".
770 # - Colon: Retain :pseudo-selectors.
773 // Only one of the three capturing groups will match, so its reference
774 // will contain the wanted value and the references for the
775 // two non-matching groups will be replaced with empty strings.
779 // End the file with a new line.
780 $contents = trim($contents);
781 // Ensure file ends in newline.