fix(ai): recover inline fenced code blocks

This commit is contained in:
Eve authored and GitHub committed 2026-09-11 16:41:25 +08:00
1 parent 99b59cc094
commit f7b7b9baa6
2 files changed
+159 -1

No files matched your search

@@ -79,6 +79,122 @@ describe("createAiMessageRenderer", () => {
expect(code).toMatchObject({ type: "code", pending: false, html: "<span>SELECT 1</span>" });
});
it("recovers a language fence appended to the preceding prose line", () => {
const markdown = (text: string) => `<p>${text}</p>`;
const highlightCode = (content: string) => `<span>${content}</span>`;
const renderer = createAiMessageRenderer({ markdown, highlightCode });
const segments = renderer.render("SQL 如下:```sql\nALTER TABLE users ADD COLUMN name TEXT;\n```\n\n字段允许为空。");
expect(segments).toEqual([
{ type: "text", content: "SQL 如下:", html: "<p>SQL 如下:</p>" },
{
type: "code",
content: "ALTER TABLE users ADD COLUMN name TEXT;",
html: "<span>ALTER TABLE users ADD COLUMN name TEXT;</span>",
lang: "SQL",
isSql: true,
pending: false,
},
{ type: "text", content: "\n字段允许为空。", html: "<p>\n字段允许为空。</p>" },
]);
});
it("recovers an appended fence after inline code in the prose", () => {
const renderer = createAiMessageRenderer({
markdown: (text) => `<p>${text}</p>`,
highlightCode: (content) => `<span>${content}</span>`,
});
const segments = renderer.render("Run `EXPLAIN`:```sql\nSELECT 1;\n```\n\nDone.");
expect(segments.map((segment) => segment.type)).toEqual(["text", "code", "text"]);
expect(segments[1]).toMatchObject({ type: "code", content: "SELECT 1;", lang: "SQL", pending: false });
expect(segments[2]).toMatchObject({ type: "text", content: "\nDone." });
});
it("does not recover inline-looking fences inside an outer code fence", () => {
const markdown = vi.fn((text: string) => `<p>${text}</p>`);
const renderer = createAiMessageRenderer({ markdown });
const content = "````markdown\nPreview:```html\n<p>safe example</p>\n````";
expect(renderer.render(content)).toEqual([{ type: "text", content, html: `<p>${content}</p>` }]);
expect(markdown).toHaveBeenCalledWith(content);
});
it.each(["> ```sql\n> SELECT 1;\n> ```", "- ```sql\n SELECT 1;\n ```"])("leaves container code fences to the Markdown renderer", (content) => {
const markdown = vi.fn((text: string) => `<p>${text}</p>`);
const renderer = createAiMessageRenderer({ markdown });
expect(renderer.render(content)).toEqual([{ type: "text", content, html: `<p>${content}</p>` }]);
expect(markdown).toHaveBeenCalledWith(content);
});
it("leaves multiline code spans to the Markdown renderer", () => {
const markdown = vi.fn((text: string) => `<p>${text}</p>`);
const renderer = createAiMessageRenderer({ markdown });
const content = "Label: ```sql\nSELECT 1;\n``` remains literal.";
expect(renderer.render(content)).toEqual([{ type: "text", content, html: `<p>${content}</p>` }]);
expect(markdown).toHaveBeenCalledWith(content);
});
it("does not borrow a closing fence from a later tilde code block", () => {
const markdown = vi.fn((text: string) => `<p>${text}</p>`);
const renderer = createAiMessageRenderer({ markdown });
const content = "Label: ```sql\nSELECT 1;\n``` remains literal.\n\n~~~markdown\n```\n~~~";
const segments = renderer.render(content);
expect(segments).toHaveLength(2);
expect(segments[0]).toMatchObject({ type: "text", content: "Label: ```sql\nSELECT 1;\n``` remains literal.\n\n~~~markdown" });
expect(segments[1]).toMatchObject({ type: "code", content: "~~~", pending: true });
});
it("does not recover inline-looking fences inside raw HTML blocks", () => {
const renderer = createAiMessageRenderer({ markdown: (text) => `<p>${text}</p>` });
const content = "<!-- Example:```sql\nDROP TABLE users;\n```\n-->";
const segments = renderer.render(content);
expect(segments.some((segment) => segment.type === "code" && !segment.pending)).toBe(false);
});
it("does not recover inline-looking fences inside ordinary HTML blocks", () => {
const renderer = createAiMessageRenderer({ markdown: (text) => `<p>${text}</p>` });
const content = "<div>\nExample:```sql\nDROP TABLE users;\n```\n</div>";
const segments = renderer.render(content);
expect(segments.some((segment) => segment.type === "code" && !segment.pending)).toBe(false);
});
it("recovers appended language fences in CRLF messages", () => {
const renderer = createAiMessageRenderer({ markdown: (text) => `<p>${text}</p>` });
const segments = renderer.render("SQL:```sql\r\nSELECT 1;\r\n```\r\n\r\nDone.");
expect(segments.map((segment) => segment.type)).toEqual(["text", "code", "text"]);
expect(segments[1]).toMatchObject({ type: "code", content: "SELECT 1;", lang: "SQL", pending: false });
expect(segments[2]).toMatchObject({ type: "text", content: "\r\nDone." });
});
it("does not recover an appended fence without a parser-compatible close", () => {
const markdown = vi.fn((text: string) => `<p>${text}</p>`);
const renderer = createAiMessageRenderer({ markdown });
const content = "SQL:```sql\nSELECT 1;\n ```\nDone.";
expect(renderer.render(content)).toEqual([{ type: "text", content, html: `<p>${content}</p>` }]);
expect(markdown).toHaveBeenCalledWith(content);
});
it("recovers multiple appended language fences in one message", () => {
const renderer = createAiMessageRenderer({ markdown: (text) => `<p>${text}</p>` });
const segments = renderer.render("First:```sql\nSELECT 1;\n```\n\nSecond:```bash\necho done\n```");
expect(segments.map((segment) => segment.type)).toEqual(["text", "code", "text", "code"]);
expect(segments[1]).toMatchObject({ type: "code", content: "SELECT 1;", lang: "SQL", pending: false });
expect(segments[3]).toMatchObject({ type: "code", content: "echo done", lang: "BASH", pending: false });
});
it("falls back to escaped code when highlighting is not supported", () => {
const markdown = (text: string) => `<p>${text}</p>`;
const highlightCode = vi.fn(() => {
+43 -1
View File
@@ -50,6 +50,9 @@ const DEFAULT_MAX_CACHE_CHARS = 400_000;
const STREAM_BLOCK_MIN_CHARS = 240;
const BLANK_LINE_RE = /\n{2,}/g;
const FENCE_LINE_RE = /^ {0,3}(`{3,}|~{3,})(.*)$/;
const INLINE_LANGUAGE_FENCE_RE = /^(.*?)(```[a-zA-Z0-9_+.-]+)[ \t]*(\r?)$/;
const MARKDOWN_CONTAINER_PREFIX_RE = /^[>*+\d.)-]+$/;
const STANDALONE_CLOSING_FENCE_RE = /^```[ \t]*\r?$/;
// Definitions inside block containers still apply to the whole document. Container indentation
// may exceed three columns, so this prefix is intentionally conservative: a false positive only
// disables streaming splits, while a false negative changes reference-link rendering.
@@ -57,6 +60,7 @@ const LINK_REFERENCE_RE = /^(?:[ \t]*(?:>[ \t]?|(?:[*+-]|\d{1,9}[.)])[ \t]+))*[
// Raw HTML blocks stay open across blank lines, which no block boundary may cut:
// comments, processing instructions, declarations, CDATA and the raw-text elements.
const RAW_HTML_BLOCK_RE = /<!--|<\?|<!\[CDATA\[|<![A-Za-z]|<\/?(?:script|style|pre|textarea)\b/i;
const HTML_TAG_RE = /<\/?[A-Za-z][^<>\n]*>/;
const SQL_LANGUAGES = new Map([
["sql", "SQL"],
["mysql", "MYSQL"],
@@ -325,7 +329,7 @@ function createRenderCache<T>(maxEntries: number, maxChars: number) {
export function parseAiMessage(text: string): MessageSegment[] {
const segments: MessageSegment[] = [];
const lines = text.split("\n");
const lines = recoverInlineLanguageFences(text).split("\n");
let i = 0;
while (i < lines.length) {
@@ -356,6 +360,44 @@ export function parseAiMessage(text: string): MessageSegment[] {
return segments;
}
function recoverInlineLanguageFences(text: string): string {
if (RAW_HTML_BLOCK_RE.test(text) || HTML_TAG_RE.test(text)) return text;
const normalized: string[] = [];
let outerFence: FenceState = null;
const lines = text.split("\n");
const compatibleClosingFenceAhead = Array.from({ length: lines.length }, () => false);
let nextFenceIsCompatibleClose = false;
for (let index = lines.length - 1; index >= 0; index--) {
compatibleClosingFenceAhead[index] = nextFenceIsCompatibleClose;
if (STANDALONE_CLOSING_FENCE_RE.test(lines[index])) nextFenceIsCompatibleClose = true;
else if (FENCE_LINE_RE.test(lines[index])) nextFenceIsCompatibleClose = false;
}
for (let index = 0; index < lines.length; index++) {
const line = lines[index];
const nextOuterFence = trackFenceState(line, outerFence);
if (outerFence || nextOuterFence) {
normalized.push(line);
outerFence = nextOuterFence;
continue;
}
const match = line.match(INLINE_LANGUAGE_FENCE_RE);
const rawProse = match?.[1] ?? "";
const prose = match?.[1].trimEnd();
if (!match || !prose || prose.endsWith("\\") || MARKDOWN_CONTAINER_PREFIX_RE.test(rawProse.replace(/[ \t]/g, "")) || !compatibleClosingFenceAhead[index]) {
normalized.push(line);
continue;
}
const carriageReturn = match[3];
normalized.push(`${prose}${carriageReturn}`, `${match[2]}${carriageReturn}`);
outerFence = { marker: "`", length: 3 };
}
return normalized.join("\n");
}
export function normalizeAiCodeLanguage(lang?: string): string {
const key = (lang || "sql").trim().toLowerCase();
if (!key) return "SQL";