MediaWiki  master
ResourceLoaderClientHtml.php
Go to the documentation of this file.
1 <?php
21 use Wikimedia\WrappedString;
22 use Wikimedia\WrappedStringList;
23 
31 
33  private $context;
34 
36  private $resourceLoader;
37 
39  private $options;
40 
42  private $config = [];
43 
45  private $modules = [];
46 
48  private $moduleStyles = [];
49 
51  private $exemptStates = [];
52 
54  private $data;
55 
63  public function __construct( ResourceLoaderContext $context, array $options = [] ) {
64  $this->context = $context;
65  $this->resourceLoader = $context->getResourceLoader();
66  $this->options = $options + [
67  'target' => null,
68  'safemode' => null,
69  'nonce' => null,
70  ];
71  }
72 
78  public function setConfig( array $vars ) {
79  foreach ( $vars as $key => $value ) {
80  $this->config[$key] = $value;
81  }
82  }
83 
89  public function setModules( array $modules ) {
90  $this->modules = $modules;
91  }
92 
98  public function setModuleStyles( array $modules ) {
99  $this->moduleStyles = $modules;
100  }
101 
109  public function setExemptStates( array $states ) {
110  $this->exemptStates = $states;
111  }
112 
116  private function getData() {
117  if ( $this->data ) {
118  // @codeCoverageIgnoreStart
119  return $this->data;
120  // @codeCoverageIgnoreEnd
121  }
122 
123  $rl = $this->resourceLoader;
124  $data = [
125  'states' => [
126  // moduleName => state
127  ],
128  'general' => [],
129  'styles' => [],
130  // Embedding for private modules
131  'embed' => [
132  'styles' => [],
133  'general' => [],
134  ],
135  // Deprecations for style-only modules
136  'styleDeprecations' => [],
137  ];
138 
139  foreach ( $this->modules as $name ) {
140  $module = $rl->getModule( $name );
141  if ( !$module ) {
142  continue;
143  }
144 
145  $group = $module->getGroup();
146  $context = $this->getContext( $group, ResourceLoaderModule::TYPE_COMBINED );
147  $shouldEmbed = $module->shouldEmbedModule( $this->context );
148 
149  if ( ( $group === 'user' || $shouldEmbed ) && $module->isKnownEmpty( $context ) ) {
150  // This is a user-specific or embedded module, which means its output
151  // can be specific to the current page or user. As such, we can optimise
152  // the way we load it based on the current version of the module.
153  // Avoid needless embed for empty module, preset ready state.
154  $data['states'][$name] = 'ready';
155  } elseif ( $group === 'user' || $shouldEmbed ) {
156  // - For group=user: We need to provide a pre-generated load.php
157  // url to the client that has the 'user' and 'version' parameters
158  // filled in. Without this, the client would wrongly use the static
159  // version hash, per T64602.
160  // - For shouldEmbed=true: Embed via mw.loader.implement, per T36907.
161  $data['embed']['general'][] = $name;
162  // Avoid duplicate request from mw.loader
163  $data['states'][$name] = 'loading';
164  } else {
165  // Load via mw.loader.load()
166  $data['general'][] = $name;
167  }
168  }
169 
170  foreach ( $this->moduleStyles as $name ) {
171  $module = $rl->getModule( $name );
172  if ( !$module ) {
173  continue;
174  }
175 
176  if ( $module->getType() !== ResourceLoaderModule::LOAD_STYLES ) {
177  $logger = $rl->getLogger();
178  $logger->error( 'Unexpected general module "{module}" in styles queue.', [
179  'module' => $name,
180  ] );
181  continue;
182  }
183 
184  // Stylesheet doesn't trigger mw.loader callback.
185  // Set "ready" state to allow script modules to depend on this module (T87871).
186  // And to avoid duplicate requests at run-time from mw.loader.
187  $data['states'][$name] = 'ready';
188 
189  $group = $module->getGroup();
190  $context = $this->getContext( $group, ResourceLoaderModule::TYPE_STYLES );
191  if ( $module->shouldEmbedModule( $this->context ) ) {
192  // Avoid needless embed for private embeds we know are empty.
193  // (Set "ready" state directly instead, which we do a few lines above.)
194  if ( !$module->isKnownEmpty( $context ) ) {
195  // Embed via <style> element
196  $data['embed']['styles'][] = $name;
197  }
198  // For other style modules, always request them, regardless of whether they are
199  // currently known to be empty. Because:
200  // 1. Those modules are requested in batch, so there is no extra request overhead
201  // or extra HTML element to be avoided.
202  // 2. Checking isKnownEmpty for those can be expensive and slow down page view
203  // generation (T230260).
204  // 3. We don't want cached HTML to vary on the current state of a module.
205  // If the module becomes non-empty a few minutes later, it should start working
206  // on cached HTML without requiring a purge.
207  //
208  // But, user-specific modules:
209  // * ... are used on page views not publicly cached.
210  // * ... are in their own group and thus a require a request we can avoid
211  // * ... have known-empty status preloaded by ResourceLoader.
212  } elseif ( $group !== 'user' || !$module->isKnownEmpty( $context ) ) {
213  // Load from load.php?only=styles via <link rel=stylesheet>
214  $data['styles'][] = $name;
215  }
216  $deprecation = $module->getDeprecationInformation( $context );
217  if ( $deprecation ) {
218  $data['styleDeprecations'][] = $deprecation;
219  }
220  }
221 
222  return $data;
223  }
224 
228  public function getDocumentAttributes() {
229  return [ 'class' => 'client-nojs' ];
230  }
231 
247  public function getHeadHtml( $nojsClass = null ) {
248  $nonce = $this->options['nonce'];
249  $data = $this->getData();
250  $chunks = [];
251 
252  // Change "client-nojs" class to client-js. This allows easy toggling of UI components.
253  // This must happen synchronously on every page view to avoid flashes of wrong content.
254  // See also startup/startup.js.
255  $nojsClass = $nojsClass ?? $this->getDocumentAttributes()['class'];
256  $jsClass = preg_replace( '/(^|\s)client-nojs(\s|$)/', '$1client-js$2', $nojsClass );
257  $jsClassJson = $this->context->encodeJson( $jsClass );
258  $script = <<<JAVASCRIPT
259 document.documentElement.className = {$jsClassJson};
260 JAVASCRIPT;
261 
262  // Inline script: Declare mw.config variables for this page.
263  if ( $this->config ) {
264  $confJson = $this->context->encodeJson( $this->config );
265  $script .= <<<JAVASCRIPT
266 RLCONF = {$confJson};
267 JAVASCRIPT;
268  }
269 
270  // Inline script: Declare initial module states for this page.
271  $states = array_merge( $this->exemptStates, $data['states'] );
272  if ( $states ) {
273  $stateJson = $this->context->encodeJson( $states );
274  $script .= <<<JAVASCRIPT
275 RLSTATE = {$stateJson};
276 JAVASCRIPT;
277  }
278 
279  // Inline script: Declare general modules to load on this page.
280  if ( $data['general'] ) {
281  $pageModulesJson = $this->context->encodeJson( $data['general'] );
282  $script .= <<<JAVASCRIPT
283 RLPAGEMODULES = {$pageModulesJson};
284 JAVASCRIPT;
285  }
286 
287  if ( !$this->context->getDebug() ) {
288  $script = ResourceLoader::filter( 'minify-js', $script, [ 'cache' => false ] );
289  }
290 
291  $chunks[] = Html::inlineScript( $script, $nonce );
292 
293  // Inline RLQ: Embedded modules
294  if ( $data['embed']['general'] ) {
295  $chunks[] = $this->getLoad(
296  $data['embed']['general'],
297  ResourceLoaderModule::TYPE_COMBINED,
298  $nonce
299  );
300  }
301 
302  // External stylesheets (only=styles)
303  if ( $data['styles'] ) {
304  $chunks[] = $this->getLoad(
305  $data['styles'],
306  ResourceLoaderModule::TYPE_STYLES,
307  $nonce
308  );
309  }
310 
311  // Inline stylesheets (embedded only=styles)
312  if ( $data['embed']['styles'] ) {
313  $chunks[] = $this->getLoad(
314  $data['embed']['styles'],
315  ResourceLoaderModule::TYPE_STYLES,
316  $nonce
317  );
318  }
319 
320  // Async scripts. Once the startup is loaded, inline RLQ scripts will run.
321  // Pass-through a custom 'target' from OutputPage (T143066).
322  $startupQuery = [ 'raw' => '1' ];
323  foreach ( [ 'target', 'safemode' ] as $param ) {
324  if ( $this->options[$param] !== null ) {
325  $startupQuery[$param] = (string)$this->options[$param];
326  }
327  }
328  $chunks[] = $this->getLoad(
329  'startup',
330  ResourceLoaderModule::TYPE_SCRIPTS,
331  $nonce,
332  $startupQuery
333  );
334 
335  // @phan-suppress-next-line PhanTypeMismatchArgument
336  return WrappedString::join( "\n", $chunks );
337  }
338 
342  public function getBodyHtml() {
343  $data = $this->getData();
344  $chunks = [];
345 
346  // Deprecations for only=styles modules
347  if ( $data['styleDeprecations'] ) {
348  $chunks[] = ResourceLoader::makeInlineScript(
349  implode( '', $data['styleDeprecations'] ),
350  $this->options['nonce']
351  );
352  }
353 
354  return WrappedString::join( "\n", $chunks );
355  }
356 
357  private function getContext( $group, $type ) {
358  return self::makeContext( $this->context, $group, $type );
359  }
360 
361  private function getLoad( $modules, $only, $nonce, array $extraQuery = [] ) {
362  return self::makeLoad( $this->context, (array)$modules, $only, $extraQuery, $nonce );
363  }
364 
365  private static function makeContext( ResourceLoaderContext $mainContext, $group, $type,
366  array $extraQuery = []
367  ) {
368  // Create new ResourceLoaderContext so that $extraQuery is supported (eg. for 'sync=1').
369  $req = new FauxRequest( array_merge( $mainContext->getRequest()->getValues(), $extraQuery ) );
370  // Set 'only' if not combined
371  $req->setVal( 'only', $type === ResourceLoaderModule::TYPE_COMBINED ? null : $type );
372  // Remove user parameter in most cases
373  if ( $group !== 'user' && $group !== 'private' ) {
374  $req->setVal( 'user', null );
375  }
376  $context = new ResourceLoaderContext( $mainContext->getResourceLoader(), $req );
377  // Allow caller to setVersion() and setModules()
379  $ret->setContentOverrideCallback( $mainContext->getContentOverrideCallback() );
380  return $ret;
381  }
382 
394  public static function makeLoad( ResourceLoaderContext $mainContext, array $modules, $only,
395  array $extraQuery = [], $nonce = null
396  ) {
397  $rl = $mainContext->getResourceLoader();
398  $chunks = [];
399 
400  // Sort module names so requests are more uniform
401  sort( $modules );
402 
403  if ( $mainContext->getDebug() && count( $modules ) > 1 ) {
404  $chunks = [];
405  // Recursively call us for every item
406  foreach ( $modules as $name ) {
407  $chunks[] = self::makeLoad( $mainContext, [ $name ], $only, $extraQuery, $nonce );
408  }
409  return new WrappedStringList( "\n", $chunks );
410  }
411 
412  // Create keyed-by-source and then keyed-by-group list of module objects from modules list
413  $sortedModules = [];
414  foreach ( $modules as $name ) {
415  $module = $rl->getModule( $name );
416  if ( !$module ) {
417  $rl->getLogger()->warning( 'Unknown module "{module}"', [ 'module' => $name ] );
418  continue;
419  }
420  $sortedModules[$module->getSource()][$module->getGroup()][$name] = $module;
421  }
422 
423  foreach ( $sortedModules as $source => $groups ) {
424  foreach ( $groups as $group => $grpModules ) {
425  $context = self::makeContext( $mainContext, $group, $only, $extraQuery );
426 
427  // Separate sets of linked and embedded modules while preserving order
428  $moduleSets = [];
429  $idx = -1;
430  foreach ( $grpModules as $name => $module ) {
431  $shouldEmbed = $module->shouldEmbedModule( $context );
432  if ( !$moduleSets || $moduleSets[$idx][0] !== $shouldEmbed ) {
433  $moduleSets[++$idx] = [ $shouldEmbed, [] ];
434  }
435  $moduleSets[$idx][1][$name] = $module;
436  }
437 
438  // Link/embed each set
439  foreach ( $moduleSets as list( $embed, $moduleSet ) ) {
440  $moduleSetNames = array_keys( $moduleSet );
441  $context->setModules( $moduleSetNames );
442  if ( $embed ) {
443  // Decide whether to use style or script element
444  if ( $only == ResourceLoaderModule::TYPE_STYLES ) {
445  $chunks[] = Html::inlineStyle(
446  $rl->makeModuleResponse( $context, $moduleSet )
447  );
448  } else {
449  $chunks[] = ResourceLoader::makeInlineScript(
450  $rl->makeModuleResponse( $context, $moduleSet ),
451  $nonce
452  );
453  }
454  } else {
455  // Special handling for the user group; because users might change their stuff
456  // on-wiki like user pages, or user preferences; we need to find the highest
457  // timestamp of these user-changeable modules so we can ensure cache misses on change
458  // This should NOT be done for the site group (T29564) because anons get that too
459  // and we shouldn't be putting timestamps in CDN-cached HTML
460  if ( $group === 'user' ) {
461  $context->setVersion( $rl->makeVersionQuery( $context, $moduleSetNames ) );
462  }
463 
464  // Must setModules() before createLoaderURL()
465  $url = $rl->createLoaderURL( $source, $context, $extraQuery );
466 
467  // Decide whether to use 'style' or 'script' element
468  if ( $only === ResourceLoaderModule::TYPE_STYLES ) {
469  $chunk = Html::linkedStyle( $url );
470  } elseif ( $context->getRaw() ) {
471  // This request is asking for the module to be delivered standalone,
472  // (aka "raw") without communicating to any mw.loader client.
473  // Use cases:
474  // - startup (naturally because this is what will define mw.loader)
475  // - html5shiv (loads synchronously in old IE before the async startup module arrives)
476  // - QUnit (needed in SpecialJavaScriptTest before async startup)
477  $chunk = Html::element( 'script', [
478  // The 'sync' option is only supported in combination with 'raw'.
479  'async' => !isset( $extraQuery['sync'] ),
480  'src' => $url
481  ] );
482  } else {
483  $chunk = ResourceLoader::makeInlineScript(
484  'mw.loader.load(' . $mainContext->encodeJson( $url ) . ');',
485  $nonce
486  );
487  }
488 
489  if ( $group == 'noscript' ) {
490  $chunks[] = Html::rawElement( 'noscript', [], $chunk );
491  } else {
492  $chunks[] = $chunk;
493  }
494  }
495  }
496  }
497  }
498 
499  return new WrappedStringList( "\n", $chunks );
500  }
501 }
ResourceLoaderContext
Context object that contains information about the state of a specific ResourceLoader web request.
Definition: ResourceLoaderContext.php:33
FauxRequest
WebRequest clone which takes values from a provided array.
Definition: FauxRequest.php:33
ResourceLoaderClientHtml
Load and configure a ResourceLoader client on an HTML page.
Definition: ResourceLoaderClientHtml.php:30
ResourceLoaderClientHtml\makeContext
static makeContext(ResourceLoaderContext $mainContext, $group, $type, array $extraQuery=[])
Definition: ResourceLoaderClientHtml.php:365
ResourceLoaderClientHtml\getContext
getContext( $group, $type)
Definition: ResourceLoaderClientHtml.php:357
ResourceLoaderContext\getResourceLoader
getResourceLoader()
Definition: ResourceLoaderContext.php:121
ResourceLoaderClientHtml\getData
getData()
Definition: ResourceLoaderClientHtml.php:116
ResourceLoaderClientHtml\getDocumentAttributes
getDocumentAttributes()
Definition: ResourceLoaderClientHtml.php:228
ResourceLoaderContext\getContentOverrideCallback
getContentOverrideCallback()
Return the replaced-content mapping callback.
Definition: ResourceLoaderContext.php:326
ResourceLoaderClientHtml\setExemptStates
setExemptStates(array $states)
Set state of special modules that are handled by the caller manually.
Definition: ResourceLoaderClientHtml.php:109
ResourceLoaderClientHtml\$options
array $options
Definition: ResourceLoaderClientHtml.php:39
ResourceLoaderClientHtml\$context
ResourceLoaderContext $context
Definition: ResourceLoaderClientHtml.php:33
ResourceLoaderClientHtml\$resourceLoader
ResourceLoader $resourceLoader
Definition: ResourceLoaderClientHtml.php:36
ResourceLoaderClientHtml\setConfig
setConfig(array $vars)
Set mw.config variables.
Definition: ResourceLoaderClientHtml.php:78
ResourceLoaderClientHtml\$modules
array $modules
Definition: ResourceLoaderClientHtml.php:45
ResourceLoaderClientHtml\$config
array $config
Definition: ResourceLoaderClientHtml.php:42
ResourceLoaderContext\getRequest
getRequest()
Definition: ResourceLoaderContext.php:136
ResourceLoaderClientHtml\getHeadHtml
getHeadHtml( $nojsClass=null)
The order of elements in the head is as follows:
Definition: ResourceLoaderClientHtml.php:247
ResourceLoaderContext\getDebug
getDebug()
Definition: ResourceLoaderContext.php:234
ResourceLoaderClientHtml\$data
array $data
Definition: ResourceLoaderClientHtml.php:54
ResourceLoaderClientHtml\$exemptStates
array $exemptStates
Definition: ResourceLoaderClientHtml.php:51
ResourceLoaderClientHtml\$moduleStyles
array $moduleStyles
Definition: ResourceLoaderClientHtml.php:48
ResourceLoaderClientHtml\getBodyHtml
getBodyHtml()
Definition: ResourceLoaderClientHtml.php:342
DerivativeResourceLoaderContext
A mutable version of ResourceLoaderContext.
Definition: DerivativeResourceLoaderContext.php:33
ResourceLoaderClientHtml\makeLoad
static makeLoad(ResourceLoaderContext $mainContext, array $modules, $only, array $extraQuery=[], $nonce=null)
Explicily load or embed modules on a page.
Definition: ResourceLoaderClientHtml.php:394
ResourceLoaderClientHtml\setModules
setModules(array $modules)
Ensure one or more modules are loaded.
Definition: ResourceLoaderClientHtml.php:89
ResourceLoaderClientHtml\setModuleStyles
setModuleStyles(array $modules)
Ensure the styles of one or more modules are loaded.
Definition: ResourceLoaderClientHtml.php:98
$source
$source
Definition: mwdoc-filter.php:34
ResourceLoaderClientHtml\getLoad
getLoad( $modules, $only, $nonce, array $extraQuery=[])
Definition: ResourceLoaderClientHtml.php:361
ResourceLoaderClientHtml\__construct
__construct(ResourceLoaderContext $context, array $options=[])
Definition: ResourceLoaderClientHtml.php:63
ResourceLoaderContext\getRaw
getRaw()
Definition: ResourceLoaderContext.php:254
ResourceLoaderContext\encodeJson
encodeJson( $data)
Wrapper around json_encode that avoids needless escapes, and pretty-prints in debug mode.
Definition: ResourceLoaderContext.php:401
$type
$type
Definition: testCompression.php:50