'ResetUserTokens' => __DIR__ . '/maintenance/resetUserTokens.php',
'ResourceFileCache' => __DIR__ . '/includes/cache/ResourceFileCache.php',
'ResourceLoader' => __DIR__ . '/includes/resourceloader/ResourceLoader.php',
+ 'ResourceLoaderClientHtml' => __DIR__ . '/includes/resourceloader/ResourceLoaderClientHtml.php',
'ResourceLoaderContext' => __DIR__ . '/includes/resourceloader/ResourceLoaderContext.php',
'ResourceLoaderEditToolbarModule' => __DIR__ . '/includes/resourceloader/ResourceLoaderEditToolbarModule.php',
'ResourceLoaderFileModule' => __DIR__ . '/includes/resourceloader/ResourceLoaderFileModule.php',
# KSS style guide
$(eval KSS_RL_TMP := $(shell mktemp /tmp/tmp.XXXXXXXXXX))
$(eval MODULE_STR := $(shell paste -sd "|" styleGuideModules.txt))
-# See OutputPage::makeResourceLoaderLink.
+# See ResourceLoaderClientHtml::makeLoad.
@curl -sG "${MEDIAWIKI_LOAD_URL}?modules=${MODULE_STR}&only=styles" > $(KSS_RL_TMP)
@node_modules/.bin/kss-node ../../resources/src/mediawiki.ui static/ --css $(KSS_RL_TMP) -t styleguide-template
@rm $(KSS_RL_TMP)
/** @var ResourceLoader */
protected $mResourceLoader;
+ /** @var ResourceLoaderClientHtml */
+ private $rlClient;
+
+ /** @var ResourceLoaderContext */
+ private $rlClientContext;
+
+ /** @var string */
+ private $rlUserModuleState;
+
/** @var array */
protected $mJsConfigVars = [];
* Add a self-contained script tag with the given contents
* Internal use only. Use OutputPage::addModules() if possible.
*
- * @param string $script JavaScript text, no "<script>" tags
+ * @param string $script JavaScript text, no script tags
*/
public function addInlineScript( $script ) {
$this->mScripts .= Html::inlineScript( $script );
* @param string $param
* @return array Array of module names
*/
- public function getModules( $filter = false, $position = null, $param = 'mModules' ) {
+ public function getModules( $filter = false, $position = null, $param = 'mModules',
+ $type = ResourceLoaderModule::TYPE_COMBINED
+ ) {
$modules = array_values( array_unique( $this->$param ) );
return $filter
- ? $this->filterModules( $modules, $position )
+ ? $this->filterModules( $modules, $position, $type )
: $modules;
}
*
* @param bool $filter
* @param string|null $position
- *
* @return array Array of module names
*/
public function getModuleScripts( $filter = false, $position = null ) {
- return $this->getModules( $filter, $position, 'mModuleScripts' );
+ return $this->getModules( $filter, $position, 'mModuleScripts',
+ ResourceLoaderModule::TYPE_SCRIPTS
+ );
}
/**
*
* @param bool $filter
* @param string|null $position
- *
* @return array Array of module names
*/
public function getModuleStyles( $filter = false, $position = null ) {
- return $this->getModules( $filter, $position, 'mModuleStyles' );
+ return $this->getModules( $filter, $position, 'mModuleStyles',
+ ResourceLoaderModule::TYPE_STYLES
+ );
}
/**
// add skin specific modules
$modules = $sk->getDefaultModules();
- // Enforce various default modules for all skins
+ // Enforce various default modules for all pages and all skins
$coreModules = [
// Keep this list as small as possible
'site',
// Hook that allows last minute changes to the output page, e.g.
// adding of CSS or Javascript by extensions.
Hooks::run( 'BeforePageDisplay', [ &$this, &$sk ] );
+ $this->getSkin()->setupSkinUserCss( $this );
try {
$sk->outputPage();
$this->addReturnTo( $titleObj, wfCgiToArray( $returntoquery ) );
}
+ private function getRlClientContext() {
+ if ( !$this->rlClientContext ) {
+ $query = ResourceLoader::makeLoaderQuery(
+ [], // modules; not relevant
+ $this->getLanguage()->getCode(),
+ $this->getSkin()->getSkinName(),
+ $this->getUser()->isLoggedIn() ? $this->getUser()->getName() : null,
+ null, // version; not relevant
+ ResourceLoader::inDebugMode(),
+ null, // only; not relevant
+ $this->isPrintable(),
+ $this->getRequest()->getBool( 'handheld' )
+ );
+ $this->rlClientContext = new ResourceLoaderContext(
+ $this->getResourceLoader(),
+ new FauxRequest( $query )
+ );
+ }
+ return $this->rlClientContext;
+ }
+
+ /**
+ * Call this to freeze the module queue and JS config and create a formatter.
+ *
+ * Depending on the Skin, this may get lazy-initialised in either headElement() or
+ * getBottomScripts(). See SkinTemplate::prepareQuickTemplate(). Calling this too early may
+ * cause unexpected side-effects since disallowUserJs() may be called at any time to change
+ * the module filters retroactively. Skins and extension hooks may also add modules until very
+ * late in the request lifecycle.
+ *
+ * @return ResourceLoaderClientHtml
+ */
+ public function getRlClient() {
+ if ( !$this->rlClient ) {
+ $context = $this->getRlClientContext();
+ $userModule = $this->getResourceLoader()->getModule( 'user' );
+ // Manually handled by getBottomScripts()
+ $userState = $userModule->isKnownEmpty( $context ) && !$this->isUserModulePreview()
+ ? 'ready'
+ : 'loading';
+ $this->rlUserModuleState = $userState;
+
+ $this->addModules( [
+ 'user.options',
+ 'user.tokens',
+ ] );
+ $rlClient = new ResourceLoaderClientHtml( $context );
+ $rlClient->setConfig( $this->getJSVars() );
+ $rlClient->setModules( $this->getModules( /*filter*/ true ) );
+ $rlClient->setModuleStyles( $this->getModuleStyles( /*filter*/ true ) );
+ $rlClient->setModuleScripts( $this->getModuleScripts( /*filter*/ true ) );
+ $rlClient->setExemptStates( [
+ 'user' => $userState,
+ // Manually handled by buildExemptModules() and getBottomScripts()
+ 'site.styles' => 'ready',
+ 'noscript' => 'ready',
+ 'user.cssprefs' => 'ready',
+ 'user.styles' => 'ready',
+ ] );
+ $this->rlClient = $rlClient;
+ }
+ return $this->rlClient;
+ }
+
/**
* @param Skin $sk The given Skin
* @param bool $includeStyle Unused
$sitedir = $wgContLang->getDir();
$pieces = [];
- $pieces[] = Html::htmlHeader( $sk->getHtmlElementAttributes() );
+ $pieces[] = Html::htmlHeader( Sanitizer::mergeAttributes(
+ $this->getRlClient()->getDocumentAttributes(),
+ $sk->getHtmlElementAttributes()
+ ) );
+ $pieces[] = Html::openElement( 'head' );
if ( $this->getHTMLTitle() == '' ) {
$this->setHTMLTitle( $this->msg( 'pagetitle', $this->getPageTitle() )->inContentLanguage() );
}
- $openHead = Html::openElement( 'head' );
- if ( $openHead ) {
- $pieces[] = $openHead;
- }
-
if ( !Html::isXmlMimeType( $this->getConfig()->get( 'MimeType' ) ) ) {
// Add <meta charset="UTF-8">
// This should be before <title> since it defines the charset used by
}
$pieces[] = Html::element( 'title', null, $this->getHTMLTitle() );
- $pieces[] = $this->getInlineHeadScripts();
- $pieces[] = $this->buildCssLinks();
- $pieces[] = $this->getExternalHeadScripts();
-
- foreach ( $this->getHeadLinksArray() as $item ) {
- $pieces[] = $item;
- }
-
- foreach ( $this->mHeadItems as $item ) {
- $pieces[] = $item;
- }
-
- $closeHead = Html::closeElement( 'head' );
- if ( $closeHead ) {
- $pieces[] = $closeHead;
- }
+ $pieces[] = $this->getRlClient()->getHeadHtml();
+ $pieces[] = $this->buildExemptModules();
+ $pieces = array_merge( $pieces, array_values( $this->getHeadLinksArray() ) );
+ $pieces = array_merge( $pieces, array_values( $this->mHeadItems ) );
+ $pieces[] = Html::closeElement( 'head' );
$bodyClasses = [];
$bodyClasses[] = 'mediawiki';
$pieces[] = Html::openElement( 'body', $bodyAttrs );
- return WrappedStringList::join( "\n", $pieces );
+ return self::combineWrappedStrings( $pieces );
}
/**
}
/**
- * Construct neccecary html and loader preset states to load modules on a page.
- *
- * Use getHtmlFromLoaderLinks() to convert this array to HTML.
+ * Explicily load or embed modules on a page.
*
* @param array|string $modules One or more module names
* @param string $only ResourceLoaderModule TYPE_ class constant
* @param array $extraQuery [optional] Array with extra query parameters for the request
- * @return array A list of HTML strings and array of client loader preset states
+ * @return string|WrappedStringList HTML
*/
public function makeResourceLoaderLink( $modules, $only, array $extraQuery = [] ) {
- $modules = (array)$modules;
-
- $links = [
- // List of html strings
- 'html' => [],
- // Associative array of module names and their states
- 'states' => [],
- ];
-
- if ( !count( $modules ) ) {
- return $links;
- }
-
- if ( count( $modules ) > 1 ) {
- // Remove duplicate module requests
- $modules = array_unique( $modules );
- // Sort module names so requests are more uniform
- sort( $modules );
-
- if ( ResourceLoader::inDebugMode() ) {
- // Recursively call us for every item
- foreach ( $modules as $name ) {
- $link = $this->makeResourceLoaderLink( $name, $only, $extraQuery );
- $links['html'] = array_merge( $links['html'], $link['html'] );
- $links['states'] += $link['states'];
- }
- return $links;
- }
- }
-
- if ( !is_null( $this->mTarget ) ) {
- $extraQuery['target'] = $this->mTarget;
- }
-
- // Create keyed-by-source and then keyed-by-group list of module objects from modules list
- $sortedModules = [];
- $resourceLoader = $this->getResourceLoader();
- foreach ( $modules as $name ) {
- $module = $resourceLoader->getModule( $name );
- # Check that we're allowed to include this module on this page
- if ( !$module
- || ( $module->getOrigin() > $this->getAllowedModules( ResourceLoaderModule::TYPE_SCRIPTS )
- && $only == ResourceLoaderModule::TYPE_SCRIPTS )
- || ( $module->getOrigin() > $this->getAllowedModules( ResourceLoaderModule::TYPE_STYLES )
- && $only == ResourceLoaderModule::TYPE_STYLES )
- || ( $module->getOrigin() > $this->getAllowedModules( ResourceLoaderModule::TYPE_COMBINED )
- && $only == ResourceLoaderModule::TYPE_COMBINED )
- || ( $this->mTarget && !in_array( $this->mTarget, $module->getTargets() ) )
- ) {
- continue;
- }
-
- if ( $only === ResourceLoaderModule::TYPE_STYLES ) {
- if ( $module->getType() !== ResourceLoaderModule::LOAD_STYLES ) {
- $logger = $resourceLoader->getLogger();
- $logger->debug( 'Unexpected general module "{module}" in styles queue.', [
- 'module' => $name,
- ] );
- } else {
- $links['states'][$name] = 'ready';
- }
- }
-
- $sortedModules[$module->getSource()][$module->getGroup()][$name] = $module;
- }
-
- foreach ( $sortedModules as $source => $groups ) {
- foreach ( $groups as $group => $grpModules ) {
- // Special handling for user-specific groups
- $user = null;
- if ( ( $group === 'user' || $group === 'private' ) && $this->getUser()->isLoggedIn() ) {
- $user = $this->getUser()->getName();
- }
-
- // Create a fake request based on the one we are about to make so modules return
- // correct timestamp and emptiness data
- $query = ResourceLoader::makeLoaderQuery(
- [], // modules; not determined yet
- $this->getLanguage()->getCode(),
- $this->getSkin()->getSkinName(),
- $user,
- null, // version; not determined yet
- ResourceLoader::inDebugMode(),
- $only === ResourceLoaderModule::TYPE_COMBINED ? null : $only,
- $this->isPrintable(),
- $this->getRequest()->getBool( 'handheld' ),
- $extraQuery
- );
- $context = new ResourceLoaderContext( $resourceLoader, new FauxRequest( $query ) );
-
- // Extract modules that know they're empty and see if we have one or more
- // raw modules
- $isRaw = false;
- foreach ( $grpModules as $key => $module ) {
- // Inline empty modules: since they're empty, just mark them as 'ready' (bug 46857)
- // If we're only getting the styles, we don't need to do anything for empty modules.
- if ( $module->isKnownEmpty( $context ) ) {
- unset( $grpModules[$key] );
- if ( $only !== ResourceLoaderModule::TYPE_STYLES ) {
- $links['states'][$key] = 'ready';
- }
- }
-
- $isRaw |= $module->isRaw();
- }
-
- // If there are no non-empty modules, skip this group
- if ( count( $grpModules ) === 0 ) {
- continue;
- }
-
- // Inline private modules. These can't be loaded through load.php for security
- // reasons, see bug 34907. Note that these modules should be loaded from
- // getExternalHeadScripts() before the first loader call. Otherwise other modules can't
- // properly use them as dependencies (bug 30914)
- if ( $group === 'private' ) {
- if ( $only == ResourceLoaderModule::TYPE_STYLES ) {
- $links['html'][] = Html::inlineStyle(
- $resourceLoader->makeModuleResponse( $context, $grpModules )
- );
- } else {
- $links['html'][] = ResourceLoader::makeInlineScript(
- $resourceLoader->makeModuleResponse( $context, $grpModules )
- );
- }
- continue;
- }
-
- // Special handling for the user group; because users might change their stuff
- // on-wiki like user pages, or user preferences; we need to find the highest
- // timestamp of these user-changeable modules so we can ensure cache misses on change
- // This should NOT be done for the site group (bug 27564) because anons get that too
- // and we shouldn't be putting timestamps in CDN-cached HTML
- $version = null;
- if ( $group === 'user' ) {
- $query['version'] = $resourceLoader->getCombinedVersion( $context, array_keys( $grpModules ) );
- }
-
- $query['modules'] = ResourceLoader::makePackedModulesString( array_keys( $grpModules ) );
- $moduleContext = new ResourceLoaderContext( $resourceLoader, new FauxRequest( $query ) );
- $url = $resourceLoader->createLoaderURL( $source, $moduleContext, $extraQuery );
-
- // Automatically select style/script elements
- if ( $only === ResourceLoaderModule::TYPE_STYLES ) {
- $link = Html::linkedStyle( $url );
- } else {
- if ( $context->getRaw() || $isRaw ) {
- // Startup module can't load itself, needs to use <script> instead of mw.loader.load
- $link = Html::element( 'script', [
- // In SpecialJavaScriptTest, QUnit must load synchronous
- 'async' => !isset( $extraQuery['sync'] ),
- 'src' => $url
- ] );
- } else {
- $link = ResourceLoader::makeInlineScript(
- Xml::encodeJsCall( 'mw.loader.load', [ $url ] )
- );
- }
-
- // For modules requested directly in the html via <script> or mw.loader.load
- // tell mw.loader they are being loading to prevent duplicate requests.
- foreach ( $grpModules as $key => $module ) {
- // Don't output state=loading for the startup module.
- if ( $key !== 'startup' ) {
- $links['states'][$key] = 'loading';
- }
- }
- }
-
- if ( $group == 'noscript' ) {
- $links['html'][] = Html::rawElement( 'noscript', [], $link );
- } else {
- $links['html'][] = $link;
- }
- }
- }
-
- return $links;
+ return ResourceLoaderClientHtml::makeLoad(
+ $this->getRlClientContext(),
+ (array)$modules,
+ $only,
+ $extraQuery
+ );
}
/**
- * Build html output from an array of links from makeResourceLoaderLink.
- * @param array $links
+ * Combine WrappedString chunks and filter out empty ones
+ *
+ * @param array $chunks
* @return string|WrappedStringList HTML
*/
- protected static function getHtmlFromLoaderLinks( array $links ) {
- $html = [];
- $states = [];
- foreach ( $links as $link ) {
- if ( !is_array( $link ) ) {
- $html[] = $link;
- } else {
- $html = array_merge( $html, $link['html'] );
- $states += $link['states'];
- }
- }
+ protected static function combineWrappedStrings( array $chunks ) {
// Filter out empty values
- $html = array_filter( $html, 'strlen' );
-
- if ( $states ) {
- array_unshift( $html, ResourceLoader::makeInlineScript(
- ResourceLoader::makeLoaderStateScript( $states )
- ) );
- }
-
- return WrappedString::join( "\n", $html );
+ $chunks = array_filter( $chunks, 'strlen' );
+ return WrappedString::join( "\n", $chunks );
}
- /**
- * JS stuff to put in the "<head>". This is the startup module, config
- * vars and modules marked with position 'top'
- *
- * @return string HTML fragment
- */
- function getHeadScripts() {
- return $this->getInlineHeadScripts() . $this->getExternalHeadScripts();
- }
-
- /**
- * <script src="..."> tags for "<head>".This is the startup module
- * and other modules marked with position 'top'.
- *
- * @return string|WrappedStringList HTML
- */
- function getExternalHeadScripts() {
- // Startup - this provides the client with the module
- // manifest and loads jquery and mediawiki base modules
- $links = [];
- $links[] = $this->makeResourceLoaderLink( 'startup', ResourceLoaderModule::TYPE_SCRIPTS );
- return self::getHtmlFromLoaderLinks( $links );
+ /** @return bool */
+ private function isUserModulePreview() {
+ return $this->getConfig()->get( 'AllowUserJs' )
+ && $this->getUser()->isLoggedIn()
+ && $this->getTitle()
+ && $this->getTitle()->isJsSubpage()
+ && $this->userCanPreview();
}
/**
- * Inline "<script>" tags to put in "<head>".
+ * JS stuff to put at the bottom of the `<body>`. These are modules with position 'bottom',
+ * legacy scripts ($this->mScripts), and user JS.
*
* @return string|WrappedStringList HTML
*/
- function getInlineHeadScripts() {
- $links = [];
-
- // Client profile classes for <html>. Allows for easy hiding/showing of UI components.
- // Must be done synchronously on every page to avoid flashes of wrong content.
- // Note: This class distinguishes MediaWiki-supported JavaScript from the rest.
- // The "rest" includes browsers that support JavaScript but not supported by our runtime.
- // For the performance benefit of the majority, this is added unconditionally here and is
- // then fixed up by the startup module for unsupported browsers.
- $links[] = Html::inlineScript(
- 'document.documentElement.className = document.documentElement.className'
- . '.replace( /(^|\s)client-nojs(\s|$)/, "$1client-js$2" );'
- );
-
- // Load config before anything else
- $links[] = ResourceLoader::makeInlineScript(
- ResourceLoader::makeConfigSetScript( $this->getJSVars() )
- );
-
- // Load embeddable private modules before any loader links
- // This needs to be TYPE_COMBINED so these modules are properly wrapped
- // in mw.loader.implement() calls and deferred until mw.user is available
- $embedScripts = [ 'user.options' ];
- $links[] = $this->makeResourceLoaderLink(
- $embedScripts,
- ResourceLoaderModule::TYPE_COMBINED
- );
- // Separate user.tokens as otherwise caching will be allowed (T84960)
- $links[] = $this->makeResourceLoaderLink(
- 'user.tokens',
- ResourceLoaderModule::TYPE_COMBINED
- );
-
- // Modules requests - let the client calculate dependencies and batch requests as it likes
- // Only load modules that have marked themselves for loading at the top
- $modules = $this->getModules( true, 'top' );
- if ( $modules ) {
- $links[] = ResourceLoader::makeInlineScript(
- Xml::encodeJsCall( 'mw.loader.load', [ $modules ] )
- );
+ public function getBottomScripts() {
+ $chunks = [];
+ $chunks[] = $this->getRlClient()->getBodyHtml();
+
+ // Legacy non-ResourceLoader scripts
+ $chunks[] = $this->mScripts;
+
+ // Exempt 'user' module
+ // - May need excludepages for live preview. (T28283)
+ // - Must use TYPE_COMBINED so its response is handled by mw.loader.implement() which
+ // ensures execution is scheduled after the "site" module.
+ // - Don't load if module state is already resolved as "ready".
+ if ( $this->rlUserModuleState === 'loading' ) {
+ if ( $this->isUserModulePreview() ) {
+ $chunks[] = $this->makeResourceLoaderLink( 'user', ResourceLoaderModule::TYPE_COMBINED,
+ [ 'excludepage' => $this->getTitle()->getPrefixedDBkey() ]
+ );
+ $chunks[] = ResourceLoader::makeInlineScript(
+ Xml::encodeJsCall( 'mw.loader.using', [
+ [ 'user', 'site' ],
+ new XmlJsCode(
+ 'function () {'
+ . Xml::encodeJsCall( '$.globalEval', [
+ $this->getRequest()->getText( 'wpTextbox1' )
+ ] )
+ . '}'
+ )
+ ] )
+ );
+ // FIXME: If the user is previewing, say, ./vector.js, his ./common.js will be loaded
+ // asynchronously and may arrive *after* the inline script here. So the previewed code
+ // may execute before ./common.js runs. Normally, ./common.js runs before ./vector.js.
+ // Similarly, when previewing ./common.js and the user module does arrive first,
+ // it will arrive without common.js and the inline script runs after.
+ // Thus running common after the excluded subpage.
+ } else {
+ // Load normally
+ $chunks[] = $this->makeResourceLoaderLink( 'user', ResourceLoaderModule::TYPE_COMBINED );
+ }
}
- // "Scripts only" modules marked for top inclusion
- $links[] = $this->makeResourceLoaderLink(
- $this->getModuleScripts( true, 'top' ),
- ResourceLoaderModule::TYPE_SCRIPTS
+ $chunks[] = ResourceLoader::makeInlineScript(
+ ResourceLoader::makeConfigSetScript(
+ [ 'wgPageParseReport' => $this->limitReportData ],
+ true
+ )
);
- return self::getHtmlFromLoaderLinks( $links );
- }
-
- /**
- * JS stuff to put at the 'bottom', which goes at the bottom of the `<body>`.
- * These are modules marked with position 'bottom', legacy scripts ($this->mScripts),
- * site JS, and user JS.
- *
- * @param bool $unused Previously used to let this method change its output based
- * on whether it was called by getExternalHeadScripts() or getBottomScripts().
- * @return string|WrappedStringList HTML
- */
- function getScriptsForBottomQueue( $unused = null ) {
- // Scripts "only" requests marked for bottom inclusion
- // If we're in the <head>, use load() calls rather than <script src="..."> tags
- $links = [];
-
- $links[] = $this->makeResourceLoaderLink( $this->getModuleScripts( true, 'bottom' ),
- ResourceLoaderModule::TYPE_SCRIPTS
- );
-
- // Modules requests - let the client calculate dependencies and batch requests as it likes
- // Only load modules that have marked themselves for loading at the bottom
- $modules = $this->getModules( true, 'bottom' );
- if ( $modules ) {
- $links[] = ResourceLoader::makeInlineScript(
- Xml::encodeJsCall( 'mw.loader.load', [ $modules ] )
- );
- }
-
- // Legacy Scripts
- $links[] = $this->mScripts;
-
- // Add user JS if enabled
- // This must use TYPE_COMBINED instead of only=scripts so that its request is handled by
- // mw.loader.implement() which ensures that execution is scheduled after the "site" module.
- if ( $this->getConfig()->get( 'AllowUserJs' )
- && $this->getUser()->isLoggedIn()
- && $this->getTitle()
- && $this->getTitle()->isJsSubpage()
- && $this->userCanPreview()
- ) {
- // We're on a preview of a JS subpage. Exclude this page from the user module (T28283)
- // and include the draft contents as a raw script instead.
- $links[] = $this->makeResourceLoaderLink( 'user', ResourceLoaderModule::TYPE_COMBINED,
- [ 'excludepage' => $this->getTitle()->getPrefixedDBkey() ]
- );
- // Load the previewed JS
- $links[] = ResourceLoader::makeInlineScript(
- Xml::encodeJsCall( 'mw.loader.using', [
- [ 'user', 'site' ],
- new XmlJsCode(
- 'function () {'
- . Xml::encodeJsCall( '$.globalEval', [
- $this->getRequest()->getText( 'wpTextbox1' )
- ] )
- . '}'
- )
- ] )
- );
-
- // FIXME: If the user is previewing, say, ./vector.js, his ./common.js will be loaded
- // asynchronously and may arrive *after* the inline script here. So the previewed code
- // may execute before ./common.js runs. Normally, ./common.js runs before ./vector.js.
- // Similarly, when previewing ./common.js and the user module does arrive first,
- // it will arrive without common.js and the inline script runs after.
- // Thus running common after the excluded subpage.
- } else {
- // Include the user module normally, i.e., raw to avoid it being wrapped in a closure.
- $links[] = $this->makeResourceLoaderLink( 'user', ResourceLoaderModule::TYPE_COMBINED );
- }
-
- return self::getHtmlFromLoaderLinks( $links );
- }
-
- /**
- * JS stuff to put at the bottom of the "<body>"
- * @return string
- */
- function getBottomScripts() {
- return $this->getScriptsForBottomQueue() .
- ResourceLoader::makeInlineScript(
- ResourceLoader::makeConfigSetScript(
- [ 'wgPageParseReport' => $this->limitReportData ],
- true
- )
- );
+ return self::combineWrappedStrings( $chunks );
}
/**
}
/**
- * Build a set of "<link>" elements for stylesheets specified in the $this->styles array.
+ * Build exempt modules and legacy non-ResourceLoader styles.
*
* @return string|WrappedStringList HTML
*/
- public function buildCssLinks() {
+ protected function buildExemptModules() {
global $wgContLang;
- $this->getSkin()->setupSkinUserCss( $this );
-
- // Add ResourceLoader styles
- // Split the styles into these groups
- $styles = [
- 'other' => [],
- 'user' => [],
- 'site' => [],
- 'private' => [],
- 'noscript' => []
- ];
- $links = [];
- $otherTags = []; // Tags to append after the normal <link> tags
$resourceLoader = $this->getResourceLoader();
+ $chunks = [];
+ // Things that should be appended after the other link and style chunks
+ $append = [];
+ $moduleStyles = [
+ 'site.styles',
+ 'noscript'
+ ];
- $moduleStyles = $this->getModuleStyles();
-
- // Per-site custom styles
- $moduleStyles[] = 'site.styles';
- $moduleStyles[] = 'noscript';
-
- // Per-user custom styles
+ // Exempt 'user' styles module.
+ // - May need excludepages for live preview.
+ // - Position after ResourceLoaderDynamicStyles marker
if ( $this->getConfig()->get( 'AllowUserCss' ) && $this->getTitle()->isCssSubpage()
&& $this->userCanPreview()
) {
- // We're on a preview of a CSS subpage
- // Exclude this page from the user module in case it's in there (bug 26283)
- $link = $this->makeResourceLoaderLink( 'user.styles', ResourceLoaderModule::TYPE_STYLES,
+ $append[] = $this->makeResourceLoaderLink(
+ 'user.styles',
+ ResourceLoaderModule::TYPE_STYLES,
[ 'excludepage' => $this->getTitle()->getPrefixedDBkey() ]
);
- $otherTags = array_merge( $otherTags, $link['html'] );
- // Load the previewed CSS
- // If needed, Janus it first. This is user-supplied CSS, so it's
- // assumed to be right for the content language directionality.
+ // Load the previewed CSS. Janus it if needed.
+ // User-supplied CSS is assumed to in the wiki's content language.
$previewedCSS = $this->getRequest()->getText( 'wpTextbox1' );
if ( $this->getLanguage()->getDir() !== $wgContLang->getDir() ) {
$previewedCSS = CSSJanus::transform( $previewedCSS, true, false );
}
- $otherTags[] = Html::inlineStyle( $previewedCSS );
+ $append[] = Html::inlineStyle( $previewedCSS );
} else {
- // Load the user styles normally
- $moduleStyles[] = 'user.styles';
+ $module = $this->getResourceLoader()->getModule( 'user.styles' );
+ if ( !$module->isKnownEmpty( $this->getRlClientContext() ) ) {
+ // Load styles normally
+ $moduleStyles[] = 'user.styles';
+ }
}
- // Per-user preference styles
+ // Exempt 'user.cssprefs' module
+ // - Position after ResourceLoaderDynamicStyles marker
$moduleStyles[] = 'user.cssprefs';
+ $groups = [
+ 'other' => [],
+ 'site' => [],
+ 'noscript' => [],
+ 'private' => [],
+ 'user' => [],
+ ];
foreach ( $moduleStyles as $name ) {
$module = $resourceLoader->getModule( $name );
- if ( !$module ) {
+ if ( !$module || $module->isKnownEmpty( $this->getRlClientContext() ) ) {
+ // E.g. Don't output empty <styles> for user.cssprefs
continue;
}
if ( $name === 'site.styles' ) {
- // HACK: The site module shouldn't be fragmented with a cache group and
- // http request. But in order to ensure its styles are separated and after the
- // ResourceLoaderDynamicStyles marker, pretend it is in a group called 'site'.
- // The scripts remain ungrouped and rides the bottom queue.
- $styles['site'][] = $name;
+ // HACK: Technically, the 'site.styles' module isn't in a separate request group.
+ // But, in order to ensure its styles are in the right position after the marker,
+ // pretend it's in a group called 'site'.
+ $groups['site'][] = $name;
continue;
}
$group = $module->getGroup();
- // Modules in groups other than the ones needing special treatment
- // (see $styles assignment)
- // will be placed in the "other" style category.
- $styles[isset( $styles[$group] ) ? $group : 'other'][] = $name;
+ // Use "other" in case. All exempt modules are in one of the known groups though.
+ $groups[isset( $groups[$group] ) ? $group : 'other'][] = $name;
}
// We want site, private and user styles to override dynamically added
// statically added styles from other modules. So the order has to be
// other, dynamic, site, private, user. Add statically added styles for
// other modules
- $links[] = $this->makeResourceLoaderLink(
- $styles['other'],
- ResourceLoaderModule::TYPE_STYLES
- );
- // Add normal styles added through addStyle()/addInlineStyle() here
- $links[] = implode( '', $this->buildCssLinksArray() ) . $this->mInlineStyles;
- // Add marker tag to mark the place where the client-side
- // loader should inject dynamic styles
- // We use a <meta> tag with a made-up name for this because that's valid HTML
- $links[] = Html::element(
+
+ // Add legacy styles added through addStyle()/addInlineStyle() here
+ $chunks[] = implode( '', $this->buildCssLinksArray() ) . $this->mInlineStyles;
+
+ // Client-side mw.loader will inject dynamic styles before this marker.
+ $chunks[] = Html::element(
'meta',
[ 'name' => 'ResourceLoaderDynamicStyles', 'content' => '' ]
);
- // Add site-specific and user-specific styles
- // 'private' at present only contains user.options, so put that before 'user'
- // Any future private modules will likely have a similar user-specific character
- foreach ( [ 'site', 'noscript', 'private', 'user' ] as $group ) {
- $links[] = $this->makeResourceLoaderLink( $styles[$group],
+ foreach ( [ 'other', 'site', 'noscript', 'private', 'user' ] as $group ) {
+ $chunks[] = $this->makeResourceLoaderLink( $groups[$group],
ResourceLoaderModule::TYPE_STYLES
);
}
- // Add stuff in $otherTags (previewed user CSS if applicable)
- $links[] = implode( '', $otherTags );
-
- return self::getHtmlFromLoaderLinks( $links );
+ return self::combineWrappedStrings( array_merge( $chunks, $append ) );
}
/**
}
/**
- * @param string $user
+ * @param string|null $user
*/
public function setUser( $user ) {
$this->user = $user;
$this->config = $config;
// Add 'local' source first
- $this->addSource( 'local', wfScript( 'load' ) );
+ $this->addSource( 'local', $config->get( 'LoadScript' ) );
// Add other sources
$this->addSource( $config->get( 'ResourceLoaderSources' ) );
--- /dev/null
+<?php
+/**
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation; either version 2 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License along
+ * with this program; if not, write to the Free Software Foundation, Inc.,
+ * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ */
+
+use WrappedString\WrappedStringList;
+
+/**
+ * Bootstrap a ResourceLoader client on an HTML page.
+ *
+ * @since 1.28
+ */
+class ResourceLoaderClientHtml {
+
+ /** @var ResourceLoaderContext */
+ private $context;
+
+ /** @var ResourceLoader */
+ private $resourceLoader;
+
+ /** @var array */
+ private $config = [];
+
+ /** @var array */
+ private $modules = [];
+
+ /** @var array */
+ private $moduleStyles = [];
+
+ /** @var array */
+ private $moduleScripts = [];
+
+ /** @var array */
+ private $exemptStates = [];
+
+ /** @var array */
+ private $data;
+
+ /**
+ * @param ResourceLoaderContext $context
+ */
+ public function __construct( ResourceLoaderContext $context ) {
+ $this->context = $context;
+ $this->resourceLoader = $context->getResourceLoader();
+ }
+
+ /**
+ * Set mw.config variables.
+ *
+ * @param array $vars Array of key/value pairs
+ */
+ public function setConfig( array $vars ) {
+ foreach ( $vars as $key => $value ) {
+ $this->config[$key] = $value;
+ }
+ }
+
+ /**
+ * Ensure one or more modules are loaded.
+ *
+ * @param array $modules Array of module names
+ */
+ public function setModules( array $modules ) {
+ $this->modules = $modules;
+ }
+
+ /**
+ * Ensure the styles of one or more modules are loaded.
+ *
+ * @deprecated since 1.28
+ * @param array $modules Array of module names
+ */
+ public function setModuleStyles( array $modules ) {
+ $this->moduleStyles = $modules;
+ }
+
+ /**
+ * Ensure the scripts of one or more modules are loaded.
+ *
+ * @deprecated since 1.28
+ * @param array $modules Array of module names
+ */
+ public function setModuleScripts( array $modules ) {
+ $this->moduleScripts = $modules;
+ }
+
+ /**
+ * Set state of special modules that are handled by the caller manually.
+ *
+ * See OutputPage::buildExemptModules() for use cases.
+ *
+ * @param array $modules Module state keyed by module name
+ */
+ public function setExemptStates( array $states ) {
+ $this->exemptStates = $states;
+ }
+
+ /**
+ * @return array
+ */
+ private function getData() {
+ if ( $this->data ) {
+ // @codeCoverageIgnoreStart
+ return $this->data;
+ // @codeCoverageIgnoreEnd
+ }
+
+ $rl = $this->resourceLoader;
+ $data = [
+ 'states' => [
+ // moduleName => state
+ ],
+ 'general' => [
+ // position => [ moduleName ]
+ 'top' => [],
+ 'bottom' => [],
+ ],
+ 'styles' => [
+ // moduleName
+ ],
+ 'scripts' => [
+ // position => [ moduleName ]
+ 'top' => [],
+ 'bottom' => [],
+ ],
+ // Embedding for private modules
+ 'embed' => [
+ 'styles' => [],
+ 'general' => [
+ 'top' => [],
+ 'bottom' => [],
+ ],
+ ],
+
+ ];
+
+ foreach ( $this->modules as $name ) {
+ $module = $rl->getModule( $name );
+ if ( !$module ) {
+ continue;
+ }
+
+ $group = $module->getGroup();
+ $position = $module->getPosition();
+
+ if ( $group === 'private' ) {
+ // Embed via mw.loader.implement per T36907.
+ $data['embed']['general'][$position][] = $name;
+ // Avoid duplicate request from mw.loader
+ $data['states'][$name] = 'loading';
+ } else {
+ // Load via mw.loader.load()
+ $data['general'][$position][] = $name;
+ }
+ }
+
+ foreach ( $this->moduleStyles as $name ) {
+ $module = $rl->getModule( $name );
+ if ( !$module ) {
+ continue;
+ }
+
+ if ( $module->getType() !== ResourceLoaderModule::LOAD_STYLES ) {
+ $logger = $rl->getLogger();
+ $logger->debug( 'Unexpected general module "{module}" in styles queue.', [
+ 'module' => $name,
+ ] );
+ } else {
+ // Stylesheet doesn't trigger mw.loader callback.
+ // Set "ready" state to allow dependencies and avoid duplicate requests. (T87871)
+ $data['states'][$name] = 'ready';
+ }
+
+ $group = $module->getGroup();
+ $context = $this->getContext( $group, ResourceLoaderModule::TYPE_STYLES );
+ if ( $module->isKnownEmpty( $context ) ) {
+ // Avoid needless request for empty module
+ $data['states'][$name] = 'ready';
+ } else {
+ if ( $group === 'private' ) {
+ // Embed via style element
+ $data['embed']['styles'][] = $name;
+ // Avoid duplicate request from mw.loader
+ $data['states'][$name] = 'ready';
+ } else {
+ // Load from load.php?only=styles via <link rel=stylesheet>
+ $data['styles'][] = $name;
+ }
+ }
+ }
+
+ foreach ( $this->moduleScripts as $name ) {
+ $module = $rl->getModule( $name );
+ if ( !$module ) {
+ continue;
+ }
+
+ $group = $module->getGroup();
+ $position = $module->getPosition();
+ $context = $this->getContext( $group, ResourceLoaderModule::TYPE_SCRIPTS );
+ if ( $module->isKnownEmpty( $context ) ) {
+ // Avoid needless request for empty module
+ $data['states'][$name] = 'ready';
+ } else {
+ // Load from load.php?only=scripts via <script src></script>
+ $data['scripts'][$position][] = $name;
+
+ // Avoid duplicate request from mw.loader
+ $data['states'][$name] = 'loading';
+ }
+ }
+
+ return $data;
+ }
+
+ /**
+ * @return array Attribute key-value pairs for the HTML document element
+ */
+ public function getDocumentAttributes() {
+ return [ 'class' => 'client-nojs' ];
+ }
+
+ /**
+ * The order of elements in the head is as follows:
+ * - Inline scripts.
+ * - Stylesheets.
+ * - Async external script-src.
+ *
+ * Reasons:
+ * - Script execution may be blocked on preceeding stylesheets.
+ * - Async scripts are not blocked on stylesheets.
+ * - Inline scripts can't be asynchronous.
+ * - For styles, earlier is better.
+ *
+ * @return string|WrappedStringList HTML
+ */
+ public function getHeadHtml() {
+ $data = $this->getData();
+ $chunks = [];
+
+ // Change "client-nojs" class to client-js. This allows easy toggling of UI components.
+ // This happens synchronously on every page view to avoid flashes of wrong content.
+ // See also #getDocumentAttributes() and /resources/src/startup.js.
+ $chunks[] = Html::inlineScript(
+ 'document.documentElement.className = document.documentElement.className'
+ . '.replace( /(^|\s)client-nojs(\s|$)/, "$1client-js$2" );'
+ );
+
+ // Inline RLQ: Set page variables
+ if ( $this->config ) {
+ $chunks[] = ResourceLoader::makeInlineScript(
+ ResourceLoader::makeConfigSetScript( $this->config )
+ );
+ }
+
+ // Inline RLQ: Initial module states
+ $states = array_merge( $this->exemptStates, $data['states'] );
+ if ( $states ) {
+ $chunks[] = ResourceLoader::makeInlineScript(
+ ResourceLoader::makeLoaderStateScript( $states )
+ );
+ }
+
+ // Inline RLQ: Embedded modules
+ if ( $data['embed']['general']['top'] ) {
+ $chunks[] = $this->getLoad(
+ $data['embed']['general']['top'],
+ ResourceLoaderModule::TYPE_COMBINED
+ );
+ }
+
+ // Inline RLQ: Load general modules
+ if ( $data['general']['top'] ) {
+ $chunks[] = ResourceLoader::makeInlineScript(
+ Xml::encodeJsCall( 'mw.loader.load', [ $data['general']['top'] ] )
+ );
+ }
+
+ // Inline RLQ: Load only=scripts
+ if ( $data['scripts']['top'] ) {
+ $chunks[] = $this->getLoad(
+ $data['scripts']['top'],
+ ResourceLoaderModule::TYPE_SCRIPTS
+ );
+ }
+
+ // External stylesheets
+ if ( $data['styles'] ) {
+ $chunks[] = $this->getLoad(
+ $data['styles'],
+ ResourceLoaderModule::TYPE_STYLES
+ );
+ }
+
+ // Inline stylesheets (embedded only=styles)
+ if ( $data['embed']['styles'] ) {
+ $chunks[] = $this->getLoad(
+ $data['embed']['styles'],
+ ResourceLoaderModule::TYPE_STYLES
+ );
+ }
+
+ // Async scripts. Once the startup is loaded, inline RLQ scripts will run.
+ $chunks[] = $this->getLoad( 'startup', ResourceLoaderModule::TYPE_SCRIPTS );
+
+ return WrappedStringList::join( "\n", $chunks );
+ }
+
+ /**
+ * @return string|WrappedStringList HTML
+ */
+ public function getBodyHtml() {
+ $data = $this->getData();
+ $chunks = [];
+
+ // Inline RLQ: Embedded modules
+ if ( $data['embed']['general']['bottom'] ) {
+ $chunks[] = $this->getLoad(
+ $data['embed']['general']['bottom'],
+ ResourceLoaderModule::TYPE_COMBINED
+ );
+ }
+
+ // Inline RLQ: Load only=scripts
+ if ( $data['scripts']['bottom'] ) {
+ $chunks[] = $this->getLoad(
+ $data['scripts']['bottom'],
+ ResourceLoaderModule::TYPE_SCRIPTS
+ );
+ }
+
+ // Inline RLQ: Load general modules
+ if ( $data['general']['bottom'] ) {
+ $chunks[] = ResourceLoader::makeInlineScript(
+ Xml::encodeJsCall( 'mw.loader.load', [ $data['general']['bottom'] ] )
+ );
+ }
+
+ return WrappedStringList::join( "\n", $chunks );
+ }
+
+ private function getContext( $group, $type ) {
+ return self::makeContext( $this->context, $group, $type );
+ }
+
+ private function getLoad( $modules, $only ) {
+ return self::makeLoad( $this->context, (array)$modules, $only );
+ }
+
+ private static function makeContext( ResourceLoaderContext $mainContext, $group, $type,
+ array $extraQuery = []
+ ) {
+ // Create new ResourceLoaderContext so that $extraQuery may trigger isRaw().
+ $req = new FauxRequest( array_merge( $mainContext->getRequest()->getValues(), $extraQuery ) );
+ // Set 'only' if not combined
+ $req->setVal( 'only', $type === ResourceLoaderModule::TYPE_COMBINED ? null : $type );
+ // Remove user parameter in most cases
+ if ( $group !== 'user' && $group !== 'private' ) {
+ $req->setVal( 'user', null );
+ }
+ $context = new ResourceLoaderContext( $mainContext->getResourceLoader(), $req );
+ // Allow caller to setVersion() and setModules()
+ return new DerivativeResourceLoaderContext( $context );
+ }
+
+ /**
+ * Explicily load or embed modules on a page.
+ *
+ * @param ResourceLoaderContext $mainContext
+ * @param array $modules One or more module names
+ * @param string $only ResourceLoaderModule TYPE_ class constant
+ * @param array $extraQuery [optional] Array with extra query parameters for the request
+ * @return string|WrappedStringList HTML
+ */
+ public static function makeLoad( ResourceLoaderContext $mainContext, array $modules, $only,
+ array $extraQuery = []
+ ) {
+ $rl = $mainContext->getResourceLoader();
+ $chunks = [];
+
+ if ( $mainContext->getDebug() && count( $modules ) > 1 ) {
+ $chunks = [];
+ // Recursively call us for every item
+ foreach ( $modules as $name ) {
+ $chunks[] = self::makeLoad( $mainContext, [ $name ], $only, $extraQuery );
+ }
+ return new WrappedStringList( "\n", $chunks );
+ }
+
+ // Sort module names so requests are more uniform
+ sort( $modules );
+ // Create keyed-by-source and then keyed-by-group list of module objects from modules list
+ $sortedModules = [];
+ foreach ( $modules as $name ) {
+ $module = $rl->getModule( $name );
+ if ( !$module ) {
+ $rl->getLogger()->warning( 'Unknown module "{module}"', [ 'module' => $name ] );
+ continue;
+ }
+ $sortedModules[$module->getSource()][$module->getGroup()][$name] = $module;
+ }
+
+ foreach ( $sortedModules as $source => $groups ) {
+ foreach ( $groups as $group => $grpModules ) {
+ $context = self::makeContext( $mainContext, $group, $only, $extraQuery );
+
+ if ( $group === 'private' ) {
+ // Decide whether to use style or script element
+ if ( $only == ResourceLoaderModule::TYPE_STYLES ) {
+ $chunks[] = Html::inlineStyle(
+ $rl->makeModuleResponse( $context, $grpModules )
+ );
+ } else {
+ $chunks[] = ResourceLoader::makeInlineScript(
+ $rl->makeModuleResponse( $context, $grpModules )
+ );
+ }
+ continue;
+ }
+
+ // See if we have one or more raw modules
+ $isRaw = false;
+ foreach ( $grpModules as $key => $module ) {
+ $isRaw |= $module->isRaw();
+ }
+
+ // Special handling for the user group; because users might change their stuff
+ // on-wiki like user pages, or user preferences; we need to find the highest
+ // timestamp of these user-changeable modules so we can ensure cache misses on change
+ // This should NOT be done for the site group (bug 27564) because anons get that too
+ // and we shouldn't be putting timestamps in CDN-cached HTML
+ if ( $group === 'user' ) {
+ $version = $rl->getCombinedVersion( $context, array_keys( $grpModules ) );
+ $context->setVersion( $version );
+ }
+
+ $context->setModules( array_keys( $grpModules ) );
+ $url = $rl->createLoaderURL( $source, $context, $extraQuery );
+
+ // Decide whether to use 'style' or 'script' element
+ if ( $only === ResourceLoaderModule::TYPE_STYLES ) {
+ $chunk = Html::linkedStyle( $url );
+ } else {
+ if ( $context->getRaw() || $isRaw ) {
+ $chunk = Html::element( 'script', [
+ // In SpecialJavaScriptTest, QUnit must load synchronous
+ 'async' => !isset( $extraQuery['sync'] ),
+ 'src' => $url
+ ] );
+ } else {
+ $chunk = ResourceLoader::makeInlineScript(
+ Xml::encodeJsCall( 'mw.loader.load', [ $url ] )
+ );
+ }
+ }
+
+ if ( $group == 'noscript' ) {
+ $chunks[] = Html::rawElement( 'noscript', [], $chunk );
+ } else {
+ $chunks[] = $chunk;
+ }
+ }
+ }
+
+ return new WrappedStringList( "\n", $chunks );
+ }
+}
/**
* @see ResourceLoaderModule::getVersionHash
- * @see OutputPage::makeResourceLoaderLink
+ * @see ResourceLoaderClientHtml::makeLoad
* @return string|null
*/
public function getVersion() {
return false;
}
+ /**
+ * @return string
+ */
+ public function getPosition() {
+ return 'top';
+ }
+
/**
* @return string
*/
return false;
}
+ /**
+ * @return string
+ */
+ public function getPosition() {
+ return 'top';
+ }
+
/**
* @return string
*/
. 'window.__karma__.loaded = function () {};'
. '}';
- // The below is essentially a pure-javascript version of OutputPage::getHeadScripts.
+ // The below is essentially a pure-javascript version of OutputPage::headElement().
$startup = $rl->makeModuleResponse( $startupContext, [
'startup' => $rl->getModule( 'startup' ),
] );
[ 'raw' => true, 'sync' => true ]
);
- $head = implode( "\n", array_merge( $styles['html'], $scripts['html'] ) );
+ $head = implode( "\n", [ $styles, $scripts ] );
$summary = $this->getSummaryHtml();
$html = <<<HTML
<!DOCTYPE html>
var NORLQ, script;
if ( !isCompatible() ) {
// Undo class swapping in case of an unsupported browser.
- // See OutputPage::getHeadScripts().
+ // See ResourceLoaderClientHtml::getDocumentAttributes().
document.documentElement.className = document.documentElement.className
.replace( /(^|\s)client-js(\s|$)/, '$1client-nojs$2' );
protected $dependencies = [];
protected $group = null;
protected $source = 'local';
+ protected $position = 'bottom';
protected $script = '';
protected $styles = '';
protected $skipFunction = null;
protected $isRaw = false;
+ protected $isKnownEmpty = false;
+ protected $type = ResourceLoaderModule::LOAD_GENERAL;
protected $targets = [ 'phpunit' ];
public function __construct( $options = [] ) {
public function getSource() {
return $this->source;
}
+ public function getPosition() {
+ return $this->position;
+ }
+
+ public function getType() {
+ return $this->type;
+ }
public function getSkipFunction() {
return $this->skipFunction;
public function isRaw() {
return $this->isRaw;
}
+ public function isKnownEmpty( ResourceLoaderContext $context ) {
+ return $this->isKnownEmpty;
+ }
public function enableModuleContentVersion() {
return true;
public static function provideMakeResourceLoaderLink() {
// @codingStandardsIgnoreStart Generic.Files.LineLength
return [
- // Load module script only
+ // Single only=scripts load
[
[ 'test.foo', ResourceLoaderModule::TYPE_SCRIPTS ],
"<script>(window.RLQ=window.RLQ||[]).push(function(){"
. 'mw.loader.load("http://127.0.0.1:8080/w/load.php?debug=false\u0026lang=en\u0026modules=test.foo\u0026only=scripts\u0026skin=fallback");'
. "});</script>"
],
- [
- // Don't condition wrap raw modules (like the startup module)
- [ 'test.raw', ResourceLoaderModule::TYPE_SCRIPTS ],
- '<script async="" src="http://127.0.0.1:8080/w/load.php?debug=false&lang=en&modules=test.raw&only=scripts&skin=fallback"></script>'
- ],
- // Load module styles only
- // This also tests the order the modules are put into the url
+ // Multiple only=styles load
[
[ [ 'test.baz', 'test.foo', 'test.bar' ], ResourceLoaderModule::TYPE_STYLES ],
'<link rel="stylesheet" href="http://127.0.0.1:8080/w/load.php?debug=false&lang=en&modules=test.bar%2Cbaz%2Cfoo&only=styles&skin=fallback"/>'
],
- // Load private module (only=scripts)
+ // Private embed (only=scripts)
[
[ 'test.quux', ResourceLoaderModule::TYPE_SCRIPTS ],
"<script>(window.RLQ=window.RLQ||[]).push(function(){"
. "mw.test.baz({token:123});mw.loader.state({\"test.quux\":\"ready\"});"
. "});</script>"
],
- // Load private module (combined)
- [
- [ 'test.quux', ResourceLoaderModule::TYPE_COMBINED ],
- "<script>(window.RLQ=window.RLQ||[]).push(function(){"
- . "mw.loader.implement(\"test.quux\",function($,jQuery,require,module){"
- . "mw.test.baz({token:123});},{\"css\":[\".mw-icon{transition:none}"
- . "\"]});});</script>"
- ],
- // Load no modules
- [
- [ [], ResourceLoaderModule::TYPE_COMBINED ],
- '',
- ],
- // noscript group
- [
- [ 'test.noscript', ResourceLoaderModule::TYPE_STYLES ],
- '<noscript><link rel="stylesheet" href="http://127.0.0.1:8080/w/load.php?debug=false&lang=en&modules=test.noscript&only=styles&skin=fallback"/></noscript>'
- ],
- // Load two modules in separate groups
- [
- [ [ 'test.group.foo', 'test.group.bar' ], ResourceLoaderModule::TYPE_COMBINED ],
- "<script>(window.RLQ=window.RLQ||[]).push(function(){"
- . 'mw.loader.load("http://127.0.0.1:8080/w/load.php?debug=false\u0026lang=en\u0026modules=test.group.bar\u0026skin=fallback");'
- . "});</script>\n"
- . "<script>(window.RLQ=window.RLQ||[]).push(function(){"
- . 'mw.loader.load("http://127.0.0.1:8080/w/load.php?debug=false\u0026lang=en\u0026modules=test.group.foo\u0026skin=fallback");'
- . "});</script>"
- ],
];
// @codingStandardsIgnoreEnd
}
/**
+ * See ResourceLoaderClientHtmlTest for full coverage.
+ *
* @dataProvider provideMakeResourceLoaderLink
* @covers OutputPage::makeResourceLoaderLink
- * @covers ResourceLoader::makeLoaderImplementScript
- * @covers ResourceLoader::makeModuleResponse
- * @covers ResourceLoader::makeInlineScript
- * @covers ResourceLoader::makeLoaderStateScript
- * @covers ResourceLoader::createLoaderURL
*/
public function testMakeResourceLoaderLink( $args, $expectedHtml ) {
$this->setMwGlobals( [
'styles' => '/* pref-animate=off */ .mw-icon { transition: none; }',
'group' => 'private',
] ),
- 'test.raw' => new ResourceLoaderTestModule( [
- 'script' => 'mw.test.baz( { token: 123 } );',
- 'isRaw' => true,
- ] ),
- 'test.noscript' => new ResourceLoaderTestModule( [
- 'styles' => '.mw-test-noscript { content: "style"; }',
- 'group' => 'noscript',
- ] ),
- 'test.group.bar' => new ResourceLoaderTestModule( [
- 'styles' => '.mw-group-bar { content: "style"; }',
- 'group' => 'bar',
- ] ),
- 'test.group.foo' => new ResourceLoaderTestModule( [
- 'styles' => '.mw-group-foo { content: "style"; }',
- 'group' => 'foo',
- ] ),
] );
$links = $method->invokeArgs( $out, $args );
- $actualHtml = implode( "\n", $links['html'] );
+ $actualHtml = strval( $links );
$this->assertEquals( $expectedHtml, $actualHtml );
}
--- /dev/null
+<?php
+
+/**
+ * @group ResourceLoader
+ */
+class ResourceLoaderClientHtmlTest extends PHPUnit_Framework_TestCase {
+
+ protected static function makeContext( $extraQuery = [] ) {
+ $conf = new HashConfig( [
+ 'ResourceLoaderSources' => [],
+ 'ResourceModuleSkinStyles' => [],
+ 'ResourceModules' => [],
+ 'EnableJavaScriptTest' => false,
+ 'ResourceLoaderDebug' => false,
+ 'LoadScript' => '/w/load.php',
+ ] );
+ return new ResourceLoaderContext(
+ new ResourceLoader( $conf ),
+ new FauxRequest( array_merge( [
+ 'lang' => 'nl',
+ 'skin' => 'fallback',
+ 'user' => 'Example',
+ 'target' => 'phpunit',
+ ], $extraQuery ) )
+ );
+ }
+
+ protected static function makeModule( array $options = [] ) {
+ return new ResourceLoaderTestModule( $options );
+ }
+
+ protected static function makeSampleModules() {
+ $modules = [
+ 'test' => [],
+ 'test.top' => [ 'position' => 'top' ],
+ 'test.private.top' => [ 'group' => 'private', 'position' => 'top' ],
+ 'test.private.bottom' => [ 'group' => 'private', 'position' => 'bottom' ],
+
+ 'test.styles.pure' => [ 'type' => ResourceLoaderModule::LOAD_STYLES ],
+ 'test.styles.mixed' => [],
+ 'test.styles.noscript' => [ 'group' => 'noscript', 'type' => ResourceLoaderModule::LOAD_STYLES ],
+ 'test.styles.mixed.user' => [ 'group' => 'user' ],
+ 'test.styles.mixed.user.empty' => [ 'group' => 'user', 'isKnownEmpty' => true ],
+ 'test.styles.private' => [ 'group' => 'private', 'styles' => '.private{}' ],
+
+ 'test.scripts' => [],
+ 'test.scripts.top' => [ 'position' => 'top' ],
+ 'test.scripts.mixed.user' => [ 'group' => 'user' ],
+ 'test.scripts.mixed.user.empty' => [ 'group' => 'user', 'isKnownEmpty' => true ],
+ 'test.scripts.raw' => [ 'isRaw' => true ],
+ ];
+ return array_map( function ( $options ) {
+ return self::makeModule( $options );
+ }, $modules );
+ }
+
+ /**
+ * @covers ResourceLoaderClientHtml::getDocumentAttributes
+ */
+ public function testGetDocumentAttributes() {
+ $client = new ResourceLoaderClientHtml( self::makeContext() );
+ $this->assertInternalType( 'array', $client->getDocumentAttributes() );
+ }
+
+ /**
+ * @covers ResourceLoaderClientHtml::__construct
+ * @covers ResourceLoaderClientHtml::setModules
+ * @covers ResourceLoaderClientHtml::setModuleStyles
+ * @covers ResourceLoaderClientHtml::setModuleScripts
+ * @covers ResourceLoaderClientHtml::getData
+ * @covers ResourceLoaderClientHtml::getContext
+ */
+ public function testGetData() {
+ $context = self::makeContext();
+ $context->getResourceLoader()->register( self::makeSampleModules() );
+
+ $client = new ResourceLoaderClientHtml( $context );
+ $client->setModules( [
+ 'test',
+ 'test.private.bottom',
+ 'test.private.top',
+ 'test.top',
+ 'test.unregistered',
+ ] );
+ $client->setModuleStyles( [
+ 'test.styles.mixed',
+ 'test.styles.mixed.user.empty',
+ 'test.styles.private',
+ 'test.styles.pure',
+ 'test.unregistered.styles',
+ ] );
+ $client->setModuleScripts( [
+ 'test.scripts',
+ 'test.scripts.mixed.user.empty',
+ 'test.scripts.top',
+ 'test.unregistered.scripts',
+ ] );
+
+ $expected = [
+ 'states' => [
+ 'test.private.top' => 'loading',
+ 'test.private.bottom' => 'loading',
+ 'test.styles.pure' => 'ready',
+ 'test.styles.mixed.user.empty' => 'ready',
+ 'test.styles.private' => 'ready',
+ 'test.scripts' => 'loading',
+ 'test.scripts.top' => 'loading',
+ 'test.scripts.mixed.user.empty' => 'ready',
+ ],
+ 'general' => [
+ 'top' => [ 'test.top' ],
+ 'bottom' => [ 'test' ],
+ ],
+ 'styles' => [
+ 'test.styles.mixed',
+ 'test.styles.pure',
+ ],
+ 'scripts' => [
+ 'top' => [ 'test.scripts.top' ],
+ 'bottom' => [ 'test.scripts' ],
+ ],
+ 'embed' => [
+ 'styles' => [ 'test.styles.private' ],
+ 'general' => [
+ 'top' => [ 'test.private.top' ],
+ 'bottom' => [ 'test.private.bottom' ],
+ ],
+ ],
+ ];
+
+ $access = TestingAccessWrapper::newFromObject( $client );
+ $this->assertEquals( $expected, $access->getData() );
+ }
+
+ /**
+ * @covers ResourceLoaderClientHtml::setConfig
+ * @covers ResourceLoaderClientHtml::setExemptStates
+ * @covers ResourceLoaderClientHtml::getHeadHtml
+ * @covers ResourceLoaderClientHtml::getLoad
+ * @covers ResourceLoader::makeLoaderStateScript
+ */
+ public function testGetHeadHtml() {
+ $context = self::makeContext();
+ $context->getResourceLoader()->register( self::makeSampleModules() );
+
+ $client = new ResourceLoaderClientHtml( $context );
+ $client->setConfig( [ 'key' => 'value' ] );
+ $client->setModules( [
+ 'test.top',
+ 'test.private.top',
+ ] );
+ $client->setModuleStyles( [
+ 'test.styles.pure',
+ 'test.styles.private',
+ ] );
+ $client->setModuleScripts( [
+ 'test.scripts.top',
+ ] );
+ $client->setExemptStates( [
+ 'test.exempt' => 'ready',
+ ] );
+
+ // @codingStandardsIgnoreStart Generic.Files.LineLength
+ $expected = '<script>document.documentElement.className = document.documentElement.className.replace( /(^|\s)client-nojs(\s|$)/, "$1client-js$2" );</script>' . "\n"
+ . '<script>(window.RLQ=window.RLQ||[]).push(function(){'
+ . 'mw.config.set({"key":"value"});'
+ . 'mw.loader.state({"test.exempt":"ready","test.private.top":"loading","test.styles.pure":"ready","test.styles.private":"ready","test.scripts.top":"loading"});'
+ . 'mw.loader.implement("test.private.top",function($,jQuery,require,module){},{"css":[]});'
+ . 'mw.loader.load(["test.top"]);'
+ . 'mw.loader.load("/w/load.php?debug=false\u0026lang=nl\u0026modules=test.scripts.top\u0026only=scripts\u0026skin=fallback");'
+ . '});</script>' . "\n"
+ . '<link rel="stylesheet" href="/w/load.php?debug=false&lang=nl&modules=test.styles.pure&only=styles&skin=fallback"/>' . "\n"
+ . '<style>.private{}</style>' . "\n"
+ . '<script async="" src="/w/load.php?debug=false&lang=nl&modules=startup&only=scripts&skin=fallback"></script>';
+ // @codingStandardsIgnoreEnd
+
+ $this->assertEquals( $expected, $client->getHeadHtml() );
+ }
+
+ /**
+ * @covers ResourceLoaderClientHtml::getBodyHtml
+ * @covers ResourceLoaderClientHtml::getLoad
+ */
+ public function testGetBodyHtml() {
+ $context = self::makeContext();
+ $context->getResourceLoader()->register( self::makeSampleModules() );
+
+ $client = new ResourceLoaderClientHtml( $context );
+ $client->setConfig( [ 'key' => 'value' ] );
+ $client->setModules( [
+ 'test',
+ 'test.private.bottom',
+ ] );
+ $client->setModuleScripts( [
+ 'test.scripts',
+ ] );
+
+ // @codingStandardsIgnoreStart Generic.Files.LineLength
+ $expected = '<script>(window.RLQ=window.RLQ||[]).push(function(){'
+ . 'mw.loader.implement("test.private.bottom",function($,jQuery,require,module){},{"css":[]});'
+ . 'mw.loader.load("/w/load.php?debug=false\u0026lang=nl\u0026modules=test.scripts\u0026only=scripts\u0026skin=fallback");'
+ . 'mw.loader.load(["test"]);'
+ . '});</script>';
+ // @codingStandardsIgnoreEnd
+
+ $this->assertEquals( $expected, $client->getBodyHtml() );
+ }
+
+ public static function provideMakeLoad() {
+ return [
+ // @codingStandardsIgnoreStart Generic.Files.LineLength
+ [
+ 'context' => [],
+ 'modules' => [ 'test.unknown' ],
+ 'only' => ResourceLoaderModule::TYPE_STYLES,
+ 'output' => '',
+ ],
+ [
+ 'context' => [],
+ 'modules' => [ 'test.styles.private' ],
+ 'only' => ResourceLoaderModule::TYPE_STYLES,
+ 'output' => '<style>.private{}</style>',
+ ],
+ [
+ 'context' => [],
+ 'modules' => [ 'test.private.top' ],
+ 'only' => ResourceLoaderModule::TYPE_COMBINED,
+ 'output' => '<script>(window.RLQ=window.RLQ||[]).push(function(){mw.loader.implement("test.private.top",function($,jQuery,require,module){},{"css":[]});});</script>',
+ ],
+ [
+ 'context' => [],
+ // Eg. startup module
+ 'modules' => [ 'test.scripts.raw' ],
+ 'only' => ResourceLoaderModule::TYPE_SCRIPTS,
+ 'output' => '<script async="" src="/w/load.php?debug=false&lang=nl&modules=test.scripts.raw&only=scripts&skin=fallback"></script>',
+ ],
+ [
+ 'context' => [],
+ 'modules' => [ 'test.scripts.mixed.user' ],
+ 'only' => ResourceLoaderModule::TYPE_SCRIPTS,
+ 'output' => '<script>(window.RLQ=window.RLQ||[]).push(function(){mw.loader.load("/w/load.php?debug=false\u0026lang=nl\u0026modules=test.scripts.mixed.user\u0026only=scripts\u0026skin=fallback\u0026user=Example\u0026version=0a56zyi");});</script>',
+ ],
+ [
+ 'context' => [ 'debug' => true ],
+ 'modules' => [ 'test.styles.pure', 'test.styles.mixed' ],
+ 'only' => ResourceLoaderModule::TYPE_STYLES,
+ 'output' => '<link rel="stylesheet" href="/w/load.php?debug=true&lang=nl&modules=test.styles.pure&only=styles&skin=fallback"/>' . "\n"
+ . '<link rel="stylesheet" href="/w/load.php?debug=true&lang=nl&modules=test.styles.mixed&only=styles&skin=fallback"/>',
+ ],
+ [
+ 'context' => [],
+ 'modules' => [ 'test.styles.noscript' ],
+ 'only' => ResourceLoaderModule::TYPE_STYLES,
+ 'output' => '<noscript><link rel="stylesheet" href="/w/load.php?debug=false&lang=nl&modules=test.styles.noscript&only=styles&skin=fallback"/></noscript>',
+ ],
+ // @codingStandardsIgnoreEnd
+ ];
+ }
+
+ /**
+ * @dataProvider provideMakeLoad
+ * @covers ResourceLoaderClientHtml::makeLoad
+ * @covers ResourceLoaderClientHtml::makeContext
+ * @covers ResourceLoader::makeModuleResponse
+ * @covers ResourceLoaderModule::getModuleContent
+ * @covers ResourceLoader::getCombinedVersion
+ * @covers ResourceLoader::createLoaderURL
+ * @covers ResourceLoader::createLoaderQuery
+ * @covers ResourceLoader::makeLoaderQuery
+ * @covers ResourceLoader::makeInlineScript
+ */
+ public function testMakeLoad( array $extraQuery, array $modules, $type, $expected ) {
+ $context = self::makeContext( $extraQuery );
+ $context->getResourceLoader()->register( self::makeSampleModules() );
+ $actual = ResourceLoaderClientHtml::makeLoad( $context, $modules, $type );
+ $this->assertEquals( $expected, (string)$actual );
+ }
+}