From 7d374e3161a3c5441de14a5e2389d7c2859e5d7c Mon Sep 17 00:00:00 2001 From: Wes McKinney Date: Wed, 17 Jun 2026 09:47:29 -0500 Subject: [PATCH 1/2] fix: handle nested fenced code blocks --- frontend/src/lib/utils/content-parser.test.ts | 14 +++ frontend/src/lib/utils/content-parser.ts | 97 ++++++++++++++++--- 2 files changed, 95 insertions(+), 16 deletions(-) diff --git a/frontend/src/lib/utils/content-parser.test.ts b/frontend/src/lib/utils/content-parser.test.ts index 85b715e5d..1141b1dfc 100644 --- a/frontend/src/lib/utils/content-parser.test.ts +++ b/frontend/src/lib/utils/content-parser.test.ts @@ -111,6 +111,20 @@ describe("parseContent", () => { ]); }); + it("keeps nested shorter fences inside longer code blocks", () => { + const content = + "````markdown\nSome context paragraph.\n\n```qmd\nauthor: \"Jane Doe\"\n```\n\nMore context here.\n````"; + const segments = parseContent(content); + expect(segments).toEqual([ + { + type: "code", + content: + "Some context paragraph.\n\n```qmd\nauthor: \"Jane Doe\"\n```\n\nMore context here.\n", + label: "markdown", + }, + ]); + }); + it("omits label for code blocks without language", () => { const segments = parseContent("```\nplain code\n```"); expect(segments[0]).toEqual({ diff --git a/frontend/src/lib/utils/content-parser.ts b/frontend/src/lib/utils/content-parser.ts index 96f02b46e..0f7f65425 100644 --- a/frontend/src/lib/utils/content-parser.ts +++ b/frontend/src/lib/utils/content-parser.ts @@ -70,8 +70,6 @@ const TOOL_RE = new RegExp( "g", ); -const CODE_BLOCK_RE = /```(\w*)\n([\s\S]*?)```/g; - /** Returns true if text[from..to) contains a backtick run of * exactly `len` characters. Used to detect a closing inline * code delimiter on the same line as the opener. */ @@ -95,7 +93,7 @@ function hasRunBefore( * backtick run of length N is closed by the next run of exactly * N backticks. Fenced code blocks (triple-backtick at line * start followed by a newline) are excluded — those are handled - * separately by CODE_BLOCK_RE. + * separately by codeBlockMatches. */ function scanInlineCodeSpans( text: string, @@ -125,7 +123,6 @@ function scanInlineCodeSpans( } // Scan for a closing run of exactly the same length. - let found = false; for (let j = i; j < text.length; j++) { if (text[j] !== "`") continue; const closeStart = j; @@ -133,7 +130,6 @@ function scanInlineCodeSpans( if (j - closeStart === runLen) { spans.push([openStart, j]); i = j; - found = true; break; } } @@ -190,6 +186,83 @@ function insideInlineCode( return spans.some(([s, e]) => pos > s && pos < e); } +function atFenceLineStart(text: string, pos: number): boolean { + const lineStart = text.lastIndexOf("\n", pos - 1) + 1; + return /^[ \t]{0,3}$/.test(text.slice(lineStart, pos)); +} + +function countBackticks(text: string, pos: number): number { + let end = pos; + while (end < text.length && text[end] === "`") end++; + return end - pos; +} + +function closingFence( + text: string, + contentStart: number, + fenceLen: number, +): { start: number; end: number } | undefined { + let pos = contentStart; + while (pos < text.length) { + const tickStart = text.indexOf("`", pos); + if (tickStart < 0) return undefined; + + const tickCount = countBackticks(text, tickStart); + if (tickCount >= fenceLen) { + return { start: tickStart, end: tickStart + tickCount }; + } + + pos = tickStart + tickCount; + } + return undefined; +} + +function codeBlockMatches(text: string): Match[] { + const matches: Match[] = []; + let pos = 0; + + while (pos < text.length) { + const start = text.indexOf("```", pos); + if (start < 0) break; + + if (!atFenceLineStart(text, start)) { + pos = start + 1; + continue; + } + + const fenceLen = countBackticks(text, start); + const infoStart = start + fenceLen; + const lineEnd = text.indexOf("\n", infoStart); + if (lineEnd < 0) break; + + const info = text.slice(infoStart, lineEnd); + if (info.includes("`")) { + pos = infoStart; + continue; + } + + const contentStart = lineEnd + 1; + const close = closingFence(text, contentStart, fenceLen); + if (close === undefined) { + pos = infoStart; + continue; + } + + matches.push({ + start, + end: close.end, + segment: { + type: "code", + content: text.slice(contentStart, close.start), + label: info.trim() || undefined, + }, + }); + pos = close.end; + } + + return matches; +} + function extractMatches(text: string, parseTools = true): Match[] { const matches: Match[] = []; @@ -271,22 +344,14 @@ function extractMatches(text: string, parseTools = true): Match[] { } } - for (const m of text.matchAll(CODE_BLOCK_RE)) { - const idx = m.index!; + for (const m of codeBlockMatches(text)) { + const idx = m.start; const insideOther = matches.some( (o) => idx >= o.start && idx < o.end, ); if (insideOther) continue; - matches.push({ - start: idx, - end: idx + m[0].length, - segment: { - type: "code", - content: m[2] ?? "", - label: m[1] || undefined, - }, - }); + matches.push(m); } return matches; From 80f01aed63422d6e1833d6991170098f6d333925 Mon Sep 17 00:00:00 2001 From: Wes McKinney Date: Wed, 17 Jun 2026 09:55:18 -0500 Subject: [PATCH 2/2] fix: require valid closing code fences --- frontend/src/lib/utils/content-parser.test.ts | 20 ++++++++++++++++++- frontend/src/lib/utils/content-parser.ts | 17 ++++++++++++---- 2 files changed, 32 insertions(+), 5 deletions(-) diff --git a/frontend/src/lib/utils/content-parser.test.ts b/frontend/src/lib/utils/content-parser.test.ts index 1141b1dfc..10517158d 100644 --- a/frontend/src/lib/utils/content-parser.test.ts +++ b/frontend/src/lib/utils/content-parser.test.ts @@ -81,7 +81,7 @@ describe("parseContent", () => { it("preserves leading whitespace in tail text", () => { const segments = - parseContent("```code\ncontent```\n Trailing text"); + parseContent("```code\ncontent\n```\n Trailing text"); expect(segments).toHaveLength(2); expect(segments[0]).toMatchObject({ type: "code" }); expect(segments[1]).toEqual({ @@ -125,6 +125,24 @@ describe("parseContent", () => { ]); }); + it("keeps inline same-length backtick runs inside code blocks", () => { + const content = + "```javascript\nconst fence = \"```\";\n[Thinking]\nnot parsed\n```\nAfter"; + const segments = parseContent(content); + expect(segments).toEqual([ + { + type: "code", + content: + "const fence = \"```\";\n[Thinking]\nnot parsed\n", + label: "javascript", + }, + { + type: "text", + content: "\nAfter", + }, + ]); + }); + it("omits label for code blocks without language", () => { const segments = parseContent("```\nplain code\n```"); expect(segments[0]).toEqual({ diff --git a/frontend/src/lib/utils/content-parser.ts b/frontend/src/lib/utils/content-parser.ts index 0f7f65425..c122477d9 100644 --- a/frontend/src/lib/utils/content-parser.ts +++ b/frontend/src/lib/utils/content-parser.ts @@ -201,15 +201,24 @@ function closingFence( text: string, contentStart: number, fenceLen: number, -): { start: number; end: number } | undefined { +): { contentEnd: number; end: number } | undefined { let pos = contentStart; while (pos < text.length) { const tickStart = text.indexOf("`", pos); if (tickStart < 0) return undefined; + const lineStart = text.lastIndexOf("\n", tickStart - 1) + 1; + const nextLineStart = text.indexOf("\n", tickStart); + const lineEnd = + nextLineStart >= 0 ? nextLineStart : text.length; const tickCount = countBackticks(text, tickStart); - if (tickCount >= fenceLen) { - return { start: tickStart, end: tickStart + tickCount }; + const rest = text.slice(tickStart + tickCount, lineEnd); + if ( + tickCount >= fenceLen && + atFenceLineStart(text, tickStart) && + /^[ \t]*$/.test(rest) + ) { + return { contentEnd: lineStart, end: lineEnd }; } pos = tickStart + tickCount; @@ -253,7 +262,7 @@ function codeBlockMatches(text: string): Match[] { end: close.end, segment: { type: "code", - content: text.slice(contentStart, close.start), + content: text.slice(contentStart, close.contentEnd), label: info.trim() || undefined, }, });