diff --git a/src/apps/officer-web/lib/I18nBridge.tsx b/src/apps/officer-web/lib/I18nBridge.tsx index 7c82d4be..b9f5f7a6 100644 --- a/src/apps/officer-web/lib/I18nBridge.tsx +++ b/src/apps/officer-web/lib/I18nBridge.tsx @@ -1,31 +1,11 @@ import { useEffect } from 'react'; import type { ReactNode } from 'react'; -import type { TranslationMap } from 'i18n'; + import { I18nProvider, localeDirection } from 'i18n'; import { setNameRevision } from 'workspaces/react'; -import { panelCatalogs } from 'officerdev'; -import { uiCatalogs } from '@/components/locales'; import { useSettings } from 'state/useSettings'; import { FALLBACK_LOCALE, SHIPPED_LOCALES } from './locales'; -import appEnGB from '../locales/en-GB.json'; -import appPtPT from '../locales/pt-PT.json'; - -const appCatalogs: TranslationMap = { 'en-GB': appEnGB, 'pt-PT': appPtPT }; - -/** - * Deep-merges the three per-workspace catalogs (D6) into one map per shipped locale — `app.*` from - * officer-web, `panel.*` from officerdev's panel apps, `ui.*` from shared components. - */ -const translations: TranslationMap = Object.fromEntries( - SHIPPED_LOCALES.map(({ code }) => [ - code, - { - app: appCatalogs[code] ?? {}, - panel: panelCatalogs[code] ?? {}, - ui: uiCatalogs[code] ?? {}, - }, - ]), -); +import { catalogues as translations } from './catalogues'; type I18nBridgeProps = { children: ReactNode; diff --git a/src/apps/officer-web/lib/catalogues.test.ts b/src/apps/officer-web/lib/catalogues.test.ts new file mode 100644 index 00000000..f3735193 --- /dev/null +++ b/src/apps/officer-web/lib/catalogues.test.ts @@ -0,0 +1,51 @@ +import { describe, expect, test } from 'bun:test'; +import { catalogues } from './catalogues'; +import { FALLBACK_LOCALE, SHIPPED_LOCALES } from './locales'; + +/** + * A locale is only shipped if it is actually LOADED. + * + * This exists because es-ES, de-DE and fr-FR shipped dead. Every one of their 1,129 keys was translated and + * in parity, all three were in `SHIPPED_LOCALES` so the picker offered them, and choosing one rendered the + * entire UI in raw keys — because none of the three catalogue files had been given the `import`. + * + * The parity tests could not have caught it and never will: they compare JSON files to each other, and an + * unimported file is as parity-correct as an imported one. The gap was between "the translation exists" and + * "the app can reach it", and nothing was asking that question. + * + * Adding a locale touches four files. This is the one that knows whether all four happened. + */ +describe('every shipped locale is reachable', () => { + const NAMESPACES = ['app', 'panel', 'ui'] as const; + + test('SHIPPED_LOCALES is not empty and contains the fallback', () => { + expect(SHIPPED_LOCALES.length).toBeGreaterThan(0); + expect(SHIPPED_LOCALES.map((l) => l.code)).toContain(FALLBACK_LOCALE); + }); + + for (const { code } of SHIPPED_LOCALES) { + describe(code, () => { + test('has a catalogue at all', () => { + expect(catalogues[code]).toBeDefined(); + }); + + for (const ns of NAMESPACES) { + // Non-empty, not merely present. The failure being pinned produced `{}` for all three, which is + // exactly what `?? {}` yields for a locale nobody imported — present, and useless. + test(`${ns}.* is populated`, () => { + const catalogue = (catalogues[code] as Record | undefined)?.[ns]; + expect(catalogue).toBeDefined(); + expect(Object.keys(catalogue ?? {}).length).toBeGreaterThan(0); + }); + } + + // Parity against the fallback is tested per-workspace already; this is the cheaper cross-check that + // the MERGED catalogue is the same size, which catches a namespace wired to the wrong locale's file. + test('is the same size as the fallback', () => { + const count = (o: unknown): number => + typeof o === 'object' && o !== null ? Object.values(o).reduce((n, v) => n + count(v), 0) : 1; + expect(count(catalogues[code])).toBe(count(catalogues[FALLBACK_LOCALE])); + }); + }); + } +}); diff --git a/src/apps/officer-web/lib/catalogues.ts b/src/apps/officer-web/lib/catalogues.ts new file mode 100644 index 00000000..3af2629c --- /dev/null +++ b/src/apps/officer-web/lib/catalogues.ts @@ -0,0 +1,47 @@ +import type { TranslationMap } from 'i18n'; +import { panelCatalogs } from 'officerdev'; +import { uiCatalogs } from '@/components/locales'; +import { SHIPPED_LOCALES } from './locales'; +import appDeDE from '../locales/de-DE.json'; +import appEnGB from '../locales/en-GB.json'; +import appEsES from '../locales/es-ES.json'; +import appFrFR from '../locales/fr-FR.json'; +import appPtPT from '../locales/pt-PT.json'; + +/** + * The three per-workspace catalogues, merged into one map per shipped locale (D6): `app.*` from + * officer-web, `panel.*` from officerdev's panel apps, `ui.*` from shared components. + * + * ── Why this is a separate file from `I18nBridge` ── + * + * So it can be tested without mounting React, and it is tested because of a real failure. es-ES, de-DE and + * fr-FR shipped translated, in perfect key parity, listed in the picker — and completely dead. All three + * `import` lists still named only en-GB and pt-PT, so `catalogues[code]` was three empty objects and the + * whole UI rendered untranslated in three of five languages. + * + * Nothing caught it. The parity tests compare the JSON FILES against each other and every file was correct; + * a locale that is never imported is invisible to them by construction. `SHIPPED_LOCALES` is what the picker + * reads, and it had been updated. Adding a locale was four edits in four files with no single place that + * knew all four had happened. + * + * `catalogues.test.ts` is now that place: it asserts every shipped locale actually resolves to a populated + * catalogue in all three namespaces. + */ +const appCatalogs: TranslationMap = { + 'en-GB': appEnGB, + 'pt-PT': appPtPT, + 'es-ES': appEsES, + 'de-DE': appDeDE, + 'fr-FR': appFrFR, +}; + +export const catalogues: TranslationMap = Object.fromEntries( + SHIPPED_LOCALES.map(({ code }) => [ + code, + { + app: appCatalogs[code] ?? {}, + panel: panelCatalogs[code] ?? {}, + ui: uiCatalogs[code] ?? {}, + }, + ]), +); diff --git a/src/workspaces/components/locales/index.ts b/src/workspaces/components/locales/index.ts index d8f20679..aa400b8d 100644 --- a/src/workspaces/components/locales/index.ts +++ b/src/workspaces/components/locales/index.ts @@ -1,8 +1,17 @@ import type { TranslationMap } from 'i18n'; +import deDE from './de-DE.json'; import enGB from './en-GB.json'; +import esES from './es-ES.json'; +import frFR from './fr-FR.json'; import ptPT from './pt-PT.json'; /** Shared components' own catalog, per locale — merged into the app-wide map by `I18nBridge` under `ui.*`. */ -export const uiCatalogs: TranslationMap = { 'en-GB': enGB, 'pt-PT': ptPT }; +export const uiCatalogs: TranslationMap = { + 'en-GB': enGB, + 'pt-PT': ptPT, + 'es-ES': esES, + 'de-DE': deDE, + 'fr-FR': frFR, +}; export { useTranslation } from './useTranslation'; diff --git a/src/workspaces/officerdev/src/locales/index.ts b/src/workspaces/officerdev/src/locales/index.ts index cbea9026..e8652db7 100644 --- a/src/workspaces/officerdev/src/locales/index.ts +++ b/src/workspaces/officerdev/src/locales/index.ts @@ -1,9 +1,18 @@ import type { TranslationMap } from 'i18n'; +import deDE from './de-DE.json'; import enGB from './en-GB.json'; +import esES from './es-ES.json'; +import frFR from './fr-FR.json'; import ptPT from './pt-PT.json'; /** Panel apps' own catalog, per locale — merged into the app-wide map by `I18nBridge` under `panel.*`. */ -export const panelCatalogs: TranslationMap = { 'en-GB': enGB, 'pt-PT': ptPT }; +export const panelCatalogs: TranslationMap = { + 'en-GB': enGB, + 'pt-PT': ptPT, + 'es-ES': esES, + 'de-DE': deDE, + 'fr-FR': frFR, +}; export { useTranslation } from './useTranslation'; export type { PanelTranslationKey } from './useTranslation';