#!/usr/bin/env bun /** * Format markdown tables for consistent spacing and alignment. * * Fixes: * - Separator lines: `|---|---|` -> `| --- | --- |` * - Column alignment: Pads cells to match widest content in each column * * Usage: * bun scripts/format-markdown-tables.ts [path] # Dry-run (lint mode) * bun scripts/format-markdown-tables.ts --fix [path] # Auto-fix * bun scripts/format-markdown-tables.ts --check [path] # Exit 1 if issues found * * Examples: * bun scripts/format-markdown-tables.ts # Check all .md files * bun scripts/format-markdown-tables.ts outfitter/ # Check specific dir * bun scripts/format-markdown-tables.ts --fix outfitter/ # Fix specific dir * bun scripts/format-markdown-tables.ts --fix path/to/file.md # Fix single file */ import { Glob } from "bun"; import { statSync } from "fs"; /** * A table formatting issue found in a markdown file. */ interface TableIssue { /** File path where issue was found */ file: string; /** Line number of the issue */ line: number; /** Description of the formatting issue */ message: string; /** Original line content */ before: string; /** Corrected line content */ after: string; } /** * Result of formatting tables in a file. */ interface FormatResult { /** Formatted file content */ content: string; /** Issues found and fixed */ issues: TableIssue[]; /** Whether content was modified */ changed: boolean; } // Match a table separator line (line with only |, -, :, and spaces) const SEPARATOR_PATTERN = /^\|[\s\-:|]+\|$/; // Match a table row (starts and ends with |) const TABLE_ROW_PATTERN = /^\|.+\|$/; function isTableRow(line: string): boolean { return TABLE_ROW_PATTERN.test(line.trim()); } function isSeparatorRow(line: string): boolean { return SEPARATOR_PATTERN.test(line.trim()); } function parseCells(line: string): string[] { // Remove leading/trailing pipes and split by | const trimmed = line.trim(); const inner = trimmed.slice(1, -1); // Remove first and last | return inner.split("|").map((cell) => cell.trim()); } function parseSeparatorCell(cell: string): { align: "left" | "center" | "right" | "none"; width: number } { const trimmed = cell.trim(); const leftColon = trimmed.startsWith(":"); const rightColon = trimmed.endsWith(":"); const dashes = trimmed.replace(/:/g, ""); let align: "left" | "center" | "right" | "none" = "none"; if (leftColon && rightColon) align = "center"; else if (leftColon) align = "left"; else if (rightColon) align = "right"; return { align, width: dashes.length }; } function formatSeparatorCell(align: "left" | "center" | "right" | "none", width: number): string { const dashes = "-".repeat(Math.max(width, 3)); switch (align) { case "left": return `:${dashes}`; case "right": return `${dashes}:`; case "center": return `:${dashes}:`; default: return dashes; } } function formatTable(lines: string[], startLine: number, filePath: string): { formatted: string[]; issues: TableIssue[] } { const issues: TableIssue[] = []; if (lines.length < 2) { return { formatted: lines, issues }; } // Parse all rows to find column widths const allCells: string[][] = []; let separatorIndex = -1; const separatorAligns: ("left" | "center" | "right" | "none")[] = []; for (let i = 0; i < lines.length; i++) { const line = lines[i]; if (isSeparatorRow(line)) { separatorIndex = i; const sepCells = parseCells(line); for (const cell of sepCells) { const { align } = parseSeparatorCell(cell); separatorAligns.push(align); } allCells.push(sepCells.map(() => "---")); // Placeholder } else { allCells.push(parseCells(line)); } } if (separatorIndex === -1) { return { formatted: lines, issues }; } // Calculate max width for each column const columnCount = Math.max(...allCells.map((row) => row.length)); const columnWidths: number[] = new Array(columnCount).fill(3); // Minimum 3 for --- for (const row of allCells) { for (let col = 0; col < row.length; col++) { const cell = row[col]; if (cell !== "---") { // Skip separator placeholders columnWidths[col] = Math.max(columnWidths[col], cell.length); } } } // Format each row const formatted: string[] = []; for (let i = 0; i < lines.length; i++) { const originalLine = lines[i]; let formattedLine: string; if (i === separatorIndex) { // Format separator row const sepParts: string[] = []; for (let col = 0; col < columnCount; col++) { const align = separatorAligns[col] || "none"; const sepCell = formatSeparatorCell(align, columnWidths[col]); sepParts.push(sepCell); } formattedLine = "| " + sepParts.join(" | ") + " |"; } else { // Format content row const cells = allCells[i]; const paddedCells: string[] = []; for (let col = 0; col < columnCount; col++) { const cell = cells[col] || ""; const padded = cell.padEnd(columnWidths[col]); paddedCells.push(padded); } formattedLine = "| " + paddedCells.join(" | ") + " |"; } if (formattedLine !== originalLine) { issues.push({ file: filePath, line: startLine + i, message: "Table formatting", before: originalLine, after: formattedLine, }); } formatted.push(formattedLine); } return { formatted, issues }; } function findAndFormatTables(content: string, filePath: string): FormatResult { const lines = content.split("\n"); const result: string[] = []; const allIssues: TableIssue[] = []; let i = 0; let inCodeBlock = false; while (i < lines.length) { const line = lines[i]; // Track code blocks if (line.trim().startsWith("```") || line.trim().startsWith("~~~")) { inCodeBlock = !inCodeBlock; result.push(line); i++; continue; } // Skip content inside code blocks if (inCodeBlock) { result.push(line); i++; continue; } // Check if this starts a table if (isTableRow(line)) { // Collect all consecutive table rows const tableLines: string[] = []; const tableStart = i + 1; // 1-indexed line number while (i < lines.length && isTableRow(lines[i])) { tableLines.push(lines[i]); i++; } // Format the table const { formatted, issues } = formatTable(tableLines, tableStart, filePath); result.push(...formatted); allIssues.push(...issues); } else { result.push(line); i++; } } const newContent = result.join("\n"); return { content: newContent, issues: allIssues, changed: newContent !== content, }; } async function processFile(filePath: string, fix: boolean): Promise { const content = await Bun.file(filePath).text(); const { content: formatted, issues, changed } = findAndFormatTables(content, filePath); if (fix && changed) { await Bun.write(filePath, formatted); } return issues; } async function main() { const args = process.argv.slice(2); const fix = args.includes("--fix"); const check = args.includes("--check"); const paths = args.filter((arg) => !arg.startsWith("--")); const searchPath = paths[0] || "."; // Check if searchPath is a file or directory let files: string[] = []; try { const stat = statSync(searchPath); if (stat.isFile()) { files = [searchPath]; } else { const glob = new Glob("**/*.md"); for await (const file of glob.scan({ cwd: searchPath, absolute: true, onlyFiles: true, })) { if ( file.includes("node_modules") || file.includes(".git") || file.includes(".beads") ) { continue; } files.push(file); } } } catch { console.error(`Error: Path not found: ${searchPath}`); process.exit(1); } let totalIssues = 0; let filesWithIssues = 0; for (const filePath of files) { const issues = await processFile(filePath, fix); if (issues.length > 0) { filesWithIssues++; totalIssues += issues.length; if (!fix) { const relativePath = filePath.replace(process.cwd() + "/", ""); console.log(`\n${relativePath}:`); for (const issue of issues) { console.log(` Line ${issue.line}:`); console.log(` - ${issue.before}`); console.log(` + ${issue.after}`); } } else { const relativePath = filePath.replace(process.cwd() + "/", ""); console.log(`Fixed: ${relativePath} (${issues.length} tables)`); } } } console.log(""); if (fix) { if (totalIssues > 0) { console.log(`Fixed ${totalIssues} table(s) in ${filesWithIssues} file(s)`); } else { console.log(`Checked ${files.length} file(s) - no issues found`); } } else { if (totalIssues > 0) { console.log(`Found ${totalIssues} table(s) to format in ${filesWithIssues} file(s)`); console.log("Run with --fix to auto-fix"); if (check) { process.exit(1); } } else { console.log(`Checked ${files.length} file(s) - all tables formatted correctly`); } } } main().catch((err) => { console.error(err); process.exit(1); });