/** * Utilities for parsing, serializing, and manipulating markdown tables. */ import { fromMarkdown } from "mdast-util-from-markdown"; import { gfmFromMarkdown } from "mdast-util-gfm"; import { gfm } from "micromark-extension-gfm"; import { visit } from "unist-util-visit"; export interface TableData { headers: string[]; rows: string[][]; /** Column alignments: "left" | "center" | "right" | "none". */ alignments: ColumnAlignment[]; } export type ColumnAlignment = "left" | "center" | "right" | "none"; // --------------------------------------------------------------------------- // Parsing // --------------------------------------------------------------------------- /** * Parse a markdown table string into structured TableData. * * Expects a standard GFM table: * | Header1 | Header2 | * | ------- | ------- | * | cell | cell | */ export function parseMarkdownTable(md: string): TableData | null { const lines = md .trim() .split("\n") .map((l) => l.trim()) .filter((l) => l.length > 0); if (lines.length < 2) return null; const parseRow = (line: string): string[] => { // Strip leading/trailing pipes and split by pipes preceded by an even number // of backslashes (0, 2, 4, …). A pipe preceded by an odd number of // backslashes is an escaped pipe and must not be treated as a column // separator. The simpler regex (?= 0 && trimmed[j] === "\\") { backslashes++; j--; } if (backslashes % 2 === 0) { cells.push(trimmed.slice(cellStart, i).trim().replace(/\\\|/g, "|")); cellStart = i + 1; } } } cells.push(trimmed.slice(cellStart).trim().replace(/\\\|/g, "|")); return cells; }; const headers = parseRow(lines[0]); // Parse the separator line for alignments. const sepCells = parseRow(lines[1]); const isSeparator = sepCells.every((cell) => /^:?-+:?$/.test(cell.trim())); if (!isSeparator) return null; const alignments: ColumnAlignment[] = sepCells.map((cell) => { const c = cell.trim(); const left = c.startsWith(":"); const right = c.endsWith(":"); if (left && right) return "center"; if (right) return "right"; if (left) return "left"; return "none"; }); const rows: string[][] = []; for (let i = 2; i < lines.length; i++) { const cells = parseRow(lines[i]); // Pad or trim to match header count. while (cells.length < headers.length) cells.push(""); if (cells.length > headers.length) cells.length = headers.length; rows.push(cells); } return { headers, rows, alignments }; } // --------------------------------------------------------------------------- // Serialization // --------------------------------------------------------------------------- /** * Serialize TableData into a properly-aligned markdown table string. */ export function serializeMarkdownTable(data: TableData): string { const { headers, rows, alignments } = data; const colCount = headers.length; const escapeCell = (text: string): string => { let result = ""; for (let i = 0; i < text.length; i++) { if (text[i] === "|") { let backslashes = 0; let j = i - 1; while (j >= 0 && text[j] === "\\") { backslashes++; j--; } if (backslashes % 2 === 0) result += "\\"; } result += text[i]; } return result; }; // Calculate maximum width per column (minimum 3 for the separator). const widths: number[] = []; for (let c = 0; c < colCount; c++) { let max = Math.max(3, escapeCell(headers[c]).length); for (const row of rows) { max = Math.max(max, escapeCell(row[c] || "").length); } widths.push(max); } const padCell = (text: string, width: number, align: ColumnAlignment): string => { const t = text || ""; const padding = width - t.length; if (padding <= 0) return t; if (align === "right") return " ".repeat(padding) + t; if (align === "center") { const left = Math.floor(padding / 2); const right = padding - left; return " ".repeat(left) + t + " ".repeat(right); } return t + " ".repeat(padding); }; const formatRow = (cells: string[]): string => { const formatted = cells.map((cell, i) => { const align = alignments[i] || "none"; return padCell(escapeCell(cell), widths[i], align); }); return "| " + formatted.join(" | ") + " |"; }; const separator = widths.map((w, i) => { const align = alignments[i] || "none"; const dashes = "-".repeat(w); if (align === "center") return ":" + dashes.slice(1, -1) + ":"; if (align === "right") return dashes.slice(0, -1) + ":"; if (align === "left") return ":" + dashes.slice(1); return dashes; }); const separatorLine = "| " + separator.join(" | ") + " |"; const headerLine = formatRow(headers); const rowLines = rows.map((row) => formatRow(row)); return [headerLine, separatorLine, ...rowLines].join("\n"); } // --------------------------------------------------------------------------- // Find & Replace // --------------------------------------------------------------------------- export interface TableMatch { /** The raw markdown of the table. */ text: string; /** Start index in the source string. */ start: number; /** End index (exclusive) in the source string. */ end: number; } /** * Find all markdown table blocks in a content string. * * Uses a GFM-aware markdown AST parser so that tables without leading/trailing * pipes (e.g. `A | B\n--- | ---\n1 | 2`) are recognised in addition to * fully-fenced `| … |` tables. */ export function findAllTables(content: string): TableMatch[] { const tree = fromMarkdown(content, { extensions: [gfm()], mdastExtensions: [gfmFromMarkdown()], }); const tables: TableMatch[] = []; visit(tree, "table", (node) => { if (!node.position) return; const start = node.position.start.offset ?? 0; const end = node.position.end.offset ?? content.length; tables.push({ text: content.slice(start, end), start, end }); }); return tables; } /** * Replace the nth table in the content with new markdown. */ export function replaceNthTable(content: string, tableIndex: number, newTableMarkdown: string): string { const tables = findAllTables(content); if (tableIndex < 0 || tableIndex >= tables.length) return content; const table = tables[tableIndex]; return content.slice(0, table.start) + newTableMarkdown + content.slice(table.end); } // --------------------------------------------------------------------------- // Default empty table // --------------------------------------------------------------------------- /** * Create a default empty table with the given dimensions. */ export function createEmptyTable(cols = 2, rows = 2): TableData { return { headers: Array.from({ length: cols }, (_, i) => `Header ${i + 1}`), rows: Array.from({ length: rows }, () => Array.from({ length: cols }, () => "")), alignments: Array.from({ length: cols }, () => "none"), }; }