aboutsummaryrefslogtreecommitdiffstats
path: root/includes/ResourceLoader
diff options
context:
space:
mode:
authorTim Starling <tstarling@wikimedia.org>2022-05-06 19:09:56 +1000
committerKrinkle <krinkle@fastmail.com>2022-05-24 15:41:46 +0000
commit3e2653f83bc096889d8b69d1e01a52d7de42b247 (patch)
tree2b87e1d578790776fa139b89f561695666bf1591 /includes/ResourceLoader
parent62c75f78f4e5b19a727571a6633b7091b84fc651 (diff)
downloadmediawikicore-3e2653f83bc096889d8b69d1e01a52d7de42b247.tar.gz
mediawikicore-3e2653f83bc096889d8b69d1e01a52d7de42b247.zip
ResourceLoader namespace (attempt 2)
Move ResourceLoader classes to their own namespace. Strip the "ResourceLoader" prefix from all except ResourceLoader itself. Move the tests by analogy. I used a namespace alias "RL" in some callers since RL\Module is less ambiguous at the call site than just "Module". I did not address DependencyStore which continues to have a non-standard location and namespace. Revert of a241d83e0a6dabedf. Bug: T308718 Change-Id: Id08a220e1d6085e2b33f3f6c9d0e3935a4204659
Diffstat (limited to 'includes/ResourceLoader')
-rw-r--r--includes/ResourceLoader/CircularDependencyError.php33
-rw-r--r--includes/ResourceLoader/ClientHtml.php503
-rw-r--r--includes/ResourceLoader/CodexModule.php88
-rw-r--r--includes/ResourceLoader/Context.php515
-rw-r--r--includes/ResourceLoader/DerivativeContext.php266
-rw-r--r--includes/ResourceLoader/FileModule.php1489
-rw-r--r--includes/ResourceLoader/FilePath.php90
-rw-r--r--includes/ResourceLoader/ForeignApiModule.php39
-rw-r--r--includes/ResourceLoader/Hook/ResourceLoaderExcludeUserOptionsHook.php37
-rw-r--r--includes/ResourceLoader/Hook/ResourceLoaderForeignApiModulesHook.php28
-rw-r--r--includes/ResourceLoader/Hook/ResourceLoaderGetConfigVarsHook.php31
-rw-r--r--includes/ResourceLoader/Hook/ResourceLoaderJqueryMsgModuleMagicWordsHook.php30
-rw-r--r--includes/ResourceLoader/Hook/ResourceLoaderRegisterModulesHook.php25
-rw-r--r--includes/ResourceLoader/Hook/ResourceLoaderSiteModulePagesHook.php24
-rw-r--r--includes/ResourceLoader/Hook/ResourceLoaderSiteStylesModulePagesHook.php24
-rw-r--r--includes/ResourceLoader/Hook/ResourceLoaderTestModulesHook.php34
-rw-r--r--includes/ResourceLoader/HookRunner.php74
-rw-r--r--includes/ResourceLoader/Image.php502
-rw-r--r--includes/ResourceLoader/ImageModule.php504
-rw-r--r--includes/ResourceLoader/LanguageDataModule.php87
-rw-r--r--includes/ResourceLoader/LessVarFileModule.php138
-rw-r--r--includes/ResourceLoader/MessageBlobStore.php266
-rw-r--r--includes/ResourceLoader/Module.php1128
-rw-r--r--includes/ResourceLoader/MwUrlModule.php46
-rw-r--r--includes/ResourceLoader/OOUIFileModule.php119
-rw-r--r--includes/ResourceLoader/OOUIIconPackModule.php90
-rw-r--r--includes/ResourceLoader/OOUIImageModule.php162
-rw-r--r--includes/ResourceLoader/OOUIModule.php184
-rw-r--r--includes/ResourceLoader/ResourceLoader.php2060
-rw-r--r--includes/ResourceLoader/SiteModule.php64
-rw-r--r--includes/ResourceLoader/SiteStylesModule.php71
-rw-r--r--includes/ResourceLoader/SkinModule.php724
-rw-r--r--includes/ResourceLoader/StartUpModule.php453
-rw-r--r--includes/ResourceLoader/UserModule.php91
-rw-r--r--includes/ResourceLoader/UserOptionsModule.php96
-rw-r--r--includes/ResourceLoader/UserStylesModule.php100
-rw-r--r--includes/ResourceLoader/VueComponentParser.php321
-rw-r--r--includes/ResourceLoader/WikiModule.php723
-rw-r--r--includes/ResourceLoader/dependencystore/DependencyStore.php126
-rw-r--r--includes/ResourceLoader/dependencystore/DependencyStoreException.php13
-rw-r--r--includes/ResourceLoader/dependencystore/KeyValueDependencyStore.php118
-rw-r--r--includes/ResourceLoader/dependencystore/SqlModuleDependencyStore.php225
42 files changed, 11741 insertions, 0 deletions
diff --git a/includes/ResourceLoader/CircularDependencyError.php b/includes/ResourceLoader/CircularDependencyError.php
new file mode 100644
index 000000000000..99835ebcac10
--- /dev/null
+++ b/includes/ResourceLoader/CircularDependencyError.php
@@ -0,0 +1,33 @@
+<?php
+/**
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation; either version 2 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License along
+ * with this program; if not, write to the Free Software Foundation, Inc.,
+ * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ */
+
+namespace MediaWiki\ResourceLoader;
+
+use Exception;
+
+/**
+ * @ingroup ResourceLoader
+ * @internal For use by ResourceLoaderStartUpModule only
+ */
+class CircularDependencyError extends Exception {
+}
+
+/** @deprecated since 1.39 */
+class_alias( CircularDependencyError::class, 'ResourceLoaderCircularDependencyError' );
diff --git a/includes/ResourceLoader/ClientHtml.php b/includes/ResourceLoader/ClientHtml.php
new file mode 100644
index 000000000000..630d10f632c5
--- /dev/null
+++ b/includes/ResourceLoader/ClientHtml.php
@@ -0,0 +1,503 @@
+<?php
+/**
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation; either version 2 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License along
+ * with this program; if not, write to the Free Software Foundation, Inc.,
+ * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ */
+
+namespace MediaWiki\ResourceLoader;
+
+use Html;
+use Wikimedia\WrappedString;
+use Wikimedia\WrappedStringList;
+
+/**
+ * Load and configure a ResourceLoader client on an HTML page.
+ *
+ * @ingroup ResourceLoader
+ * @since 1.28
+ */
+class ClientHtml {
+ /** @var Context */
+ private $context;
+
+ /** @var ResourceLoader */
+ private $resourceLoader;
+
+ /** @var array<string,string|null|false> */
+ private $options;
+
+ /** @var array<string,mixed> */
+ private $config = [];
+
+ /** @var string[] */
+ private $modules = [];
+
+ /** @var string[] */
+ private $moduleStyles = [];
+
+ /** @var array<string,string> */
+ private $exemptStates = [];
+
+ /** @var array */
+ private $data;
+
+ /**
+ * @param Context $context
+ * @param array $options [optional] Array of options
+ * - 'target': Parameter for modules=startup request, see StartUpModule.
+ * - 'safemode': Parameter for modules=startup request, see StartUpModule.
+ * - 'nonce': From OutputPage->getCSP->getNonce().
+ */
+ public function __construct( Context $context, array $options = [] ) {
+ $this->context = $context;
+ $this->resourceLoader = $context->getResourceLoader();
+ $this->options = $options + [
+ 'target' => null,
+ 'safemode' => null,
+ 'nonce' => null,
+ ];
+ }
+
+ /**
+ * Set mw.config variables.
+ *
+ * @param array $vars Array of key/value pairs
+ */
+ public function setConfig( array $vars ): void {
+ foreach ( $vars as $key => $value ) {
+ $this->config[$key] = $value;
+ }
+ }
+
+ /**
+ * Ensure one or more modules are loaded.
+ *
+ * @param string[] $modules Array of module names
+ */
+ public function setModules( array $modules ): void {
+ $this->modules = $modules;
+ }
+
+ /**
+ * Ensure the styles of one or more modules are loaded.
+ *
+ * @param string[] $modules Array of module names
+ */
+ public function setModuleStyles( array $modules ): void {
+ $this->moduleStyles = $modules;
+ }
+
+ /**
+ * Set state of special modules that are handled by the caller manually.
+ *
+ * See OutputPage::buildExemptModules() for use cases.
+ *
+ * @param array<string,string> $states Module state keyed by module name
+ */
+ public function setExemptStates( array $states ): void {
+ $this->exemptStates = $states;
+ }
+
+ private function getData(): array {
+ if ( $this->data ) {
+ // @codeCoverageIgnoreStart
+ return $this->data;
+ // @codeCoverageIgnoreEnd
+ }
+
+ $rl = $this->resourceLoader;
+ $data = [
+ 'states' => [
+ // moduleName => state
+ ],
+ 'general' => [],
+ 'styles' => [],
+ // Embedding for private modules
+ 'embed' => [
+ 'styles' => [],
+ 'general' => [],
+ ],
+ // Deprecations for style-only modules
+ 'styleDeprecations' => [],
+ ];
+
+ foreach ( $this->modules as $name ) {
+ $module = $rl->getModule( $name );
+ if ( !$module ) {
+ continue;
+ }
+
+ $group = $module->getGroup();
+ $context = $this->getContext( $group, Module::TYPE_COMBINED );
+ $shouldEmbed = $module->shouldEmbedModule( $this->context );
+
+ if ( ( $group === Module::GROUP_USER || $shouldEmbed ) &&
+ $module->isKnownEmpty( $context ) ) {
+ // This is a user-specific or embedded module, which means its output
+ // can be specific to the current page or user. As such, we can optimise
+ // the way we load it based on the current version of the module.
+ // Avoid needless embed for empty module, preset ready state.
+ $data['states'][$name] = 'ready';
+ } elseif ( $group === Module::GROUP_USER || $shouldEmbed ) {
+ // - For group=user: We need to provide a pre-generated load.php
+ // url to the client that has the 'user' and 'version' parameters
+ // filled in. Without this, the client would wrongly use the static
+ // version hash, per T64602.
+ // - For shouldEmbed=true: Embed via mw.loader.implement, per T36907.
+ $data['embed']['general'][] = $name;
+ // Avoid duplicate request from mw.loader
+ $data['states'][$name] = 'loading';
+ } else {
+ // Load via mw.loader.load()
+ $data['general'][] = $name;
+ }
+ }
+
+ foreach ( $this->moduleStyles as $name ) {
+ $module = $rl->getModule( $name );
+ if ( !$module ) {
+ continue;
+ }
+
+ if ( $module->getType() !== Module::LOAD_STYLES ) {
+ $logger = $rl->getLogger();
+ $logger->error( 'Unexpected general module "{module}" in styles queue.', [
+ 'module' => $name,
+ ] );
+ continue;
+ }
+
+ // Stylesheet doesn't trigger mw.loader callback.
+ // Set "ready" state to allow script modules to depend on this module (T87871).
+ // And to avoid duplicate requests at run-time from mw.loader.
+ //
+ // Optimization: Exclude state for "noscript" modules. Since these are also excluded
+ // from the startup registry, no need to send their states (T291735).
+ $group = $module->getGroup();
+ if ( $group !== Module::GROUP_NOSCRIPT ) {
+ $data['states'][$name] = 'ready';
+ }
+
+ $context = $this->getContext( $group, Module::TYPE_STYLES );
+ if ( $module->shouldEmbedModule( $this->context ) ) {
+ // Avoid needless embed for private embeds we know are empty.
+ // (Set "ready" state directly instead, which we do a few lines above.)
+ if ( !$module->isKnownEmpty( $context ) ) {
+ // Embed via <style> element
+ $data['embed']['styles'][] = $name;
+ }
+ // For other style modules, always request them, regardless of whether they are
+ // currently known to be empty. Because:
+ // 1. Those modules are requested in batch, so there is no extra request overhead
+ // or extra HTML element to be avoided.
+ // 2. Checking isKnownEmpty for those can be expensive and slow down page view
+ // generation (T230260).
+ // 3. We don't want cached HTML to vary on the current state of a module.
+ // If the module becomes non-empty a few minutes later, it should start working
+ // on cached HTML without requiring a purge.
+ //
+ // But, user-specific modules:
+ // * ... are used on page views not publicly cached.
+ // * ... are in their own group and thus a require a request we can avoid
+ // * ... have known-empty status preloaded by ResourceLoader.
+ } elseif ( $group !== Module::GROUP_USER || !$module->isKnownEmpty( $context ) ) {
+ // Load from load.php?only=styles via <link rel=stylesheet>
+ $data['styles'][] = $name;
+ }
+ $deprecation = $module->getDeprecationInformation( $context );
+ if ( $deprecation ) {
+ $data['styleDeprecations'][] = $deprecation;
+ }
+ }
+
+ return $data;
+ }
+
+ /**
+ * @return array<string,string> Attributes pairs for the HTML document element
+ */
+ public function getDocumentAttributes() {
+ return [ 'class' => 'client-nojs' ];
+ }
+
+ /**
+ * The order of elements in the head is as follows:
+ * - Inline scripts.
+ * - Stylesheets.
+ * - Async external script-src.
+ *
+ * Reasons:
+ * - Script execution may be blocked on preceding stylesheets.
+ * - Async scripts are not blocked on stylesheets.
+ * - Inline scripts can't be asynchronous.
+ * - For styles, earlier is better.
+ *
+ * @param string|null $nojsClass Class name that caller uses on HTML document element
+ * @return string|WrappedStringList HTML
+ */
+ public function getHeadHtml( $nojsClass = null ) {
+ $nonce = $this->options['nonce'];
+ $data = $this->getData();
+ $chunks = [];
+
+ // Change "client-nojs" class to client-js. This allows easy toggling of UI components.
+ // This must happen synchronously on every page view to avoid flashes of wrong content.
+ // See also startup/startup.js.
+ $nojsClass = $nojsClass ?? $this->getDocumentAttributes()['class'];
+ $jsClass = preg_replace( '/(^|\s)client-nojs(\s|$)/', '$1client-js$2', $nojsClass );
+ $jsClassJson = $this->context->encodeJson( $jsClass );
+ $script = "
+document.documentElement.className = {$jsClassJson};
+";
+
+ // Inline script: Declare mw.config variables for this page.
+ if ( $this->config ) {
+ $confJson = $this->context->encodeJson( $this->config );
+ $script .= "
+RLCONF = {$confJson};
+";
+ }
+
+ // Inline script: Declare initial module states for this page.
+ $states = array_merge( $this->exemptStates, $data['states'] );
+ if ( $states ) {
+ $stateJson = $this->context->encodeJson( $states );
+ $script .= "
+RLSTATE = {$stateJson};
+";
+ }
+
+ // Inline script: Declare general modules to load on this page.
+ if ( $data['general'] ) {
+ $pageModulesJson = $this->context->encodeJson( $data['general'] );
+ $script .= "
+RLPAGEMODULES = {$pageModulesJson};
+";
+ }
+
+ if ( !$this->context->getDebug() ) {
+ $script = ResourceLoader::filter( 'minify-js', $script, [ 'cache' => false ] );
+ }
+
+ $chunks[] = Html::inlineScript( $script, $nonce );
+
+ // Inline RLQ: Embedded modules
+ if ( $data['embed']['general'] ) {
+ $chunks[] = $this->getLoad(
+ $data['embed']['general'],
+ Module::TYPE_COMBINED,
+ $nonce
+ );
+ }
+
+ // External stylesheets (only=styles)
+ if ( $data['styles'] ) {
+ $chunks[] = $this->getLoad(
+ $data['styles'],
+ Module::TYPE_STYLES,
+ $nonce
+ );
+ }
+
+ // Inline stylesheets (embedded only=styles)
+ if ( $data['embed']['styles'] ) {
+ $chunks[] = $this->getLoad(
+ $data['embed']['styles'],
+ Module::TYPE_STYLES,
+ $nonce
+ );
+ }
+
+ // Async scripts. Once the startup is loaded, inline RLQ scripts will run.
+ // Pass-through a custom 'target' from OutputPage (T143066).
+ $startupQuery = [ 'raw' => '1' ];
+ foreach ( [ 'target', 'safemode' ] as $param ) {
+ if ( $this->options[$param] !== null ) {
+ $startupQuery[$param] = (string)$this->options[$param];
+ }
+ }
+ $chunks[] = $this->getLoad(
+ 'startup',
+ Module::TYPE_SCRIPTS,
+ $nonce,
+ $startupQuery
+ );
+
+ return WrappedString::join( "\n", $chunks );
+ }
+
+ /**
+ * @return string|WrappedStringList HTML
+ */
+ public function getBodyHtml() {
+ $data = $this->getData();
+ $chunks = [];
+
+ // Deprecations for only=styles modules
+ if ( $data['styleDeprecations'] ) {
+ $chunks[] = ResourceLoader::makeInlineScript(
+ implode( '', $data['styleDeprecations'] ),
+ $this->options['nonce']
+ );
+ }
+
+ return WrappedString::join( "\n", $chunks );
+ }
+
+ private function getContext( $group, $type ): Context {
+ return self::makeContext( $this->context, $group, $type );
+ }
+
+ private function getLoad( $modules, $only, $nonce, array $extraQuery = [] ) {
+ return self::makeLoad( $this->context, (array)$modules, $only, $extraQuery, $nonce );
+ }
+
+ private static function makeContext( Context $mainContext, $group, $type,
+ array $extraQuery = []
+ ): DerivativeContext {
+ // Allow caller to setVersion() and setModules()
+ $ret = new DerivativeContext( $mainContext );
+ // Set 'only' if not combined
+ $ret->setOnly( $type === Module::TYPE_COMBINED ? null : $type );
+ // Remove user parameter in most cases
+ if ( $group !== Module::GROUP_USER && $group !== Module::GROUP_PRIVATE ) {
+ $ret->setUser( null );
+ }
+ if ( isset( $extraQuery['raw'] ) ) {
+ $ret->setRaw( true );
+ }
+ return $ret;
+ }
+
+ /**
+ * Explicitly load or embed modules on a page.
+ *
+ * @param Context $mainContext
+ * @param string[] $modules One or more module names
+ * @param string $only Module TYPE_ class constant
+ * @param array $extraQuery [optional] Array with extra query parameters for the request
+ * @param string|null $nonce [optional] Content-Security-Policy nonce
+ * (from OutputPage->getCSP->getNonce())
+ * @return string|WrappedStringList HTML
+ */
+ public static function makeLoad( Context $mainContext, array $modules, $only,
+ array $extraQuery = [], $nonce = null
+ ) {
+ $rl = $mainContext->getResourceLoader();
+ $chunks = [];
+
+ // Sort module names so requests are more uniform
+ sort( $modules );
+
+ if ( $mainContext->getDebug() && count( $modules ) > 1 ) {
+ // Recursively call us for every item
+ foreach ( $modules as $name ) {
+ $chunks[] = self::makeLoad( $mainContext, [ $name ], $only, $extraQuery, $nonce );
+ }
+ return new WrappedStringList( "\n", $chunks );
+ }
+
+ // Create keyed-by-source and then keyed-by-group list of module objects from modules list
+ $sortedModules = [];
+ foreach ( $modules as $name ) {
+ $module = $rl->getModule( $name );
+ if ( !$module ) {
+ $rl->getLogger()->warning( 'Unknown module "{module}"', [ 'module' => $name ] );
+ continue;
+ }
+ $sortedModules[$module->getSource()][$module->getGroup()][$name] = $module;
+ }
+
+ foreach ( $sortedModules as $source => $groups ) {
+ foreach ( $groups as $group => $grpModules ) {
+ $context = self::makeContext( $mainContext, $group, $only, $extraQuery );
+
+ // Separate sets of linked and embedded modules while preserving order
+ $moduleSets = [];
+ $idx = -1;
+ foreach ( $grpModules as $name => $module ) {
+ $shouldEmbed = $module->shouldEmbedModule( $context );
+ if ( !$moduleSets || $moduleSets[$idx][0] !== $shouldEmbed ) {
+ $moduleSets[++$idx] = [ $shouldEmbed, [] ];
+ }
+ $moduleSets[$idx][1][$name] = $module;
+ }
+
+ // Link/embed each set
+ foreach ( $moduleSets as list( $embed, $moduleSet ) ) {
+ $moduleSetNames = array_keys( $moduleSet );
+ $context->setModules( $moduleSetNames );
+ if ( $embed ) {
+ // Decide whether to use style or script element
+ if ( $only == Module::TYPE_STYLES ) {
+ $chunks[] = Html::inlineStyle(
+ $rl->makeModuleResponse( $context, $moduleSet )
+ );
+ } else {
+ $chunks[] = ResourceLoader::makeInlineScript(
+ $rl->makeModuleResponse( $context, $moduleSet ),
+ $nonce
+ );
+ }
+ } else {
+ // Special handling for the user group; because users might change their stuff
+ // on-wiki like user pages, or user preferences; we need to find the highest
+ // timestamp of these user-changeable modules so we can ensure cache misses on change
+ // This should NOT be done for the site group (T29564) because anons get that too
+ // and we shouldn't be putting timestamps in CDN-cached HTML
+ if ( $group === Module::GROUP_USER ) {
+ $context->setVersion( $rl->makeVersionQuery( $context, $moduleSetNames ) );
+ }
+
+ // Must setModules() before createLoaderURL()
+ $url = $rl->createLoaderURL( $source, $context, $extraQuery );
+
+ // Decide whether to use 'style' or 'script' element
+ if ( $only === Module::TYPE_STYLES ) {
+ $chunk = Html::linkedStyle( $url );
+ } elseif ( $context->getRaw() ) {
+ // This request is asking for the module to be delivered standalone,
+ // (aka "raw") without communicating to any mw.loader client.
+ // For:
+ // - startup (naturally because this is what will define mw.loader)
+ $chunk = Html::element( 'script', [
+ 'async' => true,
+ 'src' => $url,
+ ] );
+ } else {
+ $chunk = ResourceLoader::makeInlineScript(
+ 'mw.loader.load(' . $mainContext->encodeJson( $url ) . ');',
+ $nonce
+ );
+ }
+
+ if ( $group == Module::GROUP_NOSCRIPT ) {
+ $chunks[] = Html::rawElement( 'noscript', [], $chunk );
+ } else {
+ $chunks[] = $chunk;
+ }
+ }
+ }
+ }
+ }
+
+ return new WrappedStringList( "\n", $chunks );
+ }
+}
+
+/** @deprecated since 1.39 */
+class_alias( ClientHtml::class, 'ResourceLoaderClientHtml' );
diff --git a/includes/ResourceLoader/CodexModule.php b/includes/ResourceLoader/CodexModule.php
new file mode 100644
index 000000000000..9cc0b208b9e2
--- /dev/null
+++ b/includes/ResourceLoader/CodexModule.php
@@ -0,0 +1,88 @@
+<?php
+/**
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation; either version 2 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License along
+ * with this program; if not, write to the Free Software Foundation, Inc.,
+ * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ */
+
+namespace MediaWiki\ResourceLoader;
+
+use Config;
+
+/**
+ * Module for codex that has direction-specific style files and a static helper function for
+ * embedding icons in package modules.
+ *
+ * @ingroup ResourceLoader
+ * @internal
+ */
+class CodexModule extends FileModule {
+
+ protected $dirSpecificStyles = [];
+
+ public function __construct( array $options = [], $localBasePath = null, $remoteBasePath = null ) {
+ if ( isset( $options['dirSpecificStyles'] ) ) {
+ $this->dirSpecificStyles = $options['dirSpecificStyles'];
+ }
+
+ parent::__construct( $options, $localBasePath, $remoteBasePath );
+ }
+
+ public function getStyleFiles( Context $context ) {
+ // Add direction-specific styles
+ $dir = $context->getDirection();
+ if ( isset( $this->dirSpecificStyles[ $dir ] ) ) {
+ $this->styles = array_merge( $this->styles, (array)$this->dirSpecificStyles[ $dir ] );
+ // Empty dirSpecificStyles so we don't add them twice if getStyleFiles() is called twice
+ $this->dirSpecificStyles = [];
+ }
+
+ return parent::getStyleFiles( $context );
+ }
+
+ /**
+ * Retrieve the specified icon definitions from codex-icons.json. Intended as a convenience
+ * function to be used in packageFiles definitions.
+ *
+ * Example:
+ * "packageFiles": [
+ * {
+ * "name": "icons.json",
+ * "callback": "ResourceLoaderCodexModule::getIcons",
+ * "callbackParam": [
+ * "cdxIconClear",
+ * "cdxIconTrash"
+ * ]
+ * }
+ * ]
+ *
+ * @param Context $context
+ * @param Config $config
+ * @param string[] $iconNames Names of icons to fetch
+ * @return array
+ */
+ public static function getIcons( Context $context, Config $config, array $iconNames = [] ) {
+ global $IP;
+ static $allIcons = null;
+ if ( $allIcons === null ) {
+ $allIcons = json_decode( file_get_contents( "$IP/resources/lib/codex-icons/codex-icons.json" ), true );
+ }
+ return array_intersect_key( $allIcons, array_flip( $iconNames ) );
+ }
+}
+
+/** @deprecated since 1.39 */
+class_alias( CodexModule::class, 'ResourceLoaderCodexModule' );
diff --git a/includes/ResourceLoader/Context.php b/includes/ResourceLoader/Context.php
new file mode 100644
index 000000000000..bd8e5791f53d
--- /dev/null
+++ b/includes/ResourceLoader/Context.php
@@ -0,0 +1,515 @@
+<?php
+/**
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation; either version 2 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License along
+ * with this program; if not, write to the Free Software Foundation, Inc.,
+ * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @author Trevor Parscal
+ * @author Roan Kattouw
+ */
+
+namespace MediaWiki\ResourceLoader;
+
+use Config;
+use FauxRequest;
+use MediaWiki\Logger\LoggerFactory;
+use MediaWiki\MediaWikiServices;
+use MediaWiki\Page\PageReferenceValue;
+use MediaWiki\User\UserIdentity;
+use MediaWiki\User\UserRigorOptions;
+use Message;
+use MessageLocalizer;
+use MessageSpecifier;
+use Psr\Log\LoggerInterface;
+use User;
+use WebRequest;
+
+/**
+ * Context object that contains information about the state of a specific
+ * ResourceLoader web request. Passed around to Module methods.
+ *
+ * @ingroup ResourceLoader
+ * @since 1.17
+ */
+class Context implements MessageLocalizer {
+ public const DEFAULT_LANG = 'qqx';
+ public const DEFAULT_SKIN = 'fallback';
+
+ /** @internal For use in ResourceLoader classes. */
+ public const DEBUG_OFF = 0;
+ /** @internal For use in ResourceLoader classes. */
+ public const DEBUG_LEGACY = 1;
+ private const DEBUG_MAIN = 2;
+
+ /** @var ResourceLoader */
+ protected $resourceLoader;
+ /** @var WebRequest */
+ protected $request;
+ /** @var LoggerInterface */
+ protected $logger;
+
+ // Module content vary
+ /** @var string */
+ protected $skin;
+ /** @var string */
+ protected $language;
+ /** @var int */
+ protected $debug;
+ /** @var string|null */
+ protected $user;
+
+ // Request vary (in addition to cache vary)
+ /** @var string[] */
+ protected $modules;
+ /** @var string|null */
+ protected $only;
+ /** @var string|null */
+ protected $version;
+ /** @var bool */
+ protected $raw;
+ /** @var string|null */
+ protected $image;
+ /** @var string|null */
+ protected $variant;
+ /** @var string|null */
+ protected $format;
+
+ /** @var string|null */
+ protected $direction;
+ /** @var string|null */
+ protected $hash;
+ /** @var User|null */
+ protected $userObj;
+ /** @var UserIdentity|null|false */
+ protected $userIdentity = false;
+ /** @var Image|false */
+ protected $imageObj;
+
+ /**
+ * @param ResourceLoader $resourceLoader
+ * @param WebRequest $request
+ */
+ public function __construct( ResourceLoader $resourceLoader, WebRequest $request ) {
+ $this->resourceLoader = $resourceLoader;
+ $this->request = $request;
+ $this->logger = $resourceLoader->getLogger();
+
+ // Optimisation: Use WebRequest::getRawVal() instead of getVal(). We don't
+ // need the slow Language+UTF logic meant for user input here. (f303bb9360)
+
+ // List of modules
+ $modules = $request->getRawVal( 'modules' );
+ $this->modules = $modules ? ResourceLoader::expandModuleNames( $modules ) : [];
+
+ // Various parameters
+ $this->user = $request->getRawVal( 'user' );
+ $this->debug = self::debugFromString( $request->getRawVal( 'debug' ) );
+ $this->only = $request->getRawVal( 'only' );
+ $this->version = $request->getRawVal( 'version' );
+ $this->raw = $request->getFuzzyBool( 'raw' );
+
+ // Image requests
+ $this->image = $request->getRawVal( 'image' );
+ $this->variant = $request->getRawVal( 'variant' );
+ $this->format = $request->getRawVal( 'format' );
+
+ $this->skin = $request->getRawVal( 'skin' );
+ $skinFactory = MediaWikiServices::getInstance()->getSkinFactory();
+ $skinnames = $skinFactory->getInstalledSkins();
+
+ if ( !$this->skin || !isset( $skinnames[$this->skin] ) ) {
+ // The 'skin' parameter is required. (Not yet enforced.)
+ // For requests without a known skin specified,
+ // use MediaWiki's 'fallback' skin for skin-specific decisions.
+ $this->skin = self::DEFAULT_SKIN;
+ }
+ }
+
+ /**
+ * @internal For use in ResourceLoader::inDebugMode
+ * @param string|null $debug
+ * @return int
+ */
+ public static function debugFromString( ?string $debug ): int {
+ // The canonical way to enable debug mode is via debug=true
+ // This continues to map to v1 until v2 is ready (T85805).
+ if ( $debug === 'true' || $debug === '1' ) {
+ $ret = self::DEBUG_LEGACY;
+ } elseif ( $debug === '2' ) {
+ $ret = self::DEBUG_MAIN;
+ } else {
+ $ret = self::DEBUG_OFF;
+ }
+
+ return $ret;
+ }
+
+ /**
+ * Return a dummy Context object suitable for passing into
+ * things that don't "really" need a context.
+ *
+ * Use cases:
+ * - Unit tests (deprecated, create empty instance directly or use RLTestCase).
+ *
+ * @return Context
+ */
+ public static function newDummyContext(): Context {
+ // This currently creates a non-empty instance of ResourceLoader (all modules registered),
+ // but that's probably not needed. So once that moves into ServiceWiring, this'll
+ // become more like the EmptyResourceLoader class we have in PHPUnit tests, which
+ // is what this should've had originally. If this turns out to be untrue, change to:
+ // `MediaWikiServices::getInstance()->getResourceLoader()` instead.
+ return new self( new ResourceLoader(
+ MediaWikiServices::getInstance()->getMainConfig(),
+ LoggerFactory::getInstance( 'resourceloader' )
+ ), new FauxRequest( [] ) );
+ }
+
+ public function getResourceLoader(): ResourceLoader {
+ return $this->resourceLoader;
+ }
+
+ /**
+ * @deprecated since 1.34 Use Module::getConfig instead inside module
+ * methods. Use ResourceLoader::getConfig elsewhere.
+ * @return Config
+ * @codeCoverageIgnore
+ */
+ public function getConfig() {
+ wfDeprecated( __METHOD__, '1.34' );
+ return $this->getResourceLoader()->getConfig();
+ }
+
+ public function getRequest(): WebRequest {
+ return $this->request;
+ }
+
+ /**
+ * @deprecated since 1.34 Use Module::getLogger instead
+ * inside module methods. Use ResourceLoader::getLogger elsewhere.
+ * @since 1.27
+ * @return LoggerInterface
+ */
+ public function getLogger() {
+ return $this->logger;
+ }
+
+ public function getModules(): array {
+ return $this->modules;
+ }
+
+ public function getLanguage(): string {
+ if ( $this->language === null ) {
+ // Must be a valid language code after this point (T64849)
+ // Only support uselang values that follow built-in conventions (T102058)
+ $lang = $this->getRequest()->getRawVal( 'lang', '' );
+ '@phan-var string $lang'; // getRawVal does not return null here
+ // Stricter version of RequestContext::sanitizeLangCode()
+ $validBuiltinCode = MediaWikiServices::getInstance()->getLanguageNameUtils()
+ ->isValidBuiltInCode( $lang );
+ if ( !$validBuiltinCode ) {
+ // The 'lang' parameter is required. (Not yet enforced.)
+ // If omitted, localise with the dummy language code.
+ $lang = self::DEFAULT_LANG;
+ }
+ $this->language = $lang;
+ }
+ return $this->language;
+ }
+
+ public function getDirection(): string {
+ if ( $this->direction === null ) {
+ $direction = $this->getRequest()->getRawVal( 'dir' );
+ if ( $direction === 'ltr' || $direction === 'rtl' ) {
+ $this->direction = $direction;
+ } else {
+ // Determine directionality based on user language (T8100)
+ $this->direction = MediaWikiServices::getInstance()->getLanguageFactory()
+ ->getLanguage( $this->getLanguage() )->getDir();
+ }
+ }
+ return $this->direction;
+ }
+
+ public function getSkin(): string {
+ return $this->skin;
+ }
+
+ /**
+ * @return string|null
+ */
+ public function getUser(): ?string {
+ return $this->user;
+ }
+
+ /**
+ * Get a Message object with context set. See wfMessage for parameters.
+ *
+ * @since 1.27
+ * @param string|string[]|MessageSpecifier $key Message key, or array of keys,
+ * or a MessageSpecifier.
+ * @param mixed ...$params
+ * @return Message
+ */
+ public function msg( $key, ...$params ): Message {
+ return wfMessage( $key, ...$params )
+ // Do not use MediaWiki user language from session. Use the provided one instead.
+ ->inLanguage( $this->getLanguage() )
+ // inLanguage() clears the interface flag, so we need re-enable it. (T291601)
+ ->setInterfaceMessageFlag( true )
+ // Use a dummy title because there is no real title for this endpoint, and the cache won't
+ // vary on it anyways.
+ ->page( PageReferenceValue::localReference( NS_SPECIAL, 'Badtitle/ResourceLoaderContext' ) );
+ }
+
+ /**
+ * Get the possibly-cached UserIdentity object for the specified username
+ *
+ * This will be null on most requests,
+ * except for load.php requests that have a 'user' parameter set.
+ *
+ * @since 1.38
+ * @return UserIdentity|null
+ */
+ public function getUserIdentity(): ?UserIdentity {
+ if ( $this->userIdentity === false ) {
+ $username = $this->getUser();
+ if ( $username === null ) {
+ // Anonymous user
+ $this->userIdentity = null;
+ } else {
+ // Use provided username if valid
+ $this->userIdentity = MediaWikiServices::getInstance()
+ ->getUserFactory()
+ ->newFromName( $username, UserRigorOptions::RIGOR_VALID );
+ }
+ }
+ return $this->userIdentity;
+ }
+
+ /**
+ * Get the possibly-cached User object for the specified username
+ *
+ * @since 1.25
+ * @return User
+ */
+ public function getUserObj(): User {
+ if ( $this->userObj === null ) {
+ $username = $this->getUser();
+ if ( $username ) {
+ // Use provided username if valid, fallback to anonymous user
+ $this->userObj = User::newFromName( $username ) ?: new User;
+ } else {
+ // Anonymous user
+ $this->userObj = new User;
+ }
+ }
+
+ return $this->userObj;
+ }
+
+ public function getDebug(): int {
+ return $this->debug;
+ }
+
+ /**
+ * @return string|null
+ */
+ public function getOnly(): ?string {
+ return $this->only;
+ }
+
+ /**
+ * @see Module::getVersionHash
+ * @see ClientHtml::makeLoad
+ * @return string|null
+ */
+ public function getVersion(): ?string {
+ return $this->version;
+ }
+
+ public function getRaw(): bool {
+ return $this->raw;
+ }
+
+ /**
+ * @return string|null
+ */
+ public function getImage(): ?string {
+ return $this->image;
+ }
+
+ /**
+ * @return string|null
+ */
+ public function getVariant(): ?string {
+ return $this->variant;
+ }
+
+ /**
+ * @return string|null
+ */
+ public function getFormat(): ?string {
+ return $this->format;
+ }
+
+ /**
+ * If this is a request for an image, get the Image object.
+ *
+ * @since 1.25
+ * @return Image|bool false if a valid object cannot be created
+ */
+ public function getImageObj() {
+ if ( $this->imageObj === null ) {
+ $this->imageObj = false;
+
+ if ( !$this->image ) {
+ return $this->imageObj;
+ }
+
+ $modules = $this->getModules();
+ if ( count( $modules ) !== 1 ) {
+ return $this->imageObj;
+ }
+
+ $module = $this->getResourceLoader()->getModule( $modules[0] );
+ if ( !$module || !$module instanceof ImageModule ) {
+ return $this->imageObj;
+ }
+
+ $image = $module->getImage( $this->image, $this );
+ if ( !$image ) {
+ return $this->imageObj;
+ }
+
+ $this->imageObj = $image;
+ }
+
+ return $this->imageObj;
+ }
+
+ /**
+ * Return the replaced-content mapping callback
+ *
+ * When editing a page that's used to generate the scripts or styles of a
+ * WikiModule, a preview should use the to-be-saved version of
+ * the page rather than the current version in the database. A context
+ * supporting such previews should return a callback to return these
+ * mappings here.
+ *
+ * @since 1.32
+ * @return callable|null Signature is `Content|null func( Title $t )`
+ */
+ public function getContentOverrideCallback() {
+ return null;
+ }
+
+ public function shouldIncludeScripts(): bool {
+ return $this->getOnly() === null || $this->getOnly() === 'scripts';
+ }
+
+ public function shouldIncludeStyles(): bool {
+ return $this->getOnly() === null || $this->getOnly() === 'styles';
+ }
+
+ public function shouldIncludeMessages(): bool {
+ return $this->getOnly() === null;
+ }
+
+ /**
+ * All factors that uniquely identify this request, except 'modules'.
+ *
+ * The list of modules is excluded here for legacy reasons as most callers already
+ * split up handling of individual modules. Including it here would massively fragment
+ * the cache and decrease its usefulness.
+ *
+ * E.g. Used by RequestFileCache to form a cache key for storing the response output.
+ *
+ * @return string
+ */
+ public function getHash(): string {
+ if ( $this->hash === null ) {
+ $this->hash = implode( '|', [
+ // Module content vary
+ $this->getLanguage(),
+ $this->getSkin(),
+ (string)$this->getDebug(),
+ $this->getUser() ?? '',
+ // Request vary
+ $this->getOnly() ?? '',
+ $this->getVersion() ?? '',
+ (string)$this->getRaw(),
+ $this->getImage() ?? '',
+ $this->getVariant() ?? '',
+ $this->getFormat() ?? '',
+ ] );
+ }
+ return $this->hash;
+ }
+
+ /**
+ * Get the request base parameters, omitting any defaults.
+ *
+ * @internal For use by StartUpModule only
+ * @return string[]
+ */
+ public function getReqBase(): array {
+ $reqBase = [];
+ $lang = $this->getLanguage();
+ if ( $lang !== self::DEFAULT_LANG ) {
+ $reqBase['lang'] = $lang;
+ }
+ $skin = $this->getSkin();
+ if ( $skin !== self::DEFAULT_SKIN ) {
+ $reqBase['skin'] = $skin;
+ }
+ $debug = $this->getDebug();
+ if ( $debug !== self::DEBUG_OFF ) {
+ $reqBase['debug'] = strval( $debug );
+ }
+ return $reqBase;
+ }
+
+ /**
+ * Wrapper around json_encode that avoids needless escapes,
+ * and pretty-prints in debug mode.
+ *
+ * @since 1.34
+ * @param mixed $data
+ * @return string|false JSON string, false on error
+ */
+ public function encodeJson( $data ) {
+ // Keep output as small as possible by disabling needless escape modes
+ // that PHP uses by default.
+ // However, while most module scripts are only served on HTTP responses
+ // for JavaScript, some modules can also be embedded in the HTML as inline
+ // scripts. This, and the fact that we sometimes need to export strings
+ // containing user-generated content and labels that may genuinely contain
+ // a sequences like "</script>", we need to encode either '/' or '<'.
+ // By default PHP escapes '/'. Let's escape '<' instead which is less common
+ // and allows URLs to mostly remain readable.
+ $jsonFlags = JSON_UNESCAPED_SLASHES |
+ JSON_UNESCAPED_UNICODE |
+ JSON_HEX_TAG |
+ JSON_HEX_AMP;
+ if ( $this->getDebug() ) {
+ $jsonFlags |= JSON_PRETTY_PRINT;
+ }
+ return json_encode( $data, $jsonFlags );
+ }
+}
+
+/** @deprecated since 1.39 */
+class_alias( Context::class, 'ResourceLoaderContext' );
diff --git a/includes/ResourceLoader/DerivativeContext.php b/includes/ResourceLoader/DerivativeContext.php
new file mode 100644
index 000000000000..0fa0f8515ecc
--- /dev/null
+++ b/includes/ResourceLoader/DerivativeContext.php
@@ -0,0 +1,266 @@
+<?php
+/**
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation; either version 2 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License along
+ * with this program; if not, write to the Free Software Foundation, Inc.,
+ * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @author Kunal Mehta
+ */
+
+namespace MediaWiki\ResourceLoader;
+
+use MediaWiki\MediaWikiServices;
+use MediaWiki\User\UserIdentity;
+use MediaWiki\User\UserRigorOptions;
+use User;
+use WebRequest;
+
+/**
+ * A mutable version of Context.
+ *
+ * Allows changing specific properties of a context object,
+ * without changing the main one. Inspired by MediaWiki's DerivativeContext.
+ *
+ * @ingroup ResourceLoader
+ * @since 1.24
+ */
+class DerivativeContext extends Context {
+ private const INHERIT_VALUE = -1;
+
+ /**
+ * @var Context
+ */
+ private $context;
+
+ /** @var int|string[] */
+ protected $modules = self::INHERIT_VALUE;
+ /** @var int|string */
+ protected $language = self::INHERIT_VALUE;
+ /** @var int|string|null */
+ protected $direction = self::INHERIT_VALUE;
+ /** @var int|string */
+ protected $skin = self::INHERIT_VALUE;
+ /** @var int|string|null */
+ protected $user = self::INHERIT_VALUE;
+ /** @var int|UserIdentity|null|false */
+ protected $userIdentity = self::INHERIT_VALUE;
+ /** @var int|User|null */
+ protected $userObj = self::INHERIT_VALUE;
+ /** @var int */
+ protected $debug = self::INHERIT_VALUE;
+ /** @var int|string|null */
+ protected $only = self::INHERIT_VALUE;
+ /** @var int|string|null */
+ protected $version = self::INHERIT_VALUE;
+ /** @var int|bool */
+ protected $raw = self::INHERIT_VALUE;
+ /** @var int|callable|null */
+ protected $contentOverrideCallback = self::INHERIT_VALUE;
+
+ public function __construct( Context $context ) {
+ $this->context = $context;
+ }
+
+ public function getModules(): array {
+ if ( $this->modules === self::INHERIT_VALUE ) {
+ return $this->context->getModules();
+ }
+
+ return $this->modules;
+ }
+
+ /**
+ * @param string[] $modules
+ */
+ public function setModules( array $modules ) {
+ $this->modules = $modules;
+ }
+
+ public function getLanguage(): string {
+ if ( $this->language === self::INHERIT_VALUE ) {
+ return $this->context->getLanguage();
+ }
+ return $this->language;
+ }
+
+ public function setLanguage( string $language ) {
+ $this->language = $language;
+ // Invalidate direction since it is based on language
+ $this->direction = null;
+ $this->hash = null;
+ }
+
+ public function getDirection(): string {
+ if ( $this->direction === self::INHERIT_VALUE ) {
+ return $this->context->getDirection();
+ }
+ if ( $this->direction === null ) {
+ $this->direction = MediaWikiServices::getInstance()->getLanguageFactory()
+ ->getLanguage( $this->getLanguage() )->getDir();
+ }
+ return $this->direction;
+ }
+
+ public function setDirection( string $direction ) {
+ $this->direction = $direction;
+ $this->hash = null;
+ }
+
+ public function getSkin(): string {
+ if ( $this->skin === self::INHERIT_VALUE ) {
+ return $this->context->getSkin();
+ }
+ return $this->skin;
+ }
+
+ public function setSkin( string $skin ) {
+ $this->skin = $skin;
+ $this->hash = null;
+ }
+
+ public function getUser(): ?string {
+ if ( $this->user === self::INHERIT_VALUE ) {
+ return $this->context->getUser();
+ }
+ return $this->user;
+ }
+
+ public function getUserIdentity(): ?UserIdentity {
+ if ( $this->userIdentity === self::INHERIT_VALUE ) {
+ return $this->context->getUserIdentity();
+ }
+ if ( $this->userIdentity === false ) {
+ $username = $this->getUser();
+ if ( $username === null ) {
+ // Anonymous user
+ $this->userIdentity = null;
+ } else {
+ // Use provided username if valid
+ $this->userIdentity = MediaWikiServices::getInstance()
+ ->getUserFactory()
+ ->newFromName( $username, UserRigorOptions::RIGOR_VALID );
+ }
+ }
+ return $this->userIdentity;
+ }
+
+ public function getUserObj(): User {
+ if ( $this->userObj === self::INHERIT_VALUE ) {
+ return $this->context->getUserObj();
+ }
+ if ( $this->userObj === null ) {
+ $username = $this->getUser();
+ if ( $username ) {
+ $this->userObj = User::newFromName( $username ) ?: new User;
+ } else {
+ $this->userObj = new User;
+ }
+ }
+ return $this->userObj;
+ }
+
+ /**
+ * @param string|null $user
+ */
+ public function setUser( ?string $user ) {
+ $this->user = $user;
+ $this->hash = null;
+ // Clear getUserObj cache
+ $this->userObj = null;
+ $this->userIdentity = false;
+ }
+
+ public function getDebug(): int {
+ if ( $this->debug === self::INHERIT_VALUE ) {
+ return $this->context->getDebug();
+ }
+ return $this->debug;
+ }
+
+ public function setDebug( int $debug ) {
+ $this->debug = $debug;
+ $this->hash = null;
+ }
+
+ public function getOnly(): ?string {
+ if ( $this->only === self::INHERIT_VALUE ) {
+ return $this->context->getOnly();
+ }
+ return $this->only;
+ }
+
+ /**
+ * @param string|null $only
+ */
+ public function setOnly( ?string $only ) {
+ $this->only = $only;
+ $this->hash = null;
+ }
+
+ public function getVersion(): ?string {
+ if ( $this->version === self::INHERIT_VALUE ) {
+ return $this->context->getVersion();
+ }
+ return $this->version;
+ }
+
+ /**
+ * @param string|null $version
+ */
+ public function setVersion( ?string $version ) {
+ $this->version = $version;
+ $this->hash = null;
+ }
+
+ public function getRaw(): bool {
+ if ( $this->raw === self::INHERIT_VALUE ) {
+ return $this->context->getRaw();
+ }
+ return $this->raw;
+ }
+
+ public function setRaw( bool $raw ) {
+ $this->raw = $raw;
+ }
+
+ public function getRequest(): WebRequest {
+ return $this->context->getRequest();
+ }
+
+ public function getResourceLoader(): ResourceLoader {
+ return $this->context->getResourceLoader();
+ }
+
+ public function getContentOverrideCallback() {
+ if ( $this->contentOverrideCallback === self::INHERIT_VALUE ) {
+ return $this->context->getContentOverrideCallback();
+ }
+ return $this->contentOverrideCallback;
+ }
+
+ /**
+ * @see self::getContentOverrideCallback
+ * @since 1.32
+ * @param callable|null|int $callback As per self::getContentOverrideCallback,
+ * or self::INHERIT_VALUE
+ */
+ public function setContentOverrideCallback( $callback ) {
+ $this->contentOverrideCallback = $callback;
+ }
+
+}
+
+/** @deprecated since 1.39 */
+class_alias( DerivativeContext::class, 'DerivativeResourceLoaderContext' );
diff --git a/includes/ResourceLoader/FileModule.php b/includes/ResourceLoader/FileModule.php
new file mode 100644
index 000000000000..63fa2d501603
--- /dev/null
+++ b/includes/ResourceLoader/FileModule.php
@@ -0,0 +1,1489 @@
+<?php
+/**
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation; either version 2 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License along
+ * with this program; if not, write to the Free Software Foundation, Inc.,
+ * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @author Trevor Parscal
+ * @author Roan Kattouw
+ */
+
+namespace MediaWiki\ResourceLoader;
+
+use CSSJanus;
+use Exception;
+use ExtensionRegistry;
+use FileContentsHasher;
+use InvalidArgumentException;
+use LogicException;
+use MediaWiki\Languages\LanguageFallback;
+use MediaWiki\MainConfigNames;
+use MediaWiki\MediaWikiServices;
+use ObjectCache;
+use OutputPage;
+use RuntimeException;
+use Wikimedia\Minify\CSSMin;
+use Wikimedia\RequestTimeout\TimeoutException;
+
+/**
+ * Module based on local JavaScript/CSS files.
+ *
+ * The following public methods can query the database:
+ *
+ * - getDefinitionSummary / … / Module::getFileDependencies.
+ * - getVersionHash / getDefinitionSummary / … / Module::getFileDependencies.
+ * - getStyles / Module::saveFileDependencies.
+ *
+ * @ingroup ResourceLoader
+ * @see $wgResourceModules
+ * @since 1.17
+ */
+class FileModule extends Module {
+ /** @var string Local base path, see __construct() */
+ protected $localBasePath = '';
+
+ /** @var string Remote base path, see __construct() */
+ protected $remoteBasePath = '';
+
+ /** @var array Saves a list of the templates named by the modules. */
+ protected $templates = [];
+
+ /**
+ * @var array List of paths to JavaScript files to always include
+ * @par Usage:
+ * @code
+ * [ [file-path], [file-path], ... ]
+ * @endcode
+ */
+ protected $scripts = [];
+
+ /**
+ * @var array List of JavaScript files to include when using a specific language
+ * @par Usage:
+ * @code
+ * [ [language-code] => [ [file-path], [file-path], ... ], ... ]
+ * @endcode
+ */
+ protected $languageScripts = [];
+
+ /**
+ * @var array List of JavaScript files to include when using a specific skin
+ * @par Usage:
+ * @code
+ * [ [skin-name] => [ [file-path], [file-path], ... ], ... ]
+ * @endcode
+ */
+ protected $skinScripts = [];
+
+ /**
+ * @var array List of paths to JavaScript files to include in debug mode
+ * @par Usage:
+ * @code
+ * [ [skin-name] => [ [file-path], [file-path], ... ], ... ]
+ * @endcode
+ */
+ protected $debugScripts = [];
+
+ /**
+ * @var array List of paths to CSS files to always include
+ * @par Usage:
+ * @code
+ * [ [file-path], [file-path], ... ]
+ * @endcode
+ */
+ protected $styles = [];
+
+ /**
+ * @var array List of paths to CSS files to include when using specific skins
+ * @par Usage:
+ * @code
+ * [ [file-path], [file-path], ... ]
+ * @endcode
+ */
+ protected $skinStyles = [];
+
+ /**
+ * @var array List of packaged files to make available through require()
+ * @par Usage:
+ * @code
+ * [ [file-path-or-object], [file-path-or-object], ... ]
+ * @endcode
+ */
+ protected $packageFiles = null;
+
+ /**
+ * @var array Expanded versions of $packageFiles, lazy-computed by expandPackageFiles();
+ * keyed by context hash
+ */
+ private $expandedPackageFiles = [];
+
+ /**
+ * @var array Further expanded versions of $expandedPackageFiles, lazy-computed by
+ * getPackageFiles(); keyed by context hash
+ */
+ private $fullyExpandedPackageFiles = [];
+
+ /**
+ * @var array List of modules this module depends on
+ * @par Usage:
+ * @code
+ * [ [file-path], [file-path], ... ]
+ * @endcode
+ */
+ protected $dependencies = [];
+
+ /**
+ * @var string File name containing the body of the skip function
+ */
+ protected $skipFunction = null;
+
+ /**
+ * @var array List of message keys used by this module
+ * @par Usage:
+ * @code
+ * [ [message-key], [message-key], ... ]
+ * @endcode
+ */
+ protected $messages = [];
+
+ /** @var string Name of group to load this module in */
+ protected $group;
+
+ /** @var bool Link to raw files in debug mode */
+ protected $debugRaw = true;
+
+ /** @var string[] */
+ protected $targets = [ 'desktop' ];
+
+ /** @var bool Whether CSSJanus flipping should be skipped for this module */
+ protected $noflip = false;
+
+ /** @var bool Whether this module requires the client to support ES6 */
+ protected $es6 = false;
+
+ /**
+ * @var bool Whether getStyleURLsForDebug should return raw file paths,
+ * or return load.php urls
+ */
+ protected $hasGeneratedStyles = false;
+
+ /**
+ * @var array Place where readStyleFile() tracks file dependencies
+ * @par Usage:
+ * @code
+ * [ [file-path], [file-path], ... ]
+ * @endcode
+ */
+ protected $localFileRefs = [];
+
+ /**
+ * @var array Place where readStyleFile() tracks file dependencies for non-existent files.
+ * Used in tests to detect missing dependencies.
+ */
+ protected $missingLocalFileRefs = [];
+
+ /**
+ * @var VueComponentParser|null Lazy-created by getVueComponentParser()
+ */
+ protected $vueComponentParser = null;
+
+ /**
+ * Constructs a new module from an options array.
+ *
+ * @param array $options See $wgResourceModules for the available options.
+ * @param string|null $localBasePath Base path to prepend to all local paths in $options.
+ * Defaults to $IP
+ * @param string|null $remoteBasePath Base path to prepend to all remote paths in $options.
+ * Defaults to $wgResourceBasePath
+ * @throws InvalidArgumentException
+ * @see $wgResourceModules
+ */
+ public function __construct(
+ array $options = [],
+ $localBasePath = null,
+ $remoteBasePath = null
+ ) {
+ // Flag to decide whether to automagically add the mediawiki.template module
+ $hasTemplates = false;
+ // localBasePath and remoteBasePath both have unbelievably long fallback chains
+ // and need to be handled separately.
+ list( $this->localBasePath, $this->remoteBasePath ) =
+ self::extractBasePaths( $options, $localBasePath, $remoteBasePath );
+
+ // Extract, validate and normalise remaining options
+ foreach ( $options as $member => $option ) {
+ switch ( $member ) {
+ // Lists of file paths
+ case 'scripts':
+ case 'debugScripts':
+ case 'styles':
+ case 'packageFiles':
+ $this->{$member} = is_array( $option ) ? $option : [ $option ];
+ break;
+ case 'templates':
+ $hasTemplates = true;
+ $this->{$member} = is_array( $option ) ? $option : [ $option ];
+ break;
+ // Collated lists of file paths
+ case 'languageScripts':
+ case 'skinScripts':
+ case 'skinStyles':
+ if ( !is_array( $option ) ) {
+ throw new InvalidArgumentException(
+ "Invalid collated file path list error. " .
+ "'$option' given, array expected."
+ );
+ }
+ foreach ( $option as $key => $value ) {
+ if ( !is_string( $key ) ) {
+ throw new InvalidArgumentException(
+ "Invalid collated file path list key error. " .
+ "'$key' given, string expected."
+ );
+ }
+ $this->{$member}[$key] = is_array( $value ) ? $value : [ $value ];
+ }
+ break;
+ case 'deprecated':
+ $this->deprecated = $option;
+ break;
+ // Lists of strings
+ case 'dependencies':
+ case 'messages':
+ case 'targets':
+ // Normalise
+ $option = array_values( array_unique( (array)$option ) );
+ sort( $option );
+
+ $this->{$member} = $option;
+ break;
+ // Single strings
+ case 'group':
+ case 'skipFunction':
+ $this->{$member} = (string)$option;
+ break;
+ // Single booleans
+ case 'debugRaw':
+ case 'noflip':
+ case 'es6':
+ $this->{$member} = (bool)$option;
+ break;
+ }
+ }
+ if ( isset( $options['scripts'] ) && isset( $options['packageFiles'] ) ) {
+ throw new InvalidArgumentException( "A module may not set both 'scripts' and 'packageFiles'" );
+ }
+ if ( isset( $options['packageFiles'] ) && isset( $options['skinScripts'] ) ) {
+ throw new InvalidArgumentException( "Options 'skinScripts' and 'packageFiles' cannot be used together." );
+ }
+ if ( $hasTemplates ) {
+ $this->dependencies[] = 'mediawiki.template';
+ // Ensure relevant template compiler module gets loaded
+ foreach ( $this->templates as $alias => $templatePath ) {
+ if ( is_int( $alias ) ) {
+ $alias = $this->getPath( $templatePath );
+ }
+ $suffix = explode( '.', $alias );
+ $suffix = end( $suffix );
+ $compilerModule = 'mediawiki.template.' . $suffix;
+ if ( $suffix !== 'html' && !in_array( $compilerModule, $this->dependencies ) ) {
+ $this->dependencies[] = $compilerModule;
+ }
+ }
+ }
+ }
+
+ /**
+ * Extract a pair of local and remote base paths from module definition information.
+ * Implementation note: the amount of global state used in this function is staggering.
+ *
+ * @param array $options Module definition
+ * @param string|null $localBasePath Path to use if not provided in module definition. Defaults
+ * to $IP
+ * @param string|null $remoteBasePath Path to use if not provided in module definition. Defaults
+ * to $wgResourceBasePath
+ * @return string[] [ localBasePath, remoteBasePath ]
+ */
+ public static function extractBasePaths(
+ array $options = [],
+ $localBasePath = null,
+ $remoteBasePath = null
+ ) {
+ global $IP;
+ // The different ways these checks are done, and their ordering, look very silly,
+ // but were preserved for backwards-compatibility just in case. Tread lightly.
+
+ if ( $localBasePath === null ) {
+ $localBasePath = $IP;
+ }
+ if ( $remoteBasePath === null ) {
+ $remoteBasePath = MediaWikiServices::getInstance()->getMainConfig()
+ ->get( MainConfigNames::ResourceBasePath );
+ }
+
+ if ( isset( $options['remoteExtPath'] ) ) {
+ $extensionAssetsPath = MediaWikiServices::getInstance()->getMainConfig()
+ ->get( MainConfigNames::ExtensionAssetsPath );
+ $remoteBasePath = $extensionAssetsPath . '/' . $options['remoteExtPath'];
+ }
+
+ if ( isset( $options['remoteSkinPath'] ) ) {
+ $stylePath = MediaWikiServices::getInstance()->getMainConfig()
+ ->get( MainConfigNames::StylePath );
+ $remoteBasePath = $stylePath . '/' . $options['remoteSkinPath'];
+ }
+
+ if ( array_key_exists( 'localBasePath', $options ) ) {
+ $localBasePath = (string)$options['localBasePath'];
+ }
+
+ if ( array_key_exists( 'remoteBasePath', $options ) ) {
+ $remoteBasePath = (string)$options['remoteBasePath'];
+ }
+
+ if ( $remoteBasePath === '' ) {
+ // If MediaWiki is installed at the document root (not recommended),
+ // then wgScriptPath is set to the empty string by the installer to
+ // ensure safe concatenating of file paths (avoid "/" + "/foo" being "//foo").
+ // However, this also means the path itself can be an invalid URI path,
+ // as those must start with a slash. Within ResourceLoader, we will not
+ // do such primitive/unsafe slash concatenation and use URI resolution
+ // instead, so beyond this point, to avoid fatal errors in CSSMin::resolveUrl(),
+ // do a best-effort support for docroot installs by casting this to a slash.
+ $remoteBasePath = '/';
+ }
+
+ return [ $localBasePath, $remoteBasePath ];
+ }
+
+ /**
+ * Gets all scripts for a given context concatenated together.
+ *
+ * @param Context $context Context in which to generate script
+ * @return string|array JavaScript code for $context, or package files data structure
+ */
+ public function getScript( Context $context ) {
+ $deprecationScript = $this->getDeprecationInformation( $context );
+ $packageFiles = $this->getPackageFiles( $context );
+ if ( $packageFiles !== null ) {
+ foreach ( $packageFiles['files'] as &$file ) {
+ if ( $file['type'] === 'script+style' ) {
+ $file['content'] = $file['content']['script'];
+ $file['type'] = 'script';
+ }
+ }
+ if ( $deprecationScript ) {
+ $mainFile =& $packageFiles['files'][$packageFiles['main']];
+ $mainFile['content'] = $deprecationScript . $mainFile['content'];
+ }
+ return $packageFiles;
+ }
+
+ $files = $this->getScriptFiles( $context );
+ return $deprecationScript . $this->readScriptFiles( $files );
+ }
+
+ /**
+ * @param Context $context
+ * @return string[]
+ */
+ public function getScriptURLsForDebug( Context $context ) {
+ $rl = $context->getResourceLoader();
+ $config = $this->getConfig();
+ $server = $config->get( MainConfigNames::Server );
+
+ $urls = [];
+ foreach ( $this->getScriptFiles( $context ) as $file ) {
+ $url = OutputPage::transformResourcePath( $config, $this->getRemotePath( $file ) );
+ // Expand debug URL in case we are another wiki's module source (T255367)
+ $url = $rl->expandUrl( $server, $url );
+ $urls[] = $url;
+ }
+ return $urls;
+ }
+
+ /**
+ * @return bool
+ */
+ public function supportsURLLoading() {
+ // If package files are involved, don't support URL loading, because that breaks
+ // scoped require() functions
+ return $this->debugRaw && !$this->packageFiles;
+ }
+
+ /**
+ * Get all styles for a given context.
+ *
+ * @param Context $context
+ * @return string[] CSS code for $context as an associative array mapping media type to CSS text.
+ */
+ public function getStyles( Context $context ) {
+ $styles = $this->readStyleFiles(
+ $this->getStyleFiles( $context ),
+ $context
+ );
+
+ $packageFiles = $this->getPackageFiles( $context );
+ if ( $packageFiles !== null ) {
+ foreach ( $packageFiles['files'] as $fileName => $file ) {
+ if ( $file['type'] === 'script+style' ) {
+ $style = $this->processStyle(
+ $file['content']['style'],
+ $file['content']['styleLang'],
+ $fileName,
+ $context
+ );
+ $styles['all'] = ( $styles['all'] ?? '' ) . "\n" . $style;
+ }
+ }
+ }
+
+ // Track indirect file dependencies so that StartUpModule can check for
+ // on-disk file changes to any of this files without having to recompute the file list
+ $this->saveFileDependencies( $context, $this->localFileRefs );
+
+ return $styles;
+ }
+
+ /**
+ * @param Context $context
+ * @return string[][]
+ */
+ public function getStyleURLsForDebug( Context $context ) {
+ if ( $this->hasGeneratedStyles ) {
+ // Do the default behaviour of returning a url back to load.php
+ // but with only=styles.
+ return parent::getStyleURLsForDebug( $context );
+ }
+ // Our module consists entirely of real css files,
+ // in debug mode we can load those directly.
+ $urls = [];
+ foreach ( $this->getStyleFiles( $context ) as $mediaType => $list ) {
+ $urls[$mediaType] = [];
+ foreach ( $list as $file ) {
+ $urls[$mediaType][] = OutputPage::transformResourcePath(
+ $this->getConfig(),
+ $this->getRemotePath( $file )
+ );
+ }
+ }
+ return $urls;
+ }
+
+ /**
+ * Gets list of message keys used by this module.
+ *
+ * @return string[] List of message keys
+ */
+ public function getMessages() {
+ return $this->messages;
+ }
+
+ /**
+ * Gets the name of the group this module should be loaded in.
+ *
+ * @return string Group name
+ */
+ public function getGroup() {
+ return $this->group;
+ }
+
+ /**
+ * Gets list of names of modules this module depends on.
+ * @param Context|null $context
+ * @return string[] List of module names
+ */
+ public function getDependencies( Context $context = null ) {
+ return $this->dependencies;
+ }
+
+ /**
+ * Helper method for getting a file.
+ *
+ * @param string $localPath The path to the resource to load
+ * @param string $type The type of resource being loaded (for error reporting only)
+ * @throws RuntimeException If the supplied path is not found, or not a path
+ * @return string
+ */
+ private function getFileContents( $localPath, $type ) {
+ if ( !is_file( $localPath ) ) {
+ throw new RuntimeException(
+ __METHOD__ . ": $type file not found, or is not a file: \"$localPath\""
+ );
+ }
+ return $this->stripBom( file_get_contents( $localPath ) );
+ }
+
+ /**
+ * @return null|string
+ * @throws RuntimeException If the file doesn't exist
+ */
+ public function getSkipFunction() {
+ if ( !$this->skipFunction ) {
+ return null;
+ }
+ $localPath = $this->getLocalPath( $this->skipFunction );
+ return $this->getFileContents( $localPath, 'skip function' );
+ }
+
+ public function requiresES6() {
+ return $this->es6;
+ }
+
+ /**
+ * Disable module content versioning.
+ *
+ * This class uses getDefinitionSummary() instead, to avoid filesystem overhead
+ * involved with building the full module content inside a startup request.
+ *
+ * @return bool
+ */
+ public function enableModuleContentVersion() {
+ return false;
+ }
+
+ /**
+ * Helper method for getDefinitionSummary.
+ *
+ * @param Context $context
+ * @return string
+ */
+ private function getFileHashes( Context $context ) {
+ $files = [];
+
+ $styleFiles = $this->getStyleFiles( $context );
+ foreach ( $styleFiles as $paths ) {
+ $files = array_merge( $files, $paths );
+ }
+
+ // Extract file paths for package files
+ // Optimisation: Use foreach() and isset() instead of array_map/array_filter.
+ // This is a hot code path, called by StartupModule for thousands of modules.
+ $expandedPackageFiles = $this->expandPackageFiles( $context );
+ $packageFiles = [];
+ if ( $expandedPackageFiles ) {
+ foreach ( $expandedPackageFiles['files'] as $fileInfo ) {
+ if ( isset( $fileInfo['filePath'] ) ) {
+ $packageFiles[] = $fileInfo['filePath'];
+ }
+ }
+ }
+
+ // Merge all the file paths we were able discover directly from the module definition.
+ // This is the master list of direct-dependent files for this module.
+ $files = array_merge(
+ $files,
+ $packageFiles,
+ $this->scripts,
+ $this->templates,
+ $context->getDebug() ? $this->debugScripts : [],
+ $this->getLanguageScripts( $context->getLanguage() ),
+ self::tryForKey( $this->skinScripts, $context->getSkin(), 'default' )
+ );
+ if ( $this->skipFunction ) {
+ $files[] = $this->skipFunction;
+ }
+
+ // Expand these local paths into absolute file paths
+ $files = array_map( [ $this, 'getLocalPath' ], $files );
+
+ // Add any lazily discovered file dependencies from previous module builds.
+ // These are added last because they are already absolute file paths.
+ $files = array_merge( $files, $this->getFileDependencies( $context ) );
+
+ // Filter out any duplicates. Typically introduced by getFileDependencies() which
+ // may lazily re-discover a master file.
+ $files = array_unique( $files );
+
+ // Don't return array keys or any other form of file path here, only the hashes.
+ // Including file paths would needlessly cause global cache invalidation when files
+ // move on disk or if e.g. the MediaWiki directory name changes.
+ // Anything where order is significant is already detected by the definition summary.
+ return FileContentsHasher::getFileContentsHash( $files );
+ }
+
+ /**
+ * Get the definition summary for this module.
+ *
+ * @param Context $context
+ * @return array
+ */
+ public function getDefinitionSummary( Context $context ) {
+ $summary = parent::getDefinitionSummary( $context );
+
+ $options = [];
+ foreach ( [
+ // The following properties are omitted because they don't affect the module response:
+ // - localBasePath (Per T104950; Changes when absolute directory name changes. If
+ // this affects 'scripts' and other file paths, getFileHashes accounts for that.)
+ // - remoteBasePath (Per T104950)
+ // - dependencies (provided via startup module)
+ // - targets
+ // - group (provided via startup module)
+ 'scripts',
+ 'debugScripts',
+ 'styles',
+ 'languageScripts',
+ 'skinScripts',
+ 'skinStyles',
+ 'messages',
+ 'templates',
+ 'skipFunction',
+ 'debugRaw',
+ ] as $member ) {
+ $options[$member] = $this->{$member};
+ }
+
+ $packageFiles = $this->expandPackageFiles( $context );
+ if ( $packageFiles ) {
+ // Extract the minimum needed:
+ // - The 'main' pointer (included as-is).
+ // - The 'files' array, simplified to only which files exist (the keys of
+ // this array), and something that represents their non-file content.
+ // For packaged files that reflect files directly from disk, the
+ // 'getFileHashes' method tracks their content already.
+ // It is important that the keys of the $packageFiles['files'] array
+ // are preserved, as they do affect the module output.
+ $packageFiles['files'] = array_map( static function ( $fileInfo ) {
+ return $fileInfo['definitionSummary'] ?? ( $fileInfo['content'] ?? null );
+ }, $packageFiles['files'] );
+ }
+
+ $summary[] = [
+ 'options' => $options,
+ 'packageFiles' => $packageFiles,
+ 'fileHashes' => $this->getFileHashes( $context ),
+ 'messageBlob' => $this->getMessageBlob( $context ),
+ ];
+
+ $lessVars = $this->getLessVars( $context );
+ if ( $lessVars ) {
+ $summary[] = [ 'lessVars' => $lessVars ];
+ }
+
+ return $summary;
+ }
+
+ /**
+ * @return VueComponentParser
+ */
+ protected function getVueComponentParser() {
+ if ( $this->vueComponentParser === null ) {
+ $this->vueComponentParser = new VueComponentParser;
+ }
+ return $this->vueComponentParser;
+ }
+
+ /**
+ * @param string|FilePath $path
+ * @return string
+ */
+ protected function getPath( $path ) {
+ if ( $path instanceof FilePath ) {
+ return $path->getPath();
+ }
+
+ return $path;
+ }
+
+ /**
+ * @param string|FilePath $path
+ * @return string
+ */
+ protected function getLocalPath( $path ) {
+ if ( $path instanceof FilePath ) {
+ return $path->getLocalPath();
+ }
+
+ return "{$this->localBasePath}/$path";
+ }
+
+ /**
+ * @param string|FilePath $path
+ * @return string
+ */
+ protected function getRemotePath( $path ) {
+ if ( $path instanceof FilePath ) {
+ return $path->getRemotePath();
+ }
+
+ if ( $this->remoteBasePath === '/' ) {
+ return "/$path";
+ } else {
+ return "{$this->remoteBasePath}/$path";
+ }
+ }
+
+ /**
+ * Infer the stylesheet language from a stylesheet file path.
+ *
+ * @since 1.22
+ * @param string $path
+ * @return string The stylesheet language name
+ */
+ public function getStyleSheetLang( $path ) {
+ return preg_match( '/\.less$/i', $path ) ? 'less' : 'css';
+ }
+
+ /**
+ * Infer the file type from a package file path.
+ * @param string $path
+ * @return string 'script', 'script-vue', or 'data'
+ */
+ public static function getPackageFileType( $path ) {
+ if ( preg_match( '/\.json$/i', $path ) ) {
+ return 'data';
+ }
+ if ( preg_match( '/\.vue$/i', $path ) ) {
+ return 'script-vue';
+ }
+ return 'script';
+ }
+
+ /**
+ * Collates file paths by option (where provided).
+ *
+ * @param array $list List of file paths in any combination of index/path
+ * or path/options pairs
+ * @param string $option Option name
+ * @param mixed $default Default value if the option isn't set
+ * @return string[][] List of file paths, collated by $option
+ */
+ protected static function collateFilePathListByOption( array $list, $option, $default ) {
+ $collatedFiles = [];
+ foreach ( $list as $key => $value ) {
+ if ( is_int( $key ) ) {
+ // File name as the value
+ if ( !isset( $collatedFiles[$default] ) ) {
+ $collatedFiles[$default] = [];
+ }
+ $collatedFiles[$default][] = $value;
+ } elseif ( is_array( $value ) ) {
+ // File name as the key, options array as the value
+ $optionValue = $value[$option] ?? $default;
+ if ( !isset( $collatedFiles[$optionValue] ) ) {
+ $collatedFiles[$optionValue] = [];
+ }
+ $collatedFiles[$optionValue][] = $key;
+ }
+ }
+ return $collatedFiles;
+ }
+
+ /**
+ * Get a list of element that match a key, optionally using a fallback key.
+ *
+ * @param array[] $list List of lists to select from
+ * @param string $key Key to look for in $list
+ * @param string|null $fallback Key to look for in $list if $key doesn't exist
+ * @return array List of elements from $list which matched $key or $fallback,
+ * or an empty list in case of no match
+ */
+ protected static function tryForKey( array $list, $key, $fallback = null ) {
+ if ( isset( $list[$key] ) && is_array( $list[$key] ) ) {
+ return $list[$key];
+ } elseif ( is_string( $fallback )
+ && isset( $list[$fallback] )
+ && is_array( $list[$fallback] )
+ ) {
+ return $list[$fallback];
+ }
+ return [];
+ }
+
+ /**
+ * Get a list of script file paths for this module, in order of proper execution.
+ *
+ * @param Context $context
+ * @return string[] List of file paths
+ */
+ private function getScriptFiles( Context $context ) {
+ // Execution order, as documented at $wgResourceModules:
+ // scripts, languageScripts, skinScripts, debugScripts.
+ $files = array_merge(
+ $this->scripts,
+ $this->getLanguageScripts( $context->getLanguage() ),
+ self::tryForKey( $this->skinScripts, $context->getSkin(), 'default' )
+ );
+ if ( $context->getDebug() ) {
+ $files = array_merge( $files, $this->debugScripts );
+ }
+
+ return array_unique( $files, SORT_REGULAR );
+ }
+
+ /**
+ * Get the set of language scripts for the given language,
+ * possibly using a fallback language.
+ *
+ * @param string $lang
+ * @return string[]
+ */
+ private function getLanguageScripts( string $lang ): array {
+ $scripts = self::tryForKey( $this->languageScripts, $lang );
+ if ( $scripts ) {
+ return $scripts;
+ }
+
+ // Optimization: Avoid initialising and calling into language services
+ // for the majority of modules that don't use this option.
+ if ( $this->languageScripts ) {
+ $fallbacks = MediaWikiServices::getInstance()
+ ->getLanguageFallback()
+ ->getAll( $lang, LanguageFallback::MESSAGES );
+ foreach ( $fallbacks as $lang ) {
+ $scripts = self::tryForKey( $this->languageScripts, $lang );
+ if ( $scripts ) {
+ return $scripts;
+ }
+ }
+ }
+
+ return [];
+ }
+
+ public function setSkinStylesOverride( array $moduleSkinStyles ): void {
+ $moduleName = $this->getName();
+ foreach ( $moduleSkinStyles as $skinName => $overrides ) {
+ // If a module provides overrides for a skin, and that skin also provides overrides
+ // for the same module, then the module has precedence.
+ if ( isset( $this->skinStyles[$skinName] ) ) {
+ continue;
+ }
+
+ // If $moduleName in ResourceModuleSkinStyles is preceded with a '+', the defined style
+ // files will be added to 'default' skinStyles, otherwise 'default' will be ignored.
+ if ( isset( $overrides[$moduleName] ) ) {
+ $paths = (array)$overrides[$moduleName];
+ $styleFiles = [];
+ } elseif ( isset( $overrides['+' . $moduleName] ) ) {
+ $paths = (array)$overrides['+' . $moduleName];
+ $styleFiles = isset( $this->skinStyles['default'] ) ?
+ (array)$this->skinStyles['default'] :
+ [];
+ } else {
+ continue;
+ }
+
+ // Add new file paths, remapping them to refer to our directories and not use settings
+ // from the module we're modifying, which come from the base definition.
+ list( $localBasePath, $remoteBasePath ) = self::extractBasePaths( $overrides );
+
+ foreach ( $paths as $path ) {
+ $styleFiles[] = new FilePath( $path, $localBasePath, $remoteBasePath );
+ }
+
+ $this->skinStyles[$skinName] = $styleFiles;
+ }
+ }
+
+ /**
+ * Get a list of file paths for all styles in this module, in order of proper inclusion.
+ *
+ * @internal Exposed only for use by structure phpunit tests.
+ * @param Context $context
+ * @return string[][] List of file paths
+ */
+ public function getStyleFiles( Context $context ) {
+ return array_merge_recursive(
+ self::collateFilePathListByOption( $this->styles, 'media', 'all' ),
+ self::collateFilePathListByOption(
+ self::tryForKey( $this->skinStyles, $context->getSkin(), 'default' ),
+ 'media',
+ 'all'
+ )
+ );
+ }
+
+ /**
+ * Gets a list of file paths for all skin styles in the module used by
+ * the skin.
+ *
+ * @param string $skinName The name of the skin
+ * @return array A list of file paths collated by media type
+ */
+ protected function getSkinStyleFiles( $skinName ) {
+ return self::collateFilePathListByOption(
+ self::tryForKey( $this->skinStyles, $skinName ),
+ 'media',
+ 'all'
+ );
+ }
+
+ /**
+ * Gets a list of file paths for all skin style files in the module,
+ * for all available skins.
+ *
+ * @return array A list of file paths collated by media type
+ */
+ protected function getAllSkinStyleFiles() {
+ $skinFactory = MediaWikiServices::getInstance()->getSkinFactory();
+ $styleFiles = [];
+
+ $internalSkinNames = array_keys( $skinFactory->getInstalledSkins() );
+ $internalSkinNames[] = 'default';
+
+ foreach ( $internalSkinNames as $internalSkinName ) {
+ $styleFiles = array_merge_recursive(
+ $styleFiles,
+ $this->getSkinStyleFiles( $internalSkinName )
+ );
+ }
+
+ return $styleFiles;
+ }
+
+ /**
+ * Returns all style files and all skin style files used by this module.
+ *
+ * @return array
+ */
+ public function getAllStyleFiles() {
+ $collatedStyleFiles = array_merge_recursive(
+ self::collateFilePathListByOption( $this->styles, 'media', 'all' ),
+ $this->getAllSkinStyleFiles()
+ );
+
+ $result = [];
+
+ foreach ( $collatedStyleFiles as $media => $styleFiles ) {
+ foreach ( $styleFiles as $styleFile ) {
+ $result[] = $this->getLocalPath( $styleFile );
+ }
+ }
+
+ return $result;
+ }
+
+ /**
+ * Get the contents of a list of JavaScript files. Helper for getScript().
+ *
+ * @param string[] $scripts List of file paths to scripts to read, remap and concatenate
+ * @return string Concatenated JavaScript data from $scripts
+ * @throws RuntimeException
+ */
+ private function readScriptFiles( array $scripts ) {
+ if ( empty( $scripts ) ) {
+ return '';
+ }
+ $js = '';
+ foreach ( array_unique( $scripts, SORT_REGULAR ) as $fileName ) {
+ $localPath = $this->getLocalPath( $fileName );
+ $contents = $this->getFileContents( $localPath, 'script' );
+ $js .= ResourceLoader::ensureNewline( $contents );
+ }
+ return $js;
+ }
+
+ /**
+ * Get the contents of a list of CSS files.
+ *
+ * @internal This is considered a private method. Exposed for internal use by WebInstallerOutput.
+ * @param array $styles Map of media type to file paths to read, remap, and concatenate
+ * @param Context $context
+ * @return string[] List of concatenated and remapped CSS data from $styles,
+ * keyed by media type
+ * @throws RuntimeException
+ */
+ public function readStyleFiles( array $styles, Context $context ) {
+ if ( !$styles ) {
+ return [];
+ }
+ foreach ( $styles as $media => $files ) {
+ $uniqueFiles = array_unique( $files, SORT_REGULAR );
+ $styleFiles = [];
+ foreach ( $uniqueFiles as $file ) {
+ $styleFiles[] = $this->readStyleFile( $file, $context );
+ }
+ $styles[$media] = implode( "\n", $styleFiles );
+ }
+ return $styles;
+ }
+
+ /**
+ * Read and process a style file. Reads a file from disk and runs it through processStyle().
+ *
+ * This method can be used as a callback for array_map()
+ *
+ * @internal
+ * @param string $path File path of style file to read
+ * @param Context $context
+ * @return string CSS data in script file
+ * @throws RuntimeException If the file doesn't exist
+ */
+ protected function readStyleFile( $path, Context $context ) {
+ $localPath = $this->getLocalPath( $path );
+ $style = $this->getFileContents( $localPath, 'style' );
+ $styleLang = $this->getStyleSheetLang( $localPath );
+
+ return $this->processStyle( $style, $styleLang, $path, $context );
+ }
+
+ /**
+ * Process a CSS/LESS string.
+ *
+ * This method performs the following processing steps:
+ * - LESS compilation (if $styleLang = 'less')
+ * - RTL flipping with CSSJanus (if getFlip() returns true)
+ * - Registration of references to local files in $localFileRefs and $missingLocalFileRefs
+ * - URL remapping and data URI embedding
+ *
+ * @internal
+ * @param string $style CSS/LESS string
+ * @param string $styleLang Language of $style ('css' or 'less')
+ * @param string $path File path where the CSS/LESS lives, used for resolving relative file paths
+ * @param Context $context
+ * @return string Processed CSS
+ */
+ protected function processStyle( $style, $styleLang, $path, Context $context ) {
+ $localPath = $this->getLocalPath( $path );
+ $remotePath = $this->getRemotePath( $path );
+
+ if ( $styleLang === 'less' ) {
+ $style = $this->compileLessString( $style, $localPath, $context );
+ $this->hasGeneratedStyles = true;
+ }
+
+ if ( $this->getFlip( $context ) ) {
+ $style = CSSJanus::transform(
+ $style,
+ /* $swapLtrRtlInURL = */ true,
+ /* $swapLeftRightInURL = */ false
+ );
+ }
+
+ $localDir = dirname( $localPath );
+ $remoteDir = dirname( $remotePath );
+ // Get and register local file references
+ $localFileRefs = CSSMin::getLocalFileReferences( $style, $localDir );
+ foreach ( $localFileRefs as $file ) {
+ if ( is_file( $file ) ) {
+ $this->localFileRefs[] = $file;
+ } else {
+ $this->missingLocalFileRefs[] = $file;
+ }
+ }
+ // Don't cache this call. remap() ensures data URIs embeds are up to date,
+ // and urls contain correct content hashes in their query string. (T128668)
+ return CSSMin::remap( $style, $localDir, $remoteDir, true );
+ }
+
+ /**
+ * Get whether CSS for this module should be flipped
+ * @param Context $context
+ * @return bool
+ */
+ public function getFlip( Context $context ) {
+ return $context->getDirection() === 'rtl' && !$this->noflip;
+ }
+
+ /**
+ * Get target(s) for the module, eg ['desktop'] or ['desktop', 'mobile']
+ *
+ * @return string[]
+ */
+ public function getTargets() {
+ return $this->targets;
+ }
+
+ /**
+ * Get the module's load type.
+ *
+ * @since 1.28
+ * @return string
+ */
+ public function getType() {
+ $canBeStylesOnly = !(
+ // All options except 'styles', 'skinStyles' and 'debugRaw'
+ $this->scripts
+ || $this->debugScripts
+ || $this->templates
+ || $this->languageScripts
+ || $this->skinScripts
+ || $this->dependencies
+ || $this->messages
+ || $this->skipFunction
+ || $this->packageFiles
+ );
+ return $canBeStylesOnly ? self::LOAD_STYLES : self::LOAD_GENERAL;
+ }
+
+ /**
+ * @deprecated since 1.35 Use compileLessString() instead
+ * @param string $fileName
+ * @param Context $context
+ * @return string
+ * @codeCoverageIgnore
+ */
+ protected function compileLessFile( $fileName, Context $context ) {
+ wfDeprecated( __METHOD__, '1.35' );
+
+ $style = $this->getFileContents( $fileName, 'LESS' );
+ return $this->compileLessString( $style, $fileName, $context );
+ }
+
+ /**
+ * Compile a LESS string into CSS.
+ *
+ * Keeps track of all used files and adds them to localFileRefs.
+ *
+ * @since 1.35
+ * @throws Exception If less.php encounters a parse error
+ * @param string $style LESS source to compile
+ * @param string $stylePath File path of LESS source, used for resolving relative file paths
+ * @param Context $context Context in which to generate script
+ * @return string CSS source
+ */
+ protected function compileLessString( $style, $stylePath, Context $context ) {
+ static $cache;
+ // @TODO: dependency injection
+ if ( !$cache ) {
+ $cache = ObjectCache::getLocalServerInstance( CACHE_ANYTHING );
+ }
+
+ $skinName = $context->getSkin();
+ $skinImportPaths = ExtensionRegistry::getInstance()->getAttribute( 'SkinLessImportPaths' );
+ $importDirs = [];
+ if ( isset( $skinImportPaths[ $skinName ] ) ) {
+ $importDirs[] = $skinImportPaths[ $skinName ];
+ }
+
+ $vars = $this->getLessVars( $context );
+ // Construct a cache key from a hash of the LESS source, and a hash digest
+ // of the LESS variables used for compilation.
+ ksort( $vars );
+ $compilerParams = [
+ 'vars' => $vars,
+ 'importDirs' => $importDirs,
+ ];
+ $key = $cache->makeGlobalKey(
+ 'resourceloader-less',
+ 'v1',
+ hash( 'md4', $style ),
+ hash( 'md4', serialize( $compilerParams ) )
+ );
+
+ // If we got a cached value, we have to validate it by getting a checksum of all the
+ // files that were loaded by the parser and ensuring it matches the cached entry's.
+ $data = $cache->get( $key );
+ if (
+ !$data ||
+ $data['hash'] !== FileContentsHasher::getFileContentsHash( $data['files'] )
+ ) {
+ $compiler = $context->getResourceLoader()->getLessCompiler( $vars, $importDirs );
+
+ $css = $compiler->parse( $style, $stylePath )->getCss();
+ // T253055: store the implicit dependency paths in a form relative to any install
+ // path so that multiple version of the application can share the cache for identical
+ // less stylesheets. This also avoids churn during application updates.
+ $files = $compiler->AllParsedFiles();
+ $data = [
+ 'css' => $css,
+ 'files' => Module::getRelativePaths( $files ),
+ 'hash' => FileContentsHasher::getFileContentsHash( $files )
+ ];
+ $cache->set( $key, $data, $cache::TTL_DAY );
+ }
+
+ foreach ( Module::expandRelativePaths( $data['files'] ) as $path ) {
+ $this->localFileRefs[] = $path;
+ }
+
+ return $data['css'];
+ }
+
+ /**
+ * Takes named templates by the module and returns an array mapping.
+ * @return array Templates mapping template alias to content
+ * @throws RuntimeException If a file doesn't exist
+ */
+ public function getTemplates() {
+ $templates = [];
+
+ foreach ( $this->templates as $alias => $templatePath ) {
+ // Alias is optional
+ if ( is_int( $alias ) ) {
+ $alias = $this->getPath( $templatePath );
+ }
+ $localPath = $this->getLocalPath( $templatePath );
+ $content = $this->getFileContents( $localPath, 'template' );
+
+ $templates[$alias] = $this->stripBom( $content );
+ }
+ return $templates;
+ }
+
+ /**
+ * Internal helper for use by getPackageFiles(), getFileHashes() and getDefinitionSummary().
+ *
+ * This expands the 'packageFiles' definition into something that's (almost) the right format
+ * for getPackageFiles() to return. It expands shorthands, resolves config vars, and handles
+ * summarising any non-file data for getVersionHash(). For file-based data, getFileHashes()
+ * handles it instead, which also ends up in getDefinitionSummary().
+ *
+ * What it does not do is reading the actual contents of any specified files, nor invoking
+ * the computation callbacks. Those things are done by getPackageFiles() instead to improve
+ * backend performance by only doing this work when the module response is needed, and not
+ * when merely computing the version hash for StartupModule, or when checking
+ * If-None-Match headers for a HTTP 304 response.
+ *
+ * @param Context $context
+ * @return array|null
+ * @phan-return array{main:?string,files:array[]}|null
+ * @throws LogicException If the 'packageFiles' definition is invalid.
+ */
+ private function expandPackageFiles( Context $context ) {
+ $hash = $context->getHash();
+ if ( isset( $this->expandedPackageFiles[$hash] ) ) {
+ return $this->expandedPackageFiles[$hash];
+ }
+ if ( $this->packageFiles === null ) {
+ return null;
+ }
+ $expandedFiles = [];
+ $mainFile = null;
+
+ foreach ( $this->packageFiles as $key => $fileInfo ) {
+ if ( is_string( $fileInfo ) ) {
+ $fileInfo = [ 'name' => $fileInfo, 'file' => $fileInfo ];
+ }
+ if ( !isset( $fileInfo['name'] ) ) {
+ $msg = "Missing 'name' key in package file info for module '{$this->getName()}'," .
+ " offset '{$key}'.";
+ $this->getLogger()->error( $msg );
+ throw new LogicException( $msg );
+ }
+ $fileName = $fileInfo['name'];
+
+ // Infer type from alias if needed
+ $type = $fileInfo['type'] ?? self::getPackageFileType( $fileName );
+ $expanded = [ 'type' => $type ];
+ if ( !empty( $fileInfo['main'] ) ) {
+ $mainFile = $fileName;
+ if ( $type !== 'script' && $type !== 'script-vue' ) {
+ $msg = "Main file in package must be of type 'script', module " .
+ "'{$this->getName()}', main file '{$mainFile}' is '{$type}'.";
+ $this->getLogger()->error( $msg );
+ throw new LogicException( $msg );
+ }
+ }
+
+ // Perform expansions (except 'file' and 'callback'), creating one of these keys:
+ // - 'content': literal value.
+ // - 'filePath': content to be read from a file.
+ // - 'callback': content computed by a callable.
+ if ( isset( $fileInfo['content'] ) ) {
+ $expanded['content'] = $fileInfo['content'];
+ } elseif ( isset( $fileInfo['file'] ) ) {
+ $expanded['filePath'] = $fileInfo['file'];
+ } elseif ( isset( $fileInfo['callback'] ) ) {
+ // If no extra parameter for the callback is given, use null.
+ $expanded['callbackParam'] = $fileInfo['callbackParam'] ?? null;
+
+ if ( !is_callable( $fileInfo['callback'] ) ) {
+ $msg = "Invalid 'callback' for module '{$this->getName()}', file '{$fileName}'.";
+ $this->getLogger()->error( $msg );
+ throw new LogicException( $msg );
+ }
+ if ( isset( $fileInfo['versionCallback'] ) ) {
+ if ( !is_callable( $fileInfo['versionCallback'] ) ) {
+ throw new LogicException( "Invalid 'versionCallback' for "
+ . "module '{$this->getName()}', file '{$fileName}'."
+ );
+ }
+
+ // Execute the versionCallback with the same arguments that
+ // would be given to the callback
+ $callbackResult = ( $fileInfo['versionCallback'] )(
+ $context,
+ $this->getConfig(),
+ $expanded['callbackParam']
+ );
+ if ( $callbackResult instanceof FilePath ) {
+ $expanded['filePath'] = $callbackResult->getPath();
+ } else {
+ $expanded['definitionSummary'] = $callbackResult;
+ }
+ // Don't invoke 'callback' here as it may be expensive (T223260).
+ $expanded['callback'] = $fileInfo['callback'];
+ } else {
+ // Else go ahead invoke callback with its arguments.
+ $callbackResult = ( $fileInfo['callback'] )(
+ $context,
+ $this->getConfig(),
+ $expanded['callbackParam']
+ );
+ if ( $callbackResult instanceof FilePath ) {
+ $expanded['filePath'] = $callbackResult->getPath();
+ } else {
+ $expanded['content'] = $callbackResult;
+ }
+ }
+ } elseif ( isset( $fileInfo['config'] ) ) {
+ if ( $type !== 'data' ) {
+ $msg = "Key 'config' only valid for data files. "
+ . " Module '{$this->getName()}', file '{$fileName}' is '{$type}'.";
+ $this->getLogger()->error( $msg );
+ throw new LogicException( $msg );
+ }
+ $expandedConfig = [];
+ foreach ( $fileInfo['config'] as $configKey => $var ) {
+ $expandedConfig[ is_numeric( $configKey ) ? $var : $configKey ] = $this->getConfig()->get( $var );
+ }
+ $expanded['content'] = $expandedConfig;
+ } elseif ( !empty( $fileInfo['main'] ) ) {
+ // [ 'name' => 'foo.js', 'main' => true ] is shorthand
+ $expanded['filePath'] = $fileName;
+ } else {
+ $msg = "Incomplete definition for module '{$this->getName()}', file '{$fileName}'. "
+ . "One of 'file', 'content', 'callback', or 'config' must be set.";
+ $this->getLogger()->error( $msg );
+ throw new LogicException( $msg );
+ }
+
+ $expandedFiles[$fileName] = $expanded;
+ }
+
+ if ( $expandedFiles && $mainFile === null ) {
+ // The first package file that is a script is the main file
+ foreach ( $expandedFiles as $path => $file ) {
+ if ( $file['type'] === 'script' || $file['type'] === 'script-vue' ) {
+ $mainFile = $path;
+ break;
+ }
+ }
+ }
+
+ $result = [
+ 'main' => $mainFile,
+ 'files' => $expandedFiles
+ ];
+
+ $this->expandedPackageFiles[$hash] = $result;
+ return $result;
+ }
+
+ /**
+ * Resolves the package files definition and generates the content of each package file.
+ * @param Context $context
+ * @return array|null Package files data structure, see ResourceLoaderModule::getScript()
+ * @throws RuntimeException If a file doesn't exist, or parsing a .vue file fails
+ */
+ public function getPackageFiles( Context $context ) {
+ if ( $this->packageFiles === null ) {
+ return null;
+ }
+ $hash = $context->getHash();
+ if ( isset( $this->fullyExpandedPackageFiles[ $hash ] ) ) {
+ return $this->fullyExpandedPackageFiles[ $hash ];
+ }
+ $expandedPackageFiles = $this->expandPackageFiles( $context );
+
+ // Expand file contents
+ foreach ( $expandedPackageFiles['files'] as $fileName => &$fileInfo ) {
+ // Turn any 'filePath' or 'callback' key into actual 'content',
+ // and remove the key after that. The callback could return a
+ // ResourceLoaderFilePath object; if that happens, fall through
+ // to the 'filePath' handling.
+ if ( isset( $fileInfo['callback'] ) ) {
+ $callbackResult = ( $fileInfo['callback'] )(
+ $context,
+ $this->getConfig(),
+ $fileInfo['callbackParam']
+ );
+ if ( $callbackResult instanceof FilePath ) {
+ // Fall through to the filePath handling code below
+ $fileInfo['filePath'] = $callbackResult->getPath();
+ } else {
+ $fileInfo['content'] = $callbackResult;
+ }
+ unset( $fileInfo['callback'] );
+ }
+ // Only interpret 'filePath' if 'content' hasn't been set already.
+ // This can happen if 'versionCallback' provided 'filePath',
+ // while 'callback' provides 'content'. In that case both are set
+ // at this point. The 'filePath' from 'versionCallback' in that case is
+ // only to inform getDefinitionSummary().
+ if ( !isset( $fileInfo['content'] ) && isset( $fileInfo['filePath'] ) ) {
+ $localPath = $this->getLocalPath( $fileInfo['filePath'] );
+ $content = $this->getFileContents( $localPath, 'package' );
+ if ( $fileInfo['type'] === 'data' ) {
+ $content = json_decode( $content );
+ }
+ $fileInfo['content'] = $content;
+ unset( $fileInfo['filePath'] );
+ }
+ if ( $fileInfo['type'] === 'script-vue' ) {
+ try {
+ $parsedComponent = $this->getVueComponentParser()->parse(
+ // @phan-suppress-next-line PhanTypePossiblyInvalidDimOffset False positive
+ $fileInfo['content'],
+ [ 'minifyTemplate' => !$context->getDebug() ]
+ );
+ } catch ( TimeoutException $e ) {
+ throw $e;
+ } catch ( Exception $e ) {
+ $msg = "Error parsing file '$fileName' in module '{$this->getName()}': " .
+ $e->getMessage();
+ $this->getLogger()->error( $msg );
+ throw new RuntimeException( $msg );
+ }
+ $encodedTemplate = json_encode( $parsedComponent['template'] );
+ if ( $context->getDebug() ) {
+ // Replace \n (backslash-n) with space + backslash-newline in debug mode
+ // We only replace \n if not preceded by a backslash, to avoid breaking '\\n'
+ $encodedTemplate = preg_replace( '/(?<!\\\\)\\\\n/', " \\\n", $encodedTemplate );
+ // Expand \t to real tabs in debug mode
+ $encodedTemplate = strtr( $encodedTemplate, [ "\\t" => "\t" ] );
+ }
+ $fileInfo['content'] = [
+ 'script' => $parsedComponent['script'] .
+ ";\nmodule.exports.template = $encodedTemplate;",
+ 'style' => $parsedComponent['style'] ?? '',
+ 'styleLang' => $parsedComponent['styleLang'] ?? 'css'
+ ];
+ $fileInfo['type'] = 'script+style';
+ }
+
+ // Not needed for client response, exists for use by getDefinitionSummary().
+ unset( $fileInfo['definitionSummary'] );
+ // Not needed for client response, used by callbacks only.
+ unset( $fileInfo['callbackParam'] );
+ }
+
+ $this->fullyExpandedPackageFiles[ $hash ] = $expandedPackageFiles;
+ return $expandedPackageFiles;
+ }
+
+ /**
+ * Takes an input string and removes the UTF-8 BOM character if present
+ *
+ * We need to remove these after reading a file, because we concatenate our files and
+ * the BOM character is not valid in the middle of a string.
+ * We already assume UTF-8 everywhere, so this should be safe.
+ *
+ * @param string $input
+ * @return string Input minus the initial BOM char
+ */
+ protected function stripBom( $input ) {
+ if ( str_starts_with( $input, "\xef\xbb\xbf" ) ) {
+ return substr( $input, 3 );
+ }
+ return $input;
+ }
+}
+
+/** @deprecated since 1.39 */
+class_alias( FileModule::class, 'ResourceLoaderFileModule' );
diff --git a/includes/ResourceLoader/FilePath.php b/includes/ResourceLoader/FilePath.php
new file mode 100644
index 000000000000..e2f451f9c776
--- /dev/null
+++ b/includes/ResourceLoader/FilePath.php
@@ -0,0 +1,90 @@
+<?php
+/**
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation; either version 2 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License along
+ * with this program; if not, write to the Free Software Foundation, Inc.,
+ * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ */
+
+namespace MediaWiki\ResourceLoader;
+
+/**
+ * An object to represent a path to a JavaScript/CSS file, along with a remote
+ * and local base path, for use with ResourceLoaderFileModule.
+ *
+ * @ingroup ResourceLoader
+ * @since 1.17
+ */
+class FilePath {
+ /** @var string Local base path */
+ protected $localBasePath;
+
+ /** @var string Remote base path */
+ protected $remoteBasePath;
+
+ /** @var string Path to the file */
+ protected $path;
+
+ /**
+ * @param string $path Relative path to the file, no leading slash.
+ * @param string $localBasePath Base path to prepend when generating a local path.
+ * @param string $remoteBasePath Base path to prepend when generating a remote path.
+ * Should not have a trailing slash unless at web document root.
+ */
+ public function __construct( $path, $localBasePath = '', $remoteBasePath = '' ) {
+ $this->path = $path;
+ $this->localBasePath = $localBasePath;
+ $this->remoteBasePath = $remoteBasePath;
+ }
+
+ /** @return string */
+ public function getLocalPath() {
+ return $this->localBasePath === '' ?
+ $this->path :
+ "{$this->localBasePath}/{$this->path}";
+ }
+
+ /** @return string */
+ public function getRemotePath() {
+ if ( $this->remoteBasePath === '' ) {
+ // No base path configured
+ return $this->path;
+ }
+ if ( $this->remoteBasePath === '/' ) {
+ // In document root
+ // Don't insert another slash (T284391).
+ return $this->remoteBasePath . $this->path;
+ }
+ return "{$this->remoteBasePath}/{$this->path}";
+ }
+
+ /** @return string */
+ public function getLocalBasePath() {
+ return $this->localBasePath;
+ }
+
+ /** @return string */
+ public function getRemoteBasePath() {
+ return $this->remoteBasePath;
+ }
+
+ /** @return string */
+ public function getPath() {
+ return $this->path;
+ }
+}
+
+/** @deprecated since 1.39 */
+class_alias( FilePath::class, 'ResourceLoaderFilePath' );
diff --git a/includes/ResourceLoader/ForeignApiModule.php b/includes/ResourceLoader/ForeignApiModule.php
new file mode 100644
index 000000000000..822fdd1fd769
--- /dev/null
+++ b/includes/ResourceLoader/ForeignApiModule.php
@@ -0,0 +1,39 @@
+<?php
+/**
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation; either version 2 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License along
+ * with this program; if not, write to the Free Software Foundation, Inc.,
+ * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ */
+
+namespace MediaWiki\ResourceLoader;
+
+/**
+ * Module for mediawiki.ForeignApi and mediawiki.ForeignRest that has dynamically
+ * generated dependencies, via a hook usable by extensions.
+ *
+ * @ingroup ResourceLoader
+ * @internal
+ */
+class ForeignApiModule extends FileModule {
+ public function getDependencies( Context $context = null ) {
+ $dependencies = $this->dependencies;
+ $this->getHookRunner()->onResourceLoaderForeignApiModules( $dependencies, $context );
+ return $dependencies;
+ }
+}
+
+/** @deprecated since 1.39 */
+class_alias( ForeignApiModule::class, 'ResourceLoaderForeignApiModule' );
diff --git a/includes/ResourceLoader/Hook/ResourceLoaderExcludeUserOptionsHook.php b/includes/ResourceLoader/Hook/ResourceLoaderExcludeUserOptionsHook.php
new file mode 100644
index 000000000000..f73e65579d32
--- /dev/null
+++ b/includes/ResourceLoader/Hook/ResourceLoaderExcludeUserOptionsHook.php
@@ -0,0 +1,37 @@
+<?php
+
+namespace MediaWiki\ResourceLoader\Hook;
+
+use MediaWiki\ResourceLoader\Context;
+
+/**
+ * This is a hook handler interface, see docs/Hooks.md.
+ * Use the hook name "ResourceLoaderExcludeUserOptions" to register handlers implementing this interface.
+ *
+ * @stable to implement
+ * @ingroup ResourceLoaderHooks
+ */
+interface ResourceLoaderExcludeUserOptionsHook {
+
+ /**
+ * Exclude a user option from the preloaded data for client-side mw.user.options.
+ *
+ * This hook is called on every index.php pageview (via ResourceLoaderUserOptionsModule),
+ * and when building responses for the "mediawiki.base" module. Avoid database queries
+ * or other expensive operations as that would increase page load time.
+ *
+ * Use this hook to optimize pageview HTML size by omitting user preference
+ * values from the export JavaScript data for `mw.user.options`. For example,
+ * when an extension stores large values in a user preference, and rarely or never
+ * needs these client-side, you can exclude it via this hook. (T251994)
+ *
+ * This will exclude both the default value (via mediawiki.base module) and
+ * the current user's value (via pageview HTML).
+ *
+ * @since 1.38
+ * @param array &$keysToExclude
+ * @param Context $context
+ * @return void
+ */
+ public function onResourceLoaderExcludeUserOptions( array &$keysToExclude, Context $context ): void;
+}
diff --git a/includes/ResourceLoader/Hook/ResourceLoaderForeignApiModulesHook.php b/includes/ResourceLoader/Hook/ResourceLoaderForeignApiModulesHook.php
new file mode 100644
index 000000000000..e7b2179b2a98
--- /dev/null
+++ b/includes/ResourceLoader/Hook/ResourceLoaderForeignApiModulesHook.php
@@ -0,0 +1,28 @@
+<?php
+
+namespace MediaWiki\ResourceLoader\Hook;
+
+use MediaWiki\ResourceLoader\Context;
+
+/**
+ * This is a hook handler interface, see docs/Hooks.md.
+ * Use the hook name "ResourceLoaderForeignApiModules" to register handlers implementing this interface.
+ *
+ * @stable to implement
+ * @ingroup ResourceLoaderHooks
+ */
+interface ResourceLoaderForeignApiModulesHook {
+ /**
+ * Add dependencies to the `mediawiki.ForeignApi` module when you wish
+ * to override its behavior. See the JS docs for more information.
+ *
+ * This hook is called from ResourceLoaderForeignApiModule.
+ *
+ * @since 1.35
+ * @param string[] &$dependencies List of modules that mediawiki.ForeignApi should
+ * depend on
+ * @param Context|null $context
+ * @return void This hook must not abort, it must return no value
+ */
+ public function onResourceLoaderForeignApiModules( &$dependencies, $context ): void;
+}
diff --git a/includes/ResourceLoader/Hook/ResourceLoaderGetConfigVarsHook.php b/includes/ResourceLoader/Hook/ResourceLoaderGetConfigVarsHook.php
new file mode 100644
index 000000000000..410401e87469
--- /dev/null
+++ b/includes/ResourceLoader/Hook/ResourceLoaderGetConfigVarsHook.php
@@ -0,0 +1,31 @@
+<?php
+
+namespace MediaWiki\ResourceLoader\Hook;
+
+use Config;
+
+/**
+ * This is a hook handler interface, see docs/Hooks.md.
+ * Use the hook name "ResourceLoaderGetConfigVars" to register handlers implementing this interface.
+ *
+ * @stable to implement
+ * @ingroup ResourceLoaderHooks
+ */
+interface ResourceLoaderGetConfigVarsHook {
+ /**
+ * Export static site-wide `mw.config` variables to JavaScript.
+ *
+ * Variables that depend on the current page or request state must be added
+ * through MediaWiki\Hook\MakeGlobalVariablesScriptHook instead.
+ * The skin name is made available to send skin-specific config only when needed.
+ *
+ * This hook is called from ResourceLoaderStartUpModule.
+ *
+ * @since 1.35
+ * @param array &$vars `[ variable name => value ]`
+ * @param string $skin
+ * @param Config $config since 1.34
+ * @return void This hook must not abort, it must return no value
+ */
+ public function onResourceLoaderGetConfigVars( array &$vars, $skin, Config $config ): void;
+}
diff --git a/includes/ResourceLoader/Hook/ResourceLoaderJqueryMsgModuleMagicWordsHook.php b/includes/ResourceLoader/Hook/ResourceLoaderJqueryMsgModuleMagicWordsHook.php
new file mode 100644
index 000000000000..c01c1ac8efeb
--- /dev/null
+++ b/includes/ResourceLoader/Hook/ResourceLoaderJqueryMsgModuleMagicWordsHook.php
@@ -0,0 +1,30 @@
+<?php
+
+namespace MediaWiki\ResourceLoader\Hook;
+
+use MediaWiki\ResourceLoader\Context;
+
+/**
+ * This is a hook handler interface, see docs/Hooks.md.
+ * Use the hook name "ResourceLoaderJqueryMsgModuleMagicWords" to register handlers implementing this interface.
+ *
+ * @stable to implement
+ * @ingroup ResourceLoaderHooks
+ */
+interface ResourceLoaderJqueryMsgModuleMagicWordsHook {
+ /**
+ * Add magic words to the `mediawiki.jqueryMsg` module. The values should be a string,
+ * and they may only vary by what's in the Context.
+ *
+ * This hook is called from ResourceLoaderJqueryMsgModule.
+ *
+ * @since 1.35
+ * @param Context $context
+ * @param string[] &$magicWords Associative array mapping all-caps magic word to a string value
+ * @return void This hook must not abort, it must return no value
+ */
+ public function onResourceLoaderJqueryMsgModuleMagicWords(
+ Context $context,
+ array &$magicWords
+ ): void;
+}
diff --git a/includes/ResourceLoader/Hook/ResourceLoaderRegisterModulesHook.php b/includes/ResourceLoader/Hook/ResourceLoaderRegisterModulesHook.php
new file mode 100644
index 000000000000..6238d4fe7384
--- /dev/null
+++ b/includes/ResourceLoader/Hook/ResourceLoaderRegisterModulesHook.php
@@ -0,0 +1,25 @@
+<?php
+
+namespace MediaWiki\ResourceLoader\Hook;
+
+use MediaWiki\ResourceLoader\ResourceLoader;
+
+/**
+ * This is a hook handler interface, see docs/Hooks.md.
+ * Use the hook name "ResourceLoaderRegisterModules" to register handlers implementing this interface.
+ *
+ * @stable to implement
+ * @ingroup ResourceLoaderHooks
+ */
+interface ResourceLoaderRegisterModulesHook {
+ /**
+ * This hook is called right before modules information is required,
+ * such as when responding to a resource
+ * loader request or generating HTML output.
+ *
+ * @since 1.35
+ * @param ResourceLoader $rl
+ * @return void This hook must not abort, it must return no value
+ */
+ public function onResourceLoaderRegisterModules( ResourceLoader $rl ): void;
+}
diff --git a/includes/ResourceLoader/Hook/ResourceLoaderSiteModulePagesHook.php b/includes/ResourceLoader/Hook/ResourceLoaderSiteModulePagesHook.php
new file mode 100644
index 000000000000..245b678d2f49
--- /dev/null
+++ b/includes/ResourceLoader/Hook/ResourceLoaderSiteModulePagesHook.php
@@ -0,0 +1,24 @@
+<?php
+
+namespace MediaWiki\ResourceLoader\Hook;
+
+/**
+ * This is a hook handler interface, see docs/Hooks.md.
+ * Use the hook name "ResourceLoaderSiteModulePages" to register handlers implementing this interface.
+ *
+ * @stable to implement
+ * @ingroup ResourceLoaderHooks
+ */
+interface ResourceLoaderSiteModulePagesHook {
+ /**
+ * Change which wiki pages comprise the `site` module in given skin.
+ *
+ * This hook is called from ResourceLoaderSiteModule.
+ *
+ * @since 1.35
+ * @param string $skin Current skin key
+ * @param array &$pages Array of pages and their types
+ * @return void This hook must not abort, it must return no value
+ */
+ public function onResourceLoaderSiteModulePages( $skin, array &$pages ): void;
+}
diff --git a/includes/ResourceLoader/Hook/ResourceLoaderSiteStylesModulePagesHook.php b/includes/ResourceLoader/Hook/ResourceLoaderSiteStylesModulePagesHook.php
new file mode 100644
index 000000000000..392044da2e83
--- /dev/null
+++ b/includes/ResourceLoader/Hook/ResourceLoaderSiteStylesModulePagesHook.php
@@ -0,0 +1,24 @@
+<?php
+
+namespace MediaWiki\ResourceLoader\Hook;
+
+/**
+ * This is a hook handler interface, see docs/Hooks.md.
+ * Use the hook name "ResourceLoaderSiteStylesModulePages" to register handlers implementing this interface.
+ *
+ * @stable to implement
+ * @ingroup ResourceLoaderHooks
+ */
+interface ResourceLoaderSiteStylesModulePagesHook {
+ /**
+ * Change which wiki pages comprise the `site.styles` module in given skin.
+ *
+ * This hook is called from ResourceLoaderSiteStylesModule.
+ *
+ * @since 1.35
+ * @param string $skin Current skin key
+ * @param array &$pages Array of pages and their types
+ * @return void This hook must not abort, it must return no value
+ */
+ public function onResourceLoaderSiteStylesModulePages( $skin, array &$pages ): void;
+}
diff --git a/includes/ResourceLoader/Hook/ResourceLoaderTestModulesHook.php b/includes/ResourceLoader/Hook/ResourceLoaderTestModulesHook.php
new file mode 100644
index 000000000000..641400aad861
--- /dev/null
+++ b/includes/ResourceLoader/Hook/ResourceLoaderTestModulesHook.php
@@ -0,0 +1,34 @@
+<?php
+
+namespace MediaWiki\ResourceLoader\Hook;
+
+use MediaWiki\ResourceLoader\ResourceLoader;
+
+/**
+ * This is a hook handler interface, see docs/Hooks.md.
+ * Use the hook name "ResourceLoaderTestModules" to register handlers implementing this interface.
+ *
+ * @deprecated since 1.33; use the QUnitTestModule static extension registration attribute instead.
+ * @ingroup ResourceLoaderHooks
+ */
+interface ResourceLoaderTestModulesHook {
+ /**
+ * Use this hook to register ResourceLoader modules that are only available
+ * when $wgEnableJavaScriptTest is true. Use this for test suites and
+ * other test-only resources.
+ *
+ * @since 1.35
+ * @param array &$testModules One array of modules per test framework.
+ * The modules array follows the same format as `$wgResourceModules`.
+ * For example:
+ * $testModules['qunit']['ext.Example.test'] = [
+ * 'localBasePath' => __DIR__ . '/tests/qunit',
+ * 'remoteExtPath' => 'Example/tests/qunit',
+ * 'script' => [ 'tests/qunit/foo.js' ],
+ * 'dependencies' => [ 'ext.Example.foo' ]
+ * ];
+ * @param ResourceLoader $rl
+ * @return void This hook must not abort, it must return no value
+ */
+ public function onResourceLoaderTestModules( array &$testModules, ResourceLoader $rl ): void;
+}
diff --git a/includes/ResourceLoader/HookRunner.php b/includes/ResourceLoader/HookRunner.php
new file mode 100644
index 000000000000..acd626b32255
--- /dev/null
+++ b/includes/ResourceLoader/HookRunner.php
@@ -0,0 +1,74 @@
+<?php
+
+namespace MediaWiki\ResourceLoader;
+
+use MediaWiki\HookContainer\HookContainer;
+
+/**
+ * @internal
+ * @codeCoverageIgnore
+ * @ingroup ResourceLoader
+ */
+class HookRunner implements
+ \MediaWiki\ResourceLoader\Hook\ResourceLoaderExcludeUserOptionsHook,
+ \MediaWiki\ResourceLoader\Hook\ResourceLoaderForeignApiModulesHook,
+ \MediaWiki\ResourceLoader\Hook\ResourceLoaderRegisterModulesHook,
+ \MediaWiki\ResourceLoader\Hook\ResourceLoaderSiteModulePagesHook,
+ \MediaWiki\ResourceLoader\Hook\ResourceLoaderSiteStylesModulePagesHook,
+ \MediaWiki\ResourceLoader\Hook\ResourceLoaderTestModulesHook
+{
+ /** @var HookContainer */
+ private $container;
+
+ public function __construct( HookContainer $container ) {
+ $this->container = $container;
+ }
+
+ public function onResourceLoaderExcludeUserOptions( array &$keysToExclude, Context $context ): void {
+ $this->container->run(
+ 'ResourceLoaderExcludeUserOptions',
+ [ &$keysToExclude, $context ],
+ [ 'abortable' => false ]
+ );
+ }
+
+ public function onResourceLoaderForeignApiModules( &$dependencies, $context ): void {
+ $this->container->run(
+ 'ResourceLoaderForeignApiModules',
+ [ &$dependencies, $context ],
+ [ 'abortable' => false ]
+ );
+ }
+
+ public function onResourceLoaderRegisterModules( ResourceLoader $rl ): void {
+ $this->container->run(
+ 'ResourceLoaderRegisterModules',
+ [ $rl ],
+ [ 'abortable' => false ]
+ );
+ }
+
+ public function onResourceLoaderSiteModulePages( $skin, array &$pages ): void {
+ $this->container->run(
+ 'ResourceLoaderSiteModulePages',
+ [ $skin, &$pages ],
+ [ 'abortable' => false ]
+ );
+ }
+
+ public function onResourceLoaderSiteStylesModulePages( $skin, array &$pages ): void {
+ $this->container->run(
+ 'ResourceLoaderSiteStylesModulePages',
+ [ $skin, &$pages ],
+ [ 'abortable' => false ]
+ );
+ }
+
+ public function onResourceLoaderTestModules( array &$testModules, ResourceLoader $rl ): void {
+ $this->container->run(
+ 'ResourceLoaderTestModules',
+ [ &$testModules, $rl ],
+ [ 'abortable' => false ]
+ );
+ }
+}
diff --git a/includes/ResourceLoader/Image.php b/includes/ResourceLoader/Image.php
new file mode 100644
index 000000000000..ef474dd4bf9d
--- /dev/null
+++ b/includes/ResourceLoader/Image.php
@@ -0,0 +1,502 @@
+<?php
+/**
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation; either version 2 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License along
+ * with this program; if not, write to the Free Software Foundation, Inc.,
+ * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ */
+
+namespace MediaWiki\ResourceLoader;
+
+use DOMDocument;
+use FileBackend;
+use InvalidArgumentException;
+use MediaWiki\Languages\LanguageFallback;
+use MediaWiki\MainConfigNames;
+use MediaWiki\MediaWikiServices;
+use MediaWiki\Shell\Shell;
+use MWException;
+use SvgHandler;
+use SVGReader;
+use Wikimedia\Minify\CSSMin;
+
+/**
+ * Class encapsulating an image used in an ImageModule.
+ *
+ * @ingroup ResourceLoader
+ * @since 1.25
+ */
+class Image {
+ /**
+ * Map of allowed file extensions to their MIME types.
+ * @var array
+ */
+ private const FILE_TYPES = [
+ 'svg' => 'image/svg+xml',
+ 'png' => 'image/png',
+ 'gif' => 'image/gif',
+ 'jpg' => 'image/jpg',
+ ];
+
+ /** @var string */
+ private $name;
+ /** @var string */
+ private $module;
+ /** @var string|array */
+ private $descriptor;
+ /** @var string */
+ private $basePath;
+ /** @var array */
+ private $variants;
+ /** @var string|null */
+ private $defaultColor;
+ /** @var string */
+ private $extension;
+
+ /**
+ * @param string $name Self-name of the image as known to ImageModule.
+ * @param string $module Self-name of the module containing this image.
+ * Used to find the image in the registry e.g. through a load.php url.
+ * @param string|array $descriptor Path to image file, or array structure containing paths
+ * @param string $basePath Directory to which paths in descriptor refer
+ * @param array $variants
+ * @param string|null $defaultColor of the base variant
+ * @throws InvalidArgumentException
+ */
+ public function __construct( $name, $module, $descriptor, $basePath, array $variants,
+ $defaultColor = null
+ ) {
+ $this->name = $name;
+ $this->module = $module;
+ $this->descriptor = $descriptor;
+ $this->basePath = $basePath;
+ $this->variants = $variants;
+ $this->defaultColor = $defaultColor;
+
+ // Expand shorthands:
+ // [ "en,de,fr" => "foo.svg" ]
+ // → [ "en" => "foo.svg", "de" => "foo.svg", "fr" => "foo.svg" ]
+ if ( is_array( $this->descriptor ) && isset( $this->descriptor['lang'] ) ) {
+ foreach ( array_keys( $this->descriptor['lang'] ) as $langList ) {
+ if ( strpos( $langList, ',' ) !== false ) {
+ $this->descriptor['lang'] += array_fill_keys(
+ explode( ',', $langList ),
+ $this->descriptor['lang'][$langList]
+ );
+ unset( $this->descriptor['lang'][$langList] );
+ }
+ }
+ }
+ // Remove 'deprecated' key
+ if ( is_array( $this->descriptor ) ) {
+ unset( $this->descriptor['deprecated'] );
+ }
+
+ // Ensure that all files have common extension.
+ $extensions = [];
+ $descriptor = is_array( $this->descriptor ) ? $this->descriptor : [ $this->descriptor ];
+ array_walk_recursive( $descriptor, function ( $path ) use ( &$extensions ) {
+ $extensions[] = pathinfo( $this->getLocalPath( $path ), PATHINFO_EXTENSION );
+ } );
+ $extensions = array_unique( $extensions );
+ if ( count( $extensions ) !== 1 ) {
+ throw new InvalidArgumentException(
+ "File type for different image files of '$name' not the same in module '$module'"
+ );
+ }
+ $ext = $extensions[0];
+ if ( !isset( self::FILE_TYPES[$ext] ) ) {
+ throw new InvalidArgumentException(
+ "Invalid file type for image files of '$name' (valid: svg, png, gif, jpg) in module '$module'"
+ );
+ }
+ $this->extension = $ext;
+ }
+
+ /**
+ * Get name of this image.
+ *
+ * @return string
+ */
+ public function getName() {
+ return $this->name;
+ }
+
+ /**
+ * Get name of the module this image belongs to.
+ *
+ * @return string
+ */
+ public function getModule() {
+ return $this->module;
+ }
+
+ /**
+ * Get the list of variants this image can be converted to.
+ *
+ * @return string[]
+ */
+ public function getVariants(): array {
+ return array_keys( $this->variants );
+ }
+
+ /**
+ * @internal For unit testing override
+ * @param string $lang
+ * @return string[]
+ */
+ protected function getLangFallbacks( string $lang ): array {
+ return MediaWikiServices::getInstance()
+ ->getLanguageFallback()
+ ->getAll( $lang, LanguageFallback::STRICT );
+ }
+
+ /**
+ * Get the path to image file for given context.
+ *
+ * @param Context $context Any context
+ * @return string
+ * @throws MWException If no matching path is found
+ */
+ public function getPath( Context $context ) {
+ $desc = $this->descriptor;
+ if ( !is_array( $desc ) ) {
+ return $this->getLocalPath( $desc );
+ }
+ if ( isset( $desc['lang'] ) ) {
+ $contextLang = $context->getLanguage();
+ if ( isset( $desc['lang'][$contextLang] ) ) {
+ return $this->getLocalPath( $desc['lang'][$contextLang] );
+ }
+ $fallbacks = $this->getLangFallbacks( $contextLang );
+ foreach ( $fallbacks as $lang ) {
+ if ( isset( $desc['lang'][$lang] ) ) {
+ return $this->getLocalPath( $desc['lang'][$lang] );
+ }
+ }
+ }
+ if ( isset( $desc[$context->getDirection()] ) ) {
+ return $this->getLocalPath( $desc[$context->getDirection()] );
+ }
+ if ( isset( $desc['default'] ) ) {
+ return $this->getLocalPath( $desc['default'] );
+ } else {
+ throw new MWException( 'No matching path found' );
+ }
+ }
+
+ /**
+ * @param string|FilePath $path
+ * @return string
+ */
+ protected function getLocalPath( $path ) {
+ if ( $path instanceof FilePath ) {
+ return $path->getLocalPath();
+ }
+
+ return "{$this->basePath}/$path";
+ }
+
+ /**
+ * Get the extension of the image.
+ *
+ * @param string|null $format Format to get the extension for, 'original' or 'rasterized'
+ * @return string Extension without leading dot, e.g. 'png'
+ */
+ public function getExtension( $format = 'original' ) {
+ if ( $format === 'rasterized' && $this->extension === 'svg' ) {
+ return 'png';
+ }
+ return $this->extension;
+ }
+
+ /**
+ * Get the MIME type of the image.
+ *
+ * @param string|null $format Format to get the MIME type for, 'original' or 'rasterized'
+ * @return string
+ */
+ public function getMimeType( $format = 'original' ) {
+ $ext = $this->getExtension( $format );
+ return self::FILE_TYPES[$ext];
+ }
+
+ /**
+ * Get the load.php URL that will produce this image.
+ *
+ * @param Context $context Any context
+ * @param string $script URL to load.php
+ * @param string|null $variant Variant to get the URL for
+ * @param string $format Format to get the URL for, 'original' or 'rasterized'
+ * @return string URL
+ */
+ public function getUrl( Context $context, $script, $variant, $format ) {
+ $query = [
+ 'modules' => $this->getModule(),
+ 'image' => $this->getName(),
+ 'variant' => $variant,
+ 'format' => $format,
+ ];
+ if ( $this->varyOnLanguage() ) {
+ $query['lang'] = $context->getLanguage();
+ }
+ // The following parameters are at the end to keep the original order of the parameters.
+ $query['skin'] = $context->getSkin();
+ $rl = $context->getResourceLoader();
+ $query['version'] = $rl->makeVersionQuery( $context, [ $this->getModule() ] );
+
+ return wfAppendQuery( $script, $query );
+ }
+
+ /**
+ * Get the data: URI that will produce this image.
+ *
+ * @param Context $context Any context
+ * @param string|null $variant Variant to get the URI for
+ * @param string $format Format to get the URI for, 'original' or 'rasterized'
+ * @return string
+ */
+ public function getDataUri( Context $context, $variant, $format ) {
+ $type = $this->getMimeType( $format );
+ $contents = $this->getImageData( $context, $variant, $format );
+ return CSSMin::encodeStringAsDataURI( $contents, $type );
+ }
+
+ /**
+ * Get actual image data for this image. This can be saved to a file or sent to the browser to
+ * produce the converted image.
+ *
+ * Call getExtension() or getMimeType() with the same $format argument to learn what file type the
+ * returned data uses.
+ *
+ * @param Context $context Image context, or any context if $variant and $format
+ * given.
+ * @param string|null|false $variant Variant to get the data for. Optional; if given, overrides the data
+ * from $context.
+ * @param string|false $format Format to get the data for, 'original' or 'rasterized'. Optional; if
+ * given, overrides the data from $context.
+ * @return string|false Possibly binary image data, or false on failure
+ * @throws MWException If the image file doesn't exist
+ */
+ public function getImageData( Context $context, $variant = false, $format = false ) {
+ if ( $variant === false ) {
+ $variant = $context->getVariant();
+ }
+ if ( $format === false ) {
+ $format = $context->getFormat();
+ }
+
+ $path = $this->getPath( $context );
+ if ( !file_exists( $path ) ) {
+ throw new MWException( "File '$path' does not exist" );
+ }
+
+ if ( $this->getExtension() !== 'svg' ) {
+ return file_get_contents( $path );
+ }
+
+ if ( $variant && isset( $this->variants[$variant] ) ) {
+ $data = $this->variantize( $this->variants[$variant], $context );
+ } else {
+ $defaultColor = $this->defaultColor;
+ $data = $defaultColor ?
+ $this->variantize( [ 'color' => $defaultColor ], $context ) :
+ file_get_contents( $path );
+ }
+
+ if ( $format === 'rasterized' ) {
+ $data = $this->rasterize( $data );
+ if ( !$data ) {
+ wfDebugLog( 'ResourceLoaderImage', __METHOD__ . " failed to rasterize for $path" );
+ }
+ }
+
+ return $data;
+ }
+
+ /**
+ * Send response headers (using the header() function) that are necessary to correctly serve the
+ * image data for this image, as returned by getImageData().
+ *
+ * Note that the headers are independent of the language or image variant.
+ *
+ * @param Context $context Image context
+ */
+ public function sendResponseHeaders( Context $context ): void {
+ $format = $context->getFormat();
+ $mime = $this->getMimeType( $format );
+ $filename = $this->getName() . '.' . $this->getExtension( $format );
+
+ header( 'Content-Type: ' . $mime );
+ header( 'Content-Disposition: ' .
+ FileBackend::makeContentDisposition( 'inline', $filename ) );
+ }
+
+ /**
+ * Convert this image, which is assumed to be SVG, to given variant.
+ *
+ * @param array $variantConf Array with a 'color' key, its value will be used as fill color
+ * @param Context $context Image context
+ * @return string New SVG file data
+ */
+ protected function variantize( array $variantConf, Context $context ) {
+ $dom = new DOMDocument;
+ $dom->loadXML( file_get_contents( $this->getPath( $context ) ) );
+ $root = $dom->documentElement;
+ $titleNode = null;
+ $wrapper = $dom->createElementNS( 'http://www.w3.org/2000/svg', 'g' );
+ // Reattach all direct children of the `<svg>` root node to the `<g>` wrapper
+ while ( $root->firstChild ) {
+ $node = $root->firstChild;
+ // @phan-suppress-next-line PhanUndeclaredProperty False positive
+ if ( !$titleNode && $node->nodeType === XML_ELEMENT_NODE && $node->tagName === 'title' ) {
+ // Remember the first encountered `<title>` node
+ $titleNode = $node;
+ }
+ $wrapper->appendChild( $node );
+ }
+ if ( $titleNode ) {
+ // Reattach the `<title>` node to the `<svg>` root node rather than the `<g>` wrapper
+ $root->appendChild( $titleNode );
+ }
+ $root->appendChild( $wrapper );
+ $wrapper->setAttribute( 'fill', $variantConf['color'] );
+ return $dom->saveXML();
+ }
+
+ /**
+ * Massage the SVG image data for converters which don't understand some path data syntax.
+ *
+ * This is necessary for rsvg and ImageMagick when compiled with rsvg support.
+ * Upstream bug is https://bugzilla.gnome.org/show_bug.cgi?id=620923, fixed 2014-11-10, so
+ * this will be needed for a while. (T76852)
+ *
+ * @param string $svg SVG image data
+ * @return string Massaged SVG image data
+ */
+ protected function massageSvgPathdata( $svg ) {
+ $dom = new DOMDocument;
+ $dom->loadXML( $svg );
+ foreach ( $dom->getElementsByTagName( 'path' ) as $node ) {
+ $pathData = $node->getAttribute( 'd' );
+ // Make sure there is at least one space between numbers, and that leading zero is not omitted.
+ // rsvg has issues with syntax like "M-1-2" and "M.445.483" and especially "M-.445-.483".
+ $pathData = preg_replace( '/(-?)(\d*\.\d+|\d+)/', ' ${1}0$2 ', $pathData );
+ // Strip unnecessary leading zeroes for prettiness, not strictly necessary
+ $pathData = preg_replace( '/([ -])0(\d)/', '$1$2', $pathData );
+ $node->setAttribute( 'd', $pathData );
+ }
+ return $dom->saveXML();
+ }
+
+ /**
+ * Convert passed image data, which is assumed to be SVG, to PNG.
+ *
+ * @param string $svg SVG image data
+ * @return string|bool PNG image data, or false on failure
+ */
+ protected function rasterize( $svg ) {
+ $svgConverter = MediaWikiServices::getInstance()->getMainConfig()->get( MainConfigNames::SVGConverter );
+ $svgConverterPath = MediaWikiServices::getInstance()->getMainConfig()->get( MainConfigNames::SVGConverterPath );
+ // This code should be factored out to a separate method on SvgHandler, or perhaps a separate
+ // class, with a separate set of configuration settings.
+ //
+ // This is a distinct use case from regular SVG rasterization:
+ // * We can skip many checks (as the images come from a trusted source,
+ // rather than from the user).
+ // * We need to provide extra options to some converters to achieve acceptable quality for very
+ // small images, which might cause performance issues in the general case.
+ // * We want to directly pass image data to the converter, rather than a file path.
+ //
+ // See https://phabricator.wikimedia.org/T76473#801446 for examples of what happens with the
+ // default settings.
+ //
+ // For now, we special-case rsvg (used in WMF production) and do a messy workaround for other
+ // converters.
+
+ $svg = $this->massageSvgPathdata( $svg );
+
+ // Sometimes this might be 'rsvg-secure'. Long as it's rsvg.
+ if ( strpos( $svgConverter, 'rsvg' ) === 0 ) {
+ $command = 'rsvg-convert';
+ if ( $svgConverterPath ) {
+ $command = Shell::escape( "{$svgConverterPath}/" ) . $command;
+ }
+
+ $process = proc_open(
+ $command,
+ [ 0 => [ 'pipe', 'r' ], 1 => [ 'pipe', 'w' ] ],
+ $pipes
+ );
+
+ if ( $process ) {
+ fwrite( $pipes[0], $svg );
+ fclose( $pipes[0] );
+ $png = stream_get_contents( $pipes[1] );
+ fclose( $pipes[1] );
+ proc_close( $process );
+
+ return $png ?: false;
+ }
+ return false;
+
+ } else {
+ // Write input to and read output from a temporary file
+ $tempFilenameSvg = tempnam( wfTempDir(), 'ResourceLoaderImage' );
+ $tempFilenamePng = tempnam( wfTempDir(), 'ResourceLoaderImage' );
+
+ file_put_contents( $tempFilenameSvg, $svg );
+
+ $svgReader = new SVGReader( $tempFilenameSvg );
+ $metadata = $svgReader->getMetadata();
+ if ( !isset( $metadata['width'] ) || !isset( $metadata['height'] ) ) {
+ unlink( $tempFilenameSvg );
+ return false;
+ }
+
+ $handler = new SvgHandler;
+ $res = $handler->rasterize(
+ $tempFilenameSvg,
+ $tempFilenamePng,
+ $metadata['width'],
+ $metadata['height']
+ );
+ unlink( $tempFilenameSvg );
+
+ $png = null;
+ if ( $res === true ) {
+ $png = file_get_contents( $tempFilenamePng );
+ unlink( $tempFilenamePng );
+ }
+
+ return $png ?: false;
+ }
+ }
+
+ /**
+ * Check if the image depends on the language.
+ *
+ * @return bool
+ */
+ private function varyOnLanguage() {
+ return is_array( $this->descriptor ) && (
+ isset( $this->descriptor['ltr'] ) ||
+ isset( $this->descriptor['rtl'] ) ||
+ isset( $this->descriptor['lang'] ) );
+ }
+}
+
+/** @deprecated since 1.39 */
+class_alias( Image::class, 'ResourceLoaderImage' );
diff --git a/includes/ResourceLoader/ImageModule.php b/includes/ResourceLoader/ImageModule.php
new file mode 100644
index 000000000000..c9b75894012f
--- /dev/null
+++ b/includes/ResourceLoader/ImageModule.php
@@ -0,0 +1,504 @@
+<?php
+/**
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation; either version 2 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License along
+ * with this program; if not, write to the Free Software Foundation, Inc.,
+ * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @author Trevor Parscal
+ */
+namespace MediaWiki\ResourceLoader;
+
+use InvalidArgumentException;
+use Wikimedia\Minify\CSSMin;
+
+/**
+ * Module for generated and embedded images.
+ *
+ * @ingroup ResourceLoader
+ * @since 1.25
+ */
+class ImageModule extends Module {
+ /** @var array|null */
+ protected $definition;
+
+ /**
+ * Local base path, see __construct()
+ * @var string
+ */
+ protected $localBasePath = '';
+
+ protected $origin = self::ORIGIN_CORE_SITEWIDE;
+
+ /** @var Image[][]|null */
+ protected $imageObjects = null;
+ /** @var array */
+ protected $images = [];
+ /** @var string|null */
+ protected $defaultColor = null;
+ protected $useDataURI = true;
+ /** @var array|null */
+ protected $globalVariants = null;
+ /** @var array */
+ protected $variants = [];
+ /** @var string|null */
+ protected $prefix = null;
+ protected $selectorWithoutVariant = '.{prefix}-{name}';
+ protected $selectorWithVariant = '.{prefix}-{name}-{variant}';
+ protected $targets = [ 'desktop', 'mobile' ];
+
+ /**
+ * Constructs a new module from an options array.
+ *
+ * @param array $options List of options; if not given or empty, an empty module will be
+ * constructed
+ * @param string|null $localBasePath Base path to prepend to all local paths in $options. Defaults
+ * to $IP
+ *
+ * Below is a description for the $options array:
+ * @par Construction options:
+ * @code
+ * [
+ * // Base path to prepend to all local paths in $options. Defaults to $IP
+ * 'localBasePath' => [base path],
+ * // Path to JSON file that contains any of the settings below
+ * 'data' => [file path string]
+ * // CSS class prefix to use in all style rules
+ * 'prefix' => [CSS class prefix],
+ * // Alternatively: Format of CSS selector to use in all style rules
+ * 'selector' => [CSS selector template, variables: {prefix} {name} {variant}],
+ * // Alternatively: When using variants
+ * 'selectorWithoutVariant' => [CSS selector template, variables: {prefix} {name}],
+ * 'selectorWithVariant' => [CSS selector template, variables: {prefix} {name} {variant}],
+ * // List of variants that may be used for the image files
+ * 'variants' => [
+ * // This level of nesting can be omitted if you use the same images for every skin
+ * [skin name (or 'default')] => [
+ * [variant name] => [
+ * 'color' => [color string, e.g. '#ffff00'],
+ * 'global' => [boolean, if true, this variant is available
+ * for all images of this type],
+ * ],
+ * ...
+ * ],
+ * ...
+ * ],
+ * // List of image files and their options
+ * 'images' => [
+ * // This level of nesting can be omitted if you use the same images for every skin
+ * [skin name (or 'default')] => [
+ * [icon name] => [
+ * 'file' => [file path string or array whose values are file path strings
+ * and whose keys are 'default', 'ltr', 'rtl', a single
+ * language code like 'en', or a list of language codes like
+ * 'en,de,ar'],
+ * 'variants' => [array of variant name strings, variants
+ * available for this image],
+ * ],
+ * ...
+ * ],
+ * ...
+ * ],
+ * ]
+ * @endcode
+ * @throws InvalidArgumentException
+ */
+ public function __construct( array $options = [], $localBasePath = null ) {
+ $this->localBasePath = static::extractLocalBasePath( $options, $localBasePath );
+
+ $this->definition = $options;
+ }
+
+ /**
+ * Parse definition and external JSON data, if referenced.
+ */
+ protected function loadFromDefinition() {
+ if ( $this->definition === null ) {
+ return;
+ }
+
+ $options = $this->definition;
+ $this->definition = null;
+
+ if ( isset( $options['data'] ) ) {
+ $dataPath = $this->getLocalPath( $options['data'] );
+ $data = json_decode( file_get_contents( $dataPath ), true );
+ $options = array_merge( $data, $options );
+ }
+
+ // Accepted combinations:
+ // * prefix
+ // * selector
+ // * selectorWithoutVariant + selectorWithVariant
+ // * prefix + selector
+ // * prefix + selectorWithoutVariant + selectorWithVariant
+
+ $prefix = isset( $options['prefix'] ) && $options['prefix'];
+ $selector = isset( $options['selector'] ) && $options['selector'];
+ $selectorWithoutVariant = isset( $options['selectorWithoutVariant'] )
+ && $options['selectorWithoutVariant'];
+ $selectorWithVariant = isset( $options['selectorWithVariant'] )
+ && $options['selectorWithVariant'];
+
+ if ( $selectorWithoutVariant && !$selectorWithVariant ) {
+ throw new InvalidArgumentException(
+ "Given 'selectorWithoutVariant' but no 'selectorWithVariant'."
+ );
+ }
+ if ( $selectorWithVariant && !$selectorWithoutVariant ) {
+ throw new InvalidArgumentException(
+ "Given 'selectorWithVariant' but no 'selectorWithoutVariant'."
+ );
+ }
+ if ( $selector && $selectorWithVariant ) {
+ throw new InvalidArgumentException(
+ "Incompatible 'selector' and 'selectorWithVariant'+'selectorWithoutVariant' given."
+ );
+ }
+ if ( !$prefix && !$selector && !$selectorWithVariant ) {
+ throw new InvalidArgumentException(
+ "None of 'prefix', 'selector' or 'selectorWithVariant'+'selectorWithoutVariant' given."
+ );
+ }
+
+ foreach ( $options as $member => $option ) {
+ switch ( $member ) {
+ case 'images':
+ case 'variants':
+ if ( !is_array( $option ) ) {
+ throw new InvalidArgumentException(
+ "Invalid list error. '$option' given, array expected."
+ );
+ }
+ if ( !isset( $option['default'] ) ) {
+ // Backwards compatibility
+ $option = [ 'default' => $option ];
+ }
+ foreach ( $option as $skin => $data ) {
+ if ( !is_array( $data ) ) {
+ throw new InvalidArgumentException(
+ "Invalid list error. '$data' given, array expected."
+ );
+ }
+ }
+ $this->{$member} = $option;
+ break;
+
+ case 'useDataURI':
+ $this->{$member} = (bool)$option;
+ break;
+ case 'defaultColor':
+ case 'prefix':
+ case 'selectorWithoutVariant':
+ case 'selectorWithVariant':
+ $this->{$member} = (string)$option;
+ break;
+
+ case 'selector':
+ $this->selectorWithoutVariant = $this->selectorWithVariant = (string)$option;
+ }
+ }
+ }
+
+ /**
+ * Get CSS class prefix used by this module.
+ * @return string
+ */
+ public function getPrefix() {
+ $this->loadFromDefinition();
+ return $this->prefix;
+ }
+
+ /**
+ * Get CSS selector templates used by this module.
+ * @return string[]
+ */
+ public function getSelectors() {
+ $this->loadFromDefinition();
+ return [
+ 'selectorWithoutVariant' => $this->selectorWithoutVariant,
+ 'selectorWithVariant' => $this->selectorWithVariant,
+ ];
+ }
+
+ /**
+ * Get an Image object for given image.
+ * @param string $name Image name
+ * @param Context $context
+ * @return Image|null
+ */
+ public function getImage( $name, Context $context ): ?Image {
+ $this->loadFromDefinition();
+ $images = $this->getImages( $context );
+ return $images[$name] ?? null;
+ }
+
+ /**
+ * Get Image objects for all images.
+ * @param Context $context
+ * @return Image[] Array keyed by image name
+ */
+ public function getImages( Context $context ): array {
+ $skin = $context->getSkin();
+ if ( $this->imageObjects === null ) {
+ $this->loadFromDefinition();
+ $this->imageObjects = [];
+ }
+ if ( !isset( $this->imageObjects[$skin] ) ) {
+ $this->imageObjects[$skin] = [];
+ if ( !isset( $this->images[$skin] ) ) {
+ $this->images[$skin] = $this->images['default'] ?? [];
+ }
+ foreach ( $this->images[$skin] as $name => $options ) {
+ $fileDescriptor = is_array( $options ) ? $options['file'] : $options;
+
+ $allowedVariants = array_merge(
+ ( is_array( $options ) && isset( $options['variants'] ) ) ? $options['variants'] : [],
+ $this->getGlobalVariants( $context )
+ );
+ if ( isset( $this->variants[$skin] ) ) {
+ $variantConfig = array_intersect_key(
+ $this->variants[$skin],
+ array_fill_keys( $allowedVariants, true )
+ );
+ } else {
+ $variantConfig = [];
+ }
+
+ $image = new Image(
+ $name,
+ $this->getName(),
+ $fileDescriptor,
+ $this->localBasePath,
+ $variantConfig,
+ $this->defaultColor
+ );
+ $this->imageObjects[$skin][$image->getName()] = $image;
+ }
+ }
+
+ return $this->imageObjects[$skin];
+ }
+
+ /**
+ * Get list of variants in this module that are 'global', i.e., available
+ * for every image regardless of image options.
+ * @param Context $context
+ * @return string[]
+ */
+ public function getGlobalVariants( Context $context ): array {
+ $skin = $context->getSkin();
+ if ( $this->globalVariants === null ) {
+ $this->loadFromDefinition();
+ $this->globalVariants = [];
+ }
+ if ( !isset( $this->globalVariants[$skin] ) ) {
+ $this->globalVariants[$skin] = [];
+ if ( !isset( $this->variants[$skin] ) ) {
+ $this->variants[$skin] = $this->variants['default'] ?? [];
+ }
+ foreach ( $this->variants[$skin] as $name => $config ) {
+ if ( $config['global'] ?? false ) {
+ $this->globalVariants[$skin][] = $name;
+ }
+ }
+ }
+
+ return $this->globalVariants[$skin];
+ }
+
+ /**
+ * @param Context $context
+ * @return array
+ */
+ public function getStyles( Context $context ): array {
+ $this->loadFromDefinition();
+
+ // Build CSS rules
+ $rules = [];
+ $script = $context->getResourceLoader()->getLoadScript( $this->getSource() );
+ $selectors = $this->getSelectors();
+
+ foreach ( $this->getImages( $context ) as $name => $image ) {
+ $declarations = $this->getStyleDeclarations( $context, $image, $script );
+ $selector = strtr(
+ $selectors['selectorWithoutVariant'],
+ [
+ '{prefix}' => $this->getPrefix(),
+ '{name}' => $name,
+ '{variant}' => '',
+ ]
+ );
+ $rules[] = "$selector {\n\t$declarations\n}";
+
+ foreach ( $image->getVariants() as $variant ) {
+ $declarations = $this->getStyleDeclarations( $context, $image, $script, $variant );
+ $selector = strtr(
+ $selectors['selectorWithVariant'],
+ [
+ '{prefix}' => $this->getPrefix(),
+ '{name}' => $name,
+ '{variant}' => $variant,
+ ]
+ );
+ $rules[] = "$selector {\n\t$declarations\n}";
+ }
+ }
+
+ $style = implode( "\n", $rules );
+ return [ 'all' => $style ];
+ }
+
+ /**
+ * This method must not be used by getDefinitionSummary as doing so would cause
+ * an infinite loop (we use ResourceLoaderImage::getUrl below which calls
+ * Module:getVersionHash, which calls Module::getDefinitionSummary).
+ *
+ * @param Context $context
+ * @param Image $image Image to get the style for
+ * @param string $script URL to load.php
+ * @param string|null $variant Variant to get the style for
+ * @return string
+ */
+ private function getStyleDeclarations(
+ Context $context,
+ Image $image,
+ $script,
+ $variant = null
+ ) {
+ $imageDataUri = $this->useDataURI ? $image->getDataUri( $context, $variant, 'original' ) : false;
+ $primaryUrl = $imageDataUri ?: $image->getUrl( $context, $script, $variant, 'original' );
+ $declarations = $this->getCssDeclarations(
+ $primaryUrl,
+ $image->getUrl( $context, $script, $variant, 'rasterized' )
+ );
+ return implode( "\n\t", $declarations );
+ }
+
+ /**
+ * SVG support using a transparent gradient to guarantee cross-browser
+ * compatibility (browsers able to understand gradient syntax support also SVG).
+ * http://pauginer.tumblr.com/post/36614680636/invisible-gradient-technique
+ *
+ * Keep synchronized with the .background-image-svg LESS mixin in
+ * /resources/src/mediawiki.less/mediawiki.mixins.less.
+ *
+ * @param string $primary Primary URI
+ * @param string $fallback Fallback URI
+ * @return string[] CSS declarations to use given URIs as background-image
+ */
+ protected function getCssDeclarations( $primary, $fallback ): array {
+ $primaryUrl = CSSMin::buildUrlValue( $primary );
+ $fallbackUrl = CSSMin::buildUrlValue( $fallback );
+ return [
+ "background-image: $fallbackUrl;",
+ "background-image: linear-gradient(transparent, transparent), $primaryUrl;",
+ ];
+ }
+
+ /**
+ * @return bool
+ */
+ public function supportsURLLoading() {
+ return false;
+ }
+
+ /**
+ * Get the definition summary for this module.
+ *
+ * @param Context $context
+ * @return array
+ */
+ public function getDefinitionSummary( Context $context ) {
+ $this->loadFromDefinition();
+ $summary = parent::getDefinitionSummary( $context );
+
+ $options = [];
+ foreach ( [
+ 'localBasePath',
+ 'images',
+ 'variants',
+ 'prefix',
+ 'selectorWithoutVariant',
+ 'selectorWithVariant',
+ ] as $member ) {
+ $options[$member] = $this->{$member};
+ }
+
+ $summary[] = [
+ 'options' => $options,
+ 'fileHashes' => $this->getFileHashes( $context ),
+ ];
+ return $summary;
+ }
+
+ /**
+ * Helper method for getDefinitionSummary.
+ * @param Context $context
+ * @return array
+ */
+ private function getFileHashes( Context $context ) {
+ $this->loadFromDefinition();
+ $files = [];
+ foreach ( $this->getImages( $context ) as $name => $image ) {
+ $files[] = $image->getPath( $context );
+ }
+ $files = array_values( array_unique( $files ) );
+ return array_map( [ __CLASS__, 'safeFileHash' ], $files );
+ }
+
+ /**
+ * @param string|FilePath $path
+ * @return string
+ */
+ protected function getLocalPath( $path ) {
+ if ( $path instanceof FilePath ) {
+ return $path->getLocalPath();
+ }
+
+ return "{$this->localBasePath}/$path";
+ }
+
+ /**
+ * Extract a local base path from module definition information.
+ *
+ * @param array $options Module definition
+ * @param string|null $localBasePath Path to use if not provided in module definition. Defaults
+ * to $IP.
+ * @return string Local base path
+ */
+ public static function extractLocalBasePath( array $options, $localBasePath = null ) {
+ global $IP;
+
+ if ( $localBasePath === null ) {
+ $localBasePath = $IP;
+ }
+
+ if ( array_key_exists( 'localBasePath', $options ) ) {
+ $localBasePath = (string)$options['localBasePath'];
+ }
+
+ return $localBasePath;
+ }
+
+ /**
+ * @return string
+ */
+ public function getType() {
+ return self::LOAD_STYLES;
+ }
+}
+
+/** @deprecated since 1.39 */
+class_alias( ImageModule::class, 'ResourceLoaderImageModule' );
diff --git a/includes/ResourceLoader/LanguageDataModule.php b/includes/ResourceLoader/LanguageDataModule.php
new file mode 100644
index 000000000000..a1b420797033
--- /dev/null
+++ b/includes/ResourceLoader/LanguageDataModule.php
@@ -0,0 +1,87 @@
+<?php
+/**
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation; either version 2 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License along
+ * with this program; if not, write to the Free Software Foundation, Inc.,
+ * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @author Santhosh Thottingal
+ */
+
+namespace MediaWiki\ResourceLoader;
+
+use LanguageCode;
+use MediaWiki\MediaWikiServices;
+
+/**
+ * Module for populating language specific data, such as grammar forms.
+ *
+ * @ingroup ResourceLoader
+ * @internal
+ */
+class LanguageDataModule extends FileModule {
+ protected $targets = [ 'desktop', 'mobile' ];
+
+ /**
+ * Get all the dynamic data for the content language to an array.
+ *
+ * @internal Only public for use by GenerateJqueryMsgData (tests)
+ * @param string $langCode
+ * @return array
+ */
+ public static function getData( $langCode ): array {
+ $language = MediaWikiServices::getInstance()->getLanguageFactory()
+ ->getLanguage( $langCode );
+ return [
+ 'digitTransformTable' => $language->digitTransformTable(),
+ 'separatorTransformTable' => $language->separatorTransformTable(),
+ 'minimumGroupingDigits' => $language->minimumGroupingDigits(),
+ 'grammarForms' => $language->getGrammarForms(),
+ 'grammarTransformations' => $language->getGrammarTransformations(),
+ 'pluralRules' => $language->getPluralRules(),
+ 'digitGroupingPattern' => $language->digitGroupingPattern(),
+ 'fallbackLanguages' => $language->getFallbackLanguages(),
+ 'bcp47Map' => LanguageCode::getNonstandardLanguageCodeMapping(),
+ ];
+ }
+
+ /**
+ * @param Context $context
+ * @return string JavaScript code
+ */
+ public function getScript( Context $context ) {
+ return parent::getScript( $context )
+ . 'mw.language.setData('
+ . $context->encodeJson( $context->getLanguage() ) . ','
+ . $context->encodeJson( self::getData( $context->getLanguage() ) )
+ . ');';
+ }
+
+ /**
+ * @return bool
+ */
+ public function enableModuleContentVersion() {
+ return true;
+ }
+
+ /**
+ * @return bool
+ */
+ public function supportsURLLoading() {
+ return false;
+ }
+}
+
+/** @deprecated since 1.39 */
+class_alias( LanguageDataModule::class, 'ResourceLoaderLanguageDataModule' );
diff --git a/includes/ResourceLoader/LessVarFileModule.php b/includes/ResourceLoader/LessVarFileModule.php
new file mode 100644
index 000000000000..6674da1a7017
--- /dev/null
+++ b/includes/ResourceLoader/LessVarFileModule.php
@@ -0,0 +1,138 @@
+<?php
+/**
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation; either version 2 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License along
+ * with this program; if not, write to the Free Software Foundation, Inc.,
+ * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ */
+namespace MediaWiki\ResourceLoader;
+
+use Wikimedia\Minify\CSSMin;
+
+// Per https://phabricator.wikimedia.org/T241091
+// phpcs:disable MediaWiki.Commenting.FunctionAnnotations.UnrecognizedAnnotation
+
+/**
+ * Module augmented with context-specific LESS variables.
+ *
+ * @ingroup ResourceLoader
+ * @since 1.32
+ */
+class LessVarFileModule extends FileModule {
+ protected $lessVariables = [];
+
+ /**
+ * @inheritDoc
+ */
+ public function __construct(
+ array $options = [],
+ $localBasePath = null,
+ $remoteBasePath = null
+ ) {
+ if ( isset( $options['lessMessages'] ) ) {
+ $this->lessVariables = $options['lessMessages'];
+ }
+ parent::__construct( $options, $localBasePath, $remoteBasePath );
+ }
+
+ /**
+ * @inheritDoc
+ */
+ public function getMessages() {
+ // Overload so MessageBlobStore can detect updates to messages and purge as needed.
+ return array_merge( $this->messages, $this->lessVariables );
+ }
+
+ /**
+ * Return a subset of messages from a JSON string representation.
+ *
+ * @param string|null $blob JSON, or null if module has no declared messages
+ * @param string[] $allowed
+ * @return array
+ */
+ private function pluckFromMessageBlob( $blob, array $allowed ): array {
+ $data = $blob ? json_decode( $blob, true ) : [];
+ // Keep only the messages intended for LESS export
+ // (opposite of getMessages essentially).
+ return array_intersect_key( $data, array_fill_keys( $allowed, true ) );
+ }
+
+ /**
+ * @inheritDoc
+ */
+ protected function getMessageBlob( Context $context ) {
+ $blob = parent::getMessageBlob( $context );
+ if ( !$blob ) {
+ // If module has no blob, preserve null to avoid needless WAN cache allocation
+ // client output for modules without messages.
+ return $blob;
+ }
+ return json_encode( (object)$this->pluckFromMessageBlob( $blob, $this->messages ) );
+ }
+
+ // phpcs:disable MediaWiki.Commenting.DocComment.SpacingDocTag, Squiz.WhiteSpace.FunctionSpacing.Before
+ /**
+ * Escape and wrap a message value as literal string for LESS.
+ *
+ * This mostly lets CSSMin escape it and wrap it, but also escape single quotes
+ * for compatibility with LESS's feature of variable interpolation into other strings.
+ * This is relatively rare for most use of LESS, but for messages it is quite common.
+ *
+ * Example:
+ *
+ * @code
+ * @x: "foo's";
+ * .eg { content: 'Value is @{x}'; }
+ * @endcode
+ *
+ * Produces output: `.eg { content: 'Value is foo's'; }`.
+ * (Tested in less.php 1.8.1, and Less.js 2.7)
+ *
+ * @param string $msg
+ * @return string wrapped LESS variable value
+ */
+ private static function wrapAndEscapeMessage( $msg ) {
+ return str_replace( "'", "\'", CSSMin::serializeStringValue( $msg ) );
+ }
+
+ // phpcs:enable MediaWiki.Commenting.DocComment.SpacingDocTag, Squiz.WhiteSpace.FunctionSpacing.Before
+
+ /**
+ * Get language-specific LESS variables for this module.
+ *
+ * @param Context $context
+ * @return array LESS variables
+ */
+ protected function getLessVars( Context $context ) {
+ $vars = parent::getLessVars( $context );
+
+ $blob = parent::getMessageBlob( $context );
+ $messages = $this->pluckFromMessageBlob( $blob, $this->lessVariables );
+
+ // It is important that we iterate the declared list from $this->lessVariables,
+ // and not $messages since in the case of undefined messages, the key is
+ // omitted entirely from the blob. This emits a log warning for developers,
+ // but we must still carry on and produce a valid LESS variable declaration,
+ // to avoid a LESS syntax error (T267785).
+ foreach ( $this->lessVariables as $msgKey ) {
+ $vars['msg-' . $msgKey] = self::wrapAndEscapeMessage( $messages[$msgKey] ?? "â§¼${msgKey}â§½" );
+ }
+
+ return $vars;
+ }
+}
+
+/** @deprecated since 1.39 */
+class_alias( LessVarFileModule::class, 'ResourceLoaderLessVarFileModule' );
diff --git a/includes/ResourceLoader/MessageBlobStore.php b/includes/ResourceLoader/MessageBlobStore.php
new file mode 100644
index 000000000000..33be0abdf601
--- /dev/null
+++ b/includes/ResourceLoader/MessageBlobStore.php
@@ -0,0 +1,266 @@
+<?php
+/**
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation; either version 2 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License along
+ * with this program; if not, write to the Free Software Foundation, Inc.,
+ * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @author Roan Kattouw
+ * @author Trevor Parscal
+ */
+
+namespace MediaWiki\ResourceLoader;
+
+use FormatJson;
+use MediaWiki\MediaWikiServices;
+use Psr\Log\LoggerAwareInterface;
+use Psr\Log\LoggerInterface;
+use Psr\Log\NullLogger;
+use WANObjectCache;
+use Wikimedia\Rdbms\Database;
+
+/**
+ * PHP 7.2 hack to work around the issue described at https://phabricator.wikimedia.org/T166010#5962098
+ * Load the ResourceLoader class when MessageBlobStore is loaded.
+ * phpcs:disable Generic.Files.OneObjectStructurePerFile.MultipleFound
+ * phpcs:disable MediaWiki.Files.ClassMatchesFilename.NotMatch
+ */
+class ResourceLoader72Hack extends ResourceLoader {
+}
+
+/**
+ * This class generates message blobs for use by ResourceLoader.
+ *
+ * A message blob is a JSON object containing the interface messages for a
+ * certain module in a certain language.
+ *
+ * @ingroup ResourceLoader
+ * @since 1.17
+ */
+class MessageBlobStore implements LoggerAwareInterface {
+ /** @var ResourceLoader */
+ private $resourceloader;
+
+ /** @var LoggerInterface */
+ protected $logger;
+
+ /** @var WANObjectCache */
+ protected $wanCache;
+
+ /**
+ * @param ResourceLoader $rl
+ * @param LoggerInterface|null $logger
+ * @param WANObjectCache|null $wanObjectCache
+ */
+ public function __construct(
+ ResourceLoader $rl,
+ ?LoggerInterface $logger,
+ ?WANObjectCache $wanObjectCache
+ ) {
+ $this->resourceloader = $rl;
+ $this->logger = $logger ?: new NullLogger();
+
+ // NOTE: when changing this assignment, make sure the code in the instantiator for
+ // LocalisationCache which calls MessageBlobStore::clearGlobalCacheEntry() uses the
+ // same cache object.
+ $this->wanCache = $wanObjectCache ?: MediaWikiServices::getInstance()
+ ->getMainWANObjectCache();
+ }
+
+ /**
+ * @since 1.27
+ * @param LoggerInterface $logger
+ */
+ public function setLogger( LoggerInterface $logger ) {
+ $this->logger = $logger;
+ }
+
+ /**
+ * Get the message blob for a module
+ *
+ * @since 1.27
+ * @param Module $module
+ * @param string $lang Language code
+ * @return string JSON
+ */
+ public function getBlob( Module $module, $lang ) {
+ $blobs = $this->getBlobs( [ $module->getName() => $module ], $lang );
+ return $blobs[$module->getName()];
+ }
+
+ /**
+ * Get the message blobs for a set of modules
+ *
+ * @since 1.27
+ * @param Module[] $modules Array of module objects keyed by name
+ * @param string $lang Language code
+ * @return string[] An array mapping module names to message blobs
+ */
+ public function getBlobs( array $modules, $lang ) {
+ // Each cache key for a message blob by module name and language code also has a generic
+ // check key without language code. This is used to invalidate any and all language subkeys
+ // that exist for a module from the updateMessage() method.
+ $cache = $this->wanCache;
+ $checkKeys = [
+ // Global check key, see clear()
+ $cache->makeGlobalKey( __CLASS__ )
+ ];
+ $cacheKeys = [];
+ foreach ( $modules as $name => $module ) {
+ $cacheKey = $this->makeCacheKey( $module, $lang );
+ $cacheKeys[$name] = $cacheKey;
+ // Per-module check key, see updateMessage()
+ $checkKeys[$cacheKey][] = $cache->makeKey( __CLASS__, $name );
+ }
+ $curTTLs = [];
+ $result = $cache->getMulti( array_values( $cacheKeys ), $curTTLs, $checkKeys );
+
+ $blobs = [];
+ foreach ( $modules as $name => $module ) {
+ $key = $cacheKeys[$name];
+ if ( !isset( $result[$key] ) || $curTTLs[$key] === null || $curTTLs[$key] < 0 ) {
+ $blobs[$name] = $this->recacheMessageBlob( $key, $module, $lang );
+ } else {
+ // Use unexpired cache
+ $blobs[$name] = $result[$key];
+ }
+ }
+ return $blobs;
+ }
+
+ /**
+ * @since 1.27
+ * @param Module $module
+ * @param string $lang
+ * @return string Cache key
+ */
+ private function makeCacheKey( Module $module, $lang ) {
+ $messages = array_values( array_unique( $module->getMessages() ) );
+ sort( $messages );
+ return $this->wanCache->makeKey( __CLASS__, $module->getName(), $lang,
+ md5( json_encode( $messages ) )
+ );
+ }
+
+ /**
+ * @since 1.27
+ * @param string $cacheKey
+ * @param Module $module
+ * @param string $lang
+ * @return string JSON blob
+ */
+ protected function recacheMessageBlob( $cacheKey, Module $module, $lang ) {
+ $blob = $this->generateMessageBlob( $module, $lang );
+ $cache = $this->wanCache;
+ $cache->set( $cacheKey, $blob,
+ // Add part of a day to TTL to avoid all modules expiring at once
+ $cache::TTL_WEEK + mt_rand( 0, $cache::TTL_DAY ),
+ Database::getCacheSetOptions( wfGetDB( DB_REPLICA ) )
+ );
+ return $blob;
+ }
+
+ /**
+ * Invalidate cache keys for modules using this message key.
+ * Called by MessageCache when a message has changed.
+ *
+ * @param string $key Message key
+ */
+ public function updateMessage( $key ): void {
+ $moduleNames = $this->resourceloader->getModulesByMessage( $key );
+ foreach ( $moduleNames as $moduleName ) {
+ // Uses a holdoff to account for database replica DB lag (for MessageCache)
+ $this->wanCache->touchCheckKey( $this->wanCache->makeKey( __CLASS__, $moduleName ) );
+ }
+ }
+
+ /**
+ * Invalidate cache keys for all known modules.
+ */
+ public function clear() {
+ self::clearGlobalCacheEntry( $this->wanCache );
+ }
+
+ /**
+ * Invalidate cache keys for all known modules.
+ *
+ * Called by LocalisationCache after cache is regenerated.
+ *
+ * @param WANObjectCache $cache
+ */
+ public static function clearGlobalCacheEntry( WANObjectCache $cache ) {
+ // Disable hold-off because:
+ // - LocalisationCache is populated by messages on-disk and don't have DB lag,
+ // thus there is no need for hold off. We only clear it after new localisation
+ // updates are known to be deployed to all servers.
+ // - This global check key invalidates message blobs for all modules for all wikis
+ // in cache contexts (e.g. languages, skins). Setting a hold-off on this key could
+ // cause a cache stampede since no values would be stored for several seconds.
+ $cache->touchCheckKey( $cache->makeGlobalKey( __CLASS__ ), $cache::HOLDOFF_TTL_NONE );
+ }
+
+ /**
+ * @since 1.27
+ * @param string $key Message key
+ * @param string $lang Language code
+ * @return string|null
+ */
+ protected function fetchMessage( $key, $lang ) {
+ $message = wfMessage( $key )->inLanguage( $lang );
+ if ( !$message->exists() ) {
+ $this->logger->warning( 'Failed to find {messageKey} ({lang})', [
+ 'messageKey' => $key,
+ 'lang' => $lang,
+ ] );
+ $value = null;
+ } else {
+ $value = $message->plain();
+ }
+ return $value;
+ }
+
+ /**
+ * Generate the message blob for a given module in a given language.
+ *
+ * @param Module $module
+ * @param string $lang Language code
+ * @return string JSON blob
+ */
+ private function generateMessageBlob( Module $module, $lang ) {
+ $messages = [];
+ foreach ( $module->getMessages() as $key ) {
+ $value = $this->fetchMessage( $key, $lang );
+ // If the message does not exist, omit it from the blob so that
+ // client-side mw.message may do its own existence handling.
+ if ( $value !== null ) {
+ $messages[$key] = $value;
+ }
+ }
+
+ $json = FormatJson::encode( (object)$messages, false, FormatJson::UTF8_OK );
+ // @codeCoverageIgnoreStart
+ if ( $json === false ) {
+ $this->logger->warning( 'Failed to encode message blob for {module} ({lang})', [
+ 'module' => $module->getName(),
+ 'lang' => $lang,
+ ] );
+ $json = '{}';
+ }
+ // codeCoverageIgnoreEnd
+ return $json;
+ }
+}
+
+/** @deprecated since 1.39 */
+class_alias( MessageBlobStore::class, 'MessageBlobStore' );
diff --git a/includes/ResourceLoader/Module.php b/includes/ResourceLoader/Module.php
new file mode 100644
index 000000000000..97d7b86d660b
--- /dev/null
+++ b/includes/ResourceLoader/Module.php
@@ -0,0 +1,1128 @@
+<?php
+/**
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation; either version 2 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License along
+ * with this program; if not, write to the Free Software Foundation, Inc.,
+ * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @author Trevor Parscal
+ * @author Roan Kattouw
+ */
+
+namespace MediaWiki\ResourceLoader;
+
+use Config;
+use Exception;
+use FileContentsHasher;
+use JSParser;
+use LogicException;
+use MediaWiki\HookContainer\HookContainer;
+use MediaWiki\MainConfigNames;
+use MediaWiki\MediaWikiServices;
+use Psr\Log\LoggerAwareInterface;
+use Psr\Log\LoggerInterface;
+use Psr\Log\NullLogger;
+use RuntimeException;
+use Wikimedia\RelPath;
+use Wikimedia\RequestTimeout\TimeoutException;
+
+/**
+ * Abstraction for ResourceLoader modules, with name registration and maxage functionality.
+ *
+ * @see $wgResourceModules for the available options when registering a module.
+ * @stable to extend
+ * @ingroup ResourceLoader
+ * @since 1.17
+ */
+abstract class Module implements LoggerAwareInterface {
+ /** @var Config */
+ protected $config;
+ /** @var LoggerInterface */
+ protected $logger;
+
+ /**
+ * Script and style modules form a hierarchy of trustworthiness, with core modules
+ * like skins and jQuery as most trustworthy, and user scripts as least trustworthy. We can
+ * limit the types of scripts and styles we allow to load on, say, sensitive special
+ * pages like Special:UserLogin and Special:Preferences
+ * @var int
+ */
+ protected $origin = self::ORIGIN_CORE_SITEWIDE;
+
+ /** @var string|null Module name */
+ protected $name = null;
+ /** @var string[] What client platforms the module targets (e.g. desktop, mobile) */
+ protected $targets = [ 'desktop' ];
+ /** @var string[]|null Skin names */
+ protected $skins = null;
+
+ /** @var array Map of (variant => indirect file dependencies) */
+ protected $fileDeps = [];
+ /** @var array Map of (language => in-object cache for message blob) */
+ protected $msgBlobs = [];
+ /** @var array Map of (context hash => cached module version hash) */
+ protected $versionHash = [];
+ /** @var array Map of (context hash => cached module content) */
+ protected $contents = [];
+
+ /** @var HookRunner|null */
+ private $hookRunner;
+
+ /** @var callback Function of (module name, variant) to get indirect file dependencies */
+ private $depLoadCallback;
+ /** @var callback Function of (module name, variant) to get indirect file dependencies */
+ private $depSaveCallback;
+
+ /** @var string|bool Deprecation string or true if deprecated; false otherwise */
+ protected $deprecated = false;
+
+ /** @var string Scripts only */
+ public const TYPE_SCRIPTS = 'scripts';
+ /** @var string Styles only */
+ public const TYPE_STYLES = 'styles';
+ /** @var string Scripts and styles */
+ public const TYPE_COMBINED = 'combined';
+
+ /** @var string */
+ public const GROUP_SITE = 'site';
+ /** @var string */
+ public const GROUP_USER = 'user';
+ /** @var string */
+ public const GROUP_PRIVATE = 'private';
+ /** @var string */
+ public const GROUP_NOSCRIPT = 'noscript';
+
+ /** @var string Module only has styles (loaded via <style> or <link rel=stylesheet>) */
+ public const LOAD_STYLES = 'styles';
+ /** @var string Module may have other resources (loaded via mw.loader from a script) */
+ public const LOAD_GENERAL = 'general';
+
+ /** @var int Sitewide core module like a skin file or jQuery component */
+ public const ORIGIN_CORE_SITEWIDE = 1;
+ /** @var int Per-user module generated by the software */
+ public const ORIGIN_CORE_INDIVIDUAL = 2;
+ /**
+ * Sitewide module generated from user-editable files, like MediaWiki:Common.js,
+ * or modules accessible to multiple users, such as those generated by the Gadgets extension.
+ * @var int
+ */
+ public const ORIGIN_USER_SITEWIDE = 3;
+ /** @var int Per-user module generated from user-editable files, like User:Me/vector.js */
+ public const ORIGIN_USER_INDIVIDUAL = 4;
+ /** @var int An access constant; make sure this is kept as the largest number in this group */
+ public const ORIGIN_ALL = 10;
+
+ /** @var int Cache version for user-script JS validation errors from validateScriptFile(). */
+ private const USERJSPARSE_CACHE_VERSION = 2;
+
+ /**
+ * Get this module's name. This is set when the module is registered
+ * with ResourceLoader::register()
+ *
+ * @return string|null Name (string) or null if no name was set
+ */
+ public function getName() {
+ return $this->name;
+ }
+
+ /**
+ * Set this module's name. This is called by ResourceLoader::register()
+ * when registering the module. Other code should not call this.
+ *
+ * @param string $name
+ */
+ public function setName( $name ) {
+ $this->name = $name;
+ }
+
+ /**
+ * Provide overrides for skinStyles to modules that support that.
+ *
+ * This MUST be called after self::setName().
+ *
+ * @since 1.37
+ * @see $wgResourceModuleSkinStyles
+ * @param array $moduleSkinStyles
+ */
+ public function setSkinStylesOverride( array $moduleSkinStyles ): void {
+ // Stub, only supported by FileModule currently.
+ }
+
+ /**
+ * Inject the functions that load/save the indirect file path dependency list from storage
+ *
+ * @param callable $loadCallback Function of (module name, variant)
+ * @param callable $saveCallback Function of (module name, variant, current paths, stored paths)
+ * @since 1.35
+ */
+ public function setDependencyAccessCallbacks( callable $loadCallback, callable $saveCallback ) {
+ $this->depLoadCallback = $loadCallback;
+ $this->depSaveCallback = $saveCallback;
+ }
+
+ /**
+ * Get this module's origin. This is set when the module is registered
+ * with ResourceLoader::register()
+ *
+ * @return int ResourceLoaderModule class constant, the subclass default
+ * if not set manually
+ */
+ public function getOrigin() {
+ return $this->origin;
+ }
+
+ /**
+ * @param Context $context
+ * @return bool
+ */
+ public function getFlip( Context $context ) {
+ return MediaWikiServices::getInstance()->getContentLanguage()->getDir() !==
+ $context->getDirection();
+ }
+
+ /**
+ * Get JS representing deprecation information for the current module if available
+ *
+ * @param Context $context
+ * @return string JavaScript code
+ */
+ public function getDeprecationInformation( Context $context ) {
+ $deprecationInfo = $this->deprecated;
+ if ( $deprecationInfo ) {
+ $name = $this->getName();
+ $warning = 'This page is using the deprecated ResourceLoader module "' . $name . '".';
+ if ( is_string( $deprecationInfo ) ) {
+ $warning .= "\n" . $deprecationInfo;
+ }
+ return 'mw.log.warn(' . $context->encodeJson( $warning ) . ');';
+ } else {
+ return '';
+ }
+ }
+
+ /**
+ * Get all JS for this module for a given language and skin.
+ * Includes all relevant JS except loader scripts.
+ *
+ * For "plain" script modules, this should return a string with JS code. For multi-file modules
+ * where require() is used to load one file from another file, this should return an array
+ * structured as follows:
+ * [
+ * 'files' => [
+ * 'file1.js' => [ 'type' => 'script', 'content' => 'JS code' ],
+ * 'file2.js' => [ 'type' => 'script', 'content' => 'JS code' ],
+ * 'data.json' => [ 'type' => 'data', 'content' => array ]
+ * ],
+ * 'main' => 'file1.js'
+ * ]
+ *
+ * @stable to override
+ * @param Context $context
+ * @return string|array JavaScript code (string), or multi-file structure described above (array)
+ */
+ public function getScript( Context $context ) {
+ // Stub, override expected
+ return '';
+ }
+
+ /**
+ * Takes named templates by the module and returns an array mapping.
+ *
+ * @stable to override
+ * @return string[] Array of templates mapping template alias to content
+ */
+ public function getTemplates() {
+ // Stub, override expected.
+ return [];
+ }
+
+ /**
+ * @return Config
+ * @since 1.24
+ */
+ public function getConfig() {
+ if ( $this->config === null ) {
+ throw new RuntimeException( 'Config accessed before it is set' );
+ }
+
+ return $this->config;
+ }
+
+ /**
+ * @param Config $config
+ * @since 1.24
+ */
+ public function setConfig( Config $config ) {
+ $this->config = $config;
+ }
+
+ /**
+ * @since 1.27
+ * @param LoggerInterface $logger
+ */
+ public function setLogger( LoggerInterface $logger ) {
+ $this->logger = $logger;
+ }
+
+ /**
+ * @since 1.27
+ * @return LoggerInterface
+ */
+ protected function getLogger() {
+ if ( !$this->logger ) {
+ $this->logger = new NullLogger();
+ }
+ return $this->logger;
+ }
+
+ /**
+ * @internal For use only by ResourceLoader::getModule
+ * @param HookContainer $hookContainer
+ */
+ public function setHookContainer( HookContainer $hookContainer ): void {
+ $this->hookRunner = new HookRunner( $hookContainer );
+ }
+
+ /**
+ * Get a HookRunner for running core hooks.
+ *
+ * @internal For use only within core ResourceLoaderModule subclasses. Hook interfaces may be removed
+ * without notice.
+ * @return HookRunner
+ */
+ protected function getHookRunner(): HookRunner {
+ return $this->hookRunner;
+ }
+
+ /**
+ * Get alternative script URLs for legacy debug mode.
+ *
+ * The default behavior is to return a `load.php?only=scripts&module=<name>` URL.
+ *
+ * Module classes that merely wrap one or more other script files in production mode, may
+ * override this method to return an array of raw URLs for those underlying scripts,
+ * if those are individually web-accessible.
+ *
+ * The mw.loader client will load and execute each URL consecutively. This has the caveat of
+ * executing legacy debug scripts in the global scope, which is why non-package file modules
+ * tend to use file closures (T50886).
+ *
+ * This function MUST NOT be called, unless all the following are true:
+ *
+ * 1. We're in debug mode,
+ * 2. There is no `only=` parameter in the context,
+ * 3. self::supportsURLLoading() has returned true.
+ *
+ * Point 2 prevents an infinite loop since we use the `only=` mechanism in the return value.
+ * Overrides must similarly return with `only`, or return or a non-load.php URL.
+ *
+ * @stable to override
+ * @param Context $context
+ * @return string[]
+ */
+ public function getScriptURLsForDebug( Context $context ) {
+ $rl = $context->getResourceLoader();
+ $derivative = new DerivativeContext( $context );
+ $derivative->setModules( [ $this->getName() ] );
+ $derivative->setOnly( 'scripts' );
+
+ $url = $rl->createLoaderURL(
+ $this->getSource(),
+ $derivative
+ );
+
+ // Expand debug URL in case we are another wiki's module source (T255367)
+ $url = $rl->expandUrl( $this->getConfig()->get( MainConfigNames::Server ), $url );
+
+ return [ $url ];
+ }
+
+ /**
+ * Whether this module supports URL loading. If this function returns false,
+ * getScript() will be used even in cases (debug mode, no only param) where
+ * getScriptURLsForDebug() would normally be used instead.
+ *
+ * @stable to override
+ * @return bool
+ */
+ public function supportsURLLoading() {
+ return true;
+ }
+
+ /**
+ * Get all CSS for this module for a given skin.
+ *
+ * @stable to override
+ * @param Context $context
+ * @return array List of CSS strings or array of CSS strings keyed by media type.
+ * like [ 'screen' => '.foo { width: 0 }' ];
+ * or [ 'screen' => [ '.foo { width: 0 }' ] ];
+ */
+ public function getStyles( Context $context ) {
+ // Stub, override expected
+ return [];
+ }
+
+ /**
+ * Get the URL or URLs to load for this module's CSS in debug mode.
+ * The default behavior is to return a load.php?only=styles URL for
+ * the module, but file-based modules will want to override this to
+ * load the files directly
+ *
+ * This function must only be called when:
+ *
+ * 1. We're in debug mode,
+ * 2. There is no `only=` parameter and,
+ * 3. self::supportsURLLoading() returns true.
+ *
+ * See also getScriptURLsForDebug().
+ *
+ * @stable to override
+ * @param Context $context
+ * @return array [ mediaType => [ URL1, URL2, ... ], ... ]
+ */
+ public function getStyleURLsForDebug( Context $context ) {
+ $resourceLoader = $context->getResourceLoader();
+ $derivative = new DerivativeContext( $context );
+ $derivative->setModules( [ $this->getName() ] );
+ $derivative->setOnly( 'styles' );
+
+ $url = $resourceLoader->createLoaderURL(
+ $this->getSource(),
+ $derivative
+ );
+
+ return [ 'all' => [ $url ] ];
+ }
+
+ /**
+ * Get the messages needed for this module.
+ *
+ * To get a JSON blob with messages, use MessageBlobStore::get()
+ *
+ * @stable to override
+ * @return string[] List of message keys. Keys may occur more than once
+ */
+ public function getMessages() {
+ // Stub, override expected
+ return [];
+ }
+
+ /**
+ * Get the group this module is in.
+ *
+ * @stable to override
+ * @return string|null Group name
+ */
+ public function getGroup() {
+ // Stub, override expected
+ return null;
+ }
+
+ /**
+ * Get the source of this module. Should only be overridden for foreign modules.
+ *
+ * @stable to override
+ * @return string Source name, 'local' for local modules
+ */
+ public function getSource() {
+ // Stub, override expected
+ return 'local';
+ }
+
+ /**
+ * Get a list of modules this module depends on.
+ *
+ * Dependency information is taken into account when loading a module
+ * on the client side.
+ *
+ * Note: It is expected that $context will be made non-optional in the near
+ * future.
+ *
+ * @stable to override
+ * @param Context|null $context
+ * @return string[] List of module names as strings
+ */
+ public function getDependencies( Context $context = null ) {
+ // Stub, override expected
+ return [];
+ }
+
+ /**
+ * Get target(s) for the module, eg ['desktop'] or ['desktop', 'mobile']
+ *
+ * @stable to override
+ * @return string[]
+ */
+ public function getTargets() {
+ return $this->targets;
+ }
+
+ /**
+ * Get list of skins for which this module must be available to load.
+ *
+ * By default, modules are available to all skins.
+ *
+ * This information may be used by the startup module to optimise registrations
+ * based on the current skin.
+ *
+ * @stable to override
+ * @since 1.39
+ * @return string[]|null
+ */
+ public function getSkins(): ?array {
+ return $this->skins;
+ }
+
+ /**
+ * Get the module's load type.
+ *
+ * @stable to override
+ * @since 1.28
+ * @return string Module LOAD_* constant
+ */
+ public function getType() {
+ return self::LOAD_GENERAL;
+ }
+
+ /**
+ * Get the skip function.
+ *
+ * Modules that provide fallback functionality can provide a "skip function". This
+ * function, if provided, will be passed along to the module registry on the client.
+ * When this module is loaded (either directly or as a dependency of another module),
+ * then this function is executed first. If the function returns true, the module will
+ * instantly be considered "ready" without requesting the associated module resources.
+ *
+ * The value returned here must be valid javascript for execution in a private function.
+ * It must not contain the "function () {" and "}" wrapper though.
+ *
+ * @stable to override
+ * @return string|null A JavaScript function body returning a boolean value, or null
+ */
+ public function getSkipFunction() {
+ return null;
+ }
+
+ /**
+ * Whether the module requires ES6 support in the client.
+ *
+ * If the client does not support ES6, attempting to load a module that requires ES6 will
+ * result in an error.
+ *
+ * @stable to override
+ * @since 1.36
+ * @return bool
+ */
+ public function requiresES6() {
+ return false;
+ }
+
+ /**
+ * Get the indirect dependencies for this module pursuant to the skin/language context
+ *
+ * These are only image files referenced by the module's stylesheet
+ *
+ * If neither setFileDependencies() nor setDependencyLoadCallback() was called, this
+ * will simply return a placeholder with an empty file list
+ *
+ * @see ResourceLoader::setFileDependencies()
+ * @see ResourceLoader::saveFileDependencies()
+ *
+ * @param Context $context
+ * @return string[] List of absolute file paths
+ * @throws RuntimeException When setFileDependencies() has not yet been called
+ */
+ protected function getFileDependencies( Context $context ) {
+ $variant = self::getVary( $context );
+
+ if ( !isset( $this->fileDeps[$variant] ) ) {
+ if ( $this->depLoadCallback ) {
+ $this->fileDeps[$variant] =
+ call_user_func( $this->depLoadCallback, $this->getName(), $variant );
+ } else {
+ $this->getLogger()->info( __METHOD__ . ": no callback registered" );
+ $this->fileDeps[$variant] = [];
+ }
+ }
+
+ return $this->fileDeps[$variant];
+ }
+
+ /**
+ * Set the indirect dependencies for this module pursuant to the skin/language context
+ *
+ * These are only image files referenced by the module's stylesheet
+ *
+ * @see ResourceLoader::getFileDependencies()
+ * @see ResourceLoader::saveFileDependencies()
+ *
+ * @param Context $context
+ * @param string[] $paths List of absolute file paths
+ */
+ public function setFileDependencies( Context $context, array $paths ) {
+ $variant = self::getVary( $context );
+ $this->fileDeps[$variant] = $paths;
+ }
+
+ /**
+ * Save the indirect dependencies for this module pursuant to the skin/language context
+ *
+ * @param Context $context
+ * @param string[] $curFileRefs List of newly computed indirect file dependencies
+ * @since 1.27
+ */
+ protected function saveFileDependencies( Context $context, array $curFileRefs ) {
+ if ( !$this->depSaveCallback ) {
+ $this->getLogger()->info( __METHOD__ . ": no callback registered" );
+
+ return;
+ }
+
+ try {
+ // Pitfalls and performance considerations:
+ // 1. Don't keep updating the tracked paths due to duplicates or sorting.
+ // 2. Use relative paths to avoid ghost entries when $IP changes. (T111481)
+ // 3. Don't needlessly replace tracked paths with the same value
+ // just because $IP changed (e.g. when upgrading a wiki).
+ // 4. Don't create an endless replace loop on every request for this
+ // module when '../' is used anywhere. Even though both are expanded
+ // (one expanded by getFileDependencies from the DB, the other is
+ // still raw as originally read by RL), the latter has not
+ // been normalized yet.
+ call_user_func(
+ $this->depSaveCallback,
+ $this->getName(),
+ self::getVary( $context ),
+ self::getRelativePaths( $curFileRefs ),
+ self::getRelativePaths( $this->getFileDependencies( $context ) )
+ );
+ } catch ( TimeoutException $e ) {
+ throw $e;
+ } catch ( Exception $e ) {
+ $this->getLogger()->warning(
+ __METHOD__ . ": failed to update dependencies: {$e->getMessage()}",
+ [ 'exception' => $e ]
+ );
+ }
+ }
+
+ /**
+ * Make file paths relative to MediaWiki directory.
+ *
+ * This is used to make file paths safe for storing in a database without the paths
+ * becoming stale or incorrect when MediaWiki is moved or upgraded (T111481).
+ *
+ * @since 1.27
+ * @param array $filePaths
+ * @return array
+ */
+ public static function getRelativePaths( array $filePaths ) {
+ global $IP;
+ return array_map( static function ( $path ) use ( $IP ) {
+ return RelPath::getRelativePath( $path, $IP );
+ }, $filePaths );
+ }
+
+ /**
+ * Expand directories relative to $IP.
+ *
+ * @since 1.27
+ * @param array $filePaths
+ * @return array
+ */
+ public static function expandRelativePaths( array $filePaths ) {
+ global $IP;
+ return array_map( static function ( $path ) use ( $IP ) {
+ return RelPath::joinPath( $IP, $path );
+ }, $filePaths );
+ }
+
+ /**
+ * Get the hash of the message blob.
+ *
+ * @stable to override
+ * @since 1.27
+ * @param Context $context
+ * @return string|null JSON blob or null if module has no messages
+ */
+ protected function getMessageBlob( Context $context ) {
+ if ( !$this->getMessages() ) {
+ // Don't bother consulting MessageBlobStore
+ return null;
+ }
+ // Message blobs may only vary language, not by context keys
+ $lang = $context->getLanguage();
+ if ( !isset( $this->msgBlobs[$lang] ) ) {
+ $this->getLogger()->warning( 'Message blob for {module} should have been preloaded', [
+ 'module' => $this->getName(),
+ ] );
+ $store = $context->getResourceLoader()->getMessageBlobStore();
+ $this->msgBlobs[$lang] = $store->getBlob( $this, $lang );
+ }
+ return $this->msgBlobs[$lang];
+ }
+
+ /**
+ * Set in-object cache for message blobs.
+ *
+ * Used to allow fetching of message blobs in batches. See ResourceLoader::preloadModuleInfo().
+ *
+ * @since 1.27
+ * @param string|null $blob JSON blob or null
+ * @param string $lang Language code
+ */
+ public function setMessageBlob( $blob, $lang ) {
+ $this->msgBlobs[$lang] = $blob;
+ }
+
+ /**
+ * Get headers to send as part of a module web response.
+ *
+ * It is not supported to send headers through this method that are
+ * required to be unique or otherwise sent once in an HTTP response
+ * because clients may make batch requests for multiple modules (as
+ * is the default behaviour for ResourceLoader clients).
+ *
+ * For exclusive or aggregated headers, see ResourceLoader::sendResponseHeaders().
+ *
+ * @since 1.30
+ * @param Context $context
+ * @return string[] Array of HTTP response headers
+ */
+ final public function getHeaders( Context $context ) {
+ $headers = [];
+
+ $formattedLinks = [];
+ foreach ( $this->getPreloadLinks( $context ) as $url => $attribs ) {
+ $link = "<{$url}>;rel=preload";
+ foreach ( $attribs as $key => $val ) {
+ $link .= ";{$key}={$val}";
+ }
+ $formattedLinks[] = $link;
+ }
+ if ( $formattedLinks ) {
+ $headers[] = 'Link: ' . implode( ',', $formattedLinks );
+ }
+
+ return $headers;
+ }
+
+ /**
+ * Get a list of resources that web browsers may preload.
+ *
+ * Behaviour of rel=preload link is specified at <https://www.w3.org/TR/preload/>.
+ *
+ * Use case for ResourceLoader originally part of T164299.
+ *
+ * @par Example
+ * @code
+ * protected function getPreloadLinks() {
+ * return [
+ * 'https://example.org/script.js' => [ 'as' => 'script' ],
+ * 'https://example.org/image.png' => [ 'as' => 'image' ],
+ * ];
+ * }
+ * @endcode
+ *
+ * @par Example using HiDPI image variants
+ * @code
+ * protected function getPreloadLinks() {
+ * return [
+ * 'https://example.org/logo.png' => [
+ * 'as' => 'image',
+ * 'media' => 'not all and (min-resolution: 2dppx)',
+ * ],
+ * 'https://example.org/logo@2x.png' => [
+ * 'as' => 'image',
+ * 'media' => '(min-resolution: 2dppx)',
+ * ],
+ * ];
+ * }
+ * @endcode
+ *
+ * @see Module::getHeaders
+ *
+ * @stable to override
+ * @since 1.30
+ * @param Context $context
+ * @return array Keyed by url, values must be an array containing
+ * at least an 'as' key. Optionally a 'media' key as well.
+ *
+ */
+ protected function getPreloadLinks( Context $context ) {
+ return [];
+ }
+
+ /**
+ * Get module-specific LESS variables, if any.
+ *
+ * @stable to override
+ * @since 1.27
+ * @param Context $context
+ * @return array Module-specific LESS variables.
+ */
+ protected function getLessVars( Context $context ) {
+ return [];
+ }
+
+ /**
+ * Get an array of this module's resources. Ready for serving to the web.
+ *
+ * @since 1.26
+ * @param Context $context
+ * @return array
+ */
+ public function getModuleContent( Context $context ) {
+ $contextHash = $context->getHash();
+ // Cache this expensive operation. This calls builds the scripts, styles, and messages
+ // content which typically involves filesystem and/or database access.
+ if ( !array_key_exists( $contextHash, $this->contents ) ) {
+ $this->contents[$contextHash] = $this->buildContent( $context );
+ }
+ return $this->contents[$contextHash];
+ }
+
+ /**
+ * Bundle all resources attached to this module into an array.
+ *
+ * @since 1.26
+ * @param Context $context
+ * @return array
+ */
+ final protected function buildContent( Context $context ) {
+ $stats = MediaWikiServices::getInstance()->getStatsdDataFactory();
+ $statStart = microtime( true );
+
+ // This MUST build both scripts and styles, regardless of whether $context->getOnly()
+ // is 'scripts' or 'styles' because the result is used by getVersionHash which
+ // must be consistent regardless of the 'only' filter on the current request.
+ // Also, when introducing new module content resources (e.g. templates, headers),
+ // these should only be included in the array when they are non-empty so that
+ // existing modules not using them do not get their cache invalidated.
+ $content = [];
+
+ // Scripts
+ if ( $context->getDebug() === $context::DEBUG_LEGACY && !$context->getOnly() && $this->supportsURLLoading() ) {
+ // In legacy debug mode, let supporting modules like FileModule replace the bundled
+ // script closure with an array of alternative script URLs to consecutively load instead.
+ // See self::getScriptURLsForDebug() more details.
+ $scripts = $this->getScriptURLsForDebug( $context );
+ } else {
+ $scripts = $this->getScript( $context );
+ // Make the script safe to concatenate by making sure there is at least one
+ // trailing new line at the end of the content. Previously, this looked for
+ // a semi-colon instead, but that breaks concatenation if the semicolon
+ // is inside a comment like "// foo();". Instead, simply use a
+ // line break as separator which matches JavaScript native logic for implicitly
+ // ending statements even if a semi-colon is missing.
+ // Bugs: T29054, T162719.
+ if ( is_string( $scripts ) ) {
+ $scripts = ResourceLoader::ensureNewline( $scripts );
+ }
+ }
+ $content['scripts'] = $scripts;
+
+ $styles = [];
+ // Don't create empty stylesheets like [ '' => '' ] for modules
+ // that don't *have* any stylesheets (T40024).
+ $stylePairs = $this->getStyles( $context );
+ if ( count( $stylePairs ) ) {
+ // If we are in debug mode without &only= set, we'll want to return an array of URLs
+ // See comment near shouldIncludeScripts() for more details
+ if ( $context->getDebug() && !$context->getOnly() && $this->supportsURLLoading() ) {
+ $styles = [
+ 'url' => $this->getStyleURLsForDebug( $context )
+ ];
+ } else {
+ // Minify CSS before embedding in mw.loader.implement call
+ // (unless in debug mode)
+ if ( !$context->getDebug() ) {
+ foreach ( $stylePairs as $media => $style ) {
+ // Can be either a string or an array of strings.
+ if ( is_array( $style ) ) {
+ $stylePairs[$media] = [];
+ foreach ( $style as $cssText ) {
+ if ( is_string( $cssText ) ) {
+ $stylePairs[$media][] =
+ ResourceLoader::filter( 'minify-css', $cssText );
+ }
+ }
+ } elseif ( is_string( $style ) ) {
+ $stylePairs[$media] = ResourceLoader::filter( 'minify-css', $style );
+ }
+ }
+ }
+ // Wrap styles into @media groups as needed and flatten into a numerical array
+ $styles = [
+ 'css' => ResourceLoader::makeCombinedStyles( $stylePairs )
+ ];
+ }
+ }
+ $content['styles'] = $styles;
+
+ // Messages
+ $blob = $this->getMessageBlob( $context );
+ if ( $blob ) {
+ $content['messagesBlob'] = $blob;
+ }
+
+ $templates = $this->getTemplates();
+ if ( $templates ) {
+ $content['templates'] = $templates;
+ }
+
+ $headers = $this->getHeaders( $context );
+ if ( $headers ) {
+ $content['headers'] = $headers;
+ }
+
+ $statTiming = microtime( true ) - $statStart;
+ $statName = strtr( $this->getName(), '.', '_' );
+ $stats->timing( "resourceloader_build.all", 1000 * $statTiming );
+ $stats->timing( "resourceloader_build.$statName", 1000 * $statTiming );
+
+ return $content;
+ }
+
+ /**
+ * Get a string identifying the current version of this module in a given context.
+ *
+ * Whenever anything happens that changes the module's response (e.g. scripts, styles, and
+ * messages) this value must change. This value is used to store module responses in caches,
+ * both server-side (by a CDN, or other HTTP cache), and client-side (in `mw.loader.store`,
+ * and in the browser's own HTTP cache).
+ *
+ * The underlying methods called here for any given module should be quick because this
+ * is called for potentially thousands of module bundles in the same request as part of the
+ * StartUpModule, which is how we invalidate caches and propagate changes to clients.
+ *
+ * @since 1.26
+ * @see self::getDefinitionSummary for how to customize version computation.
+ * @param Context $context
+ * @return string Hash formatted by ResourceLoader::makeHash
+ */
+ final public function getVersionHash( Context $context ) {
+ if ( $context->getDebug() ) {
+ // In debug mode, make uncached startup module extra fast by not computing any hashes.
+ // Server responses from load.php for individual modules already have no-cache so
+ // we don't need them. This also makes breakpoint debugging easier, as each module
+ // gets its own consistent URL. (T235672)
+ return '';
+ }
+
+ // Cache this somewhat expensive operation. Especially because some classes
+ // (e.g. startup module) iterate more than once over all modules to get versions.
+ $contextHash = $context->getHash();
+ if ( !array_key_exists( $contextHash, $this->versionHash ) ) {
+ if ( $this->enableModuleContentVersion() ) {
+ // Detect changes directly by hashing the module contents.
+ $str = json_encode( $this->getModuleContent( $context ) );
+ } else {
+ // Infer changes based on definition and other metrics
+ $summary = $this->getDefinitionSummary( $context );
+ if ( !isset( $summary['_class'] ) ) {
+ throw new LogicException( 'getDefinitionSummary must call parent method' );
+ }
+ $str = json_encode( $summary );
+ }
+
+ $this->versionHash[$contextHash] = ResourceLoader::makeHash( $str );
+ }
+ return $this->versionHash[$contextHash];
+ }
+
+ /**
+ * Whether to generate version hash based on module content.
+ *
+ * If a module requires database or file system access to build the module
+ * content, consider disabling this in favour of manually tracking relevant
+ * aspects in getDefinitionSummary(). See getVersionHash() for how this is used.
+ *
+ * @stable to override
+ * @return bool
+ */
+ public function enableModuleContentVersion() {
+ return false;
+ }
+
+ /**
+ * Get the definition summary for this module.
+ *
+ * This is the method subclasses are recommended to use to track data that
+ * should influence the module's version hash.
+ *
+ * Subclasses must call the parent getDefinitionSummary() and add to the
+ * returned array. It is recommended that each subclass appends its own array,
+ * to prevent clashes or accidental overwrites of array keys from the parent
+ * class. This gives each subclass a clean scope.
+ *
+ * @code
+ * $summary = parent::getDefinitionSummary( $context );
+ * $summary[] = [
+ * 'foo' => 123,
+ * 'bar' => 'quux',
+ * ];
+ * return $summary;
+ * @endcode
+ *
+ * Return an array that contains all significant properties that define the
+ * module. The returned data should be deterministic and only change when
+ * the generated module response would change. Prefer content hashes over
+ * modified timestamps because timestamps may change for unrelated reasons
+ * and are not deterministic (T102578). For example, because timestamps are
+ * not stored in Git, each branch checkout would cause all files to appear as
+ * new. Timestamps also tend to not match between servers causing additional
+ * ever-lasting churning of the version hash.
+ *
+ * Be careful not to normalise the data too much in an effort to be deterministic.
+ * For example, if a module concatenates files together (order is significant),
+ * then the definition summary could be a list of file names, and a list of
+ * file hashes. These lists should not be sorted as that would mean the cache
+ * is not invalidated when the order changes (T39812).
+ *
+ * This data structure must exclusively contain primitive "scalar" values,
+ * as it will be serialised using `json_encode`.
+ *
+ * @stable to override
+ * @since 1.23
+ * @param Context $context
+ * @return array|null
+ */
+ public function getDefinitionSummary( Context $context ) {
+ return [
+ '_class' => static::class,
+ // Make sure that when filter cache for minification is invalidated,
+ // we also change the HTTP urls and mw.loader.store keys (T176884).
+ '_cacheVersion' => ResourceLoader::CACHE_VERSION,
+ ];
+ }
+
+ /**
+ * Check whether this module is known to be empty. If a child class
+ * has an easy and cheap way to determine that this module is
+ * definitely going to be empty, it should override this method to
+ * return true in that case. Callers may optimize the request for this
+ * module away if this function returns true.
+ *
+ * @stable to override
+ * @param Context $context
+ * @return bool
+ */
+ public function isKnownEmpty( Context $context ) {
+ return false;
+ }
+
+ /**
+ * Check whether this module should be embedded rather than linked
+ *
+ * Modules returning true here will be embedded rather than loaded by
+ * ClientHtml.
+ *
+ * @since 1.30
+ * @stable to override
+ * @param Context $context
+ * @return bool
+ */
+ public function shouldEmbedModule( Context $context ) {
+ return $this->getGroup() === self::GROUP_PRIVATE;
+ }
+
+ /**
+ * Validate a user-provided JavaScript blob.
+ *
+ * @param string $fileName
+ * @param string $contents JavaScript code
+ * @return string JavaScript code, either the original content or a replacement
+ * that uses `mw.log.error()` to communicate a syntax error.
+ */
+ protected function validateScriptFile( $fileName, $contents ) {
+ $error = null;
+
+ if ( $this->getConfig()->get( MainConfigNames::ResourceLoaderValidateJS ) ) {
+ $cache = MediaWikiServices::getInstance()->getMainWANObjectCache();
+ // Cache potentially slow parsing of JavaScript code during the
+ // critical path. This happens lazily when responding to requests
+ // for modules=site, modules=user, and Gadgets.
+ $error = $cache->getWithSetCallback(
+ $cache->makeKey(
+ 'resourceloader-userjsparse',
+ self::USERJSPARSE_CACHE_VERSION,
+ md5( $contents ),
+ $fileName
+ ),
+ $cache::TTL_WEEK,
+ static function () use ( $contents, $fileName ) {
+ $parser = new JSParser();
+ try {
+ // Ignore compiler warnings (T77169)
+ // phpcs:ignore Generic.PHP.NoSilencedErrors.Discouraged
+ @$parser->parse( $contents, $fileName, 1 );
+ } catch ( TimeoutException $e ) {
+ throw $e;
+ } catch ( Exception $e ) {
+ return $e->getMessage();
+ }
+ // Cache success as null
+ return null;
+ }
+ );
+ }
+
+ if ( $error ) {
+ // Send the error to the browser console client-side.
+ // By returning this as replacement for the actual script,
+ // we ensure user-provided scripts are safe to include in a batch
+ // request, without risk of a syntax error in this blob breaking
+ // the response itself.
+ return 'mw.log.error(' .
+ json_encode(
+ 'JavaScript parse error (scripts need to be valid ECMAScript 5): ' .
+ $error
+ ) .
+ ');';
+ } else {
+ return $contents;
+ }
+ }
+
+ /**
+ * Compute a non-cryptographic string hash of a file's contents.
+ * If the file does not exist or cannot be read, returns an empty string.
+ *
+ * @since 1.26 Uses MD4 instead of SHA1.
+ * @param string $filePath
+ * @return string Hash
+ */
+ protected static function safeFileHash( $filePath ) {
+ return FileContentsHasher::getFileContentsHash( $filePath );
+ }
+
+ /**
+ * Get vary string.
+ *
+ * @internal For internal use only.
+ * @param Context $context
+ * @return string
+ */
+ public static function getVary( Context $context ) {
+ return implode( '|', [
+ $context->getSkin(),
+ $context->getLanguage(),
+ ] );
+ }
+}
+
+/** @deprecated since 1.39 */
+class_alias( Module::class, 'ResourceLoaderModule' );
diff --git a/includes/ResourceLoader/MwUrlModule.php b/includes/ResourceLoader/MwUrlModule.php
new file mode 100644
index 000000000000..7603f17cbc7e
--- /dev/null
+++ b/includes/ResourceLoader/MwUrlModule.php
@@ -0,0 +1,46 @@
+<?php
+/**
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation; either version 2 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License along
+ * with this program; if not, write to the Free Software Foundation, Inc.,
+ * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ */
+
+namespace MediaWiki\ResourceLoader;
+
+/**
+ * @ingroup ResourceLoader
+ * @internal
+ */
+class MwUrlModule {
+ /**
+ * @param string $content JavaScript RegExp content with additional whitespace
+ * and named capturing group allowed, which will be stripped.
+ * @return string JavaScript code
+ */
+ public static function makeJsFromExtendedRegExp( string $content ): string {
+ // Remove whitespace.
+ $content = preg_replace( '/\s+/', '', $content );
+ // Remove named capturing groups.
+ // This allows long regexes to be self-documenting, which we allow for
+ // developer convenience, but this syntax is invalid JavaScript ES5.
+ $content = preg_replace( '/\?<\w+?>/', '', $content );
+ // Format as a valid JavaScript import.
+ return 'module.exports = /' . strtr( $content, [ '/' => '\/' ] ) . '/;';
+ }
+}
+
+/** @deprecated since 1.39 */
+class_alias( MwUrlModule::class, 'ResourceLoaderMwUrlModule' );
diff --git a/includes/ResourceLoader/OOUIFileModule.php b/includes/ResourceLoader/OOUIFileModule.php
new file mode 100644
index 000000000000..37b16eec2f4a
--- /dev/null
+++ b/includes/ResourceLoader/OOUIFileModule.php
@@ -0,0 +1,119 @@
+<?php
+/**
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation; either version 2 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License along
+ * with this program; if not, write to the Free Software Foundation, Inc.,
+ * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ */
+
+namespace MediaWiki\ResourceLoader;
+
+/**
+ * Module which magically loads the right skinScripts and skinStyles for every
+ * skin, using the specified OOUI theme for each.
+ *
+ * @ingroup ResourceLoader
+ * @internal
+ */
+class OOUIFileModule extends FileModule {
+ use OOUIModule;
+
+ /** @var array<string,string|FilePath> */
+ private $themeStyles = [];
+
+ public function __construct( array $options = [] ) {
+ if ( isset( $options['themeScripts'] ) ) {
+ $skinScripts = $this->getSkinSpecific( $options['themeScripts'], 'scripts' );
+ $options['skinScripts'] = $this->extendSkinSpecific( $options['skinScripts'] ?? [], $skinScripts );
+ }
+ if ( isset( $options['themeStyles'] ) ) {
+ $this->themeStyles = $this->getSkinSpecific( $options['themeStyles'], 'styles' );
+ }
+
+ parent::__construct( $options );
+ }
+
+ public function setSkinStylesOverride( array $moduleSkinStyles ): void {
+ parent::setSkinStylesOverride( $moduleSkinStyles );
+
+ $this->skinStyles = $this->extendSkinSpecific( $this->skinStyles, $this->themeStyles );
+ }
+
+ /**
+ * Helper function to generate values for 'skinStyles' and 'skinScripts'.
+ *
+ * @param string $module Module to generate skinStyles/skinScripts for:
+ * 'core', 'widgets', 'toolbars', 'windows'
+ * @param string $which 'scripts' or 'styles'
+ * @return array<string,string|FilePath>
+ */
+ private function getSkinSpecific( $module, $which ): array {
+ $themes = self::getSkinThemeMap();
+
+ return array_combine(
+ array_keys( $themes ),
+ array_map( function ( $theme ) use ( $module, $which ) {
+ if ( $which === 'scripts' ) {
+ return $this->getThemeScriptsPath( $theme, $module );
+ } else {
+ return $this->getThemeStylesPath( $theme, $module );
+ }
+ }, array_values( $themes ) )
+ );
+ }
+
+ /**
+ * Prepend theme-specific resources on behalf of the skin.
+ *
+ * The expected order of styles and scripts in the output is:
+ *
+ * 1. Theme-specific resources for a given skin.
+ *
+ * 2. Module-defined resources for a specific skin,
+ * falling back to module-defined "default" skin resources.
+ *
+ * 3. Skin-defined resources for a specific module, which can either
+ * append to or replace the "default" (via ResourceModuleSkinStyles in skin.json)
+ * Note that skins can only define resources for a module if that
+ * module does not already explicitly provide resources for that skin.
+ *
+ * @param array $skinSpecific Module-defined 'skinScripts' or 'skinStyles'.
+ * @param array $themeSpecific
+ * @return array Modified $skinSpecific
+ */
+ private function extendSkinSpecific( array $skinSpecific, array $themeSpecific ): array {
+ // If the module or skin already set skinStyles/skinScripts, prepend ours
+ foreach ( $skinSpecific as $skin => $files ) {
+ if ( !is_array( $files ) ) {
+ $files = [ $files ];
+ }
+ if ( isset( $themeSpecific[$skin] ) ) {
+ $skinSpecific[$skin] = array_merge( [ $themeSpecific[$skin] ], $files );
+ } elseif ( isset( $themeSpecific['default'] ) ) {
+ $skinSpecific[$skin] = array_merge( [ $themeSpecific['default'] ], $files );
+ }
+ }
+ // If the module has no skinStyles/skinScripts for a skin, then set ours
+ foreach ( $themeSpecific as $skin => $file ) {
+ if ( !isset( $skinSpecific[$skin] ) ) {
+ $skinSpecific[$skin] = [ $file ];
+ }
+ }
+ return $skinSpecific;
+ }
+}
+
+/** @deprecated since 1.39 */
+class_alias( OOUIFileModule::class, 'ResourceLoaderOOUIFileModule' );
diff --git a/includes/ResourceLoader/OOUIIconPackModule.php b/includes/ResourceLoader/OOUIIconPackModule.php
new file mode 100644
index 000000000000..687a8d7eaa30
--- /dev/null
+++ b/includes/ResourceLoader/OOUIIconPackModule.php
@@ -0,0 +1,90 @@
+<?php
+/**
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation; either version 2 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License along
+ * with this program; if not, write to the Free Software Foundation, Inc.,
+ * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ */
+
+namespace MediaWiki\ResourceLoader;
+
+use InvalidArgumentException;
+
+/**
+ * Allows loading arbitrary sets of OOUI icons.
+ *
+ * @ingroup ResourceLoader
+ * @since 1.34
+ */
+class OOUIIconPackModule extends OOUIImageModule {
+ public function __construct( array $options = [], $localBasePath = null ) {
+ parent::__construct( $options, $localBasePath );
+
+ if ( !isset( $this->definition['icons'] ) || !$this->definition['icons'] ) {
+ throw new InvalidArgumentException( "Parameter 'icons' must be given." );
+ }
+
+ // A few things check for the "icons" prefix on this value, so specify it even though
+ // we don't use it for actually loading the data, like in the other modules.
+ $this->definition['themeImages'] = 'icons';
+ }
+
+ private function getIcons(): array {
+ // @phan-suppress-next-line PhanTypeArraySuspiciousNullable Checked in the constructor
+ return $this->definition['icons'];
+ }
+
+ protected function loadOOUIDefinition( $theme, $unused ): array {
+ // This is shared between instances of this class, so we only have to load the JSON files once
+ static $data = [];
+
+ if ( !isset( $data[$theme] ) ) {
+ $data[$theme] = [];
+ // Load and merge the JSON data for all "icons-foo" modules
+ foreach ( self::$knownImagesModules as $module ) {
+ if ( substr( $module, 0, 5 ) === 'icons' ) {
+ $moreData = $this->readJSONFile( $this->getThemeImagesPath( $theme, $module ) );
+ if ( $moreData ) {
+ $data[$theme] = array_replace_recursive( $data[$theme], $moreData );
+ }
+ }
+ }
+ }
+
+ $definition = $data[$theme];
+
+ // Filter out the data for all other icons, leaving only the ones we want for this module
+ $iconsNames = $this->getIcons();
+ foreach ( array_keys( $definition['images'] ) as $iconName ) {
+ if ( !in_array( $iconName, $iconsNames ) ) {
+ unset( $definition['images'][$iconName] );
+ }
+ }
+
+ return $definition;
+ }
+
+ public static function extractLocalBasePath( array $options, $localBasePath = null ) {
+ global $IP;
+ if ( $localBasePath === null ) {
+ $localBasePath = $IP;
+ }
+ // Ignore any 'localBasePath' present in $options, this always refers to files in MediaWiki core
+ return $localBasePath;
+ }
+}
+
+/** @deprecated since 1.39 */
+class_alias( OOUIIconPackModule::class, 'ResourceLoaderOOUIIconPackModule' );
diff --git a/includes/ResourceLoader/OOUIImageModule.php b/includes/ResourceLoader/OOUIImageModule.php
new file mode 100644
index 000000000000..0e342a28c5c2
--- /dev/null
+++ b/includes/ResourceLoader/OOUIImageModule.php
@@ -0,0 +1,162 @@
+<?php
+/**
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation; either version 2 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License along
+ * with this program; if not, write to the Free Software Foundation, Inc.,
+ * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ */
+
+namespace MediaWiki\ResourceLoader;
+
+use Exception;
+
+/**
+ * Loads the module definition from JSON files in the format that OOUI uses, converting it to the
+ * format we use. (Previously known as secret special sauce.)
+ *
+ * @since 1.26
+ */
+class OOUIImageModule extends ImageModule {
+ use OOUIModule;
+
+ protected function loadFromDefinition() {
+ if ( $this->definition === null ) {
+ // Do nothing if definition was already processed
+ return;
+ }
+
+ $themes = self::getSkinThemeMap();
+
+ // For backwards-compatibility, allow missing 'themeImages'
+ $module = $this->definition['themeImages'] ?? '';
+
+ $definition = [];
+ foreach ( $themes as $skin => $theme ) {
+ $data = $this->loadOOUIDefinition( $theme, $module );
+
+ if ( !$data ) {
+ // If there's no file for this module of this theme, that's okay, it will just use the defaults
+ continue;
+ }
+
+ // Convert into a definition compatible with the parent vanilla ImageModule
+ foreach ( $data as $key => $value ) {
+ switch ( $key ) {
+ // Images and color variants are defined per-theme, here converted to per-skin
+ case 'images':
+ case 'variants':
+ $definition[$key][$skin] = $value;
+ break;
+
+ // Other options must be identical for each theme (or only defined in the default one)
+ default:
+ if ( !isset( $definition[$key] ) ) {
+ $definition[$key] = $value;
+ } elseif ( $definition[$key] !== $value ) {
+ throw new Exception(
+ "Mismatched OOUI theme images definition: " .
+ "key '$key' of theme '$theme' for module '$module' " .
+ "does not match other themes"
+ );
+ }
+ break;
+ }
+ }
+ }
+
+ // Extra selectors to allow using the same icons for old-style MediaWiki UI code
+ if ( substr( $module, 0, 5 ) === 'icons' ) {
+ $definition['selectorWithoutVariant'] = '.oo-ui-icon-{name}, .mw-ui-icon-{name}:before';
+ $definition['selectorWithVariant'] = '.oo-ui-image-{variant}.oo-ui-icon-{name}, ' .
+ '.mw-ui-icon-{name}-{variant}:before';
+ }
+
+ // Fields from module definition silently override keys from JSON files
+ $this->definition += $definition;
+
+ parent::loadFromDefinition();
+ }
+
+ /**
+ * Load the module definition from the JSON file(s) for the given theme and module.
+ *
+ * @since 1.34
+ * @param string $theme
+ * @param string $module
+ * @return array|false
+ * @suppress PhanTypeArraySuspiciousNullable
+ */
+ protected function loadOOUIDefinition( $theme, $module ) {
+ // Find the path to the JSON file which contains the actual image definitions for this theme
+ if ( $module ) {
+ $dataPath = $this->getThemeImagesPath( $theme, $module );
+ if ( !$dataPath ) {
+ return [];
+ }
+ } else {
+ // Backwards-compatibility for things that probably shouldn't have used this class...
+ $dataPath =
+ $this->definition['rootPath'] . '/' .
+ strtolower( $theme ) . '/' .
+ $this->definition['name'] . '.json';
+ }
+
+ return $this->readJSONFile( $dataPath );
+ }
+
+ /**
+ * Read JSON from a file, and transform all paths in it to be relative to the module's base path.
+ *
+ * @since 1.34
+ * @param string $dataPath Path relative to the module's base bath
+ * @return array|false
+ */
+ protected function readJSONFile( $dataPath ) {
+ $localDataPath = $this->getLocalPath( $dataPath );
+
+ if ( !file_exists( $localDataPath ) ) {
+ return false;
+ }
+
+ $data = json_decode( file_get_contents( $localDataPath ), true );
+
+ // Expand the paths to images (since they are relative to the JSON file that defines them, not
+ // our base directory)
+ $fixPath = static function ( &$path ) use ( $dataPath ) {
+ if ( $dataPath instanceof FilePath ) {
+ $path = new FilePath(
+ dirname( $dataPath->getPath() ) . '/' . $path,
+ $dataPath->getLocalBasePath(),
+ $dataPath->getRemoteBasePath()
+ );
+ } else {
+ $path = dirname( $dataPath ) . '/' . $path;
+ }
+ };
+ // @phan-suppress-next-line PhanTypeArraySuspiciousNullable
+ array_walk( $data['images'], static function ( &$value ) use ( $fixPath ) {
+ if ( is_string( $value['file'] ) ) {
+ $fixPath( $value['file'] );
+ } elseif ( is_array( $value['file'] ) ) {
+ array_walk_recursive( $value['file'], $fixPath );
+ }
+ } );
+
+ return $data;
+ }
+}
+
+/** @deprecated since 1.39 */
+class_alias( OOUIImageModule::class, 'ResourceLoaderOOUIImageModule' );
diff --git a/includes/ResourceLoader/OOUIModule.php b/includes/ResourceLoader/OOUIModule.php
new file mode 100644
index 000000000000..48d10539b21b
--- /dev/null
+++ b/includes/ResourceLoader/OOUIModule.php
@@ -0,0 +1,184 @@
+<?php
+/**
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation; either version 2 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License along
+ * with this program; if not, write to the Free Software Foundation, Inc.,
+ * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ */
+
+namespace MediaWiki\ResourceLoader;
+
+use ExtensionRegistry;
+use InvalidArgumentException;
+
+/**
+ * Convenience methods for dealing with OOUI themes and their relations to MW skins.
+ *
+ * @ingroup ResourceLoader
+ * @internal
+ */
+trait OOUIModule {
+ protected static $knownScriptsModules = [ 'core' ];
+ protected static $knownStylesModules = [ 'core', 'widgets', 'toolbars', 'windows' ];
+ protected static $knownImagesModules = [
+ 'indicators',
+ // Extra icons
+ 'icons-accessibility',
+ 'icons-alerts',
+ 'icons-content',
+ 'icons-editing-advanced',
+ 'icons-editing-citation',
+ 'icons-editing-core',
+ 'icons-editing-list',
+ 'icons-editing-styling',
+ 'icons-interactions',
+ 'icons-layout',
+ 'icons-location',
+ 'icons-media',
+ 'icons-moderation',
+ 'icons-movement',
+ 'icons-user',
+ 'icons-wikimedia',
+ ];
+
+ /** @var string[] Note that keys must be lowercase, values TitleCase. */
+ protected static $builtinSkinThemeMap = [
+ 'default' => 'WikimediaUI',
+ ];
+
+ /** @var string[][] Note that keys must be TitleCase. */
+ protected static $builtinThemePaths = [
+ 'WikimediaUI' => [
+ 'scripts' => 'resources/lib/ooui/oojs-ui-wikimediaui.js',
+ 'styles' => 'resources/lib/ooui/oojs-ui-{module}-wikimediaui.css',
+ 'images' => 'resources/lib/ooui/themes/wikimediaui/{module}.json',
+ ],
+ 'Apex' => [
+ 'scripts' => 'resources/lib/ooui/oojs-ui-apex.js',
+ 'styles' => 'resources/lib/ooui/oojs-ui-{module}-apex.css',
+ 'images' => 'resources/lib/ooui/themes/apex/{module}.json',
+ ],
+ ];
+
+ /**
+ * Return a map of skin names (in lowercase) to OOUI theme names, defining which theme a given
+ * skin should use.
+ *
+ * @return array
+ */
+ public static function getSkinThemeMap() {
+ $themeMap = self::$builtinSkinThemeMap;
+ $themeMap += ExtensionRegistry::getInstance()->getAttribute( 'SkinOOUIThemes' );
+ return $themeMap;
+ }
+
+ /**
+ * Return a map of theme names to lists of paths from which a given theme should be loaded.
+ *
+ * Keys are theme names, values are associative arrays. Keys of the inner array are 'scripts',
+ * 'styles', or 'images', and values are paths. Paths may be strings or FilePaths.
+ *
+ * Additionally, the string '{module}' in paths represents the name of the module to load.
+ *
+ * @return array
+ */
+ protected static function getThemePaths() {
+ $themePaths = self::$builtinThemePaths;
+ $themePaths += ExtensionRegistry::getInstance()->getAttribute( 'OOUIThemePaths' );
+
+ list( $defaultLocalBasePath, $defaultRemoteBasePath ) =
+ FileModule::extractBasePaths();
+
+ // Allow custom themes' paths to be relative to the skin/extension that defines them,
+ // like with ResourceModuleSkinStyles
+ foreach ( $themePaths as $theme => &$paths ) {
+ list( $localBasePath, $remoteBasePath ) =
+ FileModule::extractBasePaths( $paths );
+ if ( $localBasePath !== $defaultLocalBasePath || $remoteBasePath !== $defaultRemoteBasePath ) {
+ foreach ( $paths as &$path ) {
+ $path = new FilePath( $path, $localBasePath, $remoteBasePath );
+ }
+ }
+ }
+
+ return $themePaths;
+ }
+
+ /**
+ * Return a path to load given module of given theme from.
+ *
+ * The file at this path may not exist. This should be handled by the caller (throwing an error or
+ * falling back to default theme).
+ *
+ * @param string $theme OOUI theme name, for example 'WikimediaUI' or 'Apex'
+ * @param string $kind Kind of the module: 'scripts', 'styles', or 'images'
+ * @param string $module Module name, for valid values see $knownScriptsModules,
+ * $knownStylesModules, $knownImagesModules
+ * @return string|FilePath
+ */
+ protected function getThemePath( $theme, $kind, $module ) {
+ $paths = self::getThemePaths();
+ $path = $paths[$theme][$kind];
+ if ( $path instanceof FilePath ) {
+ $path = new FilePath(
+ str_replace( '{module}', $module, $path->getPath() ),
+ $path->getLocalBasePath(),
+ $path->getRemoteBasePath()
+ );
+ } else {
+ $path = str_replace( '{module}', $module, $path );
+ }
+ return $path;
+ }
+
+ /**
+ * @param string $theme See getThemePath()
+ * @param string $module See getThemePath()
+ * @return string|FilePath
+ */
+ protected function getThemeScriptsPath( $theme, $module ) {
+ if ( !in_array( $module, self::$knownScriptsModules ) ) {
+ throw new InvalidArgumentException( "Invalid OOUI scripts module '$module'" );
+ }
+ return $this->getThemePath( $theme, 'scripts', $module );
+ }
+
+ /**
+ * @param string $theme See getThemePath()
+ * @param string $module See getThemePath()
+ * @return string|FilePath
+ */
+ protected function getThemeStylesPath( $theme, $module ) {
+ if ( !in_array( $module, self::$knownStylesModules ) ) {
+ throw new InvalidArgumentException( "Invalid OOUI styles module '$module'" );
+ }
+ return $this->getThemePath( $theme, 'styles', $module );
+ }
+
+ /**
+ * @param string $theme See getThemePath()
+ * @param string $module See getThemePath()
+ * @return string|FilePath
+ */
+ protected function getThemeImagesPath( $theme, $module ) {
+ if ( !in_array( $module, self::$knownImagesModules ) ) {
+ throw new InvalidArgumentException( "Invalid OOUI images module '$module'" );
+ }
+ return $this->getThemePath( $theme, 'images', $module );
+ }
+}
+
+/** @deprecated since 1.39 */
+class_alias( OOUIModule::class, 'ResourceLoaderOOUIModule' );
diff --git a/includes/ResourceLoader/ResourceLoader.php b/includes/ResourceLoader/ResourceLoader.php
new file mode 100644
index 000000000000..ba942e09713b
--- /dev/null
+++ b/includes/ResourceLoader/ResourceLoader.php
@@ -0,0 +1,2060 @@
+<?php
+/**
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation; either version 2 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License along
+ * with this program; if not, write to the Free Software Foundation, Inc.,
+ * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @author Roan Kattouw
+ * @author Trevor Parscal
+ */
+
+namespace MediaWiki\ResourceLoader;
+
+use BagOStuff;
+use CommentStore;
+use Config;
+use DeferredUpdates;
+use Exception;
+use ExtensionRegistry;
+use HashBagOStuff;
+use Hooks;
+use Html;
+use HttpStatus;
+use InvalidArgumentException;
+use Less_Parser;
+use MediaWiki\HeaderCallback;
+use MediaWiki\HookContainer\HookContainer;
+use MediaWiki\MainConfigNames;
+use MediaWiki\MediaWikiServices;
+use MWException;
+use MWExceptionHandler;
+use MWExceptionRenderer;
+use Net_URL2;
+use ObjectCache;
+use OutputPage;
+use Psr\Log\LoggerAwareInterface;
+use Psr\Log\LoggerInterface;
+use Psr\Log\NullLogger;
+use ResourceFileCache;
+use RuntimeException;
+use stdClass;
+use Throwable;
+use Title;
+use UnexpectedValueException;
+use WebRequest;
+use WikiMap;
+use Wikimedia\DependencyStore\DependencyStore;
+use Wikimedia\DependencyStore\KeyValueDependencyStore;
+use Wikimedia\Minify\CSSMin;
+use Wikimedia\Minify\JavaScriptMinifier;
+use Wikimedia\Rdbms\DBConnectionError;
+use Wikimedia\RequestTimeout\TimeoutException;
+use Wikimedia\ScopedCallback;
+use Wikimedia\Timestamp\ConvertibleTimestamp;
+use Wikimedia\WrappedString;
+use Xml;
+use XmlJsCode;
+
+/**
+ * @defgroup ResourceLoader ResourceLoader
+ *
+ * For higher level documentation, see <https://www.mediawiki.org/wiki/ResourceLoader/Architecture>.
+ */
+
+/**
+ * @defgroup ResourceLoaderHooks ResourceLoader Hooks
+ * @ingroup ResourceLoader
+ * @ingroup Hooks
+ */
+
+/**
+ * PHP 7.2 hack to work around the issue described at https://phabricator.wikimedia.org/T166010#5962098
+ * Load the Context class when ResourceLoader is loaded.
+ * phpcs:disable Generic.Files.OneObjectStructurePerFile.MultipleFound
+ * phpcs:disable MediaWiki.Files.ClassMatchesFilename.NotMatch
+ */
+class Context72Hack extends Context {
+}
+
+/**
+ * ResourceLoader is a loading system for JavaScript and CSS resources.
+ *
+ * For higher level documentation, see <https://www.mediawiki.org/wiki/ResourceLoader/Architecture>.
+ *
+ * @ingroup ResourceLoader
+ * @since 1.17
+ */
+class ResourceLoader implements LoggerAwareInterface {
+ /** @var int */
+ public const CACHE_VERSION = 9;
+ /** @var string JavaScript / CSS pragma to disable minification. * */
+ public const FILTER_NOMIN = '/*@nomin*/';
+
+ /** @var string */
+ private const RL_DEP_STORE_PREFIX = 'ResourceLoaderModule';
+ /** @var int How long to preserve indirect dependency metadata in our backend store. */
+ private const RL_MODULE_DEP_TTL = BagOStuff::TTL_WEEK;
+
+ /** @var int|null */
+ protected static $debugMode = null;
+
+ /** @var Config */
+ private $config;
+ /** @var MessageBlobStore */
+ private $blobStore;
+ /** @var DependencyStore */
+ private $depStore;
+ /** @var LoggerInterface */
+ private $logger;
+ /** @var HookContainer */
+ private $hookContainer;
+ /** @var HookRunner */
+ private $hookRunner;
+
+ /** @var Module[] Map of (module name => ResourceLoaderModule) */
+ private $modules = [];
+ /** @var array[] Map of (module name => associative info array) */
+ private $moduleInfos = [];
+ /** @var string[] List of module names that contain QUnit test suites */
+ private $testSuiteModuleNames = [];
+ /** @var string[] Map of (source => path); E.g. [ 'source-id' => 'http://.../load.php' ] */
+ private $sources = [];
+ /** @var array Errors accumulated during a respond() call. Exposed for testing. */
+ protected $errors = [];
+ /**
+ * @var string[] Buffer for extra response headers during a makeModuleResponse() call.
+ * Exposed for testing.
+ */
+ protected $extraHeaders = [];
+ /** @var array Map of (module-variant => buffered DependencyStore updates) */
+ private $depStoreUpdateBuffer = [];
+ /**
+ * @var array Styles that are skin-specific and supplement or replace the
+ * default skinStyles of a FileModule. See $wgResourceModuleSkinStyles.
+ */
+ private $moduleSkinStyles = [];
+
+ /**
+ * @param Config $config Required configuration:
+ * - EnableJavaScriptTest
+ * - LoadScript
+ * - ResourceLoaderMaxage
+ * - UseFileCache
+ * @param LoggerInterface|null $logger [optional]
+ * @param DependencyStore|null $tracker [optional]
+ */
+ public function __construct(
+ Config $config,
+ LoggerInterface $logger = null,
+ DependencyStore $tracker = null
+ ) {
+ $this->config = $config;
+ $this->logger = $logger ?: new NullLogger();
+
+ $services = MediaWikiServices::getInstance();
+ $this->hookContainer = $services->getHookContainer();
+ $this->hookRunner = new HookRunner( $this->hookContainer );
+
+ // Add 'local' source first
+ $this->addSource( 'local', $config->get( MainConfigNames::LoadScript ) );
+
+ // Special module that always exists
+ $this->register( 'startup', [ 'class' => StartUpModule::class ] );
+
+ $this->setMessageBlobStore(
+ new MessageBlobStore( $this, $this->logger, $services->getMainWANObjectCache() )
+ );
+
+ $tracker = $tracker ?: new KeyValueDependencyStore( new HashBagOStuff() );
+ $this->setDependencyStore( $tracker );
+ }
+
+ /**
+ * @return Config
+ */
+ public function getConfig() {
+ return $this->config;
+ }
+
+ /**
+ * @since 1.26
+ * @param LoggerInterface $logger
+ */
+ public function setLogger( LoggerInterface $logger ) {
+ $this->logger = $logger;
+ }
+
+ /**
+ * @since 1.27
+ * @return LoggerInterface
+ */
+ public function getLogger() {
+ return $this->logger;
+ }
+
+ /**
+ * @since 1.26
+ * @return MessageBlobStore
+ */
+ public function getMessageBlobStore() {
+ return $this->blobStore;
+ }
+
+ /**
+ * @since 1.25
+ * @param MessageBlobStore $blobStore
+ */
+ public function setMessageBlobStore( MessageBlobStore $blobStore ) {
+ $this->blobStore = $blobStore;
+ }
+
+ /**
+ * @since 1.35
+ * @param DependencyStore $tracker
+ */
+ public function setDependencyStore( DependencyStore $tracker ) {
+ $this->depStore = $tracker;
+ }
+
+ /**
+ * @internal For use by ServiceWiring.php
+ * @param array $moduleSkinStyles
+ */
+ public function setModuleSkinStyles( array $moduleSkinStyles ) {
+ $this->moduleSkinStyles = $moduleSkinStyles;
+ }
+
+ /**
+ * Register a module with the ResourceLoader system.
+ *
+ * @see $wgResourceModules for the available options.
+ * @param string|array[] $name Module name as a string or, array of module info arrays
+ * keyed by name.
+ * @param array|null $info Module info array. When using the first parameter to register
+ * multiple modules at once, this parameter is optional.
+ * @throws InvalidArgumentException If a module name contains illegal characters (pipes or commas)
+ * @throws InvalidArgumentException If the module info is not an array
+ */
+ public function register( $name, array $info = null ) {
+ // Allow multiple modules to be registered in one call
+ $registrations = is_array( $name ) ? $name : [ $name => $info ];
+ foreach ( $registrations as $name => $info ) {
+ // Warn on duplicate registrations
+ if ( isset( $this->moduleInfos[$name] ) ) {
+ // A module has already been registered by this name
+ $this->logger->warning(
+ 'ResourceLoader duplicate registration warning. ' .
+ 'Another module has already been registered as ' . $name
+ );
+ }
+
+ // Check validity
+ if ( !self::isValidModuleName( $name ) ) {
+ throw new InvalidArgumentException( "ResourceLoader module name '$name' is invalid, "
+ . "see ResourceLoader::isValidModuleName()" );
+ }
+ if ( !is_array( $info ) ) {
+ throw new InvalidArgumentException(
+ 'Invalid module info for "' . $name . '": expected array, got ' . gettype( $info )
+ );
+ }
+
+ // Attach module
+ $this->moduleInfos[$name] = $info;
+ }
+ }
+
+ /**
+ * @internal For use by ServiceWiring only
+ * @codeCoverageIgnore
+ */
+ public function registerTestModules(): void {
+ global $IP;
+
+ if ( $this->config->get( MainConfigNames::EnableJavaScriptTest ) !== true ) {
+ throw new MWException( 'Attempt to register JavaScript test modules '
+ . 'but <code>$wgEnableJavaScriptTest</code> is false. '
+ . 'Edit your <code>LocalSettings.php</code> to enable it.' );
+ }
+
+ // This has a 'qunit' key for compat with the below hook.
+ $testModulesMeta = [ 'qunit' => [] ];
+
+ $this->hookRunner->onResourceLoaderTestModules( $testModulesMeta, $this );
+ $extRegistry = ExtensionRegistry::getInstance();
+ // In case of conflict, the deprecated hook has precedence.
+ $testModules = $testModulesMeta['qunit']
+ + $extRegistry->getAttribute( 'QUnitTestModules' );
+
+ $testSuiteModuleNames = [];
+ foreach ( $testModules as $name => &$module ) {
+ // Turn any single-module dependency into an array
+ if ( isset( $module['dependencies'] ) && is_string( $module['dependencies'] ) ) {
+ $module['dependencies'] = [ $module['dependencies'] ];
+ }
+
+ // Ensure the testrunner loads before any test suites
+ $module['dependencies'][] = 'mediawiki.qunit-testrunner';
+
+ // Keep track of the test suites to load on SpecialJavaScriptTest
+ $testSuiteModuleNames[] = $name;
+ }
+
+ // Core test suites (their names have further precedence).
+ $testModules = ( include "$IP/tests/qunit/QUnitTestResources.php" ) + $testModules;
+ $testSuiteModuleNames[] = 'test.MediaWiki';
+
+ $this->register( $testModules );
+ $this->testSuiteModuleNames = $testSuiteModuleNames;
+ }
+
+ /**
+ * Add a foreign source of modules.
+ *
+ * Source IDs are typically the same as the Wiki ID or database name (e.g. lowercase a-z).
+ *
+ * @param array|string $sources Source ID (string), or [ id1 => loadUrl, id2 => loadUrl, ... ]
+ * @param string|array|null $loadUrl load.php url (string), or array with loadUrl key for
+ * backwards-compatibility.
+ * @throws InvalidArgumentException If array-form $loadUrl lacks a 'loadUrl' key.
+ */
+ public function addSource( $sources, $loadUrl = null ) {
+ if ( !is_array( $sources ) ) {
+ $sources = [ $sources => $loadUrl ];
+ }
+ foreach ( $sources as $id => $source ) {
+ // Disallow duplicates
+ if ( isset( $this->sources[$id] ) ) {
+ throw new RuntimeException( 'Cannot register source ' . $id . ' twice' );
+ }
+
+ // Support: MediaWiki 1.24 and earlier
+ if ( is_array( $source ) ) {
+ if ( !isset( $source['loadScript'] ) ) {
+ throw new InvalidArgumentException( 'Each source must have a "loadScript" key' );
+ }
+ $source = $source['loadScript'];
+ }
+
+ $this->sources[$id] = $source;
+ }
+ }
+
+ /**
+ * @return string[]
+ */
+ public function getModuleNames() {
+ return array_keys( $this->moduleInfos );
+ }
+
+ /**
+ * Get a list of module names with QUnit test suites.
+ *
+ * @internal For use by SpecialJavaScriptTest only
+ * @return string[]
+ * @codeCoverageIgnore
+ */
+ public function getTestSuiteModuleNames() {
+ return $this->testSuiteModuleNames;
+ }
+
+ /**
+ * Check whether a ResourceLoader module is registered
+ *
+ * @since 1.25
+ * @param string $name
+ * @return bool
+ */
+ public function isModuleRegistered( $name ) {
+ return isset( $this->moduleInfos[$name] );
+ }
+
+ /**
+ * Get the Module object for a given module name.
+ *
+ * If an array of module parameters exists but a Module object has not yet
+ * been instantiated, this method will instantiate and cache that object such that
+ * subsequent calls simply return the same object.
+ *
+ * @param string $name Module name
+ * @return Module|null If module has been registered, return a
+ * Module instance. Otherwise, return null.
+ */
+ public function getModule( $name ) {
+ if ( !isset( $this->modules[$name] ) ) {
+ if ( !isset( $this->moduleInfos[$name] ) ) {
+ // No such module
+ return null;
+ }
+ // Construct the requested module object
+ $info = $this->moduleInfos[$name];
+ if ( isset( $info['factory'] ) ) {
+ /** @var Module $object */
+ $object = call_user_func( $info['factory'], $info );
+ } else {
+ $class = $info['class'] ?? FileModule::class;
+ /** @var Module $object */
+ $object = new $class( $info );
+ }
+ $object->setConfig( $this->getConfig() );
+ $object->setLogger( $this->logger );
+ $object->setHookContainer( $this->hookContainer );
+ $object->setName( $name );
+ $object->setDependencyAccessCallbacks(
+ [ $this, 'loadModuleDependenciesInternal' ],
+ [ $this, 'saveModuleDependenciesInternal' ]
+ );
+ $object->setSkinStylesOverride( $this->moduleSkinStyles );
+ $this->modules[$name] = $object;
+ }
+
+ return $this->modules[$name];
+ }
+
+ /**
+ * Load information stored in the database and dependency tracking store about modules
+ *
+ * @param string[] $moduleNames
+ * @param Context $context ResourceLoader-specific context of the request
+ */
+ public function preloadModuleInfo( array $moduleNames, Context $context ) {
+ // Load all tracked indirect file dependencies for the modules
+ $vary = Module::getVary( $context );
+ $entitiesByModule = [];
+ foreach ( $moduleNames as $moduleName ) {
+ $entitiesByModule[$moduleName] = "$moduleName|$vary";
+ }
+ $depsByEntity = $this->depStore->retrieveMulti(
+ self::RL_DEP_STORE_PREFIX,
+ $entitiesByModule
+ );
+ // Inject the indirect file dependencies for all the modules
+ foreach ( $moduleNames as $moduleName ) {
+ $module = $this->getModule( $moduleName );
+ if ( $module ) {
+ $entity = $entitiesByModule[$moduleName];
+ $deps = $depsByEntity[$entity];
+ $paths = Module::expandRelativePaths( $deps['paths'] );
+ $module->setFileDependencies( $context, $paths );
+ }
+ }
+
+ // Batched version of ResourceLoaderWikiModule::getTitleInfo
+ $dbr = wfGetDB( DB_REPLICA );
+ WikiModule::preloadTitleInfo( $context, $dbr, $moduleNames );
+
+ // Prime in-object cache for message blobs for modules with messages
+ $modulesWithMessages = [];
+ foreach ( $moduleNames as $moduleName ) {
+ $module = $this->getModule( $moduleName );
+ if ( $module && $module->getMessages() ) {
+ $modulesWithMessages[$moduleName] = $module;
+ }
+ }
+ // Prime in-object cache for message blobs for modules with messages
+ $lang = $context->getLanguage();
+ $store = $this->getMessageBlobStore();
+ $blobs = $store->getBlobs( $modulesWithMessages, $lang );
+ foreach ( $blobs as $moduleName => $blob ) {
+ $modulesWithMessages[$moduleName]->setMessageBlob( $blob, $lang );
+ }
+ }
+
+ /**
+ * @internal Exposed for letting getModule() pass the callable to DependencyStore
+ * @param string $moduleName
+ * @param string $variant Language/skin variant
+ * @return string[] List of absolute file paths
+ */
+ public function loadModuleDependenciesInternal( $moduleName, $variant ) {
+ $deps = $this->depStore->retrieve( self::RL_DEP_STORE_PREFIX, "$moduleName|$variant" );
+
+ return Module::expandRelativePaths( $deps['paths'] );
+ }
+
+ /**
+ * @internal Exposed for letting getModule() pass the callable to DependencyStore
+ * @param string $moduleName
+ * @param string $variant Language/skin variant
+ * @param string[] $paths List of relative paths referenced during computation
+ * @param string[] $priorPaths List of relative paths tracked in the dependency store
+ */
+ public function saveModuleDependenciesInternal( $moduleName, $variant, $paths, $priorPaths ) {
+ $hasPendingUpdate = (bool)$this->depStoreUpdateBuffer;
+ $entity = "$moduleName|$variant";
+
+ if ( array_diff( $paths, $priorPaths ) || array_diff( $priorPaths, $paths ) ) {
+ // Dependency store needs to be updated with the new path list
+ if ( $paths ) {
+ $deps = $this->depStore->newEntityDependencies( $paths, time() );
+ $this->depStoreUpdateBuffer[$entity] = $deps;
+ } else {
+ $this->depStoreUpdateBuffer[$entity] = null;
+ }
+ } elseif ( $priorPaths ) {
+ // Dependency store needs to store the existing path list for longer
+ $this->depStoreUpdateBuffer[$entity] = '*';
+ }
+
+ // Use a DeferrableUpdate to flush the buffered dependency updates...
+ if ( !$hasPendingUpdate ) {
+ DeferredUpdates::addCallableUpdate( function () {
+ $updatesByEntity = $this->depStoreUpdateBuffer;
+ $this->depStoreUpdateBuffer = []; // consume
+ $cache = ObjectCache::getLocalClusterInstance();
+
+ $scopeLocks = [];
+ $depsByEntity = [];
+ $entitiesUnreg = [];
+ $entitiesRenew = [];
+ foreach ( $updatesByEntity as $entity => $update ) {
+ $lockKey = $cache->makeKey( 'rl-deps', $entity );
+ $scopeLocks[$entity] = $cache->getScopedLock( $lockKey, 0 );
+ if ( !$scopeLocks[$entity] ) {
+ // avoid duplicate write request slams (T124649)
+ // the lock must be specific to the current wiki (T247028)
+ continue;
+ }
+ if ( $update === null ) {
+ $entitiesUnreg[] = $entity;
+ } elseif ( $update === '*' ) {
+ $entitiesRenew[] = $entity;
+ } else {
+ $depsByEntity[$entity] = $update;
+ }
+ }
+
+ $ttl = self::RL_MODULE_DEP_TTL;
+ $this->depStore->storeMulti( self::RL_DEP_STORE_PREFIX, $depsByEntity, $ttl );
+ $this->depStore->remove( self::RL_DEP_STORE_PREFIX, $entitiesUnreg );
+ $this->depStore->renew( self::RL_DEP_STORE_PREFIX, $entitiesRenew, $ttl );
+ } );
+ }
+ }
+
+ /**
+ * Get the list of sources.
+ *
+ * @return array Like [ id => load.php url, ... ]
+ */
+ public function getSources() {
+ return $this->sources;
+ }
+
+ /**
+ * Get the URL to the load.php endpoint for the given ResourceLoader source.
+ *
+ * @since 1.24
+ * @param string $source Source ID
+ * @return string
+ * @throws UnexpectedValueException If the source ID was not registered
+ */
+ public function getLoadScript( $source ) {
+ if ( !isset( $this->sources[$source] ) ) {
+ throw new UnexpectedValueException( "Unknown source '$source'" );
+ }
+ return $this->sources[$source];
+ }
+
+ /**
+ * @internal For use by StartUpModule only.
+ */
+ public const HASH_LENGTH = 5;
+
+ /**
+ * Create a hash for module versioning purposes.
+ *
+ * This hash is used in three ways:
+ *
+ * - To differentiate between the current version and a past version
+ * of a module by the same name.
+ *
+ * In the cache key of localStorage in the browser (mw.loader.store).
+ * This store keeps only one version of any given module. As long as the
+ * next version the client encounters has a different hash from the last
+ * version it saw, it will correctly discard it in favour of a network fetch.
+ *
+ * A browser may evict a site's storage container for any reason (e.g. when
+ * the user hasn't visited a site for some time, and/or when the device is
+ * low on storage space). Anecdotally it seems devices rarely keep unused
+ * storage beyond 2 weeks on mobile devices and 4 weeks on desktop.
+ * But, there is no hard limit or expiration on localStorage.
+ * ResourceLoader's Client also clears localStorage when the user changes
+ * their language preference or when they (temporarily) use Debug Mode.
+ *
+ * The only hard factors that reduce the range of possible versions are
+ * 1) the name and existence of a given module, and
+ * 2) the TTL for mw.loader.store, and
+ * 3) the `$wgResourceLoaderStorageVersion` configuration variable.
+ *
+ * - To identify a batch response of modules from load.php in an HTTP cache.
+ *
+ * When fetching modules in a batch from load.php, a combined hash
+ * is created by the JS code, and appended as query parameter.
+ *
+ * In cache proxies (e.g. Varnish, Nginx) and in the browser's HTTP cache,
+ * these urls are used to identify other previously cached responses.
+ * The range of possible versions a given version has to be unique amongst
+ * is determined by the maximum duration each response is stored for, which
+ * is controlled by `$wgResourceLoaderMaxage['versioned']`.
+ *
+ * - To detect race conditions between multiple web servers in a MediaWiki
+ * deployment of which some have the newer version and some still the older
+ * version.
+ *
+ * An HTTP request from a browser for the Startup manifest may be responded
+ * to by a server with the newer version. The browser may then use that to
+ * request a given module, which may then be responded to by a server with
+ * the older version. To avoid caching this for too long (which would pollute
+ * all other users without repairing itself), the combined hash that the JS
+ * client adds to the url is verified by the server (in ::sendResponseHeaders).
+ * If they don't match, we instruct cache proxies and clients to not cache
+ * this response as long as they normally would. This is also the reason
+ * that the algorithm used here in PHP must match the one used in JS.
+ *
+ * The fnv132 digest creates a 32-bit integer, which goes upto 4 Giga and
+ * needs up to 7 chars in base 36.
+ * Within 7 characters, base 36 can count up to 78,364,164,096 (78 Giga),
+ * (but with fnv132 we'd use very little of this range, mostly padding).
+ * Within 6 characters, base 36 can count up to 2,176,782,336 (2 Giga).
+ * Within 5 characters, base 36 can count up to 60,466,176 (60 Mega).
+ *
+ * @since 1.26
+ * @param string $value
+ * @return string Hash
+ */
+ public static function makeHash( $value ) {
+ $hash = hash( 'fnv132', $value );
+ // The base_convert will pad it (if too short),
+ // then substr() will trim it (if too long).
+ return substr(
+ \Wikimedia\base_convert( $hash, 16, 36, self::HASH_LENGTH ),
+ 0,
+ self::HASH_LENGTH
+ );
+ }
+
+ /**
+ * Add an error to the 'errors' array and log it.
+ *
+ * @internal For use by StartUpModule.
+ * @since 1.29
+ * @param Exception $e
+ * @param string $msg
+ * @param array $context
+ */
+ public function outputErrorAndLog( Exception $e, $msg, array $context = [] ) {
+ MWExceptionHandler::logException( $e );
+ $this->logger->warning(
+ $msg,
+ $context + [ 'exception' => $e ]
+ );
+ $this->errors[] = self::formatExceptionNoComment( $e );
+ }
+
+ /**
+ * Helper method to get and combine versions of multiple modules.
+ *
+ * @since 1.26
+ * @param Context $context
+ * @param string[] $moduleNames List of known module names
+ * @return string Hash
+ */
+ public function getCombinedVersion( Context $context, array $moduleNames ) {
+ if ( !$moduleNames ) {
+ return '';
+ }
+ $hashes = array_map( function ( $module ) use ( $context ) {
+ try {
+ return $this->getModule( $module )->getVersionHash( $context );
+ } catch ( TimeoutException $e ) {
+ throw $e;
+ } catch ( Exception $e ) {
+ // If modules fail to compute a version, don't fail the request (T152266)
+ // and still compute versions of other modules.
+ $this->outputErrorAndLog( $e,
+ 'Calculating version for "{module}" failed: {exception}',
+ [
+ 'module' => $module,
+ ]
+ );
+ return '';
+ }
+ }, $moduleNames );
+ return self::makeHash( implode( '', $hashes ) );
+ }
+
+ /**
+ * Get the expected value of the 'version' query parameter.
+ *
+ * This is used by respond() to set a short Cache-Control header for requests with
+ * information newer than the current server has. This avoids pollution of edge caches.
+ * Typically during deployment. (T117587)
+ *
+ * This MUST match return value of `mw.loader#getCombinedVersion()` client-side.
+ *
+ * @since 1.28
+ * @param Context $context
+ * @param string[] $modules
+ * @return string Hash
+ */
+ public function makeVersionQuery( Context $context, array $modules ) {
+ // As of MediaWiki 1.28, the server and client use the same algorithm for combining
+ // version hashes. There is no technical reason for this to be same, and for years the
+ // implementations differed. If getCombinedVersion in PHP (used for StartupModule and
+ // E-Tag headers) differs in the future from getCombinedVersion in JS (used for 'version'
+ // query parameter), then this method must continue to match the JS one.
+ $filtered = [];
+ foreach ( $modules as $name ) {
+ if ( !$this->getModule( $name ) ) {
+ // If a versioned request contains a missing module, the version is a mismatch
+ // as the client considered a module (and version) we don't have.
+ return '';
+ }
+ $filtered[] = $name;
+ }
+ return $this->getCombinedVersion( $context, $filtered );
+ }
+
+ /**
+ * Output a response to a load request, including the content-type header.
+ *
+ * @param Context $context Context in which a response should be formed
+ */
+ public function respond( Context $context ) {
+ // Buffer output to catch warnings. Normally we'd use ob_clean() on the
+ // top-level output buffer to clear warnings, but that breaks when ob_gzhandler
+ // is used: ob_clean() will clear the GZIP header in that case and it won't come
+ // back for subsequent output, resulting in invalid GZIP. So we have to wrap
+ // the whole thing in our own output buffer to be sure the active buffer
+ // doesn't use ob_gzhandler.
+ // See https://bugs.php.net/bug.php?id=36514
+ ob_start();
+
+ $responseTime = $this->measureResponseTime();
+
+ $response = '';
+ try { // TimeoutException
+ // Find out which modules are missing and instantiate the others
+ $modules = [];
+ $missing = [];
+ foreach ( $context->getModules() as $name ) {
+ $module = $this->getModule( $name );
+ if ( $module ) {
+ // Do not allow private modules to be loaded from the web.
+ // This is a security issue, see T36907.
+ if ( $module->getGroup() === Module::GROUP_PRIVATE ) {
+ // Not a serious error, just means something is trying to access it (T101806)
+ $this->logger->debug( "Request for private module '$name' denied" );
+ $this->errors[] = "Cannot build private module \"$name\"";
+ continue;
+ }
+ $modules[$name] = $module;
+ } else {
+ $missing[] = $name;
+ }
+ }
+
+ try {
+ // Preload for getCombinedVersion() and for batch makeModuleResponse()
+ $this->preloadModuleInfo( array_keys( $modules ), $context );
+ }
+ catch ( TimeoutException $e ) {
+ throw $e;
+ }
+ catch ( Exception $e ) {
+ $this->outputErrorAndLog( $e, 'Preloading module info failed: {exception}' );
+ }
+
+ // Combine versions to propagate cache invalidation
+ $versionHash = '';
+ try {
+ $versionHash = $this->getCombinedVersion( $context, array_keys( $modules ) );
+ }
+ catch ( TimeoutException $e ) {
+ throw $e;
+ }
+ catch ( Exception $e ) {
+ $this->outputErrorAndLog( $e, 'Calculating version hash failed: {exception}' );
+ }
+
+ // See RFC 2616 § 3.11 Entity Tags
+ // https://www.w3.org/Protocols/rfc2616/rfc2616-sec3.html#sec3.11
+ $etag = 'W/"' . $versionHash . '"';
+
+ // Try the client-side cache first
+ if ( $this->tryRespondNotModified( $context, $etag ) ) {
+ return; // output handled (buffers cleared)
+ }
+
+ // Use file cache if enabled and available...
+ if ( $this->config->get( MainConfigNames::UseFileCache ) ) {
+ $fileCache = ResourceFileCache::newFromContext( $context );
+ if ( $this->tryRespondFromFileCache( $fileCache, $context, $etag ) ) {
+ return; // output handled
+ }
+ } else {
+ $fileCache = null;
+ }
+
+ // Generate a response
+ $response = $this->makeModuleResponse( $context, $modules, $missing );
+
+ // Capture any PHP warnings from the output buffer and append them to the
+ // error list if we're in debug mode.
+ if ( $context->getDebug() ) {
+ $warnings = ob_get_contents();
+ if ( strlen( $warnings ) ) {
+ $this->errors[] = $warnings;
+ }
+ }
+
+ // Consider saving the response to file cache (unless there are errors).
+ if ( $fileCache && !$this->errors && $missing === [] &&
+ ResourceFileCache::useFileCache( $context ) ) {
+ if ( $fileCache->isCacheWorthy() ) {
+ // There were enough hits, save the response to the cache
+ $fileCache->saveText( $response );
+ } else {
+ $fileCache->incrMissesRecent( $context->getRequest() );
+ }
+ }
+ } catch ( TimeoutException $e ) {
+ $this->outputErrorAndLog( $e, "Request timed out" );
+ }
+
+ $this->sendResponseHeaders( $context, $etag, (bool)$this->errors, $this->extraHeaders );
+
+ // Remove the output buffer and output the response
+ ob_end_clean();
+
+ if ( $context->getImageObj() && $this->errors ) {
+ // We can't show both the error messages and the response when it's an image.
+ $response = implode( "\n\n", $this->errors );
+ } elseif ( $this->errors ) {
+ $errorText = implode( "\n\n", $this->errors );
+ $errorResponse = self::makeComment( $errorText );
+ if ( $context->shouldIncludeScripts() ) {
+ $errorResponse .= 'if (window.console && console.error) { console.error('
+ . $context->encodeJson( $errorText )
+ . "); }\n";
+ }
+
+ // Prepend error info to the response
+ $response = $errorResponse . $response;
+ }
+
+ $this->errors = [];
+ // @phan-suppress-next-line SecurityCheck-XSS
+ echo $response;
+ }
+
+ /**
+ * Send stats about the time used to build the response
+ * @return ScopedCallback
+ */
+ protected function measureResponseTime() {
+ $statStart = $_SERVER['REQUEST_TIME_FLOAT'];
+ return new ScopedCallback( static function () use ( $statStart ) {
+ $statTiming = microtime( true ) - $statStart;
+ $stats = MediaWikiServices::getInstance()->getStatsdDataFactory();
+ $stats->timing( 'resourceloader.responseTime', $statTiming * 1000 );
+ } );
+ }
+
+ /**
+ * Send main response headers to the client.
+ *
+ * Deals with Content-Type, CORS (for stylesheets), and caching.
+ *
+ * @param Context $context
+ * @param string $etag ETag header value
+ * @param bool $errors Whether there are errors in the response
+ * @param string[] $extra Array of extra HTTP response headers
+ */
+ protected function sendResponseHeaders(
+ Context $context, $etag, $errors, array $extra = []
+ ): void {
+ HeaderCallback::warnIfHeadersSent();
+ $rlMaxage = $this->config->get( MainConfigNames::ResourceLoaderMaxage );
+ // Use a short cache expiry so that updates propagate to clients quickly, if:
+ // - No version specified (shared resources, e.g. stylesheets)
+ // - There were errors (recover quickly)
+ // - Version mismatch (T117587, T47877)
+ if ( $context->getVersion() === null
+ || $errors
+ || $context->getVersion() !== $this->makeVersionQuery( $context, $context->getModules() )
+ ) {
+ $maxage = $rlMaxage['unversioned'];
+ // If a version was specified we can use a longer expiry time since changing
+ // version numbers causes cache misses
+ } else {
+ $maxage = $rlMaxage['versioned'];
+ }
+ if ( $context->getImageObj() ) {
+ // Output different headers if we're outputting textual errors.
+ if ( $errors ) {
+ header( 'Content-Type: text/plain; charset=utf-8' );
+ } else {
+ $context->getImageObj()->sendResponseHeaders( $context );
+ }
+ } elseif ( $context->getOnly() === 'styles' ) {
+ header( 'Content-Type: text/css; charset=utf-8' );
+ header( 'Access-Control-Allow-Origin: *' );
+ } else {
+ header( 'Content-Type: text/javascript; charset=utf-8' );
+ }
+ // See RFC 2616 § 14.19 ETag
+ // https://www.w3.org/Protocols/rfc2616/rfc2616-sec14.html#sec14.19
+ header( 'ETag: ' . $etag );
+ if ( $context->getDebug() ) {
+ // Do not cache debug responses
+ header( 'Cache-Control: private, no-cache, must-revalidate' );
+ header( 'Pragma: no-cache' );
+ } else {
+ header( "Cache-Control: public, max-age=$maxage, s-maxage=$maxage" );
+ header( 'Expires: ' . ConvertibleTimestamp::convert( TS_RFC2822, time() + $maxage ) );
+ }
+ foreach ( $extra as $header ) {
+ header( $header );
+ }
+ }
+
+ /**
+ * Respond with HTTP 304 Not Modified if appropriate.
+ *
+ * If there's an If-None-Match header, respond with a 304 appropriately
+ * and clear out the output buffer. If the client cache is too old then do nothing.
+ *
+ * @param Context $context
+ * @param string $etag ETag header value
+ * @return bool True if HTTP 304 was sent and output handled
+ */
+ protected function tryRespondNotModified( Context $context, $etag ) {
+ // See RFC 2616 § 14.26 If-None-Match
+ // https://www.w3.org/Protocols/rfc2616/rfc2616-sec14.html#sec14.26
+ $clientKeys = $context->getRequest()->getHeader( 'If-None-Match', WebRequest::GETHEADER_LIST );
+ // Never send 304s in debug mode
+ if ( $clientKeys !== false && !$context->getDebug() && in_array( $etag, $clientKeys ) ) {
+ // There's another bug in ob_gzhandler (see also the comment at
+ // the top of this function) that causes it to gzip even empty
+ // responses, meaning it's impossible to produce a truly empty
+ // response (because the gzip header is always there). This is
+ // a problem because 304 responses have to be completely empty
+ // per the HTTP spec, and Firefox behaves buggily when they're not.
+ // See also https://bugs.php.net/bug.php?id=51579
+ // To work around this, we tear down all output buffering before
+ // sending the 304.
+ wfResetOutputBuffers( /* $resetGzipEncoding = */ true );
+
+ HttpStatus::header( 304 );
+
+ $this->sendResponseHeaders( $context, $etag, false );
+ return true;
+ }
+ return false;
+ }
+
+ /**
+ * Send out code for a response from file cache if possible.
+ *
+ * @param ResourceFileCache $fileCache Cache object for this request URL
+ * @param Context $context Context in which to generate a response
+ * @param string $etag ETag header value
+ * @return bool If this found a cache file and handled the response
+ */
+ protected function tryRespondFromFileCache(
+ ResourceFileCache $fileCache,
+ Context $context,
+ $etag
+ ) {
+ $rlMaxage = $this->config->get( MainConfigNames::ResourceLoaderMaxage );
+ // Buffer output to catch warnings.
+ ob_start();
+ // Get the maximum age the cache can be
+ $maxage = $context->getVersion() === null
+ ? $rlMaxage['unversioned']
+ : $rlMaxage['versioned'];
+ // Minimum timestamp the cache file must have
+ $minTime = time() - $maxage;
+ $good = $fileCache->isCacheGood( ConvertibleTimestamp::convert( TS_MW, $minTime ) );
+ if ( !$good ) {
+ try { // RL always hits the DB on file cache miss...
+ wfGetDB( DB_REPLICA );
+ } catch ( DBConnectionError $e ) { // ...check if we need to fallback to cache
+ $good = $fileCache->isCacheGood(); // cache existence check
+ }
+ }
+ if ( $good ) {
+ $ts = $fileCache->cacheTimestamp();
+ // Send content type and cache headers
+ $this->sendResponseHeaders( $context, $etag, false );
+ $response = $fileCache->fetchText();
+ // Capture any PHP warnings from the output buffer and append them to the
+ // response in a comment if we're in debug mode.
+ if ( $context->getDebug() ) {
+ $warnings = ob_get_contents();
+ if ( strlen( $warnings ) ) {
+ $response = self::makeComment( $warnings ) . $response;
+ }
+ }
+ // Remove the output buffer and output the response
+ ob_end_clean();
+ echo $response . "\n/* Cached {$ts} */";
+ return true; // cache hit
+ }
+ // Clear buffer
+ ob_end_clean();
+
+ return false; // cache miss
+ }
+
+ /**
+ * Generate a CSS or JS comment block.
+ *
+ * Only use this for public data, not error message details.
+ *
+ * @param string $text
+ * @return string
+ */
+ public static function makeComment( $text ) {
+ $encText = str_replace( '*/', '* /', $text );
+ return "/*\n$encText\n*/\n";
+ }
+
+ /**
+ * Handle exception display.
+ *
+ * @param Throwable $e Exception to be shown to the user
+ * @return string Sanitized text in a CSS/JS comment that can be returned to the user
+ */
+ public static function formatException( Throwable $e ) {
+ return self::makeComment( self::formatExceptionNoComment( $e ) );
+ }
+
+ /**
+ * Handle exception display.
+ *
+ * @since 1.25
+ * @param Throwable $e Exception to be shown to the user
+ * @return string Sanitized text that can be returned to the user
+ */
+ protected static function formatExceptionNoComment( Throwable $e ) {
+ if ( !MWExceptionRenderer::shouldShowExceptionDetails() ) {
+ return MWExceptionHandler::getPublicLogMessage( $e );
+ }
+
+ return MWExceptionHandler::getLogMessage( $e ) .
+ "\nBacktrace:\n" .
+ MWExceptionHandler::getRedactedTraceAsString( $e );
+ }
+
+ /**
+ * Generate code for a response.
+ *
+ * Calling this method also populates the `errors` and `headers` members,
+ * later used by respond().
+ *
+ * @param Context $context Context in which to generate a response
+ * @param Module[] $modules List of module objects keyed by module name
+ * @param string[] $missing List of requested module names that are unregistered (optional)
+ * @return string Response data
+ */
+ public function makeModuleResponse( Context $context,
+ array $modules, array $missing = []
+ ) {
+ $out = '';
+ $states = [];
+
+ if ( $modules === [] && $missing === [] ) {
+ return <<<MESSAGE
+/* This file is the Web entry point for MediaWiki's ResourceLoader:
+ <https://www.mediawiki.org/wiki/ResourceLoader>. In this request,
+ no modules were requested. Max made me put this here. */
+MESSAGE;
+ }
+
+ $image = $context->getImageObj();
+ if ( $image ) {
+ $data = $image->getImageData( $context );
+ if ( $data === false ) {
+ $data = '';
+ $this->errors[] = 'Image generation failed';
+ }
+ return $data;
+ }
+
+ foreach ( $missing as $name ) {
+ $states[$name] = 'missing';
+ }
+
+ $only = $context->getOnly();
+ $filter = $only === 'styles' ? 'minify-css' : 'minify-js';
+ $debug = (bool)$context->getDebug();
+
+ foreach ( $modules as $name => $module ) {
+ try {
+ $content = $module->getModuleContent( $context );
+ $implementKey = $name . '@' . $module->getVersionHash( $context );
+ $strContent = '';
+
+ if ( isset( $content['headers'] ) ) {
+ $this->extraHeaders = array_merge( $this->extraHeaders, $content['headers'] );
+ }
+
+ // Append output
+ switch ( $only ) {
+ case 'scripts':
+ $scripts = $content['scripts'];
+ if ( is_string( $scripts ) ) {
+ // Load scripts raw...
+ $strContent = $scripts;
+ } elseif ( is_array( $scripts ) ) {
+ // ...except when $scripts is an array of URLs or an associative array
+ $strContent = self::makeLoaderImplementScript(
+ $context,
+ $implementKey,
+ $scripts,
+ [],
+ [],
+ []
+ );
+ }
+ break;
+ case 'styles':
+ $styles = $content['styles'];
+ // We no longer separate into media, they are all combined now with
+ // custom media type groups into @media .. {} sections as part of the css string.
+ // Module returns either an empty array or a numerical array with css strings.
+ $strContent = isset( $styles['css'] ) ? implode( '', $styles['css'] ) : '';
+ break;
+ default:
+ $scripts = $content['scripts'] ?? '';
+ if ( is_string( $scripts ) ) {
+ if ( $name === 'site' || $name === 'user' ) {
+ // Legacy scripts that run in the global scope without a closure.
+ // mw.loader.implement will use eval if scripts is a string.
+ // Minify manually here, because general response minification is
+ // not effective due it being a string literal, not a function.
+ if ( !$debug ) {
+ $scripts = self::filter( 'minify-js', $scripts ); // T107377
+ }
+ } else {
+ $scripts = new XmlJsCode( $scripts );
+ }
+ }
+ $strContent = self::makeLoaderImplementScript(
+ $context,
+ $implementKey,
+ $scripts,
+ $content['styles'] ?? [],
+ isset( $content['messagesBlob'] ) ? new XmlJsCode( $content['messagesBlob'] ) : [],
+ $content['templates'] ?? []
+ );
+ break;
+ }
+
+ if ( !$debug ) {
+ $strContent = self::filter( $filter, $strContent, [
+ // Important: Do not cache minifications of embedded modules
+ // This is especially for the private 'user.options' module,
+ // which varies on every pageview and would explode the cache (T84960)
+ 'cache' => !$module->shouldEmbedModule( $context )
+ ] );
+ } else {
+ // In debug mode, separate each response by a new line.
+ // For example, between 'mw.loader.implement();' statements.
+ $strContent = self::ensureNewline( $strContent );
+ }
+
+ if ( $only === 'scripts' ) {
+ // Use a linebreak between module scripts (T162719)
+ $out .= self::ensureNewline( $strContent );
+ } else {
+ $out .= $strContent;
+ }
+
+ } catch ( TimeoutException $e ) {
+ throw $e;
+ } catch ( Exception $e ) {
+ $this->outputErrorAndLog( $e, 'Generating module package failed: {exception}' );
+
+ // Respond to client with error-state instead of module implementation
+ $states[$name] = 'error';
+ unset( $modules[$name] );
+ }
+ }
+
+ // Update module states
+ if ( $context->shouldIncludeScripts() && !$context->getRaw() ) {
+ if ( $modules && $only === 'scripts' ) {
+ // Set the state of modules loaded as only scripts to ready as
+ // they don't have an mw.loader.implement wrapper that sets the state
+ foreach ( $modules as $name => $module ) {
+ $states[$name] = 'ready';
+ }
+ }
+
+ // Set the state of modules we didn't respond to with mw.loader.implement
+ if ( $states ) {
+ $stateScript = self::makeLoaderStateScript( $context, $states );
+ if ( !$debug ) {
+ $stateScript = self::filter( 'minify-js', $stateScript );
+ }
+ // Use a linebreak between module script and state script (T162719)
+ $out = self::ensureNewline( $out ) . $stateScript;
+ }
+ } elseif ( $states ) {
+ $this->errors[] = 'Problematic modules: '
+ . $context->encodeJson( $states );
+ }
+
+ return $out;
+ }
+
+ /**
+ * Ensure the string is either empty or ends in a line break
+ * @internal
+ * @param string $str
+ * @return string
+ */
+ public static function ensureNewline( $str ) {
+ $end = substr( $str, -1 );
+ if ( $end === false || $end === '' || $end === "\n" ) {
+ return $str;
+ }
+ return $str . "\n";
+ }
+
+ /**
+ * Get names of modules that use a certain message.
+ *
+ * @param string $messageKey
+ * @return string[] List of module names
+ */
+ public function getModulesByMessage( $messageKey ) {
+ $moduleNames = [];
+ foreach ( $this->getModuleNames() as $moduleName ) {
+ $module = $this->getModule( $moduleName );
+ if ( in_array( $messageKey, $module->getMessages() ) ) {
+ $moduleNames[] = $moduleName;
+ }
+ }
+ return $moduleNames;
+ }
+
+ /**
+ * Return JS code that calls mw.loader.implement with given module properties.
+ *
+ * @param Context $context
+ * @param string $name Module name or implement key (format "`[name]@[version]`")
+ * @param XmlJsCode|array|string $scripts Code as XmlJsCode (to be wrapped in a closure),
+ * list of URLs to JavaScript files, string of JavaScript for eval, or array with
+ * 'files' and 'main' properties (see ResourceLoaderModule::getScript())
+ * @param mixed $styles Array of CSS strings keyed by media type, or an array of lists of URLs
+ * to CSS files keyed by media type
+ * @param mixed $messages List of messages associated with this module. May either be an
+ * associative array mapping message key to value, or a JSON-encoded message blob containing
+ * the same data, wrapped in an XmlJsCode object.
+ * @param array $templates Keys are name of templates and values are the source of
+ * the template.
+ * @return string JavaScript code
+ */
+ private static function makeLoaderImplementScript(
+ Context $context, $name, $scripts, $styles, $messages, $templates
+ ) {
+ if ( $scripts instanceof XmlJsCode ) {
+ if ( $scripts->value === '' ) {
+ $scripts = null;
+ } else {
+ $scripts = new XmlJsCode( "function ( $, jQuery, require, module ) {\n{$scripts->value}\n}" );
+ }
+ } elseif ( is_array( $scripts ) && isset( $scripts['files'] ) ) {
+ $files = $scripts['files'];
+ foreach ( $files as $path => &$file ) {
+ // $file is changed (by reference) from a descriptor array to the content of the file
+ // All of these essentially do $file = $file['content'];, some just have wrapping around it
+ if ( $file['type'] === 'script' ) {
+ // Ensure that the script has a newline at the end to close any comment in the
+ // last line.
+ $content = self::ensureNewline( $file['content'] );
+ // Provide CJS `exports` (in addition to CJS2 `module.exports`) to package modules (T284511).
+ // $/jQuery are simply used as globals instead.
+ // TODO: Remove $/jQuery param from traditional module closure too (and bump caching)
+ $file = new XmlJsCode( "function ( require, module, exports ) {\n$content}" );
+ } else {
+ $file = $file['content'];
+ }
+ }
+ $scripts = XmlJsCode::encodeObject( [
+ 'main' => $scripts['main'],
+ 'files' => XmlJsCode::encodeObject( $files, true )
+ ], true );
+ } elseif ( !is_string( $scripts ) && !is_array( $scripts ) ) {
+ throw new InvalidArgumentException( 'Script must be a string or an array of URLs' );
+ }
+
+ // mw.loader.implement requires 'styles', 'messages' and 'templates' to be objects (not
+ // arrays). json_encode considers empty arrays to be numerical and outputs "[]" instead
+ // of "{}". Force them to objects.
+ $module = [
+ $name,
+ $scripts,
+ (object)$styles,
+ (object)$messages,
+ (object)$templates
+ ];
+ self::trimArray( $module );
+
+ // We use pretty output unconditionally to make this method simpler.
+ // Minification is taken care of closer to the output.
+ return Xml::encodeJsCall( 'mw.loader.implement', $module, true );
+ }
+
+ /**
+ * Returns JS code which, when called, will register a given list of messages.
+ *
+ * @param mixed $messages Associative array mapping message key to value.
+ * @return string JavaScript code
+ */
+ public static function makeMessageSetScript( $messages ) {
+ return 'mw.messages.set('
+ . self::encodeJsonForScript( (object)$messages )
+ . ');';
+ }
+
+ /**
+ * Combines an associative array mapping media type to CSS into a
+ * single stylesheet with "@media" blocks.
+ *
+ * @param array<string,string|string[]> $stylePairs Map from media type to CSS string(s)
+ * @return string[] CSS strings
+ */
+ public static function makeCombinedStyles( array $stylePairs ) {
+ $out = [];
+ foreach ( $stylePairs as $media => $styles ) {
+ // FileModule::getStyle can return the styles as a string or an
+ // array of strings. This is to allow separation in the front-end.
+ $styles = (array)$styles;
+ foreach ( $styles as $style ) {
+ $style = trim( $style );
+ // Don't output an empty "@media print { }" block (T42498)
+ if ( $style !== '' ) {
+ // Transform the media type based on request params and config
+ // The way that this relies on $wgRequest to propagate request params is slightly evil
+ $media = OutputPage::transformCssMedia( $media );
+
+ if ( $media === '' || $media == 'all' ) {
+ $out[] = $style;
+ } elseif ( is_string( $media ) ) {
+ $out[] = "@media $media {\n" . str_replace( "\n", "\n\t", "\t" . $style ) . "}";
+ }
+ // else: skip
+ }
+ }
+ }
+ return $out;
+ }
+
+ /**
+ * Wrapper around json_encode that avoids needless escapes,
+ * and pretty-prints in debug mode.
+ *
+ * @internal For use within ResourceLoader classes only
+ * @since 1.32
+ * @param mixed $data
+ * @return string|false JSON string, false on error
+ */
+ public static function encodeJsonForScript( $data ) {
+ // Keep output as small as possible by disabling needless escape modes
+ // that PHP uses by default.
+ // However, while most module scripts are only served on HTTP responses
+ // for JavaScript, some modules can also be embedded in the HTML as inline
+ // scripts. This, and the fact that we sometimes need to export strings
+ // containing user-generated content and labels that may genuinely contain
+ // a sequences like "</script>", we need to encode either '/' or '<'.
+ // By default PHP escapes '/'. Let's escape '<' instead which is less common
+ // and allows URLs to mostly remain readable.
+ $jsonFlags = JSON_UNESCAPED_SLASHES |
+ JSON_UNESCAPED_UNICODE |
+ JSON_HEX_TAG |
+ JSON_HEX_AMP;
+ if ( self::inDebugMode() ) {
+ $jsonFlags |= JSON_PRETTY_PRINT;
+ }
+ return json_encode( $data, $jsonFlags );
+ }
+
+ /**
+ * Returns a JS call to mw.loader.state, which sets the state of modules
+ * to a given value:
+ *
+ * - ResourceLoader::makeLoaderStateScript( $context, [ $name => $state, ... ] ):
+ * Set the state of modules with the given names to the given states
+ *
+ * @internal For use by StartUpModule
+ * @param Context $context
+ * @param array<string,string> $states
+ * @return string JavaScript code
+ */
+ public static function makeLoaderStateScript(
+ Context $context, array $states
+ ) {
+ return 'mw.loader.state('
+ . $context->encodeJson( $states )
+ . ');';
+ }
+
+ private static function isEmptyObject( stdClass $obj ) {
+ foreach ( $obj as $key => $value ) {
+ return false;
+ }
+ return true;
+ }
+
+ /**
+ * Remove empty values from the end of an array.
+ *
+ * Values considered empty:
+ *
+ * - null
+ * - []
+ * - new XmlJsCode( '{}' )
+ * - new stdClass()
+ * - (object)[]
+ *
+ * @param array &$array
+ */
+ private static function trimArray( array &$array ): void {
+ $i = count( $array );
+ while ( $i-- ) {
+ if ( $array[$i] === null
+ || $array[$i] === []
+ || ( $array[$i] instanceof XmlJsCode && $array[$i]->value === '{}' )
+ || ( $array[$i] instanceof stdClass && self::isEmptyObject( $array[$i] ) )
+ ) {
+ unset( $array[$i] );
+ } else {
+ break;
+ }
+ }
+ }
+
+ /**
+ * Format JS code which calls `mw.loader.register()` with the given parameters.
+ *
+ * @par Example
+ * @code
+ *
+ * ResourceLoader::makeLoaderRegisterScript( $context, [
+ * [ $name1, $version1, $dependencies1, $group1, $source1, $skip1 ],
+ * [ $name2, $version2, $dependencies1, $group2, $source2, $skip2 ],
+ * ...
+ * ] ):
+ * @endcode
+ *
+ * @internal For use by StartUpModule only
+ * @param Context $context
+ * @param array[] $modules Array of module registration arrays, each containing
+ * - string: module name
+ * - string: module version
+ * - array|null: List of dependencies (optional)
+ * - string|null: Module group (optional)
+ * - string|null: Name of foreign module source, or 'local' (optional)
+ * - string|null: Script body of a skip function (optional)
+ * @phan-param array<int,array{0:string,1:string,2?:?array,3?:?string,4?:?string,5?:?string}> $modules
+ * @return string JavaScript code
+ */
+ public static function makeLoaderRegisterScript(
+ Context $context, array $modules
+ ) {
+ // Optimisation: Transform dependency names into indexes when possible
+ // to produce smaller output. They are expanded by mw.loader.register on
+ // the other end.
+ $index = [];
+ foreach ( $modules as $i => &$module ) {
+ // Build module name index
+ $index[$module[0]] = $i;
+ }
+ foreach ( $modules as &$module ) {
+ if ( isset( $module[2] ) ) {
+ foreach ( $module[2] as &$dependency ) {
+ if ( isset( $index[$dependency] ) ) {
+ // Replace module name in dependency list with index
+ $dependency = $index[$dependency];
+ }
+ }
+ }
+ }
+
+ array_walk( $modules, [ self::class, 'trimArray' ] );
+
+ return 'mw.loader.register('
+ . $context->encodeJson( $modules )
+ . ');';
+ }
+
+ /**
+ * Format JS code which calls `mw.loader.addSource()` with the given parameters.
+ *
+ * - ResourceLoader::makeLoaderSourcesScript( $context,
+ * [ $id1 => $loadUrl, $id2 => $loadUrl, ... ]
+ * );
+ * Register sources with the given IDs and properties.
+ *
+ * @internal For use by StartUpModule only
+ * @param Context $context
+ * @param array<string,string> $sources
+ * @return string JavaScript code
+ */
+ public static function makeLoaderSourcesScript(
+ Context $context, array $sources
+ ) {
+ return 'mw.loader.addSource('
+ . $context->encodeJson( $sources )
+ . ');';
+ }
+
+ /**
+ * Wrap JavaScript code to run after the startup module.
+ *
+ * @param string $script JavaScript code
+ * @return string JavaScript code
+ */
+ public static function makeLoaderConditionalScript( $script ) {
+ // Adds a function to lazy-created RLQ
+ return '(RLQ=window.RLQ||[]).push(function(){' .
+ trim( $script ) . '});';
+ }
+
+ /**
+ * Wrap JavaScript code to run after a required module.
+ *
+ * @since 1.32
+ * @param string|string[] $modules Module name(s)
+ * @param string $script JavaScript code
+ * @return string JavaScript code
+ */
+ public static function makeInlineCodeWithModule( $modules, $script ) {
+ // Adds an array to lazy-created RLQ
+ return '(RLQ=window.RLQ||[]).push(['
+ . self::encodeJsonForScript( $modules ) . ','
+ . 'function(){' . trim( $script ) . '}'
+ . ']);';
+ }
+
+ /**
+ * Make an HTML script that runs given JS code after startup and base modules.
+ *
+ * The code will be wrapped in a closure, and it will be executed by ResourceLoader's
+ * startup module if the client has adequate support for MediaWiki JavaScript code.
+ *
+ * @param string $script JavaScript code
+ * @param string|null $nonce Content-Security-Policy nonce
+ * (from `OutputPage->getCSP()->getNonce()`)
+ * @return string|WrappedString HTML
+ */
+ public static function makeInlineScript( $script, $nonce = null ) {
+ $js = self::makeLoaderConditionalScript( $script );
+ $escNonce = '';
+ if ( $nonce === null ) {
+ wfWarn( __METHOD__ . " did not get nonce. Will break CSP" );
+ } elseif ( $nonce !== false ) {
+ // If it was false, CSP is disabled, so no nonce attribute.
+ // Nonce should be only base64 characters, so should be safe,
+ // but better to be safely escaped than sorry.
+ $escNonce = ' nonce="' . htmlspecialchars( $nonce ) . '"';
+ }
+
+ return new WrappedString(
+ Html::inlineScript( $js, $nonce ),
+ "<script$escNonce>(RLQ=window.RLQ||[]).push(function(){",
+ '});</script>'
+ );
+ }
+
+ /**
+ * Return JS code which will set the MediaWiki configuration array to
+ * the given value.
+ *
+ * @param array $configuration List of configuration values keyed by variable name
+ * @return string JavaScript code
+ * @throws Exception
+ */
+ public static function makeConfigSetScript( array $configuration ) {
+ $json = self::encodeJsonForScript( $configuration );
+ if ( $json === false ) {
+ $e = new Exception(
+ 'JSON serialization of config data failed. ' .
+ 'This usually means the config data is not valid UTF-8.'
+ );
+ MWExceptionHandler::logException( $e );
+ return 'mw.log.error(' . self::encodeJsonForScript( $e->__toString() ) . ');';
+ }
+ return "mw.config.set($json);";
+ }
+
+ /**
+ * Convert an array of module names to a packed query string.
+ *
+ * For example, `[ 'foo.bar', 'foo.baz', 'bar.baz', 'bar.quux' ]`
+ * becomes `'foo.bar,baz|bar.baz,quux'`.
+ *
+ * This process is reversed by ResourceLoader::expandModuleNames().
+ * See also mw.loader#buildModulesString() which is a port of this, used
+ * on the client-side.
+ *
+ * @param string[] $modules List of module names (strings)
+ * @return string Packed query string
+ */
+ public static function makePackedModulesString( array $modules ) {
+ $moduleMap = []; // [ prefix => [ suffixes ] ]
+ foreach ( $modules as $module ) {
+ $pos = strrpos( $module, '.' );
+ $prefix = $pos === false ? '' : substr( $module, 0, $pos );
+ $suffix = $pos === false ? $module : substr( $module, $pos + 1 );
+ $moduleMap[$prefix][] = $suffix;
+ }
+
+ $arr = [];
+ foreach ( $moduleMap as $prefix => $suffixes ) {
+ $p = $prefix === '' ? '' : $prefix . '.';
+ $arr[] = $p . implode( ',', $suffixes );
+ }
+ return implode( '|', $arr );
+ }
+
+ /**
+ * Expand a string of the form `jquery.foo,bar|jquery.ui.baz,quux` to
+ * an array of module names like `[ 'jquery.foo', 'jquery.bar',
+ * 'jquery.ui.baz', 'jquery.ui.quux' ]`.
+ *
+ * This process is reversed by ResourceLoader::makePackedModulesString().
+ *
+ * @since 1.33
+ * @param string $modules Packed module name list
+ * @return string[] Array of module names
+ */
+ public static function expandModuleNames( $modules ) {
+ $retval = [];
+ $exploded = explode( '|', $modules );
+ foreach ( $exploded as $group ) {
+ if ( strpos( $group, ',' ) === false ) {
+ // This is not a set of modules in foo.bar,baz notation
+ // but a single module
+ $retval[] = $group;
+ } else {
+ // This is a set of modules in foo.bar,baz notation
+ $pos = strrpos( $group, '.' );
+ if ( $pos === false ) {
+ // Prefixless modules, i.e. without dots
+ $retval = array_merge( $retval, explode( ',', $group ) );
+ } else {
+ // We have a prefix and a bunch of suffixes
+ $prefix = substr( $group, 0, $pos ); // 'foo'
+ $suffixes = explode( ',', substr( $group, $pos + 1 ) ); // [ 'bar', 'baz' ]
+ foreach ( $suffixes as $suffix ) {
+ $retval[] = "$prefix.$suffix";
+ }
+ }
+ }
+ }
+ return $retval;
+ }
+
+ /**
+ * Determine whether debug mode is on.
+ *
+ * Order of priority is:
+ * - 1) Request parameter,
+ * - 2) Cookie,
+ * - 3) Site configuration.
+ *
+ * @return int
+ */
+ public static function inDebugMode() {
+ if ( self::$debugMode === null ) {
+ global $wgRequest;
+ $resourceLoaderDebug = MediaWikiServices::getInstance()->getMainConfig()->get(
+ MainConfigNames::ResourceLoaderDebug );
+ $str = $wgRequest->getRawVal( 'debug',
+ $wgRequest->getCookie( 'resourceLoaderDebug', '', $resourceLoaderDebug ? 'true' : '' )
+ );
+ self::$debugMode = Context::debugFromString( $str );
+ }
+ return self::$debugMode;
+ }
+
+ /**
+ * Reset static members used for caching.
+ *
+ * Global state and $wgRequest are evil, but we're using it right
+ * now and sometimes we need to be able to force ResourceLoader to
+ * re-evaluate the context because it has changed (e.g. in the test suite).
+ *
+ * @internal For use by unit tests
+ * @codeCoverageIgnore
+ */
+ public static function clearCache() {
+ self::$debugMode = null;
+ }
+
+ /**
+ * Build a load.php URL
+ *
+ * @since 1.24
+ * @param string $source Name of the ResourceLoader source
+ * @param Context $context
+ * @param array $extraQuery
+ * @return string URL to load.php. May be protocol-relative if $wgLoadScript is, too.
+ */
+ public function createLoaderURL( $source, Context $context,
+ array $extraQuery = []
+ ) {
+ $query = self::createLoaderQuery( $context, $extraQuery );
+ $script = $this->getLoadScript( $source );
+
+ return wfAppendQuery( $script, $query );
+ }
+
+ /**
+ * Helper for createLoaderURL()
+ *
+ * @since 1.24
+ * @see makeLoaderQuery
+ * @param Context $context
+ * @param array $extraQuery
+ * @return array
+ */
+ protected static function createLoaderQuery(
+ Context $context, array $extraQuery = []
+ ) {
+ return self::makeLoaderQuery(
+ $context->getModules(),
+ $context->getLanguage(),
+ $context->getSkin(),
+ $context->getUser(),
+ $context->getVersion(),
+ $context->getDebug(),
+ $context->getOnly(),
+ $context->getRequest()->getBool( 'printable' ),
+ null,
+ $extraQuery
+ );
+ }
+
+ /**
+ * Build a query array (array representation of query string) for load.php. Helper
+ * function for createLoaderURL().
+ *
+ * @param string[] $modules
+ * @param string $lang
+ * @param string $skin
+ * @param string|null $user
+ * @param string|null $version
+ * @param int $debug
+ * @param string|null $only
+ * @param bool $printable
+ * @param bool|null $handheld Unused as of MW 1.38
+ * @param array $extraQuery
+ * @return array
+ */
+ public static function makeLoaderQuery( array $modules, $lang, $skin, $user = null,
+ $version = null, $debug = Context::DEBUG_OFF, $only = null,
+ $printable = false, $handheld = null, array $extraQuery = []
+ ) {
+ $query = [
+ 'modules' => self::makePackedModulesString( $modules ),
+ ];
+ // Keep urls short by omitting query parameters that
+ // match the defaults assumed by Context.
+ // Note: This relies on the defaults either being insignificant or forever constant,
+ // as otherwise cached urls could change in meaning when the defaults change.
+ if ( $lang !== Context::DEFAULT_LANG ) {
+ $query['lang'] = $lang;
+ }
+ if ( $skin !== Context::DEFAULT_SKIN ) {
+ $query['skin'] = $skin;
+ }
+ if ( $debug !== Context::DEBUG_OFF ) {
+ $query['debug'] = strval( $debug );
+ }
+ if ( $user !== null ) {
+ $query['user'] = $user;
+ }
+ if ( $version !== null ) {
+ $query['version'] = $version;
+ }
+ if ( $only !== null ) {
+ $query['only'] = $only;
+ }
+ if ( $printable ) {
+ $query['printable'] = 1;
+ }
+ $query += $extraQuery;
+
+ // Make queries uniform in order
+ ksort( $query );
+ return $query;
+ }
+
+ /**
+ * Check a module name for validity.
+ *
+ * Module names may not contain pipes (|), commas (,) or exclamation marks (!) and can be
+ * at most 255 bytes.
+ *
+ * @param string $moduleName Module name to check
+ * @return bool Whether $moduleName is a valid module name
+ */
+ public static function isValidModuleName( $moduleName ) {
+ $len = strlen( $moduleName );
+ return $len <= 255 && strcspn( $moduleName, '!,|', 0, $len ) === $len;
+ }
+
+ /**
+ * Return a LESS compiler that is set up for use with MediaWiki.
+ *
+ * @since 1.27
+ * @param array $vars Associative array of variables that should be used
+ * for compilation. Since 1.32, this method no longer automatically includes
+ * global LESS vars from ResourceLoader::getLessVars (T191937).
+ * @param array $importDirs Additional directories to look in for @import (since 1.36)
+ * @throws MWException
+ * @return Less_Parser
+ */
+ public function getLessCompiler( array $vars = [], array $importDirs = [] ) {
+ global $IP;
+ // When called from the installer, it is possible that a required PHP extension
+ // is missing (at least for now; see T49564). If this is the case, throw an
+ // exception (caught by the installer) to prevent a fatal error later on.
+ if ( !class_exists( Less_Parser::class ) ) {
+ throw new MWException( 'MediaWiki requires the less.php parser' );
+ }
+
+ $importDirs[] = "$IP/resources/src/mediawiki.less";
+
+ $parser = new Less_Parser;
+ $parser->ModifyVars( $vars );
+ // SetImportDirs expects an array like [ 'path1' => '', 'path2' => '' ]
+ $parser->SetImportDirs( array_fill_keys( $importDirs, '' ) );
+ $parser->SetOption( 'relativeUrls', false );
+
+ return $parser;
+ }
+
+ /**
+ * Resolve a possibly relative URL against a base URL.
+ *
+ * The base URL must have a server and should have a protocol.
+ * A protocol-relative base expands to HTTPS.
+ *
+ * This is a standalone version of MediaWiki's wfExpandUrl (T32956).
+ *
+ * @internal For use by core ResourceLoader classes only
+ * @param string $base
+ * @param string $url
+ * @return string URL
+ */
+ public function expandUrl( string $base, string $url ): string {
+ // Net_URL2::resolve() doesn't allow protocol-relative URLs, but we do.
+ $isProtoRelative = strpos( $base, '//' ) === 0;
+ if ( $isProtoRelative ) {
+ $base = "https:$base";
+ }
+ // Net_URL2::resolve() takes care of throwing if $base doesn't have a server.
+ $baseUrl = new Net_URL2( $base );
+ $ret = $baseUrl->resolve( $url );
+ if ( $isProtoRelative ) {
+ $ret->setScheme( false );
+ }
+ return $ret->getURL();
+ }
+
+ /**
+ * Run JavaScript or CSS data through a filter, caching the filtered result for future calls.
+ *
+ * Available filters are:
+ *
+ * - minify-js
+ * - minify-css
+ *
+ * If $data is empty, only contains whitespace or the filter was unknown,
+ * $data is returned unmodified.
+ *
+ * @param string $filter Name of filter to run
+ * @param string $data Text to filter, such as JavaScript or CSS text
+ * @param array<string,bool> $options Keys:
+ * - (bool) cache: Whether to allow caching this data. Default: true.
+ * @return string Filtered data or unfiltered data
+ */
+ public static function filter( $filter, $data, array $options = [] ) {
+ if ( strpos( $data, self::FILTER_NOMIN ) !== false ) {
+ return $data;
+ }
+
+ if ( isset( $options['cache'] ) && $options['cache'] === false ) {
+ return self::applyFilter( $filter, $data ) ?? $data;
+ }
+
+ $stats = MediaWikiServices::getInstance()->getStatsdDataFactory();
+ $cache = ObjectCache::getLocalServerInstance( CACHE_ANYTHING );
+
+ $key = $cache->makeGlobalKey(
+ 'resourceloader-filter',
+ $filter,
+ self::CACHE_VERSION,
+ md5( $data )
+ );
+
+ $incKey = "resourceloader_cache.$filter.hit";
+ $result = $cache->getWithSetCallback(
+ $key,
+ BagOStuff::TTL_DAY,
+ function () use ( $filter, $data, &$incKey ) {
+ $incKey = "resourceloader_cache.$filter.miss";
+ return self::applyFilter( $filter, $data );
+ }
+ );
+ $stats->increment( $incKey );
+ if ( $result === null ) {
+ // Cached failure
+ $result = $data;
+ }
+
+ return $result;
+ }
+
+ /**
+ * @param string $filter
+ * @param string $data
+ * @return string|null
+ */
+ private static function applyFilter( $filter, $data ) {
+ $data = trim( $data );
+ if ( $data ) {
+ try {
+ $data = ( $filter === 'minify-css' )
+ ? CSSMin::minify( $data )
+ : JavaScriptMinifier::minify( $data );
+ } catch ( TimeoutException $e ) {
+ throw $e;
+ } catch ( Exception $e ) {
+ MWExceptionHandler::logException( $e );
+ return null;
+ }
+ }
+ return $data;
+ }
+
+ /**
+ * Get user default options to expose to JavaScript on all pages via `mw.user.options`.
+ *
+ * @internal Exposed for use from Resources.php
+ * @param Context $context
+ * @return array
+ */
+ public static function getUserDefaults( Context $context ): array {
+ // TODO inject
+ $defaultOptions = MediaWikiServices::getInstance()->getUserOptionsLookup()->getDefaultOptions();
+ $keysToExclude = [];
+ $hookRunner = new HookRunner( MediaWikiServices::getInstance()->getHookContainer() );
+ $hookRunner->onResourceLoaderExcludeUserOptions( $keysToExclude, $context );
+ foreach ( $keysToExclude as $excludedKey ) {
+ unset( $defaultOptions[ $excludedKey ] );
+ }
+ return $defaultOptions;
+ }
+
+ /**
+ * Get site configuration settings to expose to JavaScript on all pages via `mw.config`.
+ *
+ * @internal Exposed for use from Resources.php
+ * @param Context $context
+ * @param Config $conf
+ * @return array
+ */
+ public static function getSiteConfigSettings(
+ Context $context, Config $conf
+ ): array {
+ // Namespace related preparation
+ // - wgNamespaceIds: Key-value pairs of all localized, canonical and aliases for namespaces.
+ // - wgCaseSensitiveNamespaces: Array of namespaces that are case-sensitive.
+ $contLang = MediaWikiServices::getInstance()->getContentLanguage();
+ $namespaceIds = $contLang->getNamespaceIds();
+ $caseSensitiveNamespaces = [];
+ $nsInfo = MediaWikiServices::getInstance()->getNamespaceInfo();
+ foreach ( $nsInfo->getCanonicalNamespaces() as $index => $name ) {
+ $namespaceIds[$contLang->lc( $name )] = $index;
+ if ( !$nsInfo->isCapitalized( $index ) ) {
+ $caseSensitiveNamespaces[] = $index;
+ }
+ }
+
+ $illegalFileChars = $conf->get( MainConfigNames::IllegalFileChars );
+
+ // Build list of variables
+ $skin = $context->getSkin();
+
+ // Start of supported and stable config vars (for use by extensions/gadgets).
+ $vars = [
+ 'debug' => $context->getDebug(),
+ 'skin' => $skin,
+ 'stylepath' => $conf->get( MainConfigNames::StylePath ),
+ 'wgArticlePath' => $conf->get( MainConfigNames::ArticlePath ),
+ 'wgScriptPath' => $conf->get( MainConfigNames::ScriptPath ),
+ 'wgScript' => $conf->get( MainConfigNames::Script ),
+ 'wgSearchType' => $conf->get( MainConfigNames::SearchType ),
+ 'wgVariantArticlePath' => $conf->get( MainConfigNames::VariantArticlePath ),
+ 'wgServer' => $conf->get( MainConfigNames::Server ),
+ 'wgServerName' => $conf->get( MainConfigNames::ServerName ),
+ 'wgUserLanguage' => $context->getLanguage(),
+ 'wgContentLanguage' => $contLang->getCode(),
+ 'wgVersion' => MW_VERSION,
+ 'wgFormattedNamespaces' => $contLang->getFormattedNamespaces(),
+ 'wgNamespaceIds' => $namespaceIds,
+ 'wgContentNamespaces' => $nsInfo->getContentNamespaces(),
+ 'wgSiteName' => $conf->get( MainConfigNames::Sitename ),
+ 'wgDBname' => $conf->get( MainConfigNames::DBname ),
+ 'wgWikiID' => WikiMap::getCurrentWikiId(),
+ 'wgCaseSensitiveNamespaces' => $caseSensitiveNamespaces,
+ 'wgCommentCodePointLimit' => CommentStore::COMMENT_CHARACTER_LIMIT,
+ 'wgExtensionAssetsPath' => $conf->get( MainConfigNames::ExtensionAssetsPath ),
+ ];
+ // End of stable config vars.
+
+ // Internal variables for use by MediaWiki core and/or ResourceLoader.
+ $vars += [
+ // @internal For mediawiki.widgets
+ 'wgUrlProtocols' => wfUrlProtocols(),
+ // @internal For mediawiki.page.watch
+ // Force object to avoid "empty" associative array from
+ // becoming [] instead of {} in JS (T36604)
+ 'wgActionPaths' => (object)$conf->get( MainConfigNames::ActionPaths ),
+ // @internal For mediawiki.language
+ 'wgTranslateNumerals' => $conf->get( MainConfigNames::TranslateNumerals ),
+ // @internal For mediawiki.Title
+ 'wgExtraSignatureNamespaces' => $conf->get( MainConfigNames::ExtraSignatureNamespaces ),
+ 'wgLegalTitleChars' => Title::convertByteClassToUnicodeClass( Title::legalChars() ),
+ 'wgIllegalFileChars' => Title::convertByteClassToUnicodeClass( $illegalFileChars ),
+ ];
+
+ Hooks::runner()->onResourceLoaderGetConfigVars( $vars, $skin, $conf );
+
+ return $vars;
+ }
+}
+
+class_alias( ResourceLoader::class, 'ResourceLoader' );
diff --git a/includes/ResourceLoader/SiteModule.php b/includes/ResourceLoader/SiteModule.php
new file mode 100644
index 000000000000..080f001fef0b
--- /dev/null
+++ b/includes/ResourceLoader/SiteModule.php
@@ -0,0 +1,64 @@
+<?php
+/**
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation; either version 2 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License along
+ * with this program; if not, write to the Free Software Foundation, Inc.,
+ * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @author Trevor Parscal
+ * @author Roan Kattouw
+ */
+
+namespace MediaWiki\ResourceLoader;
+
+use MediaWiki\MainConfigNames;
+
+/**
+ * Module for site customizations.
+ *
+ * @ingroup ResourceLoader
+ * @internal
+ */
+class SiteModule extends WikiModule {
+ /** @var string[] What client platforms the module targets (e.g. desktop, mobile) */
+ protected $targets = [ 'desktop', 'mobile' ];
+
+ /**
+ * Get list of pages used by this module
+ *
+ * @param Context $context
+ * @return array[]
+ */
+ protected function getPages( Context $context ) {
+ $pages = [];
+ if ( $this->getConfig()->get( MainConfigNames::UseSiteJs ) ) {
+ $skin = $context->getSkin();
+ $pages['MediaWiki:Common.js'] = [ 'type' => 'script' ];
+ $pages['MediaWiki:' . ucfirst( $skin ) . '.js'] = [ 'type' => 'script' ];
+ $this->getHookRunner()->onResourceLoaderSiteModulePages( $skin, $pages );
+ }
+ return $pages;
+ }
+
+ /**
+ * @param Context|null $context
+ * @return array
+ */
+ public function getDependencies( Context $context = null ) {
+ return [ 'site.styles' ];
+ }
+}
+
+/** @deprecated since 1.39 */
+class_alias( SiteModule::class, 'ResourceLoaderSiteModule' );
diff --git a/includes/ResourceLoader/SiteStylesModule.php b/includes/ResourceLoader/SiteStylesModule.php
new file mode 100644
index 000000000000..57fce7536206
--- /dev/null
+++ b/includes/ResourceLoader/SiteStylesModule.php
@@ -0,0 +1,71 @@
+<?php
+/**
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation; either version 2 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License along
+ * with this program; if not, write to the Free Software Foundation, Inc.,
+ * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @author Trevor Parscal
+ * @author Roan Kattouw
+ */
+
+namespace MediaWiki\ResourceLoader;
+
+use MediaWiki\MainConfigNames;
+
+/**
+ * Module for site style customizations.
+ *
+ * @ingroup ResourceLoader
+ * @internal
+ */
+class SiteStylesModule extends WikiModule {
+ /** @var string[] What client platforms the module targets (e.g. desktop, mobile) */
+ protected $targets = [ 'desktop', 'mobile' ];
+
+ /**
+ * Get list of pages used by this module
+ *
+ * @param Context $context
+ * @return array[]
+ */
+ protected function getPages( Context $context ) {
+ $pages = [];
+ if ( $this->getConfig()->get( MainConfigNames::UseSiteCss ) ) {
+ $skin = $context->getSkin();
+ $pages['MediaWiki:Common.css'] = [ 'type' => 'style' ];
+ $pages['MediaWiki:' . ucfirst( $skin ) . '.css'] = [ 'type' => 'style' ];
+ $pages['MediaWiki:Print.css'] = [ 'type' => 'style', 'media' => 'print' ];
+ $this->getHookRunner()->onResourceLoaderSiteStylesModulePages( $skin, $pages );
+ }
+ return $pages;
+ }
+
+ /**
+ * @return string
+ */
+ public function getType() {
+ return self::LOAD_STYLES;
+ }
+
+ /**
+ * @return string
+ */
+ public function getGroup() {
+ return self::GROUP_SITE;
+ }
+}
+
+/** @deprecated since 1.39 */
+class_alias( SiteStylesModule::class, 'ResourceLoaderSiteStylesModule' );
diff --git a/includes/ResourceLoader/SkinModule.php b/includes/ResourceLoader/SkinModule.php
new file mode 100644
index 000000000000..63211cc6cae2
--- /dev/null
+++ b/includes/ResourceLoader/SkinModule.php
@@ -0,0 +1,724 @@
+<?php
+/**
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation; either version 2 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License along
+ * with this program; if not, write to the Free Software Foundation, Inc.,
+ * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ */
+namespace MediaWiki\ResourceLoader;
+
+use Config;
+use ConfigException;
+use InvalidArgumentException;
+use MediaWiki\MainConfigNames;
+use OutputPage;
+use Wikimedia\Minify\CSSMin;
+
+/**
+ * Module for skin stylesheets.
+ *
+ * @ingroup ResourceLoader
+ * @internal
+ */
+class SkinModule extends LessVarFileModule {
+ /**
+ * All skins are assumed to be compatible with mobile
+ */
+ public $targets = [ 'desktop', 'mobile' ];
+
+ /**
+ * Every skin should define which features it would like to reuse for core inside a
+ * ResourceLoader module that has set the class to SkinModule.
+ * For a feature to be valid it must be listed here along with the associated resources
+ *
+ * The following features are available:
+ *
+ * "accessibility":
+ * Adds universal accessibility rules.
+ *
+ * "logo":
+ * Adds CSS to style an element with class `mw-wiki-logo` using the value of wgLogos['1x'].
+ * This is enabled by default if no features are added.
+ *
+ * "normalize":
+ * Styles needed to normalize rendering across different browser rendering engines.
+ * All to address bugs and common browser inconsistencies for skins and extensions.
+ * Inspired by necolas' normalize.css. This is meant to be kept lean,
+ * basic styling beyond normalization should live in one of the following modules.
+ *
+ * "elements":
+ * The base level that only contains the most basic of common skin styles.
+ * Only styles for single elements are included, no styling for complex structures like the
+ * TOC is present. This level is for skins that want to implement the entire style of even
+ * content area structures like the TOC themselves.
+ *
+ * "content":
+ * Deprecated. Alias for "content-media".
+ *
+ * "content-thumbnails":
+ * Deprecated. Alias for "content-media".
+ *
+ * "content-media":
+ * Styles for thumbnails and floated elements.
+ * Will add styles for the new media structure on wikis where $wgParserEnableLegacyMediaDOM is disabled,
+ * or $wgUseContentMediaStyles is enabled.
+ * See https://www.mediawiki.org/wiki/Parsing/Media_structure
+ *
+ * "content-links":
+ * The skin will apply optional styling rules for links that should be styled differently
+ * to the rules in `elements` and `normalize`. It provides support for .mw-selflink,
+ * a.new (red links), a.stub (stub links) and some basic styles for external links.
+ * It also provides rules supporting the underline user preference.
+ *
+ * "content-links-external":
+ * The skin will apply optional styling rules to links to provide icons for different file types.
+ *
+ * "content-body":
+ * Styles for the mw-parser-output class.
+ *
+ * "content-tables":
+ * Styles .wikitable style tables.
+ *
+ * "interface":
+ * The highest level, this stylesheet contains extra common styles for classes like
+ * .firstHeading, #contentSub, et cetera which are not outputted by MediaWiki but are common
+ * to skins like MonoBook, Vector, etc... Essentially this level is for styles that are
+ * common to MonoBook clones.
+ *
+ * "interface-category":
+ * Styles used for styling the categories in a horizontal bar at the bottom of the content.
+ *
+ * "interface-message-box":
+ * Styles for message boxes.
+ *
+ * "i18n-ordered-lists":
+ * Styles for ordered lists elements that support mixed language content.
+ *
+ * "i18n-all-lists-margins":
+ * Styles for margins of list elements where LTR and RTL are mixed.
+ *
+ * "i18n-headings":
+ * Styles for line-heights of headings across different languages.
+ *
+ * "toc"
+ * Styling rules for the table of contents.
+ *
+ * NOTE: The order of the keys defines the order in which the styles are output.
+ */
+ private const FEATURE_FILES = [
+ 'accessibility' => [
+ 'all' => [ 'resources/src/mediawiki.skinning/accessibility.less' ],
+ ],
+ 'normalize' => [
+ 'all' => [ 'resources/src/mediawiki.skinning/normalize.less' ],
+ ],
+ 'logo' => [
+ // Applies the logo and ensures it downloads prior to printing.
+ 'all' => [ 'resources/src/mediawiki.skinning/logo.less' ],
+ // Reserves whitespace for the logo in a pseudo element.
+ 'print' => [ 'resources/src/mediawiki.skinning/logo-print.less' ],
+ ],
+ 'content-media' => [
+ 'all' => [ 'resources/src/mediawiki.skinning/content.thumbnails-common.less' ],
+ 'screen' => [ 'resources/src/mediawiki.skinning/content.thumbnails-screen.less' ],
+ 'print' => [ 'resources/src/mediawiki.skinning/content.thumbnails-print.less' ],
+ ],
+ 'content-links' => [
+ 'screen' => [ 'resources/src/mediawiki.skinning/content.links.less' ]
+ ],
+ 'content-links-external' => [
+ 'screen' => [ 'resources/src/mediawiki.skinning/content.externallinks.less' ]
+ ],
+ 'content-body' => [
+ 'screen' => [ 'resources/src/mediawiki.skinning/content.body.less' ],
+ 'print' => [ 'resources/src/mediawiki.skinning/content.body-print.less' ],
+ ],
+ 'content-tables' => [
+ 'screen' => [ 'resources/src/mediawiki.skinning/content.tables.less' ],
+ 'print' => [ 'resources/src/mediawiki.skinning/content.tables-print.less' ]
+ ],
+ 'interface' => [
+ 'screen' => [ 'resources/src/mediawiki.skinning/interface.less' ],
+ 'print' => [ 'resources/src/mediawiki.skinning/interface-print.less' ],
+ ],
+ 'interface-category' => [
+ 'screen' => [ 'resources/src/mediawiki.skinning/interface.category.less' ],
+ 'print' => [ 'resources/src/mediawiki.skinning/interface.category-print.less' ],
+ ],
+ 'interface-message-box' => [
+ 'all' => [ 'resources/src/mediawiki.skinning/messageBoxes.less' ],
+ ],
+ 'elements' => [
+ 'screen' => [ 'resources/src/mediawiki.skinning/elements.less' ],
+ 'print' => [ 'resources/src/mediawiki.skinning/elements-print.less' ],
+ ],
+ // The styles of the legacy feature was removed in 1.39. This can be removed when no skins are referencing it
+ // (Dropping this line will trigger InvalidArgumentException: Feature 'legacy' is not recognised)
+ 'legacy' => [],
+ 'i18n-ordered-lists' => [
+ 'screen' => [ 'resources/src/mediawiki.skinning/i18n-ordered-lists.less' ],
+ ],
+ 'i18n-all-lists-margins' => [
+ 'screen' => [ 'resources/src/mediawiki.skinning/i18n-all-lists-margins.less' ],
+ ],
+ 'i18n-headings' => [
+ 'screen' => [ 'resources/src/mediawiki.skinning/i18n-headings.less' ],
+ ],
+ 'toc' => [
+ 'all' => [ 'resources/src/mediawiki.skinning/toc/common.css' ],
+ 'screen' => [ 'resources/src/mediawiki.skinning/toc/screen.less' ],
+ 'print' => [ 'resources/src/mediawiki.skinning/toc/print.css' ],
+ ],
+ ];
+
+ /** @var string[] */
+ private $features;
+
+ /**
+ * Defaults for when a 'features' parameter is specified.
+ *
+ * When these apply, they are the merged into the specified options.
+ *
+ * @var array<string,bool>
+ */
+ private const DEFAULT_FEATURES_SPECIFIED = [
+ 'accessibility' => true,
+ 'content-body' => true,
+ 'toc' => true,
+ ];
+
+ /**
+ * Default for when the 'features' parameter is absent.
+ *
+ * For backward-compatibility, when the parameter is not declared
+ * only 'logo' styles are loaded.
+ *
+ * @var string[]
+ */
+ private const DEFAULT_FEATURES_ABSENT = [
+ 'logo',
+ ];
+
+ private const LESS_MESSAGES = [
+ // `toc` feature, used in screen.less
+ 'hidetoc',
+ 'showtoc',
+ ];
+
+ /**
+ * @param array $options
+ * - features: Map from feature keys to boolean indicating whether to load
+ * or not include the associated styles.
+ * Keys not specified get their default from self::DEFAULT_FEATURES_SPECIFIED.
+ *
+ * If this is set to a list of strings, then the defaults do not apply.
+ * Use this at your own risk as it means you opt-out from backwards compatibility
+ * provided through these defaults. For example, when features are migrated
+ * to the SkinModule system from other parts of MediaWiki, those new feature keys
+ * may be enabled by default, and opting out means you may be missing some styles
+ * after an upgrade until you enable them or implement them by other means.
+ *
+ * - lessMessages: Interface message keys to export as LESS variables.
+ * See also ResourceLoaderLessVarFileModule.
+ *
+ * @param string|null $localBasePath
+ * @param string|null $remoteBasePath
+ * @see Additonal options at $wgResourceModules
+ */
+ public function __construct(
+ array $options = [],
+ $localBasePath = null,
+ $remoteBasePath = null
+ ) {
+ $features = $options['features'] ?? self::DEFAULT_FEATURES_ABSENT;
+ $listMode = array_keys( $features ) === range( 0, count( $features ) - 1 );
+
+ $messages = '';
+ // NOTE: Compatibility is only applied when features are provided
+ // in map-form. The list-form does not currently get these.
+ $features = $listMode ? self::applyFeaturesCompatibility(
+ array_fill_keys( $features, true ), false, $messages
+ ) : self::applyFeaturesCompatibility( $features, true, $messages );
+
+ foreach ( $features as $key => $enabled ) {
+ if ( !isset( self::FEATURE_FILES[$key] ) ) {
+ throw new InvalidArgumentException( "Feature '$key' is not recognised" );
+ }
+ }
+
+ $this->features = $listMode
+ ? array_keys( array_filter( $features ) )
+ : array_keys( array_filter( $features + self::DEFAULT_FEATURES_SPECIFIED ) );
+
+ // Only the `toc` feature makes use of interface messages.
+ // For skins not using the `toc` feature, make sure LocalisationCache
+ // remains untouched (T270027).
+ if ( in_array( 'toc', $this->features ) ) {
+ $options['lessMessages'] = array_merge(
+ $options['lessMessages'] ?? [],
+ self::LESS_MESSAGES
+ );
+ }
+
+ if ( $messages !== '' ) {
+ $messages .= 'More information can be found at [[mw:Manual:ResourceLoaderSkinModule]]. ';
+ $options['deprecated'] = $messages;
+ }
+ parent::__construct( $options, $localBasePath, $remoteBasePath );
+ }
+
+ /**
+ * @internal
+ * @param array $features
+ * @param bool $addUnspecifiedFeatures whether to add new features if missing
+ * @param string &$messages to report deprecations
+ * @return array
+ */
+ protected static function applyFeaturesCompatibility(
+ array $features, bool $addUnspecifiedFeatures = true, &$messages = ''
+ ): array {
+ // The `content` feature is mapped to `content-media`.
+ if ( isset( $features[ 'content' ] ) ) {
+ $features[ 'content-media' ] = $features[ 'content' ];
+ unset( $features[ 'content' ] );
+ $messages .= '[1.37] The use of the `content` feature with ResourceLoaderSkinModule'
+ . ' is deprecated. Use `content-media` instead. ';
+ }
+
+ // The `content-thumbnails` feature is mapped to `content-media`.
+ if ( isset( $features[ 'content-thumbnails' ] ) ) {
+ $features[ 'content-media' ] = $features[ 'content-thumbnails' ];
+ $messages .= '[1.37] The use of the `content-thumbnails` feature with ResourceLoaderSkinModule'
+ . ' is deprecated. Use `content-media` instead. ';
+ unset( $features[ 'content-thumbnails' ] );
+ }
+
+ // If `content-links` feature is set but no preference for `content-links-external` is set
+ if ( $addUnspecifiedFeatures && isset( $features[ 'content-links' ] )
+ && !isset( $features[ 'content-links-external' ] )
+ ) {
+ // Assume the same true/false preference for both.
+ $features[ 'content-links-external' ] = $features[ 'content-links' ];
+ }
+
+ // The legacy feature no longer exists (T89981) but to avoid fatals in skins is retained.
+ if ( isset( $features['legacy'] ) && $features['legacy'] ) {
+ $messages .= '[1.37] The use of the `legacy` feature with ResourceLoaderSkinModule is deprecated'
+ . '(T89981) and is a NOOP since 1.39 (T304325). This should be urgently omited to retain compatibility '
+ . 'with future MediaWiki versions';
+ }
+
+ // The `content-links` feature was split out from `elements`.
+ // Make sure skins asking for `elements` also get these by default.
+ if ( $addUnspecifiedFeatures && isset( $features[ 'element' ] ) && !isset( $features[ 'content-links' ] ) ) {
+ $features[ 'content-links' ] = $features[ 'element' ];
+ }
+
+ // `content-parser-output` was renamed to `content-body`.
+ // No need to go through deprecation process here since content-parser-output added and removed in 1.36.
+ // Remove this check when no matches for
+ // https://codesearch.wmcloud.org/search/?q=content-parser-output&i=nope&files=&excludeFiles=&repos=
+ if ( isset( $features[ 'content-parser-output' ] ) ) {
+ $features[ 'content-body' ] = $features[ 'content-parser-output' ];
+ unset( $features[ 'content-parser-output' ] );
+ }
+
+ return $features;
+ }
+
+ /**
+ * Get styles defined in the module definition, plus any enabled feature styles.
+ *
+ * @param Context $context
+ * @return string[][]
+ */
+ public function getStyleFiles( Context $context ) {
+ $styles = parent::getStyleFiles( $context );
+
+ // Bypass the current module paths so that these files are served from core,
+ // instead of the individual skin's module directory.
+ list( $defaultLocalBasePath, $defaultRemoteBasePath ) =
+ FileModule::extractBasePaths(
+ [],
+ null,
+ $this->getConfig()->get( MainConfigNames::ResourceBasePath )
+ );
+
+ $featureFilePaths = [];
+
+ foreach ( self::FEATURE_FILES as $feature => $featureFiles ) {
+ if ( in_array( $feature, $this->features ) ) {
+ foreach ( $featureFiles as $mediaType => $files ) {
+ foreach ( $files as $filepath ) {
+ $featureFilePaths[$mediaType][] = new FilePath(
+ $filepath,
+ $defaultLocalBasePath,
+ $defaultRemoteBasePath
+ );
+ }
+ }
+ if ( $feature === 'content-media' && (
+ !$this->getConfig()->get( MainConfigNames::ParserEnableLegacyMediaDOM ) ||
+ $this->getConfig()->get( MainConfigNames::UseContentMediaStyles )
+ ) ) {
+ $featureFilePaths['all'][] = new FilePath(
+ 'resources/src/mediawiki.skinning/content.media-common.less',
+ $defaultLocalBasePath,
+ $defaultRemoteBasePath
+ );
+ $featureFilePaths['screen'][] = new FilePath(
+ 'resources/src/mediawiki.skinning/content.media-screen.less',
+ $defaultLocalBasePath,
+ $defaultRemoteBasePath
+ );
+ $featureFilePaths['print'][] = new FilePath(
+ 'resources/src/mediawiki.skinning/content.media-print.less',
+ $defaultLocalBasePath,
+ $defaultRemoteBasePath
+ );
+ }
+ }
+ }
+
+ // Styles defines in options are added to the $featureFilePaths to ensure
+ // that $featureFilePaths styles precede module defined ones.
+ // This is particularly important given the `normalize` styles need to be the first
+ // outputted (see T269618).
+ foreach ( $styles as $mediaType => $paths ) {
+ $featureFilePaths[$mediaType] = array_merge( $featureFilePaths[$mediaType] ?? [], $paths );
+ }
+
+ return $featureFilePaths;
+ }
+
+ /**
+ * @param Context $context
+ * @return array
+ */
+ public function getStyles( Context $context ) {
+ $logo = $this->getLogoData( $this->getConfig(), $context->getLanguage() );
+ $styles = parent::getStyles( $context );
+ $this->normalizeStyles( $styles );
+
+ $isLogoFeatureEnabled = in_array( 'logo', $this->features );
+ if ( $isLogoFeatureEnabled ) {
+ $default = !is_array( $logo ) ? $logo : ( $logo['1x'] ?? null );
+ // Can't add logo CSS if no logo defined.
+ if ( !$default ) {
+ return $styles;
+ }
+ $styles['all'][] = '.mw-wiki-logo { background-image: ' .
+ CSSMin::buildUrlValue( $default ) .
+ '; }';
+
+ if ( is_array( $logo ) ) {
+ if ( isset( $logo['svg'] ) ) {
+ $styles['all'][] = '.mw-wiki-logo { ' .
+ 'background-image: linear-gradient(transparent, transparent), ' .
+ CSSMin::buildUrlValue( $logo['svg'] ) . ';' .
+ 'background-size: 135px auto; }';
+ } else {
+ if ( isset( $logo['1.5x'] ) ) {
+ $styles[
+ '(-webkit-min-device-pixel-ratio: 1.5), ' .
+ '(min-resolution: 1.5dppx), ' .
+ '(min-resolution: 144dpi)'
+ ][] = '.mw-wiki-logo { background-image: ' .
+ CSSMin::buildUrlValue( $logo['1.5x'] ) . ';' .
+ 'background-size: 135px auto; }';
+ }
+ if ( isset( $logo['2x'] ) ) {
+ $styles[
+ '(-webkit-min-device-pixel-ratio: 2), ' .
+ '(min-resolution: 2dppx), ' .
+ '(min-resolution: 192dpi)'
+ ][] = '.mw-wiki-logo { background-image: ' .
+ CSSMin::buildUrlValue( $logo['2x'] ) . ';' .
+ 'background-size: 135px auto; }';
+ }
+ }
+ }
+ }
+
+ return $styles;
+ }
+
+ /**
+ * @param Context $context
+ * @return array
+ */
+ public function getPreloadLinks( Context $context ): array {
+ if ( !in_array( 'logo', $this->features ) ) {
+ return [];
+ }
+
+ $logo = $this->getLogoData( $this->getConfig(), $context->getLanguage() );
+
+ if ( !is_array( $logo ) ) {
+ // No media queries required if we only have one variant
+ return [ $logo => [ 'as' => 'image' ] ];
+ }
+
+ if ( isset( $logo['svg'] ) ) {
+ // No media queries required if we only have a 1x and svg variant
+ // because all preload-capable browsers support SVGs
+ return [ $logo['svg'] => [ 'as' => 'image' ] ];
+ }
+
+ $logosPerDppx = [];
+ foreach ( $logo as $dppx => $src ) {
+ // Keys are in this format: "1.5x"
+ $dppx = substr( $dppx, 0, -1 );
+ $logosPerDppx[$dppx] = $src;
+ }
+
+ // Because PHP can't have floats as array keys
+ uksort( $logosPerDppx, static function ( $a, $b ) {
+ $a = floatval( $a );
+ $b = floatval( $b );
+ // Sort from smallest to largest (e.g. 1x, 1.5x, 2x)
+ return $a <=> $b;
+ } );
+
+ $logos = [];
+ foreach ( $logosPerDppx as $dppx => $src ) {
+ $logos[] = [
+ 'dppx' => $dppx,
+ 'src' => $src
+ ];
+ }
+
+ $logosCount = count( $logos );
+ $preloadLinks = [];
+ // Logic must match SkinModule:
+ // - 1x applies to resolution < 1.5dppx
+ // - 1.5x applies to resolution >= 1.5dppx && < 2dppx
+ // - 2x applies to resolution >= 2dppx
+ // Note that min-resolution and max-resolution are both inclusive.
+ for ( $i = 0; $i < $logosCount; $i++ ) {
+ if ( $i === 0 ) {
+ // Smallest dppx
+ // min-resolution is ">=" (larger than or equal to)
+ // "not min-resolution" is essentially "<"
+ $media_query = 'not all and (min-resolution: ' . $logos[1]['dppx'] . 'dppx)';
+ } elseif ( $i !== $logosCount - 1 ) {
+ // In between
+ // Media query expressions can only apply "not" to the entire expression
+ // (e.g. can't express ">= 1.5 and not >= 2).
+ // Workaround: Use <= 1.9999 in place of < 2.
+ $upper_bound = floatval( $logos[$i + 1]['dppx'] ) - 0.000001;
+ $media_query = '(min-resolution: ' . $logos[$i]['dppx'] .
+ 'dppx) and (max-resolution: ' . $upper_bound . 'dppx)';
+ } else {
+ // Largest dppx
+ $media_query = '(min-resolution: ' . $logos[$i]['dppx'] . 'dppx)';
+ }
+
+ $preloadLinks[$logos[$i]['src']] = [
+ 'as' => 'image',
+ 'media' => $media_query
+ ];
+ }
+
+ return $preloadLinks;
+ }
+
+ /**
+ * Ensure all media keys use array values.
+ *
+ * Normalises arrays returned by the FileModule::getStyles() method.
+ *
+ * @param array &$styles Associative array, keys are strings (media queries),
+ * values are strings or arrays
+ */
+ private function normalizeStyles( array &$styles ): void {
+ foreach ( $styles as $key => $val ) {
+ if ( !is_array( $val ) ) {
+ $styles[$key] = [ $val ];
+ }
+ }
+ }
+
+ /**
+ * Modifies configured logo width/height to ensure they are present and scalable
+ * with different font-sizes.
+ * @param array $logoElement with width, height and src keys.
+ * @return array modified version of $logoElement
+ */
+ private static function getRelativeSizedLogo( array $logoElement ) {
+ $width = $logoElement['width'];
+ $height = $logoElement['height'];
+ $widthRelative = $width / 16;
+ $heightRelative = $height / 16;
+ // Allow skins to scale the wordmark with browser font size (T207789)
+ $logoElement['style'] = 'width: ' . $widthRelative . 'em; height: ' . $heightRelative . 'em;';
+ return $logoElement;
+ }
+
+ /**
+ * Return an array of all available logos that a skin may use.
+ * @since 1.35
+ * @param Config $conf
+ * @param string|null $lang Language code for logo variant, since 1.39
+ * @return array with the following keys:
+ * - 1x(string): a square logo composing the `icon` and `wordmark` (required)
+ * - 2x (string): a square logo for HD displays (optional)
+ * - wordmark (object): a rectangle logo (wordmark) for print media and skins which desire
+ * horizontal logo (optional). Must declare width and height fields, defined in pixels
+ * which will be converted to ems based on 16px font-size.
+ * - tagline (object): replaces `tagline` message in certain skins. Must declare width and
+ * height fields defined in pixels, which are converted to ems based on 16px font-size.
+ * - icon (string): a square logo similar to 1x, but without the wordmark. SVG recommended.
+ */
+ public static function getAvailableLogos( Config $conf, string $lang = null ): array {
+ $logos = $conf->get( MainConfigNames::Logos );
+ if ( $logos === false ) {
+ // no logos were defined... this will either
+ // 1. Load from wgLogo and wgLogoHD
+ // 2. Trigger runtime exception if those are not defined.
+ $logos = [];
+ }
+ if ( $lang && isset( $logos['variants'][$lang] ) ) {
+ foreach ( $logos['variants'][$lang] as $type => $value ) {
+ $logos[$type] = $value;
+ }
+ }
+
+ // If logos['1x'] is not defined, see if we can use wgLogo
+ if ( !isset( $logos[ '1x' ] ) ) {
+ $logo = $conf->get( MainConfigNames::Logo );
+ if ( $logo ) {
+ $logos['1x'] = $logo;
+ }
+ }
+
+ try {
+ $logoHD = $conf->get( MainConfigNames::LogoHD );
+ // make sure not false
+ if ( $logoHD ) {
+ // wfDeprecated( __METHOD__ . ' with $wgLogoHD set instead of $wgLogos', '1.35', false, 1 );
+ $logos += $logoHD;
+ }
+ } catch ( ConfigException $e ) {
+ // no backwards compatibility changes needed.
+ }
+
+ // @todo: Note the beta cluster and other wikis may be using
+ // unsupported configuration where these values are set to false.
+ // The boolean check can be removed when this has been addressed.
+ if ( isset( $logos['wordmark'] ) && $logos['wordmark'] ) {
+ // Allow skins to scale the wordmark with browser font size (T207789)
+ $logos['wordmark'] = self::getRelativeSizedLogo( $logos['wordmark'] );
+ }
+
+ // @todo: Note the beta cluster and other wikis may be using
+ // unsupported configuration where these values are set to false.
+ // The boolean check can be removed when this has been addressed.
+ if ( isset( $logos['tagline'] ) && $logos['tagline'] ) {
+ $logos['tagline'] = self::getRelativeSizedLogo( $logos['tagline'] );
+ }
+ // return the modified logos!
+ return $logos;
+ }
+
+ /**
+ * @since 1.31
+ * @param Config $conf
+ * @param string|null $lang Language code for logo variant, since 1.39
+ * @return string|array Single url if no variants are defined,
+ * or an array of logo urls keyed by dppx in form "<float>x".
+ * Key "1x" is always defined. Key "svg" may also be defined,
+ * in which case variants other than "1x" are omitted.
+ */
+ protected function getLogoData( Config $conf, string $lang = null ) {
+ $logoHD = self::getAvailableLogos( $conf, $lang );
+ $logo = $logoHD['1x'];
+
+ $logo1Url = OutputPage::transformResourcePath( $conf, $logo );
+
+ $logoUrls = [
+ '1x' => $logo1Url,
+ ];
+
+ if ( isset( $logoHD['svg'] ) ) {
+ $logoUrls['svg'] = OutputPage::transformResourcePath(
+ $conf,
+ $logoHD['svg']
+ );
+ } elseif ( isset( $logoHD['1.5x'] ) || isset( $logoHD['2x'] ) ) {
+ // Only 1.5x and 2x are supported
+ if ( isset( $logoHD['1.5x'] ) ) {
+ $logoUrls['1.5x'] = OutputPage::transformResourcePath(
+ $conf,
+ $logoHD['1.5x']
+ );
+ }
+ if ( isset( $logoHD['2x'] ) ) {
+ $logoUrls['2x'] = OutputPage::transformResourcePath(
+ $conf,
+ $logoHD['2x']
+ );
+ }
+ } else {
+ // Return a string rather than a one-element array, getLogoPreloadlinks depends on this
+ return $logo1Url;
+ }
+
+ return $logoUrls;
+ }
+
+ /**
+ * @param Context $context
+ * @return bool
+ */
+ public function isKnownEmpty( Context $context ) {
+ // Regardless of whether the files are specified, we always
+ // provide mw-wiki-logo styles.
+ return false;
+ }
+
+ /**
+ * Get language-specific LESS variables for this module.
+ *
+ * @param Context $context
+ * @return array
+ */
+ protected function getLessVars( Context $context ) {
+ $lessVars = parent::getLessVars( $context );
+ $logos = self::getAvailableLogos( $this->getConfig() );
+
+ if ( isset( $logos['wordmark'] ) ) {
+ $logo = $logos['wordmark'];
+ $lessVars[ 'logo-enabled' ] = true;
+ $lessVars[ 'logo-wordmark-url' ] = CSSMin::buildUrlValue( $logo['src'] );
+ $lessVars[ 'logo-wordmark-width' ] = intval( $logo['width'] );
+ $lessVars[ 'logo-wordmark-height' ] = intval( $logo['height'] );
+ } else {
+ $lessVars[ 'logo-enabled' ] = false;
+ }
+ return $lessVars;
+ }
+
+ public function getDefinitionSummary( Context $context ) {
+ $summary = parent::getDefinitionSummary( $context );
+ $summary[] = [
+ 'logos' => self::getAvailableLogos( $this->getConfig() ),
+ ];
+ return $summary;
+ }
+}
+
+/** @deprecated since 1.39 */
+class_alias( SkinModule::class, 'ResourceLoaderSkinModule' );
diff --git a/includes/ResourceLoader/StartUpModule.php b/includes/ResourceLoader/StartUpModule.php
new file mode 100644
index 000000000000..6949749045ca
--- /dev/null
+++ b/includes/ResourceLoader/StartUpModule.php
@@ -0,0 +1,453 @@
+<?php
+/**
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation; either version 2 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License along
+ * with this program; if not, write to the Free Software Foundation, Inc.,
+ * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @author Trevor Parscal
+ * @author Roan Kattouw
+ */
+namespace MediaWiki\ResourceLoader;
+
+use Exception;
+use MediaWiki\MainConfigNames;
+use Wikimedia\RequestTimeout\TimeoutException;
+
+/**
+ * Module for ResourceLoader initialization.
+ *
+ * See also <https://www.mediawiki.org/wiki/ResourceLoader/Features#Startup_Module>
+ *
+ * The startup module, as being called only from ClientHtml, has
+ * the ability to vary based extra query parameters, in addition to those
+ * from Context:
+ *
+ * - target: Only register modules in the client intended for this target.
+ * Default: "desktop".
+ * See also: OutputPage::setTarget(), Module::getTargets().
+ *
+ * - safemode: Only register modules that have ORIGIN_CORE as their origin.
+ * This disables ORIGIN_USER modules and mw.loader.store. (T185303, T145498)
+ * See also: OutputPage::disallowUserJs()
+ *
+ * @ingroup ResourceLoader
+ * @internal
+ */
+class StartUpModule extends Module {
+ protected $targets = [ 'desktop', 'mobile' ];
+
+ private $groupIds = [
+ // These reserved numbers MUST start at 0 and not skip any. These are preset
+ // for forward compatibility so that they can be safely referenced by mediawiki.js,
+ // even when the code is cached and the order of registrations (and implicit
+ // group ids) changes between versions of the software.
+ self::GROUP_USER => 0,
+ self::GROUP_PRIVATE => 1,
+ ];
+
+ /**
+ * Recursively get all explicit and implicit dependencies for to the given module.
+ *
+ * @param array $registryData
+ * @param string $moduleName
+ * @param string[] $handled Internal parameter for recursion. (Optional)
+ * @return array
+ * @throws CircularDependencyError
+ */
+ protected static function getImplicitDependencies(
+ array $registryData,
+ string $moduleName,
+ array $handled = []
+ ): array {
+ static $dependencyCache = [];
+
+ // No modules will be added or changed server-side after this point,
+ // so we can safely cache parts of the tree for re-use.
+ if ( !isset( $dependencyCache[$moduleName] ) ) {
+ if ( !isset( $registryData[$moduleName] ) ) {
+ // Unknown module names are allowed here, this is only an optimisation.
+ // Checks for illegal and unknown dependencies happen as PHPUnit structure tests,
+ // and also client-side at run-time.
+ $flat = [];
+ } else {
+ $data = $registryData[$moduleName];
+ $flat = $data['dependencies'];
+
+ // Prevent recursion
+ $handled[] = $moduleName;
+ foreach ( $data['dependencies'] as $dependency ) {
+ if ( in_array( $dependency, $handled, true ) ) {
+ // If we encounter a circular dependency, then stop the optimiser and leave the
+ // original dependencies array unmodified. Circular dependencies are not
+ // supported in ResourceLoader. Awareness of them exists here so that we can
+ // optimise the registry when it isn't broken, and otherwise transport the
+ // registry unchanged. The client will handle this further.
+ throw new CircularDependencyError();
+ } else {
+ // Recursively add the dependencies of the dependencies
+ $flat = array_merge(
+ $flat,
+ self::getImplicitDependencies( $registryData, $dependency, $handled )
+ );
+ }
+ }
+ }
+
+ $dependencyCache[$moduleName] = $flat;
+ }
+
+ return $dependencyCache[$moduleName];
+ }
+
+ /**
+ * Optimize the dependency tree in $this->modules.
+ *
+ * The optimization basically works like this:
+ * Given we have module A with the dependencies B and C
+ * and module B with the dependency C.
+ * Now we don't have to tell the client to explicitly fetch module
+ * C as that's already included in module B.
+ *
+ * This way we can reasonably reduce the amount of module registration
+ * data send to the client.
+ *
+ * @param array[] &$registryData Modules keyed by name with properties:
+ * - string 'version'
+ * - array 'dependencies'
+ * - string|null 'group'
+ * - string 'source'
+ * @phan-param array<string,array{version:string,dependencies:array,group:?string,source:string}> &$registryData
+ */
+ public static function compileUnresolvedDependencies( array &$registryData ): void {
+ foreach ( $registryData as $name => &$data ) {
+ $dependencies = $data['dependencies'];
+ try {
+ foreach ( $data['dependencies'] as $dependency ) {
+ $implicitDependencies = self::getImplicitDependencies( $registryData, $dependency );
+ $dependencies = array_diff( $dependencies, $implicitDependencies );
+ }
+ } catch ( CircularDependencyError $err ) {
+ // Leave unchanged
+ $dependencies = $data['dependencies'];
+ }
+
+ // Rebuild keys
+ $data['dependencies'] = array_values( $dependencies );
+ }
+ }
+
+ /**
+ * Get registration code for all modules.
+ *
+ * @param Context $context
+ * @return string JavaScript code for registering all modules with the client loader
+ */
+ public function getModuleRegistrations( Context $context ): string {
+ $resourceLoader = $context->getResourceLoader();
+ // Future developers: Use WebRequest::getRawVal() instead getVal().
+ // The getVal() method performs slow Language+UTF logic. (f303bb9360)
+ $target = $context->getRequest()->getRawVal( 'target', 'desktop' );
+ $safemode = $context->getRequest()->getRawVal( 'safemode' ) === '1';
+ $skin = $context->getSkin();
+ // Bypass target filter if this request is Special:JavaScriptTest.
+ // To prevent misuse in production, this is only allowed if testing is enabled server-side.
+ $byPassTargetFilter = $this->getConfig()->get( MainConfigNames::EnableJavaScriptTest ) && $target === 'test';
+
+ $out = '';
+ $states = [];
+ $registryData = [];
+ $moduleNames = $resourceLoader->getModuleNames();
+
+ // Preload with a batch so that the below calls to getVersionHash() for each module
+ // don't require on-demand loading of more information.
+ try {
+ $resourceLoader->preloadModuleInfo( $moduleNames, $context );
+ } catch ( TimeoutException $e ) {
+ throw $e;
+ } catch ( Exception $e ) {
+ // Don't fail the request (T152266)
+ // Also print the error in the main output
+ $resourceLoader->outputErrorAndLog( $e,
+ 'Preloading module info from startup failed: {exception}',
+ [ 'exception' => $e ]
+ );
+ }
+
+ // Get registry data
+ foreach ( $moduleNames as $name ) {
+ $module = $resourceLoader->getModule( $name );
+ $moduleTargets = $module->getTargets();
+ $moduleSkins = $module->getSkins();
+ if (
+ ( !$byPassTargetFilter && !in_array( $target, $moduleTargets ) )
+ || ( $safemode && $module->getOrigin() > Module::ORIGIN_CORE_INDIVIDUAL )
+ || ( $moduleSkins !== null && !in_array( $skin, $moduleSkins ) )
+ ) {
+ continue;
+ }
+
+ if ( $module instanceof StartUpModule ) {
+ // Don't register 'startup' to the client because loading it lazily or depending
+ // on it doesn't make sense, because the startup module *is* the client.
+ // Registering would be a waste of bandwidth and memory and risks somehow causing
+ // it to load a second time.
+
+ // ATTENTION: Because of the line below, this is not going to cause infinite recursion.
+ // Think carefully before making changes to this code!
+ // The below code is going to call Module::getVersionHash() for every module.
+ // For StartUpModule (this module) the hash is computed based on the manifest content,
+ // which is the very thing we are computing right here. As such, this must skip iterating
+ // over 'startup' itself.
+ continue;
+ }
+
+ // Optimization: Exclude modules in the `noscript` group. These are only ever used
+ // directly by HTML without use of JavaScript (T291735).
+ if ( $module->getGroup() === self::GROUP_NOSCRIPT ) {
+ continue;
+ }
+
+ try {
+ // The version should be formatted by ResourceLoader::makeHash and be of
+ // length ResourceLoader::HASH_LENGTH (or empty string).
+ // The getVersionHash method is final and is covered by tests, as is makeHash().
+ $versionHash = $module->getVersionHash( $context );
+ } catch ( TimeoutException $e ) {
+ throw $e;
+ } catch ( Exception $e ) {
+ // Don't fail the request (T152266)
+ // Also print the error in the main output
+ $resourceLoader->outputErrorAndLog( $e,
+ 'Calculating version for "{module}" failed: {exception}',
+ [
+ 'module' => $name,
+ 'exception' => $e,
+ ]
+ );
+ $versionHash = '';
+ $states[$name] = 'error';
+ }
+
+ $skipFunction = $module->getSkipFunction();
+ if ( $skipFunction !== null && !$context->getDebug() ) {
+ $skipFunction = ResourceLoader::filter( 'minify-js', $skipFunction );
+ }
+
+ $registryData[$name] = [
+ 'version' => $versionHash,
+ 'dependencies' => $module->getDependencies( $context ),
+ 'es6' => $module->requiresES6(),
+ 'group' => $this->getGroupId( $module->getGroup() ),
+ 'source' => $module->getSource(),
+ 'skip' => $skipFunction,
+ ];
+ }
+
+ self::compileUnresolvedDependencies( $registryData );
+
+ // Register sources
+ $out .= ResourceLoader::makeLoaderSourcesScript( $context, $resourceLoader->getSources() );
+
+ // Figure out the different call signatures for mw.loader.register
+ $registrations = [];
+ foreach ( $registryData as $name => $data ) {
+ // Call mw.loader.register(name, version, dependencies, group, source, skip)
+ $registrations[] = [
+ $name,
+ // HACK: signify ES6 with a ! added at the end of the version
+ // This avoids having to add another register() parameter, and generating
+ // a bunch of nulls for ES6-only modules
+ $data['version'] . ( $data['es6'] ? '!' : '' ),
+ $data['dependencies'],
+ $data['group'],
+ // Swap default (local) for null
+ $data['source'] === 'local' ? null : $data['source'],
+ $data['skip']
+ ];
+ }
+
+ // Register modules
+ $out .= "\n" . ResourceLoader::makeLoaderRegisterScript( $context, $registrations );
+
+ if ( $states ) {
+ $out .= "\n" . ResourceLoader::makeLoaderStateScript( $context, $states );
+ }
+
+ return $out;
+ }
+
+ private function getGroupId( $groupName ): ?int {
+ if ( $groupName === null ) {
+ return null;
+ }
+
+ if ( !array_key_exists( $groupName, $this->groupIds ) ) {
+ $this->groupIds[$groupName] = count( $this->groupIds );
+ }
+
+ return $this->groupIds[$groupName];
+ }
+
+ /**
+ * Base modules implicitly available to all modules.
+ *
+ * @return array
+ */
+ private function getBaseModules(): array {
+ return [ 'jquery', 'mediawiki.base' ];
+ }
+
+ /**
+ * Get the localStorage key for the entire module store. The key references
+ * $wgDBname to prevent clashes between wikis under the same web domain.
+ *
+ * @return string localStorage item key for JavaScript
+ */
+ private function getStoreKey(): string {
+ return 'MediaWikiModuleStore:' . $this->getConfig()->get( MainConfigNames::DBname );
+ }
+
+ /**
+ * @see $wgResourceLoaderMaxQueryLength
+ * @return int
+ */
+ private function getMaxQueryLength(): int {
+ $len = $this->getConfig()->get( MainConfigNames::ResourceLoaderMaxQueryLength );
+ // - Ignore -1, which in MW 1.34 and earlier was used to mean "unlimited".
+ // - Ignore invalid values, e.g. non-int or other negative values.
+ if ( $len === false || $len < 0 ) {
+ // Default
+ $len = 2000;
+ }
+ return $len;
+ }
+
+ /**
+ * Get the key on which the JavaScript module cache (mw.loader.store) will vary.
+ *
+ * @param Context $context
+ * @return string String of concatenated vary conditions
+ */
+ private function getStoreVary( Context $context ): string {
+ return implode( ':', [
+ $context->getSkin(),
+ $this->getConfig()->get( MainConfigNames::ResourceLoaderStorageVersion ),
+ $context->getLanguage(),
+ ] );
+ }
+
+ /**
+ * @param Context $context
+ * @return string JavaScript code
+ */
+ public function getScript( Context $context ): string {
+ global $IP;
+ $conf = $this->getConfig();
+
+ if ( $context->getOnly() !== 'scripts' ) {
+ return '/* Requires only=scripts */';
+ }
+
+ $startupCode = file_get_contents( "$IP/resources/src/startup/startup.js" );
+
+ // The files read here MUST be kept in sync with maintenance/jsduck/eg-iframe.html.
+ $mwLoaderCode = file_get_contents( "$IP/resources/src/startup/mediawiki.js" ) .
+ file_get_contents( "$IP/resources/src/startup/mediawiki.loader.js" ) .
+ file_get_contents( "$IP/resources/src/startup/mediawiki.requestIdleCallback.js" );
+ if ( $conf->get( MainConfigNames::ResourceLoaderEnableJSProfiler ) ) {
+ $mwLoaderCode .= file_get_contents( "$IP/resources/src/startup/profiler.js" );
+ }
+
+ // Perform replacements for mediawiki.js
+ $mwLoaderPairs = [
+ // This should always be an object, even if the base vars are empty
+ // (such as when using the default lang/skin).
+ '$VARS.reqBase' => $context->encodeJson( (object)$context->getReqBase() ),
+ '$VARS.baseModules' => $context->encodeJson( $this->getBaseModules() ),
+ '$VARS.maxQueryLength' => $context->encodeJson(
+ // In debug mode (except legacy debug mode), let the client fetch each module in
+ // its own dedicated request (T85805).
+ // This is effectively the equivalent of ClientHtml::makeLoad,
+ // which does this for stylesheets.
+ ( !$context->getDebug() || $context->getDebug() === $context::DEBUG_LEGACY ) ?
+ $this->getMaxQueryLength() :
+ 0
+ ),
+ '$VARS.storeEnabled' => $context->encodeJson(
+ $conf->get( MainConfigNames::ResourceLoaderStorageEnabled )
+ && !$context->getDebug()
+ && $context->getRequest()->getRawVal( 'safemode' ) !== '1'
+ ),
+ '$VARS.storeKey' => $context->encodeJson( $this->getStoreKey() ),
+ '$VARS.storeVary' => $context->encodeJson( $this->getStoreVary( $context ) ),
+ '$VARS.groupUser' => $context->encodeJson( $this->getGroupId( self::GROUP_USER ) ),
+ '$VARS.groupPrivate' => $context->encodeJson( $this->getGroupId( self::GROUP_PRIVATE ) ),
+ // Only expose private mw.loader.isES6ForTest in test mode.
+ '$CODE.test( isES6Supported )' => $conf->get( MainConfigNames::EnableJavaScriptTest ) ?
+ '(mw.loader.isES6ForTest !== undefined ? mw.loader.isES6ForTest : isES6Supported)' :
+ 'isES6Supported',
+ // Only expose private mw.redefineFallbacksForTest in test mode.
+ '$CODE.maybeRedefineFallbacksForTest();' => $conf->get( MainConfigNames::EnableJavaScriptTest ) ?
+ 'mw.redefineFallbacksForTest = defineFallbacks;' :
+ '',
+ ];
+ $profilerStubs = [
+ '$CODE.profileExecuteStart();' => 'mw.loader.profiler.onExecuteStart( module );',
+ '$CODE.profileExecuteEnd();' => 'mw.loader.profiler.onExecuteEnd( module );',
+ '$CODE.profileScriptStart();' => 'mw.loader.profiler.onScriptStart( module );',
+ '$CODE.profileScriptEnd();' => 'mw.loader.profiler.onScriptEnd( module );',
+ ];
+ $debugStubs = [
+ '$CODE.consoleLog();' => 'console.log.apply( console, arguments );',
+ ];
+ // When profiling is enabled, insert the calls. When disabled (by default), insert nothing.
+ $mwLoaderPairs += $conf->get( MainConfigNames::ResourceLoaderEnableJSProfiler )
+ ? $profilerStubs
+ : array_fill_keys( array_keys( $profilerStubs ), '' );
+ $mwLoaderPairs += $context->getDebug()
+ ? $debugStubs
+ : array_fill_keys( array_keys( $debugStubs ), '' );
+ $mwLoaderCode = strtr( $mwLoaderCode, $mwLoaderPairs );
+
+ // Perform string replacements for startup.js
+ $pairs = [
+ // Raw JavaScript code (not JSON)
+ '$CODE.registrations();' => trim( $this->getModuleRegistrations( $context ) ),
+ '$CODE.defineLoader();' => $mwLoaderCode,
+ ];
+ $startupCode = strtr( $startupCode, $pairs );
+
+ return $startupCode;
+ }
+
+ /**
+ * @return bool
+ */
+ public function supportsURLLoading(): bool {
+ return false;
+ }
+
+ /**
+ * @return bool
+ */
+ public function enableModuleContentVersion(): bool {
+ // Enabling this means that ResourceLoader::getVersionHash will simply call getScript()
+ // and hash it to determine the version (as used by E-Tag HTTP response header).
+ return true;
+ }
+}
+
+/** @deprecated since 1.39 */
+class_alias( StartUpModule::class, 'ResourceLoaderStartUpModule' );
diff --git a/includes/ResourceLoader/UserModule.php b/includes/ResourceLoader/UserModule.php
new file mode 100644
index 000000000000..2fd0f3a8644a
--- /dev/null
+++ b/includes/ResourceLoader/UserModule.php
@@ -0,0 +1,91 @@
+<?php
+/**
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation; either version 2 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License along
+ * with this program; if not, write to the Free Software Foundation, Inc.,
+ * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @author Trevor Parscal
+ * @author Roan Kattouw
+ */
+
+namespace MediaWiki\ResourceLoader;
+
+use MediaWiki\MainConfigNames;
+use MediaWiki\MediaWikiServices;
+use TitleValue;
+
+/**
+ * Module for user customizations scripts.
+ *
+ * @ingroup ResourceLoader
+ * @internal
+ */
+class UserModule extends WikiModule {
+ protected $origin = self::ORIGIN_USER_INDIVIDUAL;
+ protected $targets = [ 'desktop', 'mobile' ];
+
+ /**
+ * @param Context $context
+ * @return array[]
+ */
+ protected function getPages( Context $context ) {
+ $user = $context->getUserIdentity();
+ if ( !$user || !$user->isRegistered() ) {
+ return [];
+ }
+
+ $config = $this->getConfig();
+ $pages = [];
+
+ if ( $config->get( MainConfigNames::AllowUserJs ) ) {
+ $titleFormatter = MediaWikiServices::getInstance()->getTitleFormatter();
+ // Use localised/normalised variant to ensure $excludepage matches
+ $userPage = $titleFormatter->getPrefixedDBkey( new TitleValue( NS_USER, $user->getName() ) );
+ $pages["$userPage/common.js"] = [ 'type' => 'script' ];
+ $pages["$userPage/" . $context->getSkin() . '.js'] = [ 'type' => 'script' ];
+ }
+
+ // User group pages are maintained site-wide and enabled with site JS/CSS.
+ if ( $config->get( MainConfigNames::UseSiteJs ) ) {
+ $userGroupManager = MediaWikiServices::getInstance()->getUserGroupManager();
+ foreach ( $userGroupManager->getUserEffectiveGroups( $user ) as $group ) {
+ if ( $group == '*' ) {
+ continue;
+ }
+ $pages["MediaWiki:Group-$group.js"] = [ 'type' => 'script' ];
+ }
+ }
+
+ // This is obsolete since 1.32 (T112474). It was formerly used by
+ // OutputPage to implement previewing of user CSS and JS.
+ // @todo: Remove it once we're sure nothing else is using the parameter
+ $excludepage = $context->getRequest()->getVal( 'excludepage' );
+ unset( $pages[$excludepage] );
+
+ return $pages;
+ }
+
+ /**
+ * Get group name
+ *
+ * @return string
+ */
+ public function getGroup() {
+ return self::GROUP_USER;
+ }
+}
+
+/** @deprecated since 1.39 */
+class_alias( UserModule::class, 'ResourceLoaderUserModule' );
diff --git a/includes/ResourceLoader/UserOptionsModule.php b/includes/ResourceLoader/UserOptionsModule.php
new file mode 100644
index 000000000000..8b1e7a6f11ef
--- /dev/null
+++ b/includes/ResourceLoader/UserOptionsModule.php
@@ -0,0 +1,96 @@
+<?php
+
+namespace MediaWiki\ResourceLoader;
+
+use MediaWiki\MediaWikiServices;
+use MediaWiki\User\UserOptionsLookup;
+
+/**
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation; either version 2 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License along
+ * with this program; if not, write to the Free Software Foundation, Inc.,
+ * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @author Trevor Parscal
+ * @author Roan Kattouw
+ */
+
+/**
+ * Module for per-user private data that is transmitted on all HTML web responses.
+ *
+ * This module is embedded by ClientHtml and sent to the browser
+ * by OutputPage as part of the HTML `<head>`.
+ *
+ * @ingroup ResourceLoader
+ * @internal
+ */
+class UserOptionsModule extends Module {
+
+ protected $origin = self::ORIGIN_CORE_INDIVIDUAL;
+
+ protected $targets = [ 'desktop', 'mobile' ];
+
+ /**
+ * @param Context $context
+ * @return string JavaScript code
+ */
+ public function getScript( Context $context ) {
+ $user = $context->getUserObj();
+
+ $tokens = [
+ // Replacement is tricky - T287542
+ 'patrolToken' => $user->getEditToken( 'patrol' ),
+ 'watchToken' => $user->getEditToken( 'watch' ),
+ 'csrfToken' => $user->getEditToken(),
+ ];
+ $script = 'mw.user.tokens.set(' . $context->encodeJson( $tokens ) . ');' . "\n";
+
+ $userOptionsLookup = MediaWikiServices::getInstance()->getUserOptionsLookup();
+
+ // Optimisation: Exclude the defaults, which we load separately and allow the browser
+ // to cache across page views. The defaults are loaded before this code executes,
+ // as part of the "mediawiki.base" module.
+ $options = $userOptionsLookup->getOptions( $user, UserOptionsLookup::EXCLUDE_DEFAULTS );
+
+ $keysToExclude = [];
+ $this->getHookRunner()->onResourceLoaderExcludeUserOptions( $keysToExclude, $context );
+ foreach ( $keysToExclude as $excludedKey ) {
+ unset( $options[ $excludedKey ] );
+ }
+
+ // Optimisation: Only output this function call if the user has non-default settings.
+ if ( $options ) {
+ $script .= 'mw.user.options.set(' . $context->encodeJson( $options ) . ');' . "\n";
+ }
+
+ return $script;
+ }
+
+ /**
+ * @return bool
+ */
+ public function supportsURLLoading() {
+ return false;
+ }
+
+ /**
+ * @return string
+ */
+ public function getGroup() {
+ return self::GROUP_PRIVATE;
+ }
+}
+
+/** @deprecated since 1.39 */
+class_alias( UserOptionsModule::class, 'ResourceLoaderUserOptionsModule' );
diff --git a/includes/ResourceLoader/UserStylesModule.php b/includes/ResourceLoader/UserStylesModule.php
new file mode 100644
index 000000000000..9b9e8692eca0
--- /dev/null
+++ b/includes/ResourceLoader/UserStylesModule.php
@@ -0,0 +1,100 @@
+<?php
+/**
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation; either version 2 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License along
+ * with this program; if not, write to the Free Software Foundation, Inc.,
+ * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @author Trevor Parscal
+ * @author Roan Kattouw
+ */
+
+namespace MediaWiki\ResourceLoader;
+
+use MediaWiki\MainConfigNames;
+use MediaWiki\MediaWikiServices;
+use TitleValue;
+
+/**
+ * Module for user customizations styles.
+ *
+ * @ingroup ResourceLoader
+ * @internal
+ */
+class UserStylesModule extends WikiModule {
+
+ protected $origin = self::ORIGIN_USER_INDIVIDUAL;
+ protected $targets = [ 'desktop', 'mobile' ];
+
+ /**
+ * @param Context $context
+ * @return array[]
+ */
+ protected function getPages( Context $context ) {
+ $user = $context->getUserIdentity();
+ if ( !$user || !$user->isRegistered() ) {
+ return [];
+ }
+
+ $config = $this->getConfig();
+ $pages = [];
+
+ if ( $config->get( MainConfigNames::AllowUserCss ) ) {
+ $titleFormatter = MediaWikiServices::getInstance()->getTitleFormatter();
+ // Use localised/normalised variant to ensure $excludepage matches
+ $userPage = $titleFormatter->getPrefixedDBkey( new TitleValue( NS_USER, $user->getName() ) );
+ $pages["$userPage/common.css"] = [ 'type' => 'style' ];
+ $pages["$userPage/" . $context->getSkin() . '.css'] = [ 'type' => 'style' ];
+ }
+
+ // User group pages are maintained site-wide and enabled with site JS/CSS.
+ if ( $config->get( MainConfigNames::UseSiteCss ) ) {
+ $effectiveGroups = MediaWikiServices::getInstance()->getUserGroupManager()
+ ->getUserEffectiveGroups( $user );
+ foreach ( $effectiveGroups as $group ) {
+ if ( $group == '*' ) {
+ continue;
+ }
+ $pages["MediaWiki:Group-$group.css"] = [ 'type' => 'style' ];
+ }
+ }
+
+ // This is obsolete since 1.32 (T112474). It was formerly used by
+ // OutputPage to implement previewing of user CSS and JS.
+ // @todo: Remove it once we're sure nothing else is using the parameter
+ $excludepage = $context->getRequest()->getVal( 'excludepage' );
+ unset( $pages[$excludepage] );
+
+ return $pages;
+ }
+
+ /**
+ * @return string
+ */
+ public function getType() {
+ return self::LOAD_STYLES;
+ }
+
+ /**
+ * Get group name
+ *
+ * @return string
+ */
+ public function getGroup() {
+ return self::GROUP_USER;
+ }
+}
+
+/** @deprecated since 1.39 */
+class_alias( UserStylesModule::class, 'ResourceLoaderUserStylesModule' );
diff --git a/includes/ResourceLoader/VueComponentParser.php b/includes/ResourceLoader/VueComponentParser.php
new file mode 100644
index 000000000000..12a6a1a11958
--- /dev/null
+++ b/includes/ResourceLoader/VueComponentParser.php
@@ -0,0 +1,321 @@
+<?php
+/**
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation; either version 2 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License along
+ * with this program; if not, write to the Free Software Foundation, Inc.,
+ * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @author Roan Kattouw
+ */
+
+namespace MediaWiki\ResourceLoader;
+
+use DOMDocument;
+use DOMElement;
+use DOMNode;
+use Exception;
+use Wikimedia\RemexHtml\DOM\DOMBuilder;
+use Wikimedia\RemexHtml\HTMLData;
+use Wikimedia\RemexHtml\Serializer\HtmlFormatter;
+use Wikimedia\RemexHtml\Serializer\Serializer;
+use Wikimedia\RemexHtml\Serializer\SerializerNode;
+use Wikimedia\RemexHtml\Tokenizer\Attributes;
+use Wikimedia\RemexHtml\Tokenizer\Tokenizer;
+use Wikimedia\RemexHtml\TreeBuilder\Dispatcher;
+use Wikimedia\RemexHtml\TreeBuilder\Element;
+use Wikimedia\RemexHtml\TreeBuilder\TreeBuilder;
+
+/**
+ * Parser for Vue single file components (.vue files). See parse() for usage.
+ *
+ * @ingroup ResourceLoader
+ * @internal For use within FileModule.
+ */
+class VueComponentParser {
+ /**
+ * Parse a Vue single file component, and extract the script, template and style parts.
+ *
+ * Returns an associative array with the following keys:
+ * - 'script': The JS code in the <script> tag
+ * - 'template': The HTML in the <template> tag
+ * - 'style': The CSS/LESS styles in the <style> tag, or null if the <style> tag was missing
+ * - 'styleLang': The language used for 'style'; either 'css' or 'less', or null if no <style> tag
+ *
+ * The following options can be passed in the $options parameter:
+ * - 'minifyTemplate': Whether to minify the HTML in the template tag. This removes
+ * HTML comments and strips whitespace. Default: false
+ *
+ * @param string $html HTML with <script>, <template> and <style> tags at the top level
+ * @param array $options Associative array of options
+ * @return array
+ * @throws Exception If the input is invalid
+ */
+ public function parse( string $html, array $options = [] ): array {
+ $dom = $this->parseHTML( $html );
+ // Remex wraps everything in <html><head>, unwrap that
+ $head = $dom->getElementsByTagName( 'head' )->item( 0 );
+
+ // Find the <script>, <template> and <style> tags. They can appear in any order, but they
+ // must be at the top level, and there can only be one of each.
+ if ( !$head ) {
+ throw new Exception( 'Parsed DOM did not contain a <head> tag' );
+ }
+ $nodes = $this->findUniqueTags( $head, [ 'script', 'template', 'style' ] );
+
+ // Throw an error if we didn't find a <script> or <template> tag. <style> is optional.
+ foreach ( [ 'script', 'template' ] as $requiredTag ) {
+ if ( !isset( $nodes[ $requiredTag ] ) ) {
+ throw new Exception( "No <$requiredTag> tag found" );
+ }
+ }
+
+ $this->validateAttributes( $nodes['script'], [] );
+ $this->validateAttributes( $nodes['template'], [] );
+ if ( isset( $nodes['style'] ) ) {
+ $this->validateAttributes( $nodes['style'], [ 'lang' ] );
+ }
+
+ $styleData = isset( $nodes['style'] ) ? $this->getStyleAndLang( $nodes['style'] ) : null;
+ $template = $this->getTemplateHtml( $html, $options['minifyTemplate'] ?? false );
+
+ return [
+ 'script' => trim( $nodes['script']->nodeValue ),
+ 'template' => $template,
+ 'style' => $styleData ? $styleData['style'] : null,
+ 'styleLang' => $styleData ? $styleData['lang'] : null
+ ];
+ }
+
+ /**
+ * Parse HTML to DOM using RemexHtml
+ * @param string $html
+ * @return DOMDocument
+ */
+ private function parseHTML( $html ): DOMDocument {
+ $domBuilder = new DOMBuilder( [ 'suppressHtmlNamespace' => true ] );
+ $treeBuilder = new TreeBuilder( $domBuilder, [ 'ignoreErrors' => true ] );
+ $tokenizer = new Tokenizer( new Dispatcher( $treeBuilder ), $html, [ 'ignoreErrors' => true ] );
+ $tokenizer->execute();
+ // @phan-suppress-next-line PhanTypeMismatchReturnSuperType
+ return $domBuilder->getFragment();
+ }
+
+ /**
+ * Find occurrences of specified tags in a DOM node, expecting at most one occurrence of each.
+ * This method only looks at the top-level children of $rootNode, it doesn't descend into them.
+ *
+ * @param DOMNode $rootNode Node whose children to look at
+ * @param string[] $tagNames Tag names to look for (must be all lowercase)
+ * @return DOMElement[] Associative arrays whose keys are tag names and values are DOM nodes
+ */
+ private function findUniqueTags( DOMNode $rootNode, array $tagNames ): array {
+ $nodes = [];
+ foreach ( $rootNode->childNodes as $node ) {
+ $tagName = strtolower( $node->nodeName );
+ if ( in_array( $tagName, $tagNames ) ) {
+ if ( isset( $nodes[ $tagName ] ) ) {
+ throw new Exception( "More than one <$tagName> tag found" );
+ }
+ $nodes[ $tagName ] = $node;
+ }
+ }
+ return $nodes;
+ }
+
+ /**
+ * Verify that a given node only has a given set of attributes, and no others.
+ * @param DOMNode $node Node to check
+ * @param array $allowedAttributes Attributes the node is allowed to have
+ * @throws Exception If the node has an attribute it's not allowed to have
+ */
+ private function validateAttributes( DOMNode $node, array $allowedAttributes ): void {
+ if ( $allowedAttributes ) {
+ foreach ( $node->attributes as $attr ) {
+ if ( !in_array( $attr->name, $allowedAttributes ) ) {
+ throw new Exception( "<{$node->nodeName}> may not have the " .
+ "{$attr->name} attribute" );
+ }
+ }
+ } elseif ( $node->attributes->length > 0 ) {
+ throw new Exception( "<{$node->nodeName}> may not have any attributes" );
+ }
+ }
+
+ /**
+ * Get the contents and language of the <style> tag. The language can be 'css' or 'less'.
+ * @param DOMElement $styleNode The <style> tag.
+ * @return array [ 'style' => string, 'lang' => string ]
+ * @throws Exception If an invalid language is used, or if the 'scoped' attribute is set.
+ */
+ private function getStyleAndLang( DOMElement $styleNode ): array {
+ $style = trim( $styleNode->nodeValue );
+ $styleLang = $styleNode->hasAttribute( 'lang' ) ?
+ $styleNode->getAttribute( 'lang' ) : 'css';
+ if ( $styleLang !== 'css' && $styleLang !== 'less' ) {
+ throw new Exception( "<style lang=\"$styleLang\"> is invalid," .
+ " lang must be \"css\" or \"less\"" );
+ }
+ return [
+ 'style' => $style,
+ 'lang' => $styleLang,
+ ];
+ }
+
+ /**
+ * Get the HTML contents of the <template> tag, optionally minifed.
+ *
+ * To work around a bug in PHP's DOMDocument where attributes like @click get mangled,
+ * we re-parse the entire file using a Remex parse+serialize pipeline, with a custom dispatcher
+ * to zoom in on just the contents of the <template> tag, and a custom formatter for minification.
+ * Keeping everything in Remex and never converting it to DOM avoids the attribute mangling issue.
+ *
+ * @param string $html HTML that contains a <template> tag somewhere
+ * @param bool $minify Whether to minify the output (remove comments, strip whitespace)
+ * @return string HTML contents of the template tag
+ */
+ private function getTemplateHtml( $html, $minify ) {
+ $serializer = new Serializer( $this->newTemplateFormatter( $minify ) );
+ $tokenizer = new Tokenizer(
+ $this->newFilteringDispatcher(
+ new TreeBuilder( $serializer, [ 'ignoreErrors' => true ] ),
+ 'template'
+ ),
+ $html, [ 'ignoreErrors' => true ]
+ );
+ $tokenizer->execute( [ 'fragmentNamespace' => HTMLData::NS_HTML, 'fragmentName' => 'template' ] );
+ return trim( $serializer->getResult() );
+ }
+
+ /**
+ * Custom HtmlFormatter subclass that optionally removes comments and strips whitespace.
+ * If $minify=false, this formatter falls through to HtmlFormatter for everything (except that
+ * it strips the <!doctype html> tag).
+ *
+ * @param bool $minify If true, remove comments and strip whitespace
+ * @return HtmlFormatter
+ */
+ private function newTemplateFormatter( $minify ) {
+ return new class( $minify ) extends HtmlFormatter {
+ private $minify;
+
+ public function __construct( $minify ) {
+ $this->minify = $minify;
+ }
+
+ public function startDocument( $fragmentNamespace, $fragmentName ) {
+ // Remove <!doctype html>
+ return '';
+ }
+
+ public function comment( SerializerNode $parent, $text ) {
+ if ( $this->minify ) {
+ // Remove all comments
+ return '';
+ }
+ return parent::comment( $parent, $text );
+ }
+
+ public function characters( SerializerNode $parent, $text, $start, $length ) {
+ if (
+ $this->minify && (
+ // Don't touch <pre>/<listing>/<textarea> nodes
+ $parent->namespace !== HTMLData::NS_HTML ||
+ !isset( $this->prefixLfElements[ $parent->name ] )
+ )
+ ) {
+ $text = substr( $text, $start, $length );
+ // Collapse runs of adjacent whitespace, and convert all whitespace to spaces
+ $text = preg_replace( '/[ \r\n\t]+/', ' ', $text );
+ $start = 0;
+ $length = strlen( $text );
+ }
+ return parent::characters( $parent, $text, $start, $length );
+ }
+
+ public function element( SerializerNode $parent, SerializerNode $node, $contents ) {
+ if (
+ $this->minify && (
+ // Don't touch <pre>/<listing>/<textarea> nodes
+ $node->namespace !== HTMLData::NS_HTML ||
+ !isset( $this->prefixLfElements[ $node->name ] )
+ )
+ ) {
+ // Remove leading and trailing whitespace
+ $contents = preg_replace( '/(^[ \r\n\t]+)|([\r\n\t ]+$)/', '', $contents );
+ }
+ return parent::element( $parent, $node, $contents );
+ }
+ };
+ }
+
+ /**
+ * Custom Dispatcher subclass that only dispatches tree events inside a tag with a certain name.
+ * This effectively filters the tree to only the contents of that tag.
+ *
+ * @param TreeBuilder $treeBuilder
+ * @param string $nodeName Tag name to filter for
+ * @return Dispatcher
+ */
+ private function newFilteringDispatcher( TreeBuilder $treeBuilder, $nodeName ) {
+ return new class( $treeBuilder, $nodeName ) extends Dispatcher {
+ private $nodeName;
+ private $nodeDepth = 0;
+ private $seenTag = false;
+
+ public function __construct( TreeBuilder $treeBuilder, $nodeName ) {
+ $this->nodeName = $nodeName;
+ parent::__construct( $treeBuilder );
+ }
+
+ public function startTag( $name, Attributes $attrs, $selfClose, $sourceStart, $sourceLength ) {
+ if ( $this->nodeDepth ) {
+ parent::startTag( $name, $attrs, $selfClose, $sourceStart, $sourceLength );
+ }
+
+ if ( $name === $this->nodeName ) {
+ if ( $this->nodeDepth === 0 && $this->seenTag ) {
+ // This is the second opening tag, not nested in the first one
+ throw new Exception( "More than one <{$this->nodeName}> tag found" );
+ }
+ $this->nodeDepth++;
+ $this->seenTag = true;
+ }
+ }
+
+ public function endTag( $name, $sourceStart, $sourceLength ) {
+ if ( $name === $this->nodeName ) {
+ $this->nodeDepth--;
+ }
+ if ( $this->nodeDepth ) {
+ parent::endTag( $name, $sourceStart, $sourceLength );
+ }
+ }
+
+ public function characters( $text, $start, $length, $sourceStart, $sourceLength ) {
+ if ( $this->nodeDepth ) {
+ parent::characters( $text, $start, $length, $sourceStart, $sourceLength );
+ }
+ }
+
+ public function comment( $text, $sourceStart, $sourceLength ) {
+ if ( $this->nodeDepth ) {
+ parent::comment( $text, $sourceStart, $sourceLength );
+ }
+ }
+ };
+ }
+}
+
+/** @deprecated since 1.39 */
+class_alias( VueComponentParser::class, 'VueComponentParser' );
diff --git a/includes/ResourceLoader/WikiModule.php b/includes/ResourceLoader/WikiModule.php
new file mode 100644
index 000000000000..172b24801ddb
--- /dev/null
+++ b/includes/ResourceLoader/WikiModule.php
@@ -0,0 +1,723 @@
+<?php
+/**
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation; either version 2 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License along
+ * with this program; if not, write to the Free Software Foundation, Inc.,
+ * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @author Trevor Parscal
+ * @author Roan Kattouw
+ */
+
+namespace MediaWiki\ResourceLoader;
+
+use Content;
+use CSSJanus;
+use FormatJson;
+use MediaWiki\Linker\LinkTarget;
+use MediaWiki\MainConfigNames;
+use MediaWiki\MediaWikiServices;
+use MediaWiki\Page\PageIdentity;
+use MediaWiki\Revision\RevisionRecord;
+use MediaWiki\Revision\SlotRecord;
+use MemoizedCallable;
+use Title;
+use TitleValue;
+use Wikimedia\Minify\CSSMin;
+use Wikimedia\Rdbms\Database;
+use Wikimedia\Rdbms\IDatabase;
+use Wikimedia\Timestamp\ConvertibleTimestamp;
+
+/**
+ * Abstraction for ResourceLoader modules which pull from wiki pages
+ *
+ * This can only be used for wiki pages in the MediaWiki and User namespaces,
+ * because of its dependence on the functionality of Title::isUserConfigPage()
+ * and Title::isSiteConfigPage().
+ *
+ * This module supports being used as a placeholder for a module on a remote wiki.
+ * To do so, getDB() must be overloaded to return a foreign database object that
+ * allows local wikis to query page metadata.
+ *
+ * Safe for calls on local wikis are:
+ * - Option getters:
+ * - getGroup()
+ * - getPages()
+ * - Basic methods that strictly involve the foreign database
+ * - getDB()
+ * - isKnownEmpty()
+ * - getTitleInfo()
+ *
+ * @ingroup ResourceLoader
+ * @since 1.17
+ */
+class WikiModule extends Module {
+ /** @var string Origin defaults to users with sitewide authority */
+ protected $origin = self::ORIGIN_USER_SITEWIDE;
+
+ /**
+ * In-process cache for title info, structured as an array
+ * [
+ * <batchKey> // Pipe-separated list of sorted keys from getPages
+ * => [
+ * <titleKey> => [ // Normalised title key
+ * 'page_len' => ..,
+ * 'page_latest' => ..,
+ * 'page_touched' => ..,
+ * ]
+ * ]
+ * ]
+ * @see self::fetchTitleInfo()
+ * @see self::makeTitleKey()
+ * @var array
+ */
+ protected $titleInfo = [];
+
+ /** @var array List of page names that contain CSS */
+ protected $styles = [];
+
+ /** @var array List of page names that contain JavaScript */
+ protected $scripts = [];
+
+ /** @var array List of page names that contain JSON */
+ protected $datas = [];
+
+ /** @var string|null Group of module */
+ protected $group;
+
+ /**
+ * @param array|null $options For back-compat, this can be omitted in favour of overwriting
+ * getPages.
+ */
+ public function __construct( array $options = null ) {
+ if ( $options === null ) {
+ return;
+ }
+
+ foreach ( $options as $member => $option ) {
+ switch ( $member ) {
+ case 'styles':
+ case 'scripts':
+ case 'datas':
+ case 'group':
+ case 'targets':
+ $this->{$member} = $option;
+ break;
+ }
+ }
+ }
+
+ /**
+ * Subclasses should return an associative array of resources in the module.
+ * Keys should be the title of a page in the MediaWiki or User namespace.
+ *
+ * Values should be a nested array of options.
+ * The supported keys are 'type' and (CSS only) 'media'.
+ *
+ * For scripts, 'type' should be 'script'.
+ * For JSON files, 'type' should be 'data'.
+ * For stylesheets, 'type' should be 'style'.
+ *
+ * There is an optional 'media' key, the value of which can be the
+ * medium ('screen', 'print', etc.) of the stylesheet.
+ *
+ * @param Context $context
+ * @return array[]
+ * @phan-return array<string,array{type:string,media?:string}>
+ */
+ protected function getPages( Context $context ) {
+ $config = $this->getConfig();
+ $pages = [];
+
+ // Filter out pages from origins not allowed by the current wiki configuration.
+ if ( $config->get( MainConfigNames::UseSiteJs ) ) {
+ foreach ( $this->scripts as $script ) {
+ $pages[$script] = [ 'type' => 'script' ];
+ }
+ foreach ( $this->datas as $data ) {
+ $pages[$data] = [ 'type' => 'data' ];
+ }
+ }
+
+ if ( $config->get( MainConfigNames::UseSiteCss ) ) {
+ foreach ( $this->styles as $style ) {
+ $pages[$style] = [ 'type' => 'style' ];
+ }
+ }
+
+ return $pages;
+ }
+
+ /**
+ * Get group name
+ *
+ * @return string|null
+ */
+ public function getGroup() {
+ return $this->group;
+ }
+
+ /**
+ * Get the Database handle used for computing the module version.
+ *
+ * Subclasses may override this to return a foreign database, which would
+ * allow them to register a module on wiki A that fetches wiki pages from
+ * wiki B.
+ *
+ * The way this works is that the local module is a placeholder that can
+ * only computer a module version hash. The 'source' of the module must
+ * be set to the foreign wiki directly. Methods getScript() and getContent()
+ * will not use this handle and are not valid on the local wiki.
+ *
+ * @return IDatabase
+ */
+ protected function getDB() {
+ return wfGetDB( DB_REPLICA );
+ }
+
+ /**
+ * @param string $titleText
+ * @param Context $context
+ * @return null|string
+ * @since 1.32 added the $context parameter
+ */
+ protected function getContent( $titleText, Context $context ) {
+ $pageStore = MediaWikiServices::getInstance()->getPageStore();
+ $title = $pageStore->getPageByText( $titleText );
+ if ( !$title ) {
+ return null; // Bad title
+ }
+
+ $content = $this->getContentObj( $title, $context );
+ if ( !$content ) {
+ return null; // No content found
+ }
+
+ $handler = $content->getContentHandler();
+ if ( $handler->isSupportedFormat( CONTENT_FORMAT_CSS ) ) {
+ $format = CONTENT_FORMAT_CSS;
+ } elseif ( $handler->isSupportedFormat( CONTENT_FORMAT_JAVASCRIPT ) ) {
+ $format = CONTENT_FORMAT_JAVASCRIPT;
+ } elseif ( $handler->isSupportedFormat( CONTENT_FORMAT_JSON ) ) {
+ $format = CONTENT_FORMAT_JSON;
+ } else {
+ return null; // Bad content model
+ }
+
+ return $content->serialize( $format );
+ }
+
+ /**
+ * @param PageIdentity $page
+ * @param Context $context
+ * @param int $maxRedirects Maximum number of redirects to follow.
+ * Either 0 or 1.
+ * @return Content|null
+ * @since 1.32 added the $context and $maxRedirects parameters
+ * @internal for testing
+ */
+ protected function getContentObj(
+ PageIdentity $page, Context $context, $maxRedirects = 1
+ ) {
+ $overrideCallback = $context->getContentOverrideCallback();
+ $content = $overrideCallback ? call_user_func( $overrideCallback, $page ) : null;
+ if ( $content ) {
+ if ( !$content instanceof Content ) {
+ $this->getLogger()->error(
+ 'Bad content override for "{title}" in ' . __METHOD__,
+ [ 'title' => (string)$page ]
+ );
+ return null;
+ }
+ } else {
+ $revision = MediaWikiServices::getInstance()
+ ->getRevisionLookup()
+ ->getKnownCurrentRevision( $page );
+ if ( !$revision ) {
+ return null;
+ }
+ $content = $revision->getContent( SlotRecord::MAIN, RevisionRecord::RAW );
+
+ if ( !$content ) {
+ $this->getLogger()->error(
+ 'Failed to load content of CSS/JS/JSON page "{title}" in ' . __METHOD__,
+ [ 'title' => (string)$page ]
+ );
+ return null;
+ }
+ }
+
+ if ( $maxRedirects > 0 ) {
+ $newTitle = $content->getRedirectTarget();
+ if ( $newTitle ) {
+ return $this->getContentObj( $newTitle, $context, 0 );
+ }
+ }
+
+ return $content;
+ }
+
+ /**
+ * @param Context $context
+ * @return bool
+ */
+ public function shouldEmbedModule( Context $context ) {
+ $overrideCallback = $context->getContentOverrideCallback();
+ if ( $overrideCallback && $this->getSource() === 'local' ) {
+ foreach ( $this->getPages( $context ) as $page => $info ) {
+ $title = Title::newFromText( $page );
+ if ( $title && call_user_func( $overrideCallback, $title ) !== null ) {
+ return true;
+ }
+ }
+ }
+
+ return parent::shouldEmbedModule( $context );
+ }
+
+ /**
+ * @param Context $context
+ * @return string|array JavaScript code, or a package files array
+ */
+ public function getScript( Context $context ) {
+ if ( $this->isPackaged() ) {
+ return $this->getPackageFiles( $context );
+ } else {
+ $scripts = '';
+ foreach ( $this->getPages( $context ) as $titleText => $options ) {
+ if ( $options['type'] !== 'script' ) {
+ continue;
+ }
+ $script = $this->getContent( $titleText, $context );
+ if ( strval( $script ) !== '' ) {
+ $script = $this->validateScriptFile( $titleText, $script );
+ $scripts .= ResourceLoader::makeComment( $titleText ) . $script . "\n";
+ }
+ }
+ return $scripts;
+ }
+ }
+
+ /**
+ * Get whether this module is a packaged module.
+ *
+ * If false (the default), JavaScript pages are concatenated and executed as a single
+ * script. JSON pages are not supported.
+ *
+ * If true, the pages are bundled such that each page gets a virtual file name, where only
+ * the "main" script will be executed at first, and other JS or JSON pages may be be imported
+ * in client-side code through the `require()` function.
+ *
+ * @stable to override
+ * @since 1.38
+ * @return bool
+ */
+ protected function isPackaged(): bool {
+ // Packaged mode is disabled by default for backwards compatibility.
+ // Subclasses may opt-in to this feature.
+ return false;
+ }
+
+ /**
+ * @return bool
+ */
+ public function supportsURLLoading() {
+ // If package files are involved, don't support URL loading
+ return !$this->isPackaged();
+ }
+
+ /**
+ * Convert a namespace-formatted page title to a virtual package file name.
+ *
+ * This determines how the page may be imported in client-side code via `require()`.
+ *
+ * @stable to override
+ * @since 1.38
+ * @param string $titleText
+ * @return string
+ */
+ protected function getRequireKey( string $titleText ): string {
+ return $titleText;
+ }
+
+ /**
+ * @param Context $context
+ * @return array
+ * @phan-return array{main:string,files:string[][]}
+ */
+ private function getPackageFiles( Context $context ): array {
+ $main = null;
+
+ $files = [];
+ foreach ( $this->getPages( $context ) as $titleText => $options ) {
+ if ( $options['type'] !== 'script' && $options['type'] !== 'data' ) {
+ continue;
+ }
+ $content = $this->getContent( $titleText, $context );
+ if ( strval( $content ) !== '' ) {
+ $fileKey = $this->getRequireKey( $titleText );
+ if ( $options['type'] === 'script' ) {
+ $script = $this->validateScriptFile( $titleText, $content );
+ $files[$fileKey] = [
+ 'type' => 'script',
+ 'content' => $script,
+ ];
+ // First script becomes the "main" script
+ if ( $main === null ) {
+ $main = $fileKey;
+ }
+ } elseif ( $options['type'] === 'data' ) {
+ $data = FormatJson::decode( $content );
+ if ( $data == null ) {
+ // This is unlikely to happen since we only load JSON from
+ // wiki pages with a JSON content model, which are validated
+ // during edit save.
+ $data = [ 'error' => 'Invalid JSON' ];
+ }
+ $files[$fileKey] = [
+ 'type' => 'data',
+ 'content' => $data,
+ ];
+ }
+ }
+ }
+
+ return [
+ 'main' => $main,
+ 'files' => $files,
+ ];
+ }
+
+ /**
+ * @param Context $context
+ * @return array
+ */
+ public function getStyles( Context $context ) {
+ $styles = [];
+ foreach ( $this->getPages( $context ) as $titleText => $options ) {
+ if ( $options['type'] !== 'style' ) {
+ continue;
+ }
+ $media = $options['media'] ?? 'all';
+ $style = $this->getContent( $titleText, $context );
+ if ( strval( $style ) === '' ) {
+ continue;
+ }
+ if ( $this->getFlip( $context ) ) {
+ $style = CSSJanus::transform( $style, true, false );
+ }
+ $remoteDir = $this->getConfig()->get( MainConfigNames::ScriptPath );
+ if ( $remoteDir === '' ) {
+ // When the site is configured with the script path at the
+ // document root, MediaWiki uses an empty string but that is
+ // not a valid URI path. Expand to a slash to avoid fatals
+ // later in CSSMin::resolveUrl().
+ // See also FilePath::extractBasePaths, T282280.
+ $remoteDir = '/';
+ }
+
+ $style = MemoizedCallable::call(
+ [ CSSMin::class, 'remap' ],
+ [ $style, false, $remoteDir, true ]
+ );
+ if ( !isset( $styles[$media] ) ) {
+ $styles[$media] = [];
+ }
+ $style = ResourceLoader::makeComment( $titleText ) . $style;
+ $styles[$media][] = $style;
+ }
+ return $styles;
+ }
+
+ /**
+ * Disable module content versioning.
+ *
+ * This class does not support generating content outside of a module
+ * request due to foreign database support.
+ *
+ * See getDefinitionSummary() for meta-data versioning.
+ *
+ * @return bool
+ */
+ public function enableModuleContentVersion() {
+ return false;
+ }
+
+ /**
+ * @param Context $context
+ * @return array
+ */
+ public function getDefinitionSummary( Context $context ) {
+ $summary = parent::getDefinitionSummary( $context );
+ $summary[] = [
+ 'pages' => $this->getPages( $context ),
+ // Includes meta data of current revisions
+ 'titleInfo' => $this->getTitleInfo( $context ),
+ ];
+ return $summary;
+ }
+
+ /**
+ * @param Context $context
+ * @return bool
+ */
+ public function isKnownEmpty( Context $context ) {
+ // If a module has dependencies it cannot be empty. An empty array will be cast to false
+ if ( $this->getDependencies() ) {
+ return false;
+ }
+
+ // Optimisation: For user modules, don't needlessly load if there are no non-empty pages
+ // This is worthwhile because unlike most modules, user modules require their own
+ // separate embedded request (managed by ResourceLoaderClientHtml).
+ $revisions = $this->getTitleInfo( $context );
+ if ( $this->getGroup() === self::GROUP_USER ) {
+ foreach ( $revisions as $revision ) {
+ if ( $revision['page_len'] > 0 ) {
+ // At least one non-empty page, module should be loaded
+ return false;
+ }
+ }
+ return true;
+ }
+
+ // T70488: For non-user modules (i.e. ones that are called in cached HTML output) only check
+ // page existence. This ensures that, if some pages in a module are temporarily blanked,
+ // we don't stop embedding the module's script or link tag on newly cached pages.
+ return count( $revisions ) === 0;
+ }
+
+ private function setTitleInfo( $batchKey, array $titleInfo ) {
+ $this->titleInfo[$batchKey] = $titleInfo;
+ }
+
+ private static function makeTitleKey( LinkTarget $title ) {
+ // Used for keys in titleInfo.
+ return "{$title->getNamespace()}:{$title->getDBkey()}";
+ }
+
+ /**
+ * Get the information about the wiki pages for a given context.
+ * @param Context $context
+ * @return array[] Keyed by page name
+ */
+ protected function getTitleInfo( Context $context ) {
+ $dbr = $this->getDB();
+
+ $pageNames = array_keys( $this->getPages( $context ) );
+ sort( $pageNames );
+ $batchKey = implode( '|', $pageNames );
+ if ( !isset( $this->titleInfo[$batchKey] ) ) {
+ $this->titleInfo[$batchKey] = static::fetchTitleInfo( $dbr, $pageNames, __METHOD__ );
+ }
+
+ $titleInfo = $this->titleInfo[$batchKey];
+
+ // Override the title info from the overrides, if any
+ $overrideCallback = $context->getContentOverrideCallback();
+ if ( $overrideCallback ) {
+ foreach ( $pageNames as $page ) {
+ $title = Title::newFromText( $page );
+ $content = $title ? call_user_func( $overrideCallback, $title ) : null;
+ if ( $content !== null ) {
+ $titleInfo[$title->getPrefixedText()] = [
+ 'page_len' => $content->getSize(),
+ 'page_latest' => 'TBD', // None available
+ 'page_touched' => ConvertibleTimestamp::now( TS_MW ),
+ ];
+ }
+ }
+ }
+
+ return $titleInfo;
+ }
+
+ /**
+ * @param IDatabase $db
+ * @param array $pages
+ * @param string $fname
+ * @return array
+ */
+ protected static function fetchTitleInfo( IDatabase $db, array $pages, $fname = __METHOD__ ) {
+ $titleInfo = [];
+ $linkBatchFactory = MediaWikiServices::getInstance()->getLinkBatchFactory();
+ $batch = $linkBatchFactory->newLinkBatch();
+ foreach ( $pages as $titleText ) {
+ $title = Title::newFromText( $titleText );
+ if ( $title ) {
+ // Page name may be invalid if user-provided (e.g. gadgets)
+ $batch->addObj( $title );
+ }
+ }
+ if ( !$batch->isEmpty() ) {
+ $res = $db->select( 'page',
+ // Include page_touched to allow purging if cache is poisoned (T117587, T113916)
+ [ 'page_namespace', 'page_title', 'page_touched', 'page_len', 'page_latest' ],
+ $batch->constructSet( 'page', $db ),
+ $fname
+ );
+ foreach ( $res as $row ) {
+ // Avoid including ids or timestamps of revision/page tables so
+ // that versions are not wasted
+ $title = new TitleValue( (int)$row->page_namespace, $row->page_title );
+ $titleInfo[self::makeTitleKey( $title )] = [
+ 'page_len' => $row->page_len,
+ 'page_latest' => $row->page_latest,
+ 'page_touched' => $row->page_touched,
+ ];
+ }
+ }
+ return $titleInfo;
+ }
+
+ /**
+ * @since 1.28
+ * @param Context $context
+ * @param IDatabase $db
+ * @param string[] $moduleNames
+ */
+ public static function preloadTitleInfo(
+ Context $context, IDatabase $db, array $moduleNames
+ ) {
+ $rl = $context->getResourceLoader();
+ // getDB() can be overridden to point to a foreign database.
+ // For now, only preload local. In the future, we could preload by wikiID.
+ $allPages = [];
+ /** @var WikiModule[] $wikiModules */
+ $wikiModules = [];
+ foreach ( $moduleNames as $name ) {
+ $module = $rl->getModule( $name );
+ if ( $module instanceof self ) {
+ $mDB = $module->getDB();
+ // Subclasses may implement getDB differently
+ if ( $mDB->getDomainID() === $db->getDomainID() ) {
+ $wikiModules[] = $module;
+ $allPages += $module->getPages( $context );
+ }
+ }
+ }
+
+ if ( !$wikiModules ) {
+ // Nothing to preload
+ return;
+ }
+
+ $pageNames = array_keys( $allPages );
+ sort( $pageNames );
+ $hash = sha1( implode( '|', $pageNames ) );
+
+ // Avoid Zend bug where "static::" does not apply LSB in the closure
+ $func = [ static::class, 'fetchTitleInfo' ];
+ $fname = __METHOD__;
+
+ $cache = MediaWikiServices::getInstance()->getMainWANObjectCache();
+ $allInfo = $cache->getWithSetCallback(
+ $cache->makeGlobalKey( 'resourceloader-titleinfo', $db->getDomainID(), $hash ),
+ $cache::TTL_HOUR,
+ static function ( $curVal, &$ttl, array &$setOpts ) use ( $func, $pageNames, $db, $fname ) {
+ $setOpts += Database::getCacheSetOptions( $db );
+
+ return call_user_func( $func, $db, $pageNames, $fname );
+ },
+ [
+ 'checkKeys' => [
+ $cache->makeGlobalKey( 'resourceloader-titleinfo', $db->getDomainID() ) ]
+ ]
+ );
+
+ foreach ( $wikiModules as $wikiModule ) {
+ $pages = $wikiModule->getPages( $context );
+ // Before we intersect, map the names to canonical form (T145673).
+ $intersect = [];
+ foreach ( $pages as $pageName => $unused ) {
+ $title = Title::newFromText( $pageName );
+ if ( $title ) {
+ $intersect[ self::makeTitleKey( $title ) ] = 1;
+ } else {
+ // Page name may be invalid if user-provided (e.g. gadgets)
+ $rl->getLogger()->info(
+ 'Invalid wiki page title "{title}" in ' . __METHOD__,
+ [ 'title' => $pageName ]
+ );
+ }
+ }
+ $info = array_intersect_key( $allInfo, $intersect );
+ $pageNames = array_keys( $pages );
+ sort( $pageNames );
+ $batchKey = implode( '|', $pageNames );
+ $wikiModule->setTitleInfo( $batchKey, $info );
+ }
+ }
+
+ /**
+ * Clear the preloadTitleInfo() cache for all wiki modules on this wiki on
+ * page change if it was a JS or CSS page
+ *
+ * @internal
+ * @param PageIdentity $page
+ * @param RevisionRecord|null $old Prior page revision
+ * @param RevisionRecord|null $new New page revision
+ * @param string $domain Database domain ID
+ */
+ public static function invalidateModuleCache(
+ PageIdentity $page,
+ ?RevisionRecord $old,
+ ?RevisionRecord $new,
+ string $domain
+ ) {
+ static $models = [ CONTENT_MODEL_CSS, CONTENT_MODEL_JAVASCRIPT ];
+
+ $purge = false;
+ // TODO: MCR: differentiate between page functionality and content model!
+ // Not all pages containing CSS or JS have to be modules! [PageType]
+ if ( $old ) {
+ $oldModel = $old->getSlot( SlotRecord::MAIN, RevisionRecord::RAW )->getModel();
+ if ( in_array( $oldModel, $models ) ) {
+ $purge = true;
+ }
+ }
+
+ if ( !$purge && $new ) {
+ $newModel = $new->getSlot( SlotRecord::MAIN, RevisionRecord::RAW )->getModel();
+ if ( in_array( $newModel, $models ) ) {
+ $purge = true;
+ }
+ }
+
+ if ( !$purge ) {
+ $title = Title::castFromPageIdentity( $page );
+ $purge = ( $title->isSiteConfigPage() || $title->isUserConfigPage() );
+ }
+
+ if ( $purge ) {
+ $cache = MediaWikiServices::getInstance()->getMainWANObjectCache();
+ $key = $cache->makeGlobalKey( 'resourceloader-titleinfo', $domain );
+ $cache->touchCheckKey( $key );
+ }
+ }
+
+ /**
+ * @since 1.28
+ * @return string
+ */
+ public function getType() {
+ // Check both because subclasses don't always pass pages via the constructor,
+ // they may also override getPages() instead, in which case we should keep
+ // defaulting to LOAD_GENERAL and allow them to override getType() separately.
+ return ( $this->styles && !$this->scripts ) ? self::LOAD_STYLES : self::LOAD_GENERAL;
+ }
+}
+
+/** @deprecated since 1.39 */
+class_alias( WikiModule::class, 'ResourceLoaderWikiModule' );
diff --git a/includes/ResourceLoader/dependencystore/DependencyStore.php b/includes/ResourceLoader/dependencystore/DependencyStore.php
new file mode 100644
index 000000000000..36b6507b5fc3
--- /dev/null
+++ b/includes/ResourceLoader/dependencystore/DependencyStore.php
@@ -0,0 +1,126 @@
+<?php
+/**
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation; either version 2 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License along
+ * with this program; if not, write to the Free Software Foundation, Inc.,
+ * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ */
+
+namespace Wikimedia\DependencyStore;
+
+/**
+ * Class for tracking per-entity dependency path lists that are expensive to mass compute
+ *
+ * @internal This should not be used outside of ResourceLoader and ResourceLoader\Module
+ */
+abstract class DependencyStore {
+ /** @var string */
+ protected const KEY_PATHS = 'paths';
+ /** @var string */
+ protected const KEY_AS_OF = 'asOf';
+
+ /**
+ * @param string[] $paths List of dependency paths
+ * @param int|null $asOf UNIX timestamp or null
+ * @return array
+ */
+ public function newEntityDependencies( array $paths = [], $asOf = null ) {
+ return [ self::KEY_PATHS => $paths, self::KEY_AS_OF => $asOf ];
+ }
+
+ /**
+ * Get the currently tracked dependencies for an entity
+ *
+ * The "paths" field contains a sorted list of unique paths
+ *
+ * The "asOf" field reflects the last-modified timestamp of the dependency data itself.
+ * It will be null if there is no tracking data available. Note that if empty path lists
+ * are never stored (as an optimisation) then it will not be possible to discern whether
+ * the result is up-to-date.
+ *
+ * @param string $type Entity type
+ * @param string $entity Entity name
+ * @return array Map of (paths: paths, asOf: UNIX timestamp or null)
+ * @throws DependencyStoreException
+ */
+ final public function retrieve( $type, $entity ) {
+ return $this->retrieveMulti( $type, [ $entity ] )[$entity];
+ }
+
+ /**
+ * Get the currently tracked dependencies for a set of entities
+ *
+ * @see KeyValueDependencyStore::retrieve()
+ *
+ * @param string $type Entity type
+ * @param string[] $entities Entity names
+ * @return array[] Map of (entity => (paths: paths, asOf: UNIX timestamp or null))
+ * @throws DependencyStoreException
+ */
+ abstract public function retrieveMulti( $type, array $entities );
+
+ /**
+ * Set the currently tracked dependencies for an entity
+ *
+ * Dependency data should be set to persist as long as anything might rely on it existing
+ * in order to check the validity of some previously computed work. This can be achieved
+ * while minimizing storage space under the following scheme:
+ * - a) computed work has a TTL (time-to-live)
+ * - b) when work is computed, the dependency data is updated
+ * - c) the dependency data has a TTL higher enough to accounts for skew/latency
+ * - d) the TTL of tracked dependency data is renewed upon access
+ *
+ * @param string $type Entity type
+ * @param string $entity Entity name
+ * @param array $data Map of (paths: paths, asOf: UNIX timestamp or null)
+ * @param int $ttl New time-to-live in seconds
+ * @throws DependencyStoreException
+ */
+ final public function store( $type, $entity, array $data, $ttl ) {
+ $this->storeMulti( $type, [ $entity => $data ], $ttl );
+ }
+
+ /**
+ * Set the currently tracked dependencies for a set of entities
+ *
+ * @see KeyValueDependencyStore::store()
+ *
+ * @param string $type Entity type
+ * @param array[] $dataByEntity Map of (entity => (paths: paths, asOf: UNIX timestamp or null))
+ * @param int $ttl New time-to-live in seconds
+ * @throws DependencyStoreException
+ *
+ */
+ abstract public function storeMulti( $type, array $dataByEntity, $ttl );
+
+ /**
+ * Delete the currently tracked dependencies for an entity or set of entities
+ *
+ * @param string $type Entity type
+ * @param string|string[] $entities Entity name(s)
+ * @throws DependencyStoreException
+ */
+ abstract public function remove( $type, $entities );
+
+ /**
+ * Set the expiry for the currently tracked dependencies for an entity or set of entities
+ *
+ * @param string $type Entity type
+ * @param string|string[] $entities Entity name(s)
+ * @param int $ttl New time-to-live in seconds
+ * @throws DependencyStoreException
+ */
+ abstract public function renew( $type, $entities, $ttl );
+}
diff --git a/includes/ResourceLoader/dependencystore/DependencyStoreException.php b/includes/ResourceLoader/dependencystore/DependencyStoreException.php
new file mode 100644
index 000000000000..7f81d57da0c2
--- /dev/null
+++ b/includes/ResourceLoader/dependencystore/DependencyStoreException.php
@@ -0,0 +1,13 @@
+<?php
+
+namespace Wikimedia\DependencyStore;
+
+use RuntimeException;
+
+/**
+ * @stable to type
+ * @since 1.35
+ */
+class DependencyStoreException extends RuntimeException {
+
+}
diff --git a/includes/ResourceLoader/dependencystore/KeyValueDependencyStore.php b/includes/ResourceLoader/dependencystore/KeyValueDependencyStore.php
new file mode 100644
index 000000000000..4c510ba92710
--- /dev/null
+++ b/includes/ResourceLoader/dependencystore/KeyValueDependencyStore.php
@@ -0,0 +1,118 @@
+<?php
+/**
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation; either version 2 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License along
+ * with this program; if not, write to the Free Software Foundation, Inc.,
+ * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ */
+
+namespace Wikimedia\DependencyStore;
+
+use BagOStuff;
+use InvalidArgumentException;
+
+/**
+ * Lightweight class for tracking path dependencies lists via an object cache instance
+ *
+ * This does not throw DependencyStoreException due to I/O errors since it is optimized for
+ * speed and availability. Read methods return empty placeholders on failure. Write methods
+ * might issue I/O in the background and return immediately. However, reads methods will at
+ * least block on the resolution (success/failure) of any such pending writes.
+ *
+ * @since 1.35
+ */
+class KeyValueDependencyStore extends DependencyStore {
+ /** @var BagOStuff */
+ private $stash;
+
+ /**
+ * @param BagOStuff $stash Storage backend
+ */
+ public function __construct( BagOStuff $stash ) {
+ $this->stash = $stash;
+ }
+
+ public function retrieveMulti( $type, array $entities ) {
+ $entitiesByKey = [];
+ foreach ( $entities as $entity ) {
+ $entitiesByKey[$this->getStoreKey( $type, $entity )] = $entity;
+ }
+
+ $blobsByKey = $this->stash->getMulti( array_keys( $entitiesByKey ) );
+
+ $results = [];
+ foreach ( $entitiesByKey as $key => $entity ) {
+ $blob = $blobsByKey[$key] ?? null;
+ $data = is_string( $blob ) ? json_decode( $blob, true ) : null;
+ $results[$entity] = $this->newEntityDependencies(
+ $data[self::KEY_PATHS] ?? [],
+ $data[self::KEY_AS_OF] ?? null
+ );
+ }
+
+ return $results;
+ }
+
+ public function storeMulti( $type, array $dataByEntity, $ttl ) {
+ $blobsByKey = [];
+ foreach ( $dataByEntity as $entity => $data ) {
+ if ( !is_array( $data[self::KEY_PATHS] ) || !is_int( $data[self::KEY_AS_OF] ) ) {
+ throw new InvalidArgumentException( "Invalid entry for '$entity'" );
+ }
+
+ // Normalize the list by removing duplicates and sorting
+ $data[self::KEY_PATHS] = array_values( array_unique( $data[self::KEY_PATHS] ) );
+ sort( $data[self::KEY_PATHS], SORT_STRING );
+
+ $blob = json_encode( $data, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE );
+ $blobsByKey[$this->getStoreKey( $type, $entity )] = $blob;
+ }
+
+ if ( $blobsByKey ) {
+ $this->stash->setMulti( $blobsByKey, $ttl, BagOStuff::WRITE_BACKGROUND );
+ }
+ }
+
+ public function remove( $type, $entities ) {
+ $keys = [];
+ foreach ( (array)$entities as $entity ) {
+ $keys[] = $this->getStoreKey( $type, $entity );
+ }
+
+ if ( $keys ) {
+ $this->stash->deleteMulti( $keys, BagOStuff::WRITE_BACKGROUND );
+ }
+ }
+
+ public function renew( $type, $entities, $ttl ) {
+ $keys = [];
+ foreach ( (array)$entities as $entity ) {
+ $keys[] = $this->getStoreKey( $type, $entity );
+ }
+
+ if ( $keys ) {
+ $this->stash->changeTTLMulti( $keys, $ttl, BagOStuff::WRITE_BACKGROUND );
+ }
+ }
+
+ /**
+ * @param string $type
+ * @param string $entity
+ * @return string
+ */
+ private function getStoreKey( $type, $entity ) {
+ return $this->stash->makeKey( "{$type}-dependencies", $entity );
+ }
+}
diff --git a/includes/ResourceLoader/dependencystore/SqlModuleDependencyStore.php b/includes/ResourceLoader/dependencystore/SqlModuleDependencyStore.php
new file mode 100644
index 000000000000..ffb79573070b
--- /dev/null
+++ b/includes/ResourceLoader/dependencystore/SqlModuleDependencyStore.php
@@ -0,0 +1,225 @@
+<?php
+/**
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation; either version 2 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License along
+ * with this program; if not, write to the Free Software Foundation, Inc.,
+ * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ */
+
+namespace Wikimedia\DependencyStore;
+
+use InvalidArgumentException;
+use Wikimedia\Rdbms\DBConnRef;
+use Wikimedia\Rdbms\DBError;
+use Wikimedia\Rdbms\IDatabase;
+use Wikimedia\Rdbms\ILoadBalancer;
+
+/**
+ * Class for tracking per-entity dependency path lists in the module_deps table
+ *
+ * This should not be used outside of ResourceLoader and ResourceLoader\Module
+ *
+ * @internal For use with ResourceLoader/ResourceLoader\Module only
+ * @since 1.35
+ */
+class SqlModuleDependencyStore extends DependencyStore {
+ /** @var ILoadBalancer */
+ private $lb;
+
+ /**
+ * @param ILoadBalancer $lb Storage backend
+ */
+ public function __construct( ILoadBalancer $lb ) {
+ $this->lb = $lb;
+ }
+
+ public function retrieveMulti( $type, array $entities ) {
+ try {
+ $dbr = $this->getReplicaDb();
+
+ $depsBlobByEntity = $this->fetchDependencyBlobs( $entities, $dbr );
+
+ $storedPathsByEntity = [];
+ foreach ( $depsBlobByEntity as $entity => $depsBlob ) {
+ $storedPathsByEntity[$entity] = json_decode( $depsBlob, true );
+ }
+
+ $results = [];
+ foreach ( $entities as $entity ) {
+ $paths = $storedPathsByEntity[$entity] ?? [];
+ $results[$entity] = $this->newEntityDependencies( $paths, null );
+ }
+
+ return $results;
+ } catch ( DBError $e ) {
+ throw new DependencyStoreException( $e->getMessage() );
+ }
+ }
+
+ public function storeMulti( $type, array $dataByEntity, $ttl ) {
+ // Avoid opening a primary DB connection when it's not needed.
+ // ResourceLoader::saveModuleDependenciesInternal calls this method unconditionally
+ // with empty values most of the time.
+ if ( !$dataByEntity ) {
+ return;
+ }
+ try {
+ $dbw = $this->getPrimaryDB();
+
+ $depsBlobByEntity = $this->fetchDependencyBlobs( array_keys( $dataByEntity ), $dbw );
+
+ $rows = [];
+ foreach ( $dataByEntity as $entity => $data ) {
+ list( $module, $variant ) = $this->getEntityNameComponents( $entity );
+ if ( !is_array( $data[self::KEY_PATHS] ) ) {
+ throw new InvalidArgumentException( "Invalid entry for '$entity'" );
+ }
+
+ // Normalize the list by removing duplicates and sortings
+ $paths = array_values( array_unique( $data[self::KEY_PATHS] ) );
+ sort( $paths, SORT_STRING );
+ $blob = json_encode( $paths, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE );
+
+ $existingBlob = $depsBlobByEntity[$entity] ?? null;
+ if ( $blob !== $existingBlob ) {
+ $rows[] = [
+ 'md_module' => $module,
+ 'md_skin' => $variant,
+ 'md_deps' => $blob
+ ];
+ }
+ }
+
+ // @TODO: use a single query with VALUES()/aliases support in DB wrapper
+ // See https://dev.mysql.com/doc/refman/8.0/en/insert-on-duplicate.html
+ foreach ( $rows as $row ) {
+ $dbw->upsert(
+ 'module_deps',
+ $row,
+ [ [ 'md_module', 'md_skin' ] ],
+ [
+ 'md_deps' => $row['md_deps'],
+ ],
+ __METHOD__
+ );
+ }
+ } catch ( DBError $e ) {
+ throw new DependencyStoreException( $e->getMessage() );
+ }
+ }
+
+ public function remove( $type, $entities ) {
+ // Avoid opening a primary DB connection when it's not needed.
+ // ResourceLoader::saveModuleDependenciesInternal calls this method unconditionally
+ // with empty values most of the time.
+ if ( !$entities ) {
+ return;
+ }
+ try {
+ $dbw = $this->getPrimaryDB();
+
+ $disjunctionConds = [];
+ foreach ( (array)$entities as $entity ) {
+ list( $module, $variant ) = $this->getEntityNameComponents( $entity );
+ $disjunctionConds[] = $dbw->makeList(
+ [ 'md_skin' => $variant, 'md_module' => $module ],
+ $dbw::LIST_AND
+ );
+ }
+
+ if ( $disjunctionConds ) {
+ $dbw->delete(
+ 'module_deps',
+ $dbw->makeList( $disjunctionConds, $dbw::LIST_OR ),
+ __METHOD__
+ );
+ }
+ } catch ( DBError $e ) {
+ throw new DependencyStoreException( $e->getMessage() );
+ }
+ }
+
+ public function renew( $type, $entities, $ttl ) {
+ // no-op
+ }
+
+ /**
+ * @param string[] $entities
+ * @param IDatabase $db
+ * @return string[]
+ */
+ private function fetchDependencyBlobs( array $entities, IDatabase $db ) {
+ $modulesByVariant = [];
+ foreach ( $entities as $entity ) {
+ list( $module, $variant ) = $this->getEntityNameComponents( $entity );
+ $modulesByVariant[$variant][] = $module;
+ }
+
+ $disjunctionConds = [];
+ foreach ( $modulesByVariant as $variant => $modules ) {
+ $disjunctionConds[] = $db->makeList(
+ [ 'md_skin' => $variant, 'md_module' => $modules ],
+ $db::LIST_AND
+ );
+ }
+
+ $depsBlobByEntity = [];
+
+ if ( $disjunctionConds ) {
+ $res = $db->select(
+ 'module_deps',
+ [ 'md_module', 'md_skin', 'md_deps' ],
+ $db->makeList( $disjunctionConds, $db::LIST_OR ),
+ __METHOD__
+ );
+
+ foreach ( $res as $row ) {
+ $entity = "{$row->md_module}|{$row->md_skin}";
+ $depsBlobByEntity[$entity] = $row->md_deps;
+ }
+ }
+
+ return $depsBlobByEntity;
+ }
+
+ /**
+ * @return DBConnRef
+ */
+ private function getReplicaDb() {
+ return $this->lb
+ ->getConnectionRef( DB_REPLICA, [], false, ( $this->lb )::CONN_TRX_AUTOCOMMIT );
+ }
+
+ /**
+ * @return DBConnRef
+ */
+ private function getPrimaryDb() {
+ return $this->lb
+ ->getConnectionRef( DB_PRIMARY, [], false, ( $this->lb )::CONN_TRX_AUTOCOMMIT );
+ }
+
+ /**
+ * @param string $entity
+ * @return string[]
+ */
+ private function getEntityNameComponents( $entity ) {
+ $parts = explode( '|', $entity, 2 );
+ if ( count( $parts ) !== 2 ) {
+ throw new InvalidArgumentException( "Invalid module entity '$entity'" );
+ }
+
+ return $parts;
+ }
+}