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;
});
// 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(`${listStack.pop()}>`); }; 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(`"); 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(``); flushPara(); closeLists(0); closeQuote(); return out.join("\n"); }${esc(codeLines.join("\n"))}