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