MediaWiki  1.28.1
ResourceLoader.php
Go to the documentation of this file.
1 <?php
29 
36 class ResourceLoader implements LoggerAwareInterface {
38  protected static $filterCacheVersion = 7;
39 
41  protected static $debugMode = null;
42 
44  private $lessVars = null;
45 
50  protected $modules = [];
51 
56  protected $moduleInfos = [];
57 
59  protected $config;
60 
66  protected $testModuleNames = [];
67 
72  protected $sources = [];
73 
78  protected $errors = [];
79 
83  protected $blobStore;
84 
88  private $logger;
89 
91  const FILTER_NOMIN = '/*@nomin*/';
92 
107  public function preloadModuleInfo( array $moduleNames, ResourceLoaderContext $context ) {
108  if ( !$moduleNames ) {
109  // Or else Database*::select() will explode, plus it's cheaper!
110  return;
111  }
112  $dbr = wfGetDB( DB_REPLICA );
113  $skin = $context->getSkin();
114  $lang = $context->getLanguage();
115 
116  // Batched version of ResourceLoaderModule::getFileDependencies
117  $vary = "$skin|$lang";
118  $res = $dbr->select( 'module_deps', [ 'md_module', 'md_deps' ], [
119  'md_module' => $moduleNames,
120  'md_skin' => $vary,
121  ], __METHOD__
122  );
123 
124  // Prime in-object cache for file dependencies
125  $modulesWithDeps = [];
126  foreach ( $res as $row ) {
127  $module = $this->getModule( $row->md_module );
128  if ( $module ) {
129  $module->setFileDependencies( $context, ResourceLoaderModule::expandRelativePaths(
130  FormatJson::decode( $row->md_deps, true )
131  ) );
132  $modulesWithDeps[] = $row->md_module;
133  }
134  }
135  // Register the absence of a dependency row too
136  foreach ( array_diff( $moduleNames, $modulesWithDeps ) as $name ) {
137  $module = $this->getModule( $name );
138  if ( $module ) {
139  $this->getModule( $name )->setFileDependencies( $context, [] );
140  }
141  }
142 
143  // Batched version of ResourceLoaderWikiModule::getTitleInfo
144  ResourceLoaderWikiModule::preloadTitleInfo( $context, $dbr, $moduleNames );
145 
146  // Prime in-object cache for message blobs for modules with messages
147  $modules = [];
148  foreach ( $moduleNames as $name ) {
149  $module = $this->getModule( $name );
150  if ( $module && $module->getMessages() ) {
151  $modules[$name] = $module;
152  }
153  }
154  $store = $this->getMessageBlobStore();
155  $blobs = $store->getBlobs( $modules, $lang );
156  foreach ( $blobs as $name => $blob ) {
157  $modules[$name]->setMessageBlob( $blob, $lang );
158  }
159  }
160 
178  public static function filter( $filter, $data, array $options = [] ) {
179  if ( strpos( $data, ResourceLoader::FILTER_NOMIN ) !== false ) {
180  return $data;
181  }
182 
183  if ( isset( $options['cache'] ) && $options['cache'] === false ) {
184  return self::applyFilter( $filter, $data );
185  }
186 
187  $stats = RequestContext::getMain()->getStats();
189 
190  $key = $cache->makeGlobalKey(
191  'resourceloader',
192  'filter',
193  $filter,
194  self::$filterCacheVersion, md5( $data )
195  );
196 
197  $result = $cache->get( $key );
198  if ( $result === false ) {
199  $stats->increment( "resourceloader_cache.$filter.miss" );
200  $result = self::applyFilter( $filter, $data );
201  $cache->set( $key, $result, 24 * 3600 );
202  } else {
203  $stats->increment( "resourceloader_cache.$filter.hit" );
204  }
205  if ( $result === null ) {
206  // Cached failure
207  $result = $data;
208  }
209 
210  return $result;
211  }
212 
213  private static function applyFilter( $filter, $data ) {
214  $data = trim( $data );
215  if ( $data ) {
216  try {
217  $data = ( $filter === 'minify-css' )
218  ? CSSMin::minify( $data )
219  : JavaScriptMinifier::minify( $data );
220  } catch ( Exception $e ) {
222  return null;
223  }
224  }
225  return $data;
226  }
227 
228  /* Methods */
229 
235  public function __construct( Config $config = null, LoggerInterface $logger = null ) {
236  global $IP;
237 
238  $this->logger = $logger ?: new NullLogger();
239 
240  if ( !$config ) {
241  $this->logger->debug( __METHOD__ . ' was called without providing a Config instance' );
242  $config = ConfigFactory::getDefaultInstance()->makeConfig( 'main' );
243  }
244  $this->config = $config;
245 
246  // Add 'local' source first
247  $this->addSource( 'local', $config->get( 'LoadScript' ) );
248 
249  // Add other sources
250  $this->addSource( $config->get( 'ResourceLoaderSources' ) );
251 
252  // Register core modules
253  $this->register( include "$IP/resources/Resources.php" );
254  $this->register( include "$IP/resources/ResourcesOOUI.php" );
255  // Register extension modules
256  $this->register( $config->get( 'ResourceModules' ) );
257  Hooks::run( 'ResourceLoaderRegisterModules', [ &$this ] );
258 
259  if ( $config->get( 'EnableJavaScriptTest' ) === true ) {
260  $this->registerTestModules();
261  }
262 
263  $this->setMessageBlobStore( new MessageBlobStore( $this, $this->logger ) );
264  }
265 
269  public function getConfig() {
270  return $this->config;
271  }
272 
277  public function setLogger( LoggerInterface $logger ) {
278  $this->logger = $logger;
279  }
280 
285  public function getLogger() {
286  return $this->logger;
287  }
288 
293  public function getMessageBlobStore() {
294  return $this->blobStore;
295  }
296 
302  $this->blobStore = $blobStore;
303  }
304 
318  public function register( $name, $info = null ) {
319  $moduleSkinStyles = $this->config->get( 'ResourceModuleSkinStyles' );
320 
321  // Allow multiple modules to be registered in one call
322  $registrations = is_array( $name ) ? $name : [ $name => $info ];
323  foreach ( $registrations as $name => $info ) {
324  // Warn on duplicate registrations
325  if ( isset( $this->moduleInfos[$name] ) ) {
326  // A module has already been registered by this name
327  $this->logger->warning(
328  'ResourceLoader duplicate registration warning. ' .
329  'Another module has already been registered as ' . $name
330  );
331  }
332 
333  // Check $name for validity
334  if ( !self::isValidModuleName( $name ) ) {
335  throw new MWException( "ResourceLoader module name '$name' is invalid, "
336  . "see ResourceLoader::isValidModuleName()" );
337  }
338 
339  // Attach module
340  if ( $info instanceof ResourceLoaderModule ) {
341  $this->moduleInfos[$name] = [ 'object' => $info ];
342  $info->setName( $name );
343  $this->modules[$name] = $info;
344  } elseif ( is_array( $info ) ) {
345  // New calling convention
346  $this->moduleInfos[$name] = $info;
347  } else {
348  throw new MWException(
349  'ResourceLoader module info type error for module \'' . $name .
350  '\': expected ResourceLoaderModule or array (got: ' . gettype( $info ) . ')'
351  );
352  }
353 
354  // Last-minute changes
355 
356  // Apply custom skin-defined styles to existing modules.
357  if ( $this->isFileModule( $name ) ) {
358  foreach ( $moduleSkinStyles as $skinName => $skinStyles ) {
359  // If this module already defines skinStyles for this skin, ignore $wgResourceModuleSkinStyles.
360  if ( isset( $this->moduleInfos[$name]['skinStyles'][$skinName] ) ) {
361  continue;
362  }
363 
364  // If $name is preceded with a '+', the defined style files will be added to 'default'
365  // skinStyles, otherwise 'default' will be ignored as it normally would be.
366  if ( isset( $skinStyles[$name] ) ) {
367  $paths = (array)$skinStyles[$name];
368  $styleFiles = [];
369  } elseif ( isset( $skinStyles['+' . $name] ) ) {
370  $paths = (array)$skinStyles['+' . $name];
371  $styleFiles = isset( $this->moduleInfos[$name]['skinStyles']['default'] ) ?
372  (array)$this->moduleInfos[$name]['skinStyles']['default'] :
373  [];
374  } else {
375  continue;
376  }
377 
378  // Add new file paths, remapping them to refer to our directories and not use settings
379  // from the module we're modifying, which come from the base definition.
380  list( $localBasePath, $remoteBasePath ) =
382 
383  foreach ( $paths as $path ) {
384  $styleFiles[] = new ResourceLoaderFilePath( $path, $localBasePath, $remoteBasePath );
385  }
386 
387  $this->moduleInfos[$name]['skinStyles'][$skinName] = $styleFiles;
388  }
389  }
390  }
391 
392  }
393 
396  public function registerTestModules() {
397  global $IP;
398 
399  if ( $this->config->get( 'EnableJavaScriptTest' ) !== true ) {
400  throw new MWException( 'Attempt to register JavaScript test modules '
401  . 'but <code>$wgEnableJavaScriptTest</code> is false. '
402  . 'Edit your <code>LocalSettings.php</code> to enable it.' );
403  }
404 
405  // Get core test suites
406  $testModules = [];
407  $testModules['qunit'] = [];
408  // Get other test suites (e.g. from extensions)
409  Hooks::run( 'ResourceLoaderTestModules', [ &$testModules, &$this ] );
410 
411  // Add the testrunner (which configures QUnit) to the dependencies.
412  // Since it must be ready before any of the test suites are executed.
413  foreach ( $testModules['qunit'] as &$module ) {
414  // Make sure all test modules are top-loading so that when QUnit starts
415  // on document-ready, it will run once and finish. If some tests arrive
416  // later (possibly after QUnit has already finished) they will be ignored.
417  $module['position'] = 'top';
418  $module['dependencies'][] = 'test.mediawiki.qunit.testrunner';
419  }
420 
421  $testModules['qunit'] =
422  ( include "$IP/tests/qunit/QUnitTestResources.php" ) + $testModules['qunit'];
423 
424  foreach ( $testModules as $id => $names ) {
425  // Register test modules
426  $this->register( $testModules[$id] );
427 
428  // Keep track of their names so that they can be loaded together
429  $this->testModuleNames[$id] = array_keys( $testModules[$id] );
430  }
431 
432  }
433 
444  public function addSource( $id, $loadUrl = null ) {
445  // Allow multiple sources to be registered in one call
446  if ( is_array( $id ) ) {
447  foreach ( $id as $key => $value ) {
448  $this->addSource( $key, $value );
449  }
450  return;
451  }
452 
453  // Disallow duplicates
454  if ( isset( $this->sources[$id] ) ) {
455  throw new MWException(
456  'ResourceLoader duplicate source addition error. ' .
457  'Another source has already been registered as ' . $id
458  );
459  }
460 
461  // Pre 1.24 backwards-compatibility
462  if ( is_array( $loadUrl ) ) {
463  if ( !isset( $loadUrl['loadScript'] ) ) {
464  throw new MWException(
465  __METHOD__ . ' was passed an array with no "loadScript" key.'
466  );
467  }
468 
469  $loadUrl = $loadUrl['loadScript'];
470  }
471 
472  $this->sources[$id] = $loadUrl;
473  }
474 
480  public function getModuleNames() {
481  return array_keys( $this->moduleInfos );
482  }
483 
494  public function getTestModuleNames( $framework = 'all' ) {
496  if ( $framework == 'all' ) {
497  return $this->testModuleNames;
498  } elseif ( isset( $this->testModuleNames[$framework] )
499  && is_array( $this->testModuleNames[$framework] )
500  ) {
501  return $this->testModuleNames[$framework];
502  } else {
503  return [];
504  }
505  }
506 
514  public function isModuleRegistered( $name ) {
515  return isset( $this->moduleInfos[$name] );
516  }
517 
529  public function getModule( $name ) {
530  if ( !isset( $this->modules[$name] ) ) {
531  if ( !isset( $this->moduleInfos[$name] ) ) {
532  // No such module
533  return null;
534  }
535  // Construct the requested object
536  $info = $this->moduleInfos[$name];
538  if ( isset( $info['object'] ) ) {
539  // Object given in info array
540  $object = $info['object'];
541  } else {
542  if ( !isset( $info['class'] ) ) {
543  $class = 'ResourceLoaderFileModule';
544  } else {
545  $class = $info['class'];
546  }
548  $object = new $class( $info );
549  $object->setConfig( $this->getConfig() );
550  $object->setLogger( $this->logger );
551  }
552  $object->setName( $name );
553  $this->modules[$name] = $object;
554  }
555 
556  return $this->modules[$name];
557  }
558 
565  protected function isFileModule( $name ) {
566  if ( !isset( $this->moduleInfos[$name] ) ) {
567  return false;
568  }
569  $info = $this->moduleInfos[$name];
570  if ( isset( $info['object'] ) || isset( $info['class'] ) ) {
571  return false;
572  }
573  return true;
574  }
575 
581  public function getSources() {
582  return $this->sources;
583  }
584 
594  public function getLoadScript( $source ) {
595  if ( !isset( $this->sources[$source] ) ) {
596  throw new MWException( "The $source source was never registered in ResourceLoader." );
597  }
598  return $this->sources[$source];
599  }
600 
606  public static function makeHash( $value ) {
607  $hash = hash( 'fnv132', $value );
608  return Wikimedia\base_convert( $hash, 16, 36, 7 );
609  }
610 
619  public function getCombinedVersion( ResourceLoaderContext $context, array $moduleNames ) {
620  if ( !$moduleNames ) {
621  return '';
622  }
623  $hashes = array_map( function ( $module ) use ( $context ) {
624  return $this->getModule( $module )->getVersionHash( $context );
625  }, $moduleNames );
626  return self::makeHash( implode( '', $hashes ) );
627  }
628 
643  public function makeVersionQuery( ResourceLoaderContext $context ) {
644  // As of MediaWiki 1.28, the server and client use the same algorithm for combining
645  // version hashes. There is no technical reason for this to be same, and for years the
646  // implementations differed. If getCombinedVersion in PHP (used for StartupModule and
647  // E-Tag headers) differs in the future from getCombinedVersion in JS (used for 'version'
648  // query parameter), then this method must continue to match the JS one.
649  $moduleNames = [];
650  foreach ( $context->getModules() as $name ) {
651  if ( !$this->getModule( $name ) ) {
652  // If a versioned request contains a missing module, the version is a mismatch
653  // as the client considered a module (and version) we don't have.
654  return '';
655  }
656  $moduleNames[] = $name;
657  }
658  return $this->getCombinedVersion( $context, $moduleNames );
659  }
660 
666  public function respond( ResourceLoaderContext $context ) {
667  // Buffer output to catch warnings. Normally we'd use ob_clean() on the
668  // top-level output buffer to clear warnings, but that breaks when ob_gzhandler
669  // is used: ob_clean() will clear the GZIP header in that case and it won't come
670  // back for subsequent output, resulting in invalid GZIP. So we have to wrap
671  // the whole thing in our own output buffer to be sure the active buffer
672  // doesn't use ob_gzhandler.
673  // See http://bugs.php.net/bug.php?id=36514
674  ob_start();
675 
676  // Find out which modules are missing and instantiate the others
677  $modules = [];
678  $missing = [];
679  foreach ( $context->getModules() as $name ) {
680  $module = $this->getModule( $name );
681  if ( $module ) {
682  // Do not allow private modules to be loaded from the web.
683  // This is a security issue, see bug 34907.
684  if ( $module->getGroup() === 'private' ) {
685  $this->logger->debug( "Request for private module '$name' denied" );
686  $this->errors[] = "Cannot show private module \"$name\"";
687  continue;
688  }
689  $modules[$name] = $module;
690  } else {
691  $missing[] = $name;
692  }
693  }
694 
695  try {
696  // Preload for getCombinedVersion() and for batch makeModuleResponse()
697  $this->preloadModuleInfo( array_keys( $modules ), $context );
698  } catch ( Exception $e ) {
700  $this->logger->warning( 'Preloading module info failed: {exception}', [
701  'exception' => $e
702  ] );
703  $this->errors[] = self::formatExceptionNoComment( $e );
704  }
705 
706  // Combine versions to propagate cache invalidation
707  $versionHash = '';
708  try {
709  $versionHash = $this->getCombinedVersion( $context, array_keys( $modules ) );
710  } catch ( Exception $e ) {
712  $this->logger->warning( 'Calculating version hash failed: {exception}', [
713  'exception' => $e
714  ] );
715  $this->errors[] = self::formatExceptionNoComment( $e );
716  }
717 
718  // See RFC 2616 § 3.11 Entity Tags
719  // http://www.w3.org/Protocols/rfc2616/rfc2616-sec3.html#sec3.11
720  $etag = 'W/"' . $versionHash . '"';
721 
722  // Try the client-side cache first
723  if ( $this->tryRespondNotModified( $context, $etag ) ) {
724  return; // output handled (buffers cleared)
725  }
726 
727  // Use file cache if enabled and available...
728  if ( $this->config->get( 'UseFileCache' ) ) {
729  $fileCache = ResourceFileCache::newFromContext( $context );
730  if ( $this->tryRespondFromFileCache( $fileCache, $context, $etag ) ) {
731  return; // output handled
732  }
733  }
734 
735  // Generate a response
736  $response = $this->makeModuleResponse( $context, $modules, $missing );
737 
738  // Capture any PHP warnings from the output buffer and append them to the
739  // error list if we're in debug mode.
740  if ( $context->getDebug() ) {
741  $warnings = ob_get_contents();
742  if ( strlen( $warnings ) ) {
743  $this->errors[] = $warnings;
744  }
745  }
746 
747  // Save response to file cache unless there are errors
748  if ( isset( $fileCache ) && !$this->errors && !count( $missing ) ) {
749  // Cache single modules and images...and other requests if there are enough hits
750  if ( ResourceFileCache::useFileCache( $context ) ) {
751  if ( $fileCache->isCacheWorthy() ) {
752  $fileCache->saveText( $response );
753  } else {
754  $fileCache->incrMissesRecent( $context->getRequest() );
755  }
756  }
757  }
758 
759  $this->sendResponseHeaders( $context, $etag, (bool)$this->errors );
760 
761  // Remove the output buffer and output the response
762  ob_end_clean();
763 
764  if ( $context->getImageObj() && $this->errors ) {
765  // We can't show both the error messages and the response when it's an image.
766  $response = implode( "\n\n", $this->errors );
767  } elseif ( $this->errors ) {
768  $errorText = implode( "\n\n", $this->errors );
769  $errorResponse = self::makeComment( $errorText );
770  if ( $context->shouldIncludeScripts() ) {
771  $errorResponse .= 'if (window.console && console.error) {'
772  . Xml::encodeJsCall( 'console.error', [ $errorText ] )
773  . "}\n";
774  }
775 
776  // Prepend error info to the response
777  $response = $errorResponse . $response;
778  }
779 
780  $this->errors = [];
781  echo $response;
782 
783  }
784 
795  protected function sendResponseHeaders( ResourceLoaderContext $context, $etag, $errors ) {
796  $rlMaxage = $this->config->get( 'ResourceLoaderMaxage' );
797  // Use a short cache expiry so that updates propagate to clients quickly, if:
798  // - No version specified (shared resources, e.g. stylesheets)
799  // - There were errors (recover quickly)
800  // - Version mismatch (T117587, T47877)
801  if ( is_null( $context->getVersion() )
802  || $errors
803  || $context->getVersion() !== $this->makeVersionQuery( $context )
804  ) {
805  $maxage = $rlMaxage['unversioned']['client'];
806  $smaxage = $rlMaxage['unversioned']['server'];
807  // If a version was specified we can use a longer expiry time since changing
808  // version numbers causes cache misses
809  } else {
810  $maxage = $rlMaxage['versioned']['client'];
811  $smaxage = $rlMaxage['versioned']['server'];
812  }
813  if ( $context->getImageObj() ) {
814  // Output different headers if we're outputting textual errors.
815  if ( $errors ) {
816  header( 'Content-Type: text/plain; charset=utf-8' );
817  } else {
818  $context->getImageObj()->sendResponseHeaders( $context );
819  }
820  } elseif ( $context->getOnly() === 'styles' ) {
821  header( 'Content-Type: text/css; charset=utf-8' );
822  header( 'Access-Control-Allow-Origin: *' );
823  } else {
824  header( 'Content-Type: text/javascript; charset=utf-8' );
825  }
826  // See RFC 2616 § 14.19 ETag
827  // http://www.w3.org/Protocols/rfc2616/rfc2616-sec14.html#sec14.19
828  header( 'ETag: ' . $etag );
829  if ( $context->getDebug() ) {
830  // Do not cache debug responses
831  header( 'Cache-Control: private, no-cache, must-revalidate' );
832  header( 'Pragma: no-cache' );
833  } else {
834  header( "Cache-Control: public, max-age=$maxage, s-maxage=$smaxage" );
835  $exp = min( $maxage, $smaxage );
836  header( 'Expires: ' . wfTimestamp( TS_RFC2822, $exp + time() ) );
837  }
838  }
839 
850  protected function tryRespondNotModified( ResourceLoaderContext $context, $etag ) {
851  // See RFC 2616 § 14.26 If-None-Match
852  // http://www.w3.org/Protocols/rfc2616/rfc2616-sec14.html#sec14.26
853  $clientKeys = $context->getRequest()->getHeader( 'If-None-Match', WebRequest::GETHEADER_LIST );
854  // Never send 304s in debug mode
855  if ( $clientKeys !== false && !$context->getDebug() && in_array( $etag, $clientKeys ) ) {
856  // There's another bug in ob_gzhandler (see also the comment at
857  // the top of this function) that causes it to gzip even empty
858  // responses, meaning it's impossible to produce a truly empty
859  // response (because the gzip header is always there). This is
860  // a problem because 304 responses have to be completely empty
861  // per the HTTP spec, and Firefox behaves buggily when they're not.
862  // See also http://bugs.php.net/bug.php?id=51579
863  // To work around this, we tear down all output buffering before
864  // sending the 304.
865  wfResetOutputBuffers( /* $resetGzipEncoding = */ true );
866 
867  HttpStatus::header( 304 );
868 
869  $this->sendResponseHeaders( $context, $etag, false );
870  return true;
871  }
872  return false;
873  }
874 
883  protected function tryRespondFromFileCache(
884  ResourceFileCache $fileCache,
885  ResourceLoaderContext $context,
886  $etag
887  ) {
888  $rlMaxage = $this->config->get( 'ResourceLoaderMaxage' );
889  // Buffer output to catch warnings.
890  ob_start();
891  // Get the maximum age the cache can be
892  $maxage = is_null( $context->getVersion() )
893  ? $rlMaxage['unversioned']['server']
894  : $rlMaxage['versioned']['server'];
895  // Minimum timestamp the cache file must have
896  $good = $fileCache->isCacheGood( wfTimestamp( TS_MW, time() - $maxage ) );
897  if ( !$good ) {
898  try { // RL always hits the DB on file cache miss...
899  wfGetDB( DB_REPLICA );
900  } catch ( DBConnectionError $e ) { // ...check if we need to fallback to cache
901  $good = $fileCache->isCacheGood(); // cache existence check
902  }
903  }
904  if ( $good ) {
905  $ts = $fileCache->cacheTimestamp();
906  // Send content type and cache headers
907  $this->sendResponseHeaders( $context, $etag, false );
908  $response = $fileCache->fetchText();
909  // Capture any PHP warnings from the output buffer and append them to the
910  // response in a comment if we're in debug mode.
911  if ( $context->getDebug() ) {
912  $warnings = ob_get_contents();
913  if ( strlen( $warnings ) ) {
914  $response = self::makeComment( $warnings ) . $response;
915  }
916  }
917  // Remove the output buffer and output the response
918  ob_end_clean();
919  echo $response . "\n/* Cached {$ts} */";
920  return true; // cache hit
921  }
922  // Clear buffer
923  ob_end_clean();
924 
925  return false; // cache miss
926  }
927 
936  public static function makeComment( $text ) {
937  $encText = str_replace( '*/', '* /', $text );
938  return "/*\n$encText\n*/\n";
939  }
940 
947  public static function formatException( $e ) {
948  return self::makeComment( self::formatExceptionNoComment( $e ) );
949  }
950 
958  protected static function formatExceptionNoComment( $e ) {
959  global $wgShowExceptionDetails;
960 
961  if ( !$wgShowExceptionDetails ) {
963  }
964 
966  }
967 
976  public function makeModuleResponse( ResourceLoaderContext $context,
977  array $modules, array $missing = []
978  ) {
979  $out = '';
980  $states = [];
981 
982  if ( !count( $modules ) && !count( $missing ) ) {
983  return <<<MESSAGE
984 /* This file is the Web entry point for MediaWiki's ResourceLoader:
985  <https://www.mediawiki.org/wiki/ResourceLoader>. In this request,
986  no modules were requested. Max made me put this here. */
987 MESSAGE;
988  }
989 
990  $image = $context->getImageObj();
991  if ( $image ) {
992  $data = $image->getImageData( $context );
993  if ( $data === false ) {
994  $data = '';
995  $this->errors[] = 'Image generation failed';
996  }
997  return $data;
998  }
999 
1000  foreach ( $missing as $name ) {
1001  $states[$name] = 'missing';
1002  }
1003 
1004  // Generate output
1005  $isRaw = false;
1006 
1007  $filter = $context->getOnly() === 'styles' ? 'minify-css' : 'minify-js';
1008 
1009  foreach ( $modules as $name => $module ) {
1010  try {
1011  $content = $module->getModuleContent( $context );
1012  $implementKey = $name . '@' . $module->getVersionHash( $context );
1013  $strContent = '';
1014 
1015  // Append output
1016  switch ( $context->getOnly() ) {
1017  case 'scripts':
1018  $scripts = $content['scripts'];
1019  if ( is_string( $scripts ) ) {
1020  // Load scripts raw...
1021  $strContent = $scripts;
1022  } elseif ( is_array( $scripts ) ) {
1023  // ...except when $scripts is an array of URLs
1024  $strContent = self::makeLoaderImplementScript( $implementKey, $scripts, [], [], [] );
1025  }
1026  break;
1027  case 'styles':
1028  $styles = $content['styles'];
1029  // We no longer seperate into media, they are all combined now with
1030  // custom media type groups into @media .. {} sections as part of the css string.
1031  // Module returns either an empty array or a numerical array with css strings.
1032  $strContent = isset( $styles['css'] ) ? implode( '', $styles['css'] ) : '';
1033  break;
1034  default:
1035  $scripts = isset( $content['scripts'] ) ? $content['scripts'] : '';
1036  if ( is_string( $scripts ) ) {
1037  if ( $name === 'site' || $name === 'user' ) {
1038  // Legacy scripts that run in the global scope without a closure.
1039  // mw.loader.implement will use globalEval if scripts is a string.
1040  // Minify manually here, because general response minification is
1041  // not effective due it being a string literal, not a function.
1042  if ( !ResourceLoader::inDebugMode() ) {
1043  $scripts = self::filter( 'minify-js', $scripts ); // T107377
1044  }
1045  } else {
1046  $scripts = new XmlJsCode( $scripts );
1047  }
1048  }
1049  $strContent = self::makeLoaderImplementScript(
1050  $implementKey,
1051  $scripts,
1052  isset( $content['styles'] ) ? $content['styles'] : [],
1053  isset( $content['messagesBlob'] ) ? new XmlJsCode( $content['messagesBlob'] ) : [],
1054  isset( $content['templates'] ) ? $content['templates'] : []
1055  );
1056  break;
1057  }
1058 
1059  if ( !$context->getDebug() ) {
1060  $strContent = self::filter( $filter, $strContent );
1061  }
1062 
1063  $out .= $strContent;
1064 
1065  } catch ( Exception $e ) {
1067  $this->logger->warning( 'Generating module package failed: {exception}', [
1068  'exception' => $e
1069  ] );
1070  $this->errors[] = self::formatExceptionNoComment( $e );
1071 
1072  // Respond to client with error-state instead of module implementation
1073  $states[$name] = 'error';
1074  unset( $modules[$name] );
1075  }
1076  $isRaw |= $module->isRaw();
1077  }
1078 
1079  // Update module states
1080  if ( $context->shouldIncludeScripts() && !$context->getRaw() && !$isRaw ) {
1081  if ( count( $modules ) && $context->getOnly() === 'scripts' ) {
1082  // Set the state of modules loaded as only scripts to ready as
1083  // they don't have an mw.loader.implement wrapper that sets the state
1084  foreach ( $modules as $name => $module ) {
1085  $states[$name] = 'ready';
1086  }
1087  }
1088 
1089  // Set the state of modules we didn't respond to with mw.loader.implement
1090  if ( count( $states ) ) {
1091  $stateScript = self::makeLoaderStateScript( $states );
1092  if ( !$context->getDebug() ) {
1093  $stateScript = self::filter( 'minify-js', $stateScript );
1094  }
1095  $out .= $stateScript;
1096  }
1097  } else {
1098  if ( count( $states ) ) {
1099  $this->errors[] = 'Problematic modules: ' .
1101  }
1102  }
1103 
1104  return $out;
1105  }
1106 
1113  public function getModulesByMessage( $messageKey ) {
1114  $moduleNames = [];
1115  foreach ( $this->getModuleNames() as $moduleName ) {
1116  $module = $this->getModule( $moduleName );
1117  if ( in_array( $messageKey, $module->getMessages() ) ) {
1118  $moduleNames[] = $moduleName;
1119  }
1120  }
1121  return $moduleNames;
1122  }
1123 
1124  /* Static Methods */
1125 
1142  protected static function makeLoaderImplementScript(
1143  $name, $scripts, $styles, $messages, $templates
1144  ) {
1145 
1146  if ( $scripts instanceof XmlJsCode ) {
1147  $scripts = new XmlJsCode( "function ( $, jQuery, require, module ) {\n{$scripts->value}\n}" );
1148  } elseif ( !is_string( $scripts ) && !is_array( $scripts ) ) {
1149  throw new MWException( 'Invalid scripts error. Array of URLs or string of code expected.' );
1150  }
1151  // mw.loader.implement requires 'styles', 'messages' and 'templates' to be objects (not
1152  // arrays). json_encode considers empty arrays to be numerical and outputs "[]" instead
1153  // of "{}". Force them to objects.
1154  $module = [
1155  $name,
1156  $scripts,
1157  (object)$styles,
1158  (object)$messages,
1159  (object)$templates,
1160  ];
1161  self::trimArray( $module );
1162 
1163  return Xml::encodeJsCall( 'mw.loader.implement', $module, ResourceLoader::inDebugMode() );
1164  }
1165 
1173  public static function makeMessageSetScript( $messages ) {
1174  return Xml::encodeJsCall(
1175  'mw.messages.set',
1176  [ (object)$messages ],
1178  );
1179  }
1180 
1188  public static function makeCombinedStyles( array $stylePairs ) {
1189  $out = [];
1190  foreach ( $stylePairs as $media => $styles ) {
1191  // ResourceLoaderFileModule::getStyle can return the styles
1192  // as a string or an array of strings. This is to allow separation in
1193  // the front-end.
1194  $styles = (array)$styles;
1195  foreach ( $styles as $style ) {
1196  $style = trim( $style );
1197  // Don't output an empty "@media print { }" block (bug 40498)
1198  if ( $style !== '' ) {
1199  // Transform the media type based on request params and config
1200  // The way that this relies on $wgRequest to propagate request params is slightly evil
1201  $media = OutputPage::transformCssMedia( $media );
1202 
1203  if ( $media === '' || $media == 'all' ) {
1204  $out[] = $style;
1205  } elseif ( is_string( $media ) ) {
1206  $out[] = "@media $media {\n" . str_replace( "\n", "\n\t", "\t" . $style ) . "}";
1207  }
1208  // else: skip
1209  }
1210  }
1211  }
1212  return $out;
1213  }
1214 
1229  public static function makeLoaderStateScript( $name, $state = null ) {
1230  if ( is_array( $name ) ) {
1231  return Xml::encodeJsCall(
1232  'mw.loader.state',
1233  [ $name ],
1235  );
1236  } else {
1237  return Xml::encodeJsCall(
1238  'mw.loader.state',
1239  [ $name, $state ],
1241  );
1242  }
1243  }
1244 
1259  public static function makeCustomLoaderScript( $name, $version, $dependencies,
1260  $group, $source, $script
1261  ) {
1262  $script = str_replace( "\n", "\n\t", trim( $script ) );
1263  return Xml::encodeJsCall(
1264  "( function ( name, version, dependencies, group, source ) {\n\t$script\n} )",
1265  [ $name, $version, $dependencies, $group, $source ],
1267  );
1268  }
1269 
1270  private static function isEmptyObject( stdClass $obj ) {
1271  foreach ( $obj as $key => $value ) {
1272  return false;
1273  }
1274  return true;
1275  }
1276 
1289  private static function trimArray( array &$array ) {
1290  $i = count( $array );
1291  while ( $i-- ) {
1292  if ( $array[$i] === null
1293  || $array[$i] === []
1294  || ( $array[$i] instanceof XmlJsCode && $array[$i]->value === '{}' )
1295  || ( $array[$i] instanceof stdClass && self::isEmptyObject( $array[$i] ) )
1296  ) {
1297  unset( $array[$i] );
1298  } else {
1299  break;
1300  }
1301  }
1302  }
1303 
1331  public static function makeLoaderRegisterScript( $name, $version = null,
1332  $dependencies = null, $group = null, $source = null, $skip = null
1333  ) {
1334  if ( is_array( $name ) ) {
1335  // Build module name index
1336  $index = [];
1337  foreach ( $name as $i => &$module ) {
1338  $index[$module[0]] = $i;
1339  }
1340 
1341  // Transform dependency names into indexes when possible, they will be resolved by
1342  // mw.loader.register on the other end
1343  foreach ( $name as &$module ) {
1344  if ( isset( $module[2] ) ) {
1345  foreach ( $module[2] as &$dependency ) {
1346  if ( isset( $index[$dependency] ) ) {
1347  $dependency = $index[$dependency];
1348  }
1349  }
1350  }
1351  }
1352 
1353  array_walk( $name, [ 'self', 'trimArray' ] );
1354 
1355  return Xml::encodeJsCall(
1356  'mw.loader.register',
1357  [ $name ],
1359  );
1360  } else {
1361  $registration = [ $name, $version, $dependencies, $group, $source, $skip ];
1362  self::trimArray( $registration );
1363  return Xml::encodeJsCall(
1364  'mw.loader.register',
1365  $registration,
1367  );
1368  }
1369  }
1370 
1385  public static function makeLoaderSourcesScript( $id, $loadUrl = null ) {
1386  if ( is_array( $id ) ) {
1387  return Xml::encodeJsCall(
1388  'mw.loader.addSource',
1389  [ $id ],
1391  );
1392  } else {
1393  return Xml::encodeJsCall(
1394  'mw.loader.addSource',
1395  [ $id, $loadUrl ],
1397  );
1398  }
1399  }
1400 
1409  public static function makeLoaderConditionalScript( $script ) {
1410  return '(window.RLQ=window.RLQ||[]).push(function(){' .
1411  trim( $script ) . '});';
1412  }
1413 
1423  public static function makeInlineScript( $script ) {
1424  $js = self::makeLoaderConditionalScript( $script );
1425  return new WrappedString(
1426  Html::inlineScript( $js ),
1427  '<script>(window.RLQ=window.RLQ||[]).push(function(){',
1428  '});</script>'
1429  );
1430  }
1431 
1439  public static function makeConfigSetScript( array $configuration ) {
1440  return Xml::encodeJsCall(
1441  'mw.config.set',
1442  [ $configuration ],
1444  );
1445  }
1446 
1455  public static function makePackedModulesString( $modules ) {
1456  $groups = []; // [ prefix => [ suffixes ] ]
1457  foreach ( $modules as $module ) {
1458  $pos = strrpos( $module, '.' );
1459  $prefix = $pos === false ? '' : substr( $module, 0, $pos );
1460  $suffix = $pos === false ? $module : substr( $module, $pos + 1 );
1461  $groups[$prefix][] = $suffix;
1462  }
1463 
1464  $arr = [];
1465  foreach ( $groups as $prefix => $suffixes ) {
1466  $p = $prefix === '' ? '' : $prefix . '.';
1467  $arr[] = $p . implode( ',', $suffixes );
1468  }
1469  $str = implode( '|', $arr );
1470  return $str;
1471  }
1472 
1478  public static function inDebugMode() {
1479  if ( self::$debugMode === null ) {
1481  self::$debugMode = $wgRequest->getFuzzyBool( 'debug',
1482  $wgRequest->getCookie( 'resourceLoaderDebug', '', $wgResourceLoaderDebug )
1483  );
1484  }
1485  return self::$debugMode;
1486  }
1487 
1495  public static function clearCache() {
1496  self::$debugMode = null;
1497  }
1498 
1508  public function createLoaderURL( $source, ResourceLoaderContext $context,
1509  $extraQuery = []
1510  ) {
1511  $query = self::createLoaderQuery( $context, $extraQuery );
1512  $script = $this->getLoadScript( $source );
1513 
1514  return wfAppendQuery( $script, $query );
1515  }
1516 
1526  protected static function createLoaderQuery( ResourceLoaderContext $context, $extraQuery = [] ) {
1527  return self::makeLoaderQuery(
1528  $context->getModules(),
1529  $context->getLanguage(),
1530  $context->getSkin(),
1531  $context->getUser(),
1532  $context->getVersion(),
1533  $context->getDebug(),
1534  $context->getOnly(),
1535  $context->getRequest()->getBool( 'printable' ),
1536  $context->getRequest()->getBool( 'handheld' ),
1537  $extraQuery
1538  );
1539  }
1540 
1558  public static function makeLoaderQuery( $modules, $lang, $skin, $user = null,
1559  $version = null, $debug = false, $only = null, $printable = false,
1560  $handheld = false, $extraQuery = []
1561  ) {
1562  $query = [
1563  'modules' => self::makePackedModulesString( $modules ),
1564  'lang' => $lang,
1565  'skin' => $skin,
1566  'debug' => $debug ? 'true' : 'false',
1567  ];
1568  if ( $user !== null ) {
1569  $query['user'] = $user;
1570  }
1571  if ( $version !== null ) {
1572  $query['version'] = $version;
1573  }
1574  if ( $only !== null ) {
1575  $query['only'] = $only;
1576  }
1577  if ( $printable ) {
1578  $query['printable'] = 1;
1579  }
1580  if ( $handheld ) {
1581  $query['handheld'] = 1;
1582  }
1583  $query += $extraQuery;
1584 
1585  // Make queries uniform in order
1586  ksort( $query );
1587  return $query;
1588  }
1589 
1599  public static function isValidModuleName( $moduleName ) {
1600  return strcspn( $moduleName, '!,|', 0, 255 ) === strlen( $moduleName );
1601  }
1602 
1612  public function getLessCompiler( $extraVars = [] ) {
1613  // When called from the installer, it is possible that a required PHP extension
1614  // is missing (at least for now; see bug 47564). If this is the case, throw an
1615  // exception (caught by the installer) to prevent a fatal error later on.
1616  if ( !class_exists( 'Less_Parser' ) ) {
1617  throw new MWException( 'MediaWiki requires the less.php parser' );
1618  }
1619 
1620  $parser = new Less_Parser;
1621  $parser->ModifyVars( array_merge( $this->getLessVars(), $extraVars ) );
1622  $parser->SetImportDirs(
1623  array_fill_keys( $this->config->get( 'ResourceLoaderLESSImportPaths' ), '' )
1624  );
1625  $parser->SetOption( 'relativeUrls', false );
1626 
1627  return $parser;
1628  }
1629 
1636  public function getLessVars() {
1637  if ( !$this->lessVars ) {
1638  $lessVars = $this->config->get( 'ResourceLoaderLESSVars' );
1639  Hooks::run( 'ResourceLoaderGetLessVars', [ &$lessVars ] );
1640  $this->lessVars = $lessVars;
1641  }
1642  return $this->lessVars;
1643  }
1644 }
#define the
table suitable for use with IDatabase::select()
This class generates message blobs for use by ResourceLoader modules.
getModuleNames()
Get a list of module names.
deferred txt A few of the database updates required by various functions here can be deferred until after the result page is displayed to the user For updating the view updating the linked to tables after a etc PHP does not yet have any way to tell the server to actually return and disconnect while still running these but it might have such a feature in the future We handle these by creating a deferred update object and putting those objects on a global list
Definition: deferred.txt:11
array $modules
Module name/ResourceLoaderModule object pairs.
wfGetDB($db, $groups=[], $wiki=false)
Get a Database object.
static inlineScript($contents)
Output a "