#!/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 = "";
let lineStart = 0;
let lineContentEnd = 0;
let skipTerminator = false;
const truncateTo = (idx) => {
out.length = idx;
lastSig = "";
lastWord = "";
for (let j = idx - 1; j >= 0; j--) {
const ch = out[j];
if (/\s/.test(ch)) continue;
lastSig = ch;
let k = j;
while (k >= 0 && /[A-Za-z0-9_$]/.test(out[k])) k--;
lastWord = out.slice(k + 1, j + 1).join("");
break;
}
};
const emit = (s) => {
for (const ch of s) {
out.push(ch);
if (ch === "\n") {
lineStart = lineContentEnd = out.length;
} else if (/\s/.test(ch)) {
} else {
lastSig = ch;
if (/[A-Za-z0-9_$]/.test(ch)) lastWord += ch;
else lastWord = "";
lineContentEnd = out.length;
}
}
};
const nextOnLine = (idx) => {
while (idx < n && (code[idx] === " " || code[idx] === "\t" || code[idx] === "\r")) idx++;
if (idx >= n || code[idx] === "\n") return "";
return code[idx];
};
while (i < n) {
const c = code[i];
const nx = code[i + 1];
switch (state) {
case "code":
if (skipTerminator && (c === "\r" || c === "\n")) {
skipTerminator = false;
if (c === "\r" && nx === "\n") i += 2;
else i++;
break;
}
if (c === "/" && nx === "/" && lineComments) {
if (stats) stats.removed++;
if (lineContentEnd === lineStart) {
truncateTo(lineStart);
skipTerminator = true;
} else {
truncateTo(lineContentEnd);
}
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 (skipTerminator) {
skipTerminator = false;
if (c === "\r" && nx === "\n") i += 2;
else i++;
} else if (c === "\r" && nx === "\n") {
emit("\r\n");
i += 2;
} else {
emit(c);
i++;
}
state = "code";
} else {
i++;
}
break;
case "block":
if (c === "*" && nx === "/") {
i += 2;
state = "code";
if (lineContentEnd === lineStart) {
truncateTo(lineStart);
skipTerminator = nextOnLine(i) === "";
} else {
truncateTo(lineContentEnd);
const prev = out[lineContentEnd - 1];
const next = nextOnLine(i);
if (prev !== undefined && next && /[A-Za-z0-9_$'"`]/.test(prev) && /[A-Za-z0-9_$'"`]/.test(next)) {
emit(" ");
}
}
} 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("", 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("", 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", "origin", "main"], { stdio: "inherit" });
} catch {
console.error("\npush to origin/main failed — commit is local. if your local branch isn't named `main`:\n git branch -m main");
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);
});
}