fix(markdown): normalize inline HTML before rendering

This commit is contained in:
Mohamed Boudra
2026-10-15 16:08:55 +02:00
parent 648f290687
commit fca12a89d6
4 changed files with 136 additions and 1 deletions
+1 -1
View File
@@ -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.
@@ -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\b[^>]*>[\s\S]*?<\/code\s*>|${TAG})`,
"gi",
);
const EMPHASIS: Readonly<Record<string, string>> = { 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 <details> 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("<!--")) return "";
const code = /^<code\b[^>]*>([\s\S]*?)<\/code\s*>$/i.exec(raw);
if (code)
return inlineCode(
code[1].replace(/&(?:#(?:[xX][\da-fA-F]+|\d+)|[a-zA-Z][a-zA-Z\d]+);/g, (entity) =>
decodeString(entity),
),
);
const closing = raw.startsWith("</");
const name = /^<\/?([\w-]+)/.exec(raw)?.[1].toLowerCase() ?? "";
if (EMPHASIS[name]) return EMPHASIS[name];
if (name === "br") return closing ? "" : " \n";
if (name === "summary") return closing ? "**\n\n" : "**";
if (name === "details")
return (closing ? offset + raw.length === source.length : offset === 0) ? "" : "\n\n";
if (name === "a") return markdownAnchor(raw, closing, links);
if (name === "img" && !closing) {
const { src, alt = "" } = attributes(raw);
return src ? `![${alt.replace(/[[\]\\]/g, "\\$&")}](<${markdownUrl(src)}>)` : alt;
}
return "";
});
}
function attributes(tag: string): Record<string, string> {
let result: Record<string, string> = {};
const parser = new Parser({
onopentag: (_name, value) => {
result = value;
},
});
parser.end(tag);
return result;
}
function markdownUrl(url: string): string {
return url.replace(/[<>\r\n]/g, (character) => encodeURIComponent(character));
}
function inlineCode(content: string): string {
const runs = content.match(/`+/g) ?? [];
const marker = "`".repeat(Math.max(0, ...runs.map((run) => run.length)) + 1);
const padding = content.startsWith("`") || content.endsWith("`") ? " " : "";
return `${marker}${padding}${content}${padding}${marker}`;
}
function markdownAnchor(raw: string, closing: boolean, links: (string | undefined)[]): string {
if (closing) {
const href = links.pop();
return href === undefined ? "" : `](<${markdownUrl(href)}>)`;
}
const { href } = attributes(raw);
links.push(href);
return href === undefined ? "" : "[";
}
@@ -202,3 +202,49 @@ it.each([darkTheme, lightTheme])("supplies the theme selection colour to the ren
theme.colors.terminal.selectionBackground,
);
});
describe("GitHub comment HTML normalization", () => {
it.each([
[
'<img alt="P1" src="https://example.com/p1.svg" align="top">',
"![P1](<https://example.com/p1.svg>)",
],
['<a href="https://example.com/review">▶</a>', "[▶](<https://example.com/review>)"],
["Before<br>After<br/>End", "Before \nAfter \nEnd"],
["<b>bold</b> and <strong>strong</strong>", "**bold** and **strong**"],
["<i>italic</i> and <em>emphasis</em>", "*italic* and *emphasis*"],
["Use <code>const n = 1</code>.", "Use `const n = 1`."],
["H<sub>2</sub>O and x<sup>2</sup>", "H2O and x2"],
[
"<details><summary>Review details</summary>Body **stays**.</details>",
"**Review details**\n\nBody **stays**.",
],
["Before<!-- private -->After", "BeforeAfter"],
["<div><span>Keep the content</span></div>", "Keep the content"],
[
'<a href="https://example.com"><strong>Review</strong></a>',
"[**Review**](<https://example.com>)",
],
['`<img src="literal.png">` and <b>bold</b>', '`<img src="literal.png">` and **bold**'],
[
'```html\n<img src="literal.png">\n```\n\n<b>bold</b>',
'```html\n<img src="literal.png">\n```\n\n**bold**',
],
['~~~html\n<a href="literal">link</a>\n~~~', '~~~html\n<a href="literal">link</a>\n~~~'],
[
'<details><summary>Code</summary>\n\n```html\n<img src="literal.png">\n```\n</details>',
'**Code**\n\n\n\n```html\n<img src="literal.png">\n```\n',
],
[
"<https://example.com> and <user@example.com>",
"<https://example.com> and <user@example.com>",
],
])("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`<code>\*literal\* &lt;img&gt;</code>`;
expect(prepareMarkdownSource(source).render()).toBe("`" + String.raw`\*literal\* <img>` + "`");
});
@@ -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 = () =>