26 use Psr\Log\LoggerAwareInterface;
27 use Psr\Log\LoggerInterface;
28 use Psr\Log\NullLogger;
29 use WrappedString\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 = [];
93 const FILTER_NOMIN =
'/*@nomin*/';
110 if ( !$moduleNames ) {
119 $vary =
"$skin|$lang";
120 $res =
$dbr->select(
'module_deps', [
'md_module',
'md_deps' ], [
121 'md_module' => $moduleNames,
127 $modulesWithDeps = [];
128 foreach (
$res as $row ) {
129 $module = $this->getModule( $row->md_module );
134 $modulesWithDeps[] = $row->md_module;
138 foreach ( array_diff( $moduleNames, $modulesWithDeps )
as $name ) {
139 $module = $this->getModule(
$name );
141 $this->getModule(
$name )->setFileDependencies(
$context, [] );
150 foreach ( $moduleNames
as $name ) {
151 $module = $this->getModule(
$name );
152 if ( $module && $module->getMessages() ) {
156 $store = $this->getMessageBlobStore();
180 public static function filter( $filter, $data,
array $options = [] ) {
181 if ( strpos( $data, ResourceLoader::FILTER_NOMIN ) !==
false ) {
186 return self::applyFilter( $filter, $data );
189 $stats = MediaWikiServices::getInstance()->getStatsdDataFactory();
192 $key =
$cache->makeGlobalKey(
196 self::$filterCacheVersion, md5( $data )
201 $stats->increment(
"resourceloader_cache.$filter.miss" );
202 $result = self::applyFilter( $filter, $data );
205 $stats->increment(
"resourceloader_cache.$filter.hit" );
215 private static function applyFilter( $filter, $data ) {
216 $data = trim( $data );
219 $data = ( $filter ===
'minify-css' )
222 }
catch ( Exception
$e ) {
237 public function __construct( Config $config =
null, LoggerInterface $logger =
null ) {
240 $this->logger = $logger ?:
new NullLogger();
243 $this->logger->debug( __METHOD__ .
' was called without providing a Config instance' );
244 $config = MediaWikiServices::getInstance()->getMainConfig();
246 $this->config = $config;
249 $this->addSource(
'local', $config->get(
'LoadScript' ) );
252 $this->addSource( $config->get(
'ResourceLoaderSources' ) );
255 $this->
register( include
"$IP/resources/Resources.php" );
256 $this->
register( include
"$IP/resources/ResourcesOOUI.php" );
258 $this->
register( $config->get(
'ResourceModules' ) );
262 Hooks::run(
'ResourceLoaderRegisterModules', [ &$rl ] );
264 if ( $config->get(
'EnableJavaScriptTest' ) ===
true ) {
265 $this->registerTestModules();
274 public function getConfig() {
275 return $this->config;
282 public function setLogger( LoggerInterface $logger ) {
283 $this->logger = $logger;
290 public function getLogger() {
291 return $this->logger;
298 public function getMessageBlobStore() {
299 return $this->blobStore;
307 $this->blobStore = $blobStore;
323 public function register(
$name, $info = null ) {
324 $moduleSkinStyles = $this->config->
get(
'ResourceModuleSkinStyles' );
328 foreach ( $registrations
as $name => $info ) {
330 if ( isset( $this->moduleInfos[
$name] ) ) {
332 $this->logger->warning(
333 'ResourceLoader duplicate registration warning. ' .
334 'Another module has already been registered as ' .
$name
339 if ( !self::isValidModuleName(
$name ) ) {
340 throw new MWException(
"ResourceLoader module name '$name' is invalid, "
341 .
"see ResourceLoader::isValidModuleName()" );
346 $this->moduleInfos[
$name] = [
'object' => $info ];
347 $info->setName(
$name );
348 $this->modules[
$name] = $info;
349 } elseif ( is_array( $info ) ) {
351 $this->moduleInfos[
$name] = $info;
354 'ResourceLoader module info type error for module \'' .
$name .
359 // Last-minute changes
361 // Apply custom skin-defined styles to existing modules.
362 if ( $this->isFileModule( $name ) ) {
363 foreach ( $moduleSkinStyles as $skinName => $skinStyles ) {
364 // If this module already defines skinStyles for this skin, ignore $wgResourceModuleSkinStyles.
365 if ( isset( $this->moduleInfos[$name]['skinStyles
'][$skinName] ) ) {
369 // If $name is preceded with a '+
', the defined style files will be added to 'default'
370 // skinStyles, otherwise 'default' will be ignored as it normally would be.
371 if ( isset( $skinStyles[$name] ) ) {
372 $paths = (array)$skinStyles[$name];
374 } elseif ( isset( $skinStyles['+
' . $name] ) ) {
375 $paths = (array)$skinStyles['+
' . $name];
376 $styleFiles = isset( $this->moduleInfos[$name]['skinStyles
']['default'] ) ?
377 (array)$this->moduleInfos[$name]['skinStyles
']['default'] :
383 // Add new file paths, remapping them to refer to our directories and not use settings
384 // from the module we're modifying, which come
from the base
definition.
385 list( $localBasePath, $remoteBasePath ) =
392 $this->moduleInfos[
$name][
'skinStyles'][$skinName] = $styleFiles;
398 public function registerTestModules() {
401 if ( $this->config->get(
'EnableJavaScriptTest' ) !==
true ) {
402 throw new MWException(
'Attempt to register JavaScript test modules '
403 .
'but <code>$wgEnableJavaScriptTest</code> is false. '
404 .
'Edit your <code>LocalSettings.php</code> to enable it.' );
409 $testModules[
'qunit'] = [];
413 Hooks::run(
'ResourceLoaderTestModules', [ &$testModules, &$rl ] );
417 foreach ( $testModules[
'qunit']
as &$module ) {
421 $module[
'position'] =
'top';
422 $module[
'dependencies'][] =
'test.mediawiki.qunit.testrunner';
425 $testModules[
'qunit'] =
426 ( include
"$IP/tests/qunit/QUnitTestResources.php" ) + $testModules[
'qunit'];
428 foreach ( $testModules
as $id => $names ) {
430 $this->
register( $testModules[$id] );
433 $this->testModuleNames[$id] = array_keys( $testModules[$id] );
447 public function addSource( $id, $loadUrl =
null ) {
449 if ( is_array( $id ) ) {
451 $this->addSource( $key,
$value );
457 if ( isset( $this->sources[$id] ) ) {
459 'ResourceLoader duplicate source addition error. ' .
460 'Another source has already been registered as ' . $id
465 if ( is_array( $loadUrl ) ) {
466 if ( !isset( $loadUrl[
'loadScript'] ) ) {
468 __METHOD__ .
' was passed an array with no "loadScript" key.'
472 $loadUrl = $loadUrl[
'loadScript'];
475 $this->sources[$id] = $loadUrl;
483 public function getModuleNames() {
484 return array_keys( $this->moduleInfos );
497 public function getTestModuleNames( $framework =
'all' ) {
499 if ( $framework ==
'all' ) {
500 return $this->testModuleNames;
501 } elseif ( isset( $this->testModuleNames[$framework] )
502 && is_array( $this->testModuleNames[$framework] )
504 return $this->testModuleNames[$framework];
517 public function isModuleRegistered(
$name ) {
518 return isset( $this->moduleInfos[
$name] );
532 public function getModule(
$name ) {
533 if ( !isset( $this->modules[
$name] ) ) {
534 if ( !isset( $this->moduleInfos[
$name] ) ) {
539 $info = $this->moduleInfos[
$name];
541 if ( isset( $info[
'object'] ) ) {
543 $object = $info[
'object'];
545 if ( !isset( $info[
'class'] ) ) {
546 $class =
'ResourceLoaderFileModule';
548 $class = $info[
'class'];
551 $object =
new $class( $info );
552 $object->setConfig( $this->getConfig() );
553 $object->setLogger( $this->logger );
555 $object->setName(
$name );
556 $this->modules[
$name] = $object;
559 return $this->modules[
$name];
568 protected function isFileModule(
$name ) {
569 if ( !isset( $this->moduleInfos[
$name] ) ) {
572 $info = $this->moduleInfos[
$name];
573 if ( isset( $info[
'object'] ) || isset( $info[
'class'] ) ) {
584 public function getSources() {
585 return $this->sources;
597 public function getLoadScript(
$source ) {
598 if ( !isset( $this->sources[
$source] ) ) {
599 throw new MWException(
"The $source source was never registered in ResourceLoader." );
601 return $this->sources[
$source];
609 public static function makeHash(
$value ) {
610 $hash = hash(
'fnv132',
$value );
611 return Wikimedia\base_convert( $hash, 16, 36, 7 );
624 protected function outputErrorAndLog( Exception
$e, $msg,
array $context = [] ) {
626 $this->logger->warning(
630 $this->
errors[] = self::formatExceptionNoComment(
$e );
642 if ( !$moduleNames ) {
647 return $this->getModule( $module )->getVersionHash(
$context );
648 }
catch ( Exception
$e ) {
652 $this->outputErrorAndLog(
$e,
653 'Calculating version for "{module}" failed: {exception}',
661 return self::makeHash( implode(
'',
$hashes ) );
686 if ( !$this->getModule(
$name ) ) {
691 $moduleNames[] =
$name;
693 return $this->getCombinedVersion(
$context, $moduleNames );
715 $module = $this->getModule(
$name );
719 if ( $module->getGroup() ===
'private' ) {
720 $this->logger->debug(
"Request for private module '$name' denied" );
721 $this->
errors[] =
"Cannot show private module \"$name\"";
724 $modules[
$name] = $module;
733 }
catch ( Exception
$e ) {
734 $this->outputErrorAndLog(
$e,
'Preloading module info failed: {exception}' );
740 $versionHash = $this->getCombinedVersion(
$context, array_keys(
$modules ) );
741 }
catch ( Exception
$e ) {
742 $this->outputErrorAndLog(
$e,
'Calculating version hash failed: {exception}' );
747 $etag =
'W/"' . $versionHash .
'"';
750 if ( $this->tryRespondNotModified(
$context, $etag ) ) {
755 if ( $this->config->get(
'UseFileCache' ) ) {
757 if ( $this->tryRespondFromFileCache( $fileCache,
$context, $etag ) ) {
768 $warnings = ob_get_contents();
769 if ( strlen( $warnings ) ) {
770 $this->
errors[] = $warnings;
775 if ( isset( $fileCache ) && !$this->
errors && !
count( $missing ) ) {
786 $this->sendResponseHeaders(
$context, $etag, (
bool)$this->
errors );
791 if (
$context->getImageObj() && $this->errors ) {
794 } elseif ( $this->
errors ) {
795 $errorText = implode(
"\n\n", $this->
errors );
796 $errorResponse = self::makeComment( $errorText );
797 if (
$context->shouldIncludeScripts() ) {
798 $errorResponse .=
'if (window.console && console.error) {'
823 $rlMaxage = $this->config->get(
'ResourceLoaderMaxage' );
828 if ( is_null(
$context->getVersion() )
832 $maxage = $rlMaxage[
'unversioned'][
'client'];
833 $smaxage = $rlMaxage[
'unversioned'][
'server'];
837 $maxage = $rlMaxage[
'versioned'][
'client'];
838 $smaxage = $rlMaxage[
'versioned'][
'server'];
843 header(
'Content-Type: text/plain; charset=utf-8' );
847 } elseif (
$context->getOnly() ===
'styles' ) {
848 header(
'Content-Type: text/css; charset=utf-8' );
849 header(
'Access-Control-Allow-Origin: *' );
851 header(
'Content-Type: text/javascript; charset=utf-8' );
855 header(
'ETag: ' . $etag );
858 header(
'Cache-Control: private, no-cache, must-revalidate' );
859 header(
'Pragma: no-cache' );
861 header(
"Cache-Control: public, max-age=$maxage, s-maxage=$smaxage" );
862 $exp = min( $maxage, $smaxage );
863 header(
'Expires: ' .
wfTimestamp( TS_RFC2822, $exp + time() ) );
882 if ( $clientKeys !==
false && !
$context->getDebug() && in_array( $etag, $clientKeys ) ) {
896 $this->sendResponseHeaders(
$context, $etag,
false );
910 protected function tryRespondFromFileCache(
915 $rlMaxage = $this->config->get(
'ResourceLoaderMaxage' );
919 $maxage = is_null(
$context->getVersion() )
920 ? $rlMaxage[
'unversioned'][
'server']
921 : $rlMaxage[
'versioned'][
'server'];
934 $this->sendResponseHeaders(
$context, $etag,
false );
939 $warnings = ob_get_contents();
940 if ( strlen( $warnings ) ) {
963 public static function makeComment( $text ) {
964 $encText = str_replace(
'*/',
'* /', $text );
965 return "/*\n$encText\n*/\n";
974 public static function formatException(
$e ) {
975 return self::makeComment( self::formatExceptionNoComment(
$e ) );
985 protected static function formatExceptionNoComment(
$e ) {
986 global $wgShowExceptionDetails;
988 if ( !$wgShowExceptionDetails ) {
1022 if ( $data ===
false ) {
1024 $this->
errors[] =
'Image generation failed';
1029 foreach ( $missing
as $name ) {
1030 $states[
$name] =
'missing';
1036 $filter =
$context->getOnly() ===
'styles' ?
'minify-css' :
'minify-js';
1041 $implementKey =
$name .
'@' . $module->getVersionHash(
$context );
1048 if ( is_string( $scripts ) ) {
1050 $strContent = $scripts;
1051 } elseif ( is_array( $scripts ) ) {
1053 $strContent = self::makeLoaderImplementScript( $implementKey, $scripts, [], [], [] );
1061 $strContent = isset( $styles[
'css'] ) ? implode(
'', $styles[
'css'] ) :
'';
1065 if ( is_string( $scripts ) ) {
1066 if (
$name ===
'site' ||
$name ===
'user' ) {
1071 if ( !ResourceLoader::inDebugMode() ) {
1072 $scripts = self::filter(
'minify-js', $scripts );
1078 $strContent = self::makeLoaderImplementScript(
1089 $strContent = self::filter( $filter, $strContent );
1092 $out .= $strContent;
1094 }
catch ( Exception
$e ) {
1095 $this->outputErrorAndLog(
$e,
'Generating module package failed: {exception}' );
1098 $states[
$name] =
'error';
1101 $isRaw |= $module->isRaw();
1105 if (
$context->shouldIncludeScripts() && !
$context->getRaw() && !$isRaw ) {
1110 $states[
$name] =
'ready';
1115 if (
count( $states ) ) {
1116 $stateScript = self::makeLoaderStateScript( $states );
1118 $stateScript = self::filter(
'minify-js', $stateScript );
1120 $out .= $stateScript;
1123 if (
count( $states ) ) {
1124 $this->
errors[] =
'Problematic modules: ' .
1138 public function getModulesByMessage( $messageKey ) {
1140 foreach ( $this->getModuleNames()
as $moduleName ) {
1141 $module = $this->getModule( $moduleName );
1142 if ( in_array( $messageKey, $module->getMessages() ) ) {
1143 $moduleNames[] = $moduleName;
1146 return $moduleNames;
1167 protected static function makeLoaderImplementScript(
1171 $scripts =
new XmlJsCode(
"function ( $, jQuery, require, module ) {\n{$scripts->value}\n}" );
1172 } elseif ( !is_string( $scripts ) && !is_array( $scripts ) ) {
1173 throw new MWException(
'Invalid scripts error. Array of URLs or string of code expected.' );
1185 self::trimArray( $module );
1187 return Xml::encodeJsCall(
'mw.loader.implement', $module, ResourceLoader::inDebugMode() );
1197 public static function makeMessageSetScript(
$messages ) {
1201 ResourceLoader::inDebugMode()
1212 public static function makeCombinedStyles(
array $stylePairs ) {
1214 foreach ( $stylePairs
as $media => $styles ) {
1218 $styles = (
array)$styles;
1219 foreach ( $styles
as $style ) {
1220 $style = trim( $style );
1222 if ( $style !==
'' ) {
1227 if ( $media ===
'' || $media ==
'all' ) {
1229 } elseif ( is_string( $media ) ) {
1230 $out[] =
"@media $media {\n" . str_replace(
"\n",
"\n\t",
"\t" . $style ) .
"}";
1253 public static function makeLoaderStateScript(
$name, $state =
null ) {
1254 if ( is_array(
$name ) ) {
1258 ResourceLoader::inDebugMode()
1264 ResourceLoader::inDebugMode()
1283 public static function makeCustomLoaderScript(
$name, $version, $dependencies,
1286 $script = str_replace(
"\n",
"\n\t", trim( $script ) );
1288 "( function ( name, version, dependencies, group, source ) {\n\t$script\n} )",
1290 ResourceLoader::inDebugMode()
1294 private static function isEmptyObject( stdClass $obj ) {
1295 foreach ( $obj
as $key =>
$value ) {
1313 private static function trimArray(
array &$array ) {
1314 $i =
count( $array );
1316 if ( $array[$i] ===
null
1317 || $array[$i] === []
1318 || ( $array[$i] instanceof
XmlJsCode && $array[$i]->
value ===
'{}' )
1319 || ( $array[$i] instanceof stdClass && self::isEmptyObject( $array[$i] ) )
1321 unset( $array[$i] );
1355 public static function makeLoaderRegisterScript(
$name, $version =
null,
1356 $dependencies =
null, $group =
null,
$source =
null, $skip =
null
1358 if ( is_array(
$name ) ) {
1361 foreach (
$name as $i => &$module ) {
1362 $index[$module[0]] = $i;
1367 foreach (
$name as &$module ) {
1368 if ( isset( $module[2] ) ) {
1369 foreach ( $module[2]
as &$dependency ) {
1370 if ( isset( $index[$dependency] ) ) {
1371 $dependency = $index[$dependency];
1377 array_walk(
$name, [
'self',
'trimArray' ] );
1380 'mw.loader.register',
1382 ResourceLoader::inDebugMode()
1385 $registration = [
$name, $version, $dependencies, $group,
$source, $skip ];
1386 self::trimArray( $registration );
1388 'mw.loader.register',
1390 ResourceLoader::inDebugMode()
1409 public static function makeLoaderSourcesScript( $id, $loadUrl =
null ) {
1410 if ( is_array( $id ) ) {
1412 'mw.loader.addSource',
1414 ResourceLoader::inDebugMode()
1418 'mw.loader.addSource',
1420 ResourceLoader::inDebugMode()
1433 public static function makeLoaderConditionalScript( $script ) {
1434 return '(window.RLQ=window.RLQ||[]).push(function(){' .
1435 trim( $script ) .
'});';
1447 public static function makeInlineScript( $script ) {
1448 $js = self::makeLoaderConditionalScript( $script );
1449 return new WrappedString(
1451 '<script>(window.RLQ=window.RLQ||[]).push(function(){',
1463 public static function makeConfigSetScript(
array $configuration ) {
1467 ResourceLoader::inDebugMode()
1479 public static function makePackedModulesString(
$modules ) {
1482 $pos = strrpos( $module,
'.' );
1483 $prefix = $pos ===
false ?
'' : substr( $module, 0, $pos );
1484 $suffix = $pos ===
false ? $module : substr( $module, $pos + 1 );
1485 $groups[$prefix][] = $suffix;
1489 foreach ( $groups
as $prefix => $suffixes ) {
1490 $p = $prefix ===
'' ?
'' : $prefix .
'.';
1491 $arr[] = $p . implode(
',', $suffixes );
1493 $str = implode(
'|', $arr );
1502 public static function inDebugMode() {
1503 if ( self::$debugMode ===
null ) {
1505 self::$debugMode =
$wgRequest->getFuzzyBool(
'debug',
1506 $wgRequest->getCookie(
'resourceLoaderDebug',
'', $wgResourceLoaderDebug )
1509 return self::$debugMode;
1519 public static function clearCache() {
1520 self::$debugMode =
null;
1536 $script = $this->getLoadScript(
$source );
1551 return self::makeLoaderQuery(
1583 $version =
null,
$debug =
false, $only =
null, $printable =
false,
1584 $handheld =
false, $extraQuery = []
1587 'modules' => self::makePackedModulesString(
$modules ),
1590 'debug' =>
$debug ?
'true' :
'false',
1592 if (
$user !==
null ) {
1595 if ( $version !==
null ) {
1596 $query[
'version'] = $version;
1598 if ( $only !==
null ) {
1623 public static function isValidModuleName( $moduleName ) {
1624 return strcspn( $moduleName,
'!,|', 0, 255 ) === strlen( $moduleName );
1636 public function getLessCompiler( $extraVars = [] ) {
1640 if ( !class_exists(
'Less_Parser' ) ) {
1641 throw new MWException(
'MediaWiki requires the less.php parser' );
1645 $parser->ModifyVars( array_merge( $this->getLessVars(), $extraVars ) );
1647 array_fill_keys( $this->config->get(
'ResourceLoaderLESSImportPaths' ),
'' )
1649 $parser->SetOption(
'relativeUrls',
false );
1660 public function getLessVars() {
1661 if ( !$this->lessVars ) {
1662 $lessVars = $this->config->get(
'ResourceLoaderLESSVars' );
1663 Hooks::run(
'ResourceLoaderGetLessVars', [ &$lessVars ] );
1664 $this->lessVars = $lessVars;
1666 return $this->lessVars;