atagen

malice

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