6d6a4c34bdc1 | atagen | 2026-10-24 06:51 UTC | 1 | export type RichNode = |
6d6a4c34bdc1 | atagen | 2026-10-24 06:51 UTC | 2 | | { kind: 'text'; text: string } |
6d6a4c34bdc1 | atagen | 2026-10-24 06:51 UTC | 3 | | { kind: 'element'; tag: string; attrs: Record<string, string>; children: RichNode[] } |
6d6a4c34bdc1 | atagen | 2026-10-24 06:51 UTC | 4 | | { kind: 'mention'; user_id: string; children: RichNode[] } |
6d6a4c34bdc1 | atagen | 2026-10-24 06:51 UTC | 5 | | { kind: 'spoiler'; children: RichNode[] } |
6d6a4c34bdc1 | atagen | 2026-10-24 06:51 UTC | 6 | | { kind: 'emote'; mxc: string; alt: string }; |
6d6a4c34bdc1 | atagen | 2026-10-24 06:51 UTC | 7 | |
6d6a4c34bdc1 | atagen | 2026-10-24 06:51 UTC | 8 | // The tag allowlist from the Matrix spec's m.room.message HTML section, minus font colours. |
6d6a4c34bdc1 | atagen | 2026-10-24 06:51 UTC | 9 | const ALLOWED = new Set([ |
6d6a4c34bdc1 | atagen | 2026-10-24 06:51 UTC | 10 | 'del', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'blockquote', 'p', 'a', 'ul', 'ol', 'sup', 'sub', 'li', 'b', 'i', |
6d6a4c34bdc1 | atagen | 2026-10-24 06:51 UTC | 11 | 'u', 'strong', 'em', 's', 'strike', 'code', 'hr', 'br', 'div', 'table', 'thead', 'tbody', 'tr', 'th', 'td', |
6d6a4c34bdc1 | atagen | 2026-10-24 06:51 UTC | 12 | 'caption', 'pre', 'span', 'details', 'summary', 'font' |
6d6a4c34bdc1 | atagen | 2026-10-24 06:51 UTC | 13 | ]); |
6d6a4c34bdc1 | atagen | 2026-10-24 06:51 UTC | 14 | const DROPPED = new Set(['mx-reply', 'script', 'style', 'head', 'title', 'iframe', 'object', 'template']); |
6d6a4c34bdc1 | atagen | 2026-10-24 06:51 UTC | 15 | const MATRIX_TO_USER = /^https:\/\/matrix\.to\/#\/(@[^/?]+)/; |
6d6a4c34bdc1 | atagen | 2026-10-24 06:51 UTC | 16 | const SAFE_HREF = /^(https?:|mailto:)/i; |
6d6a4c34bdc1 | atagen | 2026-10-24 06:51 UTC | 17 | |
6d6a4c34bdc1 | atagen | 2026-10-24 06:51 UTC | 18 | function attrsFor(el: Element, tag: string): Record<string, string> | null { |
6d6a4c34bdc1 | atagen | 2026-10-24 06:51 UTC | 19 | const attrs: Record<string, string> = {}; |
6d6a4c34bdc1 | atagen | 2026-10-24 06:51 UTC | 20 | if (tag === 'a') { |
6d6a4c34bdc1 | atagen | 2026-10-24 06:51 UTC | 21 | const href = el.getAttribute('href') ?? ''; |
6d6a4c34bdc1 | atagen | 2026-10-24 06:51 UTC | 22 | if (!SAFE_HREF.test(href)) return null; |
6d6a4c34bdc1 | atagen | 2026-10-24 06:51 UTC | 23 | attrs['href'] = href; |
6d6a4c34bdc1 | atagen | 2026-10-24 06:51 UTC | 24 | } else if (tag === 'ol') { |
6d6a4c34bdc1 | atagen | 2026-10-24 06:51 UTC | 25 | const start = Number(el.getAttribute('start')); |
6d6a4c34bdc1 | atagen | 2026-10-24 06:51 UTC | 26 | if (Number.isInteger(start) && start > 0) attrs['start'] = String(start); |
6d6a4c34bdc1 | atagen | 2026-10-24 06:51 UTC | 27 | } else if (tag === 'code') { |
6d6a4c34bdc1 | atagen | 2026-10-24 06:51 UTC | 28 | const lang = /(?:^|\s)(language-[\w+#-]+)/.exec(el.getAttribute('class') ?? ''); |
6d6a4c34bdc1 | atagen | 2026-10-24 06:51 UTC | 29 | if (lang) attrs['class'] = lang[1]; |
6d6a4c34bdc1 | atagen | 2026-10-24 06:51 UTC | 30 | } |
6d6a4c34bdc1 | atagen | 2026-10-24 06:51 UTC | 31 | return attrs; |
6d6a4c34bdc1 | atagen | 2026-10-24 06:51 UTC | 32 | } |
6d6a4c34bdc1 | atagen | 2026-10-24 06:51 UTC | 33 | |
6d6a4c34bdc1 | atagen | 2026-10-24 06:51 UTC | 34 | function convert(node: Node): RichNode[] { |
6d6a4c34bdc1 | atagen | 2026-10-24 06:51 UTC | 35 | if (node.nodeType === Node.TEXT_NODE) return node.textContent ? [{ kind: 'text', text: node.textContent }] : []; |
6d6a4c34bdc1 | atagen | 2026-10-24 06:51 UTC | 36 | if (node.nodeType !== Node.ELEMENT_NODE) return []; |
6d6a4c34bdc1 | atagen | 2026-10-24 06:51 UTC | 37 | const el = node as Element; |
6d6a4c34bdc1 | atagen | 2026-10-24 06:51 UTC | 38 | const tag = el.tagName.toLowerCase(); |
6d6a4c34bdc1 | atagen | 2026-10-24 06:51 UTC | 39 | if (DROPPED.has(tag)) return []; |
6d6a4c34bdc1 | atagen | 2026-10-24 06:51 UTC | 40 | const children = () => [...el.childNodes].flatMap(convert); |
6d6a4c34bdc1 | atagen | 2026-10-24 06:51 UTC | 41 | if (tag === 'img') { |
6d6a4c34bdc1 | atagen | 2026-10-24 06:51 UTC | 42 | const src = el.getAttribute('src') ?? ''; |
6d6a4c34bdc1 | atagen | 2026-10-24 06:51 UTC | 43 | const alt = el.getAttribute('alt') ?? el.getAttribute('title') ?? ''; |
6d6a4c34bdc1 | atagen | 2026-10-24 06:51 UTC | 44 | if (src.startsWith('mxc://')) return [{ kind: 'emote', mxc: src, alt }]; |
6d6a4c34bdc1 | atagen | 2026-10-24 06:51 UTC | 45 | return alt ? [{ kind: 'text', text: alt }] : []; |
6d6a4c34bdc1 | atagen | 2026-10-24 06:51 UTC | 46 | } |
6d6a4c34bdc1 | atagen | 2026-10-24 06:51 UTC | 47 | if (tag === 'span' && el.hasAttribute('data-mx-spoiler')) return [{ kind: 'spoiler', children: children() }]; |
6d6a4c34bdc1 | atagen | 2026-10-24 06:51 UTC | 48 | if (!ALLOWED.has(tag)) return children(); |
6d6a4c34bdc1 | atagen | 2026-10-24 06:51 UTC | 49 | if (tag === 'a') { |
6d6a4c34bdc1 | atagen | 2026-10-24 06:51 UTC | 50 | const user = MATRIX_TO_USER.exec(el.getAttribute('href') ?? ''); |
6d6a4c34bdc1 | atagen | 2026-10-24 06:51 UTC | 51 | if (user) { |
6d6a4c34bdc1 | atagen | 2026-10-24 06:51 UTC | 52 | try { |
6d6a4c34bdc1 | atagen | 2026-10-24 06:51 UTC | 53 | return [{ kind: 'mention', user_id: decodeURIComponent(user[1]), children: children() }]; |
6d6a4c34bdc1 | atagen | 2026-10-24 06:51 UTC | 54 | } catch { |
6d6a4c34bdc1 | atagen | 2026-10-24 06:51 UTC | 55 | return children(); |
6d6a4c34bdc1 | atagen | 2026-10-24 06:51 UTC | 56 | } |
6d6a4c34bdc1 | atagen | 2026-10-24 06:51 UTC | 57 | } |
6d6a4c34bdc1 | atagen | 2026-10-24 06:51 UTC | 58 | } |
6d6a4c34bdc1 | atagen | 2026-10-24 06:51 UTC | 59 | const attrs = attrsFor(el, tag); |
6d6a4c34bdc1 | atagen | 2026-10-24 06:51 UTC | 60 | if (!attrs) return children(); |
6d6a4c34bdc1 | atagen | 2026-10-24 06:51 UTC | 61 | return [{ kind: 'element', tag: tag === 'font' ? 'span' : tag, attrs, children: children() }]; |
6d6a4c34bdc1 | atagen | 2026-10-24 06:51 UTC | 62 | } |
6d6a4c34bdc1 | atagen | 2026-10-24 06:51 UTC | 63 | |
6d6a4c34bdc1 | atagen | 2026-10-24 06:51 UTC | 64 | /** A sanitised tree for a message's `formatted_body`, safe to render without `{@html}`. */ |
6d6a4c34bdc1 | atagen | 2026-10-24 06:51 UTC | 65 | export function parseRich(html: string): RichNode[] { |
6d6a4c34bdc1 | atagen | 2026-10-24 06:51 UTC | 66 | const doc = new DOMParser().parseFromString(`<body>${html}</body>`, 'text/html'); |
6d6a4c34bdc1 | atagen | 2026-10-24 06:51 UTC | 67 | return [...doc.body.childNodes].flatMap(convert); |
6d6a4c34bdc1 | atagen | 2026-10-24 06:51 UTC | 68 | } |