first commit
This commit is contained in:
154
docs/localization/i18n/i18n-detector/README.md
Normal file
154
docs/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
|
||||
|
||||
@@ -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.
|
||||
105
docs/localization/i18n/i18n-detector/src/chain/chain.md
Normal file
105
docs/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.
|
||||
@@ -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,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,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,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,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,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,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,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,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,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,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,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).
|
||||
Reference in New Issue
Block a user