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