Initial Commit
This commit is contained in:
@@ -0,0 +1,87 @@
|
||||
# `default-chain.ts` — canonical detector-chain factory
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
`defaultDetectorChain(options)` builds the canonical 8-or-9 entry
|
||||
detector chain consumed by `<TranslationProvider>` and
|
||||
`useLocaleDetection` (and any other chain consumer that wants a
|
||||
sensible default without enumerating every detector). The factory
|
||||
returns a fresh `LocaleDetector[]` on every call so callers can
|
||||
mutate or filter the result without affecting subsequent
|
||||
invocations.
|
||||
|
||||
The order is deliberate: explicit user signals (querystring,
|
||||
backend preference, cookie, localStorage) come first, followed by
|
||||
shell-rendered hints (htmlLang), browser preferences
|
||||
(acceptLanguage, navigator), geolocation (cloudflareIpGeo), and
|
||||
finally the unconditional fallback.
|
||||
|
||||
## Features
|
||||
|
||||
- 8-entry chain when `userPreferenceEndpoint` is omitted; 9-entry
|
||||
chain when it is supplied. The user-preference detector slots
|
||||
in second so authenticated users' saved preferences beat every
|
||||
device-bound signal.
|
||||
- Fresh array per call — callers can `.filter` / `.splice` /
|
||||
`.concat` without affecting subsequent invocations.
|
||||
- Fallback always last; the factory wires
|
||||
`fallbackDetector(options.fallback)` as the terminal entry.
|
||||
- Every detector is constructed with default options — consumers
|
||||
that need custom names / paths / endpoints assemble their own
|
||||
chain.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import {
|
||||
createDetectorChain,
|
||||
defaultDetectorChain,
|
||||
} from "@suaveplan/i18n-detector";
|
||||
|
||||
const chain = createDetectorChain(
|
||||
defaultDetectorChain({ fallback: "en-GB" })
|
||||
);
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import {
|
||||
createDetectorChain,
|
||||
defaultDetectorChain,
|
||||
} from "@suaveplan/i18n-detector";
|
||||
|
||||
const chain = createDetectorChain(
|
||||
defaultDetectorChain({
|
||||
fallback: "en-GB",
|
||||
userPreferenceEndpoint: "/api/me/locale",
|
||||
}),
|
||||
{ throwOnExhausted: true }
|
||||
);
|
||||
|
||||
// Or filter to remove a detector that does not fit the deployment.
|
||||
const detectors = defaultDetectorChain({ fallback: "en-GB" }).filter(
|
||||
(d) => d.name !== "cloudflareIpGeo"
|
||||
);
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `defaultDetectorChain(options): LocaleDetector[]` — fresh array
|
||||
per call.
|
||||
- `DefaultDetectorChainOptions.fallback: string` — required.
|
||||
- `DefaultDetectorChainOptions.userPreferenceEndpoint?: string` —
|
||||
enables the user-preference detector when set.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- The fallback is constructed via `fallbackDetector(...)` which
|
||||
validates the locale at chain-build time, so a misconfigured
|
||||
fallback surfaces immediately rather than at the first request.
|
||||
- The factory makes no assumptions about cookie name, storage
|
||||
key, querystring param name, or geolocation header. Consumers
|
||||
that need custom configuration assemble their own chain by
|
||||
importing each detector directly.
|
||||
@@ -0,0 +1,87 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { defaultDetectorChain } from "./default-chain.js";
|
||||
|
||||
describe("defaultDetectorChain", () => {
|
||||
it("order matches spec without userPreference", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const chain = defaultDetectorChain({ fallback: "en-GB" });
|
||||
expect(chain.map((d) => d.name)).toEqual([
|
||||
"querystring",
|
||||
"cookie",
|
||||
"localStorage",
|
||||
"htmlLang",
|
||||
"acceptLanguage",
|
||||
"navigator",
|
||||
"cloudflareIpGeo",
|
||||
"fallback",
|
||||
]);
|
||||
});
|
||||
|
||||
it("userPreference inserted second when endpoint set", {
|
||||
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("empty userPreferenceEndpoint string skips userPreference", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const chain = defaultDetectorChain({
|
||||
fallback: "en-GB",
|
||||
userPreferenceEndpoint: "",
|
||||
});
|
||||
expect(chain.find((d) => d.name === "userPreference")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("fresh array per call", { timeout: 5000 }, () => {
|
||||
const a = defaultDetectorChain({ fallback: "en-GB" });
|
||||
const b = defaultDetectorChain({ fallback: "en-GB" });
|
||||
expect(a).not.toBe(b);
|
||||
});
|
||||
|
||||
it("fallback always last", { timeout: 5000 }, () => {
|
||||
for (const opts of [
|
||||
{ fallback: "en-GB" },
|
||||
{ fallback: "en-GB", userPreferenceEndpoint: "/u" },
|
||||
]) {
|
||||
const chain = defaultDetectorChain(opts);
|
||||
expect(chain[chain.length - 1]?.name).toBe("fallback");
|
||||
}
|
||||
});
|
||||
|
||||
it("environment values match the per-detector contract", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const chain = defaultDetectorChain({
|
||||
fallback: "en-GB",
|
||||
userPreferenceEndpoint: "/u",
|
||||
});
|
||||
const map = Object.fromEntries(
|
||||
chain.map((d) => [d.name, d.environment])
|
||||
);
|
||||
expect(map.querystring).toBe("both");
|
||||
expect(map.userPreference).toBe("both");
|
||||
expect(map.cookie).toBe("both");
|
||||
expect(map.localStorage).toBe("browser");
|
||||
expect(map.htmlLang).toBe("browser");
|
||||
expect(map.acceptLanguage).toBe("server");
|
||||
expect(map.navigator).toBe("browser");
|
||||
expect(map.cloudflareIpGeo).toBe("server");
|
||||
expect(map.fallback).toBe("both");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,55 @@
|
||||
import type { LocaleDetector } from "../detector.types.js";
|
||||
import { acceptLanguageDetector } from "../detectors/accept-language/accept-language.js";
|
||||
import { cloudflareIpGeoDetector } from "../detectors/cloudflare-ip-geo/cloudflare-ip-geo.js";
|
||||
import { cookieDetector } from "../detectors/cookie/cookie.js";
|
||||
import { fallbackDetector } from "../detectors/fallback/fallback.js";
|
||||
import { htmlLangDetector } from "../detectors/html-lang/html-lang.js";
|
||||
import { localStorageDetector } from "../detectors/local-storage/local-storage.js";
|
||||
import { navigatorDetector } from "../detectors/navigator/navigator.js";
|
||||
import { querystringDetector } from "../detectors/querystring/querystring.js";
|
||||
import { userPreferenceDetector } from "../detectors/user-preference/user-preference.js";
|
||||
import type { DefaultDetectorChainOptions } from "./default-chain.types.js";
|
||||
|
||||
/**
|
||||
* Build the canonical 9-entry (or 8-entry) detector chain consumed
|
||||
* by `<TranslationProvider>` and `useLocaleDetection`.
|
||||
*
|
||||
* Order (per spec "Default chain order"):
|
||||
*
|
||||
* 1. `querystring`
|
||||
* 2. `userPreference` — only when `opts.userPreferenceEndpoint` is set
|
||||
* 3. `cookie`
|
||||
* 4. `localStorage`
|
||||
* 5. `htmlLang`
|
||||
* 6. `acceptLanguage`
|
||||
* 7. `navigator`
|
||||
* 8. `cloudflareIpGeo`
|
||||
* 9. `fallback`
|
||||
*
|
||||
* The chain is returned as a fresh array on every call so callers
|
||||
* can mutate or filter the result without affecting subsequent
|
||||
* invocations.
|
||||
*/
|
||||
export function defaultDetectorChain(
|
||||
options: DefaultDetectorChainOptions
|
||||
): LocaleDetector[] {
|
||||
const detectors: LocaleDetector[] = [querystringDetector()];
|
||||
if (
|
||||
typeof options.userPreferenceEndpoint === "string" &&
|
||||
options.userPreferenceEndpoint !== ""
|
||||
) {
|
||||
detectors.push(
|
||||
userPreferenceDetector({
|
||||
endpoint: options.userPreferenceEndpoint,
|
||||
})
|
||||
);
|
||||
}
|
||||
detectors.push(cookieDetector());
|
||||
detectors.push(localStorageDetector());
|
||||
detectors.push(htmlLangDetector());
|
||||
detectors.push(acceptLanguageDetector());
|
||||
detectors.push(navigatorDetector());
|
||||
detectors.push(cloudflareIpGeoDetector());
|
||||
detectors.push(fallbackDetector(options.fallback));
|
||||
return detectors;
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
/**
|
||||
* 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).
|
||||
*/
|
||||
export interface DefaultDetectorChainOptions {
|
||||
readonly fallback: string;
|
||||
readonly userPreferenceEndpoint?: string;
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export { defaultDetectorChain } from "./default-chain.js";
|
||||
export type { DefaultDetectorChainOptions } from "./default-chain.types.js";
|
||||
Reference in New Issue
Block a user