MediaWiki  1.28.3
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 
258  // Avoid PHP 7.1 warning from passing $this by reference
259  $rl = $this;
260  Hooks::run( 'ResourceLoaderRegisterModules', [ &$rl ] );
261 
262  if ( $config->get( 'EnableJavaScriptTest' ) === true ) {
263  $this->registerTestModules();
264  }
265 
266  $this->setMessageBlobStore( new MessageBlobStore( $this, $this->logger ) );
267  }
268 
272  public function getConfig() {
273  return $this->config;
274  }
275 
280  public function setLogger( LoggerInterface $logger ) {
281  $this->logger = $logger;
282  }
283 
288  public function getLogger() {
289  return $this->logger;
290  }
291 
296  public function getMessageBlobStore() {
297  return $this->blobStore;
298  }
299 
305  $this->blobStore = $blobStore;
306  }
307 
321  public function register( $name, $info = null ) {
322  $moduleSkinStyles = $this->config->get( 'ResourceModuleSkinStyles' );
323 
324  // Allow multiple modules to be registered in one call
325  $registrations = is_array( $name ) ? $name : [ $name => $info ];
326  foreach ( $registrations as $name => $info ) {
327  // Warn on duplicate registrations
328  if ( isset( $this->moduleInfos[$name] ) ) {
329  // A module has already been registered by this name
330  $this->logger->warning(
331  'ResourceLoader duplicate registration warning. ' .
332  'Another module has already been registered as ' . $name
333  );
334  }
335 
336  // Check $name for validity
337  if ( !self::isValidModuleName( $name ) ) {
338  throw new MWException( "ResourceLoader module name '$name' is invalid, "
339  . "see ResourceLoader::isValidModuleName()" );
340  }
341 
342  // Attach module
343  if ( $info instanceof ResourceLoaderModule ) {
344  $this->moduleInfos[$name] = [ 'object' => $info ];
345  $info->setName( $name );
346  $this->modules[$name] = $info;
347  } elseif ( is_array( $info ) ) {
348  // New calling convention
349  $this->moduleInfos[$name] = $info;
350  } else {
351  throw new MWException(
352  'ResourceLoader module info type error for module \'' . $name .
353  '\': expected ResourceLoaderModule or array (got: ' . gettype( $info ) . ')'
354  );
355  }
356 
357  // Last-minute changes
358 
359  // Apply custom skin-defined styles to existing modules.
360  if ( $this->isFileModule( $name ) ) {
361  foreach ( $moduleSkinStyles as $skinName => $skinStyles ) {
362  // If this module already defines skinStyles for this skin, ignore $wgResourceModuleSkinStyles.
363  if ( isset( $this->moduleInfos[$name]['skinStyles'][$skinName] ) ) {
364  continue;
365  }
366 
367  // If $name is preceded with a '+', the defined style files will be added to 'default'
368  // skinStyles, otherwise 'default' will be ignored as it normally would be.
369  if ( isset( $skinStyles[$name] ) ) {
370  $paths = (array)$skinStyles[$name];
371  $styleFiles = [];
372  } elseif ( isset( $skinStyles['+' . $name] ) ) {
373  $paths = (array)$skinStyles['+' . $name];
374  $styleFiles = isset( $this->moduleInfos[$name]['skinStyles']['default'] ) ?
375  (array)$this->moduleInfos[$name]['skinStyles']['default'] :
376  [];
377  } else {
378  continue;
379  }
380 
381  // Add new file paths, remapping them to refer to our directories and not use settings
382  // from the module we're modifying, which come from the base definition.
383  list( $localBasePath, $remoteBasePath ) =
385 
386  foreach ( $paths as $path ) {
387  $styleFiles[] = new ResourceLoaderFilePath( $path, $localBasePath, $remoteBasePath );
388  }
389 
390  $this->moduleInfos[$name]['skinStyles'][$skinName] = $styleFiles;
391  }
392  }
393  }
394 
395  }
396 
399  public function registerTestModules() {
400  global $IP;
401 
402  if ( $this->config->get( 'EnableJavaScriptTest' ) !== true ) {
403  throw new MWException( 'Attempt to register JavaScript test modules '
404  . 'but <code>$wgEnableJavaScriptTest</code> is false. '
405  . 'Edit your <code>LocalSettings.php</code> to enable it.' );
406  }
407 
408  // Get core test suites
409  $testModules = [];
410  $testModules['qunit'] = [];
411  // Get other test suites (e.g. from extensions)
412  // Avoid PHP 7.1 warning from passing $this by reference
413  $rl = $this;
414  Hooks::run( 'ResourceLoaderTestModules', [ &$testModules, &$rl ] );
415 
416  // Add the testrunner (which configures QUnit) to the dependencies.
417  // Since it must be ready before any of the test suites are executed.
418  foreach ( $testModules['qunit'] as &$module ) {
419  // Make sure all test modules are top-loading so that when QUnit starts
420  // on document-ready, it will run once and finish. If some tests arrive
421  // later (possibly after QUnit has already finished) they will be ignored.
422  $module['position'] = 'top';
423  $module['dependencies'][] = 'test.mediawiki.qunit.testrunner';
424  }
425 
426  $testModules['qunit'] =
427  ( include "$IP/tests/qunit/QUnitTestResources.php" ) + $testModules['qunit'];
428 
429  foreach ( $testModules as $id => $names ) {
430  // Register test modules
431  $this->register( $testModules[$id] );
432 
433  // Keep track of their names so that they can be loaded together
434  $this->testModuleNames[$id] = array_keys( $testModules[$id] );
435  }
436 
437  }
438 
449  public function addSource( $id, $loadUrl = null ) {
450  // Allow multiple sources to be registered in one call
451  if ( is_array( $id ) ) {
452  foreach ( $id as $key => $value ) {
453  $this->addSource( $key, $value );
454  }
455  return;
456  }
457 
458  // Disallow duplicates
459  if ( isset( $this->sources[$id] ) ) {
460  throw new MWException(
461  'ResourceLoader duplicate source addition error. ' .
462  'Another source has already been registered as ' . $id
463  );
464  }
465 
466  // Pre 1.24 backwards-compatibility
467  if ( is_array( $loadUrl ) ) {
468  if ( !isset( $loadUrl['loadScript'] ) ) {
469  throw new MWException(
470  __METHOD__ . ' was passed an array with no "loadScript" key.'
471  );
472  }
473 
474  $loadUrl = $loadUrl['loadScript'];
475  }
476 
477  $this->sources[$id] = $loadUrl;
478  }
479 
485  public function getModuleNames() {
486  return array_keys( $this->moduleInfos );
487  }
488 
499  public function getTestModuleNames( $framework = 'all' ) {
501  if ( $framework == 'all' ) {
502  return $this->testModuleNames;
503  } elseif ( isset( $this->testModuleNames[$framework] )
504  && is_array( $this->testModuleNames[$framework] )
505  ) {
506  return $this->testModuleNames[$framework];
507  } else {
508  return [];
509  }
510  }
511 
519  public function isModuleRegistered( $name ) {
520  return isset( $this->moduleInfos[$name] );
521  }
522 
534  public function getModule( $name ) {
535  if ( !isset( $this->modules[$name] ) ) {
536  if ( !isset( $this->moduleInfos[$name] ) ) {
537  // No such module
538  return null;
539  }
540  // Construct the requested object
541  $info = $this->moduleInfos[$name];
543  if ( isset( $info['object'] ) ) {
544  // Object given in info array
545  $object = $info['object'];
546  } else {
547  if ( !isset( $info['class'] ) ) {
548  $class = 'ResourceLoaderFileModule';
549  } else {
550  $class = $info['class'];
551  }
553  $object = new $class( $info );
554  $object->setConfig( $this->getConfig() );
555  $object->setLogger( $this->logger );
556  }
557  $object->setName( $name );
558  $this->modules[$name] = $object;
559  }
560 
561  return $this->modules[$name];
562  }
563 
570  protected function isFileModule( $name ) {
571  if ( !isset( $this->moduleInfos[$name] ) ) {
572  return false;
573  }
574  $info = $this->moduleInfos[$name];
575  if ( isset( $info['object'] ) || isset( $info['class'] ) ) {
576  return false;
577  }
578  return true;
579  }
580 
586  public function getSources() {
587  return $this->sources;
588  }
589 
599  public function getLoadScript( $source ) {
600  if ( !isset( $this->sources[$source] ) ) {
601  throw new MWException( "The $source source was never registered in ResourceLoader." );
602  }
603  return $this->sources[$source];
604  }
605 
611  public static function makeHash( $value ) {
612  $hash = hash( 'fnv132', $value );
613  return Wikimedia\base_convert( $hash, 16, 36, 7 );
614  }
615 
624  public function getCombinedVersion( ResourceLoaderContext $context, array $moduleNames ) {
625  if ( !$moduleNames ) {
626  return '';
627  }
628  $hashes = array_map( function ( $module ) use ( $context ) {
629  return $this->getModule( $module )->getVersionHash( $context );
630  }, $moduleNames );
631  return self::makeHash( implode( '', $hashes ) );
632  }
633 
648  public function makeVersionQuery( ResourceLoaderContext $context ) {
649  // As of MediaWiki 1.28, the server and client use the same algorithm for combining
650  // version hashes. There is no technical reason for this to be same, and for years the
651  // implementations differed. If getCombinedVersion in PHP (used for StartupModule and
652  // E-Tag headers) differs in the future from getCombinedVersion in JS (used for 'version'
653  // query parameter), then this method must continue to match the JS one.
654  $moduleNames = [];
655  foreach ( $context->getModules() as $name ) {
656  if ( !$this->getModule( $name ) ) {
657  // If a versioned request contains a missing module, the version is a mismatch
658  // as the client considered a module (and version) we don't have.
659  return '';
660  }
661  $moduleNames[] = $name;
662  }
663  return $this->getCombinedVersion( $context, $moduleNames );
664  }
665 
671  public function respond( ResourceLoaderContext $context ) {
672  // Buffer output to catch warnings. Normally we'd use ob_clean() on the
673  // top-level output buffer to clear warnings, but that breaks when ob_gzhandler
674  // is used: ob_clean() will clear the GZIP header in that case and it won't come
675  // back for subsequent output, resulting in invalid GZIP. So we have to wrap
676  // the whole thing in our own output buffer to be sure the active buffer
677  // doesn't use ob_gzhandler.
678  // See http://bugs.php.net/bug.php?id=36514
679  ob_start();
680 
681  // Find out which modules are missing and instantiate the others
682  $modules = [];
683  $missing = [];
684  foreach ( $context->getModules() as $name ) {
685  $module = $this->getModule( $name );
686  if ( $module ) {
687  // Do not allow private modules to be loaded from the web.
688  // This is a security issue, see bug 34907.
689  if ( $module->getGroup() === 'private' ) {
690  $this->logger->debug( "Request for private module '$name' denied" );
691  $this->errors[] = "Cannot show private module \"$name\"";
692  continue;
693  }
694  $modules[$name] = $module;
695  } else {
696  $missing[] = $name;
697  }
698  }
699 
700  try {
701  // Preload for getCombinedVersion() and for batch makeModuleResponse()
702  $this->preloadModuleInfo( array_keys( $modules ), $context );
703  } catch ( Exception $e ) {
705  $this->logger->warning( 'Preloading module info failed: {exception}', [
706  'exception' => $e
707  ] );
708  $this->errors[] = self::formatExceptionNoComment( $e );
709  }
710 
711  // Combine versions to propagate cache invalidation
712  $versionHash = '';
713  try {
714  $versionHash = $this->getCombinedVersion( $context, array_keys( $modules ) );
715  } catch ( Exception $e ) {
717  $this->logger->warning( 'Calculating version hash failed: {exception}', [
718  'exception' => $e
719  ] );
720  $this->errors[] = self::formatExceptionNoComment( $e );
721  }
722 
723  // See RFC 2616 § 3.11 Entity Tags
724  // http://www.w3.org/Protocols/rfc2616/rfc2616-sec3.html#sec3.11
725  $etag = 'W/"' . $versionHash . '"';
726 
727  // Try the client-side cache first
728  if ( $this->tryRespondNotModified( $context, $etag ) ) {
729  return; // output handled (buffers cleared)
730  }
731 
732  // Use file cache if enabled and available...
733  if ( $this->config->get( 'UseFileCache' ) ) {
734  $fileCache = ResourceFileCache::newFromContext( $context );
735  if ( $this->tryRespondFromFileCache( $fileCache, $context, $etag ) ) {
736  return; // output handled
737  }
738  }
739 
740  // Generate a response
741  $response = $this->makeModuleResponse( $context, $modules, $missing );
742 
743  // Capture any PHP warnings from the output buffer and append them to the
744  // error list if we're in debug mode.
745  if ( $context->getDebug() ) {
746  $warnings = ob_get_contents();
747  if ( strlen( $warnings ) ) {
748  $this->errors[] = $warnings;
749  }
750  }
751 
752  // Save response to file cache unless there are errors
753  if ( isset( $fileCache ) && !$this->errors && !count( $missing ) ) {
754  // Cache single modules and images...and other requests if there are enough hits
755  if ( ResourceFileCache::useFileCache( $context ) ) {
756  if ( $fileCache->isCacheWorthy() ) {
757  $fileCache->saveText( $response );
758  } else {
759  $fileCache->incrMissesRecent( $context->getRequest() );
760  }
761  }
762  }
763 
764  $this->sendResponseHeaders( $context, $etag, (bool)$this->errors );
765 
766  // Remove the output buffer and output the response
767  ob_end_clean();
768 
769  if ( $context->getImageObj() && $this->errors ) {
770  // We can't show both the error messages and the response when it's an image.
771  $response = implode( "\n\n", $this->errors );
772  } elseif ( $this->errors ) {
773  $errorText = implode( "\n\n", $this->errors );
774  $errorResponse = self::makeComment( $errorText );
775  if ( $context->shouldIncludeScripts() ) {
776  $errorResponse .= 'if (window.console && console.error) {'
777  . Xml::encodeJsCall( 'console.error', [ $errorText ] )
778  . "}\n";
779  }
780 
781  // Prepend error info to the response
782  $response = $errorResponse . $response;
783  }
784 
785  $this->errors = [];
786  echo $response;
787 
788  }
789 
800  protected function sendResponseHeaders( ResourceLoaderContext $context, $etag, $errors ) {
801  $rlMaxage = $this->config->get( 'ResourceLoaderMaxage' );
802  // Use a short cache expiry so that updates propagate to clients quickly, if:
803  // - No version specified (shared resources, e.g. stylesheets)
804  // - There were errors (recover quickly)
805  // - Version mismatch (T117587, T47877)
806  if ( is_null( $context->getVersion() )
807  || $errors
808  || $context->getVersion() !== $this->makeVersionQuery( $context )
809  ) {
810  $maxage = $rlMaxage['unversioned']['client'];
811  $smaxage = $rlMaxage['unversioned']['server'];
812  // If a version was specified we can use a longer expiry time since changing
813  // version numbers causes cache misses
814  } else {
815  $maxage = $rlMaxage['versioned']['client'];
816  $smaxage = $rlMaxage['versioned']['server'];
817  }
818  if ( $context->getImageObj() ) {
819  // Output different headers if we're outputting textual errors.
820  if ( $errors ) {
821  header( 'Content-Type: text/plain; charset=utf-8' );
822  } else {
823  $context->getImageObj()->sendResponseHeaders( $context );
824  }
825  } elseif ( $context->getOnly() === 'styles' ) {
826  header( 'Content-Type: text/css; charset=utf-8' );
827  header( 'Access-Control-Allow-Origin: *' );
828  } else {
829  header( 'Content-Type: text/javascript; charset=utf-8' );
830  }
831  // See RFC 2616 § 14.19 ETag
832  // http://www.w3.org/Protocols/rfc2616/rfc2616-sec14.html#sec14.19
833  header( 'ETag: ' . $etag );
834  if ( $context->getDebug() ) {
835  // Do not cache debug responses
836  header( 'Cache-Control: private, no-cache, must-revalidate' );
837  header( 'Pragma: no-cache' );
838  } else {
839  header( "Cache-Control: public, max-age=$maxage, s-maxage=$smaxage" );
840  $exp = min( $maxage, $smaxage );
841  header( 'Expires: ' . wfTimestamp( TS_RFC2822, $exp + time() ) );
842  }
843  }
844 
855  protected function tryRespondNotModified( ResourceLoaderContext $context, $etag ) {
856  // See RFC 2616 § 14.26 If-None-Match
857  // http://www.w3.org/Protocols/rfc2616/rfc2616-sec14.html#sec14.26
858  $clientKeys = $context->getRequest()->getHeader( 'If-None-Match', WebRequest::GETHEADER_LIST );
859  // Never send 304s in debug mode
860  if ( $clientKeys !== false && !$context->getDebug() && in_array( $etag, $clientKeys ) ) {
861  // There's another bug in ob_gzhandler (see also the comment at
862  // the top of this function) that causes it to gzip even empty
863  // responses, meaning it's impossible to produce a truly empty
864  // response (because the gzip header is always there). This is
865  // a problem because 304 responses have to be completely empty
866  // per the HTTP spec, and Firefox behaves buggily when they're not.
867  // See also http://bugs.php.net/bug.php?id=51579
868  // To work around this, we tear down all output buffering before
869  // sending the 304.
870  wfResetOutputBuffers( /* $resetGzipEncoding = */ true );
871 
872  HttpStatus::header( 304 );
873 
874  $this->sendResponseHeaders( $context, $etag, false );
875  return true;
876  }
877  return false;
878  }
879 
888  protected function tryRespondFromFileCache(
889  ResourceFileCache $fileCache,
890  ResourceLoaderContext $context,
891  $etag
892  ) {
893  $rlMaxage = $this->config->get( 'ResourceLoaderMaxage' );
894  // Buffer output to catch warnings.
895  ob_start();
896  // Get the maximum age the cache can be
897  $maxage = is_null( $context->getVersion() )
898  ? $rlMaxage['unversioned']['server']
899  : $rlMaxage['versioned']['server'];
900  // Minimum timestamp the cache file must have
901  $good = $fileCache->isCacheGood( wfTimestamp( TS_MW, time() - $maxage ) );
902  if ( !$good ) {
903  try { // RL always hits the DB on file cache miss...
904  wfGetDB( DB_REPLICA );
905  } catch ( DBConnectionError $e ) { // ...check if we need to fallback to cache
906  $good = $fileCache->isCacheGood(); // cache existence check
907  }
908  }
909  if ( $good ) {
910  $ts = $fileCache->cacheTimestamp();
911  // Send content type and cache headers
912  $this->sendResponseHeaders( $context, $etag, false );
913  $response = $fileCache->fetchText();
914  // Capture any PHP warnings from the output buffer and append them to the
915  // response in a comment if we're in debug mode.
916  if ( $context->getDebug() ) {
917  $warnings = ob_get_contents();
918  if ( strlen( $warnings ) ) {
919  $response = self::makeComment( $warnings ) . $response;
920  }
921  }
922  // Remove the output buffer and output the response
923  ob_end_clean();
924  echo $response . "\n/* Cached {$ts} */";
925  return true; // cache hit
926  }
927  // Clear buffer
928  ob_end_clean();
929 
930  return false; // cache miss
931  }
932 
941  public static function makeComment( $text ) {
942  $encText = str_replace( '*/', '* /', $text );
943  return "/*\n$encText\n*/\n";
944  }
945 
952  public static function formatException( $e ) {
953  return self::makeComment( self::formatExceptionNoComment( $e ) );
954  }
955 
963  protected static function formatExceptionNoComment( $e ) {
964  global $wgShowExceptionDetails;
965 
966  if ( !$wgShowExceptionDetails ) {
968  }
969 
971  }
972 
981  public function makeModuleResponse( ResourceLoaderContext $context,
982  array $modules, array $missing = []
983  ) {
984  $out = '';
985  $states = [];
986 
987  if ( !count( $modules ) && !count( $missing ) ) {
988  return <<<MESSAGE
989 /* This file is the Web entry point for MediaWiki's ResourceLoader:
990  <https://www.mediawiki.org/wiki/ResourceLoader>. In this request,
991  no modules were requested. Max made me put this here. */
992 MESSAGE;
993  }
994 
995  $image = $context->getImageObj();
996  if ( $image ) {
997  $data = $image->getImageData( $context );
998  if ( $data === false ) {
999  $data = '';
1000  $this->errors[] = 'Image generation failed';
1001  }
1002  return $data;
1003  }
1004 
1005  foreach ( $missing as $name ) {
1006  $states[$name] = 'missing';
1007  }
1008 
1009  // Generate output
1010  $isRaw = false;
1011 
1012  $filter = $context->getOnly() === 'styles' ? 'minify-css' : 'minify-js';
1013 
1014  foreach ( $modules as $name => $module ) {
1015  try {
1016  $content = $module->getModuleContent( $context );
1017  $implementKey = $name . '@' . $module->getVersionHash( $context );
1018  $strContent = '';
1019 
1020  // Append output
1021  switch ( $context->getOnly() ) {
1022  case 'scripts':
1023  $scripts = $content['scripts'];
1024  if ( is_string( $scripts ) ) {
1025  // Load scripts raw...
1026  $strContent = $scripts;
1027  } elseif ( is_array( $scripts ) ) {
1028  // ...except when $scripts is an array of URLs
1029  $strContent = self::makeLoaderImplementScript( $implementKey, $scripts, [], [], [] );
1030  }
1031  break;
1032  case 'styles':
1033  $styles = $content['styles'];
1034  // We no longer seperate into media, they are all combined now with
1035  // custom media type groups into @media .. {} sections as part of the css string.
1036  // Module returns either an empty array or a numerical array with css strings.
1037  $strContent = isset( $styles['css'] ) ? implode( '', $styles['css'] ) : '';
1038  break;
1039  default:
1040  $scripts = isset( $content['scripts'] ) ? $content['scripts'] : '';
1041  if ( is_string( $scripts ) ) {
1042  if ( $name === 'site' || $name === 'user' ) {
1043  // Legacy scripts that run in the global scope without a closure.
1044  // mw.loader.implement will use globalEval if scripts is a string.
1045  // Minify manually here, because general response minification is
1046  // not effective due it being a string literal, not a function.
1047  if ( !ResourceLoader::inDebugMode() ) {
1048  $scripts = self::filter( 'minify-js', $scripts ); // T107377
1049  }
1050  } else {
1051  $scripts = new XmlJsCode( $scripts );
1052  }
1053  }
1054  $strContent = self::makeLoaderImplementScript(
1055  $implementKey,
1056  $scripts,
1057  isset( $content['styles'] ) ? $content['styles'] : [],
1058  isset( $content['messagesBlob'] ) ? new XmlJsCode( $content['messagesBlob'] ) : [],
1059  isset( $content['templates'] ) ? $content['templates'] : []
1060  );
1061  break;
1062  }
1063 
1064  if ( !$context->getDebug() ) {
1065  $strContent = self::filter( $filter, $strContent );
1066  }
1067 
1068  $out .= $strContent;
1069 
1070  } catch ( Exception $e ) {
1072  $this->logger->warning( 'Generating module package failed: {exception}', [
1073  'exception' => $e
1074  ] );
1075  $this->errors[] = self::formatExceptionNoComment( $e );
1076 
1077  // Respond to client with error-state instead of module implementation
1078  $states[$name] = 'error';
1079  unset( $modules[$name] );
1080  }
1081  $isRaw |= $module->isRaw();
1082  }
1083 
1084  // Update module states
1085  if ( $context->shouldIncludeScripts() && !$context->getRaw() && !$isRaw ) {
1086  if ( count( $modules ) && $context->getOnly() === 'scripts' ) {
1087  // Set the state of modules loaded as only scripts to ready as
1088  // they don't have an mw.loader.implement wrapper that sets the state
1089  foreach ( $modules as $name => $module ) {
1090  $states[$name] = 'ready';
1091  }
1092  }
1093 
1094  // Set the state of modules we didn't respond to with mw.loader.implement
1095  if ( count( $states ) ) {
1096  $stateScript = self::makeLoaderStateScript( $states );
1097  if ( !$context->getDebug() ) {
1098  $stateScript = self::filter( 'minify-js', $stateScript );
1099  }
1100  $out .= $stateScript;
1101  }
1102  } else {
1103  if ( count( $states ) ) {
1104  $this->errors[] = 'Problematic modules: ' .
1106  }
1107  }
1108 
1109  return $out;
1110  }
1111 
1118  public function getModulesByMessage( $messageKey ) {
1119  $moduleNames = [];
1120  foreach ( $this->getModuleNames() as $moduleName ) {
1121  $module = $this->getModule( $moduleName );
1122  if ( in_array( $messageKey, $module->getMessages() ) ) {
1123  $moduleNames[] = $moduleName;
1124  }
1125  }
1126  return $moduleNames;
1127  }
1128 
1129  /* Static Methods */
1130 
1147  protected static function makeLoaderImplementScript(
1148  $name, $scripts, $styles, $messages, $templates
1149  ) {
1150 
1151  if ( $scripts instanceof XmlJsCode ) {
1152  $scripts = new XmlJsCode( "function ( $, jQuery, require, module ) {\n{$scripts->value}\n}" );
1153  } elseif ( !is_string( $scripts ) && !is_array( $scripts ) ) {
1154  throw new MWException( 'Invalid scripts error. Array of URLs or string of code expected.' );
1155  }
1156  // mw.loader.implement requires 'styles', 'messages' and 'templates' to be objects (not
1157  // arrays). json_encode considers empty arrays to be numerical and outputs "[]" instead
1158  // of "{}". Force them to objects.
1159  $module = [
1160  $name,
1161  $scripts,
1162  (object)$styles,
1163  (object)$messages,
1164  (object)$templates,
1165  ];
1166  self::trimArray( $module );
1167 
1168  return Xml::encodeJsCall( 'mw.loader.implement', $module, ResourceLoader::inDebugMode() );
1169  }
1170 
1178  public static function makeMessageSetScript( $messages ) {
1179  return Xml::encodeJsCall(
1180  'mw.messages.set',
1181  [ (object)$messages ],
1183  );
1184  }
1185 
1193  public static function makeCombinedStyles( array $stylePairs ) {
1194  $out = [];
1195  foreach ( $stylePairs as $media => $styles ) {
1196  // ResourceLoaderFileModule::getStyle can return the styles
1197  // as a string or an array of strings. This is to allow separation in
1198  // the front-end.
1199  $styles = (array)$styles;
1200  foreach ( $styles as $style ) {
1201  $style = trim( $style );
1202  // Don't output an empty "@media print { }" block (bug 40498)
1203  if ( $style !== '' ) {
1204  // Transform the media type based on request params and config
1205  // The way that this relies on $wgRequest to propagate request params is slightly evil
1206  $media = OutputPage::transformCssMedia( $media );
1207 
1208  if ( $media === '' || $media == 'all' ) {
1209  $out[] = $style;
1210  } elseif ( is_string( $media ) ) {
1211  $out[] = "@media $media {\n" . str_replace( "\n", "\n\t", "\t" . $style ) . "}";
1212  }
1213  // else: skip
1214  }
1215  }
1216  }
1217  return $out;
1218  }
1219 
1234  public static function makeLoaderStateScript( $name, $state = null ) {
1235  if ( is_array( $name ) ) {
1236  return Xml::encodeJsCall(
1237  'mw.loader.state',
1238  [ $name ],
1240  );
1241  } else {
1242  return Xml::encodeJsCall(
1243  'mw.loader.state',
1244  [ $name, $state ],
1246  );
1247  }
1248  }
1249 
1264  public static function makeCustomLoaderScript( $name, $version, $dependencies,
1265  $group, $source, $script
1266  ) {
1267  $script = str_replace( "\n", "\n\t", trim( $script ) );
1268  return Xml::encodeJsCall(
1269  "( function ( name, version, dependencies, group, source ) {\n\t$script\n} )",
1270  [ $name, $version, $dependencies, $group, $source ],
1272  );
1273  }
1274 
1275  private static function isEmptyObject( stdClass $obj ) {
1276  foreach ( $obj as $key => $value ) {
1277  return false;
1278  }
1279  return true;
1280  }
1281 
1294  private static function trimArray( array &$array ) {
1295  $i = count( $array );
1296  while ( $i-- ) {
1297  if ( $array[$i] === null
1298  || $array[$i] === []
1299  || ( $array[$i] instanceof XmlJsCode && $array[$i]->value === '{}' )
1300  || ( $array[$i] instanceof stdClass && self::isEmptyObject( $array[$i] ) )
1301  ) {
1302  unset( $array[$i] );
1303  } else {
1304  break;
1305  }
1306  }
1307  }
1308 
1336  public static function makeLoaderRegisterScript( $name, $version = null,
1337  $dependencies = null, $group = null, $source = null, $skip = null
1338  ) {
1339  if ( is_array( $name ) ) {
1340  // Build module name index
1341  $index = [];
1342  foreach ( $name as $i => &$module ) {
1343  $index[$module[0]] = $i;
1344  }
1345 
1346  // Transform dependency names into indexes when possible, they will be resolved by
1347  // mw.loader.register on the other end
1348  foreach ( $name as &$module ) {
1349  if ( isset( $module[2] ) ) {
1350  foreach ( $module[2] as &$dependency ) {
1351  if ( isset( $index[$dependency] ) ) {
1352  $dependency = $index[$dependency];
1353  }
1354  }
1355  }
1356  }
1357 
1358  array_walk( $name, [ 'self', 'trimArray' ] );
1359 
1360  return Xml::encodeJsCall(
1361  'mw.loader.register',
1362  [ $name ],
1364  );
1365  } else {
1366  $registration = [ $name, $version, $dependencies, $group, $source, $skip ];
1367  self::trimArray( $registration );
1368  return Xml::encodeJsCall(
1369  'mw.loader.register',
1370  $registration,
1372  );
1373  }
1374  }
1375 
1390  public static function makeLoaderSourcesScript( $id, $loadUrl = null ) {
1391  if ( is_array( $id ) ) {
1392  return Xml::encodeJsCall(
1393  'mw.loader.addSource',
1394  [ $id ],
1396  );
1397  } else {
1398  return Xml::encodeJsCall(
1399  'mw.loader.addSource',
1400  [ $id, $loadUrl ],
1402  );
1403  }
1404  }
1405 
1414  public static function makeLoaderConditionalScript( $script ) {
1415  return '(window.RLQ=window.RLQ||[]).push(function(){' .
1416  trim( $script ) . '});';
1417  }
1418 
1428  public static function makeInlineScript( $script ) {
1429  $js = self::makeLoaderConditionalScript( $script );
1430  return new WrappedString(
1431  Html::inlineScript( $js ),
1432  '<script>(window.RLQ=window.RLQ||[]).push(function(){',
1433  '});</script>'
1434  );
1435  }
1436 
1444  public static function makeConfigSetScript( array $configuration ) {
1445  return Xml::encodeJsCall(
1446  'mw.config.set',
1447  [ $configuration ],
1449  );
1450  }
1451 
1460  public static function makePackedModulesString( $modules ) {
1461  $groups = []; // [ prefix => [ suffixes ] ]
1462  foreach ( $modules as $module ) {
1463  $pos = strrpos( $module, '.' );
1464  $prefix = $pos === false ? '' : substr( $module, 0, $pos );
1465  $suffix = $pos === false ? $module : substr( $module, $pos + 1 );
1466  $groups[$prefix][] = $suffix;
1467  }
1468 
1469  $arr = [];
1470  foreach ( $groups as $prefix => $suffixes ) {
1471  $p = $prefix === '' ? '' : $prefix . '.';
1472  $arr[] = $p . implode( ',', $suffixes );
1473  }
1474  $str = implode( '|', $arr );
1475  return $str;
1476  }
1477 
1483  public static function inDebugMode() {
1484  if ( self::$debugMode === null ) {
1486  self::$debugMode = $wgRequest->getFuzzyBool( 'debug',
1487  $wgRequest->getCookie( 'resourceLoaderDebug', '', $wgResourceLoaderDebug )
1488  );
1489  }
1490  return self::$debugMode;
1491  }
1492 
1500  public static function clearCache() {
1501  self::$debugMode = null;
1502  }
1503 
1513  public function createLoaderURL( $source, ResourceLoaderContext $context,
1514  $extraQuery = []
1515  ) {
1516  $query = self::createLoaderQuery( $context, $extraQuery );
1517  $script = $this->getLoadScript( $source );
1518 
1519  return wfAppendQuery( $script, $query );
1520  }
1521 
1531  protected static function createLoaderQuery( ResourceLoaderContext $context, $extraQuery = [] ) {
1532  return self::makeLoaderQuery(
1533  $context->getModules(),
1534  $context->getLanguage(),
1535  $context->getSkin(),
1536  $context->getUser(),
1537  $context->getVersion(),
1538  $context->getDebug(),
1539  $context->getOnly(),
1540  $context->getRequest()->getBool( 'printable' ),
1541  $context->getRequest()->getBool( 'handheld' ),
1542  $extraQuery
1543  );
1544  }
1545 
1563  public static function makeLoaderQuery( $modules, $lang, $skin, $user = null,
1564  $version = null, $debug = false, $only = null, $printable = false,
1565  $handheld = false, $extraQuery = []
1566  ) {
1567  $query = [
1568  'modules' => self::makePackedModulesString( $modules ),
1569  'lang' => $lang,
1570  'skin' => $skin,
1571  'debug' => $debug ? 'true' : 'false',
1572  ];
1573  if ( $user !== null ) {
1574  $query['user'] = $user;
1575  }
1576  if ( $version !== null ) {
1577  $query['version'] = $version;
1578  }
1579  if ( $only !== null ) {
1580  $query['only'] = $only;
1581  }
1582  if ( $printable ) {
1583  $query['printable'] = 1;
1584  }
1585  if ( $handheld ) {
1586  $query['handheld'] = 1;
1587  }
1588  $query += $extraQuery;
1589 
1590  // Make queries uniform in order
1591  ksort( $query );
1592  return $query;
1593  }
1594 
1604  public static function isValidModuleName( $moduleName ) {
1605  return strcspn( $moduleName, '!,|', 0, 255 ) === strlen( $moduleName );
1606  }
1607 
1617  public function getLessCompiler( $extraVars = [] ) {
1618  // When called from the installer, it is possible that a required PHP extension
1619  // is missing (at least for now; see bug 47564). If this is the case, throw an
1620  // exception (caught by the installer) to prevent a fatal error later on.
1621  if ( !class_exists( 'Less_Parser' ) ) {
1622  throw new MWException( 'MediaWiki requires the less.php parser' );
1623  }
1624 
1625  $parser = new Less_Parser;
1626  $parser->ModifyVars( array_merge( $this->getLessVars(), $extraVars ) );
1627  $parser->SetImportDirs(
1628  array_fill_keys( $this->config->get( 'ResourceLoaderLESSImportPaths' ), '' )
1629  );
1630  $parser->SetOption( 'relativeUrls', false );
1631 
1632  return $parser;
1633  }
1634 
1641  public function getLessVars() {
1642  if ( !$this->lessVars ) {
1643  $lessVars = $this->config->get( 'ResourceLoaderLESSVars' );
1644  Hooks::run( 'ResourceLoaderGetLessVars', [ &$lessVars ] );
1645  $this->lessVars = $lessVars;
1646  }
1647  return $this->lessVars;
1648  }
1649 }
#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 "