first commit
This commit is contained in:
@@ -0,0 +1,86 @@
|
||||
# `accept-language.ts` — `Accept-Language` HTTP header detector
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
`acceptLanguageDetector()` and the standalone
|
||||
`parseAcceptLanguage(header)` parser implement RFC 9110 §5.3.5
|
||||
parsing of the inbound `Accept-Language` request header. The
|
||||
detector returns the highest-q entry; `parseAcceptLanguage` is
|
||||
exported for advanced consumers building custom detectors layered
|
||||
on top of this one (e.g. honouring a specific browser's quirks, or
|
||||
combining the header with a user-agent classifier).
|
||||
|
||||
The detector is `environment: "server"` because the header is
|
||||
only meaningful for the inbound request — SSR and Edge runtimes
|
||||
consume it; browsers do not see their own outbound headers. Any
|
||||
detector chain running in either tier benefits from including
|
||||
this entry behind cookie/localStorage so first-time visitors fall
|
||||
through to a sensible default without configuration.
|
||||
|
||||
## Features
|
||||
|
||||
- RFC 9110 §5.3.5 q-value parsing with stable sort (insertion
|
||||
order preserved on ties).
|
||||
- Wildcard `*` tokens skipped — they carry no information that
|
||||
can be negotiated.
|
||||
- Malformed q values, q values out of range `[0, 1]`, and
|
||||
unparseable parameters skipped per the RFC's "be liberal in
|
||||
what you accept" guidance.
|
||||
- Empty headers and missing `accept-language` return `null`.
|
||||
- `environment: "server"` — automatic browser skip via the chain
|
||||
runner.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { acceptLanguageDetector } from "@suaveplan/i18n-detector/detectors/accept-language";
|
||||
|
||||
const detector = acceptLanguageDetector();
|
||||
const locale = detector.detect?.({
|
||||
request: {
|
||||
headers: new Headers({
|
||||
"accept-language": "en;q=0.5, fr;q=0.9, de",
|
||||
}),
|
||||
url: new URL("https://example.com/"),
|
||||
},
|
||||
supportedLocales: ["en-GB", "fr-FR", "de-DE"],
|
||||
fallbackLocale: "en-GB",
|
||||
});
|
||||
// locale === "de" (default q=1)
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { parseAcceptLanguage } from "@suaveplan/i18n-detector/detectors/accept-language";
|
||||
|
||||
const entries = parseAcceptLanguage(
|
||||
"fr-FR;q=0.9, fr;q=0.8, en-GB;q=0.7, en;q=0.6"
|
||||
);
|
||||
// entries: [
|
||||
// { locale: "fr-FR", q: 0.9 },
|
||||
// { locale: "fr", q: 0.8 },
|
||||
// { locale: "en-GB", q: 0.7 },
|
||||
// { locale: "en", q: 0.6 },
|
||||
// ]
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `acceptLanguageDetector(): LocaleDetector` — `environment: "server"`.
|
||||
- `parseAcceptLanguage(header: string): AcceptLanguageEntry[]`.
|
||||
- `AcceptLanguageEntry = { locale: string; q: number }`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- The sort is implemented by mapping `(entry, index)` pairs and
|
||||
comparing `(b.q - a.q) || (a.index - b.index)` so ties preserve
|
||||
insertion order — `Array.prototype.sort` is not guaranteed
|
||||
stable on every runtime ECMA spec actively supports.
|
||||
- Default q value is 1 when no `q=` parameter appears, per the
|
||||
RFC.
|
||||
- `q=2.0` and other out-of-range values cause the entire entry to
|
||||
be discarded (the parser refuses to "fix" the value to 1 or 0).
|
||||
@@ -0,0 +1,87 @@
|
||||
# `cloudflare-ip-geo.ts` — Cloudflare IP-geo detector
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
`cloudflareIpGeoDetector(options?)` builds a `LocaleDetector` that
|
||||
maps the Cloudflare `cf-ipcountry` header (ISO 3166-1 alpha-2) to
|
||||
a BCP-47 locale. It is the lowest-precedence detector in the
|
||||
canonical chain — only consulted when no querystring, cookie,
|
||||
storage, or browser preference signal exists — because country
|
||||
geolocation is at best a coarse approximation of language
|
||||
preference (the UK and India both speak English; Switzerland has
|
||||
four official languages).
|
||||
|
||||
Resolution order, per ISO code:
|
||||
|
||||
1. `options.countryToLocale[code]` if provided.
|
||||
2. `${language}-${code}` where `language` is the first entry from
|
||||
the country record's `languages` array in
|
||||
`@suaveplan/locale-data`.
|
||||
3. `null` when neither override nor language data exists.
|
||||
|
||||
## Features
|
||||
|
||||
- Default header `"cf-ipcountry"`, configurable for non-Cloudflare
|
||||
edge providers.
|
||||
- Per-country override map wins over the data-derived default.
|
||||
- `environment: "server"` — automatic browser skip via the chain
|
||||
runner.
|
||||
- Lowercased header values are upper-cased before lookup so
|
||||
`Headers.get` casing quirks don't break resolution.
|
||||
- Missing header, empty header, and unknown country codes all
|
||||
return `null`.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { cloudflareIpGeoDetector } from "@suaveplan/i18n-detector/detectors/cloudflare-ip-geo";
|
||||
|
||||
const detector = cloudflareIpGeoDetector();
|
||||
const locale = detector.detect?.({
|
||||
request: {
|
||||
headers: new Headers({ "cf-ipcountry": "AE" }),
|
||||
url: new URL("https://example.com/"),
|
||||
},
|
||||
supportedLocales: ["en-GB", "ar-AE"],
|
||||
fallbackLocale: "en-GB",
|
||||
});
|
||||
// locale === "ar-AE"
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { cloudflareIpGeoDetector } from "@suaveplan/i18n-detector/detectors/cloudflare-ip-geo";
|
||||
|
||||
const detector = cloudflareIpGeoDetector({
|
||||
headerName: "x-vercel-ip-country",
|
||||
countryToLocale: {
|
||||
CH: "de-CH", // Swiss German default — overrides Latin order
|
||||
BE: "nl-BE",
|
||||
IN: "en-IN",
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `cloudflareIpGeoDetector(options?): LocaleDetector` —
|
||||
`environment: "server"`, `name: "cloudflareIpGeo"`. Has only
|
||||
`detect`; no `detectAsync`, no `cache`.
|
||||
- `CloudflareIpGeoDetectorOptions.headerName?: string` — default
|
||||
`"cf-ipcountry"`.
|
||||
- `CloudflareIpGeoDetectorOptions.countryToLocale?: Readonly<Record<string, string>>`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- `getAllCountries()` is called once at detector-build time and
|
||||
the result is captured in closure scope; subsequent `detect`
|
||||
calls reuse the same record. Each detector instance therefore
|
||||
has O(1) lookup cost.
|
||||
- The `${primaryLanguage}-${code}` template is correct for the
|
||||
vast majority of countries. The override map exists precisely
|
||||
for the multilingual countries where a single primary language
|
||||
would mis-serve users.
|
||||
@@ -0,0 +1,88 @@
|
||||
# `cookie.ts` — cookie-backed locale detector
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
`cookieDetector(options?)` builds a `LocaleDetector` that reads and
|
||||
writes the active locale via a single cookie. On the server it
|
||||
reads the inbound `Cookie` header from `ctx.request.headers` and
|
||||
writes `Set-Cookie` to `ctx.responseHeaders`. On the browser it
|
||||
reads and writes `document.cookie` directly. The detector is the
|
||||
canonical persisted preference for unauthenticated visitors —
|
||||
device-local but survives navigation, and works in both tiers
|
||||
without further plumbing.
|
||||
|
||||
Cookie values are URL-encoded on write so locales containing non-
|
||||
token characters (none in the BCP-47 subset, but a defensive
|
||||
guard) cannot break the cookie wire format.
|
||||
|
||||
## Features
|
||||
|
||||
- `environment: "both"` — reads and writes on either tier.
|
||||
- Default cookie name `"suaveplan_locale"`, configurable.
|
||||
- Default `Max-Age` of 31_536_000s (one year), configurable.
|
||||
- Default `SameSite=Lax`, configurable to `Strict` or `None`.
|
||||
- Default `Path=/`, configurable.
|
||||
- Server-side write goes through `Headers.append("set-cookie", …)`
|
||||
so multiple cookies can coexist in one response.
|
||||
- Missing both `ctx.request` and any `document` global causes
|
||||
`detect` to return `null` and `cache` to no-op.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { cookieDetector } from "@suaveplan/i18n-detector/detectors/cookie";
|
||||
|
||||
const detector = cookieDetector();
|
||||
const locale = detector.detect?.({
|
||||
request: {
|
||||
headers: new Headers({ cookie: "suaveplan_locale=fr-FR" }),
|
||||
url: new URL("https://example.com/"),
|
||||
},
|
||||
supportedLocales: ["en-GB", "fr-FR"],
|
||||
fallbackLocale: "en-GB",
|
||||
});
|
||||
// locale === "fr-FR"
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { cookieDetector } from "@suaveplan/i18n-detector/detectors/cookie";
|
||||
|
||||
const detector = cookieDetector({
|
||||
name: "ui_locale",
|
||||
maxAgeSeconds: 60 * 60 * 24 * 30, // 30 days
|
||||
sameSite: "strict",
|
||||
path: "/app",
|
||||
});
|
||||
|
||||
await detector.cache?.("zh-Hant-TW", {
|
||||
responseHeaders: new Headers(),
|
||||
supportedLocales: ["en-GB", "zh-Hant-TW"],
|
||||
fallbackLocale: "en-GB",
|
||||
});
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `cookieDetector(options?): LocaleDetector`
|
||||
- `CookieDetectorOptions.name?: string` — default `"suaveplan_locale"`.
|
||||
- `CookieDetectorOptions.maxAgeSeconds?: number` — default `31536000`.
|
||||
- `CookieDetectorOptions.sameSite?: "lax" | "strict" | "none"`.
|
||||
- `CookieDetectorOptions.path?: string` — default `"/"`.
|
||||
- `CookieSameSite` — alias for the SameSite union.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- Cookie parsing splits on `;`, trims each segment, and looks up
|
||||
the configured name only — other cookies in the header are
|
||||
ignored.
|
||||
- Decode failures (e.g. percent sequences without two hex digits)
|
||||
surface the raw value rather than throwing. This matches browser
|
||||
behaviour and keeps the detector resilient to legacy cookies.
|
||||
- The `Secure` flag is intentionally NOT auto-added when
|
||||
`SameSite=None`; that enforcement is tracked as the separate
|
||||
`cookie-detector-secure-flag` change.
|
||||
@@ -0,0 +1,75 @@
|
||||
# `fallback.ts` — unconditional fallback detector
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
`fallbackDetector(locale)` builds a `LocaleDetector` that always
|
||||
returns the configured locale. The detector is the floor of every
|
||||
chain — when querystring, cookie, storage, header, navigator, and
|
||||
geo all miss, the fallback guarantees a deterministic response.
|
||||
|
||||
Because the detector never returns `null`, it MUST appear last in
|
||||
any chain — any detector after it would never run. The constructor
|
||||
validates the supplied locale up-front and throws
|
||||
`I18nError(I18N.INVALID_LOCALE)` on empty / whitespace-only input
|
||||
so a misconfiguration surfaces at chain-build time rather than at
|
||||
the first request.
|
||||
|
||||
## Features
|
||||
|
||||
- `environment: "both"` — eligible on every chain run.
|
||||
- Constructor-time validation: empty string / whitespace string /
|
||||
non-string locale all throw a typed `I18nError(INVALID_LOCALE)`.
|
||||
- Locale value is trimmed once at construction; subsequent
|
||||
`detect` calls reuse the cached value.
|
||||
- Read-only — no `cache()` because the fallback has no source to
|
||||
persist to.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { fallbackDetector } from "@suaveplan/i18n-detector/detectors/fallback";
|
||||
|
||||
const detector = fallbackDetector("en-GB");
|
||||
const locale = detector.detect?.({
|
||||
supportedLocales: ["en-GB", "fr-FR"],
|
||||
fallbackLocale: "en-GB",
|
||||
});
|
||||
// locale === "en-GB"
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { I18nError } from "@suaveplan/i18n-shared";
|
||||
import { fallbackDetector } from "@suaveplan/i18n-detector/detectors/fallback";
|
||||
|
||||
try {
|
||||
fallbackDetector("");
|
||||
} catch (err) {
|
||||
if (err instanceof I18nError) {
|
||||
console.error("misconfigured fallback:", err.code, err.message);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `fallbackDetector(locale: string): LocaleDetector` —
|
||||
`environment: "both"`, `name: "fallback"`. Has only `detect`;
|
||||
no `detectAsync`, no `cache`.
|
||||
- Throws `I18nError(I18N.INVALID_LOCALE)` synchronously on
|
||||
empty / whitespace / non-string input.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- Validation runs once at detector-build time, not on every
|
||||
`detect` call — the chain pays no per-request cost for the
|
||||
guard.
|
||||
- The trimmed locale is captured by closure so the returned
|
||||
detector is allocation-free per request.
|
||||
- The fallback's `name` is fixed at `"fallback"` so chain
|
||||
consumers can identify it deterministically (e.g. to render
|
||||
a "default locale" warning in dev tools).
|
||||
@@ -0,0 +1,76 @@
|
||||
# `html-lang.ts` — `<html lang>` detector
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
`htmlLangDetector()` builds a `LocaleDetector` that reads
|
||||
`document.documentElement.lang`. The detector is
|
||||
`environment: "browser"` and never runs in SSR. Its niche is the
|
||||
common case where a static HTML shell already declares
|
||||
`<html lang="…">` — typically rendered by the SSR layer for
|
||||
crawlers and accessibility — and the runtime surface wants to
|
||||
honour that declaration without coupling to a specific framework.
|
||||
|
||||
This detector contributes a stable, framework-neutral signal that
|
||||
sits between cookie/localStorage (device preferences) and
|
||||
navigator (raw browser preferences). It is a read-only detector —
|
||||
no `cache()` method — because the SSR layer is the source of
|
||||
truth for the `<html lang>` attribute.
|
||||
|
||||
## Features
|
||||
|
||||
- `environment: "browser"` — automatic SSR skip via the chain
|
||||
runner.
|
||||
- Reads from `ctx.document` if supplied, else from
|
||||
`globalThis.document`.
|
||||
- Empty / whitespace-only `lang` attributes return `null`.
|
||||
- Missing `documentElement` (impossible in real browsers but a
|
||||
defensive guard for jsdom shims) returns `null`.
|
||||
- No persistence — there is no `cache()` method.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { htmlLangDetector } from "@suaveplan/i18n-detector/detectors/html-lang";
|
||||
|
||||
const detector = htmlLangDetector();
|
||||
const locale = detector.detect?.({
|
||||
supportedLocales: ["en-GB", "fr-FR"],
|
||||
fallbackLocale: "en-GB",
|
||||
});
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { htmlLangDetector } from "@suaveplan/i18n-detector/detectors/html-lang";
|
||||
|
||||
const fakeDoc = {
|
||||
documentElement: { lang: "fr-FR" },
|
||||
cookie: "",
|
||||
} as unknown as Document;
|
||||
|
||||
const detector = htmlLangDetector();
|
||||
const locale = detector.detect?.({
|
||||
document: fakeDoc,
|
||||
supportedLocales: ["en-GB", "fr-FR"],
|
||||
fallbackLocale: "en-GB",
|
||||
});
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `htmlLangDetector(): LocaleDetector` — `environment: "browser"`,
|
||||
`name: "htmlLang"`. Has only `detect`; no `detectAsync`, no
|
||||
`cache`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- The detector tolerates a missing `globalThis.document` (e.g.
|
||||
Workers / Edge runtimes that have a request-bound DOM shim)
|
||||
without throwing.
|
||||
- `root.lang` is asserted as `string` defensively — some custom
|
||||
document polyfills expose `Element` instances whose `lang`
|
||||
property is undefined.
|
||||
@@ -0,0 +1,80 @@
|
||||
# `local-storage.ts` — `localStorage`-backed locale detector
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
`localStorageDetector(options?)` builds a `LocaleDetector` that
|
||||
reads and writes the active locale from a `Storage`-shaped backing
|
||||
store. The detector is `environment: "browser"` so the chain
|
||||
runner automatically skips it during SSR. Browser-side persistence
|
||||
sits between cookies (which round-trip on every request) and
|
||||
backend-stored user preference (which requires authentication) —
|
||||
ideal for unauthenticated single-page apps that want fast
|
||||
client-side reads after the first visit without paying the cookie
|
||||
header cost on every request.
|
||||
|
||||
## Features
|
||||
|
||||
- Default backing store is `globalThis.localStorage`.
|
||||
- `options.storage` injects an alternative `LocalStorageLike`
|
||||
(e.g. a session-storage handle, or an in-memory test fake).
|
||||
- Default key `"suaveplan-locale"`, configurable.
|
||||
- All storage access wrapped in `try/catch` — private mode,
|
||||
disabled storage, and quota exhaustion all surface as `null` /
|
||||
silent no-op.
|
||||
- `environment: "browser"` — server runs skip the detector
|
||||
entirely, so SSR contexts never need to inject a fake.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { localStorageDetector } from "@suaveplan/i18n-detector/detectors/local-storage";
|
||||
|
||||
const detector = localStorageDetector();
|
||||
const locale = detector.detect?.({
|
||||
supportedLocales: ["en-GB", "fr-FR"],
|
||||
fallbackLocale: "en-GB",
|
||||
});
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { localStorageDetector } from "@suaveplan/i18n-detector/detectors/local-storage";
|
||||
|
||||
const fake = new Map<string, string>();
|
||||
const detector = localStorageDetector({
|
||||
key: "app:locale",
|
||||
storage: {
|
||||
getItem: (k) => fake.get(k) ?? null,
|
||||
setItem: (k, v) => {
|
||||
fake.set(k, v);
|
||||
},
|
||||
removeItem: (k) => {
|
||||
fake.delete(k);
|
||||
},
|
||||
},
|
||||
});
|
||||
await detector.cache?.("fr-FR", ctx);
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `localStorageDetector(options?): LocaleDetector`
|
||||
- `LocalStorageDetectorOptions.key?: string` — default
|
||||
`"suaveplan-locale"`.
|
||||
- `LocalStorageDetectorOptions.storage?: LocalStorageLike`.
|
||||
- `LocalStorageLike` — `{ getItem, setItem, removeItem }` shape.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- `resolveStorage` checks both `getItem` and `setItem` are
|
||||
functions before treating the candidate as a `LocalStorageLike`
|
||||
to defend against custom shims that expose only one half of the
|
||||
contract.
|
||||
- Empty-string reads are treated as a miss — Safari has been known
|
||||
to return `""` rather than `null` for unset keys in private mode.
|
||||
- Cache failures (quota, private mode, disabled storage) are
|
||||
swallowed; the detector NEVER throws into the consumer.
|
||||
@@ -0,0 +1,85 @@
|
||||
# `navigator.ts` — `navigator.languages` detector
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
`navigatorDetector()` builds a `LocaleDetector` that reads the
|
||||
active locale from the browser's
|
||||
`globalThis.navigator.languages` (preferred) or
|
||||
`globalThis.navigator.language` (fallback). The chain runner
|
||||
negotiates the returned value against `ctx.supportedLocales` so
|
||||
this detector contributes the user's raw browser preference and
|
||||
lets the resolver perform BCP-47 fallback (so a user with
|
||||
`["fr-CA", "en-US"]` browser settings still receives `fr-FR`
|
||||
when only `fr-FR` is supported).
|
||||
|
||||
The detector is `environment: "browser"` and never runs in SSR.
|
||||
It is the lowest-priority signal in the canonical chain because
|
||||
browser preferences are coarse — they reflect OS-level locale
|
||||
rather than per-application choice — but they are the strongest
|
||||
signal available for first-time, unauthenticated visitors with
|
||||
no cookies or localStorage.
|
||||
|
||||
## Features
|
||||
|
||||
- Reads `navigator.languages` array first (preferred — every
|
||||
modern browser exposes it).
|
||||
- Falls back to `navigator.language` when `languages` is missing
|
||||
or empty.
|
||||
- Skips whitespace-only entries; returns the first non-empty.
|
||||
- Missing `navigator` global returns `null` (Workers, server
|
||||
contexts that mistakenly bypass environment gating).
|
||||
- `environment: "browser"` — server runs skip the detector
|
||||
entirely.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { navigatorDetector } from "@suaveplan/i18n-detector/detectors/navigator";
|
||||
|
||||
const detector = navigatorDetector();
|
||||
const locale = detector.detect?.({
|
||||
supportedLocales: ["en-GB", "fr-FR"],
|
||||
fallbackLocale: "en-GB",
|
||||
});
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { navigatorDetector } from "@suaveplan/i18n-detector/detectors/navigator";
|
||||
|
||||
// Test fixture: stub navigator.
|
||||
const original = (globalThis as { navigator?: unknown }).navigator;
|
||||
(globalThis as { navigator?: unknown }).navigator = {
|
||||
languages: ["fr-CA", "en-US"],
|
||||
language: "fr-CA",
|
||||
};
|
||||
|
||||
const detector = navigatorDetector();
|
||||
const locale = detector.detect?.({
|
||||
supportedLocales: ["en-GB", "fr-FR"],
|
||||
fallbackLocale: "en-GB",
|
||||
});
|
||||
|
||||
(globalThis as { navigator?: unknown }).navigator = original;
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `navigatorDetector(): LocaleDetector` — `environment: "browser"`,
|
||||
`name: "navigator"`. Has only `detect`; no `detectAsync`, no
|
||||
`cache`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- The detector NEVER throws even when `navigator` is partially
|
||||
defined (only `language`, only `languages`, or neither). The
|
||||
chain runner skips browser detectors on server runs anyway, but
|
||||
the in-detector guards keep the behaviour predictable in
|
||||
unusual runtime shims.
|
||||
- Whitespace-only entries are skipped because some Android
|
||||
browsers historically emitted `" "` as a sentinel for
|
||||
"preference unset".
|
||||
@@ -0,0 +1,77 @@
|
||||
# `querystring.ts` — `?lang=` querystring detector
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
`querystringDetector(options?)` builds a `LocaleDetector` that reads
|
||||
the active locale from a URL search parameter. It runs in both the
|
||||
browser tier (consulting `globalThis.location.search`) and the
|
||||
server tier (consulting `ctx.request.url.searchParams`), so the
|
||||
same detector entry can sit at the top of every chain regardless
|
||||
of where the chain is executed. Querystring detection is the
|
||||
highest-precedence signal because it represents an explicit user
|
||||
override (a tester switching locale via the URL bar, an outbound
|
||||
link from a translated page) that should beat every persisted
|
||||
preference.
|
||||
|
||||
When neither the request nor the browser global is available the
|
||||
detector falls through with `null`, letting the chain advance to
|
||||
the next entry.
|
||||
|
||||
## Features
|
||||
|
||||
- Default param name `"lang"`, configurable via
|
||||
`options.paramName`.
|
||||
- Server-tier read via `ctx.request.url.searchParams.get(...)`.
|
||||
- Browser-tier read via `URLSearchParams(globalThis.location.search)`.
|
||||
- Empty values and missing globals return `null`, never throw.
|
||||
- `environment: "both"` — eligible on every chain run.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { querystringDetector } from "@suaveplan/i18n-detector/detectors/querystring";
|
||||
|
||||
const detector = querystringDetector();
|
||||
const result = detector.detect?.({
|
||||
request: {
|
||||
headers: new Headers(),
|
||||
url: new URL("https://example.com/?lang=fr"),
|
||||
},
|
||||
supportedLocales: ["en-GB", "fr-FR"],
|
||||
fallbackLocale: "en-GB",
|
||||
});
|
||||
// result === "fr"
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import {
|
||||
createDetectorChain,
|
||||
querystringDetector,
|
||||
cookieDetector,
|
||||
} from "@suaveplan/i18n-detector";
|
||||
|
||||
const chain = createDetectorChain([
|
||||
querystringDetector({ paramName: "ui-lang" }),
|
||||
cookieDetector(),
|
||||
]);
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `querystringDetector(options?: QuerystringDetectorOptions): LocaleDetector`
|
||||
- `QuerystringDetectorOptions.paramName?: string` — default `"lang"`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- The browser fallback uses `URLSearchParams` rather than parsing
|
||||
manually to keep the detector free of edge-case bugs (encoded
|
||||
characters, repeated keys, etc.).
|
||||
- The server reader trusts `ctx.request.url` to be a parsed `URL`
|
||||
instance — request adapters at the edge construct it once and
|
||||
hand it through every detector, which is significantly faster
|
||||
than re-parsing per detector call.
|
||||
@@ -0,0 +1,86 @@
|
||||
# `user-preference.ts` — backend-stored locale preference detector
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
`userPreferenceDetector(options)` builds an async `LocaleDetector`
|
||||
that calls a backend endpoint to read (and later persist) the
|
||||
user's stored locale preference. This is the surface authenticated
|
||||
applications use to honour a user's saved-to-account choice across
|
||||
devices, ahead of cookie/localStorage which are device-bound.
|
||||
|
||||
Unlike most detectors in this package, user-preference is async-
|
||||
only — it has no `detect` and only contributes during the chain's
|
||||
async race phase. It runs in both server and browser tiers because
|
||||
the endpoint is typically a thin JSON API that accepts the
|
||||
session's auth header from either side.
|
||||
|
||||
## Features
|
||||
|
||||
- GET `options.endpoint` returning `{ locale }`; non-2xx,
|
||||
malformed, and rejected responses log a warn and return `null`.
|
||||
- POST `options.endpoint` with `{ locale }` JSON on
|
||||
`cache(locale, ctx)`; failures are non-fatal.
|
||||
- `options.extractAuth(ctx)` injects per-request auth headers
|
||||
(e.g. session cookie on SSR runs).
|
||||
- `options.fetch` injectable for tests.
|
||||
- `options.logger?: LoggerLike` — defaults to a no-op so the
|
||||
package keeps zero non-shared deps. Production consumers wire
|
||||
in `@suaveplan/logger`.
|
||||
- Threads `ctx.signal` into every fetch's `RequestInit.signal`.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { userPreferenceDetector } from "@suaveplan/i18n-detector/detectors/user-preference";
|
||||
|
||||
const detector = userPreferenceDetector({
|
||||
endpoint: "/api/me/locale",
|
||||
});
|
||||
|
||||
const locale = await detector.detectAsync?.({
|
||||
supportedLocales: ["en-GB", "fr-FR"],
|
||||
fallbackLocale: "en-GB",
|
||||
});
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { userPreferenceDetector } from "@suaveplan/i18n-detector/detectors/user-preference";
|
||||
import { ConsoleLogger } from "@suaveplan/logger";
|
||||
|
||||
const detector = userPreferenceDetector({
|
||||
endpoint: "/api/me/locale",
|
||||
logger: new ConsoleLogger({ tags: ["i18n.user-preference"] }),
|
||||
extractAuth: (ctx) => ({
|
||||
cookie: ctx.request?.headers.get("cookie") ?? "",
|
||||
}),
|
||||
fetch: globalThis.fetch,
|
||||
});
|
||||
|
||||
await detector.cache?.("fr-FR", ctx);
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `userPreferenceDetector(options): LocaleDetector` — `environment: "both"`.
|
||||
- `UserPreferenceDetectorOptions.endpoint: string` — required.
|
||||
- `UserPreferenceDetectorOptions.extractAuth?: (ctx) => HeadersInit`.
|
||||
- `UserPreferenceDetectorOptions.cacheKey?: (ctx) => string`.
|
||||
- `UserPreferenceDetectorOptions.fetch?: typeof fetch`.
|
||||
- `UserPreferenceDetectorOptions.logger?: LoggerLike`.
|
||||
- `UserPreferenceResponseBody = { locale: string }` — wire shape.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- A pre-aborted `ctx.signal` typically causes the underlying
|
||||
`fetch` to reject; the rejection is swallowed and `null` is
|
||||
returned, never propagated into the chain.
|
||||
- `Content-Type: application/json` is set automatically on cache
|
||||
POSTs unless the auth headers already declare one.
|
||||
- The `cacheKey` field is exposed for consumers that want
|
||||
request-level dedup but is intentionally not consulted by the
|
||||
detector itself — caching is the consumer's concern.
|
||||
Reference in New Issue
Block a user