返回 CodeWhale
detect.ts
根目录 / web / lib / i18n / detect.ts
1 /**
2 * Deterministic locale detection for the website middleware (#3091).
3 *
4 * Resolution order (first match wins, no ambient state):
5 * 1. The NEXT_LOCALE cookie (a previous explicit choice).
6 * 2. Accept-Language, in the header's stated preference order — descending
7 * `q` weight, original order breaking ties, and `q=0` ("not acceptable")
8 * dropped outright. Each tag matches:
9 * a. exact full tag against the routed set (pt-BR → pt-BR);
10 * b. its primary subtag against the routed set (ru-RU → ru, zh-Hant → zh);
11 * c. a declared base→variant mapping for bases we only serve as a
12 * regional variant (pt → pt-BR).
13 * 3. The default locale (en).
14 *
15 * The mapping table is deliberately tiny and explicit — no guessing that
16 * e.g. es-419 should route anywhere other than the shipped `es`.
17 */
18 import { defaultLocale, locales } from "./config";
19
20 const ROUTED = locales as readonly string[];
21
22 /** Base subtags that route to a specific regional variant. */
23 const BASE_TO_VARIANT: Record<string, string> = {
24 pt: "pt-BR",
25 };
26
27 /** Match one language tag (any case, optional region/script) to a routed locale. */
28 export function matchLocaleTag(tag: string): string | null {
29 const t = tag.trim().toLowerCase();
30 if (!t || t === "*") return null;
31
32 // Exact full-tag match (case-insensitive; routed codes are lowercase).
33 const exact = ROUTED.find((l) => l.toLowerCase() === t);
34 if (exact) return exact;
35
36 const base = t.split("-")[0];
37 if (ROUTED.includes(base)) return base;
38
39 const variant = BASE_TO_VARIANT[base];
40 if (variant && ROUTED.includes(variant)) return variant;
41
42 return null;
43 }
44
45 /**
46 * Accept-Language tags in the client's stated preference order.
47 *
48 * The header carries weights, and its list order is not required to be the
49 * preference order: `en;q=0.2, ja;q=0.9` asks for Japanese, and `q=0` means
50 * "not acceptable" (RFC 9110 §12.4.2), not "least preferred". Reading the
51 * list positionally handed the first of those a reader English and the
52 * second one a language they had explicitly refused.
53 *
54 * Weights sort descending; original order breaks ties, so an unweighted
55 * `ru,uk` still resolves to Russian. A malformed weight is ignored rather
56 * than guessed at, leaving the tag at the default weight of 1.
57 */
58 export function acceptLanguageTags(header: string): string[] {
59 const entries: { tag: string; q: number; order: number }[] = [];
60
61 header.split(",").forEach((part, order) => {
62 const [rawTag, ...params] = part.split(";");
63 const tag = rawTag.trim();
64 if (!tag) return;
65
66 let q = 1;
67 for (const param of params) {
68 const eq = param.indexOf("=");
69 if (eq === -1) continue;
70 if (param.slice(0, eq).trim().toLowerCase() !== "q") continue;
71 const parsed = Number.parseFloat(param.slice(eq + 1).trim());
72 if (Number.isFinite(parsed)) q = Math.min(Math.max(parsed, 0), 1);
73 }
74
75 if (q === 0) return;
76 entries.push({ tag, q, order });
77 });
78
79 entries.sort((a, b) => b.q - a.q || a.order - b.order);
80 return entries.map((entry) => entry.tag);
81 }
82
83 /** Resolve the locale for a request from its cookie and Accept-Language header. */
84 export function detectLocaleFromHeaders(
85 cookie: string | undefined,
86 acceptLanguage: string | null,
87 ): string {
88 if (cookie) {
89 const match = matchLocaleTag(cookie);
90 if (match) return match;
91 }
92
93 if (acceptLanguage) {
94 for (const tag of acceptLanguageTags(acceptLanguage)) {
95 const match = matchLocaleTag(tag);
96 if (match) return match;
97 }
98 }
99
100 return defaultLocale;
101 }
102
102 lines TYPESCRIPT