localization: three shipped locales were never loaded

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 whole UI in raw keys.

All three catalogue files still imported only en-GB and pt-PT, so
`catalogues[code]` resolved to `{}` in every namespace — present, and useless.
Adding a locale is four edits across four files and nothing knew whether all
four had happened.

The parity tests could not have caught this and never will. They compare the
JSON files against each other, and a file nobody imports is exactly as
parity-correct as one that is loaded. The gap was between "the translation
exists" and "the app can reach it".

Registers all three in appCatalogs, panelCatalogs and uiCatalogs, moves the
merge out of I18nBridge into lib/catalogues.ts so it is testable without
mounting React, and adds catalogues.test.ts as the place that knows: every
shipped locale must resolve to a populated catalogue in all three namespaces,
and be the same size as the fallback.

Verified the test fails against the shipped code — 6 failures, panel.* for
each of the three — and passes after. 26 pass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043
This commit is contained in:
2026-08-23 17:38:55 +00:00
co-authored by Claude Opus 5
parent d7a4a18ce8
commit 6490dbc368
5 changed files with 120 additions and 24 deletions
+2 -22
View File
@@ -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;
@@ -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<string, object> | 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<number>((n, v) => n + count(v), 0) : 1;
expect(count(catalogues[code])).toBe(count(catalogues[FALLBACK_LOCALE]));
});
});
}
});
+47
View File
@@ -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] ?? {},
},
]),
);
+10 -1
View File
@@ -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';
+10 -1
View File
@@ -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';