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