atagen

malice

public
Star0
Watch0
824dfd38cdf3a131fab37ae25a60f3b51ccd93bc
2
ssh://brewery@pls.lobotomise.me:33/atagen/malice.git
web/src/lib/markdown.tsTypescript81 lines3.3 KB
1
import { Marked, type Token, type Tokens } from 'marked';
2
import type { Emote } from './emotes';
3
import type { Mention } from './mentions';
4
5
function escapeHtml(text: string): string {
6
  return text.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
7
}
8
9
function escapeRegExp(text: string): string {
10
  return text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
11
}
12
13
function mentionLinker(mentions: Mention[]): (escaped: string) => string {
14
  const byText = new Map(mentions.filter((m) => m.text).map((m) => [escapeHtml(m.text), m.user_id]));
15
  if (byText.size === 0) return (escaped) => escaped;
16
  const names = [...byText.keys()].sort((a, b) => b.length - a.length);
17
  const pattern = new RegExp(names.map(escapeRegExp).join('|'), 'g');
18
  return (escaped) =>
19
    escaped.replace(pattern, (name) => `<a href="https://matrix.to/#/${escapeHtml(byText.get(name)!)}">${name}</a>`);
20
}
21
22
function emoteLinker(emotes: Emote[]): (escaped: string) => string {
23
  if (emotes.length === 0) return (escaped) => escaped;
24
  const byCode = new Map(emotes.map((e) => [e.shortcode, e.mxc]));
25
  return (escaped) =>
26
    escaped.replace(/:([\w+-]+):/g, (whole, code: string) => {
27
      const mxc = byCode.get(code);
28
      return mxc ? `<img data-mx-emoticon src="${escapeHtml(mxc)}" alt="${whole}" title="${whole}" height="32">` : whole;
29
    });
30
}
31
32
function textToHtml(text: string, link: (escaped: string) => string): string {
33
  return link(escapeHtml(text)).replace(/\n/g, '<br>');
34
}
35
36
const PLAIN_INLINE = new Set(['text', 'escape', 'br']);
37
38
function isPlain(tokens: Token[]): boolean {
39
  const blocks = tokens.filter((t) => t.type !== 'space');
40
  if (blocks.length !== 1 || blocks[0].type !== 'paragraph') return blocks.length === 0;
41
  return (blocks[0] as Tokens.Paragraph).tokens.every((t) => PLAIN_INLINE.has(t.type) && !('tokens' in t && t.tokens));
42
}
43
44
function markdownToHtml(body: string, link: (escaped: string) => string): string | null {
45
  const marked = new Marked({ gfm: true, breaks: true });
46
  marked.use({
47
    renderer: {
48
      text(token) {
49
        if ('tokens' in token && token.tokens) return false;
50
        return link('escaped' in token && token.escaped ? token.text : escapeHtml(token.text));
51
      },
52
      html(token) {
53
        return escapeHtml(token.text);
54
      },
55
      image(token) {
56
        return `<a href="${escapeHtml(token.href)}">${escapeHtml(token.text || token.href)}</a>`;
57
      },
58
      checkbox(token) {
59
        return token.checked ? '[x] ' : '[ ] ';
60
      }
61
    }
62
  });
63
  const tokens = marked.lexer(body);
64
  if (isPlain(tokens)) return null;
65
  return marked.parser(tokens).trim();
66
}
67
68
/** Matrix `formatted_body` for a composed message, or null when plain text says it all. */
69
export function formatBody(body: string, mentions: Mention[], markdown: boolean, emotes: Emote[] = []): string | null {
70
  const mention = mentionLinker(mentions);
71
  const emote = emoteLinker(emotes);
72
  const link = (escaped: string) => emote(mention(escaped));
73
  const html = markdown ? markdownToHtml(body, link) : null;
74
  if (html !== null) return html;
75
  const plain = textToHtml(body, link);
76
  return plain === escapeHtml(body).replace(/\n/g, '<br>') ? null : plain;
77
}
78
79
export function insideOpenFence(text: string, caret: number): boolean {
80
  return (text.slice(0, caret).match(/^\s*```/gm)?.length ?? 0) % 2 === 1;
81
}