diff --git a/extension.json b/extension.json index e05108d47..077ae91a5 100644 --- a/extension.json +++ b/extension.json @@ -66,7 +66,10 @@ "remoteExtPath": "Wikven/resources" }, "Hooks": { - "ParserOutputPostCacheTransform": "hider", + "ParserOutputPostCacheTransform": [ + "hider", + "respacer" + ], "SkinTemplateNavigation::Universal": [ "hider", "main" @@ -146,6 +149,9 @@ }, "versioner": { "class": "MediaWiki\\Extension\\Wikven\\Hooks\\Versioner" + }, + "respacer": { + "class": "MediaWiki\\Extension\\Wikven\\Hooks\\Respacer" } }, "config": { diff --git a/includes/Hooks/Respacer.php b/includes/Hooks/Respacer.php new file mode 100644 index 000000000..4c06d2502 --- /dev/null +++ b/includes/Hooks/Respacer.php @@ -0,0 +1,15 @@ +` and ``. + */ +class CodeSpaces { + private const NBSP = '(?:\x{00A0}| | )'; + + public static function restore(string $html): string { + $undo = static function (array $code): string { + return preg_replace( + [ + // The inverse of the rule's two patterns, in its order + '/' . self::NBSP . '(?=[?:;!%»›](?!\w))/u', + '/(?]*>.*?<\/\1>/isu', $undo, $html) ?? $html; + } +} diff --git a/tests/phpunit/integration/Hooks/RespacerTest.php b/tests/phpunit/integration/Hooks/RespacerTest.php new file mode 100644 index 000000000..87e622dc1 --- /dev/null +++ b/tests/phpunit/integration/Hooks/RespacerTest.php @@ -0,0 +1,34 @@ +getServiceContainer() + ->getParserFactory() + ->getInstance() + ->parse( + "
a != b
\n\na != b a !", + Title::newFromText('Respacer test'), + $popts + ) + ->runOutputPipeline($popts) + ->getContentHolderText(); + + $this->assertStringContainsString('
a != b
', $html); + $this->assertStringContainsString('a != b', $html); + $this->assertMatchesRegularExpression('/a(\x{00A0}| )!/u', $html, 'prose keeps its armor'); + } +} diff --git a/tests/phpunit/unit/CodeSpacesTest.php b/tests/phpunit/unit/CodeSpacesTest.php new file mode 100644 index 000000000..b232d0a9a --- /dev/null +++ b/tests/phpunit/unit/CodeSpacesTest.php @@ -0,0 +1,49 @@ +assertSame($restored, CodeSpaces::restore($armored)); + } + + public static function provideArmoredCode(): iterable { + yield '
' => ["
a\u{00A0}!= b
", '
a != b
']; + yield '' => ["

a\u{00A0}!= b

", '

a != b

']; + // What SyntaxHighlight renders for `if: a != b` in YAML + yield 'syntaxhighlight' => [ + '
'
+				. 'if: '
+				. "a\u{00A0}!= b\n
", + '
'
+				. 'if: '
+				. "a != b\n
" + ]; + yield 'as an entity' => ['a != b', 'a != b']; + yield 'after a guillemet' => ["«\u{00A0}a »", '« a »']; + } + + /** Prose keeps the armor: there the no-break space is what the rule is for. */ + public function testANoBreakSpaceOutsideCodeIsLeftAlone() { + $html = "

a\u{00A0}!

a\u{00A0}!= b

b\u{00A0}?

"; + $this->assertSame("

a\u{00A0}!

a != b

b\u{00A0}?

", CodeSpaces::restore($html)); + } + + /** One the rule could not have written, so an author meant it. */ + public function testANoBreakSpaceTheRuleDidNotWriteStays() { + foreach (["a\u{00A0}b", "a\u{00A0}!b"] as $html) { + $this->assertSame($html, CodeSpaces::restore($html)); + } + } +}