Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
78.63% covered (warning)
78.63%
460 / 585
47.83% covered (danger)
47.83%
22 / 46
CRAP
0.00% covered (danger)
0.00%
0 / 1
FileModule
78.63% covered (warning)
78.63%
460 / 585
47.83% covered (danger)
47.83%
22 / 46
724.29
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.22% covered (success)
97.22%
35 / 36
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 RuntimeException;
36use Wikimedia\Minify\CSSMin;
37use Wikimedia\RequestTimeout\TimeoutException;
38
39/**
40 * Module based on local JavaScript/CSS files.
41 *
42 * The following public methods can query the database:
43 *
44 * - getDefinitionSummary / … / Module::getFileDependencies.
45 * - getVersionHash / getDefinitionSummary / … / Module::getFileDependencies.
46 * - getStyles / Module::saveFileDependencies.
47 *
48 * @ingroup ResourceLoader
49 * @see $wgResourceModules
50 * @since 1.17
51 */
52class FileModule extends Module {
53    /** @var string Local base path, see __construct() */
54    protected $localBasePath = '';
55
56    /** @var string Remote base path, see __construct() */
57    protected $remoteBasePath = '';
58
59    /**
60     * @var array<int,string|FilePath> List of JavaScript file paths to always include
61     */
62    protected $scripts = [];
63
64    /**
65     * @var array<string,array<int,string|FilePath>> Lists of JavaScript files by language code
66     */
67    protected $languageScripts = [];
68
69    /**
70     * @var array<string,array<int,string|FilePath>> Lists of JavaScript files by skin name
71     */
72    protected $skinScripts = [];
73
74    /**
75     * @var array<int,string|FilePath> List of paths to JavaScript files to include in debug mode
76     */
77    protected $debugScripts = [];
78
79    /**
80     * @var array<int,string|FilePath> List of CSS file files to always include
81     */
82    protected $styles = [];
83
84    /**
85     * @var array<string,array<int,string|FilePath>> Lists of CSS files by skin name
86     */
87    protected $skinStyles = [];
88
89    /**
90     * Packaged files definition, to bundle and make available client-side via `require()`.
91     *
92     * @see FileModule::expandPackageFiles()
93     * @var null|array
94     * @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}>
95     */
96    protected $packageFiles = null;
97
98    /**
99     * @var array Expanded versions of $packageFiles, lazy-computed by expandPackageFiles();
100     *  keyed by context hash
101     */
102    private $expandedPackageFiles = [];
103
104    /**
105     * @var array Further expanded versions of $expandedPackageFiles, lazy-computed by
106     *   getPackageFiles(); keyed by context hash
107     */
108    private $fullyExpandedPackageFiles = [];
109
110    /**
111     * @var string[] List of modules this module depends on
112     */
113    protected $dependencies = [];
114
115    /**
116     * @var null|string File name containing the body of the skip function
117     */
118    protected $skipFunction = null;
119
120    /**
121     * @var string[] List of message keys used by this module
122     */
123    protected $messages = [];
124
125    /** @var array<int|string,string|FilePath> List of the named templates used by this module */
126    protected $templates = [];
127
128    /** @var null|string Name of group to load this module in */
129    protected $group = null;
130
131    /** @var bool Link to raw files in debug mode */
132    protected $debugRaw = true;
133
134    /** @var bool Whether CSSJanus flipping should be skipped for this module */
135    protected $noflip = false;
136
137    /** @var bool Whether to skip the structure test ResourcesTest::testRespond() */
138    protected $skipStructureTest = false;
139
140    /**
141     * @var bool Whether getStyleURLsForDebug should return raw file paths,
142     * or return load.php urls
143     */
144    protected $hasGeneratedStyles = false;
145
146    /**
147     * @var string[] Place where readStyleFile() tracks file dependencies
148     */
149    protected $localFileRefs = [];
150
151    /**
152     * @var string[] Place where readStyleFile() tracks file dependencies for non-existent files.
153     * Used in tests to detect missing dependencies.
154     */
155    protected $missingLocalFileRefs = [];
156
157    /**
158     * @var VueComponentParser|null Lazy-created by getVueComponentParser()
159     */
160    protected $vueComponentParser = null;
161
162    /**
163     * Construct a new module from an options array.
164     *
165     * @param array $options See $wgResourceModules for the available options.
166     * @param string|null $localBasePath Base path to prepend to all local paths in $options.
167     *     Defaults to MW_INSTALL_PATH
168     * @param string|null $remoteBasePath Base path to prepend to all remote paths in $options.
169     *     Defaults to $wgResourceBasePath
170     */
171    public function __construct(
172        array $options = [],
173        string $localBasePath = null,
174        string $remoteBasePath = null
175    ) {
176        // Flag to decide whether to automagically add the mediawiki.template module
177        $hasTemplates = false;
178        // localBasePath and remoteBasePath both have unbelievably long fallback chains
179        // and need to be handled separately.
180        [ $this->localBasePath, $this->remoteBasePath ] =
181            self::extractBasePaths( $options, $localBasePath, $remoteBasePath );
182
183        // Extract, validate and normalise remaining options
184        foreach ( $options as $member => $option ) {
185            switch ( $member ) {
186                // Lists of file paths
187                case 'scripts':
188                case 'debugScripts':
189                case 'styles':
190                case 'packageFiles':
191                    $this->{$member} = is_array( $option ) ? $option : [ $option ];
192                    break;
193                case 'templates':
194                    $hasTemplates = true;
195                    $this->{$member} = is_array( $option ) ? $option : [ $option ];
196                    break;
197                // Collated lists of file paths
198                case 'languageScripts':
199                case 'skinScripts':
200                case 'skinStyles':
201                    if ( !is_array( $option ) ) {
202                        throw new InvalidArgumentException(
203                            "Invalid collated file path list error. " .
204                            "'$option' given, array expected."
205                        );
206                    }
207                    foreach ( $option as $key => $value ) {
208                        if ( !is_string( $key ) ) {
209                            throw new InvalidArgumentException(
210                                "Invalid collated file path list key error. " .
211                                "'$key' given, string expected."
212                            );
213                        }
214                        $this->{$member}[$key] = is_array( $value ) ? $value : [ $value ];
215                    }
216                    break;
217                case 'deprecated':
218                    $this->deprecated = $option;
219                    break;
220                // Lists of strings
221                case 'dependencies':
222                case 'messages':
223                    // Normalise
224                    $option = array_values( array_unique( (array)$option ) );
225                    sort( $option );
226
227                    $this->{$member} = $option;
228                    break;
229                // Single strings
230                case 'group':
231                case 'skipFunction':
232                    $this->{$member} = (string)$option;
233                    break;
234                // Single booleans
235                case 'debugRaw':
236                case 'noflip':
237                case 'skipStructureTest':
238                    $this->{$member} = (bool)$option;
239                    break;
240            }
241        }
242        if ( isset( $options['scripts'] ) && isset( $options['packageFiles'] ) ) {
243            throw new InvalidArgumentException( "A module may not set both 'scripts' and 'packageFiles'" );
244        }
245        if ( isset( $options['packageFiles'] ) && isset( $options['skinScripts'] ) ) {
246            throw new InvalidArgumentException( "Options 'skinScripts' and 'packageFiles' cannot be used together." );
247        }
248        if ( $hasTemplates ) {
249            $this->dependencies[] = 'mediawiki.template';
250            // Ensure relevant template compiler module gets loaded
251            foreach ( $this->templates as $alias => $templatePath ) {
252                if ( is_int( $alias ) ) {
253                    $alias = $this->getPath( $templatePath );
254                }
255                $suffix = explode( '.', $alias );
256                $suffix = end( $suffix );
257                $compilerModule = 'mediawiki.template.' . $suffix;
258                if ( $suffix !== 'html' && !in_array( $compilerModule, $this->dependencies ) ) {
259                    $this->dependencies[] = $compilerModule;
260                }
261            }
262        }
263    }
264
265    /**
266     * Extract a pair of local and remote base paths from module definition information.
267     * Implementation note: the amount of global state used in this function is staggering.
268     *
269     * @param array $options Module definition
270     * @param string|null $localBasePath Path to use if not provided in module definition. Defaults
271     *     to MW_INSTALL_PATH
272     * @param string|null $remoteBasePath Path to use if not provided in module definition. Defaults
273     *     to $wgResourceBasePath
274     * @return string[] [ localBasePath, remoteBasePath ]
275     */
276    public static function extractBasePaths(
277        array $options = [],
278        $localBasePath = null,
279        $remoteBasePath = null
280    ) {
281        // The different ways these checks are done, and their ordering, look very silly,
282        // but were preserved for backwards-compatibility just in case. Tread lightly.
283
284        if ( $remoteBasePath === null ) {
285            $remoteBasePath = MediaWikiServices::getInstance()->getMainConfig()
286                ->get( MainConfigNames::ResourceBasePath );
287        }
288
289        if ( isset( $options['remoteExtPath'] ) ) {
290            $extensionAssetsPath = MediaWikiServices::getInstance()->getMainConfig()
291                ->get( MainConfigNames::ExtensionAssetsPath );
292            $remoteBasePath = $extensionAssetsPath . '/' . $options['remoteExtPath'];
293        }
294
295        if ( isset( $options['remoteSkinPath'] ) ) {
296            $stylePath = MediaWikiServices::getInstance()->getMainConfig()
297                ->get( MainConfigNames::StylePath );
298            $remoteBasePath = $stylePath . '/' . $options['remoteSkinPath'];
299        }
300
301        if ( array_key_exists( 'localBasePath', $options ) ) {
302            $localBasePath = (string)$options['localBasePath'];
303        }
304
305        if ( array_key_exists( 'remoteBasePath', $options ) ) {
306            $remoteBasePath = (string)$options['remoteBasePath'];
307        }
308
309        if ( $remoteBasePath === '' ) {
310            // If MediaWiki is installed at the document root (not recommended),
311            // then wgScriptPath is set to the empty string by the installer to
312            // ensure safe concatenating of file paths (avoid "/" + "/foo" being "//foo").
313            // However, this also means the path itself can be an invalid URI path,
314            // as those must start with a slash. Within ResourceLoader, we will not
315            // do such primitive/unsafe slash concatenation and use URI resolution
316            // instead, so beyond this point, to avoid fatal errors in CSSMin::resolveUrl(),
317            // do a best-effort support for docroot installs by casting this to a slash.
318            $remoteBasePath = '/';
319        }
320
321        return [ $localBasePath ?? MW_INSTALL_PATH, $remoteBasePath ];
322    }
323
324    public function getScript( Context $context ) {
325        $packageFiles = $this->getPackageFiles( $context );
326        if ( $packageFiles !== null ) {
327            foreach ( $packageFiles['files'] as &$file ) {
328                if ( $file['type'] === 'script+style' ) {
329                    $file['content'] = $file['content']['script'];
330                    $file['type'] = 'script';
331                }
332            }
333            return $packageFiles;
334        }
335
336        $files = $this->getScriptFiles( $context );
337        foreach ( $files as &$file ) {
338            $this->readFileInfo( $context, $file );
339        }
340        return [ 'plainScripts' => $files ];
341    }
342
343    /**
344     * @param Context $context
345     * @return string[] URLs
346     */
347    public function getScriptURLsForDebug( Context $context ) {
348        $rl = $context->getResourceLoader();
349        $config = $this->getConfig();
350        $server = $config->get( MainConfigNames::Server );
351
352        $urls = [];
353        foreach ( $this->getScriptFiles( $context ) as $file ) {
354            if ( isset( $file['filePath'] ) ) {
355                $url = OutputPage::transformResourcePath( $config, $this->getRemotePath( $file['filePath'] ) );
356                // Expand debug URL in case we are another wiki's module source (T255367)
357                $url = $rl->expandUrl( $server, $url );
358                $urls[] = $url;
359            }
360        }
361        return $urls;
362    }
363
364    /**
365     * @return bool
366     */
367    public function supportsURLLoading() {
368        // phpcs:ignore Generic.WhiteSpace.LanguageConstructSpacing.IncorrectSingle
369        return
370            // Denied by options?
371            $this->debugRaw
372            // If package files are involved, don't support URL loading, because that breaks
373            // scoped require() functions
374            && !$this->packageFiles
375            // Can't link to scripts generated by callbacks
376            && !$this->hasGeneratedScripts();
377    }
378
379    public function shouldSkipStructureTest() {
380        return $this->skipStructureTest || parent::shouldSkipStructureTest();
381    }
382
383    /**
384     * Determine whether the module may potentially have generated scripts.
385     *
386     * @return bool
387     */
388    private function hasGeneratedScripts() {
389        foreach (
390            [ $this->scripts, $this->languageScripts, $this->skinScripts, $this->debugScripts ]
391            as $scripts
392        ) {
393            foreach ( $scripts as $script ) {
394                if ( is_array( $script ) ) {
395                    if ( isset( $script['callback'] ) || isset( $script['versionCallback'] ) ) {
396                        return true;
397                    }
398                }
399            }
400        }
401        return false;
402    }
403
404    /**
405     * Get all styles for a given context.
406     *
407     * @param Context $context
408     * @return string[] CSS code for $context as an associative array mapping media type to CSS text.
409     */
410    public function getStyles( Context $context ) {
411        $styles = $this->readStyleFiles(
412            $this->getStyleFiles( $context ),
413            $context
414        );
415
416        $packageFiles = $this->getPackageFiles( $context );
417        if ( $packageFiles !== null ) {
418            foreach ( $packageFiles['files'] as $fileName => $file ) {
419                if ( $file['type'] === 'script+style' ) {
420                    $style = $this->processStyle(
421                        $file['content']['style'],
422                        $file['content']['styleLang'],
423                        $fileName,
424                        $context
425                    );
426                    $styles['all'] = ( $styles['all'] ?? '' ) . "\n" . $style;
427                }
428            }
429        }
430
431        // Track indirect file dependencies so that StartUpModule can check for
432        // on-disk file changes to any of this files without having to recompute the file list
433        $this->saveFileDependencies( $context, $this->localFileRefs );
434
435        return $styles;
436    }
437
438    /**
439     * @param Context $context
440     * @return string[][] Lists of URLs by media type
441     */
442    public function getStyleURLsForDebug( Context $context ) {
443        if ( $this->hasGeneratedStyles ) {
444            // Do the default behaviour of returning a url back to load.php
445            // but with only=styles.
446            return parent::getStyleURLsForDebug( $context );
447        }
448        // Our module consists entirely of real css files,
449        // in debug mode we can load those directly.
450        $urls = [];
451        foreach ( $this->getStyleFiles( $context ) as $mediaType => $list ) {
452            $urls[$mediaType] = [];
453            foreach ( $list as $file ) {
454                $urls[$mediaType][] = OutputPage::transformResourcePath(
455                    $this->getConfig(),
456                    $this->getRemotePath( $file )
457                );
458            }
459        }
460        return $urls;
461    }
462
463    /**
464     * Get message keys used by this module.
465     *
466     * @return string[] List of message keys
467     */
468    public function getMessages() {
469        return $this->messages;
470    }
471
472    /**
473     * Get the name of the group this module should be loaded in.
474     *
475     * @return null|string Group name
476     */
477    public function getGroup() {
478        return $this->group;
479    }
480
481    /**
482     * Get names of modules this module depends on.
483     *
484     * @param Context|null $context
485     * @return string[] List of module names
486     */
487    public function getDependencies( Context $context = null ) {
488        return $this->dependencies;
489    }
490
491    /**
492     * Helper method for getting a file.
493     *
494     * @param string $localPath The path to the resource to load
495     * @param string $type The type of resource being loaded (for error reporting only)
496     * @return string
497     */
498    private function getFileContents( $localPath, $type ) {
499        if ( !is_file( $localPath ) ) {
500            throw new RuntimeException( "$type file not found or not a file: \"$localPath\"" );
501        }
502        return $this->stripBom( file_get_contents( $localPath ) );
503    }
504
505    /**
506     * @return null|string
507     */
508    public function getSkipFunction() {
509        if ( !$this->skipFunction ) {
510            return null;
511        }
512        $localPath = $this->getLocalPath( $this->skipFunction );
513        return $this->getFileContents( $localPath, 'skip function' );
514    }
515
516    public function requiresES6() {
517        return true;
518    }
519
520    /**
521     * Disable module content versioning.
522     *
523     * This class uses getDefinitionSummary() instead, to avoid filesystem overhead
524     * involved with building the full module content inside a startup request.
525     *
526     * @return bool
527     */
528    public function enableModuleContentVersion() {
529        return false;
530    }
531
532    /**
533     * Helper method for getDefinitionSummary.
534     *
535     * @param Context $context
536     * @return string Hash
537     */
538    private function getFileHashes( Context $context ) {
539        $files = [];
540
541        foreach ( $this->getStyleFiles( $context ) as $filePaths ) {
542            foreach ( $filePaths as $filePath ) {
543                $files[] = $this->getLocalPath( $filePath );
544            }
545        }
546
547        // Extract file paths for package files
548        // Optimisation: Use foreach() and isset() instead of array_map/array_filter.
549        // This is a hot code path, called by StartupModule for thousands of modules.
550        $expandedPackageFiles = $this->expandPackageFiles( $context );
551        if ( $expandedPackageFiles ) {
552            foreach ( $expandedPackageFiles['files'] as $fileInfo ) {
553                if ( isset( $fileInfo['filePath'] ) ) {
554                    /** @var FilePath $filePath */
555                    $filePath = $fileInfo['filePath'];
556                    $files[] = $filePath->getLocalPath();
557                }
558            }
559        }
560
561        // Add other configured paths
562        $scriptFileInfos = $this->getScriptFiles( $context );
563        foreach ( $scriptFileInfos as $fileInfo ) {
564            $filePath = $fileInfo['filePath'] ?? $fileInfo['versionFilePath'] ?? null;
565            if ( $filePath instanceof FilePath ) {
566                $files[] = $filePath->getLocalPath();
567            }
568        }
569
570        foreach ( $this->templates as $filePath ) {
571            $files[] = $this->getLocalPath( $filePath );
572        }
573
574        if ( $this->skipFunction ) {
575            $files[] = $this->getLocalPath( $this->skipFunction );
576        }
577
578        // Add any lazily discovered file dependencies from previous module builds.
579        // These are already absolute paths.
580        foreach ( $this->getFileDependencies( $context ) as $file ) {
581            $files[] = $file;
582        }
583
584        // Filter out any duplicates. Typically introduced by getFileDependencies() which
585        // may lazily re-discover a primary file.
586        $files = array_unique( $files );
587
588        // Don't return array keys or any other form of file path here, only the hashes.
589        // Including file paths would needlessly cause global cache invalidation when files
590        // move on disk or if e.g. the MediaWiki directory name changes.
591        // Anything where order is significant is already detected by the definition summary.
592        return FileContentsHasher::getFileContentsHash( $files );
593    }
594
595    /**
596     * Get the definition summary for this module.
597     *
598     * @param Context $context
599     * @return array
600     */
601    public function getDefinitionSummary( Context $context ) {
602        $summary = parent::getDefinitionSummary( $context );
603
604        $options = [];
605        foreach ( [
606            // The following properties are omitted because they don't affect the module response:
607            // - localBasePath (Per T104950; Changes when absolute directory name changes. If
608            //    this affects 'scripts' and other file paths, getFileHashes accounts for that.)
609            // - remoteBasePath (Per T104950)
610            // - dependencies (provided via startup module)
611            // - group (provided via startup module)
612            'styles',
613            'skinStyles',
614            'messages',
615            'templates',
616            'skipFunction',
617            'debugRaw',
618        ] as $member ) {
619            $options[$member] = $this->{$member};
620        }
621
622        $packageFiles = $this->expandPackageFiles( $context );
623        $packageSummaries = [];
624        if ( $packageFiles ) {
625            // Extract the minimum needed:
626            // - The 'main' pointer (included as-is).
627            // - The 'files' array, simplified to only which files exist (the keys of
628            //   this array), and something that represents their non-file content.
629            //   For packaged files that reflect files directly from disk, the
630            //   'getFileHashes' method tracks their content already.
631            //   It is important that the keys of the $packageFiles['files'] array
632            //   are preserved, as they do affect the module output.
633            foreach ( $packageFiles['files'] as $fileName => $fileInfo ) {
634                $packageSummaries[$fileName] =
635                    $fileInfo['definitionSummary'] ?? $fileInfo['content'] ?? null;
636            }
637        }
638
639        $scriptFiles = $this->getScriptFiles( $context );
640        $scriptSummaries = [];
641        foreach ( $scriptFiles as $fileName => $fileInfo ) {
642            $scriptSummaries[$fileName] =
643                $fileInfo['definitionSummary'] ?? $fileInfo['content'] ?? null;
644        }
645
646        $summary[] = [
647            'options' => $options,
648            'packageFiles' => $packageSummaries,
649            'scripts' => $scriptSummaries,
650            'fileHashes' => $this->getFileHashes( $context ),
651            'messageBlob' => $this->getMessageBlob( $context ),
652        ];
653
654        $lessVars = $this->getLessVars( $context );
655        if ( $lessVars ) {
656            $summary[] = [ 'lessVars' => $lessVars ];
657        }
658
659        return $summary;
660    }
661
662    /**
663     * @return VueComponentParser
664     */
665    protected function getVueComponentParser() {
666        if ( $this->vueComponentParser === null ) {
667            $this->vueComponentParser = new VueComponentParser;
668        }
669        return $this->vueComponentParser;
670    }
671
672    /**
673     * @param string|FilePath $path
674     * @return string
675     */
676    protected function getPath( $path ) {
677        if ( $path instanceof FilePath ) {
678            return $path->getPath();
679        }
680
681        return $path;
682    }
683
684    /**
685     * @param string|FilePath $path
686     * @return string
687     */
688    protected function getLocalPath( $path ) {
689        if ( $path instanceof FilePath ) {
690            if ( $path->getLocalBasePath() !== null ) {
691                return $path->getLocalPath();
692            }
693            $path = $path->getPath();
694        }
695
696        return "{$this->localBasePath}/$path";
697    }
698
699    /**
700     * @param string|FilePath $path
701     * @return string
702     */
703    protected function getRemotePath( $path ) {
704        if ( $path instanceof FilePath ) {
705            if ( $path->getRemoteBasePath() !== null ) {
706                return $path->getRemotePath();
707            }
708            $path = $path->getPath();
709        }
710
711        if ( $this->remoteBasePath === '/' ) {
712            return "/$path";
713        } else {
714            return "{$this->remoteBasePath}/$path";
715        }
716    }
717
718    /**
719     * Infer the stylesheet language from a stylesheet file path.
720     *
721     * @since 1.22
722     * @param string $path
723     * @return string The stylesheet language name
724     */
725    public function getStyleSheetLang( $path ) {
726        return preg_match( '/\.less$/i', $path ) ? 'less' : 'css';
727    }
728
729    /**
730     * Infer the file type from a package file path.
731     *
732     * @param string $path
733     * @return string 'script', 'script-vue', or 'data'
734     */
735    public static function getPackageFileType( $path ) {
736        if ( preg_match( '/\.json$/i', $path ) ) {
737            return 'data';
738        }
739        if ( preg_match( '/\.vue$/i', $path ) ) {
740            return 'script-vue';
741        }
742        return 'script';
743    }
744
745    /**
746     * Collate style file paths by 'media' option (or 'all' if 'media' is not set)
747     *
748     * @param array $list List of file paths in any combination of index/path
749     *     or path/options pairs
750     * @return string[][] List of collated file paths
751     */
752    private static function collateStyleFilesByMedia( array $list ) {
753        $collatedFiles = [];
754        foreach ( $list as $key => $value ) {
755            if ( is_int( $key ) ) {
756                // File name as the value
757                $collatedFiles['all'][] = $value;
758            } elseif ( is_array( $value ) ) {
759                // File name as the key, options array as the value
760                $optionValue = $value['media'] ?? 'all';
761                $collatedFiles[$optionValue][] = $key;
762            }
763        }
764        return $collatedFiles;
765    }
766
767    /**
768     * Get a list of element that match a key, optionally using a fallback key.
769     *
770     * @param array[] $list List of lists to select from
771     * @param string $key Key to look for in $list
772     * @param string|null $fallback Key to look for in $list if $key doesn't exist
773     * @return array List of elements from $list which matched $key or $fallback,
774     *  or an empty list in case of no match
775     */
776    protected static function tryForKey( array $list, $key, $fallback = null ) {
777        if ( isset( $list[$key] ) && is_array( $list[$key] ) ) {
778            return $list[$key];
779        } elseif ( is_string( $fallback )
780            && isset( $list[$fallback] )
781            && is_array( $list[$fallback] )
782        ) {
783            return $list[$fallback];
784        }
785        return [];
786    }
787
788    /**
789     * Get script file paths for this module, in order of proper execution.
790     *
791     * @param Context $context
792     * @return array An array of file info arrays as returned by expandFileInfo()
793     */
794    private function getScriptFiles( Context $context ): array {
795        // List in execution order: scripts, languageScripts, skinScripts, debugScripts.
796        // Documented at MediaWiki\MainConfigSchema::ResourceModules.
797        $filesByCategory = [
798            'scripts' => $this->scripts,
799            'languageScripts' => $this->getLanguageScripts( $context->getLanguage() ),
800            'skinScripts' => self::tryForKey( $this->skinScripts, $context->getSkin(), 'default' ),
801        ];
802        if ( $context->getDebug() ) {
803            $filesByCategory['debugScripts'] = $this->debugScripts;
804        }
805
806        $expandedFiles = [];
807        foreach ( $filesByCategory as $category => $files ) {
808            foreach ( $files as $key => $fileInfo ) {
809                $expandedFileInfo = $this->expandFileInfo( $context, $fileInfo, "$category\[$key]" );
810                $expandedFiles[$expandedFileInfo['name']] = $expandedFileInfo;
811            }
812        }
813
814        return $expandedFiles;
815    }
816
817    /**
818     * Get the set of language scripts for the given language,
819     * possibly using a fallback language.
820     *
821     * @param string $lang
822     * @return array<int,string|FilePath> File paths
823     */
824    private function getLanguageScripts( string $lang ): array {
825        $scripts = self::tryForKey( $this->languageScripts, $lang );
826        if ( $scripts ) {
827            return $scripts;
828        }
829
830        // Optimization: Avoid initialising and calling into language services
831        // for the majority of modules that don't use this option.
832        if ( $this->languageScripts ) {
833            $fallbacks = MediaWikiServices::getInstance()
834                ->getLanguageFallback()
835                ->getAll( $lang, LanguageFallback::MESSAGES );
836            foreach ( $fallbacks as $lang ) {
837                $scripts = self::tryForKey( $this->languageScripts, $lang );
838                if ( $scripts ) {
839                    return $scripts;
840                }
841            }
842        }
843
844        return [];
845    }
846
847    public function setSkinStylesOverride( array $moduleSkinStyles ): void {
848        $moduleName = $this->getName();
849        foreach ( $moduleSkinStyles as $skinName => $overrides ) {
850            // If a module provides overrides for a skin, and that skin also provides overrides
851            // for the same module, then the module has precedence.
852            if ( isset( $this->skinStyles[$skinName] ) ) {
853                continue;
854            }
855
856            // If $moduleName in ResourceModuleSkinStyles is preceded with a '+', the defined style
857            // files will be added to 'default' skinStyles, otherwise 'default' will be ignored.
858            if ( isset( $overrides[$moduleName] ) ) {
859                $paths = (array)$overrides[$moduleName];
860                $styleFiles = [];
861            } elseif ( isset( $overrides['+' . $moduleName] ) ) {
862                $paths = (array)$overrides['+' . $moduleName];
863                $styleFiles = isset( $this->skinStyles['default'] ) ?
864                    (array)$this->skinStyles['default'] :
865                    [];
866            } else {
867                continue;
868            }
869
870            // Add new file paths, remapping them to refer to our directories and not use settings
871            // from the module we're modifying, which come from the base definition.
872            [ $localBasePath, $remoteBasePath ] = self::extractBasePaths( $overrides );
873
874            foreach ( $paths as $path ) {
875                $styleFiles[] = new FilePath( $path, $localBasePath, $remoteBasePath );
876            }
877
878            $this->skinStyles[$skinName] = $styleFiles;
879        }
880    }
881
882    /**
883     * Get a list of file paths for all styles in this module, in order of proper inclusion.
884     *
885     * @internal Exposed only for use by structure phpunit tests.
886     * @param Context $context
887     * @return array<string,array<int,string|FilePath>> Map from media type to list of file paths
888     */
889    public function getStyleFiles( Context $context ) {
890        return array_merge_recursive(
891            self::collateStyleFilesByMedia( $this->styles ),
892            self::collateStyleFilesByMedia(
893                self::tryForKey( $this->skinStyles, $context->getSkin(), 'default' )
894            )
895        );
896    }
897
898    /**
899     * Get a list of file paths for all skin styles in the module used by
900     * the skin.
901     *
902     * @param string $skinName The name of the skin
903     * @return array A list of file paths collated by media type
904     */
905    protected function getSkinStyleFiles( $skinName ) {
906        return self::collateStyleFilesByMedia(
907            self::tryForKey( $this->skinStyles, $skinName )
908        );
909    }
910
911    /**
912     * Get a list of file paths for all skin style files in the module,
913     * for all available skins.
914     *
915     * @return array A list of file paths collated by media type
916     */
917    protected function getAllSkinStyleFiles() {
918        $skinFactory = MediaWikiServices::getInstance()->getSkinFactory();
919        $styleFiles = [];
920
921        $internalSkinNames = array_keys( $skinFactory->getInstalledSkins() );
922        $internalSkinNames[] = 'default';
923
924        foreach ( $internalSkinNames as $internalSkinName ) {
925            $styleFiles = array_merge_recursive(
926                $styleFiles,
927                $this->getSkinStyleFiles( $internalSkinName )
928            );
929        }
930
931        return $styleFiles;
932    }
933
934    /**
935     * Get all style files and all skin style files used by this module.
936     *
937     * @return array
938     */
939    public function getAllStyleFiles() {
940        $collatedStyleFiles = array_merge_recursive(
941            self::collateStyleFilesByMedia( $this->styles ),
942            $this->getAllSkinStyleFiles()
943        );
944
945        $result = [];
946
947        foreach ( $collatedStyleFiles as $styleFiles ) {
948            foreach ( $styleFiles as $styleFile ) {
949                $result[] = $this->getLocalPath( $styleFile );
950            }
951        }
952
953        return $result;
954    }
955
956    /**
957     * Read the contents of a list of CSS files and remap and concatenate these.
958     *
959     * @internal This is considered a private method. Exposed for internal use by WebInstallerOutput.
960     * @param array<string,array<int,string|FilePath>> $styles Map of media type to file paths
961     * @param Context $context
962     * @return array<string,string> Map of combined CSS code, keyed by media type
963     */
964    public function readStyleFiles( array $styles, Context $context ) {
965        if ( !$styles ) {
966            return [];
967        }
968        foreach ( $styles as $media => $files ) {
969            $uniqueFiles = array_unique( $files, SORT_REGULAR );
970            $styleFiles = [];
971            foreach ( $uniqueFiles as $file ) {
972                $styleFiles[] = $this->readStyleFile( $file, $context );
973            }
974            $styles[$media] = implode( "\n", $styleFiles );
975        }
976        return $styles;
977    }
978
979    /**
980     * Read and process a style file. Reads a file from disk and runs it through processStyle().
981     *
982     * This method can be used as a callback for array_map()
983     *
984     * @internal
985     * @param string|FilePath $path Path of style file to read
986     * @param Context $context
987     * @return string CSS code
988     */
989    protected function readStyleFile( $path, Context $context ) {
990        $localPath = $this->getLocalPath( $path );
991        $style = $this->getFileContents( $localPath, 'style' );
992        $styleLang = $this->getStyleSheetLang( $localPath );
993
994        return $this->processStyle( $style, $styleLang, $path, $context );
995    }
996
997    /**
998     * Process a CSS/LESS string.
999     *
1000     * This method performs the following processing steps:
1001     * - LESS compilation (if $styleLang = 'less')
1002     * - RTL flipping with CSSJanus (if getFlip() returns true)
1003     * - Registration of references to local files in $localFileRefs and $missingLocalFileRefs
1004     * - URL remapping and data URI embedding
1005     *
1006     * @internal
1007     * @param string $style CSS or LESS code
1008     * @param string $styleLang Language of $style code ('css' or 'less')
1009     * @param string|FilePath $path Path to code file, used for resolving relative file paths
1010     * @param Context $context
1011     * @return string Processed CSS code
1012     */
1013    protected function processStyle( $style, $styleLang, $path, Context $context ) {
1014        $localPath = $this->getLocalPath( $path );
1015        $remotePath = $this->getRemotePath( $path );
1016
1017        if ( $styleLang === 'less' ) {
1018            $style = $this->compileLessString( $style, $localPath, $context );
1019            $this->hasGeneratedStyles = true;
1020        }
1021
1022        if ( $this->getFlip( $context ) ) {
1023            $style = CSSJanus::transform(
1024                $style,
1025                /* $swapLtrRtlInURL = */ true,
1026                /* $swapLeftRightInURL = */ false
1027            );
1028        }
1029
1030        $localDir = dirname( $localPath );
1031        $remoteDir = dirname( $remotePath );
1032        // Get and register local file references
1033        $localFileRefs = CSSMin::getLocalFileReferences( $style, $localDir );
1034        foreach ( $localFileRefs as $file ) {
1035            if ( is_file( $file ) ) {
1036                $this->localFileRefs[] = $file;
1037            } else {
1038                $this->missingLocalFileRefs[] = $file;
1039            }
1040        }
1041        // Don't cache this call. remap() ensures data URIs embeds are up to date,
1042        // and urls contain correct content hashes in their query string. (T128668)
1043        return CSSMin::remap( $style, $localDir, $remoteDir, true );
1044    }
1045
1046    /**
1047     * Get whether CSS for this module should be flipped
1048     * @param Context $context
1049     * @return bool
1050     */
1051    public function getFlip( Context $context ) {
1052        return $context->getDirection() === 'rtl' && !$this->noflip;
1053    }
1054
1055    /**
1056     * Get the module's load type.
1057     *
1058     * @since 1.28
1059     * @return string
1060     */
1061    public function getType() {
1062        $canBeStylesOnly = !(
1063            // All options except 'styles', 'skinStyles' and 'debugRaw'
1064            $this->scripts
1065            || $this->debugScripts
1066            || $this->templates
1067            || $this->languageScripts
1068            || $this->skinScripts
1069            || $this->dependencies
1070            || $this->messages
1071            || $this->skipFunction
1072            || $this->packageFiles
1073        );
1074        return $canBeStylesOnly ? self::LOAD_STYLES : self::LOAD_GENERAL;
1075    }
1076
1077    /**
1078     * Compile a LESS string into CSS.
1079     *
1080     * Keeps track of all used files and adds them to localFileRefs.
1081     *
1082     * @since 1.35
1083     * @param string $style LESS source to compile
1084     * @param string $stylePath File path of LESS source, used for resolving relative file paths
1085     * @param Context $context Context in which to generate script
1086     * @return string CSS source
1087     */
1088    protected function compileLessString( $style, $stylePath, Context $context ) {
1089        static $cache;
1090        // @TODO: dependency injection
1091        if ( !$cache ) {
1092            $cache = MediaWikiServices::getInstance()->getObjectCacheFactory()
1093                ->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->getParsedFiles();
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]" );