26 use Psr\Log\LoggerAwareInterface;
27 use Psr\Log\LoggerInterface;
28 use Psr\Log\NullLogger;
30 use Wikimedia\WrappedString;
38 class ResourceLoader
implements LoggerAwareInterface {
40 const CACHE_VERSION = 8;
43 protected static $debugMode =
null;
55 protected $moduleInfos = [];
65 protected $testModuleNames = [];
71 protected $sources = [];
77 protected $errors = [];
86 protected $extraHeaders = [];
99 const FILTER_NOMIN =
'/*@nomin*/';
116 if ( !$moduleNames ) {
125 $vary =
"$skin|$lang";
126 $res =
$dbr->select(
'module_deps', [
'md_module',
'md_deps' ], [
127 'md_module' => $moduleNames,
133 $modulesWithDeps = [];
134 foreach (
$res as $row ) {
135 $module = $this->getModule( $row->md_module );
138 json_decode( $row->md_deps,
true )
140 $modulesWithDeps[] = $row->md_module;
144 foreach ( array_diff( $moduleNames, $modulesWithDeps )
as $name ) {
145 $module = $this->getModule(
$name );
147 $this->getModule(
$name )->setFileDependencies(
$context, [] );
156 foreach ( $moduleNames
as $name ) {
157 $module = $this->getModule(
$name );
158 if ( $module && $module->getMessages() ) {
162 $store = $this->getMessageBlobStore();
187 if ( strpos(
$data, self::FILTER_NOMIN ) !==
false ) {
195 $stats = MediaWikiServices::getInstance()->getStatsdDataFactory();
198 $key =
$cache->makeGlobalKey(
208 $stats->increment(
"resourceloader_cache.$filter.miss" );
212 $stats->increment(
"resourceloader_cache.$filter.hit" );
227 ? CSSMin::minify(
$data )
229 }
catch ( Exception
$e ) {
242 public function __construct(
Config $config =
null, LoggerInterface $logger =
null ) {
243 $this->logger = $logger ?:
new NullLogger();
247 $this->logger->debug( __METHOD__ .
' was called without providing a Config instance' );
248 $config = MediaWikiServices::getInstance()->getMainConfig();
250 $this->config = $config;
253 $this->addSource(
'local', $config->get(
'LoadScript' ) );
259 $this->
register( $config->get(
'ResourceModules' ) );
263 Hooks::run(
'ResourceLoaderRegisterModules', [ &$rl ] );
265 if ( $config->get(
'EnableJavaScriptTest' ) ===
true ) {
266 $this->registerTestModules();
275 public function getConfig() {
276 return $this->config;
283 public function setLogger( LoggerInterface $logger ) {
284 $this->logger = $logger;
291 public function getLogger() {
292 return $this->logger;
299 public function getMessageBlobStore() {
300 return $this->blobStore;
308 $this->blobStore = $blobStore;
322 public function register(
$name, $info =
null ) {
323 $moduleSkinStyles = $this->config->get(
'ResourceModuleSkinStyles' );
327 foreach ( $registrations
as $name => $info ) {
329 if ( isset( $this->moduleInfos[
$name] ) ) {
331 $this->logger->warning(
332 'ResourceLoader duplicate registration warning. ' .
333 'Another module has already been registered as ' .
$name
338 if ( !self::isValidModuleName(
$name ) ) {
339 throw new MWException(
"ResourceLoader module name '$name' is invalid, "
340 .
"see ResourceLoader::isValidModuleName()" );
345 $this->moduleInfos[
$name] = [
'object' => $info ];
346 $info->setName(
$name );
347 $this->modules[
$name] = $info;
348 } elseif ( is_array( $info ) ) {
350 $this->moduleInfos[
$name] = $info;
353 'ResourceLoader module info type error for module \'' .
$name .
358 // Last-minute changes
360 // Apply custom skin-defined styles to existing modules.
361 if ( $this->isFileModule( $name ) ) {
362 foreach ( $moduleSkinStyles as $skinName => $skinStyles ) {
363 // If this module already defines skinStyles for this skin, ignore $wgResourceModuleSkinStyles.
364 if ( isset( $this->moduleInfos[$name]['skinStyles
'][$skinName] ) ) {
368 // If $name is preceded with a '+
', the defined style files will be added to 'default'
369 // skinStyles, otherwise 'default' will be ignored as it normally would be.
370 if ( isset( $skinStyles[$name] ) ) {
371 $paths = (array)$skinStyles[$name];
373 } elseif ( isset( $skinStyles['+
' . $name] ) ) {
374 $paths = (array)$skinStyles['+
' . $name];
375 $styleFiles = isset( $this->moduleInfos[$name]['skinStyles
']['default'] ) ?
376 (array)$this->moduleInfos[$name]['skinStyles
']['default'] :
382 // Add new file paths, remapping them to refer to our directories and not use settings
383 // from the module we're modifying, which come
from the base
definition.
384 list( $localBasePath, $remoteBasePath ) =
391 $this->moduleInfos[
$name][
'skinStyles'][$skinName] = $styleFiles;
397 public function registerTestModules() {
400 if ( $this->config->get(
'EnableJavaScriptTest' ) !==
true ) {
401 throw new MWException(
'Attempt to register JavaScript test modules '
402 .
'but <code>$wgEnableJavaScriptTest</code> is false. '
403 .
'Edit your <code>LocalSettings.php</code> to enable it.' );
413 Hooks::run(
'ResourceLoaderTestModules', [ &$testModules, &$rl ] );
416 $testModules[
'qunit'] += $extRegistry->getAttribute(
'QUnitTestModules' );
419 foreach ( $testModules[
'qunit']
as &$module ) {
421 if ( isset( $module[
'dependencies'] ) && is_string( $module[
'dependencies'] ) ) {
422 $module[
'dependencies'] = [ $module[
'dependencies'] ];
425 $module[
'dependencies'][] =
'test.mediawiki.qunit.testrunner';
429 $testModules[
'qunit'] =
430 (
include "$IP/tests/qunit/QUnitTestResources.php" ) + $testModules[
'qunit'];
432 foreach ( $testModules
as $id => $names ) {
434 $this->
register( $testModules[$id] );
437 $this->testModuleNames[$id] = array_keys( $testModules[$id] );
451 public function addSource( $id, $loadUrl =
null ) {
453 if ( is_array( $id ) ) {
455 $this->addSource( $key,
$value );
461 if ( isset( $this->sources[$id] ) ) {
463 'ResourceLoader duplicate source addition error. ' .
464 'Another source has already been registered as ' . $id
469 if ( is_array( $loadUrl ) ) {
470 if ( !isset( $loadUrl[
'loadScript'] ) ) {
472 __METHOD__ .
' was passed an array with no "loadScript" key.'
476 $loadUrl = $loadUrl[
'loadScript'];
479 $this->sources[$id] = $loadUrl;
487 public function getModuleNames() {
488 return array_keys( $this->moduleInfos );
501 public function getTestModuleNames( $framework =
'all' ) {
503 if ( $framework ==
'all' ) {
504 return $this->testModuleNames;
505 } elseif ( isset( $this->testModuleNames[$framework] )
506 && is_array( $this->testModuleNames[$framework] )
508 return $this->testModuleNames[$framework];
521 public function isModuleRegistered(
$name ) {
522 return isset( $this->moduleInfos[
$name] );
536 public function getModule(
$name ) {
537 if ( !isset( $this->modules[
$name] ) ) {
538 if ( !isset( $this->moduleInfos[
$name] ) ) {
543 $info = $this->moduleInfos[
$name];
545 if ( isset( $info[
'object'] ) ) {
547 $object = $info[
'object'];
548 } elseif ( isset( $info[
'factory'] ) ) {
549 $object = call_user_func( $info[
'factory'], $info );
550 $object->setConfig( $this->getConfig() );
551 $object->setLogger( $this->logger );
555 $object =
new $class( $info );
556 $object->setConfig( $this->getConfig() );
557 $object->setLogger( $this->logger );
559 $object->setName(
$name );
560 $this->modules[
$name] = $object;
563 return $this->modules[
$name];
572 protected function isFileModule(
$name ) {
573 if ( !isset( $this->moduleInfos[
$name] ) ) {
576 $info = $this->moduleInfos[
$name];
577 if ( isset( $info[
'object'] ) ) {
582 !isset( $info[
'class'] ) ||
594 public function getSources() {
595 return $this->sources;
607 public function getLoadScript(
$source ) {
608 if ( !isset( $this->sources[
$source] ) ) {
609 throw new MWException(
"The $source source was never registered in ResourceLoader." );
611 return $this->sources[
$source];
619 public static function makeHash(
$value ) {
620 $hash = hash(
'fnv132',
$value );
621 return Wikimedia\base_convert( $hash, 16, 36, 7 );
633 public function outputErrorAndLog( Exception
$e, $msg,
array $context = [] ) {
635 $this->logger->warning(
639 $this->
errors[] = self::formatExceptionNoComment(
$e );
651 if ( !$moduleNames ) {
656 return $this->getModule( $module )->getVersionHash(
$context );
657 }
catch ( Exception
$e ) {
660 $this->outputErrorAndLog(
$e,
661 'Calculating version for "{module}" failed: {exception}',
669 return self::makeHash( implode(
'',
$hashes ) );
693 if ( !$this->getModule(
$name ) ) {
698 $moduleNames[] =
$name;
700 return $this->getCombinedVersion(
$context, $moduleNames );
724 $module = $this->getModule(
$name );
728 if ( $module->getGroup() ===
'private' ) {
729 $this->logger->debug(
"Request for private module '$name' denied" );
730 $this->
errors[] =
"Cannot show private module \"$name\"";
733 $modules[
$name] = $module;
742 }
catch ( Exception
$e ) {
743 $this->outputErrorAndLog(
$e,
'Preloading module info failed: {exception}' );
749 $versionHash = $this->getCombinedVersion(
$context, array_keys(
$modules ) );
750 }
catch ( Exception
$e ) {
751 $this->outputErrorAndLog(
$e,
'Calculating version hash failed: {exception}' );
756 $etag =
'W/"' . $versionHash .
'"';
759 if ( $this->tryRespondNotModified(
$context, $etag ) ) {
764 if ( $this->config->get(
'UseFileCache' ) ) {
766 if ( $this->tryRespondFromFileCache( $fileCache,
$context, $etag ) ) {
777 $warnings = ob_get_contents();
778 if ( strlen( $warnings ) ) {
779 $this->
errors[] = $warnings;
784 if ( isset( $fileCache ) && !$this->
errors && $missing === [] ) {
795 $this->sendResponseHeaders(
$context, $etag, (
bool)$this->
errors, $this->extraHeaders );
800 if (
$context->getImageObj() && $this->errors ) {
803 } elseif ( $this->
errors ) {
804 $errorText = implode(
"\n\n", $this->
errors );
805 $errorResponse = self::makeComment( $errorText );
806 if (
$context->shouldIncludeScripts() ) {
807 $errorResponse .=
'if (window.console && console.error) {'
820 protected function measureResponseTime(
Timing $timing ) {
822 $measure = $timing->
measure(
'responseTime',
'requestStart',
'requestShutdown' );
823 if ( $measure !==
false ) {
824 $stats = MediaWikiServices::getInstance()->getStatsdDataFactory();
825 $stats->timing(
'resourceloader.responseTime', $measure[
'duration'] * 1000 );
841 protected function sendResponseHeaders(
845 $rlMaxage = $this->config->get(
'ResourceLoaderMaxage' );
850 if ( is_null(
$context->getVersion() )
854 $maxage = $rlMaxage[
'unversioned'][
'client'];
855 $smaxage = $rlMaxage[
'unversioned'][
'server'];
859 $maxage = $rlMaxage[
'versioned'][
'client'];
860 $smaxage = $rlMaxage[
'versioned'][
'server'];
865 header(
'Content-Type: text/plain; charset=utf-8' );
869 } elseif (
$context->getOnly() ===
'styles' ) {
870 header(
'Content-Type: text/css; charset=utf-8' );
871 header(
'Access-Control-Allow-Origin: *' );
873 header(
'Content-Type: text/javascript; charset=utf-8' );
877 header(
'ETag: ' . $etag );
880 header(
'Cache-Control: private, no-cache, must-revalidate' );
881 header(
'Pragma: no-cache' );
883 header(
"Cache-Control: public, max-age=$maxage, s-maxage=$smaxage" );
884 $exp = min( $maxage, $smaxage );
885 header(
'Expires: ' .
wfTimestamp( TS_RFC2822, $exp + time() ) );
907 if ( $clientKeys !==
false && !
$context->getDebug() && in_array( $etag, $clientKeys ) ) {
921 $this->sendResponseHeaders(
$context, $etag,
false );
935 protected function tryRespondFromFileCache(
940 $rlMaxage = $this->config->get(
'ResourceLoaderMaxage' );
944 $maxage = is_null(
$context->getVersion() )
945 ? $rlMaxage[
'unversioned'][
'server']
946 : $rlMaxage[
'versioned'][
'server'];
959 $this->sendResponseHeaders(
$context, $etag,
false );
964 $warnings = ob_get_contents();
965 if ( strlen( $warnings ) ) {
988 public static function makeComment( $text ) {
989 $encText = str_replace(
'*/',
'* /', $text );
990 return "/*\n$encText\n*/\n";
999 public static function formatException(
$e ) {
1000 return self::makeComment( self::formatExceptionNoComment(
$e ) );
1010 protected static function formatExceptionNoComment(
$e ) {
1039 if (
$modules === [] && $missing === [] ) {
1050 if (
$data ===
false ) {
1052 $this->
errors[] =
'Image generation failed';
1057 foreach ( $missing
as $name ) {
1058 $states[
$name] =
'missing';
1064 $filter =
$context->getOnly() ===
'styles' ?
'minify-css' :
'minify-js';
1069 $implementKey =
$name .
'@' . $module->getVersionHash(
$context );
1072 if ( isset(
$content[
'headers'] ) ) {
1073 $this->extraHeaders = array_merge( $this->extraHeaders,
$content[
'headers'] );
1080 if ( is_string( $scripts ) ) {
1082 $strContent = $scripts;
1083 } elseif ( is_array( $scripts ) ) {
1085 $strContent = self::makeLoaderImplementScript( $implementKey, $scripts, [], [], [] );
1093 $strContent = isset( $styles[
'css'] ) ? implode(
'', $styles[
'css'] ) :
'';
1096 $scripts =
$content[
'scripts'] ??
'';
1097 if ( is_string( $scripts ) ) {
1098 if (
$name ===
'site' ||
$name ===
'user' ) {
1104 $scripts = self::filter(
'minify-js', $scripts );
1110 $strContent = self::makeLoaderImplementScript(
1121 $strContent = self::filter(
$filter, $strContent );
1124 if (
$context->getOnly() ===
'scripts' ) {
1126 $out .= $this->ensureNewline( $strContent );
1128 $out .= $strContent;
1131 }
catch ( Exception
$e ) {
1132 $this->outputErrorAndLog(
$e,
'Generating module package failed: {exception}' );
1135 $states[
$name] =
'error';
1138 $isRaw |= $module->isRaw();
1142 if (
$context->shouldIncludeScripts() && !
$context->getRaw() && !$isRaw ) {
1147 $states[
$name] =
'ready';
1152 if (
count( $states ) ) {
1153 $stateScript = self::makeLoaderStateScript( $states );
1155 $stateScript = self::filter(
'minify-js', $stateScript );
1158 $out = $this->ensureNewline(
$out ) . $stateScript;
1160 } elseif ( $states ) {
1161 $this->
errors[] =
'Problematic modules: '
1162 . self::encodeJsonForScript( $states );
1173 private function ensureNewline( $str ) {
1174 $end = substr( $str, -1 );
1175 if ( $end ===
false || $end ===
'' || $end ===
"\n" ) {
1187 public function getModulesByMessage( $messageKey ) {
1189 foreach ( $this->getModuleNames()
as $moduleName ) {
1190 $module = $this->getModule( $moduleName );
1191 if ( in_array( $messageKey, $module->getMessages() ) ) {
1192 $moduleNames[] = $moduleName;
1195 return $moduleNames;
1215 protected static function makeLoaderImplementScript(
1219 if ( $scripts->value ===
'' ) {
1221 } elseif ( self::inDebugMode() ) {
1222 $scripts =
new XmlJsCode(
"function ( $, jQuery, require, module ) {\n{$scripts->value}\n}" );
1224 $scripts =
new XmlJsCode(
'function($,jQuery,require,module){' . $scripts->value .
'}' );
1226 } elseif ( is_array( $scripts ) && isset( $scripts[
'files'] ) ) {
1227 $files = $scripts[
'files'];
1231 if (
$file[
'type'] ===
'script' ) {
1233 if ( self::inDebugMode() ) {
1234 $file =
new XmlJsCode(
"function ( require, module ) {\n{$file['content']}\n}" );
1243 'main' => $scripts[
'main'],
1245 ], self::inDebugMode() );
1246 } elseif ( !is_string( $scripts ) && !is_array( $scripts ) ) {
1247 throw new MWException(
'Invalid scripts error. Array of URLs or string of code expected.' );
1260 self::trimArray( $module );
1262 return Xml::encodeJsCall(
'mw.loader.implement', $module, self::inDebugMode() );
1272 public static function makeMessageSetScript(
$messages ) {
1287 public static function makeCombinedStyles(
array $stylePairs ) {
1289 foreach ( $stylePairs
as $media => $styles ) {
1293 $styles = (
array)$styles;
1294 foreach ( $styles
as $style ) {
1295 $style = trim( $style );
1297 if ( $style !==
'' ) {
1300 $media = OutputPage::transformCssMedia( $media );
1302 if ( $media ===
'' || $media ==
'all' ) {
1304 } elseif ( is_string( $media ) ) {
1305 $out[] =
"@media $media {\n" . str_replace(
"\n",
"\n\t",
"\t" . $style ) .
"}";
1323 public static function encodeJsonForScript(
$data ) {
1333 $jsonFlags = JSON_UNESCAPED_SLASHES |
1334 JSON_UNESCAPED_UNICODE |
1337 if ( self::inDebugMode() ) {
1338 $jsonFlags |= JSON_PRETTY_PRINT;
1340 return json_encode(
$data, $jsonFlags );
1357 public static function makeLoaderStateScript( $states, $state =
null ) {
1358 if ( !is_array( $states ) ) {
1359 $states = [ $states => $state ];
1368 private static function isEmptyObject( stdClass $obj ) {
1369 foreach ( $obj
as $key =>
$value ) {
1387 private static function trimArray(
array &$array ) {
1388 $i =
count( $array );
1390 if ( $array[$i] ===
null
1391 || $array[$i] === []
1392 || ( $array[$i] instanceof
XmlJsCode && $array[$i]->
value ===
'{}' )
1393 || ( $array[$i] instanceof stdClass && self::isEmptyObject( $array[$i] ) )
1395 unset( $array[$i] );
1427 public static function makeLoaderRegisterScript(
array $modules ) {
1434 $index[$module[0]] = $i;
1437 if ( isset( $module[2] ) ) {
1438 foreach ( $module[2]
as &$dependency ) {
1439 if ( isset( $index[$dependency] ) ) {
1441 $dependency = $index[$dependency];
1450 'mw.loader.register',
1470 public static function makeLoaderSourcesScript( $sources, $loadUrl =
null ) {
1471 if ( !is_array( $sources ) ) {
1472 $sources = [ $sources => $loadUrl ];
1475 'mw.loader.addSource',
1487 public static function makeLoaderConditionalScript( $script ) {
1489 return '(window.RLQ=window.RLQ||[]).push(function(){' .
1490 trim( $script ) .
'});';
1501 public static function makeInlineCodeWithModule(
$modules, $script ) {
1503 return '(window.RLQ=window.RLQ||[]).push(['
1504 . self::encodeJsonForScript(
$modules ) .
','
1505 .
'function(){' . trim( $script ) .
'}'
1520 public static function makeInlineScript( $script, $nonce =
null ) {
1521 $js = self::makeLoaderConditionalScript( $script );
1523 if ( $nonce ===
null ) {
1524 wfWarn( __METHOD__ .
" did not get nonce. Will break CSP" );
1525 } elseif ( $nonce !==
false ) {
1529 $escNonce =
' nonce="' . htmlspecialchars( $nonce ) .
'"';
1532 return new WrappedString(
1533 Html::inlineScript( $js, $nonce ),
1534 "<script$escNonce>(window.RLQ=window.RLQ||[]).push(function(){",
1547 public static function makeConfigSetScript(
array $configuration ) {
1553 if ( $js ===
false ) {
1555 'JSON serialization of config data failed. ' .
1556 'This usually means the config data is not valid UTF-8.'
1577 public static function makePackedModulesString(
$modules ) {
1580 $pos = strrpos( $module,
'.' );
1581 $prefix = $pos ===
false ?
'' : substr( $module, 0, $pos );
1582 $suffix = $pos ===
false ? $module : substr( $module, $pos + 1 );
1583 $moduleMap[$prefix][] = $suffix;
1587 foreach ( $moduleMap
as $prefix => $suffixes ) {
1588 $p = $prefix ===
'' ?
'' : $prefix .
'.';
1589 $arr[] = $p . implode(
',', $suffixes );
1591 return implode(
'|', $arr );
1605 public static function expandModuleNames(
$modules ) {
1607 $exploded = explode(
'|',
$modules );
1608 foreach ( $exploded
as $group ) {
1609 if ( strpos( $group,
',' ) ===
false ) {
1615 $pos = strrpos( $group,
'.' );
1616 if ( $pos ===
false ) {
1618 $retval = array_merge( $retval, explode(
',', $group ) );
1621 $prefix = substr( $group, 0, $pos );
1622 $suffixes = explode(
',', substr( $group, $pos + 1 ) );
1623 foreach ( $suffixes
as $suffix ) {
1624 $retval[] =
"$prefix.$suffix";
1637 public static function inDebugMode() {
1638 if ( self::$debugMode ===
null ) {
1640 self::$debugMode =
$wgRequest->getFuzzyBool(
'debug',
1644 return self::$debugMode;
1657 public static function clearCache() {
1658 self::$debugMode =
null;
1674 $script = $this->getLoadScript(
$source );
1689 return self::makeLoaderQuery(
1697 $context->getRequest()->getBool(
'printable' ),
1698 $context->getRequest()->getBool(
'handheld' ),
1721 $version =
null,
$debug =
false, $only =
null, $printable =
false,
1722 $handheld =
false, $extraQuery = []
1725 'modules' => self::makePackedModulesString(
$modules ),
1730 $query[
'debug'] =
'true';
1732 if (
$user !==
null ) {
1735 if ( $version !==
null ) {
1736 $query[
'version'] = $version;
1738 if ( $only !==
null ) {
1763 public static function isValidModuleName( $moduleName ) {
1764 return strcspn( $moduleName,
'!,|', 0, 255 ) === strlen( $moduleName );
1777 public function getLessCompiler(
$vars = [] ) {
1782 if ( !class_exists(
'Less_Parser' ) ) {
1783 throw new MWException(
'MediaWiki requires the less.php parser' );
1789 "$IP/resources/src/mediawiki.less/" =>
'',
1791 $parser->SetOption(
'relativeUrls',
false );
1803 public function getLessVars() {