From aeb54d98d1f46c038e2667a85bc2da0d11726168 Mon Sep 17 00:00:00 2001 From: Lilleman auf Larv Date: Tue, 15 Sep 2026 20:52:29 +0200 Subject: [PATCH] 4.3: an escaped backtick run escapes whole, and a lone backtick before one escapes too --- .../commonmark-subset/text-backtick-run.json | 31 ++++++++++++++++++ .../commonmark-subset/text-backtick-run.md | 4 +++ spec/flavour.md | 2 ++ src/markdown/emit/line-escaping.ts | 32 +++++++++++++++---- 4 files changed, 63 insertions(+), 6 deletions(-) create mode 100644 corpus/round-trip/commonmark-subset/text-backtick-run.json create mode 100644 corpus/round-trip/commonmark-subset/text-backtick-run.md diff --git a/corpus/round-trip/commonmark-subset/text-backtick-run.json b/corpus/round-trip/commonmark-subset/text-backtick-run.json new file mode 100644 index 0000000..1274ab0 --- /dev/null +++ b/corpus/round-trip/commonmark-subset/text-backtick-run.json @@ -0,0 +1,31 @@ +{ + "content": [ + { + "content": [ + { + "text": "`a ``b``", + "type": "text" + } + ], + "type": "paragraph" + }, + { + "content": [ + { + "text": "`a", + "type": "text" + }, + { + "type": "hardBreak" + }, + { + "text": "```b``", + "type": "text" + } + ], + "type": "paragraph" + } + ], + "type": "doc", + "version": 1 +} diff --git a/corpus/round-trip/commonmark-subset/text-backtick-run.md b/corpus/round-trip/commonmark-subset/text-backtick-run.md new file mode 100644 index 0000000..ca8ca5c --- /dev/null +++ b/corpus/round-trip/commonmark-subset/text-backtick-run.md @@ -0,0 +1,4 @@ +\`a \`\`b`` + +\`a\ +\`\`\`b`` diff --git a/spec/flavour.md b/spec/flavour.md index e80d8fb..a98ec2e 100644 --- a/spec/flavour.md +++ b/spec/flavour.md @@ -54,6 +54,8 @@ normalizes to it through the round-trip. - Entity references in input decode to their characters; output backslash-escapes only where text would otherwise parse as syntax, scanning the assembled line rather than each text node: escape the leading delimiter of a construct that would otherwise open, re-scan from there, and repeat. + A backtick run escapes whole, and a lone backtick escapes wherever an escaped one follows it in + the same inline content: CommonMark reads no escape inside a code span, so `` \` `` closes one. A paragraph's opening `[` escapes wherever its line reads as a link reference definition, which resolves before any inline construct binds: a `]` inside a code span or `{attrs}` counts. An emphasis delimiter run in text escapes where CommonMark can open **or** close with it, so diff --git a/src/markdown/emit/line-escaping.ts b/src/markdown/emit/line-escaping.ts index 4671702..4f51118 100644 --- a/src/markdown/emit/line-escaping.ts +++ b/src/markdown/emit/line-escaping.ts @@ -67,9 +67,20 @@ function escape(segments: readonly InlineSegment[], container: LineContainer): A const scan = segments.map((segment) => segment.text).join('') const escapings: InlineEscaping[] = [] for (const segment of segments) for (let index = 0; index < segment.text.length; index += 1) escapings.push(segment.escaping) - const escaped = new Set() + const escaped = escapedIndexes(scan, escapings, container) const placements: number[] = [] let output = '' + for (let index = 0; index < scan.length; index += 1) { + if (escaped.has(index)) output += '\\' + placements.push(output.length) + output += scan.charAt(index) + } + const line = container === 'paragraph' && opensLinkDefinition(output) ? `\\${output}` : output + return { line, unspellableRun: unspellableRun(segments, output, placements) } +} + +function escapedIndexes(scan: string, escapings: readonly InlineEscaping[], container: LineContainer): Set { + const escaped = new Set() const linkClose = lastLinkClose(scan, escapings) let line = scanLine(scan, 0) for (let index = 0; index < scan.length; index += 1) { @@ -84,14 +95,21 @@ function escape(segments: readonly InlineSegment[], container: LineContainer): A (escaping === 'bracketed-link-target' && ((scan.charAt(index) === '`' && opensCodeSpan(scan, index, escaped)) || (scan.charAt(index) === ':' && opensInlineDirective(scan, index)))) ) { - output += '\\' escaped.add(index) } - placements.push(output.length) - output += scan.charAt(index) } - const spelled = container === 'paragraph' && opensLinkDefinition(output) ? `\\${output}` : output - return { line: spelled, unspellableRun: unspellableRun(segments, output, placements) } + escapeLoneBackticks(scan, escapings, escaped) + return escaped +} + +// CommonMark reads no escape inside a code span, so an escaped backtick still closes one a bare lone backtick before it opens. +function escapeLoneBackticks(scan: string, escapings: readonly InlineEscaping[], escaped: Set): void { + let escapedAfter = false + for (let index = scan.length - 1; index >= 0; index -= 1) { + if (scan.charAt(index) !== '`') continue + if (escapedAfter && escapings[index] !== 'none' && scan.charAt(index - 1) !== '`' && scan.charAt(index + 1) !== '`') escaped.add(index) + if (escaped.has(index)) escapedAfter = true + } } function unspellableRun(segments: readonly InlineSegment[], output: string, placements: readonly number[]): NodeRange | undefined { @@ -248,6 +266,8 @@ function lastLinkClose(scan: string, escapings: readonly (InlineEscaping | undef } function opensCodeSpan(scan: string, index: number, escaped: ReadonlySet): boolean { + // A run escapes whole: a rest left bare would be a raw run of another length for a closer. + if (scan.charAt(index - 1) === '`' && escaped.has(index - 1)) return true if (!startsRun(scan, index, escaped)) return false const opener = backtickRun(scan, index) return closingBacktickRun(scan, index + opener, opener) !== undefined