MediaWiki  master
FileModule.php
Go to the documentation of this file.
1 <?php
23 namespace MediaWiki\ResourceLoader;
24 
25 use CSSJanus;
26 use Exception;
29 use InvalidArgumentException;
30 use LogicException;
34 use ObjectCache;
35 use OutputPage;
36 use RuntimeException;
37 use Wikimedia\Minify\CSSMin;
38 use Wikimedia\RequestTimeout\TimeoutException;
39 
53 class 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  global $IP;
280  // The different ways these checks are done, and their ordering, look very silly,
281  // but were preserved for backwards-compatibility just in case. Tread lightly.
282 
283  if ( $remoteBasePath === null ) {
286  }
287 
288  if ( isset( $options['remoteExtPath'] ) ) {
289  $extensionAssetsPath = MediaWikiServices::getInstance()->getMainConfig()
291  $remoteBasePath = $extensionAssetsPath . '/' . $options['remoteExtPath'];
292  }
293 
294  if ( isset( $options['remoteSkinPath'] ) ) {
295  $stylePath = MediaWikiServices::getInstance()->getMainConfig()
297  $remoteBasePath = $stylePath . '/' . $options['remoteSkinPath'];
298  }
299 
300  if ( array_key_exists( 'localBasePath', $options ) ) {
301  $localBasePath = (string)$options['localBasePath'];
302  }
303 
304  if ( array_key_exists( 'remoteBasePath', $options ) ) {
305  $remoteBasePath = (string)$options['remoteBasePath'];
306  }
307 
308  if ( $remoteBasePath === '' ) {
309  // If MediaWiki is installed at the document root (not recommended),
310  // then wgScriptPath is set to the empty string by the installer to
311  // ensure safe concatenating of file paths (avoid "/" + "/foo" being "//foo").
312  // However, this also means the path itself can be an invalid URI path,
313  // as those must start with a slash. Within ResourceLoader, we will not
314  // do such primitive/unsafe slash concatenation and use URI resolution
315  // instead, so beyond this point, to avoid fatal errors in CSSMin::resolveUrl(),
316  // do a best-effort support for docroot installs by casting this to a slash.
317  $remoteBasePath = '/';
318  }
319 
320  return [ $localBasePath ?? $IP, $remoteBasePath ];
321  }
322 
329  public function getScript( Context $context ) {
330  $deprecationScript = $this->getDeprecationInformation( $context );
331  $packageFiles = $this->getPackageFiles( $context );
332  if ( $packageFiles !== null ) {
333  foreach ( $packageFiles['files'] as &$file ) {
334  if ( $file['type'] === 'script+style' ) {
335  $file['content'] = $file['content']['script'];
336  $file['type'] = 'script';
337  }
338  }
339  if ( $deprecationScript ) {
340  $mainFile =& $packageFiles['files'][$packageFiles['main']];
341  $mainFile['content'] = $deprecationScript . $mainFile['content'];
342  }
343  return $packageFiles;
344  }
345 
346  $files = $this->getScriptFiles( $context );
347  return $deprecationScript . $this->readScriptFiles( $context, $files );
348  }
349 
354  public function getScriptURLsForDebug( Context $context ) {
355  $rl = $context->getResourceLoader();
356  $config = $this->getConfig();
357  $server = $config->get( MainConfigNames::Server );
358 
359  $urls = [];
360  foreach ( $this->getScriptFiles( $context ) as $file ) {
361  if ( isset( $file['filePath'] ) ) {
362  $url = OutputPage::transformResourcePath( $config, $this->getRemotePath( $file['filePath'] ) );
363  // Expand debug URL in case we are another wiki's module source (T255367)
364  $url = $rl->expandUrl( $server, $url );
365  $urls[] = $url;
366  }
367  }
368  return $urls;
369  }
370 
374  public function supportsURLLoading() {
375  // phpcs:ignore Generic.WhiteSpace.LanguageConstructSpacing.IncorrectSingle
376  return
377  // Denied by options?
378  $this->debugRaw
379  // If package files are involved, don't support URL loading, because that breaks
380  // scoped require() functions
381  && !$this->packageFiles
382  // Can't link to scripts generated by callbacks
383  && !$this->hasGeneratedScripts();
384  }
385 
391  private function hasGeneratedScripts() {
392  foreach (
393  [ $this->scripts, $this->languageScripts, $this->skinScripts, $this->debugScripts ]
394  as $scripts
395  ) {
396  foreach ( $scripts as $script ) {
397  if ( is_array( $script ) ) {
398  if ( isset( $script['callback'] ) || isset( $script['versionCallback'] ) ) {
399  return true;
400  }
401  }
402  }
403  }
404  return false;
405  }
406 
413  public function getStyles( Context $context ) {
414  $styles = $this->readStyleFiles(
415  $this->getStyleFiles( $context ),
416  $context
417  );
418 
419  $packageFiles = $this->getPackageFiles( $context );
420  if ( $packageFiles !== null ) {
421  foreach ( $packageFiles['files'] as $fileName => $file ) {
422  if ( $file['type'] === 'script+style' ) {
423  $style = $this->processStyle(
424  $file['content']['style'],
425  $file['content']['styleLang'],
426  $fileName,
427  $context
428  );
429  $styles['all'] = ( $styles['all'] ?? '' ) . "\n" . $style;
430  }
431  }
432  }
433 
434  // Track indirect file dependencies so that StartUpModule can check for
435  // on-disk file changes to any of this files without having to recompute the file list
436  $this->saveFileDependencies( $context, $this->localFileRefs );
437 
438  return $styles;
439  }
440 
445  public function getStyleURLsForDebug( Context $context ) {
446  if ( $this->hasGeneratedStyles ) {
447  // Do the default behaviour of returning a url back to load.php
448  // but with only=styles.
449  return parent::getStyleURLsForDebug( $context );
450  }
451  // Our module consists entirely of real css files,
452  // in debug mode we can load those directly.
453  $urls = [];
454  foreach ( $this->getStyleFiles( $context ) as $mediaType => $list ) {
455  $urls[$mediaType] = [];
456  foreach ( $list as $file ) {
457  $urls[$mediaType][] = OutputPage::transformResourcePath(
458  $this->getConfig(),
459  $this->getRemotePath( $file )
460  );
461  }
462  }
463  return $urls;
464  }
465 
471  public function getMessages() {
472  return $this->messages;
473  }
474 
480  public function getGroup() {
481  return $this->group;
482  }
483 
490  public function getDependencies( Context $context = null ) {
491  return $this->dependencies;
492  }
493 
501  private function getFileContents( $localPath, $type ) {
502  if ( !is_file( $localPath ) ) {
503  throw new RuntimeException( "$type file not found or not a file: \"$localPath\"" );
504  }
505  return $this->stripBom( file_get_contents( $localPath ) );
506  }
507 
511  public function getSkipFunction() {
512  if ( !$this->skipFunction ) {
513  return null;
514  }
515  $localPath = $this->getLocalPath( $this->skipFunction );
516  return $this->getFileContents( $localPath, 'skip function' );
517  }
518 
519  public function requiresES6() {
520  return true;
521  }
522 
531  public function enableModuleContentVersion() {
532  return false;
533  }
534 
541  private function getFileHashes( Context $context ) {
542  $files = [];
543 
544  foreach ( $this->getStyleFiles( $context ) as $filePaths ) {
545  foreach ( $filePaths as $filePath ) {
546  $files[] = $this->getLocalPath( $filePath );
547  }
548  }
549 
550  // Extract file paths for package files
551  // Optimisation: Use foreach() and isset() instead of array_map/array_filter.
552  // This is a hot code path, called by StartupModule for thousands of modules.
553  $expandedPackageFiles = $this->expandPackageFiles( $context );
554  if ( $expandedPackageFiles ) {
555  foreach ( $expandedPackageFiles['files'] as $fileInfo ) {
556  if ( isset( $fileInfo['filePath'] ) ) {
558  $filePath = $fileInfo['filePath'];
559  $files[] = $filePath->getLocalPath();
560  }
561  }
562  }
563 
564  // Add other configured paths
565  $scriptFileInfos = $this->getScriptFiles( $context );
566  foreach ( $scriptFileInfos as $fileInfo ) {
567  if ( isset( $fileInfo['filePath'] ) ) {
569  $filePath = $fileInfo['filePath'];
570  $files[] = $filePath->getLocalPath();
571  }
572  }
573 
574  foreach ( $this->templates as $filePath ) {
575  $files[] = $this->getLocalPath( $filePath );
576  }
577 
578  if ( $this->skipFunction ) {
579  $files[] = $this->getLocalPath( $this->skipFunction );
580  }
581 
582  // Add any lazily discovered file dependencies from previous module builds.
583  // These are already absolute paths.
584  foreach ( $this->getFileDependencies( $context ) as $file ) {
585  $files[] = $file;
586  }
587 
588  // Filter out any duplicates. Typically introduced by getFileDependencies() which
589  // may lazily re-discover a primary file.
590  $files = array_unique( $files );
591 
592  // Don't return array keys or any other form of file path here, only the hashes.
593  // Including file paths would needlessly cause global cache invalidation when files
594  // move on disk or if e.g. the MediaWiki directory name changes.
595  // Anything where order is significant is already detected by the definition summary.
597  }
598 
605  public function getDefinitionSummary( Context $context ) {
606  $summary = parent::getDefinitionSummary( $context );
607 
608  $options = [];
609  foreach ( [
610  // The following properties are omitted because they don't affect the module response:
611  // - localBasePath (Per T104950; Changes when absolute directory name changes. If
612  // this affects 'scripts' and other file paths, getFileHashes accounts for that.)
613  // - remoteBasePath (Per T104950)
614  // - dependencies (provided via startup module)
615  // - targets
616  // - group (provided via startup module)
617  'styles',
618  'skinStyles',
619  'messages',
620  'templates',
621  'skipFunction',
622  'debugRaw',
623  ] as $member ) {
624  $options[$member] = $this->{$member};
625  }
626 
627  $packageFiles = $this->expandPackageFiles( $context );
628  $packageSummaries = [];
629  if ( $packageFiles ) {
630  // Extract the minimum needed:
631  // - The 'main' pointer (included as-is).
632  // - The 'files' array, simplified to only which files exist (the keys of
633  // this array), and something that represents their non-file content.
634  // For packaged files that reflect files directly from disk, the
635  // 'getFileHashes' method tracks their content already.
636  // It is important that the keys of the $packageFiles['files'] array
637  // are preserved, as they do affect the module output.
638  foreach ( $packageFiles['files'] as $fileName => $fileInfo ) {
639  $packageSummaries[$fileName] =
640  $fileInfo['definitionSummary'] ?? $fileInfo['content'] ?? null;
641  }
642  }
643 
644  $scriptFiles = $this->getScriptFiles( $context );
645  $scriptSummaries = [];
646  foreach ( $scriptFiles as $fileName => $fileInfo ) {
647  $scriptSummaries[$fileName] =
648  $fileInfo['definitionSummary'] ?? $fileInfo['content'] ?? null;
649  }
650 
651  $summary[] = [
652  'options' => $options,
653  'packageFiles' => $packageSummaries,
654  'scripts' => $scriptSummaries,
655  'fileHashes' => $this->getFileHashes( $context ),
656  'messageBlob' => $this->getMessageBlob( $context ),
657  ];
658 
659  $lessVars = $this->getLessVars( $context );
660  if ( $lessVars ) {
661  $summary[] = [ 'lessVars' => $lessVars ];
662  }
663 
664  return $summary;
665  }
666 
670  protected function getVueComponentParser() {
671  if ( $this->vueComponentParser === null ) {
672  $this->vueComponentParser = new VueComponentParser;
673  }
675  }
676 
681  protected function getPath( $path ) {
682  if ( $path instanceof FilePath ) {
683  return $path->getPath();
684  }
685 
686  return $path;
687  }
688 
693  protected function getLocalPath( $path ) {
694  if ( $path instanceof FilePath ) {
695  if ( $path->getLocalBasePath() !== null ) {
696  return $path->getLocalPath();
697  }
698  $path = $path->getPath();
699  }
700 
701  return "{$this->localBasePath}/$path";
702  }
703 
708  protected function getRemotePath( $path ) {
709  if ( $path instanceof FilePath ) {
710  if ( $path->getRemoteBasePath() !== null ) {
711  return $path->getRemotePath();
712  }
713  $path = $path->getPath();
714  }
715 
716  if ( $this->remoteBasePath === '/' ) {
717  return "/$path";
718  } else {
719  return "{$this->remoteBasePath}/$path";
720  }
721  }
722 
730  public function getStyleSheetLang( $path ) {
731  return preg_match( '/\.less$/i', $path ) ? 'less' : 'css';
732  }
733 
740  public static function getPackageFileType( $path ) {
741  if ( preg_match( '/\.json$/i', $path ) ) {
742  return 'data';
743  }
744  if ( preg_match( '/\.vue$/i', $path ) ) {
745  return 'script-vue';
746  }
747  return 'script';
748  }
749 
757  private static function collateStyleFilesByMedia( array $list ) {
758  $collatedFiles = [];
759  foreach ( $list as $key => $value ) {
760  if ( is_int( $key ) ) {
761  // File name as the value
762  $collatedFiles['all'][] = $value;
763  } elseif ( is_array( $value ) ) {
764  // File name as the key, options array as the value
765  $optionValue = $value['media'] ?? 'all';
766  $collatedFiles[$optionValue][] = $key;
767  }
768  }
769  return $collatedFiles;
770  }
771 
781  protected static function tryForKey( array $list, $key, $fallback = null ) {
782  if ( isset( $list[$key] ) && is_array( $list[$key] ) ) {
783  return $list[$key];
784  } elseif ( is_string( $fallback )
785  && isset( $list[$fallback] )
786  && is_array( $list[$fallback] )
787  ) {
788  return $list[$fallback];
789  }
790  return [];
791  }
792 
799  private function getScriptFiles( Context $context ): array {
800  // List in execution order: scripts, languageScripts, skinScripts, debugScripts.
801  // Documented at MediaWiki\MainConfigSchema::ResourceModules.
802  $filesByCategory = [
803  'scripts' => $this->scripts,
804  'languageScripts' => $this->getLanguageScripts( $context->getLanguage() ),
805  'skinScripts' => self::tryForKey( $this->skinScripts, $context->getSkin(), 'default' ),
806  ];
807  if ( $context->getDebug() ) {
808  $filesByCategory['debugScripts'] = $this->debugScripts;
809  }
810 
811  $expandedFiles = [];
812  foreach ( $filesByCategory as $category => $files ) {
813  foreach ( $files as $key => $fileInfo ) {
814  $expandedFileInfo = $this->expandFileInfo( $context, $fileInfo, "$category\[$key]" );
815  $expandedFiles[$expandedFileInfo['name']] = $expandedFileInfo;
816  }
817  }
818 
819  return $expandedFiles;
820  }
821 
829  private function getLanguageScripts( string $lang ): array {
830  $scripts = self::tryForKey( $this->languageScripts, $lang );
831  if ( $scripts ) {
832  return $scripts;
833  }
834 
835  // Optimization: Avoid initialising and calling into language services
836  // for the majority of modules that don't use this option.
837  if ( $this->languageScripts ) {
838  $fallbacks = MediaWikiServices::getInstance()
839  ->getLanguageFallback()
840  ->getAll( $lang, LanguageFallback::MESSAGES );
841  foreach ( $fallbacks as $lang ) {
842  $scripts = self::tryForKey( $this->languageScripts, $lang );
843  if ( $scripts ) {
844  return $scripts;
845  }
846  }
847  }
848 
849  return [];
850  }
851 
852  public function setSkinStylesOverride( array $moduleSkinStyles ): void {
853  $moduleName = $this->getName();
854  foreach ( $moduleSkinStyles as $skinName => $overrides ) {
855  // If a module provides overrides for a skin, and that skin also provides overrides
856  // for the same module, then the module has precedence.
857  if ( isset( $this->skinStyles[$skinName] ) ) {
858  continue;
859  }
860 
861  // If $moduleName in ResourceModuleSkinStyles is preceded with a '+', the defined style
862  // files will be added to 'default' skinStyles, otherwise 'default' will be ignored.
863  if ( isset( $overrides[$moduleName] ) ) {
864  $paths = (array)$overrides[$moduleName];
865  $styleFiles = [];
866  } elseif ( isset( $overrides['+' . $moduleName] ) ) {
867  $paths = (array)$overrides['+' . $moduleName];
868  $styleFiles = isset( $this->skinStyles['default'] ) ?
869  (array)$this->skinStyles['default'] :
870  [];
871  } else {
872  continue;
873  }
874 
875  // Add new file paths, remapping them to refer to our directories and not use settings
876  // from the module we're modifying, which come from the base definition.
877  [ $localBasePath, $remoteBasePath ] = self::extractBasePaths( $overrides );
878 
879  foreach ( $paths as $path ) {
880  $styleFiles[] = new FilePath( $path, $localBasePath, $remoteBasePath );
881  }
882 
883  $this->skinStyles[$skinName] = $styleFiles;
884  }
885  }
886 
894  public function getStyleFiles( Context $context ) {
895  return array_merge_recursive(
896  self::collateStyleFilesByMedia( $this->styles ),
897  self::collateStyleFilesByMedia(
898  self::tryForKey( $this->skinStyles, $context->getSkin(), 'default' )
899  )
900  );
901  }
902 
910  protected function getSkinStyleFiles( $skinName ) {
911  return self::collateStyleFilesByMedia(
912  self::tryForKey( $this->skinStyles, $skinName )
913  );
914  }
915 
922  protected function getAllSkinStyleFiles() {
923  $skinFactory = MediaWikiServices::getInstance()->getSkinFactory();
924  $styleFiles = [];
925 
926  $internalSkinNames = array_keys( $skinFactory->getInstalledSkins() );
927  $internalSkinNames[] = 'default';
928 
929  foreach ( $internalSkinNames as $internalSkinName ) {
930  $styleFiles = array_merge_recursive(
931  $styleFiles,
932  $this->getSkinStyleFiles( $internalSkinName )
933  );
934  }
935 
936  return $styleFiles;
937  }
938 
944  public function getAllStyleFiles() {
945  $collatedStyleFiles = array_merge_recursive(
946  self::collateStyleFilesByMedia( $this->styles ),
947  $this->getAllSkinStyleFiles()
948  );
949 
950  $result = [];
951 
952  foreach ( $collatedStyleFiles as $styleFiles ) {
953  foreach ( $styleFiles as $styleFile ) {
954  $result[] = $this->getLocalPath( $styleFile );
955  }
956  }
957 
958  return $result;
959  }
960 
968  private function readScriptFiles( Context $context, array $scripts ) {
969  $js = '';
970  foreach ( $scripts as $fileInfo ) {
971  $this->readFileInfo( $context, $fileInfo );
972  // @phan-suppress-next-line PhanTypePossiblyInvalidDimOffset
973  $js .= ResourceLoader::ensureNewline( $fileInfo['content'] );
974  }
975  return $js;
976  }
977 
986  public function readStyleFiles( array $styles, Context $context ) {
987  if ( !$styles ) {
988  return [];
989  }
990  foreach ( $styles as $media => $files ) {
991  $uniqueFiles = array_unique( $files, SORT_REGULAR );
992  $styleFiles = [];
993  foreach ( $uniqueFiles as $file ) {
994  $styleFiles[] = $this->readStyleFile( $file, $context );
995  }
996  $styles[$media] = implode( "\n", $styleFiles );
997  }
998  return $styles;
999  }
1000 
1011  protected function readStyleFile( $path, Context $context ) {
1012  $localPath = $this->getLocalPath( $path );
1013  $style = $this->getFileContents( $localPath, 'style' );
1014  $styleLang = $this->getStyleSheetLang( $localPath );
1015 
1016  return $this->processStyle( $style, $styleLang, $path, $context );
1017  }
1018 
1035  protected function processStyle( $style, $styleLang, $path, Context $context ) {
1036  $localPath = $this->getLocalPath( $path );
1037  $remotePath = $this->getRemotePath( $path );
1038 
1039  if ( $styleLang === 'less' ) {
1040  $style = $this->compileLessString( $style, $localPath, $context );
1041  $this->hasGeneratedStyles = true;
1042  }
1043 
1044  if ( $this->getFlip( $context ) ) {
1045  $style = CSSJanus::transform(
1046  $style,
1047  /* $swapLtrRtlInURL = */ true,
1048  /* $swapLeftRightInURL = */ false
1049  );
1050  }
1051 
1052  $localDir = dirname( $localPath );
1053  $remoteDir = dirname( $remotePath );
1054  // Get and register local file references
1055  $localFileRefs = CSSMin::getLocalFileReferences( $style, $localDir );
1056  foreach ( $localFileRefs as $file ) {
1057  if ( is_file( $file ) ) {
1058  $this->localFileRefs[] = $file;
1059  } else {
1060  $this->missingLocalFileRefs[] = $file;
1061  }
1062  }
1063  // Don't cache this call. remap() ensures data URIs embeds are up to date,
1064  // and urls contain correct content hashes in their query string. (T128668)
1065  return CSSMin::remap( $style, $localDir, $remoteDir, true );
1066  }
1067 
1073  public function getFlip( Context $context ) {
1074  return $context->getDirection() === 'rtl' && !$this->noflip;
1075  }
1076 
1082  public function getTargets() {
1083  return $this->targets;
1084  }
1085 
1092  public function getType() {
1093  $canBeStylesOnly = !(
1094  // All options except 'styles', 'skinStyles' and 'debugRaw'
1095  $this->scripts
1096  || $this->debugScripts
1097  || $this->templates
1098  || $this->languageScripts
1099  || $this->skinScripts
1100  || $this->dependencies
1101  || $this->messages
1102  || $this->skipFunction
1103  || $this->packageFiles
1104  );
1105  return $canBeStylesOnly ? self::LOAD_STYLES : self::LOAD_GENERAL;
1106  }
1107 
1119  protected function compileLessString( $style, $stylePath, Context $context ) {
1120  static $cache;
1121  // @TODO: dependency injection
1122  if ( !$cache ) {
1124  }
1125 
1126  $skinName = $context->getSkin();
1127  $skinImportPaths = ExtensionRegistry::getInstance()->getAttribute( 'SkinLessImportPaths' );
1128  $importDirs = [];
1129  if ( isset( $skinImportPaths[ $skinName ] ) ) {
1130  $importDirs[] = $skinImportPaths[ $skinName ];
1131  }
1132 
1133  $vars = $this->getLessVars( $context );
1134  // Construct a cache key from a hash of the LESS source, and a hash digest
1135  // of the LESS variables used for compilation.
1136  ksort( $vars );
1137  $compilerParams = [
1138  'vars' => $vars,
1139  'importDirs' => $importDirs,
1140  ];
1141  $key = $cache->makeGlobalKey(
1142  'resourceloader-less',
1143  'v1',
1144  hash( 'md4', $style ),
1145  hash( 'md4', serialize( $compilerParams ) )
1146  );
1147 
1148  // If we got a cached value, we have to validate it by getting a checksum of all the
1149  // files that were loaded by the parser and ensuring it matches the cached entry's.
1150  $data = $cache->get( $key );
1151  if (
1152  !$data ||
1153  $data['hash'] !== FileContentsHasher::getFileContentsHash( $data['files'] )
1154  ) {
1155  $compiler = $context->getResourceLoader()->getLessCompiler( $vars, $importDirs );
1156 
1157  $css = $compiler->parse( $style, $stylePath )->getCss();
1158  // T253055: store the implicit dependency paths in a form relative to any install
1159  // path so that multiple version of the application can share the cache for identical
1160  // less stylesheets. This also avoids churn during application updates.
1161  $files = $compiler->AllParsedFiles();
1162  $data = [
1163  'css' => $css,
1164  'files' => Module::getRelativePaths( $files ),
1165  'hash' => FileContentsHasher::getFileContentsHash( $files )
1166  ];
1167  $cache->set( $key, $data, $cache::TTL_DAY );
1168  }
1169 
1170  foreach ( Module::expandRelativePaths( $data['files'] ) as $path ) {
1171  $this->localFileRefs[] = $path;
1172  }
1173 
1174  return $data['css'];
1175  }
1176 
1182  public function getTemplates() {
1183  $templates = [];
1184 
1185  foreach ( $this->templates as $alias => $templatePath ) {
1186  // Alias is optional
1187  if ( is_int( $alias ) ) {
1188  $alias = $this->getPath( $templatePath );
1189  }
1190  $localPath = $this->getLocalPath( $templatePath );
1191  $content = $this->getFileContents( $localPath, 'template' );
1192 
1193  $templates[$alias] = $this->stripBom( $content );
1194  }
1195  return $templates;
1196  }
1197 
1217  private function expandPackageFiles( Context $context ) {
1218  $hash = $context->getHash();
1219  if ( isset( $this->expandedPackageFiles[$hash] ) ) {
1220  return $this->expandedPackageFiles[$hash];
1221  }
1222  if ( $this->packageFiles === null ) {
1223  return null;
1224  }
1225  $expandedFiles = [];
1226  $mainFile = null;
1227 
1228  foreach ( $this->packageFiles as $key => $fileInfo ) {
1229  $expanded = $this->expandFileInfo( $context, $fileInfo, "packageFiles[$key]" );
1230  $fileName = $expanded['name'];
1231  if ( !empty( $expanded['main'] ) ) {
1232  unset( $expanded['main'] );
1233  $type = $expanded['type'];
1234  $mainFile = $fileName;
1235  if ( $type !== 'script' && $type !== 'script-vue' ) {
1236  $msg = "Main file in package must be of type 'script', module " .
1237  "'{$this->getName()}', main file '{$mainFile}' is '{$type}'.";
1238  $this->getLogger()->error( $msg );
1239  throw new LogicException( $msg );
1240  }
1241  }
1242  $expandedFiles[$fileName] = $expanded;
1243  }
1244 
1245  if ( $expandedFiles && $mainFile === null ) {
1246  // The first package file that is a script is the main file
1247  foreach ( $expandedFiles as $path => $file ) {
1248  if ( $file['type'] === 'script' || $file['type'] === 'script-vue' ) {
1249  $mainFile = $path;
1250  break;
1251  }
1252  }
1253  }
1254 
1255  $result = [
1256  'main' => $mainFile,
1257  'files' => $expandedFiles
1258  ];
1259 
1260  $this->expandedPackageFiles[$hash] = $result;
1261  return $result;
1262  }
1263 
1286  private function expandFileInfo( Context $context, $fileInfo, $debugKey ) {
1287  if ( is_string( $fileInfo ) ) {
1288  // Inline common case
1289  return [
1290  'name' => $fileInfo,
1291  'type' => self::getPackageFileType( $fileInfo ),
1292  'filePath' => new FilePath( $fileInfo, $this->localBasePath, $this->remoteBasePath )
1293  ];
1294  } elseif ( $fileInfo instanceof FilePath ) {
1295  $fileInfo = [
1296  'name' => $fileInfo->getPath(),
1297  'file' => $fileInfo
1298  ];
1299  } elseif ( !is_array( $fileInfo ) ) {
1300  $msg = "Invalid type in $debugKey for module '{$this->getName()}', " .
1301  "must be array, string or FilePath";
1302  $this->getLogger()->error( $msg );
1303  throw new LogicException( $msg );
1304  }
1305  if ( !isset( $fileInfo['name'] ) ) {
1306  $msg = "Missing 'name' key in $debugKey for module '{$this->getName()}'";
1307  $this->getLogger()->error( $msg );
1308  throw new LogicException( $msg );
1309  }
1310  $fileName = $this->getPath( $fileInfo['name'] );
1311 
1312  // Infer type from alias if needed
1313  $type = $fileInfo['type'] ?? self::getPackageFileType( $fileName );
1314  $expanded = [
1315  'name' => $fileName,
1316  'type' => $type
1317  ];
1318  if ( !empty( $fileInfo['main'] ) ) {
1319  $expanded['main'] = true;
1320  }
1321 
1322  // Perform expansions (except 'file' and 'callback'), creating one of these keys:
1323  // - 'content': literal value.
1324  // - 'filePath': content to be read from a file.
1325  // - 'callback': content computed by a callable.
1326  if ( isset( $fileInfo['content'] ) ) {
1327  $expanded['content'] = $fileInfo['content'];
1328  } elseif ( isset( $fileInfo['file'] ) ) {
1329  $expanded['filePath'] = $this->makeFilePath( $fileInfo['file'] );
1330  } elseif ( isset( $fileInfo['callback'] ) ) {
1331  // If no extra parameter for the callback is given, use null.
1332  $expanded['callbackParam'] = $fileInfo['callbackParam'] ?? null;
1333 
1334  if ( !is_callable( $fileInfo['callback'] ) ) {
1335  $msg = "Invalid 'callback' for module '{$this->getName()}', file '{$fileName}'.";
1336  $this->getLogger()->error( $msg );
1337  throw new LogicException( $msg );
1338  }
1339  if ( isset( $fileInfo['versionCallback'] ) ) {
1340  if ( !is_callable( $fileInfo['versionCallback'] ) ) {
1341  throw new LogicException( "Invalid 'versionCallback' for "
1342  . "module '{$this->getName()}', file '{$fileName}'."
1343  );
1344  }
1345 
1346  // Execute the versionCallback with the same arguments that
1347  // would be given to the callback
1348  $callbackResult = ( $fileInfo['versionCallback'] )(
1349  $context,
1350  $this->getConfig(),
1351  $expanded['callbackParam']
1352  );
1353  if ( $callbackResult instanceof FilePath ) {
1354  $callbackResult->initBasePaths( $this->localBasePath, $this->remoteBasePath );
1355  $expanded['filePath'] = $callbackResult;
1356  } else {
1357  $expanded['definitionSummary'] = $callbackResult;
1358  }
1359  // Don't invoke 'callback' here as it may be expensive (T223260).
1360  $expanded['callback'] = $fileInfo['callback'];
1361  } else {
1362  // Else go ahead invoke callback with its arguments.
1363  $callbackResult = ( $fileInfo['callback'] )(
1364  $context,
1365  $this->getConfig(),
1366  $expanded['callbackParam']
1367  );
1368  if ( $callbackResult instanceof FilePath ) {
1369  $callbackResult->initBasePaths( $this->localBasePath, $this->remoteBasePath );
1370  $expanded['filePath'] = $callbackResult;
1371  } else {
1372  $expanded['content'] = $callbackResult;
1373  }
1374  }
1375  } elseif ( isset( $fileInfo['config'] ) ) {
1376  if ( $type !== 'data' ) {
1377  $msg = "Key 'config' only valid for data files. "
1378  . " Module '{$this->getName()}', file '{$fileName}' is '{$type}'.";
1379  $this->getLogger()->error( $msg );
1380  throw new LogicException( $msg );
1381  }
1382  $expandedConfig = [];
1383  foreach ( $fileInfo['config'] as $configKey => $var ) {
1384  $expandedConfig[ is_numeric( $configKey ) ? $var : $configKey ] = $this->getConfig()->get( $var );
1385  }
1386  $expanded['content'] = $expandedConfig;
1387  } elseif ( !empty( $fileInfo['main'] ) ) {
1388  // [ 'name' => 'foo.js', 'main' => true ] is shorthand
1389  $expanded['filePath'] = $this->makeFilePath( $fileName );
1390  } else {
1391  $msg = "Incomplete definition for module '{$this->getName()}', file '{$fileName}'. "
1392  . "One of 'file', 'content', 'callback', or 'config' must be set.";
1393  $this->getLogger()->error( $msg );
1394  throw new LogicException( $msg );
1395  }
1396  return $expanded;
1397  }
1398 
1405  private function makeFilePath( $path ): FilePath {
1406  if ( $path instanceof FilePath ) {
1407  return $path;
1408  } elseif ( is_string( $path ) ) {
1409  return new FilePath( $path, $this->localBasePath, $this->remoteBasePath );
1410  } else {
1411  throw new InvalidArgumentException( '$path must be either FilePath or string' );
1412  }
1413  }
1414 
1421  public function getPackageFiles( Context $context ) {
1422  if ( $this->packageFiles === null ) {
1423  return null;
1424  }
1425  $hash = $context->getHash();
1426  if ( isset( $this->fullyExpandedPackageFiles[ $hash ] ) ) {
1427  return $this->fullyExpandedPackageFiles[ $hash ];
1428  }
1429  $expandedPackageFiles = $this->expandPackageFiles( $context ) ?? [];
1430 
1431  foreach ( $expandedPackageFiles['files'] as &$fileInfo ) {
1432  $this->readFileInfo( $context, $fileInfo );
1433  }
1434 
1435  $this->fullyExpandedPackageFiles[ $hash ] = $expandedPackageFiles;
1436  return $expandedPackageFiles;
1437  }
1438 
1447  private function readFileInfo( Context $context, array &$fileInfo ) {
1448  // Turn any 'filePath' or 'callback' key into actual 'content',
1449  // and remove the key after that. The callback could return a
1450  // FilePath object; if that happens, fall through to the 'filePath'
1451  // handling.
1452  if ( !isset( $fileInfo['content'] ) && isset( $fileInfo['callback'] ) ) {
1453  $callbackResult = ( $fileInfo['callback'] )(
1454  $context,
1455  $this->getConfig(),
1456  $fileInfo['callbackParam']
1457  );
1458  if ( $callbackResult instanceof FilePath ) {
1459  // Fall through to the filePath handling code below
1460  $fileInfo['filePath'] = $callbackResult;
1461  } else {
1462  $fileInfo['content'] = $callbackResult;
1463  }
1464  unset( $fileInfo['callback'] );
1465  }
1466  // Only interpret 'filePath' if 'content' hasn't been set already.
1467  // This can happen if 'versionCallback' provided 'filePath',
1468  // while 'callback' provides 'content'. In that case both are set
1469  // at this point. The 'filePath' from 'versionCallback' in that case is
1470  // only to inform getDefinitionSummary().
1471  if ( !isset( $fileInfo['content'] ) && isset( $fileInfo['filePath'] ) ) {
1472  $localPath = $this->getLocalPath( $fileInfo['filePath'] );
1473  $content = $this->getFileContents( $localPath, 'package' );
1474  if ( $fileInfo['type'] === 'data' ) {
1475  $content = json_decode( $content, false, 512, JSON_THROW_ON_ERROR );
1476  }
1477  $fileInfo['content'] = $content;
1478  }
1479  if ( $fileInfo['type'] === 'script-vue' ) {
1480  try {
1481  $parsedComponent = $this->getVueComponentParser()->parse(
1482  // @phan-suppress-next-line PhanTypePossiblyInvalidDimOffset False positive
1483  $fileInfo['content'],
1484  [ 'minifyTemplate' => !$context->getDebug() ]
1485  );
1486  } catch ( TimeoutException $e ) {
1487  throw $e;
1488  } catch ( Exception $e ) {
1489  $msg = "Error parsing file '{$fileInfo['name']}' in module '{$this->getName()}': " .
1490  $e->getMessage();
1491  $this->getLogger()->error( $msg );
1492  throw new RuntimeException( $msg );
1493  }
1494  $encodedTemplate = json_encode( $parsedComponent['template'] );
1495  if ( $context->getDebug() ) {
1496  // Replace \n (backslash-n) with space + backslash-newline in debug mode
1497  // We only replace \n if not preceded by a backslash, to avoid breaking '\\n'
1498  $encodedTemplate = preg_replace( '/(?<!\\\\)\\\\n/', " \\\n", $encodedTemplate );
1499  // Expand \t to real tabs in debug mode
1500  $encodedTemplate = strtr( $encodedTemplate, [ "\\t" => "\t" ] );
1501  }
1502  $fileInfo['content'] = [
1503  'script' => $parsedComponent['script'] .
1504  ";\nmodule.exports.template = $encodedTemplate;",
1505  'style' => $parsedComponent['style'] ?? '',
1506  'styleLang' => $parsedComponent['styleLang'] ?? 'css'
1507  ];
1508  $fileInfo['type'] = 'script+style';
1509  }
1510  if ( !isset( $fileInfo['content'] ) ) {
1511  // This should not be possible due to validation in expandFileInfo()
1512  $msg = "Unable to resolve contents for file {$fileInfo['name']}";
1513  $this->getLogger()->error( $msg );
1514  throw new RuntimeException( $msg );
1515  }
1516 
1517  // Not needed for client response, exists for use by getDefinitionSummary().
1518  unset( $fileInfo['definitionSummary'] );
1519  // Not needed for client response, used by callbacks only.
1520  unset( $fileInfo['callbackParam'] );
1521  }
1522 
1533  protected function stripBom( $input ) {
1534  if ( str_starts_with( $input, "\xef\xbb\xbf" ) ) {
1535  return substr( $input, 3 );
1536  }
1537  return $input;
1538  }
1539 }
1540 
1542 class_alias( FileModule::class, 'ResourceLoaderFileModule' );
const CACHE_ANYTHING
Definition: Defines.php:85
$fallback
Definition: MessagesAb.php:8
if(!defined( 'MEDIAWIKI')) if(ini_get( 'mbstring.func_overload')) if(!defined( 'MW_ENTRY_POINT')) global $IP
Environment checks.
Definition: Setup.php:94
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 ...
const MESSAGES
Return a fallback chain for messages in getAll.
A class containing constants representing the names of configuration variables.
const StylePath
Name constant for the StylePath setting, for use with Config::get()
const ExtensionAssetsPath
Name constant for the ExtensionAssetsPath setting, for use with Config::get()
const Server
Name constant for the Server setting, for use with Config::get()
const ResourceBasePath
Name constant for the ResourceBasePath setting, for use with Config::get()
Service locator for MediaWiki core services.
static getInstance()
Returns the global default instance of the top level service locator.
Context object that contains information about the state of a specific ResourceLoader web request.
Definition: Context.php:46
getHash()
All factors that uniquely identify this request, except 'modules'.
Definition: Context.php:443
Module based on local JavaScript/CSS files.
Definition: FileModule.php:53
getDependencies(Context $context=null)
Get names of modules this module depends on.
Definition: FileModule.php:490
array< string, array< int, string|FilePath > > $skinScripts
Lists of JavaScript files by skin name.
Definition: FileModule.php:73
getScriptURLsForDebug(Context $context)
Definition: FileModule.php:354
static tryForKey(array $list, $key, $fallback=null)
Get a list of element that match a key, optionally using a fallback key.
Definition: FileModule.php:781
getAllSkinStyleFiles()
Get a list of file paths for all skin style files in the module, for all available skins.
Definition: FileModule.php:922
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.
Definition: FileModule.php:730
processStyle( $style, $styleLang, $path, Context $context)
Process a CSS/LESS string.
getDefinitionSummary(Context $context)
Get the definition summary for this module.
Definition: FileModule.php:605
requiresES6()
Whether the module requires ES6 support in the client.
Definition: FileModule.php:519
readStyleFiles(array $styles, Context $context)
Read the contents of a list of CSS files and remap and concatenate these.
Definition: FileModule.php:986
array< string, array< int, string|FilePath > > $languageScripts
Lists of JavaScript files by language code.
Definition: FileModule.php:68
array< string, array< int, string|FilePath > > $skinStyles
Lists of CSS files by skin name.
Definition: FileModule.php:88
array< int|string, string|FilePath > $templates
List of the named templates used by this module.
Definition: FileModule.php:127
bool $noflip
Whether CSSJanus flipping should be skipped for this module.
Definition: FileModule.php:136
enableModuleContentVersion()
Disable module content versioning.
Definition: FileModule.php:531
string[] $dependencies
List of modules this module depends on.
Definition: FileModule.php:114
static extractBasePaths(array $options=[], $localBasePath=null, $remoteBasePath=null)
Extract a pair of local and remote base paths from module definition information.
Definition: FileModule.php:274
string $remoteBasePath
Remote base path, see __construct()
Definition: FileModule.php:58
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.
Definition: FileModule.php:63
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.
Definition: FileModule.php:910
null string $skipFunction
File name containing the body of the skip function.
Definition: FileModule.php:119
VueComponentParser null $vueComponentParser
Lazy-created by getVueComponentParser()
Definition: FileModule.php:158
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.
Definition: FileModule.php:78
__construct(array $options=[], string $localBasePath=null, string $remoteBasePath=null)
Construct a new module from an options array.
Definition: FileModule.php:169
string $localBasePath
Local base path, see __construct()
Definition: FileModule.php:55
string[] $localFileRefs
Place where readStyleFile() tracks file dependencies.
Definition: FileModule.php:147
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.
Definition: FileModule.php:142
getStyleURLsForDebug(Context $context)
Definition: FileModule.php:445
getStyleFiles(Context $context)
Get a list of file paths for all styles in this module, in order of proper inclusion.
Definition: FileModule.php:894
getScript(Context $context)
Get all scripts for a given context concatenated together.
Definition: FileModule.php:329
getMessages()
Get message keys used by this module.
Definition: FileModule.php:471
getAllStyleFiles()
Get all style files and all skin style files used by this module.
Definition: FileModule.php:944
getGroup()
Get the name of the group this module should be loaded in.
Definition: FileModule.php:480
string[] $missingLocalFileRefs
Place where readStyleFile() tracks file dependencies for non-existent files.
Definition: FileModule.php:153
getStyles(Context $context)
Get all styles for a given context.
Definition: FileModule.php:413
static getPackageFileType( $path)
Infer the file type from a package file path.
Definition: FileModule.php:740
bool $debugRaw
Link to raw files in debug mode.
Definition: FileModule.php:133
string[] $messages
List of message keys used by this module.
Definition: FileModule.php:124
null string $group
Name of group to load this module in.
Definition: FileModule.php:130
setSkinStylesOverride(array $moduleSkinStyles)
Provide overrides for skinStyles to modules that support that.
Definition: FileModule.php:852
getType()
Get the module's load type.
null array $packageFiles
Packaged files definition, to bundle and make available client-side via require().
Definition: FileModule.php:97
array< int, string|FilePath > $styles
List of CSS file files to always include.
Definition: FileModule.php:83
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:582
getLessVars(Context $context)
Get module-specific LESS variables, if any.
Definition: Module.php:769
getFileDependencies(Context $context)
Get the indirect dependencies for this module pursuant to the skin/language context.
Definition: Module.php:544
getDeprecationInformation(Context $context)
Get JS representing deprecation information for the current module if available.
Definition: Module.php:199
getMessageBlob(Context $context)
Get the hash of the message blob.
Definition: Module.php:656
Parser for Vue single file components (.vue files).
Functions to get cache objects.
Definition: ObjectCache.php:66
static getLocalServerInstance( $fallback=CACHE_NONE)
Factory function for CACHE_ACCEL (referenced from configuration)
This is one of the Core classes and should be read at least once by any new developers.
Definition: OutputPage.php:60
static transformResourcePath(Config $config, $path)
Transform path to web-accessible static resource.
get( $name)
Get a configuration variable such as "Sitename" or "UploadMaintenance.".
$content
Definition: router.php:76
if(PHP_SAPI !='cli-server') if(!isset( $_SERVER['SCRIPT_FILENAME'])) $file
Item class for a filearchive table row.
Definition: router.php:42
if(!isset( $args[0])) $lang