MediaWiki master
FileModule.php
Go to the documentation of this file.
1<?php
24
25use CSSJanus;
26use Exception;
29use InvalidArgumentException;
30use 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
284 if ( $remoteBasePath === null ) {
287 }
288
289 if ( isset( $options['remoteExtPath'] ) ) {
290 $extensionAssetsPath = MediaWikiServices::getInstance()->getMainConfig()
292 $remoteBasePath = $extensionAssetsPath . '/' . $options['remoteExtPath'];
293 }
294
295 if ( isset( $options['remoteSkinPath'] ) ) {
296 $stylePath = MediaWikiServices::getInstance()->getMainConfig()
298 $remoteBasePath = $stylePath . '/' . $options['remoteSkinPath'];
299 }
300
301 if ( array_key_exists( 'localBasePath', $options ) ) {
302 $localBasePath = (string)$options['localBasePath'];
303 }
304
305 if ( array_key_exists( 'remoteBasePath', $options ) ) {
306 $remoteBasePath = (string)$options['remoteBasePath'];
307 }
308
309 if ( $remoteBasePath === '' ) {
310 // If MediaWiki is installed at the document root (not recommended),
311 // then wgScriptPath is set to the empty string by the installer to
312 // ensure safe concatenating of file paths (avoid "/" + "/foo" being "//foo").
313 // However, this also means the path itself can be an invalid URI path,
314 // as those must start with a slash. Within ResourceLoader, we will not
315 // do such primitive/unsafe slash concatenation and use URI resolution
316 // instead, so beyond this point, to avoid fatal errors in CSSMin::resolveUrl(),
317 // do a best-effort support for docroot installs by casting this to a slash.
318 $remoteBasePath = '/';
319 }
320
321 return [ $localBasePath ?? MW_INSTALL_PATH, $remoteBasePath ];
322 }
323
324 public function getScript( Context $context ) {
325 $packageFiles = $this->getPackageFiles( $context );
326 if ( $packageFiles !== null ) {
327 foreach ( $packageFiles['files'] as &$file ) {
328 if ( $file['type'] === 'script+style' ) {
329 $file['content'] = $file['content']['script'];
330 $file['type'] = 'script';
331 }
332 }
333 return $packageFiles;
334 }
335
336 $files = $this->getScriptFiles( $context );
337 foreach ( $files as &$file ) {
338 $this->readFileInfo( $context, $file );
339 }
340 return [ 'plainScripts' => $files ];
341 }
342
347 public function getScriptURLsForDebug( Context $context ) {
348 $rl = $context->getResourceLoader();
349 $config = $this->getConfig();
350 $server = $config->get( MainConfigNames::Server );
351
352 $urls = [];
353 foreach ( $this->getScriptFiles( $context ) as $file ) {
354 if ( isset( $file['filePath'] ) ) {
355 $url = OutputPage::transformResourcePath( $config, $this->getRemotePath( $file['filePath'] ) );
356 // Expand debug URL in case we are another wiki's module source (T255367)
357 $url = $rl->expandUrl( $server, $url );
358 $urls[] = $url;
359 }
360 }
361 return $urls;
362 }
363
367 public function supportsURLLoading() {
368 // phpcs:ignore Generic.WhiteSpace.LanguageConstructSpacing.IncorrectSingle
369 return
370 // Denied by options?
371 $this->debugRaw
372 // If package files are involved, don't support URL loading, because that breaks
373 // scoped require() functions
374 && !$this->packageFiles
375 // Can't link to scripts generated by callbacks
376 && !$this->hasGeneratedScripts();
377 }
378
379 public function shouldSkipStructureTest() {
380 return $this->skipStructureTest || parent::shouldSkipStructureTest();
381 }
382
388 private function hasGeneratedScripts() {
389 foreach (
390 [ $this->scripts, $this->languageScripts, $this->skinScripts, $this->debugScripts ]
391 as $scripts
392 ) {
393 foreach ( $scripts as $script ) {
394 if ( is_array( $script ) ) {
395 if ( isset( $script['callback'] ) || isset( $script['versionCallback'] ) ) {
396 return true;
397 }
398 }
399 }
400 }
401 return false;
402 }
403
410 public function getStyles( Context $context ) {
411 $styles = $this->readStyleFiles(
412 $this->getStyleFiles( $context ),
413 $context
414 );
415
416 $packageFiles = $this->getPackageFiles( $context );
417 if ( $packageFiles !== null ) {
418 foreach ( $packageFiles['files'] as $fileName => $file ) {
419 if ( $file['type'] === 'script+style' ) {
420 $style = $this->processStyle(
421 $file['content']['style'],
422 $file['content']['styleLang'],
423 $fileName,
424 $context
425 );
426 $styles['all'] = ( $styles['all'] ?? '' ) . "\n" . $style;
427 }
428 }
429 }
430
431 // Track indirect file dependencies so that StartUpModule can check for
432 // on-disk file changes to any of this files without having to recompute the file list
433 $this->saveFileDependencies( $context, $this->localFileRefs );
434
435 return $styles;
436 }
437
442 public function getStyleURLsForDebug( Context $context ) {
443 if ( $this->hasGeneratedStyles ) {
444 // Do the default behaviour of returning a url back to load.php
445 // but with only=styles.
446 return parent::getStyleURLsForDebug( $context );
447 }
448 // Our module consists entirely of real css files,
449 // in debug mode we can load those directly.
450 $urls = [];
451 foreach ( $this->getStyleFiles( $context ) as $mediaType => $list ) {
452 $urls[$mediaType] = [];
453 foreach ( $list as $file ) {
454 $urls[$mediaType][] = OutputPage::transformResourcePath(
455 $this->getConfig(),
456 $this->getRemotePath( $file )
457 );
458 }
459 }
460 return $urls;
461 }
462
468 public function getMessages() {
469 return $this->messages;
470 }
471
477 public function getGroup() {
478 return $this->group;
479 }
480
487 public function getDependencies( Context $context = null ) {
488 return $this->dependencies;
489 }
490
498 private function getFileContents( $localPath, $type ) {
499 if ( !is_file( $localPath ) ) {
500 throw new RuntimeException( "$type file not found or not a file: \"$localPath\"" );
501 }
502 return $this->stripBom( file_get_contents( $localPath ) );
503 }
504
508 public function getSkipFunction() {
509 if ( !$this->skipFunction ) {
510 return null;
511 }
512 $localPath = $this->getLocalPath( $this->skipFunction );
513 return $this->getFileContents( $localPath, 'skip function' );
514 }
515
516 public function requiresES6() {
517 return true;
518 }
519
528 public function enableModuleContentVersion() {
529 return false;
530 }
531
538 private function getFileHashes( Context $context ) {
539 $files = [];
540
541 foreach ( $this->getStyleFiles( $context ) as $filePaths ) {
542 foreach ( $filePaths as $filePath ) {
543 $files[] = $this->getLocalPath( $filePath );
544 }
545 }
546
547 // Extract file paths for package files
548 // Optimisation: Use foreach() and isset() instead of array_map/array_filter.
549 // This is a hot code path, called by StartupModule for thousands of modules.
550 $expandedPackageFiles = $this->expandPackageFiles( $context );
551 if ( $expandedPackageFiles ) {
552 foreach ( $expandedPackageFiles['files'] as $fileInfo ) {
553 if ( isset( $fileInfo['filePath'] ) ) {
555 $filePath = $fileInfo['filePath'];
556 $files[] = $filePath->getLocalPath();
557 }
558 }
559 }
560
561 // Add other configured paths
562 $scriptFileInfos = $this->getScriptFiles( $context );
563 foreach ( $scriptFileInfos as $fileInfo ) {
564 $filePath = $fileInfo['filePath'] ?? $fileInfo['versionFilePath'] ?? null;
565 if ( $filePath instanceof FilePath ) {
566 $files[] = $filePath->getLocalPath();
567 }
568 }
569
570 foreach ( $this->templates as $filePath ) {
571 $files[] = $this->getLocalPath( $filePath );
572 }
573
574 if ( $this->skipFunction ) {
575 $files[] = $this->getLocalPath( $this->skipFunction );
576 }
577
578 // Add any lazily discovered file dependencies from previous module builds.
579 // These are already absolute paths.
580 foreach ( $this->getFileDependencies( $context ) as $file ) {
581 $files[] = $file;
582 }
583
584 // Filter out any duplicates. Typically introduced by getFileDependencies() which
585 // may lazily re-discover a primary file.
586 $files = array_unique( $files );
587
588 // Don't return array keys or any other form of file path here, only the hashes.
589 // Including file paths would needlessly cause global cache invalidation when files
590 // move on disk or if e.g. the MediaWiki directory name changes.
591 // Anything where order is significant is already detected by the definition summary.
593 }
594
601 public function getDefinitionSummary( Context $context ) {
602 $summary = parent::getDefinitionSummary( $context );
603
604 $options = [];
605 foreach ( [
606 // The following properties are omitted because they don't affect the module response:
607 // - localBasePath (Per T104950; Changes when absolute directory name changes. If
608 // this affects 'scripts' and other file paths, getFileHashes accounts for that.)
609 // - remoteBasePath (Per T104950)
610 // - dependencies (provided via startup module)
611 // - group (provided via startup module)
612 'styles',
613 'skinStyles',
614 'messages',
615 'templates',
616 'skipFunction',
617 'debugRaw',
618 ] as $member ) {
619 $options[$member] = $this->{$member};
620 }
621
622 $packageFiles = $this->expandPackageFiles( $context );
623 $packageSummaries = [];
624 if ( $packageFiles ) {
625 // Extract the minimum needed:
626 // - The 'main' pointer (included as-is).
627 // - The 'files' array, simplified to only which files exist (the keys of
628 // this array), and something that represents their non-file content.
629 // For packaged files that reflect files directly from disk, the
630 // 'getFileHashes' method tracks their content already.
631 // It is important that the keys of the $packageFiles['files'] array
632 // are preserved, as they do affect the module output.
633 foreach ( $packageFiles['files'] as $fileName => $fileInfo ) {
634 $packageSummaries[$fileName] =
635 $fileInfo['definitionSummary'] ?? $fileInfo['content'] ?? null;
636 }
637 }
638
639 $scriptFiles = $this->getScriptFiles( $context );
640 $scriptSummaries = [];
641 foreach ( $scriptFiles as $fileName => $fileInfo ) {
642 $scriptSummaries[$fileName] =
643 $fileInfo['definitionSummary'] ?? $fileInfo['content'] ?? null;
644 }
645
646 $summary[] = [
647 'options' => $options,
648 'packageFiles' => $packageSummaries,
649 'scripts' => $scriptSummaries,
650 'fileHashes' => $this->getFileHashes( $context ),
651 'messageBlob' => $this->getMessageBlob( $context ),
652 ];
653
654 $lessVars = $this->getLessVars( $context );
655 if ( $lessVars ) {
656 $summary[] = [ 'lessVars' => $lessVars ];
657 }
658
659 return $summary;
660 }
661
665 protected function getVueComponentParser() {
666 if ( $this->vueComponentParser === null ) {
667 $this->vueComponentParser = new VueComponentParser;
668 }
670 }
671
676 protected function getPath( $path ) {
677 if ( $path instanceof FilePath ) {
678 return $path->getPath();
679 }
680
681 return $path;
682 }
683
688 protected function getLocalPath( $path ) {
689 if ( $path instanceof FilePath ) {
690 if ( $path->getLocalBasePath() !== null ) {
691 return $path->getLocalPath();
692 }
693 $path = $path->getPath();
694 }
695
696 return "{$this->localBasePath}/$path";
697 }
698
703 protected function getRemotePath( $path ) {
704 if ( $path instanceof FilePath ) {
705 if ( $path->getRemoteBasePath() !== null ) {
706 return $path->getRemotePath();
707 }
708 $path = $path->getPath();
709 }
710
711 if ( $this->remoteBasePath === '/' ) {
712 return "/$path";
713 } else {
714 return "{$this->remoteBasePath}/$path";
715 }
716 }
717
725 public function getStyleSheetLang( $path ) {
726 return preg_match( '/\.less$/i', $path ) ? 'less' : 'css';
727 }
728
735 public static function getPackageFileType( $path ) {
736 if ( preg_match( '/\.json$/i', $path ) ) {
737 return 'data';
738 }
739 if ( preg_match( '/\.vue$/i', $path ) ) {
740 return 'script-vue';
741 }
742 return 'script';
743 }
744
752 private static function collateStyleFilesByMedia( array $list ) {
753 $collatedFiles = [];
754 foreach ( $list as $key => $value ) {
755 if ( is_int( $key ) ) {
756 // File name as the value
757 $collatedFiles['all'][] = $value;
758 } elseif ( is_array( $value ) ) {
759 // File name as the key, options array as the value
760 $optionValue = $value['media'] ?? 'all';
761 $collatedFiles[$optionValue][] = $key;
762 }
763 }
764 return $collatedFiles;
765 }
766
776 protected static function tryForKey( array $list, $key, $fallback = null ) {
777 if ( isset( $list[$key] ) && is_array( $list[$key] ) ) {
778 return $list[$key];
779 } elseif ( is_string( $fallback )
780 && isset( $list[$fallback] )
781 && is_array( $list[$fallback] )
782 ) {
783 return $list[$fallback];
784 }
785 return [];
786 }
787
794 private function getScriptFiles( Context $context ): array {
795 // List in execution order: scripts, languageScripts, skinScripts, debugScripts.
796 // Documented at MediaWiki\MainConfigSchema::ResourceModules.
797 $filesByCategory = [
798 'scripts' => $this->scripts,
799 'languageScripts' => $this->getLanguageScripts( $context->getLanguage() ),
800 'skinScripts' => self::tryForKey( $this->skinScripts, $context->getSkin(), 'default' ),
801 ];
802 if ( $context->getDebug() ) {
803 $filesByCategory['debugScripts'] = $this->debugScripts;
804 }
805
806 $expandedFiles = [];
807 foreach ( $filesByCategory as $category => $files ) {
808 foreach ( $files as $key => $fileInfo ) {
809 $expandedFileInfo = $this->expandFileInfo( $context, $fileInfo, "$category\[$key]" );
810 $expandedFiles[$expandedFileInfo['name']] = $expandedFileInfo;
811 }
812 }
813
814 return $expandedFiles;
815 }
816
824 private function getLanguageScripts( string $lang ): array {
825 $scripts = self::tryForKey( $this->languageScripts, $lang );
826 if ( $scripts ) {
827 return $scripts;
828 }
829
830 // Optimization: Avoid initialising and calling into language services
831 // for the majority of modules that don't use this option.
832 if ( $this->languageScripts ) {
833 $fallbacks = MediaWikiServices::getInstance()
834 ->getLanguageFallback()
835 ->getAll( $lang, LanguageFallback::MESSAGES );
836 foreach ( $fallbacks as $lang ) {
837 $scripts = self::tryForKey( $this->languageScripts, $lang );
838 if ( $scripts ) {
839 return $scripts;
840 }
841 }
842 }
843
844 return [];
845 }
846
847 public function setSkinStylesOverride( array $moduleSkinStyles ): void {
848 $moduleName = $this->getName();
849 foreach ( $moduleSkinStyles as $skinName => $overrides ) {
850 // If a module provides overrides for a skin, and that skin also provides overrides
851 // for the same module, then the module has precedence.
852 if ( isset( $this->skinStyles[$skinName] ) ) {
853 continue;
854 }
855
856 // If $moduleName in ResourceModuleSkinStyles is preceded with a '+', the defined style
857 // files will be added to 'default' skinStyles, otherwise 'default' will be ignored.
858 if ( isset( $overrides[$moduleName] ) ) {
859 $paths = (array)$overrides[$moduleName];
860 $styleFiles = [];
861 } elseif ( isset( $overrides['+' . $moduleName] ) ) {
862 $paths = (array)$overrides['+' . $moduleName];
863 $styleFiles = isset( $this->skinStyles['default'] ) ?
864 (array)$this->skinStyles['default'] :
865 [];
866 } else {
867 continue;
868 }
869
870 // Add new file paths, remapping them to refer to our directories and not use settings
871 // from the module we're modifying, which come from the base definition.
872 [ $localBasePath, $remoteBasePath ] = self::extractBasePaths( $overrides );
873
874 foreach ( $paths as $path ) {
875 $styleFiles[] = new FilePath( $path, $localBasePath, $remoteBasePath );
876 }
877
878 $this->skinStyles[$skinName] = $styleFiles;
879 }
880 }
881
889 public function getStyleFiles( Context $context ) {
890 return array_merge_recursive(
891 self::collateStyleFilesByMedia( $this->styles ),
892 self::collateStyleFilesByMedia(
893 self::tryForKey( $this->skinStyles, $context->getSkin(), 'default' )
894 )
895 );
896 }
897
905 protected function getSkinStyleFiles( $skinName ) {
906 return self::collateStyleFilesByMedia(
907 self::tryForKey( $this->skinStyles, $skinName )
908 );
909 }
910
917 protected function getAllSkinStyleFiles() {
918 $skinFactory = MediaWikiServices::getInstance()->getSkinFactory();
919 $styleFiles = [];
920
921 $internalSkinNames = array_keys( $skinFactory->getInstalledSkins() );
922 $internalSkinNames[] = 'default';
923
924 foreach ( $internalSkinNames as $internalSkinName ) {
925 $styleFiles = array_merge_recursive(
926 $styleFiles,
927 $this->getSkinStyleFiles( $internalSkinName )
928 );
929 }
930
931 return $styleFiles;
932 }
933
939 public function getAllStyleFiles() {
940 $collatedStyleFiles = array_merge_recursive(
941 self::collateStyleFilesByMedia( $this->styles ),
942 $this->getAllSkinStyleFiles()
943 );
944
945 $result = [];
946
947 foreach ( $collatedStyleFiles as $styleFiles ) {
948 foreach ( $styleFiles as $styleFile ) {
949 $result[] = $this->getLocalPath( $styleFile );
950 }
951 }
952
953 return $result;
954 }
955
964 public function readStyleFiles( array $styles, Context $context ) {
965 if ( !$styles ) {
966 return [];
967 }
968 foreach ( $styles as $media => $files ) {
969 $uniqueFiles = array_unique( $files, SORT_REGULAR );
970 $styleFiles = [];
971 foreach ( $uniqueFiles as $file ) {
972 $styleFiles[] = $this->readStyleFile( $file, $context );
973 }
974 $styles[$media] = implode( "\n", $styleFiles );
975 }
976 return $styles;
977 }
978
989 protected function readStyleFile( $path, Context $context ) {
990 $localPath = $this->getLocalPath( $path );
991 $style = $this->getFileContents( $localPath, 'style' );
992 $styleLang = $this->getStyleSheetLang( $localPath );
993
994 return $this->processStyle( $style, $styleLang, $path, $context );
995 }
996
1013 protected function processStyle( $style, $styleLang, $path, Context $context ) {
1014 $localPath = $this->getLocalPath( $path );
1015 $remotePath = $this->getRemotePath( $path );
1016
1017 if ( $styleLang === 'less' ) {
1018 $style = $this->compileLessString( $style, $localPath, $context );
1019 $this->hasGeneratedStyles = true;
1020 }
1021
1022 if ( $this->getFlip( $context ) ) {
1023 $style = CSSJanus::transform(
1024 $style,
1025 /* $swapLtrRtlInURL = */ true,
1026 /* $swapLeftRightInURL = */ false
1027 );
1028 }
1029
1030 $localDir = dirname( $localPath );
1031 $remoteDir = dirname( $remotePath );
1032 // Get and register local file references
1033 $localFileRefs = CSSMin::getLocalFileReferences( $style, $localDir );
1034 foreach ( $localFileRefs as $file ) {
1035 if ( is_file( $file ) ) {
1036 $this->localFileRefs[] = $file;
1037 } else {
1038 $this->missingLocalFileRefs[] = $file;
1039 }
1040 }
1041 // Don't cache this call. remap() ensures data URIs embeds are up to date,
1042 // and urls contain correct content hashes in their query string. (T128668)
1043 return CSSMin::remap( $style, $localDir, $remoteDir, true );
1044 }
1045
1051 public function getFlip( Context $context ) {
1052 return $context->getDirection() === 'rtl' && !$this->noflip;
1053 }
1054
1061 public function getType() {
1062 $canBeStylesOnly = !(
1063 // All options except 'styles', 'skinStyles' and 'debugRaw'
1064 $this->scripts
1065 || $this->debugScripts
1066 || $this->templates
1067 || $this->languageScripts
1068 || $this->skinScripts
1069 || $this->dependencies
1070 || $this->messages
1071 || $this->skipFunction
1072 || $this->packageFiles
1073 );
1074 return $canBeStylesOnly ? self::LOAD_STYLES : self::LOAD_GENERAL;
1075 }
1076
1088 protected function compileLessString( $style, $stylePath, Context $context ) {
1089 static $cache;
1090 // @TODO: dependency injection
1091 if ( !$cache ) {
1092 $cache = MediaWikiServices::getInstance()->getObjectCacheFactory()
1093 ->getLocalServerInstance( CACHE_ANYTHING );
1094 }
1095
1096 $skinName = $context->getSkin();
1097 $skinImportPaths = ExtensionRegistry::getInstance()->getAttribute( 'SkinLessImportPaths' );
1098 $importDirs = [];
1099 if ( isset( $skinImportPaths[ $skinName ] ) ) {
1100 $importDirs[] = $skinImportPaths[ $skinName ];
1101 }
1102
1103 $vars = $this->getLessVars( $context );
1104 // Construct a cache key from a hash of the LESS source, and a hash digest
1105 // of the LESS variables used for compilation.
1106 ksort( $vars );
1107 $compilerParams = [
1108 'vars' => $vars,
1109 'importDirs' => $importDirs,
1110 ];
1111 $key = $cache->makeGlobalKey(
1112 'resourceloader-less',
1113 'v1',
1114 hash( 'md4', $style ),
1115 hash( 'md4', serialize( $compilerParams ) )
1116 );
1117
1118 // If we got a cached value, we have to validate it by getting a checksum of all the
1119 // files that were loaded by the parser and ensuring it matches the cached entry's.
1120 $data = $cache->get( $key );
1121 if (
1122 !$data ||
1123 $data['hash'] !== FileContentsHasher::getFileContentsHash( $data['files'] )
1124 ) {
1125 $compiler = $context->getResourceLoader()->getLessCompiler( $vars, $importDirs );
1126
1127 $css = $compiler->parse( $style, $stylePath )->getCss();
1128 // T253055: store the implicit dependency paths in a form relative to any install
1129 // path so that multiple version of the application can share the cache for identical
1130 // less stylesheets. This also avoids churn during application updates.
1131 $files = $compiler->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
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
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
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
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
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
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
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}
const CACHE_ANYTHING
Definition Defines.php:85
$fallback
if(!defined('MW_SETUP_CALLBACK'))
Definition WebStart.php:81
Load JSON files, and uses a Processor to extract information.
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.
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:442
Module based on local JavaScript/CSS files.
getDependencies(Context $context=null)
Get names of modules this module depends on.
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.
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:604
getLessVars(Context $context)
Get module-specific LESS variables, if any.
Definition Module.php:792
getFileDependencies(Context $context)
Get the indirect dependencies for this module pursuant to the skin/language context.
Definition Module.php:566
getMessageBlob(Context $context)
Get the hash of the message blob.
Definition Module.php:679
Parser for Vue single file components (.vue files).
get( $name)
Get a configuration variable such as "Sitename" or "UploadMaintenance.".