From b782a7e66ddff2c168b3f52a9cfe0cd86608b953 Mon Sep 17 00:00:00 2001 From: Petr Pchelko Date: Tue, 20 Jul 2021 18:03:59 -0700 Subject: [PATCH] Move Content::preSaveTransform to ContentHandler Create ContentTransformer to access ContentHandler::preSaveTransform through the service. Prepare object to hold a data that required for ContentHandler::preSaveTranform params. This will require making a semi-backwards-incompatible change no matter what, we don't really have a great way of hard-deprecating overriding methods. However, with the ContentHandler calling Content and Content calling ContentHandler, and with the ProxyContent trick to stop infinite recursion, it doesn't matter whether callers use Content or ContentHandler. This will allow us to naturally convert all callers. But won't really allow hard-deprecation. Bug: T287156 Change-Id: If6a2025868ceca3a3b6f11baec39695e47292e40 --- includes/EditPage.php | 22 +++++--- includes/MediaWikiServices.php | 9 ++++ includes/ServiceWiring.php | 6 +++ includes/Storage/DerivedPageDataUpdater.php | 18 +++++-- includes/Storage/PageUpdaterFactory.php | 10 +++- includes/content/AbstractContent.php | 10 +++- includes/content/Content.php | 1 + includes/content/ContentHandler.php | 32 ++++++++++++ includes/content/CssContent.php | 30 ----------- includes/content/CssContentHandler.php | 28 ++++++++++ includes/content/JavaScriptContent.php | 28 ---------- includes/content/JavaScriptContentHandler.php | 30 +++++++++++ includes/content/JsonContent.php | 18 ------- includes/content/JsonContentHandler.php | 18 +++++++ includes/content/TextContent.php | 21 -------- includes/content/TextContentHandler.php | 15 ++++++ .../content/Transform/ContentTransformer.php | 48 +++++++++++++++++ .../Transform/PreSaveTransformParams.php | 28 ++++++++++ .../Transform/PreSaveTransformParamsValue.php | 51 +++++++++++++++++++ includes/content/WikitextContent.php | 44 ++++------------ includes/content/WikitextContentHandler.php | 26 ++++++++++ .../includes/content/FallbackContentTest.php | 14 ++++- .../Transform/ContentTransformerTest.php | 31 +++++++++++ .../content/DummyContentHandlerForTesting.php | 4 ++ 24 files changed, 397 insertions(+), 145 deletions(-) create mode 100644 includes/content/Transform/ContentTransformer.php create mode 100644 includes/content/Transform/PreSaveTransformParams.php create mode 100644 includes/content/Transform/PreSaveTransformParamsValue.php create mode 100644 tests/phpunit/includes/content/Transform/ContentTransformerTest.php diff --git a/includes/EditPage.php b/includes/EditPage.php index e4170111325..08360690a98 100644 --- a/includes/EditPage.php +++ b/includes/EditPage.php @@ -1323,9 +1323,11 @@ class EditPage implements IEditObject { if ( $undoMsg === null ) { $oldContent = $this->page->getContent( RevisionRecord::RAW ); - $popts = ParserOptions::newFromUserAndLang( - $user, MediaWikiServices::getInstance()->getContentLanguage() ); - $newContent = $content->preSaveTransform( $this->mTitle, $user, $popts ); + $services = MediaWikiServices::getInstance(); + $popts = ParserOptions::newFromUserAndLang( $user, $services->getContentLanguage() ); + $contentTransformer = $services->getContentTransformer(); + $newContent = $contentTransformer->preSaveTransform( $content, $this->mTitle, $user, $popts ); + if ( $newContent->getModel() !== $oldContent->getModel() ) { // The undo may change content // model if its reverting the top @@ -2455,10 +2457,12 @@ class EditPage implements IEditObject { } // Do a pre-save transform on the retrieved undo content - $contentLanguage = MediaWikiServices::getInstance()->getContentLanguage(); + $services = MediaWikiServices::getInstance(); + $contentLanguage = $services->getContentLanguage(); $user = $this->context->getUser(); $parserOptions = ParserOptions::newFromUserAndLang( $user, $contentLanguage ); - $undoContent = $undoContent->preSaveTransform( $this->mTitle, $user, $parserOptions ); + $contentTransformer = $services->getContentTransformer(); + $undoContent = $contentTransformer->preSaveTransform( $undoContent, $this->mTitle, $user, $parserOptions ); if ( $undoContent->equals( $content ) ) { return true; @@ -3664,7 +3668,9 @@ class EditPage implements IEditObject { $user = $this->context->getUser(); $popts = ParserOptions::newFromUserAndLang( $user, MediaWikiServices::getInstance()->getContentLanguage() ); - $newContent = $newContent->preSaveTransform( $this->mTitle, $user, $popts ); + $services = MediaWikiServices::getInstance(); + $contentTransformer = $services->getContentTransformer(); + $newContent = $contentTransformer->preSaveTransform( $newContent, $this->mTitle, $user, $popts ); } if ( ( $oldContent && !$oldContent->isEmpty() ) || ( $newContent && !$newContent->isEmpty() ) ) { @@ -4191,7 +4197,9 @@ class EditPage implements IEditObject { // causing the context user to be used for {{subst:REVISIONUSER}}. // XXX: Alternatively, we could also call setupFakeRevision() a second time: // once before PST with $content, and then after PST with $pstContent. - $pstContent = $content->preSaveTransform( $this->mTitle, $user, $parserOptions ); + $services = MediaWikiServices::getInstance(); + $contentTransformer = $services->getContentTransformer(); + $pstContent = $contentTransformer->preSaveTransform( $content, $this->mTitle, $user, $parserOptions ); $scopedCallback = $parserOptions->setupFakeRevision( $this->mTitle, $pstContent, $user ); $parserOutput = $pstContent->getParserOutput( $this->mTitle, null, $parserOptions ); ScopedCallback::consume( $scopedCallback ); diff --git a/includes/MediaWikiServices.php b/includes/MediaWikiServices.php index e04dfc30475..bd7ba548563 100644 --- a/includes/MediaWikiServices.php +++ b/includes/MediaWikiServices.php @@ -40,6 +40,7 @@ use MediaWiki\Block\UnblockUserFactory; use MediaWiki\Cache\LinkBatchFactory; use MediaWiki\Config\ConfigRepository; use MediaWiki\Content\IContentHandlerFactory; +use MediaWiki\Content\Transform\ContentTransformer; use MediaWiki\EditPage\SpamChecker; use MediaWiki\FileBackend\FSFile\TempFSFileFactory; use MediaWiki\FileBackend\LockManager\LockManagerGroupFactory; @@ -787,6 +788,14 @@ class MediaWikiServices extends ServiceContainer { return $this->getService( 'ContentModelStore' ); } + /** + * @since 1.37 + * @return ContentTransformer + */ + public function getContentTransformer(): ContentTransformer { + return $this->getService( 'ContentTransformer' ); + } + /** * @since 1.35 * @return ContributionsLookup diff --git a/includes/ServiceWiring.php b/includes/ServiceWiring.php index cf4a0e1ea68..77ed50d5f27 100644 --- a/includes/ServiceWiring.php +++ b/includes/ServiceWiring.php @@ -62,6 +62,7 @@ use MediaWiki\Config\ConfigRepository; use MediaWiki\Config\ServiceOptions; use MediaWiki\Content\ContentHandlerFactory; use MediaWiki\Content\IContentHandlerFactory; +use MediaWiki\Content\Transform\ContentTransformer; use MediaWiki\EditPage\Constraint\EditConstraintFactory; use MediaWiki\EditPage\SpamChecker; use MediaWiki\FileBackend\FSFile\TempFSFileFactory; @@ -353,6 +354,10 @@ return [ return $services->getNameTableStoreFactory()->getContentModels(); }, + 'ContentTransformer' => static function ( MediaWikiServices $services ): ContentTransformer { + return new ContentTransformer( $services->getContentHandlerFactory() ); + }, + 'ContributionsLookup' => static function ( MediaWikiServices $services ): ContributionsLookup { return new ContributionsLookup( $services->getRevisionStore(), @@ -1067,6 +1072,7 @@ return [ $services->getUserEditTracker(), $services->getUserGroupManager(), $services->getTitleFormatter(), + $services->getContentTransformer(), ChangeTags::getSoftwareTags() ); }, diff --git a/includes/Storage/DerivedPageDataUpdater.php b/includes/Storage/DerivedPageDataUpdater.php index ee0342de21a..6d0265c2c45 100644 --- a/includes/Storage/DerivedPageDataUpdater.php +++ b/includes/Storage/DerivedPageDataUpdater.php @@ -34,6 +34,7 @@ use Language; use LinksUpdate; use LogicException; use MediaWiki\Content\IContentHandlerFactory; +use MediaWiki\Content\Transform\ContentTransformer; use MediaWiki\Edit\PreparedEdit; use MediaWiki\HookContainer\HookContainer; use MediaWiki\HookContainer\HookRunner; @@ -288,6 +289,9 @@ class DerivedPageDataUpdater implements IDBAccessObject, LoggerAwareInterface { /** @var UserNameUtils */ private $userNameUtils; + /** @var ContentTransformer */ + private $contentTransformer; + /** * @param WikiPage $wikiPage , * @param RevisionStore $revisionStore @@ -302,6 +306,7 @@ class DerivedPageDataUpdater implements IDBAccessObject, LoggerAwareInterface { * @param HookContainer $hookContainer * @param EditResultCache $editResultCache * @param UserNameUtils $userNameUtils + * @param ContentTransformer $contentTransformer */ public function __construct( WikiPage $wikiPage, @@ -316,7 +321,8 @@ class DerivedPageDataUpdater implements IDBAccessObject, LoggerAwareInterface { IContentHandlerFactory $contentHandlerFactory, HookContainer $hookContainer, EditResultCache $editResultCache, - UserNameUtils $userNameUtils + UserNameUtils $userNameUtils, + ContentTransformer $contentTransformer ) { $this->wikiPage = $wikiPage; @@ -334,6 +340,7 @@ class DerivedPageDataUpdater implements IDBAccessObject, LoggerAwareInterface { $this->hookRunner = new HookRunner( $hookContainer ); $this->editResultCache = $editResultCache; $this->userNameUtils = $userNameUtils; + $this->contentTransformer = $contentTransformer; $this->logger = new NullLogger(); } @@ -855,8 +862,13 @@ class DerivedPageDataUpdater implements IDBAccessObject, LoggerAwareInterface { // TODO: MCR: allow PST content for all slots to be stashed. $pstSlot = SlotRecord::newUnsaved( $role, $stashedEdit->pstContent ); } else { - $content = $slot->getContent(); - $pstContent = $content->preSaveTransform( $title, $legacyUser, $userPopts ); + $pstContent = $this->contentTransformer->preSaveTransform( + $slot->getContent(), + $title, + $user, + $userPopts + ); + $pstSlot = SlotRecord::newUnsaved( $role, $pstContent ); } diff --git a/includes/Storage/PageUpdaterFactory.php b/includes/Storage/PageUpdaterFactory.php index b8ad3f7863f..abe818df2c4 100644 --- a/includes/Storage/PageUpdaterFactory.php +++ b/includes/Storage/PageUpdaterFactory.php @@ -26,6 +26,7 @@ use JobQueueGroup; use Language; use MediaWiki\Config\ServiceOptions; use MediaWiki\Content\IContentHandlerFactory; +use MediaWiki\Content\Transform\ContentTransformer; use MediaWiki\HookContainer\HookContainer; use MediaWiki\Revision\RevisionRenderer; use MediaWiki\Revision\RevisionStore; @@ -116,6 +117,9 @@ class PageUpdaterFactory { /** @var TitleFormatter */ private $titleFormatter; + /** @var ContentTransformer */ + private $contentTransformer; + /** @var string[] */ private $softwareTags; @@ -137,6 +141,7 @@ class PageUpdaterFactory { * @param UserEditTracker $userEditTracker * @param UserGroupManager $userGroupManager * @param TitleFormatter $titleFormatter + * @param ContentTransformer $contentTransformer * @param string[] $softwareTags */ public function __construct( @@ -157,6 +162,7 @@ class PageUpdaterFactory { UserEditTracker $userEditTracker, UserGroupManager $userGroupManager, TitleFormatter $titleFormatter, + ContentTransformer $contentTransformer, array $softwareTags ) { $options->assertRequiredOptions( self::CONSTRUCTOR_OPTIONS ); @@ -178,6 +184,7 @@ class PageUpdaterFactory { $this->userEditTracker = $userEditTracker; $this->userGroupManager = $userGroupManager; $this->titleFormatter = $titleFormatter; + $this->contentTransformer = $contentTransformer; $this->softwareTags = $softwareTags; } @@ -279,7 +286,8 @@ class PageUpdaterFactory { $this->contentHandlerFactory, $this->hookContainer, $this->editResultCache, - $this->userNameUtils + $this->userNameUtils, + $this->contentTransformer ); $derivedDataUpdater->setLogger( $this->logger ); diff --git a/includes/content/AbstractContent.php b/includes/content/AbstractContent.php index 29ab6ba15ea..09412f652c0 100644 --- a/includes/content/AbstractContent.php +++ b/includes/content/AbstractContent.php @@ -27,6 +27,7 @@ */ use MediaWiki\Content\IContentHandlerFactory; +use MediaWiki\Content\Transform\PreSaveTransformParamsValue; use MediaWiki\MediaWikiServices; /** @@ -386,8 +387,9 @@ abstract class AbstractContent implements Content { } /** - * @stable to override * @since 1.21 + * @deprecated since 1.37. Use ContentTransformer::preSaveTransform instead. + * Extensions defining a content model should override ContentHandler::preSaveTransform. * * @param Title $title * @param User $user @@ -397,7 +399,11 @@ abstract class AbstractContent implements Content { * @see Content::preSaveTransform */ public function preSaveTransform( Title $title, User $user, ParserOptions $popts ) { - return $this; + $pstParams = new PreSaveTransformParamsValue( $title, $user, $popts ); + return $this->getContentHandler()->preSaveTransform( + $this, + $pstParams + ); } /** diff --git a/includes/content/Content.php b/includes/content/Content.php index 534231af649..def770585b8 100644 --- a/includes/content/Content.php +++ b/includes/content/Content.php @@ -387,6 +387,7 @@ interface Content { * object if no transformations apply). * * @since 1.21 + * @deprecated since 1.37 Use ContentTransformer::preSaveTransform and override ContentHandler::preSaveTransform. * * @param Title $title * @param User $user diff --git a/includes/content/ContentHandler.php b/includes/content/ContentHandler.php index d433dcd1eec..584ad48232c 100644 --- a/includes/content/ContentHandler.php +++ b/includes/content/ContentHandler.php @@ -26,6 +26,7 @@ * @author Daniel Kinzler */ +use MediaWiki\Content\Transform\PreSaveTransformParams; use MediaWiki\HookContainer\ProtectedHookAccessorTrait; use MediaWiki\Logger\LoggerFactory; use MediaWiki\MediaWikiServices; @@ -1527,4 +1528,35 @@ abstract class ContentHandler { return []; } + /** + * Returns a $content object with pre-save transformations applied (or the same + * object if no transformations apply). + * + * @note Not stable to call other then from ContentHandler hierarchy. + * Callers need to use ContentTransformer::preSaveTransform. + * @stable to override + * @since 1.37 + * + * @param Content $content + * @param PreSaveTransformParams $pstParams + * + * @return Content + */ + public function preSaveTransform( + Content $content, + PreSaveTransformParams $pstParams + ): Content { + $contentOverridesTransform = MWDebug::detectDeprecatedOverride( + $content, + AbstractContent::class, + 'preSaveTransform' + ); + if ( $contentOverridesTransform ) { + $services = MediaWikiServices::getInstance(); + $legacyUser = $services->getUserFactory()->newFromUserIdentity( $pstParams->getUser() ); + $legacyTitle = $services->getTitleFactory()->castFromPageReference( $pstParams->getPage() ); + return $content->preSaveTransform( $legacyTitle, $legacyUser, $pstParams->getParserOptions() ); + } + return $content; + } } diff --git a/includes/content/CssContent.php b/includes/content/CssContent.php index 4f4594f3159..2ff9468aadf 100644 --- a/includes/content/CssContent.php +++ b/includes/content/CssContent.php @@ -25,8 +25,6 @@ * @author Daniel Kinzler */ -use MediaWiki\MediaWikiServices; - /** * Content object for CSS pages. * @@ -49,34 +47,6 @@ class CssContent extends TextContent { parent::__construct( $text, $modelId ); } - /** - * Returns a Content object with pre-save transformations applied using - * Parser::preSaveTransform(). - * - * @param Title $title - * @param User $user - * @param ParserOptions $popts - * - * @return CssContent - * - * @see TextContent::preSaveTransform - */ - public function preSaveTransform( Title $title, User $user, ParserOptions $popts ) { - // @todo Make pre-save transformation optional for script pages (T34858) - - if ( !MediaWikiServices::getInstance()->getUserOptionsLookup()->getBoolOption( $user, 'pst-cssjs' ) ) { - // Allow bot users to disable the pre-save transform for CSS/JS (T236828). - $popts = clone $popts; - $popts->setPreSaveTransform( false ); - } - - $text = $this->getText(); - $pst = MediaWikiServices::getInstance()->getParser() - ->preSaveTransform( $text, $title, $user, $popts ); - - return new static( $pst ); - } - /** * @return string CSS wrapped in a
 tag.
 	 */
diff --git a/includes/content/CssContentHandler.php b/includes/content/CssContentHandler.php
index c6c98808c1f..c073d979849 100644
--- a/includes/content/CssContentHandler.php
+++ b/includes/content/CssContentHandler.php
@@ -20,6 +20,9 @@
  * @file
  * @ingroup Content
  */
+
+use MediaWiki\Content\Transform\PreSaveTransformParams;
+use MediaWiki\MediaWikiServices;
 use Wikimedia\Minify\CSSMin;
 
 /**
@@ -59,4 +62,29 @@ class CssContentHandler extends CodeContentHandler {
 		return new $class( '/* #REDIRECT */@import ' . CSSMin::buildUrlValue( $url ) . ';' );
 	}
 
+	public function preSaveTransform(
+		Content $content,
+		PreSaveTransformParams $pstParams
+	): Content {
+		'@phan-var CssContent $content';
+
+		// @todo Make pre-save transformation optional for script pages (T34858)
+		$services = MediaWikiServices::getInstance();
+		if ( !$services->getUserOptionsLookup()->getBoolOption( $pstParams->getUser(), 'pst-cssjs' ) ) {
+			// Allow bot users to disable the pre-save transform for CSS/JS (T236828).
+			$popts = clone $pstParams->getParserOptions();
+			$popts->setPreSaveTransform( false );
+		}
+
+		$text = $content->getText();
+		$pst = $services->getParser()->preSaveTransform(
+			$text,
+			$pstParams->getPage(),
+			$pstParams->getUser(),
+			$pstParams->getParserOptions()
+		);
+
+		$class = $this->getContentClass();
+		return new $class( $pst );
+	}
 }
diff --git a/includes/content/JavaScriptContent.php b/includes/content/JavaScriptContent.php
index a14b128ec44..bc3acb3bf8a 100644
--- a/includes/content/JavaScriptContent.php
+++ b/includes/content/JavaScriptContent.php
@@ -25,8 +25,6 @@
  * @author Daniel Kinzler
  */
 
-use MediaWiki\MediaWikiServices;
-
 /**
  * Content for JavaScript pages.
  *
@@ -49,32 +47,6 @@ class JavaScriptContent extends TextContent {
 		parent::__construct( $text, $modelId );
 	}
 
-	/**
-	 * Returns a Content object with pre-save transformations applied using
-	 * Parser::preSaveTransform().
-	 *
-	 * @param Title $title
-	 * @param User $user
-	 * @param ParserOptions $popts
-	 *
-	 * @return JavaScriptContent
-	 */
-	public function preSaveTransform( Title $title, User $user, ParserOptions $popts ) {
-		// @todo Make pre-save transformation optional for script pages (T34858)
-
-		if ( !MediaWikiServices::getInstance()->getUserOptionsLookup()->getBoolOption( $user, 'pst-cssjs' ) ) {
-			// Allow bot users to disable the pre-save transform for CSS/JS (T236828).
-			$popts = clone $popts;
-			$popts->setPreSaveTransform( false );
-		}
-
-		$text = $this->getText();
-		$pst = MediaWikiServices::getInstance()->getParser()
-			->preSaveTransform( $text, $title, $user, $popts );
-
-		return new static( $pst );
-	}
-
 	/**
 	 * @return string JavaScript wrapped in a 
 tag.
 	 */
diff --git a/includes/content/JavaScriptContentHandler.php b/includes/content/JavaScriptContentHandler.php
index 9abad3e22ae..69aeb99dace 100644
--- a/includes/content/JavaScriptContentHandler.php
+++ b/includes/content/JavaScriptContentHandler.php
@@ -18,6 +18,9 @@
  * @file
  */
 
+use MediaWiki\Content\Transform\PreSaveTransformParams;
+use MediaWiki\MediaWikiServices;
+
 /**
  * Content handler for JavaScript pages.
  *
@@ -59,4 +62,31 @@ class JavaScriptContentHandler extends CodeContentHandler {
 		$class = $this->getContentClass();
 		return new $class( '/* #REDIRECT */' . Xml::encodeJsCall( 'mw.loader.load', [ $url ] ) );
 	}
+
+	public function preSaveTransform(
+		Content $content,
+		PreSaveTransformParams $pstParams
+	): Content {
+		'@phan-var JavascriptContent $content';
+
+		$parserOptions = $pstParams->getParserOptions();
+		// @todo Make pre-save transformation optional for script pages (T34858)
+		$services = MediaWikiServices::getInstance();
+		if ( !$services->getUserOptionsLookup()->getBoolOption( $pstParams->getUser(), 'pst-cssjs' ) ) {
+			// Allow bot users to disable the pre-save transform for CSS/JS (T236828).
+			$parserOptions = clone $parserOptions;
+			$parserOptions->setPreSaveTransform( false );
+		}
+
+		$text = $content->getText();
+		$pst = $services->getParser()->preSaveTransform(
+			$text,
+			$pstParams->getPage(),
+			$pstParams->getUser(),
+			$parserOptions
+		);
+
+		$contentClass = $this->getContentClass();
+		return new $contentClass( $pst );
+	}
 }
diff --git a/includes/content/JsonContent.php b/includes/content/JsonContent.php
index 90f1e4ccdc4..1e1a6e88174 100644
--- a/includes/content/JsonContent.php
+++ b/includes/content/JsonContent.php
@@ -61,24 +61,6 @@ class JsonContent extends TextContent {
 		return FormatJson::encode( $this->getData()->getValue(), true, FormatJson::UTF8_OK );
 	}
 
-	/**
-	 * Beautifies JSON prior to save.
-	 *
-	 * @param Title $title
-	 * @param User $user
-	 * @param ParserOptions $popts
-	 * @return JsonContent
-	 */
-	public function preSaveTransform( Title $title, User $user, ParserOptions $popts ) {
-		// FIXME: WikiPage::doEditContent invokes PST before validation. As such, native data
-		// may be invalid (though PST result is discarded later in that case).
-		if ( !$this->isValid() ) {
-			return $this;
-		}
-
-		return new static( self::normalizeLineEndings( $this->beautifyJSON() ) );
-	}
-
 	/**
 	 * Set the HTML and add the appropriate styles.
 	 *
diff --git a/includes/content/JsonContentHandler.php b/includes/content/JsonContentHandler.php
index edb21f682c3..a6e0297e301 100644
--- a/includes/content/JsonContentHandler.php
+++ b/includes/content/JsonContentHandler.php
@@ -18,6 +18,8 @@
  * @file
  */
 
+use MediaWiki\Content\Transform\PreSaveTransformParams;
+
 /**
  * Content handler for JSON.
  *
@@ -44,4 +46,20 @@ class JsonContentHandler extends CodeContentHandler {
 		$class = $this->getContentClass();
 		return new $class( '{}' );
 	}
+
+	public function preSaveTransform(
+		Content $content,
+		PreSaveTransformParams $pstParams
+	): Content {
+		'@phan-var JsonContent $content';
+
+		// FIXME: WikiPage::doEditContent invokes PST before validation. As such, native data
+		// may be invalid (though PST result is discarded later in that case).
+		if ( !$content->isValid() ) {
+			return $content;
+		}
+
+		$contentClass = $this->getContentClass();
+		return new $contentClass( JsonContent::normalizeLineEndings( $content->beautifyJSON() ) );
+	}
 }
diff --git a/includes/content/TextContent.php b/includes/content/TextContent.php
index 6f2bcba41d4..ee532c4d694 100644
--- a/includes/content/TextContent.php
+++ b/includes/content/TextContent.php
@@ -204,27 +204,6 @@ class TextContent extends AbstractContent {
 		return str_replace( [ "\r\n", "\r" ], "\n", rtrim( $text ) );
 	}
 
-	/**
-	 * Returns a Content object with pre-save transformations applied.
-	 *
-	 * At a minimum, subclasses should make sure to call TextContent::normalizeLineEndings()
-	 * either directly or part of Parser::preSaveTransform().
-	 *
-	 * @stable to override
-	 *
-	 * @param Title $title
-	 * @param User $user
-	 * @param ParserOptions $popts
-	 *
-	 * @return Content
-	 */
-	public function preSaveTransform( Title $title, User $user, ParserOptions $popts ) {
-		$text = $this->getText();
-		$pst = self::normalizeLineEndings( $text );
-
-		return ( $text === $pst ) ? $this : new static( $pst, $this->getModel() );
-	}
-
 	/**
 	 * Diff this content object with another content object.
 	 *
diff --git a/includes/content/TextContentHandler.php b/includes/content/TextContentHandler.php
index e48dd511b8f..05b78c126ca 100644
--- a/includes/content/TextContentHandler.php
+++ b/includes/content/TextContentHandler.php
@@ -23,6 +23,8 @@
  * @ingroup Content
  */
 
+use MediaWiki\Content\Transform\PreSaveTransformParams;
+
 /**
  * Base content handler implementation for flat text contents.
  *
@@ -160,4 +162,17 @@ class TextContentHandler extends ContentHandler {
 		return $fields;
 	}
 
+	public function preSaveTransform(
+		Content $content,
+		PreSaveTransformParams $pstParams
+	): Content {
+		'@phan-var TextContent $content';
+
+		$text = $content->getText();
+
+		$pst = TextContent::normalizeLineEndings( $text );
+
+		$contentClass = $this->getContentClass();
+		return ( $text === $pst ) ? $content : new $contentClass( $pst, $content->getModel() );
+	}
 }
diff --git a/includes/content/Transform/ContentTransformer.php b/includes/content/Transform/ContentTransformer.php
new file mode 100644
index 00000000000..797b172ef6d
--- /dev/null
+++ b/includes/content/Transform/ContentTransformer.php
@@ -0,0 +1,48 @@
+contentHandlerFactory = $contentHandlerFactory;
+	}
+
+	/**
+	 * Returns a Content object with pre-save transformations applied (or $content
+	 * if no transformations apply).
+	 *
+	 * @param Content $content
+	 * @param PageReference $page
+	 * @param UserIdentity $user
+	 * @param ParserOptions $parser
+	 *
+	 * @return Content
+	 */
+	public function preSaveTransform(
+		Content $content,
+		PageReference $page,
+		UserIdentity $user,
+		ParserOptions $parser
+	): Content {
+		$contentHandler = $this->contentHandlerFactory->getContentHandler( $content->getModel() );
+		$pstParams = new PreSaveTransformParamsValue( $page, $user, $parser );
+
+		return $contentHandler->preSaveTransform( $content, $pstParams );
+	}
+}
diff --git a/includes/content/Transform/PreSaveTransformParams.php b/includes/content/Transform/PreSaveTransformParams.php
new file mode 100644
index 00000000000..bc8da70db4f
--- /dev/null
+++ b/includes/content/Transform/PreSaveTransformParams.php
@@ -0,0 +1,28 @@
+page = $page;
+		$this->user = $user;
+		$this->parserOptions = $parserOptions;
+	}
+
+	/**
+	 *
+	 * @return PageReference
+	 */
+	public function getPage(): PageReference {
+		return $this->page;
+	}
+
+	/**
+	 *
+	 * @return UserIdentity
+	 */
+	public function getUser(): UserIdentity {
+		return $this->user;
+	}
+
+	/**
+	 *
+	 * @return ParserOptions
+	 */
+	public function getParserOptions(): ParserOptions {
+		return $this->parserOptions;
+	}
+}
diff --git a/includes/content/WikitextContent.php b/includes/content/WikitextContent.php
index c7a386a74e2..df4222f0a6b 100644
--- a/includes/content/WikitextContent.php
+++ b/includes/content/WikitextContent.php
@@ -38,10 +38,9 @@ class WikitextContent extends TextContent {
 	private $redirectTargetAndText = null;
 
 	/**
-	 * @var bool Tracks if the parser set the user-signature flag when creating this content, which
-	 *   would make it expire faster in ApiStashEdit.
+	 * @var string[] flags set by PST
 	 */
-	private $hadSignature = false;
+	private $preSaveTransformFlags = [];
 
 	/**
 	 * @var string|null Stack trace of the previous parse
@@ -142,35 +141,6 @@ class WikitextContent extends TextContent {
 		return new static( $text );
 	}
 
-	/**
-	 * Returns a Content object with pre-save transformations applied using
-	 * Parser::preSaveTransform().
-	 *
-	 * @param Title $title
-	 * @param User $user
-	 * @param ParserOptions $popts
-	 *
-	 * @return Content
-	 */
-	public function preSaveTransform( Title $title, User $user, ParserOptions $popts ) {
-		$text = $this->getText();
-
-		$parser = MediaWikiServices::getInstance()->getParser();
-		$pst = $parser->preSaveTransform( $text, $title, $user, $popts );
-
-		if ( $text === $pst ) {
-			return $this;
-		}
-
-		$ret = new static( $pst );
-
-		if ( $parser->getOutput()->getFlag( 'user-signature' ) ) {
-			$ret->hadSignature = true;
-		}
-
-		return $ret;
-	}
-
 	/**
 	 * Returns a Content object with preload transformations applied (or this
 	 * object if no transformations apply).
@@ -389,7 +359,7 @@ class WikitextContent extends TextContent {
 		}
 
 		// Pass along user-signature flag
-		if ( $this->hadSignature ) {
+		if ( in_array( 'user-signature', $this->preSaveTransformFlags ) ) {
 			$output->setFlag( 'user-signature' );
 		}
 	}
@@ -417,4 +387,12 @@ class WikitextContent extends TextContent {
 		return $word->match( $this->getText() );
 	}
 
+	/**
+	 * Records flags set by preSaveTransform
+	 * @internal for use by WikitextContentHandler
+	 * @param string[] $flags
+	 */
+	public function setPreSaveTransformFlags( array $flags ) {
+		$this->preSaveTransformFlags = $flags;
+	}
 }
diff --git a/includes/content/WikitextContentHandler.php b/includes/content/WikitextContentHandler.php
index e062d0e389a..f2263b5a234 100644
--- a/includes/content/WikitextContentHandler.php
+++ b/includes/content/WikitextContentHandler.php
@@ -23,6 +23,7 @@
  * @ingroup Content
  */
 
+use MediaWiki\Content\Transform\PreSaveTransformParams;
 use MediaWiki\Languages\LanguageNameUtils;
 use MediaWiki\MediaWikiServices;
 
@@ -188,4 +189,29 @@ class WikitextContentHandler extends TextContentHandler {
 		return parent::serializeContent( $content, $format );
 	}
 
+	public function preSaveTransform(
+		Content $content,
+		PreSaveTransformParams $pstParams
+	): Content {
+		'@phan-var WikitextContent $content';
+
+		$text = $content->getText();
+
+		$parser = MediaWikiServices::getInstance()->getParser();
+		$pst = $parser->preSaveTransform(
+			$text,
+			$pstParams->getPage(),
+			$pstParams->getUser(),
+			$pstParams->getParserOptions()
+		);
+
+		if ( $text === $pst ) {
+			return $content;
+		}
+
+		$contentClass = $this->getContentClass();
+		$ret = new $contentClass( $pst );
+		$ret->setPreSaveTransformFlags( $parser->getOutput()->getAllFlags() );
+		return $ret;
+	}
 }
diff --git a/tests/phpunit/includes/content/FallbackContentTest.php b/tests/phpunit/includes/content/FallbackContentTest.php
index 4e5c6f1da69..f75001b38d5 100644
--- a/tests/phpunit/includes/content/FallbackContentTest.php
+++ b/tests/phpunit/includes/content/FallbackContentTest.php
@@ -5,13 +5,23 @@
  */
 class FallbackContentTest extends MediaWikiLangTestCase {
 
+	private const CONTENT_MODEL = 'xyzzy';
+
+	protected function setUp(): void {
+		parent::setUp();
+		$this->mergeMwGlobalArrayValue(
+			'wgContentHandlers',
+			[ self::CONTENT_MODEL => FallbackContentHandler::class ]
+		);
+	}
+
 	/**
 	 * @param string $data
 	 * @param string $type
 	 *
 	 * @return FallbackContent
 	 */
-	public function newContent( $data, $type = 'xyzzy' ) {
+	public function newContent( $data, $type = self::CONTENT_MODEL ) {
 		return new FallbackContent( $data, $type );
 	}
 
@@ -159,7 +169,7 @@ class FallbackContentTest extends MediaWikiLangTestCase {
 	public function testGetContentHandler() {
 		$this->mergeMwGlobalArrayValue(
 			'wgContentHandlers',
-			[ 'horkyporky' => 'UnknownContentHandler' ]
+			[ 'horkyporky' => FallbackContentHandler::class ]
 		);
 
 		$content = $this->newContent( "hello world.", 'horkyporky' );
diff --git a/tests/phpunit/includes/content/Transform/ContentTransformerTest.php b/tests/phpunit/includes/content/Transform/ContentTransformerTest.php
new file mode 100644
index 00000000000..7c1cf19e5b6
--- /dev/null
+++ b/tests/phpunit/includes/content/Transform/ContentTransformerTest.php
@@ -0,0 +1,31 @@
+setName( "127.0.0.1" );
+		$options = ParserOptions::newFromUser( $user );
+
+		$newContent = $services->getContentTransformer()->preSaveTransform( $content, $title, $user, $options );
+		$this->assertSame( $expectedContainText, $newContent->serialize() );
+	}
+}
diff --git a/tests/phpunit/mocks/content/DummyContentHandlerForTesting.php b/tests/phpunit/mocks/content/DummyContentHandlerForTesting.php
index f203907b904..60c982396f4 100644
--- a/tests/phpunit/mocks/content/DummyContentHandlerForTesting.php
+++ b/tests/phpunit/mocks/content/DummyContentHandlerForTesting.php
@@ -6,6 +6,10 @@ class DummyContentHandlerForTesting extends ContentHandler {
 		parent::__construct( $dataModel, $formats );
 	}
 
+	protected function getContentClass() {
+		return DummyContentForTesting::class;
+	}
+
 	/**
 	 * @see ContentHandler::serializeContent
 	 *