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