From ef78026017631e6ba587c7a5309ae13ad62635f2 Mon Sep 17 00:00:00 2001 From: Mark Scherer Date: Sat, 15 Aug 2026 19:48:06 +0200 Subject: [PATCH] fix: the Markdown target escapes a < that would open markup MarkdownRenderer::escapeText() escaped the Markdown metacharacters and left `<` alone, so djot text went out as Markdown that a CommonMark reader takes as raw HTML: `ac` emitted `ac`, and `` opens a tag. The authored text is gone and markup appears in its place. A `<` is now escaped with a backslash when the next character is an ASCII letter, `/`, `!` or `?` - the four things that open raw HTML - and left alone otherwise. `>` takes nothing: it is inert mid-line, and at line start it is a block quote marker the line-level handling already covers. A backslash rather than an entity, because the operation is to protect the character so it reads back as itself; an entity replaces it. So `a < b and 3 > 2` survives unchanged and `ac` writes `a\c`, which a CommonMark reader gives back as the text that was written. The pass runs after the metacharacter pass so the backslash it inserts is not escaped a second time. Code spans, autolinks and raw nodes never reach this function and are unaffected. --- src/Renderer/MarkdownRenderer.php | 4 +- .../Renderer/MarkdownRendererTest.php | 38 +++++++++++++++++++ 2 files changed, 41 insertions(+), 1 deletion(-) diff --git a/src/Renderer/MarkdownRenderer.php b/src/Renderer/MarkdownRenderer.php index df85255b..bc0726d1 100644 --- a/src/Renderer/MarkdownRenderer.php +++ b/src/Renderer/MarkdownRenderer.php @@ -556,6 +556,8 @@ protected function escapeText(string $text): string { // Escape special Markdown characters in text // But be careful not to over-escape - return preg_replace('/([\\\\`*_\[\]#])/', '\\\\$1', $text) ?? $text; + $escaped = preg_replace('/([\\\\`*_\[\]#])/', '\\\\$1', $text) ?? $text; + + return preg_replace('/<(?=[A-Za-z\/!?])/', '\\\\<', $escaped) ?? $escaped; } } diff --git a/tests/TestCase/Renderer/MarkdownRendererTest.php b/tests/TestCase/Renderer/MarkdownRendererTest.php index b03dd269..64c6f246 100644 --- a/tests/TestCase/Renderer/MarkdownRendererTest.php +++ b/tests/TestCase/Renderer/MarkdownRendererTest.php @@ -102,6 +102,44 @@ public function testInlineCode(): void $this->assertStringContainsString('`print()`', $this->renderer->render($document)); } + public function testEscapesLessThanThatWouldOpenMarkup(): void + { + $document = $this->converter->parse('ac'); + + $this->assertSame("a\\c\n", $this->renderer->render($document)); + } + + public function testLeavesComparisonOperatorsUnescaped(): void + { + $document = $this->converter->parse('a < b and 3 > 2'); + + $this->assertSame("a < b and 3 > 2\n", $this->renderer->render($document)); + } + + public function testAutolinkLessThanIsNotEscaped(): void + { + $document = $this->converter->parse(''); + + $this->assertSame("[https://example.com](https://example.com)\n", $this->renderer->render($document)); + } + + public function testCodeSpanLessThanIsNotEscaped(): void + { + $document = $this->converter->parse('``'); + + $this->assertSame("``\n", $this->renderer->render($document)); + } + + public function testLessThanEscapeLookahead(): void + { + $document = $this->converter->parse("5 < 6\n\nx w\n\nqassertSame( + "5 < 6\n\nx\\ w\n\nq\\renderer->render($document), + ); + } + public function testUnorderedList(): void { // Test dash marker