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,82 @@
# acceptLanguageDetector
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`acceptLanguageDetector` is a server-only `LocaleDetector` that parses
the inbound `Accept-Language` header per RFC 9110 §5.3.5 and returns
the highest-priority entry. It is the canonical fallback when nothing
else (querystring, user preference, cookie, geo lookup) has produced a
match — every browser submits an `Accept-Language` header so this
detector almost always returns _something_ on a real request.
The detector returns the _raw_ highest-q entry; supported-locale
negotiation is the chain runner's responsibility (it calls
`LocaleResolver.negotiate(raw, supported)` to perform BCP-47 fallback).
## Features
- Full RFC 9110 q-value parsing (default 1.0, range `[0, 1]`)
- Stable sort: ties keep insertion order
- Wildcard `*` skipped (no useful locale information)
- Malformed entries silently skipped
- `environment: "server"`
## Usage: Basic Example
```ts
import { acceptLanguageDetector } from "@suaveplan/react-i18n/detector/accept-language/accept-language";
import { createDetectorChain } from "@suaveplan/i18n/detector";
const chain = createDetectorChain([acceptLanguageDetector()]);
```
## Usage: Advanced Example
```ts
import { parseAcceptLanguage } from "@suaveplan/react-i18n/detector/accept-language/accept-language";
// Build a custom detector that biases language groups using the
// canonical RFC 9110 parser exposed by this module.
const groups = parseAcceptLanguage("fr-FR;q=0.5, de-DE;q=0.9, en;q=0.8");
// → [{ locale: "de-DE", q: 0.9 }, { locale: "en", q: 0.8 }, ...]
```
## API Reference
### `acceptLanguageDetector(): LocaleDetector`
Returns a `LocaleDetector` with `name: "acceptLanguage"` and
`environment: "server"`. Only `detect(ctx)` is implemented.
### `parseAcceptLanguage(header: string): AcceptLanguageEntry[]`
Parses an `Accept-Language` header into a sorted descending-by-q array
of `{ locale, q }` entries. Exposed for tests and as a building block
for advanced consumers building custom detectors.
### `AcceptLanguageEntry`
```ts
interface AcceptLanguageEntry {
readonly locale: string;
readonly q: number;
}
```
## Implementation Notes
- The parser uses `Array.prototype.sort` with a tie-breaking
insertion-index because the JavaScript spec only guarantees a
stable sort in modern engines; the explicit index keeps the
behaviour deterministic across older runtimes too.
- Wildcards are skipped at parse time — they never appear in the
returned array. The chain runner still has the option of
consulting `ctx.fallbackLocale` once every detector returns null.
- An entry whose q-value is unparseable, negative, or > 1 is treated
as a malformed entry per RFC 9110 ("the recipient SHOULD discard")
rather than coerced to the documented default — this keeps the
parser strict at the field level while staying liberal at the
header level.

View File

@@ -0,0 +1,156 @@
import type { DetectorContext } from "@suaveplan/i18n-detector";
import { describe, expect, it } from "@suaveplan/testing/runner";
import {
acceptLanguageDetector,
parseAcceptLanguage,
} from "./accept-language.js";
const SUPPORTED = ["en-GB", "fr-FR", "de-DE", "ja-JP"] as const;
function serverContext(headers: HeadersInit = {}): DetectorContext {
return {
request: {
headers: new Headers(headers),
url: new URL("https://example.com/"),
},
responseHeaders: new Headers(),
supportedLocales: SUPPORTED,
fallbackLocale: "en-GB",
};
}
describe("acceptLanguageDetector", () => {
it("exposes name and environment per spec", { timeout: 5000 }, () => {
const detector = acceptLanguageDetector();
expect(detector.name).toBe("acceptLanguage");
expect(detector.environment).toBe("server");
});
it("returns highest-q tag from Accept-Language", { timeout: 5000 }, () => {
const detector = acceptLanguageDetector();
const result = detector.detect?.(
serverContext({
"accept-language": "fr-FR;q=0.5, de-DE;q=0.9, en-GB;q=0.8",
})
);
expect(result).toBe("de-DE");
});
it("treats missing q as 1.0", { timeout: 5000 }, () => {
const detector = acceptLanguageDetector();
const result = detector.detect?.(
serverContext({
"accept-language": "fr-FR;q=0.9, de-DE",
})
);
expect(result).toBe("de-DE");
});
it("preserves insertion order on ties", { timeout: 5000 }, () => {
const detector = acceptLanguageDetector();
const result = detector.detect?.(
serverContext({ "accept-language": "fr-FR, de-DE, ja-JP" })
);
expect(result).toBe("fr-FR");
});
it("skips wildcard token", { timeout: 5000 }, () => {
const detector = acceptLanguageDetector();
const result = detector.detect?.(
serverContext({ "accept-language": "*;q=1.0, fr-FR;q=0.5" })
);
expect(result).toBe("fr-FR");
});
it("returns null for empty header", { timeout: 5000 }, () => {
const detector = acceptLanguageDetector();
const result = detector.detect?.(
serverContext({ "accept-language": "" })
);
expect(result).toBeNull();
});
it("returns null when header missing entirely", { timeout: 5000 }, () => {
const detector = acceptLanguageDetector();
const result = detector.detect?.(serverContext({}));
expect(result).toBeNull();
});
it("returns null when no request context", { timeout: 5000 }, () => {
const detector = acceptLanguageDetector();
const result = detector.detect?.({
supportedLocales: SUPPORTED,
fallbackLocale: "en-GB",
});
expect(result).toBeNull();
});
it("skips malformed entries with negative q", { timeout: 5000 }, () => {
const detector = acceptLanguageDetector();
const result = detector.detect?.(
serverContext({
"accept-language": "fr-FR;q=-1, de-DE;q=0.5",
})
);
expect(result).toBe("de-DE");
});
it("skips malformed entries with NaN q", { timeout: 5000 }, () => {
const detector = acceptLanguageDetector();
const result = detector.detect?.(
serverContext({
"accept-language": "fr-FR;q=abc, de-DE;q=0.5",
})
);
expect(result).toBe("de-DE");
});
it("skips malformed entries with q > 1", { timeout: 5000 }, () => {
const detector = acceptLanguageDetector();
const result = detector.detect?.(
serverContext({
"accept-language": "fr-FR;q=2.5, de-DE;q=0.4",
})
);
expect(result).toBe("de-DE");
});
it("ignores unrelated parameters", { timeout: 5000 }, () => {
const detector = acceptLanguageDetector();
const result = detector.detect?.(
serverContext({
"accept-language": "fr-FR;foo=bar;q=0.9, de-DE;q=0.5",
})
);
expect(result).toBe("fr-FR");
});
it("returns null for header consisting of only wildcards and empty", {
timeout: 5000,
}, () => {
const detector = acceptLanguageDetector();
const result = detector.detect?.(
serverContext({ "accept-language": "*, , *;q=0.5" })
);
expect(result).toBeNull();
});
it("parser handles parameter without '='", { timeout: 5000 }, () => {
const result = parseAcceptLanguage("fr-FR;noequals;q=0.6");
expect(result).toEqual([{ locale: "fr-FR", q: 0.6 }]);
});
it("parser handles trailing whitespace and empty params", {
timeout: 5000,
}, () => {
const result = parseAcceptLanguage(" fr-FR ; ; q = 0.7 ");
expect(result).toEqual([{ locale: "fr-FR", q: 0.7 }]);
});
it("parser yields empty when header only has commas", {
timeout: 5000,
}, () => {
const result = parseAcceptLanguage(",, ,");
expect(result).toEqual([]);
});
});

View File

@@ -0,0 +1,82 @@
import type { LocaleDetector } from "@suaveplan/i18n-detector";
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
* `LocaleResolver.negotiate(raw, supportedLocales)` 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);
}
// Stable sort by q desc, falling back to insertion order via index.
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";