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;
869 $statStart = $_SERVER[
'REQUEST_TIME_FLOAT'];
870 return new ScopedCallback(
function () use ( $statStart ) {
871 $statTiming = microtime(
true ) - $statStart;
873 $this->statsFactory->getTiming(
'resourceloader_response_time_seconds' )
874 ->copyToStatsdAt(
'resourceloader.responseTime' )
875 ->observe( 1000 * $statTiming );
890 Context $context, $etag, $errors, array $extra = []
895 $maxage = self::MAXAGE_RECOVER;
904 $this->logger->debug(
'Client and server registry version out of sync' );
905 $maxage = self::MAXAGE_RECOVER;
906 } elseif ( $context->
getVersion() ===
null ) {
910 $maxage = $this->maxageUnversioned;
914 $maxage = $this->maxageVersioned;
919 header(
'Content-Type: text/plain; charset=utf-8' );
921 $context->
getImageObj()->sendResponseHeaders( $context );
924 header(
'Content-Type: application/json' );
925 } elseif ( $context->
getOnly() ===
'styles' ) {
926 header(
'Content-Type: text/css; charset=utf-8' );
927 header(
'Access-Control-Allow-Origin: *' );
929 header(
'Content-Type: text/javascript; charset=utf-8' );
933 header(
'ETag: ' . $etag );
936 header(
'Cache-Control: private, no-cache, must-revalidate' );
940 $staleDirective = ( $maxage > self::MAXAGE_RECOVER
941 ?
", stale-while-revalidate=" . min( 60, intval( $maxage / 2 ) )
944 header(
"Cache-Control: public, max-age=$maxage, s-maxage=$maxage" . $staleDirective );
945 header(
'Expires: ' . ConvertibleTimestamp::convert( TS_RFC2822, time() + $maxage ) );
947 foreach ( $extra as
$header ) {
965 $clientKeys = $context->
getRequest()->getHeader(
'If-None-Match', WebRequest::GETHEADER_LIST );
967 if ( $clientKeys !==
false && !$context->
getDebug() && in_array( $etag, $clientKeys ) ) {
979 HttpStatus::header( 304 );
981 $this->sendResponseHeaders( $context, $etag,
false );
994 private function getSourceMapUrl(
Context $context, $version ) {
995 return $this->createLoaderURL(
'local', $context, [
997 'version' => $version
1006 private function sendSourceMapVersionMismatch( $currentVersion ) {
1007 HttpStatus::header( 404 );
1008 header(
'Content-Type: text/plain; charset=utf-8' );
1009 header(
'X-Content-Type-Options: nosniff' );
1010 echo
"Can't deliver a source map for the requested version " .
1011 "since the version is now '$currentVersion'\n";
1018 private function sendSourceMapTypeNotImplemented() {
1019 HttpStatus::header( 404 );
1020 header(
'Content-Type: text/plain; charset=utf-8' );
1021 header(
'X-Content-Type-Options: nosniff' );
1022 echo
"Can't make a source map for this content type\n";
1034 $encText = str_replace(
'*/',
'* /', $text );
1035 return "/*\n$encText\n*/\n";
1045 return self::makeComment( self::formatExceptionNoComment( $e ) );
1056 if ( !MWExceptionRenderer::shouldShowExceptionDetails() ) {
1057 return MWExceptionHandler::getPublicLogMessage( $e );
1060 return MWExceptionHandler::getLogMessage( $e ) .
1062 MWExceptionHandler::getRedactedTraceAsString( $e );
1077 array $modules, array $missing = []
1079 if ( $modules === [] && $missing === [] ) {
1089 $data = $image->getImageData( $context );
1090 if ( $data ===
false ) {
1092 $this->errors[] =
'Image generation failed';
1098 foreach ( $missing as $name ) {
1099 $states[$name] =
'missing';
1103 $debug = (bool)$context->
getDebug();
1104 if ( $context->
isSourceMap() && count( $modules ) > 1 ) {
1105 $indexMap =
new IndexMap;
1111 foreach ( $modules as $name => $module ) {
1113 [ $response, $offset ] = $this->getOneModuleResponse( $context, $name, $module );
1115 $indexMap->addEncodedMap( $response, $offset );
1119 }
catch ( TimeoutException $e ) {
1121 }
catch ( Exception $e ) {
1122 $this->outputErrorAndLog( $e,
'Generating module package failed: {exception}' );
1125 $states[$name] =
'error';
1126 unset( $modules[$name] );
1132 if ( $modules && $only ===
'scripts' ) {
1135 foreach ( $modules as $name => $module ) {
1136 $states[$name] =
'ready';
1142 $stateScript = self::makeLoaderStateScript( $context, $states );
1144 $stateScript = self::filter(
'minify-js', $stateScript );
1147 $out = self::ensureNewline( $out ) . $stateScript;
1149 } elseif ( $states ) {
1150 $this->errors[] =
'Problematic modules: '
1158 return $indexMap->getMap();
1172 private function getOneModuleResponse(
Context $context, $name,
Module $module ) {
1178 if ( $only ===
'styles' ) {
1179 $minifier =
new IdentityMinifierState;
1180 $this->addOneModuleResponse( $context, $minifier, $name, $module, $this->extraHeaders );
1183 $styles = $minifier->getMinifiedOutput();
1185 return [ $styles, null ];
1188 self::filter(
'minify-css', $styles,
1189 [
'cache' => $shouldCache ] ),
1194 $minifier =
new IdentityMinifierState;
1195 $this->addOneModuleResponse( $context, $minifier, $name, $module, $this->extraHeaders );
1196 $plainContent = $minifier->getMinifiedOutput();
1198 return [ $plainContent, null ];
1202 $callback =
function () use ( $context, $name, $module, &$isHit ) {
1205 $minifier = (
new JavaScriptMapperState )
1206 ->outputFile( $this->createLoaderURL(
'local', $context, [
1207 'modules' => self::makePackedModulesString( $context->
getModules() ),
1211 $minifier =
new JavaScriptMinifierState;
1214 $discardedHeaders =
null;
1215 $this->addOneModuleResponse( $context, $minifier, $name, $module, $discardedHeaders );
1217 $sourceMap = $minifier->getRawSourceMap();
1218 $generated = $minifier->getMinifiedOutput();
1219 $offset = IndexMapOffset::newFromText( $generated );
1220 return [ $sourceMap, $offset->toArray() ];
1222 return [ $minifier->getMinifiedOutput(), null ];
1226 if ( $shouldCache ) {
1227 [ $response, $offsetArray ] = $this->srvCache->getWithSetCallback(
1228 $this->srvCache->makeGlobalKey(
1229 'resourceloader-mapped',
1230 self::CACHE_VERSION,
1233 md5( $plainContent )
1239 $mapType = $context->
isSourceMap() ?
'map-js' :
'minify-js';
1240 $statsdNamespace = implode(
'.', [
1241 "resourceloader_cache", $mapType, $isHit ?
'hit' :
'miss'
1243 $this->statsFactory->getCounter(
'resourceloader_cache_total' )
1244 ->setLabel(
'type', $mapType )
1245 ->setLabel(
'status', $isHit ?
'hit' :
'miss' )
1246 ->copyToStatsdAt( [ $statsdNamespace ] )
1249 [ $response, $offsetArray ] = $callback();
1251 $offset = $offsetArray ? IndexMapOffset::newFromArray( $offsetArray ) : null;
1253 return [ $response, $offset ];
1266 private function addOneModuleResponse(
1267 Context $context, MinifierState $minifier, $name, Module $module, &$headers
1269 $only = $context->getOnly();
1270 $debug = (bool)$context->getDebug();
1271 $content = $module->getModuleContent( $context );
1272 $version = $module->getVersionHash( $context );
1274 if ( $headers !==
null && isset( $content[
'headers'] ) ) {
1275 $headers = array_merge( $headers, $content[
'headers'] );
1281 $scripts = $content[
'scripts'];
1282 if ( !is_array( $scripts ) ) {
1285 throw new InvalidArgumentException(
'scripts must be an array' );
1287 if ( isset( $scripts[
'plainScripts'] ) ) {
1289 $this->addPlainScripts( $minifier, $name, $scripts[
'plainScripts'] );
1290 } elseif ( isset( $scripts[
'files'] ) ) {
1292 $this->addImplementScript(
1300 $content[
'deprecationWarning'] ??
null
1305 $styles = $content[
'styles'];
1309 if ( isset( $styles[
'css'] ) ) {
1310 $minifier->addOutput( implode(
'', $styles[
'css'] ) );
1314 $scripts = $content[
'scripts'] ??
'';
1315 if ( ( $name ===
'site' || $name ===
'user' )
1316 && isset( $scripts[
'plainScripts'] )
1322 $scripts = self::concatenatePlainScripts( $scripts[
'plainScripts'] );
1324 $scripts = self::filter(
'minify-js', $scripts );
1327 $this->addImplementScript(
1332 $content[
'styles'] ?? [],
1333 isset( $content[
'messagesBlob'] ) ?
new HtmlJsCode( $content[
'messagesBlob'] ) : null,
1334 $content[
'templates'] ?? [],
1335 $content[
'deprecationWarning'] ?? null
1339 $minifier->ensureNewline();
1348 public static function ensureNewline( $str ) {
1349 $end = substr( $str, -1 );
1350 if ( $end ===
false || $end ===
'' || $end ===
"\n" ) {
1362 public function getModulesByMessage( $messageKey ) {
1364 foreach ( $this->getModuleNames() as $moduleName ) {
1365 $module = $this->getModule( $moduleName );
1366 if ( in_array( $messageKey, $module->getMessages() ) ) {
1367 $moduleNames[] = $moduleName;
1370 return $moduleNames;
1394 private function addImplementScript( MinifierState $minifier,
1395 $moduleName, $version, $scripts, $styles, $messages, $templates, $deprecationWarning
1397 $implementKey =
"$moduleName@$version";
1400 $minifier->addOutput(
"mw.loader.impl(function(){return[" .
1401 Html::encodeJsVar( $implementKey ) .
"," );
1404 if ( is_string( $scripts ) ) {
1406 $minifier->addOutput( Html::encodeJsVar( $scripts ) );
1407 } elseif ( is_array( $scripts ) ) {
1408 if ( isset( $scripts[
'files'] ) ) {
1409 $minifier->addOutput(
1411 Html::encodeJsVar( $scripts[
'main'] ) .
1413 $this->addFiles( $minifier, $moduleName, $scripts[
'files'] );
1414 $minifier->addOutput(
"}" );
1415 } elseif ( isset( $scripts[
'plainScripts'] ) ) {
1416 if ( $this->isEmptyFileInfos( $scripts[
'plainScripts'] ) ) {
1417 $minifier->addOutput(
'null' );
1419 $minifier->addOutput(
"function($,jQuery,require,module){" );
1420 $this->addPlainScripts( $minifier, $moduleName, $scripts[
'plainScripts'] );
1421 $minifier->addOutput(
"}" );
1423 } elseif ( $scripts === [] || isset( $scripts[0] ) ) {
1425 $minifier->addOutput( Html::encodeJsVar( $scripts ) );
1427 throw new InvalidArgumentException(
'Invalid script array: ' .
1428 'must contain files, plainScripts or be an array of URLs' );
1431 throw new InvalidArgumentException(
'Script must be a string or array' );
1439 $messages ?? (
object)[],
1443 self::trimArray( $extraArgs );
1444 foreach ( $extraArgs as $arg ) {
1445 $minifier->addOutput(
',' . Html::encodeJsVar( $arg ) );
1447 $minifier->addOutput(
"];});" );
1460 private function addFiles( MinifierState $minifier, $moduleName, $files ) {
1462 $minifier->addOutput(
"{" );
1463 foreach ( $files as $fileName => $file ) {
1467 $minifier->addOutput(
"," );
1469 $minifier->addOutput( Html::encodeJsVar( $fileName ) .
':' );
1470 $this->addFileContent( $minifier, $moduleName,
'packageFile', $fileName, $file );
1472 $minifier->addOutput(
"}" );
1484 private function addFileContent( MinifierState $minifier,
1485 $moduleName, $sourceType, $sourceIndex, array $file
1487 $isScript = ( $file[
'type'] ??
'script' ) ===
'script';
1489 $filePath = $file[
'filePath'] ?? $file[
'virtualFilePath'] ??
null;
1490 if ( $filePath !==
null && $filePath->getRemoteBasePath() !== null ) {
1491 $url = $filePath->getRemotePath();
1493 $ext = $isScript ?
'js' :
'json';
1494 $scriptPath = $this->config->has( MainConfigNames::ScriptPath )
1495 ? $this->config->get( MainConfigNames::ScriptPath ) :
'';
1496 $url =
"$scriptPath/virtual-resource/$moduleName-$sourceType-$sourceIndex.$ext";
1498 $content = $file[
'content'];
1500 if ( $sourceType ===
'packageFile' ) {
1504 $minifier->addOutput(
"function(require,module,exports){" );
1505 $minifier->addSourceFile( $url, $content,
true );
1506 $minifier->ensureNewline();
1507 $minifier->addOutput(
"}" );
1509 $minifier->addSourceFile( $url, $content,
true );
1510 $minifier->ensureNewline();
1513 $content = Html::encodeJsVar( $content,
true );
1514 $minifier->addSourceFile( $url, $content,
true );
1525 private static function concatenatePlainScripts( $plainScripts ) {
1527 foreach ( $plainScripts as $script ) {
1530 $s .= self::ensureNewline( $script[
'content'] );
1543 private function addPlainScripts( MinifierState $minifier, $moduleName, $plainScripts ) {
1544 foreach ( $plainScripts as $index => $file ) {
1545 $this->addFileContent( $minifier, $moduleName,
'script', $index, $file );
1555 private function isEmptyFileInfos( $infos ) {
1557 foreach ( $infos as $info ) {
1558 $len += strlen( $info[
'content'] ??
'' );
1570 public static function makeCombinedStyles( array $stylePairs ) {
1572 foreach ( $stylePairs as $media => $styles ) {
1575 $styles = (array)$styles;
1576 foreach ( $styles as $style ) {
1577 $style = trim( $style );
1579 if ( $style ===
'' ) {
1584 $media = OutputPage::transformCssMedia( $media );
1586 if ( $media ===
'' || $media ==
'all' ) {
1588 } elseif ( is_string( $media ) ) {
1589 $out[] =
"@media $media {\n" . str_replace(
"\n",
"\n\t",
"\t" . $style ) .
"}";
1604 private static function encodeJsonForScript( $data ) {
1614 $jsonFlags = JSON_UNESCAPED_SLASHES |
1615 JSON_UNESCAPED_UNICODE |
1618 if ( self::inDebugMode() ) {
1619 $jsonFlags |= JSON_PRETTY_PRINT;
1621 return json_encode( $data, $jsonFlags );
1632 public static function makeLoaderStateScript(
1633 Context $context, array $states
1635 return 'mw.loader.state('
1639 . @$context->encodeJson( $states )
1643 private static function isEmptyObject( stdClass $obj ) {
1644 foreach ( $obj as $value ) {
1663 private static function trimArray( array &$array ): void {
1664 $i = count( $array );
1666 if ( $array[$i] ===
null
1667 || $array[$i] === []
1668 || ( $array[$i] instanceof HtmlJsCode && $array[$i]->value ===
'{}' )
1669 || ( $array[$i] instanceof stdClass && self::isEmptyObject( $array[$i] ) )
1671 unset( $array[$i] );
1703 public static function makeLoaderRegisterScript(
1704 Context $context, array $modules
1710 foreach ( $modules as $i => $module ) {
1712 $index[$module[0]] = $i;
1714 foreach ( $modules as &$module ) {
1715 if ( isset( $module[2] ) ) {
1716 foreach ( $module[2] as &$dependency ) {
1717 if ( isset( $index[$dependency] ) ) {
1719 $dependency = $index[$dependency];
1723 self::trimArray( $module );
1726 return 'mw.loader.register('
1727 . $context->encodeJson( $modules )
1744 public static function makeLoaderSourcesScript(
1745 Context $context, array $sources
1747 return 'mw.loader.addSource('
1748 . $context->encodeJson( $sources )
1758 public static function makeLoaderConditionalScript( $script ) {
1760 return '(RLQ=window.RLQ||[]).push(function(){' .
1761 trim( $script ) .
'});';
1772 public static function makeInlineCodeWithModule( $modules, $script ) {
1774 return '(RLQ=window.RLQ||[]).push(['
1775 . self::encodeJsonForScript( $modules ) .
','
1776 .
'function(){' . trim( $script ) .
'}'
1790 public static function makeInlineScript( $script, $nonce =
null ) {
1791 $js = self::makeLoaderConditionalScript( $script );
1792 return new WrappedString(
1793 Html::inlineScript( $js ),
1794 "<script>(RLQ=window.RLQ||[]).push(function(){",
1807 public static function makeConfigSetScript( array $configuration ) {
1808 $json = self::encodeJsonForScript( $configuration );
1809 if ( $json ===
false ) {
1810 $e =
new LogicException(
1811 'JSON serialization of config data failed. ' .
1812 'This usually means the config data is not valid UTF-8.'
1815 return 'mw.log.error(' . self::encodeJsonForScript( $e->__toString() ) .
');';
1817 return "mw.config.set($json);";
1833 public static function makePackedModulesString( array $modules ) {
1835 foreach ( $modules as $module ) {
1836 $pos = strrpos( $module,
'.' );
1837 $prefix = $pos ===
false ?
'' : substr( $module, 0, $pos );
1838 $suffix = $pos ===
false ? $module : substr( $module, $pos + 1 );
1839 $moduleMap[$prefix][] = $suffix;
1843 foreach ( $moduleMap as $prefix => $suffixes ) {
1844 $p = $prefix ===
'' ?
'' : $prefix .
'.';
1845 $arr[] = $p . implode(
',', $suffixes );
1847 return implode(
'|', $arr );
1861 public static function expandModuleNames( $modules ) {
1863 $exploded = explode(
'|', $modules );
1864 foreach ( $exploded as $group ) {
1865 if ( strpos( $group,
',' ) ===
false ) {
1872 $pos = strrpos( $group,
'.' );
1873 if ( $pos ===
false ) {
1875 $retval = array_merge( $retval, explode(
',', $group ) );
1879 $prefix = substr( $group, 0, $pos );
1880 $suffixes = explode(
',', substr( $group, $pos + 1 ) );
1881 foreach ( $suffixes as $suffix ) {
1882 $retval[] =
"$prefix.$suffix";
1898 public static function inDebugMode() {
1899 if ( self::$debugMode ===
null ) {
1902 $resourceLoaderDebug = MediaWikiServices::getInstance()->getMainConfig()->get(
1903 MainConfigNames::ResourceLoaderDebug );
1905 $wgRequest->getCookie(
'resourceLoaderDebug',
'', $resourceLoaderDebug ?
'true' :
'' )
1907 self::$debugMode = Context::debugFromString( $str );
1909 return self::$debugMode;
1922 public static function clearCache() {
1923 self::$debugMode =
null;
1935 public function createLoaderURL(
$source, Context $context,
1936 array $extraQuery = []
1938 $query = self::createLoaderQuery( $context, $extraQuery );
1939 $script = $this->getLoadScript(
$source );
1953 protected static function createLoaderQuery(
1954 Context $context, array $extraQuery = []
1956 return self::makeLoaderQuery(
1957 $context->getModules(),
1958 $context->getLanguage(),
1959 $context->getSkin(),
1960 $context->getUser(),
1961 $context->getVersion(),
1962 $context->getDebug(),
1963 $context->getOnly(),
1964 $context->getRequest()->getBool(
'printable' ),
1986 public static function makeLoaderQuery( array $modules, $lang, $skin, $user =
null,
1987 $version =
null, $debug = Context::DEBUG_OFF, $only =
null,
1988 $printable =
false, $handheld =
null, array $extraQuery = []
1991 'modules' => self::makePackedModulesString( $modules ),
1997 if ( $lang !== Context::DEFAULT_LANG ) {
1998 $query[
'lang'] = $lang;
2000 if ( $skin !== Context::DEFAULT_SKIN ) {
2001 $query[
'skin'] = $skin;
2003 if ( $debug !== Context::DEBUG_OFF ) {
2004 $query[
'debug'] = strval( $debug );
2006 if ( $user !==
null ) {
2007 $query[
'user'] = $user;
2009 if ( $version !==
null ) {
2010 $query[
'version'] = $version;
2012 if ( $only !==
null ) {
2013 $query[
'only'] = $only;
2016 $query[
'printable'] = 1;
2018 foreach ( $extraQuery as $name => $value ) {
2019 $query[$name] = $value;
2036 public static function isValidModuleName( $moduleName ) {
2037 $len = strlen( $moduleName );
2038 return $len <= 255 && strcspn( $moduleName,
'!,|', 0, $len ) === $len;
2051 public function getLessCompiler( array $vars = [], array $importDirs = [] ) {
2056 if ( !class_exists( Less_Parser::class ) ) {
2057 throw new RuntimeException(
'MediaWiki requires the less.php parser' );
2060 $importDirs[] =
"$IP/resources/src/mediawiki.less";
2062 $parser =
new Less_Parser;
2063 $parser->ModifyVars( $vars );
2064 $parser->SetOption(
'relativeUrls',
false );
2067 $formattedImportDirs = array_fill_keys( $importDirs,
'' );
2069 $formattedImportDirs[] =
static function (
$path ) {
2072 '@wikimedia/codex-icons/' =>
"$IP/resources/lib/codex-icons/",
2073 'mediawiki.skin.codex-design-tokens/' =>
"$IP/resources/lib/codex-design-tokens/",
2074 '@wikimedia/codex-design-tokens/' =>
static function ( $unused_path ) {
2075 throw new RuntimeException(
2076 'Importing from @wikimedia/codex-design-tokens is not supported. ' .
2077 "To use the Codex tokens, use `@import 'mediawiki.skin.variables.less';` instead."
2081 foreach ( $importMap as $importPath => $substPath ) {
2082 if ( str_starts_with(
$path, $importPath ) ) {
2083 $restOfPath = substr(
$path, strlen( $importPath ) );
2084 if ( is_callable( $substPath ) ) {
2085 $resolvedPath = call_user_func( $substPath, $restOfPath );
2087 $filePath = $substPath . $restOfPath;
2089 $resolvedPath =
null;
2090 if ( file_exists( $filePath ) ) {
2091 $resolvedPath = $filePath;
2092 } elseif ( file_exists(
"$filePath.less" ) ) {
2093 $resolvedPath =
"$filePath.less";
2097 if ( $resolvedPath !==
null ) {
2099 Less_Environment::normalizePath( $resolvedPath ),
2100 Less_Environment::normalizePath( dirname(
$path ) )
2107 return [
null, null ];
2109 $parser->SetImportDirs( $formattedImportDirs );
2127 public function expandUrl(
string $base,
string $url ): string {
2129 $isProtoRelative = strpos( $base,
'//' ) === 0;
2130 if ( $isProtoRelative ) {
2131 $base =
"https:$base";
2134 $baseUrl =
new Net_URL2( $base );
2135 $ret = $baseUrl->resolve( $url );
2136 if ( $isProtoRelative ) {
2137 $ret->setScheme(
false );
2139 return $ret->getURL();
2159 public static function filter( $filter, $data, array $options = [] ) {
2160 if ( strpos( $data, self::FILTER_NOMIN ) !==
false ) {
2164 if ( isset( $options[
'cache'] ) && $options[
'cache'] ===
false ) {
2165 return self::applyFilter( $filter, $data ) ?? $data;
2168 $statsFactory = MediaWikiServices::getInstance()->getStatsFactory();
2171 $key = $cache->makeGlobalKey(
2172 'resourceloader-filter',
2174 self::CACHE_VERSION,
2179 $incKey =
"resourceloader_cache.$filter.$status";
2180 $result = $cache->getWithSetCallback(
2183 static function () use ( $filter, $data, &$incKey, &$status ) {
2185 $incKey =
"resourceloader_cache.$filter.$status";
2186 return self::applyFilter( $filter, $data );
2189 $statsFactory->
getCounter(
'resourceloader_cache_total' )
2190 ->setLabel(
'type', $filter )
2191 ->setLabel(
'status', $status )
2192 ->copyToStatsdAt( [ $incKey ] )
2196 return $result ?? $data;
2204 private static function applyFilter( $filter, $data ) {
2205 $data = trim( $data );
2208 $data = ( $filter ===
'minify-css' )
2209 ? CSSMin::minify( $data )
2210 : JavaScriptMinifier::minify( $data );
2211 }
catch ( TimeoutException $e ) {
2213 }
catch ( Exception $e ) {
2232 public static function getUserDefaults(
2234 HookContainer $hookContainer,
2235 UserOptionsLookup $userOptionsLookup
2237 $defaultOptions = $userOptionsLookup->getDefaultOptions();
2238 $keysToExclude = [];
2239 $hookRunner =
new HookRunner( $hookContainer );
2240 $hookRunner->onResourceLoaderExcludeUserOptions( $keysToExclude, $context );
2241 foreach ( $keysToExclude as $excludedKey ) {
2242 unset( $defaultOptions[ $excludedKey ] );
2244 return $defaultOptions;
2255 public static function getSiteConfigSettings(
2256 Context $context, Config $conf
2258 $services = MediaWikiServices::getInstance();
2262 $contLang = $services->getContentLanguage();
2263 $namespaceIds = $contLang->getNamespaceIds();
2264 $caseSensitiveNamespaces = [];
2265 $nsInfo = $services->getNamespaceInfo();
2266 foreach ( $nsInfo->getCanonicalNamespaces() as $index => $name ) {
2267 $namespaceIds[$contLang->lc( $name )] = $index;
2268 if ( !$nsInfo->isCapitalized( $index ) ) {
2269 $caseSensitiveNamespaces[] = $index;
2273 $illegalFileChars = $conf->get( MainConfigNames::IllegalFileChars );
2276 $skin = $context->getSkin();
2280 'debug' => $context->getDebug(),
2282 'stylepath' => $conf->get( MainConfigNames::StylePath ),
2283 'wgArticlePath' => $conf->get( MainConfigNames::ArticlePath ),
2284 'wgScriptPath' => $conf->get( MainConfigNames::ScriptPath ),
2285 'wgScript' => $conf->get( MainConfigNames::Script ),
2286 'wgSearchType' => $conf->get( MainConfigNames::SearchType ),
2287 'wgVariantArticlePath' => $conf->get( MainConfigNames::VariantArticlePath ),
2288 'wgServer' => $conf->get( MainConfigNames::Server ),
2289 'wgServerName' => $conf->get( MainConfigNames::ServerName ),
2290 'wgUserLanguage' => $context->getLanguage(),
2291 'wgContentLanguage' => $contLang->getCode(),
2293 'wgFormattedNamespaces' => $contLang->getFormattedNamespaces(),
2294 'wgNamespaceIds' => $namespaceIds,
2295 'wgContentNamespaces' => $nsInfo->getContentNamespaces(),
2296 'wgSiteName' => $conf->get( MainConfigNames::Sitename ),
2297 'wgDBname' => $conf->get( MainConfigNames::DBname ),
2298 'wgWikiID' => WikiMap::getCurrentWikiId(),
2299 'wgCaseSensitiveNamespaces' => $caseSensitiveNamespaces,
2300 'wgCommentCodePointLimit' => CommentStore::COMMENT_CHARACTER_LIMIT,
2301 'wgExtensionAssetsPath' => $conf->get( MainConfigNames::ExtensionAssetsPath ),
2312 'wgActionPaths' => (object)$conf->get( MainConfigNames::ActionPaths ),
2314 'wgTranslateNumerals' => $conf->get( MainConfigNames::TranslateNumerals ),
2316 'wgExtraSignatureNamespaces' => $conf->get( MainConfigNames::ExtraSignatureNamespaces ),
2317 'wgLegalTitleChars' => Title::convertByteClassToUnicodeClass( Title::legalChars() ),
2318 'wgIllegalFileChars' => Title::convertByteClassToUnicodeClass( $illegalFileChars ),
2321 (
new HookRunner( $services->getHookContainer() ) )
2322 ->onResourceLoaderGetConfigVars( $vars, $skin, $conf );
2331 public function getErrors() {
2332 return $this->errors;