* …
=== New features in 1.32 ===
-* …
+* (T112474) Generalized the ResourceLoader mechanism for overriding modules
+ using a particular page during edit previews.
+* Added 'ApiParseMakeOutputPage' hook.
=== External library changes in 1.32 ===
* …
* …
=== Action API internal changes in 1.32 ===
-* …
+* Added 'ApiParseMakeOutputPage' hook.
=== Languages updated in 1.32 ===
MediaWiki supports over 350 languages. Many localisations are updated
(url), 'width', 'height', 'alt', 'align'.
- url: Url for the given title.
+'ApiParseMakeOutputPage': Called when preparing the OutputPage object for
+ApiParse. This is mainly intended for calling OutputPage::addContentOverride()
+or OutputPage::addContentOverrideCallback().
+$module: ApiBase (which is also a ContextSource)
+$output: OutputPage
+
'ApiQuery::moduleManager': Called when ApiQuery has finished initializing its
module manager. Can be used to conditionally register API query modules.
$moduleManager: ApiModuleManager Module manager instance
$previewHTML = $parserResult['html'];
$this->mParserOutput = $parserOutput;
$out->addParserOutputMetadata( $parserOutput );
+ if ( $out->userCanPreview() ) {
+ $out->addContentOverride( $this->getTitle(), $content );
+ }
if ( count( $parserOutput->getWarnings() ) ) {
$note .= "\n\n" . implode( "\n\n", $parserOutput->getWarnings() );
* @file
*/
+use MediaWiki\Linker\LinkTarget;
use MediaWiki\Logger\LoggerFactory;
use MediaWiki\MediaWikiServices;
use MediaWiki\Session\SessionManager;
/** @var ResourceLoaderContext */
private $rlClientContext;
- /** @var string */
- private $rlUserModuleState;
-
/** @var array */
private $rlExemptStyleModules;
/** @var array Profiling data */
private $limitReportJSData = [];
+ /** @var array Map Title to Content */
+ private $contentOverrides = [];
+
+ /** @var callable[] */
+ private $contentOverrideCallbacks = [];
+
/**
* Link: header contents
*/
$this->mTarget = $target;
}
+ /**
+ * Add a mapping from a LinkTarget to a Content, for things like page preview.
+ * @see self::addContentOverrideCallback()
+ * @since 1.32
+ * @param LinkTarget $target
+ * @param Content $content
+ */
+ public function addContentOverride( LinkTarget $target, Content $content ) {
+ if ( !$this->contentOverrides ) {
+ // Register a callback for $this->contentOverrides on the first call
+ $this->addContentOverrideCallback( function ( LinkTarget $target ) {
+ $key = $target->getNamespace() . ':' . $target->getDBkey();
+ return isset( $this->contentOverrides[$key] )
+ ? $this->contentOverrides[$key]
+ : null;
+ } );
+ }
+
+ $key = $target->getNamespace() . ':' . $target->getDBkey();
+ $this->contentOverrides[$key] = $content;
+ }
+
+ /**
+ * Add a callback for mapping from a Title to a Content object, for things
+ * like page preview.
+ * @see ResourceLoaderContext::getContentOverrideCallback()
+ * @since 1.32
+ * @param callable $callback
+ */
+ public function addContentOverrideCallback( callable $callback ) {
+ $this->contentOverrideCallbacks[] = $callback;
+ }
+
/**
* Get an array of head items
*
$this->getResourceLoader(),
new FauxRequest( $query )
);
+ if ( $this->contentOverrideCallbacks ) {
+ $this->rlClientContext = new DerivativeResourceLoaderContext( $this->rlClientContext );
+ $this->rlClientContext->setContentOverrideCallback( function ( Title $title ) {
+ foreach ( $this->contentOverrideCallbacks as $callback ) {
+ $content = call_user_func( $callback, $title );
+ if ( $content !== null ) {
+ return $content;
+ }
+ }
+ return null;
+ } );
+ }
}
return $this->rlClientContext;
}
$context = $this->getRlClientContext();
$rl = $this->getResourceLoader();
$this->addModules( [
+ 'user',
'user.options',
'user.tokens',
] );
function ( $name ) use ( $rl, $context, &$exemptGroups, &$exemptStates ) {
$module = $rl->getModule( $name );
if ( $module ) {
- if ( $name === 'user.styles' && $this->isUserCssPreview() ) {
- $exemptStates[$name] = 'ready';
- // Special case in buildExemptModules()
- return false;
- }
$group = $module->getGroup();
if ( isset( $exemptGroups[$group] ) ) {
$exemptStates[$name] = 'ready';
);
$this->rlExemptStyleModules = $exemptGroups;
- $isUserModuleFiltered = !$this->filterModules( [ 'user' ] );
- // If this page filters out 'user', makeResourceLoaderLink will drop it.
- // Avoid indefinite "loading" state or untrue "ready" state (T145368).
- if ( !$isUserModuleFiltered ) {
- // Manually handled by getBottomScripts()
- $userModule = $rl->getModule( 'user' );
- $userState = $userModule->isKnownEmpty( $context ) && !$this->isUserJsPreview()
- ? 'ready'
- : 'loading';
- $this->rlUserModuleState = $exemptStates['user'] = $userState;
- }
-
$rlClient = new ResourceLoaderClientHtml( $context, [
'target' => $this->getTarget(),
] );
return WrappedString::join( "\n", $chunks );
}
- private function isUserJsPreview() {
- return $this->getConfig()->get( 'AllowUserJs' )
- && $this->getTitle()
- && $this->getTitle()->isUserJsConfigPage()
- && $this->userCanPreview();
- }
-
- protected function isUserCssPreview() {
- return $this->getConfig()->get( 'AllowUserCss' )
- && $this->getTitle()
- && $this->getTitle()->isUserCssConfigPage()
- && $this->userCanPreview();
- }
-
/**
* JS stuff to put at the bottom of the `<body>`.
* These are legacy scripts ($this->mScripts), and user JS.
// 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->isUserJsPreview() ) {
- $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 );
- }
- }
-
if ( $this->limitReportJSData ) {
$chunks[] = ResourceLoader::makeInlineScript(
ResourceLoader::makeConfigSetScript(
/**
* To make it harder for someone to slip a user a fake
- * user-JavaScript or user-CSS preview, a random token
+ * JavaScript or CSS preview, a random token
* is associated with the login session. If it's not
* passed back with the preview request, we won't render
* the code.
$request = $this->getRequest();
if (
$request->getVal( 'action' ) !== 'submit' ||
- !$request->getCheck( 'wpPreview' ) ||
!$request->wasPosted()
) {
return false;
}
$title = $this->getTitle();
- if (
- !$title->isUserJsConfigPage()
- && !$title->isUserCssConfigPage()
- ) {
- return false;
- }
- if ( !$title->isSubpageOf( $user->getUserPage() ) ) {
- // Don't execute another user's CSS or JS on preview (T85855)
- return false;
- }
-
$errors = $title->getUserPermissionsErrors( 'edit', $user );
if ( count( $errors ) !== 0 ) {
return false;
* @return string|WrappedStringList HTML
*/
protected function buildExemptModules() {
- global $wgContLang;
-
$chunks = [];
// Things that go after the ResourceLoaderDynamicStyles marker
$append = [];
- // Exempt 'user' styles module (may need 'excludepages' for live preview)
- if ( $this->isUserCssPreview() ) {
- $append[] = $this->makeResourceLoaderLink(
- 'user.styles',
- ResourceLoaderModule::TYPE_STYLES,
- [ 'excludepage' => $this->getTitle()->getPrefixedDBkey() ]
- );
-
- // 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 );
- }
- $append[] = Html::inlineStyle( $previewedCSS );
- }
-
// We want site, private and user styles to override dynamically added styles from
// general modules, but we want dynamically added styles to override statically added
// style modules. So the order has to be:
$outputPage = new OutputPage( $context );
$outputPage->addParserOutputMetadata( $p_result );
+ if ( $this->content ) {
+ $outputPage->addContentOverride( $titleObj, $this->content );
+ }
$context->setOutput( $outputPage );
if ( $skin ) {
$outputPage->addModules( $group );
}
}
+
+ Hooks::run( 'ApiParseMakeOutputPage', [ $this, $outputPage ] );
}
if ( !is_null( $oldid ) ) {
protected $only = self::INHERIT_VALUE;
protected $version = self::INHERIT_VALUE;
protected $raw = self::INHERIT_VALUE;
+ protected $contentOverrideCallback = self::INHERIT_VALUE;
public function __construct( ResourceLoaderContext $context ) {
$this->context = $context;
return $this->context->getResourceLoader();
}
+ public function getContentOverrideCallback() {
+ if ( $this->contentOverrideCallback === self::INHERIT_VALUE ) {
+ return $this->context->getContentOverrideCallback();
+ }
+ return $this->contentOverrideCallback;
+ }
+
+ /**
+ * @see self::getContentOverrideCallback
+ * @since 1.32
+ * @param callable|null|int $callback As per self::getContentOverrideCallback,
+ * or self::INHERIT_VALUE
+ */
+ public function setContentOverrideCallback( $callback ) {
+ $this->contentOverrideCallback = $callback;
+ }
+
}
}
$context = new ResourceLoaderContext( $mainContext->getResourceLoader(), $req );
// Allow caller to setVersion() and setModules()
- return new DerivativeResourceLoaderContext( $context );
+ $ret = new DerivativeResourceLoaderContext( $context );
+ $ret->setContentOverrideCallback( $mainContext->getContentOverrideCallback() );
+ return $ret;
}
/**
return $this->imageObj;
}
+ /**
+ * Return the replaced-content mapping callback
+ *
+ * When editing a page that's used to generate the scripts or styles of a
+ * ResourceLoaderWikiModule, a preview should use the to-be-saved version of
+ * the page rather than the current version in the database. A context
+ * supporting such previews should return a callback to return these
+ * mappings here.
+ *
+ * @since 1.32
+ * @return callable|null Signature is `Content|null func( Title $t )`
+ */
+ public function getContentOverrideCallback() {
+ return null;
+ }
+
/**
* @return bool
*/
}
}
- // Hack for T28283: Allow excluding pages for preview on a CSS/JS page.
- // The excludepage parameter is set by OutputPage.
+ // This is obsolete since 1.32 (T112474). It was formerly used by
+ // OutputPage to implement previewing of user CSS and JS.
+ // @todo: Remove it once we're sure nothing else is using the parameter
$excludepage = $context->getRequest()->getVal( 'excludepage' );
if ( isset( $pages[$excludepage] ) ) {
unset( $pages[$excludepage] );
}
}
- // Hack for T28283: Allow excluding pages for preview on a CSS/JS page.
- // The excludepage parameter is set by OutputPage.
+ // This is obsolete since 1.32 (T112474). It was formerly used by
+ // OutputPage to implement previewing of user CSS and JS.
+ // @todo: Remove it once we're sure nothing else is using the parameter
$excludepage = $context->getRequest()->getVal( 'excludepage' );
if ( isset( $pages[$excludepage] ) ) {
unset( $pages[$excludepage] );
/**
* @param string $titleText
+ * @param ResourceLoaderContext|null $context (but passing null is deprecated)
* @return null|string
+ * @since 1.32 added the $context parameter
*/
- protected function getContent( $titleText ) {
+ protected function getContent( $titleText, ResourceLoaderContext $context = null ) {
$title = Title::newFromText( $titleText );
if ( !$title ) {
return null; // Bad title
}
- // If the page is a redirect, follow the redirect.
- if ( $title->isRedirect() ) {
- $content = $this->getContentObj( $title );
- $title = $content ? $content->getUltimateRedirectTarget() : null;
- if ( !$title ) {
- return null; // Dead redirect
- }
+ $content = $this->getContentObj( $title, $context );
+ if ( !$content ) {
+ return null; // No content found
}
- $handler = ContentHandler::getForTitle( $title );
+ $handler = $content->getContentHandler();
if ( $handler->isSupportedFormat( CONTENT_FORMAT_CSS ) ) {
$format = CONTENT_FORMAT_CSS;
} elseif ( $handler->isSupportedFormat( CONTENT_FORMAT_JAVASCRIPT ) ) {
return null; // Bad content model
}
- $content = $this->getContentObj( $title );
- if ( !$content ) {
- return null; // No content found
- }
-
return $content->serialize( $format );
}
/**
* @param Title $title
+ * @param ResourceLoaderContext|null $context (but passing null is deprecated)
+ * @param int|null $maxRedirects Maximum number of redirects to follow. If
+ * null, uses $wgMaxRedirects
* @return Content|null
+ * @since 1.32 added the $context and $maxRedirects parameters
*/
- protected function getContentObj( Title $title ) {
- $revision = Revision::newKnownCurrent( wfGetDB( DB_REPLICA ), $title );
- if ( !$revision ) {
- return null;
+ protected function getContentObj(
+ Title $title, ResourceLoaderContext $context = null, $maxRedirects = null
+ ) {
+ if ( $context === null ) {
+ wfDeprecated( __METHOD__ . ' without a ResourceLoader context', '1.32' );
}
- $content = $revision->getContent( Revision::RAW );
- if ( !$content ) {
- wfDebugLog( 'resourceloader', __METHOD__ . ': failed to load content of JS/CSS page!' );
- return null;
+
+ $overrideCallback = $context ? $context->getContentOverrideCallback() : null;
+ $content = $overrideCallback ? call_user_func( $overrideCallback, $title ) : null;
+ if ( $content ) {
+ if ( !$content instanceof Content ) {
+ $this->getLogger()->error(
+ 'Bad content override for "{title}" in ' . __METHOD__,
+ [ 'title' => $title->getPrefixedText() ]
+ );
+ return null;
+ }
+ } else {
+ $revision = Revision::newKnownCurrent( wfGetDB( DB_REPLICA ), $title );
+ if ( !$revision ) {
+ return null;
+ }
+ $content = $revision->getContent( Revision::RAW );
+
+ if ( !$content ) {
+ $this->getLogger()->error(
+ 'Failed to load content of JS/CSS page "{title}" in ' . __METHOD__,
+ [ 'title' => $title->getPrefixedText() ]
+ );
+ return null;
+ }
+ }
+
+ if ( $content && $content->isRedirect() ) {
+ if ( $maxRedirects === null ) {
+ $maxRedirects = $this->getConfig()->get( 'MaxRedirects' ) ?: 0;
+ }
+ if ( $maxRedirects > 0 ) {
+ $newTitle = $content->getRedirectTarget();
+ return $newTitle ? $this->getContentObj( $newTitle, $context, $maxRedirects - 1 ) : null;
+ }
}
+
return $content;
}
+ /**
+ * @param ResourceLoaderContext $context
+ * @return bool
+ */
+ public function shouldEmbedModule( ResourceLoaderContext $context ) {
+ $overrideCallback = $context->getContentOverrideCallback();
+ if ( $overrideCallback && $this->getSource() === 'local' ) {
+ foreach ( $this->getPages( $context ) as $page => $info ) {
+ $title = Title::newFromText( $page );
+ if ( $title && call_user_func( $overrideCallback, $title ) !== null ) {
+ return true;
+ }
+ }
+ }
+
+ return parent::shouldEmbedModule( $context );
+ }
+
/**
* @param ResourceLoaderContext $context
* @return string JavaScript code
if ( $options['type'] !== 'script' ) {
continue;
}
- $script = $this->getContent( $titleText );
+ $script = $this->getContent( $titleText, $context );
if ( strval( $script ) !== '' ) {
$script = $this->validateScriptFile( $titleText, $script );
$scripts .= ResourceLoader::makeComment( $titleText ) . $script . "\n";
continue;
}
$media = isset( $options['media'] ) ? $options['media'] : 'all';
- $style = $this->getContent( $titleText );
+ $style = $this->getContent( $titleText, $context );
if ( strval( $style ) === '' ) {
continue;
}
if ( !isset( $this->titleInfo[$batchKey] ) ) {
$this->titleInfo[$batchKey] = static::fetchTitleInfo( $dbr, $pageNames, __METHOD__ );
}
- return $this->titleInfo[$batchKey];
+
+ $titleInfo = $this->titleInfo[$batchKey];
+
+ // Override the title info from the overrides, if any
+ $overrideCallback = $context->getContentOverrideCallback();
+ if ( $overrideCallback ) {
+ foreach ( $pageNames as $page ) {
+ $title = Title::newFromText( $page );
+ $content = $title ? call_user_func( $overrideCallback, $title ) : null;
+ if ( $content !== null ) {
+ $titleInfo[$title->getPrefixedText()] = [
+ 'page_len' => $content->getSize(),
+ 'page_latest' => 'TBD', // None available
+ 'page_touched' => wfTimestamp( TS_MW ),
+ ];
+ }
+ }
+ }
+
+ return $titleInfo;
}
protected static function fetchTitleInfo( IDatabase $db, array $pages, $fname = __METHOD__ ) {
$ctx->setLanguage( 'en' );
$outputPage = $this->getMockBuilder( OutputPage::class )
->setConstructorArgs( [ $ctx ] )
- ->setMethods( [ 'isUserCssPreview', 'buildCssLinksArray' ] )
+ ->setMethods( [ 'buildCssLinksArray' ] )
->getMock();
- $outputPage->expects( $this->any() )
- ->method( 'isUserCssPreview' )
- ->willReturn( false );
$outputPage->expects( $this->any() )
->method( 'buildCssLinksArray' )
->willReturn( [] );
$this->assertEquals( $derived->getHash(), 'nl|fallback||Example|scripts|||||' );
}
+ public function testContentOverrides() {
+ $derived = new DerivativeResourceLoaderContext( self::getContext() );
+
+ $this->assertNull( $derived->getContentOverrideCallback() );
+
+ $override = function ( Title $t ) {
+ return null;
+ };
+ $derived->setContentOverrideCallback( $override );
+ $this->assertSame( $override, $derived->getContentOverrideCallback() );
+
+ $derived2 = new DerivativeResourceLoaderContext( $derived );
+ $this->assertSame( $override, $derived2->getContentOverrideCallback() );
+ }
+
public function testAccessors() {
$context = self::getContext();
$derived = new DerivativeResourceLoaderContext( $context );
$this->assertEquals( null, $ctx->getOnly() );
$this->assertEquals( 'fallback', $ctx->getSkin() );
$this->assertEquals( null, $ctx->getUser() );
+ $this->assertNull( $ctx->getContentOverrideCallback() );
// Misc
$this->assertEquals( 'ltr', $ctx->getDirection() );
$module = TestingAccessWrapper::newFromObject( $module );
$this->assertEquals(
$expected,
- $module->getContent( $titleText )
+ $module->getContent( $titleText, $context )
);
}
/**
* @covers ResourceLoaderWikiModule::getContent
+ * @covers ResourceLoaderWikiModule::getContentObj
+ * @covers ResourceLoaderWikiModule::shouldEmbedModule
+ */
+ public function testContentOverrides() {
+ $pages = [
+ 'MediaWiki:Common.css' => [ 'type' => 'style' ],
+ ];
+
+ $module = $this->getMockBuilder( TestResourceLoaderWikiModule::class )
+ ->setMethods( [ 'getPages' ] )
+ ->getMock();
+ $module->method( 'getPages' )->willReturn( $pages );
+
+ $rl = new EmptyResourceLoader();
+ $rl->register( 'testmodule', $module );
+ $context = new DerivativeResourceLoaderContext(
+ new ResourceLoaderContext( $rl, new FauxRequest() )
+ );
+ $context->setContentOverrideCallback( function ( Title $t ) {
+ if ( $t->getPrefixedText() === 'MediaWiki:Common.css' ) {
+ return new CssContent( '.override{}' );
+ }
+ return null;
+ } );
+
+ $this->assertTrue( $module->shouldEmbedModule( $context ) );
+ $this->assertEquals( [
+ 'all' => [
+ "/*\nMediaWiki:Common.css\n*/\n.override{}"
+ ]
+ ], $module->getStyles( $context ) );
+
+ $context->setContentOverrideCallback( function ( Title $t ) {
+ if ( $t->getPrefixedText() === 'MediaWiki:Skin.css' ) {
+ return new CssContent( '.override{}' );
+ }
+ return null;
+ } );
+ $this->assertFalse( $module->shouldEmbedModule( $context ) );
+ }
+
+ /**
+ * @covers ResourceLoaderWikiModule::getContent
+ * @covers ResourceLoaderWikiModule::getContentObj
*/
public function testGetContentForRedirects() {
// Set up context and module object
- $context = $this->getResourceLoaderContext( [], new EmptyResourceLoader );
+ $context = new DerivativeResourceLoaderContext(
+ $this->getResourceLoaderContext( [], new EmptyResourceLoader )
+ );
$module = $this->getMockBuilder( ResourceLoaderWikiModule::class )
- ->setMethods( [ 'getPages', 'getContentObj' ] )
+ ->setMethods( [ 'getPages' ] )
->getMock();
$module->expects( $this->any() )
->method( 'getPages' )
->will( $this->returnValue( [
'MediaWiki:Redirect.js' => [ 'type' => 'script' ]
] ) );
- $module->expects( $this->any() )
- ->method( 'getContentObj' )
- ->will( $this->returnCallback( function ( Title $title ) {
- if ( $title->getPrefixedText() === 'MediaWiki:Redirect.js' ) {
- $handler = new JavaScriptContentHandler();
- return $handler->makeRedirectContent(
- Title::makeTitle( NS_MEDIAWIKI, 'Target.js' )
- );
- } elseif ( $title->getPrefixedText() === 'MediaWiki:Target.js' ) {
- return new JavaScriptContent( 'target;' );
- } else {
- return null;
- }
- } ) );
+ $context->setContentOverrideCallback( function ( Title $title ) {
+ if ( $title->getPrefixedText() === 'MediaWiki:Redirect.js' ) {
+ $handler = new JavaScriptContentHandler();
+ return $handler->makeRedirectContent(
+ Title::makeTitle( NS_MEDIAWIKI, 'Target.js' )
+ );
+ } elseif ( $title->getPrefixedText() === 'MediaWiki:Target.js' ) {
+ return new JavaScriptContent( 'target;' );
+ } else {
+ return null;
+ }
+ } );
// Mock away Title's db queries with LinkCache
MediaWikiServices::getInstance()->getLinkCache()->addGoodLinkObj(