MediaWiki master
FileModule.php
Go to the documentation of this file.
1<?php
10
11use CSSJanus;
13use InvalidArgumentException;
14use LogicException;
20use RuntimeException;
21use Wikimedia\Minify\CSSMin;
22
36class FileModule extends Module {
38 protected $localBasePath = '';
39
41 protected $remoteBasePath = '';
42
46 protected $scripts = [];
47
51 protected $languageScripts = [];
52
56 protected $skinScripts = [];
57
61 protected $debugScripts = [];
62
66 protected $styles = [];
67
71 protected $skinStyles = [];
72
80 protected $packageFiles = null;
81
86 private $expandedPackageFiles = [];
87
92 private $fullyExpandedPackageFiles = [];
93
97 protected $dependencies = [];
98
102 protected $skipFunction = null;
103
107 protected $messages = [];
108
110 protected $templates = [];
111
113 protected $group = null;
114
116 protected $debugRaw = true;
117
119 protected $noflip = false;
120
122 protected $skipStructureTest = false;
123
128 protected $hasGeneratedStyles = false;
129
133 protected $localFileRefs = [];
134
139 protected $missingLocalFileRefs = [];
140
150 public function __construct(
151 array $options = [],
152 ?string $localBasePath = null,
153 ?string $remoteBasePath = null
154 ) {
155 // Flag to decide whether to automagically add the mediawiki.template module
156 $hasTemplates = false;
157 // localBasePath and remoteBasePath both have unbelievably long fallback chains
158 // and need to be handled separately.
161
162 // Extract, validate and normalise remaining options
163 foreach ( $options as $member => $option ) {
164 switch ( $member ) {
165 // Lists of file paths
166 case 'scripts':
167 case 'debugScripts':
168 case 'styles':
169 case 'packageFiles':
170 $this->{$member} = is_array( $option ) ? $option : [ $option ];
171 break;
172 case 'templates':
173 $hasTemplates = true;
174 $this->{$member} = is_array( $option ) ? $option : [ $option ];
175 break;
176 // Collated lists of file paths
177 case 'languageScripts':
178 case 'skinScripts':
179 case 'skinStyles':
180 if ( !is_array( $option ) ) {
181 throw new InvalidArgumentException(
182 "Invalid collated file path list error. " .
183 "'$option' given, array expected."
184 );
185 }
186 foreach ( $option as $key => $value ) {
187 if ( !is_string( $key ) ) {
188 throw new InvalidArgumentException(
189 "Invalid collated file path list key error. " .
190 "'$key' given, string expected."
191 );
192 }
193 $this->{$member}[$key] = is_array( $value ) ? $value : [ $value ];
194 }
195 break;
196 case 'deprecated':
197 $this->deprecated = $option;
198 break;
199 // Lists of strings
200 case 'dependencies':
201 case 'messages':
202 // Normalise
203 $option = array_values( array_unique( (array)$option ) );
204 sort( $option );
205
206 $this->{$member} = $option;
207 break;
208 // Single strings
209 case 'group':
210 case 'skipFunction':
211 $this->{$member} = (string)$option;
212 break;
213 // Single booleans
214 case 'debugRaw':
215 case 'noflip':
216 case 'skipStructureTest':
217 $this->{$member} = (bool)$option;
218 break;
219 }
220 }
221 if ( isset( $options['scripts'] ) && isset( $options['packageFiles'] ) ) {
222 throw new InvalidArgumentException( "A module may not set both 'scripts' and 'packageFiles'" );
223 }
224 if ( isset( $options['packageFiles'] ) && isset( $options['skinScripts'] ) ) {
225 throw new InvalidArgumentException( "Options 'skinScripts' and 'packageFiles' cannot be used together." );
226 }
227 if ( $hasTemplates ) {
228 $this->dependencies[] = 'mediawiki.template';
229 // Ensure relevant template compiler module gets loaded
230 foreach ( $this->templates as $alias => $templatePath ) {
231 if ( is_int( $alias ) ) {
232 $alias = $this->getPath( $templatePath );
233 }
234 $suffix = explode( '.', $alias );
235 $suffix = end( $suffix );
236 $compilerModule = 'mediawiki.template.' . $suffix;
237 if ( $suffix !== 'html' && !in_array( $compilerModule, $this->dependencies ) ) {
238 $this->dependencies[] = $compilerModule;
239 }
240 }
241 }
242 }
243
255 public static function extractBasePaths(
256 array $options = [],
257 $localBasePath = null,
258 $remoteBasePath = null
259 ) {
260 // The different ways these checks are done, and their ordering, look very silly,
261 // but were preserved for backwards-compatibility just in case. Tread lightly.
262
265
266 if ( isset( $options['remoteExtPath'] ) ) {
267 $extensionAssetsPath = MediaWikiServices::getInstance()->getMainConfig()
269 $remoteBasePath = $extensionAssetsPath . '/' . $options['remoteExtPath'];
270 }
271
272 if ( isset( $options['remoteSkinPath'] ) ) {
273 $stylePath = MediaWikiServices::getInstance()->getMainConfig()
275 $remoteBasePath = $stylePath . '/' . $options['remoteSkinPath'];
276 }
277
278 if ( array_key_exists( 'localBasePath', $options ) ) {
279 $localBasePath = (string)$options['localBasePath'];
280 }
281
282 if ( array_key_exists( 'remoteBasePath', $options ) ) {
283 $remoteBasePath = (string)$options['remoteBasePath'];
284 }
285
286 if ( $localBasePath === null ) {
287 $localBasePath = MW_INSTALL_PATH;
288 }
289
290 if ( $remoteBasePath === '' ) {
291 // If MediaWiki is installed at the document root (not recommended),
292 // then wgScriptPath is set to the empty string by the installer to
293 // ensure safe concatenating of file paths (avoid "/" + "/foo" being "//foo").
294 // However, this also means the path itself can be an invalid URI path,
295 // as those must start with a slash. Within ResourceLoader, we will not
296 // do such primitive/unsafe slash concatenation and use URI resolution
297 // instead, so beyond this point, to avoid fatal errors in CSSMin::resolveUrl(),
298 // do a best-effort support for docroot installs by casting this to a slash.
299 $remoteBasePath = '/';
300 }
301
303 }
304
306 public function getScript( Context $context ) {
307 $packageFiles = $this->getPackageFiles( $context );
308 if ( $packageFiles !== null ) {
309 // T402278: use array_map() to avoid &references here
310 $packageFiles['files'] = array_map(
311 static function ( array $file ): array {
312 if ( $file['type'] === 'script+style' ) {
313 $file['content'] = $file['content']['script'];
314 $file['type'] = 'script';
315 }
316 return $file;
317 },
318 $packageFiles['files']
319 );
320 return $packageFiles;
321 }
322
323 $files = $this->getScriptFiles( $context );
324 // T402278: use array_map() to avoid &references here
325 $files = array_map(
326 fn ( $file ) => $this->readFileInfo( $context, $file ),
327 $files
328 );
329 return [ 'plainScripts' => $files ];
330 }
331
335 public function supportsURLLoading() {
336 // phpcs:ignore Generic.WhiteSpace.LanguageConstructSpacing.IncorrectSingle
337 return
338 // Denied by options?
339 $this->debugRaw
340 // If package files are involved, don't support URL loading, because that breaks
341 // scoped require() functions
342 && !$this->packageFiles
343 // Can't link to scripts generated by callbacks
344 && !$this->hasGeneratedScripts();
345 }
346
348 public function shouldSkipStructureTest() {
349 return $this->skipStructureTest || parent::shouldSkipStructureTest();
350 }
351
357 private function hasGeneratedScripts() {
358 foreach (
359 [ $this->scripts, $this->languageScripts, $this->skinScripts, $this->debugScripts ]
360 as $scripts
361 ) {
362 foreach ( $scripts as $script ) {
363 if ( is_array( $script ) ) {
364 if ( isset( $script['callback'] ) || isset( $script['versionCallback'] ) ) {
365 return true;
366 }
367 }
368 }
369 }
370 return false;
371 }
372
379 public function getStyles( Context $context ) {
380 $styles = $this->readStyleFiles(
381 $this->getStyleFiles( $context ),
382 $context
383 );
384
385 $packageFiles = $this->getPackageFiles( $context );
386 if ( $packageFiles !== null ) {
387 foreach ( $packageFiles['files'] as $fileName => $file ) {
388 if ( $file['type'] === 'script+style' ) {
389 $style = $this->processStyle(
390 $file['content']['style'],
391 $file['content']['styleLang'],
392 $fileName,
393 $context
394 );
395 $styles['all'] = ( $styles['all'] ?? '' ) . "\n" . $style;
396 }
397 }
398 }
399
400 // Track indirect file dependencies so that StartUpModule can check for
401 // on-disk file changes to any of this files without having to recompute the file list
402 $this->saveFileDependencies( $context, $this->localFileRefs );
403
404 return $styles;
405 }
406
411 public function getStyleURLsForDebug( Context $context ) {
412 if ( $this->hasGeneratedStyles ) {
413 // Do the default behaviour of returning a url back to load.php
414 // but with only=styles.
415 return parent::getStyleURLsForDebug( $context );
416 }
417 // Our module consists entirely of real css files,
418 // in debug mode we can load those directly.
419 $urls = [];
420 foreach ( $this->getStyleFiles( $context ) as $mediaType => $list ) {
421 $urls[$mediaType] = [];
422 foreach ( $list as $file ) {
423 $urls[$mediaType][] = OutputPage::transformResourcePath(
424 $this->getConfig(),
425 $this->getRemotePath( $file )
426 );
427 }
428 }
429 return $urls;
430 }
431
437 public function getMessages() {
438 return $this->messages;
439 }
440
446 public function getGroup() {
447 return $this->group;
448 }
449
456 public function getDependencies( ?Context $context = null ) {
457 return $this->dependencies;
458 }
459
467 private function getFileContents( $localPath, $type ) {
468 if ( !is_file( $localPath ) ) {
469 throw new RuntimeException( "$type file not found or not a file: \"$localPath\"" );
470 }
471 return $this->stripBom( file_get_contents( $localPath ) );
472 }
473
477 public function getSkipFunction() {
478 if ( !$this->skipFunction ) {
479 return null;
480 }
481 $localPath = $this->getLocalPath( $this->skipFunction );
482 return $this->getFileContents( $localPath, 'skip function' );
483 }
484
486 public function requiresES6() {
487 return true;
488 }
489
498 public function enableModuleContentVersion() {
499 return false;
500 }
501
508 private function getFileHashes( Context $context ) {
509 $files = [];
510
511 foreach ( $this->getStyleFiles( $context ) as $filePaths ) {
512 foreach ( $filePaths as $filePath ) {
513 $files[] = $this->getLocalPath( $filePath );
514 }
515 }
516
517 // Extract file paths for package files
518 // Optimisation: Use foreach() and isset() instead of array_map/array_filter.
519 // This is a hot code path, called by StartupModule for thousands of modules.
520 $expandedPackageFiles = $this->expandPackageFiles( $context );
521 if ( $expandedPackageFiles ) {
522 foreach ( $expandedPackageFiles['files'] as $fileInfo ) {
523 $filePath = $fileInfo['filePath'] ?? $fileInfo['versionFilePath'] ?? null;
524 if ( $filePath instanceof FilePath ) {
525 $files[] = $filePath->getLocalPath();
526 }
527 }
528 }
529
530 // Add other configured paths
531 $scriptFileInfos = $this->getScriptFiles( $context );
532 foreach ( $scriptFileInfos as $fileInfo ) {
533 $filePath = $fileInfo['filePath'] ?? $fileInfo['versionFilePath'] ?? null;
534 if ( $filePath instanceof FilePath ) {
535 $files[] = $filePath->getLocalPath();
536 }
537 }
538
539 foreach ( $this->templates as $filePath ) {
540 $files[] = $this->getLocalPath( $filePath );
541 }
542
543 if ( $this->skipFunction ) {
544 $files[] = $this->getLocalPath( $this->skipFunction );
545 }
546
547 // Add any lazily discovered file dependencies from previous module builds.
548 // These are saved as relatative paths.
549 foreach ( Module::expandRelativePaths( $this->getFileDependencies( $context ) ) as $file ) {
550 $files[] = $file;
551 }
552
553 // Filter out any duplicates. Typically introduced by getFileDependencies() which
554 // may lazily re-discover a primary file.
555 $files = array_unique( $files );
556
557 // Don't return array keys or any other form of file path here, only the hashes.
558 // Including file paths would needlessly cause global cache invalidation when files
559 // move on disk or if e.g. the MediaWiki directory name changes.
560 // Anything where order is significant is already detected by the definition summary.
562 }
563
570 public function getDefinitionSummary( Context $context ) {
571 $summary = parent::getDefinitionSummary( $context );
572
573 $options = [];
574 foreach ( [
575 // The following properties are omitted because they don't affect the module response:
576 // - localBasePath (Per T104950; Changes when absolute directory name changes. If
577 // this affects 'scripts' and other file paths, getFileHashes accounts for that.)
578 // - remoteBasePath (Per T104950)
579 // - dependencies (provided via startup module)
580 // - group (provided via startup module)
581 'styles',
582 'skinStyles',
583 'messages',
584 'templates',
585 'skipFunction',
586 'debugRaw',
587 ] as $member ) {
588 $options[$member] = $this->{$member};
589 }
590
591 $packageFiles = $this->expandPackageFiles( $context );
592 $packageSummaries = [];
593 if ( $packageFiles ) {
594 // Extract the minimum needed:
595 // - The 'main' pointer (included as-is).
596 // - The 'files' array, simplified to only which files exist (the keys of
597 // this array), and something that represents their non-file content.
598 // For packaged files that reflect files directly from disk, the
599 // 'getFileHashes' method tracks their content already.
600 // It is important that the keys of the $packageFiles['files'] array
601 // are preserved, as they do affect the module output.
602 foreach ( $packageFiles['files'] as $fileName => $fileInfo ) {
603 $packageSummaries[$fileName] =
604 $fileInfo['definitionSummary'] ?? $fileInfo['content'] ?? null;
605 }
606 }
607
608 $scriptFiles = $this->getScriptFiles( $context );
609 $scriptSummaries = [];
610 foreach ( $scriptFiles as $fileName => $fileInfo ) {
611 $scriptSummaries[$fileName] =
612 $fileInfo['definitionSummary'] ?? $fileInfo['content'] ?? null;
613 }
614
615 $summary[] = [
616 'options' => $options,
617 'packageFiles' => $packageSummaries,
618 'scripts' => $scriptSummaries,
619 'fileHashes' => $this->getFileHashes( $context ),
620 'messageBlob' => $this->getMessageBlob( $context ),
621 ];
622
623 $lessVars = $this->getLessVars( $context );
624 if ( $lessVars ) {
625 $summary[] = [ 'lessVars' => $lessVars ];
626 }
627
628 return $summary;
629 }
630
635 protected function getPath( $path ) {
636 if ( $path instanceof FilePath ) {
637 return $path->getPath();
638 }
639
640 return $path;
641 }
642
647 protected function getLocalPath( $path ) {
648 if ( $path instanceof FilePath ) {
649 if ( $path->getLocalBasePath() !== null ) {
650 return $path->getLocalPath();
651 }
652 $path = $path->getPath();
653 }
654
655 return "{$this->localBasePath}/$path";
656 }
657
662 protected function getRemotePath( $path ) {
663 if ( $path instanceof FilePath ) {
664 if ( $path->getRemoteBasePath() !== null ) {
665 return $path->getRemotePath();
666 }
667 $path = $path->getPath();
668 }
669
670 if ( $this->remoteBasePath === '/' ) {
671 return "/$path";
672 } else {
673 return "{$this->remoteBasePath}/$path";
674 }
675 }
676
684 public function getStyleSheetLang( $path ) {
685 return preg_match( '/\.less$/i', $path ) ? 'less' : 'css';
686 }
687
694 public static function getPackageFileType( $path ) {
695 if ( preg_match( '/\.json$/i', $path ) ) {
696 return 'data';
697 }
698 if ( preg_match( '/\.vue$/i', $path ) ) {
699 return 'script-vue';
700 }
701 return 'script';
702 }
703
711 private static function collateStyleFilesByMedia( array $list ) {
712 $collatedFiles = [];
713 foreach ( $list as $key => $value ) {
714 if ( is_int( $key ) ) {
715 // File name as the value
716 $collatedFiles['all'][] = $value;
717 } elseif ( is_array( $value ) ) {
718 // File name as the key, options array as the value
719 $optionValue = $value['media'] ?? 'all';
720 $collatedFiles[$optionValue][] = $key;
721 }
722 }
723 return $collatedFiles;
724 }
725
735 protected static function tryForKey( array $list, $key, $fallback = null ) {
736 if ( isset( $list[$key] ) && is_array( $list[$key] ) ) {
737 return $list[$key];
738 } elseif ( is_string( $fallback )
739 && isset( $list[$fallback] )
740 && is_array( $list[$fallback] )
741 ) {
742 return $list[$fallback];
743 }
744 return [];
745 }
746
753 private function getScriptFiles( Context $context ): array {
754 // List in execution order: scripts, languageScripts, skinScripts, debugScripts.
755 // Documented at MediaWiki\MainConfigSchema::ResourceModules.
756 $filesByCategory = [
757 'scripts' => $this->scripts,
758 'languageScripts' => $this->getLanguageScripts( $context->getLanguage() ),
759 'skinScripts' => self::tryForKey( $this->skinScripts, $context->getSkin(), 'default' ),
760 ];
761 if ( $context->getDebug() ) {
762 $filesByCategory['debugScripts'] = $this->debugScripts;
763 }
764
765 $expandedFiles = [];
766 foreach ( $filesByCategory as $category => $files ) {
767 foreach ( $files as $key => $fileInfo ) {
768 $expandedFileInfo = $this->expandFileInfo( $context, $fileInfo, "$category\[$key]" );
769 $expandedFiles[$expandedFileInfo['name']] = $expandedFileInfo;
770 }
771 }
772
773 return $expandedFiles;
774 }
775
783 private function getLanguageScripts( string $lang ): array {
784 $scripts = self::tryForKey( $this->languageScripts, $lang );
785 if ( $scripts ) {
786 return $scripts;
787 }
788
789 // Optimization: Avoid initialising and calling into language services
790 // for the majority of modules that don't use this option.
791 if ( $this->languageScripts ) {
792 $fallbacks = MediaWikiServices::getInstance()
793 ->getLanguageFallback()
794 ->getAll( $lang, LanguageFallbackMode::MESSAGES );
795 foreach ( $fallbacks as $lang ) {
796 $scripts = self::tryForKey( $this->languageScripts, $lang );
797 if ( $scripts ) {
798 return $scripts;
799 }
800 }
801 }
802
803 return [];
804 }
805
806 public function setSkinStylesOverride( array $moduleSkinStyles ): void {
807 $moduleName = $this->getName();
808 foreach ( $moduleSkinStyles as $skinName => $overrides ) {
809 // If a module provides overrides for a skin, and that skin also provides overrides
810 // for the same module, then the module has precedence.
811 if ( isset( $this->skinStyles[$skinName] ) ) {
812 continue;
813 }
814
815 // If $moduleName in ResourceModuleSkinStyles is preceded with a '+', the defined style
816 // files will be added to 'default' skinStyles, otherwise 'default' will be ignored.
817 if ( isset( $overrides[$moduleName] ) ) {
818 $paths = (array)$overrides[$moduleName];
819 $styleFiles = [];
820 } elseif ( isset( $overrides['+' . $moduleName] ) ) {
821 $paths = (array)$overrides['+' . $moduleName];
822 $styleFiles = isset( $this->skinStyles['default'] ) ?
823 (array)$this->skinStyles['default'] :
824 [];
825 } else {
826 continue;
827 }
828
829 // Add new file paths, remapping them to refer to our directories and not use settings
830 // from the module we're modifying, which come from the base definition.
831 [ $localBasePath, $remoteBasePath ] = self::extractBasePaths( $overrides );
832
833 foreach ( $paths as $path ) {
834 $styleFiles[] = new FilePath( $path, $localBasePath, $remoteBasePath );
835 }
836
837 $this->skinStyles[$skinName] = $styleFiles;
838 }
839 }
840
848 public function getStyleFiles( Context $context ) {
849 return array_merge_recursive(
850 self::collateStyleFilesByMedia( $this->styles ),
851 self::collateStyleFilesByMedia(
852 self::tryForKey( $this->skinStyles, $context->getSkin(), 'default' )
853 )
854 );
855 }
856
864 protected function getSkinStyleFiles( $skinName ) {
865 return self::collateStyleFilesByMedia(
866 self::tryForKey( $this->skinStyles, $skinName )
867 );
868 }
869
876 protected function getAllSkinStyleFiles() {
877 $skinFactory = MediaWikiServices::getInstance()->getSkinFactory();
878 $styleFiles = [];
879
880 $internalSkinNames = array_keys( $skinFactory->getInstalledSkins() );
881 $internalSkinNames[] = 'default';
882
883 foreach ( $internalSkinNames as $internalSkinName ) {
884 $styleFiles = array_merge_recursive(
885 $styleFiles,
886 $this->getSkinStyleFiles( $internalSkinName )
887 );
888 }
889
890 return $styleFiles;
891 }
892
898 public function getAllStyleFiles() {
899 $collatedStyleFiles = array_merge_recursive(
900 self::collateStyleFilesByMedia( $this->styles ),
901 $this->getAllSkinStyleFiles()
902 );
903
904 $result = [];
905
906 foreach ( $collatedStyleFiles as $styleFiles ) {
907 foreach ( $styleFiles as $styleFile ) {
908 $result[] = $this->getLocalPath( $styleFile );
909 }
910 }
911
912 return $result;
913 }
914
923 public function readStyleFiles( array $styles, Context $context ) {
924 if ( !$styles ) {
925 return [];
926 }
927 foreach ( $styles as $media => $files ) {
928 $uniqueFiles = array_unique( $files, SORT_REGULAR );
929 $styleFiles = [];
930 foreach ( $uniqueFiles as $file ) {
931 $styleFiles[] = $this->readStyleFile( $file, $context );
932 }
933 $styles[$media] = implode( "\n", $styleFiles );
934 }
935 return $styles;
936 }
937
948 protected function readStyleFile( $path, Context $context ) {
949 $localPath = $this->getLocalPath( $path );
950 $style = $this->getFileContents( $localPath, 'style' );
951 $styleLang = $this->getStyleSheetLang( $localPath );
952
953 return $this->processStyle( $style, $styleLang, $path, $context );
954 }
955
972 protected function processStyle( $style, $styleLang, $path, Context $context ) {
973 $localPath = $this->getLocalPath( $path );
974 $remotePath = $this->getRemotePath( $path );
975
976 if ( $styleLang === 'less' ) {
977 $style = $this->compileLessString( $style, $localPath, $context );
978 $this->hasGeneratedStyles = true;
979 }
980
981 if ( $this->getFlip( $context ) ) {
982 $style = CSSJanus::transform(
983 $style,
984 /* $swapLtrRtlInURL = */ true,
985 /* $swapLeftRightInURL = */ false
986 );
987 $this->hasGeneratedStyles = true;
988 }
989
990 $localDir = dirname( $localPath );
991 $remoteDir = dirname( $remotePath );
992 // Get and register local file references
993 $localFileRefs = CSSMin::getLocalFileReferences( $style, $localDir );
994 foreach ( $localFileRefs as $file ) {
995 if ( is_file( $file ) ) {
996 $this->localFileRefs[] = $file;
997 } else {
998 $this->missingLocalFileRefs[] = $file;
999 }
1000 }
1001 // Don't cache this call. remap() ensures data URIs embeds are up to date,
1002 // and urls contain correct content hashes in their query string. (T128668)
1003 return CSSMin::remap( $style, $localDir, $remoteDir, true );
1004 }
1005
1011 public function getFlip( Context $context ) {
1012 return $context->getDirection() === 'rtl' && !$this->noflip;
1013 }
1014
1021 public function getType() {
1022 $canBeStylesOnly = !(
1023 // All options except 'styles', 'skinStyles' and 'debugRaw'
1024 $this->scripts
1025 || $this->debugScripts
1026 || $this->templates
1027 || $this->languageScripts
1028 || $this->skinScripts
1029 || $this->dependencies
1030 || $this->messages
1031 || $this->skipFunction
1032 || $this->packageFiles
1033 );
1034 return $canBeStylesOnly ? self::LOAD_STYLES : self::LOAD_GENERAL;
1035 }
1036
1048 protected function compileLessString( $style, $stylePath, Context $context ) {
1049 static $cache;
1050 // @TODO: dependency injection
1051 if ( !$cache ) {
1052 $cache = MediaWikiServices::getInstance()->getObjectCacheFactory()
1053 ->getLocalServerInstance( CACHE_HASH );
1054 }
1055
1056 $skinName = $context->getSkin();
1057 $skinImportPaths = ExtensionRegistry::getInstance()->getAttribute( 'SkinLessImportPaths' );
1058 $importDirs = [];
1059 if ( isset( $skinImportPaths[ $skinName ] ) ) {
1060 $importDirs[] = $skinImportPaths[ $skinName ];
1061 }
1062
1063 $vars = $this->getLessVars( $context );
1064 // Construct a cache key from a hash of the LESS source, and a hash digest
1065 // of the LESS variables and import dirs used for compilation.
1066 ksort( $vars );
1067 $compilerParams = [
1068 'vars' => $vars,
1069 'importDirs' => $importDirs,
1070 // CodexDevelopmentDir affects import path mapping in ResourceLoader::getLessCompiler(),
1071 // so take that into account too
1072 'codexDevDir' => $this->getConfig()->get( MainConfigNames::CodexDevelopmentDir )
1073 ];
1074 $key = $cache->makeGlobalKey(
1075 'resourceloader-less',
1076 'v1',
1077 hash( 'md4', $style ),
1078 hash( 'md4', serialize( $compilerParams ) )
1079 );
1080
1081 // If we got a cached value, we have to validate it by getting a checksum of all the
1082 // files that were loaded by the parser and ensuring it matches the cached entry's.
1083 $data = $cache->get( $key );
1084 if (
1085 !$data ||
1086 $data['hash'] !== FileContentsHasher::getFileContentsHash( $data['files'] )
1087 ) {
1088 $compiler = $context->getResourceLoader()->getLessCompiler( $vars, $importDirs );
1089
1090 $css = $compiler->parse( $style, $stylePath )->getCss();
1091 // T253055: store the implicit dependency paths in a form relative to any install
1092 // path so that multiple version of the application can share the cache for identical
1093 // less stylesheets. This also avoids churn during application updates.
1094 $files = $compiler->getParsedFiles();
1095 $data = [
1096 'css' => $css,
1097 'files' => Module::getRelativePaths( $files ),
1098 'hash' => FileContentsHasher::getFileContentsHash( $files )
1099 ];
1100 $cache->set( $key, $data, $cache::TTL_DAY );
1101 }
1102
1103 foreach ( Module::expandRelativePaths( $data['files'] ) as $path ) {
1104 $this->localFileRefs[] = $path;
1105 }
1106
1107 return $data['css'];
1108 }
1109
1115 public function getTemplates() {
1116 $templates = [];
1117
1118 foreach ( $this->templates as $alias => $templatePath ) {
1119 // Alias is optional
1120 if ( is_int( $alias ) ) {
1121 $alias = $this->getPath( $templatePath );
1122 }
1123 $localPath = $this->getLocalPath( $templatePath );
1124 $content = $this->getFileContents( $localPath, 'template' );
1125
1126 $templates[$alias] = $this->stripBom( $content );
1127 }
1128 return $templates;
1129 }
1130
1150 private function expandPackageFiles( Context $context ) {
1151 $hash = $context->getHash();
1152 if ( isset( $this->expandedPackageFiles[$hash] ) ) {
1153 return $this->expandedPackageFiles[$hash];
1154 }
1155 if ( $this->packageFiles === null ) {
1156 return null;
1157 }
1158 $expandedFiles = [];
1159 $mainFile = null;
1160
1161 foreach ( $this->packageFiles as $key => $fileInfo ) {
1162 $expanded = $this->expandFileInfo( $context, $fileInfo, "packageFiles[$key]" );
1163 $fileName = $expanded['name'];
1164 if ( !empty( $expanded['main'] ) ) {
1165 unset( $expanded['main'] );
1166 $type = $expanded['type'];
1167 $mainFile = $fileName;
1168 if ( $type !== 'script' && $type !== 'script-vue' ) {
1169 $msg = "Main file in package must be of type 'script', module " .
1170 "'{$this->getName()}', main file '{$mainFile}' is '{$type}'.";
1171 $this->getLogger()->error( $msg );
1172 throw new LogicException( $msg );
1173 }
1174 }
1175 $expandedFiles[$fileName] = $expanded;
1176 }
1177
1178 if ( $expandedFiles && $mainFile === null ) {
1179 // The first package file that is a script is the main file
1180 foreach ( $expandedFiles as $path => $file ) {
1181 if ( $file['type'] === 'script' || $file['type'] === 'script-vue' ) {
1182 $mainFile = $path;
1183 break;
1184 }
1185 }
1186 }
1187
1188 $result = [
1189 'main' => $mainFile,
1190 'files' => $expandedFiles
1191 ];
1192
1193 $this->expandedPackageFiles[$hash] = $result;
1194 return $result;
1195 }
1196
1226 private function expandFileInfo( Context $context, $fileInfo, $debugKey ) {
1227 if ( is_string( $fileInfo ) ) {
1228 // Inline common case
1229 return [
1230 'name' => $fileInfo,
1231 'type' => self::getPackageFileType( $fileInfo ),
1232 'filePath' => new FilePath( $fileInfo, $this->localBasePath, $this->remoteBasePath )
1233 ];
1234 } elseif ( $fileInfo instanceof FilePath ) {
1235 $fileInfo = [
1236 'name' => $fileInfo->getPath(),
1237 'file' => $fileInfo
1238 ];
1239 } elseif ( !is_array( $fileInfo ) ) {
1240 $msg = "Invalid type in $debugKey for module '{$this->getName()}', " .
1241 "must be array, string or FilePath";
1242 $this->getLogger()->error( $msg );
1243 throw new LogicException( $msg );
1244 }
1245 if ( !isset( $fileInfo['name'] ) ) {
1246 $msg = "Missing 'name' key in $debugKey for module '{$this->getName()}'";
1247 $this->getLogger()->error( $msg );
1248 throw new LogicException( $msg );
1249 }
1250 $fileName = $this->getPath( $fileInfo['name'] );
1251
1252 // Infer type from alias if needed
1253 $type = $fileInfo['type'] ?? self::getPackageFileType( $fileName );
1254 $expanded = [
1255 'name' => $fileName,
1256 'type' => $type
1257 ];
1258 if ( !empty( $fileInfo['main'] ) ) {
1259 $expanded['main'] = true;
1260 }
1261
1262 // Perform expansions (except 'file' and 'callback'), creating one of these keys:
1263 // - 'content': literal value.
1264 // - 'filePath': content to be read from a file.
1265 // - 'callback': content computed by a callable.
1266 if ( isset( $fileInfo['content'] ) ) {
1267 $expanded['content'] = $fileInfo['content'];
1268 } elseif ( isset( $fileInfo['file'] ) ) {
1269 $expanded['filePath'] = $this->makeFilePath( $fileInfo['file'] );
1270 } elseif ( isset( $fileInfo['callback'] ) ) {
1271 // If no extra parameter for the callback is given, use null.
1272 $expanded['callbackParam'] = $fileInfo['callbackParam'] ?? null;
1273
1274 if ( !is_callable( $fileInfo['callback'] ) ) {
1275 $msg = "Invalid 'callback' for module '{$this->getName()}', file '{$fileName}'.";
1276 $this->getLogger()->error( $msg );
1277 throw new LogicException( $msg );
1278 }
1279 if ( isset( $fileInfo['versionCallback'] ) ) {
1280 if ( !is_callable( $fileInfo['versionCallback'] ) ) {
1281 throw new LogicException( "Invalid 'versionCallback' for "
1282 . "module '{$this->getName()}', file '{$fileName}'."
1283 );
1284 }
1285
1286 // Execute the versionCallback with the same arguments that
1287 // would be given to the callback
1288 $callbackResult = ( $fileInfo['versionCallback'] )(
1289 $context,
1290 $this->getConfig(),
1291 $expanded['callbackParam']
1292 );
1293 if ( $callbackResult instanceof FilePath ) {
1294 $callbackResult->initBasePaths( $this->localBasePath, $this->remoteBasePath );
1295 $expanded['versionFilePath'] = $callbackResult;
1296 } else {
1297 $expanded['definitionSummary'] = $callbackResult;
1298 }
1299 // Don't invoke 'callback' here as it may be expensive (T223260).
1300 $expanded['callback'] = $fileInfo['callback'];
1301 } else {
1302 // Else go ahead invoke callback with its arguments.
1303 $callbackResult = ( $fileInfo['callback'] )(
1304 $context,
1305 $this->getConfig(),
1306 $expanded['callbackParam']
1307 );
1308 if ( $callbackResult instanceof FilePath ) {
1309 $callbackResult->initBasePaths( $this->localBasePath, $this->remoteBasePath );
1310 $expanded['filePath'] = $callbackResult;
1311 } else {
1312 $expanded['content'] = $callbackResult;
1313 }
1314 }
1315 } elseif ( isset( $fileInfo['config'] ) ) {
1316 if ( $type !== 'data' ) {
1317 $msg = "Key 'config' only valid for data files. "
1318 . " Module '{$this->getName()}', file '{$fileName}' is '{$type}'.";
1319 $this->getLogger()->error( $msg );
1320 throw new LogicException( $msg );
1321 }
1322 $expandedConfig = [];
1323 foreach ( $fileInfo['config'] as $configKey => $var ) {
1324 $expandedConfig[ is_numeric( $configKey ) ? $var : $configKey ] = $this->getConfig()->get( $var );
1325 }
1326 $expanded['content'] = $expandedConfig;
1327 } elseif ( !empty( $fileInfo['main'] ) ) {
1328 // [ 'name' => 'foo.js', 'main' => true ] is shorthand
1329 $expanded['filePath'] = $this->makeFilePath( $fileName );
1330 } else {
1331 $msg = "Incomplete definition for module '{$this->getName()}', file '{$fileName}'. "
1332 . "One of 'file', 'content', 'callback', or 'config' must be set.";
1333 $this->getLogger()->error( $msg );
1334 throw new LogicException( $msg );
1335 }
1336 if ( !isset( $expanded['filePath'] ) ) {
1337 $expanded['virtualFilePath'] = $this->makeFilePath( $fileName );
1338 }
1339 return $expanded;
1340 }
1341
1348 private function makeFilePath( $path ): FilePath {
1349 if ( $path instanceof FilePath ) {
1350 return $path;
1351 } elseif ( is_string( $path ) ) {
1352 return new FilePath( $path, $this->localBasePath, $this->remoteBasePath );
1353 } else {
1354 throw new InvalidArgumentException( '$path must be either FilePath or string' );
1355 }
1356 }
1357
1364 public function getPackageFiles( Context $context ) {
1365 if ( $this->packageFiles === null ) {
1366 return null;
1367 }
1368 $hash = $context->getHash();
1369 if ( isset( $this->fullyExpandedPackageFiles[ $hash ] ) ) {
1370 return $this->fullyExpandedPackageFiles[ $hash ];
1371 }
1372 $expandedPackageFiles = $this->expandPackageFiles( $context ) ?? [];
1373
1374 // T402278: use array_map() to avoid &references here
1375 $expandedPackageFiles['files'] = array_map( function ( array $fileInfo ) use ( $context ): array {
1376 return $this->readFileInfo( $context, $fileInfo );
1377 }, $expandedPackageFiles['files'] );
1378
1379 $this->fullyExpandedPackageFiles[ $hash ] = $expandedPackageFiles;
1380 return $expandedPackageFiles;
1381 }
1382
1392 private function readFileInfo( Context $context, array $fileInfo ): array {
1393 // Turn any 'filePath' or 'callback' key into actual 'content',
1394 // and remove the key after that. The callback could return a
1395 // FilePath object; if that happens, fall through to the 'filePath'
1396 // handling.
1397 if ( !isset( $fileInfo['content'] ) && isset( $fileInfo['callback'] ) ) {
1398 $callbackResult = ( $fileInfo['callback'] )(
1399 $context,
1400 $this->getConfig(),
1401 $fileInfo['callbackParam']
1402 );
1403 if ( $callbackResult instanceof FilePath ) {
1404 // Fall through to the filePath handling code below
1405 $fileInfo['filePath'] = $callbackResult;
1406 } else {
1407 $fileInfo['content'] = $callbackResult;
1408 }
1409 unset( $fileInfo['callback'] );
1410 }
1411 // Only interpret 'filePath' if 'content' hasn't been set already.
1412 // This can happen if 'versionCallback' provided 'filePath',
1413 // while 'callback' provides 'content'. In that case both are set
1414 // at this point. The 'filePath' from 'versionCallback' in that case is
1415 // only to inform getDefinitionSummary().
1416 if ( !isset( $fileInfo['content'] ) && isset( $fileInfo['filePath'] ) ) {
1417 $localPath = $this->getLocalPath( $fileInfo['filePath'] );
1418 $content = $this->getFileContents( $localPath, 'package' );
1419 if ( $fileInfo['type'] === 'data' ) {
1420 $content = json_decode( $content, false, 512, JSON_THROW_ON_ERROR );
1421 }
1422 $fileInfo['content'] = $content;
1423 }
1424 if ( $fileInfo['type'] === 'script-vue' ) {
1425 try {
1426 // @phan-suppress-next-line PhanTypePossiblyInvalidDimOffset False positive
1427 $fileInfo[ 'content' ] = $this->parseVueContent( $context, $fileInfo[ 'content' ] );
1428 } catch ( InvalidArgumentException $e ) {
1429 $msg = "Error parsing file '{$fileInfo['name']}' in module '{$this->getName()}': " .
1430 "{$e->getMessage()}";
1431 $this->getLogger()->error( $msg );
1432 throw new RuntimeException( $msg );
1433 }
1434 $fileInfo['type'] = 'script+style';
1435 }
1436 if ( !isset( $fileInfo['content'] ) ) {
1437 // This should not be possible due to validation in expandFileInfo()
1438 $msg = "Unable to resolve contents for file {$fileInfo['name']}";
1439 $this->getLogger()->error( $msg );
1440 throw new RuntimeException( $msg );
1441 }
1442
1443 // Not needed for client response, exists for use by getDefinitionSummary().
1444 unset( $fileInfo['definitionSummary'] );
1445 // Not needed for client response, used by callbacks only.
1446 unset( $fileInfo['callbackParam'] );
1447
1448 return $fileInfo;
1449 }
1450
1461 protected function stripBom( $input ) {
1462 if ( str_starts_with( $input, "\xef\xbb\xbf" ) ) {
1463 return substr( $input, 3 );
1464 }
1465 return $input;
1466 }
1467}
const CACHE_HASH
Definition Defines.php:77
$fallback
if(!defined('MW_SETUP_CALLBACK'))
Definition WebStart.php:68
Generate hash digests of file contents to help with cache invalidation.
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:32
getHash()
All factors that uniquely identify this request, except 'modules'.
Definition Context.php:405
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.If the client does not support ES6,...
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.
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.Includes all relevant JS except loader scrip...
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.1....
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:20
Abstraction for ResourceLoader modules, with name registration and maxage functionality.
Definition Module.php:34
static expandRelativePaths(array $filePaths)
Expand directories relative to $IP.
Definition Module.php:561
saveFileDependencies(Context $context, array $curFileRefs)
Save the indirect dependencies for this module pursuant to the skin/language context.
Definition Module.php:514
getLessVars(Context $context)
Get module-specific LESS variables, if any.
Definition Module.php:689
getFileDependencies(Context $context)
Get the indirect dependencies for this module pursuant to the skin/language context.
Definition Module.php:479
getMessageBlob(Context $context)
Get the hash of the message blob.
Definition Module.php:577