From ed72f9a824e3f1282614b32e2a7634102e684be4 Mon Sep 17 00:00:00 2001 From: DEV-bot Date: Mon, 2 Mar 2026 02:15:17 +0000 Subject: [PATCH] feat(i18n): share frontend lobby loader and add da/en parity check (#257) --- docs/I18N_ARCHITECTURE.md | 3 +- frontend/angular/src/app/lobby-i18n.ts | 48 +++----------- frontend/shared/i18n/lobby-loader.ts | 74 ++++++++++++++++++++++ frontend/src/spa/lobby-i18n.ts | 23 +++---- frontend/tests/lobby-loader.parity.test.ts | 10 +++ 5 files changed, 104 insertions(+), 54 deletions(-) create mode 100644 frontend/shared/i18n/lobby-loader.ts create mode 100644 frontend/tests/lobby-loader.parity.test.ts diff --git a/docs/I18N_ARCHITECTURE.md b/docs/I18N_ARCHITECTURE.md index c6bfb4d..2ba2de1 100644 --- a/docs/I18N_ARCHITECTURE.md +++ b/docs/I18N_ARCHITECTURE.md @@ -9,7 +9,7 @@ Issue #175 requires one shared i18n contract for MVP host/player flows across fr - supported: `en`, `da` - 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 - 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/features/host/host-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) diff --git a/frontend/angular/src/app/lobby-i18n.ts b/frontend/angular/src/app/lobby-i18n.ts index 777084f..698184a 100644 --- a/frontend/angular/src/app/lobby-i18n.ts +++ b/frontend/angular/src/app/lobby-i18n.ts @@ -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))) { - return key; - } - cursor = (cursor as Record)[segment]; - } - - if (!cursor || typeof cursor !== 'object') { - return key; - } - - const translations = cursor as Record; - return translations[normalizedLocale] ?? translations[fallbackLocale] ?? key; + return translateCatalogPath(LOBBY_I18N_CATALOG.frontend.ui as Record, 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); diff --git a/frontend/shared/i18n/lobby-loader.ts b/frontend/shared/i18n/lobby-loader.ts new file mode 100644 index 0000000..70a4ba0 --- /dev/null +++ b/frontend/shared/i18n/lobby-loader.ts @@ -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, + 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))) { + return keyPath; + } + cursor = (cursor as Record)[segment]; + } + + if (!cursor || typeof cursor !== 'object') { + return keyPath; + } + + const translations = cursor as Record; + 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; + 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 || ''} missing non-empty '${locale}' translation`); + } + } + return issues; + } + + return keys.flatMap((key) => collectLocaleParityIssues(record[key], locales, path ? `${path}.${key}` : key)); +} diff --git a/frontend/src/spa/lobby-i18n.ts b/frontend/src/spa/lobby-i18n.ts index b3e31de..ab3f324 100644 --- a/frontend/src/spa/lobby-i18n.ts +++ b/frontend/src/spa/lobby-i18n.ts @@ -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; +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; @@ -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; diff --git a/frontend/tests/lobby-loader.parity.test.ts b/frontend/tests/lobby-loader.parity.test.ts new file mode 100644 index 0000000..c04d0a1 --- /dev/null +++ b/frontend/tests/lobby-loader.parity.test.ts @@ -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([]); + }); +}); -- 2.39.5