Skip to content

Commit 546db04

Browse files
authored
Fix nested fenced code block parsing (#722)
Replaces the lazy fenced-code regex in transcript parsing with a scanner that tracks the opening backtick fence length, so shorter nested fences remain inside the outer code block. Adds a regression case for a markdown fence containing a nested qmd fence. Fixes #721. Co-authored-by: Wes McKinney <wesm@users.noreply.github.com>
1 parent 46740c1 commit 546db04

2 files changed

Lines changed: 123 additions & 17 deletions

File tree

frontend/src/lib/utils/content-parser.test.ts

Lines changed: 33 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -81,7 +81,7 @@ describe("parseContent", () => {
8181

8282
it("preserves leading whitespace in tail text", () => {
8383
const segments =
84-
parseContent("```code\ncontent```\n Trailing text");
84+
parseContent("```code\ncontent\n```\n Trailing text");
8585
expect(segments).toHaveLength(2);
8686
expect(segments[0]).toMatchObject({ type: "code" });
8787
expect(segments[1]).toEqual({
@@ -111,6 +111,38 @@ describe("parseContent", () => {
111111
]);
112112
});
113113

114+
it("keeps nested shorter fences inside longer code blocks", () => {
115+
const content =
116+
"````markdown\nSome context paragraph.\n\n```qmd\nauthor: \"Jane Doe\"\n```\n\nMore context here.\n````";
117+
const segments = parseContent(content);
118+
expect(segments).toEqual([
119+
{
120+
type: "code",
121+
content:
122+
"Some context paragraph.\n\n```qmd\nauthor: \"Jane Doe\"\n```\n\nMore context here.\n",
123+
label: "markdown",
124+
},
125+
]);
126+
});
127+
128+
it("keeps inline same-length backtick runs inside code blocks", () => {
129+
const content =
130+
"```javascript\nconst fence = \"```\";\n[Thinking]\nnot parsed\n```\nAfter";
131+
const segments = parseContent(content);
132+
expect(segments).toEqual([
133+
{
134+
type: "code",
135+
content:
136+
"const fence = \"```\";\n[Thinking]\nnot parsed\n",
137+
label: "javascript",
138+
},
139+
{
140+
type: "text",
141+
content: "\nAfter",
142+
},
143+
]);
144+
});
145+
114146
it("omits label for code blocks without language", () => {
115147
const segments = parseContent("```\nplain code\n```");
116148
expect(segments[0]).toEqual({

frontend/src/lib/utils/content-parser.ts

Lines changed: 90 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -70,8 +70,6 @@ const TOOL_RE = new RegExp(
7070
"g",
7171
);
7272

73-
const CODE_BLOCK_RE = /```(\w*)\n([\s\S]*?)```/g;
74-
7573
/** Returns true if text[from..to) contains a backtick run of
7674
* exactly `len` characters. Used to detect a closing inline
7775
* code delimiter on the same line as the opener. */
@@ -95,7 +93,7 @@ function hasRunBefore(
9593
* backtick run of length N is closed by the next run of exactly
9694
* N backticks. Fenced code blocks (triple-backtick at line
9795
* start followed by a newline) are excluded — those are handled
98-
* separately by CODE_BLOCK_RE.
96+
* separately by codeBlockMatches.
9997
*/
10098
function scanInlineCodeSpans(
10199
text: string,
@@ -125,15 +123,13 @@ function scanInlineCodeSpans(
125123
}
126124

127125
// Scan for a closing run of exactly the same length.
128-
let found = false;
129126
for (let j = i; j < text.length; j++) {
130127
if (text[j] !== "`") continue;
131128
const closeStart = j;
132129
while (j < text.length && text[j] === "`") j++;
133130
if (j - closeStart === runLen) {
134131
spans.push([openStart, j]);
135132
i = j;
136-
found = true;
137133
break;
138134
}
139135
}
@@ -190,6 +186,92 @@ function insideInlineCode(
190186
return spans.some(([s, e]) => pos > s && pos < e);
191187
}
192188

189+
function atFenceLineStart(text: string, pos: number): boolean {
190+
const lineStart = text.lastIndexOf("\n", pos - 1) + 1;
191+
return /^[ \t]{0,3}$/.test(text.slice(lineStart, pos));
192+
}
193+
194+
function countBackticks(text: string, pos: number): number {
195+
let end = pos;
196+
while (end < text.length && text[end] === "`") end++;
197+
return end - pos;
198+
}
199+
200+
function closingFence(
201+
text: string,
202+
contentStart: number,
203+
fenceLen: number,
204+
): { contentEnd: number; end: number } | undefined {
205+
let pos = contentStart;
206+
while (pos < text.length) {
207+
const tickStart = text.indexOf("`", pos);
208+
if (tickStart < 0) return undefined;
209+
const lineStart = text.lastIndexOf("\n", tickStart - 1) + 1;
210+
const nextLineStart = text.indexOf("\n", tickStart);
211+
const lineEnd =
212+
nextLineStart >= 0 ? nextLineStart : text.length;
213+
214+
const tickCount = countBackticks(text, tickStart);
215+
const rest = text.slice(tickStart + tickCount, lineEnd);
216+
if (
217+
tickCount >= fenceLen &&
218+
atFenceLineStart(text, tickStart) &&
219+
/^[ \t]*$/.test(rest)
220+
) {
221+
return { contentEnd: lineStart, end: lineEnd };
222+
}
223+
224+
pos = tickStart + tickCount;
225+
}
226+
return undefined;
227+
}
228+
229+
function codeBlockMatches(text: string): Match[] {
230+
const matches: Match[] = [];
231+
let pos = 0;
232+
233+
while (pos < text.length) {
234+
const start = text.indexOf("```", pos);
235+
if (start < 0) break;
236+
237+
if (!atFenceLineStart(text, start)) {
238+
pos = start + 1;
239+
continue;
240+
}
241+
242+
const fenceLen = countBackticks(text, start);
243+
const infoStart = start + fenceLen;
244+
const lineEnd = text.indexOf("\n", infoStart);
245+
if (lineEnd < 0) break;
246+
247+
const info = text.slice(infoStart, lineEnd);
248+
if (info.includes("`")) {
249+
pos = infoStart;
250+
continue;
251+
}
252+
253+
const contentStart = lineEnd + 1;
254+
const close = closingFence(text, contentStart, fenceLen);
255+
if (close === undefined) {
256+
pos = infoStart;
257+
continue;
258+
}
259+
260+
matches.push({
261+
start,
262+
end: close.end,
263+
segment: {
264+
type: "code",
265+
content: text.slice(contentStart, close.contentEnd),
266+
label: info.trim() || undefined,
267+
},
268+
});
269+
pos = close.end;
270+
}
271+
272+
return matches;
273+
}
274+
193275
function extractMatches(text: string, parseTools = true): Match[] {
194276
const matches: Match[] = [];
195277

@@ -271,22 +353,14 @@ function extractMatches(text: string, parseTools = true): Match[] {
271353
}
272354
}
273355

274-
for (const m of text.matchAll(CODE_BLOCK_RE)) {
275-
const idx = m.index!;
356+
for (const m of codeBlockMatches(text)) {
357+
const idx = m.start;
276358
const insideOther = matches.some(
277359
(o) => idx >= o.start && idx < o.end,
278360
);
279361
if (insideOther) continue;
280362

281-
matches.push({
282-
start: idx,
283-
end: idx + m[0].length,
284-
segment: {
285-
type: "code",
286-
content: m[2] ?? "",
287-
label: m[1] || undefined,
288-
},
289-
});
363+
matches.push(m);
290364
}
291365

292366
return matches;

0 commit comments

Comments
 (0)