Initial Commit

This commit is contained in:
2026-05-27 23:47:53 +01:00
parent fbc70f96e6
commit 9b28db724f
18103 changed files with 1600554 additions and 0 deletions

View File

@@ -0,0 +1,82 @@
# acceptLanguageDetector
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`acceptLanguageDetector` is a server-only `LocaleDetector` that parses
the inbound `Accept-Language` header per RFC 9110 §5.3.5 and returns
the highest-priority entry. It is the canonical fallback when nothing
else (querystring, user preference, cookie, geo lookup) has produced a
match — every browser submits an `Accept-Language` header so this
detector almost always returns _something_ on a real request.
The detector returns the _raw_ highest-q entry; supported-locale
negotiation is the chain runner's responsibility (it calls
`LocaleResolver.negotiate(raw, supported)` to perform BCP-47 fallback).
## Features
- Full RFC 9110 q-value parsing (default 1.0, range `[0, 1]`)
- Stable sort: ties keep insertion order
- Wildcard `*` skipped (no useful locale information)
- Malformed entries silently skipped
- `environment: "server"`
## Usage: Basic Example
```ts
import { acceptLanguageDetector } from "@suaveplan/react-i18n/detector/accept-language/accept-language";
import { createDetectorChain } from "@suaveplan/i18n/detector";
const chain = createDetectorChain([acceptLanguageDetector()]);
```
## Usage: Advanced Example
```ts
import { parseAcceptLanguage } from "@suaveplan/react-i18n/detector/accept-language/accept-language";
// Build a custom detector that biases language groups using the
// canonical RFC 9110 parser exposed by this module.
const groups = parseAcceptLanguage("fr-FR;q=0.5, de-DE;q=0.9, en;q=0.8");
// → [{ locale: "de-DE", q: 0.9 }, { locale: "en", q: 0.8 }, ...]
```
## API Reference
### `acceptLanguageDetector(): LocaleDetector`
Returns a `LocaleDetector` with `name: "acceptLanguage"` and
`environment: "server"`. Only `detect(ctx)` is implemented.
### `parseAcceptLanguage(header: string): AcceptLanguageEntry[]`
Parses an `Accept-Language` header into a sorted descending-by-q array
of `{ locale, q }` entries. Exposed for tests and as a building block
for advanced consumers building custom detectors.
### `AcceptLanguageEntry`
```ts
interface AcceptLanguageEntry {
readonly locale: string;
readonly q: number;
}
```
## Implementation Notes
- The parser uses `Array.prototype.sort` with a tie-breaking
insertion-index because the JavaScript spec only guarantees a
stable sort in modern engines; the explicit index keeps the
behaviour deterministic across older runtimes too.
- Wildcards are skipped at parse time — they never appear in the
returned array. The chain runner still has the option of
consulting `ctx.fallbackLocale` once every detector returns null.
- An entry whose q-value is unparseable, negative, or > 1 is treated
as a malformed entry per RFC 9110 ("the recipient SHOULD discard")
rather than coerced to the documented default — this keeps the
parser strict at the field level while staying liberal at the
header level.

View File

@@ -0,0 +1,156 @@
import type { DetectorContext } from "@suaveplan/i18n-detector";
import { describe, expect, it } from "@suaveplan/testing/runner";
import {
acceptLanguageDetector,
parseAcceptLanguage,
} from "./accept-language.js";
const SUPPORTED = ["en-GB", "fr-FR", "de-DE", "ja-JP"] as const;
function serverContext(headers: HeadersInit = {}): DetectorContext {
return {
request: {
headers: new Headers(headers),
url: new URL("https://example.com/"),
},
responseHeaders: new Headers(),
supportedLocales: SUPPORTED,
fallbackLocale: "en-GB",
};
}
describe("acceptLanguageDetector", () => {
it("exposes name and environment per spec", { timeout: 5000 }, () => {
const detector = acceptLanguageDetector();
expect(detector.name).toBe("acceptLanguage");
expect(detector.environment).toBe("server");
});
it("returns highest-q tag from Accept-Language", { timeout: 5000 }, () => {
const detector = acceptLanguageDetector();
const result = detector.detect?.(
serverContext({
"accept-language": "fr-FR;q=0.5, de-DE;q=0.9, en-GB;q=0.8",
})
);
expect(result).toBe("de-DE");
});
it("treats missing q as 1.0", { timeout: 5000 }, () => {
const detector = acceptLanguageDetector();
const result = detector.detect?.(
serverContext({
"accept-language": "fr-FR;q=0.9, de-DE",
})
);
expect(result).toBe("de-DE");
});
it("preserves insertion order on ties", { timeout: 5000 }, () => {
const detector = acceptLanguageDetector();
const result = detector.detect?.(
serverContext({ "accept-language": "fr-FR, de-DE, ja-JP" })
);
expect(result).toBe("fr-FR");
});
it("skips wildcard token", { timeout: 5000 }, () => {
const detector = acceptLanguageDetector();
const result = detector.detect?.(
serverContext({ "accept-language": "*;q=1.0, fr-FR;q=0.5" })
);
expect(result).toBe("fr-FR");
});
it("returns null for empty header", { timeout: 5000 }, () => {
const detector = acceptLanguageDetector();
const result = detector.detect?.(
serverContext({ "accept-language": "" })
);
expect(result).toBeNull();
});
it("returns null when header missing entirely", { timeout: 5000 }, () => {
const detector = acceptLanguageDetector();
const result = detector.detect?.(serverContext({}));
expect(result).toBeNull();
});
it("returns null when no request context", { timeout: 5000 }, () => {
const detector = acceptLanguageDetector();
const result = detector.detect?.({
supportedLocales: SUPPORTED,
fallbackLocale: "en-GB",
});
expect(result).toBeNull();
});
it("skips malformed entries with negative q", { timeout: 5000 }, () => {
const detector = acceptLanguageDetector();
const result = detector.detect?.(
serverContext({
"accept-language": "fr-FR;q=-1, de-DE;q=0.5",
})
);
expect(result).toBe("de-DE");
});
it("skips malformed entries with NaN q", { timeout: 5000 }, () => {
const detector = acceptLanguageDetector();
const result = detector.detect?.(
serverContext({
"accept-language": "fr-FR;q=abc, de-DE;q=0.5",
})
);
expect(result).toBe("de-DE");
});
it("skips malformed entries with q > 1", { timeout: 5000 }, () => {
const detector = acceptLanguageDetector();
const result = detector.detect?.(
serverContext({
"accept-language": "fr-FR;q=2.5, de-DE;q=0.4",
})
);
expect(result).toBe("de-DE");
});
it("ignores unrelated parameters", { timeout: 5000 }, () => {
const detector = acceptLanguageDetector();
const result = detector.detect?.(
serverContext({
"accept-language": "fr-FR;foo=bar;q=0.9, de-DE;q=0.5",
})
);
expect(result).toBe("fr-FR");
});
it("returns null for header consisting of only wildcards and empty", {
timeout: 5000,
}, () => {
const detector = acceptLanguageDetector();
const result = detector.detect?.(
serverContext({ "accept-language": "*, , *;q=0.5" })
);
expect(result).toBeNull();
});
it("parser handles parameter without '='", { timeout: 5000 }, () => {
const result = parseAcceptLanguage("fr-FR;noequals;q=0.6");
expect(result).toEqual([{ locale: "fr-FR", q: 0.6 }]);
});
it("parser handles trailing whitespace and empty params", {
timeout: 5000,
}, () => {
const result = parseAcceptLanguage(" fr-FR ; ; q = 0.7 ");
expect(result).toEqual([{ locale: "fr-FR", q: 0.7 }]);
});
it("parser yields empty when header only has commas", {
timeout: 5000,
}, () => {
const result = parseAcceptLanguage(",, ,");
expect(result).toEqual([]);
});
});

View File

@@ -0,0 +1,82 @@
import type { LocaleDetector } from "@suaveplan/i18n-detector";
import type { AcceptLanguageEntry } from "./accept-language.types.js";
/**
* Build a `LocaleDetector` that parses the inbound `Accept-Language`
* header per RFC 9110 §5.3.5 and returns the highest-priority entry.
*
* The detector returns the *raw* highest-q entry; supported-locale
* negotiation is the chain runner's responsibility (it calls
* `LocaleResolver.negotiate(raw, supportedLocales)` to perform BCP-47
* fallback). The wildcard token (`*`) is intentionally skipped because
* it carries no locale information that can be negotiated meaningfully.
*
* Malformed entries (missing tag, unparseable q value, q out of range)
* are silently skipped per RFC 9110: the parser MUST be liberal in
* what it accepts.
*/
export function acceptLanguageDetector(): LocaleDetector {
return {
name: "acceptLanguage",
environment: "server",
detect(ctx) {
const header = ctx.request?.headers.get("accept-language");
if (typeof header !== "string" || header.trim() === "") {
return null;
}
const entries = parseAcceptLanguage(header);
if (entries.length === 0) return null;
const first = entries[0] as AcceptLanguageEntry;
return first.locale;
},
};
}
/**
* Parse an `Accept-Language` header into a sorted list of entries.
* Sorted descending by q-value; ties keep input order (stable sort).
*
* Exported for tests and as a utility for advanced consumers building
* custom detectors layered on top of this one.
*/
export function parseAcceptLanguage(header: string): AcceptLanguageEntry[] {
const result: AcceptLanguageEntry[] = [];
const segments = header.split(",");
for (let i = 0; i < segments.length; i++) {
const segment = segments[i] as string;
const parsed = parseSegment(segment);
if (parsed !== null) result.push(parsed);
}
// Stable sort by q desc, falling back to insertion order via index.
return result
.map((entry, index) => ({ entry, index }))
.sort((a, b) => {
if (a.entry.q !== b.entry.q) return b.entry.q - a.entry.q;
return a.index - b.index;
})
.map(({ entry }) => entry);
}
function parseSegment(segment: string): AcceptLanguageEntry | null {
const trimmed = segment.trim();
if (trimmed === "") return null;
const parts = trimmed.split(";");
const tagPart = (parts[0] as string).trim();
if (tagPart === "" || tagPart === "*") return null;
let q = 1;
for (let i = 1; i < parts.length; i++) {
const param = (parts[i] as string).trim();
if (param === "") continue;
const eqIndex = param.indexOf("=");
if (eqIndex === -1) continue;
const key = param.slice(0, eqIndex).trim().toLowerCase();
if (key !== "q") continue;
const raw = param.slice(eqIndex + 1).trim();
const parsed = Number.parseFloat(raw);
if (Number.isNaN(parsed) || parsed < 0 || parsed > 1) {
return null;
}
q = parsed;
}
return { locale: tagPart, q };
}

View File

@@ -0,0 +1,8 @@
/**
* Single parsed `Accept-Language` entry: the locale tag and its
* computed quality-value (defaulting to 1.0 when omitted).
*/
export interface AcceptLanguageEntry {
readonly locale: string;
readonly q: number;
}

View File

@@ -0,0 +1,5 @@
export {
acceptLanguageDetector,
parseAcceptLanguage,
} from "./accept-language.js";
export type { AcceptLanguageEntry } from "./accept-language.types.js";

View File

@@ -0,0 +1,83 @@
# cloudflareIpGeoDetector
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`cloudflareIpGeoDetector` is a server-only `LocaleDetector` that maps
the Cloudflare `cf-ipcountry` request header (ISO 3166-1 alpha-2) to a
BCP-47 locale via `@suaveplan/locale-data`. The detector targets edge
deployments behind Cloudflare's proxy, where every inbound request
already carries an authoritative country lookup. It is the last
substantive detector before the unconditional fallback in the default
chain — by the time the chain reaches it, every other source has
already returned `null`.
The mapping rule is `${language}-${ISO}` where `language` is the
country's primary spoken language as recorded in the locale-data
package (e.g. UAE → `["ar"]` → `"ar-AE"` per the spec scenario
"cloudflareIpGeoDetector maps country code").
## Features
- Reads the `cf-ipcountry` header (override via `opts.headerName`)
- Default mapping derived from `@suaveplan/locale-data`
- Per-country override map via `opts.countryToLocale`
- Case-insensitive ISO code handling
- Returns `null` for missing / empty / unknown country codes
- `environment: "server"`
## Usage: Basic Example
```ts
import { cloudflareIpGeoDetector } from "@suaveplan/react-i18n/detector/cloudflare-ip-geo/cloudflare-ip-geo";
import { createDetectorChain } from "@suaveplan/i18n/detector";
const chain = createDetectorChain([cloudflareIpGeoDetector()]);
```
## Usage: Advanced Example
```ts
import { cloudflareIpGeoDetector } from "@suaveplan/react-i18n/detector/cloudflare-ip-geo/cloudflare-ip-geo";
// Override Switzerland (locale-data lists multiple languages) so the
// detector resolves to French Swiss rather than the default first
// language. Country codes for which the default suffices are omitted.
const detector = cloudflareIpGeoDetector({
countryToLocale: { CH: "fr-CH" },
});
```
## API Reference
### `cloudflareIpGeoDetector(options?: CloudflareIpGeoDetectorOptions): LocaleDetector`
Returns a `LocaleDetector` with `name: "cloudflareIpGeo"` and
`environment: "server"`. Only `detect(ctx)` is implemented.
### `CloudflareIpGeoDetectorOptions`
| Field | Type | Default | Description |
| --------------- | ----------------------------- | ---------------- | ------------------------------------ |
| headerName | `string` | `"cf-ipcountry"` | Header name read from the request |
| countryToLocale | `Record<string, string>` | `{}` | Per-country locale overrides |
## Implementation Notes
- The detector uppercases the inbound code before lookup so it
tolerates upstream proxies that may have normalised the header
value to lower case.
- An override that maps to an empty string is treated as a no-op so
callers can safely build the override map by spreading partials
without manual filtering.
- The detector intentionally does NOT swallow the `getAllCountries`
call result — the registry is loaded once at module-load time so
every call is `O(1)` lookup.
- The detector relies on the locale-data invariant that every
country record has at least one entry in its `languages` array
(verified by the locale-data package's own conformance tests).
Should that invariant ever change, the detector would emit a
malformed `undefined-XX` locale; downstream BCP-47 negotiation in
the chain runner would surface the malformed value as `null`.

View File

@@ -0,0 +1,116 @@
import type { DetectorContext } from "@suaveplan/i18n-detector";
import { describe, expect, it } from "@suaveplan/testing/runner";
import { cloudflareIpGeoDetector } from "./cloudflare-ip-geo.js";
const SUPPORTED = ["en-GB", "ar-AE", "ja-JP", "fr-FR"] as const;
function serverContext(headers: HeadersInit = {}): DetectorContext {
return {
request: {
headers: new Headers(headers),
url: new URL("https://example.com/"),
},
responseHeaders: new Headers(),
supportedLocales: SUPPORTED,
fallbackLocale: "en-GB",
};
}
describe("cloudflareIpGeoDetector", () => {
it("exposes name and environment per spec", { timeout: 5000 }, () => {
const detector = cloudflareIpGeoDetector();
expect(detector.name).toBe("cloudflareIpGeo");
expect(detector.environment).toBe("server");
});
it("maps cf-ipcountry: AE → ar-AE per spec", { timeout: 5000 }, () => {
const detector = cloudflareIpGeoDetector();
const result = detector.detect?.(
serverContext({ "cf-ipcountry": "AE" })
);
expect(result).toBe("ar-AE");
});
it("maps JP → ja-JP via locale-data languages array", {
timeout: 5000,
}, () => {
const detector = cloudflareIpGeoDetector();
const result = detector.detect?.(
serverContext({ "cf-ipcountry": "JP" })
);
expect(result).toBe("ja-JP");
});
it("honours override map", { timeout: 5000 }, () => {
const detector = cloudflareIpGeoDetector({
countryToLocale: { AE: "en-AE" },
});
const result = detector.detect?.(
serverContext({ "cf-ipcountry": "AE" })
);
expect(result).toBe("en-AE");
});
it("ignores empty override entry and uses derived value", {
timeout: 5000,
}, () => {
const detector = cloudflareIpGeoDetector({
countryToLocale: { AE: "" },
});
const result = detector.detect?.(
serverContext({ "cf-ipcountry": "AE" })
);
expect(result).toBe("ar-AE");
});
it("honours custom headerName", { timeout: 5000 }, () => {
const detector = cloudflareIpGeoDetector({
headerName: "x-edge-country",
});
const result = detector.detect?.(
serverContext({ "x-edge-country": "FR" })
);
expect(result).toBe("fr-FR");
});
it("uppercases the inbound code before lookup", { timeout: 5000 }, () => {
const detector = cloudflareIpGeoDetector();
const result = detector.detect?.(
serverContext({ "cf-ipcountry": "ae" })
);
expect(result).toBe("ar-AE");
});
it("returns null for unknown country code", { timeout: 5000 }, () => {
const detector = cloudflareIpGeoDetector();
const result = detector.detect?.(
serverContext({ "cf-ipcountry": "ZZ" })
);
expect(result).toBeNull();
});
it("returns null when header missing", { timeout: 5000 }, () => {
const detector = cloudflareIpGeoDetector();
const result = detector.detect?.(serverContext({}));
expect(result).toBeNull();
});
it("returns null when header is empty", { timeout: 5000 }, () => {
const detector = cloudflareIpGeoDetector();
const result = detector.detect?.(
serverContext({ "cf-ipcountry": " " })
);
expect(result).toBeNull();
});
it("returns null when no request context is supplied", {
timeout: 5000,
}, () => {
const detector = cloudflareIpGeoDetector();
const result = detector.detect?.({
supportedLocales: SUPPORTED,
fallbackLocale: "en-GB",
});
expect(result).toBeNull();
});
});

View File

@@ -0,0 +1,50 @@
import type { LocaleDetector } from "@suaveplan/i18n-detector";
import { getAllCountries } from "@suaveplan/locale-data";
import type { CloudflareIpGeoDetectorOptions } from "./cloudflare-ip-geo.types.js";
const DEFAULT_HEADER_NAME = "cf-ipcountry";
/**
* Build a `LocaleDetector` that maps the Cloudflare `cf-ipcountry`
* header (ISO 3166-1 alpha-2) to a BCP-47 locale.
*
* Resolution order, per ISO code:
*
* 1. `opts.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.
*
* Example: `cf-ipcountry: AE` → `"ar-AE"` (UAE country record lists
* `["ar"]` as its languages).
*/
export function cloudflareIpGeoDetector(
options: CloudflareIpGeoDetectorOptions = {}
): LocaleDetector {
const headerName = options.headerName ?? DEFAULT_HEADER_NAME;
const overrides = options.countryToLocale ?? {};
const countries = getAllCountries();
return {
name: "cloudflareIpGeo",
environment: "server",
detect(ctx) {
const raw = ctx.request?.headers.get(headerName);
if (typeof raw !== "string" || raw.trim() === "") return null;
const code = raw.trim().toUpperCase();
const override = overrides[code];
if (typeof override === "string" && override !== "") {
return override;
}
const country = countries[code];
if (!country) return null;
// The locale-data `countries.data.ts` invariant guarantees
// every entry has at least one language; the test harness
// exercises every reachable branch via known ISO codes.
const primaryLanguage = country.languages[0] as string;
return `${primaryLanguage}-${code}`;
},
};
}

View File

@@ -0,0 +1,20 @@
/**
* Configuration accepted by `cloudflareIpGeoDetector`. Both fields are
* optional; defaults pull the standard Cloudflare `cf-ipcountry`
* header and resolve a locale via `@suaveplan/locale-data`.
*/
export interface CloudflareIpGeoDetectorOptions {
/**
* Header name to read. Default `"cf-ipcountry"`. Cloudflare always
* emits a lower-case header name on the wire; `Headers.get(...)` is
* case-insensitive so the default matches any casing the upstream
* runtime may surface.
*/
readonly headerName?: string;
/**
* Per-country override map. Keys are ISO 3166-1 alpha-2 country
* codes; values are full BCP-47 locale tags. Overrides win over
* the data-derived default mapping for the matching country.
*/
readonly countryToLocale?: Readonly<Record<string, string>>;
}

View File

@@ -0,0 +1,2 @@
export { cloudflareIpGeoDetector } from "./cloudflare-ip-geo.js";
export type { CloudflareIpGeoDetectorOptions } from "./cloudflare-ip-geo.types.js";

View File

@@ -0,0 +1,83 @@
# cookieDetector
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`cookieDetector` is a `LocaleDetector` that reads/writes the active
locale via a single cookie. It runs in both server and browser
environments, making it the natural persistence story for stateless
visitors who have not yet authenticated. On the server it consumes the
inbound `Cookie` header and writes a `Set-Cookie` header onto
`ctx.responseHeaders`; on the browser it goes through `document.cookie`.
The detector URL-encodes values on persist and decodes them on read so
the wire format is robust against unusual locale identifiers.
## Features
- Configurable cookie name, max-age, SameSite, and path
- Server read via `ctx.request.headers` and write via
`ctx.responseHeaders`
- Browser read/write via `document.cookie` (including `ctx.document`
injection for tests)
- URL-encoded values; safe parsing tolerates malformed segments and
decode failures
- `environment: "both"`
## Usage: Basic Example
```ts
import { cookieDetector } from "@suaveplan/react-i18n/detector/cookie/cookie";
import { createDetectorChain } from "@suaveplan/i18n/detector";
const chain = createDetectorChain([cookieDetector()]);
```
## Usage: Advanced Example
```ts
import { cookieDetector } from "@suaveplan/react-i18n/detector/cookie/cookie";
// Cross-site embed scenario: SameSite=None requires Secure on real
// browsers; the detector emits the SameSite attribute literally so the
// host server can append `Secure` via its existing cookie middleware.
const detector = cookieDetector({
name: "app_locale",
maxAgeSeconds: 60 * 60 * 24 * 30, // 30 days
sameSite: "none",
path: "/embed",
});
```
## API Reference
### `cookieDetector(options?: CookieDetectorOptions): LocaleDetector`
Returns a `LocaleDetector` with `name: "cookie"` and
`environment: "both"`. Both `detect` and `cache` are implemented.
### `CookieDetectorOptions`
| Field | Type | Default | Description |
| ------------- | ---------------------------------------- | -------------------- | ---------------------------- |
| name | `string` | `"suaveplan_locale"` | Cookie name |
| maxAgeSeconds | `number` | `31536000` | Cookie lifetime in seconds |
| sameSite | `"lax" \| "strict" \| "none"` (default `"lax"`) | `"lax"` | SameSite attribute value |
| path | `string` | `"/"` | Cookie path attribute |
## Implementation Notes
- The detector emits SameSite values capitalised (`Lax`, `Strict`,
`None`) per the cookie spec; the input option stays lower-case so
consumers don't have to remember the casing convention.
- Cookie parsing tolerates segments without an `=` (skipped) and
values that fail percent-decoding (returned raw rather than
throwing).
- The cookie writer uses `Headers.append('set-cookie', …)` so a single
response may carry multiple Set-Cookie writes from sibling
middleware.
- An empty server cookie header transparently falls through to
`document.cookie` so isomorphic test harnesses can stage a browser
scenario inside a server-shaped `DetectorContext` by supplying only
`globalThis.document`.

View File

@@ -0,0 +1,275 @@
import type { DetectorContext } from "@suaveplan/i18n-detector";
import { describe, expect, it } from "@suaveplan/testing/runner";
import { cookieDetector } from "./cookie.js";
const SUPPORTED = ["en-GB", "fr-FR", "de-DE", "ru-RU"] as const;
function serverContext(headers: HeadersInit = {}): DetectorContext {
return {
request: {
headers: new Headers(headers),
url: new URL("https://example.com/"),
},
responseHeaders: new Headers(),
supportedLocales: SUPPORTED,
fallbackLocale: "en-GB",
};
}
function browserContext(): DetectorContext {
return {
supportedLocales: SUPPORTED,
fallbackLocale: "en-GB",
};
}
function withDocumentCookie<T>(initial: string, fn: () => T): T {
const docOriginal = (globalThis as { document?: unknown }).document;
let store = initial;
const fakeDoc = {
get cookie() {
return store;
},
set cookie(value: string) {
store = value;
},
};
Object.defineProperty(globalThis, "document", {
configurable: true,
value: fakeDoc,
});
try {
return fn();
} finally {
Object.defineProperty(globalThis, "document", {
configurable: true,
value: docOriginal,
});
}
}
describe("cookieDetector", () => {
it("reads named cookie from server-side cookie header", {
timeout: 5000,
}, () => {
const detector = cookieDetector();
const result = detector.detect?.(
serverContext({ cookie: "suaveplan_locale=fr-FR; other=1" })
);
expect(result).toBe("fr-FR");
});
it("honours custom name option", { timeout: 5000 }, () => {
const detector = cookieDetector({ name: "l" });
const result = detector.detect?.(serverContext({ cookie: "l=ru-RU" }));
expect(result).toBe("ru-RU");
});
it("decodes URL-encoded cookie values", { timeout: 5000 }, () => {
const detector = cookieDetector();
const result = detector.detect?.(
serverContext({ cookie: "suaveplan_locale=fr%2DFR" })
);
expect(result).toBe("fr-FR");
});
it("returns the raw value when decode throws", { timeout: 5000 }, () => {
const detector = cookieDetector();
// %ZZ is invalid percent-encoding -> decodeURIComponent throws
const result = detector.detect?.(
serverContext({ cookie: "suaveplan_locale=%ZZ" })
);
expect(result).toBe("%ZZ");
});
it("returns null when cookie missing", { timeout: 5000 }, () => {
const detector = cookieDetector();
const result = detector.detect?.(
serverContext({ cookie: "session=abc" })
);
expect(result).toBeNull();
});
it("returns null when matched cookie value is empty", {
timeout: 5000,
}, () => {
const detector = cookieDetector();
const result = detector.detect?.(
serverContext({ cookie: "suaveplan_locale=" })
);
expect(result).toBeNull();
});
it("ignores malformed segments without '='", { timeout: 5000 }, () => {
const detector = cookieDetector();
const result = detector.detect?.(
serverContext({
cookie: "noequalsign; suaveplan_locale=de-DE; ",
})
);
expect(result).toBe("de-DE");
});
it("skips empty cookie segments", { timeout: 5000 }, () => {
const detector = cookieDetector();
const result = detector.detect?.(
serverContext({
cookie: ";; suaveplan_locale=fr-FR;;",
})
);
expect(result).toBe("fr-FR");
});
it("persists choice via Set-Cookie header on server", {
timeout: 5000,
}, () => {
const detector = cookieDetector({ name: "l", maxAgeSeconds: 100 });
const ctx = serverContext({});
detector.cache?.("fr-FR", ctx);
const set = ctx.responseHeaders?.get("set-cookie");
expect(set).toBe("l=fr-FR; Max-Age=100; Path=/; SameSite=Lax");
});
it("Strict and None sameSite serialise correctly", {
timeout: 5000,
}, () => {
const ctxStrict = serverContext({});
cookieDetector({ sameSite: "strict" }).cache?.("en-GB", ctxStrict);
expect(ctxStrict.responseHeaders?.get("set-cookie")).toContain(
"SameSite=Strict"
);
const ctxNone = serverContext({});
cookieDetector({ sameSite: "none" }).cache?.("en-GB", ctxNone);
expect(ctxNone.responseHeaders?.get("set-cookie")).toContain(
"SameSite=None"
);
});
it("URL-encodes the value on persist", { timeout: 5000 }, () => {
const ctx = serverContext({});
cookieDetector().cache?.("zh CN/with;semi", ctx);
const set = ctx.responseHeaders?.get("set-cookie");
expect(set).toContain(
`suaveplan_locale=${encodeURIComponent("zh CN/with;semi")}`
);
});
it("honours custom path option", { timeout: 5000 }, () => {
const ctx = serverContext({});
cookieDetector({ path: "/app" }).cache?.("en-GB", ctx);
expect(ctx.responseHeaders?.get("set-cookie")).toContain("Path=/app");
});
it("reads document.cookie on browser when no request header", {
timeout: 5000,
}, () => {
const detector = cookieDetector();
withDocumentCookie("suaveplan_locale=de-DE", () => {
const result = detector.detect?.(browserContext());
expect(result).toBe("de-DE");
});
});
it("writes document.cookie on browser when no responseHeaders", {
timeout: 5000,
}, () => {
const detector = cookieDetector();
withDocumentCookie("", () => {
detector.cache?.("ru-RU", browserContext());
const written = (globalThis as { document: { cookie: string } })
.document.cookie;
expect(written).toContain("suaveplan_locale=ru-RU");
expect(written).toContain("Max-Age=31536000");
expect(written).toContain("SameSite=Lax");
});
});
it("returns null on browser when document is unavailable", {
timeout: 5000,
}, () => {
const detector = cookieDetector();
const docOriginal = (globalThis as { document?: unknown }).document;
Object.defineProperty(globalThis, "document", {
configurable: true,
value: undefined,
});
try {
const result = detector.detect?.(browserContext());
expect(result).toBeNull();
} finally {
Object.defineProperty(globalThis, "document", {
configurable: true,
value: docOriginal,
});
}
});
it("cache is a no-op when neither responseHeaders nor document available", {
timeout: 5000,
}, () => {
const detector = cookieDetector();
const docOriginal = (globalThis as { document?: unknown }).document;
Object.defineProperty(globalThis, "document", {
configurable: true,
value: undefined,
});
try {
expect(() =>
detector.cache?.("en-GB", browserContext())
).not.toThrow();
} finally {
Object.defineProperty(globalThis, "document", {
configurable: true,
value: docOriginal,
});
}
});
it("ignores empty server cookie header and falls through to document", {
timeout: 5000,
}, () => {
const detector = cookieDetector();
withDocumentCookie("suaveplan_locale=fr-FR", () => {
const ctx = serverContext({});
const result = detector.detect?.(ctx);
expect(result).toBe("fr-FR");
});
});
it("returns null on browser when document.cookie is not a string", {
timeout: 5000,
}, () => {
const detector = cookieDetector();
const docOriginal = (globalThis as { document?: unknown }).document;
Object.defineProperty(globalThis, "document", {
configurable: true,
value: { cookie: 1234 as unknown as string },
});
try {
const result = detector.detect?.(browserContext());
expect(result).toBeNull();
} finally {
Object.defineProperty(globalThis, "document", {
configurable: true,
value: docOriginal,
});
}
});
it("uses ctx.document over globalThis.document when supplied", {
timeout: 5000,
}, () => {
const detector = cookieDetector();
withDocumentCookie("suaveplan_locale=en-GB", () => {
const injected = {
cookie: "suaveplan_locale=fr-FR",
} as unknown as Document;
const result = detector.detect?.({
...browserContext(),
document: injected,
});
expect(result).toBe("fr-FR");
});
});
});

View File

@@ -0,0 +1,110 @@
import type { DetectorContext, LocaleDetector } from "@suaveplan/i18n-detector";
import type { CookieDetectorOptions, CookieSameSite } from "./cookie.types.js";
const DEFAULT_NAME = "suaveplan_locale";
const DEFAULT_MAX_AGE_SECONDS = 31536000;
const DEFAULT_SAME_SITE: CookieSameSite = "lax";
const DEFAULT_PATH = "/";
const SAME_SITE_LABEL: Record<CookieSameSite, string> = {
lax: "Lax",
strict: "Strict",
none: "None",
};
/**
* Build a `LocaleDetector` that reads/writes the active locale via a
* single cookie. On the server the detector reads the inbound `Cookie`
* header and writes a `Set-Cookie` header onto `ctx.responseHeaders`.
* On the browser it reads/writes `document.cookie` directly.
*
* Cookie values are URL-encoded so locales containing non-token
* characters (none of the BCP-47 subset, but a defensive guard) cannot
* break the cookie wire format.
*/
export function cookieDetector(
options: CookieDetectorOptions = {}
): LocaleDetector {
const name = options.name ?? DEFAULT_NAME;
const maxAgeSeconds = options.maxAgeSeconds ?? DEFAULT_MAX_AGE_SECONDS;
const sameSite = options.sameSite ?? DEFAULT_SAME_SITE;
const path = options.path ?? DEFAULT_PATH;
return {
name: "cookie",
environment: "both",
detect(ctx) {
const cookieHeader = readCookieHeader(ctx);
if (cookieHeader === null) return null;
return parseCookieValue(cookieHeader, name);
},
cache(locale, ctx) {
const setCookieValue = formatSetCookie({
name,
value: locale,
maxAgeSeconds,
sameSite,
path,
});
if (ctx.responseHeaders) {
ctx.responseHeaders.append("set-cookie", setCookieValue);
return;
}
const doc = readDocument(ctx);
if (doc === null) return;
doc.cookie = setCookieValue;
},
};
}
function readCookieHeader(ctx: DetectorContext): string | null {
const fromRequest = ctx.request?.headers.get("cookie");
if (typeof fromRequest === "string" && fromRequest !== "") {
return fromRequest;
}
const doc = readDocument(ctx);
if (doc === null) return null;
return typeof doc.cookie === "string" ? doc.cookie : null;
}
function readDocument(ctx: DetectorContext): Document | null {
if (ctx.document) return ctx.document;
const candidate = (globalThis as { document?: unknown }).document;
if (candidate && typeof candidate === "object") {
return candidate as Document;
}
return null;
}
function parseCookieValue(header: string, name: string): string | null {
const segments = header.split(";");
for (const segment of segments) {
const trimmed = segment.trim();
if (trimmed === "") continue;
const eqIndex = trimmed.indexOf("=");
if (eqIndex === -1) continue;
const key = trimmed.slice(0, eqIndex).trim();
if (key !== name) continue;
const raw = trimmed.slice(eqIndex + 1).trim();
if (raw === "") return null;
try {
return decodeURIComponent(raw);
} catch {
return raw;
}
}
return null;
}
interface SetCookieParts {
readonly name: string;
readonly value: string;
readonly maxAgeSeconds: number;
readonly sameSite: CookieSameSite;
readonly path: string;
}
function formatSetCookie(parts: SetCookieParts): string {
const encoded = encodeURIComponent(parts.value);
return `${parts.name}=${encoded}; Max-Age=${parts.maxAgeSeconds}; Path=${parts.path}; SameSite=${SAME_SITE_LABEL[parts.sameSite]}`;
}

View File

@@ -0,0 +1,21 @@
/**
* SameSite values accepted by `cookieDetector`. Mirrors the standard
* cookie attribute semantics; values are lower-cased on emit.
*/
export type CookieSameSite = "lax" | "strict" | "none";
/**
* Configuration accepted by `cookieDetector`. Every field is optional
* with sensible defaults (cookie name `suaveplan_locale`, one-year
* lifetime, `Lax` same-site, root path).
*/
export interface CookieDetectorOptions {
/** Cookie name. Default `"suaveplan_locale"`. */
readonly name?: string;
/** Cookie max age in seconds. Default `31536000` (1 year). */
readonly maxAgeSeconds?: number;
/** Cookie SameSite attribute. Default `"lax"`. */
readonly sameSite?: CookieSameSite;
/** Cookie path attribute. Default `"/"`. */
readonly path?: string;
}

View File

@@ -0,0 +1,5 @@
export { cookieDetector } from "./cookie.js";
export type {
CookieDetectorOptions,
CookieSameSite,
} from "./cookie.types.js";

View File

@@ -0,0 +1,91 @@
# defaultDetectorChain
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`defaultDetectorChain` is the canonical factory for the locale-detector
chain consumed by `<TranslationProvider>` and the
`useLocaleDetection` hook. It returns the standard 9-entry (or
8-entry, when `userPreferenceEndpoint` is omitted) detector list in the
order mandated by the spec — explicit user intent (querystring) first,
then per-user persistence (userPreference, cookie, localStorage), then
shell hints (htmlLang), then platform negotiation (acceptLanguage,
navigator), then geo (cloudflareIpGeo), and finally the unconditional
fallback.
The factory is the single source of truth for chain ordering. Custom
chains SHOULD start from this list and apply `filter` / `map` rather
than re-implementing the order, so future chain additions land in
every consumer automatically.
## Features
- 9-entry chain when `userPreferenceEndpoint` is provided
- 8-entry chain when it is omitted (or empty)
- Fresh array on every call (mutating the result is safe)
- Fallback locale validation at construction time
## Usage: Basic Example
```ts
import { defaultDetectorChain } from "@suaveplan/react-i18n/detector/default-chain/default-chain";
import { createDetectorChain } from "@suaveplan/i18n/detector";
const chain = createDetectorChain(
defaultDetectorChain({ fallback: "en-GB" })
);
```
## Usage: Advanced Example
```ts
import { defaultDetectorChain } from "@suaveplan/react-i18n/detector/default-chain/default-chain";
import { createDetectorChain } from "@suaveplan/i18n/detector";
// Skip Cloudflare in non-edge deployments and replace the default
// querystring param name with `?locale=`.
import { querystringDetector } from "@suaveplan/react-i18n/detector/querystring/querystring";
const detectors = defaultDetectorChain({
fallback: "en-GB",
userPreferenceEndpoint: "/api/me/locale",
})
.filter((d) => d.name !== "cloudflareIpGeo")
.map((d) =>
d.name === "querystring"
? querystringDetector({ paramName: "locale" })
: d
);
const chain = createDetectorChain(detectors);
```
## API Reference
### `defaultDetectorChain(options: DefaultDetectorChainOptions): LocaleDetector[]`
Returns a fresh array of `LocaleDetector` objects in the canonical
order. Throws if `options.fallback` is empty (the fallback detector's
constructor performs the check).
### `DefaultDetectorChainOptions`
| Field | Type | Default | Description |
| ---------------------- | -------- | ------- | --------------------------------------------------- |
| fallback | `string` | — | Required. Locale used by the terminal `fallbackDetector`. |
| userPreferenceEndpoint | `string` | (omit) | URL passed to `userPreferenceDetector`. When absent (or empty) the userPreference detector is skipped. |
## Implementation Notes
- The chain is returned as a fresh array on every call so consumers
can `filter` / `map` without affecting subsequent invocations or
shared state.
- Empty `userPreferenceEndpoint` strings are treated identically to
the field being omitted — a defensive guard for callers that
produce the options bag from environment variables (`process.env`
values default to empty strings, not undefined, in many runtimes).
- The factory deliberately exposes the canonical order _only_ — there
is no priority/weight mechanism on individual detectors. Consumers
who need a different order should compose their own array.

View File

@@ -0,0 +1,214 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { createReactI18nRegistry } from "../../registry/registry.js";
import type { DetectorPlugin } from "../../registry/registry.types.js";
import { defaultDetectorChain } from "./default-chain.js";
const detectorPlugin = (id: string, priority: number): DetectorPlugin => ({
id,
priority,
detect: {
name: id,
environment: "both",
detect: () => null,
},
});
describe("defaultDetectorChain", () => {
it("order matches spec when userPreferenceEndpoint provided", {
timeout: 5000,
}, () => {
const chain = defaultDetectorChain({
fallback: "en-GB",
userPreferenceEndpoint: "/api/me/locale",
});
expect(chain.map((d) => d.name)).toEqual([
"querystring",
"userPreference",
"cookie",
"localStorage",
"htmlLang",
"acceptLanguage",
"navigator",
"cloudflareIpGeo",
"fallback",
]);
});
it("omits userPreference when endpoint absent", { timeout: 5000 }, () => {
const chain = defaultDetectorChain({ fallback: "en-GB" });
expect(chain.map((d) => d.name)).toEqual([
"querystring",
"cookie",
"localStorage",
"htmlLang",
"acceptLanguage",
"navigator",
"cloudflareIpGeo",
"fallback",
]);
expect(chain.length).toBe(8);
});
it("omits userPreference when endpoint is an empty string", {
timeout: 5000,
}, () => {
const chain = defaultDetectorChain({
fallback: "en-GB",
userPreferenceEndpoint: "",
});
expect(chain.map((d) => d.name)).not.toContain("userPreference");
expect(chain.length).toBe(8);
});
it("returns a fresh array on each call", { timeout: 5000 }, () => {
const a = defaultDetectorChain({ fallback: "en-GB" });
const b = defaultDetectorChain({ fallback: "en-GB" });
expect(a).not.toBe(b);
a.length = 0;
expect(b.length).toBe(8);
});
it("fallback detector wraps the supplied locale", { timeout: 5000 }, () => {
const chain = defaultDetectorChain({ fallback: "ja-JP" });
const fallback = chain[chain.length - 1];
expect(fallback?.name).toBe("fallback");
expect(
fallback?.detect?.({
supportedLocales: ["ja-JP"],
fallbackLocale: "ja-JP",
})
).toBe("ja-JP");
});
it("throws when fallback locale is empty", { timeout: 5000 }, () => {
expect(() => defaultDetectorChain({ fallback: "" })).toThrow();
});
it("splices a low-priority plugin before all built-ins", {
timeout: 5000,
}, () => {
const reg = createReactI18nRegistry();
reg.registerDetector(detectorPlugin("ab-bucket", 0));
const chain = defaultDetectorChain({
fallback: "en-GB",
registry: reg,
});
expect(chain.map((d) => d.name)).toEqual([
"ab-bucket",
"querystring",
"cookie",
"localStorage",
"htmlLang",
"acceptLanguage",
"navigator",
"cloudflareIpGeo",
"fallback",
]);
});
it("splices a mid-priority plugin between built-ins", {
timeout: 5000,
}, () => {
const reg = createReactI18nRegistry();
reg.registerDetector(detectorPlugin("custom", 350));
const chain = defaultDetectorChain({
fallback: "en-GB",
registry: reg,
});
expect(chain.map((d) => d.name)).toEqual([
"querystring",
"cookie",
"custom",
"localStorage",
"htmlLang",
"acceptLanguage",
"navigator",
"cloudflareIpGeo",
"fallback",
]);
});
it("places fallback last regardless of plugin priority", {
timeout: 5000,
}, () => {
const reg = createReactI18nRegistry();
reg.registerDetector(detectorPlugin("late", 10_000));
const chain = defaultDetectorChain({
fallback: "en-GB",
registry: reg,
});
expect(chain[chain.length - 1]?.name).toBe("fallback");
expect(chain[chain.length - 2]?.name).toBe("late");
});
it("built-in beats plugin at the same priority", { timeout: 5000 }, () => {
const reg = createReactI18nRegistry();
reg.registerDetector(detectorPlugin("aaa", 300));
const chain = defaultDetectorChain({
fallback: "en-GB",
registry: reg,
});
const cookieIdx = chain.findIndex((d) => d.name === "cookie");
const aaaIdx = chain.findIndex((d) => d.name === "aaa");
expect(cookieIdx).toBeLessThan(aaaIdx);
});
it("breaks plugin-vs-plugin ties lexicographically by id", {
timeout: 5000,
}, () => {
const reg = createReactI18nRegistry();
// Register 4 plugins at the same priority in non-alphabetic
// order so the sort comparator is invoked in both directions
// (a.id < b.id AND a.id > b.id) and against multiple pairs of
// plugin-vs-plugin AND plugin-vs-built-in (same priority as
// the cookie built-in slot).
reg.registerDetector(detectorPlugin("zeta", 250));
reg.registerDetector(detectorPlugin("alpha", 250));
reg.registerDetector(detectorPlugin("mike", 250));
reg.registerDetector(detectorPlugin("bravo", 250));
const chain = defaultDetectorChain({
fallback: "en-GB",
registry: reg,
});
const indexOf = (name: string) =>
chain.findIndex((d) => d.name === name);
expect(indexOf("alpha")).toBeLessThan(indexOf("bravo"));
expect(indexOf("bravo")).toBeLessThan(indexOf("mike"));
expect(indexOf("mike")).toBeLessThan(indexOf("zeta"));
});
it("plugin-with-built-in at same priority places built-in first regardless of registration order", {
timeout: 5000,
}, () => {
// Exercise the `a.isPlugin ? 1 : -1` ternary in BOTH directions
// by mixing a high-priority plugin with built-ins ordered
// either side. The sort comparator gets called with (plugin,
// builtin) AND (builtin, plugin) argument orders.
const reg = createReactI18nRegistry();
reg.registerDetector(detectorPlugin("zeta-plugin", 300));
reg.registerDetector(detectorPlugin("alpha-plugin", 100));
const chain = defaultDetectorChain({
fallback: "en-GB",
registry: reg,
});
const names = chain.map((d) => d.name);
// Built-ins always come before plugins at the same priority,
// and the fallback is always last.
expect(names[names.length - 1]).toBe("fallback");
expect(names).toContain("alpha-plugin");
expect(names).toContain("zeta-plugin");
});
it("no-registry path is byte-identical to no-options path", {
timeout: 5000,
}, () => {
const a = defaultDetectorChain({ fallback: "en-GB" }).map(
(d) => d.name
);
const b = defaultDetectorChain({
fallback: "en-GB",
registry: createReactI18nRegistry(),
}).map((d) => d.name);
expect(b).toEqual(a);
});
});

View File

@@ -0,0 +1,150 @@
import type { LocaleDetector } from "@suaveplan/i18n-detector";
import { acceptLanguageDetector } from "../accept-language/accept-language.js";
import { cloudflareIpGeoDetector } from "../cloudflare-ip-geo/cloudflare-ip-geo.js";
import { cookieDetector } from "../cookie/cookie.js";
import { fallbackDetector } from "../fallback/fallback.js";
import { htmlLangDetector } from "../html-lang/html-lang.js";
import { localStorageDetector } from "../local-storage/local-storage.js";
import { navigatorDetector } from "../navigator/navigator.js";
import { querystringDetector } from "../querystring/querystring.js";
import { userPreferenceDetector } from "../user-preference/user-preference.js";
import type { DefaultDetectorChainOptions } from "./default-chain.types.js";
interface ChainEntry {
readonly priority: number;
/** Built-ins win ties; plugins lose. */
readonly isPlugin: boolean;
/** Used for deterministic tie-break between two plugins. */
readonly id: string;
readonly detector: LocaleDetector;
}
/**
* Stable priority slots for the built-in detectors. Plugins splice in
* between adjacent slots based on their `priority`. The fallback
* detector has no slot — it is always appended last regardless of
* plugin priority, because it unconditionally resolves and would
* short-circuit any later detector if interleaved.
*/
const BUILTIN_PRIORITY = {
querystring: 100,
userPreference: 200,
cookie: 300,
localStorage: 400,
htmlLang: 500,
acceptLanguage: 600,
navigator: 700,
cloudflareIpGeo: 800,
} as const;
/**
* Build the canonical detector chain consumed by `<TranslationProvider>`
* and `useLocaleDetection`.
*
* Default order (no registry, no userPreferenceEndpoint — 8 entries):
*
* 1. `querystring` (100)
* 2. `cookie` (300)
* 3. `localStorage` (400)
* 4. `htmlLang` (500)
* 5. `acceptLanguage` (600)
* 6. `navigator` (700)
* 7. `cloudflareIpGeo` (800)
* 8. `fallback` (always last)
*
* With `userPreferenceEndpoint`, the `userPreference` detector slots
* in at priority 200. With a `registry`, every `DetectorPlugin` it
* has is spliced in by its `priority`; the fallback detector remains
* unconditionally last.
*
* The chain is returned as a fresh array on every call.
*/
export function defaultDetectorChain(
options: DefaultDetectorChainOptions
): LocaleDetector[] {
const entries: ChainEntry[] = [];
entries.push({
priority: BUILTIN_PRIORITY.querystring,
isPlugin: false,
id: "querystring",
detector: querystringDetector(),
});
if (
typeof options.userPreferenceEndpoint === "string" &&
options.userPreferenceEndpoint !== ""
) {
entries.push({
priority: BUILTIN_PRIORITY.userPreference,
isPlugin: false,
id: "userPreference",
detector: userPreferenceDetector({
endpoint: options.userPreferenceEndpoint,
}),
});
}
entries.push({
priority: BUILTIN_PRIORITY.cookie,
isPlugin: false,
id: "cookie",
detector: cookieDetector(),
});
entries.push({
priority: BUILTIN_PRIORITY.localStorage,
isPlugin: false,
id: "localStorage",
detector: localStorageDetector(),
});
entries.push({
priority: BUILTIN_PRIORITY.htmlLang,
isPlugin: false,
id: "htmlLang",
detector: htmlLangDetector(),
});
entries.push({
priority: BUILTIN_PRIORITY.acceptLanguage,
isPlugin: false,
id: "acceptLanguage",
detector: acceptLanguageDetector(),
});
entries.push({
priority: BUILTIN_PRIORITY.navigator,
isPlugin: false,
id: "navigator",
detector: navigatorDetector(),
});
entries.push({
priority: BUILTIN_PRIORITY.cloudflareIpGeo,
isPlugin: false,
id: "cloudflareIpGeo",
detector: cloudflareIpGeoDetector(),
});
if (options.registry) {
for (const plugin of options.registry.listDetectors()) {
entries.push({
priority: plugin.priority,
isPlugin: true,
id: plugin.id,
detector: plugin.detect,
});
}
}
entries.sort(compareEntries);
const detectors = entries.map((e) => e.detector);
detectors.push(fallbackDetector(options.fallback));
return detectors;
}
function compareEntries(a: ChainEntry, b: ChainEntry): number {
if (a.priority !== b.priority) return a.priority - b.priority;
// Same priority: built-in beats plugin. Convert booleans to {0, 1}
// and subtract — `Number(true)` ⇒ 1 sinks plugins below built-ins
// without a ternary that JS sort may only invoke in one direction.
const pluginDelta = Number(a.isPlugin) - Number(b.isPlugin);
if (pluginDelta !== 0) return pluginDelta;
// Same priority + both plugins: lexicographic by id (plugin ids
// are unique within a chain so we never reach the `=== 0` case).
// Two built-ins at the same priority is impossible by construction.
return a.id < b.id ? -1 : 1;
}

View File

@@ -0,0 +1,21 @@
import type { ReactI18nRegistry } from "../../registry/registry.types.js";
/**
* Configuration accepted by `defaultDetectorChain`.
*
* - `fallback` is required because the chain MUST end with the
* unconditional fallback detector.
* - `userPreferenceEndpoint` is optional; when omitted the
* userPreference detector is skipped (8-entry chain instead of 9).
* - `registry` is optional; when supplied, every `DetectorPlugin`
* registered into it is spliced into the chain at its `priority`
* position. Built-in detectors keep their relative order; ties
* between a built-in and a plugin go to the built-in. Ties between
* two plugins are broken lexicographically by `id`. The fallback
* detector is always last regardless of plugin priority.
*/
export interface DefaultDetectorChainOptions {
readonly fallback: string;
readonly userPreferenceEndpoint?: string;
readonly registry?: ReactI18nRegistry;
}

View File

@@ -0,0 +1,2 @@
export { defaultDetectorChain } from "./default-chain.js";
export type { DefaultDetectorChainOptions } from "./default-chain.types.js";

View File

@@ -0,0 +1,67 @@
# fallbackDetector
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`fallbackDetector` is the unconditional terminal entry of every locale
detector chain. It always returns the configured fallback locale, so the
chain runner never resolves to `null` once the fallback detector has
been installed. The constructor rejects empty or whitespace-only
locales up-front so misconfigurations surface at build time rather than
at the first chain invocation.
The fallback detector is non-persisting — the chain runner never
invokes `cache(...)` on it (and the spec scenario "switcher selection
walks every persisting detector" explicitly excludes it from the
walk).
## Features
- Always returns the configured locale (never `null`)
- Constructor validates the input (throws for empty/whitespace/non-string)
- `environment: "both"` — eligible everywhere
- Non-persisting (no `cache` method)
## Usage: Basic Example
```ts
import { fallbackDetector } from "@suaveplan/react-i18n/detector/fallback/fallback";
import { createDetectorChain } from "@suaveplan/i18n/detector";
const chain = createDetectorChain([fallbackDetector("en-GB")]);
```
## Usage: Advanced Example
```ts
import { fallbackDetector } from "@suaveplan/react-i18n/detector/fallback/fallback";
import { defaultDetectorChain } from "@suaveplan/react-i18n/detector/default-chain/default-chain";
// `defaultDetectorChain` already installs the fallback as the last
// entry; for ad-hoc chains assemble the same shape manually.
const chain = [...defaultDetectorChain({ fallback: "en-GB" }).slice(0, -1), fallbackDetector("ja-JP")];
```
## API Reference
### `fallbackDetector(locale: string): LocaleDetector`
Returns a `LocaleDetector` with `name: "fallback"` and
`environment: "both"`. Throws `Error("fallbackDetector requires a
non-empty locale string")` when the supplied locale is empty,
whitespace-only, or not a string.
## Implementation Notes
- The validation runs once, at construction time, so the per-detect
hot path is allocation-free.
- The detector trims the supplied locale so consumers can safely pass
values from configuration files where surrounding whitespace is
common.
- The detector exists as a separate first-class detector (rather than
a hard-coded property on the chain runner) so consumers can build
custom chains that omit the fallback step entirely — useful for
diagnostic UIs that want the chain to surface an explicit
"unresolved" state rather than fall back silently.

View File

@@ -0,0 +1,50 @@
import type { DetectorContext } from "@suaveplan/i18n-detector";
import { describe, expect, it } from "@suaveplan/testing/runner";
import { fallbackDetector } from "./fallback.js";
const SUPPORTED = ["en-GB", "fr-FR"] as const;
const ctx: DetectorContext = {
supportedLocales: SUPPORTED,
fallbackLocale: "en-GB",
};
describe("fallbackDetector", () => {
it("exposes name and environment per spec", { timeout: 5000 }, () => {
const detector = fallbackDetector("en-GB");
expect(detector.name).toBe("fallback");
expect(detector.environment).toBe("both");
});
it("always returns the configured locale", { timeout: 5000 }, () => {
expect(fallbackDetector("en-GB").detect?.(ctx)).toBe("en-GB");
expect(fallbackDetector("fr-FR").detect?.(ctx)).toBe("fr-FR");
});
it("trims whitespace from the configured locale", { timeout: 5000 }, () => {
expect(fallbackDetector(" en-GB ").detect?.(ctx)).toBe("en-GB");
});
it("throws on empty locale", { timeout: 5000 }, () => {
expect(() => fallbackDetector("")).toThrow(
"fallbackDetector requires a non-empty locale string"
);
});
it("throws on whitespace-only locale", { timeout: 5000 }, () => {
expect(() => fallbackDetector(" ")).toThrow();
});
it("throws when locale is not a string", { timeout: 5000 }, () => {
expect(() =>
fallbackDetector(undefined as unknown as string)
).toThrow();
expect(() => fallbackDetector(123 as unknown as string)).toThrow();
});
it("does not implement cache", { timeout: 5000 }, () => {
const detector = fallbackDetector("en-GB");
expect(detector.cache).toBeUndefined();
expect(detector.detectAsync).toBeUndefined();
});
});

View File

@@ -0,0 +1,24 @@
import type { LocaleDetector } from "@suaveplan/i18n-detector";
/**
* Build a `LocaleDetector` that always returns the configured locale.
*
* The fallback detector MUST appear last in any chain — it never
* returns `null`, so any detector after it would never run. The
* constructor validates the supplied locale up-front (rejects
* empty/whitespace strings) so a misconfiguration surfaces at build
* time rather than at the first chain run.
*/
export function fallbackDetector(locale: string): LocaleDetector {
if (typeof locale !== "string" || locale.trim() === "") {
throw new Error("fallbackDetector requires a non-empty locale string");
}
const value = locale.trim();
return {
name: "fallback",
environment: "both",
detect() {
return value;
},
};
}

View File

@@ -0,0 +1 @@
export { fallbackDetector } from "./fallback.js";

View File

@@ -0,0 +1,67 @@
# htmlLangDetector
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`htmlLangDetector` is a `LocaleDetector` that reads the active locale
from the document root's `lang` attribute. The common use case is a
static HTML shell that already declares `<html lang="…">` (typically
rendered by the SSR layer for crawlers and accessibility) — this
detector lets the React surface honour the shell's declaration without
coupling to a specific framework.
The detector is `environment: "browser"` so it never runs in SSR. It
is non-persisting — the chain runner does not invoke a `cache(...)` on
this detector when a switcher selection is made.
## Features
- Reads `document.documentElement.lang`
- Trims surrounding whitespace
- Returns `null` for empty / missing / non-string values
- Honours `ctx.document` over `globalThis.document` when the chain
runner injects a synthetic document
- `environment: "browser"`
## Usage: Basic Example
```ts
import { htmlLangDetector } from "@suaveplan/react-i18n/detector/html-lang/html-lang";
import { createDetectorChain } from "@suaveplan/i18n/detector";
const chain = createDetectorChain([htmlLangDetector()]);
```
## Usage: Advanced Example
```ts
import { htmlLangDetector } from "@suaveplan/react-i18n/detector/html-lang/html-lang";
import { defaultDetectorChain } from "@suaveplan/react-i18n/detector/default-chain/default-chain";
// Replace the default chain's `htmlLang` slot with a no-op for an
// embedded widget that does not control the host page's <html lang>.
const chain = defaultDetectorChain({ fallback: "en-GB" }).filter(
(d) => d.name !== "htmlLang"
);
```
## API Reference
### `htmlLangDetector(): LocaleDetector`
Returns a `LocaleDetector` with `name: "htmlLang"` and
`environment: "browser"`. Only `detect(ctx)` is implemented; the
detector does not persist.
## Implementation Notes
- The detector reads `documentElement.lang` (the `<html>` element)
rather than walking child elements — the convention is that the
document-level lang attribute reflects the active site language,
while child-level `lang` attributes mark exceptions.
- The trim step protects against `<html lang=" en-GB ">` markup
produced by templating engines that pad attribute values.
- An empty or missing `lang` attribute yields `null` so the chain
falls through to the next detector cleanly.

View File

@@ -0,0 +1,117 @@
import type { DetectorContext } from "@suaveplan/i18n-detector";
import { describe, expect, it } from "@suaveplan/testing/runner";
import { htmlLangDetector } from "./html-lang.js";
const SUPPORTED = ["en-GB", "fr-FR", "de-DE"] as const;
function browserContext(): DetectorContext {
return {
supportedLocales: SUPPORTED,
fallbackLocale: "en-GB",
};
}
function withDocumentLang<T>(lang: unknown, fn: () => T): T {
const original = (globalThis as { document?: unknown }).document;
Object.defineProperty(globalThis, "document", {
configurable: true,
value: { documentElement: { lang } },
});
try {
return fn();
} finally {
Object.defineProperty(globalThis, "document", {
configurable: true,
value: original,
});
}
}
describe("htmlLangDetector", () => {
it("exposes name and environment per spec", { timeout: 5000 }, () => {
const detector = htmlLangDetector();
expect(detector.name).toBe("htmlLang");
expect(detector.environment).toBe("browser");
});
it("reads document.documentElement.lang", { timeout: 5000 }, () => {
const detector = htmlLangDetector();
withDocumentLang("fr-FR", () => {
expect(detector.detect?.(browserContext())).toBe("fr-FR");
});
});
it("trims surrounding whitespace", { timeout: 5000 }, () => {
const detector = htmlLangDetector();
withDocumentLang(" de-DE ", () => {
expect(detector.detect?.(browserContext())).toBe("de-DE");
});
});
it("returns null for empty lang attribute", { timeout: 5000 }, () => {
const detector = htmlLangDetector();
withDocumentLang("", () => {
expect(detector.detect?.(browserContext())).toBeNull();
});
});
it("returns null when documentElement is missing", {
timeout: 5000,
}, () => {
const detector = htmlLangDetector();
const original = (globalThis as { document?: unknown }).document;
Object.defineProperty(globalThis, "document", {
configurable: true,
value: { documentElement: null },
});
try {
expect(detector.detect?.(browserContext())).toBeNull();
} finally {
Object.defineProperty(globalThis, "document", {
configurable: true,
value: original,
});
}
});
it("returns null when lang is not a string", { timeout: 5000 }, () => {
const detector = htmlLangDetector();
withDocumentLang(12345, () => {
expect(detector.detect?.(browserContext())).toBeNull();
});
});
it("returns null when no document is available", { timeout: 5000 }, () => {
const detector = htmlLangDetector();
const original = (globalThis as { document?: unknown }).document;
Object.defineProperty(globalThis, "document", {
configurable: true,
value: undefined,
});
try {
expect(detector.detect?.(browserContext())).toBeNull();
} finally {
Object.defineProperty(globalThis, "document", {
configurable: true,
value: original,
});
}
});
it("uses ctx.document over globalThis.document when supplied", {
timeout: 5000,
}, () => {
const detector = htmlLangDetector();
withDocumentLang("en-GB", () => {
const injected = {
documentElement: { lang: "fr-FR" },
} as unknown as Document;
expect(
detector.detect?.({
...browserContext(),
document: injected,
})
).toBe("fr-FR");
});
});
});

View File

@@ -0,0 +1,37 @@
import type { DetectorContext, LocaleDetector } from "@suaveplan/i18n-detector";
/**
* Build a `LocaleDetector` that reads the active locale from
* `document.documentElement.lang`.
*
* The detector is `environment: "browser"` so it never runs in SSR. The
* common use case is a static HTML shell that already declares
* `<html lang="…">` (typically rendered by the SSR layer for crawlers
* and accessibility) — this detector lets the React surface honour the
* shell's declaration without coupling to a specific framework.
*/
export function htmlLangDetector(): LocaleDetector {
return {
name: "htmlLang",
environment: "browser",
detect(ctx) {
const doc = readDocument(ctx);
if (doc === null) return null;
const root = doc.documentElement;
if (!root) return null;
const lang = root.lang;
if (typeof lang !== "string") return null;
const trimmed = lang.trim();
return trimmed === "" ? null : trimmed;
},
};
}
function readDocument(ctx: DetectorContext): Document | null {
if (ctx.document) return ctx.document;
const candidate = (globalThis as { document?: unknown }).document;
if (candidate && typeof candidate === "object") {
return candidate as Document;
}
return null;
}

View File

@@ -0,0 +1 @@
export { htmlLangDetector } from "./html-lang.js";

View File

@@ -0,0 +1,29 @@
export {
acceptLanguageDetector,
parseAcceptLanguage,
} from "./accept-language/accept-language.js";
export type { AcceptLanguageEntry } from "./accept-language/accept-language.types.js";
export { cloudflareIpGeoDetector } from "./cloudflare-ip-geo/cloudflare-ip-geo.js";
export type { CloudflareIpGeoDetectorOptions } from "./cloudflare-ip-geo/cloudflare-ip-geo.types.js";
export { cookieDetector } from "./cookie/cookie.js";
export type {
CookieDetectorOptions,
CookieSameSite,
} from "./cookie/cookie.types.js";
export { defaultDetectorChain } from "./default-chain/default-chain.js";
export type { DefaultDetectorChainOptions } from "./default-chain/default-chain.types.js";
export { fallbackDetector } from "./fallback/fallback.js";
export { htmlLangDetector } from "./html-lang/html-lang.js";
export { localStorageDetector } from "./local-storage/local-storage.js";
export type {
LocalStorageDetectorOptions,
LocalStorageLike,
} from "./local-storage/local-storage.types.js";
export { navigatorDetector } from "./navigator/navigator.js";
export { querystringDetector } from "./querystring/querystring.js";
export type { QuerystringDetectorOptions } from "./querystring/querystring.types.js";
export { userPreferenceDetector } from "./user-preference/user-preference.js";
export type {
UserPreferenceDetectorOptions,
UserPreferenceResponseBody,
} from "./user-preference/user-preference.types.js";

View File

@@ -0,0 +1,5 @@
export { localStorageDetector } from "./local-storage.js";
export type {
LocalStorageDetectorOptions,
LocalStorageLike,
} from "./local-storage.types.js";

View File

@@ -0,0 +1,80 @@
# localStorageDetector
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`localStorageDetector` reads and persists the active locale via a
`Storage`-shaped backing store, defaulting to `globalThis.localStorage`.
It is the second persistence mechanism after `cookieDetector` in the
default chain so that visitors who clear cookies (but not site data) keep
their language preference.
The detector is `environment: "browser"` so the chain runner skips it
during SSR and never accesses `localStorage` from a Node process. Any
access failure (private mode, disabled storage, quota exhaustion) is
caught and surfaced as `null` / a no-op write.
## Features
- Configurable storage key (default `"suaveplan-locale"`)
- Injectable `Storage`-shaped adapter for tests and host integrations
- Defensive `try/catch` around every storage call (private mode safe)
- `environment: "browser"`
## Usage: Basic Example
```ts
import { localStorageDetector } from "@suaveplan/react-i18n/detector/local-storage/local-storage";
import { createDetectorChain } from "@suaveplan/i18n/detector";
const chain = createDetectorChain([localStorageDetector()]);
```
## Usage: Advanced Example
```ts
import { localStorageDetector } from "@suaveplan/react-i18n/detector/local-storage/local-storage";
// Use sessionStorage instead of localStorage so the locale resets when
// the tab closes — useful for shared kiosk-style devices.
const detector = localStorageDetector({
key: "kiosk-locale",
storage: globalThis.sessionStorage,
});
```
## API Reference
### `localStorageDetector(options?: LocalStorageDetectorOptions): LocaleDetector`
Returns a `LocaleDetector` with `name: "localStorage"` and
`environment: "browser"`. Both `detect` and `cache` are implemented.
### `LocalStorageDetectorOptions`
| Field | Type | Default | Description |
| ------- | ------------------ | ---------------------- | ---------------------------------------- |
| key | `string` | `"suaveplan-locale"` | Storage key the detector reads/writes |
| storage | `LocalStorageLike` | `globalThis.localStorage` | Optional injected storage adapter |
### `LocalStorageLike`
```ts
interface LocalStorageLike {
getItem(key: string): string | null;
setItem(key: string, value: string): void;
removeItem(key: string): void;
}
```
## Implementation Notes
- The `globalThis.localStorage` access is wrapped in a try/catch
because a hardened browser may throw on the property _getter_ itself
(Safari private mode used to do this).
- The detector treats an empty stored value the same as a missing key
— both yield `null` so the chain advances.
- `cache(...)` swallows `setItem` failures so quota-exhausted storage
cannot break a language switch from the React tree's perspective.

View File

@@ -0,0 +1,169 @@
import type { DetectorContext } from "@suaveplan/i18n-detector";
import { describe, expect, it } from "@suaveplan/testing/runner";
import { localStorageDetector } from "./local-storage.js";
import type { LocalStorageLike } from "./local-storage.types.js";
const SUPPORTED = ["en-GB", "fr-FR", "de-DE"] as const;
function browserContext(): DetectorContext {
return {
supportedLocales: SUPPORTED,
fallbackLocale: "en-GB",
};
}
function makeStorage(initial: Record<string, string> = {}): LocalStorageLike {
const store = new Map<string, string>(Object.entries(initial));
return {
getItem: (k) => (store.has(k) ? (store.get(k) as string) : null),
setItem: (k, v) => {
store.set(k, v);
},
removeItem: (k) => {
store.delete(k);
},
};
}
function withGlobalLocalStorage<T>(storage: unknown, fn: () => T): T {
const original = (globalThis as { localStorage?: unknown }).localStorage;
Object.defineProperty(globalThis, "localStorage", {
configurable: true,
value: storage,
});
try {
return fn();
} finally {
Object.defineProperty(globalThis, "localStorage", {
configurable: true,
value: original,
});
}
}
describe("localStorageDetector", () => {
it("exposes name and environment per spec", { timeout: 5000 }, () => {
const detector = localStorageDetector();
expect(detector.name).toBe("localStorage");
expect(detector.environment).toBe("browser");
});
it("reads value from injected storage with default key", {
timeout: 5000,
}, () => {
const detector = localStorageDetector({
storage: makeStorage({ "suaveplan-locale": "fr-FR" }),
});
expect(detector.detect?.(browserContext())).toBe("fr-FR");
});
it("honours custom key", { timeout: 5000 }, () => {
const detector = localStorageDetector({
key: "lang",
storage: makeStorage({ lang: "de-DE" }),
});
expect(detector.detect?.(browserContext())).toBe("de-DE");
});
it("returns null when stored value is empty", { timeout: 5000 }, () => {
const detector = localStorageDetector({
storage: makeStorage({ "suaveplan-locale": "" }),
});
expect(detector.detect?.(browserContext())).toBeNull();
});
it("returns null when key is missing", { timeout: 5000 }, () => {
const detector = localStorageDetector({
storage: makeStorage({ other: "x" }),
});
expect(detector.detect?.(browserContext())).toBeNull();
});
it("returns null when storage.getItem throws", { timeout: 5000 }, () => {
const throwing: LocalStorageLike = {
getItem: () => {
throw new Error("blocked");
},
setItem: () => undefined,
removeItem: () => undefined,
};
const detector = localStorageDetector({ storage: throwing });
expect(detector.detect?.(browserContext())).toBeNull();
});
it("returns null when no storage is available", { timeout: 5000 }, () => {
withGlobalLocalStorage(undefined, () => {
const detector = localStorageDetector();
expect(detector.detect?.(browserContext())).toBeNull();
});
});
it("returns null when global access throws", { timeout: 5000 }, () => {
const original = (globalThis as { localStorage?: unknown })
.localStorage;
Object.defineProperty(globalThis, "localStorage", {
configurable: true,
get: () => {
throw new Error("denied");
},
});
try {
const detector = localStorageDetector();
expect(detector.detect?.(browserContext())).toBeNull();
} finally {
Object.defineProperty(globalThis, "localStorage", {
configurable: true,
value: original,
});
}
});
it("falls back to globalThis.localStorage when no storage supplied", {
timeout: 5000,
}, () => {
withGlobalLocalStorage(
makeStorage({ "suaveplan-locale": "de-DE" }),
() => {
const detector = localStorageDetector();
expect(detector.detect?.(browserContext())).toBe("de-DE");
}
);
});
it("ignores non-storage-like global candidate", { timeout: 5000 }, () => {
withGlobalLocalStorage({ getItem: 1, setItem: 2 }, () => {
const detector = localStorageDetector();
expect(detector.detect?.(browserContext())).toBeNull();
});
});
it("persists value via cache", { timeout: 5000 }, () => {
const storage = makeStorage();
const detector = localStorageDetector({ storage });
detector.cache?.("fr-FR", browserContext());
expect(storage.getItem("suaveplan-locale")).toBe("fr-FR");
});
it("cache is a no-op when storage is unavailable", {
timeout: 5000,
}, () => {
withGlobalLocalStorage(undefined, () => {
const detector = localStorageDetector();
expect(() =>
detector.cache?.("fr-FR", browserContext())
).not.toThrow();
});
});
it("cache swallows setItem failures", { timeout: 5000 }, () => {
const throwing: LocalStorageLike = {
getItem: () => null,
setItem: () => {
throw new Error("quota");
},
removeItem: () => undefined,
};
const detector = localStorageDetector({ storage: throwing });
expect(() => detector.cache?.("ar-AE", browserContext())).not.toThrow();
});
});

View File

@@ -0,0 +1,69 @@
import type { LocaleDetector } from "@suaveplan/i18n-detector";
import type {
LocalStorageDetectorOptions,
LocalStorageLike,
} from "./local-storage.types.js";
const DEFAULT_KEY = "suaveplan-locale";
/**
* Build a `LocaleDetector` that reads/writes the active locale from a
* `Storage`-shaped backing store (default: `globalThis.localStorage`).
*
* The detector is `environment: "browser"` so the chain runner
* automatically skips it during SSR. Any access failure (private mode,
* disabled storage, exceeded quota) is caught and surfaced as `null` /
* a silent no-op write — the detector never throws into the React tree.
*/
export function localStorageDetector(
options: LocalStorageDetectorOptions = {}
): LocaleDetector {
const key = options.key ?? DEFAULT_KEY;
const explicitStorage = options.storage;
return {
name: "localStorage",
environment: "browser",
detect() {
const store = resolveStorage(explicitStorage);
if (store === null) return null;
try {
const value = store.getItem(key);
if (typeof value !== "string" || value === "") return null;
return value;
} catch {
return null;
}
},
cache(locale) {
const store = resolveStorage(explicitStorage);
if (store === null) return;
try {
store.setItem(key, locale);
} catch {
// Persistence is best-effort — quota exhaustion or
// private-mode write rejection should not propagate.
}
},
};
}
function resolveStorage(
explicit: LocalStorageLike | undefined
): LocalStorageLike | null {
if (explicit) return explicit;
try {
const candidate = (globalThis as { localStorage?: unknown })
.localStorage;
if (
candidate &&
typeof (candidate as LocalStorageLike).getItem === "function" &&
typeof (candidate as LocalStorageLike).setItem === "function"
) {
return candidate as LocalStorageLike;
}
return null;
} catch {
return null;
}
}

View File

@@ -0,0 +1,26 @@
/**
* Storage shape required by `localStorageDetector`. Mirrors the relevant
* subset of the DOM `Storage` interface so any conforming object (an
* in-memory test fake, a session-storage handle, …) is acceptable.
*/
export interface LocalStorageLike {
getItem(key: string): string | null;
setItem(key: string, value: string): void;
removeItem(key: string): void;
}
/**
* Configuration accepted by `localStorageDetector`. Both fields are
* optional; defaults pull `localStorage` from `globalThis` and use the
* stable `"suaveplan-locale"` storage key.
*/
export interface LocalStorageDetectorOptions {
/** Storage key under which the locale is read/written. */
readonly key?: string;
/**
* Storage adapter. Defaults to `globalThis.localStorage`. Tests
* inject a `Map`-backed fake to exercise the persisting path
* without booting an actual `localStorage` polyfill.
*/
readonly storage?: LocalStorageLike;
}

View File

@@ -0,0 +1 @@
export { navigatorDetector } from "./navigator.js";

View File

@@ -0,0 +1,72 @@
# navigatorDetector
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`navigatorDetector` is a browser-only `LocaleDetector` that reads the
active locale from `navigator.languages` (preferred) or
`navigator.language` (fallback). It is the canonical browser-side
detector when no per-user preference, cookie, or local-storage value
exists yet — every modern browser populates these globals from the OS
language settings.
The detector is non-persisting: the chain runner does not invoke
`cache(...)` on it. Returning `null` (no navigator, empty languages)
lets the chain advance to the next detector.
## Features
- Iterates `navigator.languages` in order, returning the first
non-empty entry
- Falls back to `navigator.language` when `languages` is missing /
empty
- Returns `null` when no navigator global is present (defensive)
- `environment: "browser"`
## Usage: Basic Example
```ts
import { navigatorDetector } from "@suaveplan/react-i18n/detector/navigator/navigator";
import { createDetectorChain } from "@suaveplan/i18n/detector";
const chain = createDetectorChain([navigatorDetector()]);
```
## Usage: Advanced Example
```ts
import { navigatorDetector } from "@suaveplan/react-i18n/detector/navigator/navigator";
import { defaultDetectorChain } from "@suaveplan/react-i18n/detector/default-chain/default-chain";
// Use the navigator detector as the *only* browser-side fallback by
// stripping every other browser detector from the default chain.
const chain = defaultDetectorChain({ fallback: "en-GB" }).filter(
(d) =>
d.environment === "server" ||
d.environment === "both" ||
d.name === "navigator" ||
d.name === "fallback"
);
```
## API Reference
### `navigatorDetector(): LocaleDetector`
Returns a `LocaleDetector` with `name: "navigator"` and
`environment: "browser"`. Only `detect(ctx)` is implemented.
## Implementation Notes
- The detector trims candidate values before returning so a stray
whitespace-only entry in `navigator.languages` does not falsely
win.
- The `globalThis.navigator` access is structural — any object with
`languages: string[]` and/or `language: string` satisfies the
detector. This makes integration with non-DOM hosts (Node REPL,
Bun) straightforward via `globalThis.navigator = { language: "en" }`.
- The detector returns the _raw_ value (e.g. `"fr-FR"`) and lets the
chain runner negotiate against `ctx.supportedLocales` for BCP-47
fallback.

View File

@@ -0,0 +1,100 @@
import type { DetectorContext } from "@suaveplan/i18n-detector";
import { describe, expect, it } from "@suaveplan/testing/runner";
import { navigatorDetector } from "./navigator.js";
const SUPPORTED = ["en-GB", "fr-FR", "de-DE"] as const;
function browserContext(): DetectorContext {
return {
supportedLocales: SUPPORTED,
fallbackLocale: "en-GB",
};
}
function withNavigator<T>(value: unknown, fn: () => T): T {
const original = (globalThis as { navigator?: unknown }).navigator;
Object.defineProperty(globalThis, "navigator", {
configurable: true,
value,
});
try {
return fn();
} finally {
Object.defineProperty(globalThis, "navigator", {
configurable: true,
value: original,
});
}
}
describe("navigatorDetector", () => {
it("exposes name and environment per spec", { timeout: 5000 }, () => {
const detector = navigatorDetector();
expect(detector.name).toBe("navigator");
expect(detector.environment).toBe("browser");
});
it("returns first navigator.languages entry", { timeout: 5000 }, () => {
const detector = navigatorDetector();
withNavigator({ languages: ["fr-FR", "en-GB"] }, () => {
expect(detector.detect?.(browserContext())).toBe("fr-FR");
});
});
it("falls back to navigator.language when languages is empty", {
timeout: 5000,
}, () => {
const detector = navigatorDetector();
withNavigator({ languages: [], language: "de-DE" }, () => {
expect(detector.detect?.(browserContext())).toBe("de-DE");
});
});
it("falls back to navigator.language when languages is missing", {
timeout: 5000,
}, () => {
const detector = navigatorDetector();
withNavigator({ language: "fr-FR" }, () => {
expect(detector.detect?.(browserContext())).toBe("fr-FR");
});
});
it("skips empty entries in navigator.languages", { timeout: 5000 }, () => {
const detector = navigatorDetector();
withNavigator({ languages: ["", " ", "fr-FR"] }, () => {
expect(detector.detect?.(browserContext())).toBe("fr-FR");
});
});
it("returns null when both sources are empty", { timeout: 5000 }, () => {
const detector = navigatorDetector();
withNavigator({ languages: [], language: "" }, () => {
expect(detector.detect?.(browserContext())).toBeNull();
});
});
it("returns null when navigator absent", { timeout: 5000 }, () => {
const detector = navigatorDetector();
withNavigator(undefined, () => {
expect(detector.detect?.(browserContext())).toBeNull();
});
});
it("returns null when navigator is not an object", {
timeout: 5000,
}, () => {
const detector = navigatorDetector();
withNavigator("not-an-object", () => {
expect(detector.detect?.(browserContext())).toBeNull();
});
});
it("returns null when languages contains only whitespace and language is undefined", {
timeout: 5000,
}, () => {
const detector = navigatorDetector();
withNavigator({ languages: [" "] }, () => {
expect(detector.detect?.(browserContext())).toBeNull();
});
});
});

View File

@@ -0,0 +1,50 @@
import type { LocaleDetector } from "@suaveplan/i18n-detector";
/**
* Build a `LocaleDetector` that reads the active locale from the
* browser's `navigator.languages` (preferred) or `navigator.language`
* (fallback). Returns the first non-empty entry.
*
* The chain runner negotiates the returned value against
* `ctx.supportedLocales` so this detector contributes raw browser
* preference and lets the resolver perform BCP-47 fallback.
*/
export function navigatorDetector(): LocaleDetector {
return {
name: "navigator",
environment: "browser",
detect() {
const nav = readNavigator();
if (nav === null) return null;
const candidates = collectLanguages(nav);
for (const candidate of candidates) {
const value = candidate.trim();
if (value !== "") return value;
}
return null;
},
};
}
interface NavigatorLanguageLike {
readonly languages?: ReadonlyArray<string>;
readonly language?: string;
}
function readNavigator(): NavigatorLanguageLike | null {
const candidate = (globalThis as { navigator?: unknown }).navigator;
if (candidate && typeof candidate === "object") {
return candidate as NavigatorLanguageLike;
}
return null;
}
function collectLanguages(nav: NavigatorLanguageLike): ReadonlyArray<string> {
if (Array.isArray(nav.languages) && nav.languages.length > 0) {
return nav.languages;
}
if (typeof nav.language === "string" && nav.language !== "") {
return [nav.language];
}
return [];
}

View File

@@ -0,0 +1,2 @@
export { querystringDetector } from "./querystring.js";
export type { QuerystringDetectorOptions } from "./querystring.types.js";

View File

@@ -0,0 +1,88 @@
# querystringDetector
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`querystringDetector` is a `LocaleDetector` that reads the active locale
from a URL search parameter. It runs in both browser and server
environments, making it the canonical first link in the default detector
chain — explicit user intent expressed through `?lang=fr-FR` always
overrides every other source (cookie, localStorage, geo lookup, …) per
the spec scenario "querystring overrides cookie".
The detector is intentionally minimal: it never persists a value (so
sharing a `?lang=…` link does not pollute the user's stored preference),
it does not throw when the param is absent, and it never reaches into
window-level globals when a server-side `request` context is supplied.
## Features
- Configurable parameter name via `opts.paramName` (default `"lang"`)
- Server input via `ctx.request.url.searchParams`
- Browser input via `window.location.search`
- Returns `null` for missing params, empty values, or environments
where neither input source exists
- `environment: "both"` — eligible on every chain run
## Usage: Basic Example
```ts
import { querystringDetector } from "@suaveplan/react-i18n/detector/querystring/querystring";
import { createDetectorChain } from "@suaveplan/i18n/detector";
const chain = createDetectorChain([querystringDetector()]);
const result = await chain.detect({
request: {
headers: new Headers(),
url: new URL("https://example.com/?lang=fr-FR"),
},
responseHeaders: new Headers(),
supportedLocales: ["en-GB", "fr-FR"],
fallbackLocale: "en-GB",
});
// → { locale: "fr-FR", detector: "querystring", rawValue: "fr-FR" }
```
## Usage: Advanced Example
```ts
import { querystringDetector } from "@suaveplan/react-i18n/detector/querystring/querystring";
import { defaultDetectorChain } from "@suaveplan/react-i18n/detector/default-chain/default-chain";
// Replace the default `?lang=` entry with `?locale=` for a host app
// that already uses that parameter, while keeping every other detector.
const chain = defaultDetectorChain({ fallback: "en-GB" }).map((d) =>
d.name === "querystring" ? querystringDetector({ paramName: "locale" }) : d
);
```
## API Reference
### `querystringDetector(options?: QuerystringDetectorOptions): LocaleDetector`
| Param | Type | Default | Description |
| ------- | ----------------------------- | ------- | -------------------------------------------- |
| options | `QuerystringDetectorOptions?` | `{}` | Optional `{ paramName?: string }` overrides. |
Returns a `LocaleDetector` with `name: "querystring"` and
`environment: "both"`. The detector exposes only `detect(ctx)`; it does
not implement `cache(...)` (the URL is never written).
### `QuerystringDetectorOptions`
| Field | Type | Default | Description |
| --------- | -------- | -------- | ---------------------------------------- |
| paramName | `string` | `"lang"` | Search-parameter name the detector reads |
## Implementation Notes
- The detector strictly prefers `ctx.request` over `window.location` so
SSR contexts that happen to leak a `globalThis.location` (e.g. test
harnesses) do not override the request the application is rendering.
- An empty string value (e.g. `?lang=`) is treated as "no preference"
per the chain contract — the detector falls through, never wins.
- The implementation is allocation-light (one `URLSearchParams` on the
browser path) and synchronous so the chain runner picks it up in the
initial sync pass before any async detector is dispatched.

View File

@@ -0,0 +1,138 @@
import type { DetectorContext } from "@suaveplan/i18n-detector";
import { describe, expect, it } from "@suaveplan/testing/runner";
import { querystringDetector } from "./querystring.js";
const SUPPORTED = ["en-GB", "fr-FR", "cy-GB"] as const;
function serverContext(url: string): DetectorContext {
return {
request: { headers: new Headers(), url: new URL(url) },
responseHeaders: new Headers(),
supportedLocales: SUPPORTED,
fallbackLocale: "en-GB",
};
}
function browserContext(): DetectorContext {
return {
supportedLocales: SUPPORTED,
fallbackLocale: "en-GB",
};
}
function withWindowLocation<T>(search: string, fn: () => T): T {
const original = (globalThis as { location?: { search?: string } })
.location;
Object.defineProperty(globalThis, "location", {
configurable: true,
value: { search },
});
try {
return fn();
} finally {
Object.defineProperty(globalThis, "location", {
configurable: true,
value: original,
});
}
}
describe("querystringDetector", () => {
it("reads ?lang from server-side ctx.request.url with default param", {
timeout: 5000,
}, () => {
const detector = querystringDetector();
const result = detector.detect?.(
serverContext("https://example.com/?lang=cy-GB")
);
expect(result).toBe("cy-GB");
expect(detector.name).toBe("querystring");
expect(detector.environment).toBe("both");
});
it("honours opts.paramName override", { timeout: 5000 }, () => {
const detector = querystringDetector({ paramName: "locale" });
const result = detector.detect?.(
serverContext("https://example.com/?locale=fr-FR")
);
expect(result).toBe("fr-FR");
});
it("returns null when neither request nor window has the param", {
timeout: 5000,
}, () => {
const detector = querystringDetector();
const result = detector.detect?.(serverContext("https://example.com/"));
expect(result).toBeNull();
});
it("reads from window.location.search in the browser", {
timeout: 5000,
}, () => {
const detector = querystringDetector();
withWindowLocation("?lang=fr-FR", () => {
const result = detector.detect?.(browserContext());
expect(result).toBe("fr-FR");
});
});
it("returns null when window has the param but value is empty", {
timeout: 5000,
}, () => {
const detector = querystringDetector();
withWindowLocation("?lang=", () => {
const result = detector.detect?.(browserContext());
expect(result).toBeNull();
});
});
it("returns null when window.location is missing entirely", {
timeout: 5000,
}, () => {
const detector = querystringDetector();
const original = (globalThis as { location?: unknown }).location;
Object.defineProperty(globalThis, "location", {
configurable: true,
value: undefined,
});
try {
const result = detector.detect?.(browserContext());
expect(result).toBeNull();
} finally {
Object.defineProperty(globalThis, "location", {
configurable: true,
value: original,
});
}
});
it("returns null when ctx.request.url has empty param value", {
timeout: 5000,
}, () => {
const detector = querystringDetector();
const result = detector.detect?.(
serverContext("https://example.com/?lang=")
);
expect(result).toBeNull();
});
it("returns null when window.location.search is not a string", {
timeout: 5000,
}, () => {
const detector = querystringDetector();
const original = (globalThis as { location?: unknown }).location;
Object.defineProperty(globalThis, "location", {
configurable: true,
value: { search: 1234 as unknown as string },
});
try {
const result = detector.detect?.(browserContext());
expect(result).toBeNull();
} finally {
Object.defineProperty(globalThis, "location", {
configurable: true,
value: original,
});
}
});
});

View File

@@ -0,0 +1,42 @@
import type { LocaleDetector } from "@suaveplan/i18n-detector";
import type { QuerystringDetectorOptions } from "./querystring.types.js";
const DEFAULT_PARAM_NAME = "lang";
/**
* Build a `LocaleDetector` that reads the active locale from a URL search
* parameter (default `?lang=...`).
*
* Runtime resolution:
* - When `ctx.request.url` is provided (server / SSR) the detector reads
* `ctx.request.url.searchParams.get(paramName)`.
* - Otherwise the detector reads `window.location.search` via a fresh
* `URLSearchParams` instance.
*
* Returns `null` for missing params, empty values, or environments where
* neither input source is available (Node without an injected request).
*/
export function querystringDetector(
options: QuerystringDetectorOptions = {}
): LocaleDetector {
const paramName = options.paramName ?? DEFAULT_PARAM_NAME;
return {
name: "querystring",
environment: "both",
detect(ctx) {
const fromRequest = ctx.request?.url.searchParams.get(paramName);
if (typeof fromRequest === "string" && fromRequest !== "") {
return fromRequest;
}
const win = (globalThis as { location?: { search?: string } })
.location;
if (!win || typeof win.search !== "string") return null;
const params = new URLSearchParams(win.search);
const fromWindow = params.get(paramName);
if (typeof fromWindow === "string" && fromWindow !== "") {
return fromWindow;
}
return null;
},
};
}

View File

@@ -0,0 +1,13 @@
/**
* Configuration accepted by `querystringDetector`. Every field is
* optional; sensible defaults are documented on each property.
*/
export interface QuerystringDetectorOptions {
/**
* Name of the URL search parameter the detector reads. Defaults to
* `"lang"`. Spec scenario "querystring overrides cookie" exercises
* the default; consumers SHOULD only override when integrating
* with a host application that already uses a different name.
*/
readonly paramName?: string;
}

View File

@@ -0,0 +1,5 @@
export { userPreferenceDetector } from "./user-preference.js";
export type {
UserPreferenceDetectorOptions,
UserPreferenceResponseBody,
} from "./user-preference.types.js";

View File

@@ -0,0 +1,98 @@
# userPreferenceDetector
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`userPreferenceDetector` is the only async, persisting detector in the
default chain. It calls a backend endpoint to read the user's stored
locale preference (GET) and writes a chosen locale back to that same
endpoint (POST) on `cache(...)`. This is the detector that ensures a
locale picked via `<LanguageSwitcher>` survives across devices, sessions,
and even cookie deletion as long as the session itself is still
authenticated.
The detector is environment `'both'` so it runs server-side during SSR
(threading the inbound session cookie via `extractAuth`) and browser-side
on the client. Network failures are caught and logged at warn level via
`@suaveplan/logger`; the detector returns `null` so the chain advances.
No exception ever escapes into the React tree.
## Features
- GET-fetched read on `detectAsync`
- POST-fetched write on `cache(locale, ctx)`
- Required `endpoint`; optional `extractAuth(ctx)`, `cacheKey(ctx)`,
injectable `fetch`
- Server-side auth threading via `extractAuth(ctx)` — typically used to
forward the inbound `cookie` header onto the upstream call
- Best-effort cache writes (a failed POST does not throw)
- Warn-level structured logs on every failure path
## Usage: Basic Example
```ts
import { userPreferenceDetector } from "@suaveplan/react-i18n/detector/user-preference/user-preference";
import { createDetectorChain } from "@suaveplan/i18n/detector";
const chain = createDetectorChain([
userPreferenceDetector({ endpoint: "/api/me/locale" }),
]);
```
## Usage: Advanced Example
```ts
import { userPreferenceDetector } from "@suaveplan/react-i18n/detector/user-preference/user-preference";
// SSR usage — thread the inbound session cookie onto the upstream call
// so the backend endpoint can read the same authenticated session.
export const detector = userPreferenceDetector({
endpoint: "https://api.example.com/me/locale",
extractAuth: (ctx) => ({
cookie: ctx.request?.headers.get("cookie") ?? "",
}),
cacheKey: (ctx) => ctx.request?.headers.get("authorization") ?? "anon",
});
```
## API Reference
### `userPreferenceDetector(options: UserPreferenceDetectorOptions): LocaleDetector`
Returns a `LocaleDetector` with `name: "userPreference"` and
`environment: "both"` exposing both `detectAsync(ctx)` and
`cache(locale, ctx)`.
### `UserPreferenceDetectorOptions`
| Field | Type | Default | Description |
| ----------- | --------------------------------------------- | ------------------------ | ---------------------------------------------------- |
| endpoint | `string` (required) | — | URL fetched on detect (GET) and cache (POST) |
| extractAuth | `(ctx) => HeadersInit` | `() => ({})` | Returns headers threaded into both calls |
| cacheKey | `(ctx) => string` | — | Stable key consumers may use to dedupe across renders |
| fetch | `typeof fetch` | `globalThis.fetch` | Injectable fetch implementation |
### `UserPreferenceResponseBody`
```ts
interface UserPreferenceResponseBody {
readonly locale: string;
}
```
## Implementation Notes
- `cache(...)` is best-effort by design: a backend that is briefly
unavailable should not block a user's locale switch. A subsequent
detect call will surface the persisted value once the backend
recovers.
- `Content-Type: application/json` is set automatically on POST unless
the caller-supplied `extractAuth` returns its own `content-type`
header — this lets advanced consumers swap the wire format if their
endpoint uses a different shape.
- `signal` is forwarded from the `DetectorContext` so the detector
participates in the chain runner's cancellation semantics.
- The detector never throws — every error path is caught, logged, and
surfaced as `null` so the chain advances cleanly.

View File

@@ -0,0 +1,290 @@
import type { DetectorContext } from "@suaveplan/i18n-detector";
import { describe, expect, it } from "@suaveplan/testing/runner";
import { userPreferenceDetector } from "./user-preference.js";
const SUPPORTED = ["en-GB", "fr-FR", "de-DE", "ar-AE"] as const;
function serverContext(headers: HeadersInit = {}): DetectorContext {
return {
request: {
headers: new Headers(headers),
url: new URL("https://example.com/"),
},
responseHeaders: new Headers(),
supportedLocales: SUPPORTED,
fallbackLocale: "en-GB",
};
}
function browserContext(): DetectorContext {
return {
supportedLocales: SUPPORTED,
fallbackLocale: "en-GB",
};
}
interface FetchCall {
readonly url: string;
readonly init: RequestInit | undefined;
}
function recordingFetch(response: () => Response | Promise<Response>): {
fn: typeof fetch;
calls: FetchCall[];
} {
const calls: FetchCall[] = [];
const fn = (async (input: RequestInfo | URL, init?: RequestInit) => {
const url = typeof input === "string" ? input : input.toString();
calls.push({ url, init });
return await response();
}) as unknown as typeof fetch;
return { fn, calls };
}
describe("userPreferenceDetector", () => {
it("detect threads SSR auth via extractAuth(ctx) into the request headers", {
timeout: 5000,
}, async () => {
const { fn, calls } = recordingFetch(
() => new Response(JSON.stringify({ locale: "fr-FR" }))
);
const detector = userPreferenceDetector({
endpoint: "/api/me/locale",
extractAuth: (c) => ({
cookie: c.request?.headers.get("cookie") ?? "",
}),
fetch: fn,
});
const result = await detector.detectAsync?.(
serverContext({ cookie: "session=abc" })
);
expect(result).toBe("fr-FR");
expect(calls.length).toBe(1);
expect(calls[0]?.url).toBe("/api/me/locale");
expect(calls[0]?.init?.method).toBe("GET");
const headers = new Headers(calls[0]?.init?.headers);
expect(headers.get("cookie")).toBe("session=abc");
});
it("detect returns null on non-2xx response", {
timeout: 5000,
}, async () => {
const { fn } = recordingFetch(
() => new Response("nope", { status: 503 })
);
const detector = userPreferenceDetector({
endpoint: "/api/me/locale",
fetch: fn,
});
const result = await detector.detectAsync?.(browserContext());
expect(result).toBeNull();
});
it("detect returns null when fetch rejects", {
timeout: 5000,
}, async () => {
const fn = (async () => {
throw new Error("boom");
}) as unknown as typeof fetch;
const detector = userPreferenceDetector({
endpoint: "/api/me/locale",
fetch: fn,
});
const result = await detector.detectAsync?.(browserContext());
expect(result).toBeNull();
});
it("detect returns null when fetch rejects with a non-Error", {
timeout: 5000,
}, async () => {
const fn = (async () => {
const literal: unknown = "string-error";
throw literal;
}) as unknown as typeof fetch;
const detector = userPreferenceDetector({
endpoint: "/api/me/locale",
fetch: fn,
});
const result = await detector.detectAsync?.(browserContext());
expect(result).toBeNull();
});
it("detect returns null when body lacks a locale field", {
timeout: 5000,
}, async () => {
const { fn } = recordingFetch(() => new Response("{}"));
const detector = userPreferenceDetector({
endpoint: "/api/me/locale",
fetch: fn,
});
const result = await detector.detectAsync?.(browserContext());
expect(result).toBeNull();
});
it("detect returns null when body locale is empty string", {
timeout: 5000,
}, async () => {
const { fn } = recordingFetch(
() => new Response(JSON.stringify({ locale: "" }))
);
const detector = userPreferenceDetector({
endpoint: "/api/me/locale",
fetch: fn,
});
const result = await detector.detectAsync?.(browserContext());
expect(result).toBeNull();
});
it("cache POSTs JSON body to the endpoint with content-type header", {
timeout: 5000,
}, async () => {
const { fn, calls } = recordingFetch(
() => new Response("{}", { status: 204 })
);
const detector = userPreferenceDetector({
endpoint: "/api/me/locale",
fetch: fn,
});
await detector.cache?.("de-DE", browserContext());
expect(calls.length).toBe(1);
expect(calls[0]?.init?.method).toBe("POST");
const headers = new Headers(calls[0]?.init?.headers);
expect(headers.get("content-type")).toBe("application/json");
expect(calls[0]?.init?.body).toBe(JSON.stringify({ locale: "de-DE" }));
});
it("cache leaves non-2xx without throwing", { timeout: 5000 }, async () => {
const { fn } = recordingFetch(
() => new Response("err", { status: 500 })
);
const detector = userPreferenceDetector({
endpoint: "/api/me/locale",
fetch: fn,
});
await expect(
detector.cache?.("ar-AE", browserContext())
).resolves.toBeUndefined();
});
it("cache swallows fetch rejections", { timeout: 5000 }, async () => {
const fn = (async () => {
throw new Error("network down");
}) as unknown as typeof fetch;
const detector = userPreferenceDetector({
endpoint: "/api/me/locale",
fetch: fn,
});
await expect(
detector.cache?.("ar-AE", browserContext())
).resolves.toBeUndefined();
});
it("cache swallows non-Error rejections", { timeout: 5000 }, async () => {
const fn = (async () => {
const literal: unknown = "literal-failure";
throw literal;
}) as unknown as typeof fetch;
const detector = userPreferenceDetector({
endpoint: "/api/me/locale",
fetch: fn,
});
await expect(
detector.cache?.("ar-AE", browserContext())
).resolves.toBeUndefined();
});
it("cache preserves caller-supplied content-type when present", {
timeout: 5000,
}, async () => {
const { fn, calls } = recordingFetch(() => new Response(""));
const detector = userPreferenceDetector({
endpoint: "/api/me/locale",
extractAuth: () => ({ "content-type": "application/x-custom" }),
fetch: fn,
});
await detector.cache?.("de-DE", browserContext());
const headers = new Headers(calls[0]?.init?.headers);
expect(headers.get("content-type")).toBe("application/x-custom");
});
it("default fetch resolves to globalThis.fetch when not injected", {
timeout: 5000,
}, async () => {
const original = globalThis.fetch;
const calls: string[] = [];
globalThis.fetch = (async (input: RequestInfo | URL) => {
calls.push(typeof input === "string" ? input : input.toString());
return new Response(JSON.stringify({ locale: "ar-AE" }));
}) as typeof fetch;
try {
const detector = userPreferenceDetector({
endpoint: "/api/me/locale",
});
const result = await detector.detectAsync?.(browserContext());
expect(result).toBe("ar-AE");
expect(calls).toEqual(["/api/me/locale"]);
} finally {
globalThis.fetch = original;
}
});
it("exposes name and environment per spec", { timeout: 5000 }, () => {
const detector = userPreferenceDetector({ endpoint: "/x" });
expect(detector.name).toBe("userPreference");
expect(detector.environment).toBe("both");
});
it("detect threads ctx.signal into the request init", {
timeout: 5000,
}, async () => {
const calls: RequestInit[] = [];
const fn = (async (_input: RequestInfo | URL, init?: RequestInit) => {
calls.push(init ?? {});
return new Response(JSON.stringify({ locale: "en-GB" }));
}) as unknown as typeof fetch;
const detector = userPreferenceDetector({
endpoint: "/api/me/locale",
fetch: fn,
});
const controller = new AbortController();
await detector.detectAsync?.({
...browserContext(),
signal: controller.signal,
});
expect(calls[0]?.signal).toBe(controller.signal);
});
it("cache threads ctx.signal into the POST request init", {
timeout: 5000,
}, async () => {
const calls: RequestInit[] = [];
const fn = (async (_input: RequestInfo | URL, init?: RequestInit) => {
calls.push(init ?? {});
return new Response("", { status: 204 });
}) as unknown as typeof fetch;
const detector = userPreferenceDetector({
endpoint: "/api/me/locale",
fetch: fn,
});
const controller = new AbortController();
await detector.cache?.("fr-FR", {
...browserContext(),
signal: controller.signal,
});
expect(calls[0]?.signal).toBe(controller.signal);
});
it("extractAuth returning empty object still issues request", {
timeout: 5000,
}, async () => {
const { fn, calls } = recordingFetch(
() => new Response(JSON.stringify({ locale: "de-DE" }))
);
const detector = userPreferenceDetector({
endpoint: "/api/me/locale",
fetch: fn,
});
await detector.detectAsync?.(browserContext());
expect(calls.length).toBe(1);
});
});

View File

@@ -0,0 +1,105 @@
import type { DetectorContext, LocaleDetector } from "@suaveplan/i18n-detector";
import { ConsoleLogger } from "@suaveplan/logger";
import type {
UserPreferenceDetectorOptions,
UserPreferenceResponseBody,
} from "./user-preference.types.js";
const logger = new ConsoleLogger({ tags: ["react-i18n.detector"] });
/**
* Build a `LocaleDetector` that calls a backend endpoint to read (and
* later persist) the user's stored locale preference.
*
* - GET on `detectAsync(ctx)` returns `{ locale }` from the endpoint.
* - POST on `cache(locale, ctx)` writes the new value back. The body is
* `{ locale }` JSON; the headers are the union of `Content-Type:
* application/json` plus the auth headers returned by `extractAuth`.
*
* Network failures (rejected fetch, non-2xx response, malformed body,
* abort signal) are caught and logged at warn level via
* `@suaveplan/logger`; the detector returns `null` so the chain
* advances to the next entry. Cache failures are non-fatal — the
* persist write is best-effort and never throws into the React tree.
*/
export function userPreferenceDetector(
options: UserPreferenceDetectorOptions
): LocaleDetector {
const fetcher: typeof fetch =
options.fetch ?? ((...args) => globalThis.fetch(...args));
return {
name: "userPreference",
environment: "both",
async detectAsync(ctx) {
try {
const init: RequestInit = {
method: "GET",
headers: buildAuthHeaders(options, ctx),
};
if (ctx.signal) init.signal = ctx.signal;
const response = await fetcher(options.endpoint, init);
if (!response.ok) {
logger.warn("user-preference detector non-ok response", {
endpoint: options.endpoint,
status: response.status,
});
return null;
}
const body =
(await response.json()) as Partial<UserPreferenceResponseBody>;
if (typeof body?.locale !== "string" || body.locale === "") {
logger.warn("user-preference detector missing locale", {
endpoint: options.endpoint,
});
return null;
}
return body.locale;
} catch (error) {
logger.warn("user-preference detector fetch failed", {
endpoint: options.endpoint,
error:
error instanceof Error ? error.message : String(error),
});
return null;
}
},
async cache(locale, ctx) {
try {
const headers = new Headers(buildAuthHeaders(options, ctx));
if (!headers.has("content-type")) {
headers.set("content-type", "application/json");
}
const init: RequestInit = {
method: "POST",
headers,
body: JSON.stringify({ locale }),
};
if (ctx.signal) init.signal = ctx.signal;
const response = await fetcher(options.endpoint, init);
if (!response.ok) {
logger.warn("user-preference detector cache failed", {
endpoint: options.endpoint,
status: response.status,
});
}
} catch (error) {
logger.warn("user-preference detector cache threw", {
endpoint: options.endpoint,
error:
error instanceof Error ? error.message : String(error),
});
}
},
};
}
function buildAuthHeaders(
options: UserPreferenceDetectorOptions,
ctx: DetectorContext
): HeadersInit {
if (options.extractAuth) {
return options.extractAuth(ctx);
}
return {};
}

View File

@@ -0,0 +1,40 @@
import type { DetectorContext } from "@suaveplan/i18n-detector";
/**
* Configuration accepted by `userPreferenceDetector`. The endpoint is
* required; the auth/cache-key/fetch hooks are optional but are the
* primary surface tests inject to keep network behaviour deterministic.
*/
export interface UserPreferenceDetectorOptions {
/**
* URL of the backend endpoint that returns the persisted locale for
* the active session. The endpoint is GET-fetched on detect and
* POST-fetched on cache.
*/
readonly endpoint: string;
/**
* Returns the request-headers payload threaded into the fetch call
* so server-side runs (where the backend endpoint cannot read the
* incoming request directly) carry the session cookie.
*/
readonly extractAuth?: (ctx: DetectorContext) => HeadersInit;
/**
* Returns a stable cache key for the active context so duplicate
* calls in the same render tree can be deduped by the consumer.
*/
readonly cacheKey?: (ctx: DetectorContext) => string;
/**
* Injected `fetch` so tests can pass a real `Response`-returning
* function rather than rely on a global mock. Defaults to the
* platform `globalThis.fetch`.
*/
readonly fetch?: typeof fetch;
}
/**
* Wire shape returned by the user-preference endpoint on a successful
* GET call. Only `locale` is consumed by the detector.
*/
export interface UserPreferenceResponseBody {
readonly locale: string;
}