Files
Recordly/scripts/i18n-source-check.mjs
webadderall 3e6286cf04 Recordly 2.0 local review stack 1/6
Dependency-ordered review preparation. Not pushed or remotely reviewed.
2026-11-06 05:24:21 +11:00

204 lines
6.3 KiB
JavaScript

import fs from "node:fs";
import path from "node:path";
import ts from "typescript";
export function auditSourceKeys(catalogs, sourceRoot = "src") {
function walk(directory) {
return fs.readdirSync(directory, { withFileTypes: true }).flatMap((entry) => {
const file = path.join(directory, entry.name);
return entry.isDirectory() ? walk(file) : [file];
});
}
const files = walk(sourceRoot).filter(
(f) => /\.tsx?$/.test(f) && !/(\.test\.|\.spec\.|\/dev\/)/.test(f),
);
const missing = [],
dynamic = [],
unknown = [];
let count = 0;
const has = (k) => {
let v = catalogs;
const parts = k.split(".");
for (const [index, p] of parts.entries()) {
if (v == null || typeof v !== "object") return false;
if (index === parts.length - 1 && typeof v[p + "_other"] === "string") return true;
if (!Object.hasOwn(v, p)) return false;
v = v[p];
}
return typeof v === "string";
};
for (const file of files) {
const source = ts.createSourceFile(
file,
fs.readFileSync(file, "utf8"),
ts.ScriptTarget.Latest,
true,
);
const scopes = new Map(),
scopedHooks = new Set(["useScopedT"]),
rootHooks = new Set(["useI18n"]),
rootImports = new Set(["translate"]);
for (const st of source.statements) {
if (
ts.isImportDeclaration(st) &&
st.importClause?.namedBindings &&
ts.isNamedImports(st.importClause.namedBindings)
) {
for (const e of st.importClause.namedBindings.elements) {
const orig = (e.propertyName ?? e.name).text;
if (orig === "useScopedT") scopedHooks.add(e.name.text);
if (orig === "useI18n") rootHooks.add(e.name.text);
if (orig === "translate" && /I18nContext|i18n/.test(st.moduleSpecifier.text))
rootImports.add(e.name.text);
}
}
}
const isFn = (n) => ts.isFunctionLike(n);
const isScope = (n) => ts.isSourceFile(n) || ts.isBlock(n) || isFn(n);
function scopeOf(n) {
for (let p = n; p; p = p.parent) if (scopes.has(p)) return scopes.get(p);
}
function lookup(name, n) {
for (let p = n; p; p = p.parent) {
let m = scopes.get(p);
if (m?.has(name)) return m.get(name);
}
if (rootImports.has(name)) return { kind: "root" };
return null;
}
function resolve(n) {
if (!n) return null;
if (ts.isIdentifier(n)) return lookup(n.text, n);
if (ts.isCallExpression(n) && ts.isIdentifier(n.expression)) {
if (
scopedHooks.has(n.expression.text) &&
n.arguments[0] &&
ts.isStringLiteral(n.arguments[0])
)
return { kind: "scoped", namespace: n.arguments[0].text };
if (rootHooks.has(n.expression.text)) return { kind: "context" };
}
return null;
}
function bind(n) {
if (isScope(n)) scopes.set(n, new Map());
if (isFn(n))
for (const p of n.parameters) {
if (ts.isIdentifier(p.name))
scopes.get(n).set(p.name.text, { kind: "unknown" });
else if (ts.isObjectBindingPattern(p.name))
for (const item of p.name.elements)
if (
(item.propertyName ?? item.name).getText(source) === "t" &&
ts.isIdentifier(item.name)
)
scopes.get(n).set(item.name.text, { kind: "root" });
}
ts.forEachChild(n, bind);
}
bind(source);
function decl(n) {
if (ts.isVariableDeclaration(n)) {
const target = scopeOf(n);
const r = resolve(n.initializer);
if (ts.isIdentifier(n.name) && r) target.set(n.name.text, r);
if (
ts.isObjectBindingPattern(n.name) &&
(r?.kind === "context" || r?.kind === "unknown")
)
for (const e of n.name.elements)
if (
(e.propertyName ?? e.name).getText(source) === "t" &&
ts.isIdentifier(e.name)
)
target.set(e.name.text, { kind: "root" });
}
ts.forEachChild(n, decl);
}
for (let i = 0; i < 3; i++) decl(source);
const localFunctions = new Map();
function funcs(n) {
if (ts.isFunctionDeclaration(n) && n.name) localFunctions.set(n.name.text, n);
ts.forEachChild(n, funcs);
}
funcs(source);
// Infer local helper translator parameters from every proven call-site argument.
for (let round = 0; round < 3; round++) {
function infer(n) {
if (ts.isCallExpression(n) && ts.isIdentifier(n.expression)) {
const fn = localFunctions.get(n.expression.text);
if (fn)
n.arguments.forEach((arg, i) => {
const r = resolve(arg),
p = fn.parameters[i];
if (r && r.kind !== "unknown" && p && ts.isIdentifier(p.name))
scopes.get(fn).set(p.name.text, r);
});
}
ts.forEachChild(n, infer);
}
infer(source);
}
function visit(n) {
if (
ts.isCallExpression(n) &&
(ts.isIdentifier(n.expression) ||
(ts.isPropertyAccessExpression(n.expression) && n.expression.name.text === "t"))
) {
const r = ts.isIdentifier(n.expression) ? resolve(n.expression) : { kind: "root" };
if (r?.kind === "root" || r?.kind === "scoped") {
count++;
const arg = n.arguments[0];
if (
arg &&
(ts.isStringLiteral(arg) || ts.isNoSubstitutionTemplateLiteral(arg))
) {
const literal = arg.text;
const key =
r.kind === "scoped"
? r.namespace + "." + literal
: literal.split(".")[0] in catalogs
? literal
: "common." + literal;
if (!has(key)) {
const f = n.arguments[1];
missing.push({
file,
line: source.getLineAndCharacterOfPosition(n.getStart()).line + 1,
alias: n.expression.getText(source),
namespace: r.kind === "scoped" ? r.namespace : "root",
literal,
key,
fallback:
f &&
(ts.isStringLiteral(f) || ts.isNoSubstitutionTemplateLiteral(f))
? f.text
: null,
fallbackExpression: f?.getText(source) ?? null,
});
}
} else
dynamic.push({
file,
line: source.getLineAndCharacterOfPosition(n.getStart()).line + 1,
expression: arg?.getText(source),
namespace: r.namespace ?? "root",
});
} else if (
r?.kind === "unknown" &&
/^(t|t[A-Z]|.*T$|translate$)/.test(n.expression.text)
)
unknown.push({
file,
line: source.getLineAndCharacterOfPosition(n.getStart()).line + 1,
alias: n.expression.getText(source),
key: n.arguments[0]?.getText(source),
});
}
ts.forEachChild(n, visit);
}
visit(source);
}
return { files: files.length, translatorCalls: count, missing, dynamic, unknown };
}