From a1789ddde42033f1b05cc4929491214ee6e79383 Mon Sep 17 00:00:00 2001 From: Pierre Schmitz Date: Thu, 17 Dec 2015 09:15:42 +0100 Subject: Update to MediaWiki 1.26.0 --- .../DerivativeResourceLoaderContext.php | 76 ++- includes/resourceloader/ResourceLoader.php | 553 +++++++++++---------- includes/resourceloader/ResourceLoaderContext.php | 33 +- .../ResourceLoaderEditToolbarModule.php | 31 +- .../resourceloader/ResourceLoaderFileModule.php | 122 ++--- .../ResourceLoaderForeignApiModule.php | 33 ++ includes/resourceloader/ResourceLoaderImage.php | 25 +- .../resourceloader/ResourceLoaderImageModule.php | 193 +++++-- .../ResourceLoaderJqueryMsgModule.php | 66 +++ .../ResourceLoaderLanguageDataModule.php | 16 +- .../ResourceLoaderLanguageNamesModule.php | 18 +- includes/resourceloader/ResourceLoaderModule.php | 479 +++++++++++++----- .../ResourceLoaderOOUIImageModule.php | 86 ++++ .../resourceloader/ResourceLoaderRawFileModule.php | 52 ++ .../resourceloader/ResourceLoaderSiteModule.php | 9 - .../resourceloader/ResourceLoaderSkinModule.php | 13 +- .../ResourceLoaderSpecialCharacterDataModule.php | 19 +- .../resourceloader/ResourceLoaderStartUpModule.php | 191 ++++--- .../ResourceLoaderUserCSSPrefsModule.php | 18 +- .../ResourceLoaderUserDefaultsModule.php | 19 +- .../ResourceLoaderUserOptionsModule.php | 21 +- .../resourceloader/ResourceLoaderWikiModule.php | 198 ++++---- 22 files changed, 1424 insertions(+), 847 deletions(-) create mode 100644 includes/resourceloader/ResourceLoaderForeignApiModule.php create mode 100644 includes/resourceloader/ResourceLoaderJqueryMsgModule.php create mode 100644 includes/resourceloader/ResourceLoaderOOUIImageModule.php create mode 100644 includes/resourceloader/ResourceLoaderRawFileModule.php (limited to 'includes/resourceloader') diff --git a/includes/resourceloader/DerivativeResourceLoaderContext.php b/includes/resourceloader/DerivativeResourceLoaderContext.php index 5784f2a0..59675372 100644 --- a/includes/resourceloader/DerivativeResourceLoaderContext.php +++ b/includes/resourceloader/DerivativeResourceLoaderContext.php @@ -28,32 +28,32 @@ * @since 1.24 */ class DerivativeResourceLoaderContext extends ResourceLoaderContext { + const INHERIT_VALUE = -1; /** * @var ResourceLoaderContext */ private $context; - protected $modules; - protected $language; - protected $direction; - protected $skin; - protected $user; - protected $debug; - protected $only; - protected $version; - protected $hash; - protected $raw; + + protected $modules = self::INHERIT_VALUE; + protected $language = self::INHERIT_VALUE; + protected $direction = self::INHERIT_VALUE; + protected $skin = self::INHERIT_VALUE; + protected $user = self::INHERIT_VALUE; + protected $debug = self::INHERIT_VALUE; + protected $only = self::INHERIT_VALUE; + protected $version = self::INHERIT_VALUE; + protected $raw = self::INHERIT_VALUE; public function __construct( ResourceLoaderContext $context ) { $this->context = $context; } public function getModules() { - if ( !is_null( $this->modules ) ) { - return $this->modules; - } else { + if ( $this->modules === self::INHERIT_VALUE ) { return $this->context->getModules(); } + return $this->modules; } /** @@ -64,11 +64,10 @@ class DerivativeResourceLoaderContext extends ResourceLoaderContext { } public function getLanguage() { - if ( !is_null( $this->language ) ) { - return $this->language; - } else { + if ( $this->language === self::INHERIT_VALUE ) { return $this->context->getLanguage(); } + return $this->language; } /** @@ -76,16 +75,19 @@ class DerivativeResourceLoaderContext extends ResourceLoaderContext { */ public function setLanguage( $language ) { $this->language = $language; - $this->direction = null; // Invalidate direction since it might be based on language + // Invalidate direction since it is based on language + $this->direction = null; $this->hash = null; } public function getDirection() { - if ( !is_null( $this->direction ) ) { - return $this->direction; - } else { + if ( $this->direction === self::INHERIT_VALUE ) { return $this->context->getDirection(); } + if ( $this->direction === null ) { + $this->direction = Language::factory( $this->getLanguage() )->getDir(); + } + return $this->direction; } /** @@ -97,11 +99,10 @@ class DerivativeResourceLoaderContext extends ResourceLoaderContext { } public function getSkin() { - if ( !is_null( $this->skin ) ) { - return $this->skin; - } else { + if ( $this->skin === self::INHERIT_VALUE ) { return $this->context->getSkin(); } + return $this->skin; } /** @@ -113,11 +114,10 @@ class DerivativeResourceLoaderContext extends ResourceLoaderContext { } public function getUser() { - if ( !is_null( $this->user ) ) { - return $this->user; - } else { + if ( $this->user === self::INHERIT_VALUE ) { return $this->context->getUser(); } + return $this->user; } /** @@ -130,11 +130,10 @@ class DerivativeResourceLoaderContext extends ResourceLoaderContext { } public function getDebug() { - if ( !is_null( $this->debug ) ) { - return $this->debug; - } else { + if ( $this->debug === self::INHERIT_VALUE ) { return $this->context->getDebug(); } + return $this->debug; } /** @@ -146,15 +145,14 @@ class DerivativeResourceLoaderContext extends ResourceLoaderContext { } public function getOnly() { - if ( !is_null( $this->only ) ) { - return $this->only; - } else { + if ( $this->only === self::INHERIT_VALUE ) { return $this->context->getOnly(); } + return $this->only; } /** - * @param string $only + * @param string|null $only */ public function setOnly( $only ) { $this->only = $only; @@ -162,15 +160,14 @@ class DerivativeResourceLoaderContext extends ResourceLoaderContext { } public function getVersion() { - if ( !is_null( $this->version ) ) { - return $this->version; - } else { + if ( $this->version === self::INHERIT_VALUE ) { return $this->context->getVersion(); } + return $this->version; } /** - * @param string $version + * @param string|null $version */ public function setVersion( $version ) { $this->version = $version; @@ -178,11 +175,10 @@ class DerivativeResourceLoaderContext extends ResourceLoaderContext { } public function getRaw() { - if ( !is_null( $this->raw ) ) { - return $this->raw; - } else { + if ( $this->raw === self::INHERIT_VALUE ) { return $this->context->getRaw(); } + return $this->raw; } /** diff --git a/includes/resourceloader/ResourceLoader.php b/includes/resourceloader/ResourceLoader.php index 150ccd07..c8ece147 100644 --- a/includes/resourceloader/ResourceLoader.php +++ b/includes/resourceloader/ResourceLoader.php @@ -22,13 +22,18 @@ * @author Trevor Parscal */ +use Psr\Log\LoggerAwareInterface; +use Psr\Log\LoggerInterface; +use Psr\Log\NullLogger; +use WrappedString\WrappedString; + /** * Dynamic JavaScript and CSS resource loading system. * * Most of the documentation is on the MediaWiki documentation wiki starting at: * https://www.mediawiki.org/wiki/ResourceLoader */ -class ResourceLoader { +class ResourceLoader implements LoggerAwareInterface { /** @var int */ protected static $filterCacheVersion = 7; @@ -77,6 +82,11 @@ class ResourceLoader { */ protected $blobStore; + /** + * @var LoggerInterface + */ + private $logger; + /** * Load information stored in the database about modules. * @@ -169,74 +179,98 @@ class ResourceLoader { * * @param string $filter Name of filter to run * @param string $data Text to filter, such as JavaScript or CSS text - * @param string $cacheReport Whether to include the cache key report + * @param array $options For back-compat, can also be the boolean value for "cacheReport". Keys: + * - (bool) cache: Whether to allow caching this data. Default: true. + * - (bool) cacheReport: Whether to include the "cache key" report comment. Default: false. * @return string Filtered data, or a comment containing an error message */ - public function filter( $filter, $data, $cacheReport = true ) { + public function filter( $filter, $data, $options = array() ) { + // Back-compat + if ( is_bool( $options ) ) { + $options = array( 'cacheReport' => $options ); + } + // Defaults + $options += array( 'cache' => true, 'cacheReport' => false ); + $stats = RequestContext::getMain()->getStats(); - // For empty/whitespace-only data or for unknown filters, don't perform - // any caching or processing - if ( trim( $data ) === '' || !in_array( $filter, array( 'minify-js', 'minify-css' ) ) ) { + // Don't filter empty content + if ( trim( $data ) === '' ) { return $data; } - // Try for cache hit - // Use CACHE_ANYTHING since filtering is very slow compared to DB queries - $key = wfMemcKey( 'resourceloader', 'filter', $filter, self::$filterCacheVersion, md5( $data ) ); - $cache = wfGetCache( CACHE_ANYTHING ); - $cacheEntry = $cache->get( $key ); - if ( is_string( $cacheEntry ) ) { - wfIncrStats( "rl-$filter-cache-hits" ); - return $cacheEntry; + if ( !in_array( $filter, array( 'minify-js', 'minify-css' ) ) ) { + $this->logger->warning( 'Invalid filter {filter}', array( + 'filter' => $filter + ) ); + return $data; } - $result = ''; - // Run the filter - we've already verified one of these will work - try { - wfIncrStats( "rl-$filter-cache-misses" ); - switch ( $filter ) { - case 'minify-js': - $result = JavaScriptMinifier::minify( $data, - $this->config->get( 'ResourceLoaderMinifierStatementsOnOwnLine' ), - $this->config->get( 'ResourceLoaderMinifierMaxLineLength' ) - ); - if ( $cacheReport ) { - $result .= "\n/* cache key: $key */"; - } - break; - case 'minify-css': - $result = CSSMin::minify( $data ); - if ( $cacheReport ) { - $result .= "\n/* cache key: $key */"; - } - break; + if ( !$options['cache'] ) { + $result = self::applyFilter( $filter, $data, $this->config ); + } else { + $key = wfGlobalCacheKey( 'resourceloader', 'filter', $filter, self::$filterCacheVersion, md5( $data ) ); + $cache = ObjectCache::newAccelerator( CACHE_ANYTHING ); + $cacheEntry = $cache->get( $key ); + if ( is_string( $cacheEntry ) ) { + $stats->increment( "resourceloader_cache.$filter.hit" ); + return $cacheEntry; + } + $result = ''; + try { + $statStart = microtime( true ); + $result = self::applyFilter( $filter, $data, $this->config ); + $statTiming = microtime( true ) - $statStart; + $stats->increment( "resourceloader_cache.$filter.miss" ); + $stats->timing( "resourceloader_cache.$filter.timing", 1000 * $statTiming ); + if ( $options['cacheReport'] ) { + $result .= "\n/* cache key: $key */"; + } + // Set a TTL since HHVM's APC doesn't have any limitation or eviction logic. + $cache->set( $key, $result, 24 * 3600 ); + } catch ( Exception $e ) { + MWExceptionHandler::logException( $e ); + $this->logger->warning( 'Minification failed: {exception}', array( + 'exception' => $e + ) ); + $this->errors[] = self::formatExceptionNoComment( $e ); } - - // Save filtered text to Memcached - $cache->set( $key, $result ); - } catch ( Exception $e ) { - MWExceptionHandler::logException( $e ); - wfDebugLog( 'resourceloader', __METHOD__ . ": minification failed: $e" ); - $this->errors[] = self::formatExceptionNoComment( $e ); } return $result; } + private static function applyFilter( $filter, $data, Config $config ) { + switch ( $filter ) { + case 'minify-js': + return JavaScriptMinifier::minify( $data, + $config->get( 'ResourceLoaderMinifierStatementsOnOwnLine' ), + $config->get( 'ResourceLoaderMinifierMaxLineLength' ) + ); + case 'minify-css': + return CSSMin::minify( $data ); + } + + return $data; + } + /* Methods */ /** * Register core modules and runs registration hooks. * @param Config|null $config */ - public function __construct( Config $config = null ) { + public function __construct( Config $config = null, LoggerInterface $logger = null ) { global $IP; - if ( $config === null ) { - wfDebug( __METHOD__ . ' was called without providing a Config instance' ); - $config = ConfigFactory::getDefaultInstance()->makeConfig( 'main' ); + if ( !$logger ) { + $logger = new NullLogger(); } + $this->setLogger( $logger ); + if ( !$config ) { + $this->logger->debug( __METHOD__ . ' was called without providing a Config instance' ); + $config = ConfigFactory::getDefaultInstance()->makeConfig( 'main' ); + } $this->config = $config; // Add 'local' source first @@ -247,9 +281,10 @@ class ResourceLoader { // Register core modules $this->register( include "$IP/resources/Resources.php" ); + $this->register( include "$IP/resources/ResourcesOOUI.php" ); // Register extension modules - Hooks::run( 'ResourceLoaderRegisterModules', array( &$this ) ); $this->register( $config->get( 'ResourceModules' ) ); + Hooks::run( 'ResourceLoaderRegisterModules', array( &$this ) ); if ( $config->get( 'EnableJavaScriptTest' ) === true ) { $this->registerTestModules(); @@ -265,9 +300,21 @@ class ResourceLoader { return $this->config; } + public function setLogger( LoggerInterface $logger ) { + $this->logger = $logger; + } + + /** + * @since 1.26 + * @return MessageBlobStore + */ + public function getMessageBlobStore() { + return $this->blobStore; + } + /** - * @param MessageBlobStore $blobStore * @since 1.25 + * @param MessageBlobStore $blobStore */ public function setMessageBlobStore( MessageBlobStore $blobStore ) { $this->blobStore = $blobStore; @@ -565,20 +612,45 @@ class ResourceLoader { return $this->sources[$source]; } + /** + * @since 1.26 + * @param string $value + * @return string Hash + */ + public static function makeHash( $value ) { + // Use base64 to output more entropy in a more compact string (default hex is only base16). + // The first 8 chars of a base64 encoded digest represent the same binary as + // the first 12 chars of a hex encoded digest. + return substr( base64_encode( sha1( $value, true ) ), 0, 8 ); + } + + /** + * Helper method to get and combine versions of multiple modules. + * + * @since 1.26 + * @param ResourceLoaderContext $context + * @param array $modules List of ResourceLoaderModule objects + * @return string Hash + */ + public function getCombinedVersion( ResourceLoaderContext $context, Array $modules ) { + if ( !$modules ) { + return ''; + } + // Support: PHP 5.3 ("$this" for anonymous functions was added in PHP 5.4.0) + // http://php.net/functions.anonymous + $rl = $this; + $hashes = array_map( function ( $module ) use ( $rl, $context ) { + return $rl->getModule( $module )->getVersionHash( $context ); + }, $modules ); + return self::makeHash( implode( $hashes ) ); + } + /** * Output a response to a load request, including the content-type header. * * @param ResourceLoaderContext $context Context in which a response should be formed */ public function respond( ResourceLoaderContext $context ) { - // Use file cache if enabled and available... - if ( $this->config->get( 'UseFileCache' ) ) { - $fileCache = ResourceFileCache::newFromContext( $context ); - if ( $this->tryRespondFromFileCache( $fileCache, $context ) ) { - return; // output handled - } - } - // Buffer output to catch warnings. Normally we'd use ob_clean() on the // top-level output buffer to clear warnings, but that breaks when ob_gzhandler // is used: ob_clean() will clear the GZIP header in that case and it won't come @@ -597,7 +669,7 @@ class ResourceLoader { // Do not allow private modules to be loaded from the web. // This is a security issue, see bug 34907. if ( $module->getGroup() === 'private' ) { - wfDebugLog( 'resourceloader', __METHOD__ . ": request for private module '$name' denied" ); + $this->logger->debug( "Request for private module '$name' denied" ); $this->errors[] = "Cannot show private module \"$name\""; continue; } @@ -607,37 +679,46 @@ class ResourceLoader { } } - // Preload information needed to the mtime calculation below try { + // Preload for getCombinedVersion() $this->preloadModuleInfo( array_keys( $modules ), $context ); } catch ( Exception $e ) { MWExceptionHandler::logException( $e ); - wfDebugLog( 'resourceloader', __METHOD__ . ": preloading module info failed: $e" ); + $this->logger->warning( 'Preloading module info failed: {exception}', array( + 'exception' => $e + ) ); $this->errors[] = self::formatExceptionNoComment( $e ); } - // To send Last-Modified and support If-Modified-Since, we need to detect - // the last modified time - $mtime = wfTimestamp( TS_UNIX, $this->config->get( 'CacheEpoch' ) ); - foreach ( $modules as $module ) { - /** - * @var $module ResourceLoaderModule - */ - try { - // Calculate maximum modified time - $mtime = max( $mtime, $module->getModifiedTime( $context ) ); - } catch ( Exception $e ) { - MWExceptionHandler::logException( $e ); - wfDebugLog( 'resourceloader', __METHOD__ . ": calculating maximum modified time failed: $e" ); - $this->errors[] = self::formatExceptionNoComment( $e ); - } + // Combine versions to propagate cache invalidation + $versionHash = ''; + try { + $versionHash = $this->getCombinedVersion( $context, array_keys( $modules ) ); + } catch ( Exception $e ) { + MWExceptionHandler::logException( $e ); + $this->logger->warning( 'Calculating version hash failed: {exception}', array( + 'exception' => $e + ) ); + $this->errors[] = self::formatExceptionNoComment( $e ); } - // If there's an If-Modified-Since header, respond with a 304 appropriately - if ( $this->tryRespondLastModified( $context, $mtime ) ) { + // See RFC 2616 § 3.11 Entity Tags + // http://www.w3.org/Protocols/rfc2616/rfc2616-sec3.html#sec3.11 + $etag = 'W/"' . $versionHash . '"'; + + // Try the client-side cache first + if ( $this->tryRespondNotModified( $context, $etag ) ) { return; // output handled (buffers cleared) } + // Use file cache if enabled and available... + if ( $this->config->get( 'UseFileCache' ) ) { + $fileCache = ResourceFileCache::newFromContext( $context ); + if ( $this->tryRespondFromFileCache( $fileCache, $context, $etag ) ) { + return; // output handled + } + } + // Generate a response $response = $this->makeModuleResponse( $context, $modules, $missing ); @@ -659,26 +740,25 @@ class ResourceLoader { } } - // Send content type and cache related headers - $this->sendResponseHeaders( $context, $mtime, (bool)$this->errors ); + $this->sendResponseHeaders( $context, $etag, (bool)$this->errors ); // Remove the output buffer and output the response ob_end_clean(); if ( $context->getImageObj() && $this->errors ) { // We can't show both the error messages and the response when it's an image. - $errorText = ''; - foreach ( $this->errors as $error ) { - $errorText .= $error . "\n"; - } - $response = $errorText; + $response = implode( "\n\n", $this->errors ); } elseif ( $this->errors ) { - // Prepend comments indicating errors - $errorText = ''; - foreach ( $this->errors as $error ) { - $errorText .= self::makeComment( $error ); + $errorText = implode( "\n\n", $this->errors ); + $errorResponse = self::makeComment( $errorText ); + if ( $context->shouldIncludeScripts() ) { + $errorResponse .= 'if (window.console && console.error) {' + . Xml::encodeJsCall( 'console.error', array( $errorText ) ) + . "}\n"; } - $response = $errorText . $response; + + // Prepend error info to the response + $response = $errorResponse . $response; } $this->errors = array(); @@ -687,13 +767,16 @@ class ResourceLoader { } /** - * Send content type and last modified headers to the client. + * Send main response headers to the client. + * + * Deals with Content-Type, CORS (for stylesheets), and caching. + * * @param ResourceLoaderContext $context - * @param string $mtime TS_MW timestamp to use for last-modified + * @param string $etag ETag header value * @param bool $errors Whether there are errors in the response * @return void */ - protected function sendResponseHeaders( ResourceLoaderContext $context, $mtime, $errors ) { + protected function sendResponseHeaders( ResourceLoaderContext $context, $etag, $errors ) { $rlMaxage = $this->config->get( 'ResourceLoaderMaxage' ); // If a version wasn't specified we need a shorter expiry time for updates // to propagate to clients quickly @@ -720,7 +803,9 @@ class ResourceLoader { } else { header( 'Content-Type: text/javascript; charset=utf-8' ); } - header( 'Last-Modified: ' . wfTimestamp( TS_RFC2822, $mtime ) ); + // See RFC 2616 § 14.19 ETag + // http://www.w3.org/Protocols/rfc2616/rfc2616-sec14.html#sec14.19 + header( 'ETag: ' . $etag ); if ( $context->getDebug() ) { // Do not cache debug responses header( 'Cache-Control: private, no-cache, must-revalidate' ); @@ -733,39 +818,36 @@ class ResourceLoader { } /** - * Respond with 304 Last Modified if appropiate. + * Respond with HTTP 304 Not Modified if appropiate. * - * If there's an If-Modified-Since header, respond with a 304 appropriately + * If there's an If-None-Match header, respond with a 304 appropriately * and clear out the output buffer. If the client cache is too old then do nothing. * * @param ResourceLoaderContext $context - * @param string $mtime The TS_MW timestamp to check the header against - * @return bool True if 304 header sent and output handled + * @param string $etag ETag header value + * @return bool True if HTTP 304 was sent and output handled */ - protected function tryRespondLastModified( ResourceLoaderContext $context, $mtime ) { - // If there's an If-Modified-Since header, respond with a 304 appropriately - // Some clients send "timestamp;length=123". Strip the part after the first ';' - // so we get a valid timestamp. - $ims = $context->getRequest()->getHeader( 'If-Modified-Since' ); + protected function tryRespondNotModified( ResourceLoaderContext $context, $etag ) { + // See RFC 2616 § 14.26 If-None-Match + // http://www.w3.org/Protocols/rfc2616/rfc2616-sec14.html#sec14.26 + $clientKeys = $context->getRequest()->getHeader( 'If-None-Match', WebRequest::GETHEADER_LIST ); // Never send 304s in debug mode - if ( $ims !== false && !$context->getDebug() ) { - $imsTS = strtok( $ims, ';' ); - if ( $mtime <= wfTimestamp( TS_UNIX, $imsTS ) ) { - // There's another bug in ob_gzhandler (see also the comment at - // the top of this function) that causes it to gzip even empty - // responses, meaning it's impossible to produce a truly empty - // response (because the gzip header is always there). This is - // a problem because 304 responses have to be completely empty - // per the HTTP spec, and Firefox behaves buggily when they're not. - // See also http://bugs.php.net/bug.php?id=51579 - // To work around this, we tear down all output buffering before - // sending the 304. - wfResetOutputBuffers( /* $resetGzipEncoding = */ true ); - - header( 'HTTP/1.0 304 Not Modified' ); - header( 'Status: 304 Not Modified' ); - return true; - } + if ( $clientKeys !== false && !$context->getDebug() && in_array( $etag, $clientKeys ) ) { + // There's another bug in ob_gzhandler (see also the comment at + // the top of this function) that causes it to gzip even empty + // responses, meaning it's impossible to produce a truly empty + // response (because the gzip header is always there). This is + // a problem because 304 responses have to be completely empty + // per the HTTP spec, and Firefox behaves buggily when they're not. + // See also http://bugs.php.net/bug.php?id=51579 + // To work around this, we tear down all output buffering before + // sending the 304. + wfResetOutputBuffers( /* $resetGzipEncoding = */ true ); + + HttpStatus::header( 304 ); + + $this->sendResponseHeaders( $context, $etag, false ); + return true; } return false; } @@ -775,10 +857,13 @@ class ResourceLoader { * * @param ResourceFileCache $fileCache Cache object for this request URL * @param ResourceLoaderContext $context Context in which to generate a response + * @param string $etag ETag header value * @return bool If this found a cache file and handled the response */ protected function tryRespondFromFileCache( - ResourceFileCache $fileCache, ResourceLoaderContext $context + ResourceFileCache $fileCache, + ResourceLoaderContext $context, + $etag ) { $rlMaxage = $this->config->get( 'ResourceLoaderMaxage' ); // Buffer output to catch warnings. @@ -799,16 +884,12 @@ class ResourceLoader { if ( $good ) { $ts = $fileCache->cacheTimestamp(); // Send content type and cache headers - $this->sendResponseHeaders( $context, $ts, false ); - // If there's an If-Modified-Since header, respond with a 304 appropriately - if ( $this->tryRespondLastModified( $context, $ts ) ) { - return false; // output handled (buffers cleared) - } + $this->sendResponseHeaders( $context, $etag, false ); $response = $fileCache->fetchText(); // Capture any PHP warnings from the output buffer and append them to the // response in a comment if we're in debug mode. if ( $context->getDebug() && strlen( $warnings = ob_get_contents() ) ) { - $response = "/*\n$warnings\n*/\n" . $response; + $response = self::makeComment( $warnings ) . $response; } // Remove the output buffer and output the response ob_end_clean(); @@ -854,11 +935,11 @@ class ResourceLoader { protected static function formatExceptionNoComment( $e ) { global $wgShowExceptionDetails; - if ( $wgShowExceptionDetails ) { - return $e->__toString(); - } else { - return wfMessage( 'internalerror' )->text(); + if ( !$wgShowExceptionDetails ) { + return 'Internal error'; } + + return $e->__toString(); } /** @@ -896,17 +977,14 @@ MESSAGE; // Pre-fetch blobs if ( $context->shouldIncludeMessages() ) { try { - $blobs = $this->blobStore->get( $this, $modules, $context->getLanguage() ); + $this->blobStore->get( $this, $modules, $context->getLanguage() ); } catch ( Exception $e ) { MWExceptionHandler::logException( $e ); - wfDebugLog( - 'resourceloader', - __METHOD__ . ": pre-fetching blobs from MessageBlobStore failed: $e" - ); + $this->logger->warning( 'Prefetching MessageBlobStore failed: {exception}', array( + 'exception' => $e + ) ); $this->errors[] = self::formatExceptionNoComment( $e ); } - } else { - $blobs = array(); } foreach ( $missing as $name ) { @@ -916,116 +994,43 @@ MESSAGE; // Generate output $isRaw = false; foreach ( $modules as $name => $module ) { - /** - * @var $module ResourceLoaderModule - */ - try { - $scripts = ''; - if ( $context->shouldIncludeScripts() ) { - // If we are in debug mode, we'll want to return an array of URLs if possible - // However, we can't do this if the module doesn't support it - // We also can't do this if there is an only= parameter, because we have to give - // the module a way to return a load.php URL without causing an infinite loop - if ( $context->getDebug() && !$context->getOnly() && $module->supportsURLLoading() ) { - $scripts = $module->getScriptURLsForDebug( $context ); - } else { - $scripts = $module->getScript( $context ); - // rtrim() because there are usually a few line breaks - // after the last ';'. A new line at EOF, a new line - // added by ResourceLoaderFileModule::readScriptFiles, etc. - if ( is_string( $scripts ) - && strlen( $scripts ) - && substr( rtrim( $scripts ), -1 ) !== ';' - ) { - // Append semicolon to prevent weird bugs caused by files not - // terminating their statements right (bug 27054) - $scripts .= ";\n"; - } - } - } - // Styles - $styles = array(); - if ( $context->shouldIncludeStyles() ) { - // Don't create empty stylesheets like array( '' => '' ) for modules - // that don't *have* any stylesheets (bug 38024). - $stylePairs = $module->getStyles( $context ); - if ( count( $stylePairs ) ) { - // If we are in debug mode without &only= set, we'll want to return an array of URLs - // See comment near shouldIncludeScripts() for more details - if ( $context->getDebug() && !$context->getOnly() && $module->supportsURLLoading() ) { - $styles = array( - 'url' => $module->getStyleURLsForDebug( $context ) - ); - } else { - // Minify CSS before embedding in mw.loader.implement call - // (unless in debug mode) - if ( !$context->getDebug() ) { - foreach ( $stylePairs as $media => $style ) { - // Can be either a string or an array of strings. - if ( is_array( $style ) ) { - $stylePairs[$media] = array(); - foreach ( $style as $cssText ) { - if ( is_string( $cssText ) ) { - $stylePairs[$media][] = $this->filter( 'minify-css', $cssText ); - } - } - } elseif ( is_string( $style ) ) { - $stylePairs[$media] = $this->filter( 'minify-css', $style ); - } - } - } - // Wrap styles into @media groups as needed and flatten into a numerical array - $styles = array( - 'css' => self::makeCombinedStyles( $stylePairs ) - ); - } - } - } - - // Messages - $messagesBlob = isset( $blobs[$name] ) ? $blobs[$name] : '{}'; + $content = $module->getModuleContent( $context ); // Append output switch ( $context->getOnly() ) { case 'scripts': + $scripts = $content['scripts']; if ( is_string( $scripts ) ) { // Load scripts raw... $out .= $scripts; } elseif ( is_array( $scripts ) ) { // ...except when $scripts is an array of URLs - $out .= self::makeLoaderImplementScript( $name, $scripts, array(), array() ); + $out .= self::makeLoaderImplementScript( $name, $scripts, array(), array(), array() ); } break; case 'styles': + $styles = $content['styles']; // We no longer seperate into media, they are all combined now with // custom media type groups into @media .. {} sections as part of the css string. // Module returns either an empty array or a numerical array with css strings. $out .= isset( $styles['css'] ) ? implode( '', $styles['css'] ) : ''; break; - case 'messages': - $out .= self::makeMessageSetScript( new XmlJsCode( $messagesBlob ) ); - break; - case 'templates': - $out .= Xml::encodeJsCall( - 'mw.templates.set', - array( $name, (object)$module->getTemplates() ), - ResourceLoader::inDebugMode() - ); - break; default: $out .= self::makeLoaderImplementScript( $name, - $scripts, - $styles, - new XmlJsCode( $messagesBlob ), - $module->getTemplates() + isset( $content['scripts'] ) ? $content['scripts'] : '', + isset( $content['styles'] ) ? $content['styles'] : array(), + isset( $content['messagesBlob'] ) ? new XmlJsCode( $content['messagesBlob'] ) : array(), + isset( $content['templates'] ) ? $content['templates'] : array() ); break; } } catch ( Exception $e ) { MWExceptionHandler::logException( $e ); - wfDebugLog( 'resourceloader', __METHOD__ . ": generating module package failed: $e" ); + $this->logger->warning( 'Generating module package failed: {exception}', array( + 'exception' => $e + ) ); $this->errors[] = self::formatExceptionNoComment( $e ); // Respond to client with error-state instead of module implementation @@ -1056,11 +1061,19 @@ MESSAGE; } } + $enableFilterCache = true; + if ( count( $modules ) === 1 && reset( $modules ) instanceof ResourceLoaderUserTokensModule ) { + // If we're building the embedded user.tokens, don't cache (T84960) + $enableFilterCache = false; + } + if ( !$context->getDebug() ) { if ( $context->getOnly() === 'styles' ) { $out = $this->filter( 'minify-css', $out ); } else { - $out = $this->filter( 'minify-js', $out ); + $out = $this->filter( 'minify-js', $out, array( + 'cache' => $enableFilterCache + ) ); } } @@ -1084,11 +1097,22 @@ MESSAGE; * @throws MWException * @return string */ - public static function makeLoaderImplementScript( $name, $scripts, $styles, - $messages, $templates + public static function makeLoaderImplementScript( + $name, $scripts, $styles, $messages, $templates ) { if ( is_string( $scripts ) ) { - $scripts = new XmlJsCode( "function ( $, jQuery ) {\n{$scripts}\n}" ); + // Site and user module are a legacy scripts that run in the global scope (no closure). + // Transportation as string instructs mw.loader.implement to use globalEval. + if ( $name === 'site' || $name === 'user' ) { + // Minify manually because the general makeModuleResponse() minification won't be + // effective here due to the script being a string instead of a function. (T107377) + if ( !ResourceLoader::inDebugMode() ) { + $scripts = self::applyFilter( 'minify-js', $scripts, + ConfigFactory::getDefaultInstance()->makeConfig( 'main' ) ); + } + } else { + $scripts = new XmlJsCode( "function ( $, jQuery ) {\n{$scripts}\n}" ); + } } elseif ( !is_array( $scripts ) ) { throw new MWException( 'Invalid scripts error. Array of URLs or string of code expected.' ); } @@ -1098,9 +1122,9 @@ MESSAGE; $module = array( $name, $scripts, - (object) $styles, - (object) $messages, - (object) $templates, + (object)$styles, + (object)$messages, + (object)$templates, ); self::trimArray( $module ); @@ -1193,7 +1217,7 @@ MESSAGE; * and $group as supplied. * * @param string $name Module name - * @param int $version Module version number as a timestamp + * @param string $version Module version hash * @param array $dependencies List of module names on which this module depends * @param string $group Group which the module is in. * @param string $source Source of the module, or 'local' if not foreign. @@ -1265,7 +1289,7 @@ MESSAGE; * Registers modules with the given names and parameters. * * @param string $name Module name - * @param int $version Module version number as a timestamp + * @param string $version Module version hash * @param array $dependencies List of module names on which this module depends * @param string $group Group which the module is in * @param string $source Source of the module, or 'local' if not foreign @@ -1346,11 +1370,30 @@ MESSAGE; * Returns JS code which runs given JS code if the client-side framework is * present. * + * @deprecated since 1.25; use makeInlineScript instead * @param string $script JavaScript code * @return string */ public static function makeLoaderConditionalScript( $script ) { - return "if(window.mw){\n" . trim( $script ) . "\n}"; + return "window.RLQ = window.RLQ || []; window.RLQ.push( function () {\n" . trim( $script ) . "\n} );"; + } + + /** + * Construct an inline script tag with given JS code. + * + * The code will be wrapped in a closure, and it will be executed by ResourceLoader + * only if the client has adequate support for MediaWiki JavaScript code. + * + * @param string $script JavaScript code + * @return WrappedString HTML + */ + public static function makeInlineScript( $script ) { + $js = self::makeLoaderConditionalScript( $script ); + return new WrappedString( + Html::inlineScript( $js ), + "" + ); } /** @@ -1361,11 +1404,13 @@ MESSAGE; * @return string */ public static function makeConfigSetScript( array $configuration ) { - return Xml::encodeJsCall( - 'mw.config.set', - array( $configuration ), - ResourceLoader::inDebugMode() - ); + if ( ResourceLoader::inDebugMode() ) { + return Xml::encodeJsCall( 'mw.config.set', array( $configuration ), true ); + } + + $config = RequestContext::getMain()->getConfig(); + $js = Xml::encodeJsCall( 'mw.config.set', array( $configuration ), false ); + return self::applyFilter( 'minify-js', $js, $config ); } /** @@ -1427,7 +1472,7 @@ MESSAGE; * @param string $source Name of the ResourceLoader source * @param ResourceLoaderContext $context * @param array $extraQuery - * @return string URL to load.php. May be protocol-relative (if $wgLoadScript is procol-relative) + * @return string URL to load.php. May be protocol-relative if $wgLoadScript is, too. */ public function createLoaderURL( $source, ResourceLoaderContext $context, $extraQuery = array() @@ -1435,14 +1480,12 @@ MESSAGE; $query = self::createLoaderQuery( $context, $extraQuery ); $script = $this->getLoadScript( $source ); - // Prevent the IE6 extension check from being triggered (bug 28840) - // by appending a character that's invalid in Windows extensions ('*') - return wfExpandUrl( wfAppendQuery( $script, $query ) . '&*', PROTO_RELATIVE ); + return wfAppendQuery( $script, $query ); } /** * Build a load.php URL - * @deprecated since 1.24, use createLoaderURL instead + * @deprecated since 1.24 Use createLoaderURL() instead * @param array $modules Array of module names (strings) * @param string $lang Language code * @param string $skin Skin name @@ -1453,7 +1496,7 @@ MESSAGE; * @param bool $printable Printable mode * @param bool $handheld Handheld mode * @param array $extraQuery Extra query parameters to add - * @return string URL to load.php. May be protocol-relative (if $wgLoadScript is procol-relative) + * @return string URL to load.php. May be protocol-relative if $wgLoadScript is, too. */ public static function makeLoaderURL( $modules, $lang, $skin, $user = null, $version = null, $debug = false, $only = null, $printable = false, @@ -1465,9 +1508,7 @@ MESSAGE; $only, $printable, $handheld, $extraQuery ); - // Prevent the IE6 extension check from being triggered (bug 28840) - // by appending a character that's invalid in Windows extensions ('*') - return wfExpandUrl( wfAppendQuery( $wgLoadScript, $query ) . '&*', PROTO_RELATIVE ); + return wfAppendQuery( $wgLoadScript, $query ); } /** @@ -1562,27 +1603,23 @@ MESSAGE; * @param Config $config * @throws MWException * @since 1.22 - * @return lessc + * @return Less_Parser */ public static function getLessCompiler( Config $config ) { // When called from the installer, it is possible that a required PHP extension // is missing (at least for now; see bug 47564). If this is the case, throw an // exception (caught by the installer) to prevent a fatal error later on. - if ( !class_exists( 'lessc' ) ) { - throw new MWException( 'MediaWiki requires the lessphp compiler' ); - } - if ( !function_exists( 'ctype_digit' ) ) { - throw new MWException( 'lessc requires the Ctype extension' ); + if ( !class_exists( 'Less_Parser' ) ) { + throw new MWException( 'MediaWiki requires the less.php parser' ); } - $less = new lessc(); - $less->setPreserveComments( true ); - $less->setVariables( self::getLessVars( $config ) ); - $less->setImportDir( $config->get( 'ResourceLoaderLESSImportPaths' ) ); - foreach ( $config->get( 'ResourceLoaderLESSFunctions' ) as $name => $func ) { - $less->registerFunction( $name, $func ); - } - return $less; + $parser = new Less_Parser; + $parser->ModifyVars( self::getLessVars( $config ) ); + $parser->SetImportDirs( array_fill_keys( $config->get( 'ResourceLoaderLESSImportPaths' ), '' ) ); + $parser->SetOption( 'relativeUrls', false ); + $parser->SetCacheDir( $config->get( 'CacheDirectory' ) ?: wfTempDir() ); + + return $parser; } /** diff --git a/includes/resourceloader/ResourceLoaderContext.php b/includes/resourceloader/ResourceLoaderContext.php index a6a7d347..2e1752a6 100644 --- a/includes/resourceloader/ResourceLoaderContext.php +++ b/includes/resourceloader/ResourceLoaderContext.php @@ -22,6 +22,8 @@ * @author Roan Kattouw */ +use MediaWiki\Logger\LoggerFactory; + /** * Object passed around to modules which contains information about the state * of a specific loader request @@ -57,24 +59,26 @@ class ResourceLoaderContext { $this->resourceLoader = $resourceLoader; $this->request = $request; - // Interpret request // List of modules $modules = $request->getVal( 'modules' ); $this->modules = $modules ? self::expandModuleNames( $modules ) : array(); + // Various parameters - $this->skin = $request->getVal( 'skin' ); $this->user = $request->getVal( 'user' ); $this->debug = $request->getFuzzyBool( - 'debug', $resourceLoader->getConfig()->get( 'ResourceLoaderDebug' ) + 'debug', + $resourceLoader->getConfig()->get( 'ResourceLoaderDebug' ) ); - $this->only = $request->getVal( 'only' ); - $this->version = $request->getVal( 'version' ); + $this->only = $request->getVal( 'only', null ); + $this->version = $request->getVal( 'version', null ); $this->raw = $request->getFuzzyBool( 'raw' ); + // Image requests $this->image = $request->getVal( 'image' ); $this->variant = $request->getVal( 'variant' ); $this->format = $request->getVal( 'format' ); + $this->skin = $request->getVal( 'skin' ); $skinnames = Skin::getSkinNames(); // If no skin is specified, or we don't recognize the skin, use the default skin if ( !$this->skin || !isset( $skinnames[$this->skin] ) ) { @@ -123,7 +127,8 @@ class ResourceLoaderContext { */ public static function newDummyContext() { return new self( new ResourceLoader( - ConfigFactory::getDefaultInstance()->makeConfig( 'main' ) + ConfigFactory::getDefaultInstance()->makeConfig( 'main' ), + LoggerFactory::getInstance( 'resourceloader' ) ), new FauxRequest( array() ) ); } @@ -154,7 +159,7 @@ class ResourceLoaderContext { public function getLanguage() { if ( $this->language === null ) { // Must be a valid language code after this point (bug 62849) - $this->language = RequestContext::sanitizeLangCode( $this->request->getVal( 'lang' ) ); + $this->language = RequestContext::sanitizeLangCode( $this->getRequest()->getVal( 'lang' ) ); } return $this->language; } @@ -164,7 +169,7 @@ class ResourceLoaderContext { */ public function getDirection() { if ( $this->direction === null ) { - $this->direction = $this->request->getVal( 'dir' ); + $this->direction = $this->getRequest()->getVal( 'dir' ); if ( !$this->direction ) { // Determine directionality based on user language (bug 6100) $this->direction = Language::factory( $this->getLanguage() )->getDir(); @@ -174,7 +179,7 @@ class ResourceLoaderContext { } /** - * @return string|null + * @return string */ public function getSkin() { return $this->skin; @@ -227,6 +232,8 @@ class ResourceLoaderContext { } /** + * @see ResourceLoaderModule::getVersionHash + * @see OutputPage::makeResourceLoaderLink * @return string|null */ public function getVersion() { @@ -285,7 +292,7 @@ class ResourceLoaderContext { return $this->imageObj; } - $image = $module->getImage( $this->image ); + $image = $module->getImage( $this->image, $this ); if ( !$image ) { return $this->imageObj; } @@ -300,21 +307,21 @@ class ResourceLoaderContext { * @return bool */ public function shouldIncludeScripts() { - return is_null( $this->getOnly() ) || $this->getOnly() === 'scripts'; + return $this->getOnly() === null || $this->getOnly() === 'scripts'; } /** * @return bool */ public function shouldIncludeStyles() { - return is_null( $this->getOnly() ) || $this->getOnly() === 'styles'; + return $this->getOnly() === null || $this->getOnly() === 'styles'; } /** * @return bool */ public function shouldIncludeMessages() { - return is_null( $this->getOnly() ) || $this->getOnly() === 'messages'; + return $this->getOnly() === null; } /** diff --git a/includes/resourceloader/ResourceLoaderEditToolbarModule.php b/includes/resourceloader/ResourceLoaderEditToolbarModule.php index d79174cd..da729fdc 100644 --- a/includes/resourceloader/ResourceLoaderEditToolbarModule.php +++ b/includes/resourceloader/ResourceLoaderEditToolbarModule.php @@ -56,7 +56,7 @@ class ResourceLoaderEditToolbarModule extends ResourceLoaderFileModule { // This is very conveniently formatted and we can pass it right through $vars = $language->getImageFiles(); - // lessc tries to be helpful and parse our variables as LESS source code + // less.php tries to be helpful and parse our variables as LESS source code foreach ( $vars as $key => &$value ) { $value = self::cssSerializeString( $value ); } @@ -65,25 +65,10 @@ class ResourceLoaderEditToolbarModule extends ResourceLoaderFileModule { } /** - * @param ResourceLoaderContext $context - * @return int UNIX timestamp - */ - public function getModifiedTime( ResourceLoaderContext $context ) { - return max( - parent::getModifiedTime( $context ), - $this->getHashMtime( $context ) - ); - } - - /** - * @param ResourceLoaderContext $context - * @return string Hash + * @return bool */ - public function getModifiedHash( ResourceLoaderContext $context ) { - return md5( - parent::getModifiedHash( $context ) . - serialize( $this->getLessVars( $context ) ) - ); + public function enableModuleContentVersion() { + return true; } /** @@ -93,11 +78,11 @@ class ResourceLoaderEditToolbarModule extends ResourceLoaderFileModule { * * @throws MWException * @param ResourceLoaderContext $context - * @return lessc + * @return Less_Parser */ protected function getLessCompiler( ResourceLoaderContext $context = null ) { - $compiler = parent::getLessCompiler(); - $compiler->setVariables( $this->getLessVars( $context ) ); - return $compiler; + $parser = parent::getLessCompiler(); + $parser->ModifyVars( $this->getLessVars( $context ) ); + return $parser; } } diff --git a/includes/resourceloader/ResourceLoaderFileModule.php b/includes/resourceloader/ResourceLoaderFileModule.php index 671098e1..7fbc1cb4 100644 --- a/includes/resourceloader/ResourceLoaderFileModule.php +++ b/includes/resourceloader/ResourceLoaderFileModule.php @@ -143,15 +143,6 @@ class ResourceLoaderFileModule extends ResourceLoaderModule { */ protected $hasGeneratedStyles = false; - /** - * @var array Cache for mtime - * @par Usage: - * @code - * array( [hash] => [mtime], [hash] => [mtime], ... ) - * @endcode - */ - protected $modifiedTime = array(); - /** * @var array Place where readStyleFile() tracks file dependencies * @par Usage: @@ -161,6 +152,12 @@ class ResourceLoaderFileModule extends ResourceLoaderModule { */ protected $localFileRefs = array(); + /** + * @var array Place where readStyleFile() tracks file dependencies for non-existent files. + * Used in tests to detect missing dependencies. + */ + protected $missingLocalFileRefs = array(); + /* Methods */ /** @@ -281,8 +278,9 @@ class ResourceLoaderFileModule extends ResourceLoaderModule { $this->{$member} = $option; break; // Single strings - case 'group': case 'position': + $this->isPositionDefined = true; + case 'group': case 'skipFunction': $this->{$member} = (string)$option; break; @@ -486,10 +484,10 @@ class ResourceLoaderFileModule extends ResourceLoaderModule { /** * Gets list of names of modules this module depends on. - * + * @param ResourceLoaderContext context * @return array List of module names */ - public function getDependencies() { + public function getDependencies( ResourceLoaderContext $context = null ) { return $this->dependencies; } @@ -522,24 +520,28 @@ class ResourceLoaderFileModule extends ResourceLoaderModule { } /** - * Get the last modified timestamp of this module. + * Disable module content versioning. * - * Last modified timestamps are calculated from the highest last modified - * timestamp of this module's constituent files as well as the files it - * depends on. This function is context-sensitive, only performing - * calculations on files relevant to the given language, skin and debug - * mode. + * This class uses getDefinitionSummary() instead, to avoid filesystem overhead + * involved with building the full module content inside a startup request. * - * @param ResourceLoaderContext $context Context in which to calculate - * the modified time - * @return int UNIX timestamp - * @see ResourceLoaderModule::getFileDependencies + * @return bool */ - public function getModifiedTime( ResourceLoaderContext $context ) { - if ( isset( $this->modifiedTime[$context->getHash()] ) ) { - return $this->modifiedTime[$context->getHash()]; - } + public function enableModuleContentVersion() { + return false; + } + /** + * Helper method to gather file hashes for getDefinitionSummary. + * + * This function is context-sensitive, only computing hashes of files relevant to the + * given language, skin, etc. + * + * @see ResourceLoaderModule::getFileDependencies + * @param ResourceLoaderContext $context + * @return array + */ + protected function getFileHashes( ResourceLoaderContext $context ) { $files = array(); // Flatten style files into $files @@ -578,22 +580,10 @@ class ResourceLoaderFileModule extends ResourceLoaderModule { // entry point Less file we already know about. $files = array_values( array_unique( $files ) ); - // If a module is nothing but a list of dependencies, we need to avoid - // giving max() an empty array - if ( count( $files ) === 0 ) { - $this->modifiedTime[$context->getHash()] = 1; - return $this->modifiedTime[$context->getHash()]; - } - - $filesMtime = max( array_map( array( __CLASS__, 'safeFilemtime' ), $files ) ); - - $this->modifiedTime[$context->getHash()] = max( - $filesMtime, - $this->getMsgBlobMtime( $context->getLanguage() ), - $this->getDefinitionMtime( $context ) - ); - - return $this->modifiedTime[$context->getHash()]; + // Don't include keys or file paths here, only the hashes. Including that would needlessly + // cause global cache invalidation when files move or if e.g. the MediaWiki path changes. + // Any significant ordering is already detected by the definition summary. + return array_map( array( __CLASS__, 'safeFileHash' ), $files ); } /** @@ -604,7 +594,17 @@ class ResourceLoaderFileModule extends ResourceLoaderModule { */ public function getDefinitionSummary( ResourceLoaderContext $context ) { $summary = parent::getDefinitionSummary( $context ); + + $options = array(); foreach ( array( + // The following properties are omitted because they don't affect the module reponse: + // - localBasePath (Per T104950; Changes when absolute directory name changes. If + // this affects 'scripts' and other file paths, getFileHashes accounts for that.) + // - remoteBasePath (Per T104950) + // - dependencies (provided via startup module) + // - targets + // - group (provided via startup module) + // - position (only used by OutputPage) 'scripts', 'debugScripts', 'loaderScripts', @@ -612,25 +612,23 @@ class ResourceLoaderFileModule extends ResourceLoaderModule { 'languageScripts', 'skinScripts', 'skinStyles', - 'dependencies', 'messages', - 'targets', 'templates', - 'group', - 'position', 'skipFunction', - 'localBasePath', - 'remoteBasePath', 'debugRaw', 'raw', ) as $member ) { - $summary[$member] = $this->{$member}; + $options[$member] = $this->{$member}; }; + + $summary[] = array( + 'options' => $options, + 'fileHashes' => $this->getFileHashes( $context ), + 'msgBlobMtime' => $this->getMsgBlobMtime( $context->getLanguage() ), + ); return $summary; } - /* Protected Methods */ - /** * @param string|ResourceLoaderFilePath $path * @return string @@ -925,10 +923,14 @@ class ResourceLoaderFileModule extends ResourceLoaderModule { $localDir = dirname( $localPath ); $remoteDir = dirname( $remotePath ); // Get and register local file references - $this->localFileRefs = array_merge( - $this->localFileRefs, - CSSMin::getLocalFileReferences( $style, $localDir ) - ); + $localFileRefs = CSSMin::getAllLocalFileReferences( $style, $localDir ); + foreach ( $localFileRefs as $file ) { + if ( file_exists( $file ) ) { + $this->localFileRefs[] = $file; + } else { + $this->missingLocalFileRefs[] = $file; + } + } return CSSMin::remap( $style, $localDir, $remoteDir, true ); @@ -958,17 +960,17 @@ class ResourceLoaderFileModule extends ResourceLoaderModule { * Keeps track of all used files and adds them to localFileRefs. * * @since 1.22 - * @throws Exception If lessc encounters a parse error + * @throws Exception If less.php encounters a parse error * @param string $fileName File path of LESS source - * @param lessc $compiler Compiler to use, if not default + * @param Less_Parser $parser Compiler to use, if not default * @return string CSS source */ protected function compileLessFile( $fileName, $compiler = null ) { if ( !$compiler ) { $compiler = $this->getLessCompiler(); } - $result = $compiler->compileFile( $fileName ); - $this->localFileRefs += array_keys( $compiler->allParsedFiles() ); + $result = $compiler->parseFile( $fileName )->getCss(); + $this->localFileRefs += array_keys( $compiler->AllParsedFiles() ); return $result; } @@ -980,7 +982,7 @@ class ResourceLoaderFileModule extends ResourceLoaderModule { * @param ResourceLoaderContext $context * @throws MWException * @since 1.24 - * @return lessc + * @return Less_Parser */ protected function getLessCompiler( ResourceLoaderContext $context = null ) { return ResourceLoader::getLessCompiler( $this->getConfig() ); diff --git a/includes/resourceloader/ResourceLoaderForeignApiModule.php b/includes/resourceloader/ResourceLoaderForeignApiModule.php new file mode 100644 index 00000000..7ed08317 --- /dev/null +++ b/includes/resourceloader/ResourceLoaderForeignApiModule.php @@ -0,0 +1,33 @@ +dependencies; + Hooks::run( 'ResourceLoaderForeignApiModules', array( &$dependencies, $context ) ); + return $dependencies; + } +} diff --git a/includes/resourceloader/ResourceLoaderImage.php b/includes/resourceloader/ResourceLoaderImage.php index 12d1e827..2338c902 100644 --- a/includes/resourceloader/ResourceLoaderImage.php +++ b/includes/resourceloader/ResourceLoaderImage.php @@ -54,15 +54,16 @@ class ResourceLoaderImage { $this->variants = $variants; // Expand shorthands: - // array( "en,de,fr" => "foo.svg" ) → array( "en" => "foo.svg", "de" => "foo.svg", "fr" => "foo.svg" ) + // array( "en,de,fr" => "foo.svg" ) + // → array( "en" => "foo.svg", "de" => "foo.svg", "fr" => "foo.svg" ) if ( is_array( $this->descriptor ) && isset( $this->descriptor['lang'] ) ) { foreach ( array_keys( $this->descriptor['lang'] ) as $langList ) { if ( strpos( $langList, ',' ) !== false ) { $this->descriptor['lang'] += array_fill_keys( explode( ',', $langList ), - $this->descriptor['lang'][ $langList ] + $this->descriptor['lang'][$langList] ); - unset( $this->descriptor['lang'][ $langList ] ); + unset( $this->descriptor['lang'][$langList] ); } } } @@ -75,11 +76,15 @@ class ResourceLoaderImage { } ); $extensions = array_unique( $extensions ); if ( count( $extensions ) !== 1 ) { - throw new InvalidArgumentException( "File type for different image files of '$name' not the same" ); + throw new InvalidArgumentException( + "File type for different image files of '$name' not the same" + ); } $ext = $extensions[0]; if ( !isset( self::$fileTypes[$ext] ) ) { - throw new InvalidArgumentException( "Invalid file type for image files of '$name' (valid: svg, png, gif, jpg)" ); + throw new InvalidArgumentException( + "Invalid file type for image files of '$name' (valid: svg, png, gif, jpg)" + ); } $this->extension = $ext; } @@ -117,14 +122,14 @@ class ResourceLoaderImage { * @param ResourceLoaderContext $context Any context * @return string */ - protected function getPath( ResourceLoaderContext $context ) { + public function getPath( ResourceLoaderContext $context ) { $desc = $this->descriptor; if ( is_string( $desc ) ) { return $this->basePath . '/' . $desc; - } elseif ( isset( $desc['lang'][ $context->getLanguage() ] ) ) { - return $this->basePath . '/' . $desc['lang'][ $context->getLanguage() ]; - } elseif ( isset( $desc[ $context->getDirection() ] ) ) { - return $this->basePath . '/' . $desc[ $context->getDirection() ]; + } elseif ( isset( $desc['lang'][$context->getLanguage()] ) ) { + return $this->basePath . '/' . $desc['lang'][$context->getLanguage()]; + } elseif ( isset( $desc[$context->getDirection()] ) ) { + return $this->basePath . '/' . $desc[$context->getDirection()]; } else { return $this->basePath . '/' . $desc['default']; } diff --git a/includes/resourceloader/ResourceLoaderImageModule.php b/includes/resourceloader/ResourceLoaderImageModule.php index bf6a7dd2..8de87f2e 100644 --- a/includes/resourceloader/ResourceLoaderImageModule.php +++ b/includes/resourceloader/ResourceLoaderImageModule.php @@ -28,6 +28,8 @@ */ class ResourceLoaderImageModule extends ResourceLoaderModule { + protected $definition = null; + /** * Local base path, see __construct() * @var string @@ -43,6 +45,9 @@ class ResourceLoaderImageModule extends ResourceLoaderModule { protected $selectorWithVariant = '.{prefix}-{name}-{variant}'; protected $targets = array( 'desktop', 'mobile' ); + /** @var string Position on the page to load this module at */ + protected $position = 'bottom'; + /** * Constructs a new module from an options array. * @@ -57,6 +62,8 @@ class ResourceLoaderImageModule extends ResourceLoaderModule { * array( * // Base path to prepend to all local paths in $options. Defaults to $IP * 'localBasePath' => [base path], + * // Path to JSON file that contains any of the settings below + * 'data' => [file path string] * // CSS class prefix to use in all style rules * 'prefix' => [CSS class prefix], * // Alternatively: Format of CSS selector to use in all style rules @@ -66,21 +73,29 @@ class ResourceLoaderImageModule extends ResourceLoaderModule { * 'selectorWithVariant' => [CSS selector template, variables: {prefix} {name} {variant}], * // List of variants that may be used for the image files * 'variants' => array( + * [theme name] => array( * [variant name] => array( * 'color' => [color string, e.g. '#ffff00'], * 'global' => [boolean, if true, this variant is available * for all images of this type], * ), + * ... + * ), * ... * ), * // List of image files and their options * 'images' => array( - * [file path string], - * [file path string] => array( - * 'name' => [image name string, defaults to file name], + * [theme name] => array( + * [icon name] => array( + * 'file' => [file path string or array whose values are file path strings + * and whose keys are 'default', 'ltr', 'rtl', a single + * language code like 'en', or a list of language codes like + * 'en,de,ar'], * 'variants' => [array of variant name strings, variants * available for this image], * ), + * ... + * ), * ... * ), * ) @@ -90,6 +105,26 @@ class ResourceLoaderImageModule extends ResourceLoaderModule { public function __construct( $options = array(), $localBasePath = null ) { $this->localBasePath = self::extractLocalBasePath( $options, $localBasePath ); + $this->definition = $options; + } + + /** + * Parse definition and external JSON data, if referenced. + */ + protected function loadFromDefinition() { + if ( $this->definition === null ) { + return; + } + + $options = $this->definition; + $this->definition = null; + + if ( isset( $options['data'] ) ) { + $dataPath = $this->localBasePath . '/' . $options['data']; + $data = json_decode( file_get_contents( $dataPath ), true ); + $options = array_merge( $data, $options ); + } + // Accepted combinations: // * prefix // * selector @@ -99,20 +134,30 @@ class ResourceLoaderImageModule extends ResourceLoaderModule { $prefix = isset( $options['prefix'] ) && $options['prefix']; $selector = isset( $options['selector'] ) && $options['selector']; - $selectorWithoutVariant = isset( $options['selectorWithoutVariant'] ) && $options['selectorWithoutVariant']; - $selectorWithVariant = isset( $options['selectorWithVariant'] ) && $options['selectorWithVariant']; + $selectorWithoutVariant = isset( $options['selectorWithoutVariant'] ) + && $options['selectorWithoutVariant']; + $selectorWithVariant = isset( $options['selectorWithVariant'] ) + && $options['selectorWithVariant']; if ( $selectorWithoutVariant && !$selectorWithVariant ) { - throw new InvalidArgumentException( "Given 'selectorWithoutVariant' but no 'selectorWithVariant'." ); + throw new InvalidArgumentException( + "Given 'selectorWithoutVariant' but no 'selectorWithVariant'." + ); } if ( $selectorWithVariant && !$selectorWithoutVariant ) { - throw new InvalidArgumentException( "Given 'selectorWithVariant' but no 'selectorWithoutVariant'." ); + throw new InvalidArgumentException( + "Given 'selectorWithVariant' but no 'selectorWithoutVariant'." + ); } if ( $selector && $selectorWithVariant ) { - throw new InvalidArgumentException( "Incompatible 'selector' and 'selectorWithVariant'+'selectorWithoutVariant' given." ); + throw new InvalidArgumentException( + "Incompatible 'selector' and 'selectorWithVariant'+'selectorWithoutVariant' given." + ); } if ( !$prefix && !$selector && !$selectorWithVariant ) { - throw new InvalidArgumentException( "None of 'prefix', 'selector' or 'selectorWithVariant'+'selectorWithoutVariant' given." ); + throw new InvalidArgumentException( + "None of 'prefix', 'selector' or 'selectorWithVariant'+'selectorWithoutVariant' given." + ); } foreach ( $options as $member => $option ) { @@ -124,9 +169,22 @@ class ResourceLoaderImageModule extends ResourceLoaderModule { "Invalid list error. '$option' given, array expected." ); } + if ( !isset( $option['default'] ) ) { + // Backwards compatibility + $option = array( 'default' => $option ); + } + foreach ( $option as $skin => $data ) { + if ( !is_array( $option ) ) { + throw new InvalidArgumentException( + "Invalid list error. '$option' given, array expected." + ); + } + } $this->{$member} = $option; break; + case 'position': + $this->isPositionDefined = true; case 'prefix': case 'selectorWithoutVariant': case 'selectorWithVariant': @@ -144,6 +202,7 @@ class ResourceLoaderImageModule extends ResourceLoaderModule { * @return string */ public function getPrefix() { + $this->loadFromDefinition(); return $this->prefix; } @@ -152,6 +211,7 @@ class ResourceLoaderImageModule extends ResourceLoaderModule { * @return string */ public function getSelectors() { + $this->loadFromDefinition(); return array( 'selectorWithoutVariant' => $this->selectorWithoutVariant, 'selectorWithVariant' => $this->selectorWithVariant, @@ -161,31 +221,43 @@ class ResourceLoaderImageModule extends ResourceLoaderModule { /** * Get a ResourceLoaderImage object for given image. * @param string $name Image name + * @param ResourceLoaderContext $context * @return ResourceLoaderImage|null */ - public function getImage( $name ) { - $images = $this->getImages(); + public function getImage( $name, ResourceLoaderContext $context ) { + $this->loadFromDefinition(); + $images = $this->getImages( $context ); return isset( $images[$name] ) ? $images[$name] : null; } /** * Get ResourceLoaderImage objects for all images. + * @param ResourceLoaderContext $context * @return ResourceLoaderImage[] Array keyed by image name */ - public function getImages() { + public function getImages( ResourceLoaderContext $context ) { + $skin = $context->getSkin(); if ( !isset( $this->imageObjects ) ) { + $this->loadFromDefinition(); $this->imageObjects = array(); - - foreach ( $this->images as $name => $options ) { + } + if ( !isset( $this->imageObjects[$skin] ) ) { + $this->imageObjects[$skin] = array(); + if ( !isset( $this->images[$skin] ) ) { + $this->images[$skin] = isset( $this->images['default'] ) ? + $this->images['default'] : + array(); + } + foreach ( $this->images[$skin] as $name => $options ) { $fileDescriptor = is_string( $options ) ? $options : $options['file']; $allowedVariants = array_merge( is_array( $options ) && isset( $options['variants'] ) ? $options['variants'] : array(), - $this->getGlobalVariants() + $this->getGlobalVariants( $context ) ); - if ( isset( $this->variants ) ) { + if ( isset( $this->variants[$skin] ) ) { $variantConfig = array_intersect_key( - $this->variants, + $this->variants[$skin], array_fill_keys( $allowedVariants, true ) ); } else { @@ -199,32 +271,40 @@ class ResourceLoaderImageModule extends ResourceLoaderModule { $this->localBasePath, $variantConfig ); - $this->imageObjects[ $image->getName() ] = $image; + $this->imageObjects[$skin][$image->getName()] = $image; } } - return $this->imageObjects; + return $this->imageObjects[$skin]; } /** * Get list of variants in this module that are 'global', i.e., available * for every image regardless of image options. + * @param ResourceLoaderContext $context * @return string[] */ - public function getGlobalVariants() { + public function getGlobalVariants( ResourceLoaderContext $context ) { + $skin = $context->getSkin(); if ( !isset( $this->globalVariants ) ) { + $this->loadFromDefinition(); $this->globalVariants = array(); - - if ( isset( $this->variants ) ) { - foreach ( $this->variants as $name => $config ) { - if ( isset( $config['global'] ) && $config['global'] ) { - $this->globalVariants[] = $name; - } + } + if ( !isset( $this->globalVariants[$skin] ) ) { + $this->globalVariants[$skin] = array(); + if ( !isset( $this->variants[$skin] ) ) { + $this->variants[$skin] = isset( $this->variants['default'] ) ? + $this->variants['default'] : + array(); + } + foreach ( $this->variants[$skin] as $name => $config ) { + if ( isset( $config['global'] ) && $config['global'] ) { + $this->globalVariants[$skin][] = $name; } } } - return $this->globalVariants; + return $this->globalVariants[$skin]; } /** @@ -232,12 +312,14 @@ class ResourceLoaderImageModule extends ResourceLoaderModule { * @return array */ public function getStyles( ResourceLoaderContext $context ) { + $this->loadFromDefinition(); + // Build CSS rules $rules = array(); $script = $context->getResourceLoader()->getLoadScript( $this->getSource() ); $selectors = $this->getSelectors(); - foreach ( $this->getImages() as $name => $image ) { + foreach ( $this->getImages( $context ) as $name => $image ) { $declarations = $this->getCssDeclarations( $image->getDataUri( $context, null, 'original' ), $image->getUrl( $context, $script, null, 'rasterized' ) @@ -303,6 +385,48 @@ class ResourceLoaderImageModule extends ResourceLoaderModule { return false; } + /** + * Get the definition summary for this module. + * + * @param ResourceLoaderContext $context + * @return array + */ + public function getDefinitionSummary( ResourceLoaderContext $context ) { + $this->loadFromDefinition(); + $summary = parent::getDefinitionSummary( $context ); + foreach ( array( + 'localBasePath', + 'images', + 'variants', + 'prefix', + 'selectorWithoutVariant', + 'selectorWithVariant', + ) as $member ) { + $summary[$member] = $this->{$member}; + }; + return $summary; + } + + /** + * Get the last modified timestamp of this module. + * + * @param ResourceLoaderContext $context Context in which to calculate + * the modified time + * @return int UNIX timestamp + */ + public function getModifiedTime( ResourceLoaderContext $context ) { + $this->loadFromDefinition(); + $files = array(); + foreach ( $this->getImages( $context ) as $name => $image ) { + $files[] = $image->getPath( $context ); + } + + $files = array_values( array_unique( $files ) ); + $filesMtime = max( array_map( array( __CLASS__, 'safeFilemtime' ), $files ) ); + + return $filesMtime; + } + /** * Extract a local base path from module definition information. * @@ -324,4 +448,17 @@ class ResourceLoaderImageModule extends ResourceLoaderModule { return $localBasePath; } + + /** + * @return string + */ + public function getPosition() { + $this->loadFromDefinition(); + return $this->position; + } + + public function isPositionDefault() { + $this->loadFromDefinition(); + return parent::isPositionDefault(); + } } diff --git a/includes/resourceloader/ResourceLoaderJqueryMsgModule.php b/includes/resourceloader/ResourceLoaderJqueryMsgModule.php new file mode 100644 index 00000000..f9dfbdc2 --- /dev/null +++ b/includes/resourceloader/ResourceLoaderJqueryMsgModule.php @@ -0,0 +1,66 @@ +getHashMtime( $context ) ); + public function enableModuleContentVersion() { + return true; } /** * @param ResourceLoaderContext $context - * @return string Hash - */ - public function getModifiedHash( ResourceLoaderContext $context ) { - return md5( serialize( $this->getData( $context ) ) ); - } - - /** * @return array */ - public function getDependencies() { + public function getDependencies( ResourceLoaderContext $context = null ) { return array( 'mediawiki.language.init' ); } } diff --git a/includes/resourceloader/ResourceLoaderLanguageNamesModule.php b/includes/resourceloader/ResourceLoaderLanguageNamesModule.php index 55b1f4b1..081c728c 100644 --- a/includes/resourceloader/ResourceLoaderLanguageNamesModule.php +++ b/includes/resourceloader/ResourceLoaderLanguageNamesModule.php @@ -32,7 +32,6 @@ class ResourceLoaderLanguageNamesModule extends ResourceLoaderModule { protected $targets = array( 'desktop', 'mobile' ); - /** * @param ResourceLoaderContext $context * @return array @@ -60,24 +59,19 @@ class ResourceLoaderLanguageNamesModule extends ResourceLoaderModule { ); } - public function getDependencies() { - return array( 'mediawiki.language.init' ); - } - /** * @param ResourceLoaderContext $context - * @return int UNIX timestamp + * @return array */ - public function getModifiedTime( ResourceLoaderContext $context ) { - return max( 1, $this->getHashMtime( $context ) ); + public function getDependencies( ResourceLoaderContext $context = null ) { + return array( 'mediawiki.language.init' ); } /** - * @param ResourceLoaderContext $context - * @return string Hash + * @return bool */ - public function getModifiedHash( ResourceLoaderContext $context ) { - return md5( serialize( $this->getData( $context ) ) ); + public function enableModuleContentVersion() { + return true; } } diff --git a/includes/resourceloader/ResourceLoaderModule.php b/includes/resourceloader/ResourceLoaderModule.php index ed16521b..1d3ffb55 100644 --- a/includes/resourceloader/ResourceLoaderModule.php +++ b/includes/resourceloader/ResourceLoaderModule.php @@ -29,7 +29,6 @@ abstract class ResourceLoaderModule { # Type of resource const TYPE_SCRIPTS = 'scripts'; const TYPE_STYLES = 'styles'; - const TYPE_MESSAGES = 'messages'; const TYPE_COMBINED = 'combined'; # sitewide core module like a skin file or jQuery component @@ -63,6 +62,14 @@ abstract class ResourceLoaderModule { protected $fileDeps = array(); // In-object cache for message blob mtime protected $msgBlobMtime = array(); + // In-object cache for version hash + protected $versionHash = array(); + // In-object cache for module content + protected $contents = array(); + + // Whether the position returned by getPosition() is defined in the module configuration + // and not a default value + protected $isPositionDefined = false; /** * @var Config @@ -284,6 +291,19 @@ abstract class ResourceLoaderModule { return 'bottom'; } + /** + * Whether the position returned by getPosition() is a default value or comes from the module + * definition. This method is meant to be short-lived, and is only useful until classes added + * via addModuleStyles with a default value define an explicit position. See getModuleStyles() + * in OutputPage for the related migration warning. + * + * @return bool + * @since 1.26 + */ + public function isPositionDefault() { + return !$this->isPositionDefined; + } + /** * Whether this module's JS expects to work without the client-side ResourceLoader module. * Returning true from this function will prevent mw.loader.state() call from being @@ -313,9 +333,14 @@ abstract class ResourceLoaderModule { * * To add dependencies dynamically on the client side, use a custom * loader script, see getLoaderScript() + * + * Note: It is expected that $context will be made non-optional in the near + * future. + * + * @param ResourceLoaderContext $context * @return array List of module names as strings */ - public function getDependencies() { + public function getDependencies( ResourceLoaderContext $context = null ) { // Stub, override expected return array(); } @@ -361,16 +386,21 @@ abstract class ResourceLoaderModule { } $dbr = wfGetDB( DB_SLAVE ); - $deps = $dbr->selectField( 'module_deps', 'md_deps', array( + $deps = $dbr->selectField( 'module_deps', + 'md_deps', + array( 'md_module' => $this->getName(), 'md_skin' => $skin, - ), __METHOD__ + ), + __METHOD__ ); + if ( !is_null( $deps ) ) { $this->fileDeps[$skin] = (array)FormatJson::decode( $deps, true ); } else { $this->fileDeps[$skin] = array(); } + return $this->fileDeps[$skin]; } @@ -385,8 +415,7 @@ abstract class ResourceLoaderModule { } /** - * Get the last modification timestamp of the message blob for this - * module in a given language. + * Get the last modification timestamp of the messages in this module for a given language. * @param string $lang Language code * @return int UNIX timestamp */ @@ -397,10 +426,13 @@ abstract class ResourceLoaderModule { } $dbr = wfGetDB( DB_SLAVE ); - $msgBlobMtime = $dbr->selectField( 'msg_resource', 'mr_timestamp', array( + $msgBlobMtime = $dbr->selectField( 'msg_resource', + 'mr_timestamp', + array( 'mr_resource' => $this->getName(), 'mr_lang' => $lang - ), __METHOD__ + ), + __METHOD__ ); // If no blob was found, but the module does have messages, that means we need // to regenerate it. Return NOW @@ -422,143 +454,317 @@ abstract class ResourceLoaderModule { $this->msgBlobMtime[$lang] = $mtime; } - /* Abstract Methods */ - /** - * Get this module's last modification timestamp for a given - * combination of language, skin and debug mode flag. This is typically - * the highest of each of the relevant components' modification - * timestamps. Whenever anything happens that changes the module's - * contents for these parameters, the mtime should increase. - * - * NOTE: The mtime of the module's messages is NOT automatically included. - * If you want this to happen, you'll need to call getMsgBlobMtime() - * yourself and take its result into consideration. - * - * NOTE: The mtime of the module's hash is NOT automatically included. - * If your module provides a getModifiedHash() method, you'll need to call getHashMtime() - * yourself and take its result into consideration. + * Get an array of this module's resources. Ready for serving to the web. * - * @param ResourceLoaderContext $context Context object - * @return int UNIX timestamp - */ - public function getModifiedTime( ResourceLoaderContext $context ) { - return 1; + * @since 1.26 + * @param ResourceLoaderContext $context + * @return array + */ + public function getModuleContent( ResourceLoaderContext $context ) { + $contextHash = $context->getHash(); + // Cache this expensive operation. This calls builds the scripts, styles, and messages + // content which typically involves filesystem and/or database access. + if ( !array_key_exists( $contextHash, $this->contents ) ) { + $this->contents[$contextHash] = $this->buildContent( $context ); + } + return $this->contents[$contextHash]; } /** - * Helper method for calculating when the module's hash (if it has one) changed. + * Bundle all resources attached to this module into an array. * + * @since 1.26 * @param ResourceLoaderContext $context - * @return int UNIX timestamp - */ - public function getHashMtime( ResourceLoaderContext $context ) { - $hash = $this->getModifiedHash( $context ); - if ( !is_string( $hash ) ) { - return 1; + * @return array + */ + final protected function buildContent( ResourceLoaderContext $context ) { + $rl = $context->getResourceLoader(); + $stats = RequestContext::getMain()->getStats(); + $statStart = microtime( true ); + + // Only include properties that are relevant to this context (e.g. only=scripts) + // and that are non-empty (e.g. don't include "templates" for modules without + // templates). This helps prevent invalidating cache for all modules when new + // optional properties are introduced. + $content = array(); + + // Scripts + if ( $context->shouldIncludeScripts() ) { + // If we are in debug mode, we'll want to return an array of URLs if possible + // However, we can't do this if the module doesn't support it + // We also can't do this if there is an only= parameter, because we have to give + // the module a way to return a load.php URL without causing an infinite loop + if ( $context->getDebug() && !$context->getOnly() && $this->supportsURLLoading() ) { + $scripts = $this->getScriptURLsForDebug( $context ); + } else { + $scripts = $this->getScript( $context ); + // rtrim() because there are usually a few line breaks + // after the last ';'. A new line at EOF, a new line + // added by ResourceLoaderFileModule::readScriptFiles, etc. + if ( is_string( $scripts ) + && strlen( $scripts ) + && substr( rtrim( $scripts ), -1 ) !== ';' + ) { + // Append semicolon to prevent weird bugs caused by files not + // terminating their statements right (bug 27054) + $scripts .= ";\n"; + } + } + $content['scripts'] = $scripts; + } + + // Styles + if ( $context->shouldIncludeStyles() ) { + $styles = array(); + // Don't create empty stylesheets like array( '' => '' ) for modules + // that don't *have* any stylesheets (bug 38024). + $stylePairs = $this->getStyles( $context ); + if ( count( $stylePairs ) ) { + // If we are in debug mode without &only= set, we'll want to return an array of URLs + // See comment near shouldIncludeScripts() for more details + if ( $context->getDebug() && !$context->getOnly() && $this->supportsURLLoading() ) { + $styles = array( + 'url' => $this->getStyleURLsForDebug( $context ) + ); + } else { + // Minify CSS before embedding in mw.loader.implement call + // (unless in debug mode) + if ( !$context->getDebug() ) { + foreach ( $stylePairs as $media => $style ) { + // Can be either a string or an array of strings. + if ( is_array( $style ) ) { + $stylePairs[$media] = array(); + foreach ( $style as $cssText ) { + if ( is_string( $cssText ) ) { + $stylePairs[$media][] = + $rl->filter( 'minify-css', $cssText ); + } + } + } elseif ( is_string( $style ) ) { + $stylePairs[$media] = $rl->filter( 'minify-css', $style ); + } + } + } + // Wrap styles into @media groups as needed and flatten into a numerical array + $styles = array( + 'css' => $rl->makeCombinedStyles( $stylePairs ) + ); + } + } + $content['styles'] = $styles; + } + + // Messages + $blobs = $rl->getMessageBlobStore()->get( + $rl, + array( $this->getName() => $this ), + $context->getLanguage() + ); + if ( isset( $blobs[$this->getName()] ) ) { + $content['messagesBlob'] = $blobs[$this->getName()]; } - // Embed the hash itself in the cache key. This allows for a few nifty things: - // - During deployment, servers with old and new versions of the code communicating - // with the same memcached will not override the same key repeatedly increasing - // the timestamp. - // - In case of the definition changing and then changing back in a short period of time - // (e.g. in case of a revert or a corrupt server) the old timestamp and client-side cache - // url will be re-used. - // - If different context-combinations (e.g. same skin, same language or some combination - // thereof) result in the same definition, they will use the same hash and timestamp. - $cache = wfGetCache( CACHE_ANYTHING ); - $key = wfMemcKey( 'resourceloader', 'hashmtime', $this->getName(), $hash ); - - $data = $cache->get( $key ); - if ( is_int( $data ) && $data > 0 ) { - // We've seen this hash before, re-use the timestamp of when we first saw it. - return $data; + $templates = $this->getTemplates(); + if ( $templates ) { + $content['templates'] = $templates; } - $timestamp = time(); - $cache->set( $key, $timestamp ); - return $timestamp; + $statTiming = microtime( true ) - $statStart; + $statName = strtr( $this->getName(), '.', '_' ); + $stats->timing( "resourceloader_build.all", 1000 * $statTiming ); + $stats->timing( "resourceloader_build.$statName", 1000 * $statTiming ); + + return $content; } /** - * Get the hash for whatever this module may contain. + * Get a string identifying the current version of this module in a given context. + * + * Whenever anything happens that changes the module's response (e.g. scripts, styles, and + * messages) this value must change. This value is used to store module responses in cache. + * (Both client-side and server-side.) * - * This is the method subclasses should implement if they want to make - * use of getHashMTime() inside getModifiedTime(). + * It is not recommended to override this directly. Use getDefinitionSummary() instead. + * If overridden, one must call the parent getVersionHash(), append data and re-hash. * + * This method should be quick because it is frequently run by ResourceLoaderStartUpModule to + * propagate changes to the client and effectively invalidate cache. + * + * For backward-compatibility, the following optional data providers are automatically included: + * + * - getModifiedTime() + * - getModifiedHash() + * + * @since 1.26 * @param ResourceLoaderContext $context - * @return string|null Hash - */ - public function getModifiedHash( ResourceLoaderContext $context ) { - return null; + * @return string Hash (should use ResourceLoader::makeHash) + */ + public function getVersionHash( ResourceLoaderContext $context ) { + // The startup module produces a manifest with versions representing the entire module. + // Typically, the request for the startup module itself has only=scripts. That must apply + // only to the startup module content, and not to the module version computed here. + $context = new DerivativeResourceLoaderContext( $context ); + $context->setModules( array() ); + // Version hash must cover all resources, regardless of startup request itself. + $context->setOnly( null ); + // Compute version hash based on content, not debug urls. + $context->setDebug( false ); + + // Cache this somewhat expensive operation. Especially because some classes + // (e.g. startup module) iterate more than once over all modules to get versions. + $contextHash = $context->getHash(); + if ( !array_key_exists( $contextHash, $this->versionHash ) ) { + + if ( $this->enableModuleContentVersion() ) { + // Detect changes directly + $str = json_encode( $this->getModuleContent( $context ) ); + } else { + // Infer changes based on definition and other metrics + $summary = $this->getDefinitionSummary( $context ); + if ( !isset( $summary['_cacheEpoch'] ) ) { + throw new LogicException( 'getDefinitionSummary must call parent method' ); + } + $str = json_encode( $summary ); + + $mtime = $this->getModifiedTime( $context ); + if ( $mtime !== null ) { + // Support: MediaWiki 1.25 and earlier + $str .= strval( $mtime ); + } + + $mhash = $this->getModifiedHash( $context ); + if ( $mhash !== null ) { + // Support: MediaWiki 1.25 and earlier + $str .= strval( $mhash ); + } + } + + $this->versionHash[$contextHash] = ResourceLoader::makeHash( $str ); + } + return $this->versionHash[$contextHash]; } /** - * Helper method for calculating when this module's definition summary was last changed. + * Whether to generate version hash based on module content. * - * @since 1.23 + * If a module requires database or file system access to build the module + * content, consider disabling this in favour of manually tracking relevant + * aspects in getDefinitionSummary(). See getVersionHash() for how this is used. * - * @param ResourceLoaderContext $context - * @return int UNIX timestamp + * @return bool */ - public function getDefinitionMtime( ResourceLoaderContext $context ) { - $summary = $this->getDefinitionSummary( $context ); - if ( $summary === null ) { - return 1; - } - - $hash = md5( json_encode( $summary ) ); - $cache = wfGetCache( CACHE_ANYTHING ); - $key = wfMemcKey( 'resourceloader', 'moduledefinition', $this->getName(), $hash ); - - $data = $cache->get( $key ); - if ( is_int( $data ) && $data > 0 ) { - // We've seen this hash before, re-use the timestamp of when we first saw it. - return $data; - } - - wfDebugLog( 'resourceloader', __METHOD__ . ": New definition for module " - . "{$this->getName()} in context \"{$context->getHash()}\"" ); - - $timestamp = time(); - $cache->set( $key, $timestamp ); - return $timestamp; + public function enableModuleContentVersion() { + return false; } /** * Get the definition summary for this module. * - * This is the method subclasses should implement if they want to make - * use of getDefinitionMTime() inside getModifiedTime(). + * This is the method subclasses are recommended to use to track values in their + * version hash. Call this in getVersionHash() and pass it to e.g. json_encode. + * + * Subclasses must call the parent getDefinitionSummary() and build on that. + * It is recommended that each subclass appends its own new array. This prevents + * clashes or accidental overwrites of existing keys and gives each subclass + * its own scope for simple array keys. + * + * @code + * $summary = parent::getDefinitionSummary( $context ); + * $summary[] = array( + * 'foo' => 123, + * 'bar' => 'quux', + * ); + * return $summary; + * @endcode * * Return an array containing values from all significant properties of this - * module's definition. Be sure to include things that are explicitly ordered, - * in their actaul order (bug 37812). + * module's definition. * - * Avoid including things that are insiginificant (e.g. order of message - * keys is insignificant and should be sorted to avoid unnecessary cache - * invalidation). + * Be careful not to normalise too much. Especially preserve the order of things + * that carry significance in getScript and getStyles (T39812). * - * Avoid including things already considered by other methods inside your - * getModifiedTime(), such as file mtime timestamps. + * Avoid including things that are insiginificant (e.g. order of message keys is + * insignificant and should be sorted to avoid unnecessary cache invalidation). * - * Serialisation is done using json_encode, which means object state is not - * taken into account when building the hash. This data structure must only - * contain arrays and scalars as values (avoid object instances) which means - * it requires abstraction. + * This data structure must exclusively contain arrays and scalars as values (avoid + * object instances) to allow simple serialisation using json_encode. * - * @since 1.23 + * If modules have a hash or timestamp from another source, that may be incuded as-is. * + * A number of utility methods are available to help you gather data. These are not + * called by default and must be included by the subclass' getDefinitionSummary(). + * + * - getMsgBlobMtime() + * + * @since 1.23 * @param ResourceLoaderContext $context * @return array|null */ public function getDefinitionSummary( ResourceLoaderContext $context ) { return array( - 'class' => get_class( $this ), + '_class' => get_class( $this ), + '_cacheEpoch' => $this->getConfig()->get( 'CacheEpoch' ), ); } + /** + * Get this module's last modification timestamp for a given context. + * + * @deprecated since 1.26 Use getDefinitionSummary() instead + * @param ResourceLoaderContext $context Context object + * @return int|null UNIX timestamp + */ + public function getModifiedTime( ResourceLoaderContext $context ) { + return null; + } + + /** + * Helper method for providing a version hash to getVersionHash(). + * + * @deprecated since 1.26 Use getDefinitionSummary() instead + * @param ResourceLoaderContext $context + * @return string|null Hash + */ + public function getModifiedHash( ResourceLoaderContext $context ) { + return null; + } + + /** + * Back-compat dummy for old subclass implementations of getModifiedTime(). + * + * This method used to use ObjectCache to track when a hash was first seen. That principle + * stems from a time that ResourceLoader could only identify module versions by timestamp. + * That is no longer the case. Use getDefinitionSummary() directly. + * + * @deprecated since 1.26 Superseded by getVersionHash() + * @param ResourceLoaderContext $context + * @return int UNIX timestamp + */ + public function getHashMtime( ResourceLoaderContext $context ) { + if ( !is_string( $this->getModifiedHash( $context ) ) ) { + return 1; + } + // Dummy that is > 1 + return 2; + } + + /** + * Back-compat dummy for old subclass implementations of getModifiedTime(). + * + * @since 1.23 + * @deprecated since 1.26 Superseded by getVersionHash() + * @param ResourceLoaderContext $context + * @return int UNIX timestamp + */ + public function getDefinitionMtime( ResourceLoaderContext $context ) { + if ( $this->getDefinitionSummary( $context ) === null ) { + return 1; + } + // Dummy that is > 1 + return 2; + } + /** * Check whether this module is known to be empty. If a child class * has an easy and cheap way to determine that this module is @@ -587,8 +793,13 @@ abstract class ResourceLoaderModule { protected function validateScriptFile( $fileName, $contents ) { if ( $this->getConfig()->get( 'ResourceLoaderValidateJS' ) ) { // Try for cache hit - // Use CACHE_ANYTHING since filtering is very slow compared to DB queries - $key = wfMemcKey( 'resourceloader', 'jsparse', self::$parseCacheVersion, md5( $contents ) ); + // Use CACHE_ANYTHING since parsing JS is much slower than a DB query + $key = wfMemcKey( + 'resourceloader', + 'jsparse', + self::$parseCacheVersion, + md5( $contents ) + ); $cache = wfGetCache( CACHE_ANYTHING ); $cacheEntry = $cache->get( $key ); if ( is_string( $cacheEntry ) ) { @@ -602,7 +813,8 @@ abstract class ResourceLoaderModule { } catch ( Exception $e ) { // We'll save this to cache to avoid having to validate broken JS over and over... $err = $e->getMessage(); - $result = "throw new Error(" . Xml::encodeJsVar( "JavaScript parse error: $err" ) . ");"; + $result = "mw.log.error(" . + Xml::encodeJsVar( "JavaScript parse error: $err" ) . ");"; } $cache->set( $key, $result ); @@ -623,16 +835,57 @@ abstract class ResourceLoaderModule { } /** - * Safe version of filemtime(), which doesn't throw a PHP warning if the file doesn't exist - * but returns 1 instead. - * @param string $filename File name + * Safe version of filemtime(), which doesn't throw a PHP warning if the file doesn't exist. + * Defaults to 1. + * + * @param string $filePath File path * @return int UNIX timestamp */ - protected static function safeFilemtime( $filename ) { - wfSuppressWarnings(); - $mtime = filemtime( $filename ) ?: 1; - wfRestoreWarnings(); - + protected static function safeFilemtime( $filePath ) { + MediaWiki\suppressWarnings(); + $mtime = filemtime( $filePath ) ?: 1; + MediaWiki\restoreWarnings(); return $mtime; } + + /** + * Compute a non-cryptographic string hash of a file's contents. + * If the file does not exist or cannot be read, returns an empty string. + * + * @since 1.26 Uses MD4 instead of SHA1. + * @param string $filePath File path + * @return string Hash + */ + protected static function safeFileHash( $filePath ) { + static $cache; + + if ( !$cache ) { + $cache = ObjectCache::newAccelerator( CACHE_NONE ); + } + + MediaWiki\suppressWarnings(); + $mtime = filemtime( $filePath ); + MediaWiki\restoreWarnings(); + if ( !$mtime ) { + return ''; + } + + $cacheKey = wfGlobalCacheKey( 'resourceloader', __METHOD__, $filePath ); + $cachedHash = $cache->get( $cacheKey ); + if ( isset( $cachedHash['mtime'] ) && $cachedHash['mtime'] === $mtime ) { + return $cachedHash['hash']; + } + + MediaWiki\suppressWarnings(); + $contents = file_get_contents( $filePath ); + MediaWiki\restoreWarnings(); + if ( !$contents ) { + return ''; + } + + $hash = hash( 'md4', $contents ); + $cache->set( $cacheKey, array( 'mtime' => $mtime, 'hash' => $hash ), 60 * 60 * 24 ); + + return $hash; + } } diff --git a/includes/resourceloader/ResourceLoaderOOUIImageModule.php b/includes/resourceloader/ResourceLoaderOOUIImageModule.php new file mode 100644 index 00000000..8493f9fd --- /dev/null +++ b/includes/resourceloader/ResourceLoaderOOUIImageModule.php @@ -0,0 +1,86 @@ +definition === null ) { + return; + } + + // Core default themes + $themes = array( 'default' => 'mediawiki' ); + $themes += ExtensionRegistry::getInstance()->getAttribute( 'SkinOOUIThemes' ); + + $name = $this->definition['name']; + $rootPath = $this->definition['rootPath']; + + $definition = array(); + foreach ( $themes as $skin => $theme ) { + // TODO Allow extensions to specify this path somehow + $dataPath = $this->localBasePath . '/' . $rootPath . '/' . $theme . '/' . $name . '.json'; + + if ( file_exists( $dataPath ) ) { + $data = json_decode( file_get_contents( $dataPath ), true ); + $fixPath = function ( &$path ) use ( $rootPath, $theme ) { + // TODO Allow extensions to specify this path somehow + $path = $rootPath . '/' . $theme . '/' . $path; + }; + array_walk( $data['images'], function ( &$value ) use ( $fixPath ) { + if ( is_string( $value['file'] ) ) { + $fixPath( $value['file'] ); + } elseif ( is_array( $value['file'] ) ) { + array_walk_recursive( $value['file'], $fixPath ); + } + } ); + } else { + $data = array(); + } + + foreach ( $data as $key => $value ) { + switch ( $key ) { + case 'images': + case 'variants': + $definition[$key][$skin] = $data[$key]; + break; + + default: + if ( !isset( $definition[$key] ) ) { + $definition[$key] = $data[$key]; + } elseif ( $definition[$key] !== $data[$key] ) { + throw new Exception( + "Mismatched OOUI theme definitions are not supported: trying to load $key of $theme theme" + ); + } + break; + } + } + } + + // Fields from definition silently override keys from JSON files + $this->definition += $definition; + + parent::loadFromDefinition(); + } +} diff --git a/includes/resourceloader/ResourceLoaderRawFileModule.php b/includes/resourceloader/ResourceLoaderRawFileModule.php new file mode 100644 index 00000000..d9005fa5 --- /dev/null +++ b/includes/resourceloader/ResourceLoaderRawFileModule.php @@ -0,0 +1,52 @@ +getHashMtime( $context ) ); - } - /** * @param $context ResourceLoaderContext * @return string: Hash diff --git a/includes/resourceloader/ResourceLoaderSpecialCharacterDataModule.php b/includes/resourceloader/ResourceLoaderSpecialCharacterDataModule.php index 5c917091..8170cb1c 100644 --- a/includes/resourceloader/ResourceLoaderSpecialCharacterDataModule.php +++ b/includes/resourceloader/ResourceLoaderSpecialCharacterDataModule.php @@ -35,7 +35,8 @@ class ResourceLoaderSpecialCharacterDataModule extends ResourceLoaderModule { * @return array */ protected function getData() { - return json_decode( file_get_contents( $this->path ) ); + global $IP; + return json_decode( file_get_contents( "$IP/{$this->path}" ) ); } /** @@ -53,25 +54,17 @@ class ResourceLoaderSpecialCharacterDataModule extends ResourceLoaderModule { } /** - * @param ResourceLoaderContext $context - * @return int UNIX timestamp + * @return bool */ - public function getModifiedTime( ResourceLoaderContext $context ) { - return static::safeFilemtime( $this->path ); + public function enableModuleContentVersion() { + return true; } /** * @param ResourceLoaderContext $context - * @return string Hash - */ - public function getModifiedHash( ResourceLoaderContext $context ) { - return md5( serialize( $this->getData() ) ); - } - - /** * @return array */ - public function getDependencies() { + public function getDependencies( ResourceLoaderContext $context = null ) { return array( 'mediawiki.language' ); } diff --git a/includes/resourceloader/ResourceLoaderStartUpModule.php b/includes/resourceloader/ResourceLoaderStartUpModule.php index b2fbae9c..87d8ee20 100644 --- a/includes/resourceloader/ResourceLoaderStartUpModule.php +++ b/includes/resourceloader/ResourceLoaderStartUpModule.php @@ -24,14 +24,10 @@ class ResourceLoaderStartUpModule extends ResourceLoaderModule { - /* Protected Members */ - - protected $modifiedTime = array(); + // Cache for getConfigSettings() as it's called by multiple methods protected $configVars = array(); protected $targets = array( 'desktop', 'mobile' ); - /* Protected Methods */ - /** * @param ResourceLoaderContext $context * @return array @@ -92,6 +88,7 @@ class ResourceLoaderStartUpModule extends ResourceLoaderModule { 'wgContentNamespaces' => MWNamespace::getContentNamespaces(), 'wgSiteName' => $conf->get( 'Sitename' ), 'wgDBname' => $conf->get( 'DBname' ), + 'wgExtraSignatureNamespaces' => $conf->get( 'ExtraSignatureNamespaces' ), 'wgAvailableSkins' => Skin::getSkinNames(), 'wgExtensionAssetsPath' => $conf->get( 'ExtensionAssetsPath' ), // MediaWiki sets cookies to have this prefix by default @@ -104,6 +101,9 @@ class ResourceLoaderStartUpModule extends ResourceLoaderModule { 'wgLegalTitleChars' => Title::convertByteClassToUnicodeClass( Title::legalChars() ), 'wgResourceLoaderStorageVersion' => $conf->get( 'ResourceLoaderStorageVersion' ), 'wgResourceLoaderStorageEnabled' => $conf->get( 'ResourceLoaderStorageEnabled' ), + 'wgResourceLoaderLegacyModules' => self::getLegacyModules(), + 'wgForeignUploadTargets' => $conf->get( 'ForeignUploadTargets' ), + 'wgEnableUploads' => $conf->get( 'EnableUploads' ), ); Hooks::run( 'ResourceLoaderGetConfigVars', array( &$vars ) ); @@ -159,7 +159,7 @@ class ResourceLoaderStartUpModule extends ResourceLoaderModule { * data send to the client. * * @param array &$registryData Modules keyed by name with properties: - * - number 'version' + * - string 'version' * - array 'dependencies' * - string|null 'group' * - string 'source' @@ -191,6 +191,9 @@ class ResourceLoaderStartUpModule extends ResourceLoaderModule { $resourceLoader = $context->getResourceLoader(); $target = $context->getRequest()->getVal( 'target', 'desktop' ); + // Bypass target filter if this request is from a unit test context. To prevent misuse in + // production, this is only allowed if testing is enabled server-side. + $byPassTargetFilter = $this->getConfig()->get( 'EnableJavaScriptTest' ) && $target === 'test'; $out = ''; $registryData = array(); @@ -199,7 +202,7 @@ class ResourceLoaderStartUpModule extends ResourceLoaderModule { foreach ( $resourceLoader->getModuleNames() as $name ) { $module = $resourceLoader->getModule( $name ); $moduleTargets = $module->getTargets(); - if ( !in_array( $target, $moduleTargets ) ) { + if ( !$byPassTargetFilter && !in_array( $target, $moduleTargets ) ) { continue; } @@ -210,31 +213,27 @@ class ResourceLoaderStartUpModule extends ResourceLoaderModule { continue; } - // Coerce module timestamp to UNIX timestamp. - // getModifiedTime() is supposed to return a UNIX timestamp, but custom implementations - // might forget. TODO: Maybe emit warning? - $moduleMtime = wfTimestamp( TS_UNIX, $module->getModifiedTime( $context ) ); + $versionHash = $module->getVersionHash( $context ); + if ( strlen( $versionHash ) !== 8 ) { + // Module implementation either broken or deviated from ResourceLoader::makeHash + // Asserted by tests/phpunit/structure/ResourcesTest. + $versionHash = ResourceLoader::makeHash( $versionHash ); + } $skipFunction = $module->getSkipFunction(); if ( $skipFunction !== null && !ResourceLoader::inDebugMode() ) { $skipFunction = $resourceLoader->filter( 'minify-js', $skipFunction, - // There will potentially be lots of these little string in the registrations + // There will potentially be lots of these little strings in the registrations // manifest, we don't want to blow up the startup module with - // "/* cache key: ... */" all over it in non-debug mode. + // "/* cache key: ... */" all over it. /* cacheReport = */ false ); } - $mtime = max( - $moduleMtime, - wfTimestamp( TS_UNIX, $this->getConfig()->get( 'CacheEpoch' ) ) - ); - $registryData[$name] = array( - // Convert to numbers as wfTimestamp always returns a string, even for TS_UNIX - 'version' => (int) $mtime, - 'dependencies' => $module->getDependencies(), + 'version' => $versionHash, + 'dependencies' => $module->getDependencies( $context ), 'group' => $module->getGroup(), 'source' => $module->getSource(), 'loader' => $module->getLoaderScript(), @@ -263,7 +262,7 @@ class ResourceLoaderStartUpModule extends ResourceLoaderModule { continue; } - // Call mw.loader.register(name, timestamp, dependencies, group, source, skip) + // Call mw.loader.register(name, version, dependencies, group, source, skip) $registrations[] = array( $name, $data['version'], @@ -276,13 +275,11 @@ class ResourceLoaderStartUpModule extends ResourceLoaderModule { } // Register modules - $out .= ResourceLoader::makeLoaderRegisterScript( $registrations ); + $out .= "\n" . ResourceLoader::makeLoaderRegisterScript( $registrations ); return $out; } - /* Methods */ - /** * @return bool */ @@ -299,6 +296,20 @@ class ResourceLoaderStartUpModule extends ResourceLoaderModule { return array( 'jquery', 'mediawiki' ); } + public static function getLegacyModules() { + global $wgIncludeLegacyJavaScript, $wgPreloadJavaScriptMwUtil; + + $legacyModules = array(); + if ( $wgIncludeLegacyJavaScript ) { + $legacyModules[] = 'mediawiki.legacy.wikibits'; + } + if ( $wgPreloadJavaScriptMwUtil ) { + $legacyModules[] = 'mediawiki.util'; + } + + return $legacyModules; + } + /** * Get the load URL of the startup modules. * @@ -309,24 +320,16 @@ class ResourceLoaderStartUpModule extends ResourceLoaderModule { * @return string */ public static function getStartupModulesUrl( ResourceLoaderContext $context ) { + $rl = $context->getResourceLoader(); $moduleNames = self::getStartupModules(); - // Get the latest version - $loader = $context->getResourceLoader(); - $version = 1; - foreach ( $moduleNames as $moduleName ) { - $version = max( $version, - $loader->getModule( $moduleName )->getModifiedTime( $context ) - ); - } - $query = array( 'modules' => ResourceLoader::makePackedModulesString( $moduleNames ), 'only' => 'scripts', 'lang' => $context->getLanguage(), 'skin' => $context->getSkin(), 'debug' => $context->getDebug() ? 'true' : 'false', - 'version' => wfTimestamp( TS_ISO_8601_BASIC, $version ) + 'version' => $rl->getCombinedVersion( $context, $moduleNames ), ); // Ensure uniform query order ksort( $query ); @@ -339,40 +342,25 @@ class ResourceLoaderStartUpModule extends ResourceLoaderModule { */ public function getScript( ResourceLoaderContext $context ) { global $IP; + if ( $context->getOnly() !== 'scripts' ) { + return '/* Requires only=script */'; + } $out = file_get_contents( "$IP/resources/src/startup.js" ); - if ( $context->getOnly() === 'scripts' ) { - // Startup function - $configuration = $this->getConfigSettings( $context ); - $registrations = $this->getModuleRegistrations( $context ); + $pairs = array_map( function ( $value ) { + $value = FormatJson::encode( $value, ResourceLoader::inDebugMode(), FormatJson::ALL_OK ); // Fix indentation - $registrations = str_replace( "\n", "\n\t", trim( $registrations ) ); - $mwMapJsCall = Xml::encodeJsCall( - 'mw.Map', - array( $this->getConfig()->get( 'LegacyJavaScriptGlobals' ) ) - ); - $mwConfigSetJsCall = Xml::encodeJsCall( - 'mw.config.set', - array( $configuration ), - ResourceLoader::inDebugMode() - ); - - $out .= "var startUp = function () {\n" . - "\tmw.config = new " . - $mwMapJsCall . "\n" . - "\t$registrations\n" . - "\t" . $mwConfigSetJsCall . - "};\n"; - - // Conditional script injection - $scriptTag = Html::linkedScript( self::getStartupModulesUrl( $context ) ); - $out .= "if ( isCompatible() ) {\n" . - "\t" . Xml::encodeJsCall( 'document.write', array( $scriptTag ) ) . - "\n}"; - } - - return $out; + $value = str_replace( "\n", "\n\t", $value ); + return $value; + }, array( + '$VARS.wgLegacyJavaScriptGlobals' => $this->getConfig()->get( 'LegacyJavaScriptGlobals' ), + '$VARS.configuration' => $this->getConfigSettings( $context ), + '$VARS.baseModulesUri' => self::getStartupModulesUrl( $context ), + ) ); + $pairs['$CODE.registrations()'] = str_replace( "\n", "\n\t", trim( $this->getModuleRegistrations( $context ) ) ); + + return strtr( $out, $pairs ); } /** @@ -383,59 +371,48 @@ class ResourceLoaderStartUpModule extends ResourceLoaderModule { } /** + * Get the definition summary for this module. + * * @param ResourceLoaderContext $context - * @return array|mixed + * @return array */ - public function getModifiedTime( ResourceLoaderContext $context ) { + public function getDefinitionSummary( ResourceLoaderContext $context ) { global $IP; + $summary = parent::getDefinitionSummary( $context ); + $summary[] = array( + // Detect changes to variables exposed in mw.config (T30899). + 'vars' => $this->getConfigSettings( $context ), + // Changes how getScript() creates mw.Map for mw.config + 'wgLegacyJavaScriptGlobals' => $this->getConfig()->get( 'LegacyJavaScriptGlobals' ), + // Detect changes to the module registrations + 'moduleHashes' => $this->getAllModuleHashes( $context ), - $hash = $context->getHash(); - if ( isset( $this->modifiedTime[$hash] ) ) { - return $this->modifiedTime[$hash]; - } - - // Call preloadModuleInfo() on ALL modules as we're about - // to call getModifiedTime() on all of them - $loader = $context->getResourceLoader(); - $loader->preloadModuleInfo( $loader->getModuleNames(), $context ); - - $time = max( - wfTimestamp( TS_UNIX, $this->getConfig()->get( 'CacheEpoch' ) ), - filemtime( "$IP/resources/src/startup.js" ), - $this->getHashMtime( $context ) + 'fileMtimes' => array( + filemtime( "$IP/resources/src/startup.js" ), + ), ); - - // ATTENTION!: Because of the line below, this is not going to cause - // infinite recursion - think carefully before making changes to this - // code! - // Pre-populate modifiedTime with something because the loop over - // all modules below includes the startup module (this module). - $this->modifiedTime[$hash] = 1; - - foreach ( $loader->getModuleNames() as $name ) { - $module = $loader->getModule( $name ); - $time = max( $time, $module->getModifiedTime( $context ) ); - } - - $this->modifiedTime[$hash] = $time; - return $this->modifiedTime[$hash]; + return $summary; } /** - * Hash of all dynamic data embedded in getScript(). - * - * Detect changes to mw.config settings embedded in #getScript (bug 28899). + * Helper method for getDefinitionSummary(). * * @param ResourceLoaderContext $context - * @return string Hash + * @return string SHA-1 */ - public function getModifiedHash( ResourceLoaderContext $context ) { - $data = array( - 'vars' => $this->getConfigSettings( $context ), - 'wgLegacyJavaScriptGlobals' => $this->getConfig()->get( 'LegacyJavaScriptGlobals' ), - ); - - return md5( serialize( $data ) ); + protected function getAllModuleHashes( ResourceLoaderContext $context ) { + $rl = $context->getResourceLoader(); + // Preload for getCombinedVersion() + $rl->preloadModuleInfo( $rl->getModuleNames(), $context ); + + // ATTENTION: Because of the line below, this is not going to cause infinite recursion. + // Think carefully before making changes to this code! + // Pre-populate versionHash with something because the loop over all modules below includes + // the startup module (this module). + // See ResourceLoaderModule::getVersionHash() for usage of this cache. + $this->versionHash[$context->getHash()] = null; + + return $rl->getCombinedVersion( $context, $rl->getModuleNames() ); } /** diff --git a/includes/resourceloader/ResourceLoaderUserCSSPrefsModule.php b/includes/resourceloader/ResourceLoaderUserCSSPrefsModule.php index 472ceb26..65d770e2 100644 --- a/includes/resourceloader/ResourceLoaderUserCSSPrefsModule.php +++ b/includes/resourceloader/ResourceLoaderUserCSSPrefsModule.php @@ -27,25 +27,13 @@ */ class ResourceLoaderUserCSSPrefsModule extends ResourceLoaderModule { - /* Protected Members */ - - protected $modifiedTime = array(); - protected $origin = self::ORIGIN_CORE_INDIVIDUAL; - /* Methods */ - /** - * @param ResourceLoaderContext $context - * @return array|int|mixed + * @return bool */ - public function getModifiedTime( ResourceLoaderContext $context ) { - $hash = $context->getHash(); - if ( !isset( $this->modifiedTime[$hash] ) ) { - $this->modifiedTime[$hash] = wfTimestamp( TS_UNIX, $context->getUserObj()->getTouched() ); - } - - return $this->modifiedTime[$hash]; + public function enableModuleContentVersion() { + return true; } /** diff --git a/includes/resourceloader/ResourceLoaderUserDefaultsModule.php b/includes/resourceloader/ResourceLoaderUserDefaultsModule.php index 5f4bc16b..eba61edc 100644 --- a/includes/resourceloader/ResourceLoaderUserDefaultsModule.php +++ b/includes/resourceloader/ResourceLoaderUserDefaultsModule.php @@ -26,26 +26,13 @@ */ class ResourceLoaderUserDefaultsModule extends ResourceLoaderModule { - /* Protected Members */ - protected $targets = array( 'desktop', 'mobile' ); - /* Methods */ - - /** - * @param ResourceLoaderContext $context - * @return string Hash - */ - public function getModifiedHash( ResourceLoaderContext $context ) { - return md5( serialize( User::getDefaultOptions() ) ); - } - /** - * @param ResourceLoaderContext $context - * @return int + * @return bool */ - public function getModifiedTime( ResourceLoaderContext $context ) { - return $this->getHashMtime( $context ); + public function enableModuleContentVersion() { + return true; } /** diff --git a/includes/resourceloader/ResourceLoaderUserOptionsModule.php b/includes/resourceloader/ResourceLoaderUserOptionsModule.php index 84c1906d..0847109c 100644 --- a/includes/resourceloader/ResourceLoaderUserOptionsModule.php +++ b/includes/resourceloader/ResourceLoaderUserOptionsModule.php @@ -27,34 +27,23 @@ */ class ResourceLoaderUserOptionsModule extends ResourceLoaderModule { - /* Protected Members */ - - protected $modifiedTime = array(); - protected $origin = self::ORIGIN_CORE_INDIVIDUAL; protected $targets = array( 'desktop', 'mobile' ); - /* Methods */ - /** + * @param ResourceLoaderContext $context * @return array List of module names as strings */ - public function getDependencies() { + public function getDependencies( ResourceLoaderContext $context = null ) { return array( 'user.defaults' ); } /** - * @param ResourceLoaderContext $context - * @return int + * @return bool */ - public function getModifiedTime( ResourceLoaderContext $context ) { - $hash = $context->getHash(); - if ( !isset( $this->modifiedTime[$hash] ) ) { - $this->modifiedTime[$hash] = wfTimestamp( TS_UNIX, $context->getUserObj()->getTouched() ); - } - - return $this->modifiedTime[$hash]; + public function enableModuleContentVersion() { + return true; } /** diff --git a/includes/resourceloader/ResourceLoaderWikiModule.php b/includes/resourceloader/ResourceLoaderWikiModule.php index 7b44cc67..0023de27 100644 --- a/includes/resourceloader/ResourceLoaderWikiModule.php +++ b/includes/resourceloader/ResourceLoaderWikiModule.php @@ -26,15 +26,30 @@ * Abstraction for resource loader modules which pull from wiki pages * * This can only be used for wiki pages in the MediaWiki and User namespaces, - * because of its dependence on the functionality of - * Title::isCssJsSubpage. + * because of its dependence on the functionality of Title::isCssJsSubpage. + * + * This module supports being used as a placeholder for a module on a remote wiki. + * To do so, getDB() must be overloaded to return a foreign database object that + * allows local wikis to query page metadata. + * + * Safe for calls on local wikis are: + * - Option getters: + * - getGroup() + * - getPosition() + * - getPages() + * - Basic methods that strictly involve the foreign database + * - getDB() + * - isKnownEmpty() + * - getTitleInfo() */ class ResourceLoaderWikiModule extends ResourceLoaderModule { + /** @var string Position on the page to load this module at */ + protected $position = 'bottom'; // Origin defaults to users with sitewide authority protected $origin = self::ORIGIN_USER_SITEWIDE; - // In-object cache for title info + // In-process cache for title info protected $titleInfo = array(); // List of page names that contain CSS @@ -50,14 +65,21 @@ class ResourceLoaderWikiModule extends ResourceLoaderModule { * @param array $options For back-compat, this can be omitted in favour of overwriting getPages. */ public function __construct( array $options = null ) { - if ( isset( $options['styles'] ) ) { - $this->styles = $options['styles']; + if ( is_null( $options ) ) { + return; } - if ( isset( $options['scripts'] ) ) { - $this->scripts = $options['scripts']; - } - if ( isset( $options['group'] ) ) { - $this->group = $options['group']; + + foreach ( $options as $member => $option ) { + switch ( $member ) { + case 'position': + $this->isPositionDefined = true; + // Don't break since we need the member set as well + case 'styles': + case 'scripts': + case 'group': + $this->{$member} = $option; + break; + } } } @@ -107,13 +129,13 @@ class ResourceLoaderWikiModule extends ResourceLoaderModule { } /** - * Get the Database object used in getTitleMTimes(). Defaults to the local slave DB - * but subclasses may want to override this to return a remote DB object, or to return - * null if getTitleMTimes() shouldn't access the DB at all. + * Get the Database object used in getTitleInfo(). + * + * Defaults to the local slave DB. Subclasses may want to override this to return a foreign + * database object, or null if getTitleInfo() shouldn't access the database. * - * NOTE: This ONLY works for getTitleMTimes() and getModifiedTime(), NOT FOR ANYTHING ELSE. - * In particular, it doesn't work for getting the content of JS and CSS pages. That functionality - * will use the local DB irrespective of the return value of this method. + * NOTE: This ONLY works for getTitleInfo() and isKnownEmpty(), NOT FOR ANYTHING ELSE. + * In particular, it doesn't work for getContent() or getScript() etc. * * @return IDatabase|null */ @@ -122,10 +144,15 @@ class ResourceLoaderWikiModule extends ResourceLoaderModule { } /** - * @param Title $title + * @param string $title * @return null|string */ - protected function getContent( $title ) { + protected function getContent( $titleText ) { + $title = Title::newFromText( $titleText ); + if ( !$title ) { + return null; + } + $handler = ContentHandler::getForTitle( $title ); if ( $handler->isSupportedFormat( CONTENT_FORMAT_CSS ) ) { $format = CONTENT_FORMAT_CSS; @@ -160,11 +187,7 @@ class ResourceLoaderWikiModule extends ResourceLoaderModule { if ( $options['type'] !== 'script' ) { continue; } - $title = Title::newFromText( $titleText ); - if ( !$title || $title->isRedirect() ) { - continue; - } - $script = $this->getContent( $title ); + $script = $this->getContent( $titleText ); if ( strval( $script ) !== '' ) { $script = $this->validateScriptFile( $titleText, $script ); $scripts .= ResourceLoader::makeComment( $titleText ) . $script . "\n"; @@ -183,12 +206,8 @@ class ResourceLoaderWikiModule extends ResourceLoaderModule { if ( $options['type'] !== 'style' ) { continue; } - $title = Title::newFromText( $titleText ); - if ( !$title || $title->isRedirect() ) { - continue; - } $media = isset( $options['media'] ) ? $options['media'] : 'all'; - $style = $this->getContent( $title ); + $style = $this->getContent( $titleText ); if ( strval( $style ) === '' ) { continue; } @@ -206,37 +225,31 @@ class ResourceLoaderWikiModule extends ResourceLoaderModule { } /** - * @param ResourceLoaderContext $context - * @return int + * Disable module content versioning. + * + * This class does not support generating content outside of a module + * request due to foreign database support. + * + * See getDefinitionSummary() for meta-data versioning. + * + * @return bool */ - public function getModifiedTime( ResourceLoaderContext $context ) { - $modifiedTime = 1; - $titleInfo = $this->getTitleInfo( $context ); - if ( count( $titleInfo ) ) { - $mtimes = array_map( function ( $value ) { - return $value['timestamp']; - }, $titleInfo ); - $modifiedTime = max( $modifiedTime, max( $mtimes ) ); - } - $modifiedTime = max( - $modifiedTime, - $this->getMsgBlobMtime( $context->getLanguage() ), - $this->getDefinitionMtime( $context ) - ); - return $modifiedTime; + public function enableModuleContentVersion() { + return false; } /** - * Get the definition summary for this module. - * * @param ResourceLoaderContext $context * @return array */ public function getDefinitionSummary( ResourceLoaderContext $context ) { - return array( - 'class' => get_class( $this ), + $summary = parent::getDefinitionSummary( $context ); + $summary[] = array( 'pages' => $this->getPages( $context ), + // Includes SHA1 of content + 'titleInfo' => $this->getTitleInfo( $context ), ); + return $summary; } /** @@ -244,33 +257,29 @@ class ResourceLoaderWikiModule extends ResourceLoaderModule { * @return bool */ public function isKnownEmpty( ResourceLoaderContext $context ) { - $titleInfo = $this->getTitleInfo( $context ); - // Bug 68488: For modules in the "user" group, we should actually - // check that the pages are empty (page_len == 0), but for other - // groups, just check the pages exist so that we don't end up - // caching temporarily-blank pages without the appropriate - //