fix(markdown-editor): track fence length so a 4-backtick block isn't closed early

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 <noreply@anthropic.com>
This commit is contained in:
Sergey Kozyrenko
2026-07-07 18:56:09 +07:00
co-authored by Claude Opus 4.8
parent 81e448cf05
commit 19fc4b3092
2 changed files with 34 additions and 3 deletions
@@ -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(
@@ -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;
}