795 lines
29 KiB
TypeScript
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;
|
|
}
|
|
}
|