#!/usr/bin/env node import { execFileSync } from "node:child_process"; import { copyFileSync, existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync, } from "node:fs"; import { tmpdir } from "node:os"; import { dirname, extname, join, relative } from "node:path"; import { createInterface } from "node:readline/promises"; import { pathToFileURL } from "node:url"; import { transform } from "esbuild"; const ROOT = process.cwd(); const DRY = process.argv.includes("--dry-run"); const MSG_ARG = process.argv[2] && !process.argv[2].startsWith("--") ? process.argv[2] : undefined; const SKIP_DIRS = new Set([".git", "node_modules", "dist", ".astro", ".wrangler", "public", ".vscode"]); const SKIP_FILES = new Set([".dev.vars", ".dev.vars.example", "package-lock.json"]); const PROCESS_EXT = new Set([ ".ts", ".mts", ".cts", ".js", ".mjs", ".cjs", ".jsx", ".tsx", ".astro", ".jsonc", ".css", ]); const LOADER_BY_EXT = { ".ts": "ts", ".mts": "ts", ".cts": "ts", ".js": "js", ".mjs": "js", ".cjs": "js", ".jsx": "jsx", ".tsx": "tsx", }; const KW_REGEX = new Set([ "return", "typeof", "instanceof", "in", "of", "yield", "await", "case", "delete", "void", "throw", "new", "do", "else", ]); let backup = null; let changed = []; function restore() { if (DRY || !backup) return; for (const rel of changed) { const b = join(backup, rel); if (existsSync(b)) copyFileSync(b, join(ROOT, rel)); } } process.on("SIGINT", () => { console.error("\ninterrupted — restoring comments"); restore(); process.exit(130); }); function stripJs(code, { lineComments = true, stats } = {}) { const out = []; const n = code.length; let i = 0; let state = "code"; const frames = []; let braceDepth = 0; let inClass = false; let lastSig = ""; let lastWord = ""; const emit = (s) => { for (const ch of s) { out.push(ch); if (/\s/.test(ch)) continue; lastSig = ch; if (/[A-Za-z0-9_$]/.test(ch)) lastWord += ch; else lastWord = ""; } }; while (i < n) { const c = code[i]; const nx = code[i + 1]; switch (state) { case "code": if (c === "/" && nx === "/" && lineComments) { if (stats) stats.removed++; state = "line"; i += 2; } else if (c === "/" && nx === "*") { if (stats) stats.removed++; state = "block"; i += 2; } else if (c === "'") { state = "sq"; emit(c); i++; } else if (c === '"') { state = "dq"; emit(c); i++; } else if (c === "`") { frames.push({ kind: "tpl", prev: state }); state = "tpl"; emit(c); i++; } else if (c === "/") { const isRegex = lastSig === "" || "([{=,:;!&|?".includes(lastSig) || (/[A-Za-z0-9_$]/.test(lastSig) && KW_REGEX.has(lastWord)); state = isRegex ? "regex" : "code"; if (isRegex) inClass = false; emit(c); i++; } else if (c === "}") { const top = frames[frames.length - 1]; if (top && top.kind === "interp" && braceDepth === top.depth) { frames.pop(); state = "tpl"; } else if (braceDepth > 0) { braceDepth--; } emit(c); i++; } else { if (c === "{") braceDepth++; emit(c); i++; } break; case "line": if (c === "\r" || c === "\n") { if (c === "\r" && nx === "\n") { emit("\r\n"); i += 2; } else { emit(c); i++; } state = "code"; } else { i++; } break; case "block": if (c === "*" && nx === "/") { emit(" "); i += 2; state = "code"; } else { i++; } break; case "sq": case "dq": emit(c); if (c === "\\" && nx) { emit(nx); i += 2; } else { if ((state === "sq" && c === "'") || (state === "dq" && c === '"')) state = "code"; i++; } break; case "tpl": emit(c); if (c === "\\" && nx) { emit(nx); i += 2; } else if (c === "`") { frames.pop(); state = "code"; i++; } else if (c === "$" && nx === "{") { frames.push({ kind: "interp", depth: braceDepth }); emit("{"); state = "code"; i += 2; } else { i++; } break; case "regex": emit(c); if (c === "\\" && nx) { emit(nx); i += 2; } else if (c === "[") { inClass = true; i++; } else if (c === "]") { inClass = false; i++; } else if (c === "/" && !inClass) { state = "code"; i++; } else { i++; } break; } } return out.join(""); } export { stripJs, stripCss, stripAstro }; function stripCss(code, stats) { return stripJs(code, { lineComments: false, stats }); } function stripAstro(code, stats, parts) { let rest = code; let head = ""; const open = /^---\r?\n/.exec(code); if (open) { const m = /^---\r?\n([\s\S]*?)\r?\n---(\r?\n|$)/.exec(code); if (m) { const fm = stripJs(m[1], { stats }); parts.push({ code: fm, loader: "ts", label: "frontmatter" }); head = "---\n" + fm + "\n---" + (m[2] === "\r\n" ? "\r\n" : "\n"); rest = code.slice(m[0].length); } } let i = 0; const n = rest.length; while (i < n) { if (rest.startsWith("", i); const close = rest.indexOf("", tagEnd + 1); if (tagEnd === -1 || close === -1) { head += rest.slice(i); break; } const inner = stripJs(rest.slice(tagEnd + 1, close), { stats }); parts.push({ code: inner, loader: "ts", label: "".length); i = close + "".length; } else if (rest.startsWith("", i); const close = rest.indexOf("", tagEnd + 1); if (tagEnd === -1 || close === -1) { head += rest.slice(i); break; } const inner = stripCss(rest.slice(tagEnd + 1, close), stats); head += rest.slice(i, tagEnd + 1) + inner + rest.slice(close, close + "".length); i = close + "".length; } else if (rest.startsWith("", i + 4); if (end === -1) { i = n; break; } if (stats) stats.removed++; i = end + 3; } else if (rest.startsWith("{/*", i)) { const end = rest.indexOf("*/}", i + 3); if (end === -1) { i = n; break; } if (stats) stats.removed++; i = end + 3; } else { head += rest[i]; i++; } } return head; } function collect() { const out = []; const walk = (dir) => { for (const ent of readdirSync(dir, { withFileTypes: true })) { const p = join(dir, ent.name); if (ent.isDirectory()) { if (SKIP_DIRS.has(ent.name)) continue; walk(p); } else if (ent.isFile()) { if (SKIP_FILES.has(ent.name)) continue; const rel = relative(ROOT, p); if (PROCESS_EXT.has(extname(p))) out.push(rel); } } }; walk(ROOT); return out.sort(); } function stripFile(rel) { const stats = { removed: 0 }; const src = readFileSync(join(ROOT, rel), "utf8"); const ext = extname(rel); const parts = []; let out; if (ext === ".astro") { out = stripAstro(src, stats, parts); } else if (ext === ".css") { out = stripCss(src, stats); } else { out = stripJs(src, { stats }); if (LOADER_BY_EXT[ext]) parts.push({ code: out, loader: LOADER_BY_EXT[ext], label: "file" }); } return { rel, src, out, stats, parts, changed: src !== out }; } async function validate(r) { const problems = []; for (const { code, loader, label } of r.parts) { try { await transform(code, { loader }); } catch (e) { problems.push({ fatal: true, msg: `${r.rel} (${label}): ${e.errors?.[0]?.text ?? e.message}` }); } } if (extname(r.rel) === ".jsonc") { try { JSON.parse(r.out); } catch { problems.push({ fatal: false, msg: `${r.rel}: no longer strict JSON after strip (ok if it's jsonc with trailing commas)` }); } } return problems; } async function main() { if (DRY) { console.log("dry run — stripping in memory, touching nothing\n"); } else { try { execFileSync("git", ["rev-parse", "--is-inside-work-tree"], { stdio: "pipe" }); } catch { console.error("not a git repository — `git init` + `git remote add origin …` first, then run again"); process.exit(1); } const remotes = String(execFileSync("git", ["remote"], { stdio: "pipe" })).trim(); if (!remotes) { console.error("no git remote configured — add one (`git remote add origin …`) so the push has somewhere to go"); process.exit(1); } backup = mkdtempSync(join(tmpdir(), "commit-clean-")); } let stripped = 0; const problems = []; for (const rel of collect()) { const r = stripFile(rel); problems.push(...(await validate(r))); if (!r.changed) continue; stripped += r.stats.removed; changed.push(rel); if (DRY) { console.log(` ${rel} −${r.stats.removed} comment${r.stats.removed === 1 ? "" : "s"}`); } else { const dest = join(backup, rel); mkdirSync(dirname(dest), { recursive: true }); copyFileSync(join(ROOT, rel), dest); writeFileSync(join(ROOT, rel), r.out); } } const fatal = problems.filter((p) => p.fatal); if (fatal.length) { console.error("\nvalidation failed — aborting, nothing was committed:"); for (const p of fatal) console.error(" " + p.msg); restore(); process.exit(1); } for (const p of problems) console.warn("warn: " + p.msg); if (!changed.length) { console.log("no comments found — nothing to strip or commit"); return; } console.log(`\n${changed.length} file${changed.length === 1 ? "" : "s"}, ${stripped} comment${stripped === 1 ? "" : "s"} stripped`); if (DRY) return; try { execFileSync("git", ["add", "-A"], { stdio: "inherit" }); let hasChanges = true; try { execFileSync("git", ["diff", "--cached", "--quiet"], { stdio: "pipe" }); hasChanges = false; } catch {} if (!hasChanges) { restore(); console.log("stripped snapshot already matches the last commit — nothing new to commit; local comments restored"); return; } const stat = String(execFileSync("git", ["diff", "--cached", "--stat"], { stdio: "pipe" })); const branch = String(execFileSync("git", ["branch", "--show-current"], { stdio: "pipe" })).trim(); console.log(`\nstaged for commit on ${branch || "(detached HEAD)"}:\n${stat}`); let message = MSG_ARG; if (!message) { const rl = createInterface({ input: process.stdin, output: process.stdout }); message = (await rl.question("Commit message: ")).trim(); rl.close(); } if (!message) { console.error("empty commit message — aborting"); throw new Error("abort"); } execFileSync("git", ["commit", "-m", message], { stdio: "inherit" }); try { execFileSync("git", ["push"], { stdio: "inherit" }); } catch { console.error("\npush failed — commit is local. if this branch has no upstream yet:\n git push -u origin HEAD"); throw new Error("push"); } console.log("\ncommitted (comment-stripped) and pushed ✓"); } finally { restore(); console.log("local comments restored ✓"); } console.log("note: the committed copy has no comments; your working tree keeps them, so git will show\n those files as modified until the next code change"); if (backup) rmSync(backup, { recursive: true, force: true }); } if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { main().catch((e) => { restore(); if (e.message !== "abort" && e.message !== "push") console.error(e.message); if (backup) console.error(`originals backed up at ${backup} — restore with:\n cp -r ${backup}/. .`); process.exit(1); }); }