| 1 | // π€ The allowlisted CommonMark subset for message rendering (issue #531). |
| 2 | // |
| 3 | // Messages are parsed by a real spec-compliant parser (vendored markdown-it, |
| 4 | // web/static/vendor/markdown-it.js) instead of the hand-rolled regex passes in |
| 5 | // format.js β a defined *subset* with an allowlist: emphasis, inline code, |
| 6 | // links (http(s) only), lists, blockquotes, fenced code, strike. Constructs |
| 7 | // outside the allowlist (headings, tables, HTML, images, β¦) stay literal text. |
| 8 | // The native twin is MarkdownTree.swift (apple/swift-markdown walked with the |
| 9 | // same allowlist); both sides normalize to the SAME semantic tree, pinned by |
| 10 | // test/event-vectors/markdown.json β the vectors are the contract: when the |
| 11 | // twins disagree, fix the implementations, never the vectors. |
| 12 | // |
| 13 | // Layering: parseMessage(text) β tree is pure (Node-testable, no DOM); |
| 14 | // renderTree(el, tree, hooks) walks the tree into DOM nodes β createElement / |
| 15 | // textContent only, never innerHTML (CLAUDE.md). Bare-URL autolinking is NOT |
| 16 | // markdown-it's linkify: text runs go through format.js tokenizeText, so the |
| 17 | // curated BARE_TLDS + trailing-punctuation rules survive verbatim, and a URL |
| 18 | // inside emphasis links correctly (**github.com/x** β strong > link β the bug |
| 19 | // that motivated #531). |
| 20 | |
| 21 | import MarkdownIt from "./vendor/markdown-it.js"; |
| 22 | import { tokenizeText } from "./format.js"; |
| 23 | |
| 24 | // --- house parser configuration --------------------------------------------- |
| 25 | |
| 26 | // Only http(s) may become a real link β no relative paths, no mailto:, and |
| 27 | // javascript:/data: die here. A rejected link never tokenizes, so the source |
| 28 | // text stays literal automatically. |
| 29 | function validateLink(url) { |
| 30 | return /^https?:\/\//i.test(url); |
| 31 | } |
| 32 | |
| 33 | // Single-OR-double-tilde strike (~s~ and ~~s~~), matching cmark-gfm on native |
| 34 | // (its strikethrough extension accepts runs of 1 or 2; the DOUBLE_TILDE-only |
| 35 | // option is never set by swift-markdown). Adapted from markdown-it's own |
| 36 | // strikethrough rule, with two house differences: runs of 3+ tildes never |
| 37 | // strike (they stay literal, as in cmark-gfm), and an opener only pairs with |
| 38 | // a closer of the same length (~a~~ stays literal). History needs this rule: |
| 39 | // old messages use the original grammar's single ~. |
| 40 | function strike11Tokenize(state, silent) { |
| 41 | const marker = state.src.charCodeAt(state.pos); |
| 42 | if (silent || marker !== 0x7e /* ~ */) return false; |
| 43 | const scanned = state.scanDelims(state.pos, true); |
| 44 | const len = scanned.length; |
| 45 | const token = state.push("text", "", 0); |
| 46 | token.content = "~".repeat(len); |
| 47 | if (len <= 2) { |
| 48 | state.delimiters.push({ |
| 49 | marker, |
| 50 | length: len, |
| 51 | token: state.tokens.length - 1, |
| 52 | end: -1, |
| 53 | open: scanned.can_open, |
| 54 | close: scanned.can_close, |
| 55 | }); |
| 56 | } |
| 57 | state.pos += len; |
| 58 | return true; |
| 59 | } |
| 60 | |
| 61 | function strike11Pair(state, delimiters) { |
| 62 | for (let i = 0; i < delimiters.length; i++) { |
| 63 | const open = delimiters[i]; |
| 64 | if (open.marker !== 0x7e || open.end === -1) continue; |
| 65 | const close = delimiters[open.end]; |
| 66 | const openTok = state.tokens[open.token]; |
| 67 | const closeTok = state.tokens[close.token]; |
| 68 | if (openTok.content !== closeTok.content) continue; // ~ pairs with ~, ~~ with ~~ |
| 69 | openTok.type = "s_open"; |
| 70 | openTok.tag = "s"; |
| 71 | openTok.nesting = 1; |
| 72 | openTok.markup = openTok.content; |
| 73 | openTok.content = ""; |
| 74 | closeTok.type = "s_close"; |
| 75 | closeTok.tag = "s"; |
| 76 | closeTok.nesting = -1; |
| 77 | closeTok.markup = openTok.markup; |
| 78 | closeTok.content = ""; |
| 79 | } |
| 80 | } |
| 81 | |
| 82 | function strike11PostProcess(state) { |
| 83 | strike11Pair(state, state.delimiters); |
| 84 | for (const meta of state.tokens_meta) { |
| 85 | if (meta && meta.delimiters) strike11Pair(state, meta.delimiters); |
| 86 | } |
| 87 | } |
| 88 | |
| 89 | // The allowlist is expressed ENABLE-ONLY from the "zero" preset, so a vendored |
| 90 | // parser version bump can never sneak a new construct in. Never enabled (and |
| 91 | // therefore literal text): heading, lheading, hr, code (indented β it mangles |
| 92 | // pasted text), table, reference, html_block, html_inline, linkify, |
| 93 | // smartquotes, replacements, strikethrough (markdown-it's ~~-only rule β |
| 94 | // replaced by strike11 above). `image` IS enabled, deliberately: the tree |
| 95 | // walk reconstructs images as literal text (below) β with the rule disabled, |
| 96 | //  would half-parse as "!" + a link, which native cmark (where |
| 97 | // images cannot be disabled) could not mirror. |
| 98 | export function houseParser() { |
| 99 | const md = new MarkdownIt("zero", { |
| 100 | html: false, |
| 101 | breaks: true, // every newline is a break in chat (renderer-level; tokens carry softbreak) |
| 102 | linkify: false, |
| 103 | typographer: false, |
| 104 | }); |
| 105 | md.enable([ |
| 106 | "fence", "blockquote", "list", // block |
| 107 | "newline", "escape", "backticks", "emphasis", "link", "image", "autolink", "entity", // inline |
| 108 | ]); |
| 109 | md.validateLink = validateLink; |
| 110 | md.inline.ruler.before("emphasis", "strike11", strike11Tokenize); |
| 111 | md.inline.ruler2.before("emphasis", "strike11", strike11PostProcess); |
| 112 | return md; |
| 113 | } |
| 114 | |
| 115 | const md = houseParser(); |
| 116 | |
| 117 | // --- token stream β semantic tree -------------------------------------------- |
| 118 | // |
| 119 | // Tree shape (the vector contract β see test/event-vectors/markdown.json): |
| 120 | // blocks: {t:"p", children} {t:"quote", children:[blocks]} |
| 121 | // {t:"ul", items:[[blocks]]} {t:"ol", start, items:[[blocks]]} |
| 122 | // {t:"codeblock", text, lang} |
| 123 | // inlines: {t:"text", text} {t:"br"} {t:"code", text} |
| 124 | // {t:"link", href, children} {t:"strong"|"em"|"strike", children} |
| 125 | |
| 126 | function pushText(list, text) { |
| 127 | if (!text) return; |
| 128 | const last = list[list.length - 1]; |
| 129 | // Adjacent text runs merge so both twins normalize to one canonical tree |
| 130 | // (native splits text around inline HTML; web merges in text_join). |
| 131 | if (last && last.t === "text") last.text += text; |
| 132 | else list.push({ t: "text", text }); |
| 133 | } |
| 134 | |
| 135 | // A plain text run outside links: format.js tokenizeText splits out bare URLs |
| 136 | // under the house rules (curated TLDs, trailing punctuation stays text). |
| 137 | function pushLinkified(list, text) { |
| 138 | for (const tok of tokenizeText(text)) { |
| 139 | if (tok.href) list.push({ t: "link", href: tok.href, children: [{ t: "text", text: tok.text }] }); |
| 140 | else pushText(list, tok.text); |
| 141 | } |
| 142 | } |
| 143 | |
| 144 | const INLINE_WRAP = { em_open: "em", strong_open: "strong", s_open: "strike" }; |
| 145 | |
| 146 | function readInline(children) { |
| 147 | const root = { t: "", children: [] }; |
| 148 | const stack = [root]; |
| 149 | let linkDepth = 0; |
| 150 | const top = () => stack[stack.length - 1].children; |
| 151 | for (const c of children || []) { |
| 152 | if (c.type === "text") { |
| 153 | if (linkDepth > 0) pushText(top(), c.content); |
| 154 | else pushLinkified(top(), c.content); |
| 155 | } else if (c.type === "softbreak" || c.type === "hardbreak") { |
| 156 | top().push({ t: "br" }); |
| 157 | } else if (c.type === "code_inline") { |
| 158 | top().push({ t: "code", text: c.content }); |
| 159 | } else if (INLINE_WRAP[c.type]) { |
| 160 | const node = { t: INLINE_WRAP[c.type], children: [] }; |
| 161 | top().push(node); |
| 162 | stack.push(node); |
| 163 | } else if (c.type === "em_close" || c.type === "strong_close" || c.type === "s_close") { |
| 164 | if (stack.length > 1) stack.pop(); |
| 165 | } else if (c.type === "link_open") { |
| 166 | const node = { t: "link", href: c.attrGet("href") || "", children: [] }; |
| 167 | top().push(node); |
| 168 | stack.push(node); |
| 169 | linkDepth++; |
| 170 | } else if (c.type === "link_close") { |
| 171 | if (stack.length > 1) stack.pop(); |
| 172 | linkDepth--; |
| 173 | } else if (c.type === "image") { |
| 174 | // Outside the allowlist: reconstruct the literal source from the parsed |
| 175 | // parts (alt rides in content, the destination in src). The native twin |
| 176 | // rebuilds the same string from cmark's Image node. |
| 177 | pushText(top(), " || "") + ")"); |
| 178 | } else { |
| 179 | // Unreachable under the enable-list β a safety net, not a contract. |
| 180 | pushText(top(), (c.markup || "") + (c.content || "")); |
| 181 | } |
| 182 | } |
| 183 | return root.children; |
| 184 | } |
| 185 | |
| 186 | function readBlocks(tokens, i, closeType, srcLines) { |
| 187 | const blocks = []; |
| 188 | while (i < tokens.length) { |
| 189 | const t = tokens[i]; |
| 190 | if (t.type === closeType) return [blocks, i + 1]; |
| 191 | if (t.type === "paragraph_open") { |
| 192 | blocks.push({ t: "p", children: readInline(tokens[i + 1].children) }); |
| 193 | i += 3; |
| 194 | } else if (t.type === "fence") { |
| 195 | blocks.push({ |
| 196 | t: "codeblock", |
| 197 | text: t.content.replace(/\n$/, ""), |
| 198 | lang: (t.info || "").trim(), |
| 199 | }); |
| 200 | i++; |
| 201 | } else if (t.type === "blockquote_open") { |
| 202 | const [children, next] = readBlocks(tokens, i + 1, "blockquote_close", srcLines); |
| 203 | blocks.push({ t: "quote", children }); |
| 204 | i = next; |
| 205 | } else if (t.type === "bullet_list_open" || t.type === "ordered_list_open") { |
| 206 | const ordered = t.type === "ordered_list_open"; |
| 207 | const items = []; |
| 208 | let j = i + 1; |
| 209 | while (j < tokens.length && tokens[j].type === "list_item_open") { |
| 210 | const [itemBlocks, next] = readBlocks(tokens, j + 1, "list_item_close", srcLines); |
| 211 | items.push(itemBlocks); |
| 212 | j = next; |
| 213 | } |
| 214 | if (ordered) { |
| 215 | blocks.push({ t: "ol", start: parseInt(t.attrGet("start") || "1", 10), items }); |
| 216 | } else { |
| 217 | blocks.push({ t: "ul", items }); |
| 218 | } |
| 219 | i = j + 1; // past the list_close |
| 220 | } else { |
| 221 | // Unreachable under the enable-list; recover exact source via the block |
| 222 | // token's line map as a literal paragraph. |
| 223 | if (t.map) { |
| 224 | blocks.push({ t: "p", children: [{ t: "text", text: srcLines.slice(t.map[0], t.map[1]).join("\n") }] }); |
| 225 | i++; |
| 226 | while (i < tokens.length && tokens[i].map && tokens[i].map[1] <= t.map[1]) i++; |
| 227 | } else { |
| 228 | i++; |
| 229 | } |
| 230 | } |
| 231 | } |
| 232 | return [blocks, i]; |
| 233 | } |
| 234 | |
| 235 | // The pure half: message text β semantic tree (exactly the vector shape). |
| 236 | export function parseMessage(text) { |
| 237 | const src = String(text ?? ""); |
| 238 | const tokens = md.parse(src, {}); |
| 239 | const [blocks] = readBlocks(tokens, 0, null, src.split("\n")); |
| 240 | return blocks; |
| 241 | } |
| 242 | |
| 243 | // --- tree β DOM --------------------------------------------------------------- |
| 244 | |
| 245 | const WRAP_TAG = { strong: "strong", em: "em", strike: "s" }; |
| 246 | |
| 247 | // hooks.plain(target, text): render a plain text run β chat.js passes its |
| 248 | // Markdown-aware PLAIN projection for one-line surfaces (the sidebar's |
| 249 | // last-message preview, notification bodies): escapes resolve (\* β *) and |
| 250 | // the marks themselves drop β the same characters the bubbles show, minus |
| 251 | // the styling. Never raw message text on those surfaces: the composer's |
| 252 | // opt-out escapes leak as visible backslashes (Paul's report, 2026-07-15). |
| 253 | // Twin: markdownPlainText (MarkdownTree.swift); the `plain` fields in |
| 254 | // test/event-vectors/markdown.json pin the pair. |
| 255 | export function plainText(text) { |
| 256 | const inline = (children) => |
| 257 | children |
| 258 | .map((n) => { |
| 259 | if (n.t === "text" || n.t === "code") return n.text; |
| 260 | if (n.t === "br") return " "; |
| 261 | if (n.children) return inline(n.children); // link / strong / em / strike |
| 262 | return ""; |
| 263 | }) |
| 264 | .join(""); |
| 265 | const blocks = (list) => |
| 266 | list |
| 267 | .map((b) => { |
| 268 | if (b.t === "p") return inline(b.children); |
| 269 | if (b.t === "quote") return blocks(b.children); |
| 270 | if (b.t === "ul" || b.t === "ol") return b.items.map(blocks).join(" "); |
| 271 | if (b.t === "codeblock") return b.text; |
| 272 | return ""; |
| 273 | }) |
| 274 | .join(" "); |
| 275 | return blocks(parseMessage(text)); |
| 276 | } |
| 277 | |
| 278 | // mention scanner; default is a bare text node. Mentions are scanned only on |
| 279 | // text runs, never inside code or links, matching the original feed. |
| 280 | export function renderTree(el, blocks, hooks = {}) { |
| 281 | const plain = hooks.plain || ((target, t) => target.appendChild(document.createTextNode(t))); |
| 282 | const inline = (target, children) => { |
| 283 | for (const n of children) { |
| 284 | if (n.t === "text") { |
| 285 | plain(target, n.text); |
| 286 | } else if (n.t === "br") { |
| 287 | target.appendChild(document.createElement("br")); |
| 288 | } else if (n.t === "code") { |
| 289 | const c = document.createElement("code"); |
| 290 | c.textContent = n.text; |
| 291 | target.appendChild(c); |
| 292 | } else if (n.t === "link") { |
| 293 | const a = document.createElement("a"); |
| 294 | a.href = n.href; |
| 295 | a.target = "_blank"; |
| 296 | a.rel = "noopener noreferrer"; |
| 297 | inline(a, n.children); |
| 298 | target.appendChild(a); |
| 299 | } else if (WRAP_TAG[n.t]) { |
| 300 | const w = document.createElement(WRAP_TAG[n.t]); |
| 301 | inline(w, n.children); |
| 302 | target.appendChild(w); |
| 303 | } |
| 304 | } |
| 305 | }; |
| 306 | const block = (target, b) => { |
| 307 | if (b.t === "p") { |
| 308 | const p = document.createElement("p"); |
| 309 | inline(p, b.children); |
| 310 | target.appendChild(p); |
| 311 | } else if (b.t === "codeblock") { |
| 312 | const pre = document.createElement("pre"); |
| 313 | const code = document.createElement("code"); |
| 314 | code.textContent = b.text; |
| 315 | pre.appendChild(code); |
| 316 | target.appendChild(pre); |
| 317 | } else if (b.t === "quote") { |
| 318 | const q = document.createElement("blockquote"); |
| 319 | for (const child of b.children) block(q, child); |
| 320 | target.appendChild(q); |
| 321 | } else if (b.t === "ul" || b.t === "ol") { |
| 322 | const list = document.createElement(b.t); |
| 323 | if (b.t === "ol" && b.start !== 1) list.start = b.start; |
| 324 | for (const item of b.items) { |
| 325 | const li = document.createElement("li"); |
| 326 | for (const child of item) block(li, child); |
| 327 | list.appendChild(li); |
| 328 | } |
| 329 | target.appendChild(list); |
| 330 | } |
| 331 | }; |
| 332 | // The common case β one plain paragraph β keeps the original feed's DOM |
| 333 | // shape (inline nodes appended bare, no <p> wrapper), so existing bubble |
| 334 | // styling is untouched for ordinary messages. |
| 335 | if (blocks.length === 1 && blocks[0].t === "p") { |
| 336 | inline(el, blocks[0].children); |
| 337 | return; |
| 338 | } |
| 339 | for (const b of blocks) block(el, b); |
| 340 | } |
| 341 | |