Files
HiveGrid-Wireframes/03 Quellen und Referenzen/hivegrid-cloud/scripts/i18n-check.ts
T

795 lines
29 KiB
TypeScript

#!/usr/bin/env tsx
/* eslint-disable no-console */
import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
import path from "node:path";
import { catalogReadyLocales, defaultLocale, i18nNamespaces } 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[];
}
| {
type: "language-leak";
locale: string;
namespace: string;
filePath: string;
findings: Array<{ key: string; words: 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;
family: boolean;
};
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";
// High-confidence German UI vocabulary that must not appear in the English
// catalog. The match is intentionally case-sensitive: German UI labels and
// sentence starts are capitalized, while lower-case technical identifiers such
// as `kontakt.created` can legitimately appear in examples and audit filters.
// Keep this an explicit vocabulary instead of guessing language from all text;
// product names and established English/German loanwords stay false-positive
// free.
const GERMAN_UI_WORDS = [
"Abbrechen",
"Aktualisieren",
"Alle",
"Anmelden",
"Archiviert",
"Aufgabe",
"Aufgaben",
"Ausgewählt",
"Auswahl",
"Bearbeiten",
"Benutzer",
"Bereit",
"Bestätigen",
"Bitte",
"Datei",
"Dateien",
"Diese",
"Dieser",
"Dieses",
"Einstellungen",
"Erstellen",
"Fehlgeschlagen",
"Firma",
"Hinzufügen",
"Kein",
"Keine",
"Kontakt",
"Kontakte",
"Konto",
"Löschen",
"Neu",
"Neue",
"Neuer",
"Neues",
"Öffnen",
"Projekt",
"Projekte",
"Prüfe",
"Schließen",
"Senden",
"Speichern",
"Stornieren",
"Suchen",
"Telefonnummer",
"Unbekannt",
"Verantwortlicher",
"Verfügbar",
"Verknüpfung",
"Wähle",
"Zurück",
"Zustellung",
] as const;
const GERMAN_UI_WORD_PATTERN = new RegExp(`(?<![\\p{L}\\p{N}_])(?:${GERMAN_UI_WORDS.join("|")})(?![\\p{L}\\p{N}_])`, "gu");
// View-models and server registries often pass translation keys to the UI
// instead of calling `t()` at the declaration site. Keep this list explicit:
// names such as `featureFlagKey`, `triggerKey` or `idempotencyKey` are stable
// protocol identifiers and must not be mistaken for translation keys.
const TRANSLATION_KEY_FIELDS = [
"actionErrorKey",
"actionKey",
"actionLabelKey",
"actorKindLabelKey",
"anonymousHintKey",
"badgeLabelKey",
"blockedReasonKey",
"bodyKey",
"breadcrumbKey",
"buttonKey",
"captionKey",
"candidatesEmptyKey",
"candidatesLoadingKey",
"commentEmptyKey",
"complaintMessageKey",
"composerPlaceholderKey",
"conflictHintKey",
"conflictsEmptyKey",
"ctaKey",
"descriptionKey",
"designBlockedByKey",
"detailKey",
"disabledReasonKey",
"discoverHeadlineKey",
"dmModeKey",
"dmReadOnlyKey",
"dropzoneBodyKey",
"dropzoneHeadlineKey",
"emptyBodyKey",
"emptyCandidatesKey",
"emptyDescriptionKey",
"emptyHeadlineKey",
"emptyItemsBodyKey",
"emptyItemsHeadlineKey",
"emptyKey",
"emptyMembersKey",
"emptyRepliesBodyKey",
"emptyRepliesHeadlineKey",
"emptyTitleKey",
"environmentNoticeKey",
"errorKey",
"errorMessageKey",
"eyebrowKey",
"expiryKey",
"failureI18nKey",
"failureReasonKey",
"feedbackKey",
"groupModeKey",
"groupsEmptyKey",
"healthMessageKey",
"headlineKey",
"heicNoticeKey",
"helpDescriptionKey",
"helpKey",
"helpTextKey",
"helpTitleKey",
"hintKey",
"i18nKey",
"infoKey",
"infoTooltipKey",
"kindLabelKey",
"labelKey",
"lastErrorMessageKey",
"loadOlderLabelKey",
"manualGuestsHeadlineKey",
"membersEmptyKey",
"membersLoadingKey",
"messageKey",
"nameKey",
"newMessagesLabelKey",
"noAllowedModesKey",
"noMailboxBodyKey",
"noMailboxHeadlineKey",
"noMailboxKey",
"noMatchesKey",
"noPackageHintKey",
"noResultsBodyKey",
"noResultsHeadlineKey",
"noResultsKey",
"noResultsTitleKey",
"notConfiguredKey",
"overageNoticeKey",
"paymentStatusLabelKey",
"placeholderKey",
"priorityLabelKey",
"projectManagedExternallyKey",
"reasonKey",
"roleLabelKey",
"searchPlaceholderKey",
"sectionLabelKey",
"sourceLabelKey",
"stateLabelKey",
"statusLabelKey",
"submitKey",
"submittedHintKey",
"subtitleKey",
"successKey",
"successMessageKey",
"summaryKey",
"textKey",
"titleKey",
"tombstoneKey",
"translationKey",
"typeLabelKey",
"unreadEmptyKey",
"usersEmptyKey",
"usersLoadingKey",
] as const;
const TRANSLATION_KEY_FIELD_PATTERN = TRANSLATION_KEY_FIELDS.join("|");
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 ?? catalogReadyLocales)];
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 })) {
// `<locale>-formal` dirs are intentionally-sparse Anrede ("Sie") override
// bundles: they hold only the formal-variant strings and fall back to the
// base locale via i18next `fallbackLng`. They are NOT in `supportedLocales`
// (adding them would force full key parity on deliberately partial files),
// so the unexpected-locale-dir guard must skip them. See `effectiveLng`
// in `@hivegrid/i18n`.
const isFormalOverrideDir = entry.name.endsWith("-formal") && configuredLocales.has(entry.name.slice(0, -"-formal".length));
if (entry.isDirectory() && !configuredLocales.has(entry.name) && !isFormalOverrideDir) {
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 });
}
if (locale === "en") {
const findings = collectGermanLanguageLeaks(parsed);
if (findings.length > 0) issues.push({ type: "language-leak", locale, namespace, filePath, findings });
}
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", key: sourceKey.key, namespace: sourceKey.namespace, fallback: sourceKey.fallback, 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, sourceKey.family);
}
// 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, sourceKey.family));
}
function hasTranslationKey(keys: Set<string>, key: string, family = false) {
if (family) {
const pattern = new RegExp(`^${escapeRegExp(key).replaceAll("\\*", ".+")}$`, "u");
return [...keys].some((candidate) => pattern.test(candidate));
}
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 collectGermanLanguageLeaks(value: unknown, prefix = ""): Array<{ key: string; words: string[] }> {
if (!isJsonObject(value)) {
if (typeof value !== "string" || !prefix) return [];
const words = [...new Set([...value.matchAll(GERMAN_UI_WORD_PATTERN)].map((match) => match[0]))];
return words.length > 0 ? [{ key: prefix, words }] : [];
}
return Object.keys(value)
.sort((left, right) => left.localeCompare(right))
.flatMap((key) => collectGermanLanguageLeaks(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}:${sourceKey.family ? "family" : "exact"}`;
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, family: false };
keys.set(`${sourceKey.namespace ?? "*"}:${sourceKey.key}:${sourceKey.fallback}:exact`, sourceKey);
};
const addFamily = (rawPattern: string, fallback: MissingSourceKeyFallback, namespace: string | null = inferredNamespace, keyPrefix: string | null = inferredKeyPrefix) => {
const parsed = parseTranslationKey(rawPattern, namespace, namespaceSet);
const key = applyKeyPrefix(parsed.key, rawPattern, keyPrefix);
const sourceKey = { key, namespace: parsed.namespace, fallback, family: true };
keys.set(`${sourceKey.namespace ?? "*"}:${sourceKey.key}:${sourceKey.fallback}:family`, 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*`((?:\\.|[^`])*(?:\$\{)(?:\\.|[^`])*)`([^)]*)\)/g)) {
const rawPattern = dynamicTemplateToKeyFamily(match[1]);
if (!rawPattern) continue;
const tail = match[2] ?? "";
const namespace = extractOptionNamespace(tail, namespaceSet) ?? inferredNamespace;
const keyPrefix = extractOptionKeyPrefix(tail) ?? inferredKeyPrefix;
addFamily(rawPattern, hasDefaultValueFallback(tail) ? "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(new RegExp(`\\b(?:${TRANSLATION_KEY_FIELD_PATTERN})\\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(new RegExp(`\\b(?:${TRANSLATION_KEY_FIELD_PATTERN})[ \\t]*=[ \\t]*["']([A-Za-z0-9_.:-]+)["']`, "g"))) {
const linePrefix = source.slice(source.lastIndexOf("\n", match.index ?? 0) + 1, match.index ?? 0);
if (/\b(?:const|let|var)\s+$/u.test(linePrefix)) continue;
addKey(match[1], "no-fallback", null);
}
return [...keys.values()].sort((left, right) => `${left.namespace ?? "*"}:${left.key}`.localeCompare(`${right.namespace ?? "*"}:${right.key}`));
}
function dynamicTemplateToKeyFamily(template: string): string | null {
let pattern = "";
let cursor = 0;
let interpolationCount = 0;
while (cursor < template.length) {
const interpolationStart = template.indexOf("${", cursor);
if (interpolationStart < 0) {
pattern += template.slice(cursor);
break;
}
pattern += template.slice(cursor, interpolationStart);
const interpolationEnd = findInterpolationEnd(template, interpolationStart + 2);
if (interpolationEnd < 0) return null;
pattern += "*";
interpolationCount += 1;
cursor = interpolationEnd + 1;
}
const literalPart = pattern.replaceAll("*", "").replaceAll(/[.:-]/g, "");
return interpolationCount > 0 && /[A-Za-z0-9]/u.test(literalPart) ? pattern.replaceAll(/\*+/g, "*") : null;
}
function findInterpolationEnd(template: string, expressionStart: number): number {
let depth = 1;
let quote: "\"" | "'" | null = null;
let escaped = false;
for (let index = expressionStart; index < template.length; index += 1) {
const char = template[index];
if (quote) {
if (escaped) {
escaped = false;
} else if (char === "\\") {
escaped = true;
} else if (char === quote) {
quote = null;
}
continue;
}
if (char === "\"" || char === "'") {
quote = char;
continue;
}
if (char === "{") depth += 1;
if (char === "}") depth -= 1;
if (depth === 0) return index;
}
return -1;
}
function escapeRegExp(value: string) {
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
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 === "language-leak") {
return [
`- German UI text in English catalog [${issue.locale}/${issue.namespace}]: ${issue.filePath}`,
...issue.findings.map((finding) => ` - ${finding.key} [${finding.words.join(", ")}]`),
].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}:` : "";
const kind = issue.key.includes("*") ? "key family" : "key";
return [`- missing source translation ${kind} (${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;
}
}