atagen

malice

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

Blame: web/src/lib/markdown.ts

6d6a4c34bdc1atagen2026-10-24 06:51 UTC1
import { Marked, type Token, type Tokens } from 'marked';
05a48e9e7942atagen2026-10-24 09:32 UTC2
import type { Emote } from './emotes';
6d6a4c34bdc1atagen2026-10-24 06:51 UTC3
import type { Mention } from './mentions';
6d6a4c34bdc1atagen2026-10-24 06:51 UTC4
6d6a4c34bdc1atagen2026-10-24 06:51 UTC5
function escapeHtml(text: string): string {
6d6a4c34bdc1atagen2026-10-24 06:51 UTC6
  return text.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
6d6a4c34bdc1atagen2026-10-24 06:51 UTC7
}
6d6a4c34bdc1atagen2026-10-24 06:51 UTC8
6d6a4c34bdc1atagen2026-10-24 06:51 UTC9
function escapeRegExp(text: string): string {
6d6a4c34bdc1atagen2026-10-24 06:51 UTC10
  return text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
6d6a4c34bdc1atagen2026-10-24 06:51 UTC11
}
6d6a4c34bdc1atagen2026-10-24 06:51 UTC12
6d6a4c34bdc1atagen2026-10-24 06:51 UTC13
function mentionLinker(mentions: Mention[]): (escaped: string) => string {
6d6a4c34bdc1atagen2026-10-24 06:51 UTC14
  const byText = new Map(mentions.filter((m) => m.text).map((m) => [escapeHtml(m.text), m.user_id]));
6d6a4c34bdc1atagen2026-10-24 06:51 UTC15
  if (byText.size === 0) return (escaped) => escaped;
6d6a4c34bdc1atagen2026-10-24 06:51 UTC16
  const names = [...byText.keys()].sort((a, b) => b.length - a.length);
6d6a4c34bdc1atagen2026-10-24 06:51 UTC17
  const pattern = new RegExp(names.map(escapeRegExp).join('|'), 'g');
6d6a4c34bdc1atagen2026-10-24 06:51 UTC18
  return (escaped) =>
6d6a4c34bdc1atagen2026-10-24 06:51 UTC19
    escaped.replace(pattern, (name) => `<a href="https://matrix.to/#/${escapeHtml(byText.get(name)!)}">${name}</a>`);
6d6a4c34bdc1atagen2026-10-24 06:51 UTC20
}
6d6a4c34bdc1atagen2026-10-24 06:51 UTC21
05a48e9e7942atagen2026-10-24 09:32 UTC22
function emoteLinker(emotes: Emote[]): (escaped: string) => string {
05a48e9e7942atagen2026-10-24 09:32 UTC23
  if (emotes.length === 0) return (escaped) => escaped;
05a48e9e7942atagen2026-10-24 09:32 UTC24
  const byCode = new Map(emotes.map((e) => [e.shortcode, e.mxc]));
05a48e9e7942atagen2026-10-24 09:32 UTC25
  return (escaped) =>
05a48e9e7942atagen2026-10-24 09:32 UTC26
    escaped.replace(/:([\w+-]+):/g, (whole, code: string) => {
05a48e9e7942atagen2026-10-24 09:32 UTC27
      const mxc = byCode.get(code);
05a48e9e7942atagen2026-10-24 09:32 UTC28
      return mxc ? `<img data-mx-emoticon src="${escapeHtml(mxc)}" alt="${whole}" title="${whole}" height="32">` : whole;
05a48e9e7942atagen2026-10-24 09:32 UTC29
    });
05a48e9e7942atagen2026-10-24 09:32 UTC30
}
05a48e9e7942atagen2026-10-24 09:32 UTC31
6d6a4c34bdc1atagen2026-10-24 06:51 UTC32
function textToHtml(text: string, link: (escaped: string) => string): string {
6d6a4c34bdc1atagen2026-10-24 06:51 UTC33
  return link(escapeHtml(text)).replace(/\n/g, '<br>');
6d6a4c34bdc1atagen2026-10-24 06:51 UTC34
}
6d6a4c34bdc1atagen2026-10-24 06:51 UTC35
6d6a4c34bdc1atagen2026-10-24 06:51 UTC36
const PLAIN_INLINE = new Set(['text', 'escape', 'br']);
6d6a4c34bdc1atagen2026-10-24 06:51 UTC37
6d6a4c34bdc1atagen2026-10-24 06:51 UTC38
function isPlain(tokens: Token[]): boolean {
6d6a4c34bdc1atagen2026-10-24 06:51 UTC39
  const blocks = tokens.filter((t) => t.type !== 'space');
6d6a4c34bdc1atagen2026-10-24 06:51 UTC40
  if (blocks.length !== 1 || blocks[0].type !== 'paragraph') return blocks.length === 0;
6d6a4c34bdc1atagen2026-10-24 06:51 UTC41
  return (blocks[0] as Tokens.Paragraph).tokens.every((t) => PLAIN_INLINE.has(t.type) && !('tokens' in t && t.tokens));
6d6a4c34bdc1atagen2026-10-24 06:51 UTC42
}
6d6a4c34bdc1atagen2026-10-24 06:51 UTC43
6d6a4c34bdc1atagen2026-10-24 06:51 UTC44
function markdownToHtml(body: string, link: (escaped: string) => string): string | null {
6d6a4c34bdc1atagen2026-10-24 06:51 UTC45
  const marked = new Marked({ gfm: true, breaks: true });
6d6a4c34bdc1atagen2026-10-24 06:51 UTC46
  marked.use({
6d6a4c34bdc1atagen2026-10-24 06:51 UTC47
    renderer: {
6d6a4c34bdc1atagen2026-10-24 06:51 UTC48
      text(token) {
6d6a4c34bdc1atagen2026-10-24 06:51 UTC49
        if ('tokens' in token && token.tokens) return false;
6d6a4c34bdc1atagen2026-10-24 06:51 UTC50
        return link('escaped' in token && token.escaped ? token.text : escapeHtml(token.text));
6d6a4c34bdc1atagen2026-10-24 06:51 UTC51
      },
6d6a4c34bdc1atagen2026-10-24 06:51 UTC52
      html(token) {
6d6a4c34bdc1atagen2026-10-24 06:51 UTC53
        return escapeHtml(token.text);
6d6a4c34bdc1atagen2026-10-24 06:51 UTC54
      },
6d6a4c34bdc1atagen2026-10-24 06:51 UTC55
      image(token) {
6d6a4c34bdc1atagen2026-10-24 06:51 UTC56
        return `<a href="${escapeHtml(token.href)}">${escapeHtml(token.text || token.href)}</a>`;
6d6a4c34bdc1atagen2026-10-24 06:51 UTC57
      },
6d6a4c34bdc1atagen2026-10-24 06:51 UTC58
      checkbox(token) {
6d6a4c34bdc1atagen2026-10-24 06:51 UTC59
        return token.checked ? '[x] ' : '[ ] ';
6d6a4c34bdc1atagen2026-10-24 06:51 UTC60
      }
6d6a4c34bdc1atagen2026-10-24 06:51 UTC61
    }
6d6a4c34bdc1atagen2026-10-24 06:51 UTC62
  });
6d6a4c34bdc1atagen2026-10-24 06:51 UTC63
  const tokens = marked.lexer(body);
6d6a4c34bdc1atagen2026-10-24 06:51 UTC64
  if (isPlain(tokens)) return null;
6d6a4c34bdc1atagen2026-10-24 06:51 UTC65
  return marked.parser(tokens).trim();
6d6a4c34bdc1atagen2026-10-24 06:51 UTC66
}
6d6a4c34bdc1atagen2026-10-24 06:51 UTC67
6d6a4c34bdc1atagen2026-10-24 06:51 UTC68
/** Matrix `formatted_body` for a composed message, or null when plain text says it all. */
05a48e9e7942atagen2026-10-24 09:32 UTC69
export function formatBody(body: string, mentions: Mention[], markdown: boolean, emotes: Emote[] = []): string | null {
05a48e9e7942atagen2026-10-24 09:32 UTC70
  const mention = mentionLinker(mentions);
05a48e9e7942atagen2026-10-24 09:32 UTC71
  const emote = emoteLinker(emotes);
05a48e9e7942atagen2026-10-24 09:32 UTC72
  const link = (escaped: string) => emote(mention(escaped));
6d6a4c34bdc1atagen2026-10-24 06:51 UTC73
  const html = markdown ? markdownToHtml(body, link) : null;
6d6a4c34bdc1atagen2026-10-24 06:51 UTC74
  if (html !== null) return html;
6d6a4c34bdc1atagen2026-10-24 06:51 UTC75
  const plain = textToHtml(body, link);
6d6a4c34bdc1atagen2026-10-24 06:51 UTC76
  return plain === escapeHtml(body).replace(/\n/g, '<br>') ? null : plain;
6d6a4c34bdc1atagen2026-10-24 06:51 UTC77
}
6d6a4c34bdc1atagen2026-10-24 06:51 UTC78
6d6a4c34bdc1atagen2026-10-24 06:51 UTC79
export function insideOpenFence(text: string, caret: number): boolean {
6d6a4c34bdc1atagen2026-10-24 06:51 UTC80
  return (text.slice(0, caret).match(/^\s*```/gm)?.length ?? 0) % 2 === 1;
6d6a4c34bdc1atagen2026-10-24 06:51 UTC81
}