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\*\* |",
+ '',
+ ];
+
+ // 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
",
+ ];
+ }
+}