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

235 lines
6.3 KiB
TypeScript
Executable File

#!/usr/bin/env bun
/**
* Unified markdown formatter for Claude Code files.
*
* Runs formatting tools in sequence:
* 1. format-markdown-tables.ts - Table alignment and separator spacing
* 2. Custom fixes (XML tag spacing, etc.)
* 3. markdownlint-cli2 --fix - Standard markdown linting
*
* Usage:
* bun scripts/format-markdown.ts [path] # Format files
* bun scripts/format-markdown.ts --check [path] # Check only (exit 1 if issues)
* bun scripts/format-markdown.ts --dry-run [path] # Show what would change
*
* Examples:
* bun scripts/format-markdown.ts # Format all .md files
* bun scripts/format-markdown.ts outfitter/ # Format specific dir
* bun scripts/format-markdown.ts path/to/file.md # Format single file
* bun scripts/format-markdown.ts --check # CI mode
*/
import { spawn } from "bun";
import { statSync, existsSync } from "fs";
import { dirname, join, resolve } from "path";
const SCRIPTS_DIR = dirname(new URL(import.meta.url).pathname);
/**
* Result of a single formatting step.
*/
interface FormatResult {
/** Name of the formatting step */
step: string;
/** Whether the step completed without errors */
success: boolean;
/** Output from the formatting tool */
output: string;
/** Whether files were modified */
changed: boolean;
}
async function runCommand(
cmd: string[],
description: string
): Promise<FormatResult> {
const proc = spawn({
cmd,
stdout: "pipe",
stderr: "pipe",
});
const stdout = await new Response(proc.stdout).text();
const stderr = await new Response(proc.stderr).text();
const exitCode = await proc.exited;
const output = stdout + stderr;
const changed = output.includes("Fixed") || output.includes("fixed");
return {
step: description,
success: exitCode === 0,
output: output.trim(),
changed,
};
}
async function checkToolAvailable(tool: string): Promise<boolean> {
try {
const proc = spawn({
cmd: ["which", tool],
stdout: "pipe",
stderr: "pipe",
});
await proc.exited;
return proc.exitCode === 0;
} catch {
return false;
}
}
async function formatTables(
path: string,
dryRun: boolean
): Promise<FormatResult> {
const script = join(SCRIPTS_DIR, "format-markdown-tables.ts");
const args = ["bun", script];
if (!dryRun) {
args.push("--fix");
}
args.push(path);
return runCommand(args, "Table formatting");
}
async function formatXmlTags(
path: string,
dryRun: boolean
): Promise<FormatResult> {
const script = join(SCRIPTS_DIR, "lint-xml-tags.ts");
if (!existsSync(script)) {
return {
step: "XML tag formatting",
success: true,
output: "Skipped (script not found)",
changed: false,
};
}
const args = ["bun", script];
if (!dryRun) {
args.push("--fix");
}
args.push(path);
return runCommand(args, "XML tag formatting");
}
async function runMarkdownlint(
path: string,
dryRun: boolean,
isFile: boolean
): Promise<FormatResult> {
// Check if markdownlint-cli2 is available
const hasMarkdownlint = await checkToolAvailable("markdownlint-cli2");
if (!hasMarkdownlint) {
return {
step: "markdownlint-cli2",
success: true,
output: "Skipped (markdownlint-cli2 not installed)\nInstall: bun add -g markdownlint-cli2",
changed: false,
};
}
const args = ["markdownlint-cli2"];
if (!dryRun) {
args.push("--fix");
}
// Always ignore config globs - we specify our own targets
args.push("--no-globs");
// Handle path - literal file (: prefix) or glob pattern for directories
if (isFile) {
args.push(`:${path}`); // : prefix = literal file path
} else {
args.push(join(path, "**/*.md"));
}
return runCommand(args, "markdownlint-cli2");
}
function printResult(result: FormatResult, verbose: boolean) {
const icon = result.success ? "✓" : "✗";
const color = result.success ? "\x1b[32m" : "\x1b[31m";
const reset = "\x1b[0m";
console.log(`${color}${icon}${reset} ${result.step}`);
if (verbose || !result.success) {
if (result.output) {
const indented = result.output
.split("\n")
.map((line) => ` ${line}`)
.join("\n");
console.log(indented);
}
} else if (result.changed) {
console.log(" Files formatted");
}
}
async function main() {
const args = process.argv.slice(2);
const check = args.includes("--check");
const dryRun = args.includes("--dry-run") || check;
const verbose = args.includes("--verbose") || args.includes("-v");
const paths = args.filter((arg) => !arg.startsWith("-"));
const targetPath = resolve(paths[0] || ".");
// Detect if target is a file or directory
let isFile = false;
try {
const stat = statSync(targetPath);
isFile = stat.isFile();
} catch {
console.error(`Error: Path not found: ${targetPath}`);
process.exit(1);
}
const mode = check ? "check" : dryRun ? "dry-run" : "format";
console.log(`\x1b[34mMarkdown Formatter\x1b[0m (${mode} mode)`);
console.log(`Target: ${targetPath}\n`);
const results: FormatResult[] = [];
// Step 1: Format tables
console.log("Running formatters...\n");
results.push(await formatTables(targetPath, dryRun));
printResult(results[results.length - 1], verbose);
// Step 2: Format XML tags
results.push(await formatXmlTags(targetPath, dryRun));
printResult(results[results.length - 1], verbose);
// Step 3: Run markdownlint
results.push(await runMarkdownlint(targetPath, dryRun, isFile));
printResult(results[results.length - 1], verbose);
// Summary
console.log("");
const failures = results.filter((r) => !r.success);
const changes = results.filter((r) => r.changed);
if (failures.length > 0) {
console.log(`\x1b[31m✗ ${failures.length} step(s) failed\x1b[0m`);
process.exit(1);
} else if (check && changes.length > 0) {
console.log(`\x1b[33m⚠ ${changes.length} step(s) would make changes\x1b[0m`);
console.log("Run without --check to apply fixes");
process.exit(1);
} else if (changes.length > 0) {
console.log(`\x1b[32m✓ Formatting complete (${changes.length} step(s) made changes)\x1b[0m`);
} else {
console.log("\x1b[32m✓ All files formatted correctly\x1b[0m");
}
}
main().catch((err) => {
console.error(err);
process.exit(1);
});