505 lines
30 KiB
Markdown
505 lines
30 KiB
Markdown
<!-- markdownlint-disable MD024 -->
|
|
|
|
# Capability Spec: i18n-detector (i18n-detector)
|
|
|
|
**Package**: `@suaveplan/i18n-detector`
|
|
**Status**: ACTIVE
|
|
|
|
> Every requirement below carries a `**Verified by:**` line at authoring time. (See `openspec/AGENTS.md` Section 13.)
|
|
|
|
## 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"`
|