/** * Lightweight markdown-to-HTML renderer. * Supports: headings (h1–h3), bold, italic, inline code, code blocks, * blockquotes, unordered lists, ordered lists, horizontal rules, links, paragraphs. * No external dependencies. */ /** @type {Array<{ pattern: RegExp, svg: string }>} */ const LINK_ICONS = [ { pattern: /youtube\.com|youtu\.be/, svg: ``, }, { pattern: /github\.com/, svg: ``, }, { pattern: /bsky\.app/, svg: ``, }, { pattern: /matrix\.to|matrix\.org/, svg: ``, }, ]; export class MarkdownRenderer { /** * Converts a markdown string to sanitized HTML. * * @param {string} markdown - Raw markdown text. * @returns {string} HTML string safe for innerHTML insertion. */ static render(markdown) { let html = markdown // Normalize line endings .replace(/\r\n/g, "\n") .replace(/\r/g, "\n"); // Split into blocks separated by blank lines const blocks = html.split(/\n{2,}/); const parts = blocks.map(block => MarkdownRenderer.#renderBlock(block.trim())); return parts.filter(Boolean).join("\n"); } // ─── Block-level ────────────────────────────────────────────────────────── /** * @param {string} block * @returns {string} */ static #renderBlock(block) { if (!block) return ""; // Fenced code block const fenceMatch = block.match(/^```(\w*)\n([\s\S]*?)```$/); if (fenceMatch) { const lang = MarkdownRenderer.#escape(fenceMatch[1] || ""); const code = MarkdownRenderer.#escape(fenceMatch[2]); return `
${code}`;
}
// Horizontal rule
if (/^[-*_]{3,}$/.test(block)) {
return `${MarkdownRenderer.#renderInline(inner)}`; } // Unordered list if (/^[-*+] /.test(block)) { const items = block.split("\n").filter(Boolean).map(line => { const content = line.replace(/^[-*+]\s+/, ""); return `
${lines.join("
")}
${code}`
);
// Bold + italic
out = out.replace(/\*\*\*(.+?)\*\*\*/g, "$1");
// Bold
out = out.replace(/\*\*(.+?)\*\*/g, "$1");
// Italic
out = out.replace(/\*(.+?)\*/g, "$1");
// Links [text](url)
out = out.replace(
/\[([^\]]+)\]\(([^)]+)\)/g,
(_, linkText, url) => {
const safeUrl = MarkdownRenderer.#sanitizeUrl(url);
if (!safeUrl) return linkText;
const iconEntry = LINK_ICONS.find(e => e.pattern.test(safeUrl));
const prefix = iconEntry ? iconEntry.svg : "";
const pipeIdx = linkText.indexOf("|");
const inner = pipeIdx !== -1
? `${linkText.slice(0, pipeIdx)}${linkText.slice(pipeIdx + 1)}`
: linkText;
return `${prefix}${inner}`;
}
);
return out;
}
// ─── Utilities ────────────────────────────────────────────────────────────
/**
* Escapes HTML special characters.
*
* @param {string} str
* @returns {string}
*/
static #escape(str) {
return str
.replace(/&/g, "&")
.replace(//g, ">")
.replace(/"/g, """)
.replace(/'/g, "'");
}
/**
* Returns a URL only if it uses a safe scheme; otherwise null.
*
* @param {string} url
* @returns {string | null}
*/
static #sanitizeUrl(url) {
const trimmed = url.trim();
if (/^(https?:\/\/|mailto:|\/|#|\.)/.test(trimmed)) return trimmed;
return null;
}
}