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,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.

View File

@@ -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");
});
});

View File

@@ -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;
}

View File

@@ -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;
}

View File

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