atagen

malice

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

Blame: web/src/lib/markdown.ts

132eeceeb362atagen2026-10-25 03:34 UTC1
import markdown, { type MarkdownNode } from '@cloudrac3r/discord-markdown';
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
132eeceeb362atagen2026-10-25 03:34 UTC5
const { markdownEngine, rules } = markdown;
132eeceeb362atagen2026-10-25 03:34 UTC6
6d6a4c34bdc1atagen2026-10-24 06:51 UTC7
function escapeHtml(text: string): string {
6d6a4c34bdc1atagen2026-10-24 06:51 UTC8
  return text.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
6d6a4c34bdc1atagen2026-10-24 06:51 UTC9
}
6d6a4c34bdc1atagen2026-10-24 06:51 UTC10
6d6a4c34bdc1atagen2026-10-24 06:51 UTC11
function escapeRegExp(text: string): string {
6d6a4c34bdc1atagen2026-10-24 06:51 UTC12
  return text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
6d6a4c34bdc1atagen2026-10-24 06:51 UTC13
}
6d6a4c34bdc1atagen2026-10-24 06:51 UTC14
6d6a4c34bdc1atagen2026-10-24 06:51 UTC15
function mentionLinker(mentions: Mention[]): (escaped: string) => string {
6d6a4c34bdc1atagen2026-10-24 06:51 UTC16
  const byText = new Map(mentions.filter((m) => m.text).map((m) => [escapeHtml(m.text), m.user_id]));
6d6a4c34bdc1atagen2026-10-24 06:51 UTC17
  if (byText.size === 0) return (escaped) => escaped;
6d6a4c34bdc1atagen2026-10-24 06:51 UTC18
  const names = [...byText.keys()].sort((a, b) => b.length - a.length);
6d6a4c34bdc1atagen2026-10-24 06:51 UTC19
  const pattern = new RegExp(names.map(escapeRegExp).join('|'), 'g');
6d6a4c34bdc1atagen2026-10-24 06:51 UTC20
  return (escaped) =>
6d6a4c34bdc1atagen2026-10-24 06:51 UTC21
    escaped.replace(pattern, (name) => `<a href="https://matrix.to/#/${escapeHtml(byText.get(name)!)}">${name}</a>`);
6d6a4c34bdc1atagen2026-10-24 06:51 UTC22
}
6d6a4c34bdc1atagen2026-10-24 06:51 UTC23
05a48e9e7942atagen2026-10-24 09:32 UTC24
function emoteLinker(emotes: Emote[]): (escaped: string) => string {
05a48e9e7942atagen2026-10-24 09:32 UTC25
  if (emotes.length === 0) return (escaped) => escaped;
05a48e9e7942atagen2026-10-24 09:32 UTC26
  const byCode = new Map(emotes.map((e) => [e.shortcode, e.mxc]));
05a48e9e7942atagen2026-10-24 09:32 UTC27
  return (escaped) =>
05a48e9e7942atagen2026-10-24 09:32 UTC28
    escaped.replace(/:([\w+-]+):/g, (whole, code: string) => {
05a48e9e7942atagen2026-10-24 09:32 UTC29
      const mxc = byCode.get(code);
05a48e9e7942atagen2026-10-24 09:32 UTC30
      return mxc ? `<img data-mx-emoticon src="${escapeHtml(mxc)}" alt="${whole}" title="${whole}" height="32">` : whole;
05a48e9e7942atagen2026-10-24 09:32 UTC31
    });
05a48e9e7942atagen2026-10-24 09:32 UTC32
}
05a48e9e7942atagen2026-10-24 09:32 UTC33
6d6a4c34bdc1atagen2026-10-24 06:51 UTC34
function textToHtml(text: string, link: (escaped: string) => string): string {
6d6a4c34bdc1atagen2026-10-24 06:51 UTC35
  return link(escapeHtml(text)).replace(/\n/g, '<br>');
6d6a4c34bdc1atagen2026-10-24 06:51 UTC36
}
6d6a4c34bdc1atagen2026-10-24 06:51 UTC37
e822c659890fatagen2026-10-25 02:37 UTC38
const parseMarkdown = markdownEngine.parserFor(
e822c659890fatagen2026-10-25 02:37 UTC39
  Object.fromEntries(Object.entries(rules).filter(([name]) => !name.startsWith('discord')))
e822c659890fatagen2026-10-25 02:37 UTC40
);
e822c659890fatagen2026-10-25 02:37 UTC41
const INLINE_TAGS: Record<string, string> = { em: 'em', strong: 'strong', u: 'u', strike: 'del', small: 'sub' };
e822c659890fatagen2026-10-25 02:37 UTC42
const BLOCK_TYPES = new Set(['blockQuote', 'codeBlock', 'heading', 'list', 'paragraph']);
6d6a4c34bdc1atagen2026-10-24 06:51 UTC43
e822c659890fatagen2026-10-25 02:37 UTC44
function renderMarkdown(nodes: MarkdownNode[], link: (escaped: string) => string): string {
e822c659890fatagen2026-10-25 02:37 UTC45
  let html = '';
e822c659890fatagen2026-10-25 02:37 UTC46
  let text = '';
e822c659890fatagen2026-10-25 02:37 UTC47
  const flushText = () => {
e822c659890fatagen2026-10-25 02:37 UTC48
    html += textToHtml(text, link);
e822c659890fatagen2026-10-25 02:37 UTC49
    text = '';
e822c659890fatagen2026-10-25 02:37 UTC50
  };
e822c659890fatagen2026-10-25 02:37 UTC51
  for (const node of nodes) {
e822c659890fatagen2026-10-25 02:37 UTC52
    if (node.type === 'text') {
e822c659890fatagen2026-10-25 02:37 UTC53
      text += node.content;
e822c659890fatagen2026-10-25 02:37 UTC54
      continue;
e822c659890fatagen2026-10-25 02:37 UTC55
    }
e822c659890fatagen2026-10-25 02:37 UTC56
    flushText();
e822c659890fatagen2026-10-25 02:37 UTC57
    const children = () => renderMarkdown(node.content, link);
e822c659890fatagen2026-10-25 02:37 UTC58
    const tag = INLINE_TAGS[node.type];
e822c659890fatagen2026-10-25 02:37 UTC59
    if (tag) {
e822c659890fatagen2026-10-25 02:37 UTC60
      html += `<${tag}>${children()}</${tag}>`;
e822c659890fatagen2026-10-25 02:37 UTC61
      continue;
e822c659890fatagen2026-10-25 02:37 UTC62
    }
e822c659890fatagen2026-10-25 02:37 UTC63
    switch (node.type) {
e822c659890fatagen2026-10-25 02:37 UTC64
      case 'br':
e822c659890fatagen2026-10-25 02:37 UTC65
      case 'newline':
e822c659890fatagen2026-10-25 02:37 UTC66
        html += '<br>';
e822c659890fatagen2026-10-25 02:37 UTC67
        break;
e822c659890fatagen2026-10-25 02:37 UTC68
      case 'inlineCode':
e822c659890fatagen2026-10-25 02:37 UTC69
        html += `<code>${escapeHtml(node.content)}</code>`;
e822c659890fatagen2026-10-25 02:37 UTC70
        break;
e822c659890fatagen2026-10-25 02:37 UTC71
      case 'codeBlock': {
e822c659890fatagen2026-10-25 02:37 UTC72
        const lang = /^[\w+#-]+$/.test(node.lang ?? '') ? ` class="language-${escapeHtml(node.lang)}"` : '';
e822c659890fatagen2026-10-25 02:37 UTC73
        html += `<pre><code${lang}>${escapeHtml(node.content)}\n</code></pre>`;
e822c659890fatagen2026-10-25 02:37 UTC74
        break;
e822c659890fatagen2026-10-25 02:37 UTC75
      }
e822c659890fatagen2026-10-25 02:37 UTC76
      case 'spoiler':
e822c659890fatagen2026-10-25 02:37 UTC77
        html += `<span data-mx-spoiler>${renderMarkdown(node.parsed, link)}</span>`;
e822c659890fatagen2026-10-25 02:37 UTC78
        break;
e822c659890fatagen2026-10-25 02:37 UTC79
      case 'blockQuote':
e822c659890fatagen2026-10-25 02:37 UTC80
        html += `<blockquote>${children()}</blockquote>`;
e822c659890fatagen2026-10-25 02:37 UTC81
        break;
e822c659890fatagen2026-10-25 02:37 UTC82
      case 'paragraph':
e822c659890fatagen2026-10-25 02:37 UTC83
        html += `<p>${children()}</p>`;
e822c659890fatagen2026-10-25 02:37 UTC84
        break;
e822c659890fatagen2026-10-25 02:37 UTC85
      case 'heading': {
e822c659890fatagen2026-10-25 02:37 UTC86
        const level = Math.max(1, Math.min(6, node.level));
e822c659890fatagen2026-10-25 02:37 UTC87
        html += `<h${level}>${children()}</h${level}>`;
e822c659890fatagen2026-10-25 02:37 UTC88
        break;
e822c659890fatagen2026-10-25 02:37 UTC89
      }
e822c659890fatagen2026-10-25 02:37 UTC90
      case 'list': {
e822c659890fatagen2026-10-25 02:37 UTC91
        const tag = node.ordered ? 'ol' : 'ul';
e822c659890fatagen2026-10-25 02:37 UTC92
        const start = node.ordered && Number.isSafeInteger(node.start) && node.start > 0 ? ` start="${node.start}"` : '';
e822c659890fatagen2026-10-25 02:37 UTC93
        html += `<${tag}${start}>${node.items.map((item: MarkdownNode[]) => `<li>${renderMarkdown(item, link)}</li>`).join('')}</${tag}>`;
e822c659890fatagen2026-10-25 02:37 UTC94
        break;
e822c659890fatagen2026-10-25 02:37 UTC95
      }
e822c659890fatagen2026-10-25 02:37 UTC96
      case 'link':
e822c659890fatagen2026-10-25 02:37 UTC97
      case 'url':
e822c659890fatagen2026-10-25 02:37 UTC98
      case 'autolink': {
e822c659890fatagen2026-10-25 02:37 UTC99
        const label = renderMarkdown(node.content, (escaped) => escaped);
e822c659890fatagen2026-10-25 02:37 UTC100
        const href = markdownEngine.sanitizeUrl(node.target);
e822c659890fatagen2026-10-25 02:37 UTC101
        const title = node.title ? ` title="${escapeHtml(node.title)}"` : '';
e822c659890fatagen2026-10-25 02:37 UTC102
        html += href && /^(https?:|mailto:)/i.test(href) ? `<a href="${escapeHtml(href)}"${title}>${label}</a>` : label;
e822c659890fatagen2026-10-25 02:37 UTC103
        break;
e822c659890fatagen2026-10-25 02:37 UTC104
      }
e822c659890fatagen2026-10-25 02:37 UTC105
    }
e822c659890fatagen2026-10-25 02:37 UTC106
  }
e822c659890fatagen2026-10-25 02:37 UTC107
  flushText();
e822c659890fatagen2026-10-25 02:37 UTC108
  return html;
6d6a4c34bdc1atagen2026-10-24 06:51 UTC109
}
6d6a4c34bdc1atagen2026-10-24 06:51 UTC110
6d6a4c34bdc1atagen2026-10-24 06:51 UTC111
function markdownToHtml(body: string, link: (escaped: string) => string): string | null {
e822c659890fatagen2026-10-25 02:37 UTC112
  const nodes = parseMarkdown(body, { inline: true });
e822c659890fatagen2026-10-25 02:37 UTC113
  const html = renderMarkdown(nodes, link);
e822c659890fatagen2026-10-25 02:37 UTC114
  if (html === textToHtml(body, (escaped) => escaped)) return null;
e822c659890fatagen2026-10-25 02:37 UTC115
  if (nodes.every((node) => node.type === 'text' || node.type === 'br' || node.type === 'newline') ||
e822c659890fatagen2026-10-25 02:37 UTC116
    nodes.some((node) => BLOCK_TYPES.has(node.type))) return html;
e822c659890fatagen2026-10-25 02:37 UTC117
  return `<p>${html}</p>`;
6d6a4c34bdc1atagen2026-10-24 06:51 UTC118
}
6d6a4c34bdc1atagen2026-10-24 06:51 UTC119
6d6a4c34bdc1atagen2026-10-24 06:51 UTC120
/** Matrix `formatted_body` for a composed message, or null when plain text says it all. */
05a48e9e7942atagen2026-10-24 09:32 UTC121
export function formatBody(body: string, mentions: Mention[], markdown: boolean, emotes: Emote[] = []): string | null {
05a48e9e7942atagen2026-10-24 09:32 UTC122
  const mention = mentionLinker(mentions);
05a48e9e7942atagen2026-10-24 09:32 UTC123
  const emote = emoteLinker(emotes);
05a48e9e7942atagen2026-10-24 09:32 UTC124
  const link = (escaped: string) => emote(mention(escaped));
6d6a4c34bdc1atagen2026-10-24 06:51 UTC125
  const html = markdown ? markdownToHtml(body, link) : null;
6d6a4c34bdc1atagen2026-10-24 06:51 UTC126
  if (html !== null) return html;
6d6a4c34bdc1atagen2026-10-24 06:51 UTC127
  const plain = textToHtml(body, link);
6d6a4c34bdc1atagen2026-10-24 06:51 UTC128
  return plain === escapeHtml(body).replace(/\n/g, '<br>') ? null : plain;
6d6a4c34bdc1atagen2026-10-24 06:51 UTC129
}
6d6a4c34bdc1atagen2026-10-24 06:51 UTC130
6d6a4c34bdc1atagen2026-10-24 06:51 UTC131
export function insideOpenFence(text: string, caret: number): boolean {
6d6a4c34bdc1atagen2026-10-24 06:51 UTC132
  return (text.slice(0, caret).match(/^\s*```/gm)?.length ?? 0) % 2 === 1;
6d6a4c34bdc1atagen2026-10-24 06:51 UTC133
}