@mandujs/core 0.30.0 → 0.32.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mandujs/core",
3
- "version": "0.30.0",
3
+ "version": "0.32.0",
4
4
  "description": "Mandu Framework Core - Spec, Generator, Guard, Runtime",
5
5
  "type": "module",
6
6
  "main": "./src/index.ts",
@@ -13,6 +13,9 @@
13
13
  "./auth/reset": "./src/auth/reset.ts",
14
14
  "./auth/verification": "./src/auth/verification.ts",
15
15
  "./client": "./src/client/index.ts",
16
+ "./client/rpc": "./src/client/rpc.ts",
17
+ "./contract": "./src/contract/index.ts",
18
+ "./contract/rpc": "./src/contract/rpc.ts",
16
19
  "./content": "./src/content/index.ts",
17
20
  "./content/prebuild": "./src/content/prebuild.ts",
18
21
  "./content/collection": "./src/content/collection.ts",
@@ -33,6 +36,7 @@
33
36
  "./testing": "./src/testing/index.ts",
34
37
  "./plugins": "./src/plugins/index.ts",
35
38
  "./error": "./src/error/index.ts",
39
+ "./i18n": "./src/i18n/index.ts",
36
40
  "./id": "./src/id/index.ts",
37
41
  "./observability": "./src/observability/index.ts",
38
42
  "./perf": "./src/perf/index.ts",
@@ -40,6 +44,8 @@
40
44
  "./routes": "./src/routes/index.ts",
41
45
  "./scheduler": "./src/scheduler/index.ts",
42
46
  "./storage/s3": "./src/storage/s3/index.ts",
47
+ "./guard/define-rule": "./src/guard/define-rule.ts",
48
+ "./guard/rule-presets": "./src/guard/rule-presets.ts",
43
49
  "./bundler/prerender": "./src/bundler/prerender.ts",
44
50
  "./bundler/safe-build": "./src/bundler/safe-build.ts",
45
51
  "./bundler/hmr-types": "./src/bundler/hmr-types.ts",
@@ -112,7 +112,17 @@ export { Link, NavLink, type LinkProps, type NavLinkProps } from "./Link";
112
112
  export { Form, type FormProps, type FormState } from "./Form";
113
113
 
114
114
  // RPC Client
115
- export { createClient, RpcError, type RpcMethods, type RpcRequestOptions, type RpcClientOptions } from "./rpc";
115
+ export {
116
+ createClient,
117
+ RpcError,
118
+ type RpcMethods,
119
+ type RpcRequestOptions,
120
+ type RpcClientOptions,
121
+ // Phase 18.κ — typed RPC proxy
122
+ createRpcClient,
123
+ RpcCallError,
124
+ type CreateRpcClientOptions,
125
+ } from "./rpc";
116
126
 
117
127
  // useFetch Composable
118
128
  export { useFetch, type UseFetchOptions, type UseFetchReturn } from "./use-fetch";
package/src/client/rpc.ts CHANGED
@@ -1,140 +1,293 @@
1
- /**
2
- * Mandu RPC Client
3
- * Contract 정의에서 타입 안전한 API 클라이언트 생성
4
- */
5
-
6
- // ========== Types ==========
7
-
8
- /** RPC 클라이언트 에러 */
9
- export class RpcError extends Error {
10
- constructor(
11
- public readonly status: number,
12
- public readonly body: unknown
13
- ) {
14
- super(`API Error ${status}`);
15
- this.name = "RpcError";
16
- }
17
- }
18
-
19
- export interface RpcRequestOptions {
20
- query?: Record<string, unknown>;
21
- body?: unknown;
22
- params?: Record<string, string>;
23
- headers?: Record<string, string>;
24
- signal?: AbortSignal;
25
- }
26
-
27
- export interface RpcClientOptions {
28
- /** API base URL (기본: 현재 origin) */
29
- baseUrl?: string;
30
- /** 공통 헤더 */
31
- headers?: Record<string, string>;
32
- /** 커스텀 fetch (테스트용) */
33
- fetch?: typeof globalThis.fetch;
34
- }
35
-
36
- // ========== Implementation ==========
37
-
38
- /**
39
- * Contract 기반 타입 안전 RPC 클라이언트 생성
40
- *
41
- * @example
42
- * ```typescript
43
- * import { createClient } from "@mandujs/core/client";
44
- * import type todoContract from "../spec/contracts/api-todos.contract";
45
- *
46
- * const api = createClient<typeof todoContract>("/api/todos");
47
- *
48
- * // 타입 추론 동작
49
- * const { todos } = await api.get({ query: { page: 2 } });
50
- * const { id } = await api.post({ body: { title: "New" } });
51
- * ```
52
- */
53
- export function createClient<TContract = unknown>(
54
- path: string,
55
- options?: RpcClientOptions
56
- ): RpcMethods {
57
- const baseFetch = options?.fetch ?? globalThis.fetch;
58
- const baseUrl = options?.baseUrl ?? "";
59
- const baseHeaders = options?.headers ?? {};
60
-
61
- function makeRequest(method: string) {
62
- return async (input?: RpcRequestOptions): Promise<unknown> => {
63
- const url = new URL(`${baseUrl}${path}`, typeof window !== "undefined" ? window.location.origin : "http://localhost");
64
-
65
- // URL 파라미터 치환
66
- if (input?.params) {
67
- let resolvedPath = url.pathname;
68
- for (const [key, value] of Object.entries(input.params)) {
69
- resolvedPath = resolvedPath.replace(`:${key}`, encodeURIComponent(value));
70
- }
71
- // 미해결 파라미터 검출
72
- if (resolvedPath.includes(":")) {
73
- throw new RpcError(0, `Unresolved path params in "${resolvedPath}". Check your params object.`);
74
- }
75
- url.pathname = resolvedPath;
76
- }
77
-
78
- // Query 파라미터
79
- if (input?.query) {
80
- for (const [key, value] of Object.entries(input.query)) {
81
- if (value !== undefined && value !== null) {
82
- url.searchParams.set(key, String(value));
83
- }
84
- }
85
- }
86
-
87
- const headers: Record<string, string> = {
88
- ...baseHeaders,
89
- ...input?.headers,
90
- "Accept": "application/json",
91
- };
92
-
93
- const fetchOptions: RequestInit = {
94
- method: method.toUpperCase(),
95
- headers,
96
- signal: input?.signal,
97
- };
98
-
99
- // Body (GET/HEAD 제외)
100
- if (input?.body && method !== "GET" && method !== "HEAD") {
101
- fetchOptions.body = JSON.stringify(input.body);
102
- headers["Content-Type"] = "application/json";
103
- }
104
-
105
- const response = await baseFetch(url.toString(), fetchOptions);
106
-
107
- if (!response.ok) {
108
- let body: unknown;
109
- try {
110
- body = await response.json();
111
- } catch {
112
- body = await response.text().catch(() => null);
113
- }
114
- throw new RpcError(response.status, body);
115
- }
116
-
117
- const contentType = response.headers.get("content-type") ?? "";
118
- if (contentType.includes("application/json")) {
119
- return response.json();
120
- }
121
- return response.text();
122
- };
123
- }
124
-
125
- return {
126
- get: makeRequest("GET"),
127
- post: makeRequest("POST"),
128
- put: makeRequest("PUT"),
129
- patch: makeRequest("PATCH"),
130
- delete: makeRequest("DELETE"),
131
- };
132
- }
133
-
134
- export interface RpcMethods {
135
- get: (input?: RpcRequestOptions) => Promise<unknown>;
136
- post: (input?: RpcRequestOptions) => Promise<unknown>;
137
- put: (input?: RpcRequestOptions) => Promise<unknown>;
138
- patch: (input?: RpcRequestOptions) => Promise<unknown>;
139
- delete: (input?: RpcRequestOptions) => Promise<unknown>;
140
- }
1
+ /**
2
+ * Mandu RPC Client
3
+ * Contract 정의에서 타입 안전한 API 클라이언트 생성
4
+ */
5
+
6
+ // ========== Types ==========
7
+
8
+ /** RPC 클라이언트 에러 */
9
+ export class RpcError extends Error {
10
+ constructor(
11
+ public readonly status: number,
12
+ public readonly body: unknown
13
+ ) {
14
+ super(`API Error ${status}`);
15
+ this.name = "RpcError";
16
+ }
17
+ }
18
+
19
+ export interface RpcRequestOptions {
20
+ query?: Record<string, unknown>;
21
+ body?: unknown;
22
+ params?: Record<string, string>;
23
+ headers?: Record<string, string>;
24
+ signal?: AbortSignal;
25
+ }
26
+
27
+ export interface RpcClientOptions {
28
+ /** API base URL (기본: 현재 origin) */
29
+ baseUrl?: string;
30
+ /** 공통 헤더 */
31
+ headers?: Record<string, string>;
32
+ /** 커스텀 fetch (테스트용) */
33
+ fetch?: typeof globalThis.fetch;
34
+ }
35
+
36
+ // ========== Implementation ==========
37
+
38
+ /**
39
+ * Contract 기반 타입 안전 RPC 클라이언트 생성
40
+ *
41
+ * @example
42
+ * ```typescript
43
+ * import { createClient } from "@mandujs/core/client";
44
+ * import type todoContract from "../spec/contracts/api-todos.contract";
45
+ *
46
+ * const api = createClient<typeof todoContract>("/api/todos");
47
+ *
48
+ * // 타입 추론 동작
49
+ * const { todos } = await api.get({ query: { page: 2 } });
50
+ * const { id } = await api.post({ body: { title: "New" } });
51
+ * ```
52
+ */
53
+ export function createClient<TContract = unknown>(
54
+ path: string,
55
+ options?: RpcClientOptions
56
+ ): RpcMethods {
57
+ const baseFetch = options?.fetch ?? globalThis.fetch;
58
+ const baseUrl = options?.baseUrl ?? "";
59
+ const baseHeaders = options?.headers ?? {};
60
+
61
+ function makeRequest(method: string) {
62
+ return async (input?: RpcRequestOptions): Promise<unknown> => {
63
+ const url = new URL(`${baseUrl}${path}`, typeof window !== "undefined" ? window.location.origin : "http://localhost");
64
+
65
+ // URL 파라미터 치환
66
+ if (input?.params) {
67
+ let resolvedPath = url.pathname;
68
+ for (const [key, value] of Object.entries(input.params)) {
69
+ resolvedPath = resolvedPath.replace(`:${key}`, encodeURIComponent(value));
70
+ }
71
+ // 미해결 파라미터 검출
72
+ if (resolvedPath.includes(":")) {
73
+ throw new RpcError(0, `Unresolved path params in "${resolvedPath}". Check your params object.`);
74
+ }
75
+ url.pathname = resolvedPath;
76
+ }
77
+
78
+ // Query 파라미터
79
+ if (input?.query) {
80
+ for (const [key, value] of Object.entries(input.query)) {
81
+ if (value !== undefined && value !== null) {
82
+ url.searchParams.set(key, String(value));
83
+ }
84
+ }
85
+ }
86
+
87
+ const headers: Record<string, string> = {
88
+ ...baseHeaders,
89
+ ...input?.headers,
90
+ "Accept": "application/json",
91
+ };
92
+
93
+ const fetchOptions: RequestInit = {
94
+ method: method.toUpperCase(),
95
+ headers,
96
+ signal: input?.signal,
97
+ };
98
+
99
+ // Body (GET/HEAD 제외)
100
+ if (input?.body && method !== "GET" && method !== "HEAD") {
101
+ fetchOptions.body = JSON.stringify(input.body);
102
+ headers["Content-Type"] = "application/json";
103
+ }
104
+
105
+ const response = await baseFetch(url.toString(), fetchOptions);
106
+
107
+ if (!response.ok) {
108
+ let body: unknown;
109
+ try {
110
+ body = await response.json();
111
+ } catch {
112
+ body = await response.text().catch(() => null);
113
+ }
114
+ throw new RpcError(response.status, body);
115
+ }
116
+
117
+ const contentType = response.headers.get("content-type") ?? "";
118
+ if (contentType.includes("application/json")) {
119
+ return response.json();
120
+ }
121
+ return response.text();
122
+ };
123
+ }
124
+
125
+ return {
126
+ get: makeRequest("GET"),
127
+ post: makeRequest("POST"),
128
+ put: makeRequest("PUT"),
129
+ patch: makeRequest("PATCH"),
130
+ delete: makeRequest("DELETE"),
131
+ };
132
+ }
133
+
134
+ export interface RpcMethods {
135
+ get: (input?: RpcRequestOptions) => Promise<unknown>;
136
+ post: (input?: RpcRequestOptions) => Promise<unknown>;
137
+ put: (input?: RpcRequestOptions) => Promise<unknown>;
138
+ patch: (input?: RpcRequestOptions) => Promise<unknown>;
139
+ delete: (input?: RpcRequestOptions) => Promise<unknown>;
140
+ }
141
+
142
+ // ═══════════════════════════════════════════════════════════════════════════
143
+ // Phase 18.κ — Typed RPC Client (tRPC-like)
144
+ // ═══════════════════════════════════════════════════════════════════════════
145
+ //
146
+ // `createRpcClient<typeof postsRpc>()` returns a Proxy whose properties
147
+ // are the RPC procedure names (`list`, `get`, …). Each access produces
148
+ // an async function whose input type is the procedure's Zod input
149
+ // type and whose return type is the procedure's Zod output type.
150
+ //
151
+ // Wire protocol:
152
+ // POST <baseUrl>/<method>
153
+ // body: { "input": <value> }
154
+ // response: { "ok": true, "data": <value> }
155
+ // | { "ok": false, "error": { code, message, issues? } }
156
+ //
157
+ // Errors throw {@link RpcCallError} with the structured fields
158
+ // preserved so UI code can surface field-level validation issues.
159
+
160
+ import type { RpcClient, RpcDefinition, RpcProcedureRecord, RpcWireEnvelope, RpcWireError } from "../contract/rpc";
161
+
162
+ /** Options passed to {@link createRpcClient}. */
163
+ export interface CreateRpcClientOptions {
164
+ /**
165
+ * Absolute or site-relative base URL for the RPC endpoint —
166
+ * typically `/api/rpc/<name>`. The method name is appended.
167
+ */
168
+ baseUrl: string;
169
+ /** Extra headers sent with every call. */
170
+ headers?: Record<string, string>;
171
+ /** Custom fetch (e.g. test double or node-fetch polyfill). */
172
+ fetch?: typeof globalThis.fetch;
173
+ /**
174
+ * Optional per-call AbortSignal factory. Useful when the proxy is
175
+ * shared across React components that want independent cancellation.
176
+ */
177
+ signal?: AbortSignal;
178
+ }
179
+
180
+ /**
181
+ * Typed error thrown by {@link createRpcClient} calls on non-OK
182
+ * envelopes. Carries the wire-level {@link RpcWireError} + HTTP
183
+ * status so callers can distinguish validation vs. handler errors
184
+ * without string-matching on `message`.
185
+ */
186
+ export class RpcCallError extends Error {
187
+ constructor(
188
+ public readonly status: number,
189
+ public readonly error: RpcWireError
190
+ ) {
191
+ super(`[Mandu RPC] ${error.code}: ${error.message}`);
192
+ this.name = "RpcCallError";
193
+ }
194
+
195
+ /** Machine-readable code (forwarded from the server). */
196
+ get code(): string {
197
+ return this.error.code;
198
+ }
199
+
200
+ /** Field-level issues, if any. */
201
+ get issues(): RpcWireError["issues"] {
202
+ return this.error.issues;
203
+ }
204
+ }
205
+
206
+ /**
207
+ * Create a typed RPC client from an `RpcDefinition` type import.
208
+ *
209
+ * The implementation uses a `Proxy` so no codegen step is required —
210
+ * TypeScript infers call signatures from the imported `typeof` at
211
+ * compile time, and at runtime every property access produces a
212
+ * fetch wrapper.
213
+ *
214
+ * @example
215
+ * ```ts
216
+ * import { createRpcClient } from "@mandujs/core/client";
217
+ * import type { postsRpc } from "../server/rpc/posts";
218
+ *
219
+ * const api = createRpcClient<typeof postsRpc>({ baseUrl: "/api/rpc/posts" });
220
+ * const posts = await api.list({ limit: 20 }); // fully typed
221
+ * const post = await api.get({ id: "abc" }); // fully typed
222
+ * ```
223
+ *
224
+ * Type-check failures at the call site are real compile errors:
225
+ * `api.list({ limit: "not-a-number" })` is a TS2322.
226
+ */
227
+ export function createRpcClient<TDef extends RpcDefinition<RpcProcedureRecord>>(
228
+ options: CreateRpcClientOptions
229
+ ): RpcClient<TDef> {
230
+ const baseFetch = options.fetch ?? globalThis.fetch;
231
+ const baseUrl = options.baseUrl.replace(/\/$/, "");
232
+ const baseHeaders = options.headers ?? {};
233
+
234
+ const call = async (method: string, input: unknown): Promise<unknown> => {
235
+ const url = `${baseUrl}/${method}`;
236
+ const payload = input === undefined ? { input: undefined } : { input };
237
+ const response = await baseFetch(url, {
238
+ method: "POST",
239
+ headers: {
240
+ "Content-Type": "application/json",
241
+ Accept: "application/json",
242
+ ...baseHeaders,
243
+ },
244
+ body: JSON.stringify(payload),
245
+ signal: options.signal,
246
+ });
247
+
248
+ // Prefer JSON parse (every legitimate RPC response is JSON), but
249
+ // tolerate non-JSON failure paths (e.g. upstream proxy error).
250
+ let envelope: RpcWireEnvelope | null = null;
251
+ const text = await response.text();
252
+ try {
253
+ envelope = text.length > 0 ? (JSON.parse(text) as RpcWireEnvelope) : null;
254
+ } catch {
255
+ envelope = null;
256
+ }
257
+
258
+ if (envelope && envelope.ok === true) {
259
+ return envelope.data;
260
+ }
261
+
262
+ // Non-OK path — throw a structured error. Prefer server-emitted
263
+ // envelope; fall back to a synthesized one for transport errors.
264
+ let error: RpcWireError;
265
+ if (envelope && envelope.ok === false) {
266
+ error = envelope.error;
267
+ } else {
268
+ error = {
269
+ code: response.ok ? "BAD_RESPONSE" : `HTTP_${response.status}`,
270
+ message:
271
+ text.length > 0
272
+ ? text.slice(0, 500)
273
+ : `RPC call to ${url} failed with HTTP ${response.status}`,
274
+ };
275
+ }
276
+ throw new RpcCallError(response.status, error);
277
+ };
278
+
279
+ // A Proxy on a plain object: every property access returns a
280
+ // pre-curried call fn. Symbol keys (`Symbol.toStringTag`, etc.) fall
281
+ // through so `console.log(api)` does not throw.
282
+ const target = Object.create(null) as Record<string, unknown>;
283
+ return new Proxy(target, {
284
+ get(_t, prop) {
285
+ if (typeof prop !== "string") return undefined;
286
+ // Allow common non-method inspection hooks to no-op.
287
+ if (prop === "then") return undefined; // not thenable
288
+ if (prop === "toJSON") return undefined;
289
+ return (input?: unknown) => call(prop, input);
290
+ },
291
+ }) as RpcClient<TDef>;
292
+ }
293
+
@@ -3,6 +3,10 @@ import { readJsonFile } from "../utils/bun";
3
3
  import type { ManduAdapter } from "../runtime/adapter";
4
4
  import type { ManduPlugin, ManduHooks } from "../plugins/hooks";
5
5
  import type { Middleware } from "../middleware/define";
6
+ import type { RpcDefinition, RpcProcedureRecord } from "../contract/rpc";
7
+ import type { CronDef } from "../scheduler";
8
+ import type { GuardRule as CustomGuardRule } from "../guard/define-rule";
9
+ import type { I18nStrategy, LocaleCode } from "../i18n/types";
6
10
 
7
11
  export type GuardRuleSeverity = "error" | "warn" | "warning" | "off";
8
12
 
@@ -146,7 +150,20 @@ export interface ManduConfig {
146
150
  srcDir?: string;
147
151
  exclude?: string[];
148
152
  realtime?: boolean;
149
- rules?: Record<string, GuardRuleSeverity>;
153
+ /**
154
+ * Built-in rule severity overrides (map) OR consumer-defined
155
+ * custom rules (array). The Guard runner dispatches on shape:
156
+ *
157
+ * - `Record<string, GuardRuleSeverity>` → override severity of
158
+ * Mandu's built-in rules by id.
159
+ * - `GuardRule[]` (Phase 18.ν) → register consumer-defined rules
160
+ * alongside the built-in presets. See `@mandujs/core/guard/define-rule`.
161
+ *
162
+ * Passing both at once is not supported; pick one shape per config.
163
+ * Mixed input falls back to "custom rules only" and the built-in
164
+ * rule severity overrides become unreachable.
165
+ */
166
+ rules?: Record<string, GuardRuleSeverity> | CustomGuardRule[];
150
167
  contractRequired?: GuardRuleSeverity;
151
168
  /**
152
169
  * Issue #207 — hard-fail on direct `__generated__/` imports at the
@@ -373,6 +390,95 @@ export interface ManduConfig {
373
390
  * @see `docs/architect/middleware-composition.md`
374
391
  */
375
392
  middleware?: Middleware[];
393
+ /**
394
+ * Phase 18.κ — tRPC-like typed RPC endpoints.
395
+ *
396
+ * Keys become URL segments: `endpoints.posts` is served from
397
+ * `/api/rpc/posts/<method>`. Each value is a `defineRpc()` result —
398
+ * a tagged object carrying Zod input/output schemas and handler
399
+ * functions. The runtime dispatcher (`runtime/server.ts`) registers
400
+ * every declared endpoint at `startServer()` time and validates
401
+ * both request inputs and handler outputs against the schemas.
402
+ *
403
+ * Wire protocol: `POST /api/rpc/<name>/<method>` with JSON body
404
+ * `{ "input": <value> }`; returns `{ ok: true, data }` or
405
+ * `{ ok: false, error: { code, message, issues? } }`.
406
+ *
407
+ * Client: `createRpcClient<typeof postsRpc>({ baseUrl: "/api/rpc/posts" })`.
408
+ *
409
+ * @see `docs/architect/typed-rpc.md`
410
+ * @see `@mandujs/core/contract/rpc` for `defineRpc`.
411
+ * @see `@mandujs/core/client/rpc` for `createRpcClient`.
412
+ */
413
+ rpc?: {
414
+ endpoints?: Record<string, RpcDefinition<RpcProcedureRecord>>;
415
+ };
416
+ /**
417
+ * Phase 18.λ — declarative cron job scheduler.
418
+ *
419
+ * `scheduler.jobs` is an array of {@link CronDef} objects (from
420
+ * `@mandujs/core/scheduler`). At `startServer()` boot time the runtime
421
+ * filters the set by `runOn === "bun"` (or `runOn` omitted) and registers
422
+ * each surviving job with `Bun.cron`. At build time, when
423
+ * `--target=workers` is set, the CLI filters by `runOn === "workers"` (or
424
+ * omitted) and emits the schedule strings into the generated
425
+ * `wrangler.toml` `[triggers] crons = [...]` block.
426
+ *
427
+ * Schedule strings are validated synchronously at `defineCron` time, so
428
+ * malformed crontabs fail boot instead of silently never firing.
429
+ *
430
+ * `scheduler.disabled` is an escape hatch for environments where cron
431
+ * should not fire (e.g., a read-only replica). Default: `false`.
432
+ *
433
+ * @see `docs/architect/cron-scheduler.md`
434
+ * @see `@mandujs/core/scheduler` for `defineCron`.
435
+ */
436
+ scheduler?: {
437
+ jobs?: CronDef[];
438
+ disabled?: boolean;
439
+ };
440
+ /**
441
+ * Phase 18.μ — first-class internationalization.
442
+ *
443
+ * Declaring this block opts the project into the framework's built-in
444
+ * locale resolution + route synthesis. The CLI (`mandu build` /
445
+ * `mandu dev`) materializes per-locale route variants when
446
+ * `strategy === "path-prefix"` so a single `app/docs/page.tsx`
447
+ * serves `/en/docs`, `/ko/docs`, etc. without file duplication.
448
+ *
449
+ * At runtime, the server dispatcher attaches `ctx.locale`
450
+ * ({@link ResolvedLocale}) + `ctx.t` (typed translator, when a
451
+ * message registry is wired) to every loader and stamps
452
+ * `Vary: Accept-Language` on responses so CDNs cache correctly.
453
+ *
454
+ * Coexists with the legacy `app/[lang]/...` manual pattern — users
455
+ * migrate only when ready. See `docs/architect/i18n.md`.
456
+ *
457
+ * @example
458
+ * ```ts
459
+ * export default {
460
+ * i18n: {
461
+ * locales: ['en', 'ko'],
462
+ * defaultLocale: 'en',
463
+ * strategy: 'path-prefix',
464
+ * },
465
+ * } satisfies ManduConfig;
466
+ * ```
467
+ */
468
+ i18n?: {
469
+ /** Non-empty list of supported locale codes. */
470
+ locales: LocaleCode[];
471
+ /** Fallback when no signal matches. MUST be in `locales`. */
472
+ defaultLocale: LocaleCode;
473
+ /** Optional fallback chain between request locale and defaultLocale. */
474
+ fallback?: LocaleCode;
475
+ /** Locale detection strategy. See {@link I18nStrategy}. */
476
+ strategy: I18nStrategy;
477
+ /** Cookie name (default: "mandu_locale"). */
478
+ cookieName?: string;
479
+ /** Domain → locale map; required when strategy === "domain". */
480
+ domains?: Record<string, LocaleCode>;
481
+ };
376
482
  }
377
483
 
378
484
  export const CONFIG_FILES = [