MediaWiki  1.27.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  private $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_SLAVE );
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  // Prime in-object cache for message blobs for modules with messages
144  $modules = [];
145  foreach ( $moduleNames as $name ) {
146  $module = $this->getModule( $name );
147  if ( $module && $module->getMessages() ) {
148  $modules[$name] = $module;
149  }
150  }
151  $store = $this->getMessageBlobStore();
152  $blobs = $store->getBlobs( $modules, $lang );
153  foreach ( $blobs as $name => $blob ) {
154  $modules[$name]->setMessageBlob( $blob, $lang );
155  }
156  }
157 
175  public static function filter( $filter, $data, array $options = [] ) {
176  if ( strpos( $data, ResourceLoader::FILTER_NOMIN ) !== false ) {
177  return $data;
178  }
179 
180  if ( isset( $options['cache'] ) && $options['cache'] === false ) {
181  return self::applyFilter( $filter, $data );
182  }
183 
184  $stats = RequestContext::getMain()->getStats();
186 
187  $key = $cache->makeGlobalKey(
188  'resourceloader',
189  'filter',
190  $filter,
191  self::$filterCacheVersion, md5( $data )
192  );
193 
194  $result = $cache->get( $key );
195  if ( $result === false ) {
196  $stats->increment( "resourceloader_cache.$filter.miss" );
197  $result = self::applyFilter( $filter, $data );
198  $cache->set( $key, $result, 24 * 3600 );
199  } else {
200  $stats->increment( "resourceloader_cache.$filter.hit" );
201  }
202  if ( $result === null ) {
203  // Cached failure
204  $result = $data;
205  }
206 
207  return $result;
208  }
209 
210  private static function applyFilter( $filter, $data ) {
211  $data = trim( $data );
212  if ( $data ) {
213  try {
214  $data = ( $filter === 'minify-css' )
215  ? CSSMin::minify( $data )
216  : JavaScriptMinifier::minify( $data );
217  } catch ( Exception $e ) {
219  return null;
220  }
221  }
222  return $data;
223  }
224 
225  /* Methods */
226 
232  public function __construct( Config $config = null, LoggerInterface $logger = null ) {
233  global $IP;
234 
235  $this->logger = $logger ?: new NullLogger();
236 
237  if ( !$config ) {
238  $this->logger->debug( __METHOD__ . ' was called without providing a Config instance' );
239  $config = ConfigFactory::getDefaultInstance()->makeConfig( 'main' );
240  }
241  $this->config = $config;
242 
243  // Add 'local' source first
244  $this->addSource( 'local', wfScript( 'load' ) );
245 
246  // Add other sources
247  $this->addSource( $config->get( 'ResourceLoaderSources' ) );
248 
249  // Register core modules
250  $this->register( include "$IP/resources/Resources.php" );
251  $this->register( include "$IP/resources/ResourcesOOUI.php" );
252  // Register extension modules
253  $this->register( $config->get( 'ResourceModules' ) );
254 
255  // Avoid PHP 7.1 warning from passing $this by reference
256  $rl = $this;
257  Hooks::run( 'ResourceLoaderRegisterModules', [ &$rl ] );
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 
320  // Allow multiple modules to be registered in one call
321  $registrations = is_array( $name ) ? $name : [ $name => $info ];
322  foreach ( $registrations as $name => $info ) {
323  // Warn on duplicate registrations
324  if ( isset( $this->moduleInfos[$name] ) ) {
325  // A module has already been registered by this name
326  $this->logger->warning(
327  'ResourceLoader duplicate registration warning. ' .
328  'Another module has already been registered as ' . $name
329  );
330  }
331 
332  // Check $name for validity
333  if ( !self::isValidModuleName( $name ) ) {
334  throw new MWException( "ResourceLoader module name '$name' is invalid, "
335  . "see ResourceLoader::isValidModuleName()" );
336  }
337 
338  // Attach module
339  if ( $info instanceof ResourceLoaderModule ) {
340  $this->moduleInfos[$name] = [ 'object' => $info ];
341  $info->setName( $name );
342  $this->modules[$name] = $info;
343  } elseif ( is_array( $info ) ) {
344  // New calling convention
345  $this->moduleInfos[$name] = $info;
346  } else {
347  throw new MWException(
348  'ResourceLoader module info type error for module \'' . $name .
349  '\': expected ResourceLoaderModule or array (got: ' . gettype( $info ) . ')'
350  );
351  }
352 
353  // Last-minute changes
354 
355  // Apply custom skin-defined styles to existing modules.
356  if ( $this->isFileModule( $name ) ) {
357  foreach ( $this->config->get( 'ResourceModuleSkinStyles' ) as $skinName => $skinStyles ) {
358  // If this module already defines skinStyles for this skin, ignore $wgResourceModuleSkinStyles.
359  if ( isset( $this->moduleInfos[$name]['skinStyles'][$skinName] ) ) {
360  continue;
361  }
362 
363  // If $name is preceded with a '+', the defined style files will be added to 'default'
364  // skinStyles, otherwise 'default' will be ignored as it normally would be.
365  if ( isset( $skinStyles[$name] ) ) {
366  $paths = (array)$skinStyles[$name];
367  $styleFiles = [];
368  } elseif ( isset( $skinStyles['+' . $name] ) ) {
369  $paths = (array)$skinStyles['+' . $name];
370  $styleFiles = isset( $this->moduleInfos[$name]['skinStyles']['default'] ) ?
371  (array)$this->moduleInfos[$name]['skinStyles']['default'] :
372  [];
373  } else {
374  continue;
375  }
376 
377  // Add new file paths, remapping them to refer to our directories and not use settings
378  // from the module we're modifying, which come from the base definition.
379  list( $localBasePath, $remoteBasePath ) =
381 
382  foreach ( $paths as $path ) {
383  $styleFiles[] = new ResourceLoaderFilePath( $path, $localBasePath, $remoteBasePath );
384  }
385 
386  $this->moduleInfos[$name]['skinStyles'][$skinName] = $styleFiles;
387  }
388  }
389  }
390 
391  }
392 
395  public function registerTestModules() {
396  global $IP;
397 
398  if ( $this->config->get( 'EnableJavaScriptTest' ) !== true ) {
399  throw new MWException( 'Attempt to register JavaScript test modules '
400  . 'but <code>$wgEnableJavaScriptTest</code> is false. '
401  . 'Edit your <code>LocalSettings.php</code> to enable it.' );
402  }
403 
404  // Get core test suites
405  $testModules = [];
406  $testModules['qunit'] = [];
407  // Get other test suites (e.g. from extensions)
408  // Avoid PHP 7.1 warning from passing $this by reference
409  $rl = $this;
410  Hooks::run( 'ResourceLoaderTestModules', [ &$testModules, &$rl ] );
411 
412  // Add the testrunner (which configures QUnit) to the dependencies.
413  // Since it must be ready before any of the test suites are executed.
414  foreach ( $testModules['qunit'] as &$module ) {
415  // Make sure all test modules are top-loading so that when QUnit starts
416  // on document-ready, it will run once and finish. If some tests arrive
417  // later (possibly after QUnit has already finished) they will be ignored.
418  $module['position'] = 'top';
419  $module['dependencies'][] = 'test.mediawiki.qunit.testrunner';
420  }
421 
422  $testModules['qunit'] =
423  ( include "$IP/tests/qunit/QUnitTestResources.php" ) + $testModules['qunit'];
424 
425  foreach ( $testModules as $id => $names ) {
426  // Register test modules
427  $this->register( $testModules[$id] );
428 
429  // Keep track of their names so that they can be loaded together
430  $this->testModuleNames[$id] = array_keys( $testModules[$id] );
431  }
432 
433  }
434 
445  public function addSource( $id, $loadUrl = null ) {
446  // Allow multiple sources to be registered in one call
447  if ( is_array( $id ) ) {
448  foreach ( $id as $key => $value ) {
449  $this->addSource( $key, $value );
450  }
451  return;
452  }
453 
454  // Disallow duplicates
455  if ( isset( $this->sources[$id] ) ) {
456  throw new MWException(
457  'ResourceLoader duplicate source addition error. ' .
458  'Another source has already been registered as ' . $id
459  );
460  }
461 
462  // Pre 1.24 backwards-compatibility
463  if ( is_array( $loadUrl ) ) {
464  if ( !isset( $loadUrl['loadScript'] ) ) {
465  throw new MWException(
466  __METHOD__ . ' was passed an array with no "loadScript" key.'
467  );
468  }
469 
470  $loadUrl = $loadUrl['loadScript'];
471  }
472 
473  $this->sources[$id] = $loadUrl;
474  }
475 
481  public function getModuleNames() {
482  return array_keys( $this->moduleInfos );
483  }
484 
495  public function getTestModuleNames( $framework = 'all' ) {
497  if ( $framework == 'all' ) {
498  return $this->testModuleNames;
499  } elseif ( isset( $this->testModuleNames[$framework] )
500  && is_array( $this->testModuleNames[$framework] )
501  ) {
502  return $this->testModuleNames[$framework];
503  } else {
504  return [];
505  }
506  }
507 
515  public function isModuleRegistered( $name ) {
516  return isset( $this->moduleInfos[$name] );
517  }
518 
530  public function getModule( $name ) {
531  if ( !isset( $this->modules[$name] ) ) {
532  if ( !isset( $this->moduleInfos[$name] ) ) {
533  // No such module
534  return null;
535  }
536  // Construct the requested object
537  $info = $this->moduleInfos[$name];
539  if ( isset( $info['object'] ) ) {
540  // Object given in info array
541  $object = $info['object'];
542  } else {
543  if ( !isset( $info['class'] ) ) {
544  $class = 'ResourceLoaderFileModule';
545  } else {
546  $class = $info['class'];
547  }
549  $object = new $class( $info );
550  $object->setConfig( $this->getConfig() );
551  $object->setLogger( $this->logger );
552  }
553  $object->setName( $name );
554  $this->modules[$name] = $object;
555  }
556 
557  return $this->modules[$name];
558  }
559 
566  protected function isFileModule( $name ) {
567  if ( !isset( $this->moduleInfos[$name] ) ) {
568  return false;
569  }
570  $info = $this->moduleInfos[$name];
571  if ( isset( $info['object'] ) || isset( $info['class'] ) ) {
572  return false;
573  }
574  return true;
575  }
576 
582  public function getSources() {
583  return $this->sources;
584  }
585 
595  public function getLoadScript( $source ) {
596  if ( !isset( $this->sources[$source] ) ) {
597  throw new MWException( "The $source source was never registered in ResourceLoader." );
598  }
599  return $this->sources[$source];
600  }
601 
607  public static function makeHash( $value ) {
608  // Use base64 to output more entropy in a more compact string (default hex is only base16).
609  // The first 8 chars of a base64 encoded digest represent the same binary as
610  // the first 12 chars of a hex encoded digest.
611  return substr( base64_encode( sha1( $value, true ) ), 0, 8 );
612  }
613 
622  public function getCombinedVersion( ResourceLoaderContext $context, array $modules ) {
623  if ( !$modules ) {
624  return '';
625  }
626  $hashes = array_map( function ( $module ) use ( $context ) {
627  return $this->getModule( $module )->getVersionHash( $context );
628  }, $modules );
629  return self::makeHash( implode( $hashes ) );
630  }
631 
637  public function respond( ResourceLoaderContext $context ) {
638  // Buffer output to catch warnings. Normally we'd use ob_clean() on the
639  // top-level output buffer to clear warnings, but that breaks when ob_gzhandler
640  // is used: ob_clean() will clear the GZIP header in that case and it won't come
641  // back for subsequent output, resulting in invalid GZIP. So we have to wrap
642  // the whole thing in our own output buffer to be sure the active buffer
643  // doesn't use ob_gzhandler.
644  // See http://bugs.php.net/bug.php?id=36514
645  ob_start();
646 
647  // Find out which modules are missing and instantiate the others
648  $modules = [];
649  $missing = [];
650  foreach ( $context->getModules() as $name ) {
651  $module = $this->getModule( $name );
652  if ( $module ) {
653  // Do not allow private modules to be loaded from the web.
654  // This is a security issue, see bug 34907.
655  if ( $module->getGroup() === 'private' ) {
656  $this->logger->debug( "Request for private module '$name' denied" );
657  $this->errors[] = "Cannot show private module \"$name\"";
658  continue;
659  }
660  $modules[$name] = $module;
661  } else {
662  $missing[] = $name;
663  }
664  }
665 
666  try {
667  // Preload for getCombinedVersion() and for batch makeModuleResponse()
668  $this->preloadModuleInfo( array_keys( $modules ), $context );
669  } catch ( Exception $e ) {
671  $this->logger->warning( 'Preloading module info failed: {exception}', [
672  'exception' => $e
673  ] );
674  $this->errors[] = self::formatExceptionNoComment( $e );
675  }
676 
677  // Combine versions to propagate cache invalidation
678  $versionHash = '';
679  try {
680  $versionHash = $this->getCombinedVersion( $context, array_keys( $modules ) );
681  } catch ( Exception $e ) {
683  $this->logger->warning( 'Calculating version hash failed: {exception}', [
684  'exception' => $e
685  ] );
686  $this->errors[] = self::formatExceptionNoComment( $e );
687  }
688 
689  // See RFC 2616 § 3.11 Entity Tags
690  // http://www.w3.org/Protocols/rfc2616/rfc2616-sec3.html#sec3.11
691  $etag = 'W/"' . $versionHash . '"';
692 
693  // Try the client-side cache first
694  if ( $this->tryRespondNotModified( $context, $etag ) ) {
695  return; // output handled (buffers cleared)
696  }
697 
698  // Use file cache if enabled and available...
699  if ( $this->config->get( 'UseFileCache' ) ) {
700  $fileCache = ResourceFileCache::newFromContext( $context );
701  if ( $this->tryRespondFromFileCache( $fileCache, $context, $etag ) ) {
702  return; // output handled
703  }
704  }
705 
706  // Generate a response
707  $response = $this->makeModuleResponse( $context, $modules, $missing );
708 
709  // Capture any PHP warnings from the output buffer and append them to the
710  // error list if we're in debug mode.
711  if ( $context->getDebug() ) {
712  $warnings = ob_get_contents();
713  if ( strlen( $warnings ) ) {
714  $this->errors[] = $warnings;
715  }
716  }
717 
718  // Save response to file cache unless there are errors
719  if ( isset( $fileCache ) && !$this->errors && !count( $missing ) ) {
720  // Cache single modules and images...and other requests if there are enough hits
721  if ( ResourceFileCache::useFileCache( $context ) ) {
722  if ( $fileCache->isCacheWorthy() ) {
723  $fileCache->saveText( $response );
724  } else {
725  $fileCache->incrMissesRecent( $context->getRequest() );
726  }
727  }
728  }
729 
730  $this->sendResponseHeaders( $context, $etag, (bool)$this->errors );
731 
732  // Remove the output buffer and output the response
733  ob_end_clean();
734 
735  if ( $context->getImageObj() && $this->errors ) {
736  // We can't show both the error messages and the response when it's an image.
737  $response = implode( "\n\n", $this->errors );
738  } elseif ( $this->errors ) {
739  $errorText = implode( "\n\n", $this->errors );
740  $errorResponse = self::makeComment( $errorText );
741  if ( $context->shouldIncludeScripts() ) {
742  $errorResponse .= 'if (window.console && console.error) {'
743  . Xml::encodeJsCall( 'console.error', [ $errorText ] )
744  . "}\n";
745  }
746 
747  // Prepend error info to the response
748  $response = $errorResponse . $response;
749  }
750 
751  $this->errors = [];
752  echo $response;
753 
754  }
755 
766  protected function sendResponseHeaders( ResourceLoaderContext $context, $etag, $errors ) {
767  $rlMaxage = $this->config->get( 'ResourceLoaderMaxage' );
768  // If a version wasn't specified we need a shorter expiry time for updates
769  // to propagate to clients quickly
770  // If there were errors, we also need a shorter expiry time so we can recover quickly
771  if ( is_null( $context->getVersion() ) || $errors ) {
772  $maxage = $rlMaxage['unversioned']['client'];
773  $smaxage = $rlMaxage['unversioned']['server'];
774  // If a version was specified we can use a longer expiry time since changing
775  // version numbers causes cache misses
776  } else {
777  $maxage = $rlMaxage['versioned']['client'];
778  $smaxage = $rlMaxage['versioned']['server'];
779  }
780  if ( $context->getImageObj() ) {
781  // Output different headers if we're outputting textual errors.
782  if ( $errors ) {
783  header( 'Content-Type: text/plain; charset=utf-8' );
784  } else {
785  $context->getImageObj()->sendResponseHeaders( $context );
786  }
787  } elseif ( $context->getOnly() === 'styles' ) {
788  header( 'Content-Type: text/css; charset=utf-8' );
789  header( 'Access-Control-Allow-Origin: *' );
790  } else {
791  header( 'Content-Type: text/javascript; charset=utf-8' );
792  }
793  // See RFC 2616 § 14.19 ETag
794  // http://www.w3.org/Protocols/rfc2616/rfc2616-sec14.html#sec14.19
795  header( 'ETag: ' . $etag );
796  if ( $context->getDebug() ) {
797  // Do not cache debug responses
798  header( 'Cache-Control: private, no-cache, must-revalidate' );
799  header( 'Pragma: no-cache' );
800  } else {
801  header( "Cache-Control: public, max-age=$maxage, s-maxage=$smaxage" );
802  $exp = min( $maxage, $smaxage );
803  header( 'Expires: ' . wfTimestamp( TS_RFC2822, $exp + time() ) );
804  }
805  }
806 
817  protected function tryRespondNotModified( ResourceLoaderContext $context, $etag ) {
818  // See RFC 2616 § 14.26 If-None-Match
819  // http://www.w3.org/Protocols/rfc2616/rfc2616-sec14.html#sec14.26
820  $clientKeys = $context->getRequest()->getHeader( 'If-None-Match', WebRequest::GETHEADER_LIST );
821  // Never send 304s in debug mode
822  if ( $clientKeys !== false && !$context->getDebug() && in_array( $etag, $clientKeys ) ) {
823  // There's another bug in ob_gzhandler (see also the comment at
824  // the top of this function) that causes it to gzip even empty
825  // responses, meaning it's impossible to produce a truly empty
826  // response (because the gzip header is always there). This is
827  // a problem because 304 responses have to be completely empty
828  // per the HTTP spec, and Firefox behaves buggily when they're not.
829  // See also http://bugs.php.net/bug.php?id=51579
830  // To work around this, we tear down all output buffering before
831  // sending the 304.
832  wfResetOutputBuffers( /* $resetGzipEncoding = */ true );
833 
834  HttpStatus::header( 304 );
835 
836  $this->sendResponseHeaders( $context, $etag, false );
837  return true;
838  }
839  return false;
840  }
841 
850  protected function tryRespondFromFileCache(
851  ResourceFileCache $fileCache,
852  ResourceLoaderContext $context,
853  $etag
854  ) {
855  $rlMaxage = $this->config->get( 'ResourceLoaderMaxage' );
856  // Buffer output to catch warnings.
857  ob_start();
858  // Get the maximum age the cache can be
859  $maxage = is_null( $context->getVersion() )
860  ? $rlMaxage['unversioned']['server']
861  : $rlMaxage['versioned']['server'];
862  // Minimum timestamp the cache file must have
863  $good = $fileCache->isCacheGood( wfTimestamp( TS_MW, time() - $maxage ) );
864  if ( !$good ) {
865  try { // RL always hits the DB on file cache miss...
866  wfGetDB( DB_SLAVE );
867  } catch ( DBConnectionError $e ) { // ...check if we need to fallback to cache
868  $good = $fileCache->isCacheGood(); // cache existence check
869  }
870  }
871  if ( $good ) {
872  $ts = $fileCache->cacheTimestamp();
873  // Send content type and cache headers
874  $this->sendResponseHeaders( $context, $etag, false );
875  $response = $fileCache->fetchText();
876  // Capture any PHP warnings from the output buffer and append them to the
877  // response in a comment if we're in debug mode.
878  if ( $context->getDebug() ) {
879  $warnings = ob_get_contents();
880  if ( strlen( $warnings ) ) {
881  $response = self::makeComment( $warnings ) . $response;
882  }
883  }
884  // Remove the output buffer and output the response
885  ob_end_clean();
886  echo $response . "\n/* Cached {$ts} */";
887  return true; // cache hit
888  }
889  // Clear buffer
890  ob_end_clean();
891 
892  return false; // cache miss
893  }
894 
903  public static function makeComment( $text ) {
904  $encText = str_replace( '*/', '* /', $text );
905  return "/*\n$encText\n*/\n";
906  }
907 
914  public static function formatException( $e ) {
915  return self::makeComment( self::formatExceptionNoComment( $e ) );
916  }
917 
925  protected static function formatExceptionNoComment( $e ) {
926  global $wgShowExceptionDetails;
927 
928  if ( !$wgShowExceptionDetails ) {
930  }
931 
933  }
934 
943  public function makeModuleResponse( ResourceLoaderContext $context,
944  array $modules, array $missing = []
945  ) {
946  $out = '';
947  $states = [];
948 
949  if ( !count( $modules ) && !count( $missing ) ) {
950  return <<<MESSAGE
951 /* This file is the Web entry point for MediaWiki's ResourceLoader:
952  <https://www.mediawiki.org/wiki/ResourceLoader>. In this request,
953  no modules were requested. Max made me put this here. */
954 MESSAGE;
955  }
956 
957  $image = $context->getImageObj();
958  if ( $image ) {
959  $data = $image->getImageData( $context );
960  if ( $data === false ) {
961  $data = '';
962  $this->errors[] = 'Image generation failed';
963  }
964  return $data;
965  }
966 
967  foreach ( $missing as $name ) {
968  $states[$name] = 'missing';
969  }
970 
971  // Generate output
972  $isRaw = false;
973 
974  $filter = $context->getOnly() === 'styles' ? 'minify-css' : 'minify-js';
975 
976  foreach ( $modules as $name => $module ) {
977  try {
978  $content = $module->getModuleContent( $context );
979  $strContent = '';
980 
981  // Append output
982  switch ( $context->getOnly() ) {
983  case 'scripts':
984  $scripts = $content['scripts'];
985  if ( is_string( $scripts ) ) {
986  // Load scripts raw...
987  $strContent = $scripts;
988  } elseif ( is_array( $scripts ) ) {
989  // ...except when $scripts is an array of URLs
990  $strContent = self::makeLoaderImplementScript( $name, $scripts, [], [], [] );
991  }
992  break;
993  case 'styles':
994  $styles = $content['styles'];
995  // We no longer seperate into media, they are all combined now with
996  // custom media type groups into @media .. {} sections as part of the css string.
997  // Module returns either an empty array or a numerical array with css strings.
998  $strContent = isset( $styles['css'] ) ? implode( '', $styles['css'] ) : '';
999  break;
1000  default:
1001  $strContent = self::makeLoaderImplementScript(
1002  $name,
1003  isset( $content['scripts'] ) ? $content['scripts'] : '',
1004  isset( $content['styles'] ) ? $content['styles'] : [],
1005  isset( $content['messagesBlob'] ) ? new XmlJsCode( $content['messagesBlob'] ) : [],
1006  isset( $content['templates'] ) ? $content['templates'] : []
1007  );
1008  break;
1009  }
1010 
1011  if ( !$context->getDebug() ) {
1012  $strContent = self::filter( $filter, $strContent );
1013  }
1014 
1015  $out .= $strContent;
1016 
1017  } catch ( Exception $e ) {
1019  $this->logger->warning( 'Generating module package failed: {exception}', [
1020  'exception' => $e
1021  ] );
1022  $this->errors[] = self::formatExceptionNoComment( $e );
1023 
1024  // Respond to client with error-state instead of module implementation
1025  $states[$name] = 'error';
1026  unset( $modules[$name] );
1027  }
1028  $isRaw |= $module->isRaw();
1029  }
1030 
1031  // Update module states
1032  if ( $context->shouldIncludeScripts() && !$context->getRaw() && !$isRaw ) {
1033  if ( count( $modules ) && $context->getOnly() === 'scripts' ) {
1034  // Set the state of modules loaded as only scripts to ready as
1035  // they don't have an mw.loader.implement wrapper that sets the state
1036  foreach ( $modules as $name => $module ) {
1037  $states[$name] = 'ready';
1038  }
1039  }
1040 
1041  // Set the state of modules we didn't respond to with mw.loader.implement
1042  if ( count( $states ) ) {
1043  $stateScript = self::makeLoaderStateScript( $states );
1044  if ( !$context->getDebug() ) {
1045  $stateScript = self::filter( 'minify-js', $stateScript );
1046  }
1047  $out .= $stateScript;
1048  }
1049  } else {
1050  if ( count( $states ) ) {
1051  $this->errors[] = 'Problematic modules: ' .
1053  }
1054  }
1055 
1056  return $out;
1057  }
1058 
1065  public function getModulesByMessage( $messageKey ) {
1066  $moduleNames = [];
1067  foreach ( $this->getModuleNames() as $moduleName ) {
1068  $module = $this->getModule( $moduleName );
1069  if ( in_array( $messageKey, $module->getMessages() ) ) {
1070  $moduleNames[] = $moduleName;
1071  }
1072  }
1073  return $moduleNames;
1074  }
1075 
1076  /* Static Methods */
1077 
1093  public static function makeLoaderImplementScript(
1094  $name, $scripts, $styles, $messages, $templates
1095  ) {
1096  if ( is_string( $scripts ) ) {
1097  // Site and user module are a legacy scripts that run in the global scope (no closure).
1098  // Transportation as string instructs mw.loader.implement to use globalEval.
1099  if ( $name === 'site' || $name === 'user' ) {
1100  // Minify manually because the general makeModuleResponse() minification won't be
1101  // effective here due to the script being a string instead of a function. (T107377)
1102  if ( !ResourceLoader::inDebugMode() ) {
1103  $scripts = self::filter( 'minify-js', $scripts );
1104  }
1105  } else {
1106  $scripts = new XmlJsCode( "function ( $, jQuery, require, module ) {\n{$scripts}\n}" );
1107  }
1108  } elseif ( !is_array( $scripts ) ) {
1109  throw new MWException( 'Invalid scripts error. Array of URLs or string of code expected.' );
1110  }
1111  // mw.loader.implement requires 'styles', 'messages' and 'templates' to be objects (not
1112  // arrays). json_encode considers empty arrays to be numerical and outputs "[]" instead
1113  // of "{}". Force them to objects.
1114  $module = [
1115  $name,
1116  $scripts,
1117  (object)$styles,
1118  (object)$messages,
1119  (object)$templates,
1120  ];
1121  self::trimArray( $module );
1122 
1123  return Xml::encodeJsCall( 'mw.loader.implement', $module, ResourceLoader::inDebugMode() );
1124  }
1125 
1133  public static function makeMessageSetScript( $messages ) {
1134  return Xml::encodeJsCall(
1135  'mw.messages.set',
1136  [ (object)$messages ],
1138  );
1139  }
1140 
1148  public static function makeCombinedStyles( array $stylePairs ) {
1149  $out = [];
1150  foreach ( $stylePairs as $media => $styles ) {
1151  // ResourceLoaderFileModule::getStyle can return the styles
1152  // as a string or an array of strings. This is to allow separation in
1153  // the front-end.
1154  $styles = (array)$styles;
1155  foreach ( $styles as $style ) {
1156  $style = trim( $style );
1157  // Don't output an empty "@media print { }" block (bug 40498)
1158  if ( $style !== '' ) {
1159  // Transform the media type based on request params and config
1160  // The way that this relies on $wgRequest to propagate request params is slightly evil
1161  $media = OutputPage::transformCssMedia( $media );
1162 
1163  if ( $media === '' || $media == 'all' ) {
1164  $out[] = $style;
1165  } elseif ( is_string( $media ) ) {
1166  $out[] = "@media $media {\n" . str_replace( "\n", "\n\t", "\t" . $style ) . "}";
1167  }
1168  // else: skip
1169  }
1170  }
1171  }
1172  return $out;
1173  }
1174 
1189  public static function makeLoaderStateScript( $name, $state = null ) {
1190  if ( is_array( $name ) ) {
1191  return Xml::encodeJsCall(
1192  'mw.loader.state',
1193  [ $name ],
1195  );
1196  } else {
1197  return Xml::encodeJsCall(
1198  'mw.loader.state',
1199  [ $name, $state ],
1201  );
1202  }
1203  }
1204 
1219  public static function makeCustomLoaderScript( $name, $version, $dependencies,
1220  $group, $source, $script
1221  ) {
1222  $script = str_replace( "\n", "\n\t", trim( $script ) );
1223  return Xml::encodeJsCall(
1224  "( function ( name, version, dependencies, group, source ) {\n\t$script\n} )",
1225  [ $name, $version, $dependencies, $group, $source ],
1227  );
1228  }
1229 
1230  private static function isEmptyObject( stdClass $obj ) {
1231  foreach ( $obj as $key => $value ) {
1232  return false;
1233  }
1234  return true;
1235  }
1236 
1249  private static function trimArray( array &$array ) {
1250  $i = count( $array );
1251  while ( $i-- ) {
1252  if ( $array[$i] === null
1253  || $array[$i] === []
1254  || ( $array[$i] instanceof XmlJsCode && $array[$i]->value === '{}' )
1255  || ( $array[$i] instanceof stdClass && self::isEmptyObject( $array[$i] ) )
1256  ) {
1257  unset( $array[$i] );
1258  } else {
1259  break;
1260  }
1261  }
1262  }
1263 
1291  public static function makeLoaderRegisterScript( $name, $version = null,
1292  $dependencies = null, $group = null, $source = null, $skip = null
1293  ) {
1294  if ( is_array( $name ) ) {
1295  // Build module name index
1296  $index = [];
1297  foreach ( $name as $i => &$module ) {
1298  $index[$module[0]] = $i;
1299  }
1300 
1301  // Transform dependency names into indexes when possible, they will be resolved by
1302  // mw.loader.register on the other end
1303  foreach ( $name as &$module ) {
1304  if ( isset( $module[2] ) ) {
1305  foreach ( $module[2] as &$dependency ) {
1306  if ( isset( $index[$dependency] ) ) {
1307  $dependency = $index[$dependency];
1308  }
1309  }
1310  }
1311  }
1312 
1313  array_walk( $name, [ 'self', 'trimArray' ] );
1314 
1315  return Xml::encodeJsCall(
1316  'mw.loader.register',
1317  [ $name ],
1319  );
1320  } else {
1321  $registration = [ $name, $version, $dependencies, $group, $source, $skip ];
1322  self::trimArray( $registration );
1323  return Xml::encodeJsCall(
1324  'mw.loader.register',
1325  $registration,
1327  );
1328  }
1329  }
1330 
1345  public static function makeLoaderSourcesScript( $id, $properties = null ) {
1346  if ( is_array( $id ) ) {
1347  return Xml::encodeJsCall(
1348  'mw.loader.addSource',
1349  [ $id ],
1351  );
1352  } else {
1353  return Xml::encodeJsCall(
1354  'mw.loader.addSource',
1355  [ $id, $properties ],
1357  );
1358  }
1359  }
1360 
1369  public static function makeLoaderConditionalScript( $script ) {
1370  return '(window.RLQ=window.RLQ||[]).push(function(){' .
1371  trim( $script ) . '});';
1372  }
1373 
1383  public static function makeInlineScript( $script ) {
1384  $js = self::makeLoaderConditionalScript( $script );
1385  return new WrappedString(
1386  Html::inlineScript( $js ),
1387  '<script>(window.RLQ=window.RLQ||[]).push(function(){',
1388  '});</script>'
1389  );
1390  }
1391 
1399  public static function makeConfigSetScript( array $configuration ) {
1400  return Xml::encodeJsCall(
1401  'mw.config.set',
1402  [ $configuration ],
1404  );
1405  }
1406 
1415  public static function makePackedModulesString( $modules ) {
1416  $groups = []; // array( prefix => array( suffixes ) )
1417  foreach ( $modules as $module ) {
1418  $pos = strrpos( $module, '.' );
1419  $prefix = $pos === false ? '' : substr( $module, 0, $pos );
1420  $suffix = $pos === false ? $module : substr( $module, $pos + 1 );
1421  $groups[$prefix][] = $suffix;
1422  }
1423 
1424  $arr = [];
1425  foreach ( $groups as $prefix => $suffixes ) {
1426  $p = $prefix === '' ? '' : $prefix . '.';
1427  $arr[] = $p . implode( ',', $suffixes );
1428  }
1429  $str = implode( '|', $arr );
1430  return $str;
1431  }
1432 
1438  public static function inDebugMode() {
1439  if ( self::$debugMode === null ) {
1441  self::$debugMode = $wgRequest->getFuzzyBool( 'debug',
1442  $wgRequest->getCookie( 'resourceLoaderDebug', '', $wgResourceLoaderDebug )
1443  );
1444  }
1445  return self::$debugMode;
1446  }
1447 
1455  public static function clearCache() {
1456  self::$debugMode = null;
1457  }
1458 
1468  public function createLoaderURL( $source, ResourceLoaderContext $context,
1469  $extraQuery = []
1470  ) {
1471  $query = self::createLoaderQuery( $context, $extraQuery );
1472  $script = $this->getLoadScript( $source );
1473 
1474  return wfAppendQuery( $script, $query );
1475  }
1476 
1492  public static function makeLoaderURL( $modules, $lang, $skin, $user = null,
1493  $version = null, $debug = false, $only = null, $printable = false,
1494  $handheld = false, $extraQuery = []
1495  ) {
1497 
1498  $query = self::makeLoaderQuery( $modules, $lang, $skin, $user, $version, $debug,
1499  $only, $printable, $handheld, $extraQuery
1500  );
1501 
1502  return wfAppendQuery( $wgLoadScript, $query );
1503  }
1504 
1514  public static function createLoaderQuery( ResourceLoaderContext $context, $extraQuery = [] ) {
1515  return self::makeLoaderQuery(
1516  $context->getModules(),
1517  $context->getLanguage(),
1518  $context->getSkin(),
1519  $context->getUser(),
1520  $context->getVersion(),
1521  $context->getDebug(),
1522  $context->getOnly(),
1523  $context->getRequest()->getBool( 'printable' ),
1524  $context->getRequest()->getBool( 'handheld' ),
1525  $extraQuery
1526  );
1527  }
1528 
1546  public static function makeLoaderQuery( $modules, $lang, $skin, $user = null,
1547  $version = null, $debug = false, $only = null, $printable = false,
1548  $handheld = false, $extraQuery = []
1549  ) {
1550  $query = [
1551  'modules' => self::makePackedModulesString( $modules ),
1552  'lang' => $lang,
1553  'skin' => $skin,
1554  'debug' => $debug ? 'true' : 'false',
1555  ];
1556  if ( $user !== null ) {
1557  $query['user'] = $user;
1558  }
1559  if ( $version !== null ) {
1560  $query['version'] = $version;
1561  }
1562  if ( $only !== null ) {
1563  $query['only'] = $only;
1564  }
1565  if ( $printable ) {
1566  $query['printable'] = 1;
1567  }
1568  if ( $handheld ) {
1569  $query['handheld'] = 1;
1570  }
1571  $query += $extraQuery;
1572 
1573  // Make queries uniform in order
1574  ksort( $query );
1575  return $query;
1576  }
1577 
1587  public static function isValidModuleName( $moduleName ) {
1588  return strcspn( $moduleName, '!,|', 0, 255 ) === strlen( $moduleName );
1589  }
1590 
1600  public function getLessCompiler( $extraVars = [] ) {
1601  // When called from the installer, it is possible that a required PHP extension
1602  // is missing (at least for now; see bug 47564). If this is the case, throw an
1603  // exception (caught by the installer) to prevent a fatal error later on.
1604  if ( !class_exists( 'Less_Parser' ) ) {
1605  throw new MWException( 'MediaWiki requires the less.php parser' );
1606  }
1607 
1608  $parser = new Less_Parser;
1609  $parser->ModifyVars( array_merge( $this->getLessVars(), $extraVars ) );
1610  $parser->SetImportDirs(
1611  array_fill_keys( $this->config->get( 'ResourceLoaderLESSImportPaths' ), '' )
1612  );
1613  $parser->SetOption( 'relativeUrls', false );
1614  $parser->SetCacheDir( $this->config->get( 'CacheDirectory' ) ?: wfTempDir() );
1615 
1616  return $parser;
1617  }
1618 
1625  public function getLessVars() {
1626  if ( !$this->lessVars ) {
1627  $lessVars = $this->config->get( 'ResourceLoaderLESSVars' );
1628  Hooks::run( 'ResourceLoaderGetLessVars', [ &$lessVars ] );
1629  $this->lessVars = $lessVars;
1630  }
1631  return $this->lessVars;
1632  }
1633 }
const TS_RFC2822
RFC 2822 format, for E-mail and HTTP headers.
#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 "