132eeceeb362 | atagen | 2026-10-25 03:34 UTC | 1 | import markdown, { type MarkdownNode } from '@cloudrac3r/discord-markdown'; |
05a48e9e7942 | atagen | 2026-10-24 09:32 UTC | 2 | import type { Emote } from './emotes'; |
6d6a4c34bdc1 | atagen | 2026-10-24 06:51 UTC | 3 | import type { Mention } from './mentions'; |
6d6a4c34bdc1 | atagen | 2026-10-24 06:51 UTC | 4 | |
132eeceeb362 | atagen | 2026-10-25 03:34 UTC | 5 | const { markdownEngine, rules } = markdown; |
132eeceeb362 | atagen | 2026-10-25 03:34 UTC | 6 | |
6d6a4c34bdc1 | atagen | 2026-10-24 06:51 UTC | 7 | function escapeHtml(text: string): string { |
6d6a4c34bdc1 | atagen | 2026-10-24 06:51 UTC | 8 | return text.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"'); |
6d6a4c34bdc1 | atagen | 2026-10-24 06:51 UTC | 9 | } |
6d6a4c34bdc1 | atagen | 2026-10-24 06:51 UTC | 10 | |
6d6a4c34bdc1 | atagen | 2026-10-24 06:51 UTC | 11 | function escapeRegExp(text: string): string { |
6d6a4c34bdc1 | atagen | 2026-10-24 06:51 UTC | 12 | return text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); |
6d6a4c34bdc1 | atagen | 2026-10-24 06:51 UTC | 13 | } |
6d6a4c34bdc1 | atagen | 2026-10-24 06:51 UTC | 14 | |
6d6a4c34bdc1 | atagen | 2026-10-24 06:51 UTC | 15 | function mentionLinker(mentions: Mention[]): (escaped: string) => string { |
6d6a4c34bdc1 | atagen | 2026-10-24 06:51 UTC | 16 | const byText = new Map(mentions.filter((m) => m.text).map((m) => [escapeHtml(m.text), m.user_id])); |
6d6a4c34bdc1 | atagen | 2026-10-24 06:51 UTC | 17 | if (byText.size === 0) return (escaped) => escaped; |
6d6a4c34bdc1 | atagen | 2026-10-24 06:51 UTC | 18 | const names = [...byText.keys()].sort((a, b) => b.length - a.length); |
6d6a4c34bdc1 | atagen | 2026-10-24 06:51 UTC | 19 | const pattern = new RegExp(names.map(escapeRegExp).join('|'), 'g'); |
6d6a4c34bdc1 | atagen | 2026-10-24 06:51 UTC | 20 | return (escaped) => |
6d6a4c34bdc1 | atagen | 2026-10-24 06:51 UTC | 21 | escaped.replace(pattern, (name) => `<a href="https://matrix.to/#/${escapeHtml(byText.get(name)!)}">${name}</a>`); |
6d6a4c34bdc1 | atagen | 2026-10-24 06:51 UTC | 22 | } |
6d6a4c34bdc1 | atagen | 2026-10-24 06:51 UTC | 23 | |
05a48e9e7942 | atagen | 2026-10-24 09:32 UTC | 24 | function emoteLinker(emotes: Emote[]): (escaped: string) => string { |
05a48e9e7942 | atagen | 2026-10-24 09:32 UTC | 25 | if (emotes.length === 0) return (escaped) => escaped; |
05a48e9e7942 | atagen | 2026-10-24 09:32 UTC | 26 | const byCode = new Map(emotes.map((e) => [e.shortcode, e.mxc])); |
05a48e9e7942 | atagen | 2026-10-24 09:32 UTC | 27 | return (escaped) => |
05a48e9e7942 | atagen | 2026-10-24 09:32 UTC | 28 | escaped.replace(/:([\w+-]+):/g, (whole, code: string) => { |
05a48e9e7942 | atagen | 2026-10-24 09:32 UTC | 29 | const mxc = byCode.get(code); |
05a48e9e7942 | atagen | 2026-10-24 09:32 UTC | 30 | return mxc ? `<img data-mx-emoticon src="${escapeHtml(mxc)}" alt="${whole}" title="${whole}" height="32">` : whole; |
05a48e9e7942 | atagen | 2026-10-24 09:32 UTC | 31 | }); |
05a48e9e7942 | atagen | 2026-10-24 09:32 UTC | 32 | } |
05a48e9e7942 | atagen | 2026-10-24 09:32 UTC | 33 | |
6d6a4c34bdc1 | atagen | 2026-10-24 06:51 UTC | 34 | function textToHtml(text: string, link: (escaped: string) => string): string { |
6d6a4c34bdc1 | atagen | 2026-10-24 06:51 UTC | 35 | return link(escapeHtml(text)).replace(/\n/g, '<br>'); |
6d6a4c34bdc1 | atagen | 2026-10-24 06:51 UTC | 36 | } |
6d6a4c34bdc1 | atagen | 2026-10-24 06:51 UTC | 37 | |
e822c659890f | atagen | 2026-10-25 02:37 UTC | 38 | const parseMarkdown = markdownEngine.parserFor( |
e822c659890f | atagen | 2026-10-25 02:37 UTC | 39 | Object.fromEntries(Object.entries(rules).filter(([name]) => !name.startsWith('discord'))) |
e822c659890f | atagen | 2026-10-25 02:37 UTC | 40 | ); |
e822c659890f | atagen | 2026-10-25 02:37 UTC | 41 | const INLINE_TAGS: Record<string, string> = { em: 'em', strong: 'strong', u: 'u', strike: 'del', small: 'sub' }; |
e822c659890f | atagen | 2026-10-25 02:37 UTC | 42 | const BLOCK_TYPES = new Set(['blockQuote', 'codeBlock', 'heading', 'list', 'paragraph']); |
6d6a4c34bdc1 | atagen | 2026-10-24 06:51 UTC | 43 | |
e822c659890f | atagen | 2026-10-25 02:37 UTC | 44 | function renderMarkdown(nodes: MarkdownNode[], link: (escaped: string) => string): string { |
e822c659890f | atagen | 2026-10-25 02:37 UTC | 45 | let html = ''; |
e822c659890f | atagen | 2026-10-25 02:37 UTC | 46 | let text = ''; |
e822c659890f | atagen | 2026-10-25 02:37 UTC | 47 | const flushText = () => { |
e822c659890f | atagen | 2026-10-25 02:37 UTC | 48 | html += textToHtml(text, link); |
e822c659890f | atagen | 2026-10-25 02:37 UTC | 49 | text = ''; |
e822c659890f | atagen | 2026-10-25 02:37 UTC | 50 | }; |
e822c659890f | atagen | 2026-10-25 02:37 UTC | 51 | for (const node of nodes) { |
e822c659890f | atagen | 2026-10-25 02:37 UTC | 52 | if (node.type === 'text') { |
e822c659890f | atagen | 2026-10-25 02:37 UTC | 53 | text += node.content; |
e822c659890f | atagen | 2026-10-25 02:37 UTC | 54 | continue; |
e822c659890f | atagen | 2026-10-25 02:37 UTC | 55 | } |
e822c659890f | atagen | 2026-10-25 02:37 UTC | 56 | flushText(); |
e822c659890f | atagen | 2026-10-25 02:37 UTC | 57 | const children = () => renderMarkdown(node.content, link); |
e822c659890f | atagen | 2026-10-25 02:37 UTC | 58 | const tag = INLINE_TAGS[node.type]; |
e822c659890f | atagen | 2026-10-25 02:37 UTC | 59 | if (tag) { |
e822c659890f | atagen | 2026-10-25 02:37 UTC | 60 | html += `<${tag}>${children()}</${tag}>`; |
e822c659890f | atagen | 2026-10-25 02:37 UTC | 61 | continue; |
e822c659890f | atagen | 2026-10-25 02:37 UTC | 62 | } |
e822c659890f | atagen | 2026-10-25 02:37 UTC | 63 | switch (node.type) { |
e822c659890f | atagen | 2026-10-25 02:37 UTC | 64 | case 'br': |
e822c659890f | atagen | 2026-10-25 02:37 UTC | 65 | case 'newline': |
e822c659890f | atagen | 2026-10-25 02:37 UTC | 66 | html += '<br>'; |
e822c659890f | atagen | 2026-10-25 02:37 UTC | 67 | break; |
e822c659890f | atagen | 2026-10-25 02:37 UTC | 68 | case 'inlineCode': |
e822c659890f | atagen | 2026-10-25 02:37 UTC | 69 | html += `<code>${escapeHtml(node.content)}</code>`; |
e822c659890f | atagen | 2026-10-25 02:37 UTC | 70 | break; |
e822c659890f | atagen | 2026-10-25 02:37 UTC | 71 | case 'codeBlock': { |
e822c659890f | atagen | 2026-10-25 02:37 UTC | 72 | const lang = /^[\w+#-]+$/.test(node.lang ?? '') ? ` class="language-${escapeHtml(node.lang)}"` : ''; |
e822c659890f | atagen | 2026-10-25 02:37 UTC | 73 | html += `<pre><code${lang}>${escapeHtml(node.content)}\n</code></pre>`; |
e822c659890f | atagen | 2026-10-25 02:37 UTC | 74 | break; |
e822c659890f | atagen | 2026-10-25 02:37 UTC | 75 | } |
e822c659890f | atagen | 2026-10-25 02:37 UTC | 76 | case 'spoiler': |
e822c659890f | atagen | 2026-10-25 02:37 UTC | 77 | html += `<span data-mx-spoiler>${renderMarkdown(node.parsed, link)}</span>`; |
e822c659890f | atagen | 2026-10-25 02:37 UTC | 78 | break; |
e822c659890f | atagen | 2026-10-25 02:37 UTC | 79 | case 'blockQuote': |
e822c659890f | atagen | 2026-10-25 02:37 UTC | 80 | html += `<blockquote>${children()}</blockquote>`; |
e822c659890f | atagen | 2026-10-25 02:37 UTC | 81 | break; |
e822c659890f | atagen | 2026-10-25 02:37 UTC | 82 | case 'paragraph': |
e822c659890f | atagen | 2026-10-25 02:37 UTC | 83 | html += `<p>${children()}</p>`; |
e822c659890f | atagen | 2026-10-25 02:37 UTC | 84 | break; |
e822c659890f | atagen | 2026-10-25 02:37 UTC | 85 | case 'heading': { |
e822c659890f | atagen | 2026-10-25 02:37 UTC | 86 | const level = Math.max(1, Math.min(6, node.level)); |
e822c659890f | atagen | 2026-10-25 02:37 UTC | 87 | html += `<h${level}>${children()}</h${level}>`; |
e822c659890f | atagen | 2026-10-25 02:37 UTC | 88 | break; |
e822c659890f | atagen | 2026-10-25 02:37 UTC | 89 | } |
e822c659890f | atagen | 2026-10-25 02:37 UTC | 90 | case 'list': { |
e822c659890f | atagen | 2026-10-25 02:37 UTC | 91 | const tag = node.ordered ? 'ol' : 'ul'; |
e822c659890f | atagen | 2026-10-25 02:37 UTC | 92 | const start = node.ordered && Number.isSafeInteger(node.start) && node.start > 0 ? ` start="${node.start}"` : ''; |
e822c659890f | atagen | 2026-10-25 02:37 UTC | 93 | html += `<${tag}${start}>${node.items.map((item: MarkdownNode[]) => `<li>${renderMarkdown(item, link)}</li>`).join('')}</${tag}>`; |
e822c659890f | atagen | 2026-10-25 02:37 UTC | 94 | break; |
e822c659890f | atagen | 2026-10-25 02:37 UTC | 95 | } |
e822c659890f | atagen | 2026-10-25 02:37 UTC | 96 | case 'link': |
e822c659890f | atagen | 2026-10-25 02:37 UTC | 97 | case 'url': |
e822c659890f | atagen | 2026-10-25 02:37 UTC | 98 | case 'autolink': { |
e822c659890f | atagen | 2026-10-25 02:37 UTC | 99 | const label = renderMarkdown(node.content, (escaped) => escaped); |
e822c659890f | atagen | 2026-10-25 02:37 UTC | 100 | const href = markdownEngine.sanitizeUrl(node.target); |
e822c659890f | atagen | 2026-10-25 02:37 UTC | 101 | const title = node.title ? ` title="${escapeHtml(node.title)}"` : ''; |
e822c659890f | atagen | 2026-10-25 02:37 UTC | 102 | html += href && /^(https?:|mailto:)/i.test(href) ? `<a href="${escapeHtml(href)}"${title}>${label}</a>` : label; |
e822c659890f | atagen | 2026-10-25 02:37 UTC | 103 | break; |
e822c659890f | atagen | 2026-10-25 02:37 UTC | 104 | } |
e822c659890f | atagen | 2026-10-25 02:37 UTC | 105 | } |
e822c659890f | atagen | 2026-10-25 02:37 UTC | 106 | } |
e822c659890f | atagen | 2026-10-25 02:37 UTC | 107 | flushText(); |
e822c659890f | atagen | 2026-10-25 02:37 UTC | 108 | return html; |
6d6a4c34bdc1 | atagen | 2026-10-24 06:51 UTC | 109 | } |
6d6a4c34bdc1 | atagen | 2026-10-24 06:51 UTC | 110 | |
6d6a4c34bdc1 | atagen | 2026-10-24 06:51 UTC | 111 | function markdownToHtml(body: string, link: (escaped: string) => string): string | null { |
e822c659890f | atagen | 2026-10-25 02:37 UTC | 112 | const nodes = parseMarkdown(body, { inline: true }); |
e822c659890f | atagen | 2026-10-25 02:37 UTC | 113 | const html = renderMarkdown(nodes, link); |
e822c659890f | atagen | 2026-10-25 02:37 UTC | 114 | if (html === textToHtml(body, (escaped) => escaped)) return null; |
e822c659890f | atagen | 2026-10-25 02:37 UTC | 115 | if (nodes.every((node) => node.type === 'text' || node.type === 'br' || node.type === 'newline') || |
e822c659890f | atagen | 2026-10-25 02:37 UTC | 116 | nodes.some((node) => BLOCK_TYPES.has(node.type))) return html; |
e822c659890f | atagen | 2026-10-25 02:37 UTC | 117 | return `<p>${html}</p>`; |
6d6a4c34bdc1 | atagen | 2026-10-24 06:51 UTC | 118 | } |
6d6a4c34bdc1 | atagen | 2026-10-24 06:51 UTC | 119 | |
6d6a4c34bdc1 | atagen | 2026-10-24 06:51 UTC | 120 | /** Matrix `formatted_body` for a composed message, or null when plain text says it all. */ |
05a48e9e7942 | atagen | 2026-10-24 09:32 UTC | 121 | export function formatBody(body: string, mentions: Mention[], markdown: boolean, emotes: Emote[] = []): string | null { |
05a48e9e7942 | atagen | 2026-10-24 09:32 UTC | 122 | const mention = mentionLinker(mentions); |
05a48e9e7942 | atagen | 2026-10-24 09:32 UTC | 123 | const emote = emoteLinker(emotes); |
05a48e9e7942 | atagen | 2026-10-24 09:32 UTC | 124 | const link = (escaped: string) => emote(mention(escaped)); |
6d6a4c34bdc1 | atagen | 2026-10-24 06:51 UTC | 125 | const html = markdown ? markdownToHtml(body, link) : null; |
6d6a4c34bdc1 | atagen | 2026-10-24 06:51 UTC | 126 | if (html !== null) return html; |
6d6a4c34bdc1 | atagen | 2026-10-24 06:51 UTC | 127 | const plain = textToHtml(body, link); |
6d6a4c34bdc1 | atagen | 2026-10-24 06:51 UTC | 128 | return plain === escapeHtml(body).replace(/\n/g, '<br>') ? null : plain; |
6d6a4c34bdc1 | atagen | 2026-10-24 06:51 UTC | 129 | } |
6d6a4c34bdc1 | atagen | 2026-10-24 06:51 UTC | 130 | |
6d6a4c34bdc1 | atagen | 2026-10-24 06:51 UTC | 131 | export function insideOpenFence(text: string, caret: number): boolean { |
6d6a4c34bdc1 | atagen | 2026-10-24 06:51 UTC | 132 | return (text.slice(0, caret).match(/^\s*```/gm)?.length ?? 0) % 2 === 1; |
6d6a4c34bdc1 | atagen | 2026-10-24 06:51 UTC | 133 | } |