import { Marked } from "marked"; import footnote from "marked-footnote"; import markedKatex from "marked-katex-extension"; import { createHighlighter, type Highlighter } from "shiki"; import imageMap from "./image-map.json" with { type: "json" }; let highlighterPromise: Promise | undefined; function getHighlighter(): Promise { if (!highlighterPromise) { highlighterPromise = createHighlighter({ themes: ["github-light", "github-dark"], langs: [ "javascript", "typescript", "python", "julia", "rust", "json", "bash", "yaml", "markdown", "html", "css", "sql", "r", "toml", ], }).catch((error) => { highlighterPromise = undefined; throw error; }); } return highlighterPromise; } export async function renderMarkdown( source: string, options: { allowRawHtml?: boolean } = {}, ): Promise { const hl = await getHighlighter(); const allowRawHtml = options.allowRawHtml ?? true; // Rewrite old Franklin image paths to CDN URLs const map = imageMap as Record; const withImages = source.replace( /!\[([^\]]*)\]\(([^)]+)\)/g, (match, alt, src) => { const cdnUrl = map[src]; return cdnUrl ? `![${alt}](${cdnUrl})` : match; } ); const rewritten = allowRawHtml ? withImages.replace( /]*)><\/leaflet-card>/gi, (match, attributes: string) => { const value = (name: string) => { const found = attributes.match(new RegExp(`\\b${name}="([^"]*)"`, "i")); return found?.[1] ?.replace(/"/g, '"') .replace(/</g, "<") .replace(/>/g, ">") .replace(/&/g, "&"); }; const url = safeHttpUrl(value("data-href")); if (!url) return ""; const href = url.toString(); const title = value("data-title") || href; const description = value("data-description"); return ``; }, ) : withImages; const marked = new Marked({ renderer: { html({ text }) { return allowRawHtml ? text : escapeHtml(text); }, ...(!allowRawHtml ? { link({ href, title, tokens }) { const safeHref = safeMarkdownUrl(href, true); const label = this.parser.parseInline(tokens); if (!safeHref) return label; const titleAttribute = title ? ` title="${escapeHtml(title)}"` : ""; return `${label}`; }, image({ href, title, text }) { const safeSrc = safeMarkdownUrl(href, false); if (!safeSrc) return escapeHtml(text); const titleAttribute = title ? ` title="${escapeHtml(title)}"` : ""; return `${escapeHtml(text)}`; }, } : {}), code({ text, lang }) { const language = lang || "text"; try { return hl.codeToHtml(text, { lang: language, themes: { light: "github-light", dark: "github-dark" }, }); } catch { // Unknown language, fall back to plain return `
${escapeHtml(text)}
`; } }, }, }); marked.use(footnote()); marked.use(markedKatex({ throwOnError: false, output: "mathml", })); let html = await marked.parse(rewritten); if (allowRawHtml) { // Replace placeholders with oEmbed HTML. const bskyRegex = /]*data-url="([^"]+)"[^>]*><\/bsky-embed>/g; const matches = [...html.matchAll(bskyRegex)]; for (const match of matches) { const url = match[1]; try { const res = await fetch( `https://embed.bsky.app/oembed?url=${encodeURIComponent(url)}` ); if (res.ok) { const data = await res.json(); html = html.replace(match[0], data.html); } } catch { // Keep as link fallback. html = html.replace( match[0], `

Bluesky post

` ); } } } return html; } function escapeHtml(str: string): string { return str .replace(/&/g, "&") .replace(//g, ">") .replace(/"/g, """) .replace(/'/g, "'"); } function safeMarkdownUrl(value: string, allowMailto: boolean): string | undefined { const decoded = value .replace(/&#x([0-9a-f]+);?/gi, (_, hex: string) => String.fromCodePoint(Number.parseInt(hex, 16))) .replace(/&#([0-9]+);?/g, (_, decimal: string) => String.fromCodePoint(Number.parseInt(decimal, 10))) .replace(/:/gi, ":") .replace(/&(?:tab|newline);/gi, ""); const compact = decoded.replace(/[\u0000-\u0020\u007f]+/g, "").trim(); if (!compact || compact.includes("\\") || compact.startsWith("//")) return undefined; if (compact.startsWith("/") || compact.startsWith("#") || compact.startsWith("./") || compact.startsWith("../")) { return compact; } const scheme = compact.match(/^([a-z][a-z0-9+.-]*):/i)?.[1]?.toLowerCase(); if (!scheme) return compact; if (scheme === "http" || scheme === "https" || (allowMailto && scheme === "mailto")) { return compact; } return undefined; } function safeHttpUrl(value: string | undefined): URL | undefined { if (!value) return undefined; try { const url = new URL(value); return url.protocol === "http:" || url.protocol === "https:" ? url : undefined; } catch { return undefined; } }