# 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"`