first commit

This commit is contained in:
2026-05-29 08:20:56 +01:00
commit 949299f58d
6368 changed files with 527176 additions and 0 deletions

View File

@@ -0,0 +1,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

View File

@@ -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.

View 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.

View File

@@ -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.

View File

@@ -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).

View File

@@ -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.

View File

@@ -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.

View File

@@ -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).

View File

@@ -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.

View File

@@ -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.

View File

@@ -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".

View File

@@ -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.

View File

@@ -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.

View File

@@ -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.

View File

@@ -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).