feat(i18n): share frontend lobby loader and add da/en parity check (#257)
This commit is contained in:
@@ -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);
|
||||
|
||||
74
frontend/shared/i18n/lobby-loader.ts
Normal file
74
frontend/shared/i18n/lobby-loader.ts
Normal 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));
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
10
frontend/tests/lobby-loader.parity.test.ts
Normal file
10
frontend/tests/lobby-loader.parity.test.ts
Normal 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([]);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user