# @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. ## Documentation tree - [chain](./src/chain/chain.md) — _(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) — _(no submodules)_ - [default-chain](./src/default-chain/default-chain.md) — _(no submodules)_ - [default-chain](./src/default-chain/default-chain.md) — _(no submodules)_ - [accept-language](./src/detectors/accept-language/accept-language.md) — _(no submodules)_ - [accept-language](./src/detectors/accept-language/accept-language.md) — _(no submodules)_ - [cloudflare-ip-geo](./src/detectors/cloudflare-ip-geo/cloudflare-ip-geo.md) — _(no submodules)_ - [cloudflare-ip-geo](./src/detectors/cloudflare-ip-geo/cloudflare-ip-geo.md) — _(no submodules)_ - [cookie](./src/detectors/cookie/cookie.md) — _(no submodules)_ - [cookie](./src/detectors/cookie/cookie.md) — _(no submodules)_ - [fallback](./src/detectors/fallback/fallback.md) — _(no submodules)_ - [fallback](./src/detectors/fallback/fallback.md) — _(no submodules)_ - [html-lang](./src/detectors/html-lang/html-lang.md) — _(no submodules)_ - [html-lang](./src/detectors/html-lang/html-lang.md) — _(no submodules)_ - [local-storage](./src/detectors/local-storage/local-storage.md) — _(no submodules)_ - [local-storage](./src/detectors/local-storage/local-storage.md) — _(no submodules)_ - [navigator](./src/detectors/navigator/navigator.md) — _(no submodules)_ - [navigator](./src/detectors/navigator/navigator.md) — _(no submodules)_ - [querystring](./src/detectors/querystring/querystring.md) — _(no submodules)_ - [querystring](./src/detectors/querystring/querystring.md) — _(no submodules)_ - [user-preference](./src/detectors/user-preference/user-preference.md) — _(no submodules)_ - [user-preference](./src/detectors/user-preference/user-preference.md) — _(no submodules)_ - [direction](./src/direction/direction.md) — _(no submodules)_ - [direction](./src/direction/direction.md) — _(no submodules)_ - [observability](./src/observability/metrics.md) — _(no submodules)_ - [metrics](./src/observability/metrics.md) — _(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) — _(no submodules)_- [default-chain](./src/default-chain/default-chain.md) — _(no submodules)_- [default-chain](./src/default-chain/default-chain.md) — _(no submodules)_- [accept-language](./src/detectors/accept-language/accept-language.md) — _(no submodules)_- [accept-language](./src/detectors/accept-language/accept-language.md) — _(no submodules)_- [cloudflare-ip-geo](./src/detectors/cloudflare-ip-geo/cloudflare-ip-geo.md) — _(no submodules)_- [cloudflare-ip-geo](./src/detectors/cloudflare-ip-geo/cloudflare-ip-geo.md) — _(no submodules)_- [cookie](./src/detectors/cookie/cookie.md) — _(no submodules)_- [cookie](./src/detectors/cookie/cookie.md) — _(no submodules)_- [fallback](./src/detectors/fallback/fallback.md) — _(no submodules)_- [fallback](./src/detectors/fallback/fallback.md) — _(no submodules)_- [html-lang](./src/detectors/html-lang/html-lang.md) — _(no submodules)_- [html-lang](./src/detectors/html-lang/html-lang.md) — _(no submodules)_- [local-storage](./src/detectors/local-storage/local-storage.md) — _(no submodules)_- [local-storage](./src/detectors/local-storage/local-storage.md) — _(no submodules)_- [navigator](./src/detectors/navigator/navigator.md) — _(no submodules)_- [navigator](./src/detectors/navigator/navigator.md) — _(no submodules)_- [querystring](./src/detectors/querystring/querystring.md) — _(no submodules)_- [querystring](./src/detectors/querystring/querystring.md) — _(no submodules)_- [user-preference](./src/detectors/user-preference/user-preference.md) — _(no submodules)_- [user-preference](./src/detectors/user-preference/user-preference.md) — _(no submodules)_- [direction](./src/direction/direction.md) — _(no submodules)_- [direction](./src/direction/direction.md) — _(no submodules)_- [observability](./src/observability/metrics.md) — _(no submodules)_- [metrics](./src/observability/metrics.md) — _(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) — _(no submodules)_- [default-chain](./src/default-chain/default-chain.md) — _(no submodules)_- [default-chain](./src/default-chain/default-chain.md) — _(no submodules)_- [accept-language](./src/detectors/accept-language/accept-language.md) — _(no submodules)_- [accept-language](./src/detectors/accept-language/accept-language.md) — _(no submodules)_- [cloudflare-ip-geo](./src/detectors/cloudflare-ip-geo/cloudflare-ip-geo.md) — _(no submodules)_- [cloudflare-ip-geo](./src/detectors/cloudflare-ip-geo/cloudflare-ip-geo.md) — _(no submodules)_- [cookie](./src/detectors/cookie/cookie.md) — _(no submodules)_- [cookie](./src/detectors/cookie/cookie.md) — _(no submodules)_- [fallback](./src/detectors/fallback/fallback.md) — _(no submodules)_- [fallback](./src/detectors/fallback/fallback.md) — _(no submodules)_- [html-lang](./src/detectors/html-lang/html-lang.md) — _(no submodules)_- [html-lang](./src/detectors/html-lang/html-lang.md) — _(no submodules)_- [local-storage](./src/detectors/local-storage/local-storage.md) — _(no submodules)_- [local-storage](./src/detectors/local-storage/local-storage.md) — _(no submodules)_- [navigator](./src/detectors/navigator/navigator.md) — _(no submodules)_- [navigator](./src/detectors/navigator/navigator.md) — _(no submodules)_- [querystring](./src/detectors/querystring/querystring.md) — _(no submodules)_- [querystring](./src/detectors/querystring/querystring.md) — _(no submodules)_- [user-preference](./src/detectors/user-preference/user-preference.md) — _(no submodules)_- [user-preference](./src/detectors/user-preference/user-preference.md) — _(no submodules)_- [direction](./src/direction/direction.md) — _(no submodules)_- [direction](./src/direction/direction.md) — _(no submodules)_- [observability](./src/observability/metrics.md) — _(no submodules)_- [metrics](./src/observability/metrics.md) — _(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) — _(no submodules)_- [default-chain](./src/default-chain/default-chain.md) — _(no submodules)_- [default-chain](./src/default-chain/default-chain.md) — _(no submodules)_- [accept-language](./src/detectors/accept-language/accept-language.md) — _(no submodules)_- [accept-language](./src/detectors/accept-language/accept-language.md) — _(no submodules)_- [cloudflare-ip-geo](./src/detectors/cloudflare-ip-geo/cloudflare-ip-geo.md) — _(no submodules)_- [cloudflare-ip-geo](./src/detectors/cloudflare-ip-geo/cloudflare-ip-geo.md) — _(no submodules)_- [cookie](./src/detectors/cookie/cookie.md) — _(no submodules)_- [cookie](./src/detectors/cookie/cookie.md) — _(no submodules)_- [fallback](./src/detectors/fallback/fallback.md) — _(no submodules)_- [fallback](./src/detectors/fallback/fallback.md) — _(no submodules)_- [html-lang](./src/detectors/html-lang/html-lang.md) — _(no submodules)_- [html-lang](./src/detectors/html-lang/html-lang.md) — _(no submodules)_- [local-storage](./src/detectors/local-storage/local-storage.md) — _(no submodules)_- [local-storage](./src/detectors/local-storage/local-storage.md) — _(no submodules)_- [navigator](./src/detectors/navigator/navigator.md) — _(no submodules)_- [navigator](./src/detectors/navigator/navigator.md) — _(no submodules)_- [querystring](./src/detectors/querystring/querystring.md) — _(no submodules)_- [querystring](./src/detectors/querystring/querystring.md) — _(no submodules)_- [user-preference](./src/detectors/user-preference/user-preference.md) — _(no submodules)_- [user-preference](./src/detectors/user-preference/user-preference.md) — _(no submodules)_- [direction](./src/direction/direction.md) — _(no submodules)_- [direction](./src/direction/direction.md) — _(no submodules)_- [observability](./src/observability/metrics.md) — _(no submodules)_- [metrics](./src/observability/metrics.md) — _(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) — _(no submodules)_- [default-chain](./src/default-chain/default-chain.md) — _(no submodules)_ - [default-chain](./src/default-chain/default-chain.md) — _(no submodules)_- [accept-language](./src/detectors/accept-language/accept-language.md) — _(no submodules)_ - [accept-language](./src/detectors/accept-language/accept-language.md) — _(no submodules)_- [cloudflare-ip-geo](./src/detectors/cloudflare-ip-geo/cloudflare-ip-geo.md) — _(no submodules)_ - [cloudflare-ip-geo](./src/detectors/cloudflare-ip-geo/cloudflare-ip-geo.md) — _(no submodules)_- [cookie](./src/detectors/cookie/cookie.md) — _(no submodules)_ - [cookie](./src/detectors/cookie/cookie.md) — _(no submodules)_- [fallback](./src/detectors/fallback/fallback.md) — _(no submodules)_ - [fallback](./src/detectors/fallback/fallback.md) — _(no submodules)_- [html-lang](./src/detectors/html-lang/html-lang.md) — _(no submodules)_ - [html-lang](./src/detectors/html-lang/html-lang.md) — _(no submodules)_- [local-storage](./src/detectors/local-storage/local-storage.md) — _(no submodules)_ - [local-storage](./src/detectors/local-storage/local-storage.md) — _(no submodules)_- [navigator](./src/detectors/navigator/navigator.md) — _(no submodules)_ - [navigator](./src/detectors/navigator/navigator.md) — _(no submodules)_- [querystring](./src/detectors/querystring/querystring.md) — _(no submodules)_ - [querystring](./src/detectors/querystring/querystring.md) — _(no submodules)_- [user-preference](./src/detectors/user-preference/user-preference.md) — _(no submodules)_ - [user-preference](./src/detectors/user-preference/user-preference.md) — _(no submodules)_- [direction](./src/direction/direction.md) — _(no submodules)_ - [direction](./src/direction/direction.md) — _(no submodules)_- [observability](./src/observability/metrics.md) — _(no submodules)_ - [metrics](./src/observability/metrics.md) — _(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/` 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