markdown.ts
Raw
1import DOMPurify from "isomorphic-dompurify";
2import { Marked, marked, type Tokens } from "marked";
3import { MAX_MD_CACHE, PREVIEW_MAX_LENGTH, PREVIEW_TRUNCATION_THRESHOLD } from "../constants.ts";
4
5marked.setOptions({ gfm: true });
6
7const mdCache = new Map<string, string>();
8
9export interface MarkdownContext {
10 repo: string;
11 ref: string;
12 /** Directory of the markdown file relative to repo root, e.g. "" or "docs/subdir" */
13 dir: string;
14}
15
16/**
17 * Resolves a markdown href to a repo-root-relative path for rewriting.
18 * Returns null if the href should not be rewritten (protocol-absolute or anchor).
19 *
20 * - Protocol-absolute (http://, mailto:, data:, …): returns null
21 * - Anchor (#section): returns null
22 * - Root-relative (/subdir/img.png): strips leading slash → "subdir/img.png"
23 * - Path-relative (./img.png, ../img.png, subdir/img.png): resolved against dir
24 */
25export function resolveMarkdownHref(dir: string, href: string): string | null {
26 if (/^[a-zA-Z][a-zA-Z\d+\-.]*:/.test(href)) return null; // protocol-absolute
27 if (href.startsWith("#")) return null; // anchor
28 if (href.startsWith("/")) return href.slice(1); // root-relative
29 // Path-relative: resolve against current directory using URL API
30 const base = new URL(`http://x/${dir ? `${dir}/` : ""}`);
31 return new URL(href, base).pathname.slice(1); // strip leading /
32}
33
34function makeContextualMarked(ctx: MarkdownContext): Marked {
35 const m = new Marked({ gfm: true });
36 m.use({
37 renderer: {
38 image({ href, title, text }) {
39 const resolved = resolveMarkdownHref(ctx.dir, href);
40 if (resolved !== null) {
41 href = `/${ctx.repo}/raw/${ctx.ref}/${resolved}`;
42 }
43 return `<img src="${href}" alt="${text}"${title ? ` title="${title}"` : ""}>`;
44 },
45 link({ href, title, tokens }) {
46 const resolved = resolveMarkdownHref(ctx.dir, href);
47 if (resolved !== null) {
48 href = `/${ctx.repo}/blob/${ctx.ref}/${resolved}`;
49 }
50 const text = String(this.parser.parseInline(tokens));
51 return `<a href="${href}"${title ? ` title="${title}"` : ""}>${text}</a>`;
52 },
53 },
54 });
55 return m;
56}
57
58export function renderMarkdown(
59 md: string,
60 cacheKey?: string,
61 ctx?: MarkdownContext,
62): string {
63 if (cacheKey) {
64 const cached = mdCache.get(cacheKey);
65 if (cached) return cached;
66 }
67 const raw = ctx
68 ? (makeContextualMarked(ctx).parse(md) as string)
69 : (marked(md) as string);
70 const result = DOMPurify.sanitize(raw, {
71 ADD_TAGS: ["details", "summary"],
72 ADD_ATTR: ["class"],
73 });
74 if (cacheKey) {
75 if (mdCache.size >= MAX_MD_CACHE)
76 mdCache.delete(mdCache.keys().next().value!);
77 mdCache.set(cacheKey, result);
78 }
79 return result;
80}
81
82const _plaintextMarked = new Marked({ gfm: true });
83_plaintextMarked.use({
84 renderer: {
85 // Block renderers
86 heading({ tokens }) {
87 return `${String(this.parser.parseInline(tokens))}\n\n`;
88 },
89 paragraph({ tokens }) {
90 return `${String(this.parser.parseInline(tokens))}\n\n`;
91 },
92 blockquote({ tokens }) {
93 return `${String(this.parser.parse(tokens))
94 .trim()
95 .split("\n")
96 .map((l) => `> ${l}`)
97 .join("\n")}\n\n`;
98 },
99 code({ text }) {
100 return `${text}\n\n`;
101 },
102 list(token) {
103 const start = typeof token.start === "number" ? token.start : 1;
104 let body = "";
105 for (let i = 0; i < token.items.length; i++) {
106 const item = token.items[i]!;
107 const prefix = token.ordered ? `${start + i}. ` : "- ";
108 const checkedPrefix = item.task
109 ? item.checked
110 ? "[x] "
111 : "[ ] "
112 : "";
113 const content = String(this.parser.parse(item.tokens))
114 .trim()
115 .replace(/\n+/g, " ");
116 body += `${prefix + checkedPrefix + content}\n`;
117 }
118 return `${body}\n`;
119 },
120 listitem() {
121 // Handled entirely inside list()
122 return "";
123 },
124 table(token: Tokens.Table) {
125 const cells = (row: Tokens.TableCell[]) =>
126 row
127 .map((c) => String(this.parser.parseInline(c.tokens)))
128 .join(" | ");
129 let out = `${cells(token.header)}\n`;
130 for (const row of token.rows) {
131 out += `${cells(row)}\n`;
132 }
133 return `${out}\n`;
134 },
135 hr() {
136 return "---\n\n";
137 },
138 html() {
139 return "";
140 },
141 // Inline renderers
142 strong({ tokens }) {
143 return String(this.parser.parseInline(tokens));
144 },
145 em({ tokens }) {
146 return String(this.parser.parseInline(tokens));
147 },
148 del({ tokens }) {
149 return String(this.parser.parseInline(tokens));
150 },
151 codespan({ text }) {
152 return `\`${text}\``;
153 },
154 link({ tokens }) {
155 return String(this.parser.parseInline(tokens));
156 },
157 image({ text, title }) {
158 const label = (title || text || "").trim();
159 return label ? `[Image: ${label}]` : "[Image]";
160 },
161 br() {
162 return "\n";
163 },
164 text(token) {
165 if ("tokens" in token && token.tokens) {
166 return String(this.parser.parseInline(token.tokens));
167 }
168 return token.text;
169 },
170 },
171});
172
173/** Convert markdown to plaintext, preserving structure (list prefixes, headings, etc.) */
174export function markdownToPlaintext(md: string): string {
175 return (_plaintextMarked.parse(md) as string)
176 .replace(/\n{3,}/g, "\n\n")
177 .trim();
178}
179
180/**
181 * Returns a short single-line preview of a plaintext string:
182 * the first paragraph/heading line, truncated to maxLen chars.
183 */
184export function plaintextPreview(text: string, maxLen = PREVIEW_MAX_LENGTH): string {
185 const firstBlock = text.split("\n\n")[0]?.trim() ?? "";
186 const firstLine = firstBlock.split("\n")[0] ?? "";
187 if (firstLine.length <= maxLen) return firstLine;
188 const truncated = firstLine.slice(0, maxLen);
189 const lastSpace = truncated.lastIndexOf(" ");
190 return (
191 (lastSpace > maxLen * PREVIEW_TRUNCATION_THRESHOLD ? truncated.slice(0, lastSpace) : truncated) +
192 "…"
193 );
194}
195