Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
79.32% covered (warning)
79.32%
468 / 590
52.08% covered (warning)
52.08%
25 / 48
CRAP
0.00% covered (danger)
0.00%
0 / 1
FileModule
79.46% covered (warning)
79.46%
468 / 589
52.08% covered (warning)
52.08%
25 / 48
673.75
0.00% covered (danger)
0.00%
0 / 1
 __construct
77.78% covered (warning)
77.78%
49 / 63
0.00% covered (danger)
0.00%
0 / 1
46.69
 extractBasePaths
63.16% covered (warning)
63.16%
12 / 19
0.00% covered (danger)
0.00%
0 / 1
9.45
 getScript
100.00% covered (success)
100.00%
18 / 18
100.00% covered (success)
100.00%
1 / 1
3
 supportsURLLoading
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
12
 shouldSkipStructureTest
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
6
 hasGeneratedScripts
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
42
 getStyles
47.06% covered (danger)
47.06%
8 / 17
0.00% covered (danger)
0.00%
0 / 1
6.37
 getStyleURLsForDebug
90.91% covered (success)
90.91%
10 / 11
0.00% covered (danger)
0.00%
0 / 1
4.01
 getMessages
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 pluckFromMessageBlob
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
2
 getMessageBlob
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
4
 wrapAndEscapeMessage
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getLessVars
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
3
 getGroup
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getDependencies
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getFileContents
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 getSkipFunction
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
6
 requiresES6
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 enableModuleContentVersion
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getFileHashes
60.87% covered (warning)
60.87%
14 / 23
0.00% covered (danger)
0.00%
0 / 1
18.25
 getDefinitionSummary
93.94% covered (success)
93.94%
31 / 33
0.00% covered (danger)
0.00%
0 / 1
6.01
 getPath
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 getLocalPath
80.00% covered (warning)
80.00%
4 / 5
0.00% covered (danger)
0.00%
0 / 1
3.07
 getRemotePath
71.43% covered (warning)
71.43%
5 / 7
0.00% covered (danger)
0.00%
0 / 1
4.37
 getStyleSheetLang
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
2
 getPackageFileType
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
3
 collateStyleFilesByMedia
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
4
 tryForKey
85.71% covered (warning)
85.71%
6 / 7
0.00% covered (danger)
0.00%
0 / 1
6.10
 getScriptFiles
100.00% covered (success)
100.00%
13 / 13
100.00% covered (success)
100.00%
1 / 1
4
 getLanguageScripts
33.33% covered (danger)
33.33%
4 / 12
0.00% covered (danger)
0.00%
0 / 1
12.41
 setSkinStylesOverride
58.82% covered (warning)
58.82%
10 / 17
0.00% covered (danger)
0.00%
0 / 1
10.42
 getStyleFiles
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
1
 getSkinStyleFiles
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 getAllSkinStyleFiles
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
2
 getAllStyleFiles
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
3
 readStyleFiles
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
4
 readStyleFile
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
1
 processStyle
95.00% covered (success)
95.00%
19 / 20
0.00% covered (danger)
0.00%
0 / 1
5
 getFlip
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
2
 getType
0.00% covered (danger)
0.00%
0 / 12
0.00% covered (danger)
0.00%
0 / 1
110
 compileLessString
97.30% covered (success)
97.30%
36 / 37
0.00% covered (danger)
0.00%
0 / 1
6
 getTemplates
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
3
 expandPackageFiles
100.00% covered (success)
100.00%
31 / 31
100.00% covered (success)
100.00%
1 / 1
12
 expandFileInfo
81.48% covered (warning)
81.48%
66 / 81
0.00% covered (danger)
0.00%
0 / 1
22.54
 makeFilePath
80.00% covered (warning)
80.00%
4 / 5
0.00% covered (danger)
0.00%
0 / 1
3.07
 getPackageFiles
90.91% covered (success)
90.91%
10 / 11
0.00% covered (danger)
0.00%
0 / 1
3.01
 readFileInfo
70.97% covered (warning)
70.97%
22 / 31
0.00% covered (danger)
0.00%
0 / 1
12.45
 stripBom
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
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 CSSJanus;
12use FileContentsHasher;
13use InvalidArgumentException;
14use LogicException;
15use MediaWiki\Language\LanguageFallbackMode;
16use MediaWiki\MainConfigNames;
17use MediaWiki\MediaWikiServices;
18use MediaWiki\Output\OutputPage;
19use MediaWiki\Registration\ExtensionRegistry;
20use RuntimeException;
21use Wikimedia\Minify\CSSMin;
22
23// Per https://phabricator.wikimedia.org/T241091
24// phpcs:disable MediaWiki.Commenting.FunctionAnnotations.UnrecognizedAnnotation
25
26/**
27 * Module based on local JavaScript/CSS files.
28 *
29 * The following public methods can query the database:
30 *
31 * - getDefinitionSummary / â€¦ / Module::getFileDependencies.
32 * - getVersionHash / getDefinitionSummary / â€¦ / Module::getFileDependencies.
33 * - getStyles / Module::saveFileDependencies.
34 *
35 * @ingroup ResourceLoader
36 * @see $wgResourceModules
37 * @since 1.17
38 */
39class FileModule extends Module {
40    /** @var string Local base path, see __construct() */
41    protected $localBasePath = '';
42
43    /** @var string Remote base path, see __construct() */
44    protected $remoteBasePath = '';
45
46    /**
47     * @var array<int,string|FilePath> List of JavaScript file paths to always include
48     */
49    protected $scripts = [];
50
51    /**
52     * @var array<string,array<int,string|FilePath>> Lists of JavaScript files by language code
53     */
54    protected $languageScripts = [];
55
56    /**
57     * @var array<string,array<int,string|FilePath>> Lists of JavaScript files by skin name
58     */
59    protected $skinScripts = [];
60
61    /**
62     * @var array<int,string|FilePath> List of paths to JavaScript files to include in debug mode
63     */
64    protected $debugScripts = [];
65
66    /**
67     * @var array<int,string|FilePath> List of CSS file files to always include
68     */
69    protected $styles = [];
70
71    /**
72     * @var array<string,array<int,string|FilePath>> Lists of CSS files by skin name
73     */
74    protected $skinStyles = [];
75
76    /**
77     * Packaged files definition, to bundle and make available client-side via `require()`.
78     *
79     * @see FileModule::expandPackageFiles()
80     * @var null|array
81     * @phan-var null|array<int,string|FilePath|array{main?:bool,name?:string,file?:string|FilePath,type?:string,content?:mixed,config?:array,callback?:callable,callbackParam?:mixed,versionCallback?:callable}>
82     */
83    protected $packageFiles = null;
84
85    /**
86     * @var array Expanded versions of $packageFiles, lazy-computed by expandPackageFiles();
87     *  keyed by context hash
88     */
89    private $expandedPackageFiles = [];
90
91    /**
92     * @var array Further expanded versions of $expandedPackageFiles, lazy-computed by
93     *   getPackageFiles(); keyed by context hash
94     */
95    private $fullyExpandedPackageFiles = [];
96
97    /**
98     * @var string[] List of modules this module depends on
99     */
100    protected $dependencies = [];
101
102    /**
103     * @var null|string File name containing the body of the skip function
104     */
105    protected $skipFunction = null;
106
107    /**
108     * @var string[] List of message keys used by this module
109     */
110    protected $messages = [];
111
112    /** @var array<int|string,string|FilePath> List of the named templates used by this module */
113    protected $templates = [];
114
115    /** @var null|string Name of group to load this module in */
116    protected $group = null;
117
118    /** @var bool Link to raw files in debug mode */
119    protected $debugRaw = true;
120
121    /** @var bool Whether CSSJanus flipping should be skipped for this module */
122    protected $noflip = false;
123
124    /** @var bool Whether to skip the structure test ResourcesTest::testRespond() */
125    protected $skipStructureTest = false;
126
127    /**
128     * @var bool Whether getStyleURLsForDebug should return raw file paths,
129     * or return load.php urls
130     */
131    protected $hasGeneratedStyles = false;
132
133    /**
134     * @var string[] Place where readStyleFile() tracks file dependencies
135     */
136    protected $localFileRefs = [];
137
138    /**
139     * @var string[] Place where readStyleFile() tracks file dependencies for non-existent files.
140     * Used in tests to detect missing dependencies.
141     */
142    protected $missingLocalFileRefs = [];
143
144    /** @var array */
145    protected $lessMessages = [];
146
147    /**
148     * Construct a new module from an options array.
149     *
150     * @param array $options See $wgResourceModules for the available options.
151     * @param string|null $localBasePath Base path to prepend to all local paths in $options.
152     *     Defaults to MW_INSTALL_PATH
153     * @param string|null $remoteBasePath Base path to prepend to all remote paths in $options.
154     *     Defaults to $wgResourceBasePath
155     */
156    public function __construct(
157        array $options = [],
158        ?string $localBasePath = null,
159        ?string $remoteBasePath = null
160    ) {
161        // Flag to decide whether to automagically add the mediawiki.template module
162        $hasTemplates = false;
163        // localBasePath and remoteBasePath both have unbelievably long fallback chains
164        // and need to be handled separately.
165        [ $this->localBasePath, $this->remoteBasePath ] =
166            self::extractBasePaths( $options, $localBasePath, $remoteBasePath );
167
168        // Extract, validate and normalise remaining options
169        foreach ( $options as $member => $option ) {
170            switch ( $member ) {
171                // Lists of file paths
172                case 'scripts':
173                case 'debugScripts':
174                case 'styles':
175                case 'packageFiles':
176                    $this->{$member} = is_array( $option ) ? $option : [ $option ];
177                    break;
178                case 'templates':
179                    $hasTemplates = true;
180                    $this->{$member} = is_array( $option ) ? $option : [ $option ];
181                    break;
182                // Collated lists of file paths
183                case 'languageScripts':
184                case 'skinScripts':
185                case 'skinStyles':
186                    if ( !is_array( $option ) ) {
187                        throw new InvalidArgumentException(
188                            "Invalid collated file path list error. " .
189                            "'$option' given, array expected."
190                        );
191                    }
192                    foreach ( $option as $key => $value ) {
193                        if ( !is_string( $key ) ) {
194                            throw new InvalidArgumentException(
195                                "Invalid collated file path list key error. " .
196                                "'$key' given, string expected."
197                            );
198                        }
199                        $this->{$member}[$key] = is_array( $value ) ? $value : [ $value ];
200                    }
201                    break;
202                case 'deprecated':
203                    $this->deprecated = $option;
204                    break;
205                // Lists of strings
206                case 'dependencies':
207                case 'messages':
208                case 'lessMessages':
209                    // Normalise
210                    $option = array_values( array_unique( (array)$option ) );
211                    sort( $option );
212
213                    $this->{$member} = $option;
214                    break;
215                // Single strings
216                case 'group':
217                case 'skipFunction':
218                    $this->{$member} = (string)$option;
219                    break;
220                // Single booleans
221                case 'debugRaw':
222                case 'noflip':
223                case 'skipStructureTest':
224                    $this->{$member} = (bool)$option;
225                    break;
226            }
227        }
228        if ( isset( $options['scripts'] ) && isset( $options['packageFiles'] ) ) {
229            throw new InvalidArgumentException( "A module may not set both 'scripts' and 'packageFiles'" );
230        }
231        if ( isset( $options['packageFiles'] ) && isset( $options['skinScripts'] ) ) {
232            throw new InvalidArgumentException( "Options 'skinScripts' and 'packageFiles' cannot be used together." );
233        }
234        if ( $hasTemplates ) {
235            $this->dependencies[] = 'mediawiki.template';
236            // Ensure relevant template compiler module gets loaded
237            foreach ( $this->templates as $alias => $templatePath ) {
238                if ( is_int( $alias ) ) {
239                    $alias = $this->getPath( $templatePath );
240                }
241                $suffix = explode( '.', $alias );
242                $suffix = end( $suffix );
243                $compilerModule = 'mediawiki.template.' . $suffix;
244                if ( $suffix !== 'html' && !in_array( $compilerModule, $this->dependencies ) ) {
245                    $this->dependencies[] = $compilerModule;
246                }
247            }
248        }
249    }
250
251    /**
252     * Extract a pair of local and remote base paths from module definition information.
253     * Implementation note: the amount of global state used in this function is staggering.
254     *
255     * @param array $options Module definition
256     * @param string|null $localBasePath Path to use if not provided in module definition. Defaults
257     *     to MW_INSTALL_PATH
258     * @param string|null $remoteBasePath Path to use if not provided in module definition. Defaults
259     *     to $wgResourceBasePath
260     * @return string[] [ localBasePath, remoteBasePath ]
261     */
262    public static function extractBasePaths(
263        array $options = [],
264        $localBasePath = null,
265        $remoteBasePath = null
266    ) {
267        // The different ways these checks are done, and their ordering, look very silly,
268        // but were preserved for backwards-compatibility just in case. Tread lightly.
269
270        $remoteBasePath ??= MediaWikiServices::getInstance()->getMainConfig()
271            ->get( MainConfigNames::ResourceBasePath );
272
273        if ( isset( $options['remoteExtPath'] ) ) {
274            $extensionAssetsPath = MediaWikiServices::getInstance()->getMainConfig()
275                ->get( MainConfigNames::ExtensionAssetsPath );
276            $remoteBasePath = $extensionAssetsPath . '/' . $options['remoteExtPath'];
277        }
278
279        if ( isset( $options['remoteSkinPath'] ) ) {
280            $stylePath = MediaWikiServices::getInstance()->getMainConfig()
281                ->get( MainConfigNames::StylePath );
282            $remoteBasePath = $stylePath . '/' . $options['remoteSkinPath'];
283        }
284
285        if ( array_key_exists( 'localBasePath', $options ) ) {
286            $localBasePath = (string)$options['localBasePath'];
287        }
288
289        if ( array_key_exists( 'remoteBasePath', $options ) ) {
290            $remoteBasePath = (string)$options['remoteBasePath'];
291        }
292
293        if ( $localBasePath === null ) {
294            $localBasePath = MW_INSTALL_PATH;
295        }
296
297        if ( $remoteBasePath === '' ) {
298            // If MediaWiki is installed at the document root (not recommended),
299            // then wgScriptPath is set to the empty string by the installer to
300            // ensure safe concatenating of file paths (avoid "/" + "/foo" being "//foo").
301            // However, this also means the path itself can be an invalid URI path,
302            // as those must start with a slash. Within ResourceLoader, we will not
303            // do such primitive/unsafe slash concatenation and use URI resolution
304            // instead, so beyond this point, to avoid fatal errors in CSSMin::resolveUrl(),
305            // do a best-effort support for docroot installs by casting this to a slash.
306            $remoteBasePath = '/';
307        }
308
309        return [ $localBasePath, $remoteBasePath ];
310    }
311
312    /** @inheritDoc */
313    public function getScript( Context $context ) {
314        $packageFiles = $this->getPackageFiles( $context );
315        if ( $packageFiles !== null ) {
316            // T402278: use array_map() to avoid &references here
317            $packageFiles['files'] = array_map(
318                static function ( array $file ): array {
319                    if ( $file['type'] === 'script+style' ) {
320                        $file['content'] = $file['content']['script'];
321                        $file['type'] = 'script';
322                    }
323                    return $file;
324                },
325                $packageFiles['files']
326            );
327            return $packageFiles;
328        }
329
330        $files = $this->getScriptFiles( $context );
331        // T402278: use array_map() to avoid &references here
332        $files = array_map(
333            fn ( $file ) => $this->readFileInfo( $context, $file ),
334            $files
335        );
336        return [ 'plainScripts' => $files ];
337    }
338
339    /**
340     * @return bool
341     */
342    public function supportsURLLoading() {
343        // phpcs:ignore Generic.WhiteSpace.LanguageConstructSpacing.IncorrectSingle
344        return
345            // Denied by options?
346            $this->debugRaw
347            // If package files are involved, don't support URL loading, because that breaks
348            // scoped require() functions
349            && !$this->packageFiles
350            // Can't link to scripts generated by callbacks
351            && !$this->hasGeneratedScripts();
352    }
353
354    /** @inheritDoc */
355    public function shouldSkipStructureTest() {
356        return $this->skipStructureTest || parent::shouldSkipStructureTest();
357    }
358
359    /**
360     * Determine whether the module may potentially have generated scripts.
361     *
362     * @return bool
363     */
364    private function hasGeneratedScripts() {
365        foreach (
366            [ $this->scripts, $this->languageScripts, $this->skinScripts, $this->debugScripts ]
367            as $scripts
368        ) {
369            foreach ( $scripts as $script ) {
370                if ( is_array( $script ) ) {
371                    if ( isset( $script['callback'] ) || isset( $script['versionCallback'] ) ) {
372                        return true;
373                    }
374                }
375            }
376        }
377        return false;
378    }
379
380    /**
381     * Get all styles for a given context.
382     *
383     * @param Context $context
384     * @return string[] CSS code for $context as an associative array mapping media type to CSS text.
385     */
386    public function getStyles( Context $context ) {
387        $styles = $this->readStyleFiles(
388            $this->getStyleFiles( $context ),
389            $context
390        );
391
392        $packageFiles = $this->getPackageFiles( $context );
393        if ( $packageFiles !== null ) {
394            foreach ( $packageFiles['files'] as $fileName => $file ) {
395                if ( $file['type'] === 'script+style' ) {
396                    $style = $this->processStyle(
397                        $file['content']['style'],
398                        $file['content']['styleLang'],
399                        $fileName,
400                        $context
401                    );
402                    $styles['all'] = ( $styles['all'] ?? '' ) . "\n" . $style;
403                }
404            }
405        }
406
407        // Track indirect file dependencies so that StartUpModule can check for
408        // on-disk file changes to any of this files without having to recompute the file list
409        $this->saveFileDependencies( $context, $this->localFileRefs );
410
411        return $styles;
412    }
413
414    /**
415     * @param Context $context
416     * @return string[][] Lists of URLs by media type
417     */
418    public function getStyleURLsForDebug( Context $context ) {
419        if ( $this->hasGeneratedStyles ) {
420            // Do the default behaviour of returning a url back to load.php
421            // but with only=styles.
422            return parent::getStyleURLsForDebug( $context );
423        }
424        // Our module consists entirely of real css files,
425        // in debug mode we can load those directly.
426        $urls = [];
427        foreach ( $this->getStyleFiles( $context ) as $mediaType => $list ) {
428            $urls[$mediaType] = [];
429            foreach ( $list as $file ) {
430                $urls[$mediaType][] = OutputPage::transformResourcePath(
431                    $this->getConfig(),
432                    $this->getRemotePath( $file )
433                );
434            }
435        }
436        return $urls;
437    }
438
439    /**
440     * Get message keys used by this module.
441     *
442     * @return string[] List of message keys
443     */
444    public function getMessages() {
445        return array_merge( $this->messages, $this->lessMessages );
446    }
447
448    /**
449     * Return a subset of messages from a JSON string representation.
450     *
451     * @param string|null $blob JSON, or null if module has no declared messages
452     * @param string[] $allowed
453     * @return array
454     */
455    private function pluckFromMessageBlob( $blob, array $allowed ): array {
456        $data = $blob ? json_decode( $blob, true ) : [];
457        // Keep only the messages intended for script or Less export
458        // (opposite of getMessages essentially).
459        return array_intersect_key( $data, array_fill_keys( $allowed, true ) );
460    }
461
462    /**
463     * @inheritDoc
464     */
465    protected function getMessageBlob( Context $context ) {
466        $blob = parent::getMessageBlob( $context );
467        if ( !$blob ) {
468            // If module has no blob, preserve null to avoid needless WAN cache allocation
469            // client output for modules without messages.
470            return $blob;
471        }
472
473        // T409619: Support for lessMessages should not break getMessages subclassing
474        //
475        // Avoid array_diff because it removes all matches instead of just one,
476        // whereas we allow a getMessage() subclass to add the same message in lessMessages.
477        $reducedMessages = $this->getMessages();
478        foreach ( $this->lessMessages as $messageKey ) {
479            $i = array_search( $messageKey, $reducedMessages );
480            if ( $i !== false ) {
481                unset( $reducedMessages[$i] );
482            }
483        }
484        return json_encode( (object)$this->pluckFromMessageBlob( $blob, $reducedMessages ) );
485    }
486
487    // phpcs:disable MediaWiki.Commenting.DocComment.SpacingDocTag, Squiz.WhiteSpace.FunctionSpacing.Before
488    /**
489     * Escape and wrap a message value as literal string for LESS.
490     *
491     * This mostly lets CSSMin escape it and wrap it, but also escape single quotes
492     * for compatibility with LESS's feature of variable interpolation into other strings.
493     * This is relatively rare for most use of LESS, but for messages it is quite common.
494     *
495     * Example:
496     *
497     * @code
498     *     @x: "foo's";
499     *     .eg { content: 'Value is @{x}'; }
500     * @endcode
501     *
502     * Produces output: `.eg { content: 'Value is foo's'; }`.
503     * (Tested in less.php 1.8.1, and Less.js 2.7)
504     *
505     * @param string $msg
506     * @return string wrapped LESS variable value
507     */
508    private static function wrapAndEscapeMessage( $msg ) {
509        return str_replace( "'", "\'", CSSMin::serializeStringValue( $msg ) );
510    }
511
512    // phpcs:enable
513
514    /**
515     * Get language-specific LESS variables for this module.
516     *
517     * @param Context $context
518     * @return array LESS variables
519     */
520    protected function getLessVars( Context $context ) {
521        $vars = parent::getLessVars( $context );
522
523        if ( $this->lessMessages ) {
524            $blob = parent::getMessageBlob( $context );
525            $messages = $this->pluckFromMessageBlob( $blob, $this->lessMessages );
526
527            // It is important that we iterate the declared list from $this->lessMessages,
528            // and not $messages since in the case of undefined messages, the key is
529            // omitted entirely from the blob. This emits a log warning for developers,
530            // but we must still carry on and produce a valid LESS variable declaration,
531            // to avoid a LESS syntax error (T267785).
532            foreach ( $this->lessMessages as $msgKey ) {
533                $vars['msg-' . $msgKey] = self::wrapAndEscapeMessage( $messages[$msgKey] ?? "â§¼{$msgKey}â§½" );
534            }
535        }
536
537        return $vars;
538    }
539
540    /**
541     * Get the name of the group this module should be loaded in.
542     *
543     * @return null|string Group name
544     */
545    public function getGroup() {
546        return $this->group;
547    }
548
549    /**
550     * Get names of modules this module depends on.
551     *
552     * @param Context|null $context
553     * @return string[] List of module names
554     */
555    public function getDependencies( ?Context $context = null ) {
556        return $this->dependencies;
557    }
558
559    /**
560     * Helper method for getting a file.
561     *
562     * @param string $localPath The path to the resource to load
563     * @param string $type The type of resource being loaded (for error reporting only)
564     * @return string
565     */
566    private function getFileContents( $localPath, $type ) {
567        if ( !is_file( $localPath ) ) {
568            throw new RuntimeException( "$type file not found or not a file: \"$localPath\"" );
569        }
570        return $this->stripBom( file_get_contents( $localPath ) );
571    }
572
573    /**
574     * @return null|string
575     */
576    public function getSkipFunction() {
577        if ( !$this->skipFunction ) {
578            return null;
579        }
580        $localPath = $this->getLocalPath( $this->skipFunction );
581        return $this->getFileContents( $localPath, 'skip function' );
582    }
583
584    /** @inheritDoc */
585    public function requiresES6() {
586        return true;
587    }
588
589    /**
590     * Disable module content versioning.
591     *
592     * This class uses getDefinitionSummary() instead, to avoid filesystem overhead
593     * involved with building the full module content inside a startup request.
594     *
595     * @return bool
596     */
597    public function enableModuleContentVersion() {
598        return false;
599    }
600
601    /**
602     * Helper method for getDefinitionSummary.
603     *
604     * @param Context $context
605     * @return string Hash
606     */
607    private function getFileHashes( Context $context ) {
608        $files = [];
609
610        foreach ( $this->getStyleFiles( $context ) as $filePaths ) {
611            foreach ( $filePaths as $filePath ) {
612                $files[] = $this->getLocalPath( $filePath );
613            }
614        }
615
616        // Extract file paths for package files
617        // Optimisation: Use foreach() and isset() instead of array_map/array_filter.
618        // This is a hot code path, called by StartupModule for thousands of modules.
619        $expandedPackageFiles = $this->expandPackageFiles( $context );
620        if ( $expandedPackageFiles ) {
621            foreach ( $expandedPackageFiles['files'] as $fileInfo ) {
622                $filePath = $fileInfo['filePath'] ?? $fileInfo['versionFilePath'] ?? null;
623                if ( $filePath instanceof FilePath ) {
624                    $files[] = $filePath->getLocalPath();
625                }
626            }
627        }
628
629        // Add other configured paths
630        $scriptFileInfos = $this->getScriptFiles( $context );
631        foreach ( $scriptFileInfos as $fileInfo ) {
632            $filePath = $fileInfo['filePath'] ?? $fileInfo['versionFilePath'] ?? null;
633            if ( $filePath instanceof FilePath ) {
634                $files[] = $filePath->getLocalPath();
635            }
636        }
637
638        foreach ( $this->templates as $filePath ) {
639            $files[] = $this->getLocalPath( $filePath );
640        }
641
642        if ( $this->skipFunction ) {
643            $files[] = $this->getLocalPath( $this->skipFunction );
644        }
645
646        // Add any lazily discovered file dependencies from previous module builds.
647        // These are saved as relatative paths.
648        foreach ( Module::expandRelativePaths( $this->getFileDependencies( $context ) ) as $file ) {
649            $files[] = $file;
650        }
651
652        // Filter out any duplicates. Typically introduced by getFileDependencies() which
653        // may lazily re-discover a primary file.
654        $files = array_unique( $files );
655
656        // Don't return array keys or any other form of file path here, only the hashes.
657        // Including file paths would needlessly cause global cache invalidation when files
658        // move on disk or if e.g. the MediaWiki directory name changes.
659        // Anything where order is significant is already detected by the definition summary.
660        return FileContentsHasher::getFileContentsHash( $files );
661    }
662
663    /**
664     * Get the definition summary for this module.
665     *
666     * @param Context $context
667     * @return array
668     */
669    public function getDefinitionSummary( Context $context ) {
670        $summary = parent::getDefinitionSummary( $context );
671
672        $options = [];
673        foreach ( [
674            // The following properties are omitted because they don't affect the module response:
675            // - localBasePath (Per T104950; Changes when absolute directory name changes. If
676            //    this affects 'scripts' and other file paths, getFileHashes accounts for that.)
677            // - remoteBasePath (Per T104950)
678            // - dependencies (provided via startup module)
679            // - group (provided via startup module)
680            'styles',
681            'skinStyles',
682            'messages',
683            'templates',
684            'skipFunction',
685            'debugRaw',
686        ] as $member ) {
687            $options[$member] = $this->{$member};
688        }
689
690        $packageFiles = $this->expandPackageFiles( $context );
691        $packageSummaries = [];
692        if ( $packageFiles ) {
693            // Extract the minimum needed:
694            // - The 'main' pointer (included as-is).
695            // - The 'files' array, simplified to only which files exist (the keys of
696            //   this array), and something that represents their non-file content.
697            //   For packaged files that reflect files directly from disk, the
698            //   'getFileHashes' method tracks their content already.
699            //   It is important that the keys of the $packageFiles['files'] array
700            //   are preserved, as they do affect the module output.
701            foreach ( $packageFiles['files'] as $fileName => $fileInfo ) {
702                $packageSummaries[$fileName] =
703                    $fileInfo['definitionSummary'] ?? $fileInfo['content'] ?? null;
704            }
705        }
706
707        $scriptFiles = $this->getScriptFiles( $context );
708        $scriptSummaries = [];
709        foreach ( $scriptFiles as $fileName => $fileInfo ) {
710            $scriptSummaries[$fileName] =
711                $fileInfo['definitionSummary'] ?? $fileInfo['content'] ?? null;
712        }
713
714        $summary[] = [
715            'options' => $options,
716            'packageFiles' => $packageSummaries,
717            'scripts' => $scriptSummaries,
718            'fileHashes' => $this->getFileHashes( $context ),
719            'messageBlob' => $this->getMessageBlob( $context ),
720        ];
721
722        $lessVars = $this->getLessVars( $context );
723        if ( $lessVars ) {
724            $summary[] = [ 'lessVars' => $lessVars ];
725        }
726
727        return $summary;
728    }
729
730    /**
731     * @param string|FilePath $path
732     * @return string
733     */
734    protected function getPath( $path ) {
735        if ( $path instanceof FilePath ) {
736            return $path->getPath();
737        }
738
739        return $path;
740    }
741
742    /**
743     * @param string|FilePath $path
744     * @return string
745     */
746    protected function getLocalPath( $path ) {
747        if ( $path instanceof FilePath ) {
748            if ( $path->getLocalBasePath() !== null ) {
749                return $path->getLocalPath();
750            }
751            $path = $path->getPath();
752        }
753
754        return "{$this->localBasePath}/$path";
755    }
756
757    /**
758     * @param string|FilePath $path
759     * @return string
760     */
761    protected function getRemotePath( $path ) {
762        if ( $path instanceof FilePath ) {
763            if ( $path->getRemoteBasePath() !== null ) {
764                return $path->getRemotePath();
765            }
766            $path = $path->getPath();
767        }
768
769        if ( $this->remoteBasePath === '/' ) {
770            return "/$path";
771        } else {
772            return "{$this->remoteBasePath}/$path";
773        }
774    }
775
776    /**
777     * Infer the stylesheet language from a stylesheet file path.
778     *
779     * @since 1.22
780     * @param string $path
781     * @return string The stylesheet language name
782     */
783    public function getStyleSheetLang( $path ) {
784        return preg_match( '/\.less$/i', $path ) ? 'less' : 'css';
785    }
786
787    /**
788     * Infer the file type from a package file path.
789     *
790     * @param string $path
791     * @return string 'script', 'script-vue', or 'data'
792     */
793    public static function getPackageFileType( $path ) {
794        if ( preg_match( '/\.json$/i', $path ) ) {
795            return 'data';
796        }
797        if ( preg_match( '/\.vue$/i', $path ) ) {
798            return 'script-vue';
799        }
800        return 'script';
801    }
802
803    /**
804     * Collate style file paths by 'media' option (or 'all' if 'media' is not set)
805     *
806     * @param array $list List of file paths in any combination of index/path
807     *     or path/options pairs
808     * @return string[][] List of collated file paths
809     */
810    private static function collateStyleFilesByMedia( array $list ) {
811        $collatedFiles = [];
812        foreach ( $list as $key => $value ) {
813            if ( is_int( $key ) ) {
814                // File name as the value
815                $collatedFiles['all'][] = $value;
816            } elseif ( is_array( $value ) ) {
817                // File name as the key, options array as the value
818                $optionValue = $value['media'] ?? 'all';
819                $collatedFiles[$optionValue][] = $key;
820            }
821        }
822        return $collatedFiles;
823    }
824
825    /**
826     * Get a list of element that match a key, optionally using a fallback key.
827     *
828     * @param array[] $list List of lists to select from
829     * @param string $key Key to look for in $list
830     * @param string|null $fallback Key to look for in $list if $key doesn't exist
831     * @return array List of elements from $list which matched $key or $fallback,
832     *  or an empty list in case of no match
833     */
834    protected static function tryForKey( array $list, $key, $fallback = null ) {
835        if ( isset( $list[$key] ) && is_array( $list[$key] ) ) {
836            return $list[$key];
837        } elseif ( is_string( $fallback )
838            && isset( $list[$fallback] )
839            && is_array( $list[$fallback] )
840        ) {
841            return $list[$fallback];
842        }
843        return [];
844    }
845
846    /**
847     * Get script file paths for this module, in order of proper execution.
848     *
849     * @param Context $context
850     * @return array An array of file info arrays as returned by expandFileInfo()
851     */
852    private function getScriptFiles( Context $context ): array {
853        // List in execution order: scripts, languageScripts, skinScripts, debugScripts.
854        // Documented at MediaWiki\MainConfigSchema::ResourceModules.
855        $filesByCategory = [
856            'scripts' => $this->scripts,
857            'languageScripts' => $this->getLanguageScripts( $context->getLanguage() ),
858            'skinScripts' => self::tryForKey( $this->skinScripts, $context->getSkin(), 'default' ),
859        ];
860        if ( $context->getDebug() ) {
861            $filesByCategory['debugScripts'] = $this->debugScripts;
862        }
863
864        $expandedFiles = [];
865        foreach ( $filesByCategory as $category => $files ) {
866            foreach ( $files as $key => $fileInfo ) {
867                $expandedFileInfo = $this->expandFileInfo( $context, $fileInfo, "$category\[$key]" );
868                $expandedFiles[$expandedFileInfo['name']] = $expandedFileInfo;
869            }
870        }
871
872        return $expandedFiles;
873    }
874
875    /**
876     * Get the set of language scripts for the given language,
877     * possibly using a fallback language.
878     *
879     * @param string $lang
880     * @return array<int,string|FilePath> File paths
881     */
882    private function getLanguageScripts( string $lang ): array {
883        $scripts = self::tryForKey( $this->languageScripts, $lang );
884        if ( $scripts ) {
885            return $scripts;
886        }
887
888        // Optimization: Avoid initialising and calling into language services
889        // for the majority of modules that don't use this option.
890        if ( $this->languageScripts ) {
891            $fallbacks = MediaWikiServices::getInstance()
892                ->getLanguageFallback()
893                ->getAll( $lang, LanguageFallbackMode::MESSAGES );
894            foreach ( $fallbacks as $lang ) {
895                $scripts = self::tryForKey( $this->languageScripts, $lang );
896                if ( $scripts ) {
897                    return $scripts;
898                }
899            }
900        }
901
902        return [];
903    }
904
905    public function setSkinStylesOverride( array $moduleSkinStyles ): void {
906        $moduleName = $this->getName();
907        foreach ( $moduleSkinStyles as $skinName => $overrides ) {
908            // If a module provides overrides for a skin, and that skin also provides overrides
909            // for the same module, then the module has precedence.
910            if ( isset( $this->skinStyles[$skinName] ) ) {
911                continue;
912            }
913
914            // If $moduleName in ResourceModuleSkinStyles is preceded with a '+', the defined style
915            // files will be added to 'default' skinStyles, otherwise 'default' will be ignored.
916            if ( isset( $overrides[$moduleName] ) ) {
917                $paths = (array)$overrides[$moduleName];
918                $styleFiles = [];
919            } elseif ( isset( $overrides['+' . $moduleName] ) ) {
920                $paths = (array)$overrides['+' . $moduleName];
921                $styleFiles = isset( $this->skinStyles['default'] ) ?
922                    (array)$this->skinStyles['default'] :
923                    [];
924            } else {
925                continue;
926            }
927
928            // Add new file paths, remapping them to refer to our directories and not use settings
929            // from the module we're modifying, which come from the base definition.
930            [ $localBasePath, $remoteBasePath ] = self::extractBasePaths( $overrides );
931
932            foreach ( $paths as $path ) {
933                $styleFiles[] = new FilePath( $path, $localBasePath, $remoteBasePath );
934            }
935
936            $this->skinStyles[$skinName] = $styleFiles;
937        }
938    }
939
940    /**
941     * Get a list of file paths for all styles in this module, in order of proper inclusion.
942     *
943     * @internal Exposed only for use by structure phpunit tests.
944     * @param Context $context
945     * @return array<string,array<int,string|FilePath>> Map from media type to list of file paths
946     */
947    public function getStyleFiles( Context $context ) {
948        return array_merge_recursive(
949            self::collateStyleFilesByMedia( $this->styles ),
950            self::collateStyleFilesByMedia(
951                self::tryForKey( $this->skinStyles, $context->getSkin(), 'default' )
952            )
953        );
954    }
955
956    /**
957     * Get a list of file paths for all skin styles in the module used by
958     * the skin.
959     *
960     * @param string $skinName The name of the skin
961     * @return array A list of file paths collated by media type
962     */
963    protected function getSkinStyleFiles( $skinName ) {
964        return self::collateStyleFilesByMedia(
965            self::tryForKey( $this->skinStyles, $skinName )
966        );
967    }
968
969    /**
970     * Get a list of file paths for all skin style files in the module,
971     * for all available skins.
972     *
973     * @return array A list of file paths collated by media type
974     */
975    protected function getAllSkinStyleFiles() {
976        $skinFactory = MediaWikiServices::getInstance()->getSkinFactory();
977        $styleFiles = [];
978
979        $internalSkinNames = array_keys( $skinFactory->getInstalledSkins() );
980        $internalSkinNames[] = 'default';
981
982        foreach ( $internalSkinNames as $internalSkinName ) {
983            $styleFiles = array_merge_recursive(
984                $styleFiles,
985                $this->getSkinStyleFiles( $internalSkinName )
986            );
987        }
988
989        return $styleFiles;
990    }
991
992    /**
993     * Get all style files and all skin style files used by this module.
994     *
995     * @return array
996     */
997    public function getAllStyleFiles() {
998        $collatedStyleFiles = array_merge_recursive(
999            self::collateStyleFilesByMedia( $this->styles ),
1000            $this->getAllSkinStyleFiles()
1001        );
1002
1003        $result = [];
1004
1005        foreach ( $collatedStyleFiles as $styleFiles ) {
1006            foreach ( $styleFiles as $styleFile ) {
1007                $result[] = $this->getLocalPath( $styleFile );
1008            }
1009        }
1010
1011        return $result;
1012    }
1013
1014    /**
1015     * Read the contents of a list of CSS files and remap and concatenate these.
1016     *
1017     * @internal This is considered a private method. Exposed for internal use by WebInstallerOutput.
1018     * @param array<string,array<int,string|FilePath>> $styles Map of media type to file paths
1019     * @param Context $context
1020     * @return array<string,string> Map of combined CSS code, keyed by media type
1021     */
1022    public function readStyleFiles( array $styles, Context $context ) {
1023        if ( !$styles ) {
1024            return [];
1025        }
1026        foreach ( $styles as $media => $files ) {
1027            $uniqueFiles = array_unique( $files, SORT_REGULAR );
1028            $styleFiles = [];
1029            foreach ( $uniqueFiles as $file ) {
1030                $styleFiles[] = $this->readStyleFile( $file, $context );
1031            }
1032            $styles[$media] = implode( "\n", $styleFiles );
1033        }
1034        return $styles;
1035    }
1036
1037    /**
1038     * Read and process a style file. Reads a file from disk and runs it through processStyle().
1039     *
1040     * This method can be used as a callback for array_map()
1041     *
1042     * @internal
1043     * @param string|FilePath $path Path of style file to read
1044     * @param Context $context
1045     * @return string CSS code
1046     */
1047    protected function readStyleFile( $path, Context $context ) {
1048        $localPath = $this->getLocalPath( $path );
1049        $style = $this->getFileContents( $localPath, 'style' );
1050        $styleLang = $this->getStyleSheetLang( $localPath );
1051
1052        return $this->processStyle( $style, $styleLang, $path, $context );
1053    }
1054
1055    /**
1056     * Process a CSS/LESS string.
1057     *
1058     * This method performs the following processing steps:
1059     * - LESS compilation (if $styleLang = 'less')
1060     * - RTL flipping with CSSJanus (if getFlip() returns true)
1061     * - Registration of references to local files in $localFileRefs and $missingLocalFileRefs
1062     * - URL remapping and data URI embedding
1063     *
1064     * @internal
1065     * @param string $style CSS or LESS code
1066     * @param string $styleLang Language of $style code ('css' or 'less')
1067     * @param string|FilePath $path Path to code file, used for resolving relative file paths
1068     * @param Context $context
1069     * @return string Processed CSS code
1070     */
1071    protected function processStyle( $style, $styleLang, $path, Context $context ) {
1072        $localPath = $this->getLocalPath( $path );
1073        $remotePath = $this->getRemotePath( $path );
1074
1075        if ( $styleLang === 'less' ) {
1076            $style = $this->compileLessString( $style, $localPath, $context );
1077            $this->hasGeneratedStyles = true;
1078        }
1079
1080        if ( $this->getFlip( $context ) ) {
1081            $style = CSSJanus::transform(
1082                $style,
1083                /* $swapLtrRtlInURL = */ true,
1084                /* $swapLeftRightInURL = */ false
1085            );
1086            $this->hasGeneratedStyles = true;
1087        }
1088
1089        $localDir = dirname( $localPath );
1090        $remoteDir = dirname( $remotePath );
1091        // Get and register local file references
1092        $localFileRefs = CSSMin::getLocalFileReferences( $style, $localDir );
1093        foreach ( $localFileRefs as $file ) {
1094            if ( is_file( $file ) ) {
1095                $this->localFileRefs[] = $file;
1096            } else {
1097                $this->missingLocalFileRefs[] = $file;
1098            }
1099        }
1100        // Don't cache this call. remap() ensures data URIs embeds are up to date,
1101        // and urls contain correct content hashes in their query string. (T128668)
1102        return CSSMin::remap( $style, $localDir, $remoteDir, true );
1103    }
1104
1105    /**
1106     * Get whether CSS for this module should be flipped
1107     * @param Context $context
1108     * @return bool
1109     */
1110    public function getFlip( Context $context ) {
1111        return $context->getDirection() === 'rtl' && !$this->noflip;
1112    }
1113
1114    /**
1115     * Get the module's load type.
1116     *
1117     * @since 1.28
1118     * @return string
1119     */
1120    public function getType() {
1121        $canBeStylesOnly = !(
1122            // All options except 'styles', 'skinStyles' and 'debugRaw'
1123            $this->scripts
1124            || $this->debugScripts
1125            || $this->templates
1126            || $this->languageScripts
1127            || $this->skinScripts
1128            || $this->dependencies
1129            || $this->messages
1130            || $this->skipFunction
1131            || $this->packageFiles
1132        );
1133        return $canBeStylesOnly ? self::LOAD_STYLES : self::LOAD_GENERAL;
1134    }
1135
1136    /**
1137     * Compile a LESS string into CSS.
1138     *
1139     * Keeps track of all used files and adds them to localFileRefs.
1140     *
1141     * @since 1.35
1142     * @param string $style LESS source to compile
1143     * @param string $stylePath File path of LESS source, used for resolving relative file paths
1144     * @param Context $context Context in which to generate script
1145     * @return string CSS source
1146     */
1147    protected function compileLessString( $style, $stylePath, Context $context ) {
1148        static $cache;
1149        // @TODO: dependency injection
1150        if ( !$cache ) {
1151            $cache = MediaWikiServices::getInstance()->getObjectCacheFactory()
1152                ->getLocalServerInstance( CACHE_HASH );
1153        }
1154
1155        $skinName = $context->getSkin();
1156        $skinImportPaths = ExtensionRegistry::getInstance()->getAttribute( 'SkinLessImportPaths' );
1157        $importDirs = [];
1158        if ( isset( $skinImportPaths[ $skinName ] ) ) {
1159            $importDirs[] = $skinImportPaths[ $skinName ];
1160        }
1161
1162        $vars = $this->getLessVars( $context );
1163        // Construct a cache key from a hash of the LESS source, and a hash digest
1164        // of the LESS variables and import dirs used for compilation.
1165        ksort( $vars );
1166        $compilerParams = [
1167            'vars' => $vars,
1168            'importDirs' => $importDirs,
1169            // CodexDevelopmentDir affects import path mapping in ResourceLoader::getLessCompiler(),
1170            // so take that into account too
1171            'codexDevDir' => $this->getConfig()->get( MainConfigNames::CodexDevelopmentDir )
1172        ];
1173        $key = $cache->makeGlobalKey(
1174            'resourceloader-less',
1175            'v1',
1176            hash( 'md4', $style ),
1177            hash( 'md4', serialize( $compilerParams ) )
1178        );
1179
1180        // If we got a cached value, we have to validate it by getting a checksum of all the
1181        // files that were loaded by the parser and ensuring it matches the cached entry's.
1182        $data = $cache->get( $key );
1183        if (
1184            !$data ||
1185            $data['hash'] !== FileContentsHasher::getFileContentsHash( $data['files'] )
1186        ) {
1187            $compiler = $context->getResourceLoader()->getLessCompiler( $vars, $importDirs );
1188
1189            $css = $compiler->parse( $style, $stylePath )->getCss();
1190            // T253055: store the implicit dependency paths in a form relative to any install
1191            // path so that multiple version of the application can share the cache for identical
1192            // less stylesheets. This also avoids churn during application updates.
1193            $files = $compiler->getParsedFiles();
1194            $data = [
1195                'css'   => $css,
1196                'files' => Module::getRelativePaths( $files ),
1197                'hash'  => FileContentsHasher::getFileContentsHash( $files )
1198            ];
1199            $cache->set( $key, $data, $cache::TTL_DAY );
1200        }
1201
1202        foreach ( Module::expandRelativePaths( $data['files'] ) as $path ) {
1203            $this->localFileRefs[] = $path;
1204        }
1205
1206        return $data['css'];
1207    }
1208
1209    /**
1210     * Get content of named templates for this module.
1211     *
1212     * @return array<string,string> Templates mapping template alias to content
1213     */
1214    public function getTemplates() {
1215        $templates = [];
1216
1217        foreach ( $this->templates as $alias => $templatePath ) {
1218            // Alias is optional
1219            if ( is_int( $alias ) ) {
1220                $alias = $this->getPath( $templatePath );
1221            }
1222            $localPath = $this->getLocalPath( $templatePath );
1223            $content = $this->getFileContents( $localPath, 'template' );
1224
1225            $templates[$alias] = $this->stripBom( $content );
1226        }
1227        return $templates;
1228    }
1229
1230    /**
1231     * Internal helper for use by getPackageFiles(), getFileHashes() and getDefinitionSummary().
1232     *
1233     * This expands the 'packageFiles' definition into something that's (almost) the right format
1234     * for getPackageFiles() to return. It expands shorthands, resolves config vars, and handles
1235     * summarising any non-file data for getVersionHash(). For file-based data, getFileHashes()
1236     * handles it instead, which also ends up in getDefinitionSummary().
1237     *
1238     * What it does not do is reading the actual contents of any specified files, nor invoking
1239     * the computation callbacks. Those things are done by getPackageFiles() instead to improve
1240     * backend performance by only doing this work when the module response is needed, and not
1241     * when merely computing the version hash for StartupModule, or when checking
1242     * If-None-Match headers for a HTTP 304 response.
1243     *
1244     * @param Context $context
1245     * @return array|null Array of arrays as returned by expandFileInfo(), with the key being
1246     *   the file name, or null if this is not a package file module.
1247     * @phan-return array{main:?string,files:array[]}|null
1248     */
1249    private function expandPackageFiles( Context $context ) {
1250        $hash = $context->getHash();
1251        if ( isset( $this->expandedPackageFiles[$hash] ) ) {
1252            return $this->expandedPackageFiles[$hash];
1253        }
1254        if ( $this->packageFiles === null ) {
1255            return null;
1256        }
1257        $expandedFiles = [];
1258        $mainFile = null;
1259
1260        foreach ( $this->packageFiles as $key => $fileInfo ) {
1261            $expanded = $this->expandFileInfo( $context, $fileInfo, "packageFiles[$key]" );
1262            $fileName = $expanded['name'];
1263            if ( !empty( $expanded['main'] ) ) {
1264                unset( $expanded['main'] );
1265                $type = $expanded['type'];
1266                $mainFile = $fileName;
1267                if ( $type !== 'script' && $type !== 'script-vue' ) {
1268                    $msg = "Main file in package must be of type 'script', module " .
1269                        "'{$this->getName()}', main file '{$mainFile}' is '{$type}'.";
1270                    $this->getLogger()->error( $msg );
1271                    throw new LogicException( $msg );
1272                }
1273            }
1274            $expandedFiles[$fileName] = $expanded;
1275        }
1276
1277        if ( $expandedFiles && $mainFile === null ) {
1278            // The first package file that is a script is the main file
1279            foreach ( $expandedFiles as $path => $file ) {
1280                if ( $file['type'] === 'script' || $file['type'] === 'script-vue' ) {
1281                    $mainFile = $path;
1282                    break;
1283                }
1284            }
1285        }
1286
1287        $result = [
1288            'main' => $mainFile,
1289            'files' => $expandedFiles
1290        ];
1291
1292        $this->expandedPackageFiles[$hash] = $result;
1293        return $result;
1294    }
1295
1296    /**
1297     * Process a file info array as specified in configuration or extension.json,
1298     * expanding shortcuts and callbacks.
1299     *
1300     * @see MainConfigSchema::ResourceModules
1301     *
1302     * @param Context $context
1303     * @param array|string|FilePath $fileInfo
1304     * @param string $debugKey
1305     * @return array An associative array with the following keys:
1306     *   - name: (string) The filename relative to the module base. This is unique only within
1307     *     the context of the current module. It may be a virtual name.
1308     *   - type: (string) May be 'script', 'script-vue', 'data' or 'text'
1309     *   - filePath: (FilePath) The FilePath object which should be used to load the content.
1310     *     This will be absent if the content was loaded another way.
1311     *   - virtualFilePath: (FilePath) A FilePath object for a virtual path which doesn't actually
1312     *     exist. This is used for source map generation. Optional.
1313     *   - versionFilePath: (FilePath) A FilePath object which is the ultimate source of a
1314     *     generated file. The timestamp and contents will be used for version generation.
1315     *     Generated by the callback specified in versionCallback. Optional.
1316     *   - content: (string|mixed) If the 'type' element is 'script', this is a string containing
1317     *     JS code, being the contents of the script file. For any other type, this contains data
1318     *     which will be JSON serialized. Optional, if not set, it will be set in readFileInfo().
1319     *   - callback: (callable) A callback to call to obtain the contents. This will be set if the
1320     *     version callback was present in the input, indicating that the callback is expensive.
1321     *   - callbackParam: (array) The parameters to be passed to the callback.
1322     *   - definitionSummary: (array) The data returned by the version callback.
1323     *   - main: (bool) Whether the file is the main file of the package.
1324     */
1325    private function expandFileInfo( Context $context, $fileInfo, $debugKey ) {
1326        if ( is_string( $fileInfo ) ) {
1327            // Inline common case
1328            return [
1329                'name' => $fileInfo,
1330                'type' => self::getPackageFileType( $fileInfo ),
1331                'filePath' => new FilePath( $fileInfo, $this->localBasePath, $this->remoteBasePath )
1332            ];
1333        } elseif ( $fileInfo instanceof FilePath ) {
1334            $fileInfo = [
1335                'name' => $fileInfo->getPath(),
1336                'file' => $fileInfo
1337            ];
1338        } elseif ( !is_array( $fileInfo ) ) {
1339            $msg = "Invalid type in $debugKey for module '{$this->getName()}', " .
1340                "must be array, string or FilePath";
1341            $this->getLogger()->error( $msg );
1342            throw new LogicException( $msg );
1343        }
1344        if ( !isset( $fileInfo['name'] ) ) {
1345            $msg = "Missing 'name' key in $debugKey for module '{$this->getName()}'";
1346            $this->getLogger()->error( $msg );
1347            throw new LogicException( $msg );
1348        }
1349        $fileName = $this->getPath( $fileInfo['name'] );
1350
1351        // Infer type from alias if needed
1352        $type = $fileInfo['type'] ?? self::getPackageFileType( $fileName );
1353        $expanded = [
1354            'name' => $fileName,
1355            'type' => $type
1356        ];
1357        if ( !empty( $fileInfo['main'] ) ) {
1358            $expanded['main'] = true;
1359        }
1360
1361        // Perform expansions (except 'file' and 'callback'), creating one of these keys:
1362        // - 'content': literal value.
1363        // - 'filePath': content to be read from a file.
1364        // - 'callback': content computed by a callable.
1365        if ( isset( $fileInfo['content'] ) ) {
1366            $expanded['content'] = $fileInfo['content'];
1367        } elseif ( isset( $fileInfo['file'] ) ) {
1368            $expanded['filePath'] = $this->makeFilePath( $fileInfo['file'] );
1369        } elseif ( isset( $fileInfo['callback'] ) ) {
1370            // If no extra parameter for the callback is given, use null.
1371            $expanded['callbackParam'] = $fileInfo['callbackParam'] ?? null;
1372
1373            if ( !is_callable( $fileInfo['callback'] ) ) {
1374                $msg = "Invalid 'callback' for module '{$this->getName()}', file '{$fileName}'.";
1375                $this->getLogger()->error( $msg );
1376                throw new LogicException( $msg );
1377            }
1378            if ( isset( $fileInfo['versionCallback'] ) ) {
1379                if ( !is_callable( $fileInfo['versionCallback'] ) ) {
1380                    throw new LogicException( "Invalid 'versionCallback' for "
1381                        . "module '{$this->getName()}', file '{$fileName}'."
1382                    );
1383                }
1384
1385                // Execute the versionCallback with the same arguments that
1386                // would be given to the callback
1387                $callbackResult = ( $fileInfo['versionCallback'] )(
1388                    $context,
1389                    $this->getConfig(),
1390                    $expanded['callbackParam']
1391                );
1392                if ( $callbackResult instanceof FilePath ) {
1393                    $callbackResult->initBasePaths( $this->localBasePath, $this->remoteBasePath );
1394                    $expanded['versionFilePath'] = $callbackResult;
1395                } else {
1396                    $expanded['definitionSummary'] = $callbackResult;
1397                }
1398                // Don't invoke 'callback' here as it may be expensive (T223260).
1399                $expanded['callback'] = $fileInfo['callback'];
1400            } else {
1401                // Else go ahead invoke callback with its arguments.
1402                $callbackResult = ( $fileInfo['callback'] )(
1403                    $context,
1404                    $this->getConfig(),
1405                    $expanded['callbackParam']
1406                );
1407                if ( $callbackResult instanceof FilePath ) {
1408                    $callbackResult->initBasePaths( $this->localBasePath, $this->remoteBasePath );
1409                    $expanded['filePath'] = $callbackResult;
1410                } else {
1411                    $expanded['content'] = $callbackResult;
1412                }
1413            }
1414        } elseif ( isset( $fileInfo['config'] ) ) {
1415            if ( $type !== 'data' ) {
1416                $msg = "Key 'config' only valid for data files. "
1417                    . " Module '{$this->getName()}', file '{$fileName}' is '{$type}'.";
1418                $this->getLogger()->error( $msg );
1419                throw new LogicException( $msg );
1420            }
1421            $expandedConfig = [];
1422            foreach ( $fileInfo['config'] as $configKey => $var ) {
1423                $expandedConfig[ is_numeric( $configKey ) ? $var : $configKey ] = $this->getConfig()->get( $var );
1424            }
1425            $expanded['content'] = $expandedConfig;
1426        } elseif ( !empty( $fileInfo['main'] ) ) {
1427            // [ 'name' => 'foo.js', 'main' => true ] is shorthand
1428            $expanded['filePath'] = $this->makeFilePath( $fileName );
1429        } else {
1430            $msg = "Incomplete definition for module '{$this->getName()}', file '{$fileName}'. "
1431                . "One of 'file', 'content', 'callback', or 'config' must be set.";
1432            $this->getLogger()->error( $msg );
1433            throw new LogicException( $msg );
1434        }
1435        if ( !isset( $expanded['filePath'] ) ) {
1436            $expanded['virtualFilePath'] = $this->makeFilePath( $fileName );
1437        }
1438        return $expanded;
1439    }
1440
1441    /**
1442     * Cast a FilePath or string to a FilePath
1443     *
1444     * @param FilePath|string $path
1445     * @return FilePath
1446     */
1447    private function makeFilePath( $path ): FilePath {
1448        if ( $path instanceof FilePath ) {
1449            return $path;
1450        } elseif ( is_string( $path ) ) {
1451            return new FilePath( $path, $this->localBasePath, $this->remoteBasePath );
1452        } else {
1453            throw new InvalidArgumentException( '$path must be either FilePath or string' );
1454        }
1455    }
1456
1457    /**
1458     * Resolve the package files definition and generate the content of each package file.
1459     *
1460     * @param Context $context
1461     * @return array|null Package files data structure, see Module::getScript()
1462     */
1463    public function getPackageFiles( Context $context ) {
1464        if ( $this->packageFiles === null ) {
1465            return null;
1466        }
1467        $hash = $context->getHash();
1468        if ( isset( $this->fullyExpandedPackageFiles[ $hash ] ) ) {
1469            return $this->fullyExpandedPackageFiles[ $hash ];
1470        }
1471        $expandedPackageFiles = $this->expandPackageFiles( $context ) ?? [];
1472
1473        // T402278: use array_map() to avoid &references here
1474        $expandedPackageFiles['files'] = array_map( function ( array $fileInfo ) use ( $context ): array {
1475            return $this->readFileInfo( $context, $fileInfo );
1476        }, $expandedPackageFiles['files'] );
1477
1478        $this->fullyExpandedPackageFiles[ $hash ] = $expandedPackageFiles;
1479        return $expandedPackageFiles;
1480    }
1481
1482    /**
1483     * Given a file info array as returned by expandFileInfo(), expand the file paths and
1484     * remaining callbacks, ensuring that the 'content' element is populated. Return a
1485     * modified copy of the array, removing intermediate data such as callback parameters.
1486     *
1487     * @param Context $context
1488     * @param array $fileInfo
1489     * @return array
1490     */
1491    private function readFileInfo( Context $context, array $fileInfo ): array {
1492        // Turn any 'filePath' or 'callback' key into actual 'content',
1493        // and remove the key after that. The callback could return a
1494        // FilePath object; if that happens, fall through to the 'filePath'
1495        // handling.
1496        if ( !isset( $fileInfo['content'] ) && isset( $fileInfo['callback'] ) ) {
1497            $callbackResult = ( $fileInfo['callback'] )(
1498                $context,
1499                $this->getConfig(),
1500                $fileInfo['callbackParam']
1501            );
1502            if ( $callbackResult instanceof FilePath ) {
1503                // Fall through to the filePath handling code below
1504                $fileInfo['filePath'] = $callbackResult;
1505            } else {
1506                $fileInfo['content'] = $callbackResult;
1507            }
1508            unset( $fileInfo['callback'] );
1509        }
1510        // Only interpret 'filePath' if 'content' hasn't been set already.
1511        // This can happen if 'versionCallback' provided 'filePath',
1512        // while 'callback' provides 'content'. In that case both are set
1513        // at this point. The 'filePath' from 'versionCallback' in that case is
1514        // only to inform getDefinitionSummary().
1515        if ( !isset( $fileInfo['content'] ) && isset( $fileInfo['filePath'] ) ) {
1516            $localPath = $this->getLocalPath( $fileInfo['filePath'] );
1517            $content = $this->getFileContents( $localPath, 'package' );
1518            if ( $fileInfo['type'] === 'data' ) {
1519                $content = json_decode( $content, false, 512, JSON_THROW_ON_ERROR );
1520            }
1521            $fileInfo['content'] = $content;
1522        }
1523        if ( $fileInfo['type'] === 'script-vue' ) {
1524            try {
1525                // @phan-suppress-next-line PhanTypePossiblyInvalidDimOffset False positive
1526                $fileInfo[ 'content' ] = $this->parseVueContent( $context, $fileInfo[ 'content' ] );
1527            } catch ( InvalidArgumentException $e ) {
1528                $msg = "Error parsing file '{$fileInfo['name']}' in module '{$this->getName()}': " .
1529                    "{$e->getMessage()}";
1530                $this->getLogger()->error( $msg );
1531                throw new RuntimeException( $msg );
1532            }
1533            $fileInfo['type'] = 'script+style';
1534        }
1535        if ( !isset( $fileInfo['content'] ) ) {
1536            // This should not be possible due to validation in expandFileInfo()
1537            $msg = "Unable to resolve contents for file {$fileInfo['name']}";
1538            $this->getLogger()->error( $msg );
1539            throw new RuntimeException( $msg );
1540        }
1541
1542        // Not needed for client response, exists for use by getDefinitionSummary().
1543        unset( $fileInfo['definitionSummary'] );
1544        // Not needed for client response, used by callbacks only.
1545        unset( $fileInfo['callbackParam'] );
1546
1547        return $fileInfo;
1548    }
1549
1550    /**
1551     * Take an input string and remove the UTF-8 BOM character if present
1552     *
1553     * We need to remove these after reading a file, because we concatenate our files and
1554     * the BOM character is not valid in the middle of a string.
1555     * We already assume UTF-8 everywhere, so this should be safe.
1556     *
1557     * @param string $input
1558     * @return string Input minus the initial BOM char
1559     */
1560    protected function stripBom( $input ) {
1561        if ( str_starts_with( $input, "\xef\xbb\xbf" ) ) {
1562            return substr( $input, 3 );
1563        }
1564        return $input;
1565    }
1566}
1567
1568class_alias( FileModule::class, 'MediaWiki\ResourceLoader\LessVarFileModule' );