MediaWiki  1.27.2
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  Hooks::run( 'ResourceLoaderRegisterModules', [ &$this ] );
255 
256  if ( $config->get( 'EnableJavaScriptTest' ) === true ) {
257  $this->registerTestModules();
258  }
259 
260  $this->setMessageBlobStore( new MessageBlobStore( $this, $this->logger ) );
261  }
262 
266  public function getConfig() {
267  return $this->config;
268  }
269 
274  public function setLogger( LoggerInterface $logger ) {
275  $this->logger = $logger;
276  }
277 
282  public function getLogger() {
283  return $this->logger;
284  }
285 
290  public function getMessageBlobStore() {
291  return $this->blobStore;
292  }
293 
299  $this->blobStore = $blobStore;
300  }
301 
315  public function register( $name, $info = null ) {
316 
317  // Allow multiple modules to be registered in one call
318  $registrations = is_array( $name ) ? $name : [ $name => $info ];
319  foreach ( $registrations as $name => $info ) {
320  // Warn on duplicate registrations
321  if ( isset( $this->moduleInfos[$name] ) ) {
322  // A module has already been registered by this name
323  $this->logger->warning(
324  'ResourceLoader duplicate registration warning. ' .
325  'Another module has already been registered as ' . $name
326  );
327  }
328 
329  // Check $name for validity
330  if ( !self::isValidModuleName( $name ) ) {
331  throw new MWException( "ResourceLoader module name '$name' is invalid, "
332  . "see ResourceLoader::isValidModuleName()" );
333  }
334 
335  // Attach module
336  if ( $info instanceof ResourceLoaderModule ) {
337  $this->moduleInfos[$name] = [ 'object' => $info ];
338  $info->setName( $name );
339  $this->modules[$name] = $info;
340  } elseif ( is_array( $info ) ) {
341  // New calling convention
342  $this->moduleInfos[$name] = $info;
343  } else {
344  throw new MWException(
345  'ResourceLoader module info type error for module \'' . $name .
346  '\': expected ResourceLoaderModule or array (got: ' . gettype( $info ) . ')'
347  );
348  }
349 
350  // Last-minute changes
351 
352  // Apply custom skin-defined styles to existing modules.
353  if ( $this->isFileModule( $name ) ) {
354  foreach ( $this->config->get( 'ResourceModuleSkinStyles' ) as $skinName => $skinStyles ) {
355  // If this module already defines skinStyles for this skin, ignore $wgResourceModuleSkinStyles.
356  if ( isset( $this->moduleInfos[$name]['skinStyles'][$skinName] ) ) {
357  continue;
358  }
359 
360  // If $name is preceded with a '+', the defined style files will be added to 'default'
361  // skinStyles, otherwise 'default' will be ignored as it normally would be.
362  if ( isset( $skinStyles[$name] ) ) {
363  $paths = (array)$skinStyles[$name];
364  $styleFiles = [];
365  } elseif ( isset( $skinStyles['+' . $name] ) ) {
366  $paths = (array)$skinStyles['+' . $name];
367  $styleFiles = isset( $this->moduleInfos[$name]['skinStyles']['default'] ) ?
368  (array)$this->moduleInfos[$name]['skinStyles']['default'] :
369  [];
370  } else {
371  continue;
372  }
373 
374  // Add new file paths, remapping them to refer to our directories and not use settings
375  // from the module we're modifying, which come from the base definition.
376  list( $localBasePath, $remoteBasePath ) =
378 
379  foreach ( $paths as $path ) {
380  $styleFiles[] = new ResourceLoaderFilePath( $path, $localBasePath, $remoteBasePath );
381  }
382 
383  $this->moduleInfos[$name]['skinStyles'][$skinName] = $styleFiles;
384  }
385  }
386  }
387 
388  }
389 
392  public function registerTestModules() {
393  global $IP;
394 
395  if ( $this->config->get( 'EnableJavaScriptTest' ) !== true ) {
396  throw new MWException( 'Attempt to register JavaScript test modules '
397  . 'but <code>$wgEnableJavaScriptTest</code> is false. '
398  . 'Edit your <code>LocalSettings.php</code> to enable it.' );
399  }
400 
401  // Get core test suites
402  $testModules = [];
403  $testModules['qunit'] = [];
404  // Get other test suites (e.g. from extensions)
405  Hooks::run( 'ResourceLoaderTestModules', [ &$testModules, &$this ] );
406 
407  // Add the testrunner (which configures QUnit) to the dependencies.
408  // Since it must be ready before any of the test suites are executed.
409  foreach ( $testModules['qunit'] as &$module ) {
410  // Make sure all test modules are top-loading so that when QUnit starts
411  // on document-ready, it will run once and finish. If some tests arrive
412  // later (possibly after QUnit has already finished) they will be ignored.
413  $module['position'] = 'top';
414  $module['dependencies'][] = 'test.mediawiki.qunit.testrunner';
415  }
416 
417  $testModules['qunit'] =
418  ( include "$IP/tests/qunit/QUnitTestResources.php" ) + $testModules['qunit'];
419 
420  foreach ( $testModules as $id => $names ) {
421  // Register test modules
422  $this->register( $testModules[$id] );
423 
424  // Keep track of their names so that they can be loaded together
425  $this->testModuleNames[$id] = array_keys( $testModules[$id] );
426  }
427 
428  }
429 
440  public function addSource( $id, $loadUrl = null ) {
441  // Allow multiple sources to be registered in one call
442  if ( is_array( $id ) ) {
443  foreach ( $id as $key => $value ) {
444  $this->addSource( $key, $value );
445  }
446  return;
447  }
448 
449  // Disallow duplicates
450  if ( isset( $this->sources[$id] ) ) {
451  throw new MWException(
452  'ResourceLoader duplicate source addition error. ' .
453  'Another source has already been registered as ' . $id
454  );
455  }
456 
457  // Pre 1.24 backwards-compatibility
458  if ( is_array( $loadUrl ) ) {
459  if ( !isset( $loadUrl['loadScript'] ) ) {
460  throw new MWException(
461  __METHOD__ . ' was passed an array with no "loadScript" key.'
462  );
463  }
464 
465  $loadUrl = $loadUrl['loadScript'];
466  }
467 
468  $this->sources[$id] = $loadUrl;
469  }
470 
476  public function getModuleNames() {
477  return array_keys( $this->moduleInfos );
478  }
479 
490  public function getTestModuleNames( $framework = 'all' ) {
492  if ( $framework == 'all' ) {
493  return $this->testModuleNames;
494  } elseif ( isset( $this->testModuleNames[$framework] )
495  && is_array( $this->testModuleNames[$framework] )
496  ) {
497  return $this->testModuleNames[$framework];
498  } else {
499  return [];
500  }
501  }
502 
510  public function isModuleRegistered( $name ) {
511  return isset( $this->moduleInfos[$name] );
512  }
513 
525  public function getModule( $name ) {
526  if ( !isset( $this->modules[$name] ) ) {
527  if ( !isset( $this->moduleInfos[$name] ) ) {
528  // No such module
529  return null;
530  }
531  // Construct the requested object
532  $info = $this->moduleInfos[$name];
534  if ( isset( $info['object'] ) ) {
535  // Object given in info array
536  $object = $info['object'];
537  } else {
538  if ( !isset( $info['class'] ) ) {
539  $class = 'ResourceLoaderFileModule';
540  } else {
541  $class = $info['class'];
542  }
544  $object = new $class( $info );
545  $object->setConfig( $this->getConfig() );
546  $object->setLogger( $this->logger );
547  }
548  $object->setName( $name );
549  $this->modules[$name] = $object;
550  }
551 
552  return $this->modules[$name];
553  }
554 
561  protected function isFileModule( $name ) {
562  if ( !isset( $this->moduleInfos[$name] ) ) {
563  return false;
564  }
565  $info = $this->moduleInfos[$name];
566  if ( isset( $info['object'] ) || isset( $info['class'] ) ) {
567  return false;
568  }
569  return true;
570  }
571 
577  public function getSources() {
578  return $this->sources;
579  }
580 
590  public function getLoadScript( $source ) {
591  if ( !isset( $this->sources[$source] ) ) {
592  throw new MWException( "The $source source was never registered in ResourceLoader." );
593  }
594  return $this->sources[$source];
595  }
596 
602  public static function makeHash( $value ) {
603  // Use base64 to output more entropy in a more compact string (default hex is only base16).
604  // The first 8 chars of a base64 encoded digest represent the same binary as
605  // the first 12 chars of a hex encoded digest.
606  return substr( base64_encode( sha1( $value, true ) ), 0, 8 );
607  }
608 
617  public function getCombinedVersion( ResourceLoaderContext $context, array $modules ) {
618  if ( !$modules ) {
619  return '';
620  }
621  $hashes = array_map( function ( $module ) use ( $context ) {
622  return $this->getModule( $module )->getVersionHash( $context );
623  }, $modules );
624  return self::makeHash( implode( $hashes ) );
625  }
626 
632  public function respond( ResourceLoaderContext $context ) {
633  // Buffer output to catch warnings. Normally we'd use ob_clean() on the
634  // top-level output buffer to clear warnings, but that breaks when ob_gzhandler
635  // is used: ob_clean() will clear the GZIP header in that case and it won't come
636  // back for subsequent output, resulting in invalid GZIP. So we have to wrap
637  // the whole thing in our own output buffer to be sure the active buffer
638  // doesn't use ob_gzhandler.
639  // See http://bugs.php.net/bug.php?id=36514
640  ob_start();
641 
642  // Find out which modules are missing and instantiate the others
643  $modules = [];
644  $missing = [];
645  foreach ( $context->getModules() as $name ) {
646  $module = $this->getModule( $name );
647  if ( $module ) {
648  // Do not allow private modules to be loaded from the web.
649  // This is a security issue, see bug 34907.
650  if ( $module->getGroup() === 'private' ) {
651  $this->logger->debug( "Request for private module '$name' denied" );
652  $this->errors[] = "Cannot show private module \"$name\"";
653  continue;
654  }
655  $modules[$name] = $module;
656  } else {
657  $missing[] = $name;
658  }
659  }
660 
661  try {
662  // Preload for getCombinedVersion() and for batch makeModuleResponse()
663  $this->preloadModuleInfo( array_keys( $modules ), $context );
664  } catch ( Exception $e ) {
666  $this->logger->warning( 'Preloading module info failed: {exception}', [
667  'exception' => $e
668  ] );
669  $this->errors[] = self::formatExceptionNoComment( $e );
670  }
671 
672  // Combine versions to propagate cache invalidation
673  $versionHash = '';
674  try {
675  $versionHash = $this->getCombinedVersion( $context, array_keys( $modules ) );
676  } catch ( Exception $e ) {
678  $this->logger->warning( 'Calculating version hash failed: {exception}', [
679  'exception' => $e
680  ] );
681  $this->errors[] = self::formatExceptionNoComment( $e );
682  }
683 
684  // See RFC 2616 § 3.11 Entity Tags
685  // http://www.w3.org/Protocols/rfc2616/rfc2616-sec3.html#sec3.11
686  $etag = 'W/"' . $versionHash . '"';
687 
688  // Try the client-side cache first
689  if ( $this->tryRespondNotModified( $context, $etag ) ) {
690  return; // output handled (buffers cleared)
691  }
692 
693  // Use file cache if enabled and available...
694  if ( $this->config->get( 'UseFileCache' ) ) {
695  $fileCache = ResourceFileCache::newFromContext( $context );
696  if ( $this->tryRespondFromFileCache( $fileCache, $context, $etag ) ) {
697  return; // output handled
698  }
699  }
700 
701  // Generate a response
702  $response = $this->makeModuleResponse( $context, $modules, $missing );
703 
704  // Capture any PHP warnings from the output buffer and append them to the
705  // error list if we're in debug mode.
706  if ( $context->getDebug() ) {
707  $warnings = ob_get_contents();
708  if ( strlen( $warnings ) ) {
709  $this->errors[] = $warnings;
710  }
711  }
712 
713  // Save response to file cache unless there are errors
714  if ( isset( $fileCache ) && !$this->errors && !count( $missing ) ) {
715  // Cache single modules and images...and other requests if there are enough hits
716  if ( ResourceFileCache::useFileCache( $context ) ) {
717  if ( $fileCache->isCacheWorthy() ) {
718  $fileCache->saveText( $response );
719  } else {
720  $fileCache->incrMissesRecent( $context->getRequest() );
721  }
722  }
723  }
724 
725  $this->sendResponseHeaders( $context, $etag, (bool)$this->errors );
726 
727  // Remove the output buffer and output the response
728  ob_end_clean();
729 
730  if ( $context->getImageObj() && $this->errors ) {
731  // We can't show both the error messages and the response when it's an image.
732  $response = implode( "\n\n", $this->errors );
733  } elseif ( $this->errors ) {
734  $errorText = implode( "\n\n", $this->errors );
735  $errorResponse = self::makeComment( $errorText );
736  if ( $context->shouldIncludeScripts() ) {
737  $errorResponse .= 'if (window.console && console.error) {'
738  . Xml::encodeJsCall( 'console.error', [ $errorText ] )
739  . "}\n";
740  }
741 
742  // Prepend error info to the response
743  $response = $errorResponse . $response;
744  }
745 
746  $this->errors = [];
747  echo $response;
748 
749  }
750 
761  protected function sendResponseHeaders( ResourceLoaderContext $context, $etag, $errors ) {
762  $rlMaxage = $this->config->get( 'ResourceLoaderMaxage' );
763  // If a version wasn't specified we need a shorter expiry time for updates
764  // to propagate to clients quickly
765  // If there were errors, we also need a shorter expiry time so we can recover quickly
766  if ( is_null( $context->getVersion() ) || $errors ) {
767  $maxage = $rlMaxage['unversioned']['client'];
768  $smaxage = $rlMaxage['unversioned']['server'];
769  // If a version was specified we can use a longer expiry time since changing
770  // version numbers causes cache misses
771  } else {
772  $maxage = $rlMaxage['versioned']['client'];
773  $smaxage = $rlMaxage['versioned']['server'];
774  }
775  if ( $context->getImageObj() ) {
776  // Output different headers if we're outputting textual errors.
777  if ( $errors ) {
778  header( 'Content-Type: text/plain; charset=utf-8' );
779  } else {
780  $context->getImageObj()->sendResponseHeaders( $context );
781  }
782  } elseif ( $context->getOnly() === 'styles' ) {
783  header( 'Content-Type: text/css; charset=utf-8' );
784  header( 'Access-Control-Allow-Origin: *' );
785  } else {
786  header( 'Content-Type: text/javascript; charset=utf-8' );
787  }
788  // See RFC 2616 § 14.19 ETag
789  // http://www.w3.org/Protocols/rfc2616/rfc2616-sec14.html#sec14.19
790  header( 'ETag: ' . $etag );
791  if ( $context->getDebug() ) {
792  // Do not cache debug responses
793  header( 'Cache-Control: private, no-cache, must-revalidate' );
794  header( 'Pragma: no-cache' );
795  } else {
796  header( "Cache-Control: public, max-age=$maxage, s-maxage=$smaxage" );
797  $exp = min( $maxage, $smaxage );
798  header( 'Expires: ' . wfTimestamp( TS_RFC2822, $exp + time() ) );
799  }
800  }
801 
812  protected function tryRespondNotModified( ResourceLoaderContext $context, $etag ) {
813  // See RFC 2616 § 14.26 If-None-Match
814  // http://www.w3.org/Protocols/rfc2616/rfc2616-sec14.html#sec14.26
815  $clientKeys = $context->getRequest()->getHeader( 'If-None-Match', WebRequest::GETHEADER_LIST );
816  // Never send 304s in debug mode
817  if ( $clientKeys !== false && !$context->getDebug() && in_array( $etag, $clientKeys ) ) {
818  // There's another bug in ob_gzhandler (see also the comment at
819  // the top of this function) that causes it to gzip even empty
820  // responses, meaning it's impossible to produce a truly empty
821  // response (because the gzip header is always there). This is
822  // a problem because 304 responses have to be completely empty
823  // per the HTTP spec, and Firefox behaves buggily when they're not.
824  // See also http://bugs.php.net/bug.php?id=51579
825  // To work around this, we tear down all output buffering before
826  // sending the 304.
827  wfResetOutputBuffers( /* $resetGzipEncoding = */ true );
828 
829  HttpStatus::header( 304 );
830 
831  $this->sendResponseHeaders( $context, $etag, false );
832  return true;
833  }
834  return false;
835  }
836 
845  protected function tryRespondFromFileCache(
846  ResourceFileCache $fileCache,
847  ResourceLoaderContext $context,
848  $etag
849  ) {
850  $rlMaxage = $this->config->get( 'ResourceLoaderMaxage' );
851  // Buffer output to catch warnings.
852  ob_start();
853  // Get the maximum age the cache can be
854  $maxage = is_null( $context->getVersion() )
855  ? $rlMaxage['unversioned']['server']
856  : $rlMaxage['versioned']['server'];
857  // Minimum timestamp the cache file must have
858  $good = $fileCache->isCacheGood( wfTimestamp( TS_MW, time() - $maxage ) );
859  if ( !$good ) {
860  try { // RL always hits the DB on file cache miss...
861  wfGetDB( DB_SLAVE );
862  } catch ( DBConnectionError $e ) { // ...check if we need to fallback to cache
863  $good = $fileCache->isCacheGood(); // cache existence check
864  }
865  }
866  if ( $good ) {
867  $ts = $fileCache->cacheTimestamp();
868  // Send content type and cache headers
869  $this->sendResponseHeaders( $context, $etag, false );
870  $response = $fileCache->fetchText();
871  // Capture any PHP warnings from the output buffer and append them to the
872  // response in a comment if we're in debug mode.
873  if ( $context->getDebug() ) {
874  $warnings = ob_get_contents();
875  if ( strlen( $warnings ) ) {
876  $response = self::makeComment( $warnings ) . $response;
877  }
878  }
879  // Remove the output buffer and output the response
880  ob_end_clean();
881  echo $response . "\n/* Cached {$ts} */";
882  return true; // cache hit
883  }
884  // Clear buffer
885  ob_end_clean();
886 
887  return false; // cache miss
888  }
889 
898  public static function makeComment( $text ) {
899  $encText = str_replace( '*/', '* /', $text );
900  return "/*\n$encText\n*/\n";
901  }
902 
909  public static function formatException( $e ) {
910  return self::makeComment( self::formatExceptionNoComment( $e ) );
911  }
912 
920  protected static function formatExceptionNoComment( $e ) {
921  global $wgShowExceptionDetails;
922 
923  if ( !$wgShowExceptionDetails ) {
925  }
926 
928  }
929 
938  public function makeModuleResponse( ResourceLoaderContext $context,
939  array $modules, array $missing = []
940  ) {
941  $out = '';
942  $states = [];
943 
944  if ( !count( $modules ) && !count( $missing ) ) {
945  return <<<MESSAGE
946 /* This file is the Web entry point for MediaWiki's ResourceLoader:
947  <https://www.mediawiki.org/wiki/ResourceLoader>. In this request,
948  no modules were requested. Max made me put this here. */
949 MESSAGE;
950  }
951 
952  $image = $context->getImageObj();
953  if ( $image ) {
954  $data = $image->getImageData( $context );
955  if ( $data === false ) {
956  $data = '';
957  $this->errors[] = 'Image generation failed';
958  }
959  return $data;
960  }
961 
962  foreach ( $missing as $name ) {
963  $states[$name] = 'missing';
964  }
965 
966  // Generate output
967  $isRaw = false;
968 
969  $filter = $context->getOnly() === 'styles' ? 'minify-css' : 'minify-js';
970 
971  foreach ( $modules as $name => $module ) {
972  try {
973  $content = $module->getModuleContent( $context );
974  $strContent = '';
975 
976  // Append output
977  switch ( $context->getOnly() ) {
978  case 'scripts':
979  $scripts = $content['scripts'];
980  if ( is_string( $scripts ) ) {
981  // Load scripts raw...
982  $strContent = $scripts;
983  } elseif ( is_array( $scripts ) ) {
984  // ...except when $scripts is an array of URLs
985  $strContent = self::makeLoaderImplementScript( $name, $scripts, [], [], [] );
986  }
987  break;
988  case 'styles':
989  $styles = $content['styles'];
990  // We no longer seperate into media, they are all combined now with
991  // custom media type groups into @media .. {} sections as part of the css string.
992  // Module returns either an empty array or a numerical array with css strings.
993  $strContent = isset( $styles['css'] ) ? implode( '', $styles['css'] ) : '';
994  break;
995  default:
996  $strContent = self::makeLoaderImplementScript(
997  $name,
998  isset( $content['scripts'] ) ? $content['scripts'] : '',
999  isset( $content['styles'] ) ? $content['styles'] : [],
1000  isset( $content['messagesBlob'] ) ? new XmlJsCode( $content['messagesBlob'] ) : [],
1001  isset( $content['templates'] ) ? $content['templates'] : []
1002  );
1003  break;
1004  }
1005 
1006  if ( !$context->getDebug() ) {
1007  $strContent = self::filter( $filter, $strContent );
1008  }
1009 
1010  $out .= $strContent;
1011 
1012  } catch ( Exception $e ) {
1014  $this->logger->warning( 'Generating module package failed: {exception}', [
1015  'exception' => $e
1016  ] );
1017  $this->errors[] = self::formatExceptionNoComment( $e );
1018 
1019  // Respond to client with error-state instead of module implementation
1020  $states[$name] = 'error';
1021  unset( $modules[$name] );
1022  }
1023  $isRaw |= $module->isRaw();
1024  }
1025 
1026  // Update module states
1027  if ( $context->shouldIncludeScripts() && !$context->getRaw() && !$isRaw ) {
1028  if ( count( $modules ) && $context->getOnly() === 'scripts' ) {
1029  // Set the state of modules loaded as only scripts to ready as
1030  // they don't have an mw.loader.implement wrapper that sets the state
1031  foreach ( $modules as $name => $module ) {
1032  $states[$name] = 'ready';
1033  }
1034  }
1035 
1036  // Set the state of modules we didn't respond to with mw.loader.implement
1037  if ( count( $states ) ) {
1038  $stateScript = self::makeLoaderStateScript( $states );
1039  if ( !$context->getDebug() ) {
1040  $stateScript = self::filter( 'minify-js', $stateScript );
1041  }
1042  $out .= $stateScript;
1043  }
1044  } else {
1045  if ( count( $states ) ) {
1046  $this->errors[] = 'Problematic modules: ' .
1048  }
1049  }
1050 
1051  return $out;
1052  }
1053 
1060  public function getModulesByMessage( $messageKey ) {
1061  $moduleNames = [];
1062  foreach ( $this->getModuleNames() as $moduleName ) {
1063  $module = $this->getModule( $moduleName );
1064  if ( in_array( $messageKey, $module->getMessages() ) ) {
1065  $moduleNames[] = $moduleName;
1066  }
1067  }
1068  return $moduleNames;
1069  }
1070 
1071  /* Static Methods */
1072 
1088  public static function makeLoaderImplementScript(
1089  $name, $scripts, $styles, $messages, $templates
1090  ) {
1091  if ( is_string( $scripts ) ) {
1092  // Site and user module are a legacy scripts that run in the global scope (no closure).
1093  // Transportation as string instructs mw.loader.implement to use globalEval.
1094  if ( $name === 'site' || $name === 'user' ) {
1095  // Minify manually because the general makeModuleResponse() minification won't be
1096  // effective here due to the script being a string instead of a function. (T107377)
1097  if ( !ResourceLoader::inDebugMode() ) {
1098  $scripts = self::filter( 'minify-js', $scripts );
1099  }
1100  } else {
1101  $scripts = new XmlJsCode( "function ( $, jQuery, require, module ) {\n{$scripts}\n}" );
1102  }
1103  } elseif ( !is_array( $scripts ) ) {
1104  throw new MWException( 'Invalid scripts error. Array of URLs or string of code expected.' );
1105  }
1106  // mw.loader.implement requires 'styles', 'messages' and 'templates' to be objects (not
1107  // arrays). json_encode considers empty arrays to be numerical and outputs "[]" instead
1108  // of "{}". Force them to objects.
1109  $module = [
1110  $name,
1111  $scripts,
1112  (object)$styles,
1113  (object)$messages,
1114  (object)$templates,
1115  ];
1116  self::trimArray( $module );
1117 
1118  return Xml::encodeJsCall( 'mw.loader.implement', $module, ResourceLoader::inDebugMode() );
1119  }
1120 
1128  public static function makeMessageSetScript( $messages ) {
1129  return Xml::encodeJsCall(
1130  'mw.messages.set',
1131  [ (object)$messages ],
1133  );
1134  }
1135 
1143  public static function makeCombinedStyles( array $stylePairs ) {
1144  $out = [];
1145  foreach ( $stylePairs as $media => $styles ) {
1146  // ResourceLoaderFileModule::getStyle can return the styles
1147  // as a string or an array of strings. This is to allow separation in
1148  // the front-end.
1149  $styles = (array)$styles;
1150  foreach ( $styles as $style ) {
1151  $style = trim( $style );
1152  // Don't output an empty "@media print { }" block (bug 40498)
1153  if ( $style !== '' ) {
1154  // Transform the media type based on request params and config
1155  // The way that this relies on $wgRequest to propagate request params is slightly evil
1156  $media = OutputPage::transformCssMedia( $media );
1157 
1158  if ( $media === '' || $media == 'all' ) {
1159  $out[] = $style;
1160  } elseif ( is_string( $media ) ) {
1161  $out[] = "@media $media {\n" . str_replace( "\n", "\n\t", "\t" . $style ) . "}";
1162  }
1163  // else: skip
1164  }
1165  }
1166  }
1167  return $out;
1168  }
1169 
1184  public static function makeLoaderStateScript( $name, $state = null ) {
1185  if ( is_array( $name ) ) {
1186  return Xml::encodeJsCall(
1187  'mw.loader.state',
1188  [ $name ],
1190  );
1191  } else {
1192  return Xml::encodeJsCall(
1193  'mw.loader.state',
1194  [ $name, $state ],
1196  );
1197  }
1198  }
1199 
1214  public static function makeCustomLoaderScript( $name, $version, $dependencies,
1215  $group, $source, $script
1216  ) {
1217  $script = str_replace( "\n", "\n\t", trim( $script ) );
1218  return Xml::encodeJsCall(
1219  "( function ( name, version, dependencies, group, source ) {\n\t$script\n} )",
1220  [ $name, $version, $dependencies, $group, $source ],
1222  );
1223  }
1224 
1225  private static function isEmptyObject( stdClass $obj ) {
1226  foreach ( $obj as $key => $value ) {
1227  return false;
1228  }
1229  return true;
1230  }
1231 
1244  private static function trimArray( array &$array ) {
1245  $i = count( $array );
1246  while ( $i-- ) {
1247  if ( $array[$i] === null
1248  || $array[$i] === []
1249  || ( $array[$i] instanceof XmlJsCode && $array[$i]->value === '{}' )
1250  || ( $array[$i] instanceof stdClass && self::isEmptyObject( $array[$i] ) )
1251  ) {
1252  unset( $array[$i] );
1253  } else {
1254  break;
1255  }
1256  }
1257  }
1258 
1286  public static function makeLoaderRegisterScript( $name, $version = null,
1287  $dependencies = null, $group = null, $source = null, $skip = null
1288  ) {
1289  if ( is_array( $name ) ) {
1290  // Build module name index
1291  $index = [];
1292  foreach ( $name as $i => &$module ) {
1293  $index[$module[0]] = $i;
1294  }
1295 
1296  // Transform dependency names into indexes when possible, they will be resolved by
1297  // mw.loader.register on the other end
1298  foreach ( $name as &$module ) {
1299  if ( isset( $module[2] ) ) {
1300  foreach ( $module[2] as &$dependency ) {
1301  if ( isset( $index[$dependency] ) ) {
1302  $dependency = $index[$dependency];
1303  }
1304  }
1305  }
1306  }
1307 
1308  array_walk( $name, [ 'self', 'trimArray' ] );
1309 
1310  return Xml::encodeJsCall(
1311  'mw.loader.register',
1312  [ $name ],
1314  );
1315  } else {
1316  $registration = [ $name, $version, $dependencies, $group, $source, $skip ];
1317  self::trimArray( $registration );
1318  return Xml::encodeJsCall(
1319  'mw.loader.register',
1320  $registration,
1322  );
1323  }
1324  }
1325 
1340  public static function makeLoaderSourcesScript( $id, $properties = null ) {
1341  if ( is_array( $id ) ) {
1342  return Xml::encodeJsCall(
1343  'mw.loader.addSource',
1344  [ $id ],
1346  );
1347  } else {
1348  return Xml::encodeJsCall(
1349  'mw.loader.addSource',
1350  [ $id, $properties ],
1352  );
1353  }
1354  }
1355 
1364  public static function makeLoaderConditionalScript( $script ) {
1365  return '(window.RLQ=window.RLQ||[]).push(function(){' .
1366  trim( $script ) . '});';
1367  }
1368 
1378  public static function makeInlineScript( $script ) {
1379  $js = self::makeLoaderConditionalScript( $script );
1380  return new WrappedString(
1381  Html::inlineScript( $js ),
1382  '<script>(window.RLQ=window.RLQ||[]).push(function(){',
1383  '});</script>'
1384  );
1385  }
1386 
1394  public static function makeConfigSetScript( array $configuration ) {
1395  return Xml::encodeJsCall(
1396  'mw.config.set',
1397  [ $configuration ],
1399  );
1400  }
1401 
1410  public static function makePackedModulesString( $modules ) {
1411  $groups = []; // array( prefix => array( suffixes ) )
1412  foreach ( $modules as $module ) {
1413  $pos = strrpos( $module, '.' );
1414  $prefix = $pos === false ? '' : substr( $module, 0, $pos );
1415  $suffix = $pos === false ? $module : substr( $module, $pos + 1 );
1416  $groups[$prefix][] = $suffix;
1417  }
1418 
1419  $arr = [];
1420  foreach ( $groups as $prefix => $suffixes ) {
1421  $p = $prefix === '' ? '' : $prefix . '.';
1422  $arr[] = $p . implode( ',', $suffixes );
1423  }
1424  $str = implode( '|', $arr );
1425  return $str;
1426  }
1427 
1433  public static function inDebugMode() {
1434  if ( self::$debugMode === null ) {
1436  self::$debugMode = $wgRequest->getFuzzyBool( 'debug',
1437  $wgRequest->getCookie( 'resourceLoaderDebug', '', $wgResourceLoaderDebug )
1438  );
1439  }
1440  return self::$debugMode;
1441  }
1442 
1450  public static function clearCache() {
1451  self::$debugMode = null;
1452  }
1453 
1463  public function createLoaderURL( $source, ResourceLoaderContext $context,
1464  $extraQuery = []
1465  ) {
1466  $query = self::createLoaderQuery( $context, $extraQuery );
1467  $script = $this->getLoadScript( $source );
1468 
1469  return wfAppendQuery( $script, $query );
1470  }
1471 
1487  public static function makeLoaderURL( $modules, $lang, $skin, $user = null,
1488  $version = null, $debug = false, $only = null, $printable = false,
1489  $handheld = false, $extraQuery = []
1490  ) {
1492 
1493  $query = self::makeLoaderQuery( $modules, $lang, $skin, $user, $version, $debug,
1494  $only, $printable, $handheld, $extraQuery
1495  );
1496 
1497  return wfAppendQuery( $wgLoadScript, $query );
1498  }
1499 
1509  public static function createLoaderQuery( ResourceLoaderContext $context, $extraQuery = [] ) {
1510  return self::makeLoaderQuery(
1511  $context->getModules(),
1512  $context->getLanguage(),
1513  $context->getSkin(),
1514  $context->getUser(),
1515  $context->getVersion(),
1516  $context->getDebug(),
1517  $context->getOnly(),
1518  $context->getRequest()->getBool( 'printable' ),
1519  $context->getRequest()->getBool( 'handheld' ),
1520  $extraQuery
1521  );
1522  }
1523 
1541  public static function makeLoaderQuery( $modules, $lang, $skin, $user = null,
1542  $version = null, $debug = false, $only = null, $printable = false,
1543  $handheld = false, $extraQuery = []
1544  ) {
1545  $query = [
1546  'modules' => self::makePackedModulesString( $modules ),
1547  'lang' => $lang,
1548  'skin' => $skin,
1549  'debug' => $debug ? 'true' : 'false',
1550  ];
1551  if ( $user !== null ) {
1552  $query['user'] = $user;
1553  }
1554  if ( $version !== null ) {
1555  $query['version'] = $version;
1556  }
1557  if ( $only !== null ) {
1558  $query['only'] = $only;
1559  }
1560  if ( $printable ) {
1561  $query['printable'] = 1;
1562  }
1563  if ( $handheld ) {
1564  $query['handheld'] = 1;
1565  }
1566  $query += $extraQuery;
1567 
1568  // Make queries uniform in order
1569  ksort( $query );
1570  return $query;
1571  }
1572 
1582  public static function isValidModuleName( $moduleName ) {
1583  return strcspn( $moduleName, '!,|', 0, 255 ) === strlen( $moduleName );
1584  }
1585 
1595  public function getLessCompiler( $extraVars = [] ) {
1596  // When called from the installer, it is possible that a required PHP extension
1597  // is missing (at least for now; see bug 47564). If this is the case, throw an
1598  // exception (caught by the installer) to prevent a fatal error later on.
1599  if ( !class_exists( 'Less_Parser' ) ) {
1600  throw new MWException( 'MediaWiki requires the less.php parser' );
1601  }
1602 
1603  $parser = new Less_Parser;
1604  $parser->ModifyVars( array_merge( $this->getLessVars(), $extraVars ) );
1605  $parser->SetImportDirs(
1606  array_fill_keys( $this->config->get( 'ResourceLoaderLESSImportPaths' ), '' )
1607  );
1608  $parser->SetOption( 'relativeUrls', false );
1609  $parser->SetCacheDir( $this->config->get( 'CacheDirectory' ) ?: wfTempDir() );
1610 
1611  return $parser;
1612  }
1613 
1620  public function getLessVars() {
1621  if ( !$this->lessVars ) {
1622  $lessVars = $this->config->get( 'ResourceLoaderLESSVars' );
1623  Hooks::run( 'ResourceLoaderGetLessVars', [ &$lessVars ] );
1624  $this->lessVars = $lessVars;
1625  }
1626  return $this->lessVars;
1627  }
1628 }
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 "