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