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 $targets = [ 'desktop' ];
137 
139  protected $noflip = false;
140 
142  protected $es6 = false;
143 
148  protected $hasGeneratedStyles = false;
149 
153  protected $localFileRefs = [];
154 
159  protected $missingLocalFileRefs = [];
160 
164  protected $vueComponentParser = null;
165 
175  public function __construct(
176  array $options = [],
177  string $localBasePath = null,
178  string $remoteBasePath = null
179  ) {
180  // Flag to decide whether to automagically add the mediawiki.template module
181  $hasTemplates = false;
182  // localBasePath and remoteBasePath both have unbelievably long fallback chains
183  // and need to be handled separately.
186 
187  // Extract, validate and normalise remaining options
188  foreach ( $options as $member => $option ) {
189  switch ( $member ) {
190  // Lists of file paths
191  case 'scripts':
192  case 'debugScripts':
193  case 'styles':
194  case 'packageFiles':
195  $this->{$member} = is_array( $option ) ? $option : [ $option ];
196  break;
197  case 'templates':
198  $hasTemplates = true;
199  $this->{$member} = is_array( $option ) ? $option : [ $option ];
200  break;
201  // Collated lists of file paths
202  case 'languageScripts':
203  case 'skinScripts':
204  case 'skinStyles':
205  if ( !is_array( $option ) ) {
206  throw new InvalidArgumentException(
207  "Invalid collated file path list error. " .
208  "'$option' given, array expected."
209  );
210  }
211  foreach ( $option as $key => $value ) {
212  if ( !is_string( $key ) ) {
213  throw new InvalidArgumentException(
214  "Invalid collated file path list key error. " .
215  "'$key' given, string expected."
216  );
217  }
218  $this->{$member}[$key] = is_array( $value ) ? $value : [ $value ];
219  }
220  break;
221  case 'deprecated':
222  $this->deprecated = $option;
223  break;
224  // Lists of strings
225  case 'dependencies':
226  case 'messages':
227  case 'targets':
228  // Normalise
229  $option = array_values( array_unique( (array)$option ) );
230  sort( $option );
231 
232  $this->{$member} = $option;
233  break;
234  // Single strings
235  case 'group':
236  case 'skipFunction':
237  $this->{$member} = (string)$option;
238  break;
239  // Single booleans
240  case 'debugRaw':
241  case 'noflip':
242  case 'es6':
243  $this->{$member} = (bool)$option;
244  break;
245  }
246  }
247  if ( isset( $options['scripts'] ) && isset( $options['packageFiles'] ) ) {
248  throw new InvalidArgumentException( "A module may not set both 'scripts' and 'packageFiles'" );
249  }
250  if ( isset( $options['packageFiles'] ) && isset( $options['skinScripts'] ) ) {
251  throw new InvalidArgumentException( "Options 'skinScripts' and 'packageFiles' cannot be used together." );
252  }
253  if ( $hasTemplates ) {
254  $this->dependencies[] = 'mediawiki.template';
255  // Ensure relevant template compiler module gets loaded
256  foreach ( $this->templates as $alias => $templatePath ) {
257  if ( is_int( $alias ) ) {
258  $alias = $this->getPath( $templatePath );
259  }
260  $suffix = explode( '.', $alias );
261  $suffix = end( $suffix );
262  $compilerModule = 'mediawiki.template.' . $suffix;
263  if ( $suffix !== 'html' && !in_array( $compilerModule, $this->dependencies ) ) {
264  $this->dependencies[] = $compilerModule;
265  }
266  }
267  }
268  }
269 
281  public static function extractBasePaths(
282  array $options = [],
283  $localBasePath = null,
284  $remoteBasePath = null
285  ) {
286  global $IP;
287  // The different ways these checks are done, and their ordering, look very silly,
288  // but were preserved for backwards-compatibility just in case. Tread lightly.
289 
290  if ( $remoteBasePath === null ) {
293  }
294 
295  if ( isset( $options['remoteExtPath'] ) ) {
296  $extensionAssetsPath = MediaWikiServices::getInstance()->getMainConfig()
298  $remoteBasePath = $extensionAssetsPath . '/' . $options['remoteExtPath'];
299  }
300 
301  if ( isset( $options['remoteSkinPath'] ) ) {
302  $stylePath = MediaWikiServices::getInstance()->getMainConfig()
304  $remoteBasePath = $stylePath . '/' . $options['remoteSkinPath'];
305  }
306 
307  if ( array_key_exists( 'localBasePath', $options ) ) {
308  $localBasePath = (string)$options['localBasePath'];
309  }
310 
311  if ( array_key_exists( 'remoteBasePath', $options ) ) {
312  $remoteBasePath = (string)$options['remoteBasePath'];
313  }
314 
315  if ( $remoteBasePath === '' ) {
316  // If MediaWiki is installed at the document root (not recommended),
317  // then wgScriptPath is set to the empty string by the installer to
318  // ensure safe concatenating of file paths (avoid "/" + "/foo" being "//foo").
319  // However, this also means the path itself can be an invalid URI path,
320  // as those must start with a slash. Within ResourceLoader, we will not
321  // do such primitive/unsafe slash concatenation and use URI resolution
322  // instead, so beyond this point, to avoid fatal errors in CSSMin::resolveUrl(),
323  // do a best-effort support for docroot installs by casting this to a slash.
324  $remoteBasePath = '/';
325  }
326 
327  return [ $localBasePath ?? $IP, $remoteBasePath ];
328  }
329 
336  public function getScript( Context $context ) {
337  $deprecationScript = $this->getDeprecationInformation( $context );
338  $packageFiles = $this->getPackageFiles( $context );
339  if ( $packageFiles !== null ) {
340  foreach ( $packageFiles['files'] as &$file ) {
341  if ( $file['type'] === 'script+style' ) {
342  $file['content'] = $file['content']['script'];
343  $file['type'] = 'script';
344  }
345  }
346  if ( $deprecationScript ) {
347  $mainFile =& $packageFiles['files'][$packageFiles['main']];
348  $mainFile['content'] = $deprecationScript . $mainFile['content'];
349  }
350  return $packageFiles;
351  }
352 
353  $files = $this->getScriptFiles( $context );
354  return $deprecationScript . $this->readScriptFiles( $files );
355  }
356 
361  public function getScriptURLsForDebug( Context $context ) {
362  $rl = $context->getResourceLoader();
363  $config = $this->getConfig();
364  $server = $config->get( MainConfigNames::Server );
365 
366  $urls = [];
367  foreach ( $this->getScriptFiles( $context ) as $file ) {
368  $url = OutputPage::transformResourcePath( $config, $this->getRemotePath( $file ) );
369  // Expand debug URL in case we are another wiki's module source (T255367)
370  $url = $rl->expandUrl( $server, $url );
371  $urls[] = $url;
372  }
373  return $urls;
374  }
375 
379  public function supportsURLLoading() {
380  // If package files are involved, don't support URL loading, because that breaks
381  // scoped require() functions
382  return $this->debugRaw && !$this->packageFiles;
383  }
384 
391  public function getStyles( Context $context ) {
392  $styles = $this->readStyleFiles(
393  $this->getStyleFiles( $context ),
394  $context
395  );
396 
397  $packageFiles = $this->getPackageFiles( $context );
398  if ( $packageFiles !== null ) {
399  foreach ( $packageFiles['files'] as $fileName => $file ) {
400  if ( $file['type'] === 'script+style' ) {
401  $style = $this->processStyle(
402  $file['content']['style'],
403  $file['content']['styleLang'],
404  $fileName,
405  $context
406  );
407  $styles['all'] = ( $styles['all'] ?? '' ) . "\n" . $style;
408  }
409  }
410  }
411 
412  // Track indirect file dependencies so that StartUpModule can check for
413  // on-disk file changes to any of this files without having to recompute the file list
414  $this->saveFileDependencies( $context, $this->localFileRefs );
415 
416  return $styles;
417  }
418 
423  public function getStyleURLsForDebug( Context $context ) {
424  if ( $this->hasGeneratedStyles ) {
425  // Do the default behaviour of returning a url back to load.php
426  // but with only=styles.
427  return parent::getStyleURLsForDebug( $context );
428  }
429  // Our module consists entirely of real css files,
430  // in debug mode we can load those directly.
431  $urls = [];
432  foreach ( $this->getStyleFiles( $context ) as $mediaType => $list ) {
433  $urls[$mediaType] = [];
434  foreach ( $list as $file ) {
435  $urls[$mediaType][] = OutputPage::transformResourcePath(
436  $this->getConfig(),
437  $this->getRemotePath( $file )
438  );
439  }
440  }
441  return $urls;
442  }
443 
449  public function getMessages() {
450  return $this->messages;
451  }
452 
458  public function getGroup() {
459  return $this->group;
460  }
461 
468  public function getDependencies( Context $context = null ) {
469  return $this->dependencies;
470  }
471 
479  private function getFileContents( $localPath, $type ) {
480  if ( !is_file( $localPath ) ) {
481  throw new RuntimeException( "$type file not found or not a file: \"$localPath\"" );
482  }
483  return $this->stripBom( file_get_contents( $localPath ) );
484  }
485 
489  public function getSkipFunction() {
490  if ( !$this->skipFunction ) {
491  return null;
492  }
493  $localPath = $this->getLocalPath( $this->skipFunction );
494  return $this->getFileContents( $localPath, 'skip function' );
495  }
496 
497  public function requiresES6() {
498  return $this->es6;
499  }
500 
509  public function enableModuleContentVersion() {
510  return false;
511  }
512 
519  private function getFileHashes( Context $context ) {
520  $files = [];
521 
522  $styleFiles = $this->getStyleFiles( $context );
523  foreach ( $styleFiles as $paths ) {
524  $files = array_merge( $files, $paths );
525  }
526 
527  // Extract file paths for package files
528  // Optimisation: Use foreach() and isset() instead of array_map/array_filter.
529  // This is a hot code path, called by StartupModule for thousands of modules.
530  $expandedPackageFiles = $this->expandPackageFiles( $context );
531  $packageFiles = [];
532  if ( $expandedPackageFiles ) {
533  foreach ( $expandedPackageFiles['files'] as $fileInfo ) {
534  if ( isset( $fileInfo['filePath'] ) ) {
535  $packageFiles[] = $fileInfo['filePath'];
536  }
537  }
538  }
539 
540  // Merge all the file paths we were able discover directly from the module definition.
541  // This is the primary list of direct-dependent files for this module.
542  $files = array_merge(
543  $files,
545  $this->scripts,
546  $this->templates,
547  $context->getDebug() ? $this->debugScripts : [],
548  $this->getLanguageScripts( $context->getLanguage() ),
549  self::tryForKey( $this->skinScripts, $context->getSkin(), 'default' )
550  );
551  if ( $this->skipFunction ) {
552  $files[] = $this->skipFunction;
553  }
554 
555  // Expand these local paths into absolute file paths
556  $files = array_map( [ $this, 'getLocalPath' ], $files );
557 
558  // Add any lazily discovered file dependencies from previous module builds.
559  // These are added last because they are already absolute file paths.
560  $files = array_merge( $files, $this->getFileDependencies( $context ) );
561 
562  // Filter out any duplicates. Typically introduced by getFileDependencies() which
563  // may lazily re-discover a primary file.
564  $files = array_unique( $files );
565 
566  // Don't return array keys or any other form of file path here, only the hashes.
567  // Including file paths would needlessly cause global cache invalidation when files
568  // move on disk or if e.g. the MediaWiki directory name changes.
569  // Anything where order is significant is already detected by the definition summary.
571  }
572 
579  public function getDefinitionSummary( Context $context ) {
580  $summary = parent::getDefinitionSummary( $context );
581 
582  $options = [];
583  foreach ( [
584  // The following properties are omitted because they don't affect the module response:
585  // - localBasePath (Per T104950; Changes when absolute directory name changes. If
586  // this affects 'scripts' and other file paths, getFileHashes accounts for that.)
587  // - remoteBasePath (Per T104950)
588  // - dependencies (provided via startup module)
589  // - targets
590  // - group (provided via startup module)
591  'scripts',
592  'debugScripts',
593  'styles',
594  'languageScripts',
595  'skinScripts',
596  'skinStyles',
597  'messages',
598  'templates',
599  'skipFunction',
600  'debugRaw',
601  ] as $member ) {
602  $options[$member] = $this->{$member};
603  }
604 
605  $packageFiles = $this->expandPackageFiles( $context );
606  if ( $packageFiles ) {
607  // Extract the minimum needed:
608  // - The 'main' pointer (included as-is).
609  // - The 'files' array, simplified to only which files exist (the keys of
610  // this array), and something that represents their non-file content.
611  // For packaged files that reflect files directly from disk, the
612  // 'getFileHashes' method tracks their content already.
613  // It is important that the keys of the $packageFiles['files'] array
614  // are preserved, as they do affect the module output.
615  $packageFiles['files'] = array_map( static function ( $fileInfo ) {
616  return $fileInfo['definitionSummary'] ?? ( $fileInfo['content'] ?? null );
617  }, $packageFiles['files'] );
618  }
619 
620  $summary[] = [
621  'options' => $options,
622  'packageFiles' => $packageFiles,
623  'fileHashes' => $this->getFileHashes( $context ),
624  'messageBlob' => $this->getMessageBlob( $context ),
625  ];
626 
627  $lessVars = $this->getLessVars( $context );
628  if ( $lessVars ) {
629  $summary[] = [ 'lessVars' => $lessVars ];
630  }
631 
632  return $summary;
633  }
634 
638  protected function getVueComponentParser() {
639  if ( $this->vueComponentParser === null ) {
640  $this->vueComponentParser = new VueComponentParser;
641  }
643  }
644 
649  protected function getPath( $path ) {
650  if ( $path instanceof FilePath ) {
651  return $path->getPath();
652  }
653 
654  return $path;
655  }
656 
661  protected function getLocalPath( $path ) {
662  if ( $path instanceof FilePath ) {
663  if ( $path->getLocalBasePath() !== null ) {
664  return $path->getLocalPath();
665  }
666  $path = $path->getPath();
667  }
668 
669  return "{$this->localBasePath}/$path";
670  }
671 
676  protected function getRemotePath( $path ) {
677  if ( $path instanceof FilePath ) {
678  if ( $path->getRemoteBasePath() !== null ) {
679  return $path->getRemotePath();
680  }
681  $path = $path->getPath();
682  }
683 
684  if ( $this->remoteBasePath === '/' ) {
685  return "/$path";
686  } else {
687  return "{$this->remoteBasePath}/$path";
688  }
689  }
690 
698  public function getStyleSheetLang( $path ) {
699  return preg_match( '/\.less$/i', $path ) ? 'less' : 'css';
700  }
701 
707  public static function getPackageFileType( $path ) {
708  if ( preg_match( '/\.json$/i', $path ) ) {
709  return 'data';
710  }
711  if ( preg_match( '/\.vue$/i', $path ) ) {
712  return 'script-vue';
713  }
714  return 'script';
715  }
716 
724  private static function collateStyleFilesByMedia( array $list ) {
725  $collatedFiles = [];
726  foreach ( $list as $key => $value ) {
727  if ( is_int( $key ) ) {
728  // File name as the value
729  $collatedFiles['all'][] = $value;
730  } elseif ( is_array( $value ) ) {
731  // File name as the key, options array as the value
732  $optionValue = $value['media'] ?? 'all';
733  $collatedFiles[$optionValue][] = $key;
734  }
735  }
736  return $collatedFiles;
737  }
738 
748  protected static function tryForKey( array $list, $key, $fallback = null ) {
749  if ( isset( $list[$key] ) && is_array( $list[$key] ) ) {
750  return $list[$key];
751  } elseif ( is_string( $fallback )
752  && isset( $list[$fallback] )
753  && is_array( $list[$fallback] )
754  ) {
755  return $list[$fallback];
756  }
757  return [];
758  }
759 
766  private function getScriptFiles( Context $context ): array {
767  // List in execution order: scripts, languageScripts, skinScripts, debugScripts.
768  // Documented at MediaWiki\MainConfigSchema::ResourceModules.
769  $files = array_merge(
770  $this->scripts,
771  $this->getLanguageScripts( $context->getLanguage() ),
772  self::tryForKey( $this->skinScripts, $context->getSkin(), 'default' )
773  );
774  if ( $context->getDebug() ) {
775  $files = array_merge( $files, $this->debugScripts );
776  }
777 
778  return array_unique( $files, SORT_REGULAR );
779  }
780 
788  private function getLanguageScripts( string $lang ): array {
789  $scripts = self::tryForKey( $this->languageScripts, $lang );
790  if ( $scripts ) {
791  return $scripts;
792  }
793 
794  // Optimization: Avoid initialising and calling into language services
795  // for the majority of modules that don't use this option.
796  if ( $this->languageScripts ) {
797  $fallbacks = MediaWikiServices::getInstance()
798  ->getLanguageFallback()
799  ->getAll( $lang, LanguageFallback::MESSAGES );
800  foreach ( $fallbacks as $lang ) {
801  $scripts = self::tryForKey( $this->languageScripts, $lang );
802  if ( $scripts ) {
803  return $scripts;
804  }
805  }
806  }
807 
808  return [];
809  }
810 
811  public function setSkinStylesOverride( array $moduleSkinStyles ): void {
812  $moduleName = $this->getName();
813  foreach ( $moduleSkinStyles as $skinName => $overrides ) {
814  // If a module provides overrides for a skin, and that skin also provides overrides
815  // for the same module, then the module has precedence.
816  if ( isset( $this->skinStyles[$skinName] ) ) {
817  continue;
818  }
819 
820  // If $moduleName in ResourceModuleSkinStyles is preceded with a '+', the defined style
821  // files will be added to 'default' skinStyles, otherwise 'default' will be ignored.
822  if ( isset( $overrides[$moduleName] ) ) {
823  $paths = (array)$overrides[$moduleName];
824  $styleFiles = [];
825  } elseif ( isset( $overrides['+' . $moduleName] ) ) {
826  $paths = (array)$overrides['+' . $moduleName];
827  $styleFiles = isset( $this->skinStyles['default'] ) ?
828  (array)$this->skinStyles['default'] :
829  [];
830  } else {
831  continue;
832  }
833 
834  // Add new file paths, remapping them to refer to our directories and not use settings
835  // from the module we're modifying, which come from the base definition.
836  [ $localBasePath, $remoteBasePath ] = self::extractBasePaths( $overrides );
837 
838  foreach ( $paths as $path ) {
839  $styleFiles[] = new FilePath( $path, $localBasePath, $remoteBasePath );
840  }
841 
842  $this->skinStyles[$skinName] = $styleFiles;
843  }
844  }
845 
853  public function getStyleFiles( Context $context ) {
854  return array_merge_recursive(
855  self::collateStyleFilesByMedia( $this->styles ),
856  self::collateStyleFilesByMedia(
857  self::tryForKey( $this->skinStyles, $context->getSkin(), 'default' )
858  )
859  );
860  }
861 
869  protected function getSkinStyleFiles( $skinName ) {
870  return self::collateStyleFilesByMedia(
871  self::tryForKey( $this->skinStyles, $skinName )
872  );
873  }
874 
881  protected function getAllSkinStyleFiles() {
882  $skinFactory = MediaWikiServices::getInstance()->getSkinFactory();
883  $styleFiles = [];
884 
885  $internalSkinNames = array_keys( $skinFactory->getInstalledSkins() );
886  $internalSkinNames[] = 'default';
887 
888  foreach ( $internalSkinNames as $internalSkinName ) {
889  $styleFiles = array_merge_recursive(
890  $styleFiles,
891  $this->getSkinStyleFiles( $internalSkinName )
892  );
893  }
894 
895  return $styleFiles;
896  }
897 
903  public function getAllStyleFiles() {
904  $collatedStyleFiles = array_merge_recursive(
905  self::collateStyleFilesByMedia( $this->styles ),
906  $this->getAllSkinStyleFiles()
907  );
908 
909  $result = [];
910 
911  foreach ( $collatedStyleFiles as $styleFiles ) {
912  foreach ( $styleFiles as $styleFile ) {
913  $result[] = $this->getLocalPath( $styleFile );
914  }
915  }
916 
917  return $result;
918  }
919 
926  private function readScriptFiles( array $scripts ) {
927  if ( !$scripts ) {
928  return '';
929  }
930  $js = '';
931  foreach ( array_unique( $scripts, SORT_REGULAR ) as $fileName ) {
932  $localPath = $this->getLocalPath( $fileName );
933  $contents = $this->getFileContents( $localPath, 'script' );
934  $js .= ResourceLoader::ensureNewline( $contents );
935  }
936  return $js;
937  }
938 
947  public function readStyleFiles( array $styles, Context $context ) {
948  if ( !$styles ) {
949  return [];
950  }
951  foreach ( $styles as $media => $files ) {
952  $uniqueFiles = array_unique( $files, SORT_REGULAR );
953  $styleFiles = [];
954  foreach ( $uniqueFiles as $file ) {
955  $styleFiles[] = $this->readStyleFile( $file, $context );
956  }
957  $styles[$media] = implode( "\n", $styleFiles );
958  }
959  return $styles;
960  }
961 
972  protected function readStyleFile( $path, Context $context ) {
973  $localPath = $this->getLocalPath( $path );
974  $style = $this->getFileContents( $localPath, 'style' );
975  $styleLang = $this->getStyleSheetLang( $localPath );
976 
977  return $this->processStyle( $style, $styleLang, $path, $context );
978  }
979 
996  protected function processStyle( $style, $styleLang, $path, Context $context ) {
997  $localPath = $this->getLocalPath( $path );
998  $remotePath = $this->getRemotePath( $path );
999 
1000  if ( $styleLang === 'less' ) {
1001  $style = $this->compileLessString( $style, $localPath, $context );
1002  $this->hasGeneratedStyles = true;
1003  }
1004 
1005  if ( $this->getFlip( $context ) ) {
1006  $style = CSSJanus::transform(
1007  $style,
1008  /* $swapLtrRtlInURL = */ true,
1009  /* $swapLeftRightInURL = */ false
1010  );
1011  }
1012 
1013  $localDir = dirname( $localPath );
1014  $remoteDir = dirname( $remotePath );
1015  // Get and register local file references
1016  $localFileRefs = CSSMin::getLocalFileReferences( $style, $localDir );
1017  foreach ( $localFileRefs as $file ) {
1018  if ( is_file( $file ) ) {
1019  $this->localFileRefs[] = $file;
1020  } else {
1021  $this->missingLocalFileRefs[] = $file;
1022  }
1023  }
1024  // Don't cache this call. remap() ensures data URIs embeds are up to date,
1025  // and urls contain correct content hashes in their query string. (T128668)
1026  return CSSMin::remap( $style, $localDir, $remoteDir, true );
1027  }
1028 
1034  public function getFlip( Context $context ) {
1035  return $context->getDirection() === 'rtl' && !$this->noflip;
1036  }
1037 
1043  public function getTargets() {
1044  return $this->targets;
1045  }
1046 
1053  public function getType() {
1054  $canBeStylesOnly = !(
1055  // All options except 'styles', 'skinStyles' and 'debugRaw'
1056  $this->scripts
1057  || $this->debugScripts
1058  || $this->templates
1059  || $this->languageScripts
1060  || $this->skinScripts
1061  || $this->dependencies
1062  || $this->messages
1063  || $this->skipFunction
1064  || $this->packageFiles
1065  );
1066  return $canBeStylesOnly ? self::LOAD_STYLES : self::LOAD_GENERAL;
1067  }
1068 
1080  protected function compileLessString( $style, $stylePath, Context $context ) {
1081  static $cache;
1082  // @TODO: dependency injection
1083  if ( !$cache ) {
1085  }
1086 
1087  $skinName = $context->getSkin();
1088  $skinImportPaths = ExtensionRegistry::getInstance()->getAttribute( 'SkinLessImportPaths' );
1089  $importDirs = [];
1090  if ( isset( $skinImportPaths[ $skinName ] ) ) {
1091  $importDirs[] = $skinImportPaths[ $skinName ];
1092  }
1093 
1094  $vars = $this->getLessVars( $context );
1095  // Construct a cache key from a hash of the LESS source, and a hash digest
1096  // of the LESS variables used for compilation.
1097  ksort( $vars );
1098  $compilerParams = [
1099  'vars' => $vars,
1100  'importDirs' => $importDirs,
1101  ];
1102  $key = $cache->makeGlobalKey(
1103  'resourceloader-less',
1104  'v1',
1105  hash( 'md4', $style ),
1106  hash( 'md4', serialize( $compilerParams ) )
1107  );
1108 
1109  // If we got a cached value, we have to validate it by getting a checksum of all the
1110  // files that were loaded by the parser and ensuring it matches the cached entry's.
1111  $data = $cache->get( $key );
1112  if (
1113  !$data ||
1114  $data['hash'] !== FileContentsHasher::getFileContentsHash( $data['files'] )
1115  ) {
1116  $compiler = $context->getResourceLoader()->getLessCompiler( $vars, $importDirs );
1117 
1118  $css = $compiler->parse( $style, $stylePath )->getCss();
1119  // T253055: store the implicit dependency paths in a form relative to any install
1120  // path so that multiple version of the application can share the cache for identical
1121  // less stylesheets. This also avoids churn during application updates.
1122  $files = $compiler->AllParsedFiles();
1123  $data = [
1124  'css' => $css,
1125  'files' => Module::getRelativePaths( $files ),
1126  'hash' => FileContentsHasher::getFileContentsHash( $files )
1127  ];
1128  $cache->set( $key, $data, $cache::TTL_DAY );
1129  }
1130 
1131  foreach ( Module::expandRelativePaths( $data['files'] ) as $path ) {
1132  $this->localFileRefs[] = $path;
1133  }
1134 
1135  return $data['css'];
1136  }
1137 
1143  public function getTemplates() {
1144  $templates = [];
1145 
1146  foreach ( $this->templates as $alias => $templatePath ) {
1147  // Alias is optional
1148  if ( is_int( $alias ) ) {
1149  $alias = $this->getPath( $templatePath );
1150  }
1151  $localPath = $this->getLocalPath( $templatePath );
1152  $content = $this->getFileContents( $localPath, 'template' );
1153 
1154  $templates[$alias] = $this->stripBom( $content );
1155  }
1156  return $templates;
1157  }
1158 
1177  private function expandPackageFiles( Context $context ) {
1178  $hash = $context->getHash();
1179  if ( isset( $this->expandedPackageFiles[$hash] ) ) {
1180  return $this->expandedPackageFiles[$hash];
1181  }
1182  if ( $this->packageFiles === null ) {
1183  return null;
1184  }
1185  $expandedFiles = [];
1186  $mainFile = null;
1187 
1188  foreach ( $this->packageFiles as $key => $fileInfo ) {
1189  if ( !is_array( $fileInfo ) ) {
1190  $fileInfo = [ 'name' => $fileInfo, 'file' => $fileInfo ];
1191  }
1192  if ( !isset( $fileInfo['name'] ) ) {
1193  $msg = "Missing 'name' key in package file info for module '{$this->getName()}'," .
1194  " offset '{$key}'.";
1195  $this->getLogger()->error( $msg );
1196  throw new LogicException( $msg );
1197  }
1198  $fileName = $this->getPath( $fileInfo['name'] );
1199 
1200  // Infer type from alias if needed
1201  $type = $fileInfo['type'] ?? self::getPackageFileType( $fileName );
1202  $expanded = [ 'type' => $type ];
1203  if ( !empty( $fileInfo['main'] ) ) {
1204  $mainFile = $fileName;
1205  if ( $type !== 'script' && $type !== 'script-vue' ) {
1206  $msg = "Main file in package must be of type 'script', module " .
1207  "'{$this->getName()}', main file '{$mainFile}' is '{$type}'.";
1208  $this->getLogger()->error( $msg );
1209  throw new LogicException( $msg );
1210  }
1211  }
1212 
1213  // Perform expansions (except 'file' and 'callback'), creating one of these keys:
1214  // - 'content': literal value.
1215  // - 'filePath': content to be read from a file.
1216  // - 'callback': content computed by a callable.
1217  if ( isset( $fileInfo['content'] ) ) {
1218  $expanded['content'] = $fileInfo['content'];
1219  } elseif ( isset( $fileInfo['file'] ) ) {
1220  $expanded['filePath'] = $fileInfo['file'];
1221  } elseif ( isset( $fileInfo['callback'] ) ) {
1222  // If no extra parameter for the callback is given, use null.
1223  $expanded['callbackParam'] = $fileInfo['callbackParam'] ?? null;
1224 
1225  if ( !is_callable( $fileInfo['callback'] ) ) {
1226  $msg = "Invalid 'callback' for module '{$this->getName()}', file '{$fileName}'.";
1227  $this->getLogger()->error( $msg );
1228  throw new LogicException( $msg );
1229  }
1230  if ( isset( $fileInfo['versionCallback'] ) ) {
1231  if ( !is_callable( $fileInfo['versionCallback'] ) ) {
1232  throw new LogicException( "Invalid 'versionCallback' for "
1233  . "module '{$this->getName()}', file '{$fileName}'."
1234  );
1235  }
1236 
1237  // Execute the versionCallback with the same arguments that
1238  // would be given to the callback
1239  $callbackResult = ( $fileInfo['versionCallback'] )(
1240  $context,
1241  $this->getConfig(),
1242  $expanded['callbackParam']
1243  );
1244  if ( $callbackResult instanceof FilePath ) {
1245  $expanded['filePath'] = $callbackResult;
1246  } else {
1247  $expanded['definitionSummary'] = $callbackResult;
1248  }
1249  // Don't invoke 'callback' here as it may be expensive (T223260).
1250  $expanded['callback'] = $fileInfo['callback'];
1251  } else {
1252  // Else go ahead invoke callback with its arguments.
1253  $callbackResult = ( $fileInfo['callback'] )(
1254  $context,
1255  $this->getConfig(),
1256  $expanded['callbackParam']
1257  );
1258  if ( $callbackResult instanceof FilePath ) {
1259  $expanded['filePath'] = $callbackResult;
1260  } else {
1261  $expanded['content'] = $callbackResult;
1262  }
1263  }
1264  } elseif ( isset( $fileInfo['config'] ) ) {
1265  if ( $type !== 'data' ) {
1266  $msg = "Key 'config' only valid for data files. "
1267  . " Module '{$this->getName()}', file '{$fileName}' is '{$type}'.";
1268  $this->getLogger()->error( $msg );
1269  throw new LogicException( $msg );
1270  }
1271  $expandedConfig = [];
1272  foreach ( $fileInfo['config'] as $configKey => $var ) {
1273  $expandedConfig[ is_numeric( $configKey ) ? $var : $configKey ] = $this->getConfig()->get( $var );
1274  }
1275  $expanded['content'] = $expandedConfig;
1276  } elseif ( !empty( $fileInfo['main'] ) ) {
1277  // [ 'name' => 'foo.js', 'main' => true ] is shorthand
1278  $expanded['filePath'] = $fileName;
1279  } else {
1280  $msg = "Incomplete definition for module '{$this->getName()}', file '{$fileName}'. "
1281  . "One of 'file', 'content', 'callback', or 'config' must be set.";
1282  $this->getLogger()->error( $msg );
1283  throw new LogicException( $msg );
1284  }
1285 
1286  $expandedFiles[$fileName] = $expanded;
1287  }
1288 
1289  if ( $expandedFiles && $mainFile === null ) {
1290  // The first package file that is a script is the main file
1291  foreach ( $expandedFiles as $path => $file ) {
1292  if ( $file['type'] === 'script' || $file['type'] === 'script-vue' ) {
1293  $mainFile = $path;
1294  break;
1295  }
1296  }
1297  }
1298 
1299  $result = [
1300  'main' => $mainFile,
1301  'files' => $expandedFiles
1302  ];
1303 
1304  $this->expandedPackageFiles[$hash] = $result;
1305  return $result;
1306  }
1307 
1314  public function getPackageFiles( Context $context ) {
1315  if ( $this->packageFiles === null ) {
1316  return null;
1317  }
1318  $hash = $context->getHash();
1319  if ( isset( $this->fullyExpandedPackageFiles[ $hash ] ) ) {
1320  return $this->fullyExpandedPackageFiles[ $hash ];
1321  }
1322  $expandedPackageFiles = $this->expandPackageFiles( $context );
1323 
1324  // Expand file contents
1325  foreach ( $expandedPackageFiles['files'] as $fileName => &$fileInfo ) {
1326  // Turn any 'filePath' or 'callback' key into actual 'content',
1327  // and remove the key after that. The callback could return a
1328  // ResourceLoaderFilePath object; if that happens, fall through
1329  // to the 'filePath' handling.
1330  if ( isset( $fileInfo['callback'] ) ) {
1331  $callbackResult = ( $fileInfo['callback'] )(
1332  $context,
1333  $this->getConfig(),
1334  $fileInfo['callbackParam']
1335  );
1336  if ( $callbackResult instanceof FilePath ) {
1337  // Fall through to the filePath handling code below
1338  $fileInfo['filePath'] = $callbackResult;
1339  } else {
1340  $fileInfo['content'] = $callbackResult;
1341  }
1342  unset( $fileInfo['callback'] );
1343  }
1344  // Only interpret 'filePath' if 'content' hasn't been set already.
1345  // This can happen if 'versionCallback' provided 'filePath',
1346  // while 'callback' provides 'content'. In that case both are set
1347  // at this point. The 'filePath' from 'versionCallback' in that case is
1348  // only to inform getDefinitionSummary().
1349  if ( !isset( $fileInfo['content'] ) && isset( $fileInfo['filePath'] ) ) {
1350  $localPath = $this->getLocalPath( $fileInfo['filePath'] );
1351  $content = $this->getFileContents( $localPath, 'package' );
1352  if ( $fileInfo['type'] === 'data' ) {
1353  $content = json_decode( $content );
1354  }
1355  $fileInfo['content'] = $content;
1356  unset( $fileInfo['filePath'] );
1357  }
1358  if ( $fileInfo['type'] === 'script-vue' ) {
1359  try {
1360  $parsedComponent = $this->getVueComponentParser()->parse(
1361  // @phan-suppress-next-line PhanTypePossiblyInvalidDimOffset False positive
1362  $fileInfo['content'],
1363  [ 'minifyTemplate' => !$context->getDebug() ]
1364  );
1365  } catch ( TimeoutException $e ) {
1366  throw $e;
1367  } catch ( Exception $e ) {
1368  $msg = "Error parsing file '$fileName' in module '{$this->getName()}': " .
1369  $e->getMessage();
1370  $this->getLogger()->error( $msg );
1371  throw new RuntimeException( $msg );
1372  }
1373  $encodedTemplate = json_encode( $parsedComponent['template'] );
1374  if ( $context->getDebug() ) {
1375  // Replace \n (backslash-n) with space + backslash-newline in debug mode
1376  // We only replace \n if not preceded by a backslash, to avoid breaking '\\n'
1377  $encodedTemplate = preg_replace( '/(?<!\\\\)\\\\n/', " \\\n", $encodedTemplate );
1378  // Expand \t to real tabs in debug mode
1379  $encodedTemplate = strtr( $encodedTemplate, [ "\\t" => "\t" ] );
1380  }
1381  $fileInfo['content'] = [
1382  'script' => $parsedComponent['script'] .
1383  ";\nmodule.exports.template = $encodedTemplate;",
1384  'style' => $parsedComponent['style'] ?? '',
1385  'styleLang' => $parsedComponent['styleLang'] ?? 'css'
1386  ];
1387  $fileInfo['type'] = 'script+style';
1388  }
1389 
1390  // Not needed for client response, exists for use by getDefinitionSummary().
1391  unset( $fileInfo['definitionSummary'] );
1392  // Not needed for client response, used by callbacks only.
1393  unset( $fileInfo['callbackParam'] );
1394  }
1395 
1396  $this->fullyExpandedPackageFiles[ $hash ] = $expandedPackageFiles;
1397  return $expandedPackageFiles;
1398  }
1399 
1410  protected function stripBom( $input ) {
1411  if ( str_starts_with( $input, "\xef\xbb\xbf" ) ) {
1412  return substr( $input, 3 );
1413  }
1414  return $input;
1415  }
1416 }
1417 
1419 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:91
The Registry loads JSON files, and uses a Processor to extract information from them.
static getFileContentsHash( $filePaths)
Get a hash of the combined contents of one or more files, either by retrieving a previously-computed ...
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:442
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:468
array< string, array< int, string|FilePath > > $skinScripts
Lists of JavaScript files by skin name.
Definition: FileModule.php:73
getScriptURLsForDebug(Context $context)
Definition: FileModule.php:361
static tryForKey(array $list, $key, $fallback=null)
Get a list of element that match a key, optionally using a fallback key.
Definition: FileModule.php:748
getAllSkinStyleFiles()
Gets a list of file paths for all skin style files in the module, for all available skins.
Definition: FileModule.php:881
readStyleFile( $path, Context $context)
Read and process a style file.
Definition: FileModule.php:972
getTemplates()
Get content of named templates for this module.
getStyleSheetLang( $path)
Infer the stylesheet language from a stylesheet file path.
Definition: FileModule.php:698
processStyle( $style, $styleLang, $path, Context $context)
Process a CSS/LESS string.
Definition: FileModule.php:996
getDefinitionSummary(Context $context)
Get the definition summary for this module.
Definition: FileModule.php:579
requiresES6()
Whether the module requires ES6 support in the client.
Definition: FileModule.php:497
readStyleFiles(array $styles, Context $context)
Read the contents of a list of CSS files and remap and concatenate these.
Definition: FileModule.php:947
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:139
enableModuleContentVersion()
Disable module content versioning.
Definition: FileModule.php:509
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:281
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)
Takes an input string and removes the UTF-8 BOM character if present.
getFlip(Context $context)
Get whether CSS for this module should be flipped.
getSkinStyleFiles( $skinName)
Gets a list of file paths for all skin styles in the module used by the skin.
Definition: FileModule.php:869
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:164
getPackageFiles(Context $context)
Resolve the package files definition and generates the content of each package file.
array< int, string|FilePath > $debugScripts
List of paths to JavaScript files to include in debug mode.
Definition: FileModule.php:78
__construct(array $options=[], string $localBasePath=null, string $remoteBasePath=null)
Constructs a new module from an options array.
Definition: FileModule.php:175
string $localBasePath
Local base path, see __construct()
Definition: FileModule.php:55
string[] $localFileRefs
Place where readStyleFile() tracks file dependencies.
Definition: FileModule.php:153
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:148
getStyleURLsForDebug(Context $context)
Definition: FileModule.php:423
getStyleFiles(Context $context)
Get a list of file paths for all styles in this module, in order of proper inclusion.
Definition: FileModule.php:853
bool $es6
Whether this module requires the client to support ES6.
Definition: FileModule.php:142
getScript(Context $context)
Gets all scripts for a given context concatenated together.
Definition: FileModule.php:336
getMessages()
Get message keys used by this module.
Definition: FileModule.php:449
getAllStyleFiles()
Returns all style files and all skin style files used by this module.
Definition: FileModule.php:903
getGroup()
Get the name of the group this module should be loaded in.
Definition: FileModule.php:458
string[] $missingLocalFileRefs
Place where readStyleFile() tracks file dependencies for non-existent files.
Definition: FileModule.php:159
getStyles(Context $context)
Get all styles for a given context.
Definition: FileModule.php:391
static getPackageFileType( $path)
Infer the file type from a package file path.
Definition: FileModule.php:707
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:811
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:583
getLessVars(Context $context)
Get module-specific LESS variables, if any.
Definition: Module.php:770
getFileDependencies(Context $context)
Get the indirect dependencies for this module pursuant to the skin/language context.
Definition: Module.php:545
getDeprecationInformation(Context $context)
Get JS representing deprecation information for the current module if available.
Definition: Module.php:200
getMessageBlob(Context $context)
Get the hash of the message blob.
Definition: Module.php:657
Parser for Vue single file components (.vue files).
Functions to get cache objects.
Definition: ObjectCache.php:65
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:57
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