MediaWiki 1.43.1
FileModule.php
Go to the documentation of this file.
1<?php
24
25use CSSJanus;
26use Exception;
28use InvalidArgumentException;
29use LogicException;
35use RuntimeException;
36use Wikimedia\Minify\CSSMin;
37use Wikimedia\RequestTimeout\TimeoutException;
38
52class FileModule extends Module {
54 protected $localBasePath = '';
55
57 protected $remoteBasePath = '';
58
62 protected $scripts = [];
63
67 protected $languageScripts = [];
68
72 protected $skinScripts = [];
73
77 protected $debugScripts = [];
78
82 protected $styles = [];
83
87 protected $skinStyles = [];
88
96 protected $packageFiles = null;
97
102 private $expandedPackageFiles = [];
103
108 private $fullyExpandedPackageFiles = [];
109
113 protected $dependencies = [];
114
118 protected $skipFunction = null;
119
123 protected $messages = [];
124
126 protected $templates = [];
127
129 protected $group = null;
130
132 protected $debugRaw = true;
133
135 protected $noflip = false;
136
138 protected $skipStructureTest = false;
139
144 protected $hasGeneratedStyles = false;
145
149 protected $localFileRefs = [];
150
155 protected $missingLocalFileRefs = [];
156
160 protected $vueComponentParser = null;
161
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.
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
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
286
287 if ( isset( $options['remoteExtPath'] ) ) {
288 $extensionAssetsPath = MediaWikiServices::getInstance()->getMainConfig()
290 $remoteBasePath = $extensionAssetsPath . '/' . $options['remoteExtPath'];
291 }
292
293 if ( isset( $options['remoteSkinPath'] ) ) {
294 $stylePath = MediaWikiServices::getInstance()->getMainConfig()
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 ( $remoteBasePath === '' ) {
308 // If MediaWiki is installed at the document root (not recommended),
309 // then wgScriptPath is set to the empty string by the installer to
310 // ensure safe concatenating of file paths (avoid "/" + "/foo" being "//foo").
311 // However, this also means the path itself can be an invalid URI path,
312 // as those must start with a slash. Within ResourceLoader, we will not
313 // do such primitive/unsafe slash concatenation and use URI resolution
314 // instead, so beyond this point, to avoid fatal errors in CSSMin::resolveUrl(),
315 // do a best-effort support for docroot installs by casting this to a slash.
316 $remoteBasePath = '/';
317 }
318
319 return [ $localBasePath ?? MW_INSTALL_PATH, $remoteBasePath ];
320 }
321
322 public function getScript( Context $context ) {
323 $packageFiles = $this->getPackageFiles( $context );
324 if ( $packageFiles !== null ) {
325 foreach ( $packageFiles['files'] as &$file ) {
326 if ( $file['type'] === 'script+style' ) {
327 $file['content'] = $file['content']['script'];
328 $file['type'] = 'script';
329 }
330 }
331 return $packageFiles;
332 }
333
334 $files = $this->getScriptFiles( $context );
335 foreach ( $files as &$file ) {
336 $this->readFileInfo( $context, $file );
337 }
338 return [ 'plainScripts' => $files ];
339 }
340
345 public function getScriptURLsForDebug( Context $context ) {
346 $rl = $context->getResourceLoader();
347 $config = $this->getConfig();
348 $server = $config->get( MainConfigNames::Server );
349
350 $urls = [];
351 foreach ( $this->getScriptFiles( $context ) as $file ) {
352 if ( isset( $file['filePath'] ) ) {
353 $url = OutputPage::transformResourcePath( $config, $this->getRemotePath( $file['filePath'] ) );
354 // Expand debug URL in case we are another wiki's module source (T255367)
355 $url = $rl->expandUrl( $server, $url );
356 $urls[] = $url;
357 }
358 }
359 return $urls;
360 }
361
365 public function supportsURLLoading() {
366 // phpcs:ignore Generic.WhiteSpace.LanguageConstructSpacing.IncorrectSingle
367 return
368 // Denied by options?
369 $this->debugRaw
370 // If package files are involved, don't support URL loading, because that breaks
371 // scoped require() functions
372 && !$this->packageFiles
373 // Can't link to scripts generated by callbacks
374 && !$this->hasGeneratedScripts();
375 }
376
377 public function shouldSkipStructureTest() {
378 return $this->skipStructureTest || parent::shouldSkipStructureTest();
379 }
380
386 private function hasGeneratedScripts() {
387 foreach (
388 [ $this->scripts, $this->languageScripts, $this->skinScripts, $this->debugScripts ]
389 as $scripts
390 ) {
391 foreach ( $scripts as $script ) {
392 if ( is_array( $script ) ) {
393 if ( isset( $script['callback'] ) || isset( $script['versionCallback'] ) ) {
394 return true;
395 }
396 }
397 }
398 }
399 return false;
400 }
401
408 public function getStyles( Context $context ) {
409 $styles = $this->readStyleFiles(
410 $this->getStyleFiles( $context ),
411 $context
412 );
413
414 $packageFiles = $this->getPackageFiles( $context );
415 if ( $packageFiles !== null ) {
416 foreach ( $packageFiles['files'] as $fileName => $file ) {
417 if ( $file['type'] === 'script+style' ) {
418 $style = $this->processStyle(
419 $file['content']['style'],
420 $file['content']['styleLang'],
421 $fileName,
422 $context
423 );
424 $styles['all'] = ( $styles['all'] ?? '' ) . "\n" . $style;
425 }
426 }
427 }
428
429 // Track indirect file dependencies so that StartUpModule can check for
430 // on-disk file changes to any of this files without having to recompute the file list
431 $this->saveFileDependencies( $context, $this->localFileRefs );
432
433 return $styles;
434 }
435
440 public function getStyleURLsForDebug( Context $context ) {
441 if ( $this->hasGeneratedStyles ) {
442 // Do the default behaviour of returning a url back to load.php
443 // but with only=styles.
444 return parent::getStyleURLsForDebug( $context );
445 }
446 // Our module consists entirely of real css files,
447 // in debug mode we can load those directly.
448 $urls = [];
449 foreach ( $this->getStyleFiles( $context ) as $mediaType => $list ) {
450 $urls[$mediaType] = [];
451 foreach ( $list as $file ) {
452 $urls[$mediaType][] = OutputPage::transformResourcePath(
453 $this->getConfig(),
454 $this->getRemotePath( $file )
455 );
456 }
457 }
458 return $urls;
459 }
460
466 public function getMessages() {
467 return $this->messages;
468 }
469
475 public function getGroup() {
476 return $this->group;
477 }
478
485 public function getDependencies( ?Context $context = null ) {
486 return $this->dependencies;
487 }
488
496 private function getFileContents( $localPath, $type ) {
497 if ( !is_file( $localPath ) ) {
498 throw new RuntimeException( "$type file not found or not a file: \"$localPath\"" );
499 }
500 return $this->stripBom( file_get_contents( $localPath ) );
501 }
502
506 public function getSkipFunction() {
507 if ( !$this->skipFunction ) {
508 return null;
509 }
510 $localPath = $this->getLocalPath( $this->skipFunction );
511 return $this->getFileContents( $localPath, 'skip function' );
512 }
513
514 public function requiresES6() {
515 return true;
516 }
517
526 public function enableModuleContentVersion() {
527 return false;
528 }
529
536 private function getFileHashes( Context $context ) {
537 $files = [];
538
539 foreach ( $this->getStyleFiles( $context ) as $filePaths ) {
540 foreach ( $filePaths as $filePath ) {
541 $files[] = $this->getLocalPath( $filePath );
542 }
543 }
544
545 // Extract file paths for package files
546 // Optimisation: Use foreach() and isset() instead of array_map/array_filter.
547 // This is a hot code path, called by StartupModule for thousands of modules.
548 $expandedPackageFiles = $this->expandPackageFiles( $context );
549 if ( $expandedPackageFiles ) {
550 foreach ( $expandedPackageFiles['files'] as $fileInfo ) {
551 $filePath = $fileInfo['filePath'] ?? $fileInfo['versionFilePath'] ?? null;
552 if ( $filePath instanceof FilePath ) {
553 $files[] = $filePath->getLocalPath();
554 }
555 }
556 }
557
558 // Add other configured paths
559 $scriptFileInfos = $this->getScriptFiles( $context );
560 foreach ( $scriptFileInfos as $fileInfo ) {
561 $filePath = $fileInfo['filePath'] ?? $fileInfo['versionFilePath'] ?? null;
562 if ( $filePath instanceof FilePath ) {
563 $files[] = $filePath->getLocalPath();
564 }
565 }
566
567 foreach ( $this->templates as $filePath ) {
568 $files[] = $this->getLocalPath( $filePath );
569 }
570
571 if ( $this->skipFunction ) {
572 $files[] = $this->getLocalPath( $this->skipFunction );
573 }
574
575 // Add any lazily discovered file dependencies from previous module builds.
576 // These are already absolute paths.
577 foreach ( $this->getFileDependencies( $context ) as $file ) {
578 $files[] = $file;
579 }
580
581 // Filter out any duplicates. Typically introduced by getFileDependencies() which
582 // may lazily re-discover a primary file.
583 $files = array_unique( $files );
584
585 // Don't return array keys or any other form of file path here, only the hashes.
586 // Including file paths would needlessly cause global cache invalidation when files
587 // move on disk or if e.g. the MediaWiki directory name changes.
588 // Anything where order is significant is already detected by the definition summary.
590 }
591
598 public function getDefinitionSummary( Context $context ) {
599 $summary = parent::getDefinitionSummary( $context );
600
601 $options = [];
602 foreach ( [
603 // The following properties are omitted because they don't affect the module response:
604 // - localBasePath (Per T104950; Changes when absolute directory name changes. If
605 // this affects 'scripts' and other file paths, getFileHashes accounts for that.)
606 // - remoteBasePath (Per T104950)
607 // - dependencies (provided via startup module)
608 // - group (provided via startup module)
609 'styles',
610 'skinStyles',
611 'messages',
612 'templates',
613 'skipFunction',
614 'debugRaw',
615 ] as $member ) {
616 $options[$member] = $this->{$member};
617 }
618
619 $packageFiles = $this->expandPackageFiles( $context );
620 $packageSummaries = [];
621 if ( $packageFiles ) {
622 // Extract the minimum needed:
623 // - The 'main' pointer (included as-is).
624 // - The 'files' array, simplified to only which files exist (the keys of
625 // this array), and something that represents their non-file content.
626 // For packaged files that reflect files directly from disk, the
627 // 'getFileHashes' method tracks their content already.
628 // It is important that the keys of the $packageFiles['files'] array
629 // are preserved, as they do affect the module output.
630 foreach ( $packageFiles['files'] as $fileName => $fileInfo ) {
631 $packageSummaries[$fileName] =
632 $fileInfo['definitionSummary'] ?? $fileInfo['content'] ?? null;
633 }
634 }
635
636 $scriptFiles = $this->getScriptFiles( $context );
637 $scriptSummaries = [];
638 foreach ( $scriptFiles as $fileName => $fileInfo ) {
639 $scriptSummaries[$fileName] =
640 $fileInfo['definitionSummary'] ?? $fileInfo['content'] ?? null;
641 }
642
643 $summary[] = [
644 'options' => $options,
645 'packageFiles' => $packageSummaries,
646 'scripts' => $scriptSummaries,
647 'fileHashes' => $this->getFileHashes( $context ),
648 'messageBlob' => $this->getMessageBlob( $context ),
649 ];
650
651 $lessVars = $this->getLessVars( $context );
652 if ( $lessVars ) {
653 $summary[] = [ 'lessVars' => $lessVars ];
654 }
655
656 return $summary;
657 }
658
662 protected function getVueComponentParser() {
663 if ( $this->vueComponentParser === null ) {
664 $this->vueComponentParser = new VueComponentParser;
665 }
667 }
668
673 protected function getPath( $path ) {
674 if ( $path instanceof FilePath ) {
675 return $path->getPath();
676 }
677
678 return $path;
679 }
680
685 protected function getLocalPath( $path ) {
686 if ( $path instanceof FilePath ) {
687 if ( $path->getLocalBasePath() !== null ) {
688 return $path->getLocalPath();
689 }
690 $path = $path->getPath();
691 }
692
693 return "{$this->localBasePath}/$path";
694 }
695
700 protected function getRemotePath( $path ) {
701 if ( $path instanceof FilePath ) {
702 if ( $path->getRemoteBasePath() !== null ) {
703 return $path->getRemotePath();
704 }
705 $path = $path->getPath();
706 }
707
708 if ( $this->remoteBasePath === '/' ) {
709 return "/$path";
710 } else {
711 return "{$this->remoteBasePath}/$path";
712 }
713 }
714
722 public function getStyleSheetLang( $path ) {
723 return preg_match( '/\.less$/i', $path ) ? 'less' : 'css';
724 }
725
732 public static function getPackageFileType( $path ) {
733 if ( preg_match( '/\.json$/i', $path ) ) {
734 return 'data';
735 }
736 if ( preg_match( '/\.vue$/i', $path ) ) {
737 return 'script-vue';
738 }
739 return 'script';
740 }
741
749 private static function collateStyleFilesByMedia( array $list ) {
750 $collatedFiles = [];
751 foreach ( $list as $key => $value ) {
752 if ( is_int( $key ) ) {
753 // File name as the value
754 $collatedFiles['all'][] = $value;
755 } elseif ( is_array( $value ) ) {
756 // File name as the key, options array as the value
757 $optionValue = $value['media'] ?? 'all';
758 $collatedFiles[$optionValue][] = $key;
759 }
760 }
761 return $collatedFiles;
762 }
763
773 protected static function tryForKey( array $list, $key, $fallback = null ) {
774 if ( isset( $list[$key] ) && is_array( $list[$key] ) ) {
775 return $list[$key];
776 } elseif ( is_string( $fallback )
777 && isset( $list[$fallback] )
778 && is_array( $list[$fallback] )
779 ) {
780 return $list[$fallback];
781 }
782 return [];
783 }
784
791 private function getScriptFiles( Context $context ): array {
792 // List in execution order: scripts, languageScripts, skinScripts, debugScripts.
793 // Documented at MediaWiki\MainConfigSchema::ResourceModules.
794 $filesByCategory = [
795 'scripts' => $this->scripts,
796 'languageScripts' => $this->getLanguageScripts( $context->getLanguage() ),
797 'skinScripts' => self::tryForKey( $this->skinScripts, $context->getSkin(), 'default' ),
798 ];
799 if ( $context->getDebug() ) {
800 $filesByCategory['debugScripts'] = $this->debugScripts;
801 }
802
803 $expandedFiles = [];
804 foreach ( $filesByCategory as $category => $files ) {
805 foreach ( $files as $key => $fileInfo ) {
806 $expandedFileInfo = $this->expandFileInfo( $context, $fileInfo, "$category\[$key]" );
807 $expandedFiles[$expandedFileInfo['name']] = $expandedFileInfo;
808 }
809 }
810
811 return $expandedFiles;
812 }
813
821 private function getLanguageScripts( string $lang ): array {
822 $scripts = self::tryForKey( $this->languageScripts, $lang );
823 if ( $scripts ) {
824 return $scripts;
825 }
826
827 // Optimization: Avoid initialising and calling into language services
828 // for the majority of modules that don't use this option.
829 if ( $this->languageScripts ) {
830 $fallbacks = MediaWikiServices::getInstance()
831 ->getLanguageFallback()
832 ->getAll( $lang, LanguageFallback::MESSAGES );
833 foreach ( $fallbacks as $lang ) {
834 $scripts = self::tryForKey( $this->languageScripts, $lang );
835 if ( $scripts ) {
836 return $scripts;
837 }
838 }
839 }
840
841 return [];
842 }
843
844 public function setSkinStylesOverride( array $moduleSkinStyles ): void {
845 $moduleName = $this->getName();
846 foreach ( $moduleSkinStyles as $skinName => $overrides ) {
847 // If a module provides overrides for a skin, and that skin also provides overrides
848 // for the same module, then the module has precedence.
849 if ( isset( $this->skinStyles[$skinName] ) ) {
850 continue;
851 }
852
853 // If $moduleName in ResourceModuleSkinStyles is preceded with a '+', the defined style
854 // files will be added to 'default' skinStyles, otherwise 'default' will be ignored.
855 if ( isset( $overrides[$moduleName] ) ) {
856 $paths = (array)$overrides[$moduleName];
857 $styleFiles = [];
858 } elseif ( isset( $overrides['+' . $moduleName] ) ) {
859 $paths = (array)$overrides['+' . $moduleName];
860 $styleFiles = isset( $this->skinStyles['default'] ) ?
861 (array)$this->skinStyles['default'] :
862 [];
863 } else {
864 continue;
865 }
866
867 // Add new file paths, remapping them to refer to our directories and not use settings
868 // from the module we're modifying, which come from the base definition.
869 [ $localBasePath, $remoteBasePath ] = self::extractBasePaths( $overrides );
870
871 foreach ( $paths as $path ) {
872 $styleFiles[] = new FilePath( $path, $localBasePath, $remoteBasePath );
873 }
874
875 $this->skinStyles[$skinName] = $styleFiles;
876 }
877 }
878
886 public function getStyleFiles( Context $context ) {
887 return array_merge_recursive(
888 self::collateStyleFilesByMedia( $this->styles ),
889 self::collateStyleFilesByMedia(
890 self::tryForKey( $this->skinStyles, $context->getSkin(), 'default' )
891 )
892 );
893 }
894
902 protected function getSkinStyleFiles( $skinName ) {
903 return self::collateStyleFilesByMedia(
904 self::tryForKey( $this->skinStyles, $skinName )
905 );
906 }
907
914 protected function getAllSkinStyleFiles() {
915 $skinFactory = MediaWikiServices::getInstance()->getSkinFactory();
916 $styleFiles = [];
917
918 $internalSkinNames = array_keys( $skinFactory->getInstalledSkins() );
919 $internalSkinNames[] = 'default';
920
921 foreach ( $internalSkinNames as $internalSkinName ) {
922 $styleFiles = array_merge_recursive(
923 $styleFiles,
924 $this->getSkinStyleFiles( $internalSkinName )
925 );
926 }
927
928 return $styleFiles;
929 }
930
936 public function getAllStyleFiles() {
937 $collatedStyleFiles = array_merge_recursive(
938 self::collateStyleFilesByMedia( $this->styles ),
939 $this->getAllSkinStyleFiles()
940 );
941
942 $result = [];
943
944 foreach ( $collatedStyleFiles as $styleFiles ) {
945 foreach ( $styleFiles as $styleFile ) {
946 $result[] = $this->getLocalPath( $styleFile );
947 }
948 }
949
950 return $result;
951 }
952
961 public function readStyleFiles( array $styles, Context $context ) {
962 if ( !$styles ) {
963 return [];
964 }
965 foreach ( $styles as $media => $files ) {
966 $uniqueFiles = array_unique( $files, SORT_REGULAR );
967 $styleFiles = [];
968 foreach ( $uniqueFiles as $file ) {
969 $styleFiles[] = $this->readStyleFile( $file, $context );
970 }
971 $styles[$media] = implode( "\n", $styleFiles );
972 }
973 return $styles;
974 }
975
986 protected function readStyleFile( $path, Context $context ) {
987 $localPath = $this->getLocalPath( $path );
988 $style = $this->getFileContents( $localPath, 'style' );
989 $styleLang = $this->getStyleSheetLang( $localPath );
990
991 return $this->processStyle( $style, $styleLang, $path, $context );
992 }
993
1010 protected function processStyle( $style, $styleLang, $path, Context $context ) {
1011 $localPath = $this->getLocalPath( $path );
1012 $remotePath = $this->getRemotePath( $path );
1013
1014 if ( $styleLang === 'less' ) {
1015 $style = $this->compileLessString( $style, $localPath, $context );
1016 $this->hasGeneratedStyles = true;
1017 }
1018
1019 if ( $this->getFlip( $context ) ) {
1020 $style = CSSJanus::transform(
1021 $style,
1022 /* $swapLtrRtlInURL = */ true,
1023 /* $swapLeftRightInURL = */ false
1024 );
1025 $this->hasGeneratedStyles = true;
1026 }
1027
1028 $localDir = dirname( $localPath );
1029 $remoteDir = dirname( $remotePath );
1030 // Get and register local file references
1031 $localFileRefs = CSSMin::getLocalFileReferences( $style, $localDir );
1032 foreach ( $localFileRefs as $file ) {
1033 if ( is_file( $file ) ) {
1034 $this->localFileRefs[] = $file;
1035 } else {
1036 $this->missingLocalFileRefs[] = $file;
1037 }
1038 }
1039 // Don't cache this call. remap() ensures data URIs embeds are up to date,
1040 // and urls contain correct content hashes in their query string. (T128668)
1041 return CSSMin::remap( $style, $localDir, $remoteDir, true );
1042 }
1043
1049 public function getFlip( Context $context ) {
1050 return $context->getDirection() === 'rtl' && !$this->noflip;
1051 }
1052
1059 public function getType() {
1060 $canBeStylesOnly = !(
1061 // All options except 'styles', 'skinStyles' and 'debugRaw'
1062 $this->scripts
1063 || $this->debugScripts
1064 || $this->templates
1065 || $this->languageScripts
1066 || $this->skinScripts
1067 || $this->dependencies
1068 || $this->messages
1069 || $this->skipFunction
1070 || $this->packageFiles
1071 );
1072 return $canBeStylesOnly ? self::LOAD_STYLES : self::LOAD_GENERAL;
1073 }
1074
1086 protected function compileLessString( $style, $stylePath, Context $context ) {
1087 static $cache;
1088 // @TODO: dependency injection
1089 if ( !$cache ) {
1090 $cache = MediaWikiServices::getInstance()->getObjectCacheFactory()
1091 ->getLocalServerInstance( CACHE_ANYTHING );
1092 }
1093
1094 $skinName = $context->getSkin();
1095 $skinImportPaths = ExtensionRegistry::getInstance()->getAttribute( 'SkinLessImportPaths' );
1096 $importDirs = [];
1097 if ( isset( $skinImportPaths[ $skinName ] ) ) {
1098 $importDirs[] = $skinImportPaths[ $skinName ];
1099 }
1100
1101 $vars = $this->getLessVars( $context );
1102 // Construct a cache key from a hash of the LESS source, and a hash digest
1103 // of the LESS variables and import dirs used for compilation.
1104 ksort( $vars );
1105 $compilerParams = [
1106 'vars' => $vars,
1107 'importDirs' => $importDirs,
1108 // CodexDevelopmentDir affects import path mapping in ResourceLoader::getLessCompiler(),
1109 // so take that into account too
1110 'codexDevDir' => $this->getConfig()->get( MainConfigNames::CodexDevelopmentDir )
1111 ];
1112 $key = $cache->makeGlobalKey(
1113 'resourceloader-less',
1114 'v1',
1115 hash( 'md4', $style ),
1116 hash( 'md4', serialize( $compilerParams ) )
1117 );
1118
1119 // If we got a cached value, we have to validate it by getting a checksum of all the
1120 // files that were loaded by the parser and ensuring it matches the cached entry's.
1121 $data = $cache->get( $key );
1122 if (
1123 !$data ||
1124 $data['hash'] !== FileContentsHasher::getFileContentsHash( $data['files'] )
1125 ) {
1126 $compiler = $context->getResourceLoader()->getLessCompiler( $vars, $importDirs );
1127
1128 $css = $compiler->parse( $style, $stylePath )->getCss();
1129 // T253055: store the implicit dependency paths in a form relative to any install
1130 // path so that multiple version of the application can share the cache for identical
1131 // less stylesheets. This also avoids churn during application updates.
1132 $files = $compiler->getParsedFiles();
1133 $data = [
1134 'css' => $css,
1135 'files' => Module::getRelativePaths( $files ),
1136 'hash' => FileContentsHasher::getFileContentsHash( $files )
1137 ];
1138 $cache->set( $key, $data, $cache::TTL_DAY );
1139 }
1140
1141 foreach ( Module::expandRelativePaths( $data['files'] ) as $path ) {
1142 $this->localFileRefs[] = $path;
1143 }
1144
1145 return $data['css'];
1146 }
1147
1153 public function getTemplates() {
1154 $templates = [];
1155
1156 foreach ( $this->templates as $alias => $templatePath ) {
1157 // Alias is optional
1158 if ( is_int( $alias ) ) {
1159 $alias = $this->getPath( $templatePath );
1160 }
1161 $localPath = $this->getLocalPath( $templatePath );
1162 $content = $this->getFileContents( $localPath, 'template' );
1163
1164 $templates[$alias] = $this->stripBom( $content );
1165 }
1166 return $templates;
1167 }
1168
1188 private function expandPackageFiles( Context $context ) {
1189 $hash = $context->getHash();
1190 if ( isset( $this->expandedPackageFiles[$hash] ) ) {
1191 return $this->expandedPackageFiles[$hash];
1192 }
1193 if ( $this->packageFiles === null ) {
1194 return null;
1195 }
1196 $expandedFiles = [];
1197 $mainFile = null;
1198
1199 foreach ( $this->packageFiles as $key => $fileInfo ) {
1200 $expanded = $this->expandFileInfo( $context, $fileInfo, "packageFiles[$key]" );
1201 $fileName = $expanded['name'];
1202 if ( !empty( $expanded['main'] ) ) {
1203 unset( $expanded['main'] );
1204 $type = $expanded['type'];
1205 $mainFile = $fileName;
1206 if ( $type !== 'script' && $type !== 'script-vue' ) {
1207 $msg = "Main file in package must be of type 'script', module " .
1208 "'{$this->getName()}', main file '{$mainFile}' is '{$type}'.";
1209 $this->getLogger()->error( $msg );
1210 throw new LogicException( $msg );
1211 }
1212 }
1213 $expandedFiles[$fileName] = $expanded;
1214 }
1215
1216 if ( $expandedFiles && $mainFile === null ) {
1217 // The first package file that is a script is the main file
1218 foreach ( $expandedFiles as $path => $file ) {
1219 if ( $file['type'] === 'script' || $file['type'] === 'script-vue' ) {
1220 $mainFile = $path;
1221 break;
1222 }
1223 }
1224 }
1225
1226 $result = [
1227 'main' => $mainFile,
1228 'files' => $expandedFiles
1229 ];
1230
1231 $this->expandedPackageFiles[$hash] = $result;
1232 return $result;
1233 }
1234
1264 private function expandFileInfo( Context $context, $fileInfo, $debugKey ) {
1265 if ( is_string( $fileInfo ) ) {
1266 // Inline common case
1267 return [
1268 'name' => $fileInfo,
1269 'type' => self::getPackageFileType( $fileInfo ),
1270 'filePath' => new FilePath( $fileInfo, $this->localBasePath, $this->remoteBasePath )
1271 ];
1272 } elseif ( $fileInfo instanceof FilePath ) {
1273 $fileInfo = [
1274 'name' => $fileInfo->getPath(),
1275 'file' => $fileInfo
1276 ];
1277 } elseif ( !is_array( $fileInfo ) ) {
1278 $msg = "Invalid type in $debugKey for module '{$this->getName()}', " .
1279 "must be array, string or FilePath";
1280 $this->getLogger()->error( $msg );
1281 throw new LogicException( $msg );
1282 }
1283 if ( !isset( $fileInfo['name'] ) ) {
1284 $msg = "Missing 'name' key in $debugKey for module '{$this->getName()}'";
1285 $this->getLogger()->error( $msg );
1286 throw new LogicException( $msg );
1287 }
1288 $fileName = $this->getPath( $fileInfo['name'] );
1289
1290 // Infer type from alias if needed
1291 $type = $fileInfo['type'] ?? self::getPackageFileType( $fileName );
1292 $expanded = [
1293 'name' => $fileName,
1294 'type' => $type
1295 ];
1296 if ( !empty( $fileInfo['main'] ) ) {
1297 $expanded['main'] = true;
1298 }
1299
1300 // Perform expansions (except 'file' and 'callback'), creating one of these keys:
1301 // - 'content': literal value.
1302 // - 'filePath': content to be read from a file.
1303 // - 'callback': content computed by a callable.
1304 if ( isset( $fileInfo['content'] ) ) {
1305 $expanded['content'] = $fileInfo['content'];
1306 } elseif ( isset( $fileInfo['file'] ) ) {
1307 $expanded['filePath'] = $this->makeFilePath( $fileInfo['file'] );
1308 } elseif ( isset( $fileInfo['callback'] ) ) {
1309 // If no extra parameter for the callback is given, use null.
1310 $expanded['callbackParam'] = $fileInfo['callbackParam'] ?? null;
1311
1312 if ( !is_callable( $fileInfo['callback'] ) ) {
1313 $msg = "Invalid 'callback' for module '{$this->getName()}', file '{$fileName}'.";
1314 $this->getLogger()->error( $msg );
1315 throw new LogicException( $msg );
1316 }
1317 if ( isset( $fileInfo['versionCallback'] ) ) {
1318 if ( !is_callable( $fileInfo['versionCallback'] ) ) {
1319 throw new LogicException( "Invalid 'versionCallback' for "
1320 . "module '{$this->getName()}', file '{$fileName}'."
1321 );
1322 }
1323
1324 // Execute the versionCallback with the same arguments that
1325 // would be given to the callback
1326 $callbackResult = ( $fileInfo['versionCallback'] )(
1327 $context,
1328 $this->getConfig(),
1329 $expanded['callbackParam']
1330 );
1331 if ( $callbackResult instanceof FilePath ) {
1332 $callbackResult->initBasePaths( $this->localBasePath, $this->remoteBasePath );
1333 $expanded['versionFilePath'] = $callbackResult;
1334 } else {
1335 $expanded['definitionSummary'] = $callbackResult;
1336 }
1337 // Don't invoke 'callback' here as it may be expensive (T223260).
1338 $expanded['callback'] = $fileInfo['callback'];
1339 } else {
1340 // Else go ahead invoke callback with its arguments.
1341 $callbackResult = ( $fileInfo['callback'] )(
1342 $context,
1343 $this->getConfig(),
1344 $expanded['callbackParam']
1345 );
1346 if ( $callbackResult instanceof FilePath ) {
1347 $callbackResult->initBasePaths( $this->localBasePath, $this->remoteBasePath );
1348 $expanded['filePath'] = $callbackResult;
1349 } else {
1350 $expanded['content'] = $callbackResult;
1351 }
1352 }
1353 } elseif ( isset( $fileInfo['config'] ) ) {
1354 if ( $type !== 'data' ) {
1355 $msg = "Key 'config' only valid for data files. "
1356 . " Module '{$this->getName()}', file '{$fileName}' is '{$type}'.";
1357 $this->getLogger()->error( $msg );
1358 throw new LogicException( $msg );
1359 }
1360 $expandedConfig = [];
1361 foreach ( $fileInfo['config'] as $configKey => $var ) {
1362 $expandedConfig[ is_numeric( $configKey ) ? $var : $configKey ] = $this->getConfig()->get( $var );
1363 }
1364 $expanded['content'] = $expandedConfig;
1365 } elseif ( !empty( $fileInfo['main'] ) ) {
1366 // [ 'name' => 'foo.js', 'main' => true ] is shorthand
1367 $expanded['filePath'] = $this->makeFilePath( $fileName );
1368 } else {
1369 $msg = "Incomplete definition for module '{$this->getName()}', file '{$fileName}'. "
1370 . "One of 'file', 'content', 'callback', or 'config' must be set.";
1371 $this->getLogger()->error( $msg );
1372 throw new LogicException( $msg );
1373 }
1374 if ( !isset( $expanded['filePath'] ) ) {
1375 $expanded['virtualFilePath'] = $this->makeFilePath( $fileName );
1376 }
1377 return $expanded;
1378 }
1379
1386 private function makeFilePath( $path ): FilePath {
1387 if ( $path instanceof FilePath ) {
1388 return $path;
1389 } elseif ( is_string( $path ) ) {
1390 return new FilePath( $path, $this->localBasePath, $this->remoteBasePath );
1391 } else {
1392 throw new InvalidArgumentException( '$path must be either FilePath or string' );
1393 }
1394 }
1395
1402 public function getPackageFiles( Context $context ) {
1403 if ( $this->packageFiles === null ) {
1404 return null;
1405 }
1406 $hash = $context->getHash();
1407 if ( isset( $this->fullyExpandedPackageFiles[ $hash ] ) ) {
1408 return $this->fullyExpandedPackageFiles[ $hash ];
1409 }
1410 $expandedPackageFiles = $this->expandPackageFiles( $context ) ?? [];
1411
1412 foreach ( $expandedPackageFiles['files'] as &$fileInfo ) {
1413 $this->readFileInfo( $context, $fileInfo );
1414 }
1415
1416 $this->fullyExpandedPackageFiles[ $hash ] = $expandedPackageFiles;
1417 return $expandedPackageFiles;
1418 }
1419
1428 private function readFileInfo( Context $context, array &$fileInfo ) {
1429 // Turn any 'filePath' or 'callback' key into actual 'content',
1430 // and remove the key after that. The callback could return a
1431 // FilePath object; if that happens, fall through to the 'filePath'
1432 // handling.
1433 if ( !isset( $fileInfo['content'] ) && isset( $fileInfo['callback'] ) ) {
1434 $callbackResult = ( $fileInfo['callback'] )(
1435 $context,
1436 $this->getConfig(),
1437 $fileInfo['callbackParam']
1438 );
1439 if ( $callbackResult instanceof FilePath ) {
1440 // Fall through to the filePath handling code below
1441 $fileInfo['filePath'] = $callbackResult;
1442 } else {
1443 $fileInfo['content'] = $callbackResult;
1444 }
1445 unset( $fileInfo['callback'] );
1446 }
1447 // Only interpret 'filePath' if 'content' hasn't been set already.
1448 // This can happen if 'versionCallback' provided 'filePath',
1449 // while 'callback' provides 'content'. In that case both are set
1450 // at this point. The 'filePath' from 'versionCallback' in that case is
1451 // only to inform getDefinitionSummary().
1452 if ( !isset( $fileInfo['content'] ) && isset( $fileInfo['filePath'] ) ) {
1453 $localPath = $this->getLocalPath( $fileInfo['filePath'] );
1454 $content = $this->getFileContents( $localPath, 'package' );
1455 if ( $fileInfo['type'] === 'data' ) {
1456 $content = json_decode( $content, false, 512, JSON_THROW_ON_ERROR );
1457 }
1458 $fileInfo['content'] = $content;
1459 }
1460 if ( $fileInfo['type'] === 'script-vue' ) {
1461 try {
1462 $parsedComponent = $this->getVueComponentParser()->parse(
1463 // @phan-suppress-next-line PhanTypePossiblyInvalidDimOffset False positive
1464 $fileInfo['content'],
1465 [ 'minifyTemplate' => !$context->getDebug() ]
1466 );
1467 } catch ( TimeoutException $e ) {
1468 throw $e;
1469 } catch ( Exception $e ) {
1470 $msg = "Error parsing file '{$fileInfo['name']}' in module '{$this->getName()}': " .
1471 $e->getMessage();
1472 $this->getLogger()->error( $msg );
1473 throw new RuntimeException( $msg );
1474 }
1475 $encodedTemplate = json_encode( $parsedComponent['template'] );
1476 if ( $context->getDebug() ) {
1477 // Replace \n (backslash-n) with space + backslash-n + backslash-newline in debug mode
1478 // The \n has to be preserved to prevent Vue parser issues (T351771)
1479 // We only replace \n if not preceded by a backslash, to avoid breaking '\\n'
1480 $encodedTemplate = preg_replace( '/(?<!\\\\)\\\\n/', " \\n\\\n", $encodedTemplate );
1481 // Expand \t to real tabs in debug mode
1482 $encodedTemplate = strtr( $encodedTemplate, [ "\\t" => "\t" ] );
1483 }
1484 $fileInfo['content'] = [
1485 'script' => $parsedComponent['script'] .
1486 ";\nmodule.exports.template = $encodedTemplate;",
1487 'style' => $parsedComponent['style'] ?? '',
1488 'styleLang' => $parsedComponent['styleLang'] ?? 'css'
1489 ];
1490 $fileInfo['type'] = 'script+style';
1491 }
1492 if ( !isset( $fileInfo['content'] ) ) {
1493 // This should not be possible due to validation in expandFileInfo()
1494 $msg = "Unable to resolve contents for file {$fileInfo['name']}";
1495 $this->getLogger()->error( $msg );
1496 throw new RuntimeException( $msg );
1497 }
1498
1499 // Not needed for client response, exists for use by getDefinitionSummary().
1500 unset( $fileInfo['definitionSummary'] );
1501 // Not needed for client response, used by callbacks only.
1502 unset( $fileInfo['callbackParam'] );
1503 }
1504
1515 protected function stripBom( $input ) {
1516 if ( str_starts_with( $input, "\xef\xbb\xbf" ) ) {
1517 return substr( $input, 3 );
1518 }
1519 return $input;
1520 }
1521}
const CACHE_ANYTHING
Definition Defines.php:86
$fallback
if(!defined('MW_SETUP_CALLBACK'))
Definition WebStart.php:81
static getFileContentsHash( $filePaths)
Get a hash of the combined contents of one or more files, either by retrieving a previously-computed ...
A class containing constants representing the names of configuration variables.
const StylePath
Name constant for the StylePath setting, for use with Config::get()
const ExtensionAssetsPath
Name constant for the ExtensionAssetsPath setting, for use with Config::get()
const Server
Name constant for the Server setting, for use with Config::get()
const ResourceBasePath
Name constant for the ResourceBasePath setting, for use with Config::get()
Service locator for MediaWiki core services.
static getInstance()
Returns the global default instance of the top level service locator.
This is one of the Core classes and should be read at least once by any new developers.
Load JSON files, and uses a Processor to extract information.
Context object that contains information about the state of a specific ResourceLoader web request.
Definition Context.php:45
getHash()
All factors that uniquely identify this request, except 'modules'.
Definition Context.php:445
Module based on local JavaScript/CSS files.
array< string, array< int, string|FilePath > > $skinScripts
Lists of JavaScript files by skin name.
getScriptURLsForDebug(Context $context)
static tryForKey(array $list, $key, $fallback=null)
Get a list of element that match a key, optionally using a fallback key.
getAllSkinStyleFiles()
Get a list of file paths for all skin style files in the module, for all available skins.
getDependencies(?Context $context=null)
Get names of modules this module depends on.
readStyleFile( $path, Context $context)
Read and process a style file.
getTemplates()
Get content of named templates for this module.
getStyleSheetLang( $path)
Infer the stylesheet language from a stylesheet file path.
processStyle( $style, $styleLang, $path, Context $context)
Process a CSS/LESS string.
getDefinitionSummary(Context $context)
Get the definition summary for this module.
requiresES6()
Whether the module requires ES6 support in the client.
readStyleFiles(array $styles, Context $context)
Read the contents of a list of CSS files and remap and concatenate these.
array< string, array< int, string|FilePath > > $languageScripts
Lists of JavaScript files by language code.
array< string, array< int, string|FilePath > > $skinStyles
Lists of CSS files by skin name.
array< int|string, string|FilePath > $templates
List of the named templates used by this module.
bool $noflip
Whether CSSJanus flipping should be skipped for this module.
enableModuleContentVersion()
Disable module content versioning.
string[] $dependencies
List of modules this module depends on.
static extractBasePaths(array $options=[], $localBasePath=null, $remoteBasePath=null)
Extract a pair of local and remote base paths from module definition information.
string $remoteBasePath
Remote base path, see __construct()
array< int, string|FilePath > $scripts
List of JavaScript file paths to always include.
stripBom( $input)
Take an input string and remove the UTF-8 BOM character if present.
getFlip(Context $context)
Get whether CSS for this module should be flipped.
getSkinStyleFiles( $skinName)
Get a list of file paths for all skin styles in the module used by the skin.
null string $skipFunction
File name containing the body of the skip function.
VueComponentParser null $vueComponentParser
Lazy-created by getVueComponentParser()
getPackageFiles(Context $context)
Resolve the package files definition and generate the content of each package file.
array< int, string|FilePath > $debugScripts
List of paths to JavaScript files to include in debug mode.
__construct(array $options=[], ?string $localBasePath=null, ?string $remoteBasePath=null)
Construct a new module from an options array.
string $localBasePath
Local base path, see __construct()
string[] $localFileRefs
Place where readStyleFile() tracks file dependencies.
compileLessString( $style, $stylePath, Context $context)
Compile a LESS string into CSS.
bool $hasGeneratedStyles
Whether getStyleURLsForDebug should return raw file paths, or return load.php urls.
getStyleFiles(Context $context)
Get a list of file paths for all styles in this module, in order of proper inclusion.
getScript(Context $context)
Get all JS for this module for a given language and skin.
getMessages()
Get message keys used by this module.
getAllStyleFiles()
Get all style files and all skin style files used by this module.
getGroup()
Get the name of the group this module should be loaded in.
string[] $missingLocalFileRefs
Place where readStyleFile() tracks file dependencies for non-existent files.
getStyles(Context $context)
Get all styles for a given context.
shouldSkipStructureTest()
Whether to skip the structure test ResourcesTest::testRespond() for this module.
static getPackageFileType( $path)
Infer the file type from a package file path.
bool $debugRaw
Link to raw files in debug mode.
string[] $messages
List of message keys used by this module.
null string $group
Name of group to load this module in.
setSkinStylesOverride(array $moduleSkinStyles)
Provide overrides for skinStyles to modules that support that.
getType()
Get the module's load type.
null array $packageFiles
Packaged files definition, to bundle and make available client-side via require().
bool $skipStructureTest
Whether to skip the structure test ResourcesTest::testRespond()
array< int, string|FilePath > $styles
List of CSS file files to always include.
A path to a bundled file (such as JavaScript or CSS), along with a remote and local base path.
Definition FilePath.php:34
Abstraction for ResourceLoader modules, with name registration and maxage functionality.
Definition Module.php:49
saveFileDependencies(Context $context, array $curFileRefs)
Save the indirect dependencies for this module pursuant to the skin/language context.
Definition Module.php:608
getLessVars(Context $context)
Get module-specific LESS variables, if any.
Definition Module.php:796
getFileDependencies(Context $context)
Get the indirect dependencies for this module pursuant to the skin/language context.
Definition Module.php:570
getMessageBlob(Context $context)
Get the hash of the message blob.
Definition Module.php:683
Parser for Vue single file components (.vue files).
get( $name)
Get a configuration variable such as "Sitename" or "UploadMaintenance.".