diff --git a/src/Application/RedirectSyntax.php b/src/Application/RedirectSyntax.php new file mode 100644 index 0000000..523279f --- /dev/null +++ b/src/Application/RedirectSyntax.php @@ -0,0 +1,96 @@ +magicWordSynonyms === [] ) { + return null; + } + + $afterMagicWord = preg_replace( $this->magicWordRegex(), '', ltrim( $pageText ), 1, $count ); + + if ( $count === 0 || $afterMagicWord === null ) { + return null; + } + + // Mirrors the first-link extraction of WikitextContentHandler: a leading + // colon and a piped label are both allowed and dropped. + if ( preg_match( '!^\s*:?\s*\[{2}(.*?)(?:\|.*?)?\]{2}\s*!', $afterMagicWord, $matches ) !== 1 ) { + return null; + } + + return $this->decodeTarget( $matches[1] ); + } + + private function decodeTarget( string $target ): string { + if ( str_contains( $target, '%' ) ) { + return rawurldecode( ltrim( $target, ':' ) ); + } + + return $target; + } + + /** + * Builds the redirect line pointing at the given target text. The caller + * supplies any leading colon needed to escape category or interlanguage + * targets, matching the wikitext handler. + */ + public function buildRedirectText( string $target ): string { + return $this->preferredSynonym() . ' [[' . $target . ']]'; + } + + /** + * The content language's preferred spelling of the redirect magic word. + * MediaWiki always defines at least the English `#REDIRECT`, the fallback + * used should the injected synonym list ever be empty. + */ + private function preferredSynonym(): string { + return $this->magicWordSynonyms[0] ?? '#REDIRECT'; + } + + /** + * The redirect magic word is always case-insensitive, so this matches any + * synonym at the very start of the page. Longest synonyms come first, as in + * MediaWiki's own magic word matching, so one is never a prefix of another. + * + * @return non-empty-string + */ + private function magicWordRegex(): string { + $synonyms = $this->magicWordSynonyms; + usort( $synonyms, static fn ( string $a, string $b ) => strlen( $b ) <=> strlen( $a ) ); + + $alternatives = implode( '|', array_map( + static fn ( string $synonym ) => preg_quote( $synonym, '/' ), + $synonyms + ) ); + + return '/^(?:' . $alternatives . ')/iu'; + } + +} diff --git a/src/EntryPoints/MarkdownContent.php b/src/EntryPoints/MarkdownContent.php index e87a057..9512683 100644 --- a/src/EntryPoints/MarkdownContent.php +++ b/src/EntryPoints/MarkdownContent.php @@ -5,6 +5,7 @@ namespace ProfessionalWiki\NativeMarkdown\EntryPoints; use MediaWiki\Content\TextContent; +use MediaWiki\Title\Title; use ProfessionalWiki\NativeMarkdown\NativeMarkdownExtension; /** @@ -19,6 +20,19 @@ public function __construct( string $text ) { parent::__construct( $text, NativeMarkdownExtension::CONTENT_MODEL ); } + /** + * Redirect resolution lives on the handler, which has the services to turn + * the redirect syntax into a validated title. This drives redirect detection + * for the whole wiki: page moves, WhatLinksHere and the redirect table. + */ + public function getRedirectTarget(): ?Title { + $handler = $this->getContentHandler(); + + return $handler instanceof MarkdownContentHandler + ? $handler->getRedirectTargetForContent( $this ) + : null; + } + /** * The search index gets the rendered words rather than the raw markdown, so * result snippets do not show markup characters or hidden front matter. diff --git a/src/EntryPoints/MarkdownContentHandler.php b/src/EntryPoints/MarkdownContentHandler.php index a010675..be0fc54 100644 --- a/src/EntryPoints/MarkdownContentHandler.php +++ b/src/EntryPoints/MarkdownContentHandler.php @@ -7,8 +7,11 @@ use MediaWiki\Content\Content; use MediaWiki\Content\Renderer\ContentParseParams; use MediaWiki\Content\TextContentHandler; +use MediaWiki\Languages\LanguageNameUtils; +use MediaWiki\MediaWikiServices; use MediaWiki\Parser\ParserOutput; use MediaWiki\Parser\ParserOutputFlags; +use MediaWiki\Title\Title; use MediaWiki\Title\TitleValue; use ProfessionalWiki\NativeMarkdown\Application\RenderedMarkdown; use ProfessionalWiki\NativeMarkdown\Application\Section; @@ -44,9 +47,81 @@ public function isParserCacheSupported() { return true; } + public function supportsRedirects(): bool { + return true; + } + + public function makeRedirectContent( Title $destination, $text = '' ): Content { + $redirectText = NativeMarkdownExtension::getInstance()->newRedirectSyntax()->buildRedirectText( + $this->redirectTargetText( $destination ) + ); + + if ( $text !== '' ) { + $redirectText .= "\n" . $text; + } + + return new MarkdownContent( $redirectText ); + } + + private function redirectTargetText( Title $destination ): string { + $colon = $this->redirectTargetNeedsColon( $destination ) ? ':' : ''; + + return $colon . $destination->getFullText(); + } + + /** + * A redirect to a category or an interlanguage link needs a leading colon, + * otherwise the target reads as a category assignment or a language link + * rather than a redirect. Mirrors WikitextContentHandler. + */ + private function redirectTargetNeedsColon( Title $destination ): bool { + // NS_CATEGORY is a MediaWiki global constant psalm cannot resolve from scanned core files. + /** @psalm-suppress UndefinedConstant */ + if ( $destination->getNamespace() === NS_CATEGORY ) { + return true; + } + + $interwiki = $destination->getInterwiki(); + + return $interwiki !== '' && MediaWikiServices::getInstance()->getLanguageNameUtils()->getLanguageName( + $interwiki, + LanguageNameUtils::AUTONYMS, + LanguageNameUtils::DEFINED + ) !== ''; + } + + public function getRedirectTargetForContent( MarkdownContent $content ): ?Title { + return $this->resolveRedirectTarget( $content->getText() ); + } + + private function resolveRedirectTarget( string $text ): ?Title { + $targetText = NativeMarkdownExtension::getInstance()->newRedirectSyntax()->extractTargetText( $text ); + + if ( $targetText === null ) { + return null; + } + + $target = MediaWikiServices::getInstance()->getTitleFactory()->newFromText( $targetText ); + + if ( !$target instanceof Title || !$target->isValidRedirectTarget() ) { + return null; + } + + return $target; + } + protected function fillParserOutput( Content $content, ContentParseParams $cpoParams, ParserOutput &$output ): void { + $text = $content instanceof MarkdownContent ? $content->getText() : ''; + + $redirectTarget = $this->resolveRedirectTarget( $text ); + + if ( $redirectTarget !== null ) { + $this->fillRedirectParserOutput( $output, $cpoParams, $redirectTarget ); + return; + } + $rendered = NativeMarkdownExtension::getInstance()->getMarkdownRenderer()->render( - $content instanceof MarkdownContent ? $content->getText() : '', + $text, $cpoParams->getGenerateHtml() ); @@ -62,6 +137,30 @@ protected function fillParserOutput( Content $content, ContentParseParams $cpoPa $output->setRawText( $cpoParams->getGenerateHtml() ? $rendered->html : null ); } + /** + * A redirect page shows MediaWiki's redirect view (the arrow to the target) + * in place of its markdown body, and records the target as a link so page + * moves, WhatLinksHere and the redirect table all see it. + */ + private function fillRedirectParserOutput( ParserOutput $output, ContentParseParams $cpoParams, Title $target ): void { + $output->setFromParserOptions( $cpoParams->getParserOptions() ); + $output->addLink( $target ); + + if ( !$cpoParams->getGenerateHtml() ) { + $output->setRawText( null ); + return; + } + + $services = MediaWikiServices::getInstance(); + $page = $services->getTitleFactory()->newFromPageReference( $cpoParams->getPage() ); + + $output->setRedirectHeader( + $services->getLinkRenderer()->makeRedirectHeader( $page->getPageLanguage(), $target ) + ); + $output->addModuleStyles( [ 'mediawiki.action.view.redirectPage' ] ); + $output->setRawText( '' ); + } + private function registerLinks( ParserOutput $output, RenderedMarkdown $rendered ): void { foreach ( $rendered->links as $link ) { // addLink() routes interwiki targets to the interwiki table itself. diff --git a/src/NativeMarkdownExtension.php b/src/NativeMarkdownExtension.php index d2a9cbf..204a652 100644 --- a/src/NativeMarkdownExtension.php +++ b/src/NativeMarkdownExtension.php @@ -8,6 +8,7 @@ use MediaWiki\Parser\Parser; use ProfessionalWiki\NativeMarkdown\Application\MarkdownDefaultPolicy; use ProfessionalWiki\NativeMarkdown\Application\MarkdownRenderer; +use ProfessionalWiki\NativeMarkdown\Application\RedirectSyntax; use ProfessionalWiki\NativeMarkdown\Persistence\MediaWikiFileEmbedRenderer; use ProfessionalWiki\NativeMarkdown\Persistence\MediaWikiPageLinkRenderer; use ProfessionalWiki\NativeMarkdown\Persistence\MediaWikiTitleParser; @@ -68,6 +69,15 @@ private function newMarkdownRenderer(): MarkdownRenderer { ); } + public function newRedirectSyntax(): RedirectSyntax { + return new RedirectSyntax( + magicWordSynonyms: MediaWikiServices::getInstance() + ->getMagicWordFactory() + ->get( 'redirect' ) + ->getSynonyms() + ); + } + public function newMarkdownDefaultPolicy(): MarkdownDefaultPolicy { $config = MediaWikiServices::getInstance()->getMainConfig(); diff --git a/tests/phpunit/Application/RedirectSyntaxTest.php b/tests/phpunit/Application/RedirectSyntaxTest.php new file mode 100644 index 0000000..ec4b1ac --- /dev/null +++ b/tests/phpunit/Application/RedirectSyntaxTest.php @@ -0,0 +1,75 @@ +assertSame( 'Target Page', $this->englishSyntax()->extractTargetText( '#REDIRECT [[Target Page]]' ) ); + } + + public function testHasNoTargetWithoutMagicWord(): void { + $this->assertNull( $this->englishSyntax()->extractTargetText( '[[Target Page]]' ) ); + } + + public function testHasNoTargetInPlainText(): void { + $this->assertNull( $this->englishSyntax()->extractTargetText( 'Just some prose with a [link](https://example.com).' ) ); + } + + public function testMatchesMagicWordCaseInsensitively(): void { + $this->assertSame( 'Target Page', $this->englishSyntax()->extractTargetText( '#redirect [[Target Page]]' ) ); + } + + public function testIgnoresLeadingWhitespaceBeforeMagicWord(): void { + $this->assertSame( 'Target Page', $this->englishSyntax()->extractTargetText( "\n\n #REDIRECT [[Target Page]]" ) ); + } + + public function testRecognizesAnyLocalizedSynonym(): void { + $syntax = new RedirectSyntax( magicWordSynonyms: [ '#REDIRECT', '#WEITERLEITUNG' ] ); + + $this->assertSame( 'Ziel', $syntax->extractTargetText( '#WEITERLEITUNG [[Ziel]]' ) ); + } + + public function testDropsPipedLabelFromTarget(): void { + $this->assertSame( 'Target Page', $this->englishSyntax()->extractTargetText( '#REDIRECT [[Target Page|see over here]]' ) ); + } + + public function testKeepsLeadingColonEscapeInTarget(): void { + $this->assertSame( ':Category:Guides', $this->englishSyntax()->extractTargetText( '#REDIRECT [[:Category:Guides]]' ) ); + } + + public function testUrlDecodesPercentEncodedTarget(): void { + $this->assertSame( 'Foo Bar', $this->englishSyntax()->extractTargetText( '#REDIRECT [[Foo%20Bar]]' ) ); + } + + public function testKeepsSectionFragmentInTarget(): void { + $this->assertSame( 'Target Page#History', $this->englishSyntax()->extractTargetText( '#REDIRECT [[Target Page#History]]' ) ); + } + + public function testHasNoTargetWhenNoLinkFollowsMagicWord(): void { + $this->assertNull( $this->englishSyntax()->extractTargetText( '#REDIRECT nowhere in particular' ) ); + } + + public function testBuildsRedirectUsingPreferredSynonym(): void { + $syntax = new RedirectSyntax( magicWordSynonyms: [ '#WEITERLEITUNG', '#REDIRECT' ] ); + + $this->assertSame( '#WEITERLEITUNG [[Target Page]]', $syntax->buildRedirectText( 'Target Page' ) ); + } + + public function testBuildsRedirectWithColonEscapedTarget(): void { + $this->assertSame( '#REDIRECT [[:Category:Guides]]', $this->englishSyntax()->buildRedirectText( ':Category:Guides' ) ); + } + +} diff --git a/tests/phpunit/EntryPoints/MarkdownEditingBehaviorTest.php b/tests/phpunit/EntryPoints/MarkdownEditingBehaviorTest.php index 2e00768..453845f 100644 --- a/tests/phpunit/EntryPoints/MarkdownEditingBehaviorTest.php +++ b/tests/phpunit/EntryPoints/MarkdownEditingBehaviorTest.php @@ -77,8 +77,8 @@ public function testDiffShowsAddedAndRemovedLines(): void { $this->assertStringContainsString( 'diff-addedline', $diff ); } - public function testMarkdownContentModelDoesNotSupportRedirects(): void { - $this->assertFalse( $this->handler()->supportsRedirects() ); + public function testMarkdownContentModelSupportsRedirects(): void { + $this->assertTrue( $this->handler()->supportsRedirects() ); } } diff --git a/tests/phpunit/EntryPoints/MarkdownPageLifecycleTest.php b/tests/phpunit/EntryPoints/MarkdownPageLifecycleTest.php index 1185469..e755fef 100644 --- a/tests/phpunit/EntryPoints/MarkdownPageLifecycleTest.php +++ b/tests/phpunit/EntryPoints/MarkdownPageLifecycleTest.php @@ -9,8 +9,8 @@ use ProfessionalWiki\NativeMarkdown\EntryPoints\MarkdownContent; /** - * Page-level lifecycle behavior for markdown pages: moving (with redirects - * unsupported), deletion/undeletion and model preservation across them. + * Page-level lifecycle behavior for markdown pages: moving (leaving a working + * redirect at the old title), deletion/undeletion and model preservation. * * @covers \ProfessionalWiki\NativeMarkdown\EntryPoints\MarkdownContentHandler * @covers \ProfessionalWiki\NativeMarkdown\EntryPoints\NativeMarkdownHooks @@ -40,19 +40,22 @@ public function testMovedPageKeepsMarkdownModelAtNewTitle(): void { $this->assertSame( 'markdown', $this->contentModelOf( $target ) ); } - public function testMovingMarkdownPageLeavesNoRedirectAtOldTitle(): void { + public function testMovingMarkdownPageLeavesWorkingRedirectAtOldTitle(): void { $source = Title::makeTitle( NS_MAIN, 'Markdown Redirect Source' ); $target = Title::makeTitle( NS_MAIN, 'Markdown Redirect Target' ); - $this->createMarkdownPage( $source, "# No Redirect\n\nBody." ); + $this->createMarkdownPage( $source, "# Original\n\nBody." ); - // Ask for a redirect: the model cannot hold one, so none is created. $this->getServiceContainer()->getMovePageFactory() ->newMovePage( $source, $target ) ->move( $this->getTestSysop()->getUser(), 'moving', true ); - $this->assertFalse( - $source->toPageIdentity()->exists(), - 'A markdown page move must not leave a redirect stub behind.' + $stub = $this->getServiceContainer()->getWikiPageFactory()->newFromTitle( $source )->getContent(); + $this->assertInstanceOf( MarkdownContent::class, $stub ); + + $this->assertSame( + $target->getPrefixedText(), + $stub->getRedirectTarget()?->getPrefixedText(), + 'The move must leave a working markdown redirect at the old title.' ); } diff --git a/tests/phpunit/EntryPoints/MarkdownRedirectTest.php b/tests/phpunit/EntryPoints/MarkdownRedirectTest.php new file mode 100644 index 0000000..dfbc5da --- /dev/null +++ b/tests/phpunit/EntryPoints/MarkdownRedirectTest.php @@ -0,0 +1,101 @@ +getServiceContainer()->getContentHandlerFactory()->getContentHandler( 'markdown' ); + $this->assertInstanceOf( MarkdownContentHandler::class, $handler ); + + return $handler; + } + + private function getParserOutput( string $markdown ): ParserOutput { + return $this->getServiceContainer()->getContentRenderer()->getParserOutput( + new MarkdownContent( $markdown ), + PageReferenceValue::localReference( NS_MAIN, 'NativeMarkdownRedirectPage' ) + ); + } + + public function testRedirectPageRendersRedirectViewToTarget(): void { + $header = $this->getParserOutput( '#REDIRECT [[Redirect View Target]]' )->getRedirectHeader(); + + $this->assertNotNull( $header ); + $this->assertStringContainsString( 'redirectMsg', $header ); + $this->assertStringContainsString( 'Redirect View Target', $header ); + } + + public function testRedirectPageRegistersTargetInPageLinks(): void { + $output = $this->getParserOutput( '#REDIRECT [[Redirect Link Target]]' ); + + $this->assertArrayHasKey( 'Redirect_Link_Target', $output->getLinks()[NS_MAIN] ?? [] ); + } + + public function testRedirectPageLoadsRedirectPageModuleStyles(): void { + $output = $this->getParserOutput( '#REDIRECT [[Redirect Styles Target]]' ); + + $this->assertContains( 'mediawiki.action.view.redirectPage', $output->getModuleStyles() ); + } + + public function testNonRedirectPageHasNoRedirectView(): void { + $output = $this->getParserOutput( "# Just A Heading\n\nWith some body text." ); + + $this->assertNull( $output->getRedirectHeader() ); + } + + public function testContentReportsRedirectTarget(): void { + $content = new MarkdownContent( '#REDIRECT [[Reported Target]]' ); + + $this->assertTrue( $content->isRedirect() ); + $this->assertSame( 'Reported Target', $content->getRedirectTarget()?->getPrefixedText() ); + } + + public function testNormalContentReportsNoRedirectTarget(): void { + $content = new MarkdownContent( "# Not A Redirect\n\nJust [[Some Link]] in prose." ); + + $this->assertFalse( $content->isRedirect() ); + $this->assertNull( $content->getRedirectTarget() ); + } + + public function testInvalidRedirectTargetIsNotARedirect(): void { + $content = new MarkdownContent( '#REDIRECT [[Special:Userlogout]]' ); + + $this->assertFalse( $content->isRedirect() ); + } + + public function testMakeRedirectContentRoundTripsToTarget(): void { + $content = $this->handler()->makeRedirectContent( Title::makeTitle( NS_MAIN, 'Round Trip Target' ) ); + + $this->assertInstanceOf( MarkdownContent::class, $content ); + $this->assertSame( 'Round Trip Target', $content->getRedirectTarget()?->getPrefixedText() ); + } + + public function testMakeRedirectContentColonEscapesCategoryTarget(): void { + $content = $this->handler()->makeRedirectContent( Title::makeTitle( NS_CATEGORY, 'Escaped Cat' ) ); + + $this->assertInstanceOf( MarkdownContent::class, $content ); + $this->assertStringContainsString( '[[:Category:Escaped Cat]]', $content->getText() ); + } + +}