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