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