Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
95.36% covered (success)
95.36%
185 / 194
85.71% covered (warning)
85.71%
12 / 14
CRAP
0.00% covered (danger)
0.00%
0 / 1
ClientHtml
95.36% covered (success)
95.36%
185 / 194
85.71% covered (warning)
85.71%
12 / 14
65
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
1
 setConfig
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
2
 setModules
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 setModuleStyles
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 setExemptStates
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getData
98.11% covered (success)
98.11%
52 / 53
0.00% covered (danger)
0.00%
0 / 1
18
 getDocumentAttributes
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getDocumentClassNameScript
42.86% covered (danger)
42.86%
6 / 14
0.00% covered (danger)
0.00%
0 / 1
2.75
 getHeadHtml
100.00% covered (success)
100.00%
47 / 47
100.00% covered (success)
100.00%
1 / 1
10
 getBodyHtml
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
3
 getContext
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getLoad
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 makeContext
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
5
 makeLoad
100.00% covered (success)
100.00%
49 / 49
100.00% covered (success)
100.00%
1 / 1
18
1<?php
2/**
3 * @license GPL-2.0-or-later
4 * @file
5 */
6
7namespace MediaWiki\ResourceLoader;
8
9use MediaWiki\Html\Html;
10use Wikimedia\WrappedString;
11use Wikimedia\WrappedStringList;
12
13/**
14 * Load and configure a ResourceLoader client on an HTML page.
15 *
16 * @ingroup ResourceLoader
17 * @since 1.28
18 */
19class ClientHtml {
20    /**
21     * Used by extensions to apply anonymous user preferences
22     * across domains during the authentication flow. This name
23     * doesn't include the cookie prefix which can be the ID of
24     * the domain we're on. See $wgCookiePrefix for that.
25     *
26     * @see https://www.mediawiki.org/wiki/Reading/Web/Preference_Persistence_For_Anonymous_Users
27     *
28     * @note Keep in sync with resources/src/mediawiki.user.js
29     */
30    public const CLIENT_PREFS_COOKIE_NAME = 'mwclientpreferences';
31
32    /** @var Context */
33    private $context;
34
35    /** @var ResourceLoader */
36    private $resourceLoader;
37
38    /** @var array<string,string|null|false> */
39    private $options;
40
41    /** @var array<string,mixed> */
42    private $config = [];
43
44    /** @var string[] */
45    private $modules = [];
46
47    /** @var string[] */
48    private $moduleStyles = [];
49
50    /** @var array<string,string> */
51    private $exemptStates = [];
52
53    /** @var array */
54    private $data;
55
56    /**
57     * @param Context $context
58     * @param array $options [optional] Array of options
59     *  - 'target': Parameter for modules=startup request, see StartUpModule.
60     *  - 'safemode': Parameter for modules=startup request, see StartUpModule.
61     *  - 'clientPrefEnabled': See Skin options.
62     *  - 'clientPrefCookiePrefix': See $wgCookiePrefix.
63     */
64    public function __construct( Context $context, array $options = [] ) {
65        $this->context = $context;
66        $this->resourceLoader = $context->getResourceLoader();
67        $this->options = $options + [
68            'target' => null,
69            'safemode' => null,
70            'clientPrefEnabled' => false,
71            'clientPrefCookiePrefix' => '',
72        ];
73    }
74
75    /**
76     * Set mw.config variables.
77     *
78     * @param array $vars Array of key/value pairs
79     */
80    public function setConfig( array $vars ): void {
81        foreach ( $vars as $key => $value ) {
82            $this->config[$key] = $value;
83        }
84    }
85
86    /**
87     * Ensure one or more modules are loaded.
88     *
89     * @param string[] $modules Array of module names
90     */
91    public function setModules( array $modules ): void {
92        $this->modules = $modules;
93    }
94
95    /**
96     * Ensure the styles of one or more modules are loaded.
97     *
98     * @param string[] $modules Array of module names
99     */
100    public function setModuleStyles( array $modules ): void {
101        $this->moduleStyles = $modules;
102    }
103
104    /**
105     * Set state of special modules that are handled by the caller manually.
106     *
107     * See OutputPage::buildExemptModules() for use cases.
108     *
109     * @param array<string,string> $states Module state keyed by module name
110     */
111    public function setExemptStates( array $states ): void {
112        $this->exemptStates = $states;
113    }
114
115    private function getData(): array {
116        if ( $this->data ) {
117            return $this->data;
118        }
119
120        $rl = $this->resourceLoader;
121        $data = [
122            'states' => [
123                // moduleName => state
124            ],
125            'general' => [],
126            'styles' => [],
127            // Embedding for private modules
128            'embed' => [
129                'styles' => [],
130                'general' => [],
131            ],
132            // Deprecation warnings for style-only modules
133            'styleDeprecations' => [],
134        ];
135
136        foreach ( $this->modules as $name ) {
137            $module = $rl->getModule( $name );
138            if ( !$module ) {
139                continue;
140            }
141
142            $group = $module->getGroup();
143            $context = $this->getContext( $group, Module::TYPE_COMBINED );
144            $shouldEmbed = $module->shouldEmbedModule( $this->context );
145
146            if ( ( $group === Module::GROUP_USER || $shouldEmbed ) &&
147                $module->isKnownEmpty( $context ) ) {
148                // This is a user-specific or embedded module, which means its output
149                // can be specific to the current page or user. As such, we can optimise
150                // the way we load it based on the current version of the module.
151                // Avoid needless embed for empty module, preset ready state.
152                $data['states'][$name] = 'ready';
153            } elseif ( $group === Module::GROUP_USER || $shouldEmbed ) {
154                // - For group=user: We need to provide a pre-generated load.php
155                //   url to the client that has the 'user' and 'version' parameters
156                //   filled in. Without this, the client would wrongly use the static
157                //   version hash, per T64602.
158                // - For shouldEmbed=true:  Embed via mw.loader.impl, per T36907.
159                $data['embed']['general'][] = $name;
160                // Avoid duplicate request from mw.loader
161                $data['states'][$name] = 'loading';
162            } else {
163                // Load via mw.loader.load()
164                $data['general'][] = $name;
165            }
166        }
167
168        foreach ( $this->moduleStyles as $name ) {
169            $module = $rl->getModule( $name );
170            if ( !$module ) {
171                continue;
172            }
173
174            if ( $module->getType() !== Module::LOAD_STYLES ) {
175                $logger = $rl->getLogger();
176                $logger->error( 'Unexpected general module "{module}" in styles queue.', [
177                    'module' => $name,
178                ] );
179                continue;
180            }
181
182            // Stylesheet doesn't trigger mw.loader callback.
183            // Set "ready" state to allow script modules to depend on this module  (T87871).
184            // And to avoid duplicate requests at run-time from mw.loader.
185            //
186            // Optimization: Exclude state for "noscript" modules. Since these are also excluded
187            // from the startup registry, no need to send their states (T291735).
188            $group = $module->getGroup();
189            if ( $group !== Module::GROUP_NOSCRIPT ) {
190                $data['states'][$name] = 'ready';
191            }
192
193            $context = $this->getContext( $group, Module::TYPE_STYLES );
194            if ( $module->shouldEmbedModule( $this->context ) ) {
195                // Avoid needless embed for private embeds we know are empty.
196                // (Set "ready" state directly instead, which we do a few lines above.)
197                if ( !$module->isKnownEmpty( $context ) ) {
198                    // Embed via <style> element
199                    $data['embed']['styles'][] = $name;
200                }
201            // For other style modules, always request them, regardless of whether they are
202            // currently known to be empty. Because:
203            // 1. Those modules are requested in batch, so there is no extra request overhead
204            //    or extra HTML element to be avoided.
205            // 2. Checking isKnownEmpty for those can be expensive and slow down page view
206            //    generation (T230260).
207            // 3. We don't want cached HTML to vary on the current state of a module.
208            //    If the module becomes non-empty a few minutes later, it should start working
209            //    on cached HTML without requiring a purge.
210            //
211            // But, user-specific modules:
212            // * ... are used on page views not publicly cached.
213            // * ... are in their own group and thus a require a request we can avoid
214            // * ... have known-empty status preloaded by ResourceLoader.
215            } elseif ( $group !== Module::GROUP_USER || !$module->isKnownEmpty( $context ) ) {
216                // Load from load.php?only=styles via <link rel=stylesheet>
217                $data['styles'][] = $name;
218            }
219            $deprecation = $module->getDeprecationWarning();
220            if ( $deprecation ) {
221                $data['styleDeprecations'][] = $deprecation;
222            }
223        }
224
225        $this->data = $data;
226
227        return $this->data;
228    }
229
230    /**
231     * @return array<string,string> Attributes pairs for the HTML document element
232     */
233    public function getDocumentAttributes() {
234        return [ 'class' => 'client-nojs' ];
235    }
236
237    /**
238     * Set relevant classes on document.documentElement
239     *
240     * @param string|null $nojsClass Class name that Skin will set on HTML document
241     * @return string
242     */
243    private function getDocumentClassNameScript( $nojsClass ) {
244        // Change "client-nojs" to "client-js".
245        // This enables server rendering of UI components, even for those that should be hidden
246        // in Grade C where JavaScript is unsupported, whilst avoiding a flash of wrong content.
247        //
248        // See also Skin:getHtmlElementAttributes() and startup/startup.js.
249        //
250        // Optimisation: Produce shorter and faster JS by only writing to DOM.
251        // This is possible because Skin informs RL about the final value of <html class>, and
252        // because RL already controls the first element in HTML <head> for performance reasons.
253        // - Avoid reading HTMLElement.className
254        // - Avoid executing JS regexes in the common case, by doing the string replace in PHP.
255        $nojsClass ??= $this->getDocumentAttributes()['class'];
256        $jsClass = preg_replace( '/(^|\s)client-nojs(\s|$)/', '$1client-js$2', $nojsClass );
257        $jsClassJson = $this->context->encodeJson( $jsClass );
258
259        // Apply client preferences stored by mw.user.clientPrefs as "class1,class2", where each
260        // item is an <html> class following the pattern "<key>-clientpref-<value>" (T339268)
261        if ( $this->options['clientPrefEnabled'] ) {
262            $cookiePrefix = $this->options['clientPrefCookiePrefix'];
263            $script = strtr(
264                file_get_contents( MW_INSTALL_PATH . '/resources/src/startup/clientprefs.js' ),
265                [
266                    '$VARS.jsClass' => $jsClassJson,
267                    '__COOKIE_PREFIX__' => $cookiePrefix,
268                ]
269            );
270        } else {
271            $script = "document.documentElement.className = {$jsClassJson};";
272        }
273
274        return $script;
275    }
276
277    /**
278     * The order of elements in the head is as follows:
279     * - Inline scripts.
280     * - Stylesheets.
281     * - Async external script-src.
282     *
283     * Reasons:
284     * - Script execution may be blocked on preceding stylesheets.
285     * - Async scripts are not blocked on stylesheets.
286     * - Inline scripts can't be asynchronous.
287     * - For styles, earlier is better.
288     *
289     * @param string|null $nojsClass Class name that caller uses on HTML document element
290     * @return string|WrappedStringList HTML
291     */
292    public function getHeadHtml( $nojsClass = null ) {
293        $script = $this->getDocumentClassNameScript( $nojsClass );
294
295        // Inline script: Declare mw.config variables for this page.
296        if ( $this->config ) {
297            $confJson = $this->context->encodeJson( $this->config );
298            $script .= "
299RLCONF = {$confJson};
300";
301        }
302
303        $data = $this->getData();
304
305        // Inline script: Declare initial module states for this page.
306        $states = array_merge( $this->exemptStates, $data['states'] );
307        if ( $states ) {
308            $stateJson = $this->context->encodeJson( $states );
309            $script .= "
310RLSTATE = {$stateJson};
311";
312        }
313
314        // Inline script: Declare general modules to load on this page.
315        if ( $data['general'] ) {
316            $pageModulesJson = $this->context->encodeJson( $data['general'] );
317            $script .= "
318RLPAGEMODULES = {$pageModulesJson};
319";
320        }
321
322        if ( !$this->context->getDebug() ) {
323            $script = ResourceLoader::filter( 'minify-js', $script, [ 'cache' => false ] );
324        }
325
326        $chunks = [];
327        $chunks[] = Html::inlineScript( $script );
328
329        // Inline RLQ: Embedded modules
330        if ( $data['embed']['general'] ) {
331            $chunks[] = $this->getLoad(
332                $data['embed']['general'],
333                Module::TYPE_COMBINED
334            );
335        }
336
337        // External stylesheets (only=styles)
338        if ( $data['styles'] ) {
339            $chunks[] = $this->getLoad(
340                $data['styles'],
341                Module::TYPE_STYLES
342            );
343        }
344
345        // Inline stylesheets (embedded only=styles)
346        // @phan-suppress-next-line PhanTypeInvalidDimOffset False positive
347        if ( $data['embed']['styles'] ) {
348            $chunks[] = $this->getLoad(
349                $data['embed']['styles'],
350                Module::TYPE_STYLES
351            );
352        }
353
354        // Async scripts. Once the startup is loaded, inline RLQ scripts will run.
355        // Pass-through a custom 'target' from OutputPage (T143066).
356        $startupQuery = [ 'raw' => '1' ];
357        foreach ( [ 'target', 'safemode' ] as $param ) {
358            if ( $this->options[$param] !== null ) {
359                $startupQuery[$param] = (string)$this->options[$param];
360            }
361        }
362        $chunks[] = $this->getLoad(
363            'startup',
364            Module::TYPE_SCRIPTS,
365            $startupQuery
366        );
367
368        return WrappedString::join( "\n", $chunks );
369    }
370
371    /**
372     * @return string|WrappedStringList HTML
373     */
374    public function getBodyHtml() {
375        $data = $this->getData();
376        $chunks = [];
377
378        // Deprecations for only=styles modules
379        if ( $data['styleDeprecations'] ) {
380            $calls = '';
381            foreach ( $data['styleDeprecations'] as $warning ) {
382                $calls .= Html::encodeJsCall( 'mw.log.warn', [ $warning ] );
383            }
384            $chunks[] = ResourceLoader::makeInlineScript( $calls );
385        }
386
387        return WrappedString::join( "\n", $chunks );
388    }
389
390    private function getContext( ?string $group, string $type ): Context {
391        return self::makeContext( $this->context, $group, $type );
392    }
393
394    /**
395     * @param string|string[] $modules
396     * @param string $only
397     * @param array $extraQuery
398     * @return string|WrappedStringList HTML
399     */
400    private function getLoad( $modules, string $only, array $extraQuery = [] ) {
401        return self::makeLoad( $this->context, (array)$modules, $only, $extraQuery );
402    }
403
404    private static function makeContext( Context $mainContext, ?string $group, string $type,
405        array $extraQuery = []
406    ): DerivativeContext {
407        // Allow caller to setVersion() and setModules()
408        $ret = new DerivativeContext( $mainContext );
409        // Set 'only' if not combined
410        $ret->setOnly( $type === Module::TYPE_COMBINED ? null : $type );
411        // Remove user parameter in most cases
412        if ( $group !== Module::GROUP_USER && $group !== Module::GROUP_PRIVATE ) {
413            $ret->setUser( null );
414        }
415        if ( isset( $extraQuery['raw'] ) ) {
416            $ret->setRaw( true );
417        }
418        return $ret;
419    }
420
421    /**
422     * Explicitly load or embed modules on a page.
423     *
424     * @param Context $mainContext
425     * @param string[] $modules One or more module names
426     * @param string $only Module TYPE_ class constant
427     * @param array $extraQuery [optional] Array with extra query parameters for the request
428     * @return string|WrappedStringList HTML
429     */
430    public static function makeLoad( Context $mainContext, array $modules, $only,
431        array $extraQuery = []
432    ) {
433        $rl = $mainContext->getResourceLoader();
434        $chunks = [];
435
436        // Sort module names so requests are more uniform
437        sort( $modules );
438
439        if ( $mainContext->getDebug() && count( $modules ) > 1 ) {
440            // Recursively call us for every item
441            foreach ( $modules as $name ) {
442                $chunks[] = self::makeLoad( $mainContext, [ $name ], $only, $extraQuery );
443            }
444            return new WrappedStringList( "\n", $chunks );
445        }
446
447        // Create keyed-by-source and then keyed-by-group list of module objects from modules list
448        $sortedModules = [];
449        foreach ( $modules as $name ) {
450            $module = $rl->getModule( $name );
451            if ( !$module ) {
452                $rl->getLogger()->warning( 'Unknown module "{module}"', [ 'module' => $name ] );
453                continue;
454            }
455            $sortedModules[$module->getSource()][$module->getGroup() ?? ''][$name] = $module;
456        }
457
458        foreach ( $sortedModules as $source => $groups ) {
459            foreach ( $groups as $group => $grpModules ) {
460                $context = self::makeContext( $mainContext, $group, $only, $extraQuery );
461
462                // Separate sets of linked and embedded modules while preserving order
463                $moduleSets = [];
464                $idx = -1;
465                foreach ( $grpModules as $name => $module ) {
466                    $shouldEmbed = $module->shouldEmbedModule( $context );
467                    if ( !$moduleSets || $moduleSets[$idx][0] !== $shouldEmbed ) {
468                        $moduleSets[++$idx] = [ $shouldEmbed, [] ];
469                    }
470                    $moduleSets[$idx][1][$name] = $module;
471                }
472
473                // Link/embed each set
474                foreach ( $moduleSets as [ $embed, $moduleSet ] ) {
475                    $moduleSetNames = array_keys( $moduleSet );
476                    $context->setModules( $moduleSetNames );
477                    if ( $embed ) {
478                        $response = $rl->makeModuleResponse( $context, $moduleSet );
479                        // Decide whether to use style or script element
480                        if ( $only == Module::TYPE_STYLES ) {
481                            $chunks[] = Html::inlineStyle( $response );
482                        } else {
483                            $chunks[] = ResourceLoader::makeInlineScript( $response );
484                        }
485                    } else {
486                        // Not embedded
487
488                        // Special handling for the user group; because users might change their stuff
489                        // on-wiki like user pages, or user preferences; we need to find the highest
490                        // timestamp of these user-changeable modules so we can ensure cache misses on change
491                        // This should NOT be done for the site group (T29564) because anons get that too
492                        // and we shouldn't be putting timestamps in CDN-cached HTML
493                        if ( $group === Module::GROUP_USER ) {
494                            $context->setVersion( $rl->makeVersionQuery( $context, $moduleSetNames ) );
495                        }
496
497                        // Must setModules() before createLoaderURL()
498                        $url = $rl->createLoaderURL( $source, $context, $extraQuery );
499
500                        // Decide whether to use 'style' or 'script' element
501                        if ( $only === Module::TYPE_STYLES ) {
502                            $chunk = Html::linkedStyle( $url );
503                        } elseif ( $context->getRaw() ) {
504                            // This request is asking for the module to be delivered standalone,
505                            // (aka "raw") without communicating to any mw.loader client.
506                            // For:
507                            // - startup (naturally because this is what will define mw.loader)
508                            $chunk = Html::element( 'script', [
509                                'async' => true,
510                                'src' => $url,
511                            ] );
512                        } else {
513                            $chunk = ResourceLoader::makeInlineScript(
514                                'mw.loader.load(' . $mainContext->encodeJson( $url ) . ');'
515                            );
516                        }
517
518                        if ( $group == Module::GROUP_NOSCRIPT ) {
519                            $chunks[] = Html::rawElement( 'noscript', [], $chunk );
520                        } else {
521                            $chunks[] = $chunk;
522                        }
523                    }
524                }
525            }
526        }
527
528        return new WrappedStringList( "\n", $chunks );
529    }
530}