#!/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; 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>>(); 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>(); 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(); 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>, sourceKey: SourceTranslationKey, namespaces: readonly string[]) { if (sourceKey.namespace) { return hasTranslationKey(referenceNamespaces.get(sourceKey.namespace) ?? new Set(), 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(), sourceKey.key)); } function hasTranslationKey(keys: Set, 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(); 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(); 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) { 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) { const found = new Set(); 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) { 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(); 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) { 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(); 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; } }