/**
* 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 `
`;
}
// 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}` : ""}`;
}
}
// 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("")}${tag}>`;
}
/**
* 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 ``;
}
/**
* 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  — 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 `
`;
}
);
// 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: 
* - 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;
}
}