[TASK] Move XLIFF handling to t3lib
[Packages/TYPO3.CMS.git] / t3lib / class.t3lib_compressor.php
1 <?php
2 /***************************************************************
3 * Copyright notice
4 *
5 * (c) 2010-2011 Steffen Gebert (steffen@steffen-gebert.de)
6 * All rights reserved
7 *
8 * This script is part of the TYPO3 project. The TYPO3 project is
9 * free software; you can redistribute it and/or modify
10 * it under the terms of the GNU General Public License as published by
11 * the Free Software Foundation; either version 2 of the License, or
12 * (at your option) any later version.
13 *
14 * The GNU General Public License can be found at
15 * http://www.gnu.org/copyleft/gpl.html.
16 * A copy is found in the textfile GPL.txt and important notices to the license
17 * from the author is found in LICENSE.txt distributed with these scripts.
18 *
19 *
20 * This script is distributed in the hope that it will be useful,
21 * but WITHOUT ANY WARRANTY; without even the implied warranty of
22 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
23 * GNU General Public License for more details.
24 *
25 * This copyright notice MUST APPEAR in all copies of the script!
26 ***************************************************************/
27
28 /**
29 * Compressor
30 * This merges and compresses CSS and JavaScript files of the TYPO3 Backend.
31 *
32 * @author Steffen Gebert <steffen@steffen-gebert.de>
33 * @package TYPO3
34 * @subpackage t3lib
35 */
36 class t3lib_Compressor {
37
38 protected $targetDirectory = 'typo3temp/compressor/';
39
40 // gzipped versions are only created if $TYPO3_CONF_VARS[TYPO3_MODE]['compressionLevel'] is set
41 protected $createGzipped = FALSE;
42 // default compression level is -1
43 protected $gzipCompressionLevel = -1;
44
45 protected $htaccessTemplate = '<FilesMatch "\.(js|css)(\.gzip)?$">
46 <IfModule mod_expires.c>
47 ExpiresActive on
48 ExpiresDefault "access plus 7 days"
49 </IfModule>
50 FileETag MTime Size
51 </FilesMatch>';
52
53 /**
54 * Constructor
55 */
56 public function __construct() {
57
58 // we check for existance of our targetDirectory
59 if (!is_dir(PATH_site . $this->targetDirectory)) {
60 t3lib_div::mkdir(PATH_site . $this->targetDirectory);
61 }
62
63 // if enabled, we check whether we should auto-create the .htaccess file
64 if ($GLOBALS['TYPO3_CONF_VARS']['SYS']['generateApacheHtaccess']) {
65 // check whether .htaccess exists
66 $htaccessPath = PATH_site . $this->targetDirectory . '.htaccess';
67 if (!file_exists($htaccessPath)) {
68 t3lib_div::writeFile($htaccessPath, $this->htaccessTemplate);
69 }
70 }
71
72 // decide whether we should create gzipped versions or not
73 $compressionLevel = $GLOBALS['TYPO3_CONF_VARS'][TYPO3_MODE]['compressionLevel'];
74 // we need zlib for gzencode()
75 if (extension_loaded('zlib') && $compressionLevel) {
76 $this->createGzipped = TRUE;
77 // $compressionLevel can also be TRUE
78 if (t3lib_div::testInt($compressionLevel)) {
79 $this->gzipCompressionLevel = intval($compressionLevel);
80 }
81 }
82 }
83
84 /**
85 * Concatenates the cssFiles
86 *
87 * Options:
88 * baseDirectories If set, only include files below one of the base directories
89 *
90 * @param array $cssFiles CSS files to process
91 * @param array $options Additional options
92 * @return array CSS files
93 */
94 public function concatenateCssFiles(array $cssFiles, $options = array()) {
95
96 $filesToInclude = array();
97 foreach ($cssFiles as $filename => $fileOptions) {
98 // we remove BACK_PATH from $filename, so make it relative to TYPO3_mainDir
99 $filenameFromMainDir = $this->getFilenameFromMainDir($filename);
100 // if $options['baseDirectories'] set, we only include files below these directories
101 if ((!isset($options['baseDirectories'])
102 || $this->checkBaseDirectory($filenameFromMainDir, array_merge($options['baseDirectories'], array($this->targetDirectory))))
103 && ($fileOptions['media'] === 'all')
104 ) {
105
106 $filesToInclude[] = $filenameFromMainDir;
107 // remove the file from the incoming file array
108 unset($cssFiles[$filename]);
109 }
110 }
111
112 if (count($filesToInclude)) {
113 $targetFile = $this->createMergedCssFile($filesToInclude);
114 $concatenatedOptions = array(
115 'rel' => 'stylesheet',
116 'media' => 'all',
117 'compress' => TRUE,
118 );
119 $targetFileRelative = $GLOBALS['BACK_PATH'] . '../' . $targetFile;
120 // place the merged stylesheet on top of the stylesheets
121 $cssFiles = array_merge(array($targetFileRelative => $concatenatedOptions), $cssFiles);
122 }
123 return $cssFiles;
124 }
125
126 /**
127 * Finds the relative path to a file, relative to the TYPO3_mainDir.
128 *
129 * @param string $filename the name of the file
130 * @return string the path to the file relative to the TYPO3_mainDir
131 */
132 private function getFilenameFromMainDir($filename) {
133 // if the file exists in the typo3/ folder or the BACK_PATH is empty, just return the $filename
134 if (substr($filename, 0, strlen($GLOBALS['BACK_PATH'])) === $GLOBALS['BACK_PATH']) {
135 $file = str_replace($GLOBALS['BACK_PATH'], '', $filename);
136 if (is_file(PATH_typo3 . $file) || empty($GLOBALS['BACK_PATH'])) {
137 return $file;
138 }
139 }
140
141 // build the file path relatively to the PATH_site
142 $backPath = str_replace(TYPO3_mainDir, '', $GLOBALS['BACK_PATH']);
143 $file = str_replace($backPath, '', $filename);
144 if (substr($file, 0, 3) === '../') {
145 $file = t3lib_div::resolveBackPath(PATH_typo3 . $file);
146 } else {
147 $file = PATH_site . $file;
148 }
149
150 // check if the file exists, and if so, return the path relative to TYPO3_mainDir
151 if (is_file($file)) {
152 $mainDirDepth = substr_count(TYPO3_mainDir, '/');
153 return str_repeat('../', $mainDirDepth) . str_replace(PATH_site, '', $file);
154 }
155
156 // none of above conditions were met, fallback to default behaviour
157 return substr($filename, strlen($GLOBALS['BACK_PATH']));
158 }
159
160 /**
161 * Creates a merged CSS file
162 *
163 * @param array $filesToInclude Files which should be merged, paths relative to TYPO3_mainDir
164 * @return mixed Filename of the merged file
165 */
166 protected function createMergedCssFile(array $filesToInclude) {
167 // we add up the filenames, filemtimes and filsizes to later build a checksum over
168 // it and include it in the temporary file name
169 $unique = '';
170
171 foreach ($filesToInclude as $filename) {
172 $filepath = t3lib_div::resolveBackPath(PATH_typo3 . $filename);
173 $unique .= $filename . filemtime($filepath) . filesize($filepath);
174 }
175 $targetFile = $this->targetDirectory . 'merged-' . md5($unique) . '.css';
176
177 // if the file doesn't already exist, we create it
178 if (!file_exists(PATH_site . $targetFile)) {
179 $concatenated = '';
180 // concatenate all the files together
181 foreach ($filesToInclude as $filename) {
182 $contents = t3lib_div::getUrl(t3lib_div::resolveBackPath(PATH_typo3 . $filename));
183 // only fix paths if files aren't already in typo3temp (already processed)
184 if (!t3lib_div::isFirstPartOfStr($filename, $this->targetDirectory)) {
185 $concatenated .= $this->cssFixRelativeUrlPaths($contents, dirname($filename) . '/');
186 } else {
187 $concatenated .= $contents;
188 }
189 }
190 t3lib_div::writeFile(PATH_site . $targetFile, $concatenated);
191 }
192 return $targetFile;
193 }
194
195 /**
196 * Compress multiple css files
197 *
198 * @param array $cssFiles The files to compress (array key = filename), relative to requested page
199 * @return array The CSS files after compression (array key = new filename), relative to requested page
200 */
201 public function compressCssFiles(array $cssFiles) {
202 $filesAfterCompression = array();
203 foreach ($cssFiles as $filename => $fileOptions) {
204 // if compression is enabled
205 if ($fileOptions['compress']) {
206 $filesAfterCompression[$this->compressCssFile($filename)] = $fileOptions;
207 } else {
208 $filesAfterCompression[$filename] = $fileOptions;
209 }
210 }
211 return $filesAfterCompression;
212 }
213
214 /**
215 * Compresses a CSS file
216 *
217 * Options:
218 * baseDirectories If set, only include files below one of the base directories
219 *
220 * removes comments and whitespaces
221 * Adopted from http://drupal.org/files/issues/minify_css.php__1.txt
222 *
223 * @param string $filename Source filename, relative to requested page
224 * @return string Compressed filename, relative to requested page
225 */
226 public function compressCssFile($filename) {
227 // generate the unique name of the file
228 $filenameAbsolute = t3lib_div::resolveBackPath(PATH_typo3 . $this->getFilenameFromMainDir($filename));
229 $unique = $filenameAbsolute . filemtime($filenameAbsolute) . filesize($filenameAbsolute);
230
231 $pathinfo = pathinfo($filename);
232 $targetFile = $this->targetDirectory . $pathinfo['filename'] . '-' . md5($unique) . '.css';
233 // only create it, if it doesn't exist, yet
234 if (!file_exists(PATH_site . $targetFile) || ($this->createGzipped && !file_exists(PATH_site . $targetFile . '.gzip'))) {
235 $contents = t3lib_div::getUrl($filenameAbsolute);
236 // Perform some safe CSS optimizations.
237 $contents = str_replace("\r", '', $contents); // Strip any and all carriage returns.
238 // Match and process strings, comments and everything else, one chunk at a time.
239 // To understand this regex, read: "Mastering Regular Expressions 3rd Edition" chapter 6.
240 $contents = preg_replace_callback('%
241 # One-regex-to-rule-them-all! - version: 20100220_0100
242 # Group 1: Match a double quoted string.
243 ("[^"\\\\]*+(?:\\\\.[^"\\\\]*+)*+") | # or...
244 # Group 2: Match a single quoted string.
245 (\'[^\'\\\\]*+(?:\\\\.[^\'\\\\]*+)*+\') | # or...
246 # Group 3: Match a regular non-MacIE5-hack comment.
247 (/\*[^\\\\*]*+\*++(?:[^\\\\*/][^\\\\*]*+\*++)*+/) | # or...
248 # Group 4: Match a MacIE5-type1 comment.
249 (/\*(?:[^*\\\\]*+\**+(?!/))*+\\\\[^*]*+\*++(?:[^*/][^*]*+\*++)*+/(?<!\\\\\*/)) | # or...
250 # Group 5: Match a MacIE5-type2 comment.
251 (/\*[^*]*\*+(?:[^/*][^*]*\*+)*/(?<=\\\\\*/)) # folllowed by...
252 # Group 6: Match everything up to final closing regular comment
253 ([^/]*+(?:(?!\*)/[^/]*+)*?)
254 # Group 7: Match final closing regular comment
255 (/\*[^/]++(?:(?<!\*)/(?!\*)[^/]*+)*+/(?<=(?<!\\\\)\*/)) | # or...
256 # Group 8: Match regular non-string, non-comment text.
257 ([^"\'/]*+(?:(?!/\*)/[^"\'/]*+)*+)
258 %Ssx', array('self', 'compressCssPregCallback'), $contents); // Do it!
259 $contents = preg_replace('/^\s++/', '', $contents); // Strip leading whitespace.
260 $contents = preg_replace('/[ \t]*+\n\s*+/S', "\n", $contents); // Consolidate multi-lines space.
261 $contents = preg_replace('/(?<!\s)\s*+$/S', "\n", $contents); // Ensure file ends in newline.
262 // we have to fix relative paths, if we aren't working on a file in our target directory
263 if (!is_int(strpos($filename, $this->targetDirectory))) {
264 $filenameRelativeToMainDir = substr($filename, strlen($GLOBALS['BACK_PATH']));
265 $contents = $this->cssFixRelativeUrlPaths($contents, dirname($filenameRelativeToMainDir) . '/');
266 }
267 $this->writeFileAndCompressed($targetFile, $contents);
268 }
269
270 return $GLOBALS['BACK_PATH'] . '../' . $this->returnFileReference($targetFile);
271 }
272
273 /**
274 * Callback function for preg_replace
275 *
276 * @see compressCssFile
277 * @param array $matches
278 * @return string the compressed string
279 */
280 public static function compressCssPregCallback($matches) {
281 if ($matches[1]) { // Group 1: Double quoted string.
282 return $matches[1]; // Return the string unmodified.
283 } elseif ($matches[2]) { // Group 2: Single quoted string.
284 return $matches[2]; // Return the string unmodified.
285 } elseif ($matches[3]) { // Group 3: Regular non-MacIE5-hack comment.
286 return "\n"; // Return single space.
287 } elseif ($matches[4]) { // Group 4: MacIE5-hack-type-1 comment.
288 return "\n/*\\T1*/\n"; // Return minimal MacIE5-hack-type-1 comment.
289 }
290 elseif ($matches[5]) { // Group 5,6,7: MacIE5-hack-type-2 comment
291 $matches[6] = preg_replace('/\s++([+>{};,)])/S', '$1', $matches[6]); // Clean pre-punctuation.
292 $matches[6] = preg_replace('/([+>{}:;,(])\s++/S', '$1', $matches[6]); // Clean post-punctuation.
293 $matches[6] = preg_replace('/;?\}/S', "}\n", $matches[6]); // Add a touch of formatting.
294 return "\n/*T2\\*/" . $matches[6] . "\n/*T2E*/\n"; // Minify and reassemble composite type2 comment.
295 } elseif (isset($matches[8])) { // Group 8: Non-string, non-comment. Safe to clean whitespace here.
296 $matches[8] = preg_replace('/^\s++/', '', $matches[8]); // Strip all leading whitespace.
297 $matches[8] = preg_replace('/\s++$/', '', $matches[8]); // Strip all trailing whitespace.
298 $matches[8] = preg_replace('/\s{2,}+/', ' ', $matches[8]); // Consolidate multiple whitespace.
299 $matches[8] = preg_replace('/\s++([+>{};,)])/S', '$1', $matches[8]); // Clean pre-punctuation.
300 $matches[8] = preg_replace('/([+>{}:;,(])\s++/S', '$1', $matches[8]); // Clean post-punctuation.
301 $matches[8] = preg_replace('/;?\}/S', "}\n", $matches[8]); // Add a touch of formatting.
302 return $matches[8];
303 }
304 return $matches[0] . "\n/* ERROR! Unexpected _proccess_css_minify() parameter */\n"; // never get here
305 }
306
307 /**
308 * Compress multiple javascript files
309 *
310 * @param array $jsFiles The files to compress (array key = filename), relative to requested page
311 * @return array The js files after compression (array key = new filename), relative to requested page
312 */
313 public function compressJsFiles(array $jsFiles) {
314 $filesAfterCompression = array();
315 foreach ($jsFiles as $filename => $fileOptions) {
316 // we remove BACK_PATH from $filename, so make it relative to TYPO3_mainDir
317 $filenameFromMainDir = $this->getFilenameFromMainDir($filename);
318 // if compression is enabled
319 if ($fileOptions['compress']) {
320 $filesAfterCompression[$this->compressJsFile($filename)] = $fileOptions;
321 } else {
322 $filesAfterCompression[$filename] = $fileOptions;
323 }
324 }
325 return $filesAfterCompression;
326 }
327
328 /**
329 * Compresses a javascript file
330 *
331 * Options:
332 * baseDirectories If set, only include files below one of the base directories
333 *
334 * @param string $filename Source filename, relative to requested page
335 * @return string Filename of the compressed file, relative to requested page
336 */
337 public function compressJsFile($filename) {
338 // generate the unique name of the file
339 $filenameAbsolute = t3lib_div::resolveBackPath(PATH_typo3 . $this->getFilenameFromMainDir($filename));
340 $unique = $filenameAbsolute . filemtime($filenameAbsolute) . filesize($filenameAbsolute);
341
342 $pathinfo = pathinfo($filename);
343 $targetFile = $this->targetDirectory . $pathinfo['filename'] . '-' . md5($unique) . '.js';
344 // only create it, if it doesn't exist, yet
345 if (!file_exists(PATH_site . $targetFile) || ($this->createGzipped && !file_exists(PATH_site . $targetFile . '.gzip'))) {
346 $contents = t3lib_div::getUrl($filenameAbsolute);
347 $this->writeFileAndCompressed($targetFile, $contents);
348 }
349 return $GLOBALS['BACK_PATH'] . '../' . $this->returnFileReference($targetFile);
350 }
351
352 /**
353 * Decides whether a CSS file comes from one of the baseDirectories
354 *
355 * @param string $filename Filename
356 * @return boolean File belongs to a skin or not
357 */
358 protected function checkBaseDirectory($filename, array $baseDirectories) {
359 foreach ($baseDirectories as $baseDirectory) {
360 // check, if $filename starts with $skinStylesheetDirectory
361 if (t3lib_div::isFirstPartOfStr($filename, $baseDirectory)) {
362 return TRUE;
363 }
364 }
365 return FALSE;
366 }
367
368 /**
369 * Fixes the relative paths inside of url() references in CSS files
370 *
371 * @param string $contents Data to process
372 * @param string $oldDir Directory of the originial file, relative to TYPO3_mainDir
373 * @return string Processed data
374 */
375 protected function cssFixRelativeUrlPaths($contents, $oldDir) {
376 $matches = array();
377
378 preg_match_all('/url(\(\s*["\']?([^"\']+)["\']?\s*\))/iU', $contents, $matches);
379 foreach ($matches[2] as $matchCount => $match) {
380 // remove '," or white-spaces around
381 $match = preg_replace('/[\"\'\s]/', '', $match);
382
383 // we must not rewrite paths containing ":", e.g. data URIs (see RFC 2397)
384 if (strpos($match, ':') === FALSE) {
385 $newPath = t3lib_div::resolveBackPath('../../' . TYPO3_mainDir . $oldDir . $match);
386 $contents = str_replace($matches[1][$matchCount], '(\'' . $newPath . '\')', $contents);
387 }
388 }
389 return $contents;
390 }
391
392 /**
393 * Writes $contents into file $filename together with a gzipped version into $filename.gz
394 *
395 * @param string $filename Target filename
396 * @param strings $contents File contents
397 * @return void
398 */
399 protected function writeFileAndCompressed($filename, $contents) {
400 // write uncompressed file
401 t3lib_div::writeFile(PATH_site . $filename, $contents);
402
403 if ($this->createGzipped) {
404 // create compressed version
405 t3lib_div::writeFile(PATH_site . $filename . '.gzip', gzencode($contents, $this->gzipCompressionLevel));
406 }
407 }
408
409 /**
410 * Decides whether a client can deal with gzipped content or not and returns the according file name,
411 * based on HTTP_ACCEPT_ENCODING
412 *
413 * @param string $filename File name
414 * @return string $filename suffixed with '.gzip' or not - dependent on HTTP_ACCEPT_ENCODING
415 */
416 protected function returnFileReference($filename) {
417 // if the client accepts gzip and we can create gzipped files, we give him compressed versions
418 if ($this->createGzipped && strpos(t3lib_div::getIndpEnv('HTTP_ACCEPT_ENCODING'), 'gzip') !== FALSE) {
419 return $filename . '.gzip';
420 } else {
421 return $filename;
422 }
423 }
424 }
425
426 if (defined('TYPO3_MODE') && isset($GLOBALS['TYPO3_CONF_VARS'][TYPO3_MODE]['XCLASS']['t3lib/class.t3lib_compressor.php'])) {
427 include_once($GLOBALS['TYPO3_CONF_VARS'][TYPO3_MODE]['XCLASS']['t3lib/class.t3lib_compressor.php']);
428 }
429
430 ?>