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.
184  list( $this->localBasePath, $this->remoteBasePath ) =
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 ( $localBasePath === null ) {
292  }
293  if ( $remoteBasePath === null ) {
296  }
297 
298  if ( isset( $options['remoteExtPath'] ) ) {
299  $extensionAssetsPath = MediaWikiServices::getInstance()->getMainConfig()
301  $remoteBasePath = $extensionAssetsPath . '/' . $options['remoteExtPath'];
302  }
303 
304  if ( isset( $options['remoteSkinPath'] ) ) {
305  $stylePath = MediaWikiServices::getInstance()->getMainConfig()
307  $remoteBasePath = $stylePath . '/' . $options['remoteSkinPath'];
308  }
309 
310  if ( array_key_exists( 'localBasePath', $options ) ) {
311  $localBasePath = (string)$options['localBasePath'];
312  }
313 
314  if ( array_key_exists( 'remoteBasePath', $options ) ) {
315  $remoteBasePath = (string)$options['remoteBasePath'];
316  }
317 
318  if ( $remoteBasePath === '' ) {
319  // If MediaWiki is installed at the document root (not recommended),
320  // then wgScriptPath is set to the empty string by the installer to
321  // ensure safe concatenating of file paths (avoid "/" + "/foo" being "//foo").
322  // However, this also means the path itself can be an invalid URI path,
323  // as those must start with a slash. Within ResourceLoader, we will not
324  // do such primitive/unsafe slash concatenation and use URI resolution
325  // instead, so beyond this point, to avoid fatal errors in CSSMin::resolveUrl(),
326  // do a best-effort support for docroot installs by casting this to a slash.
327  $remoteBasePath = '/';
328  }
329 
330  return [ $localBasePath, $remoteBasePath ];
331  }
332 
339  public function getScript( Context $context ) {
340  $deprecationScript = $this->getDeprecationInformation( $context );
341  $packageFiles = $this->getPackageFiles( $context );
342  if ( $packageFiles !== null ) {
343  foreach ( $packageFiles['files'] as &$file ) {
344  if ( $file['type'] === 'script+style' ) {
345  $file['content'] = $file['content']['script'];
346  $file['type'] = 'script';
347  }
348  }
349  if ( $deprecationScript ) {
350  $mainFile =& $packageFiles['files'][$packageFiles['main']];
351  $mainFile['content'] = $deprecationScript . $mainFile['content'];
352  }
353  return $packageFiles;
354  }
355 
356  $files = $this->getScriptFiles( $context );
357  return $deprecationScript . $this->readScriptFiles( $files );
358  }
359 
364  public function getScriptURLsForDebug( Context $context ) {
365  $rl = $context->getResourceLoader();
366  $config = $this->getConfig();
367  $server = $config->get( MainConfigNames::Server );
368 
369  $urls = [];
370  foreach ( $this->getScriptFiles( $context ) as $file ) {
371  $url = OutputPage::transformResourcePath( $config, $this->getRemotePath( $file ) );
372  // Expand debug URL in case we are another wiki's module source (T255367)
373  $url = $rl->expandUrl( $server, $url );
374  $urls[] = $url;
375  }
376  return $urls;
377  }
378 
382  public function supportsURLLoading() {
383  // If package files are involved, don't support URL loading, because that breaks
384  // scoped require() functions
385  return $this->debugRaw && !$this->packageFiles;
386  }
387 
394  public function getStyles( Context $context ) {
395  $styles = $this->readStyleFiles(
396  $this->getStyleFiles( $context ),
397  $context
398  );
399 
400  $packageFiles = $this->getPackageFiles( $context );
401  if ( $packageFiles !== null ) {
402  foreach ( $packageFiles['files'] as $fileName => $file ) {
403  if ( $file['type'] === 'script+style' ) {
404  $style = $this->processStyle(
405  $file['content']['style'],
406  $file['content']['styleLang'],
407  $fileName,
408  $context
409  );
410  $styles['all'] = ( $styles['all'] ?? '' ) . "\n" . $style;
411  }
412  }
413  }
414 
415  // Track indirect file dependencies so that StartUpModule can check for
416  // on-disk file changes to any of this files without having to recompute the file list
417  $this->saveFileDependencies( $context, $this->localFileRefs );
418 
419  return $styles;
420  }
421 
426  public function getStyleURLsForDebug( Context $context ) {
427  if ( $this->hasGeneratedStyles ) {
428  // Do the default behaviour of returning a url back to load.php
429  // but with only=styles.
430  return parent::getStyleURLsForDebug( $context );
431  }
432  // Our module consists entirely of real css files,
433  // in debug mode we can load those directly.
434  $urls = [];
435  foreach ( $this->getStyleFiles( $context ) as $mediaType => $list ) {
436  $urls[$mediaType] = [];
437  foreach ( $list as $file ) {
438  $urls[$mediaType][] = OutputPage::transformResourcePath(
439  $this->getConfig(),
440  $this->getRemotePath( $file )
441  );
442  }
443  }
444  return $urls;
445  }
446 
452  public function getMessages() {
453  return $this->messages;
454  }
455 
461  public function getGroup() {
462  return $this->group;
463  }
464 
471  public function getDependencies( Context $context = null ) {
472  return $this->dependencies;
473  }
474 
482  private function getFileContents( $localPath, $type ) {
483  if ( !is_file( $localPath ) ) {
484  throw new RuntimeException( "$type file not found or not a file: \"$localPath\"" );
485  }
486  return $this->stripBom( file_get_contents( $localPath ) );
487  }
488 
492  public function getSkipFunction() {
493  if ( !$this->skipFunction ) {
494  return null;
495  }
496  $localPath = $this->getLocalPath( $this->skipFunction );
497  return $this->getFileContents( $localPath, 'skip function' );
498  }
499 
500  public function requiresES6() {
501  return $this->es6;
502  }
503 
512  public function enableModuleContentVersion() {
513  return false;
514  }
515 
522  private function getFileHashes( Context $context ) {
523  $files = [];
524 
525  $styleFiles = $this->getStyleFiles( $context );
526  foreach ( $styleFiles as $paths ) {
527  $files = array_merge( $files, $paths );
528  }
529 
530  // Extract file paths for package files
531  // Optimisation: Use foreach() and isset() instead of array_map/array_filter.
532  // This is a hot code path, called by StartupModule for thousands of modules.
533  $expandedPackageFiles = $this->expandPackageFiles( $context );
534  $packageFiles = [];
535  if ( $expandedPackageFiles ) {
536  foreach ( $expandedPackageFiles['files'] as $fileInfo ) {
537  if ( isset( $fileInfo['filePath'] ) ) {
538  $packageFiles[] = $fileInfo['filePath'];
539  }
540  }
541  }
542 
543  // Merge all the file paths we were able discover directly from the module definition.
544  // This is the primary list of direct-dependent files for this module.
545  $files = array_merge(
546  $files,
548  $this->scripts,
549  $this->templates,
550  $context->getDebug() ? $this->debugScripts : [],
551  $this->getLanguageScripts( $context->getLanguage() ),
552  self::tryForKey( $this->skinScripts, $context->getSkin(), 'default' )
553  );
554  if ( $this->skipFunction ) {
555  $files[] = $this->skipFunction;
556  }
557 
558  // Expand these local paths into absolute file paths
559  $files = array_map( [ $this, 'getLocalPath' ], $files );
560 
561  // Add any lazily discovered file dependencies from previous module builds.
562  // These are added last because they are already absolute file paths.
563  $files = array_merge( $files, $this->getFileDependencies( $context ) );
564 
565  // Filter out any duplicates. Typically introduced by getFileDependencies() which
566  // may lazily re-discover a primary file.
567  $files = array_unique( $files );
568 
569  // Don't return array keys or any other form of file path here, only the hashes.
570  // Including file paths would needlessly cause global cache invalidation when files
571  // move on disk or if e.g. the MediaWiki directory name changes.
572  // Anything where order is significant is already detected by the definition summary.
574  }
575 
582  public function getDefinitionSummary( Context $context ) {
583  $summary = parent::getDefinitionSummary( $context );
584 
585  $options = [];
586  foreach ( [
587  // The following properties are omitted because they don't affect the module response:
588  // - localBasePath (Per T104950; Changes when absolute directory name changes. If
589  // this affects 'scripts' and other file paths, getFileHashes accounts for that.)
590  // - remoteBasePath (Per T104950)
591  // - dependencies (provided via startup module)
592  // - targets
593  // - group (provided via startup module)
594  'scripts',
595  'debugScripts',
596  'styles',
597  'languageScripts',
598  'skinScripts',
599  'skinStyles',
600  'messages',
601  'templates',
602  'skipFunction',
603  'debugRaw',
604  ] as $member ) {
605  $options[$member] = $this->{$member};
606  }
607 
608  $packageFiles = $this->expandPackageFiles( $context );
609  if ( $packageFiles ) {
610  // Extract the minimum needed:
611  // - The 'main' pointer (included as-is).
612  // - The 'files' array, simplified to only which files exist (the keys of
613  // this array), and something that represents their non-file content.
614  // For packaged files that reflect files directly from disk, the
615  // 'getFileHashes' method tracks their content already.
616  // It is important that the keys of the $packageFiles['files'] array
617  // are preserved, as they do affect the module output.
618  $packageFiles['files'] = array_map( static function ( $fileInfo ) {
619  return $fileInfo['definitionSummary'] ?? ( $fileInfo['content'] ?? null );
620  }, $packageFiles['files'] );
621  }
622 
623  $summary[] = [
624  'options' => $options,
625  'packageFiles' => $packageFiles,
626  'fileHashes' => $this->getFileHashes( $context ),
627  'messageBlob' => $this->getMessageBlob( $context ),
628  ];
629 
630  $lessVars = $this->getLessVars( $context );
631  if ( $lessVars ) {
632  $summary[] = [ 'lessVars' => $lessVars ];
633  }
634 
635  return $summary;
636  }
637 
641  protected function getVueComponentParser() {
642  if ( $this->vueComponentParser === null ) {
643  $this->vueComponentParser = new VueComponentParser;
644  }
646  }
647 
652  protected function getPath( $path ) {
653  if ( $path instanceof FilePath ) {
654  return $path->getPath();
655  }
656 
657  return $path;
658  }
659 
664  protected function getLocalPath( $path ) {
665  if ( $path instanceof FilePath ) {
666  if ( $path->getLocalBasePath() !== null ) {
667  return $path->getLocalPath();
668  }
669  $path = $path->getPath();
670  }
671 
672  return "{$this->localBasePath}/$path";
673  }
674 
679  protected function getRemotePath( $path ) {
680  if ( $path instanceof FilePath ) {
681  if ( $path->getRemoteBasePath() !== null ) {
682  return $path->getRemotePath();
683  }
684  $path = $path->getPath();
685  }
686 
687  if ( $this->remoteBasePath === '/' ) {
688  return "/$path";
689  } else {
690  return "{$this->remoteBasePath}/$path";
691  }
692  }
693 
701  public function getStyleSheetLang( $path ) {
702  return preg_match( '/\.less$/i', $path ) ? 'less' : 'css';
703  }
704 
710  public static function getPackageFileType( $path ) {
711  if ( preg_match( '/\.json$/i', $path ) ) {
712  return 'data';
713  }
714  if ( preg_match( '/\.vue$/i', $path ) ) {
715  return 'script-vue';
716  }
717  return 'script';
718  }
719 
727  private static function collateStyleFilesByMedia( array $list ) {
728  $collatedFiles = [];
729  foreach ( $list as $key => $value ) {
730  if ( is_int( $key ) ) {
731  // File name as the value
732  if ( !isset( $collatedFiles['all'] ) ) {
733  $collatedFiles['all'] = [];
734  }
735  $collatedFiles['all'][] = $value;
736  } elseif ( is_array( $value ) ) {
737  // File name as the key, options array as the value
738  $optionValue = $value['media'] ?? 'all';
739  if ( !isset( $collatedFiles[$optionValue] ) ) {
740  $collatedFiles[$optionValue] = [];
741  }
742  $collatedFiles[$optionValue][] = $key;
743  }
744  }
745  return $collatedFiles;
746  }
747 
757  protected static function tryForKey( array $list, $key, $fallback = null ) {
758  if ( isset( $list[$key] ) && is_array( $list[$key] ) ) {
759  return $list[$key];
760  } elseif ( is_string( $fallback )
761  && isset( $list[$fallback] )
762  && is_array( $list[$fallback] )
763  ) {
764  return $list[$fallback];
765  }
766  return [];
767  }
768 
775  private function getScriptFiles( Context $context ): array {
776  // List in execution order: scripts, languageScripts, skinScripts, debugScripts.
777  // Documented at MediaWiki\MainConfigSchema::ResourceModules.
778  $files = array_merge(
779  $this->scripts,
780  $this->getLanguageScripts( $context->getLanguage() ),
781  self::tryForKey( $this->skinScripts, $context->getSkin(), 'default' )
782  );
783  if ( $context->getDebug() ) {
784  $files = array_merge( $files, $this->debugScripts );
785  }
786 
787  return array_unique( $files, SORT_REGULAR );
788  }
789 
797  private function getLanguageScripts( string $lang ): array {
798  $scripts = self::tryForKey( $this->languageScripts, $lang );
799  if ( $scripts ) {
800  return $scripts;
801  }
802 
803  // Optimization: Avoid initialising and calling into language services
804  // for the majority of modules that don't use this option.
805  if ( $this->languageScripts ) {
806  $fallbacks = MediaWikiServices::getInstance()
807  ->getLanguageFallback()
808  ->getAll( $lang, LanguageFallback::MESSAGES );
809  foreach ( $fallbacks as $lang ) {
810  $scripts = self::tryForKey( $this->languageScripts, $lang );
811  if ( $scripts ) {
812  return $scripts;
813  }
814  }
815  }
816 
817  return [];
818  }
819 
820  public function setSkinStylesOverride( array $moduleSkinStyles ): void {
821  $moduleName = $this->getName();
822  foreach ( $moduleSkinStyles as $skinName => $overrides ) {
823  // If a module provides overrides for a skin, and that skin also provides overrides
824  // for the same module, then the module has precedence.
825  if ( isset( $this->skinStyles[$skinName] ) ) {
826  continue;
827  }
828 
829  // If $moduleName in ResourceModuleSkinStyles is preceded with a '+', the defined style
830  // files will be added to 'default' skinStyles, otherwise 'default' will be ignored.
831  if ( isset( $overrides[$moduleName] ) ) {
832  $paths = (array)$overrides[$moduleName];
833  $styleFiles = [];
834  } elseif ( isset( $overrides['+' . $moduleName] ) ) {
835  $paths = (array)$overrides['+' . $moduleName];
836  $styleFiles = isset( $this->skinStyles['default'] ) ?
837  (array)$this->skinStyles['default'] :
838  [];
839  } else {
840  continue;
841  }
842 
843  // Add new file paths, remapping them to refer to our directories and not use settings
844  // from the module we're modifying, which come from the base definition.
845  list( $localBasePath, $remoteBasePath ) = self::extractBasePaths( $overrides );
846 
847  foreach ( $paths as $path ) {
848  $styleFiles[] = new FilePath( $path, $localBasePath, $remoteBasePath );
849  }
850 
851  $this->skinStyles[$skinName] = $styleFiles;
852  }
853  }
854 
862  public function getStyleFiles( Context $context ) {
863  return array_merge_recursive(
864  self::collateStyleFilesByMedia( $this->styles ),
865  self::collateStyleFilesByMedia(
866  self::tryForKey( $this->skinStyles, $context->getSkin(), 'default' )
867  )
868  );
869  }
870 
878  protected function getSkinStyleFiles( $skinName ) {
879  return self::collateStyleFilesByMedia(
880  self::tryForKey( $this->skinStyles, $skinName )
881  );
882  }
883 
890  protected function getAllSkinStyleFiles() {
891  $skinFactory = MediaWikiServices::getInstance()->getSkinFactory();
892  $styleFiles = [];
893 
894  $internalSkinNames = array_keys( $skinFactory->getInstalledSkins() );
895  $internalSkinNames[] = 'default';
896 
897  foreach ( $internalSkinNames as $internalSkinName ) {
898  $styleFiles = array_merge_recursive(
899  $styleFiles,
900  $this->getSkinStyleFiles( $internalSkinName )
901  );
902  }
903 
904  return $styleFiles;
905  }
906 
912  public function getAllStyleFiles() {
913  $collatedStyleFiles = array_merge_recursive(
914  self::collateStyleFilesByMedia( $this->styles ),
915  $this->getAllSkinStyleFiles()
916  );
917 
918  $result = [];
919 
920  foreach ( $collatedStyleFiles as $media => $styleFiles ) {
921  foreach ( $styleFiles as $styleFile ) {
922  $result[] = $this->getLocalPath( $styleFile );
923  }
924  }
925 
926  return $result;
927  }
928 
935  private function readScriptFiles( array $scripts ) {
936  if ( !$scripts ) {
937  return '';
938  }
939  $js = '';
940  foreach ( array_unique( $scripts, SORT_REGULAR ) as $fileName ) {
941  $localPath = $this->getLocalPath( $fileName );
942  $contents = $this->getFileContents( $localPath, 'script' );
943  $js .= ResourceLoader::ensureNewline( $contents );
944  }
945  return $js;
946  }
947 
956  public function readStyleFiles( array $styles, Context $context ) {
957  if ( !$styles ) {
958  return [];
959  }
960  foreach ( $styles as $media => $files ) {
961  $uniqueFiles = array_unique( $files, SORT_REGULAR );
962  $styleFiles = [];
963  foreach ( $uniqueFiles as $file ) {
964  $styleFiles[] = $this->readStyleFile( $file, $context );
965  }
966  $styles[$media] = implode( "\n", $styleFiles );
967  }
968  return $styles;
969  }
970 
981  protected function readStyleFile( $path, Context $context ) {
982  $localPath = $this->getLocalPath( $path );
983  $style = $this->getFileContents( $localPath, 'style' );
984  $styleLang = $this->getStyleSheetLang( $localPath );
985 
986  return $this->processStyle( $style, $styleLang, $path, $context );
987  }
988 
1005  protected function processStyle( $style, $styleLang, $path, Context $context ) {
1006  $localPath = $this->getLocalPath( $path );
1007  $remotePath = $this->getRemotePath( $path );
1008 
1009  if ( $styleLang === 'less' ) {
1010  $style = $this->compileLessString( $style, $localPath, $context );
1011  $this->hasGeneratedStyles = true;
1012  }
1013 
1014  if ( $this->getFlip( $context ) ) {
1015  $style = CSSJanus::transform(
1016  $style,
1017  /* $swapLtrRtlInURL = */ true,
1018  /* $swapLeftRightInURL = */ false
1019  );
1020  }
1021 
1022  $localDir = dirname( $localPath );
1023  $remoteDir = dirname( $remotePath );
1024  // Get and register local file references
1025  $localFileRefs = CSSMin::getLocalFileReferences( $style, $localDir );
1026  foreach ( $localFileRefs as $file ) {
1027  if ( is_file( $file ) ) {
1028  $this->localFileRefs[] = $file;
1029  } else {
1030  $this->missingLocalFileRefs[] = $file;
1031  }
1032  }
1033  // Don't cache this call. remap() ensures data URIs embeds are up to date,
1034  // and urls contain correct content hashes in their query string. (T128668)
1035  return CSSMin::remap( $style, $localDir, $remoteDir, true );
1036  }
1037 
1043  public function getFlip( Context $context ) {
1044  return $context->getDirection() === 'rtl' && !$this->noflip;
1045  }
1046 
1052  public function getTargets() {
1053  return $this->targets;
1054  }
1055 
1062  public function getType() {
1063  $canBeStylesOnly = !(
1064  // All options except 'styles', 'skinStyles' and 'debugRaw'
1065  $this->scripts
1066  || $this->debugScripts
1067  || $this->templates
1068  || $this->languageScripts
1069  || $this->skinScripts
1070  || $this->dependencies
1071  || $this->messages
1072  || $this->skipFunction
1073  || $this->packageFiles
1074  );
1075  return $canBeStylesOnly ? self::LOAD_STYLES : self::LOAD_GENERAL;
1076  }
1077 
1089  protected function compileLessString( $style, $stylePath, Context $context ) {
1090  static $cache;
1091  // @TODO: dependency injection
1092  if ( !$cache ) {
1094  }
1095 
1096  $skinName = $context->getSkin();
1097  $skinImportPaths = ExtensionRegistry::getInstance()->getAttribute( 'SkinLessImportPaths' );
1098  $importDirs = [];
1099  if ( isset( $skinImportPaths[ $skinName ] ) ) {
1100  $importDirs[] = $skinImportPaths[ $skinName ];
1101  }
1102 
1103  $vars = $this->getLessVars( $context );
1104  // Construct a cache key from a hash of the LESS source, and a hash digest
1105  // of the LESS variables used for compilation.
1106  ksort( $vars );
1107  $compilerParams = [
1108  'vars' => $vars,
1109  'importDirs' => $importDirs,
1110  ];
1111  $key = $cache->makeGlobalKey(
1112  'resourceloader-less',
1113  'v1',
1114  hash( 'md4', $style ),
1115  hash( 'md4', serialize( $compilerParams ) )
1116  );
1117 
1118  // If we got a cached value, we have to validate it by getting a checksum of all the
1119  // files that were loaded by the parser and ensuring it matches the cached entry's.
1120  $data = $cache->get( $key );
1121  if (
1122  !$data ||
1123  $data['hash'] !== FileContentsHasher::getFileContentsHash( $data['files'] )
1124  ) {
1125  $compiler = $context->getResourceLoader()->getLessCompiler( $vars, $importDirs );
1126 
1127  $css = $compiler->parse( $style, $stylePath )->getCss();
1128  // T253055: store the implicit dependency paths in a form relative to any install
1129  // path so that multiple version of the application can share the cache for identical
1130  // less stylesheets. This also avoids churn during application updates.
1131  $files = $compiler->AllParsedFiles();
1132  $data = [
1133  'css' => $css,
1134  'files' => Module::getRelativePaths( $files ),
1135  'hash' => FileContentsHasher::getFileContentsHash( $files )
1136  ];
1137  $cache->set( $key, $data, $cache::TTL_DAY );
1138  }
1139 
1140  foreach ( Module::expandRelativePaths( $data['files'] ) as $path ) {
1141  $this->localFileRefs[] = $path;
1142  }
1143 
1144  return $data['css'];
1145  }
1146 
1152  public function getTemplates() {
1153  $templates = [];
1154 
1155  foreach ( $this->templates as $alias => $templatePath ) {
1156  // Alias is optional
1157  if ( is_int( $alias ) ) {
1158  $alias = $this->getPath( $templatePath );
1159  }
1160  $localPath = $this->getLocalPath( $templatePath );
1161  $content = $this->getFileContents( $localPath, 'template' );
1162 
1163  $templates[$alias] = $this->stripBom( $content );
1164  }
1165  return $templates;
1166  }
1167 
1186  private function expandPackageFiles( Context $context ) {
1187  $hash = $context->getHash();
1188  if ( isset( $this->expandedPackageFiles[$hash] ) ) {
1189  return $this->expandedPackageFiles[$hash];
1190  }
1191  if ( $this->packageFiles === null ) {
1192  return null;
1193  }
1194  $expandedFiles = [];
1195  $mainFile = null;
1196 
1197  foreach ( $this->packageFiles as $key => $fileInfo ) {
1198  if ( !is_array( $fileInfo ) ) {
1199  $fileInfo = [ 'name' => $fileInfo, 'file' => $fileInfo ];
1200  }
1201  if ( !isset( $fileInfo['name'] ) ) {
1202  $msg = "Missing 'name' key in package file info for module '{$this->getName()}'," .
1203  " offset '{$key}'.";
1204  $this->getLogger()->error( $msg );
1205  throw new LogicException( $msg );
1206  }
1207  $fileName = $this->getPath( $fileInfo['name'] );
1208 
1209  // Infer type from alias if needed
1210  $type = $fileInfo['type'] ?? self::getPackageFileType( $fileName );
1211  $expanded = [ 'type' => $type ];
1212  if ( !empty( $fileInfo['main'] ) ) {
1213  $mainFile = $fileName;
1214  if ( $type !== 'script' && $type !== 'script-vue' ) {
1215  $msg = "Main file in package must be of type 'script', module " .
1216  "'{$this->getName()}', main file '{$mainFile}' is '{$type}'.";
1217  $this->getLogger()->error( $msg );
1218  throw new LogicException( $msg );
1219  }
1220  }
1221 
1222  // Perform expansions (except 'file' and 'callback'), creating one of these keys:
1223  // - 'content': literal value.
1224  // - 'filePath': content to be read from a file.
1225  // - 'callback': content computed by a callable.
1226  if ( isset( $fileInfo['content'] ) ) {
1227  $expanded['content'] = $fileInfo['content'];
1228  } elseif ( isset( $fileInfo['file'] ) ) {
1229  $expanded['filePath'] = $fileInfo['file'];
1230  } elseif ( isset( $fileInfo['callback'] ) ) {
1231  // If no extra parameter for the callback is given, use null.
1232  $expanded['callbackParam'] = $fileInfo['callbackParam'] ?? null;
1233 
1234  if ( !is_callable( $fileInfo['callback'] ) ) {
1235  $msg = "Invalid 'callback' for module '{$this->getName()}', file '{$fileName}'.";
1236  $this->getLogger()->error( $msg );
1237  throw new LogicException( $msg );
1238  }
1239  if ( isset( $fileInfo['versionCallback'] ) ) {
1240  if ( !is_callable( $fileInfo['versionCallback'] ) ) {
1241  throw new LogicException( "Invalid 'versionCallback' for "
1242  . "module '{$this->getName()}', file '{$fileName}'."
1243  );
1244  }
1245 
1246  // Execute the versionCallback with the same arguments that
1247  // would be given to the callback
1248  $callbackResult = ( $fileInfo['versionCallback'] )(
1249  $context,
1250  $this->getConfig(),
1251  $expanded['callbackParam']
1252  );
1253  if ( $callbackResult instanceof FilePath ) {
1254  $expanded['filePath'] = $callbackResult;
1255  } else {
1256  $expanded['definitionSummary'] = $callbackResult;
1257  }
1258  // Don't invoke 'callback' here as it may be expensive (T223260).
1259  $expanded['callback'] = $fileInfo['callback'];
1260  } else {
1261  // Else go ahead invoke callback with its arguments.
1262  $callbackResult = ( $fileInfo['callback'] )(
1263  $context,
1264  $this->getConfig(),
1265  $expanded['callbackParam']
1266  );
1267  if ( $callbackResult instanceof FilePath ) {
1268  $expanded['filePath'] = $callbackResult;
1269  } else {
1270  $expanded['content'] = $callbackResult;
1271  }
1272  }
1273  } elseif ( isset( $fileInfo['config'] ) ) {
1274  if ( $type !== 'data' ) {
1275  $msg = "Key 'config' only valid for data files. "
1276  . " Module '{$this->getName()}', file '{$fileName}' is '{$type}'.";
1277  $this->getLogger()->error( $msg );
1278  throw new LogicException( $msg );
1279  }
1280  $expandedConfig = [];
1281  foreach ( $fileInfo['config'] as $configKey => $var ) {
1282  $expandedConfig[ is_numeric( $configKey ) ? $var : $configKey ] = $this->getConfig()->get( $var );
1283  }
1284  $expanded['content'] = $expandedConfig;
1285  } elseif ( !empty( $fileInfo['main'] ) ) {
1286  // [ 'name' => 'foo.js', 'main' => true ] is shorthand
1287  $expanded['filePath'] = $fileName;
1288  } else {
1289  $msg = "Incomplete definition for module '{$this->getName()}', file '{$fileName}'. "
1290  . "One of 'file', 'content', 'callback', or 'config' must be set.";
1291  $this->getLogger()->error( $msg );
1292  throw new LogicException( $msg );
1293  }
1294 
1295  $expandedFiles[$fileName] = $expanded;
1296  }
1297 
1298  if ( $expandedFiles && $mainFile === null ) {
1299  // The first package file that is a script is the main file
1300  foreach ( $expandedFiles as $path => $file ) {
1301  if ( $file['type'] === 'script' || $file['type'] === 'script-vue' ) {
1302  $mainFile = $path;
1303  break;
1304  }
1305  }
1306  }
1307 
1308  $result = [
1309  'main' => $mainFile,
1310  'files' => $expandedFiles
1311  ];
1312 
1313  $this->expandedPackageFiles[$hash] = $result;
1314  return $result;
1315  }
1316 
1323  public function getPackageFiles( Context $context ) {
1324  if ( $this->packageFiles === null ) {
1325  return null;
1326  }
1327  $hash = $context->getHash();
1328  if ( isset( $this->fullyExpandedPackageFiles[ $hash ] ) ) {
1329  return $this->fullyExpandedPackageFiles[ $hash ];
1330  }
1331  $expandedPackageFiles = $this->expandPackageFiles( $context );
1332 
1333  // Expand file contents
1334  foreach ( $expandedPackageFiles['files'] as $fileName => &$fileInfo ) {
1335  // Turn any 'filePath' or 'callback' key into actual 'content',
1336  // and remove the key after that. The callback could return a
1337  // ResourceLoaderFilePath object; if that happens, fall through
1338  // to the 'filePath' handling.
1339  if ( isset( $fileInfo['callback'] ) ) {
1340  $callbackResult = ( $fileInfo['callback'] )(
1341  $context,
1342  $this->getConfig(),
1343  $fileInfo['callbackParam']
1344  );
1345  if ( $callbackResult instanceof FilePath ) {
1346  // Fall through to the filePath handling code below
1347  $fileInfo['filePath'] = $callbackResult;
1348  } else {
1349  $fileInfo['content'] = $callbackResult;
1350  }
1351  unset( $fileInfo['callback'] );
1352  }
1353  // Only interpret 'filePath' if 'content' hasn't been set already.
1354  // This can happen if 'versionCallback' provided 'filePath',
1355  // while 'callback' provides 'content'. In that case both are set
1356  // at this point. The 'filePath' from 'versionCallback' in that case is
1357  // only to inform getDefinitionSummary().
1358  if ( !isset( $fileInfo['content'] ) && isset( $fileInfo['filePath'] ) ) {
1359  $localPath = $this->getLocalPath( $fileInfo['filePath'] );
1360  $content = $this->getFileContents( $localPath, 'package' );
1361  if ( $fileInfo['type'] === 'data' ) {
1362  $content = json_decode( $content );
1363  }
1364  $fileInfo['content'] = $content;
1365  unset( $fileInfo['filePath'] );
1366  }
1367  if ( $fileInfo['type'] === 'script-vue' ) {
1368  try {
1369  $parsedComponent = $this->getVueComponentParser()->parse(
1370  // @phan-suppress-next-line PhanTypePossiblyInvalidDimOffset False positive
1371  $fileInfo['content'],
1372  [ 'minifyTemplate' => !$context->getDebug() ]
1373  );
1374  } catch ( TimeoutException $e ) {
1375  throw $e;
1376  } catch ( Exception $e ) {
1377  $msg = "Error parsing file '$fileName' in module '{$this->getName()}': " .
1378  $e->getMessage();
1379  $this->getLogger()->error( $msg );
1380  throw new RuntimeException( $msg );
1381  }
1382  $encodedTemplate = json_encode( $parsedComponent['template'] );
1383  if ( $context->getDebug() ) {
1384  // Replace \n (backslash-n) with space + backslash-newline in debug mode
1385  // We only replace \n if not preceded by a backslash, to avoid breaking '\\n'
1386  $encodedTemplate = preg_replace( '/(?<!\\\\)\\\\n/', " \\\n", $encodedTemplate );
1387  // Expand \t to real tabs in debug mode
1388  $encodedTemplate = strtr( $encodedTemplate, [ "\\t" => "\t" ] );
1389  }
1390  $fileInfo['content'] = [
1391  'script' => $parsedComponent['script'] .
1392  ";\nmodule.exports.template = $encodedTemplate;",
1393  'style' => $parsedComponent['style'] ?? '',
1394  'styleLang' => $parsedComponent['styleLang'] ?? 'css'
1395  ];
1396  $fileInfo['type'] = 'script+style';
1397  }
1398 
1399  // Not needed for client response, exists for use by getDefinitionSummary().
1400  unset( $fileInfo['definitionSummary'] );
1401  // Not needed for client response, used by callbacks only.
1402  unset( $fileInfo['callbackParam'] );
1403  }
1404 
1405  $this->fullyExpandedPackageFiles[ $hash ] = $expandedPackageFiles;
1406  return $expandedPackageFiles;
1407  }
1408 
1419  protected function stripBom( $input ) {
1420  if ( str_starts_with( $input, "\xef\xbb\xbf" ) ) {
1421  return substr( $input, 3 );
1422  }
1423  return $input;
1424  }
1425 }
1426 
1428 class_alias( FileModule::class, 'ResourceLoaderFileModule' );
serialize()
const CACHE_ANYTHING
Definition: Defines.php:85
$fallback
Definition: MessagesAb.php:10
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:471
array< string, array< int, string|FilePath > > $skinScripts
Lists of JavaScript files by skin name.
Definition: FileModule.php:73
getScriptURLsForDebug(Context $context)
Definition: FileModule.php:364
static tryForKey(array $list, $key, $fallback=null)
Get a list of element that match a key, optionally using a fallback key.
Definition: FileModule.php:757
getAllSkinStyleFiles()
Gets a list of file paths for all skin style files in the module, for all available skins.
Definition: FileModule.php:890
readStyleFile( $path, Context $context)
Read and process a style file.
Definition: FileModule.php:981
getTemplates()
Get content of named templates for this module.
getStyleSheetLang( $path)
Infer the stylesheet language from a stylesheet file path.
Definition: FileModule.php:701
processStyle( $style, $styleLang, $path, Context $context)
Process a CSS/LESS string.
getDefinitionSummary(Context $context)
Get the definition summary for this module.
Definition: FileModule.php:582
requiresES6()
Whether the module requires ES6 support in the client.
Definition: FileModule.php:500
readStyleFiles(array $styles, Context $context)
Read the contents of a list of CSS files and remap and concatenate these.
Definition: FileModule.php:956
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:512
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:878
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:426
getStyleFiles(Context $context)
Get a list of file paths for all styles in this module, in order of proper inclusion.
Definition: FileModule.php:862
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:339
getMessages()
Get message keys used by this module.
Definition: FileModule.php:452
getAllStyleFiles()
Returns all style files and all skin style files used by this module.
Definition: FileModule.php:912
getGroup()
Get the name of the group this module should be loaded in.
Definition: FileModule.php:461
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:394
static getPackageFileType( $path)
Infer the file type from a package file path.
Definition: FileModule.php:710
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:820
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:54
static transformResourcePath(Config $config, $path)
Transform path to web-accessible static resource.
get( $name)
Get a configuration variable such as "Sitename" or "UploadMaintenance.".
$cache
Definition: mcc.php:33
$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