From 89c9937a748eb80ca91f570856823ae3ebeeecf0 Mon Sep 17 00:00:00 2001 From: RobinDev Date: Tue, 29 Sep 2026 11:51:18 +0200 Subject: [PATCH] fix: treat backslash-escaped punctuation as literal --- src/Rules/EscapeRule.php | 51 ++++++++++++++++++++++++++ src/Tokens/BoldAndItalicToken.php | 2 ++ src/Tokens/BoldToken.php | 2 ++ src/Tokens/DivToken.php | 2 ++ src/Tokens/HeadingToken.php | 2 ++ src/Tokens/HtmlToken.php | 2 ++ src/Tokens/ItalicToken.php | 2 ++ src/Tokens/LinkToken.php | 2 ++ src/Tokens/ListToken.php | 2 ++ src/Tokens/OrderedListToken.php | 2 ++ src/Tokens/ParagraphToken.php | 2 ++ src/Tokens/QuoteToken.php | 2 ++ src/Tokens/StrikethroughToken.php | 2 ++ src/Tokens/TableToken.php | 2 ++ tests/Rules/EscapeRuleTest.php | 60 +++++++++++++++++++++++++++++++ 15 files changed, 137 insertions(+) create mode 100644 src/Rules/EscapeRule.php create mode 100644 tests/Rules/EscapeRuleTest.php diff --git a/src/Rules/EscapeRule.php b/src/Rules/EscapeRule.php new file mode 100644 index 0000000..cdbb764 --- /dev/null +++ b/src/Rules/EscapeRule.php @@ -0,0 +1,51 @@ +?@[\\]^_`{|}~'; + + private(set) string $firstChar = '\\'; + private(set) string $stopChar = '\\'; + + public function shouldParse(Parser $parser): bool + { + $next = $parser->content[$parser->position + 1] ?? ''; + + return $next !== '' && str_contains(self::PUNCTUATION, $next); + } + + public function parse(Parser $parser): ?Token + { + $parser->consume(); + $char = $parser->consume(); + + // Written as an entity before a line ending, an escaped backslash + // cannot be read back as a hard line break by TextToken. + $text = $char === '\\' + && str_contains(Parser::NEW_LINE, $parser->current ?? ' ') + ? '\' + : htmlspecialchars($char, ENT_COMPAT | ENT_HTML5); + + if ($parser->lastToken instanceof TextToken) { + $parser->lastToken->append($text); + + return null; + } + + return new TextToken($text); + } +} diff --git a/src/Tokens/BoldAndItalicToken.php b/src/Tokens/BoldAndItalicToken.php index 271ee18..47e89c2 100644 --- a/src/Tokens/BoldAndItalicToken.php +++ b/src/Tokens/BoldAndItalicToken.php @@ -4,6 +4,7 @@ use Tempest\Markdown\Parser; use Tempest\Markdown\Rules\CodeRule; +use Tempest\Markdown\Rules\EscapeRule; use Tempest\Markdown\Rules\ImageRule; use Tempest\Markdown\Rules\LinkRule; use Tempest\Markdown\Rules\SocialHandleRule; @@ -26,6 +27,7 @@ public function parse(Parser $parser): string new LinkRule(), new SocialHandleRule(), new ImageRule(), + new EscapeRule(), new TextRule(), ]) ->parse($this->content); diff --git a/src/Tokens/BoldToken.php b/src/Tokens/BoldToken.php index cd2a8a0..42cf845 100644 --- a/src/Tokens/BoldToken.php +++ b/src/Tokens/BoldToken.php @@ -4,6 +4,7 @@ use Tempest\Markdown\Parser; use Tempest\Markdown\Rules\CodeRule; +use Tempest\Markdown\Rules\EscapeRule; use Tempest\Markdown\Rules\ImageRule; use Tempest\Markdown\Rules\ItalicRule; use Tempest\Markdown\Rules\LinkRule; @@ -28,6 +29,7 @@ public function parse(Parser $parser): string new LinkRule(), new SocialHandleRule(), new ImageRule(), + new EscapeRule(), new TextRule(), ]) ->parse($this->content); diff --git a/src/Tokens/DivToken.php b/src/Tokens/DivToken.php index 0ea7470..6dcb2de 100644 --- a/src/Tokens/DivToken.php +++ b/src/Tokens/DivToken.php @@ -6,6 +6,7 @@ use Tempest\Markdown\Rules\BoldAndItalicRule; use Tempest\Markdown\Rules\BoldRule; use Tempest\Markdown\Rules\CodeRule; +use Tempest\Markdown\Rules\EscapeRule; use Tempest\Markdown\Rules\HeadingRule; use Tempest\Markdown\Rules\ImageRule; use Tempest\Markdown\Rules\ItalicRule; @@ -39,6 +40,7 @@ public function parse(Parser $parser): string new ImageRule(), new PreRule(), new CodeRule(), + new EscapeRule(), new TextRule(), ]) ->parse($this->content); diff --git a/src/Tokens/HeadingToken.php b/src/Tokens/HeadingToken.php index 385f3b2..6ff38ac 100644 --- a/src/Tokens/HeadingToken.php +++ b/src/Tokens/HeadingToken.php @@ -6,6 +6,7 @@ use Tempest\Markdown\Rules\BoldAndItalicRule; use Tempest\Markdown\Rules\BoldRule; use Tempest\Markdown\Rules\CodeRule; +use Tempest\Markdown\Rules\EscapeRule; use Tempest\Markdown\Rules\ImageRule; use Tempest\Markdown\Rules\ItalicRule; use Tempest\Markdown\Rules\LinkRule; @@ -42,6 +43,7 @@ public function parse(Parser $parser): string new SocialHandleRule(), new ImageRule(), new CodeRule(), + new EscapeRule(), new TextRule(), ]) ->parse($this->content); diff --git a/src/Tokens/HtmlToken.php b/src/Tokens/HtmlToken.php index 5663e04..b7a892f 100644 --- a/src/Tokens/HtmlToken.php +++ b/src/Tokens/HtmlToken.php @@ -6,6 +6,7 @@ use Tempest\Markdown\Rules\BoldAndItalicRule; use Tempest\Markdown\Rules\BoldRule; use Tempest\Markdown\Rules\CodeRule; +use Tempest\Markdown\Rules\EscapeRule; use Tempest\Markdown\Rules\ImageRule; use Tempest\Markdown\Rules\ItalicRule; use Tempest\Markdown\Rules\LinkRule; @@ -39,6 +40,7 @@ public function parse(Parser $parser): string new SocialHandleRule(), new ImageRule(), new CodeRule(), + new EscapeRule(), new TextRule(), ]) ->parse($this->html) diff --git a/src/Tokens/ItalicToken.php b/src/Tokens/ItalicToken.php index dceeb9e..31ade66 100644 --- a/src/Tokens/ItalicToken.php +++ b/src/Tokens/ItalicToken.php @@ -5,6 +5,7 @@ use Tempest\Markdown\Parser; use Tempest\Markdown\Rules\BoldRule; use Tempest\Markdown\Rules\CodeRule; +use Tempest\Markdown\Rules\EscapeRule; use Tempest\Markdown\Rules\ImageRule; use Tempest\Markdown\Rules\LinkRule; use Tempest\Markdown\Rules\SocialHandleRule; @@ -28,6 +29,7 @@ public function parse(Parser $parser): string new LinkRule(), new SocialHandleRule(), new ImageRule(), + new EscapeRule(), new TextRule(), ]) ->parse($this->content); diff --git a/src/Tokens/LinkToken.php b/src/Tokens/LinkToken.php index 7e8c856..d7b2e87 100644 --- a/src/Tokens/LinkToken.php +++ b/src/Tokens/LinkToken.php @@ -6,6 +6,7 @@ use Tempest\Markdown\Rules\BoldAndItalicRule; use Tempest\Markdown\Rules\BoldRule; use Tempest\Markdown\Rules\CodeRule; +use Tempest\Markdown\Rules\EscapeRule; use Tempest\Markdown\Rules\ImageRule; use Tempest\Markdown\Rules\ItalicRule; use Tempest\Markdown\Rules\StrikethroughRule; @@ -35,6 +36,7 @@ public function parse(Parser $parser): string new ItalicRule(), new StrikethroughRule(), new ImageRule(), + new EscapeRule(), new TextRule(), ]) ->parse($this->content); diff --git a/src/Tokens/ListToken.php b/src/Tokens/ListToken.php index e228ed3..0fabb41 100644 --- a/src/Tokens/ListToken.php +++ b/src/Tokens/ListToken.php @@ -6,6 +6,7 @@ use Tempest\Markdown\Rules\BoldAndItalicRule; use Tempest\Markdown\Rules\BoldRule; use Tempest\Markdown\Rules\CodeRule; +use Tempest\Markdown\Rules\EscapeRule; use Tempest\Markdown\Rules\ImageRule; use Tempest\Markdown\Rules\ItalicRule; use Tempest\Markdown\Rules\LinkRule; @@ -32,6 +33,7 @@ public function parse(Parser $parser): string new SocialHandleRule(), new ImageRule(), new CodeRule(), + new EscapeRule(), new TextRule(), ]); diff --git a/src/Tokens/OrderedListToken.php b/src/Tokens/OrderedListToken.php index d588b46..361a901 100644 --- a/src/Tokens/OrderedListToken.php +++ b/src/Tokens/OrderedListToken.php @@ -6,6 +6,7 @@ use Tempest\Markdown\Rules\BoldAndItalicRule; use Tempest\Markdown\Rules\BoldRule; use Tempest\Markdown\Rules\CodeRule; +use Tempest\Markdown\Rules\EscapeRule; use Tempest\Markdown\Rules\ImageRule; use Tempest\Markdown\Rules\ItalicRule; use Tempest\Markdown\Rules\LinkRule; @@ -35,6 +36,7 @@ public function parse(Parser $parser): string new SocialHandleRule(), new ImageRule(), new CodeRule(), + new EscapeRule(), new TextRule(), ]); diff --git a/src/Tokens/ParagraphToken.php b/src/Tokens/ParagraphToken.php index e7af088..a344d76 100644 --- a/src/Tokens/ParagraphToken.php +++ b/src/Tokens/ParagraphToken.php @@ -7,6 +7,7 @@ use Tempest\Markdown\Rules\BoldRule; use Tempest\Markdown\Rules\CodeRule; use Tempest\Markdown\Rules\DivRule; +use Tempest\Markdown\Rules\EscapeRule; use Tempest\Markdown\Rules\ImageRule; use Tempest\Markdown\Rules\ItalicRule; use Tempest\Markdown\Rules\LinkRule; @@ -36,6 +37,7 @@ public function parse(Parser $parser): string new PreRule(), new CodeRule(), new DivRule(), + new EscapeRule(), new TextRule(), ]); diff --git a/src/Tokens/QuoteToken.php b/src/Tokens/QuoteToken.php index f6f11d5..737d9fe 100644 --- a/src/Tokens/QuoteToken.php +++ b/src/Tokens/QuoteToken.php @@ -6,6 +6,7 @@ use Tempest\Markdown\Rules\BoldAndItalicRule; use Tempest\Markdown\Rules\BoldRule; use Tempest\Markdown\Rules\CodeRule; +use Tempest\Markdown\Rules\EscapeRule; use Tempest\Markdown\Rules\ImageRule; use Tempest\Markdown\Rules\ItalicRule; use Tempest\Markdown\Rules\LinkRule; @@ -34,6 +35,7 @@ public function parse(Parser $parser): string new LinkRule(), new SocialHandleRule(), new ImageRule(), + new EscapeRule(), new TextRule(), ]) ->parse($this->content); diff --git a/src/Tokens/StrikethroughToken.php b/src/Tokens/StrikethroughToken.php index 8b68180..a6d1cde 100644 --- a/src/Tokens/StrikethroughToken.php +++ b/src/Tokens/StrikethroughToken.php @@ -6,6 +6,7 @@ use Tempest\Markdown\Rules\BoldAndItalicRule; use Tempest\Markdown\Rules\BoldRule; use Tempest\Markdown\Rules\CodeRule; +use Tempest\Markdown\Rules\EscapeRule; use Tempest\Markdown\Rules\ImageRule; use Tempest\Markdown\Rules\ItalicRule; use Tempest\Markdown\Rules\LinkRule; @@ -30,6 +31,7 @@ public function parse(Parser $parser): string new LinkRule(), new SocialHandleRule(), new ImageRule(), + new EscapeRule(), new TextRule(), ]) ->parse($this->content); diff --git a/src/Tokens/TableToken.php b/src/Tokens/TableToken.php index ea69f2d..3abb897 100644 --- a/src/Tokens/TableToken.php +++ b/src/Tokens/TableToken.php @@ -6,6 +6,7 @@ use Tempest\Markdown\Rules\BoldAndItalicRule; use Tempest\Markdown\Rules\BoldRule; use Tempest\Markdown\Rules\CodeRule; +use Tempest\Markdown\Rules\EscapeRule; use Tempest\Markdown\Rules\ImageRule; use Tempest\Markdown\Rules\ItalicRule; use Tempest\Markdown\Rules\LinkRule; @@ -32,6 +33,7 @@ public function parse(Parser $parser): string new SocialHandleRule(), new CodeRule(), new ImageRule(), + new EscapeRule(), new TextRule(), ]); diff --git a/tests/Rules/EscapeRuleTest.php b/tests/Rules/EscapeRuleTest.php new file mode 100644 index 0000000..a619d58 --- /dev/null +++ b/tests/Rules/EscapeRuleTest.php @@ -0,0 +1,60 @@ +assertSame( + $expected, + new Markdown(highlighter: null)->parse($markdown)->html, + ); + } + + public static function provideEscapes(): iterable + { + yield 'asterisks' => [ + 'Hôtels 3\*\*\* et 4\*\*\*\*', + '

Hôtels 3*** et 4****

', + ]; + yield 'emphasis delimiters' => [ + '\*not emphasis\*', + '

*not emphasis*

', + ]; + yield 'underscores' => ['\_x\_', '

_x_

']; + yield 'link brackets' => ['\[x\](/y)', '

[x](/y)

']; + yield 'ordered list marker' => ['1\. Step', '

1. Step

']; + yield 'heading marker' => ['\# Title', '

# Title

']; + yield 'backslash' => ['a\\\\b', '

a\b

']; + yield 'markup' => ['a\a<b

']; + yield 'in a list item' => ['- \* star', '']; + yield 'in a table cell' => [ + "| a |\n| - |\n| 3\*\* |", + '' + . '
a
3**
', + ]; + + // Only ASCII punctuation can be escaped, and code spans keep their + // backslashes. + yield 'before a letter' => ['a\qb', '

a\qb

']; + yield 'inside code' => ['`a\*b`', '

a\*b

']; + + // A backslash before a line ending is a hard line break, an escaped + // one is not. + yield 'hard line break' => ["a\\\nb", "

a
\nb

"]; + yield 'escaped backslash before a line ending' => [ + "a\\\\\nb", + "

a\\nb

", + ]; + } +}