From f56a1e9181e28153442f6a1592778b8abf22d63b Mon Sep 17 00:00:00 2001 From: Max Litruv Boonzaayer Date: Sun, 10 May 2026 19:20:27 +1000 Subject: [PATCH] feat: Add table and line break support in Markdown renderer --- website/scripts/MarkdownRenderer.js | 137 +++++++++++++++++++++++++++- 1 file changed, 136 insertions(+), 1 deletion(-) diff --git a/website/scripts/MarkdownRenderer.js b/website/scripts/MarkdownRenderer.js index bd7037d..731d3ee 100644 --- a/website/scripts/MarkdownRenderer.js +++ b/website/scripts/MarkdownRenderer.js @@ -1,7 +1,7 @@ /** * Lightweight markdown-to-HTML renderer. * Supports: headings (h1–h3), bold, italic, inline code, code blocks, - * blockquotes, unordered lists, ordered lists, horizontal rules, links, paragraphs. + * blockquotes, unordered lists, ordered lists, horizontal rules, links, tables, paragraphs. * No external dependencies. */ @@ -107,6 +107,11 @@ export class MarkdownRenderer { return `${MarkdownRenderer.#renderInline(headingMatch[2])}`; } + // Tables + if (MarkdownRenderer.#isTableBlock(block)) { + return MarkdownRenderer.#renderTable(block); + } + // Standalone image const imgBlockMatch = block.match(/^!\[([^\]]*)\]\((.+)\)$/); if (imgBlockMatch) { @@ -132,6 +137,11 @@ export class MarkdownRenderer { 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("
")}

`; @@ -188,6 +198,128 @@ export class MarkdownRenderer { 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"); + } + // ─── Inline-level ───────────────────────────────────────────────────────── /** @@ -198,6 +330,9 @@ export class MarkdownRenderer { // 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}`