website/scripts/commit.mjs
2026-09-08 19:06:59 -04:00

478 lines
12 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/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", "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);
});
}