From 1fc053ce70d18091715e25221be43f87faabe1d8 Mon Sep 17 00:00:00 2001 From: Chris Wanstrath Date: Mon, 23 Feb 2026 20:03:11 -0800 Subject: [PATCH] Add markdown table rendering with box-drawing characters and alignment support --- src/markdown.ts | 79 +++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 79 insertions(+) diff --git a/src/markdown.ts b/src/markdown.ts index 4644882..4916d36 100644 --- a/src/markdown.ts +++ b/src/markdown.ts @@ -1,3 +1,74 @@ +function stripAnsi(s: string): string { + return s.replace(/\x1b\]8;;[^\x07]*\x07/g, "").replace(/\x1b\[[0-9;]*m/g, "") +} + +function renderTable(block: string): string { + const lines = block.trim().split("\n") + if (lines.length < 2) return block + + const parseRow = (line: string): string[] => + line.replace(/^\|/, "").replace(/\|$/, "").split("|").map(c => c.trim()) + + const header = parseRow(lines[0]) + const sepCells = parseRow(lines[1]) + + // Validate separator row + if (!sepCells.every(s => /^:?-+:?$/.test(s.trim()))) return block + + const cols = header.length + const align: ("left" | "center" | "right")[] = sepCells.map(s => { + const t = s.trim() + if (t.startsWith(":") && t.endsWith(":")) return "center" + if (t.endsWith(":")) return "right" + return "left" + }) + + const rows = lines.slice(2).map(parseRow) + + // Column widths based on visible text (no ANSI codes) + const widths = new Array(cols).fill(0) + for (let c = 0; c < cols; c++) { + widths[c] = Math.max(widths[c], stripAnsi(header[c] ?? "").length) + for (const row of rows) { + widths[c] = Math.max(widths[c], stripAnsi(row[c] ?? "").length) + } + } + + const pad = (text: string, width: number, a: "left" | "center" | "right"): string => { + const needed = width - stripAnsi(text).length + if (needed <= 0) return text + if (a === "right") return " ".repeat(needed) + text + if (a === "center") { + const l = Math.floor(needed / 2) + return " ".repeat(l) + text + " ".repeat(needed - l) + } + return text + " ".repeat(needed) + } + + const D = "\x1b[2m" // dim + const R = "\x1b[22m" // reset intensity + + const renderRow = (cells: string[], bold: boolean): string => { + const parts = cells.map((c, i) => pad(c ?? "", widths[i] ?? 0, align[i] ?? "left")) + if (bold) { + return `${D}│${R} ${parts.map(p => `\x1b[1m${p}\x1b[22m`).join(` ${D}│${R} `)} ${D}│${R}` + } + return `${D}│${R} ${parts.join(` ${D}│${R} `)} ${D}│${R}` + } + + const hline = (l: string, m: string, r: string): string => { + return `${D}${l}${widths.map(w => "─".repeat(w + 2)).join(m)}${r}${R}` + } + + const out: string[] = [] + out.push(hline("┌", "┬", "┐")) + out.push(renderRow(header, true)) + out.push(hline("├", "┼", "┤")) + for (const row of rows) out.push(renderRow(row, false)) + out.push(hline("└", "┴", "┘")) + return out.join("\n") +} + export function renderMarkdown(text: string): string { // Extract fenced code blocks before anything else const codeBlocks: string[] = [] @@ -57,6 +128,14 @@ export function renderMarkdown(text: string): string { // Restore backslash escapes as literal characters result = result.replace(/\x00ESC(\d+)\x00/g, (_, i) => escapes[parseInt(i)]) + // Tables: render pipe tables with box-drawing characters + // Processed after inline formatting so cell contents are styled, + // but before code block restoration so tables inside code blocks are ignored. + result = result.replace( + /^(\|[^\n]+\|\n)(\|[\s:|-]+\|\n)((?:\|[^\n]+\|\n?)*)/gm, + (match) => renderTable(match), + ) + // Restore fenced code blocks as plain text result = result.replace(/\x00CODEBLOCK(\d+)\x00/g, (_, i) => codeBlocks[parseInt(i)])