import DOMPurify from "isomorphic-dompurify"; import { Marked, marked, type Tokens } from "marked"; import { MAX_MD_CACHE } from "../constants.ts"; marked.setOptions({ gfm: true }); const mdCache = new Map(); export function renderMarkdown(md: string, cacheKey?: string): string { if (cacheKey) { const cached = mdCache.get(cacheKey); if (cached) return cached; } const raw = marked(md) as string; const result = DOMPurify.sanitize(raw, { ADD_TAGS: ["details", "summary"], ADD_ATTR: ["class"], }); if (cacheKey) { if (mdCache.size >= MAX_MD_CACHE) mdCache.delete(mdCache.keys().next().value!); mdCache.set(cacheKey, result); } return result; } const _plaintextMarked = new Marked({ gfm: true }); _plaintextMarked.use({ renderer: { // Block renderers heading({ tokens }) { return `${String(this.parser.parseInline(tokens))}\n\n`; }, paragraph({ tokens }) { return `${String(this.parser.parseInline(tokens))}\n\n`; }, blockquote({ tokens }) { return `${String(this.parser.parse(tokens)) .trim() .split("\n") .map((l) => `> ${l}`) .join("\n")}\n\n`; }, code({ text }) { return `${text}\n\n`; }, list(token) { const start = typeof token.start === "number" ? token.start : 1; let body = ""; for (let i = 0; i < token.items.length; i++) { const item = token.items[i]!; const prefix = token.ordered ? `${start + i}. ` : "- "; const checkedPrefix = item.task ? item.checked ? "[x] " : "[ ] " : ""; const content = String(this.parser.parse(item.tokens)) .trim() .replace(/\n+/g, " "); body += `${prefix + checkedPrefix + content}\n`; } return `${body}\n`; }, listitem() { // Handled entirely inside list() return ""; }, table(token: Tokens.Table) { const cells = (row: Tokens.TableCell[]) => row .map((c) => String(this.parser.parseInline(c.tokens))) .join(" | "); let out = `${cells(token.header)}\n`; for (const row of token.rows) { out += `${cells(row)}\n`; } return `${out}\n`; }, hr() { return "---\n\n"; }, html() { return ""; }, // Inline renderers strong({ tokens }) { return String(this.parser.parseInline(tokens)); }, em({ tokens }) { return String(this.parser.parseInline(tokens)); }, del({ tokens }) { return String(this.parser.parseInline(tokens)); }, codespan({ text }) { return `\`${text}\``; }, link({ tokens }) { return String(this.parser.parseInline(tokens)); }, image({ text, title }) { const label = (title || text || "").trim(); return label ? `[Image: ${label}]` : "[Image]"; }, br() { return "\n"; }, text(token) { if ("tokens" in token && token.tokens) { return String(this.parser.parseInline(token.tokens)); } return token.text; }, }, }); /** Convert markdown to plaintext, preserving structure (list prefixes, headings, etc.) */ export function markdownToPlaintext(md: string): string { return (_plaintextMarked.parse(md) as string) .replace(/\n{3,}/g, "\n\n") .trim(); } /** * Returns a short single-line preview of a plaintext string: * the first paragraph/heading line, truncated to maxLen chars. */ export function plaintextPreview(text: string, maxLen = 180): string { const firstBlock = text.split("\n\n")[0]?.trim() ?? ""; const firstLine = firstBlock.split("\n")[0] ?? ""; if (firstLine.length <= maxLen) return firstLine; const truncated = firstLine.slice(0, maxLen); const lastSpace = truncated.lastIndexOf(" "); return ( (lastSpace > maxLen * 0.6 ? truncated.slice(0, lastSpace) : truncated) + "…" ); }