atagen

malice

public
Star0
Watch0
61382e376b154e5b03d5e65195237ccd3ddfcd14
2
ssh://brewery@pls.lobotomise.me:33/atagen/malice.git

Blame: web/src/lib/rich.ts

6d6a4c34bdc1atagen2026-10-24 06:51 UTC1
export type RichNode =
6d6a4c34bdc1atagen2026-10-24 06:51 UTC2
  | { kind: 'text'; text: string }
6d6a4c34bdc1atagen2026-10-24 06:51 UTC3
  | { kind: 'element'; tag: string; attrs: Record<string, string>; children: RichNode[] }
6d6a4c34bdc1atagen2026-10-24 06:51 UTC4
  | { kind: 'mention'; user_id: string; children: RichNode[] }
6d6a4c34bdc1atagen2026-10-24 06:51 UTC5
  | { kind: 'spoiler'; children: RichNode[] }
6d6a4c34bdc1atagen2026-10-24 06:51 UTC6
  | { kind: 'emote'; mxc: string; alt: string };
6d6a4c34bdc1atagen2026-10-24 06:51 UTC7
6d6a4c34bdc1atagen2026-10-24 06:51 UTC8
// The tag allowlist from the Matrix spec's m.room.message HTML section, minus font colours.
6d6a4c34bdc1atagen2026-10-24 06:51 UTC9
const ALLOWED = new Set([
6d6a4c34bdc1atagen2026-10-24 06:51 UTC10
  'del', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'blockquote', 'p', 'a', 'ul', 'ol', 'sup', 'sub', 'li', 'b', 'i',
6d6a4c34bdc1atagen2026-10-24 06:51 UTC11
  'u', 'strong', 'em', 's', 'strike', 'code', 'hr', 'br', 'div', 'table', 'thead', 'tbody', 'tr', 'th', 'td',
6d6a4c34bdc1atagen2026-10-24 06:51 UTC12
  'caption', 'pre', 'span', 'details', 'summary', 'font'
6d6a4c34bdc1atagen2026-10-24 06:51 UTC13
]);
6d6a4c34bdc1atagen2026-10-24 06:51 UTC14
const DROPPED = new Set(['mx-reply', 'script', 'style', 'head', 'title', 'iframe', 'object', 'template']);
6d6a4c34bdc1atagen2026-10-24 06:51 UTC15
const MATRIX_TO_USER = /^https:\/\/matrix\.to\/#\/(@[^/?]+)/;
6d6a4c34bdc1atagen2026-10-24 06:51 UTC16
const SAFE_HREF = /^(https?:|mailto:)/i;
6d6a4c34bdc1atagen2026-10-24 06:51 UTC17
6d6a4c34bdc1atagen2026-10-24 06:51 UTC18
function attrsFor(el: Element, tag: string): Record<string, string> | null {
6d6a4c34bdc1atagen2026-10-24 06:51 UTC19
  const attrs: Record<string, string> = {};
6d6a4c34bdc1atagen2026-10-24 06:51 UTC20
  if (tag === 'a') {
6d6a4c34bdc1atagen2026-10-24 06:51 UTC21
    const href = el.getAttribute('href') ?? '';
6d6a4c34bdc1atagen2026-10-24 06:51 UTC22
    if (!SAFE_HREF.test(href)) return null;
6d6a4c34bdc1atagen2026-10-24 06:51 UTC23
    attrs['href'] = href;
6d6a4c34bdc1atagen2026-10-24 06:51 UTC24
  } else if (tag === 'ol') {
6d6a4c34bdc1atagen2026-10-24 06:51 UTC25
    const start = Number(el.getAttribute('start'));
6d6a4c34bdc1atagen2026-10-24 06:51 UTC26
    if (Number.isInteger(start) && start > 0) attrs['start'] = String(start);
6d6a4c34bdc1atagen2026-10-24 06:51 UTC27
  } else if (tag === 'code') {
6d6a4c34bdc1atagen2026-10-24 06:51 UTC28
    const lang = /(?:^|\s)(language-[\w+#-]+)/.exec(el.getAttribute('class') ?? '');
6d6a4c34bdc1atagen2026-10-24 06:51 UTC29
    if (lang) attrs['class'] = lang[1];
6d6a4c34bdc1atagen2026-10-24 06:51 UTC30
  }
6d6a4c34bdc1atagen2026-10-24 06:51 UTC31
  return attrs;
6d6a4c34bdc1atagen2026-10-24 06:51 UTC32
}
6d6a4c34bdc1atagen2026-10-24 06:51 UTC33
6d6a4c34bdc1atagen2026-10-24 06:51 UTC34
function convert(node: Node): RichNode[] {
6d6a4c34bdc1atagen2026-10-24 06:51 UTC35
  if (node.nodeType === Node.TEXT_NODE) return node.textContent ? [{ kind: 'text', text: node.textContent }] : [];
6d6a4c34bdc1atagen2026-10-24 06:51 UTC36
  if (node.nodeType !== Node.ELEMENT_NODE) return [];
6d6a4c34bdc1atagen2026-10-24 06:51 UTC37
  const el = node as Element;
6d6a4c34bdc1atagen2026-10-24 06:51 UTC38
  const tag = el.tagName.toLowerCase();
6d6a4c34bdc1atagen2026-10-24 06:51 UTC39
  if (DROPPED.has(tag)) return [];
6d6a4c34bdc1atagen2026-10-24 06:51 UTC40
  const children = () => [...el.childNodes].flatMap(convert);
6d6a4c34bdc1atagen2026-10-24 06:51 UTC41
  if (tag === 'img') {
6d6a4c34bdc1atagen2026-10-24 06:51 UTC42
    const src = el.getAttribute('src') ?? '';
6d6a4c34bdc1atagen2026-10-24 06:51 UTC43
    const alt = el.getAttribute('alt') ?? el.getAttribute('title') ?? '';
6d6a4c34bdc1atagen2026-10-24 06:51 UTC44
    if (src.startsWith('mxc://')) return [{ kind: 'emote', mxc: src, alt }];
6d6a4c34bdc1atagen2026-10-24 06:51 UTC45
    return alt ? [{ kind: 'text', text: alt }] : [];
6d6a4c34bdc1atagen2026-10-24 06:51 UTC46
  }
6d6a4c34bdc1atagen2026-10-24 06:51 UTC47
  if (tag === 'span' && el.hasAttribute('data-mx-spoiler')) return [{ kind: 'spoiler', children: children() }];
6d6a4c34bdc1atagen2026-10-24 06:51 UTC48
  if (!ALLOWED.has(tag)) return children();
6d6a4c34bdc1atagen2026-10-24 06:51 UTC49
  if (tag === 'a') {
6d6a4c34bdc1atagen2026-10-24 06:51 UTC50
    const user = MATRIX_TO_USER.exec(el.getAttribute('href') ?? '');
6d6a4c34bdc1atagen2026-10-24 06:51 UTC51
    if (user) {
6d6a4c34bdc1atagen2026-10-24 06:51 UTC52
      try {
6d6a4c34bdc1atagen2026-10-24 06:51 UTC53
        return [{ kind: 'mention', user_id: decodeURIComponent(user[1]), children: children() }];
6d6a4c34bdc1atagen2026-10-24 06:51 UTC54
      } catch {
6d6a4c34bdc1atagen2026-10-24 06:51 UTC55
        return children();
6d6a4c34bdc1atagen2026-10-24 06:51 UTC56
      }
6d6a4c34bdc1atagen2026-10-24 06:51 UTC57
    }
6d6a4c34bdc1atagen2026-10-24 06:51 UTC58
  }
6d6a4c34bdc1atagen2026-10-24 06:51 UTC59
  const attrs = attrsFor(el, tag);
6d6a4c34bdc1atagen2026-10-24 06:51 UTC60
  if (!attrs) return children();
6d6a4c34bdc1atagen2026-10-24 06:51 UTC61
  return [{ kind: 'element', tag: tag === 'font' ? 'span' : tag, attrs, children: children() }];
6d6a4c34bdc1atagen2026-10-24 06:51 UTC62
}
6d6a4c34bdc1atagen2026-10-24 06:51 UTC63
d07bca5da4aeatagen2026-10-26 03:48 UTC64
let scratch: HTMLElement | null = null;
d07bca5da4aeatagen2026-10-26 03:48 UTC65
d07bca5da4aeatagen2026-10-26 03:48 UTC66
/** Parses into one shared inert body, so no Document is allocated per message. */
d07bca5da4aeatagen2026-10-26 03:48 UTC67
function withParsedBody<T>(html: string, read: (body: HTMLElement) => T): T {
d07bca5da4aeatagen2026-10-26 03:48 UTC68
  scratch ??= document.implementation.createHTMLDocument('').body;
d07bca5da4aeatagen2026-10-26 03:48 UTC69
  scratch.innerHTML = html;
d07bca5da4aeatagen2026-10-26 03:48 UTC70
  try {
d07bca5da4aeatagen2026-10-26 03:48 UTC71
    return read(scratch);
d07bca5da4aeatagen2026-10-26 03:48 UTC72
  } finally {
d07bca5da4aeatagen2026-10-26 03:48 UTC73
    scratch.replaceChildren();
d07bca5da4aeatagen2026-10-26 03:48 UTC74
  }
d07bca5da4aeatagen2026-10-26 03:48 UTC75
}
d07bca5da4aeatagen2026-10-26 03:48 UTC76
6d6a4c34bdc1atagen2026-10-24 06:51 UTC77
/** A sanitised tree for a message's `formatted_body`, safe to render without `{@html}`. */
6d6a4c34bdc1atagen2026-10-24 06:51 UTC78
export function parseRich(html: string): RichNode[] {
d07bca5da4aeatagen2026-10-26 03:48 UTC79
  return withParsedBody(html, (body) => [...body.childNodes].flatMap(convert));
6d6a4c34bdc1atagen2026-10-24 06:51 UTC80
}