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