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