Files
HiveGrid-Wireframes/hivegrid-cloud/scripts/i18n-check.ts
T

503 lines
20 KiB
TypeScript

#!/usr/bin/env tsx
/* eslint-disable no-console */
import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
import path from "node:path";
import { defaultLocale, i18nNamespaces, supportedLocales } from "../packages/i18n/src/index.ts";
export type JsonObject = Record<string, unknown>;
export type MissingSourceKeyFallback = "fallback" | "no-fallback";
export type LocaleParityIssue =
| {
type: "missing-file";
locale: string;
namespace: string;
filePath: string;
}
| {
type: "invalid-json";
locale: string;
namespace: string;
filePath: string;
message: string;
}
| {
type: "invalid-root";
locale: string;
namespace: string;
filePath: string;
}
| {
type: "key-divergence";
locale: string;
namespace: string;
filePath: string;
referenceLocale: string;
missingKeys: string[];
extraKeys: string[];
}
| {
type: "unexpected-locale-dir";
locale: string;
directoryPath: string;
}
| {
type: "missing-source-key";
key: string;
namespace: string | null;
fallback: MissingSourceKeyFallback;
files: string[];
}
| {
type: "empty-reference";
locale: string;
namespace: string;
filePath?: string;
}
| {
type: "empty-translation";
locale: string;
namespace: string;
filePath: string;
keys: string[];
};
export type LocaleKeySummary = {
locale: string;
namespace: string;
filePath: string;
keyCount: number;
};
export type I18nParityResult = {
ok: boolean;
referenceLocale: string;
namespaces: string[];
keyCount: number;
locales: LocaleKeySummary[];
issues: LocaleParityIssue[];
};
export type I18nParityOptions = {
cwd?: string;
localesRoot?: string;
locales?: readonly string[];
referenceLocale?: string;
namespaces?: readonly string[];
sourceRoots?: readonly string[];
};
type SourceTranslationKey = {
key: string;
namespace: string | null;
fallback: MissingSourceKeyFallback;
};
const DEFAULT_SOURCE_ROOTS = ["apps/web/app", "packages"] as const;
const SOURCE_FILE_PATTERN = /\.(ts|tsx)$/;
const TEST_FILE_PATTERN = /\.(test|spec)\.(ts|tsx)$/;
const IGNORED_SOURCE_DIRS = new Set(["node_modules", "dist", "build", "coverage", ".cache", "generated"]);
const DEFAULT_NAMESPACE = "translations";
function isJsonObject(value: unknown): value is JsonObject {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
export function collectLeafKeys(value: unknown, prefix = ""): string[] {
if (!isJsonObject(value)) return prefix ? [prefix] : [];
const keys = Object.keys(value).sort((left, right) => left.localeCompare(right));
if (keys.length === 0) return prefix ? [prefix] : [];
return keys.flatMap((key) => {
const nextPrefix = prefix ? `${prefix}.${key}` : key;
const child = value[key];
if (isJsonObject(child)) return collectLeafKeys(child, nextPrefix);
return [nextPrefix];
});
}
export function checkI18nParity(options: I18nParityOptions = {}): I18nParityResult {
const cwd = options.cwd ?? process.cwd();
const localesRoot = options.localesRoot ?? resolveLocalesRoot(cwd);
const locales = [...(options.locales ?? supportedLocales)];
const namespaces = [...(options.namespaces ?? i18nNamespaces)];
const configuredReferenceLocale = options.referenceLocale ?? defaultLocale;
const referenceLocale = locales.includes(configuredReferenceLocale) ? configuredReferenceLocale : locales[0] ?? configuredReferenceLocale;
const configuredLocales = new Set(locales);
const issues: LocaleParityIssue[] = [];
const keySetsByLocale = new Map<string, Map<string, Set<string>>>();
const summaries: LocaleKeySummary[] = [];
if (existsSync(localesRoot)) {
for (const entry of readdirSync(localesRoot, { withFileTypes: true })) {
if (entry.isDirectory() && !configuredLocales.has(entry.name)) {
issues.push({ type: "unexpected-locale-dir", locale: entry.name, directoryPath: path.join(localesRoot, entry.name) });
}
}
}
for (const locale of locales) {
const localeKeySets = new Map<string, Set<string>>();
keySetsByLocale.set(locale, localeKeySets);
for (const namespace of namespaces) {
const filePath = path.join(localesRoot, locale, `${namespace}.json`);
if (!existsSync(filePath)) {
issues.push({ type: "missing-file", locale, namespace, filePath });
continue;
}
let parsed: unknown;
try {
parsed = JSON.parse(readFileSync(filePath, "utf8"));
} catch (error) {
issues.push({
type: "invalid-json",
locale,
namespace,
filePath,
message: error instanceof Error ? error.message : "Unknown JSON parse error",
});
continue;
}
if (!isJsonObject(parsed)) {
issues.push({ type: "invalid-root", locale, namespace, filePath });
continue;
}
const leafKeys = collectLeafKeys(parsed);
const emptyTranslationKeys = collectEmptyTranslationKeys(parsed);
if (emptyTranslationKeys.length > 0) {
issues.push({ type: "empty-translation", locale, namespace, filePath, keys: emptyTranslationKeys });
}
localeKeySets.set(namespace, new Set(leafKeys));
summaries.push({ locale, namespace, filePath, keyCount: leafKeys.length });
}
}
const referenceNamespaces = keySetsByLocale.get(referenceLocale);
if (referenceNamespaces) {
for (const namespace of namespaces) {
const referenceKeys = referenceNamespaces.get(namespace);
if (!referenceKeys) continue;
const sortedReferenceKeys = [...referenceKeys].sort((left, right) => left.localeCompare(right));
const referenceSummary = summaries.find((summary) => summary.locale === referenceLocale && summary.namespace === namespace);
if (sortedReferenceKeys.length === 0) {
issues.push({ type: "empty-reference", locale: referenceLocale, namespace, filePath: referenceSummary?.filePath });
}
for (const summary of summaries) {
if (summary.locale === referenceLocale || summary.namespace !== namespace) continue;
const localeKeys = keySetsByLocale.get(summary.locale)?.get(namespace) ?? new Set<string>();
const missingKeys = sortedReferenceKeys.filter((key) => !localeKeys.has(key));
const extraKeys = [...localeKeys].filter((key) => !referenceKeys.has(key)).sort((left, right) => left.localeCompare(right));
if (missingKeys.length > 0 || extraKeys.length > 0) {
issues.push({
type: "key-divergence",
locale: summary.locale,
namespace,
filePath: summary.filePath,
referenceLocale,
missingKeys,
extraKeys,
});
}
}
}
for (const [sourceKey, files] of collectStaticSourceTranslationKeys(cwd, options.sourceRoots ?? DEFAULT_SOURCE_ROOTS, namespaces)) {
if (!hasSourceTranslationKey(referenceNamespaces, sourceKey, namespaces)) {
issues.push({ type: "missing-source-key", ...sourceKey, files });
}
}
}
return {
ok: issues.every((issue) => !isBlockingIssue(issue)),
referenceLocale,
namespaces,
keyCount: [...(referenceNamespaces?.values() ?? [])].reduce((count, keys) => count + keys.size, 0),
locales: summaries.sort((left, right) => `${left.locale}:${left.namespace}`.localeCompare(`${right.locale}:${right.namespace}`)),
issues,
};
}
function hasSourceTranslationKey(referenceNamespaces: Map<string, Set<string>>, sourceKey: SourceTranslationKey, namespaces: readonly string[]) {
if (sourceKey.namespace) {
return hasTranslationKey(referenceNamespaces.get(sourceKey.namespace) ?? new Set<string>(), sourceKey.key);
}
// Laufzeit-Semantik (i18next): der Namespace wird mit ":" getrennt (nsSeparator), NICHT mit ".".
// Ein Key wie `t("settings.tenant.theme.title")` adressiert daher NICHT den `settings`-Namespace,
// sondern den effektiven (Default-)Namespace mit dem vollständigen, gepunkteten Key. Wir prüfen den
// Key deshalb nur als vollständigen Key in den (effektiven) Namespaces. KEIN "erstes Punkt-Segment
// als Namespace"-Fallback: der würde einen Punkt-statt-Doppelpunkt-Fehler (im UI ein nackter Key)
// stillschweigend als gültig durchwinken -- genau die Lücke, die den Theme-Settings-Bug verdeckte.
return namespaces.some((namespace) => hasTranslationKey(referenceNamespaces.get(namespace) ?? new Set<string>(), sourceKey.key));
}
function hasTranslationKey(keys: Set<string>, key: string) {
return keys.has(key) || keys.has(`${key}_one`) || keys.has(`${key}_other`);
}
function collectEmptyTranslationKeys(value: unknown, prefix = ""): string[] {
if (!isJsonObject(value)) {
return typeof value === "string" && value.trim().length === 0 && prefix ? [prefix] : [];
}
return Object.keys(value)
.sort((left, right) => left.localeCompare(right))
.flatMap((key) => collectEmptyTranslationKeys(value[key], prefix ? `${prefix}.${key}` : key));
}
function collectStaticSourceTranslationKeys(cwd: string, sourceRoots: readonly string[], namespaces: readonly string[]) {
const keys = new Map<string, { sourceKey: SourceTranslationKey; files: string[] }>();
for (const sourceRoot of sourceRoots) {
const absoluteRoot = path.join(cwd, sourceRoot);
if (!existsSync(absoluteRoot)) continue;
for (const filePath of walkSourceFiles(absoluteRoot)) {
const relativePath = slash(path.relative(cwd, filePath));
const source = readFileSync(filePath, "utf8");
for (const sourceKey of extractStaticTranslationKeys(source, namespaces)) {
const id = `${sourceKey.namespace ?? "*"}:${sourceKey.key}:${sourceKey.fallback}`;
const entry = keys.get(id) ?? { sourceKey, files: [] };
entry.files.push(relativePath);
keys.set(id, entry);
}
}
}
return [...keys.values()]
.map(({ sourceKey, files }) => [sourceKey, [...new Set(files)].sort((left, right) => left.localeCompare(right))] as const)
.sort(([left], [right]) => `${left.namespace ?? "*"}:${left.key}`.localeCompare(`${right.namespace ?? "*"}:${right.key}`));
}
function walkSourceFiles(directory: string): string[] {
return readdirSync(directory).flatMap((entry) => {
const filePath = path.join(directory, entry);
const stats = statSync(filePath);
if (stats.isDirectory()) {
if (IGNORED_SOURCE_DIRS.has(entry)) return [];
return walkSourceFiles(filePath);
}
if (!SOURCE_FILE_PATTERN.test(entry) || TEST_FILE_PATTERN.test(entry) || entry.endsWith(".generated.ts") || entry.endsWith(".generated.tsx")) {
return [];
}
return [filePath];
});
}
function extractStaticTranslationKeys(source: string, namespaces: readonly string[]) {
const keys = new Map<string, SourceTranslationKey>();
const namespaceSet = new Set(namespaces);
const inferredNamespace = inferSingleNamespace(source, namespaceSet);
const inferredKeyPrefix = inferSingleKeyPrefix(source);
const localFallbackKeys = collectLocalDictionaryKeys(source);
const addKey = (rawKey: string, fallback: MissingSourceKeyFallback, namespace: string | null = inferredNamespace, keyPrefix: string | null = inferredKeyPrefix) => {
if (rawKey.endsWith(".")) return;
const parsed = parseTranslationKey(rawKey, namespace, namespaceSet);
const key = applyKeyPrefix(parsed.key, rawKey, keyPrefix);
const sourceKey = { key, namespace: parsed.namespace, fallback };
keys.set(`${sourceKey.namespace ?? "*"}:${sourceKey.key}:${sourceKey.fallback}`, sourceKey);
};
for (const match of source.matchAll(/\bt\(\s*["']([A-Za-z0-9_.:-]+)["']([^)]*)\)/g)) {
const rawKey = match[1];
const tail = match[2] ?? "";
const namespace = extractOptionNamespace(tail, namespaceSet) ?? inferredNamespace;
const keyPrefix = extractOptionKeyPrefix(tail) ?? inferredKeyPrefix;
addKey(rawKey, hasDefaultValueFallback(tail) || localFallbackKeys.has(rawKey) ? "fallback" : "no-fallback", namespace, keyPrefix);
}
for (const match of source.matchAll(/\bt\(\s*\(?\s*\$[A-Za-z0-9_]*\s*\)?\s*=>\s*\$[A-Za-z0-9_]*((?:\.[A-Za-z_$][\w$]*)+)([^)]*)\)/g)) {
const rawKey = match[1].slice(1);
const tail = match[2] ?? "";
const namespace = extractOptionNamespace(tail, namespaceSet) ?? inferredNamespace;
addKey(rawKey, hasDefaultValueFallback(tail) ? "fallback" : "no-fallback", namespace, null);
}
for (const match of source.matchAll(/\bi18nKey\s*:\s*["']([A-Za-z0-9_.:-]+)["']/g)) {
const index = match.index ?? 0;
addKey(match[1], hasNearbyLiteralFallback(source, index) ? "fallback" : "no-fallback", extractNearbyNamespace(source, index, namespaceSet), extractNearbyKeyPrefix(source, index));
}
for (const match of source.matchAll(/\b(?:labelKey|titleKey|descriptionKey|messageKey)\s*:\s*["']([A-Za-z0-9_.:-]+)["']/g)) {
addKey(match[1], hasNearbyLiteralFallback(source, match.index ?? 0) ? "fallback" : "no-fallback", null);
}
for (const match of source.matchAll(/\bi18nKey\s*=\s*["']([A-Za-z0-9_.:-]+)["']/g)) {
const index = match.index ?? 0;
addKey(match[1], "no-fallback", extractNearbyNamespace(source, index, namespaceSet), extractNearbyKeyPrefix(source, index));
}
for (const match of source.matchAll(/\b(?:labelKey|titleKey|descriptionKey|messageKey)\s*=\s*["']([A-Za-z0-9_.:-]+)["']/g)) {
addKey(match[1], "no-fallback", null);
}
return [...keys.values()].sort((left, right) => `${left.namespace ?? "*"}:${left.key}`.localeCompare(`${right.namespace ?? "*"}:${right.key}`));
}
function parseTranslationKey(rawKey: string, namespace: string | null, namespaces: Set<string>) {
const separatorIndex = rawKey.indexOf(":");
if (separatorIndex > 0) {
const candidateNamespace = rawKey.slice(0, separatorIndex);
if (namespaces.has(candidateNamespace)) {
return { namespace: candidateNamespace, key: rawKey.slice(separatorIndex + 1) };
}
}
return { namespace, key: rawKey };
}
function inferSingleNamespace(source: string, namespaces: Set<string>) {
const found = new Set<string>();
for (const match of source.matchAll(/\b(?:useTranslation|useTranslate|getTranslations)\s*\([^)]*["']([A-Za-z0-9_-]+)["']/g)) {
if (namespaces.has(match[1]) && match[1] !== DEFAULT_NAMESPACE) found.add(match[1]);
}
for (const match of source.matchAll(/\bgetFixedT\s*\([^)]*,\s*["']([A-Za-z0-9_-]+)["']/g)) {
if (namespaces.has(match[1]) && match[1] !== DEFAULT_NAMESPACE) found.add(match[1]);
}
return found.size === 1 ? [...found][0] : null;
}
function extractOptionNamespace(source: string, namespaces: Set<string>) {
const match = source.match(/\bns\s*:\s*["']([A-Za-z0-9_-]+)["']/);
return match && namespaces.has(match[1]) ? match[1] : null;
}
function inferSingleKeyPrefix(source: string) {
const found = new Set<string>();
for (const match of source.matchAll(/\b(?:useTranslation|useTranslate|getTranslations)\s*\([^)]*\bkeyPrefix\s*:\s*["']([A-Za-z0-9_.:-]+)["']/g)) {
found.add(match[1]);
}
for (const match of source.matchAll(/\bgetFixedT\s*\([^)]*,\s*["'][A-Za-z0-9_-]+["']\s*,\s*["']([A-Za-z0-9_.:-]+)["']/g)) {
found.add(match[1]);
}
return found.size === 1 ? [...found][0] : null;
}
function extractOptionKeyPrefix(source: string) {
return source.match(/\bkeyPrefix\s*:\s*["']([A-Za-z0-9_.:-]+)["']/)?.[1] ?? null;
}
function applyKeyPrefix(key: string, rawKey: string, keyPrefix: string | null) {
if (!keyPrefix || rawKey.includes(":") || key.startsWith(`${keyPrefix}.`)) return key;
return `${keyPrefix}.${key}`;
}
function extractNearbyNamespace(source: string, index: number, namespaces: Set<string>) {
const window = extractNearbyKeyContainer(source, index);
if (!window) return null;
const jsxMatch = window.match(/\bns\s*=\s*["']([A-Za-z0-9_-]+)["']/);
if (jsxMatch && namespaces.has(jsxMatch[1])) return jsxMatch[1];
return extractOptionNamespace(window, namespaces);
}
function extractNearbyKeyPrefix(source: string, index: number) {
const window = extractNearbyKeyContainer(source, index);
if (!window) return null;
return window.match(/\bkeyPrefix\s*=\s*["']([A-Za-z0-9_.:-]+)["']/)?.[1] ?? extractOptionKeyPrefix(window);
}
function hasDefaultValueFallback(source: string) {
return /^\s*,\s*["']/.test(source) || /\bdefaultValue\s*:/.test(source);
}
function collectLocalDictionaryKeys(source: string) {
const keys = new Set<string>();
const dictionaryMatch = source.match(/\b(?:englishMailDictionary|fallbackDictionary)\b[\s\S]*?=\s*\{([\s\S]*?)\n\};/);
if (!dictionaryMatch) return keys;
for (const match of dictionaryMatch[1].matchAll(/["']([A-Za-z0-9_.:-]+)["']\s*:/g)) {
keys.add(match[1]);
}
return keys;
}
function hasNearbyLiteralFallback(source: string, index: number) {
const objectSource = extractNearbyKeyContainer(source, index);
if (!objectSource) return false;
return /\b(?:labelText|title|description|message|fallback|defaultValue)\s*:/.test(objectSource);
}
function extractNearbyKeyContainer(source: string, index: number) {
const objectStart = source.lastIndexOf("{", index);
const objectEnd = source.indexOf("}", index);
if (objectStart >= 0 && objectEnd >= 0 && objectEnd - objectStart <= 800) return source.slice(objectStart, objectEnd);
const tagStart = source.lastIndexOf("<", index);
const tagEnd = source.indexOf(">", index);
if (tagStart >= 0 && tagEnd >= 0 && tagEnd - tagStart <= 800) return source.slice(tagStart, tagEnd);
return null;
}
function slash(filePath: string) {
return filePath.split(path.sep).join("/");
}
function resolveLocalesRoot(cwd: string) {
return path.join(cwd, "apps", "web", "public", "locales");
}
export function formatI18nParityReport(result: I18nParityResult): string {
if (result.ok) {
const locales = [...new Set(result.locales.map((summary) => summary.locale))].join(", ");
const summary = `PASS i18n-parity: ${new Set(result.locales.map((summary) => summary.locale)).size} locales (${locales}) share ${result.keyCount} translation keys across ${result.namespaces.length} namespaces`;
const warnings = result.issues.filter((issue) => !isBlockingIssue(issue));
return warnings.length === 0 ? summary : [summary, ...warnings.map(formatIssue)].join("\n");
}
return ["FAIL i18n-parity: locale namespace files are not in parity", ...result.issues.map(formatIssue)].join("\n");
}
function isBlockingIssue(issue: LocaleParityIssue) {
return issue.type !== "missing-source-key" || issue.fallback === "no-fallback";
}
function formatIssue(issue: LocaleParityIssue): string {
if (issue.type === "missing-file") return `- missing locale namespace file [${issue.locale}/${issue.namespace}]: ${issue.filePath}`;
if (issue.type === "invalid-json") return `- invalid JSON [${issue.locale}/${issue.namespace}]: ${issue.filePath} (${issue.message})`;
if (issue.type === "invalid-root") return `- invalid root [${issue.locale}/${issue.namespace}]: ${issue.filePath} (expected a JSON object)`;
if (issue.type === "empty-reference") {
return `- empty reference namespace [${issue.locale}/${issue.namespace}]: ${issue.filePath ?? "missing reference file"} (expected at least one translation key)`;
}
if (issue.type === "empty-translation") {
return [`- empty translation values [${issue.locale}/${issue.namespace}]: ${issue.filePath}`, formatKeyList("keys", issue.keys)].join("\n");
}
if (issue.type === "unexpected-locale-dir") return `- unexpected locale directory [${issue.locale}]: ${issue.directoryPath}`;
if (issue.type === "missing-source-key") {
const namespace = issue.namespace ? `${issue.namespace}:` : "";
return [`- missing source translation key (${issue.fallback}): ${namespace}${issue.key}`, formatKeyList("files", issue.files)].join("\n");
}
return [
`- key divergence [${issue.locale}/${issue.namespace}] vs [${issue.referenceLocale}/${issue.namespace}]: ${issue.filePath}`,
formatKeyList("missing keys", issue.missingKeys),
formatKeyList("extra keys", issue.extraKeys),
].join("\n");
}
function formatKeyList(label: string, keys: string[]): string {
if (keys.length === 0) return ` ${label}: none`;
return [` ${label}:`, ...keys.map((key) => ` - ${key}`)].join("\n");
}
if (import.meta.url === `file://${process.argv[1]}`) {
const result = checkI18nParity();
const report = formatI18nParityReport(result);
if (result.ok) {
console.log(report);
} else {
console.error(report);
process.exitCode = 1;
}
}