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