PluginProbe
WP Super Minify • Minify, Compress and Cache HTML, CSS & JavaScript / trunk
WP Super Minify • Minify, Compress and Cache HTML, CSS & JavaScript vtrunk
trunk 1.0 1.1 1.2 1.3 1.3.1 1.3.2 1.4 1.5 1.5.1 1.6 2.0 2.0.1
wp-super-minify / includes / min / lib / Minify.php

Minify.php in WP Super Minify • Minify, Compress and Cache HTML, CSS & JavaScript trunk, at includes/min/lib/Minify.php

762 lines 26.5 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Class Minify
4 * @package Minify
5 */
6
7 use Psr\Log\LoggerInterface;
8
9 /**
10 * Minify - Combines, minifies, and caches JavaScript and CSS files on demand.
11 *
12 * See README for usage instructions (for now).
13 *
14 * This library was inspired by {@link mailto:flashkot@mail.ru jscsscomp by Maxim Martynyuk}
15 * and by the article {@link http://www.hunlock.com/blogs/Supercharged_Javascript "Supercharged JavaScript" by Patrick Hunlock}.
16 *
17 * @package Minify
18 * @author Ryan Grove <ryan@wonko.com>
19 * @author Stephen Clay <steve@mrclay.org>
20 * @copyright 2008 Ryan Grove, Stephen Clay. All rights reserved.
21 * @license http://opensource.org/licenses/bsd-license.php New BSD License
22 * @link https://github.com/mrclay/minify
23 */
24 class Minify
25 {
26
27 /**
28 * API version
29 *
30 * This is only bumped when API breaks are done and should follow the major version of the library
31 *
32 * @var int
33 */
34 const VERSION = 3;
35
36 const TYPE_CSS = 'text/css';
37 const TYPE_HTML = 'text/html';
38 // there is some debate over the ideal JS Content-Type, but this is the
39 // Apache default and what Yahoo! uses..
40 const TYPE_JS = 'application/x-javascript';
41 const URL_DEBUG = 'https://github.com/mrclay/minify/blob/master/docs/Debugging.wiki.md';
42
43 /**
44 * Any Minify_Cache_* object or null (i.e. no server cache is used)
45 *
46 * @var Minify_CacheInterface
47 */
48 private $cache;
49
50 /**
51 * Active controller for current request
52 *
53 * @var Minify_Controller_Base
54 */
55 protected $controller;
56
57 /**
58 * @var Minify_Env
59 */
60 protected $env;
61
62 /**
63 * @var Minify_SourceInterface[]
64 */
65 protected $sources;
66
67 /**
68 * @var string
69 */
70 protected $selectionId;
71
72 /**
73 * Options for current request
74 *
75 * @var array
76 */
77 protected $options;
78
79 /**
80 * @var LoggerInterface|null
81 */
82 protected $logger;
83
84 /**
85 * @param Minify_CacheInterface $cache
86 * @param LoggerInterface $logger
87 */
88 public function __construct(Minify_CacheInterface $cache, ?LoggerInterface $logger = null)
89 {
90 $this->cache = $cache;
91 $this->logger = $logger;
92 }
93
94 /**
95 * Get default Minify options.
96 *
97 * @return array options for Minify
98 */
99 public function getDefaultOptions()
100 {
101 return array(
102 'isPublic' => true,
103 'encodeOutput' => function_exists('gzdeflate'),
104 'encodeMethod' => null, // determine later
105 'encodeLevel' => 9,
106
107 'minifiers' => array(
108 Minify::TYPE_JS => array('JSMin\\JSMin', 'minify'),
109 Minify::TYPE_CSS => array('Minify_CSSmin', 'minify'),
110 Minify::TYPE_HTML => array('Minify_HTML', 'minify'),
111 ),
112 'minifierOptions' => array(), // no minifier options
113
114 'contentTypeCharset' => 'utf-8',
115 'maxAge' => 1800, // 30 minutes
116 'rewriteCssUris' => true,
117 'bubbleCssImports' => false,
118 'quiet' => false, // serve() will send headers and output
119 'debug' => false,
120 'concatOnly' => false,
121 'invalidate' => false,
122
123 // if you override these, the response codes MUST be directly after
124 // the first space.
125 'badRequestHeader' => 'HTTP/1.0 400 Bad Request',
126 'errorHeader' => 'HTTP/1.0 500 Internal Server Error',
127
128 // callback function to see/modify content of all sources
129 'postprocessor' => null,
130 // file to require to load preprocessor
131 'postprocessorRequire' => null,
132
133 /**
134 * If this string is not empty AND the serve() option 'bubbleCssImports' is
135 * NOT set, then serve() will check CSS files for @import declarations that
136 * appear too late in the combined stylesheet. If found, serve() will prepend
137 * the output with this warning.
138 */
139 'importWarning' => "/* See https://github.com/mrclay/minify/blob/master/docs/CommonProblems.wiki.md#imports-can-appear-in-invalid-locations-in-combined-css-files */\n"
140 );
141 }
142
143 /**
144 * Serve a request for a minified file.
145 *
146 * Here are the available options and defaults:
147 *
148 * 'isPublic' : send "public" instead of "private" in Cache-Control
149 * headers, allowing shared caches to cache the output. (default true)
150 *
151 * 'quiet' : set to true to have serve() return an array rather than sending
152 * any headers/output (default false)
153 *
154 * 'encodeOutput' : set to false to disable content encoding, and not send
155 * the Vary header (default true)
156 *
157 * 'encodeMethod' : generally you should let this be determined by
158 * HTTP_Encoder (leave null), but you can force a particular encoding
159 * to be returned, by setting this to 'gzip' or '' (no encoding)
160 *
161 * 'encodeLevel' : level of encoding compression (0 to 9, default 9)
162 *
163 * 'contentTypeCharset' : appended to the Content-Type header sent. Set to a falsey
164 * value to remove. (default 'utf-8')
165 *
166 * 'maxAge' : set this to the number of seconds the client should use its cache
167 * before revalidating with the server. This sets Cache-Control: max-age and the
168 * Expires header. Unlike the old 'setExpires' setting, this setting will NOT
169 * prevent conditional GETs. Note this has nothing to do with server-side caching.
170 *
171 * 'rewriteCssUris' : If true, serve() will automatically set the 'currentDir'
172 * minifier option to enable URI rewriting in CSS files (default true)
173 *
174 * 'bubbleCssImports' : If true, all @import declarations in combined CSS
175 * files will be move to the top. Note this may alter effective CSS values
176 * due to a change in order. (default false)
177 *
178 * 'debug' : set to true to minify all sources with the 'Lines' controller, which
179 * eases the debugging of combined files. This also prevents 304 responses.
180 * @see Minify_Lines::minify()
181 *
182 * 'concatOnly' : set to true to disable minification and simply concatenate the files.
183 * For JS, no minifier will be used. For CSS, only URI rewriting is still performed.
184 *
185 * 'minifiers' : to override Minify's default choice of minifier function for
186 * a particular content-type, specify your callback under the key of the
187 * content-type:
188 * <code>
189 * // call customCssMinifier($css) for all CSS minification
190 * $options['minifiers'][Minify::TYPE_CSS] = 'customCssMinifier';
191 *
192 * // don't minify Javascript at all
193 * $options['minifiers'][Minify::TYPE_JS] = 'Minify::nullMinifier';
194 * </code>
195 *
196 * 'minifierOptions' : to send options to the minifier function, specify your options
197 * under the key of the content-type. E.g. To send the CSS minifier an option:
198 * <code>
199 * // give CSS minifier array('optionName' => 'optionValue') as 2nd argument
200 * $options['minifierOptions'][Minify::TYPE_CSS]['optionName'] = 'optionValue';
201 * </code>
202 *
203 * 'contentType' : (optional) this is only needed if your file extension is not
204 * js/css/html. The given content-type will be sent regardless of source file
205 * extension, so this should not be used in a Groups config with other
206 * Javascript/CSS files.
207 *
208 * 'importWarning' : serve() will check CSS files for @import declarations that
209 * appear too late in the combined stylesheet. If found, serve() will prepend
210 * the output with this warning. To disable this, set this option to empty string.
211 *
212 * Any controller options are documented in that controller's createConfiguration() method.
213 *
214 * @param Minify_ControllerInterface $controller instance of subclass of Minify_Controller_Base
215 *
216 * @param array $options controller/serve options
217 *
218 * @return null|array if the 'quiet' option is set to true, an array
219 * with keys "success" (bool), "statusCode" (int), "content" (string), and
220 * "headers" (array).
221 *
222 * @throws Exception
223 */
224 public function serve(Minify_ControllerInterface $controller, $options = array())
225 {
226 $this->env = $controller->getEnv();
227
228 $options = array_merge($this->getDefaultOptions(), $options);
229
230 $config = $controller->createConfiguration($options);
231
232 $this->sources = $config->getSources();
233 $this->selectionId = $config->getSelectionId();
234 $this->options = $this->analyzeSources($config->getOptions());
235
236 if (!$this->options['quiet'] && !headers_sent()) {
237 ini_set('zlib.output_compression', '0');
238 }
239
240 // check request validity
241 if (!$this->sources) {
242 // invalid request!
243 if (! $this->options['quiet']) {
244 $this->errorExit($this->options['badRequestHeader'], self::URL_DEBUG);
245 } else {
246 list(, $statusCode) = explode(' ', $this->options['badRequestHeader']);
247
248 return array(
249 'success' => false,
250 'statusCode' => (int)$statusCode,
251 'content' => '',
252 'headers' => array(),
253 );
254 }
255 }
256
257 $this->controller = $controller;
258
259 if ($this->options['debug']) {
260 $this->setupDebug();
261 $this->options['maxAge'] = 0;
262 }
263
264 // determine encoding
265 if ($this->options['encodeOutput']) {
266 $sendVary = true;
267 if ($this->options['encodeMethod'] !== null) {
268 // controller specifically requested this
269 $contentEncoding = $this->options['encodeMethod'];
270 } else {
271 // sniff request header
272 // depending on what the client accepts, $contentEncoding may be
273 // 'x-gzip' while our internal encodeMethod is 'gzip'. Calling
274 // getAcceptedEncoding(false, false) leaves out compress and deflate as options.
275 $list = HTTP_Encoder::getAcceptedEncoding(false, false);
276 list($this->options['encodeMethod'], $contentEncoding) = $list;
277 $sendVary = ! HTTP_Encoder::isBuggyIe();
278 }
279 } else {
280 $this->options['encodeMethod'] = ''; // identity (no encoding)
281 }
282
283 // check client cache
284 $cgOptions = array(
285 'lastModifiedTime' => $this->options['lastModifiedTime'],
286 'isPublic' => $this->options['isPublic'],
287 'encoding' => $this->options['encodeMethod'],
288 'invalidate' => $this->options['invalidate'],
289 );
290
291 if ($this->options['maxAge'] > 0) {
292 $cgOptions['maxAge'] = $this->options['maxAge'];
293 } elseif ($this->options['debug']) {
294 $cgOptions['invalidate'] = true;
295 }
296
297 $cg = new HTTP_ConditionalGet($cgOptions);
298 if ($cg->cacheIsValid) {
299 // client's cache is valid
300 if (! $this->options['quiet']) {
301 $cg->sendHeaders();
302
303 return;
304 }
305
306 return array(
307 'success' => true,
308 'statusCode' => 304,
309 'content' => '',
310 'headers' => $cg->getHeaders(),
311 );
312 }
313
314 // client will need output
315 $headers = $cg->getHeaders();
316 unset($cg);
317
318 if ($this->options['contentType'] === self::TYPE_CSS && $this->options['rewriteCssUris']) {
319 $this->setupUriRewrites();
320 }
321
322 if ($this->options['concatOnly']) {
323 $this->options['minifiers'][self::TYPE_JS] = false;
324 foreach ($this->sources as $key => $source) {
325 if ($this->options['contentType'] === self::TYPE_JS) {
326 $source->setMinifier('Minify::nullMinifier');
327 } elseif ($this->options['contentType'] === self::TYPE_CSS) {
328 $source->setMinifier(array('Minify_CSSmin', 'minify'));
329 $sourceOpts = $source->getMinifierOptions();
330 $sourceOpts['compress'] = false;
331 $source->setMinifierOptions($sourceOpts);
332 }
333 }
334 }
335
336 // check server cache
337 if (! $this->options['debug']) {
338 // using cache
339 // the goal is to use only the cache methods to sniff the length and
340 // output the content, as they do not require ever loading the file into
341 // memory.
342 $cacheId = $this->_getCacheId();
343 $fullCacheId = ($this->options['encodeMethod']) ? $cacheId . '.gz' : $cacheId;
344
345 // check cache for valid entry
346 $cacheIsReady = $this->cache->isValid($fullCacheId, $this->options['lastModifiedTime']);
347 if ($cacheIsReady) {
348 $cacheContentLength = $this->cache->getSize($fullCacheId);
349 } else {
350 // generate & cache content
351 try {
352 $content = $this->combineMinify();
353 } catch (Exception $e) {
354 $this->logger && $this->logger->critical($e->getMessage());
355 if (! $this->options['quiet']) {
356 $this->errorExit($this->options['errorHeader'], self::URL_DEBUG);
357 }
358 throw $e;
359 }
360 $this->cache->store($cacheId, $content);
361 if (function_exists('gzencode') && $this->options['encodeMethod']) {
362 $this->cache->store($cacheId . '.gz', gzencode($content, $this->options['encodeLevel']));
363 }
364 }
365 } else {
366 // no cache
367 $cacheIsReady = false;
368 try {
369 $content = $this->combineMinify();
370 } catch (Exception $e) {
371 $this->logger && $this->logger->critical($e->getMessage());
372 if (! $this->options['quiet']) {
373 $this->errorExit($this->options['errorHeader'], self::URL_DEBUG);
374 }
375 throw $e;
376 }
377 }
378 if (! $cacheIsReady && $this->options['encodeMethod']) {
379 // still need to encode
380 $content = gzencode($content, $this->options['encodeLevel']);
381 }
382
383 // add headers
384 if ($cacheIsReady) {
385 $headers['Content-Length'] = $cacheContentLength;
386 } else {
387 if (function_exists('mb_strlen') && ((int)ini_get('mbstring.func_overload') & 2)) {
388 $headers['Content-Length'] = mb_strlen($content, '8bit');
389 } else {
390 $headers['Content-Length'] = strlen($content);
391 }
392 }
393
394 $headers['Content-Type'] = $this->options['contentType'];
395 if ($this->options['contentTypeCharset']) {
396 $headers['Content-Type'] .= '; charset=' . $this->options['contentTypeCharset'];
397 }
398
399 if ($this->options['encodeMethod'] !== '') {
400 $headers['Content-Encoding'] = $contentEncoding;
401 }
402 if ($this->options['encodeOutput'] && $sendVary) {
403 $headers['Vary'] = 'Accept-Encoding';
404 }
405
406 if (! $this->options['quiet']) {
407 // output headers & content
408 foreach ($headers as $name => $val) {
409 header($name . ': ' . $val);
410 }
411 if ($cacheIsReady) {
412 $this->cache->display($fullCacheId);
413 } else {
414 echo $content;
415 }
416 } else {
417 return array(
418 'success' => true,
419 'statusCode' => 200,
420 'content' => $cacheIsReady ? $this->cache->fetch($fullCacheId) : $content,
421 'headers' => $headers,
422 );
423 }
424 }
425
426 /**
427 * Return combined minified content for a set of sources
428 *
429 * No internal caching will be used and the content will not be HTTP encoded.
430 *
431 * @param array $sources array of filepaths and/or Minify_Source objects
432 *
433 * @param array $options (optional) array of options for serve.
434 *
435 * @return string
436 */
437 public function combine($sources, $options = array())
438 {
439 $tmpCache = $this->cache;
440 $this->cache = new Minify_Cache_Null();
441
442 $env = new Minify_Env();
443 $sourceFactory = new Minify_Source_Factory($env, array(
444 'checkAllowDirs' => false,
445 ), $this->cache);
446 $controller = new Minify_Controller_Files($env, $sourceFactory, $this->logger);
447
448 $options = array_merge($options, array(
449 'files' => (array)$sources,
450 'quiet' => true,
451 'encodeMethod' => '',
452 'lastModifiedTime' => 0,
453 ));
454 $out = $this->serve($controller, $options);
455
456 $this->cache = $tmpCache;
457
458 return $out['content'];
459 }
460
461 /**
462 * Show an error page
463 *
464 * @param string $header Full header. E.g. 'HTTP/1.0 500 Internal Server Error'
465 * @param string $url URL to direct the user to
466 * @param string $msgHtml HTML message for the client
467 *
468 * @return void
469 * @internal This is not part of the public API and is subject to change
470 * @access private
471 */
472 public function errorExit($header, $url = '', $msgHtml = '')
473 {
474 $url = htmlspecialchars($url);
475 list(, $h1) = explode(' ', $header, 2);
476 $h1 = htmlspecialchars($h1);
477 // FastCGI environments require 3rd arg to header() to be set
478 list(, $code) = explode(' ', $header, 3);
479 header($header, true, $code);
480 header('Content-Type: text/html; charset=utf-8');
481 echo "<h1>$h1</h1>";
482 if ($msgHtml) {
483 echo $msgHtml;
484 }
485 if ($url) {
486 echo "<p>Please see <a href='$url'>$url</a>.</p>";
487 }
488 exit;
489 }
490
491 /**
492 * Default minifier for .min or -min JS files.
493 *
494 * @param string $content
495 * @return string
496 */
497 public static function nullMinifier($content)
498 {
499 if (isset($content[0]) && $content[0] === "\xef") {
500 $content = substr($content, 3);
501 }
502 $content = str_replace("\r\n", "\n", $content);
503
504 return trim($content);
505 }
506
507 /**
508 * Setup CSS sources for URI rewriting
509 */
510 protected function setupUriRewrites()
511 {
512 foreach ($this->sources as $key => $source) {
513 $file = $this->env->normalizePath($source->getFilePath());
514 $minifyOptions = $source->getMinifierOptions();
515
516 if ($file
517 && !isset($minifyOptions['currentDir'])
518 && !isset($minifyOptions['prependRelativePath'])) {
519 $minifyOptions['currentDir'] = dirname($file);
520 $source->setMinifierOptions($minifyOptions);
521 }
522 }
523 }
524
525 /**
526 * Set up sources to use Minify_Lines
527 */
528 protected function setupDebug()
529 {
530 foreach ($this->sources as $source) {
531 $source->setMinifier(array('Minify_Lines', 'minify'));
532 $id = $source->getId();
533 $source->setMinifierOptions(array(
534 'id' => (is_file($id) ? basename($id) : $id),
535 ));
536 }
537 }
538
539 /**
540 * Combines sources and minifies the result.
541 *
542 * @return string
543 *
544 * @throws Exception
545 */
546 protected function combineMinify()
547 {
548 $type = $this->options['contentType']; // ease readability
549
550 // when combining scripts, make sure all statements separated and
551 // trailing single line comment is terminated
552 $implodeSeparator = ($type === self::TYPE_JS) ? "\n;" : '';
553
554 // allow the user to pass a particular array of options to each
555 // minifier (designated by type). source objects may still override
556 // these
557 if (isset($this->options['minifierOptions'][$type])) {
558 $defaultOptions = $this->options['minifierOptions'][$type];
559 } else {
560 $defaultOptions = array();
561 }
562
563 // if minifier not set, default is no minification. source objects
564 // may still override this
565 if (isset($this->options['minifiers'][$type])) {
566 $defaultMinifier = $this->options['minifiers'][$type];
567 } else {
568 $defaultMinifier = false;
569 }
570
571 // process groups of sources with identical minifiers/options
572 $content = array();
573 $i = 0;
574 $l = count($this->sources);
575 $groupToProcessTogether = array();
576 $lastMinifier = null;
577 $lastOptions = null;
578 do {
579 // get next source
580 $source = null;
581 if ($i < $l) {
582 $source = $this->sources[$i];
583 $sourceContent = $source->getContent();
584
585 // allow the source to override our minifier and options
586 $minifier = $source->getMinifier();
587 if (!$minifier) {
588 $minifier = $defaultMinifier;
589 }
590 $options = array_merge($defaultOptions, $source->getMinifierOptions());
591 }
592 // do we need to process our group right now?
593 if ($i > 0 // yes, we have at least the first group populated
594 && (
595 ! $source // yes, we ran out of sources
596 || $type === self::TYPE_CSS // yes, to process CSS individually (avoiding PCRE bugs/limits)
597 || $minifier !== $lastMinifier // yes, minifier changed
598 || $options !== $lastOptions // yes, options changed
599 )) {
600 // minify previous sources with last settings
601 $imploded = implode($implodeSeparator, $groupToProcessTogether);
602 $groupToProcessTogether = array();
603 if ($lastMinifier) {
604 try {
605 $content[] = call_user_func($lastMinifier, $imploded, $lastOptions);
606 } catch (Exception $e) {
607 throw new Exception("Exception in minifier: " . $e->getMessage());
608 }
609 } else {
610 $content[] = $imploded;
611 }
612 }
613 // add content to the group
614 if ($source) {
615 $groupToProcessTogether[] = $sourceContent;
616 $lastMinifier = $minifier;
617 $lastOptions = $options;
618 }
619 $i++;
620 } while ($source);
621
622 $content = implode($implodeSeparator, $content);
623
624 if ($type === self::TYPE_CSS && false !== strpos($content, '@import')) {
625 $content = $this->handleCssImports($content);
626 }
627
628 // do any post-processing (esp. for editing build URIs)
629 if ($this->options['postprocessorRequire']) {
630 require_once $this->options['postprocessorRequire'];
631 }
632 if ($this->options['postprocessor']) {
633 $content = call_user_func($this->options['postprocessor'], $content, $type);
634 }
635
636 return $content;
637 }
638
639 /**
640 * Make a unique cache id for for this request.
641 *
642 * Any settings that could affect output are taken into consideration
643 *
644 * @param string $prefix
645 *
646 * @return string
647 */
648 protected function _getCacheId($prefix = 'minify')
649 {
650 $name = preg_replace('/[^a-zA-Z0-9\\.=_,]/', '', $this->selectionId);
651 $name = preg_replace('/\\.+/', '.', $name);
652 $name = substr($name, 0, 100 - 34 - strlen($prefix));
653 $md5 = md5(serialize(array(
654 Minify_SourceSet::getDigest($this->sources),
655 $this->options['minifiers'],
656 $this->options['minifierOptions'],
657 $this->options['postprocessor'],
658 $this->options['bubbleCssImports'],
659 Minify::VERSION,
660 )));
661
662 return "{$prefix}_{$name}_{$md5}";
663 }
664
665 /**
666 * Bubble CSS @imports to the top or prepend a warning if an import is detected not at the top.
667 *
668 * @param string $css
669 *
670 * @return string
671 */
672 protected function handleCssImports($css)
673 {
674 if ($this->options['bubbleCssImports']) {
675 // bubble CSS imports
676 preg_match_all('/@import.*?;/', $css, $imports);
677 $css = implode('', $imports[0]) . preg_replace('/@import.*?;/', '', $css);
678
679 return $css;
680 }
681
682 if ('' === $this->options['importWarning']) {
683 return $css;
684 }
685
686 // remove comments so we don't mistake { in a comment as a block
687 $noCommentCss = preg_replace('@/\\*[\\s\\S]*?\\*/@', '', $css);
688 $lastImportPos = strrpos($noCommentCss, '@import');
689 $firstBlockPos = strpos($noCommentCss, '{');
690 if (false !== $lastImportPos
691 && false !== $firstBlockPos
692 && $firstBlockPos < $lastImportPos
693 ) {
694 // { appears before @import : prepend warning
695 $css = $this->options['importWarning'] . $css;
696 }
697
698 return $css;
699 }
700
701 /**
702 * Analyze sources (if there are any) and set $options 'contentType'
703 * and 'lastModifiedTime' if they already aren't.
704 *
705 * @param array $options options for Minify
706 *
707 * @return array options for Minify
708 */
709 protected function analyzeSources($options = array())
710 {
711 if (!$this->sources) {
712 return $options;
713 }
714
715 $type = null;
716 foreach ($this->sources as $source) {
717 $sourceType = $source->getContentType();
718
719 if (!empty($options['contentType'])) {
720 // just verify sources have null content type or match the options
721 if ($sourceType !== null && $sourceType !== $options['contentType']) {
722 $this->logger && $this->logger->warning("ContentType mismatch: '{$sourceType}' != '{$options['contentType']}'");
723
724 $this->sources = array();
725
726 return $options;
727 }
728
729 continue;
730 }
731
732 if ($type === null) {
733 $type = $sourceType;
734 } elseif ($sourceType !== $type) {
735 $this->logger && $this->logger->warning("ContentType mismatch: '{$sourceType}' != '{$type}'");
736
737 $this->sources = array();
738
739 return $options;
740 }
741 }
742
743 if (empty($options['contentType'])) {
744 if (null === $type) {
745 $type = 'text/plain';
746 }
747 $options['contentType'] = $type;
748 }
749
750 // last modified is needed for caching, even if setExpires is set
751 if (!isset($options['lastModifiedTime'])) {
752 $max = 0;
753 foreach ($this->sources as $source) {
754 $max = max($source->getLastModified(), $max);
755 }
756 $options['lastModifiedTime'] = $max;
757 }
758
759 return $options;
760 }
761 }
762