MediaWiki master
FileModule.php
Go to the documentation of this file.
1<?php
10
11use CSSJanus;
12use InvalidArgumentException;
13use LogicException;
20use RuntimeException;
21use Wikimedia\Minify\CSSMin;
22
23// Per https://phabricator.wikimedia.org/T241091
24// phpcs:disable MediaWiki.Commenting.FunctionAnnotations.UnrecognizedAnnotation
25
39class FileModule extends Module {
41 protected $localBasePath = '';
42
44 protected $remoteBasePath = '';
45
49 protected $scripts = [];
50
54 protected $languageScripts = [];
55
59 protected $skinScripts = [];
60
64 protected $debugScripts = [];
65
69 protected $styles = [];
70
74 protected $skinStyles = [];
75
83 protected $packageFiles = null;
84
89 private $expandedPackageFiles = [];
90
95 private $fullyExpandedPackageFiles = [];
96
100 protected $dependencies = [];
101
105 protected $skipFunction = null;
106
110 protected $messages = [];
111
113 protected $templates = [];
114
116 protected $group = null;
117
119 protected $debugRaw = true;
120
122 protected $noflip = false;
123
125 protected $skipStructureTest = false;
126
131 protected $hasGeneratedStyles = false;
132
136 protected $localFileRefs = [];
137
142 protected $missingLocalFileRefs = [];
143
145 protected array $lessMessages = [];
146
156 public function __construct(
157 array $options = [],
158 ?string $localBasePath = null,
159 ?string $remoteBasePath = null
160 ) {
161 // Flag to decide whether to automagically add the mediawiki.template module
162 $hasTemplates = false;
163 // localBasePath and remoteBasePath both have unbelievably long fallback chains
164 // and need to be handled separately.
167
168 // Extract, validate and normalise remaining options
169 foreach ( $options as $member => $option ) {
170 switch ( $member ) {
171 // Lists of file paths
172 case 'scripts':
173 case 'debugScripts':
174 case 'styles':
175 case 'packageFiles':
176 $this->{$member} = is_array( $option ) ? $option : [ $option ];
177 break;
178 case 'templates':
179 $hasTemplates = true;
180 $this->{$member} = is_array( $option ) ? $option : [ $option ];
181 break;
182 // Collated lists of file paths
183 case 'languageScripts':
184 case 'skinScripts':
185 case 'skinStyles':
186 if ( !is_array( $option ) ) {
187 throw new InvalidArgumentException(
188 "Invalid collated file path list error. " .
189 "'$option' given, array expected."
190 );
191 }
192 foreach ( $option as $key => $value ) {
193 if ( !is_string( $key ) ) {
194 throw new InvalidArgumentException(
195 "Invalid collated file path list key error. " .
196 "'$key' given, string expected."
197 );
198 }
199 $this->{$member}[$key] = is_array( $value ) ? $value : [ $value ];
200 }
201 break;
202 case 'deprecated':
203 $this->deprecated = $option;
204 break;
205 // Lists of strings
206 case 'dependencies':
207 case 'messages':
208 case 'lessMessages':
209 // Normalise
210 $option = array_values( array_unique( (array)$option ) );
211 sort( $option );
212
213 $this->{$member} = $option;
214 break;
215 // Single strings
216 case 'group':
217 case 'skipFunction':
218 $this->{$member} = (string)$option;
219 break;
220 // Single booleans
221 case 'debugRaw':
222 case 'noflip':
223 case 'skipStructureTest':
224 $this->{$member} = (bool)$option;
225 break;
226 }
227 }
228 if ( isset( $options['scripts'] ) && isset( $options['packageFiles'] ) ) {
229 throw new InvalidArgumentException( "A module may not set both 'scripts' and 'packageFiles'" );
230 }
231 if ( isset( $options['packageFiles'] ) && isset( $options['skinScripts'] ) ) {
232 throw new InvalidArgumentException( "Options 'skinScripts' and 'packageFiles' cannot be used together." );
233 }
234 if ( $hasTemplates ) {
235 $this->dependencies[] = 'mediawiki.template';
236 // Ensure relevant template compiler module gets loaded
237 foreach ( $this->templates as $alias => $templatePath ) {
238 if ( is_int( $alias ) ) {
239 $alias = $this->getPath( $templatePath );
240 }
241 $suffix = explode( '.', $alias );
242 $suffix = end( $suffix );
243 $compilerModule = 'mediawiki.template.' . $suffix;
244 if ( $suffix !== 'html' && !in_array( $compilerModule, $this->dependencies ) ) {
245 $this->dependencies[] = $compilerModule;
246 }
247 }
248 }
249 }
250
262 public static function extractBasePaths(
263 array $options = [],
264 $localBasePath = null,
265 $remoteBasePath = null
266 ) {
267 // The different ways these checks are done, and their ordering, look very silly,
268 // but were preserved for backwards-compatibility just in case. Tread lightly.
269
272
273 if ( isset( $options['remoteExtPath'] ) ) {
274 $extensionAssetsPath = MediaWikiServices::getInstance()->getMainConfig()
276 $remoteBasePath = $extensionAssetsPath . '/' . $options['remoteExtPath'];
277 }
278
279 if ( isset( $options['remoteSkinPath'] ) ) {
280 $stylePath = MediaWikiServices::getInstance()->getMainConfig()
282 $remoteBasePath = $stylePath . '/' . $options['remoteSkinPath'];
283 }
284
285 if ( array_key_exists( 'localBasePath', $options ) ) {
286 $localBasePath = (string)$options['localBasePath'];
287 }
288
289 if ( array_key_exists( 'remoteBasePath', $options ) ) {
290 $remoteBasePath = (string)$options['remoteBasePath'];
291 }
292
293 if ( $localBasePath === null ) {
294 $localBasePath = MW_INSTALL_PATH;
295 }
296
297 if ( $remoteBasePath === '' ) {
298 // If MediaWiki is installed at the document root (not recommended),
299 // then wgScriptPath is set to the empty string by the installer to
300 // ensure safe concatenating of file paths (avoid "/" + "/foo" being "//foo").
301 // However, this also means the path itself can be an invalid URI path,
302 // as those must start with a slash. Within ResourceLoader, we will not
303 // do such primitive/unsafe slash concatenation and use URI resolution
304 // instead, so beyond this point, to avoid fatal errors in CSSMin::resolveUrl(),
305 // do a best-effort support for docroot installs by casting this to a slash.
306 $remoteBasePath = '/';
307 }
308
310 }
311
313 public function getScript( Context $context ) {
314 $packageFiles = $this->getPackageFiles( $context );
315 if ( $packageFiles !== null ) {
316 // T402278: use array_map() to avoid &references here
317 $packageFiles['files'] = array_map(
318 static function ( array $file ): array {
319 if ( $file['type'] === 'script+style' ) {
320 $file['content'] = $file['content']['script'];
321 $file['type'] = 'script';
322 }
323 return $file;
324 },
325 $packageFiles['files']
326 );
327 return $packageFiles;
328 }
329
330 $files = $this->getScriptFiles( $context );
331 // T402278: use array_map() to avoid &references here
332 $files = array_map(
333 fn ( $file ) => $this->readFileInfo( $context, $file ),
334 $files
335 );
336 return [ 'plainScripts' => $files ];
337 }
338
342 public function supportsURLLoading() {
343 // phpcs:ignore Generic.WhiteSpace.LanguageConstructSpacing.IncorrectSingle
344 return
345 // Denied by options?
346 $this->debugRaw
347 // If package files are involved, don't support URL loading, because that breaks
348 // scoped require() functions
349 && !$this->packageFiles
350 // Can't link to scripts generated by callbacks
351 && !$this->hasGeneratedScripts();
352 }
353
355 public function shouldSkipStructureTest() {
356 return $this->skipStructureTest || parent::shouldSkipStructureTest();
357 }
358
364 private function hasGeneratedScripts() {
365 foreach (
366 [ $this->scripts, $this->languageScripts, $this->skinScripts, $this->debugScripts ]
367 as $scripts
368 ) {
369 foreach ( $scripts as $script ) {
370 if ( is_array( $script ) ) {
371 if ( isset( $script['callback'] ) || isset( $script['versionCallback'] ) ) {
372 return true;
373 }
374 }
375 }
376 }
377 return false;
378 }
379
386 public function getStyles( Context $context ) {
387 $styles = $this->readStyleFiles(
388 $this->getStyleFiles( $context ),
389 $context
390 );
391
392 $packageFiles = $this->getPackageFiles( $context );
393 if ( $packageFiles !== null ) {
394 foreach ( $packageFiles['files'] as $fileName => $file ) {
395 if ( $file['type'] === 'script+style' ) {
396 $style = $this->processStyle(
397 $file['content']['style'],
398 $file['content']['styleLang'],
399 $fileName,
400 $context
401 );
402 $styles['all'] = ( $styles['all'] ?? '' ) . "\n" . $style;
403 }
404 }
405 }
406
407 // Track indirect file dependencies so that StartUpModule can check for
408 // on-disk file changes to any of this files without having to recompute the file list
409 $this->saveFileDependencies( $context, $this->localFileRefs );
410
411 return $styles;
412 }
413
418 public function getStyleURLsForDebug( Context $context ) {
419 if ( $this->hasGeneratedStyles ) {
420 // Do the default behaviour of returning a url back to load.php
421 // but with only=styles.
422 return parent::getStyleURLsForDebug( $context );
423 }
424 // Our module consists entirely of real css files,
425 // in debug mode we can load those directly.
426 $urls = [];
427 foreach ( $this->getStyleFiles( $context ) as $mediaType => $list ) {
428 $urls[$mediaType] = [];
429 foreach ( $list as $file ) {
430 $urls[$mediaType][] = OutputPage::transformResourcePath(
431 $this->getConfig(),
432 $this->getRemotePath( $file )
433 );
434 }
435 }
436 return $urls;
437 }
438
444 public function getMessages() {
445 return array_merge( $this->messages, $this->lessMessages );
446 }
447
455 private function pluckFromMessageBlob( $blob, array $allowed ): array {
456 $data = $blob ? json_decode( $blob, true ) : [];
457 // Keep only the messages intended for script or Less export
458 // (opposite of getMessages essentially).
459 return array_intersect_key( $data, array_fill_keys( $allowed, true ) );
460 }
461
465 protected function getMessageBlob( Context $context ) {
466 $blob = parent::getMessageBlob( $context );
467 if ( !$blob ) {
468 // If module has no blob, preserve null to avoid needless WAN cache allocation
469 // client output for modules without messages.
470 return $blob;
471 }
472
473 // T409619: Support for lessMessages should not break getMessages subclassing
474 //
475 // Avoid array_diff because it removes all matches instead of just one,
476 // whereas we allow a getMessage() subclass to add the same message in lessMessages.
477 $reducedMessages = $this->getMessages();
478 foreach ( $this->lessMessages as $messageKey ) {
479 $i = array_search( $messageKey, $reducedMessages );
480 if ( $i !== false ) {
481 unset( $reducedMessages[$i] );
482 }
483 }
484 return json_encode( (object)$this->pluckFromMessageBlob( $blob, $reducedMessages ) );
485 }
486
487 // phpcs:disable MediaWiki.Commenting.DocComment.SpacingDocTag, Squiz.WhiteSpace.FunctionSpacing.Before
508 private static function wrapAndEscapeMessage( $msg ) {
509 return str_replace( "'", "\'", CSSMin::serializeStringValue( $msg ) );
510 }
511
512 // phpcs:enable
513
520 protected function getLessVars( Context $context ) {
521 $vars = parent::getLessVars( $context );
522
523 if ( $this->lessMessages ) {
524 $blob = parent::getMessageBlob( $context );
525 $messages = $this->pluckFromMessageBlob( $blob, $this->lessMessages );
526
527 // It is important that we iterate the declared list from $this->lessMessages,
528 // and not $messages since in the case of undefined messages, the key is
529 // omitted entirely from the blob. This emits a log warning for developers,
530 // but we must still carry on and produce a valid LESS variable declaration,
531 // to avoid a LESS syntax error (T267785).
532 foreach ( $this->lessMessages as $msgKey ) {
533 $vars['msg-' . $msgKey] = self::wrapAndEscapeMessage( $messages[$msgKey] ?? "â§¼{$msgKey}â§½" );
534 }
535 }
536
537 return $vars;
538 }
539
545 public function getGroup() {
546 return $this->group;
547 }
548
555 public function getDependencies( ?Context $context = null ) {
556 return $this->dependencies;
557 }
558
566 private function getFileContents( $localPath, $type ) {
567 if ( !is_file( $localPath ) ) {
568 throw new RuntimeException( "$type file not found or not a file: \"$localPath\"" );
569 }
570 return $this->stripBom( file_get_contents( $localPath ) );
571 }
572
576 public function getSkipFunction() {
577 if ( !$this->skipFunction ) {
578 return null;
579 }
580 $localPath = $this->getLocalPath( $this->skipFunction );
581 return $this->getFileContents( $localPath, 'skip function' );
582 }
583
585 public function requiresES6() {
586 return true;
587 }
588
597 public function enableModuleContentVersion() {
598 return false;
599 }
600
607 private function getFileHashes( Context $context ) {
608 $files = [];
609
610 foreach ( $this->getStyleFiles( $context ) as $filePaths ) {
611 foreach ( $filePaths as $filePath ) {
612 $files[] = $this->getLocalPath( $filePath );
613 }
614 }
615
616 // Extract file paths for package files
617 // Optimisation: Use foreach() and isset() instead of array_map/array_filter.
618 // This is a hot code path, called by StartupModule for thousands of modules.
619 $expandedPackageFiles = $this->expandPackageFiles( $context );
620 if ( $expandedPackageFiles ) {
621 foreach ( $expandedPackageFiles['files'] as $fileInfo ) {
622 $filePath = $fileInfo['filePath'] ?? $fileInfo['versionFilePath'] ?? null;
623 if ( $filePath instanceof FilePath ) {
624 $files[] = $filePath->getLocalPath();
625 }
626 }
627 }
628
629 // Add other configured paths
630 $scriptFileInfos = $this->getScriptFiles( $context );
631 foreach ( $scriptFileInfos as $fileInfo ) {
632 $filePath = $fileInfo['filePath'] ?? $fileInfo['versionFilePath'] ?? null;
633 if ( $filePath instanceof FilePath ) {
634 $files[] = $filePath->getLocalPath();
635 }
636 }
637
638 foreach ( $this->templates as $filePath ) {
639 $files[] = $this->getLocalPath( $filePath );
640 }
641
642 if ( $this->skipFunction ) {
643 $files[] = $this->getLocalPath( $this->skipFunction );
644 }
645
646 // Add any lazily discovered file dependencies from previous module builds.
647 // These are saved as relative paths.
648 foreach ( Module::expandRelativePaths( $this->getFileDependencies( $context ) ) as $file ) {
649 $files[] = $file;
650 }
651
652 // Filter out any duplicates. Typically introduced by getFileDependencies() which
653 // may lazily re-discover a primary file.
654 $files = array_unique( $files );
655
656 // Don't return array keys or any other form of file path here, only the hashes.
657 // Including file paths would needlessly cause global cache invalidation when files
658 // move on disk or if e.g. the MediaWiki directory name changes.
659 // Anything where order is significant is already detected by the definition summary.
660 return FileContentsHasher::getFileContentsHash( $files );
661 }
662
669 public function getDefinitionSummary( Context $context ) {
670 $summary = parent::getDefinitionSummary( $context );
671
672 $options = [];
673 foreach ( [
674 // The following properties are omitted because they don't affect the module response:
675 // - localBasePath (Per T104950; Changes when absolute directory name changes. If
676 // this affects 'scripts' and other file paths, getFileHashes accounts for that.)
677 // - remoteBasePath (Per T104950)
678 // - dependencies (provided via startup module)
679 // - group (provided via startup module)
680 'styles',
681 'skinStyles',
682 'messages',
683 'templates',
684 'skipFunction',
685 'debugRaw',
686 ] as $member ) {
687 $options[$member] = $this->{$member};
688 }
689
690 $packageFiles = $this->expandPackageFiles( $context );
691 $packageSummaries = [];
692 $packageMain = null;
693 if ( $packageFiles ) {
694 // Extract the minimum needed:
695 // - The 'main' pointer (included as-is).
696 // - The 'files' array, simplified to only which files exist (the keys of
697 // this array), and something that represents their non-file content.
698 // For packaged files that reflect files directly from disk, the
699 // 'getFileHashes' method tracks their content already.
700 // It is important that the keys of the $packageFiles['files'] array
701 // are preserved, as they do affect the module output.
702 $packageMain = $packageFiles['main'];
703 foreach ( $packageFiles['files'] as $fileName => $fileInfo ) {
704 $packageSummaries[$fileName] =
705 $fileInfo['definitionSummary'] ?? $fileInfo['content'] ?? null;
706 }
707 }
708
709 $scriptFiles = $this->getScriptFiles( $context );
710 $scriptSummaries = [];
711 foreach ( $scriptFiles as $fileName => $fileInfo ) {
712 $scriptSummaries[$fileName] =
713 $fileInfo['definitionSummary'] ?? $fileInfo['content'] ?? null;
714 }
715
716 $summary[] = [
717 'options' => $options,
718 'packageFiles' => $packageSummaries,
719 'packageMain' => $packageMain,
720 'scripts' => $scriptSummaries,
721 'fileHashes' => $this->getFileHashes( $context ),
722 'messageBlob' => $this->getMessageBlob( $context ),
723 ];
724
725 $lessVars = $this->getLessVars( $context );
726 if ( $lessVars ) {
727 $summary[] = [ 'lessVars' => $lessVars ];
728 }
729
730 return $summary;
731 }
732
737 protected function getPath( $path ) {
738 if ( $path instanceof FilePath ) {
739 return $path->getPath();
740 }
741
742 return $path;
743 }
744
749 protected function getLocalPath( $path ) {
750 if ( $path instanceof FilePath ) {
751 if ( $path->getLocalBasePath() !== null ) {
752 return $path->getLocalPath();
753 }
754 $path = $path->getPath();
755 }
756
757 return "{$this->localBasePath}/$path";
758 }
759
764 protected function getRemotePath( $path ) {
765 if ( $path instanceof FilePath ) {
766 if ( $path->getRemoteBasePath() !== null ) {
767 return $path->getRemotePath();
768 }
769 $path = $path->getPath();
770 }
771
772 if ( $this->remoteBasePath === '/' ) {
773 return "/$path";
774 } else {
775 return "{$this->remoteBasePath}/$path";
776 }
777 }
778
786 public function getStyleSheetLang( $path ) {
787 return preg_match( '/\.less$/i', $path ) ? 'less' : 'css';
788 }
789
796 public static function getPackageFileType( $path ) {
797 if ( preg_match( '/\.json$/i', $path ) ) {
798 return 'data';
799 }
800 if ( preg_match( '/\.vue$/i', $path ) ) {
801 return 'script-vue';
802 }
803 return 'script';
804 }
805
813 private static function collateStyleFilesByMedia( array $list ) {
814 $collatedFiles = [];
815 foreach ( $list as $key => $value ) {
816 if ( is_int( $key ) ) {
817 // File name as the value
818 $collatedFiles['all'][] = $value;
819 } elseif ( is_array( $value ) ) {
820 // File name as the key, options array as the value
821 $optionValue = $value['media'] ?? 'all';
822 $collatedFiles[$optionValue][] = $key;
823 }
824 }
825 return $collatedFiles;
826 }
827
837 protected static function tryForKey( array $list, $key, $fallback = null ) {
838 if ( isset( $list[$key] ) && is_array( $list[$key] ) ) {
839 return $list[$key];
840 } elseif ( is_string( $fallback )
841 && isset( $list[$fallback] )
842 && is_array( $list[$fallback] )
843 ) {
844 return $list[$fallback];
845 }
846 return [];
847 }
848
855 private function getScriptFiles( Context $context ): array {
856 // List in execution order: scripts, languageScripts, skinScripts, debugScripts.
857 // Documented at MediaWiki\MainConfigSchema::ResourceModules.
858 $filesByCategory = [
859 'scripts' => $this->scripts,
860 'languageScripts' => $this->getLanguageScripts( $context->getLanguage() ),
861 'skinScripts' => self::tryForKey( $this->skinScripts, $context->getSkin(), 'default' ),
862 ];
863 if ( $context->getDebug() ) {
864 $filesByCategory['debugScripts'] = $this->debugScripts;
865 }
866
867 $expandedFiles = [];
868 foreach ( $filesByCategory as $category => $files ) {
869 foreach ( $files as $key => $fileInfo ) {
870 $expandedFileInfo = $this->expandFileInfo( $context, $fileInfo, "$category\[$key]" );
871 $expandedFiles[$expandedFileInfo['name']] = $expandedFileInfo;
872 }
873 }
874
875 return $expandedFiles;
876 }
877
885 private function getLanguageScripts( string $lang ): array {
886 $scripts = self::tryForKey( $this->languageScripts, $lang );
887 if ( $scripts ) {
888 return $scripts;
889 }
890
891 // Optimization: Avoid initialising and calling into language services
892 // for the majority of modules that don't use this option.
893 if ( $this->languageScripts ) {
894 $fallbacks = MediaWikiServices::getInstance()
895 ->getLanguageFallback()
896 ->getAll( $lang, LanguageFallbackMode::MESSAGES );
897 foreach ( $fallbacks as $lang ) {
898 $scripts = self::tryForKey( $this->languageScripts, $lang );
899 if ( $scripts ) {
900 return $scripts;
901 }
902 }
903 }
904
905 return [];
906 }
907
908 public function setSkinStylesOverride( array $moduleSkinStyles ): void {
909 $moduleName = $this->getName();
910 foreach ( $moduleSkinStyles as $skinName => $overrides ) {
911 // If a module provides overrides for a skin, and that skin also provides overrides
912 // for the same module, then the module has precedence.
913 if ( isset( $this->skinStyles[$skinName] ) ) {
914 continue;
915 }
916
917 // If $moduleName in ResourceModuleSkinStyles is preceded with a '+', the defined style
918 // files will be added to 'default' skinStyles, otherwise 'default' will be ignored.
919 if ( isset( $overrides[$moduleName] ) ) {
920 $paths = (array)$overrides[$moduleName];
921 $styleFiles = [];
922 } elseif ( isset( $overrides['+' . $moduleName] ) ) {
923 $paths = (array)$overrides['+' . $moduleName];
924 $styleFiles = isset( $this->skinStyles['default'] ) ?
925 (array)$this->skinStyles['default'] :
926 [];
927 } else {
928 continue;
929 }
930
931 // Add new file paths, remapping them to refer to our directories and not use settings
932 // from the module we're modifying, which come from the base definition.
933 [ $localBasePath, $remoteBasePath ] = self::extractBasePaths( $overrides );
934
935 foreach ( $paths as $path ) {
936 $styleFiles[] = new FilePath( $path, $localBasePath, $remoteBasePath );
937 }
938
939 $this->skinStyles[$skinName] = $styleFiles;
940 }
941 }
942
950 public function getStyleFiles( Context $context ) {
951 return array_merge_recursive(
952 self::collateStyleFilesByMedia( $this->styles ),
953 self::collateStyleFilesByMedia(
954 self::tryForKey( $this->skinStyles, $context->getSkin(), 'default' )
955 )
956 );
957 }
958
966 protected function getSkinStyleFiles( $skinName ) {
967 return self::collateStyleFilesByMedia(
968 self::tryForKey( $this->skinStyles, $skinName )
969 );
970 }
971
978 protected function getAllSkinStyleFiles() {
979 $skinFactory = MediaWikiServices::getInstance()->getSkinFactory();
980 $styleFiles = [];
981
982 $internalSkinNames = array_keys( $skinFactory->getInstalledSkins() );
983 $internalSkinNames[] = 'default';
984
985 foreach ( $internalSkinNames as $internalSkinName ) {
986 $styleFiles = array_merge_recursive(
987 $styleFiles,
988 $this->getSkinStyleFiles( $internalSkinName )
989 );
990 }
991
992 return $styleFiles;
993 }
994
1000 public function getAllStyleFiles() {
1001 $collatedStyleFiles = array_merge_recursive(
1002 self::collateStyleFilesByMedia( $this->styles ),
1003 $this->getAllSkinStyleFiles()
1004 );
1005
1006 $result = [];
1007
1008 foreach ( $collatedStyleFiles as $styleFiles ) {
1009 foreach ( $styleFiles as $styleFile ) {
1010 $result[] = $this->getLocalPath( $styleFile );
1011 }
1012 }
1013
1014 return $result;
1015 }
1016
1025 public function readStyleFiles( array $styles, Context $context ) {
1026 if ( !$styles ) {
1027 return [];
1028 }
1029 foreach ( $styles as $media => $files ) {
1030 $uniqueFiles = array_unique( $files, SORT_REGULAR );
1031 $styleFiles = [];
1032 foreach ( $uniqueFiles as $file ) {
1033 $styleFiles[] = $this->readStyleFile( $file, $context );
1034 }
1035 $styles[$media] = implode( "\n", $styleFiles );
1036 }
1037 return $styles;
1038 }
1039
1050 protected function readStyleFile( $path, Context $context ) {
1051 $localPath = $this->getLocalPath( $path );
1052 $style = $this->getFileContents( $localPath, 'style' );
1053 $styleLang = $this->getStyleSheetLang( $localPath );
1054
1055 return $this->processStyle( $style, $styleLang, $path, $context );
1056 }
1057
1074 protected function processStyle( $style, $styleLang, $path, Context $context ) {
1075 $localPath = $this->getLocalPath( $path );
1076 $remotePath = $this->getRemotePath( $path );
1077
1078 if ( $styleLang === 'less' ) {
1079 $style = $this->compileLessString( $style, $localPath, $context );
1080 $this->hasGeneratedStyles = true;
1081 }
1082
1083 if ( $this->getFlip( $context ) ) {
1084 $style = CSSJanus::transform(
1085 $style,
1086 /* $swapLtrRtlInURL = */ true,
1087 /* $swapLeftRightInURL = */ false
1088 );
1089 $this->hasGeneratedStyles = true;
1090 }
1091
1092 $localDir = dirname( $localPath );
1093 $remoteDir = dirname( $remotePath );
1094 // Get and register local file references
1095 $localFileRefs = CSSMin::getLocalFileReferences( $style, $localDir );
1096 foreach ( $localFileRefs as $file ) {
1097 if ( is_file( $file ) ) {
1098 $this->localFileRefs[] = $file;
1099 } else {
1100 $this->missingLocalFileRefs[] = $file;
1101 }
1102 }
1103 // Don't cache this call. remap() ensures data URIs embeds are up to date,
1104 // and urls contain correct content hashes in their query string. (T128668)
1105 return CSSMin::remap( $style, $localDir, $remoteDir, true );
1106 }
1107
1113 public function getFlip( Context $context ) {
1114 return $context->getDirection() === 'rtl' && !$this->noflip;
1115 }
1116
1123 public function getType() {
1124 $canBeStylesOnly = !(
1125 // All options except 'styles', 'skinStyles' and 'debugRaw'
1126 $this->scripts
1127 || $this->debugScripts
1128 || $this->templates
1129 || $this->languageScripts
1130 || $this->skinScripts
1131 || $this->dependencies
1132 || $this->messages
1133 || $this->skipFunction
1134 || $this->packageFiles
1135 );
1136 return $canBeStylesOnly ? self::LOAD_STYLES : self::LOAD_GENERAL;
1137 }
1138
1150 protected function compileLessString( $style, $stylePath, Context $context ) {
1151 static $cache;
1152 // @TODO: dependency injection
1153 if ( !$cache ) {
1154 $cache = MediaWikiServices::getInstance()->getObjectCacheFactory()
1155 ->getLocalServerInstance( CACHE_HASH );
1156 }
1157
1158 $skinName = $context->getSkin();
1159 $skinImportPaths = ExtensionRegistry::getInstance()->getAttribute( 'SkinLessImportPaths' );
1160 $importDirs = [];
1161 if ( isset( $skinImportPaths[ $skinName ] ) ) {
1162 $importDirs[] = $skinImportPaths[ $skinName ];
1163 }
1164
1165 $vars = $this->getLessVars( $context );
1166 // Construct a cache key from a hash of the LESS source, and a hash digest
1167 // of the LESS variables and import dirs used for compilation.
1168 ksort( $vars );
1169 $compilerParams = [
1170 'vars' => $vars,
1171 'importDirs' => $importDirs,
1172 // CodexDevelopmentDir affects import path mapping in ResourceLoader::getLessCompiler(),
1173 // so take that into account too
1174 'codexDevDir' => $this->getConfig()->get( MainConfigNames::CodexDevelopmentDir )
1175 ];
1176 $key = $cache->makeGlobalKey(
1177 'resourceloader-less',
1178 'v1',
1179 hash( 'md4', $style ),
1180 hash( 'md4', serialize( $compilerParams ) )
1181 );
1182
1183 // If we got a cached value, we have to validate it by getting a checksum of all the
1184 // files that were loaded by the parser and ensuring it matches the cached entry's.
1185 $data = $cache->get( $key );
1186 // T425356: Expand here to avoid implicit reliance on global getcwd() matching MW_INSTALL_PATH.
1187 $files = $data ? Module::expandRelativePaths( $data['files'] ) : false;
1188
1189 if (
1190 !$data ||
1191 $data['hash'] !== FileContentsHasher::getFileContentsHash( $files )
1192 ) {
1193 $compiler = $context->getResourceLoader()->getLessCompiler( $vars, $importDirs );
1194
1195 $css = $compiler->parse( $style, $stylePath )->getCss();
1196 $files = $compiler->getParsedFiles();
1197 $data = [
1198 'css' => $css,
1199 'files' => Module::getRelativePaths( $files ),
1200 // T253055: store the implicit dependency paths in a form relative to any install
1201 // path so that multiple version of the application can share the cache for identical
1202 // less stylesheets. This also avoids churn during application updates.
1203 'hash' => FileContentsHasher::getFileContentsHash( $files )
1204 ];
1205 $cache->set( $key, $data, $cache::TTL_DAY );
1206 }
1207
1208 foreach ( $files as $path ) {
1209 $this->localFileRefs[] = $path;
1210 }
1211
1212 return $data['css'];
1213 }
1214
1220 public function getTemplates() {
1221 $templates = [];
1222
1223 foreach ( $this->templates as $alias => $templatePath ) {
1224 // Alias is optional
1225 if ( is_int( $alias ) ) {
1226 $alias = $this->getPath( $templatePath );
1227 }
1228 $localPath = $this->getLocalPath( $templatePath );
1229 $content = $this->getFileContents( $localPath, 'template' );
1230
1231 $templates[$alias] = $this->stripBom( $content );
1232 }
1233 return $templates;
1234 }
1235
1255 private function expandPackageFiles( Context $context ) {
1256 $hash = $context->getHash();
1257 if ( isset( $this->expandedPackageFiles[$hash] ) ) {
1258 return $this->expandedPackageFiles[$hash];
1259 }
1260 if ( $this->packageFiles === null ) {
1261 return null;
1262 }
1263 $expandedFiles = [];
1264 $mainFile = null;
1265
1266 foreach ( $this->packageFiles as $key => $fileInfo ) {
1267 $expanded = $this->expandFileInfo( $context, $fileInfo, "packageFiles[$key]" );
1268 $fileName = $expanded['name'];
1269 if ( !empty( $expanded['main'] ) ) {
1270 unset( $expanded['main'] );
1271 $type = $expanded['type'];
1272 $mainFile = $fileName;
1273 if ( $type !== 'script' && $type !== 'script-vue' ) {
1274 $msg = "Main file in package must be of type 'script', module " .
1275 "'{$this->getName()}', main file '{$mainFile}' is '{$type}'.";
1276 $this->getLogger()->error( $msg );
1277 throw new LogicException( $msg );
1278 }
1279 }
1280 $expandedFiles[$fileName] = $expanded;
1281 }
1282
1283 if ( $expandedFiles && $mainFile === null ) {
1284 // The first package file that is a script is the main file
1285 foreach ( $expandedFiles as $path => $file ) {
1286 if ( $file['type'] === 'script' || $file['type'] === 'script-vue' ) {
1287 $mainFile = $path;
1288 break;
1289 }
1290 }
1291 }
1292
1293 $result = [
1294 'main' => $mainFile,
1295 'files' => $expandedFiles
1296 ];
1297
1298 $this->expandedPackageFiles[$hash] = $result;
1299 return $result;
1300 }
1301
1331 private function expandFileInfo( Context $context, $fileInfo, $debugKey ) {
1332 if ( is_string( $fileInfo ) ) {
1333 // Inline common case
1334 return [
1335 'name' => $fileInfo,
1336 'type' => self::getPackageFileType( $fileInfo ),
1337 'filePath' => new FilePath( $fileInfo, $this->localBasePath, $this->remoteBasePath )
1338 ];
1339 } elseif ( $fileInfo instanceof FilePath ) {
1340 $fileInfo = [
1341 'name' => $fileInfo->getPath(),
1342 'file' => $fileInfo
1343 ];
1344 } elseif ( !is_array( $fileInfo ) ) {
1345 $msg = "Invalid type in $debugKey for module '{$this->getName()}', " .
1346 "must be array, string or FilePath";
1347 $this->getLogger()->error( $msg );
1348 throw new LogicException( $msg );
1349 }
1350 if ( !isset( $fileInfo['name'] ) ) {
1351 $msg = "Missing 'name' key in $debugKey for module '{$this->getName()}'";
1352 $this->getLogger()->error( $msg );
1353 throw new LogicException( $msg );
1354 }
1355 $fileName = $this->getPath( $fileInfo['name'] );
1356
1357 // Infer type from alias if needed
1358 $type = $fileInfo['type'] ?? self::getPackageFileType( $fileName );
1359 $expanded = [
1360 'name' => $fileName,
1361 'type' => $type
1362 ];
1363 if ( !empty( $fileInfo['main'] ) ) {
1364 $expanded['main'] = true;
1365 }
1366
1367 // Perform expansions (except 'file' and 'callback'), creating one of these keys:
1368 // - 'content': literal value.
1369 // - 'filePath': content to be read from a file.
1370 // - 'callback': content computed by a callable.
1371 if ( isset( $fileInfo['content'] ) ) {
1372 $expanded['content'] = $fileInfo['content'];
1373 } elseif ( isset( $fileInfo['file'] ) ) {
1374 $expanded['filePath'] = $this->makeFilePath( $fileInfo['file'] );
1375 } elseif ( isset( $fileInfo['callback'] ) ) {
1376 // If no extra parameter for the callback is given, use null.
1377 $expanded['callbackParam'] = $fileInfo['callbackParam'] ?? null;
1378
1379 if ( !is_callable( $fileInfo['callback'] ) ) {
1380 $msg = "Invalid 'callback' for module '{$this->getName()}', file '{$fileName}'.";
1381 $this->getLogger()->error( $msg );
1382 throw new LogicException( $msg );
1383 }
1384 if ( isset( $fileInfo['versionCallback'] ) ) {
1385 if ( !is_callable( $fileInfo['versionCallback'] ) ) {
1386 throw new LogicException( "Invalid 'versionCallback' for "
1387 . "module '{$this->getName()}', file '{$fileName}'."
1388 );
1389 }
1390
1391 // Execute the versionCallback with the same arguments that
1392 // would be given to the callback
1393 $callbackResult = ( $fileInfo['versionCallback'] )(
1394 $context,
1395 $this->getConfig(),
1396 $expanded['callbackParam']
1397 );
1398 if ( $callbackResult instanceof FilePath ) {
1399 $callbackResult->initBasePaths( $this->localBasePath, $this->remoteBasePath );
1400 $expanded['versionFilePath'] = $callbackResult;
1401 } else {
1402 $expanded['definitionSummary'] = $callbackResult;
1403 }
1404 // Don't invoke 'callback' here as it may be expensive (T223260).
1405 $expanded['callback'] = $fileInfo['callback'];
1406 } else {
1407 // Else go ahead invoke callback with its arguments.
1408 $callbackResult = ( $fileInfo['callback'] )(
1409 $context,
1410 $this->getConfig(),
1411 $expanded['callbackParam']
1412 );
1413 if ( $callbackResult instanceof FilePath ) {
1414 $callbackResult->initBasePaths( $this->localBasePath, $this->remoteBasePath );
1415 $expanded['filePath'] = $callbackResult;
1416 } else {
1417 $expanded['content'] = $callbackResult;
1418 }
1419 }
1420 } elseif ( isset( $fileInfo['config'] ) ) {
1421 if ( $type !== 'data' ) {
1422 $msg = "Key 'config' only valid for data files. "
1423 . " Module '{$this->getName()}', file '{$fileName}' is '{$type}'.";
1424 $this->getLogger()->error( $msg );
1425 throw new LogicException( $msg );
1426 }
1427 $expandedConfig = [];
1428 foreach ( $fileInfo['config'] as $configKey => $var ) {
1429 $expandedConfig[ is_numeric( $configKey ) ? $var : $configKey ] = $this->getConfig()->get( $var );
1430 }
1431 $expanded['content'] = $expandedConfig;
1432 } elseif ( !empty( $fileInfo['main'] ) ) {
1433 // [ 'name' => 'foo.js', 'main' => true ] is shorthand
1434 $expanded['filePath'] = $this->makeFilePath( $fileName );
1435 } else {
1436 $msg = "Incomplete definition for module '{$this->getName()}', file '{$fileName}'. "
1437 . "One of 'file', 'content', 'callback', or 'config' must be set.";
1438 $this->getLogger()->error( $msg );
1439 throw new LogicException( $msg );
1440 }
1441 if ( !isset( $expanded['filePath'] ) ) {
1442 $expanded['virtualFilePath'] = $this->makeFilePath( $fileName );
1443 }
1444 return $expanded;
1445 }
1446
1453 private function makeFilePath( $path ): FilePath {
1454 if ( $path instanceof FilePath ) {
1455 return $path;
1456 } elseif ( is_string( $path ) ) {
1457 return new FilePath( $path, $this->localBasePath, $this->remoteBasePath );
1458 } else {
1459 throw new InvalidArgumentException( '$path must be either FilePath or string' );
1460 }
1461 }
1462
1469 public function getPackageFiles( Context $context ) {
1470 if ( $this->packageFiles === null ) {
1471 return null;
1472 }
1473 $hash = $context->getHash();
1474 if ( isset( $this->fullyExpandedPackageFiles[ $hash ] ) ) {
1475 return $this->fullyExpandedPackageFiles[ $hash ];
1476 }
1477 $expandedPackageFiles = $this->expandPackageFiles( $context ) ?? [];
1478
1479 // T402278: use array_map() to avoid &references here
1480 $expandedPackageFiles['files'] = array_map( function ( array $fileInfo ) use ( $context ): array {
1481 return $this->readFileInfo( $context, $fileInfo );
1482 }, $expandedPackageFiles['files'] );
1483
1484 $this->fullyExpandedPackageFiles[ $hash ] = $expandedPackageFiles;
1485 return $expandedPackageFiles;
1486 }
1487
1497 private function readFileInfo( Context $context, array $fileInfo ): array {
1498 // Turn any 'filePath' or 'callback' key into actual 'content',
1499 // and remove the key after that. The callback could return a
1500 // FilePath object; if that happens, fall through to the 'filePath'
1501 // handling.
1502 if ( !isset( $fileInfo['content'] ) && isset( $fileInfo['callback'] ) ) {
1503 $callbackResult = ( $fileInfo['callback'] )(
1504 $context,
1505 $this->getConfig(),
1506 $fileInfo['callbackParam']
1507 );
1508 if ( $callbackResult instanceof FilePath ) {
1509 // Fall through to the filePath handling code below
1510 $fileInfo['filePath'] = $callbackResult;
1511 } else {
1512 $fileInfo['content'] = $callbackResult;
1513 }
1514 unset( $fileInfo['callback'] );
1515 }
1516 // Only interpret 'filePath' if 'content' hasn't been set already.
1517 // This can happen if 'versionCallback' provided 'filePath',
1518 // while 'callback' provides 'content'. In that case both are set
1519 // at this point. The 'filePath' from 'versionCallback' in that case is
1520 // only to inform getDefinitionSummary().
1521 if ( !isset( $fileInfo['content'] ) && isset( $fileInfo['filePath'] ) ) {
1522 $localPath = $this->getLocalPath( $fileInfo['filePath'] );
1523 $content = $this->getFileContents( $localPath, 'package' );
1524 if ( $fileInfo['type'] === 'data' ) {
1525 $content = json_decode( $content, false, 512, JSON_THROW_ON_ERROR );
1526 }
1527 $fileInfo['content'] = $content;
1528 }
1529 if ( $fileInfo['type'] === 'script-vue' ) {
1530 try {
1531 $fileInfo[ 'content' ] = $this->parseVueContent( $context, $fileInfo[ 'content' ] );
1532 } catch ( InvalidArgumentException $e ) {
1533 $msg = "Error parsing file '{$fileInfo['name']}' in module '{$this->getName()}': " .
1534 "{$e->getMessage()}";
1535 $this->getLogger()->error( $msg );
1536 throw new RuntimeException( $msg );
1537 }
1538 $fileInfo['type'] = 'script+style';
1539 }
1540 if ( !isset( $fileInfo['content'] ) ) {
1541 // This should not be possible due to validation in expandFileInfo()
1542 $msg = "Unable to resolve contents for file {$fileInfo['name']}";
1543 $this->getLogger()->error( $msg );
1544 throw new RuntimeException( $msg );
1545 }
1546
1547 // Not needed for client response, exists for use by getDefinitionSummary().
1548 unset( $fileInfo['definitionSummary'] );
1549 // Not needed for client response, used by callbacks only.
1550 unset( $fileInfo['callbackParam'] );
1551
1552 return $fileInfo;
1553 }
1554
1565 protected function stripBom( $input ) {
1566 if ( str_starts_with( $input, "\xef\xbb\xbf" ) ) {
1567 return substr( $input, 3 );
1568 }
1569 return $input;
1570 }
1571}
1572
1578class_alias( FileModule::class, 'MediaWiki\ResourceLoader\LessVarFileModule' );
const CACHE_HASH
Definition Defines.php:77
$fallback
if(!defined('MW_SETUP_CALLBACK'))
Definition WebStart.php:71
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:35
getHash()
All factors that uniquely identify this request, except 'modules'.
Definition Context.php:413
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.
array string[] $lessMessages
Message keys.
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.
getLessVars(Context $context)
Get language-specific LESS variables for this module.
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()
getMessageBlob(Context $context)
Get the hash of the message blob.to override 1.27 string|null JSON blob or null if module has no mess...
array< int, string|FilePath > $styles
List of CSS file files to always include.
A path to a bundled file (such as JavaScript or CSS), along with a remote and local base path.
Definition FilePath.php:20
Abstraction for ResourceLoader modules, with name registration and maxage functionality.
Definition Module.php:34
static expandRelativePaths(array $filePaths)
Expand directories relative to $IP.
Definition Module.php:566
saveFileDependencies(Context $context, array $curFileRefs)
Save the indirect dependencies for this module pursuant to the skin/language context.
Definition Module.php:519
Generate hash digests of file contents to help with cache invalidation.