playbook/outfitter-agents/scripts/format-markdown-tables.ts

335 lines
9.2 KiB
TypeScript
Executable File

#!/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<TableIssue[]> {
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);
});