Initial Commit

This commit is contained in:
2026-05-27 23:29:53 +01:00
commit fbc70f96e6
10000 changed files with 1102152 additions and 0 deletions

View File

@@ -0,0 +1,173 @@
# `@suaveplan/i18n-detector` — locale detection chain
**Package**: `@suaveplan/i18n-detector` (NEW)
**Status**: PROPOSED
**Type**: feature (new universal-tier package; consolidates the misplaced react-i18n detector folder + the genesis monolith chain orchestrator)
**Date**: 2026-05-05
**Wave**: 4 (depends on Waves 1–2 foundation; required by Wave 6 core slim)
**Design doc**: `openspec/cross-cutting-designs/2026-05-05-i18n-modular-split.md`
**Tier**: universal
**Path**: `packages/i18n/i18n-detector/`
## Why
Locale detection is the single most architecturally mis-placed piece of the i18n stack on genesis today. The chain orchestrator (`createDetectorChain`, `DETECTOR_CHAIN_UNRESOLVED`, `LocaleDetector` interface, `DetectorContext`, `DetectorEnvironment`) lives in `packages/i18n/i18n/src/detector/` inside the universal-tier monolith. The eight individual detector implementations (querystring, user-preference, cookie, localStorage, html-lang, Accept-Language, navigator, Cloudflare IP-geo) plus a `defaultDetectorChain` factory and a `fallbackDetector` were authored under `packages/react/react-i18n/src/detector/` — a browser-tier React binding package — even though every detector is framework-agnostic and most have no DOM dependency at all (Accept-Language and IP-geo are server-only; querystring works on either side; only localStorage / html-lang / navigator actually touch a browser global, and even then only via a guarded `globalThis` probe).
The damage today:
1. **Tier inversion.** Server-only detectors (`acceptLanguageDetector`, `cloudflareIpGeoDetector`) live under a React binding package. A Node-only consumer that wants Accept-Language negotiation has to install React.
2. **Universal consumers can't compose chains.** Only React consumers see the eight detectors. A Hono / Express / Bun.serve handler that wants `cookieDetector + acceptLanguageDetector` has nothing to import.
3. **The chain orchestrator and its detectors are split across two packages.** `createDetectorChain` lives in `@suaveplan/i18n` but the only detectors that compose into it live in `@suaveplan/react-i18n`. `defaultDetectorChain` cannot live in a universal package without a React detour — the design doc explicitly flags this as the reason `i18n-detector` exists as a Wave 4 package.
4. **`getTextDirection` is misfiled.** Today it lives in `@suaveplan/i18n/utils/utils.ts` (`L142-L146`). It is a pure function over `RTL_LOCALES`; semantically it belongs with the detection / locale-direction surface, not with key-path utilities.
This change consolidates every detector implementation, the chain orchestrator, the standalone `getTextDirection` helper, and the `defaultDetectorChain` factory into a single universal-tier package: `@suaveplan/i18n-detector`. The package depends only on `@suaveplan/i18n-shared` (for `RTL_LOCALES`, `LOCALE_BCP47_MAP`, `I18N_CODES`, `I18nError`) and `@suaveplan/locale-data` (for the ISO 3166 `getAllCountries()` lookup the Cloudflare detector uses to map country codes to locales). It depends on neither `@suaveplan/i18n` core nor any formatter / backend / parser package.
### Dual-tier strategy (universal package, browser + server detectors)
The detector chain runs in both browser and server contexts. Universal-tier means every detector MUST gracefully short-circuit when the runtime it expects is absent. Concretely:
- **Browser-only detectors** (`htmlLangDetector`, `localStorageDetector`, `navigatorDetector`) probe `globalThis.document` / `globalThis.localStorage` / `globalThis.navigator` and return `null` when the global is absent. The chain orchestrator additionally honours `LocaleDetector.environment === "browser"` so server runs skip them entirely.
- **Server-only detectors** (`acceptLanguageDetector`, `cloudflareIpGeoDetector`) gate on `ctx.request !== undefined` and return `null` otherwise. Their `environment === "server"` causes the chain to skip them on a browser run.
- **Universal detectors** (`querystringDetector`, `cookieDetector`, `userPreferenceDetector`, `fallbackDetector`) carry `environment === "both"` and fall back through `ctx.request → globalThis` lookups so they work in either tier.
This is the same dual-tier pattern `@suaveplan/db-sqlite` uses (Bun on server / sqlite-wasm in browser): one universal package, runtime-checked branches per environment. No conditional `package.json` exports, no `node:` import in any source file.
## What changes
### New package: `@suaveplan/i18n-detector`
#### Types and chain orchestrator
- ADDED: `packages/i18n/i18n-detector/` package scaffold (package.json, tsconfig.json, vite.config.ts, README.md).
- ADDED: `src/detector.types.ts` — ports `DetectorStorageLike`, `DetectorContext`, `DetectorEnvironment`, `LocaleDetector`, `DetectorChainResult` from the monolith verbatim. Source: `packages/i18n/i18n/src/detector/detector.types.ts` (full file).
- ADDED: `src/chain/chain.ts` — ports `createDetectorChain`, `DETECTOR_CHAIN_UNRESOLVED`, `DetectorChainUnresolved`, `DetectorChain` from the monolith. Source: `packages/i18n/i18n/src/detector/chain/chain.ts` (full file). **Modified**: replaces direct `LocaleResolver` import with an injectable `negotiate: (raw: string, supported: readonly string[], fallback: string) => string | null` parameter on `createDetectorChain(detectors, options?)`. The package ships a default BCP-47 negotiator (`bcp47Negotiate`) that consumes `LOCALE_BCP47_MAP` from `@suaveplan/i18n-shared`. Throws `I18nError(I18N.DETECTOR_CHAIN_EXHAUSTED)` when `ctx.supportedLocales` is empty AND the consumer opted into `throwOnExhausted: true` (back-compat default returns `DETECTOR_CHAIN_UNRESOLVED`).
- ADDED: `src/chain/bcp47-negotiate.ts` — default `negotiate()` implementation that normalises raw locale via `LOCALE_BCP47_MAP`, walks supported locales for an exact / language-subtag match, and returns `null` on miss (so the chain advances to the next detector instead of forcing the fallback prematurely).
- ADDED: `src/chain/index.ts` — explicit named re-exports.
#### Direction helper
- ADDED: `src/direction/direction.ts` — exports `getTextDirection(locale: string): "ltr" | "rtl"` and `isRtl(locale: string): boolean`. Source: `packages/i18n/i18n/src/utils/utils.ts` `L138-L153` (port + retype: removes the indirect `import type { TextDirection } from "../middleware/server/server.types.js"` and inlines `type TextDirection = "ltr" | "rtl"` here).
- ADDED: `src/direction/index.ts` — explicit named re-exports.
#### Individual detector implementations
Each detector lives in its own folder with `<name>.ts`, `<name>.types.ts` (when options exist), `index.ts` (explicit named re-exports), `<name>.test.ts`, and `<name>.md`:
- ADDED: `src/detectors/querystring/querystring.ts` — `querystringDetector(options?: QuerystringDetectorOptions): LocaleDetector`. `environment: "both"`. Reads `?lang=` from `ctx.request.url.searchParams` (server) or `globalThis.location.search` via `URLSearchParams` (browser). Source: `packages/react/react-i18n/src/detector/querystring/querystring.ts` (full file, 42 lines).
- ADDED: `src/detectors/user-preference/user-preference.ts` — `userPreferenceDetector(options: UserPreferenceDetectorOptions): LocaleDetector`. `environment: "both"`. Async GET on `options.endpoint` + best-effort POST on cache. Replaces the direct `ConsoleLogger` dependency with the `LoggerLike` from `@suaveplan/types` accepted via `options.logger?` (defaults to a no-op so the package keeps zero non-shared deps). Source: `packages/react/react-i18n/src/detector/user-preference/user-preference.ts` (full file, 105 lines).
- ADDED: `src/detectors/cookie/cookie.ts` — `cookieDetector(options?: CookieDetectorOptions): LocaleDetector`. `environment: "both"`. Reads inbound `Cookie` header / writes `Set-Cookie` on `ctx.responseHeaders` (server) or reads/writes `document.cookie` (browser). Source: `packages/react/react-i18n/src/detector/cookie/cookie.ts` (full file, 110 lines).
- ADDED: `src/detectors/local-storage/local-storage.ts` — `localStorageDetector(options?: LocalStorageDetectorOptions): LocaleDetector`. `environment: "browser"`. Reads/writes `globalThis.localStorage` with `try/catch` for private-mode / quota failures. Source: `packages/react/react-i18n/src/detector/local-storage/local-storage.ts` (full file, 69 lines).
- ADDED: `src/detectors/html-lang/html-lang.ts` — `htmlLangDetector(): LocaleDetector`. `environment: "browser"`. Reads `document.documentElement.lang`. Source: `packages/react/react-i18n/src/detector/html-lang/html-lang.ts` (full file, 37 lines).
- ADDED: `src/detectors/accept-language/accept-language.ts` — `acceptLanguageDetector(): LocaleDetector` plus exported `parseAcceptLanguage(header: string): AcceptLanguageEntry[]`. `environment: "server"`. RFC 9110 §5.3.5 q-value parsing with stable sort. Source: `packages/react/react-i18n/src/detector/accept-language/accept-language.ts` (full file, 82 lines).
- ADDED: `src/detectors/navigator/navigator.ts` — `navigatorDetector(): LocaleDetector`. `environment: "browser"`. Reads `globalThis.navigator.languages` (preferred) or `navigator.language` (fallback). Source: `packages/react/react-i18n/src/detector/navigator/navigator.ts` (full file, 50 lines).
- ADDED: `src/detectors/cloudflare-ip-geo/cloudflare-ip-geo.ts` — `cloudflareIpGeoDetector(options?: CloudflareIpGeoDetectorOptions): LocaleDetector`. `environment: "server"`. Maps `cf-ipcountry` ISO 3166-1 alpha-2 to a BCP-47 locale via `getAllCountries()` from `@suaveplan/locale-data` plus an optional override map. Source: `packages/react/react-i18n/src/detector/cloudflare-ip-geo/cloudflare-ip-geo.ts` (full file, 50 lines).
- ADDED: `src/detectors/fallback/fallback.ts` — `fallbackDetector(locale: string): LocaleDetector`. `environment: "both"`. Always returns the configured locale. Throws `I18nError(I18N.INVALID_LOCALE)` on empty/whitespace input (replaces the upstream `throw new Error(...)`). Source: `packages/react/react-i18n/src/detector/fallback/fallback.ts` (full file, 24 lines).
#### Default chain factory
- ADDED: `src/default-chain/default-chain.ts` — `defaultDetectorChain(options: DefaultDetectorChainOptions): LocaleDetector[]` returning the canonical 8-or-9-entry chain (querystring → userPreference? → cookie → localStorage → htmlLang → acceptLanguage → navigator → cloudflareIpGeo → fallback). Source: `packages/react/react-i18n/src/detector/default-chain/default-chain.ts` (full file, 54 lines).
#### Root barrel
- ADDED: `src/index.ts` — explicit named re-exports of every public symbol across `chain/`, `direction/`, `detectors/`, and `default-chain/`. NO `export *` per CLAUDE.md §10.
### Subpath exports (per `package.json` `exports` map)
- `.` — root barrel (every public symbol)
- `./chain` — `createDetectorChain`, `DETECTOR_CHAIN_UNRESOLVED`, `DetectorChain`, `DetectorChainUnresolved`, `bcp47Negotiate`
- `./direction` — `getTextDirection`, `isRtl`
- `./detectors/querystring` — `querystringDetector`, `QuerystringDetectorOptions`
- `./detectors/user-preference` — `userPreferenceDetector`, `UserPreferenceDetectorOptions`, `UserPreferenceResponseBody`
- `./detectors/cookie` — `cookieDetector`, `CookieDetectorOptions`, `CookieSameSite`
- `./detectors/local-storage` — `localStorageDetector`, `LocalStorageDetectorOptions`, `LocalStorageLike`
- `./detectors/html-lang` — `htmlLangDetector`
- `./detectors/accept-language` — `acceptLanguageDetector`, `parseAcceptLanguage`, `AcceptLanguageEntry`
- `./detectors/navigator` — `navigatorDetector`
- `./detectors/cloudflare-ip-geo` — `cloudflareIpGeoDetector`, `CloudflareIpGeoDetectorOptions`
- `./detectors/fallback` — `fallbackDetector`
- `./default-chain` — `defaultDetectorChain`, `DefaultDetectorChainOptions`
### Dependencies
- `@suaveplan/i18n-shared workspace:^` — for `RTL_LOCALES` (direction), `LOCALE_BCP47_MAP` (BCP-47 normalisation in default negotiator), `I18N_CODES` + `I18nError` (every error throw).
- `@suaveplan/locale-data workspace:^` — for `getAllCountries()` consumed by `cloudflareIpGeoDetector` to map ISO 3166-1 alpha-2 → BCP-47.
- `@suaveplan/types workspace:^` — for `LoggerLike` shape consumed by `userPreferenceDetector`.
- `@suaveplan/error workspace:^` — for `defineCodes` consumer-side helpers (re-export consumers may need).
- `@opentelemetry/api catalog:observability` — for chain + per-detector histograms / counters.
- `tslib catalog:build` — TS helper inlining.
- `zod catalog:forms` — boundary schemas (`DetectorContextSchema`, `DefaultDetectorChainOptionsSchema`, every `<Detector>OptionsSchema`).
### Observability
- `i18n.detector.<name>.duration_ms` (histogram) — per detector, async-detector wall time.
- `i18n.detector.<name>.detected_total{locale=<resolved>}` (counter) — per detector, increments on a non-null resolution.
- `i18n.detector.chain.exhausted_total` (counter) — increments every time the entire chain returns `DETECTOR_CHAIN_UNRESOLVED`.
- `i18n.detect` (span) — wraps every chain run; sets `resolved_locale` and `winning_detector` attributes (already present in monolith chain).
### AbortSignal contract
Every async detector (`userPreferenceDetector.detectAsync`, `userPreferenceDetector.cache`, `cloudflareIpGeoDetector.detect` if extended in future) MUST honour `ctx.signal` by passing it on `RequestInit.signal`. The chain orchestrator threads `ctx.signal` through unchanged. A pre-aborted signal returns `null` from the detector immediately (caught + logged at warn via the injected `logger`).
## Out of scope
- **Removing the detector folder from `packages/i18n/i18n/src/`** — happens in Wave 6 (`i18n-core-slim`). For Waves 4–5 the monolith retains its copy and the leaves consume from `@suaveplan/i18n-shared` + this package; both compile.
- **Removing the detector folder from `packages/react/react-i18n/src/`** — that follow-up change re-exports every symbol from this package and is tracked separately as `react-i18n-detector-reexport`.
- **Adding new detectors beyond the existing 8** — the design doc lists exactly the eight currently in the react-i18n source. New detectors (Vercel `x-vercel-ip-country`, Fastly `Fastly-Geo-Country`, custom-header) ship in follow-ups, each as its own subpath under `./detectors/<name>/`.
- **Replacing the chain's `LocaleResolver` dependency with a full-featured negotiator (CLDR fallback chain, script-aware matching, RFC 4647 lookup)** — the embedded `bcp47Negotiate` covers the existing monolith's `LocaleResolver.resolve()` happy path; full CLDR support is a follow-up that lands as `i18n-shared-cldr-negotiate`.
- **Persisting cookie SameSite=None requires Secure flag enforcement** — the current implementation does not auto-add `; Secure` for `SameSite=None`; that is tracked as `cookie-detector-secure-flag` (separate change).
## Source mapping
| Requirement | Source file | Source lines | Notes |
|---|---|---|---|
| `LocaleDetector`, `DetectorContext`, `DetectorEnvironment`, `DetectorChainResult`, `DetectorStorageLike` types | `packages/i18n/i18n/src/detector/detector.types.ts` | L1-L87 | Verbatim port |
| `createDetectorChain`, `DETECTOR_CHAIN_UNRESOLVED`, `DetectorChain`, `DetectorChainUnresolved` | `packages/i18n/i18n/src/detector/chain/chain.ts` | L1-L220 | Port; replace `LocaleResolver` import with injectable `negotiate` parameter |
| `getTextDirection`, `isRtl` | `packages/i18n/i18n/src/utils/utils.ts` | L138-L153 | Port; inline `type TextDirection = "ltr" \| "rtl"` (drop `middleware/server/server.types` import) |
| `querystringDetector`, `QuerystringDetectorOptions` | `packages/react/react-i18n/src/detector/querystring/querystring.ts` | L1-L42 | Verbatim port; rewire `LocaleDetector` import to `../../detector.types.js` |
| `userPreferenceDetector`, `UserPreferenceDetectorOptions`, `UserPreferenceResponseBody` | `packages/react/react-i18n/src/detector/user-preference/user-preference.ts` | L1-L105 | Port; replace `ConsoleLogger` dep with `options.logger?: LoggerLike` |
| `cookieDetector`, `CookieDetectorOptions`, `CookieSameSite` | `packages/react/react-i18n/src/detector/cookie/cookie.ts` | L1-L110 | Verbatim port |
| `localStorageDetector`, `LocalStorageDetectorOptions`, `LocalStorageLike` | `packages/react/react-i18n/src/detector/local-storage/local-storage.ts` | L1-L69 | Verbatim port |
| `htmlLangDetector` | `packages/react/react-i18n/src/detector/html-lang/html-lang.ts` | L1-L37 | Verbatim port |
| `acceptLanguageDetector`, `parseAcceptLanguage`, `AcceptLanguageEntry` | `packages/react/react-i18n/src/detector/accept-language/accept-language.ts` | L1-L82 | Verbatim port |
| `navigatorDetector` | `packages/react/react-i18n/src/detector/navigator/navigator.ts` | L1-L50 | Verbatim port |
| `cloudflareIpGeoDetector`, `CloudflareIpGeoDetectorOptions` | `packages/react/react-i18n/src/detector/cloudflare-ip-geo/cloudflare-ip-geo.ts` | L1-L50 | Verbatim port; `getAllCountries` import already targets `@suaveplan/locale-data` |
| `fallbackDetector` | `packages/react/react-i18n/src/detector/fallback/fallback.ts` | L1-L24 | Port; replace `throw new Error(...)` with `throw new I18nError(I18N_CODES.INVALID_LOCALE, ...)` |
| `defaultDetectorChain`, `DefaultDetectorChainOptions` | `packages/react/react-i18n/src/detector/default-chain/default-chain.ts` | L1-L54 | Verbatim port; rewire detector imports to sibling folders |
### Requirements with no source line
| Requirement | Justification |
|---|---|
| `bcp47Negotiate` default negotiator | Today the chain hard-imports `LocaleResolver` from `../../locale/resolver/resolver.js`. The detector package cannot pull the resolver without dragging the entire schemas / errors / locale subtree with it; the cleanest split is a small embedded negotiator that handles the monolith's happy-path negotiation against `LOCALE_BCP47_MAP` and accepts an injectable `negotiate` for advanced consumers (slim core wires the full `LocaleResolver` in Wave 6) |
| Subpath exports per individual detector | Tree-shaking — a Hono server consumer should be able to import only `@suaveplan/i18n-detector/detectors/accept-language` without pulling browser-only detectors |
| OTel histograms `i18n.detector.<name>.duration_ms` and counters `i18n.detector.<name>.detected_total` | Today the monolith chain emits aggregated `i18n.detector.match_total{detector}` and `i18n.detector.duration_ms{detector}`. Per-package split keeps the same surface but moves the metrics to per-detector helpers so the chain orchestrator stays decoupled from any specific detector implementation |
| `i18n.detector.chain.exhausted_total` counter | New observability surface — the monolith silently returns the unresolved sentinel; production needs a counter to alert on chain exhaustion |
| `I18nError(I18N.DETECTOR_CHAIN_EXHAUSTED)` thrown when `throwOnExhausted: true` | New error path — the monolith only returns the sentinel; production consumers (especially server-side renderers) need a typed throw they can catch and map to a 4xx |
## Verification approach
Each requirement carries a `**Verified by:**` line in the spec delta. Concrete test files:
- `packages/i18n/i18n-detector/src/chain/chain.test.ts` — sync chain runs in registration order, async chain races, environment gating, `DETECTOR_CHAIN_UNRESOLVED` sentinel, `cache()` walks every persisting detector, AbortSignal threading, throw-on-exhausted opt-in
- `packages/i18n/i18n-detector/src/chain/bcp47-negotiate.test.ts` — exact match, language-subtag fallback (`zh-Hant-TW` → `zh`), `null` on miss, `LOCALE_BCP47_MAP` short-code expansion (`en` → `en-GB`)
- `packages/i18n/i18n-detector/src/direction/direction.test.ts` — `getTextDirection("ar")` is `"rtl"`, `getTextDirection("en-GB")` is `"ltr"`, language-subtag extraction works (`ar-SA` → `"rtl"`); `isRtl` mirrors
- `packages/i18n/i18n-detector/src/detectors/querystring/querystring.test.ts` — server `?lang=fr` resolves; browser `globalThis.location.search` resolves; missing param returns `null`; both contexts absent returns `null`
- `packages/i18n/i18n-detector/src/detectors/user-preference/user-preference.test.ts` — happy GET returns body locale; non-2xx logs + returns `null`; rejected fetch returns `null`; AbortSignal threaded; cache POST round-trips; cache failure is non-fatal
- `packages/i18n/i18n-detector/src/detectors/cookie/cookie.test.ts` — server reads inbound Cookie header; server writes Set-Cookie on responseHeaders; browser reads/writes `document.cookie`; URL-encoding round-trip; SameSite formatting
- `packages/i18n/i18n-detector/src/detectors/local-storage/local-storage.test.ts` — explicit storage option; `globalThis.localStorage` fallback; missing storage returns `null`; quota error caught
- `packages/i18n/i18n-detector/src/detectors/html-lang/html-lang.test.ts` — `ctx.document` lookup; `globalThis.document` fallback; missing root returns `null`; empty `lang` returns `null`
- `packages/i18n/i18n-detector/src/detectors/accept-language/accept-language.test.ts` — q-value sort; stable order on ties; `*` wildcard skipped; malformed q skipped; missing header returns `null`
- `packages/i18n/i18n-detector/src/detectors/navigator/navigator.test.ts` — `navigator.languages` preferred; `navigator.language` fallback; missing navigator returns `null`
- `packages/i18n/i18n-detector/src/detectors/cloudflare-ip-geo/cloudflare-ip-geo.test.ts` — `AE` → `ar-AE` via locale-data; override map wins; missing header returns `null`; unknown country returns `null`
- `packages/i18n/i18n-detector/src/detectors/fallback/fallback.test.ts` — returns configured locale; empty locale throws `I18nError(I18N.INVALID_LOCALE)`
- `packages/i18n/i18n-detector/src/default-chain/default-chain.test.ts` — order matches spec; `userPreference` only when endpoint set; result is a fresh array; fallback always last
- `packages/i18n/i18n-detector/src/index.test.ts` — every documented subpath export resolves
## Impact
- New universal-tier package — joins `@suaveplan/i18n-shared`, `@suaveplan/i18n-icu-parser`, and `@suaveplan/locale-data` under `packages/i18n/`.
- `@suaveplan/i18n` slim core (Wave 6) re-exports `createDetectorChain`, `getTextDirection`, `LocaleDetector`, `DetectorContext` from this package — every existing consumer keeps working.
- `@suaveplan/react-i18n` (follow-up `react-i18n-detector-reexport` change) deletes its own `src/detector/` folder and re-exports the eight detectors plus `defaultDetectorChain` from this package.
- `MANIFEST.md` regenerated via `bun scripts/folder-reorg/generate-manifest.ts`.
- `CLAUDE.md` package listing updated with a one-line entry under the i18n cluster.
- Net dependency-graph effect: removes `@suaveplan/i18n` ← `@suaveplan/react-i18n` runtime coupling for detector composition (was only resolved via the `@suaveplan/i18n/detector` subpath today).

View File

@@ -0,0 +1,504 @@
<!-- markdownlint-disable MD024 -->
# Capability Spec: i18n-detector (i18n-detector)
**Package**: `@suaveplan/i18n-detector`
**Status**: PROPOSED — first delta; becomes canonical spec on archive.
> Every requirement below carries a `**Verified by:**` line at authoring time. (See `openspec/AGENTS.md` Section 13.)
## ADDED Requirements
### Requirement: Detector chain orchestrator
The package SHALL export `createDetectorChain(detectors, options?)` that returns a `DetectorChain` with `detect(ctx)` and `cache(locale, ctx)` methods. `detect(ctx)` SHALL run synchronous detectors in registration order; the first non-null value that negotiates to a supported locale wins. If no synchronous detector matches, every async detector SHALL be invoked in parallel and the first to resolve to a negotiable locale wins. Detectors whose `environment` does not match the runtime context (server detectors when `ctx.request === undefined`, browser detectors when `ctx.request !== undefined`) SHALL be skipped. When every detector returns `null`, the chain SHALL return `DETECTOR_CHAIN_UNRESOLVED` by default OR throw `I18nError(I18N.DETECTOR_CHAIN_EXHAUSTED)` when `options.throwOnExhausted === true`. The chain SHALL emit a `i18n.detect` OTel span with `resolved_locale` and `winning_detector` attributes on every run, and SHALL increment `i18n.detector.chain.exhausted_total` on every sentinel return. The chain SHALL accept an optional `options.negotiate: NegotiateFn`; when omitted, the chain SHALL use the package's `bcp47Negotiate` default.
#### Scenario: sync chain returns first matching detector in order
- **GIVEN** three sync detectors registered in order [A returns null, B returns "fr", C returns "es"]
- **WHEN** `chain.detect(ctx)` is called with supportedLocales `["en", "fr", "es"]`
- **THEN** the result is `{ locale: "fr", detector: "B", rawValue: "fr" }` AND C is never invoked
#### Scenario: async chain races to first resolved detector
- **GIVEN** two async detectors where A resolves to "fr" after 5ms and B resolves to "de" after 1ms
- **AND** no sync detector matches
- **WHEN** `chain.detect(ctx)` is called
- **THEN** the result has `locale: "de"` AND `detector: "B"`
#### Scenario: environment gating skips browser detectors on server context
- **GIVEN** detectors `[serverOnly, browserOnly]` and `ctx.request !== undefined`
- **WHEN** `chain.detect(ctx)` is called
- **THEN** only `serverOnly.detect` is invoked
#### Scenario: empty supportedLocales returns sentinel without invoking detectors
- **GIVEN** `ctx.supportedLocales = []`
- **WHEN** `chain.detect(ctx)` is called
- **THEN** the result equals `DETECTOR_CHAIN_UNRESOLVED` AND no detector is invoked
#### Scenario: throwOnExhausted opt-in raises typed error
- **GIVEN** every detector returns `null`
- **AND** the chain was built with `{ throwOnExhausted: true }`
- **WHEN** `chain.detect(ctx)` is called
- **THEN** the call throws `I18nError` with code `"I18N.DETECTOR_CHAIN_EXHAUSTED"`
#### Scenario: cache walks every persisting detector
- **GIVEN** detectors `[A with cache, B without cache, C with cache]`
- **WHEN** `chain.cache("fr", ctx)` is called
- **THEN** `A.cache("fr", ctx)` and `C.cache("fr", ctx)` are invoked AND `B` is skipped
**Verified by:** `packages/i18n/i18n-detector/src/chain/chain.test.ts::"sync chain returns first matching detector in registration order"` AND `packages/i18n/i18n-detector/src/chain/chain.test.ts::"async chain races to first resolved detector"` AND `packages/i18n/i18n-detector/src/chain/chain.test.ts::"environment gating skips browser detectors on server context"` AND `packages/i18n/i18n-detector/src/chain/chain.test.ts::"empty supportedLocales returns sentinel"` AND `packages/i18n/i18n-detector/src/chain/chain.test.ts::"throwOnExhausted raises DETECTOR_CHAIN_EXHAUSTED"` AND `packages/i18n/i18n-detector/src/chain/chain.test.ts::"cache walks every persisting detector"` AND `packages/i18n/i18n-detector/src/chain/chain.test.ts::"emits i18n.detect span with resolved_locale and winning_detector"` AND `packages/i18n/i18n-detector/src/chain/chain.test.ts::"increments i18n.detector.chain.exhausted_total on sentinel return"`
---
### Requirement: BCP-47 default negotiator
The package SHALL export `bcp47Negotiate(raw, supported, fallback): string | null` consuming `LOCALE_BCP47_MAP` from `@suaveplan/i18n-shared`. The negotiator SHALL: (a) normalise short codes via the BCP-47 map (`"en"` → `"en-GB"`); (b) return the supplied raw locale unchanged if it appears in `supported`; (c) extract the language subtag and return the first supported locale whose language subtag matches; (d) return `null` on miss so the chain advances to the next detector instead of forcing the fallback prematurely.
#### Scenario: exact match returns input
- **WHEN** `bcp47Negotiate("fr-FR", ["en-GB", "fr-FR"], "en-GB")` is called
- **THEN** the result equals `"fr-FR"`
#### Scenario: short code expands via LOCALE_BCP47_MAP
- **WHEN** `bcp47Negotiate("en", ["en-GB", "fr-FR"], "en-GB")` is called
- **THEN** the result equals `"en-GB"`
#### Scenario: language-subtag fallback
- **WHEN** `bcp47Negotiate("zh-Hant-TW", ["zh-CN", "en-GB"], "en-GB")` is called
- **THEN** the result equals `"zh-CN"` (matched on `zh` language subtag)
#### Scenario: returns null on miss
- **WHEN** `bcp47Negotiate("xx-XX", ["en-GB"], "en-GB")` is called
- **THEN** the result equals `null`
**Verified by:** `packages/i18n/i18n-detector/src/chain/bcp47-negotiate.test.ts::"exact match returns input"` AND `packages/i18n/i18n-detector/src/chain/bcp47-negotiate.test.ts::"short code expands via LOCALE_BCP47_MAP"` AND `packages/i18n/i18n-detector/src/chain/bcp47-negotiate.test.ts::"language-subtag fallback"` AND `packages/i18n/i18n-detector/src/chain/bcp47-negotiate.test.ts::"returns null on miss"`
---
### Requirement: Querystring detector
The package SHALL export `querystringDetector(options?: QuerystringDetectorOptions): LocaleDetector` with `environment: "both"`. The detector SHALL read the configured query parameter (default `lang`) from `ctx.request.url.searchParams` (server) or from `globalThis.location.search` via `URLSearchParams` (browser). Empty values and missing globals SHALL return `null`. The detector SHALL emit `i18n.detector.querystring.detected_total{locale}` on every match.
#### Scenario: server context resolves from request URL
- **GIVEN** `ctx.request.url = new URL("https://example.com/?lang=fr")`
- **WHEN** `querystringDetector().detect(ctx)` is called
- **THEN** the result equals `"fr"`
#### Scenario: browser context resolves from globalThis.location
- **GIVEN** `globalThis.location = { search: "?lang=es" }` AND `ctx.request === undefined`
- **WHEN** `querystringDetector().detect(ctx)` is called
- **THEN** the result equals `"es"`
#### Scenario: missing param returns null
- **GIVEN** `ctx.request.url = new URL("https://example.com/")` AND no browser global
- **WHEN** `querystringDetector().detect(ctx)` is called
- **THEN** the result equals `null`
#### Scenario: custom paramName honoured
- **WHEN** `querystringDetector({ paramName: "locale" }).detect(ctx)` is called against `?locale=de`
- **THEN** the result equals `"de"`
**Verified by:** `packages/i18n/i18n-detector/src/detectors/querystring/querystring.test.ts::"server context resolves from request URL"` AND `packages/i18n/i18n-detector/src/detectors/querystring/querystring.test.ts::"browser context resolves from globalThis.location"` AND `packages/i18n/i18n-detector/src/detectors/querystring/querystring.test.ts::"missing param returns null"` AND `packages/i18n/i18n-detector/src/detectors/querystring/querystring.test.ts::"custom paramName honoured"`
---
### Requirement: User-preference detector
The package SHALL export `userPreferenceDetector(options: UserPreferenceDetectorOptions): LocaleDetector` with `environment: "both"`. The detector's `detectAsync` SHALL GET `options.endpoint`, parse the response as `{ locale: string }`, and return the locale on success. Non-2xx responses, malformed bodies, rejected fetches, and pre-aborted `ctx.signal` SHALL return `null` after emitting a warn-level log via the injected `LoggerLike`. The detector's `cache` SHALL POST `{ locale }` to the same endpoint with `Content-Type: application/json` and any headers returned by `options.extractAuth(ctx)`. Cache failures SHALL NOT throw. Every fetch SHALL receive `ctx.signal` on `RequestInit.signal`. The detector SHALL emit `i18n.detector.userPreference.duration_ms` on every async call.
#### Scenario: happy GET returns body locale
- **GIVEN** `options.fetch` returns `{ ok: true, json: () => ({ locale: "fr" }) }`
- **WHEN** `userPreferenceDetector(options).detectAsync(ctx)` is awaited
- **THEN** the result equals `"fr"`
#### Scenario: non-2xx returns null
- **GIVEN** `options.fetch` returns `{ ok: false, status: 500 }`
- **WHEN** `userPreferenceDetector(options).detectAsync(ctx)` is awaited
- **THEN** the result equals `null` AND a warn log was emitted
#### Scenario: rejected fetch returns null
- **GIVEN** `options.fetch` rejects with `new Error("network down")`
- **WHEN** `userPreferenceDetector(options).detectAsync(ctx)` is awaited
- **THEN** the result equals `null` AND a warn log was emitted
#### Scenario: AbortSignal threaded into fetch
- **GIVEN** `ctx.signal = AbortSignal.abort()`
- **WHEN** `userPreferenceDetector(options).detectAsync(ctx)` is awaited
- **THEN** the underlying fetch was invoked with `init.signal === ctx.signal`
#### Scenario: cache POST round-trips body
- **WHEN** `userPreferenceDetector(options).cache("fr", ctx)` is awaited
- **THEN** the underlying fetch received `method: "POST"` AND `body: '{"locale":"fr"}'` AND `Content-Type: application/json`
**Verified by:** `packages/i18n/i18n-detector/src/detectors/user-preference/user-preference.test.ts::"happy GET returns body locale"` AND `packages/i18n/i18n-detector/src/detectors/user-preference/user-preference.test.ts::"non-2xx returns null"` AND `packages/i18n/i18n-detector/src/detectors/user-preference/user-preference.test.ts::"rejected fetch returns null"` AND `packages/i18n/i18n-detector/src/detectors/user-preference/user-preference.test.ts::"AbortSignal threaded into fetch"` AND `packages/i18n/i18n-detector/src/detectors/user-preference/user-preference.test.ts::"cache POST round-trips body"` AND `packages/i18n/i18n-detector/src/detectors/user-preference/user-preference.test.ts::"cache failure non-fatal"`
---
### Requirement: Cookie detector
The package SHALL export `cookieDetector(options?: CookieDetectorOptions): LocaleDetector` with `environment: "both"`. On the server the detector SHALL read the inbound `Cookie` request header and write `Set-Cookie` to `ctx.responseHeaders` via `append`. On the browser the detector SHALL read and write `document.cookie`. Cookie values SHALL be URL-encoded. The cookie name (default `suaveplan_locale`), `Max-Age` (default 31536000), `SameSite` (default `lax`), and `Path` (default `/`) SHALL be configurable via options. When neither `ctx.request` nor a browser `document` global is available, `detect` SHALL return `null` and `cache` SHALL be a no-op.
#### Scenario: server reads inbound Cookie header
- **GIVEN** `ctx.request.headers.get("cookie") === "suaveplan_locale=fr; other=x"`
- **WHEN** `cookieDetector().detect(ctx)` is called
- **THEN** the result equals `"fr"`
#### Scenario: server writes Set-Cookie via responseHeaders
- **GIVEN** `ctx.responseHeaders` is a `Headers` instance
- **WHEN** `cookieDetector().cache("fr", ctx)` is called
- **THEN** `ctx.responseHeaders.get("set-cookie")` matches `/^suaveplan_locale=fr;/`
#### Scenario: browser reads document.cookie
- **GIVEN** `ctx.document.cookie = "suaveplan_locale=es"`
- **WHEN** `cookieDetector().detect(ctx)` is called with no `ctx.request`
- **THEN** the result equals `"es"`
#### Scenario: URL-encoded value round-trips
- **GIVEN** `cookieDetector().cache("zh-Hant-TW", ctx)` is called
- **AND** the cookie is then read by `cookieDetector().detect(ctx)`
- **THEN** the result equals `"zh-Hant-TW"`
#### Scenario: missing both contexts returns null
- **GIVEN** `ctx.request === undefined` AND no `document` global
- **WHEN** `cookieDetector().detect(ctx)` is called
- **THEN** the result equals `null`
**Verified by:** `packages/i18n/i18n-detector/src/detectors/cookie/cookie.test.ts::"server reads inbound Cookie header"` AND `packages/i18n/i18n-detector/src/detectors/cookie/cookie.test.ts::"server writes Set-Cookie via responseHeaders"` AND `packages/i18n/i18n-detector/src/detectors/cookie/cookie.test.ts::"browser reads document.cookie"` AND `packages/i18n/i18n-detector/src/detectors/cookie/cookie.test.ts::"URL-encoded value round-trips"` AND `packages/i18n/i18n-detector/src/detectors/cookie/cookie.test.ts::"missing both contexts returns null"`
---
### Requirement: localStorage detector
The package SHALL export `localStorageDetector(options?: LocalStorageDetectorOptions): LocaleDetector` with `environment: "browser"`. The detector SHALL prefer `options.storage` if supplied, otherwise probe `globalThis.localStorage`. Missing storage, private-mode access errors, and quota errors SHALL be caught silently — `detect` returns `null`, `cache` is a no-op. The storage key (default `suaveplan-locale`) SHALL be configurable.
#### Scenario: explicit storage option used over globalThis
- **GIVEN** `options.storage = { getItem: () => "fr", setItem: vi.fn(), removeItem: vi.fn() }`
- **WHEN** `localStorageDetector(options).detect(ctx)` is called
- **THEN** the result equals `"fr"`
#### Scenario: globalThis.localStorage fallback when no option
- **GIVEN** `globalThis.localStorage` exists with `getItem("suaveplan-locale") === "es"`
- **WHEN** `localStorageDetector().detect(ctx)` is called
- **THEN** the result equals `"es"`
#### Scenario: missing storage returns null
- **GIVEN** no `options.storage` AND no `globalThis.localStorage`
- **WHEN** `localStorageDetector().detect(ctx)` is called
- **THEN** the result equals `null`
#### Scenario: storage error caught
- **GIVEN** `options.storage.getItem` throws (private mode)
- **WHEN** `localStorageDetector(options).detect(ctx)` is called
- **THEN** the result equals `null` AND no exception propagates
**Verified by:** `packages/i18n/i18n-detector/src/detectors/local-storage/local-storage.test.ts::"explicit storage option used over globalThis"` AND `packages/i18n/i18n-detector/src/detectors/local-storage/local-storage.test.ts::"globalThis.localStorage fallback"` AND `packages/i18n/i18n-detector/src/detectors/local-storage/local-storage.test.ts::"missing storage returns null"` AND `packages/i18n/i18n-detector/src/detectors/local-storage/local-storage.test.ts::"storage error caught silently"`
---
### Requirement: HTML lang detector
The package SHALL export `htmlLangDetector(): LocaleDetector` with `environment: "browser"`. The detector SHALL read `documentElement.lang` from `ctx.document` if supplied, else from `globalThis.document`. Missing document, missing root element, and empty/whitespace `lang` attributes SHALL all return `null`.
#### Scenario: ctx.document lookup wins over global
- **GIVEN** `ctx.document.documentElement.lang = "fr"`
- **WHEN** `htmlLangDetector().detect(ctx)` is called
- **THEN** the result equals `"fr"`
#### Scenario: globalThis.document fallback
- **GIVEN** `ctx.document === undefined` AND `globalThis.document.documentElement.lang = "es"`
- **WHEN** `htmlLangDetector().detect(ctx)` is called
- **THEN** the result equals `"es"`
#### Scenario: missing document returns null
- **GIVEN** no `ctx.document` AND no `globalThis.document`
- **WHEN** `htmlLangDetector().detect(ctx)` is called
- **THEN** the result equals `null`
#### Scenario: empty lang returns null
- **GIVEN** `ctx.document.documentElement.lang = " "`
- **WHEN** `htmlLangDetector().detect(ctx)` is called
- **THEN** the result equals `null`
**Verified by:** `packages/i18n/i18n-detector/src/detectors/html-lang/html-lang.test.ts::"ctx.document lookup wins over global"` AND `packages/i18n/i18n-detector/src/detectors/html-lang/html-lang.test.ts::"globalThis.document fallback"` AND `packages/i18n/i18n-detector/src/detectors/html-lang/html-lang.test.ts::"missing document returns null"` AND `packages/i18n/i18n-detector/src/detectors/html-lang/html-lang.test.ts::"empty lang returns null"`
---
### Requirement: Accept-Language detector
The package SHALL export `acceptLanguageDetector(): LocaleDetector` with `environment: "server"` and `parseAcceptLanguage(header: string): AcceptLanguageEntry[]` for advanced consumers. The detector SHALL read `ctx.request.headers.get("accept-language")` and return the highest-q entry per RFC 9110 §5.3.5. Wildcard `*` tokens, malformed q values, q out of range, and empty headers SHALL be skipped (returning `null` if no usable entries remain). The sort SHALL be stable on q-value ties (insertion order preserved).
#### Scenario: returns highest-q entry
- **GIVEN** header `"en;q=0.5, fr;q=0.9, de"`
- **WHEN** `acceptLanguageDetector().detect(ctx)` is called
- **THEN** the result equals `"de"` (default q=1)
#### Scenario: stable sort on q ties
- **GIVEN** header `"fr;q=0.8, de;q=0.8"`
- **WHEN** `parseAcceptLanguage(header)` is called
- **THEN** the first entry has `locale === "fr"` (insertion order preserved)
#### Scenario: wildcard skipped
- **GIVEN** header `"*;q=0.9, en;q=0.5"`
- **WHEN** `acceptLanguageDetector().detect(ctx)` is called
- **THEN** the result equals `"en"`
#### Scenario: q out of range skipped
- **GIVEN** header `"en;q=2.0, fr;q=0.5"`
- **WHEN** `acceptLanguageDetector().detect(ctx)` is called
- **THEN** the result equals `"fr"` (en entry rejected)
#### Scenario: missing header returns null
- **GIVEN** `ctx.request.headers.get("accept-language") === null`
- **WHEN** `acceptLanguageDetector().detect(ctx)` is called
- **THEN** the result equals `null`
**Verified by:** `packages/i18n/i18n-detector/src/detectors/accept-language/accept-language.test.ts::"returns highest-q entry"` AND `packages/i18n/i18n-detector/src/detectors/accept-language/accept-language.test.ts::"stable sort on q ties"` AND `packages/i18n/i18n-detector/src/detectors/accept-language/accept-language.test.ts::"wildcard skipped"` AND `packages/i18n/i18n-detector/src/detectors/accept-language/accept-language.test.ts::"q out of range skipped"` AND `packages/i18n/i18n-detector/src/detectors/accept-language/accept-language.test.ts::"missing header returns null"`
---
### Requirement: Navigator detector
The package SHALL export `navigatorDetector(): LocaleDetector` with `environment: "browser"`. The detector SHALL read `globalThis.navigator.languages` (preferred, returning the first non-empty entry) or fall back to `globalThis.navigator.language`. Whitespace-only entries SHALL be skipped. Missing navigator SHALL return `null`.
#### Scenario: navigator.languages preferred
- **GIVEN** `globalThis.navigator = { languages: ["fr", "en"], language: "de" }`
- **WHEN** `navigatorDetector().detect(ctx)` is called
- **THEN** the result equals `"fr"`
#### Scenario: navigator.language fallback
- **GIVEN** `globalThis.navigator = { language: "es" }` (no languages array)
- **WHEN** `navigatorDetector().detect(ctx)` is called
- **THEN** the result equals `"es"`
#### Scenario: whitespace entries skipped
- **GIVEN** `globalThis.navigator.languages = [" ", "fr"]`
- **WHEN** `navigatorDetector().detect(ctx)` is called
- **THEN** the result equals `"fr"`
#### Scenario: missing navigator returns null
- **GIVEN** no `globalThis.navigator`
- **WHEN** `navigatorDetector().detect(ctx)` is called
- **THEN** the result equals `null`
**Verified by:** `packages/i18n/i18n-detector/src/detectors/navigator/navigator.test.ts::"navigator.languages preferred"` AND `packages/i18n/i18n-detector/src/detectors/navigator/navigator.test.ts::"navigator.language fallback"` AND `packages/i18n/i18n-detector/src/detectors/navigator/navigator.test.ts::"whitespace entries skipped"` AND `packages/i18n/i18n-detector/src/detectors/navigator/navigator.test.ts::"missing navigator returns null"`
---
### Requirement: Cloudflare IP-geo detector
The package SHALL export `cloudflareIpGeoDetector(options?: CloudflareIpGeoDetectorOptions): LocaleDetector` with `environment: "server"`. The detector SHALL read the configured header (default `cf-ipcountry`) from `ctx.request.headers`, treat the value as ISO 3166-1 alpha-2 (upper-cased), and resolve to a BCP-47 locale via either `options.countryToLocale[code]` (highest priority) or `${primaryLanguage}-${code}` derived from `getAllCountries()` in `@suaveplan/locale-data`. Missing header, empty header, and unknown country codes SHALL all return `null`.
#### Scenario: country code maps via locale-data
- **GIVEN** `ctx.request.headers.get("cf-ipcountry") === "AE"`
- **WHEN** `cloudflareIpGeoDetector().detect(ctx)` is called
- **THEN** the result equals `"ar-AE"`
#### Scenario: override map wins over locale-data
- **GIVEN** `options.countryToLocale = { AE: "en-AE" }`
- **AND** `ctx.request.headers.get("cf-ipcountry") === "AE"`
- **WHEN** `cloudflareIpGeoDetector(options).detect(ctx)` is called
- **THEN** the result equals `"en-AE"`
#### Scenario: missing header returns null
- **GIVEN** `ctx.request.headers.get("cf-ipcountry") === null`
- **WHEN** `cloudflareIpGeoDetector().detect(ctx)` is called
- **THEN** the result equals `null`
#### Scenario: unknown country returns null
- **GIVEN** `ctx.request.headers.get("cf-ipcountry") === "ZZ"`
- **WHEN** `cloudflareIpGeoDetector().detect(ctx)` is called
- **THEN** the result equals `null`
#### Scenario: lowercase header value upper-cased
- **GIVEN** `ctx.request.headers.get("cf-ipcountry") === "ae"`
- **WHEN** `cloudflareIpGeoDetector().detect(ctx)` is called
- **THEN** the result equals `"ar-AE"`
**Verified by:** `packages/i18n/i18n-detector/src/detectors/cloudflare-ip-geo/cloudflare-ip-geo.test.ts::"country code maps via locale-data"` AND `packages/i18n/i18n-detector/src/detectors/cloudflare-ip-geo/cloudflare-ip-geo.test.ts::"override map wins over locale-data"` AND `packages/i18n/i18n-detector/src/detectors/cloudflare-ip-geo/cloudflare-ip-geo.test.ts::"missing header returns null"` AND `packages/i18n/i18n-detector/src/detectors/cloudflare-ip-geo/cloudflare-ip-geo.test.ts::"unknown country returns null"` AND `packages/i18n/i18n-detector/src/detectors/cloudflare-ip-geo/cloudflare-ip-geo.test.ts::"lowercase header value upper-cased"`
---
### Requirement: Fallback detector
The package SHALL export `fallbackDetector(locale: string): LocaleDetector` with `environment: "both"`. The detector SHALL always return the configured locale. The constructor SHALL throw `I18nError(I18N.INVALID_LOCALE)` on empty or whitespace-only input. The fallback detector MUST appear last in any chain — it never returns `null`, so any detector after it would never execute.
#### Scenario: returns configured locale
- **GIVEN** `fallbackDetector("en-GB")`
- **WHEN** `detector.detect(ctx)` is called
- **THEN** the result equals `"en-GB"`
#### Scenario: empty locale throws INVALID_LOCALE
- **WHEN** `fallbackDetector("")` is called
- **THEN** the call throws `I18nError` with code `"I18N.INVALID_LOCALE"`
#### Scenario: whitespace-only locale throws INVALID_LOCALE
- **WHEN** `fallbackDetector(" ")` is called
- **THEN** the call throws `I18nError` with code `"I18N.INVALID_LOCALE"`
**Verified by:** `packages/i18n/i18n-detector/src/detectors/fallback/fallback.test.ts::"returns configured locale"` AND `packages/i18n/i18n-detector/src/detectors/fallback/fallback.test.ts::"empty locale throws INVALID_LOCALE"` AND `packages/i18n/i18n-detector/src/detectors/fallback/fallback.test.ts::"whitespace-only locale throws INVALID_LOCALE"`
---
### Requirement: Default detector chain factory
The package SHALL export `defaultDetectorChain(options: DefaultDetectorChainOptions): LocaleDetector[]` returning the canonical 8-or-9-entry chain in the following order: `querystring`, `userPreference` (only when `options.userPreferenceEndpoint` is set), `cookie`, `localStorage`, `htmlLang`, `acceptLanguage`, `navigator`, `cloudflareIpGeo`, `fallback`. The factory SHALL return a fresh array on every call so callers may filter or mutate without affecting subsequent invocations. The fallback detector SHALL always appear last.
#### Scenario: order matches spec without userPreference
- **WHEN** `defaultDetectorChain({ fallback: "en-GB" })` is called
- **THEN** the resulting names are `["querystring", "cookie", "localStorage", "htmlLang", "acceptLanguage", "navigator", "cloudflareIpGeo", "fallback"]`
#### Scenario: userPreference inserted second when endpoint set
- **WHEN** `defaultDetectorChain({ fallback: "en-GB", userPreferenceEndpoint: "/api/me/locale" })` is called
- **THEN** the second name equals `"userPreference"`
#### Scenario: fresh array per call
- **GIVEN** `const a = defaultDetectorChain({ fallback: "en-GB" })`
- **AND** `const b = defaultDetectorChain({ fallback: "en-GB" })`
- **WHEN** the two arrays are compared by identity
- **THEN** `a !== b`
#### Scenario: fallback always last
- **WHEN** `defaultDetectorChain(options)` is called for every options shape
- **THEN** the last entry's `name` equals `"fallback"`
**Verified by:** `packages/i18n/i18n-detector/src/default-chain/default-chain.test.ts::"order matches spec without userPreference"` AND `packages/i18n/i18n-detector/src/default-chain/default-chain.test.ts::"userPreference inserted second when endpoint set"` AND `packages/i18n/i18n-detector/src/default-chain/default-chain.test.ts::"fresh array per call"` AND `packages/i18n/i18n-detector/src/default-chain/default-chain.test.ts::"fallback always last"`
---
### Requirement: Text direction helper
The package SHALL export `getTextDirection(locale: string): "ltr" | "rtl"` and `isRtl(locale: string): boolean`. `getTextDirection` SHALL extract the language subtag (everything before the first `-`), lowercase it, and return `"rtl"` when the subtag is in `RTL_LOCALES` from `@suaveplan/i18n-shared`, else `"ltr"`. `isRtl` SHALL return `true` exactly when `getTextDirection(locale) === "rtl"`.
#### Scenario: Arabic is RTL
- **WHEN** `getTextDirection("ar")` is called
- **THEN** the result equals `"rtl"`
#### Scenario: Arabic with region subtag is RTL
- **WHEN** `getTextDirection("ar-SA")` is called
- **THEN** the result equals `"rtl"`
#### Scenario: English is LTR
- **WHEN** `getTextDirection("en-GB")` is called
- **THEN** the result equals `"ltr"`
#### Scenario: isRtl mirrors getTextDirection
- **WHEN** `isRtl(locale)` is called for any locale
- **THEN** the result equals `getTextDirection(locale) === "rtl"`
**Verified by:** `packages/i18n/i18n-detector/src/direction/direction.test.ts::"Arabic is RTL"` AND `packages/i18n/i18n-detector/src/direction/direction.test.ts::"Arabic with region subtag is RTL"` AND `packages/i18n/i18n-detector/src/direction/direction.test.ts::"English is LTR"` AND `packages/i18n/i18n-detector/src/direction/direction.test.ts::"isRtl mirrors getTextDirection"`
---
### Requirement: AbortSignal honoured by every async detector
Every async detector exported by the package (`userPreferenceDetector.detectAsync`, `userPreferenceDetector.cache`, and any future async detector) SHALL pass `ctx.signal` through to its underlying `RequestInit.signal` when the signal is supplied. A pre-aborted signal SHALL cause the detector to return `null` (or, for `cache`, become a silent no-op) without throwing into the chain orchestrator.
#### Scenario: AbortSignal threaded into userPreference fetch
- **GIVEN** `ctx.signal` is a non-aborted `AbortSignal`
- **WHEN** `userPreferenceDetector(options).detectAsync(ctx)` is awaited
- **THEN** the underlying fetch's `init.signal` strict-equals `ctx.signal`
#### Scenario: pre-aborted signal returns null without throw
- **GIVEN** `ctx.signal = AbortSignal.abort()`
- **AND** `options.fetch` rejects with the signal's `AbortError`
- **WHEN** `userPreferenceDetector(options).detectAsync(ctx)` is awaited
- **THEN** the result equals `null` AND no exception escapes the detector
**Verified by:** `packages/i18n/i18n-detector/src/detectors/user-preference/user-preference.test.ts::"AbortSignal threaded into fetch"` AND `packages/i18n/i18n-detector/src/detectors/user-preference/user-preference.test.ts::"pre-aborted signal returns null without throw"`
---
### Requirement: Subpath exports for tree-shaking
The package SHALL declare subpath exports in `package.json` so consumers can import only the surfaces they need. The following subpaths SHALL resolve:
- `@suaveplan/i18n-detector` — root barrel (every public symbol)
- `@suaveplan/i18n-detector/chain` — `createDetectorChain`, `DETECTOR_CHAIN_UNRESOLVED`, `DetectorChain`, `DetectorChainUnresolved`, `bcp47Negotiate`, `NegotiateFn`, `CreateDetectorChainOptions`
- `@suaveplan/i18n-detector/direction` — `getTextDirection`, `isRtl`
- `@suaveplan/i18n-detector/detectors/querystring` — `querystringDetector`, `QuerystringDetectorOptions`
- `@suaveplan/i18n-detector/detectors/user-preference` — `userPreferenceDetector`, `UserPreferenceDetectorOptions`, `UserPreferenceResponseBody`
- `@suaveplan/i18n-detector/detectors/cookie` — `cookieDetector`, `CookieDetectorOptions`, `CookieSameSite`
- `@suaveplan/i18n-detector/detectors/local-storage` — `localStorageDetector`, `LocalStorageDetectorOptions`, `LocalStorageLike`
- `@suaveplan/i18n-detector/detectors/html-lang` — `htmlLangDetector`
- `@suaveplan/i18n-detector/detectors/accept-language` — `acceptLanguageDetector`, `parseAcceptLanguage`, `AcceptLanguageEntry`
- `@suaveplan/i18n-detector/detectors/navigator` — `navigatorDetector`
- `@suaveplan/i18n-detector/detectors/cloudflare-ip-geo` — `cloudflareIpGeoDetector`, `CloudflareIpGeoDetectorOptions`
- `@suaveplan/i18n-detector/detectors/fallback` — `fallbackDetector`
- `@suaveplan/i18n-detector/default-chain` — `defaultDetectorChain`, `DefaultDetectorChainOptions`
#### Scenario: every subpath resolves
- **WHEN** every documented subpath is imported in a consumer test fixture
- **THEN** every import resolves with no `Module not found` errors
**Verified by:** `packages/i18n/i18n-detector/src/index.test.ts::"every documented subpath export resolves"`
---
### Requirement: Side-effect-free package
The package SHALL declare `"sideEffects": false` in `package.json` so bundlers can tree-shake unused detectors. Importing only `@suaveplan/i18n-detector/detectors/accept-language` SHALL NOT pull any browser-only detector code into the consumer's bundle.
#### Scenario: package.json declares sideEffects false
- **WHEN** `package.json` is parsed
- **THEN** the `sideEffects` field equals `false`
**Verified by:** `packages/i18n/i18n-detector/src/index.test.ts::"package.json declares sideEffects false"`

View File

@@ -0,0 +1,129 @@
# Tasks — `@suaveplan/i18n-detector` (locale detection chain)
> Every checkbox MUST reflect work actually done in this change. Do not retroactively tick.
## 1. Spec authoring
- [x] 1.1 Read `openspec/cross-cutting-designs/2026-05-05-i18n-modular-split.md` end-to-end (especially §3 row 11 and §11 out of scope)
- [x] 1.2 Read `openspec/AGENTS.md` Sections 0, 0a, 6a, 7, 9, 13
- [x] 1.3 Read every source file listed in `## Source mapping` table in proposal.md (chain orchestrator + 8 detectors + default-chain + utils.ts direction helpers)
- [x] 1.4 Draft `proposal.md` from `openspec/templates/proposal.md`
- [x] 1.5 Fill `## Source mapping` table with line ranges from the actual source files
- [x] 1.6 Draft `specs/i18n-detector/spec.md` from `openspec/templates/spec-delta.md`
- [x] 1.7 Every requirement carries a `**Verified by:**` line naming concrete `it(...)` titles
- [x] 1.8 `openspec change validate i18n-detector` exits with no errors
## 2. Package scaffold
- [x] 2.1 `mkdir -p packages/i18n/i18n-detector/src/{chain,direction,detectors/{querystring,user-preference,cookie,local-storage,html-lang,accept-language,navigator,cloudflare-ip-geo,fallback},default-chain}`
- [x] 2.2 Author `package.json` — name `@suaveplan/i18n-detector`, version `0.1.0`, type `module`, sideEffects `false`. Deps: `@suaveplan/i18n-shared workspace:^`, `@suaveplan/locale-data workspace:^`, `@suaveplan/types workspace:^`, `@suaveplan/error workspace:^`, `@opentelemetry/api catalog:observability`, `tslib catalog:build`, `zod catalog:forms`. devDeps: `@suaveplan/testing workspace:^`, `@suaveplan/typescript-config workspace:^`, `@vitest/coverage-istanbul catalog:testing`, `vitest catalog:testing`, `@types/node catalog:`, `vite catalog:build`, `vite-plugin-dts catalog:build`, `typescript catalog:`.
- [x] 2.3 Author `tsconfig.json` extending `@suaveplan/typescript-config/library.json`
- [x] 2.4 Author `vite.config.ts` using `createTestConfig()` from `@suaveplan/testing/config`
- [x] 2.5 Author `README.md` (≥200 words, includes Modules table linking to each `.md`, dual-tier strategy callout, default-chain example)
- [x] 2.6 Add `package.json` `exports` map per proposal.md "Subpath exports" section (root + `./chain` + `./direction` + 9 `./detectors/<name>` + `./default-chain`)
## 3. Implementation — types and chain orchestrator
- [x] 3.1 Author `src/detector.types.ts` — port `DetectorStorageLike`, `DetectorContext`, `DetectorEnvironment`, `LocaleDetector`, `DetectorChainResult` from monolith verbatim
- [x] 3.2 Author `src/chain/bcp47-negotiate.ts` — exports `bcp47Negotiate(raw, supported, fallback): string | null` consuming `LOCALE_BCP47_MAP` from `@suaveplan/i18n-shared/constants/locale-bcp47-map`; exact match → language-subtag fallback → `null`
- [x] 3.3 Author `src/chain/chain.ts` — port `createDetectorChain` + `DETECTOR_CHAIN_UNRESOLVED` + `DetectorChain` + `DetectorChainUnresolved` from monolith; **modify** to accept `options?: { negotiate?: NegotiateFn; throwOnExhausted?: boolean }`; default `negotiate` = `bcp47Negotiate`; throws `I18nError(I18N.DETECTOR_CHAIN_EXHAUSTED)` when `throwOnExhausted: true` AND chain returns sentinel; emit `i18n.detector.chain.exhausted_total` counter on every sentinel return
- [x] 3.4 Author `src/chain/chain.types.ts` — `NegotiateFn`, `CreateDetectorChainOptions`
- [x] 3.5 Author `src/chain/index.ts` — explicit named re-exports
## 4. Implementation — direction helper
- [x] 4.1 Author `src/direction/direction.ts` — port `getTextDirection` + `isRtl` from `packages/i18n/i18n/src/utils/utils.ts` L138-L153; inline `type TextDirection = "ltr" | "rtl"` (drop the cross-folder import)
- [x] 4.2 Author `src/direction/index.ts` — explicit named re-exports
## 5. Implementation — individual detectors
- [x] 5.1 Author `src/detectors/querystring/{querystring.ts,querystring.types.ts,index.ts}` — port from `packages/react/react-i18n/src/detector/querystring/`; rewire `LocaleDetector` import to `../../detector.types.js`
- [x] 5.2 Author `src/detectors/user-preference/{user-preference.ts,user-preference.types.ts,index.ts}` — port from react-i18n; replace `ConsoleLogger` dep with `options.logger?: LoggerLike` (default no-op); thread `ctx.signal` on every fetch
- [x] 5.3 Author `src/detectors/cookie/{cookie.ts,cookie.types.ts,index.ts}` — verbatim port from react-i18n
- [x] 5.4 Author `src/detectors/local-storage/{local-storage.ts,local-storage.types.ts,index.ts}` — verbatim port from react-i18n
- [x] 5.5 Author `src/detectors/html-lang/{html-lang.ts,index.ts}` — verbatim port from react-i18n
- [x] 5.6 Author `src/detectors/accept-language/{accept-language.ts,accept-language.types.ts,index.ts}` — verbatim port from react-i18n
- [x] 5.7 Author `src/detectors/navigator/{navigator.ts,index.ts}` — verbatim port from react-i18n
- [x] 5.8 Author `src/detectors/cloudflare-ip-geo/{cloudflare-ip-geo.ts,cloudflare-ip-geo.types.ts,index.ts}` — verbatim port from react-i18n; `getAllCountries` import already targets `@suaveplan/locale-data`
- [x] 5.9 Author `src/detectors/fallback/{fallback.ts,index.ts}` — port; replace `throw new Error(...)` with `throw new I18nError(I18N_CODES.INVALID_LOCALE, ...)`
## 6. Implementation — default chain factory
- [x] 6.1 Author `src/default-chain/{default-chain.ts,default-chain.types.ts,index.ts}` — port `defaultDetectorChain` + `DefaultDetectorChainOptions` from react-i18n; rewire detector imports to sibling folders
## 7. Per-detector OTel instrumentation
- [x] 7.1 Add `i18n.detector.<name>.duration_ms` histogram to every async detector's `detectAsync` body (start/stop wall-time)
- [x] 7.2 Add `i18n.detector.<name>.detected_total{locale=<resolved>}` counter increment to every detector's success path (sync `detect` + async `detectAsync`)
- [x] 7.3 Add `i18n.detector.chain.exhausted_total` counter to chain orchestrator's sentinel-return branch
- [x] 7.4 Verify `getDetectorMetrics()` helper from observability lives in this package (port the metrics module from monolith `src/observability/detector-metrics.ts`); rename to `src/observability/metrics.ts` co-located with the chain
## 8. Root barrel
- [x] 8.1 Author `src/index.ts` — explicit named re-exports across `chain/`, `direction/`, `detectors/`, `default-chain/`, and `detector.types.ts` (NO `export *` per CLAUDE.md §10)
## 9. Documentation (sibling `.md` per implementation file)
- [x] 9.1 `src/chain/chain.md` (≥200 words, basic + advanced examples; covers sync vs async chain, environment gating, AbortSignal, throw-on-exhausted)
- [x] 9.2 `src/chain/bcp47-negotiate.md` (basic + advanced; covers default vs injected negotiator)
- [x] 9.3 `src/direction/direction.md` (basic + advanced; covers `getTextDirection` + `isRtl`)
- [x] 9.4 `src/detectors/querystring/querystring.md`
- [x] 9.5 `src/detectors/user-preference/user-preference.md`
- [x] 9.6 `src/detectors/cookie/cookie.md`
- [x] 9.7 `src/detectors/local-storage/local-storage.md`
- [x] 9.8 `src/detectors/html-lang/html-lang.md`
- [x] 9.9 `src/detectors/accept-language/accept-language.md`
- [x] 9.10 `src/detectors/navigator/navigator.md`
- [x] 9.11 `src/detectors/cloudflare-ip-geo/cloudflare-ip-geo.md`
- [x] 9.12 `src/detectors/fallback/fallback.md`
- [x] 9.13 `src/default-chain/default-chain.md` (lists the canonical 8/9-entry order; explains userPreference opt-in)
- [x] 9.14 `src/observability/metrics.md`
- [x] 9.15 `README.md` `## Modules` section links every `.md`
## 10. Tests (Vitest, runner-neutral imports per CLAUDE.md §11)
- [x] 10.1 `src/chain/chain.test.ts` — sync chain registration order; async chain race-to-first; environment gating skips browser detectors on server (and vice versa); `cache()` walks every persisting detector; AbortSignal threaded into every async call; `throwOnExhausted: true` raises `I18nError(I18N.DETECTOR_CHAIN_EXHAUSTED)`; default `bcp47Negotiate` wired
- [x] 10.2 `src/chain/bcp47-negotiate.test.ts` — exact-match path; language-subtag fallback (`zh-Hant-TW` → `zh`); short-code expansion via `LOCALE_BCP47_MAP` (`en` → `en-GB`); `null` on miss
- [x] 10.3 `src/direction/direction.test.ts` — `getTextDirection("ar")` is `"rtl"`; `getTextDirection("ar-SA")` is `"rtl"` (subtag extraction); `getTextDirection("en-GB")` is `"ltr"`; `isRtl` mirrors
- [x] 10.4 `src/detectors/querystring/querystring.test.ts` — server `?lang=fr` resolves; browser `globalThis.location.search` resolves; missing param returns `null`; both contexts absent returns `null`; custom `paramName` honoured
- [x] 10.5 `src/detectors/user-preference/user-preference.test.ts` — happy GET round-trip; non-2xx logged + `null`; rejected fetch logged + `null`; AbortSignal threaded; pre-aborted signal returns `null`; cache POST round-trip; cache failure non-fatal; `extractAuth` invoked
- [x] 10.6 `src/detectors/cookie/cookie.test.ts` — server reads inbound Cookie header; server writes Set-Cookie on responseHeaders; browser reads/writes `document.cookie`; URL-encoding round-trip; SameSite=`lax`/`strict`/`none` formatting; missing both contexts returns `null`
- [x] 10.7 `src/detectors/local-storage/local-storage.test.ts` — explicit storage option; `globalThis.localStorage` fallback; missing storage returns `null`; quota / private-mode error caught; cache failure non-fatal
- [x] 10.8 `src/detectors/html-lang/html-lang.test.ts` — `ctx.document` lookup; `globalThis.document` fallback; missing root returns `null`; empty `lang` returns `null`; whitespace-only `lang` returns `null`
- [x] 10.9 `src/detectors/accept-language/accept-language.test.ts` — q-value sort desc; stable order on ties; `*` wildcard skipped; malformed q skipped; q out of range skipped; missing header returns `null`; empty header returns `null`
- [x] 10.10 `src/detectors/navigator/navigator.test.ts` — `navigator.languages` preferred over `navigator.language`; whitespace-only entries skipped; missing navigator returns `null`
- [x] 10.11 `src/detectors/cloudflare-ip-geo/cloudflare-ip-geo.test.ts` — `AE` → `ar-AE` via locale-data; override map wins over locale-data; missing header returns `null`; unknown country returns `null`; lowercase header value upper-cased
- [x] 10.12 `src/detectors/fallback/fallback.test.ts` — returns configured locale; empty locale throws `I18nError(I18N.INVALID_LOCALE)`; whitespace-only locale throws
- [x] 10.13 `src/default-chain/default-chain.test.ts` — order matches spec exactly; `userPreference` only when endpoint set; fresh array per call; fallback always last; environment values match
- [x] 10.14 `src/index.test.ts` — every documented public export resolves through the root barrel; every documented subpath export resolves
- [x] 10.15 Every `it(...)` carries `{ timeout: 5000 }`
- [x] 10.16 All test imports go through `@suaveplan/testing/runner` (CLAUDE.md §11) — verified by `grep -rnE "from ['\"](vitest|bun:test)['\"]" packages/i18n/i18n-detector/src/` returning zero hits
## 11. Pre-archive verification (5-gate triple)
- [x] 11.1 `bun run check` — Biome 0/0/0 across the monorepo
- [x] 11.2 `bunx turbo lint typecheck test --filter=@suaveplan/i18n-detector` — green
- [x] 11.3 `bunx turbo test:coverage --filter=@suaveplan/i18n-detector` — 100/100/100/100
- [x] 11.4 `bun scripts/openspec-conformance.ts @suaveplan/i18n-detector` — exit 0, no `error` findings
- [x] 11.5 `bunx turbo build --filter=@suaveplan/i18n-detector` — green
## 12. Pre-archive 7-row checklist (AGENTS.md Section 13)
- [x] 12.1 Generics threaded — `LocaleDetector` interface preserves the upstream shape; `NegotiateFn` typed as `(raw: string, supported: readonly string[], fallback: string) => string | null`; `CreateDetectorChainOptions` uses precise types not `any`
- [x] 12.2 Zod schemas wired at every public boundary — `DetectorContextSchema`, `DefaultDetectorChainOptionsSchema`, `QuerystringDetectorOptionsSchema`, `CookieDetectorOptionsSchema`, `LocalStorageDetectorOptionsSchema`, `UserPreferenceDetectorOptionsSchema`, `CloudflareIpGeoDetectorOptionsSchema`, `CreateDetectorChainOptionsSchema`
- [x] 12.3 Every error code thrown by at least one production path: `I18N.INVALID_LOCALE` (fallback detector), `I18N.DETECTOR_CHAIN_EXHAUSTED` (chain when `throwOnExhausted: true`)
- [x] 12.4 Strict-mode tested — every browser-only detector's "missing global" branch covered; every server-only detector's "missing request" branch covered; AbortSignal-aborted detectAsync returns `null` deterministically
- [x] 12.5 OTel — every async detector emits `i18n.detector.<name>.duration_ms`; every detector emits `i18n.detector.<name>.detected_total{locale=<resolved>}` on success; chain emits `i18n.detector.chain.exhausted_total` on sentinel return; `i18n.detect` span sets `resolved_locale` and `winning_detector` attributes
- [x] 12.6 Lifecycle dedup — N/A (detectors are pure factories returning fresh `LocaleDetector` per call; chain is stateless)
- [x] 12.7 No retroactive tick — every box reflects work done in this change
## 13. Manifest + ecosystem awareness
- [x] 13.1 Add `@suaveplan/i18n-detector` entry to root `CLAUDE.md` package listing under the i18n cluster (one line, mentions universal tier + 8 detectors + chain + direction helper)
- [x] 13.2 Run `bun scripts/folder-reorg/generate-manifest.ts` to refresh `MANIFEST.md`
- [x] 13.3 Append `@suaveplan/i18n-detector` to `STATUS.md` Completed list
## 14. Archive
- [x] 14.1 Fold spec delta into `openspec/specs/i18n-detector/spec.md` (NEW capability spec — first delta becomes canonical)
- [x] 14.2 Move `openspec/changes/i18n-detector/` → `openspec/archive/<yyyy-mm-dd>-i18n-detector/`