Generalize ResourceLoader 'excludepage' functionality
authorBrad Jorsch <bjorsch@wikimedia.org>
Tue, 28 Feb 2017 20:52:17 +0000 (15:52 -0500)
committerKrinkle <krinklemail@gmail.com>
Wed, 25 Apr 2018 00:37:08 +0000 (00:37 +0000)
There has long been a hack for previewing edits to user JS/CSS, where
OutputPage would pass an 'excludepage' parameter to
ResourceLoaderUserModule to tell it not to load one particular page and
would instead embed that page statically. That's nice, but there are
other places where we could use the same thing.

This patch generalizes it:
* DerivativeResourceLoaderContext may now contain a callback for mapping
  titles to replacement Content objects.
* ResourceLoaderWikiModule::getContent() uses the overrides, and
  requests embedding when they're used. All subclasses in Gerrit should
  pick it up automatically.
* OutputPage gains methods for callers to add to the override mapping,
  which it passes on to RL. It loses a bunch of the special casing it
  had for the 'user' and 'user.styles' modules.
* EditPage sets the overrides on OutputPage when doing the preview, as
  does ApiParse for prop=headhtml. TemplateSandbox does too in I83fa0856.
* OutputPage::userCanPreview() gets less specific to editing user CSS
  and JS, since RL now handles the embedding based on the actual
  modules' dependencies and EditPage only requests it on preview.

ApiParse also gets a new hook to support TemplateSandbox's API
integration (used in I83fa0856).

Bug: T112474
Change-Id: Ib9d2ce42931c1de8372e231314a1f672d7e2ac0e

15 files changed:
RELEASE-NOTES-1.32
docs/hooks.txt
includes/EditPage.php
includes/OutputPage.php
includes/api/ApiParse.php
includes/resourceloader/DerivativeResourceLoaderContext.php
includes/resourceloader/ResourceLoaderClientHtml.php
includes/resourceloader/ResourceLoaderContext.php
includes/resourceloader/ResourceLoaderUserModule.php
includes/resourceloader/ResourceLoaderUserStylesModule.php
includes/resourceloader/ResourceLoaderWikiModule.php
tests/phpunit/includes/OutputPageTest.php
tests/phpunit/includes/resourceloader/DerivativeResourceLoaderContextTest.php
tests/phpunit/includes/resourceloader/ResourceLoaderContextTest.php
tests/phpunit/includes/resourceloader/ResourceLoaderWikiModuleTest.php

index 2eb3679..366182b 100644 (file)
@@ -14,7 +14,9 @@ production.
 * …
 
 === 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 ===
 * …
@@ -35,7 +37,7 @@ production.
 * …
 
 === Action API internal changes in 1.32 ===
-* 
+* Added 'ApiParseMakeOutputPage' hook.
 
 === Languages updated in 1.32 ===
 MediaWiki supports over 350 languages. Many localisations are updated
index d932148..b38bd66 100644 (file)
@@ -467,6 +467,12 @@ can alter or append to the array.
       (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
index a1d9ae8..fcf3d49 100644 (file)
@@ -3893,6 +3893,9 @@ ERROR;
                        $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() );
index 37527cf..56df0f0 100644 (file)
@@ -20,6 +20,7 @@
  * @file
  */
 
+use MediaWiki\Linker\LinkTarget;
 use MediaWiki\Logger\LoggerFactory;
 use MediaWiki\MediaWikiServices;
 use MediaWiki\Session\SessionManager;
@@ -155,9 +156,6 @@ class OutputPage extends ContextSource {
        /** @var ResourceLoaderContext */
        private $rlClientContext;
 
-       /** @var string */
-       private $rlUserModuleState;
-
        /** @var array */
        private $rlExemptStyleModules;
 
@@ -295,6 +293,12 @@ class OutputPage extends ContextSource {
        /** @var array Profiling data */
        private $limitReportJSData = [];
 
+       /** @var array Map Title to Content */
+       private $contentOverrides = [];
+
+       /** @var callable[] */
+       private $contentOverrideCallbacks = [];
+
        /**
         * Link: header contents
         */
@@ -622,6 +626,39 @@ class OutputPage extends ContextSource {
                $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
         *
@@ -2723,6 +2760,18 @@ class OutputPage extends ContextSource {
                                $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;
        }
@@ -2743,6 +2792,7 @@ class OutputPage extends ContextSource {
                        $context = $this->getRlClientContext();
                        $rl = $this->getResourceLoader();
                        $this->addModules( [
+                               'user',
                                'user.options',
                                'user.tokens',
                        ] );
@@ -2771,11 +2821,6 @@ class OutputPage extends ContextSource {
                                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';
@@ -2791,18 +2836,6 @@ class OutputPage extends ContextSource {
                        );
                        $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(),
                        ] );
@@ -2959,20 +2992,6 @@ class OutputPage extends ContextSource {
                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.
@@ -2986,40 +3005,6 @@ class OutputPage extends ContextSource {
                // 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(
@@ -3193,7 +3178,7 @@ class OutputPage extends ContextSource {
 
        /**
         * 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.
@@ -3204,7 +3189,6 @@ class OutputPage extends ContextSource {
                $request = $this->getRequest();
                if (
                        $request->getVal( 'action' ) !== 'submit' ||
-                       !$request->getCheck( 'wpPreview' ) ||
                        !$request->wasPosted()
                ) {
                        return false;
@@ -3221,17 +3205,6 @@ class OutputPage extends ContextSource {
                }
 
                $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;
@@ -3570,29 +3543,10 @@ class OutputPage extends ContextSource {
         * @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:
index 099d278..05b4289 100644 (file)
@@ -314,6 +314,9 @@ class ApiParse extends ApiBase {
 
                        $outputPage = new OutputPage( $context );
                        $outputPage->addParserOutputMetadata( $p_result );
+                       if ( $this->content ) {
+                               $outputPage->addContentOverride( $titleObj, $this->content );
+                       }
                        $context->setOutput( $outputPage );
 
                        if ( $skin ) {
@@ -324,6 +327,8 @@ class ApiParse extends ApiBase {
                                        $outputPage->addModules( $group );
                                }
                        }
+
+                       Hooks::run( 'ApiParseMakeOutputPage', [ $this, $outputPage ] );
                }
 
                if ( !is_null( $oldid ) ) {
index 418d17f..b11bd6f 100644 (file)
@@ -44,6 +44,7 @@ class DerivativeResourceLoaderContext extends ResourceLoaderContext {
        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;
@@ -196,4 +197,21 @@ class DerivativeResourceLoaderContext extends ResourceLoaderContext {
                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;
+       }
+
 }
index 6c4a5d0..bb8ab32 100644 (file)
@@ -358,7 +358,9 @@ class ResourceLoaderClientHtml {
                }
                $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;
        }
 
        /**
index c4e9884..d41198a 100644 (file)
@@ -341,6 +341,22 @@ class ResourceLoaderContext implements MessageLocalizer {
                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
         */
index 8e21381..e747373 100644 (file)
@@ -58,8 +58,9 @@ class ResourceLoaderUserModule extends ResourceLoaderWikiModule {
                        }
                }
 
-               // 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] );
index 8d8e008..69e8a97 100644 (file)
@@ -58,8 +58,9 @@ class ResourceLoaderUserStylesModule extends ResourceLoaderWikiModule {
                        }
                }
 
-               // 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] );
index e87d28a..085244a 100644 (file)
@@ -157,24 +157,22 @@ class ResourceLoaderWikiModule extends ResourceLoaderModule {
 
        /**
         * @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 ) ) {
@@ -183,31 +181,81 @@ class ResourceLoaderWikiModule extends ResourceLoaderModule {
                        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
@@ -218,7 +266,7 @@ class ResourceLoaderWikiModule extends ResourceLoaderModule {
                        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";
@@ -238,7 +286,7 @@ class ResourceLoaderWikiModule extends ResourceLoaderModule {
                                continue;
                        }
                        $media = isset( $options['media'] ) ? $options['media'] : 'all';
-                       $style = $this->getContent( $titleText );
+                       $style = $this->getContent( $titleText, $context );
                        if ( strval( $style ) === '' ) {
                                continue;
                        }
@@ -339,7 +387,26 @@ class ResourceLoaderWikiModule extends ResourceLoaderModule {
                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__ ) {
index 0a657d8..a5a7364 100644 (file)
@@ -441,11 +441,8 @@ class OutputPageTest extends MediaWikiTestCase {
                $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( [] );
index e4f58eb..97ffd94 100644 (file)
@@ -119,6 +119,21 @@ class DerivativeResourceLoaderContextTest extends PHPUnit\Framework\TestCase {
                $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 );
index b226ee1..1b7e0fe 100644 (file)
@@ -31,6 +31,7 @@ class ResourceLoaderContextTest extends PHPUnit\Framework\TestCase {
                $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() );
index db4494e..7a47a63 100644 (file)
@@ -351,38 +351,82 @@ class ResourceLoaderWikiModuleTest extends ResourceLoaderTestCase {
                $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(