27 use Psr\Log\LoggerAwareInterface;
28 use Psr\Log\LoggerInterface;
29 use Psr\Log\NullLogger;
33 use Wikimedia\Timestamp\ConvertibleTimestamp;
34 use Wikimedia\WrappedString;
104 public const CACHE_VERSION = 8;
107 private const RL_DEP_STORE_PREFIX =
'ResourceLoaderModule';
109 private const RL_MODULE_DEP_TTL = BagOStuff::TTL_WEEK;
112 public const FILTER_NOMIN =
'/*@nomin*/';
123 $entitiesByModule = [];
124 foreach ( $moduleNames as $moduleName ) {
125 $entitiesByModule[$moduleName] =
"$moduleName|$vary";
127 $depsByEntity = $this->depStore->retrieveMulti(
128 self::RL_DEP_STORE_PREFIX,
132 foreach ( $moduleNames as $moduleName ) {
133 $module = $this->
getModule( $moduleName );
135 $entity = $entitiesByModule[$moduleName];
136 $deps = $depsByEntity[$entity];
138 $module->setFileDependencies( $context, $paths );
147 $modulesWithMessages = [];
148 foreach ( $moduleNames as $moduleName ) {
149 $module = $this->
getModule( $moduleName );
150 if ( $module && $module->getMessages() ) {
151 $modulesWithMessages[$moduleName] = $module;
157 $blobs = $store->getBlobs( $modulesWithMessages,
$lang );
158 foreach ( $blobs as $moduleName =>
$blob ) {
159 $modulesWithMessages[$moduleName]->setMessageBlob(
$blob,
$lang );
180 public static function filter( $filter, $data, array $options = [] ) {
181 if ( strpos( $data, self::FILTER_NOMIN ) !==
false ) {
185 if ( isset( $options[
'cache'] ) && $options[
'cache'] ===
false ) {
189 $stats = MediaWikiServices::getInstance()->getStatsdDataFactory();
192 $key =
$cache->makeGlobalKey(
193 'resourceloader-filter',
199 $result =
$cache->get( $key );
200 if ( $result ===
false ) {
201 $stats->increment(
"resourceloader_cache.$filter.miss" );
203 $cache->set( $key, $result, 24 * 3600 );
205 $stats->increment(
"resourceloader_cache.$filter.hit" );
207 if ( $result ===
null ) {
221 $data = trim( $data );
224 $data = ( $filter ===
'minify-css' )
227 }
catch ( Exception $e ) {
243 LoggerInterface
$logger =
null,
246 $this->logger =
$logger ?:
new NullLogger();
247 $services = MediaWikiServices::getInstance();
250 wfDeprecated( __METHOD__ .
' without a Config instance',
'1.34' );
251 $config = $services->getMainConfig();
255 $this->hookContainer = $services->getHookContainer();
256 $this->hookRunner =
new HookRunner( $this->hookContainer );
259 $this->
addSource(
'local', $config->
get(
'LoadScript' ) );
262 $this->
register(
'startup', [
'class' => ResourceLoaderStartUpModule::class ] );
265 new MessageBlobStore( $this, $this->logger, $services->getMainWANObjectCache() )
338 public function register( $name, array $info = null ) {
340 $registrations = is_array( $name ) ? $name : [ $name => $info ];
341 foreach ( $registrations as $name => $info ) {
343 if ( isset( $this->moduleInfos[$name] ) ) {
345 $this->logger->warning(
346 'ResourceLoader duplicate registration warning. ' .
347 'Another module has already been registered as ' . $name
352 if ( !self::isValidModuleName( $name ) ) {
353 throw new InvalidArgumentException(
"ResourceLoader module name '$name' is invalid, "
354 .
"see ResourceLoader::isValidModuleName()" );
356 if ( !is_array( $info ) ) {
357 throw new InvalidArgumentException(
358 'Invalid module info for "' . $name .
'": expected array, got ' . gettype( $info )
363 $this->moduleInfos[$name] = $info;
368 foreach ( $this->moduleSkinStyles as $skinName => $skinStyles ) {
370 if ( isset( $this->moduleInfos[$name][
'skinStyles'][$skinName] ) ) {
376 if ( isset( $skinStyles[$name] ) ) {
377 $paths = (array)$skinStyles[$name];
379 } elseif ( isset( $skinStyles[
'+' . $name] ) ) {
380 $paths = (array)$skinStyles[
'+' . $name];
381 $styleFiles = isset( $this->moduleInfos[$name][
'skinStyles'][
'default'] ) ?
382 (array)$this->moduleInfos[$name][
'skinStyles'][
'default'] :
390 list( $localBasePath, $remoteBasePath ) =
393 foreach ( $paths as
$path ) {
397 $this->moduleInfos[$name][
'skinStyles'][$skinName] = $styleFiles;
410 if ( $this->config->get(
'EnableJavaScriptTest' ) !==
true ) {
411 throw new MWException(
'Attempt to register JavaScript test modules '
412 .
'but <code>$wgEnableJavaScriptTest</code> is false. '
413 .
'Edit your <code>LocalSettings.php</code> to enable it.' );
417 $testModulesMeta = [
'qunit' => [] ];
419 $this->hookRunner->onResourceLoaderTestModules( $testModulesMeta, $this );
422 $testModules = $testModulesMeta[
'qunit']
423 + $extRegistry->getAttribute(
'QUnitTestModules' );
426 foreach ( $testModules as $name => &$module ) {
428 if ( isset( $module[
'dependencies'] ) && is_string( $module[
'dependencies'] ) ) {
429 $module[
'dependencies'] = [ $module[
'dependencies'] ];
433 $module[
'dependencies'][] =
'mediawiki.qunit-testrunner';
440 $testModules = ( include
"$IP/tests/qunit/QUnitTestResources.php" ) + $testModules;
443 $this->
register( $testModules );
463 if ( isset( $this->sources[$id] ) ) {
464 throw new RuntimeException(
'Cannot register source ' . $id .
' twice' );
469 if ( !isset(
$source[
'loadScript'] ) ) {
470 throw new InvalidArgumentException(
'Each source must have a "loadScript" key' );
483 return array_keys( $this->moduleInfos );
505 return isset( $this->moduleInfos[$name] );
520 if ( !isset( $this->modules[$name] ) ) {
521 if ( !isset( $this->moduleInfos[$name] ) ) {
526 $info = $this->moduleInfos[$name];
527 if ( isset( $info[
'factory'] ) ) {
529 $object = call_user_func( $info[
'factory'], $info );
531 $class = $info[
'class'] ?? ResourceLoaderFileModule::class;
533 $object =
new $class( $info );
535 $object->setConfig( $this->
getConfig() );
536 $object->setLogger( $this->logger );
537 $object->setHookContainer( $this->hookContainer );
538 $object->setName( $name );
539 $object->setDependencyAccessCallbacks(
540 [ $this,
'loadModuleDependenciesInternal' ],
541 [ $this,
'saveModuleDependenciesInternal' ]
543 $this->modules[$name] = $object;
546 return $this->modules[$name];
556 $deps = $this->depStore->retrieve( self::RL_DEP_STORE_PREFIX,
"$moduleName|$variant" );
569 $hasPendingUpdate = (bool)$this->depStoreUpdateBuffer;
570 $entity =
"$moduleName|$variant";
572 if ( array_diff( $paths, $priorPaths ) || array_diff( $priorPaths, $paths ) ) {
575 $deps = $this->depStore->newEntityDependencies( $paths, time() );
576 $this->depStoreUpdateBuffer[$entity] = $deps;
578 $this->depStoreUpdateBuffer[$entity] =
null;
580 } elseif ( $priorPaths ) {
582 $this->depStoreUpdateBuffer[$entity] =
'*';
586 if ( !$hasPendingUpdate ) {
589 $this->depStoreUpdateBuffer = [];
596 foreach ( $updatesByEntity as $entity => $update ) {
597 $lockKey =
$cache->makeKey(
'rl-deps', $entity );
598 $scopeLocks[$entity] =
$cache->getScopedLock( $lockKey, 0 );
599 if ( !$scopeLocks[$entity] ) {
603 } elseif ( $update ===
null ) {
604 $entitiesUnreg[] = $entity;
605 } elseif ( $update ===
'*' ) {
606 $entitiesRenew[] = $entity;
608 $depsByEntity[$entity] = $update;
612 $ttl = self::RL_MODULE_DEP_TTL;
613 $this->depStore->storeMulti( self::RL_DEP_STORE_PREFIX, $depsByEntity, $ttl );
614 $this->depStore->remove( self::RL_DEP_STORE_PREFIX, $entitiesUnreg );
615 $this->depStore->renew( self::RL_DEP_STORE_PREFIX, $entitiesRenew, $ttl );
627 if ( !isset( $this->moduleInfos[$name] ) ) {
630 $info = $this->moduleInfos[$name];
631 return !isset( $info[
'factory'] ) && (
633 !isset( $info[
'class'] ) ||
635 $info[
'class'] === ResourceLoaderFileModule::class ||
636 is_subclass_of( $info[
'class'], ResourceLoaderFileModule::class )
658 if ( !isset( $this->sources[
$source] ) ) {
659 throw new UnexpectedValueException(
"Unknown source '$source'" );
661 return $this->sources[
$source];
732 $hash = hash(
'fnv132', $value );
736 Wikimedia\base_convert( $hash, 16, 36, self::HASH_LENGTH ),
753 $this->logger->warning(
755 $context + [
'exception' => $e ]
769 if ( !$moduleNames ) {
772 $hashes = array_map(
function ( $module ) use ( $context ) {
774 return $this->
getModule( $module )->getVersionHash( $context );
775 }
catch ( Exception $e ) {
779 'Calculating version for "{module}" failed: {exception}',
806 wfDeprecated( __METHOD__ .
' without $modules',
'1.34' );
851 if ( $module->getGroup() ===
'private' ) {
853 $this->logger->debug(
"Request for private module '$name' denied" );
854 $this->errors[] =
"Cannot build private module \"$name\"";
866 }
catch ( Exception $e ) {
874 }
catch ( Exception $e ) {
880 $etag =
'W/"' . $versionHash .
'"';
888 if ( $this->config->get(
'UseFileCache' ) ) {
903 $warnings = ob_get_contents();
904 if ( strlen( $warnings ) ) {
905 $this->errors[] = $warnings;
915 if ( $fileCache->isCacheWorthy() ) {
917 $fileCache->saveText( $response );
919 $fileCache->incrMissesRecent( $context->
getRequest() );
930 $response = implode(
"\n\n", $this->errors );
931 } elseif ( $this->errors ) {
932 $errorText = implode(
"\n\n", $this->errors );
935 $errorResponse .=
'if (window.console && console.error) { console.error('
941 $response = $errorResponse . $response;
950 $measure = $timing->
measure(
'responseTime',
'requestStart',
'requestShutdown' );
951 if ( $measure !==
false ) {
952 $stats = MediaWikiServices::getInstance()->getStatsdDataFactory();
953 $stats->timing(
'resourceloader.responseTime', $measure[
'duration'] * 1000 );
971 HeaderCallback::warnIfHeadersSent();
972 $rlMaxage = $this->config->get(
'ResourceLoaderMaxage' );
981 $maxage = $rlMaxage[
'unversioned'];
985 $maxage = $rlMaxage[
'versioned'];
990 header(
'Content-Type: text/plain; charset=utf-8' );
992 $context->
getImageObj()->sendResponseHeaders( $context );
994 } elseif ( $context->
getOnly() ===
'styles' ) {
995 header(
'Content-Type: text/css; charset=utf-8' );
996 header(
'Access-Control-Allow-Origin: *' );
998 header(
'Content-Type: text/javascript; charset=utf-8' );
1002 header(
'ETag: ' . $etag );
1005 header(
'Cache-Control: private, no-cache, must-revalidate' );
1006 header(
'Pragma: no-cache' );
1008 header(
"Cache-Control: public, max-age=$maxage, s-maxage=$maxage" );
1009 header(
'Expires: ' . ConvertibleTimestamp::convert( TS_RFC2822, time() + $maxage ) );
1011 foreach ( $extra as
$header ) {
1031 if ( $clientKeys !==
false && !$context->
getDebug() && in_array( $etag, $clientKeys ) ) {
1064 $rlMaxage = $this->config->get(
'ResourceLoaderMaxage' );
1069 ? $rlMaxage[
'unversioned']
1070 : $rlMaxage[
'versioned'];
1072 $minTime = time() - $maxage;
1073 $good = $fileCache->
isCacheGood( ConvertibleTimestamp::convert( TS_MW, $minTime ) );
1089 $warnings = ob_get_contents();
1090 if ( strlen( $warnings ) ) {
1096 echo $response .
"\n/* Cached {$ts} */";
1114 $encText = str_replace(
'*/',
'* /', $text );
1115 return "/*\n$encText\n*/\n";
1159 array
$modules, array $missing = []
1164 if (
$modules === [] && $missing === [] ) {
1174 $data = $image->getImageData( $context );
1175 if ( $data ===
false ) {
1177 $this->errors[] =
'Image generation failed';
1182 foreach ( $missing as $name ) {
1183 $states[$name] =
'missing';
1186 $filter = $context->
getOnly() ===
'styles' ?
'minify-css' :
'minify-js';
1188 foreach (
$modules as $name => $module ) {
1190 $content = $module->getModuleContent( $context );
1191 $implementKey = $name .
'@' . $module->getVersionHash( $context );
1194 if ( isset(
$content[
'headers'] ) ) {
1195 $this->extraHeaders = array_merge( $this->extraHeaders,
$content[
'headers'] );
1199 switch ( $context->
getOnly() ) {
1202 if ( is_string( $scripts ) ) {
1204 $strContent = $scripts;
1205 } elseif ( is_array( $scripts ) ) {
1222 $strContent = isset( $styles[
'css'] ) ? implode(
'', $styles[
'css'] ) :
'';
1225 $scripts =
$content[
'scripts'] ??
'';
1226 if ( is_string( $scripts ) ) {
1227 if ( $name ===
'site' || $name ===
'user' ) {
1258 if ( $context->
getOnly() ===
'scripts' ) {
1262 $out .= $strContent;
1265 }
catch ( Exception $e ) {
1266 $this->
outputErrorAndLog( $e,
'Generating module package failed: {exception}' );
1269 $states[$name] =
'error';
1279 foreach (
$modules as $name => $module ) {
1280 $states[$name] =
'ready';
1288 $stateScript =
self::filter(
'minify-js', $stateScript );
1293 } elseif ( $states ) {
1294 $this->errors[] =
'Problematic modules: '
1307 $end = substr( $str, -1 );
1308 if ( $end ===
false || $end ===
'' || $end ===
"\n" ) {
1323 $module = $this->
getModule( $moduleName );
1324 if ( in_array( $messageKey, $module->getMessages() ) ) {
1325 $moduleNames[] = $moduleName;
1328 return $moduleNames;
1352 if ( $scripts->value ===
'' ) {
1354 } elseif ( $context->
getDebug() ) {
1355 $scripts =
new XmlJsCode(
"function ( $, jQuery, require, module ) {\n{$scripts->value}\n}" );
1357 $scripts =
new XmlJsCode(
'function($,jQuery,require,module){' . $scripts->value .
'}' );
1359 } elseif ( is_array( $scripts ) && isset( $scripts[
'files'] ) ) {
1360 $files = $scripts[
'files'];
1364 if (
$file[
'type'] ===
'script' ) {
1367 $file =
new XmlJsCode(
"function ( require, module ) {\n{$file['content']}\n}" );
1376 'main' => $scripts[
'main'],
1379 } elseif ( !is_string( $scripts ) && !is_array( $scripts ) ) {
1380 throw new InvalidArgumentException(
'Script must be a string or an array of URLs' );
1405 return 'mw.messages.set('
1419 foreach ( $stylePairs as $media => $styles ) {
1423 $styles = (array)$styles;
1424 foreach ( $styles as $style ) {
1425 $style = trim( $style );
1427 if ( $style !==
'' ) {
1432 if ( $media ===
'' || $media ==
'all' ) {
1434 } elseif ( is_string( $media ) ) {
1435 $out[] =
"@media $media {\n" . str_replace(
"\n",
"\n\t",
"\t" . $style ) .
"}";
1463 $jsonFlags = JSON_UNESCAPED_SLASHES |
1464 JSON_UNESCAPED_UNICODE |
1467 if ( self::inDebugMode() ) {
1468 $jsonFlags |= JSON_PRETTY_PRINT;
1470 return json_encode( $data, $jsonFlags );
1488 return 'mw.loader.state('
1494 foreach ( $obj as $key => $value ) {
1513 private static function trimArray( array &$array ) : void {
1514 $i = count( $array );
1516 if ( $array[$i] ===
null
1517 || $array[$i] === []
1518 || ( $array[$i] instanceof
XmlJsCode && $array[$i]->value ===
'{}' )
1519 || ( $array[$i] instanceof stdClass && self::isEmptyObject( $array[$i] ) )
1521 unset( $array[$i] );
1562 foreach (
$modules as $i => &$module ) {
1564 $index[$module[0]] = $i;
1567 if ( isset( $module[2] ) ) {
1568 foreach ( $module[2] as &$dependency ) {
1569 if ( isset( $index[$dependency] ) ) {
1571 $dependency = $index[$dependency];
1577 array_walk(
$modules, [ self::class,
'trimArray' ] );
1579 return 'mw.loader.register('
1600 return 'mw.loader.addSource('
1613 return '(RLQ=window.RLQ||[]).push(function(){' .
1614 trim( $script ) .
'});';
1627 return '(RLQ=window.RLQ||[]).push(['
1629 .
'function(){' . trim( $script ) .
'}'
1647 if ( $nonce ===
null ) {
1648 wfWarn( __METHOD__ .
" did not get nonce. Will break CSP" );
1649 } elseif ( $nonce !==
false ) {
1653 $escNonce =
' nonce="' . htmlspecialchars( $nonce ) .
'"';
1656 return new WrappedString(
1658 "<script$escNonce>(RLQ=window.RLQ||[]).push(function(){",
1673 if ( $json ===
false ) {
1675 'JSON serialization of config data failed. ' .
1676 'This usually means the config data is not valid UTF-8.'
1681 return "mw.config.set($json);";
1700 $pos = strrpos( $module,
'.' );
1701 $prefix = $pos ===
false ?
'' : substr( $module, 0, $pos );
1702 $suffix = $pos ===
false ? $module : substr( $module, $pos + 1 );
1703 $moduleMap[$prefix][] = $suffix;
1707 foreach ( $moduleMap as $prefix => $suffixes ) {
1708 $p = $prefix ===
'' ?
'' : $prefix .
'.';
1709 $arr[] = $p . implode(
',', $suffixes );
1711 return implode(
'|', $arr );
1727 $exploded = explode(
'|',
$modules );
1728 foreach ( $exploded as $group ) {
1729 if ( strpos( $group,
',' ) ===
false ) {
1735 $pos = strrpos( $group,
'.' );
1736 if ( $pos ===
false ) {
1738 $retval = array_merge( $retval, explode(
',', $group ) );
1741 $prefix = substr( $group, 0, $pos );
1742 $suffixes = explode(
',', substr( $group, $pos + 1 ) );
1743 foreach ( $suffixes as $suffix ) {
1744 $retval[] =
"$prefix.$suffix";
1763 if ( self::$debugMode ===
null ) {
1765 self::$debugMode =
$wgRequest->getFuzzyBool(
'debug',
1783 self::$debugMode =
null;
1796 array $extraQuery = []
1824 $context->
getRequest()->getBool(
'printable' ),
1825 $context->
getRequest()->getBool(
'handheld' ),
1847 $version =
null,
$debug =
false, $only =
null, $printable =
false,
1848 $handheld =
false, array $extraQuery = []
1858 $query[
'lang'] =
$lang;
1861 $query[
'skin'] = $skin;
1864 $query[
'debug'] =
'true';
1866 if ( $user !==
null ) {
1867 $query[
'user'] = $user;
1869 if ( $version !==
null ) {
1870 $query[
'version'] = $version;
1872 if ( $only !==
null ) {
1873 $query[
'only'] = $only;
1876 $query[
'printable'] = 1;
1879 $query[
'handheld'] = 1;
1881 $query += $extraQuery;
1898 $len = strlen( $moduleName );
1899 return $len <= 255 && strcspn( $moduleName,
'!,|', 0, $len ) === $len;
1913 public function getLessCompiler( array $vars = [], array $importDirs = [] ) {
1918 if ( !class_exists(
'Less_Parser' ) ) {
1919 throw new MWException(
'MediaWiki requires the less.php parser' );
1922 $importDirs[] =
"$IP/resources/src/mediawiki.less";
1924 $parser =
new Less_Parser;
1925 $parser->ModifyVars( $vars );
1927 $parser->SetImportDirs( array_fill_keys( $importDirs,
'' ) );
1928 $parser->SetOption(
'relativeUrls',
false );
1947 $contLang = MediaWikiServices::getInstance()->getContentLanguage();
1948 $namespaceIds = $contLang->getNamespaceIds();
1949 $caseSensitiveNamespaces = [];
1950 $nsInfo = MediaWikiServices::getInstance()->getNamespaceInfo();
1951 foreach ( $nsInfo->getCanonicalNamespaces() as $index => $name ) {
1952 $namespaceIds[$contLang->lc( $name )] = $index;
1953 if ( !$nsInfo->isCapitalized( $index ) ) {
1954 $caseSensitiveNamespaces[] = $index;
1958 $illegalFileChars = $conf->
get(
'IllegalFileChars' );
1967 'stylepath' => $conf->
get(
'StylePath' ),
1968 'wgArticlePath' => $conf->
get(
'ArticlePath' ),
1969 'wgScriptPath' => $conf->
get(
'ScriptPath' ),
1970 'wgScript' => $conf->
get(
'Script' ),
1971 'wgSearchType' => $conf->
get(
'SearchType' ),
1972 'wgVariantArticlePath' => $conf->
get(
'VariantArticlePath' ),
1973 'wgServer' => $conf->
get(
'Server' ),
1974 'wgServerName' => $conf->
get(
'ServerName' ),
1976 'wgContentLanguage' => $contLang->getCode(),
1978 'wgFormattedNamespaces' => $contLang->getFormattedNamespaces(),
1979 'wgNamespaceIds' => $namespaceIds,
1980 'wgContentNamespaces' => $nsInfo->getContentNamespaces(),
1981 'wgSiteName' => $conf->
get(
'Sitename' ),
1982 'wgDBname' => $conf->
get(
'DBname' ),
1984 'wgCaseSensitiveNamespaces' => $caseSensitiveNamespaces,
1985 'wgCommentByteLimit' =>
null,
1987 'wgExtensionAssetsPath' => $conf->
get(
'ExtensionAssetsPath' ),
1998 'wgActionPaths' => (object)$conf->
get(
'ActionPaths' ),
2000 'wgTranslateNumerals' => $conf->
get(
'TranslateNumerals' ),
2002 'wgExtraSignatureNamespaces' => $conf->
get(
'ExtraSignatureNamespaces' ),
2004 'wgCookiePrefix' => $conf->
get(
'CookiePrefix' ),
2005 'wgCookieDomain' => $conf->
get(
'CookieDomain' ),
2006 'wgCookiePath' => $conf->
get(
'CookiePath' ),
2007 'wgCookieExpiration' => $conf->
get(
'CookieExpiration' ),
2012 'wgForeignUploadTargets' => $conf->
get(
'ForeignUploadTargets' ),
2013 'wgEnableUploads' => $conf->
get(
'EnableUploads' ),
2016 Hooks::runner()->onResourceLoaderGetConfigVars( $vars, $skin, $conf );