markdown.ts
Raw
1import DOMPurify from "isomorphic-dompurify";
2import { Marked, marked, type Tokens } from "marked";
3import { MAX_MD_CACHE } from "../constants.ts";
4
5marked.setOptions({ gfm: true });
6
7const mdCache = new Map<string, string>();
8
9export function renderMarkdown(md: string, cacheKey?: string): string {
10 if (cacheKey) {
11 const cached = mdCache.get(cacheKey);
12 if (cached) return cached;
13 }
14 const raw = marked(md) as string;
15 const result = DOMPurify.sanitize(raw, {
16 ADD_TAGS: ["details", "summary"],
17 ADD_ATTR: ["class"],
18 });
19 if (cacheKey) {
20 if (mdCache.size >= MAX_MD_CACHE)
21 mdCache.delete(mdCache.keys().next().value!);
22 mdCache.set(cacheKey, result);
23 }
24 return result;
25}
26
27const _plaintextMarked = new Marked({ gfm: true });
28_plaintextMarked.use({
29 renderer: {
30 // Block renderers
31 heading({ tokens }) {
32 return `${String(this.parser.parseInline(tokens))}\n\n`;
33 },
34 paragraph({ tokens }) {
35 return `${String(this.parser.parseInline(tokens))}\n\n`;
36 },
37 blockquote({ tokens }) {
38 return `${String(this.parser.parse(tokens))
39 .trim()
40 .split("\n")
41 .map((l) => `> ${l}`)
42 .join("\n")}\n\n`;
43 },
44 code({ text }) {
45 return `${text}\n\n`;
46 },
47 list(token) {
48 const start = typeof token.start === "number" ? token.start : 1;
49 let body = "";
50 for (let i = 0; i < token.items.length; i++) {
51 const item = token.items[i]!;
52 const prefix = token.ordered ? `${start + i}. ` : "- ";
53 const checkedPrefix = item.task
54 ? item.checked
55 ? "[x] "
56 : "[ ] "
57 : "";
58 const content = String(this.parser.parse(item.tokens))
59 .trim()
60 .replace(/\n+/g, " ");
61 body += `${prefix + checkedPrefix + content}\n`;
62 }
63 return `${body}\n`;
64 },
65 listitem() {
66 // Handled entirely inside list()
67 return "";
68 },
69 table(token: Tokens.Table) {
70 const cells = (row: Tokens.TableCell[]) =>
71 row
72 .map((c) => String(this.parser.parseInline(c.tokens)))
73 .join(" | ");
74 let out = `${cells(token.header)}\n`;
75 for (const row of token.rows) {
76 out += `${cells(row)}\n`;
77 }
78 return `${out}\n`;
79 },
80 hr() {
81 return "---\n\n";
82 },
83 html() {
84 return "";
85 },
86 // Inline renderers
87 strong({ tokens }) {
88 return String(this.parser.parseInline(tokens));
89 },
90 em({ tokens }) {
91 return String(this.parser.parseInline(tokens));
92 },
93 del({ tokens }) {
94 return String(this.parser.parseInline(tokens));
95 },
96 codespan({ text }) {
97 return `\`${text}\``;
98 },
99 link({ tokens }) {
100 return String(this.parser.parseInline(tokens));
101 },
102 image({ text, title }) {
103 const label = (title || text || "").trim();
104 return label ? `[Image: ${label}]` : "[Image]";
105 },
106 br() {
107 return "\n";
108 },
109 text(token) {
110 if ("tokens" in token && token.tokens) {
111 return String(this.parser.parseInline(token.tokens));
112 }
113 return token.text;
114 },
115 },
116});
117
118/** Convert markdown to plaintext, preserving structure (list prefixes, headings, etc.) */
119export function markdownToPlaintext(md: string): string {
120 return (_plaintextMarked.parse(md) as string)
121 .replace(/\n{3,}/g, "\n\n")
122 .trim();
123}
124
125/**
126 * Returns a short single-line preview of a plaintext string:
127 * the first paragraph/heading line, truncated to maxLen chars.
128 */
129export function plaintextPreview(text: string, maxLen = 180): string {
130 const firstBlock = text.split("\n\n")[0]?.trim() ?? "";
131 const firstLine = firstBlock.split("\n")[0] ?? "";
132 if (firstLine.length <= maxLen) return firstLine;
133 const truncated = firstLine.slice(0, maxLen);
134 const lastSpace = truncated.lastIndexOf(" ");
135 return (
136 (lastSpace > maxLen * 0.6 ? truncated.slice(0, lastSpace) : truncated) +
137 "…"
138 );
139}
140