feat(i18n): share frontend lobby loader and add da/en parity check (#257)
This commit is contained in:
@@ -9,7 +9,7 @@ Issue #175 requires one shared i18n contract for MVP host/player flows across fr
|
|||||||
- supported: `en`, `da`
|
- supported: `en`, `da`
|
||||||
- default/fallback: `en`
|
- default/fallback: `en`
|
||||||
|
|
||||||
Both Angular (`frontend/angular/src/app/lobby-i18n.ts`) and Django (`lobby/i18n.py`) read from this catalog.
|
Django (`lobby/i18n.py`) reads directly from the catalog. Frontend runtimes are Angular-first and use a shared loader (`frontend/shared/i18n/lobby-loader.ts`) so Angular shell and SPA fallback consume the same keyspace + locale normalization.
|
||||||
|
|
||||||
## Key naming convention
|
## Key naming convention
|
||||||
- Domain-first namespaces:
|
- Domain-first namespaces:
|
||||||
@@ -46,3 +46,4 @@ Both Angular (`frontend/angular/src/app/lobby-i18n.ts`) and Django (`lobby/i18n.
|
|||||||
- `frontend/angular/src/app/i18n-mvp-flow-smoke.spec.ts`
|
- `frontend/angular/src/app/i18n-mvp-flow-smoke.spec.ts`
|
||||||
- `frontend/angular/src/app/features/host/host-shell.component.spec.ts`
|
- `frontend/angular/src/app/features/host/host-shell.component.spec.ts`
|
||||||
- `frontend/angular/src/app/features/player/player-shell.component.spec.ts`
|
- `frontend/angular/src/app/features/player/player-shell.component.spec.ts`
|
||||||
|
- `frontend/tests/lobby-loader.parity.test.ts` (minimal da/en key parity guard for shared keyspace)
|
||||||
|
|||||||
@@ -1,26 +1,15 @@
|
|||||||
import lobbyCatalog from '../../../../shared/i18n/lobby.json';
|
import {
|
||||||
|
DEFAULT_LOCALE,
|
||||||
type SupportedLocale = (typeof lobbyCatalog.locales.supported)[number];
|
LOBBY_I18N_CATALOG,
|
||||||
|
normalizeLocale,
|
||||||
const DEFAULT_LOCALE = lobbyCatalog.locales.default as SupportedLocale;
|
type SupportedLocale,
|
||||||
const SUPPORTED_LOCALES = lobbyCatalog.locales.supported as readonly SupportedLocale[];
|
translateCatalogPath,
|
||||||
|
} from '../../../shared/i18n/lobby-loader';
|
||||||
|
|
||||||
let activeLocale: SupportedLocale | null = null;
|
let activeLocale: SupportedLocale | null = null;
|
||||||
const localeSubscribers = new Set<(locale: SupportedLocale) => void>();
|
const localeSubscribers = new Set<(locale: SupportedLocale) => void>();
|
||||||
|
|
||||||
export function normalizeLocale(rawLocale?: string | null): SupportedLocale {
|
export { normalizeLocale };
|
||||||
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 resolvePreferredLocale(): SupportedLocale {
|
export function resolvePreferredLocale(): SupportedLocale {
|
||||||
if (activeLocale) {
|
if (activeLocale) {
|
||||||
@@ -79,24 +68,7 @@ function resolveCatalogPath(key: string): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function t(key: string, locale: string): string {
|
export function t(key: string, locale: string): string {
|
||||||
const normalizedLocale = normalizeLocale(locale);
|
return translateCatalogPath(LOBBY_I18N_CATALOG.frontend.ui as Record<string, unknown>, resolveCatalogPath(key), 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') {
|
export const clientHasNoAudioOutput = Boolean(LOBBY_I18N_CATALOG.frontend.capabilities.client_has_no_audio_output);
|
||||||
return key;
|
|
||||||
}
|
|
||||||
|
|
||||||
const translations = cursor as Record<string, string>;
|
|
||||||
return translations[normalizedLocale] ?? translations[fallbackLocale] ?? key;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const clientHasNoAudioOutput = Boolean(lobbyCatalog.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 frontendErrors = LOBBY_I18N_CATALOG.frontend.errors;
|
||||||
const localeConfig = lobbyCatalog.locales;
|
const backendToFrontendErrorKeys = LOBBY_I18N_CATALOG.contract.backend_to_frontend_error_keys as Record<
|
||||||
const backendToFrontendErrorKeys = lobbyCatalog.contract.backend_to_frontend_error_keys as Record<string, keyof typeof frontendErrors>;
|
string,
|
||||||
|
keyof typeof frontendErrors
|
||||||
|
>;
|
||||||
|
|
||||||
type FrontendErrorKey = keyof typeof frontendErrors;
|
type FrontendErrorKey = keyof typeof frontendErrors;
|
||||||
type SupportedLocale = (typeof localeConfig.supported)[number];
|
|
||||||
|
|
||||||
function isFrontendErrorKey(value: string): value is FrontendErrorKey {
|
function isFrontendErrorKey(value: string): value is FrontendErrorKey {
|
||||||
return value in frontendErrors;
|
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 {
|
export function lobbyMessage(key: FrontendErrorKey, locale?: string): string {
|
||||||
const resolvedLocale = normalizeLocale(locale);
|
const resolvedLocale = normalizeLocale(locale);
|
||||||
const translations = frontendErrors[key] as Record<string, string>;
|
const translations = frontendErrors[key] as Record<string, string>;
|
||||||
@@ -26,8 +19,8 @@ export function lobbyMessage(key: FrontendErrorKey, locale?: string): string {
|
|||||||
if (translations[resolvedLocale]) {
|
if (translations[resolvedLocale]) {
|
||||||
return translations[resolvedLocale];
|
return translations[resolvedLocale];
|
||||||
}
|
}
|
||||||
if (translations[localeConfig.default]) {
|
if (translations[DEFAULT_LOCALE]) {
|
||||||
return translations[localeConfig.default];
|
return translations[DEFAULT_LOCALE];
|
||||||
}
|
}
|
||||||
|
|
||||||
return key;
|
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