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. (Seeopenspec/AGENTS.mdSection 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"ANDdetector: "B"
Scenario: environment gating skips browser detectors on server context
- GIVEN detectors
[serverOnly, browserOnly]andctx.request !== undefined - WHEN
chain.detect(ctx)is called - THEN only
serverOnly.detectis invoked
Scenario: empty supportedLocales returns sentinel without invoking detectors
- GIVEN
ctx.supportedLocales = [] - WHEN
chain.detect(ctx)is called - THEN the result equals
DETECTOR_CHAIN_UNRESOLVEDAND 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
I18nErrorwith 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)andC.cache("fr", ctx)are invoked ANDBis 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 onzhlanguage 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" }ANDctx.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.fetchreturns{ ok: true, json: () => ({ locale: "fr" }) } - WHEN
userPreferenceDetector(options).detectAsync(ctx)is awaited - THEN the result equals
"fr"
Scenario: non-2xx returns null
- GIVEN
options.fetchreturns{ ok: false, status: 500 } - WHEN
userPreferenceDetector(options).detectAsync(ctx)is awaited - THEN the result equals
nullAND a warn log was emitted
Scenario: rejected fetch returns null
- GIVEN
options.fetchrejects withnew Error("network down") - WHEN
userPreferenceDetector(options).detectAsync(ctx)is awaited - THEN the result equals
nullAND 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"ANDbody: '{"locale":"fr"}'ANDContent-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.responseHeadersis aHeadersinstance - 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 noctx.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 === undefinedAND nodocumentglobal - 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.localStorageexists withgetItem("suaveplan-locale") === "es" - WHEN
localStorageDetector().detect(ctx)is called - THEN the result equals
"es"
Scenario: missing storage returns null
- GIVEN no
options.storageAND noglobalThis.localStorage - WHEN
localStorageDetector().detect(ctx)is called - THEN the result equals
null
Scenario: storage error caught
- GIVEN
options.storage.getItemthrows (private mode) - WHEN
localStorageDetector(options).detect(ctx)is called - THEN the result equals
nullAND 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 === undefinedANDglobalThis.document.documentElement.lang = "es" - WHEN
htmlLangDetector().detect(ctx)is called - THEN the result equals
"es"
Scenario: missing document returns null
- GIVEN no
ctx.documentAND noglobalThis.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
I18nErrorwith code"I18N.INVALID_LOCALE"
Scenario: whitespace-only locale throws INVALID_LOCALE
- WHEN
fallbackDetector(" ")is called - THEN the call throws
I18nErrorwith 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
nameequals"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.signalis a non-abortedAbortSignal - WHEN
userPreferenceDetector(options).detectAsync(ctx)is awaited - THEN the underlying fetch's
init.signalstrict-equalsctx.signal
Scenario: pre-aborted signal returns null without throw
- GIVEN
ctx.signal = AbortSignal.abort() - AND
options.fetchrejects with the signal'sAbortError - WHEN
userPreferenceDetector(options).detectAsync(ctx)is awaited - THEN the result equals
nullAND 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 founderrors
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.jsonis parsed - THEN the
sideEffectsfield equalsfalse
Verified by: packages/i18n/i18n-detector/src/index.test.ts::"package.json declares sideEffects false"