97 public const CACHE_VERSION = 9;
99 public const FILTER_NOMIN =
'/*@nomin*/';
102 private const RL_DEP_STORE_PREFIX =
'ResourceLoaderModule';
104 private const RL_MODULE_DEP_TTL = BagOStuff::TTL_YEAR;
106 private const MAXAGE_RECOVER = 60;
120 private $hookContainer;
126 private $maxageVersioned;
128 private $maxageUnversioned;
133 private $moduleInfos = [];
135 private $testModuleNames = [];
137 private $sources = [];
146 private $depStoreUpdateBuffer = [];
151 private $moduleSkinStyles = [];
175 LoggerInterface $logger =
null,
179 $this->maxageVersioned = $params[
'maxageVersioned'] ?? 30 * 24 * 60 * 60;
180 $this->maxageUnversioned = $params[
'maxageUnversioned'] ?? 5 * 60;
182 $this->config = $config;
183 $this->logger = $logger ?:
new NullLogger();
186 $this->hookContainer = $services->getHookContainer();
188 $this->srvCache = $services->getLocalServerObjectCache();
189 $this->stats = $services->getStatsdDataFactory();
192 $this->
addSource(
'local', $params[
'loadScript'] ??
'/load.php' );
195 $this->
register(
'startup', [
'class' => StartUpModule::class ] );
198 new MessageBlobStore( $this, $this->logger, $services->getMainWANObjectCache() )
209 return $this->config;
217 $this->logger = $logger;
225 return $this->logger;
233 return $this->blobStore;
241 $this->blobStore = $blobStore;
249 $this->depStore = $tracker;
257 $this->moduleSkinStyles = $moduleSkinStyles;
271 public function register( $name, array $info = null ) {
273 $registrations = is_array( $name ) ? $name : [ $name => $info ];
274 foreach ( $registrations as $name => $info ) {
276 if ( isset( $this->moduleInfos[$name] ) ) {
278 $this->logger->warning(
279 'ResourceLoader duplicate registration warning. ' .
280 'Another module has already been registered as ' . $name
285 if ( !self::isValidModuleName( $name ) ) {
286 throw new InvalidArgumentException(
"ResourceLoader module name '$name' is invalid, "
287 .
"see ResourceLoader::isValidModuleName()" );
289 if ( !is_array( $info ) ) {
290 throw new InvalidArgumentException(
291 'Invalid module info for "' . $name .
'": expected array, got ' . gettype( $info )
296 $this->moduleInfos[$name] = $info;
306 $testModules = $extRegistry->
getAttribute(
'QUnitTestModules' );
308 $testModuleNames = [];
309 foreach ( $testModules as $name => &$module ) {
311 if ( isset( $module[
'dependencies'] ) && is_string( $module[
'dependencies'] ) ) {
312 $module[
'dependencies'] = [ $module[
'dependencies'] ];
316 $module[
'dependencies'][] =
'mediawiki.qunit-testrunner';
319 $testModuleNames[] = $name;
323 $testModules = ( include MW_INSTALL_PATH .
'/tests/qunit/QUnitTestResources.php' ) + $testModules;
324 $testModuleNames[] =
'test.MediaWiki';
326 $this->
register( $testModules );
327 $this->testModuleNames = $testModuleNames;
340 public function addSource( $sources, $loadUrl =
null ) {
341 if ( !is_array( $sources ) ) {
342 $sources = [ $sources => $loadUrl ];
344 foreach ( $sources as $id =>
$source ) {
346 if ( isset( $this->sources[$id] ) ) {
347 throw new RuntimeException(
'Cannot register source ' . $id .
' twice' );
352 if ( !isset(
$source[
'loadScript'] ) ) {
353 throw new InvalidArgumentException(
'Each source must have a "loadScript" key' );
366 return array_keys( $this->moduleInfos );
377 return $this->testModuleNames;
388 return isset( $this->moduleInfos[$name] );
403 if ( !isset( $this->modules[$name] ) ) {
404 if ( !isset( $this->moduleInfos[$name] ) ) {
409 $info = $this->moduleInfos[$name];
410 if ( isset( $info[
'factory'] ) ) {
412 $object = call_user_func( $info[
'factory'], $info );
414 $class = $info[
'class'] ?? FileModule::class;
416 $object =
new $class( $info );
418 $object->setConfig( $this->getConfig() );
419 $object->setLogger( $this->logger );
420 $object->setHookContainer( $this->hookContainer );
421 $object->setName( $name );
422 $object->setDependencyAccessCallbacks(
423 [ $this,
'loadModuleDependenciesInternal' ],
424 [ $this,
'saveModuleDependenciesInternal' ]
426 $object->setSkinStylesOverride( $this->moduleSkinStyles );
427 $this->modules[$name] = $object;
430 return $this->modules[$name];
442 $entitiesByModule = [];
443 foreach ( $moduleNames as $moduleName ) {
444 $entitiesByModule[$moduleName] =
"$moduleName|$vary";
446 $depsByEntity = $this->depStore->retrieveMulti(
447 self::RL_DEP_STORE_PREFIX,
451 foreach ( $moduleNames as $moduleName ) {
452 $module = $this->getModule( $moduleName );
454 $entity = $entitiesByModule[$moduleName];
455 $deps = $depsByEntity[$entity];
457 $module->setFileDependencies( $context, $paths );
466 $modulesWithMessages = [];
467 foreach ( $moduleNames as $moduleName ) {
468 $module = $this->getModule( $moduleName );
469 if ( $module && $module->getMessages() ) {
470 $modulesWithMessages[$moduleName] = $module;
475 $store = $this->getMessageBlobStore();
476 $blobs = $store->getBlobs( $modulesWithMessages, $lang );
477 foreach ( $blobs as $moduleName => $blob ) {
478 $modulesWithMessages[$moduleName]->setMessageBlob( $blob, $lang );
489 $deps = $this->depStore->retrieve( self::RL_DEP_STORE_PREFIX,
"$moduleName|$variant" );
502 $hasPendingUpdate = (bool)$this->depStoreUpdateBuffer;
503 $entity =
"$moduleName|$variant";
505 if ( array_diff( $paths, $priorPaths ) || array_diff( $priorPaths, $paths ) ) {
508 $deps = $this->depStore->newEntityDependencies( $paths, time() );
509 $this->depStoreUpdateBuffer[$entity] = $deps;
511 $this->depStoreUpdateBuffer[$entity] =
null;
521 if ( !$hasPendingUpdate ) {
522 DeferredUpdates::addCallableUpdate(
function () {
523 $updatesByEntity = $this->depStoreUpdateBuffer;
524 $this->depStoreUpdateBuffer = [];
525 $cache = ObjectCache::getLocalClusterInstance();
530 foreach ( $updatesByEntity as $entity => $update ) {
531 $lockKey = $cache->makeKey(
'rl-deps', $entity );
532 $scopeLocks[$entity] = $cache->getScopedLock( $lockKey, 0 );
533 if ( !$scopeLocks[$entity] ) {
538 if ( $update ===
null ) {
539 $entitiesUnreg[] = $entity;
541 $depsByEntity[$entity] = $update;
545 $ttl = self::RL_MODULE_DEP_TTL;
546 $this->depStore->storeMulti( self::RL_DEP_STORE_PREFIX, $depsByEntity, $ttl );
547 $this->depStore->remove( self::RL_DEP_STORE_PREFIX, $entitiesUnreg );
558 return $this->sources;
570 if ( !isset( $this->sources[
$source] ) ) {
571 throw new UnexpectedValueException(
"Unknown source '$source'" );
573 return $this->sources[
$source];
579 public const HASH_LENGTH = 5;
644 $hash = hash(
'fnv132', $value );
648 \
Wikimedia\base_convert( $hash, 16, 36, self::HASH_LENGTH ),
664 MWExceptionHandler::logException( $e );
665 $this->logger->warning(
667 $context + [
'exception' => $e ]
669 $this->errors[] = self::formatExceptionNoComment( $e );
681 if ( !$moduleNames ) {
685 foreach ( $moduleNames as $module ) {
687 $hash = $this->getModule( $module )->getVersionHash( $context );
688 }
catch ( TimeoutException $e ) {
690 }
catch ( Exception $e ) {
693 $this->outputErrorAndLog( $e,
694 'Calculating version for "{module}" failed: {exception}',
703 return self::makeHash( implode(
'', $hashes ) );
728 if ( !$this->getModule( $name ) ) {
735 return $this->getCombinedVersion( $context, $filtered );
754 $responseTime = $this->measureResponseTime();
761 $module = $this->getModule( $name );
765 if ( $module->getGroup() === Module::GROUP_PRIVATE ) {
767 $this->logger->debug(
"Request for private module '$name' denied" );
768 $this->errors[] =
"Cannot build private module \"$name\"";
779 $this->preloadModuleInfo( array_keys(
$modules ), $context );
780 }
catch ( TimeoutException $e ) {
782 }
catch ( Exception $e ) {
783 $this->outputErrorAndLog( $e,
'Preloading module info failed: {exception}' );
787 $versionHash = $this->getCombinedVersion( $context, array_keys(
$modules ) );
791 $etag =
'W/"' . $versionHash .
'"';
794 if ( $this->tryRespondNotModified( $context, $etag ) ) {
802 $this->sendSourceMapVersionMismatch( $versionHash );
807 || $context->
getOnly() ===
'styles'
811 $this->sendSourceMapTypeNotImplemented();
822 $this->extraHeaders[] =
'SourceMap: ' . $this->getSourceMapUrl( $context, $versionHash );
826 $response = $this->makeModuleResponse( $context,
$modules, $missing );
831 $warnings = ob_get_contents();
832 if ( strlen( $warnings ) ) {
833 $this->errors[] = $warnings;
837 $this->sendResponseHeaders( $context, $etag, (
bool)$this->errors, $this->extraHeaders );
844 $response = implode(
"\n\n", $this->errors );
845 } elseif ( $this->errors ) {
846 $errorText = implode(
"\n\n", $this->errors );
847 $errorResponse = self::makeComment( $errorText );
849 $errorResponse .=
'if (window.console && console.error) { console.error('
854 $response .= $errorResponse;
857 $response = $errorResponse . $response;
870 $statStart = $_SERVER[
'REQUEST_TIME_FLOAT'];
871 return new ScopedCallback(
function () use ( $statStart ) {
872 $statTiming = microtime(
true ) - $statStart;
873 $this->stats->timing(
'resourceloader.responseTime', $statTiming * 1000 );
888 Context $context, $etag, $errors, array $extra = []
893 $maxage = self::MAXAGE_RECOVER;
902 $this->logger->debug(
'Client and server registry version out of sync' );
903 $maxage = self::MAXAGE_RECOVER;
904 } elseif ( $context->
getVersion() ===
null ) {
908 $maxage = $this->maxageUnversioned;
912 $maxage = $this->maxageVersioned;
917 header(
'Content-Type: text/plain; charset=utf-8' );
919 $context->
getImageObj()->sendResponseHeaders( $context );
922 header(
'Content-Type: application/json' );
923 } elseif ( $context->
getOnly() ===
'styles' ) {
924 header(
'Content-Type: text/css; charset=utf-8' );
925 header(
'Access-Control-Allow-Origin: *' );
927 header(
'Content-Type: text/javascript; charset=utf-8' );
931 header(
'ETag: ' . $etag );
934 header(
'Cache-Control: private, no-cache, must-revalidate' );
938 $staleDirective = ( $maxage > self::MAXAGE_RECOVER
939 ?
", stale-while-revalidate=" . min( 60, intval( $maxage / 2 ) )
942 header(
"Cache-Control: public, max-age=$maxage, s-maxage=$maxage" . $staleDirective );
943 header(
'Expires: ' . ConvertibleTimestamp::convert( TS_RFC2822, time() + $maxage ) );
945 foreach ( $extra as
$header ) {
963 $clientKeys = $context->
getRequest()->getHeader(
'If-None-Match', WebRequest::GETHEADER_LIST );
965 if ( $clientKeys !==
false && !$context->
getDebug() && in_array( $etag, $clientKeys ) ) {
977 HttpStatus::header( 304 );
979 $this->sendResponseHeaders( $context, $etag,
false );
992 private function getSourceMapUrl(
Context $context, $version ) {
993 return $this->createLoaderURL(
'local', $context, [
995 'version' => $version
1004 private function sendSourceMapVersionMismatch( $currentVersion ) {
1005 HttpStatus::header( 404 );
1006 header(
'Content-Type: text/plain; charset=utf-8' );
1007 header(
'X-Content-Type-Options: nosniff' );
1008 echo
"Can't deliver a source map for the requested version " .
1009 "since the version is now '$currentVersion'\n";
1016 private function sendSourceMapTypeNotImplemented() {
1017 HttpStatus::header( 404 );
1018 header(
'Content-Type: text/plain; charset=utf-8' );
1019 header(
'X-Content-Type-Options: nosniff' );
1020 echo
"Can't make a source map for this content type\n";
1032 $encText = str_replace(
'*/',
'* /', $text );
1033 return "/*\n$encText\n*/\n";
1043 return self::makeComment( self::formatExceptionNoComment( $e ) );
1054 if ( !MWExceptionRenderer::shouldShowExceptionDetails() ) {
1055 return MWExceptionHandler::getPublicLogMessage( $e );
1058 return MWExceptionHandler::getLogMessage( $e ) .
1060 MWExceptionHandler::getRedactedTraceAsString( $e );
1075 array
$modules, array $missing = []
1077 if (
$modules === [] && $missing === [] ) {
1087 $data = $image->getImageData( $context );
1088 if ( $data ===
false ) {
1090 $this->errors[] =
'Image generation failed';
1096 foreach ( $missing as $name ) {
1097 $states[$name] =
'missing';
1101 $debug = (bool)$context->
getDebug();
1103 $indexMap =
new IndexMap;
1109 foreach (
$modules as $name => $module ) {
1111 [ $response, $offset ] = $this->getOneModuleResponse( $context, $name, $module );
1113 $indexMap->addEncodedMap( $response, $offset );
1117 }
catch ( TimeoutException $e ) {
1119 }
catch ( Exception $e ) {
1120 $this->outputErrorAndLog( $e,
'Generating module package failed: {exception}' );
1123 $states[$name] =
'error';
1130 if (
$modules && $only ===
'scripts' ) {
1133 foreach (
$modules as $name => $module ) {
1134 $states[$name] =
'ready';
1140 $stateScript = self::makeLoaderStateScript( $context, $states );
1142 $stateScript = self::filter(
'minify-js', $stateScript );
1145 $out = self::ensureNewline( $out ) . $stateScript;
1147 } elseif ( $states ) {
1148 $this->errors[] =
'Problematic modules: '
1156 return $indexMap->getMap();
1170 private function getOneModuleResponse(
Context $context, $name,
Module $module ) {
1176 if ( $only ===
'styles' ) {
1177 $minifier =
new IdentityMinifierState;
1178 $this->addOneModuleResponse( $context, $minifier, $name, $module, $this->extraHeaders );
1180 self::filter(
'minify-css', $minifier->getMinifiedOutput(),
1181 [
'cache' => $shouldCache ] ),
1186 $minifier =
new IdentityMinifierState;
1187 $this->addOneModuleResponse( $context, $minifier, $name, $module, $this->extraHeaders );
1188 $plainContent = $minifier->getMinifiedOutput();
1190 return [ $plainContent, null ];
1194 $callback =
function () use ( $context, $name, $module, &$isHit ) {
1197 $minifier = (
new JavaScriptMapperState )
1198 ->outputFile( $this->createLoaderURL(
'local', $context, [
1199 'modules' => self::makePackedModulesString( $context->
getModules() ),
1203 $minifier =
new JavaScriptMinifierState;
1206 $discardedHeaders =
null;
1207 $this->addOneModuleResponse( $context, $minifier, $name, $module, $discardedHeaders );
1209 $sourceMap = $minifier->getRawSourceMap();
1210 $generated = $minifier->getMinifiedOutput();
1211 $offset = IndexMapOffset::newFromText( $generated );
1212 return [ $sourceMap, $offset->toArray() ];
1214 return [ $minifier->getMinifiedOutput(), null ];
1218 if ( $shouldCache ) {
1219 [ $response, $offsetArray ] = $this->srvCache->getWithSetCallback(
1220 $this->srvCache->makeGlobalKey(
1221 'resourceloader-mapped',
1222 self::CACHE_VERSION,
1225 md5( $plainContent )
1230 $this->stats->increment( implode(
'.', [
1231 "resourceloader_cache",
1233 $isHit ?
'hit' :
'miss'
1236 [ $response, $offsetArray ] = $callback();
1238 $offset = $offsetArray ? IndexMapOffset::newFromArray( $offsetArray ) : null;
1240 return [ $response, $offset ];
1253 private function addOneModuleResponse(
1254 Context $context, MinifierState $minifier, $name, Module $module, &$headers
1256 $only = $context->getOnly();
1257 $debug = (bool)$context->getDebug();
1258 $content = $module->getModuleContent( $context );
1259 $version = $module->getVersionHash( $context );
1261 if ( $headers !==
null && isset(
$content[
'headers'] ) ) {
1262 $headers = array_merge( $headers,
$content[
'headers'] );
1269 if ( !is_array( $scripts ) ) {
1272 throw new InvalidArgumentException(
'scripts must be an array' );
1274 if ( isset( $scripts[
'plainScripts'] ) ) {
1276 $this->addPlainScripts( $minifier, $name, $scripts[
'plainScripts'] );
1277 } elseif ( isset( $scripts[
'files'] ) ) {
1279 $this->addImplementScript(
1287 $content[
'deprecationWarning'] ??
null
1296 if ( isset( $styles[
'css'] ) ) {
1297 $minifier->addOutput( implode(
'', $styles[
'css'] ) );
1301 $scripts =
$content[
'scripts'] ??
'';
1302 if ( ( $name ===
'site' || $name ===
'user' )
1303 && isset( $scripts[
'plainScripts'] )
1309 $scripts = self::concatenatePlainScripts( $scripts[
'plainScripts'] );
1311 $scripts = self::filter(
'minify-js', $scripts );
1314 $this->addImplementScript(
1320 isset(
$content[
'messagesBlob'] ) ?
new HtmlJsCode(
$content[
'messagesBlob'] ) : null,
1322 $content[
'deprecationWarning'] ?? null
1326 $minifier->ensureNewline();
1335 public static function ensureNewline( $str ) {
1336 $end = substr( $str, -1 );
1337 if ( $end ===
false || $end ===
'' || $end ===
"\n" ) {
1349 public function getModulesByMessage( $messageKey ) {
1351 foreach ( $this->getModuleNames() as $moduleName ) {
1352 $module = $this->getModule( $moduleName );
1353 if ( in_array( $messageKey, $module->getMessages() ) ) {
1354 $moduleNames[] = $moduleName;
1357 return $moduleNames;
1381 private function addImplementScript( MinifierState $minifier,
1382 $moduleName, $version, $scripts, $styles, $messages, $templates, $deprecationWarning
1384 $implementKey =
"$moduleName@$version";
1387 $minifier->addOutput(
"mw.loader.impl(function(){return[" .
1388 Html::encodeJsVar( $implementKey ) .
"," );
1391 if ( is_string( $scripts ) ) {
1393 $minifier->addOutput( Html::encodeJsVar( $scripts ) );
1394 } elseif ( is_array( $scripts ) ) {
1395 if ( isset( $scripts[
'files'] ) ) {
1396 $minifier->addOutput(
1398 Html::encodeJsVar( $scripts[
'main'] ) .
1400 $this->addFiles( $minifier, $moduleName, $scripts[
'files'] );
1401 $minifier->addOutput(
"}" );
1402 } elseif ( isset( $scripts[
'plainScripts'] ) ) {
1403 if ( $this->isEmptyFileInfos( $scripts[
'plainScripts'] ) ) {
1404 $minifier->addOutput(
'null' );
1406 $minifier->addOutput(
"function($,jQuery,require,module){" );
1407 $this->addPlainScripts( $minifier, $moduleName, $scripts[
'plainScripts'] );
1408 $minifier->addOutput(
"}" );
1410 } elseif ( $scripts === [] || isset( $scripts[0] ) ) {
1412 $minifier->addOutput( Html::encodeJsVar( $scripts ) );
1414 throw new InvalidArgumentException(
'Invalid script array: ' .
1415 'must contain files, plainScripts or be an array of URLs' );
1418 throw new InvalidArgumentException(
'Script must be a string or array' );
1426 $messages ?? (
object)[],
1430 self::trimArray( $extraArgs );
1431 foreach ( $extraArgs as $arg ) {
1432 $minifier->addOutput(
',' . Html::encodeJsVar( $arg ) );
1434 $minifier->addOutput(
"];});" );
1447 private function addFiles( MinifierState $minifier, $moduleName, $files ) {
1449 $minifier->addOutput(
"{" );
1450 foreach ( $files as $fileName =>
$file ) {
1454 $minifier->addOutput(
"," );
1456 $minifier->addOutput( Html::encodeJsVar( $fileName ) .
':' );
1457 $this->addFileContent( $minifier, $moduleName,
'packageFile', $fileName,
$file );
1459 $minifier->addOutput(
"}" );
1471 private function addFileContent( MinifierState $minifier,
1472 $moduleName, $sourceType, $sourceIndex, array
$file
1474 $isScript = (
$file[
'type'] ??
'script' ) ===
'script';
1476 $filePath =
$file[
'filePath'] ??
$file[
'virtualFilePath'] ??
null;
1477 if ( $filePath !==
null && $filePath->getRemoteBasePath() !== null ) {
1478 $url = $filePath->getRemotePath();
1480 $ext = $isScript ?
'js' :
'json';
1481 $scriptPath = $this->config->has( MainConfigNames::ScriptPath )
1482 ? $this->config->get( MainConfigNames::ScriptPath ) :
'';
1483 $url =
"$scriptPath/virtual-resource/$moduleName-$sourceType-$sourceIndex.$ext";
1487 if ( $sourceType ===
'packageFile' ) {
1491 $minifier->addOutput(
"function(require,module,exports){" );
1492 $minifier->addSourceFile( $url,
$content,
true );
1493 $minifier->ensureNewline();
1494 $minifier->addOutput(
"}" );
1496 $minifier->addSourceFile( $url,
$content,
true );
1497 $minifier->ensureNewline();
1501 $minifier->addSourceFile( $url,
$content,
true );
1512 private static function concatenatePlainScripts( $plainScripts ) {
1514 foreach ( $plainScripts as $script ) {
1517 $s .= self::ensureNewline( $script[
'content'] );
1530 private function addPlainScripts( MinifierState $minifier, $moduleName, $plainScripts ) {
1531 foreach ( $plainScripts as $index =>
$file ) {
1532 $this->addFileContent( $minifier, $moduleName,
'script', $index,
$file );
1542 private function isEmptyFileInfos( $infos ) {
1544 foreach ( $infos as $info ) {
1545 $len += strlen( $info[
'content'] ??
'' );
1557 public static function makeCombinedStyles( array $stylePairs ) {
1559 foreach ( $stylePairs as $media => $styles ) {
1562 $styles = (array)$styles;
1563 foreach ( $styles as $style ) {
1564 $style = trim( $style );
1566 if ( $style ===
'' ) {
1571 $media = OutputPage::transformCssMedia( $media );
1573 if ( $media ===
'' || $media ==
'all' ) {
1575 } elseif ( is_string( $media ) ) {
1576 $out[] =
"@media $media {\n" . str_replace(
"\n",
"\n\t",
"\t" . $style ) .
"}";
1591 private static function encodeJsonForScript( $data ) {
1601 $jsonFlags = JSON_UNESCAPED_SLASHES |
1602 JSON_UNESCAPED_UNICODE |
1605 if ( self::inDebugMode() ) {
1606 $jsonFlags |= JSON_PRETTY_PRINT;
1608 return json_encode( $data, $jsonFlags );
1619 public static function makeLoaderStateScript(
1620 Context $context, array $states
1622 return 'mw.loader.state('
1626 . @$context->encodeJson( $states )
1630 private static function isEmptyObject( stdClass $obj ) {
1631 foreach ( $obj as $value ) {
1650 private static function trimArray( array &$array ): void {
1651 $i = count( $array );
1653 if ( $array[$i] ===
null
1654 || $array[$i] === []
1655 || ( $array[$i] instanceof HtmlJsCode && $array[$i]->value ===
'{}' )
1656 || ( $array[$i] instanceof stdClass && self::isEmptyObject( $array[$i] ) )
1658 unset( $array[$i] );
1690 public static function makeLoaderRegisterScript(
1697 foreach (
$modules as $i => $module ) {
1699 $index[$module[0]] = $i;
1702 if ( isset( $module[2] ) ) {
1703 foreach ( $module[2] as &$dependency ) {
1704 if ( isset( $index[$dependency] ) ) {
1706 $dependency = $index[$dependency];
1710 self::trimArray( $module );
1713 return 'mw.loader.register('
1731 public static function makeLoaderSourcesScript(
1732 Context $context, array $sources
1734 return 'mw.loader.addSource('
1735 . $context->encodeJson( $sources )
1745 public static function makeLoaderConditionalScript( $script ) {
1747 return '(RLQ=window.RLQ||[]).push(function(){' .
1748 trim( $script ) .
'});';
1759 public static function makeInlineCodeWithModule(
$modules, $script ) {
1761 return '(RLQ=window.RLQ||[]).push(['
1762 . self::encodeJsonForScript(
$modules ) .
','
1763 .
'function(){' . trim( $script ) .
'}'
1777 public static function makeInlineScript( $script, $nonce =
null ) {
1778 $js = self::makeLoaderConditionalScript( $script );
1779 return new WrappedString(
1780 Html::inlineScript( $js ),
1781 "<script>(RLQ=window.RLQ||[]).push(function(){",
1794 public static function makeConfigSetScript( array $configuration ) {
1795 $json = self::encodeJsonForScript( $configuration );
1796 if ( $json ===
false ) {
1798 'JSON serialization of config data failed. ' .
1799 'This usually means the config data is not valid UTF-8.'
1802 return 'mw.log.error(' . self::encodeJsonForScript( $e->__toString() ) .
');';
1804 return "mw.config.set($json);";
1820 public static function makePackedModulesString( array
$modules ) {
1823 $pos = strrpos( $module,
'.' );
1824 $prefix = $pos ===
false ?
'' : substr( $module, 0, $pos );
1825 $suffix = $pos ===
false ? $module : substr( $module, $pos + 1 );
1826 $moduleMap[$prefix][] = $suffix;
1830 foreach ( $moduleMap as $prefix => $suffixes ) {
1831 $p = $prefix ===
'' ?
'' : $prefix .
'.';
1832 $arr[] = $p . implode(
',', $suffixes );
1834 return implode(
'|', $arr );
1848 public static function expandModuleNames(
$modules ) {
1850 $exploded = explode(
'|',
$modules );
1851 foreach ( $exploded as $group ) {
1852 if ( strpos( $group,
',' ) ===
false ) {
1859 $pos = strrpos( $group,
'.' );
1860 if ( $pos ===
false ) {
1862 $retval = array_merge( $retval, explode(
',', $group ) );
1866 $prefix = substr( $group, 0, $pos );
1867 $suffixes = explode(
',', substr( $group, $pos + 1 ) );
1868 foreach ( $suffixes as $suffix ) {
1869 $retval[] =
"$prefix.$suffix";
1885 public static function inDebugMode() {
1886 if ( self::$debugMode ===
null ) {
1889 $resourceLoaderDebug = MediaWikiServices::getInstance()->getMainConfig()->get(
1890 MainConfigNames::ResourceLoaderDebug );
1892 $wgRequest->getCookie(
'resourceLoaderDebug',
'', $resourceLoaderDebug ?
'true' :
'' )
1894 self::$debugMode = Context::debugFromString( $str );
1896 return self::$debugMode;
1909 public static function clearCache() {
1910 self::$debugMode =
null;
1922 public function createLoaderURL(
$source, Context $context,
1923 array $extraQuery = []
1925 $query = self::createLoaderQuery( $context, $extraQuery );
1926 $script = $this->getLoadScript(
$source );
1940 protected static function createLoaderQuery(
1941 Context $context, array $extraQuery = []
1943 return self::makeLoaderQuery(
1944 $context->getModules(),
1945 $context->getLanguage(),
1946 $context->getSkin(),
1947 $context->getUser(),
1948 $context->getVersion(),
1949 $context->getDebug(),
1950 $context->getOnly(),
1951 $context->getRequest()->getBool(
'printable' ),
1973 public static function makeLoaderQuery( array
$modules, $lang, $skin, $user =
null,
1974 $version =
null, $debug = Context::DEBUG_OFF, $only =
null,
1975 $printable =
false, $handheld =
null, array $extraQuery = []
1978 'modules' => self::makePackedModulesString(
$modules ),
1984 if ( $lang !== Context::DEFAULT_LANG ) {
1985 $query[
'lang'] = $lang;
1987 if ( $skin !== Context::DEFAULT_SKIN ) {
1988 $query[
'skin'] = $skin;
1990 if ( $debug !== Context::DEBUG_OFF ) {
1991 $query[
'debug'] = strval( $debug );
1993 if ( $user !==
null ) {
1994 $query[
'user'] = $user;
1996 if ( $version !==
null ) {
1997 $query[
'version'] = $version;
1999 if ( $only !==
null ) {
2000 $query[
'only'] = $only;
2003 $query[
'printable'] = 1;
2005 foreach ( $extraQuery as $name => $value ) {
2006 $query[$name] = $value;
2023 public static function isValidModuleName( $moduleName ) {
2024 $len = strlen( $moduleName );
2025 return $len <= 255 && strcspn( $moduleName,
'!,|', 0, $len ) === $len;
2038 public function getLessCompiler( array $vars = [], array $importDirs = [] ) {
2043 if ( !class_exists( Less_Parser::class ) ) {
2044 throw new RuntimeException(
'MediaWiki requires the less.php parser' );
2047 $importDirs[] =
"$IP/resources/src/mediawiki.less";
2049 $parser =
new Less_Parser;
2050 $parser->ModifyVars( $vars );
2051 $parser->SetOption(
'relativeUrls',
false );
2054 $formattedImportDirs = array_fill_keys( $importDirs,
'' );
2056 $formattedImportDirs[] =
static function (
$path ) {
2059 '@wikimedia/codex-icons/' =>
"$IP/resources/lib/codex-icons/",
2060 'mediawiki.skin.codex-design-tokens/' =>
"$IP/resources/lib/codex-design-tokens/",
2061 '@wikimedia/codex-design-tokens/' =>
static function ( $unused_path ) {
2062 throw new RuntimeException(
2063 'Importing from @wikimedia/codex-design-tokens is not supported. ' .
2064 "To use the Codex tokens, use `@import 'mediawiki.skin.variables.less';` instead."
2068 foreach ( $importMap as $importPath => $substPath ) {
2069 if ( str_starts_with(
$path, $importPath ) ) {
2070 $restOfPath = substr(
$path, strlen( $importPath ) );
2071 if ( is_callable( $substPath ) ) {
2072 $resolvedPath = call_user_func( $substPath, $restOfPath );
2074 $filePath = $substPath . $restOfPath;
2076 $resolvedPath =
null;
2077 if ( file_exists( $filePath ) ) {
2078 $resolvedPath = $filePath;
2079 } elseif ( file_exists(
"$filePath.less" ) ) {
2080 $resolvedPath =
"$filePath.less";
2084 if ( $resolvedPath !==
null ) {
2086 Less_Environment::normalizePath( $resolvedPath ),
2087 Less_Environment::normalizePath( dirname(
$path ) )
2094 return [
null, null ];
2096 $parser->SetImportDirs( $formattedImportDirs );
2114 public function expandUrl(
string $base,
string $url ): string {
2116 $isProtoRelative = strpos( $base,
'//' ) === 0;
2117 if ( $isProtoRelative ) {
2118 $base =
"https:$base";
2121 $baseUrl =
new Net_URL2( $base );
2122 $ret = $baseUrl->resolve( $url );
2123 if ( $isProtoRelative ) {
2124 $ret->setScheme(
false );
2126 return $ret->getURL();
2146 public static function filter( $filter, $data, array $options = [] ) {
2147 if ( strpos( $data, self::FILTER_NOMIN ) !==
false ) {
2151 if ( isset( $options[
'cache'] ) && $options[
'cache'] ===
false ) {
2152 return self::applyFilter( $filter, $data ) ?? $data;
2155 $stats = MediaWikiServices::getInstance()->getStatsdDataFactory();
2158 $key = $cache->makeGlobalKey(
2159 'resourceloader-filter',
2161 self::CACHE_VERSION,
2165 $incKey =
"resourceloader_cache.$filter.hit";
2166 $result = $cache->getWithSetCallback(
2169 static function () use ( $filter, $data, &$incKey ) {
2170 $incKey =
"resourceloader_cache.$filter.miss";
2171 return self::applyFilter( $filter, $data );
2174 $stats->increment( $incKey );
2177 return $result ?? $data;
2185 private static function applyFilter( $filter, $data ) {
2186 $data = trim( $data );
2189 $data = ( $filter ===
'minify-css' )
2190 ? CSSMin::minify( $data )
2191 : JavaScriptMinifier::minify( $data );
2192 }
catch ( TimeoutException $e ) {
2194 }
catch ( Exception $e ) {
2213 public static function getUserDefaults(
2215 HookContainer $hookContainer,
2216 UserOptionsLookup $userOptionsLookup
2218 $defaultOptions = $userOptionsLookup->getDefaultOptions();
2219 $keysToExclude = [];
2220 $hookRunner =
new HookRunner( $hookContainer );
2221 $hookRunner->onResourceLoaderExcludeUserOptions( $keysToExclude, $context );
2222 foreach ( $keysToExclude as $excludedKey ) {
2223 unset( $defaultOptions[ $excludedKey ] );
2225 return $defaultOptions;
2236 public static function getSiteConfigSettings(
2237 Context $context, Config $conf
2239 $services = MediaWikiServices::getInstance();
2243 $contLang = $services->getContentLanguage();
2244 $namespaceIds = $contLang->getNamespaceIds();
2245 $caseSensitiveNamespaces = [];
2246 $nsInfo = $services->getNamespaceInfo();
2247 foreach ( $nsInfo->getCanonicalNamespaces() as $index => $name ) {
2248 $namespaceIds[$contLang->lc( $name )] = $index;
2249 if ( !$nsInfo->isCapitalized( $index ) ) {
2250 $caseSensitiveNamespaces[] = $index;
2254 $illegalFileChars = $conf->get( MainConfigNames::IllegalFileChars );
2257 $skin = $context->getSkin();
2261 'debug' => $context->getDebug(),
2263 'stylepath' => $conf->get( MainConfigNames::StylePath ),
2264 'wgArticlePath' => $conf->get( MainConfigNames::ArticlePath ),
2265 'wgScriptPath' => $conf->get( MainConfigNames::ScriptPath ),
2266 'wgScript' => $conf->get( MainConfigNames::Script ),
2267 'wgSearchType' => $conf->get( MainConfigNames::SearchType ),
2268 'wgVariantArticlePath' => $conf->get( MainConfigNames::VariantArticlePath ),
2269 'wgServer' => $conf->get( MainConfigNames::Server ),
2270 'wgServerName' => $conf->get( MainConfigNames::ServerName ),
2271 'wgUserLanguage' => $context->getLanguage(),
2272 'wgContentLanguage' => $contLang->getCode(),
2274 'wgFormattedNamespaces' => $contLang->getFormattedNamespaces(),
2275 'wgNamespaceIds' => $namespaceIds,
2276 'wgContentNamespaces' => $nsInfo->getContentNamespaces(),
2277 'wgSiteName' => $conf->get( MainConfigNames::Sitename ),
2278 'wgDBname' => $conf->get( MainConfigNames::DBname ),
2279 'wgWikiID' => WikiMap::getCurrentWikiId(),
2280 'wgCaseSensitiveNamespaces' => $caseSensitiveNamespaces,
2281 'wgCommentCodePointLimit' => CommentStore::COMMENT_CHARACTER_LIMIT,
2282 'wgExtensionAssetsPath' => $conf->get( MainConfigNames::ExtensionAssetsPath ),
2293 'wgActionPaths' => (object)$conf->get( MainConfigNames::ActionPaths ),
2295 'wgTranslateNumerals' => $conf->get( MainConfigNames::TranslateNumerals ),
2297 'wgExtraSignatureNamespaces' => $conf->get( MainConfigNames::ExtraSignatureNamespaces ),
2298 'wgLegalTitleChars' => Title::convertByteClassToUnicodeClass( Title::legalChars() ),
2299 'wgIllegalFileChars' => Title::convertByteClassToUnicodeClass( $illegalFileChars ),
2302 (
new HookRunner( $services->getHookContainer() ) )
2303 ->onResourceLoaderGetConfigVars( $vars, $skin, $conf );
2312 public function getErrors() {
2313 return $this->errors;