From fca12a89d64490b9f1f32e7adacc72a38d79d430 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Thu, 15 Oct 2026 16:08:55 +0200 Subject: [PATCH] fix(markdown): normalize inline HTML before rendering --- docs/markdown-renderer.md | 2 +- packages/app/src/components/markdown/html.ts | 87 +++++++++++++++++++ .../src/components/markdown/renderer.test.ts | 46 ++++++++++ .../app/src/components/markdown/source.ts | 2 + 4 files changed, 136 insertions(+), 1 deletion(-) create mode 100644 packages/app/src/components/markdown/html.ts diff --git a/docs/markdown-renderer.md b/docs/markdown-renderer.md index be74ab25d3..d934a8a98e 100644 --- a/docs/markdown-renderer.md +++ b/docs/markdown-renderer.md @@ -14,7 +14,7 @@ Resolve workspace image paths over the existing session RPC, then give the libra The pinned nightly has the same React Native package and core source trees as upstream commit `73ac5085eb1fdc743973b3473d4dba65abad5261`. Compatibility was checked with React Native 0.81.5. Native dependencies must be rebuilt when changing the highlighting configuration; JavaScript refresh cannot enable compiled grammars. -Math and video are disabled. Task lists and admonitions are enabled; task checkboxes are read-only. Source adaptation spaces adjacent empty GFM table delimiters before escaping prose spoiler delimiters and decodes entity text before the engine can discard it. Code spans and fences keep their literal source. HTML and details collapsibles have no custom renderer. +Math and video are disabled. Task lists and admonitions are enabled; task checkboxes are read-only. Source adaptation spaces adjacent empty GFM table delimiters before escaping prose spoiler delimiters and decodes entity text before the engine can discard it. Code spans and fences keep their literal source. Comment HTML is normalized to Markdown before parsing; details show their summary and body expanded. Mermaid fences use the existing isolated diagram runtime and fullscreen viewer. Web dispatches them through the library’s code-block override. Native has no block-renderer hook, so the renderer extracts only Mermaid fences and places the existing WebView host between library-rendered segments. This is the only block exception; selection stops at a diagram boundary. Streaming keeps the last valid diagram visible while the next revision renders. diff --git a/packages/app/src/components/markdown/html.ts b/packages/app/src/components/markdown/html.ts new file mode 100644 index 0000000000..464c65d729 --- /dev/null +++ b/packages/app/src/components/markdown/html.ts @@ -0,0 +1,87 @@ +import { Parser } from "htmlparser2"; +import { fromMarkdown } from "mdast-util-from-markdown"; +import { decodeString } from "micromark-util-decode-string"; +import type { Nodes } from "mdast"; + +const ATTRIBUTE = String.raw`\s+[A-Za-z_:][\w.:-]*(?:\s*=\s*(?:[^\s"'=<>\x60]+|'[^']*'|"[^"]*"))?`; +const TAG = String.raw`<(?:[A-Za-z][A-Za-z0-9-]*(?:${ATTRIBUTE})*\s*\/?|\/[A-Za-z][A-Za-z0-9-]*\s*)>`; +const HTML = new RegExp( + String.raw`(?|]*>[\s\S]*?<\/code\s*>|${TAG})`, + "gi", +); +const EMPHASIS: Readonly> = { b: "**", strong: "**", i: "*", em: "*" }; + +/** Convert comment HTML to Markdown while preserving the original spelling of code. */ +export function normalizeMarkdownHtml(source: string): string { + if (!source.includes("<")) return source; + const codeRanges: { start: number; end: number }[] = []; + // Disable HTML parsing so code inside
remains visible to the Markdown parser. + const tree = fromMarkdown(source, { + extensions: [{ disable: { null: ["htmlFlow", "htmlText"] } }], + }); + function protectCode(node: Nodes) { + if (node.type === "code" || node.type === "inlineCode") { + const start = node.position?.start.offset; + const end = node.position?.end.offset; + if (start !== undefined && end !== undefined) codeRanges.push({ start, end }); + } else if ("children" in node) node.children.forEach(protectCode); + } + protectCode(tree); + const links: (string | undefined)[] = []; + return source.replace(HTML, (raw: string, offset: number) => { + if (codeRanges.some(({ start, end }) => offset >= start && offset < end)) return raw; + if (raw.startsWith("After", "BeforeAfter"], + ["
Keep the content
", "Keep the content"], + [ + 'Review', + "[**Review**]()", + ], + ['`` and bold', '`` and **bold**'], + [ + '```html\n\n```\n\nbold', + '```html\n\n```\n\n**bold**', + ], + ['~~~html\nlink\n~~~', '~~~html\nlink\n~~~'], + [ + '
Code\n\n```html\n\n```\n
', + '**Code**\n\n\n\n```html\n\n```\n', + ], + [ + " and ", + " and ", + ], + ])("normalizes %s without changing code", (source, expected) => { + expect(prepareMarkdownSource(source).render()).toBe(expected); + }); +}); + +it("preserves backslashes and decodes entities inside HTML code", () => { + const source = String.raw`\*literal\* <img>`; + expect(prepareMarkdownSource(source).render()).toBe("`" + String.raw`\*literal\* ` + "`"); +}); diff --git a/packages/app/src/components/markdown/source.ts b/packages/app/src/components/markdown/source.ts index da220c0753..cf81511860 100644 --- a/packages/app/src/components/markdown/source.ts +++ b/packages/app/src/components/markdown/source.ts @@ -1,3 +1,4 @@ +import { normalizeMarkdownHtml } from "./html"; import { gfmTable } from "micromark-extension-gfm-table"; import { gfmTableFromMarkdown } from "mdast-util-gfm-table"; import type { AssistantFileLinkSource } from "@/assistant-file-links"; @@ -49,6 +50,7 @@ function workspaceHoverTitle(link: Link, markup: string, workspaceRoot?: string) /** Preserve source spelling; adapt workspace images, spoiler delimiters, and entity text. */ export function prepareMarkdownSource(source: string, workspaceRoot?: string) { + source = normalizeMarkdownHtml(source); const images: MarkdownImage[] = []; let tree: Root | undefined; const getTree = () =>