Initial Commit

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

View File

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

View File

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

View File

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

View File

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

View File

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