MediaWiki REL1_40
FileModule.php
Go to the documentation of this file.
1<?php
24
25use CSSJanus;
26use Exception;
29use InvalidArgumentException;
30use LogicException;
34use ObjectCache;
35use OutputPage;
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 $es6 = 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 case 'targets':
225 // Normalise
226 $option = array_values( array_unique( (array)$option ) );
227 sort( $option );
228
229 $this->{$member} = $option;
230 break;
231 // Single strings
232 case 'group':
233 case 'skipFunction':
234 $this->{$member} = (string)$option;
235 break;
236 // Single booleans
237 case 'debugRaw':
238 case 'noflip':
239 case 'es6':
240 $this->{$member} = (bool)$option;
241 break;
242 }
243 }
244 if ( isset( $options['scripts'] ) && isset( $options['packageFiles'] ) ) {
245 throw new InvalidArgumentException( "A module may not set both 'scripts' and 'packageFiles'" );
246 }
247 if ( isset( $options['packageFiles'] ) && isset( $options['skinScripts'] ) ) {
248 throw new InvalidArgumentException( "Options 'skinScripts' and 'packageFiles' cannot be used together." );
249 }
250 if ( $hasTemplates ) {
251 $this->dependencies[] = 'mediawiki.template';
252 // Ensure relevant template compiler module gets loaded
253 foreach ( $this->templates as $alias => $templatePath ) {
254 if ( is_int( $alias ) ) {
255 $alias = $this->getPath( $templatePath );
256 }
257 $suffix = explode( '.', $alias );
258 $suffix = end( $suffix );
259 $compilerModule = 'mediawiki.template.' . $suffix;
260 if ( $suffix !== 'html' && !in_array( $compilerModule, $this->dependencies ) ) {
261 $this->dependencies[] = $compilerModule;
262 }
263 }
264 }
265 }
266
278 public static function extractBasePaths(
279 array $options = [],
280 $localBasePath = null,
281 $remoteBasePath = null
282 ) {
283 global $IP;
284 // The different ways these checks are done, and their ordering, look very silly,
285 // but were preserved for backwards-compatibility just in case. Tread lightly.
286
287 if ( $remoteBasePath === null ) {
290 }
291
292 if ( isset( $options['remoteExtPath'] ) ) {
293 $extensionAssetsPath = MediaWikiServices::getInstance()->getMainConfig()
295 $remoteBasePath = $extensionAssetsPath . '/' . $options['remoteExtPath'];
296 }
297
298 if ( isset( $options['remoteSkinPath'] ) ) {
299 $stylePath = MediaWikiServices::getInstance()->getMainConfig()
301 $remoteBasePath = $stylePath . '/' . $options['remoteSkinPath'];
302 }
303
304 if ( array_key_exists( 'localBasePath', $options ) ) {
305 $localBasePath = (string)$options['localBasePath'];
306 }
307
308 if ( array_key_exists( 'remoteBasePath', $options ) ) {
309 $remoteBasePath = (string)$options['remoteBasePath'];
310 }
311
312 if ( $remoteBasePath === '' ) {
313 // If MediaWiki is installed at the document root (not recommended),
314 // then wgScriptPath is set to the empty string by the installer to
315 // ensure safe concatenating of file paths (avoid "/" + "/foo" being "//foo").
316 // However, this also means the path itself can be an invalid URI path,
317 // as those must start with a slash. Within ResourceLoader, we will not
318 // do such primitive/unsafe slash concatenation and use URI resolution
319 // instead, so beyond this point, to avoid fatal errors in CSSMin::resolveUrl(),
320 // do a best-effort support for docroot installs by casting this to a slash.
321 $remoteBasePath = '/';
322 }
323
324 return [ $localBasePath ?? $IP, $remoteBasePath ];
325 }
326
333 public function getScript( Context $context ) {
334 $deprecationScript = $this->getDeprecationInformation( $context );
335 $packageFiles = $this->getPackageFiles( $context );
336 if ( $packageFiles !== null ) {
337 foreach ( $packageFiles['files'] as &$file ) {
338 if ( $file['type'] === 'script+style' ) {
339 $file['content'] = $file['content']['script'];
340 $file['type'] = 'script';
341 }
342 }
343 if ( $deprecationScript ) {
344 $mainFile =& $packageFiles['files'][$packageFiles['main']];
345 $mainFile['content'] = $deprecationScript . $mainFile['content'];
346 }
347 return $packageFiles;
348 }
349
350 $files = $this->getScriptFiles( $context );
351 return $deprecationScript . $this->readScriptFiles( $files );
352 }
353
358 public function getScriptURLsForDebug( Context $context ) {
359 $rl = $context->getResourceLoader();
360 $config = $this->getConfig();
361 $server = $config->get( MainConfigNames::Server );
362
363 $urls = [];
364 foreach ( $this->getScriptFiles( $context ) as $file ) {
365 $url = OutputPage::transformResourcePath( $config, $this->getRemotePath( $file ) );
366 // Expand debug URL in case we are another wiki's module source (T255367)
367 $url = $rl->expandUrl( $server, $url );
368 $urls[] = $url;
369 }
370 return $urls;
371 }
372
376 public function supportsURLLoading() {
377 // If package files are involved, don't support URL loading, because that breaks
378 // scoped require() functions
379 return $this->debugRaw && !$this->packageFiles;
380 }
381
388 public function getStyles( Context $context ) {
389 $styles = $this->readStyleFiles(
390 $this->getStyleFiles( $context ),
391 $context
392 );
393
394 $packageFiles = $this->getPackageFiles( $context );
395 if ( $packageFiles !== null ) {
396 foreach ( $packageFiles['files'] as $fileName => $file ) {
397 if ( $file['type'] === 'script+style' ) {
398 $style = $this->processStyle(
399 $file['content']['style'],
400 $file['content']['styleLang'],
401 $fileName,
402 $context
403 );
404 $styles['all'] = ( $styles['all'] ?? '' ) . "\n" . $style;
405 }
406 }
407 }
408
409 // Track indirect file dependencies so that StartUpModule can check for
410 // on-disk file changes to any of this files without having to recompute the file list
411 $this->saveFileDependencies( $context, $this->localFileRefs );
412
413 return $styles;
414 }
415
420 public function getStyleURLsForDebug( Context $context ) {
421 if ( $this->hasGeneratedStyles ) {
422 // Do the default behaviour of returning a url back to load.php
423 // but with only=styles.
424 return parent::getStyleURLsForDebug( $context );
425 }
426 // Our module consists entirely of real css files,
427 // in debug mode we can load those directly.
428 $urls = [];
429 foreach ( $this->getStyleFiles( $context ) as $mediaType => $list ) {
430 $urls[$mediaType] = [];
431 foreach ( $list as $file ) {
432 $urls[$mediaType][] = OutputPage::transformResourcePath(
433 $this->getConfig(),
434 $this->getRemotePath( $file )
435 );
436 }
437 }
438 return $urls;
439 }
440
446 public function getMessages() {
447 return $this->messages;
448 }
449
455 public function getGroup() {
456 return $this->group;
457 }
458
465 public function getDependencies( Context $context = null ) {
466 return $this->dependencies;
467 }
468
476 private function getFileContents( $localPath, $type ) {
477 if ( !is_file( $localPath ) ) {
478 throw new RuntimeException( "$type file not found or not a file: \"$localPath\"" );
479 }
480 return $this->stripBom( file_get_contents( $localPath ) );
481 }
482
486 public function getSkipFunction() {
487 if ( !$this->skipFunction ) {
488 return null;
489 }
490 $localPath = $this->getLocalPath( $this->skipFunction );
491 return $this->getFileContents( $localPath, 'skip function' );
492 }
493
494 public function requiresES6() {
495 return $this->es6;
496 }
497
506 public function enableModuleContentVersion() {
507 return false;
508 }
509
516 private function getFileHashes( Context $context ) {
517 $files = [];
518
519 $styleFiles = $this->getStyleFiles( $context );
520 foreach ( $styleFiles as $paths ) {
521 $files = array_merge( $files, $paths );
522 }
523
524 // Extract file paths for package files
525 // Optimisation: Use foreach() and isset() instead of array_map/array_filter.
526 // This is a hot code path, called by StartupModule for thousands of modules.
527 $expandedPackageFiles = $this->expandPackageFiles( $context );
528 $packageFiles = [];
529 if ( $expandedPackageFiles ) {
530 foreach ( $expandedPackageFiles['files'] as $fileInfo ) {
531 if ( isset( $fileInfo['filePath'] ) ) {
532 $packageFiles[] = $fileInfo['filePath'];
533 }
534 }
535 }
536
537 // Merge all the file paths we were able discover directly from the module definition.
538 // This is the primary list of direct-dependent files for this module.
539 $files = array_merge(
540 $files,
542 $this->scripts,
543 $this->templates,
544 $context->getDebug() ? $this->debugScripts : [],
545 $this->getLanguageScripts( $context->getLanguage() ),
546 self::tryForKey( $this->skinScripts, $context->getSkin(), 'default' )
547 );
548 if ( $this->skipFunction ) {
549 $files[] = $this->skipFunction;
550 }
551
552 // Expand these local paths into absolute file paths
553 $files = array_map( [ $this, 'getLocalPath' ], $files );
554
555 // Add any lazily discovered file dependencies from previous module builds.
556 // These are added last because they are already absolute file paths.
557 $files = array_merge( $files, $this->getFileDependencies( $context ) );
558
559 // Filter out any duplicates. Typically introduced by getFileDependencies() which
560 // may lazily re-discover a primary file.
561 $files = array_unique( $files );
562
563 // Don't return array keys or any other form of file path here, only the hashes.
564 // Including file paths would needlessly cause global cache invalidation when files
565 // move on disk or if e.g. the MediaWiki directory name changes.
566 // Anything where order is significant is already detected by the definition summary.
568 }
569
576 public function getDefinitionSummary( Context $context ) {
577 $summary = parent::getDefinitionSummary( $context );
578
579 $options = [];
580 foreach ( [
581 // The following properties are omitted because they don't affect the module response:
582 // - localBasePath (Per T104950; Changes when absolute directory name changes. If
583 // this affects 'scripts' and other file paths, getFileHashes accounts for that.)
584 // - remoteBasePath (Per T104950)
585 // - dependencies (provided via startup module)
586 // - targets
587 // - group (provided via startup module)
588 'scripts',
589 'debugScripts',
590 'styles',
591 'languageScripts',
592 'skinScripts',
593 'skinStyles',
594 'messages',
595 'templates',
596 'skipFunction',
597 'debugRaw',
598 ] as $member ) {
599 $options[$member] = $this->{$member};
600 }
601
602 $packageFiles = $this->expandPackageFiles( $context );
603 if ( $packageFiles ) {
604 // Extract the minimum needed:
605 // - The 'main' pointer (included as-is).
606 // - The 'files' array, simplified to only which files exist (the keys of
607 // this array), and something that represents their non-file content.
608 // For packaged files that reflect files directly from disk, the
609 // 'getFileHashes' method tracks their content already.
610 // It is important that the keys of the $packageFiles['files'] array
611 // are preserved, as they do affect the module output.
612 $packageFiles['files'] = array_map( static function ( $fileInfo ) {
613 return $fileInfo['definitionSummary'] ?? ( $fileInfo['content'] ?? null );
614 }, $packageFiles['files'] );
615 }
616
617 $summary[] = [
618 'options' => $options,
619 'packageFiles' => $packageFiles,
620 'fileHashes' => $this->getFileHashes( $context ),
621 'messageBlob' => $this->getMessageBlob( $context ),
622 ];
623
624 $lessVars = $this->getLessVars( $context );
625 if ( $lessVars ) {
626 $summary[] = [ 'lessVars' => $lessVars ];
627 }
628
629 return $summary;
630 }
631
635 protected function getVueComponentParser() {
636 if ( $this->vueComponentParser === null ) {
637 $this->vueComponentParser = new VueComponentParser;
638 }
640 }
641
646 protected function getPath( $path ) {
647 if ( $path instanceof FilePath ) {
648 return $path->getPath();
649 }
650
651 return $path;
652 }
653
658 protected function getLocalPath( $path ) {
659 if ( $path instanceof FilePath ) {
660 if ( $path->getLocalBasePath() !== null ) {
661 return $path->getLocalPath();
662 }
663 $path = $path->getPath();
664 }
665
666 return "{$this->localBasePath}/$path";
667 }
668
673 protected function getRemotePath( $path ) {
674 if ( $path instanceof FilePath ) {
675 if ( $path->getRemoteBasePath() !== null ) {
676 return $path->getRemotePath();
677 }
678 $path = $path->getPath();
679 }
680
681 if ( $this->remoteBasePath === '/' ) {
682 return "/$path";
683 } else {
684 return "{$this->remoteBasePath}/$path";
685 }
686 }
687
695 public function getStyleSheetLang( $path ) {
696 return preg_match( '/\.less$/i', $path ) ? 'less' : 'css';
697 }
698
704 public static function getPackageFileType( $path ) {
705 if ( preg_match( '/\.json$/i', $path ) ) {
706 return 'data';
707 }
708 if ( preg_match( '/\.vue$/i', $path ) ) {
709 return 'script-vue';
710 }
711 return 'script';
712 }
713
721 private static function collateStyleFilesByMedia( array $list ) {
722 $collatedFiles = [];
723 foreach ( $list as $key => $value ) {
724 if ( is_int( $key ) ) {
725 // File name as the value
726 $collatedFiles['all'][] = $value;
727 } elseif ( is_array( $value ) ) {
728 // File name as the key, options array as the value
729 $optionValue = $value['media'] ?? 'all';
730 $collatedFiles[$optionValue][] = $key;
731 }
732 }
733 return $collatedFiles;
734 }
735
745 protected static function tryForKey( array $list, $key, $fallback = null ) {
746 if ( isset( $list[$key] ) && is_array( $list[$key] ) ) {
747 return $list[$key];
748 } elseif ( is_string( $fallback )
749 && isset( $list[$fallback] )
750 && is_array( $list[$fallback] )
751 ) {
752 return $list[$fallback];
753 }
754 return [];
755 }
756
763 private function getScriptFiles( Context $context ): array {
764 // List in execution order: scripts, languageScripts, skinScripts, debugScripts.
765 // Documented at MediaWiki\MainConfigSchema::ResourceModules.
766 $files = array_merge(
767 $this->scripts,
768 $this->getLanguageScripts( $context->getLanguage() ),
769 self::tryForKey( $this->skinScripts, $context->getSkin(), 'default' )
770 );
771 if ( $context->getDebug() ) {
772 $files = array_merge( $files, $this->debugScripts );
773 }
774
775 return array_unique( $files, SORT_REGULAR );
776 }
777
785 private function getLanguageScripts( string $lang ): array {
786 $scripts = self::tryForKey( $this->languageScripts, $lang );
787 if ( $scripts ) {
788 return $scripts;
789 }
790
791 // Optimization: Avoid initialising and calling into language services
792 // for the majority of modules that don't use this option.
793 if ( $this->languageScripts ) {
794 $fallbacks = MediaWikiServices::getInstance()
795 ->getLanguageFallback()
796 ->getAll( $lang, LanguageFallback::MESSAGES );
797 foreach ( $fallbacks as $lang ) {
798 $scripts = self::tryForKey( $this->languageScripts, $lang );
799 if ( $scripts ) {
800 return $scripts;
801 }
802 }
803 }
804
805 return [];
806 }
807
808 public function setSkinStylesOverride( array $moduleSkinStyles ): void {
809 $moduleName = $this->getName();
810 foreach ( $moduleSkinStyles as $skinName => $overrides ) {
811 // If a module provides overrides for a skin, and that skin also provides overrides
812 // for the same module, then the module has precedence.
813 if ( isset( $this->skinStyles[$skinName] ) ) {
814 continue;
815 }
816
817 // If $moduleName in ResourceModuleSkinStyles is preceded with a '+', the defined style
818 // files will be added to 'default' skinStyles, otherwise 'default' will be ignored.
819 if ( isset( $overrides[$moduleName] ) ) {
820 $paths = (array)$overrides[$moduleName];
821 $styleFiles = [];
822 } elseif ( isset( $overrides['+' . $moduleName] ) ) {
823 $paths = (array)$overrides['+' . $moduleName];
824 $styleFiles = isset( $this->skinStyles['default'] ) ?
825 (array)$this->skinStyles['default'] :
826 [];
827 } else {
828 continue;
829 }
830
831 // Add new file paths, remapping them to refer to our directories and not use settings
832 // from the module we're modifying, which come from the base definition.
833 [ $localBasePath, $remoteBasePath ] = self::extractBasePaths( $overrides );
834
835 foreach ( $paths as $path ) {
836 $styleFiles[] = new FilePath( $path, $localBasePath, $remoteBasePath );
837 }
838
839 $this->skinStyles[$skinName] = $styleFiles;
840 }
841 }
842
850 public function getStyleFiles( Context $context ) {
851 return array_merge_recursive(
852 self::collateStyleFilesByMedia( $this->styles ),
853 self::collateStyleFilesByMedia(
854 self::tryForKey( $this->skinStyles, $context->getSkin(), 'default' )
855 )
856 );
857 }
858
866 protected function getSkinStyleFiles( $skinName ) {
867 return self::collateStyleFilesByMedia(
868 self::tryForKey( $this->skinStyles, $skinName )
869 );
870 }
871
878 protected function getAllSkinStyleFiles() {
879 $skinFactory = MediaWikiServices::getInstance()->getSkinFactory();
880 $styleFiles = [];
881
882 $internalSkinNames = array_keys( $skinFactory->getInstalledSkins() );
883 $internalSkinNames[] = 'default';
884
885 foreach ( $internalSkinNames as $internalSkinName ) {
886 $styleFiles = array_merge_recursive(
887 $styleFiles,
888 $this->getSkinStyleFiles( $internalSkinName )
889 );
890 }
891
892 return $styleFiles;
893 }
894
900 public function getAllStyleFiles() {
901 $collatedStyleFiles = array_merge_recursive(
902 self::collateStyleFilesByMedia( $this->styles ),
903 $this->getAllSkinStyleFiles()
904 );
905
906 $result = [];
907
908 foreach ( $collatedStyleFiles as $styleFiles ) {
909 foreach ( $styleFiles as $styleFile ) {
910 $result[] = $this->getLocalPath( $styleFile );
911 }
912 }
913
914 return $result;
915 }
916
923 private function readScriptFiles( array $scripts ) {
924 if ( !$scripts ) {
925 return '';
926 }
927 $js = '';
928 foreach ( array_unique( $scripts, SORT_REGULAR ) as $fileName ) {
929 $localPath = $this->getLocalPath( $fileName );
930 $contents = $this->getFileContents( $localPath, 'script' );
931 $js .= ResourceLoader::ensureNewline( $contents );
932 }
933 return $js;
934 }
935
944 public function readStyleFiles( array $styles, Context $context ) {
945 if ( !$styles ) {
946 return [];
947 }
948 foreach ( $styles as $media => $files ) {
949 $uniqueFiles = array_unique( $files, SORT_REGULAR );
950 $styleFiles = [];
951 foreach ( $uniqueFiles as $file ) {
952 $styleFiles[] = $this->readStyleFile( $file, $context );
953 }
954 $styles[$media] = implode( "\n", $styleFiles );
955 }
956 return $styles;
957 }
958
969 protected function readStyleFile( $path, Context $context ) {
970 $localPath = $this->getLocalPath( $path );
971 $style = $this->getFileContents( $localPath, 'style' );
972 $styleLang = $this->getStyleSheetLang( $localPath );
973
974 return $this->processStyle( $style, $styleLang, $path, $context );
975 }
976
993 protected function processStyle( $style, $styleLang, $path, Context $context ) {
994 $localPath = $this->getLocalPath( $path );
995 $remotePath = $this->getRemotePath( $path );
996
997 if ( $styleLang === 'less' ) {
998 $style = $this->compileLessString( $style, $localPath, $context );
999 $this->hasGeneratedStyles = true;
1000 }
1001
1002 if ( $this->getFlip( $context ) ) {
1003 $style = CSSJanus::transform(
1004 $style,
1005 /* $swapLtrRtlInURL = */ true,
1006 /* $swapLeftRightInURL = */ false
1007 );
1008 }
1009
1010 $localDir = dirname( $localPath );
1011 $remoteDir = dirname( $remotePath );
1012 // Get and register local file references
1013 $localFileRefs = CSSMin::getLocalFileReferences( $style, $localDir );
1014 foreach ( $localFileRefs as $file ) {
1015 if ( is_file( $file ) ) {
1016 $this->localFileRefs[] = $file;
1017 } else {
1018 $this->missingLocalFileRefs[] = $file;
1019 }
1020 }
1021 // Don't cache this call. remap() ensures data URIs embeds are up to date,
1022 // and urls contain correct content hashes in their query string. (T128668)
1023 return CSSMin::remap( $style, $localDir, $remoteDir, true );
1024 }
1025
1031 public function getFlip( Context $context ) {
1032 return $context->getDirection() === 'rtl' && !$this->noflip;
1033 }
1034
1040 public function getTargets() {
1041 return $this->targets;
1042 }
1043
1050 public function getType() {
1051 $canBeStylesOnly = !(
1052 // All options except 'styles', 'skinStyles' and 'debugRaw'
1053 $this->scripts
1054 || $this->debugScripts
1055 || $this->templates
1056 || $this->languageScripts
1057 || $this->skinScripts
1058 || $this->dependencies
1059 || $this->messages
1060 || $this->skipFunction
1061 || $this->packageFiles
1062 );
1063 return $canBeStylesOnly ? self::LOAD_STYLES : self::LOAD_GENERAL;
1064 }
1065
1077 protected function compileLessString( $style, $stylePath, Context $context ) {
1078 static $cache;
1079 // @TODO: dependency injection
1080 if ( !$cache ) {
1081 $cache = ObjectCache::getLocalServerInstance( CACHE_ANYTHING );
1082 }
1083
1084 $skinName = $context->getSkin();
1085 $skinImportPaths = ExtensionRegistry::getInstance()->getAttribute( 'SkinLessImportPaths' );
1086 $importDirs = [];
1087 if ( isset( $skinImportPaths[ $skinName ] ) ) {
1088 $importDirs[] = $skinImportPaths[ $skinName ];
1089 }
1090
1091 $vars = $this->getLessVars( $context );
1092 // Construct a cache key from a hash of the LESS source, and a hash digest
1093 // of the LESS variables used for compilation.
1094 ksort( $vars );
1095 $compilerParams = [
1096 'vars' => $vars,
1097 'importDirs' => $importDirs,
1098 ];
1099 $key = $cache->makeGlobalKey(
1100 'resourceloader-less',
1101 'v1',
1102 hash( 'md4', $style ),
1103 hash( 'md4', serialize( $compilerParams ) )
1104 );
1105
1106 // If we got a cached value, we have to validate it by getting a checksum of all the
1107 // files that were loaded by the parser and ensuring it matches the cached entry's.
1108 $data = $cache->get( $key );
1109 if (
1110 !$data ||
1111 $data['hash'] !== FileContentsHasher::getFileContentsHash( $data['files'] )
1112 ) {
1113 $compiler = $context->getResourceLoader()->getLessCompiler( $vars, $importDirs );
1114
1115 $css = $compiler->parse( $style, $stylePath )->getCss();
1116 // T253055: store the implicit dependency paths in a form relative to any install
1117 // path so that multiple version of the application can share the cache for identical
1118 // less stylesheets. This also avoids churn during application updates.
1119 $files = $compiler->AllParsedFiles();
1120 $data = [
1121 'css' => $css,
1122 'files' => Module::getRelativePaths( $files ),
1123 'hash' => FileContentsHasher::getFileContentsHash( $files )
1124 ];
1125 $cache->set( $key, $data, $cache::TTL_DAY );
1126 }
1127
1128 foreach ( Module::expandRelativePaths( $data['files'] ) as $path ) {
1129 $this->localFileRefs[] = $path;
1130 }
1131
1132 return $data['css'];
1133 }
1134
1140 public function getTemplates() {
1141 $templates = [];
1142
1143 foreach ( $this->templates as $alias => $templatePath ) {
1144 // Alias is optional
1145 if ( is_int( $alias ) ) {
1146 $alias = $this->getPath( $templatePath );
1147 }
1148 $localPath = $this->getLocalPath( $templatePath );
1149 $content = $this->getFileContents( $localPath, 'template' );
1150
1151 $templates[$alias] = $this->stripBom( $content );
1152 }
1153 return $templates;
1154 }
1155
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 if ( !is_array( $fileInfo ) ) {
1187 $fileInfo = [ 'name' => $fileInfo, 'file' => $fileInfo ];
1188 }
1189 if ( !isset( $fileInfo['name'] ) ) {
1190 $msg = "Missing 'name' key in package file info for module '{$this->getName()}'," .
1191 " offset '{$key}'.";
1192 $this->getLogger()->error( $msg );
1193 throw new LogicException( $msg );
1194 }
1195 $fileName = $this->getPath( $fileInfo['name'] );
1196
1197 // Infer type from alias if needed
1198 $type = $fileInfo['type'] ?? self::getPackageFileType( $fileName );
1199 $expanded = [ 'type' => $type ];
1200 if ( !empty( $fileInfo['main'] ) ) {
1201 $mainFile = $fileName;
1202 if ( $type !== 'script' && $type !== 'script-vue' ) {
1203 $msg = "Main file in package must be of type 'script', module " .
1204 "'{$this->getName()}', main file '{$mainFile}' is '{$type}'.";
1205 $this->getLogger()->error( $msg );
1206 throw new LogicException( $msg );
1207 }
1208 }
1209
1210 // Perform expansions (except 'file' and 'callback'), creating one of these keys:
1211 // - 'content': literal value.
1212 // - 'filePath': content to be read from a file.
1213 // - 'callback': content computed by a callable.
1214 if ( isset( $fileInfo['content'] ) ) {
1215 $expanded['content'] = $fileInfo['content'];
1216 } elseif ( isset( $fileInfo['file'] ) ) {
1217 $expanded['filePath'] = $fileInfo['file'];
1218 } elseif ( isset( $fileInfo['callback'] ) ) {
1219 // If no extra parameter for the callback is given, use null.
1220 $expanded['callbackParam'] = $fileInfo['callbackParam'] ?? null;
1221
1222 if ( !is_callable( $fileInfo['callback'] ) ) {
1223 $msg = "Invalid 'callback' for module '{$this->getName()}', file '{$fileName}'.";
1224 $this->getLogger()->error( $msg );
1225 throw new LogicException( $msg );
1226 }
1227 if ( isset( $fileInfo['versionCallback'] ) ) {
1228 if ( !is_callable( $fileInfo['versionCallback'] ) ) {
1229 throw new LogicException( "Invalid 'versionCallback' for "
1230 . "module '{$this->getName()}', file '{$fileName}'."
1231 );
1232 }
1233
1234 // Execute the versionCallback with the same arguments that
1235 // would be given to the callback
1236 $callbackResult = ( $fileInfo['versionCallback'] )(
1237 $context,
1238 $this->getConfig(),
1239 $expanded['callbackParam']
1240 );
1241 if ( $callbackResult instanceof FilePath ) {
1242 $expanded['filePath'] = $callbackResult;
1243 } else {
1244 $expanded['definitionSummary'] = $callbackResult;
1245 }
1246 // Don't invoke 'callback' here as it may be expensive (T223260).
1247 $expanded['callback'] = $fileInfo['callback'];
1248 } else {
1249 // Else go ahead invoke callback with its arguments.
1250 $callbackResult = ( $fileInfo['callback'] )(
1251 $context,
1252 $this->getConfig(),
1253 $expanded['callbackParam']
1254 );
1255 if ( $callbackResult instanceof FilePath ) {
1256 $expanded['filePath'] = $callbackResult;
1257 } else {
1258 $expanded['content'] = $callbackResult;
1259 }
1260 }
1261 } elseif ( isset( $fileInfo['config'] ) ) {
1262 if ( $type !== 'data' ) {
1263 $msg = "Key 'config' only valid for data files. "
1264 . " Module '{$this->getName()}', file '{$fileName}' is '{$type}'.";
1265 $this->getLogger()->error( $msg );
1266 throw new LogicException( $msg );
1267 }
1268 $expandedConfig = [];
1269 foreach ( $fileInfo['config'] as $configKey => $var ) {
1270 $expandedConfig[ is_numeric( $configKey ) ? $var : $configKey ] = $this->getConfig()->get( $var );
1271 }
1272 $expanded['content'] = $expandedConfig;
1273 } elseif ( !empty( $fileInfo['main'] ) ) {
1274 // [ 'name' => 'foo.js', 'main' => true ] is shorthand
1275 $expanded['filePath'] = $fileName;
1276 } else {
1277 $msg = "Incomplete definition for module '{$this->getName()}', file '{$fileName}'. "
1278 . "One of 'file', 'content', 'callback', or 'config' must be set.";
1279 $this->getLogger()->error( $msg );
1280 throw new LogicException( $msg );
1281 }
1282
1283 $expandedFiles[$fileName] = $expanded;
1284 }
1285
1286 if ( $expandedFiles && $mainFile === null ) {
1287 // The first package file that is a script is the main file
1288 foreach ( $expandedFiles as $path => $file ) {
1289 if ( $file['type'] === 'script' || $file['type'] === 'script-vue' ) {
1290 $mainFile = $path;
1291 break;
1292 }
1293 }
1294 }
1295
1296 $result = [
1297 'main' => $mainFile,
1298 'files' => $expandedFiles
1299 ];
1300
1301 $this->expandedPackageFiles[$hash] = $result;
1302 return $result;
1303 }
1304
1311 public function getPackageFiles( Context $context ) {
1312 if ( $this->packageFiles === null ) {
1313 return null;
1314 }
1315 $hash = $context->getHash();
1316 if ( isset( $this->fullyExpandedPackageFiles[ $hash ] ) ) {
1317 return $this->fullyExpandedPackageFiles[ $hash ];
1318 }
1319 $expandedPackageFiles = $this->expandPackageFiles( $context );
1320
1321 // Expand file contents
1322 foreach ( $expandedPackageFiles['files'] as $fileName => &$fileInfo ) {
1323 // Turn any 'filePath' or 'callback' key into actual 'content',
1324 // and remove the key after that. The callback could return a
1325 // ResourceLoaderFilePath object; if that happens, fall through
1326 // to the 'filePath' handling.
1327 if ( isset( $fileInfo['callback'] ) ) {
1328 $callbackResult = ( $fileInfo['callback'] )(
1329 $context,
1330 $this->getConfig(),
1331 $fileInfo['callbackParam']
1332 );
1333 if ( $callbackResult instanceof FilePath ) {
1334 // Fall through to the filePath handling code below
1335 $fileInfo['filePath'] = $callbackResult;
1336 } else {
1337 $fileInfo['content'] = $callbackResult;
1338 }
1339 unset( $fileInfo['callback'] );
1340 }
1341 // Only interpret 'filePath' if 'content' hasn't been set already.
1342 // This can happen if 'versionCallback' provided 'filePath',
1343 // while 'callback' provides 'content'. In that case both are set
1344 // at this point. The 'filePath' from 'versionCallback' in that case is
1345 // only to inform getDefinitionSummary().
1346 if ( !isset( $fileInfo['content'] ) && isset( $fileInfo['filePath'] ) ) {
1347 $localPath = $this->getLocalPath( $fileInfo['filePath'] );
1348 $content = $this->getFileContents( $localPath, 'package' );
1349 if ( $fileInfo['type'] === 'data' ) {
1350 $content = json_decode( $content );
1351 }
1352 $fileInfo['content'] = $content;
1353 unset( $fileInfo['filePath'] );
1354 }
1355 if ( $fileInfo['type'] === 'script-vue' ) {
1356 try {
1357 $parsedComponent = $this->getVueComponentParser()->parse(
1358 // @phan-suppress-next-line PhanTypePossiblyInvalidDimOffset False positive
1359 $fileInfo['content'],
1360 [ 'minifyTemplate' => !$context->getDebug() ]
1361 );
1362 } catch ( TimeoutException $e ) {
1363 throw $e;
1364 } catch ( Exception $e ) {
1365 $msg = "Error parsing file '$fileName' in module '{$this->getName()}': " .
1366 $e->getMessage();
1367 $this->getLogger()->error( $msg );
1368 throw new RuntimeException( $msg );
1369 }
1370 $encodedTemplate = json_encode( $parsedComponent['template'] );
1371 if ( $context->getDebug() ) {
1372 // Replace \n (backslash-n) with space + backslash-newline in debug mode
1373 // We only replace \n if not preceded by a backslash, to avoid breaking '\\n'
1374 $encodedTemplate = preg_replace( '/(?<!\\\\)\\\\n/', " \\\n", $encodedTemplate );
1375 // Expand \t to real tabs in debug mode
1376 $encodedTemplate = strtr( $encodedTemplate, [ "\\t" => "\t" ] );
1377 }
1378 $fileInfo['content'] = [
1379 'script' => $parsedComponent['script'] .
1380 ";\nmodule.exports.template = $encodedTemplate;",
1381 'style' => $parsedComponent['style'] ?? '',
1382 'styleLang' => $parsedComponent['styleLang'] ?? 'css'
1383 ];
1384 $fileInfo['type'] = 'script+style';
1385 }
1386
1387 // Not needed for client response, exists for use by getDefinitionSummary().
1388 unset( $fileInfo['definitionSummary'] );
1389 // Not needed for client response, used by callbacks only.
1390 unset( $fileInfo['callbackParam'] );
1391 }
1392
1393 $this->fullyExpandedPackageFiles[ $hash ] = $expandedPackageFiles;
1394 return $expandedPackageFiles;
1395 }
1396
1407 protected function stripBom( $input ) {
1408 if ( str_starts_with( $input, "\xef\xbb\xbf" ) ) {
1409 return substr( $input, 3 );
1410 }
1411 return $input;
1412 }
1413}
1414
1416class_alias( FileModule::class, 'ResourceLoaderFileModule' );
const CACHE_ANYTHING
Definition Defines.php:85
$fallback
Definition MessagesAb.php:8
if(!defined( 'MEDIAWIKI')) if(ini_get('mbstring.func_overload')) if(!defined( 'MW_ENTRY_POINT')) global $IP
Environment checks.
Definition Setup.php:93
The Registry loads JSON files, and uses a Processor to extract information from them.
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.
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:443
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()
Gets 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()
getTargets()
Get target(s) for the module, eg ['desktop'] or ['desktop', 'mobile'].
array< int, string|FilePath > $scripts
List of JavaScript file paths to always include.
stripBom( $input)
Takes an input string and removes the UTF-8 BOM character if present.
getFlip(Context $context)
Get whether CSS for this module should be flipped.
getSkinStyleFiles( $skinName)
Gets 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 generates 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)
Constructs 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.
bool $es6
Whether this module requires the client to support ES6.
getScript(Context $context)
Gets all scripts for a given context concatenated together.
getMessages()
Get message keys used by this module.
getAllStyleFiles()
Returns 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.
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().
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:48
saveFileDependencies(Context $context, array $curFileRefs)
Save the indirect dependencies for this module pursuant to the skin/language context.
Definition Module.php:583
getLessVars(Context $context)
Get module-specific LESS variables, if any.
Definition Module.php:770
getFileDependencies(Context $context)
Get the indirect dependencies for this module pursuant to the skin/language context.
Definition Module.php:545
getDeprecationInformation(Context $context)
Get JS representing deprecation information for the current module if available.
Definition Module.php:200
getMessageBlob(Context $context)
Get the hash of the message blob.
Definition Module.php:657
Parser for Vue single file components (.vue files).
Functions to get cache objects.
This is one of the Core classes and should be read at least once by any new developers.
get( $name)
Get a configuration variable such as "Sitename" or "UploadMaintenance.".
$content
Definition router.php:76
if(PHP_SAPI !='cli-server') if(!isset( $_SERVER['SCRIPT_FILENAME'])) $file
Item class for a filearchive table row.
Definition router.php:42
if(!isset( $args[0])) $lang