Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
79.02% covered (warning)
79.02%
610 / 772
60.00% covered (warning)
60.00%
39 / 65
CRAP
0.00% covered (danger)
0.00%
0 / 1
ResourceLoader
79.02% covered (warning)
79.02%
610 / 772
60.00% covered (warning)
60.00%
39 / 65
937.65
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
15 / 15
100.00% covered (success)
100.00%
1 / 1
3
 getConfig
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 setLogger
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getLogger
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getMessageBlobStore
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 setMessageBlobStore
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 setDependencyStore
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getDependencyStore
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 setModuleSkinStyles
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 register
100.00% covered (success)
100.00%
15 / 15
100.00% covered (success)
100.00%
1 / 1
6
 registerTestModules
n/a
0 / 0
n/a
0 / 0
4
 addSource
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
6
 getModuleNames
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getTestSuiteModuleNames
n/a
0 / 0
n/a
0 / 0
1
 isModuleRegistered
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getModule
100.00% covered (success)
100.00%
15 / 15
100.00% covered (success)
100.00%
1 / 1
4
 preloadModuleInfo
92.00% covered (success)
92.00%
23 / 25
0.00% covered (danger)
0.00%
0 / 1
8.03
 getSources
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getLoadScript
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 makeHash
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
1
 outputErrorAndLog
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
1
 getCombinedVersion
94.12% covered (success)
94.12%
16 / 17
0.00% covered (danger)
0.00%
0 / 1
5.01
 makeVersionQuery
83.33% covered (warning)
83.33%
5 / 6
0.00% covered (danger)
0.00%
0 / 1
3.04
 respond
83.61% covered (warning)
83.61%
51 / 61
0.00% covered (danger)
0.00%
0 / 1
27.75
 measureResponseTime
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
1
 sendResponseHeaders
64.52% covered (warning)
64.52%
20 / 31
0.00% covered (danger)
0.00%
0 / 1
18.43
 tryRespondNotModified
42.86% covered (danger)
42.86%
3 / 7
0.00% covered (danger)
0.00%
0 / 1
6.99
 getSourceMapUrl
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
1
 sendSourceMapVersionMismatch
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
2
 sendSourceMapTypeNotImplemented
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
2
 makeComment
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 formatExceptionNoComment
85.71% covered (warning)
85.71%
6 / 7
0.00% covered (danger)
0.00%
0 / 1
2.01
 makeModuleResponse
86.96% covered (warning)
86.96%
40 / 46
0.00% covered (danger)
0.00%
0 / 1
23.07
 getOneModuleResponse
98.28% covered (success)
98.28%
57 / 58
0.00% covered (danger)
0.00%
0 / 1
11
 addOneModuleResponse
68.09% covered (warning)
68.09%
32 / 47
0.00% covered (danger)
0.00%
0 / 1
22.31
 ensureNewline
75.00% covered (warning)
75.00%
3 / 4
0.00% covered (danger)
0.00%
0 / 1
3.14
 getModulesByMessage
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
12
 addImplementScript
94.12% covered (success)
94.12%
32 / 34
0.00% covered (danger)
0.00%
0 / 1
9.02
 addFiles
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
3
 addFileContent
100.00% covered (success)
100.00%
19 / 19
100.00% covered (success)
100.00%
1 / 1
7
 concatenatePlainScripts
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
6
 addPlainScripts
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
2
 isEmptyFileInfos
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 makeCombinedStyles
84.62% covered (warning)
84.62%
11 / 13
0.00% covered (danger)
0.00%
0 / 1
7.18
 encodeJsonForScript
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
6
 makeLoaderStateScript
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
1
 isEmptyObject
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 trimArray
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
8
 makeLoaderRegisterScript
100.00% covered (success)
100.00%
12 / 12
100.00% covered (success)
100.00%
1 / 1
6
 makeLoaderSourcesScript
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 makeLoaderConditionalScript
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 makeInlineCodeWithModule
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
2
 makeInlineScript
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
1
 makeConfigSetScript
0.00% covered (danger)
0.00%
0 / 9
0.00% covered (danger)
0.00%
0 / 1
6
 makePackedModulesString
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
1 / 1
6
 expandModuleNames
100.00% covered (success)
100.00%
15 / 15
100.00% covered (success)
100.00%
1 / 1
5
 inDebugMode
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
12
 clearCache
n/a
0 / 0
n/a
0 / 0
1
 createLoaderURL
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 createLoaderQuery
100.00% covered (success)
100.00%
12 / 12
100.00% covered (success)
100.00%
1 / 1
1
 makeLoaderQuery
90.48% covered (success)
90.48%
19 / 21
0.00% covered (danger)
0.00%
0 / 1
9.07
 isValidModuleName
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
4
 getLessCompiler
89.58% covered (warning)
89.58%
43 / 48
0.00% covered (danger)
0.00%
0 / 1
11.14
 filter
100.00% covered (success)
100.00%
24 / 24
100.00% covered (success)
100.00%
1 / 1
3
 applyFilter
54.55% covered (warning)
54.55%
6 / 11
0.00% covered (danger)
0.00%
0 / 1
7.35
 getUserDefaults
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
2
 getSiteConfigSettings
0.00% covered (danger)
0.00%
0 / 46
0.00% covered (danger)
0.00%
0 / 1
12
 getErrors
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
1<?php
2/**
3 * @license GPL-2.0-or-later
4 * @file
5 * @author Roan Kattouw
6 * @author Trevor Parscal
7 */
8
9namespace MediaWiki\ResourceLoader;
10
11use Exception;
12use InvalidArgumentException;
13use Less_Environment;
14use Less_Parser;
15use LogicException;
16use MediaWiki\CommentStore\CommentStore;
17use MediaWiki\Config\Config;
18use MediaWiki\Exception\MWExceptionHandler;
19use MediaWiki\Exception\MWExceptionRenderer;
20use MediaWiki\HookContainer\HookContainer;
21use MediaWiki\Html\Html;
22use MediaWiki\Html\HtmlJsCode;
23use MediaWiki\MainConfigNames;
24use MediaWiki\MediaWikiServices;
25use MediaWiki\Output\OutputPage;
26use MediaWiki\Profiler\ProfilingContext;
27use MediaWiki\Registration\ExtensionRegistry;
28use MediaWiki\Request\HeaderCallback;
29use MediaWiki\Request\WebRequest;
30use MediaWiki\Title\Title;
31use MediaWiki\User\Options\UserOptionsLookup;
32use MediaWiki\WikiMap\WikiMap;
33use Psr\Log\LoggerAwareInterface;
34use Psr\Log\LoggerInterface;
35use Psr\Log\NullLogger;
36use RuntimeException;
37use stdClass;
38use Throwable;
39use UnexpectedValueException;
40use Wikimedia\DependencyStore\DependencyStore;
41use Wikimedia\Http\HttpStatus;
42use Wikimedia\Minify\CSSMin;
43use Wikimedia\Minify\IdentityMinifierState;
44use Wikimedia\Minify\IndexMap;
45use Wikimedia\Minify\IndexMapOffset;
46use Wikimedia\Minify\JavaScriptMapperState;
47use Wikimedia\Minify\JavaScriptMinifier;
48use Wikimedia\Minify\JavaScriptMinifierState;
49use Wikimedia\Minify\MinifierState;
50use Wikimedia\ObjectCache\BagOStuff;
51use Wikimedia\ObjectCache\HashBagOStuff;
52use Wikimedia\RequestTimeout\TimeoutException;
53use Wikimedia\ScopedCallback;
54use Wikimedia\Stats\StatsFactory;
55use Wikimedia\Timestamp\ConvertibleTimestamp;
56use Wikimedia\WrappedString;
57
58/**
59 * @defgroup ResourceLoader ResourceLoader
60 *
61 * For higher level documentation, see <https://www.mediawiki.org/wiki/ResourceLoader/Architecture>.
62 */
63
64/**
65 * @defgroup ResourceLoaderHooks ResourceLoader Hooks
66 * @ingroup ResourceLoader
67 * @ingroup Hooks
68 */
69
70/**
71 * ResourceLoader is a loading system for JavaScript and CSS resources.
72 *
73 * For higher level documentation, see <https://www.mediawiki.org/wiki/ResourceLoader/Architecture>.
74 *
75 * @ingroup ResourceLoader
76 * @since 1.17
77 */
78class ResourceLoader implements LoggerAwareInterface {
79    /** @var int */
80    public const CACHE_VERSION = 9;
81
82    /** @var int */
83    private const MAXAGE_RECOVER = 60;
84
85    /** @var int|null */
86    protected static $debugMode = null;
87
88    /** @var Config */
89    private $config;
90    /** @var MessageBlobStore */
91    private $blobStore;
92    /** @var DependencyStore */
93    private $depStore;
94    /** @var LoggerInterface */
95    private $logger;
96    /** @var HookContainer */
97    private $hookContainer;
98    /** @var BagOStuff */
99    private $srvCache;
100    /** @var StatsFactory */
101    private $statsFactory;
102    /** @var int */
103    private $maxageVersioned;
104    /** @var int */
105    private $maxageUnversioned;
106
107    /** @var Module[] Map of (module name => Module) */
108    private $modules = [];
109    /** @var array[] Map of (module name => associative info array) */
110    private $moduleInfos = [];
111    /** @var string[] List of module names that contain QUnit tests */
112    private $testModuleNames = [];
113    /** @var string[] Map of (source => path); E.g. [ 'source-id' => 'http://.../load.php' ] */
114    private $sources = [];
115    /** @var array Errors accumulated during a respond() call. Exposed for testing. */
116    protected $errors = [];
117    /**
118     * @var string[] Buffer for extra response headers during a makeModuleResponse() call.
119     * Exposed for testing.
120     */
121    protected $extraHeaders = [];
122    /**
123     * @var array Styles that are skin-specific and supplement or replace the
124     * default skinStyles of a FileModule. See $wgResourceModuleSkinStyles.
125     */
126    private $moduleSkinStyles = [];
127
128    /**
129     * @internal For ServiceWiring only (TODO: Make stable as part of T32956).
130     * @param Config $config Generic pass-through for use by extension callbacks
131     *  and other MediaWiki-specific module classes.
132     * @param LoggerInterface|null $logger [optional]
133     * @param DependencyStore|null $tracker [optional]
134     * @param array $params [optional]
135     *  - loadScript: URL path to the load.php entrypoint.
136     *    Default: `'/load.php'`.
137     *  - maxageVersioned: HTTP cache max-age in seconds for URLs with a "version" parameter.
138     *    This applies to most load.php responses, and may have a long duration (e.g. weeks or
139     *    months), because a change in the module bundle will naturally produce a different URL
140     *    and thus automatically bust the CDN and web browser caches.
141     *    Default: 30 days.
142     *  - maxageUnversioned: HTTP cache max-age in seconds for URLs without a "version" parameter.
143     *    This should have a short duration (e.g. minutes), and affects the startup manifest which
144     *    controls how quickly changes (in the module registry, dependency tree, or module content)
145     *    will propagate to clients.
146     *    Default: 5 minutes.
147     */
148    public function __construct(
149        Config $config,
150        ?LoggerInterface $logger = null,
151        ?DependencyStore $tracker = null,
152        array $params = []
153    ) {
154        $this->maxageVersioned = $params['maxageVersioned'] ?? 30 * 24 * 60 * 60;
155        $this->maxageUnversioned = $params['maxageUnversioned'] ?? 5 * 60;
156
157        $this->config = $config;
158        $this->logger = $logger ?: new NullLogger();
159
160        $services = MediaWikiServices::getInstance();
161        $this->hookContainer = $services->getHookContainer();
162
163        $this->srvCache = $services->getLocalServerObjectCache();
164        $this->statsFactory = $services->getStatsFactory();
165
166        // Add 'local' source first
167        $this->addSource( 'local', $params['loadScript'] ?? '/load.php' );
168
169        // Special module that always exists
170        $this->register( 'startup', [ 'class' => StartUpModule::class ] );
171
172        $this->setMessageBlobStore(
173            new MessageBlobStore( $this, $this->logger, $services->getMainWANObjectCache() )
174        );
175
176        $tracker = $tracker ?: new DependencyStore( new HashBagOStuff() );
177        $this->setDependencyStore( $tracker );
178    }
179
180    /**
181     * @return Config
182     */
183    public function getConfig() {
184        return $this->config;
185    }
186
187    /**
188     * @since 1.26
189     * @param LoggerInterface $logger
190     */
191    public function setLogger( LoggerInterface $logger ): void {
192        $this->logger = $logger;
193    }
194
195    /**
196     * @since 1.27
197     * @return LoggerInterface
198     */
199    public function getLogger(): LoggerInterface {
200        return $this->logger;
201    }
202
203    /**
204     * @since 1.26
205     * @return MessageBlobStore
206     */
207    public function getMessageBlobStore() {
208        return $this->blobStore;
209    }
210
211    /**
212     * @since 1.25
213     * @param MessageBlobStore $blobStore
214     */
215    public function setMessageBlobStore( MessageBlobStore $blobStore ) {
216        $this->blobStore = $blobStore;
217    }
218
219    /**
220     * @since 1.35
221     * @param DependencyStore $tracker
222     */
223    public function setDependencyStore( DependencyStore $tracker ) {
224        $this->depStore = $tracker;
225    }
226
227    /**
228     * @internal For use by Module.php
229     * @since 1.44
230     * @return DependencyStore
231     */
232    public function getDependencyStore(): DependencyStore {
233        return $this->depStore;
234    }
235
236    /**
237     * @internal For use by ServiceWiring.php
238     * @param array $moduleSkinStyles
239     */
240    public function setModuleSkinStyles( array $moduleSkinStyles ) {
241        $this->moduleSkinStyles = $moduleSkinStyles;
242    }
243
244    /**
245     * Register a module with the ResourceLoader system.
246     *
247     * @see $wgResourceModules for the available options.
248     * @param string|array[] $name Module name as a string or, array of module info arrays
249     *  keyed by name.
250     * @param array|null $info Module info array. When using the first parameter to register
251     *  multiple modules at once, this parameter is optional.
252     * @throws InvalidArgumentException If a module name contains illegal characters (pipes or commas)
253     * @throws InvalidArgumentException If the module info is not an array
254     */
255    public function register( $name, ?array $info = null ) {
256        // Allow multiple modules to be registered in one call
257        $registrations = is_array( $name ) ? $name : [ $name => $info ];
258        foreach ( $registrations as $name => $info ) {
259            // Warn on duplicate registrations
260            if ( isset( $this->moduleInfos[$name] ) ) {
261                // A module has already been registered by this name
262                $this->logger->warning(
263                    'ResourceLoader duplicate registration warning. ' .
264                    'Another module has already been registered as ' . $name
265                );
266            }
267
268            // Check validity
269            if ( !self::isValidModuleName( $name ) ) {
270                throw new InvalidArgumentException( "ResourceLoader module name '$name' is invalid, "
271                    . "see ResourceLoader::isValidModuleName()" );
272            }
273            if ( !is_array( $info ) ) {
274                throw new InvalidArgumentException(
275                    'Invalid module info for "' . $name . '": expected array, got ' . get_debug_type( $info )
276                );
277            }
278
279            // Attach module
280            $this->moduleInfos[$name] = $info;
281        }
282    }
283
284    /**
285     * @internal For use by ServiceWiring only
286     * @codeCoverageIgnore
287     */
288    public function registerTestModules(): void {
289        $extRegistry = ExtensionRegistry::getInstance();
290        $testModules = $extRegistry->getAttribute( 'QUnitTestModule' );
291
292        $testModuleNames = [];
293        foreach ( $testModules as $name => &$module ) {
294            // Turn any single-module dependency into an array
295            if ( isset( $module['dependencies'] ) && is_string( $module['dependencies'] ) ) {
296                $module['dependencies'] = [ $module['dependencies'] ];
297            }
298
299            // Ensure the testrunner loads before any tests
300            $module['dependencies'][] = 'mediawiki.qunit-testrunner';
301
302            // Keep track of the modules to load on SpecialJavaScriptTest
303            $testModuleNames[] = $name;
304        }
305
306        // Core test modules (their names have further precedence).
307        $testModules = ( include MW_INSTALL_PATH . '/tests/qunit/QUnitTestResources.php' ) + $testModules;
308        $testModuleNames[] = 'test.MediaWiki';
309
310        $this->register( $testModules );
311        $this->testModuleNames = $testModuleNames;
312    }
313
314    /**
315     * Add a foreign source of modules.
316     *
317     * Source IDs are typically the same as the Wiki ID or database name (e.g. lowercase a-z).
318     *
319     * @param array|string $sources Source ID (string), or [ id1 => loadUrl, id2 => loadUrl, ... ]
320     * @param string|array|null $loadUrl load.php url (string), or array with loadUrl key for
321     *  backwards-compatibility.
322     * @throws InvalidArgumentException If array-form $loadUrl lacks a 'loadUrl' key.
323     */
324    public function addSource( $sources, $loadUrl = null ) {
325        if ( !is_array( $sources ) ) {
326            $sources = [ $sources => $loadUrl ];
327        }
328        foreach ( $sources as $id => $source ) {
329            // Disallow duplicates
330            if ( isset( $this->sources[$id] ) ) {
331                throw new RuntimeException( 'Cannot register source ' . $id . ' twice' );
332            }
333
334            // Support: MediaWiki 1.24 and earlier
335            if ( is_array( $source ) ) {
336                if ( !isset( $source['loadScript'] ) ) {
337                    throw new InvalidArgumentException( 'Each source must have a "loadScript" key' );
338                }
339                $source = $source['loadScript'];
340            }
341
342            $this->sources[$id] = $source;
343        }
344    }
345
346    /**
347     * @return string[]
348     */
349    public function getModuleNames() {
350        return array_keys( $this->moduleInfos );
351    }
352
353    /**
354     * Get a list of modules with QUnit tests.
355     *
356     * @internal For use by SpecialJavaScriptTest only
357     * @return string[]
358     * @codeCoverageIgnore
359     */
360    public function getTestSuiteModuleNames() {
361        return $this->testModuleNames;
362    }
363
364    /**
365     * Check whether a ResourceLoader module is registered
366     *
367     * @since 1.25
368     * @param string $name
369     * @return bool
370     */
371    public function isModuleRegistered( $name ) {
372        return isset( $this->moduleInfos[$name] );
373    }
374
375    /**
376     * Get the Module object for a given module name.
377     *
378     * If an array of module parameters exists but a Module object has not yet
379     * been instantiated, this method will instantiate and cache that object such that
380     * subsequent calls simply return the same object.
381     *
382     * @param string $name Module name
383     * @return Module|null If module has been registered, return a
384     *  Module instance. Otherwise, return null.
385     */
386    public function getModule( $name ) {
387        if ( !isset( $this->modules[$name] ) ) {
388            if ( !isset( $this->moduleInfos[$name] ) ) {
389                // No such module
390                return null;
391            }
392            // Construct the requested module object
393            $info = $this->moduleInfos[$name];
394            if ( isset( $info['factory'] ) ) {
395                /** @var Module $object */
396                $object = $info['factory']( $info );
397            } else {
398                $class = $info['class'] ?? FileModule::class;
399                /** @var Module $object */
400                $object = new $class( $info );
401            }
402            $object->setConfig( $this->getConfig() );
403            $object->setLogger( $this->logger );
404            $object->setHookContainer( $this->hookContainer );
405            $object->setName( $name );
406            $object->setSkinStylesOverride( $this->moduleSkinStyles );
407            $this->modules[$name] = $object;
408        }
409
410        return $this->modules[$name];
411    }
412
413    /**
414     * Load information stored in the database and dependency tracking store about modules
415     *
416     * @param string[] $moduleNames
417     * @param Context $context ResourceLoader-specific context of the request
418     */
419    public function preloadModuleInfo( array $moduleNames, Context $context ) {
420        // Load all tracked indirect file dependencies for the modules
421        $vary = Module::getVary( $context );
422        $entitiesByModule = [];
423        foreach ( $moduleNames as $moduleName ) {
424            $entitiesByModule[$moduleName] = "$moduleName|$vary";
425        }
426        $depsByEntity = $this->depStore->retrieveMulti(
427            $entitiesByModule
428        );
429        // Inject the indirect file dependencies for all the modules
430        foreach ( $moduleNames as $moduleName ) {
431            $module = $this->getModule( $moduleName );
432            if ( $module ) {
433                $entity = $entitiesByModule[$moduleName];
434                $deps = $depsByEntity[$entity];
435                $paths = $deps['paths'];
436                $module->setFileDependencies( $context, $paths );
437            }
438        }
439
440        WikiModule::preloadTitleInfo( $context, $moduleNames );
441
442        // Prime in-object cache for message blobs for modules with messages
443        $modulesWithMessages = [];
444        foreach ( $moduleNames as $moduleName ) {
445            $module = $this->getModule( $moduleName );
446            if ( $module && $module->getMessages() ) {
447                $modulesWithMessages[$moduleName] = $module;
448            }
449        }
450        // Prime in-object cache for message blobs for modules with messages
451        $lang = $context->getLanguage();
452        $store = $this->getMessageBlobStore();
453        $blobs = $store->getBlobs( $modulesWithMessages, $lang );
454        foreach ( $blobs as $moduleName => $blob ) {
455            $modulesWithMessages[$moduleName]->setMessageBlob( $blob, $lang );
456        }
457    }
458
459    /**
460     * Get the list of sources.
461     *
462     * @return array Like [ id => load.php url, ... ]
463     */
464    public function getSources() {
465        return $this->sources;
466    }
467
468    /**
469     * Get the URL to the load.php endpoint for the given ResourceLoader source.
470     *
471     * @since 1.24
472     * @param string $source Source ID
473     * @return string
474     * @throws UnexpectedValueException If the source ID was not registered
475     */
476    public function getLoadScript( $source ) {
477        if ( !isset( $this->sources[$source] ) ) {
478            throw new UnexpectedValueException( "Unknown source '$source'" );
479        }
480        return $this->sources[$source];
481    }
482
483    /**
484     * @internal For use by StartUpModule only.
485     */
486    public const HASH_LENGTH = 5;
487
488    /**
489     * Create a hash for module versioning purposes.
490     *
491     * This hash is used in three ways:
492     *
493     * - To differentiate between the current version and a past version
494     *   of a module by the same name.
495     *
496     *   In the cache key of localStorage in the browser (mw.loader.store).
497     *   This store keeps only one version of any given module. As long as the
498     *   next version the client encounters has a different hash from the last
499     *   version it saw, it will correctly discard it in favour of a network fetch.
500     *
501     *   A browser may evict a site's storage container for any reason (e.g. when
502     *   the user hasn't visited a site for some time, and/or when the device is
503     *   low on storage space). Anecdotally it seems devices rarely keep unused
504     *   storage beyond 2 weeks on mobile devices and 4 weeks on desktop.
505     *   But, there is no hard limit or expiration on localStorage.
506     *   ResourceLoader's Client also clears localStorage when the user changes
507     *   their language preference or when they (temporarily) use Debug Mode.
508     *
509     *   The only hard factors that reduce the range of possible versions are
510     *   1) the name and existence of a given module, and
511     *   2) the TTL for mw.loader.store, and
512     *   3) the `$wgResourceLoaderStorageVersion` configuration variable.
513     *
514     * - To identify a batch response of modules from load.php in an HTTP cache.
515     *
516     *   When fetching modules in a batch from load.php, a combined hash
517     *   is created by the JS code, and appended as query parameter.
518     *
519     *   In cache proxies (e.g. Varnish, Nginx) and in the browser's HTTP cache,
520     *   these urls are used to identify other previously cached responses.
521     *   The range of possible versions a given version has to be unique amongst
522     *   is determined by the maximum duration each response is stored for, which
523     *   is controlled by `$wgResourceLoaderMaxage['versioned']`.
524     *
525     * - To detect race conditions between multiple web servers in a MediaWiki
526     *   deployment of which some have the newer version and some still the older
527     *   version.
528     *
529     *   An HTTP request from a browser for the Startup manifest may be responded
530     *   to by a server with the newer version. The browser may then use that to
531     *   request a given module, which may then be responded to by a server with
532     *   the older version. To avoid caching this for too long (which would pollute
533     *   all other users without repairing itself), the combined hash that the JS
534     *   client adds to the url is verified by the server (in ::sendResponseHeaders).
535     *   If they don't match, we instruct cache proxies and clients to not cache
536     *   this response as long as they normally would. This is also the reason
537     *   that the algorithm used here in PHP must match the one used in JS.
538     *
539     * The fnv132 digest creates a 32-bit integer, which goes upto 4 Giga and
540     * needs up to 7 chars in base 36.
541     * Within 7 characters, base 36 can count up to 78,364,164,096 (78 Giga),
542     * (but with fnv132 we'd use very little of this range, mostly padding).
543     * Within 6 characters, base 36 can count up to 2,176,782,336 (2 Giga).
544     * Within 5 characters, base 36 can count up to 60,466,176 (60 Mega).
545     *
546     * @since 1.26
547     * @param string $value
548     * @return string Hash
549     */
550    public static function makeHash( $value ) {
551        $hash = hash( 'fnv132', $value );
552        // The base_convert will pad it (if too short),
553        // then substr() will trim it (if too long).
554        return substr(
555            \Wikimedia\base_convert( $hash, 16, 36, self::HASH_LENGTH ),
556            0,
557            self::HASH_LENGTH
558        );
559    }
560
561    /**
562     * Add an error to the 'errors' array and log it.
563     *
564     * @internal For use by StartUpModule.
565     * @since 1.29
566     * @param Exception $e
567     * @param string $msg
568     * @param array $context
569     */
570    public function outputErrorAndLog( Exception $e, $msg, array $context = [] ) {
571        MWExceptionHandler::logException( $e );
572        $this->logger->warning(
573            $msg,
574            $context + [ 'exception' => $e ]
575        );
576        $this->errors[] = self::formatExceptionNoComment( $e );
577    }
578
579    /**
580     * Helper method to get and combine versions of multiple modules.
581     *
582     * @since 1.26
583     * @param Context $context
584     * @param string[] $moduleNames List of known module names
585     * @return string Hash
586     */
587    public function getCombinedVersion( Context $context, array $moduleNames ) {
588        if ( !$moduleNames ) {
589            return '';
590        }
591        $hashes = [];
592        foreach ( $moduleNames as $module ) {
593            try {
594                $hash = $this->getModule( $module )->getVersionHash( $context );
595            } catch ( TimeoutException $e ) {
596                throw $e;
597            } catch ( Exception $e ) {
598                // If modules fail to compute a version, don't fail the request (T152266)
599                // and still compute versions of other modules.
600                $this->outputErrorAndLog( $e,
601                    'Calculating version for "{module}" failed: {exception}',
602                    [
603                        'module' => $module,
604                    ]
605                );
606                $hash = '';
607            }
608            $hashes[] = $hash;
609        }
610        return self::makeHash( implode( '', $hashes ) );
611    }
612
613    /**
614     * Get the expected value of the 'version' query parameter.
615     *
616     * This is used by respond() to set a short Cache-Control header for requests with
617     * information newer than the current server has. This avoids pollution of edge caches.
618     * Typically during deployment. (T117587)
619     *
620     * This MUST match return value of `mw.loader#getCombinedVersion()` client-side.
621     *
622     * @since 1.28
623     * @param Context $context
624     * @param string[] $modules
625     * @return string Hash
626     */
627    public function makeVersionQuery( Context $context, array $modules ) {
628        // As of MediaWiki 1.28, the server and client use the same algorithm for combining
629        // version hashes. There is no technical reason for this to be same, and for years the
630        // implementations differed. If getCombinedVersion in PHP (used for StartupModule and
631        // E-Tag headers) differs in the future from getCombinedVersion in JS (used for 'version'
632        // query parameter), then this method must continue to match the JS one.
633        $filtered = [];
634        foreach ( $modules as $name ) {
635            if ( !$this->getModule( $name ) ) {
636                // If a versioned request contains a missing module, the version is a mismatch
637                // as the client considered a module (and version) we don't have.
638                return '';
639            }
640            $filtered[] = $name;
641        }
642        return $this->getCombinedVersion( $context, $filtered );
643    }
644
645    /**
646     * Output a response to a load request, including the content-type header.
647     *
648     * @param Context $context Context in which a response should be formed
649     * @param string[] $extraHeaders HTTP response headers to send regardless of
650     * status (200 OK, or 304 Not Modified) and content type (CSS, JS, Image, SourceMap)
651     */
652    public function respond( Context $context, array $extraHeaders = [] ) {
653        // Buffer output to catch warnings. Normally we'd use ob_clean() on the
654        // top-level output buffer to clear warnings, but that breaks when ob_gzhandler
655        // is used: ob_clean() will clear the GZIP header in that case and it won't come
656        // back for subsequent output, resulting in invalid GZIP. So we have to wrap
657        // the whole thing in our own output buffer to be sure the active buffer
658        // doesn't use ob_gzhandler.
659        // See https://bugs.php.net/bug.php?id=36514
660        ob_start();
661
662        $this->errors = [];
663        $this->extraHeaders = $extraHeaders;
664        $responseTime = $this->measureResponseTime();
665        ProfilingContext::singleton()->init( MW_ENTRY_POINT, 'respond' );
666
667        // Find out which modules are missing and instantiate the others
668        $modules = [];
669        $missing = [];
670        foreach ( $context->getModules() as $name ) {
671            $module = $this->getModule( $name );
672            if ( $module ) {
673                // Do not allow private modules to be loaded from the web.
674                // This is a security issue, see T36907.
675                if ( $module->getGroup() === Module::GROUP_PRIVATE ) {
676                    // Not a serious error, just means something is trying to access it (T101806)
677                    $this->logger->debug( "Request for private module '$name' denied" );
678                    $this->errors[] = "Cannot build private module \"$name\"";
679                    continue;
680                }
681                $modules[$name] = $module;
682            } else {
683                $missing[] = $name;
684            }
685        }
686
687        try {
688            // Preload for getCombinedVersion() and for batch makeModuleResponse()
689            $this->preloadModuleInfo( array_keys( $modules ), $context );
690        } catch ( TimeoutException $e ) {
691            throw $e;
692        } catch ( Exception $e ) {
693            $this->outputErrorAndLog( $e, 'Preloading module info failed: {exception}' );
694        }
695
696        // Combine versions to propagate cache invalidation
697        $versionHash = $this->getCombinedVersion( $context, array_keys( $modules ) );
698
699        // See RFC 2616 Â§ 3.11 Entity Tags
700        // https://www.w3.org/Protocols/rfc2616/rfc2616-sec3.html#sec3.11
701        $etag = 'W/"' . $versionHash . '"';
702
703        // Try the client-side cache first
704        if ( $this->tryRespondNotModified( $context, $etag ) ) {
705            return; // output handled (buffers cleared)
706        }
707
708        if ( $context->isSourceMap() ) {
709            // In source map mode, a version mismatch should be a 404
710            if ( $context->getVersion() !== null && $versionHash !== $context->getVersion() ) {
711                ob_end_clean();
712                $this->sendSourceMapVersionMismatch( $versionHash );
713                return;
714            }
715            // No source maps for images, only=styles requests, or debug mode
716            if ( $context->getImage()
717                || $context->getOnly() === 'styles'
718                || $context->getDebug()
719            ) {
720                ob_end_clean();
721                $this->sendSourceMapTypeNotImplemented();
722                return;
723            }
724        }
725        // Emit source map header if supported (inverse of the above check)
726        if ( $this->config->get( MainConfigNames::ResourceLoaderEnableSourceMapLinks )
727            && !$context->getImageObj()
728            && !$context->isSourceMap()
729            && $context->shouldIncludeScripts()
730            && !$context->getDebug()
731        ) {
732            $this->extraHeaders[] = 'SourceMap: ' . $this->getSourceMapUrl( $context, $versionHash );
733        }
734
735        // Generate a response
736        $response = $this->makeModuleResponse( $context, $modules, $missing );
737
738        // Capture any PHP warnings from the output buffer and append them to the
739        // error list if we're in debug mode.
740        if ( $context->getDebug() ) {
741            $warnings = ob_get_contents();
742            if ( $warnings !== false && $warnings !== '' ) {
743                $this->errors[] = $warnings;
744            }
745        }
746
747        $this->sendResponseHeaders( $context, $etag, (bool)$this->errors );
748
749        // Remove the output buffer and output the response
750        ob_end_clean();
751
752        if ( $context->getImageObj() && $this->errors ) {
753            // We can't show both the error messages and the response when it's an image.
754            $response = implode( "\n\n", $this->errors );
755        } elseif ( $this->errors ) {
756            $errorText = implode( "\n\n", $this->errors );
757            $errorResponse = self::makeComment( $errorText );
758            if ( $context->shouldIncludeScripts() ) {
759                $errorResponse .= 'if (window.console && console.error) { console.error('
760                    . $context->encodeJson( $errorText )
761                    . "); }\n";
762                // Append the error info to the response
763                // We used to prepend it, but that would corrupt the source map
764                $response .= $errorResponse;
765            } else {
766                // For styles we can still prepend
767                $response = $errorResponse . $response;
768            }
769        }
770
771        // @phan-suppress-next-line SecurityCheck-XSS
772        echo $response;
773    }
774
775    /**
776     * Send stats about the time used to build the response
777     * @return ScopedCallback
778     */
779    protected function measureResponseTime() {
780        $requestStart = $_SERVER['REQUEST_TIME_FLOAT'];
781        return new ScopedCallback( function () use ( $requestStart ) {
782            $statTiming = microtime( true ) - $requestStart;
783
784            $this->statsFactory->getTiming( 'resourceloader_response_time_seconds' )
785                ->observe( 1000 * $statTiming );
786        } );
787    }
788
789    /**
790     * Send main response headers to the client.
791     *
792     * Deals with Content-Type, CORS (for stylesheets), and caching.
793     *
794     * @param Context $context
795     * @param string $etag ETag header value
796     * @param bool $errors Whether there are errors in the response
797     */
798    protected function sendResponseHeaders(
799        Context $context, $etag, $errors
800    ): void {
801        HeaderCallback::warnIfHeadersSent();
802
803        if ( $errors ) {
804            $maxage = self::MAXAGE_RECOVER;
805        } elseif (
806            $context->getVersion() !== null
807            && $context->getVersion() !== $this->makeVersionQuery( $context, $context->getModules() )
808        ) {
809            // If we need to self-correct, set a very short cache expiry
810            // to basically just debounce CDN traffic. This applies to:
811            // - Internal errors, e.g. due to misconfiguration.
812            // - Version mismatch, e.g. due to deployment race (T117587, T47877).
813            $this->logger->debug( 'Client and server registry version out of sync' );
814            $maxage = self::MAXAGE_RECOVER;
815        } elseif ( $context->getVersion() === null ) {
816            // Resources that can't set a version, should have their updates propagate to
817            // clients quickly. This applies to shared resources linked from HTML, such as
818            // the startup module and stylesheets.
819            $maxage = $this->maxageUnversioned;
820        } else {
821            // When a version is set, use a long expiry because changes
822            // will naturally miss the cache by using a different URL.
823            $maxage = $this->maxageVersioned;
824        }
825        if ( $context->getImageObj() ) {
826            // Output different headers if we're outputting textual errors.
827            if ( $errors ) {
828                header( 'Content-Type: text/plain; charset=utf-8' );
829            } else {
830                $context->getImageObj()->sendResponseHeaders( $context );
831            }
832        } elseif ( $context->isSourceMap() ) {
833            header( 'Content-Type: application/json' );
834        } elseif ( $context->getOnly() === 'styles' ) {
835            header( 'Content-Type: text/css; charset=utf-8' );
836            header( 'Access-Control-Allow-Origin: *' );
837        } else {
838            header( 'Content-Type: text/javascript; charset=utf-8' );
839        }
840        // See RFC 2616 Â§ 14.19 ETag
841        // https://www.w3.org/Protocols/rfc2616/rfc2616-sec14.html#sec14.19
842        header( 'ETag: ' . $etag );
843        if ( $context->getDebug() ) {
844            // Do not cache debug responses
845            header( 'Cache-Control: private, no-cache, must-revalidate' );
846        } else {
847            // T132418: When a resource expires mid-way a browsing session, prefer to renew it in
848            // the background instead of blocking the next page load (eg. startup module, or CSS).
849            $staleDirective = ( $maxage > self::MAXAGE_RECOVER
850                ? ", stale-while-revalidate=" . min( 60, intval( $maxage / 2 ) )
851                : ''
852            );
853            header( "Cache-Control: public, max-age=$maxage, s-maxage=$maxage" . $staleDirective );
854            header( 'Expires: ' . ConvertibleTimestamp::convert( TS_RFC2822, time() + $maxage ) );
855        }
856
857        foreach ( $this->extraHeaders as $header ) {
858            header( $header );
859        }
860    }
861
862    /**
863     * Respond with HTTP 304 Not Modified if appropriate.
864     *
865     * If there's an If-None-Match header, respond with a 304 appropriately
866     * and clear out the output buffer. If the client cache is too old then do nothing.
867     *
868     * @param Context $context
869     * @param string $etag ETag header value
870     * @return bool True if HTTP 304 was sent and output handled
871     */
872    protected function tryRespondNotModified( Context $context, $etag ) {
873        // See RFC 2616 Â§ 14.26 If-None-Match
874        // https://www.w3.org/Protocols/rfc2616/rfc2616-sec14.html#sec14.26
875        $clientKeys = $context->getRequest()->getHeader( 'If-None-Match', WebRequest::GETHEADER_LIST );
876        // Never send 304s in debug mode
877        if ( $clientKeys !== false && !$context->getDebug() && in_array( $etag, $clientKeys ) ) {
878            // There's another bug in ob_gzhandler (see also the comment at
879            // the top of this function) that causes it to gzip even empty
880            // responses, meaning it's impossible to produce a truly empty
881            // response (because the gzip header is always there). This is
882            // a problem because 304 responses have to be completely empty
883            // per the HTTP spec, and Firefox behaves buggily when they're not.
884            // See also https://bugs.php.net/bug.php?id=51579
885            // To work around this, we tear down all output buffering before
886            // sending the 304.
887            wfResetOutputBuffers( /* $resetGzipEncoding = */ true );
888
889            HttpStatus::header( 304 );
890            $this->sendResponseHeaders( $context, $etag, false );
891            return true;
892        }
893        return false;
894    }
895
896    /**
897     * Get the URL which will deliver the source map for the current response.
898     *
899     * @param Context $context
900     * @param string $version The combined version hash
901     * @return string
902     */
903    private function getSourceMapUrl( Context $context, $version ) {
904        return $this->createLoaderURL( 'local', $context, [
905            'sourcemap' => '1',
906            'version' => $version
907        ] );
908    }
909
910    /**
911     * Send an error page for a source map version mismatch
912     *
913     * @param string $currentVersion
914     */
915    private function sendSourceMapVersionMismatch( $currentVersion ) {
916        HttpStatus::header( 404 );
917        header( 'Content-Type: text/plain; charset=utf-8' );
918        header( 'X-Content-Type-Options: nosniff' );
919        echo "Can't deliver a source map for the requested version " .
920            "since the version is now '$currentVersion'\n";
921    }
922
923    /**
924     * Send an error page when a source map is requested but there is no
925     * support for the specified content type
926     */
927    private function sendSourceMapTypeNotImplemented() {
928        HttpStatus::header( 404 );
929        header( 'Content-Type: text/plain; charset=utf-8' );
930        header( 'X-Content-Type-Options: nosniff' );
931        echo "Can't make a source map for this content type\n";
932    }
933
934    /**
935     * Generate a CSS or JS comment block.
936     *
937     * Only use this for public data, not error message details.
938     *
939     * @param string $text
940     * @return string
941     */
942    public static function makeComment( $text ) {
943        $encText = str_replace( '*/', '* /', $text );
944        return "/*\n$encText\n*/\n";
945    }
946
947    /**
948     * Handle exception display.
949     *
950     * @since 1.25
951     * @param Throwable $e Exception to be shown to the user
952     * @return string Sanitized text for a CSS/JS comment that can be returned to the user
953     */
954    protected static function formatExceptionNoComment( Throwable $e ) {
955        if ( !MWExceptionRenderer::shouldShowExceptionDetails() ) {
956            return MWExceptionHandler::getPublicLogMessage( $e );
957        }
958
959        // Like MWExceptionHandler::getLogMessage but without $url and $id.
960        // - Long load.php URL would push the actual error message off-screen into
961        //   scroll overflow in browser devtools.
962        // - reqId is redundant with X-Request-Id header, plus usually no need to
963        //   correlate the reqId since the backtrace is already included below.
964        $type = get_class( $e );
965        $message = $e->getMessage();
966
967        return "$type$message" .
968            "\nBacktrace:\n" .
969            MWExceptionHandler::getRedactedTraceAsString( $e );
970    }
971
972    /**
973     * Generate code for a response.
974     *
975     * Calling this method also populates the `errors` and `headers` members,
976     * later used by respond().
977     *
978     * @param Context $context Context in which to generate a response
979     * @param Module[] $modules List of module objects keyed by module name
980     * @param string[] $missing List of requested module names that are unregistered (optional)
981     * @return string Response data
982     */
983    public function makeModuleResponse( Context $context,
984        array $modules, array $missing = []
985    ) {
986        if ( $modules === [] && $missing === [] ) {
987            return <<<MESSAGE
988/* This file is the Web entry point for MediaWiki's ResourceLoader:
989   <https://www.mediawiki.org/wiki/ResourceLoader>. In this request,
990   no modules were requested. Max made me put this here. */
991MESSAGE;
992        }
993
994        $image = $context->getImageObj();
995        if ( $image ) {
996            $data = $image->getImageData( $context );
997            if ( $data === false ) {
998                $data = '';
999                $this->errors[] = 'Image generation failed';
1000            }
1001            return $data;
1002        }
1003
1004        $states = [];
1005        foreach ( $missing as $name ) {
1006            $states[$name] = 'missing';
1007        }
1008
1009        $only = $context->getOnly();
1010        $debug = (bool)$context->getDebug();
1011        if ( $context->isSourceMap() && count( $modules ) > 1 ) {
1012            $indexMap = new IndexMap;
1013        } else {
1014            $indexMap = null;
1015        }
1016
1017        $out = '';
1018        foreach ( $modules as $name => $module ) {
1019            try {
1020                [ $response, $offset ] = $this->getOneModuleResponse( $context, $name, $module );
1021                if ( $indexMap ) {
1022                    $indexMap->addEncodedMap( $response, $offset );
1023                } else {
1024                    $out .= $response;
1025                }
1026            } catch ( TimeoutException $e ) {
1027                throw $e;
1028            } catch ( Exception $e ) {
1029                $this->outputErrorAndLog( $e, 'Generating module package failed: {exception}' );
1030
1031                // Respond to client with error-state instead of module implementation
1032                $states[$name] = 'error';
1033                unset( $modules[$name] );
1034            }
1035        }
1036
1037        // Update module states
1038        if ( $context->shouldIncludeScripts() && !$context->getRaw() ) {
1039            if ( $modules && $only === 'scripts' ) {
1040                // Set the state of modules loaded as only scripts to ready as
1041                // they don't have an mw.loader.impl wrapper that sets the state
1042                foreach ( $modules as $name => $module ) {
1043                    $states[$name] = 'ready';
1044                }
1045            }
1046
1047            // Set the state of modules we didn't respond to with mw.loader.impl
1048            if ( $states && !$context->isSourceMap() ) {
1049                $stateScript = self::makeLoaderStateScript( $context, $states );
1050                if ( !$debug ) {
1051                    $stateScript = self::filter( 'minify-js', $stateScript );
1052                }
1053                // Use a linebreak between module script and state script (T162719)
1054                $out = self::ensureNewline( $out ) . $stateScript;
1055            }
1056        } elseif ( $states ) {
1057            $this->errors[] = 'Problematic modules: '
1058                // Silently ignore invalid UTF-8 injected via 'modules' query
1059                // Don't issue server-side warnings for client errors. (T331641)
1060                // phpcs:ignore Generic.PHP.NoSilencedErrors.Discouraged
1061                . @$context->encodeJson( $states );
1062        }
1063
1064        if ( $indexMap ) {
1065            return $indexMap->getMap();
1066        }
1067        return $out;
1068    }
1069
1070    /**
1071     * Get the response of a single module
1072     *
1073     * @param Context $context
1074     * @param string $name
1075     * @param Module $module
1076     * @return array{string,IndexMapOffset|null}
1077     */
1078    private function getOneModuleResponse( Context $context, $name, Module $module ) {
1079        $only = $context->getOnly();
1080        // Important: Do not cache minifications of embedded modules
1081        // This is especially for the private 'user.options' module,
1082        // which varies on every pageview and would explode the cache (T84960)
1083        $shouldCache = !$module->shouldEmbedModule( $context );
1084        if ( $only === 'styles' ) {
1085            $minifier = new IdentityMinifierState;
1086            $this->addOneModuleResponse( $context, $minifier, $name, $module, $this->extraHeaders );
1087            // NOTE: This is not actually "minified". IdentityMinifierState is a no-op wrapper
1088            // to ease code reuse. The filter() call below performs CSS minification.
1089            $styles = $minifier->getMinifiedOutput();
1090            if ( $context->getDebug() ) {
1091                return [ $styles, null ];
1092            }
1093            return [
1094                self::filter( 'minify-css', $styles,
1095                    [ 'cache' => $shouldCache ] ),
1096                null
1097            ];
1098        }
1099
1100        $replayMinifier = new ReplayMinifierState;
1101        $this->addOneModuleResponse( $context, $replayMinifier, $name, $module, $this->extraHeaders );
1102
1103        $minifier = new IdentityMinifierState;
1104        $replayMinifier->replayOn( $minifier );
1105        $plainContent = $minifier->getMinifiedOutput();
1106        if ( $context->getDebug() ) {
1107            return [ $plainContent, null ];
1108        }
1109
1110        $isHit = true;
1111        $callback = function () use ( $context, $replayMinifier, &$isHit ) {
1112            $isHit = false;
1113            if ( $context->isSourceMap() ) {
1114                $minifier = ( new JavaScriptMapperState )
1115                    ->outputFile( $this->createLoaderURL( 'local', $context, [
1116                        'modules' => self::makePackedModulesString( $context->getModules() ),
1117                        'only' => $context->getOnly()
1118                    ] ) );
1119            } else {
1120                $minifier = new JavaScriptMinifierState;
1121            }
1122            $replayMinifier->replayOn( $minifier );
1123            if ( $context->isSourceMap() ) {
1124                $sourceMap = $minifier->getRawSourceMap();
1125                $generated = $minifier->getMinifiedOutput();
1126                $offset = IndexMapOffset::newFromText( $generated );
1127                return [ $sourceMap, $offset->toArray() ];
1128            } else {
1129                return [ $minifier->getMinifiedOutput(), null ];
1130            }
1131        };
1132
1133        // The below is based on ResourceLoader::filter. Keep together to ease review/maintenance:
1134        // * Handle $shouldCache, skip cache and minify directly if set.
1135        // * Use minify cache, minify on-demand and populate cache as needed.
1136        // * Emit resourceloader_cache_total stats.
1137
1138        if ( $shouldCache ) {
1139            [ $response, $offsetArray ] = $this->srvCache->getWithSetCallback(
1140                $this->srvCache->makeGlobalKey(
1141                    'resourceloader-mapped',
1142                    self::CACHE_VERSION,
1143                    $name,
1144                    $context->isSourceMap() ? '1' : '0',
1145                    md5( $plainContent )
1146                ),
1147                BagOStuff::TTL_DAY,
1148                $callback
1149            );
1150
1151            $mapType = $context->isSourceMap() ? 'map-js' : 'minify-js';
1152            $this->statsFactory->getCounter( 'resourceloader_cache_total' )
1153                ->setLabel( 'type', $mapType )
1154                ->setLabel( 'status', $isHit ? 'hit' : 'miss' )
1155                ->increment();
1156        } else {
1157            [ $response, $offsetArray ] = $callback();
1158        }
1159        $offset = $offsetArray ? IndexMapOffset::newFromArray( $offsetArray ) : null;
1160
1161        return [ $response, $offset ];
1162    }
1163
1164    /**
1165     * Add the response of a single module to the MinifierState
1166     *
1167     * @param Context $context
1168     * @param MinifierState $minifier
1169     * @param string $name
1170     * @param Module $module
1171     * @param array|null &$headers Array of headers. If it is not null, the
1172     *   module's headers will be appended to this array.
1173     */
1174    private function addOneModuleResponse(
1175        Context $context, MinifierState $minifier, $name, Module $module, &$headers
1176    ) {
1177        $only = $context->getOnly();
1178        $debug = (bool)$context->getDebug();
1179        $content = $module->getModuleContent( $context );
1180        $version = $module->getVersionHash( $context );
1181
1182        if ( $headers !== null && isset( $content['headers'] ) ) {
1183            $headers = array_merge( $headers, $content['headers'] );
1184        }
1185
1186        // Append output
1187        switch ( $only ) {
1188            case 'scripts':
1189                $scripts = $content['scripts'];
1190                if ( !is_array( $scripts ) ) {
1191                    // Formerly scripts was usually a string, but now it is
1192                    // normalized to an array by buildContent().
1193                    throw new InvalidArgumentException( 'scripts must be an array' );
1194                }
1195                if ( isset( $scripts['plainScripts'] ) ) {
1196                    // Add plain scripts
1197                    $this->addPlainScripts( $minifier, $name, $scripts['plainScripts'] );
1198                } elseif ( isset( $scripts['files'] ) ) {
1199                    // Add implement call if any
1200                    $this->addImplementScript(
1201                        $minifier,
1202                        $name,
1203                        $version,
1204                        $scripts,
1205                        [],
1206                        null,
1207                        [],
1208                        $content['deprecationWarning'] ?? null
1209                    );
1210                }
1211                break;
1212            case 'styles':
1213                $styles = $content['styles'];
1214                // We no longer separate into media, they are all combined now with
1215                // custom media type groups into @media .. {} sections as part of the css string.
1216                // Module returns either an empty array or a numerical array with css strings.
1217                if ( isset( $styles['css'] ) ) {
1218                    $minifier->addOutput( implode( '', $styles['css'] ) );
1219                }
1220                break;
1221            default:
1222                $scripts = $content['scripts'] ?? '';
1223                if ( ( $name === 'site' || $name === 'user' )
1224                    && isset( $scripts['plainScripts'] )
1225                ) {
1226                    // Legacy scripts that run in the global scope without a closure.
1227                    // mw.loader.impl will use eval if scripts is a string.
1228                    // Minify manually here, because general response minification is
1229                    // not effective due it being a string literal, not a function.
1230                    $scripts = self::concatenatePlainScripts( $scripts['plainScripts'] );
1231                    if ( !$debug ) {
1232                        $scripts = self::filter( 'minify-js', $scripts ); // T107377
1233                    }
1234                }
1235                $this->addImplementScript(
1236                    $minifier,
1237                    $name,
1238                    $version,
1239                    $scripts,
1240                    $content['styles'] ?? [],
1241                    isset( $content['messagesBlob'] ) ? new HtmlJsCode( $content['messagesBlob'] ) : null,
1242                    $content['templates'] ?? [],
1243                    $content['deprecationWarning'] ?? null
1244                );
1245                break;
1246        }
1247        $minifier->ensureNewline();
1248    }
1249
1250    /**
1251     * Ensure the string is either empty or ends in a line break
1252     * @internal
1253     * @param string $str
1254     * @return string
1255     */
1256    public static function ensureNewline( $str ) {
1257        $end = substr( $str, -1 );
1258        if ( $end === '' || $end === "\n" ) {
1259            return $str;
1260        }
1261        return $str . "\n";
1262    }
1263
1264    /**
1265     * Get names of modules that use a certain message.
1266     *
1267     * @param string $messageKey
1268     * @return string[] List of module names
1269     */
1270    public function getModulesByMessage( $messageKey ) {
1271        $moduleNames = [];
1272        foreach ( $this->getModuleNames() as $moduleName ) {
1273            $module = $this->getModule( $moduleName );
1274            if ( in_array( $messageKey, $module->getMessages() ) ) {
1275                $moduleNames[] = $moduleName;
1276            }
1277        }
1278        return $moduleNames;
1279    }
1280
1281    /**
1282     * Generate JS code that calls mw.loader.impl with given module properties
1283     * and add it to the MinifierState.
1284     *
1285     * @param MinifierState $minifier The minifier to which output should be appended
1286     * @param string $moduleName The module name
1287     * @param string $version The module version hash
1288     * @param array|string|string[] $scripts
1289     *  - array: Package files array containing strings for individual JS files,
1290     *    as produced by Module::getScript().
1291     *  - string: Script contents to eval in global scope (for site/user scripts).
1292     *  - string[]: List of URLs (for debug mode).
1293     * @param array<string,string|array<string,string[]>> $styles
1294     *   Under optional key "css", there is a concatenated CSS string.
1295     *   Under optional key "url", there is an array by media type withs URLs to stylesheets (for debug mode).
1296     *   These come from Module::getStyles(), formatted by Module:buildContent().
1297     * @param HtmlJsCode|null $messages An already JSON-encoded map from message keys to values,
1298     *   wrapped in an HtmlJsCode object.
1299     * @param array<string,string> $templates Map from template name to template source.
1300     * @param string|null $deprecationWarning
1301     */
1302    private function addImplementScript( MinifierState $minifier,
1303        $moduleName, $version, $scripts, $styles, $messages, $templates, $deprecationWarning
1304    ) {
1305        $implementKey = "$moduleName@$version";
1306        // Plain functions are used instead of arrow functions to avoid
1307        // defeating lazy compilation on Chrome. (T343407)
1308        $minifier->addOutput( "mw.loader.impl(function(){return[" .
1309            Html::encodeJsVar( $implementKey ) . "," );
1310
1311        // Scripts
1312        if ( is_string( $scripts ) ) {
1313            // user/site script
1314            $minifier->addOutput( Html::encodeJsVar( $scripts ) );
1315        } elseif ( is_array( $scripts ) ) {
1316            if ( isset( $scripts['files'] ) ) {
1317                $minifier->addOutput(
1318                    "{\"main\":" .
1319                    Html::encodeJsVar( $scripts['main'] ) .
1320                    ",\"files\":" );
1321                $this->addFiles( $minifier, $moduleName, $scripts['files'] );
1322                $minifier->addOutput( "}" );
1323            } elseif ( isset( $scripts['plainScripts'] ) ) {
1324                if ( $this->isEmptyFileInfos( $scripts['plainScripts'] ) ) {
1325                    $minifier->addOutput( 'null' );
1326                } else {
1327                    $minifier->addOutput( "function($,jQuery,require,module){" );
1328                    $this->addPlainScripts( $minifier, $moduleName, $scripts['plainScripts'] );
1329                    $minifier->addOutput( "}" );
1330                }
1331            } elseif ( $scripts === [] || isset( $scripts[0] ) ) {
1332                // Array of URLs
1333                $minifier->addOutput( Html::encodeJsVar( $scripts ) );
1334            } else {
1335                throw new InvalidArgumentException( 'Invalid script array: ' .
1336                    'must contain files, plainScripts or be an array of URLs' );
1337            }
1338        } else {
1339            throw new InvalidArgumentException( 'Script must be a string or array' );
1340        }
1341
1342        // mw.loader.impl requires 'styles', 'messages' and 'templates' to be objects (not
1343        // arrays). json_encode considers empty arrays to be numerical and outputs "[]" instead
1344        // of "{}". Force them to objects.
1345        $extraArgs = [
1346            (object)$styles,
1347            $messages ?? (object)[],
1348            (object)$templates,
1349            $deprecationWarning
1350        ];
1351        self::trimArray( $extraArgs );
1352        foreach ( $extraArgs as $arg ) {
1353            $minifier->addOutput( ',' . Html::encodeJsVar( $arg ) );
1354        }
1355        $minifier->addOutput( "];});" );
1356    }
1357
1358    /**
1359     * Extract the contents of an array of package files, and convert it to a
1360     * JavaScript array. Add the array to the minifier state.
1361     *
1362     * Package files can contain JSON data.
1363     *
1364     * @param MinifierState $minifier
1365     * @param string $moduleName
1366     * @param array $files
1367     */
1368    private function addFiles( MinifierState $minifier, $moduleName, $files ) {
1369        $first = true;
1370        $minifier->addOutput( "{" );
1371        foreach ( $files as $fileName => $file ) {
1372            if ( $first ) {
1373                $first = false;
1374            } else {
1375                $minifier->addOutput( "," );
1376            }
1377            $minifier->addOutput( Html::encodeJsVar( $fileName ) . ':' );
1378            $this->addFileContent( $minifier, $moduleName, 'packageFile', $fileName, $file );
1379        }
1380        $minifier->addOutput( "}" );
1381    }
1382
1383    /**
1384     * Add a package file to a MinifierState
1385     *
1386     * @param MinifierState $minifier
1387     * @param string $moduleName
1388     * @param string $sourceType
1389     * @param string|int $sourceIndex
1390     * @param array $file The expanded file info array
1391     */
1392    private function addFileContent( MinifierState $minifier,
1393        $moduleName, $sourceType, $sourceIndex, array $file
1394    ) {
1395        $isScript = ( $file['type'] ?? 'script' ) === 'script';
1396        /** @var FilePath|null $filePath */
1397        $filePath = $file['filePath'] ?? $file['virtualFilePath'] ?? null;
1398        if ( $filePath !== null && $filePath->getRemoteBasePath() !== null ) {
1399            $url = $filePath->getRemotePath();
1400        } else {
1401            $ext = $isScript ? 'js' : 'json';
1402            $scriptPath = $this->config->has( MainConfigNames::ScriptPath )
1403                ? $this->config->get( MainConfigNames::ScriptPath ) : '';
1404            $url = "$scriptPath/virtual-resource/$moduleName-$sourceType-$sourceIndex.$ext";
1405        }
1406        $content = $file['content'];
1407        if ( $isScript ) {
1408            if ( $sourceType === 'packageFile' ) {
1409                // Provide CJS `exports` (in addition to CJS2 `module.exports`) to package modules (T284511).
1410                // $/jQuery are simply used as globals instead.
1411                // TODO: Remove $/jQuery param from traditional module closure too (and bump caching)
1412                $minifier->addOutput( "function(require,module,exports){" );
1413                $minifier->addSourceFile( $url, $content, true );
1414                $minifier->ensureNewline();
1415                $minifier->addOutput( "}" );
1416            } else {
1417                $minifier->addSourceFile( $url, $content, true );
1418                $minifier->ensureNewline();
1419            }
1420        } else {
1421            $content = Html::encodeJsVar( $content, true );
1422            $minifier->addSourceFile( $url, $content, true );
1423        }
1424    }
1425
1426    /**
1427     * Combine a plainScripts array like [ [ 'content' => '...' ] ] into a
1428     * single string.
1429     *
1430     * @param array[] $plainScripts
1431     * @return string
1432     */
1433    private static function concatenatePlainScripts( $plainScripts ) {
1434        $s = '';
1435        foreach ( $plainScripts as $script ) {
1436            // Make the script safe to concatenate by making sure there is at least one
1437            // trailing new line at the end of the content (T29054, T162719)
1438            $s .= self::ensureNewline( $script['content'] );
1439        }
1440        return $s;
1441    }
1442
1443    /**
1444     * Add contents from a plainScripts array like [ [ 'content' => '...' ]
1445     * to a MinifierState
1446     *
1447     * @param MinifierState $minifier
1448     * @param string $moduleName
1449     * @param array[] $plainScripts
1450     */
1451    private function addPlainScripts( MinifierState $minifier, $moduleName, $plainScripts ) {
1452        foreach ( $plainScripts as $index => $file ) {
1453            $this->addFileContent( $minifier, $moduleName, 'script', $index, $file );
1454        }
1455    }
1456
1457    /**
1458     * Determine whether an array of file info arrays has empty content
1459     *
1460     * @param array $infos
1461     * @return bool
1462     */
1463    private function isEmptyFileInfos( $infos ) {
1464        $len = 0;
1465        foreach ( $infos as $info ) {
1466            $len += strlen( $info['content'] ?? '' );
1467        }
1468        return $len === 0;
1469    }
1470
1471    /**
1472     * Combines an associative array mapping media type to CSS into a
1473     * single stylesheet with "@media" blocks.
1474     *
1475     * @param array<string,string|string[]> $stylePairs Map from media type to CSS string(s)
1476     * @return string[] CSS strings
1477     */
1478    public static function makeCombinedStyles( array $stylePairs ) {
1479        $out = [];
1480        foreach ( $stylePairs as $media => $styles ) {
1481            // FileModule::getStyle can return the styles as a string or an
1482            // array of strings. This is to allow separation in the front-end.
1483            $styles = (array)$styles;
1484            foreach ( $styles as $style ) {
1485                $style = trim( $style );
1486                // Don't output an empty "@media print { }" block (T42498)
1487                if ( $style === '' ) {
1488                    continue;
1489                }
1490                // Transform the media type based on request params and config
1491                // The way that this relies on $wgRequest to propagate request params is slightly evil
1492                $media = OutputPage::transformCssMedia( $media );
1493
1494                if ( $media === '' || $media == 'all' ) {
1495                    $out[] = $style;
1496                } elseif ( is_string( $media ) ) {
1497                    $out[] = "@media $media {\n" . str_replace( "\n", "\n\t", "\t" . $style ) . "}";
1498                }
1499                // else: skip
1500            }
1501        }
1502        return $out;
1503    }
1504
1505    /**
1506     * Wrapper around json_encode that avoids needless escapes,
1507     * and pretty-prints in debug mode.
1508     *
1509     * @param mixed $data
1510     * @return string|false JSON string, false on error
1511     */
1512    private static function encodeJsonForScript( $data ) {
1513        // Keep output as small as possible by disabling needless escape modes
1514        // that PHP uses by default.
1515        // However, while most module scripts are only served on HTTP responses
1516        // for JavaScript, some modules can also be embedded in the HTML as inline
1517        // scripts. This, and the fact that we sometimes need to export strings
1518        // containing user-generated content and labels that may genuinely contain
1519        // a sequences like "</script>", we need to encode either '/' or '<'.
1520        // By default PHP escapes '/'. Let's escape '<' instead which is less common
1521        // and allows URLs to mostly remain readable.
1522        $jsonFlags = JSON_UNESCAPED_SLASHES |
1523            JSON_UNESCAPED_UNICODE |
1524            JSON_HEX_TAG |
1525            JSON_HEX_AMP;
1526        if ( self::inDebugMode() ) {
1527            $jsonFlags |= JSON_PRETTY_PRINT;
1528        }
1529        return json_encode( $data, $jsonFlags );
1530    }
1531
1532    /**
1533     * Format a JS call to mw.loader.state()
1534     *
1535     * @internal For use by StartUpModule
1536     * @param Context $context
1537     * @param array<string,string> $states
1538     * @return string JavaScript code
1539     */
1540    public static function makeLoaderStateScript(
1541        Context $context, array $states
1542    ) {
1543        return 'mw.loader.state('
1544            // Silently ignore invalid UTF-8 injected via 'modules' query
1545            // Don't issue server-side warnings for client errors. (T331641)
1546            // phpcs:ignore Generic.PHP.NoSilencedErrors.Discouraged
1547            . @$context->encodeJson( $states )
1548            . ');';
1549    }
1550
1551    private static function isEmptyObject( stdClass $obj ): bool {
1552        foreach ( $obj as $value ) {
1553            return false;
1554        }
1555        return true;
1556    }
1557
1558    /**
1559     * Remove empty values from the end of an array.
1560     *
1561     * Values considered empty:
1562     *
1563     * - null
1564     * - []
1565     * - new HtmlJsCode( '{}' )
1566     * - new stdClass()
1567     * - (object)[]
1568     */
1569    private static function trimArray( array &$array ): void {
1570        $i = count( $array );
1571        while ( $i-- ) {
1572            if ( $array[$i] === null
1573                || $array[$i] === []
1574                || ( $array[$i] instanceof HtmlJsCode && $array[$i]->value === '{}' )
1575                || ( $array[$i] instanceof stdClass && self::isEmptyObject( $array[$i] ) )
1576            ) {
1577                unset( $array[$i] );
1578            } else {
1579                break;
1580            }
1581        }
1582    }
1583
1584    /**
1585     * Format JS code which calls `mw.loader.register()` with the given parameters.
1586     *
1587     * @par Example
1588     * @code
1589     *
1590     *     ResourceLoader::makeLoaderRegisterScript( $context, [
1591     *        [ $name1, $version1, $dependencies1, $group1, $source1, $skip1 ],
1592     *        [ $name2, $version2, $dependencies1, $group2, $source2, $skip2 ],
1593     *        ...
1594     *     ] ):
1595     * @endcode
1596     *
1597     * @internal For use by StartUpModule only
1598     * @param Context $context
1599     * @param array[] $modules Array of module registration arrays, each containing
1600     *  - string: module name
1601     *  - string: module version
1602     *  - array|null: List of dependencies (optional)
1603     *  - int|null: Module group (optional)
1604     *  - string|null: Name of foreign module source, or 'local' (optional)
1605     *  - string|null: Script body of a skip function (optional)
1606     * @phan-param array<int,array{0:string,1:string,2?:?array,3?:?int,4?:?string,5?:?string}> $modules
1607     * @return string JavaScript code
1608     */
1609    public static function makeLoaderRegisterScript(
1610        Context $context, array $modules
1611    ) {
1612        // Optimisation: Transform dependency names into indexes when possible
1613        // to produce smaller output. They are expanded by mw.loader.register on
1614        // the other end.
1615        $index = [];
1616        foreach ( $modules as $i => $module ) {
1617            // Build module name index
1618            $index[$module[0]] = $i;
1619        }
1620        foreach ( $modules as &$module ) {
1621            if ( isset( $module[2] ) ) {
1622                foreach ( $module[2] as &$dependency ) {
1623                    if ( isset( $index[$dependency] ) ) {
1624                        // Replace module name in dependency list with index
1625                        $dependency = $index[$dependency];
1626                    }
1627                }
1628            }
1629            self::trimArray( $module );
1630        }
1631
1632        return 'mw.loader.register('
1633            . $context->encodeJson( $modules )
1634            . ');';
1635    }
1636
1637    /**
1638     * Format JS code which calls `mw.loader.addSource()` with the given parameters.
1639     *
1640     *   - ResourceLoader::makeLoaderSourcesScript( $context,
1641     *         [ $id1 => $loadUrl, $id2 => $loadUrl, ... ]
1642     *     );
1643     *       Register sources with the given IDs and properties.
1644     *
1645     * @internal For use by StartUpModule only
1646     * @param Context $context
1647     * @param array<string,string> $sources
1648     * @return string JavaScript code
1649     */
1650    public static function makeLoaderSourcesScript(
1651        Context $context, array $sources
1652    ) {
1653        return 'mw.loader.addSource('
1654            . $context->encodeJson( $sources )
1655            . ');';
1656    }
1657
1658    /**
1659     * Wrap JavaScript code to run after the startup module.
1660     *
1661     * @param string $script JavaScript code
1662     * @return string JavaScript code
1663     */
1664    public static function makeLoaderConditionalScript( $script ) {
1665        // Adds a function to lazy-created RLQ
1666        return '(RLQ=window.RLQ||[]).push(function(){' .
1667            trim( $script ) . '});';
1668    }
1669
1670    /**
1671     * Wrap JavaScript code to run after a required module.
1672     *
1673     * @since 1.32
1674     * @param string|string[] $modules Module name(s)
1675     * @param string $script JavaScript code
1676     * @return string JavaScript code
1677     */
1678    public static function makeInlineCodeWithModule( $modules, $script ) {
1679        // Adds an array to lazy-created RLQ
1680        return '(RLQ=window.RLQ||[]).push(['
1681            . json_encode( $modules ) . ','
1682            . 'function(){' . trim( $script ) . '}'
1683            . ']);';
1684    }
1685
1686    /**
1687     * Make an HTML script that runs given JS code after startup and base modules.
1688     *
1689     * The code will be wrapped in a closure, and it will be executed by ResourceLoader's
1690     * startup module if the client has adequate support for MediaWiki JavaScript code.
1691     *
1692     * @param string $script JavaScript code
1693     * @param string|null $nonce Unused
1694     * @return string|WrappedString HTML
1695     */
1696    public static function makeInlineScript( $script, $nonce = null ) {
1697        $js = self::makeLoaderConditionalScript( $script );
1698        return new WrappedString(
1699            Html::inlineScript( $js ),
1700            "<script>(RLQ=window.RLQ||[]).push(function(){",
1701            '});</script>'
1702        );
1703    }
1704
1705    /**
1706     * Return JS code which will set the MediaWiki configuration array to
1707     * the given value.
1708     *
1709     * @param array $configuration List of configuration values keyed by variable name
1710     * @return string JavaScript code
1711     * @throws LogicException
1712     *
1713     * @deprecated since 1.44, Consider using package files instead or
1714     * you can return mw.config.set() combined with RL\Context::encodeJson, if available.
1715     * If not, use FormatJson::encode.
1716     */
1717    public static function makeConfigSetScript( array $configuration ) {
1718        $json = self::encodeJsonForScript( $configuration );
1719        if ( $json === false ) {
1720            $e = new LogicException(
1721                'JSON serialization of config data failed. ' .
1722                'This usually means the config data is not valid UTF-8.'
1723            );
1724            MWExceptionHandler::logException( $e );
1725            return 'mw.log.error(' . self::encodeJsonForScript( $e->__toString() ) . ');';
1726        }
1727        return "mw.config.set($json);";
1728    }
1729
1730    /**
1731     * Convert an array of module names to a packed query string.
1732     *
1733     * For example, `[ 'foo.bar', 'foo.baz', 'bar.baz', 'bar.quux' ]`
1734     * becomes `'foo.bar,baz|bar.baz,quux'`.
1735     *
1736     * This process is reversed by ResourceLoader::expandModuleNames().
1737     * See also mw.loader#buildModulesString() which is a port of this, used
1738     * on the client-side.
1739     *
1740     * @param string[] $modules List of module names (strings)
1741     * @return string Packed query string
1742     */
1743    public static function makePackedModulesString( array $modules ) {
1744        $moduleMap = []; // [ prefix => [ suffixes ] ]
1745        foreach ( $modules as $module ) {
1746            $pos = strrpos( $module, '.' );
1747            $prefix = $pos === false ? '' : substr( $module, 0, $pos );
1748            $suffix = $pos === false ? $module : substr( $module, $pos + 1 );
1749            $moduleMap[$prefix][] = $suffix;
1750        }
1751
1752        $arr = [];
1753        foreach ( $moduleMap as $prefix => $suffixes ) {
1754            $p = $prefix === '' ? '' : $prefix . '.';
1755            $arr[] = $p . implode( ',', $suffixes );
1756        }
1757        return implode( '|', $arr );
1758    }
1759
1760    /**
1761     * Expand a string of the form `jquery.foo,bar|jquery.ui.baz,quux` to
1762     * an array of module names like `[ 'jquery.foo', 'jquery.bar',
1763     * 'jquery.ui.baz', 'jquery.ui.quux' ]`.
1764     *
1765     * This process is reversed by ResourceLoader::makePackedModulesString().
1766     *
1767     * @since 1.33
1768     * @param string $modules Packed module name list
1769     * @return string[] Array of module names
1770     */
1771    public static function expandModuleNames( $modules ) {
1772        $retval = [];
1773        $exploded = explode( '|', $modules );
1774        foreach ( $exploded as $group ) {
1775            if ( !str_contains( $group, ',' ) ) {
1776                // This is not a set of modules in foo.bar,baz notation
1777                // but a single module
1778                $retval[] = $group;
1779                continue;
1780            }
1781            // This is a set of modules in foo.bar,baz notation
1782            $pos = strrpos( $group, '.' );
1783            if ( $pos === false ) {
1784                // Prefixless modules, i.e. without dots
1785                $retval = array_merge( $retval, explode( ',', $group ) );
1786                continue;
1787            }
1788            // We have a prefix and a bunch of suffixes
1789            $prefix = substr( $group, 0, $pos ); // 'foo'
1790            $suffixes = explode( ',', substr( $group, $pos + 1 ) ); // [ 'bar', 'baz' ]
1791            foreach ( $suffixes as $suffix ) {
1792                $retval[] = "$prefix.$suffix";
1793            }
1794        }
1795        return $retval;
1796    }
1797
1798    /**
1799     * Determine whether debug mode is on.
1800     *
1801     * Order of priority is:
1802     * - 1) Request parameter,
1803     * - 2) Cookie,
1804     * - 3) Site configuration.
1805     *
1806     * @return int
1807     */
1808    public static function inDebugMode() {
1809        if ( self::$debugMode === null ) {
1810            global $wgRequest;
1811
1812            $resourceLoaderDebug = MediaWikiServices::getInstance()->getMainConfig()->get(
1813                MainConfigNames::ResourceLoaderDebug );
1814            $str = $wgRequest->getRawVal( 'debug' ) ??
1815                $wgRequest->getCookie( 'resourceLoaderDebug', '', $resourceLoaderDebug ? 'true' : '' );
1816            self::$debugMode = Context::debugFromString( $str );
1817        }
1818        return self::$debugMode;
1819    }
1820
1821    /**
1822     * Reset static members used for caching.
1823     *
1824     * Global state and $wgRequest are evil, but we're using it right
1825     * now and sometimes we need to be able to force ResourceLoader to
1826     * re-evaluate the context because it has changed (e.g. in the test suite).
1827     *
1828     * @internal For use by unit tests
1829     * @codeCoverageIgnore
1830     */
1831    public static function clearCache() {
1832        self::$debugMode = null;
1833    }
1834
1835    /**
1836     * Build a load.php URL
1837     *
1838     * @since 1.24
1839     * @param string $source Name of the ResourceLoader source
1840     * @param Context $context
1841     * @param array $extraQuery
1842     * @return string URL to load.php. May be protocol-relative if $wgLoadScript is, too.
1843     */
1844    public function createLoaderURL( $source, Context $context,
1845        array $extraQuery = []
1846    ) {
1847        $query = self::createLoaderQuery( $context, $extraQuery );
1848        $script = $this->getLoadScript( $source );
1849
1850        return wfAppendQuery( $script, $query );
1851    }
1852
1853    /**
1854     * Helper for createLoaderURL()
1855     *
1856     * @since 1.24
1857     * @see makeLoaderQuery
1858     * @param Context $context
1859     * @param array $extraQuery
1860     * @return array
1861     */
1862    protected static function createLoaderQuery(
1863        Context $context, array $extraQuery = []
1864    ) {
1865        return self::makeLoaderQuery(
1866            $context->getModules(),
1867            $context->getLanguage(),
1868            $context->getSkin(),
1869            $context->getUser(),
1870            $context->getVersion(),
1871            $context->getDebug(),
1872            $context->getOnly(),
1873            $context->getRequest()->getBool( 'printable' ),
1874            null,
1875            $extraQuery
1876        );
1877    }
1878
1879    /**
1880     * Build a query array (array representation of query string) for load.php. Helper
1881     * function for createLoaderURL().
1882     *
1883     * @param string[] $modules
1884     * @param string $lang
1885     * @param string $skin
1886     * @param string|null $user
1887     * @param string|null $version
1888     * @param int $debug
1889     * @param string|null $only
1890     * @param bool $printable
1891     * @param bool|null $handheld Unused as of MW 1.38
1892     * @param array $extraQuery
1893     * @return array
1894     */
1895    public static function makeLoaderQuery( array $modules, $lang, $skin, $user = null,
1896        $version = null, $debug = Context::DEBUG_OFF, $only = null,
1897        $printable = false, $handheld = null, array $extraQuery = []
1898    ) {
1899        $query = [
1900            'modules' => self::makePackedModulesString( $modules ),
1901        ];
1902        // Keep urls short by omitting query parameters that
1903        // match the defaults assumed by Context.
1904        // Note: This relies on the defaults either being insignificant or forever constant,
1905        // as otherwise cached urls could change in meaning when the defaults change.
1906        if ( $lang !== Context::DEFAULT_LANG ) {
1907            $query['lang'] = $lang;
1908        }
1909        if ( $skin !== Context::DEFAULT_SKIN ) {
1910            $query['skin'] = $skin;
1911        }
1912        if ( $debug !== Context::DEBUG_OFF ) {
1913            $query['debug'] = strval( $debug );
1914        }
1915        if ( $user !== null ) {
1916            $query['user'] = $user;
1917        }
1918        if ( $version !== null ) {
1919            $query['version'] = $version;
1920        }
1921        if ( $only !== null ) {
1922            $query['only'] = $only;
1923        }
1924        if ( $printable ) {
1925            $query['printable'] = 1;
1926        }
1927        foreach ( $extraQuery as $name => $value ) {
1928            $query[$name] = $value;
1929        }
1930
1931        // Make queries uniform in order
1932        ksort( $query );
1933        return $query;
1934    }
1935
1936    /**
1937     * Check a module name for validity.
1938     *
1939     * Module names may not contain pipes (|), commas (,) or exclamation marks (!) and can be
1940     * at most 255 bytes.
1941     *
1942     * @param string $moduleName Module name to check
1943     * @return bool Whether $moduleName is a valid module name
1944     */
1945    public static function isValidModuleName( $moduleName ) {
1946        $len = strlen( $moduleName );
1947        return ( $len <= 255
1948            && strcspn( $moduleName, '!,|', 0, $len ) === $len )
1949            && ( !str_starts_with( $moduleName, "./" ) && !str_starts_with( $moduleName, "../" ) );
1950    }
1951
1952    /**
1953     * Return a LESS compiler that is set up for use with MediaWiki.
1954     *
1955     * @since 1.27
1956     * @param array $vars Associative array of variables that should be used
1957     *  for compilation. Since 1.32, this method no longer automatically includes
1958     *  global LESS vars from ResourceLoader::getLessVars (T191937).
1959     * @param array $importDirs Additional directories to look in for @import (since 1.36)
1960     * @return Less_Parser
1961     */
1962    public function getLessCompiler( array $vars = [], array $importDirs = [] ) {
1963        // When called from the installer, it is possible that a required PHP extension
1964        // is missing (at least for now; see T49564). If this is the case, throw an
1965        // exception (caught by the installer) to prevent a fatal error later on.
1966        if ( !class_exists( Less_Parser::class ) ) {
1967            throw new RuntimeException( 'MediaWiki requires the less.php parser' );
1968        }
1969
1970        $importDirs[] = MW_INSTALL_PATH . '/resources/src/mediawiki.less';
1971
1972        $parser = new Less_Parser;
1973        $parser->ModifyVars( $vars );
1974        $parser->SetOption( 'relativeUrls', false );
1975        $parser->SetOption( 'math', 'parens-division' );
1976
1977        // SetImportDirs expects an array like [ 'path1' => '', 'path2' => '' ]
1978        $formattedImportDirs = array_fill_keys( $importDirs, '' );
1979
1980        // Add a callback to the import dirs array for path remapping
1981        $codexDevDir = $this->getConfig()->get( MainConfigNames::CodexDevelopmentDir );
1982        $formattedImportDirs[] = static function ( $path ) use ( $codexDevDir ) {
1983            // For each of the Codex import paths, use CodexDevelopmentDir if it's set
1984            $importMap = [
1985                '@wikimedia/codex-icons/' => $codexDevDir !== null ?
1986                    "$codexDevDir/packages/codex-icons/dist/" :
1987                    MW_INSTALL_PATH . '/resources/lib/codex-icons/',
1988                'mediawiki.skin.codex/' => $codexDevDir !== null ?
1989                    "$codexDevDir/packages/codex/dist/" :
1990                    MW_INSTALL_PATH . '/resources/lib/codex/',
1991                'mediawiki.skin.codex-design-tokens/' => $codexDevDir !== null ?
1992                    "$codexDevDir/packages/codex-design-tokens/dist/" :
1993                    MW_INSTALL_PATH . '/resources/lib/codex-design-tokens/',
1994                '@wikimedia/codex-design-tokens/' => static function ( $unused_path ): never {
1995                    throw new RuntimeException(
1996                        'Importing from @wikimedia/codex-design-tokens is not supported. ' .
1997                        "To use the Codex tokens, use `@import 'mediawiki.skin.variables.less';` instead."
1998                    );
1999                }
2000            ];
2001            foreach ( $importMap as $importPath => $substPath ) {
2002                if ( str_starts_with( $path, $importPath ) ) {
2003                    $restOfPath = substr( $path, strlen( $importPath ) );
2004                    if ( is_callable( $substPath ) ) {
2005                        // @phan-suppress-next-line PhanUseReturnValueOfNever
2006                        $resolvedPath = $substPath( $restOfPath );
2007                    } else {
2008                        $filePath = $substPath . $restOfPath;
2009
2010                        $resolvedPath = null;
2011                        if ( file_exists( $filePath ) ) {
2012                            $resolvedPath = $filePath;
2013                        } elseif ( file_exists( "$filePath.less" ) ) {
2014                            $resolvedPath = "$filePath.less";
2015                        }
2016                    }
2017
2018                    if ( $resolvedPath !== null ) {
2019                        return [
2020                            Less_Environment::normalizePath( $resolvedPath ),
2021                            Less_Environment::normalizePath( dirname( $path ) )
2022                        ];
2023                    } else {
2024                        break;
2025                    }
2026                }
2027            }
2028            return [ null, null ];
2029        };
2030        $parser->SetImportDirs( $formattedImportDirs );
2031
2032        return $parser;
2033    }
2034
2035    /**
2036     * Run JavaScript or CSS data through a filter, caching the filtered result for future calls.
2037     *
2038     * Available filters are:
2039     *
2040     *    - minify-js
2041     *    - minify-css
2042     *
2043     * If $data is empty, only contains whitespace or the filter was unknown,
2044     * $data is returned unmodified.
2045     *
2046     * @param string $filter Name of filter to run
2047     * @param string $data Text to filter, such as JavaScript or CSS text
2048     * @param array<string,bool> $options Keys:
2049     *  - (bool) cache: Whether to allow caching this data. Default: true.
2050     * @return string Filtered data or unfiltered data
2051     */
2052    public static function filter( $filter, $data, array $options = [] ) {
2053        if ( isset( $options['cache'] ) && $options['cache'] === false ) {
2054            return self::applyFilter( $filter, $data ) ?? $data;
2055        }
2056
2057        $statsFactory = MediaWikiServices::getInstance()->getStatsFactory();
2058        // Same as ResourceLoader->srvCache
2059        $cache = MediaWikiServices::getInstance()->getLocalServerObjectCache();
2060
2061        $key = $cache->makeGlobalKey(
2062            'resourceloader-filter',
2063            $filter,
2064            self::CACHE_VERSION,
2065            md5( $data )
2066        );
2067
2068        $status = 'hit';
2069        $result = $cache->getWithSetCallback(
2070            $key,
2071            BagOStuff::TTL_DAY,
2072            static function () use ( $filter, $data, &$status ) {
2073                $status = 'miss';
2074                return self::applyFilter( $filter, $data );
2075            }
2076        );
2077        $statsFactory->getCounter( 'resourceloader_cache_total' )
2078            ->setLabel( 'type', $filter )
2079            ->setLabel( 'status', $status )
2080            ->increment();
2081
2082        // Use $data on cache failure
2083        return $result ?? $data;
2084    }
2085
2086    /**
2087     * @param string $filter
2088     * @param string $data
2089     * @return string|null
2090     */
2091    private static function applyFilter( $filter, $data ) {
2092        $data = trim( $data );
2093        if ( $data ) {
2094            try {
2095                $data = ( $filter === 'minify-css' )
2096                    ? CSSMin::minify( $data )
2097                    : JavaScriptMinifier::minify( $data );
2098            } catch ( TimeoutException $e ) {
2099                throw $e;
2100            } catch ( Exception $e ) {
2101                MWExceptionHandler::logException( $e );
2102                return null;
2103            }
2104        }
2105        return $data;
2106    }
2107
2108    /**
2109     * Get user default options to expose to JavaScript on all pages via `mw.user.options`.
2110     *
2111     * @internal Exposed for use from Resources.php
2112     *
2113     * @param Context $context
2114     * @param HookContainer $hookContainer
2115     * @param UserOptionsLookup $userOptionsLookup
2116     *
2117     * @return array
2118     */
2119    public static function getUserDefaults(
2120        Context $context,
2121        HookContainer $hookContainer,
2122        UserOptionsLookup $userOptionsLookup
2123    ): array {
2124        $defaultOptions = $userOptionsLookup->getDefaultOptions();
2125        $keysToExclude = [];
2126        $hookRunner = new HookRunner( $hookContainer );
2127        $hookRunner->onResourceLoaderExcludeUserOptions( $keysToExclude, $context );
2128        foreach ( $keysToExclude as $excludedKey ) {
2129            unset( $defaultOptions[ $excludedKey ] );
2130        }
2131        return $defaultOptions;
2132    }
2133
2134    /**
2135     * Get site configuration settings to expose to JavaScript on all pages via `mw.config`.
2136     *
2137     * @internal Exposed for use from Resources.php
2138     * @param Context $context
2139     * @param Config $conf
2140     * @return array
2141     */
2142    public static function getSiteConfigSettings(
2143        Context $context, Config $conf
2144    ): array {
2145        $services = MediaWikiServices::getInstance();
2146        // Namespace related preparation
2147        // - wgNamespaceIds: Key-value pairs of all localized, canonical and aliases for namespaces.
2148        // - wgCaseSensitiveNamespaces: Array of namespaces that are case-sensitive.
2149        $contLang = $services->getContentLanguage();
2150        $namespaceIds = $contLang->getNamespaceIds();
2151        $caseSensitiveNamespaces = [];
2152        $nsInfo = $services->getNamespaceInfo();
2153        foreach ( $nsInfo->getCanonicalNamespaces() as $index => $name ) {
2154            $namespaceIds[$contLang->lc( $name )] = $index;
2155            if ( !$nsInfo->isCapitalized( $index ) ) {
2156                $caseSensitiveNamespaces[] = $index;
2157            }
2158        }
2159
2160        $illegalFileChars = $conf->get( MainConfigNames::IllegalFileChars );
2161
2162        // Build list of variables
2163        $skin = $context->getSkin();
2164
2165        // Start of supported and stable config vars (for use by extensions/gadgets).
2166        $vars = [
2167            'debug' => $context->getDebug(),
2168            'skin' => $skin,
2169            'stylepath' => $conf->get( MainConfigNames::StylePath ),
2170            'wgArticlePath' => $conf->get( MainConfigNames::ArticlePath ),
2171            'wgScriptPath' => $conf->get( MainConfigNames::ScriptPath ),
2172            'wgScript' => $conf->get( MainConfigNames::Script ),
2173            'wgSearchType' => $conf->get( MainConfigNames::SearchType ),
2174            'wgVariantArticlePath' => $conf->get( MainConfigNames::VariantArticlePath ),
2175            'wgServer' => $conf->get( MainConfigNames::Server ),
2176            'wgServerName' => $conf->get( MainConfigNames::ServerName ),
2177            'wgUserLanguage' => $context->getLanguage(),
2178            'wgContentLanguage' => $contLang->getCode(),
2179            'wgVersion' => MW_VERSION,
2180            'wgFormattedNamespaces' => $contLang->getFormattedNamespaces(),
2181            'wgNamespaceIds' => $namespaceIds,
2182            'wgContentNamespaces' => $nsInfo->getContentNamespaces(),
2183            'wgSiteName' => $conf->get( MainConfigNames::Sitename ),
2184            'wgDBname' => $conf->get( MainConfigNames::DBname ),
2185            'wgWikiID' => WikiMap::getCurrentWikiId(),
2186            'wgCaseSensitiveNamespaces' => $caseSensitiveNamespaces,
2187            'wgCommentCodePointLimit' => CommentStore::COMMENT_CHARACTER_LIMIT,
2188            'wgExtensionAssetsPath' => $conf->get( MainConfigNames::ExtensionAssetsPath ),
2189        ];
2190        // End of stable config vars.
2191
2192        // Internal variables for use by MediaWiki core and/or ResourceLoader.
2193        $vars += [
2194            // @internal For mediawiki.widgets
2195            'wgUrlProtocols' => $services->getUrlUtils()->validProtocols(),
2196            // @internal For mediawiki.page.watch
2197            // Force object to avoid "empty" associative array from
2198            // becoming [] instead of {} in JS (T36604)
2199            'wgActionPaths' => (object)$conf->get( MainConfigNames::ActionPaths ),
2200            // @internal For mediawiki.language
2201            'wgTranslateNumerals' => $conf->get( MainConfigNames::TranslateNumerals ),
2202            // @internal For mediawiki.Title
2203            'wgExtraSignatureNamespaces' => $conf->get( MainConfigNames::ExtraSignatureNamespaces ),
2204            'wgLegalTitleChars' => Title::convertByteClassToUnicodeClass( Title::legalChars() ),
2205            'wgIllegalFileChars' => Title::convertByteClassToUnicodeClass( $illegalFileChars ),
2206        ];
2207
2208        ( new HookRunner( $services->getHookContainer() ) )
2209            ->onResourceLoaderGetConfigVars( $vars, $skin, $conf );
2210
2211        return $vars;
2212    }
2213
2214    /**
2215     * @internal For testing
2216     * @return array
2217     */
2218    public function getErrors() {
2219        return $this->errors;
2220    }
2221}