first commit
This commit is contained in:
656
docs/localization/i18n/react-i18n/README.md
Normal file
656
docs/localization/i18n/react-i18n/README.md
Normal 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.
|
||||
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
143
docs/localization/i18n/react-i18n/src/components/trans/trans.md
Normal file
143
docs/localization/i18n/react-i18n/src/components/trans/trans.md
Normal 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.
|
||||
@@ -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`.
|
||||
@@ -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).
|
||||
@@ -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.
|
||||
@@ -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`.
|
||||
@@ -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`.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
87
docs/localization/i18n/react-i18n/src/errors/error-codes.md
Normal file
87
docs/localization/i18n/react-i18n/src/errors/error-codes.md
Normal 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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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).
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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`.
|
||||
@@ -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.
|
||||
@@ -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`.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
170
docs/localization/i18n/react-i18n/src/provider/provider.md
Normal file
170
docs/localization/i18n/react-i18n/src/provider/provider.md
Normal 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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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).
|
||||
158
docs/localization/i18n/react-i18n/src/registry/registry.md
Normal file
158
docs/localization/i18n/react-i18n/src/registry/registry.md
Normal 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).
|
||||
@@ -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).
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
90
docs/localization/i18n/react-i18n/src/utils/env.md
Normal file
90
docs/localization/i18n/react-i18n/src/utils/env.md
Normal 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(...)`.
|
||||
114
docs/localization/i18n/react-i18n/src/utils/identity-instance.md
Normal file
114
docs/localization/i18n/react-i18n/src/utils/identity-instance.md
Normal 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.
|
||||
@@ -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.
|
||||
Reference in New Issue
Block a user