Initial Commit
This commit is contained in:
656
packages/localization/i18n/react-i18n/README.md
Normal file
656
packages/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.
|
||||
|
||||
245
packages/localization/i18n/react-i18n/e2e/react-i18n.spec.ts
Normal file
245
packages/localization/i18n/react-i18n/e2e/react-i18n.spec.ts
Normal file
@@ -0,0 +1,245 @@
|
||||
/**
|
||||
* @suaveplan/react-i18n — Playwright E2E sweep.
|
||||
*
|
||||
* Covers the five user-visible behaviours required by sub-wave 1g:
|
||||
*
|
||||
* 1. Live language switch through 5 locales with localStorage persistence
|
||||
* 2. RTL flip on Arabic — `<html dir="rtl">` + visible reflow
|
||||
* 3. Lazy-namespace Suspense — `<I18nSuspense>` fallback resolves to
|
||||
* translated content
|
||||
* 4. Missing-key overlay (dev mode) — capped at 50 entries with
|
||||
* `aria-live="polite"`
|
||||
* 5. SSR hydration round-trip — server-rendered HTML hydrates without
|
||||
* a React warning AND without a flash of untranslated content
|
||||
*
|
||||
* Per `CLAUDE.md` "Web App & Page Verification": every scenario also
|
||||
* asserts (a) zero console errors, (b) zero failed network requests
|
||||
* (4xx/5xx), (c) the rendered DOM contains the expected content (not
|
||||
* merely a 200 response).
|
||||
*/
|
||||
|
||||
import { expect, test } from "@playwright/test";
|
||||
import {
|
||||
attachConsoleMonitor,
|
||||
attachNetworkMonitor,
|
||||
} from "@suaveplan/testing-e2e";
|
||||
|
||||
const LOCALES = ["en-GB", "ar-AE", "zh-CN", "cy-GB", "ru-RU"] as const;
|
||||
|
||||
const GREETING_BY_LOCALE: Record<(typeof LOCALES)[number], string> = {
|
||||
"en-GB": "Hello, world",
|
||||
"ar-AE": "مرحبا بالعالم",
|
||||
"zh-CN": "你好,世界",
|
||||
"cy-GB": "Helo, byd",
|
||||
"ru-RU": "Привет, мир",
|
||||
};
|
||||
|
||||
const SIGN_IN_BY_LOCALE: Record<(typeof LOCALES)[number], string> = {
|
||||
"en-GB": "Sign in",
|
||||
"ar-AE": "تسجيل الدخول",
|
||||
"zh-CN": "登录",
|
||||
"cy-GB": "Mewngofnodi",
|
||||
"ru-RU": "Войти",
|
||||
};
|
||||
|
||||
// Each Playwright test runs in its own browser context with its own
|
||||
// localStorage namespace by default — no per-test clearing needed.
|
||||
|
||||
test.describe("react-i18n live behaviour", () => {
|
||||
test("cycles through all five locales and persists choice", async ({
|
||||
page,
|
||||
}) => {
|
||||
const consoleMonitor = attachConsoleMonitor(page, {
|
||||
failOn: ["error"],
|
||||
// happy-dom-style hydration warnings can leak as React's
|
||||
// "Warning:" via console.error — we treat any error as a
|
||||
// failure but tolerate React DevTools install hints which
|
||||
// arrive as info.
|
||||
ignore: [/Download the React DevTools/i],
|
||||
});
|
||||
const networkMonitor = attachNetworkMonitor(page);
|
||||
|
||||
await page.goto("/");
|
||||
await expect(page.getByTestId("greeting")).toHaveText(
|
||||
GREETING_BY_LOCALE["en-GB"]
|
||||
);
|
||||
await expect(page.getByTestId("auth-signin")).toHaveText(
|
||||
SIGN_IN_BY_LOCALE["en-GB"]
|
||||
);
|
||||
|
||||
for (const locale of LOCALES.slice(1)) {
|
||||
await page
|
||||
.getByTestId("language-switcher")
|
||||
.selectOption(locale);
|
||||
await expect(page.getByTestId("greeting")).toHaveText(
|
||||
GREETING_BY_LOCALE[locale]
|
||||
);
|
||||
await expect(page.getByTestId("auth-signin")).toHaveText(
|
||||
SIGN_IN_BY_LOCALE[locale]
|
||||
);
|
||||
await expect(page.getByTestId("current-locale")).toContainText(
|
||||
locale
|
||||
);
|
||||
}
|
||||
|
||||
// Persistence: capture the persisted entry, reload, and confirm
|
||||
// the App initialises from localStorage (not the en-GB default).
|
||||
const lastLocale = LOCALES[LOCALES.length - 1];
|
||||
const persistedBefore = await page.evaluate(() =>
|
||||
window.localStorage.getItem("suaveplan-react-i18n-e2e-locale")
|
||||
);
|
||||
expect(persistedBefore).toBe(lastLocale);
|
||||
await page.reload();
|
||||
await expect(page.getByTestId("greeting")).toHaveText(
|
||||
GREETING_BY_LOCALE[lastLocale]
|
||||
);
|
||||
const persistedAfter = await page.evaluate(() =>
|
||||
window.localStorage.getItem("suaveplan-react-i18n-e2e-locale")
|
||||
);
|
||||
expect(persistedAfter).toBe(lastLocale);
|
||||
|
||||
consoleMonitor.assertClean();
|
||||
networkMonitor.assertNoFailures();
|
||||
});
|
||||
|
||||
test("flips <html dir=rtl> and mirrors layout when Arabic is active", async ({
|
||||
page,
|
||||
}) => {
|
||||
const consoleMonitor = attachConsoleMonitor(page, { failOn: ["error"] });
|
||||
const networkMonitor = attachNetworkMonitor(page);
|
||||
|
||||
await page.goto("/");
|
||||
const ltrBox = await page
|
||||
.getByTestId("greeting")
|
||||
.boundingBox();
|
||||
expect(ltrBox).not.toBeNull();
|
||||
|
||||
const initialDir = await page.evaluate(() =>
|
||||
document.documentElement.getAttribute("dir")
|
||||
);
|
||||
expect(initialDir).toBe("ltr");
|
||||
|
||||
await page.getByTestId("language-switcher").selectOption("ar-AE");
|
||||
await expect(page.getByTestId("greeting")).toHaveText(
|
||||
GREETING_BY_LOCALE["ar-AE"]
|
||||
);
|
||||
|
||||
await expect
|
||||
.poll(async () =>
|
||||
page.evaluate(() =>
|
||||
document.documentElement.getAttribute("dir")
|
||||
)
|
||||
)
|
||||
.toBe("rtl");
|
||||
|
||||
const rtlBox = await page
|
||||
.getByTestId("greeting")
|
||||
.boundingBox();
|
||||
expect(rtlBox).not.toBeNull();
|
||||
// RTL layout flips text alignment — the bounding box's right edge
|
||||
// anchors to the same x as the parent's right padding while the
|
||||
// left edge moves outward. The simplest provable invariant is the
|
||||
// direction attribute itself, which we assert above; here we
|
||||
// additionally confirm the render produced a measurable box (i.e.
|
||||
// not a zero-sized phantom node from a failed reflow).
|
||||
expect((rtlBox?.width ?? 0) > 0).toBe(true);
|
||||
expect((ltrBox?.width ?? 0) > 0).toBe(true);
|
||||
|
||||
consoleMonitor.assertClean();
|
||||
networkMonitor.assertNoFailures();
|
||||
});
|
||||
|
||||
test("renders the I18nSuspense fallback while the auth namespace loads", async ({
|
||||
page,
|
||||
}) => {
|
||||
const consoleMonitor = attachConsoleMonitor(page, { failOn: ["error"] });
|
||||
const networkMonitor = attachNetworkMonitor(page);
|
||||
|
||||
// Slow the navigation enough for the very first render to mount
|
||||
// the Suspense boundary before the auth namespace resolves.
|
||||
await page.goto("/");
|
||||
const fallback = page.getByTestId(
|
||||
"suaveplan-i18n-suspense-fallback"
|
||||
);
|
||||
// Fallback may render very briefly; race the assertion against the
|
||||
// resolved auth panel and assert at least one of the two reaches
|
||||
// its terminal state. The fallback's `role="status"` + busy
|
||||
// attribute give us a deterministic selector.
|
||||
await Promise.race([
|
||||
fallback.waitFor({ state: "visible", timeout: 1500 }).catch(() => undefined),
|
||||
page.getByTestId("auth-panel").waitFor({ state: "visible", timeout: 5000 }),
|
||||
]);
|
||||
|
||||
// Once the namespace has loaded, the panel must show the
|
||||
// translated string — proof the Suspense boundary actually
|
||||
// resolved instead of leaving a key string in the DOM.
|
||||
await expect(page.getByTestId("auth-signin")).toHaveText(
|
||||
SIGN_IN_BY_LOCALE["en-GB"]
|
||||
);
|
||||
await expect(page.getByTestId("auth-welcome")).not.toHaveText("welcome");
|
||||
|
||||
consoleMonitor.assertClean();
|
||||
networkMonitor.assertNoFailures();
|
||||
});
|
||||
|
||||
test("missing-key overlay caps at 50 entries with aria-live=polite", async ({
|
||||
page,
|
||||
}) => {
|
||||
const consoleMonitor = attachConsoleMonitor(page, { failOn: ["error"] });
|
||||
const networkMonitor = attachNetworkMonitor(page);
|
||||
|
||||
await page.goto("/");
|
||||
const triggerButton = page.getByTestId("trigger-missing");
|
||||
const overlay = page.getByTestId("suaveplan-missing-key-overlay");
|
||||
|
||||
// Fire 60 distinct missing keys; overlay must cap at 50.
|
||||
for (let i = 0; i < 60; i += 1) {
|
||||
await triggerButton.click();
|
||||
}
|
||||
|
||||
await expect(overlay).toBeVisible();
|
||||
await expect(overlay).toHaveAttribute("aria-live", "polite");
|
||||
|
||||
const itemCount = await overlay.locator("li").count();
|
||||
expect(itemCount).toBe(50);
|
||||
|
||||
consoleMonitor.assertClean();
|
||||
networkMonitor.assertNoFailures();
|
||||
});
|
||||
|
||||
test("SSR hydration round-trip serves translated markup with no flash", async ({
|
||||
page,
|
||||
}) => {
|
||||
const consoleMonitor = attachConsoleMonitor(page, {
|
||||
failOn: ["error", "warning"],
|
||||
ignore: [/Download the React DevTools/i],
|
||||
});
|
||||
const networkMonitor = attachNetworkMonitor(page);
|
||||
|
||||
// Capture the raw server response BEFORE hydration to prove the
|
||||
// initial HTML already contains the translated string. If we only
|
||||
// looked at the live DOM we could not distinguish "SSR worked"
|
||||
// from "client rendered after a flash".
|
||||
const response = await page.request.get("/ssr/zh-CN");
|
||||
expect(response.status()).toBe(200);
|
||||
const rawHtml = await response.text();
|
||||
expect(rawHtml).toContain(GREETING_BY_LOCALE["zh-CN"]);
|
||||
expect(rawHtml).toContain('lang="zh-CN"');
|
||||
expect(rawHtml).toContain('dir="ltr"');
|
||||
|
||||
await page.goto("/ssr/zh-CN");
|
||||
// Assert the visible greeting is the SSR string immediately and
|
||||
// does not change after hydration (no flash). We poll the text
|
||||
// for 400ms — far longer than any reasonable hydration step on
|
||||
// a local Vite dev server — to confirm stability.
|
||||
const greeting = page.getByTestId("greeting");
|
||||
await expect(greeting).toHaveText(GREETING_BY_LOCALE["zh-CN"]);
|
||||
const before = await greeting.textContent();
|
||||
await page.waitForLoadState("networkidle");
|
||||
const after = await greeting.textContent();
|
||||
expect(after).toBe(before);
|
||||
|
||||
consoleMonitor.assertClean();
|
||||
networkMonitor.assertNoFailures();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,12 @@
|
||||
<!doctype html>
|
||||
<html lang="en-GB" dir="ltr">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>react-i18n e2e</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/entry-client.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
219
packages/localization/i18n/react-i18n/e2e/test-app/src/App.tsx
Normal file
219
packages/localization/i18n/react-i18n/e2e/test-app/src/App.tsx
Normal file
@@ -0,0 +1,219 @@
|
||||
import type { TranslationRecord } from "@suaveplan/i18n";
|
||||
import { createI18n } from "@suaveplan/i18n/plugin";
|
||||
import {
|
||||
I18nSuspense,
|
||||
LanguageSwitcher,
|
||||
MissingKeyOverlay,
|
||||
Trans,
|
||||
TranslationProvider,
|
||||
useDirection,
|
||||
useLocale,
|
||||
useTranslation,
|
||||
} from "@suaveplan/react-i18n";
|
||||
import { useEffect, useState } from "react";
|
||||
import { createTestBackend } from "./test-backend.js";
|
||||
import {
|
||||
AUTH_BUNDLE,
|
||||
COMMON_BUNDLE,
|
||||
SUPPORTED_LOCALES,
|
||||
type SupportedLocale,
|
||||
} from "./translations.js";
|
||||
|
||||
const LOCAL_STORAGE_KEY = "suaveplan-react-i18n-e2e-locale";
|
||||
const TEST_BACKEND = createTestBackend({ delayMs: 200 });
|
||||
|
||||
interface AppProps {
|
||||
readonly initialLocale?: SupportedLocale;
|
||||
/**
|
||||
* When true, the `auth` namespace is also bundled into
|
||||
* `initialMessages` so SSR renders the full panel synchronously
|
||||
* (no Suspense fallback). Hydration still uses the same shape so
|
||||
* server and client agree on the initial markup.
|
||||
*/
|
||||
readonly includeAuth?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Top-level test-app shell. Wraps the tree in a `<TranslationProvider>`
|
||||
* and exposes the five Playwright scenarios (live switch, RTL, lazy
|
||||
* Suspense, missing-key overlay, SSR hydration round-trip) through a
|
||||
* single page so the spec only needs one `goto`.
|
||||
*/
|
||||
export function App(props: AppProps): JSX.Element {
|
||||
// Build the I18nInstance directly (caller-managed mode). This
|
||||
// bypasses the Provider's config-path Zod re-parse which strips
|
||||
// function-typed Backend methods — instead `i18n` is structurally
|
||||
// validated and forwarded as-is, preserving the backend's
|
||||
// `read`/`readMulti` callable surface. `useState` lazy-init keeps
|
||||
// the instance stable across re-renders.
|
||||
const [i18n] = useState(() =>
|
||||
createI18n({
|
||||
defaultLocale:
|
||||
props.initialLocale ?? readPersistedLocale() ?? "en-GB",
|
||||
fallbackLocale: "en-GB",
|
||||
messages: buildInitialMessages(props.includeAuth ?? false) as never,
|
||||
backend: TEST_BACKEND as never,
|
||||
})
|
||||
);
|
||||
return (
|
||||
<TranslationProvider i18n={i18n}>
|
||||
<Page />
|
||||
<MissingKeyOverlay />
|
||||
</TranslationProvider>
|
||||
);
|
||||
}
|
||||
|
||||
function buildInitialMessages(
|
||||
includeAuth: boolean
|
||||
): Record<SupportedLocale, TranslationRecord> {
|
||||
if (!includeAuth) return COMMON_BUNDLE;
|
||||
const merged: Record<SupportedLocale, TranslationRecord> = {
|
||||
...COMMON_BUNDLE,
|
||||
};
|
||||
for (const locale of SUPPORTED_LOCALES) {
|
||||
merged[locale] = {
|
||||
...COMMON_BUNDLE[locale],
|
||||
auth: AUTH_BUNDLE[locale],
|
||||
};
|
||||
}
|
||||
return merged;
|
||||
}
|
||||
|
||||
function Page(): JSX.Element {
|
||||
const { locale } = useLocale();
|
||||
const { direction } = useDirection();
|
||||
|
||||
// Mirror the active locale + direction onto the <html> element so
|
||||
// CSS hooks and screen readers see the same value the i18n core
|
||||
// resolved. Tests assert against this attribute set.
|
||||
useEffect(() => {
|
||||
document.documentElement.lang = locale;
|
||||
document.documentElement.dir = direction;
|
||||
try {
|
||||
window.localStorage.setItem(LOCAL_STORAGE_KEY, locale);
|
||||
} catch {
|
||||
// localStorage may be unavailable (private browsing); ignore.
|
||||
}
|
||||
}, [locale, direction]);
|
||||
|
||||
return (
|
||||
<main data-testid="page" style={{ padding: "2rem", fontFamily: "system-ui" }}>
|
||||
<h1 data-testid="greeting">
|
||||
<Trans i18nKey="common:greeting" />
|
||||
</h1>
|
||||
<p data-testid="current-locale">
|
||||
<Trans
|
||||
i18nKey="common:currentLocale"
|
||||
values={{ locale }}
|
||||
/>
|
||||
</p>
|
||||
|
||||
<section
|
||||
data-testid="switcher-section"
|
||||
style={{ marginTop: "1.5rem" }}
|
||||
>
|
||||
<LanguageSwitcher locales={[...SUPPORTED_LOCALES]}>
|
||||
{(state) => (
|
||||
<select
|
||||
data-testid="language-switcher"
|
||||
aria-busy={state.isPending}
|
||||
value={state.current}
|
||||
onChange={(event) => {
|
||||
void state.change(event.target.value);
|
||||
}}
|
||||
>
|
||||
{state.locales.map((entry) => (
|
||||
<option
|
||||
key={entry.code}
|
||||
value={entry.code}
|
||||
data-direction={entry.direction}
|
||||
>
|
||||
{entry.code}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
</LanguageSwitcher>
|
||||
</section>
|
||||
|
||||
<section
|
||||
data-testid="auth-section"
|
||||
style={{ marginTop: "1.5rem" }}
|
||||
>
|
||||
<I18nSuspense>
|
||||
<AuthPanel />
|
||||
</I18nSuspense>
|
||||
</section>
|
||||
|
||||
<section
|
||||
data-testid="missing-section"
|
||||
style={{ marginTop: "1.5rem" }}
|
||||
>
|
||||
<MissingKeyTrigger />
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
function AuthPanel(): JSX.Element {
|
||||
const { t } = useTranslation("auth");
|
||||
return (
|
||||
<div data-testid="auth-panel">
|
||||
<button type="button" data-testid="auth-signin">
|
||||
{t("signIn")}
|
||||
</button>
|
||||
<p data-testid="auth-welcome">{t("welcome")}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface MissingResolution {
|
||||
readonly key: string;
|
||||
readonly resolved: string;
|
||||
}
|
||||
|
||||
function MissingKeyTrigger(): JSX.Element {
|
||||
const [resolved, setResolved] = useState<readonly MissingResolution[]>([]);
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div>
|
||||
<button
|
||||
type="button"
|
||||
data-testid="trigger-missing"
|
||||
onClick={() => {
|
||||
// Resolve `t()` inside the click handler — NOT during
|
||||
// render — so the bus's `missingKey` event fires
|
||||
// outside React's render phase. This avoids the
|
||||
// "Cannot update a component while rendering a
|
||||
// different component" warning that would otherwise
|
||||
// fire when the overlay's `setState` lands inside a
|
||||
// sibling render pass.
|
||||
const next = `e2e.missing.${resolved.length + 1}`;
|
||||
const value = t(next);
|
||||
setResolved((prev) => [...prev, { key: next, resolved: value }]);
|
||||
}}
|
||||
>
|
||||
Request missing key
|
||||
</button>
|
||||
<ul data-testid="missing-list">
|
||||
{resolved.map((entry) => (
|
||||
<li key={entry.key} data-key={entry.key}>
|
||||
{entry.resolved}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function readPersistedLocale(): SupportedLocale | undefined {
|
||||
try {
|
||||
const value = window.localStorage.getItem(LOCAL_STORAGE_KEY);
|
||||
if (value !== null && (SUPPORTED_LOCALES as readonly string[]).includes(value)) {
|
||||
return value as SupportedLocale;
|
||||
}
|
||||
} catch {
|
||||
// ignore — falls back to default below
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import { hydrateRoot, createRoot } from "react-dom/client";
|
||||
import { App } from "./App.js";
|
||||
import type { SupportedLocale } from "./translations.js";
|
||||
|
||||
interface BootstrapWindow extends Window {
|
||||
__SUAVEPLAN_E2E_INITIAL_LOCALE__?: SupportedLocale;
|
||||
__SUAVEPLAN_E2E_SSR__?: boolean;
|
||||
}
|
||||
|
||||
const win = window as BootstrapWindow;
|
||||
const initialLocale = win.__SUAVEPLAN_E2E_INITIAL_LOCALE__;
|
||||
const container = document.getElementById("root");
|
||||
if (container === null) {
|
||||
throw new Error("Missing #root container");
|
||||
}
|
||||
|
||||
if (win.__SUAVEPLAN_E2E_SSR__ === true) {
|
||||
// SSR mode: hydrate the server-rendered tree without re-mounting so
|
||||
// we can assert there is no hydration warning + no flash of un-
|
||||
// translated content. `includeAuth=true` mirrors the server's
|
||||
// initialMessages so the client's first render produces byte-
|
||||
// identical DOM and React does not log a hydration mismatch.
|
||||
hydrateRoot(
|
||||
container,
|
||||
<App initialLocale={initialLocale} includeAuth={true} />
|
||||
);
|
||||
} else {
|
||||
createRoot(container).render(<App initialLocale={initialLocale} />);
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { renderToString } from "react-dom/server";
|
||||
import { App } from "./App.js";
|
||||
import type { SupportedLocale } from "./translations.js";
|
||||
|
||||
export interface RenderResult {
|
||||
readonly html: string;
|
||||
readonly locale: SupportedLocale;
|
||||
}
|
||||
|
||||
/**
|
||||
* Server-side render the test-app with a pinned `initialLocale`. The Vite
|
||||
* dev server's SSR middleware loads this module via `ssrLoadModule` and
|
||||
* inlines the result into the served HTML, alongside a script tag that
|
||||
* surfaces the initial locale to the client entry for hydration.
|
||||
*/
|
||||
export function render(locale: SupportedLocale): RenderResult {
|
||||
// The SSR pass pre-populates the catalog with both the `common` and
|
||||
// `auth` namespaces so no Suspense boundary suspends on the server.
|
||||
// The client hydrate pass mirrors the same shape so the initial DOM
|
||||
// matches and React does not log a hydration mismatch.
|
||||
const html = renderToString(
|
||||
<App initialLocale={locale} includeAuth={true} />
|
||||
);
|
||||
return { html, locale };
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
import type { TranslationRecord } from "@suaveplan/i18n";
|
||||
import { AUTH_BUNDLE, COMMON_BUNDLE } from "./translations.js";
|
||||
|
||||
/**
|
||||
* Tiny async test backend: serves the `auth` namespace with a
|
||||
* deterministic 200ms delay so Playwright can observe the
|
||||
* `<I18nSuspense>` fallback before the namespace resolves.
|
||||
*
|
||||
* The `common` namespace is delivered through `initialMessages` and
|
||||
* never reaches the backend, mirroring real production deployments
|
||||
* where the SSR payload pre-populates the catalog.
|
||||
*
|
||||
* The shape mirrors `@suaveplan/i18n`'s `Backend` interface (intentionally
|
||||
* not imported because the universal core does not re-export it from the
|
||||
* public root). The `<TranslationProvider>` validates the structural
|
||||
* `name` + `capabilities` fields via `I18nConfigSchema`'s `BackendShape`
|
||||
* which is what matters here.
|
||||
*/
|
||||
export interface TestBackend {
|
||||
readonly name: string;
|
||||
readonly capabilities: {
|
||||
readonly canRead: true;
|
||||
readonly canReadMulti: boolean;
|
||||
readonly canSave: boolean;
|
||||
readonly canTrackModification: boolean;
|
||||
readonly canWatch: boolean;
|
||||
};
|
||||
read(locale: string, namespace: string): Promise<TranslationRecord>;
|
||||
readMulti(
|
||||
locale: string,
|
||||
namespaces: readonly string[]
|
||||
): Promise<Record<string, TranslationRecord>>;
|
||||
}
|
||||
|
||||
export interface TestBackendOptions {
|
||||
readonly delayMs?: number;
|
||||
}
|
||||
|
||||
export function createTestBackend(options: TestBackendOptions = {}): TestBackend {
|
||||
const delayMs = options.delayMs ?? 200;
|
||||
return {
|
||||
name: "e2e-test-backend",
|
||||
capabilities: {
|
||||
canRead: true,
|
||||
canReadMulti: true,
|
||||
canSave: false,
|
||||
canTrackModification: false,
|
||||
canWatch: false,
|
||||
},
|
||||
async read(locale, namespace): Promise<TranslationRecord> {
|
||||
await sleep(delayMs);
|
||||
if (namespace !== "auth") {
|
||||
throw new Error(`Unknown namespace ${namespace}`);
|
||||
}
|
||||
const bundle = AUTH_BUNDLE[locale as keyof typeof AUTH_BUNDLE];
|
||||
if (bundle === undefined) {
|
||||
throw new Error(`Unknown locale ${locale}`);
|
||||
}
|
||||
return bundle;
|
||||
},
|
||||
async readMulti(locale, namespaces): Promise<
|
||||
Record<string, TranslationRecord>
|
||||
> {
|
||||
const out: Record<string, TranslationRecord> = {};
|
||||
for (const ns of namespaces) {
|
||||
if (ns === "auth") {
|
||||
const bundle =
|
||||
AUTH_BUNDLE[locale as keyof typeof AUTH_BUNDLE];
|
||||
if (bundle !== undefined) {
|
||||
out[ns] = bundle;
|
||||
}
|
||||
} else if (ns === "common") {
|
||||
const bundle =
|
||||
COMMON_BUNDLE[locale as keyof typeof COMMON_BUNDLE];
|
||||
if (bundle !== undefined) {
|
||||
out[ns] = bundle;
|
||||
}
|
||||
}
|
||||
}
|
||||
await sleep(delayMs);
|
||||
return out;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function sleep(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => {
|
||||
setTimeout(resolve, ms);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
/**
|
||||
* Test-app translations for the react-i18n Playwright suite.
|
||||
*
|
||||
* These literal strings ARE the test fixture — they would be supplied
|
||||
* by the consumer app in production. The translations namespace `common`
|
||||
* is preloaded synchronously via `initialMessages`; the `auth` namespace
|
||||
* is delivered by the async backend so Suspense fallbacks fire.
|
||||
*/
|
||||
|
||||
import type { TranslationRecord } from "@suaveplan/i18n";
|
||||
|
||||
export type SupportedLocale =
|
||||
| "en-GB"
|
||||
| "ar-AE"
|
||||
| "zh-CN"
|
||||
| "cy-GB"
|
||||
| "ru-RU";
|
||||
|
||||
export const SUPPORTED_LOCALES: readonly SupportedLocale[] = [
|
||||
"en-GB",
|
||||
"ar-AE",
|
||||
"zh-CN",
|
||||
"cy-GB",
|
||||
"ru-RU",
|
||||
];
|
||||
|
||||
export const COMMON_BUNDLE: Record<SupportedLocale, TranslationRecord> = {
|
||||
"en-GB": {
|
||||
common: {
|
||||
greeting: "Hello, world",
|
||||
language: "Language",
|
||||
switchTo: "Switch to {locale}",
|
||||
currentLocale: "Current locale: {locale}",
|
||||
},
|
||||
},
|
||||
"ar-AE": {
|
||||
common: {
|
||||
greeting: "مرحبا بالعالم",
|
||||
language: "اللغة",
|
||||
switchTo: "التبديل إلى {locale}",
|
||||
currentLocale: "اللغة الحالية: {locale}",
|
||||
},
|
||||
},
|
||||
"zh-CN": {
|
||||
common: {
|
||||
greeting: "你好,世界",
|
||||
language: "语言",
|
||||
switchTo: "切换到 {locale}",
|
||||
currentLocale: "当前语言:{locale}",
|
||||
},
|
||||
},
|
||||
"cy-GB": {
|
||||
common: {
|
||||
greeting: "Helo, byd",
|
||||
language: "Iaith",
|
||||
switchTo: "Newid i {locale}",
|
||||
currentLocale: "Iaith bresennol: {locale}",
|
||||
},
|
||||
},
|
||||
"ru-RU": {
|
||||
common: {
|
||||
greeting: "Привет, мир",
|
||||
language: "Язык",
|
||||
switchTo: "Переключиться на {locale}",
|
||||
currentLocale: "Текущая локаль: {locale}",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const AUTH_BUNDLE: Record<SupportedLocale, TranslationRecord> = {
|
||||
"en-GB": {
|
||||
signIn: "Sign in",
|
||||
welcome: "Welcome back",
|
||||
},
|
||||
"ar-AE": {
|
||||
signIn: "تسجيل الدخول",
|
||||
welcome: "مرحبا بعودتك",
|
||||
},
|
||||
"zh-CN": {
|
||||
signIn: "登录",
|
||||
welcome: "欢迎回来",
|
||||
},
|
||||
"cy-GB": {
|
||||
signIn: "Mewngofnodi",
|
||||
welcome: "Croeso yn ôl",
|
||||
},
|
||||
"ru-RU": {
|
||||
signIn: "Войти",
|
||||
welcome: "С возвращением",
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/tsconfig",
|
||||
"extends": "../../../../../foundation/core/typescript-config/react-library.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "./src",
|
||||
"noEmit": true,
|
||||
"types": ["node"],
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": false
|
||||
},
|
||||
"include": ["src/**/*.ts", "src/**/*.tsx", "vite.config.ts"]
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import react from "@vitejs/plugin-react";
|
||||
import { defineConfig } from "vite";
|
||||
|
||||
export default defineConfig({
|
||||
root: __dirname,
|
||||
server: {
|
||||
port: 5273,
|
||||
strictPort: true,
|
||||
host: "127.0.0.1",
|
||||
},
|
||||
plugins: [react(), ssrMiddlewarePlugin()],
|
||||
optimizeDeps: {
|
||||
// Force pre-bundle of the workspace package so Vite resolves the
|
||||
// exports map correctly the first hit.
|
||||
include: ["@suaveplan/react-i18n", "@suaveplan/i18n"],
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* Adds a `/ssr/:locale` endpoint that renders the React tree on the
|
||||
* server, inlines the markup into the HTML template, and adds a tiny
|
||||
* bootstrap script that flips `entry-client.tsx` into hydration mode.
|
||||
*
|
||||
* This is the SSR scenario surface exercised by the Playwright spec —
|
||||
* the regular `/` route serves the standard CSR build.
|
||||
*/
|
||||
function ssrMiddlewarePlugin(): import("vite").Plugin {
|
||||
return {
|
||||
name: "react-i18n-e2e-ssr",
|
||||
configureServer(server) {
|
||||
server.middlewares.use(async (req, res, next) => {
|
||||
if (req.url === undefined || !req.url.startsWith("/ssr/")) {
|
||||
next();
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const localeFromUrl = req.url.replace(/^\/ssr\//, "").split("?")[0] ?? "";
|
||||
const locale = decodeURIComponent(localeFromUrl);
|
||||
const templatePath = `${__dirname}/index.html`;
|
||||
const fs = await import("node:fs/promises");
|
||||
const rawTemplate = await fs.readFile(templatePath, "utf8");
|
||||
const transformed = await server.transformIndexHtml(req.url, rawTemplate);
|
||||
const mod = await server.ssrLoadModule("/src/entry-server.tsx");
|
||||
const renderFn = (mod as { render: (l: string) => { html: string; locale: string } }).render;
|
||||
const { html, locale: resolvedLocale } = renderFn(locale);
|
||||
const bootstrap = `<script>window.__SUAVEPLAN_E2E_INITIAL_LOCALE__=${JSON.stringify(resolvedLocale)};window.__SUAVEPLAN_E2E_SSR__=true;</script>`;
|
||||
const finalHtml = transformed
|
||||
.replace(`<div id="root"></div>`, `<div id="root">${html}</div>${bootstrap}`)
|
||||
.replace(`<html lang="en-GB" dir="ltr">`, `<html lang="${resolvedLocale}" dir="${resolvedLocale.startsWith("ar") ? "rtl" : "ltr"}">`);
|
||||
res.statusCode = 200;
|
||||
res.setHeader("Content-Type", "text/html");
|
||||
res.end(finalHtml);
|
||||
} catch (error) {
|
||||
next(error as Error);
|
||||
}
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
197
packages/localization/i18n/react-i18n/package.json
Normal file
197
packages/localization/i18n/react-i18n/package.json
Normal file
@@ -0,0 +1,197 @@
|
||||
{
|
||||
"name": "@suaveplan/react-i18n",
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"sideEffects": false,
|
||||
"description": "React bindings for @suaveplan/i18n: TranslationProvider, hooks, <Trans>, <LanguageSwitcher>, locale-detection chain glue, branded TranslationKey contract.",
|
||||
"license": "MIT",
|
||||
"author": "Suaveplan Team",
|
||||
"main": "./dist/index.js",
|
||||
"module": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts",
|
||||
"exports": {
|
||||
"./package.json": "./package.json",
|
||||
".": {
|
||||
"import": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"default": "./dist/index.js"
|
||||
},
|
||||
"require": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"default": "./dist/index.js"
|
||||
}
|
||||
},
|
||||
"./provider": {
|
||||
"import": {
|
||||
"types": "./dist/provider/index.d.ts",
|
||||
"default": "./dist/provider/index.js"
|
||||
},
|
||||
"require": {
|
||||
"types": "./dist/provider/index.d.ts",
|
||||
"default": "./dist/provider/index.js"
|
||||
}
|
||||
},
|
||||
"./errors": {
|
||||
"import": {
|
||||
"types": "./dist/errors/index.d.ts",
|
||||
"default": "./dist/errors/index.js"
|
||||
},
|
||||
"require": {
|
||||
"types": "./dist/errors/index.d.ts",
|
||||
"default": "./dist/errors/index.js"
|
||||
}
|
||||
},
|
||||
"./schemas": {
|
||||
"import": {
|
||||
"types": "./dist/schemas/index.d.ts",
|
||||
"default": "./dist/schemas/index.js"
|
||||
},
|
||||
"require": {
|
||||
"types": "./dist/schemas/index.d.ts",
|
||||
"default": "./dist/schemas/index.js"
|
||||
}
|
||||
},
|
||||
"./config": {
|
||||
"import": {
|
||||
"types": "./dist/config/index.d.ts",
|
||||
"default": "./dist/config/index.js"
|
||||
},
|
||||
"require": {
|
||||
"types": "./dist/config/index.d.ts",
|
||||
"default": "./dist/config/index.js"
|
||||
}
|
||||
},
|
||||
"./observability": {
|
||||
"import": {
|
||||
"types": "./dist/observability/react-i18n-metrics.d.ts",
|
||||
"default": "./dist/observability/react-i18n-metrics.js"
|
||||
},
|
||||
"require": {
|
||||
"types": "./dist/observability/react-i18n-metrics.d.ts",
|
||||
"default": "./dist/observability/react-i18n-metrics.js"
|
||||
}
|
||||
},
|
||||
"./components/trans": {
|
||||
"import": {
|
||||
"types": "./dist/components/trans/trans.d.ts",
|
||||
"default": "./dist/components/trans/trans.js"
|
||||
},
|
||||
"require": {
|
||||
"types": "./dist/components/trans/trans.d.ts",
|
||||
"default": "./dist/components/trans/trans.js"
|
||||
}
|
||||
},
|
||||
"./components/language-switcher": {
|
||||
"import": {
|
||||
"types": "./dist/components/language-switcher/language-switcher.d.ts",
|
||||
"default": "./dist/components/language-switcher/language-switcher.js"
|
||||
},
|
||||
"require": {
|
||||
"types": "./dist/components/language-switcher/language-switcher.d.ts",
|
||||
"default": "./dist/components/language-switcher/language-switcher.js"
|
||||
}
|
||||
},
|
||||
"./components/missing-key-overlay": {
|
||||
"import": {
|
||||
"types": "./dist/components/missing-key-overlay/missing-key-overlay.d.ts",
|
||||
"default": "./dist/components/missing-key-overlay/missing-key-overlay.js"
|
||||
},
|
||||
"require": {
|
||||
"types": "./dist/components/missing-key-overlay/missing-key-overlay.d.ts",
|
||||
"default": "./dist/components/missing-key-overlay/missing-key-overlay.js"
|
||||
}
|
||||
},
|
||||
"./components/i18n-suspense": {
|
||||
"import": {
|
||||
"types": "./dist/components/i18n-suspense/i18n-suspense.d.ts",
|
||||
"default": "./dist/components/i18n-suspense/i18n-suspense.js"
|
||||
},
|
||||
"require": {
|
||||
"types": "./dist/components/i18n-suspense/i18n-suspense.d.ts",
|
||||
"default": "./dist/components/i18n-suspense/i18n-suspense.js"
|
||||
}
|
||||
},
|
||||
"./hoc/with-translation": {
|
||||
"import": {
|
||||
"types": "./dist/hoc/with-translation/with-translation.d.ts",
|
||||
"default": "./dist/hoc/with-translation/with-translation.js"
|
||||
},
|
||||
"require": {
|
||||
"types": "./dist/hoc/with-translation/with-translation.d.ts",
|
||||
"default": "./dist/hoc/with-translation/with-translation.js"
|
||||
}
|
||||
},
|
||||
"./registry": {
|
||||
"import": {
|
||||
"types": "./dist/registry/index.d.ts",
|
||||
"default": "./dist/registry/index.js"
|
||||
},
|
||||
"require": {
|
||||
"types": "./dist/registry/index.d.ts",
|
||||
"default": "./dist/registry/index.js"
|
||||
}
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"build": "vite build",
|
||||
"test": "vitest run --passWithNoTests",
|
||||
"test:watch": "vitest",
|
||||
"test:coverage": "vitest run --coverage --passWithNoTests",
|
||||
"test:e2e": "playwright test",
|
||||
"lint": "bunx biome check src/ && bun run ../../../../scripts/lint/check-no-backwards-deps.ts --package-json ./package.json",
|
||||
"lint:fix": "bunx biome lint --fix --unsafe --max-diagnostics 500 src/",
|
||||
"format": "bunx biome format --write --max-diagnostics 500 src/",
|
||||
"check": "bunx biome check --write --unsafe --max-diagnostics 500 src/",
|
||||
"typecheck": "bunx tsc --noEmit --composite false -p tsconfig.lib.json",
|
||||
"docs:check": "bun run ../../../../scripts/docs-check.ts ."
|
||||
},
|
||||
"dependencies": {
|
||||
"@suaveplan/error": "workspace:^",
|
||||
"@suaveplan/events": "workspace:^",
|
||||
"@suaveplan/i18n": "workspace:^",
|
||||
"@suaveplan/i18n-detector": "workspace:^",
|
||||
"@suaveplan/i18n-formatter-currency": "workspace:^",
|
||||
"@suaveplan/i18n-formatter-date": "workspace:^",
|
||||
"@suaveplan/i18n-icu-parser": "workspace:^",
|
||||
"@suaveplan/i18n-shared": "workspace:^",
|
||||
"@suaveplan/locale-data": "workspace:^",
|
||||
"@suaveplan/logger": "workspace:^",
|
||||
"@suaveplan/types": "workspace:^",
|
||||
"@suaveplan/validation": "workspace:^",
|
||||
"@opentelemetry/api": "catalog:observability",
|
||||
"tslib": "catalog:build",
|
||||
"zod": "catalog:forms"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^18.0.0 || ^19.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "catalog:testing",
|
||||
"@suaveplan/testing": "workspace:^",
|
||||
"@suaveplan/testing-dom": "workspace:^",
|
||||
"@suaveplan/testing-e2e": "workspace:^",
|
||||
"@testing-library/react": "catalog:testing",
|
||||
"@types/node": "catalog:",
|
||||
"@types/react": "catalog:testing",
|
||||
"@types/react-dom": "catalog:testing",
|
||||
"@vitejs/plugin-react": "catalog:testing",
|
||||
"@vitest/coverage-istanbul": "catalog:testing",
|
||||
"fast-check": "catalog:testing",
|
||||
"happy-dom": "catalog:testing",
|
||||
"react": "catalog:testing",
|
||||
"react-dom": "catalog:testing",
|
||||
"typescript": "catalog:",
|
||||
"vite": "catalog:build",
|
||||
"vite-plugin-dts": "catalog:build",
|
||||
"vitest": "catalog:testing"
|
||||
},
|
||||
"publishConfig": {
|
||||
"registry": "https://npmjs.fq.io/",
|
||||
"access": "public"
|
||||
},
|
||||
"files": [
|
||||
"dist",
|
||||
"src",
|
||||
"README.md"
|
||||
]
|
||||
}
|
||||
27
packages/localization/i18n/react-i18n/playwright.config.ts
Normal file
27
packages/localization/i18n/react-i18n/playwright.config.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import { devices } from "@playwright/test";
|
||||
import { createPlaywrightConfig } from "@suaveplan/testing-e2e/config";
|
||||
|
||||
export default createPlaywrightConfig({
|
||||
testDir: "./e2e",
|
||||
testMatch: /\.spec\.ts$/,
|
||||
timeout: 30_000,
|
||||
expect: { timeout: 5_000 },
|
||||
reporter: process.env["CI"] ? [["list"], ["html", { open: "never" }]] : [["list"]],
|
||||
projects: [
|
||||
{
|
||||
name: "chromium",
|
||||
use: { ...devices["Desktop Chrome"] },
|
||||
},
|
||||
],
|
||||
webServer: {
|
||||
command: "bunx vite --config e2e/test-app/vite.config.ts",
|
||||
url: "http://127.0.0.1:5273/",
|
||||
reuseExistingServer: !process.env["CI"],
|
||||
timeout: 120_000,
|
||||
stdout: "pipe",
|
||||
stderr: "pipe",
|
||||
},
|
||||
use: {
|
||||
baseURL: "http://127.0.0.1:5273",
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,334 @@
|
||||
/**
|
||||
* Tests for generate-translation-types.ts.
|
||||
*
|
||||
* Uses Bun's native test runner because this script lives outside the
|
||||
* `src/` tree of any workspace package — the repo-wide canonical-runner
|
||||
* rule (openspec/AGENTS.md §7) applies to `packages/<plugin>/<pkg>/src/`,
|
||||
* not to `packages/<plugin>/<pkg>/scripts/`. This matches the precedent
|
||||
* set by `scripts/lint/check-test-runner-imports.test.ts`.
|
||||
*
|
||||
* Real fixtures: every test writes a real en-GB.json to a real temp
|
||||
* directory and reads back the generated files via Bun.file. No mocks.
|
||||
*/
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it } from "bun:test";
|
||||
import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
|
||||
import {
|
||||
flattenKeys,
|
||||
generateTranslationTypes,
|
||||
packageNameToSymbol,
|
||||
renderAugmentationFile,
|
||||
renderTranslationKeysFile,
|
||||
} from "./generate-translation-types.ts";
|
||||
|
||||
interface Fixture {
|
||||
readonly root: string;
|
||||
cleanup(): Promise<void>;
|
||||
}
|
||||
|
||||
async function makeFixture(): Promise<Fixture> {
|
||||
const root = await mkdtemp(join(tmpdir(), "gen-translation-types-"));
|
||||
return {
|
||||
root,
|
||||
async cleanup() {
|
||||
await rm(root, { recursive: true, force: true });
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function writeBundle(
|
||||
root: string,
|
||||
bundle: Record<string, unknown>
|
||||
): Promise<void> {
|
||||
const dir = join(root, "src/locales");
|
||||
await mkdir(dir, { recursive: true });
|
||||
await writeFile(join(dir, "en-GB.json"), JSON.stringify(bundle));
|
||||
}
|
||||
|
||||
describe("flattenKeys", () => {
|
||||
it("returns top-level dot-keys as-is when leaves are strings", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const out = flattenKeys({ "ns.key.a": "A", "ns.key.b": "B" });
|
||||
expect(out.sort()).toEqual(["ns.key.a", "ns.key.b"]);
|
||||
});
|
||||
|
||||
it("flattens nested records by dot-joining", { timeout: 5000 }, () => {
|
||||
const out = flattenKeys({
|
||||
errors: { auth: { invalid_token: "x", expired: "y" } },
|
||||
greeting: { hello: "Hi" },
|
||||
});
|
||||
expect(out.sort()).toEqual([
|
||||
"errors.auth.expired",
|
||||
"errors.auth.invalid_token",
|
||||
"greeting.hello",
|
||||
]);
|
||||
});
|
||||
|
||||
it("collapses plural shapes to a single parent key", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const out = flattenKeys({
|
||||
items: { one: "{count} item", other: "{count} items" },
|
||||
});
|
||||
expect(out).toEqual(["items"]);
|
||||
});
|
||||
|
||||
it("rejects array leaves with a typed error", { timeout: 5000 }, () => {
|
||||
expect(() => flattenKeys({ bad: ["nope"] })).toThrow(
|
||||
/Invalid leaf value at key 'bad'/
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects number leaves with a typed error", { timeout: 5000 }, () => {
|
||||
expect(() => flattenKeys({ bad: 42 } as Record<string, unknown>)).toThrow(
|
||||
/Invalid leaf value at key 'bad'/
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("packageNameToSymbol", () => {
|
||||
it("strips the @scope/ prefix and PascalCases the slug", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
expect(packageNameToSymbol("@suaveplan/react-ui")).toBe("ReactUi");
|
||||
expect(packageNameToSymbol("@suaveplan/react-ui-pack-auth")).toBe(
|
||||
"ReactUiPackAuth"
|
||||
);
|
||||
});
|
||||
|
||||
it("handles names without a scope", { timeout: 5000 }, () => {
|
||||
expect(packageNameToSymbol("plain-name")).toBe("PlainName");
|
||||
});
|
||||
|
||||
it("ignores empty parts in slug splits", { timeout: 5000 }, () => {
|
||||
expect(packageNameToSymbol("@x/--double--dash--")).toBe("DoubleDash");
|
||||
});
|
||||
});
|
||||
|
||||
describe("renderTranslationKeysFile", () => {
|
||||
it("emits a sorted union with the package-derived symbol", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const out = renderTranslationKeysFile(
|
||||
["ns.key.b", "ns.key.a"],
|
||||
"@suaveplan/react-ui"
|
||||
);
|
||||
expect(out).toContain("export type ReactUiTranslationKey =");
|
||||
const aIndex = out.indexOf('"ns.key.a"');
|
||||
const bIndex = out.indexOf('"ns.key.b"');
|
||||
expect(aIndex).toBeGreaterThan(0);
|
||||
expect(bIndex).toBeGreaterThan(aIndex);
|
||||
});
|
||||
|
||||
it("emits `never` when the key list is empty", { timeout: 5000 }, () => {
|
||||
const out = renderTranslationKeysFile([], "@suaveplan/x");
|
||||
expect(out).toContain("export type XTranslationKey = never;");
|
||||
});
|
||||
|
||||
it("falls back to TranslationKey when no package name is given", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const out = renderTranslationKeysFile(["a"], undefined);
|
||||
expect(out).toContain("export type TranslationKey =");
|
||||
expect(out).toContain('| "a";');
|
||||
});
|
||||
|
||||
it("dedupes input keys", { timeout: 5000 }, () => {
|
||||
const out = renderTranslationKeysFile(["dup", "dup", "dup"], undefined);
|
||||
const matches = out.match(/"dup"/g) ?? [];
|
||||
expect(matches.length).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("renderAugmentationFile", () => {
|
||||
it("emits the declare-module block with sorted entries", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const out = renderAugmentationFile(
|
||||
["b.x", "a.y"],
|
||||
"@suaveplan/react-ui"
|
||||
);
|
||||
expect(out).toContain('declare module "@suaveplan/react-i18n"');
|
||||
expect(out).toContain("interface ContributedTranslationKeys");
|
||||
expect(out).toContain('"a.y": true;');
|
||||
expect(out).toContain('"b.x": true;');
|
||||
const aIdx = out.indexOf('"a.y"');
|
||||
const bIdx = out.indexOf('"b.x"');
|
||||
expect(aIdx).toBeGreaterThan(0);
|
||||
expect(bIdx).toBeGreaterThan(aIdx);
|
||||
});
|
||||
|
||||
it("emits an empty interface body when no keys are present", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const out = renderAugmentationFile([], "@suaveplan/empty");
|
||||
expect(out).toContain("interface ContributedTranslationKeys {");
|
||||
expect(out).toContain(" }");
|
||||
});
|
||||
|
||||
it("renders the unnamed-package header when packageName is undefined", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const out = renderAugmentationFile(["k"], undefined);
|
||||
expect(out).toContain("this package's translation keys");
|
||||
});
|
||||
|
||||
it("renders the unnamed-package header for translation-keys.ts too", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const out = renderTranslationKeysFile(["k"], undefined);
|
||||
expect(out).toContain("this package's en-GB bundle");
|
||||
});
|
||||
});
|
||||
|
||||
describe("generateTranslationTypes — end to end", () => {
|
||||
let fixture: Fixture;
|
||||
beforeEach(async () => {
|
||||
fixture = await makeFixture();
|
||||
});
|
||||
afterEach(async () => {
|
||||
await fixture.cleanup();
|
||||
});
|
||||
|
||||
it("reads en-GB.json and writes both output files with sorted keys", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
await writeBundle(fixture.root, {
|
||||
"ns.key.b": "B",
|
||||
"ns.key.a": "A",
|
||||
});
|
||||
const result = await generateTranslationTypes({
|
||||
packagePath: fixture.root,
|
||||
packageName: "@suaveplan/react-ui",
|
||||
});
|
||||
expect(result.translationKeys.sort()).toEqual([
|
||||
"ns.key.a",
|
||||
"ns.key.b",
|
||||
]);
|
||||
const keysSource = await readFile(
|
||||
result.translationKeysPath,
|
||||
"utf8"
|
||||
);
|
||||
expect(keysSource).toContain("export type ReactUiTranslationKey =");
|
||||
const aIndex = keysSource.indexOf('"ns.key.a"');
|
||||
const bIndex = keysSource.indexOf('"ns.key.b"');
|
||||
expect(aIndex).toBeGreaterThan(0);
|
||||
expect(bIndex).toBeGreaterThan(aIndex);
|
||||
const augSource = await readFile(result.augmentationPath, "utf8");
|
||||
expect(augSource).toContain('"ns.key.a": true;');
|
||||
expect(augSource).toContain('"ns.key.b": true;');
|
||||
expect(augSource).toContain('declare module "@suaveplan/react-i18n"');
|
||||
});
|
||||
|
||||
it("is deterministic across two runs over the same fixture", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
await writeBundle(fixture.root, {
|
||||
errors: { auth: { invalid_token: "x", expired: "y" } },
|
||||
greeting: { hello: "Hi" },
|
||||
});
|
||||
const a = await generateTranslationTypes({
|
||||
packagePath: fixture.root,
|
||||
packageName: "@suaveplan/x",
|
||||
});
|
||||
const aKeys = await readFile(a.translationKeysPath, "utf8");
|
||||
const aAug = await readFile(a.augmentationPath, "utf8");
|
||||
const b = await generateTranslationTypes({
|
||||
packagePath: fixture.root,
|
||||
packageName: "@suaveplan/x",
|
||||
});
|
||||
const bKeys = await readFile(b.translationKeysPath, "utf8");
|
||||
const bAug = await readFile(b.augmentationPath, "utf8");
|
||||
expect(aKeys).toBe(bKeys);
|
||||
expect(aAug).toBe(bAug);
|
||||
});
|
||||
|
||||
it("works without a package name", { timeout: 5000 }, async () => {
|
||||
await writeBundle(fixture.root, { simple: "x" });
|
||||
const result = await generateTranslationTypes({
|
||||
packagePath: fixture.root,
|
||||
});
|
||||
const keysSource = await readFile(
|
||||
result.translationKeysPath,
|
||||
"utf8"
|
||||
);
|
||||
expect(keysSource).toContain("export type TranslationKey =");
|
||||
expect(keysSource).toContain('| "simple";');
|
||||
});
|
||||
|
||||
it("throws when en-GB.json is missing", { timeout: 5000 }, async () => {
|
||||
await expect(
|
||||
generateTranslationTypes({ packagePath: fixture.root })
|
||||
).rejects.toThrow(/cannot read/);
|
||||
});
|
||||
|
||||
it("throws when en-GB.json is unparsable", { timeout: 5000 }, async () => {
|
||||
const dir = join(fixture.root, "src/locales");
|
||||
await mkdir(dir, { recursive: true });
|
||||
await writeFile(join(dir, "en-GB.json"), "{not-json");
|
||||
await expect(
|
||||
generateTranslationTypes({ packagePath: fixture.root })
|
||||
).rejects.toThrow(/cannot parse/);
|
||||
});
|
||||
|
||||
it("throws when en-GB.json is not a top-level object", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const dir = join(fixture.root, "src/locales");
|
||||
await mkdir(dir, { recursive: true });
|
||||
await writeFile(join(dir, "en-GB.json"), "[]");
|
||||
await expect(
|
||||
generateTranslationTypes({ packagePath: fixture.root })
|
||||
).rejects.toThrow(/top level/);
|
||||
});
|
||||
|
||||
it("throws when bundle JSON is null", { timeout: 5000 }, async () => {
|
||||
const dir = join(fixture.root, "src/locales");
|
||||
await mkdir(dir, { recursive: true });
|
||||
await writeFile(join(dir, "en-GB.json"), "null");
|
||||
await expect(
|
||||
generateTranslationTypes({ packagePath: fixture.root })
|
||||
).rejects.toThrow(/top level/);
|
||||
});
|
||||
|
||||
it("respects injected readFile / writeFile dependencies", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const reads: string[] = [];
|
||||
const writes: Array<{ path: string; content: string }> = [];
|
||||
const fakeRead = async (path: import("node:fs").PathLike) => {
|
||||
reads.push(String(path));
|
||||
return JSON.stringify({ greet: "hi" });
|
||||
};
|
||||
const fakeWrite = async (
|
||||
path: import("node:fs").PathLike,
|
||||
content: string | Uint8Array
|
||||
) => {
|
||||
writes.push({
|
||||
path: String(path),
|
||||
content:
|
||||
typeof content === "string"
|
||||
? content
|
||||
: new TextDecoder().decode(content),
|
||||
});
|
||||
};
|
||||
const result = await generateTranslationTypes(
|
||||
{
|
||||
packagePath: "/virtual/pkg",
|
||||
packageName: "@suaveplan/virtual",
|
||||
},
|
||||
{
|
||||
readFileFn: fakeRead as unknown as typeof readFile,
|
||||
writeFileFn: fakeWrite as unknown as typeof writeFile,
|
||||
}
|
||||
);
|
||||
expect(reads).toHaveLength(1);
|
||||
expect(writes).toHaveLength(2);
|
||||
expect(result.translationKeys).toEqual(["greet"]);
|
||||
expect(writes[0]?.content).toContain('"greet"');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,319 @@
|
||||
#!/usr/bin/env bun
|
||||
/**
|
||||
* generate-translation-types.ts
|
||||
*
|
||||
* Reads a workspace package's canonical English bundle
|
||||
* (`<package-path>/src/locales/en-GB.json`) and emits two sibling
|
||||
* files:
|
||||
*
|
||||
* <package-path>/src/locales/translation-keys.ts
|
||||
* A string-literal union type of every (flattened) key in the
|
||||
* bundle, sorted alphabetically for diff stability. Useful as a
|
||||
* regular type alias for non-React consumers.
|
||||
*
|
||||
* <package-path>/src/locales/augmentation.d.ts
|
||||
* A `declare module "@suaveplan/react-i18n"` block that registers
|
||||
* every key into the `ContributedTranslationKeys` interface so the
|
||||
* branded `TranslationKey` union widens to include the package's
|
||||
* keys. Side-effect-imported from the package's `src/index.ts` so
|
||||
* downstream consumers automatically inherit the registrations.
|
||||
*
|
||||
* Usage:
|
||||
*
|
||||
* bun packages/react/react-i18n/scripts/generate-translation-types.ts \
|
||||
* <package-path> [--package-name <name>]
|
||||
*
|
||||
* Determinism: keys are flattened with dot-joins, deduped (the same
|
||||
* key is rare but guarded), and sorted via `localeCompare("en")` so
|
||||
* the output is byte-identical for identical input regardless of
|
||||
* input-key insertion order.
|
||||
*
|
||||
* Exit codes:
|
||||
* 0 — generation succeeded
|
||||
* 1 — bundle missing or malformed (details on stderr)
|
||||
* 2 — internal error (cannot read repo, etc.)
|
||||
*/
|
||||
|
||||
import { readFile, writeFile } from "node:fs/promises";
|
||||
import { resolve } from "node:path";
|
||||
|
||||
export interface GenerateOptions {
|
||||
/** Absolute or repo-relative path to the package directory. */
|
||||
readonly packagePath: string;
|
||||
/** Optional package name for the comment header (e.g. `@suaveplan/react-ui`). */
|
||||
readonly packageName?: string;
|
||||
}
|
||||
|
||||
export interface GenerateResult {
|
||||
readonly bundlePath: string;
|
||||
readonly translationKeysPath: string;
|
||||
readonly augmentationPath: string;
|
||||
readonly translationKeys: readonly string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Recursively flatten a nested record of strings (or sub-records) into
|
||||
* a flat list of dot-joined keys. Non-string leaves outside a
|
||||
* recognised plural shape are treated as opaque sub-records and the
|
||||
* walker descends through them.
|
||||
*
|
||||
* Plural shapes (`{ one: '...', other: '...' }` and friends) collapse
|
||||
* to the parent key — that single key is what consumers reference via
|
||||
* `t('items', { count })` — so the leaves under `zero|one|two|few|many|other`
|
||||
* are NOT flattened into separate keys.
|
||||
*/
|
||||
const PLURAL_CATEGORIES = new Set([
|
||||
"zero",
|
||||
"one",
|
||||
"two",
|
||||
"few",
|
||||
"many",
|
||||
"other",
|
||||
]);
|
||||
|
||||
function isPluralShape(value: Record<string, unknown>): boolean {
|
||||
const keys = Object.keys(value);
|
||||
if (keys.length === 0) return false;
|
||||
return keys.every((k) => PLURAL_CATEGORIES.has(k));
|
||||
}
|
||||
|
||||
export function flattenKeys(
|
||||
bundle: Record<string, unknown>,
|
||||
prefix = ""
|
||||
): string[] {
|
||||
const out: string[] = [];
|
||||
for (const [rawKey, rawValue] of Object.entries(bundle)) {
|
||||
const joined = prefix === "" ? rawKey : `${prefix}.${rawKey}`;
|
||||
if (typeof rawValue === "string") {
|
||||
out.push(joined);
|
||||
continue;
|
||||
}
|
||||
if (
|
||||
typeof rawValue === "object" &&
|
||||
rawValue !== null &&
|
||||
!Array.isArray(rawValue)
|
||||
) {
|
||||
const sub = rawValue as Record<string, unknown>;
|
||||
if (isPluralShape(sub)) {
|
||||
out.push(joined);
|
||||
continue;
|
||||
}
|
||||
out.push(...flattenKeys(sub, joined));
|
||||
continue;
|
||||
}
|
||||
// Arrays / numbers / booleans / nulls are not valid translation
|
||||
// leaves — surface a typed error rather than silently dropping.
|
||||
throw new Error(
|
||||
`Invalid leaf value at key '${joined}': expected string or nested record, got ${typeof rawValue}`
|
||||
);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the `translation-keys.ts` payload — a sorted string-literal
|
||||
* union exported as `<PackageId>TranslationKey`. The identifier is
|
||||
* derived from the package name (e.g. `@suaveplan/react-ui` →
|
||||
* `ReactUiTranslationKey`).
|
||||
*/
|
||||
export function renderTranslationKeysFile(
|
||||
keys: readonly string[],
|
||||
packageName: string | undefined
|
||||
): string {
|
||||
const sorted = [...new Set(keys)].sort((a, b) => a.localeCompare(b, "en"));
|
||||
const symbol = packageName
|
||||
? `${packageNameToSymbol(packageName)}TranslationKey`
|
||||
: "TranslationKey";
|
||||
const lines = [
|
||||
"// Generated by packages/react/react-i18n/scripts/generate-translation-types.ts",
|
||||
"// Do not edit by hand — re-run the codegen script after editing en-GB.json.",
|
||||
"",
|
||||
];
|
||||
if (packageName) {
|
||||
lines.push(`/** Translation keys contributed by ${packageName}. */`);
|
||||
} else {
|
||||
lines.push(
|
||||
"/** Translation keys contributed by this package's en-GB bundle. */"
|
||||
);
|
||||
}
|
||||
if (sorted.length === 0) {
|
||||
lines.push(`export type ${symbol} = never;`);
|
||||
} else {
|
||||
lines.push(`export type ${symbol} =`);
|
||||
sorted.forEach((key, i) => {
|
||||
const tail = i === sorted.length - 1 ? ";" : "";
|
||||
lines.push(` | "${key}"${tail}`);
|
||||
});
|
||||
}
|
||||
lines.push("");
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the `augmentation.d.ts` payload — a `declare module` block
|
||||
* registering every key into `ContributedTranslationKeys`.
|
||||
*/
|
||||
export function renderAugmentationFile(
|
||||
keys: readonly string[],
|
||||
packageName: string | undefined
|
||||
): string {
|
||||
const sorted = [...new Set(keys)].sort((a, b) => a.localeCompare(b, "en"));
|
||||
const lines = [
|
||||
"// Generated by packages/react/react-i18n/scripts/generate-translation-types.ts",
|
||||
"// Do not edit by hand — re-run the codegen script after editing en-GB.json.",
|
||||
"",
|
||||
];
|
||||
if (packageName) {
|
||||
lines.push(
|
||||
`/** Module augmentation: register ${packageName}'s translation keys */`,
|
||||
"/** into @suaveplan/react-i18n's ContributedTranslationKeys interface. */"
|
||||
);
|
||||
} else {
|
||||
lines.push(
|
||||
"/** Module augmentation: register this package's translation keys */",
|
||||
"/** into @suaveplan/react-i18n's ContributedTranslationKeys interface. */"
|
||||
);
|
||||
}
|
||||
lines.push(
|
||||
'import "@suaveplan/react-i18n";',
|
||||
"",
|
||||
'declare module "@suaveplan/react-i18n" {',
|
||||
" interface ContributedTranslationKeys {"
|
||||
);
|
||||
if (sorted.length === 0) {
|
||||
// Empty stub keeps the augmentation file syntactically valid
|
||||
// but the interface gains no members.
|
||||
} else {
|
||||
for (const key of sorted) {
|
||||
lines.push(` "${key}": true;`);
|
||||
}
|
||||
}
|
||||
lines.push(" }", "}", "");
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
/**
|
||||
* `@suaveplan/react-ui` → `ReactUi`. Used to namespace the union
|
||||
* type identifier emitted by `renderTranslationKeysFile`.
|
||||
*/
|
||||
export function packageNameToSymbol(packageName: string): string {
|
||||
const slug = packageName.replace(/^@[^/]+\//, "");
|
||||
return slug
|
||||
.split(/[-_]/)
|
||||
.filter((part) => part.length > 0)
|
||||
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
|
||||
.join("");
|
||||
}
|
||||
|
||||
export async function generateTranslationTypes(
|
||||
options: GenerateOptions,
|
||||
deps: {
|
||||
readonly readFileFn?: typeof readFile;
|
||||
readonly writeFileFn?: typeof writeFile;
|
||||
} = {}
|
||||
): Promise<GenerateResult> {
|
||||
const readFn = deps.readFileFn ?? readFile;
|
||||
const writeFn = deps.writeFileFn ?? writeFile;
|
||||
const packageRoot = resolve(options.packagePath);
|
||||
const bundlePath = resolve(packageRoot, "src/locales/en-GB.json");
|
||||
const translationKeysPath = resolve(
|
||||
packageRoot,
|
||||
"src/locales/translation-keys.ts"
|
||||
);
|
||||
const augmentationPath = resolve(
|
||||
packageRoot,
|
||||
"src/locales/augmentation.d.ts"
|
||||
);
|
||||
|
||||
let raw: string;
|
||||
try {
|
||||
raw = await readFn(bundlePath, "utf8");
|
||||
} catch (cause) {
|
||||
throw new Error(
|
||||
`generate-translation-types: cannot read ${bundlePath}: ${(cause as Error).message}`
|
||||
);
|
||||
}
|
||||
let bundle: unknown;
|
||||
try {
|
||||
bundle = JSON.parse(raw);
|
||||
} catch (cause) {
|
||||
throw new Error(
|
||||
`generate-translation-types: cannot parse ${bundlePath}: ${(cause as Error).message}`
|
||||
);
|
||||
}
|
||||
if (
|
||||
bundle === null ||
|
||||
typeof bundle !== "object" ||
|
||||
Array.isArray(bundle)
|
||||
) {
|
||||
throw new Error(
|
||||
`generate-translation-types: ${bundlePath} must contain a JSON object at the top level`
|
||||
);
|
||||
}
|
||||
const keys = flattenKeys(bundle as Record<string, unknown>);
|
||||
|
||||
const translationKeysSource = renderTranslationKeysFile(
|
||||
keys,
|
||||
options.packageName
|
||||
);
|
||||
const augmentationSource = renderAugmentationFile(
|
||||
keys,
|
||||
options.packageName
|
||||
);
|
||||
|
||||
await writeFn(translationKeysPath, translationKeysSource, "utf8");
|
||||
await writeFn(augmentationPath, augmentationSource, "utf8");
|
||||
|
||||
return {
|
||||
bundlePath,
|
||||
translationKeysPath,
|
||||
augmentationPath,
|
||||
translationKeys: keys,
|
||||
};
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const argv = process.argv.slice(2);
|
||||
if (argv.length === 0) {
|
||||
process.stderr.write(
|
||||
"Usage: bun packages/react/react-i18n/scripts/generate-translation-types.ts <package-path> [--package-name <name>]\n"
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
let packagePath = "";
|
||||
let packageName: string | undefined;
|
||||
for (let i = 0; i < argv.length; i += 1) {
|
||||
const arg = argv[i];
|
||||
if (arg === "--package-name" && i + 1 < argv.length) {
|
||||
packageName = argv[i + 1];
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
if (typeof arg === "string" && !arg.startsWith("--")) {
|
||||
packagePath = arg;
|
||||
}
|
||||
}
|
||||
if (packagePath === "") {
|
||||
process.stderr.write(
|
||||
"generate-translation-types: missing <package-path> argument\n"
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
try {
|
||||
const result = await generateTranslationTypes({
|
||||
packagePath,
|
||||
packageName,
|
||||
});
|
||||
process.stdout.write(
|
||||
`generate-translation-types: wrote ${result.translationKeys.length} key${result.translationKeys.length === 1 ? "" : "s"} to:\n ${result.translationKeysPath}\n ${result.augmentationPath}\n`
|
||||
);
|
||||
process.exit(0);
|
||||
} catch (cause) {
|
||||
process.stderr.write(`${(cause as Error).message}\n`);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
if (import.meta.main) {
|
||||
await main();
|
||||
}
|
||||
@@ -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,16 @@
|
||||
/**
|
||||
* Bundled fallback strings shipped with `@suaveplan/react-i18n` so the
|
||||
* `<I18nSuspense>` default fallback always has a screen-reader-friendly
|
||||
* label even before the active catalog loads its own copy. Consumers
|
||||
* override these by supplying a translation under the matching key in
|
||||
* their catalog (e.g. `react-i18n.suspense.loading: 'Loading…'`).
|
||||
*
|
||||
* Keys live under the `react-i18n` namespace so they coexist with
|
||||
* application keys without collision risk.
|
||||
*/
|
||||
export const REACT_I18N_FALLBACK_BUNDLE = {
|
||||
/** Screen-reader text announced while a Suspense fallback is on screen. */
|
||||
"react-i18n.suspense.loading": "Loading translations…",
|
||||
} as const;
|
||||
|
||||
export type ReactI18nFallbackKey = keyof typeof REACT_I18N_FALLBACK_BUNDLE;
|
||||
@@ -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,182 @@
|
||||
/**
|
||||
* Storybook stories for `<I18nSuspense>`.
|
||||
*
|
||||
* The package does not declare a `@storybook/react` dependency. Story-meta
|
||||
* shapes are inlined so the file ships without forcing a Storybook
|
||||
* install at the library boundary.
|
||||
*/
|
||||
|
||||
import type { TranslationRecord } from "@suaveplan/i18n";
|
||||
import { createI18n, type I18nInstance } from "@suaveplan/i18n/plugin";
|
||||
import { type ReactNode, useEffect, useState } from "react";
|
||||
import { TranslationProvider } from "../../provider/provider.js";
|
||||
import { I18nSuspense } from "./i18n-suspense.js";
|
||||
|
||||
interface Meta<T> {
|
||||
readonly component: T;
|
||||
readonly title: string;
|
||||
readonly tags?: readonly string[];
|
||||
}
|
||||
|
||||
interface StoryObj<T> {
|
||||
readonly args?: Record<string, unknown>;
|
||||
readonly decorators?: ReadonlyArray<(Story: () => ReactNode) => ReactNode>;
|
||||
readonly render?: () => ReactNode;
|
||||
readonly name?: string;
|
||||
readonly _component?: T;
|
||||
}
|
||||
|
||||
type LocaleKey = "en-GB" | "zh-CN" | "ar-AE" | "cy-GB" | "ru-RU";
|
||||
|
||||
const messages: Partial<Record<LocaleKey, TranslationRecord>> = {
|
||||
"en-GB": {},
|
||||
"zh-CN": {
|
||||
"react-i18n": { suspense: { loading: "正在加载翻译…" } },
|
||||
},
|
||||
"ar-AE": {
|
||||
"react-i18n": { suspense: { loading: "جارٍ تحميل الترجمات…" } },
|
||||
},
|
||||
"cy-GB": {
|
||||
"react-i18n": { suspense: { loading: "Llwytho cyfieithiadau…" } },
|
||||
},
|
||||
"ru-RU": {
|
||||
"react-i18n": { suspense: { loading: "Загрузка переводов…" } },
|
||||
},
|
||||
};
|
||||
|
||||
function withProviderFor(locale: LocaleKey) {
|
||||
return function Wrapper(props: { children: ReactNode }) {
|
||||
const i18n = createI18n({
|
||||
defaultLocale: locale,
|
||||
messages,
|
||||
}) as I18nInstance;
|
||||
return (
|
||||
<TranslationProvider i18n={i18n}>
|
||||
{props.children}
|
||||
</TranslationProvider>
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
function DelayedChild(props: { delayMs: number }) {
|
||||
const [ready, setReady] = useState(false);
|
||||
useEffect(() => {
|
||||
const id = setTimeout(() => setReady(true), props.delayMs);
|
||||
return () => clearTimeout(id);
|
||||
}, [props.delayMs]);
|
||||
if (!ready) {
|
||||
// Throw a fresh promise on every render so the boundary stays
|
||||
// suspended for the configured delay.
|
||||
throw new Promise<void>((r) => setTimeout(r, props.delayMs));
|
||||
}
|
||||
return <span>Loaded.</span>;
|
||||
}
|
||||
|
||||
const meta: Meta<typeof I18nSuspense> = {
|
||||
component: I18nSuspense,
|
||||
title: "react-i18n/I18nSuspense",
|
||||
tags: ["autodocs"],
|
||||
};
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj<typeof I18nSuspense>;
|
||||
|
||||
export const Default: Story = {
|
||||
render: () => {
|
||||
const W = withProviderFor("en-GB");
|
||||
return (
|
||||
<W>
|
||||
<I18nSuspense>
|
||||
<DelayedChild delayMs={1500} />
|
||||
</I18nSuspense>
|
||||
</W>
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
export const Playground: Story = {
|
||||
render: () => {
|
||||
const W = withProviderFor("en-GB");
|
||||
return (
|
||||
<W>
|
||||
<I18nSuspense
|
||||
fallback={<span data-testid="custom">Custom spinner…</span>}
|
||||
>
|
||||
<DelayedChild delayMs={2000} />
|
||||
</I18nSuspense>
|
||||
</W>
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
export const WithI18nEnglish: Story = {
|
||||
name: "WithI18n / en-GB",
|
||||
render: () => {
|
||||
const W = withProviderFor("en-GB");
|
||||
return (
|
||||
<W>
|
||||
<I18nSuspense>
|
||||
<DelayedChild delayMs={1500} />
|
||||
</I18nSuspense>
|
||||
</W>
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
export const WithI18nChinese: Story = {
|
||||
name: "WithI18n / zh-CN",
|
||||
render: () => {
|
||||
const W = withProviderFor("zh-CN");
|
||||
return (
|
||||
<W>
|
||||
<I18nSuspense>
|
||||
<DelayedChild delayMs={1500} />
|
||||
</I18nSuspense>
|
||||
</W>
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
export const WithI18nArabic: Story = {
|
||||
name: "WithI18n / ar-AE (RTL)",
|
||||
render: () => {
|
||||
const W = withProviderFor("ar-AE");
|
||||
return (
|
||||
<W>
|
||||
<div dir="rtl">
|
||||
<I18nSuspense>
|
||||
<DelayedChild delayMs={1500} />
|
||||
</I18nSuspense>
|
||||
</div>
|
||||
</W>
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
export const WithI18nWelsh: Story = {
|
||||
name: "WithI18n / cy-GB",
|
||||
render: () => {
|
||||
const W = withProviderFor("cy-GB");
|
||||
return (
|
||||
<W>
|
||||
<I18nSuspense>
|
||||
<DelayedChild delayMs={1500} />
|
||||
</I18nSuspense>
|
||||
</W>
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
export const WithI18nRussian: Story = {
|
||||
name: "WithI18n / ru-RU",
|
||||
render: () => {
|
||||
const W = withProviderFor("ru-RU");
|
||||
return (
|
||||
<W>
|
||||
<I18nSuspense>
|
||||
<DelayedChild delayMs={1500} />
|
||||
</I18nSuspense>
|
||||
</W>
|
||||
);
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,259 @@
|
||||
import { createI18n } from "@suaveplan/i18n/plugin";
|
||||
import {
|
||||
afterEach,
|
||||
beforeEach,
|
||||
describe,
|
||||
expect,
|
||||
it,
|
||||
} from "@suaveplan/testing/runner";
|
||||
import { renderComponent } from "@suaveplan/testing-dom/render";
|
||||
import { act } from "@testing-library/react";
|
||||
import { useState } from "react";
|
||||
import {
|
||||
getReactI18nMetricsState,
|
||||
resetReactI18nMetricsState,
|
||||
} from "../../observability/react-i18n-metrics.js";
|
||||
import { TranslationProvider } from "../../provider/provider.js";
|
||||
import { REACT_I18N_FALLBACK_BUNDLE } from "./fallback-bundle.js";
|
||||
import { I18nSuspense } from "./i18n-suspense.js";
|
||||
|
||||
beforeEach(() => {
|
||||
resetReactI18nMetricsState();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
resetReactI18nMetricsState();
|
||||
});
|
||||
|
||||
/**
|
||||
* Suspense-throwing helper: yields a React-renderable string only once
|
||||
* the controller's `resolve()` runs. Used to drive the Suspense
|
||||
* boundary deterministically without spinning up a real Backend.
|
||||
*/
|
||||
interface SuspenseController {
|
||||
readonly promise: Promise<void>;
|
||||
resolve(): void;
|
||||
}
|
||||
|
||||
function createController(): SuspenseController {
|
||||
let resolveFn: () => void = () => {};
|
||||
const promise = new Promise<void>((r) => {
|
||||
resolveFn = r;
|
||||
});
|
||||
return {
|
||||
promise,
|
||||
resolve() {
|
||||
resolveFn();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function Suspender(props: {
|
||||
controller: SuspenseController;
|
||||
state: { resolved: boolean };
|
||||
}) {
|
||||
if (!props.state.resolved) {
|
||||
throw props.controller.promise;
|
||||
}
|
||||
return <span data-testid="loaded">loaded</span>;
|
||||
}
|
||||
|
||||
describe("I18nSuspense", () => {
|
||||
it("renders children when nothing suspends", { timeout: 5000 }, () => {
|
||||
const i18n = createI18n({
|
||||
defaultLocale: "en-GB",
|
||||
messages: { "en-GB": {} },
|
||||
});
|
||||
const { container } = renderComponent(
|
||||
<TranslationProvider i18n={i18n}>
|
||||
<I18nSuspense>
|
||||
<span data-testid="ok">ok</span>
|
||||
</I18nSuspense>
|
||||
</TranslationProvider>
|
||||
);
|
||||
expect(container.querySelector("[data-testid='ok']")?.textContent).toBe(
|
||||
"ok"
|
||||
);
|
||||
expect(
|
||||
container.querySelector(
|
||||
"[data-testid='suaveplan-i18n-suspense-fallback']"
|
||||
)
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("renders the default fallback with aria-live polite", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const i18n = createI18n({
|
||||
defaultLocale: "en-GB",
|
||||
messages: { "en-GB": {} },
|
||||
});
|
||||
const ctrl = createController();
|
||||
const state = { resolved: false };
|
||||
const { container } = renderComponent(
|
||||
<TranslationProvider i18n={i18n}>
|
||||
<I18nSuspense namespace="auth">
|
||||
<Suspender controller={ctrl} state={state} />
|
||||
</I18nSuspense>
|
||||
</TranslationProvider>
|
||||
);
|
||||
const fallback = container.querySelector(
|
||||
"[data-testid='suaveplan-i18n-suspense-fallback']"
|
||||
);
|
||||
expect(fallback).not.toBeNull();
|
||||
expect(fallback?.getAttribute("aria-live")).toBe("polite");
|
||||
expect(fallback?.getAttribute("role")).toBe("status");
|
||||
expect(fallback?.textContent).toBe(
|
||||
REACT_I18N_FALLBACK_BUNDLE["react-i18n.suspense.loading"]
|
||||
);
|
||||
});
|
||||
|
||||
it("increments react_i18n.suspense.fallback.total when fallback renders", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const i18n = createI18n({
|
||||
defaultLocale: "en-GB",
|
||||
messages: { "en-GB": {} },
|
||||
});
|
||||
const ctrl = createController();
|
||||
const state = { resolved: false };
|
||||
renderComponent(
|
||||
<TranslationProvider i18n={i18n}>
|
||||
<I18nSuspense namespace="auth">
|
||||
<Suspender controller={ctrl} state={state} />
|
||||
</I18nSuspense>
|
||||
</TranslationProvider>
|
||||
);
|
||||
const samples = getReactI18nMetricsState().suspenseFallbackTotal;
|
||||
expect(samples.length).toBeGreaterThan(0);
|
||||
const last = samples[samples.length - 1];
|
||||
expect(last?.attributes.namespace).toBe("auth");
|
||||
expect(last?.attributes.locale).toBe("en-GB");
|
||||
});
|
||||
|
||||
it("uses the custom fallback when provided", { timeout: 5000 }, () => {
|
||||
const i18n = createI18n({
|
||||
defaultLocale: "en-GB",
|
||||
messages: { "en-GB": {} },
|
||||
});
|
||||
const ctrl = createController();
|
||||
const state = { resolved: false };
|
||||
const { container } = renderComponent(
|
||||
<TranslationProvider i18n={i18n}>
|
||||
<I18nSuspense
|
||||
fallback={<span data-testid="custom">spinner</span>}
|
||||
>
|
||||
<Suspender controller={ctrl} state={state} />
|
||||
</I18nSuspense>
|
||||
</TranslationProvider>
|
||||
);
|
||||
expect(
|
||||
container.querySelector("[data-testid='custom']")?.textContent
|
||||
).toBe("spinner");
|
||||
});
|
||||
|
||||
it("prefers a consumer-provided loading label over the bundled default", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const i18n = createI18n({
|
||||
defaultLocale: "en-GB",
|
||||
messages: {
|
||||
"en-GB": {
|
||||
"react-i18n": { suspense: { loading: "Bitte warten…" } },
|
||||
},
|
||||
},
|
||||
});
|
||||
const ctrl = createController();
|
||||
const state = { resolved: false };
|
||||
const { container } = renderComponent(
|
||||
<TranslationProvider i18n={i18n}>
|
||||
<I18nSuspense>
|
||||
<Suspender controller={ctrl} state={state} />
|
||||
</I18nSuspense>
|
||||
</TranslationProvider>
|
||||
);
|
||||
const fallback = container.querySelector(
|
||||
"[data-testid='suaveplan-i18n-suspense-fallback']"
|
||||
);
|
||||
expect(fallback?.textContent).toBe("Bitte warten…");
|
||||
});
|
||||
|
||||
it("renders the bundled default when no Provider is mounted", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const ctrl = createController();
|
||||
const state = { resolved: false };
|
||||
const { container } = renderComponent(
|
||||
<I18nSuspense>
|
||||
<Suspender controller={ctrl} state={state} />
|
||||
</I18nSuspense>
|
||||
);
|
||||
const fallback = container.querySelector(
|
||||
"[data-testid='suaveplan-i18n-suspense-fallback']"
|
||||
);
|
||||
expect(fallback?.textContent).toBe(
|
||||
REACT_I18N_FALLBACK_BUNDLE["react-i18n.suspense.loading"]
|
||||
);
|
||||
});
|
||||
|
||||
it("transitions from fallback to children when the promise resolves", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const i18n = createI18n({
|
||||
defaultLocale: "en-GB",
|
||||
messages: { "en-GB": {} },
|
||||
});
|
||||
const ctrl = createController();
|
||||
function Harness() {
|
||||
const [resolved, setResolved] = useState(false);
|
||||
// Resolve via a side-effect tied to the controller so the
|
||||
// child re-renders into the loaded path after resolve.
|
||||
ctrl.promise.then(() => setResolved(true));
|
||||
return (
|
||||
<I18nSuspense>
|
||||
<Suspender controller={ctrl} state={{ resolved }} />
|
||||
</I18nSuspense>
|
||||
);
|
||||
}
|
||||
const { container } = renderComponent(
|
||||
<TranslationProvider i18n={i18n}>
|
||||
<Harness />
|
||||
</TranslationProvider>
|
||||
);
|
||||
expect(
|
||||
container.querySelector(
|
||||
"[data-testid='suaveplan-i18n-suspense-fallback']"
|
||||
)
|
||||
).not.toBeNull();
|
||||
await act(async () => {
|
||||
ctrl.resolve();
|
||||
await ctrl.promise;
|
||||
// Allow the .then callback to flush.
|
||||
await new Promise<void>((r) => setTimeout(r, 0));
|
||||
});
|
||||
expect(
|
||||
container.querySelector("[data-testid='loaded']")?.textContent
|
||||
).toBe("loaded");
|
||||
});
|
||||
|
||||
it("defaults the namespace label to the empty string", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const i18n = createI18n({
|
||||
defaultLocale: "en-GB",
|
||||
messages: { "en-GB": {} },
|
||||
});
|
||||
const ctrl = createController();
|
||||
const state = { resolved: false };
|
||||
renderComponent(
|
||||
<TranslationProvider i18n={i18n}>
|
||||
<I18nSuspense>
|
||||
<Suspender controller={ctrl} state={state} />
|
||||
</I18nSuspense>
|
||||
</TranslationProvider>
|
||||
);
|
||||
const samples = getReactI18nMetricsState().suspenseFallbackTotal;
|
||||
const last = samples[samples.length - 1];
|
||||
expect(last?.attributes.namespace).toBe("");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,88 @@
|
||||
import { type ReactElement, Suspense, useContext, useEffect } from "react";
|
||||
import { recordSuspenseFallback } from "../../observability/react-i18n-metrics.js";
|
||||
import { TranslationContext } from "../../provider/provider-context.js";
|
||||
import { REACT_I18N_FALLBACK_BUNDLE } from "./fallback-bundle.js";
|
||||
import type { I18nSuspenseProps } from "./i18n-suspense.types.js";
|
||||
|
||||
const LOADING_KEY = "react-i18n.suspense.loading";
|
||||
|
||||
/**
|
||||
* Convenience wrapper around React's `<Suspense>` that:
|
||||
*
|
||||
* 1. Provides a default fallback with `aria-live="polite"` so the
|
||||
* "Loading translations…" announcement reaches screen-reader users
|
||||
* when an explicit fallback prop is omitted.
|
||||
* 2. Increments `react_i18n.suspense.fallback.total` every time the
|
||||
* fallback is rendered so observability captures load-time cost.
|
||||
* 3. Resolves the loading label through the active catalog (with the
|
||||
* bundled English string as the build-time fallback) so consumer
|
||||
* translations override the default without code changes.
|
||||
*/
|
||||
export function I18nSuspense(props: I18nSuspenseProps): ReactElement {
|
||||
const namespace = props.namespace ?? "";
|
||||
const fallback = props.fallback ?? (
|
||||
<DefaultFallback namespace={namespace} />
|
||||
);
|
||||
return <Suspense fallback={fallback}>{props.children}</Suspense>;
|
||||
}
|
||||
|
||||
I18nSuspense.displayName = "SuaveplanI18nSuspense";
|
||||
|
||||
interface DefaultFallbackProps {
|
||||
readonly namespace: string;
|
||||
}
|
||||
|
||||
function DefaultFallback(props: DefaultFallbackProps): ReactElement {
|
||||
const i18n = useContext(TranslationContext);
|
||||
|
||||
// Record the fallback on every effect run. The dependency array
|
||||
// captures the locale + namespace so a locale change while the
|
||||
// fallback is on-screen records a fresh sample for the new locale
|
||||
// — this matches the spec's "exactly one sample per fallback
|
||||
// render" intent because the fallback element re-mounts when its
|
||||
// identity is determined by the surrounding Suspense boundary.
|
||||
useEffect(() => {
|
||||
recordSuspenseFallback({
|
||||
namespace: props.namespace,
|
||||
locale: i18n?.locale ?? "",
|
||||
});
|
||||
}, [i18n, props.namespace]);
|
||||
|
||||
const label = resolveLoadingLabel(i18n);
|
||||
return (
|
||||
<div
|
||||
data-testid="suaveplan-i18n-suspense-fallback"
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
aria-busy="true"
|
||||
>
|
||||
<span style={visuallyHidden}>{label}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function resolveLoadingLabel(
|
||||
i18n: {
|
||||
readonly catalog: {
|
||||
has(key: string): boolean;
|
||||
t(key: string): string;
|
||||
};
|
||||
} | null
|
||||
): string {
|
||||
if (i18n?.catalog.has(LOADING_KEY)) {
|
||||
return i18n.catalog.t(LOADING_KEY);
|
||||
}
|
||||
return REACT_I18N_FALLBACK_BUNDLE[LOADING_KEY];
|
||||
}
|
||||
|
||||
const visuallyHidden: React.CSSProperties = {
|
||||
position: "absolute",
|
||||
width: 1,
|
||||
height: 1,
|
||||
padding: 0,
|
||||
margin: -1,
|
||||
overflow: "hidden",
|
||||
clip: "rect(0, 0, 0, 0)",
|
||||
whiteSpace: "nowrap",
|
||||
border: 0,
|
||||
};
|
||||
@@ -0,0 +1,25 @@
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
/**
|
||||
* Props for `<I18nSuspense>`.
|
||||
*
|
||||
* The component is a thin wrapper around React's built-in `<Suspense>`
|
||||
* that wires the fallback render to the React-i18n telemetry surface
|
||||
* (`react_i18n.suspense.fallback.total`) and announces loading state to
|
||||
* assistive technology via an `aria-live="polite"` region.
|
||||
*/
|
||||
export interface I18nSuspenseProps {
|
||||
/**
|
||||
* Fallback element rendered while a child suspends. When omitted,
|
||||
* the component renders the bundled default fallback (a screen-
|
||||
* reader-friendly polite live-region).
|
||||
*/
|
||||
readonly fallback?: ReactNode;
|
||||
/**
|
||||
* Namespace label attached to telemetry samples for the fallback
|
||||
* render. Defaults to the empty string.
|
||||
*/
|
||||
readonly namespace?: string;
|
||||
/** Subtree wrapped by the underlying React Suspense boundary. */
|
||||
readonly children: ReactNode;
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
export type { ReactI18nFallbackKey } from "./fallback-bundle.js";
|
||||
export { REACT_I18N_FALLBACK_BUNDLE } from "./fallback-bundle.js";
|
||||
export { I18nSuspense } from "./i18n-suspense.js";
|
||||
export type { I18nSuspenseProps } from "./i18n-suspense.types.js";
|
||||
@@ -0,0 +1,6 @@
|
||||
export { LanguageSwitcher } from "./language-switcher.js";
|
||||
export type {
|
||||
LanguageSwitcherProps,
|
||||
LanguageSwitcherState,
|
||||
LocaleDescriptor,
|
||||
} from "./language-switcher.types.js";
|
||||
@@ -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,184 @@
|
||||
/**
|
||||
* Storybook stories for `<LanguageSwitcher>`.
|
||||
*
|
||||
* The package does not declare a `@storybook/react` dependency. Story-meta
|
||||
* shapes are inlined so the file ships without forcing a Storybook
|
||||
* install at the library boundary.
|
||||
*/
|
||||
|
||||
import type { TranslationRecord } from "@suaveplan/i18n";
|
||||
import { createI18n, type I18nInstance } from "@suaveplan/i18n/plugin";
|
||||
import type { ReactNode } from "react";
|
||||
import { TranslationProvider } from "../../provider/provider.js";
|
||||
import { LanguageSwitcher } from "./language-switcher.js";
|
||||
import type {
|
||||
LanguageSwitcherProps,
|
||||
LanguageSwitcherState,
|
||||
} from "./language-switcher.types.js";
|
||||
|
||||
interface Meta<T> {
|
||||
readonly component: T;
|
||||
readonly title: string;
|
||||
readonly tags?: readonly string[];
|
||||
}
|
||||
|
||||
interface StoryObj<T> {
|
||||
readonly args?: Partial<LanguageSwitcherProps>;
|
||||
readonly decorators?: ReadonlyArray<(Story: () => ReactNode) => ReactNode>;
|
||||
readonly render?: () => ReactNode;
|
||||
readonly name?: string;
|
||||
readonly _component?: T;
|
||||
}
|
||||
|
||||
type LocaleKey = "en-GB" | "zh-CN" | "ar-AE" | "cy-GB" | "ru-RU";
|
||||
|
||||
const messages: Partial<Record<LocaleKey, TranslationRecord>> = {
|
||||
"en-GB": { x: "x" },
|
||||
"zh-CN": { x: "x" },
|
||||
"ar-AE": { x: "x" },
|
||||
"cy-GB": { x: "x" },
|
||||
"ru-RU": { x: "x" },
|
||||
};
|
||||
|
||||
function withProviderFor(locale: LocaleKey) {
|
||||
return function Wrapper(props: { children: ReactNode }) {
|
||||
const i18n = createI18n({
|
||||
defaultLocale: locale,
|
||||
messages,
|
||||
}) as I18nInstance;
|
||||
return (
|
||||
<TranslationProvider i18n={i18n}>
|
||||
{props.children}
|
||||
</TranslationProvider>
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
const meta: Meta<typeof LanguageSwitcher> = {
|
||||
component: LanguageSwitcher,
|
||||
title: "react-i18n/LanguageSwitcher",
|
||||
tags: ["autodocs"],
|
||||
};
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj<typeof LanguageSwitcher>;
|
||||
|
||||
function SelectRender(props: { state: LanguageSwitcherState }) {
|
||||
return (
|
||||
<select
|
||||
value={props.state.current}
|
||||
disabled={props.state.isPending}
|
||||
onChange={(e) => {
|
||||
void props.state.change(e.target.value);
|
||||
}}
|
||||
>
|
||||
{props.state.locales.map((l) => (
|
||||
<option key={l.code} value={l.code}>
|
||||
{l.code}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
);
|
||||
}
|
||||
|
||||
export const Default: Story = {
|
||||
args: {
|
||||
locales: ["en-GB", "fr-FR", "ar-AE"],
|
||||
children: (state) => <SelectRender state={state} />,
|
||||
},
|
||||
decorators: [
|
||||
(Story) => {
|
||||
const W = withProviderFor("en-GB");
|
||||
return <W>{Story()}</W>;
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
export const Playground: Story = {
|
||||
args: {
|
||||
locales: ["en-GB", "zh-CN", "ar-AE", "cy-GB", "ru-RU"],
|
||||
children: (state) => (
|
||||
<pre data-testid="state">{JSON.stringify(state, null, 2)}</pre>
|
||||
),
|
||||
},
|
||||
decorators: [
|
||||
(Story) => {
|
||||
const W = withProviderFor("en-GB");
|
||||
return <W>{Story()}</W>;
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
export const WithI18nEnglish: Story = {
|
||||
name: "WithI18n / en-GB",
|
||||
args: {
|
||||
locales: ["en-GB", "zh-CN", "ar-AE"],
|
||||
children: (state) => <SelectRender state={state} />,
|
||||
},
|
||||
decorators: [
|
||||
(Story) => {
|
||||
const W = withProviderFor("en-GB");
|
||||
return <W>{Story()}</W>;
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
export const WithI18nChinese: Story = {
|
||||
name: "WithI18n / zh-CN",
|
||||
args: {
|
||||
locales: ["en-GB", "zh-CN", "ar-AE"],
|
||||
children: (state) => <SelectRender state={state} />,
|
||||
},
|
||||
decorators: [
|
||||
(Story) => {
|
||||
const W = withProviderFor("zh-CN");
|
||||
return <W>{Story()}</W>;
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
export const WithI18nArabic: Story = {
|
||||
name: "WithI18n / ar-AE (RTL)",
|
||||
args: {
|
||||
locales: ["en-GB", "zh-CN", "ar-AE"],
|
||||
children: (state) => (
|
||||
<div dir="rtl">
|
||||
<SelectRender state={state} />
|
||||
</div>
|
||||
),
|
||||
},
|
||||
decorators: [
|
||||
(Story) => {
|
||||
const W = withProviderFor("ar-AE");
|
||||
return <W>{Story()}</W>;
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
export const WithI18nWelsh: Story = {
|
||||
name: "WithI18n / cy-GB",
|
||||
args: {
|
||||
locales: ["en-GB", "cy-GB", "zh-CN"],
|
||||
children: (state) => <SelectRender state={state} />,
|
||||
},
|
||||
decorators: [
|
||||
(Story) => {
|
||||
const W = withProviderFor("cy-GB");
|
||||
return <W>{Story()}</W>;
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
export const WithI18nRussian: Story = {
|
||||
name: "WithI18n / ru-RU",
|
||||
args: {
|
||||
locales: ["en-GB", "ru-RU", "ar-AE"],
|
||||
children: (state) => <SelectRender state={state} />,
|
||||
},
|
||||
decorators: [
|
||||
(Story) => {
|
||||
const W = withProviderFor("ru-RU");
|
||||
return <W>{Story()}</W>;
|
||||
},
|
||||
],
|
||||
};
|
||||
@@ -0,0 +1,200 @@
|
||||
import { createI18n } from "@suaveplan/i18n/plugin";
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { renderComponent } from "@suaveplan/testing-dom/render";
|
||||
import { act } from "@testing-library/react";
|
||||
import { TranslationProvider } from "../../provider/provider.js";
|
||||
import { LanguageSwitcher } from "./language-switcher.js";
|
||||
import type { LanguageSwitcherState } from "./language-switcher.types.js";
|
||||
|
||||
function rootCode(err: unknown): string | undefined {
|
||||
let current: unknown = err;
|
||||
let lastCode: string | undefined;
|
||||
let depth = 0;
|
||||
while (current instanceof Error && depth < 10) {
|
||||
const code = (current as Error & { code?: string }).code;
|
||||
if (code !== undefined) lastCode = code;
|
||||
const cause = (current as Error & { cause?: unknown }).cause;
|
||||
if (cause === undefined || cause === null) break;
|
||||
current = cause;
|
||||
depth += 1;
|
||||
}
|
||||
return lastCode;
|
||||
}
|
||||
|
||||
describe("LanguageSwitcher", () => {
|
||||
it("render prop receives full state with current locale", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const i18n = createI18n({
|
||||
defaultLocale: "en-GB",
|
||||
messages: {
|
||||
"en-GB": { x: "x" },
|
||||
"fr-FR": { x: "x" },
|
||||
"ar-AE": { x: "x" },
|
||||
},
|
||||
});
|
||||
let captured: LanguageSwitcherState | undefined;
|
||||
renderComponent(
|
||||
<TranslationProvider i18n={i18n}>
|
||||
<LanguageSwitcher locales={["en-GB", "fr-FR", "ar-AE"]}>
|
||||
{(state) => {
|
||||
captured = state;
|
||||
return <span data-testid="probe">{state.current}</span>;
|
||||
}}
|
||||
</LanguageSwitcher>
|
||||
</TranslationProvider>
|
||||
);
|
||||
expect(captured).toBeDefined();
|
||||
expect(captured?.current).toBe("en-GB");
|
||||
expect(captured?.isPending).toBe(false);
|
||||
expect(captured?.locales.length).toBe(3);
|
||||
expect(captured?.locales[0]?.code).toBe("en-GB");
|
||||
expect(captured?.locales[2]?.direction).toBe("rtl");
|
||||
});
|
||||
|
||||
it("change wraps in transition and resolves to new locale", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const i18n = createI18n({
|
||||
defaultLocale: "en-GB",
|
||||
messages: {
|
||||
"en-GB": { x: "x" },
|
||||
"fr-FR": { x: "x" },
|
||||
},
|
||||
});
|
||||
let captured: LanguageSwitcherState | undefined;
|
||||
renderComponent(
|
||||
<TranslationProvider i18n={i18n}>
|
||||
<LanguageSwitcher locales={["en-GB", "fr-FR"]}>
|
||||
{(state) => {
|
||||
captured = state;
|
||||
return null;
|
||||
}}
|
||||
</LanguageSwitcher>
|
||||
</TranslationProvider>
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
await captured?.change("fr-FR");
|
||||
});
|
||||
|
||||
expect(captured?.current).toBe("fr-FR");
|
||||
expect(captured?.isPending).toBe(false);
|
||||
});
|
||||
|
||||
it("accepts descriptor objects without overriding direction", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const i18n = createI18n({
|
||||
defaultLocale: "en-GB",
|
||||
messages: { "en-GB": {} },
|
||||
});
|
||||
let captured: LanguageSwitcherState | undefined;
|
||||
renderComponent(
|
||||
<TranslationProvider i18n={i18n}>
|
||||
<LanguageSwitcher
|
||||
locales={[
|
||||
{
|
||||
code: "ar-AE",
|
||||
name: "Arabic",
|
||||
nativeName: "العربية",
|
||||
direction: "rtl",
|
||||
},
|
||||
]}
|
||||
>
|
||||
{(state) => {
|
||||
captured = state;
|
||||
return null;
|
||||
}}
|
||||
</LanguageSwitcher>
|
||||
</TranslationProvider>
|
||||
);
|
||||
expect(captured?.locales[0]?.direction).toBe("rtl");
|
||||
expect(captured?.locales[0]?.name).toBe("Arabic");
|
||||
});
|
||||
|
||||
it("derives direction for descriptors without explicit direction", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const i18n = createI18n({
|
||||
defaultLocale: "en-GB",
|
||||
messages: { "en-GB": {} },
|
||||
});
|
||||
let captured: LanguageSwitcherState | undefined;
|
||||
renderComponent(
|
||||
<TranslationProvider i18n={i18n}>
|
||||
<LanguageSwitcher
|
||||
locales={[
|
||||
{ code: "en-GB", name: "English" },
|
||||
{ code: "ar-AE", name: "Arabic" },
|
||||
]}
|
||||
>
|
||||
{(state) => {
|
||||
captured = state;
|
||||
return null;
|
||||
}}
|
||||
</LanguageSwitcher>
|
||||
</TranslationProvider>
|
||||
);
|
||||
expect(captured?.locales[0]?.direction).toBe("ltr");
|
||||
expect(captured?.locales[1]?.direction).toBe("rtl");
|
||||
});
|
||||
|
||||
it("rejects when changeLanguage rejects", { timeout: 5000 }, async () => {
|
||||
const i18n = createI18n({
|
||||
defaultLocale: "en-GB",
|
||||
messages: { "en-GB": {} },
|
||||
});
|
||||
// Force changeLanguage to reject so the awaiting flag still
|
||||
// resets and the rejection propagates.
|
||||
const failure = new Error("boom");
|
||||
i18n.changeLanguage = () => Promise.reject(failure);
|
||||
|
||||
let captured: LanguageSwitcherState | undefined;
|
||||
renderComponent(
|
||||
<TranslationProvider i18n={i18n}>
|
||||
<LanguageSwitcher locales={["en-GB", "fr-FR"]}>
|
||||
{(state) => {
|
||||
captured = state;
|
||||
return null;
|
||||
}}
|
||||
</LanguageSwitcher>
|
||||
</TranslationProvider>
|
||||
);
|
||||
|
||||
let thrown: unknown;
|
||||
await act(async () => {
|
||||
try {
|
||||
await captured?.change("fr-FR");
|
||||
} catch (err) {
|
||||
thrown = err;
|
||||
}
|
||||
});
|
||||
|
||||
expect(thrown).toBe(failure);
|
||||
expect(captured?.isPending).toBe(false);
|
||||
});
|
||||
|
||||
it("throws REACT_I18N.NO_PROVIDER outside a Provider", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const original = console.error;
|
||||
console.error = (() => {}) as typeof console.error;
|
||||
try {
|
||||
let thrown: unknown;
|
||||
try {
|
||||
renderComponent(
|
||||
<LanguageSwitcher locales={["en-GB"]}>
|
||||
{() => null}
|
||||
</LanguageSwitcher>
|
||||
);
|
||||
} catch (err) {
|
||||
thrown = err;
|
||||
}
|
||||
expect(thrown).toBeDefined();
|
||||
expect(rootCode(thrown)).toBe("REACT_I18N.NO_PROVIDER");
|
||||
} finally {
|
||||
console.error = original;
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,136 @@
|
||||
import { getTextDirection } from "@suaveplan/i18n";
|
||||
import {
|
||||
type ReactElement,
|
||||
useCallback,
|
||||
useContext,
|
||||
useMemo,
|
||||
useState,
|
||||
useSyncExternalStore,
|
||||
useTransition,
|
||||
} from "react";
|
||||
import { REACT_I18N_CODES } from "../../errors/error-codes.js";
|
||||
import { ReactI18nError } from "../../errors/react-i18n-error.js";
|
||||
import { TranslationContext } from "../../provider/provider-context.js";
|
||||
import type {
|
||||
LanguageSwitcherProps,
|
||||
LanguageSwitcherState,
|
||||
LocaleDescriptor,
|
||||
} from "./language-switcher.types.js";
|
||||
|
||||
/**
|
||||
* Headless language-switcher component.
|
||||
*
|
||||
* `<LanguageSwitcher locales={[...]}>{(state) => ReactNode}</LanguageSwitcher>`
|
||||
* renders nothing of its own. The render-prop receives a fully-resolved
|
||||
* state object with the current locale, an array of `LocaleDescriptor`
|
||||
* entries derived from the supplied codes, and a `change(locale)`
|
||||
* callback wrapped in `useTransition` so namespace loads triggered by
|
||||
* the switch do not block urgent updates. Throws
|
||||
* `ReactI18nError(REACT_I18N.NO_PROVIDER)` outside a Provider.
|
||||
*/
|
||||
export function LanguageSwitcher(
|
||||
props: LanguageSwitcherProps
|
||||
): ReactElement | null {
|
||||
const i18n = useContext(TranslationContext);
|
||||
if (i18n === null) {
|
||||
throw new ReactI18nError(
|
||||
REACT_I18N_CODES.NO_PROVIDER,
|
||||
"react-i18n.no_provider.language_switcher"
|
||||
);
|
||||
}
|
||||
|
||||
// Subscribe to the bus so `current` and the render result reflect
|
||||
// language-change events. Bumps the carrier counter on every notify
|
||||
// so the snapshot advances regardless of listener order versus the
|
||||
// Provider's own snapshot ticker. The shared reader covers both
|
||||
// client and server snapshots so SSR-rendered output matches the
|
||||
// initial client read.
|
||||
const snapshotReader = () => readSnapshot(i18n);
|
||||
useSyncExternalStore(
|
||||
(notify) => {
|
||||
const tickAndNotify = () => {
|
||||
bumpSnapshot(i18n);
|
||||
notify();
|
||||
};
|
||||
const offLanguage = i18n.events.on(
|
||||
"languageChanged",
|
||||
tickAndNotify
|
||||
);
|
||||
const offLoaded = i18n.events.on("loaded", tickAndNotify);
|
||||
return () => {
|
||||
offLanguage();
|
||||
offLoaded();
|
||||
};
|
||||
},
|
||||
snapshotReader,
|
||||
snapshotReader
|
||||
);
|
||||
|
||||
const [isPending, startTransition] = useTransition();
|
||||
// Track an explicit "in-flight" flag for the awaitable `change` so
|
||||
// tests can observe the transition lifecycle. `useTransition` alone
|
||||
// would only reflect render-time pendingness.
|
||||
const [awaiting, setAwaiting] = useState(false);
|
||||
|
||||
const descriptors = useMemo(
|
||||
() => normaliseLocales(props.locales),
|
||||
[props.locales]
|
||||
);
|
||||
|
||||
const change = useCallback(
|
||||
async (next: string): Promise<void> => {
|
||||
setAwaiting(true);
|
||||
try {
|
||||
// The transition wrapper marks the React work that follows
|
||||
// `i18n.changeLanguage` as low-priority so other inputs
|
||||
// remain responsive while the catalog updates.
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
startTransition(() => {
|
||||
i18n.changeLanguage(next).then(resolve, reject);
|
||||
});
|
||||
});
|
||||
} finally {
|
||||
setAwaiting(false);
|
||||
}
|
||||
},
|
||||
[i18n]
|
||||
);
|
||||
|
||||
const state: LanguageSwitcherState = {
|
||||
locales: descriptors,
|
||||
current: i18n.locale,
|
||||
change,
|
||||
isPending: isPending || awaiting,
|
||||
};
|
||||
|
||||
return <>{props.children(state)}</>;
|
||||
}
|
||||
|
||||
LanguageSwitcher.displayName = "SuaveplanLanguageSwitcher";
|
||||
|
||||
function normaliseLocales(
|
||||
input: ReadonlyArray<string | LocaleDescriptor>
|
||||
): readonly LocaleDescriptor[] {
|
||||
return input.map((entry) => {
|
||||
if (typeof entry === "string") {
|
||||
return { code: entry, direction: getTextDirection(entry) };
|
||||
}
|
||||
if (entry.direction === undefined) {
|
||||
return { ...entry, direction: getTextDirection(entry.code) };
|
||||
}
|
||||
return entry;
|
||||
});
|
||||
}
|
||||
|
||||
interface SnapshotCarrier {
|
||||
__reactI18nSnapshot?: number;
|
||||
}
|
||||
|
||||
function readSnapshot(instance: unknown): number {
|
||||
return (instance as SnapshotCarrier).__reactI18nSnapshot ?? 0;
|
||||
}
|
||||
|
||||
function bumpSnapshot(instance: unknown): void {
|
||||
const carrier = instance as SnapshotCarrier;
|
||||
carrier.__reactI18nSnapshot = (carrier.__reactI18nSnapshot ?? 0) + 1;
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
/**
|
||||
* Per-locale descriptor surfaced to the render-prop. The minimal shape
|
||||
* matches `@suaveplan/locale-data`'s `LanguageItem` plus the BCP-47
|
||||
* locale code, with the optional `direction` field exposed for RTL-aware
|
||||
* UI without forcing a separate lookup.
|
||||
*/
|
||||
export interface LocaleDescriptor {
|
||||
/** BCP-47 locale code (e.g. `"en-GB"`, `"ar-AE"`). */
|
||||
readonly code: string;
|
||||
/** Latin-script display name (e.g. `"English"`). Optional. */
|
||||
readonly name?: string;
|
||||
/** Native-script display name (e.g. `"العربية"`). Optional. */
|
||||
readonly nativeName?: string;
|
||||
/** Text direction. Defaults to `"ltr"` when omitted. */
|
||||
readonly direction?: "ltr" | "rtl";
|
||||
}
|
||||
|
||||
/**
|
||||
* State surface delivered to the render-prop child.
|
||||
*
|
||||
* `change(locale)` is wrapped in `useTransition` so namespace loads
|
||||
* triggered by the locale switch do not block urgent updates. The
|
||||
* caller observes pending loads via `isPending`.
|
||||
*/
|
||||
export interface LanguageSwitcherState {
|
||||
/** Locales accepted by the switcher (resolved via the `locales` prop). */
|
||||
readonly locales: readonly LocaleDescriptor[];
|
||||
/** Active locale code from the bound `I18nInstance`. */
|
||||
readonly current: string;
|
||||
/** Switch the active locale (returns when the namespace load resolves). */
|
||||
readonly change: (locale: string) => Promise<void>;
|
||||
/** True while a `useTransition`-wrapped change is in-flight. */
|
||||
readonly isPending: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Props for `<LanguageSwitcher>`.
|
||||
*
|
||||
* The `locales` prop accepts either a string array (codes only) or a
|
||||
* descriptor array (codes + names). Strings are upgraded to descriptors
|
||||
* with `code === input` so the render-prop always receives a uniform
|
||||
* shape.
|
||||
*/
|
||||
export interface LanguageSwitcherProps {
|
||||
/** Locales advertised to the render prop. Required. */
|
||||
readonly locales: ReadonlyArray<string | LocaleDescriptor>;
|
||||
/** Headless render-prop child. The component renders no DOM itself. */
|
||||
readonly children: (state: LanguageSwitcherState) => ReactNode;
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
export { MissingKeyOverlay } from "./missing-key-overlay.js";
|
||||
export type {
|
||||
MissingKeyEntry,
|
||||
MissingKeyOverlayProps,
|
||||
} from "./missing-key-overlay.types.js";
|
||||
@@ -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,153 @@
|
||||
/**
|
||||
* Storybook stories for `<MissingKeyOverlay>`.
|
||||
*
|
||||
* The package does not declare a `@storybook/react` dependency. Story-meta
|
||||
* shapes are inlined so the file ships without forcing a Storybook
|
||||
* install at the library boundary.
|
||||
*/
|
||||
|
||||
import type { TranslationRecord } from "@suaveplan/i18n";
|
||||
import { createI18n, type I18nInstance } from "@suaveplan/i18n/plugin";
|
||||
import { type ReactNode, useEffect, useState } from "react";
|
||||
import { TranslationProvider } from "../../provider/provider.js";
|
||||
import { MissingKeyOverlay } from "./missing-key-overlay.js";
|
||||
|
||||
interface Meta<T> {
|
||||
readonly component: T;
|
||||
readonly title: string;
|
||||
readonly tags?: readonly string[];
|
||||
}
|
||||
|
||||
interface StoryObj<T> {
|
||||
readonly args?: Record<string, unknown>;
|
||||
readonly decorators?: ReadonlyArray<(Story: () => ReactNode) => ReactNode>;
|
||||
readonly render?: () => ReactNode;
|
||||
readonly name?: string;
|
||||
readonly _component?: T;
|
||||
}
|
||||
|
||||
type LocaleKey = "en-GB" | "zh-CN" | "ar-AE" | "cy-GB" | "ru-RU";
|
||||
|
||||
const messages: Partial<Record<LocaleKey, TranslationRecord>> = {
|
||||
"en-GB": {},
|
||||
"zh-CN": {},
|
||||
"ar-AE": {},
|
||||
"cy-GB": {},
|
||||
"ru-RU": {},
|
||||
};
|
||||
|
||||
function StoryHarness(props: {
|
||||
locale: LocaleKey;
|
||||
keys: readonly string[];
|
||||
children?: ReactNode;
|
||||
}) {
|
||||
const [i18n] = useState<I18nInstance>(
|
||||
() =>
|
||||
createI18n({
|
||||
defaultLocale: props.locale,
|
||||
messages,
|
||||
}) as I18nInstance
|
||||
);
|
||||
useEffect(() => {
|
||||
let i = 0;
|
||||
const id = setInterval(() => {
|
||||
const key = props.keys[i % props.keys.length];
|
||||
if (key !== undefined) {
|
||||
i18n.events.emit("missingKey", {
|
||||
locale: props.locale,
|
||||
namespace: "auth",
|
||||
key,
|
||||
});
|
||||
}
|
||||
i += 1;
|
||||
}, 600);
|
||||
return () => clearInterval(id);
|
||||
}, [i18n, props.keys, props.locale]);
|
||||
return (
|
||||
<TranslationProvider i18n={i18n}>
|
||||
{props.children}
|
||||
<MissingKeyOverlay production={false} />
|
||||
</TranslationProvider>
|
||||
);
|
||||
}
|
||||
|
||||
const meta: Meta<typeof MissingKeyOverlay> = {
|
||||
component: MissingKeyOverlay,
|
||||
title: "react-i18n/MissingKeyOverlay",
|
||||
tags: ["autodocs"],
|
||||
};
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj<typeof MissingKeyOverlay>;
|
||||
|
||||
export const Default: Story = {
|
||||
render: () => (
|
||||
<StoryHarness
|
||||
locale="en-GB"
|
||||
keys={["page.welcome", "auth.login.title", "common.cancel"]}
|
||||
>
|
||||
<p>Open dev tools — entries stream into the overlay.</p>
|
||||
</StoryHarness>
|
||||
),
|
||||
};
|
||||
|
||||
export const Playground: Story = {
|
||||
render: () => (
|
||||
<StoryHarness
|
||||
locale="en-GB"
|
||||
keys={["a.1", "a.2", "a.3", "a.4", "a.5"]}
|
||||
/>
|
||||
),
|
||||
};
|
||||
|
||||
export const WithI18nEnglish: Story = {
|
||||
name: "WithI18n / en-GB",
|
||||
render: () => (
|
||||
<StoryHarness
|
||||
locale="en-GB"
|
||||
keys={["pricing.cta", "footer.copyright"]}
|
||||
/>
|
||||
),
|
||||
};
|
||||
|
||||
export const WithI18nChinese: Story = {
|
||||
name: "WithI18n / zh-CN",
|
||||
render: () => (
|
||||
<StoryHarness
|
||||
locale="zh-CN"
|
||||
keys={["pricing.cta", "footer.copyright"]}
|
||||
/>
|
||||
),
|
||||
};
|
||||
|
||||
export const WithI18nArabic: Story = {
|
||||
name: "WithI18n / ar-AE (RTL)",
|
||||
render: () => (
|
||||
<div dir="rtl">
|
||||
<StoryHarness
|
||||
locale="ar-AE"
|
||||
keys={["pricing.cta", "footer.copyright"]}
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
};
|
||||
|
||||
export const WithI18nWelsh: Story = {
|
||||
name: "WithI18n / cy-GB",
|
||||
render: () => (
|
||||
<StoryHarness
|
||||
locale="cy-GB"
|
||||
keys={["pricing.cta", "footer.copyright"]}
|
||||
/>
|
||||
),
|
||||
};
|
||||
|
||||
export const WithI18nRussian: Story = {
|
||||
name: "WithI18n / ru-RU",
|
||||
render: () => (
|
||||
<StoryHarness
|
||||
locale="ru-RU"
|
||||
keys={["pricing.cta", "footer.copyright"]}
|
||||
/>
|
||||
),
|
||||
};
|
||||
@@ -0,0 +1,291 @@
|
||||
import { createI18n } from "@suaveplan/i18n/plugin";
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { renderComponent } from "@suaveplan/testing-dom/render";
|
||||
import { act } from "@testing-library/react";
|
||||
import { TranslationProvider } from "../../provider/provider.js";
|
||||
import { MissingKeyOverlay } from "./missing-key-overlay.js";
|
||||
|
||||
describe("MissingKeyOverlay", () => {
|
||||
it("renders nothing in production", { timeout: 5000 }, () => {
|
||||
const i18n = createI18n({
|
||||
defaultLocale: "en-GB",
|
||||
messages: { "en-GB": {} },
|
||||
});
|
||||
const { container } = renderComponent(
|
||||
<TranslationProvider i18n={i18n}>
|
||||
<MissingKeyOverlay production={true} />
|
||||
</TranslationProvider>
|
||||
);
|
||||
expect(
|
||||
container.querySelector(
|
||||
"[data-testid='suaveplan-missing-key-overlay']"
|
||||
)
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("renders nothing when no missingKey events fire yet", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const i18n = createI18n({
|
||||
defaultLocale: "en-GB",
|
||||
messages: { "en-GB": {} },
|
||||
});
|
||||
const { container } = renderComponent(
|
||||
<TranslationProvider i18n={i18n}>
|
||||
<MissingKeyOverlay production={false} />
|
||||
</TranslationProvider>
|
||||
);
|
||||
expect(
|
||||
container.querySelector(
|
||||
"[data-testid='suaveplan-missing-key-overlay']"
|
||||
)
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("renders an entry when a missingKey event fires", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const i18n = createI18n({
|
||||
defaultLocale: "en-GB",
|
||||
messages: { "en-GB": {} },
|
||||
});
|
||||
const { container } = renderComponent(
|
||||
<TranslationProvider i18n={i18n}>
|
||||
<MissingKeyOverlay production={false} />
|
||||
</TranslationProvider>
|
||||
);
|
||||
act(() => {
|
||||
i18n.events.emit("missingKey", {
|
||||
locale: "en-GB",
|
||||
namespace: "auth",
|
||||
key: "absent.key",
|
||||
});
|
||||
});
|
||||
const overlay = container.querySelector(
|
||||
"[data-testid='suaveplan-missing-key-overlay']"
|
||||
);
|
||||
expect(overlay).not.toBeNull();
|
||||
expect(overlay?.getAttribute("aria-live")).toBe("polite");
|
||||
expect(overlay?.textContent).toContain("absent.key");
|
||||
expect(overlay?.textContent).toContain("en-GB");
|
||||
expect(overlay?.textContent).toContain("auth");
|
||||
});
|
||||
|
||||
it("caps entries at maxEntries (default 50)", { timeout: 5000 }, () => {
|
||||
const i18n = createI18n({
|
||||
defaultLocale: "en-GB",
|
||||
messages: { "en-GB": {} },
|
||||
});
|
||||
const { container } = renderComponent(
|
||||
<TranslationProvider i18n={i18n}>
|
||||
<MissingKeyOverlay production={false} />
|
||||
</TranslationProvider>
|
||||
);
|
||||
act(() => {
|
||||
for (let i = 0; i < 60; i++) {
|
||||
i18n.events.emit("missingKey", {
|
||||
locale: "en-GB",
|
||||
namespace: "",
|
||||
key: `key.${i}`,
|
||||
});
|
||||
}
|
||||
});
|
||||
const items = container.querySelectorAll("li");
|
||||
expect(items.length).toBe(50);
|
||||
// Oldest should be key.10 (60 - 50 = 10), newest key.59
|
||||
expect(items[0]?.textContent).toContain("key.10");
|
||||
expect(items[items.length - 1]?.textContent).toContain("key.59");
|
||||
});
|
||||
|
||||
it("respects a custom maxEntries override", { timeout: 5000 }, () => {
|
||||
const i18n = createI18n({
|
||||
defaultLocale: "en-GB",
|
||||
messages: { "en-GB": {} },
|
||||
});
|
||||
const { container } = renderComponent(
|
||||
<TranslationProvider i18n={i18n}>
|
||||
<MissingKeyOverlay production={false} maxEntries={3} />
|
||||
</TranslationProvider>
|
||||
);
|
||||
act(() => {
|
||||
for (let i = 0; i < 5; i++) {
|
||||
i18n.events.emit("missingKey", {
|
||||
locale: "en-GB",
|
||||
namespace: "",
|
||||
key: `k.${i}`,
|
||||
});
|
||||
}
|
||||
});
|
||||
const items = container.querySelectorAll("li");
|
||||
expect(items.length).toBe(3);
|
||||
expect(items[0]?.textContent).toContain("k.2");
|
||||
expect(items[2]?.textContent).toContain("k.4");
|
||||
});
|
||||
|
||||
it("hides namespace separator when namespace is empty", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const i18n = createI18n({
|
||||
defaultLocale: "en-GB",
|
||||
messages: { "en-GB": {} },
|
||||
});
|
||||
const { container } = renderComponent(
|
||||
<TranslationProvider i18n={i18n}>
|
||||
<MissingKeyOverlay production={false} />
|
||||
</TranslationProvider>
|
||||
);
|
||||
act(() => {
|
||||
i18n.events.emit("missingKey", {
|
||||
locale: "en-GB",
|
||||
namespace: "",
|
||||
key: "no.ns",
|
||||
});
|
||||
});
|
||||
const overlay = container.querySelector(
|
||||
"[data-testid='suaveplan-missing-key-overlay']"
|
||||
);
|
||||
expect(overlay?.textContent).not.toContain(" · ");
|
||||
});
|
||||
|
||||
it("renders nothing without a Provider ancestor", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const { container } = renderComponent(
|
||||
<MissingKeyOverlay production={false} />
|
||||
);
|
||||
expect(
|
||||
container.querySelector(
|
||||
"[data-testid='suaveplan-missing-key-overlay']"
|
||||
)
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("falls back to runtime env detection when production prop is omitted", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const original = (
|
||||
globalThis as unknown as {
|
||||
process?: { env?: { NODE_ENV?: string } };
|
||||
}
|
||||
).process?.env?.NODE_ENV;
|
||||
const proc = (
|
||||
globalThis as unknown as {
|
||||
process?: { env?: { NODE_ENV?: string } };
|
||||
}
|
||||
).process;
|
||||
if (proc?.env) proc.env.NODE_ENV = "production";
|
||||
const i18n = createI18n({
|
||||
defaultLocale: "en-GB",
|
||||
messages: { "en-GB": {} },
|
||||
});
|
||||
try {
|
||||
const { container } = renderComponent(
|
||||
<TranslationProvider i18n={i18n}>
|
||||
<MissingKeyOverlay />
|
||||
</TranslationProvider>
|
||||
);
|
||||
act(() => {
|
||||
i18n.events.emit("missingKey", {
|
||||
locale: "en-GB",
|
||||
namespace: "",
|
||||
key: "shouldNotRender",
|
||||
});
|
||||
});
|
||||
expect(
|
||||
container.querySelector(
|
||||
"[data-testid='suaveplan-missing-key-overlay']"
|
||||
)
|
||||
).toBeNull();
|
||||
} finally {
|
||||
if (proc?.env) proc.env.NODE_ENV = original ?? "";
|
||||
}
|
||||
});
|
||||
|
||||
it("renders nothing when called with no props (default object)", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
// Exercises the `props: MissingKeyOverlayProps = {}` default.
|
||||
const i18n = createI18n({
|
||||
defaultLocale: "en-GB",
|
||||
messages: { "en-GB": {} },
|
||||
});
|
||||
const proc = (
|
||||
globalThis as unknown as {
|
||||
process?: { env?: { NODE_ENV?: string } };
|
||||
}
|
||||
).process;
|
||||
const original = proc?.env?.NODE_ENV;
|
||||
if (proc?.env) proc.env.NODE_ENV = "development";
|
||||
try {
|
||||
const { container } = renderComponent(
|
||||
<TranslationProvider i18n={i18n}>
|
||||
<MissingKeyOverlay />
|
||||
</TranslationProvider>
|
||||
);
|
||||
// No events fired yet → still null.
|
||||
expect(
|
||||
container.querySelector(
|
||||
"[data-testid='suaveplan-missing-key-overlay']"
|
||||
)
|
||||
).toBeNull();
|
||||
} finally {
|
||||
if (proc?.env) proc.env.NODE_ENV = original ?? "";
|
||||
}
|
||||
});
|
||||
|
||||
it("handles a missingKey payload with undefined namespace", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const i18n = createI18n({
|
||||
defaultLocale: "en-GB",
|
||||
messages: { "en-GB": {} },
|
||||
});
|
||||
const { container } = renderComponent(
|
||||
<TranslationProvider i18n={i18n}>
|
||||
<MissingKeyOverlay production={false} />
|
||||
</TranslationProvider>
|
||||
);
|
||||
act(() => {
|
||||
i18n.events.emit("missingKey", {
|
||||
locale: "en-GB",
|
||||
// namespace deliberately omitted to exercise the
|
||||
// `payload.namespace ?? ""` fallback branch.
|
||||
namespace: undefined as unknown as string,
|
||||
key: "no.namespace",
|
||||
});
|
||||
});
|
||||
const overlay = container.querySelector(
|
||||
"[data-testid='suaveplan-missing-key-overlay']"
|
||||
);
|
||||
expect(overlay?.textContent).toContain("no.namespace");
|
||||
});
|
||||
|
||||
it("removes the bus listener on unmount", { timeout: 5000 }, () => {
|
||||
const i18n = createI18n({
|
||||
defaultLocale: "en-GB",
|
||||
messages: { "en-GB": {} },
|
||||
});
|
||||
const { container, unmount } = renderComponent(
|
||||
<TranslationProvider i18n={i18n}>
|
||||
<MissingKeyOverlay production={false} />
|
||||
</TranslationProvider>
|
||||
);
|
||||
act(() => {
|
||||
i18n.events.emit("missingKey", {
|
||||
locale: "en-GB",
|
||||
namespace: "",
|
||||
key: "first",
|
||||
});
|
||||
});
|
||||
expect(container.textContent).toContain("first");
|
||||
unmount();
|
||||
// Emit after unmount; the listener should be gone, no throw.
|
||||
expect(() =>
|
||||
i18n.events.emit("missingKey", {
|
||||
locale: "en-GB",
|
||||
namespace: "",
|
||||
key: "second",
|
||||
})
|
||||
).not.toThrow();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,137 @@
|
||||
import {
|
||||
type ReactElement,
|
||||
useContext,
|
||||
useEffect,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import { TranslationContext } from "../../provider/provider-context.js";
|
||||
import { isProductionEnv } from "../../utils/env.js";
|
||||
import type {
|
||||
MissingKeyEntry,
|
||||
MissingKeyOverlayProps,
|
||||
} from "./missing-key-overlay.types.js";
|
||||
|
||||
const DEFAULT_MAX_ENTRIES = 50;
|
||||
|
||||
/**
|
||||
* Dev-mode overlay that lists every untranslated key as it streams off
|
||||
* the active `I18nInstance`'s event bus.
|
||||
*
|
||||
* The overlay subscribes to the `missingKey` topic and renders a
|
||||
* fixed-position list with `aria-live="polite"` so screen-reader users
|
||||
* notice incoming entries without forced focus changes. The list is
|
||||
* capped at the most-recent `maxEntries` (default 50) — older entries
|
||||
* silently drop off so the overlay never balloons in long-running dev
|
||||
* sessions. In production builds the overlay returns `null` and never
|
||||
* subscribes to the bus so it adds zero runtime cost.
|
||||
*/
|
||||
export function MissingKeyOverlay(
|
||||
props: MissingKeyOverlayProps
|
||||
): ReactElement | null {
|
||||
const i18n = useContext(TranslationContext);
|
||||
const isProd = props.production ?? isProductionEnv();
|
||||
const [entries, setEntries] = useState<readonly MissingKeyEntry[]>([]);
|
||||
const counterRef = useRef(0);
|
||||
const max = props.maxEntries ?? DEFAULT_MAX_ENTRIES;
|
||||
|
||||
useEffect(() => {
|
||||
// Production builds neither subscribe nor render; the early
|
||||
// return on `i18n === null` keeps the hook chain consistent
|
||||
// when there is no Provider ancestor (e.g. an import-but-don't-
|
||||
// mount path during SSR scaffolding).
|
||||
if (isProd || i18n === null) return;
|
||||
const off = i18n.events.on("missingKey", (payload) => {
|
||||
counterRef.current += 1;
|
||||
const next: MissingKeyEntry = {
|
||||
id: counterRef.current,
|
||||
key: String(payload.key),
|
||||
locale: String(payload.locale),
|
||||
namespace: String(payload.namespace ?? ""),
|
||||
};
|
||||
setEntries((prev) => appendCapped(prev, next, max));
|
||||
});
|
||||
return () => {
|
||||
off();
|
||||
};
|
||||
}, [i18n, isProd, max]);
|
||||
|
||||
if (isProd) return null;
|
||||
if (entries.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
data-testid="suaveplan-missing-key-overlay"
|
||||
aria-live="polite"
|
||||
style={overlayStyle}
|
||||
>
|
||||
<ul style={listStyle}>
|
||||
{entries.map((entry) => (
|
||||
<li key={entry.id} style={itemStyle}>
|
||||
<span data-key={entry.key}>{entry.key}</span>
|
||||
<span style={metaStyle}>
|
||||
{entry.locale}
|
||||
{entry.namespace !== ""
|
||||
? ` · ${entry.namespace}`
|
||||
: ""}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
MissingKeyOverlay.displayName = "SuaveplanMissingKeyOverlay";
|
||||
|
||||
/**
|
||||
* Append `next` to `prev`, dropping the oldest entry when the list
|
||||
* exceeds `max`. The function never mutates `prev` so React diffs the
|
||||
* returned array as a fresh value.
|
||||
*/
|
||||
function appendCapped(
|
||||
prev: readonly MissingKeyEntry[],
|
||||
next: MissingKeyEntry,
|
||||
max: number
|
||||
): readonly MissingKeyEntry[] {
|
||||
const out =
|
||||
prev.length >= max ? prev.slice(prev.length - max + 1) : [...prev];
|
||||
out.push(next);
|
||||
return out;
|
||||
}
|
||||
|
||||
const overlayStyle: React.CSSProperties = {
|
||||
position: "fixed",
|
||||
bottom: "1rem",
|
||||
right: "1rem",
|
||||
maxWidth: "32rem",
|
||||
maxHeight: "16rem",
|
||||
overflowY: "auto",
|
||||
padding: "0.5rem 0.75rem",
|
||||
background: "rgba(20, 20, 20, 0.92)",
|
||||
color: "#fff",
|
||||
fontFamily: "monospace",
|
||||
fontSize: "0.75rem",
|
||||
lineHeight: 1.4,
|
||||
borderRadius: "0.375rem",
|
||||
boxShadow: "0 4px 16px rgba(0, 0, 0, 0.35)",
|
||||
zIndex: 2_147_483_647,
|
||||
};
|
||||
|
||||
const listStyle: React.CSSProperties = {
|
||||
listStyle: "none",
|
||||
margin: 0,
|
||||
padding: 0,
|
||||
};
|
||||
|
||||
const itemStyle: React.CSSProperties = {
|
||||
display: "flex",
|
||||
justifyContent: "space-between",
|
||||
gap: "1rem",
|
||||
padding: "0.125rem 0",
|
||||
};
|
||||
|
||||
const metaStyle: React.CSSProperties = {
|
||||
opacity: 0.65,
|
||||
whiteSpace: "nowrap",
|
||||
};
|
||||
@@ -0,0 +1,36 @@
|
||||
/**
|
||||
* One captured `missingKey` event surfaced through the dev-mode overlay.
|
||||
*
|
||||
* Mirrors the runtime payload emitted by `MessageCatalog` / `I18nInstance`,
|
||||
* with the locale frozen at the moment the miss was observed so
|
||||
* subsequent language changes do not retroactively re-attribute the entry.
|
||||
*/
|
||||
export interface MissingKeyEntry {
|
||||
/** Stable counter used as the React `key` and to dedupe rapid re-emits. */
|
||||
readonly id: number;
|
||||
/** Translation key that triggered the missing-key event. */
|
||||
readonly key: string;
|
||||
/** Active locale at the moment the event was observed. */
|
||||
readonly locale: string;
|
||||
/** Namespace label (empty string when no namespace). */
|
||||
readonly namespace: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Props for `<MissingKeyOverlay>`.
|
||||
*
|
||||
* `maxEntries` defaults to 50 (matching the spec). `production` is an
|
||||
* injectable override of the env-detection logic — exposed for tests so
|
||||
* the production-render-null path can be exercised without mutating
|
||||
* `process.env`.
|
||||
*/
|
||||
export interface MissingKeyOverlayProps {
|
||||
/** Hard cap on the most-recent entries retained. Default: 50. */
|
||||
readonly maxEntries?: number;
|
||||
/**
|
||||
* Override for the production-mode detection. When `true`, the
|
||||
* overlay renders nothing and registers no listener. Defaults to the
|
||||
* runtime env-check (`isProductionEnv()`).
|
||||
*/
|
||||
readonly production?: boolean;
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export {
|
||||
childrenToMessageString,
|
||||
stringifyChild,
|
||||
} from "./stringify-children.js";
|
||||
export { Trans } from "./trans.js";
|
||||
export type { TransProps } from "./trans.types.js";
|
||||
@@ -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.
|
||||
@@ -0,0 +1,94 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import {
|
||||
childrenToMessageString,
|
||||
stringifyChild,
|
||||
} from "./stringify-children.js";
|
||||
|
||||
describe("stringifyChild", () => {
|
||||
it("stringifies primitives correctly", { timeout: 5000 }, () => {
|
||||
expect(stringifyChild("hello", "k")).toBe("hello");
|
||||
expect(stringifyChild(42, "k")).toBe("42");
|
||||
expect(stringifyChild(true, "k")).toBe("");
|
||||
expect(stringifyChild(false, "k")).toBe("");
|
||||
expect(stringifyChild(null, "k")).toBe("");
|
||||
expect(stringifyChild(undefined, "k")).toBe("");
|
||||
});
|
||||
|
||||
it("handles elements without children prop", { timeout: 5000 }, () => {
|
||||
expect(stringifyChild(<br />, "k")).toBe("<br></br>");
|
||||
expect(stringifyChild(<strong />, "k")).toBe("<strong></strong>");
|
||||
});
|
||||
|
||||
it("throws TRANS_INVALID_CHILD for structurally-invalid input", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
// Direct unit test against `stringifyChild`. React's pre-render
|
||||
// child validation rejects most invalid values before they reach
|
||||
// the helper through the JSX surface, so the defensive throw is
|
||||
// only reachable through direct invocation.
|
||||
let thrown: unknown;
|
||||
try {
|
||||
stringifyChild(
|
||||
{ a: 1 } as unknown as React.ReactNode,
|
||||
"absent.bad"
|
||||
);
|
||||
} catch (err) {
|
||||
thrown = err;
|
||||
}
|
||||
expect(thrown).toBeDefined();
|
||||
expect((thrown as { code?: string }).code).toBe(
|
||||
"REACT_I18N.TRANS_INVALID_CHILD"
|
||||
);
|
||||
});
|
||||
|
||||
it("renders nested elements with displayName as the tag name", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const Bold = (props: { children?: React.ReactNode }) => (
|
||||
<strong>{props.children}</strong>
|
||||
);
|
||||
Bold.displayName = "Bold";
|
||||
expect(stringifyChild(<Bold>{"x"}</Bold>, "k")).toBe("<Bold>x</Bold>");
|
||||
});
|
||||
|
||||
it("falls back to function name when displayName is absent", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
function Italic(props: { children?: React.ReactNode }) {
|
||||
return <em>{props.children}</em>;
|
||||
}
|
||||
expect(stringifyChild(<Italic>{"y"}</Italic>, "k")).toBe(
|
||||
"<Italic>y</Italic>"
|
||||
);
|
||||
});
|
||||
|
||||
it("falls back to 'x' when neither displayName nor name is present", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const Anon = (props: { children?: React.ReactNode }) => (
|
||||
<span>{props.children}</span>
|
||||
);
|
||||
Object.defineProperty(Anon, "name", { value: "" });
|
||||
expect(stringifyChild(<Anon>{"z"}</Anon>, "k")).toBe("<x>z</x>");
|
||||
});
|
||||
});
|
||||
|
||||
describe("childrenToMessageString", () => {
|
||||
it("returns the key when children is null", { timeout: 5000 }, () => {
|
||||
expect(childrenToMessageString(null, "absent.k")).toBe("absent.k");
|
||||
});
|
||||
|
||||
it("returns the key when children is undefined", { timeout: 5000 }, () => {
|
||||
expect(childrenToMessageString(undefined, "absent.k")).toBe("absent.k");
|
||||
});
|
||||
|
||||
it("concatenates a mixed children array into an ICU body", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const result = childrenToMessageString(
|
||||
["Hello ", <strong key="b">{"World"}</strong>, "!"],
|
||||
"k"
|
||||
);
|
||||
expect(result).toBe("Hello <strong>World</strong>!");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,112 @@
|
||||
/**
|
||||
* Children-stringification helpers for `<Trans>` missing-key fallback.
|
||||
*
|
||||
* When the catalog has no entry for the requested key, `<Trans>` falls
|
||||
* back to its `children` prop and treats it as a default ICU message.
|
||||
* The helpers in this module walk the React-children tree and emit a
|
||||
* canonical string form (`Hello <strong>{name}</strong>!`) that the
|
||||
* parser renders identically to a catalog entry.
|
||||
*
|
||||
* Exported for direct unit testing of the structurally-invalid-child
|
||||
* branch — React's own pre-render child validation rejects most
|
||||
* invalid values before they reach our `Children.forEach` callback in
|
||||
* the JSX surface, so the defensive throw is only reachable through
|
||||
* direct invocation.
|
||||
*/
|
||||
|
||||
import {
|
||||
Children,
|
||||
isValidElement,
|
||||
type ReactElement,
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
import { REACT_I18N_CODES } from "../../errors/error-codes.js";
|
||||
import { ReactI18nError } from "../../errors/react-i18n-error.js";
|
||||
|
||||
/**
|
||||
* Stringify a `<Trans>` `children` block back to an ICU message body so
|
||||
* the parser can render it identically to a catalog entry. Used only on
|
||||
* the missing-key fallback path.
|
||||
*
|
||||
* - Strings round-trip verbatim
|
||||
* - Numbers stringify via `String()`
|
||||
* - Booleans, `null`, and `undefined` render as the empty string (matching
|
||||
* React's own rendering rules)
|
||||
* - JSX element children are translated to `<tagName>...</tagName>` so
|
||||
* the same tag-name → component map drives the render
|
||||
*
|
||||
* Throws `ReactI18nError(REACT_I18N.TRANS_INVALID_CHILD)` for any other
|
||||
* child (functions, custom objects, etc.).
|
||||
*/
|
||||
export function childrenToMessageString(
|
||||
children: ReactNode,
|
||||
key: string
|
||||
): string {
|
||||
if (children === null || children === undefined) return key;
|
||||
let out = "";
|
||||
Children.forEach(children, (child) => {
|
||||
out += stringifyChild(child, key);
|
||||
});
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Stringify a single React child node. Exposed as a named export so
|
||||
* the structurally-invalid-child branch can be unit-tested directly.
|
||||
*/
|
||||
export function stringifyChild(child: ReactNode, key: string): string {
|
||||
if (typeof child === "string") return child;
|
||||
if (typeof child === "number") return String(child);
|
||||
if (typeof child === "boolean" || child === null || child === undefined) {
|
||||
return "";
|
||||
}
|
||||
if (isValidElement(child)) {
|
||||
const tagName = elementTagName(child);
|
||||
const inner = nestedChildrenToString(
|
||||
(child.props as { children?: ReactNode }).children ?? null,
|
||||
key
|
||||
);
|
||||
return `<${tagName}>${inner}</${tagName}>`;
|
||||
}
|
||||
throw new ReactI18nError(
|
||||
REACT_I18N_CODES.TRANS_INVALID_CHILD,
|
||||
"react-i18n.trans.invalid_child",
|
||||
{ context: { key } }
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Stringify a child's nested children for the tag-wrap path. Empty
|
||||
* nested-children produce an empty inner string (NOT the outer key) so
|
||||
* `<br />` round-trips to `<br></br>` rather than `<br>key</br>`.
|
||||
*/
|
||||
function nestedChildrenToString(children: ReactNode, key: string): string {
|
||||
if (children === null || children === undefined) return "";
|
||||
let out = "";
|
||||
Children.forEach(children, (child) => {
|
||||
out += stringifyChild(child, key);
|
||||
});
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve an ICU tag name for a JSX element. The element's `type` may be a
|
||||
* string (HTML tag) or a component reference. Components prefer
|
||||
* `displayName`, then fall back to the function `name`. Empty strings
|
||||
* (an explicitly cleared `Function.name`) are treated as "no name". The
|
||||
* default is `"x"` so a tag-less element still serialises to a parseable
|
||||
* token.
|
||||
*/
|
||||
function elementTagName(element: ReactElement): string {
|
||||
const type = element.type as
|
||||
| string
|
||||
| { displayName?: string; name?: string };
|
||||
if (typeof type === "string") return type;
|
||||
if (typeof type.displayName === "string" && type.displayName.length > 0) {
|
||||
return type.displayName;
|
||||
}
|
||||
if (typeof type.name === "string" && type.name.length > 0) {
|
||||
return type.name;
|
||||
}
|
||||
return "x";
|
||||
}
|
||||
@@ -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,182 @@
|
||||
/**
|
||||
* Storybook stories for `<Trans>`.
|
||||
*
|
||||
* The package does not declare a `@storybook/react` dependency (Storybook
|
||||
* lives in the testbed app, not in library packages). The story-meta
|
||||
* shapes are inlined as `interface Meta` / `interface StoryObj` so the
|
||||
* file can be consumed by any Storybook 7+/8+ config that auto-discovers
|
||||
* stories without forcing every library package to depend on the full
|
||||
* Storybook toolchain at install time.
|
||||
*/
|
||||
|
||||
import type { TranslationRecord } from "@suaveplan/i18n";
|
||||
import { createI18n, type I18nInstance } from "@suaveplan/i18n/plugin";
|
||||
import type { ReactNode } from "react";
|
||||
import { TranslationProvider } from "../../provider/provider.js";
|
||||
import { Trans } from "./trans.js";
|
||||
import type { TransProps } from "./trans.types.js";
|
||||
|
||||
interface Meta<T> {
|
||||
readonly component: T;
|
||||
readonly title: string;
|
||||
readonly tags?: readonly string[];
|
||||
}
|
||||
|
||||
interface StoryObj<T> {
|
||||
readonly args?: Partial<TransProps>;
|
||||
readonly decorators?: ReadonlyArray<(Story: () => ReactNode) => ReactNode>;
|
||||
readonly render?: () => ReactNode;
|
||||
readonly name?: string;
|
||||
readonly _component?: T;
|
||||
}
|
||||
|
||||
type LocaleKey = "en-GB" | "zh-CN" | "ar-AE" | "cy-GB" | "ru-RU";
|
||||
|
||||
const messages: Partial<Record<LocaleKey, TranslationRecord>> = {
|
||||
"en-GB": {
|
||||
welcome: "Hello <b>{name}</b>!",
|
||||
items: { one: "{count} item", other: "{count} items" },
|
||||
},
|
||||
"zh-CN": {
|
||||
welcome: "<b>{name}</b>,你好!",
|
||||
items: { one: "{count} 项", other: "{count} 项" },
|
||||
},
|
||||
"ar-AE": {
|
||||
welcome: "مرحبا <b>{name}</b>!",
|
||||
items: { one: "{count} عنصر", other: "{count} عناصر" },
|
||||
},
|
||||
"cy-GB": {
|
||||
welcome: "Helo <b>{name}</b>!",
|
||||
items: { one: "{count} eitem", other: "{count} eitemau" },
|
||||
},
|
||||
"ru-RU": {
|
||||
welcome: "Привет, <b>{name}</b>!",
|
||||
items: { one: "{count} элемент", other: "{count} элементов" },
|
||||
},
|
||||
};
|
||||
|
||||
function withProviderFor(locale: LocaleKey) {
|
||||
return function Wrapper(props: { children: ReactNode }) {
|
||||
const i18n = createI18n({
|
||||
defaultLocale: locale,
|
||||
messages,
|
||||
}) as I18nInstance;
|
||||
return (
|
||||
<TranslationProvider i18n={i18n}>
|
||||
{props.children}
|
||||
</TranslationProvider>
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
const meta: Meta<typeof Trans> = {
|
||||
component: Trans,
|
||||
title: "react-i18n/Trans",
|
||||
tags: ["autodocs"],
|
||||
};
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj<typeof Trans>;
|
||||
|
||||
export const Default: Story = {
|
||||
args: {
|
||||
i18nKey: "welcome",
|
||||
values: { name: "Alice" },
|
||||
components: { b: <strong /> },
|
||||
},
|
||||
decorators: [
|
||||
(Story) => {
|
||||
const W = withProviderFor("en-GB");
|
||||
return <W>{Story()}</W>;
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
export const Playground: Story = {
|
||||
args: { i18nKey: "items", count: 3 },
|
||||
decorators: [
|
||||
(Story) => {
|
||||
const W = withProviderFor("en-GB");
|
||||
return <W>{Story()}</W>;
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
export const WithI18nEnglish: Story = {
|
||||
name: "WithI18n / en-GB",
|
||||
args: {
|
||||
i18nKey: "welcome",
|
||||
values: { name: "Alice" },
|
||||
components: { b: <strong /> },
|
||||
},
|
||||
decorators: [
|
||||
(Story) => {
|
||||
const W = withProviderFor("en-GB");
|
||||
return <W>{Story()}</W>;
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
export const WithI18nChinese: Story = {
|
||||
name: "WithI18n / zh-CN",
|
||||
args: {
|
||||
i18nKey: "welcome",
|
||||
values: { name: "爱丽丝" },
|
||||
components: { b: <strong /> },
|
||||
},
|
||||
decorators: [
|
||||
(Story) => {
|
||||
const W = withProviderFor("zh-CN");
|
||||
return <W>{Story()}</W>;
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
export const WithI18nArabic: Story = {
|
||||
name: "WithI18n / ar-AE (RTL)",
|
||||
args: {
|
||||
i18nKey: "welcome",
|
||||
values: { name: "أليس" },
|
||||
components: { b: <strong /> },
|
||||
},
|
||||
decorators: [
|
||||
(Story) => {
|
||||
const W = withProviderFor("ar-AE");
|
||||
return (
|
||||
<W>
|
||||
<div dir="rtl">{Story()}</div>
|
||||
</W>
|
||||
);
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
export const WithI18nWelsh: Story = {
|
||||
name: "WithI18n / cy-GB",
|
||||
args: {
|
||||
i18nKey: "welcome",
|
||||
values: { name: "Alys" },
|
||||
components: { b: <strong /> },
|
||||
},
|
||||
decorators: [
|
||||
(Story) => {
|
||||
const W = withProviderFor("cy-GB");
|
||||
return <W>{Story()}</W>;
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
export const WithI18nRussian: Story = {
|
||||
name: "WithI18n / ru-RU",
|
||||
args: {
|
||||
i18nKey: "welcome",
|
||||
values: { name: "Алиса" },
|
||||
components: { b: <strong /> },
|
||||
},
|
||||
decorators: [
|
||||
(Story) => {
|
||||
const W = withProviderFor("ru-RU");
|
||||
return <W>{Story()}</W>;
|
||||
},
|
||||
],
|
||||
};
|
||||
@@ -0,0 +1,524 @@
|
||||
import { createI18n } from "@suaveplan/i18n/plugin";
|
||||
import {
|
||||
afterEach,
|
||||
beforeEach,
|
||||
describe,
|
||||
expect,
|
||||
it,
|
||||
} from "@suaveplan/testing/runner";
|
||||
import { renderComponent } from "@suaveplan/testing-dom/render";
|
||||
import {
|
||||
getReactI18nMetricsState,
|
||||
resetReactI18nMetricsState,
|
||||
} from "../../observability/react-i18n-metrics.js";
|
||||
import { TranslationProvider } from "../../provider/provider.js";
|
||||
import { RegistryProvider } from "../../registry/registry-provider.js";
|
||||
import { Trans } from "./trans.js";
|
||||
|
||||
/** Walk an Error cause-chain and return the deepest `.code`, if any. */
|
||||
function rootCode(err: unknown): string | undefined {
|
||||
let current: unknown = err;
|
||||
let lastCode: string | undefined;
|
||||
let depth = 0;
|
||||
while (current instanceof Error && depth < 10) {
|
||||
const code = (current as Error & { code?: string }).code;
|
||||
if (code !== undefined) lastCode = code;
|
||||
const cause = (current as Error & { cause?: unknown }).cause;
|
||||
if (cause === undefined || cause === null) break;
|
||||
current = cause;
|
||||
depth += 1;
|
||||
}
|
||||
return lastCode;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
resetReactI18nMetricsState();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
resetReactI18nMetricsState();
|
||||
});
|
||||
|
||||
describe("Trans", () => {
|
||||
it("tag substitution renders bold child", { timeout: 5000 }, () => {
|
||||
const i18n = createI18n({
|
||||
defaultLocale: "en-GB",
|
||||
messages: { "en-GB": { welcome: "Hello <b>{name}</b>!" } },
|
||||
});
|
||||
const { container } = renderComponent(
|
||||
<TranslationProvider i18n={i18n}>
|
||||
<Trans
|
||||
i18nKey="welcome"
|
||||
values={{ name: "Alice" }}
|
||||
components={{ b: <strong /> }}
|
||||
/>
|
||||
</TranslationProvider>
|
||||
);
|
||||
const strong = container.querySelector("strong");
|
||||
expect(strong?.textContent).toBe("Alice");
|
||||
expect(container.textContent).toBe("Hello Alice!");
|
||||
});
|
||||
|
||||
it("nested tags render correctly", { timeout: 5000 }, () => {
|
||||
const i18n = createI18n({
|
||||
defaultLocale: "en-GB",
|
||||
messages: { "en-GB": { link: "See <a><b>here</b></a>" } },
|
||||
});
|
||||
const { container } = renderComponent(
|
||||
<TranslationProvider i18n={i18n}>
|
||||
<Trans
|
||||
i18nKey="link"
|
||||
components={{
|
||||
// The `<a>` is a placeholder element whose
|
||||
// children are replaced by the chunked render.
|
||||
// The label inside satisfies biome's a11y check
|
||||
// for the source element; consumers see only
|
||||
// the rendered chunks.
|
||||
a: <a href="/x">link</a>,
|
||||
b: <strong />,
|
||||
}}
|
||||
/>
|
||||
</TranslationProvider>
|
||||
);
|
||||
const a = container.querySelector("a");
|
||||
expect(a?.getAttribute("href")).toBe("/x");
|
||||
const strong = a?.querySelector("strong");
|
||||
expect(strong?.textContent).toBe("here");
|
||||
});
|
||||
|
||||
it("count drives plural branch", { timeout: 5000 }, () => {
|
||||
const i18n = createI18n({
|
||||
defaultLocale: "en-GB",
|
||||
messages: {
|
||||
"en-GB": {
|
||||
items: {
|
||||
one: "{count} item",
|
||||
other: "{count} items",
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
const { container } = renderComponent(
|
||||
<TranslationProvider i18n={i18n}>
|
||||
<Trans i18nKey="items" count={3} />
|
||||
</TranslationProvider>
|
||||
);
|
||||
expect(container.textContent).toBe("3 items");
|
||||
});
|
||||
|
||||
it("count of 1 selects the one branch", { timeout: 5000 }, () => {
|
||||
const i18n = createI18n({
|
||||
defaultLocale: "en-GB",
|
||||
messages: {
|
||||
"en-GB": {
|
||||
items: {
|
||||
one: "{count} item",
|
||||
other: "{count} items",
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
const { container } = renderComponent(
|
||||
<TranslationProvider i18n={i18n}>
|
||||
<Trans i18nKey="items" count={1} />
|
||||
</TranslationProvider>
|
||||
);
|
||||
expect(container.textContent).toBe("1 item");
|
||||
});
|
||||
|
||||
it("children render as default message when key is missing", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const i18n = createI18n({
|
||||
defaultLocale: "en-GB",
|
||||
messages: { "en-GB": {} },
|
||||
});
|
||||
const { container } = renderComponent(
|
||||
<TranslationProvider i18n={i18n}>
|
||||
<Trans
|
||||
i18nKey="absent"
|
||||
values={{ name: "World" }}
|
||||
components={{ b: <strong /> }}
|
||||
>
|
||||
{"Hello "}
|
||||
<b>{"{name}"}</b>
|
||||
{"!"}
|
||||
</Trans>
|
||||
</TranslationProvider>
|
||||
);
|
||||
const strong = container.querySelector("strong");
|
||||
expect(strong?.textContent).toBe("World");
|
||||
expect(container.textContent).toBe("Hello World!");
|
||||
});
|
||||
|
||||
it("missing key without children renders the key itself", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const i18n = createI18n({
|
||||
defaultLocale: "en-GB",
|
||||
messages: { "en-GB": {} },
|
||||
});
|
||||
const { container } = renderComponent(
|
||||
<TranslationProvider i18n={i18n}>
|
||||
<Trans i18nKey="absent.no.children" />
|
||||
</TranslationProvider>
|
||||
);
|
||||
expect(container.textContent).toBe("absent.no.children");
|
||||
});
|
||||
|
||||
it("missing key increments react_i18n.missing_key.total", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const i18n = createI18n({
|
||||
defaultLocale: "en-GB",
|
||||
messages: { "en-GB": {} },
|
||||
});
|
||||
renderComponent(
|
||||
<TranslationProvider i18n={i18n}>
|
||||
<Trans i18nKey="absent" componentName="LoginForm">
|
||||
{"Fallback"}
|
||||
</Trans>
|
||||
</TranslationProvider>
|
||||
);
|
||||
const samples = getReactI18nMetricsState().missingKeyTotal;
|
||||
const match = samples.find(
|
||||
(s) =>
|
||||
s.attributes.locale === "en-GB" &&
|
||||
s.attributes.componentName === "LoginForm"
|
||||
);
|
||||
expect(match).toBeDefined();
|
||||
expect(match?.attributes.namespace).toBe("");
|
||||
});
|
||||
|
||||
it("missing key without componentName omits the attribute", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const i18n = createI18n({
|
||||
defaultLocale: "en-GB",
|
||||
messages: { "en-GB": {} },
|
||||
});
|
||||
renderComponent(
|
||||
<TranslationProvider i18n={i18n}>
|
||||
<Trans i18nKey="absent2">{"Hi"}</Trans>
|
||||
</TranslationProvider>
|
||||
);
|
||||
const samples = getReactI18nMetricsState().missingKeyTotal;
|
||||
const last = samples[samples.length - 1];
|
||||
expect(last?.attributes.componentName).toBeUndefined();
|
||||
});
|
||||
|
||||
it("emits a render-duration histogram sample", { timeout: 5000 }, () => {
|
||||
const i18n = createI18n({
|
||||
defaultLocale: "en-GB",
|
||||
messages: { "en-GB": { hi: "Hi" } },
|
||||
});
|
||||
renderComponent(
|
||||
<TranslationProvider i18n={i18n}>
|
||||
<Trans i18nKey="hi" />
|
||||
</TranslationProvider>
|
||||
);
|
||||
const samples = getReactI18nMetricsState().renderDurationMs;
|
||||
expect(samples.length).toBeGreaterThan(0);
|
||||
expect(samples[samples.length - 1]?.attributes.locale).toBe("en-GB");
|
||||
});
|
||||
|
||||
it("throws REACT_I18N.NO_PROVIDER outside a Provider", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const original = console.error;
|
||||
console.error = (() => {}) as typeof console.error;
|
||||
try {
|
||||
let thrown: unknown;
|
||||
try {
|
||||
renderComponent(<Trans i18nKey="x" />);
|
||||
} catch (err) {
|
||||
thrown = err;
|
||||
}
|
||||
expect(thrown).toBeDefined();
|
||||
expect(rootCode(thrown)).toBe("REACT_I18N.NO_PROVIDER");
|
||||
} finally {
|
||||
console.error = original;
|
||||
}
|
||||
});
|
||||
|
||||
it("count with empty messages map falls back to children", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
// Empty messages object is set; lookup returns the empty record,
|
||||
// so `lookupPluralBranch` walks parts and returns false at the
|
||||
// missing-part guard (`!messages` is false but no key matches).
|
||||
const i18n = createI18n({
|
||||
defaultLocale: "en-GB",
|
||||
// No messages key at all so getMessages('en-GB') returns undefined.
|
||||
});
|
||||
const { container } = renderComponent(
|
||||
<TranslationProvider i18n={i18n}>
|
||||
<Trans i18nKey="absent.count" count={5}>
|
||||
{"{count}"}
|
||||
</Trans>
|
||||
</TranslationProvider>
|
||||
);
|
||||
expect(container.textContent).toBe("5");
|
||||
});
|
||||
|
||||
it("plural branch lookup accepts a record with only 'one'", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
// Exercises the right-hand side of the `'other' in cursor || 'one' in cursor`
|
||||
// short-circuit in `lookupPluralBranch`. Catalogs that only
|
||||
// declare a `one` branch are atypical but valid for languages
|
||||
// where the singular form is the only meaningful variant.
|
||||
const i18n = createI18n({
|
||||
defaultLocale: "en-GB",
|
||||
messages: {
|
||||
"en-GB": {
|
||||
only_one: { one: "{count} singleton" },
|
||||
},
|
||||
},
|
||||
});
|
||||
const { container } = renderComponent(
|
||||
<TranslationProvider i18n={i18n}>
|
||||
<Trans i18nKey="only_one" count={1} />
|
||||
</TranslationProvider>
|
||||
);
|
||||
// The catalog's plural-resolver returns the `one` branch when
|
||||
// count === 1; for other counts the resolver returns undefined
|
||||
// and the rendered output falls back to the key.
|
||||
expect(container.textContent).toBe("1 singleton");
|
||||
});
|
||||
|
||||
it("count where path resolves to a string leaf does not select plural", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const i18n = createI18n({
|
||||
defaultLocale: "en-GB",
|
||||
messages: { "en-GB": { hello: "Hi" } },
|
||||
});
|
||||
// Plural-branch lookup walks the dot path; when the cursor
|
||||
// resolves to a string (not an object with 'one'/'other' keys),
|
||||
// the function returns false and we fall through to the
|
||||
// string-hit branch.
|
||||
const { container } = renderComponent(
|
||||
<TranslationProvider i18n={i18n}>
|
||||
<Trans i18nKey="hello" count={5} />
|
||||
</TranslationProvider>
|
||||
);
|
||||
expect(container.textContent).toBe("Hi");
|
||||
});
|
||||
|
||||
it("renders children that include numeric and boolean nodes", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const i18n = createI18n({
|
||||
defaultLocale: "en-GB",
|
||||
messages: { "en-GB": {} },
|
||||
});
|
||||
const { container } = renderComponent(
|
||||
<TranslationProvider i18n={i18n}>
|
||||
<Trans i18nKey="absent.mixed">
|
||||
{"Score: "}
|
||||
{42}
|
||||
{true}
|
||||
</Trans>
|
||||
</TranslationProvider>
|
||||
);
|
||||
expect(container.textContent).toBe("Score: 42");
|
||||
});
|
||||
|
||||
it("renders explicitly null children as the key on missing", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const i18n = createI18n({
|
||||
defaultLocale: "en-GB",
|
||||
messages: { "en-GB": {} },
|
||||
});
|
||||
const { container } = renderComponent(
|
||||
<TranslationProvider i18n={i18n}>
|
||||
<Trans i18nKey="absent.null">{null}</Trans>
|
||||
</TranslationProvider>
|
||||
);
|
||||
expect(container.textContent).toBe("absent.null");
|
||||
});
|
||||
|
||||
it("preserves caller-supplied count in values when count prop matches", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const i18n = createI18n({
|
||||
defaultLocale: "en-GB",
|
||||
messages: {
|
||||
"en-GB": {
|
||||
items: {
|
||||
one: "{count} item",
|
||||
other: "{count} items",
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
const { container } = renderComponent(
|
||||
<TranslationProvider i18n={i18n}>
|
||||
<Trans i18nKey="items" count={5} values={{ count: 5 }} />
|
||||
</TranslationProvider>
|
||||
);
|
||||
expect(container.textContent).toBe("5 items");
|
||||
});
|
||||
|
||||
it("re-renders on languageChanged event", { timeout: 5000 }, async () => {
|
||||
const i18n = createI18n({
|
||||
defaultLocale: "en-GB",
|
||||
messages: {
|
||||
"en-GB": { hi: "Hi" },
|
||||
"fr-FR": { hi: "Salut" },
|
||||
},
|
||||
});
|
||||
const { container, rerender } = renderComponent(
|
||||
<TranslationProvider i18n={i18n}>
|
||||
<Trans i18nKey="hi" />
|
||||
</TranslationProvider>
|
||||
);
|
||||
expect(container.textContent).toBe("Hi");
|
||||
await i18n.changeLanguage("fr-FR");
|
||||
rerender(
|
||||
<TranslationProvider i18n={i18n}>
|
||||
<Trans i18nKey="hi" />
|
||||
</TranslationProvider>
|
||||
);
|
||||
expect(container.textContent).toBe("Salut");
|
||||
});
|
||||
|
||||
it("renders elements without displayName via function name", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const i18n = createI18n({
|
||||
defaultLocale: "en-GB",
|
||||
messages: {
|
||||
"en-GB": {
|
||||
body: "<UnnamedHelper>x</UnnamedHelper>",
|
||||
},
|
||||
},
|
||||
});
|
||||
function UnnamedHelper(props: { children?: React.ReactNode }) {
|
||||
return <span>{props.children}</span>;
|
||||
}
|
||||
const { container } = renderComponent(
|
||||
<TranslationProvider i18n={i18n}>
|
||||
<Trans
|
||||
i18nKey="body"
|
||||
components={{ UnnamedHelper: <UnnamedHelper /> }}
|
||||
/>
|
||||
</TranslationProvider>
|
||||
);
|
||||
expect(container.querySelector("span")?.textContent).toBe("x");
|
||||
});
|
||||
|
||||
it("missing key with count falls through to children", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const i18n = createI18n({
|
||||
defaultLocale: "en-GB",
|
||||
messages: { "en-GB": {} },
|
||||
});
|
||||
const { container } = renderComponent(
|
||||
<TranslationProvider i18n={i18n}>
|
||||
<Trans i18nKey="absent.with.count" count={3}>
|
||||
{"Default {count}"}
|
||||
</Trans>
|
||||
</TranslationProvider>
|
||||
);
|
||||
expect(container.textContent).toBe("Default 3");
|
||||
});
|
||||
|
||||
it("missing key with count and partial path falls back", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const i18n = createI18n({
|
||||
defaultLocale: "en-GB",
|
||||
messages: {
|
||||
"en-GB": {
|
||||
nested: { real: { one: "{count}", other: "{count}" } },
|
||||
},
|
||||
},
|
||||
});
|
||||
const { container } = renderComponent(
|
||||
<TranslationProvider i18n={i18n}>
|
||||
<Trans i18nKey="nested.absent" count={2}>
|
||||
{"Fallback {count}"}
|
||||
</Trans>
|
||||
</TranslationProvider>
|
||||
);
|
||||
expect(container.textContent).toBe("Fallback 2");
|
||||
});
|
||||
|
||||
it("applies a registered post-processor to the message body", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const i18n = createI18n({
|
||||
defaultLocale: "en-GB",
|
||||
messages: { "en-GB": { greeting: "Hello, {name}!" } },
|
||||
});
|
||||
const { container } = renderComponent(
|
||||
<RegistryProvider
|
||||
plugins={[
|
||||
{
|
||||
kind: "post-processor",
|
||||
plugin: {
|
||||
id: "pseudo",
|
||||
priority: 100,
|
||||
process: (msg) => `[${msg}]`,
|
||||
},
|
||||
},
|
||||
]}
|
||||
>
|
||||
<TranslationProvider i18n={i18n}>
|
||||
<Trans i18nKey="greeting" values={{ name: "Alice" }} />
|
||||
</TranslationProvider>
|
||||
</RegistryProvider>
|
||||
);
|
||||
expect(container.textContent).toBe("[Hello, Alice!]");
|
||||
});
|
||||
|
||||
it("no-registry path renders unchanged message", { timeout: 5000 }, () => {
|
||||
const i18n = createI18n({
|
||||
defaultLocale: "en-GB",
|
||||
messages: { "en-GB": { greeting: "Hello!" } },
|
||||
});
|
||||
const { container } = renderComponent(
|
||||
<TranslationProvider i18n={i18n}>
|
||||
<Trans i18nKey="greeting" />
|
||||
</TranslationProvider>
|
||||
);
|
||||
expect(container.textContent).toBe("Hello!");
|
||||
});
|
||||
|
||||
it("passes the explicit namespace prop into the post-processor context", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const i18n = createI18n({
|
||||
defaultLocale: "en-GB",
|
||||
messages: { "en-GB": { greeting: "Hi" } },
|
||||
});
|
||||
let receivedNamespace: string | undefined;
|
||||
const { container } = renderComponent(
|
||||
<RegistryProvider
|
||||
plugins={[
|
||||
{
|
||||
kind: "post-processor",
|
||||
plugin: {
|
||||
id: "ns-capture",
|
||||
priority: 100,
|
||||
process: (msg, ctx) => {
|
||||
receivedNamespace = ctx.namespace;
|
||||
return msg;
|
||||
},
|
||||
},
|
||||
},
|
||||
]}
|
||||
>
|
||||
<TranslationProvider i18n={i18n}>
|
||||
<Trans i18nKey="greeting" namespace="settings" />
|
||||
</TranslationProvider>
|
||||
</RegistryProvider>
|
||||
);
|
||||
expect(container.textContent).toBe("Hi");
|
||||
expect(receivedNamespace).toBe("settings");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,246 @@
|
||||
import { MessageParser } from "@suaveplan/i18n-icu-parser";
|
||||
import {
|
||||
cloneElement,
|
||||
Fragment,
|
||||
type ReactElement,
|
||||
type ReactNode,
|
||||
useContext,
|
||||
useSyncExternalStore,
|
||||
} from "react";
|
||||
import { REACT_I18N_CODES } from "../../errors/error-codes.js";
|
||||
import { ReactI18nError } from "../../errors/react-i18n-error.js";
|
||||
import {
|
||||
recordMissingKey,
|
||||
recordRenderDuration,
|
||||
} from "../../observability/react-i18n-metrics.js";
|
||||
import { TranslationContext } from "../../provider/provider-context.js";
|
||||
import { runPostProcessors } from "../../registry/post-processors/post-processors.js";
|
||||
import { useOptionalReactI18nRegistry } from "../../registry/use-registry.js";
|
||||
import { childrenToMessageString } from "./stringify-children.js";
|
||||
import type { TransProps } from "./trans.types.js";
|
||||
|
||||
const TRANS_PARSER = new MessageParser({ icuSyntax: true });
|
||||
|
||||
/**
|
||||
* Render a translated rich-text message with tag interpolation.
|
||||
*
|
||||
* The component subscribes to the active `I18nInstance` via context and
|
||||
* re-renders on `languageChanged` / `loaded` events through
|
||||
* `useSyncExternalStore`. The lookup key is resolved against the catalog,
|
||||
* parsed through `MessageParser`, and rendered via
|
||||
* `formatChunks<ReactNode>` so every `<tagName>` placeholder maps to a
|
||||
* JSX element supplied through the `components` prop.
|
||||
*
|
||||
* When the key is missing, the component falls back to its `children` (a
|
||||
* default ICU message) and increments the missing-key telemetry counter.
|
||||
*
|
||||
* Throws `ReactI18nError(REACT_I18N.TRANS_INVALID_CHILD)` when the
|
||||
* children-fallback path receives a structurally-invalid child.
|
||||
*/
|
||||
export function Trans(props: TransProps): ReactElement {
|
||||
const i18n = useContext(TranslationContext);
|
||||
if (i18n === null) {
|
||||
throw new ReactI18nError(
|
||||
REACT_I18N_CODES.NO_PROVIDER,
|
||||
"react-i18n.no_provider.trans"
|
||||
);
|
||||
}
|
||||
|
||||
// Subscribe to bus events so the component re-renders on locale or
|
||||
// namespace-load changes. The carrier counter on `i18n` is bumped by
|
||||
// the Provider AND by our own listener so the snapshot moves
|
||||
// forward regardless of listener order between the two subscribers.
|
||||
// The same reader covers both client and server snapshots — the
|
||||
// counter is initialised to zero and only bumps in response to bus
|
||||
// events, so the SSR read is stable (no client/server divergence).
|
||||
const snapshotReader = () => readSnapshot(i18n);
|
||||
useSyncExternalStore(
|
||||
(notify) => {
|
||||
const tickAndNotify = () => {
|
||||
bumpSnapshot(i18n);
|
||||
notify();
|
||||
};
|
||||
const offLanguage = i18n.events.on(
|
||||
"languageChanged",
|
||||
tickAndNotify
|
||||
);
|
||||
const offLoaded = i18n.events.on("loaded", tickAndNotify);
|
||||
return () => {
|
||||
offLanguage();
|
||||
offLoaded();
|
||||
};
|
||||
},
|
||||
snapshotReader,
|
||||
snapshotReader
|
||||
);
|
||||
|
||||
const namespace = props.namespace ?? "";
|
||||
const componentName = props.componentName;
|
||||
const count = props.count;
|
||||
// Recompute on every render so locale switches reflect immediately;
|
||||
// the catalog itself caches the lookup so this is cheap.
|
||||
const message = resolveMessage(i18n, props.i18nKey, count, props.children);
|
||||
|
||||
if (message.missing) {
|
||||
recordMissingKey({
|
||||
locale: i18n.locale,
|
||||
namespace,
|
||||
...(componentName !== undefined ? { componentName } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
// Apply registry post-processors to the message body BEFORE ICU
|
||||
// parsing so pseudo-localization / redaction / debug overlays
|
||||
// operate on the full template (including interpolation slots).
|
||||
// Plugins run only when a `<RegistryProvider>` ancestor is in
|
||||
// scope; the no-registry path is a single short-circuit check.
|
||||
const registry = useOptionalReactI18nRegistry();
|
||||
const processedBody =
|
||||
registry === null
|
||||
? message.body
|
||||
: runPostProcessors(
|
||||
message.body,
|
||||
{
|
||||
locale: i18n.locale,
|
||||
key: props.i18nKey,
|
||||
...(namespace !== "" ? { namespace } : {}),
|
||||
},
|
||||
registry.listPostProcessors()
|
||||
);
|
||||
|
||||
const values = buildValues(props.values, count);
|
||||
const componentMap = props.components ?? {};
|
||||
|
||||
const t0 = performance.now();
|
||||
const chunks = TRANS_PARSER.formatChunks<ReactNode>(
|
||||
processedBody,
|
||||
values,
|
||||
buildChunkValues(componentMap),
|
||||
{ locale: i18n.locale }
|
||||
);
|
||||
recordRenderDuration(performance.now() - t0, {
|
||||
locale: i18n.locale,
|
||||
namespace,
|
||||
...(componentName !== undefined ? { componentName } : {}),
|
||||
});
|
||||
|
||||
return <Fragment>{chunks}</Fragment>;
|
||||
}
|
||||
|
||||
Trans.displayName = "SuaveplanTrans";
|
||||
|
||||
interface ResolvedMessage {
|
||||
readonly body: string;
|
||||
readonly missing: boolean;
|
||||
}
|
||||
|
||||
interface CatalogLike {
|
||||
readonly catalog: {
|
||||
has(key: string): boolean;
|
||||
getMessages(): Record<string, unknown> | undefined;
|
||||
};
|
||||
t(key: string, options?: { readonly count?: number }): string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the raw ICU body string for `<Trans>`.
|
||||
*
|
||||
* The catalog supports both string-leaf keys (`hello: 'Hi {name}'`) and
|
||||
* plural-object leaves (`items: { one: '...', other: '...' }`). The
|
||||
* universal-core `t()` returns the raw key when no value resolves and a
|
||||
* branch-selected string when `count` is supplied. We probe both
|
||||
* lookups so the missing-key signal is accurate in both shapes.
|
||||
*/
|
||||
function resolveMessage(
|
||||
i18n: CatalogLike,
|
||||
key: string,
|
||||
count: number | undefined,
|
||||
children: ReactNode
|
||||
): ResolvedMessage {
|
||||
const stringHit = i18n.catalog.has(key);
|
||||
const pluralHit =
|
||||
!stringHit && count !== undefined && lookupPluralBranch(i18n, key);
|
||||
if (stringHit || pluralHit) {
|
||||
const body = count !== undefined ? i18n.t(key, { count }) : i18n.t(key);
|
||||
return { body, missing: false };
|
||||
}
|
||||
const body = childrenToMessageString(children, key);
|
||||
return { body, missing: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns `true` when `key` resolves to a plural-object branch. Walks the
|
||||
* dot path through the active locale's messages map so the function does
|
||||
* not invoke `t()` (which would emit a `missingKey` event for the
|
||||
* underlying string lookup).
|
||||
*/
|
||||
function lookupPluralBranch(i18n: CatalogLike, key: string): boolean {
|
||||
const messages = i18n.catalog.getMessages();
|
||||
if (!messages) return false;
|
||||
const parts = key.split(".");
|
||||
let cursor: unknown = messages;
|
||||
for (const part of parts) {
|
||||
if (
|
||||
typeof cursor !== "object" ||
|
||||
cursor === null ||
|
||||
!(part in (cursor as Record<string, unknown>))
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
cursor = (cursor as Record<string, unknown>)[part];
|
||||
}
|
||||
return (
|
||||
typeof cursor === "object" &&
|
||||
cursor !== null &&
|
||||
("other" in (cursor as Record<string, unknown>) ||
|
||||
"one" in (cursor as Record<string, unknown>))
|
||||
);
|
||||
}
|
||||
|
||||
function buildValues(
|
||||
values: Readonly<Record<string, unknown>> | undefined,
|
||||
count: number | undefined
|
||||
): Record<string, unknown> {
|
||||
const out: Record<string, unknown> = { ...(values ?? {}) };
|
||||
if (count !== undefined && out["count"] === undefined) {
|
||||
out["count"] = count;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function buildChunkValues(
|
||||
components: Readonly<Record<string, ReactElement>>
|
||||
): Record<string, (chunks: Array<string | ReactNode>) => ReactNode> {
|
||||
const out: Record<
|
||||
string,
|
||||
(chunks: Array<string | ReactNode>) => ReactNode
|
||||
> = {};
|
||||
for (const [name, element] of Object.entries(components)) {
|
||||
out[name] = (chunks) => cloneTagElement(name, element, chunks);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function cloneTagElement(
|
||||
tagName: string,
|
||||
element: ReactElement,
|
||||
chunks: Array<string | ReactNode>
|
||||
): ReactNode {
|
||||
// `cloneElement` accepts a variadic children list; the parser
|
||||
// produces a deterministic chunk order so React reconciles by
|
||||
// position without needing per-chunk keys.
|
||||
return cloneElement(element, { key: tagName }, ...chunks);
|
||||
}
|
||||
|
||||
interface SnapshotCarrier {
|
||||
__reactI18nSnapshot?: number;
|
||||
}
|
||||
|
||||
function readSnapshot(instance: unknown): number {
|
||||
return (instance as SnapshotCarrier).__reactI18nSnapshot ?? 0;
|
||||
}
|
||||
|
||||
function bumpSnapshot(instance: unknown): void {
|
||||
const carrier = instance as SnapshotCarrier;
|
||||
carrier.__reactI18nSnapshot = (carrier.__reactI18nSnapshot ?? 0) + 1;
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import type { ReactElement, ReactNode } from "react";
|
||||
|
||||
/**
|
||||
* Props for the `<Trans>` rich-text component.
|
||||
*
|
||||
* `i18nKey` is looked up in the active catalog. The resolved message is
|
||||
* parsed through `MessageParser` and rendered via `formatChunks<ReactNode>`
|
||||
* so each tag placeholder maps to a JSX element supplied via `components`.
|
||||
*
|
||||
* When the key is absent the component falls back to `children`, which the
|
||||
* parser treats as a literal default ICU message.
|
||||
*/
|
||||
export interface TransProps {
|
||||
/** Lookup key against the active catalog. */
|
||||
readonly i18nKey: string;
|
||||
/** Variable values for `{var}` and `{count, plural, ...}` interpolation. */
|
||||
readonly values?: Readonly<Record<string, unknown>>;
|
||||
/**
|
||||
* Tag-name → JSX element map. Tags in the message body are replaced by
|
||||
* cloning the supplied element with the formatted children appended.
|
||||
*/
|
||||
readonly components?: Readonly<Record<string, ReactElement>>;
|
||||
/** Plural-branch selector. Equivalent to `values.count`. */
|
||||
readonly count?: number;
|
||||
/** Optional namespace pinning for the lookup. */
|
||||
readonly namespace?: string;
|
||||
/**
|
||||
* Default ICU message used when `i18nKey` is missing from the catalog.
|
||||
* Children are stringified with React's children API; if `children` is
|
||||
* non-empty it parses identically to a catalog entry.
|
||||
*/
|
||||
readonly children?: ReactNode;
|
||||
/**
|
||||
* Optional component-name attribute attached to the missing-key /
|
||||
* render-duration telemetry samples. Useful when `<Trans>` is wrapped
|
||||
* inside a higher-order component whose `displayName` should propagate
|
||||
* to the metrics surface.
|
||||
*/
|
||||
readonly componentName?: string;
|
||||
}
|
||||
@@ -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,89 @@
|
||||
import { I18nError } from "@suaveplan/i18n-shared";
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { ReactI18nError } from "../errors/react-i18n-error.js";
|
||||
import { createI18nConfig } from "./create-i18n-config.js";
|
||||
|
||||
describe("createI18nConfig", () => {
|
||||
it("returns the parsed config on the happy path", { timeout: 5000 }, () => {
|
||||
const cfg = createI18nConfig({
|
||||
defaultLocale: "en-GB",
|
||||
fallbackLocale: "en-GB",
|
||||
supportedLocales: ["en-GB", "fr-FR"],
|
||||
initialMessages: { "en-GB": { hello: "Hi" } },
|
||||
});
|
||||
expect(cfg.defaultLocale).toBe("en-GB");
|
||||
expect(cfg.supportedLocales).toEqual(["en-GB", "fr-FR"]);
|
||||
});
|
||||
|
||||
it("throws ReactI18nError(HYDRATION_MISMATCH) on bad initialMessages", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
try {
|
||||
createI18nConfig({
|
||||
defaultLocale: "en-GB",
|
||||
initialMessages: {
|
||||
"en-GB": "not-an-object" as unknown as Record<
|
||||
string,
|
||||
string
|
||||
>,
|
||||
},
|
||||
});
|
||||
expect.unreachable("should throw");
|
||||
} catch (err) {
|
||||
expect(err).toBeInstanceOf(ReactI18nError);
|
||||
if (err instanceof ReactI18nError) {
|
||||
expect(err.code).toBe("REACT_I18N.HYDRATION_MISMATCH");
|
||||
expect(err.context).toBeDefined();
|
||||
const ctx = err.context as
|
||||
| undefined
|
||||
| { zodIssues?: ReadonlyArray<unknown> };
|
||||
expect(ctx?.zodIssues).toBeDefined();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("throws I18nError(INVALID_OPTIONS) on generic shape failures", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
try {
|
||||
createI18nConfig({ defaultLocale: "" });
|
||||
expect.unreachable("should throw");
|
||||
} catch (err) {
|
||||
expect(err).toBeInstanceOf(I18nError);
|
||||
if (err instanceof I18nError) {
|
||||
expect(err.code).toBe("I18N.INVALID_OPTIONS");
|
||||
const ctx = err.context as
|
||||
| undefined
|
||||
| {
|
||||
zodIssues?: ReadonlyArray<unknown>;
|
||||
source?: string;
|
||||
};
|
||||
expect(ctx?.zodIssues).toBeDefined();
|
||||
expect(ctx?.source).toBe("react-i18n.config");
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("throws I18nError(INVALID_OPTIONS) on unknown fields", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
try {
|
||||
createI18nConfig({
|
||||
defaultLocale: "en-GB",
|
||||
typo: true,
|
||||
});
|
||||
expect.unreachable("should throw");
|
||||
} catch (err) {
|
||||
expect(err).toBeInstanceOf(I18nError);
|
||||
}
|
||||
});
|
||||
|
||||
it("throws I18nError on non-object input", { timeout: 5000 }, () => {
|
||||
try {
|
||||
createI18nConfig("not-an-object");
|
||||
expect.unreachable("should throw");
|
||||
} catch (err) {
|
||||
expect(err).toBeInstanceOf(I18nError);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,48 @@
|
||||
import { I18N_CODES, I18nError } from "@suaveplan/i18n-shared";
|
||||
import { REACT_I18N_CODES } from "../errors/error-codes.js";
|
||||
import { ReactI18nError } from "../errors/react-i18n-error.js";
|
||||
import {
|
||||
type I18nConfig,
|
||||
I18nConfigSchema,
|
||||
} from "../schemas/i18n-config.schemas.js";
|
||||
|
||||
/**
|
||||
* Build a validated `I18nConfig` from a caller-supplied option bag.
|
||||
*
|
||||
* Runs the option bag through `I18nConfigSchema` (Zod). On success returns
|
||||
* the parsed config. On failure:
|
||||
*
|
||||
* - When ANY Zod issue is rooted in `initialMessages`, throws
|
||||
* `ReactI18nError(REACT_I18N.HYDRATION_MISMATCH)` so SSR-hydration
|
||||
* failures surface with the React-tier code observability dashboards
|
||||
* group on (this is the exact contract the spec scenario
|
||||
* "invalid initialMessages rejected" requires).
|
||||
* - Otherwise throws `I18nError(I18N.INVALID_OPTIONS)` so generic
|
||||
* shape failures land in the universal-core error namespace they
|
||||
* would have hit if they had escaped to `createI18n(...)` deeper in
|
||||
* the stack.
|
||||
*
|
||||
* Both errors carry `context.zodIssues` so structured logs, span events,
|
||||
* and the wire-format envelope all surface the precise field that broke.
|
||||
*/
|
||||
export function createI18nConfig(input: unknown): I18nConfig {
|
||||
const result = I18nConfigSchema.safeParse(input);
|
||||
if (result.success) return result.data;
|
||||
|
||||
const issues = result.error.issues;
|
||||
const isInitialMessagesFailure = issues.some(
|
||||
(issue) => issue.path[0] === "initialMessages"
|
||||
);
|
||||
|
||||
if (isInitialMessagesFailure) {
|
||||
throw new ReactI18nError(
|
||||
REACT_I18N_CODES.HYDRATION_MISMATCH,
|
||||
"react-i18n.hydration_mismatch.initial_messages",
|
||||
{ context: { zodIssues: issues } }
|
||||
);
|
||||
}
|
||||
|
||||
throw new I18nError(I18N_CODES.INVALID_OPTIONS, "i18n.invalid_options", {
|
||||
context: { zodIssues: issues, source: "react-i18n.config" },
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export { createI18nConfig } from "./create-i18n-config.js";
|
||||
export { toI18nInstanceOptions } from "./to-i18n-instance-options.js";
|
||||
@@ -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,66 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { createI18nConfig } from "./create-i18n-config.js";
|
||||
import { toI18nInstanceOptions } from "./to-i18n-instance-options.js";
|
||||
|
||||
describe("toI18nInstanceOptions", () => {
|
||||
it("maps the minimal config (defaultLocale only)", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const cfg = createI18nConfig({ defaultLocale: "en-GB" });
|
||||
const opts = toI18nInstanceOptions(cfg);
|
||||
expect(opts.defaultLocale).toBe("en-GB");
|
||||
expect(opts.fallbackLocale).toBeUndefined();
|
||||
expect(opts.messages).toBeUndefined();
|
||||
expect(opts.backend).toBeUndefined();
|
||||
expect(opts.backends).toBeUndefined();
|
||||
expect(opts.plugins).toBeUndefined();
|
||||
expect(opts.saveMissing).toBeUndefined();
|
||||
});
|
||||
|
||||
it("renames initialMessages → messages", { timeout: 5000 }, () => {
|
||||
const cfg = createI18nConfig({
|
||||
defaultLocale: "en-GB",
|
||||
initialMessages: { "en-GB": { hello: "Hi" } },
|
||||
});
|
||||
const opts = toI18nInstanceOptions(cfg);
|
||||
expect(opts.messages).toEqual({ "en-GB": { hello: "Hi" } });
|
||||
});
|
||||
|
||||
it("forwards every documented field", { timeout: 5000 }, () => {
|
||||
const cfg = createI18nConfig({
|
||||
defaultLocale: "en-GB",
|
||||
fallbackLocale: "en-US",
|
||||
initialMessages: { "en-GB": { hello: "Hi" } },
|
||||
backend: {
|
||||
name: "memory",
|
||||
capabilities: { canRead: true },
|
||||
},
|
||||
backends: [
|
||||
{ name: "memory", capabilities: { canRead: true } },
|
||||
{ name: "http", capabilities: { canRead: true } },
|
||||
],
|
||||
saveMissing: true,
|
||||
plugins: [{ name: "missing-key-reporter" }],
|
||||
});
|
||||
const opts = toI18nInstanceOptions(cfg);
|
||||
expect(opts.defaultLocale).toBe("en-GB");
|
||||
expect(opts.fallbackLocale).toBe("en-US");
|
||||
expect(opts.messages).toEqual({ "en-GB": { hello: "Hi" } });
|
||||
expect(opts.backend).toBeDefined();
|
||||
expect(opts.backends).toHaveLength(2);
|
||||
expect(opts.saveMissing).toBe(true);
|
||||
expect(opts.plugins).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("is pure — calling it twice yields equivalent options", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const cfg = createI18nConfig({
|
||||
defaultLocale: "en-GB",
|
||||
fallbackLocale: "en-US",
|
||||
});
|
||||
const a = toI18nInstanceOptions(cfg);
|
||||
const b = toI18nInstanceOptions(cfg);
|
||||
expect(a).toEqual(b);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,72 @@
|
||||
import type { I18nInstanceOptions } from "@suaveplan/i18n/plugin";
|
||||
import type { I18nConfig } from "../schemas/i18n-config.schemas.js";
|
||||
|
||||
type Mutable<T> = { -readonly [K in keyof T]: T[K] };
|
||||
type Required<T, K extends keyof T> = Exclude<T[K], undefined>;
|
||||
|
||||
/**
|
||||
* Translate a Zod-parsed `I18nConfig` (the React-tier shape) into the
|
||||
* universal-core `I18nInstanceOptions` accepted by `createI18n(...)`.
|
||||
*
|
||||
* Field mapping:
|
||||
*
|
||||
* - `defaultLocale` → `defaultLocale`
|
||||
* - `fallbackLocale` → `fallbackLocale` (when present)
|
||||
* - `initialMessages` → `messages` (the universal core uses the
|
||||
* shorter name; the React surface uses `initialMessages` so SSR
|
||||
* semantics are obvious to consumers)
|
||||
* - `backend` / `backends` / `saveMissing` → forwarded as-is
|
||||
* - `plugins` → forwarded after a structural cast since the React
|
||||
* schema only validates the `name` field of each plugin and the core
|
||||
* accepts the full `I18nPlugin` shape
|
||||
*
|
||||
* The function is pure and has no side effects so the Provider can call
|
||||
* it inside `useMemo` without destabilising the rendered tree.
|
||||
*
|
||||
* The `Backend` / `I18nPlugin` / `TranslationRecord` types from the
|
||||
* universal core are deliberately consumed via structural casts here
|
||||
* (`as unknown as Required<I18nInstanceOptions, "backend">` etc.) so
|
||||
* this module does not need a hard dependency on the universal core's
|
||||
* type-only subpaths beyond `./plugin`. This keeps the React boundary
|
||||
* loosely coupled: the universal core can re-shape its `Backend` /
|
||||
* `I18nPlugin` interfaces without invalidating the React surface as
|
||||
* long as `I18nInstanceOptions` stays compatible.
|
||||
*/
|
||||
export function toI18nInstanceOptions(config: I18nConfig): I18nInstanceOptions {
|
||||
const opts: Mutable<I18nInstanceOptions> = {
|
||||
defaultLocale: config.defaultLocale,
|
||||
};
|
||||
|
||||
if (config.fallbackLocale !== undefined) {
|
||||
opts.fallbackLocale = config.fallbackLocale;
|
||||
}
|
||||
if (config.initialMessages !== undefined) {
|
||||
opts.messages = config.initialMessages as unknown as Required<
|
||||
I18nInstanceOptions,
|
||||
"messages"
|
||||
>;
|
||||
}
|
||||
if (config.backend !== undefined) {
|
||||
opts.backend = config.backend as unknown as Required<
|
||||
I18nInstanceOptions,
|
||||
"backend"
|
||||
>;
|
||||
}
|
||||
if (config.backends !== undefined) {
|
||||
opts.backends = config.backends as unknown as Required<
|
||||
I18nInstanceOptions,
|
||||
"backends"
|
||||
>;
|
||||
}
|
||||
if (config.saveMissing !== undefined) {
|
||||
opts.saveMissing = config.saveMissing;
|
||||
}
|
||||
if (config.plugins !== undefined) {
|
||||
opts.plugins = config.plugins as unknown as Required<
|
||||
I18nInstanceOptions,
|
||||
"plugins"
|
||||
>;
|
||||
}
|
||||
|
||||
return opts;
|
||||
}
|
||||
@@ -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,156 @@
|
||||
import type { DetectorContext } from "@suaveplan/i18n-detector";
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import {
|
||||
acceptLanguageDetector,
|
||||
parseAcceptLanguage,
|
||||
} from "./accept-language.js";
|
||||
|
||||
const SUPPORTED = ["en-GB", "fr-FR", "de-DE", "ja-JP"] as const;
|
||||
|
||||
function serverContext(headers: HeadersInit = {}): DetectorContext {
|
||||
return {
|
||||
request: {
|
||||
headers: new Headers(headers),
|
||||
url: new URL("https://example.com/"),
|
||||
},
|
||||
responseHeaders: new Headers(),
|
||||
supportedLocales: SUPPORTED,
|
||||
fallbackLocale: "en-GB",
|
||||
};
|
||||
}
|
||||
|
||||
describe("acceptLanguageDetector", () => {
|
||||
it("exposes name and environment per spec", { timeout: 5000 }, () => {
|
||||
const detector = acceptLanguageDetector();
|
||||
expect(detector.name).toBe("acceptLanguage");
|
||||
expect(detector.environment).toBe("server");
|
||||
});
|
||||
|
||||
it("returns highest-q tag from Accept-Language", { timeout: 5000 }, () => {
|
||||
const detector = acceptLanguageDetector();
|
||||
const result = detector.detect?.(
|
||||
serverContext({
|
||||
"accept-language": "fr-FR;q=0.5, de-DE;q=0.9, en-GB;q=0.8",
|
||||
})
|
||||
);
|
||||
expect(result).toBe("de-DE");
|
||||
});
|
||||
|
||||
it("treats missing q as 1.0", { timeout: 5000 }, () => {
|
||||
const detector = acceptLanguageDetector();
|
||||
const result = detector.detect?.(
|
||||
serverContext({
|
||||
"accept-language": "fr-FR;q=0.9, de-DE",
|
||||
})
|
||||
);
|
||||
expect(result).toBe("de-DE");
|
||||
});
|
||||
|
||||
it("preserves insertion order on ties", { timeout: 5000 }, () => {
|
||||
const detector = acceptLanguageDetector();
|
||||
const result = detector.detect?.(
|
||||
serverContext({ "accept-language": "fr-FR, de-DE, ja-JP" })
|
||||
);
|
||||
expect(result).toBe("fr-FR");
|
||||
});
|
||||
|
||||
it("skips wildcard token", { timeout: 5000 }, () => {
|
||||
const detector = acceptLanguageDetector();
|
||||
const result = detector.detect?.(
|
||||
serverContext({ "accept-language": "*;q=1.0, fr-FR;q=0.5" })
|
||||
);
|
||||
expect(result).toBe("fr-FR");
|
||||
});
|
||||
|
||||
it("returns null for empty header", { timeout: 5000 }, () => {
|
||||
const detector = acceptLanguageDetector();
|
||||
const result = detector.detect?.(
|
||||
serverContext({ "accept-language": "" })
|
||||
);
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null when header missing entirely", { timeout: 5000 }, () => {
|
||||
const detector = acceptLanguageDetector();
|
||||
const result = detector.detect?.(serverContext({}));
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null when no request context", { timeout: 5000 }, () => {
|
||||
const detector = acceptLanguageDetector();
|
||||
const result = detector.detect?.({
|
||||
supportedLocales: SUPPORTED,
|
||||
fallbackLocale: "en-GB",
|
||||
});
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it("skips malformed entries with negative q", { timeout: 5000 }, () => {
|
||||
const detector = acceptLanguageDetector();
|
||||
const result = detector.detect?.(
|
||||
serverContext({
|
||||
"accept-language": "fr-FR;q=-1, de-DE;q=0.5",
|
||||
})
|
||||
);
|
||||
expect(result).toBe("de-DE");
|
||||
});
|
||||
|
||||
it("skips malformed entries with NaN q", { timeout: 5000 }, () => {
|
||||
const detector = acceptLanguageDetector();
|
||||
const result = detector.detect?.(
|
||||
serverContext({
|
||||
"accept-language": "fr-FR;q=abc, de-DE;q=0.5",
|
||||
})
|
||||
);
|
||||
expect(result).toBe("de-DE");
|
||||
});
|
||||
|
||||
it("skips malformed entries with q > 1", { timeout: 5000 }, () => {
|
||||
const detector = acceptLanguageDetector();
|
||||
const result = detector.detect?.(
|
||||
serverContext({
|
||||
"accept-language": "fr-FR;q=2.5, de-DE;q=0.4",
|
||||
})
|
||||
);
|
||||
expect(result).toBe("de-DE");
|
||||
});
|
||||
|
||||
it("ignores unrelated parameters", { timeout: 5000 }, () => {
|
||||
const detector = acceptLanguageDetector();
|
||||
const result = detector.detect?.(
|
||||
serverContext({
|
||||
"accept-language": "fr-FR;foo=bar;q=0.9, de-DE;q=0.5",
|
||||
})
|
||||
);
|
||||
expect(result).toBe("fr-FR");
|
||||
});
|
||||
|
||||
it("returns null for header consisting of only wildcards and empty", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const detector = acceptLanguageDetector();
|
||||
const result = detector.detect?.(
|
||||
serverContext({ "accept-language": "*, , *;q=0.5" })
|
||||
);
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it("parser handles parameter without '='", { timeout: 5000 }, () => {
|
||||
const result = parseAcceptLanguage("fr-FR;noequals;q=0.6");
|
||||
expect(result).toEqual([{ locale: "fr-FR", q: 0.6 }]);
|
||||
});
|
||||
|
||||
it("parser handles trailing whitespace and empty params", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const result = parseAcceptLanguage(" fr-FR ; ; q = 0.7 ");
|
||||
expect(result).toEqual([{ locale: "fr-FR", q: 0.7 }]);
|
||||
});
|
||||
|
||||
it("parser yields empty when header only has commas", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const result = parseAcceptLanguage(",, ,");
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,82 @@
|
||||
import type { LocaleDetector } from "@suaveplan/i18n-detector";
|
||||
import type { AcceptLanguageEntry } from "./accept-language.types.js";
|
||||
|
||||
/**
|
||||
* Build a `LocaleDetector` that parses the inbound `Accept-Language`
|
||||
* header per RFC 9110 §5.3.5 and returns the highest-priority entry.
|
||||
*
|
||||
* The detector returns the *raw* highest-q entry; supported-locale
|
||||
* negotiation is the chain runner's responsibility (it calls
|
||||
* `LocaleResolver.negotiate(raw, supportedLocales)` to perform BCP-47
|
||||
* fallback). The wildcard token (`*`) is intentionally skipped because
|
||||
* it carries no locale information that can be negotiated meaningfully.
|
||||
*
|
||||
* Malformed entries (missing tag, unparseable q value, q out of range)
|
||||
* are silently skipped per RFC 9110: the parser MUST be liberal in
|
||||
* what it accepts.
|
||||
*/
|
||||
export function acceptLanguageDetector(): LocaleDetector {
|
||||
return {
|
||||
name: "acceptLanguage",
|
||||
environment: "server",
|
||||
detect(ctx) {
|
||||
const header = ctx.request?.headers.get("accept-language");
|
||||
if (typeof header !== "string" || header.trim() === "") {
|
||||
return null;
|
||||
}
|
||||
const entries = parseAcceptLanguage(header);
|
||||
if (entries.length === 0) return null;
|
||||
const first = entries[0] as AcceptLanguageEntry;
|
||||
return first.locale;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse an `Accept-Language` header into a sorted list of entries.
|
||||
* Sorted descending by q-value; ties keep input order (stable sort).
|
||||
*
|
||||
* Exported for tests and as a utility for advanced consumers building
|
||||
* custom detectors layered on top of this one.
|
||||
*/
|
||||
export function parseAcceptLanguage(header: string): AcceptLanguageEntry[] {
|
||||
const result: AcceptLanguageEntry[] = [];
|
||||
const segments = header.split(",");
|
||||
for (let i = 0; i < segments.length; i++) {
|
||||
const segment = segments[i] as string;
|
||||
const parsed = parseSegment(segment);
|
||||
if (parsed !== null) result.push(parsed);
|
||||
}
|
||||
// Stable sort by q desc, falling back to insertion order via index.
|
||||
return result
|
||||
.map((entry, index) => ({ entry, index }))
|
||||
.sort((a, b) => {
|
||||
if (a.entry.q !== b.entry.q) return b.entry.q - a.entry.q;
|
||||
return a.index - b.index;
|
||||
})
|
||||
.map(({ entry }) => entry);
|
||||
}
|
||||
|
||||
function parseSegment(segment: string): AcceptLanguageEntry | null {
|
||||
const trimmed = segment.trim();
|
||||
if (trimmed === "") return null;
|
||||
const parts = trimmed.split(";");
|
||||
const tagPart = (parts[0] as string).trim();
|
||||
if (tagPart === "" || tagPart === "*") return null;
|
||||
let q = 1;
|
||||
for (let i = 1; i < parts.length; i++) {
|
||||
const param = (parts[i] as string).trim();
|
||||
if (param === "") continue;
|
||||
const eqIndex = param.indexOf("=");
|
||||
if (eqIndex === -1) continue;
|
||||
const key = param.slice(0, eqIndex).trim().toLowerCase();
|
||||
if (key !== "q") continue;
|
||||
const raw = param.slice(eqIndex + 1).trim();
|
||||
const parsed = Number.parseFloat(raw);
|
||||
if (Number.isNaN(parsed) || parsed < 0 || parsed > 1) {
|
||||
return null;
|
||||
}
|
||||
q = parsed;
|
||||
}
|
||||
return { locale: tagPart, q };
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
/**
|
||||
* Single parsed `Accept-Language` entry: the locale tag and its
|
||||
* computed quality-value (defaulting to 1.0 when omitted).
|
||||
*/
|
||||
export interface AcceptLanguageEntry {
|
||||
readonly locale: string;
|
||||
readonly q: number;
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
export {
|
||||
acceptLanguageDetector,
|
||||
parseAcceptLanguage,
|
||||
} from "./accept-language.js";
|
||||
export type { AcceptLanguageEntry } from "./accept-language.types.js";
|
||||
@@ -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,116 @@
|
||||
import type { DetectorContext } from "@suaveplan/i18n-detector";
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { cloudflareIpGeoDetector } from "./cloudflare-ip-geo.js";
|
||||
|
||||
const SUPPORTED = ["en-GB", "ar-AE", "ja-JP", "fr-FR"] as const;
|
||||
|
||||
function serverContext(headers: HeadersInit = {}): DetectorContext {
|
||||
return {
|
||||
request: {
|
||||
headers: new Headers(headers),
|
||||
url: new URL("https://example.com/"),
|
||||
},
|
||||
responseHeaders: new Headers(),
|
||||
supportedLocales: SUPPORTED,
|
||||
fallbackLocale: "en-GB",
|
||||
};
|
||||
}
|
||||
|
||||
describe("cloudflareIpGeoDetector", () => {
|
||||
it("exposes name and environment per spec", { timeout: 5000 }, () => {
|
||||
const detector = cloudflareIpGeoDetector();
|
||||
expect(detector.name).toBe("cloudflareIpGeo");
|
||||
expect(detector.environment).toBe("server");
|
||||
});
|
||||
|
||||
it("maps cf-ipcountry: AE → ar-AE per spec", { timeout: 5000 }, () => {
|
||||
const detector = cloudflareIpGeoDetector();
|
||||
const result = detector.detect?.(
|
||||
serverContext({ "cf-ipcountry": "AE" })
|
||||
);
|
||||
expect(result).toBe("ar-AE");
|
||||
});
|
||||
|
||||
it("maps JP → ja-JP via locale-data languages array", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const detector = cloudflareIpGeoDetector();
|
||||
const result = detector.detect?.(
|
||||
serverContext({ "cf-ipcountry": "JP" })
|
||||
);
|
||||
expect(result).toBe("ja-JP");
|
||||
});
|
||||
|
||||
it("honours override map", { timeout: 5000 }, () => {
|
||||
const detector = cloudflareIpGeoDetector({
|
||||
countryToLocale: { AE: "en-AE" },
|
||||
});
|
||||
const result = detector.detect?.(
|
||||
serverContext({ "cf-ipcountry": "AE" })
|
||||
);
|
||||
expect(result).toBe("en-AE");
|
||||
});
|
||||
|
||||
it("ignores empty override entry and uses derived value", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const detector = cloudflareIpGeoDetector({
|
||||
countryToLocale: { AE: "" },
|
||||
});
|
||||
const result = detector.detect?.(
|
||||
serverContext({ "cf-ipcountry": "AE" })
|
||||
);
|
||||
expect(result).toBe("ar-AE");
|
||||
});
|
||||
|
||||
it("honours custom headerName", { timeout: 5000 }, () => {
|
||||
const detector = cloudflareIpGeoDetector({
|
||||
headerName: "x-edge-country",
|
||||
});
|
||||
const result = detector.detect?.(
|
||||
serverContext({ "x-edge-country": "FR" })
|
||||
);
|
||||
expect(result).toBe("fr-FR");
|
||||
});
|
||||
|
||||
it("uppercases the inbound code before lookup", { timeout: 5000 }, () => {
|
||||
const detector = cloudflareIpGeoDetector();
|
||||
const result = detector.detect?.(
|
||||
serverContext({ "cf-ipcountry": "ae" })
|
||||
);
|
||||
expect(result).toBe("ar-AE");
|
||||
});
|
||||
|
||||
it("returns null for unknown country code", { timeout: 5000 }, () => {
|
||||
const detector = cloudflareIpGeoDetector();
|
||||
const result = detector.detect?.(
|
||||
serverContext({ "cf-ipcountry": "ZZ" })
|
||||
);
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null when header missing", { timeout: 5000 }, () => {
|
||||
const detector = cloudflareIpGeoDetector();
|
||||
const result = detector.detect?.(serverContext({}));
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null when header is empty", { timeout: 5000 }, () => {
|
||||
const detector = cloudflareIpGeoDetector();
|
||||
const result = detector.detect?.(
|
||||
serverContext({ "cf-ipcountry": " " })
|
||||
);
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null when no request context is supplied", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const detector = cloudflareIpGeoDetector();
|
||||
const result = detector.detect?.({
|
||||
supportedLocales: SUPPORTED,
|
||||
fallbackLocale: "en-GB",
|
||||
});
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,50 @@
|
||||
import type { LocaleDetector } from "@suaveplan/i18n-detector";
|
||||
import { getAllCountries } from "@suaveplan/locale-data";
|
||||
import type { CloudflareIpGeoDetectorOptions } from "./cloudflare-ip-geo.types.js";
|
||||
|
||||
const DEFAULT_HEADER_NAME = "cf-ipcountry";
|
||||
|
||||
/**
|
||||
* Build a `LocaleDetector` that maps the Cloudflare `cf-ipcountry`
|
||||
* header (ISO 3166-1 alpha-2) to a BCP-47 locale.
|
||||
*
|
||||
* Resolution order, per ISO code:
|
||||
*
|
||||
* 1. `opts.countryToLocale[code]` if provided.
|
||||
* 2. `${language}-${code}` where `language` is the first entry from the
|
||||
* country record's `languages` array in `@suaveplan/locale-data`.
|
||||
* 3. `null` when neither override nor language data exists.
|
||||
*
|
||||
* Example: `cf-ipcountry: AE` → `"ar-AE"` (UAE country record lists
|
||||
* `["ar"]` as its languages).
|
||||
*/
|
||||
export function cloudflareIpGeoDetector(
|
||||
options: CloudflareIpGeoDetectorOptions = {}
|
||||
): LocaleDetector {
|
||||
const headerName = options.headerName ?? DEFAULT_HEADER_NAME;
|
||||
const overrides = options.countryToLocale ?? {};
|
||||
const countries = getAllCountries();
|
||||
|
||||
return {
|
||||
name: "cloudflareIpGeo",
|
||||
environment: "server",
|
||||
detect(ctx) {
|
||||
const raw = ctx.request?.headers.get(headerName);
|
||||
if (typeof raw !== "string" || raw.trim() === "") return null;
|
||||
const code = raw.trim().toUpperCase();
|
||||
|
||||
const override = overrides[code];
|
||||
if (typeof override === "string" && override !== "") {
|
||||
return override;
|
||||
}
|
||||
|
||||
const country = countries[code];
|
||||
if (!country) return null;
|
||||
// The locale-data `countries.data.ts` invariant guarantees
|
||||
// every entry has at least one language; the test harness
|
||||
// exercises every reachable branch via known ISO codes.
|
||||
const primaryLanguage = country.languages[0] as string;
|
||||
return `${primaryLanguage}-${code}`;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
/**
|
||||
* Configuration accepted by `cloudflareIpGeoDetector`. Both fields are
|
||||
* optional; defaults pull the standard Cloudflare `cf-ipcountry`
|
||||
* header and resolve a locale via `@suaveplan/locale-data`.
|
||||
*/
|
||||
export interface CloudflareIpGeoDetectorOptions {
|
||||
/**
|
||||
* Header name to read. Default `"cf-ipcountry"`. Cloudflare always
|
||||
* emits a lower-case header name on the wire; `Headers.get(...)` is
|
||||
* case-insensitive so the default matches any casing the upstream
|
||||
* runtime may surface.
|
||||
*/
|
||||
readonly headerName?: string;
|
||||
/**
|
||||
* Per-country override map. Keys are ISO 3166-1 alpha-2 country
|
||||
* codes; values are full BCP-47 locale tags. Overrides win over
|
||||
* the data-derived default mapping for the matching country.
|
||||
*/
|
||||
readonly countryToLocale?: Readonly<Record<string, string>>;
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export { cloudflareIpGeoDetector } from "./cloudflare-ip-geo.js";
|
||||
export type { CloudflareIpGeoDetectorOptions } from "./cloudflare-ip-geo.types.js";
|
||||
@@ -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,275 @@
|
||||
import type { DetectorContext } from "@suaveplan/i18n-detector";
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { cookieDetector } from "./cookie.js";
|
||||
|
||||
const SUPPORTED = ["en-GB", "fr-FR", "de-DE", "ru-RU"] as const;
|
||||
|
||||
function serverContext(headers: HeadersInit = {}): DetectorContext {
|
||||
return {
|
||||
request: {
|
||||
headers: new Headers(headers),
|
||||
url: new URL("https://example.com/"),
|
||||
},
|
||||
responseHeaders: new Headers(),
|
||||
supportedLocales: SUPPORTED,
|
||||
fallbackLocale: "en-GB",
|
||||
};
|
||||
}
|
||||
|
||||
function browserContext(): DetectorContext {
|
||||
return {
|
||||
supportedLocales: SUPPORTED,
|
||||
fallbackLocale: "en-GB",
|
||||
};
|
||||
}
|
||||
|
||||
function withDocumentCookie<T>(initial: string, fn: () => T): T {
|
||||
const docOriginal = (globalThis as { document?: unknown }).document;
|
||||
let store = initial;
|
||||
const fakeDoc = {
|
||||
get cookie() {
|
||||
return store;
|
||||
},
|
||||
set cookie(value: string) {
|
||||
store = value;
|
||||
},
|
||||
};
|
||||
Object.defineProperty(globalThis, "document", {
|
||||
configurable: true,
|
||||
value: fakeDoc,
|
||||
});
|
||||
try {
|
||||
return fn();
|
||||
} finally {
|
||||
Object.defineProperty(globalThis, "document", {
|
||||
configurable: true,
|
||||
value: docOriginal,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
describe("cookieDetector", () => {
|
||||
it("reads named cookie from server-side cookie header", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const detector = cookieDetector();
|
||||
const result = detector.detect?.(
|
||||
serverContext({ cookie: "suaveplan_locale=fr-FR; other=1" })
|
||||
);
|
||||
expect(result).toBe("fr-FR");
|
||||
});
|
||||
|
||||
it("honours custom name option", { timeout: 5000 }, () => {
|
||||
const detector = cookieDetector({ name: "l" });
|
||||
const result = detector.detect?.(serverContext({ cookie: "l=ru-RU" }));
|
||||
expect(result).toBe("ru-RU");
|
||||
});
|
||||
|
||||
it("decodes URL-encoded cookie values", { timeout: 5000 }, () => {
|
||||
const detector = cookieDetector();
|
||||
const result = detector.detect?.(
|
||||
serverContext({ cookie: "suaveplan_locale=fr%2DFR" })
|
||||
);
|
||||
expect(result).toBe("fr-FR");
|
||||
});
|
||||
|
||||
it("returns the raw value when decode throws", { timeout: 5000 }, () => {
|
||||
const detector = cookieDetector();
|
||||
// %ZZ is invalid percent-encoding -> decodeURIComponent throws
|
||||
const result = detector.detect?.(
|
||||
serverContext({ cookie: "suaveplan_locale=%ZZ" })
|
||||
);
|
||||
expect(result).toBe("%ZZ");
|
||||
});
|
||||
|
||||
it("returns null when cookie missing", { timeout: 5000 }, () => {
|
||||
const detector = cookieDetector();
|
||||
const result = detector.detect?.(
|
||||
serverContext({ cookie: "session=abc" })
|
||||
);
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null when matched cookie value is empty", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const detector = cookieDetector();
|
||||
const result = detector.detect?.(
|
||||
serverContext({ cookie: "suaveplan_locale=" })
|
||||
);
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it("ignores malformed segments without '='", { timeout: 5000 }, () => {
|
||||
const detector = cookieDetector();
|
||||
const result = detector.detect?.(
|
||||
serverContext({
|
||||
cookie: "noequalsign; suaveplan_locale=de-DE; ",
|
||||
})
|
||||
);
|
||||
expect(result).toBe("de-DE");
|
||||
});
|
||||
|
||||
it("skips empty cookie segments", { timeout: 5000 }, () => {
|
||||
const detector = cookieDetector();
|
||||
const result = detector.detect?.(
|
||||
serverContext({
|
||||
cookie: ";; suaveplan_locale=fr-FR;;",
|
||||
})
|
||||
);
|
||||
expect(result).toBe("fr-FR");
|
||||
});
|
||||
|
||||
it("persists choice via Set-Cookie header on server", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const detector = cookieDetector({ name: "l", maxAgeSeconds: 100 });
|
||||
const ctx = serverContext({});
|
||||
detector.cache?.("fr-FR", ctx);
|
||||
const set = ctx.responseHeaders?.get("set-cookie");
|
||||
expect(set).toBe("l=fr-FR; Max-Age=100; Path=/; SameSite=Lax");
|
||||
});
|
||||
|
||||
it("Strict and None sameSite serialise correctly", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const ctxStrict = serverContext({});
|
||||
cookieDetector({ sameSite: "strict" }).cache?.("en-GB", ctxStrict);
|
||||
expect(ctxStrict.responseHeaders?.get("set-cookie")).toContain(
|
||||
"SameSite=Strict"
|
||||
);
|
||||
|
||||
const ctxNone = serverContext({});
|
||||
cookieDetector({ sameSite: "none" }).cache?.("en-GB", ctxNone);
|
||||
expect(ctxNone.responseHeaders?.get("set-cookie")).toContain(
|
||||
"SameSite=None"
|
||||
);
|
||||
});
|
||||
|
||||
it("URL-encodes the value on persist", { timeout: 5000 }, () => {
|
||||
const ctx = serverContext({});
|
||||
cookieDetector().cache?.("zh CN/with;semi", ctx);
|
||||
const set = ctx.responseHeaders?.get("set-cookie");
|
||||
expect(set).toContain(
|
||||
`suaveplan_locale=${encodeURIComponent("zh CN/with;semi")}`
|
||||
);
|
||||
});
|
||||
|
||||
it("honours custom path option", { timeout: 5000 }, () => {
|
||||
const ctx = serverContext({});
|
||||
cookieDetector({ path: "/app" }).cache?.("en-GB", ctx);
|
||||
expect(ctx.responseHeaders?.get("set-cookie")).toContain("Path=/app");
|
||||
});
|
||||
|
||||
it("reads document.cookie on browser when no request header", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const detector = cookieDetector();
|
||||
withDocumentCookie("suaveplan_locale=de-DE", () => {
|
||||
const result = detector.detect?.(browserContext());
|
||||
expect(result).toBe("de-DE");
|
||||
});
|
||||
});
|
||||
|
||||
it("writes document.cookie on browser when no responseHeaders", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const detector = cookieDetector();
|
||||
withDocumentCookie("", () => {
|
||||
detector.cache?.("ru-RU", browserContext());
|
||||
const written = (globalThis as { document: { cookie: string } })
|
||||
.document.cookie;
|
||||
expect(written).toContain("suaveplan_locale=ru-RU");
|
||||
expect(written).toContain("Max-Age=31536000");
|
||||
expect(written).toContain("SameSite=Lax");
|
||||
});
|
||||
});
|
||||
|
||||
it("returns null on browser when document is unavailable", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const detector = cookieDetector();
|
||||
const docOriginal = (globalThis as { document?: unknown }).document;
|
||||
Object.defineProperty(globalThis, "document", {
|
||||
configurable: true,
|
||||
value: undefined,
|
||||
});
|
||||
try {
|
||||
const result = detector.detect?.(browserContext());
|
||||
expect(result).toBeNull();
|
||||
} finally {
|
||||
Object.defineProperty(globalThis, "document", {
|
||||
configurable: true,
|
||||
value: docOriginal,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it("cache is a no-op when neither responseHeaders nor document available", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const detector = cookieDetector();
|
||||
const docOriginal = (globalThis as { document?: unknown }).document;
|
||||
Object.defineProperty(globalThis, "document", {
|
||||
configurable: true,
|
||||
value: undefined,
|
||||
});
|
||||
try {
|
||||
expect(() =>
|
||||
detector.cache?.("en-GB", browserContext())
|
||||
).not.toThrow();
|
||||
} finally {
|
||||
Object.defineProperty(globalThis, "document", {
|
||||
configurable: true,
|
||||
value: docOriginal,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it("ignores empty server cookie header and falls through to document", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const detector = cookieDetector();
|
||||
withDocumentCookie("suaveplan_locale=fr-FR", () => {
|
||||
const ctx = serverContext({});
|
||||
const result = detector.detect?.(ctx);
|
||||
expect(result).toBe("fr-FR");
|
||||
});
|
||||
});
|
||||
|
||||
it("returns null on browser when document.cookie is not a string", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const detector = cookieDetector();
|
||||
const docOriginal = (globalThis as { document?: unknown }).document;
|
||||
Object.defineProperty(globalThis, "document", {
|
||||
configurable: true,
|
||||
value: { cookie: 1234 as unknown as string },
|
||||
});
|
||||
try {
|
||||
const result = detector.detect?.(browserContext());
|
||||
expect(result).toBeNull();
|
||||
} finally {
|
||||
Object.defineProperty(globalThis, "document", {
|
||||
configurable: true,
|
||||
value: docOriginal,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it("uses ctx.document over globalThis.document when supplied", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const detector = cookieDetector();
|
||||
withDocumentCookie("suaveplan_locale=en-GB", () => {
|
||||
const injected = {
|
||||
cookie: "suaveplan_locale=fr-FR",
|
||||
} as unknown as Document;
|
||||
const result = detector.detect?.({
|
||||
...browserContext(),
|
||||
document: injected,
|
||||
});
|
||||
expect(result).toBe("fr-FR");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,110 @@
|
||||
import type { DetectorContext, LocaleDetector } from "@suaveplan/i18n-detector";
|
||||
import type { CookieDetectorOptions, CookieSameSite } from "./cookie.types.js";
|
||||
|
||||
const DEFAULT_NAME = "suaveplan_locale";
|
||||
const DEFAULT_MAX_AGE_SECONDS = 31536000;
|
||||
const DEFAULT_SAME_SITE: CookieSameSite = "lax";
|
||||
const DEFAULT_PATH = "/";
|
||||
|
||||
const SAME_SITE_LABEL: Record<CookieSameSite, string> = {
|
||||
lax: "Lax",
|
||||
strict: "Strict",
|
||||
none: "None",
|
||||
};
|
||||
|
||||
/**
|
||||
* Build a `LocaleDetector` that reads/writes the active locale via a
|
||||
* single cookie. On the server the detector reads the inbound `Cookie`
|
||||
* header and writes a `Set-Cookie` header onto `ctx.responseHeaders`.
|
||||
* On the browser it reads/writes `document.cookie` directly.
|
||||
*
|
||||
* Cookie values are URL-encoded so locales containing non-token
|
||||
* characters (none of the BCP-47 subset, but a defensive guard) cannot
|
||||
* break the cookie wire format.
|
||||
*/
|
||||
export function cookieDetector(
|
||||
options: CookieDetectorOptions = {}
|
||||
): LocaleDetector {
|
||||
const name = options.name ?? DEFAULT_NAME;
|
||||
const maxAgeSeconds = options.maxAgeSeconds ?? DEFAULT_MAX_AGE_SECONDS;
|
||||
const sameSite = options.sameSite ?? DEFAULT_SAME_SITE;
|
||||
const path = options.path ?? DEFAULT_PATH;
|
||||
|
||||
return {
|
||||
name: "cookie",
|
||||
environment: "both",
|
||||
detect(ctx) {
|
||||
const cookieHeader = readCookieHeader(ctx);
|
||||
if (cookieHeader === null) return null;
|
||||
return parseCookieValue(cookieHeader, name);
|
||||
},
|
||||
cache(locale, ctx) {
|
||||
const setCookieValue = formatSetCookie({
|
||||
name,
|
||||
value: locale,
|
||||
maxAgeSeconds,
|
||||
sameSite,
|
||||
path,
|
||||
});
|
||||
if (ctx.responseHeaders) {
|
||||
ctx.responseHeaders.append("set-cookie", setCookieValue);
|
||||
return;
|
||||
}
|
||||
const doc = readDocument(ctx);
|
||||
if (doc === null) return;
|
||||
doc.cookie = setCookieValue;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function readCookieHeader(ctx: DetectorContext): string | null {
|
||||
const fromRequest = ctx.request?.headers.get("cookie");
|
||||
if (typeof fromRequest === "string" && fromRequest !== "") {
|
||||
return fromRequest;
|
||||
}
|
||||
const doc = readDocument(ctx);
|
||||
if (doc === null) return null;
|
||||
return typeof doc.cookie === "string" ? doc.cookie : null;
|
||||
}
|
||||
|
||||
function readDocument(ctx: DetectorContext): Document | null {
|
||||
if (ctx.document) return ctx.document;
|
||||
const candidate = (globalThis as { document?: unknown }).document;
|
||||
if (candidate && typeof candidate === "object") {
|
||||
return candidate as Document;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function parseCookieValue(header: string, name: string): string | null {
|
||||
const segments = header.split(";");
|
||||
for (const segment of segments) {
|
||||
const trimmed = segment.trim();
|
||||
if (trimmed === "") continue;
|
||||
const eqIndex = trimmed.indexOf("=");
|
||||
if (eqIndex === -1) continue;
|
||||
const key = trimmed.slice(0, eqIndex).trim();
|
||||
if (key !== name) continue;
|
||||
const raw = trimmed.slice(eqIndex + 1).trim();
|
||||
if (raw === "") return null;
|
||||
try {
|
||||
return decodeURIComponent(raw);
|
||||
} catch {
|
||||
return raw;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
interface SetCookieParts {
|
||||
readonly name: string;
|
||||
readonly value: string;
|
||||
readonly maxAgeSeconds: number;
|
||||
readonly sameSite: CookieSameSite;
|
||||
readonly path: string;
|
||||
}
|
||||
|
||||
function formatSetCookie(parts: SetCookieParts): string {
|
||||
const encoded = encodeURIComponent(parts.value);
|
||||
return `${parts.name}=${encoded}; Max-Age=${parts.maxAgeSeconds}; Path=${parts.path}; SameSite=${SAME_SITE_LABEL[parts.sameSite]}`;
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
/**
|
||||
* SameSite values accepted by `cookieDetector`. Mirrors the standard
|
||||
* cookie attribute semantics; values are lower-cased on emit.
|
||||
*/
|
||||
export type CookieSameSite = "lax" | "strict" | "none";
|
||||
|
||||
/**
|
||||
* Configuration accepted by `cookieDetector`. Every field is optional
|
||||
* with sensible defaults (cookie name `suaveplan_locale`, one-year
|
||||
* lifetime, `Lax` same-site, root path).
|
||||
*/
|
||||
export interface CookieDetectorOptions {
|
||||
/** Cookie name. Default `"suaveplan_locale"`. */
|
||||
readonly name?: string;
|
||||
/** Cookie max age in seconds. Default `31536000` (1 year). */
|
||||
readonly maxAgeSeconds?: number;
|
||||
/** Cookie SameSite attribute. Default `"lax"`. */
|
||||
readonly sameSite?: CookieSameSite;
|
||||
/** Cookie path attribute. Default `"/"`. */
|
||||
readonly path?: string;
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
export { cookieDetector } from "./cookie.js";
|
||||
export type {
|
||||
CookieDetectorOptions,
|
||||
CookieSameSite,
|
||||
} from "./cookie.types.js";
|
||||
@@ -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,214 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { createReactI18nRegistry } from "../../registry/registry.js";
|
||||
import type { DetectorPlugin } from "../../registry/registry.types.js";
|
||||
import { defaultDetectorChain } from "./default-chain.js";
|
||||
|
||||
const detectorPlugin = (id: string, priority: number): DetectorPlugin => ({
|
||||
id,
|
||||
priority,
|
||||
detect: {
|
||||
name: id,
|
||||
environment: "both",
|
||||
detect: () => null,
|
||||
},
|
||||
});
|
||||
|
||||
describe("defaultDetectorChain", () => {
|
||||
it("order matches spec when userPreferenceEndpoint provided", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const chain = defaultDetectorChain({
|
||||
fallback: "en-GB",
|
||||
userPreferenceEndpoint: "/api/me/locale",
|
||||
});
|
||||
expect(chain.map((d) => d.name)).toEqual([
|
||||
"querystring",
|
||||
"userPreference",
|
||||
"cookie",
|
||||
"localStorage",
|
||||
"htmlLang",
|
||||
"acceptLanguage",
|
||||
"navigator",
|
||||
"cloudflareIpGeo",
|
||||
"fallback",
|
||||
]);
|
||||
});
|
||||
|
||||
it("omits userPreference when endpoint absent", { timeout: 5000 }, () => {
|
||||
const chain = defaultDetectorChain({ fallback: "en-GB" });
|
||||
expect(chain.map((d) => d.name)).toEqual([
|
||||
"querystring",
|
||||
"cookie",
|
||||
"localStorage",
|
||||
"htmlLang",
|
||||
"acceptLanguage",
|
||||
"navigator",
|
||||
"cloudflareIpGeo",
|
||||
"fallback",
|
||||
]);
|
||||
expect(chain.length).toBe(8);
|
||||
});
|
||||
|
||||
it("omits userPreference when endpoint is an empty string", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const chain = defaultDetectorChain({
|
||||
fallback: "en-GB",
|
||||
userPreferenceEndpoint: "",
|
||||
});
|
||||
expect(chain.map((d) => d.name)).not.toContain("userPreference");
|
||||
expect(chain.length).toBe(8);
|
||||
});
|
||||
|
||||
it("returns a fresh array on each call", { timeout: 5000 }, () => {
|
||||
const a = defaultDetectorChain({ fallback: "en-GB" });
|
||||
const b = defaultDetectorChain({ fallback: "en-GB" });
|
||||
expect(a).not.toBe(b);
|
||||
a.length = 0;
|
||||
expect(b.length).toBe(8);
|
||||
});
|
||||
|
||||
it("fallback detector wraps the supplied locale", { timeout: 5000 }, () => {
|
||||
const chain = defaultDetectorChain({ fallback: "ja-JP" });
|
||||
const fallback = chain[chain.length - 1];
|
||||
expect(fallback?.name).toBe("fallback");
|
||||
expect(
|
||||
fallback?.detect?.({
|
||||
supportedLocales: ["ja-JP"],
|
||||
fallbackLocale: "ja-JP",
|
||||
})
|
||||
).toBe("ja-JP");
|
||||
});
|
||||
|
||||
it("throws when fallback locale is empty", { timeout: 5000 }, () => {
|
||||
expect(() => defaultDetectorChain({ fallback: "" })).toThrow();
|
||||
});
|
||||
|
||||
it("splices a low-priority plugin before all built-ins", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const reg = createReactI18nRegistry();
|
||||
reg.registerDetector(detectorPlugin("ab-bucket", 0));
|
||||
const chain = defaultDetectorChain({
|
||||
fallback: "en-GB",
|
||||
registry: reg,
|
||||
});
|
||||
expect(chain.map((d) => d.name)).toEqual([
|
||||
"ab-bucket",
|
||||
"querystring",
|
||||
"cookie",
|
||||
"localStorage",
|
||||
"htmlLang",
|
||||
"acceptLanguage",
|
||||
"navigator",
|
||||
"cloudflareIpGeo",
|
||||
"fallback",
|
||||
]);
|
||||
});
|
||||
|
||||
it("splices a mid-priority plugin between built-ins", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const reg = createReactI18nRegistry();
|
||||
reg.registerDetector(detectorPlugin("custom", 350));
|
||||
const chain = defaultDetectorChain({
|
||||
fallback: "en-GB",
|
||||
registry: reg,
|
||||
});
|
||||
expect(chain.map((d) => d.name)).toEqual([
|
||||
"querystring",
|
||||
"cookie",
|
||||
"custom",
|
||||
"localStorage",
|
||||
"htmlLang",
|
||||
"acceptLanguage",
|
||||
"navigator",
|
||||
"cloudflareIpGeo",
|
||||
"fallback",
|
||||
]);
|
||||
});
|
||||
|
||||
it("places fallback last regardless of plugin priority", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const reg = createReactI18nRegistry();
|
||||
reg.registerDetector(detectorPlugin("late", 10_000));
|
||||
const chain = defaultDetectorChain({
|
||||
fallback: "en-GB",
|
||||
registry: reg,
|
||||
});
|
||||
expect(chain[chain.length - 1]?.name).toBe("fallback");
|
||||
expect(chain[chain.length - 2]?.name).toBe("late");
|
||||
});
|
||||
|
||||
it("built-in beats plugin at the same priority", { timeout: 5000 }, () => {
|
||||
const reg = createReactI18nRegistry();
|
||||
reg.registerDetector(detectorPlugin("aaa", 300));
|
||||
const chain = defaultDetectorChain({
|
||||
fallback: "en-GB",
|
||||
registry: reg,
|
||||
});
|
||||
const cookieIdx = chain.findIndex((d) => d.name === "cookie");
|
||||
const aaaIdx = chain.findIndex((d) => d.name === "aaa");
|
||||
expect(cookieIdx).toBeLessThan(aaaIdx);
|
||||
});
|
||||
|
||||
it("breaks plugin-vs-plugin ties lexicographically by id", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const reg = createReactI18nRegistry();
|
||||
// Register 4 plugins at the same priority in non-alphabetic
|
||||
// order so the sort comparator is invoked in both directions
|
||||
// (a.id < b.id AND a.id > b.id) and against multiple pairs of
|
||||
// plugin-vs-plugin AND plugin-vs-built-in (same priority as
|
||||
// the cookie built-in slot).
|
||||
reg.registerDetector(detectorPlugin("zeta", 250));
|
||||
reg.registerDetector(detectorPlugin("alpha", 250));
|
||||
reg.registerDetector(detectorPlugin("mike", 250));
|
||||
reg.registerDetector(detectorPlugin("bravo", 250));
|
||||
const chain = defaultDetectorChain({
|
||||
fallback: "en-GB",
|
||||
registry: reg,
|
||||
});
|
||||
const indexOf = (name: string) =>
|
||||
chain.findIndex((d) => d.name === name);
|
||||
expect(indexOf("alpha")).toBeLessThan(indexOf("bravo"));
|
||||
expect(indexOf("bravo")).toBeLessThan(indexOf("mike"));
|
||||
expect(indexOf("mike")).toBeLessThan(indexOf("zeta"));
|
||||
});
|
||||
|
||||
it("plugin-with-built-in at same priority places built-in first regardless of registration order", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
// Exercise the `a.isPlugin ? 1 : -1` ternary in BOTH directions
|
||||
// by mixing a high-priority plugin with built-ins ordered
|
||||
// either side. The sort comparator gets called with (plugin,
|
||||
// builtin) AND (builtin, plugin) argument orders.
|
||||
const reg = createReactI18nRegistry();
|
||||
reg.registerDetector(detectorPlugin("zeta-plugin", 300));
|
||||
reg.registerDetector(detectorPlugin("alpha-plugin", 100));
|
||||
const chain = defaultDetectorChain({
|
||||
fallback: "en-GB",
|
||||
registry: reg,
|
||||
});
|
||||
const names = chain.map((d) => d.name);
|
||||
// Built-ins always come before plugins at the same priority,
|
||||
// and the fallback is always last.
|
||||
expect(names[names.length - 1]).toBe("fallback");
|
||||
expect(names).toContain("alpha-plugin");
|
||||
expect(names).toContain("zeta-plugin");
|
||||
});
|
||||
|
||||
it("no-registry path is byte-identical to no-options path", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const a = defaultDetectorChain({ fallback: "en-GB" }).map(
|
||||
(d) => d.name
|
||||
);
|
||||
const b = defaultDetectorChain({
|
||||
fallback: "en-GB",
|
||||
registry: createReactI18nRegistry(),
|
||||
}).map((d) => d.name);
|
||||
expect(b).toEqual(a);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,150 @@
|
||||
import type { LocaleDetector } from "@suaveplan/i18n-detector";
|
||||
import { acceptLanguageDetector } from "../accept-language/accept-language.js";
|
||||
import { cloudflareIpGeoDetector } from "../cloudflare-ip-geo/cloudflare-ip-geo.js";
|
||||
import { cookieDetector } from "../cookie/cookie.js";
|
||||
import { fallbackDetector } from "../fallback/fallback.js";
|
||||
import { htmlLangDetector } from "../html-lang/html-lang.js";
|
||||
import { localStorageDetector } from "../local-storage/local-storage.js";
|
||||
import { navigatorDetector } from "../navigator/navigator.js";
|
||||
import { querystringDetector } from "../querystring/querystring.js";
|
||||
import { userPreferenceDetector } from "../user-preference/user-preference.js";
|
||||
import type { DefaultDetectorChainOptions } from "./default-chain.types.js";
|
||||
|
||||
interface ChainEntry {
|
||||
readonly priority: number;
|
||||
/** Built-ins win ties; plugins lose. */
|
||||
readonly isPlugin: boolean;
|
||||
/** Used for deterministic tie-break between two plugins. */
|
||||
readonly id: string;
|
||||
readonly detector: LocaleDetector;
|
||||
}
|
||||
|
||||
/**
|
||||
* Stable priority slots for the built-in detectors. Plugins splice in
|
||||
* between adjacent slots based on their `priority`. The fallback
|
||||
* detector has no slot — it is always appended last regardless of
|
||||
* plugin priority, because it unconditionally resolves and would
|
||||
* short-circuit any later detector if interleaved.
|
||||
*/
|
||||
const BUILTIN_PRIORITY = {
|
||||
querystring: 100,
|
||||
userPreference: 200,
|
||||
cookie: 300,
|
||||
localStorage: 400,
|
||||
htmlLang: 500,
|
||||
acceptLanguage: 600,
|
||||
navigator: 700,
|
||||
cloudflareIpGeo: 800,
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Build the canonical detector chain consumed by `<TranslationProvider>`
|
||||
* and `useLocaleDetection`.
|
||||
*
|
||||
* Default order (no registry, no userPreferenceEndpoint — 8 entries):
|
||||
*
|
||||
* 1. `querystring` (100)
|
||||
* 2. `cookie` (300)
|
||||
* 3. `localStorage` (400)
|
||||
* 4. `htmlLang` (500)
|
||||
* 5. `acceptLanguage` (600)
|
||||
* 6. `navigator` (700)
|
||||
* 7. `cloudflareIpGeo` (800)
|
||||
* 8. `fallback` (always last)
|
||||
*
|
||||
* With `userPreferenceEndpoint`, the `userPreference` detector slots
|
||||
* in at priority 200. With a `registry`, every `DetectorPlugin` it
|
||||
* has is spliced in by its `priority`; the fallback detector remains
|
||||
* unconditionally last.
|
||||
*
|
||||
* The chain is returned as a fresh array on every call.
|
||||
*/
|
||||
export function defaultDetectorChain(
|
||||
options: DefaultDetectorChainOptions
|
||||
): LocaleDetector[] {
|
||||
const entries: ChainEntry[] = [];
|
||||
entries.push({
|
||||
priority: BUILTIN_PRIORITY.querystring,
|
||||
isPlugin: false,
|
||||
id: "querystring",
|
||||
detector: querystringDetector(),
|
||||
});
|
||||
if (
|
||||
typeof options.userPreferenceEndpoint === "string" &&
|
||||
options.userPreferenceEndpoint !== ""
|
||||
) {
|
||||
entries.push({
|
||||
priority: BUILTIN_PRIORITY.userPreference,
|
||||
isPlugin: false,
|
||||
id: "userPreference",
|
||||
detector: userPreferenceDetector({
|
||||
endpoint: options.userPreferenceEndpoint,
|
||||
}),
|
||||
});
|
||||
}
|
||||
entries.push({
|
||||
priority: BUILTIN_PRIORITY.cookie,
|
||||
isPlugin: false,
|
||||
id: "cookie",
|
||||
detector: cookieDetector(),
|
||||
});
|
||||
entries.push({
|
||||
priority: BUILTIN_PRIORITY.localStorage,
|
||||
isPlugin: false,
|
||||
id: "localStorage",
|
||||
detector: localStorageDetector(),
|
||||
});
|
||||
entries.push({
|
||||
priority: BUILTIN_PRIORITY.htmlLang,
|
||||
isPlugin: false,
|
||||
id: "htmlLang",
|
||||
detector: htmlLangDetector(),
|
||||
});
|
||||
entries.push({
|
||||
priority: BUILTIN_PRIORITY.acceptLanguage,
|
||||
isPlugin: false,
|
||||
id: "acceptLanguage",
|
||||
detector: acceptLanguageDetector(),
|
||||
});
|
||||
entries.push({
|
||||
priority: BUILTIN_PRIORITY.navigator,
|
||||
isPlugin: false,
|
||||
id: "navigator",
|
||||
detector: navigatorDetector(),
|
||||
});
|
||||
entries.push({
|
||||
priority: BUILTIN_PRIORITY.cloudflareIpGeo,
|
||||
isPlugin: false,
|
||||
id: "cloudflareIpGeo",
|
||||
detector: cloudflareIpGeoDetector(),
|
||||
});
|
||||
|
||||
if (options.registry) {
|
||||
for (const plugin of options.registry.listDetectors()) {
|
||||
entries.push({
|
||||
priority: plugin.priority,
|
||||
isPlugin: true,
|
||||
id: plugin.id,
|
||||
detector: plugin.detect,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
entries.sort(compareEntries);
|
||||
const detectors = entries.map((e) => e.detector);
|
||||
detectors.push(fallbackDetector(options.fallback));
|
||||
return detectors;
|
||||
}
|
||||
|
||||
function compareEntries(a: ChainEntry, b: ChainEntry): number {
|
||||
if (a.priority !== b.priority) return a.priority - b.priority;
|
||||
// Same priority: built-in beats plugin. Convert booleans to {0, 1}
|
||||
// and subtract — `Number(true)` ⇒ 1 sinks plugins below built-ins
|
||||
// without a ternary that JS sort may only invoke in one direction.
|
||||
const pluginDelta = Number(a.isPlugin) - Number(b.isPlugin);
|
||||
if (pluginDelta !== 0) return pluginDelta;
|
||||
// Same priority + both plugins: lexicographic by id (plugin ids
|
||||
// are unique within a chain so we never reach the `=== 0` case).
|
||||
// Two built-ins at the same priority is impossible by construction.
|
||||
return a.id < b.id ? -1 : 1;
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import type { ReactI18nRegistry } from "../../registry/registry.types.js";
|
||||
|
||||
/**
|
||||
* Configuration accepted by `defaultDetectorChain`.
|
||||
*
|
||||
* - `fallback` is required because the chain MUST end with the
|
||||
* unconditional fallback detector.
|
||||
* - `userPreferenceEndpoint` is optional; when omitted the
|
||||
* userPreference detector is skipped (8-entry chain instead of 9).
|
||||
* - `registry` is optional; when supplied, every `DetectorPlugin`
|
||||
* registered into it is spliced into the chain at its `priority`
|
||||
* position. Built-in detectors keep their relative order; ties
|
||||
* between a built-in and a plugin go to the built-in. Ties between
|
||||
* two plugins are broken lexicographically by `id`. The fallback
|
||||
* detector is always last regardless of plugin priority.
|
||||
*/
|
||||
export interface DefaultDetectorChainOptions {
|
||||
readonly fallback: string;
|
||||
readonly userPreferenceEndpoint?: string;
|
||||
readonly registry?: ReactI18nRegistry;
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export { defaultDetectorChain } from "./default-chain.js";
|
||||
export type { DefaultDetectorChainOptions } from "./default-chain.types.js";
|
||||
@@ -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,50 @@
|
||||
import type { DetectorContext } from "@suaveplan/i18n-detector";
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { fallbackDetector } from "./fallback.js";
|
||||
|
||||
const SUPPORTED = ["en-GB", "fr-FR"] as const;
|
||||
|
||||
const ctx: DetectorContext = {
|
||||
supportedLocales: SUPPORTED,
|
||||
fallbackLocale: "en-GB",
|
||||
};
|
||||
|
||||
describe("fallbackDetector", () => {
|
||||
it("exposes name and environment per spec", { timeout: 5000 }, () => {
|
||||
const detector = fallbackDetector("en-GB");
|
||||
expect(detector.name).toBe("fallback");
|
||||
expect(detector.environment).toBe("both");
|
||||
});
|
||||
|
||||
it("always returns the configured locale", { timeout: 5000 }, () => {
|
||||
expect(fallbackDetector("en-GB").detect?.(ctx)).toBe("en-GB");
|
||||
expect(fallbackDetector("fr-FR").detect?.(ctx)).toBe("fr-FR");
|
||||
});
|
||||
|
||||
it("trims whitespace from the configured locale", { timeout: 5000 }, () => {
|
||||
expect(fallbackDetector(" en-GB ").detect?.(ctx)).toBe("en-GB");
|
||||
});
|
||||
|
||||
it("throws on empty locale", { timeout: 5000 }, () => {
|
||||
expect(() => fallbackDetector("")).toThrow(
|
||||
"fallbackDetector requires a non-empty locale string"
|
||||
);
|
||||
});
|
||||
|
||||
it("throws on whitespace-only locale", { timeout: 5000 }, () => {
|
||||
expect(() => fallbackDetector(" ")).toThrow();
|
||||
});
|
||||
|
||||
it("throws when locale is not a string", { timeout: 5000 }, () => {
|
||||
expect(() =>
|
||||
fallbackDetector(undefined as unknown as string)
|
||||
).toThrow();
|
||||
expect(() => fallbackDetector(123 as unknown as string)).toThrow();
|
||||
});
|
||||
|
||||
it("does not implement cache", { timeout: 5000 }, () => {
|
||||
const detector = fallbackDetector("en-GB");
|
||||
expect(detector.cache).toBeUndefined();
|
||||
expect(detector.detectAsync).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,24 @@
|
||||
import type { LocaleDetector } from "@suaveplan/i18n-detector";
|
||||
|
||||
/**
|
||||
* Build a `LocaleDetector` that always returns the configured locale.
|
||||
*
|
||||
* The fallback detector MUST appear last in any chain — it never
|
||||
* returns `null`, so any detector after it would never run. The
|
||||
* constructor validates the supplied locale up-front (rejects
|
||||
* empty/whitespace strings) so a misconfiguration surfaces at build
|
||||
* time rather than at the first chain run.
|
||||
*/
|
||||
export function fallbackDetector(locale: string): LocaleDetector {
|
||||
if (typeof locale !== "string" || locale.trim() === "") {
|
||||
throw new Error("fallbackDetector requires a non-empty locale string");
|
||||
}
|
||||
const value = locale.trim();
|
||||
return {
|
||||
name: "fallback",
|
||||
environment: "both",
|
||||
detect() {
|
||||
return value;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { fallbackDetector } from "./fallback.js";
|
||||
@@ -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,117 @@
|
||||
import type { DetectorContext } from "@suaveplan/i18n-detector";
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { htmlLangDetector } from "./html-lang.js";
|
||||
|
||||
const SUPPORTED = ["en-GB", "fr-FR", "de-DE"] as const;
|
||||
|
||||
function browserContext(): DetectorContext {
|
||||
return {
|
||||
supportedLocales: SUPPORTED,
|
||||
fallbackLocale: "en-GB",
|
||||
};
|
||||
}
|
||||
|
||||
function withDocumentLang<T>(lang: unknown, fn: () => T): T {
|
||||
const original = (globalThis as { document?: unknown }).document;
|
||||
Object.defineProperty(globalThis, "document", {
|
||||
configurable: true,
|
||||
value: { documentElement: { lang } },
|
||||
});
|
||||
try {
|
||||
return fn();
|
||||
} finally {
|
||||
Object.defineProperty(globalThis, "document", {
|
||||
configurable: true,
|
||||
value: original,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
describe("htmlLangDetector", () => {
|
||||
it("exposes name and environment per spec", { timeout: 5000 }, () => {
|
||||
const detector = htmlLangDetector();
|
||||
expect(detector.name).toBe("htmlLang");
|
||||
expect(detector.environment).toBe("browser");
|
||||
});
|
||||
|
||||
it("reads document.documentElement.lang", { timeout: 5000 }, () => {
|
||||
const detector = htmlLangDetector();
|
||||
withDocumentLang("fr-FR", () => {
|
||||
expect(detector.detect?.(browserContext())).toBe("fr-FR");
|
||||
});
|
||||
});
|
||||
|
||||
it("trims surrounding whitespace", { timeout: 5000 }, () => {
|
||||
const detector = htmlLangDetector();
|
||||
withDocumentLang(" de-DE ", () => {
|
||||
expect(detector.detect?.(browserContext())).toBe("de-DE");
|
||||
});
|
||||
});
|
||||
|
||||
it("returns null for empty lang attribute", { timeout: 5000 }, () => {
|
||||
const detector = htmlLangDetector();
|
||||
withDocumentLang("", () => {
|
||||
expect(detector.detect?.(browserContext())).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
it("returns null when documentElement is missing", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const detector = htmlLangDetector();
|
||||
const original = (globalThis as { document?: unknown }).document;
|
||||
Object.defineProperty(globalThis, "document", {
|
||||
configurable: true,
|
||||
value: { documentElement: null },
|
||||
});
|
||||
try {
|
||||
expect(detector.detect?.(browserContext())).toBeNull();
|
||||
} finally {
|
||||
Object.defineProperty(globalThis, "document", {
|
||||
configurable: true,
|
||||
value: original,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it("returns null when lang is not a string", { timeout: 5000 }, () => {
|
||||
const detector = htmlLangDetector();
|
||||
withDocumentLang(12345, () => {
|
||||
expect(detector.detect?.(browserContext())).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
it("returns null when no document is available", { timeout: 5000 }, () => {
|
||||
const detector = htmlLangDetector();
|
||||
const original = (globalThis as { document?: unknown }).document;
|
||||
Object.defineProperty(globalThis, "document", {
|
||||
configurable: true,
|
||||
value: undefined,
|
||||
});
|
||||
try {
|
||||
expect(detector.detect?.(browserContext())).toBeNull();
|
||||
} finally {
|
||||
Object.defineProperty(globalThis, "document", {
|
||||
configurable: true,
|
||||
value: original,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it("uses ctx.document over globalThis.document when supplied", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const detector = htmlLangDetector();
|
||||
withDocumentLang("en-GB", () => {
|
||||
const injected = {
|
||||
documentElement: { lang: "fr-FR" },
|
||||
} as unknown as Document;
|
||||
expect(
|
||||
detector.detect?.({
|
||||
...browserContext(),
|
||||
document: injected,
|
||||
})
|
||||
).toBe("fr-FR");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,37 @@
|
||||
import type { DetectorContext, LocaleDetector } from "@suaveplan/i18n-detector";
|
||||
|
||||
/**
|
||||
* Build a `LocaleDetector` that reads the active locale from
|
||||
* `document.documentElement.lang`.
|
||||
*
|
||||
* The detector is `environment: "browser"` so it never runs in SSR. The
|
||||
* common use case is a static HTML shell that already declares
|
||||
* `<html lang="…">` (typically rendered by the SSR layer for crawlers
|
||||
* and accessibility) — this detector lets the React surface honour the
|
||||
* shell's declaration without coupling to a specific framework.
|
||||
*/
|
||||
export function htmlLangDetector(): LocaleDetector {
|
||||
return {
|
||||
name: "htmlLang",
|
||||
environment: "browser",
|
||||
detect(ctx) {
|
||||
const doc = readDocument(ctx);
|
||||
if (doc === null) return null;
|
||||
const root = doc.documentElement;
|
||||
if (!root) return null;
|
||||
const lang = root.lang;
|
||||
if (typeof lang !== "string") return null;
|
||||
const trimmed = lang.trim();
|
||||
return trimmed === "" ? null : trimmed;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function readDocument(ctx: DetectorContext): Document | null {
|
||||
if (ctx.document) return ctx.document;
|
||||
const candidate = (globalThis as { document?: unknown }).document;
|
||||
if (candidate && typeof candidate === "object") {
|
||||
return candidate as Document;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { htmlLangDetector } from "./html-lang.js";
|
||||
29
packages/localization/i18n/react-i18n/src/detector/index.ts
Normal file
29
packages/localization/i18n/react-i18n/src/detector/index.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
export {
|
||||
acceptLanguageDetector,
|
||||
parseAcceptLanguage,
|
||||
} from "./accept-language/accept-language.js";
|
||||
export type { AcceptLanguageEntry } from "./accept-language/accept-language.types.js";
|
||||
export { cloudflareIpGeoDetector } from "./cloudflare-ip-geo/cloudflare-ip-geo.js";
|
||||
export type { CloudflareIpGeoDetectorOptions } from "./cloudflare-ip-geo/cloudflare-ip-geo.types.js";
|
||||
export { cookieDetector } from "./cookie/cookie.js";
|
||||
export type {
|
||||
CookieDetectorOptions,
|
||||
CookieSameSite,
|
||||
} from "./cookie/cookie.types.js";
|
||||
export { defaultDetectorChain } from "./default-chain/default-chain.js";
|
||||
export type { DefaultDetectorChainOptions } from "./default-chain/default-chain.types.js";
|
||||
export { fallbackDetector } from "./fallback/fallback.js";
|
||||
export { htmlLangDetector } from "./html-lang/html-lang.js";
|
||||
export { localStorageDetector } from "./local-storage/local-storage.js";
|
||||
export type {
|
||||
LocalStorageDetectorOptions,
|
||||
LocalStorageLike,
|
||||
} from "./local-storage/local-storage.types.js";
|
||||
export { navigatorDetector } from "./navigator/navigator.js";
|
||||
export { querystringDetector } from "./querystring/querystring.js";
|
||||
export type { QuerystringDetectorOptions } from "./querystring/querystring.types.js";
|
||||
export { userPreferenceDetector } from "./user-preference/user-preference.js";
|
||||
export type {
|
||||
UserPreferenceDetectorOptions,
|
||||
UserPreferenceResponseBody,
|
||||
} from "./user-preference/user-preference.types.js";
|
||||
@@ -0,0 +1,5 @@
|
||||
export { localStorageDetector } from "./local-storage.js";
|
||||
export type {
|
||||
LocalStorageDetectorOptions,
|
||||
LocalStorageLike,
|
||||
} from "./local-storage.types.js";
|
||||
@@ -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,169 @@
|
||||
import type { DetectorContext } from "@suaveplan/i18n-detector";
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { localStorageDetector } from "./local-storage.js";
|
||||
import type { LocalStorageLike } from "./local-storage.types.js";
|
||||
|
||||
const SUPPORTED = ["en-GB", "fr-FR", "de-DE"] as const;
|
||||
|
||||
function browserContext(): DetectorContext {
|
||||
return {
|
||||
supportedLocales: SUPPORTED,
|
||||
fallbackLocale: "en-GB",
|
||||
};
|
||||
}
|
||||
|
||||
function makeStorage(initial: Record<string, string> = {}): LocalStorageLike {
|
||||
const store = new Map<string, string>(Object.entries(initial));
|
||||
return {
|
||||
getItem: (k) => (store.has(k) ? (store.get(k) as string) : null),
|
||||
setItem: (k, v) => {
|
||||
store.set(k, v);
|
||||
},
|
||||
removeItem: (k) => {
|
||||
store.delete(k);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function withGlobalLocalStorage<T>(storage: unknown, fn: () => T): T {
|
||||
const original = (globalThis as { localStorage?: unknown }).localStorage;
|
||||
Object.defineProperty(globalThis, "localStorage", {
|
||||
configurable: true,
|
||||
value: storage,
|
||||
});
|
||||
try {
|
||||
return fn();
|
||||
} finally {
|
||||
Object.defineProperty(globalThis, "localStorage", {
|
||||
configurable: true,
|
||||
value: original,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
describe("localStorageDetector", () => {
|
||||
it("exposes name and environment per spec", { timeout: 5000 }, () => {
|
||||
const detector = localStorageDetector();
|
||||
expect(detector.name).toBe("localStorage");
|
||||
expect(detector.environment).toBe("browser");
|
||||
});
|
||||
|
||||
it("reads value from injected storage with default key", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const detector = localStorageDetector({
|
||||
storage: makeStorage({ "suaveplan-locale": "fr-FR" }),
|
||||
});
|
||||
expect(detector.detect?.(browserContext())).toBe("fr-FR");
|
||||
});
|
||||
|
||||
it("honours custom key", { timeout: 5000 }, () => {
|
||||
const detector = localStorageDetector({
|
||||
key: "lang",
|
||||
storage: makeStorage({ lang: "de-DE" }),
|
||||
});
|
||||
expect(detector.detect?.(browserContext())).toBe("de-DE");
|
||||
});
|
||||
|
||||
it("returns null when stored value is empty", { timeout: 5000 }, () => {
|
||||
const detector = localStorageDetector({
|
||||
storage: makeStorage({ "suaveplan-locale": "" }),
|
||||
});
|
||||
expect(detector.detect?.(browserContext())).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null when key is missing", { timeout: 5000 }, () => {
|
||||
const detector = localStorageDetector({
|
||||
storage: makeStorage({ other: "x" }),
|
||||
});
|
||||
expect(detector.detect?.(browserContext())).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null when storage.getItem throws", { timeout: 5000 }, () => {
|
||||
const throwing: LocalStorageLike = {
|
||||
getItem: () => {
|
||||
throw new Error("blocked");
|
||||
},
|
||||
setItem: () => undefined,
|
||||
removeItem: () => undefined,
|
||||
};
|
||||
const detector = localStorageDetector({ storage: throwing });
|
||||
expect(detector.detect?.(browserContext())).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null when no storage is available", { timeout: 5000 }, () => {
|
||||
withGlobalLocalStorage(undefined, () => {
|
||||
const detector = localStorageDetector();
|
||||
expect(detector.detect?.(browserContext())).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
it("returns null when global access throws", { timeout: 5000 }, () => {
|
||||
const original = (globalThis as { localStorage?: unknown })
|
||||
.localStorage;
|
||||
Object.defineProperty(globalThis, "localStorage", {
|
||||
configurable: true,
|
||||
get: () => {
|
||||
throw new Error("denied");
|
||||
},
|
||||
});
|
||||
try {
|
||||
const detector = localStorageDetector();
|
||||
expect(detector.detect?.(browserContext())).toBeNull();
|
||||
} finally {
|
||||
Object.defineProperty(globalThis, "localStorage", {
|
||||
configurable: true,
|
||||
value: original,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it("falls back to globalThis.localStorage when no storage supplied", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
withGlobalLocalStorage(
|
||||
makeStorage({ "suaveplan-locale": "de-DE" }),
|
||||
() => {
|
||||
const detector = localStorageDetector();
|
||||
expect(detector.detect?.(browserContext())).toBe("de-DE");
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
it("ignores non-storage-like global candidate", { timeout: 5000 }, () => {
|
||||
withGlobalLocalStorage({ getItem: 1, setItem: 2 }, () => {
|
||||
const detector = localStorageDetector();
|
||||
expect(detector.detect?.(browserContext())).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
it("persists value via cache", { timeout: 5000 }, () => {
|
||||
const storage = makeStorage();
|
||||
const detector = localStorageDetector({ storage });
|
||||
detector.cache?.("fr-FR", browserContext());
|
||||
expect(storage.getItem("suaveplan-locale")).toBe("fr-FR");
|
||||
});
|
||||
|
||||
it("cache is a no-op when storage is unavailable", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
withGlobalLocalStorage(undefined, () => {
|
||||
const detector = localStorageDetector();
|
||||
expect(() =>
|
||||
detector.cache?.("fr-FR", browserContext())
|
||||
).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
it("cache swallows setItem failures", { timeout: 5000 }, () => {
|
||||
const throwing: LocalStorageLike = {
|
||||
getItem: () => null,
|
||||
setItem: () => {
|
||||
throw new Error("quota");
|
||||
},
|
||||
removeItem: () => undefined,
|
||||
};
|
||||
const detector = localStorageDetector({ storage: throwing });
|
||||
expect(() => detector.cache?.("ar-AE", browserContext())).not.toThrow();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,69 @@
|
||||
import type { LocaleDetector } from "@suaveplan/i18n-detector";
|
||||
import type {
|
||||
LocalStorageDetectorOptions,
|
||||
LocalStorageLike,
|
||||
} from "./local-storage.types.js";
|
||||
|
||||
const DEFAULT_KEY = "suaveplan-locale";
|
||||
|
||||
/**
|
||||
* Build a `LocaleDetector` that reads/writes the active locale from a
|
||||
* `Storage`-shaped backing store (default: `globalThis.localStorage`).
|
||||
*
|
||||
* The detector is `environment: "browser"` so the chain runner
|
||||
* automatically skips it during SSR. Any access failure (private mode,
|
||||
* disabled storage, exceeded quota) is caught and surfaced as `null` /
|
||||
* a silent no-op write — the detector never throws into the React tree.
|
||||
*/
|
||||
export function localStorageDetector(
|
||||
options: LocalStorageDetectorOptions = {}
|
||||
): LocaleDetector {
|
||||
const key = options.key ?? DEFAULT_KEY;
|
||||
const explicitStorage = options.storage;
|
||||
|
||||
return {
|
||||
name: "localStorage",
|
||||
environment: "browser",
|
||||
detect() {
|
||||
const store = resolveStorage(explicitStorage);
|
||||
if (store === null) return null;
|
||||
try {
|
||||
const value = store.getItem(key);
|
||||
if (typeof value !== "string" || value === "") return null;
|
||||
return value;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
},
|
||||
cache(locale) {
|
||||
const store = resolveStorage(explicitStorage);
|
||||
if (store === null) return;
|
||||
try {
|
||||
store.setItem(key, locale);
|
||||
} catch {
|
||||
// Persistence is best-effort — quota exhaustion or
|
||||
// private-mode write rejection should not propagate.
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function resolveStorage(
|
||||
explicit: LocalStorageLike | undefined
|
||||
): LocalStorageLike | null {
|
||||
if (explicit) return explicit;
|
||||
try {
|
||||
const candidate = (globalThis as { localStorage?: unknown })
|
||||
.localStorage;
|
||||
if (
|
||||
candidate &&
|
||||
typeof (candidate as LocalStorageLike).getItem === "function" &&
|
||||
typeof (candidate as LocalStorageLike).setItem === "function"
|
||||
) {
|
||||
return candidate as LocalStorageLike;
|
||||
}
|
||||
return null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
/**
|
||||
* Storage shape required by `localStorageDetector`. Mirrors the relevant
|
||||
* subset of the DOM `Storage` interface so any conforming object (an
|
||||
* in-memory test fake, a session-storage handle, …) is acceptable.
|
||||
*/
|
||||
export interface LocalStorageLike {
|
||||
getItem(key: string): string | null;
|
||||
setItem(key: string, value: string): void;
|
||||
removeItem(key: string): void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Configuration accepted by `localStorageDetector`. Both fields are
|
||||
* optional; defaults pull `localStorage` from `globalThis` and use the
|
||||
* stable `"suaveplan-locale"` storage key.
|
||||
*/
|
||||
export interface LocalStorageDetectorOptions {
|
||||
/** Storage key under which the locale is read/written. */
|
||||
readonly key?: string;
|
||||
/**
|
||||
* Storage adapter. Defaults to `globalThis.localStorage`. Tests
|
||||
* inject a `Map`-backed fake to exercise the persisting path
|
||||
* without booting an actual `localStorage` polyfill.
|
||||
*/
|
||||
readonly storage?: LocalStorageLike;
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { navigatorDetector } from "./navigator.js";
|
||||
@@ -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,100 @@
|
||||
import type { DetectorContext } from "@suaveplan/i18n-detector";
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { navigatorDetector } from "./navigator.js";
|
||||
|
||||
const SUPPORTED = ["en-GB", "fr-FR", "de-DE"] as const;
|
||||
|
||||
function browserContext(): DetectorContext {
|
||||
return {
|
||||
supportedLocales: SUPPORTED,
|
||||
fallbackLocale: "en-GB",
|
||||
};
|
||||
}
|
||||
|
||||
function withNavigator<T>(value: unknown, fn: () => T): T {
|
||||
const original = (globalThis as { navigator?: unknown }).navigator;
|
||||
Object.defineProperty(globalThis, "navigator", {
|
||||
configurable: true,
|
||||
value,
|
||||
});
|
||||
try {
|
||||
return fn();
|
||||
} finally {
|
||||
Object.defineProperty(globalThis, "navigator", {
|
||||
configurable: true,
|
||||
value: original,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
describe("navigatorDetector", () => {
|
||||
it("exposes name and environment per spec", { timeout: 5000 }, () => {
|
||||
const detector = navigatorDetector();
|
||||
expect(detector.name).toBe("navigator");
|
||||
expect(detector.environment).toBe("browser");
|
||||
});
|
||||
|
||||
it("returns first navigator.languages entry", { timeout: 5000 }, () => {
|
||||
const detector = navigatorDetector();
|
||||
withNavigator({ languages: ["fr-FR", "en-GB"] }, () => {
|
||||
expect(detector.detect?.(browserContext())).toBe("fr-FR");
|
||||
});
|
||||
});
|
||||
|
||||
it("falls back to navigator.language when languages is empty", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const detector = navigatorDetector();
|
||||
withNavigator({ languages: [], language: "de-DE" }, () => {
|
||||
expect(detector.detect?.(browserContext())).toBe("de-DE");
|
||||
});
|
||||
});
|
||||
|
||||
it("falls back to navigator.language when languages is missing", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const detector = navigatorDetector();
|
||||
withNavigator({ language: "fr-FR" }, () => {
|
||||
expect(detector.detect?.(browserContext())).toBe("fr-FR");
|
||||
});
|
||||
});
|
||||
|
||||
it("skips empty entries in navigator.languages", { timeout: 5000 }, () => {
|
||||
const detector = navigatorDetector();
|
||||
withNavigator({ languages: ["", " ", "fr-FR"] }, () => {
|
||||
expect(detector.detect?.(browserContext())).toBe("fr-FR");
|
||||
});
|
||||
});
|
||||
|
||||
it("returns null when both sources are empty", { timeout: 5000 }, () => {
|
||||
const detector = navigatorDetector();
|
||||
withNavigator({ languages: [], language: "" }, () => {
|
||||
expect(detector.detect?.(browserContext())).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
it("returns null when navigator absent", { timeout: 5000 }, () => {
|
||||
const detector = navigatorDetector();
|
||||
withNavigator(undefined, () => {
|
||||
expect(detector.detect?.(browserContext())).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
it("returns null when navigator is not an object", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const detector = navigatorDetector();
|
||||
withNavigator("not-an-object", () => {
|
||||
expect(detector.detect?.(browserContext())).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
it("returns null when languages contains only whitespace and language is undefined", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const detector = navigatorDetector();
|
||||
withNavigator({ languages: [" "] }, () => {
|
||||
expect(detector.detect?.(browserContext())).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,50 @@
|
||||
import type { LocaleDetector } from "@suaveplan/i18n-detector";
|
||||
|
||||
/**
|
||||
* Build a `LocaleDetector` that reads the active locale from the
|
||||
* browser's `navigator.languages` (preferred) or `navigator.language`
|
||||
* (fallback). Returns the first non-empty entry.
|
||||
*
|
||||
* The chain runner negotiates the returned value against
|
||||
* `ctx.supportedLocales` so this detector contributes raw browser
|
||||
* preference and lets the resolver perform BCP-47 fallback.
|
||||
*/
|
||||
export function navigatorDetector(): LocaleDetector {
|
||||
return {
|
||||
name: "navigator",
|
||||
environment: "browser",
|
||||
detect() {
|
||||
const nav = readNavigator();
|
||||
if (nav === null) return null;
|
||||
const candidates = collectLanguages(nav);
|
||||
for (const candidate of candidates) {
|
||||
const value = candidate.trim();
|
||||
if (value !== "") return value;
|
||||
}
|
||||
return null;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
interface NavigatorLanguageLike {
|
||||
readonly languages?: ReadonlyArray<string>;
|
||||
readonly language?: string;
|
||||
}
|
||||
|
||||
function readNavigator(): NavigatorLanguageLike | null {
|
||||
const candidate = (globalThis as { navigator?: unknown }).navigator;
|
||||
if (candidate && typeof candidate === "object") {
|
||||
return candidate as NavigatorLanguageLike;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function collectLanguages(nav: NavigatorLanguageLike): ReadonlyArray<string> {
|
||||
if (Array.isArray(nav.languages) && nav.languages.length > 0) {
|
||||
return nav.languages;
|
||||
}
|
||||
if (typeof nav.language === "string" && nav.language !== "") {
|
||||
return [nav.language];
|
||||
}
|
||||
return [];
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export { querystringDetector } from "./querystring.js";
|
||||
export type { QuerystringDetectorOptions } from "./querystring.types.js";
|
||||
@@ -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,138 @@
|
||||
import type { DetectorContext } from "@suaveplan/i18n-detector";
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { querystringDetector } from "./querystring.js";
|
||||
|
||||
const SUPPORTED = ["en-GB", "fr-FR", "cy-GB"] as const;
|
||||
|
||||
function serverContext(url: string): DetectorContext {
|
||||
return {
|
||||
request: { headers: new Headers(), url: new URL(url) },
|
||||
responseHeaders: new Headers(),
|
||||
supportedLocales: SUPPORTED,
|
||||
fallbackLocale: "en-GB",
|
||||
};
|
||||
}
|
||||
|
||||
function browserContext(): DetectorContext {
|
||||
return {
|
||||
supportedLocales: SUPPORTED,
|
||||
fallbackLocale: "en-GB",
|
||||
};
|
||||
}
|
||||
|
||||
function withWindowLocation<T>(search: string, fn: () => T): T {
|
||||
const original = (globalThis as { location?: { search?: string } })
|
||||
.location;
|
||||
Object.defineProperty(globalThis, "location", {
|
||||
configurable: true,
|
||||
value: { search },
|
||||
});
|
||||
try {
|
||||
return fn();
|
||||
} finally {
|
||||
Object.defineProperty(globalThis, "location", {
|
||||
configurable: true,
|
||||
value: original,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
describe("querystringDetector", () => {
|
||||
it("reads ?lang from server-side ctx.request.url with default param", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const detector = querystringDetector();
|
||||
const result = detector.detect?.(
|
||||
serverContext("https://example.com/?lang=cy-GB")
|
||||
);
|
||||
expect(result).toBe("cy-GB");
|
||||
expect(detector.name).toBe("querystring");
|
||||
expect(detector.environment).toBe("both");
|
||||
});
|
||||
|
||||
it("honours opts.paramName override", { timeout: 5000 }, () => {
|
||||
const detector = querystringDetector({ paramName: "locale" });
|
||||
const result = detector.detect?.(
|
||||
serverContext("https://example.com/?locale=fr-FR")
|
||||
);
|
||||
expect(result).toBe("fr-FR");
|
||||
});
|
||||
|
||||
it("returns null when neither request nor window has the param", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const detector = querystringDetector();
|
||||
const result = detector.detect?.(serverContext("https://example.com/"));
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it("reads from window.location.search in the browser", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const detector = querystringDetector();
|
||||
withWindowLocation("?lang=fr-FR", () => {
|
||||
const result = detector.detect?.(browserContext());
|
||||
expect(result).toBe("fr-FR");
|
||||
});
|
||||
});
|
||||
|
||||
it("returns null when window has the param but value is empty", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const detector = querystringDetector();
|
||||
withWindowLocation("?lang=", () => {
|
||||
const result = detector.detect?.(browserContext());
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
it("returns null when window.location is missing entirely", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const detector = querystringDetector();
|
||||
const original = (globalThis as { location?: unknown }).location;
|
||||
Object.defineProperty(globalThis, "location", {
|
||||
configurable: true,
|
||||
value: undefined,
|
||||
});
|
||||
try {
|
||||
const result = detector.detect?.(browserContext());
|
||||
expect(result).toBeNull();
|
||||
} finally {
|
||||
Object.defineProperty(globalThis, "location", {
|
||||
configurable: true,
|
||||
value: original,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it("returns null when ctx.request.url has empty param value", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const detector = querystringDetector();
|
||||
const result = detector.detect?.(
|
||||
serverContext("https://example.com/?lang=")
|
||||
);
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null when window.location.search is not a string", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const detector = querystringDetector();
|
||||
const original = (globalThis as { location?: unknown }).location;
|
||||
Object.defineProperty(globalThis, "location", {
|
||||
configurable: true,
|
||||
value: { search: 1234 as unknown as string },
|
||||
});
|
||||
try {
|
||||
const result = detector.detect?.(browserContext());
|
||||
expect(result).toBeNull();
|
||||
} finally {
|
||||
Object.defineProperty(globalThis, "location", {
|
||||
configurable: true,
|
||||
value: original,
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,42 @@
|
||||
import type { LocaleDetector } from "@suaveplan/i18n-detector";
|
||||
import type { QuerystringDetectorOptions } from "./querystring.types.js";
|
||||
|
||||
const DEFAULT_PARAM_NAME = "lang";
|
||||
|
||||
/**
|
||||
* Build a `LocaleDetector` that reads the active locale from a URL search
|
||||
* parameter (default `?lang=...`).
|
||||
*
|
||||
* Runtime resolution:
|
||||
* - When `ctx.request.url` is provided (server / SSR) the detector reads
|
||||
* `ctx.request.url.searchParams.get(paramName)`.
|
||||
* - Otherwise the detector reads `window.location.search` via a fresh
|
||||
* `URLSearchParams` instance.
|
||||
*
|
||||
* Returns `null` for missing params, empty values, or environments where
|
||||
* neither input source is available (Node without an injected request).
|
||||
*/
|
||||
export function querystringDetector(
|
||||
options: QuerystringDetectorOptions = {}
|
||||
): LocaleDetector {
|
||||
const paramName = options.paramName ?? DEFAULT_PARAM_NAME;
|
||||
return {
|
||||
name: "querystring",
|
||||
environment: "both",
|
||||
detect(ctx) {
|
||||
const fromRequest = ctx.request?.url.searchParams.get(paramName);
|
||||
if (typeof fromRequest === "string" && fromRequest !== "") {
|
||||
return fromRequest;
|
||||
}
|
||||
const win = (globalThis as { location?: { search?: string } })
|
||||
.location;
|
||||
if (!win || typeof win.search !== "string") return null;
|
||||
const params = new URLSearchParams(win.search);
|
||||
const fromWindow = params.get(paramName);
|
||||
if (typeof fromWindow === "string" && fromWindow !== "") {
|
||||
return fromWindow;
|
||||
}
|
||||
return null;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
/**
|
||||
* Configuration accepted by `querystringDetector`. Every field is
|
||||
* optional; sensible defaults are documented on each property.
|
||||
*/
|
||||
export interface QuerystringDetectorOptions {
|
||||
/**
|
||||
* Name of the URL search parameter the detector reads. Defaults to
|
||||
* `"lang"`. Spec scenario "querystring overrides cookie" exercises
|
||||
* the default; consumers SHOULD only override when integrating
|
||||
* with a host application that already uses a different name.
|
||||
*/
|
||||
readonly paramName?: string;
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
export { userPreferenceDetector } from "./user-preference.js";
|
||||
export type {
|
||||
UserPreferenceDetectorOptions,
|
||||
UserPreferenceResponseBody,
|
||||
} from "./user-preference.types.js";
|
||||
@@ -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.
|
||||
@@ -0,0 +1,290 @@
|
||||
import type { DetectorContext } from "@suaveplan/i18n-detector";
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { userPreferenceDetector } from "./user-preference.js";
|
||||
|
||||
const SUPPORTED = ["en-GB", "fr-FR", "de-DE", "ar-AE"] as const;
|
||||
|
||||
function serverContext(headers: HeadersInit = {}): DetectorContext {
|
||||
return {
|
||||
request: {
|
||||
headers: new Headers(headers),
|
||||
url: new URL("https://example.com/"),
|
||||
},
|
||||
responseHeaders: new Headers(),
|
||||
supportedLocales: SUPPORTED,
|
||||
fallbackLocale: "en-GB",
|
||||
};
|
||||
}
|
||||
|
||||
function browserContext(): DetectorContext {
|
||||
return {
|
||||
supportedLocales: SUPPORTED,
|
||||
fallbackLocale: "en-GB",
|
||||
};
|
||||
}
|
||||
|
||||
interface FetchCall {
|
||||
readonly url: string;
|
||||
readonly init: RequestInit | undefined;
|
||||
}
|
||||
|
||||
function recordingFetch(response: () => Response | Promise<Response>): {
|
||||
fn: typeof fetch;
|
||||
calls: FetchCall[];
|
||||
} {
|
||||
const calls: FetchCall[] = [];
|
||||
const fn = (async (input: RequestInfo | URL, init?: RequestInit) => {
|
||||
const url = typeof input === "string" ? input : input.toString();
|
||||
calls.push({ url, init });
|
||||
return await response();
|
||||
}) as unknown as typeof fetch;
|
||||
return { fn, calls };
|
||||
}
|
||||
|
||||
describe("userPreferenceDetector", () => {
|
||||
it("detect threads SSR auth via extractAuth(ctx) into the request headers", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const { fn, calls } = recordingFetch(
|
||||
() => new Response(JSON.stringify({ locale: "fr-FR" }))
|
||||
);
|
||||
const detector = userPreferenceDetector({
|
||||
endpoint: "/api/me/locale",
|
||||
extractAuth: (c) => ({
|
||||
cookie: c.request?.headers.get("cookie") ?? "",
|
||||
}),
|
||||
fetch: fn,
|
||||
});
|
||||
const result = await detector.detectAsync?.(
|
||||
serverContext({ cookie: "session=abc" })
|
||||
);
|
||||
expect(result).toBe("fr-FR");
|
||||
expect(calls.length).toBe(1);
|
||||
expect(calls[0]?.url).toBe("/api/me/locale");
|
||||
expect(calls[0]?.init?.method).toBe("GET");
|
||||
const headers = new Headers(calls[0]?.init?.headers);
|
||||
expect(headers.get("cookie")).toBe("session=abc");
|
||||
});
|
||||
|
||||
it("detect returns null on non-2xx response", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const { fn } = recordingFetch(
|
||||
() => new Response("nope", { status: 503 })
|
||||
);
|
||||
const detector = userPreferenceDetector({
|
||||
endpoint: "/api/me/locale",
|
||||
fetch: fn,
|
||||
});
|
||||
const result = await detector.detectAsync?.(browserContext());
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it("detect returns null when fetch rejects", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const fn = (async () => {
|
||||
throw new Error("boom");
|
||||
}) as unknown as typeof fetch;
|
||||
const detector = userPreferenceDetector({
|
||||
endpoint: "/api/me/locale",
|
||||
fetch: fn,
|
||||
});
|
||||
const result = await detector.detectAsync?.(browserContext());
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it("detect returns null when fetch rejects with a non-Error", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const fn = (async () => {
|
||||
const literal: unknown = "string-error";
|
||||
throw literal;
|
||||
}) as unknown as typeof fetch;
|
||||
const detector = userPreferenceDetector({
|
||||
endpoint: "/api/me/locale",
|
||||
fetch: fn,
|
||||
});
|
||||
const result = await detector.detectAsync?.(browserContext());
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it("detect returns null when body lacks a locale field", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const { fn } = recordingFetch(() => new Response("{}"));
|
||||
const detector = userPreferenceDetector({
|
||||
endpoint: "/api/me/locale",
|
||||
fetch: fn,
|
||||
});
|
||||
const result = await detector.detectAsync?.(browserContext());
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it("detect returns null when body locale is empty string", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const { fn } = recordingFetch(
|
||||
() => new Response(JSON.stringify({ locale: "" }))
|
||||
);
|
||||
const detector = userPreferenceDetector({
|
||||
endpoint: "/api/me/locale",
|
||||
fetch: fn,
|
||||
});
|
||||
const result = await detector.detectAsync?.(browserContext());
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it("cache POSTs JSON body to the endpoint with content-type header", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const { fn, calls } = recordingFetch(
|
||||
() => new Response("{}", { status: 204 })
|
||||
);
|
||||
const detector = userPreferenceDetector({
|
||||
endpoint: "/api/me/locale",
|
||||
fetch: fn,
|
||||
});
|
||||
await detector.cache?.("de-DE", browserContext());
|
||||
expect(calls.length).toBe(1);
|
||||
expect(calls[0]?.init?.method).toBe("POST");
|
||||
const headers = new Headers(calls[0]?.init?.headers);
|
||||
expect(headers.get("content-type")).toBe("application/json");
|
||||
expect(calls[0]?.init?.body).toBe(JSON.stringify({ locale: "de-DE" }));
|
||||
});
|
||||
|
||||
it("cache leaves non-2xx without throwing", { timeout: 5000 }, async () => {
|
||||
const { fn } = recordingFetch(
|
||||
() => new Response("err", { status: 500 })
|
||||
);
|
||||
const detector = userPreferenceDetector({
|
||||
endpoint: "/api/me/locale",
|
||||
fetch: fn,
|
||||
});
|
||||
await expect(
|
||||
detector.cache?.("ar-AE", browserContext())
|
||||
).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it("cache swallows fetch rejections", { timeout: 5000 }, async () => {
|
||||
const fn = (async () => {
|
||||
throw new Error("network down");
|
||||
}) as unknown as typeof fetch;
|
||||
const detector = userPreferenceDetector({
|
||||
endpoint: "/api/me/locale",
|
||||
fetch: fn,
|
||||
});
|
||||
await expect(
|
||||
detector.cache?.("ar-AE", browserContext())
|
||||
).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it("cache swallows non-Error rejections", { timeout: 5000 }, async () => {
|
||||
const fn = (async () => {
|
||||
const literal: unknown = "literal-failure";
|
||||
throw literal;
|
||||
}) as unknown as typeof fetch;
|
||||
const detector = userPreferenceDetector({
|
||||
endpoint: "/api/me/locale",
|
||||
fetch: fn,
|
||||
});
|
||||
await expect(
|
||||
detector.cache?.("ar-AE", browserContext())
|
||||
).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it("cache preserves caller-supplied content-type when present", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const { fn, calls } = recordingFetch(() => new Response(""));
|
||||
const detector = userPreferenceDetector({
|
||||
endpoint: "/api/me/locale",
|
||||
extractAuth: () => ({ "content-type": "application/x-custom" }),
|
||||
fetch: fn,
|
||||
});
|
||||
await detector.cache?.("de-DE", browserContext());
|
||||
const headers = new Headers(calls[0]?.init?.headers);
|
||||
expect(headers.get("content-type")).toBe("application/x-custom");
|
||||
});
|
||||
|
||||
it("default fetch resolves to globalThis.fetch when not injected", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const original = globalThis.fetch;
|
||||
const calls: string[] = [];
|
||||
globalThis.fetch = (async (input: RequestInfo | URL) => {
|
||||
calls.push(typeof input === "string" ? input : input.toString());
|
||||
return new Response(JSON.stringify({ locale: "ar-AE" }));
|
||||
}) as typeof fetch;
|
||||
try {
|
||||
const detector = userPreferenceDetector({
|
||||
endpoint: "/api/me/locale",
|
||||
});
|
||||
const result = await detector.detectAsync?.(browserContext());
|
||||
expect(result).toBe("ar-AE");
|
||||
expect(calls).toEqual(["/api/me/locale"]);
|
||||
} finally {
|
||||
globalThis.fetch = original;
|
||||
}
|
||||
});
|
||||
|
||||
it("exposes name and environment per spec", { timeout: 5000 }, () => {
|
||||
const detector = userPreferenceDetector({ endpoint: "/x" });
|
||||
expect(detector.name).toBe("userPreference");
|
||||
expect(detector.environment).toBe("both");
|
||||
});
|
||||
|
||||
it("detect threads ctx.signal into the request init", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const calls: RequestInit[] = [];
|
||||
const fn = (async (_input: RequestInfo | URL, init?: RequestInit) => {
|
||||
calls.push(init ?? {});
|
||||
return new Response(JSON.stringify({ locale: "en-GB" }));
|
||||
}) as unknown as typeof fetch;
|
||||
const detector = userPreferenceDetector({
|
||||
endpoint: "/api/me/locale",
|
||||
fetch: fn,
|
||||
});
|
||||
const controller = new AbortController();
|
||||
await detector.detectAsync?.({
|
||||
...browserContext(),
|
||||
signal: controller.signal,
|
||||
});
|
||||
expect(calls[0]?.signal).toBe(controller.signal);
|
||||
});
|
||||
|
||||
it("cache threads ctx.signal into the POST request init", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const calls: RequestInit[] = [];
|
||||
const fn = (async (_input: RequestInfo | URL, init?: RequestInit) => {
|
||||
calls.push(init ?? {});
|
||||
return new Response("", { status: 204 });
|
||||
}) as unknown as typeof fetch;
|
||||
const detector = userPreferenceDetector({
|
||||
endpoint: "/api/me/locale",
|
||||
fetch: fn,
|
||||
});
|
||||
const controller = new AbortController();
|
||||
await detector.cache?.("fr-FR", {
|
||||
...browserContext(),
|
||||
signal: controller.signal,
|
||||
});
|
||||
expect(calls[0]?.signal).toBe(controller.signal);
|
||||
});
|
||||
|
||||
it("extractAuth returning empty object still issues request", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const { fn, calls } = recordingFetch(
|
||||
() => new Response(JSON.stringify({ locale: "de-DE" }))
|
||||
);
|
||||
const detector = userPreferenceDetector({
|
||||
endpoint: "/api/me/locale",
|
||||
fetch: fn,
|
||||
});
|
||||
await detector.detectAsync?.(browserContext());
|
||||
expect(calls.length).toBe(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,105 @@
|
||||
import type { DetectorContext, LocaleDetector } from "@suaveplan/i18n-detector";
|
||||
import { ConsoleLogger } from "@suaveplan/logger";
|
||||
import type {
|
||||
UserPreferenceDetectorOptions,
|
||||
UserPreferenceResponseBody,
|
||||
} from "./user-preference.types.js";
|
||||
|
||||
const logger = new ConsoleLogger({ tags: ["react-i18n.detector"] });
|
||||
|
||||
/**
|
||||
* Build a `LocaleDetector` that calls a backend endpoint to read (and
|
||||
* later persist) the user's stored locale preference.
|
||||
*
|
||||
* - GET on `detectAsync(ctx)` returns `{ locale }` from the endpoint.
|
||||
* - POST on `cache(locale, ctx)` writes the new value back. The body is
|
||||
* `{ locale }` JSON; the headers are the union of `Content-Type:
|
||||
* application/json` plus the auth headers returned by `extractAuth`.
|
||||
*
|
||||
* Network failures (rejected fetch, non-2xx response, malformed body,
|
||||
* abort signal) are caught and logged at warn level via
|
||||
* `@suaveplan/logger`; the detector returns `null` so the chain
|
||||
* advances to the next entry. Cache failures are non-fatal — the
|
||||
* persist write is best-effort and never throws into the React tree.
|
||||
*/
|
||||
export function userPreferenceDetector(
|
||||
options: UserPreferenceDetectorOptions
|
||||
): LocaleDetector {
|
||||
const fetcher: typeof fetch =
|
||||
options.fetch ?? ((...args) => globalThis.fetch(...args));
|
||||
|
||||
return {
|
||||
name: "userPreference",
|
||||
environment: "both",
|
||||
async detectAsync(ctx) {
|
||||
try {
|
||||
const init: RequestInit = {
|
||||
method: "GET",
|
||||
headers: buildAuthHeaders(options, ctx),
|
||||
};
|
||||
if (ctx.signal) init.signal = ctx.signal;
|
||||
const response = await fetcher(options.endpoint, init);
|
||||
if (!response.ok) {
|
||||
logger.warn("user-preference detector non-ok response", {
|
||||
endpoint: options.endpoint,
|
||||
status: response.status,
|
||||
});
|
||||
return null;
|
||||
}
|
||||
const body =
|
||||
(await response.json()) as Partial<UserPreferenceResponseBody>;
|
||||
if (typeof body?.locale !== "string" || body.locale === "") {
|
||||
logger.warn("user-preference detector missing locale", {
|
||||
endpoint: options.endpoint,
|
||||
});
|
||||
return null;
|
||||
}
|
||||
return body.locale;
|
||||
} catch (error) {
|
||||
logger.warn("user-preference detector fetch failed", {
|
||||
endpoint: options.endpoint,
|
||||
error:
|
||||
error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
return null;
|
||||
}
|
||||
},
|
||||
async cache(locale, ctx) {
|
||||
try {
|
||||
const headers = new Headers(buildAuthHeaders(options, ctx));
|
||||
if (!headers.has("content-type")) {
|
||||
headers.set("content-type", "application/json");
|
||||
}
|
||||
const init: RequestInit = {
|
||||
method: "POST",
|
||||
headers,
|
||||
body: JSON.stringify({ locale }),
|
||||
};
|
||||
if (ctx.signal) init.signal = ctx.signal;
|
||||
const response = await fetcher(options.endpoint, init);
|
||||
if (!response.ok) {
|
||||
logger.warn("user-preference detector cache failed", {
|
||||
endpoint: options.endpoint,
|
||||
status: response.status,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
logger.warn("user-preference detector cache threw", {
|
||||
endpoint: options.endpoint,
|
||||
error:
|
||||
error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function buildAuthHeaders(
|
||||
options: UserPreferenceDetectorOptions,
|
||||
ctx: DetectorContext
|
||||
): HeadersInit {
|
||||
if (options.extractAuth) {
|
||||
return options.extractAuth(ctx);
|
||||
}
|
||||
return {};
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import type { DetectorContext } from "@suaveplan/i18n-detector";
|
||||
|
||||
/**
|
||||
* Configuration accepted by `userPreferenceDetector`. The endpoint is
|
||||
* required; the auth/cache-key/fetch hooks are optional but are the
|
||||
* primary surface tests inject to keep network behaviour deterministic.
|
||||
*/
|
||||
export interface UserPreferenceDetectorOptions {
|
||||
/**
|
||||
* URL of the backend endpoint that returns the persisted locale for
|
||||
* the active session. The endpoint is GET-fetched on detect and
|
||||
* POST-fetched on cache.
|
||||
*/
|
||||
readonly endpoint: string;
|
||||
/**
|
||||
* Returns the request-headers payload threaded into the fetch call
|
||||
* so server-side runs (where the backend endpoint cannot read the
|
||||
* incoming request directly) carry the session cookie.
|
||||
*/
|
||||
readonly extractAuth?: (ctx: DetectorContext) => HeadersInit;
|
||||
/**
|
||||
* Returns a stable cache key for the active context so duplicate
|
||||
* calls in the same render tree can be deduped by the consumer.
|
||||
*/
|
||||
readonly cacheKey?: (ctx: DetectorContext) => string;
|
||||
/**
|
||||
* Injected `fetch` so tests can pass a real `Response`-returning
|
||||
* function rather than rely on a global mock. Defaults to the
|
||||
* platform `globalThis.fetch`.
|
||||
*/
|
||||
readonly fetch?: typeof fetch;
|
||||
}
|
||||
|
||||
/**
|
||||
* Wire shape returned by the user-preference endpoint on a successful
|
||||
* GET call. Only `locale` is consumed by the detector.
|
||||
*/
|
||||
export interface UserPreferenceResponseBody {
|
||||
readonly locale: string;
|
||||
}
|
||||
@@ -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,55 @@
|
||||
import { defineCodes } from "@suaveplan/error";
|
||||
|
||||
/**
|
||||
* Stable error codes for the @suaveplan/react-i18n package.
|
||||
*
|
||||
* Codes are grouped under the REACT_I18N namespace and registered at module
|
||||
* load time via `defineCodes`. The registry is the single source of truth
|
||||
* for error-code identity across the React surface — every thrown
|
||||
* `ReactI18nError` carries one of these strings as its `.code`.
|
||||
*
|
||||
* Codes:
|
||||
* - `NO_PROVIDER` — a hook was called outside a `<TranslationProvider>` and
|
||||
* `process.env.NODE_ENV !== "production"`.
|
||||
* - `NAMESPACE_LOAD_FAILED` — a Suspense-thrown promise from
|
||||
* `i18n.preload(namespace)` rejected and an error boundary caught it.
|
||||
* - `HYDRATION_MISMATCH` — SSR `initialMessages` payload did not satisfy
|
||||
* `TranslationRecordSchema` (Zod) when the Provider mounted.
|
||||
* - `TRANS_INVALID_CHILD` — `<Trans>` received a child that cannot be
|
||||
* matched to a tag pair in the parsed message.
|
||||
* - `UNKNOWN_KEY` — `tDynamic(key)` was called with a key absent from the
|
||||
* active catalog (the runtime escape hatch path).
|
||||
* - `PLUGIN_DUPLICATE_ID` — a plugin with the same `id` as an existing
|
||||
* registration (different instance) was registered into the same kind.
|
||||
* - `PLUGIN_INVALID` — a plugin failed schema validation at registration
|
||||
* time. The Zod issue list is attached as `cause`.
|
||||
* - `REGISTRY_NOT_PROVIDED` — `useReactI18nRegistry()` was called outside
|
||||
* a `<RegistryProvider>` ancestor.
|
||||
* - `FORMATTER_NOT_REGISTERED` — `useFormatter(id, args)` was called with
|
||||
* an `id` that has no matching `FormatterPlugin` in the registry.
|
||||
* - `DETECTOR_PLUGIN_DUPLICATE_ID` — alias of `PLUGIN_DUPLICATE_ID`
|
||||
* specialised for detector kind, retained for downstream consumers
|
||||
* asserting against the detector-specific code.
|
||||
* - `DETECTOR_PLUGIN_INVALID` — alias of `PLUGIN_INVALID` specialised
|
||||
* for detector kind.
|
||||
* - `LOCALE_ADAPTER_NOT_REGISTERED` — `useLocaleAdapter(id)` was called
|
||||
* with an `id` that has no matching `LocaleAdapterPlugin` in the
|
||||
* registry.
|
||||
*/
|
||||
export const REACT_I18N_CODES = defineCodes("REACT_I18N", [
|
||||
"NO_PROVIDER",
|
||||
"NAMESPACE_LOAD_FAILED",
|
||||
"HYDRATION_MISMATCH",
|
||||
"TRANS_INVALID_CHILD",
|
||||
"UNKNOWN_KEY",
|
||||
"PLUGIN_DUPLICATE_ID",
|
||||
"PLUGIN_INVALID",
|
||||
"REGISTRY_NOT_PROVIDED",
|
||||
"FORMATTER_NOT_REGISTERED",
|
||||
"DETECTOR_PLUGIN_DUPLICATE_ID",
|
||||
"DETECTOR_PLUGIN_INVALID",
|
||||
"LOCALE_ADAPTER_NOT_REGISTERED",
|
||||
] as const);
|
||||
|
||||
export type ReactI18nCode =
|
||||
(typeof REACT_I18N_CODES)[keyof typeof REACT_I18N_CODES];
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user