Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
79.82% covered (warning)
79.82%
605 / 758
60.32% covered (warning)
60.32%
38 / 63
CRAP
0.00% covered (danger)
0.00%
0 / 1
ResourceLoader
79.82% covered (warning)
79.82%
605 / 758
60.32% covered (warning)
60.32%
38 / 63
831.82
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
84.13% covered (warning)
84.13%
53 / 63
0.00% covered (danger)
0.00%
0 / 1
28.70
 measureResponseTime
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
1
 sendResponseHeaders
64.52% covered (warning)
64.52%
20 / 31
0.00% covered (danger)
0.00%
0 / 1
18.43
 tryRespondNotModified
42.86% covered (danger)
42.86%
3 / 7
0.00% covered (danger)
0.00%
0 / 1
6.99
 getSourceMapUrl
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
1
 sendSourceMapVersionMismatch
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
2
 sendSourceMapTypeNotImplemented
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
2
 makeComment
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 formatExceptionNoComment
85.71% covered (warning)
85.71%
6 / 7
0.00% covered (danger)
0.00%
0 / 1
2.01
 makeModuleResponse
86.96% covered (warning)
86.96%
40 / 46
0.00% covered (danger)
0.00%
0 / 1
23.07
 getOneModuleResponse
98.28% covered (success)
98.28%
57 / 58
0.00% covered (danger)
0.00%
0 / 1
11
 addOneModuleResponse
68.09% covered (warning)
68.09%
32 / 47
0.00% covered (danger)
0.00%
0 / 1
22.31
 ensureNewline
75.00% covered (warning)
75.00%
3 / 4
0.00% covered (danger)
0.00%
0 / 1
3.14
 getModulesByMessage
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
12
 addImplementScript
94.12% covered (success)
94.12%
32 / 34
0.00% covered (danger)
0.00%
0 / 1
9.02
 addFiles
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
3
 addFileContent
100.00% covered (success)
100.00%
19 / 19
100.00% covered (success)
100.00%
1 / 1
7
 concatenatePlainScripts
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
6
 addPlainScripts
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
2
 isEmptyFileInfos
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 makeCombinedStyles
84.62% covered (warning)
84.62%
11 / 13
0.00% covered (danger)
0.00%
0 / 1
7.18
 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        // Use an alternate E-Tag so that HTTP caches self-correct after an error (T431583).
748        //
749        // Usually when a module is broken, ResourceLoader sends a partial response with the rest
750        // of the batch. The mw.loader client isolates dependency trees such that errors often go unnoticed.
751        // The combined version hash skips broken modules and so the future response with the fixed
752        // module naturally has different E-Tag and the cache self-corrects. But, if
753        // Module::getVersionHash suceeeds and only Module::getVersionHash fails, then the error
754        // response could be renewed via HTTP 304 after the error is fixed. This prevents that.
755        if ( $this->errors ) {
756            $etag = 'W/"' . $versionHash . '_with_errors"';
757        }
758
759        $this->sendResponseHeaders( $context, $etag, (bool)$this->errors );
760
761        // Remove the output buffer and output the response
762        ob_end_clean();
763
764        if ( $context->getImageObj() && $this->errors ) {
765            // We can't show both the error messages and the response when it's an image.
766            $response = implode( "\n\n", $this->errors );
767        } elseif ( $this->errors ) {
768            $errorText = implode( "\n\n", $this->errors );
769            $errorResponse = self::makeComment( $errorText );
770            if ( $context->shouldIncludeScripts() ) {
771                $errorResponse .= 'if (window.console && console.error) { console.error('
772                    . $context->encodeJson( $errorText )
773                    . "); }\n";
774                // Append the error info to the response
775                // We used to prepend it, but that would corrupt the source map
776                $response .= $errorResponse;
777            } else {
778                // For styles we can still prepend
779                $response = $errorResponse . $response;
780            }
781        }
782
783        // @phan-suppress-next-line SecurityCheck-XSS
784        echo $response;
785    }
786
787    /**
788     * Send stats about the time used to build the response
789     */
790    #[\NoDiscard]
791    protected function measureResponseTime(): ScopedCallback {
792        $requestStart = $_SERVER['REQUEST_TIME_FLOAT'];
793        return new ScopedCallback( function () use ( $requestStart ) {
794            $statTiming = microtime( true ) - $requestStart;
795
796            $this->statsFactory->getTiming( 'resourceloader_response_time_seconds' )
797                ->observe( 1000 * $statTiming );
798        } );
799    }
800
801    /**
802     * Send main response headers to the client.
803     *
804     * Deals with Content-Type, CORS (for stylesheets), and caching.
805     *
806     * @param Context $context
807     * @param string $etag ETag header value
808     * @param bool $errors Whether there are errors in the response
809     */
810    protected function sendResponseHeaders(
811        Context $context, $etag, $errors
812    ): void {
813        HeaderCallback::warnIfHeadersSent();
814
815        if ( $errors ) {
816            $maxage = self::MAXAGE_RECOVER;
817        } elseif (
818            $context->getVersion() !== null
819            && $context->getVersion() !== $this->makeVersionQuery( $context, $context->getModules() )
820        ) {
821            // If we need to self-correct, set a very short cache expiry
822            // to basically just debounce CDN traffic. This applies to:
823            // - Internal errors, e.g. due to misconfiguration.
824            // - Version mismatch, e.g. due to deployment race (T117587, T47877).
825            $this->logger->debug( 'Client and server registry version out of sync' );
826            $maxage = self::MAXAGE_RECOVER;
827        } elseif ( $context->getVersion() === null ) {
828            // Resources that can't set a version, should have their updates propagate to
829            // clients quickly. This applies to shared resources linked from HTML, such as
830            // the startup module and stylesheets.
831            $maxage = $this->maxageUnversioned;
832        } else {
833            // When a version is set, use a long expiry because changes
834            // will naturally miss the cache by using a different URL.
835            $maxage = $this->maxageVersioned;
836        }
837        if ( $context->getImageObj() ) {
838            // Output different headers if we're outputting textual errors.
839            if ( $errors ) {
840                header( 'Content-Type: text/plain; charset=utf-8' );
841            } else {
842                $context->getImageObj()->sendResponseHeaders( $context );
843            }
844        } elseif ( $context->isSourceMap() ) {
845            header( 'Content-Type: application/json' );
846        } elseif ( $context->getOnly() === 'styles' ) {
847            header( 'Content-Type: text/css; charset=utf-8' );
848            header( 'Access-Control-Allow-Origin: *' );
849        } else {
850            header( 'Content-Type: text/javascript; charset=utf-8' );
851        }
852        // See RFC 2616 Â§ 14.19 ETag
853        // https://www.w3.org/Protocols/rfc2616/rfc2616-sec14.html#sec14.19
854        header( 'ETag: ' . $etag );
855        if ( $context->getDebug() ) {
856            // Do not cache debug responses
857            header( 'Cache-Control: private, no-cache, must-revalidate' );
858        } else {
859            // T132418: When a resource expires mid-way a browsing session, prefer to renew it in
860            // the background instead of blocking the next page load (eg. startup module, or CSS).
861            $staleDirective = ( $maxage > self::MAXAGE_RECOVER
862                ? ", stale-while-revalidate=" . min( 60, intval( $maxage / 2 ) )
863                : ''
864            );
865            header( "Cache-Control: public, max-age=$maxage, s-maxage=$maxage" . $staleDirective );
866            header( 'Expires: ' . ConvertibleTimestamp::convert( TS::RFC2822, time() + $maxage ) );
867        }
868
869        foreach ( $this->extraHeaders as $header ) {
870            header( $header );
871        }
872    }
873
874    /**
875     * Respond with HTTP 304 Not Modified if appropriate.
876     *
877     * If there's an If-None-Match header, respond with a 304 appropriately
878     * and clear out the output buffer. If the client cache is too old then do nothing.
879     *
880     * @param Context $context
881     * @param string $etag ETag header value
882     * @return bool True if HTTP 304 was sent and output handled
883     */
884    protected function tryRespondNotModified( Context $context, $etag ) {
885        // See RFC 2616 Â§ 14.26 If-None-Match
886        // https://www.w3.org/Protocols/rfc2616/rfc2616-sec14.html#sec14.26
887        $clientKeys = $context->getRequest()->getHeader( 'If-None-Match', WebRequest::GETHEADER_LIST );
888        // Never send 304s in debug mode
889        if ( $clientKeys !== false && !$context->getDebug() && in_array( $etag, $clientKeys ) ) {
890            // There's another bug in ob_gzhandler (see also the comment at
891            // the top of this function) that causes it to gzip even empty
892            // responses, meaning it's impossible to produce a truly empty
893            // response (because the gzip header is always there). This is
894            // a problem because 304 responses have to be completely empty
895            // per the HTTP spec, and Firefox behaves buggily when they're not.
896            // See also https://bugs.php.net/bug.php?id=51579
897            // To work around this, we tear down all output buffering before
898            // sending the 304.
899            wfResetOutputBuffers( /* $resetGzipEncoding = */ true );
900
901            HttpStatus::header( 304 );
902            $this->sendResponseHeaders( $context, $etag, false );
903            return true;
904        }
905        return false;
906    }
907
908    /**
909     * Get the URL which will deliver the source map for the current response.
910     *
911     * @param Context $context
912     * @param string $version The combined version hash
913     * @return string
914     */
915    private function getSourceMapUrl( Context $context, $version ) {
916        return $this->createLoaderURL( 'local', $context, [
917            'sourcemap' => '1',
918            'version' => $version
919        ] );
920    }
921
922    /**
923     * Send an error page for a source map version mismatch
924     *
925     * @param string $currentVersion
926     */
927    private function sendSourceMapVersionMismatch( $currentVersion ) {
928        HttpStatus::header( 404 );
929        header( 'Content-Type: text/plain; charset=utf-8' );
930        header( 'X-Content-Type-Options: nosniff' );
931        echo "Can't deliver a source map for the requested version " .
932            "since the version is now '$currentVersion'\n";
933    }
934
935    /**
936     * Send an error page when a source map is requested but there is no
937     * support for the specified content type
938     */
939    private function sendSourceMapTypeNotImplemented() {
940        HttpStatus::header( 404 );
941        header( 'Content-Type: text/plain; charset=utf-8' );
942        header( 'X-Content-Type-Options: nosniff' );
943        echo "Can't make a source map for this content type\n";
944    }
945
946    /**
947     * Generate a CSS or JS comment block.
948     *
949     * Only use this for public data, not error message details.
950     *
951     * @param string $text
952     * @return string
953     */
954    public static function makeComment( $text ) {
955        $encText = str_replace( '*/', '* /', $text );
956        return "/*\n$encText\n*/\n";
957    }
958
959    /**
960     * Handle exception display.
961     *
962     * @since 1.25
963     * @param Throwable $e Exception to be shown to the user
964     * @return string Sanitized text for a CSS/JS comment that can be returned to the user
965     */
966    protected static function formatExceptionNoComment( Throwable $e ) {
967        if ( !MWExceptionRenderer::shouldShowExceptionDetails() ) {
968            return MWExceptionHandler::getPublicLogMessage( $e );
969        }
970
971        // Like MWExceptionHandler::getLogMessage but without $url and $id.
972        // - Long load.php URL would push the actual error message off-screen into
973        //   scroll overflow in browser devtools.
974        // - reqId is redundant with X-Request-Id header, plus usually no need to
975        //   correlate the reqId since the backtrace is already included below.
976        $type = get_class( $e );
977        $message = $e->getMessage();
978
979        return "$type$message" .
980            "\nBacktrace:\n" .
981            MWExceptionHandler::getRedactedTraceAsString( $e );
982    }
983
984    /**
985     * Generate code for a response.
986     *
987     * Calling this method also populates the `errors` and `headers` members,
988     * later used by respond().
989     *
990     * @param Context $context Context in which to generate a response
991     * @param Module[] $modules List of module objects keyed by module name
992     * @param string[] $missing List of requested module names that are unregistered (optional)
993     * @return string Response data
994     */
995    public function makeModuleResponse( Context $context,
996        array $modules, array $missing = []
997    ) {
998        if ( $modules === [] && $missing === [] ) {
999            return <<<MESSAGE
1000/* This file is the Web entry point for MediaWiki's ResourceLoader:
1001   <https://www.mediawiki.org/wiki/ResourceLoader>. In this request,
1002   no modules were requested. Max made me put this here. */
1003MESSAGE;
1004        }
1005
1006        $image = $context->getImageObj();
1007        if ( $image ) {
1008            $data = $image->getImageData( $context );
1009            if ( $data === false ) {
1010                $data = '';
1011                $this->errors[] = 'Image generation failed';
1012            }
1013            return $data;
1014        }
1015
1016        $states = [];
1017        foreach ( $missing as $name ) {
1018            $states[$name] = 'missing';
1019        }
1020
1021        $only = $context->getOnly();
1022        $debug = (bool)$context->getDebug();
1023        if ( $context->isSourceMap() && count( $modules ) > 1 ) {
1024            $indexMap = new IndexMap;
1025        } else {
1026            $indexMap = null;
1027        }
1028
1029        $out = '';
1030        foreach ( $modules as $name => $module ) {
1031            try {
1032                [ $response, $offset ] = $this->getOneModuleResponse( $context, $name, $module );
1033                if ( $indexMap ) {
1034                    $indexMap->addEncodedMap( $response, $offset );
1035                } else {
1036                    $out .= $response;
1037                }
1038            } catch ( TimeoutException $e ) {
1039                throw $e;
1040            } catch ( Exception $e ) {
1041                $this->outputErrorAndLog( $e, 'Generating module package failed: {exception}' );
1042
1043                // Respond to client with error-state instead of module implementation
1044                $states[$name] = 'error';
1045                unset( $modules[$name] );
1046            }
1047        }
1048
1049        // Update module states
1050        if ( $context->shouldIncludeScripts() && !$context->getRaw() ) {
1051            if ( $modules && $only === 'scripts' ) {
1052                // Set the state of modules loaded as only scripts to ready as
1053                // they don't have an mw.loader.impl wrapper that sets the state
1054                foreach ( $modules as $name => $module ) {
1055                    $states[$name] = 'ready';
1056                }
1057            }
1058
1059            // Set the state of modules we didn't respond to with mw.loader.impl
1060            if ( $states && !$context->isSourceMap() ) {
1061                $stateScript = self::makeLoaderStateScript( $context, $states );
1062                if ( !$debug ) {
1063                    $stateScript = self::filter( 'minify-js', $stateScript );
1064                }
1065                // Use a linebreak between module script and state script (T162719)
1066                $out = self::ensureNewline( $out ) . $stateScript;
1067            }
1068        } elseif ( $states ) {
1069            $this->errors[] = 'Problematic modules: '
1070                // Silently ignore invalid UTF-8 injected via 'modules' query
1071                // Don't issue server-side warnings for client errors. (T331641)
1072                // phpcs:ignore Generic.PHP.NoSilencedErrors.Discouraged
1073                . @$context->encodeJson( $states );
1074        }
1075
1076        if ( $indexMap ) {
1077            return $indexMap->getMap();
1078        }
1079        return $out;
1080    }
1081
1082    /**
1083     * Get the response of a single module
1084     *
1085     * @param Context $context
1086     * @param string $name
1087     * @param Module $module
1088     * @return array{string,IndexMapOffset|null}
1089     */
1090    private function getOneModuleResponse( Context $context, $name, Module $module ) {
1091        $only = $context->getOnly();
1092        // Important: Do not cache minifications of embedded modules
1093        // This is especially for the private 'user.options' module,
1094        // which varies on every pageview and would explode the cache (T84960)
1095        $shouldCache = !$module->shouldEmbedModule( $context );
1096        if ( $only === 'styles' ) {
1097            $minifier = new IdentityMinifierState;
1098            $this->addOneModuleResponse( $context, $minifier, $name, $module, $this->extraHeaders );
1099            // NOTE: This is not actually "minified". IdentityMinifierState is a no-op wrapper
1100            // to ease code reuse. The filter() call below performs CSS minification.
1101            $styles = $minifier->getMinifiedOutput();
1102            if ( $context->getDebug() ) {
1103                return [ $styles, null ];
1104            }
1105            return [
1106                self::filter( 'minify-css', $styles,
1107                    [ 'cache' => $shouldCache ] ),
1108                null
1109            ];
1110        }
1111
1112        $replayMinifier = new ReplayMinifierState;
1113        $this->addOneModuleResponse( $context, $replayMinifier, $name, $module, $this->extraHeaders );
1114
1115        $minifier = new IdentityMinifierState;
1116        $replayMinifier->replayOn( $minifier );
1117        $plainContent = $minifier->getMinifiedOutput();
1118        if ( $context->getDebug() ) {
1119            return [ $plainContent, null ];
1120        }
1121
1122        $isHit = true;
1123        $callback = function () use ( $context, $replayMinifier, &$isHit ) {
1124            $isHit = false;
1125            if ( $context->isSourceMap() ) {
1126                $minifier = ( new JavaScriptMapperState )
1127                    ->outputFile( $this->createLoaderURL( 'local', $context, [
1128                        'modules' => self::makePackedModulesString( $context->getModules() ),
1129                        'only' => $context->getOnly()
1130                    ] ) );
1131            } else {
1132                $minifier = new JavaScriptMinifierState;
1133            }
1134            $replayMinifier->replayOn( $minifier );
1135            if ( $context->isSourceMap() ) {
1136                $sourceMap = $minifier->getRawSourceMap();
1137                $generated = $minifier->getMinifiedOutput();
1138                $offset = IndexMapOffset::newFromText( $generated );
1139                return [ $sourceMap, $offset->toArray() ];
1140            } else {
1141                return [ $minifier->getMinifiedOutput(), null ];
1142            }
1143        };
1144
1145        // The below is based on ResourceLoader::filter. Keep together to ease review/maintenance:
1146        // * Handle $shouldCache, skip cache and minify directly if set.
1147        // * Use minify cache, minify on-demand and populate cache as needed.
1148        // * Emit resourceloader_cache_total stats.
1149
1150        if ( $shouldCache ) {
1151            [ $response, $offsetArray ] = $this->srvCache->getWithSetCallback(
1152                $this->srvCache->makeGlobalKey(
1153                    'resourceloader-mapped',
1154                    self::CACHE_VERSION,
1155                    $name,
1156                    $context->isSourceMap() ? '1' : '0',
1157                    md5( $plainContent )
1158                ),
1159                BagOStuff::TTL_DAY,
1160                $callback
1161            );
1162
1163            $mapType = $context->isSourceMap() ? 'map-js' : 'minify-js';
1164            $this->statsFactory->getCounter( 'resourceloader_cache_total' )
1165                ->setLabel( 'type', $mapType )
1166                ->setLabel( 'status', $isHit ? 'hit' : 'miss' )
1167                ->increment();
1168        } else {
1169            [ $response, $offsetArray ] = $callback();
1170        }
1171        $offset = $offsetArray ? IndexMapOffset::newFromArray( $offsetArray ) : null;
1172
1173        return [ $response, $offset ];
1174    }
1175
1176    /**
1177     * Add the response of a single module to the MinifierState
1178     *
1179     * @param Context $context
1180     * @param MinifierState $minifier
1181     * @param string $name
1182     * @param Module $module
1183     * @param array|null &$headers Array of headers. If it is not null, the
1184     *   module's headers will be appended to this array.
1185     */
1186    private function addOneModuleResponse(
1187        Context $context, MinifierState $minifier, $name, Module $module, &$headers
1188    ) {
1189        $only = $context->getOnly();
1190        $debug = (bool)$context->getDebug();
1191        $content = $module->getModuleContent( $context );
1192        $version = $module->getVersionHash( $context );
1193
1194        if ( $headers !== null && isset( $content['headers'] ) ) {
1195            $headers = array_merge( $headers, $content['headers'] );
1196        }
1197
1198        // Append output
1199        switch ( $only ) {
1200            case 'scripts':
1201                $scripts = $content['scripts'];
1202                if ( !is_array( $scripts ) ) {
1203                    // Formerly scripts was usually a string, but now it is
1204                    // normalized to an array by buildContent().
1205                    throw new InvalidArgumentException( 'scripts must be an array' );
1206                }
1207                if ( isset( $scripts['plainScripts'] ) ) {
1208                    // Add plain scripts
1209                    $this->addPlainScripts( $minifier, $name, $scripts['plainScripts'] );
1210                } elseif ( isset( $scripts['files'] ) ) {
1211                    // Add implement call if any
1212                    $this->addImplementScript(
1213                        $minifier,
1214                        $name,
1215                        $version,
1216                        $scripts,
1217                        [],
1218                        null,
1219                        [],
1220                        $content['deprecationWarning'] ?? null
1221                    );
1222                }
1223                break;
1224            case 'styles':
1225                $styles = $content['styles'];
1226                // We no longer separate into media, they are all combined now with
1227                // custom media type groups into @media .. {} sections as part of the css string.
1228                // Module returns either an empty array or a numerical array with css strings.
1229                if ( isset( $styles['css'] ) ) {
1230                    $minifier->addOutput( implode( '', $styles['css'] ) );
1231                }
1232                break;
1233            default:
1234                $scripts = $content['scripts'] ?? '';
1235                if ( ( $name === 'site' || $name === 'user' )
1236                    && isset( $scripts['plainScripts'] )
1237                ) {
1238                    // Legacy scripts that run in the global scope without a closure.
1239                    // mw.loader.impl will use eval if scripts is a string.
1240                    // Minify manually here, because general response minification is
1241                    // not effective due it being a string literal, not a function.
1242                    $scripts = self::concatenatePlainScripts( $scripts['plainScripts'] );
1243                    if ( !$debug ) {
1244                        $scripts = self::filter( 'minify-js', $scripts ); // T107377
1245                    }
1246                }
1247                $this->addImplementScript(
1248                    $minifier,
1249                    $name,
1250                    $version,
1251                    $scripts,
1252                    $content['styles'] ?? [],
1253                    isset( $content['messagesBlob'] ) ? new HtmlJsCode( $content['messagesBlob'] ) : null,
1254                    $content['templates'] ?? [],
1255                    $content['deprecationWarning'] ?? null
1256                );
1257                break;
1258        }
1259        $minifier->ensureNewline();
1260    }
1261
1262    /**
1263     * Ensure the string is either empty or ends in a line break
1264     * @internal
1265     * @param string $str
1266     * @return string
1267     */
1268    public static function ensureNewline( $str ) {
1269        $end = substr( $str, -1 );
1270        if ( $end === '' || $end === "\n" ) {
1271            return $str;
1272        }
1273        return $str . "\n";
1274    }
1275
1276    /**
1277     * Get names of modules that use a certain message.
1278     *
1279     * @param string $messageKey
1280     * @return string[] List of module names
1281     */
1282    public function getModulesByMessage( $messageKey ) {
1283        $moduleNames = [];
1284        foreach ( $this->getModuleNames() as $moduleName ) {
1285            $module = $this->getModule( $moduleName );
1286            if ( in_array( $messageKey, $module->getMessages() ) ) {
1287                $moduleNames[] = $moduleName;
1288            }
1289        }
1290        return $moduleNames;
1291    }
1292
1293    /**
1294     * Generate JS code that calls mw.loader.impl with given module properties
1295     * and add it to the MinifierState.
1296     *
1297     * @param MinifierState $minifier The minifier to which output should be appended
1298     * @param string $moduleName The module name
1299     * @param string $version The module version hash
1300     * @param array|string|string[] $scripts
1301     *  - array: Package files array containing strings for individual JS files,
1302     *    as produced by Module::getScript().
1303     *  - string: Script contents to eval in global scope (for site/user scripts).
1304     *  - string[]: List of URLs (for debug mode).
1305     * @param array<string,string|array<string,string[]>> $styles
1306     *   Under optional key "css", there is a concatenated CSS string.
1307     *   Under optional key "url", there is an array by media type withs URLs to stylesheets (for debug mode).
1308     *   These come from Module::getStyles(), formatted by Module:buildContent().
1309     * @param HtmlJsCode|null $messages An already JSON-encoded map from message keys to values,
1310     *   wrapped in an HtmlJsCode object.
1311     * @param array<string,string> $templates Map from template name to template source.
1312     * @param string|null $deprecationWarning
1313     */
1314    private function addImplementScript( MinifierState $minifier,
1315        $moduleName, $version, $scripts, $styles, $messages, $templates, $deprecationWarning
1316    ) {
1317        $implementKey = "$moduleName@$version";
1318        // Plain functions are used instead of arrow functions to avoid
1319        // defeating lazy compilation on Chrome. (T343407)
1320        $minifier->addOutput( "mw.loader.impl(function(){return[" .
1321            Html::encodeJsVar( $implementKey ) . "," );
1322
1323        // Scripts
1324        if ( is_string( $scripts ) ) {
1325            // user/site script
1326            $minifier->addOutput( Html::encodeJsVar( $scripts ) );
1327        } elseif ( is_array( $scripts ) ) {
1328            if ( isset( $scripts['files'] ) ) {
1329                $minifier->addOutput(
1330                    "{\"main\":" .
1331                    Html::encodeJsVar( $scripts['main'] ) .
1332                    ",\"files\":" );
1333                $this->addFiles( $minifier, $moduleName, $scripts['files'] );
1334                $minifier->addOutput( "}" );
1335            } elseif ( isset( $scripts['plainScripts'] ) ) {
1336                if ( $this->isEmptyFileInfos( $scripts['plainScripts'] ) ) {
1337                    $minifier->addOutput( 'null' );
1338                } else {
1339                    $minifier->addOutput( "function($,jQuery,require,module){" );
1340                    $this->addPlainScripts( $minifier, $moduleName, $scripts['plainScripts'] );
1341                    $minifier->addOutput( "}" );
1342                }
1343            } elseif ( $scripts === [] || isset( $scripts[0] ) ) {
1344                // Array of URLs
1345                $minifier->addOutput( Html::encodeJsVar( $scripts ) );
1346            } else {
1347                throw new InvalidArgumentException( 'Invalid script array: ' .
1348                    'must contain files, plainScripts or be an array of URLs' );
1349            }
1350        } else {
1351            throw new InvalidArgumentException( 'Script must be a string or array' );
1352        }
1353
1354        // mw.loader.impl requires 'styles', 'messages' and 'templates' to be objects (not
1355        // arrays). json_encode considers empty arrays to be numerical and outputs "[]" instead
1356        // of "{}". Force them to objects.
1357        $extraArgs = [
1358            (object)$styles,
1359            $messages ?? (object)[],
1360            (object)$templates,
1361            $deprecationWarning
1362        ];
1363        self::trimArray( $extraArgs );
1364        foreach ( $extraArgs as $arg ) {
1365            $minifier->addOutput( ',' . Html::encodeJsVar( $arg ) );
1366        }
1367        $minifier->addOutput( "];});" );
1368    }
1369
1370    /**
1371     * Extract the contents of an array of package files, and convert it to a
1372     * JavaScript array. Add the array to the minifier state.
1373     *
1374     * Package files can contain JSON data.
1375     *
1376     * @param MinifierState $minifier
1377     * @param string $moduleName
1378     * @param array $files
1379     */
1380    private function addFiles( MinifierState $minifier, $moduleName, $files ) {
1381        $first = true;
1382        $minifier->addOutput( "{" );
1383        foreach ( $files as $fileName => $file ) {
1384            if ( $first ) {
1385                $first = false;
1386            } else {
1387                $minifier->addOutput( "," );
1388            }
1389            $minifier->addOutput( Html::encodeJsVar( $fileName ) . ':' );
1390            $this->addFileContent( $minifier, $moduleName, 'packageFile', $fileName, $file );
1391        }
1392        $minifier->addOutput( "}" );
1393    }
1394
1395    /**
1396     * Add a package file to a MinifierState
1397     *
1398     * @param MinifierState $minifier
1399     * @param string $moduleName
1400     * @param string $sourceType
1401     * @param string|int $sourceIndex
1402     * @param array $file The expanded file info array
1403     */
1404    private function addFileContent( MinifierState $minifier,
1405        $moduleName, $sourceType, $sourceIndex, array $file
1406    ) {
1407        $isScript = ( $file['type'] ?? 'script' ) === 'script';
1408        /** @var FilePath|null $filePath */
1409        $filePath = $file['filePath'] ?? $file['virtualFilePath'] ?? null;
1410        if ( $filePath !== null && $filePath->getRemoteBasePath() !== null ) {
1411            $url = $filePath->getRemotePath();
1412        } else {
1413            $ext = $isScript ? 'js' : 'json';
1414            $scriptPath = $this->config->has( MainConfigNames::ScriptPath )
1415                ? $this->config->get( MainConfigNames::ScriptPath ) : '';
1416            $url = "$scriptPath/virtual-resource/$moduleName-$sourceType-$sourceIndex.$ext";
1417        }
1418        $content = $file['content'];
1419        if ( $isScript ) {
1420            if ( $sourceType === 'packageFile' ) {
1421                // Provide CJS `exports` (in addition to CJS2 `module.exports`) to package modules (T284511).
1422                // $/jQuery are simply used as globals instead.
1423                // TODO: Remove $/jQuery param from traditional module closure too (and bump caching)
1424                $minifier->addOutput( "function(require,module,exports){" );
1425                $minifier->addSourceFile( $url, $content, true );
1426                $minifier->ensureNewline();
1427                $minifier->addOutput( "}" );
1428            } else {
1429                $minifier->addSourceFile( $url, $content, true );
1430                $minifier->ensureNewline();
1431            }
1432        } else {
1433            $content = Html::encodeJsVar( $content, true );
1434            $minifier->addSourceFile( $url, $content, true );
1435        }
1436    }
1437
1438    /**
1439     * Combine a plainScripts array like [ [ 'content' => '...' ] ] into a
1440     * single string.
1441     *
1442     * @param array[] $plainScripts
1443     * @return string
1444     */
1445    private static function concatenatePlainScripts( $plainScripts ) {
1446        $s = '';
1447        foreach ( $plainScripts as $script ) {
1448            // Make the script safe to concatenate by making sure there is at least one
1449            // trailing new line at the end of the content (T29054, T162719)
1450            $s .= self::ensureNewline( $script['content'] );
1451        }
1452        return $s;
1453    }
1454
1455    /**
1456     * Add contents from a plainScripts array like [ [ 'content' => '...' ]
1457     * to a MinifierState
1458     *
1459     * @param MinifierState $minifier
1460     * @param string $moduleName
1461     * @param array[] $plainScripts
1462     */
1463    private function addPlainScripts( MinifierState $minifier, $moduleName, $plainScripts ) {
1464        foreach ( $plainScripts as $index => $file ) {
1465            $this->addFileContent( $minifier, $moduleName, 'script', $index, $file );
1466        }
1467    }
1468
1469    /**
1470     * Determine whether an array of file info arrays has empty content
1471     *
1472     * @param array $infos
1473     * @return bool
1474     */
1475    private function isEmptyFileInfos( $infos ) {
1476        $len = 0;
1477        foreach ( $infos as $info ) {
1478            $len += strlen( $info['content'] ?? '' );
1479        }
1480        return $len === 0;
1481    }
1482
1483    /**
1484     * Combines an associative array mapping media type to CSS into a
1485     * single stylesheet with "@media" blocks.
1486     *
1487     * @param array<string,string|string[]> $stylePairs Map from media type to CSS string(s)
1488     * @param WebRequest $request
1489     * @return string[] CSS strings
1490     */
1491    public static function makeCombinedStyles( array $stylePairs, WebRequest $request ) {
1492        $out = [];
1493        foreach ( $stylePairs as $media => $styles ) {
1494            // FileModule::getStyle can return the styles as a string or an
1495            // array of strings. This is to allow separation in the front-end.
1496            $styles = (array)$styles;
1497            foreach ( $styles as $style ) {
1498                $style = trim( $style );
1499                // Don't output an empty "@media print { }" block (T42498)
1500                if ( $style === '' ) {
1501                    continue;
1502                }
1503                // Transform the media type based on request params and config
1504                // The way that this relies on $wgRequest to propagate request params is slightly evil
1505                $media = OutputPage::transformCssMedia( $media, $request );
1506
1507                if ( $media === '' || $media == 'all' ) {
1508                    $out[] = $style;
1509                } elseif ( is_string( $media ) ) {
1510                    $out[] = "@media $media {\n" . str_replace( "\n", "\n\t", "\t" . $style ) . "}";
1511                }
1512                // else: skip
1513            }
1514        }
1515        return $out;
1516    }
1517
1518    /**
1519     * Format a JS call to mw.loader.state()
1520     *
1521     * @internal For use by StartUpModule
1522     * @param Context $context
1523     * @param array<string,string> $states
1524     * @return string JavaScript code
1525     */
1526    public static function makeLoaderStateScript(
1527        Context $context, array $states
1528    ) {
1529        return 'mw.loader.state('
1530            // Silently ignore invalid UTF-8 injected via 'modules' query
1531            // Don't issue server-side warnings for client errors. (T331641)
1532            // phpcs:ignore Generic.PHP.NoSilencedErrors.Discouraged
1533            . @$context->encodeJson( $states )
1534            . ');';
1535    }
1536
1537    private static function isEmptyObject( stdClass $obj ): bool {
1538        foreach ( $obj as $value ) {
1539            return false;
1540        }
1541        return true;
1542    }
1543
1544    /**
1545     * Remove empty values from the end of an array.
1546     *
1547     * Values considered empty:
1548     *
1549     * - null
1550     * - []
1551     * - new HtmlJsCode( '{}' )
1552     * - new stdClass()
1553     * - (object)[]
1554     */
1555    private static function trimArray( array &$array ): void {
1556        $i = count( $array );
1557        while ( $i-- ) {
1558            if ( $array[$i] === null
1559                || $array[$i] === []
1560                || ( $array[$i] instanceof HtmlJsCode && $array[$i]->value === '{}' )
1561                || ( $array[$i] instanceof stdClass && self::isEmptyObject( $array[$i] ) )
1562            ) {
1563                unset( $array[$i] );
1564            } else {
1565                break;
1566            }
1567        }
1568    }
1569
1570    /**
1571     * Format JS code which calls `mw.loader.register()` with the given parameters.
1572     *
1573     * @par Example
1574     * @code
1575     *
1576     *     ResourceLoader::makeLoaderRegisterScript( $context, [
1577     *        [ $name1, $version1, $dependencies1, $group1, $source1, $skip1 ],
1578     *        [ $name2, $version2, $dependencies1, $group2, $source2, $skip2 ],
1579     *        ...
1580     *     ] ):
1581     * @endcode
1582     *
1583     * @internal For use by StartUpModule only
1584     * @param Context $context
1585     * @param array[] $modules Array of module registration arrays, each containing
1586     *  - string: module name
1587     *  - string: module version
1588     *  - array|null: List of dependencies (optional)
1589     *  - int|null: Module group (optional)
1590     *  - string|null: Name of foreign module source, or 'local' (optional)
1591     *  - string|null: Script body of a skip function (optional)
1592     * @phan-param array<int,array{0:string,1:string,2?:?array,3?:?int,4?:?string,5?:?string}> $modules
1593     * @return string JavaScript code
1594     */
1595    public static function makeLoaderRegisterScript(
1596        Context $context, array $modules
1597    ) {
1598        // Optimisation: Transform dependency names into indexes when possible
1599        // to produce smaller output. They are expanded by mw.loader.register on
1600        // the other end.
1601        $index = [];
1602        foreach ( $modules as $i => $module ) {
1603            // Build module name index
1604            $index[$module[0]] = $i;
1605        }
1606        foreach ( $modules as &$module ) {
1607            if ( isset( $module[2] ) ) {
1608                foreach ( $module[2] as &$dependency ) {
1609                    if ( isset( $index[$dependency] ) ) {
1610                        // Replace module name in dependency list with index
1611                        $dependency = $index[$dependency];
1612                    }
1613                }
1614            }
1615            self::trimArray( $module );
1616        }
1617
1618        return 'mw.loader.register('
1619            . $context->encodeJson( $modules )
1620            . ');';
1621    }
1622
1623    /**
1624     * Format JS code which calls `mw.loader.addSource()` with the given parameters.
1625     *
1626     *   - ResourceLoader::makeLoaderSourcesScript( $context,
1627     *         [ $id1 => $loadUrl, $id2 => $loadUrl, ... ]
1628     *     );
1629     *       Register sources with the given IDs and properties.
1630     *
1631     * @internal For use by StartUpModule only
1632     * @param Context $context
1633     * @param array<string,string> $sources
1634     * @return string JavaScript code
1635     */
1636    public static function makeLoaderSourcesScript(
1637        Context $context, array $sources
1638    ) {
1639        return 'mw.loader.addSource('
1640            . $context->encodeJson( $sources )
1641            . ');';
1642    }
1643
1644    /**
1645     * Wrap JavaScript code to run after the startup module.
1646     *
1647     * @param string $script JavaScript code
1648     * @return string JavaScript code
1649     */
1650    public static function makeLoaderConditionalScript( $script ) {
1651        // Adds a function to lazy-created RLQ
1652        return '(RLQ=window.RLQ||[]).push(function(){' .
1653            trim( $script ) . '});';
1654    }
1655
1656    /**
1657     * Wrap JavaScript code to run after a required module.
1658     *
1659     * @since 1.32
1660     * @param string|string[] $modules Module name(s)
1661     * @param string $script JavaScript code
1662     * @return string JavaScript code
1663     */
1664    public static function makeInlineCodeWithModule( $modules, $script ) {
1665        // Adds an array to lazy-created RLQ
1666        return '(RLQ=window.RLQ||[]).push(['
1667            . json_encode( $modules ) . ','
1668            . 'function(){' . trim( $script ) . '}'
1669            . ']);';
1670    }
1671
1672    /**
1673     * Make an HTML script that runs given JS code after startup and base modules.
1674     *
1675     * The code will be wrapped in a closure, and it will be executed by ResourceLoader's
1676     * startup module if the client has adequate support for MediaWiki JavaScript code.
1677     *
1678     * @param string $script JavaScript code
1679     * @param string|null $nonce Unused
1680     * @return string|WrappedString HTML
1681     */
1682    public static function makeInlineScript( $script, $nonce = null ) {
1683        $js = self::makeLoaderConditionalScript( $script );
1684        return new WrappedString(
1685            Html::inlineScript( $js ),
1686            "<script>(RLQ=window.RLQ||[]).push(function(){",
1687            '});</script>'
1688        );
1689    }
1690
1691    /**
1692     * Convert an array of module names to a packed query string.
1693     *
1694     * For example, `[ 'foo.bar', 'foo.baz', 'bar.baz', 'bar.quux' ]`
1695     * becomes `'foo.bar,baz|bar.baz,quux'`.
1696     *
1697     * This process is reversed by ResourceLoader::expandModuleNames().
1698     * See also mw.loader#buildModulesString() which is a port of this, used
1699     * on the client-side.
1700     *
1701     * @param string[] $modules List of module names (strings)
1702     * @return string Packed query string
1703     */
1704    public static function makePackedModulesString( array $modules ) {
1705        $moduleMap = []; // [ prefix => [ suffixes ] ]
1706        foreach ( $modules as $module ) {
1707            $pos = strrpos( $module, '.' );
1708            $prefix = $pos === false ? '' : substr( $module, 0, $pos );
1709            $suffix = $pos === false ? $module : substr( $module, $pos + 1 );
1710            $moduleMap[$prefix][] = $suffix;
1711        }
1712
1713        $arr = [];
1714        foreach ( $moduleMap as $prefix => $suffixes ) {
1715            $p = $prefix === '' ? '' : $prefix . '.';
1716            $arr[] = $p . implode( ',', $suffixes );
1717        }
1718        return implode( '|', $arr );
1719    }
1720
1721    /**
1722     * Expand a string of the form `jquery.foo,bar|jquery.ui.baz,quux` to
1723     * an array of module names like `[ 'jquery.foo', 'jquery.bar',
1724     * 'jquery.ui.baz', 'jquery.ui.quux' ]`.
1725     *
1726     * This process is reversed by ResourceLoader::makePackedModulesString().
1727     *
1728     * @since 1.33
1729     * @param string $modules Packed module name list
1730     * @return string[] Array of module names
1731     */
1732    public static function expandModuleNames( $modules ) {
1733        $retval = [];
1734        $exploded = explode( '|', $modules );
1735        foreach ( $exploded as $group ) {
1736            if ( !str_contains( $group, ',' ) ) {
1737                // This is not a set of modules in foo.bar,baz notation
1738                // but a single module
1739                $retval[] = $group;
1740                continue;
1741            }
1742            // This is a set of modules in foo.bar,baz notation
1743            $pos = strrpos( $group, '.' );
1744            if ( $pos === false ) {
1745                // Prefixless modules, i.e. without dots
1746                $retval = array_merge( $retval, explode( ',', $group ) );
1747                continue;
1748            }
1749            // We have a prefix and a bunch of suffixes
1750            $prefix = substr( $group, 0, $pos ); // 'foo'
1751            $suffixes = explode( ',', substr( $group, $pos + 1 ) ); // [ 'bar', 'baz' ]
1752            foreach ( $suffixes as $suffix ) {
1753                $retval[] = "$prefix.$suffix";
1754            }
1755        }
1756        return $retval;
1757    }
1758
1759    /**
1760     * Determine whether debug mode is on.
1761     *
1762     * Order of priority is:
1763     * - 1) Request parameter,
1764     * - 2) Cookie,
1765     * - 3) Site configuration.
1766     *
1767     * @deprecated since 1.47
1768     * @return int
1769     */
1770    public static function inDebugMode() {
1771        wfDeprecated( __METHOD__, '1.47' );
1772        if ( self::$debugMode === null ) {
1773            $resourceLoaderDebug = MediaWikiServices::getInstance()->getMainConfig()->get(
1774                MainConfigNames::ResourceLoaderDebug );
1775            $request = RequestContext::getMain()->getRequest();
1776            $str = $request->getRawVal( 'debug' ) ??
1777                $request->getCookie( 'resourceLoaderDebug', '', $resourceLoaderDebug ? 'true' : '' );
1778            self::$debugMode = Context::debugFromString( $str );
1779        }
1780        return self::$debugMode;
1781    }
1782
1783    /**
1784     * Reset static members used for caching.
1785     *
1786     * Global state and $wgRequest are evil, but we're using it right
1787     * now and sometimes we need to be able to force ResourceLoader to
1788     * re-evaluate the context because it has changed (e.g. in the test suite).
1789     *
1790     * @internal For use by unit tests
1791     * @codeCoverageIgnore
1792     */
1793    public static function clearCache() {
1794        self::$debugMode = null;
1795    }
1796
1797    /**
1798     * Build a load.php URL
1799     *
1800     * @since 1.24
1801     * @param string $source Name of the ResourceLoader source
1802     * @param Context $context
1803     * @param array $extraQuery
1804     * @return string URL to load.php. May be protocol-relative if $wgLoadScript is, too.
1805     */
1806    public function createLoaderURL( $source, Context $context,
1807        array $extraQuery = []
1808    ) {
1809        $query = self::createLoaderQuery( $context, $extraQuery );
1810        $script = $this->getLoadScript( $source );
1811
1812        return wfAppendQuery( $script, $query );
1813    }
1814
1815    /**
1816     * Helper for createLoaderURL()
1817     *
1818     * @since 1.24
1819     * @see makeLoaderQuery
1820     * @param Context $context
1821     * @param array $extraQuery
1822     * @return array
1823     */
1824    protected static function createLoaderQuery(
1825        Context $context, array $extraQuery = []
1826    ) {
1827        return self::makeLoaderQuery(
1828            $context->getModules(),
1829            $context->getLanguage(),
1830            $context->getSkin(),
1831            $context->getUser(),
1832            $context->getVersion(),
1833            $context->getDebug(),
1834            $context->getOnly(),
1835            $context->getRequest()->getBool( 'printable' ),
1836            null,
1837            $extraQuery
1838        );
1839    }
1840
1841    /**
1842     * Build a query array (array representation of query string) for load.php. Helper
1843     * function for createLoaderURL().
1844     *
1845     * @param string[] $modules
1846     * @param string $lang
1847     * @param string $skin
1848     * @param string|null $user
1849     * @param string|null $version
1850     * @param int $debug
1851     * @param string|null $only
1852     * @param bool $printable
1853     * @param bool|null $handheld Unused as of MW 1.38
1854     * @param array $extraQuery
1855     * @return array
1856     */
1857    public static function makeLoaderQuery( array $modules, $lang, $skin, $user = null,
1858        $version = null, $debug = Context::DEBUG_OFF, $only = null,
1859        $printable = false, $handheld = null, array $extraQuery = []
1860    ) {
1861        $query = [
1862            'modules' => self::makePackedModulesString( $modules ),
1863        ];
1864        // Keep urls short by omitting query parameters that
1865        // match the defaults assumed by Context.
1866        // Note: This relies on the defaults either being insignificant or forever constant,
1867        // as otherwise cached urls could change in meaning when the defaults change.
1868        if ( $lang !== Context::DEFAULT_LANG ) {
1869            $query['lang'] = $lang;
1870        }
1871        if ( $skin !== Context::DEFAULT_SKIN ) {
1872            $query['skin'] = $skin;
1873        }
1874        if ( $debug !== Context::DEBUG_OFF ) {
1875            $query['debug'] = strval( $debug );
1876        }
1877        if ( $user !== null ) {
1878            $query['user'] = $user;
1879        }
1880        if ( $version !== null ) {
1881            $query['version'] = $version;
1882        }
1883        if ( $only !== null ) {
1884            $query['only'] = $only;
1885        }
1886        if ( $printable ) {
1887            $query['printable'] = 1;
1888        }
1889        foreach ( $extraQuery as $name => $value ) {
1890            $query[$name] = $value;
1891        }
1892
1893        // Make queries uniform in order
1894        ksort( $query );
1895        return $query;
1896    }
1897
1898    /**
1899     * Check a module name for validity.
1900     *
1901     * Module names may not contain pipes (|), commas (,) or exclamation marks (!) and can be
1902     * at most 255 bytes.
1903     *
1904     * @param string $moduleName Module name to check
1905     * @return bool Whether $moduleName is a valid module name
1906     */
1907    public static function isValidModuleName( $moduleName ) {
1908        $len = strlen( $moduleName );
1909        return ( $len <= 255
1910            && strcspn( $moduleName, '!,|', 0, $len ) === $len )
1911            && ( !str_starts_with( $moduleName, "./" ) && !str_starts_with( $moduleName, "../" ) );
1912    }
1913
1914    /**
1915     * Return a LESS compiler that is set up for use with MediaWiki.
1916     *
1917     * @since 1.27
1918     * @param array $vars Associative array of variables that should be used
1919     *  for compilation. Since 1.32, this method no longer automatically includes
1920     *  global LESS vars from ResourceLoader::getLessVars (T191937).
1921     * @param array $importDirs Additional directories to look in for @import (since 1.36)
1922     * @return Less_Parser
1923     */
1924    public function getLessCompiler( array $vars = [], array $importDirs = [] ) {
1925        // When called from the installer, it is possible that a required PHP extension
1926        // is missing (at least for now; see T49564). If this is the case, throw an
1927        // exception (caught by the installer) to prevent a fatal error later on.
1928        if ( !class_exists( Less_Parser::class ) ) {
1929            throw new RuntimeException( 'MediaWiki requires the less.php parser' );
1930        }
1931
1932        $importDirs[] = MW_INSTALL_PATH . '/resources/src/mediawiki.less';
1933
1934        $parser = new Less_Parser;
1935        $parser->ModifyVars( $vars );
1936        $parser->SetOption( 'relativeUrls', false );
1937        $parser->SetOption( 'math', 'parens-division' );
1938
1939        // SetImportDirs expects an array like [ 'path1' => '', 'path2' => '' ]
1940        $formattedImportDirs = array_fill_keys( $importDirs, '' );
1941
1942        // Add a callback to the import dirs array for path remapping
1943        $codexDevDir = $this->getConfig()->get( MainConfigNames::CodexDevelopmentDir );
1944        $formattedImportDirs[] = static function ( $path ) use ( $codexDevDir ) {
1945            // For each of the Codex import paths, use CodexDevelopmentDir if it's set
1946            $importMap = [
1947                '@wikimedia/codex-icons/' => $codexDevDir !== null ?
1948                    "$codexDevDir/packages/codex-icons/dist/" :
1949                    MW_INSTALL_PATH . '/resources/lib/codex-icons/',
1950                'mediawiki.skin.codex/' => $codexDevDir !== null ?
1951                    "$codexDevDir/packages/codex/dist/" :
1952                    MW_INSTALL_PATH . '/resources/lib/codex/',
1953                'mediawiki.skin.codex-design-tokens/' => $codexDevDir !== null ?
1954                    "$codexDevDir/packages/codex-design-tokens/dist/" :
1955                    MW_INSTALL_PATH . '/resources/lib/codex-design-tokens/',
1956                '@wikimedia/codex-design-tokens/' => static function ( $unused_path ): never {
1957                    throw new RuntimeException(
1958                        'Importing from @wikimedia/codex-design-tokens is not supported. ' .
1959                        "To use the Codex tokens, use `@import 'mediawiki.skin.variables.less';` instead."
1960                    );
1961                }
1962            ];
1963            foreach ( $importMap as $importPath => $substPath ) {
1964                if ( str_starts_with( $path, $importPath ) ) {
1965                    $restOfPath = substr( $path, strlen( $importPath ) );
1966                    if ( is_callable( $substPath ) ) {
1967                        // @phan-suppress-next-line PhanUseReturnValueOfNever
1968                        $resolvedPath = $substPath( $restOfPath );
1969                    } else {
1970                        $filePath = $substPath . $restOfPath;
1971
1972                        $resolvedPath = null;
1973                        if ( file_exists( $filePath ) ) {
1974                            $resolvedPath = $filePath;
1975                        } elseif ( file_exists( "$filePath.less" ) ) {
1976                            $resolvedPath = "$filePath.less";
1977                        }
1978                    }
1979
1980                    if ( $resolvedPath !== null ) {
1981                        return [
1982                            Less_Environment::normalizePath( $resolvedPath ),
1983                            Less_Environment::normalizePath( dirname( $path ) )
1984                        ];
1985                    } else {
1986                        break;
1987                    }
1988                }
1989            }
1990            return [ null, null ];
1991        };
1992        $parser->SetImportDirs( $formattedImportDirs );
1993
1994        return $parser;
1995    }
1996
1997    /**
1998     * Run JavaScript or CSS data through a filter, caching the filtered result for future calls.
1999     *
2000     * Available filters are:
2001     *
2002     *    - minify-js
2003     *    - minify-css
2004     *
2005     * If $data is empty, only contains whitespace or the filter was unknown,
2006     * $data is returned unmodified.
2007     *
2008     * @param string $filter Name of filter to run
2009     * @param string $data Text to filter, such as JavaScript or CSS text
2010     * @param array<string,bool> $options Keys:
2011     *  - (bool) cache: Whether to allow caching this data. Default: true.
2012     * @return string Filtered data or unfiltered data
2013     */
2014    public static function filter( $filter, $data, array $options = [] ) {
2015        if ( isset( $options['cache'] ) && $options['cache'] === false ) {
2016            return self::applyFilter( $filter, $data ) ?? $data;
2017        }
2018
2019        $statsFactory = MediaWikiServices::getInstance()->getStatsFactory();
2020        // Same as ResourceLoader->srvCache
2021        $cache = MediaWikiServices::getInstance()->getLocalServerObjectCache();
2022
2023        $key = $cache->makeGlobalKey(
2024            'resourceloader-filter',
2025            $filter,
2026            self::CACHE_VERSION,
2027            md5( $data )
2028        );
2029
2030        $status = 'hit';
2031        $result = $cache->getWithSetCallback(
2032            $key,
2033            BagOStuff::TTL_DAY,
2034            static function () use ( $filter, $data, &$status ) {
2035                $status = 'miss';
2036                return self::applyFilter( $filter, $data );
2037            }
2038        );
2039        $statsFactory->getCounter( 'resourceloader_cache_total' )
2040            ->setLabel( 'type', $filter )
2041            ->setLabel( 'status', $status )
2042            ->increment();
2043
2044        // Use $data on cache failure
2045        return $result ?? $data;
2046    }
2047
2048    /**
2049     * @param string $filter
2050     * @param string $data
2051     * @return string|null
2052     */
2053    private static function applyFilter( $filter, $data ) {
2054        $data = trim( $data );
2055        if ( $data ) {
2056            try {
2057                $data = ( $filter === 'minify-css' )
2058                    ? CSSMin::minify( $data )
2059                    : JavaScriptMinifier::minify( $data );
2060            } catch ( TimeoutException $e ) {
2061                throw $e;
2062            } catch ( Exception $e ) {
2063                MWExceptionHandler::logException( $e );
2064                return null;
2065            }
2066        }
2067        return $data;
2068    }
2069
2070    /**
2071     * Get user default options to expose to JavaScript on all pages via `mw.user.options`.
2072     *
2073     * @internal Exposed for use from Resources.php
2074     *
2075     * @param Context $context
2076     * @param HookContainer $hookContainer
2077     * @param UserOptionsLookup $userOptionsLookup
2078     *
2079     * @return array
2080     */
2081    public static function getUserDefaults(
2082        Context $context,
2083        HookContainer $hookContainer,
2084        UserOptionsLookup $userOptionsLookup
2085    ): array {
2086        $defaultOptions = $userOptionsLookup->getDefaultOptions();
2087        $keysToExclude = [];
2088        $hookRunner = new HookRunner( $hookContainer );
2089        $hookRunner->onResourceLoaderExcludeUserOptions( $keysToExclude, $context );
2090        foreach ( $keysToExclude as $excludedKey ) {
2091            unset( $defaultOptions[ $excludedKey ] );
2092        }
2093        return $defaultOptions;
2094    }
2095
2096    /**
2097     * Get site configuration settings to expose to JavaScript on all pages via `mw.config`.
2098     *
2099     * @internal Exposed for use from Resources.php
2100     * @param Context $context
2101     * @param Config $conf
2102     * @return array
2103     */
2104    public static function getSiteConfigSettings(
2105        Context $context, Config $conf
2106    ): array {
2107        $services = MediaWikiServices::getInstance();
2108        // Namespace related preparation
2109        // - wgNamespaceIds: Key-value pairs of all localized, canonical and aliases for namespaces.
2110        // - wgCaseSensitiveNamespaces: Array of namespaces that are case-sensitive.
2111        $contLang = $services->getContentLanguage();
2112        $namespaceIds = $contLang->getNamespaceIds();
2113        $caseSensitiveNamespaces = [];
2114        $nsInfo = $services->getNamespaceInfo();
2115        foreach ( $nsInfo->getCanonicalNamespaces() as $index => $name ) {
2116            $namespaceIds[$contLang->lc( $name )] = $index;
2117            if ( !$nsInfo->isCapitalized( $index ) ) {
2118                $caseSensitiveNamespaces[] = $index;
2119            }
2120        }
2121
2122        $illegalFileChars = $conf->get( MainConfigNames::IllegalFileChars );
2123
2124        // Build list of variables
2125        $skin = $context->getSkin();
2126
2127        // Start of supported and stable config vars (for use by extensions/gadgets).
2128        $vars = [
2129            'debug' => $context->getDebug(),
2130            'skin' => $skin,
2131            'stylepath' => $conf->get( MainConfigNames::StylePath ),
2132            'wgArticlePath' => $conf->get( MainConfigNames::ArticlePath ),
2133            'wgScriptPath' => $conf->get( MainConfigNames::ScriptPath ),
2134            'wgScript' => $conf->get( MainConfigNames::Script ),
2135            'wgSearchType' => $conf->get( MainConfigNames::SearchType ),
2136            'wgVariantArticlePath' => $conf->get( MainConfigNames::VariantArticlePath ),
2137            'wgServer' => $conf->get( MainConfigNames::Server ),
2138            'wgServerName' => $conf->get( MainConfigNames::ServerName ),
2139            'wgUserLanguage' => $context->getLanguage(),
2140            'wgContentLanguage' => $contLang->getCode(),
2141            'wgVersion' => MW_VERSION,
2142            'wgFormattedNamespaces' => $contLang->getFormattedNamespaces(),
2143            'wgNamespaceIds' => $namespaceIds,
2144            'wgContentNamespaces' => $nsInfo->getContentNamespaces(),
2145            'wgSiteName' => $conf->get( MainConfigNames::Sitename ),
2146            'wgDBname' => $conf->get( MainConfigNames::DBname ),
2147            'wgWikiID' => WikiMap::getCurrentWikiId(),
2148            'wgCaseSensitiveNamespaces' => $caseSensitiveNamespaces,
2149            'wgCommentCodePointLimit' => CommentStore::COMMENT_CHARACTER_LIMIT,
2150            'wgExtensionAssetsPath' => $conf->get( MainConfigNames::ExtensionAssetsPath ),
2151        ];
2152        // End of stable config vars.
2153
2154        // Internal variables for use by MediaWiki core and/or ResourceLoader.
2155        $vars += [
2156            // @internal For mediawiki.widgets
2157            'wgUrlProtocols' => $services->getUrlUtils()->validProtocols(),
2158            // @internal For mediawiki.page.watch
2159            // Force object to avoid "empty" associative array from
2160            // becoming [] instead of {} in JS (T36604)
2161            'wgActionPaths' => (object)$conf->get( MainConfigNames::ActionPaths ),
2162            // @internal For mediawiki.language
2163            'wgTranslateNumerals' => $conf->get( MainConfigNames::TranslateNumerals ),
2164            // @internal For mediawiki.Title
2165            'wgExtraSignatureNamespaces' => $conf->get( MainConfigNames::ExtraSignatureNamespaces ),
2166            'wgLegalTitleChars' => Title::convertByteClassToUnicodeClass( Title::legalChars() ),
2167            'wgIllegalFileChars' => Title::convertByteClassToUnicodeClass( $illegalFileChars ),
2168        ];
2169
2170        ( new HookRunner( $services->getHookContainer() ) )
2171            ->onResourceLoaderGetConfigVars( $vars, $skin, $conf );
2172
2173        return $vars;
2174    }
2175
2176    /**
2177     * @internal For testing
2178     * @return array
2179     */
2180    public function getErrors() {
2181        return $this->errors;
2182    }
2183}