/** * Lightweight markdown-to-HTML renderer. * Supports: headings (h1–h3), bold, italic, inline code, code blocks, * blockquotes, unordered lists, ordered lists, horizontal rules, links, tables, 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, preserving loose-list continuity across blank lines const blocks = MarkdownRenderer.#splitBlocks(html); 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;"` : ""; const escapedCode = MarkdownRenderer.#escape(code); 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; const pin = level === 1 ? '' : level === 2 ? '' : ''; return `${pin}${MarkdownRenderer.#renderInline(headingMatch[2])}`; } // Tables if (MarkdownRenderer.#isTableBlock(block)) { return MarkdownRenderer.#renderTable(block); } // Standalone image const imgBlockMatch = block.match(/^!\[([^\]]*)\]\((.+)\)$/); if (imgBlockMatch) { const { src, caption, imageAttributes } = MarkdownRenderer.#parseImageMeta(imgBlockMatch[1], imgBlockMatch[2]); if (src) { return `
${caption}${caption ? `
${caption}
` : ""}
`; } } // Blockquote if (/^> /.test(block)) { const inner = block.replace(/^> ?/gm, ""); return `
${MarkdownRenderer.#renderInline(inner)}
`; } // Unordered list if (/^[-*+] /.test(block)) { return MarkdownRenderer.#renderList(block, "ul"); } // Ordered list if (/^\d+\. /.test(block)) { return MarkdownRenderer.#renderList(block, "ol"); } // Standalone line break tags if (MarkdownRenderer.#isLineBreakBlock(block)) { return MarkdownRenderer.#renderLineBreakBlock(block); } // Paragraph (handle single-line line breaks within block) const lines = block.split("\n").map(l => MarkdownRenderer.#renderInline(l)); return `

${lines.join("
")}

`; } /** * Renders nested lists (ul or ol) with indentation support. * @param {string} block * @param {"ul" | "ol"} listType * @returns {string} */ static #renderList(block, listType) { const lines = block.split("\n").filter(Boolean); const items = []; let i = 0; while (i < lines.length) { const line = lines[i]; const indent = line.match(/^(\s*)/)[1].length; // Extract content let content; if (listType === "ul") { content = line.replace(/^\s*[-*+]\s+/, ""); } else { content = line.replace(/^\s*\d+\.\s+/, ""); } // Look ahead for nested items (more indented) let nestedBlock = ""; let j = i + 1; while (j < lines.length) { const nextLine = lines[j]; const nextIndent = nextLine.match(/^(\s*)/)[1].length; if (nextIndent > indent) { nestedBlock += (nestedBlock ? "\n" : "") + nextLine.slice(indent + 2); // Remove parent indent j++; } else { break; } } // Render item with nested list if present let itemHtml = MarkdownRenderer.#renderInline(content); if (nestedBlock) { const nestedType = /^[-*+] /.test(nestedBlock.trim()) ? "ul" : "ol"; itemHtml += MarkdownRenderer.#renderList(nestedBlock, nestedType); } items.push(`
  • ${itemHtml}
  • `); i = j; } const tag = listType === "ul" ? "ul" : "ol"; return `<${tag} class="md-${tag}">${items.join("")}`; } /** * Detects whether a block is a markdown pipe table. * * @param {string} block * @returns {boolean} */ static #isTableBlock(block) { const lines = block.split("\n").map(line => line.trim()).filter(Boolean); if (lines.length < 2) return false; if (!lines[0].includes("|") || !lines[1].includes("|")) return false; const separators = MarkdownRenderer.#splitTableRow(lines[1]); if (separators.length === 0) return false; return separators.every(cell => /^:?-{3,}:?$/.test(cell.trim())); } /** * Renders a markdown pipe table block. * * @param {string} block * @returns {string} */ static #renderTable(block) { const lines = block.split("\n").map(line => line.trim()).filter(Boolean); const headerCells = MarkdownRenderer.#splitTableRow(lines[0]); const separatorCells = MarkdownRenderer.#splitTableRow(lines[1]); const columnCount = Math.max(headerCells.length, separatorCells.length); const alignments = MarkdownRenderer.#parseTableAlignments(separatorCells, columnCount); const normalizedHeader = MarkdownRenderer.#normalizeTableCells(headerCells, columnCount); const thead = `${normalizedHeader .map((cell, index) => `${MarkdownRenderer.#renderInline(cell)}`) .join("")}`; const bodyRows = lines.slice(2) .map(row => MarkdownRenderer.#normalizeTableCells(MarkdownRenderer.#splitTableRow(row), columnCount)) .map(rowCells => `${rowCells .map((cell, index) => `${MarkdownRenderer.#renderInline(cell)}`) .join("")}`) .join(""); const tbody = `${bodyRows}`; return `
    ${thead}${tbody}
    `; } /** * Splits a markdown table row into cells. * * @param {string} row * @returns {string[]} */ static #splitTableRow(row) { let normalized = row.trim(); if (normalized.startsWith("|")) normalized = normalized.slice(1); if (normalized.endsWith("|")) normalized = normalized.slice(0, -1); return normalized.split("|").map(cell => cell.trim()); } /** * Normalizes row cells to a fixed column count. * * @param {string[]} cells * @param {number} columnCount * @returns {string[]} */ static #normalizeTableCells(cells, columnCount) { const normalized = cells.slice(0, columnCount); while (normalized.length < columnCount) normalized.push(""); return normalized; } /** * Parses markdown alignment hints from the table separator row. * * @param {string[]} separatorCells * @param {number} columnCount * @returns {Array<"left" | "center" | "right">} */ static #parseTableAlignments(separatorCells, columnCount) { const normalized = MarkdownRenderer.#normalizeTableCells(separatorCells, columnCount); return normalized.map(cell => { const trimmed = cell.trim(); const startsWithColon = trimmed.startsWith(":"); const endsWithColon = trimmed.endsWith(":"); if (startsWithColon && endsWithColon) return "center"; if (endsWithColon) return "right"; return "left"; }); } /** * Builds a text-align style attribute for table cells. * * @param {"left" | "center" | "right"} align * @returns {string} */ static #getTableAlignStyle(align) { return ` style="text-align: ${align};"`; } /** * Detects blocks that only contain one or more HTML line break tags. * * @param {string} block * @returns {boolean} */ static #isLineBreakBlock(block) { const lines = block.split("\n").map(line => line.trim()).filter(Boolean); if (lines.length === 0) return false; return lines.every(line => /^$/i.test(line)); } /** * Renders a standalone line-break block without paragraph wrappers. * * @param {string} block * @returns {string} */ static #renderLineBreakBlock(block) { const lineCount = block.split("\n").map(line => line.trim()).filter(Boolean).length; return Array.from({ length: lineCount }, () => "
    ").join("\n"); } /** * Splits markdown into renderable blocks while keeping loose/nested list items together. * * @param {string} markdown * @returns {string[]} */ static #splitBlocks(markdown) { const lines = markdown.split("\n"); const blocks = []; let index = 0; while (index < lines.length) { while (index < lines.length && lines[index].trim() === "") { index++; } if (index >= lines.length) { break; } const line = lines[index]; const trimmedLine = line.trim(); if (/^___CODEBLOCK_\d+___$/.test(trimmedLine)) { blocks.push(trimmedLine); index++; continue; } if (MarkdownRenderer.#isListMarkerLine(line)) { const listLines = [line]; index++; while (index < lines.length) { const current = lines[index]; if (current.trim() === "") { let lookAhead = index; while (lookAhead < lines.length && lines[lookAhead].trim() === "") { lookAhead++; } if (lookAhead >= lines.length) { index = lookAhead; break; } const next = lines[lookAhead]; if (MarkdownRenderer.#isListMarkerLine(next) || /^\s+/.test(next)) { listLines.push(...lines.slice(index, lookAhead)); index = lookAhead; continue; } break; } if (MarkdownRenderer.#isListMarkerLine(current) || /^\s+/.test(current)) { listLines.push(current); index++; continue; } break; } blocks.push(listLines.join("\n").trimEnd()); continue; } const paragraphLines = [line]; index++; while (index < lines.length && lines[index].trim() !== "") { paragraphLines.push(lines[index]); index++; } blocks.push(paragraphLines.join("\n")); } return blocks; } /** * Checks whether a line starts a markdown list item. * * @param {string} line * @returns {boolean} */ static #isListMarkerLine(line) { return /^\s*(?:[-*+]\s+|\d+\.\s+)/.test(line); } // ─── Inline-level ───────────────────────────────────────────────────────── /** * @param {string} text * @returns {string} */ static #renderInline(text) { // Escape HTML first, then re-apply formatting let out = MarkdownRenderer.#escape(text); // Allow explicit line break tags in markdown text only. out = out.replace(/<br\s*\/?>/gi, "
    "); // 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"); // Images ![alt](url) — must be matched before links out = out.replace( /!\[([^\]]*)\]\((.+?)\)/g, (_, altText, rawMeta) => { const { src, caption, imageAttributes } = MarkdownRenderer.#parseImageMeta(altText, rawMeta); if (!src) return MarkdownRenderer.#escape(altText); return `${caption}`; } ); // 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(); // Allow absolute URLs, root-relative, hash links, relative-dot paths, and plain relative paths if (/^(https?:\/\/|mailto:|\/|#|\.)/.test(trimmed)) return trimmed; // Allow relative paths (e.g. data/blog/media/image.png) — no protocol = safe relative if (/^[a-zA-Z0-9_\-][a-zA-Z0-9_.\-\/]*$/.test(trimmed)) return trimmed; return null; } /** * Applies intrinsic pixel scaling to markdown images with a size token. * * @param {ParentNode} container * @returns {void} */ static applyImageScale(container) { container.querySelectorAll("img[data-md-scale]").forEach(img => { const scale = parseFloat(img.getAttribute("data-md-scale") || ""); if (!Number.isFinite(scale) || scale <= 0) return; const applyScale = () => { if (!img.naturalWidth) return; const targetWidthPx = Math.max(1, Math.round(img.naturalWidth * scale)); img.style.width = `${targetWidthPx}px`; img.style.height = "auto"; img.style.maxWidth = "100%"; }; if (img.complete) { applyScale(); return; } img.addEventListener("load", applyScale, { once: true }); }); } /** * Parses markdown image metadata including optional title and size token. * * Supported format: ![alt](url "title") * - Numeric alt-only values (e.g. 0.50) are treated as intrinsic size scale metadata. * - Title text is used as the visible caption when present. * * @param {string} rawAlt * @param {string} rawMeta * @returns {{ src: string | null, caption: string, imageAttributes: string }} */ static #parseImageMeta(rawAlt, rawMeta) { const meta = rawMeta.trim(); const titleMatch = meta.match(/^(\S+)(?:\s+"([\s\S]*?)")?$/); if (!titleMatch) { return { src: null, caption: MarkdownRenderer.#escape(rawAlt), imageAttributes: "", }; } const src = MarkdownRenderer.#sanitizeUrl(titleMatch[1]); const titleCaption = titleMatch[2] ? MarkdownRenderer.#escape(titleMatch[2]) : ""; const trimmedAlt = rawAlt.trim(); const sizeScale = MarkdownRenderer.#parseImageSizeScale(trimmedAlt); const caption = titleCaption || (sizeScale === null ? MarkdownRenderer.#escape(trimmedAlt) : ""); const imageAttributes = sizeScale !== null ? ` data-md-scale="${sizeScale}"` : ""; return { src, caption, imageAttributes, }; } /** * Parses a numeric image scale token from markdown alt text. * * @param {string} rawAlt * @returns {number | null} */ static #parseImageSizeScale(rawAlt) { if (!/^\d*\.?\d+$/.test(rawAlt)) return null; const value = parseFloat(rawAlt); if (!Number.isFinite(value)) return null; if (value <= 0 || value > 1) return null; return value; } }