5e155f9cee
Co-authored-by: Kandev Agent <agent@kandev.dev>
105 lines
3.8 KiB
JavaScript
105 lines
3.8 KiB
JavaScript
#!/usr/bin/env node
|
|
/**
|
|
* Fail when `t()` is called at module scope.
|
|
*
|
|
* The module-level `t` from `@/lib/i18n` resolves when the module is imported —
|
|
* before a locale is necessarily active, and exactly once. A `const ROWS = [{
|
|
* label: t("k") }]` therefore pins that copy to the boot locale forever: the
|
|
* strings render, so nothing looks broken, but switching language leaves them in
|
|
* the old one. Module-scope JSX (`const BADGE = <span>{t("k")}</span>`) has the
|
|
* same problem — the element is built once.
|
|
*
|
|
* The fix is always to defer: store the catalog key and resolve it at render
|
|
* (see `lib/i18n/option-label.ts`), or turn the element into a component.
|
|
*
|
|
* The pseudo-locale cannot catch this class — the text *is* translated, just
|
|
* frozen at the boot locale — which is why this check exists (docs/i18n.md).
|
|
*
|
|
* Usage: node scripts/check-module-scope-t.mjs [<dir> ...]
|
|
*/
|
|
import { parseSync } from "@babel/core";
|
|
import fs from "node:fs";
|
|
import path from "node:path";
|
|
|
|
const ROOT = path.resolve(import.meta.dirname, "..");
|
|
const TARGETS = process.argv.slice(2).filter((a) => !a.startsWith("--"));
|
|
const DIRS = TARGETS.length ? TARGETS : ["components", "app", "hooks", "lib", "src"];
|
|
const FUNCTIONS = new Set([
|
|
"FunctionDeclaration",
|
|
"FunctionExpression",
|
|
"ArrowFunctionExpression",
|
|
"ObjectMethod",
|
|
"ClassMethod",
|
|
]);
|
|
|
|
function listFiles(dir, out = []) {
|
|
for (const e of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
const full = path.join(dir, e.name);
|
|
if (e.isDirectory()) {
|
|
if (["node_modules", "dist", "e2e", "locales"].includes(e.name)) continue;
|
|
listFiles(full, out);
|
|
} else if (/\.tsx?$/.test(e.name) && !/\.(test|d)\.tsx?$/.test(e.name)) out.push(full);
|
|
}
|
|
return out;
|
|
}
|
|
|
|
const problems = [];
|
|
|
|
/** A `t("key")` call that no enclosing function defers. */
|
|
function isModuleScopeT(node, ancestors) {
|
|
if (node.type !== "CallExpression") return false;
|
|
if (node.callee?.type !== "Identifier" || node.callee.name !== "t") return false;
|
|
if (node.arguments[0]?.type !== "StringLiteral") return false;
|
|
return !ancestors.some((a) => FUNCTIONS.has(a.type));
|
|
}
|
|
|
|
/** Walk carrying the ancestor chain so module scope can be told from a body. */
|
|
function visit(node, ancestors, source, rel) {
|
|
if (!node || typeof node.type !== "string") return;
|
|
if (isModuleScopeT(node, ancestors)) {
|
|
const line = source.slice(0, node.start).split("\n").length;
|
|
problems.push(`${rel}:${line} t("${node.arguments[0].value}") at module scope`);
|
|
}
|
|
const next = ancestors.concat([node]);
|
|
for (const key of Object.keys(node)) {
|
|
if (key === "loc" || key.endsWith("Comments")) continue;
|
|
const child = node[key];
|
|
if (Array.isArray(child)) {
|
|
for (const c of child) if (c && typeof c.type === "string") visit(c, next, source, rel);
|
|
} else if (child && typeof child.type === "string") visit(child, next, source, rel);
|
|
}
|
|
}
|
|
|
|
for (const dir of DIRS) {
|
|
const abs = path.isAbsolute(dir) ? dir : path.join(ROOT, dir);
|
|
if (!fs.existsSync(abs)) continue;
|
|
for (const file of listFiles(abs)) {
|
|
const source = fs.readFileSync(file, "utf8");
|
|
if (!/\bt\(\s*"/.test(source)) continue;
|
|
let ast;
|
|
try {
|
|
ast = parseSync(source, {
|
|
filename: file,
|
|
babelrc: false,
|
|
configFile: false,
|
|
sourceType: "module",
|
|
parserOpts: { plugins: ["typescript", "jsx"] },
|
|
});
|
|
} catch {
|
|
continue;
|
|
}
|
|
visit(ast, [], source, path.relative(ROOT, file));
|
|
}
|
|
}
|
|
|
|
if (problems.length) {
|
|
console.error(`✗ ${problems.length} module-scope t() call(s):\n`);
|
|
for (const p of problems) console.error(` ${p}`);
|
|
console.error(
|
|
`\nThese resolve once at import and never update on a locale switch.` +
|
|
`\nStore the key and resolve it at render, or make the value a component.`,
|
|
);
|
|
process.exit(1);
|
|
}
|
|
console.log("✓ no module-scope t() — all copy resolves at render.");
|