503 lines
20 KiB
TypeScript
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;
|
|
}
|
|
}
|