/** * 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"); // Extract and protect code blocks first (prevents splitting on blank lines inside code) const codeBlocks = []; html = html.replace(/```[\s\S]*?```/g, (match) => { const placeholder = `\n___CODEBLOCK_${codeBlocks.length}___\n`; codeBlocks.push(match); return placeholder; }); // Split into blocks separated by blank lines const blocks = html.split(/\n{2,}/); const parts = blocks.map(block => { block = block.trim(); // Restore code blocks block = block.replace(/___CODEBLOCK_(\d+)___/g, (_, idx) => codeBlocks[parseInt(idx)]); return MarkdownRenderer.#renderBlock(block); }); 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*)(?:\s*\{([^}]+)\})?\s*\n([\s\S]*?)\n?```$/); if (fenceMatch) { const lang = fenceMatch[1] || ""; const options = fenceMatch[2] || ""; const code = fenceMatch[3]; // Parse options (e.g., {maxHeight: 300}) let maxHeight = null; if (options) { const maxHeightMatch = options.match(/maxHeight\s*:\s*(\d+)/); if (maxHeightMatch) { maxHeight = maxHeightMatch[1]; } } // Use Prism for syntax highlighting if available and language is specified let highlightedCode = code; if (lang && typeof Prism !== 'undefined' && Prism.languages[lang]) { highlightedCode = Prism.highlight(code, Prism.languages[lang], lang); } else { highlightedCode = MarkdownRenderer.#escape(code); } const langClass = lang ? ` language-${lang}` : ""; const styleAttr = maxHeight ? ` style="max-height: ${maxHeight}px; overflow-y: auto;"` : ""; return `
${highlightedCode}
`; } // Horizontal rule if (/^[-*_]{3,}$/.test(block)) { return `
`; } // Headings const headingMatch = block.match(/^(#{1,6})\s+(.+)$/m); if (headingMatch && block.split("\n").length === 1) { const level = headingMatch[1].length; return `${MarkdownRenderer.#renderInline(headingMatch[2])}`; } // Blockquote if (/^> /.test(block)) { const inner = block.replace(/^> ?/gm, ""); return `
${MarkdownRenderer.#renderInline(inner)}
`; } // Unordered list if (/^[-*+] /.test(block)) { const items = block.split("\n").filter(Boolean).map(line => { const content = line.replace(/^[-*+]\s+/, ""); return `
  • ${MarkdownRenderer.#renderInline(content)}
  • `; }); return ``; } // Ordered list if (/^\d+\. /.test(block)) { const items = block.split("\n").filter(Boolean).map(line => { const content = line.replace(/^\d+\.\s+/, ""); return `
  • ${MarkdownRenderer.#renderInline(content)}
  • `; }); return `
      ${items.join("")}
    `; } // Paragraph (handle single-line line breaks within block) const lines = block.split("\n").map(l => MarkdownRenderer.#renderInline(l)); return `

    ${lines.join("
    ")}

    `; } // ─── Inline-level ───────────────────────────────────────────────────────── /** * @param {string} text * @returns {string} */ static #renderInline(text) { // Escape HTML first, then re-apply formatting let out = MarkdownRenderer.#escape(text); // Inline code (must come before bold/italic to avoid breaking backtick content) out = out.replace(/`([^`]+)`/g, (_, code) => `${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; } }