feat(i18n): share frontend lobby loader and add da/en parity check (#257)
All checks were successful
CI / test-and-quality (push) Successful in 3m21s
CI / test-and-quality (pull_request) Successful in 3m2s

This commit is contained in:
2026-03-02 02:15:17 +00:00
parent 0bc4e6f066
commit ed72f9a824
5 changed files with 104 additions and 54 deletions

View File

@@ -1,26 +1,15 @@
import lobbyCatalog from '../../../../shared/i18n/lobby.json';
type SupportedLocale = (typeof lobbyCatalog.locales.supported)[number];
const DEFAULT_LOCALE = lobbyCatalog.locales.default as SupportedLocale;
const SUPPORTED_LOCALES = lobbyCatalog.locales.supported as readonly SupportedLocale[];
import {
DEFAULT_LOCALE,
LOBBY_I18N_CATALOG,
normalizeLocale,
type SupportedLocale,
translateCatalogPath,
} from '../../../shared/i18n/lobby-loader';
let activeLocale: SupportedLocale | null = null;
const localeSubscribers = new Set<(locale: SupportedLocale) => void>();
export function normalizeLocale(rawLocale?: string | null): SupportedLocale {
const locale = (rawLocale ?? '').trim().toLowerCase();
if ((SUPPORTED_LOCALES as readonly string[]).includes(locale)) {
return locale as SupportedLocale;
}
const shortLocale = locale.split('-')[0] ?? '';
if ((SUPPORTED_LOCALES as readonly string[]).includes(shortLocale)) {
return shortLocale as SupportedLocale;
}
return DEFAULT_LOCALE;
}
export { normalizeLocale };
export function resolvePreferredLocale(): SupportedLocale {
if (activeLocale) {
@@ -79,24 +68,7 @@ function resolveCatalogPath(key: string): string {
}
export function t(key: string, locale: string): string {
const normalizedLocale = normalizeLocale(locale);
const fallbackLocale = DEFAULT_LOCALE;
const segments = resolveCatalogPath(key).split('.');
let cursor: unknown = lobbyCatalog.frontend.ui;
for (const segment of segments) {
if (!cursor || typeof cursor !== 'object' || !(segment in (cursor as Record<string, unknown>))) {
return key;
}
cursor = (cursor as Record<string, unknown>)[segment];
}
if (!cursor || typeof cursor !== 'object') {
return key;
}
const translations = cursor as Record<string, string>;
return translations[normalizedLocale] ?? translations[fallbackLocale] ?? key;
return translateCatalogPath(LOBBY_I18N_CATALOG.frontend.ui as Record<string, unknown>, resolveCatalogPath(key), locale);
}
export const clientHasNoAudioOutput = Boolean(lobbyCatalog.frontend.capabilities.client_has_no_audio_output);
export const clientHasNoAudioOutput = Boolean(LOBBY_I18N_CATALOG.frontend.capabilities.client_has_no_audio_output);

View File

@@ -0,0 +1,74 @@
import lobbyCatalog from '../../../shared/i18n/lobby.json';
export type LobbyCatalog = typeof lobbyCatalog;
export type SupportedLocale = LobbyCatalog['locales']['supported'][number];
export const LOBBY_I18N_CATALOG = lobbyCatalog;
export const DEFAULT_LOCALE = lobbyCatalog.locales.default as SupportedLocale;
export const SUPPORTED_LOCALES = lobbyCatalog.locales.supported as readonly SupportedLocale[];
export function normalizeLocale(rawLocale?: string | null): SupportedLocale {
const locale = (rawLocale ?? '').trim().toLowerCase();
if ((SUPPORTED_LOCALES as readonly string[]).includes(locale)) {
return locale as SupportedLocale;
}
const shortLocale = locale.split('-')[0] ?? '';
if ((SUPPORTED_LOCALES as readonly string[]).includes(shortLocale)) {
return shortLocale as SupportedLocale;
}
return DEFAULT_LOCALE;
}
export function translateCatalogPath(
root: Record<string, unknown>,
keyPath: string,
locale: string,
fallback = DEFAULT_LOCALE,
): string {
const normalizedLocale = normalizeLocale(locale);
const segments = keyPath.split('.');
let cursor: unknown = root;
for (const segment of segments) {
if (!cursor || typeof cursor !== 'object' || !(segment in (cursor as Record<string, unknown>))) {
return keyPath;
}
cursor = (cursor as Record<string, unknown>)[segment];
}
if (!cursor || typeof cursor !== 'object') {
return keyPath;
}
const translations = cursor as Record<string, string>;
return translations[normalizedLocale] ?? translations[fallback] ?? keyPath;
}
export function collectLocaleParityIssues(
node: unknown,
locales: readonly string[] = SUPPORTED_LOCALES,
path = '',
): string[] {
if (!node || typeof node !== 'object') {
return [];
}
const record = node as Record<string, unknown>;
const keys = Object.keys(record);
const isTranslationLeaf = keys.length > 0 && locales.every((locale) => locale in record);
if (isTranslationLeaf) {
const issues: string[] = [];
for (const locale of locales) {
const value = record[locale];
if (typeof value !== 'string' || !value.trim()) {
issues.push(`${path || '<root>'} missing non-empty '${locale}' translation`);
}
}
return issues;
}
return keys.flatMap((key) => collectLocaleParityIssues(record[key], locales, path ? `${path}.${key}` : key));
}

View File

@@ -1,24 +1,17 @@
import lobbyCatalog from '../../../shared/i18n/lobby.json';
import { DEFAULT_LOCALE, LOBBY_I18N_CATALOG, normalizeLocale } from '../../shared/i18n/lobby-loader';
const frontendErrors = lobbyCatalog.frontend.errors;
const localeConfig = lobbyCatalog.locales;
const backendToFrontendErrorKeys = lobbyCatalog.contract.backend_to_frontend_error_keys as Record<string, keyof typeof frontendErrors>;
const frontendErrors = LOBBY_I18N_CATALOG.frontend.errors;
const backendToFrontendErrorKeys = LOBBY_I18N_CATALOG.contract.backend_to_frontend_error_keys as Record<
string,
keyof typeof frontendErrors
>;
type FrontendErrorKey = keyof typeof frontendErrors;
type SupportedLocale = (typeof localeConfig.supported)[number];
function isFrontendErrorKey(value: string): value is FrontendErrorKey {
return value in frontendErrors;
}
function normalizeLocale(rawLocale?: string): SupportedLocale {
const requested = (rawLocale ?? '').trim().toLowerCase();
if (localeConfig.supported.includes(requested as SupportedLocale)) {
return requested as SupportedLocale;
}
return localeConfig.default;
}
export function lobbyMessage(key: FrontendErrorKey, locale?: string): string {
const resolvedLocale = normalizeLocale(locale);
const translations = frontendErrors[key] as Record<string, string>;
@@ -26,8 +19,8 @@ export function lobbyMessage(key: FrontendErrorKey, locale?: string): string {
if (translations[resolvedLocale]) {
return translations[resolvedLocale];
}
if (translations[localeConfig.default]) {
return translations[localeConfig.default];
if (translations[DEFAULT_LOCALE]) {
return translations[DEFAULT_LOCALE];
}
return key;

View File

@@ -0,0 +1,10 @@
import { describe, expect, it } from 'vitest';
import { collectLocaleParityIssues, LOBBY_I18N_CATALOG, SUPPORTED_LOCALES } from '../shared/i18n/lobby-loader';
describe('shared lobby i18n loader parity', () => {
it('keeps da/en translation parity in shared keyspace', () => {
const issues = collectLocaleParityIssues(LOBBY_I18N_CATALOG, SUPPORTED_LOCALES);
expect(issues).toEqual([]);
});
});