import DOMPurify from "isomorphic-dompurify"; import { Marked, marked, type Tokens } from "marked"; import { MAX_MD_CACHE, PREVIEW_MAX_LENGTH, PREVIEW_TRUNCATION_THRESHOLD, } from "../constants.ts"; marked.setOptions({ gfm: true }); const mdCache = new Map(); export interface MarkdownContext { repo: string; ref: string; /** Directory of the markdown file relative to repo root, e.g. "" or "docs/subdir" */ dir: string; } /** * Resolves a markdown href to a repo-root-relative path for rewriting. * Returns null if the href should not be rewritten (protocol-absolute or anchor). * * - Protocol-absolute (http://, mailto:, data:, …): returns null * - Anchor (#section): returns null * - Root-relative (/subdir/img.png): strips leading slash → "subdir/img.png" * - Path-relative (./img.png, ../img.png, subdir/img.png): resolved against dir */ export function resolveMarkdownHref(dir: string, href: string): string | null { if (/^[a-zA-Z][a-zA-Z\d+\-.]*:/.test(href)) return null; // protocol-absolute if (href.startsWith("#")) return null; // anchor if (href.startsWith("/")) return href.slice(1); // root-relative // Path-relative: resolve against current directory using URL API const base = new URL(`http://x/${dir ? `${dir}/` : ""}`); return new URL(href, base).pathname.slice(1); // strip leading / } function makeContextualMarked(ctx: MarkdownContext): Marked { const m = new Marked({ gfm: true }); m.use({ renderer: { image({ href, title, text }) { const resolved = resolveMarkdownHref(ctx.dir, href); if (resolved !== null) { href = `/${ctx.repo}/raw/${ctx.ref}/${resolved}`; } return `${text}`; }, link({ href, title, tokens }) { const resolved = resolveMarkdownHref(ctx.dir, href); if (resolved !== null) { href = `/${ctx.repo}/blob/${ctx.ref}/${resolved}`; } const text = String(this.parser.parseInline(tokens)); return `${text}`; }, }, }); return m; } export function renderMarkdown( md: string, cacheKey?: string, ctx?: MarkdownContext, ): string { if (cacheKey) { const cached = mdCache.get(cacheKey); if (cached) return cached; } const raw = ctx ? (makeContextualMarked(ctx).parse(md) as string) : (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 = PREVIEW_MAX_LENGTH, ): 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 * PREVIEW_TRUNCATION_THRESHOLD ? truncated.slice(0, lastSpace) : truncated }…`; }