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