478 lines
12 KiB
JavaScript
478 lines
12 KiB
JavaScript
#!/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("<script", i)) {
|
||
const tagEnd = rest.indexOf(">", i);
|
||
const close = rest.indexOf("</script>", 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: "<script>" });
|
||
head += rest.slice(i, tagEnd + 1) + inner + rest.slice(close, close + "</script>".length);
|
||
i = close + "</script>".length;
|
||
} else if (rest.startsWith("<style", i)) {
|
||
const tagEnd = rest.indexOf(">", i);
|
||
const close = rest.indexOf("</style>", 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 + "</style>".length);
|
||
i = close + "</style>".length;
|
||
} else if (rest.startsWith("<!--", i)) {
|
||
const end = rest.indexOf("-->", 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);
|
||
});
|
||
} |