Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
79.68% covered (warning)
79.68%
604 / 758
60.32% covered (warning)
60.32%
38 / 63
CRAP
0.00% covered (danger)
0.00%
0 / 1
ResourceLoader
79.68% covered (warning)
79.68%
604 / 758
60.32% covered (warning)
60.32%
38 / 63
843.05
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
14 / 14
100.00% covered (success)
100.00%
1 / 1
1
 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
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 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
75.00% covered (warning)
75.00%
18 / 24
0.00% covered (danger)
0.00%
0 / 1
7.77
 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
80.00% covered (warning)
80.00%
12 / 15
0.00% covered (danger)
0.00%
0 / 1
8.51
 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
 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 / 9
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 MediaWiki\CommentStore\CommentStore;
16use MediaWiki\Config\Config;
17use MediaWiki\Context\RequestContext;
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\Http\HttpStatus;
41use Wikimedia\Minify\CSSMin;
42use Wikimedia\Minify\IdentityMinifierState;
43use Wikimedia\Minify\IndexMap;
44use Wikimedia\Minify\IndexMapOffset;
45use Wikimedia\Minify\JavaScriptMapperState;
46use Wikimedia\Minify\JavaScriptMinifier;
47use Wikimedia\Minify\JavaScriptMinifierState;
48use Wikimedia\Minify\MinifierState;
49use Wikimedia\ObjectCache\BagOStuff;
50use Wikimedia\ObjectCache\HashBagOStuff;
51use Wikimedia\RequestTimeout\TimeoutException;
52use Wikimedia\ScopedCallback;
53use Wikimedia\Stats\StatsFactory;
54use Wikimedia\Timestamp\ConvertibleTimestamp;
55use Wikimedia\Timestamp\TimestampFormat as TS;
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        $this->setDependencyStore( $tracker ?? new DependencyStore( new HashBagOStuff() ) );
177    }
178
179    /**
180     * @return Config
181     */
182    public function getConfig() {
183        return $this->config;
184    }
185
186    /**
187     * @since 1.26
188     * @param LoggerInterface $logger
189     */
190    public function setLogger( LoggerInterface $logger ): void {
191        $this->logger = $logger;
192    }
193
194    /**
195     * @since 1.27
196     * @return LoggerInterface
197     */
198    public function getLogger(): LoggerInterface {
199        return $this->logger;
200    }
201
202    /**
203     * @since 1.26
204     * @return MessageBlobStore
205     */
206    public function getMessageBlobStore() {
207        return $this->blobStore;
208    }
209
210    /**
211     * @since 1.25
212     * @param MessageBlobStore $blobStore
213     */
214    public function setMessageBlobStore( MessageBlobStore $blobStore ) {
215        $this->blobStore = $blobStore;
216    }
217
218    /**
219     * @since 1.35
220     * @param DependencyStore $tracker
221     */
222    public function setDependencyStore( DependencyStore $tracker ) {
223        $this->depStore = $tracker;
224    }
225
226    /**
227     * @internal For use by Module.php
228     * @since 1.44
229     * @return DependencyStore
230     */
231    public function getDependencyStore(): DependencyStore {
232        return $this->depStore;
233    }
234
235    /**
236     * @internal For use by ServiceWiring.php
237     * @param array $moduleSkinStyles
238     */
239    public function setModuleSkinStyles( array $moduleSkinStyles ) {
240        $this->moduleSkinStyles = $moduleSkinStyles;
241    }
242
243    /**
244     * Register a module with the ResourceLoader system.
245     *
246     * @see $wgResourceModules for the available options.
247     * @param string|array[] $name Module name as a string or, array of module info arrays
248     *  keyed by name.
249     * @param array|null $info Module info array. When using the first parameter to register
250     *  multiple modules at once, this parameter is optional.
251     * @throws InvalidArgumentException If a module name contains illegal characters (pipes or commas)
252     * @throws InvalidArgumentException If the module info is not an array
253     */
254    public function register( $name, ?array $info = null ) {
255        // Allow multiple modules to be registered in one call
256        $registrations = is_array( $name ) ? $name : [ $name => $info ];
257        foreach ( $registrations as $name => $info ) {
258            // Warn on duplicate registrations
259            if ( isset( $this->moduleInfos[$name] ) ) {
260                // A module has already been registered by this name
261                $this->logger->warning(
262                    'ResourceLoader duplicate registration warning. ' .
263                    'Another module has already been registered as ' . $name
264                );
265            }
266
267            // Check validity
268            if ( !self::isValidModuleName( $name ) ) {
269                throw new InvalidArgumentException( "ResourceLoader module name '$name' is invalid, "
270                    . "see ResourceLoader::isValidModuleName()" );
271            }
272            if ( !is_array( $info ) ) {
273                throw new InvalidArgumentException(
274                    'Invalid module info for "' . $name . '": expected array, got ' . get_debug_type( $info )
275                );
276            }
277
278            // Attach module
279            $this->moduleInfos[$name] = $info;
280        }
281    }
282
283    /**
284     * @internal For use by ServiceWiring only
285     * @codeCoverageIgnore
286     */
287    public function registerTestModules(): void {
288        $extRegistry = ExtensionRegistry::getInstance();
289        $testModules = $extRegistry->getAttribute( 'QUnitTestModule' );
290
291        $testModuleNames = [];
292        foreach ( $testModules as $name => &$module ) {
293            // Turn any single-module dependency into an array
294            if ( isset( $module['dependencies'] ) && is_string( $module['dependencies'] ) ) {
295                $module['dependencies'] = [ $module['dependencies'] ];
296            }
297
298            // Ensure the testrunner loads before any tests
299            $module['dependencies'][] = 'mediawiki.qunit-testrunner';
300
301            // Keep track of the modules to load on SpecialJavaScriptTest
302            $testModuleNames[] = $name;
303        }
304
305        // Core test modules (their names have further precedence).
306        $testModules = ( include MW_INSTALL_PATH . '/tests/qunit/QUnitTestResources.php' ) + $testModules;
307        $testModuleNames[] = 'test.MediaWiki';
308
309        $this->register( $testModules );
310        $this->testModuleNames = $testModuleNames;
311    }
312
313    /**
314     * Add a foreign source of modules.
315     *
316     * Source IDs are typically the same as the Wiki ID or database name (e.g. lowercase a-z).
317     *
318     * @param array|string $sources Source ID (string), or [ id1 => loadUrl, id2 => loadUrl, ... ]
319     * @param string|array|null $loadUrl load.php url (string), or array with loadUrl key for
320     *  backwards-compatibility.
321     * @throws InvalidArgumentException If array-form $loadUrl lacks a 'loadUrl' key.
322     */
323    public function addSource( $sources, $loadUrl = null ) {
324        if ( !is_array( $sources ) ) {
325            $sources = [ $sources => $loadUrl ];
326        }
327        foreach ( $sources as $id => $source ) {
328            // Disallow duplicates
329            if ( isset( $this->sources[$id] ) ) {
330                throw new RuntimeException( 'Cannot register source ' . $id . ' twice' );
331            }
332
333            // Support: MediaWiki 1.24 and earlier
334            if ( is_array( $source ) ) {
335                if ( !isset( $source['loadScript'] ) ) {
336                    throw new InvalidArgumentException( 'Each source must have a "loadScript" key' );
337                }
338                $source = $source['loadScript'];
339            }
340
341            $this->sources[$id] = $source;
342        }
343    }
344
345    /**
346     * @return string[]
347     */
348    public function getModuleNames() {
349        return array_keys( $this->moduleInfos );
350    }
351
352    /**
353     * Get a list of modules with QUnit tests.
354     *
355     * @internal For use by SpecialJavaScriptTest only
356     * @return string[]
357     * @codeCoverageIgnore
358     */
359    public function getTestSuiteModuleNames() {
360        return $this->testModuleNames;
361    }
362
363    /**
364     * Check whether a ResourceLoader module is registered
365     *
366     * @since 1.25
367     * @param string $name
368     * @return bool
369     */
370    public function isModuleRegistered( $name ) {
371        return isset( $this->moduleInfos[$name] );
372    }
373
374    /**
375     * Get the Module object for a given module name.
376     *
377     * If an array of module parameters exists but a Module object has not yet
378     * been instantiated, this method will instantiate and cache that object such that
379     * subsequent calls simply return the same object.
380     *
381     * @param string $name Module name
382     * @return Module|null If module has been registered, return a
383     *  Module instance. Otherwise, return null.
384     */
385    public function getModule( $name ) {
386        if ( !isset( $this->modules[$name] ) ) {
387            if ( !isset( $this->moduleInfos[$name] ) ) {
388                // No such module
389                return null;
390            }
391            // Construct the requested module object
392            $info = $this->moduleInfos[$name];
393            if ( isset( $info['factory'] ) ) {
394                /** @var Module $object */
395                $object = $info['factory']( $info );
396            } else {
397                $class = $info['class'] ?? FileModule::class;
398                /** @var Module $object */
399                $object = new $class( $info );
400            }
401            $object->setConfig( $this->getConfig() );
402            $object->setLogger( $this->logger );
403            $object->setHookContainer( $this->hookContainer );
404            $object->setName( $name );
405            $object->setSkinStylesOverride( $this->moduleSkinStyles );
406            $this->modules[$name] = $object;
407        }
408
409        return $this->modules[$name];
410    }
411
412    /**
413     * Load information stored in the database and dependency tracking store about modules
414     *
415     * @param string[] $moduleNames
416     * @param Context $context ResourceLoader-specific context of the request
417     */
418    public function preloadModuleInfo( array $moduleNames, Context $context ) {
419        // Load all tracked indirect file dependencies for the modules
420        $vary = Module::getVary( $context );
421        $entitiesByModule = [];
422        foreach ( $moduleNames as $moduleName ) {
423            $entitiesByModule[$moduleName] = "$moduleName|$vary";
424        }
425        $depsByEntity = $this->depStore->retrieveMulti(
426            $entitiesByModule
427        );
428
429        $modulesWithMessages = [];
430
431        // Inject the indirect file dependencies for all the modules
432        foreach ( $moduleNames as $moduleName ) {
433            $module = $this->getModule( $moduleName );
434            if ( $module ) {
435                $entity = $entitiesByModule[$moduleName];
436                $deps = $depsByEntity[$entity];
437                $paths = $deps['paths'];
438                $module->setFileDependencies( $context, $paths );
439
440                if ( $module->getMessages() ) {
441                    $modulesWithMessages[$moduleName] = $module;
442                }
443            }
444        }
445
446        WikiModule::preloadTitleInfo( $context, $moduleNames );
447
448        // Prime in-object cache for message blobs for modules with messages
449        if ( $modulesWithMessages ) {
450            $lang = $context->getLanguage();
451            $store = $this->getMessageBlobStore();
452            $blobs = $store->getBlobs( $modulesWithMessages, $lang );
453            foreach ( $blobs as $moduleName => $blob ) {
454                $modulesWithMessages[$moduleName]->setMessageBlob( $blob, $lang );
455            }
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     */
778    #[\NoDiscard]
779    protected function measureResponseTime(): ScopedCallback {
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     * @param WebRequest|null $request Null (deprecated since 1.46) falls back to $wgRequest
1477     * @return string[] CSS strings
1478     */
1479    public static function makeCombinedStyles( array $stylePairs, $request = null ) {
1480        if ( $request === null ) {
1481            wfDeprecated( __METHOD__ . ' with null $request', '1.46' );
1482        }
1483        $out = [];
1484        foreach ( $stylePairs as $media => $styles ) {
1485            // FileModule::getStyle can return the styles as a string or an
1486            // array of strings. This is to allow separation in the front-end.
1487            $styles = (array)$styles;
1488            foreach ( $styles as $style ) {
1489                $style = trim( $style );
1490                // Don't output an empty "@media print { }" block (T42498)
1491                if ( $style === '' ) {
1492                    continue;
1493                }
1494                // Transform the media type based on request params and config
1495                // The way that this relies on $wgRequest to propagate request params is slightly evil
1496                $media = OutputPage::transformCssMedia( $media, $request );
1497
1498                if ( $media === '' || $media == 'all' ) {
1499                    $out[] = $style;
1500                } elseif ( is_string( $media ) ) {
1501                    $out[] = "@media $media {\n" . str_replace( "\n", "\n\t", "\t" . $style ) . "}";
1502                }
1503                // else: skip
1504            }
1505        }
1506        return $out;
1507    }
1508
1509    /**
1510     * Format a JS call to mw.loader.state()
1511     *
1512     * @internal For use by StartUpModule
1513     * @param Context $context
1514     * @param array<string,string> $states
1515     * @return string JavaScript code
1516     */
1517    public static function makeLoaderStateScript(
1518        Context $context, array $states
1519    ) {
1520        return 'mw.loader.state('
1521            // Silently ignore invalid UTF-8 injected via 'modules' query
1522            // Don't issue server-side warnings for client errors. (T331641)
1523            // phpcs:ignore Generic.PHP.NoSilencedErrors.Discouraged
1524            . @$context->encodeJson( $states )
1525            . ');';
1526    }
1527
1528    private static function isEmptyObject( stdClass $obj ): bool {
1529        foreach ( $obj as $value ) {
1530            return false;
1531        }
1532        return true;
1533    }
1534
1535    /**
1536     * Remove empty values from the end of an array.
1537     *
1538     * Values considered empty:
1539     *
1540     * - null
1541     * - []
1542     * - new HtmlJsCode( '{}' )
1543     * - new stdClass()
1544     * - (object)[]
1545     */
1546    private static function trimArray( array &$array ): void {
1547        $i = count( $array );
1548        while ( $i-- ) {
1549            if ( $array[$i] === null
1550                || $array[$i] === []
1551                || ( $array[$i] instanceof HtmlJsCode && $array[$i]->value === '{}' )
1552                || ( $array[$i] instanceof stdClass && self::isEmptyObject( $array[$i] ) )
1553            ) {
1554                unset( $array[$i] );
1555            } else {
1556                break;
1557            }
1558        }
1559    }
1560
1561    /**
1562     * Format JS code which calls `mw.loader.register()` with the given parameters.
1563     *
1564     * @par Example
1565     * @code
1566     *
1567     *     ResourceLoader::makeLoaderRegisterScript( $context, [
1568     *        [ $name1, $version1, $dependencies1, $group1, $source1, $skip1 ],
1569     *        [ $name2, $version2, $dependencies1, $group2, $source2, $skip2 ],
1570     *        ...
1571     *     ] ):
1572     * @endcode
1573     *
1574     * @internal For use by StartUpModule only
1575     * @param Context $context
1576     * @param array[] $modules Array of module registration arrays, each containing
1577     *  - string: module name
1578     *  - string: module version
1579     *  - array|null: List of dependencies (optional)
1580     *  - int|null: Module group (optional)
1581     *  - string|null: Name of foreign module source, or 'local' (optional)
1582     *  - string|null: Script body of a skip function (optional)
1583     * @phan-param array<int,array{0:string,1:string,2?:?array,3?:?int,4?:?string,5?:?string}> $modules
1584     * @return string JavaScript code
1585     */
1586    public static function makeLoaderRegisterScript(
1587        Context $context, array $modules
1588    ) {
1589        // Optimisation: Transform dependency names into indexes when possible
1590        // to produce smaller output. They are expanded by mw.loader.register on
1591        // the other end.
1592        $index = [];
1593        foreach ( $modules as $i => $module ) {
1594            // Build module name index
1595            $index[$module[0]] = $i;
1596        }
1597        foreach ( $modules as &$module ) {
1598            if ( isset( $module[2] ) ) {
1599                foreach ( $module[2] as &$dependency ) {
1600                    if ( isset( $index[$dependency] ) ) {
1601                        // Replace module name in dependency list with index
1602                        $dependency = $index[$dependency];
1603                    }
1604                }
1605            }
1606            self::trimArray( $module );
1607        }
1608
1609        return 'mw.loader.register('
1610            . $context->encodeJson( $modules )
1611            . ');';
1612    }
1613
1614    /**
1615     * Format JS code which calls `mw.loader.addSource()` with the given parameters.
1616     *
1617     *   - ResourceLoader::makeLoaderSourcesScript( $context,
1618     *         [ $id1 => $loadUrl, $id2 => $loadUrl, ... ]
1619     *     );
1620     *       Register sources with the given IDs and properties.
1621     *
1622     * @internal For use by StartUpModule only
1623     * @param Context $context
1624     * @param array<string,string> $sources
1625     * @return string JavaScript code
1626     */
1627    public static function makeLoaderSourcesScript(
1628        Context $context, array $sources
1629    ) {
1630        return 'mw.loader.addSource('
1631            . $context->encodeJson( $sources )
1632            . ');';
1633    }
1634
1635    /**
1636     * Wrap JavaScript code to run after the startup module.
1637     *
1638     * @param string $script JavaScript code
1639     * @return string JavaScript code
1640     */
1641    public static function makeLoaderConditionalScript( $script ) {
1642        // Adds a function to lazy-created RLQ
1643        return '(RLQ=window.RLQ||[]).push(function(){' .
1644            trim( $script ) . '});';
1645    }
1646
1647    /**
1648     * Wrap JavaScript code to run after a required module.
1649     *
1650     * @since 1.32
1651     * @param string|string[] $modules Module name(s)
1652     * @param string $script JavaScript code
1653     * @return string JavaScript code
1654     */
1655    public static function makeInlineCodeWithModule( $modules, $script ) {
1656        // Adds an array to lazy-created RLQ
1657        return '(RLQ=window.RLQ||[]).push(['
1658            . json_encode( $modules ) . ','
1659            . 'function(){' . trim( $script ) . '}'
1660            . ']);';
1661    }
1662
1663    /**
1664     * Make an HTML script that runs given JS code after startup and base modules.
1665     *
1666     * The code will be wrapped in a closure, and it will be executed by ResourceLoader's
1667     * startup module if the client has adequate support for MediaWiki JavaScript code.
1668     *
1669     * @param string $script JavaScript code
1670     * @param string|null $nonce Unused
1671     * @return string|WrappedString HTML
1672     */
1673    public static function makeInlineScript( $script, $nonce = null ) {
1674        $js = self::makeLoaderConditionalScript( $script );
1675        return new WrappedString(
1676            Html::inlineScript( $js ),
1677            "<script>(RLQ=window.RLQ||[]).push(function(){",
1678            '});</script>'
1679        );
1680    }
1681
1682    /**
1683     * Convert an array of module names to a packed query string.
1684     *
1685     * For example, `[ 'foo.bar', 'foo.baz', 'bar.baz', 'bar.quux' ]`
1686     * becomes `'foo.bar,baz|bar.baz,quux'`.
1687     *
1688     * This process is reversed by ResourceLoader::expandModuleNames().
1689     * See also mw.loader#buildModulesString() which is a port of this, used
1690     * on the client-side.
1691     *
1692     * @param string[] $modules List of module names (strings)
1693     * @return string Packed query string
1694     */
1695    public static function makePackedModulesString( array $modules ) {
1696        $moduleMap = []; // [ prefix => [ suffixes ] ]
1697        foreach ( $modules as $module ) {
1698            $pos = strrpos( $module, '.' );
1699            $prefix = $pos === false ? '' : substr( $module, 0, $pos );
1700            $suffix = $pos === false ? $module : substr( $module, $pos + 1 );
1701            $moduleMap[$prefix][] = $suffix;
1702        }
1703
1704        $arr = [];
1705        foreach ( $moduleMap as $prefix => $suffixes ) {
1706            $p = $prefix === '' ? '' : $prefix . '.';
1707            $arr[] = $p . implode( ',', $suffixes );
1708        }
1709        return implode( '|', $arr );
1710    }
1711
1712    /**
1713     * Expand a string of the form `jquery.foo,bar|jquery.ui.baz,quux` to
1714     * an array of module names like `[ 'jquery.foo', 'jquery.bar',
1715     * 'jquery.ui.baz', 'jquery.ui.quux' ]`.
1716     *
1717     * This process is reversed by ResourceLoader::makePackedModulesString().
1718     *
1719     * @since 1.33
1720     * @param string $modules Packed module name list
1721     * @return string[] Array of module names
1722     */
1723    public static function expandModuleNames( $modules ) {
1724        $retval = [];
1725        $exploded = explode( '|', $modules );
1726        foreach ( $exploded as $group ) {
1727            if ( !str_contains( $group, ',' ) ) {
1728                // This is not a set of modules in foo.bar,baz notation
1729                // but a single module
1730                $retval[] = $group;
1731                continue;
1732            }
1733            // This is a set of modules in foo.bar,baz notation
1734            $pos = strrpos( $group, '.' );
1735            if ( $pos === false ) {
1736                // Prefixless modules, i.e. without dots
1737                $retval = array_merge( $retval, explode( ',', $group ) );
1738                continue;
1739            }
1740            // We have a prefix and a bunch of suffixes
1741            $prefix = substr( $group, 0, $pos ); // 'foo'
1742            $suffixes = explode( ',', substr( $group, $pos + 1 ) ); // [ 'bar', 'baz' ]
1743            foreach ( $suffixes as $suffix ) {
1744                $retval[] = "$prefix.$suffix";
1745            }
1746        }
1747        return $retval;
1748    }
1749
1750    /**
1751     * Determine whether debug mode is on.
1752     *
1753     * Order of priority is:
1754     * - 1) Request parameter,
1755     * - 2) Cookie,
1756     * - 3) Site configuration.
1757     *
1758     * @deprecated since 1.47
1759     * @return int
1760     */
1761    public static function inDebugMode() {
1762        wfDeprecated( __METHOD__, '1.47' );
1763        if ( self::$debugMode === null ) {
1764            $resourceLoaderDebug = MediaWikiServices::getInstance()->getMainConfig()->get(
1765                MainConfigNames::ResourceLoaderDebug );
1766            $request = RequestContext::getMain()->getRequest();
1767            $str = $request->getRawVal( 'debug' ) ??
1768                $request->getCookie( 'resourceLoaderDebug', '', $resourceLoaderDebug ? 'true' : '' );
1769            self::$debugMode = Context::debugFromString( $str );
1770        }
1771        return self::$debugMode;
1772    }
1773
1774    /**
1775     * Reset static members used for caching.
1776     *
1777     * Global state and $wgRequest are evil, but we're using it right
1778     * now and sometimes we need to be able to force ResourceLoader to
1779     * re-evaluate the context because it has changed (e.g. in the test suite).
1780     *
1781     * @internal For use by unit tests
1782     * @codeCoverageIgnore
1783     */
1784    public static function clearCache() {
1785        self::$debugMode = null;
1786    }
1787
1788    /**
1789     * Build a load.php URL
1790     *
1791     * @since 1.24
1792     * @param string $source Name of the ResourceLoader source
1793     * @param Context $context
1794     * @param array $extraQuery
1795     * @return string URL to load.php. May be protocol-relative if $wgLoadScript is, too.
1796     */
1797    public function createLoaderURL( $source, Context $context,
1798        array $extraQuery = []
1799    ) {
1800        $query = self::createLoaderQuery( $context, $extraQuery );
1801        $script = $this->getLoadScript( $source );
1802
1803        return wfAppendQuery( $script, $query );
1804    }
1805
1806    /**
1807     * Helper for createLoaderURL()
1808     *
1809     * @since 1.24
1810     * @see makeLoaderQuery
1811     * @param Context $context
1812     * @param array $extraQuery
1813     * @return array
1814     */
1815    protected static function createLoaderQuery(
1816        Context $context, array $extraQuery = []
1817    ) {
1818        return self::makeLoaderQuery(
1819            $context->getModules(),
1820            $context->getLanguage(),
1821            $context->getSkin(),
1822            $context->getUser(),
1823            $context->getVersion(),
1824            $context->getDebug(),
1825            $context->getOnly(),
1826            $context->getRequest()->getBool( 'printable' ),
1827            null,
1828            $extraQuery
1829        );
1830    }
1831
1832    /**
1833     * Build a query array (array representation of query string) for load.php. Helper
1834     * function for createLoaderURL().
1835     *
1836     * @param string[] $modules
1837     * @param string $lang
1838     * @param string $skin
1839     * @param string|null $user
1840     * @param string|null $version
1841     * @param int $debug
1842     * @param string|null $only
1843     * @param bool $printable
1844     * @param bool|null $handheld Unused as of MW 1.38
1845     * @param array $extraQuery
1846     * @return array
1847     */
1848    public static function makeLoaderQuery( array $modules, $lang, $skin, $user = null,
1849        $version = null, $debug = Context::DEBUG_OFF, $only = null,
1850        $printable = false, $handheld = null, array $extraQuery = []
1851    ) {
1852        $query = [
1853            'modules' => self::makePackedModulesString( $modules ),
1854        ];
1855        // Keep urls short by omitting query parameters that
1856        // match the defaults assumed by Context.
1857        // Note: This relies on the defaults either being insignificant or forever constant,
1858        // as otherwise cached urls could change in meaning when the defaults change.
1859        if ( $lang !== Context::DEFAULT_LANG ) {
1860            $query['lang'] = $lang;
1861        }
1862        if ( $skin !== Context::DEFAULT_SKIN ) {
1863            $query['skin'] = $skin;
1864        }
1865        if ( $debug !== Context::DEBUG_OFF ) {
1866            $query['debug'] = strval( $debug );
1867        }
1868        if ( $user !== null ) {
1869            $query['user'] = $user;
1870        }
1871        if ( $version !== null ) {
1872            $query['version'] = $version;
1873        }
1874        if ( $only !== null ) {
1875            $query['only'] = $only;
1876        }
1877        if ( $printable ) {
1878            $query['printable'] = 1;
1879        }
1880        foreach ( $extraQuery as $name => $value ) {
1881            $query[$name] = $value;
1882        }
1883
1884        // Make queries uniform in order
1885        ksort( $query );
1886        return $query;
1887    }
1888
1889    /**
1890     * Check a module name for validity.
1891     *
1892     * Module names may not contain pipes (|), commas (,) or exclamation marks (!) and can be
1893     * at most 255 bytes.
1894     *
1895     * @param string $moduleName Module name to check
1896     * @return bool Whether $moduleName is a valid module name
1897     */
1898    public static function isValidModuleName( $moduleName ) {
1899        $len = strlen( $moduleName );
1900        return ( $len <= 255
1901            && strcspn( $moduleName, '!,|', 0, $len ) === $len )
1902            && ( !str_starts_with( $moduleName, "./" ) && !str_starts_with( $moduleName, "../" ) );
1903    }
1904
1905    /**
1906     * Return a LESS compiler that is set up for use with MediaWiki.
1907     *
1908     * @since 1.27
1909     * @param array $vars Associative array of variables that should be used
1910     *  for compilation. Since 1.32, this method no longer automatically includes
1911     *  global LESS vars from ResourceLoader::getLessVars (T191937).
1912     * @param array $importDirs Additional directories to look in for @import (since 1.36)
1913     * @return Less_Parser
1914     */
1915    public function getLessCompiler( array $vars = [], array $importDirs = [] ) {
1916        // When called from the installer, it is possible that a required PHP extension
1917        // is missing (at least for now; see T49564). If this is the case, throw an
1918        // exception (caught by the installer) to prevent a fatal error later on.
1919        if ( !class_exists( Less_Parser::class ) ) {
1920            throw new RuntimeException( 'MediaWiki requires the less.php parser' );
1921        }
1922
1923        $importDirs[] = MW_INSTALL_PATH . '/resources/src/mediawiki.less';
1924
1925        $parser = new Less_Parser;
1926        $parser->ModifyVars( $vars );
1927        $parser->SetOption( 'relativeUrls', false );
1928        $parser->SetOption( 'math', 'parens-division' );
1929
1930        // SetImportDirs expects an array like [ 'path1' => '', 'path2' => '' ]
1931        $formattedImportDirs = array_fill_keys( $importDirs, '' );
1932
1933        // Add a callback to the import dirs array for path remapping
1934        $codexDevDir = $this->getConfig()->get( MainConfigNames::CodexDevelopmentDir );
1935        $formattedImportDirs[] = static function ( $path ) use ( $codexDevDir ) {
1936            // For each of the Codex import paths, use CodexDevelopmentDir if it's set
1937            $importMap = [
1938                '@wikimedia/codex-icons/' => $codexDevDir !== null ?
1939                    "$codexDevDir/packages/codex-icons/dist/" :
1940                    MW_INSTALL_PATH . '/resources/lib/codex-icons/',
1941                'mediawiki.skin.codex/' => $codexDevDir !== null ?
1942                    "$codexDevDir/packages/codex/dist/" :
1943                    MW_INSTALL_PATH . '/resources/lib/codex/',
1944                'mediawiki.skin.codex-design-tokens/' => $codexDevDir !== null ?
1945                    "$codexDevDir/packages/codex-design-tokens/dist/" :
1946                    MW_INSTALL_PATH . '/resources/lib/codex-design-tokens/',
1947                '@wikimedia/codex-design-tokens/' => static function ( $unused_path ): never {
1948                    throw new RuntimeException(
1949                        'Importing from @wikimedia/codex-design-tokens is not supported. ' .
1950                        "To use the Codex tokens, use `@import 'mediawiki.skin.variables.less';` instead."
1951                    );
1952                }
1953            ];
1954            foreach ( $importMap as $importPath => $substPath ) {
1955                if ( str_starts_with( $path, $importPath ) ) {
1956                    $restOfPath = substr( $path, strlen( $importPath ) );
1957                    if ( is_callable( $substPath ) ) {
1958                        // @phan-suppress-next-line PhanUseReturnValueOfNever
1959                        $resolvedPath = $substPath( $restOfPath );
1960                    } else {
1961                        $filePath = $substPath . $restOfPath;
1962
1963                        $resolvedPath = null;
1964                        if ( file_exists( $filePath ) ) {
1965                            $resolvedPath = $filePath;
1966                        } elseif ( file_exists( "$filePath.less" ) ) {
1967                            $resolvedPath = "$filePath.less";
1968                        }
1969                    }
1970
1971                    if ( $resolvedPath !== null ) {
1972                        return [
1973                            Less_Environment::normalizePath( $resolvedPath ),
1974                            Less_Environment::normalizePath( dirname( $path ) )
1975                        ];
1976                    } else {
1977                        break;
1978                    }
1979                }
1980            }
1981            return [ null, null ];
1982        };
1983        $parser->SetImportDirs( $formattedImportDirs );
1984
1985        return $parser;
1986    }
1987
1988    /**
1989     * Run JavaScript or CSS data through a filter, caching the filtered result for future calls.
1990     *
1991     * Available filters are:
1992     *
1993     *    - minify-js
1994     *    - minify-css
1995     *
1996     * If $data is empty, only contains whitespace or the filter was unknown,
1997     * $data is returned unmodified.
1998     *
1999     * @param string $filter Name of filter to run
2000     * @param string $data Text to filter, such as JavaScript or CSS text
2001     * @param array<string,bool> $options Keys:
2002     *  - (bool) cache: Whether to allow caching this data. Default: true.
2003     * @return string Filtered data or unfiltered data
2004     */
2005    public static function filter( $filter, $data, array $options = [] ) {
2006        if ( isset( $options['cache'] ) && $options['cache'] === false ) {
2007            return self::applyFilter( $filter, $data ) ?? $data;
2008        }
2009
2010        $statsFactory = MediaWikiServices::getInstance()->getStatsFactory();
2011        // Same as ResourceLoader->srvCache
2012        $cache = MediaWikiServices::getInstance()->getLocalServerObjectCache();
2013
2014        $key = $cache->makeGlobalKey(
2015            'resourceloader-filter',
2016            $filter,
2017            self::CACHE_VERSION,
2018            md5( $data )
2019        );
2020
2021        $status = 'hit';
2022        $result = $cache->getWithSetCallback(
2023            $key,
2024            BagOStuff::TTL_DAY,
2025            static function () use ( $filter, $data, &$status ) {
2026                $status = 'miss';
2027                return self::applyFilter( $filter, $data );
2028            }
2029        );
2030        $statsFactory->getCounter( 'resourceloader_cache_total' )
2031            ->setLabel( 'type', $filter )
2032            ->setLabel( 'status', $status )
2033            ->increment();
2034
2035        // Use $data on cache failure
2036        return $result ?? $data;
2037    }
2038
2039    /**
2040     * @param string $filter
2041     * @param string $data
2042     * @return string|null
2043     */
2044    private static function applyFilter( $filter, $data ) {
2045        $data = trim( $data );
2046        if ( $data ) {
2047            try {
2048                $data = ( $filter === 'minify-css' )
2049                    ? CSSMin::minify( $data )
2050                    : JavaScriptMinifier::minify( $data );
2051            } catch ( TimeoutException $e ) {
2052                throw $e;
2053            } catch ( Exception $e ) {
2054                MWExceptionHandler::logException( $e );
2055                return null;
2056            }
2057        }
2058        return $data;
2059    }
2060
2061    /**
2062     * Get user default options to expose to JavaScript on all pages via `mw.user.options`.
2063     *
2064     * @internal Exposed for use from Resources.php
2065     *
2066     * @param Context $context
2067     * @param HookContainer $hookContainer
2068     * @param UserOptionsLookup $userOptionsLookup
2069     *
2070     * @return array
2071     */
2072    public static function getUserDefaults(
2073        Context $context,
2074        HookContainer $hookContainer,
2075        UserOptionsLookup $userOptionsLookup
2076    ): array {
2077        $defaultOptions = $userOptionsLookup->getDefaultOptions();
2078        $keysToExclude = [];
2079        $hookRunner = new HookRunner( $hookContainer );
2080        $hookRunner->onResourceLoaderExcludeUserOptions( $keysToExclude, $context );
2081        foreach ( $keysToExclude as $excludedKey ) {
2082            unset( $defaultOptions[ $excludedKey ] );
2083        }
2084        return $defaultOptions;
2085    }
2086
2087    /**
2088     * Get site configuration settings to expose to JavaScript on all pages via `mw.config`.
2089     *
2090     * @internal Exposed for use from Resources.php
2091     * @param Context $context
2092     * @param Config $conf
2093     * @return array
2094     */
2095    public static function getSiteConfigSettings(
2096        Context $context, Config $conf
2097    ): array {
2098        $services = MediaWikiServices::getInstance();
2099        // Namespace related preparation
2100        // - wgNamespaceIds: Key-value pairs of all localized, canonical and aliases for namespaces.
2101        // - wgCaseSensitiveNamespaces: Array of namespaces that are case-sensitive.
2102        $contLang = $services->getContentLanguage();
2103        $namespaceIds = $contLang->getNamespaceIds();
2104        $caseSensitiveNamespaces = [];
2105        $nsInfo = $services->getNamespaceInfo();
2106        foreach ( $nsInfo->getCanonicalNamespaces() as $index => $name ) {
2107            $namespaceIds[$contLang->lc( $name )] = $index;
2108            if ( !$nsInfo->isCapitalized( $index ) ) {
2109                $caseSensitiveNamespaces[] = $index;
2110            }
2111        }
2112
2113        $illegalFileChars = $conf->get( MainConfigNames::IllegalFileChars );
2114
2115        // Build list of variables
2116        $skin = $context->getSkin();
2117
2118        // Start of supported and stable config vars (for use by extensions/gadgets).
2119        $vars = [
2120            'debug' => $context->getDebug(),
2121            'skin' => $skin,
2122            'stylepath' => $conf->get( MainConfigNames::StylePath ),
2123            'wgArticlePath' => $conf->get( MainConfigNames::ArticlePath ),
2124            'wgScriptPath' => $conf->get( MainConfigNames::ScriptPath ),
2125            'wgScript' => $conf->get( MainConfigNames::Script ),
2126            'wgSearchType' => $conf->get( MainConfigNames::SearchType ),
2127            'wgVariantArticlePath' => $conf->get( MainConfigNames::VariantArticlePath ),
2128            'wgServer' => $conf->get( MainConfigNames::Server ),
2129            'wgServerName' => $conf->get( MainConfigNames::ServerName ),
2130            'wgUserLanguage' => $context->getLanguage(),
2131            'wgContentLanguage' => $contLang->getCode(),
2132            'wgVersion' => MW_VERSION,
2133            'wgFormattedNamespaces' => $contLang->getFormattedNamespaces(),
2134            'wgNamespaceIds' => $namespaceIds,
2135            'wgContentNamespaces' => $nsInfo->getContentNamespaces(),
2136            'wgSiteName' => $conf->get( MainConfigNames::Sitename ),
2137            'wgDBname' => $conf->get( MainConfigNames::DBname ),
2138            'wgWikiID' => WikiMap::getCurrentWikiId(),
2139            'wgCaseSensitiveNamespaces' => $caseSensitiveNamespaces,
2140            'wgCommentCodePointLimit' => CommentStore::COMMENT_CHARACTER_LIMIT,
2141            'wgExtensionAssetsPath' => $conf->get( MainConfigNames::ExtensionAssetsPath ),
2142        ];
2143        // End of stable config vars.
2144
2145        // Internal variables for use by MediaWiki core and/or ResourceLoader.
2146        $vars += [
2147            // @internal For mediawiki.widgets
2148            'wgUrlProtocols' => $services->getUrlUtils()->validProtocols(),
2149            // @internal For mediawiki.page.watch
2150            // Force object to avoid "empty" associative array from
2151            // becoming [] instead of {} in JS (T36604)
2152            'wgActionPaths' => (object)$conf->get( MainConfigNames::ActionPaths ),
2153            // @internal For mediawiki.language
2154            'wgTranslateNumerals' => $conf->get( MainConfigNames::TranslateNumerals ),
2155            // @internal For mediawiki.Title
2156            'wgExtraSignatureNamespaces' => $conf->get( MainConfigNames::ExtraSignatureNamespaces ),
2157            'wgLegalTitleChars' => Title::convertByteClassToUnicodeClass( Title::legalChars() ),
2158            'wgIllegalFileChars' => Title::convertByteClassToUnicodeClass( $illegalFileChars ),
2159        ];
2160
2161        ( new HookRunner( $services->getHookContainer() ) )
2162            ->onResourceLoaderGetConfigVars( $vars, $skin, $conf );
2163
2164        return $vars;
2165    }
2166
2167    /**
2168     * @internal For testing
2169     * @return array
2170     */
2171    public function getErrors() {
2172        return $this->errors;
2173    }
2174}