MWException: Don't send headers multiple times
[mediawiki.git] / includes / resourceloader / ResourceLoader.php
blob680bd994d315904ae723de2778a32d960bf5d5e7
1 <?php
2 /**
3 * Base class for resource loading system.
5 * This program is free software; you can redistribute it and/or modify
6 * it under the terms of the GNU General Public License as published by
7 * the Free Software Foundation; either version 2 of the License, or
8 * (at your option) any later version.
10 * This program is distributed in the hope that it will be useful,
11 * but WITHOUT ANY WARRANTY; without even the implied warranty of
12 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13 * GNU General Public License for more details.
15 * You should have received a copy of the GNU General Public License along
16 * with this program; if not, write to the Free Software Foundation, Inc.,
17 * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
18 * http://www.gnu.org/copyleft/gpl.html
20 * @file
21 * @author Roan Kattouw
22 * @author Trevor Parscal
25 /**
26 * Dynamic JavaScript and CSS resource loading system.
28 * Most of the documentation is on the MediaWiki documentation wiki starting at:
29 * https://www.mediawiki.org/wiki/ResourceLoader
31 class ResourceLoader {
32 /** @var int */
33 protected static $filterCacheVersion = 7;
35 /** @var array */
36 protected static $requiredSourceProperties = array( 'loadScript' );
38 /** @var array Module name/ResourceLoaderModule object pairs */
39 protected $modules = array();
41 /** @var array Associative array mapping module name to info associative array */
42 protected $moduleInfos = array();
44 /**
45 * @var array Associative array mapping framework ids to a list of names of test suite modules
46 * like array( 'qunit' => array( 'mediawiki.tests.qunit.suites', 'ext.foo.tests', .. ), .. )
48 protected $testModuleNames = array();
50 /** @var array e.g. array( 'source-id' => array( 'loadScript' => 'http://.../load.php' ) ) */
51 protected $sources = array();
53 /** @var bool */
54 protected $hasErrors = false;
56 /**
57 * Load information stored in the database about modules.
59 * This method grabs modules dependencies from the database and updates modules
60 * objects.
62 * This is not inside the module code because it is much faster to
63 * request all of the information at once than it is to have each module
64 * requests its own information. This sacrifice of modularity yields a substantial
65 * performance improvement.
67 * @param array $modules List of module names to preload information for
68 * @param ResourceLoaderContext $context Context to load the information within
70 public function preloadModuleInfo( array $modules, ResourceLoaderContext $context ) {
71 if ( !count( $modules ) ) {
72 // Or else Database*::select() will explode, plus it's cheaper!
73 return;
75 $dbr = wfGetDB( DB_SLAVE );
76 $skin = $context->getSkin();
77 $lang = $context->getLanguage();
79 // Get file dependency information
80 $res = $dbr->select( 'module_deps', array( 'md_module', 'md_deps' ), array(
81 'md_module' => $modules,
82 'md_skin' => $skin
83 ), __METHOD__
86 // Set modules' dependencies
87 $modulesWithDeps = array();
88 foreach ( $res as $row ) {
89 $module = $this->getModule( $row->md_module );
90 if ( $module ) {
91 $module->setFileDependencies( $skin, FormatJson::decode( $row->md_deps, true ) );
92 $modulesWithDeps[] = $row->md_module;
96 // Register the absence of a dependency row too
97 foreach ( array_diff( $modules, $modulesWithDeps ) as $name ) {
98 $module = $this->getModule( $name );
99 if ( $module ) {
100 $this->getModule( $name )->setFileDependencies( $skin, array() );
104 // Get message blob mtimes. Only do this for modules with messages
105 $modulesWithMessages = array();
106 foreach ( $modules as $name ) {
107 $module = $this->getModule( $name );
108 if ( $module && count( $module->getMessages() ) ) {
109 $modulesWithMessages[] = $name;
112 $modulesWithoutMessages = array_flip( $modules ); // Will be trimmed down by the loop below
113 if ( count( $modulesWithMessages ) ) {
114 $res = $dbr->select( 'msg_resource', array( 'mr_resource', 'mr_timestamp' ), array(
115 'mr_resource' => $modulesWithMessages,
116 'mr_lang' => $lang
117 ), __METHOD__
119 foreach ( $res as $row ) {
120 $module = $this->getModule( $row->mr_resource );
121 if ( $module ) {
122 $module->setMsgBlobMtime( $lang, wfTimestamp( TS_UNIX, $row->mr_timestamp ) );
123 unset( $modulesWithoutMessages[$row->mr_resource] );
127 foreach ( array_keys( $modulesWithoutMessages ) as $name ) {
128 $module = $this->getModule( $name );
129 if ( $module ) {
130 $module->setMsgBlobMtime( $lang, 0 );
136 * Run JavaScript or CSS data through a filter, caching the filtered result for future calls.
138 * Available filters are:
140 * - minify-js \see JavaScriptMinifier::minify
141 * - minify-css \see CSSMin::minify
143 * If $data is empty, only contains whitespace or the filter was unknown,
144 * $data is returned unmodified.
146 * @param string $filter Name of filter to run
147 * @param string $data Text to filter, such as JavaScript or CSS text
148 * @return string Filtered data, or a comment containing an error message
150 protected function filter( $filter, $data ) {
151 global $wgResourceLoaderMinifierStatementsOnOwnLine, $wgResourceLoaderMinifierMaxLineLength;
152 wfProfileIn( __METHOD__ );
154 // For empty/whitespace-only data or for unknown filters, don't perform
155 // any caching or processing
156 if ( trim( $data ) === '' || !in_array( $filter, array( 'minify-js', 'minify-css' ) ) ) {
157 wfProfileOut( __METHOD__ );
158 return $data;
161 // Try for cache hit
162 // Use CACHE_ANYTHING since filtering is very slow compared to DB queries
163 $key = wfMemcKey( 'resourceloader', 'filter', $filter, self::$filterCacheVersion, md5( $data ) );
164 $cache = wfGetCache( CACHE_ANYTHING );
165 $cacheEntry = $cache->get( $key );
166 if ( is_string( $cacheEntry ) ) {
167 wfIncrStats( "rl-$filter-cache-hits" );
168 wfProfileOut( __METHOD__ );
169 return $cacheEntry;
172 $result = '';
173 // Run the filter - we've already verified one of these will work
174 try {
175 wfIncrStats( "rl-$filter-cache-misses" );
176 switch ( $filter ) {
177 case 'minify-js':
178 $result = JavaScriptMinifier::minify( $data,
179 $wgResourceLoaderMinifierStatementsOnOwnLine,
180 $wgResourceLoaderMinifierMaxLineLength
182 $result .= "\n/* cache key: $key */";
183 break;
184 case 'minify-css':
185 $result = CSSMin::minify( $data );
186 $result .= "\n/* cache key: $key */";
187 break;
190 // Save filtered text to Memcached
191 $cache->set( $key, $result );
192 } catch ( Exception $e ) {
193 MWExceptionHandler::logException( $e );
194 wfDebugLog( 'resourceloader', __METHOD__ . ": minification failed: $e" );
195 $this->hasErrors = true;
196 // Return exception as a comment
197 $result = self::formatException( $e );
200 wfProfileOut( __METHOD__ );
202 return $result;
205 /* Methods */
208 * Register core modules and runs registration hooks.
210 public function __construct() {
211 global $IP, $wgResourceModules, $wgResourceLoaderSources, $wgLoadScript, $wgEnableJavaScriptTest;
213 wfProfileIn( __METHOD__ );
215 // Add 'local' source first
216 $this->addSource(
217 'local',
218 array( 'loadScript' => $wgLoadScript, 'apiScript' => wfScript( 'api' ) )
221 // Add other sources
222 $this->addSource( $wgResourceLoaderSources );
224 // Register core modules
225 $this->register( include "$IP/resources/Resources.php" );
226 // Register extension modules
227 wfRunHooks( 'ResourceLoaderRegisterModules', array( &$this ) );
228 $this->register( $wgResourceModules );
230 if ( $wgEnableJavaScriptTest === true ) {
231 $this->registerTestModules();
234 wfProfileOut( __METHOD__ );
238 * Register a module with the ResourceLoader system.
240 * @param mixed $name Name of module as a string or List of name/object pairs as an array
241 * @param array $info Module info array. For backwards compatibility with 1.17alpha,
242 * this may also be a ResourceLoaderModule object. Optional when using
243 * multiple-registration calling style.
244 * @throws MWException If a duplicate module registration is attempted
245 * @throws MWException If a module name contains illegal characters (pipes or commas)
246 * @throws MWException If something other than a ResourceLoaderModule is being registered
247 * @return bool False if there were any errors, in which case one or more modules were
248 * not registered
250 public function register( $name, $info = null ) {
251 wfProfileIn( __METHOD__ );
253 // Allow multiple modules to be registered in one call
254 $registrations = is_array( $name ) ? $name : array( $name => $info );
255 foreach ( $registrations as $name => $info ) {
256 // Disallow duplicate registrations
257 if ( isset( $this->moduleInfos[$name] ) ) {
258 wfProfileOut( __METHOD__ );
259 // A module has already been registered by this name
260 throw new MWException(
261 'ResourceLoader duplicate registration error. ' .
262 'Another module has already been registered as ' . $name
266 // Check $name for validity
267 if ( !self::isValidModuleName( $name ) ) {
268 wfProfileOut( __METHOD__ );
269 throw new MWException( "ResourceLoader module name '$name' is invalid, "
270 . "see ResourceLoader::isValidModuleName()" );
273 // Attach module
274 if ( $info instanceof ResourceLoaderModule ) {
275 $this->moduleInfos[$name] = array( 'object' => $info );
276 $info->setName( $name );
277 $this->modules[$name] = $info;
278 } elseif ( is_array( $info ) ) {
279 // New calling convention
280 $this->moduleInfos[$name] = $info;
281 } else {
282 wfProfileOut( __METHOD__ );
283 throw new MWException(
284 'ResourceLoader module info type error for module \'' . $name .
285 '\': expected ResourceLoaderModule or array (got: ' . gettype( $info ) . ')'
290 wfProfileOut( __METHOD__ );
295 public function registerTestModules() {
296 global $IP, $wgEnableJavaScriptTest;
298 if ( $wgEnableJavaScriptTest !== true ) {
299 throw new MWException( 'Attempt to register JavaScript test modules '
300 . 'but <code>$wgEnableJavaScriptTest</code> is false. '
301 . 'Edit your <code>LocalSettings.php</code> to enable it.' );
304 wfProfileIn( __METHOD__ );
306 // Get core test suites
307 $testModules = array();
308 $testModules['qunit'] = array();
309 // Get other test suites (e.g. from extensions)
310 wfRunHooks( 'ResourceLoaderTestModules', array( &$testModules, &$this ) );
312 // Add the testrunner (which configures QUnit) to the dependencies.
313 // Since it must be ready before any of the test suites are executed.
314 foreach ( $testModules['qunit'] as &$module ) {
315 // Make sure all test modules are top-loading so that when QUnit starts
316 // on document-ready, it will run once and finish. If some tests arrive
317 // later (possibly after QUnit has already finished) they will be ignored.
318 $module['position'] = 'top';
319 $module['dependencies'][] = 'test.mediawiki.qunit.testrunner';
322 $testModules['qunit'] =
323 ( include "$IP/tests/qunit/QUnitTestResources.php" ) + $testModules['qunit'];
325 foreach ( $testModules as $id => $names ) {
326 // Register test modules
327 $this->register( $testModules[$id] );
329 // Keep track of their names so that they can be loaded together
330 $this->testModuleNames[$id] = array_keys( $testModules[$id] );
333 wfProfileOut( __METHOD__ );
337 * Add a foreign source of modules.
339 * Source properties:
340 * 'loadScript': URL (either fully-qualified or protocol-relative) of load.php for this source
342 * @param mixed $id Source ID (string), or array( id1 => props1, id2 => props2, ... )
343 * @param array $properties Source properties
344 * @throws MWException
346 public function addSource( $id, $properties = null ) {
347 // Allow multiple sources to be registered in one call
348 if ( is_array( $id ) ) {
349 foreach ( $id as $key => $value ) {
350 $this->addSource( $key, $value );
352 return;
355 // Disallow duplicates
356 if ( isset( $this->sources[$id] ) ) {
357 throw new MWException(
358 'ResourceLoader duplicate source addition error. ' .
359 'Another source has already been registered as ' . $id
363 // Validate properties
364 foreach ( self::$requiredSourceProperties as $prop ) {
365 if ( !isset( $properties[$prop] ) ) {
366 throw new MWException( "Required property $prop missing from source ID $id" );
370 $this->sources[$id] = $properties;
374 * Get a list of module names.
376 * @return array List of module names
378 public function getModuleNames() {
379 return array_keys( $this->moduleInfos );
383 * Get a list of test module names for one (or all) frameworks.
385 * If the given framework id is unknkown, or if the in-object variable is not an array,
386 * then it will return an empty array.
388 * @param string $framework Get only the test module names for one
389 * particular framework (optional)
390 * @return array
392 public function getTestModuleNames( $framework = 'all' ) {
393 /** @todo api siteinfo prop testmodulenames modulenames */
394 if ( $framework == 'all' ) {
395 return $this->testModuleNames;
396 } elseif ( isset( $this->testModuleNames[$framework] )
397 && is_array( $this->testModuleNames[$framework] )
399 return $this->testModuleNames[$framework];
400 } else {
401 return array();
406 * Get the ResourceLoaderModule object for a given module name.
408 * If an array of module parameters exists but a ResourceLoaderModule object has not
409 * yet been instantiated, this method will instantiate and cache that object such that
410 * subsequent calls simply return the same object.
412 * @param string $name Module name
413 * @return ResourceLoaderModule|null If module has been registered, return a
414 * ResourceLoaderModule instance. Otherwise, return null.
416 public function getModule( $name ) {
417 if ( !isset( $this->modules[$name] ) ) {
418 if ( !isset( $this->moduleInfos[$name] ) ) {
419 // No such module
420 return null;
422 // Construct the requested object
423 $info = $this->moduleInfos[$name];
424 /** @var ResourceLoaderModule $object */
425 if ( isset( $info['object'] ) ) {
426 // Object given in info array
427 $object = $info['object'];
428 } else {
429 if ( !isset( $info['class'] ) ) {
430 $class = 'ResourceLoaderFileModule';
431 } else {
432 $class = $info['class'];
434 $object = new $class( $info );
436 $object->setName( $name );
437 $this->modules[$name] = $object;
440 return $this->modules[$name];
444 * Get the list of sources.
446 * @return array Like array( id => array of properties, .. )
448 public function getSources() {
449 return $this->sources;
453 * Output a response to a load request, including the content-type header.
455 * @param ResourceLoaderContext $context Context in which a response should be formed
457 public function respond( ResourceLoaderContext $context ) {
458 global $wgCacheEpoch, $wgUseFileCache;
460 // Use file cache if enabled and available...
461 if ( $wgUseFileCache ) {
462 $fileCache = ResourceFileCache::newFromContext( $context );
463 if ( $this->tryRespondFromFileCache( $fileCache, $context ) ) {
464 return; // output handled
468 // Buffer output to catch warnings. Normally we'd use ob_clean() on the
469 // top-level output buffer to clear warnings, but that breaks when ob_gzhandler
470 // is used: ob_clean() will clear the GZIP header in that case and it won't come
471 // back for subsequent output, resulting in invalid GZIP. So we have to wrap
472 // the whole thing in our own output buffer to be sure the active buffer
473 // doesn't use ob_gzhandler.
474 // See http://bugs.php.net/bug.php?id=36514
475 ob_start();
477 wfProfileIn( __METHOD__ );
478 $errors = '';
480 // Find out which modules are missing and instantiate the others
481 $modules = array();
482 $missing = array();
483 foreach ( $context->getModules() as $name ) {
484 $module = $this->getModule( $name );
485 if ( $module ) {
486 // Do not allow private modules to be loaded from the web.
487 // This is a security issue, see bug 34907.
488 if ( $module->getGroup() === 'private' ) {
489 wfDebugLog( 'resourceloader', __METHOD__ . ": request for private module '$name' denied" );
490 $this->hasErrors = true;
491 // Add exception to the output as a comment
492 $errors .= self::makeComment( "Cannot show private module \"$name\"" );
494 continue;
496 $modules[$name] = $module;
497 } else {
498 $missing[] = $name;
502 // Preload information needed to the mtime calculation below
503 try {
504 $this->preloadModuleInfo( array_keys( $modules ), $context );
505 } catch ( Exception $e ) {
506 MWExceptionHandler::logException( $e );
507 wfDebugLog( 'resourceloader', __METHOD__ . ": preloading module info failed: $e" );
508 $this->hasErrors = true;
509 // Add exception to the output as a comment
510 $errors .= self::formatException( $e );
513 wfProfileIn( __METHOD__ . '-getModifiedTime' );
515 // To send Last-Modified and support If-Modified-Since, we need to detect
516 // the last modified time
517 $mtime = wfTimestamp( TS_UNIX, $wgCacheEpoch );
518 foreach ( $modules as $module ) {
520 * @var $module ResourceLoaderModule
522 try {
523 // Calculate maximum modified time
524 $mtime = max( $mtime, $module->getModifiedTime( $context ) );
525 } catch ( Exception $e ) {
526 MWExceptionHandler::logException( $e );
527 wfDebugLog( 'resourceloader', __METHOD__ . ": calculating maximum modified time failed: $e" );
528 $this->hasErrors = true;
529 // Add exception to the output as a comment
530 $errors .= self::formatException( $e );
534 wfProfileOut( __METHOD__ . '-getModifiedTime' );
536 // If there's an If-Modified-Since header, respond with a 304 appropriately
537 if ( $this->tryRespondLastModified( $context, $mtime ) ) {
538 wfProfileOut( __METHOD__ );
539 return; // output handled (buffers cleared)
542 // Generate a response
543 $response = $this->makeModuleResponse( $context, $modules, $missing );
545 // Prepend comments indicating exceptions
546 $response = $errors . $response;
548 // Capture any PHP warnings from the output buffer and append them to the
549 // response in a comment if we're in debug mode.
550 if ( $context->getDebug() && strlen( $warnings = ob_get_contents() ) ) {
551 $response = self::makeComment( $warnings ) . $response;
552 $this->hasErrors = true;
555 // Save response to file cache unless there are errors
556 if ( isset( $fileCache ) && !$errors && !count( $missing ) ) {
557 // Cache single modules...and other requests if there are enough hits
558 if ( ResourceFileCache::useFileCache( $context ) ) {
559 if ( $fileCache->isCacheWorthy() ) {
560 $fileCache->saveText( $response );
561 } else {
562 $fileCache->incrMissesRecent( $context->getRequest() );
567 // Send content type and cache related headers
568 $this->sendResponseHeaders( $context, $mtime, $this->hasErrors );
570 // Remove the output buffer and output the response
571 ob_end_clean();
572 echo $response;
574 wfProfileOut( __METHOD__ );
578 * Send content type and last modified headers to the client.
579 * @param ResourceLoaderContext $context
580 * @param string $mtime TS_MW timestamp to use for last-modified
581 * @param bool $errors Whether there are commented-out errors in the response
582 * @return void
584 protected function sendResponseHeaders( ResourceLoaderContext $context, $mtime, $errors ) {
585 global $wgResourceLoaderMaxage;
586 // If a version wasn't specified we need a shorter expiry time for updates
587 // to propagate to clients quickly
588 // If there were errors, we also need a shorter expiry time so we can recover quickly
589 if ( is_null( $context->getVersion() ) || $errors ) {
590 $maxage = $wgResourceLoaderMaxage['unversioned']['client'];
591 $smaxage = $wgResourceLoaderMaxage['unversioned']['server'];
592 // If a version was specified we can use a longer expiry time since changing
593 // version numbers causes cache misses
594 } else {
595 $maxage = $wgResourceLoaderMaxage['versioned']['client'];
596 $smaxage = $wgResourceLoaderMaxage['versioned']['server'];
598 if ( $context->getOnly() === 'styles' ) {
599 header( 'Content-Type: text/css; charset=utf-8' );
600 header( 'Access-Control-Allow-Origin: *' );
601 } else {
602 header( 'Content-Type: text/javascript; charset=utf-8' );
604 header( 'Last-Modified: ' . wfTimestamp( TS_RFC2822, $mtime ) );
605 if ( $context->getDebug() ) {
606 // Do not cache debug responses
607 header( 'Cache-Control: private, no-cache, must-revalidate' );
608 header( 'Pragma: no-cache' );
609 } else {
610 header( "Cache-Control: public, max-age=$maxage, s-maxage=$smaxage" );
611 $exp = min( $maxage, $smaxage );
612 header( 'Expires: ' . wfTimestamp( TS_RFC2822, $exp + time() ) );
617 * Respond with 304 Last Modified if appropiate.
619 * If there's an If-Modified-Since header, respond with a 304 appropriately
620 * and clear out the output buffer. If the client cache is too old then do nothing.
622 * @param ResourceLoaderContext $context
623 * @param string $mtime The TS_MW timestamp to check the header against
624 * @return bool True if 304 header sent and output handled
626 protected function tryRespondLastModified( ResourceLoaderContext $context, $mtime ) {
627 // If there's an If-Modified-Since header, respond with a 304 appropriately
628 // Some clients send "timestamp;length=123". Strip the part after the first ';'
629 // so we get a valid timestamp.
630 $ims = $context->getRequest()->getHeader( 'If-Modified-Since' );
631 // Never send 304s in debug mode
632 if ( $ims !== false && !$context->getDebug() ) {
633 $imsTS = strtok( $ims, ';' );
634 if ( $mtime <= wfTimestamp( TS_UNIX, $imsTS ) ) {
635 // There's another bug in ob_gzhandler (see also the comment at
636 // the top of this function) that causes it to gzip even empty
637 // responses, meaning it's impossible to produce a truly empty
638 // response (because the gzip header is always there). This is
639 // a problem because 304 responses have to be completely empty
640 // per the HTTP spec, and Firefox behaves buggily when they're not.
641 // See also http://bugs.php.net/bug.php?id=51579
642 // To work around this, we tear down all output buffering before
643 // sending the 304.
644 wfResetOutputBuffers( /* $resetGzipEncoding = */ true );
646 header( 'HTTP/1.0 304 Not Modified' );
647 header( 'Status: 304 Not Modified' );
648 return true;
651 return false;
655 * Send out code for a response from file cache if possible.
657 * @param ResourceFileCache $fileCache Cache object for this request URL
658 * @param ResourceLoaderContext $context Context in which to generate a response
659 * @return bool If this found a cache file and handled the response
661 protected function tryRespondFromFileCache(
662 ResourceFileCache $fileCache, ResourceLoaderContext $context
664 global $wgResourceLoaderMaxage;
665 // Buffer output to catch warnings.
666 ob_start();
667 // Get the maximum age the cache can be
668 $maxage = is_null( $context->getVersion() )
669 ? $wgResourceLoaderMaxage['unversioned']['server']
670 : $wgResourceLoaderMaxage['versioned']['server'];
671 // Minimum timestamp the cache file must have
672 $good = $fileCache->isCacheGood( wfTimestamp( TS_MW, time() - $maxage ) );
673 if ( !$good ) {
674 try { // RL always hits the DB on file cache miss...
675 wfGetDB( DB_SLAVE );
676 } catch ( DBConnectionError $e ) { // ...check if we need to fallback to cache
677 $good = $fileCache->isCacheGood(); // cache existence check
680 if ( $good ) {
681 $ts = $fileCache->cacheTimestamp();
682 // Send content type and cache headers
683 $this->sendResponseHeaders( $context, $ts, false );
684 // If there's an If-Modified-Since header, respond with a 304 appropriately
685 if ( $this->tryRespondLastModified( $context, $ts ) ) {
686 return false; // output handled (buffers cleared)
688 $response = $fileCache->fetchText();
689 // Capture any PHP warnings from the output buffer and append them to the
690 // response in a comment if we're in debug mode.
691 if ( $context->getDebug() && strlen( $warnings = ob_get_contents() ) ) {
692 $response = "/*\n$warnings\n*/\n" . $response;
694 // Remove the output buffer and output the response
695 ob_end_clean();
696 echo $response . "\n/* Cached {$ts} */";
697 return true; // cache hit
699 // Clear buffer
700 ob_end_clean();
702 return false; // cache miss
706 * Generate a CSS or JS comment block.
708 * Only use this for public data, not error message details.
710 * @param string $text
711 * @return string
713 public static function makeComment( $text ) {
714 $encText = str_replace( '*/', '* /', $text );
715 return "/*\n$encText\n*/\n";
719 * Handle exception display.
721 * @param Exception $e Exception to be shown to the user
722 * @return string Sanitized text that can be returned to the user
724 public static function formatException( $e ) {
725 global $wgShowExceptionDetails;
727 if ( $wgShowExceptionDetails ) {
728 return self::makeComment( $e->__toString() );
729 } else {
730 return self::makeComment( wfMessage( 'internalerror' )->text() );
735 * Generate code for a response.
737 * @param ResourceLoaderContext $context Context in which to generate a response
738 * @param array $modules List of module objects keyed by module name
739 * @param array $missing List of requested module names that are unregistered (optional)
740 * @return string Response data
742 public function makeModuleResponse( ResourceLoaderContext $context,
743 array $modules, array $missing = array()
745 $out = '';
746 $exceptions = '';
747 $states = array();
749 if ( !count( $modules ) && !count( $missing ) ) {
750 return "/* This file is the Web entry point for MediaWiki's ResourceLoader:
751 <https://www.mediawiki.org/wiki/ResourceLoader>. In this request,
752 no modules were requested. Max made me put this here. */";
755 wfProfileIn( __METHOD__ );
757 // Pre-fetch blobs
758 if ( $context->shouldIncludeMessages() ) {
759 try {
760 $blobs = MessageBlobStore::get( $this, $modules, $context->getLanguage() );
761 } catch ( Exception $e ) {
762 MWExceptionHandler::logException( $e );
763 wfDebugLog(
764 'resourceloader',
765 __METHOD__ . ": pre-fetching blobs from MessageBlobStore failed: $e"
767 $this->hasErrors = true;
768 // Add exception to the output as a comment
769 $exceptions .= self::formatException( $e );
771 } else {
772 $blobs = array();
775 foreach ( $missing as $name ) {
776 $states[$name] = 'missing';
779 // Generate output
780 $isRaw = false;
781 foreach ( $modules as $name => $module ) {
783 * @var $module ResourceLoaderModule
786 wfProfileIn( __METHOD__ . '-' . $name );
787 try {
788 $scripts = '';
789 if ( $context->shouldIncludeScripts() ) {
790 // If we are in debug mode, we'll want to return an array of URLs if possible
791 // However, we can't do this if the module doesn't support it
792 // We also can't do this if there is an only= parameter, because we have to give
793 // the module a way to return a load.php URL without causing an infinite loop
794 if ( $context->getDebug() && !$context->getOnly() && $module->supportsURLLoading() ) {
795 $scripts = $module->getScriptURLsForDebug( $context );
796 } else {
797 $scripts = $module->getScript( $context );
798 // rtrim() because there are usually a few line breaks
799 // after the last ';'. A new line at EOF, a new line
800 // added by ResourceLoaderFileModule::readScriptFiles, etc.
801 if ( is_string( $scripts )
802 && strlen( $scripts )
803 && substr( rtrim( $scripts ), -1 ) !== ';'
805 // Append semicolon to prevent weird bugs caused by files not
806 // terminating their statements right (bug 27054)
807 $scripts .= ";\n";
811 // Styles
812 $styles = array();
813 if ( $context->shouldIncludeStyles() ) {
814 // Don't create empty stylesheets like array( '' => '' ) for modules
815 // that don't *have* any stylesheets (bug 38024).
816 $stylePairs = $module->getStyles( $context );
817 if ( count ( $stylePairs ) ) {
818 // If we are in debug mode without &only= set, we'll want to return an array of URLs
819 // See comment near shouldIncludeScripts() for more details
820 if ( $context->getDebug() && !$context->getOnly() && $module->supportsURLLoading() ) {
821 $styles = array(
822 'url' => $module->getStyleURLsForDebug( $context )
824 } else {
825 // Minify CSS before embedding in mw.loader.implement call
826 // (unless in debug mode)
827 if ( !$context->getDebug() ) {
828 foreach ( $stylePairs as $media => $style ) {
829 // Can be either a string or an array of strings.
830 if ( is_array( $style ) ) {
831 $stylePairs[$media] = array();
832 foreach ( $style as $cssText ) {
833 if ( is_string( $cssText ) ) {
834 $stylePairs[$media][] = $this->filter( 'minify-css', $cssText );
837 } elseif ( is_string( $style ) ) {
838 $stylePairs[$media] = $this->filter( 'minify-css', $style );
842 // Wrap styles into @media groups as needed and flatten into a numerical array
843 $styles = array(
844 'css' => self::makeCombinedStyles( $stylePairs )
850 // Messages
851 $messagesBlob = isset( $blobs[$name] ) ? $blobs[$name] : '{}';
853 // Append output
854 switch ( $context->getOnly() ) {
855 case 'scripts':
856 if ( is_string( $scripts ) ) {
857 // Load scripts raw...
858 $out .= $scripts;
859 } elseif ( is_array( $scripts ) ) {
860 // ...except when $scripts is an array of URLs
861 $out .= self::makeLoaderImplementScript( $name, $scripts, array(), array() );
863 break;
864 case 'styles':
865 // We no longer seperate into media, they are all combined now with
866 // custom media type groups into @media .. {} sections as part of the css string.
867 // Module returns either an empty array or a numerical array with css strings.
868 $out .= isset( $styles['css'] ) ? implode( '', $styles['css'] ) : '';
869 break;
870 case 'messages':
871 $out .= self::makeMessageSetScript( new XmlJsCode( $messagesBlob ) );
872 break;
873 default:
874 $out .= self::makeLoaderImplementScript(
875 $name,
876 $scripts,
877 $styles,
878 new XmlJsCode( $messagesBlob )
880 break;
882 } catch ( Exception $e ) {
883 MWExceptionHandler::logException( $e );
884 wfDebugLog( 'resourceloader', __METHOD__ . ": generating module package failed: $e" );
885 $this->hasErrors = true;
886 // Add exception to the output as a comment
887 $exceptions .= self::formatException( $e );
889 // Respond to client with error-state instead of module implementation
890 $states[$name] = 'error';
891 unset( $modules[$name] );
893 $isRaw |= $module->isRaw();
894 wfProfileOut( __METHOD__ . '-' . $name );
897 // Update module states
898 if ( $context->shouldIncludeScripts() && !$context->getRaw() && !$isRaw ) {
899 if ( count( $modules ) && $context->getOnly() === 'scripts' ) {
900 // Set the state of modules loaded as only scripts to ready as
901 // they don't have an mw.loader.implement wrapper that sets the state
902 foreach ( $modules as $name => $module ) {
903 $states[$name] = 'ready';
907 // Set the state of modules we didn't respond to with mw.loader.implement
908 if ( count( $states ) ) {
909 $out .= self::makeLoaderStateScript( $states );
911 } else {
912 if ( count( $states ) ) {
913 $exceptions .= self::makeComment(
914 'Problematic modules: ' . FormatJson::encode( $states, ResourceLoader::inDebugMode() )
919 if ( !$context->getDebug() ) {
920 if ( $context->getOnly() === 'styles' ) {
921 $out = $this->filter( 'minify-css', $out );
922 } else {
923 $out = $this->filter( 'minify-js', $out );
927 wfProfileOut( __METHOD__ );
928 return $exceptions . $out;
931 /* Static Methods */
934 * Return JS code that calls mw.loader.implement with given module properties.
936 * @param string $name Module name
937 * @param mixed $scripts List of URLs to JavaScript files or String of JavaScript code
938 * @param mixed $styles Array of CSS strings keyed by media type, or an array of lists of URLs
939 * to CSS files keyed by media type
940 * @param mixed $messages List of messages associated with this module. May either be an
941 * associative array mapping message key to value, or a JSON-encoded message blob containing
942 * the same data, wrapped in an XmlJsCode object.
943 * @throws MWException
944 * @return string
946 public static function makeLoaderImplementScript( $name, $scripts, $styles, $messages ) {
947 if ( is_string( $scripts ) ) {
948 $scripts = new XmlJsCode( "function ( $, jQuery ) {\n{$scripts}\n}" );
949 } elseif ( !is_array( $scripts ) ) {
950 throw new MWException( 'Invalid scripts error. Array of URLs or string of code expected.' );
952 return Xml::encodeJsCall(
953 'mw.loader.implement',
954 array(
955 $name,
956 $scripts,
957 // Force objects. mw.loader.implement requires them to be javascript objects.
958 // Although these variables are associative arrays, which become javascript
959 // objects through json_encode. In many cases they will be empty arrays, and
960 // PHP/json_encode() consider empty arrays to be numerical arrays and
961 // output javascript "[]" instead of "{}". This fixes that.
962 (object)$styles,
963 (object)$messages
965 ResourceLoader::inDebugMode()
970 * Returns JS code which, when called, will register a given list of messages.
972 * @param mixed $messages Either an associative array mapping message key to value, or a
973 * JSON-encoded message blob containing the same data, wrapped in an XmlJsCode object.
974 * @return string
976 public static function makeMessageSetScript( $messages ) {
977 return Xml::encodeJsCall(
978 'mw.messages.set',
979 array( (object)$messages ),
980 ResourceLoader::inDebugMode()
985 * Combines an associative array mapping media type to CSS into a
986 * single stylesheet with "@media" blocks.
988 * @param array $stylePairs Array keyed by media type containing (arrays of) CSS strings
989 * @return array
991 private static function makeCombinedStyles( array $stylePairs ) {
992 $out = array();
993 foreach ( $stylePairs as $media => $styles ) {
994 // ResourceLoaderFileModule::getStyle can return the styles
995 // as a string or an array of strings. This is to allow separation in
996 // the front-end.
997 $styles = (array)$styles;
998 foreach ( $styles as $style ) {
999 $style = trim( $style );
1000 // Don't output an empty "@media print { }" block (bug 40498)
1001 if ( $style !== '' ) {
1002 // Transform the media type based on request params and config
1003 // The way that this relies on $wgRequest to propagate request params is slightly evil
1004 $media = OutputPage::transformCssMedia( $media );
1006 if ( $media === '' || $media == 'all' ) {
1007 $out[] = $style;
1008 } elseif ( is_string( $media ) ) {
1009 $out[] = "@media $media {\n" . str_replace( "\n", "\n\t", "\t" . $style ) . "}";
1011 // else: skip
1015 return $out;
1019 * Returns a JS call to mw.loader.state, which sets the state of a
1020 * module or modules to a given value. Has two calling conventions:
1022 * - ResourceLoader::makeLoaderStateScript( $name, $state ):
1023 * Set the state of a single module called $name to $state
1025 * - ResourceLoader::makeLoaderStateScript( array( $name => $state, ... ) ):
1026 * Set the state of modules with the given names to the given states
1028 * @param string $name
1029 * @param string $state
1030 * @return string
1032 public static function makeLoaderStateScript( $name, $state = null ) {
1033 if ( is_array( $name ) ) {
1034 return Xml::encodeJsCall(
1035 'mw.loader.state',
1036 array( $name ),
1037 ResourceLoader::inDebugMode()
1039 } else {
1040 return Xml::encodeJsCall(
1041 'mw.loader.state',
1042 array( $name, $state ),
1043 ResourceLoader::inDebugMode()
1049 * Returns JS code which calls the script given by $script. The script will
1050 * be called with local variables name, version, dependencies and group,
1051 * which will have values corresponding to $name, $version, $dependencies
1052 * and $group as supplied.
1054 * @param string $name Module name
1055 * @param int $version Module version number as a timestamp
1056 * @param array $dependencies List of module names on which this module depends
1057 * @param string $group Group which the module is in.
1058 * @param string $source Source of the module, or 'local' if not foreign.
1059 * @param string $script JavaScript code
1060 * @return string
1062 public static function makeCustomLoaderScript( $name, $version, $dependencies,
1063 $group, $source, $script
1065 $script = str_replace( "\n", "\n\t", trim( $script ) );
1066 return Xml::encodeJsCall(
1067 "( function ( name, version, dependencies, group, source ) {\n\t$script\n} )",
1068 array( $name, $version, $dependencies, $group, $source ),
1069 ResourceLoader::inDebugMode()
1074 * Returns JS code which calls mw.loader.register with the given
1075 * parameters. Has three calling conventions:
1077 * - ResourceLoader::makeLoaderRegisterScript( $name, $version, $dependencies, $group, $source ):
1078 * Register a single module.
1080 * - ResourceLoader::makeLoaderRegisterScript( array( $name1, $name2 ) ):
1081 * Register modules with the given names.
1083 * - ResourceLoader::makeLoaderRegisterScript( array(
1084 * array( $name1, $version1, $dependencies1, $group1, $source1 ),
1085 * array( $name2, $version2, $dependencies1, $group2, $source2 ),
1086 * ...
1087 * ) ):
1088 * Registers modules with the given names and parameters.
1090 * @param string $name Module name
1091 * @param int $version Module version number as a timestamp
1092 * @param array $dependencies List of module names on which this module depends
1093 * @param string $group Group which the module is in
1094 * @param string $source Source of the module, or 'local' if not foreign
1095 * @return string
1097 public static function makeLoaderRegisterScript( $name, $version = null,
1098 $dependencies = null, $group = null, $source = null
1100 if ( is_array( $name ) ) {
1101 return Xml::encodeJsCall(
1102 'mw.loader.register',
1103 array( $name ),
1104 ResourceLoader::inDebugMode()
1106 } else {
1107 $version = (int)$version > 1 ? (int)$version : 1;
1108 return Xml::encodeJsCall(
1109 'mw.loader.register',
1110 array( $name, $version, $dependencies, $group, $source ),
1111 ResourceLoader::inDebugMode()
1117 * Returns JS code which calls mw.loader.addSource() with the given
1118 * parameters. Has two calling conventions:
1120 * - ResourceLoader::makeLoaderSourcesScript( $id, $properties ):
1121 * Register a single source
1123 * - ResourceLoader::makeLoaderSourcesScript( array( $id1 => $props1, $id2 => $props2, ... ) );
1124 * Register sources with the given IDs and properties.
1126 * @param string $id Source ID
1127 * @param array $properties Source properties (see addSource())
1128 * @return string
1130 public static function makeLoaderSourcesScript( $id, $properties = null ) {
1131 if ( is_array( $id ) ) {
1132 return Xml::encodeJsCall(
1133 'mw.loader.addSource',
1134 array( $id ),
1135 ResourceLoader::inDebugMode()
1137 } else {
1138 return Xml::encodeJsCall(
1139 'mw.loader.addSource',
1140 array( $id, $properties ),
1141 ResourceLoader::inDebugMode()
1147 * Returns JS code which runs given JS code if the client-side framework is
1148 * present.
1150 * @param string $script JavaScript code
1151 * @return string
1153 public static function makeLoaderConditionalScript( $script ) {
1154 return "if(window.mw){\n" . trim( $script ) . "\n}";
1158 * Returns JS code which will set the MediaWiki configuration array to
1159 * the given value.
1161 * @param array $configuration List of configuration values keyed by variable name
1162 * @return string
1164 public static function makeConfigSetScript( array $configuration ) {
1165 return Xml::encodeJsCall(
1166 'mw.config.set',
1167 array( $configuration ),
1168 ResourceLoader::inDebugMode()
1173 * Convert an array of module names to a packed query string.
1175 * For example, array( 'foo.bar', 'foo.baz', 'bar.baz', 'bar.quux' )
1176 * becomes 'foo.bar,baz|bar.baz,quux'
1177 * @param array $modules List of module names (strings)
1178 * @return string Packed query string
1180 public static function makePackedModulesString( $modules ) {
1181 $groups = array(); // array( prefix => array( suffixes ) )
1182 foreach ( $modules as $module ) {
1183 $pos = strrpos( $module, '.' );
1184 $prefix = $pos === false ? '' : substr( $module, 0, $pos );
1185 $suffix = $pos === false ? $module : substr( $module, $pos + 1 );
1186 $groups[$prefix][] = $suffix;
1189 $arr = array();
1190 foreach ( $groups as $prefix => $suffixes ) {
1191 $p = $prefix === '' ? '' : $prefix . '.';
1192 $arr[] = $p . implode( ',', $suffixes );
1194 $str = implode( '|', $arr );
1195 return $str;
1199 * Determine whether debug mode was requested
1200 * Order of priority is 1) request param, 2) cookie, 3) $wg setting
1201 * @return bool
1203 public static function inDebugMode() {
1204 global $wgRequest, $wgResourceLoaderDebug;
1205 static $retval = null;
1206 if ( is_null( $retval ) ) {
1207 $retval = $wgRequest->getFuzzyBool( 'debug',
1208 $wgRequest->getCookie( 'resourceLoaderDebug', '', $wgResourceLoaderDebug ) );
1210 return $retval;
1214 * Build a load.php URL
1215 * @param array $modules Array of module names (strings)
1216 * @param string $lang Language code
1217 * @param string $skin Skin name
1218 * @param string|null $user User name. If null, the &user= parameter is omitted
1219 * @param string|null $version Versioning timestamp
1220 * @param bool $debug Whether the request should be in debug mode
1221 * @param string|null $only &only= parameter
1222 * @param bool $printable Printable mode
1223 * @param bool $handheld Handheld mode
1224 * @param array $extraQuery Extra query parameters to add
1225 * @return string URL to load.php. May be protocol-relative (if $wgLoadScript is procol-relative)
1227 public static function makeLoaderURL( $modules, $lang, $skin, $user = null,
1228 $version = null, $debug = false, $only = null, $printable = false,
1229 $handheld = false, $extraQuery = array()
1231 global $wgLoadScript;
1233 $query = self::makeLoaderQuery( $modules, $lang, $skin, $user, $version, $debug,
1234 $only, $printable, $handheld, $extraQuery
1237 // Prevent the IE6 extension check from being triggered (bug 28840)
1238 // by appending a character that's invalid in Windows extensions ('*')
1239 return wfExpandUrl( wfAppendQuery( $wgLoadScript, $query ) . '&*', PROTO_RELATIVE );
1243 * Build a query array (array representation of query string) for load.php. Helper
1244 * function for makeLoaderURL().
1246 * @param array $modules
1247 * @param string $lang
1248 * @param string $skin
1249 * @param string $user
1250 * @param string $version
1251 * @param bool $debug
1252 * @param string $only
1253 * @param bool $printable
1254 * @param bool $handheld
1255 * @param array $extraQuery
1257 * @return array
1259 public static function makeLoaderQuery( $modules, $lang, $skin, $user = null,
1260 $version = null, $debug = false, $only = null, $printable = false,
1261 $handheld = false, $extraQuery = array()
1263 $query = array(
1264 'modules' => self::makePackedModulesString( $modules ),
1265 'lang' => $lang,
1266 'skin' => $skin,
1267 'debug' => $debug ? 'true' : 'false',
1269 if ( $user !== null ) {
1270 $query['user'] = $user;
1272 if ( $version !== null ) {
1273 $query['version'] = $version;
1275 if ( $only !== null ) {
1276 $query['only'] = $only;
1278 if ( $printable ) {
1279 $query['printable'] = 1;
1281 if ( $handheld ) {
1282 $query['handheld'] = 1;
1284 $query += $extraQuery;
1286 // Make queries uniform in order
1287 ksort( $query );
1288 return $query;
1292 * Check a module name for validity.
1294 * Module names may not contain pipes (|), commas (,) or exclamation marks (!) and can be
1295 * at most 255 bytes.
1297 * @param string $moduleName Module name to check
1298 * @return bool Whether $moduleName is a valid module name
1300 public static function isValidModuleName( $moduleName ) {
1301 return !preg_match( '/[|,!]/', $moduleName ) && strlen( $moduleName ) <= 255;
1305 * Returns LESS compiler set up for use with MediaWiki
1307 * @since 1.22
1308 * @return lessc
1310 public static function getLessCompiler() {
1311 global $wgResourceLoaderLESSFunctions, $wgResourceLoaderLESSImportPaths;
1313 // When called from the installer, it is possible that a required PHP extension
1314 // is missing (at least for now; see bug 47564). If this is the case, throw an
1315 // exception (caught by the installer) to prevent a fatal error later on.
1316 if ( !function_exists( 'ctype_digit' ) ) {
1317 throw new MWException( 'lessc requires the Ctype extension' );
1320 $less = new lessc();
1321 $less->setPreserveComments( true );
1322 $less->setVariables( self::getLESSVars() );
1323 $less->setImportDir( $wgResourceLoaderLESSImportPaths );
1324 foreach ( $wgResourceLoaderLESSFunctions as $name => $func ) {
1325 $less->registerFunction( $name, $func );
1327 return $less;
1331 * Get global LESS variables.
1333 * $since 1.22
1334 * @return array Map of variable names to string CSS values.
1336 public static function getLESSVars() {
1337 global $wgResourceLoaderLESSVars;
1339 $lessVars = $wgResourceLoaderLESSVars;
1340 // Sort by key to ensure consistent hashing for cache lookups.
1341 ksort( $lessVars );
1342 return $lessVars;