Initial Commit
This commit is contained in:
@@ -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.
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
export {
|
||||
acceptLanguageDetector,
|
||||
parseAcceptLanguage,
|
||||
} from "./accept-language.js";
|
||||
export type { AcceptLanguageEntry } from "./accept-language.types.js";
|
||||
Reference in New Issue
Block a user