MediaWiki  master
ResourceLoaderClientHtml.php
Go to the documentation of this file.
1 <?php
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  return WrappedString::join( "\n", $chunks );
336  }
337 
341  public function getBodyHtml() {
342  $data = $this->getData();
343  $chunks = [];
344 
345  // Deprecations for only=styles modules
346  if ( $data['styleDeprecations'] ) {
348  implode( '', $data['styleDeprecations'] ),
349  $this->options['nonce']
350  );
351  }
352 
353  return WrappedString::join( "\n", $chunks );
354  }
355 
356  private function getContext( $group, $type ) {
357  return self::makeContext( $this->context, $group, $type );
358  }
359 
360  private function getLoad( $modules, $only, $nonce, array $extraQuery = [] ) {
361  return self::makeLoad( $this->context, (array)$modules, $only, $extraQuery, $nonce );
362  }
363 
364  private static function makeContext( ResourceLoaderContext $mainContext, $group, $type,
365  array $extraQuery = []
366  ) {
367  // Create new ResourceLoaderContext so that $extraQuery is supported (eg. for 'sync=1').
368  $req = new FauxRequest( array_merge( $mainContext->getRequest()->getValues(), $extraQuery ) );
369  // Set 'only' if not combined
370  $req->setVal( 'only', $type === ResourceLoaderModule::TYPE_COMBINED ? null : $type );
371  // Remove user parameter in most cases
372  if ( $group !== 'user' && $group !== 'private' ) {
373  $req->setVal( 'user', null );
374  }
375  $context = new ResourceLoaderContext( $mainContext->getResourceLoader(), $req );
376  // Allow caller to setVersion() and setModules()
378  $ret->setContentOverrideCallback( $mainContext->getContentOverrideCallback() );
379  return $ret;
380  }
381 
393  public static function makeLoad( ResourceLoaderContext $mainContext, array $modules, $only,
394  array $extraQuery = [], $nonce = null
395  ) {
396  $rl = $mainContext->getResourceLoader();
397  $chunks = [];
398 
399  // Sort module names so requests are more uniform
400  sort( $modules );
401 
402  if ( $mainContext->getDebug() && count( $modules ) > 1 ) {
403  $chunks = [];
404  // Recursively call us for every item
405  foreach ( $modules as $name ) {
406  $chunks[] = self::makeLoad( $mainContext, [ $name ], $only, $extraQuery, $nonce );
407  }
408  return new WrappedStringList( "\n", $chunks );
409  }
410 
411  // Create keyed-by-source and then keyed-by-group list of module objects from modules list
412  $sortedModules = [];
413  foreach ( $modules as $name ) {
414  $module = $rl->getModule( $name );
415  if ( !$module ) {
416  $rl->getLogger()->warning( 'Unknown module "{module}"', [ 'module' => $name ] );
417  continue;
418  }
419  $sortedModules[$module->getSource()][$module->getGroup()][$name] = $module;
420  }
421 
422  foreach ( $sortedModules as $source => $groups ) {
423  foreach ( $groups as $group => $grpModules ) {
424  $context = self::makeContext( $mainContext, $group, $only, $extraQuery );
425 
426  // Separate sets of linked and embedded modules while preserving order
427  $moduleSets = [];
428  $idx = -1;
429  foreach ( $grpModules as $name => $module ) {
430  $shouldEmbed = $module->shouldEmbedModule( $context );
431  if ( !$moduleSets || $moduleSets[$idx][0] !== $shouldEmbed ) {
432  $moduleSets[++$idx] = [ $shouldEmbed, [] ];
433  }
434  $moduleSets[$idx][1][$name] = $module;
435  }
436 
437  // Link/embed each set
438  foreach ( $moduleSets as list( $embed, $moduleSet ) ) {
439  $moduleSetNames = array_keys( $moduleSet );
440  $context->setModules( $moduleSetNames );
441  if ( $embed ) {
442  // Decide whether to use style or script element
443  if ( $only == ResourceLoaderModule::TYPE_STYLES ) {
444  $chunks[] = Html::inlineStyle(
445  $rl->makeModuleResponse( $context, $moduleSet )
446  );
447  } else {
449  $rl->makeModuleResponse( $context, $moduleSet ),
450  $nonce
451  );
452  }
453  } else {
454  // Special handling for the user group; because users might change their stuff
455  // on-wiki like user pages, or user preferences; we need to find the highest
456  // timestamp of these user-changeable modules so we can ensure cache misses on change
457  // This should NOT be done for the site group (T29564) because anons get that too
458  // and we shouldn't be putting timestamps in CDN-cached HTML
459  if ( $group === 'user' ) {
460  $context->setVersion( $rl->makeVersionQuery( $context, $moduleSetNames ) );
461  }
462 
463  // Must setModules() before createLoaderURL()
464  $url = $rl->createLoaderURL( $source, $context, $extraQuery );
465 
466  // Decide whether to use 'style' or 'script' element
467  if ( $only === ResourceLoaderModule::TYPE_STYLES ) {
468  $chunk = Html::linkedStyle( $url );
469  } elseif ( $context->getRaw() ) {
470  // This request is asking for the module to be delivered standalone,
471  // (aka "raw") without communicating to any mw.loader client.
472  // Use cases:
473  // - startup (naturally because this is what will define mw.loader)
474  // - html5shiv (loads synchronously in old IE before the async startup module arrives)
475  // - QUnit (needed in SpecialJavaScriptTest before async startup)
476  $chunk = Html::element( 'script', [
477  // The 'sync' option is only supported in combination with 'raw'.
478  'async' => !isset( $extraQuery['sync'] ),
479  'src' => $url
480  ] );
481  } else {
483  'mw.loader.load(' . $mainContext->encodeJson( $url ) . ');',
484  $nonce
485  );
486  }
487 
488  if ( $group == 'noscript' ) {
489  $chunks[] = Html::rawElement( 'noscript', [], $chunk );
490  } else {
491  $chunks[] = $chunk;
492  }
493  }
494  }
495  }
496  }
497 
498  return new WrappedStringList( "\n", $chunks );
499  }
500 }
setConfig(array $vars)
Set mw.config variables.
setModuleStyles(array $modules)
Ensure the styles of one or more modules are loaded.
static linkedStyle( $url, $media='all')
Output a "<link rel=stylesheet>" linking to the given URL for the given media type (if any)...
Definition: Html.php:648
static element( $element, $attribs=[], $contents='')
Identical to rawElement(), but HTML-escapes $contents (like Xml::element()).
Definition: Html.php:231
static filter( $filter, $data, array $options=[])
Run JavaScript or CSS data through a filter, caching the filtered result for future calls...
getLoad( $modules, $only, $nonce, array $extraQuery=[])
static rawElement( $element, $attribs=[], $contents='')
Returns an HTML element in a string.
Definition: Html.php:209
getHeadHtml( $nojsClass=null)
The order of elements in the head is as follows:
$source
static inlineScript( $contents, $nonce=null)
Output an HTML script tag with the given contents.
Definition: Html.php:572
__construct(ResourceLoaderContext $context, array $options=[])
A mutable version of ResourceLoaderContext.
setExemptStates(array $states)
Set state of special modules that are handled by the caller manually.
static makeContext(ResourceLoaderContext $mainContext, $group, $type, array $extraQuery=[])
Load and configure a ResourceLoader client on an HTML page.
encodeJson( $data)
Wrapper around json_encode that avoids needless escapes, and pretty-prints in debug mode...
getContentOverrideCallback()
Return the replaced-content mapping callback.
static makeInlineScript( $script, $nonce=null)
Returns an HTML script tag that runs given JS code after startup and base modules.
static makeLoad(ResourceLoaderContext $mainContext, array $modules, $only, array $extraQuery=[], $nonce=null)
Explicily load or embed modules on a page.
static inlineStyle( $contents, $media='all', $attribs=[])
Output a "<style>" tag with the given contents for the given media type (if any). ...
Definition: Html.php:619
setModules(array $modules)
Ensure one or more modules are loaded.
Context object that contains information about the state of a specific ResourceLoader web request...