98 public const CACHE_VERSION = 9;
100 public const FILTER_NOMIN =
'/*@nomin*/';
103 private const RL_DEP_STORE_PREFIX =
'ResourceLoaderModule';
105 private const RL_MODULE_DEP_TTL = BagOStuff::TTL_YEAR;
107 private const MAXAGE_RECOVER = 60;
121 private $hookContainer;
125 private $statsFactory;
127 private $maxageVersioned;
129 private $maxageUnversioned;
132 private $modules = [];
134 private $moduleInfos = [];
136 private $testModuleNames = [];
138 private $sources = [];
147 private $depStoreUpdateBuffer = [];
152 private $moduleSkinStyles = [];
176 LoggerInterface $logger =
null,
180 $this->maxageVersioned =
$params[
'maxageVersioned'] ?? 30 * 24 * 60 * 60;
181 $this->maxageUnversioned =
$params[
'maxageUnversioned'] ?? 5 * 60;
183 $this->config = $config;
184 $this->logger = $logger ?:
new NullLogger();
187 $this->hookContainer = $services->getHookContainer();
189 $this->srvCache = $services->getLocalServerObjectCache();
190 $this->statsFactory = $services->getStatsFactory();
196 $this->
register(
'startup', [
'class' => StartUpModule::class ] );
199 new MessageBlobStore( $this, $this->logger, $services->getMainWANObjectCache() )
210 return $this->config;
218 $this->logger = $logger;
226 return $this->logger;
234 return $this->blobStore;
242 $this->blobStore = $blobStore;
250 $this->depStore = $tracker;
258 $this->moduleSkinStyles = $moduleSkinStyles;
272 public function register( $name, array $info = null ) {
274 $registrations = is_array( $name ) ? $name : [ $name => $info ];
275 foreach ( $registrations as $name => $info ) {
277 if ( isset( $this->moduleInfos[$name] ) ) {
279 $this->logger->warning(
280 'ResourceLoader duplicate registration warning. ' .
281 'Another module has already been registered as ' . $name
286 if ( !self::isValidModuleName( $name ) ) {
287 throw new InvalidArgumentException(
"ResourceLoader module name '$name' is invalid, "
288 .
"see ResourceLoader::isValidModuleName()" );
290 if ( !is_array( $info ) ) {
291 throw new InvalidArgumentException(
292 'Invalid module info for "' . $name .
'": expected array, got ' . gettype( $info )
297 $this->moduleInfos[$name] = $info;
307 $testModules = $extRegistry->
getAttribute(
'QUnitTestModules' );
309 $testModuleNames = [];
310 foreach ( $testModules as $name => &$module ) {
312 if ( isset( $module[
'dependencies'] ) && is_string( $module[
'dependencies'] ) ) {
313 $module[
'dependencies'] = [ $module[
'dependencies'] ];
317 $module[
'dependencies'][] =
'mediawiki.qunit-testrunner';
320 $testModuleNames[] = $name;
324 $testModules = ( include MW_INSTALL_PATH .
'/tests/qunit/QUnitTestResources.php' ) + $testModules;
325 $testModuleNames[] =
'test.MediaWiki';
327 $this->
register( $testModules );
328 $this->testModuleNames = $testModuleNames;
341 public function addSource( $sources, $loadUrl =
null ) {
342 if ( !is_array( $sources ) ) {
343 $sources = [ $sources => $loadUrl ];
345 foreach ( $sources as $id =>
$source ) {
347 if ( isset( $this->sources[$id] ) ) {
348 throw new RuntimeException(
'Cannot register source ' . $id .
' twice' );
353 if ( !isset(
$source[
'loadScript'] ) ) {
354 throw new InvalidArgumentException(
'Each source must have a "loadScript" key' );
367 return array_keys( $this->moduleInfos );
378 return $this->testModuleNames;
389 return isset( $this->moduleInfos[$name] );
404 if ( !isset( $this->modules[$name] ) ) {
405 if ( !isset( $this->moduleInfos[$name] ) ) {
410 $info = $this->moduleInfos[$name];
411 if ( isset( $info[
'factory'] ) ) {
413 $object = call_user_func( $info[
'factory'], $info );
415 $class = $info[
'class'] ?? FileModule::class;
417 $object =
new $class( $info );
419 $object->setConfig( $this->getConfig() );
420 $object->setLogger( $this->logger );
421 $object->setHookContainer( $this->hookContainer );
422 $object->setName( $name );
423 $object->setDependencyAccessCallbacks(
424 [ $this,
'loadModuleDependenciesInternal' ],
425 [ $this,
'saveModuleDependenciesInternal' ]
427 $object->setSkinStylesOverride( $this->moduleSkinStyles );
428 $this->modules[$name] = $object;
431 return $this->modules[$name];
443 $entitiesByModule = [];
444 foreach ( $moduleNames as $moduleName ) {
445 $entitiesByModule[$moduleName] =
"$moduleName|$vary";
447 $depsByEntity = $this->depStore->retrieveMulti(
448 self::RL_DEP_STORE_PREFIX,
452 foreach ( $moduleNames as $moduleName ) {
453 $module = $this->getModule( $moduleName );
455 $entity = $entitiesByModule[$moduleName];
456 $deps = $depsByEntity[$entity];
458 $module->setFileDependencies( $context, $paths );
465 $modulesWithMessages = [];
466 foreach ( $moduleNames as $moduleName ) {
467 $module = $this->getModule( $moduleName );
468 if ( $module && $module->getMessages() ) {
469 $modulesWithMessages[$moduleName] = $module;
474 $store = $this->getMessageBlobStore();
475 $blobs = $store->getBlobs( $modulesWithMessages, $lang );
476 foreach ( $blobs as $moduleName => $blob ) {
477 $modulesWithMessages[$moduleName]->setMessageBlob( $blob, $lang );
488 $deps = $this->depStore->retrieve( self::RL_DEP_STORE_PREFIX,
"$moduleName|$variant" );
501 $hasPendingUpdate = (bool)$this->depStoreUpdateBuffer;
502 $entity =
"$moduleName|$variant";
504 if ( array_diff( $paths, $priorPaths ) || array_diff( $priorPaths, $paths ) ) {
507 $deps = $this->depStore->newEntityDependencies( $paths, time() );
508 $this->depStoreUpdateBuffer[$entity] = $deps;
510 $this->depStoreUpdateBuffer[$entity] =
null;
520 if ( !$hasPendingUpdate ) {
521 DeferredUpdates::addCallableUpdate(
function () {
522 $updatesByEntity = $this->depStoreUpdateBuffer;
523 $this->depStoreUpdateBuffer = [];
524 $cache = ObjectCache::getLocalClusterInstance();
529 foreach ( $updatesByEntity as $entity => $update ) {
530 $lockKey = $cache->makeKey(
'rl-deps', $entity );
531 $scopeLocks[$entity] = $cache->getScopedLock( $lockKey, 0 );
532 if ( !$scopeLocks[$entity] ) {
537 if ( $update ===
null ) {
538 $entitiesUnreg[] = $entity;
540 $depsByEntity[$entity] = $update;
544 $ttl = self::RL_MODULE_DEP_TTL;
545 $this->depStore->storeMulti( self::RL_DEP_STORE_PREFIX, $depsByEntity, $ttl );
546 $this->depStore->remove( self::RL_DEP_STORE_PREFIX, $entitiesUnreg );
557 return $this->sources;
569 if ( !isset( $this->sources[
$source] ) ) {
570 throw new UnexpectedValueException(
"Unknown source '$source'" );
572 return $this->sources[
$source];
578 public const HASH_LENGTH = 5;
643 $hash = hash(
'fnv132', $value );
647 \
Wikimedia\base_convert( $hash, 16, 36, self::HASH_LENGTH ),
663 MWExceptionHandler::logException( $e );
664 $this->logger->warning(
666 $context + [
'exception' => $e ]
668 $this->errors[] = self::formatExceptionNoComment( $e );
680 if ( !$moduleNames ) {
684 foreach ( $moduleNames as $module ) {
686 $hash = $this->getModule( $module )->getVersionHash( $context );
687 }
catch ( TimeoutException $e ) {
689 }
catch ( Exception $e ) {
692 $this->outputErrorAndLog( $e,
693 'Calculating version for "{module}" failed: {exception}',
702 return self::makeHash( implode(
'', $hashes ) );
726 foreach ( $modules as $name ) {
727 if ( !$this->getModule( $name ) ) {
734 return $this->getCombinedVersion( $context, $filtered );
753 $responseTime = $this->measureResponseTime();
760 $module = $this->getModule( $name );
764 if ( $module->getGroup() === Module::GROUP_PRIVATE ) {
766 $this->logger->debug(
"Request for private module '$name' denied" );
767 $this->errors[] =
"Cannot build private module \"$name\"";
770 $modules[$name] = $module;
778 $this->preloadModuleInfo( array_keys( $modules ), $context );
779 }
catch ( TimeoutException $e ) {
781 }
catch ( Exception $e ) {
782 $this->outputErrorAndLog( $e,
'Preloading module info failed: {exception}' );
786 $versionHash = $this->getCombinedVersion( $context, array_keys( $modules ) );
790 $etag =
'W/"' . $versionHash .
'"';
793 if ( $this->tryRespondNotModified( $context, $etag ) ) {
801 $this->sendSourceMapVersionMismatch( $versionHash );
806 || $context->
getOnly() ===
'styles'
810 $this->sendSourceMapTypeNotImplemented();
821 $this->extraHeaders[] =
'SourceMap: ' . $this->getSourceMapUrl( $context, $versionHash );
825 $response = $this->makeModuleResponse( $context, $modules, $missing );
830 $warnings = ob_get_contents();
831 if ( strlen( $warnings ) ) {
832 $this->errors[] = $warnings;
836 $this->sendResponseHeaders( $context, $etag, (
bool)$this->errors, $this->extraHeaders );
843 $response = implode(
"\n\n", $this->errors );
844 } elseif ( $this->errors ) {
845 $errorText = implode(
"\n\n", $this->errors );
846 $errorResponse = self::makeComment( $errorText );
848 $errorResponse .=
'if (window.console && console.error) { console.error('
853 $response .= $errorResponse;
856 $response = $errorResponse . $response;
868 $statStart = $_SERVER[
'REQUEST_TIME_FLOAT'];
869 return new ScopedCallback(
function () use ( $statStart ) {
870 $statTiming = microtime(
true ) - $statStart;
872 $this->statsFactory->getTiming(
'resourceloader_response_time_seconds' )
873 ->copyToStatsdAt(
'resourceloader.responseTime' )
874 ->observe( 1000 * $statTiming );
889 Context $context, $etag, $errors, array $extra = []
894 $maxage = self::MAXAGE_RECOVER;
903 $this->logger->debug(
'Client and server registry version out of sync' );
904 $maxage = self::MAXAGE_RECOVER;
905 } elseif ( $context->
getVersion() ===
null ) {
909 $maxage = $this->maxageUnversioned;
913 $maxage = $this->maxageVersioned;
918 header(
'Content-Type: text/plain; charset=utf-8' );
920 $context->
getImageObj()->sendResponseHeaders( $context );
923 header(
'Content-Type: application/json' );
924 } elseif ( $context->
getOnly() ===
'styles' ) {
925 header(
'Content-Type: text/css; charset=utf-8' );
926 header(
'Access-Control-Allow-Origin: *' );
928 header(
'Content-Type: text/javascript; charset=utf-8' );
932 header(
'ETag: ' . $etag );
935 header(
'Cache-Control: private, no-cache, must-revalidate' );
939 $staleDirective = ( $maxage > self::MAXAGE_RECOVER
940 ?
", stale-while-revalidate=" . min( 60, intval( $maxage / 2 ) )
943 header(
"Cache-Control: public, max-age=$maxage, s-maxage=$maxage" . $staleDirective );
944 header(
'Expires: ' . ConvertibleTimestamp::convert( TS_RFC2822, time() + $maxage ) );
946 foreach ( $extra as
$header ) {
964 $clientKeys = $context->
getRequest()->getHeader(
'If-None-Match', WebRequest::GETHEADER_LIST );
966 if ( $clientKeys !==
false && !$context->
getDebug() && in_array( $etag, $clientKeys ) ) {
978 HttpStatus::header( 304 );
980 $this->sendResponseHeaders( $context, $etag,
false );
993 private function getSourceMapUrl(
Context $context, $version ) {
994 return $this->createLoaderURL(
'local', $context, [
996 'version' => $version
1005 private function sendSourceMapVersionMismatch( $currentVersion ) {
1006 HttpStatus::header( 404 );
1007 header(
'Content-Type: text/plain; charset=utf-8' );
1008 header(
'X-Content-Type-Options: nosniff' );
1009 echo
"Can't deliver a source map for the requested version " .
1010 "since the version is now '$currentVersion'\n";
1017 private function sendSourceMapTypeNotImplemented() {
1018 HttpStatus::header( 404 );
1019 header(
'Content-Type: text/plain; charset=utf-8' );
1020 header(
'X-Content-Type-Options: nosniff' );
1021 echo
"Can't make a source map for this content type\n";
1033 $encText = str_replace(
'*/',
'* /', $text );
1034 return "/*\n$encText\n*/\n";
1045 if ( !MWExceptionRenderer::shouldShowExceptionDetails() ) {
1046 return MWExceptionHandler::getPublicLogMessage( $e );
1054 $type = get_class( $e );
1055 $message = $e->getMessage();
1057 return "$type: $message" .
1059 MWExceptionHandler::getRedactedTraceAsString( $e );
1074 array $modules, array $missing = []
1076 if ( $modules === [] && $missing === [] ) {
1086 $data = $image->getImageData( $context );
1087 if ( $data ===
false ) {
1089 $this->errors[] =
'Image generation failed';
1095 foreach ( $missing as $name ) {
1096 $states[$name] =
'missing';
1100 $debug = (bool)$context->
getDebug();
1101 if ( $context->
isSourceMap() && count( $modules ) > 1 ) {
1102 $indexMap =
new IndexMap;
1108 foreach ( $modules as $name => $module ) {
1110 [ $response, $offset ] = $this->getOneModuleResponse( $context, $name, $module );
1112 $indexMap->addEncodedMap( $response, $offset );
1116 }
catch ( TimeoutException $e ) {
1118 }
catch ( Exception $e ) {
1119 $this->outputErrorAndLog( $e,
'Generating module package failed: {exception}' );
1122 $states[$name] =
'error';
1123 unset( $modules[$name] );
1129 if ( $modules && $only ===
'scripts' ) {
1132 foreach ( $modules as $name => $module ) {
1133 $states[$name] =
'ready';
1139 $stateScript = self::makeLoaderStateScript( $context, $states );
1141 $stateScript = self::filter(
'minify-js', $stateScript );
1144 $out = self::ensureNewline( $out ) . $stateScript;
1146 } elseif ( $states ) {
1147 $this->errors[] =
'Problematic modules: '
1155 return $indexMap->getMap();
1169 private function getOneModuleResponse(
Context $context, $name,
Module $module ) {
1175 if ( $only ===
'styles' ) {
1176 $minifier =
new IdentityMinifierState;
1177 $this->addOneModuleResponse( $context, $minifier, $name, $module, $this->extraHeaders );
1180 $styles = $minifier->getMinifiedOutput();
1182 return [ $styles, null ];
1185 self::filter(
'minify-css', $styles,
1186 [
'cache' => $shouldCache ] ),
1191 $minifier =
new IdentityMinifierState;
1192 $this->addOneModuleResponse( $context, $minifier, $name, $module, $this->extraHeaders );
1193 $plainContent = $minifier->getMinifiedOutput();
1195 return [ $plainContent, null ];
1199 $callback =
function () use ( $context, $name, $module, &$isHit ) {
1202 $minifier = (
new JavaScriptMapperState )
1203 ->outputFile( $this->createLoaderURL(
'local', $context, [
1204 'modules' => self::makePackedModulesString( $context->
getModules() ),
1208 $minifier =
new JavaScriptMinifierState;
1211 $discardedHeaders =
null;
1212 $this->addOneModuleResponse( $context, $minifier, $name, $module, $discardedHeaders );
1214 $sourceMap = $minifier->getRawSourceMap();
1215 $generated = $minifier->getMinifiedOutput();
1216 $offset = IndexMapOffset::newFromText( $generated );
1217 return [ $sourceMap, $offset->toArray() ];
1219 return [ $minifier->getMinifiedOutput(), null ];
1223 if ( $shouldCache ) {
1224 [ $response, $offsetArray ] = $this->srvCache->getWithSetCallback(
1225 $this->srvCache->makeGlobalKey(
1226 'resourceloader-mapped',
1227 self::CACHE_VERSION,
1230 md5( $plainContent )
1236 $mapType = $context->
isSourceMap() ?
'map-js' :
'minify-js';
1237 $statsdNamespace = implode(
'.', [
1238 "resourceloader_cache", $mapType, $isHit ?
'hit' :
'miss'
1240 $this->statsFactory->getCounter(
'resourceloader_cache_total' )
1241 ->setLabel(
'type', $mapType )
1242 ->setLabel(
'status', $isHit ?
'hit' :
'miss' )
1243 ->copyToStatsdAt( [ $statsdNamespace ] )
1246 [ $response, $offsetArray ] = $callback();
1248 $offset = $offsetArray ? IndexMapOffset::newFromArray( $offsetArray ) : null;
1250 return [ $response, $offset ];
1263 private function addOneModuleResponse(
1264 Context $context, MinifierState $minifier, $name, Module $module, &$headers
1266 $only = $context->getOnly();
1267 $debug = (bool)$context->getDebug();
1268 $content = $module->getModuleContent( $context );
1269 $version = $module->getVersionHash( $context );
1271 if ( $headers !==
null && isset( $content[
'headers'] ) ) {
1272 $headers = array_merge( $headers, $content[
'headers'] );
1278 $scripts = $content[
'scripts'];
1279 if ( !is_array( $scripts ) ) {
1282 throw new InvalidArgumentException(
'scripts must be an array' );
1284 if ( isset( $scripts[
'plainScripts'] ) ) {
1286 $this->addPlainScripts( $minifier, $name, $scripts[
'plainScripts'] );
1287 } elseif ( isset( $scripts[
'files'] ) ) {
1289 $this->addImplementScript(
1297 $content[
'deprecationWarning'] ??
null
1302 $styles = $content[
'styles'];
1306 if ( isset( $styles[
'css'] ) ) {
1307 $minifier->addOutput( implode(
'', $styles[
'css'] ) );
1311 $scripts = $content[
'scripts'] ??
'';
1312 if ( ( $name ===
'site' || $name ===
'user' )
1313 && isset( $scripts[
'plainScripts'] )
1319 $scripts = self::concatenatePlainScripts( $scripts[
'plainScripts'] );
1321 $scripts = self::filter(
'minify-js', $scripts );
1324 $this->addImplementScript(
1329 $content[
'styles'] ?? [],
1330 isset( $content[
'messagesBlob'] ) ?
new HtmlJsCode( $content[
'messagesBlob'] ) : null,
1331 $content[
'templates'] ?? [],
1332 $content[
'deprecationWarning'] ?? null
1336 $minifier->ensureNewline();
1345 public static function ensureNewline( $str ) {
1346 $end = substr( $str, -1 );
1347 if ( $end ===
false || $end ===
'' || $end ===
"\n" ) {
1359 public function getModulesByMessage( $messageKey ) {
1361 foreach ( $this->getModuleNames() as $moduleName ) {
1362 $module = $this->getModule( $moduleName );
1363 if ( in_array( $messageKey, $module->getMessages() ) ) {
1364 $moduleNames[] = $moduleName;
1367 return $moduleNames;
1391 private function addImplementScript( MinifierState $minifier,
1392 $moduleName, $version, $scripts, $styles, $messages, $templates, $deprecationWarning
1394 $implementKey =
"$moduleName@$version";
1397 $minifier->addOutput(
"mw.loader.impl(function(){return[" .
1398 Html::encodeJsVar( $implementKey ) .
"," );
1401 if ( is_string( $scripts ) ) {
1403 $minifier->addOutput( Html::encodeJsVar( $scripts ) );
1404 } elseif ( is_array( $scripts ) ) {
1405 if ( isset( $scripts[
'files'] ) ) {
1406 $minifier->addOutput(
1408 Html::encodeJsVar( $scripts[
'main'] ) .
1410 $this->addFiles( $minifier, $moduleName, $scripts[
'files'] );
1411 $minifier->addOutput(
"}" );
1412 } elseif ( isset( $scripts[
'plainScripts'] ) ) {
1413 if ( $this->isEmptyFileInfos( $scripts[
'plainScripts'] ) ) {
1414 $minifier->addOutput(
'null' );
1416 $minifier->addOutput(
"function($,jQuery,require,module){" );
1417 $this->addPlainScripts( $minifier, $moduleName, $scripts[
'plainScripts'] );
1418 $minifier->addOutput(
"}" );
1420 } elseif ( $scripts === [] || isset( $scripts[0] ) ) {
1422 $minifier->addOutput( Html::encodeJsVar( $scripts ) );
1424 throw new InvalidArgumentException(
'Invalid script array: ' .
1425 'must contain files, plainScripts or be an array of URLs' );
1428 throw new InvalidArgumentException(
'Script must be a string or array' );
1436 $messages ?? (
object)[],
1440 self::trimArray( $extraArgs );
1441 foreach ( $extraArgs as $arg ) {
1442 $minifier->addOutput(
',' . Html::encodeJsVar( $arg ) );
1444 $minifier->addOutput(
"];});" );
1457 private function addFiles( MinifierState $minifier, $moduleName, $files ) {
1459 $minifier->addOutput(
"{" );
1460 foreach ( $files as $fileName => $file ) {
1464 $minifier->addOutput(
"," );
1466 $minifier->addOutput( Html::encodeJsVar( $fileName ) .
':' );
1467 $this->addFileContent( $minifier, $moduleName,
'packageFile', $fileName, $file );
1469 $minifier->addOutput(
"}" );
1481 private function addFileContent( MinifierState $minifier,
1482 $moduleName, $sourceType, $sourceIndex, array $file
1484 $isScript = ( $file[
'type'] ??
'script' ) ===
'script';
1486 $filePath = $file[
'filePath'] ?? $file[
'virtualFilePath'] ??
null;
1487 if ( $filePath !==
null && $filePath->getRemoteBasePath() !== null ) {
1488 $url = $filePath->getRemotePath();
1490 $ext = $isScript ?
'js' :
'json';
1491 $scriptPath = $this->config->has( MainConfigNames::ScriptPath )
1492 ? $this->config->get( MainConfigNames::ScriptPath ) :
'';
1493 $url =
"$scriptPath/virtual-resource/$moduleName-$sourceType-$sourceIndex.$ext";
1495 $content = $file[
'content'];
1497 if ( $sourceType ===
'packageFile' ) {
1501 $minifier->addOutput(
"function(require,module,exports){" );
1502 $minifier->addSourceFile( $url, $content,
true );
1503 $minifier->ensureNewline();
1504 $minifier->addOutput(
"}" );
1506 $minifier->addSourceFile( $url, $content,
true );
1507 $minifier->ensureNewline();
1510 $content = Html::encodeJsVar( $content,
true );
1511 $minifier->addSourceFile( $url, $content,
true );
1522 private static function concatenatePlainScripts( $plainScripts ) {
1524 foreach ( $plainScripts as $script ) {
1527 $s .= self::ensureNewline( $script[
'content'] );
1540 private function addPlainScripts( MinifierState $minifier, $moduleName, $plainScripts ) {
1541 foreach ( $plainScripts as $index => $file ) {
1542 $this->addFileContent( $minifier, $moduleName,
'script', $index, $file );
1552 private function isEmptyFileInfos( $infos ) {
1554 foreach ( $infos as $info ) {
1555 $len += strlen( $info[
'content'] ??
'' );
1567 public static function makeCombinedStyles( array $stylePairs ) {
1569 foreach ( $stylePairs as $media => $styles ) {
1572 $styles = (array)$styles;
1573 foreach ( $styles as $style ) {
1574 $style = trim( $style );
1576 if ( $style ===
'' ) {
1581 $media = OutputPage::transformCssMedia( $media );
1583 if ( $media ===
'' || $media ==
'all' ) {
1585 } elseif ( is_string( $media ) ) {
1586 $out[] =
"@media $media {\n" . str_replace(
"\n",
"\n\t",
"\t" . $style ) .
"}";
1601 private static function encodeJsonForScript( $data ) {
1611 $jsonFlags = JSON_UNESCAPED_SLASHES |
1612 JSON_UNESCAPED_UNICODE |
1615 if ( self::inDebugMode() ) {
1616 $jsonFlags |= JSON_PRETTY_PRINT;
1618 return json_encode( $data, $jsonFlags );
1629 public static function makeLoaderStateScript(
1630 Context $context, array $states
1632 return 'mw.loader.state('
1636 . @$context->encodeJson( $states )
1640 private static function isEmptyObject( stdClass $obj ) {
1641 foreach ( $obj as $value ) {
1660 private static function trimArray( array &$array ): void {
1661 $i = count( $array );
1663 if ( $array[$i] ===
null
1664 || $array[$i] === []
1665 || ( $array[$i] instanceof HtmlJsCode && $array[$i]->value ===
'{}' )
1666 || ( $array[$i] instanceof stdClass && self::isEmptyObject( $array[$i] ) )
1668 unset( $array[$i] );
1700 public static function makeLoaderRegisterScript(
1701 Context $context, array $modules
1707 foreach ( $modules as $i => $module ) {
1709 $index[$module[0]] = $i;
1711 foreach ( $modules as &$module ) {
1712 if ( isset( $module[2] ) ) {
1713 foreach ( $module[2] as &$dependency ) {
1714 if ( isset( $index[$dependency] ) ) {
1716 $dependency = $index[$dependency];
1720 self::trimArray( $module );
1723 return 'mw.loader.register('
1724 . $context->encodeJson( $modules )
1741 public static function makeLoaderSourcesScript(
1742 Context $context, array $sources
1744 return 'mw.loader.addSource('
1745 . $context->encodeJson( $sources )
1755 public static function makeLoaderConditionalScript( $script ) {
1757 return '(RLQ=window.RLQ||[]).push(function(){' .
1758 trim( $script ) .
'});';
1769 public static function makeInlineCodeWithModule( $modules, $script ) {
1771 return '(RLQ=window.RLQ||[]).push(['
1772 . self::encodeJsonForScript( $modules ) .
','
1773 .
'function(){' . trim( $script ) .
'}'
1787 public static function makeInlineScript( $script, $nonce =
null ) {
1788 $js = self::makeLoaderConditionalScript( $script );
1789 return new WrappedString(
1790 Html::inlineScript( $js ),
1791 "<script>(RLQ=window.RLQ||[]).push(function(){",
1804 public static function makeConfigSetScript( array $configuration ) {
1805 $json = self::encodeJsonForScript( $configuration );
1806 if ( $json ===
false ) {
1807 $e =
new LogicException(
1808 'JSON serialization of config data failed. ' .
1809 'This usually means the config data is not valid UTF-8.'
1812 return 'mw.log.error(' . self::encodeJsonForScript( $e->__toString() ) .
');';
1814 return "mw.config.set($json);";
1830 public static function makePackedModulesString( array $modules ) {
1832 foreach ( $modules as $module ) {
1833 $pos = strrpos( $module,
'.' );
1834 $prefix = $pos ===
false ?
'' : substr( $module, 0, $pos );
1835 $suffix = $pos ===
false ? $module : substr( $module, $pos + 1 );
1836 $moduleMap[$prefix][] = $suffix;
1840 foreach ( $moduleMap as $prefix => $suffixes ) {
1841 $p = $prefix ===
'' ?
'' : $prefix .
'.';
1842 $arr[] = $p . implode(
',', $suffixes );
1844 return implode(
'|', $arr );
1858 public static function expandModuleNames( $modules ) {
1860 $exploded = explode(
'|', $modules );
1861 foreach ( $exploded as $group ) {
1862 if ( strpos( $group,
',' ) ===
false ) {
1869 $pos = strrpos( $group,
'.' );
1870 if ( $pos ===
false ) {
1872 $retval = array_merge( $retval, explode(
',', $group ) );
1876 $prefix = substr( $group, 0, $pos );
1877 $suffixes = explode(
',', substr( $group, $pos + 1 ) );
1878 foreach ( $suffixes as $suffix ) {
1879 $retval[] =
"$prefix.$suffix";
1895 public static function inDebugMode() {
1896 if ( self::$debugMode ===
null ) {
1899 $resourceLoaderDebug = MediaWikiServices::getInstance()->getMainConfig()->get(
1900 MainConfigNames::ResourceLoaderDebug );
1902 $wgRequest->getCookie(
'resourceLoaderDebug',
'', $resourceLoaderDebug ?
'true' :
'' )
1904 self::$debugMode = Context::debugFromString( $str );
1906 return self::$debugMode;
1919 public static function clearCache() {
1920 self::$debugMode =
null;
1932 public function createLoaderURL(
$source, Context $context,
1933 array $extraQuery = []
1935 $query = self::createLoaderQuery( $context, $extraQuery );
1936 $script = $this->getLoadScript(
$source );
1950 protected static function createLoaderQuery(
1951 Context $context, array $extraQuery = []
1953 return self::makeLoaderQuery(
1954 $context->getModules(),
1955 $context->getLanguage(),
1956 $context->getSkin(),
1957 $context->getUser(),
1958 $context->getVersion(),
1959 $context->getDebug(),
1960 $context->getOnly(),
1961 $context->getRequest()->getBool(
'printable' ),
1983 public static function makeLoaderQuery( array $modules, $lang, $skin, $user =
null,
1984 $version =
null, $debug = Context::DEBUG_OFF, $only =
null,
1985 $printable =
false, $handheld =
null, array $extraQuery = []
1988 'modules' => self::makePackedModulesString( $modules ),
1994 if ( $lang !== Context::DEFAULT_LANG ) {
1995 $query[
'lang'] = $lang;
1997 if ( $skin !== Context::DEFAULT_SKIN ) {
1998 $query[
'skin'] = $skin;
2000 if ( $debug !== Context::DEBUG_OFF ) {
2001 $query[
'debug'] = strval( $debug );
2003 if ( $user !==
null ) {
2004 $query[
'user'] = $user;
2006 if ( $version !==
null ) {
2007 $query[
'version'] = $version;
2009 if ( $only !==
null ) {
2010 $query[
'only'] = $only;
2013 $query[
'printable'] = 1;
2015 foreach ( $extraQuery as $name => $value ) {
2016 $query[$name] = $value;
2033 public static function isValidModuleName( $moduleName ) {
2034 $len = strlen( $moduleName );
2035 return $len <= 255 && strcspn( $moduleName,
'!,|', 0, $len ) === $len;
2048 public function getLessCompiler( array $vars = [], array $importDirs = [] ) {
2053 if ( !class_exists( Less_Parser::class ) ) {
2054 throw new RuntimeException(
'MediaWiki requires the less.php parser' );
2057 $importDirs[] =
"$IP/resources/src/mediawiki.less";
2059 $parser =
new Less_Parser;
2060 $parser->ModifyVars( $vars );
2061 $parser->SetOption(
'relativeUrls',
false );
2064 $formattedImportDirs = array_fill_keys( $importDirs,
'' );
2066 $formattedImportDirs[] =
static function (
$path ) {
2069 '@wikimedia/codex-icons/' =>
"$IP/resources/lib/codex-icons/",
2070 'mediawiki.skin.codex-design-tokens/' =>
"$IP/resources/lib/codex-design-tokens/",
2071 '@wikimedia/codex-design-tokens/' =>
static function ( $unused_path ) {
2072 throw new RuntimeException(
2073 'Importing from @wikimedia/codex-design-tokens is not supported. ' .
2074 "To use the Codex tokens, use `@import 'mediawiki.skin.variables.less';` instead."
2078 foreach ( $importMap as $importPath => $substPath ) {
2079 if ( str_starts_with(
$path, $importPath ) ) {
2080 $restOfPath = substr(
$path, strlen( $importPath ) );
2081 if ( is_callable( $substPath ) ) {
2082 $resolvedPath = call_user_func( $substPath, $restOfPath );
2084 $filePath = $substPath . $restOfPath;
2086 $resolvedPath =
null;
2087 if ( file_exists( $filePath ) ) {
2088 $resolvedPath = $filePath;
2089 } elseif ( file_exists(
"$filePath.less" ) ) {
2090 $resolvedPath =
"$filePath.less";
2094 if ( $resolvedPath !==
null ) {
2096 Less_Environment::normalizePath( $resolvedPath ),
2097 Less_Environment::normalizePath( dirname(
$path ) )
2104 return [
null, null ];
2106 $parser->SetImportDirs( $formattedImportDirs );
2124 public function expandUrl(
string $base,
string $url ): string {
2126 $isProtoRelative = strpos( $base,
'//' ) === 0;
2127 if ( $isProtoRelative ) {
2128 $base =
"https:$base";
2131 $baseUrl =
new Net_URL2( $base );
2132 $ret = $baseUrl->resolve( $url );
2133 if ( $isProtoRelative ) {
2134 $ret->setScheme(
false );
2136 return $ret->getURL();
2156 public static function filter( $filter, $data, array $options = [] ) {
2157 if ( strpos( $data, self::FILTER_NOMIN ) !==
false ) {
2161 if ( isset( $options[
'cache'] ) && $options[
'cache'] ===
false ) {
2162 return self::applyFilter( $filter, $data ) ?? $data;
2165 $statsFactory = MediaWikiServices::getInstance()->getStatsFactory();
2168 $key = $cache->makeGlobalKey(
2169 'resourceloader-filter',
2171 self::CACHE_VERSION,
2176 $incKey =
"resourceloader_cache.$filter.$status";
2177 $result = $cache->getWithSetCallback(
2180 static function () use ( $filter, $data, &$incKey, &$status ) {
2182 $incKey =
"resourceloader_cache.$filter.$status";
2183 return self::applyFilter( $filter, $data );
2186 $statsFactory->
getCounter(
'resourceloader_cache_total' )
2187 ->setLabel(
'type', $filter )
2188 ->setLabel(
'status', $status )
2189 ->copyToStatsdAt( [ $incKey ] )
2193 return $result ?? $data;
2201 private static function applyFilter( $filter, $data ) {
2202 $data = trim( $data );
2205 $data = ( $filter ===
'minify-css' )
2206 ? CSSMin::minify( $data )
2207 : JavaScriptMinifier::minify( $data );
2208 }
catch ( TimeoutException $e ) {
2210 }
catch ( Exception $e ) {
2229 public static function getUserDefaults(
2231 HookContainer $hookContainer,
2232 UserOptionsLookup $userOptionsLookup
2234 $defaultOptions = $userOptionsLookup->getDefaultOptions();
2235 $keysToExclude = [];
2236 $hookRunner =
new HookRunner( $hookContainer );
2237 $hookRunner->onResourceLoaderExcludeUserOptions( $keysToExclude, $context );
2238 foreach ( $keysToExclude as $excludedKey ) {
2239 unset( $defaultOptions[ $excludedKey ] );
2241 return $defaultOptions;
2252 public static function getSiteConfigSettings(
2253 Context $context, Config $conf
2255 $services = MediaWikiServices::getInstance();
2259 $contLang = $services->getContentLanguage();
2260 $namespaceIds = $contLang->getNamespaceIds();
2261 $caseSensitiveNamespaces = [];
2262 $nsInfo = $services->getNamespaceInfo();
2263 foreach ( $nsInfo->getCanonicalNamespaces() as $index => $name ) {
2264 $namespaceIds[$contLang->lc( $name )] = $index;
2265 if ( !$nsInfo->isCapitalized( $index ) ) {
2266 $caseSensitiveNamespaces[] = $index;
2270 $illegalFileChars = $conf->get( MainConfigNames::IllegalFileChars );
2273 $skin = $context->getSkin();
2277 'debug' => $context->getDebug(),
2279 'stylepath' => $conf->get( MainConfigNames::StylePath ),
2280 'wgArticlePath' => $conf->get( MainConfigNames::ArticlePath ),
2281 'wgScriptPath' => $conf->get( MainConfigNames::ScriptPath ),
2282 'wgScript' => $conf->get( MainConfigNames::Script ),
2283 'wgSearchType' => $conf->get( MainConfigNames::SearchType ),
2284 'wgVariantArticlePath' => $conf->get( MainConfigNames::VariantArticlePath ),
2285 'wgServer' => $conf->get( MainConfigNames::Server ),
2286 'wgServerName' => $conf->get( MainConfigNames::ServerName ),
2287 'wgUserLanguage' => $context->getLanguage(),
2288 'wgContentLanguage' => $contLang->getCode(),
2290 'wgFormattedNamespaces' => $contLang->getFormattedNamespaces(),
2291 'wgNamespaceIds' => $namespaceIds,
2292 'wgContentNamespaces' => $nsInfo->getContentNamespaces(),
2293 'wgSiteName' => $conf->get( MainConfigNames::Sitename ),
2294 'wgDBname' => $conf->get( MainConfigNames::DBname ),
2295 'wgWikiID' => WikiMap::getCurrentWikiId(),
2296 'wgCaseSensitiveNamespaces' => $caseSensitiveNamespaces,
2297 'wgCommentCodePointLimit' => CommentStore::COMMENT_CHARACTER_LIMIT,
2298 'wgExtensionAssetsPath' => $conf->get( MainConfigNames::ExtensionAssetsPath ),
2309 'wgActionPaths' => (object)$conf->get( MainConfigNames::ActionPaths ),
2311 'wgTranslateNumerals' => $conf->get( MainConfigNames::TranslateNumerals ),
2313 'wgExtraSignatureNamespaces' => $conf->get( MainConfigNames::ExtraSignatureNamespaces ),
2314 'wgLegalTitleChars' => Title::convertByteClassToUnicodeClass( Title::legalChars() ),
2315 'wgIllegalFileChars' => Title::convertByteClassToUnicodeClass( $illegalFileChars ),
2318 (
new HookRunner( $services->getHookContainer() ) )
2319 ->onResourceLoaderGetConfigVars( $vars, $skin, $conf );
2328 public function getErrors() {
2329 return $this->errors;