fix(i18n): persist CMS translations and validate message catalogs
This commit is contained in:
1 parent
96234075f8
commit
ef94646d60
37 files changed
+1621
-463
No files matched your search
@@ -0,0 +1,189 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import * as babel from "@babel/parser";
|
||||
import { parse } from "@formatjs/icu-messageformat-parser";
|
||||
|
||||
const flat = (obj, p = "", r = {}) => {
|
||||
for (const [k, v] of Object.entries(obj)) {
|
||||
const key = p ? `${p}.${k}` : k;
|
||||
if (v && typeof v === "object") flat(v, key, r);
|
||||
else r[key] = v;
|
||||
}
|
||||
return r;
|
||||
};
|
||||
const messages = Object.fromEntries(
|
||||
fs
|
||||
.readdirSync("src/messages")
|
||||
.filter((n) => n.endsWith(".json"))
|
||||
.map((n) => [
|
||||
n.slice(0, -5),
|
||||
flat(JSON.parse(fs.readFileSync(`src/messages/${n}`, "utf8"))),
|
||||
]),
|
||||
);
|
||||
const base = messages.en,
|
||||
invalid = [],
|
||||
mismatches = [];
|
||||
function vars(ast, r = new Set()) {
|
||||
for (const n of ast) {
|
||||
if ([1, 2, 3, 4, 5, 6, 8].includes(n.type)) r.add(n.value);
|
||||
if (n.options) for (const o of Object.values(n.options)) vars(o.value, r);
|
||||
if (n.children) vars(n.children, r);
|
||||
}
|
||||
return [...r].sort().join(",");
|
||||
}
|
||||
const signatures = {};
|
||||
for (const [k, v] of Object.entries(base)) {
|
||||
try {
|
||||
signatures[k] = vars(parse(v));
|
||||
} catch {}
|
||||
}
|
||||
const locales = Object.entries(messages).map(([locale, m]) => {
|
||||
for (const [k, v] of Object.entries(m)) {
|
||||
try {
|
||||
const sig = vars(parse(v));
|
||||
if (k in signatures && sig !== signatures[k])
|
||||
mismatches.push({
|
||||
locale,
|
||||
key: k,
|
||||
expected: signatures[k],
|
||||
actual: sig,
|
||||
value: v,
|
||||
});
|
||||
} catch (e) {
|
||||
invalid.push({ locale, key: k, value: v, error: e.message });
|
||||
}
|
||||
}
|
||||
return {
|
||||
locale,
|
||||
total: Object.keys(m).length,
|
||||
missing: Object.keys(base).filter((k) => !(k in m)).length,
|
||||
empty: Object.values(m).filter((v) => !v?.trim()).length,
|
||||
sameAsEnglish: Object.keys(base).filter((k) => m[k] === base[k]).length,
|
||||
};
|
||||
});
|
||||
const missingCalls = [],
|
||||
hardcoded = [],
|
||||
unparsedFiles = [];
|
||||
function walk(n, fn) {
|
||||
if (!n || typeof n !== "object") return;
|
||||
fn(n);
|
||||
for (const [k, v] of Object.entries(n)) {
|
||||
if (k === "loc" || k === "extra") continue;
|
||||
if (Array.isArray(v))
|
||||
v.forEach((x) => {
|
||||
walk(x, fn);
|
||||
});
|
||||
else if (v && typeof v === "object") walk(v, fn);
|
||||
}
|
||||
}
|
||||
for (const f of fs
|
||||
.readdirSync("src", { recursive: true })
|
||||
.filter((f) => /\.(tsx|ts)$/.test(f) && !f.includes(".test."))) {
|
||||
let ast;
|
||||
try {
|
||||
ast = babel.parse(fs.readFileSync(path.join("src", f), "utf8"), {
|
||||
sourceType: "module",
|
||||
plugins: ["typescript", "jsx"],
|
||||
});
|
||||
} catch (error) {
|
||||
unparsedFiles.push({ file: f, error: error.message });
|
||||
continue;
|
||||
}
|
||||
const bindings = {};
|
||||
walk(ast, (n) => {
|
||||
if (n.type === "VariableDeclarator" && n.id.type === "Identifier") {
|
||||
let c = n.init;
|
||||
if (c?.type === "AwaitExpression") c = c.argument;
|
||||
if (
|
||||
c?.type === "CallExpression" &&
|
||||
["useTranslations", "getTranslations"].includes(c.callee.name)
|
||||
) {
|
||||
const arg = c.arguments[0];
|
||||
const ns =
|
||||
arg?.type === "StringLiteral"
|
||||
? arg.value
|
||||
: arg?.type === "ObjectExpression"
|
||||
? arg.properties.find((p) => p.key?.name === "namespace")?.value
|
||||
?.value
|
||||
: arg
|
||||
? null
|
||||
: "";
|
||||
if (ns !== null) {
|
||||
bindings[n.id.name] ??= new Set();
|
||||
bindings[n.id.name].add(ns);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
walk(ast, (n) => {
|
||||
if (n.type === "CallExpression") {
|
||||
const name = n.callee.name ?? n.callee.object?.name;
|
||||
const ns = bindings[name];
|
||||
const k = n.arguments[0];
|
||||
if (ns?.size === 1 && k?.type === "StringLiteral") {
|
||||
const full = [...ns][0] ? `${[...ns][0]}.${k.value}` : k.value;
|
||||
if (
|
||||
!(full in base) &&
|
||||
!Object.keys(base).some((b) => b.startsWith(`${full}.`))
|
||||
)
|
||||
missingCalls.push({ file: f, line: n.loc.start.line, key: full });
|
||||
}
|
||||
}
|
||||
if (n.type === "JSXText" && /[A-Za-z]{3}/.test(n.value.trim()))
|
||||
hardcoded.push({
|
||||
file: f,
|
||||
line: n.loc.start.line,
|
||||
text: n.value.trim().replace(/\s+/g, " "),
|
||||
});
|
||||
});
|
||||
}
|
||||
const result = {
|
||||
locales,
|
||||
invalid,
|
||||
mismatches,
|
||||
missingCalls,
|
||||
hardcoded,
|
||||
unparsedFiles,
|
||||
};
|
||||
if (process.argv.includes("--json"))
|
||||
console.log(JSON.stringify(result, null, 2));
|
||||
else {
|
||||
console.table(locales);
|
||||
console.log(
|
||||
"Invalid ICU:",
|
||||
invalid.length,
|
||||
"Variable mismatches:",
|
||||
mismatches.length,
|
||||
"Missing static message references:",
|
||||
missingCalls.length,
|
||||
);
|
||||
console.log(
|
||||
"Literal JSX text candidates requiring editorial review:",
|
||||
hardcoded.length,
|
||||
);
|
||||
console.log(
|
||||
"Coverage checks static translator namespaces and literal keys; dynamic keys and semantic translation quality require review.",
|
||||
);
|
||||
if (
|
||||
invalid.length ||
|
||||
mismatches.length ||
|
||||
missingCalls.length ||
|
||||
unparsedFiles.length
|
||||
)
|
||||
console.log(
|
||||
JSON.stringify(
|
||||
{ invalid, mismatches, missingCalls, unparsedFiles },
|
||||
null,
|
||||
2,
|
||||
),
|
||||
);
|
||||
}
|
||||
if (
|
||||
process.argv.includes("--check") &&
|
||||
(invalid.length ||
|
||||
mismatches.length ||
|
||||
missingCalls.length ||
|
||||
unparsedFiles.length ||
|
||||
locales.some((locale) => locale.empty > 0))
|
||||
)
|
||||
process.exitCode = 1;
|
||||
Reference in new issue
Block a user