MediaWiki REL1_41
FileModule.php
Go to the documentation of this file.
1<?php
24
25use CSSJanus;
26use Exception;
29use InvalidArgumentException;
30use LogicException;
35use ObjectCache;
36use RuntimeException;
37use Wikimedia\Minify\CSSMin;
38use Wikimedia\RequestTimeout\TimeoutException;
39
53class FileModule extends Module {
55 protected $localBasePath = '';
56
58 protected $remoteBasePath = '';
59
63 protected $scripts = [];
64
68 protected $languageScripts = [];
69
73 protected $skinScripts = [];
74
78 protected $debugScripts = [];
79
83 protected $styles = [];
84
88 protected $skinStyles = [];
89
97 protected $packageFiles = null;
98
103 private $expandedPackageFiles = [];
104
109 private $fullyExpandedPackageFiles = [];
110
114 protected $dependencies = [];
115
119 protected $skipFunction = null;
120
124 protected $messages = [];
125
127 protected $templates = [];
128
130 protected $group = null;
131
133 protected $debugRaw = true;
134
136 protected $noflip = false;
137
142 protected $hasGeneratedStyles = false;
143
147 protected $localFileRefs = [];
148
153 protected $missingLocalFileRefs = [];
154
158 protected $vueComponentParser = null;
159
169 public function __construct(
170 array $options = [],
171 string $localBasePath = null,
172 string $remoteBasePath = null
173 ) {
174 // Flag to decide whether to automagically add the mediawiki.template module
175 $hasTemplates = false;
176 // localBasePath and remoteBasePath both have unbelievably long fallback chains
177 // and need to be handled separately.
180
181 // Extract, validate and normalise remaining options
182 foreach ( $options as $member => $option ) {
183 switch ( $member ) {
184 // Lists of file paths
185 case 'scripts':
186 case 'debugScripts':
187 case 'styles':
188 case 'packageFiles':
189 $this->{$member} = is_array( $option ) ? $option : [ $option ];
190 break;
191 case 'templates':
192 $hasTemplates = true;
193 $this->{$member} = is_array( $option ) ? $option : [ $option ];
194 break;
195 // Collated lists of file paths
196 case 'languageScripts':
197 case 'skinScripts':
198 case 'skinStyles':
199 if ( !is_array( $option ) ) {
200 throw new InvalidArgumentException(
201 "Invalid collated file path list error. " .
202 "'$option' given, array expected."
203 );
204 }
205 foreach ( $option as $key => $value ) {
206 if ( !is_string( $key ) ) {
207 throw new InvalidArgumentException(
208 "Invalid collated file path list key error. " .
209 "'$key' given, string expected."
210 );
211 }
212 $this->{$member}[$key] = is_array( $value ) ? $value : [ $value ];
213 }
214 break;
215 case 'deprecated':
216 $this->deprecated = $option;
217 break;
218 // Lists of strings
219 case 'dependencies':
220 case 'messages':
221 case 'targets':
222 // Normalise
223 $option = array_values( array_unique( (array)$option ) );
224 sort( $option );
225
226 $this->{$member} = $option;
227 break;
228 // Single strings
229 case 'group':
230 case 'skipFunction':
231 $this->{$member} = (string)$option;
232 break;
233 // Single booleans
234 case 'debugRaw':
235 case 'noflip':
236 $this->{$member} = (bool)$option;
237 break;
238 }
239 }
240 if ( isset( $options['scripts'] ) && isset( $options['packageFiles'] ) ) {
241 throw new InvalidArgumentException( "A module may not set both 'scripts' and 'packageFiles'" );
242 }
243 if ( isset( $options['packageFiles'] ) && isset( $options['skinScripts'] ) ) {
244 throw new InvalidArgumentException( "Options 'skinScripts' and 'packageFiles' cannot be used together." );
245 }
246 if ( $hasTemplates ) {
247 $this->dependencies[] = 'mediawiki.template';
248 // Ensure relevant template compiler module gets loaded
249 foreach ( $this->templates as $alias => $templatePath ) {
250 if ( is_int( $alias ) ) {
251 $alias = $this->getPath( $templatePath );
252 }
253 $suffix = explode( '.', $alias );
254 $suffix = end( $suffix );
255 $compilerModule = 'mediawiki.template.' . $suffix;
256 if ( $suffix !== 'html' && !in_array( $compilerModule, $this->dependencies ) ) {
257 $this->dependencies[] = $compilerModule;
258 }
259 }
260 }
261 }
262
274 public static function extractBasePaths(
275 array $options = [],
276 $localBasePath = null,
277 $remoteBasePath = null
278 ) {
279 // The different ways these checks are done, and their ordering, look very silly,
280 // but were preserved for backwards-compatibility just in case. Tread lightly.
281
282 if ( $remoteBasePath === null ) {
285 }
286
287 if ( isset( $options['remoteExtPath'] ) ) {
288 $extensionAssetsPath = MediaWikiServices::getInstance()->getMainConfig()
290 $remoteBasePath = $extensionAssetsPath . '/' . $options['remoteExtPath'];
291 }
292
293 if ( isset( $options['remoteSkinPath'] ) ) {
294 $stylePath = MediaWikiServices::getInstance()->getMainConfig()
296 $remoteBasePath = $stylePath . '/' . $options['remoteSkinPath'];
297 }
298
299 if ( array_key_exists( 'localBasePath', $options ) ) {
300 $localBasePath = (string)$options['localBasePath'];
301 }
302
303 if ( array_key_exists( 'remoteBasePath', $options ) ) {
304 $remoteBasePath = (string)$options['remoteBasePath'];
305 }
306
307 if ( $remoteBasePath === '' ) {
308 // If MediaWiki is installed at the document root (not recommended),
309 // then wgScriptPath is set to the empty string by the installer to
310 // ensure safe concatenating of file paths (avoid "/" + "/foo" being "//foo").
311 // However, this also means the path itself can be an invalid URI path,
312 // as those must start with a slash. Within ResourceLoader, we will not
313 // do such primitive/unsafe slash concatenation and use URI resolution
314 // instead, so beyond this point, to avoid fatal errors in CSSMin::resolveUrl(),
315 // do a best-effort support for docroot installs by casting this to a slash.
316 $remoteBasePath = '/';
317 }
318
319 return [ $localBasePath ?? MW_INSTALL_PATH, $remoteBasePath ];
320 }
321
322 public function getScript( Context $context ) {
323 $packageFiles = $this->getPackageFiles( $context );
324 if ( $packageFiles !== null ) {
325 foreach ( $packageFiles['files'] as &$file ) {
326 if ( $file['type'] === 'script+style' ) {
327 $file['content'] = $file['content']['script'];
328 $file['type'] = 'script';
329 }
330 }
331 return $packageFiles;
332 }
333
334 $files = $this->getScriptFiles( $context );
335 foreach ( $files as &$file ) {
336 $this->readFileInfo( $context, $file );
337 }
338 return [ 'plainScripts' => $files ];
339 }
340
345 public function getScriptURLsForDebug( Context $context ) {
346 $rl = $context->getResourceLoader();
347 $config = $this->getConfig();
348 $server = $config->get( MainConfigNames::Server );
349
350 $urls = [];
351 foreach ( $this->getScriptFiles( $context ) as $file ) {
352 if ( isset( $file['filePath'] ) ) {
353 $url = OutputPage::transformResourcePath( $config, $this->getRemotePath( $file['filePath'] ) );
354 // Expand debug URL in case we are another wiki's module source (T255367)
355 $url = $rl->expandUrl( $server, $url );
356 $urls[] = $url;
357 }
358 }
359 return $urls;
360 }
361
365 public function supportsURLLoading() {
366 // phpcs:ignore Generic.WhiteSpace.LanguageConstructSpacing.IncorrectSingle
367 return
368 // Denied by options?
369 $this->debugRaw
370 // If package files are involved, don't support URL loading, because that breaks
371 // scoped require() functions
372 && !$this->packageFiles
373 // Can't link to scripts generated by callbacks
374 && !$this->hasGeneratedScripts();
375 }
376
382 private function hasGeneratedScripts() {
383 foreach (
384 [ $this->scripts, $this->languageScripts, $this->skinScripts, $this->debugScripts ]
385 as $scripts
386 ) {
387 foreach ( $scripts as $script ) {
388 if ( is_array( $script ) ) {
389 if ( isset( $script['callback'] ) || isset( $script['versionCallback'] ) ) {
390 return true;
391 }
392 }
393 }
394 }
395 return false;
396 }
397
404 public function getStyles( Context $context ) {
405 $styles = $this->readStyleFiles(
406 $this->getStyleFiles( $context ),
407 $context
408 );
409
410 $packageFiles = $this->getPackageFiles( $context );
411 if ( $packageFiles !== null ) {
412 foreach ( $packageFiles['files'] as $fileName => $file ) {
413 if ( $file['type'] === 'script+style' ) {
414 $style = $this->processStyle(
415 $file['content']['style'],
416 $file['content']['styleLang'],
417 $fileName,
418 $context
419 );
420 $styles['all'] = ( $styles['all'] ?? '' ) . "\n" . $style;
421 }
422 }
423 }
424
425 // Track indirect file dependencies so that StartUpModule can check for
426 // on-disk file changes to any of this files without having to recompute the file list
427 $this->saveFileDependencies( $context, $this->localFileRefs );
428
429 return $styles;
430 }
431
436 public function getStyleURLsForDebug( Context $context ) {
437 if ( $this->hasGeneratedStyles ) {
438 // Do the default behaviour of returning a url back to load.php
439 // but with only=styles.
440 return parent::getStyleURLsForDebug( $context );
441 }
442 // Our module consists entirely of real css files,
443 // in debug mode we can load those directly.
444 $urls = [];
445 foreach ( $this->getStyleFiles( $context ) as $mediaType => $list ) {
446 $urls[$mediaType] = [];
447 foreach ( $list as $file ) {
448 $urls[$mediaType][] = OutputPage::transformResourcePath(
449 $this->getConfig(),
450 $this->getRemotePath( $file )
451 );
452 }
453 }
454 return $urls;
455 }
456
462 public function getMessages() {
463 return $this->messages;
464 }
465
471 public function getGroup() {
472 return $this->group;
473 }
474
481 public function getDependencies( Context $context = null ) {
482 return $this->dependencies;
483 }
484
492 private function getFileContents( $localPath, $type ) {
493 if ( !is_file( $localPath ) ) {
494 throw new RuntimeException( "$type file not found or not a file: \"$localPath\"" );
495 }
496 return $this->stripBom( file_get_contents( $localPath ) );
497 }
498
502 public function getSkipFunction() {
503 if ( !$this->skipFunction ) {
504 return null;
505 }
506 $localPath = $this->getLocalPath( $this->skipFunction );
507 return $this->getFileContents( $localPath, 'skip function' );
508 }
509
510 public function requiresES6() {
511 return true;
512 }
513
522 public function enableModuleContentVersion() {
523 return false;
524 }
525
532 private function getFileHashes( Context $context ) {
533 $files = [];
534
535 foreach ( $this->getStyleFiles( $context ) as $filePaths ) {
536 foreach ( $filePaths as $filePath ) {
537 $files[] = $this->getLocalPath( $filePath );
538 }
539 }
540
541 // Extract file paths for package files
542 // Optimisation: Use foreach() and isset() instead of array_map/array_filter.
543 // This is a hot code path, called by StartupModule for thousands of modules.
544 $expandedPackageFiles = $this->expandPackageFiles( $context );
545 if ( $expandedPackageFiles ) {
546 foreach ( $expandedPackageFiles['files'] as $fileInfo ) {
547 if ( isset( $fileInfo['filePath'] ) ) {
549 $filePath = $fileInfo['filePath'];
550 $files[] = $filePath->getLocalPath();
551 }
552 }
553 }
554
555 // Add other configured paths
556 $scriptFileInfos = $this->getScriptFiles( $context );
557 foreach ( $scriptFileInfos as $fileInfo ) {
558 $filePath = $fileInfo['filePath'] ?? $fileInfo['versionFilePath'] ?? null;
559 if ( $filePath instanceof FilePath ) {
560 $files[] = $filePath->getLocalPath();
561 }
562 }
563
564 foreach ( $this->templates as $filePath ) {
565 $files[] = $this->getLocalPath( $filePath );
566 }
567
568 if ( $this->skipFunction ) {
569 $files[] = $this->getLocalPath( $this->skipFunction );
570 }
571
572 // Add any lazily discovered file dependencies from previous module builds.
573 // These are already absolute paths.
574 foreach ( $this->getFileDependencies( $context ) as $file ) {
575 $files[] = $file;
576 }
577
578 // Filter out any duplicates. Typically introduced by getFileDependencies() which
579 // may lazily re-discover a primary file.
580 $files = array_unique( $files );
581
582 // Don't return array keys or any other form of file path here, only the hashes.
583 // Including file paths would needlessly cause global cache invalidation when files
584 // move on disk or if e.g. the MediaWiki directory name changes.
585 // Anything where order is significant is already detected by the definition summary.
587 }
588
595 public function getDefinitionSummary( Context $context ) {
596 $summary = parent::getDefinitionSummary( $context );
597
598 $options = [];
599 foreach ( [
600 // The following properties are omitted because they don't affect the module response:
601 // - localBasePath (Per T104950; Changes when absolute directory name changes. If
602 // this affects 'scripts' and other file paths, getFileHashes accounts for that.)
603 // - remoteBasePath (Per T104950)
604 // - dependencies (provided via startup module)
605 // - targets
606 // - group (provided via startup module)
607 'styles',
608 'skinStyles',
609 'messages',
610 'templates',
611 'skipFunction',
612 'debugRaw',
613 ] as $member ) {
614 $options[$member] = $this->{$member};
615 }
616
617 $packageFiles = $this->expandPackageFiles( $context );
618 $packageSummaries = [];
619 if ( $packageFiles ) {
620 // Extract the minimum needed:
621 // - The 'main' pointer (included as-is).
622 // - The 'files' array, simplified to only which files exist (the keys of
623 // this array), and something that represents their non-file content.
624 // For packaged files that reflect files directly from disk, the
625 // 'getFileHashes' method tracks their content already.
626 // It is important that the keys of the $packageFiles['files'] array
627 // are preserved, as they do affect the module output.
628 foreach ( $packageFiles['files'] as $fileName => $fileInfo ) {
629 $packageSummaries[$fileName] =
630 $fileInfo['definitionSummary'] ?? $fileInfo['content'] ?? null;
631 }
632 }
633
634 $scriptFiles = $this->getScriptFiles( $context );
635 $scriptSummaries = [];
636 foreach ( $scriptFiles as $fileName => $fileInfo ) {
637 $scriptSummaries[$fileName] =
638 $fileInfo['definitionSummary'] ?? $fileInfo['content'] ?? null;
639 }
640
641 $summary[] = [
642 'options' => $options,
643 'packageFiles' => $packageSummaries,
644 'scripts' => $scriptSummaries,
645 'fileHashes' => $this->getFileHashes( $context ),
646 'messageBlob' => $this->getMessageBlob( $context ),
647 ];
648
649 $lessVars = $this->getLessVars( $context );
650 if ( $lessVars ) {
651 $summary[] = [ 'lessVars' => $lessVars ];
652 }
653
654 return $summary;
655 }
656
660 protected function getVueComponentParser() {
661 if ( $this->vueComponentParser === null ) {
662 $this->vueComponentParser = new VueComponentParser;
663 }
665 }
666
671 protected function getPath( $path ) {
672 if ( $path instanceof FilePath ) {
673 return $path->getPath();
674 }
675
676 return $path;
677 }
678
683 protected function getLocalPath( $path ) {
684 if ( $path instanceof FilePath ) {
685 if ( $path->getLocalBasePath() !== null ) {
686 return $path->getLocalPath();
687 }
688 $path = $path->getPath();
689 }
690
691 return "{$this->localBasePath}/$path";
692 }
693
698 protected function getRemotePath( $path ) {
699 if ( $path instanceof FilePath ) {
700 if ( $path->getRemoteBasePath() !== null ) {
701 return $path->getRemotePath();
702 }
703 $path = $path->getPath();
704 }
705
706 if ( $this->remoteBasePath === '/' ) {
707 return "/$path";
708 } else {
709 return "{$this->remoteBasePath}/$path";
710 }
711 }
712
720 public function getStyleSheetLang( $path ) {
721 return preg_match( '/\.less$/i', $path ) ? 'less' : 'css';
722 }
723
730 public static function getPackageFileType( $path ) {
731 if ( preg_match( '/\.json$/i', $path ) ) {
732 return 'data';
733 }
734 if ( preg_match( '/\.vue$/i', $path ) ) {
735 return 'script-vue';
736 }
737 return 'script';
738 }
739
747 private static function collateStyleFilesByMedia( array $list ) {
748 $collatedFiles = [];
749 foreach ( $list as $key => $value ) {
750 if ( is_int( $key ) ) {
751 // File name as the value
752 $collatedFiles['all'][] = $value;
753 } elseif ( is_array( $value ) ) {
754 // File name as the key, options array as the value
755 $optionValue = $value['media'] ?? 'all';
756 $collatedFiles[$optionValue][] = $key;
757 }
758 }
759 return $collatedFiles;
760 }
761
771 protected static function tryForKey( array $list, $key, $fallback = null ) {
772 if ( isset( $list[$key] ) && is_array( $list[$key] ) ) {
773 return $list[$key];
774 } elseif ( is_string( $fallback )
775 && isset( $list[$fallback] )
776 && is_array( $list[$fallback] )
777 ) {
778 return $list[$fallback];
779 }
780 return [];
781 }
782
789 private function getScriptFiles( Context $context ): array {
790 // List in execution order: scripts, languageScripts, skinScripts, debugScripts.
791 // Documented at MediaWiki\MainConfigSchema::ResourceModules.
792 $filesByCategory = [
793 'scripts' => $this->scripts,
794 'languageScripts' => $this->getLanguageScripts( $context->getLanguage() ),
795 'skinScripts' => self::tryForKey( $this->skinScripts, $context->getSkin(), 'default' ),
796 ];
797 if ( $context->getDebug() ) {
798 $filesByCategory['debugScripts'] = $this->debugScripts;
799 }
800
801 $expandedFiles = [];
802 foreach ( $filesByCategory as $category => $files ) {
803 foreach ( $files as $key => $fileInfo ) {
804 $expandedFileInfo = $this->expandFileInfo( $context, $fileInfo, "$category\[$key]" );
805 $expandedFiles[$expandedFileInfo['name']] = $expandedFileInfo;
806 }
807 }
808
809 return $expandedFiles;
810 }
811
819 private function getLanguageScripts( string $lang ): array {
820 $scripts = self::tryForKey( $this->languageScripts, $lang );
821 if ( $scripts ) {
822 return $scripts;
823 }
824
825 // Optimization: Avoid initialising and calling into language services
826 // for the majority of modules that don't use this option.
827 if ( $this->languageScripts ) {
828 $fallbacks = MediaWikiServices::getInstance()
829 ->getLanguageFallback()
830 ->getAll( $lang, LanguageFallback::MESSAGES );
831 foreach ( $fallbacks as $lang ) {
832 $scripts = self::tryForKey( $this->languageScripts, $lang );
833 if ( $scripts ) {
834 return $scripts;
835 }
836 }
837 }
838
839 return [];
840 }
841
842 public function setSkinStylesOverride( array $moduleSkinStyles ): void {
843 $moduleName = $this->getName();
844 foreach ( $moduleSkinStyles as $skinName => $overrides ) {
845 // If a module provides overrides for a skin, and that skin also provides overrides
846 // for the same module, then the module has precedence.
847 if ( isset( $this->skinStyles[$skinName] ) ) {
848 continue;
849 }
850
851 // If $moduleName in ResourceModuleSkinStyles is preceded with a '+', the defined style
852 // files will be added to 'default' skinStyles, otherwise 'default' will be ignored.
853 if ( isset( $overrides[$moduleName] ) ) {
854 $paths = (array)$overrides[$moduleName];
855 $styleFiles = [];
856 } elseif ( isset( $overrides['+' . $moduleName] ) ) {
857 $paths = (array)$overrides['+' . $moduleName];
858 $styleFiles = isset( $this->skinStyles['default'] ) ?
859 (array)$this->skinStyles['default'] :
860 [];
861 } else {
862 continue;
863 }
864
865 // Add new file paths, remapping them to refer to our directories and not use settings
866 // from the module we're modifying, which come from the base definition.
867 [ $localBasePath, $remoteBasePath ] = self::extractBasePaths( $overrides );
868
869 foreach ( $paths as $path ) {
870 $styleFiles[] = new FilePath( $path, $localBasePath, $remoteBasePath );
871 }
872
873 $this->skinStyles[$skinName] = $styleFiles;
874 }
875 }
876
884 public function getStyleFiles( Context $context ) {
885 return array_merge_recursive(
886 self::collateStyleFilesByMedia( $this->styles ),
887 self::collateStyleFilesByMedia(
888 self::tryForKey( $this->skinStyles, $context->getSkin(), 'default' )
889 )
890 );
891 }
892
900 protected function getSkinStyleFiles( $skinName ) {
901 return self::collateStyleFilesByMedia(
902 self::tryForKey( $this->skinStyles, $skinName )
903 );
904 }
905
912 protected function getAllSkinStyleFiles() {
913 $skinFactory = MediaWikiServices::getInstance()->getSkinFactory();
914 $styleFiles = [];
915
916 $internalSkinNames = array_keys( $skinFactory->getInstalledSkins() );
917 $internalSkinNames[] = 'default';
918
919 foreach ( $internalSkinNames as $internalSkinName ) {
920 $styleFiles = array_merge_recursive(
921 $styleFiles,
922 $this->getSkinStyleFiles( $internalSkinName )
923 );
924 }
925
926 return $styleFiles;
927 }
928
934 public function getAllStyleFiles() {
935 $collatedStyleFiles = array_merge_recursive(
936 self::collateStyleFilesByMedia( $this->styles ),
937 $this->getAllSkinStyleFiles()
938 );
939
940 $result = [];
941
942 foreach ( $collatedStyleFiles as $styleFiles ) {
943 foreach ( $styleFiles as $styleFile ) {
944 $result[] = $this->getLocalPath( $styleFile );
945 }
946 }
947
948 return $result;
949 }
950
959 public function readStyleFiles( array $styles, Context $context ) {
960 if ( !$styles ) {
961 return [];
962 }
963 foreach ( $styles as $media => $files ) {
964 $uniqueFiles = array_unique( $files, SORT_REGULAR );
965 $styleFiles = [];
966 foreach ( $uniqueFiles as $file ) {
967 $styleFiles[] = $this->readStyleFile( $file, $context );
968 }
969 $styles[$media] = implode( "\n", $styleFiles );
970 }
971 return $styles;
972 }
973
984 protected function readStyleFile( $path, Context $context ) {
985 $localPath = $this->getLocalPath( $path );
986 $style = $this->getFileContents( $localPath, 'style' );
987 $styleLang = $this->getStyleSheetLang( $localPath );
988
989 return $this->processStyle( $style, $styleLang, $path, $context );
990 }
991
1008 protected function processStyle( $style, $styleLang, $path, Context $context ) {
1009 $localPath = $this->getLocalPath( $path );
1010 $remotePath = $this->getRemotePath( $path );
1011
1012 if ( $styleLang === 'less' ) {
1013 $style = $this->compileLessString( $style, $localPath, $context );
1014 $this->hasGeneratedStyles = true;
1015 }
1016
1017 if ( $this->getFlip( $context ) ) {
1018 $style = CSSJanus::transform(
1019 $style,
1020 /* $swapLtrRtlInURL = */ true,
1021 /* $swapLeftRightInURL = */ false
1022 );
1023 }
1024
1025 $localDir = dirname( $localPath );
1026 $remoteDir = dirname( $remotePath );
1027 // Get and register local file references
1028 $localFileRefs = CSSMin::getLocalFileReferences( $style, $localDir );
1029 foreach ( $localFileRefs as $file ) {
1030 if ( is_file( $file ) ) {
1031 $this->localFileRefs[] = $file;
1032 } else {
1033 $this->missingLocalFileRefs[] = $file;
1034 }
1035 }
1036 // Don't cache this call. remap() ensures data URIs embeds are up to date,
1037 // and urls contain correct content hashes in their query string. (T128668)
1038 return CSSMin::remap( $style, $localDir, $remoteDir, true );
1039 }
1040
1046 public function getFlip( Context $context ) {
1047 return $context->getDirection() === 'rtl' && !$this->noflip;
1048 }
1049
1055 public function getTargets() {
1056 return $this->targets;
1057 }
1058
1065 public function getType() {
1066 $canBeStylesOnly = !(
1067 // All options except 'styles', 'skinStyles' and 'debugRaw'
1068 $this->scripts
1069 || $this->debugScripts
1070 || $this->templates
1071 || $this->languageScripts
1072 || $this->skinScripts
1073 || $this->dependencies
1074 || $this->messages
1075 || $this->skipFunction
1076 || $this->packageFiles
1077 );
1078 return $canBeStylesOnly ? self::LOAD_STYLES : self::LOAD_GENERAL;
1079 }
1080
1092 protected function compileLessString( $style, $stylePath, Context $context ) {
1093 static $cache;
1094 // @TODO: dependency injection
1095 if ( !$cache ) {
1096 $cache = ObjectCache::getLocalServerInstance( CACHE_ANYTHING );
1097 }
1098
1099 $skinName = $context->getSkin();
1100 $skinImportPaths = ExtensionRegistry::getInstance()->getAttribute( 'SkinLessImportPaths' );
1101 $importDirs = [];
1102 if ( isset( $skinImportPaths[ $skinName ] ) ) {
1103 $importDirs[] = $skinImportPaths[ $skinName ];
1104 }
1105
1106 $vars = $this->getLessVars( $context );
1107 // Construct a cache key from a hash of the LESS source, and a hash digest
1108 // of the LESS variables used for compilation.
1109 ksort( $vars );
1110 $compilerParams = [
1111 'vars' => $vars,
1112 'importDirs' => $importDirs,
1113 ];
1114 $key = $cache->makeGlobalKey(
1115 'resourceloader-less',
1116 'v1',
1117 hash( 'md4', $style ),
1118 hash( 'md4', serialize( $compilerParams ) )
1119 );
1120
1121 // If we got a cached value, we have to validate it by getting a checksum of all the
1122 // files that were loaded by the parser and ensuring it matches the cached entry's.
1123 $data = $cache->get( $key );
1124 if (
1125 !$data ||
1126 $data['hash'] !== FileContentsHasher::getFileContentsHash( $data['files'] )
1127 ) {
1128 $compiler = $context->getResourceLoader()->getLessCompiler( $vars, $importDirs );
1129
1130 $css = $compiler->parse( $style, $stylePath )->getCss();
1131 // T253055: store the implicit dependency paths in a form relative to any install
1132 // path so that multiple version of the application can share the cache for identical
1133 // less stylesheets. This also avoids churn during application updates.
1134 $files = $compiler->AllParsedFiles();
1135 $data = [
1136 'css' => $css,
1137 'files' => Module::getRelativePaths( $files ),
1138 'hash' => FileContentsHasher::getFileContentsHash( $files )
1139 ];
1140 $cache->set( $key, $data, $cache::TTL_DAY );
1141 }
1142
1143 foreach ( Module::expandRelativePaths( $data['files'] ) as $path ) {
1144 $this->localFileRefs[] = $path;
1145 }
1146
1147 return $data['css'];
1148 }
1149
1155 public function getTemplates() {
1156 $templates = [];
1157
1158 foreach ( $this->templates as $alias => $templatePath ) {
1159 // Alias is optional
1160 if ( is_int( $alias ) ) {
1161 $alias = $this->getPath( $templatePath );
1162 }
1163 $localPath = $this->getLocalPath( $templatePath );
1164 $content = $this->getFileContents( $localPath, 'template' );
1165
1166 $templates[$alias] = $this->stripBom( $content );
1167 }
1168 return $templates;
1169 }
1170
1190 private function expandPackageFiles( Context $context ) {
1191 $hash = $context->getHash();
1192 if ( isset( $this->expandedPackageFiles[$hash] ) ) {
1193 return $this->expandedPackageFiles[$hash];
1194 }
1195 if ( $this->packageFiles === null ) {
1196 return null;
1197 }
1198 $expandedFiles = [];
1199 $mainFile = null;
1200
1201 foreach ( $this->packageFiles as $key => $fileInfo ) {
1202 $expanded = $this->expandFileInfo( $context, $fileInfo, "packageFiles[$key]" );
1203 $fileName = $expanded['name'];
1204 if ( !empty( $expanded['main'] ) ) {
1205 unset( $expanded['main'] );
1206 $type = $expanded['type'];
1207 $mainFile = $fileName;
1208 if ( $type !== 'script' && $type !== 'script-vue' ) {
1209 $msg = "Main file in package must be of type 'script', module " .
1210 "'{$this->getName()}', main file '{$mainFile}' is '{$type}'.";
1211 $this->getLogger()->error( $msg );
1212 throw new LogicException( $msg );
1213 }
1214 }
1215 $expandedFiles[$fileName] = $expanded;
1216 }
1217
1218 if ( $expandedFiles && $mainFile === null ) {
1219 // The first package file that is a script is the main file
1220 foreach ( $expandedFiles as $path => $file ) {
1221 if ( $file['type'] === 'script' || $file['type'] === 'script-vue' ) {
1222 $mainFile = $path;
1223 break;
1224 }
1225 }
1226 }
1227
1228 $result = [
1229 'main' => $mainFile,
1230 'files' => $expandedFiles
1231 ];
1232
1233 $this->expandedPackageFiles[$hash] = $result;
1234 return $result;
1235 }
1236
1266 private function expandFileInfo( Context $context, $fileInfo, $debugKey ) {
1267 if ( is_string( $fileInfo ) ) {
1268 // Inline common case
1269 return [
1270 'name' => $fileInfo,
1271 'type' => self::getPackageFileType( $fileInfo ),
1272 'filePath' => new FilePath( $fileInfo, $this->localBasePath, $this->remoteBasePath )
1273 ];
1274 } elseif ( $fileInfo instanceof FilePath ) {
1275 $fileInfo = [
1276 'name' => $fileInfo->getPath(),
1277 'file' => $fileInfo
1278 ];
1279 } elseif ( !is_array( $fileInfo ) ) {
1280 $msg = "Invalid type in $debugKey for module '{$this->getName()}', " .
1281 "must be array, string or FilePath";
1282 $this->getLogger()->error( $msg );
1283 throw new LogicException( $msg );
1284 }
1285 if ( !isset( $fileInfo['name'] ) ) {
1286 $msg = "Missing 'name' key in $debugKey for module '{$this->getName()}'";
1287 $this->getLogger()->error( $msg );
1288 throw new LogicException( $msg );
1289 }
1290 $fileName = $this->getPath( $fileInfo['name'] );
1291
1292 // Infer type from alias if needed
1293 $type = $fileInfo['type'] ?? self::getPackageFileType( $fileName );
1294 $expanded = [
1295 'name' => $fileName,
1296 'type' => $type
1297 ];
1298 if ( !empty( $fileInfo['main'] ) ) {
1299 $expanded['main'] = true;
1300 }
1301
1302 // Perform expansions (except 'file' and 'callback'), creating one of these keys:
1303 // - 'content': literal value.
1304 // - 'filePath': content to be read from a file.
1305 // - 'callback': content computed by a callable.
1306 if ( isset( $fileInfo['content'] ) ) {
1307 $expanded['content'] = $fileInfo['content'];
1308 } elseif ( isset( $fileInfo['file'] ) ) {
1309 $expanded['filePath'] = $this->makeFilePath( $fileInfo['file'] );
1310 } elseif ( isset( $fileInfo['callback'] ) ) {
1311 // If no extra parameter for the callback is given, use null.
1312 $expanded['callbackParam'] = $fileInfo['callbackParam'] ?? null;
1313
1314 if ( !is_callable( $fileInfo['callback'] ) ) {
1315 $msg = "Invalid 'callback' for module '{$this->getName()}', file '{$fileName}'.";
1316 $this->getLogger()->error( $msg );
1317 throw new LogicException( $msg );
1318 }
1319 if ( isset( $fileInfo['versionCallback'] ) ) {
1320 if ( !is_callable( $fileInfo['versionCallback'] ) ) {
1321 throw new LogicException( "Invalid 'versionCallback' for "
1322 . "module '{$this->getName()}', file '{$fileName}'."
1323 );
1324 }
1325
1326 // Execute the versionCallback with the same arguments that
1327 // would be given to the callback
1328 $callbackResult = ( $fileInfo['versionCallback'] )(
1329 $context,
1330 $this->getConfig(),
1331 $expanded['callbackParam']
1332 );
1333 if ( $callbackResult instanceof FilePath ) {
1334 $callbackResult->initBasePaths( $this->localBasePath, $this->remoteBasePath );
1335 $expanded['versionFilePath'] = $callbackResult;
1336 } else {
1337 $expanded['definitionSummary'] = $callbackResult;
1338 }
1339 // Don't invoke 'callback' here as it may be expensive (T223260).
1340 $expanded['callback'] = $fileInfo['callback'];
1341 } else {
1342 // Else go ahead invoke callback with its arguments.
1343 $callbackResult = ( $fileInfo['callback'] )(
1344 $context,
1345 $this->getConfig(),
1346 $expanded['callbackParam']
1347 );
1348 if ( $callbackResult instanceof FilePath ) {
1349 $callbackResult->initBasePaths( $this->localBasePath, $this->remoteBasePath );
1350 $expanded['filePath'] = $callbackResult;
1351 } else {
1352 $expanded['content'] = $callbackResult;
1353 }
1354 }
1355 } elseif ( isset( $fileInfo['config'] ) ) {
1356 if ( $type !== 'data' ) {
1357 $msg = "Key 'config' only valid for data files. "
1358 . " Module '{$this->getName()}', file '{$fileName}' is '{$type}'.";
1359 $this->getLogger()->error( $msg );
1360 throw new LogicException( $msg );
1361 }
1362 $expandedConfig = [];
1363 foreach ( $fileInfo['config'] as $configKey => $var ) {
1364 $expandedConfig[ is_numeric( $configKey ) ? $var : $configKey ] = $this->getConfig()->get( $var );
1365 }
1366 $expanded['content'] = $expandedConfig;
1367 } elseif ( !empty( $fileInfo['main'] ) ) {
1368 // [ 'name' => 'foo.js', 'main' => true ] is shorthand
1369 $expanded['filePath'] = $this->makeFilePath( $fileName );
1370 } else {
1371 $msg = "Incomplete definition for module '{$this->getName()}', file '{$fileName}'. "
1372 . "One of 'file', 'content', 'callback', or 'config' must be set.";
1373 $this->getLogger()->error( $msg );
1374 throw new LogicException( $msg );
1375 }
1376 if ( !isset( $expanded['filePath'] ) ) {
1377 $expanded['virtualFilePath'] = $this->makeFilePath( $fileName );
1378 }
1379 return $expanded;
1380 }
1381
1388 private function makeFilePath( $path ): FilePath {
1389 if ( $path instanceof FilePath ) {
1390 return $path;
1391 } elseif ( is_string( $path ) ) {
1392 return new FilePath( $path, $this->localBasePath, $this->remoteBasePath );
1393 } else {
1394 throw new InvalidArgumentException( '$path must be either FilePath or string' );
1395 }
1396 }
1397
1404 public function getPackageFiles( Context $context ) {
1405 if ( $this->packageFiles === null ) {
1406 return null;
1407 }
1408 $hash = $context->getHash();
1409 if ( isset( $this->fullyExpandedPackageFiles[ $hash ] ) ) {
1410 return $this->fullyExpandedPackageFiles[ $hash ];
1411 }
1412 $expandedPackageFiles = $this->expandPackageFiles( $context ) ?? [];
1413
1414 foreach ( $expandedPackageFiles['files'] as &$fileInfo ) {
1415 $this->readFileInfo( $context, $fileInfo );
1416 }
1417
1418 $this->fullyExpandedPackageFiles[ $hash ] = $expandedPackageFiles;
1419 return $expandedPackageFiles;
1420 }
1421
1430 private function readFileInfo( Context $context, array &$fileInfo ) {
1431 // Turn any 'filePath' or 'callback' key into actual 'content',
1432 // and remove the key after that. The callback could return a
1433 // FilePath object; if that happens, fall through to the 'filePath'
1434 // handling.
1435 if ( !isset( $fileInfo['content'] ) && isset( $fileInfo['callback'] ) ) {
1436 $callbackResult = ( $fileInfo['callback'] )(
1437 $context,
1438 $this->getConfig(),
1439 $fileInfo['callbackParam']
1440 );
1441 if ( $callbackResult instanceof FilePath ) {
1442 // Fall through to the filePath handling code below
1443 $fileInfo['filePath'] = $callbackResult;
1444 } else {
1445 $fileInfo['content'] = $callbackResult;
1446 }
1447 unset( $fileInfo['callback'] );
1448 }
1449 // Only interpret 'filePath' if 'content' hasn't been set already.
1450 // This can happen if 'versionCallback' provided 'filePath',
1451 // while 'callback' provides 'content'. In that case both are set
1452 // at this point. The 'filePath' from 'versionCallback' in that case is
1453 // only to inform getDefinitionSummary().
1454 if ( !isset( $fileInfo['content'] ) && isset( $fileInfo['filePath'] ) ) {
1455 $localPath = $this->getLocalPath( $fileInfo['filePath'] );
1456 $content = $this->getFileContents( $localPath, 'package' );
1457 if ( $fileInfo['type'] === 'data' ) {
1458 $content = json_decode( $content, false, 512, JSON_THROW_ON_ERROR );
1459 }
1460 $fileInfo['content'] = $content;
1461 }
1462 if ( $fileInfo['type'] === 'script-vue' ) {
1463 try {
1464 $parsedComponent = $this->getVueComponentParser()->parse(
1465 // @phan-suppress-next-line PhanTypePossiblyInvalidDimOffset False positive
1466 $fileInfo['content'],
1467 [ 'minifyTemplate' => !$context->getDebug() ]
1468 );
1469 } catch ( TimeoutException $e ) {
1470 throw $e;
1471 } catch ( Exception $e ) {
1472 $msg = "Error parsing file '{$fileInfo['name']}' in module '{$this->getName()}': " .
1473 $e->getMessage();
1474 $this->getLogger()->error( $msg );
1475 throw new RuntimeException( $msg );
1476 }
1477 $encodedTemplate = json_encode( $parsedComponent['template'] );
1478 if ( $context->getDebug() ) {
1479 // Replace \n (backslash-n) with space + backslash-newline in debug mode
1480 // We only replace \n if not preceded by a backslash, to avoid breaking '\\n'
1481 $encodedTemplate = preg_replace( '/(?<!\\\\)\\\\n/', " \\\n", $encodedTemplate );
1482 // Expand \t to real tabs in debug mode
1483 $encodedTemplate = strtr( $encodedTemplate, [ "\\t" => "\t" ] );
1484 }
1485 $fileInfo['content'] = [
1486 'script' => $parsedComponent['script'] .
1487 ";\nmodule.exports.template = $encodedTemplate;",
1488 'style' => $parsedComponent['style'] ?? '',
1489 'styleLang' => $parsedComponent['styleLang'] ?? 'css'
1490 ];
1491 $fileInfo['type'] = 'script+style';
1492 }
1493 if ( !isset( $fileInfo['content'] ) ) {
1494 // This should not be possible due to validation in expandFileInfo()
1495 $msg = "Unable to resolve contents for file {$fileInfo['name']}";
1496 $this->getLogger()->error( $msg );
1497 throw new RuntimeException( $msg );
1498 }
1499
1500 // Not needed for client response, exists for use by getDefinitionSummary().
1501 unset( $fileInfo['definitionSummary'] );
1502 // Not needed for client response, used by callbacks only.
1503 unset( $fileInfo['callbackParam'] );
1504 }
1505
1516 protected function stripBom( $input ) {
1517 if ( str_starts_with( $input, "\xef\xbb\xbf" ) ) {
1518 return substr( $input, 3 );
1519 }
1520 return $input;
1521 }
1522}
1523
1525class_alias( FileModule::class, 'ResourceLoaderFileModule' );
const CACHE_ANYTHING
Definition Defines.php:85
$fallback
Definition MessagesAb.php:8
if(!defined('MW_SETUP_CALLBACK'))
Definition WebStart.php:88
Load JSON files, and uses a Processor to extract information.
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.
This is one of the Core classes and should be read at least once by any new developers.
Context object that contains information about the state of a specific ResourceLoader web request.
Definition Context.php:45
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()
Get 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)
Take an input string and remove the UTF-8 BOM character if present.
getFlip(Context $context)
Get whether CSS for this module should be flipped.
getSkinStyleFiles( $skinName)
Get a list of file paths for all skin styles in the module used by the skin.
null string $skipFunction
File name containing the body of the skip function.
VueComponentParser null $vueComponentParser
Lazy-created by getVueComponentParser()
getPackageFiles(Context $context)
Resolve the package files definition and generate the content of each package file.
array< int, string|FilePath > $debugScripts
List of paths to JavaScript files to include in debug mode.
__construct(array $options=[], string $localBasePath=null, string $remoteBasePath=null)
Construct a new module from an options array.
string $localBasePath
Local base path, see __construct()
string[] $localFileRefs
Place where readStyleFile() tracks file dependencies.
compileLessString( $style, $stylePath, Context $context)
Compile a LESS string into CSS.
bool $hasGeneratedStyles
Whether getStyleURLsForDebug should return raw file paths, or return load.php urls.
getStyleFiles(Context $context)
Get a list of file paths for all styles in this module, in order of proper inclusion.
getScript(Context $context)
Get all JS for this module for a given language and skin.
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.
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:615
getLessVars(Context $context)
Get module-specific LESS variables, if any.
Definition Module.php:803
getFileDependencies(Context $context)
Get the indirect dependencies for this module pursuant to the skin/language context.
Definition Module.php:577
getMessageBlob(Context $context)
Get the hash of the message blob.
Definition Module.php:690
Parser for Vue single file components (.vue files).
Functions to get cache objects.
get( $name)
Get a configuration variable such as "Sitename" or "UploadMaintenance.".
$content
Definition router.php:76
if(PHP_SAPI !='cli-server') if(!isset( $_SERVER['SCRIPT_FILENAME'])) $file
Item class for a filearchive table row.
Definition router.php:42