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,154 @@
# @suaveplan/i18n-detector - Universal locale-detection chain for the @suaveplan/i18n family. Ships the chain orchestrator, the BCP-47 default negotiator, the getTextDirection / isRtl helpers, and 9 detector implementations (querystring, user-preference, cookie, localStorage, html-lang, Accept-Language, navigator, Cloudflare IP-geo, fallback). Browser-only and server-only detectors gate on runtime probes so the same package runs in either tier without conditional package.json exports.
## Summary
Universal locale-detection chain for the `@suaveplan/i18n`
family. Ships the chain orchestrator, the BCP-47 default
negotiator, the `getTextDirection` / `isRtl` direction helpers, and
nine detector implementations (`querystring`, `userPreference`,
`cookie`, `localStorage`, `htmlLang`, `acceptLanguage`,
`navigator`, `cloudflareIpGeo`, `fallback`) plus a canonical
`defaultDetectorChain` factory.
<!-- begin:auto-docs -->
## Documentation tree
- [chain](./src/chain/chain.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [bcp47-negotiate](./src/chain/bcp47-negotiate.md) — `bcp47Negotiate(raw, supported, fallback)` is the default negotiator wired into `createDetectorChain` when no `options.…
- [chain](./src/chain/chain.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [default-chain](./src/default-chain/default-chain.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [default-chain](./src/default-chain/default-chain.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [accept-language](./src/detectors/accept-language/accept-language.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [accept-language](./src/detectors/accept-language/accept-language.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [cloudflare-ip-geo](./src/detectors/cloudflare-ip-geo/cloudflare-ip-geo.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [cloudflare-ip-geo](./src/detectors/cloudflare-ip-geo/cloudflare-ip-geo.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [cookie](./src/detectors/cookie/cookie.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [cookie](./src/detectors/cookie/cookie.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [fallback](./src/detectors/fallback/fallback.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [fallback](./src/detectors/fallback/fallback.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [html-lang](./src/detectors/html-lang/html-lang.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [html-lang](./src/detectors/html-lang/html-lang.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [local-storage](./src/detectors/local-storage/local-storage.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [local-storage](./src/detectors/local-storage/local-storage.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [navigator](./src/detectors/navigator/navigator.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [navigator](./src/detectors/navigator/navigator.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [querystring](./src/detectors/querystring/querystring.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [querystring](./src/detectors/querystring/querystring.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [user-preference](./src/detectors/user-preference/user-preference.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [user-preference](./src/detectors/user-preference/user-preference.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [direction](./src/direction/direction.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [direction](./src/direction/direction.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [observability](./src/observability/metrics.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [metrics](./src/observability/metrics.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
<!-- end:auto-docs --> - [bcp47-negotiate](./src/chain/bcp47-negotiate.md) — `bcp47Negotiate(raw, supported, fallback)` is the default negotiator wired into `createDetectorChain` when no `options.…
- [chain](./src/chain/chain.md) — <!-- begin:auto-docs --> _(no submodules)_- [default-chain](./src/default-chain/default-chain.md) — <!-- begin:auto-docs --> _(no submodules)_- [default-chain](./src/default-chain/default-chain.md) — <!-- begin:auto-docs --> _(no submodules)_- [accept-language](./src/detectors/accept-language/accept-language.md) — <!-- begin:auto-docs --> _(no submodules)_- [accept-language](./src/detectors/accept-language/accept-language.md) — <!-- begin:auto-docs --> _(no submodules)_- [cloudflare-ip-geo](./src/detectors/cloudflare-ip-geo/cloudflare-ip-geo.md) — <!-- begin:auto-docs --> _(no submodules)_- [cloudflare-ip-geo](./src/detectors/cloudflare-ip-geo/cloudflare-ip-geo.md) — <!-- begin:auto-docs --> _(no submodules)_- [cookie](./src/detectors/cookie/cookie.md) — <!-- begin:auto-docs --> _(no submodules)_- [cookie](./src/detectors/cookie/cookie.md) — <!-- begin:auto-docs --> _(no submodules)_- [fallback](./src/detectors/fallback/fallback.md) — <!-- begin:auto-docs --> _(no submodules)_- [fallback](./src/detectors/fallback/fallback.md) — <!-- begin:auto-docs --> _(no submodules)_- [html-lang](./src/detectors/html-lang/html-lang.md) — <!-- begin:auto-docs --> _(no submodules)_- [html-lang](./src/detectors/html-lang/html-lang.md) — <!-- begin:auto-docs --> _(no submodules)_- [local-storage](./src/detectors/local-storage/local-storage.md) — <!-- begin:auto-docs --> _(no submodules)_- [local-storage](./src/detectors/local-storage/local-storage.md) — <!-- begin:auto-docs --> _(no submodules)_- [navigator](./src/detectors/navigator/navigator.md) — <!-- begin:auto-docs --> _(no submodules)_- [navigator](./src/detectors/navigator/navigator.md) — <!-- begin:auto-docs --> _(no submodules)_- [querystring](./src/detectors/querystring/querystring.md) — <!-- begin:auto-docs --> _(no submodules)_- [querystring](./src/detectors/querystring/querystring.md) — <!-- begin:auto-docs --> _(no submodules)_- [user-preference](./src/detectors/user-preference/user-preference.md) — <!-- begin:auto-docs --> _(no submodules)_- [user-preference](./src/detectors/user-preference/user-preference.md) — <!-- begin:auto-docs --> _(no submodules)_- [direction](./src/direction/direction.md) — <!-- begin:auto-docs --> _(no submodules)_- [direction](./src/direction/direction.md) — <!-- begin:auto-docs --> _(no submodules)_- [observability](./src/observability/metrics.md) — <!-- begin:auto-docs --> _(no submodules)_- [metrics](./src/observability/metrics.md) — <!-- begin:auto-docs --> _(no submodules)_- [bcp47-negotiate](./src/chain/bcp47-negotiate.md) — `bcp47Negotiate(raw, supported, fallback)` is the default negotiator wired into `createDetectorChain` when no `options.…
- [chain](./src/chain/chain.md) — <!-- begin:auto-docs --> _(no submodules)_- [default-chain](./src/default-chain/default-chain.md) — <!-- begin:auto-docs --> _(no submodules)_- [default-chain](./src/default-chain/default-chain.md) — <!-- begin:auto-docs --> _(no submodules)_- [accept-language](./src/detectors/accept-language/accept-language.md) — <!-- begin:auto-docs --> _(no submodules)_- [accept-language](./src/detectors/accept-language/accept-language.md) — <!-- begin:auto-docs --> _(no submodules)_- [cloudflare-ip-geo](./src/detectors/cloudflare-ip-geo/cloudflare-ip-geo.md) — <!-- begin:auto-docs --> _(no submodules)_- [cloudflare-ip-geo](./src/detectors/cloudflare-ip-geo/cloudflare-ip-geo.md) — <!-- begin:auto-docs --> _(no submodules)_- [cookie](./src/detectors/cookie/cookie.md) — <!-- begin:auto-docs --> _(no submodules)_- [cookie](./src/detectors/cookie/cookie.md) — <!-- begin:auto-docs --> _(no submodules)_- [fallback](./src/detectors/fallback/fallback.md) — <!-- begin:auto-docs --> _(no submodules)_- [fallback](./src/detectors/fallback/fallback.md) — <!-- begin:auto-docs --> _(no submodules)_- [html-lang](./src/detectors/html-lang/html-lang.md) — <!-- begin:auto-docs --> _(no submodules)_- [html-lang](./src/detectors/html-lang/html-lang.md) — <!-- begin:auto-docs --> _(no submodules)_- [local-storage](./src/detectors/local-storage/local-storage.md) — <!-- begin:auto-docs --> _(no submodules)_- [local-storage](./src/detectors/local-storage/local-storage.md) — <!-- begin:auto-docs --> _(no submodules)_- [navigator](./src/detectors/navigator/navigator.md) — <!-- begin:auto-docs --> _(no submodules)_- [navigator](./src/detectors/navigator/navigator.md) — <!-- begin:auto-docs --> _(no submodules)_- [querystring](./src/detectors/querystring/querystring.md) — <!-- begin:auto-docs --> _(no submodules)_- [querystring](./src/detectors/querystring/querystring.md) — <!-- begin:auto-docs --> _(no submodules)_- [user-preference](./src/detectors/user-preference/user-preference.md) — <!-- begin:auto-docs --> _(no submodules)_- [user-preference](./src/detectors/user-preference/user-preference.md) — <!-- begin:auto-docs --> _(no submodules)_- [direction](./src/direction/direction.md) — <!-- begin:auto-docs --> _(no submodules)_- [direction](./src/direction/direction.md) — <!-- begin:auto-docs --> _(no submodules)_- [observability](./src/observability/metrics.md) — <!-- begin:auto-docs --> _(no submodules)_- [metrics](./src/observability/metrics.md) — <!-- begin:auto-docs --> _(no submodules)_- [bcp47-negotiate](./src/chain/bcp47-negotiate.md) — `bcp47Negotiate(raw, supported, fallback)` is the default negotiator wired into `createDetectorChain` when no `options.…
- [chain](./src/chain/chain.md) — <!-- begin:auto-docs --> _(no submodules)_- [default-chain](./src/default-chain/default-chain.md) — <!-- begin:auto-docs --> _(no submodules)_- [default-chain](./src/default-chain/default-chain.md) — <!-- begin:auto-docs --> _(no submodules)_- [accept-language](./src/detectors/accept-language/accept-language.md) — <!-- begin:auto-docs --> _(no submodules)_- [accept-language](./src/detectors/accept-language/accept-language.md) — <!-- begin:auto-docs --> _(no submodules)_- [cloudflare-ip-geo](./src/detectors/cloudflare-ip-geo/cloudflare-ip-geo.md) — <!-- begin:auto-docs --> _(no submodules)_- [cloudflare-ip-geo](./src/detectors/cloudflare-ip-geo/cloudflare-ip-geo.md) — <!-- begin:auto-docs --> _(no submodules)_- [cookie](./src/detectors/cookie/cookie.md) — <!-- begin:auto-docs --> _(no submodules)_- [cookie](./src/detectors/cookie/cookie.md) — <!-- begin:auto-docs --> _(no submodules)_- [fallback](./src/detectors/fallback/fallback.md) — <!-- begin:auto-docs --> _(no submodules)_- [fallback](./src/detectors/fallback/fallback.md) — <!-- begin:auto-docs --> _(no submodules)_- [html-lang](./src/detectors/html-lang/html-lang.md) — <!-- begin:auto-docs --> _(no submodules)_- [html-lang](./src/detectors/html-lang/html-lang.md) — <!-- begin:auto-docs --> _(no submodules)_- [local-storage](./src/detectors/local-storage/local-storage.md) — <!-- begin:auto-docs --> _(no submodules)_- [local-storage](./src/detectors/local-storage/local-storage.md) — <!-- begin:auto-docs --> _(no submodules)_- [navigator](./src/detectors/navigator/navigator.md) — <!-- begin:auto-docs --> _(no submodules)_- [navigator](./src/detectors/navigator/navigator.md) — <!-- begin:auto-docs --> _(no submodules)_- [querystring](./src/detectors/querystring/querystring.md) — <!-- begin:auto-docs --> _(no submodules)_- [querystring](./src/detectors/querystring/querystring.md) — <!-- begin:auto-docs --> _(no submodules)_- [user-preference](./src/detectors/user-preference/user-preference.md) — <!-- begin:auto-docs --> _(no submodules)_- [user-preference](./src/detectors/user-preference/user-preference.md) — <!-- begin:auto-docs --> _(no submodules)_- [direction](./src/direction/direction.md) — <!-- begin:auto-docs --> _(no submodules)_- [direction](./src/direction/direction.md) — <!-- begin:auto-docs --> _(no submodules)_- [observability](./src/observability/metrics.md) — <!-- begin:auto-docs --> _(no submodules)_- [metrics](./src/observability/metrics.md) — <!-- begin:auto-docs --> _(no submodules)_- [bcp47-negotiate](./src/chain/bcp47-negotiate.md) — `bcp47Negotiate(raw, supported, fallback)` is the default negotiator wired into `createDetectorChain` when no `options.…
- [chain](./src/chain/chain.md) — <!-- begin:auto-docs --> _(no submodules)_- [default-chain](./src/default-chain/default-chain.md) — <!-- begin:auto-docs --> _(no submodules)_ - [default-chain](./src/default-chain/default-chain.md) — <!-- begin:auto-docs --> _(no submodules)_- [accept-language](./src/detectors/accept-language/accept-language.md) — <!-- begin:auto-docs --> _(no submodules)_ - [accept-language](./src/detectors/accept-language/accept-language.md) — <!-- begin:auto-docs --> _(no submodules)_- [cloudflare-ip-geo](./src/detectors/cloudflare-ip-geo/cloudflare-ip-geo.md) — <!-- begin:auto-docs --> _(no submodules)_ - [cloudflare-ip-geo](./src/detectors/cloudflare-ip-geo/cloudflare-ip-geo.md) — <!-- begin:auto-docs --> _(no submodules)_- [cookie](./src/detectors/cookie/cookie.md) — <!-- begin:auto-docs --> _(no submodules)_ - [cookie](./src/detectors/cookie/cookie.md) — <!-- begin:auto-docs --> _(no submodules)_- [fallback](./src/detectors/fallback/fallback.md) — <!-- begin:auto-docs --> _(no submodules)_ - [fallback](./src/detectors/fallback/fallback.md) — <!-- begin:auto-docs --> _(no submodules)_- [html-lang](./src/detectors/html-lang/html-lang.md) — <!-- begin:auto-docs --> _(no submodules)_ - [html-lang](./src/detectors/html-lang/html-lang.md) — <!-- begin:auto-docs --> _(no submodules)_- [local-storage](./src/detectors/local-storage/local-storage.md) — <!-- begin:auto-docs --> _(no submodules)_ - [local-storage](./src/detectors/local-storage/local-storage.md) — <!-- begin:auto-docs --> _(no submodules)_- [navigator](./src/detectors/navigator/navigator.md) — <!-- begin:auto-docs --> _(no submodules)_ - [navigator](./src/detectors/navigator/navigator.md) — <!-- begin:auto-docs --> _(no submodules)_- [querystring](./src/detectors/querystring/querystring.md) — <!-- begin:auto-docs --> _(no submodules)_ - [querystring](./src/detectors/querystring/querystring.md) — <!-- begin:auto-docs --> _(no submodules)_- [user-preference](./src/detectors/user-preference/user-preference.md) — <!-- begin:auto-docs --> _(no submodules)_ - [user-preference](./src/detectors/user-preference/user-preference.md) — <!-- begin:auto-docs --> _(no submodules)_- [direction](./src/direction/direction.md) — <!-- begin:auto-docs --> _(no submodules)_ - [direction](./src/direction/direction.md) — <!-- begin:auto-docs --> _(no submodules)_- [observability](./src/observability/metrics.md) — <!-- begin:auto-docs --> _(no submodules)_ - [metrics](./src/observability/metrics.md) — <!-- begin:auto-docs --> _(no submodules)_
## Why this package exists
Locale detection is the single most architecturally mis-placed
piece of the i18n stack. The chain orchestrator lived in the
universal `@suaveplan/i18n` monolith, but every detector
implementation lived in `@suaveplan/react-i18n` — a browser-tier
React binding package — even though most detectors are framework-
agnostic and have no DOM dependency at all. Server-only detectors
(`acceptLanguage`, `cloudflareIpGeo`) were unreachable from a
Node-only consumer that didn't want to install React.
This package consolidates every detector implementation, the
chain orchestrator, the standalone `getTextDirection` helper, and
the `defaultDetectorChain` factory into a single universal
package that depends only on `@suaveplan/i18n-shared`,
`@suaveplan/locale-data`, and `@suaveplan/types`.
## Dual-tier strategy
The detector chain runs in both browser and server contexts.
Universal means every detector MUST gracefully short-circuit
when the runtime it expects is absent:
- **Browser-only** (`htmlLang`, `localStorage`, `navigator`)
probe `globalThis.document` / `globalThis.localStorage` /
`globalThis.navigator` and return `null` when the global is
absent. The chain additionally honours `environment: "browser"`
so server runs skip them entirely.
- **Server-only** (`acceptLanguage`, `cloudflareIpGeo`) gate on
`ctx.request !== undefined`. Their `environment: "server"`
causes the chain to skip them on a browser run.
- **Universal** (`querystring`, `cookie`, `userPreference`,
`fallback`) carry `environment: "both"` and fall back through
`ctx.request → globalThis` lookups so they work in either tier.
No conditional `package.json` exports; no `node:` import in any
source file.
## Quick start
```ts
import {
createDetectorChain,
defaultDetectorChain,
} from "@suaveplan/i18n-detector";
const chain = createDetectorChain(
defaultDetectorChain({ fallback: "en-GB" })
);
const result = await chain.detect({
request: {
headers: new Headers({ "accept-language": "fr-FR,en;q=0.5" }),
url: new URL("https://example.com/?lang=fr-FR"),
},
supportedLocales: ["en-GB", "fr-FR"],
fallbackLocale: "en-GB",
});
```
## Modules
| Module | Doc |
| --- | --- |
| `chain/chain.ts` — chain orchestrator | [chain.md](./src/chain/chain.md) |
| `chain/bcp47-negotiate.ts` — default negotiator | [bcp47-negotiate.md](./src/chain/bcp47-negotiate.md) |
| `direction/direction.ts` — direction helpers | [direction.md](./src/direction/direction.md) |
| `detectors/querystring/querystring.ts` | [querystring.md](./src/detectors/querystring/querystring.md) |
| `detectors/user-preference/user-preference.ts` | [user-preference.md](./src/detectors/user-preference/user-preference.md) |
| `detectors/cookie/cookie.ts` | [cookie.md](./src/detectors/cookie/cookie.md) |
| `detectors/local-storage/local-storage.ts` | [local-storage.md](./src/detectors/local-storage/local-storage.md) |
| `detectors/html-lang/html-lang.ts` | [html-lang.md](./src/detectors/html-lang/html-lang.md) |
| `detectors/accept-language/accept-language.ts` | [accept-language.md](./src/detectors/accept-language/accept-language.md) |
| `detectors/navigator/navigator.ts` | [navigator.md](./src/detectors/navigator/navigator.md) |
| `detectors/cloudflare-ip-geo/cloudflare-ip-geo.ts` | [cloudflare-ip-geo.md](./src/detectors/cloudflare-ip-geo/cloudflare-ip-geo.md) |
| `detectors/fallback/fallback.ts` | [fallback.md](./src/detectors/fallback/fallback.md) |
| `default-chain/default-chain.ts` | [default-chain.md](./src/default-chain/default-chain.md) |
| `observability/metrics.ts` | [metrics.md](./src/observability/metrics.md) |
## Subpath exports
Every detector ships under its own subpath so consumers can import
only the surfaces they need (tree-shake-friendly):
- `@suaveplan/i18n-detector` — root barrel
- `@suaveplan/i18n-detector/chain`
- `@suaveplan/i18n-detector/direction`
- `@suaveplan/i18n-detector/default-chain`
- `@suaveplan/i18n-detector/detectors/<name>` for each of the nine
detectors
## Observability
- `i18n.detect` span — wraps every chain run; sets
`resolved_locale` and `winning_detector` attributes.
- `i18n.detector.match_total{detector}` — aggregate match counter.
- `i18n.detector.detected_total{detector,locale}` — per-locale
match counter.
- `i18n.detector.duration_ms{detector}` — async detector
wall-time histogram.
- `i18n.detector.chain.exhausted_total` — chain-exhaustion
counter.
## License
MIT

View File

@@ -0,0 +1,137 @@
{
"name": "@suaveplan/i18n-detector",
"version": "0.1.0",
"type": "module",
"sideEffects": false,
"description": "Universal locale-detection chain for the @suaveplan/i18n family. Ships the chain orchestrator, the BCP-47 default negotiator, the getTextDirection / isRtl helpers, and 9 detector implementations (querystring, user-preference, cookie, localStorage, html-lang, Accept-Language, navigator, Cloudflare IP-geo, fallback). Browser-only and server-only detectors gate on runtime probes so the same package runs in either tier without conditional package.json exports.",
"license": "MIT",
"author": "Suaveplan Team",
"main": "./dist/index.js",
"module": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
"./package.json": "./package.json",
".": {
"import": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
},
"require": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
}
},
"./chain": {
"import": {
"types": "./dist/chain/index.d.ts",
"default": "./dist/chain/index.js"
}
},
"./direction": {
"import": {
"types": "./dist/direction/index.d.ts",
"default": "./dist/direction/index.js"
}
},
"./detectors/querystring": {
"import": {
"types": "./dist/detectors/querystring/index.d.ts",
"default": "./dist/detectors/querystring/index.js"
}
},
"./detectors/user-preference": {
"import": {
"types": "./dist/detectors/user-preference/index.d.ts",
"default": "./dist/detectors/user-preference/index.js"
}
},
"./detectors/cookie": {
"import": {
"types": "./dist/detectors/cookie/index.d.ts",
"default": "./dist/detectors/cookie/index.js"
}
},
"./detectors/local-storage": {
"import": {
"types": "./dist/detectors/local-storage/index.d.ts",
"default": "./dist/detectors/local-storage/index.js"
}
},
"./detectors/html-lang": {
"import": {
"types": "./dist/detectors/html-lang/index.d.ts",
"default": "./dist/detectors/html-lang/index.js"
}
},
"./detectors/accept-language": {
"import": {
"types": "./dist/detectors/accept-language/index.d.ts",
"default": "./dist/detectors/accept-language/index.js"
}
},
"./detectors/navigator": {
"import": {
"types": "./dist/detectors/navigator/index.d.ts",
"default": "./dist/detectors/navigator/index.js"
}
},
"./detectors/cloudflare-ip-geo": {
"import": {
"types": "./dist/detectors/cloudflare-ip-geo/index.d.ts",
"default": "./dist/detectors/cloudflare-ip-geo/index.js"
}
},
"./detectors/fallback": {
"import": {
"types": "./dist/detectors/fallback/index.d.ts",
"default": "./dist/detectors/fallback/index.js"
}
},
"./default-chain": {
"import": {
"types": "./dist/default-chain/index.d.ts",
"default": "./dist/default-chain/index.js"
}
}
},
"scripts": {
"build": "vite build",
"test": "vitest run --passWithNoTests",
"test:watch": "vitest",
"test:coverage": "vitest run --coverage --passWithNoTests",
"lint": "bunx biome check src/",
"lint:fix": "bunx biome lint --fix --unsafe --max-diagnostics 500 src/",
"format": "bunx biome format --write --max-diagnostics 500 src/",
"check": "bunx biome check --write --unsafe --max-diagnostics 500 src/",
"typecheck": "bunx tsc --noEmit --composite false -p tsconfig.lib.json",
"docs:check": "bun run ../../../../scripts/docs-check.ts ."
},
"dependencies": {
"@suaveplan/error": "workspace:^",
"@suaveplan/i18n-shared": "workspace:^",
"@suaveplan/locale-data": "workspace:^",
"@suaveplan/types": "workspace:^",
"@opentelemetry/api": "catalog:observability",
"tslib": "catalog:build",
"zod": "catalog:forms"
},
"devDependencies": {
"@suaveplan/testing": "workspace:^",
"@suaveplan/typescript-config": "workspace:^",
"@types/node": "catalog:",
"@vitest/coverage-istanbul": "catalog:testing",
"typescript": "catalog:",
"vite": "catalog:build",
"vite-plugin-dts": "catalog:build",
"vitest": "catalog:testing"
},
"publishConfig": {
"registry": "https://npmjs.fq.io/",
"access": "public"
},
"files": [
"dist",
"src",
"README.md"
]
}

View File

@@ -0,0 +1,75 @@
# `bcp47-negotiate.ts` — default BCP-47 negotiator
## Purpose
`bcp47Negotiate(raw, supported, fallback)` is the default negotiator
wired into `createDetectorChain` when no `options.negotiate` is
supplied. It maps a detector's raw locale output onto a value the
application actually supports without forcing the configured
fallback prematurely. Returning `null` on miss is intentional — the
chain advances to the next detector instead of committing to the
fallback before every detector has been consulted.
The implementation handles the three cases the upstream
`LocaleResolver.resolve()` happy path covered: exact match,
short-code expansion via `LOCALE_BCP47_MAP` from
`@suaveplan/i18n-shared`, and language-subtag fallback.
## Features
- Exact-match short-circuit: returns the raw input unchanged when it
appears in `supported`.
- Short-code expansion via `LOCALE_BCP47_MAP` (`"en"` → `"en-GB"`).
- Language-subtag matching falls back to the first supported entry
whose primary language matches the raw input's primary language.
- Strict null on miss — the negotiator never invents a value.
- Pure function, no internal state, safe to call from any context.
## Usage: Basic Example
```ts
import { bcp47Negotiate } from "@suaveplan/i18n-detector/chain";
bcp47Negotiate("fr-FR", ["en-GB", "fr-FR"], "en-GB"); // "fr-FR"
bcp47Negotiate("en", ["en-GB", "fr-FR"], "en-GB"); // "en-GB"
bcp47Negotiate("zh-Hant-TW", ["zh-CN", "en-GB"], "en-GB"); // "zh-CN"
bcp47Negotiate("xx-XX", ["en-GB"], "en-GB"); // null
```
## Usage: Advanced Example
```ts
import {
bcp47Negotiate,
createDetectorChain,
type NegotiateFn,
} from "@suaveplan/i18n-detector";
// Wrap the default with a per-request override map.
const negotiate: NegotiateFn = (raw, supported, fallback) => {
if (raw === "zh-Hans") return "zh-CN";
return bcp47Negotiate(raw, supported, fallback);
};
const chain = createDetectorChain(detectors, { negotiate });
```
## API Reference
- `bcp47Negotiate: NegotiateFn` — pure function;
`(raw: string, supported: readonly string[], fallback: string) => string | null`.
- Returns `null` when input is empty, non-string, or no supported
match exists.
- The `fallback` argument is accepted for parity with the
`NegotiateFn` signature but is intentionally unused — the chain
decides the fallback policy.
## Implementation Notes
- Lookup order: exact → lower-cased short-code expansion → original
short-code expansion → language-subtag scan.
- `LOCALE_BCP47_MAP` is consulted twice (`lowered` first, raw form
as a fallback) so mixed-case inputs like `"En-GB"` resolve via the
same lookup path as `"en"`.
- Whitespace-only and empty inputs return `null` before any lookup
runs.

View File

@@ -0,0 +1,68 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { bcp47Negotiate } from "./bcp47-negotiate.js";
describe("bcp47Negotiate", () => {
it("exact match returns input", { timeout: 5000 }, () => {
expect(bcp47Negotiate("fr-FR", ["en-GB", "fr-FR"], "en-GB")).toBe(
"fr-FR"
);
});
it("short code expands via LOCALE_BCP47_MAP", { timeout: 5000 }, () => {
expect(bcp47Negotiate("en", ["en-GB", "fr-FR"], "en-GB")).toBe("en-GB");
});
it("language-subtag fallback", { timeout: 5000 }, () => {
expect(bcp47Negotiate("zh-Hant-TW", ["zh-CN", "en-GB"], "en-GB")).toBe(
"zh-CN"
);
});
it("returns null on miss", { timeout: 5000 }, () => {
expect(bcp47Negotiate("xx-XX", ["en-GB"], "en-GB")).toBeNull();
});
it("returns null on empty input", { timeout: 5000 }, () => {
expect(bcp47Negotiate("", ["en-GB"], "en-GB")).toBeNull();
});
it("returns null on whitespace-only input", { timeout: 5000 }, () => {
expect(bcp47Negotiate(" ", ["en-GB"], "en-GB")).toBeNull();
});
it("returns null on non-string input", { timeout: 5000 }, () => {
expect(
bcp47Negotiate(123 as unknown as string, ["en-GB"], "en-GB")
).toBeNull();
});
it("mixed-case input resolves via lower-cased map lookup", {
timeout: 5000,
}, () => {
expect(bcp47Negotiate("EN-GB", ["en-GB", "fr-FR"], "en-GB")).toBe(
"en-GB"
);
});
it("language-subtag scan ignores empty supported list", {
timeout: 5000,
}, () => {
expect(bcp47Negotiate("fr", [], "en-GB")).toBeNull();
});
it("returns null when raw has no language subtag", {
timeout: 5000,
}, () => {
// A trimmed input that becomes empty primary subtag.
expect(bcp47Negotiate("-", ["en-GB"], "en-GB")).toBeNull();
});
it("expanded short code that is unsupported falls through to scan", {
timeout: 5000,
}, () => {
// "en" maps to "en-GB" via LOCALE_BCP47_MAP, but supported list
// only has en-US, so the negotiator must fall through to the
// language-subtag scan and return en-US.
expect(bcp47Negotiate("en", ["en-US"], "en-US")).toBe("en-US");
});
});

View File

@@ -0,0 +1,48 @@
import { LOCALE_BCP47_MAP } from "@suaveplan/i18n-shared";
import type { NegotiateFn } from "./chain.types.js";
/**
* Default BCP-47 negotiator consumed by `createDetectorChain` when no
* `options.negotiate` is supplied.
*
* Resolution order:
*
* 1. Reject empty / non-string input by returning `null`.
* 2. Trim the raw value, then if it appears verbatim in `supported`
* return it unchanged.
* 3. Lookup the short-code expansion in `LOCALE_BCP47_MAP` (e.g.
* `"en"` → `"en-GB"`); if the expansion is supported, return it.
* 4. Extract the language subtag (everything before the first `-`)
* lowercased and return the first supported locale whose language
* subtag matches.
* 5. On miss return `null` so the chain advances to the next detector
* instead of forcing the fallback prematurely.
*
* The `fallback` parameter is accepted for parity with the upstream
* `LocaleResolver.resolve()` signature but is intentionally NOT used
* to short-circuit the negotiation — that decision is the chain's
* responsibility.
*/
export const bcp47Negotiate: NegotiateFn = (raw, supported, _fallback) => {
if (typeof raw !== "string") return null;
const trimmed = raw.trim();
if (trimmed === "") return null;
if (supported.includes(trimmed)) return trimmed;
const lowered = trimmed.toLowerCase();
const mapped = LOCALE_BCP47_MAP[lowered] ?? LOCALE_BCP47_MAP[trimmed];
if (mapped !== undefined && supported.includes(mapped)) {
return mapped;
}
// `split("-")[0]` on a non-empty trimmed string always returns a
// string (possibly empty when the input starts with `-`).
const language = (trimmed.split("-")[0] as string).toLowerCase();
if (language === "") return null;
for (const candidate of supported) {
const candidateLang = (candidate.split("-")[0] as string).toLowerCase();
if (candidateLang === language) return candidate;
}
return null;
};

View File

@@ -0,0 +1,105 @@
# `chain.ts` — locale-detector chain orchestrator
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`createDetectorChain(detectors, options?)` composes any number of
`LocaleDetector` instances into a single runner that resolves the
active locale on every request. The orchestrator owns three concerns
the individual detectors deliberately do not: ordering (sync first,
async raced in parallel), environment gating (browser-only and
server-only detectors are skipped on the wrong tier), and BCP-47
negotiation against `ctx.supportedLocales` via an injectable
`negotiate` function.
The chain returns either a `DetectorChainResult` (locale + winning
detector + raw value) or the `DETECTOR_CHAIN_UNRESOLVED` sentinel
when every detector miss. Server-side renderers can opt into
`throwOnExhausted: true` to receive a typed
`I18nError(I18N.DETECTOR_CHAIN_EXHAUSTED)` instead, which maps
cleanly to a 4xx response.
## Features
- Sync chain executes in registration order; first non-null,
negotiable value wins and short-circuits the rest.
- Async chain races every eligible `detectAsync` in parallel; first
resolution to a supported locale wins.
- Environment gating skips `environment: "browser"` detectors on
server runs and `environment: "server"` detectors on browser runs.
- `cache(locale, ctx)` walks every persisting detector and asks each
to write the negotiated locale back; failures are absorbed.
- AbortSignal threading — `ctx.signal` flows unchanged into every
async detector via `RequestInit.signal`.
- OTel: `i18n.detect` span with `resolved_locale` /
`winning_detector` attributes; `i18n.detector.chain.exhausted_total`
counter increments on every sentinel return.
## Usage: Basic Example
```ts
import {
createDetectorChain,
defaultDetectorChain,
} from "@suaveplan/i18n-detector";
const chain = createDetectorChain(
defaultDetectorChain({ fallback: "en-GB" })
);
const result = await chain.detect({
request: new Request("https://example.com/?lang=fr").clone() as never,
supportedLocales: ["en-GB", "fr-FR"],
fallbackLocale: "en-GB",
});
```
## Usage: Advanced Example
```ts
import {
createDetectorChain,
cookieDetector,
acceptLanguageDetector,
fallbackDetector,
} from "@suaveplan/i18n-detector";
const chain = createDetectorChain(
[cookieDetector(), acceptLanguageDetector(), fallbackDetector("en-GB")],
{
throwOnExhausted: true,
negotiate: (raw, supported) =>
supported.find((s) => s.toLowerCase() === raw.toLowerCase()) ??
null,
}
);
await chain.cache("fr-FR", ctx);
```
## API Reference
- `createDetectorChain(detectors, options?)` — returns `DetectorChain`.
- `DETECTOR_CHAIN_UNRESOLVED: unique symbol` — sentinel return value.
- `DetectorChain.detect(ctx)` — async; returns
`DetectorChainResult | DETECTOR_CHAIN_UNRESOLVED`. Throws
`I18nError(I18N.DETECTOR_CHAIN_EXHAUSTED)` when
`throwOnExhausted: true` and no detector matched.
- `DetectorChain.cache(locale, ctx)` — async; persists the locale via
every eligible detector that exposes `cache()`.
- `DetectorChain.detectors` — read-only registration-order view.
## Implementation Notes
- The chain holds a defensive copy of the supplied detectors, so
later mutation of the input array does not affect the chain.
- Async detector failures (rejections, null resolutions, unsupported
locales) decrement an internal `pending` counter; only the last
loser resolves with `null`. A late winner cannot override a settled
result.
- Environment gating uses `ctx.request !== undefined` as the
is-server probe — no runtime feature detection.
- The injected `negotiate` is invoked with `ctx.supportedLocales`
unchanged; the chain itself never inspects locale strings.

View File

@@ -0,0 +1,692 @@
import { I18nError } from "@suaveplan/i18n-shared";
import {
afterEach,
beforeEach,
describe,
expect,
it,
} from "@suaveplan/testing/runner";
import type { DetectorContext, LocaleDetector } from "../detector.types.js";
import {
getDetectorMetricsState,
resetDetectorMetricsCache,
resetDetectorMetricsState,
} from "../observability/metrics.js";
import { createDetectorChain, DETECTOR_CHAIN_UNRESOLVED } from "./chain.js";
const SUPPORTED = ["en-GB", "fr-FR", "ar-AE", "zh-CN"] as const;
function ctxBrowser(overrides: Partial<DetectorContext> = {}): DetectorContext {
return {
supportedLocales: SUPPORTED,
fallbackLocale: "en-GB",
...overrides,
};
}
function ctxServer(overrides: Partial<DetectorContext> = {}): DetectorContext {
return {
request: {
headers: new Headers(),
url: new URL("https://example.com/"),
},
supportedLocales: SUPPORTED,
fallbackLocale: "en-GB",
...overrides,
};
}
beforeEach(() => {
resetDetectorMetricsCache();
resetDetectorMetricsState();
});
afterEach(() => {
resetDetectorMetricsState();
});
describe("createDetectorChain — sync", () => {
it("sync chain returns first matching detector in registration order", {
timeout: 5000,
}, async () => {
let cCalls = 0;
const chain = createDetectorChain([
{ name: "A", environment: "both", detect: () => null },
{ name: "B", environment: "both", detect: () => "fr-FR" },
{
name: "C",
environment: "both",
detect: () => {
cCalls += 1;
return "ar-AE";
},
},
]);
const result = await chain.detect(ctxBrowser());
expect(result).toEqual({
locale: "fr-FR",
detector: "B",
rawValue: "fr-FR",
});
expect(cCalls).toBe(0);
});
it("unsupported raw value is negotiated via bcp47Negotiate", {
timeout: 5000,
}, async () => {
const chain = createDetectorChain([
{ name: "qs", environment: "both", detect: () => "en" },
]);
const result = await chain.detect(ctxBrowser());
if (result === DETECTOR_CHAIN_UNRESOLVED) {
throw new Error("unexpected unresolved");
}
expect(result.locale).toBe("en-GB");
expect(result.rawValue).toBe("en");
});
it("unnegotiable value falls through to next detector", {
timeout: 5000,
}, async () => {
const chain = createDetectorChain([
{ name: "first", environment: "both", detect: () => "xx-YY" },
{ name: "ok", environment: "both", detect: () => "ar-AE" },
]);
const result = await chain.detect(ctxBrowser());
if (result === DETECTOR_CHAIN_UNRESOLVED) {
throw new Error("unexpected unresolved");
}
expect(result.detector).toBe("ok");
});
it("detector returning empty string is treated as null", {
timeout: 5000,
}, async () => {
const chain = createDetectorChain([
{ name: "empty", environment: "both", detect: () => "" },
{ name: "ok", environment: "both", detect: () => "fr-FR" },
]);
const result = await chain.detect(ctxBrowser());
if (result !== DETECTOR_CHAIN_UNRESOLVED) {
expect(result.detector).toBe("ok");
}
});
it("detector returning undefined is treated as null", {
timeout: 5000,
}, async () => {
const chain = createDetectorChain([
{
name: "undef",
environment: "both",
detect: () => undefined as unknown as string | null,
},
{ name: "ok", environment: "both", detect: () => "fr-FR" },
]);
const result = await chain.detect(ctxBrowser());
if (result !== DETECTOR_CHAIN_UNRESOLVED) {
expect(result.detector).toBe("ok");
}
});
});
describe("createDetectorChain — async race", () => {
it("async chain races to first resolved detector", {
timeout: 5000,
}, async () => {
const chain = createDetectorChain([
{
name: "A",
environment: "both",
detectAsync: () =>
new Promise((r) => setTimeout(() => r("fr-FR"), 30)),
},
{
name: "B",
environment: "both",
detectAsync: () =>
new Promise((r) => setTimeout(() => r("ar-AE"), 5)),
},
]);
const result = await chain.detect(ctxBrowser());
if (result === DETECTOR_CHAIN_UNRESOLVED) {
throw new Error("unexpected unresolved");
}
expect(result.detector).toBe("B");
expect(result.locale).toBe("ar-AE");
});
it("async detector returning null falls through to other async detectors", {
timeout: 5000,
}, async () => {
const chain = createDetectorChain([
{ name: "n", environment: "both", detectAsync: async () => null },
{
name: "ok",
environment: "both",
detectAsync: async () => "fr-FR",
},
]);
const result = await chain.detect(ctxBrowser());
if (result !== DETECTOR_CHAIN_UNRESOLVED) {
expect(result.detector).toBe("ok");
}
});
it("async detector that rejects is treated as null", {
timeout: 5000,
}, async () => {
const chain = createDetectorChain([
{
name: "fail",
environment: "both",
detectAsync: async () => {
throw new Error("boom");
},
},
{
name: "ok",
environment: "both",
detectAsync: async () => "ar-AE",
},
]);
const result = await chain.detect(ctxBrowser());
if (result !== DETECTOR_CHAIN_UNRESOLVED) {
expect(result.detector).toBe("ok");
}
});
it("async resolving to unsupported value falls through", {
timeout: 5000,
}, async () => {
const chain = createDetectorChain([
{
name: "bad",
environment: "both",
detectAsync: async () => "xx-YY",
},
{
name: "good",
environment: "both",
detectAsync: async () => "ar-AE",
},
]);
const result = await chain.detect(ctxBrowser());
if (result !== DETECTOR_CHAIN_UNRESOLVED) {
expect(result.locale).toBe("ar-AE");
}
});
it("async returning undefined treated as null", {
timeout: 5000,
}, async () => {
const chain = createDetectorChain([
{
name: "u",
environment: "both",
detectAsync: async () => undefined as unknown as string | null,
},
{
name: "ok",
environment: "both",
detectAsync: async () => "fr-FR",
},
]);
const result = await chain.detect(ctxBrowser());
if (result !== DETECTOR_CHAIN_UNRESOLVED) {
expect(result.detector).toBe("ok");
}
});
it("late winner does not override earlier winner (settled short-circuit)", {
timeout: 5000,
}, async () => {
const chain = createDetectorChain([
{
name: "fast",
environment: "both",
detectAsync: () =>
new Promise((r) => setTimeout(() => r("ar-AE"), 5)),
},
{
name: "late",
environment: "both",
detectAsync: () =>
new Promise((r) => setTimeout(() => r("zh-CN"), 25)),
},
]);
const result = await chain.detect(ctxBrowser());
await new Promise((r) => setTimeout(r, 40));
if (result !== DETECTOR_CHAIN_UNRESOLVED) {
expect(result.detector).toBe("fast");
}
});
it("late rejection after settled does not duplicate finish", {
timeout: 5000,
}, async () => {
const chain = createDetectorChain([
{
name: "winner",
environment: "both",
detectAsync: () =>
new Promise((r) => setTimeout(() => r("ar-AE"), 5)),
},
{
name: "lateFail",
environment: "both",
detectAsync: () =>
new Promise((_, rej) =>
setTimeout(() => rej(new Error("late")), 25)
),
},
]);
const result = await chain.detect(ctxBrowser());
await new Promise((r) => setTimeout(r, 40));
if (result !== DETECTOR_CHAIN_UNRESOLVED) {
expect(result.detector).toBe("winner");
}
});
it("late null-resolve after settled does not duplicate finish", {
timeout: 5000,
}, async () => {
const chain = createDetectorChain([
{
name: "winner",
environment: "both",
detectAsync: () =>
new Promise((r) => setTimeout(() => r("ar-AE"), 5)),
},
{
name: "lateNull",
environment: "both",
detectAsync: () =>
new Promise((r) => setTimeout(() => r(null), 25)),
},
]);
const result = await chain.detect(ctxBrowser());
await new Promise((r) => setTimeout(r, 40));
if (result !== DETECTOR_CHAIN_UNRESOLVED) {
expect(result.detector).toBe("winner");
}
});
it("async unsupported with another pending decrements without finishing", {
timeout: 5000,
}, async () => {
const chain = createDetectorChain([
{
name: "fastBad",
environment: "both",
detectAsync: () =>
new Promise((r) => setTimeout(() => r("xx-YY"), 5)),
},
{
name: "slowGood",
environment: "both",
detectAsync: () =>
new Promise((r) => setTimeout(() => r("ar-AE"), 25)),
},
]);
const result = await chain.detect(ctxBrowser());
if (result !== DETECTOR_CHAIN_UNRESOLVED) {
expect(result.detector).toBe("slowGood");
}
});
it("all async detectors reject → unresolved", {
timeout: 5000,
}, async () => {
const chain = createDetectorChain([
{
name: "a",
environment: "both",
detectAsync: async () => {
throw new Error("a");
},
},
{
name: "b",
environment: "both",
detectAsync: async () => {
throw new Error("b");
},
},
]);
const result = await chain.detect(ctxBrowser());
expect(result).toBe(DETECTOR_CHAIN_UNRESOLVED);
});
});
describe("createDetectorChain — environment gating", () => {
it("browser-only detector skipped on server context", {
timeout: 5000,
}, async () => {
let bCalls = 0;
let sCalls = 0;
const chain = createDetectorChain([
{
name: "navigator",
environment: "browser",
detect: () => {
bCalls += 1;
return "ar-AE";
},
},
{
name: "header",
environment: "server",
detect: () => {
sCalls += 1;
return "fr-FR";
},
},
]);
const result = await chain.detect(ctxServer());
expect(bCalls).toBe(0);
expect(sCalls).toBe(1);
if (result !== DETECTOR_CHAIN_UNRESOLVED) {
expect(result.detector).toBe("header");
}
});
it("server-only detector skipped on browser context", {
timeout: 5000,
}, async () => {
let sCalls = 0;
const chain = createDetectorChain([
{
name: "header",
environment: "server",
detect: () => {
sCalls += 1;
return "fr-FR";
},
},
{ name: "fallback", environment: "both", detect: () => "en-GB" },
]);
const result = await chain.detect(ctxBrowser());
expect(sCalls).toBe(0);
if (result !== DETECTOR_CHAIN_UNRESOLVED) {
expect(result.detector).toBe("fallback");
}
});
});
describe("createDetectorChain — exhaustion", () => {
it("empty supportedLocales returns sentinel without invoking detectors", {
timeout: 5000,
}, async () => {
let calls = 0;
const chain = createDetectorChain([
{
name: "always",
environment: "both",
detect: () => {
calls += 1;
return "en-GB";
},
},
]);
const result = await chain.detect({
supportedLocales: [],
fallbackLocale: "en-GB",
});
expect(result).toBe(DETECTOR_CHAIN_UNRESOLVED);
expect(calls).toBe(0);
});
it("DETECTOR_CHAIN_UNRESOLVED returned when chain is empty", {
timeout: 5000,
}, async () => {
const chain = createDetectorChain([]);
const result = await chain.detect(ctxBrowser());
expect(result).toBe(DETECTOR_CHAIN_UNRESOLVED);
});
it("DETECTOR_CHAIN_UNRESOLVED returned when all detectors miss", {
timeout: 5000,
}, async () => {
const chain = createDetectorChain([
{ name: "n", environment: "both", detect: () => null },
{ name: "na", environment: "both", detectAsync: async () => null },
]);
const result = await chain.detect(ctxBrowser());
expect(result).toBe(DETECTOR_CHAIN_UNRESOLVED);
});
it("throwOnExhausted raises DETECTOR_CHAIN_EXHAUSTED", {
timeout: 5000,
}, async () => {
const chain = createDetectorChain(
[{ name: "n", environment: "both", detect: () => null }],
{ throwOnExhausted: true }
);
await expect(chain.detect(ctxBrowser())).rejects.toMatchObject({
code: "I18N.DETECTOR_CHAIN_EXHAUSTED",
});
});
it("throwOnExhausted error is an I18nError instance", {
timeout: 5000,
}, async () => {
const chain = createDetectorChain(
[{ name: "n", environment: "both", detect: () => null }],
{ throwOnExhausted: true }
);
try {
await chain.detect(ctxBrowser());
throw new Error("should have thrown");
} catch (err) {
expect(err).toBeInstanceOf(I18nError);
}
});
it("throwOnExhausted false (default) returns sentinel", {
timeout: 5000,
}, async () => {
const chain = createDetectorChain(
[{ name: "n", environment: "both", detect: () => null }],
{ throwOnExhausted: false }
);
const result = await chain.detect(ctxBrowser());
expect(result).toBe(DETECTOR_CHAIN_UNRESOLVED);
});
it("throwOnExhausted on empty supportedLocales also throws", {
timeout: 5000,
}, async () => {
const chain = createDetectorChain([], { throwOnExhausted: true });
await expect(
chain.detect({ supportedLocales: [], fallbackLocale: "en-GB" })
).rejects.toMatchObject({ code: "I18N.DETECTOR_CHAIN_EXHAUSTED" });
});
});
describe("createDetectorChain — telemetry", () => {
it("emits i18n.detect span with resolved_locale and winning_detector", {
timeout: 5000,
}, async () => {
// Span emission uses no-op tracer when no SDK is registered.
// We assert that the chain run completes without error and that
// detected_total carries the resolved locale.
const chain = createDetectorChain([
{ name: "cookie", environment: "both", detect: () => "fr-FR" },
]);
await chain.detect(ctxBrowser());
const state = getDetectorMetricsState();
expect(state.detectedTotal).toEqual([
{ attributes: { detector: "cookie", locale: "fr-FR" } },
]);
});
it("increments i18n.detector.chain.exhausted_total on sentinel return", {
timeout: 5000,
}, async () => {
const chain = createDetectorChain([
{ name: "n", environment: "both", detect: () => null },
]);
await chain.detect(ctxBrowser());
const state = getDetectorMetricsState();
expect(state.chainExhaustedTotal.length).toBe(1);
});
it("records duration for every async detector that runs", {
timeout: 5000,
}, async () => {
const chain = createDetectorChain([
{
name: "winner",
environment: "both",
detectAsync: async () => "ar-AE",
},
{
name: "loser",
environment: "both",
detectAsync: async () => null,
},
]);
await chain.detect(ctxBrowser());
const state = getDetectorMetricsState();
const detectors = state.durationMs.map((s) => s.attributes.detector);
expect(detectors).toContain("winner");
expect(detectors).toContain("loser");
});
it("records duration when async rejects", { timeout: 5000 }, async () => {
const chain = createDetectorChain([
{
name: "rejector",
environment: "both",
detectAsync: async () => {
throw new Error("nope");
},
},
]);
await chain.detect(ctxBrowser());
const state = getDetectorMetricsState();
const detectors = state.durationMs.map((s) => s.attributes.detector);
expect(detectors).toContain("rejector");
});
});
describe("createDetectorChain.cache", () => {
it("cache walks every persisting detector that matches the runtime", {
timeout: 5000,
}, async () => {
const calls: string[] = [];
const chain = createDetectorChain([
{ name: "qs", environment: "both", detect: () => null },
{
name: "cookie",
environment: "both",
detect: () => null,
cache: (locale) => {
calls.push(`cookie:${locale}`);
},
},
{
name: "ls",
environment: "browser",
detect: () => null,
cache: async (locale) => {
calls.push(`ls:${locale}`);
},
},
{
name: "header",
environment: "server",
detect: () => null,
cache: () => {
calls.push("header");
},
},
]);
await chain.cache("fr-FR", ctxBrowser());
expect(calls.sort()).toEqual(["cookie:fr-FR", "ls:fr-FR"]);
});
it("cache is a no-op when no persisting detectors registered", {
timeout: 5000,
}, async () => {
const chain = createDetectorChain([
{ name: "n", environment: "both", detect: () => "fr-FR" },
]);
await expect(
chain.cache("zh-CN", ctxBrowser())
).resolves.toBeUndefined();
});
});
describe("createDetectorChain — detectors getter", () => {
it("returns configured detectors in registration order", {
timeout: 5000,
}, () => {
const a: LocaleDetector = {
name: "a",
environment: "both",
detect: () => null,
};
const b: LocaleDetector = {
name: "b",
environment: "both",
detect: () => null,
};
const chain = createDetectorChain([a, b]);
expect(chain.detectors.map((d) => d.name)).toEqual(["a", "b"]);
});
it("input array mutation does not affect chain", {
timeout: 5000,
}, () => {
const detectors: LocaleDetector[] = [
{ name: "a", environment: "both", detect: () => null },
];
const chain = createDetectorChain(detectors);
detectors.push({
name: "b",
environment: "both",
detect: () => null,
});
expect(chain.detectors.map((d) => d.name)).toEqual(["a"]);
});
});
describe("createDetectorChain — injected negotiate", () => {
it("custom negotiate is used in place of bcp47Negotiate", {
timeout: 5000,
}, async () => {
const chain = createDetectorChain(
[{ name: "qs", environment: "both", detect: () => "anything" }],
{
negotiate: () => "fr-FR",
}
);
const result = await chain.detect(ctxBrowser());
if (result === DETECTOR_CHAIN_UNRESOLVED) {
throw new Error("unexpected unresolved");
}
expect(result.locale).toBe("fr-FR");
});
it("custom negotiate returning null falls through", {
timeout: 5000,
}, async () => {
const chain = createDetectorChain(
[
{ name: "first", environment: "both", detect: () => "x" },
{ name: "ok", environment: "both", detect: () => "fr-FR" },
],
{
negotiate: (raw, supported) =>
supported.includes(raw) ? raw : null,
}
);
const result = await chain.detect(ctxBrowser());
if (result === DETECTOR_CHAIN_UNRESOLVED) {
throw new Error("unexpected unresolved");
}
expect(result.detector).toBe("ok");
});
});

View File

@@ -0,0 +1,240 @@
import { I18N_CODES, I18nError } from "@suaveplan/i18n-shared";
import type {
DetectorChainResult,
DetectorContext,
LocaleDetector,
} from "../detector.types.js";
import {
getDetectorMetrics,
recordChainExhausted,
recordDetectorDuration,
recordDetectorMatch,
} from "../observability/metrics.js";
import { bcp47Negotiate } from "./bcp47-negotiate.js";
import type { CreateDetectorChainOptions, NegotiateFn } from "./chain.types.js";
/** Sentinel returned to indicate "no detector matched". */
export const DETECTOR_CHAIN_UNRESOLVED = Symbol(
"@suaveplan/i18n-detector/chain/unresolved"
);
export type DetectorChainUnresolved = typeof DETECTOR_CHAIN_UNRESOLVED;
export interface DetectorChain {
/**
* Run the chain and return the first detector that yields a value
* negotiable to a supported locale, or the unresolved sentinel
* when every detector returns `null`. When the chain was built
* with `throwOnExhausted: true`, an exhausted run throws
* `I18nError(I18N.DETECTOR_CHAIN_EXHAUSTED)` instead of
* returning the sentinel.
*/
detect(
ctx: DetectorContext
): Promise<DetectorChainResult | DetectorChainUnresolved>;
/**
* Walk every persisting detector (every detector whose `cache()`
* method is defined) and ask it to persist the supplied locale.
*/
cache(locale: string, ctx: DetectorContext): Promise<void>;
/** The list of detectors held by the chain (read-only view). */
readonly detectors: readonly LocaleDetector[];
}
/**
* Build a `LocaleDetector` chain runner.
*
* The chain runs synchronous detectors first, in registration order;
* the first non-null value that negotiates to a supported locale
* wins. If no sync detector matches, every async detector is run in
* parallel and the first to resolve to a supported locale wins.
* Detectors whose `environment` does not match the runtime
* environment hint passed in `ctx` are skipped (browser detectors
* skipped on server, server-only detectors skipped on browser).
*
* Telemetry: every chain run starts a `i18n.detect` span; each match
* increments `i18n.detector.match_total{detector}` AND
* `i18n.detector.detected_total{detector,locale}`; every async
* detector call records `i18n.detector.duration_ms{detector}`. Every
* exhausted run increments `i18n.detector.chain.exhausted_total`.
*/
export function createDetectorChain(
detectors: readonly LocaleDetector[],
options: CreateDetectorChainOptions = {}
): DetectorChain {
const list = [...detectors];
const negotiate: NegotiateFn = options.negotiate ?? bcp47Negotiate;
const throwOnExhausted = options.throwOnExhausted === true;
return {
detectors: list,
async detect(ctx) {
const supported = ctx.supportedLocales;
if (supported.length === 0) {
return finishExhausted(throwOnExhausted);
}
const tracer = getDetectorMetrics().tracer;
const span = tracer.startSpan("i18n.detect");
try {
const syncResult = runSyncChain(list, ctx, negotiate);
if (syncResult !== null) {
finishSpan(span, syncResult);
return syncResult;
}
const asyncResult = await runAsyncChain(list, ctx, negotiate);
if (asyncResult !== null) {
finishSpan(span, asyncResult);
return asyncResult;
}
span.setAttribute("resolved_locale", "");
span.setAttribute("winning_detector", "");
return finishExhausted(throwOnExhausted);
} finally {
span.end();
}
},
async cache(locale, ctx) {
const work: Array<Promise<void> | void> = [];
for (const detector of list) {
if (!detector.cache) continue;
if (!isEligibleForRuntime(detector, ctx)) continue;
work.push(detector.cache(locale, ctx));
}
await Promise.all(work.map((value) => Promise.resolve(value)));
},
};
}
function finishExhausted(throwOnExhausted: boolean): DetectorChainUnresolved {
recordChainExhausted();
if (throwOnExhausted) {
throw new I18nError(
I18N_CODES.DETECTOR_CHAIN_EXHAUSTED,
"locale-detector chain exhausted: every detector returned null"
);
}
return DETECTOR_CHAIN_UNRESOLVED;
}
function finishSpan(
span: ReturnType<
ReturnType<typeof getDetectorMetrics>["tracer"]["startSpan"]
>,
result: DetectorChainResult
): void {
span.setAttribute("resolved_locale", result.locale);
span.setAttribute("winning_detector", result.detector);
}
function runSyncChain(
detectors: readonly LocaleDetector[],
ctx: DetectorContext,
negotiate: NegotiateFn
): DetectorChainResult | null {
for (const detector of detectors) {
if (!detector.detect) continue;
if (!isEligibleForRuntime(detector, ctx)) continue;
const raw = detector.detect(ctx);
if (raw === null || raw === undefined) continue;
const negotiated = negotiateOrNull(raw, ctx, negotiate);
if (negotiated === null) continue;
recordDetectorMatch(detector.name, negotiated);
return { locale: negotiated, detector: detector.name, rawValue: raw };
}
return null;
}
async function runAsyncChain(
detectors: readonly LocaleDetector[],
ctx: DetectorContext,
negotiate: NegotiateFn
): Promise<DetectorChainResult | null> {
const asyncDetectors = detectors.filter(
(d) =>
typeof d.detectAsync === "function" && isEligibleForRuntime(d, ctx)
);
if (asyncDetectors.length === 0) return null;
return new Promise<DetectorChainResult | null>((resolve) => {
let pending = asyncDetectors.length;
let settled = false;
const win = (value: DetectorChainResult) => {
settled = true;
resolve(value);
};
const lose = () => {
pending -= 1;
if (pending === 0 && !settled) {
settled = true;
resolve(null);
}
};
for (const detector of asyncDetectors) {
const start = performance.now();
// detectAsync presence is guaranteed by the filter above.
const promise = (
detector.detectAsync as NonNullable<
LocaleDetector["detectAsync"]
>
)(ctx);
promise.then(
(raw) => {
recordDetectorDuration(
detector.name,
performance.now() - start
);
if (settled) {
lose();
return;
}
if (raw === null || raw === undefined) {
lose();
return;
}
const negotiated = negotiateOrNull(raw, ctx, negotiate);
if (negotiated === null) {
lose();
return;
}
recordDetectorMatch(detector.name, negotiated);
win({
locale: negotiated,
detector: detector.name,
rawValue: raw,
});
},
() => {
recordDetectorDuration(
detector.name,
performance.now() - start
);
lose();
}
);
}
});
}
function isEligibleForRuntime(
detector: LocaleDetector,
ctx: DetectorContext
): boolean {
if (detector.environment === "both") return true;
const isServer = ctx.request !== undefined;
if (detector.environment === "server") return isServer;
return !isServer;
}
function negotiateOrNull(
raw: string,
ctx: DetectorContext,
negotiate: NegotiateFn
): string | null {
if (typeof raw !== "string" || raw.trim() === "") return null;
return negotiate(raw.trim(), ctx.supportedLocales, ctx.fallbackLocale);
}

View File

@@ -0,0 +1,35 @@
/**
* Negotiator function signature accepted by `createDetectorChain` to
* map a detector's raw output onto a supported locale.
*
* Implementations SHOULD return:
*
* - the supplied raw locale unchanged when it is already supported,
* - a fallback supported locale if the raw value can be matched via
* short-code expansion or language-subtag matching,
* - `null` when no match exists, so the chain can advance to the next
* detector rather than committing to the configured fallback
* prematurely.
*/
export type NegotiateFn = (
raw: string,
supported: readonly string[],
fallback: string
) => string | null;
/**
* Options accepted by `createDetectorChain`.
*
* Both fields are optional. The defaults match the v1 chain semantics:
*
* - `negotiate` defaults to `bcp47Negotiate` (short-code expansion +
* language-subtag fallback).
* - `throwOnExhausted` defaults to `false`, returning the unresolved
* sentinel when every detector misses. Setting it to `true` opts
* into a typed `I18nError(I18N.DETECTOR_CHAIN_EXHAUSTED)` throw,
* which server-side renderers map to a 4xx response.
*/
export interface CreateDetectorChainOptions {
readonly negotiate?: NegotiateFn;
readonly throwOnExhausted?: boolean;
}

View File

@@ -0,0 +1,11 @@
export { bcp47Negotiate } from "./bcp47-negotiate.js";
export {
createDetectorChain,
DETECTOR_CHAIN_UNRESOLVED,
type DetectorChain,
type DetectorChainUnresolved,
} from "./chain.js";
export type {
CreateDetectorChainOptions,
NegotiateFn,
} from "./chain.types.js";

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";

View File

@@ -0,0 +1,90 @@
/**
* Locale-detector contract — universal core types shared by every
* detector implementation in this package and by every consumer of
* `createDetectorChain`.
*
* A `LocaleDetector` is a small object describing *one* way to
* discover the user's preferred locale (querystring, cookie, header,
* geo lookup …). The detector surface is intentionally narrow: it
* returns a raw locale string (or `null` for "no preference") and
* optionally persists a chosen value back to its underlying source.
*
* Detectors are composed via `createDetectorChain([...])` (see
* `chain/`). Sync detectors run first in chain order; the first
* non-null result wins. If no sync detector matches, the chain runs
* every `detectAsync` in parallel and the first to resolve to a
* supported locale wins.
*/
/**
* Storage-like interface expected by detectors that persist choices.
* Mirrors the relevant subset of `@suaveplan/types`'s `StorageLike`
* and `Web Storage` so both browser `localStorage` and an in-memory
* test fake satisfy the same shape.
*/
export interface DetectorStorageLike {
getItem(key: string): string | null;
setItem(key: string, value: string): void;
removeItem(key: string): void;
}
/**
* Per-call execution context handed to every detector. Sources of
* input (request headers, document, storage, fetch) are all optional
* so the same detector can run on browser and server contexts.
*/
export interface DetectorContext {
/** Server-side request shape (null on browser). */
readonly request?: {
readonly headers: Headers;
readonly url: URL;
};
/** Headers writer used by persisting detectors on the server (Set-Cookie). */
readonly responseHeaders?: Headers;
/** Browser document handle. */
readonly document?: Document;
/** Browser-side storage for `localStorage`-backed detectors. */
readonly storage?: DetectorStorageLike;
/** Injectable `fetch` so async detectors are testable. */
readonly fetch?: typeof fetch;
/** Cancellation signal threaded into async detectors. */
readonly signal?: AbortSignal;
/** The full set of locales the application supports. */
readonly supportedLocales: readonly string[];
/** Final fallback when nothing matched. */
readonly fallbackLocale: string;
}
/**
* Discriminator for *where* a detector can run. Sync detectors with
* `environment: 'server'` are skipped when running in the browser
* pass and vice-versa; `'both'` runs everywhere.
*/
export type DetectorEnvironment = "browser" | "server" | "both";
export interface LocaleDetector {
/** Stable detector identifier — appears in OTel labels and chain results. */
readonly name: string;
/** Runtime environments where this detector is active. */
readonly environment: DetectorEnvironment;
/** Synchronous detection. Return `null` to fall through to the next detector. */
detect?(ctx: DetectorContext): string | null;
/** Asynchronous detection (network, geo lookup). Return `null` to fall through. */
detectAsync?(ctx: DetectorContext): Promise<string | null>;
/** Persist a chosen locale back to this detector's source. */
cache?(locale: string, ctx: DetectorContext): void | Promise<void>;
}
/**
* Result returned by `createDetectorChain(...).detect(ctx)` describing
* which detector won and what locale was finally chosen
* (post-negotiation).
*/
export interface DetectorChainResult {
/** The negotiated locale (always one of `ctx.supportedLocales`). */
readonly locale: string;
/** Name of the detector whose value won the chain. */
readonly detector: string;
/** Pre-negotiation raw detector output (useful for telemetry). */
readonly rawValue: string | null;
}

View File

@@ -0,0 +1,86 @@
# `accept-language.ts` — `Accept-Language` HTTP header detector
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`acceptLanguageDetector()` and the standalone
`parseAcceptLanguage(header)` parser implement RFC 9110 §5.3.5
parsing of the inbound `Accept-Language` request header. The
detector returns the highest-q entry; `parseAcceptLanguage` is
exported for advanced consumers building custom detectors layered
on top of this one (e.g. honouring a specific browser's quirks, or
combining the header with a user-agent classifier).
The detector is `environment: "server"` because the header is
only meaningful for the inbound request — SSR and Edge runtimes
consume it; browsers do not see their own outbound headers. Any
detector chain running in either tier benefits from including
this entry behind cookie/localStorage so first-time visitors fall
through to a sensible default without configuration.
## Features
- RFC 9110 §5.3.5 q-value parsing with stable sort (insertion
order preserved on ties).
- Wildcard `*` tokens skipped — they carry no information that
can be negotiated.
- Malformed q values, q values out of range `[0, 1]`, and
unparseable parameters skipped per the RFC's "be liberal in
what you accept" guidance.
- Empty headers and missing `accept-language` return `null`.
- `environment: "server"` — automatic browser skip via the chain
runner.
## Usage: Basic Example
```ts
import { acceptLanguageDetector } from "@suaveplan/i18n-detector/detectors/accept-language";
const detector = acceptLanguageDetector();
const locale = detector.detect?.({
request: {
headers: new Headers({
"accept-language": "en;q=0.5, fr;q=0.9, de",
}),
url: new URL("https://example.com/"),
},
supportedLocales: ["en-GB", "fr-FR", "de-DE"],
fallbackLocale: "en-GB",
});
// locale === "de" (default q=1)
```
## Usage: Advanced Example
```ts
import { parseAcceptLanguage } from "@suaveplan/i18n-detector/detectors/accept-language";
const entries = parseAcceptLanguage(
"fr-FR;q=0.9, fr;q=0.8, en-GB;q=0.7, en;q=0.6"
);
// entries: [
// { locale: "fr-FR", q: 0.9 },
// { locale: "fr", q: 0.8 },
// { locale: "en-GB", q: 0.7 },
// { locale: "en", q: 0.6 },
// ]
```
## API Reference
- `acceptLanguageDetector(): LocaleDetector` — `environment: "server"`.
- `parseAcceptLanguage(header: string): AcceptLanguageEntry[]`.
- `AcceptLanguageEntry = { locale: string; q: number }`.
## Implementation Notes
- The sort is implemented by mapping `(entry, index)` pairs and
comparing `(b.q - a.q) || (a.index - b.index)` so ties preserve
insertion order — `Array.prototype.sort` is not guaranteed
stable on every runtime ECMA spec actively supports.
- Default q value is 1 when no `q=` parameter appears, per the
RFC.
- `q=2.0` and other out-of-range values cause the entire entry to
be discarded (the parser refuses to "fix" the value to 1 or 0).

View File

@@ -0,0 +1,129 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import type { DetectorContext } from "../../detector.types.js";
import {
acceptLanguageDetector,
parseAcceptLanguage,
} from "./accept-language.js";
const SUPPORTED = ["en-GB", "fr-FR", "de-DE"] as const;
function ctx(headers: HeadersInit): DetectorContext {
return {
request: {
headers: new Headers(headers),
url: new URL("https://example.com/"),
},
supportedLocales: SUPPORTED,
fallbackLocale: "en-GB",
};
}
describe("acceptLanguageDetector", () => {
it("returns highest-q entry", { timeout: 5000 }, () => {
const detector = acceptLanguageDetector();
expect(
detector.detect?.(
ctx({ "accept-language": "en;q=0.5, fr;q=0.9, de" })
)
).toBe("de");
});
it("missing header returns null", { timeout: 5000 }, () => {
const detector = acceptLanguageDetector();
expect(detector.detect?.(ctx({}))).toBeNull();
});
it("empty header returns null", { timeout: 5000 }, () => {
const detector = acceptLanguageDetector();
expect(detector.detect?.(ctx({ "accept-language": "" }))).toBeNull();
});
it("whitespace header returns null", { timeout: 5000 }, () => {
const detector = acceptLanguageDetector();
expect(detector.detect?.(ctx({ "accept-language": " " }))).toBeNull();
});
it("only wildcard returns null", { timeout: 5000 }, () => {
const detector = acceptLanguageDetector();
expect(
detector.detect?.(ctx({ "accept-language": "*;q=0.5" }))
).toBeNull();
});
it("environment is 'server'", { timeout: 5000 }, () => {
expect(acceptLanguageDetector().environment).toBe("server");
});
});
describe("parseAcceptLanguage", () => {
it("stable sort on q ties", { timeout: 5000 }, () => {
const result = parseAcceptLanguage("fr;q=0.8, de;q=0.8");
expect(result[0]?.locale).toBe("fr");
expect(result[1]?.locale).toBe("de");
});
it("wildcard skipped", { timeout: 5000 }, () => {
const result = parseAcceptLanguage("*;q=0.9, en;q=0.5");
expect(result.length).toBe(1);
expect(result[0]?.locale).toBe("en");
});
it("malformed q skipped", { timeout: 5000 }, () => {
const result = parseAcceptLanguage("fr;q=not-a-number, en;q=0.5");
expect(result.map((e) => e.locale)).toEqual(["en"]);
});
it("q out of range skipped (high)", { timeout: 5000 }, () => {
const result = parseAcceptLanguage("en;q=2.0, fr;q=0.5");
expect(result.map((e) => e.locale)).toEqual(["fr"]);
});
it("q out of range skipped (negative)", { timeout: 5000 }, () => {
const result = parseAcceptLanguage("en;q=-0.1, fr;q=0.5");
expect(result.map((e) => e.locale)).toEqual(["fr"]);
});
it("missing q defaults to 1", { timeout: 5000 }, () => {
const result = parseAcceptLanguage("fr, en;q=0.5");
expect(result[0]?.locale).toBe("fr");
expect(result[0]?.q).toBe(1);
});
it("empty segment skipped", { timeout: 5000 }, () => {
const result = parseAcceptLanguage(", fr, ,en;q=0.5,,");
expect(result.map((e) => e.locale)).toEqual(["fr", "en"]);
});
it("parameter without = skipped", { timeout: 5000 }, () => {
const result = parseAcceptLanguage("fr;weird, en");
expect(result.length).toBe(2);
// 'weird' has no = so it's ignored; q stays default 1.
});
it("parameter with key other than q skipped", {
timeout: 5000,
}, () => {
const result = parseAcceptLanguage("fr;extra=foo, en;q=0.5");
expect(result[0]?.q).toBe(1);
expect(result[0]?.locale).toBe("fr");
});
it("empty parameter inside semicolons skipped", {
timeout: 5000,
}, () => {
const result = parseAcceptLanguage("fr;;q=0.7");
expect(result[0]?.q).toBe(0.7);
});
it("empty tag part skipped", { timeout: 5000 }, () => {
const result = parseAcceptLanguage("; q=0.5, fr");
expect(result.map((e) => e.locale)).toEqual(["fr"]);
});
it("uppercase Q parameter respected (case-insensitive key)", {
timeout: 5000,
}, () => {
const result = parseAcceptLanguage("fr;Q=0.7");
expect(result[0]?.q).toBe(0.7);
});
});

View File

@@ -0,0 +1,81 @@
import type { LocaleDetector } from "../../detector.types.js";
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 the
* configured `negotiate` 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);
}
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 };
}

View File

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

View File

@@ -0,0 +1,5 @@
export {
acceptLanguageDetector,
parseAcceptLanguage,
} from "./accept-language.js";
export type { AcceptLanguageEntry } from "./accept-language.types.js";

View File

@@ -0,0 +1,87 @@
# `cloudflare-ip-geo.ts` — Cloudflare IP-geo detector
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`cloudflareIpGeoDetector(options?)` builds a `LocaleDetector` that
maps the Cloudflare `cf-ipcountry` header (ISO 3166-1 alpha-2) to
a BCP-47 locale. It is the lowest-precedence detector in the
canonical chain — only consulted when no querystring, cookie,
storage, or browser preference signal exists — because country
geolocation is at best a coarse approximation of language
preference (the UK and India both speak English; Switzerland has
four official languages).
Resolution order, per ISO code:
1. `options.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.
## Features
- Default header `"cf-ipcountry"`, configurable for non-Cloudflare
edge providers.
- Per-country override map wins over the data-derived default.
- `environment: "server"` — automatic browser skip via the chain
runner.
- Lowercased header values are upper-cased before lookup so
`Headers.get` casing quirks don't break resolution.
- Missing header, empty header, and unknown country codes all
return `null`.
## Usage: Basic Example
```ts
import { cloudflareIpGeoDetector } from "@suaveplan/i18n-detector/detectors/cloudflare-ip-geo";
const detector = cloudflareIpGeoDetector();
const locale = detector.detect?.({
request: {
headers: new Headers({ "cf-ipcountry": "AE" }),
url: new URL("https://example.com/"),
},
supportedLocales: ["en-GB", "ar-AE"],
fallbackLocale: "en-GB",
});
// locale === "ar-AE"
```
## Usage: Advanced Example
```ts
import { cloudflareIpGeoDetector } from "@suaveplan/i18n-detector/detectors/cloudflare-ip-geo";
const detector = cloudflareIpGeoDetector({
headerName: "x-vercel-ip-country",
countryToLocale: {
CH: "de-CH", // Swiss German default — overrides Latin order
BE: "nl-BE",
IN: "en-IN",
},
});
```
## API Reference
- `cloudflareIpGeoDetector(options?): LocaleDetector` —
`environment: "server"`, `name: "cloudflareIpGeo"`. Has only
`detect`; no `detectAsync`, no `cache`.
- `CloudflareIpGeoDetectorOptions.headerName?: string` — default
`"cf-ipcountry"`.
- `CloudflareIpGeoDetectorOptions.countryToLocale?: Readonly<Record<string, string>>`.
## Implementation Notes
- `getAllCountries()` is called once at detector-build time and
the result is captured in closure scope; subsequent `detect`
calls reuse the same record. Each detector instance therefore
has O(1) lookup cost.
- The `${primaryLanguage}-${code}` template is correct for the
vast majority of countries. The override map exists precisely
for the multilingual countries where a single primary language
would mis-serve users.

View File

@@ -0,0 +1,91 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import type { DetectorContext } from "../../detector.types.js";
import { cloudflareIpGeoDetector } from "./cloudflare-ip-geo.js";
const SUPPORTED = ["en-GB", "ar-AE", "en-AE"] as const;
function ctx(headers: HeadersInit): DetectorContext {
return {
request: {
headers: new Headers(headers),
url: new URL("https://example.com/"),
},
supportedLocales: SUPPORTED,
fallbackLocale: "en-GB",
};
}
describe("cloudflareIpGeoDetector", () => {
it("country code maps via locale-data (AE → ar-AE)", {
timeout: 5000,
}, () => {
const detector = cloudflareIpGeoDetector();
expect(detector.detect?.(ctx({ "cf-ipcountry": "AE" }))).toBe("ar-AE");
});
it("override map wins over locale-data", { timeout: 5000 }, () => {
const detector = cloudflareIpGeoDetector({
countryToLocale: { AE: "en-AE" },
});
expect(detector.detect?.(ctx({ "cf-ipcountry": "AE" }))).toBe("en-AE");
});
it("missing header returns null", { timeout: 5000 }, () => {
const detector = cloudflareIpGeoDetector();
expect(detector.detect?.(ctx({}))).toBeNull();
});
it("empty header returns null", { timeout: 5000 }, () => {
const detector = cloudflareIpGeoDetector();
expect(detector.detect?.(ctx({ "cf-ipcountry": "" }))).toBeNull();
});
it("whitespace header returns null", { timeout: 5000 }, () => {
const detector = cloudflareIpGeoDetector();
expect(detector.detect?.(ctx({ "cf-ipcountry": " " }))).toBeNull();
});
it("unknown country returns null", { timeout: 5000 }, () => {
const detector = cloudflareIpGeoDetector();
expect(detector.detect?.(ctx({ "cf-ipcountry": "ZZ" }))).toBeNull();
});
it("lowercase header value upper-cased", { timeout: 5000 }, () => {
const detector = cloudflareIpGeoDetector();
expect(detector.detect?.(ctx({ "cf-ipcountry": "ae" }))).toBe("ar-AE");
});
it("custom headerName honoured", { timeout: 5000 }, () => {
const detector = cloudflareIpGeoDetector({
headerName: "x-vercel-ip-country",
});
expect(detector.detect?.(ctx({ "x-vercel-ip-country": "AE" }))).toBe(
"ar-AE"
);
});
it("override with empty string falls back to locale-data", {
timeout: 5000,
}, () => {
const detector = cloudflareIpGeoDetector({
countryToLocale: { AE: "" },
});
expect(detector.detect?.(ctx({ "cf-ipcountry": "AE" }))).toBe("ar-AE");
});
it("missing request returns null (env gate would normally skip)", {
timeout: 5000,
}, () => {
const detector = cloudflareIpGeoDetector();
expect(
detector.detect?.({
supportedLocales: SUPPORTED,
fallbackLocale: "en-GB",
})
).toBeNull();
});
it("environment is 'server'", { timeout: 5000 }, () => {
expect(cloudflareIpGeoDetector().environment).toBe("server");
});
});

View File

@@ -0,0 +1,48 @@
import { getAllCountries } from "@suaveplan/locale-data";
import type { LocaleDetector } from "../../detector.types.js";
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;
const primaryLanguage = country.languages[0] as string;
return `${primaryLanguage}-${code}`;
},
};
}

View File

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

View File

@@ -0,0 +1,2 @@
export { cloudflareIpGeoDetector } from "./cloudflare-ip-geo.js";
export type { CloudflareIpGeoDetectorOptions } from "./cloudflare-ip-geo.types.js";

View File

@@ -0,0 +1,88 @@
# `cookie.ts` — cookie-backed locale detector
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`cookieDetector(options?)` builds a `LocaleDetector` that reads and
writes the active locale via a single cookie. On the server it
reads the inbound `Cookie` header from `ctx.request.headers` and
writes `Set-Cookie` to `ctx.responseHeaders`. On the browser it
reads and writes `document.cookie` directly. The detector is the
canonical persisted preference for unauthenticated visitors —
device-local but survives navigation, and works in both tiers
without further plumbing.
Cookie values are URL-encoded on write so locales containing non-
token characters (none in the BCP-47 subset, but a defensive
guard) cannot break the cookie wire format.
## Features
- `environment: "both"` — reads and writes on either tier.
- Default cookie name `"suaveplan_locale"`, configurable.
- Default `Max-Age` of 31_536_000s (one year), configurable.
- Default `SameSite=Lax`, configurable to `Strict` or `None`.
- Default `Path=/`, configurable.
- Server-side write goes through `Headers.append("set-cookie", …)`
so multiple cookies can coexist in one response.
- Missing both `ctx.request` and any `document` global causes
`detect` to return `null` and `cache` to no-op.
## Usage: Basic Example
```ts
import { cookieDetector } from "@suaveplan/i18n-detector/detectors/cookie";
const detector = cookieDetector();
const locale = detector.detect?.({
request: {
headers: new Headers({ cookie: "suaveplan_locale=fr-FR" }),
url: new URL("https://example.com/"),
},
supportedLocales: ["en-GB", "fr-FR"],
fallbackLocale: "en-GB",
});
// locale === "fr-FR"
```
## Usage: Advanced Example
```ts
import { cookieDetector } from "@suaveplan/i18n-detector/detectors/cookie";
const detector = cookieDetector({
name: "ui_locale",
maxAgeSeconds: 60 * 60 * 24 * 30, // 30 days
sameSite: "strict",
path: "/app",
});
await detector.cache?.("zh-Hant-TW", {
responseHeaders: new Headers(),
supportedLocales: ["en-GB", "zh-Hant-TW"],
fallbackLocale: "en-GB",
});
```
## API Reference
- `cookieDetector(options?): LocaleDetector`
- `CookieDetectorOptions.name?: string` — default `"suaveplan_locale"`.
- `CookieDetectorOptions.maxAgeSeconds?: number` — default `31536000`.
- `CookieDetectorOptions.sameSite?: "lax" | "strict" | "none"`.
- `CookieDetectorOptions.path?: string` — default `"/"`.
- `CookieSameSite` — alias for the SameSite union.
## Implementation Notes
- Cookie parsing splits on `;`, trims each segment, and looks up
the configured name only — other cookies in the header are
ignored.
- Decode failures (e.g. percent sequences without two hex digits)
surface the raw value rather than throwing. This matches browser
behaviour and keeps the detector resilient to legacy cookies.
- The `Secure` flag is intentionally NOT auto-added when
`SameSite=None`; that enforcement is tracked as the separate
`cookie-detector-secure-flag` change.

View File

@@ -0,0 +1,280 @@
import { afterEach, describe, expect, it } from "@suaveplan/testing/runner";
import type { DetectorContext } from "../../detector.types.js";
import { cookieDetector } from "./cookie.js";
const SUPPORTED = ["en-GB", "fr-FR", "ar-AE", "zh-Hant-TW"] as const;
function serverCtx(headers: HeadersInit = {}): DetectorContext {
return {
request: {
headers: new Headers(headers),
url: new URL("https://example.com/"),
},
responseHeaders: new Headers(),
supportedLocales: SUPPORTED,
fallbackLocale: "en-GB",
};
}
const originalDocument = (globalThis as { document?: unknown }).document;
afterEach(() => {
if (originalDocument === undefined) {
delete (globalThis as { document?: unknown }).document;
} else {
(globalThis as { document?: unknown }).document = originalDocument;
}
});
describe("cookieDetector — server reads", () => {
it("server reads inbound Cookie header", { timeout: 5000 }, () => {
const detector = cookieDetector();
const result = detector.detect?.(
serverCtx({ cookie: "suaveplan_locale=fr-FR; other=x" })
);
expect(result).toBe("fr-FR");
});
it("missing Cookie header returns null on server", {
timeout: 5000,
}, () => {
delete (globalThis as { document?: unknown }).document;
const detector = cookieDetector();
expect(detector.detect?.(serverCtx())).toBeNull();
});
it("URL-encoded cookie value is decoded on read", {
timeout: 5000,
}, () => {
const detector = cookieDetector();
expect(
detector.detect?.(
serverCtx({ cookie: "suaveplan_locale=zh-Hant-TW" })
)
).toBe("zh-Hant-TW");
});
it("malformed percent-encoding falls back to raw value", {
timeout: 5000,
}, () => {
const detector = cookieDetector();
// %FZ is invalid; decodeURIComponent throws → return raw.
expect(
detector.detect?.(serverCtx({ cookie: "suaveplan_locale=%FZ" }))
).toBe("%FZ");
});
it("segment without = is skipped", { timeout: 5000 }, () => {
const detector = cookieDetector();
expect(
detector.detect?.(
serverCtx({ cookie: "novalue; suaveplan_locale=fr-FR" })
)
).toBe("fr-FR");
});
it("empty cookie value returns null", { timeout: 5000 }, () => {
const detector = cookieDetector();
expect(
detector.detect?.(serverCtx({ cookie: "suaveplan_locale=" }))
).toBeNull();
});
it("cookie not present in header returns null", {
timeout: 5000,
}, () => {
delete (globalThis as { document?: unknown }).document;
const detector = cookieDetector();
expect(detector.detect?.(serverCtx({ cookie: "other=x" }))).toBeNull();
});
it("custom name is honoured", { timeout: 5000 }, () => {
const detector = cookieDetector({ name: "ui_locale" });
expect(
detector.detect?.(serverCtx({ cookie: "ui_locale=fr-FR" }))
).toBe("fr-FR");
});
it("empty Cookie header value falls through to document", {
timeout: 5000,
}, () => {
delete (globalThis as { document?: unknown }).document;
const detector = cookieDetector();
// An explicit empty cookie header — readCookieHeader sees ""
// and falls through to readDocument which is null.
const ctx: DetectorContext = {
request: {
headers: new Headers({ cookie: "" }),
url: new URL("https://example.com/"),
},
supportedLocales: SUPPORTED,
fallbackLocale: "en-GB",
};
expect(detector.detect?.(ctx)).toBeNull();
});
it("empty segments in cookie string are skipped", {
timeout: 5000,
}, () => {
const detector = cookieDetector();
expect(
detector.detect?.(
serverCtx({ cookie: ";; suaveplan_locale=fr-FR ;;" })
)
).toBe("fr-FR");
});
});
describe("cookieDetector — server writes", () => {
it("server writes Set-Cookie via responseHeaders", {
timeout: 5000,
}, () => {
const detector = cookieDetector();
const ctx = serverCtx();
detector.cache?.("fr-FR", ctx);
const setCookie = ctx.responseHeaders?.get("set-cookie");
expect(setCookie).toMatch(/^suaveplan_locale=fr-FR;/);
expect(setCookie).toContain("Max-Age=31536000");
expect(setCookie).toContain("Path=/");
expect(setCookie).toContain("SameSite=Lax");
});
it("SameSite=strict label is correct", { timeout: 5000 }, () => {
const detector = cookieDetector({ sameSite: "strict" });
const ctx = serverCtx();
detector.cache?.("fr-FR", ctx);
expect(ctx.responseHeaders?.get("set-cookie")).toContain(
"SameSite=Strict"
);
});
it("SameSite=none label is correct", { timeout: 5000 }, () => {
const detector = cookieDetector({ sameSite: "none" });
const ctx = serverCtx();
detector.cache?.("fr-FR", ctx);
expect(ctx.responseHeaders?.get("set-cookie")).toContain(
"SameSite=None"
);
});
it("URL-encoded value round-trips", { timeout: 5000 }, () => {
const detector = cookieDetector();
const ctx = serverCtx();
detector.cache?.("zh-Hant-TW", ctx);
const setCookie = ctx.responseHeaders?.get("set-cookie") ?? "";
// Replay the cookie back.
const replay: DetectorContext = {
request: {
headers: new Headers({ cookie: setCookie.split(";")[0] ?? "" }),
url: new URL("https://example.com/"),
},
supportedLocales: SUPPORTED,
fallbackLocale: "en-GB",
};
delete (globalThis as { document?: unknown }).document;
expect(detector.detect?.(replay)).toBe("zh-Hant-TW");
});
it("custom maxAgeSeconds and path emitted", {
timeout: 5000,
}, () => {
const detector = cookieDetector({
maxAgeSeconds: 60,
path: "/app",
});
const ctx = serverCtx();
detector.cache?.("fr-FR", ctx);
const setCookie = ctx.responseHeaders?.get("set-cookie") ?? "";
expect(setCookie).toContain("Max-Age=60");
expect(setCookie).toContain("Path=/app");
});
});
describe("cookieDetector — browser", () => {
it("browser reads document.cookie via ctx.document", {
timeout: 5000,
}, () => {
const fakeDoc = {
cookie: "suaveplan_locale=es",
} as unknown as Document;
const detector = cookieDetector();
const result = detector.detect?.({
document: fakeDoc,
supportedLocales: SUPPORTED,
fallbackLocale: "en-GB",
});
expect(result).toBe("es");
});
it("browser reads via globalThis.document fallback", {
timeout: 5000,
}, () => {
(globalThis as { document?: unknown }).document = {
cookie: "suaveplan_locale=es",
};
const detector = cookieDetector();
const result = detector.detect?.({
supportedLocales: SUPPORTED,
fallbackLocale: "en-GB",
});
expect(result).toBe("es");
});
it("browser writes via document.cookie", { timeout: 5000 }, () => {
let written = "";
const fakeDoc = {
get cookie() {
return "";
},
set cookie(value: string) {
written = value;
},
} as unknown as Document;
const detector = cookieDetector();
detector.cache?.("fr-FR", {
document: fakeDoc,
supportedLocales: SUPPORTED,
fallbackLocale: "en-GB",
});
expect(written).toMatch(/^suaveplan_locale=fr-FR;/);
});
it("missing both contexts: detect returns null, cache no-ops", {
timeout: 5000,
}, () => {
delete (globalThis as { document?: unknown }).document;
const detector = cookieDetector();
const ctx: DetectorContext = {
supportedLocales: SUPPORTED,
fallbackLocale: "en-GB",
};
expect(detector.detect?.(ctx)).toBeNull();
expect(() => detector.cache?.("fr-FR", ctx)).not.toThrow();
});
it("globalThis.document with non-string cookie returns null", {
timeout: 5000,
}, () => {
(globalThis as { document?: unknown }).document = { cookie: 42 };
const detector = cookieDetector();
expect(
detector.detect?.({
supportedLocales: SUPPORTED,
fallbackLocale: "en-GB",
})
).toBeNull();
});
it("globalThis.document not an object falls through", {
timeout: 5000,
}, () => {
(globalThis as { document?: unknown }).document = "string";
const detector = cookieDetector();
expect(
detector.detect?.({
supportedLocales: SUPPORTED,
fallbackLocale: "en-GB",
})
).toBeNull();
});
});

View File

@@ -0,0 +1,111 @@
import type { DetectorContext, LocaleDetector } from "../../detector.types.js";
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]}`;
}

View File

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

View File

@@ -0,0 +1,2 @@
export { cookieDetector } from "./cookie.js";
export type { CookieDetectorOptions, CookieSameSite } from "./cookie.types.js";

View File

@@ -0,0 +1,75 @@
# `fallback.ts` — unconditional fallback detector
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`fallbackDetector(locale)` builds a `LocaleDetector` that always
returns the configured locale. The detector is the floor of every
chain — when querystring, cookie, storage, header, navigator, and
geo all miss, the fallback guarantees a deterministic response.
Because the detector never returns `null`, it MUST appear last in
any chain — any detector after it would never run. The constructor
validates the supplied locale up-front and throws
`I18nError(I18N.INVALID_LOCALE)` on empty / whitespace-only input
so a misconfiguration surfaces at chain-build time rather than at
the first request.
## Features
- `environment: "both"` — eligible on every chain run.
- Constructor-time validation: empty string / whitespace string /
non-string locale all throw a typed `I18nError(INVALID_LOCALE)`.
- Locale value is trimmed once at construction; subsequent
`detect` calls reuse the cached value.
- Read-only — no `cache()` because the fallback has no source to
persist to.
## Usage: Basic Example
```ts
import { fallbackDetector } from "@suaveplan/i18n-detector/detectors/fallback";
const detector = fallbackDetector("en-GB");
const locale = detector.detect?.({
supportedLocales: ["en-GB", "fr-FR"],
fallbackLocale: "en-GB",
});
// locale === "en-GB"
```
## Usage: Advanced Example
```ts
import { I18nError } from "@suaveplan/i18n-shared";
import { fallbackDetector } from "@suaveplan/i18n-detector/detectors/fallback";
try {
fallbackDetector("");
} catch (err) {
if (err instanceof I18nError) {
console.error("misconfigured fallback:", err.code, err.message);
}
}
```
## API Reference
- `fallbackDetector(locale: string): LocaleDetector` —
`environment: "both"`, `name: "fallback"`. Has only `detect`;
no `detectAsync`, no `cache`.
- Throws `I18nError(I18N.INVALID_LOCALE)` synchronously on
empty / whitespace / non-string input.
## Implementation Notes
- Validation runs once at detector-build time, not on every
`detect` call — the chain pays no per-request cost for the
guard.
- The trimmed locale is captured by closure so the returned
detector is allocation-free per request.
- The fallback's `name` is fixed at `"fallback"` so chain
consumers can identify it deterministically (e.g. to render
a "default locale" warning in dev tools).

View File

@@ -0,0 +1,57 @@
import { I18nError } from "@suaveplan/i18n-shared";
import { describe, expect, it } from "@suaveplan/testing/runner";
import type { DetectorContext } from "../../detector.types.js";
import { fallbackDetector } from "./fallback.js";
const ctx: DetectorContext = {
supportedLocales: ["en-GB"],
fallbackLocale: "en-GB",
};
describe("fallbackDetector", () => {
it("returns configured locale", { timeout: 5000 }, () => {
const detector = fallbackDetector("en-GB");
expect(detector.detect?.(ctx)).toBe("en-GB");
});
it("trims surrounding whitespace", { timeout: 5000 }, () => {
const detector = fallbackDetector(" fr-FR ");
expect(detector.detect?.(ctx)).toBe("fr-FR");
});
it("empty locale throws INVALID_LOCALE", { timeout: 5000 }, () => {
expect(() => fallbackDetector("")).toThrow(I18nError);
});
it("empty locale throw carries I18N.INVALID_LOCALE code", {
timeout: 5000,
}, () => {
try {
fallbackDetector("");
throw new Error("should have thrown");
} catch (err) {
if (!(err instanceof I18nError)) throw err;
expect(err.code).toBe("I18N.INVALID_LOCALE");
}
});
it("whitespace-only locale throws INVALID_LOCALE", {
timeout: 5000,
}, () => {
expect(() => fallbackDetector(" ")).toThrow(I18nError);
});
it("non-string locale throws INVALID_LOCALE", { timeout: 5000 }, () => {
expect(() => fallbackDetector(null as unknown as string)).toThrow(
I18nError
);
});
it("environment is 'both' and name is 'fallback'", {
timeout: 5000,
}, () => {
const detector = fallbackDetector("en-GB");
expect(detector.environment).toBe("both");
expect(detector.name).toBe("fallback");
});
});

View File

@@ -0,0 +1,29 @@
import { I18N_CODES, I18nError } from "@suaveplan/i18n-shared";
import type { LocaleDetector } from "../../detector.types.js";
/**
* 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 as a typed `I18nError(I18N.INVALID_LOCALE)` rather than at
* the first chain run.
*/
export function fallbackDetector(locale: string): LocaleDetector {
if (typeof locale !== "string" || locale.trim() === "") {
throw new I18nError(
I18N_CODES.INVALID_LOCALE,
"fallbackDetector requires a non-empty locale string"
);
}
const value = locale.trim();
return {
name: "fallback",
environment: "both",
detect() {
return value;
},
};
}

View File

@@ -0,0 +1 @@
export { fallbackDetector } from "./fallback.js";

View File

@@ -0,0 +1,76 @@
# `html-lang.ts` — `<html lang>` detector
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`htmlLangDetector()` builds a `LocaleDetector` that reads
`document.documentElement.lang`. The detector is
`environment: "browser"` and never runs in SSR. Its niche is the
common case where a static HTML shell already declares
`<html lang="…">` — typically rendered by the SSR layer for
crawlers and accessibility — and the runtime surface wants to
honour that declaration without coupling to a specific framework.
This detector contributes a stable, framework-neutral signal that
sits between cookie/localStorage (device preferences) and
navigator (raw browser preferences). It is a read-only detector —
no `cache()` method — because the SSR layer is the source of
truth for the `<html lang>` attribute.
## Features
- `environment: "browser"` — automatic SSR skip via the chain
runner.
- Reads from `ctx.document` if supplied, else from
`globalThis.document`.
- Empty / whitespace-only `lang` attributes return `null`.
- Missing `documentElement` (impossible in real browsers but a
defensive guard for jsdom shims) returns `null`.
- No persistence — there is no `cache()` method.
## Usage: Basic Example
```ts
import { htmlLangDetector } from "@suaveplan/i18n-detector/detectors/html-lang";
const detector = htmlLangDetector();
const locale = detector.detect?.({
supportedLocales: ["en-GB", "fr-FR"],
fallbackLocale: "en-GB",
});
```
## Usage: Advanced Example
```ts
import { htmlLangDetector } from "@suaveplan/i18n-detector/detectors/html-lang";
const fakeDoc = {
documentElement: { lang: "fr-FR" },
cookie: "",
} as unknown as Document;
const detector = htmlLangDetector();
const locale = detector.detect?.({
document: fakeDoc,
supportedLocales: ["en-GB", "fr-FR"],
fallbackLocale: "en-GB",
});
```
## API Reference
- `htmlLangDetector(): LocaleDetector` — `environment: "browser"`,
`name: "htmlLang"`. Has only `detect`; no `detectAsync`, no
`cache`.
## Implementation Notes
- The detector tolerates a missing `globalThis.document` (e.g.
Workers / Edge runtimes that have a request-bound DOM shim)
without throwing.
- `root.lang` is asserted as `string` defensively — some custom
document polyfills expose `Element` instances whose `lang`
property is undefined.

View File

@@ -0,0 +1,92 @@
import { afterEach, describe, expect, it } from "@suaveplan/testing/runner";
import type { DetectorContext } from "../../detector.types.js";
import { htmlLangDetector } from "./html-lang.js";
const SUPPORTED = ["en-GB", "fr-FR", "es-ES"] as const;
const ctx: DetectorContext = {
supportedLocales: SUPPORTED,
fallbackLocale: "en-GB",
};
const originalDocument = (globalThis as { document?: unknown }).document;
afterEach(() => {
if (originalDocument === undefined) {
delete (globalThis as { document?: unknown }).document;
} else {
(globalThis as { document?: unknown }).document = originalDocument;
}
});
describe("htmlLangDetector", () => {
it("ctx.document lookup wins over global", { timeout: 5000 }, () => {
(globalThis as { document?: unknown }).document = {
documentElement: { lang: "es" },
};
const ctxDoc = {
documentElement: { lang: "fr" },
} as unknown as Document;
const detector = htmlLangDetector();
expect(detector.detect?.({ ...ctx, document: ctxDoc })).toBe("fr");
});
it("globalThis.document fallback", { timeout: 5000 }, () => {
(globalThis as { document?: unknown }).document = {
documentElement: { lang: "es" },
};
const detector = htmlLangDetector();
expect(detector.detect?.(ctx)).toBe("es");
});
it("missing document returns null", { timeout: 5000 }, () => {
delete (globalThis as { document?: unknown }).document;
const detector = htmlLangDetector();
expect(detector.detect?.(ctx)).toBeNull();
});
it("missing documentElement returns null", { timeout: 5000 }, () => {
(globalThis as { document?: unknown }).document = {};
const detector = htmlLangDetector();
expect(detector.detect?.(ctx)).toBeNull();
});
it("non-string lang returns null", { timeout: 5000 }, () => {
(globalThis as { document?: unknown }).document = {
documentElement: { lang: 123 },
};
const detector = htmlLangDetector();
expect(detector.detect?.(ctx)).toBeNull();
});
it("empty lang returns null", { timeout: 5000 }, () => {
(globalThis as { document?: unknown }).document = {
documentElement: { lang: "" },
};
const detector = htmlLangDetector();
expect(detector.detect?.(ctx)).toBeNull();
});
it("whitespace-only lang returns null", { timeout: 5000 }, () => {
(globalThis as { document?: unknown }).document = {
documentElement: { lang: " " },
};
const detector = htmlLangDetector();
expect(detector.detect?.(ctx)).toBeNull();
});
it("globalThis.document not an object falls through", {
timeout: 5000,
}, () => {
(globalThis as { document?: unknown }).document = "string";
const detector = htmlLangDetector();
expect(detector.detect?.(ctx)).toBeNull();
});
it("environment is 'browser' and name is 'htmlLang'", {
timeout: 5000,
}, () => {
const detector = htmlLangDetector();
expect(detector.environment).toBe("browser");
expect(detector.name).toBe("htmlLang");
});
});

View File

@@ -0,0 +1,38 @@
import type { DetectorContext, LocaleDetector } from "../../detector.types.js";
/**
* 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 runtime
* 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;
}

View File

@@ -0,0 +1 @@
export { htmlLangDetector } from "./html-lang.js";

View File

@@ -0,0 +1,5 @@
export { localStorageDetector } from "./local-storage.js";
export type {
LocalStorageDetectorOptions,
LocalStorageLike,
} from "./local-storage.types.js";

View File

@@ -0,0 +1,80 @@
# `local-storage.ts` — `localStorage`-backed locale detector
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`localStorageDetector(options?)` builds a `LocaleDetector` that
reads and writes the active locale from a `Storage`-shaped backing
store. The detector is `environment: "browser"` so the chain
runner automatically skips it during SSR. Browser-side persistence
sits between cookies (which round-trip on every request) and
backend-stored user preference (which requires authentication) —
ideal for unauthenticated single-page apps that want fast
client-side reads after the first visit without paying the cookie
header cost on every request.
## Features
- Default backing store is `globalThis.localStorage`.
- `options.storage` injects an alternative `LocalStorageLike`
(e.g. a session-storage handle, or an in-memory test fake).
- Default key `"suaveplan-locale"`, configurable.
- All storage access wrapped in `try/catch` — private mode,
disabled storage, and quota exhaustion all surface as `null` /
silent no-op.
- `environment: "browser"` — server runs skip the detector
entirely, so SSR contexts never need to inject a fake.
## Usage: Basic Example
```ts
import { localStorageDetector } from "@suaveplan/i18n-detector/detectors/local-storage";
const detector = localStorageDetector();
const locale = detector.detect?.({
supportedLocales: ["en-GB", "fr-FR"],
fallbackLocale: "en-GB",
});
```
## Usage: Advanced Example
```ts
import { localStorageDetector } from "@suaveplan/i18n-detector/detectors/local-storage";
const fake = new Map<string, string>();
const detector = localStorageDetector({
key: "app:locale",
storage: {
getItem: (k) => fake.get(k) ?? null,
setItem: (k, v) => {
fake.set(k, v);
},
removeItem: (k) => {
fake.delete(k);
},
},
});
await detector.cache?.("fr-FR", ctx);
```
## API Reference
- `localStorageDetector(options?): LocaleDetector`
- `LocalStorageDetectorOptions.key?: string` — default
`"suaveplan-locale"`.
- `LocalStorageDetectorOptions.storage?: LocalStorageLike`.
- `LocalStorageLike` — `{ getItem, setItem, removeItem }` shape.
## Implementation Notes
- `resolveStorage` checks both `getItem` and `setItem` are
functions before treating the candidate as a `LocalStorageLike`
to defend against custom shims that expose only one half of the
contract.
- Empty-string reads are treated as a miss — Safari has been known
to return `""` rather than `null` for unset keys in private mode.
- Cache failures (quota, private mode, disabled storage) are
swallowed; the detector NEVER throws into the consumer.

View File

@@ -0,0 +1,183 @@
import { afterEach, describe, expect, it } from "@suaveplan/testing/runner";
import type { DetectorContext } from "../../detector.types.js";
import { localStorageDetector } from "./local-storage.js";
const SUPPORTED = ["en-GB", "fr-FR", "es-ES"] as const;
const ctx: DetectorContext = {
supportedLocales: SUPPORTED,
fallbackLocale: "en-GB",
};
const originalLocalStorage = (globalThis as { localStorage?: unknown })
.localStorage;
afterEach(() => {
if (originalLocalStorage === undefined) {
delete (globalThis as { localStorage?: unknown }).localStorage;
} else {
(globalThis as { localStorage?: unknown }).localStorage =
originalLocalStorage;
}
});
describe("localStorageDetector", () => {
it("explicit storage option used over globalThis", {
timeout: 5000,
}, () => {
const map = new Map<string, string>([["suaveplan-locale", "fr-FR"]]);
const storage = {
getItem: (k: string) => map.get(k) ?? null,
setItem: (k: string, v: string) => {
map.set(k, v);
},
removeItem: (k: string) => {
map.delete(k);
},
};
const detector = localStorageDetector({ storage });
expect(detector.detect?.(ctx)).toBe("fr-FR");
});
it("globalThis.localStorage fallback", { timeout: 5000 }, () => {
const map = new Map<string, string>([["suaveplan-locale", "es-ES"]]);
(globalThis as { localStorage?: unknown }).localStorage = {
getItem: (k: string) => map.get(k) ?? null,
setItem: (k: string, v: string) => {
map.set(k, v);
},
removeItem: (k: string) => {
map.delete(k);
},
};
const detector = localStorageDetector();
expect(detector.detect?.(ctx)).toBe("es-ES");
});
it("missing storage returns null", { timeout: 5000 }, () => {
delete (globalThis as { localStorage?: unknown }).localStorage;
const detector = localStorageDetector();
expect(detector.detect?.(ctx)).toBeNull();
});
it("storage error caught silently", { timeout: 5000 }, () => {
const detector = localStorageDetector({
storage: {
getItem: () => {
throw new Error("private mode");
},
setItem: () => undefined,
removeItem: () => undefined,
},
});
expect(detector.detect?.(ctx)).toBeNull();
});
it("empty string from storage treated as null", {
timeout: 5000,
}, () => {
const detector = localStorageDetector({
storage: {
getItem: () => "",
setItem: () => undefined,
removeItem: () => undefined,
},
});
expect(detector.detect?.(ctx)).toBeNull();
});
it("non-string return from storage treated as null", {
timeout: 5000,
}, () => {
const detector = localStorageDetector({
storage: {
getItem: () => 42 as unknown as string,
setItem: () => undefined,
removeItem: () => undefined,
},
});
expect(detector.detect?.(ctx)).toBeNull();
});
it("custom key honoured", { timeout: 5000 }, () => {
const detector = localStorageDetector({
key: "app:locale",
storage: {
getItem: (k: string) => (k === "app:locale" ? "fr-FR" : null),
setItem: () => undefined,
removeItem: () => undefined,
},
});
expect(detector.detect?.(ctx)).toBe("fr-FR");
});
it("cache writes via setItem", { timeout: 5000 }, () => {
const writes: Array<[string, string]> = [];
const detector = localStorageDetector({
storage: {
getItem: () => null,
setItem: (k: string, v: string) => {
writes.push([k, v]);
},
removeItem: () => undefined,
},
});
detector.cache?.("fr-FR", ctx);
expect(writes).toEqual([["suaveplan-locale", "fr-FR"]]);
});
it("cache failure absorbed silently", { timeout: 5000 }, () => {
const detector = localStorageDetector({
storage: {
getItem: () => null,
setItem: () => {
throw new Error("quota");
},
removeItem: () => undefined,
},
});
expect(() => detector.cache?.("fr-FR", ctx)).not.toThrow();
});
it("cache no-ops when no storage available", {
timeout: 5000,
}, () => {
delete (globalThis as { localStorage?: unknown }).localStorage;
const detector = localStorageDetector();
expect(() => detector.cache?.("fr-FR", ctx)).not.toThrow();
});
it("globalThis throw on read returns null", { timeout: 5000 }, () => {
Object.defineProperty(globalThis, "localStorage", {
configurable: true,
get() {
throw new Error("blocked");
},
});
const detector = localStorageDetector();
try {
expect(detector.detect?.(ctx)).toBeNull();
} finally {
// Restore via afterEach restorer; configurable=true allows that.
Object.defineProperty(globalThis, "localStorage", {
configurable: true,
writable: true,
value: originalLocalStorage,
});
}
});
it("globalThis without setItem treated as missing", {
timeout: 5000,
}, () => {
(globalThis as { localStorage?: unknown }).localStorage = {
getItem: () => "fr-FR",
};
const detector = localStorageDetector();
expect(detector.detect?.(ctx)).toBeNull();
});
it("environment is 'browser'", { timeout: 5000 }, () => {
const detector = localStorageDetector();
expect(detector.environment).toBe("browser");
});
});

View File

@@ -0,0 +1,70 @@
import type { LocaleDetector } from "../../detector.types.js";
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 consumer.
*/
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;
}
}

View File

@@ -0,0 +1,27 @@
/**
* 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;
}

View File

@@ -0,0 +1 @@
export { navigatorDetector } from "./navigator.js";

View File

@@ -0,0 +1,85 @@
# `navigator.ts` — `navigator.languages` detector
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`navigatorDetector()` builds a `LocaleDetector` that reads the
active locale from the browser's
`globalThis.navigator.languages` (preferred) or
`globalThis.navigator.language` (fallback). The chain runner
negotiates the returned value against `ctx.supportedLocales` so
this detector contributes the user's raw browser preference and
lets the resolver perform BCP-47 fallback (so a user with
`["fr-CA", "en-US"]` browser settings still receives `fr-FR`
when only `fr-FR` is supported).
The detector is `environment: "browser"` and never runs in SSR.
It is the lowest-priority signal in the canonical chain because
browser preferences are coarse — they reflect OS-level locale
rather than per-application choice — but they are the strongest
signal available for first-time, unauthenticated visitors with
no cookies or localStorage.
## Features
- Reads `navigator.languages` array first (preferred — every
modern browser exposes it).
- Falls back to `navigator.language` when `languages` is missing
or empty.
- Skips whitespace-only entries; returns the first non-empty.
- Missing `navigator` global returns `null` (Workers, server
contexts that mistakenly bypass environment gating).
- `environment: "browser"` — server runs skip the detector
entirely.
## Usage: Basic Example
```ts
import { navigatorDetector } from "@suaveplan/i18n-detector/detectors/navigator";
const detector = navigatorDetector();
const locale = detector.detect?.({
supportedLocales: ["en-GB", "fr-FR"],
fallbackLocale: "en-GB",
});
```
## Usage: Advanced Example
```ts
import { navigatorDetector } from "@suaveplan/i18n-detector/detectors/navigator";
// Test fixture: stub navigator.
const original = (globalThis as { navigator?: unknown }).navigator;
(globalThis as { navigator?: unknown }).navigator = {
languages: ["fr-CA", "en-US"],
language: "fr-CA",
};
const detector = navigatorDetector();
const locale = detector.detect?.({
supportedLocales: ["en-GB", "fr-FR"],
fallbackLocale: "en-GB",
});
(globalThis as { navigator?: unknown }).navigator = original;
```
## API Reference
- `navigatorDetector(): LocaleDetector` — `environment: "browser"`,
`name: "navigator"`. Has only `detect`; no `detectAsync`, no
`cache`.
## Implementation Notes
- The detector NEVER throws even when `navigator` is partially
defined (only `language`, only `languages`, or neither). The
chain runner skips browser detectors on server runs anyway, but
the in-detector guards keep the behaviour predictable in
unusual runtime shims.
- Whitespace-only entries are skipped because some Android
browsers historically emitted `" "` as a sentinel for
"preference unset".

View File

@@ -0,0 +1,106 @@
import { afterEach, describe, expect, it } from "@suaveplan/testing/runner";
import type { DetectorContext } from "../../detector.types.js";
import { navigatorDetector } from "./navigator.js";
const ctx: DetectorContext = {
supportedLocales: ["en-GB", "fr-FR", "de-DE"],
fallbackLocale: "en-GB",
};
const originalDescriptor = Object.getOwnPropertyDescriptor(
globalThis,
"navigator"
);
function setNavigator(value: unknown): void {
Object.defineProperty(globalThis, "navigator", {
configurable: true,
writable: true,
value,
});
}
function deleteNavigator(): void {
Object.defineProperty(globalThis, "navigator", {
configurable: true,
writable: true,
value: undefined,
});
delete (globalThis as { navigator?: unknown }).navigator;
}
afterEach(() => {
if (originalDescriptor) {
Object.defineProperty(globalThis, "navigator", originalDescriptor);
} else {
deleteNavigator();
}
});
describe("navigatorDetector", () => {
it("navigator.languages preferred", { timeout: 5000 }, () => {
setNavigator({ languages: ["fr", "en"], language: "de" });
const detector = navigatorDetector();
expect(detector.detect?.(ctx)).toBe("fr");
});
it("navigator.language fallback", { timeout: 5000 }, () => {
setNavigator({ language: "es" });
const detector = navigatorDetector();
expect(detector.detect?.(ctx)).toBe("es");
});
it("whitespace entries skipped", { timeout: 5000 }, () => {
setNavigator({ languages: [" ", "fr"] });
const detector = navigatorDetector();
expect(detector.detect?.(ctx)).toBe("fr");
});
it("missing navigator returns null", { timeout: 5000 }, () => {
deleteNavigator();
const detector = navigatorDetector();
expect(detector.detect?.(ctx)).toBeNull();
});
it("navigator with neither languages nor language returns null", {
timeout: 5000,
}, () => {
setNavigator({});
const detector = navigatorDetector();
expect(detector.detect?.(ctx)).toBeNull();
});
it("empty languages array falls back to language", {
timeout: 5000,
}, () => {
setNavigator({ languages: [], language: "fr" });
const detector = navigatorDetector();
expect(detector.detect?.(ctx)).toBe("fr");
});
it("non-array languages and empty language returns null", {
timeout: 5000,
}, () => {
setNavigator({ languages: "not-an-array", language: "" });
const detector = navigatorDetector();
expect(detector.detect?.(ctx)).toBeNull();
});
it("only whitespace entries returns null", { timeout: 5000 }, () => {
setNavigator({ languages: [" ", " "] });
const detector = navigatorDetector();
expect(detector.detect?.(ctx)).toBeNull();
});
it("navigator value not an object falls through", {
timeout: 5000,
}, () => {
setNavigator("string");
const detector = navigatorDetector();
expect(detector.detect?.(ctx)).toBeNull();
});
it("environment is 'browser'", { timeout: 5000 }, () => {
expect(navigatorDetector().environment).toBe("browser");
});
});

View File

@@ -0,0 +1,50 @@
import type { LocaleDetector } from "../../detector.types.js";
interface NavigatorLanguageLike {
readonly languages?: ReadonlyArray<string>;
readonly language?: string;
}
/**
* 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;
},
};
}
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 [];
}

View File

@@ -0,0 +1,2 @@
export { querystringDetector } from "./querystring.js";
export type { QuerystringDetectorOptions } from "./querystring.types.js";

View File

@@ -0,0 +1,77 @@
# `querystring.ts` — `?lang=` querystring detector
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`querystringDetector(options?)` builds a `LocaleDetector` that reads
the active locale from a URL search parameter. It runs in both the
browser tier (consulting `globalThis.location.search`) and the
server tier (consulting `ctx.request.url.searchParams`), so the
same detector entry can sit at the top of every chain regardless
of where the chain is executed. Querystring detection is the
highest-precedence signal because it represents an explicit user
override (a tester switching locale via the URL bar, an outbound
link from a translated page) that should beat every persisted
preference.
When neither the request nor the browser global is available the
detector falls through with `null`, letting the chain advance to
the next entry.
## Features
- Default param name `"lang"`, configurable via
`options.paramName`.
- Server-tier read via `ctx.request.url.searchParams.get(...)`.
- Browser-tier read via `URLSearchParams(globalThis.location.search)`.
- Empty values and missing globals return `null`, never throw.
- `environment: "both"` — eligible on every chain run.
## Usage: Basic Example
```ts
import { querystringDetector } from "@suaveplan/i18n-detector/detectors/querystring";
const detector = querystringDetector();
const result = detector.detect?.({
request: {
headers: new Headers(),
url: new URL("https://example.com/?lang=fr"),
},
supportedLocales: ["en-GB", "fr-FR"],
fallbackLocale: "en-GB",
});
// result === "fr"
```
## Usage: Advanced Example
```ts
import {
createDetectorChain,
querystringDetector,
cookieDetector,
} from "@suaveplan/i18n-detector";
const chain = createDetectorChain([
querystringDetector({ paramName: "ui-lang" }),
cookieDetector(),
]);
```
## API Reference
- `querystringDetector(options?: QuerystringDetectorOptions): LocaleDetector`
- `QuerystringDetectorOptions.paramName?: string` — default `"lang"`.
## Implementation Notes
- The browser fallback uses `URLSearchParams` rather than parsing
manually to keep the detector free of edge-case bugs (encoded
characters, repeated keys, etc.).
- The server reader trusts `ctx.request.url` to be a parsed `URL`
instance — request adapters at the edge construct it once and
hand it through every detector, which is significantly faster
than re-parsing per detector call.

View File

@@ -0,0 +1,112 @@
import { afterEach, describe, expect, it } from "@suaveplan/testing/runner";
import type { DetectorContext } from "../../detector.types.js";
import { querystringDetector } from "./querystring.js";
const SUPPORTED = ["en-GB", "fr-FR", "de-DE"] as const;
function serverCtx(url: string): DetectorContext {
return {
request: { headers: new Headers(), url: new URL(url) },
supportedLocales: SUPPORTED,
fallbackLocale: "en-GB",
};
}
function browserCtx(): DetectorContext {
return { supportedLocales: SUPPORTED, fallbackLocale: "en-GB" };
}
const originalLocation = (globalThis as { location?: unknown }).location;
afterEach(() => {
if (originalLocation === undefined) {
delete (globalThis as { location?: unknown }).location;
} else {
(globalThis as { location?: unknown }).location = originalLocation;
}
});
describe("querystringDetector", () => {
it("server context resolves from request URL", {
timeout: 5000,
}, () => {
const detector = querystringDetector();
expect(
detector.detect?.(serverCtx("https://example.com/?lang=fr"))
).toBe("fr");
});
it("browser context resolves from globalThis.location", {
timeout: 5000,
}, () => {
(globalThis as { location?: { search: string } }).location = {
search: "?lang=es",
};
const detector = querystringDetector();
expect(detector.detect?.(browserCtx())).toBe("es");
});
it("missing param returns null", { timeout: 5000 }, () => {
const detector = querystringDetector();
expect(detector.detect?.(serverCtx("https://example.com/"))).toBeNull();
});
it("custom paramName honoured", { timeout: 5000 }, () => {
const detector = querystringDetector({ paramName: "locale" });
expect(
detector.detect?.(serverCtx("https://example.com/?locale=de"))
).toBe("de");
});
it("empty value returns null and falls through to global", {
timeout: 5000,
}, () => {
delete (globalThis as { location?: unknown }).location;
const detector = querystringDetector();
expect(
detector.detect?.(serverCtx("https://example.com/?lang="))
).toBeNull();
});
it("missing both contexts returns null", { timeout: 5000 }, () => {
delete (globalThis as { location?: unknown }).location;
const detector = querystringDetector();
expect(detector.detect?.(browserCtx())).toBeNull();
});
it("browser global with non-string search returns null", {
timeout: 5000,
}, () => {
(globalThis as { location?: unknown }).location = { search: 42 };
const detector = querystringDetector();
expect(detector.detect?.(browserCtx())).toBeNull();
});
it("browser global with empty search returns null", {
timeout: 5000,
}, () => {
(globalThis as { location?: { search: string } }).location = {
search: "",
};
const detector = querystringDetector();
expect(detector.detect?.(browserCtx())).toBeNull();
});
it("browser global present but param missing returns null", {
timeout: 5000,
}, () => {
(globalThis as { location?: { search: string } }).location = {
search: "?other=x",
};
const detector = querystringDetector();
expect(detector.detect?.(browserCtx())).toBeNull();
});
it("environment is 'both' and name is 'querystring'", {
timeout: 5000,
}, () => {
const detector = querystringDetector();
expect(detector.environment).toBe("both");
expect(detector.name).toBe("querystring");
});
});

View File

@@ -0,0 +1,44 @@
import type { LocaleDetector } from "../../detector.types.js";
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 `globalThis.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;
},
};
}

View File

@@ -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"`. Consumers SHOULD only override when integrating
* with a host application that already uses a different name
* (e.g. `?locale=...` or `?ui-lang=...`).
*/
readonly paramName?: string;
}

View File

@@ -0,0 +1,5 @@
export { userPreferenceDetector } from "./user-preference.js";
export type {
UserPreferenceDetectorOptions,
UserPreferenceResponseBody,
} from "./user-preference.types.js";

View File

@@ -0,0 +1,86 @@
# `user-preference.ts` — backend-stored locale preference detector
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`userPreferenceDetector(options)` builds an async `LocaleDetector`
that calls a backend endpoint to read (and later persist) the
user's stored locale preference. This is the surface authenticated
applications use to honour a user's saved-to-account choice across
devices, ahead of cookie/localStorage which are device-bound.
Unlike most detectors in this package, user-preference is async-
only — it has no `detect` and only contributes during the chain's
async race phase. It runs in both server and browser tiers because
the endpoint is typically a thin JSON API that accepts the
session's auth header from either side.
## Features
- GET `options.endpoint` returning `{ locale }`; non-2xx,
malformed, and rejected responses log a warn and return `null`.
- POST `options.endpoint` with `{ locale }` JSON on
`cache(locale, ctx)`; failures are non-fatal.
- `options.extractAuth(ctx)` injects per-request auth headers
(e.g. session cookie on SSR runs).
- `options.fetch` injectable for tests.
- `options.logger?: LoggerLike` — defaults to a no-op so the
package keeps zero non-shared deps. Production consumers wire
in `@suaveplan/logger`.
- Threads `ctx.signal` into every fetch's `RequestInit.signal`.
## Usage: Basic Example
```ts
import { userPreferenceDetector } from "@suaveplan/i18n-detector/detectors/user-preference";
const detector = userPreferenceDetector({
endpoint: "/api/me/locale",
});
const locale = await detector.detectAsync?.({
supportedLocales: ["en-GB", "fr-FR"],
fallbackLocale: "en-GB",
});
```
## Usage: Advanced Example
```ts
import { userPreferenceDetector } from "@suaveplan/i18n-detector/detectors/user-preference";
import { ConsoleLogger } from "@suaveplan/logger";
const detector = userPreferenceDetector({
endpoint: "/api/me/locale",
logger: new ConsoleLogger({ tags: ["i18n.user-preference"] }),
extractAuth: (ctx) => ({
cookie: ctx.request?.headers.get("cookie") ?? "",
}),
fetch: globalThis.fetch,
});
await detector.cache?.("fr-FR", ctx);
```
## API Reference
- `userPreferenceDetector(options): LocaleDetector` — `environment: "both"`.
- `UserPreferenceDetectorOptions.endpoint: string` — required.
- `UserPreferenceDetectorOptions.extractAuth?: (ctx) => HeadersInit`.
- `UserPreferenceDetectorOptions.cacheKey?: (ctx) => string`.
- `UserPreferenceDetectorOptions.fetch?: typeof fetch`.
- `UserPreferenceDetectorOptions.logger?: LoggerLike`.
- `UserPreferenceResponseBody = { locale: string }` — wire shape.
## Implementation Notes
- A pre-aborted `ctx.signal` typically causes the underlying
`fetch` to reject; the rejection is swallowed and `null` is
returned, never propagated into the chain.
- `Content-Type: application/json` is set automatically on cache
POSTs unless the auth headers already declare one.
- The `cacheKey` field is exposed for consumers that want
request-level dedup but is intentionally not consulted by the
detector itself — caching is the consumer's concern.

View File

@@ -0,0 +1,363 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import type { LoggerLike } from "@suaveplan/types";
import type { DetectorContext } from "../../detector.types.js";
import { userPreferenceDetector } from "./user-preference.js";
const SUPPORTED = ["en-GB", "fr-FR", "de-DE", "ar-AE"] as const;
function serverCtx(headers: HeadersInit = {}): DetectorContext {
return {
request: {
headers: new Headers(headers),
url: new URL("https://example.com/"),
},
responseHeaders: new Headers(),
supportedLocales: SUPPORTED,
fallbackLocale: "en-GB",
};
}
function browserCtx(signal?: AbortSignal): DetectorContext {
const ctx: DetectorContext = {
supportedLocales: SUPPORTED,
fallbackLocale: "en-GB",
};
if (signal) {
return { ...ctx, signal };
}
return ctx;
}
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 };
}
function captureLogger(): {
logger: LoggerLike;
warns: Array<[string, Readonly<Record<string, unknown>> | undefined]>;
} {
const warns: Array<
[string, Readonly<Record<string, unknown>> | undefined]
> = [];
const logger: LoggerLike = {
debug: () => undefined,
info: () => undefined,
warn: (msg, ctx) => {
warns.push([msg, ctx]);
},
error: () => undefined,
};
return { logger, warns };
}
describe("userPreferenceDetector — detectAsync", () => {
it("happy GET returns body locale", { timeout: 5000 }, async () => {
const { fn, calls } = recordingFetch(
() => new Response(JSON.stringify({ locale: "fr-FR" }))
);
const detector = userPreferenceDetector({
endpoint: "/api/me/locale",
fetch: fn,
});
const result = await detector.detectAsync?.(browserCtx());
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");
});
it("non-2xx returns null and logs warn", { timeout: 5000 }, async () => {
const { fn } = recordingFetch(
() => new Response("nope", { status: 503 })
);
const { logger, warns } = captureLogger();
const detector = userPreferenceDetector({
endpoint: "/api/me/locale",
fetch: fn,
logger,
});
const result = await detector.detectAsync?.(browserCtx());
expect(result).toBeNull();
expect(warns.length).toBe(1);
expect(warns[0]?.[0]).toContain("non-ok");
});
it("rejected fetch returns null and logs warn", {
timeout: 5000,
}, async () => {
const { logger, warns } = captureLogger();
const detector = userPreferenceDetector({
endpoint: "/api/me/locale",
fetch: (async () => {
throw new Error("network down");
}) as unknown as typeof fetch,
logger,
});
const result = await detector.detectAsync?.(browserCtx());
expect(result).toBeNull();
expect(warns.length).toBe(1);
expect(warns[0]?.[0]).toContain("fetch failed");
});
it("rejected fetch with non-Error rejection logs string form", {
timeout: 5000,
}, async () => {
const { logger, warns } = captureLogger();
const detector = userPreferenceDetector({
endpoint: "/api/me/locale",
fetch: (() =>
Promise.reject("string-rejection")) as unknown as typeof fetch,
logger,
});
const result = await detector.detectAsync?.(browserCtx());
expect(result).toBeNull();
const ctxArg = warns[0]?.[1];
expect(ctxArg?.error).toBe("string-rejection");
});
it("body missing locale returns null", { timeout: 5000 }, async () => {
const { fn } = recordingFetch(() => new Response(JSON.stringify({})));
const { logger, warns } = captureLogger();
const detector = userPreferenceDetector({
endpoint: "/api/me/locale",
fetch: fn,
logger,
});
const result = await detector.detectAsync?.(browserCtx());
expect(result).toBeNull();
expect(warns[0]?.[0]).toContain("missing locale");
});
it("body with empty locale returns null", { 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?.(browserCtx());
expect(result).toBeNull();
});
it("AbortSignal threaded into fetch", { timeout: 5000 }, async () => {
const { fn, calls } = recordingFetch(
() => new Response(JSON.stringify({ locale: "fr-FR" }))
);
const ctrl = new AbortController();
const detector = userPreferenceDetector({
endpoint: "/api/me/locale",
fetch: fn,
});
await detector.detectAsync?.(browserCtx(ctrl.signal));
expect(calls[0]?.init?.signal).toBe(ctrl.signal);
});
it("pre-aborted signal returns null without throw", {
timeout: 5000,
}, async () => {
const ctrl = new AbortController();
ctrl.abort();
const detector = userPreferenceDetector({
endpoint: "/api/me/locale",
fetch: (async () => {
throw new DOMException("aborted", "AbortError");
}) as unknown as typeof fetch,
});
const result = await detector.detectAsync?.(browserCtx(ctrl.signal));
expect(result).toBeNull();
});
it("extractAuth invoked and headers threaded", {
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,
});
await detector.detectAsync?.(serverCtx({ cookie: "session=abc" }));
const headers = new Headers(calls[0]?.init?.headers);
expect(headers.get("cookie")).toBe("session=abc");
});
it("default no-op logger swallows warn on rejected fetch", {
timeout: 5000,
}, async () => {
// No `logger` option — exercises the NOOP_LOGGER branch.
const detector = userPreferenceDetector({
endpoint: "/api/me/locale",
fetch: (async () => {
throw new Error("network");
}) as unknown as typeof fetch,
});
const result = await detector.detectAsync?.(browserCtx());
expect(result).toBeNull();
});
it("default fetch falls through to globalThis.fetch", {
timeout: 5000,
}, async () => {
const original = globalThis.fetch;
const stub = (async () =>
new Response(
JSON.stringify({ locale: "ar-AE" })
)) as unknown as typeof fetch;
globalThis.fetch = stub;
try {
const detector = userPreferenceDetector({
endpoint: "/api/me/locale",
});
const result = await detector.detectAsync?.(browserCtx());
expect(result).toBe("ar-AE");
} finally {
globalThis.fetch = original;
}
});
});
describe("userPreferenceDetector — cache", () => {
it("cache POST round-trips body and content-type", {
timeout: 5000,
}, async () => {
const { fn, calls } = recordingFetch(
() => new Response("ok", { status: 200 })
);
const detector = userPreferenceDetector({
endpoint: "/api/me/locale",
fetch: fn,
});
await detector.cache?.("fr-FR", browserCtx());
expect(calls[0]?.init?.method).toBe("POST");
expect(calls[0]?.init?.body).toBe('{"locale":"fr-FR"}');
const headers = new Headers(calls[0]?.init?.headers);
expect(headers.get("content-type")).toBe("application/json");
});
it("cache failure on non-2xx is non-fatal but logged", {
timeout: 5000,
}, async () => {
const { fn } = recordingFetch(
() => new Response("nope", { status: 503 })
);
const { logger, warns } = captureLogger();
const detector = userPreferenceDetector({
endpoint: "/api/me/locale",
fetch: fn,
logger,
});
await expect(
detector.cache?.("fr-FR", browserCtx())
).resolves.toBeUndefined();
expect(warns.length).toBe(1);
expect(warns[0]?.[0]).toContain("cache failed");
});
it("cache rejected fetch is non-fatal but logged", {
timeout: 5000,
}, async () => {
const { logger, warns } = captureLogger();
const detector = userPreferenceDetector({
endpoint: "/api/me/locale",
fetch: (async () => {
throw new Error("network");
}) as unknown as typeof fetch,
logger,
});
await expect(
detector.cache?.("fr-FR", browserCtx())
).resolves.toBeUndefined();
expect(warns[0]?.[0]).toContain("cache threw");
});
it("cache rejected with non-Error logs string form", {
timeout: 5000,
}, async () => {
const { logger, warns } = captureLogger();
const detector = userPreferenceDetector({
endpoint: "/api/me/locale",
fetch: (() => Promise.reject("nope")) as unknown as typeof fetch,
logger,
});
await detector.cache?.("fr-FR", browserCtx());
expect(warns[0]?.[1]?.error).toBe("nope");
});
it("cache threads AbortSignal", { timeout: 5000 }, async () => {
const { fn, calls } = recordingFetch(
() => new Response("ok", { status: 200 })
);
const ctrl = new AbortController();
const detector = userPreferenceDetector({
endpoint: "/api/me/locale",
fetch: fn,
});
await detector.cache?.("fr-FR", browserCtx(ctrl.signal));
expect(calls[0]?.init?.signal).toBe(ctrl.signal);
});
it("cache merges custom auth headers without dropping content-type", {
timeout: 5000,
}, async () => {
const { fn, calls } = recordingFetch(
() => new Response("ok", { status: 200 })
);
const detector = userPreferenceDetector({
endpoint: "/api/me/locale",
extractAuth: () => ({ authorization: "Bearer x" }),
fetch: fn,
});
await detector.cache?.("fr-FR", browserCtx());
const headers = new Headers(calls[0]?.init?.headers);
expect(headers.get("authorization")).toBe("Bearer x");
expect(headers.get("content-type")).toBe("application/json");
});
it("cache preserves caller-supplied content-type", {
timeout: 5000,
}, async () => {
const { fn, calls } = recordingFetch(
() => new Response("ok", { status: 200 })
);
const detector = userPreferenceDetector({
endpoint: "/api/me/locale",
extractAuth: () => ({ "Content-Type": "application/x-custom" }),
fetch: fn,
});
await detector.cache?.("fr-FR", browserCtx());
const headers = new Headers(calls[0]?.init?.headers);
expect(headers.get("content-type")).toBe("application/x-custom");
});
});
describe("userPreferenceDetector — shape", () => {
it("environment is 'both' and name is 'userPreference'", {
timeout: 5000,
}, () => {
const detector = userPreferenceDetector({
endpoint: "/api/me/locale",
});
expect(detector.environment).toBe("both");
expect(detector.name).toBe("userPreference");
expect(detector.detect).toBeUndefined();
});
});

View File

@@ -0,0 +1,114 @@
import type { LoggerLike } from "@suaveplan/types";
import type { DetectorContext, LocaleDetector } from "../../detector.types.js";
import type {
UserPreferenceDetectorOptions,
UserPreferenceResponseBody,
} from "./user-preference.types.js";
const noop = (): void => undefined;
const NOOP_LOGGER: LoggerLike = {
debug: noop,
info: noop,
warn: noop,
error: noop,
};
/**
* 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 the
* injected `LoggerLike` (defaults to a no-op); 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 consumer.
*/
export function userPreferenceDetector(
options: UserPreferenceDetectorOptions
): LocaleDetector {
const fetcher: typeof fetch =
options.fetch ?? ((...args) => globalThis.fetch(...args));
const logger: LoggerLike = options.logger ?? NOOP_LOGGER;
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 {};
}

View File

@@ -0,0 +1,49 @@
import type { LoggerLike } from "@suaveplan/types";
import type { DetectorContext } from "../../detector.types.js";
/**
* Configuration accepted by `userPreferenceDetector`. The endpoint is
* required; the auth/cache-key/fetch/logger 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;
/**
* Injectable logger. Defaults to a no-op so the package keeps
* zero non-shared dependencies. Production consumers SHOULD
* inject a `@suaveplan/logger` instance so failed lookups surface
* via the same observability pipeline as the rest of the stack.
*/
readonly logger?: LoggerLike;
}
/**
* 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;
}

View File

@@ -0,0 +1,78 @@
# `direction.ts` — text-direction helpers
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`getTextDirection(locale)` and `isRtl(locale)` resolve a locale's
display direction without instantiating a full locale resolver or
booting the slim core. They are pure functions over `RTL_LOCALES`
from `@suaveplan/i18n-shared` and complete in O(1) — perfect for use
in render hot paths (e.g. `<html dir={getTextDirection(locale)}>`)
where every microsecond saved compounds across SSR fan-out.
The helpers ship in `@suaveplan/i18n-detector` rather than the slim
core because direction lookup is logically part of the locale-
resolution surface — every consumer that detects a locale needs to
know which direction to render — and pulling them here keeps the
slim core lighter.
## Features
- `getTextDirection(locale)` returns `"ltr"` or `"rtl"` for any
BCP-47 locale tag.
- `isRtl(locale)` is sugar for `getTextDirection(locale) === "rtl"`.
- Region subtags are tolerated: `"ar-SA"`, `"he-IL"`, `"fa-IR"` all
resolve to `"rtl"`.
- Lookup is case-insensitive — the language subtag is lower-cased
before the `RTL_LOCALES` set probe.
- Unknown locales default to `"ltr"`, matching browser behaviour.
## Usage: Basic Example
```ts
import { getTextDirection, isRtl } from "@suaveplan/i18n-detector/direction";
getTextDirection("ar"); // "rtl"
getTextDirection("ar-SA"); // "rtl"
getTextDirection("en-GB"); // "ltr"
isRtl("he-IL"); // true
```
## Usage: Advanced Example
```tsx
import { getTextDirection } from "@suaveplan/i18n-detector/direction";
export function HtmlShell({
locale,
children,
}: {
locale: string;
children: React.ReactNode;
}) {
return (
<html lang={locale} dir={getTextDirection(locale)}>
{children}
</html>
);
}
```
## API Reference
- `getTextDirection(locale: string): "ltr" | "rtl"` — pure.
- `isRtl(locale: string): boolean` — pure.
- `type TextDirection = "ltr" | "rtl"` — re-exported for consumer
type-safety.
## Implementation Notes
- The language subtag is everything before the first `-`. Multi-tag
locales (`zh-Hant-TW`) resolve via the first segment only — script
subtags are ignored because every script-bearing RTL locale is
already covered by its language subtag.
- Adding a new RTL language is a one-line change in
`@suaveplan/i18n-shared/constants/rtl-locales.ts` — no detector
code needs to be touched.

View File

@@ -0,0 +1,60 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { getTextDirection, isRtl } from "./direction.js";
describe("getTextDirection", () => {
it("Arabic is RTL", { timeout: 5000 }, () => {
expect(getTextDirection("ar")).toBe("rtl");
});
it("Arabic with region subtag is RTL", { timeout: 5000 }, () => {
expect(getTextDirection("ar-SA")).toBe("rtl");
});
it("Hebrew is RTL", { timeout: 5000 }, () => {
expect(getTextDirection("he-IL")).toBe("rtl");
});
it("Persian is RTL", { timeout: 5000 }, () => {
expect(getTextDirection("fa-IR")).toBe("rtl");
});
it("English is LTR", { timeout: 5000 }, () => {
expect(getTextDirection("en-GB")).toBe("ltr");
});
it("French is LTR", { timeout: 5000 }, () => {
expect(getTextDirection("fr-FR")).toBe("ltr");
});
it("uppercase language subtag still RTL", { timeout: 5000 }, () => {
expect(getTextDirection("AR-SA")).toBe("rtl");
});
it("unknown locale defaults to LTR", { timeout: 5000 }, () => {
expect(getTextDirection("xx-YY")).toBe("ltr");
});
});
describe("isRtl", () => {
it("returns true for Arabic", { timeout: 5000 }, () => {
expect(isRtl("ar")).toBe(true);
});
it("returns false for English", { timeout: 5000 }, () => {
expect(isRtl("en-GB")).toBe(false);
});
it("isRtl mirrors getTextDirection", { timeout: 5000 }, () => {
for (const locale of [
"ar",
"he-IL",
"fr-FR",
"en",
"ur-PK",
"zh-CN",
"yi",
]) {
expect(isRtl(locale)).toBe(getTextDirection(locale) === "rtl");
}
});
});

View File

@@ -0,0 +1,35 @@
import { RTL_LOCALES } from "@suaveplan/i18n-shared";
/**
* Text direction returned by {@link getTextDirection}.
*
* Inlined here (rather than imported from the upstream
* `middleware/server/server.types.ts`) so the detector package stays
* decoupled from the i18n monolith. Slim core (Wave 6) re-exports
* this same alias from `@suaveplan/i18n` for back-compat.
*/
export type TextDirection = "ltr" | "rtl";
/**
* Return the text direction for a given locale code.
*
* The lookup uses the language subtag (everything before the first
* `-`) lowercased so region-bearing tags like `"ar-SA"` and
* `"he-IL"` resolve correctly. Unknown locales default to `"ltr"`.
*/
export function getTextDirection(locale: string): TextDirection {
const tag = locale.split("-");
const lang = (tag[0] as string).toLowerCase();
return RTL_LOCALES.has(lang) ? "rtl" : "ltr";
}
/**
* Return `true` if the locale uses right-to-left text direction.
*
* Equivalent to `getTextDirection(locale) === "rtl"` and provided as
* a sugar so consumers can write `if (isRtl(locale))` without the
* additional comparison.
*/
export function isRtl(locale: string): boolean {
return getTextDirection(locale) === "rtl";
}

View File

@@ -0,0 +1,5 @@
export type { TextDirection } from "./direction.js";
export {
getTextDirection,
isRtl,
} from "./direction.js";

View File

@@ -0,0 +1,127 @@
import { readFileSync } from "node:fs";
import { fileURLToPath } from "node:url";
import { describe, expect, it } from "@suaveplan/testing/runner";
import * as root from "./index.js";
describe("@suaveplan/i18n-detector — root barrel", () => {
it("re-exports createDetectorChain", { timeout: 5000 }, () => {
expect(typeof root.createDetectorChain).toBe("function");
});
it("re-exports DETECTOR_CHAIN_UNRESOLVED", { timeout: 5000 }, () => {
expect(typeof root.DETECTOR_CHAIN_UNRESOLVED).toBe("symbol");
});
it("re-exports bcp47Negotiate", { timeout: 5000 }, () => {
expect(typeof root.bcp47Negotiate).toBe("function");
});
it("re-exports getTextDirection and isRtl", { timeout: 5000 }, () => {
expect(root.getTextDirection("ar")).toBe("rtl");
expect(root.isRtl("en-GB")).toBe(false);
});
it("re-exports every detector factory", { timeout: 5000 }, () => {
expect(typeof root.querystringDetector).toBe("function");
expect(typeof root.userPreferenceDetector).toBe("function");
expect(typeof root.cookieDetector).toBe("function");
expect(typeof root.localStorageDetector).toBe("function");
expect(typeof root.htmlLangDetector).toBe("function");
expect(typeof root.acceptLanguageDetector).toBe("function");
expect(typeof root.parseAcceptLanguage).toBe("function");
expect(typeof root.navigatorDetector).toBe("function");
expect(typeof root.cloudflareIpGeoDetector).toBe("function");
expect(typeof root.fallbackDetector).toBe("function");
});
it("re-exports defaultDetectorChain", { timeout: 5000 }, () => {
expect(typeof root.defaultDetectorChain).toBe("function");
});
it("re-exports observability helpers", { timeout: 5000 }, () => {
expect(typeof root.getDetectorMetrics).toBe("function");
expect(typeof root.getDetectorMetricsState).toBe("function");
expect(typeof root.resetDetectorMetricsState).toBe("function");
expect(typeof root.resetDetectorMetricsCache).toBe("function");
expect(typeof root.recordDetectorMatch).toBe("function");
expect(typeof root.recordDetectorDuration).toBe("function");
expect(typeof root.recordChainExhausted).toBe("function");
});
});
describe("@suaveplan/i18n-detector — every documented subpath export resolves", () => {
it("subpath barrels resolve through the package", {
timeout: 5000,
}, async () => {
const chain = await import("./chain/index.js");
const direction = await import("./direction/index.js");
const querystring = await import("./detectors/querystring/index.js");
const userPreference = await import(
"./detectors/user-preference/index.js"
);
const cookie = await import("./detectors/cookie/index.js");
const localStorage = await import("./detectors/local-storage/index.js");
const htmlLang = await import("./detectors/html-lang/index.js");
const acceptLanguage = await import(
"./detectors/accept-language/index.js"
);
const navigator = await import("./detectors/navigator/index.js");
const cloudflareIpGeo = await import(
"./detectors/cloudflare-ip-geo/index.js"
);
const fallback = await import("./detectors/fallback/index.js");
const defaultChain = await import("./default-chain/index.js");
expect(typeof chain.createDetectorChain).toBe("function");
expect(typeof chain.bcp47Negotiate).toBe("function");
expect(typeof direction.getTextDirection).toBe("function");
expect(typeof querystring.querystringDetector).toBe("function");
expect(typeof userPreference.userPreferenceDetector).toBe("function");
expect(typeof cookie.cookieDetector).toBe("function");
expect(typeof localStorage.localStorageDetector).toBe("function");
expect(typeof htmlLang.htmlLangDetector).toBe("function");
expect(typeof acceptLanguage.acceptLanguageDetector).toBe("function");
expect(typeof acceptLanguage.parseAcceptLanguage).toBe("function");
expect(typeof navigator.navigatorDetector).toBe("function");
expect(typeof cloudflareIpGeo.cloudflareIpGeoDetector).toBe("function");
expect(typeof fallback.fallbackDetector).toBe("function");
expect(typeof defaultChain.defaultDetectorChain).toBe("function");
});
});
describe("@suaveplan/i18n-detector — package.json", () => {
it("declares sideEffects false", { timeout: 5000 }, () => {
const pkgUrl = new URL("../package.json", import.meta.url);
const pkg = JSON.parse(
readFileSync(fileURLToPath(pkgUrl), "utf-8")
) as { sideEffects?: boolean };
expect(pkg.sideEffects).toBe(false);
});
it("declares every documented subpath export", {
timeout: 5000,
}, () => {
const pkgUrl = new URL("../package.json", import.meta.url);
const pkg = JSON.parse(
readFileSync(fileURLToPath(pkgUrl), "utf-8")
) as { exports?: Record<string, unknown> };
const documented = [
".",
"./chain",
"./direction",
"./detectors/querystring",
"./detectors/user-preference",
"./detectors/cookie",
"./detectors/local-storage",
"./detectors/html-lang",
"./detectors/accept-language",
"./detectors/navigator",
"./detectors/cloudflare-ip-geo",
"./detectors/fallback",
"./default-chain",
];
for (const subpath of documented) {
expect(pkg.exports?.[subpath]).toBeDefined();
}
});
});

View File

@@ -0,0 +1,73 @@
// Root barrel for @suaveplan/i18n-detector. Explicit named re-exports
// only — no `export *` per CLAUDE.md §10. Mirrors every per-feature
// barrel under chain/, direction/, detectors/<name>/, default-chain/,
// and the top-level detector.types.ts so the surface stays auditable
// and tree-shakeable.
export {
bcp47Negotiate,
type CreateDetectorChainOptions,
createDetectorChain,
DETECTOR_CHAIN_UNRESOLVED,
type DetectorChain,
type DetectorChainUnresolved,
type NegotiateFn,
} from "./chain/index.js";
export {
type DefaultDetectorChainOptions,
defaultDetectorChain,
} from "./default-chain/index.js";
export type {
DetectorChainResult,
DetectorContext,
DetectorEnvironment,
DetectorStorageLike,
LocaleDetector,
} from "./detector.types.js";
export {
type AcceptLanguageEntry,
acceptLanguageDetector,
parseAcceptLanguage,
} from "./detectors/accept-language/index.js";
export {
type CloudflareIpGeoDetectorOptions,
cloudflareIpGeoDetector,
} from "./detectors/cloudflare-ip-geo/index.js";
export {
type CookieDetectorOptions,
type CookieSameSite,
cookieDetector,
} from "./detectors/cookie/index.js";
export { fallbackDetector } from "./detectors/fallback/index.js";
export { htmlLangDetector } from "./detectors/html-lang/index.js";
export {
type LocalStorageDetectorOptions,
type LocalStorageLike,
localStorageDetector,
} from "./detectors/local-storage/index.js";
export { navigatorDetector } from "./detectors/navigator/index.js";
export {
type QuerystringDetectorOptions,
querystringDetector,
} from "./detectors/querystring/index.js";
export {
type UserPreferenceDetectorOptions,
type UserPreferenceResponseBody,
userPreferenceDetector,
} from "./detectors/user-preference/index.js";
export {
getTextDirection,
isRtl,
type TextDirection,
} from "./direction/index.js";
export {
type DetectorMetricSample,
type DetectorMetricsState,
getDetectorMetrics,
getDetectorMetricsState,
recordChainExhausted,
recordDetectorDuration,
recordDetectorMatch,
resetDetectorMetricsCache,
resetDetectorMetricsState,
} from "./observability/index.js";

View File

@@ -0,0 +1,11 @@
export {
type DetectorMetricSample,
type DetectorMetricsState,
getDetectorMetrics,
getDetectorMetricsState,
recordChainExhausted,
recordDetectorDuration,
recordDetectorMatch,
resetDetectorMetricsCache,
resetDetectorMetricsState,
} from "./metrics.js";

View File

@@ -0,0 +1,94 @@
# `metrics.ts` — detector-chain OTel instruments
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`metrics.ts` owns the OTel surface for the entire
`@suaveplan/i18n-detector` package. It exposes lazily-built
instruments (`tracer`, `matchTotal`, `durationMs`,
`detectedTotal`, `chainExhaustedTotal`) plus an in-memory state
ring so spec-conformance tests can assert on exact attribute
payloads without standing up a full `MeterProvider` +
`InMemoryMetricExporter` pipeline.
All instruments are no-ops when no `MeterProvider` /
`TracerProvider` is registered (the default), so consumers pay
zero overhead unless they wire up the SDK. The cached singleton
is reset between tests via `resetDetectorMetricsCache()`.
## Features
- `getDetectorMetrics()` — lazily builds the tracer, counters, and
histograms; caches the result.
- `recordDetectorMatch(detector, locale)` — increments the
aggregate `i18n.detector.match_total{detector}` counter AND
the per-locale `i18n.detector.detected_total{detector,locale}`
counter, mirroring both events into the in-memory state ring.
- `recordDetectorDuration(detector, durationMs)` — records
`i18n.detector.duration_ms{detector}` and pushes the sample
into state.
- `recordChainExhausted()` — increments
`i18n.detector.chain.exhausted_total` on every sentinel return.
- `getDetectorMetricsState()` / `resetDetectorMetricsState()` —
test-only helpers for asserting + clearing the in-memory ring.
- `resetDetectorMetricsCache()` — drops the singleton so a fresh
meter can be built between tests.
## Usage: Basic Example
```ts
import { getDetectorMetrics } from "@suaveplan/i18n-detector";
const m = getDetectorMetrics();
m.matchTotal.add(1, { detector: "cookie" });
```
## Usage: Advanced Example
```ts
import {
getDetectorMetricsState,
recordDetectorMatch,
resetDetectorMetricsState,
} from "@suaveplan/i18n-detector";
beforeEach(() => resetDetectorMetricsState());
it("increments detected_total once per match", () => {
recordDetectorMatch("cookie", "fr-FR");
const state = getDetectorMetricsState();
expect(state.detectedTotal).toEqual([
{ attributes: { detector: "cookie", locale: "fr-FR" } },
]);
});
```
## API Reference
- `getDetectorMetrics()` — returns the cached `CachedInstruments`
object (`tracer`, `matchTotal`, `durationMs`,
`detectedTotal`, `chainExhaustedTotal`).
- `recordDetectorMatch(detector, locale)` — emits both aggregate
and labelled counters.
- `recordDetectorDuration(detector, ms)` — emits the duration
histogram.
- `recordChainExhausted()` — emits the chain-exhausted counter.
- `getDetectorMetricsState()` / `resetDetectorMetricsState()` /
`resetDetectorMetricsCache()` — test plumbing.
- `DetectorMetricSample` / `DetectorMetricsState` — in-memory
ring shape.
## Implementation Notes
- The cached singleton has process-wide lifetime by design —
metrics emitters are not per-chain because the chain is a thin
orchestrator and detector names are globally unique.
- `state.detectedTotal` carries `{detector, locale}` attributes;
`state.matchTotal` carries `{detector}` only. Tests that need
per-locale assertions read `detectedTotal`; tests that only
care about which detector won read `matchTotal`.
- All sample arrays are unbounded — `resetDetectorMetricsState`
is the safety valve. Production code should never call the
state-inspection helpers (they exist for spec conformance).

View File

@@ -0,0 +1,92 @@
import {
afterEach,
beforeEach,
describe,
expect,
it,
} from "@suaveplan/testing/runner";
import {
getDetectorMetrics,
getDetectorMetricsState,
recordChainExhausted,
recordDetectorDuration,
recordDetectorMatch,
resetDetectorMetricsCache,
resetDetectorMetricsState,
} from "./metrics.js";
beforeEach(() => {
resetDetectorMetricsCache();
resetDetectorMetricsState();
});
afterEach(() => {
resetDetectorMetricsState();
});
describe("metrics", () => {
it("getDetectorMetrics returns the same instance on repeat calls", {
timeout: 5000,
}, () => {
const a = getDetectorMetrics();
const b = getDetectorMetrics();
expect(a).toBe(b);
});
it("recordDetectorMatch pushes both aggregate and labelled samples", {
timeout: 5000,
}, () => {
recordDetectorMatch("cookie", "fr-FR");
const state = getDetectorMetricsState();
expect(state.matchTotal).toEqual([
{ attributes: { detector: "cookie" } },
]);
expect(state.detectedTotal).toEqual([
{ attributes: { detector: "cookie", locale: "fr-FR" } },
]);
});
it("recordDetectorDuration captures detector + ms value", {
timeout: 5000,
}, () => {
recordDetectorDuration("userPreference", 12.5);
const state = getDetectorMetricsState();
expect(state.durationMs).toEqual([
{
attributes: { detector: "userPreference" },
value: 12.5,
},
]);
});
it("recordChainExhausted appends an empty-attribute sample", {
timeout: 5000,
}, () => {
recordChainExhausted();
recordChainExhausted();
const state = getDetectorMetricsState();
expect(state.chainExhaustedTotal.length).toBe(2);
expect(state.chainExhaustedTotal[0]?.attributes).toEqual({});
});
it("resetDetectorMetricsState clears every ring", { timeout: 5000 }, () => {
recordDetectorMatch("cookie", "fr-FR");
recordDetectorDuration("userPreference", 1);
recordChainExhausted();
resetDetectorMetricsState();
const state = getDetectorMetricsState();
expect(state.matchTotal).toEqual([]);
expect(state.detectedTotal).toEqual([]);
expect(state.durationMs).toEqual([]);
expect(state.chainExhaustedTotal).toEqual([]);
});
it("resetDetectorMetricsCache forces a fresh build on next access", {
timeout: 5000,
}, () => {
const a = getDetectorMetrics();
resetDetectorMetricsCache();
const b = getDetectorMetrics();
expect(a).not.toBe(b);
});
});

View File

@@ -0,0 +1,142 @@
import {
type Counter,
type Histogram,
metrics,
type Tracer,
trace,
} from "@opentelemetry/api";
/**
* OTel instruments for the `@suaveplan/i18n-detector` chain and its
* individual detectors.
*
* Built lazily via `metrics.getMeter('suaveplan.i18n-detector')`. All
* instruments are no-ops when no MeterProvider / TracerProvider is
* registered (the default), so consumers pay zero overhead unless
* they wire up the SDK.
*
* In addition to emitting via OTel, this module retains a small
* in-memory state ring so spec-conformance tests can assert exact
* attribute payloads without standing up a full SDK +
* InMemoryMetricExporter pipeline.
*/
export interface DetectorMetricSample {
readonly attributes: Record<string, string>;
readonly value?: number;
}
export interface DetectorMetricsState {
readonly matchTotal: DetectorMetricSample[];
readonly durationMs: DetectorMetricSample[];
readonly detectedTotal: DetectorMetricSample[];
readonly chainExhaustedTotal: DetectorMetricSample[];
}
interface CachedInstruments {
readonly tracer: Tracer;
readonly matchTotal: Counter;
readonly durationMs: Histogram;
readonly detectedTotal: Counter;
readonly chainExhaustedTotal: Counter;
}
let cached: CachedInstruments | undefined;
const state: DetectorMetricsState = {
matchTotal: [],
durationMs: [],
detectedTotal: [],
chainExhaustedTotal: [],
};
/**
* Build (or return cached) OTel instruments + tracer for the detector
* chain. Idempotent — safe to call from any production code path.
*/
export function getDetectorMetrics(): CachedInstruments {
if (cached) return cached;
const meter = metrics.getMeter("suaveplan.i18n-detector");
cached = {
tracer: trace.getTracer("suaveplan.i18n-detector"),
matchTotal: meter.createCounter("i18n.detector.match_total", {
description:
"Aggregated locale-detector matches, labelled by detector",
}),
durationMs: meter.createHistogram("i18n.detector.duration_ms", {
description:
"Aggregated detector execution duration in milliseconds",
unit: "ms",
}),
detectedTotal: meter.createCounter("i18n.detector.detected_total", {
description:
"Per-detector successful match counter labelled by detector + resolved locale",
}),
chainExhaustedTotal: meter.createCounter(
"i18n.detector.chain.exhausted_total",
{
description:
"Number of chain runs that returned the unresolved sentinel (every detector returned null)",
}
),
};
return cached;
}
/**
* Record a successful detector match. Increments the aggregate counter
* AND the per-detector `detected_total{detector,locale}` counter, then
* mirrors both events into the in-memory state ring so tests can
* assert on the exact attribute payloads.
*/
export function recordDetectorMatch(detector: string, locale: string): void {
const aggregate = { detector };
const labelled = { detector, locale };
const m = getDetectorMetrics();
m.matchTotal.add(1, aggregate);
m.detectedTotal.add(1, labelled);
state.matchTotal.push({ attributes: aggregate });
state.detectedTotal.push({ attributes: labelled });
}
/**
* Record an async detector's wall-clock duration. Mirrors
* `recordDetectorMatch` for the duration histogram so the in-memory
* state ring captures every duration sample.
*/
export function recordDetectorDuration(
detector: string,
durationMs: number
): void {
const attributes = { detector };
getDetectorMetrics().durationMs.record(durationMs, attributes);
state.durationMs.push({ attributes, value: durationMs });
}
/**
* Record that the chain returned the unresolved sentinel. Increments
* the `i18n.detector.chain.exhausted_total` counter.
*/
export function recordChainExhausted(): void {
const attributes = {};
getDetectorMetrics().chainExhaustedTotal.add(1, attributes);
state.chainExhaustedTotal.push({ attributes });
}
/** Return a frozen snapshot of the in-memory state. */
export function getDetectorMetricsState(): DetectorMetricsState {
return state;
}
/** Reset the in-memory state ring. Tests call this between cases. */
export function resetDetectorMetricsState(): void {
state.matchTotal.length = 0;
state.durationMs.length = 0;
state.detectedTotal.length = 0;
state.chainExhaustedTotal.length = 0;
}
/** Test-only: drop the cached instruments so a fresh meter can be built. */
export function resetDetectorMetricsCache(): void {
cached = undefined;
}

View File

@@ -0,0 +1,12 @@
{
"$schema": "https://json.schemastore.org/tsconfig",
"extends": "../../../../tsconfig.base.json",
"compilerOptions": {
"rootDir": "./src",
"outDir": "./dist",
"lib": ["ES2022", "DOM"],
"types": ["node"]
},
"include": ["src/**/*.ts"],
"exclude": ["src/**/*.test.ts", "dist", "node_modules"]
}

View File

@@ -0,0 +1,39 @@
import { defineConfig } from "vite";
import dts from "vite-plugin-dts";
export default defineConfig({
root: __dirname,
cacheDir: "../../../node_modules/.vite/packages/i18n-detector",
plugins: [
dts({
entryRoot: "src",
tsconfigPath: "./tsconfig.lib.json",
insertTypesEntry: true,
}),
],
build: {
outDir: "./dist",
emptyOutDir: true,
reportCompressedSize: true,
sourcemap: true,
lib: { entry: "src/index.ts", formats: ["es"] },
rollupOptions: {
treeshake: true,
output: {
preserveModules: true,
preserveModulesRoot: "src",
entryFileNames: "[name].js",
},
external: [
"@suaveplan/error",
"@suaveplan/i18n-shared",
"@suaveplan/locale-data",
"@suaveplan/types",
"@opentelemetry/api",
"tslib",
"zod",
/^node:/,
],
},
},
});

View File

@@ -0,0 +1,29 @@
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
globals: false,
environment: "node",
pool: "forks",
include: ["src/**/*.test.ts"],
exclude: ["dist/**", "node_modules/**"],
coverage: {
provider: "istanbul",
reporter: ["text", "html", "lcov"],
include: ["src/**/*.ts"],
exclude: [
"src/**/*.test.ts",
"src/**/*.types.ts",
"src/**/*.schemas.ts",
"src/**/index.ts",
],
thresholds: {
statements: 100,
branches: 100,
functions: 100,
lines: 100,
},
},
testTimeout: 5000,
},
});