import { esc } from "./html"; /** * Compact markdown renderer for README/about pages. Supported: headings, * fenced code, lists, blockquotes, hr, links, images, emphasis, inline * code, autolinks. Not supported: tables, footnotes, raw HTML (everything * is escaped first; only http(s)/relative/# link targets are allowed). */ function safeUrl(url: string): string | null { const u = url.trim(); if (/^(https?:)?\/\//i.test(u) || u.startsWith("#") || /^[\w./-]/.test(u)) { if (/^javascript:/i.test(u) || /^data:/i.test(u) || /^vbscript:/i.test(u)) return null; return u; } return null; } function inline(text: string): string { let s = esc(text); // inline code first (protects its content from other rules) s = s.replace(/`([^`]+)`/g, (_m, code) => `${code}`); // images s = s.replace(/!\[([^\]]*)\]\(([^)\s]+)(?:\s+"[^&]*")?\)/g, (_m, alt, url) => { const u = safeUrl(url); return u ? `${alt}` : alt; }); // links s = s.replace(/\[([^\]]+)\]\(([^)\s]+)(?:\s+"[^&]*")?\)/g, (_m, txt, url) => { const u = safeUrl(url); return u ? `${txt}` : txt; }); // bold, italics s = s.replace(/\*\*([^*]+)\*\*/g, "$1"); s = s.replace(/(^|[\s(])\*([^*\s][^*]*)\*/g, "$1$2"); s = s.replace(/(^|[\s(])_([^_\s][^_]*)_/g, "$1$2"); // autolink bare urls (not already inside an attribute) s = s.replace(/(^|[\s(])(https?:\/\/[^\s<)]+)/g, "$1$2"); return s; } export function renderMarkdown(src: string): string { const lines = src.replaceAll("\r\n", "\n").split("\n"); const out: string[] = []; let para: string[] = []; let inCode = false; let codeLines: string[] = []; let listStack: ("ul" | "ol")[] = []; let quote = false; const flushPara = () => { if (para.length) { out.push(`

${inline(para.join("\n"))}

`); para = []; } }; const closeLists = (depth: number) => { while (listStack.length > depth) out.push(``); }; const closeQuote = () => { if (quote) { out.push(""); quote = false; } }; for (const raw of lines) { if (inCode) { if (/^\s*(```|~~~)\s*$/.test(raw)) { out.push(`
${esc(codeLines.join("\n"))}
`); codeLines = []; inCode = false; } else { codeLines.push(raw); } continue; } const fence = raw.match(/^\s*(```|~~~)\s*(\S*)\s*$/); if (fence) { flushPara(); closeLists(0); closeQuote(); inCode = true; continue; } const heading = raw.match(/^(#{1,6})\s+(.*)$/); if (heading) { flushPara(); closeLists(0); closeQuote(); const level = heading[1].length; out.push(`${inline(heading[2].replace(/\s+#+\s*$/, ""))}`); continue; } if (/^\s*([-*_])\s*\1\s*\1[\s\-*_]*$/.test(raw)) { flushPara(); closeLists(0); closeQuote(); out.push("
"); continue; } const bq = raw.match(/^\s*>\s?(.*)$/); if (bq) { flushPara(); closeLists(0); if (!quote) { out.push("
"); quote = true; } out.push(inline(bq[1]) + "
"); continue; } const li = raw.match(/^(\s*)([-*+]|\d+[.)])\s+(.*)$/); if (li) { flushPara(); closeQuote(); const depth = Math.min(Math.floor(li[1].length / 2), 3) + 1; const kind: "ul" | "ol" = /^[-*+]$/.test(li[2]) ? "ul" : "ol"; while (listStack.length < depth) { out.push(`<${kind}>`); listStack.push(kind); } closeLists(depth); out.push(`
  • ${inline(li[3])}
  • `); continue; } if (/^\s*$/.test(raw)) { flushPara(); closeLists(0); closeQuote(); continue; } // setext-ish: plain text joins the current paragraph closeLists(0); closeQuote(); para.push(raw); } if (inCode) out.push(`
    ${esc(codeLines.join("\n"))}
    `); flushPara(); closeLists(0); closeQuote(); return out.join("\n"); }