Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
96 changes: 96 additions & 0 deletions src/Application/RedirectSyntax.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
<?php

declare( strict_types = 1 );

namespace ProfessionalWiki\NativeMarkdown\Application;

/**
* Reads and writes the MediaWiki redirect syntax (`#REDIRECT [[Target]]`) on a
* markdown page. The `redirect` magic word is localized, so its synonyms are
* injected; the first synonym is the content language's preferred spelling.
*
* This mirrors how the wikitext content handler treats redirects, so a page
* move (which writes this syntax) leaves a working redirect and hand-written
* redirects behave the same across content models.
*/
final class RedirectSyntax {

/**
* @param string[] $magicWordSynonyms Redirect magic word spellings, preferred one first
*/
public function __construct(
private readonly array $magicWordSynonyms
) {
}

/**
* The raw target text of the redirect, or null when the page is not a
* redirect. Turning that text into a validated title is the caller's job,
* since redirect-target validation is a wiki concern.
*/
public function extractTargetText( string $pageText ): ?string {
if ( $this->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';
}

}
14 changes: 14 additions & 0 deletions src/EntryPoints/MarkdownContent.php
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
namespace ProfessionalWiki\NativeMarkdown\EntryPoints;

use MediaWiki\Content\TextContent;
use MediaWiki\Title\Title;
use ProfessionalWiki\NativeMarkdown\NativeMarkdownExtension;

/**
Expand All @@ -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.
Expand Down
101 changes: 100 additions & 1 deletion src/EntryPoints/MarkdownContentHandler.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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()
);

Expand All @@ -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.
Expand Down
10 changes: 10 additions & 0 deletions src/NativeMarkdownExtension.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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();

Expand Down
75 changes: 75 additions & 0 deletions tests/phpunit/Application/RedirectSyntaxTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
<?php

declare( strict_types = 1 );

namespace ProfessionalWiki\NativeMarkdown\Tests\Application;

use PHPUnit\Framework\TestCase;
use ProfessionalWiki\NativeMarkdown\Application\RedirectSyntax;

/**
* @covers \ProfessionalWiki\NativeMarkdown\Application\RedirectSyntax
*/
class RedirectSyntaxTest extends TestCase {

private function englishSyntax(): RedirectSyntax {
return new RedirectSyntax( magicWordSynonyms: [ '#REDIRECT' ] );
}

public function testExtractsTargetFollowingMagicWord(): void {
$this->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' ) );
}

}
4 changes: 2 additions & 2 deletions tests/phpunit/EntryPoints/MarkdownEditingBehaviorTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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() );
}

}
Loading
Loading