MediaWiki master
FileModule.php
Go to the documentation of this file.
1<?php
24
25use CSSJanus;
26use Exception;
29use InvalidArgumentException;
30use LogicException;
35use ObjectCache;
36use RuntimeException;
37use Wikimedia\Minify\CSSMin;
38use Wikimedia\RequestTimeout\TimeoutException;
39
53class FileModule extends Module {
55 protected $localBasePath = '';
56
58 protected $remoteBasePath = '';
59
63 protected $scripts = [];
64
68 protected $languageScripts = [];
69
73 protected $skinScripts = [];
74
78 protected $debugScripts = [];
79
83 protected $styles = [];
84
88 protected $skinStyles = [];
89
97 protected $packageFiles = null;
98
103 private $expandedPackageFiles = [];
104
109 private $fullyExpandedPackageFiles = [];
110
114 protected $dependencies = [];
115
119 protected $skipFunction = null;
120
124 protected $messages = [];
125
127 protected $templates = [];
128
130 protected $group = null;
131
133 protected $debugRaw = true;
134
136 protected $noflip = false;
137
139 protected $skipStructureTest = false;
140
145 protected $hasGeneratedStyles = false;
146
150 protected $localFileRefs = [];
151
156 protected $missingLocalFileRefs = [];
157
161 protected $vueComponentParser = null;
162
172 public function __construct(
173 array $options = [],
174 string $localBasePath = null,
175 string $remoteBasePath = null
176 ) {
177 // Flag to decide whether to automagically add the mediawiki.template module
178 $hasTemplates = false;
179 // localBasePath and remoteBasePath both have unbelievably long fallback chains
180 // and need to be handled separately.
183
184 // Extract, validate and normalise remaining options
185 foreach ( $options as $member => $option ) {
186 switch ( $member ) {
187 // Lists of file paths
188 case 'scripts':
189 case 'debugScripts':
190 case 'styles':
191 case 'packageFiles':
192 $this->{$member} = is_array( $option ) ? $option : [ $option ];
193 break;
194 case 'templates':
195 $hasTemplates = true;
196 $this->{$member} = is_array( $option ) ? $option : [ $option ];
197 break;
198 // Collated lists of file paths
199 case 'languageScripts':
200 case 'skinScripts':
201 case 'skinStyles':
202 if ( !is_array( $option ) ) {
203 throw new InvalidArgumentException(
204 "Invalid collated file path list error. " .
205 "'$option' given, array expected."
206 );
207 }
208 foreach ( $option as $key => $value ) {
209 if ( !is_string( $key ) ) {
210 throw new InvalidArgumentException(
211 "Invalid collated file path list key error. " .
212 "'$key' given, string expected."
213 );
214 }
215 $this->{$member}[$key] = is_array( $value ) ? $value : [ $value ];
216 }
217 break;
218 case 'deprecated':
219 $this->deprecated = $option;
220 break;
221 // Lists of strings
222 case 'dependencies':
223 case 'messages':
224 // Normalise
225 $option = array_values( array_unique( (array)$option ) );
226 sort( $option );
227
228 $this->{$member} = $option;
229 break;
230 // Single strings
231 case 'group':
232 case 'skipFunction':
233 $this->{$member} = (string)$option;
234 break;
235 // Single booleans
236 case 'debugRaw':
237 case 'noflip':
238 case 'skipStructureTest':
239 $this->{$member} = (bool)$option;
240 break;
241 }
242 }
243 if ( isset( $options['scripts'] ) && isset( $options['packageFiles'] ) ) {
244 throw new InvalidArgumentException( "A module may not set both 'scripts' and 'packageFiles'" );
245 }
246 if ( isset( $options['packageFiles'] ) && isset( $options['skinScripts'] ) ) {
247 throw new InvalidArgumentException( "Options 'skinScripts' and 'packageFiles' cannot be used together." );
248 }
249 if ( $hasTemplates ) {
250 $this->dependencies[] = 'mediawiki.template';
251 // Ensure relevant template compiler module gets loaded
252 foreach ( $this->templates as $alias => $templatePath ) {
253 if ( is_int( $alias ) ) {
254 $alias = $this->getPath( $templatePath );
255 }
256 $suffix = explode( '.', $alias );
257 $suffix = end( $suffix );
258 $compilerModule = 'mediawiki.template.' . $suffix;
259 if ( $suffix !== 'html' && !in_array( $compilerModule, $this->dependencies ) ) {
260 $this->dependencies[] = $compilerModule;
261 }
262 }
263 }
264 }
265
277 public static function extractBasePaths(
278 array $options = [],
279 $localBasePath = null,
280 $remoteBasePath = null
281 ) {
282 // The different ways these checks are done, and their ordering, look very silly,
283 // but were preserved for backwards-compatibility just in case. Tread lightly.
284
285 if ( $remoteBasePath === null ) {
288 }
289
290 if ( isset( $options['remoteExtPath'] ) ) {
291 $extensionAssetsPath = MediaWikiServices::getInstance()->getMainConfig()
293 $remoteBasePath = $extensionAssetsPath . '/' . $options['remoteExtPath'];
294 }
295
296 if ( isset( $options['remoteSkinPath'] ) ) {
297 $stylePath = MediaWikiServices::getInstance()->getMainConfig()
299 $remoteBasePath = $stylePath . '/' . $options['remoteSkinPath'];
300 }
301
302 if ( array_key_exists( 'localBasePath', $options ) ) {
303 $localBasePath = (string)$options['localBasePath'];
304 }
305
306 if ( array_key_exists( 'remoteBasePath', $options ) ) {
307 $remoteBasePath = (string)$options['remoteBasePath'];
308 }
309
310 if ( $remoteBasePath === '' ) {
311 // If MediaWiki is installed at the document root (not recommended),
312 // then wgScriptPath is set to the empty string by the installer to
313 // ensure safe concatenating of file paths (avoid "/" + "/foo" being "//foo").
314 // However, this also means the path itself can be an invalid URI path,
315 // as those must start with a slash. Within ResourceLoader, we will not
316 // do such primitive/unsafe slash concatenation and use URI resolution
317 // instead, so beyond this point, to avoid fatal errors in CSSMin::resolveUrl(),
318 // do a best-effort support for docroot installs by casting this to a slash.
319 $remoteBasePath = '/';
320 }
321
322 return [ $localBasePath ?? MW_INSTALL_PATH, $remoteBasePath ];
323 }
324
325 public function getScript( Context $context ) {
326 $packageFiles = $this->getPackageFiles( $context );
327 if ( $packageFiles !== null ) {
328 foreach ( $packageFiles['files'] as &$file ) {
329 if ( $file['type'] === 'script+style' ) {
330 $file['content'] = $file['content']['script'];
331 $file['type'] = 'script';
332 }
333 }
334 return $packageFiles;
335 }
336
337 $files = $this->getScriptFiles( $context );
338 foreach ( $files as &$file ) {
339 $this->readFileInfo( $context, $file );
340 }
341 return [ 'plainScripts' => $files ];
342 }
343
348 public function getScriptURLsForDebug( Context $context ) {
349 $rl = $context->getResourceLoader();
350 $config = $this->getConfig();
351 $server = $config->get( MainConfigNames::Server );
352
353 $urls = [];
354 foreach ( $this->getScriptFiles( $context ) as $file ) {
355 if ( isset( $file['filePath'] ) ) {
356 $url = OutputPage::transformResourcePath( $config, $this->getRemotePath( $file['filePath'] ) );
357 // Expand debug URL in case we are another wiki's module source (T255367)
358 $url = $rl->expandUrl( $server, $url );
359 $urls[] = $url;
360 }
361 }
362 return $urls;
363 }
364
368 public function supportsURLLoading() {
369 // phpcs:ignore Generic.WhiteSpace.LanguageConstructSpacing.IncorrectSingle
370 return
371 // Denied by options?
372 $this->debugRaw
373 // If package files are involved, don't support URL loading, because that breaks
374 // scoped require() functions
375 && !$this->packageFiles
376 // Can't link to scripts generated by callbacks
377 && !$this->hasGeneratedScripts();
378 }
379
380 public function shouldSkipStructureTest() {
381 return $this->skipStructureTest || parent::shouldSkipStructureTest();
382 }
383
389 private function hasGeneratedScripts() {
390 foreach (
391 [ $this->scripts, $this->languageScripts, $this->skinScripts, $this->debugScripts ]
392 as $scripts
393 ) {
394 foreach ( $scripts as $script ) {
395 if ( is_array( $script ) ) {
396 if ( isset( $script['callback'] ) || isset( $script['versionCallback'] ) ) {
397 return true;
398 }
399 }
400 }
401 }
402 return false;
403 }
404
411 public function getStyles( Context $context ) {
412 $styles = $this->readStyleFiles(
413 $this->getStyleFiles( $context ),
414 $context
415 );
416
417 $packageFiles = $this->getPackageFiles( $context );
418 if ( $packageFiles !== null ) {
419 foreach ( $packageFiles['files'] as $fileName => $file ) {
420 if ( $file['type'] === 'script+style' ) {
421 $style = $this->processStyle(
422 $file['content']['style'],
423 $file['content']['styleLang'],
424 $fileName,
425 $context
426 );
427 $styles['all'] = ( $styles['all'] ?? '' ) . "\n" . $style;
428 }
429 }
430 }
431
432 // Track indirect file dependencies so that StartUpModule can check for
433 // on-disk file changes to any of this files without having to recompute the file list
434 $this->saveFileDependencies( $context, $this->localFileRefs );
435
436 return $styles;
437 }
438
443 public function getStyleURLsForDebug( Context $context ) {
444 if ( $this->hasGeneratedStyles ) {
445 // Do the default behaviour of returning a url back to load.php
446 // but with only=styles.
447 return parent::getStyleURLsForDebug( $context );
448 }
449 // Our module consists entirely of real css files,
450 // in debug mode we can load those directly.
451 $urls = [];
452 foreach ( $this->getStyleFiles( $context ) as $mediaType => $list ) {
453 $urls[$mediaType] = [];
454 foreach ( $list as $file ) {
455 $urls[$mediaType][] = OutputPage::transformResourcePath(
456 $this->getConfig(),
457 $this->getRemotePath( $file )
458 );
459 }
460 }
461 return $urls;
462 }
463
469 public function getMessages() {
470 return $this->messages;
471 }
472
478 public function getGroup() {
479 return $this->group;
480 }
481
488 public function getDependencies( Context $context = null ) {
489 return $this->dependencies;
490 }
491
499 private function getFileContents( $localPath, $type ) {
500 if ( !is_file( $localPath ) ) {
501 throw new RuntimeException( "$type file not found or not a file: \"$localPath\"" );
502 }
503 return $this->stripBom( file_get_contents( $localPath ) );
504 }
505
509 public function getSkipFunction() {
510 if ( !$this->skipFunction ) {
511 return null;
512 }
513 $localPath = $this->getLocalPath( $this->skipFunction );
514 return $this->getFileContents( $localPath, 'skip function' );
515 }
516
517 public function requiresES6() {
518 return true;
519 }
520
529 public function enableModuleContentVersion() {
530 return false;
531 }
532
539 private function getFileHashes( Context $context ) {
540 $files = [];
541
542 foreach ( $this->getStyleFiles( $context ) as $filePaths ) {
543 foreach ( $filePaths as $filePath ) {
544 $files[] = $this->getLocalPath( $filePath );
545 }
546 }
547
548 // Extract file paths for package files
549 // Optimisation: Use foreach() and isset() instead of array_map/array_filter.
550 // This is a hot code path, called by StartupModule for thousands of modules.
551 $expandedPackageFiles = $this->expandPackageFiles( $context );
552 if ( $expandedPackageFiles ) {
553 foreach ( $expandedPackageFiles['files'] as $fileInfo ) {
554 if ( isset( $fileInfo['filePath'] ) ) {
556 $filePath = $fileInfo['filePath'];
557 $files[] = $filePath->getLocalPath();
558 }
559 }
560 }
561
562 // Add other configured paths
563 $scriptFileInfos = $this->getScriptFiles( $context );
564 foreach ( $scriptFileInfos as $fileInfo ) {
565 $filePath = $fileInfo['filePath'] ?? $fileInfo['versionFilePath'] ?? null;
566 if ( $filePath instanceof FilePath ) {
567 $files[] = $filePath->getLocalPath();
568 }
569 }
570
571 foreach ( $this->templates as $filePath ) {
572 $files[] = $this->getLocalPath( $filePath );
573 }
574
575 if ( $this->skipFunction ) {
576 $files[] = $this->getLocalPath( $this->skipFunction );
577 }
578
579 // Add any lazily discovered file dependencies from previous module builds.
580 // These are already absolute paths.
581 foreach ( $this->getFileDependencies( $context ) as $file ) {
582 $files[] = $file;
583 }
584
585 // Filter out any duplicates. Typically introduced by getFileDependencies() which
586 // may lazily re-discover a primary file.
587 $files = array_unique( $files );
588
589 // Don't return array keys or any other form of file path here, only the hashes.
590 // Including file paths would needlessly cause global cache invalidation when files
591 // move on disk or if e.g. the MediaWiki directory name changes.
592 // Anything where order is significant is already detected by the definition summary.
594 }
595
602 public function getDefinitionSummary( Context $context ) {
603 $summary = parent::getDefinitionSummary( $context );
604
605 $options = [];
606 foreach ( [
607 // The following properties are omitted because they don't affect the module response:
608 // - localBasePath (Per T104950; Changes when absolute directory name changes. If
609 // this affects 'scripts' and other file paths, getFileHashes accounts for that.)
610 // - remoteBasePath (Per T104950)
611 // - dependencies (provided via startup module)
612 // - group (provided via startup module)
613 'styles',
614 'skinStyles',
615 'messages',
616 'templates',
617 'skipFunction',
618 'debugRaw',
619 ] as $member ) {
620 $options[$member] = $this->{$member};
621 }
622
623 $packageFiles = $this->expandPackageFiles( $context );
624 $packageSummaries = [];
625 if ( $packageFiles ) {
626 // Extract the minimum needed:
627 // - The 'main' pointer (included as-is).
628 // - The 'files' array, simplified to only which files exist (the keys of
629 // this array), and something that represents their non-file content.
630 // For packaged files that reflect files directly from disk, the
631 // 'getFileHashes' method tracks their content already.
632 // It is important that the keys of the $packageFiles['files'] array
633 // are preserved, as they do affect the module output.
634 foreach ( $packageFiles['files'] as $fileName => $fileInfo ) {
635 $packageSummaries[$fileName] =
636 $fileInfo['definitionSummary'] ?? $fileInfo['content'] ?? null;
637 }
638 }
639
640 $scriptFiles = $this->getScriptFiles( $context );
641 $scriptSummaries = [];
642 foreach ( $scriptFiles as $fileName => $fileInfo ) {
643 $scriptSummaries[$fileName] =
644 $fileInfo['definitionSummary'] ?? $fileInfo['content'] ?? null;
645 }
646
647 $summary[] = [
648 'options' => $options,
649 'packageFiles' => $packageSummaries,
650 'scripts' => $scriptSummaries,
651 'fileHashes' => $this->getFileHashes( $context ),
652 'messageBlob' => $this->getMessageBlob( $context ),
653 ];
654
655 $lessVars = $this->getLessVars( $context );
656 if ( $lessVars ) {
657 $summary[] = [ 'lessVars' => $lessVars ];
658 }
659
660 return $summary;
661 }
662
666 protected function getVueComponentParser() {
667 if ( $this->vueComponentParser === null ) {
668 $this->vueComponentParser = new VueComponentParser;
669 }
671 }
672
677 protected function getPath( $path ) {
678 if ( $path instanceof FilePath ) {
679 return $path->getPath();
680 }
681
682 return $path;
683 }
684
689 protected function getLocalPath( $path ) {
690 if ( $path instanceof FilePath ) {
691 if ( $path->getLocalBasePath() !== null ) {
692 return $path->getLocalPath();
693 }
694 $path = $path->getPath();
695 }
696
697 return "{$this->localBasePath}/$path";
698 }
699
704 protected function getRemotePath( $path ) {
705 if ( $path instanceof FilePath ) {
706 if ( $path->getRemoteBasePath() !== null ) {
707 return $path->getRemotePath();
708 }
709 $path = $path->getPath();
710 }
711
712 if ( $this->remoteBasePath === '/' ) {
713 return "/$path";
714 } else {
715 return "{$this->remoteBasePath}/$path";
716 }
717 }
718
726 public function getStyleSheetLang( $path ) {
727 return preg_match( '/\.less$/i', $path ) ? 'less' : 'css';
728 }
729
736 public static function getPackageFileType( $path ) {
737 if ( preg_match( '/\.json$/i', $path ) ) {
738 return 'data';
739 }
740 if ( preg_match( '/\.vue$/i', $path ) ) {
741 return 'script-vue';
742 }
743 return 'script';
744 }
745
753 private static function collateStyleFilesByMedia( array $list ) {
754 $collatedFiles = [];
755 foreach ( $list as $key => $value ) {
756 if ( is_int( $key ) ) {
757 // File name as the value
758 $collatedFiles['all'][] = $value;
759 } elseif ( is_array( $value ) ) {
760 // File name as the key, options array as the value
761 $optionValue = $value['media'] ?? 'all';
762 $collatedFiles[$optionValue][] = $key;
763 }
764 }
765 return $collatedFiles;
766 }
767
777 protected static function tryForKey( array $list, $key, $fallback = null ) {
778 if ( isset( $list[$key] ) && is_array( $list[$key] ) ) {
779 return $list[$key];
780 } elseif ( is_string( $fallback )
781 && isset( $list[$fallback] )
782 && is_array( $list[$fallback] )
783 ) {
784 return $list[$fallback];
785 }
786 return [];
787 }
788
795 private function getScriptFiles( Context $context ): array {
796 // List in execution order: scripts, languageScripts, skinScripts, debugScripts.
797 // Documented at MediaWiki\MainConfigSchema::ResourceModules.
798 $filesByCategory = [
799 'scripts' => $this->scripts,
800 'languageScripts' => $this->getLanguageScripts( $context->getLanguage() ),
801 'skinScripts' => self::tryForKey( $this->skinScripts, $context->getSkin(), 'default' ),
802 ];
803 if ( $context->getDebug() ) {
804 $filesByCategory['debugScripts'] = $this->debugScripts;
805 }
806
807 $expandedFiles = [];
808 foreach ( $filesByCategory as $category => $files ) {
809 foreach ( $files as $key => $fileInfo ) {
810 $expandedFileInfo = $this->expandFileInfo( $context, $fileInfo, "$category\[$key]" );
811 $expandedFiles[$expandedFileInfo['name']] = $expandedFileInfo;
812 }
813 }
814
815 return $expandedFiles;
816 }
817
825 private function getLanguageScripts( string $lang ): array {
826 $scripts = self::tryForKey( $this->languageScripts, $lang );
827 if ( $scripts ) {
828 return $scripts;
829 }
830
831 // Optimization: Avoid initialising and calling into language services
832 // for the majority of modules that don't use this option.
833 if ( $this->languageScripts ) {
834 $fallbacks = MediaWikiServices::getInstance()
835 ->getLanguageFallback()
836 ->getAll( $lang, LanguageFallback::MESSAGES );
837 foreach ( $fallbacks as $lang ) {
838 $scripts = self::tryForKey( $this->languageScripts, $lang );
839 if ( $scripts ) {
840 return $scripts;
841 }
842 }
843 }
844
845 return [];
846 }
847
848 public function setSkinStylesOverride( array $moduleSkinStyles ): void {
849 $moduleName = $this->getName();
850 foreach ( $moduleSkinStyles as $skinName => $overrides ) {
851 // If a module provides overrides for a skin, and that skin also provides overrides
852 // for the same module, then the module has precedence.
853 if ( isset( $this->skinStyles[$skinName] ) ) {
854 continue;
855 }
856
857 // If $moduleName in ResourceModuleSkinStyles is preceded with a '+', the defined style
858 // files will be added to 'default' skinStyles, otherwise 'default' will be ignored.
859 if ( isset( $overrides[$moduleName] ) ) {
860 $paths = (array)$overrides[$moduleName];
861 $styleFiles = [];
862 } elseif ( isset( $overrides['+' . $moduleName] ) ) {
863 $paths = (array)$overrides['+' . $moduleName];
864 $styleFiles = isset( $this->skinStyles['default'] ) ?
865 (array)$this->skinStyles['default'] :
866 [];
867 } else {
868 continue;
869 }
870
871 // Add new file paths, remapping them to refer to our directories and not use settings
872 // from the module we're modifying, which come from the base definition.
873 [ $localBasePath, $remoteBasePath ] = self::extractBasePaths( $overrides );
874
875 foreach ( $paths as $path ) {
876 $styleFiles[] = new FilePath( $path, $localBasePath, $remoteBasePath );
877 }
878
879 $this->skinStyles[$skinName] = $styleFiles;
880 }
881 }
882
890 public function getStyleFiles( Context $context ) {
891 return array_merge_recursive(
892 self::collateStyleFilesByMedia( $this->styles ),
893 self::collateStyleFilesByMedia(
894 self::tryForKey( $this->skinStyles, $context->getSkin(), 'default' )
895 )
896 );
897 }
898
906 protected function getSkinStyleFiles( $skinName ) {
907 return self::collateStyleFilesByMedia(
908 self::tryForKey( $this->skinStyles, $skinName )
909 );
910 }
911
918 protected function getAllSkinStyleFiles() {
919 $skinFactory = MediaWikiServices::getInstance()->getSkinFactory();
920 $styleFiles = [];
921
922 $internalSkinNames = array_keys( $skinFactory->getInstalledSkins() );
923 $internalSkinNames[] = 'default';
924
925 foreach ( $internalSkinNames as $internalSkinName ) {
926 $styleFiles = array_merge_recursive(
927 $styleFiles,
928 $this->getSkinStyleFiles( $internalSkinName )
929 );
930 }
931
932 return $styleFiles;
933 }
934
940 public function getAllStyleFiles() {
941 $collatedStyleFiles = array_merge_recursive(
942 self::collateStyleFilesByMedia( $this->styles ),
943 $this->getAllSkinStyleFiles()
944 );
945
946 $result = [];
947
948 foreach ( $collatedStyleFiles as $styleFiles ) {
949 foreach ( $styleFiles as $styleFile ) {
950 $result[] = $this->getLocalPath( $styleFile );
951 }
952 }
953
954 return $result;
955 }
956
965 public function readStyleFiles( array $styles, Context $context ) {
966 if ( !$styles ) {
967 return [];
968 }
969 foreach ( $styles as $media => $files ) {
970 $uniqueFiles = array_unique( $files, SORT_REGULAR );
971 $styleFiles = [];
972 foreach ( $uniqueFiles as $file ) {
973 $styleFiles[] = $this->readStyleFile( $file, $context );
974 }
975 $styles[$media] = implode( "\n", $styleFiles );
976 }
977 return $styles;
978 }
979
990 protected function readStyleFile( $path, Context $context ) {
991 $localPath = $this->getLocalPath( $path );
992 $style = $this->getFileContents( $localPath, 'style' );
993 $styleLang = $this->getStyleSheetLang( $localPath );
994
995 return $this->processStyle( $style, $styleLang, $path, $context );
996 }
997
1014 protected function processStyle( $style, $styleLang, $path, Context $context ) {
1015 $localPath = $this->getLocalPath( $path );
1016 $remotePath = $this->getRemotePath( $path );
1017
1018 if ( $styleLang === 'less' ) {
1019 $style = $this->compileLessString( $style, $localPath, $context );
1020 $this->hasGeneratedStyles = true;
1021 }
1022
1023 if ( $this->getFlip( $context ) ) {
1024 $style = CSSJanus::transform(
1025 $style,
1026 /* $swapLtrRtlInURL = */ true,
1027 /* $swapLeftRightInURL = */ false
1028 );
1029 }
1030
1031 $localDir = dirname( $localPath );
1032 $remoteDir = dirname( $remotePath );
1033 // Get and register local file references
1034 $localFileRefs = CSSMin::getLocalFileReferences( $style, $localDir );
1035 foreach ( $localFileRefs as $file ) {
1036 if ( is_file( $file ) ) {
1037 $this->localFileRefs[] = $file;
1038 } else {
1039 $this->missingLocalFileRefs[] = $file;
1040 }
1041 }
1042 // Don't cache this call. remap() ensures data URIs embeds are up to date,
1043 // and urls contain correct content hashes in their query string. (T128668)
1044 return CSSMin::remap( $style, $localDir, $remoteDir, true );
1045 }
1046
1052 public function getFlip( Context $context ) {
1053 return $context->getDirection() === 'rtl' && !$this->noflip;
1054 }
1055
1062 public function getType() {
1063 $canBeStylesOnly = !(
1064 // All options except 'styles', 'skinStyles' and 'debugRaw'
1065 $this->scripts
1066 || $this->debugScripts
1067 || $this->templates
1068 || $this->languageScripts
1069 || $this->skinScripts
1070 || $this->dependencies
1071 || $this->messages
1072 || $this->skipFunction
1073 || $this->packageFiles
1074 );
1075 return $canBeStylesOnly ? self::LOAD_STYLES : self::LOAD_GENERAL;
1076 }
1077
1089 protected function compileLessString( $style, $stylePath, Context $context ) {
1090 static $cache;
1091 // @TODO: dependency injection
1092 if ( !$cache ) {
1093 $cache = ObjectCache::getLocalServerInstance( CACHE_ANYTHING );
1094 }
1095
1096 $skinName = $context->getSkin();
1097 $skinImportPaths = ExtensionRegistry::getInstance()->getAttribute( 'SkinLessImportPaths' );
1098 $importDirs = [];
1099 if ( isset( $skinImportPaths[ $skinName ] ) ) {
1100 $importDirs[] = $skinImportPaths[ $skinName ];
1101 }
1102
1103 $vars = $this->getLessVars( $context );
1104 // Construct a cache key from a hash of the LESS source, and a hash digest
1105 // of the LESS variables used for compilation.
1106 ksort( $vars );
1107 $compilerParams = [
1108 'vars' => $vars,
1109 'importDirs' => $importDirs,
1110 ];
1111 $key = $cache->makeGlobalKey(
1112 'resourceloader-less',
1113 'v1',
1114 hash( 'md4', $style ),
1115 hash( 'md4', serialize( $compilerParams ) )
1116 );
1117
1118 // If we got a cached value, we have to validate it by getting a checksum of all the
1119 // files that were loaded by the parser and ensuring it matches the cached entry's.
1120 $data = $cache->get( $key );
1121 if (
1122 !$data ||
1123 $data['hash'] !== FileContentsHasher::getFileContentsHash( $data['files'] )
1124 ) {
1125 $compiler = $context->getResourceLoader()->getLessCompiler( $vars, $importDirs );
1126
1127 $css = $compiler->parse( $style, $stylePath )->getCss();
1128 // T253055: store the implicit dependency paths in a form relative to any install
1129 // path so that multiple version of the application can share the cache for identical
1130 // less stylesheets. This also avoids churn during application updates.
1131 $files = $compiler->AllParsedFiles();
1132 $data = [
1133 'css' => $css,
1134 'files' => Module::getRelativePaths( $files ),
1135 'hash' => FileContentsHasher::getFileContentsHash( $files )
1136 ];
1137 $cache->set( $key, $data, $cache::TTL_DAY );
1138 }
1139
1140 foreach ( Module::expandRelativePaths( $data['files'] ) as $path ) {
1141 $this->localFileRefs[] = $path;
1142 }
1143
1144 return $data['css'];
1145 }
1146
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.".