Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
79.46% covered (warning)
79.46%
472 / 594
52.08% covered (warning)
52.08%
25 / 48
CRAP
0.00% covered (danger)
0.00%
0 / 1
FileModule
79.60% covered (warning)
79.60%
472 / 593
52.08% covered (warning)
52.08%
25 / 48
669.63
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
94.44% covered (success)
94.44%
34 / 36
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.37% covered (success)
97.37%
37 / 38
0.00% covered (danger)
0.00%
0 / 1
7
 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 InvalidArgumentException;
13use LogicException;
14use MediaWiki\Language\LanguageFallbackMode;
15use MediaWiki\MainConfigNames;
16use MediaWiki\MediaWikiServices;
17use MediaWiki\Output\OutputPage;
18use MediaWiki\Registration\ExtensionRegistry;
19use MediaWiki\Utils\FileContentsHasher;
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 string[] Message keys */
145    protected array $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 relative 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        $packageMain = null;
693        if ( $packageFiles ) {
694            // Extract the minimum needed:
695            // - The 'main' pointer (included as-is).
696            // - The 'files' array, simplified to only which files exist (the keys of
697            //   this array), and something that represents their non-file content.
698            //   For packaged files that reflect files directly from disk, the
699            //   'getFileHashes' method tracks their content already.
700            //   It is important that the keys of the $packageFiles['files'] array
701            //   are preserved, as they do affect the module output.
702            $packageMain = $packageFiles['main'];
703            foreach ( $packageFiles['files'] as $fileName => $fileInfo ) {
704                $packageSummaries[$fileName] =
705                    $fileInfo['definitionSummary'] ?? $fileInfo['content'] ?? null;
706            }
707        }
708
709        $scriptFiles = $this->getScriptFiles( $context );
710        $scriptSummaries = [];
711        foreach ( $scriptFiles as $fileName => $fileInfo ) {
712            $scriptSummaries[$fileName] =
713                $fileInfo['definitionSummary'] ?? $fileInfo['content'] ?? null;
714        }
715
716        $summary[] = [
717            'options' => $options,
718            'packageFiles' => $packageSummaries,
719            'packageMain' => $packageMain,
720            'scripts' => $scriptSummaries,
721            'fileHashes' => $this->getFileHashes( $context ),
722            'messageBlob' => $this->getMessageBlob( $context ),
723        ];
724
725        $lessVars = $this->getLessVars( $context );
726        if ( $lessVars ) {
727            $summary[] = [ 'lessVars' => $lessVars ];
728        }
729
730        return $summary;
731    }
732
733    /**
734     * @param string|FilePath $path
735     * @return string
736     */
737    protected function getPath( $path ) {
738        if ( $path instanceof FilePath ) {
739            return $path->getPath();
740        }
741
742        return $path;
743    }
744
745    /**
746     * @param string|FilePath $path
747     * @return string
748     */
749    protected function getLocalPath( $path ) {
750        if ( $path instanceof FilePath ) {
751            if ( $path->getLocalBasePath() !== null ) {
752                return $path->getLocalPath();
753            }
754            $path = $path->getPath();
755        }
756
757        return "{$this->localBasePath}/$path";
758    }
759
760    /**
761     * @param string|FilePath $path
762     * @return string
763     */
764    protected function getRemotePath( $path ) {
765        if ( $path instanceof FilePath ) {
766            if ( $path->getRemoteBasePath() !== null ) {
767                return $path->getRemotePath();
768            }
769            $path = $path->getPath();
770        }
771
772        if ( $this->remoteBasePath === '/' ) {
773            return "/$path";
774        } else {
775            return "{$this->remoteBasePath}/$path";
776        }
777    }
778
779    /**
780     * Infer the stylesheet language from a stylesheet file path.
781     *
782     * @since 1.22
783     * @param string $path
784     * @return string The stylesheet language name
785     */
786    public function getStyleSheetLang( $path ) {
787        return preg_match( '/\.less$/i', $path ) ? 'less' : 'css';
788    }
789
790    /**
791     * Infer the file type from a package file path.
792     *
793     * @param string $path
794     * @return string 'script', 'script-vue', or 'data'
795     */
796    public static function getPackageFileType( $path ) {
797        if ( preg_match( '/\.json$/i', $path ) ) {
798            return 'data';
799        }
800        if ( preg_match( '/\.vue$/i', $path ) ) {
801            return 'script-vue';
802        }
803        return 'script';
804    }
805
806    /**
807     * Collate style file paths by 'media' option (or 'all' if 'media' is not set)
808     *
809     * @param array $list List of file paths in any combination of index/path
810     *     or path/options pairs
811     * @return string[][] List of collated file paths
812     */
813    private static function collateStyleFilesByMedia( array $list ) {
814        $collatedFiles = [];
815        foreach ( $list as $key => $value ) {
816            if ( is_int( $key ) ) {
817                // File name as the value
818                $collatedFiles['all'][] = $value;
819            } elseif ( is_array( $value ) ) {
820                // File name as the key, options array as the value
821                $optionValue = $value['media'] ?? 'all';
822                $collatedFiles[$optionValue][] = $key;
823            }
824        }
825        return $collatedFiles;
826    }
827
828    /**
829     * Get a list of element that match a key, optionally using a fallback key.
830     *
831     * @param array[] $list List of lists to select from
832     * @param string $key Key to look for in $list
833     * @param string|null $fallback Key to look for in $list if $key doesn't exist
834     * @return array List of elements from $list which matched $key or $fallback,
835     *  or an empty list in case of no match
836     */
837    protected static function tryForKey( array $list, $key, $fallback = null ) {
838        if ( isset( $list[$key] ) && is_array( $list[$key] ) ) {
839            return $list[$key];
840        } elseif ( is_string( $fallback )
841            && isset( $list[$fallback] )
842            && is_array( $list[$fallback] )
843        ) {
844            return $list[$fallback];
845        }
846        return [];
847    }
848
849    /**
850     * Get script file paths for this module, in order of proper execution.
851     *
852     * @param Context $context
853     * @return array An array of file info arrays as returned by expandFileInfo()
854     */
855    private function getScriptFiles( Context $context ): array {
856        // List in execution order: scripts, languageScripts, skinScripts, debugScripts.
857        // Documented at MediaWiki\MainConfigSchema::ResourceModules.
858        $filesByCategory = [
859            'scripts' => $this->scripts,
860            'languageScripts' => $this->getLanguageScripts( $context->getLanguage() ),
861            'skinScripts' => self::tryForKey( $this->skinScripts, $context->getSkin(), 'default' ),
862        ];
863        if ( $context->getDebug() ) {
864            $filesByCategory['debugScripts'] = $this->debugScripts;
865        }
866
867        $expandedFiles = [];
868        foreach ( $filesByCategory as $category => $files ) {
869            foreach ( $files as $key => $fileInfo ) {
870                $expandedFileInfo = $this->expandFileInfo( $context, $fileInfo, "$category\[$key]" );
871                $expandedFiles[$expandedFileInfo['name']] = $expandedFileInfo;
872            }
873        }
874
875        return $expandedFiles;
876    }
877
878    /**
879     * Get the set of language scripts for the given language,
880     * possibly using a fallback language.
881     *
882     * @param string $lang
883     * @return array<int,string|FilePath> File paths
884     */
885    private function getLanguageScripts( string $lang ): array {
886        $scripts = self::tryForKey( $this->languageScripts, $lang );
887        if ( $scripts ) {
888            return $scripts;
889        }
890
891        // Optimization: Avoid initialising and calling into language services
892        // for the majority of modules that don't use this option.
893        if ( $this->languageScripts ) {
894            $fallbacks = MediaWikiServices::getInstance()
895                ->getLanguageFallback()
896                ->getAll( $lang, LanguageFallbackMode::MESSAGES );
897            foreach ( $fallbacks as $lang ) {
898                $scripts = self::tryForKey( $this->languageScripts, $lang );
899                if ( $scripts ) {
900                    return $scripts;
901                }
902            }
903        }
904
905        return [];
906    }
907
908    public function setSkinStylesOverride( array $moduleSkinStyles ): void {
909        $moduleName = $this->getName();
910        foreach ( $moduleSkinStyles as $skinName => $overrides ) {
911            // If a module provides overrides for a skin, and that skin also provides overrides
912            // for the same module, then the module has precedence.
913            if ( isset( $this->skinStyles[$skinName] ) ) {
914                continue;
915            }
916
917            // If $moduleName in ResourceModuleSkinStyles is preceded with a '+', the defined style
918            // files will be added to 'default' skinStyles, otherwise 'default' will be ignored.
919            if ( isset( $overrides[$moduleName] ) ) {
920                $paths = (array)$overrides[$moduleName];
921                $styleFiles = [];
922            } elseif ( isset( $overrides['+' . $moduleName] ) ) {
923                $paths = (array)$overrides['+' . $moduleName];
924                $styleFiles = isset( $this->skinStyles['default'] ) ?
925                    (array)$this->skinStyles['default'] :
926                    [];
927            } else {
928                continue;
929            }
930
931            // Add new file paths, remapping them to refer to our directories and not use settings
932            // from the module we're modifying, which come from the base definition.
933            [ $localBasePath, $remoteBasePath ] = self::extractBasePaths( $overrides );
934
935            foreach ( $paths as $path ) {
936                $styleFiles[] = new FilePath( $path, $localBasePath, $remoteBasePath );
937            }
938
939            $this->skinStyles[$skinName] = $styleFiles;
940        }
941    }
942
943    /**
944     * Get a list of file paths for all styles in this module, in order of proper inclusion.
945     *
946     * @internal Exposed only for use by structure phpunit tests.
947     * @param Context $context
948     * @return array<string,array<int,string|FilePath>> Map from media type to list of file paths
949     */
950    public function getStyleFiles( Context $context ) {
951        return array_merge_recursive(
952            self::collateStyleFilesByMedia( $this->styles ),
953            self::collateStyleFilesByMedia(
954                self::tryForKey( $this->skinStyles, $context->getSkin(), 'default' )
955            )
956        );
957    }
958
959    /**
960     * Get a list of file paths for all skin styles in the module used by
961     * the skin.
962     *
963     * @param string $skinName The name of the skin
964     * @return array A list of file paths collated by media type
965     */
966    protected function getSkinStyleFiles( $skinName ) {
967        return self::collateStyleFilesByMedia(
968            self::tryForKey( $this->skinStyles, $skinName )
969        );
970    }
971
972    /**
973     * Get a list of file paths for all skin style files in the module,
974     * for all available skins.
975     *
976     * @return array A list of file paths collated by media type
977     */
978    protected function getAllSkinStyleFiles() {
979        $skinFactory = MediaWikiServices::getInstance()->getSkinFactory();
980        $styleFiles = [];
981
982        $internalSkinNames = array_keys( $skinFactory->getInstalledSkins() );
983        $internalSkinNames[] = 'default';
984
985        foreach ( $internalSkinNames as $internalSkinName ) {
986            $styleFiles = array_merge_recursive(
987                $styleFiles,
988                $this->getSkinStyleFiles( $internalSkinName )
989            );
990        }
991
992        return $styleFiles;
993    }
994
995    /**
996     * Get all style files and all skin style files used by this module.
997     *
998     * @return array
999     */
1000    public function getAllStyleFiles() {
1001        $collatedStyleFiles = array_merge_recursive(
1002            self::collateStyleFilesByMedia( $this->styles ),
1003            $this->getAllSkinStyleFiles()
1004        );
1005
1006        $result = [];
1007
1008        foreach ( $collatedStyleFiles as $styleFiles ) {
1009            foreach ( $styleFiles as $styleFile ) {
1010                $result[] = $this->getLocalPath( $styleFile );
1011            }
1012        }
1013
1014        return $result;
1015    }
1016
1017    /**
1018     * Read the contents of a list of CSS files and remap and concatenate these.
1019     *
1020     * @internal This is considered a private method. Exposed for internal use by WebInstallerOutput.
1021     * @param array<string,array<int,string|FilePath>> $styles Map of media type to file paths
1022     * @param Context $context
1023     * @return array<string,string> Map of combined CSS code, keyed by media type
1024     */
1025    public function readStyleFiles( array $styles, Context $context ) {
1026        if ( !$styles ) {
1027            return [];
1028        }
1029        foreach ( $styles as $media => $files ) {
1030            $uniqueFiles = array_unique( $files, SORT_REGULAR );
1031            $styleFiles = [];
1032            foreach ( $uniqueFiles as $file ) {
1033                $styleFiles[] = $this->readStyleFile( $file, $context );
1034            }
1035            $styles[$media] = implode( "\n", $styleFiles );
1036        }
1037        return $styles;
1038    }
1039
1040    /**
1041     * Read and process a style file. Reads a file from disk and runs it through processStyle().
1042     *
1043     * This method can be used as a callback for array_map()
1044     *
1045     * @internal
1046     * @param string|FilePath $path Path of style file to read
1047     * @param Context $context
1048     * @return string CSS code
1049     */
1050    protected function readStyleFile( $path, Context $context ) {
1051        $localPath = $this->getLocalPath( $path );
1052        $style = $this->getFileContents( $localPath, 'style' );
1053        $styleLang = $this->getStyleSheetLang( $localPath );
1054
1055        return $this->processStyle( $style, $styleLang, $path, $context );
1056    }
1057
1058    /**
1059     * Process a CSS/LESS string.
1060     *
1061     * This method performs the following processing steps:
1062     * - LESS compilation (if $styleLang = 'less')
1063     * - RTL flipping with CSSJanus (if getFlip() returns true)
1064     * - Registration of references to local files in $localFileRefs and $missingLocalFileRefs
1065     * - URL remapping and data URI embedding
1066     *
1067     * @internal
1068     * @param string $style CSS or LESS code
1069     * @param string $styleLang Language of $style code ('css' or 'less')
1070     * @param string|FilePath $path Path to code file, used for resolving relative file paths
1071     * @param Context $context
1072     * @return string Processed CSS code
1073     */
1074    protected function processStyle( $style, $styleLang, $path, Context $context ) {
1075        $localPath = $this->getLocalPath( $path );
1076        $remotePath = $this->getRemotePath( $path );
1077
1078        if ( $styleLang === 'less' ) {
1079            $style = $this->compileLessString( $style, $localPath, $context );
1080            $this->hasGeneratedStyles = true;
1081        }
1082
1083        if ( $this->getFlip( $context ) ) {
1084            $style = CSSJanus::transform(
1085                $style,
1086                /* $swapLtrRtlInURL = */ true,
1087                /* $swapLeftRightInURL = */ false
1088            );
1089            $this->hasGeneratedStyles = true;
1090        }
1091
1092        $localDir = dirname( $localPath );
1093        $remoteDir = dirname( $remotePath );
1094        // Get and register local file references
1095        $localFileRefs = CSSMin::getLocalFileReferences( $style, $localDir );
1096        foreach ( $localFileRefs as $file ) {
1097            if ( is_file( $file ) ) {
1098                $this->localFileRefs[] = $file;
1099            } else {
1100                $this->missingLocalFileRefs[] = $file;
1101            }
1102        }
1103        // Don't cache this call. remap() ensures data URIs embeds are up to date,
1104        // and urls contain correct content hashes in their query string. (T128668)
1105        return CSSMin::remap( $style, $localDir, $remoteDir, true );
1106    }
1107
1108    /**
1109     * Get whether CSS for this module should be flipped
1110     * @param Context $context
1111     * @return bool
1112     */
1113    public function getFlip( Context $context ) {
1114        return $context->getDirection() === 'rtl' && !$this->noflip;
1115    }
1116
1117    /**
1118     * Get the module's load type.
1119     *
1120     * @since 1.28
1121     * @return string
1122     */
1123    public function getType() {
1124        $canBeStylesOnly = !(
1125            // All options except 'styles', 'skinStyles' and 'debugRaw'
1126            $this->scripts
1127            || $this->debugScripts
1128            || $this->templates
1129            || $this->languageScripts
1130            || $this->skinScripts
1131            || $this->dependencies
1132            || $this->messages
1133            || $this->skipFunction
1134            || $this->packageFiles
1135        );
1136        return $canBeStylesOnly ? self::LOAD_STYLES : self::LOAD_GENERAL;
1137    }
1138
1139    /**
1140     * Compile a LESS string into CSS.
1141     *
1142     * Keeps track of all used files and adds them to localFileRefs.
1143     *
1144     * @since 1.35
1145     * @param string $style LESS source to compile
1146     * @param string $stylePath File path of LESS source, used for resolving relative file paths
1147     * @param Context $context Context in which to generate script
1148     * @return string CSS source
1149     */
1150    protected function compileLessString( $style, $stylePath, Context $context ) {
1151        static $cache;
1152        // @TODO: dependency injection
1153        if ( !$cache ) {
1154            $cache = MediaWikiServices::getInstance()->getObjectCacheFactory()
1155                ->getLocalServerInstance( CACHE_HASH );
1156        }
1157
1158        $skinName = $context->getSkin();
1159        $skinImportPaths = ExtensionRegistry::getInstance()->getAttribute( 'SkinLessImportPaths' );
1160        $importDirs = [];
1161        if ( isset( $skinImportPaths[ $skinName ] ) ) {
1162            $importDirs[] = $skinImportPaths[ $skinName ];
1163        }
1164
1165        $vars = $this->getLessVars( $context );
1166        // Construct a cache key from a hash of the LESS source, and a hash digest
1167        // of the LESS variables and import dirs used for compilation.
1168        ksort( $vars );
1169        $compilerParams = [
1170            'vars' => $vars,
1171            'importDirs' => $importDirs,
1172            // CodexDevelopmentDir affects import path mapping in ResourceLoader::getLessCompiler(),
1173            // so take that into account too
1174            'codexDevDir' => $this->getConfig()->get( MainConfigNames::CodexDevelopmentDir )
1175        ];
1176        $key = $cache->makeGlobalKey(
1177            'resourceloader-less',
1178            'v1',
1179            hash( 'md4', $style ),
1180            hash( 'md4', serialize( $compilerParams ) )
1181        );
1182
1183        // If we got a cached value, we have to validate it by getting a checksum of all the
1184        // files that were loaded by the parser and ensuring it matches the cached entry's.
1185        $data = $cache->get( $key );
1186        // T425356: Expand here to avoid implicit reliance on global getcwd() matching MW_INSTALL_PATH.
1187        $files = $data ? Module::expandRelativePaths( $data['files'] ) : false;
1188
1189        if (
1190            !$data ||
1191            $data['hash'] !== FileContentsHasher::getFileContentsHash( $files )
1192        ) {
1193            $compiler = $context->getResourceLoader()->getLessCompiler( $vars, $importDirs );
1194
1195            $css = $compiler->parse( $style, $stylePath )->getCss();
1196            $files = $compiler->getParsedFiles();
1197            $data = [
1198                'css'   => $css,
1199                'files' => Module::getRelativePaths( $files ),
1200                // T253055: store the implicit dependency paths in a form relative to any install
1201                // path so that multiple version of the application can share the cache for identical
1202                // less stylesheets. This also avoids churn during application updates.
1203                'hash'  => FileContentsHasher::getFileContentsHash( $files )
1204            ];
1205            $cache->set( $key, $data, $cache::TTL_DAY );
1206        }
1207
1208        foreach ( $files as $path ) {
1209            $this->localFileRefs[] = $path;
1210        }
1211
1212        return $data['css'];
1213    }
1214
1215    /**
1216     * Get content of named templates for this module.
1217     *
1218     * @return array<string,string> Templates mapping template alias to content
1219     */
1220    public function getTemplates() {
1221        $templates = [];
1222
1223        foreach ( $this->templates as $alias => $templatePath ) {
1224            // Alias is optional
1225            if ( is_int( $alias ) ) {
1226                $alias = $this->getPath( $templatePath );
1227            }
1228            $localPath = $this->getLocalPath( $templatePath );
1229            $content = $this->getFileContents( $localPath, 'template' );
1230
1231            $templates[$alias] = $this->stripBom( $content );
1232        }
1233        return $templates;
1234    }
1235
1236    /**
1237     * Internal helper for use by getPackageFiles(), getFileHashes() and getDefinitionSummary().
1238     *
1239     * This expands the 'packageFiles' definition into something that's (almost) the right format
1240     * for getPackageFiles() to return. It expands shorthands, resolves config vars, and handles
1241     * summarising any non-file data for getVersionHash(). For file-based data, getFileHashes()
1242     * handles it instead, which also ends up in getDefinitionSummary().
1243     *
1244     * What it does not do is reading the actual contents of any specified files, nor invoking
1245     * the computation callbacks. Those things are done by getPackageFiles() instead to improve
1246     * backend performance by only doing this work when the module response is needed, and not
1247     * when merely computing the version hash for StartupModule, or when checking
1248     * If-None-Match headers for a HTTP 304 response.
1249     *
1250     * @param Context $context
1251     * @return array|null Array of arrays as returned by expandFileInfo(), with the key being
1252     *   the file name, or null if this is not a package file module.
1253     * @phan-return array{main:?string,files:array[]}|null
1254     */
1255    private function expandPackageFiles( Context $context ) {
1256        $hash = $context->getHash();
1257        if ( isset( $this->expandedPackageFiles[$hash] ) ) {
1258            return $this->expandedPackageFiles[$hash];
1259        }
1260        if ( $this->packageFiles === null ) {
1261            return null;
1262        }
1263        $expandedFiles = [];
1264        $mainFile = null;
1265
1266        foreach ( $this->packageFiles as $key => $fileInfo ) {
1267            $expanded = $this->expandFileInfo( $context, $fileInfo, "packageFiles[$key]" );
1268            $fileName = $expanded['name'];
1269            if ( !empty( $expanded['main'] ) ) {
1270                unset( $expanded['main'] );
1271                $type = $expanded['type'];
1272                $mainFile = $fileName;
1273                if ( $type !== 'script' && $type !== 'script-vue' ) {
1274                    $msg = "Main file in package must be of type 'script', module " .
1275                        "'{$this->getName()}', main file '{$mainFile}' is '{$type}'.";
1276                    $this->getLogger()->error( $msg );
1277                    throw new LogicException( $msg );
1278                }
1279            }
1280            $expandedFiles[$fileName] = $expanded;
1281        }
1282
1283        if ( $expandedFiles && $mainFile === null ) {
1284            // The first package file that is a script is the main file
1285            foreach ( $expandedFiles as $path => $file ) {
1286                if ( $file['type'] === 'script' || $file['type'] === 'script-vue' ) {
1287                    $mainFile = $path;
1288                    break;
1289                }
1290            }
1291        }
1292
1293        $result = [
1294            'main' => $mainFile,
1295            'files' => $expandedFiles
1296        ];
1297
1298        $this->expandedPackageFiles[$hash] = $result;
1299        return $result;
1300    }
1301
1302    /**
1303     * Process a file info array as specified in configuration or extension.json,
1304     * expanding shortcuts and callbacks.
1305     *
1306     * @see MainConfigSchema::ResourceModules
1307     *
1308     * @param Context $context
1309     * @param array|string|FilePath $fileInfo
1310     * @param string $debugKey
1311     * @return array An associative array with the following keys:
1312     *   - name: (string) The filename relative to the module base. This is unique only within
1313     *     the context of the current module. It may be a virtual name.
1314     *   - type: (string) May be 'script', 'script-vue', 'data' or 'text'
1315     *   - filePath: (FilePath) The FilePath object which should be used to load the content.
1316     *     This will be absent if the content was loaded another way.
1317     *   - virtualFilePath: (FilePath) A FilePath object for a virtual path which doesn't actually
1318     *     exist. This is used for source map generation. Optional.
1319     *   - versionFilePath: (FilePath) A FilePath object which is the ultimate source of a
1320     *     generated file. The timestamp and contents will be used for version generation.
1321     *     Generated by the callback specified in versionCallback. Optional.
1322     *   - content: (string|mixed) If the 'type' element is 'script', this is a string containing
1323     *     JS code, being the contents of the script file. For any other type, this contains data
1324     *     which will be JSON serialized. Optional, if not set, it will be set in readFileInfo().
1325     *   - callback: (callable) A callback to call to obtain the contents. This will be set if the
1326     *     version callback was present in the input, indicating that the callback is expensive.
1327     *   - callbackParam: (array) The parameters to be passed to the callback.
1328     *   - definitionSummary: (array) The data returned by the version callback.
1329     *   - main: (bool) Whether the file is the main file of the package.
1330     */
1331    private function expandFileInfo( Context $context, $fileInfo, $debugKey ) {
1332        if ( is_string( $fileInfo ) ) {
1333            // Inline common case
1334            return [
1335                'name' => $fileInfo,
1336                'type' => self::getPackageFileType( $fileInfo ),
1337                'filePath' => new FilePath( $fileInfo, $this->localBasePath, $this->remoteBasePath )
1338            ];
1339        } elseif ( $fileInfo instanceof FilePath ) {
1340            $fileInfo = [
1341                'name' => $fileInfo->getPath(),
1342                'file' => $fileInfo
1343            ];
1344        } elseif ( !is_array( $fileInfo ) ) {
1345            $msg = "Invalid type in $debugKey for module '{$this->getName()}', " .
1346                "must be array, string or FilePath";
1347            $this->getLogger()->error( $msg );
1348            throw new LogicException( $msg );
1349        }
1350        if ( !isset( $fileInfo['name'] ) ) {
1351            $msg = "Missing 'name' key in $debugKey for module '{$this->getName()}'";
1352            $this->getLogger()->error( $msg );
1353            throw new LogicException( $msg );
1354        }
1355        $fileName = $this->getPath( $fileInfo['name'] );
1356
1357        // Infer type from alias if needed
1358        $type = $fileInfo['type'] ?? self::getPackageFileType( $fileName );
1359        $expanded = [
1360            'name' => $fileName,
1361            'type' => $type
1362        ];
1363        if ( !empty( $fileInfo['main'] ) ) {
1364            $expanded['main'] = true;
1365        }
1366
1367        // Perform expansions (except 'file' and 'callback'), creating one of these keys:
1368        // - 'content': literal value.
1369        // - 'filePath': content to be read from a file.
1370        // - 'callback': content computed by a callable.
1371        if ( isset( $fileInfo['content'] ) ) {
1372            $expanded['content'] = $fileInfo['content'];
1373        } elseif ( isset( $fileInfo['file'] ) ) {
1374            $expanded['filePath'] = $this->makeFilePath( $fileInfo['file'] );
1375        } elseif ( isset( $fileInfo['callback'] ) ) {
1376            // If no extra parameter for the callback is given, use null.
1377            $expanded['callbackParam'] = $fileInfo['callbackParam'] ?? null;
1378
1379            if ( !is_callable( $fileInfo['callback'] ) ) {
1380                $msg = "Invalid 'callback' for module '{$this->getName()}', file '{$fileName}'.";
1381                $this->getLogger()->error( $msg );
1382                throw new LogicException( $msg );
1383            }
1384            if ( isset( $fileInfo['versionCallback'] ) ) {
1385                if ( !is_callable( $fileInfo['versionCallback'] ) ) {
1386                    throw new LogicException( "Invalid 'versionCallback' for "
1387                        . "module '{$this->getName()}', file '{$fileName}'."
1388                    );
1389                }
1390
1391                // Execute the versionCallback with the same arguments that
1392                // would be given to the callback
1393                $callbackResult = ( $fileInfo['versionCallback'] )(
1394                    $context,
1395                    $this->getConfig(),
1396                    $expanded['callbackParam']
1397                );
1398                if ( $callbackResult instanceof FilePath ) {
1399                    $callbackResult->initBasePaths( $this->localBasePath, $this->remoteBasePath );
1400                    $expanded['versionFilePath'] = $callbackResult;
1401                } else {
1402                    $expanded['definitionSummary'] = $callbackResult;
1403                }
1404                // Don't invoke 'callback' here as it may be expensive (T223260).
1405                $expanded['callback'] = $fileInfo['callback'];
1406            } else {
1407                // Else go ahead invoke callback with its arguments.
1408                $callbackResult = ( $fileInfo['callback'] )(
1409                    $context,
1410                    $this->getConfig(),
1411                    $expanded['callbackParam']
1412                );
1413                if ( $callbackResult instanceof FilePath ) {
1414                    $callbackResult->initBasePaths( $this->localBasePath, $this->remoteBasePath );
1415                    $expanded['filePath'] = $callbackResult;
1416                } else {
1417                    $expanded['content'] = $callbackResult;
1418                }
1419            }
1420        } elseif ( isset( $fileInfo['config'] ) ) {
1421            if ( $type !== 'data' ) {
1422                $msg = "Key 'config' only valid for data files. "
1423                    . " Module '{$this->getName()}', file '{$fileName}' is '{$type}'.";
1424                $this->getLogger()->error( $msg );
1425                throw new LogicException( $msg );
1426            }
1427            $expandedConfig = [];
1428            foreach ( $fileInfo['config'] as $configKey => $var ) {
1429                $expandedConfig[ is_numeric( $configKey ) ? $var : $configKey ] = $this->getConfig()->get( $var );
1430            }
1431            $expanded['content'] = $expandedConfig;
1432        } elseif ( !empty( $fileInfo['main'] ) ) {
1433            // [ 'name' => 'foo.js', 'main' => true ] is shorthand
1434            $expanded['filePath'] = $this->makeFilePath( $fileName );
1435        } else {
1436            $msg = "Incomplete definition for module '{$this->getName()}', file '{$fileName}'. "
1437                . "One of 'file', 'content', 'callback', or 'config' must be set.";
1438            $this->getLogger()->error( $msg );
1439            throw new LogicException( $msg );
1440        }
1441        if ( !isset( $expanded['filePath'] ) ) {
1442            $expanded['virtualFilePath'] = $this->makeFilePath( $fileName );
1443        }
1444        return $expanded;
1445    }
1446
1447    /**
1448     * Cast a FilePath or string to a FilePath
1449     *
1450     * @param FilePath|string $path
1451     * @return FilePath
1452     */
1453    private function makeFilePath( $path ): FilePath {
1454        if ( $path instanceof FilePath ) {
1455            return $path;
1456        } elseif ( is_string( $path ) ) {
1457            return new FilePath( $path, $this->localBasePath, $this->remoteBasePath );
1458        } else {
1459            throw new InvalidArgumentException( '$path must be either FilePath or string' );
1460        }
1461    }
1462
1463    /**
1464     * Resolve the package files definition and generate the content of each package file.
1465     *
1466     * @param Context $context
1467     * @return array|null Package files data structure, see Module::getScript()
1468     */
1469    public function getPackageFiles( Context $context ) {
1470        if ( $this->packageFiles === null ) {
1471            return null;
1472        }
1473        $hash = $context->getHash();
1474        if ( isset( $this->fullyExpandedPackageFiles[ $hash ] ) ) {
1475            return $this->fullyExpandedPackageFiles[ $hash ];
1476        }
1477        $expandedPackageFiles = $this->expandPackageFiles( $context ) ?? [];
1478
1479        // T402278: use array_map() to avoid &references here
1480        $expandedPackageFiles['files'] = array_map( function ( array $fileInfo ) use ( $context ): array {
1481            return $this->readFileInfo( $context, $fileInfo );
1482        }, $expandedPackageFiles['files'] );
1483
1484        $this->fullyExpandedPackageFiles[ $hash ] = $expandedPackageFiles;
1485        return $expandedPackageFiles;
1486    }
1487
1488    /**
1489     * Given a file info array as returned by expandFileInfo(), expand the file paths and
1490     * remaining callbacks, ensuring that the 'content' element is populated. Return a
1491     * modified copy of the array, removing intermediate data such as callback parameters.
1492     *
1493     * @param Context $context
1494     * @param array $fileInfo
1495     * @return array
1496     */
1497    private function readFileInfo( Context $context, array $fileInfo ): array {
1498        // Turn any 'filePath' or 'callback' key into actual 'content',
1499        // and remove the key after that. The callback could return a
1500        // FilePath object; if that happens, fall through to the 'filePath'
1501        // handling.
1502        if ( !isset( $fileInfo['content'] ) && isset( $fileInfo['callback'] ) ) {
1503            $callbackResult = ( $fileInfo['callback'] )(
1504                $context,
1505                $this->getConfig(),
1506                $fileInfo['callbackParam']
1507            );
1508            if ( $callbackResult instanceof FilePath ) {
1509                // Fall through to the filePath handling code below
1510                $fileInfo['filePath'] = $callbackResult;
1511            } else {
1512                $fileInfo['content'] = $callbackResult;
1513            }
1514            unset( $fileInfo['callback'] );
1515        }
1516        // Only interpret 'filePath' if 'content' hasn't been set already.
1517        // This can happen if 'versionCallback' provided 'filePath',
1518        // while 'callback' provides 'content'. In that case both are set
1519        // at this point. The 'filePath' from 'versionCallback' in that case is
1520        // only to inform getDefinitionSummary().
1521        if ( !isset( $fileInfo['content'] ) && isset( $fileInfo['filePath'] ) ) {
1522            $localPath = $this->getLocalPath( $fileInfo['filePath'] );
1523            $content = $this->getFileContents( $localPath, 'package' );
1524            if ( $fileInfo['type'] === 'data' ) {
1525                $content = json_decode( $content, false, 512, JSON_THROW_ON_ERROR );
1526            }
1527            $fileInfo['content'] = $content;
1528        }
1529        if ( $fileInfo['type'] === 'script-vue' ) {
1530            try {
1531                $fileInfo[ 'content' ] = $this->parseVueContent( $context, $fileInfo[ 'content' ] );
1532            } catch ( InvalidArgumentException $e ) {
1533                $msg = "Error parsing file '{$fileInfo['name']}' in module '{$this->getName()}': " .
1534                    "{$e->getMessage()}";
1535                $this->getLogger()->error( $msg );
1536                throw new RuntimeException( $msg );
1537            }
1538            $fileInfo['type'] = 'script+style';
1539        }
1540        if ( !isset( $fileInfo['content'] ) ) {
1541            // This should not be possible due to validation in expandFileInfo()
1542            $msg = "Unable to resolve contents for file {$fileInfo['name']}";
1543            $this->getLogger()->error( $msg );
1544            throw new RuntimeException( $msg );
1545        }
1546
1547        // Not needed for client response, exists for use by getDefinitionSummary().
1548        unset( $fileInfo['definitionSummary'] );
1549        // Not needed for client response, used by callbacks only.
1550        unset( $fileInfo['callbackParam'] );
1551
1552        return $fileInfo;
1553    }
1554
1555    /**
1556     * Take an input string and remove the UTF-8 BOM character if present
1557     *
1558     * We need to remove these after reading a file, because we concatenate our files and
1559     * the BOM character is not valid in the middle of a string.
1560     * We already assume UTF-8 everywhere, so this should be safe.
1561     *
1562     * @param string $input
1563     * @return string Input minus the initial BOM char
1564     */
1565    protected function stripBom( $input ) {
1566        if ( str_starts_with( $input, "\xef\xbb\xbf" ) ) {
1567            return substr( $input, 3 );
1568        }
1569        return $input;
1570    }
1571}
1572
1573/**
1574 * NOTE: This is kept as a stable class alias, intentionally not deprecated (T398827).
1575 *
1576 * @since 1.32
1577 */
1578class_alias( FileModule::class, 'MediaWiki\ResourceLoader\LessVarFileModule' );