Skip to content

Commit 93ceb84

Browse files
icecrasher321claude
andcommitted
fix(markdown): close a fence only on a bare delimiter run
A closing fence carries nothing but its delimiter run; a line that merely starts with one is content. The guard matched the prefix alone, so an interior line like ` ````example ` inside a same-length fence ended the block, and every cleanup below then processed the author's remaining code as prose — dropping the backslashes from their `a\_b`. Both fence walks in this file shared that flaw, so both now go through one `closesFence`, which requires the run to be followed by nothing but whitespace. Strictly more conservative: a fence stays open longer, so more content is left verbatim. Scope, stated plainly: the serializer always opens a block with one more delimiter than the longest run inside it, so its own output cannot reach this shape today, and `postProcessSerializedMarkdown` only ever sees serializer output. This is a correctness fix that removes an unstated coupling to that choice, not a live corruption path. The tests therefore exercise `postProcessSerializedMarkdown` directly — a round-trip test of the same input would pass either way, which is exactly the vacuous check worth avoiding. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent 7d75e14 commit 93ceb84

2 files changed

Lines changed: 47 additions & 5 deletions

File tree

apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/markdown-fidelity.ts

Lines changed: 21 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -55,12 +55,12 @@ function unescapeIntrawordUnderscores(markdown: string): string {
5555
let fence: string | null = null
5656

5757
for (let i = 0; i < lines.length; i++) {
58-
const delimiter = lines[i].match(FENCE_DELIMITER)?.[1]
59-
6058
if (fence) {
61-
if (delimiter && delimiter[0] === fence[0] && delimiter.length >= fence.length) fence = null
59+
if (closesFence(lines[i], fence)) fence = null
6260
continue
6361
}
62+
63+
const delimiter = lines[i].match(FENCE_DELIMITER)?.[1]
6464
if (delimiter) {
6565
fence = delimiter
6666
continue
@@ -167,6 +167,22 @@ export function normalizeLinkHref(href: string): string {
167167
const EMPTY_LIST_ITEM_LINE = /^([ \t]*)(?:[-*+]|\d+[.)])[ \t]*$/
168168
/** A fenced code-block delimiter (``` or ~~~), used to leave code interiors untouched. */
169169
const FENCE_DELIMITER = /^[ \t]*(`{3,}|~{3,})/
170+
/**
171+
* A line carrying nothing but a delimiter run — the only thing that CLOSES a fence.
172+
*
173+
* An OPENING fence may be followed by an info string (` ```python `), so opening is matched with
174+
* {@link FENCE_DELIMITER}; a closing one may be followed only by whitespace. Treating any
175+
* delimiter-prefixed line as a close ends the block at an interior line like ` ```example ` inside
176+
* a `````` ```` ``````-fence, and every cleanup below then processes the rest of the author's code
177+
* as prose.
178+
*/
179+
const CLOSING_FENCE = /^[ \t]*(`{3,}|~{3,})[ \t]*$/
180+
181+
/** Whether `line` closes a fence opened with `fence`: same character, and at least as long. */
182+
function closesFence(line: string, fence: string): boolean {
183+
const delimiter = line.match(CLOSING_FENCE)?.[1]
184+
return Boolean(delimiter && delimiter[0] === fence[0] && delimiter.length >= fence.length)
185+
}
170186
/** Leading indentation of a line, used to detect whether an empty list item has indented children. */
171187
const LEADING_INDENT = /^[ \t]*/
172188

@@ -192,12 +208,12 @@ function stripEmptyListItemLines(markdown: string): string {
192208
let fence: string | null = null
193209
for (let i = 0; i < lines.length; i++) {
194210
const line = lines[i]
195-
const delimiter = line.match(FENCE_DELIMITER)?.[1]
196211
if (fence) {
197212
kept.push(line)
198-
if (delimiter && delimiter[0] === fence[0] && delimiter.length >= fence.length) fence = null
213+
if (closesFence(line, fence)) fence = null
199214
continue
200215
}
216+
const delimiter = line.match(FENCE_DELIMITER)?.[1]
201217
if (delimiter) {
202218
fence = delimiter
203219
kept.push(line)

apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/round-trip.test.ts

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,32 @@ describe('markdown-fidelity utils', () => {
9898
expect(postProcessSerializedMarkdown('> \\[!NOTE\\]\n> hi')).toBe('> [!NOTE]\n> hi')
9999
})
100100

101+
/*
102+
* A closing fence carries nothing but its delimiter run; a line that merely STARTS with one is
103+
* content. Matching the prefix alone ends the block at an interior line like ` ````example `,
104+
* after which the rest of the author's code is cleaned up as prose and its backslashes vanish.
105+
*
106+
* Exercised directly rather than through `roundTrip` on purpose: the serializer always opens a
107+
* block with one more delimiter than the longest run inside it, so its own output cannot reach
108+
* this shape and a round-trip test of it would pass either way. Relying on that choice staying
109+
* true is an unstated coupling — this keeps the guard honest on any input.
110+
*/
111+
it('does not close a fence on a delimiter-prefixed content line', () => {
112+
const input = '````\nx = a\\_b\n````example\ny = c\\_d\n````\n'
113+
expect(postProcessSerializedMarkdown(input)).toBe(input)
114+
})
115+
116+
it('does not close a tilde fence on a delimiter-prefixed content line', () => {
117+
const input = '~~~~\n~~~~note\ny = c\\_d\n~~~~\n'
118+
expect(postProcessSerializedMarkdown(input)).toBe(input)
119+
})
120+
121+
it('still closes a fence on a bare delimiter run with trailing spaces', () => {
122+
expect(postProcessSerializedMarkdown('````\nx = a\\_b\n```` \ny = c\\_d\n')).toBe(
123+
'````\nx = a\\_b\n```` \ny = c_d\n'
124+
)
125+
})
126+
101127
it('restores escaped callout markers in nested blockquotes', () => {
102128
expect(postProcessSerializedMarkdown('> > \\[!WARNING\\]\n> > hi')).toBe(
103129
'> > [!WARNING]\n> > hi'

0 commit comments

Comments
 (0)