MediaWiki master
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 ( $localBasePath === null ) {
308 $localBasePath = MW_INSTALL_PATH;
309 }
310
311 if ( $remoteBasePath === '' ) {
312 // If MediaWiki is installed at the document root (not recommended),
313 // then wgScriptPath is set to the empty string by the installer to
314 // ensure safe concatenating of file paths (avoid "/" + "/foo" being "//foo").
315 // However, this also means the path itself can be an invalid URI path,
316 // as those must start with a slash. Within ResourceLoader, we will not
317 // do such primitive/unsafe slash concatenation and use URI resolution
318 // instead, so beyond this point, to avoid fatal errors in CSSMin::resolveUrl(),
319 // do a best-effort support for docroot installs by casting this to a slash.
320 $remoteBasePath = '/';
321 }
322
324 }
325
326 public function getScript( Context $context ) {
327 $packageFiles = $this->getPackageFiles( $context );
328 if ( $packageFiles !== null ) {
329 foreach ( $packageFiles['files'] as &$file ) {
330 if ( $file['type'] === 'script+style' ) {
331 $file['content'] = $file['content']['script'];
332 $file['type'] = 'script';
333 }
334 }
335 return $packageFiles;
336 }
337
338 $files = $this->getScriptFiles( $context );
339 foreach ( $files as &$file ) {
340 $this->readFileInfo( $context, $file );
341 }
342 return [ 'plainScripts' => $files ];
343 }
344
348 public function supportsURLLoading() {
349 // phpcs:ignore Generic.WhiteSpace.LanguageConstructSpacing.IncorrectSingle
350 return
351 // Denied by options?
352 $this->debugRaw
353 // If package files are involved, don't support URL loading, because that breaks
354 // scoped require() functions
355 && !$this->packageFiles
356 // Can't link to scripts generated by callbacks
357 && !$this->hasGeneratedScripts();
358 }
359
360 public function shouldSkipStructureTest() {
361 return $this->skipStructureTest || parent::shouldSkipStructureTest();
362 }
363
369 private function hasGeneratedScripts() {
370 foreach (
371 [ $this->scripts, $this->languageScripts, $this->skinScripts, $this->debugScripts ]
372 as $scripts
373 ) {
374 foreach ( $scripts as $script ) {
375 if ( is_array( $script ) ) {
376 if ( isset( $script['callback'] ) || isset( $script['versionCallback'] ) ) {
377 return true;
378 }
379 }
380 }
381 }
382 return false;
383 }
384
391 public function getStyles( Context $context ) {
392 $styles = $this->readStyleFiles(
393 $this->getStyleFiles( $context ),
394 $context
395 );
396
397 $packageFiles = $this->getPackageFiles( $context );
398 if ( $packageFiles !== null ) {
399 foreach ( $packageFiles['files'] as $fileName => $file ) {
400 if ( $file['type'] === 'script+style' ) {
401 $style = $this->processStyle(
402 $file['content']['style'],
403 $file['content']['styleLang'],
404 $fileName,
405 $context
406 );
407 $styles['all'] = ( $styles['all'] ?? '' ) . "\n" . $style;
408 }
409 }
410 }
411
412 // Track indirect file dependencies so that StartUpModule can check for
413 // on-disk file changes to any of this files without having to recompute the file list
414 $this->saveFileDependencies( $context, $this->localFileRefs );
415
416 return $styles;
417 }
418
423 public function getStyleURLsForDebug( Context $context ) {
424 if ( $this->hasGeneratedStyles ) {
425 // Do the default behaviour of returning a url back to load.php
426 // but with only=styles.
427 return parent::getStyleURLsForDebug( $context );
428 }
429 // Our module consists entirely of real css files,
430 // in debug mode we can load those directly.
431 $urls = [];
432 foreach ( $this->getStyleFiles( $context ) as $mediaType => $list ) {
433 $urls[$mediaType] = [];
434 foreach ( $list as $file ) {
435 $urls[$mediaType][] = OutputPage::transformResourcePath(
436 $this->getConfig(),
437 $this->getRemotePath( $file )
438 );
439 }
440 }
441 return $urls;
442 }
443
449 public function getMessages() {
450 return $this->messages;
451 }
452
458 public function getGroup() {
459 return $this->group;
460 }
461
468 public function getDependencies( ?Context $context = null ) {
469 return $this->dependencies;
470 }
471
479 private function getFileContents( $localPath, $type ) {
480 if ( !is_file( $localPath ) ) {
481 throw new RuntimeException( "$type file not found or not a file: \"$localPath\"" );
482 }
483 return $this->stripBom( file_get_contents( $localPath ) );
484 }
485
489 public function getSkipFunction() {
490 if ( !$this->skipFunction ) {
491 return null;
492 }
493 $localPath = $this->getLocalPath( $this->skipFunction );
494 return $this->getFileContents( $localPath, 'skip function' );
495 }
496
497 public function requiresES6() {
498 return true;
499 }
500
509 public function enableModuleContentVersion() {
510 return false;
511 }
512
519 private function getFileHashes( Context $context ) {
520 $files = [];
521
522 foreach ( $this->getStyleFiles( $context ) as $filePaths ) {
523 foreach ( $filePaths as $filePath ) {
524 $files[] = $this->getLocalPath( $filePath );
525 }
526 }
527
528 // Extract file paths for package files
529 // Optimisation: Use foreach() and isset() instead of array_map/array_filter.
530 // This is a hot code path, called by StartupModule for thousands of modules.
531 $expandedPackageFiles = $this->expandPackageFiles( $context );
532 if ( $expandedPackageFiles ) {
533 foreach ( $expandedPackageFiles['files'] as $fileInfo ) {
534 $filePath = $fileInfo['filePath'] ?? $fileInfo['versionFilePath'] ?? null;
535 if ( $filePath instanceof FilePath ) {
536 $files[] = $filePath->getLocalPath();
537 }
538 }
539 }
540
541 // Add other configured paths
542 $scriptFileInfos = $this->getScriptFiles( $context );
543 foreach ( $scriptFileInfos as $fileInfo ) {
544 $filePath = $fileInfo['filePath'] ?? $fileInfo['versionFilePath'] ?? null;
545 if ( $filePath instanceof FilePath ) {
546 $files[] = $filePath->getLocalPath();
547 }
548 }
549
550 foreach ( $this->templates as $filePath ) {
551 $files[] = $this->getLocalPath( $filePath );
552 }
553
554 if ( $this->skipFunction ) {
555 $files[] = $this->getLocalPath( $this->skipFunction );
556 }
557
558 // Add any lazily discovered file dependencies from previous module builds.
559 // These are already absolute paths.
560 foreach ( $this->getFileDependencies( $context ) as $file ) {
561 $files[] = $file;
562 }
563
564 // Filter out any duplicates. Typically introduced by getFileDependencies() which
565 // may lazily re-discover a primary file.
566 $files = array_unique( $files );
567
568 // Don't return array keys or any other form of file path here, only the hashes.
569 // Including file paths would needlessly cause global cache invalidation when files
570 // move on disk or if e.g. the MediaWiki directory name changes.
571 // Anything where order is significant is already detected by the definition summary.
573 }
574
581 public function getDefinitionSummary( Context $context ) {
582 $summary = parent::getDefinitionSummary( $context );
583
584 $options = [];
585 foreach ( [
586 // The following properties are omitted because they don't affect the module response:
587 // - localBasePath (Per T104950; Changes when absolute directory name changes. If
588 // this affects 'scripts' and other file paths, getFileHashes accounts for that.)
589 // - remoteBasePath (Per T104950)
590 // - dependencies (provided via startup module)
591 // - group (provided via startup module)
592 'styles',
593 'skinStyles',
594 'messages',
595 'templates',
596 'skipFunction',
597 'debugRaw',
598 ] as $member ) {
599 $options[$member] = $this->{$member};
600 }
601
602 $packageFiles = $this->expandPackageFiles( $context );
603 $packageSummaries = [];
604 if ( $packageFiles ) {
605 // Extract the minimum needed:
606 // - The 'main' pointer (included as-is).
607 // - The 'files' array, simplified to only which files exist (the keys of
608 // this array), and something that represents their non-file content.
609 // For packaged files that reflect files directly from disk, the
610 // 'getFileHashes' method tracks their content already.
611 // It is important that the keys of the $packageFiles['files'] array
612 // are preserved, as they do affect the module output.
613 foreach ( $packageFiles['files'] as $fileName => $fileInfo ) {
614 $packageSummaries[$fileName] =
615 $fileInfo['definitionSummary'] ?? $fileInfo['content'] ?? null;
616 }
617 }
618
619 $scriptFiles = $this->getScriptFiles( $context );
620 $scriptSummaries = [];
621 foreach ( $scriptFiles as $fileName => $fileInfo ) {
622 $scriptSummaries[$fileName] =
623 $fileInfo['definitionSummary'] ?? $fileInfo['content'] ?? null;
624 }
625
626 $summary[] = [
627 'options' => $options,
628 'packageFiles' => $packageSummaries,
629 'scripts' => $scriptSummaries,
630 'fileHashes' => $this->getFileHashes( $context ),
631 'messageBlob' => $this->getMessageBlob( $context ),
632 ];
633
634 $lessVars = $this->getLessVars( $context );
635 if ( $lessVars ) {
636 $summary[] = [ 'lessVars' => $lessVars ];
637 }
638
639 return $summary;
640 }
641
645 protected function getVueComponentParser() {
646 if ( $this->vueComponentParser === null ) {
647 $this->vueComponentParser = new VueComponentParser;
648 }
650 }
651
656 protected function getPath( $path ) {
657 if ( $path instanceof FilePath ) {
658 return $path->getPath();
659 }
660
661 return $path;
662 }
663
668 protected function getLocalPath( $path ) {
669 if ( $path instanceof FilePath ) {
670 if ( $path->getLocalBasePath() !== null ) {
671 return $path->getLocalPath();
672 }
673 $path = $path->getPath();
674 }
675
676 return "{$this->localBasePath}/$path";
677 }
678
683 protected function getRemotePath( $path ) {
684 if ( $path instanceof FilePath ) {
685 if ( $path->getRemoteBasePath() !== null ) {
686 return $path->getRemotePath();
687 }
688 $path = $path->getPath();
689 }
690
691 if ( $this->remoteBasePath === '/' ) {
692 return "/$path";
693 } else {
694 return "{$this->remoteBasePath}/$path";
695 }
696 }
697
705 public function getStyleSheetLang( $path ) {
706 return preg_match( '/\.less$/i', $path ) ? 'less' : 'css';
707 }
708
715 public static function getPackageFileType( $path ) {
716 if ( preg_match( '/\.json$/i', $path ) ) {
717 return 'data';
718 }
719 if ( preg_match( '/\.vue$/i', $path ) ) {
720 return 'script-vue';
721 }
722 return 'script';
723 }
724
732 private static function collateStyleFilesByMedia( array $list ) {
733 $collatedFiles = [];
734 foreach ( $list as $key => $value ) {
735 if ( is_int( $key ) ) {
736 // File name as the value
737 $collatedFiles['all'][] = $value;
738 } elseif ( is_array( $value ) ) {
739 // File name as the key, options array as the value
740 $optionValue = $value['media'] ?? 'all';
741 $collatedFiles[$optionValue][] = $key;
742 }
743 }
744 return $collatedFiles;
745 }
746
756 protected static function tryForKey( array $list, $key, $fallback = null ) {
757 if ( isset( $list[$key] ) && is_array( $list[$key] ) ) {
758 return $list[$key];
759 } elseif ( is_string( $fallback )
760 && isset( $list[$fallback] )
761 && is_array( $list[$fallback] )
762 ) {
763 return $list[$fallback];
764 }
765 return [];
766 }
767
774 private function getScriptFiles( Context $context ): array {
775 // List in execution order: scripts, languageScripts, skinScripts, debugScripts.
776 // Documented at MediaWiki\MainConfigSchema::ResourceModules.
777 $filesByCategory = [
778 'scripts' => $this->scripts,
779 'languageScripts' => $this->getLanguageScripts( $context->getLanguage() ),
780 'skinScripts' => self::tryForKey( $this->skinScripts, $context->getSkin(), 'default' ),
781 ];
782 if ( $context->getDebug() ) {
783 $filesByCategory['debugScripts'] = $this->debugScripts;
784 }
785
786 $expandedFiles = [];
787 foreach ( $filesByCategory as $category => $files ) {
788 foreach ( $files as $key => $fileInfo ) {
789 $expandedFileInfo = $this->expandFileInfo( $context, $fileInfo, "$category\[$key]" );
790 $expandedFiles[$expandedFileInfo['name']] = $expandedFileInfo;
791 }
792 }
793
794 return $expandedFiles;
795 }
796
804 private function getLanguageScripts( string $lang ): array {
805 $scripts = self::tryForKey( $this->languageScripts, $lang );
806 if ( $scripts ) {
807 return $scripts;
808 }
809
810 // Optimization: Avoid initialising and calling into language services
811 // for the majority of modules that don't use this option.
812 if ( $this->languageScripts ) {
813 $fallbacks = MediaWikiServices::getInstance()
814 ->getLanguageFallback()
815 ->getAll( $lang, LanguageFallback::MESSAGES );
816 foreach ( $fallbacks as $lang ) {
817 $scripts = self::tryForKey( $this->languageScripts, $lang );
818 if ( $scripts ) {
819 return $scripts;
820 }
821 }
822 }
823
824 return [];
825 }
826
827 public function setSkinStylesOverride( array $moduleSkinStyles ): void {
828 $moduleName = $this->getName();
829 foreach ( $moduleSkinStyles as $skinName => $overrides ) {
830 // If a module provides overrides for a skin, and that skin also provides overrides
831 // for the same module, then the module has precedence.
832 if ( isset( $this->skinStyles[$skinName] ) ) {
833 continue;
834 }
835
836 // If $moduleName in ResourceModuleSkinStyles is preceded with a '+', the defined style
837 // files will be added to 'default' skinStyles, otherwise 'default' will be ignored.
838 if ( isset( $overrides[$moduleName] ) ) {
839 $paths = (array)$overrides[$moduleName];
840 $styleFiles = [];
841 } elseif ( isset( $overrides['+' . $moduleName] ) ) {
842 $paths = (array)$overrides['+' . $moduleName];
843 $styleFiles = isset( $this->skinStyles['default'] ) ?
844 (array)$this->skinStyles['default'] :
845 [];
846 } else {
847 continue;
848 }
849
850 // Add new file paths, remapping them to refer to our directories and not use settings
851 // from the module we're modifying, which come from the base definition.
852 [ $localBasePath, $remoteBasePath ] = self::extractBasePaths( $overrides );
853
854 foreach ( $paths as $path ) {
855 $styleFiles[] = new FilePath( $path, $localBasePath, $remoteBasePath );
856 }
857
858 $this->skinStyles[$skinName] = $styleFiles;
859 }
860 }
861
869 public function getStyleFiles( Context $context ) {
870 return array_merge_recursive(
871 self::collateStyleFilesByMedia( $this->styles ),
872 self::collateStyleFilesByMedia(
873 self::tryForKey( $this->skinStyles, $context->getSkin(), 'default' )
874 )
875 );
876 }
877
885 protected function getSkinStyleFiles( $skinName ) {
886 return self::collateStyleFilesByMedia(
887 self::tryForKey( $this->skinStyles, $skinName )
888 );
889 }
890
897 protected function getAllSkinStyleFiles() {
898 $skinFactory = MediaWikiServices::getInstance()->getSkinFactory();
899 $styleFiles = [];
900
901 $internalSkinNames = array_keys( $skinFactory->getInstalledSkins() );
902 $internalSkinNames[] = 'default';
903
904 foreach ( $internalSkinNames as $internalSkinName ) {
905 $styleFiles = array_merge_recursive(
906 $styleFiles,
907 $this->getSkinStyleFiles( $internalSkinName )
908 );
909 }
910
911 return $styleFiles;
912 }
913
919 public function getAllStyleFiles() {
920 $collatedStyleFiles = array_merge_recursive(
921 self::collateStyleFilesByMedia( $this->styles ),
922 $this->getAllSkinStyleFiles()
923 );
924
925 $result = [];
926
927 foreach ( $collatedStyleFiles as $styleFiles ) {
928 foreach ( $styleFiles as $styleFile ) {
929 $result[] = $this->getLocalPath( $styleFile );
930 }
931 }
932
933 return $result;
934 }
935
944 public function readStyleFiles( array $styles, Context $context ) {
945 if ( !$styles ) {
946 return [];
947 }
948 foreach ( $styles as $media => $files ) {
949 $uniqueFiles = array_unique( $files, SORT_REGULAR );
950 $styleFiles = [];
951 foreach ( $uniqueFiles as $file ) {
952 $styleFiles[] = $this->readStyleFile( $file, $context );
953 }
954 $styles[$media] = implode( "\n", $styleFiles );
955 }
956 return $styles;
957 }
958
969 protected function readStyleFile( $path, Context $context ) {
970 $localPath = $this->getLocalPath( $path );
971 $style = $this->getFileContents( $localPath, 'style' );
972 $styleLang = $this->getStyleSheetLang( $localPath );
973
974 return $this->processStyle( $style, $styleLang, $path, $context );
975 }
976
993 protected function processStyle( $style, $styleLang, $path, Context $context ) {
994 $localPath = $this->getLocalPath( $path );
995 $remotePath = $this->getRemotePath( $path );
996
997 if ( $styleLang === 'less' ) {
998 $style = $this->compileLessString( $style, $localPath, $context );
999 $this->hasGeneratedStyles = true;
1000 }
1001
1002 if ( $this->getFlip( $context ) ) {
1003 $style = CSSJanus::transform(
1004 $style,
1005 /* $swapLtrRtlInURL = */ true,
1006 /* $swapLeftRightInURL = */ false
1007 );
1008 $this->hasGeneratedStyles = true;
1009 }
1010
1011 $localDir = dirname( $localPath );
1012 $remoteDir = dirname( $remotePath );
1013 // Get and register local file references
1014 $localFileRefs = CSSMin::getLocalFileReferences( $style, $localDir );
1015 foreach ( $localFileRefs as $file ) {
1016 if ( is_file( $file ) ) {
1017 $this->localFileRefs[] = $file;
1018 } else {
1019 $this->missingLocalFileRefs[] = $file;
1020 }
1021 }
1022 // Don't cache this call. remap() ensures data URIs embeds are up to date,
1023 // and urls contain correct content hashes in their query string. (T128668)
1024 return CSSMin::remap( $style, $localDir, $remoteDir, true );
1025 }
1026
1032 public function getFlip( Context $context ) {
1033 return $context->getDirection() === 'rtl' && !$this->noflip;
1034 }
1035
1042 public function getType() {
1043 $canBeStylesOnly = !(
1044 // All options except 'styles', 'skinStyles' and 'debugRaw'
1045 $this->scripts
1046 || $this->debugScripts
1047 || $this->templates
1048 || $this->languageScripts
1049 || $this->skinScripts
1050 || $this->dependencies
1051 || $this->messages
1052 || $this->skipFunction
1053 || $this->packageFiles
1054 );
1055 return $canBeStylesOnly ? self::LOAD_STYLES : self::LOAD_GENERAL;
1056 }
1057
1069 protected function compileLessString( $style, $stylePath, Context $context ) {
1070 static $cache;
1071 // @TODO: dependency injection
1072 if ( !$cache ) {
1073 $cache = MediaWikiServices::getInstance()->getObjectCacheFactory()
1074 ->getLocalServerInstance( CACHE_HASH );
1075 }
1076
1077 $skinName = $context->getSkin();
1078 $skinImportPaths = ExtensionRegistry::getInstance()->getAttribute( 'SkinLessImportPaths' );
1079 $importDirs = [];
1080 if ( isset( $skinImportPaths[ $skinName ] ) ) {
1081 $importDirs[] = $skinImportPaths[ $skinName ];
1082 }
1083
1084 $vars = $this->getLessVars( $context );
1085 // Construct a cache key from a hash of the LESS source, and a hash digest
1086 // of the LESS variables and import dirs used for compilation.
1087 ksort( $vars );
1088 $compilerParams = [
1089 'vars' => $vars,
1090 'importDirs' => $importDirs,
1091 // CodexDevelopmentDir affects import path mapping in ResourceLoader::getLessCompiler(),
1092 // so take that into account too
1093 'codexDevDir' => $this->getConfig()->get( MainConfigNames::CodexDevelopmentDir )
1094 ];
1095 $key = $cache->makeGlobalKey(
1096 'resourceloader-less',
1097 'v1',
1098 hash( 'md4', $style ),
1099 hash( 'md4', serialize( $compilerParams ) )
1100 );
1101
1102 // If we got a cached value, we have to validate it by getting a checksum of all the
1103 // files that were loaded by the parser and ensuring it matches the cached entry's.
1104 $data = $cache->get( $key );
1105 if (
1106 !$data ||
1107 $data['hash'] !== FileContentsHasher::getFileContentsHash( $data['files'] )
1108 ) {
1109 $compiler = $context->getResourceLoader()->getLessCompiler( $vars, $importDirs );
1110
1111 $css = $compiler->parse( $style, $stylePath )->getCss();
1112 // T253055: store the implicit dependency paths in a form relative to any install
1113 // path so that multiple version of the application can share the cache for identical
1114 // less stylesheets. This also avoids churn during application updates.
1115 $files = $compiler->getParsedFiles();
1116 $data = [
1117 'css' => $css,
1118 'files' => Module::getRelativePaths( $files ),
1119 'hash' => FileContentsHasher::getFileContentsHash( $files )
1120 ];
1121 $cache->set( $key, $data, $cache::TTL_DAY );
1122 }
1123
1124 foreach ( Module::expandRelativePaths( $data['files'] ) as $path ) {
1125 $this->localFileRefs[] = $path;
1126 }
1127
1128 return $data['css'];
1129 }
1130
1136 public function getTemplates() {
1137 $templates = [];
1138
1139 foreach ( $this->templates as $alias => $templatePath ) {
1140 // Alias is optional
1141 if ( is_int( $alias ) ) {
1142 $alias = $this->getPath( $templatePath );
1143 }
1144 $localPath = $this->getLocalPath( $templatePath );
1145 $content = $this->getFileContents( $localPath, 'template' );
1146
1147 $templates[$alias] = $this->stripBom( $content );
1148 }
1149 return $templates;
1150 }
1151
1171 private function expandPackageFiles( Context $context ) {
1172 $hash = $context->getHash();
1173 if ( isset( $this->expandedPackageFiles[$hash] ) ) {
1174 return $this->expandedPackageFiles[$hash];
1175 }
1176 if ( $this->packageFiles === null ) {
1177 return null;
1178 }
1179 $expandedFiles = [];
1180 $mainFile = null;
1181
1182 foreach ( $this->packageFiles as $key => $fileInfo ) {
1183 $expanded = $this->expandFileInfo( $context, $fileInfo, "packageFiles[$key]" );
1184 $fileName = $expanded['name'];
1185 if ( !empty( $expanded['main'] ) ) {
1186 unset( $expanded['main'] );
1187 $type = $expanded['type'];
1188 $mainFile = $fileName;
1189 if ( $type !== 'script' && $type !== 'script-vue' ) {
1190 $msg = "Main file in package must be of type 'script', module " .
1191 "'{$this->getName()}', main file '{$mainFile}' is '{$type}'.";
1192 $this->getLogger()->error( $msg );
1193 throw new LogicException( $msg );
1194 }
1195 }
1196 $expandedFiles[$fileName] = $expanded;
1197 }
1198
1199 if ( $expandedFiles && $mainFile === null ) {
1200 // The first package file that is a script is the main file
1201 foreach ( $expandedFiles as $path => $file ) {
1202 if ( $file['type'] === 'script' || $file['type'] === 'script-vue' ) {
1203 $mainFile = $path;
1204 break;
1205 }
1206 }
1207 }
1208
1209 $result = [
1210 'main' => $mainFile,
1211 'files' => $expandedFiles
1212 ];
1213
1214 $this->expandedPackageFiles[$hash] = $result;
1215 return $result;
1216 }
1217
1247 private function expandFileInfo( Context $context, $fileInfo, $debugKey ) {
1248 if ( is_string( $fileInfo ) ) {
1249 // Inline common case
1250 return [
1251 'name' => $fileInfo,
1252 'type' => self::getPackageFileType( $fileInfo ),
1253 'filePath' => new FilePath( $fileInfo, $this->localBasePath, $this->remoteBasePath )
1254 ];
1255 } elseif ( $fileInfo instanceof FilePath ) {
1256 $fileInfo = [
1257 'name' => $fileInfo->getPath(),
1258 'file' => $fileInfo
1259 ];
1260 } elseif ( !is_array( $fileInfo ) ) {
1261 $msg = "Invalid type in $debugKey for module '{$this->getName()}', " .
1262 "must be array, string or FilePath";
1263 $this->getLogger()->error( $msg );
1264 throw new LogicException( $msg );
1265 }
1266 if ( !isset( $fileInfo['name'] ) ) {
1267 $msg = "Missing 'name' key in $debugKey for module '{$this->getName()}'";
1268 $this->getLogger()->error( $msg );
1269 throw new LogicException( $msg );
1270 }
1271 $fileName = $this->getPath( $fileInfo['name'] );
1272
1273 // Infer type from alias if needed
1274 $type = $fileInfo['type'] ?? self::getPackageFileType( $fileName );
1275 $expanded = [
1276 'name' => $fileName,
1277 'type' => $type
1278 ];
1279 if ( !empty( $fileInfo['main'] ) ) {
1280 $expanded['main'] = true;
1281 }
1282
1283 // Perform expansions (except 'file' and 'callback'), creating one of these keys:
1284 // - 'content': literal value.
1285 // - 'filePath': content to be read from a file.
1286 // - 'callback': content computed by a callable.
1287 if ( isset( $fileInfo['content'] ) ) {
1288 $expanded['content'] = $fileInfo['content'];
1289 } elseif ( isset( $fileInfo['file'] ) ) {
1290 $expanded['filePath'] = $this->makeFilePath( $fileInfo['file'] );
1291 } elseif ( isset( $fileInfo['callback'] ) ) {
1292 // If no extra parameter for the callback is given, use null.
1293 $expanded['callbackParam'] = $fileInfo['callbackParam'] ?? null;
1294
1295 if ( !is_callable( $fileInfo['callback'] ) ) {
1296 $msg = "Invalid 'callback' for module '{$this->getName()}', file '{$fileName}'.";
1297 $this->getLogger()->error( $msg );
1298 throw new LogicException( $msg );
1299 }
1300 if ( isset( $fileInfo['versionCallback'] ) ) {
1301 if ( !is_callable( $fileInfo['versionCallback'] ) ) {
1302 throw new LogicException( "Invalid 'versionCallback' for "
1303 . "module '{$this->getName()}', file '{$fileName}'."
1304 );
1305 }
1306
1307 // Execute the versionCallback with the same arguments that
1308 // would be given to the callback
1309 $callbackResult = ( $fileInfo['versionCallback'] )(
1310 $context,
1311 $this->getConfig(),
1312 $expanded['callbackParam']
1313 );
1314 if ( $callbackResult instanceof FilePath ) {
1315 $callbackResult->initBasePaths( $this->localBasePath, $this->remoteBasePath );
1316 $expanded['versionFilePath'] = $callbackResult;
1317 } else {
1318 $expanded['definitionSummary'] = $callbackResult;
1319 }
1320 // Don't invoke 'callback' here as it may be expensive (T223260).
1321 $expanded['callback'] = $fileInfo['callback'];
1322 } else {
1323 // Else go ahead invoke callback with its arguments.
1324 $callbackResult = ( $fileInfo['callback'] )(
1325 $context,
1326 $this->getConfig(),
1327 $expanded['callbackParam']
1328 );
1329 if ( $callbackResult instanceof FilePath ) {
1330 $callbackResult->initBasePaths( $this->localBasePath, $this->remoteBasePath );
1331 $expanded['filePath'] = $callbackResult;
1332 } else {
1333 $expanded['content'] = $callbackResult;
1334 }
1335 }
1336 } elseif ( isset( $fileInfo['config'] ) ) {
1337 if ( $type !== 'data' ) {
1338 $msg = "Key 'config' only valid for data files. "
1339 . " Module '{$this->getName()}', file '{$fileName}' is '{$type}'.";
1340 $this->getLogger()->error( $msg );
1341 throw new LogicException( $msg );
1342 }
1343 $expandedConfig = [];
1344 foreach ( $fileInfo['config'] as $configKey => $var ) {
1345 $expandedConfig[ is_numeric( $configKey ) ? $var : $configKey ] = $this->getConfig()->get( $var );
1346 }
1347 $expanded['content'] = $expandedConfig;
1348 } elseif ( !empty( $fileInfo['main'] ) ) {
1349 // [ 'name' => 'foo.js', 'main' => true ] is shorthand
1350 $expanded['filePath'] = $this->makeFilePath( $fileName );
1351 } else {
1352 $msg = "Incomplete definition for module '{$this->getName()}', file '{$fileName}'. "
1353 . "One of 'file', 'content', 'callback', or 'config' must be set.";
1354 $this->getLogger()->error( $msg );
1355 throw new LogicException( $msg );
1356 }
1357 if ( !isset( $expanded['filePath'] ) ) {
1358 $expanded['virtualFilePath'] = $this->makeFilePath( $fileName );
1359 }
1360 return $expanded;
1361 }
1362
1369 private function makeFilePath( $path ): FilePath {
1370 if ( $path instanceof FilePath ) {
1371 return $path;
1372 } elseif ( is_string( $path ) ) {
1373 return new FilePath( $path, $this->localBasePath, $this->remoteBasePath );
1374 } else {
1375 throw new InvalidArgumentException( '$path must be either FilePath or string' );
1376 }
1377 }
1378
1385 public function getPackageFiles( Context $context ) {
1386 if ( $this->packageFiles === null ) {
1387 return null;
1388 }
1389 $hash = $context->getHash();
1390 if ( isset( $this->fullyExpandedPackageFiles[ $hash ] ) ) {
1391 return $this->fullyExpandedPackageFiles[ $hash ];
1392 }
1393 $expandedPackageFiles = $this->expandPackageFiles( $context ) ?? [];
1394
1395 foreach ( $expandedPackageFiles['files'] as &$fileInfo ) {
1396 $this->readFileInfo( $context, $fileInfo );
1397 }
1398
1399 $this->fullyExpandedPackageFiles[ $hash ] = $expandedPackageFiles;
1400 return $expandedPackageFiles;
1401 }
1402
1411 private function readFileInfo( Context $context, array &$fileInfo ) {
1412 // Turn any 'filePath' or 'callback' key into actual 'content',
1413 // and remove the key after that. The callback could return a
1414 // FilePath object; if that happens, fall through to the 'filePath'
1415 // handling.
1416 if ( !isset( $fileInfo['content'] ) && isset( $fileInfo['callback'] ) ) {
1417 $callbackResult = ( $fileInfo['callback'] )(
1418 $context,
1419 $this->getConfig(),
1420 $fileInfo['callbackParam']
1421 );
1422 if ( $callbackResult instanceof FilePath ) {
1423 // Fall through to the filePath handling code below
1424 $fileInfo['filePath'] = $callbackResult;
1425 } else {
1426 $fileInfo['content'] = $callbackResult;
1427 }
1428 unset( $fileInfo['callback'] );
1429 }
1430 // Only interpret 'filePath' if 'content' hasn't been set already.
1431 // This can happen if 'versionCallback' provided 'filePath',
1432 // while 'callback' provides 'content'. In that case both are set
1433 // at this point. The 'filePath' from 'versionCallback' in that case is
1434 // only to inform getDefinitionSummary().
1435 if ( !isset( $fileInfo['content'] ) && isset( $fileInfo['filePath'] ) ) {
1436 $localPath = $this->getLocalPath( $fileInfo['filePath'] );
1437 $content = $this->getFileContents( $localPath, 'package' );
1438 if ( $fileInfo['type'] === 'data' ) {
1439 $content = json_decode( $content, false, 512, JSON_THROW_ON_ERROR );
1440 }
1441 $fileInfo['content'] = $content;
1442 }
1443 if ( $fileInfo['type'] === 'script-vue' ) {
1444 try {
1445 $parsedComponent = $this->getVueComponentParser()->parse(
1446 // @phan-suppress-next-line PhanTypePossiblyInvalidDimOffset False positive
1447 $fileInfo['content'],
1448 [ 'minifyTemplate' => !$context->getDebug() ]
1449 );
1450 } catch ( TimeoutException $e ) {
1451 throw $e;
1452 } catch ( Exception $e ) {
1453 $msg = "Error parsing file '{$fileInfo['name']}' in module '{$this->getName()}': " .
1454 $e->getMessage();
1455 $this->getLogger()->error( $msg );
1456 throw new RuntimeException( $msg );
1457 }
1458 $encodedTemplate = json_encode( $parsedComponent['template'] );
1459 if ( $context->getDebug() ) {
1460 // Replace \n (backslash-n) with space + backslash-n + backslash-newline in debug mode
1461 // The \n has to be preserved to prevent Vue parser issues (T351771)
1462 // We only replace \n if not preceded by a backslash, to avoid breaking '\\n'
1463 $encodedTemplate = preg_replace( '/(?<!\\\\)\\\\n/', " \\n\\\n", $encodedTemplate );
1464 // Expand \t to real tabs in debug mode
1465 $encodedTemplate = strtr( $encodedTemplate, [ "\\t" => "\t" ] );
1466 }
1467 $fileInfo['content'] = [
1468 'script' => $parsedComponent['script'] .
1469 ";\nmodule.exports.template = $encodedTemplate;",
1470 'style' => $parsedComponent['style'] ?? '',
1471 'styleLang' => $parsedComponent['styleLang'] ?? 'css'
1472 ];
1473 $fileInfo['type'] = 'script+style';
1474 }
1475 if ( !isset( $fileInfo['content'] ) ) {
1476 // This should not be possible due to validation in expandFileInfo()
1477 $msg = "Unable to resolve contents for file {$fileInfo['name']}";
1478 $this->getLogger()->error( $msg );
1479 throw new RuntimeException( $msg );
1480 }
1481
1482 // Not needed for client response, exists for use by getDefinitionSummary().
1483 unset( $fileInfo['definitionSummary'] );
1484 // Not needed for client response, used by callbacks only.
1485 unset( $fileInfo['callbackParam'] );
1486 }
1487
1498 protected function stripBom( $input ) {
1499 if ( str_starts_with( $input, "\xef\xbb\xbf" ) ) {
1500 return substr( $input, 3 );
1501 }
1502 return $input;
1503 }
1504}
const CACHE_HASH
Definition Defines.php:91
$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 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:46
getHash()
All factors that uniquely identify this request, except 'modules'.
Definition Context.php:419
Module based on local JavaScript/CSS files.
array< string, array< int, string|FilePath > > $skinScripts
Lists of JavaScript files by skin name.
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:47
saveFileDependencies(Context $context, array $curFileRefs)
Save the indirect dependencies for this module pursuant to the skin/language context.
Definition Module.php:542
getLessVars(Context $context)
Get module-specific LESS variables, if any.
Definition Module.php:718
getFileDependencies(Context $context)
Get the indirect dependencies for this module pursuant to the skin/language context.
Definition Module.php:507
getMessageBlob(Context $context)
Get the hash of the message blob.
Definition Module.php:605
Parser for Vue single file components (.vue files).