MediaWiki master
FileModule.php
Go to the documentation of this file.
1<?php
24
25use CSSJanus;
26use Exception;
28use InvalidArgumentException;
29use LogicException;
30use MediaWiki\Languages\LanguageFallback;
35use RuntimeException;
36use Wikimedia\Minify\CSSMin;
37use Wikimedia\RequestTimeout\TimeoutException;
38
52class FileModule extends Module {
54 protected $localBasePath = '';
55
57 protected $remoteBasePath = '';
58
62 protected $scripts = [];
63
67 protected $languageScripts = [];
68
72 protected $skinScripts = [];
73
77 protected $debugScripts = [];
78
82 protected $styles = [];
83
87 protected $skinStyles = [];
88
96 protected $packageFiles = null;
97
102 private $expandedPackageFiles = [];
103
108 private $fullyExpandedPackageFiles = [];
109
113 protected $dependencies = [];
114
118 protected $skipFunction = null;
119
123 protected $messages = [];
124
126 protected $templates = [];
127
129 protected $group = null;
130
132 protected $debugRaw = true;
133
135 protected $noflip = false;
136
138 protected $skipStructureTest = false;
139
144 protected $hasGeneratedStyles = false;
145
149 protected $localFileRefs = [];
150
155 protected $missingLocalFileRefs = [];
156
160 protected $vueComponentParser = null;
161
171 public function __construct(
172 array $options = [],
173 ?string $localBasePath = null,
174 ?string $remoteBasePath = null
175 ) {
176 // Flag to decide whether to automagically add the mediawiki.template module
177 $hasTemplates = false;
178 // localBasePath and remoteBasePath both have unbelievably long fallback chains
179 // and need to be handled separately.
182
183 // Extract, validate and normalise remaining options
184 foreach ( $options as $member => $option ) {
185 switch ( $member ) {
186 // Lists of file paths
187 case 'scripts':
188 case 'debugScripts':
189 case 'styles':
190 case 'packageFiles':
191 $this->{$member} = is_array( $option ) ? $option : [ $option ];
192 break;
193 case 'templates':
194 $hasTemplates = true;
195 $this->{$member} = is_array( $option ) ? $option : [ $option ];
196 break;
197 // Collated lists of file paths
198 case 'languageScripts':
199 case 'skinScripts':
200 case 'skinStyles':
201 if ( !is_array( $option ) ) {
202 throw new InvalidArgumentException(
203 "Invalid collated file path list error. " .
204 "'$option' given, array expected."
205 );
206 }
207 foreach ( $option as $key => $value ) {
208 if ( !is_string( $key ) ) {
209 throw new InvalidArgumentException(
210 "Invalid collated file path list key error. " .
211 "'$key' given, string expected."
212 );
213 }
214 $this->{$member}[$key] = is_array( $value ) ? $value : [ $value ];
215 }
216 break;
217 case 'deprecated':
218 $this->deprecated = $option;
219 break;
220 // Lists of strings
221 case 'dependencies':
222 case 'messages':
223 // Normalise
224 $option = array_values( array_unique( (array)$option ) );
225 sort( $option );
226
227 $this->{$member} = $option;
228 break;
229 // Single strings
230 case 'group':
231 case 'skipFunction':
232 $this->{$member} = (string)$option;
233 break;
234 // Single booleans
235 case 'debugRaw':
236 case 'noflip':
237 case 'skipStructureTest':
238 $this->{$member} = (bool)$option;
239 break;
240 }
241 }
242 if ( isset( $options['scripts'] ) && isset( $options['packageFiles'] ) ) {
243 throw new InvalidArgumentException( "A module may not set both 'scripts' and 'packageFiles'" );
244 }
245 if ( isset( $options['packageFiles'] ) && isset( $options['skinScripts'] ) ) {
246 throw new InvalidArgumentException( "Options 'skinScripts' and 'packageFiles' cannot be used together." );
247 }
248 if ( $hasTemplates ) {
249 $this->dependencies[] = 'mediawiki.template';
250 // Ensure relevant template compiler module gets loaded
251 foreach ( $this->templates as $alias => $templatePath ) {
252 if ( is_int( $alias ) ) {
253 $alias = $this->getPath( $templatePath );
254 }
255 $suffix = explode( '.', $alias );
256 $suffix = end( $suffix );
257 $compilerModule = 'mediawiki.template.' . $suffix;
258 if ( $suffix !== 'html' && !in_array( $compilerModule, $this->dependencies ) ) {
259 $this->dependencies[] = $compilerModule;
260 }
261 }
262 }
263 }
264
276 public static function extractBasePaths(
277 array $options = [],
278 $localBasePath = null,
279 $remoteBasePath = null
280 ) {
281 // The different ways these checks are done, and their ordering, look very silly,
282 // but were preserved for backwards-compatibility just in case. Tread lightly.
283
286
287 if ( isset( $options['remoteExtPath'] ) ) {
288 $extensionAssetsPath = MediaWikiServices::getInstance()->getMainConfig()
290 $remoteBasePath = $extensionAssetsPath . '/' . $options['remoteExtPath'];
291 }
292
293 if ( isset( $options['remoteSkinPath'] ) ) {
294 $stylePath = MediaWikiServices::getInstance()->getMainConfig()
296 $remoteBasePath = $stylePath . '/' . $options['remoteSkinPath'];
297 }
298
299 if ( array_key_exists( 'localBasePath', $options ) ) {
300 $localBasePath = (string)$options['localBasePath'];
301 }
302
303 if ( array_key_exists( 'remoteBasePath', $options ) ) {
304 $remoteBasePath = (string)$options['remoteBasePath'];
305 }
306
307 if ( $localBasePath === null ) {
308 $localBasePath = MW_INSTALL_PATH;
309 }
310
311 if ( $remoteBasePath === '' ) {
312 // If MediaWiki is installed at the document root (not recommended),
313 // then wgScriptPath is set to the empty string by the installer to
314 // ensure safe concatenating of file paths (avoid "/" + "/foo" being "//foo").
315 // However, this also means the path itself can be an invalid URI path,
316 // as those must start with a slash. Within ResourceLoader, we will not
317 // do such primitive/unsafe slash concatenation and use URI resolution
318 // instead, so beyond this point, to avoid fatal errors in CSSMin::resolveUrl(),
319 // do a best-effort support for docroot installs by casting this to a slash.
320 $remoteBasePath = '/';
321 }
322
324 }
325
327 public function getScript( Context $context ) {
328 $packageFiles = $this->getPackageFiles( $context );
329 if ( $packageFiles !== null ) {
330 foreach ( $packageFiles['files'] as &$file ) {
331 if ( $file['type'] === 'script+style' ) {
332 $file['content'] = $file['content']['script'];
333 $file['type'] = 'script';
334 }
335 }
336 return $packageFiles;
337 }
338
339 $files = $this->getScriptFiles( $context );
340 foreach ( $files as &$file ) {
341 $this->readFileInfo( $context, $file );
342 }
343 return [ 'plainScripts' => $files ];
344 }
345
349 public function supportsURLLoading() {
350 // phpcs:ignore Generic.WhiteSpace.LanguageConstructSpacing.IncorrectSingle
351 return
352 // Denied by options?
353 $this->debugRaw
354 // If package files are involved, don't support URL loading, because that breaks
355 // scoped require() functions
356 && !$this->packageFiles
357 // Can't link to scripts generated by callbacks
358 && !$this->hasGeneratedScripts();
359 }
360
362 public function shouldSkipStructureTest() {
363 return $this->skipStructureTest || parent::shouldSkipStructureTest();
364 }
365
371 private function hasGeneratedScripts() {
372 foreach (
373 [ $this->scripts, $this->languageScripts, $this->skinScripts, $this->debugScripts ]
374 as $scripts
375 ) {
376 foreach ( $scripts as $script ) {
377 if ( is_array( $script ) ) {
378 if ( isset( $script['callback'] ) || isset( $script['versionCallback'] ) ) {
379 return true;
380 }
381 }
382 }
383 }
384 return false;
385 }
386
393 public function getStyles( Context $context ) {
394 $styles = $this->readStyleFiles(
395 $this->getStyleFiles( $context ),
396 $context
397 );
398
399 $packageFiles = $this->getPackageFiles( $context );
400 if ( $packageFiles !== null ) {
401 foreach ( $packageFiles['files'] as $fileName => $file ) {
402 if ( $file['type'] === 'script+style' ) {
403 $style = $this->processStyle(
404 $file['content']['style'],
405 $file['content']['styleLang'],
406 $fileName,
407 $context
408 );
409 $styles['all'] = ( $styles['all'] ?? '' ) . "\n" . $style;
410 }
411 }
412 }
413
414 // Track indirect file dependencies so that StartUpModule can check for
415 // on-disk file changes to any of this files without having to recompute the file list
416 $this->saveFileDependencies( $context, $this->localFileRefs );
417
418 return $styles;
419 }
420
425 public function getStyleURLsForDebug( Context $context ) {
426 if ( $this->hasGeneratedStyles ) {
427 // Do the default behaviour of returning a url back to load.php
428 // but with only=styles.
429 return parent::getStyleURLsForDebug( $context );
430 }
431 // Our module consists entirely of real css files,
432 // in debug mode we can load those directly.
433 $urls = [];
434 foreach ( $this->getStyleFiles( $context ) as $mediaType => $list ) {
435 $urls[$mediaType] = [];
436 foreach ( $list as $file ) {
437 $urls[$mediaType][] = OutputPage::transformResourcePath(
438 $this->getConfig(),
439 $this->getRemotePath( $file )
440 );
441 }
442 }
443 return $urls;
444 }
445
451 public function getMessages() {
452 return $this->messages;
453 }
454
460 public function getGroup() {
461 return $this->group;
462 }
463
470 public function getDependencies( ?Context $context = null ) {
471 return $this->dependencies;
472 }
473
481 private function getFileContents( $localPath, $type ) {
482 if ( !is_file( $localPath ) ) {
483 throw new RuntimeException( "$type file not found or not a file: \"$localPath\"" );
484 }
485 return $this->stripBom( file_get_contents( $localPath ) );
486 }
487
491 public function getSkipFunction() {
492 if ( !$this->skipFunction ) {
493 return null;
494 }
495 $localPath = $this->getLocalPath( $this->skipFunction );
496 return $this->getFileContents( $localPath, 'skip function' );
497 }
498
500 public function requiresES6() {
501 return true;
502 }
503
512 public function enableModuleContentVersion() {
513 return false;
514 }
515
522 private function getFileHashes( Context $context ) {
523 $files = [];
524
525 foreach ( $this->getStyleFiles( $context ) as $filePaths ) {
526 foreach ( $filePaths as $filePath ) {
527 $files[] = $this->getLocalPath( $filePath );
528 }
529 }
530
531 // Extract file paths for package files
532 // Optimisation: Use foreach() and isset() instead of array_map/array_filter.
533 // This is a hot code path, called by StartupModule for thousands of modules.
534 $expandedPackageFiles = $this->expandPackageFiles( $context );
535 if ( $expandedPackageFiles ) {
536 foreach ( $expandedPackageFiles['files'] as $fileInfo ) {
537 $filePath = $fileInfo['filePath'] ?? $fileInfo['versionFilePath'] ?? null;
538 if ( $filePath instanceof FilePath ) {
539 $files[] = $filePath->getLocalPath();
540 }
541 }
542 }
543
544 // Add other configured paths
545 $scriptFileInfos = $this->getScriptFiles( $context );
546 foreach ( $scriptFileInfos as $fileInfo ) {
547 $filePath = $fileInfo['filePath'] ?? $fileInfo['versionFilePath'] ?? null;
548 if ( $filePath instanceof FilePath ) {
549 $files[] = $filePath->getLocalPath();
550 }
551 }
552
553 foreach ( $this->templates as $filePath ) {
554 $files[] = $this->getLocalPath( $filePath );
555 }
556
557 if ( $this->skipFunction ) {
558 $files[] = $this->getLocalPath( $this->skipFunction );
559 }
560
561 // Add any lazily discovered file dependencies from previous module builds.
562 // These are saved as relatative paths.
563 foreach ( Module::expandRelativePaths( $this->getFileDependencies( $context ) ) as $file ) {
564 $files[] = $file;
565 }
566
567 // Filter out any duplicates. Typically introduced by getFileDependencies() which
568 // may lazily re-discover a primary file.
569 $files = array_unique( $files );
570
571 // Don't return array keys or any other form of file path here, only the hashes.
572 // Including file paths would needlessly cause global cache invalidation when files
573 // move on disk or if e.g. the MediaWiki directory name changes.
574 // Anything where order is significant is already detected by the definition summary.
576 }
577
584 public function getDefinitionSummary( Context $context ) {
585 $summary = parent::getDefinitionSummary( $context );
586
587 $options = [];
588 foreach ( [
589 // The following properties are omitted because they don't affect the module response:
590 // - localBasePath (Per T104950; Changes when absolute directory name changes. If
591 // this affects 'scripts' and other file paths, getFileHashes accounts for that.)
592 // - remoteBasePath (Per T104950)
593 // - dependencies (provided via startup module)
594 // - group (provided via startup module)
595 'styles',
596 'skinStyles',
597 'messages',
598 'templates',
599 'skipFunction',
600 'debugRaw',
601 ] as $member ) {
602 $options[$member] = $this->{$member};
603 }
604
605 $packageFiles = $this->expandPackageFiles( $context );
606 $packageSummaries = [];
607 if ( $packageFiles ) {
608 // Extract the minimum needed:
609 // - The 'main' pointer (included as-is).
610 // - The 'files' array, simplified to only which files exist (the keys of
611 // this array), and something that represents their non-file content.
612 // For packaged files that reflect files directly from disk, the
613 // 'getFileHashes' method tracks their content already.
614 // It is important that the keys of the $packageFiles['files'] array
615 // are preserved, as they do affect the module output.
616 foreach ( $packageFiles['files'] as $fileName => $fileInfo ) {
617 $packageSummaries[$fileName] =
618 $fileInfo['definitionSummary'] ?? $fileInfo['content'] ?? null;
619 }
620 }
621
622 $scriptFiles = $this->getScriptFiles( $context );
623 $scriptSummaries = [];
624 foreach ( $scriptFiles as $fileName => $fileInfo ) {
625 $scriptSummaries[$fileName] =
626 $fileInfo['definitionSummary'] ?? $fileInfo['content'] ?? null;
627 }
628
629 $summary[] = [
630 'options' => $options,
631 'packageFiles' => $packageSummaries,
632 'scripts' => $scriptSummaries,
633 'fileHashes' => $this->getFileHashes( $context ),
634 'messageBlob' => $this->getMessageBlob( $context ),
635 ];
636
637 $lessVars = $this->getLessVars( $context );
638 if ( $lessVars ) {
639 $summary[] = [ 'lessVars' => $lessVars ];
640 }
641
642 return $summary;
643 }
644
648 protected function getVueComponentParser() {
649 if ( $this->vueComponentParser === null ) {
650 $this->vueComponentParser = new VueComponentParser;
651 }
653 }
654
659 protected function getPath( $path ) {
660 if ( $path instanceof FilePath ) {
661 return $path->getPath();
662 }
663
664 return $path;
665 }
666
671 protected function getLocalPath( $path ) {
672 if ( $path instanceof FilePath ) {
673 if ( $path->getLocalBasePath() !== null ) {
674 return $path->getLocalPath();
675 }
676 $path = $path->getPath();
677 }
678
679 return "{$this->localBasePath}/$path";
680 }
681
686 protected function getRemotePath( $path ) {
687 if ( $path instanceof FilePath ) {
688 if ( $path->getRemoteBasePath() !== null ) {
689 return $path->getRemotePath();
690 }
691 $path = $path->getPath();
692 }
693
694 if ( $this->remoteBasePath === '/' ) {
695 return "/$path";
696 } else {
697 return "{$this->remoteBasePath}/$path";
698 }
699 }
700
708 public function getStyleSheetLang( $path ) {
709 return preg_match( '/\.less$/i', $path ) ? 'less' : 'css';
710 }
711
718 public static function getPackageFileType( $path ) {
719 if ( preg_match( '/\.json$/i', $path ) ) {
720 return 'data';
721 }
722 if ( preg_match( '/\.vue$/i', $path ) ) {
723 return 'script-vue';
724 }
725 return 'script';
726 }
727
735 private static function collateStyleFilesByMedia( array $list ) {
736 $collatedFiles = [];
737 foreach ( $list as $key => $value ) {
738 if ( is_int( $key ) ) {
739 // File name as the value
740 $collatedFiles['all'][] = $value;
741 } elseif ( is_array( $value ) ) {
742 // File name as the key, options array as the value
743 $optionValue = $value['media'] ?? 'all';
744 $collatedFiles[$optionValue][] = $key;
745 }
746 }
747 return $collatedFiles;
748 }
749
759 protected static function tryForKey( array $list, $key, $fallback = null ) {
760 if ( isset( $list[$key] ) && is_array( $list[$key] ) ) {
761 return $list[$key];
762 } elseif ( is_string( $fallback )
763 && isset( $list[$fallback] )
764 && is_array( $list[$fallback] )
765 ) {
766 return $list[$fallback];
767 }
768 return [];
769 }
770
777 private function getScriptFiles( Context $context ): array {
778 // List in execution order: scripts, languageScripts, skinScripts, debugScripts.
779 // Documented at MediaWiki\MainConfigSchema::ResourceModules.
780 $filesByCategory = [
781 'scripts' => $this->scripts,
782 'languageScripts' => $this->getLanguageScripts( $context->getLanguage() ),
783 'skinScripts' => self::tryForKey( $this->skinScripts, $context->getSkin(), 'default' ),
784 ];
785 if ( $context->getDebug() ) {
786 $filesByCategory['debugScripts'] = $this->debugScripts;
787 }
788
789 $expandedFiles = [];
790 foreach ( $filesByCategory as $category => $files ) {
791 foreach ( $files as $key => $fileInfo ) {
792 $expandedFileInfo = $this->expandFileInfo( $context, $fileInfo, "$category\[$key]" );
793 $expandedFiles[$expandedFileInfo['name']] = $expandedFileInfo;
794 }
795 }
796
797 return $expandedFiles;
798 }
799
807 private function getLanguageScripts( string $lang ): array {
808 $scripts = self::tryForKey( $this->languageScripts, $lang );
809 if ( $scripts ) {
810 return $scripts;
811 }
812
813 // Optimization: Avoid initialising and calling into language services
814 // for the majority of modules that don't use this option.
815 if ( $this->languageScripts ) {
816 $fallbacks = MediaWikiServices::getInstance()
817 ->getLanguageFallback()
818 ->getAll( $lang, LanguageFallback::MESSAGES );
819 foreach ( $fallbacks as $lang ) {
820 $scripts = self::tryForKey( $this->languageScripts, $lang );
821 if ( $scripts ) {
822 return $scripts;
823 }
824 }
825 }
826
827 return [];
828 }
829
830 public function setSkinStylesOverride( array $moduleSkinStyles ): void {
831 $moduleName = $this->getName();
832 foreach ( $moduleSkinStyles as $skinName => $overrides ) {
833 // If a module provides overrides for a skin, and that skin also provides overrides
834 // for the same module, then the module has precedence.
835 if ( isset( $this->skinStyles[$skinName] ) ) {
836 continue;
837 }
838
839 // If $moduleName in ResourceModuleSkinStyles is preceded with a '+', the defined style
840 // files will be added to 'default' skinStyles, otherwise 'default' will be ignored.
841 if ( isset( $overrides[$moduleName] ) ) {
842 $paths = (array)$overrides[$moduleName];
843 $styleFiles = [];
844 } elseif ( isset( $overrides['+' . $moduleName] ) ) {
845 $paths = (array)$overrides['+' . $moduleName];
846 $styleFiles = isset( $this->skinStyles['default'] ) ?
847 (array)$this->skinStyles['default'] :
848 [];
849 } else {
850 continue;
851 }
852
853 // Add new file paths, remapping them to refer to our directories and not use settings
854 // from the module we're modifying, which come from the base definition.
855 [ $localBasePath, $remoteBasePath ] = self::extractBasePaths( $overrides );
856
857 foreach ( $paths as $path ) {
858 $styleFiles[] = new FilePath( $path, $localBasePath, $remoteBasePath );
859 }
860
861 $this->skinStyles[$skinName] = $styleFiles;
862 }
863 }
864
872 public function getStyleFiles( Context $context ) {
873 return array_merge_recursive(
874 self::collateStyleFilesByMedia( $this->styles ),
875 self::collateStyleFilesByMedia(
876 self::tryForKey( $this->skinStyles, $context->getSkin(), 'default' )
877 )
878 );
879 }
880
888 protected function getSkinStyleFiles( $skinName ) {
889 return self::collateStyleFilesByMedia(
890 self::tryForKey( $this->skinStyles, $skinName )
891 );
892 }
893
900 protected function getAllSkinStyleFiles() {
901 $skinFactory = MediaWikiServices::getInstance()->getSkinFactory();
902 $styleFiles = [];
903
904 $internalSkinNames = array_keys( $skinFactory->getInstalledSkins() );
905 $internalSkinNames[] = 'default';
906
907 foreach ( $internalSkinNames as $internalSkinName ) {
908 $styleFiles = array_merge_recursive(
909 $styleFiles,
910 $this->getSkinStyleFiles( $internalSkinName )
911 );
912 }
913
914 return $styleFiles;
915 }
916
922 public function getAllStyleFiles() {
923 $collatedStyleFiles = array_merge_recursive(
924 self::collateStyleFilesByMedia( $this->styles ),
925 $this->getAllSkinStyleFiles()
926 );
927
928 $result = [];
929
930 foreach ( $collatedStyleFiles as $styleFiles ) {
931 foreach ( $styleFiles as $styleFile ) {
932 $result[] = $this->getLocalPath( $styleFile );
933 }
934 }
935
936 return $result;
937 }
938
947 public function readStyleFiles( array $styles, Context $context ) {
948 if ( !$styles ) {
949 return [];
950 }
951 foreach ( $styles as $media => $files ) {
952 $uniqueFiles = array_unique( $files, SORT_REGULAR );
953 $styleFiles = [];
954 foreach ( $uniqueFiles as $file ) {
955 $styleFiles[] = $this->readStyleFile( $file, $context );
956 }
957 $styles[$media] = implode( "\n", $styleFiles );
958 }
959 return $styles;
960 }
961
972 protected function readStyleFile( $path, Context $context ) {
973 $localPath = $this->getLocalPath( $path );
974 $style = $this->getFileContents( $localPath, 'style' );
975 $styleLang = $this->getStyleSheetLang( $localPath );
976
977 return $this->processStyle( $style, $styleLang, $path, $context );
978 }
979
996 protected function processStyle( $style, $styleLang, $path, Context $context ) {
997 $localPath = $this->getLocalPath( $path );
998 $remotePath = $this->getRemotePath( $path );
999
1000 if ( $styleLang === 'less' ) {
1001 $style = $this->compileLessString( $style, $localPath, $context );
1002 $this->hasGeneratedStyles = true;
1003 }
1004
1005 if ( $this->getFlip( $context ) ) {
1006 $style = CSSJanus::transform(
1007 $style,
1008 /* $swapLtrRtlInURL = */ true,
1009 /* $swapLeftRightInURL = */ false
1010 );
1011 $this->hasGeneratedStyles = true;
1012 }
1013
1014 $localDir = dirname( $localPath );
1015 $remoteDir = dirname( $remotePath );
1016 // Get and register local file references
1017 $localFileRefs = CSSMin::getLocalFileReferences( $style, $localDir );
1018 foreach ( $localFileRefs as $file ) {
1019 if ( is_file( $file ) ) {
1020 $this->localFileRefs[] = $file;
1021 } else {
1022 $this->missingLocalFileRefs[] = $file;
1023 }
1024 }
1025 // Don't cache this call. remap() ensures data URIs embeds are up to date,
1026 // and urls contain correct content hashes in their query string. (T128668)
1027 return CSSMin::remap( $style, $localDir, $remoteDir, true );
1028 }
1029
1035 public function getFlip( Context $context ) {
1036 return $context->getDirection() === 'rtl' && !$this->noflip;
1037 }
1038
1045 public function getType() {
1046 $canBeStylesOnly = !(
1047 // All options except 'styles', 'skinStyles' and 'debugRaw'
1048 $this->scripts
1049 || $this->debugScripts
1050 || $this->templates
1051 || $this->languageScripts
1052 || $this->skinScripts
1053 || $this->dependencies
1054 || $this->messages
1055 || $this->skipFunction
1056 || $this->packageFiles
1057 );
1058 return $canBeStylesOnly ? self::LOAD_STYLES : self::LOAD_GENERAL;
1059 }
1060
1072 protected function compileLessString( $style, $stylePath, Context $context ) {
1073 static $cache;
1074 // @TODO: dependency injection
1075 if ( !$cache ) {
1076 $cache = MediaWikiServices::getInstance()->getObjectCacheFactory()
1077 ->getLocalServerInstance( CACHE_HASH );
1078 }
1079
1080 $skinName = $context->getSkin();
1081 $skinImportPaths = ExtensionRegistry::getInstance()->getAttribute( 'SkinLessImportPaths' );
1082 $importDirs = [];
1083 if ( isset( $skinImportPaths[ $skinName ] ) ) {
1084 $importDirs[] = $skinImportPaths[ $skinName ];
1085 }
1086
1087 $vars = $this->getLessVars( $context );
1088 // Construct a cache key from a hash of the LESS source, and a hash digest
1089 // of the LESS variables and import dirs used for compilation.
1090 ksort( $vars );
1091 $compilerParams = [
1092 'vars' => $vars,
1093 'importDirs' => $importDirs,
1094 // CodexDevelopmentDir affects import path mapping in ResourceLoader::getLessCompiler(),
1095 // so take that into account too
1096 'codexDevDir' => $this->getConfig()->get( MainConfigNames::CodexDevelopmentDir )
1097 ];
1098 $key = $cache->makeGlobalKey(
1099 'resourceloader-less',
1100 'v1',
1101 hash( 'md4', $style ),
1102 hash( 'md4', serialize( $compilerParams ) )
1103 );
1104
1105 // If we got a cached value, we have to validate it by getting a checksum of all the
1106 // files that were loaded by the parser and ensuring it matches the cached entry's.
1107 $data = $cache->get( $key );
1108 if (
1109 !$data ||
1110 $data['hash'] !== FileContentsHasher::getFileContentsHash( $data['files'] )
1111 ) {
1112 $compiler = $context->getResourceLoader()->getLessCompiler( $vars, $importDirs );
1113
1114 $css = $compiler->parse( $style, $stylePath )->getCss();
1115 // T253055: store the implicit dependency paths in a form relative to any install
1116 // path so that multiple version of the application can share the cache for identical
1117 // less stylesheets. This also avoids churn during application updates.
1118 $files = $compiler->getParsedFiles();
1119 $data = [
1120 'css' => $css,
1121 'files' => Module::getRelativePaths( $files ),
1122 'hash' => FileContentsHasher::getFileContentsHash( $files )
1123 ];
1124 $cache->set( $key, $data, $cache::TTL_DAY );
1125 }
1126
1127 foreach ( Module::expandRelativePaths( $data['files'] ) as $path ) {
1128 $this->localFileRefs[] = $path;
1129 }
1130
1131 return $data['css'];
1132 }
1133
1139 public function getTemplates() {
1140 $templates = [];
1141
1142 foreach ( $this->templates as $alias => $templatePath ) {
1143 // Alias is optional
1144 if ( is_int( $alias ) ) {
1145 $alias = $this->getPath( $templatePath );
1146 }
1147 $localPath = $this->getLocalPath( $templatePath );
1148 $content = $this->getFileContents( $localPath, 'template' );
1149
1150 $templates[$alias] = $this->stripBom( $content );
1151 }
1152 return $templates;
1153 }
1154
1174 private function expandPackageFiles( Context $context ) {
1175 $hash = $context->getHash();
1176 if ( isset( $this->expandedPackageFiles[$hash] ) ) {
1177 return $this->expandedPackageFiles[$hash];
1178 }
1179 if ( $this->packageFiles === null ) {
1180 return null;
1181 }
1182 $expandedFiles = [];
1183 $mainFile = null;
1184
1185 foreach ( $this->packageFiles as $key => $fileInfo ) {
1186 $expanded = $this->expandFileInfo( $context, $fileInfo, "packageFiles[$key]" );
1187 $fileName = $expanded['name'];
1188 if ( !empty( $expanded['main'] ) ) {
1189 unset( $expanded['main'] );
1190 $type = $expanded['type'];
1191 $mainFile = $fileName;
1192 if ( $type !== 'script' && $type !== 'script-vue' ) {
1193 $msg = "Main file in package must be of type 'script', module " .
1194 "'{$this->getName()}', main file '{$mainFile}' is '{$type}'.";
1195 $this->getLogger()->error( $msg );
1196 throw new LogicException( $msg );
1197 }
1198 }
1199 $expandedFiles[$fileName] = $expanded;
1200 }
1201
1202 if ( $expandedFiles && $mainFile === null ) {
1203 // The first package file that is a script is the main file
1204 foreach ( $expandedFiles as $path => $file ) {
1205 if ( $file['type'] === 'script' || $file['type'] === 'script-vue' ) {
1206 $mainFile = $path;
1207 break;
1208 }
1209 }
1210 }
1211
1212 $result = [
1213 'main' => $mainFile,
1214 'files' => $expandedFiles
1215 ];
1216
1217 $this->expandedPackageFiles[$hash] = $result;
1218 return $result;
1219 }
1220
1250 private function expandFileInfo( Context $context, $fileInfo, $debugKey ) {
1251 if ( is_string( $fileInfo ) ) {
1252 // Inline common case
1253 return [
1254 'name' => $fileInfo,
1255 'type' => self::getPackageFileType( $fileInfo ),
1256 'filePath' => new FilePath( $fileInfo, $this->localBasePath, $this->remoteBasePath )
1257 ];
1258 } elseif ( $fileInfo instanceof FilePath ) {
1259 $fileInfo = [
1260 'name' => $fileInfo->getPath(),
1261 'file' => $fileInfo
1262 ];
1263 } elseif ( !is_array( $fileInfo ) ) {
1264 $msg = "Invalid type in $debugKey for module '{$this->getName()}', " .
1265 "must be array, string or FilePath";
1266 $this->getLogger()->error( $msg );
1267 throw new LogicException( $msg );
1268 }
1269 if ( !isset( $fileInfo['name'] ) ) {
1270 $msg = "Missing 'name' key in $debugKey for module '{$this->getName()}'";
1271 $this->getLogger()->error( $msg );
1272 throw new LogicException( $msg );
1273 }
1274 $fileName = $this->getPath( $fileInfo['name'] );
1275
1276 // Infer type from alias if needed
1277 $type = $fileInfo['type'] ?? self::getPackageFileType( $fileName );
1278 $expanded = [
1279 'name' => $fileName,
1280 'type' => $type
1281 ];
1282 if ( !empty( $fileInfo['main'] ) ) {
1283 $expanded['main'] = true;
1284 }
1285
1286 // Perform expansions (except 'file' and 'callback'), creating one of these keys:
1287 // - 'content': literal value.
1288 // - 'filePath': content to be read from a file.
1289 // - 'callback': content computed by a callable.
1290 if ( isset( $fileInfo['content'] ) ) {
1291 $expanded['content'] = $fileInfo['content'];
1292 } elseif ( isset( $fileInfo['file'] ) ) {
1293 $expanded['filePath'] = $this->makeFilePath( $fileInfo['file'] );
1294 } elseif ( isset( $fileInfo['callback'] ) ) {
1295 // If no extra parameter for the callback is given, use null.
1296 $expanded['callbackParam'] = $fileInfo['callbackParam'] ?? null;
1297
1298 if ( !is_callable( $fileInfo['callback'] ) ) {
1299 $msg = "Invalid 'callback' for module '{$this->getName()}', file '{$fileName}'.";
1300 $this->getLogger()->error( $msg );
1301 throw new LogicException( $msg );
1302 }
1303 if ( isset( $fileInfo['versionCallback'] ) ) {
1304 if ( !is_callable( $fileInfo['versionCallback'] ) ) {
1305 throw new LogicException( "Invalid 'versionCallback' for "
1306 . "module '{$this->getName()}', file '{$fileName}'."
1307 );
1308 }
1309
1310 // Execute the versionCallback with the same arguments that
1311 // would be given to the callback
1312 $callbackResult = ( $fileInfo['versionCallback'] )(
1313 $context,
1314 $this->getConfig(),
1315 $expanded['callbackParam']
1316 );
1317 if ( $callbackResult instanceof FilePath ) {
1318 $callbackResult->initBasePaths( $this->localBasePath, $this->remoteBasePath );
1319 $expanded['versionFilePath'] = $callbackResult;
1320 } else {
1321 $expanded['definitionSummary'] = $callbackResult;
1322 }
1323 // Don't invoke 'callback' here as it may be expensive (T223260).
1324 $expanded['callback'] = $fileInfo['callback'];
1325 } else {
1326 // Else go ahead invoke callback with its arguments.
1327 $callbackResult = ( $fileInfo['callback'] )(
1328 $context,
1329 $this->getConfig(),
1330 $expanded['callbackParam']
1331 );
1332 if ( $callbackResult instanceof FilePath ) {
1333 $callbackResult->initBasePaths( $this->localBasePath, $this->remoteBasePath );
1334 $expanded['filePath'] = $callbackResult;
1335 } else {
1336 $expanded['content'] = $callbackResult;
1337 }
1338 }
1339 } elseif ( isset( $fileInfo['config'] ) ) {
1340 if ( $type !== 'data' ) {
1341 $msg = "Key 'config' only valid for data files. "
1342 . " Module '{$this->getName()}', file '{$fileName}' is '{$type}'.";
1343 $this->getLogger()->error( $msg );
1344 throw new LogicException( $msg );
1345 }
1346 $expandedConfig = [];
1347 foreach ( $fileInfo['config'] as $configKey => $var ) {
1348 $expandedConfig[ is_numeric( $configKey ) ? $var : $configKey ] = $this->getConfig()->get( $var );
1349 }
1350 $expanded['content'] = $expandedConfig;
1351 } elseif ( !empty( $fileInfo['main'] ) ) {
1352 // [ 'name' => 'foo.js', 'main' => true ] is shorthand
1353 $expanded['filePath'] = $this->makeFilePath( $fileName );
1354 } else {
1355 $msg = "Incomplete definition for module '{$this->getName()}', file '{$fileName}'. "
1356 . "One of 'file', 'content', 'callback', or 'config' must be set.";
1357 $this->getLogger()->error( $msg );
1358 throw new LogicException( $msg );
1359 }
1360 if ( !isset( $expanded['filePath'] ) ) {
1361 $expanded['virtualFilePath'] = $this->makeFilePath( $fileName );
1362 }
1363 return $expanded;
1364 }
1365
1372 private function makeFilePath( $path ): FilePath {
1373 if ( $path instanceof FilePath ) {
1374 return $path;
1375 } elseif ( is_string( $path ) ) {
1376 return new FilePath( $path, $this->localBasePath, $this->remoteBasePath );
1377 } else {
1378 throw new InvalidArgumentException( '$path must be either FilePath or string' );
1379 }
1380 }
1381
1388 public function getPackageFiles( Context $context ) {
1389 if ( $this->packageFiles === null ) {
1390 return null;
1391 }
1392 $hash = $context->getHash();
1393 if ( isset( $this->fullyExpandedPackageFiles[ $hash ] ) ) {
1394 return $this->fullyExpandedPackageFiles[ $hash ];
1395 }
1396 $expandedPackageFiles = $this->expandPackageFiles( $context ) ?? [];
1397
1398 foreach ( $expandedPackageFiles['files'] as &$fileInfo ) {
1399 $this->readFileInfo( $context, $fileInfo );
1400 }
1401
1402 $this->fullyExpandedPackageFiles[ $hash ] = $expandedPackageFiles;
1403 return $expandedPackageFiles;
1404 }
1405
1414 private function readFileInfo( Context $context, array &$fileInfo ) {
1415 // Turn any 'filePath' or 'callback' key into actual 'content',
1416 // and remove the key after that. The callback could return a
1417 // FilePath object; if that happens, fall through to the 'filePath'
1418 // handling.
1419 if ( !isset( $fileInfo['content'] ) && isset( $fileInfo['callback'] ) ) {
1420 $callbackResult = ( $fileInfo['callback'] )(
1421 $context,
1422 $this->getConfig(),
1423 $fileInfo['callbackParam']
1424 );
1425 if ( $callbackResult instanceof FilePath ) {
1426 // Fall through to the filePath handling code below
1427 $fileInfo['filePath'] = $callbackResult;
1428 } else {
1429 $fileInfo['content'] = $callbackResult;
1430 }
1431 unset( $fileInfo['callback'] );
1432 }
1433 // Only interpret 'filePath' if 'content' hasn't been set already.
1434 // This can happen if 'versionCallback' provided 'filePath',
1435 // while 'callback' provides 'content'. In that case both are set
1436 // at this point. The 'filePath' from 'versionCallback' in that case is
1437 // only to inform getDefinitionSummary().
1438 if ( !isset( $fileInfo['content'] ) && isset( $fileInfo['filePath'] ) ) {
1439 $localPath = $this->getLocalPath( $fileInfo['filePath'] );
1440 $content = $this->getFileContents( $localPath, 'package' );
1441 if ( $fileInfo['type'] === 'data' ) {
1442 $content = json_decode( $content, false, 512, JSON_THROW_ON_ERROR );
1443 }
1444 $fileInfo['content'] = $content;
1445 }
1446 if ( $fileInfo['type'] === 'script-vue' ) {
1447 try {
1448 $parsedComponent = $this->getVueComponentParser()->parse(
1449 // @phan-suppress-next-line PhanTypePossiblyInvalidDimOffset False positive
1450 $fileInfo['content'],
1451 [ 'minifyTemplate' => !$context->getDebug() ]
1452 );
1453 } catch ( TimeoutException $e ) {
1454 throw $e;
1455 } catch ( Exception $e ) {
1456 $msg = "Error parsing file '{$fileInfo['name']}' in module '{$this->getName()}': " .
1457 $e->getMessage();
1458 $this->getLogger()->error( $msg );
1459 throw new RuntimeException( $msg );
1460 }
1461 $encodedTemplate = json_encode( $parsedComponent['template'] );
1462 if ( $context->getDebug() ) {
1463 // Replace \n (backslash-n) with space + backslash-n + backslash-newline in debug mode
1464 // The \n has to be preserved to prevent Vue parser issues (T351771)
1465 // We only replace \n if not preceded by a backslash, to avoid breaking '\\n'
1466 $encodedTemplate = preg_replace( '/(?<!\\\\)\\\\n/', " \\n\\\n", $encodedTemplate );
1467 // Expand \t to real tabs in debug mode
1468 $encodedTemplate = strtr( $encodedTemplate, [ "\\t" => "\t" ] );
1469 }
1470 $fileInfo['content'] = [
1471 'script' => $parsedComponent['script'] .
1472 ";\nmodule.exports.template = $encodedTemplate;",
1473 'style' => $parsedComponent['style'] ?? '',
1474 'styleLang' => $parsedComponent['styleLang'] ?? 'css'
1475 ];
1476 $fileInfo['type'] = 'script+style';
1477 }
1478 if ( !isset( $fileInfo['content'] ) ) {
1479 // This should not be possible due to validation in expandFileInfo()
1480 $msg = "Unable to resolve contents for file {$fileInfo['name']}";
1481 $this->getLogger()->error( $msg );
1482 throw new RuntimeException( $msg );
1483 }
1484
1485 // Not needed for client response, exists for use by getDefinitionSummary().
1486 unset( $fileInfo['definitionSummary'] );
1487 // Not needed for client response, used by callbacks only.
1488 unset( $fileInfo['callbackParam'] );
1489 }
1490
1501 protected function stripBom( $input ) {
1502 if ( str_starts_with( $input, "\xef\xbb\xbf" ) ) {
1503 return substr( $input, 3 );
1504 }
1505 return $input;
1506 }
1507}
const CACHE_HASH
Definition Defines.php:91
$fallback
if(!defined('MW_SETUP_CALLBACK'))
Definition WebStart.php:82
static getFileContentsHash( $filePaths)
Get a hash of the combined contents of one or more files, either by retrieving a previously-computed ...
A class containing constants representing the names of configuration variables.
const StylePath
Name constant for the StylePath setting, for use with Config::get()
const ExtensionAssetsPath
Name constant for the ExtensionAssetsPath setting, for use with Config::get()
const ResourceBasePath
Name constant for the ResourceBasePath setting, for use with Config::get()
Service locator for MediaWiki core services.
static getInstance()
Returns the global default instance of the top level service locator.
This is one of the Core classes and should be read at least once by any new developers.
Load JSON files, and uses a Processor to extract information.
Context object that contains information about the state of a specific ResourceLoader web request.
Definition Context.php:46
getHash()
All factors that uniquely identify this request, except 'modules'.
Definition Context.php:419
Module based on local JavaScript/CSS files.
array< string, array< int, string|FilePath > > $skinScripts
Lists of JavaScript files by skin name.
static tryForKey(array $list, $key, $fallback=null)
Get a list of element that match a key, optionally using a fallback key.
getAllSkinStyleFiles()
Get a list of file paths for all skin style files in the module, for all available skins.
getDependencies(?Context $context=null)
Get names of modules this module depends on.
readStyleFile( $path, Context $context)
Read and process a style file.
getTemplates()
Get content of named templates for this module.
getStyleSheetLang( $path)
Infer the stylesheet language from a stylesheet file path.
processStyle( $style, $styleLang, $path, Context $context)
Process a CSS/LESS string.
getDefinitionSummary(Context $context)
Get the definition summary for this module.
requiresES6()
Whether the module requires ES6 support in the client.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.
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.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:34
Abstraction for ResourceLoader modules, with name registration and maxage functionality.
Definition Module.php:47
static expandRelativePaths(array $filePaths)
Expand directories relative to $IP.
Definition Module.php:572
saveFileDependencies(Context $context, array $curFileRefs)
Save the indirect dependencies for this module pursuant to the skin/language context.
Definition Module.php:525
getLessVars(Context $context)
Get module-specific LESS variables, if any.
Definition Module.php:701
getFileDependencies(Context $context)
Get the indirect dependencies for this module pursuant to the skin/language context.
Definition Module.php:490
getMessageBlob(Context $context)
Get the hash of the message blob.
Definition Module.php:588
Parser for Vue single file components (.vue files).