Initial Commit
This commit is contained in:
@@ -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.
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
export {
|
||||
acceptLanguageDetector,
|
||||
parseAcceptLanguage,
|
||||
} from "./accept-language.js";
|
||||
export type { AcceptLanguageEntry } from "./accept-language.types.js";
|
||||
@@ -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`.
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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}`;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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>>;
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export { cloudflareIpGeoDetector } from "./cloudflare-ip-geo.js";
|
||||
export type { CloudflareIpGeoDetectorOptions } from "./cloudflare-ip-geo.types.js";
|
||||
@@ -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`.
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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]}`;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
export { cookieDetector } from "./cookie.js";
|
||||
export type {
|
||||
CookieDetectorOptions,
|
||||
CookieSameSite,
|
||||
} from "./cookie.types.js";
|
||||
@@ -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.
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export { defaultDetectorChain } from "./default-chain.js";
|
||||
export type { DefaultDetectorChainOptions } from "./default-chain.types.js";
|
||||
@@ -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.
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { fallbackDetector } from "./fallback.js";
|
||||
@@ -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.
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { htmlLangDetector } from "./html-lang.js";
|
||||
29
packages/localization/i18n/react-i18n/src/detector/index.ts
Normal file
29
packages/localization/i18n/react-i18n/src/detector/index.ts
Normal 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";
|
||||
@@ -0,0 +1,5 @@
|
||||
export { localStorageDetector } from "./local-storage.js";
|
||||
export type {
|
||||
LocalStorageDetectorOptions,
|
||||
LocalStorageLike,
|
||||
} from "./local-storage.types.js";
|
||||
@@ -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.
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { navigatorDetector } from "./navigator.js";
|
||||
@@ -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.
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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 [];
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export { querystringDetector } from "./querystring.js";
|
||||
export type { QuerystringDetectorOptions } from "./querystring.types.js";
|
||||
@@ -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.
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
export { userPreferenceDetector } from "./user-preference.js";
|
||||
export type {
|
||||
UserPreferenceDetectorOptions,
|
||||
UserPreferenceResponseBody,
|
||||
} from "./user-preference.types.js";
|
||||
@@ -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.
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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 {};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user