first commit

This commit is contained in:
2026-05-29 08:20:56 +01:00
commit 949299f58d
6368 changed files with 527176 additions and 0 deletions

View File

@@ -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).

View File

@@ -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.

View File

@@ -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.

View File

@@ -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).

View File

@@ -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.

View File

@@ -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.

View File

@@ -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".

View File

@@ -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.

View File

@@ -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.