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 $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 relatative 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 if ( $packageFiles ) {
693 // Extract the minimum needed:
694 // - The 'main' pointer (included as-is).
695 // - The 'files' array, simplified to only which files exist (the keys of
696 // this array), and something that represents their non-file content.
697 // For packaged files that reflect files directly from disk, the
698 // 'getFileHashes' method tracks their content already.
699 // It is important that the keys of the $packageFiles['files'] array
700 // are preserved, as they do affect the module output.
701 foreach ( $packageFiles['files'] as $fileName => $fileInfo ) {
702 $packageSummaries[$fileName] =
703 $fileInfo['definitionSummary'] ?? $fileInfo['content'] ?? null;
704 }
705 }
706
707 $scriptFiles = $this->getScriptFiles( $context );
708 $scriptSummaries = [];
709 foreach ( $scriptFiles as $fileName => $fileInfo ) {
710 $scriptSummaries[$fileName] =
711 $fileInfo['definitionSummary'] ?? $fileInfo['content'] ?? null;
712 }
713
714 $summary[] = [
715 'options' => $options,
716 'packageFiles' => $packageSummaries,
717 'scripts' => $scriptSummaries,
718 'fileHashes' => $this->getFileHashes( $context ),
719 'messageBlob' => $this->getMessageBlob( $context ),
720 ];
721
722 $lessVars = $this->getLessVars( $context );
723 if ( $lessVars ) {
724 $summary[] = [ 'lessVars' => $lessVars ];
725 }
726
727 return $summary;
728 }
729
734 protected function getPath( $path ) {
735 if ( $path instanceof FilePath ) {
736 return $path->getPath();
737 }
738
739 return $path;
740 }
741
746 protected function getLocalPath( $path ) {
747 if ( $path instanceof FilePath ) {
748 if ( $path->getLocalBasePath() !== null ) {
749 return $path->getLocalPath();
750 }
751 $path = $path->getPath();
752 }
753
754 return "{$this->localBasePath}/$path";
755 }
756
761 protected function getRemotePath( $path ) {
762 if ( $path instanceof FilePath ) {
763 if ( $path->getRemoteBasePath() !== null ) {
764 return $path->getRemotePath();
765 }
766 $path = $path->getPath();
767 }
768
769 if ( $this->remoteBasePath === '/' ) {
770 return "/$path";
771 } else {
772 return "{$this->remoteBasePath}/$path";
773 }
774 }
775
783 public function getStyleSheetLang( $path ) {
784 return preg_match( '/\.less$/i', $path ) ? 'less' : 'css';
785 }
786
793 public static function getPackageFileType( $path ) {
794 if ( preg_match( '/\.json$/i', $path ) ) {
795 return 'data';
796 }
797 if ( preg_match( '/\.vue$/i', $path ) ) {
798 return 'script-vue';
799 }
800 return 'script';
801 }
802
810 private static function collateStyleFilesByMedia( array $list ) {
811 $collatedFiles = [];
812 foreach ( $list as $key => $value ) {
813 if ( is_int( $key ) ) {
814 // File name as the value
815 $collatedFiles['all'][] = $value;
816 } elseif ( is_array( $value ) ) {
817 // File name as the key, options array as the value
818 $optionValue = $value['media'] ?? 'all';
819 $collatedFiles[$optionValue][] = $key;
820 }
821 }
822 return $collatedFiles;
823 }
824
834 protected static function tryForKey( array $list, $key, $fallback = null ) {
835 if ( isset( $list[$key] ) && is_array( $list[$key] ) ) {
836 return $list[$key];
837 } elseif ( is_string( $fallback )
838 && isset( $list[$fallback] )
839 && is_array( $list[$fallback] )
840 ) {
841 return $list[$fallback];
842 }
843 return [];
844 }
845
852 private function getScriptFiles( Context $context ): array {
853 // List in execution order: scripts, languageScripts, skinScripts, debugScripts.
854 // Documented at MediaWiki\MainConfigSchema::ResourceModules.
855 $filesByCategory = [
856 'scripts' => $this->scripts,
857 'languageScripts' => $this->getLanguageScripts( $context->getLanguage() ),
858 'skinScripts' => self::tryForKey( $this->skinScripts, $context->getSkin(), 'default' ),
859 ];
860 if ( $context->getDebug() ) {
861 $filesByCategory['debugScripts'] = $this->debugScripts;
862 }
863
864 $expandedFiles = [];
865 foreach ( $filesByCategory as $category => $files ) {
866 foreach ( $files as $key => $fileInfo ) {
867 $expandedFileInfo = $this->expandFileInfo( $context, $fileInfo, "$category\[$key]" );
868 $expandedFiles[$expandedFileInfo['name']] = $expandedFileInfo;
869 }
870 }
871
872 return $expandedFiles;
873 }
874
882 private function getLanguageScripts( string $lang ): array {
883 $scripts = self::tryForKey( $this->languageScripts, $lang );
884 if ( $scripts ) {
885 return $scripts;
886 }
887
888 // Optimization: Avoid initialising and calling into language services
889 // for the majority of modules that don't use this option.
890 if ( $this->languageScripts ) {
891 $fallbacks = MediaWikiServices::getInstance()
892 ->getLanguageFallback()
893 ->getAll( $lang, LanguageFallbackMode::MESSAGES );
894 foreach ( $fallbacks as $lang ) {
895 $scripts = self::tryForKey( $this->languageScripts, $lang );
896 if ( $scripts ) {
897 return $scripts;
898 }
899 }
900 }
901
902 return [];
903 }
904
905 public function setSkinStylesOverride( array $moduleSkinStyles ): void {
906 $moduleName = $this->getName();
907 foreach ( $moduleSkinStyles as $skinName => $overrides ) {
908 // If a module provides overrides for a skin, and that skin also provides overrides
909 // for the same module, then the module has precedence.
910 if ( isset( $this->skinStyles[$skinName] ) ) {
911 continue;
912 }
913
914 // If $moduleName in ResourceModuleSkinStyles is preceded with a '+', the defined style
915 // files will be added to 'default' skinStyles, otherwise 'default' will be ignored.
916 if ( isset( $overrides[$moduleName] ) ) {
917 $paths = (array)$overrides[$moduleName];
918 $styleFiles = [];
919 } elseif ( isset( $overrides['+' . $moduleName] ) ) {
920 $paths = (array)$overrides['+' . $moduleName];
921 $styleFiles = isset( $this->skinStyles['default'] ) ?
922 (array)$this->skinStyles['default'] :
923 [];
924 } else {
925 continue;
926 }
927
928 // Add new file paths, remapping them to refer to our directories and not use settings
929 // from the module we're modifying, which come from the base definition.
930 [ $localBasePath, $remoteBasePath ] = self::extractBasePaths( $overrides );
931
932 foreach ( $paths as $path ) {
933 $styleFiles[] = new FilePath( $path, $localBasePath, $remoteBasePath );
934 }
935
936 $this->skinStyles[$skinName] = $styleFiles;
937 }
938 }
939
947 public function getStyleFiles( Context $context ) {
948 return array_merge_recursive(
949 self::collateStyleFilesByMedia( $this->styles ),
950 self::collateStyleFilesByMedia(
951 self::tryForKey( $this->skinStyles, $context->getSkin(), 'default' )
952 )
953 );
954 }
955
963 protected function getSkinStyleFiles( $skinName ) {
964 return self::collateStyleFilesByMedia(
965 self::tryForKey( $this->skinStyles, $skinName )
966 );
967 }
968
975 protected function getAllSkinStyleFiles() {
976 $skinFactory = MediaWikiServices::getInstance()->getSkinFactory();
977 $styleFiles = [];
978
979 $internalSkinNames = array_keys( $skinFactory->getInstalledSkins() );
980 $internalSkinNames[] = 'default';
981
982 foreach ( $internalSkinNames as $internalSkinName ) {
983 $styleFiles = array_merge_recursive(
984 $styleFiles,
985 $this->getSkinStyleFiles( $internalSkinName )
986 );
987 }
988
989 return $styleFiles;
990 }
991
997 public function getAllStyleFiles() {
998 $collatedStyleFiles = array_merge_recursive(
999 self::collateStyleFilesByMedia( $this->styles ),
1000 $this->getAllSkinStyleFiles()
1001 );
1002
1003 $result = [];
1004
1005 foreach ( $collatedStyleFiles as $styleFiles ) {
1006 foreach ( $styleFiles as $styleFile ) {
1007 $result[] = $this->getLocalPath( $styleFile );
1008 }
1009 }
1010
1011 return $result;
1012 }
1013
1022 public function readStyleFiles( array $styles, Context $context ) {
1023 if ( !$styles ) {
1024 return [];
1025 }
1026 foreach ( $styles as $media => $files ) {
1027 $uniqueFiles = array_unique( $files, SORT_REGULAR );
1028 $styleFiles = [];
1029 foreach ( $uniqueFiles as $file ) {
1030 $styleFiles[] = $this->readStyleFile( $file, $context );
1031 }
1032 $styles[$media] = implode( "\n", $styleFiles );
1033 }
1034 return $styles;
1035 }
1036
1047 protected function readStyleFile( $path, Context $context ) {
1048 $localPath = $this->getLocalPath( $path );
1049 $style = $this->getFileContents( $localPath, 'style' );
1050 $styleLang = $this->getStyleSheetLang( $localPath );
1051
1052 return $this->processStyle( $style, $styleLang, $path, $context );
1053 }
1054
1071 protected function processStyle( $style, $styleLang, $path, Context $context ) {
1072 $localPath = $this->getLocalPath( $path );
1073 $remotePath = $this->getRemotePath( $path );
1074
1075 if ( $styleLang === 'less' ) {
1076 $style = $this->compileLessString( $style, $localPath, $context );
1077 $this->hasGeneratedStyles = true;
1078 }
1079
1080 if ( $this->getFlip( $context ) ) {
1081 $style = CSSJanus::transform(
1082 $style,
1083 /* $swapLtrRtlInURL = */ true,
1084 /* $swapLeftRightInURL = */ false
1085 );
1086 $this->hasGeneratedStyles = true;
1087 }
1088
1089 $localDir = dirname( $localPath );
1090 $remoteDir = dirname( $remotePath );
1091 // Get and register local file references
1092 $localFileRefs = CSSMin::getLocalFileReferences( $style, $localDir );
1093 foreach ( $localFileRefs as $file ) {
1094 if ( is_file( $file ) ) {
1095 $this->localFileRefs[] = $file;
1096 } else {
1097 $this->missingLocalFileRefs[] = $file;
1098 }
1099 }
1100 // Don't cache this call. remap() ensures data URIs embeds are up to date,
1101 // and urls contain correct content hashes in their query string. (T128668)
1102 return CSSMin::remap( $style, $localDir, $remoteDir, true );
1103 }
1104
1110 public function getFlip( Context $context ) {
1111 return $context->getDirection() === 'rtl' && !$this->noflip;
1112 }
1113
1120 public function getType() {
1121 $canBeStylesOnly = !(
1122 // All options except 'styles', 'skinStyles' and 'debugRaw'
1123 $this->scripts
1124 || $this->debugScripts
1125 || $this->templates
1126 || $this->languageScripts
1127 || $this->skinScripts
1128 || $this->dependencies
1129 || $this->messages
1130 || $this->skipFunction
1131 || $this->packageFiles
1132 );
1133 return $canBeStylesOnly ? self::LOAD_STYLES : self::LOAD_GENERAL;
1134 }
1135
1147 protected function compileLessString( $style, $stylePath, Context $context ) {
1148 static $cache;
1149 // @TODO: dependency injection
1150 if ( !$cache ) {
1151 $cache = MediaWikiServices::getInstance()->getObjectCacheFactory()
1152 ->getLocalServerInstance( CACHE_HASH );
1153 }
1154
1155 $skinName = $context->getSkin();
1156 $skinImportPaths = ExtensionRegistry::getInstance()->getAttribute( 'SkinLessImportPaths' );
1157 $importDirs = [];
1158 if ( isset( $skinImportPaths[ $skinName ] ) ) {
1159 $importDirs[] = $skinImportPaths[ $skinName ];
1160 }
1161
1162 $vars = $this->getLessVars( $context );
1163 // Construct a cache key from a hash of the LESS source, and a hash digest
1164 // of the LESS variables and import dirs used for compilation.
1165 ksort( $vars );
1166 $compilerParams = [
1167 'vars' => $vars,
1168 'importDirs' => $importDirs,
1169 // CodexDevelopmentDir affects import path mapping in ResourceLoader::getLessCompiler(),
1170 // so take that into account too
1171 'codexDevDir' => $this->getConfig()->get( MainConfigNames::CodexDevelopmentDir )
1172 ];
1173 $key = $cache->makeGlobalKey(
1174 'resourceloader-less',
1175 'v1',
1176 hash( 'md4', $style ),
1177 hash( 'md4', serialize( $compilerParams ) )
1178 );
1179
1180 // If we got a cached value, we have to validate it by getting a checksum of all the
1181 // files that were loaded by the parser and ensuring it matches the cached entry's.
1182 $data = $cache->get( $key );
1183 if (
1184 !$data ||
1185 $data['hash'] !== FileContentsHasher::getFileContentsHash( $data['files'] )
1186 ) {
1187 $compiler = $context->getResourceLoader()->getLessCompiler( $vars, $importDirs );
1188
1189 $css = $compiler->parse( $style, $stylePath )->getCss();
1190 // T253055: store the implicit dependency paths in a form relative to any install
1191 // path so that multiple version of the application can share the cache for identical
1192 // less stylesheets. This also avoids churn during application updates.
1193 $files = $compiler->getParsedFiles();
1194 $data = [
1195 'css' => $css,
1196 'files' => Module::getRelativePaths( $files ),
1197 'hash' => FileContentsHasher::getFileContentsHash( $files )
1198 ];
1199 $cache->set( $key, $data, $cache::TTL_DAY );
1200 }
1201
1202 foreach ( Module::expandRelativePaths( $data['files'] ) as $path ) {
1203 $this->localFileRefs[] = $path;
1204 }
1205
1206 return $data['css'];
1207 }
1208
1214 public function getTemplates() {
1215 $templates = [];
1216
1217 foreach ( $this->templates as $alias => $templatePath ) {
1218 // Alias is optional
1219 if ( is_int( $alias ) ) {
1220 $alias = $this->getPath( $templatePath );
1221 }
1222 $localPath = $this->getLocalPath( $templatePath );
1223 $content = $this->getFileContents( $localPath, 'template' );
1224
1225 $templates[$alias] = $this->stripBom( $content );
1226 }
1227 return $templates;
1228 }
1229
1249 private function expandPackageFiles( Context $context ) {
1250 $hash = $context->getHash();
1251 if ( isset( $this->expandedPackageFiles[$hash] ) ) {
1252 return $this->expandedPackageFiles[$hash];
1253 }
1254 if ( $this->packageFiles === null ) {
1255 return null;
1256 }
1257 $expandedFiles = [];
1258 $mainFile = null;
1259
1260 foreach ( $this->packageFiles as $key => $fileInfo ) {
1261 $expanded = $this->expandFileInfo( $context, $fileInfo, "packageFiles[$key]" );
1262 $fileName = $expanded['name'];
1263 if ( !empty( $expanded['main'] ) ) {
1264 unset( $expanded['main'] );
1265 $type = $expanded['type'];
1266 $mainFile = $fileName;
1267 if ( $type !== 'script' && $type !== 'script-vue' ) {
1268 $msg = "Main file in package must be of type 'script', module " .
1269 "'{$this->getName()}', main file '{$mainFile}' is '{$type}'.";
1270 $this->getLogger()->error( $msg );
1271 throw new LogicException( $msg );
1272 }
1273 }
1274 $expandedFiles[$fileName] = $expanded;
1275 }
1276
1277 if ( $expandedFiles && $mainFile === null ) {
1278 // The first package file that is a script is the main file
1279 foreach ( $expandedFiles as $path => $file ) {
1280 if ( $file['type'] === 'script' || $file['type'] === 'script-vue' ) {
1281 $mainFile = $path;
1282 break;
1283 }
1284 }
1285 }
1286
1287 $result = [
1288 'main' => $mainFile,
1289 'files' => $expandedFiles
1290 ];
1291
1292 $this->expandedPackageFiles[$hash] = $result;
1293 return $result;
1294 }
1295
1325 private function expandFileInfo( Context $context, $fileInfo, $debugKey ) {
1326 if ( is_string( $fileInfo ) ) {
1327 // Inline common case
1328 return [
1329 'name' => $fileInfo,
1330 'type' => self::getPackageFileType( $fileInfo ),
1331 'filePath' => new FilePath( $fileInfo, $this->localBasePath, $this->remoteBasePath )
1332 ];
1333 } elseif ( $fileInfo instanceof FilePath ) {
1334 $fileInfo = [
1335 'name' => $fileInfo->getPath(),
1336 'file' => $fileInfo
1337 ];
1338 } elseif ( !is_array( $fileInfo ) ) {
1339 $msg = "Invalid type in $debugKey for module '{$this->getName()}', " .
1340 "must be array, string or FilePath";
1341 $this->getLogger()->error( $msg );
1342 throw new LogicException( $msg );
1343 }
1344 if ( !isset( $fileInfo['name'] ) ) {
1345 $msg = "Missing 'name' key in $debugKey for module '{$this->getName()}'";
1346 $this->getLogger()->error( $msg );
1347 throw new LogicException( $msg );
1348 }
1349 $fileName = $this->getPath( $fileInfo['name'] );
1350
1351 // Infer type from alias if needed
1352 $type = $fileInfo['type'] ?? self::getPackageFileType( $fileName );
1353 $expanded = [
1354 'name' => $fileName,
1355 'type' => $type
1356 ];
1357 if ( !empty( $fileInfo['main'] ) ) {
1358 $expanded['main'] = true;
1359 }
1360
1361 // Perform expansions (except 'file' and 'callback'), creating one of these keys:
1362 // - 'content': literal value.
1363 // - 'filePath': content to be read from a file.
1364 // - 'callback': content computed by a callable.
1365 if ( isset( $fileInfo['content'] ) ) {
1366 $expanded['content'] = $fileInfo['content'];
1367 } elseif ( isset( $fileInfo['file'] ) ) {
1368 $expanded['filePath'] = $this->makeFilePath( $fileInfo['file'] );
1369 } elseif ( isset( $fileInfo['callback'] ) ) {
1370 // If no extra parameter for the callback is given, use null.
1371 $expanded['callbackParam'] = $fileInfo['callbackParam'] ?? null;
1372
1373 if ( !is_callable( $fileInfo['callback'] ) ) {
1374 $msg = "Invalid 'callback' for module '{$this->getName()}', file '{$fileName}'.";
1375 $this->getLogger()->error( $msg );
1376 throw new LogicException( $msg );
1377 }
1378 if ( isset( $fileInfo['versionCallback'] ) ) {
1379 if ( !is_callable( $fileInfo['versionCallback'] ) ) {
1380 throw new LogicException( "Invalid 'versionCallback' for "
1381 . "module '{$this->getName()}', file '{$fileName}'."
1382 );
1383 }
1384
1385 // Execute the versionCallback with the same arguments that
1386 // would be given to the callback
1387 $callbackResult = ( $fileInfo['versionCallback'] )(
1388 $context,
1389 $this->getConfig(),
1390 $expanded['callbackParam']
1391 );
1392 if ( $callbackResult instanceof FilePath ) {
1393 $callbackResult->initBasePaths( $this->localBasePath, $this->remoteBasePath );
1394 $expanded['versionFilePath'] = $callbackResult;
1395 } else {
1396 $expanded['definitionSummary'] = $callbackResult;
1397 }
1398 // Don't invoke 'callback' here as it may be expensive (T223260).
1399 $expanded['callback'] = $fileInfo['callback'];
1400 } else {
1401 // Else go ahead invoke callback with its arguments.
1402 $callbackResult = ( $fileInfo['callback'] )(
1403 $context,
1404 $this->getConfig(),
1405 $expanded['callbackParam']
1406 );
1407 if ( $callbackResult instanceof FilePath ) {
1408 $callbackResult->initBasePaths( $this->localBasePath, $this->remoteBasePath );
1409 $expanded['filePath'] = $callbackResult;
1410 } else {
1411 $expanded['content'] = $callbackResult;
1412 }
1413 }
1414 } elseif ( isset( $fileInfo['config'] ) ) {
1415 if ( $type !== 'data' ) {
1416 $msg = "Key 'config' only valid for data files. "
1417 . " Module '{$this->getName()}', file '{$fileName}' is '{$type}'.";
1418 $this->getLogger()->error( $msg );
1419 throw new LogicException( $msg );
1420 }
1421 $expandedConfig = [];
1422 foreach ( $fileInfo['config'] as $configKey => $var ) {
1423 $expandedConfig[ is_numeric( $configKey ) ? $var : $configKey ] = $this->getConfig()->get( $var );
1424 }
1425 $expanded['content'] = $expandedConfig;
1426 } elseif ( !empty( $fileInfo['main'] ) ) {
1427 // [ 'name' => 'foo.js', 'main' => true ] is shorthand
1428 $expanded['filePath'] = $this->makeFilePath( $fileName );
1429 } else {
1430 $msg = "Incomplete definition for module '{$this->getName()}', file '{$fileName}'. "
1431 . "One of 'file', 'content', 'callback', or 'config' must be set.";
1432 $this->getLogger()->error( $msg );
1433 throw new LogicException( $msg );
1434 }
1435 if ( !isset( $expanded['filePath'] ) ) {
1436 $expanded['virtualFilePath'] = $this->makeFilePath( $fileName );
1437 }
1438 return $expanded;
1439 }
1440
1447 private function makeFilePath( $path ): FilePath {
1448 if ( $path instanceof FilePath ) {
1449 return $path;
1450 } elseif ( is_string( $path ) ) {
1451 return new FilePath( $path, $this->localBasePath, $this->remoteBasePath );
1452 } else {
1453 throw new InvalidArgumentException( '$path must be either FilePath or string' );
1454 }
1455 }
1456
1463 public function getPackageFiles( Context $context ) {
1464 if ( $this->packageFiles === null ) {
1465 return null;
1466 }
1467 $hash = $context->getHash();
1468 if ( isset( $this->fullyExpandedPackageFiles[ $hash ] ) ) {
1469 return $this->fullyExpandedPackageFiles[ $hash ];
1470 }
1471 $expandedPackageFiles = $this->expandPackageFiles( $context ) ?? [];
1472
1473 // T402278: use array_map() to avoid &references here
1474 $expandedPackageFiles['files'] = array_map( function ( array $fileInfo ) use ( $context ): array {
1475 return $this->readFileInfo( $context, $fileInfo );
1476 }, $expandedPackageFiles['files'] );
1477
1478 $this->fullyExpandedPackageFiles[ $hash ] = $expandedPackageFiles;
1479 return $expandedPackageFiles;
1480 }
1481
1491 private function readFileInfo( Context $context, array $fileInfo ): array {
1492 // Turn any 'filePath' or 'callback' key into actual 'content',
1493 // and remove the key after that. The callback could return a
1494 // FilePath object; if that happens, fall through to the 'filePath'
1495 // handling.
1496 if ( !isset( $fileInfo['content'] ) && isset( $fileInfo['callback'] ) ) {
1497 $callbackResult = ( $fileInfo['callback'] )(
1498 $context,
1499 $this->getConfig(),
1500 $fileInfo['callbackParam']
1501 );
1502 if ( $callbackResult instanceof FilePath ) {
1503 // Fall through to the filePath handling code below
1504 $fileInfo['filePath'] = $callbackResult;
1505 } else {
1506 $fileInfo['content'] = $callbackResult;
1507 }
1508 unset( $fileInfo['callback'] );
1509 }
1510 // Only interpret 'filePath' if 'content' hasn't been set already.
1511 // This can happen if 'versionCallback' provided 'filePath',
1512 // while 'callback' provides 'content'. In that case both are set
1513 // at this point. The 'filePath' from 'versionCallback' in that case is
1514 // only to inform getDefinitionSummary().
1515 if ( !isset( $fileInfo['content'] ) && isset( $fileInfo['filePath'] ) ) {
1516 $localPath = $this->getLocalPath( $fileInfo['filePath'] );
1517 $content = $this->getFileContents( $localPath, 'package' );
1518 if ( $fileInfo['type'] === 'data' ) {
1519 $content = json_decode( $content, false, 512, JSON_THROW_ON_ERROR );
1520 }
1521 $fileInfo['content'] = $content;
1522 }
1523 if ( $fileInfo['type'] === 'script-vue' ) {
1524 try {
1525 $fileInfo[ 'content' ] = $this->parseVueContent( $context, $fileInfo[ 'content' ] );
1526 } catch ( InvalidArgumentException $e ) {
1527 $msg = "Error parsing file '{$fileInfo['name']}' in module '{$this->getName()}': " .
1528 "{$e->getMessage()}";
1529 $this->getLogger()->error( $msg );
1530 throw new RuntimeException( $msg );
1531 }
1532 $fileInfo['type'] = 'script+style';
1533 }
1534 if ( !isset( $fileInfo['content'] ) ) {
1535 // This should not be possible due to validation in expandFileInfo()
1536 $msg = "Unable to resolve contents for file {$fileInfo['name']}";
1537 $this->getLogger()->error( $msg );
1538 throw new RuntimeException( $msg );
1539 }
1540
1541 // Not needed for client response, exists for use by getDefinitionSummary().
1542 unset( $fileInfo['definitionSummary'] );
1543 // Not needed for client response, used by callbacks only.
1544 unset( $fileInfo['callbackParam'] );
1545
1546 return $fileInfo;
1547 }
1548
1559 protected function stripBom( $input ) {
1560 if ( str_starts_with( $input, "\xef\xbb\xbf" ) ) {
1561 return substr( $input, 3 );
1562 }
1563 return $input;
1564 }
1565}
1566
1567class_alias( FileModule::class, 'MediaWiki\ResourceLoader\LessVarFileModule' );
const CACHE_HASH
Definition Defines.php:77
$fallback
if(!defined('MW_SETUP_CALLBACK'))
Definition WebStart.php:69
A class containing constants representing the names of configuration variables.
const StylePath
Name constant for the StylePath setting, for use with Config::get()
const ExtensionAssetsPath
Name constant for the ExtensionAssetsPath setting, for use with Config::get()
const ResourceBasePath
Name constant for the ResourceBasePath setting, for use with Config::get()
Service locator for MediaWiki core services.
static getInstance()
Returns the global default instance of the top level service locator.
This is one of the Core classes and should be read at least once by any new developers.
Load JSON files, and uses a Processor to extract information.
Context object that contains information about the state of a specific ResourceLoader web request.
Definition Context.php:32
getHash()
All factors that uniquely identify this request, except 'modules'.
Definition Context.php:405
Module based on local JavaScript/CSS files.
array< string, array< int, string|FilePath > > $skinScripts
Lists of JavaScript files by skin name.
static tryForKey(array $list, $key, $fallback=null)
Get a list of element that match a key, optionally using a fallback key.
getAllSkinStyleFiles()
Get a list of file paths for all skin style files in the module, for all available skins.
getDependencies(?Context $context=null)
Get names of modules this module depends on.
readStyleFile( $path, Context $context)
Read and process a style file.
getTemplates()
Get content of named templates for this module.
getStyleSheetLang( $path)
Infer the stylesheet language from a stylesheet file path.
processStyle( $style, $styleLang, $path, Context $context)
Process a CSS/LESS string.
getDefinitionSummary(Context $context)
Get the definition summary for this module.
requiresES6()
Whether the module requires ES6 support in the client.If the client does not support ES6,...
readStyleFiles(array $styles, Context $context)
Read the contents of a list of CSS files and remap and concatenate these.
array< string, array< int, string|FilePath > > $languageScripts
Lists of JavaScript files by language code.
array< string, array< int, string|FilePath > > $skinStyles
Lists of CSS files by skin name.
array< int|string, string|FilePath > $templates
List of the named templates used by this module.
bool $noflip
Whether CSSJanus flipping should be skipped for this module.
enableModuleContentVersion()
Disable module content versioning.
string[] $dependencies
List of modules this module depends on.
static extractBasePaths(array $options=[], $localBasePath=null, $remoteBasePath=null)
Extract a pair of local and remote base paths from module definition information.
string $remoteBasePath
Remote base path, see __construct()
array< int, string|FilePath > $scripts
List of JavaScript file paths to always include.
stripBom( $input)
Take an input string and remove the UTF-8 BOM character if present.
getFlip(Context $context)
Get whether CSS for this module should be flipped.
getSkinStyleFiles( $skinName)
Get a list of file paths for all skin styles in the module used by the skin.
null string $skipFunction
File name containing the body of the skip function.
getPackageFiles(Context $context)
Resolve the package files definition and generate the content of each package file.
array< int, string|FilePath > $debugScripts
List of paths to JavaScript files to include in debug mode.
__construct(array $options=[], ?string $localBasePath=null, ?string $remoteBasePath=null)
Construct a new module from an options array.
string $localBasePath
Local base path, see __construct()
string[] $localFileRefs
Place where readStyleFile() tracks file dependencies.
compileLessString( $style, $stylePath, Context $context)
Compile a LESS string into CSS.
bool $hasGeneratedStyles
Whether getStyleURLsForDebug should return raw file paths, or return load.php urls.
getStyleFiles(Context $context)
Get a list of file paths for all styles in this module, in order of proper inclusion.
getScript(Context $context)
Get all JS for this module for a given language and skin.Includes all relevant JS except loader scrip...
getMessages()
Get message keys used by this module.
getAllStyleFiles()
Get all style files and all skin style files used by this module.
getGroup()
Get the name of the group this module should be loaded in.
string[] $missingLocalFileRefs
Place where readStyleFile() tracks file dependencies for non-existent files.
getStyles(Context $context)
Get all styles for a given context.
shouldSkipStructureTest()
Whether to skip the structure test ResourcesTest::testRespond() for this module.1....
static getPackageFileType( $path)
Infer the file type from a package file path.
bool $debugRaw
Link to raw files in debug mode.
string[] $messages
List of message keys used by this module.
null string $group
Name of group to load this module in.
setSkinStylesOverride(array $moduleSkinStyles)
Provide overrides for skinStyles to modules that support that.
getType()
Get the module's load type.
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:563
saveFileDependencies(Context $context, array $curFileRefs)
Save the indirect dependencies for this module pursuant to the skin/language context.
Definition Module.php:516
Generate hash digests of file contents to help with cache invalidation.