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
8 changes: 7 additions & 1 deletion extension.json
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,10 @@
"remoteExtPath": "Wikven/resources"
},
"Hooks": {
"ParserOutputPostCacheTransform": "hider",
"ParserOutputPostCacheTransform": [
"hider",
"respacer"
],
"SkinTemplateNavigation::Universal": [
"hider",
"main"
Expand Down Expand Up @@ -146,6 +149,9 @@
},
"versioner": {
"class": "MediaWiki\\Extension\\Wikven\\Hooks\\Versioner"
},
"respacer": {
"class": "MediaWiki\\Extension\\Wikven\\Hooks\\Respacer"
}
},
"config": {
Expand Down
15 changes: 15 additions & 0 deletions includes/Hooks/Respacer.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
<?php

namespace MediaWiki\Extension\Wikven\Hooks;

use MediaWiki\Extension\Wikven\Output\CodeSpaces;

/**
* Code reads as its source wrote it, a skin preview included: the spaces are content, not chrome.
*/
class Respacer implements \MediaWiki\Hook\ParserOutputPostCacheTransformHook {
/** @inheritDoc */
public function onParserOutputPostCacheTransform($parserOutput, &$text, &$options): void {
$text = CodeSpaces::restore($text);
}
}
27 changes: 27 additions & 0 deletions includes/Output/CodeSpaces.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
<?php

namespace MediaWiki\Extension\Wikven\Output;

/**
* Puts back the spaces Sanitizer::armorFrenchSpaces took out of code, where Tidy runs it too: the
* space in `a != b` becomes a no-break space that copied code then carries (#833). Only the shapes
* that rule writes are undone, and only inside `<pre>` and `<code>`.
*/
class CodeSpaces {
private const NBSP = '(?:\x{00A0}|&#160;|&nbsp;)';

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',
'/(?<!\w)([«‹])' . self::NBSP . '/u'
],
[' ', '$1 '],
$code[0]
) ?? $code[0];
};
return preg_replace_callback('/<(pre|code)\b[^>]*>.*?<\/\1>/isu', $undo, $html) ?? $html;
}
}
34 changes: 34 additions & 0 deletions tests/phpunit/integration/Hooks/RespacerTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
<?php

namespace MediaWiki\Extension\Wikven\Tests\Integration\Hooks;

use MediaWiki\Parser\ParserOptions;
use MediaWiki\Title\Title;
use MediaWikiIntegrationTestCase;

/**
* A parse needs a database whatever it is parsing; see VersionerTest.
*
* @covers \MediaWiki\Extension\Wikven\Hooks\Respacer
* @group Database
*/
class RespacerTest extends MediaWikiIntegrationTestCase {
/** Through core's own parse and output pipeline, so it also fails if extension.json drops the handler. */
public function testCodeReadsAsWrittenWhileProseKeepsItsArmor() {
$popts = ParserOptions::newFromAnon();
$html = $this->getServiceContainer()
->getParserFactory()
->getInstance()
->parse(
"<pre>a != b</pre>\n\n<code>a != b</code> a !",
Title::newFromText('Respacer test'),
$popts
)
->runOutputPipeline($popts)
->getContentHolderText();

$this->assertStringContainsString('<pre>a != b</pre>', $html);
$this->assertStringContainsString('<code>a != b</code>', $html);
$this->assertMatchesRegularExpression('/a(\x{00A0}|&#160;)!/u', $html, 'prose keeps its armor');
}
}
49 changes: 49 additions & 0 deletions tests/phpunit/unit/CodeSpacesTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
<?php

namespace MediaWiki\Extension\Wikven\Tests\Unit;

use MediaWiki\Extension\Wikven\Output\CodeSpaces;
use MediaWikiUnitTestCase;

/**
* @covers \MediaWiki\Extension\Wikven\Output\CodeSpaces
*/
class CodeSpacesTest extends MediaWikiUnitTestCase {
/**
* Each way core hands code over, with the no-break space armorFrenchSpaces put before `!=`.
*
* @dataProvider provideArmoredCode
*/
public function testTheSpaceBeforeAnOperatorIsASpaceAgain(string $armored, string $restored) {
$this->assertSame($restored, CodeSpaces::restore($armored));
}

public static function provideArmoredCode(): iterable {
yield '<pre>' => ["<pre>a\u{00A0}!= b</pre>", '<pre>a != b</pre>'];
yield '<code>' => ["<p><code>a\u{00A0}!= b</code></p>", '<p><code>a != b</code></p>'];
// What SyntaxHighlight renders for `if: a != b` in YAML
yield 'syntaxhighlight' => [
'<div class="mw-highlight mw-highlight-lang-yaml mw-content-ltr" dir="ltr"><pre><span></span>'
. '<span class="nt">if</span><span class="p">:</span><span class="w"> </span>'
. "<span class=\"l l-Scalar l-Scalar-Plain\">a\u{00A0}!= b</span>\n</pre></div>",
'<div class="mw-highlight mw-highlight-lang-yaml mw-content-ltr" dir="ltr"><pre><span></span>'
. '<span class="nt">if</span><span class="p">:</span><span class="w"> </span>'
. "<span class=\"l l-Scalar l-Scalar-Plain\">a != b</span>\n</pre></div>"
];
yield 'as an entity' => ['<code>a&#160;!= b</code>', '<code>a != b</code>'];
yield 'after a guillemet' => ["<code>«\u{00A0}a »</code>", '<code>« a »</code>'];
}

/** Prose keeps the armor: there the no-break space is what the rule is for. */
public function testANoBreakSpaceOutsideCodeIsLeftAlone() {
$html = "<p>a\u{00A0}!</p><pre>a\u{00A0}!= b</pre><p>b\u{00A0}?</p>";
$this->assertSame("<p>a\u{00A0}!</p><pre>a != b</pre><p>b\u{00A0}?</p>", CodeSpaces::restore($html));
}

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