From 9698e0d09fe68d1894ada3134c679dc3342541af Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ademir=20Jos=C3=A9=20Ferreira=20J=C3=BAnior?= Date: Thu, 3 Sep 2026 02:31:47 -0300 Subject: [PATCH] Escape a marker run only where it can open a thematic break The three markers are counted across the line rather than inside one run of it, so `* * *` and `_ _ _` still open a break where `**` and `__` no longer do. That is why the check is not the run-length test the tilde branch beside it makes for a code fence. `\_ __a__` loses its escape as a consequence. An underscore opens no bullet marker, so the line's character class was the whole of what held it, unlike its `*` sibling, which the bullet-marker check still holds. --- CHANGELOG.md | 1 + .../tests/markdownCompatibility.test.ts | 27 ++++++++++++++++++- src/features/editor/utils/markdownText.ts | 7 ++++- 3 files changed, 33 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index aaeb739..a265f6e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -68,6 +68,7 @@ Leafdown uses lightweight [Keep a Changelog](https://keepachangelog.com/en/1.1.0 - Apply that same precise escaping to text held inside bold, italic, strikethrough, or a link label, so literal text such as `**\[a](b)**` keeps the one backslash it needs and an underscore inside bold stays bare, instead of gaining the extra backslashes that ordinary text no longer collects. - Leave a link or image destination that balances its own parentheses bare on save, so a path such as `garden(section(one)).md` keeps its parentheses as written, instead of saving `garden\(section\(one\)\).md`. A parenthesis that would close the destination early still keeps its backslash. - Leave a block marker bare where the line it opens cannot form the construct, so text such as `#no separator`, `####### seven hashes`, `1234567890. ten digits`, a list starting at two that follows a paragraph line, or a pipe row that no matching delimiter row follows keeps its markers as written, instead of carrying a backslash for a heading, list, or table the line never spells. Hashes followed by a space, a list starting at one that interrupts a paragraph, and a pipe row a matching delimiter row follows all still keep their backslash. +- Leave a run of `*` or `_` too short for a horizontal rule bare on save, so a line holding `**`, `_`, or `__` keeps its markers as written, instead of carrying a backslash for a rule that needs three of them. A line spending three or more, spaced or not, still keeps its backslash. - Leave an ampersand in a link or image destination bare where it starts no character reference, so a destination such as `a.md?x=1&y=2` keeps its query as written, instead of saving `a.md?x=1\&y=2`. A destination ampersand that does begin a reference still keeps its backslash, and a link label holding a literal `©` now keeps its own backslash even where the destination drops one. - Keep a list item that starts with a code block, table, quote, nested list, heading, or thematic break nested in the saved file, instead of writing an empty item and leaving the block outside the list the next time the document is opened. - Escape text the editor keeps literal even when a space follows it, instead of writing it as live Markdown that turns into something else the next time the document is opened. diff --git a/src/features/editor/tests/markdownCompatibility.test.ts b/src/features/editor/tests/markdownCompatibility.test.ts index 022d1df..f18a60a 100644 --- a/src/features/editor/tests/markdownCompatibility.test.ts +++ b/src/features/editor/tests/markdownCompatibility.test.ts @@ -684,6 +684,20 @@ describe("Escape precision", () => { }, // A marker with nothing between it and its content opens no list item at all. { saved: "does not interrupt:\n1)item", source: "does not interrupt:\n1\\)item" }, + // A thematic break spends three markers, so a shorter run is ordinary text wherever it sits. + { saved: "**", source: "\\*\\*" }, + { saved: "_", source: "\\_" }, + { saved: "__", source: "\\_\\_" }, + { saved: "_ _", source: "\\_ \\_" }, + { saved: "a paragraph line\n**", source: "a paragraph line\n\\*\\*" }, + // The three are counted across the line rather than inside one run of it, and only the run + // the line starts with stands where the break could open. + { saved: "\\*\\* *", source: "\\*\\* \\*" }, + { saved: "\\* * *", source: "\\* \\* \\*" }, + { saved: "\\_ _ _", source: "\\_ \\_ \\_" }, + // An underscore opens no bullet marker, so a run standing off a span of its own character is + // held by nothing once the line it shares spells fewer than three markers. + { saved: "_ __a__", source: "\\_ __a__" }, // A table needs a delimiter row whose cell count matches the header row above it. { saved: "| Header | Cells |\n| --- |\n| mismatch | stays text |", @@ -719,6 +733,8 @@ describe("Escape precision", () => { "1\\. not a list item", "\\*\\*\\*", "\\_\\_\\_", + // A thematic break interrupts a paragraph, so a continuation line spending three needs it too. + "a paragraph line\n\\*\\*\\*", "\\# not a heading", "\\> not a quote", "\\- not a list item", @@ -760,7 +776,6 @@ describe("Escape precision", () => { // Whitespace between a run and the span beside it keeps them two runs rather than one, so the // run is still a bullet marker on its own and the span is not what holds it literal. "\\* **a**", - "\\_ __a__", // The enclosing delimiters remain available counterparts for a run of the same character. "**\\*a**", "**\\*opening-only asterisk**", @@ -823,6 +838,16 @@ describe("Escape precision", () => { "**strong***trailing", "x***strong**", "x___strong__", + // A marker run too short to open a thematic break reopens as its own text whether or not a + // backslash holds it, which puts it in the same blind spot. + "**", + "_", + "__", + "_ _", + "_ __a__", + "\\*\\*\\*", + "\\_\\_\\_", + "\\* * *", ])("writes %j as authored and reopens it as the same document", async (source) => { const mounted = await mountEditor(`${source}\n`); const document: unknown = mounted.view.state.doc.toJSON(); diff --git a/src/features/editor/utils/markdownText.ts b/src/features/editor/utils/markdownText.ts index 46a9ec0..1729aee 100644 --- a/src/features/editor/utils/markdownText.ts +++ b/src/features/editor/utils/markdownText.ts @@ -60,7 +60,12 @@ const LINE_ENDING_PATTERN = /[\r\n]$/u; const ESCAPABLE_PATTERN = /[!-/:-@[-`{-~]/u; const UNICODE_PUNCTUATION_PATTERN = /[\p{P}\p{S}]/u; const ATTENTION_CHARACTERS = "*_~"; -const THEMATIC_BREAK_PATTERNS: Record = { "*": /^[*\t ]*$/u, _: /^[_\t ]*$/u }; +// A thematic break spends three markers or more, counted across the line rather than inside one +// run of it, and admits nothing else but spaces and tabs. +const THEMATIC_BREAK_PATTERNS: Record = { + "*": /^(?:[\t ]*\*){3,}[\t ]*$/u, + _: /^(?:[\t ]*_){3,}[\t ]*$/u, +}; const WHOLE_LINE_PHRASING_PARENTS = new Set(["heading", "paragraph", "tableCell"]); // A mark holds only a fragment of its line, so its siblings cannot answer what follows the mark. // Every `[` in the fragment keeps its escape, which is also what makes an unescaped `[` from