From 19fc4b3092fdf00e5791fe7817222f7a406bb08f Mon Sep 17 00:00:00 2001 From: Sergey Kozyrenko Date: Tue, 7 Jul 2026 18:56:09 +0700 Subject: [PATCH] fix(markdown-editor): track fence length so a 4-backtick block isn't closed early MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The escapeTablePipes fence tracker matched a fixed 3-char run (```/~~~) and compared truncated tokens, so a 4-backtick block — which renderTunedCodeBlock emits whenever the block's content holds a ``` line — was "closed" by that inner 3-backtick line. Two failures followed: pipes inside the real code block got `\|` injected into the saved bytes, and a genuine table AFTER the block lost its protection (the tracker thought it was still inside a fence), dropping cells. Capture the full run and its trailing text; close only on a same-char run of length >= the opener with no info string, mirroring CommonMark. Co-Authored-By: Claude Opus 4.8 --- .../markdown-editor-table-pipes.test.ts | 21 +++++++++++++++++++ .../markdown-editor-table-pipes.ts | 16 +++++++++++--- 2 files changed, 34 insertions(+), 3 deletions(-) diff --git a/frontend/src/components/shared/markdown-editor/markdown-editor-table-pipes.test.ts b/frontend/src/components/shared/markdown-editor/markdown-editor-table-pipes.test.ts index 438fc40b..bc5e2888 100644 --- a/frontend/src/components/shared/markdown-editor/markdown-editor-table-pipes.test.ts +++ b/frontend/src/components/shared/markdown-editor/markdown-editor-table-pipes.test.ts @@ -143,6 +143,27 @@ describe('table cell with a pipe inside a URL — content survives load and conv }); }); +describe('fence length tracking — a longer fence is not closed by a shorter inner run', () => { + it('keeps protecting a table after a 4-backtick block that contains a ``` line', () => { + const src = '````\n```\ninner\n````\n\n| a | b |\n| --- | --- |\n| `x | y` | z |'; + + expect(escapeTablePipes(src)).toBe('````\n```\ninner\n````\n\n| a | b |\n| --- | --- |\n| `x \\| y` | z |'); + }); + + it('does not escape a table sitting inside a 4-backtick block that also holds a ``` line', () => { + const src = '````\n```\n| a | b |\n| --- | --- |\n| `x | y` | z |\n````'; + + expect(escapeTablePipes(src)).toBe(src); + }); + + it('round-trips a table after a fence-demonstrating code block without losing the cell', () => { + const out = roundTrip('````\n```\ninner\n````\n\n| a | b |\n| --- | --- |\n| `x | y` | z |'); + + expect(out).toContain('z'); + expect(out).toContain('`x \\| y`'); + }); +}); + describe('CRLF line endings — tables still protected', () => { it('escapes a code-span pipe in a CRLF table row', () => { expect(escapeTablePipes('| a | b |\r\n| --- | --- |\r\n| `x | y` | z |\r\n')).toBe( diff --git a/frontend/src/components/shared/markdown-editor/markdown-editor-table-pipes.ts b/frontend/src/components/shared/markdown-editor/markdown-editor-table-pipes.ts index f28c6709..702343a2 100644 --- a/frontend/src/components/shared/markdown-editor/markdown-editor-table-pipes.ts +++ b/frontend/src/components/shared/markdown-editor/markdown-editor-table-pipes.ts @@ -17,7 +17,10 @@ // marked would end the table there; we do not replicate its HTML-block interrupt (vanishingly rare in this // content, and our html tokenizers render such lines as literal text anyway). -const FENCE_LINE = /^ {0,3}(```|~~~)/; +// Capture the full fence run (not a fixed 3) plus the trailing text: renderTunedCodeBlock widens a fence to +// 4+ backticks when its content holds a ``` line, and CommonMark closes a fence only with a run of the same +// char, length >= the opener, and no info string — so length- and char-aware tracking is load-bearing. +const FENCE_LINE = /^ {0,3}(`{3,}|~{3,})(.*)$/; // Written to be linear: the trailing `(?: *\|)? *$` (not `\|? *$`) plus per-cell spacing keep any two space // runs from competing for the same characters, so a crafted delimiter-looking line can't force O(n²) backtracking. const TABLE_DELIMITER_LINE = /^ {0,3}\|? *:?-+:?(?: *\| *:?-+:?)*(?: *\|)? *$/; @@ -142,7 +145,7 @@ export const escapeTablePipes = (markdown: string): string => { // lose cells. Normalize first; the return below keeps the original bytes when nothing was escaped. const source = markdown.includes('\r') ? markdown.replace(/\r\n?/g, '\n') : markdown; const lines = source.split('\n'); - let openFence: null | string = null; + let openFence: null | { char: string; length: number } = null; let isChanged = false; const escapeRow = (row: number): void => { @@ -159,7 +162,14 @@ export const escapeTablePipes = (markdown: string): string => { const fence = FENCE_LINE.exec(line); if (fence) { - openFence = openFence === fence[1] ? null : (openFence ?? fence[1]!); + const marker = fence[1]!; + + if (openFence === null) { + openFence = { char: marker[0]!, length: marker.length }; + } else if (marker[0] === openFence.char && marker.length >= openFence.length && !fence[2]!.trim()) { + openFence = null; + } + continue; }