Initial Commit
This commit is contained in:
154
packages/localization/i18n/i18n-detector/README.md
Normal file
154
packages/localization/i18n/i18n-detector/README.md
Normal file
@@ -0,0 +1,154 @@
|
||||
# @suaveplan/i18n-detector - Universal locale-detection chain for the @suaveplan/i18n family. Ships the chain orchestrator, the BCP-47 default negotiator, the getTextDirection / isRtl helpers, and 9 detector implementations (querystring, user-preference, cookie, localStorage, html-lang, Accept-Language, navigator, Cloudflare IP-geo, fallback). Browser-only and server-only detectors gate on runtime probes so the same package runs in either tier without conditional package.json exports.
|
||||
|
||||
## Summary
|
||||
|
||||
Universal locale-detection chain for the `@suaveplan/i18n`
|
||||
family. Ships the chain orchestrator, the BCP-47 default
|
||||
negotiator, the `getTextDirection` / `isRtl` direction helpers, and
|
||||
nine detector implementations (`querystring`, `userPreference`,
|
||||
`cookie`, `localStorage`, `htmlLang`, `acceptLanguage`,
|
||||
`navigator`, `cloudflareIpGeo`, `fallback`) plus a canonical
|
||||
`defaultDetectorChain` factory.
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
## Documentation tree
|
||||
|
||||
- [chain](./src/chain/chain.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [bcp47-negotiate](./src/chain/bcp47-negotiate.md) — `bcp47Negotiate(raw, supported, fallback)` is the default negotiator wired into `createDetectorChain` when no `options.…
|
||||
- [chain](./src/chain/chain.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [default-chain](./src/default-chain/default-chain.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [default-chain](./src/default-chain/default-chain.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [accept-language](./src/detectors/accept-language/accept-language.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [accept-language](./src/detectors/accept-language/accept-language.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [cloudflare-ip-geo](./src/detectors/cloudflare-ip-geo/cloudflare-ip-geo.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [cloudflare-ip-geo](./src/detectors/cloudflare-ip-geo/cloudflare-ip-geo.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [cookie](./src/detectors/cookie/cookie.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [cookie](./src/detectors/cookie/cookie.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [fallback](./src/detectors/fallback/fallback.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [fallback](./src/detectors/fallback/fallback.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [html-lang](./src/detectors/html-lang/html-lang.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [html-lang](./src/detectors/html-lang/html-lang.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [local-storage](./src/detectors/local-storage/local-storage.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [local-storage](./src/detectors/local-storage/local-storage.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [navigator](./src/detectors/navigator/navigator.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [navigator](./src/detectors/navigator/navigator.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [querystring](./src/detectors/querystring/querystring.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [querystring](./src/detectors/querystring/querystring.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [user-preference](./src/detectors/user-preference/user-preference.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [user-preference](./src/detectors/user-preference/user-preference.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [direction](./src/direction/direction.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [direction](./src/direction/direction.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [observability](./src/observability/metrics.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [metrics](./src/observability/metrics.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
<!-- end:auto-docs --> - [bcp47-negotiate](./src/chain/bcp47-negotiate.md) — `bcp47Negotiate(raw, supported, fallback)` is the default negotiator wired into `createDetectorChain` when no `options.…
|
||||
- [chain](./src/chain/chain.md) — <!-- begin:auto-docs --> _(no submodules)_- [default-chain](./src/default-chain/default-chain.md) — <!-- begin:auto-docs --> _(no submodules)_- [default-chain](./src/default-chain/default-chain.md) — <!-- begin:auto-docs --> _(no submodules)_- [accept-language](./src/detectors/accept-language/accept-language.md) — <!-- begin:auto-docs --> _(no submodules)_- [accept-language](./src/detectors/accept-language/accept-language.md) — <!-- begin:auto-docs --> _(no submodules)_- [cloudflare-ip-geo](./src/detectors/cloudflare-ip-geo/cloudflare-ip-geo.md) — <!-- begin:auto-docs --> _(no submodules)_- [cloudflare-ip-geo](./src/detectors/cloudflare-ip-geo/cloudflare-ip-geo.md) — <!-- begin:auto-docs --> _(no submodules)_- [cookie](./src/detectors/cookie/cookie.md) — <!-- begin:auto-docs --> _(no submodules)_- [cookie](./src/detectors/cookie/cookie.md) — <!-- begin:auto-docs --> _(no submodules)_- [fallback](./src/detectors/fallback/fallback.md) — <!-- begin:auto-docs --> _(no submodules)_- [fallback](./src/detectors/fallback/fallback.md) — <!-- begin:auto-docs --> _(no submodules)_- [html-lang](./src/detectors/html-lang/html-lang.md) — <!-- begin:auto-docs --> _(no submodules)_- [html-lang](./src/detectors/html-lang/html-lang.md) — <!-- begin:auto-docs --> _(no submodules)_- [local-storage](./src/detectors/local-storage/local-storage.md) — <!-- begin:auto-docs --> _(no submodules)_- [local-storage](./src/detectors/local-storage/local-storage.md) — <!-- begin:auto-docs --> _(no submodules)_- [navigator](./src/detectors/navigator/navigator.md) — <!-- begin:auto-docs --> _(no submodules)_- [navigator](./src/detectors/navigator/navigator.md) — <!-- begin:auto-docs --> _(no submodules)_- [querystring](./src/detectors/querystring/querystring.md) — <!-- begin:auto-docs --> _(no submodules)_- [querystring](./src/detectors/querystring/querystring.md) — <!-- begin:auto-docs --> _(no submodules)_- [user-preference](./src/detectors/user-preference/user-preference.md) — <!-- begin:auto-docs --> _(no submodules)_- [user-preference](./src/detectors/user-preference/user-preference.md) — <!-- begin:auto-docs --> _(no submodules)_- [direction](./src/direction/direction.md) — <!-- begin:auto-docs --> _(no submodules)_- [direction](./src/direction/direction.md) — <!-- begin:auto-docs --> _(no submodules)_- [observability](./src/observability/metrics.md) — <!-- begin:auto-docs --> _(no submodules)_- [metrics](./src/observability/metrics.md) — <!-- begin:auto-docs --> _(no submodules)_- [bcp47-negotiate](./src/chain/bcp47-negotiate.md) — `bcp47Negotiate(raw, supported, fallback)` is the default negotiator wired into `createDetectorChain` when no `options.…
|
||||
- [chain](./src/chain/chain.md) — <!-- begin:auto-docs --> _(no submodules)_- [default-chain](./src/default-chain/default-chain.md) — <!-- begin:auto-docs --> _(no submodules)_- [default-chain](./src/default-chain/default-chain.md) — <!-- begin:auto-docs --> _(no submodules)_- [accept-language](./src/detectors/accept-language/accept-language.md) — <!-- begin:auto-docs --> _(no submodules)_- [accept-language](./src/detectors/accept-language/accept-language.md) — <!-- begin:auto-docs --> _(no submodules)_- [cloudflare-ip-geo](./src/detectors/cloudflare-ip-geo/cloudflare-ip-geo.md) — <!-- begin:auto-docs --> _(no submodules)_- [cloudflare-ip-geo](./src/detectors/cloudflare-ip-geo/cloudflare-ip-geo.md) — <!-- begin:auto-docs --> _(no submodules)_- [cookie](./src/detectors/cookie/cookie.md) — <!-- begin:auto-docs --> _(no submodules)_- [cookie](./src/detectors/cookie/cookie.md) — <!-- begin:auto-docs --> _(no submodules)_- [fallback](./src/detectors/fallback/fallback.md) — <!-- begin:auto-docs --> _(no submodules)_- [fallback](./src/detectors/fallback/fallback.md) — <!-- begin:auto-docs --> _(no submodules)_- [html-lang](./src/detectors/html-lang/html-lang.md) — <!-- begin:auto-docs --> _(no submodules)_- [html-lang](./src/detectors/html-lang/html-lang.md) — <!-- begin:auto-docs --> _(no submodules)_- [local-storage](./src/detectors/local-storage/local-storage.md) — <!-- begin:auto-docs --> _(no submodules)_- [local-storage](./src/detectors/local-storage/local-storage.md) — <!-- begin:auto-docs --> _(no submodules)_- [navigator](./src/detectors/navigator/navigator.md) — <!-- begin:auto-docs --> _(no submodules)_- [navigator](./src/detectors/navigator/navigator.md) — <!-- begin:auto-docs --> _(no submodules)_- [querystring](./src/detectors/querystring/querystring.md) — <!-- begin:auto-docs --> _(no submodules)_- [querystring](./src/detectors/querystring/querystring.md) — <!-- begin:auto-docs --> _(no submodules)_- [user-preference](./src/detectors/user-preference/user-preference.md) — <!-- begin:auto-docs --> _(no submodules)_- [user-preference](./src/detectors/user-preference/user-preference.md) — <!-- begin:auto-docs --> _(no submodules)_- [direction](./src/direction/direction.md) — <!-- begin:auto-docs --> _(no submodules)_- [direction](./src/direction/direction.md) — <!-- begin:auto-docs --> _(no submodules)_- [observability](./src/observability/metrics.md) — <!-- begin:auto-docs --> _(no submodules)_- [metrics](./src/observability/metrics.md) — <!-- begin:auto-docs --> _(no submodules)_- [bcp47-negotiate](./src/chain/bcp47-negotiate.md) — `bcp47Negotiate(raw, supported, fallback)` is the default negotiator wired into `createDetectorChain` when no `options.…
|
||||
- [chain](./src/chain/chain.md) — <!-- begin:auto-docs --> _(no submodules)_- [default-chain](./src/default-chain/default-chain.md) — <!-- begin:auto-docs --> _(no submodules)_- [default-chain](./src/default-chain/default-chain.md) — <!-- begin:auto-docs --> _(no submodules)_- [accept-language](./src/detectors/accept-language/accept-language.md) — <!-- begin:auto-docs --> _(no submodules)_- [accept-language](./src/detectors/accept-language/accept-language.md) — <!-- begin:auto-docs --> _(no submodules)_- [cloudflare-ip-geo](./src/detectors/cloudflare-ip-geo/cloudflare-ip-geo.md) — <!-- begin:auto-docs --> _(no submodules)_- [cloudflare-ip-geo](./src/detectors/cloudflare-ip-geo/cloudflare-ip-geo.md) — <!-- begin:auto-docs --> _(no submodules)_- [cookie](./src/detectors/cookie/cookie.md) — <!-- begin:auto-docs --> _(no submodules)_- [cookie](./src/detectors/cookie/cookie.md) — <!-- begin:auto-docs --> _(no submodules)_- [fallback](./src/detectors/fallback/fallback.md) — <!-- begin:auto-docs --> _(no submodules)_- [fallback](./src/detectors/fallback/fallback.md) — <!-- begin:auto-docs --> _(no submodules)_- [html-lang](./src/detectors/html-lang/html-lang.md) — <!-- begin:auto-docs --> _(no submodules)_- [html-lang](./src/detectors/html-lang/html-lang.md) — <!-- begin:auto-docs --> _(no submodules)_- [local-storage](./src/detectors/local-storage/local-storage.md) — <!-- begin:auto-docs --> _(no submodules)_- [local-storage](./src/detectors/local-storage/local-storage.md) — <!-- begin:auto-docs --> _(no submodules)_- [navigator](./src/detectors/navigator/navigator.md) — <!-- begin:auto-docs --> _(no submodules)_- [navigator](./src/detectors/navigator/navigator.md) — <!-- begin:auto-docs --> _(no submodules)_- [querystring](./src/detectors/querystring/querystring.md) — <!-- begin:auto-docs --> _(no submodules)_- [querystring](./src/detectors/querystring/querystring.md) — <!-- begin:auto-docs --> _(no submodules)_- [user-preference](./src/detectors/user-preference/user-preference.md) — <!-- begin:auto-docs --> _(no submodules)_- [user-preference](./src/detectors/user-preference/user-preference.md) — <!-- begin:auto-docs --> _(no submodules)_- [direction](./src/direction/direction.md) — <!-- begin:auto-docs --> _(no submodules)_- [direction](./src/direction/direction.md) — <!-- begin:auto-docs --> _(no submodules)_- [observability](./src/observability/metrics.md) — <!-- begin:auto-docs --> _(no submodules)_- [metrics](./src/observability/metrics.md) — <!-- begin:auto-docs --> _(no submodules)_- [bcp47-negotiate](./src/chain/bcp47-negotiate.md) — `bcp47Negotiate(raw, supported, fallback)` is the default negotiator wired into `createDetectorChain` when no `options.…
|
||||
- [chain](./src/chain/chain.md) — <!-- begin:auto-docs --> _(no submodules)_- [default-chain](./src/default-chain/default-chain.md) — <!-- begin:auto-docs --> _(no submodules)_ - [default-chain](./src/default-chain/default-chain.md) — <!-- begin:auto-docs --> _(no submodules)_- [accept-language](./src/detectors/accept-language/accept-language.md) — <!-- begin:auto-docs --> _(no submodules)_ - [accept-language](./src/detectors/accept-language/accept-language.md) — <!-- begin:auto-docs --> _(no submodules)_- [cloudflare-ip-geo](./src/detectors/cloudflare-ip-geo/cloudflare-ip-geo.md) — <!-- begin:auto-docs --> _(no submodules)_ - [cloudflare-ip-geo](./src/detectors/cloudflare-ip-geo/cloudflare-ip-geo.md) — <!-- begin:auto-docs --> _(no submodules)_- [cookie](./src/detectors/cookie/cookie.md) — <!-- begin:auto-docs --> _(no submodules)_ - [cookie](./src/detectors/cookie/cookie.md) — <!-- begin:auto-docs --> _(no submodules)_- [fallback](./src/detectors/fallback/fallback.md) — <!-- begin:auto-docs --> _(no submodules)_ - [fallback](./src/detectors/fallback/fallback.md) — <!-- begin:auto-docs --> _(no submodules)_- [html-lang](./src/detectors/html-lang/html-lang.md) — <!-- begin:auto-docs --> _(no submodules)_ - [html-lang](./src/detectors/html-lang/html-lang.md) — <!-- begin:auto-docs --> _(no submodules)_- [local-storage](./src/detectors/local-storage/local-storage.md) — <!-- begin:auto-docs --> _(no submodules)_ - [local-storage](./src/detectors/local-storage/local-storage.md) — <!-- begin:auto-docs --> _(no submodules)_- [navigator](./src/detectors/navigator/navigator.md) — <!-- begin:auto-docs --> _(no submodules)_ - [navigator](./src/detectors/navigator/navigator.md) — <!-- begin:auto-docs --> _(no submodules)_- [querystring](./src/detectors/querystring/querystring.md) — <!-- begin:auto-docs --> _(no submodules)_ - [querystring](./src/detectors/querystring/querystring.md) — <!-- begin:auto-docs --> _(no submodules)_- [user-preference](./src/detectors/user-preference/user-preference.md) — <!-- begin:auto-docs --> _(no submodules)_ - [user-preference](./src/detectors/user-preference/user-preference.md) — <!-- begin:auto-docs --> _(no submodules)_- [direction](./src/direction/direction.md) — <!-- begin:auto-docs --> _(no submodules)_ - [direction](./src/direction/direction.md) — <!-- begin:auto-docs --> _(no submodules)_- [observability](./src/observability/metrics.md) — <!-- begin:auto-docs --> _(no submodules)_ - [metrics](./src/observability/metrics.md) — <!-- begin:auto-docs --> _(no submodules)_
|
||||
## Why this package exists
|
||||
|
||||
Locale detection is the single most architecturally mis-placed
|
||||
piece of the i18n stack. The chain orchestrator lived in the
|
||||
universal `@suaveplan/i18n` monolith, but every detector
|
||||
implementation lived in `@suaveplan/react-i18n` — a browser-tier
|
||||
React binding package — even though most detectors are framework-
|
||||
agnostic and have no DOM dependency at all. Server-only detectors
|
||||
(`acceptLanguage`, `cloudflareIpGeo`) were unreachable from a
|
||||
Node-only consumer that didn't want to install React.
|
||||
|
||||
This package consolidates every detector implementation, the
|
||||
chain orchestrator, the standalone `getTextDirection` helper, and
|
||||
the `defaultDetectorChain` factory into a single universal
|
||||
package that depends only on `@suaveplan/i18n-shared`,
|
||||
`@suaveplan/locale-data`, and `@suaveplan/types`.
|
||||
|
||||
## Dual-tier strategy
|
||||
|
||||
The detector chain runs in both browser and server contexts.
|
||||
Universal means every detector MUST gracefully short-circuit
|
||||
when the runtime it expects is absent:
|
||||
|
||||
- **Browser-only** (`htmlLang`, `localStorage`, `navigator`)
|
||||
probe `globalThis.document` / `globalThis.localStorage` /
|
||||
`globalThis.navigator` and return `null` when the global is
|
||||
absent. The chain additionally honours `environment: "browser"`
|
||||
so server runs skip them entirely.
|
||||
- **Server-only** (`acceptLanguage`, `cloudflareIpGeo`) gate on
|
||||
`ctx.request !== undefined`. Their `environment: "server"`
|
||||
causes the chain to skip them on a browser run.
|
||||
- **Universal** (`querystring`, `cookie`, `userPreference`,
|
||||
`fallback`) carry `environment: "both"` and fall back through
|
||||
`ctx.request → globalThis` lookups so they work in either tier.
|
||||
|
||||
No conditional `package.json` exports; no `node:` import in any
|
||||
source file.
|
||||
|
||||
## Quick start
|
||||
|
||||
```ts
|
||||
import {
|
||||
createDetectorChain,
|
||||
defaultDetectorChain,
|
||||
} from "@suaveplan/i18n-detector";
|
||||
|
||||
const chain = createDetectorChain(
|
||||
defaultDetectorChain({ fallback: "en-GB" })
|
||||
);
|
||||
|
||||
const result = await chain.detect({
|
||||
request: {
|
||||
headers: new Headers({ "accept-language": "fr-FR,en;q=0.5" }),
|
||||
url: new URL("https://example.com/?lang=fr-FR"),
|
||||
},
|
||||
supportedLocales: ["en-GB", "fr-FR"],
|
||||
fallbackLocale: "en-GB",
|
||||
});
|
||||
```
|
||||
|
||||
## Modules
|
||||
|
||||
| Module | Doc |
|
||||
| --- | --- |
|
||||
| `chain/chain.ts` — chain orchestrator | [chain.md](./src/chain/chain.md) |
|
||||
| `chain/bcp47-negotiate.ts` — default negotiator | [bcp47-negotiate.md](./src/chain/bcp47-negotiate.md) |
|
||||
| `direction/direction.ts` — direction helpers | [direction.md](./src/direction/direction.md) |
|
||||
| `detectors/querystring/querystring.ts` | [querystring.md](./src/detectors/querystring/querystring.md) |
|
||||
| `detectors/user-preference/user-preference.ts` | [user-preference.md](./src/detectors/user-preference/user-preference.md) |
|
||||
| `detectors/cookie/cookie.ts` | [cookie.md](./src/detectors/cookie/cookie.md) |
|
||||
| `detectors/local-storage/local-storage.ts` | [local-storage.md](./src/detectors/local-storage/local-storage.md) |
|
||||
| `detectors/html-lang/html-lang.ts` | [html-lang.md](./src/detectors/html-lang/html-lang.md) |
|
||||
| `detectors/accept-language/accept-language.ts` | [accept-language.md](./src/detectors/accept-language/accept-language.md) |
|
||||
| `detectors/navigator/navigator.ts` | [navigator.md](./src/detectors/navigator/navigator.md) |
|
||||
| `detectors/cloudflare-ip-geo/cloudflare-ip-geo.ts` | [cloudflare-ip-geo.md](./src/detectors/cloudflare-ip-geo/cloudflare-ip-geo.md) |
|
||||
| `detectors/fallback/fallback.ts` | [fallback.md](./src/detectors/fallback/fallback.md) |
|
||||
| `default-chain/default-chain.ts` | [default-chain.md](./src/default-chain/default-chain.md) |
|
||||
| `observability/metrics.ts` | [metrics.md](./src/observability/metrics.md) |
|
||||
|
||||
## Subpath exports
|
||||
|
||||
Every detector ships under its own subpath so consumers can import
|
||||
only the surfaces they need (tree-shake-friendly):
|
||||
|
||||
- `@suaveplan/i18n-detector` — root barrel
|
||||
- `@suaveplan/i18n-detector/chain`
|
||||
- `@suaveplan/i18n-detector/direction`
|
||||
- `@suaveplan/i18n-detector/default-chain`
|
||||
- `@suaveplan/i18n-detector/detectors/<name>` for each of the nine
|
||||
detectors
|
||||
|
||||
## Observability
|
||||
|
||||
- `i18n.detect` span — wraps every chain run; sets
|
||||
`resolved_locale` and `winning_detector` attributes.
|
||||
- `i18n.detector.match_total{detector}` — aggregate match counter.
|
||||
- `i18n.detector.detected_total{detector,locale}` — per-locale
|
||||
match counter.
|
||||
- `i18n.detector.duration_ms{detector}` — async detector
|
||||
wall-time histogram.
|
||||
- `i18n.detector.chain.exhausted_total` — chain-exhaustion
|
||||
counter.
|
||||
|
||||
## License
|
||||
|
||||
MIT
|
||||
|
||||
137
packages/localization/i18n/i18n-detector/package.json
Normal file
137
packages/localization/i18n/i18n-detector/package.json
Normal file
@@ -0,0 +1,137 @@
|
||||
{
|
||||
"name": "@suaveplan/i18n-detector",
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"sideEffects": false,
|
||||
"description": "Universal locale-detection chain for the @suaveplan/i18n family. Ships the chain orchestrator, the BCP-47 default negotiator, the getTextDirection / isRtl helpers, and 9 detector implementations (querystring, user-preference, cookie, localStorage, html-lang, Accept-Language, navigator, Cloudflare IP-geo, fallback). Browser-only and server-only detectors gate on runtime probes so the same package runs in either tier without conditional package.json exports.",
|
||||
"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"
|
||||
}
|
||||
},
|
||||
"./chain": {
|
||||
"import": {
|
||||
"types": "./dist/chain/index.d.ts",
|
||||
"default": "./dist/chain/index.js"
|
||||
}
|
||||
},
|
||||
"./direction": {
|
||||
"import": {
|
||||
"types": "./dist/direction/index.d.ts",
|
||||
"default": "./dist/direction/index.js"
|
||||
}
|
||||
},
|
||||
"./detectors/querystring": {
|
||||
"import": {
|
||||
"types": "./dist/detectors/querystring/index.d.ts",
|
||||
"default": "./dist/detectors/querystring/index.js"
|
||||
}
|
||||
},
|
||||
"./detectors/user-preference": {
|
||||
"import": {
|
||||
"types": "./dist/detectors/user-preference/index.d.ts",
|
||||
"default": "./dist/detectors/user-preference/index.js"
|
||||
}
|
||||
},
|
||||
"./detectors/cookie": {
|
||||
"import": {
|
||||
"types": "./dist/detectors/cookie/index.d.ts",
|
||||
"default": "./dist/detectors/cookie/index.js"
|
||||
}
|
||||
},
|
||||
"./detectors/local-storage": {
|
||||
"import": {
|
||||
"types": "./dist/detectors/local-storage/index.d.ts",
|
||||
"default": "./dist/detectors/local-storage/index.js"
|
||||
}
|
||||
},
|
||||
"./detectors/html-lang": {
|
||||
"import": {
|
||||
"types": "./dist/detectors/html-lang/index.d.ts",
|
||||
"default": "./dist/detectors/html-lang/index.js"
|
||||
}
|
||||
},
|
||||
"./detectors/accept-language": {
|
||||
"import": {
|
||||
"types": "./dist/detectors/accept-language/index.d.ts",
|
||||
"default": "./dist/detectors/accept-language/index.js"
|
||||
}
|
||||
},
|
||||
"./detectors/navigator": {
|
||||
"import": {
|
||||
"types": "./dist/detectors/navigator/index.d.ts",
|
||||
"default": "./dist/detectors/navigator/index.js"
|
||||
}
|
||||
},
|
||||
"./detectors/cloudflare-ip-geo": {
|
||||
"import": {
|
||||
"types": "./dist/detectors/cloudflare-ip-geo/index.d.ts",
|
||||
"default": "./dist/detectors/cloudflare-ip-geo/index.js"
|
||||
}
|
||||
},
|
||||
"./detectors/fallback": {
|
||||
"import": {
|
||||
"types": "./dist/detectors/fallback/index.d.ts",
|
||||
"default": "./dist/detectors/fallback/index.js"
|
||||
}
|
||||
},
|
||||
"./default-chain": {
|
||||
"import": {
|
||||
"types": "./dist/default-chain/index.d.ts",
|
||||
"default": "./dist/default-chain/index.js"
|
||||
}
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"build": "vite build",
|
||||
"test": "vitest run --passWithNoTests",
|
||||
"test:watch": "vitest",
|
||||
"test:coverage": "vitest run --coverage --passWithNoTests",
|
||||
"lint": "bunx biome check src/",
|
||||
"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/i18n-shared": "workspace:^",
|
||||
"@suaveplan/locale-data": "workspace:^",
|
||||
"@suaveplan/types": "workspace:^",
|
||||
"@opentelemetry/api": "catalog:observability",
|
||||
"tslib": "catalog:build",
|
||||
"zod": "catalog:forms"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@suaveplan/testing": "workspace:^",
|
||||
"@suaveplan/typescript-config": "workspace:^",
|
||||
"@types/node": "catalog:",
|
||||
"@vitest/coverage-istanbul": "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"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
# `bcp47-negotiate.ts` — default BCP-47 negotiator
|
||||
|
||||
## Purpose
|
||||
|
||||
`bcp47Negotiate(raw, supported, fallback)` is the default negotiator
|
||||
wired into `createDetectorChain` when no `options.negotiate` is
|
||||
supplied. It maps a detector's raw locale output onto a value the
|
||||
application actually supports without forcing the configured
|
||||
fallback prematurely. Returning `null` on miss is intentional — the
|
||||
chain advances to the next detector instead of committing to the
|
||||
fallback before every detector has been consulted.
|
||||
|
||||
The implementation handles the three cases the upstream
|
||||
`LocaleResolver.resolve()` happy path covered: exact match,
|
||||
short-code expansion via `LOCALE_BCP47_MAP` from
|
||||
`@suaveplan/i18n-shared`, and language-subtag fallback.
|
||||
|
||||
## Features
|
||||
|
||||
- Exact-match short-circuit: returns the raw input unchanged when it
|
||||
appears in `supported`.
|
||||
- Short-code expansion via `LOCALE_BCP47_MAP` (`"en"` → `"en-GB"`).
|
||||
- Language-subtag matching falls back to the first supported entry
|
||||
whose primary language matches the raw input's primary language.
|
||||
- Strict null on miss — the negotiator never invents a value.
|
||||
- Pure function, no internal state, safe to call from any context.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { bcp47Negotiate } from "@suaveplan/i18n-detector/chain";
|
||||
|
||||
bcp47Negotiate("fr-FR", ["en-GB", "fr-FR"], "en-GB"); // "fr-FR"
|
||||
bcp47Negotiate("en", ["en-GB", "fr-FR"], "en-GB"); // "en-GB"
|
||||
bcp47Negotiate("zh-Hant-TW", ["zh-CN", "en-GB"], "en-GB"); // "zh-CN"
|
||||
bcp47Negotiate("xx-XX", ["en-GB"], "en-GB"); // null
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import {
|
||||
bcp47Negotiate,
|
||||
createDetectorChain,
|
||||
type NegotiateFn,
|
||||
} from "@suaveplan/i18n-detector";
|
||||
|
||||
// Wrap the default with a per-request override map.
|
||||
const negotiate: NegotiateFn = (raw, supported, fallback) => {
|
||||
if (raw === "zh-Hans") return "zh-CN";
|
||||
return bcp47Negotiate(raw, supported, fallback);
|
||||
};
|
||||
|
||||
const chain = createDetectorChain(detectors, { negotiate });
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `bcp47Negotiate: NegotiateFn` — pure function;
|
||||
`(raw: string, supported: readonly string[], fallback: string) => string | null`.
|
||||
- Returns `null` when input is empty, non-string, or no supported
|
||||
match exists.
|
||||
- The `fallback` argument is accepted for parity with the
|
||||
`NegotiateFn` signature but is intentionally unused — the chain
|
||||
decides the fallback policy.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- Lookup order: exact → lower-cased short-code expansion → original
|
||||
short-code expansion → language-subtag scan.
|
||||
- `LOCALE_BCP47_MAP` is consulted twice (`lowered` first, raw form
|
||||
as a fallback) so mixed-case inputs like `"En-GB"` resolve via the
|
||||
same lookup path as `"en"`.
|
||||
- Whitespace-only and empty inputs return `null` before any lookup
|
||||
runs.
|
||||
@@ -0,0 +1,68 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { bcp47Negotiate } from "./bcp47-negotiate.js";
|
||||
|
||||
describe("bcp47Negotiate", () => {
|
||||
it("exact match returns input", { timeout: 5000 }, () => {
|
||||
expect(bcp47Negotiate("fr-FR", ["en-GB", "fr-FR"], "en-GB")).toBe(
|
||||
"fr-FR"
|
||||
);
|
||||
});
|
||||
|
||||
it("short code expands via LOCALE_BCP47_MAP", { timeout: 5000 }, () => {
|
||||
expect(bcp47Negotiate("en", ["en-GB", "fr-FR"], "en-GB")).toBe("en-GB");
|
||||
});
|
||||
|
||||
it("language-subtag fallback", { timeout: 5000 }, () => {
|
||||
expect(bcp47Negotiate("zh-Hant-TW", ["zh-CN", "en-GB"], "en-GB")).toBe(
|
||||
"zh-CN"
|
||||
);
|
||||
});
|
||||
|
||||
it("returns null on miss", { timeout: 5000 }, () => {
|
||||
expect(bcp47Negotiate("xx-XX", ["en-GB"], "en-GB")).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null on empty input", { timeout: 5000 }, () => {
|
||||
expect(bcp47Negotiate("", ["en-GB"], "en-GB")).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null on whitespace-only input", { timeout: 5000 }, () => {
|
||||
expect(bcp47Negotiate(" ", ["en-GB"], "en-GB")).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null on non-string input", { timeout: 5000 }, () => {
|
||||
expect(
|
||||
bcp47Negotiate(123 as unknown as string, ["en-GB"], "en-GB")
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("mixed-case input resolves via lower-cased map lookup", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
expect(bcp47Negotiate("EN-GB", ["en-GB", "fr-FR"], "en-GB")).toBe(
|
||||
"en-GB"
|
||||
);
|
||||
});
|
||||
|
||||
it("language-subtag scan ignores empty supported list", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
expect(bcp47Negotiate("fr", [], "en-GB")).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null when raw has no language subtag", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
// A trimmed input that becomes empty primary subtag.
|
||||
expect(bcp47Negotiate("-", ["en-GB"], "en-GB")).toBeNull();
|
||||
});
|
||||
|
||||
it("expanded short code that is unsupported falls through to scan", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
// "en" maps to "en-GB" via LOCALE_BCP47_MAP, but supported list
|
||||
// only has en-US, so the negotiator must fall through to the
|
||||
// language-subtag scan and return en-US.
|
||||
expect(bcp47Negotiate("en", ["en-US"], "en-US")).toBe("en-US");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,48 @@
|
||||
import { LOCALE_BCP47_MAP } from "@suaveplan/i18n-shared";
|
||||
import type { NegotiateFn } from "./chain.types.js";
|
||||
|
||||
/**
|
||||
* Default BCP-47 negotiator consumed by `createDetectorChain` when no
|
||||
* `options.negotiate` is supplied.
|
||||
*
|
||||
* Resolution order:
|
||||
*
|
||||
* 1. Reject empty / non-string input by returning `null`.
|
||||
* 2. Trim the raw value, then if it appears verbatim in `supported`
|
||||
* return it unchanged.
|
||||
* 3. Lookup the short-code expansion in `LOCALE_BCP47_MAP` (e.g.
|
||||
* `"en"` → `"en-GB"`); if the expansion is supported, return it.
|
||||
* 4. Extract the language subtag (everything before the first `-`)
|
||||
* lowercased and return the first supported locale whose language
|
||||
* subtag matches.
|
||||
* 5. On miss return `null` so the chain advances to the next detector
|
||||
* instead of forcing the fallback prematurely.
|
||||
*
|
||||
* The `fallback` parameter is accepted for parity with the upstream
|
||||
* `LocaleResolver.resolve()` signature but is intentionally NOT used
|
||||
* to short-circuit the negotiation — that decision is the chain's
|
||||
* responsibility.
|
||||
*/
|
||||
export const bcp47Negotiate: NegotiateFn = (raw, supported, _fallback) => {
|
||||
if (typeof raw !== "string") return null;
|
||||
const trimmed = raw.trim();
|
||||
if (trimmed === "") return null;
|
||||
|
||||
if (supported.includes(trimmed)) return trimmed;
|
||||
|
||||
const lowered = trimmed.toLowerCase();
|
||||
const mapped = LOCALE_BCP47_MAP[lowered] ?? LOCALE_BCP47_MAP[trimmed];
|
||||
if (mapped !== undefined && supported.includes(mapped)) {
|
||||
return mapped;
|
||||
}
|
||||
|
||||
// `split("-")[0]` on a non-empty trimmed string always returns a
|
||||
// string (possibly empty when the input starts with `-`).
|
||||
const language = (trimmed.split("-")[0] as string).toLowerCase();
|
||||
if (language === "") return null;
|
||||
for (const candidate of supported) {
|
||||
const candidateLang = (candidate.split("-")[0] as string).toLowerCase();
|
||||
if (candidateLang === language) return candidate;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
105
packages/localization/i18n/i18n-detector/src/chain/chain.md
Normal file
105
packages/localization/i18n/i18n-detector/src/chain/chain.md
Normal file
@@ -0,0 +1,105 @@
|
||||
# `chain.ts` — locale-detector chain orchestrator
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
`createDetectorChain(detectors, options?)` composes any number of
|
||||
`LocaleDetector` instances into a single runner that resolves the
|
||||
active locale on every request. The orchestrator owns three concerns
|
||||
the individual detectors deliberately do not: ordering (sync first,
|
||||
async raced in parallel), environment gating (browser-only and
|
||||
server-only detectors are skipped on the wrong tier), and BCP-47
|
||||
negotiation against `ctx.supportedLocales` via an injectable
|
||||
`negotiate` function.
|
||||
|
||||
The chain returns either a `DetectorChainResult` (locale + winning
|
||||
detector + raw value) or the `DETECTOR_CHAIN_UNRESOLVED` sentinel
|
||||
when every detector miss. Server-side renderers can opt into
|
||||
`throwOnExhausted: true` to receive a typed
|
||||
`I18nError(I18N.DETECTOR_CHAIN_EXHAUSTED)` instead, which maps
|
||||
cleanly to a 4xx response.
|
||||
|
||||
## Features
|
||||
|
||||
- Sync chain executes in registration order; first non-null,
|
||||
negotiable value wins and short-circuits the rest.
|
||||
- Async chain races every eligible `detectAsync` in parallel; first
|
||||
resolution to a supported locale wins.
|
||||
- Environment gating skips `environment: "browser"` detectors on
|
||||
server runs and `environment: "server"` detectors on browser runs.
|
||||
- `cache(locale, ctx)` walks every persisting detector and asks each
|
||||
to write the negotiated locale back; failures are absorbed.
|
||||
- AbortSignal threading — `ctx.signal` flows unchanged into every
|
||||
async detector via `RequestInit.signal`.
|
||||
- OTel: `i18n.detect` span with `resolved_locale` /
|
||||
`winning_detector` attributes; `i18n.detector.chain.exhausted_total`
|
||||
counter increments on every sentinel return.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import {
|
||||
createDetectorChain,
|
||||
defaultDetectorChain,
|
||||
} from "@suaveplan/i18n-detector";
|
||||
|
||||
const chain = createDetectorChain(
|
||||
defaultDetectorChain({ fallback: "en-GB" })
|
||||
);
|
||||
|
||||
const result = await chain.detect({
|
||||
request: new Request("https://example.com/?lang=fr").clone() as never,
|
||||
supportedLocales: ["en-GB", "fr-FR"],
|
||||
fallbackLocale: "en-GB",
|
||||
});
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import {
|
||||
createDetectorChain,
|
||||
cookieDetector,
|
||||
acceptLanguageDetector,
|
||||
fallbackDetector,
|
||||
} from "@suaveplan/i18n-detector";
|
||||
|
||||
const chain = createDetectorChain(
|
||||
[cookieDetector(), acceptLanguageDetector(), fallbackDetector("en-GB")],
|
||||
{
|
||||
throwOnExhausted: true,
|
||||
negotiate: (raw, supported) =>
|
||||
supported.find((s) => s.toLowerCase() === raw.toLowerCase()) ??
|
||||
null,
|
||||
}
|
||||
);
|
||||
|
||||
await chain.cache("fr-FR", ctx);
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `createDetectorChain(detectors, options?)` — returns `DetectorChain`.
|
||||
- `DETECTOR_CHAIN_UNRESOLVED: unique symbol` — sentinel return value.
|
||||
- `DetectorChain.detect(ctx)` — async; returns
|
||||
`DetectorChainResult | DETECTOR_CHAIN_UNRESOLVED`. Throws
|
||||
`I18nError(I18N.DETECTOR_CHAIN_EXHAUSTED)` when
|
||||
`throwOnExhausted: true` and no detector matched.
|
||||
- `DetectorChain.cache(locale, ctx)` — async; persists the locale via
|
||||
every eligible detector that exposes `cache()`.
|
||||
- `DetectorChain.detectors` — read-only registration-order view.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- The chain holds a defensive copy of the supplied detectors, so
|
||||
later mutation of the input array does not affect the chain.
|
||||
- Async detector failures (rejections, null resolutions, unsupported
|
||||
locales) decrement an internal `pending` counter; only the last
|
||||
loser resolves with `null`. A late winner cannot override a settled
|
||||
result.
|
||||
- Environment gating uses `ctx.request !== undefined` as the
|
||||
is-server probe — no runtime feature detection.
|
||||
- The injected `negotiate` is invoked with `ctx.supportedLocales`
|
||||
unchanged; the chain itself never inspects locale strings.
|
||||
692
packages/localization/i18n/i18n-detector/src/chain/chain.test.ts
Normal file
692
packages/localization/i18n/i18n-detector/src/chain/chain.test.ts
Normal file
@@ -0,0 +1,692 @@
|
||||
import { I18nError } from "@suaveplan/i18n-shared";
|
||||
import {
|
||||
afterEach,
|
||||
beforeEach,
|
||||
describe,
|
||||
expect,
|
||||
it,
|
||||
} from "@suaveplan/testing/runner";
|
||||
import type { DetectorContext, LocaleDetector } from "../detector.types.js";
|
||||
import {
|
||||
getDetectorMetricsState,
|
||||
resetDetectorMetricsCache,
|
||||
resetDetectorMetricsState,
|
||||
} from "../observability/metrics.js";
|
||||
import { createDetectorChain, DETECTOR_CHAIN_UNRESOLVED } from "./chain.js";
|
||||
|
||||
const SUPPORTED = ["en-GB", "fr-FR", "ar-AE", "zh-CN"] as const;
|
||||
|
||||
function ctxBrowser(overrides: Partial<DetectorContext> = {}): DetectorContext {
|
||||
return {
|
||||
supportedLocales: SUPPORTED,
|
||||
fallbackLocale: "en-GB",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function ctxServer(overrides: Partial<DetectorContext> = {}): DetectorContext {
|
||||
return {
|
||||
request: {
|
||||
headers: new Headers(),
|
||||
url: new URL("https://example.com/"),
|
||||
},
|
||||
supportedLocales: SUPPORTED,
|
||||
fallbackLocale: "en-GB",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
resetDetectorMetricsCache();
|
||||
resetDetectorMetricsState();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
resetDetectorMetricsState();
|
||||
});
|
||||
|
||||
describe("createDetectorChain — sync", () => {
|
||||
it("sync chain returns first matching detector in registration order", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
let cCalls = 0;
|
||||
const chain = createDetectorChain([
|
||||
{ name: "A", environment: "both", detect: () => null },
|
||||
{ name: "B", environment: "both", detect: () => "fr-FR" },
|
||||
{
|
||||
name: "C",
|
||||
environment: "both",
|
||||
detect: () => {
|
||||
cCalls += 1;
|
||||
return "ar-AE";
|
||||
},
|
||||
},
|
||||
]);
|
||||
const result = await chain.detect(ctxBrowser());
|
||||
expect(result).toEqual({
|
||||
locale: "fr-FR",
|
||||
detector: "B",
|
||||
rawValue: "fr-FR",
|
||||
});
|
||||
expect(cCalls).toBe(0);
|
||||
});
|
||||
|
||||
it("unsupported raw value is negotiated via bcp47Negotiate", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const chain = createDetectorChain([
|
||||
{ name: "qs", environment: "both", detect: () => "en" },
|
||||
]);
|
||||
const result = await chain.detect(ctxBrowser());
|
||||
if (result === DETECTOR_CHAIN_UNRESOLVED) {
|
||||
throw new Error("unexpected unresolved");
|
||||
}
|
||||
expect(result.locale).toBe("en-GB");
|
||||
expect(result.rawValue).toBe("en");
|
||||
});
|
||||
|
||||
it("unnegotiable value falls through to next detector", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const chain = createDetectorChain([
|
||||
{ name: "first", environment: "both", detect: () => "xx-YY" },
|
||||
{ name: "ok", environment: "both", detect: () => "ar-AE" },
|
||||
]);
|
||||
const result = await chain.detect(ctxBrowser());
|
||||
if (result === DETECTOR_CHAIN_UNRESOLVED) {
|
||||
throw new Error("unexpected unresolved");
|
||||
}
|
||||
expect(result.detector).toBe("ok");
|
||||
});
|
||||
|
||||
it("detector returning empty string is treated as null", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const chain = createDetectorChain([
|
||||
{ name: "empty", environment: "both", detect: () => "" },
|
||||
{ name: "ok", environment: "both", detect: () => "fr-FR" },
|
||||
]);
|
||||
const result = await chain.detect(ctxBrowser());
|
||||
if (result !== DETECTOR_CHAIN_UNRESOLVED) {
|
||||
expect(result.detector).toBe("ok");
|
||||
}
|
||||
});
|
||||
|
||||
it("detector returning undefined is treated as null", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const chain = createDetectorChain([
|
||||
{
|
||||
name: "undef",
|
||||
environment: "both",
|
||||
detect: () => undefined as unknown as string | null,
|
||||
},
|
||||
{ name: "ok", environment: "both", detect: () => "fr-FR" },
|
||||
]);
|
||||
const result = await chain.detect(ctxBrowser());
|
||||
if (result !== DETECTOR_CHAIN_UNRESOLVED) {
|
||||
expect(result.detector).toBe("ok");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("createDetectorChain — async race", () => {
|
||||
it("async chain races to first resolved detector", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const chain = createDetectorChain([
|
||||
{
|
||||
name: "A",
|
||||
environment: "both",
|
||||
detectAsync: () =>
|
||||
new Promise((r) => setTimeout(() => r("fr-FR"), 30)),
|
||||
},
|
||||
{
|
||||
name: "B",
|
||||
environment: "both",
|
||||
detectAsync: () =>
|
||||
new Promise((r) => setTimeout(() => r("ar-AE"), 5)),
|
||||
},
|
||||
]);
|
||||
const result = await chain.detect(ctxBrowser());
|
||||
if (result === DETECTOR_CHAIN_UNRESOLVED) {
|
||||
throw new Error("unexpected unresolved");
|
||||
}
|
||||
expect(result.detector).toBe("B");
|
||||
expect(result.locale).toBe("ar-AE");
|
||||
});
|
||||
|
||||
it("async detector returning null falls through to other async detectors", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const chain = createDetectorChain([
|
||||
{ name: "n", environment: "both", detectAsync: async () => null },
|
||||
{
|
||||
name: "ok",
|
||||
environment: "both",
|
||||
detectAsync: async () => "fr-FR",
|
||||
},
|
||||
]);
|
||||
const result = await chain.detect(ctxBrowser());
|
||||
if (result !== DETECTOR_CHAIN_UNRESOLVED) {
|
||||
expect(result.detector).toBe("ok");
|
||||
}
|
||||
});
|
||||
|
||||
it("async detector that rejects is treated as null", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const chain = createDetectorChain([
|
||||
{
|
||||
name: "fail",
|
||||
environment: "both",
|
||||
detectAsync: async () => {
|
||||
throw new Error("boom");
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "ok",
|
||||
environment: "both",
|
||||
detectAsync: async () => "ar-AE",
|
||||
},
|
||||
]);
|
||||
const result = await chain.detect(ctxBrowser());
|
||||
if (result !== DETECTOR_CHAIN_UNRESOLVED) {
|
||||
expect(result.detector).toBe("ok");
|
||||
}
|
||||
});
|
||||
|
||||
it("async resolving to unsupported value falls through", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const chain = createDetectorChain([
|
||||
{
|
||||
name: "bad",
|
||||
environment: "both",
|
||||
detectAsync: async () => "xx-YY",
|
||||
},
|
||||
{
|
||||
name: "good",
|
||||
environment: "both",
|
||||
detectAsync: async () => "ar-AE",
|
||||
},
|
||||
]);
|
||||
const result = await chain.detect(ctxBrowser());
|
||||
if (result !== DETECTOR_CHAIN_UNRESOLVED) {
|
||||
expect(result.locale).toBe("ar-AE");
|
||||
}
|
||||
});
|
||||
|
||||
it("async returning undefined treated as null", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const chain = createDetectorChain([
|
||||
{
|
||||
name: "u",
|
||||
environment: "both",
|
||||
detectAsync: async () => undefined as unknown as string | null,
|
||||
},
|
||||
{
|
||||
name: "ok",
|
||||
environment: "both",
|
||||
detectAsync: async () => "fr-FR",
|
||||
},
|
||||
]);
|
||||
const result = await chain.detect(ctxBrowser());
|
||||
if (result !== DETECTOR_CHAIN_UNRESOLVED) {
|
||||
expect(result.detector).toBe("ok");
|
||||
}
|
||||
});
|
||||
|
||||
it("late winner does not override earlier winner (settled short-circuit)", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const chain = createDetectorChain([
|
||||
{
|
||||
name: "fast",
|
||||
environment: "both",
|
||||
detectAsync: () =>
|
||||
new Promise((r) => setTimeout(() => r("ar-AE"), 5)),
|
||||
},
|
||||
{
|
||||
name: "late",
|
||||
environment: "both",
|
||||
detectAsync: () =>
|
||||
new Promise((r) => setTimeout(() => r("zh-CN"), 25)),
|
||||
},
|
||||
]);
|
||||
const result = await chain.detect(ctxBrowser());
|
||||
await new Promise((r) => setTimeout(r, 40));
|
||||
if (result !== DETECTOR_CHAIN_UNRESOLVED) {
|
||||
expect(result.detector).toBe("fast");
|
||||
}
|
||||
});
|
||||
|
||||
it("late rejection after settled does not duplicate finish", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const chain = createDetectorChain([
|
||||
{
|
||||
name: "winner",
|
||||
environment: "both",
|
||||
detectAsync: () =>
|
||||
new Promise((r) => setTimeout(() => r("ar-AE"), 5)),
|
||||
},
|
||||
{
|
||||
name: "lateFail",
|
||||
environment: "both",
|
||||
detectAsync: () =>
|
||||
new Promise((_, rej) =>
|
||||
setTimeout(() => rej(new Error("late")), 25)
|
||||
),
|
||||
},
|
||||
]);
|
||||
const result = await chain.detect(ctxBrowser());
|
||||
await new Promise((r) => setTimeout(r, 40));
|
||||
if (result !== DETECTOR_CHAIN_UNRESOLVED) {
|
||||
expect(result.detector).toBe("winner");
|
||||
}
|
||||
});
|
||||
|
||||
it("late null-resolve after settled does not duplicate finish", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const chain = createDetectorChain([
|
||||
{
|
||||
name: "winner",
|
||||
environment: "both",
|
||||
detectAsync: () =>
|
||||
new Promise((r) => setTimeout(() => r("ar-AE"), 5)),
|
||||
},
|
||||
{
|
||||
name: "lateNull",
|
||||
environment: "both",
|
||||
detectAsync: () =>
|
||||
new Promise((r) => setTimeout(() => r(null), 25)),
|
||||
},
|
||||
]);
|
||||
const result = await chain.detect(ctxBrowser());
|
||||
await new Promise((r) => setTimeout(r, 40));
|
||||
if (result !== DETECTOR_CHAIN_UNRESOLVED) {
|
||||
expect(result.detector).toBe("winner");
|
||||
}
|
||||
});
|
||||
|
||||
it("async unsupported with another pending decrements without finishing", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const chain = createDetectorChain([
|
||||
{
|
||||
name: "fastBad",
|
||||
environment: "both",
|
||||
detectAsync: () =>
|
||||
new Promise((r) => setTimeout(() => r("xx-YY"), 5)),
|
||||
},
|
||||
{
|
||||
name: "slowGood",
|
||||
environment: "both",
|
||||
detectAsync: () =>
|
||||
new Promise((r) => setTimeout(() => r("ar-AE"), 25)),
|
||||
},
|
||||
]);
|
||||
const result = await chain.detect(ctxBrowser());
|
||||
if (result !== DETECTOR_CHAIN_UNRESOLVED) {
|
||||
expect(result.detector).toBe("slowGood");
|
||||
}
|
||||
});
|
||||
|
||||
it("all async detectors reject → unresolved", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const chain = createDetectorChain([
|
||||
{
|
||||
name: "a",
|
||||
environment: "both",
|
||||
detectAsync: async () => {
|
||||
throw new Error("a");
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "b",
|
||||
environment: "both",
|
||||
detectAsync: async () => {
|
||||
throw new Error("b");
|
||||
},
|
||||
},
|
||||
]);
|
||||
const result = await chain.detect(ctxBrowser());
|
||||
expect(result).toBe(DETECTOR_CHAIN_UNRESOLVED);
|
||||
});
|
||||
});
|
||||
|
||||
describe("createDetectorChain — environment gating", () => {
|
||||
it("browser-only detector skipped on server context", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
let bCalls = 0;
|
||||
let sCalls = 0;
|
||||
const chain = createDetectorChain([
|
||||
{
|
||||
name: "navigator",
|
||||
environment: "browser",
|
||||
detect: () => {
|
||||
bCalls += 1;
|
||||
return "ar-AE";
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "header",
|
||||
environment: "server",
|
||||
detect: () => {
|
||||
sCalls += 1;
|
||||
return "fr-FR";
|
||||
},
|
||||
},
|
||||
]);
|
||||
const result = await chain.detect(ctxServer());
|
||||
expect(bCalls).toBe(0);
|
||||
expect(sCalls).toBe(1);
|
||||
if (result !== DETECTOR_CHAIN_UNRESOLVED) {
|
||||
expect(result.detector).toBe("header");
|
||||
}
|
||||
});
|
||||
|
||||
it("server-only detector skipped on browser context", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
let sCalls = 0;
|
||||
const chain = createDetectorChain([
|
||||
{
|
||||
name: "header",
|
||||
environment: "server",
|
||||
detect: () => {
|
||||
sCalls += 1;
|
||||
return "fr-FR";
|
||||
},
|
||||
},
|
||||
{ name: "fallback", environment: "both", detect: () => "en-GB" },
|
||||
]);
|
||||
const result = await chain.detect(ctxBrowser());
|
||||
expect(sCalls).toBe(0);
|
||||
if (result !== DETECTOR_CHAIN_UNRESOLVED) {
|
||||
expect(result.detector).toBe("fallback");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("createDetectorChain — exhaustion", () => {
|
||||
it("empty supportedLocales returns sentinel without invoking detectors", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
let calls = 0;
|
||||
const chain = createDetectorChain([
|
||||
{
|
||||
name: "always",
|
||||
environment: "both",
|
||||
detect: () => {
|
||||
calls += 1;
|
||||
return "en-GB";
|
||||
},
|
||||
},
|
||||
]);
|
||||
const result = await chain.detect({
|
||||
supportedLocales: [],
|
||||
fallbackLocale: "en-GB",
|
||||
});
|
||||
expect(result).toBe(DETECTOR_CHAIN_UNRESOLVED);
|
||||
expect(calls).toBe(0);
|
||||
});
|
||||
|
||||
it("DETECTOR_CHAIN_UNRESOLVED returned when chain is empty", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const chain = createDetectorChain([]);
|
||||
const result = await chain.detect(ctxBrowser());
|
||||
expect(result).toBe(DETECTOR_CHAIN_UNRESOLVED);
|
||||
});
|
||||
|
||||
it("DETECTOR_CHAIN_UNRESOLVED returned when all detectors miss", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const chain = createDetectorChain([
|
||||
{ name: "n", environment: "both", detect: () => null },
|
||||
{ name: "na", environment: "both", detectAsync: async () => null },
|
||||
]);
|
||||
const result = await chain.detect(ctxBrowser());
|
||||
expect(result).toBe(DETECTOR_CHAIN_UNRESOLVED);
|
||||
});
|
||||
|
||||
it("throwOnExhausted raises DETECTOR_CHAIN_EXHAUSTED", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const chain = createDetectorChain(
|
||||
[{ name: "n", environment: "both", detect: () => null }],
|
||||
{ throwOnExhausted: true }
|
||||
);
|
||||
await expect(chain.detect(ctxBrowser())).rejects.toMatchObject({
|
||||
code: "I18N.DETECTOR_CHAIN_EXHAUSTED",
|
||||
});
|
||||
});
|
||||
|
||||
it("throwOnExhausted error is an I18nError instance", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const chain = createDetectorChain(
|
||||
[{ name: "n", environment: "both", detect: () => null }],
|
||||
{ throwOnExhausted: true }
|
||||
);
|
||||
try {
|
||||
await chain.detect(ctxBrowser());
|
||||
throw new Error("should have thrown");
|
||||
} catch (err) {
|
||||
expect(err).toBeInstanceOf(I18nError);
|
||||
}
|
||||
});
|
||||
|
||||
it("throwOnExhausted false (default) returns sentinel", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const chain = createDetectorChain(
|
||||
[{ name: "n", environment: "both", detect: () => null }],
|
||||
{ throwOnExhausted: false }
|
||||
);
|
||||
const result = await chain.detect(ctxBrowser());
|
||||
expect(result).toBe(DETECTOR_CHAIN_UNRESOLVED);
|
||||
});
|
||||
|
||||
it("throwOnExhausted on empty supportedLocales also throws", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const chain = createDetectorChain([], { throwOnExhausted: true });
|
||||
await expect(
|
||||
chain.detect({ supportedLocales: [], fallbackLocale: "en-GB" })
|
||||
).rejects.toMatchObject({ code: "I18N.DETECTOR_CHAIN_EXHAUSTED" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("createDetectorChain — telemetry", () => {
|
||||
it("emits i18n.detect span with resolved_locale and winning_detector", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
// Span emission uses no-op tracer when no SDK is registered.
|
||||
// We assert that the chain run completes without error and that
|
||||
// detected_total carries the resolved locale.
|
||||
const chain = createDetectorChain([
|
||||
{ name: "cookie", environment: "both", detect: () => "fr-FR" },
|
||||
]);
|
||||
await chain.detect(ctxBrowser());
|
||||
const state = getDetectorMetricsState();
|
||||
expect(state.detectedTotal).toEqual([
|
||||
{ attributes: { detector: "cookie", locale: "fr-FR" } },
|
||||
]);
|
||||
});
|
||||
|
||||
it("increments i18n.detector.chain.exhausted_total on sentinel return", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const chain = createDetectorChain([
|
||||
{ name: "n", environment: "both", detect: () => null },
|
||||
]);
|
||||
await chain.detect(ctxBrowser());
|
||||
const state = getDetectorMetricsState();
|
||||
expect(state.chainExhaustedTotal.length).toBe(1);
|
||||
});
|
||||
|
||||
it("records duration for every async detector that runs", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const chain = createDetectorChain([
|
||||
{
|
||||
name: "winner",
|
||||
environment: "both",
|
||||
detectAsync: async () => "ar-AE",
|
||||
},
|
||||
{
|
||||
name: "loser",
|
||||
environment: "both",
|
||||
detectAsync: async () => null,
|
||||
},
|
||||
]);
|
||||
await chain.detect(ctxBrowser());
|
||||
const state = getDetectorMetricsState();
|
||||
const detectors = state.durationMs.map((s) => s.attributes.detector);
|
||||
expect(detectors).toContain("winner");
|
||||
expect(detectors).toContain("loser");
|
||||
});
|
||||
|
||||
it("records duration when async rejects", { timeout: 5000 }, async () => {
|
||||
const chain = createDetectorChain([
|
||||
{
|
||||
name: "rejector",
|
||||
environment: "both",
|
||||
detectAsync: async () => {
|
||||
throw new Error("nope");
|
||||
},
|
||||
},
|
||||
]);
|
||||
await chain.detect(ctxBrowser());
|
||||
const state = getDetectorMetricsState();
|
||||
const detectors = state.durationMs.map((s) => s.attributes.detector);
|
||||
expect(detectors).toContain("rejector");
|
||||
});
|
||||
});
|
||||
|
||||
describe("createDetectorChain.cache", () => {
|
||||
it("cache walks every persisting detector that matches the runtime", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const calls: string[] = [];
|
||||
const chain = createDetectorChain([
|
||||
{ name: "qs", environment: "both", detect: () => null },
|
||||
{
|
||||
name: "cookie",
|
||||
environment: "both",
|
||||
detect: () => null,
|
||||
cache: (locale) => {
|
||||
calls.push(`cookie:${locale}`);
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "ls",
|
||||
environment: "browser",
|
||||
detect: () => null,
|
||||
cache: async (locale) => {
|
||||
calls.push(`ls:${locale}`);
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "header",
|
||||
environment: "server",
|
||||
detect: () => null,
|
||||
cache: () => {
|
||||
calls.push("header");
|
||||
},
|
||||
},
|
||||
]);
|
||||
await chain.cache("fr-FR", ctxBrowser());
|
||||
expect(calls.sort()).toEqual(["cookie:fr-FR", "ls:fr-FR"]);
|
||||
});
|
||||
|
||||
it("cache is a no-op when no persisting detectors registered", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const chain = createDetectorChain([
|
||||
{ name: "n", environment: "both", detect: () => "fr-FR" },
|
||||
]);
|
||||
await expect(
|
||||
chain.cache("zh-CN", ctxBrowser())
|
||||
).resolves.toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("createDetectorChain — detectors getter", () => {
|
||||
it("returns configured detectors in registration order", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const a: LocaleDetector = {
|
||||
name: "a",
|
||||
environment: "both",
|
||||
detect: () => null,
|
||||
};
|
||||
const b: LocaleDetector = {
|
||||
name: "b",
|
||||
environment: "both",
|
||||
detect: () => null,
|
||||
};
|
||||
const chain = createDetectorChain([a, b]);
|
||||
expect(chain.detectors.map((d) => d.name)).toEqual(["a", "b"]);
|
||||
});
|
||||
|
||||
it("input array mutation does not affect chain", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const detectors: LocaleDetector[] = [
|
||||
{ name: "a", environment: "both", detect: () => null },
|
||||
];
|
||||
const chain = createDetectorChain(detectors);
|
||||
detectors.push({
|
||||
name: "b",
|
||||
environment: "both",
|
||||
detect: () => null,
|
||||
});
|
||||
expect(chain.detectors.map((d) => d.name)).toEqual(["a"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("createDetectorChain — injected negotiate", () => {
|
||||
it("custom negotiate is used in place of bcp47Negotiate", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const chain = createDetectorChain(
|
||||
[{ name: "qs", environment: "both", detect: () => "anything" }],
|
||||
{
|
||||
negotiate: () => "fr-FR",
|
||||
}
|
||||
);
|
||||
const result = await chain.detect(ctxBrowser());
|
||||
if (result === DETECTOR_CHAIN_UNRESOLVED) {
|
||||
throw new Error("unexpected unresolved");
|
||||
}
|
||||
expect(result.locale).toBe("fr-FR");
|
||||
});
|
||||
|
||||
it("custom negotiate returning null falls through", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const chain = createDetectorChain(
|
||||
[
|
||||
{ name: "first", environment: "both", detect: () => "x" },
|
||||
{ name: "ok", environment: "both", detect: () => "fr-FR" },
|
||||
],
|
||||
{
|
||||
negotiate: (raw, supported) =>
|
||||
supported.includes(raw) ? raw : null,
|
||||
}
|
||||
);
|
||||
const result = await chain.detect(ctxBrowser());
|
||||
if (result === DETECTOR_CHAIN_UNRESOLVED) {
|
||||
throw new Error("unexpected unresolved");
|
||||
}
|
||||
expect(result.detector).toBe("ok");
|
||||
});
|
||||
});
|
||||
240
packages/localization/i18n/i18n-detector/src/chain/chain.ts
Normal file
240
packages/localization/i18n/i18n-detector/src/chain/chain.ts
Normal file
@@ -0,0 +1,240 @@
|
||||
import { I18N_CODES, I18nError } from "@suaveplan/i18n-shared";
|
||||
import type {
|
||||
DetectorChainResult,
|
||||
DetectorContext,
|
||||
LocaleDetector,
|
||||
} from "../detector.types.js";
|
||||
import {
|
||||
getDetectorMetrics,
|
||||
recordChainExhausted,
|
||||
recordDetectorDuration,
|
||||
recordDetectorMatch,
|
||||
} from "../observability/metrics.js";
|
||||
import { bcp47Negotiate } from "./bcp47-negotiate.js";
|
||||
import type { CreateDetectorChainOptions, NegotiateFn } from "./chain.types.js";
|
||||
|
||||
/** Sentinel returned to indicate "no detector matched". */
|
||||
export const DETECTOR_CHAIN_UNRESOLVED = Symbol(
|
||||
"@suaveplan/i18n-detector/chain/unresolved"
|
||||
);
|
||||
|
||||
export type DetectorChainUnresolved = typeof DETECTOR_CHAIN_UNRESOLVED;
|
||||
|
||||
export interface DetectorChain {
|
||||
/**
|
||||
* Run the chain and return the first detector that yields a value
|
||||
* negotiable to a supported locale, or the unresolved sentinel
|
||||
* when every detector returns `null`. When the chain was built
|
||||
* with `throwOnExhausted: true`, an exhausted run throws
|
||||
* `I18nError(I18N.DETECTOR_CHAIN_EXHAUSTED)` instead of
|
||||
* returning the sentinel.
|
||||
*/
|
||||
detect(
|
||||
ctx: DetectorContext
|
||||
): Promise<DetectorChainResult | DetectorChainUnresolved>;
|
||||
|
||||
/**
|
||||
* Walk every persisting detector (every detector whose `cache()`
|
||||
* method is defined) and ask it to persist the supplied locale.
|
||||
*/
|
||||
cache(locale: string, ctx: DetectorContext): Promise<void>;
|
||||
|
||||
/** The list of detectors held by the chain (read-only view). */
|
||||
readonly detectors: readonly LocaleDetector[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a `LocaleDetector` chain runner.
|
||||
*
|
||||
* The chain runs synchronous detectors first, in registration order;
|
||||
* the first non-null value that negotiates to a supported locale
|
||||
* wins. If no sync detector matches, every async detector is run in
|
||||
* parallel and the first to resolve to a supported locale wins.
|
||||
* Detectors whose `environment` does not match the runtime
|
||||
* environment hint passed in `ctx` are skipped (browser detectors
|
||||
* skipped on server, server-only detectors skipped on browser).
|
||||
*
|
||||
* Telemetry: every chain run starts a `i18n.detect` span; each match
|
||||
* increments `i18n.detector.match_total{detector}` AND
|
||||
* `i18n.detector.detected_total{detector,locale}`; every async
|
||||
* detector call records `i18n.detector.duration_ms{detector}`. Every
|
||||
* exhausted run increments `i18n.detector.chain.exhausted_total`.
|
||||
*/
|
||||
export function createDetectorChain(
|
||||
detectors: readonly LocaleDetector[],
|
||||
options: CreateDetectorChainOptions = {}
|
||||
): DetectorChain {
|
||||
const list = [...detectors];
|
||||
const negotiate: NegotiateFn = options.negotiate ?? bcp47Negotiate;
|
||||
const throwOnExhausted = options.throwOnExhausted === true;
|
||||
|
||||
return {
|
||||
detectors: list,
|
||||
async detect(ctx) {
|
||||
const supported = ctx.supportedLocales;
|
||||
if (supported.length === 0) {
|
||||
return finishExhausted(throwOnExhausted);
|
||||
}
|
||||
const tracer = getDetectorMetrics().tracer;
|
||||
const span = tracer.startSpan("i18n.detect");
|
||||
try {
|
||||
const syncResult = runSyncChain(list, ctx, negotiate);
|
||||
if (syncResult !== null) {
|
||||
finishSpan(span, syncResult);
|
||||
return syncResult;
|
||||
}
|
||||
const asyncResult = await runAsyncChain(list, ctx, negotiate);
|
||||
if (asyncResult !== null) {
|
||||
finishSpan(span, asyncResult);
|
||||
return asyncResult;
|
||||
}
|
||||
span.setAttribute("resolved_locale", "");
|
||||
span.setAttribute("winning_detector", "");
|
||||
return finishExhausted(throwOnExhausted);
|
||||
} finally {
|
||||
span.end();
|
||||
}
|
||||
},
|
||||
async cache(locale, ctx) {
|
||||
const work: Array<Promise<void> | void> = [];
|
||||
for (const detector of list) {
|
||||
if (!detector.cache) continue;
|
||||
if (!isEligibleForRuntime(detector, ctx)) continue;
|
||||
work.push(detector.cache(locale, ctx));
|
||||
}
|
||||
await Promise.all(work.map((value) => Promise.resolve(value)));
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function finishExhausted(throwOnExhausted: boolean): DetectorChainUnresolved {
|
||||
recordChainExhausted();
|
||||
if (throwOnExhausted) {
|
||||
throw new I18nError(
|
||||
I18N_CODES.DETECTOR_CHAIN_EXHAUSTED,
|
||||
"locale-detector chain exhausted: every detector returned null"
|
||||
);
|
||||
}
|
||||
return DETECTOR_CHAIN_UNRESOLVED;
|
||||
}
|
||||
|
||||
function finishSpan(
|
||||
span: ReturnType<
|
||||
ReturnType<typeof getDetectorMetrics>["tracer"]["startSpan"]
|
||||
>,
|
||||
result: DetectorChainResult
|
||||
): void {
|
||||
span.setAttribute("resolved_locale", result.locale);
|
||||
span.setAttribute("winning_detector", result.detector);
|
||||
}
|
||||
|
||||
function runSyncChain(
|
||||
detectors: readonly LocaleDetector[],
|
||||
ctx: DetectorContext,
|
||||
negotiate: NegotiateFn
|
||||
): DetectorChainResult | null {
|
||||
for (const detector of detectors) {
|
||||
if (!detector.detect) continue;
|
||||
if (!isEligibleForRuntime(detector, ctx)) continue;
|
||||
const raw = detector.detect(ctx);
|
||||
if (raw === null || raw === undefined) continue;
|
||||
const negotiated = negotiateOrNull(raw, ctx, negotiate);
|
||||
if (negotiated === null) continue;
|
||||
recordDetectorMatch(detector.name, negotiated);
|
||||
return { locale: negotiated, detector: detector.name, rawValue: raw };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async function runAsyncChain(
|
||||
detectors: readonly LocaleDetector[],
|
||||
ctx: DetectorContext,
|
||||
negotiate: NegotiateFn
|
||||
): Promise<DetectorChainResult | null> {
|
||||
const asyncDetectors = detectors.filter(
|
||||
(d) =>
|
||||
typeof d.detectAsync === "function" && isEligibleForRuntime(d, ctx)
|
||||
);
|
||||
if (asyncDetectors.length === 0) return null;
|
||||
|
||||
return new Promise<DetectorChainResult | null>((resolve) => {
|
||||
let pending = asyncDetectors.length;
|
||||
let settled = false;
|
||||
|
||||
const win = (value: DetectorChainResult) => {
|
||||
settled = true;
|
||||
resolve(value);
|
||||
};
|
||||
|
||||
const lose = () => {
|
||||
pending -= 1;
|
||||
if (pending === 0 && !settled) {
|
||||
settled = true;
|
||||
resolve(null);
|
||||
}
|
||||
};
|
||||
|
||||
for (const detector of asyncDetectors) {
|
||||
const start = performance.now();
|
||||
// detectAsync presence is guaranteed by the filter above.
|
||||
const promise = (
|
||||
detector.detectAsync as NonNullable<
|
||||
LocaleDetector["detectAsync"]
|
||||
>
|
||||
)(ctx);
|
||||
promise.then(
|
||||
(raw) => {
|
||||
recordDetectorDuration(
|
||||
detector.name,
|
||||
performance.now() - start
|
||||
);
|
||||
if (settled) {
|
||||
lose();
|
||||
return;
|
||||
}
|
||||
if (raw === null || raw === undefined) {
|
||||
lose();
|
||||
return;
|
||||
}
|
||||
const negotiated = negotiateOrNull(raw, ctx, negotiate);
|
||||
if (negotiated === null) {
|
||||
lose();
|
||||
return;
|
||||
}
|
||||
recordDetectorMatch(detector.name, negotiated);
|
||||
win({
|
||||
locale: negotiated,
|
||||
detector: detector.name,
|
||||
rawValue: raw,
|
||||
});
|
||||
},
|
||||
() => {
|
||||
recordDetectorDuration(
|
||||
detector.name,
|
||||
performance.now() - start
|
||||
);
|
||||
lose();
|
||||
}
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function isEligibleForRuntime(
|
||||
detector: LocaleDetector,
|
||||
ctx: DetectorContext
|
||||
): boolean {
|
||||
if (detector.environment === "both") return true;
|
||||
const isServer = ctx.request !== undefined;
|
||||
if (detector.environment === "server") return isServer;
|
||||
return !isServer;
|
||||
}
|
||||
|
||||
function negotiateOrNull(
|
||||
raw: string,
|
||||
ctx: DetectorContext,
|
||||
negotiate: NegotiateFn
|
||||
): string | null {
|
||||
if (typeof raw !== "string" || raw.trim() === "") return null;
|
||||
return negotiate(raw.trim(), ctx.supportedLocales, ctx.fallbackLocale);
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
/**
|
||||
* Negotiator function signature accepted by `createDetectorChain` to
|
||||
* map a detector's raw output onto a supported locale.
|
||||
*
|
||||
* Implementations SHOULD return:
|
||||
*
|
||||
* - the supplied raw locale unchanged when it is already supported,
|
||||
* - a fallback supported locale if the raw value can be matched via
|
||||
* short-code expansion or language-subtag matching,
|
||||
* - `null` when no match exists, so the chain can advance to the next
|
||||
* detector rather than committing to the configured fallback
|
||||
* prematurely.
|
||||
*/
|
||||
export type NegotiateFn = (
|
||||
raw: string,
|
||||
supported: readonly string[],
|
||||
fallback: string
|
||||
) => string | null;
|
||||
|
||||
/**
|
||||
* Options accepted by `createDetectorChain`.
|
||||
*
|
||||
* Both fields are optional. The defaults match the v1 chain semantics:
|
||||
*
|
||||
* - `negotiate` defaults to `bcp47Negotiate` (short-code expansion +
|
||||
* language-subtag fallback).
|
||||
* - `throwOnExhausted` defaults to `false`, returning the unresolved
|
||||
* sentinel when every detector misses. Setting it to `true` opts
|
||||
* into a typed `I18nError(I18N.DETECTOR_CHAIN_EXHAUSTED)` throw,
|
||||
* which server-side renderers map to a 4xx response.
|
||||
*/
|
||||
export interface CreateDetectorChainOptions {
|
||||
readonly negotiate?: NegotiateFn;
|
||||
readonly throwOnExhausted?: boolean;
|
||||
}
|
||||
11
packages/localization/i18n/i18n-detector/src/chain/index.ts
Normal file
11
packages/localization/i18n/i18n-detector/src/chain/index.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
export { bcp47Negotiate } from "./bcp47-negotiate.js";
|
||||
export {
|
||||
createDetectorChain,
|
||||
DETECTOR_CHAIN_UNRESOLVED,
|
||||
type DetectorChain,
|
||||
type DetectorChainUnresolved,
|
||||
} from "./chain.js";
|
||||
export type {
|
||||
CreateDetectorChainOptions,
|
||||
NegotiateFn,
|
||||
} from "./chain.types.js";
|
||||
@@ -0,0 +1,87 @@
|
||||
# `default-chain.ts` — canonical detector-chain factory
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
`defaultDetectorChain(options)` builds the canonical 8-or-9 entry
|
||||
detector chain consumed by `<TranslationProvider>` and
|
||||
`useLocaleDetection` (and any other chain consumer that wants a
|
||||
sensible default without enumerating every detector). The factory
|
||||
returns a fresh `LocaleDetector[]` on every call so callers can
|
||||
mutate or filter the result without affecting subsequent
|
||||
invocations.
|
||||
|
||||
The order is deliberate: explicit user signals (querystring,
|
||||
backend preference, cookie, localStorage) come first, followed by
|
||||
shell-rendered hints (htmlLang), browser preferences
|
||||
(acceptLanguage, navigator), geolocation (cloudflareIpGeo), and
|
||||
finally the unconditional fallback.
|
||||
|
||||
## Features
|
||||
|
||||
- 8-entry chain when `userPreferenceEndpoint` is omitted; 9-entry
|
||||
chain when it is supplied. The user-preference detector slots
|
||||
in second so authenticated users' saved preferences beat every
|
||||
device-bound signal.
|
||||
- Fresh array per call — callers can `.filter` / `.splice` /
|
||||
`.concat` without affecting subsequent invocations.
|
||||
- Fallback always last; the factory wires
|
||||
`fallbackDetector(options.fallback)` as the terminal entry.
|
||||
- Every detector is constructed with default options — consumers
|
||||
that need custom names / paths / endpoints assemble their own
|
||||
chain.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import {
|
||||
createDetectorChain,
|
||||
defaultDetectorChain,
|
||||
} from "@suaveplan/i18n-detector";
|
||||
|
||||
const chain = createDetectorChain(
|
||||
defaultDetectorChain({ fallback: "en-GB" })
|
||||
);
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import {
|
||||
createDetectorChain,
|
||||
defaultDetectorChain,
|
||||
} from "@suaveplan/i18n-detector";
|
||||
|
||||
const chain = createDetectorChain(
|
||||
defaultDetectorChain({
|
||||
fallback: "en-GB",
|
||||
userPreferenceEndpoint: "/api/me/locale",
|
||||
}),
|
||||
{ throwOnExhausted: true }
|
||||
);
|
||||
|
||||
// Or filter to remove a detector that does not fit the deployment.
|
||||
const detectors = defaultDetectorChain({ fallback: "en-GB" }).filter(
|
||||
(d) => d.name !== "cloudflareIpGeo"
|
||||
);
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `defaultDetectorChain(options): LocaleDetector[]` — fresh array
|
||||
per call.
|
||||
- `DefaultDetectorChainOptions.fallback: string` — required.
|
||||
- `DefaultDetectorChainOptions.userPreferenceEndpoint?: string` —
|
||||
enables the user-preference detector when set.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- The fallback is constructed via `fallbackDetector(...)` which
|
||||
validates the locale at chain-build time, so a misconfigured
|
||||
fallback surfaces immediately rather than at the first request.
|
||||
- The factory makes no assumptions about cookie name, storage
|
||||
key, querystring param name, or geolocation header. Consumers
|
||||
that need custom configuration assemble their own chain by
|
||||
importing each detector directly.
|
||||
@@ -0,0 +1,87 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { defaultDetectorChain } from "./default-chain.js";
|
||||
|
||||
describe("defaultDetectorChain", () => {
|
||||
it("order matches spec without userPreference", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const chain = defaultDetectorChain({ fallback: "en-GB" });
|
||||
expect(chain.map((d) => d.name)).toEqual([
|
||||
"querystring",
|
||||
"cookie",
|
||||
"localStorage",
|
||||
"htmlLang",
|
||||
"acceptLanguage",
|
||||
"navigator",
|
||||
"cloudflareIpGeo",
|
||||
"fallback",
|
||||
]);
|
||||
});
|
||||
|
||||
it("userPreference inserted second when endpoint set", {
|
||||
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("empty userPreferenceEndpoint string skips userPreference", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const chain = defaultDetectorChain({
|
||||
fallback: "en-GB",
|
||||
userPreferenceEndpoint: "",
|
||||
});
|
||||
expect(chain.find((d) => d.name === "userPreference")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("fresh array per call", { timeout: 5000 }, () => {
|
||||
const a = defaultDetectorChain({ fallback: "en-GB" });
|
||||
const b = defaultDetectorChain({ fallback: "en-GB" });
|
||||
expect(a).not.toBe(b);
|
||||
});
|
||||
|
||||
it("fallback always last", { timeout: 5000 }, () => {
|
||||
for (const opts of [
|
||||
{ fallback: "en-GB" },
|
||||
{ fallback: "en-GB", userPreferenceEndpoint: "/u" },
|
||||
]) {
|
||||
const chain = defaultDetectorChain(opts);
|
||||
expect(chain[chain.length - 1]?.name).toBe("fallback");
|
||||
}
|
||||
});
|
||||
|
||||
it("environment values match the per-detector contract", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const chain = defaultDetectorChain({
|
||||
fallback: "en-GB",
|
||||
userPreferenceEndpoint: "/u",
|
||||
});
|
||||
const map = Object.fromEntries(
|
||||
chain.map((d) => [d.name, d.environment])
|
||||
);
|
||||
expect(map.querystring).toBe("both");
|
||||
expect(map.userPreference).toBe("both");
|
||||
expect(map.cookie).toBe("both");
|
||||
expect(map.localStorage).toBe("browser");
|
||||
expect(map.htmlLang).toBe("browser");
|
||||
expect(map.acceptLanguage).toBe("server");
|
||||
expect(map.navigator).toBe("browser");
|
||||
expect(map.cloudflareIpGeo).toBe("server");
|
||||
expect(map.fallback).toBe("both");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,55 @@
|
||||
import type { LocaleDetector } from "../detector.types.js";
|
||||
import { acceptLanguageDetector } from "../detectors/accept-language/accept-language.js";
|
||||
import { cloudflareIpGeoDetector } from "../detectors/cloudflare-ip-geo/cloudflare-ip-geo.js";
|
||||
import { cookieDetector } from "../detectors/cookie/cookie.js";
|
||||
import { fallbackDetector } from "../detectors/fallback/fallback.js";
|
||||
import { htmlLangDetector } from "../detectors/html-lang/html-lang.js";
|
||||
import { localStorageDetector } from "../detectors/local-storage/local-storage.js";
|
||||
import { navigatorDetector } from "../detectors/navigator/navigator.js";
|
||||
import { querystringDetector } from "../detectors/querystring/querystring.js";
|
||||
import { userPreferenceDetector } from "../detectors/user-preference/user-preference.js";
|
||||
import type { DefaultDetectorChainOptions } from "./default-chain.types.js";
|
||||
|
||||
/**
|
||||
* Build the canonical 9-entry (or 8-entry) detector chain consumed
|
||||
* by `<TranslationProvider>` and `useLocaleDetection`.
|
||||
*
|
||||
* Order (per spec "Default chain order"):
|
||||
*
|
||||
* 1. `querystring`
|
||||
* 2. `userPreference` — only when `opts.userPreferenceEndpoint` is set
|
||||
* 3. `cookie`
|
||||
* 4. `localStorage`
|
||||
* 5. `htmlLang`
|
||||
* 6. `acceptLanguage`
|
||||
* 7. `navigator`
|
||||
* 8. `cloudflareIpGeo`
|
||||
* 9. `fallback`
|
||||
*
|
||||
* The chain is returned as a fresh array on every call so callers
|
||||
* can mutate or filter the result without affecting subsequent
|
||||
* invocations.
|
||||
*/
|
||||
export function defaultDetectorChain(
|
||||
options: DefaultDetectorChainOptions
|
||||
): LocaleDetector[] {
|
||||
const detectors: LocaleDetector[] = [querystringDetector()];
|
||||
if (
|
||||
typeof options.userPreferenceEndpoint === "string" &&
|
||||
options.userPreferenceEndpoint !== ""
|
||||
) {
|
||||
detectors.push(
|
||||
userPreferenceDetector({
|
||||
endpoint: options.userPreferenceEndpoint,
|
||||
})
|
||||
);
|
||||
}
|
||||
detectors.push(cookieDetector());
|
||||
detectors.push(localStorageDetector());
|
||||
detectors.push(htmlLangDetector());
|
||||
detectors.push(acceptLanguageDetector());
|
||||
detectors.push(navigatorDetector());
|
||||
detectors.push(cloudflareIpGeoDetector());
|
||||
detectors.push(fallbackDetector(options.fallback));
|
||||
return detectors;
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
/**
|
||||
* 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).
|
||||
*/
|
||||
export interface DefaultDetectorChainOptions {
|
||||
readonly fallback: string;
|
||||
readonly userPreferenceEndpoint?: string;
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export { defaultDetectorChain } from "./default-chain.js";
|
||||
export type { DefaultDetectorChainOptions } from "./default-chain.types.js";
|
||||
@@ -0,0 +1,90 @@
|
||||
/**
|
||||
* Locale-detector contract — universal core types shared by every
|
||||
* detector implementation in this package and by every consumer of
|
||||
* `createDetectorChain`.
|
||||
*
|
||||
* A `LocaleDetector` is a small object describing *one* way to
|
||||
* discover the user's preferred locale (querystring, cookie, header,
|
||||
* geo lookup …). The detector surface is intentionally narrow: it
|
||||
* returns a raw locale string (or `null` for "no preference") and
|
||||
* optionally persists a chosen value back to its underlying source.
|
||||
*
|
||||
* Detectors are composed via `createDetectorChain([...])` (see
|
||||
* `chain/`). Sync detectors run first in chain order; the first
|
||||
* non-null result wins. If no sync detector matches, the chain runs
|
||||
* every `detectAsync` in parallel and the first to resolve to a
|
||||
* supported locale wins.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Storage-like interface expected by detectors that persist choices.
|
||||
* Mirrors the relevant subset of `@suaveplan/types`'s `StorageLike`
|
||||
* and `Web Storage` so both browser `localStorage` and an in-memory
|
||||
* test fake satisfy the same shape.
|
||||
*/
|
||||
export interface DetectorStorageLike {
|
||||
getItem(key: string): string | null;
|
||||
setItem(key: string, value: string): void;
|
||||
removeItem(key: string): void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-call execution context handed to every detector. Sources of
|
||||
* input (request headers, document, storage, fetch) are all optional
|
||||
* so the same detector can run on browser and server contexts.
|
||||
*/
|
||||
export interface DetectorContext {
|
||||
/** Server-side request shape (null on browser). */
|
||||
readonly request?: {
|
||||
readonly headers: Headers;
|
||||
readonly url: URL;
|
||||
};
|
||||
/** Headers writer used by persisting detectors on the server (Set-Cookie). */
|
||||
readonly responseHeaders?: Headers;
|
||||
/** Browser document handle. */
|
||||
readonly document?: Document;
|
||||
/** Browser-side storage for `localStorage`-backed detectors. */
|
||||
readonly storage?: DetectorStorageLike;
|
||||
/** Injectable `fetch` so async detectors are testable. */
|
||||
readonly fetch?: typeof fetch;
|
||||
/** Cancellation signal threaded into async detectors. */
|
||||
readonly signal?: AbortSignal;
|
||||
/** The full set of locales the application supports. */
|
||||
readonly supportedLocales: readonly string[];
|
||||
/** Final fallback when nothing matched. */
|
||||
readonly fallbackLocale: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Discriminator for *where* a detector can run. Sync detectors with
|
||||
* `environment: 'server'` are skipped when running in the browser
|
||||
* pass and vice-versa; `'both'` runs everywhere.
|
||||
*/
|
||||
export type DetectorEnvironment = "browser" | "server" | "both";
|
||||
|
||||
export interface LocaleDetector {
|
||||
/** Stable detector identifier — appears in OTel labels and chain results. */
|
||||
readonly name: string;
|
||||
/** Runtime environments where this detector is active. */
|
||||
readonly environment: DetectorEnvironment;
|
||||
/** Synchronous detection. Return `null` to fall through to the next detector. */
|
||||
detect?(ctx: DetectorContext): string | null;
|
||||
/** Asynchronous detection (network, geo lookup). Return `null` to fall through. */
|
||||
detectAsync?(ctx: DetectorContext): Promise<string | null>;
|
||||
/** Persist a chosen locale back to this detector's source. */
|
||||
cache?(locale: string, ctx: DetectorContext): void | Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Result returned by `createDetectorChain(...).detect(ctx)` describing
|
||||
* which detector won and what locale was finally chosen
|
||||
* (post-negotiation).
|
||||
*/
|
||||
export interface DetectorChainResult {
|
||||
/** The negotiated locale (always one of `ctx.supportedLocales`). */
|
||||
readonly locale: string;
|
||||
/** Name of the detector whose value won the chain. */
|
||||
readonly detector: string;
|
||||
/** Pre-negotiation raw detector output (useful for telemetry). */
|
||||
readonly rawValue: string | null;
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
# `accept-language.ts` — `Accept-Language` HTTP header detector
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
`acceptLanguageDetector()` and the standalone
|
||||
`parseAcceptLanguage(header)` parser implement RFC 9110 §5.3.5
|
||||
parsing of the inbound `Accept-Language` request header. The
|
||||
detector returns the highest-q entry; `parseAcceptLanguage` is
|
||||
exported for advanced consumers building custom detectors layered
|
||||
on top of this one (e.g. honouring a specific browser's quirks, or
|
||||
combining the header with a user-agent classifier).
|
||||
|
||||
The detector is `environment: "server"` because the header is
|
||||
only meaningful for the inbound request — SSR and Edge runtimes
|
||||
consume it; browsers do not see their own outbound headers. Any
|
||||
detector chain running in either tier benefits from including
|
||||
this entry behind cookie/localStorage so first-time visitors fall
|
||||
through to a sensible default without configuration.
|
||||
|
||||
## Features
|
||||
|
||||
- RFC 9110 §5.3.5 q-value parsing with stable sort (insertion
|
||||
order preserved on ties).
|
||||
- Wildcard `*` tokens skipped — they carry no information that
|
||||
can be negotiated.
|
||||
- Malformed q values, q values out of range `[0, 1]`, and
|
||||
unparseable parameters skipped per the RFC's "be liberal in
|
||||
what you accept" guidance.
|
||||
- Empty headers and missing `accept-language` return `null`.
|
||||
- `environment: "server"` — automatic browser skip via the chain
|
||||
runner.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { acceptLanguageDetector } from "@suaveplan/i18n-detector/detectors/accept-language";
|
||||
|
||||
const detector = acceptLanguageDetector();
|
||||
const locale = detector.detect?.({
|
||||
request: {
|
||||
headers: new Headers({
|
||||
"accept-language": "en;q=0.5, fr;q=0.9, de",
|
||||
}),
|
||||
url: new URL("https://example.com/"),
|
||||
},
|
||||
supportedLocales: ["en-GB", "fr-FR", "de-DE"],
|
||||
fallbackLocale: "en-GB",
|
||||
});
|
||||
// locale === "de" (default q=1)
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { parseAcceptLanguage } from "@suaveplan/i18n-detector/detectors/accept-language";
|
||||
|
||||
const entries = parseAcceptLanguage(
|
||||
"fr-FR;q=0.9, fr;q=0.8, en-GB;q=0.7, en;q=0.6"
|
||||
);
|
||||
// entries: [
|
||||
// { locale: "fr-FR", q: 0.9 },
|
||||
// { locale: "fr", q: 0.8 },
|
||||
// { locale: "en-GB", q: 0.7 },
|
||||
// { locale: "en", q: 0.6 },
|
||||
// ]
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `acceptLanguageDetector(): LocaleDetector` — `environment: "server"`.
|
||||
- `parseAcceptLanguage(header: string): AcceptLanguageEntry[]`.
|
||||
- `AcceptLanguageEntry = { locale: string; q: number }`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- The sort is implemented by mapping `(entry, index)` pairs and
|
||||
comparing `(b.q - a.q) || (a.index - b.index)` so ties preserve
|
||||
insertion order — `Array.prototype.sort` is not guaranteed
|
||||
stable on every runtime ECMA spec actively supports.
|
||||
- Default q value is 1 when no `q=` parameter appears, per the
|
||||
RFC.
|
||||
- `q=2.0` and other out-of-range values cause the entire entry to
|
||||
be discarded (the parser refuses to "fix" the value to 1 or 0).
|
||||
@@ -0,0 +1,129 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import type { DetectorContext } from "../../detector.types.js";
|
||||
import {
|
||||
acceptLanguageDetector,
|
||||
parseAcceptLanguage,
|
||||
} from "./accept-language.js";
|
||||
|
||||
const SUPPORTED = ["en-GB", "fr-FR", "de-DE"] as const;
|
||||
|
||||
function ctx(headers: HeadersInit): DetectorContext {
|
||||
return {
|
||||
request: {
|
||||
headers: new Headers(headers),
|
||||
url: new URL("https://example.com/"),
|
||||
},
|
||||
supportedLocales: SUPPORTED,
|
||||
fallbackLocale: "en-GB",
|
||||
};
|
||||
}
|
||||
|
||||
describe("acceptLanguageDetector", () => {
|
||||
it("returns highest-q entry", { timeout: 5000 }, () => {
|
||||
const detector = acceptLanguageDetector();
|
||||
expect(
|
||||
detector.detect?.(
|
||||
ctx({ "accept-language": "en;q=0.5, fr;q=0.9, de" })
|
||||
)
|
||||
).toBe("de");
|
||||
});
|
||||
|
||||
it("missing header returns null", { timeout: 5000 }, () => {
|
||||
const detector = acceptLanguageDetector();
|
||||
expect(detector.detect?.(ctx({}))).toBeNull();
|
||||
});
|
||||
|
||||
it("empty header returns null", { timeout: 5000 }, () => {
|
||||
const detector = acceptLanguageDetector();
|
||||
expect(detector.detect?.(ctx({ "accept-language": "" }))).toBeNull();
|
||||
});
|
||||
|
||||
it("whitespace header returns null", { timeout: 5000 }, () => {
|
||||
const detector = acceptLanguageDetector();
|
||||
expect(detector.detect?.(ctx({ "accept-language": " " }))).toBeNull();
|
||||
});
|
||||
|
||||
it("only wildcard returns null", { timeout: 5000 }, () => {
|
||||
const detector = acceptLanguageDetector();
|
||||
expect(
|
||||
detector.detect?.(ctx({ "accept-language": "*;q=0.5" }))
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("environment is 'server'", { timeout: 5000 }, () => {
|
||||
expect(acceptLanguageDetector().environment).toBe("server");
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseAcceptLanguage", () => {
|
||||
it("stable sort on q ties", { timeout: 5000 }, () => {
|
||||
const result = parseAcceptLanguage("fr;q=0.8, de;q=0.8");
|
||||
expect(result[0]?.locale).toBe("fr");
|
||||
expect(result[1]?.locale).toBe("de");
|
||||
});
|
||||
|
||||
it("wildcard skipped", { timeout: 5000 }, () => {
|
||||
const result = parseAcceptLanguage("*;q=0.9, en;q=0.5");
|
||||
expect(result.length).toBe(1);
|
||||
expect(result[0]?.locale).toBe("en");
|
||||
});
|
||||
|
||||
it("malformed q skipped", { timeout: 5000 }, () => {
|
||||
const result = parseAcceptLanguage("fr;q=not-a-number, en;q=0.5");
|
||||
expect(result.map((e) => e.locale)).toEqual(["en"]);
|
||||
});
|
||||
|
||||
it("q out of range skipped (high)", { timeout: 5000 }, () => {
|
||||
const result = parseAcceptLanguage("en;q=2.0, fr;q=0.5");
|
||||
expect(result.map((e) => e.locale)).toEqual(["fr"]);
|
||||
});
|
||||
|
||||
it("q out of range skipped (negative)", { timeout: 5000 }, () => {
|
||||
const result = parseAcceptLanguage("en;q=-0.1, fr;q=0.5");
|
||||
expect(result.map((e) => e.locale)).toEqual(["fr"]);
|
||||
});
|
||||
|
||||
it("missing q defaults to 1", { timeout: 5000 }, () => {
|
||||
const result = parseAcceptLanguage("fr, en;q=0.5");
|
||||
expect(result[0]?.locale).toBe("fr");
|
||||
expect(result[0]?.q).toBe(1);
|
||||
});
|
||||
|
||||
it("empty segment skipped", { timeout: 5000 }, () => {
|
||||
const result = parseAcceptLanguage(", fr, ,en;q=0.5,,");
|
||||
expect(result.map((e) => e.locale)).toEqual(["fr", "en"]);
|
||||
});
|
||||
|
||||
it("parameter without = skipped", { timeout: 5000 }, () => {
|
||||
const result = parseAcceptLanguage("fr;weird, en");
|
||||
expect(result.length).toBe(2);
|
||||
// 'weird' has no = so it's ignored; q stays default 1.
|
||||
});
|
||||
|
||||
it("parameter with key other than q skipped", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const result = parseAcceptLanguage("fr;extra=foo, en;q=0.5");
|
||||
expect(result[0]?.q).toBe(1);
|
||||
expect(result[0]?.locale).toBe("fr");
|
||||
});
|
||||
|
||||
it("empty parameter inside semicolons skipped", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const result = parseAcceptLanguage("fr;;q=0.7");
|
||||
expect(result[0]?.q).toBe(0.7);
|
||||
});
|
||||
|
||||
it("empty tag part skipped", { timeout: 5000 }, () => {
|
||||
const result = parseAcceptLanguage("; q=0.5, fr");
|
||||
expect(result.map((e) => e.locale)).toEqual(["fr"]);
|
||||
});
|
||||
|
||||
it("uppercase Q parameter respected (case-insensitive key)", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const result = parseAcceptLanguage("fr;Q=0.7");
|
||||
expect(result[0]?.q).toBe(0.7);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,81 @@
|
||||
import type { LocaleDetector } from "../../detector.types.js";
|
||||
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 the
|
||||
* configured `negotiate` 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);
|
||||
}
|
||||
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,87 @@
|
||||
# `cloudflare-ip-geo.ts` — Cloudflare IP-geo detector
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
`cloudflareIpGeoDetector(options?)` builds a `LocaleDetector` that
|
||||
maps the Cloudflare `cf-ipcountry` header (ISO 3166-1 alpha-2) to
|
||||
a BCP-47 locale. It is the lowest-precedence detector in the
|
||||
canonical chain — only consulted when no querystring, cookie,
|
||||
storage, or browser preference signal exists — because country
|
||||
geolocation is at best a coarse approximation of language
|
||||
preference (the UK and India both speak English; Switzerland has
|
||||
four official languages).
|
||||
|
||||
Resolution order, per ISO code:
|
||||
|
||||
1. `options.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.
|
||||
|
||||
## Features
|
||||
|
||||
- Default header `"cf-ipcountry"`, configurable for non-Cloudflare
|
||||
edge providers.
|
||||
- Per-country override map wins over the data-derived default.
|
||||
- `environment: "server"` — automatic browser skip via the chain
|
||||
runner.
|
||||
- Lowercased header values are upper-cased before lookup so
|
||||
`Headers.get` casing quirks don't break resolution.
|
||||
- Missing header, empty header, and unknown country codes all
|
||||
return `null`.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { cloudflareIpGeoDetector } from "@suaveplan/i18n-detector/detectors/cloudflare-ip-geo";
|
||||
|
||||
const detector = cloudflareIpGeoDetector();
|
||||
const locale = detector.detect?.({
|
||||
request: {
|
||||
headers: new Headers({ "cf-ipcountry": "AE" }),
|
||||
url: new URL("https://example.com/"),
|
||||
},
|
||||
supportedLocales: ["en-GB", "ar-AE"],
|
||||
fallbackLocale: "en-GB",
|
||||
});
|
||||
// locale === "ar-AE"
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { cloudflareIpGeoDetector } from "@suaveplan/i18n-detector/detectors/cloudflare-ip-geo";
|
||||
|
||||
const detector = cloudflareIpGeoDetector({
|
||||
headerName: "x-vercel-ip-country",
|
||||
countryToLocale: {
|
||||
CH: "de-CH", // Swiss German default — overrides Latin order
|
||||
BE: "nl-BE",
|
||||
IN: "en-IN",
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `cloudflareIpGeoDetector(options?): LocaleDetector` —
|
||||
`environment: "server"`, `name: "cloudflareIpGeo"`. Has only
|
||||
`detect`; no `detectAsync`, no `cache`.
|
||||
- `CloudflareIpGeoDetectorOptions.headerName?: string` — default
|
||||
`"cf-ipcountry"`.
|
||||
- `CloudflareIpGeoDetectorOptions.countryToLocale?: Readonly<Record<string, string>>`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- `getAllCountries()` is called once at detector-build time and
|
||||
the result is captured in closure scope; subsequent `detect`
|
||||
calls reuse the same record. Each detector instance therefore
|
||||
has O(1) lookup cost.
|
||||
- The `${primaryLanguage}-${code}` template is correct for the
|
||||
vast majority of countries. The override map exists precisely
|
||||
for the multilingual countries where a single primary language
|
||||
would mis-serve users.
|
||||
@@ -0,0 +1,91 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import type { DetectorContext } from "../../detector.types.js";
|
||||
import { cloudflareIpGeoDetector } from "./cloudflare-ip-geo.js";
|
||||
|
||||
const SUPPORTED = ["en-GB", "ar-AE", "en-AE"] as const;
|
||||
|
||||
function ctx(headers: HeadersInit): DetectorContext {
|
||||
return {
|
||||
request: {
|
||||
headers: new Headers(headers),
|
||||
url: new URL("https://example.com/"),
|
||||
},
|
||||
supportedLocales: SUPPORTED,
|
||||
fallbackLocale: "en-GB",
|
||||
};
|
||||
}
|
||||
|
||||
describe("cloudflareIpGeoDetector", () => {
|
||||
it("country code maps via locale-data (AE → ar-AE)", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const detector = cloudflareIpGeoDetector();
|
||||
expect(detector.detect?.(ctx({ "cf-ipcountry": "AE" }))).toBe("ar-AE");
|
||||
});
|
||||
|
||||
it("override map wins over locale-data", { timeout: 5000 }, () => {
|
||||
const detector = cloudflareIpGeoDetector({
|
||||
countryToLocale: { AE: "en-AE" },
|
||||
});
|
||||
expect(detector.detect?.(ctx({ "cf-ipcountry": "AE" }))).toBe("en-AE");
|
||||
});
|
||||
|
||||
it("missing header returns null", { timeout: 5000 }, () => {
|
||||
const detector = cloudflareIpGeoDetector();
|
||||
expect(detector.detect?.(ctx({}))).toBeNull();
|
||||
});
|
||||
|
||||
it("empty header returns null", { timeout: 5000 }, () => {
|
||||
const detector = cloudflareIpGeoDetector();
|
||||
expect(detector.detect?.(ctx({ "cf-ipcountry": "" }))).toBeNull();
|
||||
});
|
||||
|
||||
it("whitespace header returns null", { timeout: 5000 }, () => {
|
||||
const detector = cloudflareIpGeoDetector();
|
||||
expect(detector.detect?.(ctx({ "cf-ipcountry": " " }))).toBeNull();
|
||||
});
|
||||
|
||||
it("unknown country returns null", { timeout: 5000 }, () => {
|
||||
const detector = cloudflareIpGeoDetector();
|
||||
expect(detector.detect?.(ctx({ "cf-ipcountry": "ZZ" }))).toBeNull();
|
||||
});
|
||||
|
||||
it("lowercase header value upper-cased", { timeout: 5000 }, () => {
|
||||
const detector = cloudflareIpGeoDetector();
|
||||
expect(detector.detect?.(ctx({ "cf-ipcountry": "ae" }))).toBe("ar-AE");
|
||||
});
|
||||
|
||||
it("custom headerName honoured", { timeout: 5000 }, () => {
|
||||
const detector = cloudflareIpGeoDetector({
|
||||
headerName: "x-vercel-ip-country",
|
||||
});
|
||||
expect(detector.detect?.(ctx({ "x-vercel-ip-country": "AE" }))).toBe(
|
||||
"ar-AE"
|
||||
);
|
||||
});
|
||||
|
||||
it("override with empty string falls back to locale-data", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const detector = cloudflareIpGeoDetector({
|
||||
countryToLocale: { AE: "" },
|
||||
});
|
||||
expect(detector.detect?.(ctx({ "cf-ipcountry": "AE" }))).toBe("ar-AE");
|
||||
});
|
||||
|
||||
it("missing request returns null (env gate would normally skip)", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const detector = cloudflareIpGeoDetector();
|
||||
expect(
|
||||
detector.detect?.({
|
||||
supportedLocales: SUPPORTED,
|
||||
fallbackLocale: "en-GB",
|
||||
})
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("environment is 'server'", { timeout: 5000 }, () => {
|
||||
expect(cloudflareIpGeoDetector().environment).toBe("server");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,48 @@
|
||||
import { getAllCountries } from "@suaveplan/locale-data";
|
||||
import type { LocaleDetector } from "../../detector.types.js";
|
||||
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;
|
||||
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,88 @@
|
||||
# `cookie.ts` — cookie-backed locale detector
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
`cookieDetector(options?)` builds a `LocaleDetector` that reads and
|
||||
writes the active locale via a single cookie. On the server it
|
||||
reads the inbound `Cookie` header from `ctx.request.headers` and
|
||||
writes `Set-Cookie` to `ctx.responseHeaders`. On the browser it
|
||||
reads and writes `document.cookie` directly. The detector is the
|
||||
canonical persisted preference for unauthenticated visitors —
|
||||
device-local but survives navigation, and works in both tiers
|
||||
without further plumbing.
|
||||
|
||||
Cookie values are URL-encoded on write so locales containing non-
|
||||
token characters (none in the BCP-47 subset, but a defensive
|
||||
guard) cannot break the cookie wire format.
|
||||
|
||||
## Features
|
||||
|
||||
- `environment: "both"` — reads and writes on either tier.
|
||||
- Default cookie name `"suaveplan_locale"`, configurable.
|
||||
- Default `Max-Age` of 31_536_000s (one year), configurable.
|
||||
- Default `SameSite=Lax`, configurable to `Strict` or `None`.
|
||||
- Default `Path=/`, configurable.
|
||||
- Server-side write goes through `Headers.append("set-cookie", …)`
|
||||
so multiple cookies can coexist in one response.
|
||||
- Missing both `ctx.request` and any `document` global causes
|
||||
`detect` to return `null` and `cache` to no-op.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { cookieDetector } from "@suaveplan/i18n-detector/detectors/cookie";
|
||||
|
||||
const detector = cookieDetector();
|
||||
const locale = detector.detect?.({
|
||||
request: {
|
||||
headers: new Headers({ cookie: "suaveplan_locale=fr-FR" }),
|
||||
url: new URL("https://example.com/"),
|
||||
},
|
||||
supportedLocales: ["en-GB", "fr-FR"],
|
||||
fallbackLocale: "en-GB",
|
||||
});
|
||||
// locale === "fr-FR"
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { cookieDetector } from "@suaveplan/i18n-detector/detectors/cookie";
|
||||
|
||||
const detector = cookieDetector({
|
||||
name: "ui_locale",
|
||||
maxAgeSeconds: 60 * 60 * 24 * 30, // 30 days
|
||||
sameSite: "strict",
|
||||
path: "/app",
|
||||
});
|
||||
|
||||
await detector.cache?.("zh-Hant-TW", {
|
||||
responseHeaders: new Headers(),
|
||||
supportedLocales: ["en-GB", "zh-Hant-TW"],
|
||||
fallbackLocale: "en-GB",
|
||||
});
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `cookieDetector(options?): LocaleDetector`
|
||||
- `CookieDetectorOptions.name?: string` — default `"suaveplan_locale"`.
|
||||
- `CookieDetectorOptions.maxAgeSeconds?: number` — default `31536000`.
|
||||
- `CookieDetectorOptions.sameSite?: "lax" | "strict" | "none"`.
|
||||
- `CookieDetectorOptions.path?: string` — default `"/"`.
|
||||
- `CookieSameSite` — alias for the SameSite union.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- Cookie parsing splits on `;`, trims each segment, and looks up
|
||||
the configured name only — other cookies in the header are
|
||||
ignored.
|
||||
- Decode failures (e.g. percent sequences without two hex digits)
|
||||
surface the raw value rather than throwing. This matches browser
|
||||
behaviour and keeps the detector resilient to legacy cookies.
|
||||
- The `Secure` flag is intentionally NOT auto-added when
|
||||
`SameSite=None`; that enforcement is tracked as the separate
|
||||
`cookie-detector-secure-flag` change.
|
||||
@@ -0,0 +1,280 @@
|
||||
import { afterEach, describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import type { DetectorContext } from "../../detector.types.js";
|
||||
import { cookieDetector } from "./cookie.js";
|
||||
|
||||
const SUPPORTED = ["en-GB", "fr-FR", "ar-AE", "zh-Hant-TW"] as const;
|
||||
|
||||
function serverCtx(headers: HeadersInit = {}): DetectorContext {
|
||||
return {
|
||||
request: {
|
||||
headers: new Headers(headers),
|
||||
url: new URL("https://example.com/"),
|
||||
},
|
||||
responseHeaders: new Headers(),
|
||||
supportedLocales: SUPPORTED,
|
||||
fallbackLocale: "en-GB",
|
||||
};
|
||||
}
|
||||
|
||||
const originalDocument = (globalThis as { document?: unknown }).document;
|
||||
|
||||
afterEach(() => {
|
||||
if (originalDocument === undefined) {
|
||||
delete (globalThis as { document?: unknown }).document;
|
||||
} else {
|
||||
(globalThis as { document?: unknown }).document = originalDocument;
|
||||
}
|
||||
});
|
||||
|
||||
describe("cookieDetector — server reads", () => {
|
||||
it("server reads inbound Cookie header", { timeout: 5000 }, () => {
|
||||
const detector = cookieDetector();
|
||||
const result = detector.detect?.(
|
||||
serverCtx({ cookie: "suaveplan_locale=fr-FR; other=x" })
|
||||
);
|
||||
expect(result).toBe("fr-FR");
|
||||
});
|
||||
|
||||
it("missing Cookie header returns null on server", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
delete (globalThis as { document?: unknown }).document;
|
||||
const detector = cookieDetector();
|
||||
expect(detector.detect?.(serverCtx())).toBeNull();
|
||||
});
|
||||
|
||||
it("URL-encoded cookie value is decoded on read", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const detector = cookieDetector();
|
||||
expect(
|
||||
detector.detect?.(
|
||||
serverCtx({ cookie: "suaveplan_locale=zh-Hant-TW" })
|
||||
)
|
||||
).toBe("zh-Hant-TW");
|
||||
});
|
||||
|
||||
it("malformed percent-encoding falls back to raw value", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const detector = cookieDetector();
|
||||
// %FZ is invalid; decodeURIComponent throws → return raw.
|
||||
expect(
|
||||
detector.detect?.(serverCtx({ cookie: "suaveplan_locale=%FZ" }))
|
||||
).toBe("%FZ");
|
||||
});
|
||||
|
||||
it("segment without = is skipped", { timeout: 5000 }, () => {
|
||||
const detector = cookieDetector();
|
||||
expect(
|
||||
detector.detect?.(
|
||||
serverCtx({ cookie: "novalue; suaveplan_locale=fr-FR" })
|
||||
)
|
||||
).toBe("fr-FR");
|
||||
});
|
||||
|
||||
it("empty cookie value returns null", { timeout: 5000 }, () => {
|
||||
const detector = cookieDetector();
|
||||
expect(
|
||||
detector.detect?.(serverCtx({ cookie: "suaveplan_locale=" }))
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("cookie not present in header returns null", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
delete (globalThis as { document?: unknown }).document;
|
||||
const detector = cookieDetector();
|
||||
expect(detector.detect?.(serverCtx({ cookie: "other=x" }))).toBeNull();
|
||||
});
|
||||
|
||||
it("custom name is honoured", { timeout: 5000 }, () => {
|
||||
const detector = cookieDetector({ name: "ui_locale" });
|
||||
expect(
|
||||
detector.detect?.(serverCtx({ cookie: "ui_locale=fr-FR" }))
|
||||
).toBe("fr-FR");
|
||||
});
|
||||
|
||||
it("empty Cookie header value falls through to document", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
delete (globalThis as { document?: unknown }).document;
|
||||
const detector = cookieDetector();
|
||||
// An explicit empty cookie header — readCookieHeader sees ""
|
||||
// and falls through to readDocument which is null.
|
||||
const ctx: DetectorContext = {
|
||||
request: {
|
||||
headers: new Headers({ cookie: "" }),
|
||||
url: new URL("https://example.com/"),
|
||||
},
|
||||
supportedLocales: SUPPORTED,
|
||||
fallbackLocale: "en-GB",
|
||||
};
|
||||
expect(detector.detect?.(ctx)).toBeNull();
|
||||
});
|
||||
|
||||
it("empty segments in cookie string are skipped", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const detector = cookieDetector();
|
||||
expect(
|
||||
detector.detect?.(
|
||||
serverCtx({ cookie: ";; suaveplan_locale=fr-FR ;;" })
|
||||
)
|
||||
).toBe("fr-FR");
|
||||
});
|
||||
});
|
||||
|
||||
describe("cookieDetector — server writes", () => {
|
||||
it("server writes Set-Cookie via responseHeaders", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const detector = cookieDetector();
|
||||
const ctx = serverCtx();
|
||||
detector.cache?.("fr-FR", ctx);
|
||||
const setCookie = ctx.responseHeaders?.get("set-cookie");
|
||||
expect(setCookie).toMatch(/^suaveplan_locale=fr-FR;/);
|
||||
expect(setCookie).toContain("Max-Age=31536000");
|
||||
expect(setCookie).toContain("Path=/");
|
||||
expect(setCookie).toContain("SameSite=Lax");
|
||||
});
|
||||
|
||||
it("SameSite=strict label is correct", { timeout: 5000 }, () => {
|
||||
const detector = cookieDetector({ sameSite: "strict" });
|
||||
const ctx = serverCtx();
|
||||
detector.cache?.("fr-FR", ctx);
|
||||
expect(ctx.responseHeaders?.get("set-cookie")).toContain(
|
||||
"SameSite=Strict"
|
||||
);
|
||||
});
|
||||
|
||||
it("SameSite=none label is correct", { timeout: 5000 }, () => {
|
||||
const detector = cookieDetector({ sameSite: "none" });
|
||||
const ctx = serverCtx();
|
||||
detector.cache?.("fr-FR", ctx);
|
||||
expect(ctx.responseHeaders?.get("set-cookie")).toContain(
|
||||
"SameSite=None"
|
||||
);
|
||||
});
|
||||
|
||||
it("URL-encoded value round-trips", { timeout: 5000 }, () => {
|
||||
const detector = cookieDetector();
|
||||
const ctx = serverCtx();
|
||||
detector.cache?.("zh-Hant-TW", ctx);
|
||||
const setCookie = ctx.responseHeaders?.get("set-cookie") ?? "";
|
||||
// Replay the cookie back.
|
||||
const replay: DetectorContext = {
|
||||
request: {
|
||||
headers: new Headers({ cookie: setCookie.split(";")[0] ?? "" }),
|
||||
url: new URL("https://example.com/"),
|
||||
},
|
||||
supportedLocales: SUPPORTED,
|
||||
fallbackLocale: "en-GB",
|
||||
};
|
||||
delete (globalThis as { document?: unknown }).document;
|
||||
expect(detector.detect?.(replay)).toBe("zh-Hant-TW");
|
||||
});
|
||||
|
||||
it("custom maxAgeSeconds and path emitted", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const detector = cookieDetector({
|
||||
maxAgeSeconds: 60,
|
||||
path: "/app",
|
||||
});
|
||||
const ctx = serverCtx();
|
||||
detector.cache?.("fr-FR", ctx);
|
||||
const setCookie = ctx.responseHeaders?.get("set-cookie") ?? "";
|
||||
expect(setCookie).toContain("Max-Age=60");
|
||||
expect(setCookie).toContain("Path=/app");
|
||||
});
|
||||
});
|
||||
|
||||
describe("cookieDetector — browser", () => {
|
||||
it("browser reads document.cookie via ctx.document", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const fakeDoc = {
|
||||
cookie: "suaveplan_locale=es",
|
||||
} as unknown as Document;
|
||||
const detector = cookieDetector();
|
||||
const result = detector.detect?.({
|
||||
document: fakeDoc,
|
||||
supportedLocales: SUPPORTED,
|
||||
fallbackLocale: "en-GB",
|
||||
});
|
||||
expect(result).toBe("es");
|
||||
});
|
||||
|
||||
it("browser reads via globalThis.document fallback", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
(globalThis as { document?: unknown }).document = {
|
||||
cookie: "suaveplan_locale=es",
|
||||
};
|
||||
const detector = cookieDetector();
|
||||
const result = detector.detect?.({
|
||||
supportedLocales: SUPPORTED,
|
||||
fallbackLocale: "en-GB",
|
||||
});
|
||||
expect(result).toBe("es");
|
||||
});
|
||||
|
||||
it("browser writes via document.cookie", { timeout: 5000 }, () => {
|
||||
let written = "";
|
||||
const fakeDoc = {
|
||||
get cookie() {
|
||||
return "";
|
||||
},
|
||||
set cookie(value: string) {
|
||||
written = value;
|
||||
},
|
||||
} as unknown as Document;
|
||||
const detector = cookieDetector();
|
||||
detector.cache?.("fr-FR", {
|
||||
document: fakeDoc,
|
||||
supportedLocales: SUPPORTED,
|
||||
fallbackLocale: "en-GB",
|
||||
});
|
||||
expect(written).toMatch(/^suaveplan_locale=fr-FR;/);
|
||||
});
|
||||
|
||||
it("missing both contexts: detect returns null, cache no-ops", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
delete (globalThis as { document?: unknown }).document;
|
||||
const detector = cookieDetector();
|
||||
const ctx: DetectorContext = {
|
||||
supportedLocales: SUPPORTED,
|
||||
fallbackLocale: "en-GB",
|
||||
};
|
||||
expect(detector.detect?.(ctx)).toBeNull();
|
||||
expect(() => detector.cache?.("fr-FR", ctx)).not.toThrow();
|
||||
});
|
||||
|
||||
it("globalThis.document with non-string cookie returns null", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
(globalThis as { document?: unknown }).document = { cookie: 42 };
|
||||
const detector = cookieDetector();
|
||||
expect(
|
||||
detector.detect?.({
|
||||
supportedLocales: SUPPORTED,
|
||||
fallbackLocale: "en-GB",
|
||||
})
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("globalThis.document not an object falls through", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
(globalThis as { document?: unknown }).document = "string";
|
||||
const detector = cookieDetector();
|
||||
expect(
|
||||
detector.detect?.({
|
||||
supportedLocales: SUPPORTED,
|
||||
fallbackLocale: "en-GB",
|
||||
})
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,111 @@
|
||||
import type { DetectorContext, LocaleDetector } from "../../detector.types.js";
|
||||
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,2 @@
|
||||
export { cookieDetector } from "./cookie.js";
|
||||
export type { CookieDetectorOptions, CookieSameSite } from "./cookie.types.js";
|
||||
@@ -0,0 +1,75 @@
|
||||
# `fallback.ts` — unconditional fallback detector
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
`fallbackDetector(locale)` builds a `LocaleDetector` that always
|
||||
returns the configured locale. The detector is the floor of every
|
||||
chain — when querystring, cookie, storage, header, navigator, and
|
||||
geo all miss, the fallback guarantees a deterministic response.
|
||||
|
||||
Because the detector never returns `null`, it MUST appear last in
|
||||
any chain — any detector after it would never run. The constructor
|
||||
validates the supplied locale up-front and throws
|
||||
`I18nError(I18N.INVALID_LOCALE)` on empty / whitespace-only input
|
||||
so a misconfiguration surfaces at chain-build time rather than at
|
||||
the first request.
|
||||
|
||||
## Features
|
||||
|
||||
- `environment: "both"` — eligible on every chain run.
|
||||
- Constructor-time validation: empty string / whitespace string /
|
||||
non-string locale all throw a typed `I18nError(INVALID_LOCALE)`.
|
||||
- Locale value is trimmed once at construction; subsequent
|
||||
`detect` calls reuse the cached value.
|
||||
- Read-only — no `cache()` because the fallback has no source to
|
||||
persist to.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { fallbackDetector } from "@suaveplan/i18n-detector/detectors/fallback";
|
||||
|
||||
const detector = fallbackDetector("en-GB");
|
||||
const locale = detector.detect?.({
|
||||
supportedLocales: ["en-GB", "fr-FR"],
|
||||
fallbackLocale: "en-GB",
|
||||
});
|
||||
// locale === "en-GB"
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { I18nError } from "@suaveplan/i18n-shared";
|
||||
import { fallbackDetector } from "@suaveplan/i18n-detector/detectors/fallback";
|
||||
|
||||
try {
|
||||
fallbackDetector("");
|
||||
} catch (err) {
|
||||
if (err instanceof I18nError) {
|
||||
console.error("misconfigured fallback:", err.code, err.message);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `fallbackDetector(locale: string): LocaleDetector` —
|
||||
`environment: "both"`, `name: "fallback"`. Has only `detect`;
|
||||
no `detectAsync`, no `cache`.
|
||||
- Throws `I18nError(I18N.INVALID_LOCALE)` synchronously on
|
||||
empty / whitespace / non-string input.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- Validation runs once at detector-build time, not on every
|
||||
`detect` call — the chain pays no per-request cost for the
|
||||
guard.
|
||||
- The trimmed locale is captured by closure so the returned
|
||||
detector is allocation-free per request.
|
||||
- The fallback's `name` is fixed at `"fallback"` so chain
|
||||
consumers can identify it deterministically (e.g. to render
|
||||
a "default locale" warning in dev tools).
|
||||
@@ -0,0 +1,57 @@
|
||||
import { I18nError } from "@suaveplan/i18n-shared";
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import type { DetectorContext } from "../../detector.types.js";
|
||||
import { fallbackDetector } from "./fallback.js";
|
||||
|
||||
const ctx: DetectorContext = {
|
||||
supportedLocales: ["en-GB"],
|
||||
fallbackLocale: "en-GB",
|
||||
};
|
||||
|
||||
describe("fallbackDetector", () => {
|
||||
it("returns configured locale", { timeout: 5000 }, () => {
|
||||
const detector = fallbackDetector("en-GB");
|
||||
expect(detector.detect?.(ctx)).toBe("en-GB");
|
||||
});
|
||||
|
||||
it("trims surrounding whitespace", { timeout: 5000 }, () => {
|
||||
const detector = fallbackDetector(" fr-FR ");
|
||||
expect(detector.detect?.(ctx)).toBe("fr-FR");
|
||||
});
|
||||
|
||||
it("empty locale throws INVALID_LOCALE", { timeout: 5000 }, () => {
|
||||
expect(() => fallbackDetector("")).toThrow(I18nError);
|
||||
});
|
||||
|
||||
it("empty locale throw carries I18N.INVALID_LOCALE code", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
try {
|
||||
fallbackDetector("");
|
||||
throw new Error("should have thrown");
|
||||
} catch (err) {
|
||||
if (!(err instanceof I18nError)) throw err;
|
||||
expect(err.code).toBe("I18N.INVALID_LOCALE");
|
||||
}
|
||||
});
|
||||
|
||||
it("whitespace-only locale throws INVALID_LOCALE", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
expect(() => fallbackDetector(" ")).toThrow(I18nError);
|
||||
});
|
||||
|
||||
it("non-string locale throws INVALID_LOCALE", { timeout: 5000 }, () => {
|
||||
expect(() => fallbackDetector(null as unknown as string)).toThrow(
|
||||
I18nError
|
||||
);
|
||||
});
|
||||
|
||||
it("environment is 'both' and name is 'fallback'", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const detector = fallbackDetector("en-GB");
|
||||
expect(detector.environment).toBe("both");
|
||||
expect(detector.name).toBe("fallback");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,29 @@
|
||||
import { I18N_CODES, I18nError } from "@suaveplan/i18n-shared";
|
||||
import type { LocaleDetector } from "../../detector.types.js";
|
||||
|
||||
/**
|
||||
* 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 as a typed `I18nError(I18N.INVALID_LOCALE)` rather than at
|
||||
* the first chain run.
|
||||
*/
|
||||
export function fallbackDetector(locale: string): LocaleDetector {
|
||||
if (typeof locale !== "string" || locale.trim() === "") {
|
||||
throw new I18nError(
|
||||
I18N_CODES.INVALID_LOCALE,
|
||||
"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,76 @@
|
||||
# `html-lang.ts` — `<html lang>` detector
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
`htmlLangDetector()` builds a `LocaleDetector` that reads
|
||||
`document.documentElement.lang`. The detector is
|
||||
`environment: "browser"` and never runs in SSR. Its niche is the
|
||||
common case where a static HTML shell already declares
|
||||
`<html lang="…">` — typically rendered by the SSR layer for
|
||||
crawlers and accessibility — and the runtime surface wants to
|
||||
honour that declaration without coupling to a specific framework.
|
||||
|
||||
This detector contributes a stable, framework-neutral signal that
|
||||
sits between cookie/localStorage (device preferences) and
|
||||
navigator (raw browser preferences). It is a read-only detector —
|
||||
no `cache()` method — because the SSR layer is the source of
|
||||
truth for the `<html lang>` attribute.
|
||||
|
||||
## Features
|
||||
|
||||
- `environment: "browser"` — automatic SSR skip via the chain
|
||||
runner.
|
||||
- Reads from `ctx.document` if supplied, else from
|
||||
`globalThis.document`.
|
||||
- Empty / whitespace-only `lang` attributes return `null`.
|
||||
- Missing `documentElement` (impossible in real browsers but a
|
||||
defensive guard for jsdom shims) returns `null`.
|
||||
- No persistence — there is no `cache()` method.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { htmlLangDetector } from "@suaveplan/i18n-detector/detectors/html-lang";
|
||||
|
||||
const detector = htmlLangDetector();
|
||||
const locale = detector.detect?.({
|
||||
supportedLocales: ["en-GB", "fr-FR"],
|
||||
fallbackLocale: "en-GB",
|
||||
});
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { htmlLangDetector } from "@suaveplan/i18n-detector/detectors/html-lang";
|
||||
|
||||
const fakeDoc = {
|
||||
documentElement: { lang: "fr-FR" },
|
||||
cookie: "",
|
||||
} as unknown as Document;
|
||||
|
||||
const detector = htmlLangDetector();
|
||||
const locale = detector.detect?.({
|
||||
document: fakeDoc,
|
||||
supportedLocales: ["en-GB", "fr-FR"],
|
||||
fallbackLocale: "en-GB",
|
||||
});
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `htmlLangDetector(): LocaleDetector` — `environment: "browser"`,
|
||||
`name: "htmlLang"`. Has only `detect`; no `detectAsync`, no
|
||||
`cache`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- The detector tolerates a missing `globalThis.document` (e.g.
|
||||
Workers / Edge runtimes that have a request-bound DOM shim)
|
||||
without throwing.
|
||||
- `root.lang` is asserted as `string` defensively — some custom
|
||||
document polyfills expose `Element` instances whose `lang`
|
||||
property is undefined.
|
||||
@@ -0,0 +1,92 @@
|
||||
import { afterEach, describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import type { DetectorContext } from "../../detector.types.js";
|
||||
import { htmlLangDetector } from "./html-lang.js";
|
||||
|
||||
const SUPPORTED = ["en-GB", "fr-FR", "es-ES"] as const;
|
||||
const ctx: DetectorContext = {
|
||||
supportedLocales: SUPPORTED,
|
||||
fallbackLocale: "en-GB",
|
||||
};
|
||||
|
||||
const originalDocument = (globalThis as { document?: unknown }).document;
|
||||
|
||||
afterEach(() => {
|
||||
if (originalDocument === undefined) {
|
||||
delete (globalThis as { document?: unknown }).document;
|
||||
} else {
|
||||
(globalThis as { document?: unknown }).document = originalDocument;
|
||||
}
|
||||
});
|
||||
|
||||
describe("htmlLangDetector", () => {
|
||||
it("ctx.document lookup wins over global", { timeout: 5000 }, () => {
|
||||
(globalThis as { document?: unknown }).document = {
|
||||
documentElement: { lang: "es" },
|
||||
};
|
||||
const ctxDoc = {
|
||||
documentElement: { lang: "fr" },
|
||||
} as unknown as Document;
|
||||
const detector = htmlLangDetector();
|
||||
expect(detector.detect?.({ ...ctx, document: ctxDoc })).toBe("fr");
|
||||
});
|
||||
|
||||
it("globalThis.document fallback", { timeout: 5000 }, () => {
|
||||
(globalThis as { document?: unknown }).document = {
|
||||
documentElement: { lang: "es" },
|
||||
};
|
||||
const detector = htmlLangDetector();
|
||||
expect(detector.detect?.(ctx)).toBe("es");
|
||||
});
|
||||
|
||||
it("missing document returns null", { timeout: 5000 }, () => {
|
||||
delete (globalThis as { document?: unknown }).document;
|
||||
const detector = htmlLangDetector();
|
||||
expect(detector.detect?.(ctx)).toBeNull();
|
||||
});
|
||||
|
||||
it("missing documentElement returns null", { timeout: 5000 }, () => {
|
||||
(globalThis as { document?: unknown }).document = {};
|
||||
const detector = htmlLangDetector();
|
||||
expect(detector.detect?.(ctx)).toBeNull();
|
||||
});
|
||||
|
||||
it("non-string lang returns null", { timeout: 5000 }, () => {
|
||||
(globalThis as { document?: unknown }).document = {
|
||||
documentElement: { lang: 123 },
|
||||
};
|
||||
const detector = htmlLangDetector();
|
||||
expect(detector.detect?.(ctx)).toBeNull();
|
||||
});
|
||||
|
||||
it("empty lang returns null", { timeout: 5000 }, () => {
|
||||
(globalThis as { document?: unknown }).document = {
|
||||
documentElement: { lang: "" },
|
||||
};
|
||||
const detector = htmlLangDetector();
|
||||
expect(detector.detect?.(ctx)).toBeNull();
|
||||
});
|
||||
|
||||
it("whitespace-only lang returns null", { timeout: 5000 }, () => {
|
||||
(globalThis as { document?: unknown }).document = {
|
||||
documentElement: { lang: " " },
|
||||
};
|
||||
const detector = htmlLangDetector();
|
||||
expect(detector.detect?.(ctx)).toBeNull();
|
||||
});
|
||||
|
||||
it("globalThis.document not an object falls through", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
(globalThis as { document?: unknown }).document = "string";
|
||||
const detector = htmlLangDetector();
|
||||
expect(detector.detect?.(ctx)).toBeNull();
|
||||
});
|
||||
|
||||
it("environment is 'browser' and name is 'htmlLang'", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const detector = htmlLangDetector();
|
||||
expect(detector.environment).toBe("browser");
|
||||
expect(detector.name).toBe("htmlLang");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,38 @@
|
||||
import type { DetectorContext, LocaleDetector } from "../../detector.types.js";
|
||||
|
||||
/**
|
||||
* 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 runtime
|
||||
* 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";
|
||||
@@ -0,0 +1,5 @@
|
||||
export { localStorageDetector } from "./local-storage.js";
|
||||
export type {
|
||||
LocalStorageDetectorOptions,
|
||||
LocalStorageLike,
|
||||
} from "./local-storage.types.js";
|
||||
@@ -0,0 +1,80 @@
|
||||
# `local-storage.ts` — `localStorage`-backed locale detector
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
`localStorageDetector(options?)` builds a `LocaleDetector` that
|
||||
reads and writes the active locale from a `Storage`-shaped backing
|
||||
store. The detector is `environment: "browser"` so the chain
|
||||
runner automatically skips it during SSR. Browser-side persistence
|
||||
sits between cookies (which round-trip on every request) and
|
||||
backend-stored user preference (which requires authentication) —
|
||||
ideal for unauthenticated single-page apps that want fast
|
||||
client-side reads after the first visit without paying the cookie
|
||||
header cost on every request.
|
||||
|
||||
## Features
|
||||
|
||||
- Default backing store is `globalThis.localStorage`.
|
||||
- `options.storage` injects an alternative `LocalStorageLike`
|
||||
(e.g. a session-storage handle, or an in-memory test fake).
|
||||
- Default key `"suaveplan-locale"`, configurable.
|
||||
- All storage access wrapped in `try/catch` — private mode,
|
||||
disabled storage, and quota exhaustion all surface as `null` /
|
||||
silent no-op.
|
||||
- `environment: "browser"` — server runs skip the detector
|
||||
entirely, so SSR contexts never need to inject a fake.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { localStorageDetector } from "@suaveplan/i18n-detector/detectors/local-storage";
|
||||
|
||||
const detector = localStorageDetector();
|
||||
const locale = detector.detect?.({
|
||||
supportedLocales: ["en-GB", "fr-FR"],
|
||||
fallbackLocale: "en-GB",
|
||||
});
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { localStorageDetector } from "@suaveplan/i18n-detector/detectors/local-storage";
|
||||
|
||||
const fake = new Map<string, string>();
|
||||
const detector = localStorageDetector({
|
||||
key: "app:locale",
|
||||
storage: {
|
||||
getItem: (k) => fake.get(k) ?? null,
|
||||
setItem: (k, v) => {
|
||||
fake.set(k, v);
|
||||
},
|
||||
removeItem: (k) => {
|
||||
fake.delete(k);
|
||||
},
|
||||
},
|
||||
});
|
||||
await detector.cache?.("fr-FR", ctx);
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `localStorageDetector(options?): LocaleDetector`
|
||||
- `LocalStorageDetectorOptions.key?: string` — default
|
||||
`"suaveplan-locale"`.
|
||||
- `LocalStorageDetectorOptions.storage?: LocalStorageLike`.
|
||||
- `LocalStorageLike` — `{ getItem, setItem, removeItem }` shape.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- `resolveStorage` checks both `getItem` and `setItem` are
|
||||
functions before treating the candidate as a `LocalStorageLike`
|
||||
to defend against custom shims that expose only one half of the
|
||||
contract.
|
||||
- Empty-string reads are treated as a miss — Safari has been known
|
||||
to return `""` rather than `null` for unset keys in private mode.
|
||||
- Cache failures (quota, private mode, disabled storage) are
|
||||
swallowed; the detector NEVER throws into the consumer.
|
||||
@@ -0,0 +1,183 @@
|
||||
import { afterEach, describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import type { DetectorContext } from "../../detector.types.js";
|
||||
import { localStorageDetector } from "./local-storage.js";
|
||||
|
||||
const SUPPORTED = ["en-GB", "fr-FR", "es-ES"] as const;
|
||||
const ctx: DetectorContext = {
|
||||
supportedLocales: SUPPORTED,
|
||||
fallbackLocale: "en-GB",
|
||||
};
|
||||
|
||||
const originalLocalStorage = (globalThis as { localStorage?: unknown })
|
||||
.localStorage;
|
||||
|
||||
afterEach(() => {
|
||||
if (originalLocalStorage === undefined) {
|
||||
delete (globalThis as { localStorage?: unknown }).localStorage;
|
||||
} else {
|
||||
(globalThis as { localStorage?: unknown }).localStorage =
|
||||
originalLocalStorage;
|
||||
}
|
||||
});
|
||||
|
||||
describe("localStorageDetector", () => {
|
||||
it("explicit storage option used over globalThis", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const map = new Map<string, string>([["suaveplan-locale", "fr-FR"]]);
|
||||
const storage = {
|
||||
getItem: (k: string) => map.get(k) ?? null,
|
||||
setItem: (k: string, v: string) => {
|
||||
map.set(k, v);
|
||||
},
|
||||
removeItem: (k: string) => {
|
||||
map.delete(k);
|
||||
},
|
||||
};
|
||||
const detector = localStorageDetector({ storage });
|
||||
expect(detector.detect?.(ctx)).toBe("fr-FR");
|
||||
});
|
||||
|
||||
it("globalThis.localStorage fallback", { timeout: 5000 }, () => {
|
||||
const map = new Map<string, string>([["suaveplan-locale", "es-ES"]]);
|
||||
(globalThis as { localStorage?: unknown }).localStorage = {
|
||||
getItem: (k: string) => map.get(k) ?? null,
|
||||
setItem: (k: string, v: string) => {
|
||||
map.set(k, v);
|
||||
},
|
||||
removeItem: (k: string) => {
|
||||
map.delete(k);
|
||||
},
|
||||
};
|
||||
const detector = localStorageDetector();
|
||||
expect(detector.detect?.(ctx)).toBe("es-ES");
|
||||
});
|
||||
|
||||
it("missing storage returns null", { timeout: 5000 }, () => {
|
||||
delete (globalThis as { localStorage?: unknown }).localStorage;
|
||||
const detector = localStorageDetector();
|
||||
expect(detector.detect?.(ctx)).toBeNull();
|
||||
});
|
||||
|
||||
it("storage error caught silently", { timeout: 5000 }, () => {
|
||||
const detector = localStorageDetector({
|
||||
storage: {
|
||||
getItem: () => {
|
||||
throw new Error("private mode");
|
||||
},
|
||||
setItem: () => undefined,
|
||||
removeItem: () => undefined,
|
||||
},
|
||||
});
|
||||
expect(detector.detect?.(ctx)).toBeNull();
|
||||
});
|
||||
|
||||
it("empty string from storage treated as null", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const detector = localStorageDetector({
|
||||
storage: {
|
||||
getItem: () => "",
|
||||
setItem: () => undefined,
|
||||
removeItem: () => undefined,
|
||||
},
|
||||
});
|
||||
expect(detector.detect?.(ctx)).toBeNull();
|
||||
});
|
||||
|
||||
it("non-string return from storage treated as null", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const detector = localStorageDetector({
|
||||
storage: {
|
||||
getItem: () => 42 as unknown as string,
|
||||
setItem: () => undefined,
|
||||
removeItem: () => undefined,
|
||||
},
|
||||
});
|
||||
expect(detector.detect?.(ctx)).toBeNull();
|
||||
});
|
||||
|
||||
it("custom key honoured", { timeout: 5000 }, () => {
|
||||
const detector = localStorageDetector({
|
||||
key: "app:locale",
|
||||
storage: {
|
||||
getItem: (k: string) => (k === "app:locale" ? "fr-FR" : null),
|
||||
setItem: () => undefined,
|
||||
removeItem: () => undefined,
|
||||
},
|
||||
});
|
||||
expect(detector.detect?.(ctx)).toBe("fr-FR");
|
||||
});
|
||||
|
||||
it("cache writes via setItem", { timeout: 5000 }, () => {
|
||||
const writes: Array<[string, string]> = [];
|
||||
const detector = localStorageDetector({
|
||||
storage: {
|
||||
getItem: () => null,
|
||||
setItem: (k: string, v: string) => {
|
||||
writes.push([k, v]);
|
||||
},
|
||||
removeItem: () => undefined,
|
||||
},
|
||||
});
|
||||
detector.cache?.("fr-FR", ctx);
|
||||
expect(writes).toEqual([["suaveplan-locale", "fr-FR"]]);
|
||||
});
|
||||
|
||||
it("cache failure absorbed silently", { timeout: 5000 }, () => {
|
||||
const detector = localStorageDetector({
|
||||
storage: {
|
||||
getItem: () => null,
|
||||
setItem: () => {
|
||||
throw new Error("quota");
|
||||
},
|
||||
removeItem: () => undefined,
|
||||
},
|
||||
});
|
||||
expect(() => detector.cache?.("fr-FR", ctx)).not.toThrow();
|
||||
});
|
||||
|
||||
it("cache no-ops when no storage available", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
delete (globalThis as { localStorage?: unknown }).localStorage;
|
||||
const detector = localStorageDetector();
|
||||
expect(() => detector.cache?.("fr-FR", ctx)).not.toThrow();
|
||||
});
|
||||
|
||||
it("globalThis throw on read returns null", { timeout: 5000 }, () => {
|
||||
Object.defineProperty(globalThis, "localStorage", {
|
||||
configurable: true,
|
||||
get() {
|
||||
throw new Error("blocked");
|
||||
},
|
||||
});
|
||||
const detector = localStorageDetector();
|
||||
try {
|
||||
expect(detector.detect?.(ctx)).toBeNull();
|
||||
} finally {
|
||||
// Restore via afterEach restorer; configurable=true allows that.
|
||||
Object.defineProperty(globalThis, "localStorage", {
|
||||
configurable: true,
|
||||
writable: true,
|
||||
value: originalLocalStorage,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it("globalThis without setItem treated as missing", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
(globalThis as { localStorage?: unknown }).localStorage = {
|
||||
getItem: () => "fr-FR",
|
||||
};
|
||||
const detector = localStorageDetector();
|
||||
expect(detector.detect?.(ctx)).toBeNull();
|
||||
});
|
||||
|
||||
it("environment is 'browser'", { timeout: 5000 }, () => {
|
||||
const detector = localStorageDetector();
|
||||
expect(detector.environment).toBe("browser");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,70 @@
|
||||
import type { LocaleDetector } from "../../detector.types.js";
|
||||
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 consumer.
|
||||
*/
|
||||
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,27 @@
|
||||
/**
|
||||
* 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,85 @@
|
||||
# `navigator.ts` — `navigator.languages` detector
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
`navigatorDetector()` builds a `LocaleDetector` that reads the
|
||||
active locale from the browser's
|
||||
`globalThis.navigator.languages` (preferred) or
|
||||
`globalThis.navigator.language` (fallback). The chain runner
|
||||
negotiates the returned value against `ctx.supportedLocales` so
|
||||
this detector contributes the user's raw browser preference and
|
||||
lets the resolver perform BCP-47 fallback (so a user with
|
||||
`["fr-CA", "en-US"]` browser settings still receives `fr-FR`
|
||||
when only `fr-FR` is supported).
|
||||
|
||||
The detector is `environment: "browser"` and never runs in SSR.
|
||||
It is the lowest-priority signal in the canonical chain because
|
||||
browser preferences are coarse — they reflect OS-level locale
|
||||
rather than per-application choice — but they are the strongest
|
||||
signal available for first-time, unauthenticated visitors with
|
||||
no cookies or localStorage.
|
||||
|
||||
## Features
|
||||
|
||||
- Reads `navigator.languages` array first (preferred — every
|
||||
modern browser exposes it).
|
||||
- Falls back to `navigator.language` when `languages` is missing
|
||||
or empty.
|
||||
- Skips whitespace-only entries; returns the first non-empty.
|
||||
- Missing `navigator` global returns `null` (Workers, server
|
||||
contexts that mistakenly bypass environment gating).
|
||||
- `environment: "browser"` — server runs skip the detector
|
||||
entirely.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { navigatorDetector } from "@suaveplan/i18n-detector/detectors/navigator";
|
||||
|
||||
const detector = navigatorDetector();
|
||||
const locale = detector.detect?.({
|
||||
supportedLocales: ["en-GB", "fr-FR"],
|
||||
fallbackLocale: "en-GB",
|
||||
});
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { navigatorDetector } from "@suaveplan/i18n-detector/detectors/navigator";
|
||||
|
||||
// Test fixture: stub navigator.
|
||||
const original = (globalThis as { navigator?: unknown }).navigator;
|
||||
(globalThis as { navigator?: unknown }).navigator = {
|
||||
languages: ["fr-CA", "en-US"],
|
||||
language: "fr-CA",
|
||||
};
|
||||
|
||||
const detector = navigatorDetector();
|
||||
const locale = detector.detect?.({
|
||||
supportedLocales: ["en-GB", "fr-FR"],
|
||||
fallbackLocale: "en-GB",
|
||||
});
|
||||
|
||||
(globalThis as { navigator?: unknown }).navigator = original;
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `navigatorDetector(): LocaleDetector` — `environment: "browser"`,
|
||||
`name: "navigator"`. Has only `detect`; no `detectAsync`, no
|
||||
`cache`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- The detector NEVER throws even when `navigator` is partially
|
||||
defined (only `language`, only `languages`, or neither). The
|
||||
chain runner skips browser detectors on server runs anyway, but
|
||||
the in-detector guards keep the behaviour predictable in
|
||||
unusual runtime shims.
|
||||
- Whitespace-only entries are skipped because some Android
|
||||
browsers historically emitted `" "` as a sentinel for
|
||||
"preference unset".
|
||||
@@ -0,0 +1,106 @@
|
||||
import { afterEach, describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import type { DetectorContext } from "../../detector.types.js";
|
||||
import { navigatorDetector } from "./navigator.js";
|
||||
|
||||
const ctx: DetectorContext = {
|
||||
supportedLocales: ["en-GB", "fr-FR", "de-DE"],
|
||||
fallbackLocale: "en-GB",
|
||||
};
|
||||
|
||||
const originalDescriptor = Object.getOwnPropertyDescriptor(
|
||||
globalThis,
|
||||
"navigator"
|
||||
);
|
||||
|
||||
function setNavigator(value: unknown): void {
|
||||
Object.defineProperty(globalThis, "navigator", {
|
||||
configurable: true,
|
||||
writable: true,
|
||||
value,
|
||||
});
|
||||
}
|
||||
|
||||
function deleteNavigator(): void {
|
||||
Object.defineProperty(globalThis, "navigator", {
|
||||
configurable: true,
|
||||
writable: true,
|
||||
value: undefined,
|
||||
});
|
||||
delete (globalThis as { navigator?: unknown }).navigator;
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
if (originalDescriptor) {
|
||||
Object.defineProperty(globalThis, "navigator", originalDescriptor);
|
||||
} else {
|
||||
deleteNavigator();
|
||||
}
|
||||
});
|
||||
|
||||
describe("navigatorDetector", () => {
|
||||
it("navigator.languages preferred", { timeout: 5000 }, () => {
|
||||
setNavigator({ languages: ["fr", "en"], language: "de" });
|
||||
const detector = navigatorDetector();
|
||||
expect(detector.detect?.(ctx)).toBe("fr");
|
||||
});
|
||||
|
||||
it("navigator.language fallback", { timeout: 5000 }, () => {
|
||||
setNavigator({ language: "es" });
|
||||
const detector = navigatorDetector();
|
||||
expect(detector.detect?.(ctx)).toBe("es");
|
||||
});
|
||||
|
||||
it("whitespace entries skipped", { timeout: 5000 }, () => {
|
||||
setNavigator({ languages: [" ", "fr"] });
|
||||
const detector = navigatorDetector();
|
||||
expect(detector.detect?.(ctx)).toBe("fr");
|
||||
});
|
||||
|
||||
it("missing navigator returns null", { timeout: 5000 }, () => {
|
||||
deleteNavigator();
|
||||
const detector = navigatorDetector();
|
||||
expect(detector.detect?.(ctx)).toBeNull();
|
||||
});
|
||||
|
||||
it("navigator with neither languages nor language returns null", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
setNavigator({});
|
||||
const detector = navigatorDetector();
|
||||
expect(detector.detect?.(ctx)).toBeNull();
|
||||
});
|
||||
|
||||
it("empty languages array falls back to language", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
setNavigator({ languages: [], language: "fr" });
|
||||
const detector = navigatorDetector();
|
||||
expect(detector.detect?.(ctx)).toBe("fr");
|
||||
});
|
||||
|
||||
it("non-array languages and empty language returns null", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
setNavigator({ languages: "not-an-array", language: "" });
|
||||
const detector = navigatorDetector();
|
||||
expect(detector.detect?.(ctx)).toBeNull();
|
||||
});
|
||||
|
||||
it("only whitespace entries returns null", { timeout: 5000 }, () => {
|
||||
setNavigator({ languages: [" ", " "] });
|
||||
const detector = navigatorDetector();
|
||||
expect(detector.detect?.(ctx)).toBeNull();
|
||||
});
|
||||
|
||||
it("navigator value not an object falls through", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
setNavigator("string");
|
||||
const detector = navigatorDetector();
|
||||
expect(detector.detect?.(ctx)).toBeNull();
|
||||
});
|
||||
|
||||
it("environment is 'browser'", { timeout: 5000 }, () => {
|
||||
expect(navigatorDetector().environment).toBe("browser");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,50 @@
|
||||
import type { LocaleDetector } from "../../detector.types.js";
|
||||
|
||||
interface NavigatorLanguageLike {
|
||||
readonly languages?: ReadonlyArray<string>;
|
||||
readonly language?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
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,77 @@
|
||||
# `querystring.ts` — `?lang=` querystring detector
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
`querystringDetector(options?)` builds a `LocaleDetector` that reads
|
||||
the active locale from a URL search parameter. It runs in both the
|
||||
browser tier (consulting `globalThis.location.search`) and the
|
||||
server tier (consulting `ctx.request.url.searchParams`), so the
|
||||
same detector entry can sit at the top of every chain regardless
|
||||
of where the chain is executed. Querystring detection is the
|
||||
highest-precedence signal because it represents an explicit user
|
||||
override (a tester switching locale via the URL bar, an outbound
|
||||
link from a translated page) that should beat every persisted
|
||||
preference.
|
||||
|
||||
When neither the request nor the browser global is available the
|
||||
detector falls through with `null`, letting the chain advance to
|
||||
the next entry.
|
||||
|
||||
## Features
|
||||
|
||||
- Default param name `"lang"`, configurable via
|
||||
`options.paramName`.
|
||||
- Server-tier read via `ctx.request.url.searchParams.get(...)`.
|
||||
- Browser-tier read via `URLSearchParams(globalThis.location.search)`.
|
||||
- Empty values and missing globals return `null`, never throw.
|
||||
- `environment: "both"` — eligible on every chain run.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { querystringDetector } from "@suaveplan/i18n-detector/detectors/querystring";
|
||||
|
||||
const detector = querystringDetector();
|
||||
const result = detector.detect?.({
|
||||
request: {
|
||||
headers: new Headers(),
|
||||
url: new URL("https://example.com/?lang=fr"),
|
||||
},
|
||||
supportedLocales: ["en-GB", "fr-FR"],
|
||||
fallbackLocale: "en-GB",
|
||||
});
|
||||
// result === "fr"
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import {
|
||||
createDetectorChain,
|
||||
querystringDetector,
|
||||
cookieDetector,
|
||||
} from "@suaveplan/i18n-detector";
|
||||
|
||||
const chain = createDetectorChain([
|
||||
querystringDetector({ paramName: "ui-lang" }),
|
||||
cookieDetector(),
|
||||
]);
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `querystringDetector(options?: QuerystringDetectorOptions): LocaleDetector`
|
||||
- `QuerystringDetectorOptions.paramName?: string` — default `"lang"`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- The browser fallback uses `URLSearchParams` rather than parsing
|
||||
manually to keep the detector free of edge-case bugs (encoded
|
||||
characters, repeated keys, etc.).
|
||||
- The server reader trusts `ctx.request.url` to be a parsed `URL`
|
||||
instance — request adapters at the edge construct it once and
|
||||
hand it through every detector, which is significantly faster
|
||||
than re-parsing per detector call.
|
||||
@@ -0,0 +1,112 @@
|
||||
import { afterEach, describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import type { DetectorContext } from "../../detector.types.js";
|
||||
import { querystringDetector } from "./querystring.js";
|
||||
|
||||
const SUPPORTED = ["en-GB", "fr-FR", "de-DE"] as const;
|
||||
|
||||
function serverCtx(url: string): DetectorContext {
|
||||
return {
|
||||
request: { headers: new Headers(), url: new URL(url) },
|
||||
supportedLocales: SUPPORTED,
|
||||
fallbackLocale: "en-GB",
|
||||
};
|
||||
}
|
||||
|
||||
function browserCtx(): DetectorContext {
|
||||
return { supportedLocales: SUPPORTED, fallbackLocale: "en-GB" };
|
||||
}
|
||||
|
||||
const originalLocation = (globalThis as { location?: unknown }).location;
|
||||
|
||||
afterEach(() => {
|
||||
if (originalLocation === undefined) {
|
||||
delete (globalThis as { location?: unknown }).location;
|
||||
} else {
|
||||
(globalThis as { location?: unknown }).location = originalLocation;
|
||||
}
|
||||
});
|
||||
|
||||
describe("querystringDetector", () => {
|
||||
it("server context resolves from request URL", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const detector = querystringDetector();
|
||||
expect(
|
||||
detector.detect?.(serverCtx("https://example.com/?lang=fr"))
|
||||
).toBe("fr");
|
||||
});
|
||||
|
||||
it("browser context resolves from globalThis.location", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
(globalThis as { location?: { search: string } }).location = {
|
||||
search: "?lang=es",
|
||||
};
|
||||
const detector = querystringDetector();
|
||||
expect(detector.detect?.(browserCtx())).toBe("es");
|
||||
});
|
||||
|
||||
it("missing param returns null", { timeout: 5000 }, () => {
|
||||
const detector = querystringDetector();
|
||||
expect(detector.detect?.(serverCtx("https://example.com/"))).toBeNull();
|
||||
});
|
||||
|
||||
it("custom paramName honoured", { timeout: 5000 }, () => {
|
||||
const detector = querystringDetector({ paramName: "locale" });
|
||||
expect(
|
||||
detector.detect?.(serverCtx("https://example.com/?locale=de"))
|
||||
).toBe("de");
|
||||
});
|
||||
|
||||
it("empty value returns null and falls through to global", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
delete (globalThis as { location?: unknown }).location;
|
||||
const detector = querystringDetector();
|
||||
expect(
|
||||
detector.detect?.(serverCtx("https://example.com/?lang="))
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("missing both contexts returns null", { timeout: 5000 }, () => {
|
||||
delete (globalThis as { location?: unknown }).location;
|
||||
const detector = querystringDetector();
|
||||
expect(detector.detect?.(browserCtx())).toBeNull();
|
||||
});
|
||||
|
||||
it("browser global with non-string search returns null", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
(globalThis as { location?: unknown }).location = { search: 42 };
|
||||
const detector = querystringDetector();
|
||||
expect(detector.detect?.(browserCtx())).toBeNull();
|
||||
});
|
||||
|
||||
it("browser global with empty search returns null", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
(globalThis as { location?: { search: string } }).location = {
|
||||
search: "",
|
||||
};
|
||||
const detector = querystringDetector();
|
||||
expect(detector.detect?.(browserCtx())).toBeNull();
|
||||
});
|
||||
|
||||
it("browser global present but param missing returns null", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
(globalThis as { location?: { search: string } }).location = {
|
||||
search: "?other=x",
|
||||
};
|
||||
const detector = querystringDetector();
|
||||
expect(detector.detect?.(browserCtx())).toBeNull();
|
||||
});
|
||||
|
||||
it("environment is 'both' and name is 'querystring'", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const detector = querystringDetector();
|
||||
expect(detector.environment).toBe("both");
|
||||
expect(detector.name).toBe("querystring");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,44 @@
|
||||
import type { LocaleDetector } from "../../detector.types.js";
|
||||
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 `globalThis.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"`. Consumers SHOULD only override when integrating
|
||||
* with a host application that already uses a different name
|
||||
* (e.g. `?locale=...` or `?ui-lang=...`).
|
||||
*/
|
||||
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,86 @@
|
||||
# `user-preference.ts` — backend-stored locale preference detector
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
`userPreferenceDetector(options)` builds an async `LocaleDetector`
|
||||
that calls a backend endpoint to read (and later persist) the
|
||||
user's stored locale preference. This is the surface authenticated
|
||||
applications use to honour a user's saved-to-account choice across
|
||||
devices, ahead of cookie/localStorage which are device-bound.
|
||||
|
||||
Unlike most detectors in this package, user-preference is async-
|
||||
only — it has no `detect` and only contributes during the chain's
|
||||
async race phase. It runs in both server and browser tiers because
|
||||
the endpoint is typically a thin JSON API that accepts the
|
||||
session's auth header from either side.
|
||||
|
||||
## Features
|
||||
|
||||
- GET `options.endpoint` returning `{ locale }`; non-2xx,
|
||||
malformed, and rejected responses log a warn and return `null`.
|
||||
- POST `options.endpoint` with `{ locale }` JSON on
|
||||
`cache(locale, ctx)`; failures are non-fatal.
|
||||
- `options.extractAuth(ctx)` injects per-request auth headers
|
||||
(e.g. session cookie on SSR runs).
|
||||
- `options.fetch` injectable for tests.
|
||||
- `options.logger?: LoggerLike` — defaults to a no-op so the
|
||||
package keeps zero non-shared deps. Production consumers wire
|
||||
in `@suaveplan/logger`.
|
||||
- Threads `ctx.signal` into every fetch's `RequestInit.signal`.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { userPreferenceDetector } from "@suaveplan/i18n-detector/detectors/user-preference";
|
||||
|
||||
const detector = userPreferenceDetector({
|
||||
endpoint: "/api/me/locale",
|
||||
});
|
||||
|
||||
const locale = await detector.detectAsync?.({
|
||||
supportedLocales: ["en-GB", "fr-FR"],
|
||||
fallbackLocale: "en-GB",
|
||||
});
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { userPreferenceDetector } from "@suaveplan/i18n-detector/detectors/user-preference";
|
||||
import { ConsoleLogger } from "@suaveplan/logger";
|
||||
|
||||
const detector = userPreferenceDetector({
|
||||
endpoint: "/api/me/locale",
|
||||
logger: new ConsoleLogger({ tags: ["i18n.user-preference"] }),
|
||||
extractAuth: (ctx) => ({
|
||||
cookie: ctx.request?.headers.get("cookie") ?? "",
|
||||
}),
|
||||
fetch: globalThis.fetch,
|
||||
});
|
||||
|
||||
await detector.cache?.("fr-FR", ctx);
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `userPreferenceDetector(options): LocaleDetector` — `environment: "both"`.
|
||||
- `UserPreferenceDetectorOptions.endpoint: string` — required.
|
||||
- `UserPreferenceDetectorOptions.extractAuth?: (ctx) => HeadersInit`.
|
||||
- `UserPreferenceDetectorOptions.cacheKey?: (ctx) => string`.
|
||||
- `UserPreferenceDetectorOptions.fetch?: typeof fetch`.
|
||||
- `UserPreferenceDetectorOptions.logger?: LoggerLike`.
|
||||
- `UserPreferenceResponseBody = { locale: string }` — wire shape.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- A pre-aborted `ctx.signal` typically causes the underlying
|
||||
`fetch` to reject; the rejection is swallowed and `null` is
|
||||
returned, never propagated into the chain.
|
||||
- `Content-Type: application/json` is set automatically on cache
|
||||
POSTs unless the auth headers already declare one.
|
||||
- The `cacheKey` field is exposed for consumers that want
|
||||
request-level dedup but is intentionally not consulted by the
|
||||
detector itself — caching is the consumer's concern.
|
||||
@@ -0,0 +1,363 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import type { LoggerLike } from "@suaveplan/types";
|
||||
import type { DetectorContext } from "../../detector.types.js";
|
||||
import { userPreferenceDetector } from "./user-preference.js";
|
||||
|
||||
const SUPPORTED = ["en-GB", "fr-FR", "de-DE", "ar-AE"] as const;
|
||||
|
||||
function serverCtx(headers: HeadersInit = {}): DetectorContext {
|
||||
return {
|
||||
request: {
|
||||
headers: new Headers(headers),
|
||||
url: new URL("https://example.com/"),
|
||||
},
|
||||
responseHeaders: new Headers(),
|
||||
supportedLocales: SUPPORTED,
|
||||
fallbackLocale: "en-GB",
|
||||
};
|
||||
}
|
||||
|
||||
function browserCtx(signal?: AbortSignal): DetectorContext {
|
||||
const ctx: DetectorContext = {
|
||||
supportedLocales: SUPPORTED,
|
||||
fallbackLocale: "en-GB",
|
||||
};
|
||||
if (signal) {
|
||||
return { ...ctx, signal };
|
||||
}
|
||||
return ctx;
|
||||
}
|
||||
|
||||
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 };
|
||||
}
|
||||
|
||||
function captureLogger(): {
|
||||
logger: LoggerLike;
|
||||
warns: Array<[string, Readonly<Record<string, unknown>> | undefined]>;
|
||||
} {
|
||||
const warns: Array<
|
||||
[string, Readonly<Record<string, unknown>> | undefined]
|
||||
> = [];
|
||||
const logger: LoggerLike = {
|
||||
debug: () => undefined,
|
||||
info: () => undefined,
|
||||
warn: (msg, ctx) => {
|
||||
warns.push([msg, ctx]);
|
||||
},
|
||||
error: () => undefined,
|
||||
};
|
||||
return { logger, warns };
|
||||
}
|
||||
|
||||
describe("userPreferenceDetector — detectAsync", () => {
|
||||
it("happy GET returns body locale", { timeout: 5000 }, async () => {
|
||||
const { fn, calls } = recordingFetch(
|
||||
() => new Response(JSON.stringify({ locale: "fr-FR" }))
|
||||
);
|
||||
const detector = userPreferenceDetector({
|
||||
endpoint: "/api/me/locale",
|
||||
fetch: fn,
|
||||
});
|
||||
const result = await detector.detectAsync?.(browserCtx());
|
||||
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");
|
||||
});
|
||||
|
||||
it("non-2xx returns null and logs warn", { timeout: 5000 }, async () => {
|
||||
const { fn } = recordingFetch(
|
||||
() => new Response("nope", { status: 503 })
|
||||
);
|
||||
const { logger, warns } = captureLogger();
|
||||
const detector = userPreferenceDetector({
|
||||
endpoint: "/api/me/locale",
|
||||
fetch: fn,
|
||||
logger,
|
||||
});
|
||||
const result = await detector.detectAsync?.(browserCtx());
|
||||
expect(result).toBeNull();
|
||||
expect(warns.length).toBe(1);
|
||||
expect(warns[0]?.[0]).toContain("non-ok");
|
||||
});
|
||||
|
||||
it("rejected fetch returns null and logs warn", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const { logger, warns } = captureLogger();
|
||||
const detector = userPreferenceDetector({
|
||||
endpoint: "/api/me/locale",
|
||||
fetch: (async () => {
|
||||
throw new Error("network down");
|
||||
}) as unknown as typeof fetch,
|
||||
logger,
|
||||
});
|
||||
const result = await detector.detectAsync?.(browserCtx());
|
||||
expect(result).toBeNull();
|
||||
expect(warns.length).toBe(1);
|
||||
expect(warns[0]?.[0]).toContain("fetch failed");
|
||||
});
|
||||
|
||||
it("rejected fetch with non-Error rejection logs string form", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const { logger, warns } = captureLogger();
|
||||
const detector = userPreferenceDetector({
|
||||
endpoint: "/api/me/locale",
|
||||
fetch: (() =>
|
||||
Promise.reject("string-rejection")) as unknown as typeof fetch,
|
||||
logger,
|
||||
});
|
||||
const result = await detector.detectAsync?.(browserCtx());
|
||||
expect(result).toBeNull();
|
||||
const ctxArg = warns[0]?.[1];
|
||||
expect(ctxArg?.error).toBe("string-rejection");
|
||||
});
|
||||
|
||||
it("body missing locale returns null", { timeout: 5000 }, async () => {
|
||||
const { fn } = recordingFetch(() => new Response(JSON.stringify({})));
|
||||
const { logger, warns } = captureLogger();
|
||||
const detector = userPreferenceDetector({
|
||||
endpoint: "/api/me/locale",
|
||||
fetch: fn,
|
||||
logger,
|
||||
});
|
||||
const result = await detector.detectAsync?.(browserCtx());
|
||||
expect(result).toBeNull();
|
||||
expect(warns[0]?.[0]).toContain("missing locale");
|
||||
});
|
||||
|
||||
it("body with empty locale returns null", { 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?.(browserCtx());
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it("AbortSignal threaded into fetch", { timeout: 5000 }, async () => {
|
||||
const { fn, calls } = recordingFetch(
|
||||
() => new Response(JSON.stringify({ locale: "fr-FR" }))
|
||||
);
|
||||
const ctrl = new AbortController();
|
||||
const detector = userPreferenceDetector({
|
||||
endpoint: "/api/me/locale",
|
||||
fetch: fn,
|
||||
});
|
||||
await detector.detectAsync?.(browserCtx(ctrl.signal));
|
||||
expect(calls[0]?.init?.signal).toBe(ctrl.signal);
|
||||
});
|
||||
|
||||
it("pre-aborted signal returns null without throw", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const ctrl = new AbortController();
|
||||
ctrl.abort();
|
||||
const detector = userPreferenceDetector({
|
||||
endpoint: "/api/me/locale",
|
||||
fetch: (async () => {
|
||||
throw new DOMException("aborted", "AbortError");
|
||||
}) as unknown as typeof fetch,
|
||||
});
|
||||
const result = await detector.detectAsync?.(browserCtx(ctrl.signal));
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it("extractAuth invoked and headers threaded", {
|
||||
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,
|
||||
});
|
||||
await detector.detectAsync?.(serverCtx({ cookie: "session=abc" }));
|
||||
const headers = new Headers(calls[0]?.init?.headers);
|
||||
expect(headers.get("cookie")).toBe("session=abc");
|
||||
});
|
||||
|
||||
it("default no-op logger swallows warn on rejected fetch", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
// No `logger` option — exercises the NOOP_LOGGER branch.
|
||||
const detector = userPreferenceDetector({
|
||||
endpoint: "/api/me/locale",
|
||||
fetch: (async () => {
|
||||
throw new Error("network");
|
||||
}) as unknown as typeof fetch,
|
||||
});
|
||||
const result = await detector.detectAsync?.(browserCtx());
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it("default fetch falls through to globalThis.fetch", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const original = globalThis.fetch;
|
||||
const stub = (async () =>
|
||||
new Response(
|
||||
JSON.stringify({ locale: "ar-AE" })
|
||||
)) as unknown as typeof fetch;
|
||||
globalThis.fetch = stub;
|
||||
try {
|
||||
const detector = userPreferenceDetector({
|
||||
endpoint: "/api/me/locale",
|
||||
});
|
||||
const result = await detector.detectAsync?.(browserCtx());
|
||||
expect(result).toBe("ar-AE");
|
||||
} finally {
|
||||
globalThis.fetch = original;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("userPreferenceDetector — cache", () => {
|
||||
it("cache POST round-trips body and content-type", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const { fn, calls } = recordingFetch(
|
||||
() => new Response("ok", { status: 200 })
|
||||
);
|
||||
const detector = userPreferenceDetector({
|
||||
endpoint: "/api/me/locale",
|
||||
fetch: fn,
|
||||
});
|
||||
await detector.cache?.("fr-FR", browserCtx());
|
||||
expect(calls[0]?.init?.method).toBe("POST");
|
||||
expect(calls[0]?.init?.body).toBe('{"locale":"fr-FR"}');
|
||||
const headers = new Headers(calls[0]?.init?.headers);
|
||||
expect(headers.get("content-type")).toBe("application/json");
|
||||
});
|
||||
|
||||
it("cache failure on non-2xx is non-fatal but logged", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const { fn } = recordingFetch(
|
||||
() => new Response("nope", { status: 503 })
|
||||
);
|
||||
const { logger, warns } = captureLogger();
|
||||
const detector = userPreferenceDetector({
|
||||
endpoint: "/api/me/locale",
|
||||
fetch: fn,
|
||||
logger,
|
||||
});
|
||||
await expect(
|
||||
detector.cache?.("fr-FR", browserCtx())
|
||||
).resolves.toBeUndefined();
|
||||
expect(warns.length).toBe(1);
|
||||
expect(warns[0]?.[0]).toContain("cache failed");
|
||||
});
|
||||
|
||||
it("cache rejected fetch is non-fatal but logged", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const { logger, warns } = captureLogger();
|
||||
const detector = userPreferenceDetector({
|
||||
endpoint: "/api/me/locale",
|
||||
fetch: (async () => {
|
||||
throw new Error("network");
|
||||
}) as unknown as typeof fetch,
|
||||
logger,
|
||||
});
|
||||
await expect(
|
||||
detector.cache?.("fr-FR", browserCtx())
|
||||
).resolves.toBeUndefined();
|
||||
expect(warns[0]?.[0]).toContain("cache threw");
|
||||
});
|
||||
|
||||
it("cache rejected with non-Error logs string form", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const { logger, warns } = captureLogger();
|
||||
const detector = userPreferenceDetector({
|
||||
endpoint: "/api/me/locale",
|
||||
fetch: (() => Promise.reject("nope")) as unknown as typeof fetch,
|
||||
logger,
|
||||
});
|
||||
await detector.cache?.("fr-FR", browserCtx());
|
||||
expect(warns[0]?.[1]?.error).toBe("nope");
|
||||
});
|
||||
|
||||
it("cache threads AbortSignal", { timeout: 5000 }, async () => {
|
||||
const { fn, calls } = recordingFetch(
|
||||
() => new Response("ok", { status: 200 })
|
||||
);
|
||||
const ctrl = new AbortController();
|
||||
const detector = userPreferenceDetector({
|
||||
endpoint: "/api/me/locale",
|
||||
fetch: fn,
|
||||
});
|
||||
await detector.cache?.("fr-FR", browserCtx(ctrl.signal));
|
||||
expect(calls[0]?.init?.signal).toBe(ctrl.signal);
|
||||
});
|
||||
|
||||
it("cache merges custom auth headers without dropping content-type", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const { fn, calls } = recordingFetch(
|
||||
() => new Response("ok", { status: 200 })
|
||||
);
|
||||
const detector = userPreferenceDetector({
|
||||
endpoint: "/api/me/locale",
|
||||
extractAuth: () => ({ authorization: "Bearer x" }),
|
||||
fetch: fn,
|
||||
});
|
||||
await detector.cache?.("fr-FR", browserCtx());
|
||||
const headers = new Headers(calls[0]?.init?.headers);
|
||||
expect(headers.get("authorization")).toBe("Bearer x");
|
||||
expect(headers.get("content-type")).toBe("application/json");
|
||||
});
|
||||
|
||||
it("cache preserves caller-supplied content-type", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const { fn, calls } = recordingFetch(
|
||||
() => new Response("ok", { status: 200 })
|
||||
);
|
||||
const detector = userPreferenceDetector({
|
||||
endpoint: "/api/me/locale",
|
||||
extractAuth: () => ({ "Content-Type": "application/x-custom" }),
|
||||
fetch: fn,
|
||||
});
|
||||
await detector.cache?.("fr-FR", browserCtx());
|
||||
const headers = new Headers(calls[0]?.init?.headers);
|
||||
expect(headers.get("content-type")).toBe("application/x-custom");
|
||||
});
|
||||
});
|
||||
|
||||
describe("userPreferenceDetector — shape", () => {
|
||||
it("environment is 'both' and name is 'userPreference'", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const detector = userPreferenceDetector({
|
||||
endpoint: "/api/me/locale",
|
||||
});
|
||||
expect(detector.environment).toBe("both");
|
||||
expect(detector.name).toBe("userPreference");
|
||||
expect(detector.detect).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,114 @@
|
||||
import type { LoggerLike } from "@suaveplan/types";
|
||||
import type { DetectorContext, LocaleDetector } from "../../detector.types.js";
|
||||
import type {
|
||||
UserPreferenceDetectorOptions,
|
||||
UserPreferenceResponseBody,
|
||||
} from "./user-preference.types.js";
|
||||
|
||||
const noop = (): void => undefined;
|
||||
const NOOP_LOGGER: LoggerLike = {
|
||||
debug: noop,
|
||||
info: noop,
|
||||
warn: noop,
|
||||
error: noop,
|
||||
};
|
||||
|
||||
/**
|
||||
* 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 the
|
||||
* injected `LoggerLike` (defaults to a no-op); 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 consumer.
|
||||
*/
|
||||
export function userPreferenceDetector(
|
||||
options: UserPreferenceDetectorOptions
|
||||
): LocaleDetector {
|
||||
const fetcher: typeof fetch =
|
||||
options.fetch ?? ((...args) => globalThis.fetch(...args));
|
||||
const logger: LoggerLike = options.logger ?? NOOP_LOGGER;
|
||||
|
||||
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,49 @@
|
||||
import type { LoggerLike } from "@suaveplan/types";
|
||||
import type { DetectorContext } from "../../detector.types.js";
|
||||
|
||||
/**
|
||||
* Configuration accepted by `userPreferenceDetector`. The endpoint is
|
||||
* required; the auth/cache-key/fetch/logger 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;
|
||||
/**
|
||||
* Injectable logger. Defaults to a no-op so the package keeps
|
||||
* zero non-shared dependencies. Production consumers SHOULD
|
||||
* inject a `@suaveplan/logger` instance so failed lookups surface
|
||||
* via the same observability pipeline as the rest of the stack.
|
||||
*/
|
||||
readonly logger?: LoggerLike;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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,78 @@
|
||||
# `direction.ts` — text-direction helpers
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
`getTextDirection(locale)` and `isRtl(locale)` resolve a locale's
|
||||
display direction without instantiating a full locale resolver or
|
||||
booting the slim core. They are pure functions over `RTL_LOCALES`
|
||||
from `@suaveplan/i18n-shared` and complete in O(1) — perfect for use
|
||||
in render hot paths (e.g. `<html dir={getTextDirection(locale)}>`)
|
||||
where every microsecond saved compounds across SSR fan-out.
|
||||
|
||||
The helpers ship in `@suaveplan/i18n-detector` rather than the slim
|
||||
core because direction lookup is logically part of the locale-
|
||||
resolution surface — every consumer that detects a locale needs to
|
||||
know which direction to render — and pulling them here keeps the
|
||||
slim core lighter.
|
||||
|
||||
## Features
|
||||
|
||||
- `getTextDirection(locale)` returns `"ltr"` or `"rtl"` for any
|
||||
BCP-47 locale tag.
|
||||
- `isRtl(locale)` is sugar for `getTextDirection(locale) === "rtl"`.
|
||||
- Region subtags are tolerated: `"ar-SA"`, `"he-IL"`, `"fa-IR"` all
|
||||
resolve to `"rtl"`.
|
||||
- Lookup is case-insensitive — the language subtag is lower-cased
|
||||
before the `RTL_LOCALES` set probe.
|
||||
- Unknown locales default to `"ltr"`, matching browser behaviour.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { getTextDirection, isRtl } from "@suaveplan/i18n-detector/direction";
|
||||
|
||||
getTextDirection("ar"); // "rtl"
|
||||
getTextDirection("ar-SA"); // "rtl"
|
||||
getTextDirection("en-GB"); // "ltr"
|
||||
isRtl("he-IL"); // true
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```tsx
|
||||
import { getTextDirection } from "@suaveplan/i18n-detector/direction";
|
||||
|
||||
export function HtmlShell({
|
||||
locale,
|
||||
children,
|
||||
}: {
|
||||
locale: string;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<html lang={locale} dir={getTextDirection(locale)}>
|
||||
{children}
|
||||
</html>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `getTextDirection(locale: string): "ltr" | "rtl"` — pure.
|
||||
- `isRtl(locale: string): boolean` — pure.
|
||||
- `type TextDirection = "ltr" | "rtl"` — re-exported for consumer
|
||||
type-safety.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- The language subtag is everything before the first `-`. Multi-tag
|
||||
locales (`zh-Hant-TW`) resolve via the first segment only — script
|
||||
subtags are ignored because every script-bearing RTL locale is
|
||||
already covered by its language subtag.
|
||||
- Adding a new RTL language is a one-line change in
|
||||
`@suaveplan/i18n-shared/constants/rtl-locales.ts` — no detector
|
||||
code needs to be touched.
|
||||
@@ -0,0 +1,60 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { getTextDirection, isRtl } from "./direction.js";
|
||||
|
||||
describe("getTextDirection", () => {
|
||||
it("Arabic is RTL", { timeout: 5000 }, () => {
|
||||
expect(getTextDirection("ar")).toBe("rtl");
|
||||
});
|
||||
|
||||
it("Arabic with region subtag is RTL", { timeout: 5000 }, () => {
|
||||
expect(getTextDirection("ar-SA")).toBe("rtl");
|
||||
});
|
||||
|
||||
it("Hebrew is RTL", { timeout: 5000 }, () => {
|
||||
expect(getTextDirection("he-IL")).toBe("rtl");
|
||||
});
|
||||
|
||||
it("Persian is RTL", { timeout: 5000 }, () => {
|
||||
expect(getTextDirection("fa-IR")).toBe("rtl");
|
||||
});
|
||||
|
||||
it("English is LTR", { timeout: 5000 }, () => {
|
||||
expect(getTextDirection("en-GB")).toBe("ltr");
|
||||
});
|
||||
|
||||
it("French is LTR", { timeout: 5000 }, () => {
|
||||
expect(getTextDirection("fr-FR")).toBe("ltr");
|
||||
});
|
||||
|
||||
it("uppercase language subtag still RTL", { timeout: 5000 }, () => {
|
||||
expect(getTextDirection("AR-SA")).toBe("rtl");
|
||||
});
|
||||
|
||||
it("unknown locale defaults to LTR", { timeout: 5000 }, () => {
|
||||
expect(getTextDirection("xx-YY")).toBe("ltr");
|
||||
});
|
||||
});
|
||||
|
||||
describe("isRtl", () => {
|
||||
it("returns true for Arabic", { timeout: 5000 }, () => {
|
||||
expect(isRtl("ar")).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false for English", { timeout: 5000 }, () => {
|
||||
expect(isRtl("en-GB")).toBe(false);
|
||||
});
|
||||
|
||||
it("isRtl mirrors getTextDirection", { timeout: 5000 }, () => {
|
||||
for (const locale of [
|
||||
"ar",
|
||||
"he-IL",
|
||||
"fr-FR",
|
||||
"en",
|
||||
"ur-PK",
|
||||
"zh-CN",
|
||||
"yi",
|
||||
]) {
|
||||
expect(isRtl(locale)).toBe(getTextDirection(locale) === "rtl");
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,35 @@
|
||||
import { RTL_LOCALES } from "@suaveplan/i18n-shared";
|
||||
|
||||
/**
|
||||
* Text direction returned by {@link getTextDirection}.
|
||||
*
|
||||
* Inlined here (rather than imported from the upstream
|
||||
* `middleware/server/server.types.ts`) so the detector package stays
|
||||
* decoupled from the i18n monolith. Slim core (Wave 6) re-exports
|
||||
* this same alias from `@suaveplan/i18n` for back-compat.
|
||||
*/
|
||||
export type TextDirection = "ltr" | "rtl";
|
||||
|
||||
/**
|
||||
* Return the text direction for a given locale code.
|
||||
*
|
||||
* The lookup uses the language subtag (everything before the first
|
||||
* `-`) lowercased so region-bearing tags like `"ar-SA"` and
|
||||
* `"he-IL"` resolve correctly. Unknown locales default to `"ltr"`.
|
||||
*/
|
||||
export function getTextDirection(locale: string): TextDirection {
|
||||
const tag = locale.split("-");
|
||||
const lang = (tag[0] as string).toLowerCase();
|
||||
return RTL_LOCALES.has(lang) ? "rtl" : "ltr";
|
||||
}
|
||||
|
||||
/**
|
||||
* Return `true` if the locale uses right-to-left text direction.
|
||||
*
|
||||
* Equivalent to `getTextDirection(locale) === "rtl"` and provided as
|
||||
* a sugar so consumers can write `if (isRtl(locale))` without the
|
||||
* additional comparison.
|
||||
*/
|
||||
export function isRtl(locale: string): boolean {
|
||||
return getTextDirection(locale) === "rtl";
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
export type { TextDirection } from "./direction.js";
|
||||
export {
|
||||
getTextDirection,
|
||||
isRtl,
|
||||
} from "./direction.js";
|
||||
127
packages/localization/i18n/i18n-detector/src/index.test.ts
Normal file
127
packages/localization/i18n/i18n-detector/src/index.test.ts
Normal file
@@ -0,0 +1,127 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import * as root from "./index.js";
|
||||
|
||||
describe("@suaveplan/i18n-detector — root barrel", () => {
|
||||
it("re-exports createDetectorChain", { timeout: 5000 }, () => {
|
||||
expect(typeof root.createDetectorChain).toBe("function");
|
||||
});
|
||||
|
||||
it("re-exports DETECTOR_CHAIN_UNRESOLVED", { timeout: 5000 }, () => {
|
||||
expect(typeof root.DETECTOR_CHAIN_UNRESOLVED).toBe("symbol");
|
||||
});
|
||||
|
||||
it("re-exports bcp47Negotiate", { timeout: 5000 }, () => {
|
||||
expect(typeof root.bcp47Negotiate).toBe("function");
|
||||
});
|
||||
|
||||
it("re-exports getTextDirection and isRtl", { timeout: 5000 }, () => {
|
||||
expect(root.getTextDirection("ar")).toBe("rtl");
|
||||
expect(root.isRtl("en-GB")).toBe(false);
|
||||
});
|
||||
|
||||
it("re-exports every detector factory", { timeout: 5000 }, () => {
|
||||
expect(typeof root.querystringDetector).toBe("function");
|
||||
expect(typeof root.userPreferenceDetector).toBe("function");
|
||||
expect(typeof root.cookieDetector).toBe("function");
|
||||
expect(typeof root.localStorageDetector).toBe("function");
|
||||
expect(typeof root.htmlLangDetector).toBe("function");
|
||||
expect(typeof root.acceptLanguageDetector).toBe("function");
|
||||
expect(typeof root.parseAcceptLanguage).toBe("function");
|
||||
expect(typeof root.navigatorDetector).toBe("function");
|
||||
expect(typeof root.cloudflareIpGeoDetector).toBe("function");
|
||||
expect(typeof root.fallbackDetector).toBe("function");
|
||||
});
|
||||
|
||||
it("re-exports defaultDetectorChain", { timeout: 5000 }, () => {
|
||||
expect(typeof root.defaultDetectorChain).toBe("function");
|
||||
});
|
||||
|
||||
it("re-exports observability helpers", { timeout: 5000 }, () => {
|
||||
expect(typeof root.getDetectorMetrics).toBe("function");
|
||||
expect(typeof root.getDetectorMetricsState).toBe("function");
|
||||
expect(typeof root.resetDetectorMetricsState).toBe("function");
|
||||
expect(typeof root.resetDetectorMetricsCache).toBe("function");
|
||||
expect(typeof root.recordDetectorMatch).toBe("function");
|
||||
expect(typeof root.recordDetectorDuration).toBe("function");
|
||||
expect(typeof root.recordChainExhausted).toBe("function");
|
||||
});
|
||||
});
|
||||
|
||||
describe("@suaveplan/i18n-detector — every documented subpath export resolves", () => {
|
||||
it("subpath barrels resolve through the package", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const chain = await import("./chain/index.js");
|
||||
const direction = await import("./direction/index.js");
|
||||
const querystring = await import("./detectors/querystring/index.js");
|
||||
const userPreference = await import(
|
||||
"./detectors/user-preference/index.js"
|
||||
);
|
||||
const cookie = await import("./detectors/cookie/index.js");
|
||||
const localStorage = await import("./detectors/local-storage/index.js");
|
||||
const htmlLang = await import("./detectors/html-lang/index.js");
|
||||
const acceptLanguage = await import(
|
||||
"./detectors/accept-language/index.js"
|
||||
);
|
||||
const navigator = await import("./detectors/navigator/index.js");
|
||||
const cloudflareIpGeo = await import(
|
||||
"./detectors/cloudflare-ip-geo/index.js"
|
||||
);
|
||||
const fallback = await import("./detectors/fallback/index.js");
|
||||
const defaultChain = await import("./default-chain/index.js");
|
||||
|
||||
expect(typeof chain.createDetectorChain).toBe("function");
|
||||
expect(typeof chain.bcp47Negotiate).toBe("function");
|
||||
expect(typeof direction.getTextDirection).toBe("function");
|
||||
expect(typeof querystring.querystringDetector).toBe("function");
|
||||
expect(typeof userPreference.userPreferenceDetector).toBe("function");
|
||||
expect(typeof cookie.cookieDetector).toBe("function");
|
||||
expect(typeof localStorage.localStorageDetector).toBe("function");
|
||||
expect(typeof htmlLang.htmlLangDetector).toBe("function");
|
||||
expect(typeof acceptLanguage.acceptLanguageDetector).toBe("function");
|
||||
expect(typeof acceptLanguage.parseAcceptLanguage).toBe("function");
|
||||
expect(typeof navigator.navigatorDetector).toBe("function");
|
||||
expect(typeof cloudflareIpGeo.cloudflareIpGeoDetector).toBe("function");
|
||||
expect(typeof fallback.fallbackDetector).toBe("function");
|
||||
expect(typeof defaultChain.defaultDetectorChain).toBe("function");
|
||||
});
|
||||
});
|
||||
|
||||
describe("@suaveplan/i18n-detector — package.json", () => {
|
||||
it("declares sideEffects false", { timeout: 5000 }, () => {
|
||||
const pkgUrl = new URL("../package.json", import.meta.url);
|
||||
const pkg = JSON.parse(
|
||||
readFileSync(fileURLToPath(pkgUrl), "utf-8")
|
||||
) as { sideEffects?: boolean };
|
||||
expect(pkg.sideEffects).toBe(false);
|
||||
});
|
||||
|
||||
it("declares every documented subpath export", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const pkgUrl = new URL("../package.json", import.meta.url);
|
||||
const pkg = JSON.parse(
|
||||
readFileSync(fileURLToPath(pkgUrl), "utf-8")
|
||||
) as { exports?: Record<string, unknown> };
|
||||
const documented = [
|
||||
".",
|
||||
"./chain",
|
||||
"./direction",
|
||||
"./detectors/querystring",
|
||||
"./detectors/user-preference",
|
||||
"./detectors/cookie",
|
||||
"./detectors/local-storage",
|
||||
"./detectors/html-lang",
|
||||
"./detectors/accept-language",
|
||||
"./detectors/navigator",
|
||||
"./detectors/cloudflare-ip-geo",
|
||||
"./detectors/fallback",
|
||||
"./default-chain",
|
||||
];
|
||||
for (const subpath of documented) {
|
||||
expect(pkg.exports?.[subpath]).toBeDefined();
|
||||
}
|
||||
});
|
||||
});
|
||||
73
packages/localization/i18n/i18n-detector/src/index.ts
Normal file
73
packages/localization/i18n/i18n-detector/src/index.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
// Root barrel for @suaveplan/i18n-detector. Explicit named re-exports
|
||||
// only — no `export *` per CLAUDE.md §10. Mirrors every per-feature
|
||||
// barrel under chain/, direction/, detectors/<name>/, default-chain/,
|
||||
// and the top-level detector.types.ts so the surface stays auditable
|
||||
// and tree-shakeable.
|
||||
|
||||
export {
|
||||
bcp47Negotiate,
|
||||
type CreateDetectorChainOptions,
|
||||
createDetectorChain,
|
||||
DETECTOR_CHAIN_UNRESOLVED,
|
||||
type DetectorChain,
|
||||
type DetectorChainUnresolved,
|
||||
type NegotiateFn,
|
||||
} from "./chain/index.js";
|
||||
export {
|
||||
type DefaultDetectorChainOptions,
|
||||
defaultDetectorChain,
|
||||
} from "./default-chain/index.js";
|
||||
export type {
|
||||
DetectorChainResult,
|
||||
DetectorContext,
|
||||
DetectorEnvironment,
|
||||
DetectorStorageLike,
|
||||
LocaleDetector,
|
||||
} from "./detector.types.js";
|
||||
export {
|
||||
type AcceptLanguageEntry,
|
||||
acceptLanguageDetector,
|
||||
parseAcceptLanguage,
|
||||
} from "./detectors/accept-language/index.js";
|
||||
export {
|
||||
type CloudflareIpGeoDetectorOptions,
|
||||
cloudflareIpGeoDetector,
|
||||
} from "./detectors/cloudflare-ip-geo/index.js";
|
||||
export {
|
||||
type CookieDetectorOptions,
|
||||
type CookieSameSite,
|
||||
cookieDetector,
|
||||
} from "./detectors/cookie/index.js";
|
||||
export { fallbackDetector } from "./detectors/fallback/index.js";
|
||||
export { htmlLangDetector } from "./detectors/html-lang/index.js";
|
||||
export {
|
||||
type LocalStorageDetectorOptions,
|
||||
type LocalStorageLike,
|
||||
localStorageDetector,
|
||||
} from "./detectors/local-storage/index.js";
|
||||
export { navigatorDetector } from "./detectors/navigator/index.js";
|
||||
export {
|
||||
type QuerystringDetectorOptions,
|
||||
querystringDetector,
|
||||
} from "./detectors/querystring/index.js";
|
||||
export {
|
||||
type UserPreferenceDetectorOptions,
|
||||
type UserPreferenceResponseBody,
|
||||
userPreferenceDetector,
|
||||
} from "./detectors/user-preference/index.js";
|
||||
export {
|
||||
getTextDirection,
|
||||
isRtl,
|
||||
type TextDirection,
|
||||
} from "./direction/index.js";
|
||||
export {
|
||||
type DetectorMetricSample,
|
||||
type DetectorMetricsState,
|
||||
getDetectorMetrics,
|
||||
getDetectorMetricsState,
|
||||
recordChainExhausted,
|
||||
recordDetectorDuration,
|
||||
recordDetectorMatch,
|
||||
resetDetectorMetricsCache,
|
||||
resetDetectorMetricsState,
|
||||
} from "./observability/index.js";
|
||||
@@ -0,0 +1,11 @@
|
||||
export {
|
||||
type DetectorMetricSample,
|
||||
type DetectorMetricsState,
|
||||
getDetectorMetrics,
|
||||
getDetectorMetricsState,
|
||||
recordChainExhausted,
|
||||
recordDetectorDuration,
|
||||
recordDetectorMatch,
|
||||
resetDetectorMetricsCache,
|
||||
resetDetectorMetricsState,
|
||||
} from "./metrics.js";
|
||||
@@ -0,0 +1,94 @@
|
||||
# `metrics.ts` — detector-chain OTel instruments
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
`metrics.ts` owns the OTel surface for the entire
|
||||
`@suaveplan/i18n-detector` package. It exposes lazily-built
|
||||
instruments (`tracer`, `matchTotal`, `durationMs`,
|
||||
`detectedTotal`, `chainExhaustedTotal`) plus an in-memory state
|
||||
ring so spec-conformance tests can assert on exact attribute
|
||||
payloads without standing up a full `MeterProvider` +
|
||||
`InMemoryMetricExporter` pipeline.
|
||||
|
||||
All instruments are no-ops when no `MeterProvider` /
|
||||
`TracerProvider` is registered (the default), so consumers pay
|
||||
zero overhead unless they wire up the SDK. The cached singleton
|
||||
is reset between tests via `resetDetectorMetricsCache()`.
|
||||
|
||||
## Features
|
||||
|
||||
- `getDetectorMetrics()` — lazily builds the tracer, counters, and
|
||||
histograms; caches the result.
|
||||
- `recordDetectorMatch(detector, locale)` — increments the
|
||||
aggregate `i18n.detector.match_total{detector}` counter AND
|
||||
the per-locale `i18n.detector.detected_total{detector,locale}`
|
||||
counter, mirroring both events into the in-memory state ring.
|
||||
- `recordDetectorDuration(detector, durationMs)` — records
|
||||
`i18n.detector.duration_ms{detector}` and pushes the sample
|
||||
into state.
|
||||
- `recordChainExhausted()` — increments
|
||||
`i18n.detector.chain.exhausted_total` on every sentinel return.
|
||||
- `getDetectorMetricsState()` / `resetDetectorMetricsState()` —
|
||||
test-only helpers for asserting + clearing the in-memory ring.
|
||||
- `resetDetectorMetricsCache()` — drops the singleton so a fresh
|
||||
meter can be built between tests.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { getDetectorMetrics } from "@suaveplan/i18n-detector";
|
||||
|
||||
const m = getDetectorMetrics();
|
||||
m.matchTotal.add(1, { detector: "cookie" });
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import {
|
||||
getDetectorMetricsState,
|
||||
recordDetectorMatch,
|
||||
resetDetectorMetricsState,
|
||||
} from "@suaveplan/i18n-detector";
|
||||
|
||||
beforeEach(() => resetDetectorMetricsState());
|
||||
|
||||
it("increments detected_total once per match", () => {
|
||||
recordDetectorMatch("cookie", "fr-FR");
|
||||
const state = getDetectorMetricsState();
|
||||
expect(state.detectedTotal).toEqual([
|
||||
{ attributes: { detector: "cookie", locale: "fr-FR" } },
|
||||
]);
|
||||
});
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `getDetectorMetrics()` — returns the cached `CachedInstruments`
|
||||
object (`tracer`, `matchTotal`, `durationMs`,
|
||||
`detectedTotal`, `chainExhaustedTotal`).
|
||||
- `recordDetectorMatch(detector, locale)` — emits both aggregate
|
||||
and labelled counters.
|
||||
- `recordDetectorDuration(detector, ms)` — emits the duration
|
||||
histogram.
|
||||
- `recordChainExhausted()` — emits the chain-exhausted counter.
|
||||
- `getDetectorMetricsState()` / `resetDetectorMetricsState()` /
|
||||
`resetDetectorMetricsCache()` — test plumbing.
|
||||
- `DetectorMetricSample` / `DetectorMetricsState` — in-memory
|
||||
ring shape.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- The cached singleton has process-wide lifetime by design —
|
||||
metrics emitters are not per-chain because the chain is a thin
|
||||
orchestrator and detector names are globally unique.
|
||||
- `state.detectedTotal` carries `{detector, locale}` attributes;
|
||||
`state.matchTotal` carries `{detector}` only. Tests that need
|
||||
per-locale assertions read `detectedTotal`; tests that only
|
||||
care about which detector won read `matchTotal`.
|
||||
- All sample arrays are unbounded — `resetDetectorMetricsState`
|
||||
is the safety valve. Production code should never call the
|
||||
state-inspection helpers (they exist for spec conformance).
|
||||
@@ -0,0 +1,92 @@
|
||||
import {
|
||||
afterEach,
|
||||
beforeEach,
|
||||
describe,
|
||||
expect,
|
||||
it,
|
||||
} from "@suaveplan/testing/runner";
|
||||
import {
|
||||
getDetectorMetrics,
|
||||
getDetectorMetricsState,
|
||||
recordChainExhausted,
|
||||
recordDetectorDuration,
|
||||
recordDetectorMatch,
|
||||
resetDetectorMetricsCache,
|
||||
resetDetectorMetricsState,
|
||||
} from "./metrics.js";
|
||||
|
||||
beforeEach(() => {
|
||||
resetDetectorMetricsCache();
|
||||
resetDetectorMetricsState();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
resetDetectorMetricsState();
|
||||
});
|
||||
|
||||
describe("metrics", () => {
|
||||
it("getDetectorMetrics returns the same instance on repeat calls", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const a = getDetectorMetrics();
|
||||
const b = getDetectorMetrics();
|
||||
expect(a).toBe(b);
|
||||
});
|
||||
|
||||
it("recordDetectorMatch pushes both aggregate and labelled samples", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
recordDetectorMatch("cookie", "fr-FR");
|
||||
const state = getDetectorMetricsState();
|
||||
expect(state.matchTotal).toEqual([
|
||||
{ attributes: { detector: "cookie" } },
|
||||
]);
|
||||
expect(state.detectedTotal).toEqual([
|
||||
{ attributes: { detector: "cookie", locale: "fr-FR" } },
|
||||
]);
|
||||
});
|
||||
|
||||
it("recordDetectorDuration captures detector + ms value", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
recordDetectorDuration("userPreference", 12.5);
|
||||
const state = getDetectorMetricsState();
|
||||
expect(state.durationMs).toEqual([
|
||||
{
|
||||
attributes: { detector: "userPreference" },
|
||||
value: 12.5,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("recordChainExhausted appends an empty-attribute sample", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
recordChainExhausted();
|
||||
recordChainExhausted();
|
||||
const state = getDetectorMetricsState();
|
||||
expect(state.chainExhaustedTotal.length).toBe(2);
|
||||
expect(state.chainExhaustedTotal[0]?.attributes).toEqual({});
|
||||
});
|
||||
|
||||
it("resetDetectorMetricsState clears every ring", { timeout: 5000 }, () => {
|
||||
recordDetectorMatch("cookie", "fr-FR");
|
||||
recordDetectorDuration("userPreference", 1);
|
||||
recordChainExhausted();
|
||||
resetDetectorMetricsState();
|
||||
const state = getDetectorMetricsState();
|
||||
expect(state.matchTotal).toEqual([]);
|
||||
expect(state.detectedTotal).toEqual([]);
|
||||
expect(state.durationMs).toEqual([]);
|
||||
expect(state.chainExhaustedTotal).toEqual([]);
|
||||
});
|
||||
|
||||
it("resetDetectorMetricsCache forces a fresh build on next access", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const a = getDetectorMetrics();
|
||||
resetDetectorMetricsCache();
|
||||
const b = getDetectorMetrics();
|
||||
expect(a).not.toBe(b);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,142 @@
|
||||
import {
|
||||
type Counter,
|
||||
type Histogram,
|
||||
metrics,
|
||||
type Tracer,
|
||||
trace,
|
||||
} from "@opentelemetry/api";
|
||||
|
||||
/**
|
||||
* OTel instruments for the `@suaveplan/i18n-detector` chain and its
|
||||
* individual detectors.
|
||||
*
|
||||
* Built lazily via `metrics.getMeter('suaveplan.i18n-detector')`. All
|
||||
* instruments are no-ops when no MeterProvider / TracerProvider is
|
||||
* registered (the default), so consumers pay zero overhead unless
|
||||
* they wire up the SDK.
|
||||
*
|
||||
* In addition to emitting via OTel, this module retains a small
|
||||
* in-memory state ring so spec-conformance tests can assert exact
|
||||
* attribute payloads without standing up a full SDK +
|
||||
* InMemoryMetricExporter pipeline.
|
||||
*/
|
||||
|
||||
export interface DetectorMetricSample {
|
||||
readonly attributes: Record<string, string>;
|
||||
readonly value?: number;
|
||||
}
|
||||
|
||||
export interface DetectorMetricsState {
|
||||
readonly matchTotal: DetectorMetricSample[];
|
||||
readonly durationMs: DetectorMetricSample[];
|
||||
readonly detectedTotal: DetectorMetricSample[];
|
||||
readonly chainExhaustedTotal: DetectorMetricSample[];
|
||||
}
|
||||
|
||||
interface CachedInstruments {
|
||||
readonly tracer: Tracer;
|
||||
readonly matchTotal: Counter;
|
||||
readonly durationMs: Histogram;
|
||||
readonly detectedTotal: Counter;
|
||||
readonly chainExhaustedTotal: Counter;
|
||||
}
|
||||
|
||||
let cached: CachedInstruments | undefined;
|
||||
|
||||
const state: DetectorMetricsState = {
|
||||
matchTotal: [],
|
||||
durationMs: [],
|
||||
detectedTotal: [],
|
||||
chainExhaustedTotal: [],
|
||||
};
|
||||
|
||||
/**
|
||||
* Build (or return cached) OTel instruments + tracer for the detector
|
||||
* chain. Idempotent — safe to call from any production code path.
|
||||
*/
|
||||
export function getDetectorMetrics(): CachedInstruments {
|
||||
if (cached) return cached;
|
||||
const meter = metrics.getMeter("suaveplan.i18n-detector");
|
||||
cached = {
|
||||
tracer: trace.getTracer("suaveplan.i18n-detector"),
|
||||
matchTotal: meter.createCounter("i18n.detector.match_total", {
|
||||
description:
|
||||
"Aggregated locale-detector matches, labelled by detector",
|
||||
}),
|
||||
durationMs: meter.createHistogram("i18n.detector.duration_ms", {
|
||||
description:
|
||||
"Aggregated detector execution duration in milliseconds",
|
||||
unit: "ms",
|
||||
}),
|
||||
detectedTotal: meter.createCounter("i18n.detector.detected_total", {
|
||||
description:
|
||||
"Per-detector successful match counter labelled by detector + resolved locale",
|
||||
}),
|
||||
chainExhaustedTotal: meter.createCounter(
|
||||
"i18n.detector.chain.exhausted_total",
|
||||
{
|
||||
description:
|
||||
"Number of chain runs that returned the unresolved sentinel (every detector returned null)",
|
||||
}
|
||||
),
|
||||
};
|
||||
return cached;
|
||||
}
|
||||
|
||||
/**
|
||||
* Record a successful detector match. Increments the aggregate counter
|
||||
* AND the per-detector `detected_total{detector,locale}` counter, then
|
||||
* mirrors both events into the in-memory state ring so tests can
|
||||
* assert on the exact attribute payloads.
|
||||
*/
|
||||
export function recordDetectorMatch(detector: string, locale: string): void {
|
||||
const aggregate = { detector };
|
||||
const labelled = { detector, locale };
|
||||
const m = getDetectorMetrics();
|
||||
m.matchTotal.add(1, aggregate);
|
||||
m.detectedTotal.add(1, labelled);
|
||||
state.matchTotal.push({ attributes: aggregate });
|
||||
state.detectedTotal.push({ attributes: labelled });
|
||||
}
|
||||
|
||||
/**
|
||||
* Record an async detector's wall-clock duration. Mirrors
|
||||
* `recordDetectorMatch` for the duration histogram so the in-memory
|
||||
* state ring captures every duration sample.
|
||||
*/
|
||||
export function recordDetectorDuration(
|
||||
detector: string,
|
||||
durationMs: number
|
||||
): void {
|
||||
const attributes = { detector };
|
||||
getDetectorMetrics().durationMs.record(durationMs, attributes);
|
||||
state.durationMs.push({ attributes, value: durationMs });
|
||||
}
|
||||
|
||||
/**
|
||||
* Record that the chain returned the unresolved sentinel. Increments
|
||||
* the `i18n.detector.chain.exhausted_total` counter.
|
||||
*/
|
||||
export function recordChainExhausted(): void {
|
||||
const attributes = {};
|
||||
getDetectorMetrics().chainExhaustedTotal.add(1, attributes);
|
||||
state.chainExhaustedTotal.push({ attributes });
|
||||
}
|
||||
|
||||
/** Return a frozen snapshot of the in-memory state. */
|
||||
export function getDetectorMetricsState(): DetectorMetricsState {
|
||||
return state;
|
||||
}
|
||||
|
||||
/** Reset the in-memory state ring. Tests call this between cases. */
|
||||
export function resetDetectorMetricsState(): void {
|
||||
state.matchTotal.length = 0;
|
||||
state.durationMs.length = 0;
|
||||
state.detectedTotal.length = 0;
|
||||
state.chainExhaustedTotal.length = 0;
|
||||
}
|
||||
|
||||
/** Test-only: drop the cached instruments so a fresh meter can be built. */
|
||||
export function resetDetectorMetricsCache(): void {
|
||||
cached = undefined;
|
||||
}
|
||||
12
packages/localization/i18n/i18n-detector/tsconfig.lib.json
Normal file
12
packages/localization/i18n/i18n-detector/tsconfig.lib.json
Normal file
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/tsconfig",
|
||||
"extends": "../../../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "./src",
|
||||
"outDir": "./dist",
|
||||
"lib": ["ES2022", "DOM"],
|
||||
"types": ["node"]
|
||||
},
|
||||
"include": ["src/**/*.ts"],
|
||||
"exclude": ["src/**/*.test.ts", "dist", "node_modules"]
|
||||
}
|
||||
39
packages/localization/i18n/i18n-detector/vite.config.ts
Normal file
39
packages/localization/i18n/i18n-detector/vite.config.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import { defineConfig } from "vite";
|
||||
import dts from "vite-plugin-dts";
|
||||
|
||||
export default defineConfig({
|
||||
root: __dirname,
|
||||
cacheDir: "../../../node_modules/.vite/packages/i18n-detector",
|
||||
plugins: [
|
||||
dts({
|
||||
entryRoot: "src",
|
||||
tsconfigPath: "./tsconfig.lib.json",
|
||||
insertTypesEntry: true,
|
||||
}),
|
||||
],
|
||||
build: {
|
||||
outDir: "./dist",
|
||||
emptyOutDir: true,
|
||||
reportCompressedSize: true,
|
||||
sourcemap: true,
|
||||
lib: { entry: "src/index.ts", formats: ["es"] },
|
||||
rollupOptions: {
|
||||
treeshake: true,
|
||||
output: {
|
||||
preserveModules: true,
|
||||
preserveModulesRoot: "src",
|
||||
entryFileNames: "[name].js",
|
||||
},
|
||||
external: [
|
||||
"@suaveplan/error",
|
||||
"@suaveplan/i18n-shared",
|
||||
"@suaveplan/locale-data",
|
||||
"@suaveplan/types",
|
||||
"@opentelemetry/api",
|
||||
"tslib",
|
||||
"zod",
|
||||
/^node:/,
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
29
packages/localization/i18n/i18n-detector/vitest.config.ts
Normal file
29
packages/localization/i18n/i18n-detector/vitest.config.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import { defineConfig } from "vitest/config";
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
globals: false,
|
||||
environment: "node",
|
||||
pool: "forks",
|
||||
include: ["src/**/*.test.ts"],
|
||||
exclude: ["dist/**", "node_modules/**"],
|
||||
coverage: {
|
||||
provider: "istanbul",
|
||||
reporter: ["text", "html", "lcov"],
|
||||
include: ["src/**/*.ts"],
|
||||
exclude: [
|
||||
"src/**/*.test.ts",
|
||||
"src/**/*.types.ts",
|
||||
"src/**/*.schemas.ts",
|
||||
"src/**/index.ts",
|
||||
],
|
||||
thresholds: {
|
||||
statements: 100,
|
||||
branches: 100,
|
||||
functions: 100,
|
||||
lines: 100,
|
||||
},
|
||||
},
|
||||
testTimeout: 5000,
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user