Files
2026-05-27 23:29:53 +01:00

30 KiB

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"


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.

  • GIVEN ctx.request.headers.get("cookie") === "suaveplan_locale=fr; other=x"
  • WHEN cookieDetector().detect(ctx) is called
  • THEN the result equals "fr"
  • 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"