atagen

malice

public
Star0
Watch0
ebd21444da0f089f9ef6137a717fbf9ce17d58a6
2
ssh://brewery@pls.lobotomise.me:33/atagen/malice.git
web/src/lib/rich.tsTypescript68 lines3.0 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
/** A sanitised tree for a message's `formatted_body`, safe to render without `{@html}`. */
65
export function parseRich(html: string): RichNode[] {
66
  const doc = new DOMParser().parseFromString(`<body>${html}</body>`, 'text/html');
67
  return [...doc.body.childNodes].flatMap(convert);
68
}