Files
suaveplan/openspec/archive/2026-05-09-i18n-detector/proposal.md
2026-05-27 23:29:53 +01:00

174 lines
22 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# `@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).