Initial Commit

This commit is contained in:
2026-05-27 23:29:53 +01:00
commit fbc70f96e6
10000 changed files with 1102152 additions and 0 deletions

View File

@@ -0,0 +1,232 @@
# OpenSpec Change: enterprise-react-i18n
- **Change ID**: enterprise-react-i18n
- **Package**: `@suaveplan/react-i18n`
- **Category**: react
- **Target path**: `packages/react/react-i18n`
- **Status**: PROPOSED
- **Date**: 2026-05-04 (amended 2026-05-05 — see `## Amendment 2026-05-05` below)
- **Supersedes**: `openspec/changes/enterprise-react-translate/` (i18next-based; replaced by this change which builds on our universal `@suaveplan/i18n` core)
- **Cross-cutting design**: `openspec/cross-cutting-designs/2026-05-05-react-ui-react-i18n-react-forms.md` (this proposal is one of six changes implementing that design — see §13 of the design doc for the full list)
## Why
`SUMMARY.md` lists `@suaveplan/react-translate` as a pending migration; the legacy `enterprise-react-translate` proposal scaffolded that package on top of `i18next` + `react-i18next`. Since that proposal was written, genesis has shipped its own universal i18n core (`@suaveplan/i18n` — `MessageCatalog`, `InterpolationEngine`, `PluralizationEngine`, `MessageParser`, `LocaleResolver`, middleware, formatters) plus the `i18n-storage-backends` and `i18n-completion` changes (this wave) which round it out to feature parity with i18next. Building React bindings on i18next now would duplicate every primitive we already own and force two parallel observability stacks (i18next emits its own events, our core emits via `@suaveplan/events`).
This change supersedes the legacy proposal. We rebuild the React package as `@suaveplan/react-i18n`, built **on top of our universal core**, with the same public hooks the legacy proposal targeted (`useTranslation`, `useErrorMessage`, `useDirection`, `withTranslation`, `TranslationProvider`) plus a `<Trans>` rich-text component (the universally expected piece the legacy proposal omitted), Suspense-driven lazy namespace loading via the new `Backend` interface, and a missing-key dev overlay. `@suaveplan/react-translate` is retired before it ships — there is no migration cost because nothing in genesis imports it yet.
This package is the foundation every other React package will depend on for translated strings, so it must land before the `enterprise-react-ui` rewrite.
## Current-state audit (genesis)
Package does not exist on genesis. `packages/react/react-i18n/` does not exist. No genesis consumer imports `@suaveplan/react-translate`.
## Available workspace deps on genesis
| Package | Role |
|---|---|
| `@suaveplan/i18n` (`workspace:^`) | Universal core: catalog, parser, plurals, formatters, plugin host |
| `@suaveplan/error` (`workspace:^`) | `ReactI18nError` extends `SuaveplanError` |
| `@suaveplan/events` (`workspace:^`) | Subscribe to `loaded` / `missingKey` / `languageChanged` |
| `@suaveplan/logger` (`workspace:^`) | Missing-key telemetry log sink |
| `@suaveplan/storage` (`workspace:^`) | Used by `StorageBackend` consumers via `@suaveplan/i18n/backend/storage` |
| `@suaveplan/locale-data` (`workspace:^`) | Country / language metadata for the language switcher |
| `@suaveplan/types` (`workspace:^`) | Shared `Clock`, `IdGenerator` contracts |
| `@opentelemetry/api` (`catalog:observability`) | Missing-key counter, render-duration histogram |
External: `react` (peer, ≥18), `tslib catalog:build`, `zod catalog:forms`. **No `i18next`, no `react-i18next`, no `i18next-browser-languagedetector`, no `i18next-http-backend`** — all replaced by the universal core's `Backend` plugins, detector plugins, and event bus.
## Public API surface
```ts
// Provider
TranslationProvider, TranslationProviderProps
// Top-level instance access
useI18n, UseI18nReturn
// Translation hooks
useTranslation, UseTranslationReturn
useTranslate, UseTranslateReturn // hook-form returning the bound `t` directly
// Locale / direction hooks
useLocale, UseLocaleReturn
useDirection, UseDirectionReturn
// Specialised hooks
useErrorMessage, UseErrorMessageReturn
usePlural, UsePluralReturn
useDate, UseDateReturn
useNumber, UseNumberReturn
useCurrency, UseCurrencyReturn
useRelativeTime, UseRelativeTimeReturn
useList, UseListReturn
// Components
<Trans /> // rich-text component using MessageParser.formatChunks
<LanguageSwitcher /> // headless-by-default switcher; render-prop API
<MissingKeyOverlay /> // dev-mode visible badge for untranslated keys
<I18nSuspense /> // declarative Suspense wrapper for lazy backends
// HOC (escape hatch for class components)
withTranslation
// Config
createI18nConfig, I18nConfig
// Types
ReactI18nError, ReactI18nNamespace, ReactSupportedLocale, LanguageBundle
```
## Design decisions
1. **Built on `@suaveplan/i18n.createI18n(...)`, not i18next.** The Provider holds an `I18nInstance` from the universal core. Hooks subscribe to its event bus via `useSyncExternalStore` so they re-render on `languageChanged` / `loaded` / `missingKey` without polling.
2. **Backend-agnostic.** The Provider accepts a backend (or list of backends) via `@suaveplan/i18n`'s `Backend` interface. Common deployment shapes:
- `<TranslationProvider backend={multiFileFsBackend({ root: '/locales' })}>` — Node SSR with on-disk JSON
- `<TranslationProvider backend={httpBackend({ loadPath: '/api/i18n/{locale}/{namespace}.json' })}>` — browser SPA
- `<TranslationProvider backend={storageBackend({ storage: opfsStorage, layout: 'flat-keys' })}>` — offline-first PWA
- `<TranslationProvider backend={chainedBackend([memoryBackend(seed), httpBackend(...)])}>` — seeded + lazy
3. **Suspense for lazy namespaces.** The `useTranslation('auth')` hook triggers `i18n.preload('auth')` on mount. If the namespace is not yet loaded, the hook reads from `useSuspenseQuery`-style state and the parent `<I18nSuspense fallback={...}>` renders the fallback. No more `i18next-react`'s "show key string until ready" flash.
4. **Rich-text via `<Trans>`.** Backed by `MessageParser.formatChunks<ReactNode>` from `i18n-completion`. `<Trans i18nKey="welcome">Hello <bold>world</bold></Trans>` renders with the values map `{ bold: (chunks) => <b>{chunks}</b> }`.
5. **Dev-mode loud failure.** `useTranslation` outside a `TranslationProvider` throws `ReactI18nError(REACT_I18N.NO_PROVIDER)` in `process.env.NODE_ENV !== 'production'`; in production it logs once via `@suaveplan/logger` and falls through to a no-op identity translator that returns the key string.
6. **Missing-key overlay (dev-only).** `<MissingKeyOverlay />` is opt-in; when mounted, it subscribes to the `missingKey` event and renders a fixed-position badge listing untranslated keys. Tree-shaken out of production builds via `process.env.NODE_ENV` checks.
7. **Headless `<LanguageSwitcher>`.** Render-prop API with built-in keyboard navigation (`useReducer` state machine), `aria-*` attributes, and `useTransition`-wrapped change so big namespace loads don't block rendering. No DOM markup imposed.
8. **Server-side rendering.** Provider is SSR-safe. Use `createI18nConfig({ initialLocale, initialMessages })` to pre-populate the catalog from server-rendered HTML; the Provider hydrates synchronously without a network round-trip.
9. **No bundled translations.** The legacy proposal hardcoded 18 translation files (`en.ts`, `en-gb.ts`, `cy.ts`, `auth-en.ts`, ...). That decision belongs in the consumer app, not in the React binding. This package ships **only** the React surface; translations come via the consumer's chosen `Backend`.
## What changes
### `@suaveplan/react-i18n` (new package)
- ADDED: `TranslationProvider` — wraps the app with an `I18nInstance`. Accepts `i18n?: I18nInstance` (caller-managed) or `config: I18nConfig` (Provider-managed). Connects the instance event bus to React state via `useSyncExternalStore`.
- ADDED: `useI18n()` — returns the active `I18nInstance` for advanced consumers (plugin registration, manual `preload`).
- ADDED: `useTranslation(namespace?)` — Suspense-aware. Auto-triggers `preload` on mount when a backend is registered. Returns `{ t, i18n, ready }`. The `t` function is the ergonomic-options-bag form from `i18n-completion`.
- ADDED: `useTranslate(namespace?)` — terse alias returning the `t` function directly.
- ADDED: `useLocale()` / `useDirection()` — re-render on `languageChanged`. `useDirection` uses `getTextDirection` from the core.
- ADDED: `useErrorMessage(error: SuaveplanError | unknown)` — looks up the error code in the `errors` namespace and falls back to `error.message`.
- ADDED: `usePlural(count, locale?)` — returns `{ category, rule }` derived from the active `PluralizationEngine`.
- ADDED: `useDate(date, options?)`, `useNumber(value, options?)`, `useCurrency(amount, currency, options?)`, `useRelativeTime(deltaMs, options?)`, `useList(values, options?)` — memoised wrappers around the universal-core formatters; re-render on `languageChanged`.
- ADDED: `<Trans i18nKey="..." values={...} components={...}>...</Trans>` — rich-text component using `MessageParser.formatChunks<ReactNode>`. Supports nested tags, fallback children-as-default-message, and `count` for plural selection.
- ADDED: `<LanguageSwitcher>` — headless render-prop. Receives `{ locales, current, change, isPending }`; consumers render their own DOM. Built-in `useTransition` for non-blocking namespace loads.
- ADDED: `<MissingKeyOverlay>` — dev-mode badge. Subscribes to `missingKey` event; renders absolute-positioned list. Production-tree-shaken.
- ADDED: `<I18nSuspense fallback={...}>` — convenience Suspense wrapper that catches namespace-load promises with a default `aria-live` region for accessibility.
- ADDED: `withTranslation` HOC — class-component escape hatch. Injects `t` and `i18n` props.
- ADDED: `createI18nConfig(options)` — config builder for `TranslationProvider`. Validates options through `I18nConfigSchema` (Zod). Returns a config object the Provider passes to `createI18n`.
- ADDED: Error codes via `defineCodes("REACT_I18N", [...])`:
- `REACT_I18N.NO_PROVIDER` — hook used outside a `TranslationProvider`
- `REACT_I18N.NAMESPACE_LOAD_FAILED` — Suspense-thrown promise rejected and the boundary caught it
- `REACT_I18N.HYDRATION_MISMATCH` — SSR initial messages do not satisfy `TranslationRecordSchema`
- `REACT_I18N.TRANS_INVALID_CHILD` — `<Trans>` received a non-element / non-string child that can't be matched to a tag pair
### Telemetry
- ADDED: counter `react_i18n.missing_key.total` (mirrors core counter but tagged with React component name when available via React Profiler hook)
- ADDED: histogram `react_i18n.render.duration_ms` for `<Trans>` formatChunks render
- ADDED: counter `react_i18n.suspense.fallback.total` — incremented every time a Suspense fallback was rendered for a namespace load
## Out of scope
- **Translation bundles** (`en.ts`, `cy.ts`, etc. from the legacy proposal) — wrong layer. Consumer apps own their translations and choose a `Backend`. We do publish a *seed* helper (`createSeedBackend(record)`) for tests and Storybook, no actual bundles.
- **Built-in language detector configurations** (browser, server, mixed) — these belong in `@suaveplan/i18n` as detector plugins (covered by `i18n-completion` §3). The Provider just registers them; this package adds no detector code.
- **A11y-styled `<LanguageSwitcher>` with menu UI** — separate change once `@suaveplan/react-ui` is rebuilt; the headless version here is the building block.
- **i18next interop shim** — out of scope for genesis 0.x. If a consumer needs to migrate from i18next, write a one-shot codemod after this package stabilises.
- **React Server Components experimental APIs** — defer until Next 16 / React 20 stabilise the boundary.
## Source mapping
This change supersedes the legacy `../suaveplan/enterprise/react-translate.md`. The supersession reason is structural (different core; cannot be a verbatim port). The hooks API is preserved by name and shape so the legacy spec's requirements still apply.
| Legacy requirement | Source | Migrated form | Notes |
|---|---|---|---|
| `TranslationProvider` wraps with i18next | `enterprise/react-translate.md` | `TranslationProvider` wraps with `I18nInstance` | Same external API, different internal engine |
| `useTranslation` hook | legacy | `useTranslation` | Now Suspense-aware; auto-preload |
| `useDirection` hook | legacy | `useDirection` | Direct port; reads from `LocaleResolver` |
| `useErrorMessage` hook | legacy | `useErrorMessage` | Direct port |
| `withTranslation` HOC | legacy | `withTranslation` | Direct port |
| Lazy bundle loading | legacy via `i18next-http-backend` | `<I18nSuspense>` + `Backend` interface | Genesis architecture; superior because the backend is pluggable per-deployment |
| Missing-key telemetry | legacy via i18next event | core `missingKey` event + `react_i18n.missing_key.total` counter | Same observable; different wire |
| Translation bundles (18 files) | legacy | DROPPED — wrong layer | Justification in `## Out of scope`; bundles belong in consumer apps |
### Requirements with no source line
| Requirement | Justification |
|---|---|
| `<Trans>` rich-text component | Universal in React i18n libs; missing from legacy proposal. Unblocks complex strings like `<b>{name}</b> joined the chat`. |
| Suspense-driven lazy loading | Modern React idiom; eliminates the "show raw key" flash that legacy `react-i18next` consumers complain about. |
| Headless `<LanguageSwitcher>` | Composable building block; concrete UI lives in `@suaveplan/react-ui`. |
| `<MissingKeyOverlay>` dev tool | Accelerates translator workflow; standard in modern i18n stacks. |
| `useDate` / `useNumber` / `useCurrency` / `useRelativeTime` / `useList` | Memoised, locale-aware, re-render on locale change. The single most-asked feature in i18n React libraries. |
## Verification approach
Each requirement carries a `**Verified by:**` line in the spec delta.
- `packages/react/react-i18n/src/provider/provider.test.tsx` — Provider mount, instance hand-off, SSR initial-messages hydration
- `packages/react/react-i18n/src/hooks/use-translation/use-translation.test.tsx` — count / context / defaultValue / nested-ref / Suspense
- `packages/react/react-i18n/src/hooks/use-translation/use-translation.spec-conformance.test.tsx` — dev-mode no-provider throw, prod-mode soft-fail
- `packages/react/react-i18n/src/components/trans/trans.test.tsx` — tag interpolation, nested tags, plural, fallback
- `packages/react/react-i18n/src/components/language-switcher/language-switcher.test.tsx` — headless render-prop, keyboard, useTransition
- `packages/react/react-i18n/src/components/missing-key-overlay/missing-key-overlay.test.tsx` — dev only, subscribes to event bus, batches updates
- `packages/react/react-i18n/src/hooks/formatting/*.test.tsx` — every formatting hook re-renders on `languageChanged`
- `packages/react/react-i18n/e2e/react-i18n.spec.ts` — Playwright: live language switch, RTL flip on Arabic, lazy-namespace Suspense, missing-key overlay, SSR hydration
## Migration impact
Pure additive. `@suaveplan/react-translate` does not exist on genesis (it was scoped in the legacy proposal but never built). Consumers from `../suaveplan/` that imported `@suaveplan/react-translate` will need a one-line rename when they migrate to genesis (`@suaveplan/react-translate` → `@suaveplan/react-i18n`); document the rename in the React migration guide once written.
### Affected dependents
`grep -rl "@suaveplan/react-translate\|@suaveplan/react-i18n" /home/ubuntu/web/genesis/packages/ --include="package.json"` — currently zero. Future React packages will declare `@suaveplan/react-i18n workspace:^` as a peer.
### React versioning
Peer `react ^18.0.0 || ^19.0.0`. `useSyncExternalStore` was added in React 18 (and shimmed in 17 via `use-sync-external-store/shim`); we target 18+ to avoid the shim. Verify only one React version is hoisted after install per CLAUDE.md "Vitest / WSL2" guidance.
---
## Amendment 2026-05-05
The cross-cutting design at `openspec/cross-cutting-designs/2026-05-05-react-ui-react-i18n-react-forms.md` adds three capability blocks to this change. The amendment is non-breaking — existing requirements stand as-is; the new requirements extend the public surface.
### A1. Locale-detection chain (was deferred to a non-existent `i18n-completion §3`)
The original proposal punted detector-chain configuration to a follow-up i18n-completion change that was never authored. The detector chain is required by the React UI stack (theme switcher, language switcher, SSR contract) and lands here. Spec deltas added under `## ADDED Requirements` in `specs/react-i18n/spec.md`:
- `LocaleDetector` contract re-exported from `@suaveplan/i18n/detector`
- Nine built-in detectors: `querystring`, `userPreference`, `cookie`, `localStorage`, `htmlLang`, `acceptLanguage`, `navigator`, `cloudflareIpGeo`, `fallback`
- `defaultDetectorChain(opts)` factory exporting the canonical order
- `useLocaleDetection()` React hook
- SSR contract — server's choice authoritative for initial render; browser detectors skipped during hydration; `redetect()` re-runs full chain on user action
- Detector telemetry: `i18n.detector.match_total{detector}`, `i18n.detector.duration_ms{detector}`, span `i18n.detect`
`cloudflareIpGeoDetector` reads the free `cf-ipcountry` header and maps the ISO 3166 country code to a locale via `@suaveplan/locale-data`. Other IP-geo providers ship as user-provided `lookup` injected into a generic detector — covered by the universal core, not this package.
The `userPreferenceDetector` requires SSR auth threading via `extractAuth(ctx) => HeadersInit` — documented in §7.2.1 of the cross-cutting design.
### A2. Branded `TranslationKey` typing contract
Required by the no-hardcoded-strings rule (`scripts/lint/check-no-hardcoded-strings.ts`) shipped by `enterprise-react-ui-v2`, `enterprise-react-forms-v2`, `enterprise-react-ui-theme-dracula`, and `enterprise-react-ui-pack-auth`. The contract:
- `TranslationKey` is exported from `@suaveplan/react-i18n` as a branded string union over `keyof AppTranslationKeys | keyof ContributedTranslationKeys`
- Both interfaces empty by default; downstream packages augment `ContributedTranslationKeys`, consumer apps augment `AppTranslationKeys`
- `@suaveplan/react-i18n` package.json contains NO leaf-package deps (CI gate `scripts/lint/check-no-backwards-deps.ts` enforces this)
- Each downstream package generates its own augmentation file from its `locales/en-GB.json` via `bun scripts/generate-translation-types.ts`
This pattern is identical to how `react-router` augments register types — proven in production. The leaf-package CI gate exists because if `react-i18n` ever grew a dep on a leaf package, the cycle would silently re-emerge.
### A3. `tDynamic()` runtime escape hatch + `REACT_I18N.UNKNOWN_KEY`
For the rare case where a translation key is computed at runtime (e.g. mapping `error.code` to `errors.<code>`). `tDynamic(unknownString)` does a runtime catalog assertion; missing key throws `ReactI18nError(REACT_I18N.UNKNOWN_KEY)`. Every call emits a `warn`-level log so observability surfaces every use site (the typed path via `t(literalKey)` is preferred 99% of the time).
### A4. Tasks added under §11 below
A new task block in `tasks.md` covers the detector implementations, the `useLocaleDetection` hook, the `TranslationKey` typing contract, the `generate-translation-types.ts` script, and the `check-no-backwards-deps.ts` CI gate. Existing scaffolding tasks (§2) and existing module tasks (§3-§10) are unaffected.
### A5. Telemetry additions
Added to the existing telemetry table in this proposal:
- Counter `i18n.detector.match_total{detector}` (per A1)
- Histogram `i18n.detector.duration_ms{detector}` (per A1)
- Span `i18n.detect` (per A1)
- The `tDynamic` warn-log entries are not OTel — they go to the `@suaveplan/logger` sink and surface in centralised log aggregation