import DOMPurify from 'dompurify'; import { lexer, marked } from 'marked'; import type { Token } from 'marked'; const stripHtmlTags = (html: string): string => html.replace(/<[^>]*>/g, ' '); const getInlineText = (tokens: Token[]): string => tokens.map(getTokenText).join(''); function getTokenText(token: Token): string { switch (token.type) { case 'space': case 'hr': case 'br': case 'def': return ''; case 'code': case 'codespan': case 'escape': return token.text; case 'html': return stripHtmlTags(token.text); case 'image': return token.text; case 'blockquote': case 'list_item': return (token.tokens ?? []).map(getTokenText).join(' '); case 'list': return token.items.map(getTokenText).join(' '); case 'table': { const cells = [ ...token.header, ...token.rows.flat(), ]; return cells.map(cell => getInlineText(cell.tokens)).join(' '); } default: if ('tokens' in token && token.tokens) { return getInlineText(token.tokens); } return 'text' in token && typeof token.text === 'string' ? token.text : ''; } } /** * Converts Markdown into compact plain text for single-line message previews. */ export function markdownToPlainText(markdown: string): string { if (!markdown) { return ''; } return lexer(markdown, { gfm: true }) .map(getTokenText) .join(' ') .replace(/\s+/g, ' ') .trim(); } export function renderMarkdown(markdown: string): string { if (!markdown) { return ''; } // `marked` does not sanitize its output, so the HTML it produces from a // remote message is untrusted. Every HTML string this package hands to // `dangerouslySetInnerHTML` must go through DOMPurify first. const html = marked.parse(markdown, { async: false }); // DOMPurify needs a DOM. Without one it returns the input unchanged, which // would emit unsanitized HTML, so fail closed instead. if (!DOMPurify.isSupported) { return ''; } return DOMPurify.sanitize(html); }