@mandujs/core 0.41.2 → 0.43.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +21 -4
- package/src/auth/__tests__/login.test.ts +420 -419
- package/src/auth/__tests__/reset.test.ts +296 -296
- package/src/brain/adapters/anthropic-oauth.ts +421 -420
- package/src/brain/adapters/index.ts +2 -1
- package/src/brain/adapters/ollama.ts +1 -1
- package/src/brain/adapters/openai-oauth.ts +534 -533
- package/src/brain/brain.ts +2 -1
- package/src/brain/redactor.ts +196 -196
- package/src/bundler/__tests__/cli-bench-utils.test.ts +149 -149
- package/src/bundler/__tests__/cold-start.test.ts +504 -504
- package/src/bundler/__tests__/fast-refresh.test.ts +607 -606
- package/src/bundler/__tests__/hdr.test.ts +1 -1
- package/src/bundler/analyzer.ts +958 -958
- package/src/bundler/build.ts +104 -14
- package/src/bundler/dev.ts +125 -0
- package/src/bundler/hmr-types.ts +1 -0
- package/src/bundler/plugins/__tests__/react-compiler-lint.test.ts +110 -0
- package/src/bundler/plugins/index.ts +14 -0
- package/src/bundler/plugins/react-compiler-lint.ts +253 -0
- package/src/bundler/plugins/react-compiler.ts +162 -0
- package/src/bundler/types.ts +12 -0
- package/src/change/integrity.ts +2 -1
- package/src/client/index.ts +10 -0
- package/src/client/island.ts +38 -11
- package/src/client/router.ts +6 -1
- package/src/config/mandu.ts +57 -0
- package/src/config/validate.ts +42 -0
- package/src/content/collection.ts +844 -809
- package/src/content/content-layer.ts +316 -314
- package/src/content/content.test.ts +433 -433
- package/src/content/digest.ts +133 -133
- package/src/content/generate-types.ts +168 -168
- package/src/content/index.ts +6 -1
- package/src/content/llms-txt.ts +277 -277
- package/src/contract/define.ts +474 -474
- package/src/contract/route-helpers.ts +2 -1
- package/src/contract/zod-utils.ts +158 -155
- package/src/db/index.ts +513 -513
- package/src/desktop/__tests__/smoke.test.ts +100 -100
- package/src/desktop/webview-fallback.ts +583 -583
- package/src/desktop/window.ts +3 -1
- package/src/dev-error-overlay/overlay-client.ts +300 -300
- package/src/devtools/ai/mcp-connector.ts +499 -498
- package/src/devtools/client/components/kitchen-root.tsx +7 -2
- package/src/email/resend.ts +163 -163
- package/src/guard/__tests__/tsgolint-bridge.test.ts +347 -0
- package/src/guard/ast-analyzer.ts +806 -806
- package/src/guard/graph.ts +898 -898
- package/src/guard/index.ts +16 -0
- package/src/guard/statistics.ts +578 -578
- package/src/guard/tsgolint-bridge.ts +512 -0
- package/src/i18n/locale-resolver.ts +214 -214
- package/src/id/__tests__/id.test.ts +120 -120
- package/src/intent/index.ts +321 -321
- package/src/island/index.ts +39 -23
- package/src/kitchen/api/contract-api.ts +15 -8
- package/src/kitchen/kitchen-ui.ts +2137 -2137
- package/src/lockfile/index.ts +3 -2
- package/src/middleware/oauth/__tests__/oauth.test.ts +575 -574
- package/src/middleware/rate-limit/__tests__/rate-limit.test.ts +642 -642
- package/src/middleware/secure/index.ts +417 -417
- package/src/observability/event-bus.ts +2 -2
- package/src/observability/metrics.ts +334 -334
- package/src/observability/tracing.ts +694 -694
- package/src/openapi/generator.ts +1 -1
- package/src/perf/user-marks.ts +553 -553
- package/src/plugins/registry.ts +387 -387
- package/src/resource/ddl/diff.ts +392 -392
- package/src/resource/ddl/snapshot.ts +448 -447
- package/src/resource/generator-schema.ts +477 -476
- package/src/resource/parser.ts +4 -2
- package/src/resource/schema.ts +1 -1
- package/src/router/fs-patterns.ts +422 -422
- package/src/runtime/fast-refresh-types.ts +126 -128
- package/src/runtime/image-handler.ts +206 -195
- package/src/runtime/router.test.ts +476 -476
- package/src/runtime/security.ts +155 -155
- package/src/runtime/server.ts +36 -19
- package/src/runtime/session-key.ts +328 -328
- package/src/scheduler/__tests__/scheduler.test.ts +514 -514
- package/src/seo/resolve/index.ts +353 -353
- package/src/spec/load.ts +1 -1
- package/src/testing/reporter.ts +676 -676
- package/src/testing/server.ts +196 -196
- package/src/testing/snapshot.ts +444 -444
- package/src/utils/__tests__/lru-cache.test.ts +186 -186
- package/src/utils/bun.ts +8 -8
|
@@ -1,214 +1,214 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Phase 18.μ — locale resolution.
|
|
3
|
-
*
|
|
4
|
-
* Given a request + i18n config, return the active {@link ResolvedLocale}.
|
|
5
|
-
* Resolution order depends on `config.strategy`:
|
|
6
|
-
*
|
|
7
|
-
* path-prefix → URL path segment → cookie → Accept-Language → default
|
|
8
|
-
* domain → Host header → cookie → Accept-Language → default
|
|
9
|
-
* header → Accept-Language → cookie → default
|
|
10
|
-
* cookie → Cookie → Accept-Language → default
|
|
11
|
-
*
|
|
12
|
-
* In every mode, an invalid locale signal (e.g. `/fr/docs` when `fr`
|
|
13
|
-
* isn't configured) falls through to the next signal — invalid inputs
|
|
14
|
-
* never short-circuit to an error. The final tier is always the
|
|
15
|
-
* configured `defaultLocale` or `fallback`.
|
|
16
|
-
*
|
|
17
|
-
* This module is pure; it never touches the server registry or
|
|
18
|
-
* `ctx.cookies`. Callers assemble the result and bolt it onto the
|
|
19
|
-
* request via `ManduContext` — see `runtime/server.ts` μ dispatch
|
|
20
|
-
* section.
|
|
21
|
-
*/
|
|
22
|
-
|
|
23
|
-
import type { I18nDefinition, LocaleCode, ResolvedLocale } from "./types";
|
|
24
|
-
import { DEFAULT_I18N_COOKIE } from "./define";
|
|
25
|
-
|
|
26
|
-
/**
|
|
27
|
-
* Remove a locale prefix from a URL pathname. Returns `{ locale, rest }`
|
|
28
|
-
* when the pathname starts with `/<locale>(/...)?` for a known locale;
|
|
29
|
-
* otherwise `{ locale: undefined, rest: pathname }`.
|
|
30
|
-
*
|
|
31
|
-
* Handles trailing + empty slashes so `/en`, `/en/`, `/en/docs` all
|
|
32
|
-
* match `locale="en"` with the rest normalized to `"/"`, `"/"`, `"/docs"`.
|
|
33
|
-
*/
|
|
34
|
-
export function stripLocalePrefix(
|
|
35
|
-
pathname: string,
|
|
36
|
-
locales: readonly LocaleCode[]
|
|
37
|
-
): { locale: LocaleCode | undefined; rest: string } {
|
|
38
|
-
if (!pathname.startsWith("/")) {
|
|
39
|
-
return { locale: undefined, rest: pathname };
|
|
40
|
-
}
|
|
41
|
-
// Fast path: root.
|
|
42
|
-
if (pathname === "/") return { locale: undefined, rest: "/" };
|
|
43
|
-
const slashIdx = pathname.indexOf("/", 1);
|
|
44
|
-
const first = slashIdx === -1 ? pathname.slice(1) : pathname.slice(1, slashIdx);
|
|
45
|
-
if (!locales.includes(first)) {
|
|
46
|
-
return { locale: undefined, rest: pathname };
|
|
47
|
-
}
|
|
48
|
-
const rest = slashIdx === -1 ? "/" : pathname.slice(slashIdx);
|
|
49
|
-
return { locale: first, rest };
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
/**
|
|
53
|
-
* Parse the `Accept-Language` header and pick the best supported
|
|
54
|
-
* locale. Honours q-weights; falls back to undefined when nothing
|
|
55
|
-
* matches.
|
|
56
|
-
*
|
|
57
|
-
* Comparison is case-insensitive, and we accept exact-match only
|
|
58
|
-
* (no Accept-Language `en-US` → `en` widening — userland can list
|
|
59
|
-
* both in `config.locales` when they want fallback behaviour).
|
|
60
|
-
*/
|
|
61
|
-
export function parseAcceptLanguage(
|
|
62
|
-
header: string | null | undefined,
|
|
63
|
-
locales: readonly LocaleCode[]
|
|
64
|
-
): LocaleCode | undefined {
|
|
65
|
-
if (!header) return undefined;
|
|
66
|
-
const want = new Map<string, number>();
|
|
67
|
-
for (const piece of header.split(",")) {
|
|
68
|
-
const [tagRaw, ...params] = piece.trim().split(";");
|
|
69
|
-
if (!tagRaw) continue;
|
|
70
|
-
const tag = tagRaw.trim().toLowerCase();
|
|
71
|
-
if (!tag || tag === "*") continue;
|
|
72
|
-
let q = 1.0;
|
|
73
|
-
for (const p of params) {
|
|
74
|
-
const m = /^\s*q\s*=\s*([0-9.]+)\s*$/i.exec(p);
|
|
75
|
-
if (m) q = parseFloat(m[1]!);
|
|
76
|
-
}
|
|
77
|
-
if (Number.isFinite(q) && q > 0 && !want.has(tag)) {
|
|
78
|
-
want.set(tag, q);
|
|
79
|
-
}
|
|
80
|
-
}
|
|
81
|
-
// Highest-q first; stable within equal weights.
|
|
82
|
-
const sorted = [...want.entries()].sort((a, b) => b[1] - a[1]);
|
|
83
|
-
const lowerLocales = locales.map((l) => ({ raw: l, lower: l.toLowerCase() }));
|
|
84
|
-
for (const [tag] of sorted) {
|
|
85
|
-
for (const { raw, lower } of lowerLocales) {
|
|
86
|
-
if (lower === tag) return raw;
|
|
87
|
-
}
|
|
88
|
-
}
|
|
89
|
-
// Widening fallback: `zh-TW` → try `zh` if only `zh` is configured.
|
|
90
|
-
for (const [tag] of sorted) {
|
|
91
|
-
const short = tag.split("-")[0]!;
|
|
92
|
-
for (const { raw, lower } of lowerLocales) {
|
|
93
|
-
if (lower === short) return raw;
|
|
94
|
-
}
|
|
95
|
-
}
|
|
96
|
-
return undefined;
|
|
97
|
-
}
|
|
98
|
-
|
|
99
|
-
/**
|
|
100
|
-
* Read the locale cookie from the raw `Cookie` header. A nano-parser
|
|
101
|
-
* so the resolver stays dependency-free (the `CookieManager` in
|
|
102
|
-
* `filling/context.ts` lives on the request-wrapping side, not here).
|
|
103
|
-
*/
|
|
104
|
-
export function readLocaleCookie(
|
|
105
|
-
header: string | null | undefined,
|
|
106
|
-
cookieName: string,
|
|
107
|
-
locales: readonly LocaleCode[]
|
|
108
|
-
): LocaleCode | undefined {
|
|
109
|
-
if (!header) return undefined;
|
|
110
|
-
const target = `${cookieName}=`;
|
|
111
|
-
for (const piece of header.split(";")) {
|
|
112
|
-
const trimmed = piece.trim();
|
|
113
|
-
if (!trimmed.startsWith(target)) continue;
|
|
114
|
-
const raw = trimmed.slice(target.length);
|
|
115
|
-
try {
|
|
116
|
-
const value = decodeURIComponent(raw);
|
|
117
|
-
if (locales.includes(value)) return value;
|
|
118
|
-
} catch {
|
|
119
|
-
// fallthrough
|
|
120
|
-
}
|
|
121
|
-
}
|
|
122
|
-
return undefined;
|
|
123
|
-
}
|
|
124
|
-
|
|
125
|
-
/**
|
|
126
|
-
* Resolve the locale for this request.
|
|
127
|
-
*
|
|
128
|
-
* The returned {@link ResolvedLocale} is always valid w.r.t.
|
|
129
|
-
* `config.locales` — when every signal misses, we fall back to
|
|
130
|
-
* `fallback` then `defaultLocale` (in that order) and flag the
|
|
131
|
-
* result with `strategy: "fallback"` / `"default"`.
|
|
132
|
-
*/
|
|
133
|
-
export function resolveLocale(
|
|
134
|
-
request: Request,
|
|
135
|
-
config: I18nDefinition
|
|
136
|
-
): ResolvedLocale {
|
|
137
|
-
const url = new URL(request.url);
|
|
138
|
-
const cookieName = config.cookieName ?? DEFAULT_I18N_COOKIE;
|
|
139
|
-
const cookieHeader = request.headers.get("cookie");
|
|
140
|
-
const acceptLanguage = request.headers.get("accept-language");
|
|
141
|
-
|
|
142
|
-
switch (config.strategy) {
|
|
143
|
-
case "path-prefix": {
|
|
144
|
-
const { locale } = stripLocalePrefix(url.pathname, config.locales);
|
|
145
|
-
if (locale) {
|
|
146
|
-
return { code: locale, isDefault: false, strategy: "path-prefix", source: url.pathname };
|
|
147
|
-
}
|
|
148
|
-
const cookie = readLocaleCookie(cookieHeader, cookieName, config.locales);
|
|
149
|
-
if (cookie) {
|
|
150
|
-
return { code: cookie, isDefault: false, strategy: "cookie", source: cookieHeader ?? undefined };
|
|
151
|
-
}
|
|
152
|
-
const accept = parseAcceptLanguage(acceptLanguage, config.locales);
|
|
153
|
-
if (accept) {
|
|
154
|
-
return { code: accept, isDefault: false, strategy: "header", source: acceptLanguage ?? undefined };
|
|
155
|
-
}
|
|
156
|
-
return finalFallback(config);
|
|
157
|
-
}
|
|
158
|
-
|
|
159
|
-
case "domain": {
|
|
160
|
-
const host = request.headers.get("host") || url.host;
|
|
161
|
-
const mapped = config.domains?.[host];
|
|
162
|
-
if (mapped) {
|
|
163
|
-
return { code: mapped, isDefault: false, strategy: "domain", source: host };
|
|
164
|
-
}
|
|
165
|
-
const cookie = readLocaleCookie(cookieHeader, cookieName, config.locales);
|
|
166
|
-
if (cookie) {
|
|
167
|
-
return { code: cookie, isDefault: false, strategy: "cookie", source: cookieHeader ?? undefined };
|
|
168
|
-
}
|
|
169
|
-
const accept = parseAcceptLanguage(acceptLanguage, config.locales);
|
|
170
|
-
if (accept) {
|
|
171
|
-
return { code: accept, isDefault: false, strategy: "header", source: acceptLanguage ?? undefined };
|
|
172
|
-
}
|
|
173
|
-
return finalFallback(config);
|
|
174
|
-
}
|
|
175
|
-
|
|
176
|
-
case "cookie": {
|
|
177
|
-
const cookie = readLocaleCookie(cookieHeader, cookieName, config.locales);
|
|
178
|
-
if (cookie) {
|
|
179
|
-
return { code: cookie, isDefault: false, strategy: "cookie", source: cookieHeader ?? undefined };
|
|
180
|
-
}
|
|
181
|
-
const accept = parseAcceptLanguage(acceptLanguage, config.locales);
|
|
182
|
-
if (accept) {
|
|
183
|
-
return { code: accept, isDefault: false, strategy: "header", source: acceptLanguage ?? undefined };
|
|
184
|
-
}
|
|
185
|
-
return finalFallback(config);
|
|
186
|
-
}
|
|
187
|
-
|
|
188
|
-
case "header": {
|
|
189
|
-
const accept = parseAcceptLanguage(acceptLanguage, config.locales);
|
|
190
|
-
if (accept) {
|
|
191
|
-
return { code: accept, isDefault: false, strategy: "header", source: acceptLanguage ?? undefined };
|
|
192
|
-
}
|
|
193
|
-
const cookie = readLocaleCookie(cookieHeader, cookieName, config.locales);
|
|
194
|
-
if (cookie) {
|
|
195
|
-
return { code: cookie, isDefault: false, strategy: "cookie", source: cookieHeader ?? undefined };
|
|
196
|
-
}
|
|
197
|
-
return finalFallback(config);
|
|
198
|
-
}
|
|
199
|
-
}
|
|
200
|
-
|
|
201
|
-
// Exhaustive check
|
|
202
|
-
return finalFallback(config);
|
|
203
|
-
}
|
|
204
|
-
|
|
205
|
-
function finalFallback(config: I18nDefinition): ResolvedLocale {
|
|
206
|
-
if (config.fallback && config.fallback !== config.defaultLocale) {
|
|
207
|
-
return {
|
|
208
|
-
code: config.fallback,
|
|
209
|
-
isDefault: config.fallback === config.defaultLocale,
|
|
210
|
-
strategy: "fallback",
|
|
211
|
-
};
|
|
212
|
-
}
|
|
213
|
-
return { code: config.defaultLocale, isDefault: true, strategy: "default" };
|
|
214
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* Phase 18.μ — locale resolution.
|
|
3
|
+
*
|
|
4
|
+
* Given a request + i18n config, return the active {@link ResolvedLocale}.
|
|
5
|
+
* Resolution order depends on `config.strategy`:
|
|
6
|
+
*
|
|
7
|
+
* path-prefix → URL path segment → cookie → Accept-Language → default
|
|
8
|
+
* domain → Host header → cookie → Accept-Language → default
|
|
9
|
+
* header → Accept-Language → cookie → default
|
|
10
|
+
* cookie → Cookie → Accept-Language → default
|
|
11
|
+
*
|
|
12
|
+
* In every mode, an invalid locale signal (e.g. `/fr/docs` when `fr`
|
|
13
|
+
* isn't configured) falls through to the next signal — invalid inputs
|
|
14
|
+
* never short-circuit to an error. The final tier is always the
|
|
15
|
+
* configured `defaultLocale` or `fallback`.
|
|
16
|
+
*
|
|
17
|
+
* This module is pure; it never touches the server registry or
|
|
18
|
+
* `ctx.cookies`. Callers assemble the result and bolt it onto the
|
|
19
|
+
* request via `ManduContext` — see `runtime/server.ts` μ dispatch
|
|
20
|
+
* section.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import type { I18nDefinition, LocaleCode, ResolvedLocale } from "./types";
|
|
24
|
+
import { DEFAULT_I18N_COOKIE } from "./define";
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Remove a locale prefix from a URL pathname. Returns `{ locale, rest }`
|
|
28
|
+
* when the pathname starts with `/<locale>(/...)?` for a known locale;
|
|
29
|
+
* otherwise `{ locale: undefined, rest: pathname }`.
|
|
30
|
+
*
|
|
31
|
+
* Handles trailing + empty slashes so `/en`, `/en/`, `/en/docs` all
|
|
32
|
+
* match `locale="en"` with the rest normalized to `"/"`, `"/"`, `"/docs"`.
|
|
33
|
+
*/
|
|
34
|
+
export function stripLocalePrefix(
|
|
35
|
+
pathname: string,
|
|
36
|
+
locales: readonly LocaleCode[]
|
|
37
|
+
): { locale: LocaleCode | undefined; rest: string } {
|
|
38
|
+
if (!pathname.startsWith("/")) {
|
|
39
|
+
return { locale: undefined, rest: pathname };
|
|
40
|
+
}
|
|
41
|
+
// Fast path: root.
|
|
42
|
+
if (pathname === "/") return { locale: undefined, rest: "/" };
|
|
43
|
+
const slashIdx = pathname.indexOf("/", 1);
|
|
44
|
+
const first = slashIdx === -1 ? pathname.slice(1) : pathname.slice(1, slashIdx);
|
|
45
|
+
if (!locales.includes(first)) {
|
|
46
|
+
return { locale: undefined, rest: pathname };
|
|
47
|
+
}
|
|
48
|
+
const rest = slashIdx === -1 ? "/" : pathname.slice(slashIdx);
|
|
49
|
+
return { locale: first, rest };
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Parse the `Accept-Language` header and pick the best supported
|
|
54
|
+
* locale. Honours q-weights; falls back to undefined when nothing
|
|
55
|
+
* matches.
|
|
56
|
+
*
|
|
57
|
+
* Comparison is case-insensitive, and we accept exact-match only
|
|
58
|
+
* (no Accept-Language `en-US` → `en` widening — userland can list
|
|
59
|
+
* both in `config.locales` when they want fallback behaviour).
|
|
60
|
+
*/
|
|
61
|
+
export function parseAcceptLanguage(
|
|
62
|
+
header: string | null | undefined,
|
|
63
|
+
locales: readonly LocaleCode[]
|
|
64
|
+
): LocaleCode | undefined {
|
|
65
|
+
if (!header) return undefined;
|
|
66
|
+
const want = new Map<string, number>();
|
|
67
|
+
for (const piece of header.split(",")) {
|
|
68
|
+
const [tagRaw, ...params] = piece.trim().split(";");
|
|
69
|
+
if (!tagRaw) continue;
|
|
70
|
+
const tag = tagRaw.trim().toLowerCase();
|
|
71
|
+
if (!tag || tag === "*") continue;
|
|
72
|
+
let q = 1.0;
|
|
73
|
+
for (const p of params) {
|
|
74
|
+
const m = /^\s*q\s*=\s*([0-9.]+)\s*$/i.exec(p);
|
|
75
|
+
if (m) q = parseFloat(m[1]!);
|
|
76
|
+
}
|
|
77
|
+
if (Number.isFinite(q) && q > 0 && !want.has(tag)) {
|
|
78
|
+
want.set(tag, q);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
// Highest-q first; stable within equal weights.
|
|
82
|
+
const sorted = [...want.entries()].sort((a, b) => b[1] - a[1]);
|
|
83
|
+
const lowerLocales = locales.map((l) => ({ raw: l, lower: l.toLowerCase() }));
|
|
84
|
+
for (const [tag] of sorted) {
|
|
85
|
+
for (const { raw, lower } of lowerLocales) {
|
|
86
|
+
if (lower === tag) return raw;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
// Widening fallback: `zh-TW` → try `zh` if only `zh` is configured.
|
|
90
|
+
for (const [tag] of sorted) {
|
|
91
|
+
const short = tag.split("-")[0]!;
|
|
92
|
+
for (const { raw, lower } of lowerLocales) {
|
|
93
|
+
if (lower === short) return raw;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
return undefined;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Read the locale cookie from the raw `Cookie` header. A nano-parser
|
|
101
|
+
* so the resolver stays dependency-free (the `CookieManager` in
|
|
102
|
+
* `filling/context.ts` lives on the request-wrapping side, not here).
|
|
103
|
+
*/
|
|
104
|
+
export function readLocaleCookie(
|
|
105
|
+
header: string | null | undefined,
|
|
106
|
+
cookieName: string,
|
|
107
|
+
locales: readonly LocaleCode[]
|
|
108
|
+
): LocaleCode | undefined {
|
|
109
|
+
if (!header) return undefined;
|
|
110
|
+
const target = `${cookieName}=`;
|
|
111
|
+
for (const piece of header.split(";")) {
|
|
112
|
+
const trimmed = piece.trim();
|
|
113
|
+
if (!trimmed.startsWith(target)) continue;
|
|
114
|
+
const raw = trimmed.slice(target.length);
|
|
115
|
+
try {
|
|
116
|
+
const value = decodeURIComponent(raw);
|
|
117
|
+
if (locales.includes(value)) return value;
|
|
118
|
+
} catch {
|
|
119
|
+
// fallthrough
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
return undefined;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Resolve the locale for this request.
|
|
127
|
+
*
|
|
128
|
+
* The returned {@link ResolvedLocale} is always valid w.r.t.
|
|
129
|
+
* `config.locales` — when every signal misses, we fall back to
|
|
130
|
+
* `fallback` then `defaultLocale` (in that order) and flag the
|
|
131
|
+
* result with `strategy: "fallback"` / `"default"`.
|
|
132
|
+
*/
|
|
133
|
+
export function resolveLocale(
|
|
134
|
+
request: Request,
|
|
135
|
+
config: I18nDefinition
|
|
136
|
+
): ResolvedLocale {
|
|
137
|
+
const url = new URL(request.url);
|
|
138
|
+
const cookieName = config.cookieName ?? DEFAULT_I18N_COOKIE;
|
|
139
|
+
const cookieHeader = request.headers.get("cookie");
|
|
140
|
+
const acceptLanguage = request.headers.get("accept-language");
|
|
141
|
+
|
|
142
|
+
switch (config.strategy) {
|
|
143
|
+
case "path-prefix": {
|
|
144
|
+
const { locale } = stripLocalePrefix(url.pathname, config.locales);
|
|
145
|
+
if (locale) {
|
|
146
|
+
return { code: locale, isDefault: false, strategy: "path-prefix", source: url.pathname };
|
|
147
|
+
}
|
|
148
|
+
const cookie = readLocaleCookie(cookieHeader, cookieName, config.locales);
|
|
149
|
+
if (cookie) {
|
|
150
|
+
return { code: cookie, isDefault: false, strategy: "cookie", source: cookieHeader ?? undefined };
|
|
151
|
+
}
|
|
152
|
+
const accept = parseAcceptLanguage(acceptLanguage, config.locales);
|
|
153
|
+
if (accept) {
|
|
154
|
+
return { code: accept, isDefault: false, strategy: "header", source: acceptLanguage ?? undefined };
|
|
155
|
+
}
|
|
156
|
+
return finalFallback(config);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
case "domain": {
|
|
160
|
+
const host = request.headers.get("host") || url.host;
|
|
161
|
+
const mapped = config.domains?.[host];
|
|
162
|
+
if (mapped) {
|
|
163
|
+
return { code: mapped, isDefault: false, strategy: "domain", source: host };
|
|
164
|
+
}
|
|
165
|
+
const cookie = readLocaleCookie(cookieHeader, cookieName, config.locales);
|
|
166
|
+
if (cookie) {
|
|
167
|
+
return { code: cookie, isDefault: false, strategy: "cookie", source: cookieHeader ?? undefined };
|
|
168
|
+
}
|
|
169
|
+
const accept = parseAcceptLanguage(acceptLanguage, config.locales);
|
|
170
|
+
if (accept) {
|
|
171
|
+
return { code: accept, isDefault: false, strategy: "header", source: acceptLanguage ?? undefined };
|
|
172
|
+
}
|
|
173
|
+
return finalFallback(config);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
case "cookie": {
|
|
177
|
+
const cookie = readLocaleCookie(cookieHeader, cookieName, config.locales);
|
|
178
|
+
if (cookie) {
|
|
179
|
+
return { code: cookie, isDefault: false, strategy: "cookie", source: cookieHeader ?? undefined };
|
|
180
|
+
}
|
|
181
|
+
const accept = parseAcceptLanguage(acceptLanguage, config.locales);
|
|
182
|
+
if (accept) {
|
|
183
|
+
return { code: accept, isDefault: false, strategy: "header", source: acceptLanguage ?? undefined };
|
|
184
|
+
}
|
|
185
|
+
return finalFallback(config);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
case "header": {
|
|
189
|
+
const accept = parseAcceptLanguage(acceptLanguage, config.locales);
|
|
190
|
+
if (accept) {
|
|
191
|
+
return { code: accept, isDefault: false, strategy: "header", source: acceptLanguage ?? undefined };
|
|
192
|
+
}
|
|
193
|
+
const cookie = readLocaleCookie(cookieHeader, cookieName, config.locales);
|
|
194
|
+
if (cookie) {
|
|
195
|
+
return { code: cookie, isDefault: false, strategy: "cookie", source: cookieHeader ?? undefined };
|
|
196
|
+
}
|
|
197
|
+
return finalFallback(config);
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// Exhaustive check
|
|
202
|
+
return finalFallback(config);
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
function finalFallback(config: I18nDefinition): ResolvedLocale {
|
|
206
|
+
if (config.fallback && config.fallback !== config.defaultLocale) {
|
|
207
|
+
return {
|
|
208
|
+
code: config.fallback,
|
|
209
|
+
isDefault: config.fallback === config.defaultLocale,
|
|
210
|
+
strategy: "fallback",
|
|
211
|
+
};
|
|
212
|
+
}
|
|
213
|
+
return { code: config.defaultLocale, isDefault: true, strategy: "default" };
|
|
214
|
+
}
|
|
@@ -1,120 +1,120 @@
|
|
|
1
|
-
import { describe, it, expect } from "bun:test";
|
|
2
|
-
import { newId, newShortId, _resolveGenerator } from "../index";
|
|
3
|
-
|
|
4
|
-
const UUID_REGEX =
|
|
5
|
-
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
6
|
-
const HEX8_REGEX = /^[0-9a-f]{8}$/i;
|
|
7
|
-
|
|
8
|
-
interface CryptoLike {
|
|
9
|
-
randomUUIDv7?: () => string;
|
|
10
|
-
randomUUID: () => string;
|
|
11
|
-
}
|
|
12
|
-
|
|
13
|
-
describe("@mandujs/core/id — newId", () => {
|
|
14
|
-
it("returns a canonical UUID string", () => {
|
|
15
|
-
const id = newId();
|
|
16
|
-
expect(id).toMatch(UUID_REGEX);
|
|
17
|
-
expect(id.length).toBe(36);
|
|
18
|
-
});
|
|
19
|
-
|
|
20
|
-
it("returns distinct values across calls", () => {
|
|
21
|
-
const seen = new Set<string>();
|
|
22
|
-
for (let i = 0; i < 1000; i++) {
|
|
23
|
-
seen.add(newId());
|
|
24
|
-
}
|
|
25
|
-
expect(seen.size).toBe(1000);
|
|
26
|
-
});
|
|
27
|
-
|
|
28
|
-
it("is monotonic: lexicographic sort preserves generation order (v7 property)", async () => {
|
|
29
|
-
// Skip this invariant if neither global exposes v7 — the v4 fallback is
|
|
30
|
-
// correctly non-monotonic by design.
|
|
31
|
-
const c = globalThis.crypto as unknown as CryptoLike;
|
|
32
|
-
const bun = (globalThis as unknown as { Bun?: { randomUUIDv7?: () => string } }).Bun;
|
|
33
|
-
const v7Available =
|
|
34
|
-
typeof c.randomUUIDv7 === "function" ||
|
|
35
|
-
(bun !== undefined && typeof bun.randomUUIDv7 === "function");
|
|
36
|
-
if (!v7Available) {
|
|
37
|
-
return;
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
const generated: string[] = [];
|
|
41
|
-
for (let i = 0; i < 1000; i++) {
|
|
42
|
-
generated.push(newId());
|
|
43
|
-
// Force sub-millisecond spread across a few ticks so the time component
|
|
44
|
-
// advances at least once during the run. UUID v7 counter handles
|
|
45
|
-
// intra-millisecond monotonicity on its own.
|
|
46
|
-
if (i % 200 === 0) {
|
|
47
|
-
await new Promise((r) => setTimeout(r, 1));
|
|
48
|
-
}
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
const sorted = [...generated].sort();
|
|
52
|
-
|
|
53
|
-
// Require overall monotonic trend: the full sorted list equals the
|
|
54
|
-
// generation order. Bun's v7 implementation uses a per-millisecond
|
|
55
|
-
// monotonic counter, so ties within the same ms remain ordered.
|
|
56
|
-
expect(sorted).toEqual(generated);
|
|
57
|
-
});
|
|
58
|
-
});
|
|
59
|
-
|
|
60
|
-
describe("@mandujs/core/id — newShortId", () => {
|
|
61
|
-
it("returns exactly 8 hex characters", () => {
|
|
62
|
-
const short = newShortId();
|
|
63
|
-
expect(short).toMatch(HEX8_REGEX);
|
|
64
|
-
expect(short.length).toBe(8);
|
|
65
|
-
});
|
|
66
|
-
|
|
67
|
-
it("matches the prefix of the underlying UUID", () => {
|
|
68
|
-
// The short form is defined as the first 8 chars of the full ID, which is
|
|
69
|
-
// the first hex group of the UUID (pre-hyphen).
|
|
70
|
-
for (let i = 0; i < 50; i++) {
|
|
71
|
-
const short = newShortId();
|
|
72
|
-
expect(short).not.toContain("-");
|
|
73
|
-
}
|
|
74
|
-
});
|
|
75
|
-
});
|
|
76
|
-
|
|
77
|
-
describe("@mandujs/core/id — generator resolution", () => {
|
|
78
|
-
it("prefers crypto.randomUUIDv7 when present", () => {
|
|
79
|
-
const sentinel = "01912345-6789-7abc-def0-123456789abc";
|
|
80
|
-
const fakeCrypto = {
|
|
81
|
-
randomUUIDv7: () => sentinel,
|
|
82
|
-
randomUUID: () => "00000000-0000-4000-8000-000000000000",
|
|
83
|
-
};
|
|
84
|
-
const fakeBun = { randomUUIDv7: () => "SHOULD_NOT_BE_CALLED" };
|
|
85
|
-
const gen = _resolveGenerator(fakeCrypto, fakeBun);
|
|
86
|
-
expect(gen()).toBe(sentinel);
|
|
87
|
-
});
|
|
88
|
-
|
|
89
|
-
it("uses Bun.randomUUIDv7 when crypto.randomUUIDv7 is absent", () => {
|
|
90
|
-
const sentinel = "01abcdef-0123-7456-8789-abcdef012345";
|
|
91
|
-
const fakeCrypto = {
|
|
92
|
-
randomUUID: () => "00000000-0000-4000-8000-000000000000",
|
|
93
|
-
};
|
|
94
|
-
const fakeBun = { randomUUIDv7: () => sentinel };
|
|
95
|
-
const gen = _resolveGenerator(fakeCrypto, fakeBun);
|
|
96
|
-
expect(gen()).toBe(sentinel);
|
|
97
|
-
});
|
|
98
|
-
|
|
99
|
-
it("falls back to crypto.randomUUID when neither v7 source is available", () => {
|
|
100
|
-
const fakeCrypto = {
|
|
101
|
-
// No randomUUIDv7 — force fallback.
|
|
102
|
-
randomUUID: () => "00000000-0000-4000-8000-000000000000",
|
|
103
|
-
};
|
|
104
|
-
const fakeBun = {}; // also no randomUUIDv7
|
|
105
|
-
const gen = _resolveGenerator(fakeCrypto, fakeBun);
|
|
106
|
-
const id = gen();
|
|
107
|
-
expect(id).toMatch(UUID_REGEX);
|
|
108
|
-
expect(id.length).toBe(36);
|
|
109
|
-
});
|
|
110
|
-
|
|
111
|
-
it("falls back to crypto.randomUUID when Bun stub lacks randomUUIDv7", () => {
|
|
112
|
-
const fakeCrypto = {
|
|
113
|
-
randomUUID: () => "11111111-1111-4111-8111-111111111111",
|
|
114
|
-
};
|
|
115
|
-
// Pass an empty stub explicitly rather than `undefined` (which would
|
|
116
|
-
// trigger the default-parameter fallback to the real Bun global).
|
|
117
|
-
const gen = _resolveGenerator(fakeCrypto, {});
|
|
118
|
-
expect(gen()).toBe("11111111-1111-4111-8111-111111111111");
|
|
119
|
-
});
|
|
120
|
-
});
|
|
1
|
+
import { describe, it, expect } from "bun:test";
|
|
2
|
+
import { newId, newShortId, _resolveGenerator } from "../index";
|
|
3
|
+
|
|
4
|
+
const UUID_REGEX =
|
|
5
|
+
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
6
|
+
const HEX8_REGEX = /^[0-9a-f]{8}$/i;
|
|
7
|
+
|
|
8
|
+
interface CryptoLike {
|
|
9
|
+
randomUUIDv7?: () => string;
|
|
10
|
+
randomUUID: () => string;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
describe("@mandujs/core/id — newId", () => {
|
|
14
|
+
it("returns a canonical UUID string", () => {
|
|
15
|
+
const id = newId();
|
|
16
|
+
expect(id).toMatch(UUID_REGEX);
|
|
17
|
+
expect(id.length).toBe(36);
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
it("returns distinct values across calls", () => {
|
|
21
|
+
const seen = new Set<string>();
|
|
22
|
+
for (let i = 0; i < 1000; i++) {
|
|
23
|
+
seen.add(newId());
|
|
24
|
+
}
|
|
25
|
+
expect(seen.size).toBe(1000);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it("is monotonic: lexicographic sort preserves generation order (v7 property)", async () => {
|
|
29
|
+
// Skip this invariant if neither global exposes v7 — the v4 fallback is
|
|
30
|
+
// correctly non-monotonic by design.
|
|
31
|
+
const c = globalThis.crypto as unknown as CryptoLike;
|
|
32
|
+
const bun = (globalThis as unknown as { Bun?: { randomUUIDv7?: () => string } }).Bun;
|
|
33
|
+
const v7Available =
|
|
34
|
+
typeof c.randomUUIDv7 === "function" ||
|
|
35
|
+
(bun !== undefined && typeof bun.randomUUIDv7 === "function");
|
|
36
|
+
if (!v7Available) {
|
|
37
|
+
return;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const generated: string[] = [];
|
|
41
|
+
for (let i = 0; i < 1000; i++) {
|
|
42
|
+
generated.push(newId());
|
|
43
|
+
// Force sub-millisecond spread across a few ticks so the time component
|
|
44
|
+
// advances at least once during the run. UUID v7 counter handles
|
|
45
|
+
// intra-millisecond monotonicity on its own.
|
|
46
|
+
if (i % 200 === 0) {
|
|
47
|
+
await new Promise((r) => setTimeout(r, 1));
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const sorted = [...generated].sort();
|
|
52
|
+
|
|
53
|
+
// Require overall monotonic trend: the full sorted list equals the
|
|
54
|
+
// generation order. Bun's v7 implementation uses a per-millisecond
|
|
55
|
+
// monotonic counter, so ties within the same ms remain ordered.
|
|
56
|
+
expect(sorted).toEqual(generated);
|
|
57
|
+
});
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
describe("@mandujs/core/id — newShortId", () => {
|
|
61
|
+
it("returns exactly 8 hex characters", () => {
|
|
62
|
+
const short = newShortId();
|
|
63
|
+
expect(short).toMatch(HEX8_REGEX);
|
|
64
|
+
expect(short.length).toBe(8);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it("matches the prefix of the underlying UUID", () => {
|
|
68
|
+
// The short form is defined as the first 8 chars of the full ID, which is
|
|
69
|
+
// the first hex group of the UUID (pre-hyphen).
|
|
70
|
+
for (let i = 0; i < 50; i++) {
|
|
71
|
+
const short = newShortId();
|
|
72
|
+
expect(short).not.toContain("-");
|
|
73
|
+
}
|
|
74
|
+
});
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
describe("@mandujs/core/id — generator resolution", () => {
|
|
78
|
+
it("prefers crypto.randomUUIDv7 when present", () => {
|
|
79
|
+
const sentinel = "01912345-6789-7abc-def0-123456789abc";
|
|
80
|
+
const fakeCrypto = {
|
|
81
|
+
randomUUIDv7: () => sentinel,
|
|
82
|
+
randomUUID: () => "00000000-0000-4000-8000-000000000000",
|
|
83
|
+
};
|
|
84
|
+
const fakeBun = { randomUUIDv7: () => "SHOULD_NOT_BE_CALLED" };
|
|
85
|
+
const gen = _resolveGenerator(fakeCrypto, fakeBun);
|
|
86
|
+
expect(gen()).toBe(sentinel);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it("uses Bun.randomUUIDv7 when crypto.randomUUIDv7 is absent", () => {
|
|
90
|
+
const sentinel = "01abcdef-0123-7456-8789-abcdef012345";
|
|
91
|
+
const fakeCrypto = {
|
|
92
|
+
randomUUID: () => "00000000-0000-4000-8000-000000000000",
|
|
93
|
+
};
|
|
94
|
+
const fakeBun = { randomUUIDv7: () => sentinel };
|
|
95
|
+
const gen = _resolveGenerator(fakeCrypto, fakeBun);
|
|
96
|
+
expect(gen()).toBe(sentinel);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it("falls back to crypto.randomUUID when neither v7 source is available", () => {
|
|
100
|
+
const fakeCrypto = {
|
|
101
|
+
// No randomUUIDv7 — force fallback.
|
|
102
|
+
randomUUID: () => "00000000-0000-4000-8000-000000000000",
|
|
103
|
+
};
|
|
104
|
+
const fakeBun = {}; // also no randomUUIDv7
|
|
105
|
+
const gen = _resolveGenerator(fakeCrypto, fakeBun);
|
|
106
|
+
const id = gen();
|
|
107
|
+
expect(id).toMatch(UUID_REGEX);
|
|
108
|
+
expect(id.length).toBe(36);
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it("falls back to crypto.randomUUID when Bun stub lacks randomUUIDv7", () => {
|
|
112
|
+
const fakeCrypto = {
|
|
113
|
+
randomUUID: () => "11111111-1111-4111-8111-111111111111",
|
|
114
|
+
};
|
|
115
|
+
// Pass an empty stub explicitly rather than `undefined` (which would
|
|
116
|
+
// trigger the default-parameter fallback to the real Bun global).
|
|
117
|
+
const gen = _resolveGenerator(fakeCrypto, {});
|
|
118
|
+
expect(gen()).toBe("11111111-1111-4111-8111-111111111111");
|
|
119
|
+
});
|
|
120
|
+
});
|