import { escapeHtml, renderInline } from "../inline/render.js"; import type { BlockHandler } from "./handler.type.js"; /** Column alignment, as carried by {@link TableData.align}. */ export type Alignment = "left" | "center" | "right"; const ALIGNMENTS: readonly string[] = ["left", "center", "right"]; /** A single table cell value as carried in the fence body. */ export type TableCell = string | number | boolean | null; /** * Payload of a ```` ```table ```` fenced block. * * This is the on-the-wire shape: the fence body is exactly `JSON.stringify` of * this object. Producers should build a `TableData` and hand it to * {@link serializeTable} rather than assembling the fence by hand. * * Head strings are literal label text — no character is reserved, so a label * may begin or end with a colon. Alignment travels in {@link align}. */ export interface TableData { caption?: string; head?: string[]; /** * Per-column alignment, parallel to {@link head}. Omit it entirely for an * all-left table — missing entries are left-aligned — so the common case * costs nothing on the wire. */ align?: Alignment[]; body?: TableCell[][]; options?: Record; } /** Info string identifying a table fence. */ const TABLE_INFO = "table"; /** Matches a complete table fence, capturing the JSON body. */ const TABLE_FENCE_RE = new RegExp( `^(\`{3,})[ \\t]*${TABLE_INFO}[ \\t]*\\r?\\n([\\s\\S]*?)\\r?\\n?\\1[ \\t]*$`, ); /** * Validate an unknown value against {@link TableData}. * Returns an error message, or null when the value is a usable table. */ export function validateTableData(value: unknown): string | null { if (typeof value !== "object" || value === null || Array.isArray(value)) { return "Table body must be a JSON object"; } const data = value as TableData; if (data.head === undefined && data.body === undefined) { return 'Table requires at least "head" or "body"'; } if (data.head !== undefined && !Array.isArray(data.head)) { return 'Table "head" must be an array'; } if (data.align !== undefined) { if (!Array.isArray(data.align)) return 'Table "align" must be an array'; if (data.align.some((a) => !ALIGNMENTS.includes(a))) { return 'Table "align" entries must be "left", "center" or "right"'; } } if (data.body !== undefined) { if (!Array.isArray(data.body)) return 'Table "body" must be an array'; if (data.body.some((row) => !Array.isArray(row))) { return "Each table row must be an array"; } } return null; } /** * Wrap a JSON body in a table fence, widening the delimiter past any backtick * run in the content so a table containing a fenced sample cannot close its * own block. */ function wrapTableFence(body: string): string { let longest = 0; for (const run of body.match(/`+/g) ?? []) { longest = Math.max(longest, run.length); } const fence = "`".repeat(Math.max(3, longest + 1)); return `${fence}${TABLE_INFO}\n${body}\n${fence}`; } /** * Serialize a {@link TableData} to a complete fenced block, delimiters * included. This is the only supported way to produce a table fence. * * @example * ```ts * serializeTable({ head: ["Name", "Qty:"], body: [["Bolt", 4]] }); * ``` */ export function serializeTable(data: TableData): string { const problem = validateTableData(data); if (problem) throw new TypeError(problem); return wrapTableFence(JSON.stringify(data, null, 2)); } /** * Parse a table fence back to {@link TableData}. Accepts either a complete * fenced block or a bare JSON body, so it round-trips {@link serializeTable}. * * Throws `SyntaxError` on malformed JSON and `TypeError` on a well-formed * document with the wrong shape — useful as a guard when normalizing tables * from another markdown flavour. */ export function parseTable(fence: string): TableData { const trimmed = fence.trim(); const match = TABLE_FENCE_RE.exec(trimmed); const body = match ? match[2] : trimmed; const parsed: unknown = JSON.parse(body); const problem = validateTableData(parsed); if (problem) throw new TypeError(problem); return parsed as TableData; } function cellToString(cell: string | number | boolean | null): string { if (cell === null) return ""; return String(cell); } function alignAttr(align: Alignment | undefined): string { if (!align || align === "left") return ""; return ` class="align-${align}"`; } export const tableHandler = { render(raw: string): string { let data: TableData; try { data = JSON.parse(raw); } catch (e) { const msg = e instanceof Error ? e.message : "Invalid JSON"; return `
${escapeHtml(msg)}
`; } // Shape check before any `.map`: a non-array `head`/`body`, or a row that // is not an array, used to throw out of render() and kill the whole // document instead of producing the error box every other path returns. const problem = validateTableData(data); if (problem) { return `
${escapeHtml(problem)}
`; } const aligns = data.align ?? []; let headHtml = ""; if (data.head) { headHtml = "" + data.head .map((label, i) => `${renderInline(label)}`) .join("") + ""; } let bodyHtml = ""; if (data.body && data.body.length > 0) { bodyHtml = "" + data.body .map( (row) => "" + row .map( (cell, i) => `${renderInline(cellToString(cell))}`, ) .join("") + "", ) .join("") + ""; } const captionHtml = data.caption ? `${escapeHtml(data.caption)}` : ""; return `${captionHtml}${headHtml}${bodyHtml}
`; }, serialize(raw: string): string { // Shares the fence builder with serializeTable() so the delimiter rules // are defined in exactly one place. return wrapTableFence(raw); }, } satisfies BlockHandler;