Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
69.15% covered (warning)
69.15%
139 / 201
46.81% covered (danger)
46.81%
22 / 47
CRAP
0.00% covered (danger)
0.00%
0 / 1
Module
69.15% covered (warning)
69.15%
139 / 201
46.81% covered (danger)
46.81%
22 / 47
285.18
0.00% covered (danger)
0.00%
0 / 1
 getName
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 setName
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 setSkinStylesOverride
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getOrigin
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getFlip
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 getDeprecationWarning
28.57% covered (danger)
28.57%
2 / 7
0.00% covered (danger)
0.00%
0 / 1
6.28
 getScript
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getTemplates
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getConfig
66.67% covered (warning)
66.67%
2 / 3
0.00% covered (danger)
0.00%
0 / 1
2.15
 setConfig
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 setLogger
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getLogger
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 setHookContainer
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getHookRunner
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 supportsURLLoading
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getStyles
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getStyleURLsForDebug
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
1
 getMessages
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getGroup
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getSource
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getDependencies
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getSkins
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getType
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getSkipFunction
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 requiresES6
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getFileDependencies
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
2
 setFileDependencies
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 saveFileDependencies
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
12
 getRelativePaths
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 expandRelativePaths
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 getMessageBlob
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
3
 setMessageBlob
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getHeaders
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
4
 getPreloadLinks
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getLessVars
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getModuleContent
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 buildContent
72.73% covered (warning)
72.73%
32 / 44
0.00% covered (danger)
0.00%
0 / 1
21.19
 getVersionHash
100.00% covered (success)
100.00%
12 / 12
100.00% covered (success)
100.00%
1 / 1
5
 enableModuleContentVersion
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getDefinitionSummary
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
1
 isKnownEmpty
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 shouldEmbedModule
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 shouldSkipStructureTest
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 validateScriptFile
96.00% covered (success)
96.00%
24 / 25
0.00% covered (danger)
0.00%
0 / 1
4
 parseVueContent
0.00% covered (danger)
0.00%
0 / 15
0.00% covered (danger)
0.00%
0 / 1
6
 safeFileHash
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getVary
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
1
1<?php
2/**
3 * @license GPL-2.0-or-later
4 * @file
5 * @author Trevor Parscal
6 * @author Roan Kattouw
7 */
8
9namespace MediaWiki\ResourceLoader;
10
11use InvalidArgumentException;
12use LogicException;
13use MediaWiki\Config\Config;
14use MediaWiki\HookContainer\HookContainer;
15use MediaWiki\MainConfigNames;
16use MediaWiki\MediaWikiServices;
17use MediaWiki\Utils\FileContentsHasher;
18use Peast\Peast;
19use Peast\Syntax\Exception as PeastSyntaxException;
20use Psr\Log\LoggerAwareInterface;
21use Psr\Log\LoggerInterface;
22use Psr\Log\NullLogger;
23use RuntimeException;
24use Wikimedia\RelPath;
25
26/**
27 * Abstraction for ResourceLoader modules, with name registration and maxage functionality.
28 *
29 * @see $wgResourceModules for the available options when registering a module.
30 * @stable to extend
31 * @ingroup ResourceLoader
32 * @since 1.17
33 */
34abstract class Module implements LoggerAwareInterface {
35    /** @var Config */
36    protected $config;
37    /** @var LoggerInterface */
38    protected $logger;
39
40    private ?VueComponentParser $vueComponentParser = null;
41
42    /**
43     * Script and style modules form a hierarchy of trustworthiness, with core modules
44     * like skins and jQuery as most trustworthy, and user scripts as least trustworthy. We can
45     * limit the types of scripts and styles we allow to load on, say, sensitive special
46     * pages like Special:UserLogin and Special:Preferences
47     * @var int
48     */
49    protected $origin = self::ORIGIN_CORE_SITEWIDE;
50
51    /** @var string|null Module name */
52    protected $name = null;
53    /** @var string[]|null Skin names */
54    protected $skins = null;
55
56    /** @var array Map of (variant => indirect file dependencies) */
57    protected $fileDeps = [];
58    /** @var array Map of (language => in-object cache for message blob) */
59    protected $msgBlobs = [];
60    /** @var array Map of (context hash => cached module version hash) */
61    protected $versionHash = [];
62    /** @var array Map of (context hash => cached module content) */
63    protected $contents = [];
64
65    /** @var HookRunner|null */
66    private $hookRunner;
67
68    /** @var string|bool Deprecation string or true if deprecated; false otherwise */
69    protected $deprecated = false;
70
71    /** @var string Scripts only */
72    public const TYPE_SCRIPTS = 'scripts';
73    /** @var string Styles only */
74    public const TYPE_STYLES = 'styles';
75    /** @var string Scripts and styles */
76    public const TYPE_COMBINED = 'combined';
77
78    /** @var string */
79    public const GROUP_SITE = 'site';
80    /** @var string */
81    public const GROUP_USER = 'user';
82    /** @var string */
83    public const GROUP_PRIVATE = 'private';
84    /** @var string */
85    public const GROUP_NOSCRIPT = 'noscript';
86
87    /** @var string Module only has styles (loaded via <style> or <link rel=stylesheet>) */
88    public const LOAD_STYLES = 'styles';
89    /** @var string Module may have other resources (loaded via mw.loader from a script) */
90    public const LOAD_GENERAL = 'general';
91
92    /** @var int An constant to represent no module loading; make sure this is kept as the smallest number in this group */
93    public const ORIGIN_NONE = 0;
94
95    /** @var int Sitewide core module like a skin file or jQuery component */
96    public const ORIGIN_CORE_SITEWIDE = 1;
97    /** @var int Per-user module generated by the software */
98    public const ORIGIN_CORE_INDIVIDUAL = 2;
99    /**
100     * Sitewide module generated from user-editable files, like MediaWiki:Common.js,
101     * or modules accessible to multiple users, such as those generated by the Gadgets extension.
102     * @var int
103     */
104    public const ORIGIN_USER_SITEWIDE = 3;
105    /** @var int Per-user module generated from user-editable files, like User:Me/vector.js */
106    public const ORIGIN_USER_INDIVIDUAL = 4;
107    /** @var int An access constant; make sure this is kept as the largest number in this group */
108    public const ORIGIN_ALL = 10;
109
110    /** @var int Cache version for user-script JS validation errors from validateScriptFile(). */
111    private const USERJSPARSE_CACHE_VERSION = 5;
112
113    /**
114     * Get this module's name. This is set when the module is registered
115     * with ResourceLoader::register()
116     *
117     * @return string|null Name (string) or null if no name was set
118     */
119    public function getName() {
120        return $this->name;
121    }
122
123    /**
124     * Set this module's name. This is called by ResourceLoader::register()
125     * when registering the module. Other code should not call this.
126     *
127     * @param string $name
128     */
129    public function setName( $name ) {
130        $this->name = $name;
131    }
132
133    /**
134     * Provide overrides for skinStyles to modules that support that.
135     *
136     * This MUST be called after self::setName().
137     *
138     * @since 1.37
139     * @see $wgResourceModuleSkinStyles
140     * @param array $moduleSkinStyles
141     */
142    public function setSkinStylesOverride( array $moduleSkinStyles ): void {
143        // Stub, only supported by FileModule currently.
144    }
145
146    /**
147     * Get this module's origin. This is set when the module is registered
148     * with ResourceLoader::register()
149     *
150     * @return int Module class constant, the subclass default if not set manually
151     */
152    public function getOrigin() {
153        return $this->origin;
154    }
155
156    /**
157     * @param Context $context
158     * @return bool
159     */
160    public function getFlip( Context $context ) {
161        return MediaWikiServices::getInstance()->getContentLanguage()->getDir() !==
162            $context->getDirection();
163    }
164
165    /**
166     * Get the deprecation warning, if any
167     *
168     * @since 1.41
169     * @return string|null
170     */
171    public function getDeprecationWarning() {
172        if ( !$this->deprecated ) {
173            return null;
174        }
175        $name = $this->getName();
176        $warning = 'This page is using the deprecated ResourceLoader module "' . $name . '".';
177        if ( is_string( $this->deprecated ) ) {
178            $warning .= "\n" . $this->deprecated;
179        }
180        return $warning;
181    }
182
183    /**
184     * Get all JS for this module for a given language and skin.
185     * Includes all relevant JS except loader scripts.
186     *
187     * For multi-file modules where require() is used to load one file from
188     * another file, this should return an array structured as follows:
189     * ```
190     * [
191     *     'files' => [
192     *         'file1.js' => [ 'type' => 'script', 'content' => 'JS code' ],
193     *         'file2.js' => [ 'type' => 'script', 'content' => 'JS code' ],
194     *         'data.json' => [ 'type' => 'data', 'content' => array ]
195     *     ],
196     *     'main' => 'file1.js'
197     * ]
198     * ```
199     * For plain concatenated scripts, this can either return a string, or an
200     * associative array similar to the one used for package files:
201     * ```
202     * [
203     *     'plainScripts' => [
204     *         [ 'content' => 'JS code' ],
205     *         [ 'content' => 'JS code' ],
206     *     ],
207     * ]
208     * ```
209     * @stable to override
210     * @param Context $context
211     * @return string|array JavaScript code (string), or multi-file array with the
212     *   following keys:
213     *   - files: An associative array mapping file name to file info structure
214     *   - main: The name of the main script, a key in the files array
215     *   - plainScripts: An array of file info structures to be concatenated and
216     *     executed when the module is loaded.
217     *   Each file info structure has the following keys:
218     *   - type: May be "script", "script-vue" or "data". Optional, default "script".
219     *   - content: The string content of the file
220     *   - filePath: A FilePath object describing the location of the source file.
221     *     This will be used to construct the source map during minification.
222     */
223    public function getScript( Context $context ) {
224        // Stub, override expected
225        return '';
226    }
227
228    /**
229     * Takes named templates by the module and returns an array mapping.
230     *
231     * @stable to override
232     * @return string[] Array of templates mapping template alias to content
233     */
234    public function getTemplates() {
235        // Stub, override expected.
236        return [];
237    }
238
239    /**
240     * @return Config
241     * @since 1.24
242     */
243    public function getConfig() {
244        if ( $this->config === null ) {
245            throw new RuntimeException( 'Config accessed before it is set' );
246        }
247
248        return $this->config;
249    }
250
251    /**
252     * @param Config $config
253     * @since 1.24
254     */
255    public function setConfig( Config $config ) {
256        $this->config = $config;
257    }
258
259    /**
260     * @since 1.27
261     * @param LoggerInterface $logger
262     */
263    public function setLogger( LoggerInterface $logger ): void {
264        $this->logger = $logger;
265    }
266
267    /**
268     * @since 1.27
269     * @return LoggerInterface
270     */
271    protected function getLogger(): LoggerInterface {
272        if ( !$this->logger ) {
273            $this->logger = new NullLogger();
274        }
275        return $this->logger;
276    }
277
278    /**
279     * @internal For use only by ResourceLoader::getModule
280     * @param HookContainer $hookContainer
281     */
282    public function setHookContainer( HookContainer $hookContainer ): void {
283        $this->hookRunner = new HookRunner( $hookContainer );
284    }
285
286    /**
287     * Get a HookRunner for running core hooks.
288     *
289     * @internal For use only within core Module subclasses. Hook interfaces may be removed
290     *   without notice.
291     * @return HookRunner
292     */
293    protected function getHookRunner(): HookRunner {
294        return $this->hookRunner;
295    }
296
297    /**
298     * Whether this module supports URL loading. If this function returns false,
299     * getScript() will be used even in cases (debug mode, no only param) where
300     * getScriptURLsForDebug() would normally be used instead.
301     *
302     * @stable to override
303     * @return bool
304     */
305    public function supportsURLLoading() {
306        return true;
307    }
308
309    /**
310     * Get all CSS for this module for a given skin.
311     *
312     * @stable to override
313     * @param Context $context
314     * @return array List of CSS strings or array of CSS strings keyed by media type.
315     *  like [ 'screen' => '.foo { width: 0 }' ];
316     *  or [ 'screen' => [ '.foo { width: 0 }' ] ];
317     */
318    public function getStyles( Context $context ) {
319        // Stub, override expected
320        return [];
321    }
322
323    /**
324     * Get the URL or URLs to load for this module's CSS in debug mode.
325     * The default behavior is to return a load.php?only=styles URL for
326     * the module, but file-based modules will want to override this to
327     * load the files directly
328     *
329     * This function must only be called when:
330     *
331     * 1. We're in debug mode,
332     * 2. There is no `only=` parameter and,
333     * 3. self::supportsURLLoading() returns true.
334     *
335     *
336     * @stable to override
337     * @param Context $context
338     * @return array [ mediaType => [ URL1, URL2, ... ], ... ]
339     */
340    public function getStyleURLsForDebug( Context $context ) {
341        $resourceLoader = $context->getResourceLoader();
342        $derivative = new DerivativeContext( $context );
343        $derivative->setModules( [ $this->getName() ] );
344        $derivative->setOnly( 'styles' );
345
346        $url = $resourceLoader->createLoaderURL(
347            $this->getSource(),
348            $derivative
349        );
350
351        return [ 'all' => [ $url ] ];
352    }
353
354    /**
355     * Get the messages needed for this module.
356     *
357     * To get a JSON blob with messages, use MessageBlobStore::get()
358     *
359     * @stable to override
360     * @return string[] List of message keys. Keys may occur more than once
361     */
362    public function getMessages() {
363        // Stub, override expected
364        return [];
365    }
366
367    /**
368     * Specifies the group this module is in.
369     *
370     * Return one of the Module::GROUP_ constants for reserved group names with special behavior,
371     * or a freeform string.
372     * Refer to https://www.mediawiki.org/wiki/ResourceLoader/Architecture#Groups for documentation.
373     *
374     * @stable to override
375     * @return string|null Group name
376     */
377    public function getGroup() {
378        // Stub, override expected
379        return null;
380    }
381
382    /**
383     * Get the source of this module. Should only be overridden for foreign modules.
384     *
385     * @stable to override
386     * @return string Source name, 'local' for local modules
387     */
388    public function getSource() {
389        // Stub, override expected
390        return 'local';
391    }
392
393    /**
394     * Get a list of modules this module depends on.
395     *
396     * Dependency information is taken into account when loading a module
397     * on the client side.
398     *
399     * Note: It is expected that $context will be made non-optional in the near
400     * future.
401     *
402     * @stable to override
403     * @param Context|null $context
404     * @return string[] List of module names as strings
405     */
406    public function getDependencies( ?Context $context = null ) {
407        // Stub, override expected
408        return [];
409    }
410
411    /**
412     * Get list of skins for which this module must be available to load.
413     *
414     * By default, modules are available to all skins.
415     *
416     * This information may be used by the startup module to optimise registrations
417     * based on the current skin.
418     *
419     * @stable to override
420     * @since 1.39
421     * @return string[]|null
422     */
423    public function getSkins(): ?array {
424        return $this->skins;
425    }
426
427    /**
428     * Get the module's load type.
429     *
430     * @stable to override
431     * @since 1.28
432     * @return string Module LOAD_* constant
433     */
434    public function getType() {
435        return self::LOAD_GENERAL;
436    }
437
438    /**
439     * Get the skip function.
440     *
441     * Modules that provide fallback functionality can provide a "skip function". This
442     * function, if provided, will be passed along to the module registry on the client.
443     * When this module is loaded (either directly or as a dependency of another module),
444     * then this function is executed first. If the function returns true, the module will
445     * instantly be considered "ready" without requesting the associated module resources.
446     *
447     * The value returned here must be valid javascript for execution in a private function.
448     * It must not contain the "function () {" and "}" wrapper though.
449     *
450     * @stable to override
451     * @return string|null A JavaScript function body returning a boolean value, or null
452     */
453    public function getSkipFunction() {
454        return null;
455    }
456
457    /**
458     * Whether the module requires ES6 support in the client.
459     *
460     * If the client does not support ES6, attempting to load a module that requires ES6 will
461     * result in an error.
462     *
463     * @deprecated since 1.41, ignored by ResourceLoader
464     * @since 1.36
465     * @return bool
466     */
467    public function requiresES6() {
468        return true;
469    }
470
471    /**
472     * Get the indirect dependencies for this module pursuant to the skin/language context
473     *
474     * These are only image files referenced by the module's stylesheet
475     *
476     * If neither setFileDependencies() nor setDependencyAccessCallbacks() was called,
477     * this will simply return a placeholder with an empty file list
478     *
479     * @see Module::setFileDependencies()
480     * @see Module::saveFileDependencies()
481     * @param Context $context
482     * @return string[] List of relative file paths
483     */
484    protected function getFileDependencies( Context $context ) {
485        $variant = self::getVary( $context );
486
487        if ( !isset( $this->fileDeps[$variant] ) ) {
488            $depStore = $context->getResourceLoader()->getDependencyStore();
489            $moduleName = $this->getName();
490            $styleDependencies = $depStore->retrieve( "$moduleName|$variant" );
491            $this->fileDeps[$variant] = $styleDependencies['paths'];
492        }
493
494        return $this->fileDeps[$variant];
495    }
496
497    /**
498     * Set the indirect dependencies for this module pursuant to the skin/language context
499     *
500     * These are only image files referenced by the module's stylesheet
501     *
502     * @see Module::getFileDependencies()
503     * @see Module::saveFileDependencies()
504     * @param Context $context
505     * @param string[] $paths List of relative file paths
506     */
507    public function setFileDependencies( Context $context, array $paths ) {
508        $variant = self::getVary( $context );
509        $this->fileDeps[$variant] = $paths;
510    }
511
512    /**
513     * Save the indirect dependencies for this module pursuant to the skin/language context
514     *
515     * @param Context $context
516     * @param string[] $curFileRefs List of newly computed indirect file dependencies
517     * @since 1.27
518     */
519    protected function saveFileDependencies( Context $context, array $curFileRefs ) {
520        // Pitfalls and performance considerations:
521        // 1. Don't keep updating the tracked paths due to duplicates or sorting.
522        // 2. Use relative paths to avoid ghost entries when $IP changes. (T111481)
523        // 3. Don't needlessly replace tracked paths with the same value
524        //    just because $IP changed (e.g. when upgrading a wiki).
525        // 4. Don't create an endless replace loop on every request for this
526        //    module when '../' is used anywhere. Even though both are expanded
527        //    (one expanded by getFileDependencies from the DB, the other is
528        //    still raw as originally read by RL), the latter has not
529        //    been normalized yet.
530
531        $paths = self::getRelativePaths( $curFileRefs );
532        $priorPaths = $this->getFileDependencies( $context );
533
534        if ( array_diff( $paths, $priorPaths ) || array_diff( $priorPaths, $paths ) ) {
535            $depStore = $context->getResourceLoader()->getDependencyStore();
536            $variant = self::getVary( $context );
537            $moduleName = $this->getName();
538            $depStore->storeMulti( [ "$moduleName|$variant" => $paths ] );
539        }
540    }
541
542    /**
543     * Make file paths relative to MediaWiki directory.
544     *
545     * This is used to make file paths safe for storing in a database without the paths
546     * becoming stale or incorrect when MediaWiki is moved or upgraded (T111481).
547     *
548     * @since 1.27
549     * @param array $filePaths
550     * @return array
551     */
552    public static function getRelativePaths( array $filePaths ) {
553        global $IP;
554        return array_map( static function ( $path ) use ( $IP ) {
555            return RelPath::getRelativePath( $path, $IP );
556        }, $filePaths );
557    }
558
559    /**
560     * Expand directories relative to $IP.
561     *
562     * @since 1.27
563     * @param array $filePaths
564     * @return array
565     */
566    public static function expandRelativePaths( array $filePaths ) {
567        global $IP;
568        return array_map( static function ( $path ) use ( $IP ) {
569            return RelPath::joinPath( $IP, $path );
570        }, $filePaths );
571    }
572
573    /**
574     * Get the hash of the message blob.
575     *
576     * @stable to override
577     * @since 1.27
578     * @param Context $context
579     * @return string|null JSON blob or null if module has no messages
580     * @return-taint none -- do not propagate taint from $context->getLanguage()
581     */
582    protected function getMessageBlob( Context $context ) {
583        if ( !$this->getMessages() ) {
584            // Don't bother consulting MessageBlobStore
585            return null;
586        }
587        // Message blobs may only vary language, not by context keys
588        $lang = $context->getLanguage();
589        if ( !isset( $this->msgBlobs[$lang] ) ) {
590            $this->getLogger()->warning( 'Message blob for {module} should have been preloaded', [
591                'module' => $this->getName(),
592            ] );
593            $store = $context->getResourceLoader()->getMessageBlobStore();
594            $this->msgBlobs[$lang] = $store->getBlob( $this, $lang );
595        }
596        return $this->msgBlobs[$lang];
597    }
598
599    /**
600     * Set in-object cache for message blobs.
601     *
602     * Used to allow fetching of message blobs in batches. See ResourceLoader::preloadModuleInfo().
603     *
604     * @since 1.27
605     * @param string|null $blob JSON blob or null
606     * @param string $lang Language code
607     */
608    public function setMessageBlob( $blob, $lang ) {
609        $this->msgBlobs[$lang] = $blob;
610    }
611
612    /**
613     * Get headers to send as part of a module web response.
614     *
615     * It is not supported to send headers through this method that are
616     * required to be unique or otherwise sent once in an HTTP response
617     * because clients may make batch requests for multiple modules (as
618     * is the default behaviour for ResourceLoader clients).
619     *
620     * For exclusive or aggregated headers, see ResourceLoader::sendResponseHeaders().
621     *
622     * @since 1.30
623     * @param Context $context
624     * @return string[] Array of HTTP response headers
625     */
626    final public function getHeaders( Context $context ) {
627        $formattedLinks = [];
628        foreach ( $this->getPreloadLinks( $context ) as $url => $attribs ) {
629            $link = "<{$url}>;rel=preload";
630            foreach ( $attribs as $key => $val ) {
631                $link .= ";{$key}={$val}";
632            }
633            $formattedLinks[] = $link;
634        }
635        if ( $formattedLinks ) {
636            return [ 'Link: ' . implode( ',', $formattedLinks ) ];
637        }
638        return [];
639    }
640
641    /**
642     * Get a list of resources that web browsers may preload.
643     *
644     * Behaviour of rel=preload link is specified at <https://www.w3.org/TR/preload/>.
645     *
646     * Use case for ResourceLoader originally part of T164299.
647     *
648     * @par Example
649     * @code
650     *     protected function getPreloadLinks() {
651     *         return [
652     *             'https://example.org/script.js' => [ 'as' => 'script' ],
653     *             'https://example.org/image.png' => [ 'as' => 'image' ],
654     *         ];
655     *     }
656     * @endcode
657     *
658     * @par Example using HiDPI image variants
659     * @code
660     *     protected function getPreloadLinks() {
661     *         return [
662     *             'https://example.org/logo.png' => [
663     *                 'as' => 'image',
664     *                 'media' => 'not all and (min-resolution: 2dppx)',
665     *             ],
666     *             'https://example.org/logo@2x.png' => [
667     *                 'as' => 'image',
668     *                 'media' => '(min-resolution: 2dppx)',
669     *             ],
670     *         ];
671     *     }
672     * @endcode
673     *
674     * @see Module::getHeaders
675     *
676     * @stable to override
677     * @since 1.30
678     * @param Context $context
679     * @return array Keyed by url, values must be an array containing
680     *  at least an 'as' key. Optionally a 'media' key as well.
681     */
682    protected function getPreloadLinks( Context $context ) {
683        return [];
684    }
685
686    /**
687     * Get module-specific LESS variables, if any.
688     *
689     * @stable to override
690     * @since 1.27
691     * @param Context $context
692     * @return array Module-specific LESS variables.
693     */
694    protected function getLessVars( Context $context ) {
695        return [];
696    }
697
698    /**
699     * Get an array of this module's resources. Ready for serving to the web.
700     *
701     * @since 1.26
702     * @param Context $context
703     * @return array
704     */
705    public function getModuleContent( Context $context ) {
706        $contextHash = $context->getHash();
707        // Cache this expensive operation. This calls builds the scripts, styles, and messages
708        // content which typically involves filesystem and/or database access.
709        if ( !array_key_exists( $contextHash, $this->contents ) ) {
710            $this->contents[$contextHash] = $this->buildContent( $context );
711        }
712        return $this->contents[$contextHash];
713    }
714
715    /**
716     * Bundle all resources attached to this module into an array.
717     *
718     * @since 1.26
719     * @param Context $context
720     * @return array
721     */
722    final protected function buildContent( Context $context ) {
723        $statsFactory = MediaWikiServices::getInstance()->getStatsFactory();
724        $timer = $statsFactory->getTiming( 'resourceloader_build_seconds' )
725            ->setLabel( 'name', strtr( $this->getName(), '.', '_' ) )
726            ->start();
727
728        // This MUST build both scripts and styles, regardless of whether $context->getOnly()
729        // is 'scripts' or 'styles' because the result is used by getVersionHash which
730        // must be consistent regardless of the 'only' filter on the current request.
731        // Also, when introducing new module content resources (e.g. templates, headers),
732        // these should only be included in the array when they are non-empty so that
733        // existing modules not using them do not get their cache invalidated.
734        $content = [];
735
736        // Scripts
737        $scripts = $this->getScript( $context );
738        if ( is_string( $scripts ) ) {
739            $scripts = [ 'plainScripts' => [ [ 'content' => $scripts ] ] ];
740        }
741        $content['scripts'] = $scripts;
742
743        $styles = [];
744        // Don't create empty stylesheets like [ '' => '' ] for modules
745        // that don't *have* any stylesheets (T40024).
746        $stylePairs = $this->getStyles( $context );
747        if ( count( $stylePairs ) ) {
748            // If we are in debug mode without &only= set, we'll want to return an array of URLs
749            // See comment near shouldIncludeScripts() for more details
750            if ( $context->getDebug() && !$context->getOnly() && $this->supportsURLLoading() ) {
751                $styles = [
752                    'url' => $this->getStyleURLsForDebug( $context )
753                ];
754            } else {
755                // Minify CSS before embedding in mw.loader.impl call
756                // (unless in debug mode)
757                if ( !$context->getDebug() ) {
758                    foreach ( $stylePairs as $media => $style ) {
759                        // Can be either a string or an array of strings.
760                        if ( is_array( $style ) ) {
761                            $stylePairs[$media] = [];
762                            foreach ( $style as $cssText ) {
763                                if ( is_string( $cssText ) ) {
764                                    $stylePairs[$media][] =
765                                        ResourceLoader::filter( 'minify-css', $cssText );
766                                }
767                            }
768                        } elseif ( is_string( $style ) ) {
769                            $stylePairs[$media] = ResourceLoader::filter( 'minify-css', $style );
770                        }
771                    }
772                }
773                // Wrap styles into @media groups as needed and flatten into a numerical array
774                $styles = [
775                    'css' => ResourceLoader::makeCombinedStyles( $stylePairs, $context->getRequest() )
776                ];
777            }
778        }
779        $content['styles'] = $styles;
780
781        // Messages
782        $blob = $this->getMessageBlob( $context );
783        if ( $blob ) {
784            $content['messagesBlob'] = $blob;
785        }
786
787        $templates = $this->getTemplates();
788        if ( $templates ) {
789            $content['templates'] = $templates;
790        }
791
792        $headers = $this->getHeaders( $context );
793        if ( $headers ) {
794            $content['headers'] = $headers;
795        }
796
797        $deprecationWarning = $this->getDeprecationWarning();
798        if ( $deprecationWarning !== null ) {
799            $content['deprecationWarning'] = $deprecationWarning;
800        }
801
802        $timer->stop();
803
804        return $content;
805    }
806
807    /**
808     * Get a string identifying the current version of this module in a given context.
809     *
810     * Whenever anything happens that changes the module's response (e.g. scripts, styles, and
811     * messages) this value must change. This value is used to store module responses in caches,
812     * both server-side (by a CDN, or other HTTP cache), and client-side (in `mw.loader.store`,
813     * and in the browser's own HTTP cache).
814     *
815     * The underlying methods called here for any given module should be quick because this
816     * is called for potentially thousands of module bundles in the same request as part of the
817     * StartUpModule, which is how we invalidate caches and propagate changes to clients.
818     *
819     * @since 1.26
820     * @see self::getDefinitionSummary for how to customize version computation.
821     * @param Context $context
822     * @return string Hash formatted by ResourceLoader::makeHash
823     */
824    final public function getVersionHash( Context $context ) {
825        if ( $context->getDebug() ) {
826            // In debug mode, make uncached startup module extra fast by not computing any hashes.
827            // Server responses from load.php for individual modules already have no-cache so
828            // we don't need them. This also makes breakpoint debugging easier, as each module
829            // gets its own consistent URL. (T235672)
830            return '';
831        }
832
833        // Cache this somewhat expensive operation. Especially because some classes
834        // (e.g. startup module) iterate more than once over all modules to get versions.
835        $contextHash = $context->getHash();
836        if ( !array_key_exists( $contextHash, $this->versionHash ) ) {
837            if ( $this->enableModuleContentVersion() ) {
838                // Detect changes directly by hashing the module contents.
839                $str = json_encode( $this->getModuleContent( $context ) );
840            } else {
841                // Infer changes based on definition and other metrics
842                $summary = $this->getDefinitionSummary( $context );
843                if ( !isset( $summary['_class'] ) ) {
844                    throw new LogicException( 'getDefinitionSummary must call parent method' );
845                }
846                $str = json_encode( $summary );
847            }
848
849            $this->versionHash[$contextHash] = ResourceLoader::makeHash( $str );
850        }
851        return $this->versionHash[$contextHash];
852    }
853
854    /**
855     * Whether to generate version hash based on module content.
856     *
857     * If a module requires database or file system access to build the module
858     * content, consider disabling this in favour of manually tracking relevant
859     * aspects in getDefinitionSummary(). See getVersionHash() for how this is used.
860     *
861     * @stable to override
862     * @return bool
863     */
864    public function enableModuleContentVersion() {
865        return false;
866    }
867
868    /**
869     * Get the definition summary for this module.
870     *
871     * This is the method subclasses are recommended to use to track data that
872     * should influence the module's version hash.
873     *
874     * Subclasses must call the parent getDefinitionSummary() and add to the
875     * returned array. It is recommended that each subclass appends its own array,
876     * to prevent clashes or accidental overwrites of array keys from the parent
877     * class. This gives each subclass a clean scope.
878     *
879     * @code
880     *     $summary = parent::getDefinitionSummary( $context );
881     *     $summary[] = [
882     *         'foo' => 123,
883     *         'bar' => 'quux',
884     *     ];
885     *     return $summary;
886     * @endcode
887     *
888     * Return an array that contains all significant properties that define the
889     * module. The returned data should be deterministic and only change when
890     * the generated module response would change. Prefer content hashes over
891     * modified timestamps because timestamps may change for unrelated reasons
892     * and are not deterministic (T102578). For example, because timestamps are
893     * not stored in Git, each branch checkout would cause all files to appear as
894     * new. Timestamps also tend to not match between servers causing additional
895     * ever-lasting churning of the version hash.
896     *
897     * Be careful not to normalise the data too much in an effort to be deterministic.
898     * For example, if a module concatenates files together (order is significant),
899     * then the definition summary could be a list of file names, and a list of
900     * file hashes. These lists should not be sorted as that would mean the cache
901     * is not invalidated when the order changes (T39812).
902     *
903     * This data structure must exclusively contain primitive "scalar" values,
904     * as it will be serialised using `json_encode`.
905     *
906     * @stable to override
907     * @since 1.23
908     * @param Context $context
909     * @return array|null
910     */
911    public function getDefinitionSummary( Context $context ) {
912        return [
913            '_class' => static::class,
914            // Make sure that when filter cache for minification is invalidated,
915            // we also change the HTTP urls and mw.loader.store keys (T176884).
916            '_cacheVersion' => ResourceLoader::CACHE_VERSION,
917        ];
918    }
919
920    /**
921     * Check whether this module is known to be empty. If a child class
922     * has an easy and cheap way to determine that this module is
923     * definitely going to be empty, it should override this method to
924     * return true in that case. Callers may optimize the request for this
925     * module away if this function returns true.
926     *
927     * @stable to override
928     * @param Context $context
929     * @return bool
930     */
931    public function isKnownEmpty( Context $context ) {
932        return false;
933    }
934
935    /**
936     * Check whether this module should be embedded rather than linked
937     *
938     * Modules returning true here will be embedded rather than loaded by
939     * ClientHtml.
940     *
941     * @since 1.30
942     * @stable to override
943     * @param Context $context
944     * @return bool
945     */
946    public function shouldEmbedModule( Context $context ) {
947        return $this->getGroup() === self::GROUP_PRIVATE;
948    }
949
950    /**
951     * Whether to skip the structure test ResourcesTest::testRespond() for this
952     * module.
953     *
954     * @since 1.42
955     * @stable to override
956     * @return bool
957     */
958    public function shouldSkipStructureTest() {
959        return $this->getGroup() === self::GROUP_PRIVATE;
960    }
961
962    /**
963     * Validate a user-provided JavaScript blob.
964     *
965     * @param string $fileName Page title
966     * @param string $contents JavaScript code
967     * @return string JavaScript code, either the original content or a replacement
968     *  that uses `mw.log.error()` to communicate a syntax error.
969     */
970    protected function validateScriptFile( $fileName, $contents ) {
971        if ( !$this->getConfig()->get( MainConfigNames::ResourceLoaderValidateJS ) ) {
972            return $contents;
973        }
974        $cache = MediaWikiServices::getInstance()->getMainWANObjectCache();
975        // Cache potentially slow parsing of JavaScript code during the critical path.
976        // This happens during load.php requests for modules=site, modules=user, and Gadgets.
977        $error = $cache->getWithSetCallback(
978            // A content hash is included in the cache key so that this is immediately
979            // correct and re-computed after edits without relying on TTL or purges.
980            //
981            // We avoid accidental or abusive conflicts with other pages by including the
982            // wiki (makeKey vs makeGlobalKey) and page, because hashes are not unique.
983            $cache->makeKey(
984                'resourceloader-userjsparse',
985                self::USERJSPARSE_CACHE_VERSION,
986                md5( $contents ),
987                $fileName
988            ),
989            $cache::TTL_WEEK,
990            static function () use ( $contents ) {
991                try {
992                    Peast::ES2019( $contents )->parse();
993                } catch ( PeastSyntaxException $e ) {
994                    return $e->getMessage() . " on line " . $e->getPosition()->getLine();
995                }
996                // Cache success as null
997                return null;
998            }
999        );
1000
1001        if ( $error ) {
1002            // Send the error to the browser console client-side.
1003            // By returning this as replacement for the actual script,
1004            // we ensure user-provided scripts are safe to serve to a browser,
1005            // without breaking unrelated modules in the same response.
1006            return 'mw.log.error(' .
1007                json_encode(
1008                    "Parse error: $error in $fileName"
1009                ) .
1010                ');';
1011        }
1012        return $contents;
1013    }
1014
1015    /**
1016     * @param Context $context
1017     * @param string $content
1018     * @return array
1019     * @throws InvalidArgumentException If the input is invalid
1020     */
1021    protected function parseVueContent( Context $context, string $content ): array {
1022        $this->vueComponentParser ??= new VueComponentParser;
1023        $parsedComponent = $this->vueComponentParser->parse(
1024            $content,
1025            [ 'minifyTemplate' => !$context->getDebug() ]
1026        );
1027        $encodedTemplate = json_encode( $parsedComponent['template'] );
1028        if ( $context->getDebug() ) {
1029            // Replace \n (backslash-n) with space + backslash-n + backslash-newline in debug mode
1030            // The \n has to be preserved to prevent Vue parser issues (T351771)
1031            // We only replace \n if not preceded by a backslash, to avoid breaking '\\n'
1032            $encodedTemplate = preg_replace( '/(?<!\\\\)\\\\n/', " \\n\\\n", $encodedTemplate );
1033            // Expand \t to real tabs in debug mode
1034            $encodedTemplate = strtr( $encodedTemplate, [ "\\t" => "\t" ] );
1035        }
1036        return [
1037            'script' => $parsedComponent['script'] .
1038                ";\nmodule.exports.template = $encodedTemplate;",
1039            'style' => $parsedComponent['style'] ?? '',
1040            'styleLang' => $parsedComponent['styleLang'] ?? 'css'
1041        ];
1042    }
1043
1044    /**
1045     * Compute a non-cryptographic string hash of a file's contents.
1046     * If the file does not exist or cannot be read, returns an empty string.
1047     *
1048     * @since 1.26 Uses MD4 instead of SHA1.
1049     * @param string $filePath
1050     * @return string Hash
1051     */
1052    protected static function safeFileHash( $filePath ) {
1053        return FileContentsHasher::getFileContentsHash( $filePath );
1054    }
1055
1056    /**
1057     * Get vary string.
1058     *
1059     * @internal For internal use only.
1060     * @param Context $context
1061     * @return string
1062     */
1063    public static function getVary( Context $context ) {
1064        return implode( '|', [
1065            $context->getSkin(),
1066            $context->getLanguage(),
1067        ] );
1068    }
1069}