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 protected static $filterCacheVersion = 7;
43 protected static $debugMode =
null;
46 private $lessVars =
null;
58 protected $moduleInfos = [];
68 protected $testModuleNames = [];
74 protected $sources = [];
80 protected $errors = [];
89 protected $extraHeaders = [];
102 const FILTER_NOMIN =
'/*@nomin*/';
119 if ( !$moduleNames ) {
128 $vary =
"$skin|$lang";
129 $res =
$dbr->select(
'module_deps', [
'md_module',
'md_deps' ], [
130 'md_module' => $moduleNames,
136 $modulesWithDeps = [];
137 foreach (
$res as $row ) {
138 $module = $this->getModule( $row->md_module );
143 $modulesWithDeps[] = $row->md_module;
147 foreach ( array_diff( $moduleNames, $modulesWithDeps )
as $name ) {
148 $module = $this->getModule(
$name );
150 $this->getModule(
$name )->setFileDependencies(
$context, [] );
159 foreach ( $moduleNames
as $name ) {
160 $module = $this->getModule(
$name );
161 if ( $module && $module->getMessages() ) {
165 $store = $this->getMessageBlobStore();
189 public static function filter( $filter, $data,
array $options = [] ) {
190 if ( strpos( $data, self::FILTER_NOMIN ) !==
false ) {
195 return self::applyFilter( $filter, $data );
198 $stats = MediaWikiServices::getInstance()->getStatsdDataFactory();
201 $key =
$cache->makeGlobalKey(
205 self::$filterCacheVersion, md5( $data )
210 $stats->increment(
"resourceloader_cache.$filter.miss" );
211 $result = self::applyFilter( $filter, $data );
214 $stats->increment(
"resourceloader_cache.$filter.hit" );
224 private static function applyFilter( $filter, $data ) {
225 $data = trim( $data );
228 $data = ( $filter ===
'minify-css' )
229 ? CSSMin::minify( $data )
231 }
catch ( Exception
$e ) {
244 public function __construct(
Config $config =
null, LoggerInterface $logger =
null ) {
247 $this->logger = $logger ?:
new NullLogger();
250 $this->logger->debug( __METHOD__ .
' was called without providing a Config instance' );
251 $config = MediaWikiServices::getInstance()->getMainConfig();
253 $this->config = $config;
256 $this->addSource(
'local', $config->get(
'LoadScript' ) );
259 $this->addSource( $config->get(
'ResourceLoaderSources' ) );
262 $this->
register( include
"$IP/resources/Resources.php" );
264 $this->
register( $config->get(
'ResourceModules' ) );
268 Hooks::run(
'ResourceLoaderRegisterModules', [ &$rl ] );
270 if ( $config->get(
'EnableJavaScriptTest' ) ===
true ) {
271 $this->registerTestModules();
280 public function getConfig() {
281 return $this->config;
288 public function setLogger( LoggerInterface $logger ) {
289 $this->logger = $logger;
296 public function getLogger() {
297 return $this->logger;
304 public function getMessageBlobStore() {
305 return $this->blobStore;
313 $this->blobStore = $blobStore;
329 public function register(
$name, $info = null ) {
330 $moduleSkinStyles = $this->config->
get(
'ResourceModuleSkinStyles' );
334 foreach ( $registrations
as $name => $info ) {
336 if ( isset( $this->moduleInfos[
$name] ) ) {
338 $this->logger->warning(
339 'ResourceLoader duplicate registration warning. ' .
340 'Another module has already been registered as ' .
$name
345 if ( !self::isValidModuleName(
$name ) ) {
346 throw new MWException(
"ResourceLoader module name '$name' is invalid, "
347 .
"see ResourceLoader::isValidModuleName()" );
352 $this->moduleInfos[
$name] = [
'object' => $info ];
353 $info->setName(
$name );
354 $this->modules[
$name] = $info;
355 } elseif ( is_array( $info ) ) {
357 $this->moduleInfos[
$name] = $info;
360 'ResourceLoader module info type error for module \'' .
$name .
365 // Last-minute changes
367 // Apply custom skin-defined styles to existing modules.
368 if ( $this->isFileModule( $name ) ) {
369 foreach ( $moduleSkinStyles as $skinName => $skinStyles ) {
370 // If this module already defines skinStyles for this skin, ignore $wgResourceModuleSkinStyles.
371 if ( isset( $this->moduleInfos[$name]['skinStyles
'][$skinName] ) ) {
375 // If $name is preceded with a '+
', the defined style files will be added to 'default'
376 // skinStyles, otherwise 'default' will be ignored as it normally would be.
377 if ( isset( $skinStyles[$name] ) ) {
378 $paths = (array)$skinStyles[$name];
380 } elseif ( isset( $skinStyles['+
' . $name] ) ) {
381 $paths = (array)$skinStyles['+
' . $name];
382 $styleFiles = isset( $this->moduleInfos[$name]['skinStyles
']['default'] ) ?
383 (array)$this->moduleInfos[$name]['skinStyles
']['default'] :
389 // Add new file paths, remapping them to refer to our directories and not use settings
390 // from the module we're modifying, which come
from the base
definition.
391 list( $localBasePath, $remoteBasePath ) =
398 $this->moduleInfos[
$name][
'skinStyles'][$skinName] = $styleFiles;
404 public function registerTestModules() {
407 if ( $this->config->get(
'EnableJavaScriptTest' ) !==
true ) {
408 throw new MWException(
'Attempt to register JavaScript test modules '
409 .
'but <code>$wgEnableJavaScriptTest</code> is false. '
410 .
'Edit your <code>LocalSettings.php</code> to enable it.' );
415 $testModules[
'qunit'] = [];
419 Hooks::run(
'ResourceLoaderTestModules', [ &$testModules, &$rl ] );
423 foreach ( $testModules[
'qunit']
as &$module ) {
427 $module[
'position'] =
'top';
428 $module[
'dependencies'][] =
'test.mediawiki.qunit.testrunner';
431 $testModules[
'qunit'] =
432 ( include
"$IP/tests/qunit/QUnitTestResources.php" ) + $testModules[
'qunit'];
434 foreach ( $testModules
as $id => $names ) {
436 $this->
register( $testModules[$id] );
439 $this->testModuleNames[$id] = array_keys( $testModules[$id] );
453 public function addSource( $id, $loadUrl =
null ) {
455 if ( is_array( $id ) ) {
457 $this->addSource( $key,
$value );
463 if ( isset( $this->sources[$id] ) ) {
465 'ResourceLoader duplicate source addition error. ' .
466 'Another source has already been registered as ' . $id
471 if ( is_array( $loadUrl ) ) {
472 if ( !isset( $loadUrl[
'loadScript'] ) ) {
474 __METHOD__ .
' was passed an array with no "loadScript" key.'
478 $loadUrl = $loadUrl[
'loadScript'];
481 $this->sources[$id] = $loadUrl;
489 public function getModuleNames() {
490 return array_keys( $this->moduleInfos );
503 public function getTestModuleNames( $framework =
'all' ) {
505 if ( $framework ==
'all' ) {
506 return $this->testModuleNames;
507 } elseif ( isset( $this->testModuleNames[$framework] )
508 && is_array( $this->testModuleNames[$framework] )
510 return $this->testModuleNames[$framework];
523 public function isModuleRegistered(
$name ) {
524 return isset( $this->moduleInfos[
$name] );
538 public function getModule(
$name ) {
539 if ( !isset( $this->modules[
$name] ) ) {
540 if ( !isset( $this->moduleInfos[
$name] ) ) {
545 $info = $this->moduleInfos[
$name];
547 if ( isset( $info[
'object'] ) ) {
549 $object = $info[
'object'];
550 } elseif ( isset( $info[
'factory'] ) ) {
551 $object = call_user_func( $info[
'factory'], $info );
552 $object->setConfig( $this->getConfig() );
553 $object->setLogger( $this->logger );
555 if ( !isset( $info[
'class'] ) ) {
558 $class = $info[
'class'];
561 $object =
new $class( $info );
562 $object->setConfig( $this->getConfig() );
563 $object->setLogger( $this->logger );
565 $object->setName(
$name );
566 $this->modules[
$name] = $object;
569 return $this->modules[
$name];
579 protected function isFileModule(
$name ) {
580 if ( !isset( $this->moduleInfos[
$name] ) ) {
583 $info = $this->moduleInfos[
$name];
584 if ( isset( $info[
'object'] ) ) {
588 isset( $info[
'class'] ) &&
602 public function getSources() {
603 return $this->sources;
615 public function getLoadScript(
$source ) {
616 if ( !isset( $this->sources[
$source] ) ) {
617 throw new MWException(
"The $source source was never registered in ResourceLoader." );
619 return $this->sources[
$source];
627 public static function makeHash(
$value ) {
628 $hash = hash(
'fnv132',
$value );
629 return Wikimedia\base_convert( $hash, 16, 36, 7 );
642 protected 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 ) {
670 $this->outputErrorAndLog(
$e,
671 'Calculating version for "{module}" failed: {exception}',
679 return self::makeHash( implode(
'',
$hashes ) );
703 if ( !$this->getModule(
$name ) ) {
708 $moduleNames[] =
$name;
710 return $this->getCombinedVersion(
$context, $moduleNames );
734 $module = $this->getModule(
$name );
738 if ( $module->getGroup() ===
'private' ) {
739 $this->logger->debug(
"Request for private module '$name' denied" );
740 $this->
errors[] =
"Cannot show private module \"$name\"";
743 $modules[
$name] = $module;
752 }
catch ( Exception
$e ) {
753 $this->outputErrorAndLog(
$e,
'Preloading module info failed: {exception}' );
759 $versionHash = $this->getCombinedVersion(
$context, array_keys(
$modules ) );
760 }
catch ( Exception
$e ) {
761 $this->outputErrorAndLog(
$e,
'Calculating version hash failed: {exception}' );
766 $etag =
'W/"' . $versionHash .
'"';
769 if ( $this->tryRespondNotModified(
$context, $etag ) ) {
774 if ( $this->config->get(
'UseFileCache' ) ) {
776 if ( $this->tryRespondFromFileCache( $fileCache,
$context, $etag ) ) {
787 $warnings = ob_get_contents();
788 if ( strlen( $warnings ) ) {
789 $this->
errors[] = $warnings;
794 if ( isset( $fileCache ) && !$this->
errors && !
count( $missing ) ) {
805 $this->sendResponseHeaders(
$context, $etag, (
bool)$this->
errors, $this->extraHeaders );
810 if (
$context->getImageObj() && $this->errors ) {
813 } elseif ( $this->
errors ) {
814 $errorText = implode(
"\n\n", $this->
errors );
815 $errorResponse = self::makeComment( $errorText );
816 if (
$context->shouldIncludeScripts() ) {
817 $errorResponse .=
'if (window.console && console.error) {'
830 protected function measureResponseTime(
Timing $timing ) {
832 $measure = $timing->
measure(
'responseTime',
'requestStart',
'requestShutdown' );
833 if ( $measure !==
false ) {
834 $stats = MediaWikiServices::getInstance()->getStatsdDataFactory();
835 $stats->timing(
'resourceloader.responseTime', $measure[
'duration'] * 1000 );
851 protected function sendResponseHeaders(
855 $rlMaxage = $this->config->get(
'ResourceLoaderMaxage' );
860 if ( is_null(
$context->getVersion() )
864 $maxage = $rlMaxage[
'unversioned'][
'client'];
865 $smaxage = $rlMaxage[
'unversioned'][
'server'];
869 $maxage = $rlMaxage[
'versioned'][
'client'];
870 $smaxage = $rlMaxage[
'versioned'][
'server'];
875 header(
'Content-Type: text/plain; charset=utf-8' );
879 } elseif (
$context->getOnly() ===
'styles' ) {
880 header(
'Content-Type: text/css; charset=utf-8' );
881 header(
'Access-Control-Allow-Origin: *' );
883 header(
'Content-Type: text/javascript; charset=utf-8' );
887 header(
'ETag: ' . $etag );
890 header(
'Cache-Control: private, no-cache, must-revalidate' );
891 header(
'Pragma: no-cache' );
893 header(
"Cache-Control: public, max-age=$maxage, s-maxage=$smaxage" );
894 $exp = min( $maxage, $smaxage );
895 header(
'Expires: ' .
wfTimestamp( TS_RFC2822, $exp + time() ) );
917 if ( $clientKeys !==
false && !
$context->getDebug() && in_array( $etag, $clientKeys ) ) {
931 $this->sendResponseHeaders(
$context, $etag,
false );
945 protected function tryRespondFromFileCache(
950 $rlMaxage = $this->config->get(
'ResourceLoaderMaxage' );
954 $maxage = is_null(
$context->getVersion() )
955 ? $rlMaxage[
'unversioned'][
'server']
956 : $rlMaxage[
'versioned'][
'server'];
969 $this->sendResponseHeaders(
$context, $etag,
false );
974 $warnings = ob_get_contents();
975 if ( strlen( $warnings ) ) {
998 public static function makeComment( $text ) {
999 $encText = str_replace(
'*/',
'* /', $text );
1000 return "/*\n$encText\n*/\n";
1009 public static function formatException(
$e ) {
1010 return self::makeComment( self::formatExceptionNoComment(
$e ) );
1020 protected static function formatExceptionNoComment(
$e ) {
1060 if ( $data ===
false ) {
1062 $this->
errors[] =
'Image generation failed';
1067 foreach ( $missing
as $name ) {
1068 $states[
$name] =
'missing';
1074 $filter =
$context->getOnly() ===
'styles' ?
'minify-css' :
'minify-js';
1078 $content = $module->getModuleContent(
$context );
1079 $implementKey =
$name .
'@' . $module->getVersionHash(
$context );
1082 if ( isset( $content[
'headers'] ) ) {
1083 $this->extraHeaders = array_merge( $this->extraHeaders, $content[
'headers'] );
1089 $scripts = $content[
'scripts'];
1090 if ( is_string( $scripts ) ) {
1092 $strContent = $scripts;
1093 } elseif ( is_array( $scripts ) ) {
1095 $strContent = self::makeLoaderImplementScript( $implementKey, $scripts, [], [], [] );
1099 $styles = $content[
'styles'];
1103 $strContent = isset( $styles[
'css'] ) ? implode(
'', $styles[
'css'] ) :
'';
1106 $scripts = isset( $content[
'scripts'] ) ? $content[
'scripts'] :
'';
1107 if ( is_string( $scripts ) ) {
1108 if (
$name ===
'site' ||
$name ===
'user' ) {
1113 if ( !self::inDebugMode() ) {
1114 $scripts = self::filter(
'minify-js', $scripts );
1120 $strContent = self::makeLoaderImplementScript(
1123 isset( $content[
'styles'] ) ? $content[
'styles'] : [],
1124 isset( $content[
'messagesBlob'] ) ?
new XmlJsCode( $content[
'messagesBlob'] ) : [],
1125 isset( $content[
'templates'] ) ? $content[
'templates'] : []
1131 $strContent = self::filter( $filter, $strContent );
1134 if (
$context->getOnly() ===
'scripts' ) {
1136 $out .= $this->ensureNewline( $strContent );
1138 $out .= $strContent;
1141 }
catch ( Exception
$e ) {
1142 $this->outputErrorAndLog(
$e,
'Generating module package failed: {exception}' );
1145 $states[
$name] =
'error';
1148 $isRaw |= $module->isRaw();
1152 if (
$context->shouldIncludeScripts() && !
$context->getRaw() && !$isRaw ) {
1157 $states[
$name] =
'ready';
1162 if (
count( $states ) ) {
1163 $stateScript = self::makeLoaderStateScript( $states );
1165 $stateScript = self::filter(
'minify-js', $stateScript );
1168 $out = $this->ensureNewline(
$out ) . $stateScript;
1171 if (
count( $states ) ) {
1172 $this->
errors[] =
'Problematic modules: ' .
1185 private function ensureNewline( $str ) {
1186 $end = substr( $str, -1 );
1187 if ( $end ===
false || $end ===
"\n" ) {
1199 public function getModulesByMessage( $messageKey ) {
1201 foreach ( $this->getModuleNames()
as $moduleName ) {
1202 $module = $this->getModule( $moduleName );
1203 if ( in_array( $messageKey, $module->getMessages() ) ) {
1204 $moduleNames[] = $moduleName;
1207 return $moduleNames;
1226 protected static function makeLoaderImplementScript(
1230 if ( self::inDebugMode() ) {
1231 $scripts =
new XmlJsCode(
"function ( $, jQuery, require, module ) {\n{$scripts->value}\n}" );
1233 $scripts =
new XmlJsCode(
'function($,jQuery,require,module){'. $scripts->value .
'}' );
1235 } elseif ( !is_string( $scripts ) && !is_array( $scripts ) ) {
1236 throw new MWException(
'Invalid scripts error. Array of URLs or string of code expected.' );
1248 self::trimArray( $module );
1250 return Xml::encodeJsCall(
'mw.loader.implement', $module, self::inDebugMode() );
1260 public static function makeMessageSetScript(
$messages ) {
1275 public static function makeCombinedStyles(
array $stylePairs ) {
1277 foreach ( $stylePairs
as $media => $styles ) {
1281 $styles = (
array)$styles;
1282 foreach ( $styles
as $style ) {
1283 $style = trim( $style );
1285 if ( $style !==
'' ) {
1290 if ( $media ===
'' || $media ==
'all' ) {
1292 } elseif ( is_string( $media ) ) {
1293 $out[] =
"@media $media {\n" . str_replace(
"\n",
"\n\t",
"\t" . $style ) .
"}";
1316 public static function makeLoaderStateScript(
$name, $state =
null ) {
1317 if ( is_array(
$name ) ) {
1346 public static function makeCustomLoaderScript(
$name, $version, $dependencies,
1349 $script = str_replace(
"\n",
"\n\t", trim( $script ) );
1351 "( function ( name, version, dependencies, group, source ) {\n\t$script\n} )",
1357 private static function isEmptyObject( stdClass $obj ) {
1358 foreach ( $obj
as $key =>
$value ) {
1376 private static function trimArray(
array &$array ) {
1377 $i =
count( $array );
1379 if ( $array[$i] ===
null
1380 || $array[$i] === []
1381 || ( $array[$i] instanceof
XmlJsCode && $array[$i]->
value ===
'{}' )
1382 || ( $array[$i] instanceof stdClass && self::isEmptyObject( $array[$i] ) )
1384 unset( $array[$i] );
1418 public static function makeLoaderRegisterScript(
$name, $version =
null,
1419 $dependencies =
null, $group =
null,
$source =
null, $skip =
null
1421 if ( is_array(
$name ) ) {
1424 foreach (
$name as $i => &$module ) {
1425 $index[$module[0]] = $i;
1430 foreach (
$name as &$module ) {
1431 if ( isset( $module[2] ) ) {
1432 foreach ( $module[2]
as &$dependency ) {
1433 if ( isset( $index[$dependency] ) ) {
1434 $dependency = $index[$dependency];
1440 array_walk(
$name, [
'self',
'trimArray' ] );
1443 'mw.loader.register',
1448 $registration = [
$name, $version, $dependencies, $group,
$source, $skip ];
1449 self::trimArray( $registration );
1451 'mw.loader.register',
1472 public static function makeLoaderSourcesScript( $id, $loadUrl =
null ) {
1473 if ( is_array( $id ) ) {
1475 'mw.loader.addSource',
1481 'mw.loader.addSource',
1494 public static function makeLoaderConditionalScript( $script ) {
1495 return '(window.RLQ=window.RLQ||[]).push(function(){' .
1496 trim( $script ) .
'});';
1508 public static function makeInlineScript( $script ) {
1509 $js = self::makeLoaderConditionalScript( $script );
1510 return new WrappedString(
1512 '<script>(window.RLQ=window.RLQ||[]).push(function(){',
1524 public static function makeConfigSetScript(
array $configuration ) {
1545 public static function makePackedModulesString(
$modules ) {
1548 $pos = strrpos( $module,
'.' );
1549 $prefix = $pos ===
false ?
'' : substr( $module, 0, $pos );
1550 $suffix = $pos ===
false ? $module : substr( $module, $pos + 1 );
1551 $moduleMap[$prefix][] = $suffix;
1555 foreach ( $moduleMap
as $prefix => $suffixes ) {
1556 $p = $prefix ===
'' ?
'' : $prefix .
'.';
1557 $arr[] = $p . implode(
',', $suffixes );
1559 return implode(
'|', $arr );
1567 public static function inDebugMode() {
1568 if ( self::$debugMode ===
null ) {
1570 self::$debugMode =
$wgRequest->getFuzzyBool(
'debug',
1574 return self::$debugMode;
1584 public static function clearCache() {
1585 self::$debugMode =
null;
1601 $script = $this->getLoadScript(
$source );
1616 return self::makeLoaderQuery(
1624 $context->getRequest()->getBool(
'printable' ),
1625 $context->getRequest()->getBool(
'handheld' ),
1648 $version =
null,
$debug =
false, $only =
null, $printable =
false,
1649 $handheld =
false, $extraQuery = []
1652 'modules' => self::makePackedModulesString(
$modules ),
1655 'debug' =>
$debug ?
'true' :
'false',
1657 if (
$user !==
null ) {
1660 if ( $version !==
null ) {
1661 $query[
'version'] = $version;
1663 if ( $only !==
null ) {
1688 public static function isValidModuleName( $moduleName ) {
1689 return strcspn( $moduleName,
'!,|', 0, 255 ) === strlen( $moduleName );
1701 public function getLessCompiler( $extraVars = [] ) {
1705 if ( !class_exists(
'Less_Parser' ) ) {
1706 throw new MWException(
'MediaWiki requires the less.php parser' );
1710 $parser->ModifyVars( array_merge( $this->getLessVars(), $extraVars ) );
1712 array_fill_keys( $this->config->get(
'ResourceLoaderLESSImportPaths' ),
'' )
1714 $parser->SetOption(
'relativeUrls',
false );
1725 public function getLessVars() {
1726 if ( $this->lessVars ===
null ) {
1727 $this->lessVars = $this->config->get(
'ResourceLoaderLESSVars' );
1729 return $this->lessVars;