768ee7a269
- Corrected XML tag indent stripping to only apply to top-level closing tags, preventing incorrect whitespace removal from nested tags. - Implemented depth-aware tag tracking using a stack to distinguish top-level from nested XML elements. - Updated formatting rule documentation to clarify that indent stripping applies only to top-level closing tags.
209 lines
5.9 KiB
TypeScript
209 lines
5.9 KiB
TypeScript
#!/usr/bin/env bun
|
|
/**
|
|
* Format prompt files (mixed XML + Markdown + Handlebars).
|
|
*
|
|
* Rules:
|
|
* 1. No blank line before list items
|
|
* 2. No blank line after opening XML tag or Handlebars block
|
|
* 3. No blank line before closing XML tag or Handlebars block
|
|
* 4. Strip leading whitespace from top-level closing XML tags (opened at col 0) and Handlebars (lines starting with {{)
|
|
* 5. Compact markdown tables (remove padding)
|
|
* 6. Collapse 2+ blank lines to single blank line
|
|
* 7. Trim trailing whitespace (preserve indentation)
|
|
* 8. No trailing newline at EOF
|
|
*/
|
|
import { Glob } from "bun";
|
|
|
|
const PROMPTS_DIR = new URL("../src/prompts/", import.meta.url).pathname;
|
|
const COMMIT_PROMPTS_DIR = new URL("../src/commit/prompts/", import.meta.url).pathname;
|
|
const AGENTIC_PROMPTS_DIR = new URL("../src/commit/agentic/prompts/", import.meta.url).pathname;
|
|
|
|
const PROMPT_DIRS = [PROMPTS_DIR, COMMIT_PROMPTS_DIR, AGENTIC_PROMPTS_DIR];
|
|
|
|
// Opening XML tag (not self-closing, not closing)
|
|
const OPENING_XML = /^<([a-z_-]+)(?:\s+[^>]*)?>$/;
|
|
// Closing XML tag
|
|
const CLOSING_XML = /^<\/([a-z_-]+)>$/;
|
|
// Handlebars block start: {{#if}}, {{#has}}, {{#list}}, etc.
|
|
const OPENING_HBS = /^\{\{#/;
|
|
// Handlebars block end: {{/if}}, {{/has}}, {{/list}}, etc.
|
|
const CLOSING_HBS = /^\{\{\//;
|
|
// List item (- or * or 1.)
|
|
const LIST_ITEM = /^[-*]|\d+\.\s/;
|
|
// Code fence
|
|
const CODE_FENCE = /^```/;
|
|
// Table row
|
|
const TABLE_ROW = /^\|.*\|$/;
|
|
// Table separator (|---|---|)
|
|
const TABLE_SEP = /^\|[-:\s|]+\|$/;
|
|
|
|
/** Compact a table row by trimming cell padding */
|
|
function compactTableRow(line: string): string {
|
|
// Split by |, trim each cell, rejoin
|
|
const cells = line.split("|");
|
|
return cells.map((c) => c.trim()).join("|");
|
|
}
|
|
|
|
/** Compact a table separator row */
|
|
function compactTableSep(line: string): string {
|
|
// Normalize to minimal |---|---|
|
|
const cells = line.split("|").filter((c) => c.trim());
|
|
const normalized = cells.map((c) => {
|
|
const trimmed = c.trim();
|
|
// Preserve alignment markers
|
|
const left = trimmed.startsWith(":");
|
|
const right = trimmed.endsWith(":");
|
|
if (left && right) return ":---:";
|
|
if (left) return ":---";
|
|
if (right) return "---:";
|
|
return "---";
|
|
});
|
|
return "|" + normalized.join("|") + "|";
|
|
}
|
|
|
|
function formatPrompt(content: string): string {
|
|
const lines = content.split("\n");
|
|
const result: string[] = [];
|
|
let inCodeBlock = false;
|
|
// Stack of tag names whose opening tag was at column 0 (top-level)
|
|
const topLevelTags: string[] = [];
|
|
|
|
for (let i = 0; i < lines.length; i++) {
|
|
let line = lines[i];
|
|
|
|
const trimmed = line.trim();
|
|
|
|
// Track code blocks - don't modify inside them
|
|
if (CODE_FENCE.test(trimmed)) {
|
|
inCodeBlock = !inCodeBlock;
|
|
result.push(line);
|
|
continue;
|
|
}
|
|
|
|
if (inCodeBlock) {
|
|
result.push(line);
|
|
continue;
|
|
}
|
|
|
|
// Track top-level XML opening tags for depth-aware indent stripping
|
|
const isOpeningXml =
|
|
OPENING_XML.test(trimmed) && !trimmed.endsWith("/>");
|
|
if (isOpeningXml && line.length === trimmed.length) {
|
|
// Opening tag at column 0 — track as top-level
|
|
const match = OPENING_XML.exec(trimmed);
|
|
if (match) topLevelTags.push(match[1]);
|
|
}
|
|
|
|
// Strip leading whitespace from top-level closing XML tags and Handlebars
|
|
const closingMatch = CLOSING_XML.exec(trimmed);
|
|
if (closingMatch) {
|
|
const tagName = closingMatch[1];
|
|
if (
|
|
topLevelTags.length > 0 &&
|
|
topLevelTags[topLevelTags.length - 1] === tagName
|
|
) {
|
|
// Closing tag matches a top-level opener — strip indent
|
|
line = trimmed;
|
|
topLevelTags.pop();
|
|
} else {
|
|
line = line.trimEnd();
|
|
}
|
|
} else if (trimmed.startsWith("{{")) {
|
|
line = trimmed;
|
|
} else if (TABLE_SEP.test(trimmed)) {
|
|
// Compact table separator
|
|
line = compactTableSep(trimmed);
|
|
} else if (TABLE_ROW.test(trimmed)) {
|
|
// Compact table row
|
|
line = compactTableRow(trimmed);
|
|
} else {
|
|
// Trim trailing whitespace (preserve leading for non-closing-tags)
|
|
line = line.trimEnd();
|
|
}
|
|
|
|
const isBlank = trimmed === "";
|
|
|
|
// Skip blank lines that violate our rules
|
|
if (isBlank) {
|
|
const prevLine = result[result.length - 1]?.trim() ?? "";
|
|
const nextLine = lines[i + 1]?.trim() ?? "";
|
|
|
|
// Rule 1: No blank line before list items
|
|
if (LIST_ITEM.test(nextLine)) {
|
|
continue;
|
|
}
|
|
|
|
// Rule 2: No blank after opening XML tag or Handlebars block
|
|
if (OPENING_XML.test(prevLine) || OPENING_HBS.test(prevLine)) {
|
|
continue;
|
|
}
|
|
|
|
// Rule 3: No blank before closing XML tag or Handlebars block
|
|
if (CLOSING_XML.test(nextLine) || CLOSING_HBS.test(nextLine)) {
|
|
continue;
|
|
}
|
|
|
|
// Rule 4: Collapse multiple blank lines
|
|
const prevIsBlank = prevLine === "";
|
|
if (prevIsBlank) {
|
|
continue;
|
|
}
|
|
}
|
|
|
|
// Rule 3 (cleanup): Remove trailing blanks before closing tag
|
|
if (CLOSING_XML.test(trimmed) || CLOSING_HBS.test(trimmed)) {
|
|
while (result.length > 0 && result[result.length - 1].trim() === "") {
|
|
result.pop();
|
|
}
|
|
}
|
|
|
|
result.push(line);
|
|
}
|
|
|
|
// Rule 8: No trailing newline at EOF
|
|
while (result.length > 0 && result[result.length - 1].trim() === "") {
|
|
result.pop();
|
|
}
|
|
|
|
return result.join("\n");
|
|
}
|
|
|
|
async function main() {
|
|
const glob = new Glob("**/*.md");
|
|
const files: string[] = [];
|
|
let changed = 0;
|
|
const check = process.argv.includes("--check");
|
|
|
|
for (const dir of PROMPT_DIRS) {
|
|
for await (const path of glob.scan(dir)) {
|
|
files.push(`${dir}${path}`);
|
|
}
|
|
}
|
|
|
|
for (const fullPath of files) {
|
|
const original = await Bun.file(fullPath).text();
|
|
const formatted = formatPrompt(original);
|
|
|
|
if (original !== formatted) {
|
|
if (check) {
|
|
console.log(`Would format: ${fullPath}`);
|
|
} else {
|
|
await Bun.write(fullPath, formatted);
|
|
console.log(`Formatted: ${fullPath}`);
|
|
}
|
|
changed++;
|
|
}
|
|
}
|
|
|
|
if (check && changed > 0) {
|
|
console.log(`\n${changed} file(s) need formatting. Run 'bun run format-prompts' to fix.`);
|
|
process.exit(1);
|
|
} else if (changed === 0) {
|
|
console.log("All prompt files are formatted.");
|
|
} else {
|
|
console.log(`\nFormatted ${changed} file(s).`);
|
|
}
|
|
}
|
|
|
|
main();
|