/** * Remove comments from src/. * * Uses the TypeScript compiler's own parser to locate comments. Pattern * matching cannot do this safely: `//` appears inside string literals * ("https://..."), inside regex literals, and inside template literals, and a * regex that strips those corrupts the file in a way nothing catches until * runtime. * * JSDoc goes too, by default. Unlike a Python docstring it is not a language * feature — there is no runtime `__doc__`, and TypeScript takes its types from * the signature, so `@param` and `@returns` here are prose. Pass --keep-jsdoc * to leave it. * * Never touched: * * - **Generated files.** `src/types/api/schema.d.ts` is emitted by * openapi-typescript. Stripping it would be undone by the next * `npm run types:api` and would fail the staleness check in CI meanwhile. * - **Directives**, which read as comments but instruct a tool: * @ts-nocheck / @ts-ignore / @ts-expect-error typechecking * eslint-disable* linting * /// file inclusion * @deprecated editor + lint behaviour * prettier-ignore, @vite-ignore, webpackChunkName * * Usage: node scripts/strip-comments.mjs [--dry] [--keep-jsdoc] */ import fs from "node:fs"; import path from "node:path"; import ts from "typescript"; const DRY = process.argv.includes("--dry"); const KEEP_JSDOC = process.argv.includes("--keep-jsdoc"); const ROOT = "src"; const EXT = new Set([".ts", ".tsx", ".js", ".jsx"]); // Emitted by a generator; editing it here is undone on the next run. const GENERATED = [path.join("src", "types", "api", "schema.d.ts")]; const DIRECTIVE = /^\s*(\/\/\/?\s* { for (const r of found || []) { const key = `${r.pos}:${r.end}`; if (!seen.has(key)) { seen.add(key); ranges.push(r); } } }; const visit = (node) => { add(ts.getLeadingCommentRanges(text, node.pos)); add(ts.getTrailingCommentRanges(text, node.end)); for (const child of node.getChildren(source)) visit(child); }; visit(source); return ranges.sort((a, b) => a.pos - b.pos); } let filesTouched = 0; let removed = 0; let keptJsdoc = 0; let keptDirective = 0; const failures = []; let skippedGenerated = 0; for (const file of walk(ROOT)) { if (GENERATED.includes(file)) { skippedGenerated++; continue; } const original = fs.readFileSync(file, "utf8"); const kind = file.endsWith(".tsx") ? ts.ScriptKind.TSX : file.endsWith(".jsx") ? ts.ScriptKind.JSX : file.endsWith(".ts") ? ts.ScriptKind.TS : ts.ScriptKind.JS; const source = ts.createSourceFile( file, original, ts.ScriptTarget.Latest, true, kind, ); const drop = []; for (const range of commentRanges(source, original)) { const text = original.slice(range.pos, range.end); if (DIRECTIVE.test(text)) { keptDirective++; continue; } if (text.startsWith("/**") && (KEEP_JSDOC || ANNOTATED_JSDOC.test(text))) { keptJsdoc++; continue; } drop.push(range); removed += text.split("\n").length; } if (drop.length === 0) continue; // Right to left, so earlier offsets stay valid. let updated = original; for (const range of drop.slice().reverse()) { const before = updated.slice(0, range.pos); const after = updated.slice(range.end); // If the comment was alone on its line, take the whole line rather than // leaving an indented blank behind. const lineStart = before.lastIndexOf("\n") + 1; const aloneOnLine = before.slice(lineStart).trim() === "" && /^[ \t]*(\r?\n|$)/.test(after); if (aloneOnLine) { updated = before.slice(0, lineStart) + after.replace(/^[ \t]*\r?\n?/, ""); } else { updated = before.replace(/[ \t]+$/, "") + after; } } updated = updated.replace(/\n{4,}/g, "\n\n\n"); // The strip must not have changed the code. Re-parse and compare the // non-trivia token stream against the original: identical sequences mean // only comments went. const tokensOf = (src, k) => { const sf = ts.createSourceFile("t", src, ts.ScriptTarget.Latest, true, k); const acc = []; const walkTokens = (n) => { // TypeScript parses JSDoc into real AST nodes, so a removed JSDoc block // is a removed node. Skip them, or this check rejects the exact edit it // was written to permit. if ( n.kind >= ts.SyntaxKind.FirstJSDocNode && n.kind <= ts.SyntaxKind.LastJSDocNode ) { return; } const kids = n.getChildren(sf); if (kids.length === 0) acc.push(`${n.kind}:${n.getText(sf)}`); else for (const c of kids) walkTokens(c); }; walkTokens(sf); return acc.join(""); }; try { if (tokensOf(original, kind) !== tokensOf(updated, kind)) { failures.push(`${file}: token stream changed — left untouched`); continue; } } catch (err) { failures.push(`${file}: ${err.message} — left untouched`); continue; } if (!DRY) fs.writeFileSync(file, updated, "utf8"); filesTouched++; } console.log(`${DRY ? "[dry run] " : ""}files rewritten : ${filesTouched}`); console.log(`comment lines removed : ${removed}`); console.log(`JSDoc blocks kept : ${keptJsdoc}`); console.log(`directives kept : ${keptDirective}`); console.log(`generated files skipped: ${skippedGenerated}`); if (failures.length) { console.log(`\nSKIPPED (${failures.length}):`); for (const f of failures) console.log(" ", f); }