From 60cd004ca62e829d6ce6b1d6d6a6f1fb1f2e4236 Mon Sep 17 00:00:00 2001 From: RobinDev Date: Wed, 16 Sep 2026 12:50:51 +0200 Subject: [PATCH] fix: apply GFM flanking rules to strikethrough delimiters --- src/Rules/StrikethroughRule.php | 48 +++++++++++++++++++++++++-- tests/Rules/StrikethroughRuleTest.php | 46 +++++++++++++++++++++++++ 2 files changed, 91 insertions(+), 3 deletions(-) diff --git a/src/Rules/StrikethroughRule.php b/src/Rules/StrikethroughRule.php index 2e9adc3..bddd743 100644 --- a/src/Rules/StrikethroughRule.php +++ b/src/Rules/StrikethroughRule.php @@ -27,13 +27,55 @@ public function parse(Parser $parser): Token { $opening = $parser->consumeWhile('~'); - if (strpos($parser->content, '~', $parser->position) === false) { + $closing = $this->closingPosition($parser, strlen($opening)); + + if ($closing === null) { return new TextToken($opening); } - $buffer = $parser->consumeUntil('~'); - $parser->consumeWhile('~'); + $buffer = $parser->consume($closing - $parser->position); + + $parser->consume(strlen($opening)); return new StrikethroughToken($buffer); } + + /** + * The position of the run that closes $length tildes, or null when there + * is none. A run of more than two tildes never delimits strikethrough, an + * opening run may not be followed by whitespace, and a closing run may not + * be preceded by it. + * + * @see https://github.github.com/gfm/#strikethrough-extension- + */ + private function closingPosition(Parser $parser, int $length): ?int + { + if ($length > 2) { + return null; + } + + $next = $parser->current; + + if ($next === null || str_contains(Parser::WHITESPACE, $next)) { + return null; + } + + $content = $parser->content; + $position = $parser->position; + + while (($position = strpos($content, '~', $position)) !== false) { + $run = strspn($content, '~', $position); + + if ( + $run === $length + && ! str_contains(Parser::WHITESPACE, $content[$position - 1]) + ) { + return $position; + } + + $position += $run; + } + + return null; + } } diff --git a/tests/Rules/StrikethroughRuleTest.php b/tests/Rules/StrikethroughRuleTest.php index 88f55ff..5cfa96c 100644 --- a/tests/Rules/StrikethroughRuleTest.php +++ b/tests/Rules/StrikethroughRuleTest.php @@ -2,6 +2,7 @@ namespace Tempest\Markdown\Tests\Rules; +use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\Attributes\Test; use Tempest\Markdown\Markdown; use Tempest\Markdown\Parser; @@ -57,6 +58,51 @@ public function unmatched_tildes_remain_literal(): void ); } + #[Test] + #[DataProvider('provideFlanking')] + public function delimiters_follow_the_flanking_rules( + string $markdown, + string $expected, + ): void { + $this->assertSame( + $expected, + new Markdown(highlighter: null)->parse($markdown)->html, + ); + } + + public static function provideFlanking(): iterable + { + yield 'one tilde' => ['~struck~', '

struck

']; + yield 'two tildes' => ['~~struck~~', '

struck

']; + yield 'inside a word' => ['a~struck~b', '

astruckb

']; + + // The opening run may not be followed by whitespace, and the closing + // run may not be preceded by it. + yield 'space after the opening run' => [ + '~ struck ~', + '

~ struck ~

', + ]; + yield 'space before the closing run' => [ + '~11km / ~750m', + '

~11km / ~750m

', + ]; + yield 'approximations in a list item' => [ + "- Distance : ~120 km\n- Dénivelé : ~6 700 m D+ / ~7 300 m D-", + '', + ]; + + yield 'approximations in a table cell' => [ + "| a |\n| - |\n| ~120 et ~700 |", + '' + . '
a
~120 et ~700
', + ]; + + // Opening and closing runs have to be the same length. + yield 'one tilde closed by two' => ['~struck~~', '

~struck~~

']; + yield 'two tildes closed by one' => ['~~struck~', '

~~struck~

']; + } + #[Test] public function test_unmatched_run_is_consumed_as_one_literal_token(): void {