Initial Commit
This commit is contained in:
@@ -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";
|
||||
Reference in New Issue
Block a user