atagen

malice

public
Star0
Watch0
787cdbbf0721a1b004712020969dadaa42f35e32
2
ssh://brewery@pls.lobotomise.me:33/atagen/malice.git
web/src/lib/code.tsTypescript81 lines3.0 KB
1
import type { Mention } from './mentions';
2
3
export type CodeSegment =
4
  | { kind: 'text'; text: string }
5
  | { kind: 'inline'; text: string }
6
  | { kind: 'block'; text: string; lang: string | null };
7
8
const FENCE = /```([^\s`]*)[^\S\n]*\n?([\s\S]*?)\n?```/g;
9
const INLINE = /`([^`\n]+)`/g;
10
11
function splitInline(text: string, out: CodeSegment[]): void {
12
  let last = 0;
13
  for (const m of text.matchAll(INLINE)) {
14
    if (m.index > last) out.push({ kind: 'text', text: text.slice(last, m.index) });
15
    out.push({ kind: 'inline', text: m[1] });
16
    last = m.index + m[0].length;
17
  }
18
  if (last < text.length) out.push({ kind: 'text', text: text.slice(last) });
19
}
20
21
export function parseCode(text: string): CodeSegment[] {
22
  const out: CodeSegment[] = [];
23
  if (!text.includes('`')) {
24
    if (text) out.push({ kind: 'text', text });
25
    return out;
26
  }
27
  let last = 0;
28
  for (const m of text.matchAll(FENCE)) {
29
    splitInline(text.slice(last, m.index), out);
30
    out.push({ kind: 'block', text: m[2], lang: m[1] || null });
31
    last = m.index + m[0].length;
32
    if (text[last] === '\n') last += 1;
33
  }
34
  splitInline(text.slice(last), out);
35
  return out;
36
}
37
38
export function hasCode(segments: CodeSegment[]): boolean {
39
  return segments.some((s) => s.kind !== 'text');
40
}
41
42
function escapeHtml(text: string): string {
43
  return text.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
44
}
45
46
function escapeRegExp(text: string): string {
47
  return text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
48
}
49
50
function textToHtml(text: string, pattern: RegExp | null, byText: Map<string, string>): string {
51
  if (!pattern) return escapeHtml(text).replace(/\n/g, '<br>');
52
  let out = '';
53
  let last = 0;
54
  for (const m of text.matchAll(pattern)) {
55
    out += escapeHtml(text.slice(last, m.index)).replace(/\n/g, '<br>');
56
    const userId = byText.get(m[0]) ?? m[0];
57
    out += `<a href="https://matrix.to/#/${escapeHtml(userId)}">${escapeHtml(m[0])}</a>`;
58
    last = m.index + m[0].length;
59
  }
60
  return out + escapeHtml(text.slice(last)).replace(/\n/g, '<br>');
61
}
62
63
/** Matrix `formatted_body` for a plain body, or null when it has no code or mentions to format. */
64
export function codeToHtml(body: string, mentions: Mention[] = []): string | null {
65
  const segments = parseCode(body);
66
  const byText = new Map(mentions.map((m) => [m.text, m.user_id]));
67
  const texts = [...byText.keys()].sort((a, b) => b.length - a.length);
68
  const pattern = texts.length > 0 ? new RegExp(texts.map(escapeRegExp).join('|'), 'g') : null;
69
  const linked = pattern !== null && segments.some((s) => s.kind === 'text' && new RegExp(pattern.source).test(s.text));
70
  if (!hasCode(segments) && !linked) return null;
71
  return segments
72
    .map((s) => {
73
      if (s.kind === 'inline') return `<code>${escapeHtml(s.text)}</code>`;
74
      if (s.kind === 'block') {
75
        const cls = s.lang ? ` class="language-${escapeHtml(s.lang)}"` : '';
76
        return `<pre><code${cls}>${escapeHtml(s.text)}</code></pre>`;
77
      }
78
      return textToHtml(s.text, pattern, byText);
79
    })
80
    .join('');
81
}