// 🤖 The allowlisted CommonMark subset for message rendering (issue #531). // // Messages are parsed by a real spec-compliant parser (vendored markdown-it, // web/static/vendor/markdown-it.js) instead of the hand-rolled regex passes in // format.js — a defined *subset* with an allowlist: emphasis, inline code, // links (http(s) only), lists, blockquotes, fenced code, strike. Constructs // outside the allowlist (headings, tables, HTML, images, …) stay literal text. // The native twin is MarkdownTree.swift (apple/swift-markdown walked with the // same allowlist); both sides normalize to the SAME semantic tree, pinned by // test/event-vectors/markdown.json — the vectors are the contract: when the // twins disagree, fix the implementations, never the vectors. // // Layering: parseMessage(text) → tree is pure (Node-testable, no DOM); // renderTree(el, tree, hooks) walks the tree into DOM nodes — createElement / // textContent only, never innerHTML (CLAUDE.md). Bare-URL autolinking is NOT // markdown-it's linkify: text runs go through format.js tokenizeText, so the // curated BARE_TLDS + trailing-punctuation rules survive verbatim, and a URL // inside emphasis links correctly (**github.com/x** → strong > link — the bug // that motivated #531). import MarkdownIt from "./vendor/markdown-it.js"; import { tokenizeText } from "./format.js"; // --- house parser configuration --------------------------------------------- // Only http(s) may become a real link — no relative paths, no mailto:, and // javascript:/data: die here. A rejected link never tokenizes, so the source // text stays literal automatically. function validateLink(url) { return /^https?:\/\//i.test(url); } // Single-OR-double-tilde strike (~s~ and ~~s~~), matching cmark-gfm on native // (its strikethrough extension accepts runs of 1 or 2; the DOUBLE_TILDE-only // option is never set by swift-markdown). Adapted from markdown-it's own // strikethrough rule, with two house differences: runs of 3+ tildes never // strike (they stay literal, as in cmark-gfm), and an opener only pairs with // a closer of the same length (~a~~ stays literal). History needs this rule: // old messages use the original grammar's single ~. function strike11Tokenize(state, silent) { const marker = state.src.charCodeAt(state.pos); if (silent || marker !== 0x7e /* ~ */) return false; const scanned = state.scanDelims(state.pos, true); const len = scanned.length; const token = state.push("text", "", 0); token.content = "~".repeat(len); if (len <= 2) { state.delimiters.push({ marker, length: len, token: state.tokens.length - 1, end: -1, open: scanned.can_open, close: scanned.can_close, }); } state.pos += len; return true; } function strike11Pair(state, delimiters) { for (let i = 0; i < delimiters.length; i++) { const open = delimiters[i]; if (open.marker !== 0x7e || open.end === -1) continue; const close = delimiters[open.end]; const openTok = state.tokens[open.token]; const closeTok = state.tokens[close.token]; if (openTok.content !== closeTok.content) continue; // ~ pairs with ~, ~~ with ~~ openTok.type = "s_open"; openTok.tag = "s"; openTok.nesting = 1; openTok.markup = openTok.content; openTok.content = ""; closeTok.type = "s_close"; closeTok.tag = "s"; closeTok.nesting = -1; closeTok.markup = openTok.markup; closeTok.content = ""; } } function strike11PostProcess(state) { strike11Pair(state, state.delimiters); for (const meta of state.tokens_meta) { if (meta && meta.delimiters) strike11Pair(state, meta.delimiters); } } // The allowlist is expressed ENABLE-ONLY from the "zero" preset, so a vendored // parser version bump can never sneak a new construct in. Never enabled (and // therefore literal text): heading, lheading, hr, code (indented — it mangles // pasted text), table, reference, html_block, html_inline, linkify, // smartquotes, replacements, strikethrough (markdown-it's ~~-only rule — // replaced by strike11 above). `image` IS enabled, deliberately: the tree // walk reconstructs images as literal text (below) — with the rule disabled, //  would half-parse as "!" + a link, which native cmark (where // images cannot be disabled) could not mirror. export function houseParser() { const md = new MarkdownIt("zero", { html: false, breaks: true, // every newline is a break in chat (renderer-level; tokens carry softbreak) linkify: false, typographer: false, }); md.enable([ "fence", "blockquote", "list", // block "newline", "escape", "backticks", "emphasis", "link", "image", "autolink", "entity", // inline ]); md.validateLink = validateLink; md.inline.ruler.before("emphasis", "strike11", strike11Tokenize); md.inline.ruler2.before("emphasis", "strike11", strike11PostProcess); return md; } const md = houseParser(); // --- token stream → semantic tree -------------------------------------------- // // Tree shape (the vector contract — see test/event-vectors/markdown.json): // blocks: {t:"p", children} {t:"quote", children:[blocks]} // {t:"ul", items:[[blocks]]} {t:"ol", start, items:[[blocks]]} // {t:"codeblock", text, lang} // inlines: {t:"text", text} {t:"br"} {t:"code", text} // {t:"link", href, children} {t:"strong"|"em"|"strike", children} function pushText(list, text) { if (!text) return; const last = list[list.length - 1]; // Adjacent text runs merge so both twins normalize to one canonical tree // (native splits text around inline HTML; web merges in text_join). if (last && last.t === "text") last.text += text; else list.push({ t: "text", text }); } // A plain text run outside links: format.js tokenizeText splits out bare URLs // under the house rules (curated TLDs, trailing punctuation stays text). function pushLinkified(list, text) { for (const tok of tokenizeText(text)) { if (tok.href) list.push({ t: "link", href: tok.href, children: [{ t: "text", text: tok.text }] }); else pushText(list, tok.text); } } const INLINE_WRAP = { em_open: "em", strong_open: "strong", s_open: "strike" }; function readInline(children) { const root = { t: "", children: [] }; const stack = [root]; let linkDepth = 0; const top = () => stack[stack.length - 1].children; for (const c of children || []) { if (c.type === "text") { if (linkDepth > 0) pushText(top(), c.content); else pushLinkified(top(), c.content); } else if (c.type === "softbreak" || c.type === "hardbreak") { top().push({ t: "br" }); } else if (c.type === "code_inline") { top().push({ t: "code", text: c.content }); } else if (INLINE_WRAP[c.type]) { const node = { t: INLINE_WRAP[c.type], children: [] }; top().push(node); stack.push(node); } else if (c.type === "em_close" || c.type === "strong_close" || c.type === "s_close") { if (stack.length > 1) stack.pop(); } else if (c.type === "link_open") { const node = { t: "link", href: c.attrGet("href") || "", children: [] }; top().push(node); stack.push(node); linkDepth++; } else if (c.type === "link_close") { if (stack.length > 1) stack.pop(); linkDepth--; } else if (c.type === "image") { // Outside the allowlist: reconstruct the literal source from the parsed // parts (alt rides in content, the destination in src). The native twin // rebuilds the same string from cmark's Image node. pushText(top(), " || "") + ")"); } else { // Unreachable under the enable-list — a safety net, not a contract. pushText(top(), (c.markup || "") + (c.content || "")); } } return root.children; } function readBlocks(tokens, i, closeType, srcLines) { const blocks = []; while (i < tokens.length) { const t = tokens[i]; if (t.type === closeType) return [blocks, i + 1]; if (t.type === "paragraph_open") { blocks.push({ t: "p", children: readInline(tokens[i + 1].children) }); i += 3; } else if (t.type === "fence") { blocks.push({ t: "codeblock", text: t.content.replace(/\n$/, ""), lang: (t.info || "").trim(), }); i++; } else if (t.type === "blockquote_open") { const [children, next] = readBlocks(tokens, i + 1, "blockquote_close", srcLines); blocks.push({ t: "quote", children }); i = next; } else if (t.type === "bullet_list_open" || t.type === "ordered_list_open") { const ordered = t.type === "ordered_list_open"; const items = []; let j = i + 1; while (j < tokens.length && tokens[j].type === "list_item_open") { const [itemBlocks, next] = readBlocks(tokens, j + 1, "list_item_close", srcLines); items.push(itemBlocks); j = next; } if (ordered) { blocks.push({ t: "ol", start: parseInt(t.attrGet("start") || "1", 10), items }); } else { blocks.push({ t: "ul", items }); } i = j + 1; // past the list_close } else { // Unreachable under the enable-list; recover exact source via the block // token's line map as a literal paragraph. if (t.map) { blocks.push({ t: "p", children: [{ t: "text", text: srcLines.slice(t.map[0], t.map[1]).join("\n") }] }); i++; while (i < tokens.length && tokens[i].map && tokens[i].map[1] <= t.map[1]) i++; } else { i++; } } } return [blocks, i]; } // The pure half: message text → semantic tree (exactly the vector shape). export function parseMessage(text) { const src = String(text ?? ""); const tokens = md.parse(src, {}); const [blocks] = readBlocks(tokens, 0, null, src.split("\n")); return blocks; } // --- tree → DOM --------------------------------------------------------------- const WRAP_TAG = { strong: "strong", em: "em", strike: "s" }; // hooks.plain(target, text): render a plain text run — chat.js passes its // Markdown-aware PLAIN projection for one-line surfaces (the sidebar's // last-message preview, notification bodies): escapes resolve (\* → *) and // the marks themselves drop — the same characters the bubbles show, minus // the styling. Never raw message text on those surfaces: the composer's // opt-out escapes leak as visible backslashes (Paul's report, 2026-07-15). // Twin: markdownPlainText (MarkdownTree.swift); the `plain` fields in // test/event-vectors/markdown.json pin the pair. export function plainText(text) { const inline = (children) => children .map((n) => { if (n.t === "text" || n.t === "code") return n.text; if (n.t === "br") return " "; if (n.children) return inline(n.children); // link / strong / em / strike return ""; }) .join(""); const blocks = (list) => list .map((b) => { if (b.t === "p") return inline(b.children); if (b.t === "quote") return blocks(b.children); if (b.t === "ul" || b.t === "ol") return b.items.map(blocks).join(" "); if (b.t === "codeblock") return b.text; return ""; }) .join(" "); return blocks(parseMessage(text)); } // mention scanner; default is a bare text node. Mentions are scanned only on // text runs, never inside code or links, matching the original feed. export function renderTree(el, blocks, hooks = {}) { const plain = hooks.plain || ((target, t) => target.appendChild(document.createTextNode(t))); const inline = (target, children) => { for (const n of children) { if (n.t === "text") { plain(target, n.text); } else if (n.t === "br") { target.appendChild(document.createElement("br")); } else if (n.t === "code") { const c = document.createElement("code"); c.textContent = n.text; target.appendChild(c); } else if (n.t === "link") { const a = document.createElement("a"); a.href = n.href; a.target = "_blank"; a.rel = "noopener noreferrer"; inline(a, n.children); target.appendChild(a); } else if (WRAP_TAG[n.t]) { const w = document.createElement(WRAP_TAG[n.t]); inline(w, n.children); target.appendChild(w); } } }; const block = (target, b) => { if (b.t === "p") { const p = document.createElement("p"); inline(p, b.children); target.appendChild(p); } else if (b.t === "codeblock") { const pre = document.createElement("pre"); const code = document.createElement("code"); code.textContent = b.text; pre.appendChild(code); target.appendChild(pre); } else if (b.t === "quote") { const q = document.createElement("blockquote"); for (const child of b.children) block(q, child); target.appendChild(q); } else if (b.t === "ul" || b.t === "ol") { const list = document.createElement(b.t); if (b.t === "ol" && b.start !== 1) list.start = b.start; for (const item of b.items) { const li = document.createElement("li"); for (const child of item) block(li, child); list.appendChild(li); } target.appendChild(list); } }; // The common case — one plain paragraph — keeps the original feed's DOM // shape (inline nodes appended bare, no
wrapper), so existing bubble // styling is untouched for ordinary messages. if (blocks.length === 1 && blocks[0].t === "p") { inline(el, blocks[0].children); return; } for (const b of blocks) block(el, b); }