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;
124 private $statsFactory;
126 private $maxageVersioned;
128 private $maxageUnversioned;
131 private $modules = [];
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->statsFactory = $services->getStatsFactory();
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 ' . get_debug_type( $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 );
464 $modulesWithMessages = [];
465 foreach ( $moduleNames as $moduleName ) {
466 $module = $this->getModule( $moduleName );
467 if ( $module && $module->getMessages() ) {
468 $modulesWithMessages[$moduleName] = $module;
473 $store = $this->getMessageBlobStore();
474 $blobs = $store->getBlobs( $modulesWithMessages, $lang );
475 foreach ( $blobs as $moduleName => $blob ) {
476 $modulesWithMessages[$moduleName]->setMessageBlob( $blob, $lang );
487 $deps = $this->depStore->retrieve( self::RL_DEP_STORE_PREFIX,
"$moduleName|$variant" );
500 $hasPendingUpdate = (bool)$this->depStoreUpdateBuffer;
501 $entity =
"$moduleName|$variant";
503 if ( array_diff( $paths, $priorPaths ) || array_diff( $priorPaths, $paths ) ) {
506 $deps = $this->depStore->newEntityDependencies( $paths, time() );
507 $this->depStoreUpdateBuffer[$entity] = $deps;
509 $this->depStoreUpdateBuffer[$entity] =
null;
519 if ( !$hasPendingUpdate ) {
520 DeferredUpdates::addCallableUpdate(
function () {
521 $updatesByEntity = $this->depStoreUpdateBuffer;
522 $this->depStoreUpdateBuffer = [];
524 ->getObjectCacheFactory()->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' :
'' );
1903 self::$debugMode = Context::debugFromString( $str );
1905 return self::$debugMode;
1918 public static function clearCache() {
1919 self::$debugMode =
null;
1931 public function createLoaderURL(
$source, Context $context,
1932 array $extraQuery = []
1934 $query = self::createLoaderQuery( $context, $extraQuery );
1935 $script = $this->getLoadScript(
$source );
1949 protected static function createLoaderQuery(
1950 Context $context, array $extraQuery = []
1952 return self::makeLoaderQuery(
1953 $context->getModules(),
1954 $context->getLanguage(),
1955 $context->getSkin(),
1956 $context->getUser(),
1957 $context->getVersion(),
1958 $context->getDebug(),
1959 $context->getOnly(),
1960 $context->getRequest()->getBool(
'printable' ),
1982 public static function makeLoaderQuery( array $modules, $lang, $skin, $user =
null,
1983 $version =
null, $debug = Context::DEBUG_OFF, $only =
null,
1984 $printable =
false, $handheld =
null, array $extraQuery = []
1987 'modules' => self::makePackedModulesString( $modules ),
1993 if ( $lang !== Context::DEFAULT_LANG ) {
1994 $query[
'lang'] = $lang;
1996 if ( $skin !== Context::DEFAULT_SKIN ) {
1997 $query[
'skin'] = $skin;
1999 if ( $debug !== Context::DEBUG_OFF ) {
2000 $query[
'debug'] = strval( $debug );
2002 if ( $user !==
null ) {
2003 $query[
'user'] = $user;
2005 if ( $version !==
null ) {
2006 $query[
'version'] = $version;
2008 if ( $only !==
null ) {
2009 $query[
'only'] = $only;
2012 $query[
'printable'] = 1;
2014 foreach ( $extraQuery as $name => $value ) {
2015 $query[$name] = $value;
2032 public static function isValidModuleName( $moduleName ) {
2033 $len = strlen( $moduleName );
2034 return $len <= 255 && strcspn( $moduleName,
'!,|', 0, $len ) === $len;
2047 public function getLessCompiler( array $vars = [], array $importDirs = [] ) {
2051 if ( !class_exists( Less_Parser::class ) ) {
2052 throw new RuntimeException(
'MediaWiki requires the less.php parser' );
2055 $importDirs[] = MW_INSTALL_PATH .
'/resources/src/mediawiki.less';
2057 $parser =
new Less_Parser;
2058 $parser->ModifyVars( $vars );
2059 $parser->SetOption(
'relativeUrls',
false );
2060 $parser->SetOption(
'math',
'always' );
2063 $formattedImportDirs = array_fill_keys( $importDirs,
'' );
2066 $codexDevDir = $this->getConfig()->get( MainConfigNames::CodexDevelopmentDir );
2067 $formattedImportDirs[] =
static function (
$path ) use ( $codexDevDir ) {
2070 '@wikimedia/codex-icons/' => $codexDevDir !==
null ?
2071 "$codexDevDir/packages/codex-icons/dist/" :
2072 MW_INSTALL_PATH .
'/resources/lib/codex-icons/',
2073 'mediawiki.skin.codex/' => $codexDevDir !==
null ?
2074 "$codexDevDir/packages/codex/dist/" :
2075 MW_INSTALL_PATH .
'/resources/lib/codex/',
2076 'mediawiki.skin.codex-design-tokens/' => $codexDevDir !==
null ?
2077 "$codexDevDir/packages/codex-design-tokens/dist/" :
2078 MW_INSTALL_PATH .
'/resources/lib/codex-design-tokens/',
2079 '@wikimedia/codex-design-tokens/' =>
static function ( $unused_path ) {
2080 throw new RuntimeException(
2081 'Importing from @wikimedia/codex-design-tokens is not supported. ' .
2082 "To use the Codex tokens, use `@import 'mediawiki.skin.variables.less';` instead."
2086 foreach ( $importMap as $importPath => $substPath ) {
2087 if ( str_starts_with(
$path, $importPath ) ) {
2088 $restOfPath = substr(
$path, strlen( $importPath ) );
2089 if ( is_callable( $substPath ) ) {
2090 $resolvedPath = call_user_func( $substPath, $restOfPath );
2092 $filePath = $substPath . $restOfPath;
2094 $resolvedPath =
null;
2095 if ( file_exists( $filePath ) ) {
2096 $resolvedPath = $filePath;
2097 } elseif ( file_exists(
"$filePath.less" ) ) {
2098 $resolvedPath =
"$filePath.less";
2102 if ( $resolvedPath !==
null ) {
2104 Less_Environment::normalizePath( $resolvedPath ),
2105 Less_Environment::normalizePath( dirname(
$path ) )
2112 return [
null, null ];
2114 $parser->SetImportDirs( $formattedImportDirs );
2132 public function expandUrl(
string $base,
string $url ): string {
2134 $isProtoRelative = strpos( $base,
'//' ) === 0;
2135 if ( $isProtoRelative ) {
2136 $base =
"https:$base";
2139 $baseUrl =
new Net_URL2( $base );
2140 $ret = $baseUrl->resolve(
$url );
2141 if ( $isProtoRelative ) {
2142 $ret->setScheme(
false );
2144 return $ret->getURL();
2164 public static function filter( $filter, $data, array $options = [] ) {
2165 if ( strpos( $data, self::FILTER_NOMIN ) !==
false ) {
2169 if ( isset( $options[
'cache'] ) && $options[
'cache'] ===
false ) {
2170 return self::applyFilter( $filter, $data ) ?? $data;
2173 $statsFactory = MediaWikiServices::getInstance()->getStatsFactory();
2174 $cache = MediaWikiServices::getInstance()->getObjectCacheFactory()
2177 $key = $cache->makeGlobalKey(
2178 'resourceloader-filter',
2180 self::CACHE_VERSION,
2185 $incKey =
"resourceloader_cache.$filter.$status";
2186 $result = $cache->getWithSetCallback(
2189 static function () use ( $filter, $data, &$incKey, &$status ) {
2191 $incKey =
"resourceloader_cache.$filter.$status";
2192 return self::applyFilter( $filter, $data );
2195 $statsFactory->
getCounter(
'resourceloader_cache_total' )
2196 ->setLabel(
'type', $filter )
2197 ->setLabel(
'status', $status )
2198 ->copyToStatsdAt( [ $incKey ] )
2202 return $result ?? $data;
2210 private static function applyFilter( $filter, $data ) {
2211 $data = trim( $data );
2214 $data = ( $filter ===
'minify-css' )
2215 ? CSSMin::minify( $data )
2216 : JavaScriptMinifier::minify( $data );
2217 }
catch ( TimeoutException $e ) {
2219 }
catch ( Exception $e ) {
2238 public static function getUserDefaults(
2240 HookContainer $hookContainer,
2241 UserOptionsLookup $userOptionsLookup
2243 $defaultOptions = $userOptionsLookup->getDefaultOptions();
2244 $keysToExclude = [];
2245 $hookRunner =
new HookRunner( $hookContainer );
2246 $hookRunner->onResourceLoaderExcludeUserOptions( $keysToExclude, $context );
2247 foreach ( $keysToExclude as $excludedKey ) {
2248 unset( $defaultOptions[ $excludedKey ] );
2250 return $defaultOptions;
2261 public static function getSiteConfigSettings(
2262 Context $context, Config $conf
2264 $services = MediaWikiServices::getInstance();
2268 $contLang = $services->getContentLanguage();
2269 $namespaceIds = $contLang->getNamespaceIds();
2270 $caseSensitiveNamespaces = [];
2271 $nsInfo = $services->getNamespaceInfo();
2272 foreach ( $nsInfo->getCanonicalNamespaces() as $index => $name ) {
2273 $namespaceIds[$contLang->lc( $name )] = $index;
2274 if ( !$nsInfo->isCapitalized( $index ) ) {
2275 $caseSensitiveNamespaces[] = $index;
2279 $illegalFileChars = $conf->get( MainConfigNames::IllegalFileChars );
2282 $skin = $context->getSkin();
2286 'debug' => $context->getDebug(),
2288 'stylepath' => $conf->get( MainConfigNames::StylePath ),
2289 'wgArticlePath' => $conf->get( MainConfigNames::ArticlePath ),
2290 'wgScriptPath' => $conf->get( MainConfigNames::ScriptPath ),
2291 'wgScript' => $conf->get( MainConfigNames::Script ),
2292 'wgSearchType' => $conf->get( MainConfigNames::SearchType ),
2293 'wgVariantArticlePath' => $conf->get( MainConfigNames::VariantArticlePath ),
2294 'wgServer' => $conf->get( MainConfigNames::Server ),
2295 'wgServerName' => $conf->get( MainConfigNames::ServerName ),
2296 'wgUserLanguage' => $context->getLanguage(),
2297 'wgContentLanguage' => $contLang->getCode(),
2299 'wgFormattedNamespaces' => $contLang->getFormattedNamespaces(),
2300 'wgNamespaceIds' => $namespaceIds,
2301 'wgContentNamespaces' => $nsInfo->getContentNamespaces(),
2302 'wgSiteName' => $conf->get( MainConfigNames::Sitename ),
2303 'wgDBname' => $conf->get( MainConfigNames::DBname ),
2304 'wgWikiID' => WikiMap::getCurrentWikiId(),
2305 'wgCaseSensitiveNamespaces' => $caseSensitiveNamespaces,
2306 'wgCommentCodePointLimit' => CommentStore::COMMENT_CHARACTER_LIMIT,
2307 'wgExtensionAssetsPath' => $conf->get( MainConfigNames::ExtensionAssetsPath ),
2314 'wgUrlProtocols' => $services->getUrlUtils()->validProtocols(),
2318 'wgActionPaths' => (object)$conf->get( MainConfigNames::ActionPaths ),
2320 'wgTranslateNumerals' => $conf->get( MainConfigNames::TranslateNumerals ),
2322 'wgExtraSignatureNamespaces' => $conf->get( MainConfigNames::ExtraSignatureNamespaces ),
2323 'wgLegalTitleChars' => Title::convertByteClassToUnicodeClass( Title::legalChars() ),
2324 'wgIllegalFileChars' => Title::convertByteClassToUnicodeClass( $illegalFileChars ),
2327 (
new HookRunner( $services->getHookContainer() ) )
2328 ->onResourceLoaderGetConfigVars( $vars, $skin, $conf );
2337 public function getErrors() {
2338 return $this->errors;