Initial Commit

This commit is contained in:
2026-05-27 23:47:53 +01:00
parent fbc70f96e6
commit 9b28db724f
18103 changed files with 1600554 additions and 0 deletions

View File

@@ -0,0 +1,656 @@
# @suaveplan/react-i18n - React bindings for @suaveplan/i18n: TranslationProvider, hooks, <Trans>, <LanguageSwitcher>, locale-detection chain glue, branded TranslationKey contract.
## Summary
React bindings for `@suaveplan/i18n`. Ships the `<TranslationProvider>`,
twelve hooks, four headless components, the class-component HOC, the
nine built-in locale detectors plus the default chain, the
`useLocaleDetection` hook, the branded `TranslationKey` typing contract,
the `tDynamic` runtime escape hatch, the React-tier OTel instruments,
and the warn-once / identity-translator soft-failure substrate. The
package is the leaf-most React library in the project — every other
React package augments it (via `ContributedTranslationKeys`) but no
React package may declare it as a downstream dependency. The
[`check-no-backwards-deps.ts`](../../../scripts/lint/check-no-backwards-deps.ts)
CI gate enforces that invariant on every commit.
<!-- begin:auto-docs -->
## Documentation tree
- [i18n-suspense](./src/components/i18n-suspense/i18n-suspense.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [fallback-bundle](./src/components/i18n-suspense/fallback-bundle.md) — Ships the strings that `<I18nSuspense>` needs before the active catalog loads its own copies. Consumers can override th…
- [i18n-suspense](./src/components/i18n-suspense/i18n-suspense.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [language-switcher](./src/components/language-switcher/language-switcher.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [language-switcher](./src/components/language-switcher/language-switcher.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [missing-key-overlay](./src/components/missing-key-overlay/missing-key-overlay.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [missing-key-overlay](./src/components/missing-key-overlay/missing-key-overlay.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [trans](./src/components/trans/trans.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [stringify-children](./src/components/trans/stringify-children.md) — Helpers that translate a `<Trans>` `children` block into a canonical ICU message body string so the message parser rend…
- [trans](./src/components/trans/trans.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [create-i18n-config](./src/config/create-i18n-config.md) — Builds a validated `I18nConfig` from a caller-supplied option bag and is the canonical entry point for `<TranslationPro…
- [to-i18n-instance-options](./src/config/to-i18n-instance-options.md) — Translates a Zod-parsed `I18nConfig` (the React-tier shape) into the universal-core `I18nInstanceOptions` accepted by `…
- [accept-language](./src/detector/accept-language/accept-language.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [accept-language](./src/detector/accept-language/accept-language.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [cloudflare-ip-geo](./src/detector/cloudflare-ip-geo/cloudflare-ip-geo.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [cloudflare-ip-geo](./src/detector/cloudflare-ip-geo/cloudflare-ip-geo.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [cookie](./src/detector/cookie/cookie.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [cookie](./src/detector/cookie/cookie.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [default-chain](./src/detector/default-chain/default-chain.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [default-chain](./src/detector/default-chain/default-chain.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [fallback](./src/detector/fallback/fallback.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [fallback](./src/detector/fallback/fallback.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [html-lang](./src/detector/html-lang/html-lang.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [html-lang](./src/detector/html-lang/html-lang.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [local-storage](./src/detector/local-storage/local-storage.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [local-storage](./src/detector/local-storage/local-storage.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [navigator](./src/detector/navigator/navigator.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [navigator](./src/detector/navigator/navigator.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [querystring](./src/detector/querystring/querystring.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [querystring](./src/detector/querystring/querystring.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [user-preference](./src/detector/user-preference/user-preference.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [user-preference](./src/detector/user-preference/user-preference.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [error-codes](./src/errors/error-codes.md) — Registers the five stable error codes used by `@suaveplan/react-i18n` under the `REACT_I18N` namespace via `defineCodes…
- [react-i18n-error](./src/errors/react-i18n-error.md) — Defines `ReactI18nError`, the single error class thrown by the React surface of `@suaveplan/react-i18n`. It extends the…
- [with-translation](./src/hoc/with-translation/with-translation.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [with-translation](./src/hoc/with-translation/with-translation.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [use-currency](./src/hooks/use-currency/use-currency.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [use-currency](./src/hooks/use-currency/use-currency.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [use-date](./src/hooks/use-date/use-date.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [use-date](./src/hooks/use-date/use-date.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [use-direction](./src/hooks/use-direction/use-direction.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [use-direction](./src/hooks/use-direction/use-direction.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [use-error-message](./src/hooks/use-error-message/use-error-message.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [use-error-message](./src/hooks/use-error-message/use-error-message.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [use-formatter](./src/hooks/use-formatter/use-formatter.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [use-formatter](./src/hooks/use-formatter/use-formatter.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [use-i18n](./src/hooks/use-i18n/use-i18n.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [use-i18n](./src/hooks/use-i18n/use-i18n.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [use-list](./src/hooks/use-list/use-list.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [use-list](./src/hooks/use-list/use-list.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [use-locale](./src/hooks/use-locale/use-locale.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [use-locale](./src/hooks/use-locale/use-locale.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [use-locale-adapter](./src/hooks/use-locale-adapter/use-locale-adapter.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [use-locale-adapter](./src/hooks/use-locale-adapter/use-locale-adapter.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [use-locale-detection](./src/hooks/use-locale-detection/use-locale-detection.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [detector-chain-context](./src/hooks/use-locale-detection/detector-chain-context.md) — `DetectorChainContext` is the React context that carries the configured locale-detector chain (plus any SSR-authoritati…
- [use-locale-detection](./src/hooks/use-locale-detection/use-locale-detection.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [use-number](./src/hooks/use-number/use-number.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [use-number](./src/hooks/use-number/use-number.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [use-plural](./src/hooks/use-plural/use-plural.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [use-plural](./src/hooks/use-plural/use-plural.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [use-relative-time](./src/hooks/use-relative-time/use-relative-time.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [use-relative-time](./src/hooks/use-relative-time/use-relative-time.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [use-translate](./src/hooks/use-translate/use-translate.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [use-translate](./src/hooks/use-translate/use-translate.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [use-translation](./src/hooks/use-translation/use-translation.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [use-translation](./src/hooks/use-translation/use-translation.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [observability](./src/observability/react-i18n-metrics.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [react-i18n-metrics](./src/observability/react-i18n-metrics.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [provider](./src/provider/provider.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [provider-context](./src/provider/provider-context.md) — Defines the React context that carries the active `I18nInstance` from `@suaveplan/i18n` to every descendant of `<Transl…
- [provider](./src/provider/provider.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [registry](./src/registry/registry.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->- built-in — <!-- begin:auto-docs --> _(no submodules)…
- [built-in](./src/registry/formatters/built-in/built-in.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [built-in](./src/registry/formatters/built-in/built-in.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [built-in](./src/registry/locale-adapters/built-in/built-in.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [built-in](./src/registry/locale-adapters/built-in/built-in.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [post-processors](./src/registry/post-processors/post-processors.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [post-processors](./src/registry/post-processors/post-processors.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [registry-context](./src/registry/registry-context.md) — Module-scoped React context that carries the active `ReactI18nRegistry` to descendants of `<RegistryProvider>`. Direct…
- [registry-provider](./src/registry/registry-provider.md) — `<RegistryProvider>` is the React-context surface for `ReactI18nRegistry`. It mounts a fresh registry per tree, registe…
- [registry](./src/registry/registry.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->- built-in — <!-- begin:auto-docs --> _(no submodules)…
- [registry.schemas](./src/registry/registry.schemas.md) — Strict-object Zod schemas for every plugin kind accepted by `ReactI18nRegistry`. The schemas are the boundary at which…
- [use-registry](./src/registry/use-registry.md) — These hooks read the active `ReactI18nRegistry` from React context. The strict variant `useReactI18nRegistry()` throws…
- [schemas](./src/schemas/i18n-config.schemas.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [i18n-config.schemas](./src/schemas/i18n-config.schemas.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [translation-key](./src/translation-key/translation-key.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [t-dynamic](./src/translation-key/t-dynamic.md) — `tDynamic(i18n, key, values?)` is the rare-use escape hatch for translation lookups whose key cannot be statically know…
- [translation-key](./src/translation-key/translation-key.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [env](./src/utils/env.md) — Provides the single environment-detection helper the React surface uses to switch between dev-mode loud-failure and pro…
- [identity-instance](./src/utils/identity-instance.md) — Provides the identity translator used as the production-mode fallback when a `useTranslation()` (or any sibling hook) i…
- [warn-once-per-process](./src/utils/warn-once-per-process.md) — Implements the warn-once-per-process dedup ring used by every production-mode soft-failure path in `@suaveplan/react-i1…
<!-- end:auto-docs --> - [fallback-bundle](./src/components/i18n-suspense/fallback-bundle.md) — Ships the strings that `<I18nSuspense>` needs before the active catalog loads its own copies. Consumers can override th…
- [i18n-suspense](./src/components/i18n-suspense/i18n-suspense.md) — <!-- begin:auto-docs --> _(no submodules)_- [language-switcher](./src/components/language-switcher/language-switcher.md) — <!-- begin:auto-docs --> _(no submodules)_- [language-switcher](./src/components/language-switcher/language-switcher.md) — <!-- begin:auto-docs --> _(no submodules)_- [missing-key-overlay](./src/components/missing-key-overlay/missing-key-overlay.md) — <!-- begin:auto-docs --> _(no submodules)_- [missing-key-overlay](./src/components/missing-key-overlay/missing-key-overlay.md) — <!-- begin:auto-docs --> _(no submodules)_- [trans](./src/components/trans/trans.md) — <!-- begin:auto-docs --> _(no submodules)_- [stringify-children](./src/components/trans/stringify-children.md) — Helpers that translate a `<Trans>` `children` block into a canonical ICU message body string so the message parser rend…
- [trans](./src/components/trans/trans.md) — <!-- begin:auto-docs --> _(no submodules)_- [create-i18n-config](./src/config/create-i18n-config.md) — Builds a validated `I18nConfig` from a caller-supplied option bag and is the canonical entry point for `<TranslationPro…
- [to-i18n-instance-options](./src/config/to-i18n-instance-options.md) — Translates a Zod-parsed `I18nConfig` (the React-tier shape) into the universal-core `I18nInstanceOptions` accepted by `…
- [accept-language](./src/detector/accept-language/accept-language.md) — <!-- begin:auto-docs --> _(no submodules)_- [accept-language](./src/detector/accept-language/accept-language.md) — <!-- begin:auto-docs --> _(no submodules)_- [cloudflare-ip-geo](./src/detector/cloudflare-ip-geo/cloudflare-ip-geo.md) — <!-- begin:auto-docs --> _(no submodules)_- [cloudflare-ip-geo](./src/detector/cloudflare-ip-geo/cloudflare-ip-geo.md) — <!-- begin:auto-docs --> _(no submodules)_- [cookie](./src/detector/cookie/cookie.md) — <!-- begin:auto-docs --> _(no submodules)_- [cookie](./src/detector/cookie/cookie.md) — <!-- begin:auto-docs --> _(no submodules)_- [default-chain](./src/detector/default-chain/default-chain.md) — <!-- begin:auto-docs --> _(no submodules)_- [default-chain](./src/detector/default-chain/default-chain.md) — <!-- begin:auto-docs --> _(no submodules)_- [fallback](./src/detector/fallback/fallback.md) — <!-- begin:auto-docs --> _(no submodules)_- [fallback](./src/detector/fallback/fallback.md) — <!-- begin:auto-docs --> _(no submodules)_- [html-lang](./src/detector/html-lang/html-lang.md) — <!-- begin:auto-docs --> _(no submodules)_- [html-lang](./src/detector/html-lang/html-lang.md) — <!-- begin:auto-docs --> _(no submodules)_- [local-storage](./src/detector/local-storage/local-storage.md) — <!-- begin:auto-docs --> _(no submodules)_- [local-storage](./src/detector/local-storage/local-storage.md) — <!-- begin:auto-docs --> _(no submodules)_- [navigator](./src/detector/navigator/navigator.md) — <!-- begin:auto-docs --> _(no submodules)_- [navigator](./src/detector/navigator/navigator.md) — <!-- begin:auto-docs --> _(no submodules)_- [querystring](./src/detector/querystring/querystring.md) — <!-- begin:auto-docs --> _(no submodules)_- [querystring](./src/detector/querystring/querystring.md) — <!-- begin:auto-docs --> _(no submodules)_- [user-preference](./src/detector/user-preference/user-preference.md) — <!-- begin:auto-docs --> _(no submodules)_- [user-preference](./src/detector/user-preference/user-preference.md) — <!-- begin:auto-docs --> _(no submodules)_- [error-codes](./src/errors/error-codes.md) — Registers the five stable error codes used by `@suaveplan/react-i18n` under the `REACT_I18N` namespace via `defineCodes…
- [react-i18n-error](./src/errors/react-i18n-error.md) — Defines `ReactI18nError`, the single error class thrown by the React surface of `@suaveplan/react-i18n`. It extends the…
- [with-translation](./src/hoc/with-translation/with-translation.md) — <!-- begin:auto-docs --> _(no submodules)_- [with-translation](./src/hoc/with-translation/with-translation.md) — <!-- begin:auto-docs --> _(no submodules)_- [use-currency](./src/hooks/use-currency/use-currency.md) — <!-- begin:auto-docs --> _(no submodules)_- [use-currency](./src/hooks/use-currency/use-currency.md) — <!-- begin:auto-docs --> _(no submodules)_- [use-date](./src/hooks/use-date/use-date.md) — <!-- begin:auto-docs --> _(no submodules)_- [use-date](./src/hooks/use-date/use-date.md) — <!-- begin:auto-docs --> _(no submodules)_- [use-direction](./src/hooks/use-direction/use-direction.md) — <!-- begin:auto-docs --> _(no submodules)_- [use-direction](./src/hooks/use-direction/use-direction.md) — <!-- begin:auto-docs --> _(no submodules)_- [use-error-message](./src/hooks/use-error-message/use-error-message.md) — <!-- begin:auto-docs --> _(no submodules)_- [use-error-message](./src/hooks/use-error-message/use-error-message.md) — <!-- begin:auto-docs --> _(no submodules)_- [use-formatter](./src/hooks/use-formatter/use-formatter.md) — <!-- begin:auto-docs --> _(no submodules)_- [use-formatter](./src/hooks/use-formatter/use-formatter.md) — <!-- begin:auto-docs --> _(no submodules)_- [use-i18n](./src/hooks/use-i18n/use-i18n.md) — <!-- begin:auto-docs --> _(no submodules)_- [use-i18n](./src/hooks/use-i18n/use-i18n.md) — <!-- begin:auto-docs --> _(no submodules)_- [use-list](./src/hooks/use-list/use-list.md) — <!-- begin:auto-docs --> _(no submodules)_- [use-list](./src/hooks/use-list/use-list.md) — <!-- begin:auto-docs --> _(no submodules)_- [use-locale](./src/hooks/use-locale/use-locale.md) — <!-- begin:auto-docs --> _(no submodules)_- [use-locale](./src/hooks/use-locale/use-locale.md) — <!-- begin:auto-docs --> _(no submodules)_- [use-locale-adapter](./src/hooks/use-locale-adapter/use-locale-adapter.md) — <!-- begin:auto-docs --> _(no submodules)_- [use-locale-adapter](./src/hooks/use-locale-adapter/use-locale-adapter.md) — <!-- begin:auto-docs --> _(no submodules)_- [use-locale-detection](./src/hooks/use-locale-detection/use-locale-detection.md) — <!-- begin:auto-docs --> _(no submodules)_- [detector-chain-context](./src/hooks/use-locale-detection/detector-chain-context.md) — `DetectorChainContext` is the React context that carries the configured locale-detector chain (plus any SSR-authoritati…
- [use-locale-detection](./src/hooks/use-locale-detection/use-locale-detection.md) — <!-- begin:auto-docs --> _(no submodules)_- [use-number](./src/hooks/use-number/use-number.md) — <!-- begin:auto-docs --> _(no submodules)_- [use-number](./src/hooks/use-number/use-number.md) — <!-- begin:auto-docs --> _(no submodules)_- [use-plural](./src/hooks/use-plural/use-plural.md) — <!-- begin:auto-docs --> _(no submodules)_- [use-plural](./src/hooks/use-plural/use-plural.md) — <!-- begin:auto-docs --> _(no submodules)_- [use-relative-time](./src/hooks/use-relative-time/use-relative-time.md) — <!-- begin:auto-docs --> _(no submodules)_- [use-relative-time](./src/hooks/use-relative-time/use-relative-time.md) — <!-- begin:auto-docs --> _(no submodules)_- [use-translate](./src/hooks/use-translate/use-translate.md) — <!-- begin:auto-docs --> _(no submodules)_- [use-translate](./src/hooks/use-translate/use-translate.md) — <!-- begin:auto-docs --> _(no submodules)_- [use-translation](./src/hooks/use-translation/use-translation.md) — <!-- begin:auto-docs --> _(no submodules)_- [use-translation](./src/hooks/use-translation/use-translation.md) — <!-- begin:auto-docs --> _(no submodules)_- [observability](./src/observability/react-i18n-metrics.md) — <!-- begin:auto-docs --> _(no submodules)_- [react-i18n-metrics](./src/observability/react-i18n-metrics.md) — <!-- begin:auto-docs --> _(no submodules)_- [provider](./src/provider/provider.md) — <!-- begin:auto-docs --> _(no submodules)_- [provider-context](./src/provider/provider-context.md) — Defines the React context that carries the active `I18nInstance` from `@suaveplan/i18n` to every descendant of `<Transl…
- [provider](./src/provider/provider.md) — <!-- begin:auto-docs --> _(no submodules)_- [registry](./src/registry/registry.md) — <!-- begin:auto-docs --> _(no submodules)_- [built-in](./src/registry/formatters/built-in/built-in.md) — <!-- begin:auto-docs --> _(no submodules)_- [built-in](./src/registry/formatters/built-in/built-in.md) — <!-- begin:auto-docs --> _(no submodules)_- [built-in](./src/registry/locale-adapters/built-in/built-in.md) — <!-- begin:auto-docs --> _(no submodules)_- [built-in](./src/registry/locale-adapters/built-in/built-in.md) — <!-- begin:auto-docs --> _(no submodules)_- [post-processors](./src/registry/post-processors/post-processors.md) — <!-- begin:auto-docs --> _(no submodules)_- [post-processors](./src/registry/post-processors/post-processors.md) — <!-- begin:auto-docs --> _(no submodules)_- [registry-context](./src/registry/registry-context.md) — Module-scoped React context that carries the active `ReactI18nRegistry` to descendants of `<RegistryProvider>`. Direct…
- [registry-provider](./src/registry/registry-provider.md) — `<RegistryProvider>` is the React-context surface for `ReactI18nRegistry`. It mounts a fresh registry per tree, registe…
- [registry](./src/registry/registry.md) — <!-- begin:auto-docs --> _(no submodules)_- [registry.schemas](./src/registry/registry.schemas.md) — Strict-object Zod schemas for every plugin kind accepted by `ReactI18nRegistry`. The schemas are the boundary at which…
- [use-registry](./src/registry/use-registry.md) — These hooks read the active `ReactI18nRegistry` from React context. The strict variant `useReactI18nRegistry()` throws…
- [schemas](./src/schemas/i18n-config.schemas.md) — <!-- begin:auto-docs --> _(no submodules)_- [i18n-config.schemas](./src/schemas/i18n-config.schemas.md) — <!-- begin:auto-docs --> _(no submodules)_- [translation-key](./src/translation-key/translation-key.md) — <!-- begin:auto-docs --> _(no submodules)_- [t-dynamic](./src/translation-key/t-dynamic.md) — `tDynamic(i18n, key, values?)` is the rare-use escape hatch for translation lookups whose key cannot be statically know…
- [translation-key](./src/translation-key/translation-key.md) — <!-- begin:auto-docs --> _(no submodules)_- [env](./src/utils/env.md) — Provides the single environment-detection helper the React surface uses to switch between dev-mode loud-failure and pro…
- [identity-instance](./src/utils/identity-instance.md) — Provides the identity translator used as the production-mode fallback when a `useTranslation()` (or any sibling hook) i…
- [warn-once-per-process](./src/utils/warn-once-per-process.md) — Implements the warn-once-per-process dedup ring used by every production-mode soft-failure path in `@suaveplan/react-i1…
- [fallback-bundle](./src/components/i18n-suspense/fallback-bundle.md) — Ships the strings that `<I18nSuspense>` needs before the active catalog loads its own copies. Consumers can override th…
- [i18n-suspense](./src/components/i18n-suspense/i18n-suspense.md) — <!-- begin:auto-docs --> _(no submodules)_- [language-switcher](./src/components/language-switcher/language-switcher.md) — <!-- begin:auto-docs --> _(no submodules)_- [language-switcher](./src/components/language-switcher/language-switcher.md) — <!-- begin:auto-docs --> _(no submodules)_- [missing-key-overlay](./src/components/missing-key-overlay/missing-key-overlay.md) — <!-- begin:auto-docs --> _(no submodules)_- [missing-key-overlay](./src/components/missing-key-overlay/missing-key-overlay.md) — <!-- begin:auto-docs --> _(no submodules)_- [trans](./src/components/trans/trans.md) — <!-- begin:auto-docs --> _(no submodules)_- [stringify-children](./src/components/trans/stringify-children.md) — Helpers that translate a `<Trans>` `children` block into a canonical ICU message body string so the message parser rend…
- [trans](./src/components/trans/trans.md) — <!-- begin:auto-docs --> _(no submodules)_- [create-i18n-config](./src/config/create-i18n-config.md) — Builds a validated `I18nConfig` from a caller-supplied option bag and is the canonical entry point for `<TranslationPro…
- [to-i18n-instance-options](./src/config/to-i18n-instance-options.md) — Translates a Zod-parsed `I18nConfig` (the React-tier shape) into the universal-core `I18nInstanceOptions` accepted by `…
- [accept-language](./src/detector/accept-language/accept-language.md) — <!-- begin:auto-docs --> _(no submodules)_- [accept-language](./src/detector/accept-language/accept-language.md) — <!-- begin:auto-docs --> _(no submodules)_- [cloudflare-ip-geo](./src/detector/cloudflare-ip-geo/cloudflare-ip-geo.md) — <!-- begin:auto-docs --> _(no submodules)_- [cloudflare-ip-geo](./src/detector/cloudflare-ip-geo/cloudflare-ip-geo.md) — <!-- begin:auto-docs --> _(no submodules)_- [cookie](./src/detector/cookie/cookie.md) — <!-- begin:auto-docs --> _(no submodules)_- [cookie](./src/detector/cookie/cookie.md) — <!-- begin:auto-docs --> _(no submodules)_- [default-chain](./src/detector/default-chain/default-chain.md) — <!-- begin:auto-docs --> _(no submodules)_- [default-chain](./src/detector/default-chain/default-chain.md) — <!-- begin:auto-docs --> _(no submodules)_- [fallback](./src/detector/fallback/fallback.md) — <!-- begin:auto-docs --> _(no submodules)_- [fallback](./src/detector/fallback/fallback.md) — <!-- begin:auto-docs --> _(no submodules)_- [html-lang](./src/detector/html-lang/html-lang.md) — <!-- begin:auto-docs --> _(no submodules)_- [html-lang](./src/detector/html-lang/html-lang.md) — <!-- begin:auto-docs --> _(no submodules)_- [local-storage](./src/detector/local-storage/local-storage.md) — <!-- begin:auto-docs --> _(no submodules)_- [local-storage](./src/detector/local-storage/local-storage.md) — <!-- begin:auto-docs --> _(no submodules)_- [navigator](./src/detector/navigator/navigator.md) — <!-- begin:auto-docs --> _(no submodules)_- [navigator](./src/detector/navigator/navigator.md) — <!-- begin:auto-docs --> _(no submodules)_- [querystring](./src/detector/querystring/querystring.md) — <!-- begin:auto-docs --> _(no submodules)_- [querystring](./src/detector/querystring/querystring.md) — <!-- begin:auto-docs --> _(no submodules)_- [user-preference](./src/detector/user-preference/user-preference.md) — <!-- begin:auto-docs --> _(no submodules)_- [user-preference](./src/detector/user-preference/user-preference.md) — <!-- begin:auto-docs --> _(no submodules)_- [error-codes](./src/errors/error-codes.md) — Registers the five stable error codes used by `@suaveplan/react-i18n` under the `REACT_I18N` namespace via `defineCodes…
- [react-i18n-error](./src/errors/react-i18n-error.md) — Defines `ReactI18nError`, the single error class thrown by the React surface of `@suaveplan/react-i18n`. It extends the…
- [with-translation](./src/hoc/with-translation/with-translation.md) — <!-- begin:auto-docs --> _(no submodules)_- [with-translation](./src/hoc/with-translation/with-translation.md) — <!-- begin:auto-docs --> _(no submodules)_- [use-currency](./src/hooks/use-currency/use-currency.md) — <!-- begin:auto-docs --> _(no submodules)_- [use-currency](./src/hooks/use-currency/use-currency.md) — <!-- begin:auto-docs --> _(no submodules)_- [use-date](./src/hooks/use-date/use-date.md) — <!-- begin:auto-docs --> _(no submodules)_- [use-date](./src/hooks/use-date/use-date.md) — <!-- begin:auto-docs --> _(no submodules)_- [use-direction](./src/hooks/use-direction/use-direction.md) — <!-- begin:auto-docs --> _(no submodules)_- [use-direction](./src/hooks/use-direction/use-direction.md) — <!-- begin:auto-docs --> _(no submodules)_- [use-error-message](./src/hooks/use-error-message/use-error-message.md) — <!-- begin:auto-docs --> _(no submodules)_- [use-error-message](./src/hooks/use-error-message/use-error-message.md) — <!-- begin:auto-docs --> _(no submodules)_- [use-formatter](./src/hooks/use-formatter/use-formatter.md) — <!-- begin:auto-docs --> _(no submodules)_- [use-formatter](./src/hooks/use-formatter/use-formatter.md) — <!-- begin:auto-docs --> _(no submodules)_- [use-i18n](./src/hooks/use-i18n/use-i18n.md) — <!-- begin:auto-docs --> _(no submodules)_- [use-i18n](./src/hooks/use-i18n/use-i18n.md) — <!-- begin:auto-docs --> _(no submodules)_- [use-list](./src/hooks/use-list/use-list.md) — <!-- begin:auto-docs --> _(no submodules)_- [use-list](./src/hooks/use-list/use-list.md) — <!-- begin:auto-docs --> _(no submodules)_- [use-locale](./src/hooks/use-locale/use-locale.md) — <!-- begin:auto-docs --> _(no submodules)_- [use-locale](./src/hooks/use-locale/use-locale.md) — <!-- begin:auto-docs --> _(no submodules)_- [use-locale-adapter](./src/hooks/use-locale-adapter/use-locale-adapter.md) — <!-- begin:auto-docs --> _(no submodules)_- [use-locale-adapter](./src/hooks/use-locale-adapter/use-locale-adapter.md) — <!-- begin:auto-docs --> _(no submodules)_- [use-locale-detection](./src/hooks/use-locale-detection/use-locale-detection.md) — <!-- begin:auto-docs --> _(no submodules)_- [detector-chain-context](./src/hooks/use-locale-detection/detector-chain-context.md) — `DetectorChainContext` is the React context that carries the configured locale-detector chain (plus any SSR-authoritati…
- [use-locale-detection](./src/hooks/use-locale-detection/use-locale-detection.md) — <!-- begin:auto-docs --> _(no submodules)_- [use-number](./src/hooks/use-number/use-number.md) — <!-- begin:auto-docs --> _(no submodules)_- [use-number](./src/hooks/use-number/use-number.md) — <!-- begin:auto-docs --> _(no submodules)_- [use-plural](./src/hooks/use-plural/use-plural.md) — <!-- begin:auto-docs --> _(no submodules)_- [use-plural](./src/hooks/use-plural/use-plural.md) — <!-- begin:auto-docs --> _(no submodules)_- [use-relative-time](./src/hooks/use-relative-time/use-relative-time.md) — <!-- begin:auto-docs --> _(no submodules)_- [use-relative-time](./src/hooks/use-relative-time/use-relative-time.md) — <!-- begin:auto-docs --> _(no submodules)_- [use-translate](./src/hooks/use-translate/use-translate.md) — <!-- begin:auto-docs --> _(no submodules)_- [use-translate](./src/hooks/use-translate/use-translate.md) — <!-- begin:auto-docs --> _(no submodules)_- [use-translation](./src/hooks/use-translation/use-translation.md) — <!-- begin:auto-docs --> _(no submodules)_- [use-translation](./src/hooks/use-translation/use-translation.md) — <!-- begin:auto-docs --> _(no submodules)_- [observability](./src/observability/react-i18n-metrics.md) — <!-- begin:auto-docs --> _(no submodules)_- [react-i18n-metrics](./src/observability/react-i18n-metrics.md) — <!-- begin:auto-docs --> _(no submodules)_- [provider](./src/provider/provider.md) — <!-- begin:auto-docs --> _(no submodules)_- [provider-context](./src/provider/provider-context.md) — Defines the React context that carries the active `I18nInstance` from `@suaveplan/i18n` to every descendant of `<Transl…
- [provider](./src/provider/provider.md) — <!-- begin:auto-docs --> _(no submodules)_- [registry](./src/registry/registry.md) — <!-- begin:auto-docs --> _(no submodules)_- built-in — <!-- begin:auto-d…
- [built-in](./src/registry/formatters/built-in/built-in.md) — <!-- begin:auto-docs --> _(no submodules)_- [built-in](./src/registry/formatters/built-in/built-in.md) — <!-- begin:auto-docs --> _(no submodules)_- [built-in](./src/registry/locale-adapters/built-in/built-in.md) — <!-- begin:auto-docs --> _(no submodules)_- [built-in](./src/registry/locale-adapters/built-in/built-in.md) — <!-- begin:auto-docs --> _(no submodules)_- [post-processors](./src/registry/post-processors/post-processors.md) — <!-- begin:auto-docs --> _(no submodules)_- [post-processors](./src/registry/post-processors/post-processors.md) — <!-- begin:auto-docs --> _(no submodules)_- [registry-context](./src/registry/registry-context.md) — Module-scoped React context that carries the active `ReactI18nRegistry` to descendants of `<RegistryProvider>`. Direct…
- [registry-provider](./src/registry/registry-provider.md) — `<RegistryProvider>` is the React-context surface for `ReactI18nRegistry`. It mounts a fresh registry per tree, registe…
- [registry](./src/registry/registry.md) — <!-- begin:auto-docs --> _(no submodules)_- built-in — <!-- begin:auto-d…
- [registry.schemas](./src/registry/registry.schemas.md) — Strict-object Zod schemas for every plugin kind accepted by `ReactI18nRegistry`. The schemas are the boundary at which…
- [use-registry](./src/registry/use-registry.md) — These hooks read the active `ReactI18nRegistry` from React context. The strict variant `useReactI18nRegistry()` throws…
- [schemas](./src/schemas/i18n-config.schemas.md) — <!-- begin:auto-docs --> _(no submodules)_- [i18n-config.schemas](./src/schemas/i18n-config.schemas.md) — <!-- begin:auto-docs --> _(no submodules)_- [translation-key](./src/translation-key/translation-key.md) — <!-- begin:auto-docs --> _(no submodules)_- [t-dynamic](./src/translation-key/t-dynamic.md) — `tDynamic(i18n, key, values?)` is the rare-use escape hatch for translation lookups whose key cannot be statically know…
- [translation-key](./src/translation-key/translation-key.md) — <!-- begin:auto-docs --> _(no submodules)_- [env](./src/utils/env.md) — Provides the single environment-detection helper the React surface uses to switch between dev-mode loud-failure and pro…
- [identity-instance](./src/utils/identity-instance.md) — Provides the identity translator used as the production-mode fallback when a `useTranslation()` (or any sibling hook) i…
- [warn-once-per-process](./src/utils/warn-once-per-process.md) — Implements the warn-once-per-process dedup ring used by every production-mode soft-failure path in `@suaveplan/react-i1…
- [fallback-bundle](./src/components/i18n-suspense/fallback-bundle.md) — Ships the strings that `<I18nSuspense>` needs before the active catalog loads its own copies. Consumers can override th…
- [i18n-suspense](./src/components/i18n-suspense/i18n-suspense.md) — <!-- begin:auto-docs --> _(no submodules)_- [language-switcher](./src/components/language-switcher/language-switcher.md) — <!-- begin:auto-docs --> _(no submodules)_- [language-switcher](./src/components/language-switcher/language-switcher.md) — <!-- begin:auto-docs --> _(no submodules)_- [missing-key-overlay](./src/components/missing-key-overlay/missing-key-overlay.md) — <!-- begin:auto-docs --> _(no submodules)_- [missing-key-overlay](./src/components/missing-key-overlay/missing-key-overlay.md) — <!-- begin:auto-docs --> _(no submodules)_- [trans](./src/components/trans/trans.md) — <!-- begin:auto-docs --> _(no submodules)_- [stringify-children](./src/components/trans/stringify-children.md) — Helpers that translate a `<Trans>` `children` block into a canonical ICU message body string so the message parser rend…
- [trans](./src/components/trans/trans.md) — <!-- begin:auto-docs --> _(no submodules)_- [create-i18n-config](./src/config/create-i18n-config.md) — Builds a validated `I18nConfig` from a caller-supplied option bag and is the canonical entry point for `<TranslationPro…
- [to-i18n-instance-options](./src/config/to-i18n-instance-options.md) — Translates a Zod-parsed `I18nConfig` (the React-tier shape) into the universal-core `I18nInstanceOptions` accepted by `…
- [accept-language](./src/detector/accept-language/accept-language.md) — <!-- begin:auto-docs --> _(no submodules)_- [accept-language](./src/detector/accept-language/accept-language.md) — <!-- begin:auto-docs --> _(no submodules)_- [cloudflare-ip-geo](./src/detector/cloudflare-ip-geo/cloudflare-ip-geo.md) — <!-- begin:auto-docs --> _(no submodules)_- [cloudflare-ip-geo](./src/detector/cloudflare-ip-geo/cloudflare-ip-geo.md) — <!-- begin:auto-docs --> _(no submodules)_- [cookie](./src/detector/cookie/cookie.md) — <!-- begin:auto-docs --> _(no submodules)_- [cookie](./src/detector/cookie/cookie.md) — <!-- begin:auto-docs --> _(no submodules)_- [default-chain](./src/detector/default-chain/default-chain.md) — <!-- begin:auto-docs --> _(no submodules)_- [default-chain](./src/detector/default-chain/default-chain.md) — <!-- begin:auto-docs --> _(no submodules)_- [fallback](./src/detector/fallback/fallback.md) — <!-- begin:auto-docs --> _(no submodules)_- [fallback](./src/detector/fallback/fallback.md) — <!-- begin:auto-docs --> _(no submodules)_- [html-lang](./src/detector/html-lang/html-lang.md) — <!-- begin:auto-docs --> _(no submodules)_- [html-lang](./src/detector/html-lang/html-lang.md) — <!-- begin:auto-docs --> _(no submodules)_- [local-storage](./src/detector/local-storage/local-storage.md) — <!-- begin:auto-docs --> _(no submodules)_- [local-storage](./src/detector/local-storage/local-storage.md) — <!-- begin:auto-docs --> _(no submodules)_- [navigator](./src/detector/navigator/navigator.md) — <!-- begin:auto-docs --> _(no submodules)_- [navigator](./src/detector/navigator/navigator.md) — <!-- begin:auto-docs --> _(no submodules)_- [querystring](./src/detector/querystring/querystring.md) — <!-- begin:auto-docs --> _(no submodules)_- [querystring](./src/detector/querystring/querystring.md) — <!-- begin:auto-docs --> _(no submodules)_- [user-preference](./src/detector/user-preference/user-preference.md) — <!-- begin:auto-docs --> _(no submodules)_- [user-preference](./src/detector/user-preference/user-preference.md) — <!-- begin:auto-docs --> _(no submodules)_- [error-codes](./src/errors/error-codes.md) — Registers the five stable error codes used by `@suaveplan/react-i18n` under the `REACT_I18N` namespace via `defineCodes…
- [react-i18n-error](./src/errors/react-i18n-error.md) — Defines `ReactI18nError`, the single error class thrown by the React surface of `@suaveplan/react-i18n`. It extends the…
- [with-translation](./src/hoc/with-translation/with-translation.md) — <!-- begin:auto-docs --> _(no submodules)_- [with-translation](./src/hoc/with-translation/with-translation.md) — <!-- begin:auto-docs --> _(no submodules)_- [use-currency](./src/hooks/use-currency/use-currency.md) — <!-- begin:auto-docs --> _(no submodules)_- [use-currency](./src/hooks/use-currency/use-currency.md) — <!-- begin:auto-docs --> _(no submodules)_- [use-date](./src/hooks/use-date/use-date.md) — <!-- begin:auto-docs --> _(no submodules)_- [use-date](./src/hooks/use-date/use-date.md) — <!-- begin:auto-docs --> _(no submodules)_- [use-direction](./src/hooks/use-direction/use-direction.md) — <!-- begin:auto-docs --> _(no submodules)_- [use-direction](./src/hooks/use-direction/use-direction.md) — <!-- begin:auto-docs --> _(no submodules)_- [use-error-message](./src/hooks/use-error-message/use-error-message.md) — <!-- begin:auto-docs --> _(no submodules)_- [use-error-message](./src/hooks/use-error-message/use-error-message.md) — <!-- begin:auto-docs --> _(no submodules)_- [use-formatter](./src/hooks/use-formatter/use-formatter.md) — <!-- begin:auto-docs --> _(no submodules)_- [use-formatter](./src/hooks/use-formatter/use-formatter.md) — <!-- begin:auto-docs --> _(no submodules)_- [use-i18n](./src/hooks/use-i18n/use-i18n.md) — <!-- begin:auto-docs --> _(no submodules)_- [use-i18n](./src/hooks/use-i18n/use-i18n.md) — <!-- begin:auto-docs --> _(no submodules)_- [use-list](./src/hooks/use-list/use-list.md) — <!-- begin:auto-docs --> _(no submodules)_- [use-list](./src/hooks/use-list/use-list.md) — <!-- begin:auto-docs --> _(no submodules)_- [use-locale](./src/hooks/use-locale/use-locale.md) — <!-- begin:auto-docs --> _(no submodules)_- [use-locale](./src/hooks/use-locale/use-locale.md) — <!-- begin:auto-docs --> _(no submodules)_- [use-locale-adapter](./src/hooks/use-locale-adapter/use-locale-adapter.md) — <!-- begin:auto-docs --> _(no submodules)_- [use-locale-adapter](./src/hooks/use-locale-adapter/use-locale-adapter.md) — <!-- begin:auto-docs --> _(no submodules)_- [use-locale-detection](./src/hooks/use-locale-detection/use-locale-detection.md) — <!-- begin:auto-docs --> _(no submodules)_- [detector-chain-context](./src/hooks/use-locale-detection/detector-chain-context.md) — `DetectorChainContext` is the React context that carries the configured locale-detector chain (plus any SSR-authoritati…
- [use-locale-detection](./src/hooks/use-locale-detection/use-locale-detection.md) — <!-- begin:auto-docs --> _(no submodules)_- [use-number](./src/hooks/use-number/use-number.md) — <!-- begin:auto-docs --> _(no submodules)_- [use-number](./src/hooks/use-number/use-number.md) — <!-- begin:auto-docs --> _(no submodules)_- [use-plural](./src/hooks/use-plural/use-plural.md) — <!-- begin:auto-docs --> _(no submodules)_- [use-plural](./src/hooks/use-plural/use-plural.md) — <!-- begin:auto-docs --> _(no submodules)_- [use-relative-time](./src/hooks/use-relative-time/use-relative-time.md) — <!-- begin:auto-docs --> _(no submodules)_- [use-relative-time](./src/hooks/use-relative-time/use-relative-time.md) — <!-- begin:auto-docs --> _(no submodules)_- [use-translate](./src/hooks/use-translate/use-translate.md) — <!-- begin:auto-docs --> _(no submodules)_- [use-translate](./src/hooks/use-translate/use-translate.md) — <!-- begin:auto-docs --> _(no submodules)_- [use-translation](./src/hooks/use-translation/use-translation.md) — <!-- begin:auto-docs --> _(no submodules)_- [use-translation](./src/hooks/use-translation/use-translation.md) — <!-- begin:auto-docs --> _(no submodules)_- [observability](./src/observability/react-i18n-metrics.md) — <!-- begin:auto-docs --> _(no submodules)_- [react-i18n-metrics](./src/observability/react-i18n-metrics.md) — <!-- begin:auto-docs --> _(no submodules)_- [provider](./src/provider/provider.md) — <!-- begin:auto-docs --> _(no submodules)_- [provider-context](./src/provider/provider-context.md) — Defines the React context that carries the active `I18nInstance` from `@suaveplan/i18n` to every descendant of `<Transl…
- [provider](./src/provider/provider.md) — <!-- begin:auto-docs --> _(no submodules)_- [registry](./src/registry/registry.md) — <!-- begin:auto-docs --> _(no submodules)_- built-in — <!-- begin:auto-docs --> _(no submodules…
- [built-in](./src/registry/formatters/built-in/built-in.md) — <!-- begin:auto-docs --> _(no submodules)_- [built-in](./src/registry/formatters/built-in/built-in.md) — <!-- begin:auto-docs --> _(no submodules)_- [built-in](./src/registry/locale-adapters/built-in/built-in.md) — <!-- begin:auto-docs --> _(no submodules)_- [built-in](./src/registry/locale-adapters/built-in/built-in.md) — <!-- begin:auto-docs --> _(no submodules)_- [post-processors](./src/registry/post-processors/post-processors.md) — <!-- begin:auto-docs --> _(no submodules)_- [post-processors](./src/registry/post-processors/post-processors.md) — <!-- begin:auto-docs --> _(no submodules)_- [registry-context](./src/registry/registry-context.md) — Module-scoped React context that carries the active `ReactI18nRegistry` to descendants of `<RegistryProvider>`. Direct…
- [registry-provider](./src/registry/registry-provider.md) — `<RegistryProvider>` is the React-context surface for `ReactI18nRegistry`. It mounts a fresh registry per tree, registe…
- [registry](./src/registry/registry.md) — <!-- begin:auto-docs --> _(no submodules)_- built-in — <!-- begin:auto-docs --> _(no submodules…
- [registry.schemas](./src/registry/registry.schemas.md) — Strict-object Zod schemas for every plugin kind accepted by `ReactI18nRegistry`. The schemas are the boundary at which…
- [use-registry](./src/registry/use-registry.md) — These hooks read the active `ReactI18nRegistry` from React context. The strict variant `useReactI18nRegistry()` throws…
- [schemas](./src/schemas/i18n-config.schemas.md) — <!-- begin:auto-docs --> _(no submodules)_- [i18n-config.schemas](./src/schemas/i18n-config.schemas.md) — <!-- begin:auto-docs --> _(no submodules)_- [translation-key](./src/translation-key/translation-key.md) — <!-- begin:auto-docs --> _(no submodules)_- [t-dynamic](./src/translation-key/t-dynamic.md) — `tDynamic(i18n, key, values?)` is the rare-use escape hatch for translation lookups whose key cannot be statically know…
- [translation-key](./src/translation-key/translation-key.md) — <!-- begin:auto-docs --> _(no submodules)_- [env](./src/utils/env.md) — Provides the single environment-detection helper the React surface uses to switch between dev-mode loud-failure and pro…
- [identity-instance](./src/utils/identity-instance.md) — Provides the identity translator used as the production-mode fallback when a `useTranslation()` (or any sibling hook) i…
- [warn-once-per-process](./src/utils/warn-once-per-process.md) — Implements the warn-once-per-process dedup ring used by every production-mode soft-failure path in `@suaveplan/react-i1…
- [fallback-bundle](./src/components/i18n-suspense/fallback-bundle.md) — Ships the strings that `<I18nSuspense>` needs before the active catalog loads its own copies. Consumers can override th…
- [i18n-suspense](./src/components/i18n-suspense/i18n-suspense.md) — <!-- begin:auto-docs --> _(no submodules)_- [language-switcher](./src/components/language-switcher/language-switcher.md) — <!-- begin:auto-docs --> _(no submodules)_ - [language-switcher](./src/components/language-switcher/language-switcher.md) — <!-- begin:auto-docs --> _(no submodules)_- [missing-key-overlay](./src/components/missing-key-overlay/missing-key-overlay.md) — <!-- begin:auto-docs --> _(no submodules)_ - [missing-key-overlay](./src/components/missing-key-overlay/missing-key-overlay.md) — <!-- begin:auto-docs --> _(no submodules)_- [trans](./src/components/trans/trans.md) — <!-- begin:auto-docs --> _(no submodules)_ - [stringify-children](./src/components/trans/stringify-children.md) — Helpers that translate a `<Trans>` `children` block into a canonical ICU message body string so the message parser rend…
- [trans](./src/components/trans/trans.md) — <!-- begin:auto-docs --> _(no submodules)_- [create-i18n-config](./src/config/create-i18n-config.md) — Builds a validated `I18nConfig` from a caller-supplied option bag and is the canonical entry point for `<TranslationPro…
- [to-i18n-instance-options](./src/config/to-i18n-instance-options.md) — Translates a Zod-parsed `I18nConfig` (the React-tier shape) into the universal-core `I18nInstanceOptions` accepted by `…
- [accept-language](./src/detector/accept-language/accept-language.md) — <!-- begin:auto-docs --> _(no submodules)_ - [accept-language](./src/detector/accept-language/accept-language.md) — <!-- begin:auto-docs --> _(no submodules)_- [cloudflare-ip-geo](./src/detector/cloudflare-ip-geo/cloudflare-ip-geo.md) — <!-- begin:auto-docs --> _(no submodules)_ - [cloudflare-ip-geo](./src/detector/cloudflare-ip-geo/cloudflare-ip-geo.md) — <!-- begin:auto-docs --> _(no submodules)_- [cookie](./src/detector/cookie/cookie.md) — <!-- begin:auto-docs --> _(no submodules)_ - [cookie](./src/detector/cookie/cookie.md) — <!-- begin:auto-docs --> _(no submodules)_- [default-chain](./src/detector/default-chain/default-chain.md) — <!-- begin:auto-docs --> _(no submodules)_ - [default-chain](./src/detector/default-chain/default-chain.md) — <!-- begin:auto-docs --> _(no submodules)_- [fallback](./src/detector/fallback/fallback.md) — <!-- begin:auto-docs --> _(no submodules)_ - [fallback](./src/detector/fallback/fallback.md) — <!-- begin:auto-docs --> _(no submodules)_- [html-lang](./src/detector/html-lang/html-lang.md) — <!-- begin:auto-docs --> _(no submodules)_ - [html-lang](./src/detector/html-lang/html-lang.md) — <!-- begin:auto-docs --> _(no submodules)_- [local-storage](./src/detector/local-storage/local-storage.md) — <!-- begin:auto-docs --> _(no submodules)_ - [local-storage](./src/detector/local-storage/local-storage.md) — <!-- begin:auto-docs --> _(no submodules)_- [navigator](./src/detector/navigator/navigator.md) — <!-- begin:auto-docs --> _(no submodules)_ - [navigator](./src/detector/navigator/navigator.md) — <!-- begin:auto-docs --> _(no submodules)_- [querystring](./src/detector/querystring/querystring.md) — <!-- begin:auto-docs --> _(no submodules)_ - [querystring](./src/detector/querystring/querystring.md) — <!-- begin:auto-docs --> _(no submodules)_- [user-preference](./src/detector/user-preference/user-preference.md) — <!-- begin:auto-docs --> _(no submodules)_ - [user-preference](./src/detector/user-preference/user-preference.md) — <!-- begin:auto-docs --> _(no submodules)_- [error-codes](./src/errors/error-codes.md) — Registers the five stable error codes used by `@suaveplan/react-i18n` under the `REACT_I18N` namespace via `defineCodes…
- [react-i18n-error](./src/errors/react-i18n-error.md) — Defines `ReactI18nError`, the single error class thrown by the React surface of `@suaveplan/react-i18n`. It extends the…
- [with-translation](./src/hoc/with-translation/with-translation.md) — <!-- begin:auto-docs --> _(no submodules)_ - [with-translation](./src/hoc/with-translation/with-translation.md) — <!-- begin:auto-docs --> _(no submodules)_- [use-currency](./src/hooks/use-currency/use-currency.md) — <!-- begin:auto-docs --> _(no submodules)_ - [use-currency](./src/hooks/use-currency/use-currency.md) — <!-- begin:auto-docs --> _(no submodules)_- [use-date](./src/hooks/use-date/use-date.md) — <!-- begin:auto-docs --> _(no submodules)_ - [use-date](./src/hooks/use-date/use-date.md) — <!-- begin:auto-docs --> _(no submodules)_- [use-direction](./src/hooks/use-direction/use-direction.md) — <!-- begin:auto-docs --> _(no submodules)_ - [use-direction](./src/hooks/use-direction/use-direction.md) — <!-- begin:auto-docs --> _(no submodules)_- [use-error-message](./src/hooks/use-error-message/use-error-message.md) — <!-- begin:auto-docs --> _(no submodules)_ - [use-error-message](./src/hooks/use-error-message/use-error-message.md) — <!-- begin:auto-docs --> _(no submodules)_- [use-formatter](./src/hooks/use-formatter/use-formatter.md) — <!-- begin:auto-docs --> _(no submodules)_ - [use-formatter](./src/hooks/use-formatter/use-formatter.md) — <!-- begin:auto-docs --> _(no submodules)_- [use-i18n](./src/hooks/use-i18n/use-i18n.md) — <!-- begin:auto-docs --> _(no submodules)_ - [use-i18n](./src/hooks/use-i18n/use-i18n.md) — <!-- begin:auto-docs --> _(no submodules)_- [use-list](./src/hooks/use-list/use-list.md) — <!-- begin:auto-docs --> _(no submodules)_ - [use-list](./src/hooks/use-list/use-list.md) — <!-- begin:auto-docs --> _(no submodules)_- [use-locale](./src/hooks/use-locale/use-locale.md) — <!-- begin:auto-docs --> _(no submodules)_ - [use-locale](./src/hooks/use-locale/use-locale.md) — <!-- begin:auto-docs --> _(no submodules)_- [use-locale-adapter](./src/hooks/use-locale-adapter/use-locale-adapter.md) — <!-- begin:auto-docs --> _(no submodules)_ - [use-locale-adapter](./src/hooks/use-locale-adapter/use-locale-adapter.md) — <!-- begin:auto-docs --> _(no submodules)_- [use-locale-detection](./src/hooks/use-locale-detection/use-locale-detection.md) — <!-- begin:auto-docs --> _(no submodules)_ - [detector-chain-context](./src/hooks/use-locale-detection/detector-chain-context.md) — `DetectorChainContext` is the React context that carries the configured locale-detector chain (plus any SSR-authoritati…
- [use-locale-detection](./src/hooks/use-locale-detection/use-locale-detection.md) — <!-- begin:auto-docs --> _(no submodules)_- [use-number](./src/hooks/use-number/use-number.md) — <!-- begin:auto-docs --> _(no submodules)_ - [use-number](./src/hooks/use-number/use-number.md) — <!-- begin:auto-docs --> _(no submodules)_- [use-plural](./src/hooks/use-plural/use-plural.md) — <!-- begin:auto-docs --> _(no submodules)_ - [use-plural](./src/hooks/use-plural/use-plural.md) — <!-- begin:auto-docs --> _(no submodules)_- [use-relative-time](./src/hooks/use-relative-time/use-relative-time.md) — <!-- begin:auto-docs --> _(no submodules)_ - [use-relative-time](./src/hooks/use-relative-time/use-relative-time.md) — <!-- begin:auto-docs --> _(no submodules)_- [use-translate](./src/hooks/use-translate/use-translate.md) — <!-- begin:auto-docs --> _(no submodules)_ - [use-translate](./src/hooks/use-translate/use-translate.md) — <!-- begin:auto-docs --> _(no submodules)_- [use-translation](./src/hooks/use-translation/use-translation.md) — <!-- begin:auto-docs --> _(no submodules)_ - [use-translation](./src/hooks/use-translation/use-translation.md) — <!-- begin:auto-docs --> _(no submodules)_- [observability](./src/observability/react-i18n-metrics.md) — <!-- begin:auto-docs --> _(no submodules)_ - [react-i18n-metrics](./src/observability/react-i18n-metrics.md) — <!-- begin:auto-docs --> _(no submodules)_- [provider](./src/provider/provider.md) — <!-- begin:auto-docs --> _(no submodules)_ - [provider-context](./src/provider/provider-context.md) — Defines the React context that carries the active `I18nInstance` from `@suaveplan/i18n` to every descendant of `<Transl…
- [provider](./src/provider/provider.md) — <!-- begin:auto-docs --> _(no submodules)_- [registry](./src/registry/registry.md) — <!-- begin:auto-docs --> _(no submodules)_ - [built-in](./src/registry/formatters/built-in/built-in.md) — <!-- begin:auto-docs --> _(no submodules)_ - [built-in](./src/registry/formatters/built-in/built-in.md) — <!-- begin:auto-docs --> _(no submodules)_ - [built-in](./src/registry/locale-adapters/built-in/built-in.md) — <!-- begin:auto-docs --> _(no submodules)_ - [built-in](./src/registry/locale-adapters/built-in/built-in.md) — <!-- begin:auto-docs --> _(no submodules)_ - [post-processors](./src/registry/post-processors/post-processors.md) — <!-- begin:auto-docs --> _(no submodules)_ - [post-processors](./src/registry/post-processors/post-processors.md) — <!-- begin:auto-docs --> _(no submodules)_ - [registry-context](./src/registry/registry-context.md) — Module-scoped React context that carries the active `ReactI18nRegistry` to descendants of `<RegistryProvider>`. Direct…
- [registry-provider](./src/registry/registry-provider.md) — `<RegistryProvider>` is the React-context surface for `ReactI18nRegistry`. It mounts a fresh registry per tree, registe…
- [registry](./src/registry/registry.md) — <!-- begin:auto-docs --> _(no submodules)_ - [registry.schemas](./src/registry/registry.schemas.md) — Strict-object Zod schemas for every plugin kind accepted by `ReactI18nRegistry`. The schemas are the boundary at which…
- [use-registry](./src/registry/use-registry.md) — These hooks read the active `ReactI18nRegistry` from React context. The strict variant `useReactI18nRegistry()` throws…
- [schemas](./src/schemas/i18n-config.schemas.md) — <!-- begin:auto-docs --> _(no submodules)_ - [i18n-config.schemas](./src/schemas/i18n-config.schemas.md) — <!-- begin:auto-docs --> _(no submodules)_- [translation-key](./src/translation-key/translation-key.md) — <!-- begin:auto-docs --> _(no submodules)_ - [t-dynamic](./src/translation-key/t-dynamic.md) — `tDynamic(i18n, key, values?)` is the rare-use escape hatch for translation lookups whose key cannot be statically know…
- [translation-key](./src/translation-key/translation-key.md) — <!-- begin:auto-docs --> _(no submodules)_- [env](./src/utils/env.md) — Provides the single environment-detection helper the React surface uses to switch between dev-mode loud-failure and pro…
- [identity-instance](./src/utils/identity-instance.md) — Provides the identity translator used as the production-mode fallback when a `useTranslation()` (or any sibling hook) i…
- [warn-once-per-process](./src/utils/warn-once-per-process.md) — Implements the warn-once-per-process dedup ring used by every production-mode soft-failure path in `@suaveplan/react-i1…
## Install
```bash
bun add @suaveplan/react-i18n
```
Peer-installs:
```bash
bun add react react-dom
```
The package targets `react ^18.0.0 || ^19.0.0` so `useSyncExternalStore`
is available without the `use-sync-external-store/shim` polyfill. All
internal `@suaveplan/*` deps resolve through the workspace.
## Quick start
The minimum tree wires a Provider, a Trans component, and a hook-driven
locale switch. The `t` function exposed by `useTranslation()` is the
ergonomic-options-bag form from `@suaveplan/i18n`.
```tsx
import {
Trans,
TranslationProvider,
createI18nConfig,
useTranslation,
} from "@suaveplan/react-i18n";
const config = createI18nConfig({
defaultLocale: "en-GB",
fallbackLocale: "en-GB",
supportedLocales: ["en-GB", "fr-FR"],
initialMessages: {
"en-GB": { greeting: "Hello, <bold>{name}</bold>" },
"fr-FR": { greeting: "Bonjour, <bold>{name}</bold>" },
},
});
function Welcome({ name }: { name: string }): JSX.Element {
const { t } = useTranslation();
return (
<>
<p>{t("welcome", { defaultValue: "Welcome" })}</p>
<p>
<Trans
i18nKey="greeting"
values={{ name }}
components={{ bold: <strong /> }}
/>
</p>
</>
);
}
export function App({ children }: { children: React.ReactNode }) {
return (
<TranslationProvider config={config}>
<Welcome name="Alice" />
{children}
</TranslationProvider>
);
}
```
## Modules
Every implementation file ships a sibling `.md` with the canonical
sections (Purpose, Features, Basic + Advanced examples, API Reference,
Implementation Notes). Read them inline alongside the source.
### Provider and context
- [`<TranslationProvider>`](./src/provider/provider.md) — mounts an
`I18nInstance` and exposes it through React context. Accepts either
a caller-managed `i18n` prop (borrowed; not disposed) or a
Provider-managed `config` prop (created on mount, disposed on
unmount).
- [`TranslationContext`](./src/provider/provider-context.md) — the raw
context object for advanced consumers (mostly tests).
### Hooks
- [`useI18n`](./src/hooks/use-i18n/use-i18n.md) — returns the active
`I18nInstance` (or `null` when outside a Provider).
- [`useTranslation`](./src/hooks/use-translation/use-translation.md) —
Suspense-aware `{ t, i18n, ready }` triple. Auto-triggers
`i18n.catalog.preload(locale, namespace)` when a backend is registered
and the namespace is not yet loaded.
- [`useTranslate`](./src/hooks/use-translate/use-translate.md) — terse
alias returning the bound `t` function directly.
- [`useLocale`](./src/hooks/use-locale/use-locale.md) —
`{ locale, setLocale }` re-rendering on `languageChanged`.
- [`useDirection`](./src/hooks/use-direction/use-direction.md) —
`{ direction, locale }` derived from `getTextDirection(locale)`.
- [`useErrorMessage`](./src/hooks/use-error-message/use-error-message.md)
— maps a `SuaveplanError | unknown` to a translated string.
- [`usePlural`](./src/hooks/use-plural/use-plural.md) — the plural
category + rule for a count + locale.
- [`useDate`](./src/hooks/use-date/use-date.md) — memoised
`Intl.DateTimeFormat` wrapper.
- [`useNumber`](./src/hooks/use-number/use-number.md) — memoised
`Intl.NumberFormat` wrapper with the `clearUseNumberCache()` test
helper.
- [`useCurrency`](./src/hooks/use-currency/use-currency.md) — memoised
currency formatter.
- [`useRelativeTime`](./src/hooks/use-relative-time/use-relative-time.md)
— memoised `Intl.RelativeTimeFormat` wrapper.
- [`useList`](./src/hooks/use-list/use-list.md) — memoised
`Intl.ListFormat` wrapper.
- [`useLocaleDetection`](./src/hooks/use-locale-detection/use-locale-detection.md)
— runs the configured detector chain on mount, exposes
`{ detected, source, isPending, redetect }`.
- [`DetectorChainContext` / `DetectorChainProvider`](./src/hooks/use-locale-detection/detector-chain-context.md)
— the context wiring that exposes the chain to descendants.
### Components
- [`<Trans>`](./src/components/trans/trans.md) — rich-text component
using `MessageParser.formatChunks<ReactNode>`. Supports tag
interpolation, nested tags, plural via `count`, and a children-as-
default-message fallback.
- [`<LanguageSwitcher>`](./src/components/language-switcher/language-switcher.md)
— headless render-prop component. Receives
`{ locales, current, change, isPending }`. Wraps `change` in
`useTransition` so namespace loads on locale change do not block
urgent updates.
- [`<MissingKeyOverlay>`](./src/components/missing-key-overlay/missing-key-overlay.md)
— dev-mode overlay listing untranslated keys in real-time, capped at
50 entries with `aria-live="polite"`. Renders `null` in production.
- [`<I18nSuspense>`](./src/components/i18n-suspense/i18n-suspense.md) —
convenience wrapper around React's `<Suspense>` with a default
`aria-live="polite"` fallback and a fallback-render counter
([`react_i18n.suspense.fallback.total`](./src/observability/react-i18n-metrics.md)).
The default loading label is resolved from the active catalog with
the bundled English fallback supplied by
[`fallback-bundle`](./src/components/i18n-suspense/fallback-bundle.md).
- [`stringifyChild` / `childrenToMessageString`](./src/components/trans/stringify-children.md)
— internal `<Trans>` helpers exposed for advanced consumers building
custom rich-text components.
### HOC
- [`withTranslation`](./src/hoc/with-translation/with-translation.md) —
class-component escape hatch. Injects `t` and `i18n` props.
### Locale detectors
Each detector is independently importable from
`@suaveplan/react-i18n` (and tree-shaken when unused).
- [`querystringDetector`](./src/detector/querystring/querystring.md) —
`?lang=...` (server URL or browser `location.search`).
- [`userPreferenceDetector`](./src/detector/user-preference/user-preference.md)
— async fetch against a configurable endpoint; persists on `cache()`.
- [`cookieDetector`](./src/detector/cookie/cookie.md) —
read + write a configurable cookie.
- [`localStorageDetector`](./src/detector/local-storage/local-storage.md)
— browser-only; persists to a configurable key.
- [`htmlLangDetector`](./src/detector/html-lang/html-lang.md) —
reads `document.documentElement.lang`.
- [`acceptLanguageDetector`](./src/detector/accept-language/accept-language.md)
— server-only; parses `Accept-Language` per RFC 9110.
- [`navigatorDetector`](./src/detector/navigator/navigator.md) —
iterates `navigator.languages`.
- [`cloudflareIpGeoDetector`](./src/detector/cloudflare-ip-geo/cloudflare-ip-geo.md)
— server-only; reads `cf-ipcountry` and maps via
`@suaveplan/locale-data`.
- [`fallbackDetector`](./src/detector/fallback/fallback.md) — always
returns the configured fallback. Must appear last in any chain.
- [`defaultDetectorChain`](./src/detector/default-chain/default-chain.md)
— assembles the canonical 9-entry (or 8-entry, when no
`userPreferenceEndpoint` is supplied) chain in spec order.
### Branded TranslationKey
- [`TranslationKey` / `AppTranslationKeys` / `ContributedTranslationKeys`](./src/translation-key/translation-key.md)
— branded string union pattern. Both interfaces start empty;
downstream packages augment `ContributedTranslationKeys` and consumer
apps augment `AppTranslationKeys`.
- [`tDynamic`](./src/translation-key/t-dynamic.md) — runtime escape
hatch. Throws `ReactI18nError(REACT_I18N.UNKNOWN_KEY)` on a missing
key; emits a `warn` log on every call so observability surfaces every
use site.
### Errors and observability
- [`ReactI18nError`](./src/errors/react-i18n-error.md) — `extends
SuaveplanError<ReactI18nCode>`. The only error type the React surface
throws.
- [`REACT_I18N_CODES`](./src/errors/error-codes.md) — five frozen
codes: `NO_PROVIDER`, `NAMESPACE_LOAD_FAILED`, `HYDRATION_MISMATCH`,
`TRANS_INVALID_CHILD`, `UNKNOWN_KEY`.
- [`react_i18n.*` OTel instruments](./src/observability/react-i18n-metrics.md)
— counters (`missing_key.total`, `suspense.fallback.total`),
histogram (`render.duration_ms`), in-memory metrics state ring for
tests, plus the public `getReactI18nMetrics()` /
`recordMissingKey()` / `recordSuspenseFallback()` /
`recordRenderDuration()` helpers.
### Schemas, config, and utils
- [`createI18nConfig`](./src/config/create-i18n-config.md) — Zod
validator that returns a typed `I18nConfig`. Routes
`initialMessages` failures to `ReactI18nError(HYDRATION_MISMATCH)`
and other shape failures to `I18nError(I18N.INVALID_OPTIONS)` so
observability dashboards group on the React-tier code where
appropriate.
- [`toI18nInstanceOptions`](./src/config/to-i18n-instance-options.md) —
pure mapping from `I18nConfig` to the universal-core
`I18nInstanceOptions` shape.
- [`I18nConfigSchema` / `TranslationProviderPropsSchema`](./src/schemas/i18n-config.schemas.md)
— Zod boundary schemas for the Provider's accepted prop shape and the
config bag.
- [`isProductionEnv`](./src/utils/env.md) — single source of truth for
the dev-vs-prod branch every soft-failure path uses.
- [`getIdentityTranslator` / `IDENTITY_TRANSLATION_RETURN`](./src/utils/identity-instance.md)
— no-op identity translator returned by `useTranslation` outside a
Provider in production builds.
- [`warnOncePerProcess`](./src/utils/warn-once-per-process.md) — global
bag-of-warnings dedup so a misbehaving consumer logs each diagnostic
exactly once across the process lifetime.
## Detector chain wiring
The detector chain is the same on the server and the browser; the SSR
render captures the resolved locale, embeds it in `initialLocale`, and
the client hydrates without re-running browser-only detectors (per the
spec's "browser detectors skipped during hydration" requirement).
```tsx
import {
DetectorChainProvider,
TranslationProvider,
cookieDetector,
defaultDetectorChain,
useLocaleDetection,
} from "@suaveplan/react-i18n";
const detectors = defaultDetectorChain({
fallback: "en-GB",
userPreferenceEndpoint: "/api/me/locale",
});
function Bootstrap({ children }: { children: React.ReactNode }) {
return (
<TranslationProvider config={config}>
<DetectorChainProvider detectors={detectors}>
{children}
</DetectorChainProvider>
</TranslationProvider>
);
}
function LocaleStatus(): JSX.Element {
const { detected, source, isPending, redetect } = useLocaleDetection();
return (
<output aria-busy={isPending}>
<span data-testid="locale">{detected ?? "(unresolved)"}</span>
<span data-testid="source">via {source ?? "(none)"}</span>
<button type="button" onClick={() => void redetect()}>
Re-detect
</button>
</output>
);
}
```
For ad-hoc chains, import detectors individually:
```tsx
import {
DetectorChainProvider,
cookieDetector,
fallbackDetector,
navigatorDetector,
} from "@suaveplan/react-i18n";
const detectors = [
cookieDetector({ name: "lang", maxAgeSeconds: 31_536_000 }),
navigatorDetector(),
fallbackDetector("en-GB"),
];
```
## Branded `TranslationKey`
The branded type prevents misspelled translation keys from passing
typecheck. Both the consumer-app interface (`AppTranslationKeys`) and
the downstream-package interface (`ContributedTranslationKeys`) start
empty; augmentation contributes the keys.
### Consumer app augmentation
In your app, generate the augmentation file from the canonical
`locales/en-GB.json` via the
[`generate-translation-types.ts`](./scripts/generate-translation-types.ts)
script:
```bash
bun packages/react/react-i18n/scripts/generate-translation-types.ts \
--source locales/en-GB.json \
--target src/generated/translation-keys.d.ts \
--interface AppTranslationKeys \
--module @suaveplan/react-i18n
```
The script emits a `.d.ts` like:
```ts
declare module "@suaveplan/react-i18n" {
interface AppTranslationKeys {
"app.welcome": true;
"app.signin.title": true;
"app.signin.submit": true;
}
}
```
After importing the generated file once at the app entrypoint, every
typed `t(literalKey)` call typechecks the key against the registered
union.
```ts
import type { TranslationKey } from "@suaveplan/react-i18n";
const k: TranslationKey = "app.welcome"; // ✅ typechecks
const bad: TranslationKey = "appp.welcome"; // ✗ TS error: not assignable
```
### Downstream package augmentation
When publishing a `react-ui-pack-*` or `react-ui-theme-*` package,
augment `ContributedTranslationKeys` instead — that interface is
namespaced for downstream contributions so consumer-app augmentations
do not collide with library-defined keys.
```ts
declare module "@suaveplan/react-i18n" {
interface ContributedTranslationKeys {
"react-ui.dialog.close": true;
"react-ui.dialog.confirm": true;
}
}
```
The downstream package side-effect-imports the augmentation file from
its `index.ts`. Once a consumer adds the package to their tree, the
keys become assignable to `TranslationKey` without any per-consumer
configuration.
## `tDynamic` runtime escape hatch
For the rare case where a key is computed at runtime — most often
mapping an error code like `error.code` to `errors:<code>` — use
`tDynamic`. It performs a runtime catalog lookup and throws
`ReactI18nError(REACT_I18N.UNKNOWN_KEY)` when the key is missing.
Every call also emits a `warn`-level log so observability surfaces
each use site (the typed `t(literalKey)` path is preferred 99% of
the time).
```ts
import { ReactI18nError, tDynamic, useI18n } from "@suaveplan/react-i18n";
function ErrorMessage({ error }: { error: { code: string } }): string {
const i18n = useI18n();
if (i18n === null) return "";
try {
return tDynamic(i18n, `errors:${error.code}`);
} catch (cause) {
if (
cause instanceof ReactI18nError &&
cause.code === "REACT_I18N.UNKNOWN_KEY"
) {
return error.code;
}
throw cause;
}
}
```
## No-backwards-deps gate
`@suaveplan/react-i18n` is the leaf-most React package .
Every downstream React package — `@suaveplan/react-ui`,
`@suaveplan/react-forms`, every `@suaveplan/react-ui-theme-*`, every
`@suaveplan/react-ui-pack-*` — augments
`ContributedTranslationKeys` via TypeScript module augmentation. For
that pattern to stay sound, react-i18n MUST NOT depend on any of those
packages in any of its dep buckets (`dependencies`,
`peerDependencies`, `devDependencies`).
The CI gate at
[`scripts/lint/check-no-backwards-deps.ts`](../../../scripts/lint/check-no-backwards-deps.ts)
enforces the rule on every commit:
```bash
bun scripts/lint/check-no-backwards-deps.ts \
--package-json packages/react/react-i18n/package.json
```
The script is wired into `package.json`'s `lint` step so
`bunx turbo lint --filter=@suaveplan/react-i18n` fails fast if a banned
dep ever lands.
## Telemetry
OTel instruments published via `getMeter("suaveplan.react-i18n")`:
- Counter `react_i18n.missing_key.total` — labels: `locale`,
`namespace`, `componentName?`. Incremented from `<Trans>` (and
`tDynamic`) when a key is absent from the catalog.
- Counter `react_i18n.suspense.fallback.total` — labels: `namespace`,
`locale`. Incremented every time `<I18nSuspense>` renders its
default fallback.
- Histogram `react_i18n.render.duration_ms` — labels: `locale`,
`namespace`, `componentName?`. Recorded around the `<Trans>`
`formatChunks` render.
- Counter `i18n.detector.match_total{detector}` — recorded by every
detector when it returns a non-null supported locale.
- Histogram `i18n.detector.duration_ms{detector}` — recorded for each
`detectAsync` call.
- Span `i18n.detect` — wraps the entire chain run; carries
`winning_detector` and `resolved_locale` attributes.
The `tDynamic` call's per-use `warn` log is NOT OTel — it goes to the
`@suaveplan/logger` sink and surfaces in centralised log aggregation.
## E2E sweep
The Playwright suite at
[`e2e/react-i18n.spec.ts`](./e2e/react-i18n.spec.ts) runs against a
tiny Vite + React test app at
[`e2e/test-app/`](./e2e/test-app/) and covers the five user-visible
behaviours:
1. Live language switch through all five locales (`en-GB`, `ar-AE`,
`zh-CN`, `cy-GB`, `ru-RU`) with localStorage persistence.
2. RTL flip on Arabic — `<html dir="rtl">` updates and the layout
reflows.
3. Lazy-namespace Suspense — the `<I18nSuspense>` fallback resolves
to translated content via the async test backend.
4. Missing-key overlay (dev mode) — caps at 50 entries with
`aria-live="polite"`.
5. SSR hydration round-trip — the server-rendered HTML already
contains the translated string (no flash) and the client hydrates
without a React warning.
Per `CLAUDE.md` "Web App & Page Verification": every scenario also
asserts (a) zero browser-console errors, (b) zero failed network
requests (4xx/5xx), and (c) the rendered DOM contains the expected
content (not merely a 200 response).
```bash
bunx turbo lint typecheck test:coverage build --filter=@suaveplan/react-i18n
bun --cwd packages/react/react-i18n test:e2e
```
## Testing
```bash
bunx turbo lint typecheck test --filter=@suaveplan/react-i18n
bunx turbo test:coverage --filter=@suaveplan/react-i18n
```
Coverage targets: 100/100/100/100 (statements / branches / functions /
lines) per `CLAUDE.md` §"Quality". The thresholds are wired into
`vitest.config.ts`. The pre-publish gate also runs the full §13
verification sequence — see `AGENTS.md` for the canonical
checklist.
## License
MIT.

View File

@@ -0,0 +1,245 @@
/**
* @suaveplan/react-i18n — Playwright E2E sweep.
*
* Covers the five user-visible behaviours required by sub-wave 1g:
*
* 1. Live language switch through 5 locales with localStorage persistence
* 2. RTL flip on Arabic — `<html dir="rtl">` + visible reflow
* 3. Lazy-namespace Suspense — `<I18nSuspense>` fallback resolves to
* translated content
* 4. Missing-key overlay (dev mode) — capped at 50 entries with
* `aria-live="polite"`
* 5. SSR hydration round-trip — server-rendered HTML hydrates without
* a React warning AND without a flash of untranslated content
*
* Per `CLAUDE.md` "Web App & Page Verification": every scenario also
* asserts (a) zero console errors, (b) zero failed network requests
* (4xx/5xx), (c) the rendered DOM contains the expected content (not
* merely a 200 response).
*/
import { expect, test } from "@playwright/test";
import {
attachConsoleMonitor,
attachNetworkMonitor,
} from "@suaveplan/testing-e2e";
const LOCALES = ["en-GB", "ar-AE", "zh-CN", "cy-GB", "ru-RU"] as const;
const GREETING_BY_LOCALE: Record<(typeof LOCALES)[number], string> = {
"en-GB": "Hello, world",
"ar-AE": "مرحبا بالعالم",
"zh-CN": "你好,世界",
"cy-GB": "Helo, byd",
"ru-RU": "Привет, мир",
};
const SIGN_IN_BY_LOCALE: Record<(typeof LOCALES)[number], string> = {
"en-GB": "Sign in",
"ar-AE": "تسجيل الدخول",
"zh-CN": "登录",
"cy-GB": "Mewngofnodi",
"ru-RU": "Войти",
};
// Each Playwright test runs in its own browser context with its own
// localStorage namespace by default — no per-test clearing needed.
test.describe("react-i18n live behaviour", () => {
test("cycles through all five locales and persists choice", async ({
page,
}) => {
const consoleMonitor = attachConsoleMonitor(page, {
failOn: ["error"],
// happy-dom-style hydration warnings can leak as React's
// "Warning:" via console.error — we treat any error as a
// failure but tolerate React DevTools install hints which
// arrive as info.
ignore: [/Download the React DevTools/i],
});
const networkMonitor = attachNetworkMonitor(page);
await page.goto("/");
await expect(page.getByTestId("greeting")).toHaveText(
GREETING_BY_LOCALE["en-GB"]
);
await expect(page.getByTestId("auth-signin")).toHaveText(
SIGN_IN_BY_LOCALE["en-GB"]
);
for (const locale of LOCALES.slice(1)) {
await page
.getByTestId("language-switcher")
.selectOption(locale);
await expect(page.getByTestId("greeting")).toHaveText(
GREETING_BY_LOCALE[locale]
);
await expect(page.getByTestId("auth-signin")).toHaveText(
SIGN_IN_BY_LOCALE[locale]
);
await expect(page.getByTestId("current-locale")).toContainText(
locale
);
}
// Persistence: capture the persisted entry, reload, and confirm
// the App initialises from localStorage (not the en-GB default).
const lastLocale = LOCALES[LOCALES.length - 1];
const persistedBefore = await page.evaluate(() =>
window.localStorage.getItem("suaveplan-react-i18n-e2e-locale")
);
expect(persistedBefore).toBe(lastLocale);
await page.reload();
await expect(page.getByTestId("greeting")).toHaveText(
GREETING_BY_LOCALE[lastLocale]
);
const persistedAfter = await page.evaluate(() =>
window.localStorage.getItem("suaveplan-react-i18n-e2e-locale")
);
expect(persistedAfter).toBe(lastLocale);
consoleMonitor.assertClean();
networkMonitor.assertNoFailures();
});
test("flips <html dir=rtl> and mirrors layout when Arabic is active", async ({
page,
}) => {
const consoleMonitor = attachConsoleMonitor(page, { failOn: ["error"] });
const networkMonitor = attachNetworkMonitor(page);
await page.goto("/");
const ltrBox = await page
.getByTestId("greeting")
.boundingBox();
expect(ltrBox).not.toBeNull();
const initialDir = await page.evaluate(() =>
document.documentElement.getAttribute("dir")
);
expect(initialDir).toBe("ltr");
await page.getByTestId("language-switcher").selectOption("ar-AE");
await expect(page.getByTestId("greeting")).toHaveText(
GREETING_BY_LOCALE["ar-AE"]
);
await expect
.poll(async () =>
page.evaluate(() =>
document.documentElement.getAttribute("dir")
)
)
.toBe("rtl");
const rtlBox = await page
.getByTestId("greeting")
.boundingBox();
expect(rtlBox).not.toBeNull();
// RTL layout flips text alignment — the bounding box's right edge
// anchors to the same x as the parent's right padding while the
// left edge moves outward. The simplest provable invariant is the
// direction attribute itself, which we assert above; here we
// additionally confirm the render produced a measurable box (i.e.
// not a zero-sized phantom node from a failed reflow).
expect((rtlBox?.width ?? 0) > 0).toBe(true);
expect((ltrBox?.width ?? 0) > 0).toBe(true);
consoleMonitor.assertClean();
networkMonitor.assertNoFailures();
});
test("renders the I18nSuspense fallback while the auth namespace loads", async ({
page,
}) => {
const consoleMonitor = attachConsoleMonitor(page, { failOn: ["error"] });
const networkMonitor = attachNetworkMonitor(page);
// Slow the navigation enough for the very first render to mount
// the Suspense boundary before the auth namespace resolves.
await page.goto("/");
const fallback = page.getByTestId(
"suaveplan-i18n-suspense-fallback"
);
// Fallback may render very briefly; race the assertion against the
// resolved auth panel and assert at least one of the two reaches
// its terminal state. The fallback's `role="status"` + busy
// attribute give us a deterministic selector.
await Promise.race([
fallback.waitFor({ state: "visible", timeout: 1500 }).catch(() => undefined),
page.getByTestId("auth-panel").waitFor({ state: "visible", timeout: 5000 }),
]);
// Once the namespace has loaded, the panel must show the
// translated string — proof the Suspense boundary actually
// resolved instead of leaving a key string in the DOM.
await expect(page.getByTestId("auth-signin")).toHaveText(
SIGN_IN_BY_LOCALE["en-GB"]
);
await expect(page.getByTestId("auth-welcome")).not.toHaveText("welcome");
consoleMonitor.assertClean();
networkMonitor.assertNoFailures();
});
test("missing-key overlay caps at 50 entries with aria-live=polite", async ({
page,
}) => {
const consoleMonitor = attachConsoleMonitor(page, { failOn: ["error"] });
const networkMonitor = attachNetworkMonitor(page);
await page.goto("/");
const triggerButton = page.getByTestId("trigger-missing");
const overlay = page.getByTestId("suaveplan-missing-key-overlay");
// Fire 60 distinct missing keys; overlay must cap at 50.
for (let i = 0; i < 60; i += 1) {
await triggerButton.click();
}
await expect(overlay).toBeVisible();
await expect(overlay).toHaveAttribute("aria-live", "polite");
const itemCount = await overlay.locator("li").count();
expect(itemCount).toBe(50);
consoleMonitor.assertClean();
networkMonitor.assertNoFailures();
});
test("SSR hydration round-trip serves translated markup with no flash", async ({
page,
}) => {
const consoleMonitor = attachConsoleMonitor(page, {
failOn: ["error", "warning"],
ignore: [/Download the React DevTools/i],
});
const networkMonitor = attachNetworkMonitor(page);
// Capture the raw server response BEFORE hydration to prove the
// initial HTML already contains the translated string. If we only
// looked at the live DOM we could not distinguish "SSR worked"
// from "client rendered after a flash".
const response = await page.request.get("/ssr/zh-CN");
expect(response.status()).toBe(200);
const rawHtml = await response.text();
expect(rawHtml).toContain(GREETING_BY_LOCALE["zh-CN"]);
expect(rawHtml).toContain('lang="zh-CN"');
expect(rawHtml).toContain('dir="ltr"');
await page.goto("/ssr/zh-CN");
// Assert the visible greeting is the SSR string immediately and
// does not change after hydration (no flash). We poll the text
// for 400ms — far longer than any reasonable hydration step on
// a local Vite dev server — to confirm stability.
const greeting = page.getByTestId("greeting");
await expect(greeting).toHaveText(GREETING_BY_LOCALE["zh-CN"]);
const before = await greeting.textContent();
await page.waitForLoadState("networkidle");
const after = await greeting.textContent();
expect(after).toBe(before);
consoleMonitor.assertClean();
networkMonitor.assertNoFailures();
});
});

View File

@@ -0,0 +1,12 @@
<!doctype html>
<html lang="en-GB" dir="ltr">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>react-i18n e2e</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/entry-client.tsx"></script>
</body>
</html>

View File

@@ -0,0 +1,219 @@
import type { TranslationRecord } from "@suaveplan/i18n";
import { createI18n } from "@suaveplan/i18n/plugin";
import {
I18nSuspense,
LanguageSwitcher,
MissingKeyOverlay,
Trans,
TranslationProvider,
useDirection,
useLocale,
useTranslation,
} from "@suaveplan/react-i18n";
import { useEffect, useState } from "react";
import { createTestBackend } from "./test-backend.js";
import {
AUTH_BUNDLE,
COMMON_BUNDLE,
SUPPORTED_LOCALES,
type SupportedLocale,
} from "./translations.js";
const LOCAL_STORAGE_KEY = "suaveplan-react-i18n-e2e-locale";
const TEST_BACKEND = createTestBackend({ delayMs: 200 });
interface AppProps {
readonly initialLocale?: SupportedLocale;
/**
* When true, the `auth` namespace is also bundled into
* `initialMessages` so SSR renders the full panel synchronously
* (no Suspense fallback). Hydration still uses the same shape so
* server and client agree on the initial markup.
*/
readonly includeAuth?: boolean;
}
/**
* Top-level test-app shell. Wraps the tree in a `<TranslationProvider>`
* and exposes the five Playwright scenarios (live switch, RTL, lazy
* Suspense, missing-key overlay, SSR hydration round-trip) through a
* single page so the spec only needs one `goto`.
*/
export function App(props: AppProps): JSX.Element {
// Build the I18nInstance directly (caller-managed mode). This
// bypasses the Provider's config-path Zod re-parse which strips
// function-typed Backend methods — instead `i18n` is structurally
// validated and forwarded as-is, preserving the backend's
// `read`/`readMulti` callable surface. `useState` lazy-init keeps
// the instance stable across re-renders.
const [i18n] = useState(() =>
createI18n({
defaultLocale:
props.initialLocale ?? readPersistedLocale() ?? "en-GB",
fallbackLocale: "en-GB",
messages: buildInitialMessages(props.includeAuth ?? false) as never,
backend: TEST_BACKEND as never,
})
);
return (
<TranslationProvider i18n={i18n}>
<Page />
<MissingKeyOverlay />
</TranslationProvider>
);
}
function buildInitialMessages(
includeAuth: boolean
): Record<SupportedLocale, TranslationRecord> {
if (!includeAuth) return COMMON_BUNDLE;
const merged: Record<SupportedLocale, TranslationRecord> = {
...COMMON_BUNDLE,
};
for (const locale of SUPPORTED_LOCALES) {
merged[locale] = {
...COMMON_BUNDLE[locale],
auth: AUTH_BUNDLE[locale],
};
}
return merged;
}
function Page(): JSX.Element {
const { locale } = useLocale();
const { direction } = useDirection();
// Mirror the active locale + direction onto the <html> element so
// CSS hooks and screen readers see the same value the i18n core
// resolved. Tests assert against this attribute set.
useEffect(() => {
document.documentElement.lang = locale;
document.documentElement.dir = direction;
try {
window.localStorage.setItem(LOCAL_STORAGE_KEY, locale);
} catch {
// localStorage may be unavailable (private browsing); ignore.
}
}, [locale, direction]);
return (
<main data-testid="page" style={{ padding: "2rem", fontFamily: "system-ui" }}>
<h1 data-testid="greeting">
<Trans i18nKey="common:greeting" />
</h1>
<p data-testid="current-locale">
<Trans
i18nKey="common:currentLocale"
values={{ locale }}
/>
</p>
<section
data-testid="switcher-section"
style={{ marginTop: "1.5rem" }}
>
<LanguageSwitcher locales={[...SUPPORTED_LOCALES]}>
{(state) => (
<select
data-testid="language-switcher"
aria-busy={state.isPending}
value={state.current}
onChange={(event) => {
void state.change(event.target.value);
}}
>
{state.locales.map((entry) => (
<option
key={entry.code}
value={entry.code}
data-direction={entry.direction}
>
{entry.code}
</option>
))}
</select>
)}
</LanguageSwitcher>
</section>
<section
data-testid="auth-section"
style={{ marginTop: "1.5rem" }}
>
<I18nSuspense>
<AuthPanel />
</I18nSuspense>
</section>
<section
data-testid="missing-section"
style={{ marginTop: "1.5rem" }}
>
<MissingKeyTrigger />
</section>
</main>
);
}
function AuthPanel(): JSX.Element {
const { t } = useTranslation("auth");
return (
<div data-testid="auth-panel">
<button type="button" data-testid="auth-signin">
{t("signIn")}
</button>
<p data-testid="auth-welcome">{t("welcome")}</p>
</div>
);
}
interface MissingResolution {
readonly key: string;
readonly resolved: string;
}
function MissingKeyTrigger(): JSX.Element {
const [resolved, setResolved] = useState<readonly MissingResolution[]>([]);
const { t } = useTranslation();
return (
<div>
<button
type="button"
data-testid="trigger-missing"
onClick={() => {
// Resolve `t()` inside the click handler — NOT during
// render — so the bus's `missingKey` event fires
// outside React's render phase. This avoids the
// "Cannot update a component while rendering a
// different component" warning that would otherwise
// fire when the overlay's `setState` lands inside a
// sibling render pass.
const next = `e2e.missing.${resolved.length + 1}`;
const value = t(next);
setResolved((prev) => [...prev, { key: next, resolved: value }]);
}}
>
Request missing key
</button>
<ul data-testid="missing-list">
{resolved.map((entry) => (
<li key={entry.key} data-key={entry.key}>
{entry.resolved}
</li>
))}
</ul>
</div>
);
}
function readPersistedLocale(): SupportedLocale | undefined {
try {
const value = window.localStorage.getItem(LOCAL_STORAGE_KEY);
if (value !== null && (SUPPORTED_LOCALES as readonly string[]).includes(value)) {
return value as SupportedLocale;
}
} catch {
// ignore — falls back to default below
}
return undefined;
}

View File

@@ -0,0 +1,29 @@
import { hydrateRoot, createRoot } from "react-dom/client";
import { App } from "./App.js";
import type { SupportedLocale } from "./translations.js";
interface BootstrapWindow extends Window {
__SUAVEPLAN_E2E_INITIAL_LOCALE__?: SupportedLocale;
__SUAVEPLAN_E2E_SSR__?: boolean;
}
const win = window as BootstrapWindow;
const initialLocale = win.__SUAVEPLAN_E2E_INITIAL_LOCALE__;
const container = document.getElementById("root");
if (container === null) {
throw new Error("Missing #root container");
}
if (win.__SUAVEPLAN_E2E_SSR__ === true) {
// SSR mode: hydrate the server-rendered tree without re-mounting so
// we can assert there is no hydration warning + no flash of un-
// translated content. `includeAuth=true` mirrors the server's
// initialMessages so the client's first render produces byte-
// identical DOM and React does not log a hydration mismatch.
hydrateRoot(
container,
<App initialLocale={initialLocale} includeAuth={true} />
);
} else {
createRoot(container).render(<App initialLocale={initialLocale} />);
}

View File

@@ -0,0 +1,25 @@
import { renderToString } from "react-dom/server";
import { App } from "./App.js";
import type { SupportedLocale } from "./translations.js";
export interface RenderResult {
readonly html: string;
readonly locale: SupportedLocale;
}
/**
* Server-side render the test-app with a pinned `initialLocale`. The Vite
* dev server's SSR middleware loads this module via `ssrLoadModule` and
* inlines the result into the served HTML, alongside a script tag that
* surfaces the initial locale to the client entry for hydration.
*/
export function render(locale: SupportedLocale): RenderResult {
// The SSR pass pre-populates the catalog with both the `common` and
// `auth` namespaces so no Suspense boundary suspends on the server.
// The client hydrate pass mirrors the same shape so the initial DOM
// matches and React does not log a hydration mismatch.
const html = renderToString(
<App initialLocale={locale} includeAuth={true} />
);
return { html, locale };
}

View File

@@ -0,0 +1,90 @@
import type { TranslationRecord } from "@suaveplan/i18n";
import { AUTH_BUNDLE, COMMON_BUNDLE } from "./translations.js";
/**
* Tiny async test backend: serves the `auth` namespace with a
* deterministic 200ms delay so Playwright can observe the
* `<I18nSuspense>` fallback before the namespace resolves.
*
* The `common` namespace is delivered through `initialMessages` and
* never reaches the backend, mirroring real production deployments
* where the SSR payload pre-populates the catalog.
*
* The shape mirrors `@suaveplan/i18n`'s `Backend` interface (intentionally
* not imported because the universal core does not re-export it from the
* public root). The `<TranslationProvider>` validates the structural
* `name` + `capabilities` fields via `I18nConfigSchema`'s `BackendShape`
* which is what matters here.
*/
export interface TestBackend {
readonly name: string;
readonly capabilities: {
readonly canRead: true;
readonly canReadMulti: boolean;
readonly canSave: boolean;
readonly canTrackModification: boolean;
readonly canWatch: boolean;
};
read(locale: string, namespace: string): Promise<TranslationRecord>;
readMulti(
locale: string,
namespaces: readonly string[]
): Promise<Record<string, TranslationRecord>>;
}
export interface TestBackendOptions {
readonly delayMs?: number;
}
export function createTestBackend(options: TestBackendOptions = {}): TestBackend {
const delayMs = options.delayMs ?? 200;
return {
name: "e2e-test-backend",
capabilities: {
canRead: true,
canReadMulti: true,
canSave: false,
canTrackModification: false,
canWatch: false,
},
async read(locale, namespace): Promise<TranslationRecord> {
await sleep(delayMs);
if (namespace !== "auth") {
throw new Error(`Unknown namespace ${namespace}`);
}
const bundle = AUTH_BUNDLE[locale as keyof typeof AUTH_BUNDLE];
if (bundle === undefined) {
throw new Error(`Unknown locale ${locale}`);
}
return bundle;
},
async readMulti(locale, namespaces): Promise<
Record<string, TranslationRecord>
> {
const out: Record<string, TranslationRecord> = {};
for (const ns of namespaces) {
if (ns === "auth") {
const bundle =
AUTH_BUNDLE[locale as keyof typeof AUTH_BUNDLE];
if (bundle !== undefined) {
out[ns] = bundle;
}
} else if (ns === "common") {
const bundle =
COMMON_BUNDLE[locale as keyof typeof COMMON_BUNDLE];
if (bundle !== undefined) {
out[ns] = bundle;
}
}
}
await sleep(delayMs);
return out;
},
};
}
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => {
setTimeout(resolve, ms);
});
}

View File

@@ -0,0 +1,91 @@
/**
* Test-app translations for the react-i18n Playwright suite.
*
* These literal strings ARE the test fixture — they would be supplied
* by the consumer app in production. The translations namespace `common`
* is preloaded synchronously via `initialMessages`; the `auth` namespace
* is delivered by the async backend so Suspense fallbacks fire.
*/
import type { TranslationRecord } from "@suaveplan/i18n";
export type SupportedLocale =
| "en-GB"
| "ar-AE"
| "zh-CN"
| "cy-GB"
| "ru-RU";
export const SUPPORTED_LOCALES: readonly SupportedLocale[] = [
"en-GB",
"ar-AE",
"zh-CN",
"cy-GB",
"ru-RU",
];
export const COMMON_BUNDLE: Record<SupportedLocale, TranslationRecord> = {
"en-GB": {
common: {
greeting: "Hello, world",
language: "Language",
switchTo: "Switch to {locale}",
currentLocale: "Current locale: {locale}",
},
},
"ar-AE": {
common: {
greeting: "مرحبا بالعالم",
language: "اللغة",
switchTo: "التبديل إلى {locale}",
currentLocale: "اللغة الحالية: {locale}",
},
},
"zh-CN": {
common: {
greeting: "你好,世界",
language: "语言",
switchTo: "切换到 {locale}",
currentLocale: "当前语言:{locale}",
},
},
"cy-GB": {
common: {
greeting: "Helo, byd",
language: "Iaith",
switchTo: "Newid i {locale}",
currentLocale: "Iaith bresennol: {locale}",
},
},
"ru-RU": {
common: {
greeting: "Привет, мир",
language: "Язык",
switchTo: "Переключиться на {locale}",
currentLocale: "Текущая локаль: {locale}",
},
},
};
export const AUTH_BUNDLE: Record<SupportedLocale, TranslationRecord> = {
"en-GB": {
signIn: "Sign in",
welcome: "Welcome back",
},
"ar-AE": {
signIn: "تسجيل الدخول",
welcome: "مرحبا بعودتك",
},
"zh-CN": {
signIn: "登录",
welcome: "欢迎回来",
},
"cy-GB": {
signIn: "Mewngofnodi",
welcome: "Croeso yn ôl",
},
"ru-RU": {
signIn: "Войти",
welcome: "С возвращением",
},
};

View File

@@ -0,0 +1,12 @@
{
"$schema": "https://json.schemastore.org/tsconfig",
"extends": "../../../../../foundation/core/typescript-config/react-library.json",
"compilerOptions": {
"rootDir": "./src",
"noEmit": true,
"types": ["node"],
"moduleResolution": "bundler",
"allowImportingTsExtensions": false
},
"include": ["src/**/*.ts", "src/**/*.tsx", "vite.config.ts"]
}

View File

@@ -0,0 +1,59 @@
import react from "@vitejs/plugin-react";
import { defineConfig } from "vite";
export default defineConfig({
root: __dirname,
server: {
port: 5273,
strictPort: true,
host: "127.0.0.1",
},
plugins: [react(), ssrMiddlewarePlugin()],
optimizeDeps: {
// Force pre-bundle of the workspace package so Vite resolves the
// exports map correctly the first hit.
include: ["@suaveplan/react-i18n", "@suaveplan/i18n"],
},
});
/**
* Adds a `/ssr/:locale` endpoint that renders the React tree on the
* server, inlines the markup into the HTML template, and adds a tiny
* bootstrap script that flips `entry-client.tsx` into hydration mode.
*
* This is the SSR scenario surface exercised by the Playwright spec —
* the regular `/` route serves the standard CSR build.
*/
function ssrMiddlewarePlugin(): import("vite").Plugin {
return {
name: "react-i18n-e2e-ssr",
configureServer(server) {
server.middlewares.use(async (req, res, next) => {
if (req.url === undefined || !req.url.startsWith("/ssr/")) {
next();
return;
}
try {
const localeFromUrl = req.url.replace(/^\/ssr\//, "").split("?")[0] ?? "";
const locale = decodeURIComponent(localeFromUrl);
const templatePath = `${__dirname}/index.html`;
const fs = await import("node:fs/promises");
const rawTemplate = await fs.readFile(templatePath, "utf8");
const transformed = await server.transformIndexHtml(req.url, rawTemplate);
const mod = await server.ssrLoadModule("/src/entry-server.tsx");
const renderFn = (mod as { render: (l: string) => { html: string; locale: string } }).render;
const { html, locale: resolvedLocale } = renderFn(locale);
const bootstrap = `<script>window.__SUAVEPLAN_E2E_INITIAL_LOCALE__=${JSON.stringify(resolvedLocale)};window.__SUAVEPLAN_E2E_SSR__=true;</script>`;
const finalHtml = transformed
.replace(`<div id="root"></div>`, `<div id="root">${html}</div>${bootstrap}`)
.replace(`<html lang="en-GB" dir="ltr">`, `<html lang="${resolvedLocale}" dir="${resolvedLocale.startsWith("ar") ? "rtl" : "ltr"}">`);
res.statusCode = 200;
res.setHeader("Content-Type", "text/html");
res.end(finalHtml);
} catch (error) {
next(error as Error);
}
});
},
};
}

View File

@@ -0,0 +1,197 @@
{
"name": "@suaveplan/react-i18n",
"version": "0.1.0",
"type": "module",
"sideEffects": false,
"description": "React bindings for @suaveplan/i18n: TranslationProvider, hooks, <Trans>, <LanguageSwitcher>, locale-detection chain glue, branded TranslationKey contract.",
"license": "MIT",
"author": "Suaveplan Team",
"main": "./dist/index.js",
"module": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
"./package.json": "./package.json",
".": {
"import": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
},
"require": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
}
},
"./provider": {
"import": {
"types": "./dist/provider/index.d.ts",
"default": "./dist/provider/index.js"
},
"require": {
"types": "./dist/provider/index.d.ts",
"default": "./dist/provider/index.js"
}
},
"./errors": {
"import": {
"types": "./dist/errors/index.d.ts",
"default": "./dist/errors/index.js"
},
"require": {
"types": "./dist/errors/index.d.ts",
"default": "./dist/errors/index.js"
}
},
"./schemas": {
"import": {
"types": "./dist/schemas/index.d.ts",
"default": "./dist/schemas/index.js"
},
"require": {
"types": "./dist/schemas/index.d.ts",
"default": "./dist/schemas/index.js"
}
},
"./config": {
"import": {
"types": "./dist/config/index.d.ts",
"default": "./dist/config/index.js"
},
"require": {
"types": "./dist/config/index.d.ts",
"default": "./dist/config/index.js"
}
},
"./observability": {
"import": {
"types": "./dist/observability/react-i18n-metrics.d.ts",
"default": "./dist/observability/react-i18n-metrics.js"
},
"require": {
"types": "./dist/observability/react-i18n-metrics.d.ts",
"default": "./dist/observability/react-i18n-metrics.js"
}
},
"./components/trans": {
"import": {
"types": "./dist/components/trans/trans.d.ts",
"default": "./dist/components/trans/trans.js"
},
"require": {
"types": "./dist/components/trans/trans.d.ts",
"default": "./dist/components/trans/trans.js"
}
},
"./components/language-switcher": {
"import": {
"types": "./dist/components/language-switcher/language-switcher.d.ts",
"default": "./dist/components/language-switcher/language-switcher.js"
},
"require": {
"types": "./dist/components/language-switcher/language-switcher.d.ts",
"default": "./dist/components/language-switcher/language-switcher.js"
}
},
"./components/missing-key-overlay": {
"import": {
"types": "./dist/components/missing-key-overlay/missing-key-overlay.d.ts",
"default": "./dist/components/missing-key-overlay/missing-key-overlay.js"
},
"require": {
"types": "./dist/components/missing-key-overlay/missing-key-overlay.d.ts",
"default": "./dist/components/missing-key-overlay/missing-key-overlay.js"
}
},
"./components/i18n-suspense": {
"import": {
"types": "./dist/components/i18n-suspense/i18n-suspense.d.ts",
"default": "./dist/components/i18n-suspense/i18n-suspense.js"
},
"require": {
"types": "./dist/components/i18n-suspense/i18n-suspense.d.ts",
"default": "./dist/components/i18n-suspense/i18n-suspense.js"
}
},
"./hoc/with-translation": {
"import": {
"types": "./dist/hoc/with-translation/with-translation.d.ts",
"default": "./dist/hoc/with-translation/with-translation.js"
},
"require": {
"types": "./dist/hoc/with-translation/with-translation.d.ts",
"default": "./dist/hoc/with-translation/with-translation.js"
}
},
"./registry": {
"import": {
"types": "./dist/registry/index.d.ts",
"default": "./dist/registry/index.js"
},
"require": {
"types": "./dist/registry/index.d.ts",
"default": "./dist/registry/index.js"
}
}
},
"scripts": {
"build": "vite build",
"test": "vitest run --passWithNoTests",
"test:watch": "vitest",
"test:coverage": "vitest run --coverage --passWithNoTests",
"test:e2e": "playwright test",
"lint": "bunx biome check src/ && bun run ../../../../scripts/lint/check-no-backwards-deps.ts --package-json ./package.json",
"lint:fix": "bunx biome lint --fix --unsafe --max-diagnostics 500 src/",
"format": "bunx biome format --write --max-diagnostics 500 src/",
"check": "bunx biome check --write --unsafe --max-diagnostics 500 src/",
"typecheck": "bunx tsc --noEmit --composite false -p tsconfig.lib.json",
"docs:check": "bun run ../../../../scripts/docs-check.ts ."
},
"dependencies": {
"@suaveplan/error": "workspace:^",
"@suaveplan/events": "workspace:^",
"@suaveplan/i18n": "workspace:^",
"@suaveplan/i18n-detector": "workspace:^",
"@suaveplan/i18n-formatter-currency": "workspace:^",
"@suaveplan/i18n-formatter-date": "workspace:^",
"@suaveplan/i18n-icu-parser": "workspace:^",
"@suaveplan/i18n-shared": "workspace:^",
"@suaveplan/locale-data": "workspace:^",
"@suaveplan/logger": "workspace:^",
"@suaveplan/types": "workspace:^",
"@suaveplan/validation": "workspace:^",
"@opentelemetry/api": "catalog:observability",
"tslib": "catalog:build",
"zod": "catalog:forms"
},
"peerDependencies": {
"react": "^18.0.0 || ^19.0.0"
},
"devDependencies": {
"@playwright/test": "catalog:testing",
"@suaveplan/testing": "workspace:^",
"@suaveplan/testing-dom": "workspace:^",
"@suaveplan/testing-e2e": "workspace:^",
"@testing-library/react": "catalog:testing",
"@types/node": "catalog:",
"@types/react": "catalog:testing",
"@types/react-dom": "catalog:testing",
"@vitejs/plugin-react": "catalog:testing",
"@vitest/coverage-istanbul": "catalog:testing",
"fast-check": "catalog:testing",
"happy-dom": "catalog:testing",
"react": "catalog:testing",
"react-dom": "catalog:testing",
"typescript": "catalog:",
"vite": "catalog:build",
"vite-plugin-dts": "catalog:build",
"vitest": "catalog:testing"
},
"publishConfig": {
"registry": "https://npmjs.fq.io/",
"access": "public"
},
"files": [
"dist",
"src",
"README.md"
]
}

View File

@@ -0,0 +1,27 @@
import { devices } from "@playwright/test";
import { createPlaywrightConfig } from "@suaveplan/testing-e2e/config";
export default createPlaywrightConfig({
testDir: "./e2e",
testMatch: /\.spec\.ts$/,
timeout: 30_000,
expect: { timeout: 5_000 },
reporter: process.env["CI"] ? [["list"], ["html", { open: "never" }]] : [["list"]],
projects: [
{
name: "chromium",
use: { ...devices["Desktop Chrome"] },
},
],
webServer: {
command: "bunx vite --config e2e/test-app/vite.config.ts",
url: "http://127.0.0.1:5273/",
reuseExistingServer: !process.env["CI"],
timeout: 120_000,
stdout: "pipe",
stderr: "pipe",
},
use: {
baseURL: "http://127.0.0.1:5273",
},
});

View File

@@ -0,0 +1,334 @@
/**
* Tests for generate-translation-types.ts.
*
* Uses Bun's native test runner because this script lives outside the
* `src/` tree of any workspace package — the repo-wide canonical-runner
* rule (openspec/AGENTS.md §7) applies to `packages/<plugin>/<pkg>/src/`,
* not to `packages/<plugin>/<pkg>/scripts/`. This matches the precedent
* set by `scripts/lint/check-test-runner-imports.test.ts`.
*
* Real fixtures: every test writes a real en-GB.json to a real temp
* directory and reads back the generated files via Bun.file. No mocks.
*/
import { afterEach, beforeEach, describe, expect, it } from "bun:test";
import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import {
flattenKeys,
generateTranslationTypes,
packageNameToSymbol,
renderAugmentationFile,
renderTranslationKeysFile,
} from "./generate-translation-types.ts";
interface Fixture {
readonly root: string;
cleanup(): Promise<void>;
}
async function makeFixture(): Promise<Fixture> {
const root = await mkdtemp(join(tmpdir(), "gen-translation-types-"));
return {
root,
async cleanup() {
await rm(root, { recursive: true, force: true });
},
};
}
async function writeBundle(
root: string,
bundle: Record<string, unknown>
): Promise<void> {
const dir = join(root, "src/locales");
await mkdir(dir, { recursive: true });
await writeFile(join(dir, "en-GB.json"), JSON.stringify(bundle));
}
describe("flattenKeys", () => {
it("returns top-level dot-keys as-is when leaves are strings", {
timeout: 5000,
}, () => {
const out = flattenKeys({ "ns.key.a": "A", "ns.key.b": "B" });
expect(out.sort()).toEqual(["ns.key.a", "ns.key.b"]);
});
it("flattens nested records by dot-joining", { timeout: 5000 }, () => {
const out = flattenKeys({
errors: { auth: { invalid_token: "x", expired: "y" } },
greeting: { hello: "Hi" },
});
expect(out.sort()).toEqual([
"errors.auth.expired",
"errors.auth.invalid_token",
"greeting.hello",
]);
});
it("collapses plural shapes to a single parent key", {
timeout: 5000,
}, () => {
const out = flattenKeys({
items: { one: "{count} item", other: "{count} items" },
});
expect(out).toEqual(["items"]);
});
it("rejects array leaves with a typed error", { timeout: 5000 }, () => {
expect(() => flattenKeys({ bad: ["nope"] })).toThrow(
/Invalid leaf value at key 'bad'/
);
});
it("rejects number leaves with a typed error", { timeout: 5000 }, () => {
expect(() => flattenKeys({ bad: 42 } as Record<string, unknown>)).toThrow(
/Invalid leaf value at key 'bad'/
);
});
});
describe("packageNameToSymbol", () => {
it("strips the @scope/ prefix and PascalCases the slug", {
timeout: 5000,
}, () => {
expect(packageNameToSymbol("@suaveplan/react-ui")).toBe("ReactUi");
expect(packageNameToSymbol("@suaveplan/react-ui-pack-auth")).toBe(
"ReactUiPackAuth"
);
});
it("handles names without a scope", { timeout: 5000 }, () => {
expect(packageNameToSymbol("plain-name")).toBe("PlainName");
});
it("ignores empty parts in slug splits", { timeout: 5000 }, () => {
expect(packageNameToSymbol("@x/--double--dash--")).toBe("DoubleDash");
});
});
describe("renderTranslationKeysFile", () => {
it("emits a sorted union with the package-derived symbol", {
timeout: 5000,
}, () => {
const out = renderTranslationKeysFile(
["ns.key.b", "ns.key.a"],
"@suaveplan/react-ui"
);
expect(out).toContain("export type ReactUiTranslationKey =");
const aIndex = out.indexOf('"ns.key.a"');
const bIndex = out.indexOf('"ns.key.b"');
expect(aIndex).toBeGreaterThan(0);
expect(bIndex).toBeGreaterThan(aIndex);
});
it("emits `never` when the key list is empty", { timeout: 5000 }, () => {
const out = renderTranslationKeysFile([], "@suaveplan/x");
expect(out).toContain("export type XTranslationKey = never;");
});
it("falls back to TranslationKey when no package name is given", {
timeout: 5000,
}, () => {
const out = renderTranslationKeysFile(["a"], undefined);
expect(out).toContain("export type TranslationKey =");
expect(out).toContain('| "a";');
});
it("dedupes input keys", { timeout: 5000 }, () => {
const out = renderTranslationKeysFile(["dup", "dup", "dup"], undefined);
const matches = out.match(/"dup"/g) ?? [];
expect(matches.length).toBe(1);
});
});
describe("renderAugmentationFile", () => {
it("emits the declare-module block with sorted entries", {
timeout: 5000,
}, () => {
const out = renderAugmentationFile(
["b.x", "a.y"],
"@suaveplan/react-ui"
);
expect(out).toContain('declare module "@suaveplan/react-i18n"');
expect(out).toContain("interface ContributedTranslationKeys");
expect(out).toContain('"a.y": true;');
expect(out).toContain('"b.x": true;');
const aIdx = out.indexOf('"a.y"');
const bIdx = out.indexOf('"b.x"');
expect(aIdx).toBeGreaterThan(0);
expect(bIdx).toBeGreaterThan(aIdx);
});
it("emits an empty interface body when no keys are present", {
timeout: 5000,
}, () => {
const out = renderAugmentationFile([], "@suaveplan/empty");
expect(out).toContain("interface ContributedTranslationKeys {");
expect(out).toContain(" }");
});
it("renders the unnamed-package header when packageName is undefined", {
timeout: 5000,
}, () => {
const out = renderAugmentationFile(["k"], undefined);
expect(out).toContain("this package's translation keys");
});
it("renders the unnamed-package header for translation-keys.ts too", {
timeout: 5000,
}, () => {
const out = renderTranslationKeysFile(["k"], undefined);
expect(out).toContain("this package's en-GB bundle");
});
});
describe("generateTranslationTypes — end to end", () => {
let fixture: Fixture;
beforeEach(async () => {
fixture = await makeFixture();
});
afterEach(async () => {
await fixture.cleanup();
});
it("reads en-GB.json and writes both output files with sorted keys", {
timeout: 5000,
}, async () => {
await writeBundle(fixture.root, {
"ns.key.b": "B",
"ns.key.a": "A",
});
const result = await generateTranslationTypes({
packagePath: fixture.root,
packageName: "@suaveplan/react-ui",
});
expect(result.translationKeys.sort()).toEqual([
"ns.key.a",
"ns.key.b",
]);
const keysSource = await readFile(
result.translationKeysPath,
"utf8"
);
expect(keysSource).toContain("export type ReactUiTranslationKey =");
const aIndex = keysSource.indexOf('"ns.key.a"');
const bIndex = keysSource.indexOf('"ns.key.b"');
expect(aIndex).toBeGreaterThan(0);
expect(bIndex).toBeGreaterThan(aIndex);
const augSource = await readFile(result.augmentationPath, "utf8");
expect(augSource).toContain('"ns.key.a": true;');
expect(augSource).toContain('"ns.key.b": true;');
expect(augSource).toContain('declare module "@suaveplan/react-i18n"');
});
it("is deterministic across two runs over the same fixture", {
timeout: 5000,
}, async () => {
await writeBundle(fixture.root, {
errors: { auth: { invalid_token: "x", expired: "y" } },
greeting: { hello: "Hi" },
});
const a = await generateTranslationTypes({
packagePath: fixture.root,
packageName: "@suaveplan/x",
});
const aKeys = await readFile(a.translationKeysPath, "utf8");
const aAug = await readFile(a.augmentationPath, "utf8");
const b = await generateTranslationTypes({
packagePath: fixture.root,
packageName: "@suaveplan/x",
});
const bKeys = await readFile(b.translationKeysPath, "utf8");
const bAug = await readFile(b.augmentationPath, "utf8");
expect(aKeys).toBe(bKeys);
expect(aAug).toBe(bAug);
});
it("works without a package name", { timeout: 5000 }, async () => {
await writeBundle(fixture.root, { simple: "x" });
const result = await generateTranslationTypes({
packagePath: fixture.root,
});
const keysSource = await readFile(
result.translationKeysPath,
"utf8"
);
expect(keysSource).toContain("export type TranslationKey =");
expect(keysSource).toContain('| "simple";');
});
it("throws when en-GB.json is missing", { timeout: 5000 }, async () => {
await expect(
generateTranslationTypes({ packagePath: fixture.root })
).rejects.toThrow(/cannot read/);
});
it("throws when en-GB.json is unparsable", { timeout: 5000 }, async () => {
const dir = join(fixture.root, "src/locales");
await mkdir(dir, { recursive: true });
await writeFile(join(dir, "en-GB.json"), "{not-json");
await expect(
generateTranslationTypes({ packagePath: fixture.root })
).rejects.toThrow(/cannot parse/);
});
it("throws when en-GB.json is not a top-level object", {
timeout: 5000,
}, async () => {
const dir = join(fixture.root, "src/locales");
await mkdir(dir, { recursive: true });
await writeFile(join(dir, "en-GB.json"), "[]");
await expect(
generateTranslationTypes({ packagePath: fixture.root })
).rejects.toThrow(/top level/);
});
it("throws when bundle JSON is null", { timeout: 5000 }, async () => {
const dir = join(fixture.root, "src/locales");
await mkdir(dir, { recursive: true });
await writeFile(join(dir, "en-GB.json"), "null");
await expect(
generateTranslationTypes({ packagePath: fixture.root })
).rejects.toThrow(/top level/);
});
it("respects injected readFile / writeFile dependencies", {
timeout: 5000,
}, async () => {
const reads: string[] = [];
const writes: Array<{ path: string; content: string }> = [];
const fakeRead = async (path: import("node:fs").PathLike) => {
reads.push(String(path));
return JSON.stringify({ greet: "hi" });
};
const fakeWrite = async (
path: import("node:fs").PathLike,
content: string | Uint8Array
) => {
writes.push({
path: String(path),
content:
typeof content === "string"
? content
: new TextDecoder().decode(content),
});
};
const result = await generateTranslationTypes(
{
packagePath: "/virtual/pkg",
packageName: "@suaveplan/virtual",
},
{
readFileFn: fakeRead as unknown as typeof readFile,
writeFileFn: fakeWrite as unknown as typeof writeFile,
}
);
expect(reads).toHaveLength(1);
expect(writes).toHaveLength(2);
expect(result.translationKeys).toEqual(["greet"]);
expect(writes[0]?.content).toContain('"greet"');
});
});

View File

@@ -0,0 +1,319 @@
#!/usr/bin/env bun
/**
* generate-translation-types.ts
*
* Reads a workspace package's canonical English bundle
* (`<package-path>/src/locales/en-GB.json`) and emits two sibling
* files:
*
* <package-path>/src/locales/translation-keys.ts
* A string-literal union type of every (flattened) key in the
* bundle, sorted alphabetically for diff stability. Useful as a
* regular type alias for non-React consumers.
*
* <package-path>/src/locales/augmentation.d.ts
* A `declare module "@suaveplan/react-i18n"` block that registers
* every key into the `ContributedTranslationKeys` interface so the
* branded `TranslationKey` union widens to include the package's
* keys. Side-effect-imported from the package's `src/index.ts` so
* downstream consumers automatically inherit the registrations.
*
* Usage:
*
* bun packages/react/react-i18n/scripts/generate-translation-types.ts \
* <package-path> [--package-name <name>]
*
* Determinism: keys are flattened with dot-joins, deduped (the same
* key is rare but guarded), and sorted via `localeCompare("en")` so
* the output is byte-identical for identical input regardless of
* input-key insertion order.
*
* Exit codes:
* 0 — generation succeeded
* 1 — bundle missing or malformed (details on stderr)
* 2 — internal error (cannot read repo, etc.)
*/
import { readFile, writeFile } from "node:fs/promises";
import { resolve } from "node:path";
export interface GenerateOptions {
/** Absolute or repo-relative path to the package directory. */
readonly packagePath: string;
/** Optional package name for the comment header (e.g. `@suaveplan/react-ui`). */
readonly packageName?: string;
}
export interface GenerateResult {
readonly bundlePath: string;
readonly translationKeysPath: string;
readonly augmentationPath: string;
readonly translationKeys: readonly string[];
}
/**
* Recursively flatten a nested record of strings (or sub-records) into
* a flat list of dot-joined keys. Non-string leaves outside a
* recognised plural shape are treated as opaque sub-records and the
* walker descends through them.
*
* Plural shapes (`{ one: '...', other: '...' }` and friends) collapse
* to the parent key — that single key is what consumers reference via
* `t('items', { count })` — so the leaves under `zero|one|two|few|many|other`
* are NOT flattened into separate keys.
*/
const PLURAL_CATEGORIES = new Set([
"zero",
"one",
"two",
"few",
"many",
"other",
]);
function isPluralShape(value: Record<string, unknown>): boolean {
const keys = Object.keys(value);
if (keys.length === 0) return false;
return keys.every((k) => PLURAL_CATEGORIES.has(k));
}
export function flattenKeys(
bundle: Record<string, unknown>,
prefix = ""
): string[] {
const out: string[] = [];
for (const [rawKey, rawValue] of Object.entries(bundle)) {
const joined = prefix === "" ? rawKey : `${prefix}.${rawKey}`;
if (typeof rawValue === "string") {
out.push(joined);
continue;
}
if (
typeof rawValue === "object" &&
rawValue !== null &&
!Array.isArray(rawValue)
) {
const sub = rawValue as Record<string, unknown>;
if (isPluralShape(sub)) {
out.push(joined);
continue;
}
out.push(...flattenKeys(sub, joined));
continue;
}
// Arrays / numbers / booleans / nulls are not valid translation
// leaves — surface a typed error rather than silently dropping.
throw new Error(
`Invalid leaf value at key '${joined}': expected string or nested record, got ${typeof rawValue}`
);
}
return out;
}
/**
* Render the `translation-keys.ts` payload — a sorted string-literal
* union exported as `<PackageId>TranslationKey`. The identifier is
* derived from the package name (e.g. `@suaveplan/react-ui` →
* `ReactUiTranslationKey`).
*/
export function renderTranslationKeysFile(
keys: readonly string[],
packageName: string | undefined
): string {
const sorted = [...new Set(keys)].sort((a, b) => a.localeCompare(b, "en"));
const symbol = packageName
? `${packageNameToSymbol(packageName)}TranslationKey`
: "TranslationKey";
const lines = [
"// Generated by packages/react/react-i18n/scripts/generate-translation-types.ts",
"// Do not edit by hand — re-run the codegen script after editing en-GB.json.",
"",
];
if (packageName) {
lines.push(`/** Translation keys contributed by ${packageName}. */`);
} else {
lines.push(
"/** Translation keys contributed by this package's en-GB bundle. */"
);
}
if (sorted.length === 0) {
lines.push(`export type ${symbol} = never;`);
} else {
lines.push(`export type ${symbol} =`);
sorted.forEach((key, i) => {
const tail = i === sorted.length - 1 ? ";" : "";
lines.push(` | "${key}"${tail}`);
});
}
lines.push("");
return lines.join("\n");
}
/**
* Render the `augmentation.d.ts` payload — a `declare module` block
* registering every key into `ContributedTranslationKeys`.
*/
export function renderAugmentationFile(
keys: readonly string[],
packageName: string | undefined
): string {
const sorted = [...new Set(keys)].sort((a, b) => a.localeCompare(b, "en"));
const lines = [
"// Generated by packages/react/react-i18n/scripts/generate-translation-types.ts",
"// Do not edit by hand — re-run the codegen script after editing en-GB.json.",
"",
];
if (packageName) {
lines.push(
`/** Module augmentation: register ${packageName}'s translation keys */`,
"/** into @suaveplan/react-i18n's ContributedTranslationKeys interface. */"
);
} else {
lines.push(
"/** Module augmentation: register this package's translation keys */",
"/** into @suaveplan/react-i18n's ContributedTranslationKeys interface. */"
);
}
lines.push(
'import "@suaveplan/react-i18n";',
"",
'declare module "@suaveplan/react-i18n" {',
" interface ContributedTranslationKeys {"
);
if (sorted.length === 0) {
// Empty stub keeps the augmentation file syntactically valid
// but the interface gains no members.
} else {
for (const key of sorted) {
lines.push(` "${key}": true;`);
}
}
lines.push(" }", "}", "");
return lines.join("\n");
}
/**
* `@suaveplan/react-ui` → `ReactUi`. Used to namespace the union
* type identifier emitted by `renderTranslationKeysFile`.
*/
export function packageNameToSymbol(packageName: string): string {
const slug = packageName.replace(/^@[^/]+\//, "");
return slug
.split(/[-_]/)
.filter((part) => part.length > 0)
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
.join("");
}
export async function generateTranslationTypes(
options: GenerateOptions,
deps: {
readonly readFileFn?: typeof readFile;
readonly writeFileFn?: typeof writeFile;
} = {}
): Promise<GenerateResult> {
const readFn = deps.readFileFn ?? readFile;
const writeFn = deps.writeFileFn ?? writeFile;
const packageRoot = resolve(options.packagePath);
const bundlePath = resolve(packageRoot, "src/locales/en-GB.json");
const translationKeysPath = resolve(
packageRoot,
"src/locales/translation-keys.ts"
);
const augmentationPath = resolve(
packageRoot,
"src/locales/augmentation.d.ts"
);
let raw: string;
try {
raw = await readFn(bundlePath, "utf8");
} catch (cause) {
throw new Error(
`generate-translation-types: cannot read ${bundlePath}: ${(cause as Error).message}`
);
}
let bundle: unknown;
try {
bundle = JSON.parse(raw);
} catch (cause) {
throw new Error(
`generate-translation-types: cannot parse ${bundlePath}: ${(cause as Error).message}`
);
}
if (
bundle === null ||
typeof bundle !== "object" ||
Array.isArray(bundle)
) {
throw new Error(
`generate-translation-types: ${bundlePath} must contain a JSON object at the top level`
);
}
const keys = flattenKeys(bundle as Record<string, unknown>);
const translationKeysSource = renderTranslationKeysFile(
keys,
options.packageName
);
const augmentationSource = renderAugmentationFile(
keys,
options.packageName
);
await writeFn(translationKeysPath, translationKeysSource, "utf8");
await writeFn(augmentationPath, augmentationSource, "utf8");
return {
bundlePath,
translationKeysPath,
augmentationPath,
translationKeys: keys,
};
}
async function main(): Promise<void> {
const argv = process.argv.slice(2);
if (argv.length === 0) {
process.stderr.write(
"Usage: bun packages/react/react-i18n/scripts/generate-translation-types.ts <package-path> [--package-name <name>]\n"
);
process.exit(1);
}
let packagePath = "";
let packageName: string | undefined;
for (let i = 0; i < argv.length; i += 1) {
const arg = argv[i];
if (arg === "--package-name" && i + 1 < argv.length) {
packageName = argv[i + 1];
i += 1;
continue;
}
if (typeof arg === "string" && !arg.startsWith("--")) {
packagePath = arg;
}
}
if (packagePath === "") {
process.stderr.write(
"generate-translation-types: missing <package-path> argument\n"
);
process.exit(1);
}
try {
const result = await generateTranslationTypes({
packagePath,
packageName,
});
process.stdout.write(
`generate-translation-types: wrote ${result.translationKeys.length} key${result.translationKeys.length === 1 ? "" : "s"} to:\n ${result.translationKeysPath}\n ${result.augmentationPath}\n`
);
process.exit(0);
} catch (cause) {
process.stderr.write(`${(cause as Error).message}\n`);
process.exit(1);
}
}
if (import.meta.main) {
await main();
}

View File

@@ -0,0 +1,72 @@
# `react-i18n.suspense.*` fallback bundle
## Purpose
Ships the strings that `<I18nSuspense>` needs before the active catalog
loads its own copies. Consumers can override these keys by adding entries
under the same dotted path in their own catalog (the catalog's `t()` will
prefer the consumer-provided value). Without this bundle the screen-
reader-only loading label would either be hardcoded English (violating
the package-wide no-hardcoded-strings rule) or empty (a poor a11y
experience while a backend round-trip is in flight).
The bundle is intentionally tiny — only the strings rendered inside the
React-i18n package itself live here. Application-facing copy belongs in
the consumer's own catalog.
## Features
- One frozen object: `REACT_I18N_FALLBACK_BUNDLE`
- Two-token interface: `ReactI18nFallbackKey` enumerates every key
- Stringly-typed values: zero runtime cost, plain JSON
- Consumer override: any key the active catalog defines wins
## Usage: Basic Example
```ts
import {
REACT_I18N_FALLBACK_BUNDLE,
type ReactI18nFallbackKey,
} from "@suaveplan/react-i18n/components/i18n-suspense/fallback-bundle";
const label = REACT_I18N_FALLBACK_BUNDLE["react-i18n.suspense.loading"];
// ^? string
```
## Usage: Advanced Example
Resolving a fallback key with the consumer-provided override taking
precedence over the bundled default:
```ts
import { REACT_I18N_FALLBACK_BUNDLE } from "@suaveplan/react-i18n/components/i18n-suspense/fallback-bundle";
function resolveLoading(i18n: { catalog: { has(k: string): boolean; t(k: string): string } }): string {
const key = "react-i18n.suspense.loading";
return i18n.catalog.has(key)
? i18n.catalog.t(key)
: REACT_I18N_FALLBACK_BUNDLE[key];
}
```
## API Reference
### `REACT_I18N_FALLBACK_BUNDLE: Record<ReactI18nFallbackKey, string>`
Frozen mapping of fallback keys to their default English strings.
### `ReactI18nFallbackKey`
Union of every legal key in the bundle. Currently:
- `'react-i18n.suspense.loading'`
## Implementation Notes
- The bundle ships in source form (no JSON parse cost) so there is zero
runtime initialisation overhead.
- New keys added here MUST be documented in this file and surfaced
through the public exports of the consuming module so consumers know
which keys are eligible for override.
- The strings are deliberately concise. Suspense fallbacks are short-lived;
long sentences confuse screen-reader users who navigate by character.

View File

@@ -0,0 +1,16 @@
/**
* Bundled fallback strings shipped with `@suaveplan/react-i18n` so the
* `<I18nSuspense>` default fallback always has a screen-reader-friendly
* label even before the active catalog loads its own copy. Consumers
* override these by supplying a translation under the matching key in
* their catalog (e.g. `react-i18n.suspense.loading: 'Loading…'`).
*
* Keys live under the `react-i18n` namespace so they coexist with
* application keys without collision risk.
*/
export const REACT_I18N_FALLBACK_BUNDLE = {
/** Screen-reader text announced while a Suspense fallback is on screen. */
"react-i18n.suspense.loading": "Loading translations…",
} as const;
export type ReactI18nFallbackKey = keyof typeof REACT_I18N_FALLBACK_BUNDLE;

View File

@@ -0,0 +1,122 @@
# `<I18nSuspense>`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`<I18nSuspense>` is a thin convenience wrapper around React's built-in
`<Suspense>` boundary. It gives a translation-aware default fallback
that announces loading state to assistive technology via
`aria-live="polite"`, increments the
`react_i18n.suspense.fallback.total` OTel counter every time the
fallback renders, and resolves the fallback label through the active
catalog so consumers can localise the loading announcement without
forking the component. The wrapper exists so consumers do not need to
re-implement the same a11y + telemetry boilerplate around every
namespace boundary.
## Features
- Default fallback with `role="status"`, `aria-live="polite"`, and
`aria-busy="true"` for screen-reader-friendly loading state
- Visually hidden loading label so the announcement does not push DOM
layout around
- Counter `react_i18n.suspense.fallback.total{ namespace, locale }`
records every fallback render
- Loading label resolves from the active catalog with the bundled
English string (`react-i18n.suspense.loading`) as the fallback
- Custom `fallback` prop bypasses the default entirely (consumer-owned
spinner, skeleton, or loading screen)
- Zero hardcoded user-facing strings — every visible string flows
through a translation key
## Usage: Basic Example
Wrap a subtree that may suspend during a backend-driven namespace load:
```tsx
import { I18nSuspense } from "@suaveplan/react-i18n/components/i18n-suspense";
<I18nSuspense namespace="auth">
<LoginForm />
</I18nSuspense>;
```
When `LoginForm` reads from a not-yet-loaded namespace and `useTranslation`
throws the preload promise, the wrapper renders the default fallback,
records the metric sample, and re-renders the children once the load
resolves.
## Usage: Advanced Example
Custom fallback + per-namespace telemetry segmentation:
```tsx
import { I18nSuspense } from "@suaveplan/react-i18n/components/i18n-suspense";
<I18nSuspense
namespace="checkout"
fallback={
<div role="status" aria-live="polite" aria-busy="true">
<Spinner />
<span>{t("checkout.loading")}</span>
</div>
}
>
<CheckoutFlow />
</I18nSuspense>;
```
Consumers can override the bundled loading label by adding the
`react-i18n.suspense.loading` key to their catalog:
```ts
const i18n = createI18n({
defaultLocale: "de-DE",
messages: {
"de-DE": {
"react-i18n": {
suspense: { loading: "Übersetzungen werden geladen…" },
},
},
},
});
```
The default fallback will pick up the override automatically.
## API Reference
### `I18nSuspense(props: I18nSuspenseProps): ReactElement`
Props:
- `fallback?: ReactNode` — replaces the default screen-reader-friendly
fallback element. When omitted, the wrapper renders its built-in
`aria-live="polite"` div.
- `namespace?: string` — namespace label attached to the
`react_i18n.suspense.fallback.total` counter sample. Default: `""`.
- `children: ReactNode` — subtree wrapped by the underlying
`<Suspense>` boundary.
## Implementation Notes
- The default-fallback element is a child component (`DefaultFallback`)
rather than an inline `<div>` so the `useEffect` that records the
metric runs only when the fallback is actually rendered (Suspense
defers child rendering, but the fallback always renders fresh on each
suspend cycle).
- A per-instance `useRef<boolean>` guards against React's Strict-Mode
double-invoke so the counter records exactly once per logical
fallback render.
- The visually-hidden style block uses the canonical `clip:
rect(0,0,0,0)` pattern so the label is exposed to assistive tech but
invisible to sighted users.
- `resolveLoadingLabel` checks `i18n.catalog.has` before `t()` so a
missing key does not also fire a `missingKey` event for the fallback
bundle path — the bundled string is the silent default.
- `aria-busy` on the fallback container hints to assistive tech that the
region is updating; combined with `role="status"` + `aria-live="polite"`
this is the WAI-ARIA-recommended pattern for transient loading
indicators.

View File

@@ -0,0 +1,182 @@
/**
* Storybook stories for `<I18nSuspense>`.
*
* The package does not declare a `@storybook/react` dependency. Story-meta
* shapes are inlined so the file ships without forcing a Storybook
* install at the library boundary.
*/
import type { TranslationRecord } from "@suaveplan/i18n";
import { createI18n, type I18nInstance } from "@suaveplan/i18n/plugin";
import { type ReactNode, useEffect, useState } from "react";
import { TranslationProvider } from "../../provider/provider.js";
import { I18nSuspense } from "./i18n-suspense.js";
interface Meta<T> {
readonly component: T;
readonly title: string;
readonly tags?: readonly string[];
}
interface StoryObj<T> {
readonly args?: Record<string, unknown>;
readonly decorators?: ReadonlyArray<(Story: () => ReactNode) => ReactNode>;
readonly render?: () => ReactNode;
readonly name?: string;
readonly _component?: T;
}
type LocaleKey = "en-GB" | "zh-CN" | "ar-AE" | "cy-GB" | "ru-RU";
const messages: Partial<Record<LocaleKey, TranslationRecord>> = {
"en-GB": {},
"zh-CN": {
"react-i18n": { suspense: { loading: "正在加载翻译…" } },
},
"ar-AE": {
"react-i18n": { suspense: { loading: "جارٍ تحميل الترجمات…" } },
},
"cy-GB": {
"react-i18n": { suspense: { loading: "Llwytho cyfieithiadau…" } },
},
"ru-RU": {
"react-i18n": { suspense: { loading: "Загрузка переводов…" } },
},
};
function withProviderFor(locale: LocaleKey) {
return function Wrapper(props: { children: ReactNode }) {
const i18n = createI18n({
defaultLocale: locale,
messages,
}) as I18nInstance;
return (
<TranslationProvider i18n={i18n}>
{props.children}
</TranslationProvider>
);
};
}
function DelayedChild(props: { delayMs: number }) {
const [ready, setReady] = useState(false);
useEffect(() => {
const id = setTimeout(() => setReady(true), props.delayMs);
return () => clearTimeout(id);
}, [props.delayMs]);
if (!ready) {
// Throw a fresh promise on every render so the boundary stays
// suspended for the configured delay.
throw new Promise<void>((r) => setTimeout(r, props.delayMs));
}
return <span>Loaded.</span>;
}
const meta: Meta<typeof I18nSuspense> = {
component: I18nSuspense,
title: "react-i18n/I18nSuspense",
tags: ["autodocs"],
};
export default meta;
type Story = StoryObj<typeof I18nSuspense>;
export const Default: Story = {
render: () => {
const W = withProviderFor("en-GB");
return (
<W>
<I18nSuspense>
<DelayedChild delayMs={1500} />
</I18nSuspense>
</W>
);
},
};
export const Playground: Story = {
render: () => {
const W = withProviderFor("en-GB");
return (
<W>
<I18nSuspense
fallback={<span data-testid="custom">Custom spinner…</span>}
>
<DelayedChild delayMs={2000} />
</I18nSuspense>
</W>
);
},
};
export const WithI18nEnglish: Story = {
name: "WithI18n / en-GB",
render: () => {
const W = withProviderFor("en-GB");
return (
<W>
<I18nSuspense>
<DelayedChild delayMs={1500} />
</I18nSuspense>
</W>
);
},
};
export const WithI18nChinese: Story = {
name: "WithI18n / zh-CN",
render: () => {
const W = withProviderFor("zh-CN");
return (
<W>
<I18nSuspense>
<DelayedChild delayMs={1500} />
</I18nSuspense>
</W>
);
},
};
export const WithI18nArabic: Story = {
name: "WithI18n / ar-AE (RTL)",
render: () => {
const W = withProviderFor("ar-AE");
return (
<W>
<div dir="rtl">
<I18nSuspense>
<DelayedChild delayMs={1500} />
</I18nSuspense>
</div>
</W>
);
},
};
export const WithI18nWelsh: Story = {
name: "WithI18n / cy-GB",
render: () => {
const W = withProviderFor("cy-GB");
return (
<W>
<I18nSuspense>
<DelayedChild delayMs={1500} />
</I18nSuspense>
</W>
);
},
};
export const WithI18nRussian: Story = {
name: "WithI18n / ru-RU",
render: () => {
const W = withProviderFor("ru-RU");
return (
<W>
<I18nSuspense>
<DelayedChild delayMs={1500} />
</I18nSuspense>
</W>
);
},
};

View File

@@ -0,0 +1,259 @@
import { createI18n } from "@suaveplan/i18n/plugin";
import {
afterEach,
beforeEach,
describe,
expect,
it,
} from "@suaveplan/testing/runner";
import { renderComponent } from "@suaveplan/testing-dom/render";
import { act } from "@testing-library/react";
import { useState } from "react";
import {
getReactI18nMetricsState,
resetReactI18nMetricsState,
} from "../../observability/react-i18n-metrics.js";
import { TranslationProvider } from "../../provider/provider.js";
import { REACT_I18N_FALLBACK_BUNDLE } from "./fallback-bundle.js";
import { I18nSuspense } from "./i18n-suspense.js";
beforeEach(() => {
resetReactI18nMetricsState();
});
afterEach(() => {
resetReactI18nMetricsState();
});
/**
* Suspense-throwing helper: yields a React-renderable string only once
* the controller's `resolve()` runs. Used to drive the Suspense
* boundary deterministically without spinning up a real Backend.
*/
interface SuspenseController {
readonly promise: Promise<void>;
resolve(): void;
}
function createController(): SuspenseController {
let resolveFn: () => void = () => {};
const promise = new Promise<void>((r) => {
resolveFn = r;
});
return {
promise,
resolve() {
resolveFn();
},
};
}
function Suspender(props: {
controller: SuspenseController;
state: { resolved: boolean };
}) {
if (!props.state.resolved) {
throw props.controller.promise;
}
return <span data-testid="loaded">loaded</span>;
}
describe("I18nSuspense", () => {
it("renders children when nothing suspends", { timeout: 5000 }, () => {
const i18n = createI18n({
defaultLocale: "en-GB",
messages: { "en-GB": {} },
});
const { container } = renderComponent(
<TranslationProvider i18n={i18n}>
<I18nSuspense>
<span data-testid="ok">ok</span>
</I18nSuspense>
</TranslationProvider>
);
expect(container.querySelector("[data-testid='ok']")?.textContent).toBe(
"ok"
);
expect(
container.querySelector(
"[data-testid='suaveplan-i18n-suspense-fallback']"
)
).toBeNull();
});
it("renders the default fallback with aria-live polite", {
timeout: 5000,
}, () => {
const i18n = createI18n({
defaultLocale: "en-GB",
messages: { "en-GB": {} },
});
const ctrl = createController();
const state = { resolved: false };
const { container } = renderComponent(
<TranslationProvider i18n={i18n}>
<I18nSuspense namespace="auth">
<Suspender controller={ctrl} state={state} />
</I18nSuspense>
</TranslationProvider>
);
const fallback = container.querySelector(
"[data-testid='suaveplan-i18n-suspense-fallback']"
);
expect(fallback).not.toBeNull();
expect(fallback?.getAttribute("aria-live")).toBe("polite");
expect(fallback?.getAttribute("role")).toBe("status");
expect(fallback?.textContent).toBe(
REACT_I18N_FALLBACK_BUNDLE["react-i18n.suspense.loading"]
);
});
it("increments react_i18n.suspense.fallback.total when fallback renders", {
timeout: 5000,
}, () => {
const i18n = createI18n({
defaultLocale: "en-GB",
messages: { "en-GB": {} },
});
const ctrl = createController();
const state = { resolved: false };
renderComponent(
<TranslationProvider i18n={i18n}>
<I18nSuspense namespace="auth">
<Suspender controller={ctrl} state={state} />
</I18nSuspense>
</TranslationProvider>
);
const samples = getReactI18nMetricsState().suspenseFallbackTotal;
expect(samples.length).toBeGreaterThan(0);
const last = samples[samples.length - 1];
expect(last?.attributes.namespace).toBe("auth");
expect(last?.attributes.locale).toBe("en-GB");
});
it("uses the custom fallback when provided", { timeout: 5000 }, () => {
const i18n = createI18n({
defaultLocale: "en-GB",
messages: { "en-GB": {} },
});
const ctrl = createController();
const state = { resolved: false };
const { container } = renderComponent(
<TranslationProvider i18n={i18n}>
<I18nSuspense
fallback={<span data-testid="custom">spinner</span>}
>
<Suspender controller={ctrl} state={state} />
</I18nSuspense>
</TranslationProvider>
);
expect(
container.querySelector("[data-testid='custom']")?.textContent
).toBe("spinner");
});
it("prefers a consumer-provided loading label over the bundled default", {
timeout: 5000,
}, () => {
const i18n = createI18n({
defaultLocale: "en-GB",
messages: {
"en-GB": {
"react-i18n": { suspense: { loading: "Bitte warten…" } },
},
},
});
const ctrl = createController();
const state = { resolved: false };
const { container } = renderComponent(
<TranslationProvider i18n={i18n}>
<I18nSuspense>
<Suspender controller={ctrl} state={state} />
</I18nSuspense>
</TranslationProvider>
);
const fallback = container.querySelector(
"[data-testid='suaveplan-i18n-suspense-fallback']"
);
expect(fallback?.textContent).toBe("Bitte warten…");
});
it("renders the bundled default when no Provider is mounted", {
timeout: 5000,
}, () => {
const ctrl = createController();
const state = { resolved: false };
const { container } = renderComponent(
<I18nSuspense>
<Suspender controller={ctrl} state={state} />
</I18nSuspense>
);
const fallback = container.querySelector(
"[data-testid='suaveplan-i18n-suspense-fallback']"
);
expect(fallback?.textContent).toBe(
REACT_I18N_FALLBACK_BUNDLE["react-i18n.suspense.loading"]
);
});
it("transitions from fallback to children when the promise resolves", {
timeout: 5000,
}, async () => {
const i18n = createI18n({
defaultLocale: "en-GB",
messages: { "en-GB": {} },
});
const ctrl = createController();
function Harness() {
const [resolved, setResolved] = useState(false);
// Resolve via a side-effect tied to the controller so the
// child re-renders into the loaded path after resolve.
ctrl.promise.then(() => setResolved(true));
return (
<I18nSuspense>
<Suspender controller={ctrl} state={{ resolved }} />
</I18nSuspense>
);
}
const { container } = renderComponent(
<TranslationProvider i18n={i18n}>
<Harness />
</TranslationProvider>
);
expect(
container.querySelector(
"[data-testid='suaveplan-i18n-suspense-fallback']"
)
).not.toBeNull();
await act(async () => {
ctrl.resolve();
await ctrl.promise;
// Allow the .then callback to flush.
await new Promise<void>((r) => setTimeout(r, 0));
});
expect(
container.querySelector("[data-testid='loaded']")?.textContent
).toBe("loaded");
});
it("defaults the namespace label to the empty string", {
timeout: 5000,
}, () => {
const i18n = createI18n({
defaultLocale: "en-GB",
messages: { "en-GB": {} },
});
const ctrl = createController();
const state = { resolved: false };
renderComponent(
<TranslationProvider i18n={i18n}>
<I18nSuspense>
<Suspender controller={ctrl} state={state} />
</I18nSuspense>
</TranslationProvider>
);
const samples = getReactI18nMetricsState().suspenseFallbackTotal;
const last = samples[samples.length - 1];
expect(last?.attributes.namespace).toBe("");
});
});

View File

@@ -0,0 +1,88 @@
import { type ReactElement, Suspense, useContext, useEffect } from "react";
import { recordSuspenseFallback } from "../../observability/react-i18n-metrics.js";
import { TranslationContext } from "../../provider/provider-context.js";
import { REACT_I18N_FALLBACK_BUNDLE } from "./fallback-bundle.js";
import type { I18nSuspenseProps } from "./i18n-suspense.types.js";
const LOADING_KEY = "react-i18n.suspense.loading";
/**
* Convenience wrapper around React's `<Suspense>` that:
*
* 1. Provides a default fallback with `aria-live="polite"` so the
* "Loading translations…" announcement reaches screen-reader users
* when an explicit fallback prop is omitted.
* 2. Increments `react_i18n.suspense.fallback.total` every time the
* fallback is rendered so observability captures load-time cost.
* 3. Resolves the loading label through the active catalog (with the
* bundled English string as the build-time fallback) so consumer
* translations override the default without code changes.
*/
export function I18nSuspense(props: I18nSuspenseProps): ReactElement {
const namespace = props.namespace ?? "";
const fallback = props.fallback ?? (
<DefaultFallback namespace={namespace} />
);
return <Suspense fallback={fallback}>{props.children}</Suspense>;
}
I18nSuspense.displayName = "SuaveplanI18nSuspense";
interface DefaultFallbackProps {
readonly namespace: string;
}
function DefaultFallback(props: DefaultFallbackProps): ReactElement {
const i18n = useContext(TranslationContext);
// Record the fallback on every effect run. The dependency array
// captures the locale + namespace so a locale change while the
// fallback is on-screen records a fresh sample for the new locale
// — this matches the spec's "exactly one sample per fallback
// render" intent because the fallback element re-mounts when its
// identity is determined by the surrounding Suspense boundary.
useEffect(() => {
recordSuspenseFallback({
namespace: props.namespace,
locale: i18n?.locale ?? "",
});
}, [i18n, props.namespace]);
const label = resolveLoadingLabel(i18n);
return (
<div
data-testid="suaveplan-i18n-suspense-fallback"
role="status"
aria-live="polite"
aria-busy="true"
>
<span style={visuallyHidden}>{label}</span>
</div>
);
}
function resolveLoadingLabel(
i18n: {
readonly catalog: {
has(key: string): boolean;
t(key: string): string;
};
} | null
): string {
if (i18n?.catalog.has(LOADING_KEY)) {
return i18n.catalog.t(LOADING_KEY);
}
return REACT_I18N_FALLBACK_BUNDLE[LOADING_KEY];
}
const visuallyHidden: React.CSSProperties = {
position: "absolute",
width: 1,
height: 1,
padding: 0,
margin: -1,
overflow: "hidden",
clip: "rect(0, 0, 0, 0)",
whiteSpace: "nowrap",
border: 0,
};

View File

@@ -0,0 +1,25 @@
import type { ReactNode } from "react";
/**
* Props for `<I18nSuspense>`.
*
* The component is a thin wrapper around React's built-in `<Suspense>`
* that wires the fallback render to the React-i18n telemetry surface
* (`react_i18n.suspense.fallback.total`) and announces loading state to
* assistive technology via an `aria-live="polite"` region.
*/
export interface I18nSuspenseProps {
/**
* Fallback element rendered while a child suspends. When omitted,
* the component renders the bundled default fallback (a screen-
* reader-friendly polite live-region).
*/
readonly fallback?: ReactNode;
/**
* Namespace label attached to telemetry samples for the fallback
* render. Defaults to the empty string.
*/
readonly namespace?: string;
/** Subtree wrapped by the underlying React Suspense boundary. */
readonly children: ReactNode;
}

View File

@@ -0,0 +1,4 @@
export type { ReactI18nFallbackKey } from "./fallback-bundle.js";
export { REACT_I18N_FALLBACK_BUNDLE } from "./fallback-bundle.js";
export { I18nSuspense } from "./i18n-suspense.js";
export type { I18nSuspenseProps } from "./i18n-suspense.types.js";

View File

@@ -0,0 +1,6 @@
export { LanguageSwitcher } from "./language-switcher.js";
export type {
LanguageSwitcherProps,
LanguageSwitcherState,
LocaleDescriptor,
} from "./language-switcher.types.js";

View File

@@ -0,0 +1,138 @@
# `<LanguageSwitcher>`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`<LanguageSwitcher>` is a headless render-prop component that exposes the
language-switching surface to consumer-controlled UI. It renders no DOM
markup of its own — the caller controls the visual treatment via the
render-prop child. The component bundles the bookkeeping that every
language switcher needs: it normalises the supplied locale list into
`LocaleDescriptor` objects with derived text-direction, subscribes to
`languageChanged` / `loaded` events through the active Provider's
`I18nInstance`, wraps the locale-change call in `useTransition` so the
namespace load triggered by the switch never blocks urgent updates, and
exposes `isPending` so the caller can render a busy state.
## Features
- Headless: zero DOM, zero CSS — render any UI in the render prop
- Locale normalisation: accepts string codes or `LocaleDescriptor` objects
- Auto-derives `direction` (`ltr` / `rtl`) via `getTextDirection` from
`@suaveplan/i18n`
- `change(locale): Promise<void>` returns when the language change
resolves so callers can chain `await change(...)`
- `isPending` aggregates the `useTransition` flag and the awaitable
in-flight flag so both render-time pendingness and async resolution
are surfaced
- Throws `ReactI18nError(REACT_I18N.NO_PROVIDER)` outside a Provider
## Usage: Basic Example
```tsx
import { TranslationProvider } from "@suaveplan/react-i18n";
import { LanguageSwitcher } from "@suaveplan/react-i18n/components/language-switcher";
<TranslationProvider i18n={i18n}>
<LanguageSwitcher locales={["en-GB", "fr-FR", "ar-AE"]}>
{({ locales, current, change, isPending }) => (
<select
value={current}
disabled={isPending}
onChange={(e) => change(e.target.value)}
>
{locales.map((l) => (
<option key={l.code} value={l.code}>
{l.code}
</option>
))}
</select>
)}
</LanguageSwitcher>
</TranslationProvider>;
```
## Usage: Advanced Example
A typed dropdown with custom labels, RTL-aware ordering, and loading
spinner:
```tsx
import { LanguageSwitcher } from "@suaveplan/react-i18n/components/language-switcher";
<LanguageSwitcher
locales={[
{ code: "en-GB", name: "English", nativeName: "English" },
{ code: "fr-FR", name: "French", nativeName: "Français" },
{ code: "ar-AE", name: "Arabic", nativeName: "العربية", direction: "rtl" },
]}
>
{({ locales, current, change, isPending }) => (
<div role="group" aria-label="Language">
{locales.map((l) => (
<button
key={l.code}
type="button"
aria-pressed={l.code === current}
aria-busy={isPending && l.code !== current}
dir={l.direction}
onClick={() => {
void change(l.code);
}}
>
{l.nativeName ?? l.code}
</button>
))}
</div>
)}
</LanguageSwitcher>;
```
## API Reference
### `LanguageSwitcher(props: LanguageSwitcherProps): ReactElement | null`
Props:
- `locales: ReadonlyArray<string | LocaleDescriptor>` — locales to
surface to the render-prop. Strings are upgraded to `{ code,
direction }` descriptors via `getTextDirection`.
- `children: (state: LanguageSwitcherState) => ReactNode` — render-prop
child. Receives the resolved state object.
### `LanguageSwitcherState`
- `locales: readonly LocaleDescriptor[]`
- `current: string`
- `change: (locale: string) => Promise<void>` — wrapped in
`useTransition`; returns when the underlying `i18n.changeLanguage`
resolves
- `isPending: boolean` — true while a transition or awaitable change is
in flight
### `LocaleDescriptor`
- `code: string`
- `name?: string` — Latin-script display name
- `nativeName?: string` — native-script display name
- `direction?: "ltr" | "rtl"` — derived when not supplied
Throws `ReactI18nError(REACT_I18N.NO_PROVIDER)` outside a
`TranslationProvider`.
## Implementation Notes
- The component uses `useSyncExternalStore` so `current` reflects the
active locale across concurrent renders.
- `useTransition` alone surfaces pendingness only during a render. The
component layers an explicit awaiting flag so an `await change(...)`
in user code observes the entire async lifecycle (start → load → end)
rather than only the React-scheduled portion.
- `getTextDirection` is invoked once per render via `useMemo` keyed on
`props.locales`. Stable input arrays therefore avoid re-deriving
direction on every render.
- The render-prop is invoked as a function, not as JSX (`children(state)`),
so React does not treat the function as a component instance — no
`displayName` warnings, no Strict Mode double-invocation surprise.

View File

@@ -0,0 +1,184 @@
/**
* Storybook stories for `<LanguageSwitcher>`.
*
* The package does not declare a `@storybook/react` dependency. Story-meta
* shapes are inlined so the file ships without forcing a Storybook
* install at the library boundary.
*/
import type { TranslationRecord } from "@suaveplan/i18n";
import { createI18n, type I18nInstance } from "@suaveplan/i18n/plugin";
import type { ReactNode } from "react";
import { TranslationProvider } from "../../provider/provider.js";
import { LanguageSwitcher } from "./language-switcher.js";
import type {
LanguageSwitcherProps,
LanguageSwitcherState,
} from "./language-switcher.types.js";
interface Meta<T> {
readonly component: T;
readonly title: string;
readonly tags?: readonly string[];
}
interface StoryObj<T> {
readonly args?: Partial<LanguageSwitcherProps>;
readonly decorators?: ReadonlyArray<(Story: () => ReactNode) => ReactNode>;
readonly render?: () => ReactNode;
readonly name?: string;
readonly _component?: T;
}
type LocaleKey = "en-GB" | "zh-CN" | "ar-AE" | "cy-GB" | "ru-RU";
const messages: Partial<Record<LocaleKey, TranslationRecord>> = {
"en-GB": { x: "x" },
"zh-CN": { x: "x" },
"ar-AE": { x: "x" },
"cy-GB": { x: "x" },
"ru-RU": { x: "x" },
};
function withProviderFor(locale: LocaleKey) {
return function Wrapper(props: { children: ReactNode }) {
const i18n = createI18n({
defaultLocale: locale,
messages,
}) as I18nInstance;
return (
<TranslationProvider i18n={i18n}>
{props.children}
</TranslationProvider>
);
};
}
const meta: Meta<typeof LanguageSwitcher> = {
component: LanguageSwitcher,
title: "react-i18n/LanguageSwitcher",
tags: ["autodocs"],
};
export default meta;
type Story = StoryObj<typeof LanguageSwitcher>;
function SelectRender(props: { state: LanguageSwitcherState }) {
return (
<select
value={props.state.current}
disabled={props.state.isPending}
onChange={(e) => {
void props.state.change(e.target.value);
}}
>
{props.state.locales.map((l) => (
<option key={l.code} value={l.code}>
{l.code}
</option>
))}
</select>
);
}
export const Default: Story = {
args: {
locales: ["en-GB", "fr-FR", "ar-AE"],
children: (state) => <SelectRender state={state} />,
},
decorators: [
(Story) => {
const W = withProviderFor("en-GB");
return <W>{Story()}</W>;
},
],
};
export const Playground: Story = {
args: {
locales: ["en-GB", "zh-CN", "ar-AE", "cy-GB", "ru-RU"],
children: (state) => (
<pre data-testid="state">{JSON.stringify(state, null, 2)}</pre>
),
},
decorators: [
(Story) => {
const W = withProviderFor("en-GB");
return <W>{Story()}</W>;
},
],
};
export const WithI18nEnglish: Story = {
name: "WithI18n / en-GB",
args: {
locales: ["en-GB", "zh-CN", "ar-AE"],
children: (state) => <SelectRender state={state} />,
},
decorators: [
(Story) => {
const W = withProviderFor("en-GB");
return <W>{Story()}</W>;
},
],
};
export const WithI18nChinese: Story = {
name: "WithI18n / zh-CN",
args: {
locales: ["en-GB", "zh-CN", "ar-AE"],
children: (state) => <SelectRender state={state} />,
},
decorators: [
(Story) => {
const W = withProviderFor("zh-CN");
return <W>{Story()}</W>;
},
],
};
export const WithI18nArabic: Story = {
name: "WithI18n / ar-AE (RTL)",
args: {
locales: ["en-GB", "zh-CN", "ar-AE"],
children: (state) => (
<div dir="rtl">
<SelectRender state={state} />
</div>
),
},
decorators: [
(Story) => {
const W = withProviderFor("ar-AE");
return <W>{Story()}</W>;
},
],
};
export const WithI18nWelsh: Story = {
name: "WithI18n / cy-GB",
args: {
locales: ["en-GB", "cy-GB", "zh-CN"],
children: (state) => <SelectRender state={state} />,
},
decorators: [
(Story) => {
const W = withProviderFor("cy-GB");
return <W>{Story()}</W>;
},
],
};
export const WithI18nRussian: Story = {
name: "WithI18n / ru-RU",
args: {
locales: ["en-GB", "ru-RU", "ar-AE"],
children: (state) => <SelectRender state={state} />,
},
decorators: [
(Story) => {
const W = withProviderFor("ru-RU");
return <W>{Story()}</W>;
},
],
};

View File

@@ -0,0 +1,200 @@
import { createI18n } from "@suaveplan/i18n/plugin";
import { describe, expect, it } from "@suaveplan/testing/runner";
import { renderComponent } from "@suaveplan/testing-dom/render";
import { act } from "@testing-library/react";
import { TranslationProvider } from "../../provider/provider.js";
import { LanguageSwitcher } from "./language-switcher.js";
import type { LanguageSwitcherState } from "./language-switcher.types.js";
function rootCode(err: unknown): string | undefined {
let current: unknown = err;
let lastCode: string | undefined;
let depth = 0;
while (current instanceof Error && depth < 10) {
const code = (current as Error & { code?: string }).code;
if (code !== undefined) lastCode = code;
const cause = (current as Error & { cause?: unknown }).cause;
if (cause === undefined || cause === null) break;
current = cause;
depth += 1;
}
return lastCode;
}
describe("LanguageSwitcher", () => {
it("render prop receives full state with current locale", {
timeout: 5000,
}, () => {
const i18n = createI18n({
defaultLocale: "en-GB",
messages: {
"en-GB": { x: "x" },
"fr-FR": { x: "x" },
"ar-AE": { x: "x" },
},
});
let captured: LanguageSwitcherState | undefined;
renderComponent(
<TranslationProvider i18n={i18n}>
<LanguageSwitcher locales={["en-GB", "fr-FR", "ar-AE"]}>
{(state) => {
captured = state;
return <span data-testid="probe">{state.current}</span>;
}}
</LanguageSwitcher>
</TranslationProvider>
);
expect(captured).toBeDefined();
expect(captured?.current).toBe("en-GB");
expect(captured?.isPending).toBe(false);
expect(captured?.locales.length).toBe(3);
expect(captured?.locales[0]?.code).toBe("en-GB");
expect(captured?.locales[2]?.direction).toBe("rtl");
});
it("change wraps in transition and resolves to new locale", {
timeout: 5000,
}, async () => {
const i18n = createI18n({
defaultLocale: "en-GB",
messages: {
"en-GB": { x: "x" },
"fr-FR": { x: "x" },
},
});
let captured: LanguageSwitcherState | undefined;
renderComponent(
<TranslationProvider i18n={i18n}>
<LanguageSwitcher locales={["en-GB", "fr-FR"]}>
{(state) => {
captured = state;
return null;
}}
</LanguageSwitcher>
</TranslationProvider>
);
await act(async () => {
await captured?.change("fr-FR");
});
expect(captured?.current).toBe("fr-FR");
expect(captured?.isPending).toBe(false);
});
it("accepts descriptor objects without overriding direction", {
timeout: 5000,
}, () => {
const i18n = createI18n({
defaultLocale: "en-GB",
messages: { "en-GB": {} },
});
let captured: LanguageSwitcherState | undefined;
renderComponent(
<TranslationProvider i18n={i18n}>
<LanguageSwitcher
locales={[
{
code: "ar-AE",
name: "Arabic",
nativeName: "العربية",
direction: "rtl",
},
]}
>
{(state) => {
captured = state;
return null;
}}
</LanguageSwitcher>
</TranslationProvider>
);
expect(captured?.locales[0]?.direction).toBe("rtl");
expect(captured?.locales[0]?.name).toBe("Arabic");
});
it("derives direction for descriptors without explicit direction", {
timeout: 5000,
}, () => {
const i18n = createI18n({
defaultLocale: "en-GB",
messages: { "en-GB": {} },
});
let captured: LanguageSwitcherState | undefined;
renderComponent(
<TranslationProvider i18n={i18n}>
<LanguageSwitcher
locales={[
{ code: "en-GB", name: "English" },
{ code: "ar-AE", name: "Arabic" },
]}
>
{(state) => {
captured = state;
return null;
}}
</LanguageSwitcher>
</TranslationProvider>
);
expect(captured?.locales[0]?.direction).toBe("ltr");
expect(captured?.locales[1]?.direction).toBe("rtl");
});
it("rejects when changeLanguage rejects", { timeout: 5000 }, async () => {
const i18n = createI18n({
defaultLocale: "en-GB",
messages: { "en-GB": {} },
});
// Force changeLanguage to reject so the awaiting flag still
// resets and the rejection propagates.
const failure = new Error("boom");
i18n.changeLanguage = () => Promise.reject(failure);
let captured: LanguageSwitcherState | undefined;
renderComponent(
<TranslationProvider i18n={i18n}>
<LanguageSwitcher locales={["en-GB", "fr-FR"]}>
{(state) => {
captured = state;
return null;
}}
</LanguageSwitcher>
</TranslationProvider>
);
let thrown: unknown;
await act(async () => {
try {
await captured?.change("fr-FR");
} catch (err) {
thrown = err;
}
});
expect(thrown).toBe(failure);
expect(captured?.isPending).toBe(false);
});
it("throws REACT_I18N.NO_PROVIDER outside a Provider", {
timeout: 5000,
}, () => {
const original = console.error;
console.error = (() => {}) as typeof console.error;
try {
let thrown: unknown;
try {
renderComponent(
<LanguageSwitcher locales={["en-GB"]}>
{() => null}
</LanguageSwitcher>
);
} catch (err) {
thrown = err;
}
expect(thrown).toBeDefined();
expect(rootCode(thrown)).toBe("REACT_I18N.NO_PROVIDER");
} finally {
console.error = original;
}
});
});

View File

@@ -0,0 +1,136 @@
import { getTextDirection } from "@suaveplan/i18n";
import {
type ReactElement,
useCallback,
useContext,
useMemo,
useState,
useSyncExternalStore,
useTransition,
} from "react";
import { REACT_I18N_CODES } from "../../errors/error-codes.js";
import { ReactI18nError } from "../../errors/react-i18n-error.js";
import { TranslationContext } from "../../provider/provider-context.js";
import type {
LanguageSwitcherProps,
LanguageSwitcherState,
LocaleDescriptor,
} from "./language-switcher.types.js";
/**
* Headless language-switcher component.
*
* `<LanguageSwitcher locales={[...]}>{(state) => ReactNode}</LanguageSwitcher>`
* renders nothing of its own. The render-prop receives a fully-resolved
* state object with the current locale, an array of `LocaleDescriptor`
* entries derived from the supplied codes, and a `change(locale)`
* callback wrapped in `useTransition` so namespace loads triggered by
* the switch do not block urgent updates. Throws
* `ReactI18nError(REACT_I18N.NO_PROVIDER)` outside a Provider.
*/
export function LanguageSwitcher(
props: LanguageSwitcherProps
): ReactElement | null {
const i18n = useContext(TranslationContext);
if (i18n === null) {
throw new ReactI18nError(
REACT_I18N_CODES.NO_PROVIDER,
"react-i18n.no_provider.language_switcher"
);
}
// Subscribe to the bus so `current` and the render result reflect
// language-change events. Bumps the carrier counter on every notify
// so the snapshot advances regardless of listener order versus the
// Provider's own snapshot ticker. The shared reader covers both
// client and server snapshots so SSR-rendered output matches the
// initial client read.
const snapshotReader = () => readSnapshot(i18n);
useSyncExternalStore(
(notify) => {
const tickAndNotify = () => {
bumpSnapshot(i18n);
notify();
};
const offLanguage = i18n.events.on(
"languageChanged",
tickAndNotify
);
const offLoaded = i18n.events.on("loaded", tickAndNotify);
return () => {
offLanguage();
offLoaded();
};
},
snapshotReader,
snapshotReader
);
const [isPending, startTransition] = useTransition();
// Track an explicit "in-flight" flag for the awaitable `change` so
// tests can observe the transition lifecycle. `useTransition` alone
// would only reflect render-time pendingness.
const [awaiting, setAwaiting] = useState(false);
const descriptors = useMemo(
() => normaliseLocales(props.locales),
[props.locales]
);
const change = useCallback(
async (next: string): Promise<void> => {
setAwaiting(true);
try {
// The transition wrapper marks the React work that follows
// `i18n.changeLanguage` as low-priority so other inputs
// remain responsive while the catalog updates.
await new Promise<void>((resolve, reject) => {
startTransition(() => {
i18n.changeLanguage(next).then(resolve, reject);
});
});
} finally {
setAwaiting(false);
}
},
[i18n]
);
const state: LanguageSwitcherState = {
locales: descriptors,
current: i18n.locale,
change,
isPending: isPending || awaiting,
};
return <>{props.children(state)}</>;
}
LanguageSwitcher.displayName = "SuaveplanLanguageSwitcher";
function normaliseLocales(
input: ReadonlyArray<string | LocaleDescriptor>
): readonly LocaleDescriptor[] {
return input.map((entry) => {
if (typeof entry === "string") {
return { code: entry, direction: getTextDirection(entry) };
}
if (entry.direction === undefined) {
return { ...entry, direction: getTextDirection(entry.code) };
}
return entry;
});
}
interface SnapshotCarrier {
__reactI18nSnapshot?: number;
}
function readSnapshot(instance: unknown): number {
return (instance as SnapshotCarrier).__reactI18nSnapshot ?? 0;
}
function bumpSnapshot(instance: unknown): void {
const carrier = instance as SnapshotCarrier;
carrier.__reactI18nSnapshot = (carrier.__reactI18nSnapshot ?? 0) + 1;
}

View File

@@ -0,0 +1,51 @@
import type { ReactNode } from "react";
/**
* Per-locale descriptor surfaced to the render-prop. The minimal shape
* matches `@suaveplan/locale-data`'s `LanguageItem` plus the BCP-47
* locale code, with the optional `direction` field exposed for RTL-aware
* UI without forcing a separate lookup.
*/
export interface LocaleDescriptor {
/** BCP-47 locale code (e.g. `"en-GB"`, `"ar-AE"`). */
readonly code: string;
/** Latin-script display name (e.g. `"English"`). Optional. */
readonly name?: string;
/** Native-script display name (e.g. `"العربية"`). Optional. */
readonly nativeName?: string;
/** Text direction. Defaults to `"ltr"` when omitted. */
readonly direction?: "ltr" | "rtl";
}
/**
* State surface delivered to the render-prop child.
*
* `change(locale)` is wrapped in `useTransition` so namespace loads
* triggered by the locale switch do not block urgent updates. The
* caller observes pending loads via `isPending`.
*/
export interface LanguageSwitcherState {
/** Locales accepted by the switcher (resolved via the `locales` prop). */
readonly locales: readonly LocaleDescriptor[];
/** Active locale code from the bound `I18nInstance`. */
readonly current: string;
/** Switch the active locale (returns when the namespace load resolves). */
readonly change: (locale: string) => Promise<void>;
/** True while a `useTransition`-wrapped change is in-flight. */
readonly isPending: boolean;
}
/**
* Props for `<LanguageSwitcher>`.
*
* The `locales` prop accepts either a string array (codes only) or a
* descriptor array (codes + names). Strings are upgraded to descriptors
* with `code === input` so the render-prop always receives a uniform
* shape.
*/
export interface LanguageSwitcherProps {
/** Locales advertised to the render prop. Required. */
readonly locales: ReadonlyArray<string | LocaleDescriptor>;
/** Headless render-prop child. The component renders no DOM itself. */
readonly children: (state: LanguageSwitcherState) => ReactNode;
}

View File

@@ -0,0 +1,5 @@
export { MissingKeyOverlay } from "./missing-key-overlay.js";
export type {
MissingKeyEntry,
MissingKeyOverlayProps,
} from "./missing-key-overlay.types.js";

View File

@@ -0,0 +1,106 @@
# `<MissingKeyOverlay>`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`<MissingKeyOverlay>` is a dev-mode visual diagnostic that surfaces every
`missingKey` event emitted by the active `I18nInstance` as a fixed-position
list anchored to the bottom-right corner of the viewport. The overlay
gives engineers a passive, always-on view of translation gaps as they
develop — no console-tab toggling, no debug flag, no polling. In
production builds the component renders `null` and registers no event
listener so it costs nothing once shipped.
## Features
- Subscribes to `missingKey` events on the Provider's instance bus
- Renders a fixed-position list with `aria-live="polite"` so screen
readers announce new entries without focus theft
- Caps retained entries at the most-recent `maxEntries` (default 50);
older entries silently drop off
- Returns `null` in production (`process.env.NODE_ENV === 'production'`)
- Returns `null` when no Provider is mounted yet (no listener leak)
- Ergonomic style defaults (monospace, dark background) plus per-entry
locale and namespace meta
## Usage: Basic Example
Mount the overlay once near the root of the application — it auto-tracks
every miss for the lifetime of the active Provider:
```tsx
import { TranslationProvider } from "@suaveplan/react-i18n";
import { MissingKeyOverlay } from "@suaveplan/react-i18n/components/missing-key-overlay";
<TranslationProvider config={i18nConfig}>
<App />
<MissingKeyOverlay />
</TranslationProvider>;
```
In dev, every `t('absent.key')` call appears in the overlay. In a
production bundle the component compiles to a no-op return.
## Usage: Advanced Example
A custom cap with explicit prod override and a portal mount so the
overlay sits in a dedicated DOM root:
```tsx
import { createPortal } from "react-dom";
import { MissingKeyOverlay } from "@suaveplan/react-i18n/components/missing-key-overlay";
function DiagnosticsPortal() {
const root = document.getElementById("diagnostics-root");
if (!root) return null;
return createPortal(
<MissingKeyOverlay
// Force the overlay on even in a prod build for QA staging
production={false}
maxEntries={200}
/>,
root
);
}
```
## API Reference
### `MissingKeyOverlay(props?: MissingKeyOverlayProps): ReactElement | null`
Props:
- `maxEntries?: number` — hard cap on retained entries. Default: `50`.
- `production?: boolean` — override for the runtime env-check. When
`true`, the component returns `null` and does not subscribe.
Returns: `null` in production, when no Provider ancestor is mounted, or
when no `missingKey` events have fired yet. Otherwise returns the
fixed-position overlay.
### `MissingKeyEntry`
- `id: number` — monotonically increasing counter (used as React key)
- `key: string` — the missing translation key
- `locale: string` — locale at the moment the event was observed
- `namespace: string` — namespace label (empty string when no namespace)
## Implementation Notes
- The component uses `useEffect` (not `useSyncExternalStore`) for the bus
subscription because the entry list is a piece of internal React state
driven by the bus rather than a snapshot of an external store. The two
patterns are interchangeable here, and `useEffect` is simpler to
reason about for a buffer with bounded capacity.
- The append-and-cap helper (`appendCapped`) does the slice work outside
the React update so the overlay never re-allocates the entire history
on every event — only the bounded tail.
- The fixed-position styles are applied inline so the overlay is
self-contained: no external CSS file, no Tailwind dependency, no risk
of the host app's stylesheet hiding it.
- The `production` prop exists to make the prod-path testable without
mutating `process.env.NODE_ENV`. The default reads `isProductionEnv()`
on every render so consumers do not need to thread the flag through
manually in their own builds.

View File

@@ -0,0 +1,153 @@
/**
* Storybook stories for `<MissingKeyOverlay>`.
*
* The package does not declare a `@storybook/react` dependency. Story-meta
* shapes are inlined so the file ships without forcing a Storybook
* install at the library boundary.
*/
import type { TranslationRecord } from "@suaveplan/i18n";
import { createI18n, type I18nInstance } from "@suaveplan/i18n/plugin";
import { type ReactNode, useEffect, useState } from "react";
import { TranslationProvider } from "../../provider/provider.js";
import { MissingKeyOverlay } from "./missing-key-overlay.js";
interface Meta<T> {
readonly component: T;
readonly title: string;
readonly tags?: readonly string[];
}
interface StoryObj<T> {
readonly args?: Record<string, unknown>;
readonly decorators?: ReadonlyArray<(Story: () => ReactNode) => ReactNode>;
readonly render?: () => ReactNode;
readonly name?: string;
readonly _component?: T;
}
type LocaleKey = "en-GB" | "zh-CN" | "ar-AE" | "cy-GB" | "ru-RU";
const messages: Partial<Record<LocaleKey, TranslationRecord>> = {
"en-GB": {},
"zh-CN": {},
"ar-AE": {},
"cy-GB": {},
"ru-RU": {},
};
function StoryHarness(props: {
locale: LocaleKey;
keys: readonly string[];
children?: ReactNode;
}) {
const [i18n] = useState<I18nInstance>(
() =>
createI18n({
defaultLocale: props.locale,
messages,
}) as I18nInstance
);
useEffect(() => {
let i = 0;
const id = setInterval(() => {
const key = props.keys[i % props.keys.length];
if (key !== undefined) {
i18n.events.emit("missingKey", {
locale: props.locale,
namespace: "auth",
key,
});
}
i += 1;
}, 600);
return () => clearInterval(id);
}, [i18n, props.keys, props.locale]);
return (
<TranslationProvider i18n={i18n}>
{props.children}
<MissingKeyOverlay production={false} />
</TranslationProvider>
);
}
const meta: Meta<typeof MissingKeyOverlay> = {
component: MissingKeyOverlay,
title: "react-i18n/MissingKeyOverlay",
tags: ["autodocs"],
};
export default meta;
type Story = StoryObj<typeof MissingKeyOverlay>;
export const Default: Story = {
render: () => (
<StoryHarness
locale="en-GB"
keys={["page.welcome", "auth.login.title", "common.cancel"]}
>
<p>Open dev tools — entries stream into the overlay.</p>
</StoryHarness>
),
};
export const Playground: Story = {
render: () => (
<StoryHarness
locale="en-GB"
keys={["a.1", "a.2", "a.3", "a.4", "a.5"]}
/>
),
};
export const WithI18nEnglish: Story = {
name: "WithI18n / en-GB",
render: () => (
<StoryHarness
locale="en-GB"
keys={["pricing.cta", "footer.copyright"]}
/>
),
};
export const WithI18nChinese: Story = {
name: "WithI18n / zh-CN",
render: () => (
<StoryHarness
locale="zh-CN"
keys={["pricing.cta", "footer.copyright"]}
/>
),
};
export const WithI18nArabic: Story = {
name: "WithI18n / ar-AE (RTL)",
render: () => (
<div dir="rtl">
<StoryHarness
locale="ar-AE"
keys={["pricing.cta", "footer.copyright"]}
/>
</div>
),
};
export const WithI18nWelsh: Story = {
name: "WithI18n / cy-GB",
render: () => (
<StoryHarness
locale="cy-GB"
keys={["pricing.cta", "footer.copyright"]}
/>
),
};
export const WithI18nRussian: Story = {
name: "WithI18n / ru-RU",
render: () => (
<StoryHarness
locale="ru-RU"
keys={["pricing.cta", "footer.copyright"]}
/>
),
};

View File

@@ -0,0 +1,291 @@
import { createI18n } from "@suaveplan/i18n/plugin";
import { describe, expect, it } from "@suaveplan/testing/runner";
import { renderComponent } from "@suaveplan/testing-dom/render";
import { act } from "@testing-library/react";
import { TranslationProvider } from "../../provider/provider.js";
import { MissingKeyOverlay } from "./missing-key-overlay.js";
describe("MissingKeyOverlay", () => {
it("renders nothing in production", { timeout: 5000 }, () => {
const i18n = createI18n({
defaultLocale: "en-GB",
messages: { "en-GB": {} },
});
const { container } = renderComponent(
<TranslationProvider i18n={i18n}>
<MissingKeyOverlay production={true} />
</TranslationProvider>
);
expect(
container.querySelector(
"[data-testid='suaveplan-missing-key-overlay']"
)
).toBeNull();
});
it("renders nothing when no missingKey events fire yet", {
timeout: 5000,
}, () => {
const i18n = createI18n({
defaultLocale: "en-GB",
messages: { "en-GB": {} },
});
const { container } = renderComponent(
<TranslationProvider i18n={i18n}>
<MissingKeyOverlay production={false} />
</TranslationProvider>
);
expect(
container.querySelector(
"[data-testid='suaveplan-missing-key-overlay']"
)
).toBeNull();
});
it("renders an entry when a missingKey event fires", {
timeout: 5000,
}, () => {
const i18n = createI18n({
defaultLocale: "en-GB",
messages: { "en-GB": {} },
});
const { container } = renderComponent(
<TranslationProvider i18n={i18n}>
<MissingKeyOverlay production={false} />
</TranslationProvider>
);
act(() => {
i18n.events.emit("missingKey", {
locale: "en-GB",
namespace: "auth",
key: "absent.key",
});
});
const overlay = container.querySelector(
"[data-testid='suaveplan-missing-key-overlay']"
);
expect(overlay).not.toBeNull();
expect(overlay?.getAttribute("aria-live")).toBe("polite");
expect(overlay?.textContent).toContain("absent.key");
expect(overlay?.textContent).toContain("en-GB");
expect(overlay?.textContent).toContain("auth");
});
it("caps entries at maxEntries (default 50)", { timeout: 5000 }, () => {
const i18n = createI18n({
defaultLocale: "en-GB",
messages: { "en-GB": {} },
});
const { container } = renderComponent(
<TranslationProvider i18n={i18n}>
<MissingKeyOverlay production={false} />
</TranslationProvider>
);
act(() => {
for (let i = 0; i < 60; i++) {
i18n.events.emit("missingKey", {
locale: "en-GB",
namespace: "",
key: `key.${i}`,
});
}
});
const items = container.querySelectorAll("li");
expect(items.length).toBe(50);
// Oldest should be key.10 (60 - 50 = 10), newest key.59
expect(items[0]?.textContent).toContain("key.10");
expect(items[items.length - 1]?.textContent).toContain("key.59");
});
it("respects a custom maxEntries override", { timeout: 5000 }, () => {
const i18n = createI18n({
defaultLocale: "en-GB",
messages: { "en-GB": {} },
});
const { container } = renderComponent(
<TranslationProvider i18n={i18n}>
<MissingKeyOverlay production={false} maxEntries={3} />
</TranslationProvider>
);
act(() => {
for (let i = 0; i < 5; i++) {
i18n.events.emit("missingKey", {
locale: "en-GB",
namespace: "",
key: `k.${i}`,
});
}
});
const items = container.querySelectorAll("li");
expect(items.length).toBe(3);
expect(items[0]?.textContent).toContain("k.2");
expect(items[2]?.textContent).toContain("k.4");
});
it("hides namespace separator when namespace is empty", {
timeout: 5000,
}, () => {
const i18n = createI18n({
defaultLocale: "en-GB",
messages: { "en-GB": {} },
});
const { container } = renderComponent(
<TranslationProvider i18n={i18n}>
<MissingKeyOverlay production={false} />
</TranslationProvider>
);
act(() => {
i18n.events.emit("missingKey", {
locale: "en-GB",
namespace: "",
key: "no.ns",
});
});
const overlay = container.querySelector(
"[data-testid='suaveplan-missing-key-overlay']"
);
expect(overlay?.textContent).not.toContain(" · ");
});
it("renders nothing without a Provider ancestor", {
timeout: 5000,
}, () => {
const { container } = renderComponent(
<MissingKeyOverlay production={false} />
);
expect(
container.querySelector(
"[data-testid='suaveplan-missing-key-overlay']"
)
).toBeNull();
});
it("falls back to runtime env detection when production prop is omitted", {
timeout: 5000,
}, () => {
const original = (
globalThis as unknown as {
process?: { env?: { NODE_ENV?: string } };
}
).process?.env?.NODE_ENV;
const proc = (
globalThis as unknown as {
process?: { env?: { NODE_ENV?: string } };
}
).process;
if (proc?.env) proc.env.NODE_ENV = "production";
const i18n = createI18n({
defaultLocale: "en-GB",
messages: { "en-GB": {} },
});
try {
const { container } = renderComponent(
<TranslationProvider i18n={i18n}>
<MissingKeyOverlay />
</TranslationProvider>
);
act(() => {
i18n.events.emit("missingKey", {
locale: "en-GB",
namespace: "",
key: "shouldNotRender",
});
});
expect(
container.querySelector(
"[data-testid='suaveplan-missing-key-overlay']"
)
).toBeNull();
} finally {
if (proc?.env) proc.env.NODE_ENV = original ?? "";
}
});
it("renders nothing when called with no props (default object)", {
timeout: 5000,
}, () => {
// Exercises the `props: MissingKeyOverlayProps = {}` default.
const i18n = createI18n({
defaultLocale: "en-GB",
messages: { "en-GB": {} },
});
const proc = (
globalThis as unknown as {
process?: { env?: { NODE_ENV?: string } };
}
).process;
const original = proc?.env?.NODE_ENV;
if (proc?.env) proc.env.NODE_ENV = "development";
try {
const { container } = renderComponent(
<TranslationProvider i18n={i18n}>
<MissingKeyOverlay />
</TranslationProvider>
);
// No events fired yet → still null.
expect(
container.querySelector(
"[data-testid='suaveplan-missing-key-overlay']"
)
).toBeNull();
} finally {
if (proc?.env) proc.env.NODE_ENV = original ?? "";
}
});
it("handles a missingKey payload with undefined namespace", {
timeout: 5000,
}, () => {
const i18n = createI18n({
defaultLocale: "en-GB",
messages: { "en-GB": {} },
});
const { container } = renderComponent(
<TranslationProvider i18n={i18n}>
<MissingKeyOverlay production={false} />
</TranslationProvider>
);
act(() => {
i18n.events.emit("missingKey", {
locale: "en-GB",
// namespace deliberately omitted to exercise the
// `payload.namespace ?? ""` fallback branch.
namespace: undefined as unknown as string,
key: "no.namespace",
});
});
const overlay = container.querySelector(
"[data-testid='suaveplan-missing-key-overlay']"
);
expect(overlay?.textContent).toContain("no.namespace");
});
it("removes the bus listener on unmount", { timeout: 5000 }, () => {
const i18n = createI18n({
defaultLocale: "en-GB",
messages: { "en-GB": {} },
});
const { container, unmount } = renderComponent(
<TranslationProvider i18n={i18n}>
<MissingKeyOverlay production={false} />
</TranslationProvider>
);
act(() => {
i18n.events.emit("missingKey", {
locale: "en-GB",
namespace: "",
key: "first",
});
});
expect(container.textContent).toContain("first");
unmount();
// Emit after unmount; the listener should be gone, no throw.
expect(() =>
i18n.events.emit("missingKey", {
locale: "en-GB",
namespace: "",
key: "second",
})
).not.toThrow();
});
});

View File

@@ -0,0 +1,137 @@
import {
type ReactElement,
useContext,
useEffect,
useRef,
useState,
} from "react";
import { TranslationContext } from "../../provider/provider-context.js";
import { isProductionEnv } from "../../utils/env.js";
import type {
MissingKeyEntry,
MissingKeyOverlayProps,
} from "./missing-key-overlay.types.js";
const DEFAULT_MAX_ENTRIES = 50;
/**
* Dev-mode overlay that lists every untranslated key as it streams off
* the active `I18nInstance`'s event bus.
*
* The overlay subscribes to the `missingKey` topic and renders a
* fixed-position list with `aria-live="polite"` so screen-reader users
* notice incoming entries without forced focus changes. The list is
* capped at the most-recent `maxEntries` (default 50) — older entries
* silently drop off so the overlay never balloons in long-running dev
* sessions. In production builds the overlay returns `null` and never
* subscribes to the bus so it adds zero runtime cost.
*/
export function MissingKeyOverlay(
props: MissingKeyOverlayProps
): ReactElement | null {
const i18n = useContext(TranslationContext);
const isProd = props.production ?? isProductionEnv();
const [entries, setEntries] = useState<readonly MissingKeyEntry[]>([]);
const counterRef = useRef(0);
const max = props.maxEntries ?? DEFAULT_MAX_ENTRIES;
useEffect(() => {
// Production builds neither subscribe nor render; the early
// return on `i18n === null` keeps the hook chain consistent
// when there is no Provider ancestor (e.g. an import-but-don't-
// mount path during SSR scaffolding).
if (isProd || i18n === null) return;
const off = i18n.events.on("missingKey", (payload) => {
counterRef.current += 1;
const next: MissingKeyEntry = {
id: counterRef.current,
key: String(payload.key),
locale: String(payload.locale),
namespace: String(payload.namespace ?? ""),
};
setEntries((prev) => appendCapped(prev, next, max));
});
return () => {
off();
};
}, [i18n, isProd, max]);
if (isProd) return null;
if (entries.length === 0) return null;
return (
<div
data-testid="suaveplan-missing-key-overlay"
aria-live="polite"
style={overlayStyle}
>
<ul style={listStyle}>
{entries.map((entry) => (
<li key={entry.id} style={itemStyle}>
<span data-key={entry.key}>{entry.key}</span>
<span style={metaStyle}>
{entry.locale}
{entry.namespace !== ""
? ` · ${entry.namespace}`
: ""}
</span>
</li>
))}
</ul>
</div>
);
}
MissingKeyOverlay.displayName = "SuaveplanMissingKeyOverlay";
/**
* Append `next` to `prev`, dropping the oldest entry when the list
* exceeds `max`. The function never mutates `prev` so React diffs the
* returned array as a fresh value.
*/
function appendCapped(
prev: readonly MissingKeyEntry[],
next: MissingKeyEntry,
max: number
): readonly MissingKeyEntry[] {
const out =
prev.length >= max ? prev.slice(prev.length - max + 1) : [...prev];
out.push(next);
return out;
}
const overlayStyle: React.CSSProperties = {
position: "fixed",
bottom: "1rem",
right: "1rem",
maxWidth: "32rem",
maxHeight: "16rem",
overflowY: "auto",
padding: "0.5rem 0.75rem",
background: "rgba(20, 20, 20, 0.92)",
color: "#fff",
fontFamily: "monospace",
fontSize: "0.75rem",
lineHeight: 1.4,
borderRadius: "0.375rem",
boxShadow: "0 4px 16px rgba(0, 0, 0, 0.35)",
zIndex: 2_147_483_647,
};
const listStyle: React.CSSProperties = {
listStyle: "none",
margin: 0,
padding: 0,
};
const itemStyle: React.CSSProperties = {
display: "flex",
justifyContent: "space-between",
gap: "1rem",
padding: "0.125rem 0",
};
const metaStyle: React.CSSProperties = {
opacity: 0.65,
whiteSpace: "nowrap",
};

View File

@@ -0,0 +1,36 @@
/**
* One captured `missingKey` event surfaced through the dev-mode overlay.
*
* Mirrors the runtime payload emitted by `MessageCatalog` / `I18nInstance`,
* with the locale frozen at the moment the miss was observed so
* subsequent language changes do not retroactively re-attribute the entry.
*/
export interface MissingKeyEntry {
/** Stable counter used as the React `key` and to dedupe rapid re-emits. */
readonly id: number;
/** Translation key that triggered the missing-key event. */
readonly key: string;
/** Active locale at the moment the event was observed. */
readonly locale: string;
/** Namespace label (empty string when no namespace). */
readonly namespace: string;
}
/**
* Props for `<MissingKeyOverlay>`.
*
* `maxEntries` defaults to 50 (matching the spec). `production` is an
* injectable override of the env-detection logic — exposed for tests so
* the production-render-null path can be exercised without mutating
* `process.env`.
*/
export interface MissingKeyOverlayProps {
/** Hard cap on the most-recent entries retained. Default: 50. */
readonly maxEntries?: number;
/**
* Override for the production-mode detection. When `true`, the
* overlay renders nothing and registers no listener. Defaults to the
* runtime env-check (`isProductionEnv()`).
*/
readonly production?: boolean;
}

View File

@@ -0,0 +1,6 @@
export {
childrenToMessageString,
stringifyChild,
} from "./stringify-children.js";
export { Trans } from "./trans.js";
export type { TransProps } from "./trans.types.js";

View File

@@ -0,0 +1,85 @@
# `stringify-children`
## Purpose
Helpers that translate a `<Trans>` `children` block into a canonical
ICU message body string so the message parser renders a missing-key
fallback identically to a catalog entry. The module exists as a sibling
of `<Trans>` (rather than inline) so the trans component file stays
within the per-file size budget and so the structurally-invalid-child
branch can be unit-tested directly without going through React's JSX
surface (which validates and rejects invalid children before they
reach our `Children.forEach` callback).
## Features
- `childrenToMessageString` — entry point that walks `Children.forEach`
and concatenates each child's stringified form
- `stringifyChild` — single-child stringifier, exported for direct unit
testing of the defensive `TRANS_INVALID_CHILD` throw
- Nested children are recursed; tag-wrapped children render as
`<tagName>...</tagName>` with the same component-map semantics
- Boolean, `null`, and `undefined` children render as the empty string
(matches React's own rendering rules)
- Defensive throw of `ReactI18nError(REACT_I18N.TRANS_INVALID_CHILD)`
for any structurally-invalid value (functions, custom objects)
## Usage: Basic Example
```ts
import { childrenToMessageString } from "@suaveplan/react-i18n/components/trans/stringify-children";
const body = childrenToMessageString(
<>Hello <strong>{"{name}"}</strong>!</>,
"absent.key"
);
// body === "Hello <strong>{name}</strong>!"
```
## Usage: Advanced Example
The helpers operate purely on React children, so consumers can use
them outside `<Trans>` for snapshot / logging surfaces where a
React-children tree needs a stable string serialisation:
```ts
import { stringifyChild } from "@suaveplan/react-i18n/components/trans/stringify-children";
function snapshotChild(node: React.ReactNode): string {
try {
return stringifyChild(node, "snapshot");
} catch (err) {
return `[invalid: ${(err as Error).message}]`;
}
}
```
## API Reference
### `childrenToMessageString(children: ReactNode, key: string): string`
Walks `React.Children.forEach` over `children` and concatenates each
result of `stringifyChild`. Returns the literal `key` string when
`children` is `null` or `undefined`.
### `stringifyChild(child: ReactNode, key: string): string`
Stringifies one React child. Recurses into element children via
`childrenToMessageString` for the tag body. Throws
`ReactI18nError(REACT_I18N.TRANS_INVALID_CHILD)` with `context: { key }`
for structurally-invalid input.
## Implementation Notes
- The module is intentionally side-effect-free; both helpers are pure
string returners with the single exception of the defensive throw.
- Element children with no `props.children` produce `<tagName></tagName>`
rather than `<tagName>key</tagName>` — the nested-children helper
reads the children prop with a fall-through to the empty string so
the recursion does not mistakenly inject the outer key into a
childless tag body.
- The defensive throw exists for the edge case where TypeScript's
`ReactNode` typing is bypassed by a caller who hand-constructs a
child via `React.createElement`. In typical JSX usage React's own
pre-render validation rejects invalid values before our callback
runs.

View File

@@ -0,0 +1,94 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import {
childrenToMessageString,
stringifyChild,
} from "./stringify-children.js";
describe("stringifyChild", () => {
it("stringifies primitives correctly", { timeout: 5000 }, () => {
expect(stringifyChild("hello", "k")).toBe("hello");
expect(stringifyChild(42, "k")).toBe("42");
expect(stringifyChild(true, "k")).toBe("");
expect(stringifyChild(false, "k")).toBe("");
expect(stringifyChild(null, "k")).toBe("");
expect(stringifyChild(undefined, "k")).toBe("");
});
it("handles elements without children prop", { timeout: 5000 }, () => {
expect(stringifyChild(<br />, "k")).toBe("<br></br>");
expect(stringifyChild(<strong />, "k")).toBe("<strong></strong>");
});
it("throws TRANS_INVALID_CHILD for structurally-invalid input", {
timeout: 5000,
}, () => {
// Direct unit test against `stringifyChild`. React's pre-render
// child validation rejects most invalid values before they reach
// the helper through the JSX surface, so the defensive throw is
// only reachable through direct invocation.
let thrown: unknown;
try {
stringifyChild(
{ a: 1 } as unknown as React.ReactNode,
"absent.bad"
);
} catch (err) {
thrown = err;
}
expect(thrown).toBeDefined();
expect((thrown as { code?: string }).code).toBe(
"REACT_I18N.TRANS_INVALID_CHILD"
);
});
it("renders nested elements with displayName as the tag name", {
timeout: 5000,
}, () => {
const Bold = (props: { children?: React.ReactNode }) => (
<strong>{props.children}</strong>
);
Bold.displayName = "Bold";
expect(stringifyChild(<Bold>{"x"}</Bold>, "k")).toBe("<Bold>x</Bold>");
});
it("falls back to function name when displayName is absent", {
timeout: 5000,
}, () => {
function Italic(props: { children?: React.ReactNode }) {
return <em>{props.children}</em>;
}
expect(stringifyChild(<Italic>{"y"}</Italic>, "k")).toBe(
"<Italic>y</Italic>"
);
});
it("falls back to 'x' when neither displayName nor name is present", {
timeout: 5000,
}, () => {
const Anon = (props: { children?: React.ReactNode }) => (
<span>{props.children}</span>
);
Object.defineProperty(Anon, "name", { value: "" });
expect(stringifyChild(<Anon>{"z"}</Anon>, "k")).toBe("<x>z</x>");
});
});
describe("childrenToMessageString", () => {
it("returns the key when children is null", { timeout: 5000 }, () => {
expect(childrenToMessageString(null, "absent.k")).toBe("absent.k");
});
it("returns the key when children is undefined", { timeout: 5000 }, () => {
expect(childrenToMessageString(undefined, "absent.k")).toBe("absent.k");
});
it("concatenates a mixed children array into an ICU body", {
timeout: 5000,
}, () => {
const result = childrenToMessageString(
["Hello ", <strong key="b">{"World"}</strong>, "!"],
"k"
);
expect(result).toBe("Hello <strong>World</strong>!");
});
});

View File

@@ -0,0 +1,112 @@
/**
* Children-stringification helpers for `<Trans>` missing-key fallback.
*
* When the catalog has no entry for the requested key, `<Trans>` falls
* back to its `children` prop and treats it as a default ICU message.
* The helpers in this module walk the React-children tree and emit a
* canonical string form (`Hello <strong>{name}</strong>!`) that the
* parser renders identically to a catalog entry.
*
* Exported for direct unit testing of the structurally-invalid-child
* branch — React's own pre-render child validation rejects most
* invalid values before they reach our `Children.forEach` callback in
* the JSX surface, so the defensive throw is only reachable through
* direct invocation.
*/
import {
Children,
isValidElement,
type ReactElement,
type ReactNode,
} from "react";
import { REACT_I18N_CODES } from "../../errors/error-codes.js";
import { ReactI18nError } from "../../errors/react-i18n-error.js";
/**
* Stringify a `<Trans>` `children` block back to an ICU message body so
* the parser can render it identically to a catalog entry. Used only on
* the missing-key fallback path.
*
* - Strings round-trip verbatim
* - Numbers stringify via `String()`
* - Booleans, `null`, and `undefined` render as the empty string (matching
* React's own rendering rules)
* - JSX element children are translated to `<tagName>...</tagName>` so
* the same tag-name → component map drives the render
*
* Throws `ReactI18nError(REACT_I18N.TRANS_INVALID_CHILD)` for any other
* child (functions, custom objects, etc.).
*/
export function childrenToMessageString(
children: ReactNode,
key: string
): string {
if (children === null || children === undefined) return key;
let out = "";
Children.forEach(children, (child) => {
out += stringifyChild(child, key);
});
return out;
}
/**
* Stringify a single React child node. Exposed as a named export so
* the structurally-invalid-child branch can be unit-tested directly.
*/
export function stringifyChild(child: ReactNode, key: string): string {
if (typeof child === "string") return child;
if (typeof child === "number") return String(child);
if (typeof child === "boolean" || child === null || child === undefined) {
return "";
}
if (isValidElement(child)) {
const tagName = elementTagName(child);
const inner = nestedChildrenToString(
(child.props as { children?: ReactNode }).children ?? null,
key
);
return `<${tagName}>${inner}</${tagName}>`;
}
throw new ReactI18nError(
REACT_I18N_CODES.TRANS_INVALID_CHILD,
"react-i18n.trans.invalid_child",
{ context: { key } }
);
}
/**
* Stringify a child's nested children for the tag-wrap path. Empty
* nested-children produce an empty inner string (NOT the outer key) so
* `<br />` round-trips to `<br></br>` rather than `<br>key</br>`.
*/
function nestedChildrenToString(children: ReactNode, key: string): string {
if (children === null || children === undefined) return "";
let out = "";
Children.forEach(children, (child) => {
out += stringifyChild(child, key);
});
return out;
}
/**
* Resolve an ICU tag name for a JSX element. The element's `type` may be a
* string (HTML tag) or a component reference. Components prefer
* `displayName`, then fall back to the function `name`. Empty strings
* (an explicitly cleared `Function.name`) are treated as "no name". The
* default is `"x"` so a tag-less element still serialises to a parseable
* token.
*/
function elementTagName(element: ReactElement): string {
const type = element.type as
| string
| { displayName?: string; name?: string };
if (typeof type === "string") return type;
if (typeof type.displayName === "string" && type.displayName.length > 0) {
return type.displayName;
}
if (typeof type.name === "string" && type.name.length > 0) {
return type.name;
}
return "x";
}

View File

@@ -0,0 +1,143 @@
# `<Trans>`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`<Trans>` renders rich-text translations whose message body contains HTML-like
tag interpolation. The component looks up `i18nKey` in the active catalog,
parses the resolved string through `MessageParser`, and calls
`formatChunks<ReactNode>` so each `<tagName>` placeholder maps to a JSX
element supplied via the `components` prop. When the key is missing from
the catalog, the component falls back to `children` parsed as a default
ICU message, increments the missing-key telemetry counter, and still
performs tag interpolation against the provided `components` map. This
makes `<Trans>` the right surface for anything that mixes plain text with
inline elements (links, bold, code, footnote markers) — `useTranslation`
remains the choice for plain-text values.
## Features
- ICU tag interpolation with arbitrarily deep nesting (`<a><b>nested</b></a>`)
- Plural-branch selection driven by the `count` prop with automatic
`{count}` injection into the interpolation values
- Default-message fallback: `children` are stringified back to an ICU body
when the key is absent from the catalog
- Missing-key telemetry: `react_i18n.missing_key.total{ locale, namespace,
componentName? }` increments on every fallback render
- Render-duration histogram: `react_i18n.render.duration_ms` records the
wall-clock cost of `formatChunks`
- Concurrent-safe subscription: re-renders on `languageChanged` and
`loaded` via `useSyncExternalStore`
- Throws `ReactI18nError(REACT_I18N.NO_PROVIDER)` outside a Provider
- Throws `ReactI18nError(REACT_I18N.TRANS_INVALID_CHILD)` when a child
cannot be matched to a tag pair
## Usage: Basic Example
```tsx
import { TranslationProvider } from "@suaveplan/react-i18n";
import { Trans } from "@suaveplan/react-i18n/components/trans";
const i18n = createI18n({
defaultLocale: "en-GB",
messages: { "en-GB": { welcome: "Hello <b>{name}</b>!" } },
});
export function App() {
return (
<TranslationProvider i18n={i18n}>
<Trans
i18nKey="welcome"
values={{ name: "Alice" }}
components={{ b: <strong /> }}
/>
</TranslationProvider>
);
}
```
The component renders the equivalent of `Hello <strong>Alice</strong>!`.
## Usage: Advanced Example
Plural-aware rendering with nested tags and a missing-key children
fallback that still resolves through the same component map:
```tsx
import { Trans } from "@suaveplan/react-i18n/components/trans";
// Plural branch via count + nested tags
<Trans
i18nKey="cart.summary"
count={items.length}
values={{ name: user.name }}
components={{
b: <strong />,
a: <a href="/cart" />,
}}
/>;
// Default-message fallback when the key is not yet translated
<Trans
i18nKey="onboarding.intro"
values={{ product: "Suaveplan" }}
components={{ em: <em /> }}
>
Welcome to <em>{"{product}"}</em>!
</Trans>;
```
When `cart.summary` is `{ one: '<b>1</b> item by <a>{name}</a>', other:
'<b>{count}</b> items by <a>{name}</a>' }`, the catalog's `t()` selects
the right branch and `formatChunks` substitutes the JSX tags. The fallback
example will render as `Welcome to <em>Suaveplan</em>!` even when
`onboarding.intro` is absent from the catalog.
## API Reference
### `Trans(props: TransProps): ReactElement`
Props:
- `i18nKey: string` — lookup key in the active catalog. Required.
- `values?: Record<string, unknown>` — interpolation variable map. The
`count` prop is auto-merged into this map under key `count`.
- `components?: Record<string, ReactElement>` — tag-name → JSX element
map used by `formatChunks` to substitute `<tagName>...</tagName>`
placeholders.
- `count?: number` — drives plural-branch selection through the
catalog's `t(key, { count })` path.
- `namespace?: string` — optional namespace label attached to telemetry
samples; does NOT alter lookup (the namespace is encoded in `i18nKey`).
- `componentName?: string` — optional caller-supplied component name
attached to missing-key and render-duration samples.
- `children?: ReactNode` — default ICU message used on key miss; element
children serialise to `<tagName>...</tagName>` via `displayName` /
function `name`.
Throws:
- `ReactI18nError(REACT_I18N.NO_PROVIDER)` — when rendered outside a
`TranslationProvider` ancestor.
- `ReactI18nError(REACT_I18N.TRANS_INVALID_CHILD)` — when a fallback
child is neither string, number, boolean, null/undefined, nor a valid
React element (e.g. a function passed directly as a child).
## Implementation Notes
- The parser instance is module-level (`TRANS_PARSER`) so it is reused
across every render — the parser is stateless so this is safe.
- `cloneElement` is used to inject children into each tag-mapped element;
the original `components` map is never mutated.
- Boolean, null, and undefined children are rendered as the empty string
to match React's own rendering rules. Only structurally invalid
children (functions, custom objects) trigger `TRANS_INVALID_CHILD`.
- The component does NOT memoise the rendered output beyond what
`useMemo` provides for the message-resolution step. Re-renders on
parent prop changes always re-run `formatChunks` so React diffs see the
latest tag elements.
- Telemetry is recorded after `formatChunks` returns so the histogram
reflects the parse + render cost rather than the full React commit
cycle. The Provider's bus subscription handles re-render scheduling.

View File

@@ -0,0 +1,182 @@
/**
* Storybook stories for `<Trans>`.
*
* The package does not declare a `@storybook/react` dependency (Storybook
* lives in the testbed app, not in library packages). The story-meta
* shapes are inlined as `interface Meta` / `interface StoryObj` so the
* file can be consumed by any Storybook 7+/8+ config that auto-discovers
* stories without forcing every library package to depend on the full
* Storybook toolchain at install time.
*/
import type { TranslationRecord } from "@suaveplan/i18n";
import { createI18n, type I18nInstance } from "@suaveplan/i18n/plugin";
import type { ReactNode } from "react";
import { TranslationProvider } from "../../provider/provider.js";
import { Trans } from "./trans.js";
import type { TransProps } from "./trans.types.js";
interface Meta<T> {
readonly component: T;
readonly title: string;
readonly tags?: readonly string[];
}
interface StoryObj<T> {
readonly args?: Partial<TransProps>;
readonly decorators?: ReadonlyArray<(Story: () => ReactNode) => ReactNode>;
readonly render?: () => ReactNode;
readonly name?: string;
readonly _component?: T;
}
type LocaleKey = "en-GB" | "zh-CN" | "ar-AE" | "cy-GB" | "ru-RU";
const messages: Partial<Record<LocaleKey, TranslationRecord>> = {
"en-GB": {
welcome: "Hello <b>{name}</b>!",
items: { one: "{count} item", other: "{count} items" },
},
"zh-CN": {
welcome: "<b>{name}</b>,你好!",
items: { one: "{count} 项", other: "{count} 项" },
},
"ar-AE": {
welcome: "مرحبا <b>{name}</b>!",
items: { one: "{count} عنصر", other: "{count} عناصر" },
},
"cy-GB": {
welcome: "Helo <b>{name}</b>!",
items: { one: "{count} eitem", other: "{count} eitemau" },
},
"ru-RU": {
welcome: "Привет, <b>{name}</b>!",
items: { one: "{count} элемент", other: "{count} элементов" },
},
};
function withProviderFor(locale: LocaleKey) {
return function Wrapper(props: { children: ReactNode }) {
const i18n = createI18n({
defaultLocale: locale,
messages,
}) as I18nInstance;
return (
<TranslationProvider i18n={i18n}>
{props.children}
</TranslationProvider>
);
};
}
const meta: Meta<typeof Trans> = {
component: Trans,
title: "react-i18n/Trans",
tags: ["autodocs"],
};
export default meta;
type Story = StoryObj<typeof Trans>;
export const Default: Story = {
args: {
i18nKey: "welcome",
values: { name: "Alice" },
components: { b: <strong /> },
},
decorators: [
(Story) => {
const W = withProviderFor("en-GB");
return <W>{Story()}</W>;
},
],
};
export const Playground: Story = {
args: { i18nKey: "items", count: 3 },
decorators: [
(Story) => {
const W = withProviderFor("en-GB");
return <W>{Story()}</W>;
},
],
};
export const WithI18nEnglish: Story = {
name: "WithI18n / en-GB",
args: {
i18nKey: "welcome",
values: { name: "Alice" },
components: { b: <strong /> },
},
decorators: [
(Story) => {
const W = withProviderFor("en-GB");
return <W>{Story()}</W>;
},
],
};
export const WithI18nChinese: Story = {
name: "WithI18n / zh-CN",
args: {
i18nKey: "welcome",
values: { name: "爱丽丝" },
components: { b: <strong /> },
},
decorators: [
(Story) => {
const W = withProviderFor("zh-CN");
return <W>{Story()}</W>;
},
],
};
export const WithI18nArabic: Story = {
name: "WithI18n / ar-AE (RTL)",
args: {
i18nKey: "welcome",
values: { name: "أليس" },
components: { b: <strong /> },
},
decorators: [
(Story) => {
const W = withProviderFor("ar-AE");
return (
<W>
<div dir="rtl">{Story()}</div>
</W>
);
},
],
};
export const WithI18nWelsh: Story = {
name: "WithI18n / cy-GB",
args: {
i18nKey: "welcome",
values: { name: "Alys" },
components: { b: <strong /> },
},
decorators: [
(Story) => {
const W = withProviderFor("cy-GB");
return <W>{Story()}</W>;
},
],
};
export const WithI18nRussian: Story = {
name: "WithI18n / ru-RU",
args: {
i18nKey: "welcome",
values: { name: "Алиса" },
components: { b: <strong /> },
},
decorators: [
(Story) => {
const W = withProviderFor("ru-RU");
return <W>{Story()}</W>;
},
],
};

View File

@@ -0,0 +1,524 @@
import { createI18n } from "@suaveplan/i18n/plugin";
import {
afterEach,
beforeEach,
describe,
expect,
it,
} from "@suaveplan/testing/runner";
import { renderComponent } from "@suaveplan/testing-dom/render";
import {
getReactI18nMetricsState,
resetReactI18nMetricsState,
} from "../../observability/react-i18n-metrics.js";
import { TranslationProvider } from "../../provider/provider.js";
import { RegistryProvider } from "../../registry/registry-provider.js";
import { Trans } from "./trans.js";
/** Walk an Error cause-chain and return the deepest `.code`, if any. */
function rootCode(err: unknown): string | undefined {
let current: unknown = err;
let lastCode: string | undefined;
let depth = 0;
while (current instanceof Error && depth < 10) {
const code = (current as Error & { code?: string }).code;
if (code !== undefined) lastCode = code;
const cause = (current as Error & { cause?: unknown }).cause;
if (cause === undefined || cause === null) break;
current = cause;
depth += 1;
}
return lastCode;
}
beforeEach(() => {
resetReactI18nMetricsState();
});
afterEach(() => {
resetReactI18nMetricsState();
});
describe("Trans", () => {
it("tag substitution renders bold child", { timeout: 5000 }, () => {
const i18n = createI18n({
defaultLocale: "en-GB",
messages: { "en-GB": { welcome: "Hello <b>{name}</b>!" } },
});
const { container } = renderComponent(
<TranslationProvider i18n={i18n}>
<Trans
i18nKey="welcome"
values={{ name: "Alice" }}
components={{ b: <strong /> }}
/>
</TranslationProvider>
);
const strong = container.querySelector("strong");
expect(strong?.textContent).toBe("Alice");
expect(container.textContent).toBe("Hello Alice!");
});
it("nested tags render correctly", { timeout: 5000 }, () => {
const i18n = createI18n({
defaultLocale: "en-GB",
messages: { "en-GB": { link: "See <a><b>here</b></a>" } },
});
const { container } = renderComponent(
<TranslationProvider i18n={i18n}>
<Trans
i18nKey="link"
components={{
// The `<a>` is a placeholder element whose
// children are replaced by the chunked render.
// The label inside satisfies biome's a11y check
// for the source element; consumers see only
// the rendered chunks.
a: <a href="/x">link</a>,
b: <strong />,
}}
/>
</TranslationProvider>
);
const a = container.querySelector("a");
expect(a?.getAttribute("href")).toBe("/x");
const strong = a?.querySelector("strong");
expect(strong?.textContent).toBe("here");
});
it("count drives plural branch", { timeout: 5000 }, () => {
const i18n = createI18n({
defaultLocale: "en-GB",
messages: {
"en-GB": {
items: {
one: "{count} item",
other: "{count} items",
},
},
},
});
const { container } = renderComponent(
<TranslationProvider i18n={i18n}>
<Trans i18nKey="items" count={3} />
</TranslationProvider>
);
expect(container.textContent).toBe("3 items");
});
it("count of 1 selects the one branch", { timeout: 5000 }, () => {
const i18n = createI18n({
defaultLocale: "en-GB",
messages: {
"en-GB": {
items: {
one: "{count} item",
other: "{count} items",
},
},
},
});
const { container } = renderComponent(
<TranslationProvider i18n={i18n}>
<Trans i18nKey="items" count={1} />
</TranslationProvider>
);
expect(container.textContent).toBe("1 item");
});
it("children render as default message when key is missing", {
timeout: 5000,
}, () => {
const i18n = createI18n({
defaultLocale: "en-GB",
messages: { "en-GB": {} },
});
const { container } = renderComponent(
<TranslationProvider i18n={i18n}>
<Trans
i18nKey="absent"
values={{ name: "World" }}
components={{ b: <strong /> }}
>
{"Hello "}
<b>{"{name}"}</b>
{"!"}
</Trans>
</TranslationProvider>
);
const strong = container.querySelector("strong");
expect(strong?.textContent).toBe("World");
expect(container.textContent).toBe("Hello World!");
});
it("missing key without children renders the key itself", {
timeout: 5000,
}, () => {
const i18n = createI18n({
defaultLocale: "en-GB",
messages: { "en-GB": {} },
});
const { container } = renderComponent(
<TranslationProvider i18n={i18n}>
<Trans i18nKey="absent.no.children" />
</TranslationProvider>
);
expect(container.textContent).toBe("absent.no.children");
});
it("missing key increments react_i18n.missing_key.total", {
timeout: 5000,
}, () => {
const i18n = createI18n({
defaultLocale: "en-GB",
messages: { "en-GB": {} },
});
renderComponent(
<TranslationProvider i18n={i18n}>
<Trans i18nKey="absent" componentName="LoginForm">
{"Fallback"}
</Trans>
</TranslationProvider>
);
const samples = getReactI18nMetricsState().missingKeyTotal;
const match = samples.find(
(s) =>
s.attributes.locale === "en-GB" &&
s.attributes.componentName === "LoginForm"
);
expect(match).toBeDefined();
expect(match?.attributes.namespace).toBe("");
});
it("missing key without componentName omits the attribute", {
timeout: 5000,
}, () => {
const i18n = createI18n({
defaultLocale: "en-GB",
messages: { "en-GB": {} },
});
renderComponent(
<TranslationProvider i18n={i18n}>
<Trans i18nKey="absent2">{"Hi"}</Trans>
</TranslationProvider>
);
const samples = getReactI18nMetricsState().missingKeyTotal;
const last = samples[samples.length - 1];
expect(last?.attributes.componentName).toBeUndefined();
});
it("emits a render-duration histogram sample", { timeout: 5000 }, () => {
const i18n = createI18n({
defaultLocale: "en-GB",
messages: { "en-GB": { hi: "Hi" } },
});
renderComponent(
<TranslationProvider i18n={i18n}>
<Trans i18nKey="hi" />
</TranslationProvider>
);
const samples = getReactI18nMetricsState().renderDurationMs;
expect(samples.length).toBeGreaterThan(0);
expect(samples[samples.length - 1]?.attributes.locale).toBe("en-GB");
});
it("throws REACT_I18N.NO_PROVIDER outside a Provider", {
timeout: 5000,
}, () => {
const original = console.error;
console.error = (() => {}) as typeof console.error;
try {
let thrown: unknown;
try {
renderComponent(<Trans i18nKey="x" />);
} catch (err) {
thrown = err;
}
expect(thrown).toBeDefined();
expect(rootCode(thrown)).toBe("REACT_I18N.NO_PROVIDER");
} finally {
console.error = original;
}
});
it("count with empty messages map falls back to children", {
timeout: 5000,
}, () => {
// Empty messages object is set; lookup returns the empty record,
// so `lookupPluralBranch` walks parts and returns false at the
// missing-part guard (`!messages` is false but no key matches).
const i18n = createI18n({
defaultLocale: "en-GB",
// No messages key at all so getMessages('en-GB') returns undefined.
});
const { container } = renderComponent(
<TranslationProvider i18n={i18n}>
<Trans i18nKey="absent.count" count={5}>
{"{count}"}
</Trans>
</TranslationProvider>
);
expect(container.textContent).toBe("5");
});
it("plural branch lookup accepts a record with only 'one'", {
timeout: 5000,
}, () => {
// Exercises the right-hand side of the `'other' in cursor || 'one' in cursor`
// short-circuit in `lookupPluralBranch`. Catalogs that only
// declare a `one` branch are atypical but valid for languages
// where the singular form is the only meaningful variant.
const i18n = createI18n({
defaultLocale: "en-GB",
messages: {
"en-GB": {
only_one: { one: "{count} singleton" },
},
},
});
const { container } = renderComponent(
<TranslationProvider i18n={i18n}>
<Trans i18nKey="only_one" count={1} />
</TranslationProvider>
);
// The catalog's plural-resolver returns the `one` branch when
// count === 1; for other counts the resolver returns undefined
// and the rendered output falls back to the key.
expect(container.textContent).toBe("1 singleton");
});
it("count where path resolves to a string leaf does not select plural", {
timeout: 5000,
}, () => {
const i18n = createI18n({
defaultLocale: "en-GB",
messages: { "en-GB": { hello: "Hi" } },
});
// Plural-branch lookup walks the dot path; when the cursor
// resolves to a string (not an object with 'one'/'other' keys),
// the function returns false and we fall through to the
// string-hit branch.
const { container } = renderComponent(
<TranslationProvider i18n={i18n}>
<Trans i18nKey="hello" count={5} />
</TranslationProvider>
);
expect(container.textContent).toBe("Hi");
});
it("renders children that include numeric and boolean nodes", {
timeout: 5000,
}, () => {
const i18n = createI18n({
defaultLocale: "en-GB",
messages: { "en-GB": {} },
});
const { container } = renderComponent(
<TranslationProvider i18n={i18n}>
<Trans i18nKey="absent.mixed">
{"Score: "}
{42}
{true}
</Trans>
</TranslationProvider>
);
expect(container.textContent).toBe("Score: 42");
});
it("renders explicitly null children as the key on missing", {
timeout: 5000,
}, () => {
const i18n = createI18n({
defaultLocale: "en-GB",
messages: { "en-GB": {} },
});
const { container } = renderComponent(
<TranslationProvider i18n={i18n}>
<Trans i18nKey="absent.null">{null}</Trans>
</TranslationProvider>
);
expect(container.textContent).toBe("absent.null");
});
it("preserves caller-supplied count in values when count prop matches", {
timeout: 5000,
}, () => {
const i18n = createI18n({
defaultLocale: "en-GB",
messages: {
"en-GB": {
items: {
one: "{count} item",
other: "{count} items",
},
},
},
});
const { container } = renderComponent(
<TranslationProvider i18n={i18n}>
<Trans i18nKey="items" count={5} values={{ count: 5 }} />
</TranslationProvider>
);
expect(container.textContent).toBe("5 items");
});
it("re-renders on languageChanged event", { timeout: 5000 }, async () => {
const i18n = createI18n({
defaultLocale: "en-GB",
messages: {
"en-GB": { hi: "Hi" },
"fr-FR": { hi: "Salut" },
},
});
const { container, rerender } = renderComponent(
<TranslationProvider i18n={i18n}>
<Trans i18nKey="hi" />
</TranslationProvider>
);
expect(container.textContent).toBe("Hi");
await i18n.changeLanguage("fr-FR");
rerender(
<TranslationProvider i18n={i18n}>
<Trans i18nKey="hi" />
</TranslationProvider>
);
expect(container.textContent).toBe("Salut");
});
it("renders elements without displayName via function name", {
timeout: 5000,
}, () => {
const i18n = createI18n({
defaultLocale: "en-GB",
messages: {
"en-GB": {
body: "<UnnamedHelper>x</UnnamedHelper>",
},
},
});
function UnnamedHelper(props: { children?: React.ReactNode }) {
return <span>{props.children}</span>;
}
const { container } = renderComponent(
<TranslationProvider i18n={i18n}>
<Trans
i18nKey="body"
components={{ UnnamedHelper: <UnnamedHelper /> }}
/>
</TranslationProvider>
);
expect(container.querySelector("span")?.textContent).toBe("x");
});
it("missing key with count falls through to children", {
timeout: 5000,
}, () => {
const i18n = createI18n({
defaultLocale: "en-GB",
messages: { "en-GB": {} },
});
const { container } = renderComponent(
<TranslationProvider i18n={i18n}>
<Trans i18nKey="absent.with.count" count={3}>
{"Default {count}"}
</Trans>
</TranslationProvider>
);
expect(container.textContent).toBe("Default 3");
});
it("missing key with count and partial path falls back", {
timeout: 5000,
}, () => {
const i18n = createI18n({
defaultLocale: "en-GB",
messages: {
"en-GB": {
nested: { real: { one: "{count}", other: "{count}" } },
},
},
});
const { container } = renderComponent(
<TranslationProvider i18n={i18n}>
<Trans i18nKey="nested.absent" count={2}>
{"Fallback {count}"}
</Trans>
</TranslationProvider>
);
expect(container.textContent).toBe("Fallback 2");
});
it("applies a registered post-processor to the message body", {
timeout: 5000,
}, () => {
const i18n = createI18n({
defaultLocale: "en-GB",
messages: { "en-GB": { greeting: "Hello, {name}!" } },
});
const { container } = renderComponent(
<RegistryProvider
plugins={[
{
kind: "post-processor",
plugin: {
id: "pseudo",
priority: 100,
process: (msg) => `[${msg}]`,
},
},
]}
>
<TranslationProvider i18n={i18n}>
<Trans i18nKey="greeting" values={{ name: "Alice" }} />
</TranslationProvider>
</RegistryProvider>
);
expect(container.textContent).toBe("[Hello, Alice!]");
});
it("no-registry path renders unchanged message", { timeout: 5000 }, () => {
const i18n = createI18n({
defaultLocale: "en-GB",
messages: { "en-GB": { greeting: "Hello!" } },
});
const { container } = renderComponent(
<TranslationProvider i18n={i18n}>
<Trans i18nKey="greeting" />
</TranslationProvider>
);
expect(container.textContent).toBe("Hello!");
});
it("passes the explicit namespace prop into the post-processor context", {
timeout: 5000,
}, () => {
const i18n = createI18n({
defaultLocale: "en-GB",
messages: { "en-GB": { greeting: "Hi" } },
});
let receivedNamespace: string | undefined;
const { container } = renderComponent(
<RegistryProvider
plugins={[
{
kind: "post-processor",
plugin: {
id: "ns-capture",
priority: 100,
process: (msg, ctx) => {
receivedNamespace = ctx.namespace;
return msg;
},
},
},
]}
>
<TranslationProvider i18n={i18n}>
<Trans i18nKey="greeting" namespace="settings" />
</TranslationProvider>
</RegistryProvider>
);
expect(container.textContent).toBe("Hi");
expect(receivedNamespace).toBe("settings");
});
});

View File

@@ -0,0 +1,246 @@
import { MessageParser } from "@suaveplan/i18n-icu-parser";
import {
cloneElement,
Fragment,
type ReactElement,
type ReactNode,
useContext,
useSyncExternalStore,
} from "react";
import { REACT_I18N_CODES } from "../../errors/error-codes.js";
import { ReactI18nError } from "../../errors/react-i18n-error.js";
import {
recordMissingKey,
recordRenderDuration,
} from "../../observability/react-i18n-metrics.js";
import { TranslationContext } from "../../provider/provider-context.js";
import { runPostProcessors } from "../../registry/post-processors/post-processors.js";
import { useOptionalReactI18nRegistry } from "../../registry/use-registry.js";
import { childrenToMessageString } from "./stringify-children.js";
import type { TransProps } from "./trans.types.js";
const TRANS_PARSER = new MessageParser({ icuSyntax: true });
/**
* Render a translated rich-text message with tag interpolation.
*
* The component subscribes to the active `I18nInstance` via context and
* re-renders on `languageChanged` / `loaded` events through
* `useSyncExternalStore`. The lookup key is resolved against the catalog,
* parsed through `MessageParser`, and rendered via
* `formatChunks<ReactNode>` so every `<tagName>` placeholder maps to a
* JSX element supplied through the `components` prop.
*
* When the key is missing, the component falls back to its `children` (a
* default ICU message) and increments the missing-key telemetry counter.
*
* Throws `ReactI18nError(REACT_I18N.TRANS_INVALID_CHILD)` when the
* children-fallback path receives a structurally-invalid child.
*/
export function Trans(props: TransProps): ReactElement {
const i18n = useContext(TranslationContext);
if (i18n === null) {
throw new ReactI18nError(
REACT_I18N_CODES.NO_PROVIDER,
"react-i18n.no_provider.trans"
);
}
// Subscribe to bus events so the component re-renders on locale or
// namespace-load changes. The carrier counter on `i18n` is bumped by
// the Provider AND by our own listener so the snapshot moves
// forward regardless of listener order between the two subscribers.
// The same reader covers both client and server snapshots — the
// counter is initialised to zero and only bumps in response to bus
// events, so the SSR read is stable (no client/server divergence).
const snapshotReader = () => readSnapshot(i18n);
useSyncExternalStore(
(notify) => {
const tickAndNotify = () => {
bumpSnapshot(i18n);
notify();
};
const offLanguage = i18n.events.on(
"languageChanged",
tickAndNotify
);
const offLoaded = i18n.events.on("loaded", tickAndNotify);
return () => {
offLanguage();
offLoaded();
};
},
snapshotReader,
snapshotReader
);
const namespace = props.namespace ?? "";
const componentName = props.componentName;
const count = props.count;
// Recompute on every render so locale switches reflect immediately;
// the catalog itself caches the lookup so this is cheap.
const message = resolveMessage(i18n, props.i18nKey, count, props.children);
if (message.missing) {
recordMissingKey({
locale: i18n.locale,
namespace,
...(componentName !== undefined ? { componentName } : {}),
});
}
// Apply registry post-processors to the message body BEFORE ICU
// parsing so pseudo-localization / redaction / debug overlays
// operate on the full template (including interpolation slots).
// Plugins run only when a `<RegistryProvider>` ancestor is in
// scope; the no-registry path is a single short-circuit check.
const registry = useOptionalReactI18nRegistry();
const processedBody =
registry === null
? message.body
: runPostProcessors(
message.body,
{
locale: i18n.locale,
key: props.i18nKey,
...(namespace !== "" ? { namespace } : {}),
},
registry.listPostProcessors()
);
const values = buildValues(props.values, count);
const componentMap = props.components ?? {};
const t0 = performance.now();
const chunks = TRANS_PARSER.formatChunks<ReactNode>(
processedBody,
values,
buildChunkValues(componentMap),
{ locale: i18n.locale }
);
recordRenderDuration(performance.now() - t0, {
locale: i18n.locale,
namespace,
...(componentName !== undefined ? { componentName } : {}),
});
return <Fragment>{chunks}</Fragment>;
}
Trans.displayName = "SuaveplanTrans";
interface ResolvedMessage {
readonly body: string;
readonly missing: boolean;
}
interface CatalogLike {
readonly catalog: {
has(key: string): boolean;
getMessages(): Record<string, unknown> | undefined;
};
t(key: string, options?: { readonly count?: number }): string;
}
/**
* Resolve the raw ICU body string for `<Trans>`.
*
* The catalog supports both string-leaf keys (`hello: 'Hi {name}'`) and
* plural-object leaves (`items: { one: '...', other: '...' }`). The
* universal-core `t()` returns the raw key when no value resolves and a
* branch-selected string when `count` is supplied. We probe both
* lookups so the missing-key signal is accurate in both shapes.
*/
function resolveMessage(
i18n: CatalogLike,
key: string,
count: number | undefined,
children: ReactNode
): ResolvedMessage {
const stringHit = i18n.catalog.has(key);
const pluralHit =
!stringHit && count !== undefined && lookupPluralBranch(i18n, key);
if (stringHit || pluralHit) {
const body = count !== undefined ? i18n.t(key, { count }) : i18n.t(key);
return { body, missing: false };
}
const body = childrenToMessageString(children, key);
return { body, missing: true };
}
/**
* Returns `true` when `key` resolves to a plural-object branch. Walks the
* dot path through the active locale's messages map so the function does
* not invoke `t()` (which would emit a `missingKey` event for the
* underlying string lookup).
*/
function lookupPluralBranch(i18n: CatalogLike, key: string): boolean {
const messages = i18n.catalog.getMessages();
if (!messages) return false;
const parts = key.split(".");
let cursor: unknown = messages;
for (const part of parts) {
if (
typeof cursor !== "object" ||
cursor === null ||
!(part in (cursor as Record<string, unknown>))
) {
return false;
}
cursor = (cursor as Record<string, unknown>)[part];
}
return (
typeof cursor === "object" &&
cursor !== null &&
("other" in (cursor as Record<string, unknown>) ||
"one" in (cursor as Record<string, unknown>))
);
}
function buildValues(
values: Readonly<Record<string, unknown>> | undefined,
count: number | undefined
): Record<string, unknown> {
const out: Record<string, unknown> = { ...(values ?? {}) };
if (count !== undefined && out["count"] === undefined) {
out["count"] = count;
}
return out;
}
function buildChunkValues(
components: Readonly<Record<string, ReactElement>>
): Record<string, (chunks: Array<string | ReactNode>) => ReactNode> {
const out: Record<
string,
(chunks: Array<string | ReactNode>) => ReactNode
> = {};
for (const [name, element] of Object.entries(components)) {
out[name] = (chunks) => cloneTagElement(name, element, chunks);
}
return out;
}
function cloneTagElement(
tagName: string,
element: ReactElement,
chunks: Array<string | ReactNode>
): ReactNode {
// `cloneElement` accepts a variadic children list; the parser
// produces a deterministic chunk order so React reconciles by
// position without needing per-chunk keys.
return cloneElement(element, { key: tagName }, ...chunks);
}
interface SnapshotCarrier {
__reactI18nSnapshot?: number;
}
function readSnapshot(instance: unknown): number {
return (instance as SnapshotCarrier).__reactI18nSnapshot ?? 0;
}
function bumpSnapshot(instance: unknown): void {
const carrier = instance as SnapshotCarrier;
carrier.__reactI18nSnapshot = (carrier.__reactI18nSnapshot ?? 0) + 1;
}

View File

@@ -0,0 +1,40 @@
import type { ReactElement, ReactNode } from "react";
/**
* Props for the `<Trans>` rich-text component.
*
* `i18nKey` is looked up in the active catalog. The resolved message is
* parsed through `MessageParser` and rendered via `formatChunks<ReactNode>`
* so each tag placeholder maps to a JSX element supplied via `components`.
*
* When the key is absent the component falls back to `children`, which the
* parser treats as a literal default ICU message.
*/
export interface TransProps {
/** Lookup key against the active catalog. */
readonly i18nKey: string;
/** Variable values for `{var}` and `{count, plural, ...}` interpolation. */
readonly values?: Readonly<Record<string, unknown>>;
/**
* Tag-name → JSX element map. Tags in the message body are replaced by
* cloning the supplied element with the formatted children appended.
*/
readonly components?: Readonly<Record<string, ReactElement>>;
/** Plural-branch selector. Equivalent to `values.count`. */
readonly count?: number;
/** Optional namespace pinning for the lookup. */
readonly namespace?: string;
/**
* Default ICU message used when `i18nKey` is missing from the catalog.
* Children are stringified with React's children API; if `children` is
* non-empty it parses identically to a catalog entry.
*/
readonly children?: ReactNode;
/**
* Optional component-name attribute attached to the missing-key /
* render-duration telemetry samples. Useful when `<Trans>` is wrapped
* inside a higher-order component whose `displayName` should propagate
* to the metrics surface.
*/
readonly componentName?: string;
}

View File

@@ -0,0 +1,105 @@
# `create-i18n-config.ts`
## Purpose
Builds a validated `I18nConfig` from a caller-supplied option bag and is
the canonical entry point for `<TranslationProvider config={...}>`. The
function runs the bag through `I18nConfigSchema` (Zod) and, on failure,
routes the error to the correct namespace so observability dashboards
group SSR-hydration failures separately from generic option-shape
failures.
The split is spec-driven: the
`enterprise-react-i18n` proposal explicitly requires
`ReactI18nError(REACT_I18N.HYDRATION_MISMATCH)` for `initialMessages`
shape failures (because they correspond to the SSR contract violation
React consumers debug under that umbrella) and
`I18nError(I18N.INVALID_OPTIONS)` for every other shape failure
(because those would have surfaced from the universal core anyway if
they had escaped past the React boundary).
## Features
- Pure validation — no side effects, no global state.
- Distinguishes `initialMessages` failures from generic option failures
with a deterministic Zod-issue path scan (`issue.path[0] ===
"initialMessages"`).
- Attaches `context.zodIssues` to every thrown error so structured-log
sinks, OTel span events, and the wire-format envelope all surface the
precise field that broke.
- Returns `I18nConfig` (the parsed type), not the raw input — downstream
code can rely on defaults having been applied.
## Usage: Basic Example
```ts
import { createI18nConfig } from "@suaveplan/react-i18n/config";
const cfg = createI18nConfig({
defaultLocale: "en-GB",
fallbackLocale: "en-GB",
supportedLocales: ["en-GB", "fr-FR"],
});
```
## Usage: Advanced Example
```ts
import { createI18nConfig } from "@suaveplan/react-i18n/config";
import { ReactI18nError } from "@suaveplan/react-i18n/errors";
import { I18nError } from "@suaveplan/i18n";
function loadConfig(input: unknown) {
try {
return createI18nConfig(input);
} catch (err) {
if (err instanceof ReactI18nError) {
// SSR-hydration failure — the initialMessages payload
// produced by the server cannot be hydrated client-side.
renderHydrationError(err);
} else if (err instanceof I18nError) {
// Generic option failure — typo or wrong type on a
// non-hydration field (defaultLocale, plugins, …).
renderConfigError(err);
}
throw err;
}
}
```
## API Reference
### `createI18nConfig(input: unknown): I18nConfig`
Validates `input` against `I18nConfigSchema` (see
`schemas/i18n-config.schemas.ts`) and returns the parsed config.
Throws:
- `ReactI18nError(REACT_I18N.HYDRATION_MISMATCH)` — when ANY Zod issue is
rooted at `initialMessages.*`. Carries `context.zodIssues` (the full
issue array, not just the matching one, so callers can debug
cascade failures without re-running the parser).
- `I18nError(I18N.INVALID_OPTIONS)` — for every other shape failure.
Carries `context.zodIssues` (full issue array) and
`context.source === "react-i18n.config"` so log sinks know the failure
originated at the React boundary, not deeper in the universal core.
## Implementation Notes
- The function uses `safeParse` rather than `parse` so the Zod issue
array is reachable for the path-scan that selects between the two
error namespaces.
- `issue.path[0]` is the right discriminator because Zod issue paths
always start at the schema root. A nested failure inside
`initialMessages.en-GB.greetings.hello` still yields
`path[0] === "initialMessages"`.
- The function is intentionally separate from
`toI18nInstanceOptions` so callers can validate config without
triggering the universal-core `createI18n` machinery (useful in
Storybook, in design-time editors, and in test harnesses).
- Both error classes carry `context.zodIssues`. Sensitive fields inside
`initialMessages` are not redacted because translation strings are
not secret data; if a consumer needs redaction (e.g. for templates
containing PII placeholders), they should pre-process the payload
before calling `createI18nConfig`.

View File

@@ -0,0 +1,89 @@
import { I18nError } from "@suaveplan/i18n-shared";
import { describe, expect, it } from "@suaveplan/testing/runner";
import { ReactI18nError } from "../errors/react-i18n-error.js";
import { createI18nConfig } from "./create-i18n-config.js";
describe("createI18nConfig", () => {
it("returns the parsed config on the happy path", { timeout: 5000 }, () => {
const cfg = createI18nConfig({
defaultLocale: "en-GB",
fallbackLocale: "en-GB",
supportedLocales: ["en-GB", "fr-FR"],
initialMessages: { "en-GB": { hello: "Hi" } },
});
expect(cfg.defaultLocale).toBe("en-GB");
expect(cfg.supportedLocales).toEqual(["en-GB", "fr-FR"]);
});
it("throws ReactI18nError(HYDRATION_MISMATCH) on bad initialMessages", {
timeout: 5000,
}, () => {
try {
createI18nConfig({
defaultLocale: "en-GB",
initialMessages: {
"en-GB": "not-an-object" as unknown as Record<
string,
string
>,
},
});
expect.unreachable("should throw");
} catch (err) {
expect(err).toBeInstanceOf(ReactI18nError);
if (err instanceof ReactI18nError) {
expect(err.code).toBe("REACT_I18N.HYDRATION_MISMATCH");
expect(err.context).toBeDefined();
const ctx = err.context as
| undefined
| { zodIssues?: ReadonlyArray<unknown> };
expect(ctx?.zodIssues).toBeDefined();
}
}
});
it("throws I18nError(INVALID_OPTIONS) on generic shape failures", {
timeout: 5000,
}, () => {
try {
createI18nConfig({ defaultLocale: "" });
expect.unreachable("should throw");
} catch (err) {
expect(err).toBeInstanceOf(I18nError);
if (err instanceof I18nError) {
expect(err.code).toBe("I18N.INVALID_OPTIONS");
const ctx = err.context as
| undefined
| {
zodIssues?: ReadonlyArray<unknown>;
source?: string;
};
expect(ctx?.zodIssues).toBeDefined();
expect(ctx?.source).toBe("react-i18n.config");
}
}
});
it("throws I18nError(INVALID_OPTIONS) on unknown fields", {
timeout: 5000,
}, () => {
try {
createI18nConfig({
defaultLocale: "en-GB",
typo: true,
});
expect.unreachable("should throw");
} catch (err) {
expect(err).toBeInstanceOf(I18nError);
}
});
it("throws I18nError on non-object input", { timeout: 5000 }, () => {
try {
createI18nConfig("not-an-object");
expect.unreachable("should throw");
} catch (err) {
expect(err).toBeInstanceOf(I18nError);
}
});
});

View File

@@ -0,0 +1,48 @@
import { I18N_CODES, I18nError } from "@suaveplan/i18n-shared";
import { REACT_I18N_CODES } from "../errors/error-codes.js";
import { ReactI18nError } from "../errors/react-i18n-error.js";
import {
type I18nConfig,
I18nConfigSchema,
} from "../schemas/i18n-config.schemas.js";
/**
* Build a validated `I18nConfig` from a caller-supplied option bag.
*
* Runs the option bag through `I18nConfigSchema` (Zod). On success returns
* the parsed config. On failure:
*
* - When ANY Zod issue is rooted in `initialMessages`, throws
* `ReactI18nError(REACT_I18N.HYDRATION_MISMATCH)` so SSR-hydration
* failures surface with the React-tier code observability dashboards
* group on (this is the exact contract the spec scenario
* "invalid initialMessages rejected" requires).
* - Otherwise throws `I18nError(I18N.INVALID_OPTIONS)` so generic
* shape failures land in the universal-core error namespace they
* would have hit if they had escaped to `createI18n(...)` deeper in
* the stack.
*
* Both errors carry `context.zodIssues` so structured logs, span events,
* and the wire-format envelope all surface the precise field that broke.
*/
export function createI18nConfig(input: unknown): I18nConfig {
const result = I18nConfigSchema.safeParse(input);
if (result.success) return result.data;
const issues = result.error.issues;
const isInitialMessagesFailure = issues.some(
(issue) => issue.path[0] === "initialMessages"
);
if (isInitialMessagesFailure) {
throw new ReactI18nError(
REACT_I18N_CODES.HYDRATION_MISMATCH,
"react-i18n.hydration_mismatch.initial_messages",
{ context: { zodIssues: issues } }
);
}
throw new I18nError(I18N_CODES.INVALID_OPTIONS, "i18n.invalid_options", {
context: { zodIssues: issues, source: "react-i18n.config" },
});
}

View File

@@ -0,0 +1,2 @@
export { createI18nConfig } from "./create-i18n-config.js";
export { toI18nInstanceOptions } from "./to-i18n-instance-options.js";

View File

@@ -0,0 +1,98 @@
# `to-i18n-instance-options.ts`
## Purpose
Translates a Zod-parsed `I18nConfig` (the React-tier shape) into the
universal-core `I18nInstanceOptions` accepted by `createI18n(...)` from
`@suaveplan/i18n`. This is the bridge between the React Provider's
config surface (where field names favour SSR semantics, e.g.
`initialMessages`) and the universal core's option bag (where field
names favour catalog semantics, e.g. `messages`).
The function is pure and stable across calls so the Provider can wrap it
in `useMemo` without churning the universal core's instance lifecycle on
every render.
## Features
- Single-pass field mapping with explicit conditional forwarding so
`exactOptionalPropertyTypes` (the tsconfig setting) does not
require sentinel `undefined`s on the output.
- Renames `initialMessages` → `messages` so the React-tier name
("initial" matches the SSR-hydration story) and the core-tier name
("messages" matches the catalog story) can each be ergonomic in their
own context.
- Forwards every other field as-is (`fallbackLocale`, `backend`,
`backends`, `saveMissing`, `plugins`).
## Usage: Basic Example
```ts
import {
createI18nConfig,
toI18nInstanceOptions,
} from "@suaveplan/react-i18n/config";
import { createI18n } from "@suaveplan/i18n";
const cfg = createI18nConfig({
defaultLocale: "en-GB",
initialMessages: { "en-GB": { hello: "Hi" } },
});
const i18n = createI18n(toI18nInstanceOptions(cfg));
```
## Usage: Advanced Example
```ts
import { useMemo } from "react";
import { createI18n, type I18nInstance } from "@suaveplan/i18n";
import {
type I18nConfig,
toI18nInstanceOptions,
} from "@suaveplan/react-i18n/config";
function useStableInstance(config: I18nConfig): I18nInstance {
return useMemo(
() => createI18n(toI18nInstanceOptions(config)),
[config]
);
}
```
## API Reference
### `toI18nInstanceOptions(config: I18nConfig): I18nInstanceOptions`
Pure mapping. Returns a new `I18nInstanceOptions` object on every call;
fields not present on the input config are omitted from the output (so
the universal-core defaults take over).
| Input field | Output field | Notes |
| --- | --- | --- |
| `defaultLocale` | `defaultLocale` | Always present (required). |
| `fallbackLocale` | `fallbackLocale` | Forwarded when defined. |
| `initialMessages` | `messages` | Renamed; structurally identical. |
| `backend` | `backend` | Forwarded as-is (structural). |
| `backends` | `backends` | Forwarded as-is (structural). |
| `saveMissing` | `saveMissing` | Forwarded when defined. |
| `plugins` | `plugins` | Forwarded as-is (structural). |
## Implementation Notes
- Field forwarding uses explicit `if (value !== undefined)` guards
rather than spread because the tsconfig sets
`exactOptionalPropertyTypes: true` — explicit guards keep the output
shape free of sentinel `undefined` properties that would otherwise
conflict with optional fields on `I18nInstanceOptions`.
- `Backend` and `I18nPlugin` casts at the conversion boundary are
unavoidable: the React-tier Zod schema validates only the structural
fields (`name`, `capabilities`) of these types because their full
surface includes function fields Zod cannot meaningfully validate.
The Provider's runtime guard is the universal core itself, which
exercises the function fields when it boots the instance.
- The function is pure — calling it twice with the same input yields
equivalent options (deep-equal but not necessarily reference-equal).
- The function does NOT call `createI18n`. The Provider does that
separately so config building and instance construction can be wired
to different React lifecycles (`useMemo` on the config,
`useState`/`useRef` on the instance).

View File

@@ -0,0 +1,66 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { createI18nConfig } from "./create-i18n-config.js";
import { toI18nInstanceOptions } from "./to-i18n-instance-options.js";
describe("toI18nInstanceOptions", () => {
it("maps the minimal config (defaultLocale only)", {
timeout: 5000,
}, () => {
const cfg = createI18nConfig({ defaultLocale: "en-GB" });
const opts = toI18nInstanceOptions(cfg);
expect(opts.defaultLocale).toBe("en-GB");
expect(opts.fallbackLocale).toBeUndefined();
expect(opts.messages).toBeUndefined();
expect(opts.backend).toBeUndefined();
expect(opts.backends).toBeUndefined();
expect(opts.plugins).toBeUndefined();
expect(opts.saveMissing).toBeUndefined();
});
it("renames initialMessages → messages", { timeout: 5000 }, () => {
const cfg = createI18nConfig({
defaultLocale: "en-GB",
initialMessages: { "en-GB": { hello: "Hi" } },
});
const opts = toI18nInstanceOptions(cfg);
expect(opts.messages).toEqual({ "en-GB": { hello: "Hi" } });
});
it("forwards every documented field", { timeout: 5000 }, () => {
const cfg = createI18nConfig({
defaultLocale: "en-GB",
fallbackLocale: "en-US",
initialMessages: { "en-GB": { hello: "Hi" } },
backend: {
name: "memory",
capabilities: { canRead: true },
},
backends: [
{ name: "memory", capabilities: { canRead: true } },
{ name: "http", capabilities: { canRead: true } },
],
saveMissing: true,
plugins: [{ name: "missing-key-reporter" }],
});
const opts = toI18nInstanceOptions(cfg);
expect(opts.defaultLocale).toBe("en-GB");
expect(opts.fallbackLocale).toBe("en-US");
expect(opts.messages).toEqual({ "en-GB": { hello: "Hi" } });
expect(opts.backend).toBeDefined();
expect(opts.backends).toHaveLength(2);
expect(opts.saveMissing).toBe(true);
expect(opts.plugins).toHaveLength(1);
});
it("is pure — calling it twice yields equivalent options", {
timeout: 5000,
}, () => {
const cfg = createI18nConfig({
defaultLocale: "en-GB",
fallbackLocale: "en-US",
});
const a = toI18nInstanceOptions(cfg);
const b = toI18nInstanceOptions(cfg);
expect(a).toEqual(b);
});
});

View File

@@ -0,0 +1,72 @@
import type { I18nInstanceOptions } from "@suaveplan/i18n/plugin";
import type { I18nConfig } from "../schemas/i18n-config.schemas.js";
type Mutable<T> = { -readonly [K in keyof T]: T[K] };
type Required<T, K extends keyof T> = Exclude<T[K], undefined>;
/**
* Translate a Zod-parsed `I18nConfig` (the React-tier shape) into the
* universal-core `I18nInstanceOptions` accepted by `createI18n(...)`.
*
* Field mapping:
*
* - `defaultLocale` → `defaultLocale`
* - `fallbackLocale` → `fallbackLocale` (when present)
* - `initialMessages` → `messages` (the universal core uses the
* shorter name; the React surface uses `initialMessages` so SSR
* semantics are obvious to consumers)
* - `backend` / `backends` / `saveMissing` → forwarded as-is
* - `plugins` → forwarded after a structural cast since the React
* schema only validates the `name` field of each plugin and the core
* accepts the full `I18nPlugin` shape
*
* The function is pure and has no side effects so the Provider can call
* it inside `useMemo` without destabilising the rendered tree.
*
* The `Backend` / `I18nPlugin` / `TranslationRecord` types from the
* universal core are deliberately consumed via structural casts here
* (`as unknown as Required<I18nInstanceOptions, "backend">` etc.) so
* this module does not need a hard dependency on the universal core's
* type-only subpaths beyond `./plugin`. This keeps the React boundary
* loosely coupled: the universal core can re-shape its `Backend` /
* `I18nPlugin` interfaces without invalidating the React surface as
* long as `I18nInstanceOptions` stays compatible.
*/
export function toI18nInstanceOptions(config: I18nConfig): I18nInstanceOptions {
const opts: Mutable<I18nInstanceOptions> = {
defaultLocale: config.defaultLocale,
};
if (config.fallbackLocale !== undefined) {
opts.fallbackLocale = config.fallbackLocale;
}
if (config.initialMessages !== undefined) {
opts.messages = config.initialMessages as unknown as Required<
I18nInstanceOptions,
"messages"
>;
}
if (config.backend !== undefined) {
opts.backend = config.backend as unknown as Required<
I18nInstanceOptions,
"backend"
>;
}
if (config.backends !== undefined) {
opts.backends = config.backends as unknown as Required<
I18nInstanceOptions,
"backends"
>;
}
if (config.saveMissing !== undefined) {
opts.saveMissing = config.saveMissing;
}
if (config.plugins !== undefined) {
opts.plugins = config.plugins as unknown as Required<
I18nInstanceOptions,
"plugins"
>;
}
return opts;
}

View File

@@ -0,0 +1,82 @@
# acceptLanguageDetector
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`acceptLanguageDetector` is a server-only `LocaleDetector` that parses
the inbound `Accept-Language` header per RFC 9110 §5.3.5 and returns
the highest-priority entry. It is the canonical fallback when nothing
else (querystring, user preference, cookie, geo lookup) has produced a
match — every browser submits an `Accept-Language` header so this
detector almost always returns _something_ on a real request.
The detector returns the _raw_ highest-q entry; supported-locale
negotiation is the chain runner's responsibility (it calls
`LocaleResolver.negotiate(raw, supported)` to perform BCP-47 fallback).
## Features
- Full RFC 9110 q-value parsing (default 1.0, range `[0, 1]`)
- Stable sort: ties keep insertion order
- Wildcard `*` skipped (no useful locale information)
- Malformed entries silently skipped
- `environment: "server"`
## Usage: Basic Example
```ts
import { acceptLanguageDetector } from "@suaveplan/react-i18n/detector/accept-language/accept-language";
import { createDetectorChain } from "@suaveplan/i18n/detector";
const chain = createDetectorChain([acceptLanguageDetector()]);
```
## Usage: Advanced Example
```ts
import { parseAcceptLanguage } from "@suaveplan/react-i18n/detector/accept-language/accept-language";
// Build a custom detector that biases language groups using the
// canonical RFC 9110 parser exposed by this module.
const groups = parseAcceptLanguage("fr-FR;q=0.5, de-DE;q=0.9, en;q=0.8");
// → [{ locale: "de-DE", q: 0.9 }, { locale: "en", q: 0.8 }, ...]
```
## API Reference
### `acceptLanguageDetector(): LocaleDetector`
Returns a `LocaleDetector` with `name: "acceptLanguage"` and
`environment: "server"`. Only `detect(ctx)` is implemented.
### `parseAcceptLanguage(header: string): AcceptLanguageEntry[]`
Parses an `Accept-Language` header into a sorted descending-by-q array
of `{ locale, q }` entries. Exposed for tests and as a building block
for advanced consumers building custom detectors.
### `AcceptLanguageEntry`
```ts
interface AcceptLanguageEntry {
readonly locale: string;
readonly q: number;
}
```
## Implementation Notes
- The parser uses `Array.prototype.sort` with a tie-breaking
insertion-index because the JavaScript spec only guarantees a
stable sort in modern engines; the explicit index keeps the
behaviour deterministic across older runtimes too.
- Wildcards are skipped at parse time — they never appear in the
returned array. The chain runner still has the option of
consulting `ctx.fallbackLocale` once every detector returns null.
- An entry whose q-value is unparseable, negative, or > 1 is treated
as a malformed entry per RFC 9110 ("the recipient SHOULD discard")
rather than coerced to the documented default — this keeps the
parser strict at the field level while staying liberal at the
header level.

View File

@@ -0,0 +1,156 @@
import type { DetectorContext } from "@suaveplan/i18n-detector";
import { describe, expect, it } from "@suaveplan/testing/runner";
import {
acceptLanguageDetector,
parseAcceptLanguage,
} from "./accept-language.js";
const SUPPORTED = ["en-GB", "fr-FR", "de-DE", "ja-JP"] as const;
function serverContext(headers: HeadersInit = {}): DetectorContext {
return {
request: {
headers: new Headers(headers),
url: new URL("https://example.com/"),
},
responseHeaders: new Headers(),
supportedLocales: SUPPORTED,
fallbackLocale: "en-GB",
};
}
describe("acceptLanguageDetector", () => {
it("exposes name and environment per spec", { timeout: 5000 }, () => {
const detector = acceptLanguageDetector();
expect(detector.name).toBe("acceptLanguage");
expect(detector.environment).toBe("server");
});
it("returns highest-q tag from Accept-Language", { timeout: 5000 }, () => {
const detector = acceptLanguageDetector();
const result = detector.detect?.(
serverContext({
"accept-language": "fr-FR;q=0.5, de-DE;q=0.9, en-GB;q=0.8",
})
);
expect(result).toBe("de-DE");
});
it("treats missing q as 1.0", { timeout: 5000 }, () => {
const detector = acceptLanguageDetector();
const result = detector.detect?.(
serverContext({
"accept-language": "fr-FR;q=0.9, de-DE",
})
);
expect(result).toBe("de-DE");
});
it("preserves insertion order on ties", { timeout: 5000 }, () => {
const detector = acceptLanguageDetector();
const result = detector.detect?.(
serverContext({ "accept-language": "fr-FR, de-DE, ja-JP" })
);
expect(result).toBe("fr-FR");
});
it("skips wildcard token", { timeout: 5000 }, () => {
const detector = acceptLanguageDetector();
const result = detector.detect?.(
serverContext({ "accept-language": "*;q=1.0, fr-FR;q=0.5" })
);
expect(result).toBe("fr-FR");
});
it("returns null for empty header", { timeout: 5000 }, () => {
const detector = acceptLanguageDetector();
const result = detector.detect?.(
serverContext({ "accept-language": "" })
);
expect(result).toBeNull();
});
it("returns null when header missing entirely", { timeout: 5000 }, () => {
const detector = acceptLanguageDetector();
const result = detector.detect?.(serverContext({}));
expect(result).toBeNull();
});
it("returns null when no request context", { timeout: 5000 }, () => {
const detector = acceptLanguageDetector();
const result = detector.detect?.({
supportedLocales: SUPPORTED,
fallbackLocale: "en-GB",
});
expect(result).toBeNull();
});
it("skips malformed entries with negative q", { timeout: 5000 }, () => {
const detector = acceptLanguageDetector();
const result = detector.detect?.(
serverContext({
"accept-language": "fr-FR;q=-1, de-DE;q=0.5",
})
);
expect(result).toBe("de-DE");
});
it("skips malformed entries with NaN q", { timeout: 5000 }, () => {
const detector = acceptLanguageDetector();
const result = detector.detect?.(
serverContext({
"accept-language": "fr-FR;q=abc, de-DE;q=0.5",
})
);
expect(result).toBe("de-DE");
});
it("skips malformed entries with q > 1", { timeout: 5000 }, () => {
const detector = acceptLanguageDetector();
const result = detector.detect?.(
serverContext({
"accept-language": "fr-FR;q=2.5, de-DE;q=0.4",
})
);
expect(result).toBe("de-DE");
});
it("ignores unrelated parameters", { timeout: 5000 }, () => {
const detector = acceptLanguageDetector();
const result = detector.detect?.(
serverContext({
"accept-language": "fr-FR;foo=bar;q=0.9, de-DE;q=0.5",
})
);
expect(result).toBe("fr-FR");
});
it("returns null for header consisting of only wildcards and empty", {
timeout: 5000,
}, () => {
const detector = acceptLanguageDetector();
const result = detector.detect?.(
serverContext({ "accept-language": "*, , *;q=0.5" })
);
expect(result).toBeNull();
});
it("parser handles parameter without '='", { timeout: 5000 }, () => {
const result = parseAcceptLanguage("fr-FR;noequals;q=0.6");
expect(result).toEqual([{ locale: "fr-FR", q: 0.6 }]);
});
it("parser handles trailing whitespace and empty params", {
timeout: 5000,
}, () => {
const result = parseAcceptLanguage(" fr-FR ; ; q = 0.7 ");
expect(result).toEqual([{ locale: "fr-FR", q: 0.7 }]);
});
it("parser yields empty when header only has commas", {
timeout: 5000,
}, () => {
const result = parseAcceptLanguage(",, ,");
expect(result).toEqual([]);
});
});

View File

@@ -0,0 +1,82 @@
import type { LocaleDetector } from "@suaveplan/i18n-detector";
import type { AcceptLanguageEntry } from "./accept-language.types.js";
/**
* Build a `LocaleDetector` that parses the inbound `Accept-Language`
* header per RFC 9110 §5.3.5 and returns the highest-priority entry.
*
* The detector returns the *raw* highest-q entry; supported-locale
* negotiation is the chain runner's responsibility (it calls
* `LocaleResolver.negotiate(raw, supportedLocales)` to perform BCP-47
* fallback). The wildcard token (`*`) is intentionally skipped because
* it carries no locale information that can be negotiated meaningfully.
*
* Malformed entries (missing tag, unparseable q value, q out of range)
* are silently skipped per RFC 9110: the parser MUST be liberal in
* what it accepts.
*/
export function acceptLanguageDetector(): LocaleDetector {
return {
name: "acceptLanguage",
environment: "server",
detect(ctx) {
const header = ctx.request?.headers.get("accept-language");
if (typeof header !== "string" || header.trim() === "") {
return null;
}
const entries = parseAcceptLanguage(header);
if (entries.length === 0) return null;
const first = entries[0] as AcceptLanguageEntry;
return first.locale;
},
};
}
/**
* Parse an `Accept-Language` header into a sorted list of entries.
* Sorted descending by q-value; ties keep input order (stable sort).
*
* Exported for tests and as a utility for advanced consumers building
* custom detectors layered on top of this one.
*/
export function parseAcceptLanguage(header: string): AcceptLanguageEntry[] {
const result: AcceptLanguageEntry[] = [];
const segments = header.split(",");
for (let i = 0; i < segments.length; i++) {
const segment = segments[i] as string;
const parsed = parseSegment(segment);
if (parsed !== null) result.push(parsed);
}
// Stable sort by q desc, falling back to insertion order via index.
return result
.map((entry, index) => ({ entry, index }))
.sort((a, b) => {
if (a.entry.q !== b.entry.q) return b.entry.q - a.entry.q;
return a.index - b.index;
})
.map(({ entry }) => entry);
}
function parseSegment(segment: string): AcceptLanguageEntry | null {
const trimmed = segment.trim();
if (trimmed === "") return null;
const parts = trimmed.split(";");
const tagPart = (parts[0] as string).trim();
if (tagPart === "" || tagPart === "*") return null;
let q = 1;
for (let i = 1; i < parts.length; i++) {
const param = (parts[i] as string).trim();
if (param === "") continue;
const eqIndex = param.indexOf("=");
if (eqIndex === -1) continue;
const key = param.slice(0, eqIndex).trim().toLowerCase();
if (key !== "q") continue;
const raw = param.slice(eqIndex + 1).trim();
const parsed = Number.parseFloat(raw);
if (Number.isNaN(parsed) || parsed < 0 || parsed > 1) {
return null;
}
q = parsed;
}
return { locale: tagPart, q };
}

View File

@@ -0,0 +1,8 @@
/**
* Single parsed `Accept-Language` entry: the locale tag and its
* computed quality-value (defaulting to 1.0 when omitted).
*/
export interface AcceptLanguageEntry {
readonly locale: string;
readonly q: number;
}

View File

@@ -0,0 +1,5 @@
export {
acceptLanguageDetector,
parseAcceptLanguage,
} from "./accept-language.js";
export type { AcceptLanguageEntry } from "./accept-language.types.js";

View File

@@ -0,0 +1,83 @@
# cloudflareIpGeoDetector
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`cloudflareIpGeoDetector` is a server-only `LocaleDetector` that maps
the Cloudflare `cf-ipcountry` request header (ISO 3166-1 alpha-2) to a
BCP-47 locale via `@suaveplan/locale-data`. The detector targets edge
deployments behind Cloudflare's proxy, where every inbound request
already carries an authoritative country lookup. It is the last
substantive detector before the unconditional fallback in the default
chain — by the time the chain reaches it, every other source has
already returned `null`.
The mapping rule is `${language}-${ISO}` where `language` is the
country's primary spoken language as recorded in the locale-data
package (e.g. UAE → `["ar"]` → `"ar-AE"` per the spec scenario
"cloudflareIpGeoDetector maps country code").
## Features
- Reads the `cf-ipcountry` header (override via `opts.headerName`)
- Default mapping derived from `@suaveplan/locale-data`
- Per-country override map via `opts.countryToLocale`
- Case-insensitive ISO code handling
- Returns `null` for missing / empty / unknown country codes
- `environment: "server"`
## Usage: Basic Example
```ts
import { cloudflareIpGeoDetector } from "@suaveplan/react-i18n/detector/cloudflare-ip-geo/cloudflare-ip-geo";
import { createDetectorChain } from "@suaveplan/i18n/detector";
const chain = createDetectorChain([cloudflareIpGeoDetector()]);
```
## Usage: Advanced Example
```ts
import { cloudflareIpGeoDetector } from "@suaveplan/react-i18n/detector/cloudflare-ip-geo/cloudflare-ip-geo";
// Override Switzerland (locale-data lists multiple languages) so the
// detector resolves to French Swiss rather than the default first
// language. Country codes for which the default suffices are omitted.
const detector = cloudflareIpGeoDetector({
countryToLocale: { CH: "fr-CH" },
});
```
## API Reference
### `cloudflareIpGeoDetector(options?: CloudflareIpGeoDetectorOptions): LocaleDetector`
Returns a `LocaleDetector` with `name: "cloudflareIpGeo"` and
`environment: "server"`. Only `detect(ctx)` is implemented.
### `CloudflareIpGeoDetectorOptions`
| Field | Type | Default | Description |
| --------------- | ----------------------------- | ---------------- | ------------------------------------ |
| headerName | `string` | `"cf-ipcountry"` | Header name read from the request |
| countryToLocale | `Record<string, string>` | `{}` | Per-country locale overrides |
## Implementation Notes
- The detector uppercases the inbound code before lookup so it
tolerates upstream proxies that may have normalised the header
value to lower case.
- An override that maps to an empty string is treated as a no-op so
callers can safely build the override map by spreading partials
without manual filtering.
- The detector intentionally does NOT swallow the `getAllCountries`
call result — the registry is loaded once at module-load time so
every call is `O(1)` lookup.
- The detector relies on the locale-data invariant that every
country record has at least one entry in its `languages` array
(verified by the locale-data package's own conformance tests).
Should that invariant ever change, the detector would emit a
malformed `undefined-XX` locale; downstream BCP-47 negotiation in
the chain runner would surface the malformed value as `null`.

View File

@@ -0,0 +1,116 @@
import type { DetectorContext } from "@suaveplan/i18n-detector";
import { describe, expect, it } from "@suaveplan/testing/runner";
import { cloudflareIpGeoDetector } from "./cloudflare-ip-geo.js";
const SUPPORTED = ["en-GB", "ar-AE", "ja-JP", "fr-FR"] as const;
function serverContext(headers: HeadersInit = {}): DetectorContext {
return {
request: {
headers: new Headers(headers),
url: new URL("https://example.com/"),
},
responseHeaders: new Headers(),
supportedLocales: SUPPORTED,
fallbackLocale: "en-GB",
};
}
describe("cloudflareIpGeoDetector", () => {
it("exposes name and environment per spec", { timeout: 5000 }, () => {
const detector = cloudflareIpGeoDetector();
expect(detector.name).toBe("cloudflareIpGeo");
expect(detector.environment).toBe("server");
});
it("maps cf-ipcountry: AE → ar-AE per spec", { timeout: 5000 }, () => {
const detector = cloudflareIpGeoDetector();
const result = detector.detect?.(
serverContext({ "cf-ipcountry": "AE" })
);
expect(result).toBe("ar-AE");
});
it("maps JP → ja-JP via locale-data languages array", {
timeout: 5000,
}, () => {
const detector = cloudflareIpGeoDetector();
const result = detector.detect?.(
serverContext({ "cf-ipcountry": "JP" })
);
expect(result).toBe("ja-JP");
});
it("honours override map", { timeout: 5000 }, () => {
const detector = cloudflareIpGeoDetector({
countryToLocale: { AE: "en-AE" },
});
const result = detector.detect?.(
serverContext({ "cf-ipcountry": "AE" })
);
expect(result).toBe("en-AE");
});
it("ignores empty override entry and uses derived value", {
timeout: 5000,
}, () => {
const detector = cloudflareIpGeoDetector({
countryToLocale: { AE: "" },
});
const result = detector.detect?.(
serverContext({ "cf-ipcountry": "AE" })
);
expect(result).toBe("ar-AE");
});
it("honours custom headerName", { timeout: 5000 }, () => {
const detector = cloudflareIpGeoDetector({
headerName: "x-edge-country",
});
const result = detector.detect?.(
serverContext({ "x-edge-country": "FR" })
);
expect(result).toBe("fr-FR");
});
it("uppercases the inbound code before lookup", { timeout: 5000 }, () => {
const detector = cloudflareIpGeoDetector();
const result = detector.detect?.(
serverContext({ "cf-ipcountry": "ae" })
);
expect(result).toBe("ar-AE");
});
it("returns null for unknown country code", { timeout: 5000 }, () => {
const detector = cloudflareIpGeoDetector();
const result = detector.detect?.(
serverContext({ "cf-ipcountry": "ZZ" })
);
expect(result).toBeNull();
});
it("returns null when header missing", { timeout: 5000 }, () => {
const detector = cloudflareIpGeoDetector();
const result = detector.detect?.(serverContext({}));
expect(result).toBeNull();
});
it("returns null when header is empty", { timeout: 5000 }, () => {
const detector = cloudflareIpGeoDetector();
const result = detector.detect?.(
serverContext({ "cf-ipcountry": " " })
);
expect(result).toBeNull();
});
it("returns null when no request context is supplied", {
timeout: 5000,
}, () => {
const detector = cloudflareIpGeoDetector();
const result = detector.detect?.({
supportedLocales: SUPPORTED,
fallbackLocale: "en-GB",
});
expect(result).toBeNull();
});
});

View File

@@ -0,0 +1,50 @@
import type { LocaleDetector } from "@suaveplan/i18n-detector";
import { getAllCountries } from "@suaveplan/locale-data";
import type { CloudflareIpGeoDetectorOptions } from "./cloudflare-ip-geo.types.js";
const DEFAULT_HEADER_NAME = "cf-ipcountry";
/**
* Build a `LocaleDetector` that maps the Cloudflare `cf-ipcountry`
* header (ISO 3166-1 alpha-2) to a BCP-47 locale.
*
* Resolution order, per ISO code:
*
* 1. `opts.countryToLocale[code]` if provided.
* 2. `${language}-${code}` where `language` is the first entry from the
* country record's `languages` array in `@suaveplan/locale-data`.
* 3. `null` when neither override nor language data exists.
*
* Example: `cf-ipcountry: AE` → `"ar-AE"` (UAE country record lists
* `["ar"]` as its languages).
*/
export function cloudflareIpGeoDetector(
options: CloudflareIpGeoDetectorOptions = {}
): LocaleDetector {
const headerName = options.headerName ?? DEFAULT_HEADER_NAME;
const overrides = options.countryToLocale ?? {};
const countries = getAllCountries();
return {
name: "cloudflareIpGeo",
environment: "server",
detect(ctx) {
const raw = ctx.request?.headers.get(headerName);
if (typeof raw !== "string" || raw.trim() === "") return null;
const code = raw.trim().toUpperCase();
const override = overrides[code];
if (typeof override === "string" && override !== "") {
return override;
}
const country = countries[code];
if (!country) return null;
// The locale-data `countries.data.ts` invariant guarantees
// every entry has at least one language; the test harness
// exercises every reachable branch via known ISO codes.
const primaryLanguage = country.languages[0] as string;
return `${primaryLanguage}-${code}`;
},
};
}

View File

@@ -0,0 +1,20 @@
/**
* Configuration accepted by `cloudflareIpGeoDetector`. Both fields are
* optional; defaults pull the standard Cloudflare `cf-ipcountry`
* header and resolve a locale via `@suaveplan/locale-data`.
*/
export interface CloudflareIpGeoDetectorOptions {
/**
* Header name to read. Default `"cf-ipcountry"`. Cloudflare always
* emits a lower-case header name on the wire; `Headers.get(...)` is
* case-insensitive so the default matches any casing the upstream
* runtime may surface.
*/
readonly headerName?: string;
/**
* Per-country override map. Keys are ISO 3166-1 alpha-2 country
* codes; values are full BCP-47 locale tags. Overrides win over
* the data-derived default mapping for the matching country.
*/
readonly countryToLocale?: Readonly<Record<string, string>>;
}

View File

@@ -0,0 +1,2 @@
export { cloudflareIpGeoDetector } from "./cloudflare-ip-geo.js";
export type { CloudflareIpGeoDetectorOptions } from "./cloudflare-ip-geo.types.js";

View File

@@ -0,0 +1,83 @@
# cookieDetector
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`cookieDetector` is a `LocaleDetector` that reads/writes the active
locale via a single cookie. It runs in both server and browser
environments, making it the natural persistence story for stateless
visitors who have not yet authenticated. On the server it consumes the
inbound `Cookie` header and writes a `Set-Cookie` header onto
`ctx.responseHeaders`; on the browser it goes through `document.cookie`.
The detector URL-encodes values on persist and decodes them on read so
the wire format is robust against unusual locale identifiers.
## Features
- Configurable cookie name, max-age, SameSite, and path
- Server read via `ctx.request.headers` and write via
`ctx.responseHeaders`
- Browser read/write via `document.cookie` (including `ctx.document`
injection for tests)
- URL-encoded values; safe parsing tolerates malformed segments and
decode failures
- `environment: "both"`
## Usage: Basic Example
```ts
import { cookieDetector } from "@suaveplan/react-i18n/detector/cookie/cookie";
import { createDetectorChain } from "@suaveplan/i18n/detector";
const chain = createDetectorChain([cookieDetector()]);
```
## Usage: Advanced Example
```ts
import { cookieDetector } from "@suaveplan/react-i18n/detector/cookie/cookie";
// Cross-site embed scenario: SameSite=None requires Secure on real
// browsers; the detector emits the SameSite attribute literally so the
// host server can append `Secure` via its existing cookie middleware.
const detector = cookieDetector({
name: "app_locale",
maxAgeSeconds: 60 * 60 * 24 * 30, // 30 days
sameSite: "none",
path: "/embed",
});
```
## API Reference
### `cookieDetector(options?: CookieDetectorOptions): LocaleDetector`
Returns a `LocaleDetector` with `name: "cookie"` and
`environment: "both"`. Both `detect` and `cache` are implemented.
### `CookieDetectorOptions`
| Field | Type | Default | Description |
| ------------- | ---------------------------------------- | -------------------- | ---------------------------- |
| name | `string` | `"suaveplan_locale"` | Cookie name |
| maxAgeSeconds | `number` | `31536000` | Cookie lifetime in seconds |
| sameSite | `"lax" \| "strict" \| "none"` (default `"lax"`) | `"lax"` | SameSite attribute value |
| path | `string` | `"/"` | Cookie path attribute |
## Implementation Notes
- The detector emits SameSite values capitalised (`Lax`, `Strict`,
`None`) per the cookie spec; the input option stays lower-case so
consumers don't have to remember the casing convention.
- Cookie parsing tolerates segments without an `=` (skipped) and
values that fail percent-decoding (returned raw rather than
throwing).
- The cookie writer uses `Headers.append('set-cookie', …)` so a single
response may carry multiple Set-Cookie writes from sibling
middleware.
- An empty server cookie header transparently falls through to
`document.cookie` so isomorphic test harnesses can stage a browser
scenario inside a server-shaped `DetectorContext` by supplying only
`globalThis.document`.

View File

@@ -0,0 +1,275 @@
import type { DetectorContext } from "@suaveplan/i18n-detector";
import { describe, expect, it } from "@suaveplan/testing/runner";
import { cookieDetector } from "./cookie.js";
const SUPPORTED = ["en-GB", "fr-FR", "de-DE", "ru-RU"] as const;
function serverContext(headers: HeadersInit = {}): DetectorContext {
return {
request: {
headers: new Headers(headers),
url: new URL("https://example.com/"),
},
responseHeaders: new Headers(),
supportedLocales: SUPPORTED,
fallbackLocale: "en-GB",
};
}
function browserContext(): DetectorContext {
return {
supportedLocales: SUPPORTED,
fallbackLocale: "en-GB",
};
}
function withDocumentCookie<T>(initial: string, fn: () => T): T {
const docOriginal = (globalThis as { document?: unknown }).document;
let store = initial;
const fakeDoc = {
get cookie() {
return store;
},
set cookie(value: string) {
store = value;
},
};
Object.defineProperty(globalThis, "document", {
configurable: true,
value: fakeDoc,
});
try {
return fn();
} finally {
Object.defineProperty(globalThis, "document", {
configurable: true,
value: docOriginal,
});
}
}
describe("cookieDetector", () => {
it("reads named cookie from server-side cookie header", {
timeout: 5000,
}, () => {
const detector = cookieDetector();
const result = detector.detect?.(
serverContext({ cookie: "suaveplan_locale=fr-FR; other=1" })
);
expect(result).toBe("fr-FR");
});
it("honours custom name option", { timeout: 5000 }, () => {
const detector = cookieDetector({ name: "l" });
const result = detector.detect?.(serverContext({ cookie: "l=ru-RU" }));
expect(result).toBe("ru-RU");
});
it("decodes URL-encoded cookie values", { timeout: 5000 }, () => {
const detector = cookieDetector();
const result = detector.detect?.(
serverContext({ cookie: "suaveplan_locale=fr%2DFR" })
);
expect(result).toBe("fr-FR");
});
it("returns the raw value when decode throws", { timeout: 5000 }, () => {
const detector = cookieDetector();
// %ZZ is invalid percent-encoding -> decodeURIComponent throws
const result = detector.detect?.(
serverContext({ cookie: "suaveplan_locale=%ZZ" })
);
expect(result).toBe("%ZZ");
});
it("returns null when cookie missing", { timeout: 5000 }, () => {
const detector = cookieDetector();
const result = detector.detect?.(
serverContext({ cookie: "session=abc" })
);
expect(result).toBeNull();
});
it("returns null when matched cookie value is empty", {
timeout: 5000,
}, () => {
const detector = cookieDetector();
const result = detector.detect?.(
serverContext({ cookie: "suaveplan_locale=" })
);
expect(result).toBeNull();
});
it("ignores malformed segments without '='", { timeout: 5000 }, () => {
const detector = cookieDetector();
const result = detector.detect?.(
serverContext({
cookie: "noequalsign; suaveplan_locale=de-DE; ",
})
);
expect(result).toBe("de-DE");
});
it("skips empty cookie segments", { timeout: 5000 }, () => {
const detector = cookieDetector();
const result = detector.detect?.(
serverContext({
cookie: ";; suaveplan_locale=fr-FR;;",
})
);
expect(result).toBe("fr-FR");
});
it("persists choice via Set-Cookie header on server", {
timeout: 5000,
}, () => {
const detector = cookieDetector({ name: "l", maxAgeSeconds: 100 });
const ctx = serverContext({});
detector.cache?.("fr-FR", ctx);
const set = ctx.responseHeaders?.get("set-cookie");
expect(set).toBe("l=fr-FR; Max-Age=100; Path=/; SameSite=Lax");
});
it("Strict and None sameSite serialise correctly", {
timeout: 5000,
}, () => {
const ctxStrict = serverContext({});
cookieDetector({ sameSite: "strict" }).cache?.("en-GB", ctxStrict);
expect(ctxStrict.responseHeaders?.get("set-cookie")).toContain(
"SameSite=Strict"
);
const ctxNone = serverContext({});
cookieDetector({ sameSite: "none" }).cache?.("en-GB", ctxNone);
expect(ctxNone.responseHeaders?.get("set-cookie")).toContain(
"SameSite=None"
);
});
it("URL-encodes the value on persist", { timeout: 5000 }, () => {
const ctx = serverContext({});
cookieDetector().cache?.("zh CN/with;semi", ctx);
const set = ctx.responseHeaders?.get("set-cookie");
expect(set).toContain(
`suaveplan_locale=${encodeURIComponent("zh CN/with;semi")}`
);
});
it("honours custom path option", { timeout: 5000 }, () => {
const ctx = serverContext({});
cookieDetector({ path: "/app" }).cache?.("en-GB", ctx);
expect(ctx.responseHeaders?.get("set-cookie")).toContain("Path=/app");
});
it("reads document.cookie on browser when no request header", {
timeout: 5000,
}, () => {
const detector = cookieDetector();
withDocumentCookie("suaveplan_locale=de-DE", () => {
const result = detector.detect?.(browserContext());
expect(result).toBe("de-DE");
});
});
it("writes document.cookie on browser when no responseHeaders", {
timeout: 5000,
}, () => {
const detector = cookieDetector();
withDocumentCookie("", () => {
detector.cache?.("ru-RU", browserContext());
const written = (globalThis as { document: { cookie: string } })
.document.cookie;
expect(written).toContain("suaveplan_locale=ru-RU");
expect(written).toContain("Max-Age=31536000");
expect(written).toContain("SameSite=Lax");
});
});
it("returns null on browser when document is unavailable", {
timeout: 5000,
}, () => {
const detector = cookieDetector();
const docOriginal = (globalThis as { document?: unknown }).document;
Object.defineProperty(globalThis, "document", {
configurable: true,
value: undefined,
});
try {
const result = detector.detect?.(browserContext());
expect(result).toBeNull();
} finally {
Object.defineProperty(globalThis, "document", {
configurable: true,
value: docOriginal,
});
}
});
it("cache is a no-op when neither responseHeaders nor document available", {
timeout: 5000,
}, () => {
const detector = cookieDetector();
const docOriginal = (globalThis as { document?: unknown }).document;
Object.defineProperty(globalThis, "document", {
configurable: true,
value: undefined,
});
try {
expect(() =>
detector.cache?.("en-GB", browserContext())
).not.toThrow();
} finally {
Object.defineProperty(globalThis, "document", {
configurable: true,
value: docOriginal,
});
}
});
it("ignores empty server cookie header and falls through to document", {
timeout: 5000,
}, () => {
const detector = cookieDetector();
withDocumentCookie("suaveplan_locale=fr-FR", () => {
const ctx = serverContext({});
const result = detector.detect?.(ctx);
expect(result).toBe("fr-FR");
});
});
it("returns null on browser when document.cookie is not a string", {
timeout: 5000,
}, () => {
const detector = cookieDetector();
const docOriginal = (globalThis as { document?: unknown }).document;
Object.defineProperty(globalThis, "document", {
configurable: true,
value: { cookie: 1234 as unknown as string },
});
try {
const result = detector.detect?.(browserContext());
expect(result).toBeNull();
} finally {
Object.defineProperty(globalThis, "document", {
configurable: true,
value: docOriginal,
});
}
});
it("uses ctx.document over globalThis.document when supplied", {
timeout: 5000,
}, () => {
const detector = cookieDetector();
withDocumentCookie("suaveplan_locale=en-GB", () => {
const injected = {
cookie: "suaveplan_locale=fr-FR",
} as unknown as Document;
const result = detector.detect?.({
...browserContext(),
document: injected,
});
expect(result).toBe("fr-FR");
});
});
});

View File

@@ -0,0 +1,110 @@
import type { DetectorContext, LocaleDetector } from "@suaveplan/i18n-detector";
import type { CookieDetectorOptions, CookieSameSite } from "./cookie.types.js";
const DEFAULT_NAME = "suaveplan_locale";
const DEFAULT_MAX_AGE_SECONDS = 31536000;
const DEFAULT_SAME_SITE: CookieSameSite = "lax";
const DEFAULT_PATH = "/";
const SAME_SITE_LABEL: Record<CookieSameSite, string> = {
lax: "Lax",
strict: "Strict",
none: "None",
};
/**
* Build a `LocaleDetector` that reads/writes the active locale via a
* single cookie. On the server the detector reads the inbound `Cookie`
* header and writes a `Set-Cookie` header onto `ctx.responseHeaders`.
* On the browser it reads/writes `document.cookie` directly.
*
* Cookie values are URL-encoded so locales containing non-token
* characters (none of the BCP-47 subset, but a defensive guard) cannot
* break the cookie wire format.
*/
export function cookieDetector(
options: CookieDetectorOptions = {}
): LocaleDetector {
const name = options.name ?? DEFAULT_NAME;
const maxAgeSeconds = options.maxAgeSeconds ?? DEFAULT_MAX_AGE_SECONDS;
const sameSite = options.sameSite ?? DEFAULT_SAME_SITE;
const path = options.path ?? DEFAULT_PATH;
return {
name: "cookie",
environment: "both",
detect(ctx) {
const cookieHeader = readCookieHeader(ctx);
if (cookieHeader === null) return null;
return parseCookieValue(cookieHeader, name);
},
cache(locale, ctx) {
const setCookieValue = formatSetCookie({
name,
value: locale,
maxAgeSeconds,
sameSite,
path,
});
if (ctx.responseHeaders) {
ctx.responseHeaders.append("set-cookie", setCookieValue);
return;
}
const doc = readDocument(ctx);
if (doc === null) return;
doc.cookie = setCookieValue;
},
};
}
function readCookieHeader(ctx: DetectorContext): string | null {
const fromRequest = ctx.request?.headers.get("cookie");
if (typeof fromRequest === "string" && fromRequest !== "") {
return fromRequest;
}
const doc = readDocument(ctx);
if (doc === null) return null;
return typeof doc.cookie === "string" ? doc.cookie : null;
}
function readDocument(ctx: DetectorContext): Document | null {
if (ctx.document) return ctx.document;
const candidate = (globalThis as { document?: unknown }).document;
if (candidate && typeof candidate === "object") {
return candidate as Document;
}
return null;
}
function parseCookieValue(header: string, name: string): string | null {
const segments = header.split(";");
for (const segment of segments) {
const trimmed = segment.trim();
if (trimmed === "") continue;
const eqIndex = trimmed.indexOf("=");
if (eqIndex === -1) continue;
const key = trimmed.slice(0, eqIndex).trim();
if (key !== name) continue;
const raw = trimmed.slice(eqIndex + 1).trim();
if (raw === "") return null;
try {
return decodeURIComponent(raw);
} catch {
return raw;
}
}
return null;
}
interface SetCookieParts {
readonly name: string;
readonly value: string;
readonly maxAgeSeconds: number;
readonly sameSite: CookieSameSite;
readonly path: string;
}
function formatSetCookie(parts: SetCookieParts): string {
const encoded = encodeURIComponent(parts.value);
return `${parts.name}=${encoded}; Max-Age=${parts.maxAgeSeconds}; Path=${parts.path}; SameSite=${SAME_SITE_LABEL[parts.sameSite]}`;
}

View File

@@ -0,0 +1,21 @@
/**
* SameSite values accepted by `cookieDetector`. Mirrors the standard
* cookie attribute semantics; values are lower-cased on emit.
*/
export type CookieSameSite = "lax" | "strict" | "none";
/**
* Configuration accepted by `cookieDetector`. Every field is optional
* with sensible defaults (cookie name `suaveplan_locale`, one-year
* lifetime, `Lax` same-site, root path).
*/
export interface CookieDetectorOptions {
/** Cookie name. Default `"suaveplan_locale"`. */
readonly name?: string;
/** Cookie max age in seconds. Default `31536000` (1 year). */
readonly maxAgeSeconds?: number;
/** Cookie SameSite attribute. Default `"lax"`. */
readonly sameSite?: CookieSameSite;
/** Cookie path attribute. Default `"/"`. */
readonly path?: string;
}

View File

@@ -0,0 +1,5 @@
export { cookieDetector } from "./cookie.js";
export type {
CookieDetectorOptions,
CookieSameSite,
} from "./cookie.types.js";

View File

@@ -0,0 +1,91 @@
# defaultDetectorChain
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`defaultDetectorChain` is the canonical factory for the locale-detector
chain consumed by `<TranslationProvider>` and the
`useLocaleDetection` hook. It returns the standard 9-entry (or
8-entry, when `userPreferenceEndpoint` is omitted) detector list in the
order mandated by the spec — explicit user intent (querystring) first,
then per-user persistence (userPreference, cookie, localStorage), then
shell hints (htmlLang), then platform negotiation (acceptLanguage,
navigator), then geo (cloudflareIpGeo), and finally the unconditional
fallback.
The factory is the single source of truth for chain ordering. Custom
chains SHOULD start from this list and apply `filter` / `map` rather
than re-implementing the order, so future chain additions land in
every consumer automatically.
## Features
- 9-entry chain when `userPreferenceEndpoint` is provided
- 8-entry chain when it is omitted (or empty)
- Fresh array on every call (mutating the result is safe)
- Fallback locale validation at construction time
## Usage: Basic Example
```ts
import { defaultDetectorChain } from "@suaveplan/react-i18n/detector/default-chain/default-chain";
import { createDetectorChain } from "@suaveplan/i18n/detector";
const chain = createDetectorChain(
defaultDetectorChain({ fallback: "en-GB" })
);
```
## Usage: Advanced Example
```ts
import { defaultDetectorChain } from "@suaveplan/react-i18n/detector/default-chain/default-chain";
import { createDetectorChain } from "@suaveplan/i18n/detector";
// Skip Cloudflare in non-edge deployments and replace the default
// querystring param name with `?locale=`.
import { querystringDetector } from "@suaveplan/react-i18n/detector/querystring/querystring";
const detectors = defaultDetectorChain({
fallback: "en-GB",
userPreferenceEndpoint: "/api/me/locale",
})
.filter((d) => d.name !== "cloudflareIpGeo")
.map((d) =>
d.name === "querystring"
? querystringDetector({ paramName: "locale" })
: d
);
const chain = createDetectorChain(detectors);
```
## API Reference
### `defaultDetectorChain(options: DefaultDetectorChainOptions): LocaleDetector[]`
Returns a fresh array of `LocaleDetector` objects in the canonical
order. Throws if `options.fallback` is empty (the fallback detector's
constructor performs the check).
### `DefaultDetectorChainOptions`
| Field | Type | Default | Description |
| ---------------------- | -------- | ------- | --------------------------------------------------- |
| fallback | `string` | — | Required. Locale used by the terminal `fallbackDetector`. |
| userPreferenceEndpoint | `string` | (omit) | URL passed to `userPreferenceDetector`. When absent (or empty) the userPreference detector is skipped. |
## Implementation Notes
- The chain is returned as a fresh array on every call so consumers
can `filter` / `map` without affecting subsequent invocations or
shared state.
- Empty `userPreferenceEndpoint` strings are treated identically to
the field being omitted — a defensive guard for callers that
produce the options bag from environment variables (`process.env`
values default to empty strings, not undefined, in many runtimes).
- The factory deliberately exposes the canonical order _only_ — there
is no priority/weight mechanism on individual detectors. Consumers
who need a different order should compose their own array.

View File

@@ -0,0 +1,214 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { createReactI18nRegistry } from "../../registry/registry.js";
import type { DetectorPlugin } from "../../registry/registry.types.js";
import { defaultDetectorChain } from "./default-chain.js";
const detectorPlugin = (id: string, priority: number): DetectorPlugin => ({
id,
priority,
detect: {
name: id,
environment: "both",
detect: () => null,
},
});
describe("defaultDetectorChain", () => {
it("order matches spec when userPreferenceEndpoint provided", {
timeout: 5000,
}, () => {
const chain = defaultDetectorChain({
fallback: "en-GB",
userPreferenceEndpoint: "/api/me/locale",
});
expect(chain.map((d) => d.name)).toEqual([
"querystring",
"userPreference",
"cookie",
"localStorage",
"htmlLang",
"acceptLanguage",
"navigator",
"cloudflareIpGeo",
"fallback",
]);
});
it("omits userPreference when endpoint absent", { timeout: 5000 }, () => {
const chain = defaultDetectorChain({ fallback: "en-GB" });
expect(chain.map((d) => d.name)).toEqual([
"querystring",
"cookie",
"localStorage",
"htmlLang",
"acceptLanguage",
"navigator",
"cloudflareIpGeo",
"fallback",
]);
expect(chain.length).toBe(8);
});
it("omits userPreference when endpoint is an empty string", {
timeout: 5000,
}, () => {
const chain = defaultDetectorChain({
fallback: "en-GB",
userPreferenceEndpoint: "",
});
expect(chain.map((d) => d.name)).not.toContain("userPreference");
expect(chain.length).toBe(8);
});
it("returns a fresh array on each call", { timeout: 5000 }, () => {
const a = defaultDetectorChain({ fallback: "en-GB" });
const b = defaultDetectorChain({ fallback: "en-GB" });
expect(a).not.toBe(b);
a.length = 0;
expect(b.length).toBe(8);
});
it("fallback detector wraps the supplied locale", { timeout: 5000 }, () => {
const chain = defaultDetectorChain({ fallback: "ja-JP" });
const fallback = chain[chain.length - 1];
expect(fallback?.name).toBe("fallback");
expect(
fallback?.detect?.({
supportedLocales: ["ja-JP"],
fallbackLocale: "ja-JP",
})
).toBe("ja-JP");
});
it("throws when fallback locale is empty", { timeout: 5000 }, () => {
expect(() => defaultDetectorChain({ fallback: "" })).toThrow();
});
it("splices a low-priority plugin before all built-ins", {
timeout: 5000,
}, () => {
const reg = createReactI18nRegistry();
reg.registerDetector(detectorPlugin("ab-bucket", 0));
const chain = defaultDetectorChain({
fallback: "en-GB",
registry: reg,
});
expect(chain.map((d) => d.name)).toEqual([
"ab-bucket",
"querystring",
"cookie",
"localStorage",
"htmlLang",
"acceptLanguage",
"navigator",
"cloudflareIpGeo",
"fallback",
]);
});
it("splices a mid-priority plugin between built-ins", {
timeout: 5000,
}, () => {
const reg = createReactI18nRegistry();
reg.registerDetector(detectorPlugin("custom", 350));
const chain = defaultDetectorChain({
fallback: "en-GB",
registry: reg,
});
expect(chain.map((d) => d.name)).toEqual([
"querystring",
"cookie",
"custom",
"localStorage",
"htmlLang",
"acceptLanguage",
"navigator",
"cloudflareIpGeo",
"fallback",
]);
});
it("places fallback last regardless of plugin priority", {
timeout: 5000,
}, () => {
const reg = createReactI18nRegistry();
reg.registerDetector(detectorPlugin("late", 10_000));
const chain = defaultDetectorChain({
fallback: "en-GB",
registry: reg,
});
expect(chain[chain.length - 1]?.name).toBe("fallback");
expect(chain[chain.length - 2]?.name).toBe("late");
});
it("built-in beats plugin at the same priority", { timeout: 5000 }, () => {
const reg = createReactI18nRegistry();
reg.registerDetector(detectorPlugin("aaa", 300));
const chain = defaultDetectorChain({
fallback: "en-GB",
registry: reg,
});
const cookieIdx = chain.findIndex((d) => d.name === "cookie");
const aaaIdx = chain.findIndex((d) => d.name === "aaa");
expect(cookieIdx).toBeLessThan(aaaIdx);
});
it("breaks plugin-vs-plugin ties lexicographically by id", {
timeout: 5000,
}, () => {
const reg = createReactI18nRegistry();
// Register 4 plugins at the same priority in non-alphabetic
// order so the sort comparator is invoked in both directions
// (a.id < b.id AND a.id > b.id) and against multiple pairs of
// plugin-vs-plugin AND plugin-vs-built-in (same priority as
// the cookie built-in slot).
reg.registerDetector(detectorPlugin("zeta", 250));
reg.registerDetector(detectorPlugin("alpha", 250));
reg.registerDetector(detectorPlugin("mike", 250));
reg.registerDetector(detectorPlugin("bravo", 250));
const chain = defaultDetectorChain({
fallback: "en-GB",
registry: reg,
});
const indexOf = (name: string) =>
chain.findIndex((d) => d.name === name);
expect(indexOf("alpha")).toBeLessThan(indexOf("bravo"));
expect(indexOf("bravo")).toBeLessThan(indexOf("mike"));
expect(indexOf("mike")).toBeLessThan(indexOf("zeta"));
});
it("plugin-with-built-in at same priority places built-in first regardless of registration order", {
timeout: 5000,
}, () => {
// Exercise the `a.isPlugin ? 1 : -1` ternary in BOTH directions
// by mixing a high-priority plugin with built-ins ordered
// either side. The sort comparator gets called with (plugin,
// builtin) AND (builtin, plugin) argument orders.
const reg = createReactI18nRegistry();
reg.registerDetector(detectorPlugin("zeta-plugin", 300));
reg.registerDetector(detectorPlugin("alpha-plugin", 100));
const chain = defaultDetectorChain({
fallback: "en-GB",
registry: reg,
});
const names = chain.map((d) => d.name);
// Built-ins always come before plugins at the same priority,
// and the fallback is always last.
expect(names[names.length - 1]).toBe("fallback");
expect(names).toContain("alpha-plugin");
expect(names).toContain("zeta-plugin");
});
it("no-registry path is byte-identical to no-options path", {
timeout: 5000,
}, () => {
const a = defaultDetectorChain({ fallback: "en-GB" }).map(
(d) => d.name
);
const b = defaultDetectorChain({
fallback: "en-GB",
registry: createReactI18nRegistry(),
}).map((d) => d.name);
expect(b).toEqual(a);
});
});

View File

@@ -0,0 +1,150 @@
import type { LocaleDetector } from "@suaveplan/i18n-detector";
import { acceptLanguageDetector } from "../accept-language/accept-language.js";
import { cloudflareIpGeoDetector } from "../cloudflare-ip-geo/cloudflare-ip-geo.js";
import { cookieDetector } from "../cookie/cookie.js";
import { fallbackDetector } from "../fallback/fallback.js";
import { htmlLangDetector } from "../html-lang/html-lang.js";
import { localStorageDetector } from "../local-storage/local-storage.js";
import { navigatorDetector } from "../navigator/navigator.js";
import { querystringDetector } from "../querystring/querystring.js";
import { userPreferenceDetector } from "../user-preference/user-preference.js";
import type { DefaultDetectorChainOptions } from "./default-chain.types.js";
interface ChainEntry {
readonly priority: number;
/** Built-ins win ties; plugins lose. */
readonly isPlugin: boolean;
/** Used for deterministic tie-break between two plugins. */
readonly id: string;
readonly detector: LocaleDetector;
}
/**
* Stable priority slots for the built-in detectors. Plugins splice in
* between adjacent slots based on their `priority`. The fallback
* detector has no slot — it is always appended last regardless of
* plugin priority, because it unconditionally resolves and would
* short-circuit any later detector if interleaved.
*/
const BUILTIN_PRIORITY = {
querystring: 100,
userPreference: 200,
cookie: 300,
localStorage: 400,
htmlLang: 500,
acceptLanguage: 600,
navigator: 700,
cloudflareIpGeo: 800,
} as const;
/**
* Build the canonical detector chain consumed by `<TranslationProvider>`
* and `useLocaleDetection`.
*
* Default order (no registry, no userPreferenceEndpoint — 8 entries):
*
* 1. `querystring` (100)
* 2. `cookie` (300)
* 3. `localStorage` (400)
* 4. `htmlLang` (500)
* 5. `acceptLanguage` (600)
* 6. `navigator` (700)
* 7. `cloudflareIpGeo` (800)
* 8. `fallback` (always last)
*
* With `userPreferenceEndpoint`, the `userPreference` detector slots
* in at priority 200. With a `registry`, every `DetectorPlugin` it
* has is spliced in by its `priority`; the fallback detector remains
* unconditionally last.
*
* The chain is returned as a fresh array on every call.
*/
export function defaultDetectorChain(
options: DefaultDetectorChainOptions
): LocaleDetector[] {
const entries: ChainEntry[] = [];
entries.push({
priority: BUILTIN_PRIORITY.querystring,
isPlugin: false,
id: "querystring",
detector: querystringDetector(),
});
if (
typeof options.userPreferenceEndpoint === "string" &&
options.userPreferenceEndpoint !== ""
) {
entries.push({
priority: BUILTIN_PRIORITY.userPreference,
isPlugin: false,
id: "userPreference",
detector: userPreferenceDetector({
endpoint: options.userPreferenceEndpoint,
}),
});
}
entries.push({
priority: BUILTIN_PRIORITY.cookie,
isPlugin: false,
id: "cookie",
detector: cookieDetector(),
});
entries.push({
priority: BUILTIN_PRIORITY.localStorage,
isPlugin: false,
id: "localStorage",
detector: localStorageDetector(),
});
entries.push({
priority: BUILTIN_PRIORITY.htmlLang,
isPlugin: false,
id: "htmlLang",
detector: htmlLangDetector(),
});
entries.push({
priority: BUILTIN_PRIORITY.acceptLanguage,
isPlugin: false,
id: "acceptLanguage",
detector: acceptLanguageDetector(),
});
entries.push({
priority: BUILTIN_PRIORITY.navigator,
isPlugin: false,
id: "navigator",
detector: navigatorDetector(),
});
entries.push({
priority: BUILTIN_PRIORITY.cloudflareIpGeo,
isPlugin: false,
id: "cloudflareIpGeo",
detector: cloudflareIpGeoDetector(),
});
if (options.registry) {
for (const plugin of options.registry.listDetectors()) {
entries.push({
priority: plugin.priority,
isPlugin: true,
id: plugin.id,
detector: plugin.detect,
});
}
}
entries.sort(compareEntries);
const detectors = entries.map((e) => e.detector);
detectors.push(fallbackDetector(options.fallback));
return detectors;
}
function compareEntries(a: ChainEntry, b: ChainEntry): number {
if (a.priority !== b.priority) return a.priority - b.priority;
// Same priority: built-in beats plugin. Convert booleans to {0, 1}
// and subtract — `Number(true)` ⇒ 1 sinks plugins below built-ins
// without a ternary that JS sort may only invoke in one direction.
const pluginDelta = Number(a.isPlugin) - Number(b.isPlugin);
if (pluginDelta !== 0) return pluginDelta;
// Same priority + both plugins: lexicographic by id (plugin ids
// are unique within a chain so we never reach the `=== 0` case).
// Two built-ins at the same priority is impossible by construction.
return a.id < b.id ? -1 : 1;
}

View File

@@ -0,0 +1,21 @@
import type { ReactI18nRegistry } from "../../registry/registry.types.js";
/**
* Configuration accepted by `defaultDetectorChain`.
*
* - `fallback` is required because the chain MUST end with the
* unconditional fallback detector.
* - `userPreferenceEndpoint` is optional; when omitted the
* userPreference detector is skipped (8-entry chain instead of 9).
* - `registry` is optional; when supplied, every `DetectorPlugin`
* registered into it is spliced into the chain at its `priority`
* position. Built-in detectors keep their relative order; ties
* between a built-in and a plugin go to the built-in. Ties between
* two plugins are broken lexicographically by `id`. The fallback
* detector is always last regardless of plugin priority.
*/
export interface DefaultDetectorChainOptions {
readonly fallback: string;
readonly userPreferenceEndpoint?: string;
readonly registry?: ReactI18nRegistry;
}

View File

@@ -0,0 +1,2 @@
export { defaultDetectorChain } from "./default-chain.js";
export type { DefaultDetectorChainOptions } from "./default-chain.types.js";

View File

@@ -0,0 +1,67 @@
# fallbackDetector
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`fallbackDetector` is the unconditional terminal entry of every locale
detector chain. It always returns the configured fallback locale, so the
chain runner never resolves to `null` once the fallback detector has
been installed. The constructor rejects empty or whitespace-only
locales up-front so misconfigurations surface at build time rather than
at the first chain invocation.
The fallback detector is non-persisting — the chain runner never
invokes `cache(...)` on it (and the spec scenario "switcher selection
walks every persisting detector" explicitly excludes it from the
walk).
## Features
- Always returns the configured locale (never `null`)
- Constructor validates the input (throws for empty/whitespace/non-string)
- `environment: "both"` — eligible everywhere
- Non-persisting (no `cache` method)
## Usage: Basic Example
```ts
import { fallbackDetector } from "@suaveplan/react-i18n/detector/fallback/fallback";
import { createDetectorChain } from "@suaveplan/i18n/detector";
const chain = createDetectorChain([fallbackDetector("en-GB")]);
```
## Usage: Advanced Example
```ts
import { fallbackDetector } from "@suaveplan/react-i18n/detector/fallback/fallback";
import { defaultDetectorChain } from "@suaveplan/react-i18n/detector/default-chain/default-chain";
// `defaultDetectorChain` already installs the fallback as the last
// entry; for ad-hoc chains assemble the same shape manually.
const chain = [...defaultDetectorChain({ fallback: "en-GB" }).slice(0, -1), fallbackDetector("ja-JP")];
```
## API Reference
### `fallbackDetector(locale: string): LocaleDetector`
Returns a `LocaleDetector` with `name: "fallback"` and
`environment: "both"`. Throws `Error("fallbackDetector requires a
non-empty locale string")` when the supplied locale is empty,
whitespace-only, or not a string.
## Implementation Notes
- The validation runs once, at construction time, so the per-detect
hot path is allocation-free.
- The detector trims the supplied locale so consumers can safely pass
values from configuration files where surrounding whitespace is
common.
- The detector exists as a separate first-class detector (rather than
a hard-coded property on the chain runner) so consumers can build
custom chains that omit the fallback step entirely — useful for
diagnostic UIs that want the chain to surface an explicit
"unresolved" state rather than fall back silently.

View File

@@ -0,0 +1,50 @@
import type { DetectorContext } from "@suaveplan/i18n-detector";
import { describe, expect, it } from "@suaveplan/testing/runner";
import { fallbackDetector } from "./fallback.js";
const SUPPORTED = ["en-GB", "fr-FR"] as const;
const ctx: DetectorContext = {
supportedLocales: SUPPORTED,
fallbackLocale: "en-GB",
};
describe("fallbackDetector", () => {
it("exposes name and environment per spec", { timeout: 5000 }, () => {
const detector = fallbackDetector("en-GB");
expect(detector.name).toBe("fallback");
expect(detector.environment).toBe("both");
});
it("always returns the configured locale", { timeout: 5000 }, () => {
expect(fallbackDetector("en-GB").detect?.(ctx)).toBe("en-GB");
expect(fallbackDetector("fr-FR").detect?.(ctx)).toBe("fr-FR");
});
it("trims whitespace from the configured locale", { timeout: 5000 }, () => {
expect(fallbackDetector(" en-GB ").detect?.(ctx)).toBe("en-GB");
});
it("throws on empty locale", { timeout: 5000 }, () => {
expect(() => fallbackDetector("")).toThrow(
"fallbackDetector requires a non-empty locale string"
);
});
it("throws on whitespace-only locale", { timeout: 5000 }, () => {
expect(() => fallbackDetector(" ")).toThrow();
});
it("throws when locale is not a string", { timeout: 5000 }, () => {
expect(() =>
fallbackDetector(undefined as unknown as string)
).toThrow();
expect(() => fallbackDetector(123 as unknown as string)).toThrow();
});
it("does not implement cache", { timeout: 5000 }, () => {
const detector = fallbackDetector("en-GB");
expect(detector.cache).toBeUndefined();
expect(detector.detectAsync).toBeUndefined();
});
});

View File

@@ -0,0 +1,24 @@
import type { LocaleDetector } from "@suaveplan/i18n-detector";
/**
* Build a `LocaleDetector` that always returns the configured locale.
*
* The fallback detector MUST appear last in any chain — it never
* returns `null`, so any detector after it would never run. The
* constructor validates the supplied locale up-front (rejects
* empty/whitespace strings) so a misconfiguration surfaces at build
* time rather than at the first chain run.
*/
export function fallbackDetector(locale: string): LocaleDetector {
if (typeof locale !== "string" || locale.trim() === "") {
throw new Error("fallbackDetector requires a non-empty locale string");
}
const value = locale.trim();
return {
name: "fallback",
environment: "both",
detect() {
return value;
},
};
}

View File

@@ -0,0 +1 @@
export { fallbackDetector } from "./fallback.js";

View File

@@ -0,0 +1,67 @@
# htmlLangDetector
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`htmlLangDetector` is a `LocaleDetector` that reads the active locale
from the document root's `lang` attribute. The common use case is a
static HTML shell that already declares `<html lang="…">` (typically
rendered by the SSR layer for crawlers and accessibility) — this
detector lets the React surface honour the shell's declaration without
coupling to a specific framework.
The detector is `environment: "browser"` so it never runs in SSR. It
is non-persisting — the chain runner does not invoke a `cache(...)` on
this detector when a switcher selection is made.
## Features
- Reads `document.documentElement.lang`
- Trims surrounding whitespace
- Returns `null` for empty / missing / non-string values
- Honours `ctx.document` over `globalThis.document` when the chain
runner injects a synthetic document
- `environment: "browser"`
## Usage: Basic Example
```ts
import { htmlLangDetector } from "@suaveplan/react-i18n/detector/html-lang/html-lang";
import { createDetectorChain } from "@suaveplan/i18n/detector";
const chain = createDetectorChain([htmlLangDetector()]);
```
## Usage: Advanced Example
```ts
import { htmlLangDetector } from "@suaveplan/react-i18n/detector/html-lang/html-lang";
import { defaultDetectorChain } from "@suaveplan/react-i18n/detector/default-chain/default-chain";
// Replace the default chain's `htmlLang` slot with a no-op for an
// embedded widget that does not control the host page's <html lang>.
const chain = defaultDetectorChain({ fallback: "en-GB" }).filter(
(d) => d.name !== "htmlLang"
);
```
## API Reference
### `htmlLangDetector(): LocaleDetector`
Returns a `LocaleDetector` with `name: "htmlLang"` and
`environment: "browser"`. Only `detect(ctx)` is implemented; the
detector does not persist.
## Implementation Notes
- The detector reads `documentElement.lang` (the `<html>` element)
rather than walking child elements — the convention is that the
document-level lang attribute reflects the active site language,
while child-level `lang` attributes mark exceptions.
- The trim step protects against `<html lang=" en-GB ">` markup
produced by templating engines that pad attribute values.
- An empty or missing `lang` attribute yields `null` so the chain
falls through to the next detector cleanly.

View File

@@ -0,0 +1,117 @@
import type { DetectorContext } from "@suaveplan/i18n-detector";
import { describe, expect, it } from "@suaveplan/testing/runner";
import { htmlLangDetector } from "./html-lang.js";
const SUPPORTED = ["en-GB", "fr-FR", "de-DE"] as const;
function browserContext(): DetectorContext {
return {
supportedLocales: SUPPORTED,
fallbackLocale: "en-GB",
};
}
function withDocumentLang<T>(lang: unknown, fn: () => T): T {
const original = (globalThis as { document?: unknown }).document;
Object.defineProperty(globalThis, "document", {
configurable: true,
value: { documentElement: { lang } },
});
try {
return fn();
} finally {
Object.defineProperty(globalThis, "document", {
configurable: true,
value: original,
});
}
}
describe("htmlLangDetector", () => {
it("exposes name and environment per spec", { timeout: 5000 }, () => {
const detector = htmlLangDetector();
expect(detector.name).toBe("htmlLang");
expect(detector.environment).toBe("browser");
});
it("reads document.documentElement.lang", { timeout: 5000 }, () => {
const detector = htmlLangDetector();
withDocumentLang("fr-FR", () => {
expect(detector.detect?.(browserContext())).toBe("fr-FR");
});
});
it("trims surrounding whitespace", { timeout: 5000 }, () => {
const detector = htmlLangDetector();
withDocumentLang(" de-DE ", () => {
expect(detector.detect?.(browserContext())).toBe("de-DE");
});
});
it("returns null for empty lang attribute", { timeout: 5000 }, () => {
const detector = htmlLangDetector();
withDocumentLang("", () => {
expect(detector.detect?.(browserContext())).toBeNull();
});
});
it("returns null when documentElement is missing", {
timeout: 5000,
}, () => {
const detector = htmlLangDetector();
const original = (globalThis as { document?: unknown }).document;
Object.defineProperty(globalThis, "document", {
configurable: true,
value: { documentElement: null },
});
try {
expect(detector.detect?.(browserContext())).toBeNull();
} finally {
Object.defineProperty(globalThis, "document", {
configurable: true,
value: original,
});
}
});
it("returns null when lang is not a string", { timeout: 5000 }, () => {
const detector = htmlLangDetector();
withDocumentLang(12345, () => {
expect(detector.detect?.(browserContext())).toBeNull();
});
});
it("returns null when no document is available", { timeout: 5000 }, () => {
const detector = htmlLangDetector();
const original = (globalThis as { document?: unknown }).document;
Object.defineProperty(globalThis, "document", {
configurable: true,
value: undefined,
});
try {
expect(detector.detect?.(browserContext())).toBeNull();
} finally {
Object.defineProperty(globalThis, "document", {
configurable: true,
value: original,
});
}
});
it("uses ctx.document over globalThis.document when supplied", {
timeout: 5000,
}, () => {
const detector = htmlLangDetector();
withDocumentLang("en-GB", () => {
const injected = {
documentElement: { lang: "fr-FR" },
} as unknown as Document;
expect(
detector.detect?.({
...browserContext(),
document: injected,
})
).toBe("fr-FR");
});
});
});

View File

@@ -0,0 +1,37 @@
import type { DetectorContext, LocaleDetector } from "@suaveplan/i18n-detector";
/**
* Build a `LocaleDetector` that reads the active locale from
* `document.documentElement.lang`.
*
* The detector is `environment: "browser"` so it never runs in SSR. The
* common use case is a static HTML shell that already declares
* `<html lang="…">` (typically rendered by the SSR layer for crawlers
* and accessibility) — this detector lets the React surface honour the
* shell's declaration without coupling to a specific framework.
*/
export function htmlLangDetector(): LocaleDetector {
return {
name: "htmlLang",
environment: "browser",
detect(ctx) {
const doc = readDocument(ctx);
if (doc === null) return null;
const root = doc.documentElement;
if (!root) return null;
const lang = root.lang;
if (typeof lang !== "string") return null;
const trimmed = lang.trim();
return trimmed === "" ? null : trimmed;
},
};
}
function readDocument(ctx: DetectorContext): Document | null {
if (ctx.document) return ctx.document;
const candidate = (globalThis as { document?: unknown }).document;
if (candidate && typeof candidate === "object") {
return candidate as Document;
}
return null;
}

View File

@@ -0,0 +1 @@
export { htmlLangDetector } from "./html-lang.js";

View File

@@ -0,0 +1,29 @@
export {
acceptLanguageDetector,
parseAcceptLanguage,
} from "./accept-language/accept-language.js";
export type { AcceptLanguageEntry } from "./accept-language/accept-language.types.js";
export { cloudflareIpGeoDetector } from "./cloudflare-ip-geo/cloudflare-ip-geo.js";
export type { CloudflareIpGeoDetectorOptions } from "./cloudflare-ip-geo/cloudflare-ip-geo.types.js";
export { cookieDetector } from "./cookie/cookie.js";
export type {
CookieDetectorOptions,
CookieSameSite,
} from "./cookie/cookie.types.js";
export { defaultDetectorChain } from "./default-chain/default-chain.js";
export type { DefaultDetectorChainOptions } from "./default-chain/default-chain.types.js";
export { fallbackDetector } from "./fallback/fallback.js";
export { htmlLangDetector } from "./html-lang/html-lang.js";
export { localStorageDetector } from "./local-storage/local-storage.js";
export type {
LocalStorageDetectorOptions,
LocalStorageLike,
} from "./local-storage/local-storage.types.js";
export { navigatorDetector } from "./navigator/navigator.js";
export { querystringDetector } from "./querystring/querystring.js";
export type { QuerystringDetectorOptions } from "./querystring/querystring.types.js";
export { userPreferenceDetector } from "./user-preference/user-preference.js";
export type {
UserPreferenceDetectorOptions,
UserPreferenceResponseBody,
} from "./user-preference/user-preference.types.js";

View File

@@ -0,0 +1,5 @@
export { localStorageDetector } from "./local-storage.js";
export type {
LocalStorageDetectorOptions,
LocalStorageLike,
} from "./local-storage.types.js";

View File

@@ -0,0 +1,80 @@
# localStorageDetector
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`localStorageDetector` reads and persists the active locale via a
`Storage`-shaped backing store, defaulting to `globalThis.localStorage`.
It is the second persistence mechanism after `cookieDetector` in the
default chain so that visitors who clear cookies (but not site data) keep
their language preference.
The detector is `environment: "browser"` so the chain runner skips it
during SSR and never accesses `localStorage` from a Node process. Any
access failure (private mode, disabled storage, quota exhaustion) is
caught and surfaced as `null` / a no-op write.
## Features
- Configurable storage key (default `"suaveplan-locale"`)
- Injectable `Storage`-shaped adapter for tests and host integrations
- Defensive `try/catch` around every storage call (private mode safe)
- `environment: "browser"`
## Usage: Basic Example
```ts
import { localStorageDetector } from "@suaveplan/react-i18n/detector/local-storage/local-storage";
import { createDetectorChain } from "@suaveplan/i18n/detector";
const chain = createDetectorChain([localStorageDetector()]);
```
## Usage: Advanced Example
```ts
import { localStorageDetector } from "@suaveplan/react-i18n/detector/local-storage/local-storage";
// Use sessionStorage instead of localStorage so the locale resets when
// the tab closes — useful for shared kiosk-style devices.
const detector = localStorageDetector({
key: "kiosk-locale",
storage: globalThis.sessionStorage,
});
```
## API Reference
### `localStorageDetector(options?: LocalStorageDetectorOptions): LocaleDetector`
Returns a `LocaleDetector` with `name: "localStorage"` and
`environment: "browser"`. Both `detect` and `cache` are implemented.
### `LocalStorageDetectorOptions`
| Field | Type | Default | Description |
| ------- | ------------------ | ---------------------- | ---------------------------------------- |
| key | `string` | `"suaveplan-locale"` | Storage key the detector reads/writes |
| storage | `LocalStorageLike` | `globalThis.localStorage` | Optional injected storage adapter |
### `LocalStorageLike`
```ts
interface LocalStorageLike {
getItem(key: string): string | null;
setItem(key: string, value: string): void;
removeItem(key: string): void;
}
```
## Implementation Notes
- The `globalThis.localStorage` access is wrapped in a try/catch
because a hardened browser may throw on the property _getter_ itself
(Safari private mode used to do this).
- The detector treats an empty stored value the same as a missing key
— both yield `null` so the chain advances.
- `cache(...)` swallows `setItem` failures so quota-exhausted storage
cannot break a language switch from the React tree's perspective.

View File

@@ -0,0 +1,169 @@
import type { DetectorContext } from "@suaveplan/i18n-detector";
import { describe, expect, it } from "@suaveplan/testing/runner";
import { localStorageDetector } from "./local-storage.js";
import type { LocalStorageLike } from "./local-storage.types.js";
const SUPPORTED = ["en-GB", "fr-FR", "de-DE"] as const;
function browserContext(): DetectorContext {
return {
supportedLocales: SUPPORTED,
fallbackLocale: "en-GB",
};
}
function makeStorage(initial: Record<string, string> = {}): LocalStorageLike {
const store = new Map<string, string>(Object.entries(initial));
return {
getItem: (k) => (store.has(k) ? (store.get(k) as string) : null),
setItem: (k, v) => {
store.set(k, v);
},
removeItem: (k) => {
store.delete(k);
},
};
}
function withGlobalLocalStorage<T>(storage: unknown, fn: () => T): T {
const original = (globalThis as { localStorage?: unknown }).localStorage;
Object.defineProperty(globalThis, "localStorage", {
configurable: true,
value: storage,
});
try {
return fn();
} finally {
Object.defineProperty(globalThis, "localStorage", {
configurable: true,
value: original,
});
}
}
describe("localStorageDetector", () => {
it("exposes name and environment per spec", { timeout: 5000 }, () => {
const detector = localStorageDetector();
expect(detector.name).toBe("localStorage");
expect(detector.environment).toBe("browser");
});
it("reads value from injected storage with default key", {
timeout: 5000,
}, () => {
const detector = localStorageDetector({
storage: makeStorage({ "suaveplan-locale": "fr-FR" }),
});
expect(detector.detect?.(browserContext())).toBe("fr-FR");
});
it("honours custom key", { timeout: 5000 }, () => {
const detector = localStorageDetector({
key: "lang",
storage: makeStorage({ lang: "de-DE" }),
});
expect(detector.detect?.(browserContext())).toBe("de-DE");
});
it("returns null when stored value is empty", { timeout: 5000 }, () => {
const detector = localStorageDetector({
storage: makeStorage({ "suaveplan-locale": "" }),
});
expect(detector.detect?.(browserContext())).toBeNull();
});
it("returns null when key is missing", { timeout: 5000 }, () => {
const detector = localStorageDetector({
storage: makeStorage({ other: "x" }),
});
expect(detector.detect?.(browserContext())).toBeNull();
});
it("returns null when storage.getItem throws", { timeout: 5000 }, () => {
const throwing: LocalStorageLike = {
getItem: () => {
throw new Error("blocked");
},
setItem: () => undefined,
removeItem: () => undefined,
};
const detector = localStorageDetector({ storage: throwing });
expect(detector.detect?.(browserContext())).toBeNull();
});
it("returns null when no storage is available", { timeout: 5000 }, () => {
withGlobalLocalStorage(undefined, () => {
const detector = localStorageDetector();
expect(detector.detect?.(browserContext())).toBeNull();
});
});
it("returns null when global access throws", { timeout: 5000 }, () => {
const original = (globalThis as { localStorage?: unknown })
.localStorage;
Object.defineProperty(globalThis, "localStorage", {
configurable: true,
get: () => {
throw new Error("denied");
},
});
try {
const detector = localStorageDetector();
expect(detector.detect?.(browserContext())).toBeNull();
} finally {
Object.defineProperty(globalThis, "localStorage", {
configurable: true,
value: original,
});
}
});
it("falls back to globalThis.localStorage when no storage supplied", {
timeout: 5000,
}, () => {
withGlobalLocalStorage(
makeStorage({ "suaveplan-locale": "de-DE" }),
() => {
const detector = localStorageDetector();
expect(detector.detect?.(browserContext())).toBe("de-DE");
}
);
});
it("ignores non-storage-like global candidate", { timeout: 5000 }, () => {
withGlobalLocalStorage({ getItem: 1, setItem: 2 }, () => {
const detector = localStorageDetector();
expect(detector.detect?.(browserContext())).toBeNull();
});
});
it("persists value via cache", { timeout: 5000 }, () => {
const storage = makeStorage();
const detector = localStorageDetector({ storage });
detector.cache?.("fr-FR", browserContext());
expect(storage.getItem("suaveplan-locale")).toBe("fr-FR");
});
it("cache is a no-op when storage is unavailable", {
timeout: 5000,
}, () => {
withGlobalLocalStorage(undefined, () => {
const detector = localStorageDetector();
expect(() =>
detector.cache?.("fr-FR", browserContext())
).not.toThrow();
});
});
it("cache swallows setItem failures", { timeout: 5000 }, () => {
const throwing: LocalStorageLike = {
getItem: () => null,
setItem: () => {
throw new Error("quota");
},
removeItem: () => undefined,
};
const detector = localStorageDetector({ storage: throwing });
expect(() => detector.cache?.("ar-AE", browserContext())).not.toThrow();
});
});

View File

@@ -0,0 +1,69 @@
import type { LocaleDetector } from "@suaveplan/i18n-detector";
import type {
LocalStorageDetectorOptions,
LocalStorageLike,
} from "./local-storage.types.js";
const DEFAULT_KEY = "suaveplan-locale";
/**
* Build a `LocaleDetector` that reads/writes the active locale from a
* `Storage`-shaped backing store (default: `globalThis.localStorage`).
*
* The detector is `environment: "browser"` so the chain runner
* automatically skips it during SSR. Any access failure (private mode,
* disabled storage, exceeded quota) is caught and surfaced as `null` /
* a silent no-op write — the detector never throws into the React tree.
*/
export function localStorageDetector(
options: LocalStorageDetectorOptions = {}
): LocaleDetector {
const key = options.key ?? DEFAULT_KEY;
const explicitStorage = options.storage;
return {
name: "localStorage",
environment: "browser",
detect() {
const store = resolveStorage(explicitStorage);
if (store === null) return null;
try {
const value = store.getItem(key);
if (typeof value !== "string" || value === "") return null;
return value;
} catch {
return null;
}
},
cache(locale) {
const store = resolveStorage(explicitStorage);
if (store === null) return;
try {
store.setItem(key, locale);
} catch {
// Persistence is best-effort — quota exhaustion or
// private-mode write rejection should not propagate.
}
},
};
}
function resolveStorage(
explicit: LocalStorageLike | undefined
): LocalStorageLike | null {
if (explicit) return explicit;
try {
const candidate = (globalThis as { localStorage?: unknown })
.localStorage;
if (
candidate &&
typeof (candidate as LocalStorageLike).getItem === "function" &&
typeof (candidate as LocalStorageLike).setItem === "function"
) {
return candidate as LocalStorageLike;
}
return null;
} catch {
return null;
}
}

View File

@@ -0,0 +1,26 @@
/**
* Storage shape required by `localStorageDetector`. Mirrors the relevant
* subset of the DOM `Storage` interface so any conforming object (an
* in-memory test fake, a session-storage handle, …) is acceptable.
*/
export interface LocalStorageLike {
getItem(key: string): string | null;
setItem(key: string, value: string): void;
removeItem(key: string): void;
}
/**
* Configuration accepted by `localStorageDetector`. Both fields are
* optional; defaults pull `localStorage` from `globalThis` and use the
* stable `"suaveplan-locale"` storage key.
*/
export interface LocalStorageDetectorOptions {
/** Storage key under which the locale is read/written. */
readonly key?: string;
/**
* Storage adapter. Defaults to `globalThis.localStorage`. Tests
* inject a `Map`-backed fake to exercise the persisting path
* without booting an actual `localStorage` polyfill.
*/
readonly storage?: LocalStorageLike;
}

View File

@@ -0,0 +1 @@
export { navigatorDetector } from "./navigator.js";

View File

@@ -0,0 +1,72 @@
# navigatorDetector
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`navigatorDetector` is a browser-only `LocaleDetector` that reads the
active locale from `navigator.languages` (preferred) or
`navigator.language` (fallback). It is the canonical browser-side
detector when no per-user preference, cookie, or local-storage value
exists yet — every modern browser populates these globals from the OS
language settings.
The detector is non-persisting: the chain runner does not invoke
`cache(...)` on it. Returning `null` (no navigator, empty languages)
lets the chain advance to the next detector.
## Features
- Iterates `navigator.languages` in order, returning the first
non-empty entry
- Falls back to `navigator.language` when `languages` is missing /
empty
- Returns `null` when no navigator global is present (defensive)
- `environment: "browser"`
## Usage: Basic Example
```ts
import { navigatorDetector } from "@suaveplan/react-i18n/detector/navigator/navigator";
import { createDetectorChain } from "@suaveplan/i18n/detector";
const chain = createDetectorChain([navigatorDetector()]);
```
## Usage: Advanced Example
```ts
import { navigatorDetector } from "@suaveplan/react-i18n/detector/navigator/navigator";
import { defaultDetectorChain } from "@suaveplan/react-i18n/detector/default-chain/default-chain";
// Use the navigator detector as the *only* browser-side fallback by
// stripping every other browser detector from the default chain.
const chain = defaultDetectorChain({ fallback: "en-GB" }).filter(
(d) =>
d.environment === "server" ||
d.environment === "both" ||
d.name === "navigator" ||
d.name === "fallback"
);
```
## API Reference
### `navigatorDetector(): LocaleDetector`
Returns a `LocaleDetector` with `name: "navigator"` and
`environment: "browser"`. Only `detect(ctx)` is implemented.
## Implementation Notes
- The detector trims candidate values before returning so a stray
whitespace-only entry in `navigator.languages` does not falsely
win.
- The `globalThis.navigator` access is structural — any object with
`languages: string[]` and/or `language: string` satisfies the
detector. This makes integration with non-DOM hosts (Node REPL,
Bun) straightforward via `globalThis.navigator = { language: "en" }`.
- The detector returns the _raw_ value (e.g. `"fr-FR"`) and lets the
chain runner negotiate against `ctx.supportedLocales` for BCP-47
fallback.

View File

@@ -0,0 +1,100 @@
import type { DetectorContext } from "@suaveplan/i18n-detector";
import { describe, expect, it } from "@suaveplan/testing/runner";
import { navigatorDetector } from "./navigator.js";
const SUPPORTED = ["en-GB", "fr-FR", "de-DE"] as const;
function browserContext(): DetectorContext {
return {
supportedLocales: SUPPORTED,
fallbackLocale: "en-GB",
};
}
function withNavigator<T>(value: unknown, fn: () => T): T {
const original = (globalThis as { navigator?: unknown }).navigator;
Object.defineProperty(globalThis, "navigator", {
configurable: true,
value,
});
try {
return fn();
} finally {
Object.defineProperty(globalThis, "navigator", {
configurable: true,
value: original,
});
}
}
describe("navigatorDetector", () => {
it("exposes name and environment per spec", { timeout: 5000 }, () => {
const detector = navigatorDetector();
expect(detector.name).toBe("navigator");
expect(detector.environment).toBe("browser");
});
it("returns first navigator.languages entry", { timeout: 5000 }, () => {
const detector = navigatorDetector();
withNavigator({ languages: ["fr-FR", "en-GB"] }, () => {
expect(detector.detect?.(browserContext())).toBe("fr-FR");
});
});
it("falls back to navigator.language when languages is empty", {
timeout: 5000,
}, () => {
const detector = navigatorDetector();
withNavigator({ languages: [], language: "de-DE" }, () => {
expect(detector.detect?.(browserContext())).toBe("de-DE");
});
});
it("falls back to navigator.language when languages is missing", {
timeout: 5000,
}, () => {
const detector = navigatorDetector();
withNavigator({ language: "fr-FR" }, () => {
expect(detector.detect?.(browserContext())).toBe("fr-FR");
});
});
it("skips empty entries in navigator.languages", { timeout: 5000 }, () => {
const detector = navigatorDetector();
withNavigator({ languages: ["", " ", "fr-FR"] }, () => {
expect(detector.detect?.(browserContext())).toBe("fr-FR");
});
});
it("returns null when both sources are empty", { timeout: 5000 }, () => {
const detector = navigatorDetector();
withNavigator({ languages: [], language: "" }, () => {
expect(detector.detect?.(browserContext())).toBeNull();
});
});
it("returns null when navigator absent", { timeout: 5000 }, () => {
const detector = navigatorDetector();
withNavigator(undefined, () => {
expect(detector.detect?.(browserContext())).toBeNull();
});
});
it("returns null when navigator is not an object", {
timeout: 5000,
}, () => {
const detector = navigatorDetector();
withNavigator("not-an-object", () => {
expect(detector.detect?.(browserContext())).toBeNull();
});
});
it("returns null when languages contains only whitespace and language is undefined", {
timeout: 5000,
}, () => {
const detector = navigatorDetector();
withNavigator({ languages: [" "] }, () => {
expect(detector.detect?.(browserContext())).toBeNull();
});
});
});

View File

@@ -0,0 +1,50 @@
import type { LocaleDetector } from "@suaveplan/i18n-detector";
/**
* Build a `LocaleDetector` that reads the active locale from the
* browser's `navigator.languages` (preferred) or `navigator.language`
* (fallback). Returns the first non-empty entry.
*
* The chain runner negotiates the returned value against
* `ctx.supportedLocales` so this detector contributes raw browser
* preference and lets the resolver perform BCP-47 fallback.
*/
export function navigatorDetector(): LocaleDetector {
return {
name: "navigator",
environment: "browser",
detect() {
const nav = readNavigator();
if (nav === null) return null;
const candidates = collectLanguages(nav);
for (const candidate of candidates) {
const value = candidate.trim();
if (value !== "") return value;
}
return null;
},
};
}
interface NavigatorLanguageLike {
readonly languages?: ReadonlyArray<string>;
readonly language?: string;
}
function readNavigator(): NavigatorLanguageLike | null {
const candidate = (globalThis as { navigator?: unknown }).navigator;
if (candidate && typeof candidate === "object") {
return candidate as NavigatorLanguageLike;
}
return null;
}
function collectLanguages(nav: NavigatorLanguageLike): ReadonlyArray<string> {
if (Array.isArray(nav.languages) && nav.languages.length > 0) {
return nav.languages;
}
if (typeof nav.language === "string" && nav.language !== "") {
return [nav.language];
}
return [];
}

View File

@@ -0,0 +1,2 @@
export { querystringDetector } from "./querystring.js";
export type { QuerystringDetectorOptions } from "./querystring.types.js";

View File

@@ -0,0 +1,88 @@
# querystringDetector
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`querystringDetector` is a `LocaleDetector` that reads the active locale
from a URL search parameter. It runs in both browser and server
environments, making it the canonical first link in the default detector
chain — explicit user intent expressed through `?lang=fr-FR` always
overrides every other source (cookie, localStorage, geo lookup, …) per
the spec scenario "querystring overrides cookie".
The detector is intentionally minimal: it never persists a value (so
sharing a `?lang=…` link does not pollute the user's stored preference),
it does not throw when the param is absent, and it never reaches into
window-level globals when a server-side `request` context is supplied.
## Features
- Configurable parameter name via `opts.paramName` (default `"lang"`)
- Server input via `ctx.request.url.searchParams`
- Browser input via `window.location.search`
- Returns `null` for missing params, empty values, or environments
where neither input source exists
- `environment: "both"` — eligible on every chain run
## Usage: Basic Example
```ts
import { querystringDetector } from "@suaveplan/react-i18n/detector/querystring/querystring";
import { createDetectorChain } from "@suaveplan/i18n/detector";
const chain = createDetectorChain([querystringDetector()]);
const result = await chain.detect({
request: {
headers: new Headers(),
url: new URL("https://example.com/?lang=fr-FR"),
},
responseHeaders: new Headers(),
supportedLocales: ["en-GB", "fr-FR"],
fallbackLocale: "en-GB",
});
// → { locale: "fr-FR", detector: "querystring", rawValue: "fr-FR" }
```
## Usage: Advanced Example
```ts
import { querystringDetector } from "@suaveplan/react-i18n/detector/querystring/querystring";
import { defaultDetectorChain } from "@suaveplan/react-i18n/detector/default-chain/default-chain";
// Replace the default `?lang=` entry with `?locale=` for a host app
// that already uses that parameter, while keeping every other detector.
const chain = defaultDetectorChain({ fallback: "en-GB" }).map((d) =>
d.name === "querystring" ? querystringDetector({ paramName: "locale" }) : d
);
```
## API Reference
### `querystringDetector(options?: QuerystringDetectorOptions): LocaleDetector`
| Param | Type | Default | Description |
| ------- | ----------------------------- | ------- | -------------------------------------------- |
| options | `QuerystringDetectorOptions?` | `{}` | Optional `{ paramName?: string }` overrides. |
Returns a `LocaleDetector` with `name: "querystring"` and
`environment: "both"`. The detector exposes only `detect(ctx)`; it does
not implement `cache(...)` (the URL is never written).
### `QuerystringDetectorOptions`
| Field | Type | Default | Description |
| --------- | -------- | -------- | ---------------------------------------- |
| paramName | `string` | `"lang"` | Search-parameter name the detector reads |
## Implementation Notes
- The detector strictly prefers `ctx.request` over `window.location` so
SSR contexts that happen to leak a `globalThis.location` (e.g. test
harnesses) do not override the request the application is rendering.
- An empty string value (e.g. `?lang=`) is treated as "no preference"
per the chain contract — the detector falls through, never wins.
- The implementation is allocation-light (one `URLSearchParams` on the
browser path) and synchronous so the chain runner picks it up in the
initial sync pass before any async detector is dispatched.

View File

@@ -0,0 +1,138 @@
import type { DetectorContext } from "@suaveplan/i18n-detector";
import { describe, expect, it } from "@suaveplan/testing/runner";
import { querystringDetector } from "./querystring.js";
const SUPPORTED = ["en-GB", "fr-FR", "cy-GB"] as const;
function serverContext(url: string): DetectorContext {
return {
request: { headers: new Headers(), url: new URL(url) },
responseHeaders: new Headers(),
supportedLocales: SUPPORTED,
fallbackLocale: "en-GB",
};
}
function browserContext(): DetectorContext {
return {
supportedLocales: SUPPORTED,
fallbackLocale: "en-GB",
};
}
function withWindowLocation<T>(search: string, fn: () => T): T {
const original = (globalThis as { location?: { search?: string } })
.location;
Object.defineProperty(globalThis, "location", {
configurable: true,
value: { search },
});
try {
return fn();
} finally {
Object.defineProperty(globalThis, "location", {
configurable: true,
value: original,
});
}
}
describe("querystringDetector", () => {
it("reads ?lang from server-side ctx.request.url with default param", {
timeout: 5000,
}, () => {
const detector = querystringDetector();
const result = detector.detect?.(
serverContext("https://example.com/?lang=cy-GB")
);
expect(result).toBe("cy-GB");
expect(detector.name).toBe("querystring");
expect(detector.environment).toBe("both");
});
it("honours opts.paramName override", { timeout: 5000 }, () => {
const detector = querystringDetector({ paramName: "locale" });
const result = detector.detect?.(
serverContext("https://example.com/?locale=fr-FR")
);
expect(result).toBe("fr-FR");
});
it("returns null when neither request nor window has the param", {
timeout: 5000,
}, () => {
const detector = querystringDetector();
const result = detector.detect?.(serverContext("https://example.com/"));
expect(result).toBeNull();
});
it("reads from window.location.search in the browser", {
timeout: 5000,
}, () => {
const detector = querystringDetector();
withWindowLocation("?lang=fr-FR", () => {
const result = detector.detect?.(browserContext());
expect(result).toBe("fr-FR");
});
});
it("returns null when window has the param but value is empty", {
timeout: 5000,
}, () => {
const detector = querystringDetector();
withWindowLocation("?lang=", () => {
const result = detector.detect?.(browserContext());
expect(result).toBeNull();
});
});
it("returns null when window.location is missing entirely", {
timeout: 5000,
}, () => {
const detector = querystringDetector();
const original = (globalThis as { location?: unknown }).location;
Object.defineProperty(globalThis, "location", {
configurable: true,
value: undefined,
});
try {
const result = detector.detect?.(browserContext());
expect(result).toBeNull();
} finally {
Object.defineProperty(globalThis, "location", {
configurable: true,
value: original,
});
}
});
it("returns null when ctx.request.url has empty param value", {
timeout: 5000,
}, () => {
const detector = querystringDetector();
const result = detector.detect?.(
serverContext("https://example.com/?lang=")
);
expect(result).toBeNull();
});
it("returns null when window.location.search is not a string", {
timeout: 5000,
}, () => {
const detector = querystringDetector();
const original = (globalThis as { location?: unknown }).location;
Object.defineProperty(globalThis, "location", {
configurable: true,
value: { search: 1234 as unknown as string },
});
try {
const result = detector.detect?.(browserContext());
expect(result).toBeNull();
} finally {
Object.defineProperty(globalThis, "location", {
configurable: true,
value: original,
});
}
});
});

View File

@@ -0,0 +1,42 @@
import type { LocaleDetector } from "@suaveplan/i18n-detector";
import type { QuerystringDetectorOptions } from "./querystring.types.js";
const DEFAULT_PARAM_NAME = "lang";
/**
* Build a `LocaleDetector` that reads the active locale from a URL search
* parameter (default `?lang=...`).
*
* Runtime resolution:
* - When `ctx.request.url` is provided (server / SSR) the detector reads
* `ctx.request.url.searchParams.get(paramName)`.
* - Otherwise the detector reads `window.location.search` via a fresh
* `URLSearchParams` instance.
*
* Returns `null` for missing params, empty values, or environments where
* neither input source is available (Node without an injected request).
*/
export function querystringDetector(
options: QuerystringDetectorOptions = {}
): LocaleDetector {
const paramName = options.paramName ?? DEFAULT_PARAM_NAME;
return {
name: "querystring",
environment: "both",
detect(ctx) {
const fromRequest = ctx.request?.url.searchParams.get(paramName);
if (typeof fromRequest === "string" && fromRequest !== "") {
return fromRequest;
}
const win = (globalThis as { location?: { search?: string } })
.location;
if (!win || typeof win.search !== "string") return null;
const params = new URLSearchParams(win.search);
const fromWindow = params.get(paramName);
if (typeof fromWindow === "string" && fromWindow !== "") {
return fromWindow;
}
return null;
},
};
}

View File

@@ -0,0 +1,13 @@
/**
* Configuration accepted by `querystringDetector`. Every field is
* optional; sensible defaults are documented on each property.
*/
export interface QuerystringDetectorOptions {
/**
* Name of the URL search parameter the detector reads. Defaults to
* `"lang"`. Spec scenario "querystring overrides cookie" exercises
* the default; consumers SHOULD only override when integrating
* with a host application that already uses a different name.
*/
readonly paramName?: string;
}

View File

@@ -0,0 +1,5 @@
export { userPreferenceDetector } from "./user-preference.js";
export type {
UserPreferenceDetectorOptions,
UserPreferenceResponseBody,
} from "./user-preference.types.js";

View File

@@ -0,0 +1,98 @@
# userPreferenceDetector
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`userPreferenceDetector` is the only async, persisting detector in the
default chain. It calls a backend endpoint to read the user's stored
locale preference (GET) and writes a chosen locale back to that same
endpoint (POST) on `cache(...)`. This is the detector that ensures a
locale picked via `<LanguageSwitcher>` survives across devices, sessions,
and even cookie deletion as long as the session itself is still
authenticated.
The detector is environment `'both'` so it runs server-side during SSR
(threading the inbound session cookie via `extractAuth`) and browser-side
on the client. Network failures are caught and logged at warn level via
`@suaveplan/logger`; the detector returns `null` so the chain advances.
No exception ever escapes into the React tree.
## Features
- GET-fetched read on `detectAsync`
- POST-fetched write on `cache(locale, ctx)`
- Required `endpoint`; optional `extractAuth(ctx)`, `cacheKey(ctx)`,
injectable `fetch`
- Server-side auth threading via `extractAuth(ctx)` — typically used to
forward the inbound `cookie` header onto the upstream call
- Best-effort cache writes (a failed POST does not throw)
- Warn-level structured logs on every failure path
## Usage: Basic Example
```ts
import { userPreferenceDetector } from "@suaveplan/react-i18n/detector/user-preference/user-preference";
import { createDetectorChain } from "@suaveplan/i18n/detector";
const chain = createDetectorChain([
userPreferenceDetector({ endpoint: "/api/me/locale" }),
]);
```
## Usage: Advanced Example
```ts
import { userPreferenceDetector } from "@suaveplan/react-i18n/detector/user-preference/user-preference";
// SSR usage — thread the inbound session cookie onto the upstream call
// so the backend endpoint can read the same authenticated session.
export const detector = userPreferenceDetector({
endpoint: "https://api.example.com/me/locale",
extractAuth: (ctx) => ({
cookie: ctx.request?.headers.get("cookie") ?? "",
}),
cacheKey: (ctx) => ctx.request?.headers.get("authorization") ?? "anon",
});
```
## API Reference
### `userPreferenceDetector(options: UserPreferenceDetectorOptions): LocaleDetector`
Returns a `LocaleDetector` with `name: "userPreference"` and
`environment: "both"` exposing both `detectAsync(ctx)` and
`cache(locale, ctx)`.
### `UserPreferenceDetectorOptions`
| Field | Type | Default | Description |
| ----------- | --------------------------------------------- | ------------------------ | ---------------------------------------------------- |
| endpoint | `string` (required) | — | URL fetched on detect (GET) and cache (POST) |
| extractAuth | `(ctx) => HeadersInit` | `() => ({})` | Returns headers threaded into both calls |
| cacheKey | `(ctx) => string` | — | Stable key consumers may use to dedupe across renders |
| fetch | `typeof fetch` | `globalThis.fetch` | Injectable fetch implementation |
### `UserPreferenceResponseBody`
```ts
interface UserPreferenceResponseBody {
readonly locale: string;
}
```
## Implementation Notes
- `cache(...)` is best-effort by design: a backend that is briefly
unavailable should not block a user's locale switch. A subsequent
detect call will surface the persisted value once the backend
recovers.
- `Content-Type: application/json` is set automatically on POST unless
the caller-supplied `extractAuth` returns its own `content-type`
header — this lets advanced consumers swap the wire format if their
endpoint uses a different shape.
- `signal` is forwarded from the `DetectorContext` so the detector
participates in the chain runner's cancellation semantics.
- The detector never throws — every error path is caught, logged, and
surfaced as `null` so the chain advances cleanly.

View File

@@ -0,0 +1,290 @@
import type { DetectorContext } from "@suaveplan/i18n-detector";
import { describe, expect, it } from "@suaveplan/testing/runner";
import { userPreferenceDetector } from "./user-preference.js";
const SUPPORTED = ["en-GB", "fr-FR", "de-DE", "ar-AE"] as const;
function serverContext(headers: HeadersInit = {}): DetectorContext {
return {
request: {
headers: new Headers(headers),
url: new URL("https://example.com/"),
},
responseHeaders: new Headers(),
supportedLocales: SUPPORTED,
fallbackLocale: "en-GB",
};
}
function browserContext(): DetectorContext {
return {
supportedLocales: SUPPORTED,
fallbackLocale: "en-GB",
};
}
interface FetchCall {
readonly url: string;
readonly init: RequestInit | undefined;
}
function recordingFetch(response: () => Response | Promise<Response>): {
fn: typeof fetch;
calls: FetchCall[];
} {
const calls: FetchCall[] = [];
const fn = (async (input: RequestInfo | URL, init?: RequestInit) => {
const url = typeof input === "string" ? input : input.toString();
calls.push({ url, init });
return await response();
}) as unknown as typeof fetch;
return { fn, calls };
}
describe("userPreferenceDetector", () => {
it("detect threads SSR auth via extractAuth(ctx) into the request headers", {
timeout: 5000,
}, async () => {
const { fn, calls } = recordingFetch(
() => new Response(JSON.stringify({ locale: "fr-FR" }))
);
const detector = userPreferenceDetector({
endpoint: "/api/me/locale",
extractAuth: (c) => ({
cookie: c.request?.headers.get("cookie") ?? "",
}),
fetch: fn,
});
const result = await detector.detectAsync?.(
serverContext({ cookie: "session=abc" })
);
expect(result).toBe("fr-FR");
expect(calls.length).toBe(1);
expect(calls[0]?.url).toBe("/api/me/locale");
expect(calls[0]?.init?.method).toBe("GET");
const headers = new Headers(calls[0]?.init?.headers);
expect(headers.get("cookie")).toBe("session=abc");
});
it("detect returns null on non-2xx response", {
timeout: 5000,
}, async () => {
const { fn } = recordingFetch(
() => new Response("nope", { status: 503 })
);
const detector = userPreferenceDetector({
endpoint: "/api/me/locale",
fetch: fn,
});
const result = await detector.detectAsync?.(browserContext());
expect(result).toBeNull();
});
it("detect returns null when fetch rejects", {
timeout: 5000,
}, async () => {
const fn = (async () => {
throw new Error("boom");
}) as unknown as typeof fetch;
const detector = userPreferenceDetector({
endpoint: "/api/me/locale",
fetch: fn,
});
const result = await detector.detectAsync?.(browserContext());
expect(result).toBeNull();
});
it("detect returns null when fetch rejects with a non-Error", {
timeout: 5000,
}, async () => {
const fn = (async () => {
const literal: unknown = "string-error";
throw literal;
}) as unknown as typeof fetch;
const detector = userPreferenceDetector({
endpoint: "/api/me/locale",
fetch: fn,
});
const result = await detector.detectAsync?.(browserContext());
expect(result).toBeNull();
});
it("detect returns null when body lacks a locale field", {
timeout: 5000,
}, async () => {
const { fn } = recordingFetch(() => new Response("{}"));
const detector = userPreferenceDetector({
endpoint: "/api/me/locale",
fetch: fn,
});
const result = await detector.detectAsync?.(browserContext());
expect(result).toBeNull();
});
it("detect returns null when body locale is empty string", {
timeout: 5000,
}, async () => {
const { fn } = recordingFetch(
() => new Response(JSON.stringify({ locale: "" }))
);
const detector = userPreferenceDetector({
endpoint: "/api/me/locale",
fetch: fn,
});
const result = await detector.detectAsync?.(browserContext());
expect(result).toBeNull();
});
it("cache POSTs JSON body to the endpoint with content-type header", {
timeout: 5000,
}, async () => {
const { fn, calls } = recordingFetch(
() => new Response("{}", { status: 204 })
);
const detector = userPreferenceDetector({
endpoint: "/api/me/locale",
fetch: fn,
});
await detector.cache?.("de-DE", browserContext());
expect(calls.length).toBe(1);
expect(calls[0]?.init?.method).toBe("POST");
const headers = new Headers(calls[0]?.init?.headers);
expect(headers.get("content-type")).toBe("application/json");
expect(calls[0]?.init?.body).toBe(JSON.stringify({ locale: "de-DE" }));
});
it("cache leaves non-2xx without throwing", { timeout: 5000 }, async () => {
const { fn } = recordingFetch(
() => new Response("err", { status: 500 })
);
const detector = userPreferenceDetector({
endpoint: "/api/me/locale",
fetch: fn,
});
await expect(
detector.cache?.("ar-AE", browserContext())
).resolves.toBeUndefined();
});
it("cache swallows fetch rejections", { timeout: 5000 }, async () => {
const fn = (async () => {
throw new Error("network down");
}) as unknown as typeof fetch;
const detector = userPreferenceDetector({
endpoint: "/api/me/locale",
fetch: fn,
});
await expect(
detector.cache?.("ar-AE", browserContext())
).resolves.toBeUndefined();
});
it("cache swallows non-Error rejections", { timeout: 5000 }, async () => {
const fn = (async () => {
const literal: unknown = "literal-failure";
throw literal;
}) as unknown as typeof fetch;
const detector = userPreferenceDetector({
endpoint: "/api/me/locale",
fetch: fn,
});
await expect(
detector.cache?.("ar-AE", browserContext())
).resolves.toBeUndefined();
});
it("cache preserves caller-supplied content-type when present", {
timeout: 5000,
}, async () => {
const { fn, calls } = recordingFetch(() => new Response(""));
const detector = userPreferenceDetector({
endpoint: "/api/me/locale",
extractAuth: () => ({ "content-type": "application/x-custom" }),
fetch: fn,
});
await detector.cache?.("de-DE", browserContext());
const headers = new Headers(calls[0]?.init?.headers);
expect(headers.get("content-type")).toBe("application/x-custom");
});
it("default fetch resolves to globalThis.fetch when not injected", {
timeout: 5000,
}, async () => {
const original = globalThis.fetch;
const calls: string[] = [];
globalThis.fetch = (async (input: RequestInfo | URL) => {
calls.push(typeof input === "string" ? input : input.toString());
return new Response(JSON.stringify({ locale: "ar-AE" }));
}) as typeof fetch;
try {
const detector = userPreferenceDetector({
endpoint: "/api/me/locale",
});
const result = await detector.detectAsync?.(browserContext());
expect(result).toBe("ar-AE");
expect(calls).toEqual(["/api/me/locale"]);
} finally {
globalThis.fetch = original;
}
});
it("exposes name and environment per spec", { timeout: 5000 }, () => {
const detector = userPreferenceDetector({ endpoint: "/x" });
expect(detector.name).toBe("userPreference");
expect(detector.environment).toBe("both");
});
it("detect threads ctx.signal into the request init", {
timeout: 5000,
}, async () => {
const calls: RequestInit[] = [];
const fn = (async (_input: RequestInfo | URL, init?: RequestInit) => {
calls.push(init ?? {});
return new Response(JSON.stringify({ locale: "en-GB" }));
}) as unknown as typeof fetch;
const detector = userPreferenceDetector({
endpoint: "/api/me/locale",
fetch: fn,
});
const controller = new AbortController();
await detector.detectAsync?.({
...browserContext(),
signal: controller.signal,
});
expect(calls[0]?.signal).toBe(controller.signal);
});
it("cache threads ctx.signal into the POST request init", {
timeout: 5000,
}, async () => {
const calls: RequestInit[] = [];
const fn = (async (_input: RequestInfo | URL, init?: RequestInit) => {
calls.push(init ?? {});
return new Response("", { status: 204 });
}) as unknown as typeof fetch;
const detector = userPreferenceDetector({
endpoint: "/api/me/locale",
fetch: fn,
});
const controller = new AbortController();
await detector.cache?.("fr-FR", {
...browserContext(),
signal: controller.signal,
});
expect(calls[0]?.signal).toBe(controller.signal);
});
it("extractAuth returning empty object still issues request", {
timeout: 5000,
}, async () => {
const { fn, calls } = recordingFetch(
() => new Response(JSON.stringify({ locale: "de-DE" }))
);
const detector = userPreferenceDetector({
endpoint: "/api/me/locale",
fetch: fn,
});
await detector.detectAsync?.(browserContext());
expect(calls.length).toBe(1);
});
});

View File

@@ -0,0 +1,105 @@
import type { DetectorContext, LocaleDetector } from "@suaveplan/i18n-detector";
import { ConsoleLogger } from "@suaveplan/logger";
import type {
UserPreferenceDetectorOptions,
UserPreferenceResponseBody,
} from "./user-preference.types.js";
const logger = new ConsoleLogger({ tags: ["react-i18n.detector"] });
/**
* Build a `LocaleDetector` that calls a backend endpoint to read (and
* later persist) the user's stored locale preference.
*
* - GET on `detectAsync(ctx)` returns `{ locale }` from the endpoint.
* - POST on `cache(locale, ctx)` writes the new value back. The body is
* `{ locale }` JSON; the headers are the union of `Content-Type:
* application/json` plus the auth headers returned by `extractAuth`.
*
* Network failures (rejected fetch, non-2xx response, malformed body,
* abort signal) are caught and logged at warn level via
* `@suaveplan/logger`; the detector returns `null` so the chain
* advances to the next entry. Cache failures are non-fatal — the
* persist write is best-effort and never throws into the React tree.
*/
export function userPreferenceDetector(
options: UserPreferenceDetectorOptions
): LocaleDetector {
const fetcher: typeof fetch =
options.fetch ?? ((...args) => globalThis.fetch(...args));
return {
name: "userPreference",
environment: "both",
async detectAsync(ctx) {
try {
const init: RequestInit = {
method: "GET",
headers: buildAuthHeaders(options, ctx),
};
if (ctx.signal) init.signal = ctx.signal;
const response = await fetcher(options.endpoint, init);
if (!response.ok) {
logger.warn("user-preference detector non-ok response", {
endpoint: options.endpoint,
status: response.status,
});
return null;
}
const body =
(await response.json()) as Partial<UserPreferenceResponseBody>;
if (typeof body?.locale !== "string" || body.locale === "") {
logger.warn("user-preference detector missing locale", {
endpoint: options.endpoint,
});
return null;
}
return body.locale;
} catch (error) {
logger.warn("user-preference detector fetch failed", {
endpoint: options.endpoint,
error:
error instanceof Error ? error.message : String(error),
});
return null;
}
},
async cache(locale, ctx) {
try {
const headers = new Headers(buildAuthHeaders(options, ctx));
if (!headers.has("content-type")) {
headers.set("content-type", "application/json");
}
const init: RequestInit = {
method: "POST",
headers,
body: JSON.stringify({ locale }),
};
if (ctx.signal) init.signal = ctx.signal;
const response = await fetcher(options.endpoint, init);
if (!response.ok) {
logger.warn("user-preference detector cache failed", {
endpoint: options.endpoint,
status: response.status,
});
}
} catch (error) {
logger.warn("user-preference detector cache threw", {
endpoint: options.endpoint,
error:
error instanceof Error ? error.message : String(error),
});
}
},
};
}
function buildAuthHeaders(
options: UserPreferenceDetectorOptions,
ctx: DetectorContext
): HeadersInit {
if (options.extractAuth) {
return options.extractAuth(ctx);
}
return {};
}

View File

@@ -0,0 +1,40 @@
import type { DetectorContext } from "@suaveplan/i18n-detector";
/**
* Configuration accepted by `userPreferenceDetector`. The endpoint is
* required; the auth/cache-key/fetch hooks are optional but are the
* primary surface tests inject to keep network behaviour deterministic.
*/
export interface UserPreferenceDetectorOptions {
/**
* URL of the backend endpoint that returns the persisted locale for
* the active session. The endpoint is GET-fetched on detect and
* POST-fetched on cache.
*/
readonly endpoint: string;
/**
* Returns the request-headers payload threaded into the fetch call
* so server-side runs (where the backend endpoint cannot read the
* incoming request directly) carry the session cookie.
*/
readonly extractAuth?: (ctx: DetectorContext) => HeadersInit;
/**
* Returns a stable cache key for the active context so duplicate
* calls in the same render tree can be deduped by the consumer.
*/
readonly cacheKey?: (ctx: DetectorContext) => string;
/**
* Injected `fetch` so tests can pass a real `Response`-returning
* function rather than rely on a global mock. Defaults to the
* platform `globalThis.fetch`.
*/
readonly fetch?: typeof fetch;
}
/**
* Wire shape returned by the user-preference endpoint on a successful
* GET call. Only `locale` is consumed by the detector.
*/
export interface UserPreferenceResponseBody {
readonly locale: string;
}

View File

@@ -0,0 +1,87 @@
# `error-codes.ts`
## Purpose
Registers the five stable error codes used by `@suaveplan/react-i18n` under
the `REACT_I18N` namespace via `defineCodes` from `@suaveplan/error`. These
codes are the single source of truth for error identity across the React
surface — every `ReactI18nError` carries one of them as its `.code`, every
spec-conformance test asserts against them, and every observability sink
(OTel attributes, structured log fields, error-wire JSON) uses the
fully-qualified `REACT_I18N.<reason>` string.
The registry must run at module-import time so the codes are present in the
global registry before any hook, component, or detector throws. The barrel
imports the file as a side-effect-only entry to guarantee the registration
sequence regardless of which subpath the consumer imports first.
## Features
- Frozen `REACT_I18N_CODES` record with five entries — added/removed only
through an OpenSpec change.
- `ReactI18nCode` literal-union type derived from the registry, used as the
`TCode` parameter of `ReactI18nError`.
- Side-effect-free constant declaration: no logger calls, no global mutation
beyond what `defineCodes` performs internally on the shared registry.
## Usage: Basic Example
```ts
import { REACT_I18N_CODES } from "@suaveplan/react-i18n/errors";
const code = REACT_I18N_CODES.NO_PROVIDER;
// "REACT_I18N.NO_PROVIDER"
```
## Usage: Advanced Example
```ts
import {
REACT_I18N_CODES,
ReactI18nError,
type ReactI18nCode,
} from "@suaveplan/react-i18n/errors";
function assertProvider(value: unknown): asserts value {
if (value === undefined || value === null) {
const code: ReactI18nCode = REACT_I18N_CODES.NO_PROVIDER;
throw new ReactI18nError(code, "react-i18n.no_provider", {
context: { hook: "useTranslation" },
});
}
}
```
## API Reference
### `REACT_I18N_CODES`
Frozen record of fully-qualified error code strings. Keys are short reason
identifiers; values are the canonical `REACT_I18N.<reason>` strings.
| Key | Value |
| --- | --- |
| `NO_PROVIDER` | `"REACT_I18N.NO_PROVIDER"` |
| `NAMESPACE_LOAD_FAILED` | `"REACT_I18N.NAMESPACE_LOAD_FAILED"` |
| `HYDRATION_MISMATCH` | `"REACT_I18N.HYDRATION_MISMATCH"` |
| `TRANS_INVALID_CHILD` | `"REACT_I18N.TRANS_INVALID_CHILD"` |
| `UNKNOWN_KEY` | `"REACT_I18N.UNKNOWN_KEY"` |
### `type ReactI18nCode`
Literal union of every value in `REACT_I18N_CODES`. Used as the `TCode`
parameter of `ReactI18nError<ReactI18nCode>` so `instanceof` narrowing on
`error.code` produces a closed union.
## Implementation Notes
- The codes are added/removed only through an OpenSpec change. Adding a code
here without a spec amendment violates `AGENTS.md` §13a. Removing
a code is a breaking change.
- The order is the same order spec authors list the codes in — keep them
parallel so reviewers can scan both surfaces in one pass.
- `defineCodes` returns a `Object.create(null)`-prototyped frozen record;
attempts to mutate at runtime fail loudly under strict mode.
- Importing this file is a side effect only insofar as `defineCodes`
registers each code with the shared `@suaveplan/error` registry; importing
the file twice is idempotent.

View File

@@ -0,0 +1,55 @@
import { defineCodes } from "@suaveplan/error";
/**
* Stable error codes for the @suaveplan/react-i18n package.
*
* Codes are grouped under the REACT_I18N namespace and registered at module
* load time via `defineCodes`. The registry is the single source of truth
* for error-code identity across the React surface — every thrown
* `ReactI18nError` carries one of these strings as its `.code`.
*
* Codes:
* - `NO_PROVIDER` — a hook was called outside a `<TranslationProvider>` and
* `process.env.NODE_ENV !== "production"`.
* - `NAMESPACE_LOAD_FAILED` — a Suspense-thrown promise from
* `i18n.preload(namespace)` rejected and an error boundary caught it.
* - `HYDRATION_MISMATCH` — SSR `initialMessages` payload did not satisfy
* `TranslationRecordSchema` (Zod) when the Provider mounted.
* - `TRANS_INVALID_CHILD` — `<Trans>` received a child that cannot be
* matched to a tag pair in the parsed message.
* - `UNKNOWN_KEY` — `tDynamic(key)` was called with a key absent from the
* active catalog (the runtime escape hatch path).
* - `PLUGIN_DUPLICATE_ID` — a plugin with the same `id` as an existing
* registration (different instance) was registered into the same kind.
* - `PLUGIN_INVALID` — a plugin failed schema validation at registration
* time. The Zod issue list is attached as `cause`.
* - `REGISTRY_NOT_PROVIDED` — `useReactI18nRegistry()` was called outside
* a `<RegistryProvider>` ancestor.
* - `FORMATTER_NOT_REGISTERED` — `useFormatter(id, args)` was called with
* an `id` that has no matching `FormatterPlugin` in the registry.
* - `DETECTOR_PLUGIN_DUPLICATE_ID` — alias of `PLUGIN_DUPLICATE_ID`
* specialised for detector kind, retained for downstream consumers
* asserting against the detector-specific code.
* - `DETECTOR_PLUGIN_INVALID` — alias of `PLUGIN_INVALID` specialised
* for detector kind.
* - `LOCALE_ADAPTER_NOT_REGISTERED` — `useLocaleAdapter(id)` was called
* with an `id` that has no matching `LocaleAdapterPlugin` in the
* registry.
*/
export const REACT_I18N_CODES = defineCodes("REACT_I18N", [
"NO_PROVIDER",
"NAMESPACE_LOAD_FAILED",
"HYDRATION_MISMATCH",
"TRANS_INVALID_CHILD",
"UNKNOWN_KEY",
"PLUGIN_DUPLICATE_ID",
"PLUGIN_INVALID",
"REGISTRY_NOT_PROVIDED",
"FORMATTER_NOT_REGISTERED",
"DETECTOR_PLUGIN_DUPLICATE_ID",
"DETECTOR_PLUGIN_INVALID",
"LOCALE_ADAPTER_NOT_REGISTERED",
] as const);
export type ReactI18nCode =
(typeof REACT_I18N_CODES)[keyof typeof REACT_I18N_CODES];

Some files were not shown because too many files have changed in this diff Show More