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