first commit

This commit is contained in:
2026-05-29 08:20:56 +01:00
commit 949299f58d
6368 changed files with 527176 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,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,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,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,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,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,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,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,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,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,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,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,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,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,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,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,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,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,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,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,138 @@
# `withTranslation`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`withTranslation` is the class-component escape hatch for the
React-i18n surface. It accepts a class (or function) component whose
props extend `WithTranslationProps` and returns a new component that
injects `t` and `i18n` from the active `<TranslationProvider>` context.
Function components should prefer `useTranslation()` directly — the HOC
exists because legacy class components, error boundaries, and a few
React-DnD-style libraries cannot use hooks. The HOC wires the same
event-bus subscription as the hook (`useSyncExternalStore` over
`languageChanged` / `loaded`) so the wrapped component re-renders on
locale changes identically to a hook consumer.
## Features
- Injects `t` (ergonomic-options-bag translator) and `i18n`
(the active `I18nInstance`) into the wrapped component
- Subscribes to bus events via `useSyncExternalStore` for concurrent-
safe re-renders
- Forwards refs through `forwardRef` so the parent can still reach the
inner instance
- Sets a `displayName` of `"withTranslation(<inner>)"` for clean
React-DevTools traces and Storybook trees
- Throws `ReactI18nError(REACT_I18N.NO_PROVIDER)` outside a Provider
- Accepts a forward-compatible `namespace` option (currently surfaced
as a no-op so the signature stays stable when namespace pinning lands)
## Usage: Basic Example
Wrap a class component that needs translation access:
```tsx
import { Component } from "react";
import {
withTranslation,
type WithTranslationProps,
} from "@suaveplan/react-i18n/hoc/with-translation";
interface GreetingProps extends WithTranslationProps {
name: string;
}
class Greeting extends Component<GreetingProps> {
static displayName = "Greeting";
render() {
return (
<p>{this.props.t("hello", { values: { name: this.props.name } })}</p>
);
}
}
export default withTranslation(Greeting);
```
External callers use the wrapped component without supplying `t` or
`i18n` — they are injected from the Provider.
## Usage: Advanced Example
Refs, namespace pinning, and a typed wrapper:
```tsx
import { createRef, Component } from "react";
import { withTranslation } from "@suaveplan/react-i18n/hoc/with-translation";
class FocusableInput extends Component<WithTranslationProps & { id: string }> {
private input = createRef<HTMLInputElement>();
public focus() {
this.input.current?.focus();
}
render() {
return (
<input
ref={this.input}
id={this.props.id}
placeholder={this.props.t("input.placeholder")}
/>
);
}
}
const Wrapped = withTranslation(FocusableInput, { namespace: "auth" });
const ref = createRef<FocusableInput>();
<Wrapped id="email" ref={ref} />;
ref.current?.focus(); // ref still reaches the inner instance
```
## API Reference
### `withTranslation<P extends WithTranslationProps, R = unknown>(Component, options?)`
- `Component: ComponentType<P>` — the class or function component to
wrap. Its prop interface MUST extend `WithTranslationProps`.
- `options?: WithTranslationOptions` — optional configuration:
- `namespace?: string` — namespace label held on the closure for
forward-compat. The current core ignores this; surface kept stable.
Returns a `ForwardRefExoticComponent` whose externally-visible prop set
is `Omit<P, keyof WithTranslationProps>` (the consumer never sees `t` /
`i18n`) plus a forwarded `ref` of type `Ref<R>`.
### `WithTranslationProps`
- `t: WithTranslationT` — `(key, options?) => string`. Mirrors the
universal-core `t()` ergonomic-options-bag form.
- `i18n: I18nInstance` — the active instance.
### `WithTranslationT`
`(key: string, options?: { count?, defaultValue?, values?, context? }) => string`.
Throws `ReactI18nError(REACT_I18N.NO_PROVIDER)` when rendered outside a
`TranslationProvider`.
## Implementation Notes
- The HOC uses `forwardRef` so refs land on the inner component without
the consumer manually wiring an `innerRef` prop.
- The injected props are merged into the wrapped component's props
inside an inline object spread; the original prop bag is never
mutated.
- The `namespace` option is currently a no-op kept on the closure so
the signature does not change when namespace pinning lands. The
closure-side `void options?.namespace` keeps the parameter under the
function's lexical scope for future use without triggering an unused-
variable lint.
- The HOC does not memoise the wrapped component; consumers that want
memoisation should wrap the inner component with `React.memo` before
passing it in.
- The `getDisplayName` helper picks `displayName` first, then `name`,
then falls back to `"Component"` so the React-DevTools tree always
shows a stable name even when the inner component is anonymous.

View File

@@ -0,0 +1,95 @@
# useCurrency
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`useCurrency(amount, currency, options?)` is a memoised wrapper around
`@suaveplan/i18n`'s `formatCurrency` helper. It returns the locale +
currency-formatted string for an amount and re-renders on
`languageChanged` so the displayed format updates the moment the locale
flips. Memoisation is keyed on `(resolvedLocale, amount, currency,
options-hash)` so identical inputs across renders return the same string
without re-invoking `Intl.NumberFormat`.
The `options` argument is forwarded straight through to `formatCurrency`,
which accepts `display: 'symbol' | 'narrowSymbol' | 'code' | 'name'` and
the `min/maxFractionDigits` shorthand for currency-style formatting.
## Features
- Memoised on `(locale, amount, currency, options-hash)` — re-renders on locale change but skips re-format on identical inputs.
- Forwards the `display` shorthand (`'symbol'`, `'narrowSymbol'`, `'code'`, `'name'`).
- Forwards `minimumFractionDigits` / `maximumFractionDigits`.
- Production no-Provider fallback uses `'en-GB'` so the hook always returns a real string.
- Re-renders on `languageChanged` via the inherited `useLocale` subscription.
## Usage: Basic Example
```tsx
import { useCurrency } from "@suaveplan/react-i18n";
export function PriceTag(props: {
readonly amount: number;
readonly currency: string;
}) {
const formatted = useCurrency(props.amount, props.currency);
return <span>{formatted}</span>;
}
```
## Usage: Advanced Example
The `display: 'code'` option keeps the ISO-4217 code visible alongside
the amount — useful for accounting tables and B2B invoicing flows where
the symbol alone could be ambiguous (USD vs CAD vs AUD all use `$`). The
pattern below shows a totals row that uses code display for clarity.
```tsx
import { useCurrency } from "@suaveplan/react-i18n";
export function InvoiceTotals(props: {
readonly subtotal: number;
readonly tax: number;
readonly currency: string;
}) {
const subtotal = useCurrency(props.subtotal, props.currency, {
display: "code",
maximumFractionDigits: 2,
});
const tax = useCurrency(props.tax, props.currency, {
display: "code",
maximumFractionDigits: 2,
});
const total = useCurrency(props.subtotal + props.tax, props.currency, {
display: "code",
maximumFractionDigits: 2,
});
return (
<dl>
<dt>Subtotal</dt><dd>{subtotal}</dd>
<dt>Tax</dt><dd>{tax}</dd>
<dt>Total</dt><dd>{total}</dd>
</dl>
);
}
```
## API Reference
### `useCurrency(amount: number, currency: string, options?): string`
- **Parameters**:
- `amount` — numeric value to format.
- `currency` — ISO-4217 currency code (`'USD'`, `'GBP'`, `'JPY'`, …).
- `options?` — optional `display`, `minimumFractionDigits`, `maximumFractionDigits`.
- **Returns**: the formatted currency string.
- **Throws**: `ReactI18nError(REACT_I18N.NO_PROVIDER)` outside a `<TranslationProvider>` in dev mode (inherited from `useLocale → useI18n`).
## Implementation Notes
- The memo key is `(resolvedLocale, amount, currency, optionsHash, options)`. The explicit `options` reference is included so consumers passing the same object across renders share the memo while consumers passing fresh objects with identical contents share via the hash.
- The hook delegates to the universal-core `formatCurrency` so any future enhancements there (BCP-47 normalisation, additional preset shorthands) propagate automatically.
- The default locale `'en-GB'` on the no-Provider fallback matches the broader package convention. This keeps the hook from throwing in production while still producing meaningful output.
- For currency-specific UX questions (e.g. when to show `'narrowSymbol'` vs `'symbol'`), prefer following the locale's default unless your design system mandates otherwise.

View File

@@ -0,0 +1,85 @@
# useDate
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`useDate(date, options?)` is a memoised wrapper around the
universal-core `formatDate` helper from `@suaveplan/i18n`. It returns
the locale-formatted string for a `Date` value and re-renders on
`languageChanged` so the displayed format updates the moment the locale
flips. Memoisation is keyed on `(resolvedLocale, date.getTime(),
options-hash)` so identical inputs across renders return the same
string without re-invoking `Intl.DateTimeFormat`.
The `options` argument is forwarded straight through to `formatDate`,
which accepts both preset strings (`'short'`, `'medium'`, `'long'`,
`'full'`, `'time-short'`, `'time-long'`, `'date-short'`, `'date-long'`)
and raw `Intl.DateTimeFormatOptions` (mixed in below the preset).
## Features
- Memoised on `(locale, date, options-hash)` — re-renders on locale change but skips re-format on identical inputs.
- Preset support (`'short'`, `'medium'`, `'long'`, `'full'`, `'time-*'`, `'date-*'`).
- Raw `Intl.DateTimeFormatOptions` mixed in below the preset.
- Production no-Provider fallback uses `'en-GB'` so the hook always returns a real string.
- Subscribes through `useLocale` to re-render on `languageChanged`.
## Usage: Basic Example
```tsx
import { useDate } from "@suaveplan/react-i18n";
export function Timestamp(props: { readonly when: Date }) {
const formatted = useDate(props.when, { preset: "medium" });
return <time dateTime={props.when.toISOString()}>{formatted}</time>;
}
```
## Usage: Advanced Example
Combining a preset with an explicit `timeZone` keeps the rendering
stable across viewer time zones — useful for activity feeds and audit
logs that must always show the same local time. The pattern below
shows a row that re-formats only when its inputs change.
```tsx
import { useDate } from "@suaveplan/react-i18n";
interface Activity {
readonly id: string;
readonly when: Date;
readonly summary: string;
}
export function ActivityRow(props: { readonly entry: Activity }) {
const time = useDate(props.entry.when, {
preset: "long",
timeZone: "UTC",
});
return (
<li>
<time dateTime={props.entry.when.toISOString()}>{time}</time>
{" — "}
{props.entry.summary}
</li>
);
}
```
## API Reference
### `useDate(date: Date, options?: Parameters<typeof formatDate>[2]): string`
- **Parameters**:
- `date` — the `Date` to format. The hook keys on `date.getTime()` so wrapping the same epoch in different `Date` instances does NOT cause a re-format.
- `options?` — preset string (`'medium'`, `'date-long'`, …) or raw `Intl.DateTimeFormatOptions` (mixed in below the preset when both are supplied).
- **Returns**: the formatted string.
- **Throws**: `ReactI18nError(REACT_I18N.NO_PROVIDER)` outside a `<TranslationProvider>` in dev mode (inherited from `useLocale → useI18n`).
## Implementation Notes
- The memo key is split into two structural inputs (`dateMs`, `optionsHash`) plus the live `options` reference. The key composition is `(resolvedLocale, dateMs, optionsHash, options)` — the explicit `options` reference is included so consumers passing the same object across renders share the same memo while consumers passing fresh objects with identical contents share via the hash.
- Option hashing uses `JSON.stringify(options)`. This is fine for the documented `Intl.DateTimeFormatOptions` shape (all leaf values are primitives) — no functions, no Dates, no circular refs.
- The default locale for the no-Provider fallback is `'en-GB'`. This matches the i18n core's primary fallback — ASCII-friendly, non-empty Intl behaviour, and consistent with the project's established convention.

View File

@@ -0,0 +1,83 @@
# useDirection
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`useDirection()` returns the writing direction (`'ltr'` / `'rtl'`) for
the active locale. Composes `useLocale` (so it inherits the underlying
`useSyncExternalStore` subscription) and runs the locale through
`@suaveplan/i18n`'s `getTextDirection` helper. The single-source-of-truth
for which locales are RTL lives in the i18n core's `RTL_LOCALES` set —
the React surface does not duplicate the list.
The hook is the canonical primitive for direction-sensitive UI: setting
`<html dir="...">`, mirroring icon strips, swapping margin-left for
margin-right in a CSS module, and so on. A locale change like
`setLocale('ar-AE')` flips the result to `'rtl'` on the same React
commit cycle as the bus emission, so the UI re-mirrors atomically with
the new translations.
## Features
- Returns `{ direction, locale }` — both kept in sync with the active locale.
- `'ltr'` / `'rtl'` resolution via the i18n core's `getTextDirection`.
- `direction` defaults to `'ltr'` on the production no-Provider fallback (the safer default for unknown locales).
- Memoised on the locale string so a no-op re-render does not re-call `getTextDirection`.
- Re-renders on `languageChanged` via the inherited `useLocale` subscription.
## Usage: Basic Example
```tsx
import { useDirection } from "@suaveplan/react-i18n";
export function ChatBubble(props: { readonly children: React.ReactNode }) {
const { direction } = useDirection();
return (
<div
dir={direction}
style={{ textAlign: direction === "rtl" ? "right" : "left" }}
>
{props.children}
</div>
);
}
```
## Usage: Advanced Example
For document-level mirroring set the `<html dir>` attribute from a
top-level effect that mirrors the hook's output. The pattern below
keeps the markup in sync without needing every component to be
direction-aware.
```tsx
import { useEffect } from "react";
import { useDirection } from "@suaveplan/react-i18n";
export function HtmlDirectionSync(): null {
const { direction, locale } = useDirection();
useEffect(() => {
const root = document.documentElement;
if (locale !== undefined) root.lang = locale;
root.dir = direction;
}, [direction, locale]);
return null;
}
```
## API Reference
### `useDirection(): UseDirectionReturn`
- **Returns**: `{ direction, locale }`.
- `direction: 'ltr' | 'rtl'` — derived from `getTextDirection(locale)`. Defaults to `'ltr'` when `locale === undefined`.
- `locale: string | undefined` — current active locale, mirrored from `useLocale().locale`.
- **Throws**: `ReactI18nError(REACT_I18N.NO_PROVIDER)` outside a `<TranslationProvider>` in dev mode (inherited from `useLocale → useI18n`).
## Implementation Notes
- The lookup is wrapped in `useMemo` keyed on the locale string. `getTextDirection` is cheap (a single `Set.has` after a string split), but the memo also keeps the returned object reference stable across re-renders that did not change the locale, which keeps `React.memo`-wrapped descendants from re-rendering.
- The hook deliberately delegates the RTL list to `@suaveplan/i18n` instead of duplicating it. New RTL scripts added to the upstream `RTL_LOCALES` table propagate automatically — no React-side maintenance required.
- The fallback to `'ltr'` on the no-Provider production path matches the broader package contract: end-users see degraded but non-broken UI when configuration is missing.

View File

@@ -0,0 +1,112 @@
# useErrorMessage
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`useErrorMessage(error)` translates a `SuaveplanError` (or any value) to
a user-facing string. The lookup uses the catalog key
`errors:<error.code>` so the entire project's stable error codes map
directly onto translation keys without consumer-side wiring.
The hook is forgiving — `undefined`, plain `Error` instances, and other
non-`SuaveplanError` values all collapse to `''`. This means components
can render `useErrorMessage(error)` unconditionally and the empty
string just renders as nothing. There is no need for nullish-coalescing
guards at every call site.
## Features
- Accepts `SuaveplanError | unknown` — never throws on bad input.
- Resolves `errors:<error.code>` from the active catalog.
- Falls back to `error.message` when the catalog has no entry for the code.
- Returns `''` for `undefined` or non-`SuaveplanError` inputs.
- Re-renders implicitly on `languageChanged` via the inherited `useI18n` subscription.
- Production no-Provider fallback returns `error.message` (skips translation, never crashes).
## Usage: Basic Example
```tsx
import { useErrorMessage } from "@suaveplan/react-i18n";
import type { SuaveplanError } from "@suaveplan/error";
export function ErrorBanner(props: { readonly error: SuaveplanError | undefined }) {
const message = useErrorMessage(props.error);
if (message === "") return null;
return (
<div role="alert" aria-live="assertive">
{message}
</div>
);
}
```
## Usage: Advanced Example
The hook composes naturally inside an error boundary's render branch.
The pattern below shows a global boundary that translates whatever
caused the throw — `SuaveplanError` instances get full catalog
translation, plain `Error` instances surface as raw `.message` strings.
```tsx
import { Component, type ErrorInfo, type ReactNode } from "react";
import { useErrorMessage } from "@suaveplan/react-i18n";
function FormattedError(props: { readonly error: unknown }) {
const message = useErrorMessage(props.error);
return <pre data-testid="boundary-fallback">{message || "Unknown error"}</pre>;
}
interface BoundaryState {
readonly error?: unknown;
}
export class GlobalErrorBoundary extends Component<
{ readonly children: ReactNode },
BoundaryState
> {
public state: BoundaryState = {};
public static getDerivedStateFromError(error: unknown): BoundaryState {
return { error };
}
public componentDidCatch(_error: unknown, _info: ErrorInfo): void {
// Forward to telemetry — not shown.
}
public render(): ReactNode {
if (this.state.error !== undefined) {
return <FormattedError error={this.state.error} />;
}
return this.props.children;
}
}
```
## API Reference
### `useErrorMessage(error: unknown): string`
- **Parameters**:
- `error` — any value. Only `SuaveplanError` instances participate in catalog lookup.
- **Returns**: a translated string, or `error.message`, or `''` per the resolution table.
- **Throws**: `ReactI18nError(REACT_I18N.NO_PROVIDER)` outside a `<TranslationProvider>` in dev mode (inherited from `useI18n`).
Resolution table:
| Input | Output |
| -------------------------------------- | ------------------------------------------------ |
| `undefined` | `''` |
| Non-`SuaveplanError` (`new TypeError`) | `''` |
| `SuaveplanError`, catalog has key | catalog `errors:<code>` value |
| `SuaveplanError`, catalog lacks key | `error.message` |
| `SuaveplanError`, no Provider (prod) | `error.message` |
## Implementation Notes
- The "catalog miss" check compares the catalog return value to the literal key. The catalog returns the key string itself when no value is registered, so an equality check captures the miss without an extra `has()` call. This keeps the hook to a single `instance.t(key)` invocation per render.
- `instanceof SuaveplanError` is the discriminator. The `@suaveplan/error` package guarantees every typed error in the project extends this base, so the check is exhaustive.
- The hook does not memoise. The work is bounded (one `instanceof` check + one `instance.t` call) and React's reconciliation already deduplicates the rendered string.
- Returning `''` rather than `undefined` on the empty paths means consumers can safely use the result inside JSX text content without an extra null-check (`<span>{useErrorMessage(error)}</span>` always renders).

View File

@@ -0,0 +1,96 @@
# `useFormatter`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`useFormatter<TArgs, TResult>(id, args)` is the single registry-driven formatter hook. It looks up a `FormatterPlugin` by `id` from the active `ReactI18nRegistry`, calls its `format(locale, args)` against the active locale, and memoizes the result on `(locale, id, JSON.stringify(args))`. The six built-in plugins (`number`, `currency`, `date`, `list`, `relative-time`, `plural`) auto-registered by `<RegistryProvider>` mean the canonical ids resolve out of the box; the same hook lets a consumer add a project-specific formatter (`useDuration`, `useFiscalQuarter`) without forking the package.
## Features
- One hook covers every formatter id — `number`, `currency`, `date`, `list`, `relative-time`, `plural`, plus any consumer-registered plugin.
- Memoization on `(locale, id, args)` so a no-op re-render does not re-invoke the underlying Intl call.
- Stable JSON-key on `args` so two structurally-equal arg objects produce a single cache hit (sorted keys, `Date` and BigInt unwrapped).
- Stable error code `REACT_I18N.FORMATTER_NOT_REGISTERED` for unknown ids.
- OTel `react_i18n.formatter.duration_ms{id}` histogram per call and `react_i18n.formatter.errors_total{id, code}` counter on failure.
## Usage: Basic Example
```tsx
import { useFormatter } from "@suaveplan/react-i18n";
function PriceTag({ amount }: { amount: number }) {
const formatted = useFormatter<{ amount: number; currency: string }, string>(
"currency",
{ amount, currency: "USD" }
);
return <span>{formatted}</span>;
}
```
## Usage: Advanced Example
A project-specific `useDuration` formatter resolves through the registry too — define the plugin once, register it in `<RegistryProvider>`, then any component can `useFormatter("duration", { ms })`:
```ts
import {
type FormatterPlugin,
RegistryProvider,
useFormatter,
} from "@suaveplan/react-i18n";
const durationPlugin: FormatterPlugin<{ ms: number }, string> = {
id: "duration",
format: (locale, { ms }) => {
const seconds = Math.round(ms / 1000);
const minutes = Math.floor(seconds / 60);
const remainder = seconds % 60;
const numberFormat = new Intl.NumberFormat(locale);
return `${numberFormat.format(minutes)}:${remainder
.toString()
.padStart(2, "0")}`;
},
};
<RegistryProvider plugins={[{ kind: "formatter", plugin: durationPlugin }]}>
<TranslationProvider config={i18nConfig}>
<App />
</TranslationProvider>
</RegistryProvider>;
function ElapsedTime({ ms }: { ms: number }) {
const formatted = useFormatter<{ ms: number }, string>("duration", { ms });
return <output>{formatted}</output>;
}
```
To override a built-in (e.g. swap the default number formatter for a BigDecimal-aware variant), mount with `disableBuiltInFormatters` and supply the replacement explicitly:
```tsx
<RegistryProvider
disableBuiltInFormatters
plugins={[
{ kind: "formatter", plugin: bigDecimalNumberPlugin },
// … any other plugins
]}
>
{/* `useFormatter("number", …)` now resolves to bigDecimalNumberPlugin */}
</RegistryProvider>
```
## API Reference
### `useFormatter<TArgs, TResult>(id: string, args: TArgs): TResult`
- `id`: the registered plugin's id. The six canonical ids are auto-registered by `<RegistryProvider>` unless `disableBuiltInFormatters` is set.
- `args`: passed to the plugin's `format(locale, args)` call. Must be JSON-serialisable for memoization.
Throws `ReactI18nError(REACT_I18N.FORMATTER_NOT_REGISTERED)` when no plugin matches `id`. Throws `ReactI18nError(REACT_I18N.REGISTRY_NOT_PROVIDED)` when called outside a `<RegistryProvider>`.
## Implementation Notes
- The hook uses the strict `useReactI18nRegistry()` (not the tolerant variant) because by the time a consumer reaches for `useFormatter`, they have explicitly opted into the registry — a missing provider is a wiring bug.
- Memoization keys the args object via a `stableStringify` helper that sorts keys and unwraps `Date`/`BigInt`. This handles every plugin-arg shape the built-ins ship today; plugins with non-serialisable args (functions, `Map`) should pass them through React refs / context, not through the hook's args.
- The OTel histogram uses `performance.now()` for sub-millisecond precision. The `errors_total` counter is tagged with the underlying error's `.code` when available; otherwise `REACT_I18N.FORMATTER_THREW` is used as a synthetic placeholder so dashboards never see a `code: undefined` row.
- The hook does NOT fall back to a non-registry path when the id is missing. The fallback behavior described in the spec ("hooks fall back to the direct-import path when no registry is in scope") lives in the per-type wrapper hooks (`useNumber`, `useDate`, …); `useFormatter` itself is registry-required.

View File

@@ -0,0 +1,96 @@
# useI18n
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`useI18n()` is the foundation hook of `@suaveplan/react-i18n`. It returns the
active `I18nInstance` carried by the nearest `<TranslationProvider>` ancestor.
Every other hook in the package (`useTranslation`, `useLocale`, `useDate`,
`useNumber`, `useCurrency`, `useRelativeTime`, `useList`, `usePlural`,
`useDirection`, `useErrorMessage`) destructures from this base so the rules
about missing-provider behaviour live in one place.
The hook intentionally splits its behaviour by environment so the
configuration error has different escalation paths in the developer's
day-to-day loop and in production traffic. In dev (and tests) the hook
throws `ReactI18nError(REACT_I18N.NO_PROVIDER)` so the bug is visible
immediately. In production the hook returns `null` and emits a single
`warn-once-per-process` log entry — never throwing while real users are
trying to read your UI.
## Features
- Returns the active `I18nInstance` from `TranslationContext`.
- Dev-mode (NODE_ENV !== "production"): throws `ReactI18nError(REACT_I18N.NO_PROVIDER)` outside a Provider.
- Production-mode: returns `null` plus a one-shot `console.warn`.
- The Provider re-renders subscribers on `languageChanged` / `loaded` via `useSyncExternalStore`, so hooks reading `i18n.locale` see the update on the same React commit cycle as the bus emission.
- Zero allocations per render in the common (in-Provider) path.
## Usage: Basic Example
```tsx
import { useI18n, TranslationProvider } from "@suaveplan/react-i18n";
import { createI18n } from "@suaveplan/i18n/plugin";
function HelloLocale() {
const i18n = useI18n();
return <span>{i18n?.locale ?? "no-provider"}</span>;
}
const i18n = createI18n({
defaultLocale: "en-GB",
messages: { "en-GB": { greeting: "Hello" } },
});
export function App() {
return (
<TranslationProvider i18n={i18n}>
<HelloLocale />
</TranslationProvider>
);
}
```
## Usage: Advanced Example
`useI18n` returns the live instance, so a downstream hook can access plugin
slots, subscribe to bus events directly, or call `i18n.changeLanguage`
without going through `useLocale`. This is the recommended composition path
for custom hooks layered on top of the package.
```tsx
import { useI18n } from "@suaveplan/react-i18n";
import { useEffect, useState } from "react";
export function useNamespaceLoadCount(namespace: string): number {
const i18n = useI18n();
const [count, setCount] = useState(0);
useEffect(() => {
if (i18n === null) return;
return i18n.events.on("loaded", (payload) => {
if (payload.namespace === namespace) {
setCount((prev) => prev + 1);
}
});
}, [i18n, namespace]);
return count;
}
```
## API Reference
### `useI18n(): I18nInstance | null`
Returns the active `I18nInstance` from `TranslationContext`.
- **Returns**: `I18nInstance` when a `<TranslationProvider>` ancestor is in scope; `null` outside a Provider in production.
- **Throws**: `ReactI18nError(REACT_I18N.NO_PROVIDER)` outside a Provider when `process.env.NODE_ENV !== "production"`.
## Implementation Notes
- The `null` return in production is intentional. Higher-level hooks (`useTranslation`, `useLocale`, …) destructure this and switch to identity / no-op fallbacks so end-users never see a runtime crash from a missing Provider.
- The dedup key for the warn-once-per-process call is `"react-i18n.use-i18n.no-provider"` — module-level so even multiple call sites and multiple components share the same one-shot.
- The hook intentionally does NOT subscribe to the bus itself. The Provider holds the only `useSyncExternalStore` subscription; descendants re-render via that subscription's change-notification cascade. This keeps the hook's per-render cost equal to a single `useContext` read.
- `process.env.NODE_ENV` is read at call-time (via `isProductionEnv()`) so test suites that swap the env between cases see the swap. Bundler-stamped literals also work because `globalThis.process.env.NODE_ENV` is the lookup target.

View File

@@ -0,0 +1,86 @@
# useList
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`useList(values, options?)` formats an array of pre-formatted strings as
a localised list. Wraps `Intl.ListFormat` with a per-(locale, style,
type) cache so the formatter constructor is paid once per process per
option combination. Memoised on `(resolvedLocale, valuesHash,
options-hash)` so a no-op re-render does not reach into the cache.
The hook joins values as-is. Use `useNumber` / `useDate` /
`useCurrency` upstream when the parts are non-string primitives — the
list formatter only knows how to insert localised conjunctions /
disjunctions between already-rendered fragments.
## Features
- `'A, B, and C'` / `'A, B oder C'` / `'A، B، و C'` — locale-aware conjunctions.
- Forwards `style` (`'long' | 'short' | 'narrow'`) and `type` (`'conjunction' | 'disjunction' | 'unit'`).
- Per-(locale, style, type) cache of `Intl.ListFormat` instances.
- Memoised on `(locale, values, options-hash)` — no re-format on identical inputs.
- Production no-Provider fallback uses `'en-GB'` so the hook always returns a real string.
- Re-renders on `languageChanged` via the inherited `useLocale` subscription.
- `clearUseListCache()` exposed for tests / hot-reload scenarios.
## Usage: Basic Example
```tsx
import { useList } from "@suaveplan/react-i18n";
export function ParticipantList(props: {
readonly names: readonly string[];
}) {
const sentence = useList(props.names);
return <span>{sentence}</span>;
}
```
## Usage: Advanced Example
The `type: 'disjunction'` mode produces "A or B or C" lists — useful
for option pickers, error messages listing accepted values, and
multi-choice prompts. The pattern below shows an error message that
lists allowed file extensions.
```tsx
import { useList } from "@suaveplan/react-i18n";
export function AllowedExtensionsBadge(props: {
readonly allowed: readonly string[];
}) {
const sentence = useList(props.allowed, {
type: "disjunction",
style: "short",
});
return (
<p role="status">
Accepted file types: {sentence}
</p>
);
}
```
## API Reference
### `useList(values: readonly string[], options?: UseListOptions): string`
- **Parameters**:
- `values` — array of pre-formatted parts to join. Single-element arrays return the element as-is.
- `options?` — `style`, `type`. Defaults: `style: 'long'`, `type: 'conjunction'` (matches `Intl.ListFormat` defaults).
- **Returns**: the joined list string.
- **Throws**: `ReactI18nError(REACT_I18N.NO_PROVIDER)` outside a `<TranslationProvider>` in dev mode (inherited from `useLocale → useI18n`).
### `clearUseListCache(): void`
Test-only escape hatch to reset the process-level `Intl.ListFormat` cache.
## Implementation Notes
- The `valuesHash` derivation is `values.join(' ')`. This is cheap and stable for the documented use case (string arrays). If a consumer passes 1000 values per render the hash dominates — accept that overhead or de-dup upstream.
- The cache key is `(locale, style, type)` — `values` does NOT enter the cache because `Intl.ListFormat.format(values)` accepts the array per call. One formatter handles all values for the same (locale, style, type) combination.
- The default locale `'en-GB'` on the no-Provider fallback matches the broader package convention. This keeps the hook from throwing in production while still producing meaningful output.
- For non-string values (numbers, dates, currencies), format upstream via `useNumber` / `useDate` / `useCurrency` and pass the resulting strings into `useList`.

View File

@@ -0,0 +1,71 @@
# `useLocaleAdapter`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`useLocaleAdapter<TLocale>(id)` resolves a registered `LocaleAdapterPlugin<TLocale>` against the active locale and returns its library-specific output. The canonical use case is `react-day-picker`'s `<Calendar>`, which wants a date-fns `Locale` object — the component calls `useLocaleAdapter<Locale>("date-fns")` and the consumer registers an adapter that maps the active BCP-47 locale to the corresponding date-fns reference. The package stays library-agnostic; the consumer wires the date / time library they already use.
## Features
- Strict resolution — throws `REACT_I18N.LOCALE_ADAPTER_NOT_REGISTERED` for an unknown id.
- Memoized on `(registry, id, locale)` so a no-op re-render does not re-invoke `plugin.resolve`.
- Built-in `"intl"` identity adapter is auto-registered by `<RegistryProvider>` so any consumer that just needs the locale string back gets it without registering a plugin.
## Usage: Basic Example
```tsx
import { useLocaleAdapter } from "@suaveplan/react-i18n";
function HtmlLang(): JSX.Element {
const lang = useLocaleAdapter<string>("intl");
return <meta name="content-language" content={lang} />;
}
```
## Usage: Advanced Example
A `<Calendar>` component asks for the date-fns adapter:
```tsx
import { useLocaleAdapter } from "@suaveplan/react-i18n";
import { DayPicker } from "react-day-picker";
import type { Locale as DateFnsLocale } from "date-fns";
function Calendar(props: CalendarProps): JSX.Element {
const dateFnsLocale = useLocaleAdapter<DateFnsLocale>("date-fns");
return <DayPicker {...props} locale={dateFnsLocale} />;
}
```
The consumer wires the adapter once in `<RegistryProvider>`:
```tsx
import { createDateFnsLocaleAdapter, RegistryProvider } from "@suaveplan/react-i18n";
import { enGB, frFR, zhCN } from "date-fns/locale";
const dateFnsAdapter = createDateFnsLocaleAdapter({
locales: { enGB, frFR, zhCN },
});
<RegistryProvider plugins={[{ kind: "locale-adapter", plugin: dateFnsAdapter }]}>
{/* … Calendar resolves "date-fns" to the active locale's Locale object */}
</RegistryProvider>;
```
Without registering the adapter, `<Calendar>` throws `REACT_I18N.LOCALE_ADAPTER_NOT_REGISTERED` on first render. This is intentional — silent fallback to a hardcoded English locale would be surprising in an RTL locale.
## API Reference
### `useLocaleAdapter<TLocale>(id: string): TLocale`
- `id`: the registered plugin's id. The built-in `"intl"` is auto-registered; other ids (`"date-fns"`, `"luxon"`, `"temporal"`) are consumer-registered.
Throws `ReactI18nError(REACT_I18N.LOCALE_ADAPTER_NOT_REGISTERED)` for an unknown id, `REACT_I18N.REGISTRY_NOT_PROVIDED` outside a `<RegistryProvider>`.
## Implementation Notes
- The hook is memoized on `(registry, id, resolvedLocale)`. Adapter implementations must be pure — calling `resolve(locale)` twice with the same locale must return referentially-equal output for the memo to be useful.
- Adapters typically hold their locale map at construction time (see `createDateFnsLocaleAdapter`); this means the adapter object identity is stable across renders, which keeps the memoization key stable.
- A consumer registering multiple adapters under different ids (e.g. `"date-fns"` AND `"luxon"`) gets two independent resolution paths. Components decide which library they need by passing the matching id.

View File

@@ -0,0 +1,96 @@
# DetectorChainContext
## Purpose
`DetectorChainContext` is the React context that carries the configured
locale-detector chain (plus any SSR-authoritative `initialLocale`) to
descendants of `<DetectorChainProvider>`. It exists as a separate
context from `TranslationContext` because the foundation
`<TranslationProvider>` config schema does not yet expose a
`detectors` field — splitting the surfaces lets the detector chain
consumer (`useLocaleDetection`) read both contexts independently
without forcing the foundation to know about detectors.
The default value is `null` so `useLocaleDetection` can throw a clear
"used without `<DetectorChainProvider>`" error rather than silently
no-op.
## Features
- `null` default value (lets the hook detect missing Provider)
- `displayName: "SuaveplanDetectorChainContext"` for React DevTools
## Usage: Basic Example
```tsx
import { DetectorChainContext } from "@suaveplan/react-i18n/hooks/use-locale-detection/detector-chain-context";
import { useContext } from "react";
function CustomChainConsumer() {
const config = useContext(DetectorChainContext);
if (!config) throw new Error("missing DetectorChainProvider");
return <span>{config.detectors.length} detectors registered</span>;
}
```
## Usage: Advanced Example
```tsx
import { DetectorChainContext } from "@suaveplan/react-i18n/hooks/use-locale-detection/detector-chain-context";
import { defaultDetectorChain } from "@suaveplan/react-i18n/detector/default-chain/default-chain";
// Bypass `<DetectorChainProvider>` for advanced cases (typically tests
// that need to inject a synthetic chain config without the provider's
// memoisation semantics).
function StaticChainScope(props: { children: ReactNode }) {
return (
<DetectorChainContext.Provider
value={{
detectors: defaultDetectorChain({ fallback: "en-GB" }),
}}
>
{props.children}
</DetectorChainContext.Provider>
);
}
```
## API Reference
### `DetectorChainContext: Context<DetectorChainConfig | null>`
Standard React context; default value `null`. The associated config
shape is `DetectorChainConfig` from
`./use-locale-detection.types.ts`:
```ts
interface DetectorChainConfig {
readonly detectors: ReadonlyArray<LocaleDetector>;
readonly buildContext?: () => DetectorContext;
readonly initialLocale?: string;
readonly initialSource?: string;
}
```
## Implementation Notes
- The context value is intentionally a plain config object (not a
store) so React DevTools can introspect the registered detectors
by name without needing the chain runner's internals. The
configured detector list is therefore surface-visible via the
React DevTools Components panel which makes diagnosing chain
ordering issues during development a quick one-step inspection.
- A `null` default plus an explicit hook-level error message is
preferred over a constructed default (e.g. an empty chain) because
the empty default would silently surface as "no detection ran"
when the real failure is "Provider not mounted". Returning the
empty array would also make tests pass that should fail (a
consumer forgetting the Provider would still see an unresolved
chain), so the `null` discriminator preserves a fail-loud
contract during development.
- The `displayName` set on the context surfaces in React DevTools
as `"SuaveplanDetectorChainContext"` rather than the autogenerated
`Context.Provider`/`Context.Consumer` placeholder. This makes
multi-context trees easier to navigate and aligns with the
display-name convention used by the foundation
`TranslationContext`.

View File

@@ -0,0 +1,156 @@
# useLocaleDetection
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`useLocaleDetection` is the React surface that runs the configured
locale-detector chain on mount and exposes the result to descendants.
It works in tandem with `<DetectorChainProvider>` (paired in this
module) which carries the chain configuration and any
SSR-authoritative `initialLocale`.
The hook MUST be used inside both `<TranslationProvider>` and
`<DetectorChainProvider>` — it reads the active i18n instance from the
former and the chain detector list from the latter. The split lets the
foundation `<TranslationProvider>` remain decoupled from detector
concerns (the config schema does not yet carry a `detectors` field —
see Implementation Notes for the wiring story).
## Features
- Runs the chain on mount and stores `{ detected, source, isPending }`
- `redetect()` re-runs the chain (useful for switcher UIs and SSR
override flows)
- SSR contract: when `<DetectorChainProvider initialLocale=...>` is
set, the chain is NOT auto-run on mount — `initialLocale` is treated
as authoritative for the first render so client-side detectors do
not produce a hydration mismatch
- Calls `i18n.changeLanguage(detected)` automatically when the chain
resolves to a different locale than the currently active one
- Subscribes via `useSyncExternalStore` so multiple components reading
the hook receive consistent state on the same commit
## Usage: Basic Example
```tsx
import {
DetectorChainProvider,
useLocaleDetection,
} from "@suaveplan/react-i18n/hooks/use-locale-detection/use-locale-detection";
import { defaultDetectorChain } from "@suaveplan/react-i18n/detector/default-chain/default-chain";
import { TranslationProvider } from "@suaveplan/react-i18n";
function App() {
return (
<TranslationProvider config={{ defaultLocale: "en-GB" }}>
<DetectorChainProvider
detectors={defaultDetectorChain({ fallback: "en-GB" })}
>
<Page />
</DetectorChainProvider>
</TranslationProvider>
);
}
function Page() {
const { detected, source, isPending, redetect } = useLocaleDetection();
if (isPending) return <p>Detecting…</p>;
return (
<div>
<span>Locale: {detected ?? "unknown"}</span>
<small> (source: {source})</small>
<button type="button" onClick={() => redetect()}>
Re-run detection
</button>
</div>
);
}
```
## Usage: Advanced Example
```tsx
import { DetectorChainProvider } from "@suaveplan/react-i18n/hooks/use-locale-detection/use-locale-detection";
import { defaultDetectorChain } from "@suaveplan/react-i18n/detector/default-chain/default-chain";
// SSR scenario: pass `initialLocale` so the client-side detectors do
// NOT auto-run on mount. The user can still call `redetect()` from a
// switcher to override the SSR pick.
export function SsrTree(props: {
initialLocale: string;
initialSource: string;
children: ReactNode;
}) {
return (
<DetectorChainProvider
initialLocale={props.initialLocale}
initialSource={props.initialSource}
detectors={defaultDetectorChain({
fallback: "en-GB",
userPreferenceEndpoint: "/api/me/locale",
})}
buildContext={() => ({
supportedLocales: ["en-GB", "fr-FR", "ar-AE"],
fallbackLocale: "en-GB",
})}
>
{props.children}
</DetectorChainProvider>
);
}
```
## API Reference
### `useLocaleDetection(): UseLocaleDetectionResult`
Returns `{ detected, source, isPending, redetect }`. Throws when
called outside `<TranslationProvider>` OR
`<DetectorChainProvider>`.
### `<DetectorChainProvider>`
| Prop | Type | Description |
| -------------- | -------------------------- | ---------------------------------------------------------- |
| detectors | `LocaleDetector[]` (req) | Detectors fed to `createDetectorChain` |
| buildContext | `() => DetectorContext` | Optional supplier producing a fresh context per chain run |
| initialLocale | `string` | Authoritative SSR pick (skips auto-run on mount) |
| initialSource | `string` | Optional detector name for the SSR pick |
| children | `ReactNode` | Subtree rendered with the context in scope |
### `UseLocaleDetectionResult`
```ts
interface UseLocaleDetectionResult {
readonly detected: string | null;
readonly source: string | null;
readonly isPending: boolean;
redetect(): Promise<void>;
}
```
## Implementation Notes
- The hook is split from `<TranslationProvider>` because the
foundation Provider's config schema does not yet expose a
`detectors` field. Sub-wave 1c (`useLocale` / `setLocale`) and a
future provider-level detector option will fold this surface into
a single `<TranslationProvider detectors=…>` mount; until then the
paired `<DetectorChainProvider>` is the supported wiring.
- The hook calls `i18n.changeLanguage(detected)` automatically when
the chain resolves to a different locale than the active one. The
Provider's bus subscriptions handle the resulting React commit.
- `redetect()` always re-runs the chain — even after an SSR
`initialLocale` was supplied — so a switcher-driven user action can
override the SSR pick deterministically.
- The internal store uses `useSyncExternalStore` directly (not React
state) so concurrent renders observe a consistent snapshot of
`{ detected, source, isPending }` across the commit.
- Switcher-side persisting-detector walking (cookieDetector.cache,
localStorageDetector.cache, userPreferenceDetector.cache) is the
responsibility of the future `setLocale` surface (sub-wave 1c).
Until that lands, ad-hoc consumers can call
`createDetectorChain(detectors).cache(newLocale, ctx)` directly to
exercise the same surface.

View File

@@ -0,0 +1,94 @@
# useLocale
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`useLocale()` returns the active locale string and an updater that calls
`i18n.changeLanguage(next)`. Subscribes to the instance's
`languageChanged` bus event through `useSyncExternalStore`, so consumers
re-render on the same React commit cycle as the bus emission. The hook
is the canonical entry point for locale-aware UI — pickers, language
switchers, and components that branch on locale all use it instead of
reading from the context directly.
In the production no-Provider fallback (when `useI18n()` returns
`null`), `locale` is `undefined` and `setLocale` is a no-op resolving to
`Promise<void>`. The contract of returning the same shape under both
modes keeps consumer code free of branching: a switcher does not need to
know whether a Provider is mounted.
## Features
- Returns `{ locale, setLocale }` with a stable shape across in-Provider and no-Provider paths.
- Subscribes to `languageChanged` via `useSyncExternalStore` for concurrent-safe reads.
- `setLocale(next)` returns `Promise<void>` so callers can `await` namespace work in fancy switchers.
- Identity-fallback: `locale === undefined`, `setLocale === no-op` outside a Provider in production.
- Zero re-renders when the active locale does not change — the snapshot reader returns reference-equal strings.
## Usage: Basic Example
```tsx
import { useLocale } from "@suaveplan/react-i18n";
export function LocaleBadge() {
const { locale } = useLocale();
return <span>Current: {locale ?? "?"}</span>;
}
```
## Usage: Advanced Example
`setLocale` returns a Promise so a custom switcher can await the
language change and any namespace loads it triggers (when a backend is
registered) before re-enabling its UI. The pattern below shows a
language picker that disables itself during the change.
```tsx
import { useState } from "react";
import { useLocale } from "@suaveplan/react-i18n";
const SUPPORTED_LOCALES = ["en-GB", "fr-FR", "ar-AE", "zh-CN"] as const;
export function LanguagePicker() {
const { locale, setLocale } = useLocale();
const [pending, setPending] = useState(false);
return (
<select
value={locale ?? ""}
disabled={pending || locale === undefined}
onChange={async (event) => {
setPending(true);
try {
await setLocale(event.target.value);
} finally {
setPending(false);
}
}}
>
{SUPPORTED_LOCALES.map((code) => (
<option key={code} value={code}>
{code}
</option>
))}
</select>
);
}
```
## API Reference
### `useLocale(): UseLocaleReturn`
- **Returns**: `{ locale, setLocale }`.
- `locale: string | undefined` — current locale, or `undefined` in the no-Provider fallback.
- `setLocale: (next: string) => Promise<void>` — bound to `i18n.changeLanguage`. No-op + immediate resolve in the no-Provider fallback.
- **Throws**: `ReactI18nError(REACT_I18N.NO_PROVIDER)` outside a `<TranslationProvider>` in dev mode (inherited from `useI18n`).
## Implementation Notes
- The `subscribe` callback registered with `useSyncExternalStore` only fires on `languageChanged`. The `loaded` event (namespace loads) is bridged separately by the Provider, so locale-only consumers do not re-render on every backend read.
- `subscribeFactory` returns a no-op unsubscribe in the production no-Provider path so the `useSyncExternalStore` contract remains satisfied.
- `setLocale` is wrapped in `useCallback` keyed on `instance`, so descendants receiving it as a prop do not re-render when the locale changes — only when the instance reference changes.
- The snapshot reader returns the locale string directly. React uses reference equality on the snapshot to decide whether to schedule a re-render, and string interning in V8 makes equal locales reference-equal in practice; the hook does not need to wrap the snapshot in a stable carrier.

View File

@@ -0,0 +1,88 @@
# useNumber
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`useNumber(value, options?)` formats a number for the active locale via
`Intl.NumberFormat`. Memoised on `(resolvedLocale, value, options-hash)`
so a no-op re-render does not reach into `Intl.NumberFormat`.
Subscribes through `useLocale` so a `setLocale('de-DE')` re-renders
consumers with the German thousand-separator on the same React commit
cycle as the bus emission.
The hook keeps a process-level cache of `Intl.NumberFormat` instances
keyed on `(locale, options-hash)`. The first render with a fresh
combination pays the constructor cost; subsequent renders (across
components, providers, and tests) reuse the cached formatter.
## Features
- Memoised on `(locale, value, options-hash)` — no re-format on identical inputs.
- Process-level `Intl.NumberFormat` cache — the constructor is paid once per `(locale, options)` combination.
- Forwards the full `Intl.NumberFormatOptions` shape (`style`, `notation`, `useGrouping`, `signDisplay`, `minimumFractionDigits`, …).
- Production no-Provider fallback uses `'en-GB'` so the hook always returns a real string.
- Re-renders on `languageChanged` via the inherited `useLocale` subscription.
- `clearUseNumberCache()` exposed for tests / hot-reload scenarios.
## Usage: Basic Example
```tsx
import { useNumber } from "@suaveplan/react-i18n";
export function PriceTag(props: { readonly amount: number }) {
const formatted = useNumber(props.amount, {
style: "decimal",
minimumFractionDigits: 2,
});
return <span>{formatted}</span>;
}
```
## Usage: Advanced Example
The full `Intl.NumberFormatOptions` shape unlocks compact notation,
unit display, sign visibility, and per-locale digit grouping. The
pattern below shows a stat tile that switches between standard and
compact notation depending on magnitude.
```tsx
import { useNumber } from "@suaveplan/react-i18n";
export function StatTile(props: {
readonly value: number;
readonly unit: string;
}) {
const compact = props.value >= 10_000;
const formatted = useNumber(props.value, {
style: "unit",
unit: props.unit,
notation: compact ? "compact" : "standard",
compactDisplay: "short",
signDisplay: "exceptZero",
});
return <strong>{formatted}</strong>;
}
```
## API Reference
### `useNumber(value: number, options?: Intl.NumberFormatOptions): string`
- **Parameters**:
- `value` — the numeric value to format.
- `options?` — raw `Intl.NumberFormatOptions`. When omitted, the hook uses the locale's default decimal formatting.
- **Returns**: the formatted string.
- **Throws**: `ReactI18nError(REACT_I18N.NO_PROVIDER)` outside a `<TranslationProvider>` in dev mode (inherited from `useLocale → useI18n`).
### `clearUseNumberCache(): void`
Test-only escape hatch to reset the process-level `Intl.NumberFormat` cache. Useful for hot-reload tooling or property-based tests that span thousands of unique option combinations.
## Implementation Notes
- The cache is unbounded by design. `Intl.NumberFormat` instances are small (a handful of bytes) and the realistic upper bound on unique `(locale, options-hash)` combinations in a real application is in the dozens, not millions.
- The memo key is `(resolvedLocale, value, optionsHash, options)`. The explicit `options` reference is included so consumers who pass the same object across renders share the memo while consumers passing fresh objects with identical contents share via the hash.
- The process-level cache is shared across every consumer in the bundle. Two unrelated components requesting `useNumber(x, { style: 'percent' })` in `'en-US'` reach the same cached formatter.
- The default locale `'en-GB'` on the no-Provider fallback matches the broader package convention. This keeps the hook from throwing in production while still producing meaningful output.

View File

@@ -0,0 +1,104 @@
# usePlural
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`usePlural(count, locale?)` resolves a count to a CLDR plural category
(`'zero' | 'one' | 'two' | 'few' | 'many' | 'other'`) for the active or
explicit locale. Wraps `@suaveplan/i18n`'s `PluralizationEngine` so the
React surface does not duplicate any of the rule tables. The hook is
the building block for plural-aware UI: pluraliser components,
quantity-sensitive icons, and any place where a category branch is
needed without a full message-template render.
The engine uses `Intl.PluralRules` when the runtime supports the locale
and falls back to the hand-written rule registry (Welsh, Arabic,
Russian, Polish, …) otherwise. Subscribes through `useLocale` so a
`setLocale('ar-AE')` re-renders the consumer with the new category on
the same React commit cycle as the bus emission.
## Features
- Returns `{ category, rule, locale }` — `rule` is the underlying `PluralRuleFunction` for additional resolutions.
- Single shared `PluralizationEngine` singleton — built-in CLDR table is paid for once per process.
- Memoised on `(count, resolvedLocale)` so a no-op re-render does not re-resolve.
- Optional `locale` argument overrides the active locale.
- Falls back to `'en'` on the production no-Provider path.
## Usage: Basic Example
```tsx
import { usePlural } from "@suaveplan/react-i18n";
const PHRASES = {
one: "1 item in your cart",
other: "{count} items in your cart",
} as const;
export function CartLabel(props: { readonly count: number }) {
const { category } = usePlural(props.count);
const phrase = PHRASES[category as "one" | "other"] ?? PHRASES.other;
return <span>{phrase.replace("{count}", String(props.count))}</span>;
}
```
## Usage: Advanced Example
The `rule` field exposes the raw resolver so a tight loop (e.g. a
list-virtualiser rendering 1000 row pluralisations) can avoid the hook
overhead by resolving once and re-using the function. The pattern below
shows a stat block that pluralises every row against the same active
locale without re-running the full hook chain per row.
```tsx
import { useMemo } from "react";
import { usePlural } from "@suaveplan/react-i18n";
interface Row {
readonly id: string;
readonly count: number;
}
export function StatList(props: { readonly rows: readonly Row[] }) {
const { rule, locale } = usePlural(props.rows[0]?.count ?? 0);
const items = useMemo(
() =>
props.rows.map((row) => ({
...row,
category: rule(row.count),
})),
[props.rows, rule]
);
return (
<ul aria-label={`stats-${locale}`}>
{items.map((item) => (
<li key={item.id}>
{item.count} ({item.category})
</li>
))}
</ul>
);
}
```
## API Reference
### `usePlural(count: number, locale?: string): UsePluralReturn`
- **Parameters**:
- `count` — number to resolve. Negative numbers and decimals are forwarded to the engine unchanged.
- `locale?` — explicit locale override. When omitted, uses the active locale from `useLocale`.
- **Returns**: `{ category, rule, locale }`.
- `category: PluralCategory` — `'zero' | 'one' | 'two' | 'few' | 'many' | 'other'`.
- `rule: PluralRuleFunction` — `(count: number, ordinal?: boolean) => PluralCategory`.
- `locale: string` — locale that was actually resolved against.
- **Throws**: `ReactI18nError(REACT_I18N.NO_PROVIDER)` outside a `<TranslationProvider>` in dev mode (inherited from `useLocale → useI18n`).
## Implementation Notes
- The shared `PluralizationEngine` lives at module scope. The engine is read-only after construction (rule registration goes through a different surface), so a single instance is safe to share across providers, components, and tests.
- The memoisation key is `(count, resolvedLocale)` — `rule` is included in the return value but does not enter the key because it is derived from `resolvedLocale`. This keeps the memo stable across re-renders that did not change either input.
- `Intl.PluralRules` is preferred over the hand-written rule table when the runtime supports the locale, so the engine tracks CLDR over time without React-side maintenance.
- The `'en'` fallback on the no-Provider path mirrors the catalog convention — when no locale information is available, English is the safer default for cardinal and ordinal categories.

View File

@@ -0,0 +1,86 @@
# useRelativeTime
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`useRelativeTime(deltaMs, options?)` formats a millisecond delta as a
localised relative-time string ("in 5 minutes", "3 hours ago", "il y a
2 jours"). Wraps `Intl.RelativeTimeFormat` directly with a per-(locale,
style, numeric) cache so the formatter constructor is paid once per
process per option combination. Memoised on `(resolvedLocale, deltaMs,
options-hash)` so a no-op re-render does not reach into the cache or
the formatter.
When the `unit` option is omitted the hook auto-selects the largest
unit whose absolute value is ≥ 1 (per the universal-core
`formatRelativeTime` contract). Pass `unit` explicitly when a fixed
granularity matters — e.g. always render activity timestamps in
minutes within the first hour for consistency with a refresh interval.
## Features
- Auto-selects unit (seconds → years) when `unit` is omitted.
- Forwards `style` (`'long' | 'short' | 'narrow'`) and `numeric` (`'always' | 'auto'`).
- Per-(locale, style, numeric) cache of `Intl.RelativeTimeFormat` instances.
- Memoised on `(locale, deltaMs, options-hash)` — no re-format on identical inputs.
- Production no-Provider fallback uses `'en-GB'` so the hook always returns a real string.
- Re-renders on `languageChanged` via the inherited `useLocale` subscription.
- `clearUseRelativeTimeCache()` exposed for tests / hot-reload scenarios.
## Usage: Basic Example
```tsx
import { useRelativeTime } from "@suaveplan/react-i18n";
export function PostedAt(props: { readonly postedAt: Date }) {
const delta = props.postedAt.getTime() - Date.now();
const formatted = useRelativeTime(delta);
return <time dateTime={props.postedAt.toISOString()}>{formatted}</time>;
}
```
## Usage: Advanced Example
For activity feeds that update once per second, pin the unit to
`'seconds'` for the first minute and `'minutes'` thereafter. The
pattern below shows a row that picks the unit explicitly to avoid
stuttering between auto-selected units near boundary thresholds.
```tsx
import { useRelativeTime } from "@suaveplan/react-i18n";
export function LiveTimestamp(props: { readonly when: Date }) {
const delta = props.when.getTime() - Date.now();
const abs = Math.abs(delta);
const unit = abs < 60_000 ? "seconds" : "minutes";
const formatted = useRelativeTime(delta, {
style: "short",
numeric: "always",
unit,
});
return <span data-live="true">{formatted}</span>;
}
```
## API Reference
### `useRelativeTime(deltaMs: number, options?: UseRelativeTimeOptions): string`
- **Parameters**:
- `deltaMs` — delta in milliseconds. Positive = future, negative = past, zero = now.
- `options?` — `style`, `numeric`, `unit`. When `unit` is omitted, the hook auto-selects.
- **Returns**: the formatted relative-time string.
- **Throws**: `ReactI18nError(REACT_I18N.NO_PROVIDER)` outside a `<TranslationProvider>` in dev mode (inherited from `useLocale → useI18n`).
### `clearUseRelativeTimeCache(): void`
Test-only escape hatch to reset the process-level `Intl.RelativeTimeFormat` cache.
## Implementation Notes
- The auto-select thresholds match the universal-core `formatRelativeTime` table: `60 s → minutes`, `60 min → hours`, `24 h → days`, `7 d → weeks`, `30.4375 d → months` (Gregorian average), `365.25 d → years` (Julian average). This keeps React-side and server-side renders stable across the boundary.
- The unit conversion rounds via `Math.round` on the way down. Intentionally — a 30.5-second delta rounds to "in 31 seconds" rather than truncating to "in 30 seconds" which would feel wrong to the user staring at a one-second-resolution clock.
- The cache key is `(locale, style, numeric)` — `unit` does NOT enter the cache because `Intl.RelativeTimeFormat.format(value, unit)` accepts `unit` per call. One formatter handles all units for the same (locale, style, numeric) combination.
- The default locale `'en-GB'` on the no-Provider fallback matches the broader package convention. This keeps the hook from throwing in production while still producing meaningful output.

View File

@@ -0,0 +1,96 @@
# useTranslate
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`useTranslate(namespace?)` is the terse alias of `useTranslation`. It
returns just the bound translator function — useful when the consumer
only needs to render translated text and does not care about the
`{ ready, i18n }` extras. The hook composes `useTranslation` directly,
so all of Suspense, the error-boundary cause-chain on namespace-load
failure, and the production no-Provider identity fallback flow through
unchanged.
The intended use case is leaf components that only stamp out text. For
anything that needs to read the underlying `I18nInstance`, observe the
`ready` flag, or branch on the current locale, prefer `useTranslation`.
## Features
- Returns the bound `t(key, options?)` function from `useTranslation`.
- Optional namespace prefix forwarded to `useTranslation(namespace)`.
- Inherits Suspense + error-boundary behaviour from `useTranslation`.
- Inherits the production no-Provider identity-translator soft-fail.
- Zero extra allocations beyond the underlying `useTranslation` call.
## Usage: Basic Example
```tsx
import { useTranslate, TranslationProvider } from "@suaveplan/react-i18n";
import { createI18n } from "@suaveplan/i18n/plugin";
function Greeting() {
const t = useTranslate();
return <span>{t("hello")}</span>;
}
const i18n = createI18n({
defaultLocale: "en-GB",
messages: { "en-GB": { hello: "Hi there" } },
});
export function App() {
return (
<TranslationProvider i18n={i18n}>
<Greeting />
</TranslationProvider>
);
}
```
## Usage: Advanced Example
The terse form composes naturally with namespace prefixes and the full
options bag — the returned function is identical to the one
`useTranslation(namespace).t` returns, so anything that translator
supports also works here. The pattern below shows interpolation, plural
selection through the catalog's `count` shorthand, and a default-value
fallback when a key is absent.
```tsx
import { useTranslate } from "@suaveplan/react-i18n";
export function CartSummary(props: { readonly itemCount: number }) {
const t = useTranslate("cart");
return (
<output>
{t("summary", { count: props.itemCount })}
{" — "}
{t("subtotal", {
values: { amount: 12.5 },
defaultValue: "Subtotal pending",
})}
</output>
);
}
```
## API Reference
### `useTranslate(namespace?: string): UseTranslationT`
- **Parameters**:
- `namespace?` — optional namespace passed straight through to `useTranslation`.
- **Returns**: the bound translator `(key: string, options?: UseTranslationOptions) => string`.
- **Throws**:
- **Promise** under Suspense semantics when the namespace must be loaded.
- **`ReactI18nError(REACT_I18N.NAMESPACE_LOAD_FAILED)`** when the preload Promise rejects.
- **`ReactI18nError(REACT_I18N.NO_PROVIDER)`** outside a `<TranslationProvider>` in development.
## Implementation Notes
- The hook is intentionally a one-line composition (`useTranslation(namespace).t`). Keeping the body tiny means future changes to Suspense / no-Provider semantics in `useTranslation` propagate automatically.
- The function reference returned is the SAME `t` function `useTranslation` returns — destructuring `useTranslation(ns).t === useTranslate(ns)` for the same render. This keeps the single-function form interchangeable with the triple-return form when refactoring between the two.
- Prefer `useTranslation` when the consumer needs `ready` or wants to check `i18n` for advanced operations like switching the active locale or subscribing to bus events.

View File

@@ -0,0 +1,114 @@
# useTranslation
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`useTranslation(namespace?)` is the primary translation hook of
`@suaveplan/react-i18n`. It returns a `{ t, i18n, ready }` triple bound
to the active `I18nInstance` (from the nearest `<TranslationProvider>`)
and orchestrates Suspense-aware namespace loading when a backend is
registered. Components subscribe to locale changes through the Provider's
internal `useSyncExternalStore`, so a `setLocale('fr-FR')` call triggers
re-renders on the same React commit cycle as the bus emission.
The hook splits cleanly into three control-flow modes — synchronous-ready
(no namespace work needed), Suspense-throw (preload in flight), and
error-boundary-throw (preload rejected). All three are testable without
mocking the i18n core: the tests use a real `I18nInstance` plus a tiny
in-test `Backend` that exposes `resolve` / `reject` controls.
## Features
- Returns `{ t, i18n, ready }` where `t` is the ergonomic options-bag translator.
- Optional `namespace` argument auto-prefixes every `t(key)` call with `<namespace>:`.
- Suspense-aware: throws `i18n.catalog.preload([locale], [namespace])` when the namespace is not loaded and a backend is registered.
- Error boundary aware: a rejected preload Promise surfaces as `ReactI18nError(REACT_I18N.NAMESPACE_LOAD_FAILED)` with `error.cause` set to the original `I18nError`.
- Production no-Provider soft-fail returns the identity translator (`t(key) === key` or `defaultValue` when supplied).
- Per-(instance, locale, namespace) preload registry keeps the Suspense throw stable across re-renders so the boundary actually resolves.
## Usage: Basic Example
```tsx
import { TranslationProvider, useTranslation } from "@suaveplan/react-i18n";
import { createI18n } from "@suaveplan/i18n/plugin";
function LoginButton() {
const { t } = useTranslation("auth");
return <button type="submit">{t("login")}</button>;
}
const i18n = createI18n({
defaultLocale: "en-GB",
messages: { "en-GB": { auth: { login: "Sign in" } } },
});
export function App() {
return (
<TranslationProvider i18n={i18n}>
<LoginButton />
</TranslationProvider>
);
}
```
## Usage: Advanced Example
When a backend is registered on the instance and the namespace has not
yet been loaded, the hook participates in React Suspense. Wrap the
subtree in `<Suspense>` and the boundary handles the loading state.
A rejected backend read surfaces through the nearest error boundary as
`ReactI18nError(REACT_I18N.NAMESPACE_LOAD_FAILED)` with the underlying
`I18nError` available via `.cause`.
```tsx
import { Suspense } from "react";
import { TranslationProvider, useTranslation } from "@suaveplan/react-i18n";
import { ErrorBoundary } from "react-error-boundary";
function Profile() {
const { t } = useTranslation("profile");
return (
<section>
<h1>{t("title")}</h1>
<p>{t("subtitle", { values: { name: "Alice" } })}</p>
</section>
);
}
export function ProfilePage(props: { i18n: import("@suaveplan/i18n/plugin").I18nInstance }) {
return (
<TranslationProvider i18n={props.i18n}>
<ErrorBoundary fallbackRender={({ error }) => <pre>{error.code}</pre>}>
<Suspense fallback={<span>Loading…</span>}>
<Profile />
</Suspense>
</ErrorBoundary>
</TranslationProvider>
);
}
```
## API Reference
### `useTranslation(namespace?: string): UseTranslationReturn`
- **Parameters**:
- `namespace?` — optional namespace prefix applied to every `t(key)` call.
- **Returns**: `{ t, i18n, ready }`.
- `t(key, options?)` — bound translator. `options` accepts `count`, `context`, `defaultValue`, `values`.
- `i18n` — the active `I18nInstance`, or `undefined` in the production no-Provider fallback.
- `ready` — `true` when the namespace is loaded (or no namespace was requested), `false` only on the production no-Provider path.
- **Throws**:
- **Promise** when a backend is registered, the namespace is not loaded, and Suspense semantics should kick in. The Promise resolves to `void` once the catalog finishes loading.
- **`ReactI18nError(REACT_I18N.NAMESPACE_LOAD_FAILED)`** when the preload Promise rejects. `error.cause` carries the original `I18nError` (e.g. `BACKEND_READ_FAILED`).
- **`ReactI18nError(REACT_I18N.NO_PROVIDER)`** when called outside a `<TranslationProvider>` and `process.env.NODE_ENV !== "production"`.
## Implementation Notes
- The Suspense throw uses a per-(instance, locale, namespace) entry stored in a `WeakMap<I18nInstance, Map<string, PreloadEntry>>`. React renders the same component multiple times during a suspension cascade — without this stability the boundary would never resolve.
- `applyNamespace` does NOT prepend the namespace when the caller-supplied key already contains `":"`. This matches the upstream catalog convention so a key like `profile:title` always means namespace `profile`, key `title`.
- The hook intentionally does NOT call `useSyncExternalStore` itself. The Provider holds the only subscription and re-renders descendants on `languageChanged` / `loaded` so per-render cost stays at one `useContext` read.
- The bound translator is rebuilt on every render (via `buildBoundTranslator`) — it captures the current `instance.locale` implicitly through `instance.t`, so a locale change re-renders subscribers AND yields a fresh closure.
- Production no-Provider fallback uses a lightweight identity translator built inside the hook (NOT `IDENTITY_TRANSLATION_RETURN.t`) so it understands the `options.defaultValue` precedence exactly the same way the live translator does.

View File

@@ -0,0 +1,152 @@
# `react-i18n-metrics.ts`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Defines the OTel instruments and the in-memory state ring for the
React-i18n package's observability surface. Three instruments live here:
1. `react_i18n.missing_key.total` (counter) — incremented every time a
`<Trans>` render or hook lookup encounters a key that is not present
in the active catalog. Labelled by `locale`, `namespace`, and
optionally `componentName` (only when the React Profiler API
surfaces a component name).
2. `react_i18n.suspense.fallback.total` (counter) — incremented every
time a Suspense fallback was rendered for a namespace load. Labelled
by `namespace` and `locale`.
3. `react_i18n.render.duration_ms` (histogram) — wall-clock duration of
`<Trans>` `formatChunks` renders, in milliseconds. Labelled by
`locale` and optionally `namespace` and `componentName`.
The instruments are built lazily via
`metrics.getMeter("suaveplan.react-i18n")` and cached. All three are
no-ops when no MeterProvider is registered (the default), so consumers
pay zero overhead unless they wire the SDK.
In addition to emitting through the OTel API, this module retains a
small in-memory state ring so spec-conformance tests can assert on
exact attribute payloads without standing up a full SDK +
InMemoryMetricExporter pipeline.
## Features
- Lazy-cached instruments via `getReactI18nMetrics()` — single meter
resolved at first call, reused across every record helper.
- Per-record helpers that handle the `Record<string, string>`
attribute construction so call sites stay literal.
- In-memory state ring with the same attribute shape as the OTel
payload — tests can pattern-match either surface and get identical
data.
- Test-only `resetReactI18nMetricsCache()` lets a test substitute a
fresh MeterProvider between cases.
## Usage: Basic Example
```ts
import {
recordMissingKey,
recordSuspenseFallback,
recordRenderDuration,
} from "@suaveplan/react-i18n/observability";
recordMissingKey({ locale: "en-GB", namespace: "auth" });
recordSuspenseFallback({ namespace: "auth", locale: "en-GB" });
recordRenderDuration(1.7, { locale: "en-GB" });
```
## Usage: Advanced Example
```ts
import { MeterProvider } from "@opentelemetry/sdk-metrics";
import { metrics } from "@opentelemetry/api";
import {
recordMissingKey,
resetReactI18nMetricsState,
resetReactI18nMetricsCache,
} from "@suaveplan/react-i18n/observability";
const provider = new MeterProvider();
metrics.setGlobalMeterProvider(provider);
resetReactI18nMetricsCache();
resetReactI18nMetricsState();
recordMissingKey({
locale: "en-GB",
namespace: "auth",
componentName: "LoginForm",
});
```
## API Reference
### `getReactI18nMetrics(): { missingKeyTotal, suspenseFallbackTotal, renderDurationMs }`
Idempotent factory for the three instruments. Returns the cached bundle
on subsequent calls; resets via `resetReactI18nMetricsCache()`.
### `recordMissingKey({ locale, namespace, componentName? }): void`
Increments `react_i18n.missing_key.total` by 1 and pushes a sample on
the in-memory state ring. `componentName` is forwarded only when
defined (so absent samples don't carry a sentinel `undefined`
attribute).
### `recordSuspenseFallback({ namespace, locale }): void`
Increments `react_i18n.suspense.fallback.total` by 1 and pushes a
sample.
### `recordRenderDuration(durationMs, { locale, namespace?, componentName? }): void`
Records `durationMs` on `react_i18n.render.duration_ms` and pushes a
sample.
### `getReactI18nMetricsState(): ReactI18nMetricsState`
Returns the in-memory state object (live; mutations to the returned
object are visible to subsequent calls — tests that need a snapshot
should clone).
### `resetReactI18nMetricsState(): void`
Truncates all three sample arrays in place. Tests call this between
cases.
### `resetReactI18nMetricsCache(): void`
Drops the cached instrument bundle so the next `getReactI18nMetrics()`
builds fresh ones from the current global meter provider.
### `interface ReactI18nMetricSample`
`{ attributes: Record<string, string>; value?: number }`. Counters
omit `value`; histograms set `value` to the recorded duration.
### `interface ReactI18nMetricsState`
The shape of the in-memory state ring:
`{ missingKeyTotal, suspenseFallbackTotal, renderDurationMs }`, all
arrays of `ReactI18nMetricSample`.
## Implementation Notes
- The instruments are cached at module scope to avoid re-resolving the
meter on every record call. The cache is invalidated only by the
test-only `resetReactI18nMetricsCache()` — production code never
calls it.
- Attribute objects are constructed with optional fields conditionally
added (`if (value !== undefined) out.x = value`) rather than spread
with `undefined` sentinels. This keeps the OTel payload free of
`undefined` values that some exporters serialise as the literal
string `"undefined"`.
- The state ring grows unbounded across a process lifetime. Tests
truncate it via `resetReactI18nMetricsState()`; production code
doesn't observe it. If a future SLO requires bounded in-memory
samples, the truncation belongs in a separate test-helper module so
the production code path stays free of the bookkeeping cost.
- The naming follows OTel semantic-convention guidance: counters end in
`_total`, histograms end in `_ms` with explicit `unit: "ms"`. The
metric namespace `react_i18n.*` matches the package name, snake-case
per OTel convention.

View File

@@ -0,0 +1,101 @@
# `provider-context.ts`
## Purpose
Defines the React context that carries the active `I18nInstance` from
`@suaveplan/i18n` to every descendant of `<TranslationProvider>`. The
context is the single coupling point between the Provider and every
hook in `@suaveplan/react-i18n` — hooks read the instance through this
context, which means they all see the same instance even when the
Provider is mounted under a portal, a nested tree, or a Storybook
decorator.
The default value is intentionally `null` so the consuming hooks (which
land in sub-wave 1c) can detect a missing Provider ancestor and route
to either the dev-mode loud-failure throw
(`ReactI18nError(REACT_I18N.NO_PROVIDER)`) or the production-mode
soft-fail identity translator. A `null` default — rather than a
factory-default no-op instance — keeps the failure path explicit and
prevents silent fall-through to identity behaviour in development.
## Features
- React-native `createContext` so descendants get the standard
context-update behaviour (re-render on value-reference change) for
free.
- `displayName` set so React DevTools shows
`SuaveplanTranslationContext` instead of the anonymous `Context.Provider`
default.
- Exports the context object (not a hook) so subpath consumers can
`useContext(TranslationContext)` directly when they need raw access
outside the canonical hook surface.
## Usage: Basic Example
```tsx
import { useContext } from "react";
import { TranslationContext } from "@suaveplan/react-i18n/provider";
export function PreviewBadge() {
const i18n = useContext(TranslationContext);
if (!i18n) return null;
return <span>{i18n.catalog.defaultLocale}</span>;
}
```
## Usage: Advanced Example
```tsx
import { useContext } from "react";
import { TranslationContext } from "@suaveplan/react-i18n/provider";
import {
REACT_I18N_CODES,
ReactI18nError,
} from "@suaveplan/react-i18n/errors";
import { isProductionEnv } from "@suaveplan/react-i18n";
export function useI18n() {
const i18n = useContext(TranslationContext);
if (i18n) return i18n;
if (isProductionEnv()) {
return null;
}
throw new ReactI18nError(
REACT_I18N_CODES.NO_PROVIDER,
"react-i18n.no_provider",
{ context: { hook: "useI18n" } }
);
}
```
## API Reference
### `TranslationContext: React.Context<I18nInstance | null>`
The context whose value is the active `I18nInstance` (when a
`<TranslationProvider>` is mounted) or `null` (when no Provider is
present).
- Default value: `null`.
- `displayName`: `"SuaveplanTranslationContext"`.
## Implementation Notes
- The default of `null` is deliberate. A "no-op identity instance"
default would silently pass renders through in development and mask
the missing-Provider misconfiguration the dev-mode loud-failure path
exists to surface.
- The context is exported (rather than wrapped behind a `useI18n()`
hook) because the hook lives in a separate sub-wave (1c) and
consumers in 1b — primarily test fixtures, Storybook decorators,
and the Provider itself — need raw access. Exporting both the
context AND the eventual hook is the long-standing React pattern
proven across `react-router`, `react-query`, and dozens of other
ecosystem libraries.
- The display name is stamped at module load. React DevTools picks it
up automatically; production bundlers strip the assignment under
the standard `production` mode flag set by Webpack/Vite.
- The context value type is `I18nInstance | null`. The `null` arm
forces every reader to handle the no-Provider case explicitly,
which catches misconfigurations at type-check time rather than at
runtime.

View File

@@ -0,0 +1,170 @@
# `provider.tsx`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Defines `<TranslationProvider>`, the React component that mounts an
`I18nInstance` from `@suaveplan/i18n` and exposes it to descendants via
`TranslationContext`. Every hook in `@suaveplan/react-i18n` reads the
instance through this context, so the Provider is the single mount
point for translation state in the React tree.
The Provider operates in two modes:
1. **Caller-managed** — the consumer passes a pre-built `I18nInstance`
via the `i18n` prop. The Provider treats the instance as borrowed
and does NOT call `instance.dispose()` on unmount.
2. **Provider-managed** — the consumer passes an `I18nConfig` via the
`config` prop. The Provider builds the instance via `createI18n`
on mount and disposes it on unmount.
The mode discriminator runs through `TranslationProviderPropsSchema`
at render time so supplying both / neither raises a Zod parse error
that is easier to debug than a downstream null-instance crash.
## Features
- Dual-mode discriminator with Zod-validated runtime check.
- `useSyncExternalStore` subscription to the instance's
`languageChanged` and `loaded` event-bus emissions so every
descendant hook re-renders deterministically with the React commit
cycle that observed the event.
- SSR-safe: the server snapshot returns the same counter the first
client read returns, avoiding hydration mismatch.
- Caller-managed instances are NOT disposed on unmount; only
Provider-managed instances are.
- Stable `useMemo` dependencies so the instance is rebuilt only when
the `config` reference changes.
## Usage: Basic Example
```tsx
import { TranslationProvider } from "@suaveplan/react-i18n/provider";
import { createI18nConfig } from "@suaveplan/react-i18n/config";
const config = createI18nConfig({
defaultLocale: "en-GB",
initialMessages: { "en-GB": { hello: "Hi" } },
});
export function App({ children }) {
return (
<TranslationProvider config={config}>
{children}
</TranslationProvider>
);
}
```
## Usage: Advanced Example
Caller-managed instance shared across multiple Provider mounts (e.g.
test harnesses that swap subtrees):
```tsx
import { createI18n } from "@suaveplan/i18n";
import { TranslationProvider } from "@suaveplan/react-i18n/provider";
const sharedInstance = createI18n({
defaultLocale: "en-GB",
messages: { "en-GB": { hello: "Hi" } },
});
export function Story() {
return (
<TranslationProvider i18n={sharedInstance}>
<PreviewPanel />
</TranslationProvider>
);
}
// Later: dispose explicitly when the harness tears down.
await sharedInstance.dispose();
```
SSR hand-off:
```tsx
const config = createI18nConfig({
defaultLocale: serverChosenLocale,
initialMessages: serverPayload,
});
renderToString(
<TranslationProvider config={config}>
<App />
</TranslationProvider>
);
```
## API Reference
### `<TranslationProvider />`
#### Props
```ts
interface TranslationProviderProps {
i18n?: I18nInstance;
config?: I18nConfig;
children: ReactNode;
}
```
- `i18n` — caller-managed instance. Mutually exclusive with `config`.
- `config` — Provider-managed config. Mutually exclusive with `i18n`.
- `children` — subtree rendered with the `TranslationContext` in scope.
#### Behaviour
- Validates props through `TranslationProviderPropsSchema`. Both
fields supplied → Zod `custom` issue
`"TranslationProvider accepts either \`i18n\` or \`config\`, not both."`
Neither supplied → Zod `custom` issue
`"TranslationProvider requires one of \`i18n\` or \`config\`."`
- On mount with `config`, builds the instance via
`createI18n(toI18nInstanceOptions(config))`.
- On unmount with `config`, calls `instance.dispose()`.
- On unmount with `i18n`, leaves the instance alone.
- Subscribes to `languageChanged` / `loaded` on the instance's event
bus and re-renders descendants on every observed event.
### Internal helpers (not exported)
- `createSubscribe(instance)` — returns a `useSyncExternalStore`
subscriber that fans the bus's `languageChanged` and `loaded`
events into the React store-change callback.
- `createGetSnapshot(instance)` — returns a number-typed snapshot
reader. The number bumps on every observed bus emission so React
treats every event as a fresh snapshot. The same factory is used as
both the client and the server snapshot reader because the counter
is never bumped on the server (no subscribe runs there), so the read
is stable across hydration.
## Implementation Notes
- The snapshot counter lives on the instance via a `__reactI18nSnapshot`
carrier property. Stashing the counter on the instance (rather than
in a module-level `WeakMap`) keeps SSR snapshots deterministic across
multiple Provider mounts of the same instance.
- The `useEffect` cleanup intentionally compares the captured
`managedInstance` against the ref instead of always disposing.
This keeps the Provider safe against the React strict-mode "mount
twice" double-fire — the second mount's effect will dispose only
the second-mount-time instance.
- The Zod parse runs on every render rather than once on mount
because `TranslationProviderPropsSchema` is fast (sub-microsecond on
modern hardware) and a once-on-mount approach would silently allow
swapping `i18n` for `config` after first render — a dual-mode
swap that today's contract rejects.
- The Provider does NOT yet wire OTel spans on mount (that is a
follow-up sub-wave 1c task per `tasks.md` §4.2.5). Telemetry
instrument-resolution lives in `observability/react-i18n-metrics.ts`
and is exercised by the metric helpers from the components and
hooks that land in 1c–1g.
- The Provider does NOT yet wire the locale-detection chain
(`useLocaleDetection`, `defaultDetectorChain`, persisting-detector
walking on `setLocale`). Those land with the detector
implementations in sub-wave 1e per the BLOCKER doc handoff.

View File

@@ -0,0 +1,129 @@
# Built-in formatter plugins
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Six `FormatterPlugin` instances that implement the canonical
`number`, `currency`, `date`, `list`, `relative-time`, and `plural`
ids. `<RegistryProvider>` auto-registers all six on mount unless
`disableBuiltInFormatters` is set, so consumers using the registry
get the expected behaviour for each id without per-plugin imports.
Each built-in wraps the same per-formatter implementation the
existing per-type hooks (`useNumber`, `useDate`, …) call directly,
so swapping in a custom plugin under any of the canonical ids gives
the consumer a single hook to override and the per-type hooks
delegate transparently.
## Features
- One plugin per canonical formatter id — `number`, `currency`,
`date`, `list`, `relative-time`, `plural`.
- Per-`(locale, options)` caches for `Intl.NumberFormat`,
`Intl.ListFormat`, and `Intl.RelativeTimeFormat` so re-renders
with the same args do not re-construct Intl instances.
- Auto-unit-selection for relative-time when `unit` is omitted.
- Aggregated `BUILT_IN_FORMATTER_PLUGINS` array for one-line
registration in `<RegistryProvider>` and downstream tests.
- Typed args interfaces (`NumberFormatArgs`, `CurrencyFormatArgs`,
`DateFormatArgs`, `ListFormatArgs`, `RelativeTimeFormatArgs`,
`PluralFormatArgs`) shared with `useFormatter` so consumers get
full type-checking on `useFormatter("number", { value })`.
## Usage: Basic Example
```ts
import { numberFormatterPlugin } from "@suaveplan/react-i18n/registry";
import { createReactI18nRegistry } from "@suaveplan/react-i18n/registry";
const registry = createReactI18nRegistry();
registry.registerFormatter(numberFormatterPlugin);
const plugin = registry.getFormatter<{ value: number }, string>("number");
plugin?.format("en-GB", { value: 1234.5 }); // "1,234.5"
```
## Usage: Advanced Example
Override the built-in `currency` plugin with a BigDecimal-aware
implementation that formats large numbers in compact notation:
```ts
import {
BUILT_IN_FORMATTER_PLUGINS,
type CurrencyFormatArgs,
} from "@suaveplan/react-i18n/registry";
import { createReactI18nRegistry } from "@suaveplan/react-i18n/registry";
const registry = createReactI18nRegistry();
for (const plugin of BUILT_IN_FORMATTER_PLUGINS) {
registry.registerFormatter(plugin);
}
// Caller registers their replacement AFTER built-ins so the duplicate
// throws — the recommended pattern is to disable the built-ins first
// (via <RegistryProvider disableBuiltInFormatters>) and supply a
// curated array containing the replacement instead of the default.
```
The plural plugin returns a `{ category, locale }` result rather than
a string because consumers typically use the category to select a
message variant rather than render the category directly:
```ts
const plural = registry.getFormatter<{ count: number }, { category: Intl.LDMLPluralRule; locale: string }>(
"plural"
);
const { category } = plural!.format("ar-AE", { count: 0 }); // "zero" in Arabic, "other" in English
```
## API Reference
### `numberFormatterPlugin: FormatterPlugin<NumberFormatArgs, string>`
`format(locale, { value, options? })` → returns `Intl.NumberFormat(locale, options).format(value)` with a per-`(locale, options)` cache.
### `currencyFormatterPlugin: FormatterPlugin<CurrencyFormatArgs, string>`
`format(locale, { amount, currency, options? })` → delegates to `@suaveplan/i18n-formatter-currency`'s `formatCurrency`.
### `dateFormatterPlugin: FormatterPlugin<DateFormatArgs, string>`
`format(locale, { value, options? })` → delegates to `@suaveplan/i18n-formatter-date`'s `formatDate`. `value` accepts `Date | number`.
### `listFormatterPlugin: FormatterPlugin<ListFormatArgs, string>`
`format(locale, { values, options? })` → returns `Intl.ListFormat(locale, options).format(values)` with a per-`(locale, style, type)` cache. Defaults: `style: 'long'`, `type: 'conjunction'`.
### `relativeTimeFormatterPlugin: FormatterPlugin<RelativeTimeFormatArgs, string>`
`format(locale, { value, now?, unit?, options? })` → renders a relative time. When `unit` is omitted, auto-selects the largest unit whose magnitude is non-zero (seconds → minutes → hours → days → weeks → months → years).
### `pluralFormatterPlugin: FormatterPlugin<PluralFormatArgs, { category: Intl.LDMLPluralRule; locale: string }>`
`format(locale, { count })` → returns the CLDR plural category for `count` against `locale`. Backed by `PluralizationEngine` from `@suaveplan/i18n-icu-parser`.
### `BUILT_IN_FORMATTER_PLUGINS: readonly FormatterPlugin[]`
Aggregated array of all six built-ins in canonical id order. `<RegistryProvider>` consumes this array directly for auto-registration.
## Implementation Notes
- The Intl.* caches are shared at module scope so multiple registry
instances in the same process share the underlying Intl instances.
This is safe because `Intl.NumberFormat` / `Intl.ListFormat` /
`Intl.RelativeTimeFormat` are stateless.
- The auto-unit-selection thresholds for relative-time follow the
same boundaries as the per-hook `useRelativeTime` implementation
so consumers swapping between the hook and the registry-routed
path observe identical output.
- The plural plugin's return shape `{ category, locale }` is a
subset of the `useReturn` shape from the existing `usePlural` hook
(the hook also returns the raw rule function). The full hook
surface is preserved by the hook's direct-import fallback path; the
registry path is intentionally narrower because the rule function
is not serialisable across plugin boundaries.
- Each plugin is exported individually so consumers needing to
register a partial subset (e.g. only `number` and `currency` in a
finance app) can do so without pulling the full array.

View File

@@ -0,0 +1,78 @@
# Built-in locale adapters
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
A `LocaleAdapterPlugin<TLocale>` resolves a BCP-47 locale string into a library-specific locale object. Different third-party date / time / format libraries want different shapes — `react-day-picker` wants a date-fns `Locale`, luxon wants a `DateTime` with a zone, Temporal wants a `Calendar`. The locale-adapter slot lets a primitive (e.g. `<Calendar>`) ask for a stable id (`"date-fns"`) and trust the consumer to wire the actual library reference. The package itself stays library-agnostic — no hard dependency on date-fns / luxon / Temporal.
## Features
- `intlLocaleAdapterPlugin` — built-in identity adapter, auto-registered by `<RegistryProvider>` for any consumer using `Intl.*` primitives directly.
- `createDateFnsLocaleAdapter(options)` — factory returning a `"date-fns"` adapter from the consumer-supplied date-fns locale map. Resolves direct match first, then dashless date-fns id, then language-subtag fallback, then the configured `fallbackLocaleId`.
- `BUILT_IN_LOCALE_ADAPTER_PLUGINS` — aggregated array of built-ins (currently just `intl`) consumed by `<RegistryProvider>` for auto-registration.
## Usage: Basic Example
```ts
import { intlLocaleAdapterPlugin } from "@suaveplan/react-i18n/registry";
intlLocaleAdapterPlugin.resolve("en-GB"); // "en-GB"
```
## Usage: Advanced Example
Register the date-fns adapter using the consumer's own locale map (no hard dep on `date-fns`):
```tsx
import { createDateFnsLocaleAdapter } from "@suaveplan/react-i18n/registry";
import { enGB, frFR, zhCN } from "date-fns/locale";
const dateFnsAdapter = createDateFnsLocaleAdapter({
locales: { enGB, frFR, zhCN, "en-GB": enGB, "fr-FR": frFR, "zh-CN": zhCN },
fallbackLocaleId: "enGB",
});
<RegistryProvider plugins={[{ kind: "locale-adapter", plugin: dateFnsAdapter }]}>
<TranslationProvider config={i18nConfig}>
<App />
</TranslationProvider>
</RegistryProvider>;
```
A future luxon adapter follows the same shape:
```ts
import { type LocaleAdapterPlugin } from "@suaveplan/react-i18n";
import { DateTime } from "luxon";
const luxonAdapter: LocaleAdapterPlugin<DateTime> = {
id: "luxon",
resolve: (locale) => DateTime.now().setLocale(locale),
};
```
## API Reference
### `intlLocaleAdapterPlugin: LocaleAdapterPlugin<string>`
Identity adapter. `resolve(localeString)` returns the locale string verbatim.
### `BUILT_IN_LOCALE_ADAPTER_PLUGINS: readonly LocaleAdapterPlugin[]`
Aggregated array of built-ins, currently `[intlLocaleAdapterPlugin]`.
### `createDateFnsLocaleAdapter<TLocale>(options): LocaleAdapterPlugin<TLocale>`
- `options.locales`: `Readonly<Record<string, TLocale>>` — caller-supplied locale map. Keys may be either BCP-47 (`"en-GB"`) or date-fns convention (`"enGB"`); the adapter handles both.
- `options.fallbackLocaleId`: optional id used when nothing matches. Defaults to `"enUS"`.
Resolution order: direct key → dashless conversion → language subtag → `fallbackLocaleId` → first entry in the map. Throws when the map is empty.
## Implementation Notes
- The package does NOT depend on `date-fns`. The factory accepts the consumer's own date-fns import; the plugin does not pin a date-fns version.
- The dashless conversion (`"en-GB"` → `"enGB"`) handles the date-fns naming convention without forcing the consumer to remap their locale keys.
- Empty-map detection throws at `resolve()` time (not at factory-construction time) so the failure is observable from the call site that triggers it. A test that exercises an empty map needs to call `resolve()` explicitly.
- Future built-in additions (e.g. when Temporal `Calendar` ships across runtimes) extend `BUILT_IN_LOCALE_ADAPTER_PLUGINS` in canonical id order; the auto-registration path in `<RegistryProvider>` picks them up without further changes.

View File

@@ -0,0 +1,90 @@
# `runPostProcessors`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Runs a sequence of `PostProcessorPlugin` over a message string in deterministic order. Used inside `<Trans>` after ICU interpolation but BEFORE the final message is mounted into the DOM. The pipeline is the React-render-layer mirror of `@suaveplan/i18n`'s catalog-layer post-processors — the same `priority + id` scheme so a consumer who already understands the catalog API can register render-layer overlays without learning a second mental model. Pseudo-localization, debug overlays, redaction filters, and per-theme decorators all live in this pipeline.
## Features
- Deterministic execution order: ascending `priority`, ties broken lexicographically by `id`.
- Pure function — no React, no global state, no observability cost. Side-effects are entirely in the plugins themselves.
- Empty-plugin fast path returns the input message unchanged with no allocation.
- Does not mutate the input plugin array — sort runs against a local copy.
## Usage: Basic Example
```ts
import { runPostProcessors } from "@suaveplan/react-i18n/registry";
const message = runPostProcessors(
"Hello, world",
{ locale: "en-GB" },
[
{
id: "pseudo",
priority: 100,
process: (msg) => `[${msg}]`,
},
]
);
// => "[Hello, world]"
```
## Usage: Advanced Example
Two plugins in order — a redactor at priority 50 (runs first) and a pseudo-localizer at priority 100 (runs second, sees the redacted output):
```ts
const message = runPostProcessors(
"Card 4242424242424242 charged",
{ locale: "en-GB" },
[
{
id: "pseudo",
priority: 100,
process: (msg) => `[${msg}]`,
},
{
id: "redact-card",
priority: 50,
process: (msg) => msg.replace(/\d{16}/g, "****-****-****-****"),
},
]
);
// => "[Card ****-****-****-**** charged]"
```
Inside `<Trans>` the pipeline is fed by the active registry's post-processor list:
```tsx
import { runPostProcessors } from "@suaveplan/react-i18n/registry";
function Trans(props: TransProps) {
const registry = useOptionalReactI18nRegistry();
const interpolated = formatChunks(props);
const final = registry
? runPostProcessors(interpolated, { locale: useLocale().locale, key: props.i18nKey, namespace: props.ns }, registry.listPostProcessors())
: interpolated;
return <>{final}</>;
}
```
## API Reference
### `runPostProcessors(message, ctx, plugins): string`
- `message`: the message to process. Already ICU-interpolated by the time this function is called.
- `ctx`: `PostProcessorContext` — `{ locale, key?, namespace? }`.
- `plugins`: `readonly PostProcessorPlugin[]`. Order in this array is irrelevant — the pipeline sorts by `(priority, id)` internally.
Returns the message after every plugin has been applied.
## Implementation Notes
- The function is pure and side-effect-free. Plugins themselves can side-effect (logging, OTel) but the pipeline does not.
- Sort stability matters across the boundary between two plugins at the same `priority` with different `id`s — lexicographic tie-break makes the order deterministic regardless of the input array's insertion order.
- The pipeline does not catch plugin errors; a throwing plugin propagates the error to `<Trans>`, which surfaces it as a render error. Plugins that need defensive behavior (e.g. "redact best-effort") must implement their own try/catch around the transform.
- The empty-plugin fast path is a measurable win — `<Trans>` renders are hot, and the common case (no plugins registered) costs a single `length === 0` check.

View File

@@ -0,0 +1,54 @@
# `RegistryContext`
## Purpose
Module-scoped React context that carries the active `ReactI18nRegistry` to descendants of `<RegistryProvider>`. Direct consumption is rare — the hooks `useReactI18nRegistry()` and `useOptionalReactI18nRegistry()` are the public surface. The context is exported primarily so packages that need to compose with the registry at the React internals level (e.g. `<TranslationProvider>` reading the registry to wire post-processors into `<Trans>`) can do so without adding a dependency on the hooks.
## Features
- Default value of `null` so consuming hooks can distinguish "no provider mounted" from "registry mounted but empty".
- `displayName` set to `"SuaveplanReactI18nRegistryContext"` so it shows up clearly in React DevTools.
- Module-scoped singleton — the same `RegistryContext` reference is observed across every consumer in the same bundle, preserving brand identity through SSR and hot-reload.
## Usage: Basic Example
```tsx
import { useContext } from "react";
import { RegistryContext } from "@suaveplan/react-i18n/registry";
function Inspector(): JSX.Element | null {
const registry = useContext(RegistryContext);
if (!registry) return null;
return <pre>{registry.listFormatters().map(p => p.id).join("\n")}</pre>;
}
```
## Usage: Advanced Example
When composing with `<TranslationProvider>` to wire registered post-processors into the `<Trans>` render path, the provider reads the context directly so it can make a structural decision (post-processors present? wrap message rendering with `runPostProcessors`):
```tsx
import { useContext } from "react";
import { RegistryContext } from "@suaveplan/react-i18n/registry";
function MessageRenderer({ message, locale }: { message: string; locale: string }): JSX.Element {
const registry = useContext(RegistryContext);
if (!registry) return <>{message}</>;
const plugins = registry.listPostProcessors();
if (plugins.length === 0) return <>{message}</>;
const processed = runPostProcessors(message, { locale }, plugins);
return <>{processed}</>;
}
```
## API Reference
### `RegistryContext: React.Context<ReactI18nRegistry | null>`
The React context. Default value `null`. Provider supplied by `<RegistryProvider>`. Consumers should normally use `useReactI18nRegistry()` or `useOptionalReactI18nRegistry()` instead of consuming the context directly.
## Implementation Notes
- The context is exported as a named export rather than a default export so a consuming package can `import { RegistryContext } from "@suaveplan/react-i18n/registry"` without naming collisions with similarly-named contexts elsewhere in the React tree.
- The `displayName` is required for React DevTools clarity. Without it the context appears as anonymous `Context.Provider` / `Context.Consumer` — opaque when debugging.
- The `null` default is intentional. A non-null sentinel (e.g. an empty no-op registry) would make the strict `useReactI18nRegistry()` hook unable to distinguish "missing provider" from "provider with no plugins"; that distinction is precisely what the strict / tolerant hook split exists to express.

View File

@@ -0,0 +1,97 @@
# `<RegistryProvider>`
## Purpose
`<RegistryProvider>` is the React-context surface for `ReactI18nRegistry`. It mounts a fresh registry per tree, registers any plugins passed via the `plugins` prop synchronously on first render (so descendants observe them on the first paint, not after a `useEffect` tick), and disposes every issued handle on unmount. Compose it OUTSIDE `<TranslationProvider>` so the i18n message-render pipeline (which consumes post-processor plugins) and locale-detection chain (which consumes detector plugins) can pick up the registry on their own first render.
## Features
- Synchronous plugin registration via `useState` lazy initialiser — children see the populated registry on first render.
- Discriminated-union `RegistryPlugin` payload covers all four plugin kinds (detector, formatter, post-processor, locale-adapter).
- Optional `registry` prop accepts a pre-built `ReactI18nRegistry` for tests / advanced sharing scenarios.
- `disableBuiltInFormatters` / `disableBuiltInLocaleAdapters` opt out of the auto-registered built-ins (Phase 4 / 4b — slots reserved in this phase).
- Disposal in reverse registration order on unmount.
- The provider only disposes the handles it issued — an externally supplied registry is preserved for the caller.
## Usage: Basic Example
```tsx
import { RegistryProvider } from "@suaveplan/react-i18n/registry";
<RegistryProvider>
<TranslationProvider config={i18nConfig}>
<App />
</TranslationProvider>
</RegistryProvider>
```
## Usage: Advanced Example
Mounting with a custom detector plugin (e.g. an A/B-bucket detector that runs before every built-in) and a pseudo-localizer post-processor for dev-only diagnostics:
```tsx
import { RegistryProvider, type RegistryPlugin } from "@suaveplan/react-i18n/registry";
const abBucketDetector: RegistryPlugin = {
kind: "detector",
plugin: {
id: "ab-bucket",
priority: 0, // splice before all built-in detectors
detect: {
name: "ab-bucket",
environment: "browser",
detect: () => sessionStorage.getItem("ab.locale") ?? null,
},
},
};
const pseudoLocalizer: RegistryPlugin = {
kind: "post-processor",
plugin: {
id: "pseudo",
priority: 100,
process: (msg) => `[${msg}]`,
},
};
<RegistryProvider plugins={import.meta.env.DEV ? [abBucketDetector, pseudoLocalizer] : [abBucketDetector]}>
<TranslationProvider config={i18nConfig}>
<App />
</TranslationProvider>
</RegistryProvider>
```
For tests that need to inspect the registry directly:
```tsx
import { createReactI18nRegistry, RegistryProvider } from "@suaveplan/react-i18n/registry";
const registry = createReactI18nRegistry();
render(
<RegistryProvider registry={registry}>
<App />
</RegistryProvider>
);
expect(registry.listFormatters()).toHaveLength(0);
```
## API Reference
### `<RegistryProvider plugins? disableBuiltInFormatters? disableBuiltInLocaleAdapters? registry? children>`
- `plugins`: `readonly RegistryPlugin[]` — discriminated union over `kind`. Registered synchronously on mount.
- `disableBuiltInFormatters`: `boolean` — when `true`, skip the built-in formatter auto-registration (Phase 4).
- `disableBuiltInLocaleAdapters`: `boolean` — when `true`, skip the built-in date-fns locale-adapter auto-registration (Phase 4b).
- `registry`: `ReactI18nRegistry` — optional external registry. Provider does not dispose this registry on unmount; only the handles it issued are disposed.
- `children`: `ReactNode` — required.
### `RegistryPlugin`
Discriminated union: `{ kind: "detector"; plugin: DetectorPlugin } | { kind: "formatter"; plugin: FormatterPlugin } | { kind: "post-processor"; plugin: PostProcessorPlugin } | { kind: "locale-adapter"; plugin: LocaleAdapterPlugin }`.
## Implementation Notes
- The `useState` lazy initialiser pattern is intentional. A `useEffect` would register plugins after the first render — descendants would see an empty registry on their first paint and a populated one after the next paint, causing a flicker for any plugin-dependent rendering.
- Built-in formatter / locale-adapter registration is gated by `disable*` flags in this phase even though the catalogue itself ships in Phase 4 / 4b. This lets consumers wire `disableBuiltInFormatters` once and have the prop work consistently across releases.
- The provider does not re-register on `plugins` prop change. The recommended idiom is to remount the provider with a new key when the plugin set genuinely changes; otherwise treat the `plugins` array as a one-time configuration.
- Disposal walks `handles.reverse()` so plugins registered later are disposed first (LIFO).

View File

@@ -0,0 +1,158 @@
# `createReactI18nRegistry`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
- [built-in](./formatters/built-in/built-in.md) — <!-- begin:auto-docs --> _(no submodules)_- [built-in](./locale-adapters/built-in/built-in.md) — <!-- begin:auto-docs --> _(no submodules)_ - [built-in](./locale-adapters/built-in/built-in.md) — <!-- begin:auto-docs --> _(no submodules)_- [post-processors](./post-processors/post-processors.md) — <!-- begin:auto-docs --> _(no submodules)_ - [post-processors](./post-processors/post-processors.md) — <!-- begin:auto-docs --> _(no submodules)_- [registry-context](./registry-context.md) — Module-scoped React context that carries the active `ReactI18nRegistry` to descendants of `<RegistryProvider>`. Direct…
- [registry-provider](./registry-provider.md) — `<RegistryProvider>` is the React-context surface for `ReactI18nRegistry`. It mounts a fresh registry per tree, registe…
- [registry](./registry.md) — <!-- begin:auto-docs --> _(no submodules)_- built-in — <!-- begin:auto-docs --> _(no submodules)…
- [registry.schemas](./registry.schemas.md) — Strict-object Zod schemas for every plugin kind accepted by `ReactI18nRegistry`. The schemas are the boundary at which…
- [use-registry](./use-registry.md) — These hooks read the active `ReactI18nRegistry` from React context. The strict variant `useReactI18nRegistry()` throws…
- [built-in](./formatters/built-in/built-in.md) — <!-- begin:auto-docs --> _(no submodules)_- [built-in](./locale-adapters/built-in/built-in.md) — <!-- begin:auto-docs --> _(no submodules)_- [built-in](./locale-adapters/built-in/built-in.md) — <!-- begin:auto-docs --> _(no submodules)_- [post-processors](./post-processors/post-processors.md) — <!-- begin:auto-docs --> _(no submodules)_- [post-processors](./post-processors/post-processors.md) — <!-- begin:auto-docs --> _(no submodules)_- [registry-context](./registry-context.md) — Module-scoped React context that carries the active `ReactI18nRegistry` to descendants of `<RegistryProvider>`. Direct…
- [registry-provider](./registry-provider.md) — `<RegistryProvider>` is the React-context surface for `ReactI18nRegistry`. It mounts a fresh registry per tree, registe…
- [registry](./registry.md) — <!-- begin:auto-docs --> _(no submodules)_- [registry.schemas](./registry.schemas.md) — Strict-object Zod schemas for every plugin kind accepted by `ReactI18nRegistry`. The schemas are the boundary at which…
- [use-registry](./use-registry.md) — These hooks read the active `ReactI18nRegistry` from React context. The strict variant `useReactI18nRegistry()` throws…
- [built-in](./formatters/built-in/built-in.md) — <!-- begin:auto-docs --> _(no submodules)_- [built-in](./locale-adapters/built-in/built-in.md) — <!-- begin:auto-docs --> _(no submodules)_- [built-in](./locale-adapters/built-in/built-in.md) — <!-- begin:auto-docs --> _(no submodules)_- [post-processors](./post-processors/post-processors.md) — <!-- begin:auto-docs --> _(no submodules)_- [post-processors](./post-processors/post-processors.md) — <!-- begin:auto-docs --> _(no submodules)_- [registry-context](./registry-context.md) — Module-scoped React context that carries the active `ReactI18nRegistry` to descendants of `<RegistryProvider>`. Direct…
- [registry-provider](./registry-provider.md) — `<RegistryProvider>` is the React-context surface for `ReactI18nRegistry`. It mounts a fresh registry per tree, registe…
- [registry](./registry.md) — <!-- begin:auto-docs --> _(no submodules)_- built-in — <!-- begin:auto-d…
- [registry.schemas](./registry.schemas.md) — Strict-object Zod schemas for every plugin kind accepted by `ReactI18nRegistry`. The schemas are the boundary at which…
- [use-registry](./use-registry.md) — These hooks read the active `ReactI18nRegistry` from React context. The strict variant `useReactI18nRegistry()` throws…
- [built-in](./formatters/built-in/built-in.md) — <!-- begin:auto-docs --> _(no submodules)_- [built-in](./locale-adapters/built-in/built-in.md) — <!-- begin:auto-docs --> _(no submodules)_- [built-in](./locale-adapters/built-in/built-in.md) — <!-- begin:auto-docs --> _(no submodules)_- [post-processors](./post-processors/post-processors.md) — <!-- begin:auto-docs --> _(no submodules)_- [post-processors](./post-processors/post-processors.md) — <!-- begin:auto-docs --> _(no submodules)_- [registry-context](./registry-context.md) — Module-scoped React context that carries the active `ReactI18nRegistry` to descendants of `<RegistryProvider>`. Direct…
- [registry-provider](./registry-provider.md) — `<RegistryProvider>` is the React-context surface for `ReactI18nRegistry`. It mounts a fresh registry per tree, registe…
- [registry](./registry.md) — <!-- begin:auto-docs --> _(no submodules)_- built-in — <!-- begin:auto-docs --> _(no submodules…
- [registry.schemas](./registry.schemas.md) — Strict-object Zod schemas for every plugin kind accepted by `ReactI18nRegistry`. The schemas are the boundary at which…
- [use-registry](./use-registry.md) — These hooks read the active `ReactI18nRegistry` from React context. The strict variant `useReactI18nRegistry()` throws…
- [built-in](./formatters/built-in/built-in.md) — <!-- begin:auto-docs --> _(no submodules)_- [built-in](./locale-adapters/built-in/built-in.md) — <!-- begin:auto-docs --> _(no submodules)_ - [built-in](./locale-adapters/built-in/built-in.md) — <!-- begin:auto-docs --> _(no submodules)_- [post-processors](./post-processors/post-processors.md) — <!-- begin:auto-docs --> _(no submodules)_ - [post-processors](./post-processors/post-processors.md) — <!-- begin:auto-docs --> _(no submodules)_- [registry-context](./registry-context.md) — Module-scoped React context that carries the active `ReactI18nRegistry` to descendants of `<RegistryProvider>`. Direct…
- [registry-provider](./registry-provider.md) — `<RegistryProvider>` is the React-context surface for `ReactI18nRegistry`. It mounts a fresh registry per tree, registe…
- [registry](./registry.md) — <!-- begin:auto-docs --> _(no submodules)_- [registry.schemas](./registry.schemas.md) — Strict-object Zod schemas for every plugin kind accepted by `ReactI18nRegistry`. The schemas are the boundary at which…
- [use-registry](./use-registry.md) — These hooks read the active `ReactI18nRegistry` from React context. The strict variant `useReactI18nRegistry()` throws…
## Purpose
The plugin registry is the single substrate every extension surface in `@suaveplan/react-i18n` shares. It accepts four plugin kinds — detectors, formatters, post-processors, and locale-adapters — through a uniform registration / disposal / lookup API. The registry is the answer to the question "how does a third-party package extend the React i18n surface without forking?": instead of exposing N independent registration points (one per detector, one per formatter, …) the registry funnels every extension through one validated, observable, disposable contract. A `<RegistryProvider>` mounts a fresh registry per React tree, auto-registers built-in formatters and locale adapters, and exposes the registry to descendants through `useReactI18nRegistry()`.
## Features
- Four plugin kinds — `DetectorPlugin`, `FormatterPlugin<TArgs, TResult>`, `PostProcessorPlugin`, `LocaleAdapterPlugin<TLocale>` — partitioned independently so the same `id` may legitimately appear under two kinds.
- Idempotent re-registration of the same plugin instance.
- Duplicate-id detection (different instance under same kind+id) throws a stable `REACT_I18N.PLUGIN_DUPLICATE_ID` (or `DETECTOR_PLUGIN_DUPLICATE_ID` for detector kind).
- Schema-driven shape validation at registration time — malformed plugins throw `REACT_I18N.PLUGIN_INVALID` (or `DETECTOR_PLUGIN_INVALID`) with the Zod issue list attached as `cause`.
- Reversible registration through `Disposable` handles. Disposal is idempotent.
- OTel observability: `react_i18n.registry.plugins_total{kind,outcome}` counter increments on every registered/disposed/duplicate/invalid event.
- Zero per-render cost: lookup is O(1) `Map.get` and the registry holds no React state itself.
## Usage: Basic Example
```ts
import { createReactI18nRegistry } from "@suaveplan/react-i18n/registry";
const registry = createReactI18nRegistry();
const handle = registry.registerFormatter({
id: "duration",
format(locale, args: { ms: number }) {
return new Intl.NumberFormat(locale).format(args.ms / 1000) + "s";
},
});
const plugin = registry.getFormatter<{ ms: number }, string>("duration");
plugin?.format("en-GB", { ms: 1500 }); // "1.5s"
handle.dispose();
```
## Usage: Advanced Example
Detector plugin with priority-driven splice into the default chain, plus a post-processor pipeline:
```ts
import { createReactI18nRegistry } from "@suaveplan/react-i18n/registry";
const registry = createReactI18nRegistry();
registry.registerDetector({
id: "ab-bucket",
priority: 0, // run before every built-in detector
detect: {
name: "ab-bucket",
environment: "browser",
detect: () => sessionStorage.getItem("ab.locale") ?? null,
},
});
registry.registerPostProcessor({
id: "pseudo",
priority: 100,
process: (msg) => `[${msg}]`,
});
registry.registerPostProcessor({
id: "redact-pii",
priority: 50, // runs before pseudo
process: (msg) => msg.replace(/\d{16}/g, "****-****-****-****"),
});
```
Re-registering the same plugin instance is a no-op:
```ts
const plugin = { id: "x", priority: 1, detect: { name: "x", environment: "both" } };
registry.registerDetector(plugin);
registry.registerDetector(plugin); // idempotent — listDetectors() length stays 1
```
Schema validation rejects malformed plugins at registration:
```ts
try {
registry.registerFormatter({ id: "bad" } as never);
} catch (err) {
err.code; // "REACT_I18N.PLUGIN_INVALID"
err.cause; // ZodError carrying the issue list
}
```
## API Reference
### `createReactI18nRegistry(): ReactI18nRegistry`
Factory returning a fresh registry instance. No global state — each call returns an independent registry with its own four `Map`-backed stores.
### `ReactI18nRegistry.registerDetector(plugin: DetectorPlugin): Disposable`
Registers a detector plugin under `plugin.id`. Throws `REACT_I18N.DETECTOR_PLUGIN_INVALID` on schema failure or `REACT_I18N.DETECTOR_PLUGIN_DUPLICATE_ID` when a different plugin instance is registered with the same id. Idempotent for the same instance.
### `ReactI18nRegistry.registerFormatter<TArgs, TResult>(plugin: FormatterPlugin<TArgs, TResult>): Disposable`
Registers a formatter plugin. Throws `REACT_I18N.PLUGIN_INVALID` on schema failure or `REACT_I18N.PLUGIN_DUPLICATE_ID` on duplicate id.
### `ReactI18nRegistry.registerPostProcessor(plugin: PostProcessorPlugin): Disposable`
Registers a post-processor plugin executed inside `<Trans>` after ICU interpolation.
### `ReactI18nRegistry.registerLocaleAdapter<TLocale>(plugin: LocaleAdapterPlugin<TLocale>): Disposable`
Registers a locale-adapter plugin used by `react-ui`'s `<Calendar>` and similar third-party-locale-bound primitives.
### `ReactI18nRegistry.listDetectors() / listFormatters() / listPostProcessors() / listLocaleAdapters()`
Snapshot accessors returning the currently-registered plugins per kind. Order matches insertion order (`Map` iteration).
### `ReactI18nRegistry.getFormatter<TArgs, TResult>(id): FormatterPlugin<TArgs, TResult> | undefined`
Constant-time lookup. Returns `undefined` when the id is not registered.
### `ReactI18nRegistry.getLocaleAdapter<TLocale>(id): LocaleAdapterPlugin<TLocale> | undefined`
Constant-time lookup for locale adapters.
### `Disposable.dispose(): void`
Removes the plugin from the registry. Idempotent — calling on a previously-disposed handle is a no-op and emits no second `disposed` event.
## Implementation Notes
- The four stores are independent `Map<string, PluginEntry>`. Same id under two kinds (e.g. a `currency` formatter and a `currency` locale adapter) is intentionally legal — kinds are partitioned namespaces.
- Schema validation uses `.strict()` Zod schemas — extra keys on a plugin trigger `REACT_I18N.PLUGIN_INVALID`. The detect/format/process/resolve function shapes are validated structurally; semantic correctness is the test surface's responsibility.
- OTel attributes are bounded: `kind` is one of four enum values, `outcome` is one of four enum values. The cardinality of the `react_i18n.registry.plugins_total` counter is at most 16. Plugin `id` is NOT a counter attribute — it lives on the formatter-specific histograms (`react_i18n.formatter.duration_ms`) where the cardinality is bounded by the registered-plugin count.
- The registry does not synchronise plugin priorities — splicing detectors by `priority` is done by `defaultDetectorChain()` (consumer side) and ordering post-processors is done by `runPostProcessors()` (consumer side). The registry is purely a store.
- Re-registering the same instance returns a fresh `Disposable` rather than the original handle. This means a caller that re-registers and then disposes either handle removes the plugin once; the second handle's dispose is a no-op (the entry is already gone).

View File

@@ -0,0 +1,68 @@
# `registry.schemas`
## Purpose
Strict-object Zod schemas for every plugin kind accepted by `ReactI18nRegistry`. The schemas are the boundary at which a malformed plugin shape is rejected at registration time — before any callsite has a chance to consume the bad plugin and throw a less-actionable runtime error elsewhere. Each schema is `.strict()`-locked so extra fields on a plugin (typos, stale fields from an older registry version) trigger validation failure rather than silently passing through.
## Features
- One schema per plugin kind — `DetectorPluginSchema`, `FormatterPluginSchema`, `PostProcessorPluginSchema`, `LocaleAdapterPluginSchema`.
- Non-empty id enforced (`z.string().min(1)`) so every plugin has a stable lookup key.
- Function-shape fields validated structurally; their semantic correctness is exercised by the tests of each consumer (e.g. `useFormatter` test calls the registered plugin's `format` and asserts the output).
- Schemas surface failure as a Zod `ZodError`, which the registry wraps in a `ReactI18nError(REACT_I18N.PLUGIN_INVALID)` with the issues attached to `cause`.
## Usage: Basic Example
```ts
import { FormatterPluginSchema } from "@suaveplan/react-i18n/registry";
const result = FormatterPluginSchema.safeParse({
id: "duration",
format: (locale, args) => String(args.ms),
});
result.success; // true
```
## Usage: Advanced Example
The schemas accept generic `format` / `process` / `resolve` / `detect` functions because their signatures cannot be expressed as a closed Zod shape. The schema verifies the function is *present* and is a function; the actual `(locale, args) => result` contract is enforced at the type-system level via `FormatterPlugin<TArgs, TResult>` and at the test level by exercising the plugin's behavior.
```ts
import { DetectorPluginSchema } from "@suaveplan/react-i18n/registry";
const malformed = {
id: "ab",
priority: 0,
detect: { name: "ab" /* missing environment */ },
};
const result = DetectorPluginSchema.safeParse(malformed);
result.success; // false
result.error.issues; // [{ path: ["detect", "environment"], message: "Required" }]
```
## API Reference
### `DetectorPluginSchema`
Validates `{ id: string; priority: number; detect: { name; environment; detect?; detectAsync?; cache? } }`. The inner `detect` object is `passthrough` because `LocaleDetector` from `@suaveplan/i18n-detector` may carry forward-compatible fields the registry does not own.
### `FormatterPluginSchema`
Validates `{ id: string; schema?: unknown; defaultOptions?: unknown; format: function }`. The `schema` field is opaque (`z.unknown()`) because it is a Zod schema reference and Zod cannot meaningfully validate "is this a Zod schema."
### `PostProcessorPluginSchema`
Validates `{ id: string; priority: number; process: function }`.
### `LocaleAdapterPluginSchema`
Validates `{ id: string; resolve: function }`.
## Implementation Notes
- All schemas are `.strict()` — extra keys cause validation failure. This catches stale fields from older registry versions and typos in plugin objects.
- The schemas are exported from a single file rather than co-located with their plugin types so a consumer importing `@suaveplan/react-i18n/registry` for runtime validation does not pay the bundle cost of the plugin function code.
- Validation errors are passed through to the registry which decorates them with stable error codes; consumers should never `safeParse` directly against these schemas — register through `ReactI18nRegistry.register*` to get the decorated error.
- The schemas live in this package rather than in `@suaveplan/types` because they validate React-specific plugin shapes; the `ReactI18nRegistry` is itself React-bound (consumed via context).

View File

@@ -0,0 +1,59 @@
# `useReactI18nRegistry` & `useOptionalReactI18nRegistry`
## Purpose
These hooks read the active `ReactI18nRegistry` from React context. The strict variant `useReactI18nRegistry()` throws `REACT_I18N.REGISTRY_NOT_PROVIDED` when called outside a `<RegistryProvider>` ancestor, which is the right behavior for code that genuinely requires the registry (e.g. plugin-aware components). The tolerant variant `useOptionalReactI18nRegistry()` returns `null` outside a provider, which is the right behavior for code that wants to opt into the registry when present but fall back to a non-registry implementation when absent — built-in hooks like `useNumber` use this so existing call sites without `<RegistryProvider>` keep working.
## Features
- Strict and tolerant variants with the same context source.
- Stable error code (`REACT_I18N.REGISTRY_NOT_PROVIDED`) on the strict variant for programmatic handling.
- Zero overhead — both hooks are a single `useContext` lookup.
## Usage: Basic Example
```tsx
import { useReactI18nRegistry } from "@suaveplan/react-i18n/registry";
function MyComponent() {
const registry = useReactI18nRegistry();
const formatter = registry.getFormatter<{ ms: number }, string>("duration");
return <span>{formatter?.format("en-GB", { ms: 1500 })}</span>;
}
```
## Usage: Advanced Example
The tolerant variant is the right choice for any hook or component that should work both inside and outside a `<RegistryProvider>`. The built-in hooks (`useNumber`, `useDate`, …) use it internally:
```ts
import { useOptionalReactI18nRegistry } from "@suaveplan/react-i18n/registry";
function useNumber(value: number, options?: NumberOptions): string {
const registry = useOptionalReactI18nRegistry();
if (registry) {
const plugin = registry.getFormatter<NumberArgs, string>("number");
if (plugin) return plugin.format(useLocale(), { value, ...options });
}
// Fallback path — direct call to @suaveplan/i18n-formatter-number
return formatNumber(useLocale(), value, options);
}
```
For test fixtures that mount a registry per-case, the strict hook is appropriate — a missing provider in a test is a wiring bug and should fail loudly.
## API Reference
### `useReactI18nRegistry(): ReactI18nRegistry`
Reads the registry from `RegistryContext`. Throws `ReactI18nError(REACT_I18N.REGISTRY_NOT_PROVIDED)` when no `<RegistryProvider>` is in scope.
### `useOptionalReactI18nRegistry(): ReactI18nRegistry | null`
Reads the registry from `RegistryContext`. Returns `null` when no `<RegistryProvider>` is in scope.
## Implementation Notes
- Both hooks are bound to a single module-scoped `RegistryContext` so the brand identity of the context is preserved across SSR / hot-reload / dev-bundle scenarios.
- Two `<RegistryProvider>` ancestors in the same tree result in the inner one winning — React context resolution is closest-ancestor wins. This is the desired behavior for layered apps where a sub-tree wants its own registry overlay; if you do not want that, mount only one provider.
- The tolerant variant returns `null` rather than an "empty" registry sentinel. A null check at the call site is more honest than papering over the missing-provider case with a no-op registry.

View File

@@ -0,0 +1,147 @@
# `i18n-config.schemas.ts`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Defines the Zod boundary schemas for the configuration surface of
`@suaveplan/react-i18n`. Two schemas live here:
1. `I18nConfigSchema` — validates the option bag passed to
`createI18nConfig(...)` (and accepted directly by
`<TranslationProvider config={...}>`). It mirrors the universal-core
`I18nInstanceOptions` shape but stops at the Zod-validatable fields
(functions and class instances are described by structural shape, not
inspected for behaviour).
2. `TranslationProviderPropsSchema` — validates the React Provider's prop
bag at runtime so the dual-mode `i18n` (caller-managed) vs `config`
(Provider-managed) discriminator can fail loudly when the caller
supplies both or neither.
The two schemas live in the same file because they share helper shapes
(`BackendShape`, `PluginShape`, the `InitialMessagesSchema` re-export
of `TranslationRecordSchema`) and consumers always import them together
via the `./schemas` subpath barrel.
## Features
- Zod 4 strict-object schemas with explicit `.strict()` so unknown fields
surface immediately rather than silently passing through.
- `superRefine` discriminator on `TranslationProviderPropsSchema` that
enforces the "exactly one of `i18n` or `config`" invariant the
Provider's runtime guard relies on.
- Re-uses `TranslationRecordSchema` from `@suaveplan/i18n` so the
`initialMessages` payload validates against the same recursive grammar
the universal core's catalog uses on hydration.
- Exposes both `z.input<...>` and `z.output<...>` types so call sites can
pick the pre-parse shape (with optionals) or the post-parse shape (with
defaults applied).
## Usage: Basic Example
```ts
import { I18nConfigSchema } from "@suaveplan/react-i18n/schemas";
const cfg = I18nConfigSchema.parse({
defaultLocale: "en-GB",
fallbackLocale: "en-GB",
supportedLocales: ["en-GB", "fr-FR"],
});
```
## Usage: Advanced Example
```ts
import {
I18nConfigSchema,
TranslationProviderPropsSchema,
} from "@suaveplan/react-i18n/schemas";
import { ReactI18nError, REACT_I18N_CODES } from "@suaveplan/react-i18n/errors";
function buildConfig(input: unknown) {
const result = I18nConfigSchema.safeParse(input);
if (!result.success) {
throw new ReactI18nError(
REACT_I18N_CODES.HYDRATION_MISMATCH,
"react-i18n.hydration_mismatch",
{ context: { zodIssues: result.error.issues } }
);
}
return result.data;
}
function validateProviderProps(props: unknown) {
return TranslationProviderPropsSchema.parse(props);
}
```
## API Reference
### `I18nConfigSchema`
Strict Zod object accepting the following fields:
| Field | Type | Notes |
| --- | --- | --- |
| `defaultLocale` | `string` (min 1) | Required. |
| `fallbackLocale` | `string` (min 1) | Optional. |
| `supportedLocales` | `string[]` (min 1) | Optional list of BCP-47 codes. |
| `backend` | `Backend` shape | Optional; mutually exclusive with `backends`. |
| `backends` | `Backend[]` | Optional list (auto-wrapped in `ChainedBackend` by the core). |
| `initialMessages` | `Record<locale, TranslationRecord>` | Optional pre-loaded catalog. |
| `escapeHtml` | `boolean` | Optional interpolation flag. |
| `nestingDepth` | `number` (1–100) | Optional max interpolation nesting. |
| `plugins` | `Array<{ name: string }>` | Optional plugin descriptors. |
| `saveMissing` | `boolean` | Optional save-missing flag. |
`Backend` is validated structurally (`name: string`, `capabilities: object`)
because the surface includes function fields Zod cannot meaningfully
validate.
### `TranslationProviderPropsSchema`
Validates `<TranslationProvider>` props. Both `i18n` and `config` are
optional at the field level; the `superRefine` block enforces "exactly
one" semantics:
- Both supplied → custom issue: `"TranslationProvider accepts either
\`i18n\` or \`config\`, not both."`
- Neither supplied → custom issue: `"TranslationProvider requires one of
\`i18n\` or \`config\`."`
The `i18n` shape is structural (`{ catalog: object; events: object }`)
because validating the full `I18nInstance` surface here would duplicate
work the universal core already does at construction time.
### Type aliases
- `I18nConfigInput` — `z.input<typeof I18nConfigSchema>`. Use at API
boundaries before parsing.
- `I18nConfig` — `z.output<typeof I18nConfigSchema>`. Use after parsing.
- `TranslationProviderPropsInput` / `TranslationProviderPropsOutput` —
same input/output split for the Provider props schema.
## Implementation Notes
- The schema is `.strict()` so unknown options on a config bag throw
rather than silently pass through. This is the right default because
the Provider passes the parsed config straight to `createI18n`, where
unknown fields would also be ignored — better to surface the typo up
front.
- `Backend` and `Plugin` shapes are intentionally shallow. A deeper Zod
schema would conflict with the runtime `Backend` interface (which
includes `read`, `readMulti`, `save`, `init` function fields the Zod
surface cannot validate) and would require duplicating the universal
core's contract here. The shallow validation catches obvious shape
mistakes (missing `name`, missing `capabilities`) without forcing
changes to the canonical `Backend` interface.
- The `.passthrough()` modifier on the inner `catalog` and `events`
shapes lets the Provider pass through arbitrary internal fields the
`I18nInstance` carries without forcing a schema update for every new
field added to the universal core.
- `initialMessages` reuses `TranslationRecordSchema` from
`@suaveplan/i18n`. That schema is recursive (`z.lazy(...)`) and matches
the same grammar the universal core's catalog uses, so a payload that
passes Zod validation here is guaranteed to load into the catalog
without a second-pass shape error.

View File

@@ -0,0 +1,135 @@
# `tDynamic` — runtime escape hatch for dynamic translation keys
## Purpose
`tDynamic(i18n, key, values?)` is the rare-use escape hatch for translation lookups whose key cannot be statically known —
the canonical example is mapping an error code to its localised message:
``tDynamic(i18n, `errors.${error.code.toLowerCase()}`)``.
The branded `TranslationKey` contract enforces a closed set of keys at compile time; `tDynamic` is the one supported way to
bypass that contract while still keeping the runtime contract loud and observable.
The function is a plain (non-React) helper so it works in any context — hooks, event handlers, stores, server-side. Inside a
React component, callers obtain `i18n` from `useI18n()` (sub-wave 1c). Outside React, callers thread the instance manually.
Either way, every call emits a `warn`-level log entry and a missing key throws `ReactI18nError(REACT_I18N.UNKNOWN_KEY)` so
no raw key can ever leak into rendered output.
## Features
- **Hard runtime contract** — missing keys throw `ReactI18nError(REACT_I18N.UNKNOWN_KEY)` with `context: { key, locale }`.
- **Always-warn observability** — every successful AND missing call emits one `warn`-level log entry. Dashboards filter on
the `outcome` field of the log context (`"hit"` or `"miss"`) to track drift back to dynamic-key patterns.
- **Optional interpolation** — pass `values` through the same options bag the universal core's `t()` accepts.
- **Injectable sink** — production wires `@suaveplan/logger` via the `sink` option; the default falls back to a
`console.warn` indirection so the helper degrades gracefully when no logger peer is available.
- **Structural typing** — accepts any object matching `TDynamicI18nLike` (mirrors the public surface of
`@suaveplan/i18n`'s `I18nInstance` just enough to drive the lookup), so tests can drive it with a hand-rolled fake.
## Usage: Basic Example
```ts
import { tDynamic } from "@suaveplan/react-i18n";
import { useI18n } from "@suaveplan/react-i18n";
function ErrorBanner({ error }: { error: SuaveplanError }) {
const { i18n } = useI18n();
// Resolves the catalog key `errors.auth.invalid_token` for an
// error whose code is `AUTH.INVALID_TOKEN`.
const message = tDynamic(
i18n,
`errors.${error.code.toLowerCase().replace(".", "_")}`
);
return <div role="alert">{message}</div>;
}
```
If the catalog has no entry under `errors.auth.invalid_token`, the call throws
`ReactI18nError(REACT_I18N.UNKNOWN_KEY)` with `context.key === "errors.auth.invalid_token"` and `context.locale ===
i18n.locale`. The nearest error boundary catches the throw — `tDynamic` never silently returns a key string to the user.
## Usage: Advanced Example — wired logger sink + interpolation
In production, swap the default `console.warn` sink for the package's wired `@suaveplan/logger` warn channel and pass
interpolation values:
```ts
import type { LoggerLike } from "@suaveplan/types";
import { tDynamic } from "@suaveplan/react-i18n";
function localiseValidation(
i18n: I18nInstance,
logger: LoggerLike,
code: string,
fieldName: string
): string {
return tDynamic(i18n, `validation.${code}`, {
values: { field: fieldName },
sink: (message, context) => {
logger.warn(message, context);
},
});
}
```
Two call paths:
- **Hit:** `validation.required` resolves to `"{field} is required"` — `field` interpolates via the universal core's
interpolation engine. The sink fires once with `{ key: "validation.required", locale, outcome: "hit" }`.
- **Miss:** `validation.unknown_thing` is absent — the sink fires once with `{ key: "validation.unknown_thing", locale,
outcome: "miss" }`, then `tDynamic` throws `ReactI18nError(REACT_I18N.UNKNOWN_KEY)` so the caller's error boundary takes
over.
The `sink` is the testability seam: tests pass a `(msg, ctx) => calls.push([msg, ctx])` array-pusher and assert on the
captured calls without touching the global `console`.
## API Reference
### `tDynamic(i18n, key, options?) → string`
Parameters:
- `i18n: TDynamicI18nLike` — active i18n instance. Must expose `locale: string`, `catalog.has(key): boolean`, and
`t(key, opts?): string`. Real `I18nInstance` from `@suaveplan/i18n` satisfies this structurally.
- `key: string` — runtime-derived translation key.
- `options.values?: Record<string, unknown>` — optional interpolation values.
- `options.sink?: (message, context?) => void` — optional warn-log sink (defaults to a `console.warn` indirection).
Returns: the translated string.
Throws: `ReactI18nError(REACT_I18N.UNKNOWN_KEY)` with `context: { key, locale }` when `key` is absent from the active catalog.
Side-effect: every call (hit OR miss) invokes `sink` exactly once with `(WARN_MESSAGE, { key, locale, outcome })` where
`outcome` is `"hit"` or `"miss"`.
### `interface TDynamicI18nLike`
Structural shape required of the `i18n` argument: `locale: string`, `catalog: { has(key): boolean }`, and `t(key, opts?):
string`. Mirrors the public surface of `@suaveplan/i18n`'s `I18nInstance` minimally so the helper stays decoupled from
implementation details.
### `type TranslationValues = Record<string, unknown>`
Convenience alias for the interpolation values bag. Mirrors `TranslateOptions.values` from `@suaveplan/i18n`.
### `interface TDynamicOptions`
`{ values?: TranslationValues; sink?: TDynamicWarnSink }`. Both optional.
### `type TDynamicWarnSink = (message: string, context?: Record<string, unknown>) => void`
Per-call warn-log sink. The default sink is a `console.warn` indirection that no-ops when no `console` is available.
## Implementation Notes
- The presence check uses `i18n.catalog.has(key)` — the universal core's `MessageCatalog` exposes this directly. The helper
short-circuits BEFORE calling `t()` so the warn-then-throw ordering on the miss path is deterministic (sink fires, then
the throw happens in the same tick).
- The thrown `ReactI18nError` carries `context: { key, locale }` so observers can correlate the failure with the offending
call site without parsing the error message.
- The default sink is wrapped in a tiny `invokeConsoleWarn` indirection so the call site stays free of any
static-analysis suppression directive (CLAUDE.md "Critical" rule 9). The indirection is named so call-graph audits can
grep for the single sink call across the package.
- Performance: the hot path is one `Map.has` check + one `t()` call + one sink invocation. The sink invocation is the
intentional cost — it's the observability surface, not overhead.
- Limitation: `tDynamic` cannot enforce that the key was *intended* to be dynamic. Reviewers should reject any PR that
introduces a `tDynamic(i18n, "literal.string")` call — that case must use `useTranslation()` or `<Trans>` instead so the
branded `TranslationKey` gate fires.

View File

@@ -0,0 +1,129 @@
# `TranslationKey` — branded typing contract
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
The `TranslationKey` type is the spine of the React-i18n no-hardcoded-strings rule. By making translation-accepting prop slots
require a value drawn from a closed union of registered keys, the compiler rejects typos and out-of-bundle keys before they
ever reach the runtime catalog. This sibling module ships the type definitions and an empty pair of augmentable interfaces
that downstream packages (`@suaveplan/react-ui`, `@suaveplan/react-forms`, `@suaveplan/react-ui-theme-*`,
`@suaveplan/react-ui-pack-*`) and consumer applications populate via TypeScript module augmentation.
The two interfaces (`AppTranslationKeys` and `ContributedTranslationKeys`) are deliberately separate. App-side keys live in
`AppTranslationKeys`; package-side keys live in `ContributedTranslationKeys`. The split keeps "who contributed which keys"
auditable and lets the CI gate `scripts/lint/check-no-backwards-deps.ts` enforce that `@suaveplan/react-i18n`'s `package.json`
never lists a leaf package as a dependency. Population always flows in one direction (downstream to here), so the leaf-most
React surface stays leaf-clean by construction.
## Features
- **Empty by default** — the union of `keyof AppTranslationKeys | keyof ContributedTranslationKeys` is `never` until something
augments at least one interface. Out-of-the-box, no string literal satisfies `TranslationKey`.
- **String-compatible head** — `TranslationKey` includes `string` in its intersection so values can be passed straight into
`i18n.t(key)`. The brand only tightens the _consumer-facing_ prop slot.
- **Phantom brand** — the `__brand: "TranslationKey"` field is a compile-time-only marker. No runtime cost, no extra
serialisation surface.
- **Module-augmentation friendly** — both interfaces are open for declaration merging. Augmentation is the only entry point;
there is no `registerKey()` runtime API to misuse.
- **Codegen-ready** — the script `packages/react/react-i18n/scripts/generate-translation-types.ts` reads a package's canonical
`src/locales/en-GB.json` and emits an `augmentation.d.ts` that registers every key into `ContributedTranslationKeys`.
## Usage: Basic Example — consumer-app augmentation
A Next.js (or any consumer) app declares its own keys against `AppTranslationKeys`. Place the snippet in any `.d.ts` file
inside the app's source tree (e.g. `types/translations.d.ts`) — TypeScript picks it up automatically through
`compilerOptions.include`.
```ts
// types/translations.d.ts
import "@suaveplan/react-i18n";
declare module "@suaveplan/react-i18n" {
interface AppTranslationKeys {
"app.welcome": true;
"app.checkout.title": true;
}
}
```
Now `const k: TranslationKey = "app.welcome";` typechecks. `const k: TranslationKey = "app.typo";` does not — the literal
isn't a member of the union.
## Usage: Advanced Example — downstream-package augmentation via codegen
A workspace package such as `@suaveplan/react-ui` ships its keys via the codegen pipeline so the union stays in sync with the
canonical English bundle. The package's build step runs:
```sh
bun packages/react/react-i18n/scripts/generate-translation-types.ts \
packages/react/react-ui \
--package-name @suaveplan/react-ui
```
The script reads `packages/react/react-ui/src/locales/en-GB.json` and writes:
- `packages/react/react-ui/src/locales/translation-keys.ts` — a string-literal union type of every key in the bundle, sorted
alphabetically for diff stability.
- `packages/react/react-ui/src/locales/augmentation.d.ts` — the module-augmentation block:
```ts
// generated — do not edit by hand
import "@suaveplan/react-i18n";
declare module "@suaveplan/react-i18n" {
interface ContributedTranslationKeys {
"react-ui.dialog.close": true;
"react-ui.dialog.confirm": true;
"react-ui.calendar.previousMonth": true;
// …every key in en-GB.json
}
}
```
The package's `src/index.ts` includes a side-effect import (`import "./locales/augmentation.js";`) so consumers who add the
package to their dependency tree automatically inherit the registered keys. The TypeScript service merges every active
`@suaveplan/react-i18n` module-augmentation block, so a consumer app gets the full union of `AppTranslationKeys ∪
ContributedTranslationKeys` everywhere `TranslationKey` appears.
## API Reference
### `interface AppTranslationKeys`
Empty-by-default surface for consumer-app augmentation. Carries one phantom unique-symbol key
(`[APP_TRANSLATION_KEYS_BRAND]?: never`) whose only purpose is to keep biome's `noEmptyInterface` rule quiet — it cannot
collide with any user-supplied augmentation because the symbol is locally scoped and not exported. Augmenting consumers
register translation keys whose value is `true` (the value slot is unused — only the key set matters).
### `interface ContributedTranslationKeys`
Empty-by-default surface for downstream-package augmentation. Same phantom-symbol pattern as `AppTranslationKeys`. Generated
augmentation files come from `generate-translation-types.ts`.
### `type TranslationKey`
`string & Readonly<{ __brand: "TranslationKey" }> & (Extract<keyof AppTranslationKeys, string> | Extract<keyof
ContributedTranslationKeys, string>)`.
- The `string` head keeps `TranslationKey` assignable into APIs typed as `string`.
- The `__brand` field is phantom — present only at compile time.
- `Extract<keyof X, string>` filters out the phantom unique-symbol slot so the keyof union still evaluates to `never` until
string-keyed augmentations land.
## Implementation Notes
- The `__brand` field is wrapped in `Readonly<{ __brand: "TranslationKey" }>` rather than `{ readonly __brand: "TranslationKey" }`
to preserve homomorphic mapped-type behaviour when used inside generics. Both forms are runtime-equivalent; the chosen form
is safer when the type appears in conditional/distributive positions downstream.
- The phantom unique-symbol slot exists for biome compatibility only. Without it, biome's `noEmptyInterface` rule rewrites
`interface Foo {}` → `type Foo = {}` under `--unsafe`, which BREAKS module augmentation. The symbol is `declare const … :
unique symbol`, so it has no runtime presence and no consumer can address it.
- The interfaces are intentionally side-effect-free. Augmentation ordering does not matter — TypeScript's declaration-merging
is commutative.
- The brand is _not_ exported as a value because there is nothing to import. Augment the interface and the union grows
automatically.
- Performance: the union shape only affects type-checking; runtime values are plain strings, so there is zero cost at runtime
and no impact on bundle size.
- Limitation: the brand cannot prevent `as TranslationKey` casts. The CI gate `scripts/lint/check-no-hardcoded-strings.ts`
(sibling change) catches the most common abuse vector — JSX text nodes — at the source level.

View File

@@ -0,0 +1,90 @@
# `env.ts`
## Purpose
Provides the single environment-detection helper the React surface uses
to switch between dev-mode loud-failure and prod-mode soft-fail
behaviour. The helper reads at call time (not at module-load time) so
test suites that swap `process.env.NODE_ENV` between cases observe the
swap without module-cache busting, and edge runtimes without a full
Node `process` shim still resolve to a safe default.
The package exposes only one helper here intentionally — every other
environment check (browser vs server, hydration vs second render, RSC
vs client) is a React lifecycle question answered by `useSyncExternalStore`
or `useEffect`, not an environment question.
## Features
- Call-time `process.env.NODE_ENV` lookup with optional-chain reads so
bundlers that statically replace `process.env.NODE_ENV === "production"`
with a literal still get the right answer.
- Safe fallback: returns `false` (dev mode) when `process` or
`process.env` is missing. Loud failure beats silent swallow when the
environment is misconfigured.
- Zero allocations on the hot path — direct property reads, no
string concatenation, no regex.
## Usage: Basic Example
```ts
import { isProductionEnv } from "@suaveplan/react-i18n/utils/env";
if (isProductionEnv()) {
// soft-fail path
} else {
throw new ReactI18nError(
REACT_I18N_CODES.NO_PROVIDER,
"react-i18n.no_provider"
);
}
```
## Usage: Advanced Example
```ts
import { isProductionEnv } from "@suaveplan/react-i18n/utils/env";
function recoverableLogOrThrow(err: Error): void {
if (isProductionEnv()) {
// Soft-fail in production — log via the structured sink so the
// failure shows up in dashboards but the user-facing render
// continues.
log.warn("react-i18n.recoverable_failure", { err });
return;
}
// Dev: surface the error so the developer sees it immediately.
throw err;
}
```
## API Reference
### `isProductionEnv(): boolean`
Returns `true` iff `globalThis.process?.env?.NODE_ENV === "production"`.
Returns `false` when:
- `globalThis.process` is `undefined` (browser without bundler env shim,
Deno without a `process` polyfill).
- `process.env` is `undefined`.
- `process.env.NODE_ENV` is `undefined` or any value other than the
exact string `"production"`.
## Implementation Notes
- The lookup is intentionally optional-chained on every step rather
than guarded by a try/catch. Optional chains evaluate at zero cost
on the hot path; try/catch incurs an unwind table.
- The function reads `globalThis.process`, not bare `process`. Bare
`process` would error under strict-mode ESM in environments that do
not synthesise the binding; the global lookup degrades cleanly
instead.
- The check is deliberately `=== "production"` (case-sensitive, exact
match). Webpack/Vite stamp the literal with that exact casing; any
other value (including `"PRODUCTION"`, `"prod"`, undefined) means
"not production" and the dev-mode branch fires.
- The function lives in `utils/` rather than `config/` because it is
not configuration — it is environment introspection. Configuration
is what the consumer passes to `createI18nConfig(...)`.

View File

@@ -0,0 +1,114 @@
# `identity-instance.ts`
## Purpose
Provides the identity translator used as the production-mode fallback
when a `useTranslation()` (or any sibling hook) is called outside a
`<TranslationProvider>` ancestor. The fallback exists so production
bundles don't crash on a `null` destructure — instead they degrade to a
visibly-broken-but-not-blocking state where every key renders as its
literal key string, and the misconfiguration is logged once via the
warn-once helper.
The dev-mode counterpart is a thrown `ReactI18nError(NO_PROVIDER)`. The
split is by environment, not by hook — every hook that can be called
outside a Provider must use this fallback in production.
## Features
- Stable reference identity: `getIdentityTranslator()` returns the same
function reference on every call so memoisation in consumers does not
churn.
- `IDENTITY_TRANSLATION_RETURN` — a frozen singleton-shaped object
matching `UseTranslationReturn` so destructuring works without
allocating a fresh object per fallback render.
- Honours `options.defaultValue` so `t("missing", { defaultValue: "x" })`
still returns `"x"` even on the fallback path. This is the same
contract the universal-core `t` honours, so consumers don't need to
branch on `ready`.
## Usage: Basic Example
```ts
import { getIdentityTranslator } from "@suaveplan/react-i18n/utils/identity-instance";
const t = getIdentityTranslator();
t("hello"); // "hello"
t("hello", { defaultValue: "Hi" }); // "Hi"
```
## Usage: Advanced Example
```ts
import { useState } from "react";
import {
IDENTITY_TRANSLATION_RETURN,
type IdentityTranslationReturn,
} from "@suaveplan/react-i18n/utils/identity-instance";
import type { UseTranslationReturn } from "@suaveplan/react-i18n";
function useFallbackOrLive(
live: UseTranslationReturn | null
): UseTranslationReturn | IdentityTranslationReturn {
return live ?? IDENTITY_TRANSLATION_RETURN;
}
```
## API Reference
### `getIdentityTranslator(): IdentityTranslator`
Returns a stable reference to the identity translator function. Same
reference on every call.
### `IDENTITY_TRANSLATION_RETURN: IdentityTranslationReturn`
Frozen singleton-shaped object with `{ t, i18n: undefined, ready: false }`.
Mirrors `UseTranslationReturn` on the failure branch so consumers can
destructure safely. The `i18n` field is intentionally `undefined`
(not `null`) so the runtime distinction with a real instance is
trivial.
### `interface IdentityTranslator`
```ts
(key: string, options?: IdentityTranslatorOptions) => string;
```
Returns `options.defaultValue` when provided, else the key string.
### `interface IdentityTranslatorOptions`
Mirrors the ergonomic-options-bag the universal-core `t` accepts.
Carries `defaultValue`, `count`, and an open record of additional
fields so the fallback is signature-compatible with the live
translator.
### `interface IdentityTranslationReturn`
The exact return shape used by the production-mode fallback path of
`useTranslation()`. The `i18n` field is `undefined` (not `null`) and
`ready` is the literal `false` so a TypeScript narrow on `ready` lets
consumers branch into the fallback's specific shape.
## Implementation Notes
- The translator function is a module-level constant, not a per-call
factory product. Returning the same function reference is critical
because consumers downstream can `useMemo` on `t` to skip work; a
fresh function reference per call would invalidate that memo.
- The `IDENTITY_TRANSLATION_RETURN` object is constructed once at
module load and shared. Consumers MUST NOT mutate it (the field
types are `readonly` and the object is the singular shared instance).
- The fallback honours `defaultValue` because doing otherwise would
silently regress the production-mode behaviour of any string with a
default — `t("greeting", { defaultValue: "Hi" })` becomes `"greeting"`
instead of `"Hi"`, which is a worse failure mode than the known
identity behaviour.
- The fallback intentionally does NOT honour interpolation
(`{{count}}`-style) because doing so would couple the React-tier
fallback to the universal-core interpolation engine — the whole
point of the identity path is to avoid the engine entirely. If a
consumer wants interpolation in production-mode no-Provider
rendering, they should write a Provider rather than rely on the
fallback.

View File

@@ -0,0 +1,119 @@
# `warn-once-per-process.ts`
## Purpose
Implements the warn-once-per-process dedup ring used by every
production-mode soft-failure path in `@suaveplan/react-i18n`. Two paths
rely on it directly:
1. The production-mode `useTranslation()` no-Provider fallback — fires a
single warning per process when a hook is called outside a Provider
ancestor (so production bundles surface the misconfiguration in
observability without per-render console flooding).
2. The future `tDynamic()` runtime escape hatch — every call site emits
one warn-level entry the first time the call site fires (so log
aggregation can quantify how often the typed `t()` path is bypassed).
The dedup key is per-process (held in a module-level `Set<string>`),
which is the right scope for both patterns: the same misconfiguration
fires every render of the offending component, and a per-render warn
would flood the console.
## Features
- Module-level `Set<string>` ring with O(1) lookup.
- Pluggable `sink` so the Provider can route through `@suaveplan/logger`
when registered, falling back to `console.warn` when not.
- `resetWarnOncePerProcess()` clears the ring — tests call it between
cases so each test starts clean.
- Returns `boolean` (`true` when the warning fired, `false` when
suppressed) so tests can assert dedup behaviour without observing the
sink.
- Default sink degrades cleanly when `globalThis.console` is missing
(edge runtimes where the console is sandboxed off).
## Usage: Basic Example
```ts
import { warnOncePerProcess } from "@suaveplan/react-i18n/utils/warn-once-per-process";
warnOncePerProcess(
"react-i18n.no-provider",
"useTranslation called outside a TranslationProvider — falling back to identity translator."
);
```
## Usage: Advanced Example
```ts
import {
resetWarnOncePerProcess,
warnOncePerProcess,
} from "@suaveplan/react-i18n/utils/warn-once-per-process";
import { logger } from "@suaveplan/logger";
const sink = (message: string, context?: Record<string, unknown>) => {
logger.warn(message, context);
};
function softFailNoProvider(hookName: string) {
warnOncePerProcess(
`react-i18n.no-provider.${hookName}`,
`${hookName} called outside <TranslationProvider> — falling back.`,
{ hookName },
{ sink }
);
}
afterEach(() => resetWarnOncePerProcess());
```
## API Reference
### `warnOncePerProcess(key, message, context?, options?): boolean`
Parameters:
- `key: string` — dedup key. Two calls with the same key produce one
warning per process. Use a stable, distinguishing string —
`"react-i18n.no-provider"` is a good choice; a render-derived value
like `"warn-${Date.now()}"` defeats the dedup.
- `message: string` — the warning text.
- `context?: Record<string, unknown>` — structured fields forwarded to
the sink as the second positional argument.
- `options.sink?: (message, context?) => void` — optional sink.
Defaults to `console.warn` via a tiny indirection.
Returns `true` when the warning fired (first time this key was seen),
`false` when it was suppressed.
### `resetWarnOncePerProcess(): void`
Clears the dedup ring. Tests MUST call this between cases (typically in
an `afterEach` hook) to prevent cross-test bleed.
### `interface WarnOncePerProcessOptions`
Open-shape options bag with a single field today (`sink`). Extending the
interface is non-breaking.
## Implementation Notes
- The ring is a `Set<string>`, not a `Map<string, count>`, because the
dedup contract is "first-call wins"; counting subsequent calls would
require a richer API surface and a per-process flush strategy.
- The module-level state is intentional. Tests call
`resetWarnOncePerProcess()` to clean up; production code never needs
to. The dedup scope is the OS process, so SSR runs of multiple
requests share the same ring (this is correct: a misconfigured
Provider is a global property of the deployment, not a per-request
question).
- The default sink uses an internal `invokeConsoleWarn` indirection so
the call site stays free of `// biome-ignore` suppressions
(CLAUDE.md §9, AGENTS.md §2a). The indirection adds one synchronous
function call on the cold path; warm-path calls (post-dedup) skip
the sink entirely.
- `resetWarnOncePerProcess()` clears the entire ring, not a single key.
This is the right shape for tests but is intentionally NOT exported
for production code: clearing in production would silently re-enable
every previously-deduped warning at once, defeating the purpose.