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 = [];
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' )
231 }
catch ( Exception
$e ) {
246 public function __construct( Config $config =
null, LoggerInterface $logger =
null ) {
249 $this->logger = $logger ?:
new NullLogger();
252 $this->logger->debug( __METHOD__ .
' was called without providing a Config instance' );
253 $config = MediaWikiServices::getInstance()->getMainConfig();
255 $this->config = $config;
258 $this->addSource(
'local', $config->get(
'LoadScript' ) );
261 $this->addSource( $config->get(
'ResourceLoaderSources' ) );
264 $this->
register( include
"$IP/resources/Resources.php" );
266 $this->
register( $config->get(
'ResourceModules' ) );
270 Hooks::run(
'ResourceLoaderRegisterModules', [ &$rl ] );
272 if ( $config->get(
'EnableJavaScriptTest' ) ===
true ) {
273 $this->registerTestModules();
282 public function getConfig() {
283 return $this->config;
290 public function setLogger( LoggerInterface $logger ) {
291 $this->logger = $logger;
298 public function getLogger() {
299 return $this->logger;
306 public function getMessageBlobStore() {
307 return $this->blobStore;
315 $this->blobStore = $blobStore;
331 public function register(
$name, $info = null ) {
332 $moduleSkinStyles = $this->config->
get(
'ResourceModuleSkinStyles' );
336 foreach ( $registrations
as $name => $info ) {
338 if ( isset( $this->moduleInfos[
$name] ) ) {
340 $this->logger->warning(
341 'ResourceLoader duplicate registration warning. ' .
342 'Another module has already been registered as ' .
$name
347 if ( !self::isValidModuleName(
$name ) ) {
348 throw new MWException(
"ResourceLoader module name '$name' is invalid, "
349 .
"see ResourceLoader::isValidModuleName()" );
354 $this->moduleInfos[
$name] = [
'object' => $info ];
355 $info->setName(
$name );
356 $this->modules[
$name] = $info;
357 } elseif ( is_array( $info ) ) {
359 $this->moduleInfos[
$name] = $info;
362 'ResourceLoader module info type error for module \'' .
$name .
367 // Last-minute changes
369 // Apply custom skin-defined styles to existing modules.
370 if ( $this->isFileModule( $name ) ) {
371 foreach ( $moduleSkinStyles as $skinName => $skinStyles ) {
372 // If this module already defines skinStyles for this skin, ignore $wgResourceModuleSkinStyles.
373 if ( isset( $this->moduleInfos[$name]['skinStyles
'][$skinName] ) ) {
377 // If $name is preceded with a '+
', the defined style files will be added to 'default'
378 // skinStyles, otherwise 'default' will be ignored as it normally would be.
379 if ( isset( $skinStyles[$name] ) ) {
380 $paths = (array)$skinStyles[$name];
382 } elseif ( isset( $skinStyles['+
' . $name] ) ) {
383 $paths = (array)$skinStyles['+
' . $name];
384 $styleFiles = isset( $this->moduleInfos[$name]['skinStyles
']['default'] ) ?
385 (array)$this->moduleInfos[$name]['skinStyles
']['default'] :
391 // Add new file paths, remapping them to refer to our directories and not use settings
392 // from the module we're modifying, which come
from the base
definition.
393 list( $localBasePath, $remoteBasePath ) =
400 $this->moduleInfos[
$name][
'skinStyles'][$skinName] = $styleFiles;
406 public function registerTestModules() {
409 if ( $this->config->get(
'EnableJavaScriptTest' ) !==
true ) {
410 throw new MWException(
'Attempt to register JavaScript test modules '
411 .
'but <code>$wgEnableJavaScriptTest</code> is false. '
412 .
'Edit your <code>LocalSettings.php</code> to enable it.' );
417 $testModules[
'qunit'] = [];
421 Hooks::run(
'ResourceLoaderTestModules', [ &$testModules, &$rl ] );
425 foreach ( $testModules[
'qunit']
as &$module ) {
429 $module[
'position'] =
'top';
430 $module[
'dependencies'][] =
'test.mediawiki.qunit.testrunner';
433 $testModules[
'qunit'] =
434 ( include
"$IP/tests/qunit/QUnitTestResources.php" ) + $testModules[
'qunit'];
436 foreach ( $testModules
as $id => $names ) {
438 $this->
register( $testModules[$id] );
441 $this->testModuleNames[$id] = array_keys( $testModules[$id] );
455 public function addSource( $id, $loadUrl =
null ) {
457 if ( is_array( $id ) ) {
459 $this->addSource( $key,
$value );
465 if ( isset( $this->sources[$id] ) ) {
467 'ResourceLoader duplicate source addition error. ' .
468 'Another source has already been registered as ' . $id
473 if ( is_array( $loadUrl ) ) {
474 if ( !isset( $loadUrl[
'loadScript'] ) ) {
476 __METHOD__ .
' was passed an array with no "loadScript" key.'
480 $loadUrl = $loadUrl[
'loadScript'];
483 $this->sources[$id] = $loadUrl;
491 public function getModuleNames() {
492 return array_keys( $this->moduleInfos );
505 public function getTestModuleNames( $framework =
'all' ) {
507 if ( $framework ==
'all' ) {
508 return $this->testModuleNames;
509 } elseif ( isset( $this->testModuleNames[$framework] )
510 && is_array( $this->testModuleNames[$framework] )
512 return $this->testModuleNames[$framework];
525 public function isModuleRegistered(
$name ) {
526 return isset( $this->moduleInfos[
$name] );
540 public function getModule(
$name ) {
541 if ( !isset( $this->modules[
$name] ) ) {
542 if ( !isset( $this->moduleInfos[
$name] ) ) {
547 $info = $this->moduleInfos[
$name];
549 if ( isset( $info[
'object'] ) ) {
551 $object = $info[
'object'];
552 } elseif ( isset( $info[
'factory'] ) ) {
553 $object = call_user_func( $info[
'factory'], $info );
554 $object->setConfig( $this->getConfig() );
555 $object->setLogger( $this->logger );
557 if ( !isset( $info[
'class'] ) ) {
558 $class =
'ResourceLoaderFileModule';
560 $class = $info[
'class'];
563 $object =
new $class( $info );
564 $object->setConfig( $this->getConfig() );
565 $object->setLogger( $this->logger );
567 $object->setName(
$name );
568 $this->modules[
$name] = $object;
571 return $this->modules[
$name];
581 protected function isFileModule(
$name ) {
582 if ( !isset( $this->moduleInfos[
$name] ) ) {
585 $info = $this->moduleInfos[
$name];
586 if ( isset( $info[
'object'] ) ) {
590 isset( $info[
'class'] ) &&
591 $info[
'class'] !==
'ResourceLoaderFileModule' &&
592 !is_subclass_of( $info[
'class'],
'ResourceLoaderFileModule' )
604 public function getSources() {
605 return $this->sources;
617 public function getLoadScript(
$source ) {
618 if ( !isset( $this->sources[
$source] ) ) {
619 throw new MWException(
"The $source source was never registered in ResourceLoader." );
621 return $this->sources[
$source];
629 public static function makeHash(
$value ) {
630 $hash = hash(
'fnv132',
$value );
631 return Wikimedia\base_convert( $hash, 16, 36, 7 );
644 protected function outputErrorAndLog( Exception
$e, $msg,
array $context = [] ) {
646 $this->logger->warning(
650 $this->
errors[] = self::formatExceptionNoComment(
$e );
662 if ( !$moduleNames ) {
667 return $this->getModule( $module )->getVersionHash(
$context );
668 }
catch ( Exception
$e ) {
672 $this->outputErrorAndLog(
$e,
673 'Calculating version for "{module}" failed: {exception}',
681 return self::makeHash( implode(
'',
$hashes ) );
706 if ( !$this->getModule(
$name ) ) {
711 $moduleNames[] =
$name;
713 return $this->getCombinedVersion(
$context, $moduleNames );
735 $module = $this->getModule(
$name );
739 if ( $module->getGroup() ===
'private' ) {
740 $this->logger->debug(
"Request for private module '$name' denied" );
741 $this->
errors[] =
"Cannot show private module \"$name\"";
744 $modules[
$name] = $module;
753 }
catch ( Exception
$e ) {
754 $this->outputErrorAndLog(
$e,
'Preloading module info failed: {exception}' );
760 $versionHash = $this->getCombinedVersion(
$context, array_keys(
$modules ) );
761 }
catch ( Exception
$e ) {
762 $this->outputErrorAndLog(
$e,
'Calculating version hash failed: {exception}' );
767 $etag =
'W/"' . $versionHash .
'"';
770 if ( $this->tryRespondNotModified(
$context, $etag ) ) {
775 if ( $this->config->get(
'UseFileCache' ) ) {
777 if ( $this->tryRespondFromFileCache( $fileCache,
$context, $etag ) ) {
788 $warnings = ob_get_contents();
789 if ( strlen( $warnings ) ) {
790 $this->
errors[] = $warnings;
795 if ( isset( $fileCache ) && !$this->
errors && !
count( $missing ) ) {
806 $this->sendResponseHeaders(
$context, $etag, (
bool)$this->
errors, $this->extraHeaders );
811 if (
$context->getImageObj() && $this->errors ) {
814 } elseif ( $this->
errors ) {
815 $errorText = implode(
"\n\n", $this->
errors );
816 $errorResponse = self::makeComment( $errorText );
817 if (
$context->shouldIncludeScripts() ) {
818 $errorResponse .=
'if (window.console && console.error) {'
842 protected function sendResponseHeaders(
846 $rlMaxage = $this->config->get(
'ResourceLoaderMaxage' );
851 if ( is_null(
$context->getVersion() )
855 $maxage = $rlMaxage[
'unversioned'][
'client'];
856 $smaxage = $rlMaxage[
'unversioned'][
'server'];
860 $maxage = $rlMaxage[
'versioned'][
'client'];
861 $smaxage = $rlMaxage[
'versioned'][
'server'];
866 header(
'Content-Type: text/plain; charset=utf-8' );
870 } elseif (
$context->getOnly() ===
'styles' ) {
871 header(
'Content-Type: text/css; charset=utf-8' );
872 header(
'Access-Control-Allow-Origin: *' );
874 header(
'Content-Type: text/javascript; charset=utf-8' );
878 header(
'ETag: ' . $etag );
881 header(
'Cache-Control: private, no-cache, must-revalidate' );
882 header(
'Pragma: no-cache' );
884 header(
"Cache-Control: public, max-age=$maxage, s-maxage=$smaxage" );
885 $exp = min( $maxage, $smaxage );
886 header(
'Expires: ' .
wfTimestamp( TS_RFC2822, $exp + time() ) );
908 if ( $clientKeys !==
false && !
$context->getDebug() && in_array( $etag, $clientKeys ) ) {
922 $this->sendResponseHeaders(
$context, $etag,
false );
936 protected function tryRespondFromFileCache(
941 $rlMaxage = $this->config->get(
'ResourceLoaderMaxage' );
945 $maxage = is_null(
$context->getVersion() )
946 ? $rlMaxage[
'unversioned'][
'server']
947 : $rlMaxage[
'versioned'][
'server'];
960 $this->sendResponseHeaders(
$context, $etag,
false );
965 $warnings = ob_get_contents();
966 if ( strlen( $warnings ) ) {
989 public static function makeComment( $text ) {
990 $encText = str_replace(
'*/',
'* /', $text );
991 return "/*\n$encText\n*/\n";
1000 public static function formatException(
$e ) {
1001 return self::makeComment( self::formatExceptionNoComment(
$e ) );
1011 protected static function formatExceptionNoComment(
$e ) {
1051 if ( $data ===
false ) {
1053 $this->
errors[] =
'Image generation failed';
1058 foreach ( $missing
as $name ) {
1059 $states[
$name] =
'missing';
1065 $filter =
$context->getOnly() ===
'styles' ?
'minify-css' :
'minify-js';
1069 $content = $module->getModuleContent(
$context );
1070 $implementKey =
$name .
'@' . $module->getVersionHash(
$context );
1073 if ( isset( $content[
'headers'] ) ) {
1074 $this->extraHeaders = array_merge( $this->extraHeaders, $content[
'headers'] );
1080 $scripts = $content[
'scripts'];
1081 if ( is_string( $scripts ) ) {
1083 $strContent = $scripts;
1084 } elseif ( is_array( $scripts ) ) {
1086 $strContent = self::makeLoaderImplementScript( $implementKey, $scripts, [], [], [] );
1090 $styles = $content[
'styles'];
1094 $strContent = isset( $styles[
'css'] ) ? implode(
'', $styles[
'css'] ) :
'';
1097 $scripts = isset( $content[
'scripts'] ) ? $content[
'scripts'] :
'';
1098 if ( is_string( $scripts ) ) {
1099 if (
$name ===
'site' ||
$name ===
'user' ) {
1104 if ( !self::inDebugMode() ) {
1105 $scripts = self::filter(
'minify-js', $scripts );
1111 $strContent = self::makeLoaderImplementScript(
1114 isset( $content[
'styles'] ) ? $content[
'styles'] : [],
1115 isset( $content[
'messagesBlob'] ) ?
new XmlJsCode( $content[
'messagesBlob'] ) : [],
1116 isset( $content[
'templates'] ) ? $content[
'templates'] : []
1122 $strContent = self::filter( $filter, $strContent );
1125 if (
$context->getOnly() ===
'scripts' ) {
1127 $out .= $this->ensureNewline( $strContent );
1129 $out .= $strContent;
1132 }
catch ( Exception
$e ) {
1133 $this->outputErrorAndLog(
$e,
'Generating module package failed: {exception}' );
1136 $states[
$name] =
'error';
1139 $isRaw |= $module->isRaw();
1143 if (
$context->shouldIncludeScripts() && !
$context->getRaw() && !$isRaw ) {
1148 $states[
$name] =
'ready';
1153 if (
count( $states ) ) {
1154 $stateScript = self::makeLoaderStateScript( $states );
1156 $stateScript = self::filter(
'minify-js', $stateScript );
1159 $out = $this->ensureNewline(
$out ) . $stateScript;
1162 if (
count( $states ) ) {
1163 $this->
errors[] =
'Problematic modules: ' .
1176 private function ensureNewline( $str ) {
1177 $end = substr( $str, -1 );
1178 if ( $end ===
false || $end ===
"\n" ) {
1190 public function getModulesByMessage( $messageKey ) {
1192 foreach ( $this->getModuleNames()
as $moduleName ) {
1193 $module = $this->getModule( $moduleName );
1194 if ( in_array( $messageKey, $module->getMessages() ) ) {
1195 $moduleNames[] = $moduleName;
1198 return $moduleNames;
1219 protected static function makeLoaderImplementScript(
1223 $scripts =
new XmlJsCode(
"function ( $, jQuery, require, module ) {\n{$scripts->value}\n}" );
1224 } elseif ( !is_string( $scripts ) && !is_array( $scripts ) ) {
1225 throw new MWException(
'Invalid scripts error. Array of URLs or string of code expected.' );
1237 self::trimArray( $module );
1239 return Xml::encodeJsCall(
'mw.loader.implement', $module, self::inDebugMode() );
1249 public static function makeMessageSetScript(
$messages ) {
1264 public static function makeCombinedStyles(
array $stylePairs ) {
1266 foreach ( $stylePairs
as $media => $styles ) {
1270 $styles = (
array)$styles;
1271 foreach ( $styles
as $style ) {
1272 $style = trim( $style );
1274 if ( $style !==
'' ) {
1279 if ( $media ===
'' || $media ==
'all' ) {
1281 } elseif ( is_string( $media ) ) {
1282 $out[] =
"@media $media {\n" . str_replace(
"\n",
"\n\t",
"\t" . $style ) .
"}";
1305 public static function makeLoaderStateScript(
$name, $state =
null ) {
1306 if ( is_array(
$name ) ) {
1335 public static function makeCustomLoaderScript(
$name, $version, $dependencies,
1338 $script = str_replace(
"\n",
"\n\t", trim( $script ) );
1340 "( function ( name, version, dependencies, group, source ) {\n\t$script\n} )",
1346 private static function isEmptyObject( stdClass $obj ) {
1347 foreach ( $obj
as $key =>
$value ) {
1365 private static function trimArray(
array &$array ) {
1366 $i =
count( $array );
1368 if ( $array[$i] ===
null
1369 || $array[$i] === []
1370 || ( $array[$i] instanceof
XmlJsCode && $array[$i]->
value ===
'{}' )
1371 || ( $array[$i] instanceof stdClass && self::isEmptyObject( $array[$i] ) )
1373 unset( $array[$i] );
1407 public static function makeLoaderRegisterScript(
$name, $version =
null,
1408 $dependencies =
null, $group =
null,
$source =
null, $skip =
null
1410 if ( is_array(
$name ) ) {
1413 foreach (
$name as $i => &$module ) {
1414 $index[$module[0]] = $i;
1419 foreach (
$name as &$module ) {
1420 if ( isset( $module[2] ) ) {
1421 foreach ( $module[2]
as &$dependency ) {
1422 if ( isset( $index[$dependency] ) ) {
1423 $dependency = $index[$dependency];
1429 array_walk(
$name, [
'self',
'trimArray' ] );
1432 'mw.loader.register',
1437 $registration = [
$name, $version, $dependencies, $group,
$source, $skip ];
1438 self::trimArray( $registration );
1440 'mw.loader.register',
1461 public static function makeLoaderSourcesScript( $id, $loadUrl =
null ) {
1462 if ( is_array( $id ) ) {
1464 'mw.loader.addSource',
1470 'mw.loader.addSource',
1485 public static function makeLoaderConditionalScript( $script ) {
1486 return '(window.RLQ=window.RLQ||[]).push(function(){' .
1487 trim( $script ) .
'});';
1499 public static function makeInlineScript( $script ) {
1500 $js = self::makeLoaderConditionalScript( $script );
1501 return new WrappedString(
1503 '<script>(window.RLQ=window.RLQ||[]).push(function(){',
1515 public static function makeConfigSetScript(
array $configuration ) {
1531 public static function makePackedModulesString(
$modules ) {
1534 $pos = strrpos( $module,
'.' );
1535 $prefix = $pos ===
false ?
'' : substr( $module, 0, $pos );
1536 $suffix = $pos ===
false ? $module : substr( $module, $pos + 1 );
1537 $groups[$prefix][] = $suffix;
1541 foreach ( $groups
as $prefix => $suffixes ) {
1542 $p = $prefix ===
'' ?
'' : $prefix .
'.';
1543 $arr[] = $p . implode(
',', $suffixes );
1545 $str = implode(
'|', $arr );
1554 public static function inDebugMode() {
1555 if ( self::$debugMode ===
null ) {
1557 self::$debugMode =
$wgRequest->getFuzzyBool(
'debug',
1561 return self::$debugMode;
1571 public static function clearCache() {
1572 self::$debugMode =
null;
1588 $script = $this->getLoadScript(
$source );
1603 return self::makeLoaderQuery(
1611 $context->getRequest()->getBool(
'printable' ),
1612 $context->getRequest()->getBool(
'handheld' ),
1635 $version =
null,
$debug =
false, $only =
null, $printable =
false,
1636 $handheld =
false, $extraQuery = []
1639 'modules' => self::makePackedModulesString(
$modules ),
1642 'debug' =>
$debug ?
'true' :
'false',
1644 if (
$user !==
null ) {
1647 if ( $version !==
null ) {
1648 $query[
'version'] = $version;
1650 if ( $only !==
null ) {
1675 public static function isValidModuleName( $moduleName ) {
1676 return strcspn( $moduleName,
'!,|', 0, 255 ) === strlen( $moduleName );
1688 public function getLessCompiler( $extraVars = [] ) {
1692 if ( !class_exists(
'Less_Parser' ) ) {
1693 throw new MWException(
'MediaWiki requires the less.php parser' );
1697 $parser->ModifyVars( array_merge( $this->getLessVars(), $extraVars ) );
1699 array_fill_keys( $this->config->get(
'ResourceLoaderLESSImportPaths' ),
'' )
1701 $parser->SetOption(
'relativeUrls',
false );
1712 public function getLessVars() {
1713 if ( !$this->lessVars ) {
1714 $lessVars = $this->config->get(
'ResourceLoaderLESSVars' );
1715 Hooks::run(
'ResourceLoaderGetLessVars', [ &$lessVars ] );
1716 $this->lessVars = $lessVars;
1718 return $this->lessVars;