atagen

malice

public
Star0
Watch0
e590609b63434b8b595b383f8124dea45191bd6e
2
ssh://brewery@pls.lobotomise.me:33/atagen/malice.git
web/src/lib/rich.tsTypescript80 lines3.3 KB
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
}