24 use Psr\Log\LoggerAwareInterface;
25 use Psr\Log\LoggerInterface;
26 use Psr\Log\NullLogger;
28 use Wikimedia\WrappedString;
44 class ResourceLoader
implements LoggerAwareInterface {
56 protected $moduleInfos = [];
62 protected $testModuleNames = [];
64 protected $testSuiteModuleNames = [];
67 protected $sources = [];
69 protected $errors = [];
71 protected $extraHeaders = [];
74 protected static $debugMode =
null;
77 const CACHE_VERSION = 8;
80 const FILTER_NOMIN =
'/*@nomin*/';
97 if ( !$moduleNames ) {
106 $res =
$dbr->select(
'module_deps', [
'md_module',
'md_deps' ], [
107 'md_module' => $moduleNames,
113 $modulesWithDeps = [];
114 foreach (
$res as $row ) {
115 $module = $this->getModule( $row->md_module );
118 json_decode( $row->md_deps,
true )
120 $modulesWithDeps[] = $row->md_module;
124 foreach ( array_diff( $moduleNames, $modulesWithDeps ) as $name ) {
125 $module = $this->getModule( $name );
127 $this->getModule( $name )->setFileDependencies(
$context, [] );
136 foreach ( $moduleNames as $name ) {
137 $module = $this->getModule( $name );
138 if ( $module && $module->getMessages() ) {
142 $store = $this->getMessageBlobStore();
144 foreach ( $blobs as $name =>
$blob ) {
166 public static function filter(
$filter, $data, array $options = [] ) {
167 if ( strpos( $data, self::FILTER_NOMIN ) !==
false ) {
171 if ( isset( $options[
'cache'] ) && $options[
'cache'] ===
false ) {
172 return self::applyFilter(
$filter, $data );
175 $stats = MediaWikiServices::getInstance()->getStatsdDataFactory();
178 $key =
$cache->makeGlobalKey(
179 'resourceloader-filter',
185 $result =
$cache->get( $key );
186 if ( $result ===
false ) {
187 $stats->increment(
"resourceloader_cache.$filter.miss" );
188 $result = self::applyFilter(
$filter, $data );
189 $cache->set( $key, $result, 24 * 3600 );
191 $stats->increment(
"resourceloader_cache.$filter.hit" );
193 if ( $result ===
null ) {
201 private static function applyFilter(
$filter, $data ) {
202 $data = trim( $data );
205 $data = (
$filter ===
'minify-css' )
206 ? CSSMin::minify( $data )
208 }
catch ( Exception $e ) {
221 public function __construct(
Config $config =
null, LoggerInterface $logger =
null ) {
222 $this->logger = $logger ?:
new NullLogger();
225 wfDeprecated( __METHOD__ .
' without a Config instance',
'1.34' );
226 $config = MediaWikiServices::getInstance()->getMainConfig();
228 $this->config = $config;
231 $this->addSource(
'local', $config->
get(
'LoadScript' ) );
234 $this->
register(
'startup', [
'class' => ResourceLoaderStartUpModule::class ] );
242 public function getConfig() {
243 return $this->config;
250 public function setLogger( LoggerInterface $logger ) {
251 $this->logger = $logger;
258 public function getLogger() {
259 return $this->logger;
266 public function getMessageBlobStore() {
267 return $this->blobStore;
275 $this->blobStore = $blobStore;
289 public function register( $name, $info = null ) {
290 $moduleSkinStyles = $this->config->get(
'ResourceModuleSkinStyles' );
293 $registrations = is_array( $name ) ? $name : [ $name => $info ];
294 foreach ( $registrations as $name => $info ) {
296 if ( isset( $this->moduleInfos[$name] ) ) {
298 $this->logger->warning(
299 'ResourceLoader duplicate registration warning. ' .
300 'Another module has already been registered as ' . $name
305 if ( !self::isValidModuleName( $name ) ) {
306 throw new MWException(
"ResourceLoader module name '$name' is invalid, "
307 .
"see ResourceLoader::isValidModuleName()" );
309 if ( !is_array( $info ) ) {
310 throw new InvalidArgumentException(
311 'Invalid module info for "' . $name .
'": expected array, got ' . gettype( $info )
316 $this->moduleInfos[$name] = $info;
320 if ( $this->isFileModule( $name ) ) {
321 foreach ( $moduleSkinStyles as $skinName => $skinStyles ) {
323 if ( isset( $this->moduleInfos[$name][
'skinStyles'][$skinName] ) ) {
329 if ( isset( $skinStyles[$name] ) ) {
330 $paths = (array)$skinStyles[$name];
332 } elseif ( isset( $skinStyles[
'+' . $name] ) ) {
333 $paths = (array)$skinStyles[
'+' . $name];
334 $styleFiles = isset( $this->moduleInfos[$name][
'skinStyles'][
'default'] ) ?
335 (array)$this->moduleInfos[$name][
'skinStyles'][
'default'] :
343 list( $localBasePath, $remoteBasePath ) =
346 foreach ( $paths as
$path ) {
350 $this->moduleInfos[$name][
'skinStyles'][$skinName] = $styleFiles;
360 public function registerTestModules() {
363 if ( $this->config->get(
'EnableJavaScriptTest' ) !==
true ) {
364 throw new MWException(
'Attempt to register JavaScript test modules '
365 .
'but <code>$wgEnableJavaScriptTest</code> is false. '
366 .
'Edit your <code>LocalSettings.php</code> to enable it.' );
370 $testModulesMeta = [
'qunit' => [] ];
375 Hooks::run(
'ResourceLoaderTestModules', [ &$testModulesMeta, &$rl ] );
378 $testModules = $testModulesMeta[
'qunit'] + $extRegistry->getAttribute(
'QUnitTestModules' );
380 $testSuiteModuleNames = [];
381 foreach ( $testModules as $name => &$module ) {
383 if ( isset( $module[
'dependencies'] ) && is_string( $module[
'dependencies'] ) ) {
384 $module[
'dependencies'] = [ $module[
'dependencies'] ];
388 $module[
'dependencies'][] =
'test.mediawiki.qunit.testrunner';
391 $testSuiteModuleNames[] = $name;
395 $testModules = ( include
"$IP/tests/qunit/QUnitTestResources.php" ) + $testModules;
396 $testSuiteModuleNames[] =
'test.mediawiki.qunit.suites';
398 $this->
register( $testModules );
399 $this->testSuiteModuleNames = $testSuiteModuleNames;
412 public function addSource( $id, $loadUrl =
null ) {
414 if ( is_array( $id ) ) {
415 foreach ( $id as $key => $value ) {
416 $this->addSource( $key, $value );
422 if ( isset( $this->sources[$id] ) ) {
424 'ResourceLoader duplicate source addition error. ' .
425 'Another source has already been registered as ' . $id
430 if ( is_array( $loadUrl ) ) {
431 if ( !isset( $loadUrl[
'loadScript'] ) ) {
433 __METHOD__ .
' was passed an array with no "loadScript" key.'
437 $loadUrl = $loadUrl[
'loadScript'];
440 $this->sources[$id] = $loadUrl;
448 public function getModuleNames() {
449 return array_keys( $this->moduleInfos );
459 public function getTestSuiteModuleNames() {
460 return $this->testSuiteModuleNames;
470 public function isModuleRegistered( $name ) {
471 return isset( $this->moduleInfos[$name] );
485 public function getModule( $name ) {
486 if ( !isset( $this->modules[$name] ) ) {
487 if ( !isset( $this->moduleInfos[$name] ) ) {
492 $info = $this->moduleInfos[$name];
493 if ( isset( $info[
'factory'] ) ) {
495 $object = call_user_func( $info[
'factory'], $info );
497 $class = $info[
'class'] ?? ResourceLoaderFileModule::class;
499 $object =
new $class( $info );
501 $object->setConfig( $this->getConfig() );
502 $object->setLogger( $this->logger );
503 $object->setName( $name );
504 $this->modules[$name] = $object;
507 return $this->modules[$name];
516 protected function isFileModule( $name ) {
517 if ( !isset( $this->moduleInfos[$name] ) ) {
520 $info = $this->moduleInfos[$name];
521 return !isset( $info[
'factory'] ) && (
523 !isset( $info[
'class'] ) ||
525 $info[
'class'] === ResourceLoaderFileModule::class ||
526 is_subclass_of( $info[
'class'], ResourceLoaderFileModule::class )
535 public function getSources() {
536 return $this->sources;
548 public function getLoadScript(
$source ) {
549 if ( !isset( $this->sources[
$source] ) ) {
550 throw new MWException(
"The $source source was never registered in ResourceLoader." );
552 return $this->sources[
$source];
558 const HASH_LENGTH = 5;
622 public static function makeHash( $value ) {
623 $hash = hash(
'fnv132', $value );
627 Wikimedia\base_convert( $hash, 16, 36, self::HASH_LENGTH ),
642 public function outputErrorAndLog( Exception $e, $msg, array
$context = [] ) {
644 $this->logger->warning(
648 $this->errors[] = self::formatExceptionNoComment( $e );
660 if ( !$moduleNames ) {
665 return $this->getModule( $module )->getVersionHash(
$context );
666 }
catch ( Exception $e ) {
669 $this->outputErrorAndLog( $e,
670 'Calculating version for "{module}" failed: {exception}',
678 return self::makeHash( implode(
'',
$hashes ) );
697 wfDeprecated( __METHOD__ .
' without $modules',
'1.34' );
707 if ( !$this->getModule( $name ) ) {
714 return $this->getCombinedVersion(
$context, $filtered );
737 foreach (
$context->getModules() as $name ) {
738 $module = $this->getModule( $name );
742 if ( $module->getGroup() ===
'private' ) {
744 $this->logger->debug(
"Request for private module '$name' denied" );
745 $this->errors[] =
"Cannot build private module \"$name\"";
757 }
catch ( Exception $e ) {
758 $this->outputErrorAndLog( $e,
'Preloading module info failed: {exception}' );
764 $versionHash = $this->getCombinedVersion(
$context, array_keys(
$modules ) );
765 }
catch ( Exception $e ) {
766 $this->outputErrorAndLog( $e,
'Calculating version hash failed: {exception}' );
771 $etag =
'W/"' . $versionHash .
'"';
774 if ( $this->tryRespondNotModified(
$context, $etag ) ) {
779 if ( $this->config->get(
'UseFileCache' ) ) {
781 if ( $this->tryRespondFromFileCache( $fileCache,
$context, $etag ) ) {
794 $warnings = ob_get_contents();
795 if ( strlen( $warnings ) ) {
796 $this->errors[] = $warnings;
814 $this->sendResponseHeaders(
$context, $etag, (
bool)$this->errors, $this->extraHeaders );
819 if (
$context->getImageObj() && $this->errors ) {
821 $response = implode(
"\n\n", $this->errors );
822 } elseif ( $this->errors ) {
823 $errorText = implode(
"\n\n", $this->errors );
824 $errorResponse = self::makeComment( $errorText );
825 if (
$context->shouldIncludeScripts() ) {
826 $errorResponse .=
'if (window.console && console.error) { console.error('
827 .
$context->encodeJson( $errorText )
839 protected function measureResponseTime(
Timing $timing ) {
841 $measure = $timing->
measure(
'responseTime',
'requestStart',
'requestShutdown' );
842 if ( $measure !==
false ) {
843 $stats = MediaWikiServices::getInstance()->getStatsdDataFactory();
844 $stats->timing(
'resourceloader.responseTime', $measure[
'duration'] * 1000 );
860 protected function sendResponseHeaders(
864 $rlMaxage = $this->config->get(
'ResourceLoaderMaxage' );
869 if ( is_null(
$context->getVersion() )
873 $maxage = $rlMaxage[
'unversioned'][
'client'];
874 $smaxage = $rlMaxage[
'unversioned'][
'server'];
878 $maxage = $rlMaxage[
'versioned'][
'client'];
879 $smaxage = $rlMaxage[
'versioned'][
'server'];
884 header(
'Content-Type: text/plain; charset=utf-8' );
888 } elseif (
$context->getOnly() ===
'styles' ) {
889 header(
'Content-Type: text/css; charset=utf-8' );
890 header(
'Access-Control-Allow-Origin: *' );
892 header(
'Content-Type: text/javascript; charset=utf-8' );
896 header(
'ETag: ' . $etag );
899 header(
'Cache-Control: private, no-cache, must-revalidate' );
900 header(
'Pragma: no-cache' );
902 header(
"Cache-Control: public, max-age=$maxage, s-maxage=$smaxage" );
903 $exp = min( $maxage, $smaxage );
904 header(
'Expires: ' .
wfTimestamp( TS_RFC2822, $exp + time() ) );
906 foreach ( $extra as
$header ) {
926 if ( $clientKeys !==
false && !
$context->getDebug() && in_array( $etag, $clientKeys ) ) {
940 $this->sendResponseHeaders(
$context, $etag,
false );
954 protected function tryRespondFromFileCache(
959 $rlMaxage = $this->config->get(
'ResourceLoaderMaxage' );
963 $maxage = is_null(
$context->getVersion() )
964 ? $rlMaxage[
'unversioned'][
'server']
965 : $rlMaxage[
'versioned'][
'server'];
978 $this->sendResponseHeaders(
$context, $etag,
false );
983 $warnings = ob_get_contents();
984 if ( strlen( $warnings ) ) {
1007 public static function makeComment( $text ) {
1008 $encText = str_replace(
'*/',
'* /', $text );
1009 return "/*\n$encText\n*/\n";
1018 public static function formatException( $e ) {
1019 return self::makeComment( self::formatExceptionNoComment( $e ) );
1029 protected static function formatExceptionNoComment( $e ) {
1053 array
$modules, array $missing = []
1058 if (
$modules === [] && $missing === [] ) {
1068 $data = $image->getImageData(
$context );
1069 if ( $data ===
false ) {
1071 $this->errors[] =
'Image generation failed';
1076 foreach ( $missing as $name ) {
1077 $states[$name] =
'missing';
1080 $filter =
$context->getOnly() ===
'styles' ?
'minify-css' :
'minify-js';
1082 foreach (
$modules as $name => $module ) {
1085 $implementKey = $name .
'@' . $module->getVersionHash(
$context );
1088 if ( isset(
$content[
'headers'] ) ) {
1089 $this->extraHeaders = array_merge( $this->extraHeaders,
$content[
'headers'] );
1096 if ( is_string( $scripts ) ) {
1098 $strContent = $scripts;
1099 } elseif ( is_array( $scripts ) ) {
1101 $strContent = self::makeLoaderImplementScript(
1116 $strContent = isset( $styles[
'css'] ) ? implode(
'', $styles[
'css'] ) :
'';
1119 $scripts =
$content[
'scripts'] ??
'';
1120 if ( is_string( $scripts ) ) {
1121 if ( $name ===
'site' || $name ===
'user' ) {
1127 $scripts = self::filter(
'minify-js', $scripts );
1133 $strContent = self::makeLoaderImplementScript(
1145 $strContent = self::filter(
$filter, $strContent );
1149 $strContent = $this->ensureNewline( $strContent );
1152 if (
$context->getOnly() ===
'scripts' ) {
1154 $out .= $this->ensureNewline( $strContent );
1156 $out .= $strContent;
1159 }
catch ( Exception $e ) {
1160 $this->outputErrorAndLog( $e,
'Generating module package failed: {exception}' );
1163 $states[$name] =
'error';
1173 foreach (
$modules as $name => $module ) {
1174 $states[$name] =
'ready';
1180 $stateScript = self::makeLoaderStateScript(
$context, $states );
1182 $stateScript = self::filter(
'minify-js', $stateScript );
1185 $out = $this->ensureNewline( $out ) . $stateScript;
1187 } elseif ( $states ) {
1188 $this->errors[] =
'Problematic modules: '
1200 private function ensureNewline( $str ) {
1201 $end = substr( $str, -1 );
1202 if ( $end ===
false || $end ===
'' || $end ===
"\n" ) {
1214 public function getModulesByMessage( $messageKey ) {
1216 foreach ( $this->getModuleNames() as $moduleName ) {
1217 $module = $this->getModule( $moduleName );
1218 if ( in_array( $messageKey, $module->getMessages() ) ) {
1219 $moduleNames[] = $moduleName;
1222 return $moduleNames;
1243 private static function makeLoaderImplementScript(
1247 if ( $scripts->value ===
'' ) {
1249 } elseif (
$context->getDebug() ) {
1250 $scripts =
new XmlJsCode(
"function ( $, jQuery, require, module ) {\n{$scripts->value}\n}" );
1252 $scripts =
new XmlJsCode(
'function($,jQuery,require,module){' . $scripts->value .
'}' );
1254 } elseif ( is_array( $scripts ) && isset( $scripts[
'files'] ) ) {
1255 $files = $scripts[
'files'];
1259 if (
$file[
'type'] ===
'script' ) {
1262 $file =
new XmlJsCode(
"function ( require, module ) {\n{$file['content']}\n}" );
1271 'main' => $scripts[
'main'],
1274 } elseif ( !is_string( $scripts ) && !is_array( $scripts ) ) {
1275 throw new MWException(
'Invalid scripts error. Array of URLs or string of code expected.' );
1288 self::trimArray( $module );
1299 public static function makeMessageSetScript( $messages ) {
1300 return 'mw.messages.set('
1301 . self::encodeJsonForScript( (
object)$messages )
1312 public static function makeCombinedStyles( array $stylePairs ) {
1314 foreach ( $stylePairs as $media => $styles ) {
1318 $styles = (array)$styles;
1319 foreach ( $styles as $style ) {
1320 $style = trim( $style );
1322 if ( $style !==
'' ) {
1325 $media = OutputPage::transformCssMedia( $media );
1327 if ( $media ===
'' || $media ==
'all' ) {
1329 } elseif ( is_string( $media ) ) {
1330 $out[] =
"@media $media {\n" . str_replace(
"\n",
"\n\t",
"\t" . $style ) .
"}";
1348 public static function encodeJsonForScript( $data ) {
1358 $jsonFlags = JSON_UNESCAPED_SLASHES |
1359 JSON_UNESCAPED_UNICODE |
1362 if ( self::inDebugMode() ) {
1363 $jsonFlags |= JSON_PRETTY_PRINT;
1365 return json_encode( $data, $jsonFlags );
1380 public static function makeLoaderStateScript(
1383 return 'mw.loader.state('
1388 private static function isEmptyObject( stdClass $obj ) {
1389 foreach ( $obj as $key => $value ) {
1407 private static function trimArray( array &$array ) {
1408 $i = count( $array );
1410 if ( $array[$i] ===
null
1411 || $array[$i] === []
1412 || ( $array[$i] instanceof
XmlJsCode && $array[$i]->
value ===
'{}' )
1413 || ( $array[$i] instanceof stdClass && self::isEmptyObject( $array[$i] ) )
1415 unset( $array[$i] );
1447 public static function makeLoaderRegisterScript(
1454 foreach (
$modules as $i => &$module ) {
1456 $index[$module[0]] = $i;
1459 if ( isset( $module[2] ) ) {
1460 foreach ( $module[2] as &$dependency ) {
1461 if ( isset( $index[$dependency] ) ) {
1463 $dependency = $index[$dependency];
1469 array_walk(
$modules, [ self::class,
'trimArray' ] );
1471 return 'mw.loader.register('
1490 public static function makeLoaderSourcesScript(
1493 return 'mw.loader.addSource('
1504 public static function makeLoaderConditionalScript( $script ) {
1506 return '(RLQ=window.RLQ||[]).push(function(){' .
1507 trim( $script ) .
'});';
1518 public static function makeInlineCodeWithModule(
$modules, $script ) {
1520 return '(RLQ=window.RLQ||[]).push(['
1521 . self::encodeJsonForScript(
$modules ) .
','
1522 .
'function(){' . trim( $script ) .
'}'
1537 public static function makeInlineScript( $script, $nonce =
null ) {
1538 $js = self::makeLoaderConditionalScript( $script );
1540 if ( $nonce ===
null ) {
1541 wfWarn( __METHOD__ .
" did not get nonce. Will break CSP" );
1542 } elseif ( $nonce !==
false ) {
1546 $escNonce =
' nonce="' . htmlspecialchars( $nonce ) .
'"';
1549 return new WrappedString(
1550 Html::inlineScript( $js, $nonce ),
1551 "<script$escNonce>(RLQ=window.RLQ||[]).push(function(){",
1564 public static function makeConfigSetScript( array $configuration ) {
1565 $json = self::encodeJsonForScript( $configuration );
1566 if ( $json ===
false ) {
1568 'JSON serialization of config data failed. ' .
1569 'This usually means the config data is not valid UTF-8.'
1572 return 'mw.log.error(' . self::encodeJsonForScript( $e->__toString() ) .
');';
1574 return "mw.config.set($json);";
1590 public static function makePackedModulesString(
$modules ) {
1593 $pos = strrpos( $module,
'.' );
1594 $prefix = $pos ===
false ?
'' : substr( $module, 0, $pos );
1595 $suffix = $pos ===
false ? $module : substr( $module, $pos + 1 );
1596 $moduleMap[$prefix][] = $suffix;
1600 foreach ( $moduleMap as $prefix => $suffixes ) {
1601 $p = $prefix ===
'' ?
'' : $prefix .
'.';
1602 $arr[] = $p . implode(
',', $suffixes );
1604 return implode(
'|', $arr );
1618 public static function expandModuleNames(
$modules ) {
1620 $exploded = explode(
'|',
$modules );
1621 foreach ( $exploded as $group ) {
1622 if ( strpos( $group,
',' ) ===
false ) {
1628 $pos = strrpos( $group,
'.' );
1629 if ( $pos ===
false ) {
1631 $retval = array_merge( $retval, explode(
',', $group ) );
1634 $prefix = substr( $group, 0, $pos );
1635 $suffixes = explode(
',', substr( $group, $pos + 1 ) );
1636 foreach ( $suffixes as $suffix ) {
1637 $retval[] =
"$prefix.$suffix";
1650 public static function inDebugMode() {
1651 if ( self::$debugMode ===
null ) {
1653 self::$debugMode =
$wgRequest->getFuzzyBool(
'debug',
1657 return self::$debugMode;
1670 public static function clearCache() {
1671 self::$debugMode =
null;
1686 $query = self::createLoaderQuery(
$context, $extraQuery );
1687 $script = $this->getLoadScript(
$source );
1702 return self::makeLoaderQuery(
1710 $context->getRequest()->getBool(
'printable' ),
1711 $context->getRequest()->getBool(
'handheld' ),
1732 public static function makeLoaderQuery(
$modules,
$lang, $skin, $user =
null,
1733 $version =
null,
$debug =
false, $only =
null, $printable =
false,
1734 $handheld =
false, $extraQuery = []
1737 'modules' => self::makePackedModulesString(
$modules ),
1744 $query[
'lang'] =
$lang;
1747 $query[
'skin'] = $skin;
1750 $query[
'debug'] =
'true';
1752 if ( $user !==
null ) {
1753 $query[
'user'] = $user;
1755 if ( $version !==
null ) {
1756 $query[
'version'] = $version;
1758 if ( $only !==
null ) {
1759 $query[
'only'] = $only;
1762 $query[
'printable'] = 1;
1765 $query[
'handheld'] = 1;
1767 $query += $extraQuery;
1783 public static function isValidModuleName( $moduleName ) {
1784 return strcspn( $moduleName,
'!,|', 0, 255 ) === strlen( $moduleName );
1797 public function getLessCompiler( $vars = [] ) {
1802 if ( !class_exists(
'Less_Parser' ) ) {
1803 throw new MWException(
'MediaWiki requires the less.php parser' );
1806 $parser =
new Less_Parser;
1807 $parser->ModifyVars( $vars );
1808 $parser->SetImportDirs( [
1809 "$IP/resources/src/mediawiki.less/" =>
'',
1811 $parser->SetOption(
'relativeUrls',
false );
1823 public function getLessVars() {