MediaWiki REL1_39
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 $targets = [ 'desktop' ];
137
139 protected $noflip = false;
140
142 protected $es6 = false;
143
148 protected $hasGeneratedStyles = false;
149
153 protected $localFileRefs = [];
154
159 protected $missingLocalFileRefs = [];
160
164 protected $vueComponentParser = null;
165
175 public function __construct(
176 array $options = [],
177 string $localBasePath = null,
178 string $remoteBasePath = null
179 ) {
180 // Flag to decide whether to automagically add the mediawiki.template module
181 $hasTemplates = false;
182 // localBasePath and remoteBasePath both have unbelievably long fallback chains
183 // and need to be handled separately.
184 list( $this->localBasePath, $this->remoteBasePath ) =
186
187 // Extract, validate and normalise remaining options
188 foreach ( $options as $member => $option ) {
189 switch ( $member ) {
190 // Lists of file paths
191 case 'scripts':
192 case 'debugScripts':
193 case 'styles':
194 case 'packageFiles':
195 $this->{$member} = is_array( $option ) ? $option : [ $option ];
196 break;
197 case 'templates':
198 $hasTemplates = true;
199 $this->{$member} = is_array( $option ) ? $option : [ $option ];
200 break;
201 // Collated lists of file paths
202 case 'languageScripts':
203 case 'skinScripts':
204 case 'skinStyles':
205 if ( !is_array( $option ) ) {
206 throw new InvalidArgumentException(
207 "Invalid collated file path list error. " .
208 "'$option' given, array expected."
209 );
210 }
211 foreach ( $option as $key => $value ) {
212 if ( !is_string( $key ) ) {
213 throw new InvalidArgumentException(
214 "Invalid collated file path list key error. " .
215 "'$key' given, string expected."
216 );
217 }
218 $this->{$member}[$key] = is_array( $value ) ? $value : [ $value ];
219 }
220 break;
221 case 'deprecated':
222 $this->deprecated = $option;
223 break;
224 // Lists of strings
225 case 'dependencies':
226 case 'messages':
227 case 'targets':
228 // Normalise
229 $option = array_values( array_unique( (array)$option ) );
230 sort( $option );
231
232 $this->{$member} = $option;
233 break;
234 // Single strings
235 case 'group':
236 case 'skipFunction':
237 $this->{$member} = (string)$option;
238 break;
239 // Single booleans
240 case 'debugRaw':
241 case 'noflip':
242 case 'es6':
243 $this->{$member} = (bool)$option;
244 break;
245 }
246 }
247 if ( isset( $options['scripts'] ) && isset( $options['packageFiles'] ) ) {
248 throw new InvalidArgumentException( "A module may not set both 'scripts' and 'packageFiles'" );
249 }
250 if ( isset( $options['packageFiles'] ) && isset( $options['skinScripts'] ) ) {
251 throw new InvalidArgumentException( "Options 'skinScripts' and 'packageFiles' cannot be used together." );
252 }
253 if ( $hasTemplates ) {
254 $this->dependencies[] = 'mediawiki.template';
255 // Ensure relevant template compiler module gets loaded
256 foreach ( $this->templates as $alias => $templatePath ) {
257 if ( is_int( $alias ) ) {
258 $alias = $this->getPath( $templatePath );
259 }
260 $suffix = explode( '.', $alias );
261 $suffix = end( $suffix );
262 $compilerModule = 'mediawiki.template.' . $suffix;
263 if ( $suffix !== 'html' && !in_array( $compilerModule, $this->dependencies ) ) {
264 $this->dependencies[] = $compilerModule;
265 }
266 }
267 }
268 }
269
281 public static function extractBasePaths(
282 array $options = [],
283 $localBasePath = null,
284 $remoteBasePath = null
285 ) {
286 global $IP;
287 // The different ways these checks are done, and their ordering, look very silly,
288 // but were preserved for backwards-compatibility just in case. Tread lightly.
289
290 if ( $localBasePath === null ) {
292 }
293 if ( $remoteBasePath === null ) {
296 }
297
298 if ( isset( $options['remoteExtPath'] ) ) {
299 $extensionAssetsPath = MediaWikiServices::getInstance()->getMainConfig()
301 $remoteBasePath = $extensionAssetsPath . '/' . $options['remoteExtPath'];
302 }
303
304 if ( isset( $options['remoteSkinPath'] ) ) {
305 $stylePath = MediaWikiServices::getInstance()->getMainConfig()
307 $remoteBasePath = $stylePath . '/' . $options['remoteSkinPath'];
308 }
309
310 if ( array_key_exists( 'localBasePath', $options ) ) {
311 $localBasePath = (string)$options['localBasePath'];
312 }
313
314 if ( array_key_exists( 'remoteBasePath', $options ) ) {
315 $remoteBasePath = (string)$options['remoteBasePath'];
316 }
317
318 if ( $remoteBasePath === '' ) {
319 // If MediaWiki is installed at the document root (not recommended),
320 // then wgScriptPath is set to the empty string by the installer to
321 // ensure safe concatenating of file paths (avoid "/" + "/foo" being "//foo").
322 // However, this also means the path itself can be an invalid URI path,
323 // as those must start with a slash. Within ResourceLoader, we will not
324 // do such primitive/unsafe slash concatenation and use URI resolution
325 // instead, so beyond this point, to avoid fatal errors in CSSMin::resolveUrl(),
326 // do a best-effort support for docroot installs by casting this to a slash.
327 $remoteBasePath = '/';
328 }
329
331 }
332
339 public function getScript( Context $context ) {
340 $deprecationScript = $this->getDeprecationInformation( $context );
341 $packageFiles = $this->getPackageFiles( $context );
342 if ( $packageFiles !== null ) {
343 foreach ( $packageFiles['files'] as &$file ) {
344 if ( $file['type'] === 'script+style' ) {
345 $file['content'] = $file['content']['script'];
346 $file['type'] = 'script';
347 }
348 }
349 if ( $deprecationScript ) {
350 $mainFile =& $packageFiles['files'][$packageFiles['main']];
351 $mainFile['content'] = $deprecationScript . $mainFile['content'];
352 }
353 return $packageFiles;
354 }
355
356 $files = $this->getScriptFiles( $context );
357 return $deprecationScript . $this->readScriptFiles( $files );
358 }
359
364 public function getScriptURLsForDebug( Context $context ) {
365 $rl = $context->getResourceLoader();
366 $config = $this->getConfig();
367 $server = $config->get( MainConfigNames::Server );
368
369 $urls = [];
370 foreach ( $this->getScriptFiles( $context ) as $file ) {
371 $url = OutputPage::transformResourcePath( $config, $this->getRemotePath( $file ) );
372 // Expand debug URL in case we are another wiki's module source (T255367)
373 $url = $rl->expandUrl( $server, $url );
374 $urls[] = $url;
375 }
376 return $urls;
377 }
378
382 public function supportsURLLoading() {
383 // If package files are involved, don't support URL loading, because that breaks
384 // scoped require() functions
385 return $this->debugRaw && !$this->packageFiles;
386 }
387
394 public function getStyles( Context $context ) {
395 $styles = $this->readStyleFiles(
396 $this->getStyleFiles( $context ),
397 $context
398 );
399
400 $packageFiles = $this->getPackageFiles( $context );
401 if ( $packageFiles !== null ) {
402 foreach ( $packageFiles['files'] as $fileName => $file ) {
403 if ( $file['type'] === 'script+style' ) {
404 $style = $this->processStyle(
405 $file['content']['style'],
406 $file['content']['styleLang'],
407 $fileName,
408 $context
409 );
410 $styles['all'] = ( $styles['all'] ?? '' ) . "\n" . $style;
411 }
412 }
413 }
414
415 // Track indirect file dependencies so that StartUpModule can check for
416 // on-disk file changes to any of this files without having to recompute the file list
417 $this->saveFileDependencies( $context, $this->localFileRefs );
418
419 return $styles;
420 }
421
426 public function getStyleURLsForDebug( Context $context ) {
427 if ( $this->hasGeneratedStyles ) {
428 // Do the default behaviour of returning a url back to load.php
429 // but with only=styles.
430 return parent::getStyleURLsForDebug( $context );
431 }
432 // Our module consists entirely of real css files,
433 // in debug mode we can load those directly.
434 $urls = [];
435 foreach ( $this->getStyleFiles( $context ) as $mediaType => $list ) {
436 $urls[$mediaType] = [];
437 foreach ( $list as $file ) {
438 $urls[$mediaType][] = OutputPage::transformResourcePath(
439 $this->getConfig(),
440 $this->getRemotePath( $file )
441 );
442 }
443 }
444 return $urls;
445 }
446
452 public function getMessages() {
453 return $this->messages;
454 }
455
461 public function getGroup() {
462 return $this->group;
463 }
464
471 public function getDependencies( Context $context = null ) {
472 return $this->dependencies;
473 }
474
482 private function getFileContents( $localPath, $type ) {
483 if ( !is_file( $localPath ) ) {
484 throw new RuntimeException( "$type file not found or not a file: \"$localPath\"" );
485 }
486 return $this->stripBom( file_get_contents( $localPath ) );
487 }
488
492 public function getSkipFunction() {
493 if ( !$this->skipFunction ) {
494 return null;
495 }
496 $localPath = $this->getLocalPath( $this->skipFunction );
497 return $this->getFileContents( $localPath, 'skip function' );
498 }
499
500 public function requiresES6() {
501 return $this->es6;
502 }
503
512 public function enableModuleContentVersion() {
513 return false;
514 }
515
522 private function getFileHashes( Context $context ) {
523 $files = [];
524
525 $styleFiles = $this->getStyleFiles( $context );
526 foreach ( $styleFiles as $paths ) {
527 $files = array_merge( $files, $paths );
528 }
529
530 // Extract file paths for package files
531 // Optimisation: Use foreach() and isset() instead of array_map/array_filter.
532 // This is a hot code path, called by StartupModule for thousands of modules.
533 $expandedPackageFiles = $this->expandPackageFiles( $context );
534 $packageFiles = [];
535 if ( $expandedPackageFiles ) {
536 foreach ( $expandedPackageFiles['files'] as $fileInfo ) {
537 if ( isset( $fileInfo['filePath'] ) ) {
538 $packageFiles[] = $fileInfo['filePath'];
539 }
540 }
541 }
542
543 // Merge all the file paths we were able discover directly from the module definition.
544 // This is the primary list of direct-dependent files for this module.
545 $files = array_merge(
546 $files,
548 $this->scripts,
549 $this->templates,
550 $context->getDebug() ? $this->debugScripts : [],
551 $this->getLanguageScripts( $context->getLanguage() ),
552 self::tryForKey( $this->skinScripts, $context->getSkin(), 'default' )
553 );
554 if ( $this->skipFunction ) {
555 $files[] = $this->skipFunction;
556 }
557
558 // Expand these local paths into absolute file paths
559 $files = array_map( [ $this, 'getLocalPath' ], $files );
560
561 // Add any lazily discovered file dependencies from previous module builds.
562 // These are added last because they are already absolute file paths.
563 $files = array_merge( $files, $this->getFileDependencies( $context ) );
564
565 // Filter out any duplicates. Typically introduced by getFileDependencies() which
566 // may lazily re-discover a primary file.
567 $files = array_unique( $files );
568
569 // Don't return array keys or any other form of file path here, only the hashes.
570 // Including file paths would needlessly cause global cache invalidation when files
571 // move on disk or if e.g. the MediaWiki directory name changes.
572 // Anything where order is significant is already detected by the definition summary.
574 }
575
582 public function getDefinitionSummary( Context $context ) {
583 $summary = parent::getDefinitionSummary( $context );
584
585 $options = [];
586 foreach ( [
587 // The following properties are omitted because they don't affect the module response:
588 // - localBasePath (Per T104950; Changes when absolute directory name changes. If
589 // this affects 'scripts' and other file paths, getFileHashes accounts for that.)
590 // - remoteBasePath (Per T104950)
591 // - dependencies (provided via startup module)
592 // - targets
593 // - group (provided via startup module)
594 'scripts',
595 'debugScripts',
596 'styles',
597 'languageScripts',
598 'skinScripts',
599 'skinStyles',
600 'messages',
601 'templates',
602 'skipFunction',
603 'debugRaw',
604 ] as $member ) {
605 $options[$member] = $this->{$member};
606 }
607
608 $packageFiles = $this->expandPackageFiles( $context );
609 if ( $packageFiles ) {
610 // Extract the minimum needed:
611 // - The 'main' pointer (included as-is).
612 // - The 'files' array, simplified to only which files exist (the keys of
613 // this array), and something that represents their non-file content.
614 // For packaged files that reflect files directly from disk, the
615 // 'getFileHashes' method tracks their content already.
616 // It is important that the keys of the $packageFiles['files'] array
617 // are preserved, as they do affect the module output.
618 $packageFiles['files'] = array_map( static function ( $fileInfo ) {
619 return $fileInfo['definitionSummary'] ?? ( $fileInfo['content'] ?? null );
620 }, $packageFiles['files'] );
621 }
622
623 $summary[] = [
624 'options' => $options,
625 'packageFiles' => $packageFiles,
626 'fileHashes' => $this->getFileHashes( $context ),
627 'messageBlob' => $this->getMessageBlob( $context ),
628 ];
629
630 $lessVars = $this->getLessVars( $context );
631 if ( $lessVars ) {
632 $summary[] = [ 'lessVars' => $lessVars ];
633 }
634
635 return $summary;
636 }
637
641 protected function getVueComponentParser() {
642 if ( $this->vueComponentParser === null ) {
643 $this->vueComponentParser = new VueComponentParser;
644 }
646 }
647
652 protected function getPath( $path ) {
653 if ( $path instanceof FilePath ) {
654 return $path->getPath();
655 }
656
657 return $path;
658 }
659
664 protected function getLocalPath( $path ) {
665 if ( $path instanceof FilePath ) {
666 if ( $path->getLocalBasePath() !== null ) {
667 return $path->getLocalPath();
668 }
669 $path = $path->getPath();
670 }
671
672 return "{$this->localBasePath}/$path";
673 }
674
679 protected function getRemotePath( $path ) {
680 if ( $path instanceof FilePath ) {
681 if ( $path->getRemoteBasePath() !== null ) {
682 return $path->getRemotePath();
683 }
684 $path = $path->getPath();
685 }
686
687 if ( $this->remoteBasePath === '/' ) {
688 return "/$path";
689 } else {
690 return "{$this->remoteBasePath}/$path";
691 }
692 }
693
701 public function getStyleSheetLang( $path ) {
702 return preg_match( '/\.less$/i', $path ) ? 'less' : 'css';
703 }
704
710 public static function getPackageFileType( $path ) {
711 if ( preg_match( '/\.json$/i', $path ) ) {
712 return 'data';
713 }
714 if ( preg_match( '/\.vue$/i', $path ) ) {
715 return 'script-vue';
716 }
717 return 'script';
718 }
719
727 private static function collateStyleFilesByMedia( array $list ) {
728 $collatedFiles = [];
729 foreach ( $list as $key => $value ) {
730 if ( is_int( $key ) ) {
731 // File name as the value
732 if ( !isset( $collatedFiles['all'] ) ) {
733 $collatedFiles['all'] = [];
734 }
735 $collatedFiles['all'][] = $value;
736 } elseif ( is_array( $value ) ) {
737 // File name as the key, options array as the value
738 $optionValue = $value['media'] ?? 'all';
739 if ( !isset( $collatedFiles[$optionValue] ) ) {
740 $collatedFiles[$optionValue] = [];
741 }
742 $collatedFiles[$optionValue][] = $key;
743 }
744 }
745 return $collatedFiles;
746 }
747
757 protected static function tryForKey( array $list, $key, $fallback = null ) {
758 if ( isset( $list[$key] ) && is_array( $list[$key] ) ) {
759 return $list[$key];
760 } elseif ( is_string( $fallback )
761 && isset( $list[$fallback] )
762 && is_array( $list[$fallback] )
763 ) {
764 return $list[$fallback];
765 }
766 return [];
767 }
768
775 private function getScriptFiles( Context $context ): array {
776 // List in execution order: scripts, languageScripts, skinScripts, debugScripts.
777 // Documented at MediaWiki\MainConfigSchema::ResourceModules.
778 $files = array_merge(
779 $this->scripts,
780 $this->getLanguageScripts( $context->getLanguage() ),
781 self::tryForKey( $this->skinScripts, $context->getSkin(), 'default' )
782 );
783 if ( $context->getDebug() ) {
784 $files = array_merge( $files, $this->debugScripts );
785 }
786
787 return array_unique( $files, SORT_REGULAR );
788 }
789
797 private function getLanguageScripts( string $lang ): array {
798 $scripts = self::tryForKey( $this->languageScripts, $lang );
799 if ( $scripts ) {
800 return $scripts;
801 }
802
803 // Optimization: Avoid initialising and calling into language services
804 // for the majority of modules that don't use this option.
805 if ( $this->languageScripts ) {
806 $fallbacks = MediaWikiServices::getInstance()
807 ->getLanguageFallback()
808 ->getAll( $lang, LanguageFallback::MESSAGES );
809 foreach ( $fallbacks as $lang ) {
810 $scripts = self::tryForKey( $this->languageScripts, $lang );
811 if ( $scripts ) {
812 return $scripts;
813 }
814 }
815 }
816
817 return [];
818 }
819
820 public function setSkinStylesOverride( array $moduleSkinStyles ): void {
821 $moduleName = $this->getName();
822 foreach ( $moduleSkinStyles as $skinName => $overrides ) {
823 // If a module provides overrides for a skin, and that skin also provides overrides
824 // for the same module, then the module has precedence.
825 if ( isset( $this->skinStyles[$skinName] ) ) {
826 continue;
827 }
828
829 // If $moduleName in ResourceModuleSkinStyles is preceded with a '+', the defined style
830 // files will be added to 'default' skinStyles, otherwise 'default' will be ignored.
831 if ( isset( $overrides[$moduleName] ) ) {
832 $paths = (array)$overrides[$moduleName];
833 $styleFiles = [];
834 } elseif ( isset( $overrides['+' . $moduleName] ) ) {
835 $paths = (array)$overrides['+' . $moduleName];
836 $styleFiles = isset( $this->skinStyles['default'] ) ?
837 (array)$this->skinStyles['default'] :
838 [];
839 } else {
840 continue;
841 }
842
843 // Add new file paths, remapping them to refer to our directories and not use settings
844 // from the module we're modifying, which come from the base definition.
845 list( $localBasePath, $remoteBasePath ) = self::extractBasePaths( $overrides );
846
847 foreach ( $paths as $path ) {
848 $styleFiles[] = new FilePath( $path, $localBasePath, $remoteBasePath );
849 }
850
851 $this->skinStyles[$skinName] = $styleFiles;
852 }
853 }
854
862 public function getStyleFiles( Context $context ) {
863 return array_merge_recursive(
864 self::collateStyleFilesByMedia( $this->styles ),
865 self::collateStyleFilesByMedia(
866 self::tryForKey( $this->skinStyles, $context->getSkin(), 'default' )
867 )
868 );
869 }
870
878 protected function getSkinStyleFiles( $skinName ) {
879 return self::collateStyleFilesByMedia(
880 self::tryForKey( $this->skinStyles, $skinName )
881 );
882 }
883
890 protected function getAllSkinStyleFiles() {
891 $skinFactory = MediaWikiServices::getInstance()->getSkinFactory();
892 $styleFiles = [];
893
894 $internalSkinNames = array_keys( $skinFactory->getInstalledSkins() );
895 $internalSkinNames[] = 'default';
896
897 foreach ( $internalSkinNames as $internalSkinName ) {
898 $styleFiles = array_merge_recursive(
899 $styleFiles,
900 $this->getSkinStyleFiles( $internalSkinName )
901 );
902 }
903
904 return $styleFiles;
905 }
906
912 public function getAllStyleFiles() {
913 $collatedStyleFiles = array_merge_recursive(
914 self::collateStyleFilesByMedia( $this->styles ),
915 $this->getAllSkinStyleFiles()
916 );
917
918 $result = [];
919
920 foreach ( $collatedStyleFiles as $media => $styleFiles ) {
921 foreach ( $styleFiles as $styleFile ) {
922 $result[] = $this->getLocalPath( $styleFile );
923 }
924 }
925
926 return $result;
927 }
928
935 private function readScriptFiles( array $scripts ) {
936 if ( !$scripts ) {
937 return '';
938 }
939 $js = '';
940 foreach ( array_unique( $scripts, SORT_REGULAR ) as $fileName ) {
941 $localPath = $this->getLocalPath( $fileName );
942 $contents = $this->getFileContents( $localPath, 'script' );
943 $js .= ResourceLoader::ensureNewline( $contents );
944 }
945 return $js;
946 }
947
956 public function readStyleFiles( array $styles, Context $context ) {
957 if ( !$styles ) {
958 return [];
959 }
960 foreach ( $styles as $media => $files ) {
961 $uniqueFiles = array_unique( $files, SORT_REGULAR );
962 $styleFiles = [];
963 foreach ( $uniqueFiles as $file ) {
964 $styleFiles[] = $this->readStyleFile( $file, $context );
965 }
966 $styles[$media] = implode( "\n", $styleFiles );
967 }
968 return $styles;
969 }
970
981 protected function readStyleFile( $path, Context $context ) {
982 $localPath = $this->getLocalPath( $path );
983 $style = $this->getFileContents( $localPath, 'style' );
984 $styleLang = $this->getStyleSheetLang( $localPath );
985
986 return $this->processStyle( $style, $styleLang, $path, $context );
987 }
988
1005 protected function processStyle( $style, $styleLang, $path, Context $context ) {
1006 $localPath = $this->getLocalPath( $path );
1007 $remotePath = $this->getRemotePath( $path );
1008
1009 if ( $styleLang === 'less' ) {
1010 $style = $this->compileLessString( $style, $localPath, $context );
1011 $this->hasGeneratedStyles = true;
1012 }
1013
1014 if ( $this->getFlip( $context ) ) {
1015 $style = CSSJanus::transform(
1016 $style,
1017 /* $swapLtrRtlInURL = */ true,
1018 /* $swapLeftRightInURL = */ false
1019 );
1020 }
1021
1022 $localDir = dirname( $localPath );
1023 $remoteDir = dirname( $remotePath );
1024 // Get and register local file references
1025 $localFileRefs = CSSMin::getLocalFileReferences( $style, $localDir );
1026 foreach ( $localFileRefs as $file ) {
1027 if ( is_file( $file ) ) {
1028 $this->localFileRefs[] = $file;
1029 } else {
1030 $this->missingLocalFileRefs[] = $file;
1031 }
1032 }
1033 // Don't cache this call. remap() ensures data URIs embeds are up to date,
1034 // and urls contain correct content hashes in their query string. (T128668)
1035 return CSSMin::remap( $style, $localDir, $remoteDir, true );
1036 }
1037
1043 public function getFlip( Context $context ) {
1044 return $context->getDirection() === 'rtl' && !$this->noflip;
1045 }
1046
1052 public function getTargets() {
1053 return $this->targets;
1054 }
1055
1062 public function getType() {
1063 $canBeStylesOnly = !(
1064 // All options except 'styles', 'skinStyles' and 'debugRaw'
1065 $this->scripts
1066 || $this->debugScripts
1067 || $this->templates
1068 || $this->languageScripts
1069 || $this->skinScripts
1070 || $this->dependencies
1071 || $this->messages
1072 || $this->skipFunction
1073 || $this->packageFiles
1074 );
1075 return $canBeStylesOnly ? self::LOAD_STYLES : self::LOAD_GENERAL;
1076 }
1077
1089 protected function compileLessString( $style, $stylePath, Context $context ) {
1090 static $cache;
1091 // @TODO: dependency injection
1092 if ( !$cache ) {
1093 $cache = ObjectCache::getLocalServerInstance( CACHE_ANYTHING );
1094 }
1095
1096 $skinName = $context->getSkin();
1097 $skinImportPaths = ExtensionRegistry::getInstance()->getAttribute( 'SkinLessImportPaths' );
1098 $importDirs = [];
1099 if ( isset( $skinImportPaths[ $skinName ] ) ) {
1100 $importDirs[] = $skinImportPaths[ $skinName ];
1101 }
1102
1103 $vars = $this->getLessVars( $context );
1104 // Construct a cache key from a hash of the LESS source, and a hash digest
1105 // of the LESS variables used for compilation.
1106 ksort( $vars );
1107 $compilerParams = [
1108 'vars' => $vars,
1109 'importDirs' => $importDirs,
1110 ];
1111 $key = $cache->makeGlobalKey(
1112 'resourceloader-less',
1113 'v1',
1114 hash( 'md4', $style ),
1115 hash( 'md4', serialize( $compilerParams ) )
1116 );
1117
1118 // If we got a cached value, we have to validate it by getting a checksum of all the
1119 // files that were loaded by the parser and ensuring it matches the cached entry's.
1120 $data = $cache->get( $key );
1121 if (
1122 !$data ||
1123 $data['hash'] !== FileContentsHasher::getFileContentsHash( $data['files'] )
1124 ) {
1125 $compiler = $context->getResourceLoader()->getLessCompiler( $vars, $importDirs );
1126
1127 $css = $compiler->parse( $style, $stylePath )->getCss();
1128 // T253055: store the implicit dependency paths in a form relative to any install
1129 // path so that multiple version of the application can share the cache for identical
1130 // less stylesheets. This also avoids churn during application updates.
1131 $files = $compiler->AllParsedFiles();
1132 $data = [
1133 'css' => $css,
1134 'files' => Module::getRelativePaths( $files ),
1135 'hash' => FileContentsHasher::getFileContentsHash( $files )
1136 ];
1137 $cache->set( $key, $data, $cache::TTL_DAY );
1138 }
1139
1140 foreach ( Module::expandRelativePaths( $data['files'] ) as $path ) {
1141 $this->localFileRefs[] = $path;
1142 }
1143
1144 return $data['css'];
1145 }
1146
1152 public function getTemplates() {
1153 $templates = [];
1154
1155 foreach ( $this->templates as $alias => $templatePath ) {
1156 // Alias is optional
1157 if ( is_int( $alias ) ) {
1158 $alias = $this->getPath( $templatePath );
1159 }
1160 $localPath = $this->getLocalPath( $templatePath );
1161 $content = $this->getFileContents( $localPath, 'template' );
1162
1163 $templates[$alias] = $this->stripBom( $content );
1164 }
1165 return $templates;
1166 }
1167
1186 private function expandPackageFiles( Context $context ) {
1187 $hash = $context->getHash();
1188 if ( isset( $this->expandedPackageFiles[$hash] ) ) {
1189 return $this->expandedPackageFiles[$hash];
1190 }
1191 if ( $this->packageFiles === null ) {
1192 return null;
1193 }
1194 $expandedFiles = [];
1195 $mainFile = null;
1196
1197 foreach ( $this->packageFiles as $key => $fileInfo ) {
1198 if ( !is_array( $fileInfo ) ) {
1199 $fileInfo = [ 'name' => $fileInfo, 'file' => $fileInfo ];
1200 }
1201 if ( !isset( $fileInfo['name'] ) ) {
1202 $msg = "Missing 'name' key in package file info for module '{$this->getName()}'," .
1203 " offset '{$key}'.";
1204 $this->getLogger()->error( $msg );
1205 throw new LogicException( $msg );
1206 }
1207 $fileName = $this->getPath( $fileInfo['name'] );
1208
1209 // Infer type from alias if needed
1210 $type = $fileInfo['type'] ?? self::getPackageFileType( $fileName );
1211 $expanded = [ 'type' => $type ];
1212 if ( !empty( $fileInfo['main'] ) ) {
1213 $mainFile = $fileName;
1214 if ( $type !== 'script' && $type !== 'script-vue' ) {
1215 $msg = "Main file in package must be of type 'script', module " .
1216 "'{$this->getName()}', main file '{$mainFile}' is '{$type}'.";
1217 $this->getLogger()->error( $msg );
1218 throw new LogicException( $msg );
1219 }
1220 }
1221
1222 // Perform expansions (except 'file' and 'callback'), creating one of these keys:
1223 // - 'content': literal value.
1224 // - 'filePath': content to be read from a file.
1225 // - 'callback': content computed by a callable.
1226 if ( isset( $fileInfo['content'] ) ) {
1227 $expanded['content'] = $fileInfo['content'];
1228 } elseif ( isset( $fileInfo['file'] ) ) {
1229 $expanded['filePath'] = $fileInfo['file'];
1230 } elseif ( isset( $fileInfo['callback'] ) ) {
1231 // If no extra parameter for the callback is given, use null.
1232 $expanded['callbackParam'] = $fileInfo['callbackParam'] ?? null;
1233
1234 if ( !is_callable( $fileInfo['callback'] ) ) {
1235 $msg = "Invalid 'callback' for module '{$this->getName()}', file '{$fileName}'.";
1236 $this->getLogger()->error( $msg );
1237 throw new LogicException( $msg );
1238 }
1239 if ( isset( $fileInfo['versionCallback'] ) ) {
1240 if ( !is_callable( $fileInfo['versionCallback'] ) ) {
1241 throw new LogicException( "Invalid 'versionCallback' for "
1242 . "module '{$this->getName()}', file '{$fileName}'."
1243 );
1244 }
1245
1246 // Execute the versionCallback with the same arguments that
1247 // would be given to the callback
1248 $callbackResult = ( $fileInfo['versionCallback'] )(
1249 $context,
1250 $this->getConfig(),
1251 $expanded['callbackParam']
1252 );
1253 if ( $callbackResult instanceof FilePath ) {
1254 $expanded['filePath'] = $callbackResult;
1255 } else {
1256 $expanded['definitionSummary'] = $callbackResult;
1257 }
1258 // Don't invoke 'callback' here as it may be expensive (T223260).
1259 $expanded['callback'] = $fileInfo['callback'];
1260 } else {
1261 // Else go ahead invoke callback with its arguments.
1262 $callbackResult = ( $fileInfo['callback'] )(
1263 $context,
1264 $this->getConfig(),
1265 $expanded['callbackParam']
1266 );
1267 if ( $callbackResult instanceof FilePath ) {
1268 $expanded['filePath'] = $callbackResult;
1269 } else {
1270 $expanded['content'] = $callbackResult;
1271 }
1272 }
1273 } elseif ( isset( $fileInfo['config'] ) ) {
1274 if ( $type !== 'data' ) {
1275 $msg = "Key 'config' only valid for data files. "
1276 . " Module '{$this->getName()}', file '{$fileName}' is '{$type}'.";
1277 $this->getLogger()->error( $msg );
1278 throw new LogicException( $msg );
1279 }
1280 $expandedConfig = [];
1281 foreach ( $fileInfo['config'] as $configKey => $var ) {
1282 $expandedConfig[ is_numeric( $configKey ) ? $var : $configKey ] = $this->getConfig()->get( $var );
1283 }
1284 $expanded['content'] = $expandedConfig;
1285 } elseif ( !empty( $fileInfo['main'] ) ) {
1286 // [ 'name' => 'foo.js', 'main' => true ] is shorthand
1287 $expanded['filePath'] = $fileName;
1288 } else {
1289 $msg = "Incomplete definition for module '{$this->getName()}', file '{$fileName}'. "
1290 . "One of 'file', 'content', 'callback', or 'config' must be set.";
1291 $this->getLogger()->error( $msg );
1292 throw new LogicException( $msg );
1293 }
1294
1295 $expandedFiles[$fileName] = $expanded;
1296 }
1297
1298 if ( $expandedFiles && $mainFile === null ) {
1299 // The first package file that is a script is the main file
1300 foreach ( $expandedFiles as $path => $file ) {
1301 if ( $file['type'] === 'script' || $file['type'] === 'script-vue' ) {
1302 $mainFile = $path;
1303 break;
1304 }
1305 }
1306 }
1307
1308 $result = [
1309 'main' => $mainFile,
1310 'files' => $expandedFiles
1311 ];
1312
1313 $this->expandedPackageFiles[$hash] = $result;
1314 return $result;
1315 }
1316
1323 public function getPackageFiles( Context $context ) {
1324 if ( $this->packageFiles === null ) {
1325 return null;
1326 }
1327 $hash = $context->getHash();
1328 if ( isset( $this->fullyExpandedPackageFiles[ $hash ] ) ) {
1329 return $this->fullyExpandedPackageFiles[ $hash ];
1330 }
1331 $expandedPackageFiles = $this->expandPackageFiles( $context );
1332
1333 // Expand file contents
1334 foreach ( $expandedPackageFiles['files'] as $fileName => &$fileInfo ) {
1335 // Turn any 'filePath' or 'callback' key into actual 'content',
1336 // and remove the key after that. The callback could return a
1337 // ResourceLoaderFilePath object; if that happens, fall through
1338 // to the 'filePath' handling.
1339 if ( isset( $fileInfo['callback'] ) ) {
1340 $callbackResult = ( $fileInfo['callback'] )(
1341 $context,
1342 $this->getConfig(),
1343 $fileInfo['callbackParam']
1344 );
1345 if ( $callbackResult instanceof FilePath ) {
1346 // Fall through to the filePath handling code below
1347 $fileInfo['filePath'] = $callbackResult;
1348 } else {
1349 $fileInfo['content'] = $callbackResult;
1350 }
1351 unset( $fileInfo['callback'] );
1352 }
1353 // Only interpret 'filePath' if 'content' hasn't been set already.
1354 // This can happen if 'versionCallback' provided 'filePath',
1355 // while 'callback' provides 'content'. In that case both are set
1356 // at this point. The 'filePath' from 'versionCallback' in that case is
1357 // only to inform getDefinitionSummary().
1358 if ( !isset( $fileInfo['content'] ) && isset( $fileInfo['filePath'] ) ) {
1359 $localPath = $this->getLocalPath( $fileInfo['filePath'] );
1360 $content = $this->getFileContents( $localPath, 'package' );
1361 if ( $fileInfo['type'] === 'data' ) {
1362 $content = json_decode( $content );
1363 }
1364 $fileInfo['content'] = $content;
1365 unset( $fileInfo['filePath'] );
1366 }
1367 if ( $fileInfo['type'] === 'script-vue' ) {
1368 try {
1369 $parsedComponent = $this->getVueComponentParser()->parse(
1370 // @phan-suppress-next-line PhanTypePossiblyInvalidDimOffset False positive
1371 $fileInfo['content'],
1372 [ 'minifyTemplate' => !$context->getDebug() ]
1373 );
1374 } catch ( TimeoutException $e ) {
1375 throw $e;
1376 } catch ( Exception $e ) {
1377 $msg = "Error parsing file '$fileName' in module '{$this->getName()}': " .
1378 $e->getMessage();
1379 $this->getLogger()->error( $msg );
1380 throw new RuntimeException( $msg );
1381 }
1382 $encodedTemplate = json_encode( $parsedComponent['template'] );
1383 if ( $context->getDebug() ) {
1384 // Replace \n (backslash-n) with space + backslash-newline in debug mode
1385 // We only replace \n if not preceded by a backslash, to avoid breaking '\\n'
1386 $encodedTemplate = preg_replace( '/(?<!\\\\)\\\\n/', " \\\n", $encodedTemplate );
1387 // Expand \t to real tabs in debug mode
1388 $encodedTemplate = strtr( $encodedTemplate, [ "\\t" => "\t" ] );
1389 }
1390 $fileInfo['content'] = [
1391 'script' => $parsedComponent['script'] .
1392 ";\nmodule.exports.template = $encodedTemplate;",
1393 'style' => $parsedComponent['style'] ?? '',
1394 'styleLang' => $parsedComponent['styleLang'] ?? 'css'
1395 ];
1396 $fileInfo['type'] = 'script+style';
1397 }
1398
1399 // Not needed for client response, exists for use by getDefinitionSummary().
1400 unset( $fileInfo['definitionSummary'] );
1401 // Not needed for client response, used by callbacks only.
1402 unset( $fileInfo['callbackParam'] );
1403 }
1404
1405 $this->fullyExpandedPackageFiles[ $hash ] = $expandedPackageFiles;
1406 return $expandedPackageFiles;
1407 }
1408
1419 protected function stripBom( $input ) {
1420 if ( str_starts_with( $input, "\xef\xbb\xbf" ) ) {
1421 return substr( $input, 3 );
1422 }
1423 return $input;
1424 }
1425}
1426
1428class_alias( FileModule::class, 'ResourceLoaderFileModule' );
serialize()
const CACHE_ANYTHING
Definition Defines.php:85
$fallback
if(!defined( 'MEDIAWIKI')) if(ini_get('mbstring.func_overload')) if(!defined( 'MW_ENTRY_POINT')) global $IP
Environment checks.
Definition Setup.php:91
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:442
Module based on local JavaScript/CSS files.
getDependencies(Context $context=null)
Get names of modules this module depends on.
array< string, array< int, string|FilePath > > $skinScripts
Lists of JavaScript files by skin name.
getScriptURLsForDebug(Context $context)
static tryForKey(array $list, $key, $fallback=null)
Get a list of element that match a key, optionally using a fallback key.
getAllSkinStyleFiles()
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.".
$cache
Definition mcc.php:33
$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