Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
78.91% covered (warning)
78.91%
464 / 588
47.83% covered (danger)
47.83%
22 / 46
CRAP
0.00% covered (danger)
0.00%
0 / 1
FileModule
78.91% covered (warning)
78.91%
464 / 588
47.83% covered (danger)
47.83%
22 / 46
705.02
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
63.16% covered (warning)
63.16%
12 / 19
0.00% covered (danger)
0.00%
0 / 1
9.45
 getScript
100.00% covered (success)
100.00%
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
60.87% covered (warning)
60.87%
14 / 23
0.00% covered (danger)
0.00%
0 / 1
18.25
 getDefinitionSummary
93.94% covered (success)
93.94%
31 / 33
0.00% covered (danger)
0.00%
0 / 1
6.01
 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
95.00% covered (success)
95.00%
19 / 20
0.00% covered (danger)
0.00%
0 / 1
5
 getFlip
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
2
 getType
0.00% covered (danger)
0.00%
0 / 12
0.00% covered (danger)
0.00%
0 / 1
110
 compileLessString
97.30% covered (success)
97.30%
36 / 37
0.00% covered (danger)
0.00%
0 / 1
6
 getTemplates
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
3
 expandPackageFiles
100.00% covered (success)
100.00%
31 / 31
100.00% covered (success)
100.00%
1 / 1
12
 expandFileInfo
81.48% covered (warning)
81.48%
66 / 81
0.00% covered (danger)
0.00%
0 / 1
22.54
 makeFilePath
80.00% covered (warning)
80.00%
4 / 5
0.00% covered (danger)
0.00%
0 / 1
3.07
 getPackageFiles
90.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 FileContentsHasher;
28use InvalidArgumentException;
29use LogicException;
30use MediaWiki\Languages\LanguageFallback;
31use MediaWiki\MainConfigNames;
32use MediaWiki\MediaWikiServices;
33use MediaWiki\Output\OutputPage;
34use MediaWiki\Registration\ExtensionRegistry;
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        $remoteBasePath ??= MediaWikiServices::getInstance()->getMainConfig()
285            ->get( MainConfigNames::ResourceBasePath );
286
287        if ( isset( $options['remoteExtPath'] ) ) {
288            $extensionAssetsPath = MediaWikiServices::getInstance()->getMainConfig()
289                ->get( MainConfigNames::ExtensionAssetsPath );
290            $remoteBasePath = $extensionAssetsPath . '/' . $options['remoteExtPath'];
291        }
292
293        if ( isset( $options['remoteSkinPath'] ) ) {
294            $stylePath = MediaWikiServices::getInstance()->getMainConfig()
295                ->get( MainConfigNames::StylePath );
296            $remoteBasePath = $stylePath . '/' . $options['remoteSkinPath'];
297        }
298
299        if ( array_key_exists( 'localBasePath', $options ) ) {
300            $localBasePath = (string)$options['localBasePath'];
301        }
302
303        if ( array_key_exists( 'remoteBasePath', $options ) ) {
304            $remoteBasePath = (string)$options['remoteBasePath'];
305        }
306
307        if ( $localBasePath === null ) {
308            $localBasePath = MW_INSTALL_PATH;
309        }
310
311        if ( $remoteBasePath === '' ) {
312            // If MediaWiki is installed at the document root (not recommended),
313            // then wgScriptPath is set to the empty string by the installer to
314            // ensure safe concatenating of file paths (avoid "/" + "/foo" being "//foo").
315            // However, this also means the path itself can be an invalid URI path,
316            // as those must start with a slash. Within ResourceLoader, we will not
317            // do such primitive/unsafe slash concatenation and use URI resolution
318            // instead, so beyond this point, to avoid fatal errors in CSSMin::resolveUrl(),
319            // do a best-effort support for docroot installs by casting this to a slash.
320            $remoteBasePath = '/';
321        }
322
323        return [ $localBasePath, $remoteBasePath ];
324    }
325
326    public function getScript( Context $context ) {
327        $packageFiles = $this->getPackageFiles( $context );
328        if ( $packageFiles !== null ) {
329            foreach ( $packageFiles['files'] as &$file ) {
330                if ( $file['type'] === 'script+style' ) {
331                    $file['content'] = $file['content']['script'];
332                    $file['type'] = 'script';
333                }
334            }
335            return $packageFiles;
336        }
337
338        $files = $this->getScriptFiles( $context );
339        foreach ( $files as &$file ) {
340            $this->readFileInfo( $context, $file );
341        }
342        return [ 'plainScripts' => $files ];
343    }
344
345    /**
346     * @param Context $context
347     * @return string[] URLs
348     */
349    public function getScriptURLsForDebug( Context $context ) {
350        $rl = $context->getResourceLoader();
351        $config = $this->getConfig();
352        $server = $config->get( MainConfigNames::Server );
353
354        $urls = [];
355        foreach ( $this->getScriptFiles( $context ) as $file ) {
356            if ( isset( $file['filePath'] ) ) {
357                $url = OutputPage::transformResourcePath( $config, $this->getRemotePath( $file['filePath'] ) );
358                // Expand debug URL in case we are another wiki's module source (T255367)
359                $url = $rl->expandUrl( $server, $url );
360                $urls[] = $url;
361            }
362        }
363        return $urls;
364    }
365
366    /**
367     * @return bool
368     */
369    public function supportsURLLoading() {
370        // phpcs:ignore Generic.WhiteSpace.LanguageConstructSpacing.IncorrectSingle
371        return
372            // Denied by options?
373            $this->debugRaw
374            // If package files are involved, don't support URL loading, because that breaks
375            // scoped require() functions
376            && !$this->packageFiles
377            // Can't link to scripts generated by callbacks
378            && !$this->hasGeneratedScripts();
379    }
380
381    public function shouldSkipStructureTest() {
382        return $this->skipStructureTest || parent::shouldSkipStructureTest();
383    }
384
385    /**
386     * Determine whether the module may potentially have generated scripts.
387     *
388     * @return bool
389     */
390    private function hasGeneratedScripts() {
391        foreach (
392            [ $this->scripts, $this->languageScripts, $this->skinScripts, $this->debugScripts ]
393            as $scripts
394        ) {
395            foreach ( $scripts as $script ) {
396                if ( is_array( $script ) ) {
397                    if ( isset( $script['callback'] ) || isset( $script['versionCallback'] ) ) {
398                        return true;
399                    }
400                }
401            }
402        }
403        return false;
404    }
405
406    /**
407     * Get all styles for a given context.
408     *
409     * @param Context $context
410     * @return string[] CSS code for $context as an associative array mapping media type to CSS text.
411     */
412    public function getStyles( Context $context ) {
413        $styles = $this->readStyleFiles(
414            $this->getStyleFiles( $context ),
415            $context
416        );
417
418        $packageFiles = $this->getPackageFiles( $context );
419        if ( $packageFiles !== null ) {
420            foreach ( $packageFiles['files'] as $fileName => $file ) {
421                if ( $file['type'] === 'script+style' ) {
422                    $style = $this->processStyle(
423                        $file['content']['style'],
424                        $file['content']['styleLang'],
425                        $fileName,
426                        $context
427                    );
428                    $styles['all'] = ( $styles['all'] ?? '' ) . "\n" . $style;
429                }
430            }
431        }
432
433        // Track indirect file dependencies so that StartUpModule can check for
434        // on-disk file changes to any of this files without having to recompute the file list
435        $this->saveFileDependencies( $context, $this->localFileRefs );
436
437        return $styles;
438    }
439
440    /**
441     * @param Context $context
442     * @return string[][] Lists of URLs by media type
443     */
444    public function getStyleURLsForDebug( Context $context ) {
445        if ( $this->hasGeneratedStyles ) {
446            // Do the default behaviour of returning a url back to load.php
447            // but with only=styles.
448            return parent::getStyleURLsForDebug( $context );
449        }
450        // Our module consists entirely of real css files,
451        // in debug mode we can load those directly.
452        $urls = [];
453        foreach ( $this->getStyleFiles( $context ) as $mediaType => $list ) {
454            $urls[$mediaType] = [];
455            foreach ( $list as $file ) {
456                $urls[$mediaType][] = OutputPage::transformResourcePath(
457                    $this->getConfig(),
458                    $this->getRemotePath( $file )
459                );
460            }
461        }
462        return $urls;
463    }
464
465    /**
466     * Get message keys used by this module.
467     *
468     * @return string[] List of message keys
469     */
470    public function getMessages() {
471        return $this->messages;
472    }
473
474    /**
475     * Get the name of the group this module should be loaded in.
476     *
477     * @return null|string Group name
478     */
479    public function getGroup() {
480        return $this->group;
481    }
482
483    /**
484     * Get names of modules this module depends on.
485     *
486     * @param Context|null $context
487     * @return string[] List of module names
488     */
489    public function getDependencies( ?Context $context = null ) {
490        return $this->dependencies;
491    }
492
493    /**
494     * Helper method for getting a file.
495     *
496     * @param string $localPath The path to the resource to load
497     * @param string $type The type of resource being loaded (for error reporting only)
498     * @return string
499     */
500    private function getFileContents( $localPath, $type ) {
501        if ( !is_file( $localPath ) ) {
502            throw new RuntimeException( "$type file not found or not a file: \"$localPath\"" );
503        }
504        return $this->stripBom( file_get_contents( $localPath ) );
505    }
506
507    /**
508     * @return null|string
509     */
510    public function getSkipFunction() {
511        if ( !$this->skipFunction ) {
512            return null;
513        }
514        $localPath = $this->getLocalPath( $this->skipFunction );
515        return $this->getFileContents( $localPath, 'skip function' );
516    }
517
518    public function requiresES6() {
519        return true;
520    }
521
522    /**
523     * Disable module content versioning.
524     *
525     * This class uses getDefinitionSummary() instead, to avoid filesystem overhead
526     * involved with building the full module content inside a startup request.
527     *
528     * @return bool
529     */
530    public function enableModuleContentVersion() {
531        return false;
532    }
533
534    /**
535     * Helper method for getDefinitionSummary.
536     *
537     * @param Context $context
538     * @return string Hash
539     */
540    private function getFileHashes( Context $context ) {
541        $files = [];
542
543        foreach ( $this->getStyleFiles( $context ) as $filePaths ) {
544            foreach ( $filePaths as $filePath ) {
545                $files[] = $this->getLocalPath( $filePath );
546            }
547        }
548
549        // Extract file paths for package files
550        // Optimisation: Use foreach() and isset() instead of array_map/array_filter.
551        // This is a hot code path, called by StartupModule for thousands of modules.
552        $expandedPackageFiles = $this->expandPackageFiles( $context );
553        if ( $expandedPackageFiles ) {
554            foreach ( $expandedPackageFiles['files'] as $fileInfo ) {
555                $filePath = $fileInfo['filePath'] ?? $fileInfo['versionFilePath'] ?? null;
556                if ( $filePath instanceof 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            $this->hasGeneratedStyles = true;
1030        }
1031
1032        $localDir = dirname( $localPath );
1033        $remoteDir = dirname( $remotePath );
1034        // Get and register local file references
1035        $localFileRefs = CSSMin::getLocalFileReferences( $style, $localDir );
1036        foreach ( $localFileRefs as $file ) {
1037            if ( is_file( $file ) ) {
1038                $this->localFileRefs[] = $file;
1039            } else {
1040                $this->missingLocalFileRefs[] = $file;
1041            }
1042        }
1043        // Don't cache this call. remap() ensures data URIs embeds are up to date,
1044        // and urls contain correct content hashes in their query string. (T128668)
1045        return CSSMin::remap( $style, $localDir, $remoteDir, true );
1046    }
1047
1048    /**
1049     * Get whether CSS for this module should be flipped
1050     * @param Context $context
1051     * @return bool
1052     */
1053    public function getFlip( Context $context ) {
1054        return $context->getDirection() === 'rtl' && !$this->noflip;
1055    }
1056
1057    /**
1058     * Get the module's load type.
1059     *
1060     * @since 1.28
1061     * @return string
1062     */
1063    public function getType() {
1064        $canBeStylesOnly = !(
1065            // All options except 'styles', 'skinStyles' and 'debugRaw'
1066            $this->scripts
1067            || $this->debugScripts
1068            || $this->templates
1069            || $this->languageScripts
1070            || $this->skinScripts
1071            || $this->dependencies
1072            || $this->messages
1073            || $this->skipFunction
1074            || $this->packageFiles
1075        );
1076        return $canBeStylesOnly ? self::LOAD_STYLES : self::LOAD_GENERAL;
1077    }
1078
1079    /**
1080     * Compile a LESS string into CSS.
1081     *
1082     * Keeps track of all used files and adds them to localFileRefs.
1083     *
1084     * @since 1.35
1085     * @param string $style LESS source to compile
1086     * @param string $stylePath File path of LESS source, used for resolving relative file paths
1087     * @param Context $context Context in which to generate script
1088     * @return string CSS source
1089     */
1090    protected function compileLessString( $style, $stylePath, Context $context ) {
1091        static $cache;
1092        // @TODO: dependency injection
1093        if ( !$cache ) {
1094            $cache = MediaWikiServices::getInstance()->getObjectCacheFactory()
1095                ->getLocalServerInstance( CACHE_HASH );
1096        }
1097
1098        $skinName = $context->getSkin();
1099        $skinImportPaths = ExtensionRegistry::getInstance()->getAttribute( 'SkinLessImportPaths' );
1100        $importDirs = [];
1101        if ( isset( $skinImportPaths[ $skinName ] ) ) {
1102            $importDirs[] = $skinImportPaths[ $skinName ];
1103        }
1104
1105        $vars = $this->getLessVars( $context );
1106        // Construct a cache key from a hash of the LESS source, and a hash digest
1107        // of the LESS variables and import dirs used for compilation.
1108        ksort( $vars );
1109        $compilerParams = [
1110            'vars' => $vars,
1111            'importDirs' => $importDirs,
1112            // CodexDevelopmentDir affects import path mapping in ResourceLoader::getLessCompiler(),
1113            // so take that into account too
1114            'codexDevDir' => $this->getConfig()->get( MainConfigNames::CodexDevelopmentDir )
1115        ];
1116        $key = $cache->makeGlobalKey(
1117            'resourceloader-less',
1118            'v1',
1119            hash( 'md4', $style ),
1120            hash( 'md4', serialize( $compilerParams ) )
1121        );
1122
1123        // If we got a cached value, we have to validate it by getting a checksum of all the
1124        // files that were loaded by the parser and ensuring it matches the cached entry's.
1125        $data = $cache->get( $key );
1126        if (
1127            !$data ||
1128            $data['hash'] !== FileContentsHasher::getFileContentsHash( $data['files'] )
1129        ) {
1130            $compiler = $context->getResourceLoader()->getLessCompiler( $vars, $importDirs );
1131
1132            $css = $compiler->parse( $style, $stylePath )->getCss();
1133            // T253055: store the implicit dependency paths in a form relative to any install
1134            // path so that multiple version of the application can share the cache for identical
1135            // less stylesheets. This also avoids churn during application updates.
1136            $files = $compiler->getParsedFiles();
1137            $data = [
1138                'css'   => $css,
1139                'files' => Module::getRelativePaths( $files ),
1140                'hash'  => FileContentsHasher::getFileContentsHash( $files )
1141            ];
1142            $cache->set( $key, $data, $cache::TTL_DAY );
1143        }
1144
1145        foreach ( Module::expandRelativePaths( $data['files'] ) as $path ) {
1146            $this->localFileRefs[] = $path;
1147        }
1148
1149        return $data['css'];
1150    }
1151
1152    /**
1153     * Get content of named templates for this module.
1154     *
1155     * @return array<string,string> Templates mapping template alias to content
1156     */
1157    public function getTemplates() {
1158        $templates = [];
1159
1160        foreach ( $this->templates as $alias => $templatePath ) {
1161            // Alias is optional
1162            if ( is_int( $alias ) ) {
1163                $alias = $this->getPath( $templatePath );
1164            }
1165            $localPath = $this->getLocalPath( $templatePath );
1166            $content = $this->getFileContents( $localPath, 'template' );
1167
1168            $templates[$alias] = $this->stripBom( $content );
1169        }
1170        return $templates;
1171    }
1172
1173    /**
1174     * Internal helper for use by getPackageFiles(), getFileHashes() and getDefinitionSummary().
1175     *
1176     * This expands the 'packageFiles' definition into something that's (almost) the right format
1177     * for getPackageFiles() to return. It expands shorthands, resolves config vars, and handles
1178     * summarising any non-file data for getVersionHash(). For file-based data, getFileHashes()
1179     * handles it instead, which also ends up in getDefinitionSummary().
1180     *
1181     * What it does not do is reading the actual contents of any specified files, nor invoking
1182     * the computation callbacks. Those things are done by getPackageFiles() instead to improve
1183     * backend performance by only doing this work when the module response is needed, and not
1184     * when merely computing the version hash for StartupModule, or when checking
1185     * If-None-Match headers for a HTTP 304 response.
1186     *
1187     * @param Context $context
1188     * @return array|null Array of arrays as returned by expandFileInfo(), with the key being
1189     *   the file name, or null if this is not a package file module.
1190     * @phan-return array{main:?string,files:array[]}|null
1191     */
1192    private function expandPackageFiles( Context $context ) {