Files
EpicNext-Cms/scripts/audit-cms-translations.mjs
T
Simo ef94646d60
CI / check (push) Successful in 1m14s
CI / deploy (push) Successful in 1m27s
fix(i18n): persist CMS translations and validate message catalogs
2026-09-06 17:55:11 +02:00

190 lines
4.7 KiB
JavaScript

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;