@mandujs/core 0.9.39 → 0.9.40

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.
Files changed (49) hide show
  1. package/README.ko.md +27 -0
  2. package/README.md +21 -5
  3. package/package.json +1 -1
  4. package/src/config/index.ts +1 -0
  5. package/src/config/mandu.ts +60 -0
  6. package/src/contract/client-safe.test.ts +42 -0
  7. package/src/contract/client-safe.ts +114 -0
  8. package/src/contract/client.ts +12 -11
  9. package/src/contract/handler.ts +10 -11
  10. package/src/contract/index.ts +25 -16
  11. package/src/contract/registry.test.ts +206 -0
  12. package/src/contract/registry.ts +568 -0
  13. package/src/contract/schema.ts +48 -12
  14. package/src/contract/types.ts +58 -35
  15. package/src/contract/validator.ts +32 -17
  16. package/src/filling/context.ts +103 -0
  17. package/src/generator/templates.ts +70 -17
  18. package/src/guard/analyzer.ts +9 -4
  19. package/src/guard/check.ts +66 -30
  20. package/src/guard/contract-guard.ts +9 -9
  21. package/src/guard/file-type.test.ts +24 -0
  22. package/src/guard/presets/index.ts +193 -60
  23. package/src/guard/rules.ts +12 -6
  24. package/src/guard/statistics.ts +6 -0
  25. package/src/guard/suggestions.ts +9 -2
  26. package/src/guard/types.ts +11 -1
  27. package/src/guard/validator.ts +160 -9
  28. package/src/guard/watcher.ts +2 -0
  29. package/src/index.ts +8 -1
  30. package/src/runtime/index.ts +1 -0
  31. package/src/runtime/streaming-ssr.ts +123 -2
  32. package/src/seo/index.ts +214 -0
  33. package/src/seo/integration/ssr.ts +307 -0
  34. package/src/seo/render/basic.ts +427 -0
  35. package/src/seo/render/index.ts +143 -0
  36. package/src/seo/render/jsonld.ts +539 -0
  37. package/src/seo/render/opengraph.ts +191 -0
  38. package/src/seo/render/robots.ts +116 -0
  39. package/src/seo/render/sitemap.ts +137 -0
  40. package/src/seo/render/twitter.ts +126 -0
  41. package/src/seo/resolve/index.ts +353 -0
  42. package/src/seo/resolve/opengraph.ts +143 -0
  43. package/src/seo/resolve/robots.ts +73 -0
  44. package/src/seo/resolve/title.ts +94 -0
  45. package/src/seo/resolve/twitter.ts +73 -0
  46. package/src/seo/resolve/url.ts +97 -0
  47. package/src/seo/routes/index.ts +290 -0
  48. package/src/seo/types.ts +575 -0
  49. package/src/slot/validator.ts +39 -16
package/README.ko.md CHANGED
@@ -133,6 +133,33 @@ if (!result.passed) {
133
133
  | `COMPONENT_NOT_FOUND` | 컴포넌트 파일 없음 | ❌ |
134
134
  | `SLOT_NOT_FOUND` | slot 파일 없음 | ✅ |
135
135
 
136
+ ## Contract 모듈
137
+
138
+ Zod 기반 계약(Contract) 정의 및 타입 안전 클라이언트 생성.
139
+
140
+ ```typescript
141
+ import { Mandu } from "@mandujs/core";
142
+ import { z } from "zod";
143
+
144
+ const userContract = Mandu.contract({
145
+ request: {
146
+ GET: { query: z.object({ id: z.string() }) },
147
+ POST: { body: z.object({ name: z.string() }) },
148
+ },
149
+ response: {
150
+ 200: z.object({ data: z.any() }),
151
+ 400: z.object({ error: z.string() }),
152
+ },
153
+ });
154
+
155
+ // 클라이언트에 노출할 스키마만 선택
156
+ const clientContract = Mandu.clientContract(userContract, {
157
+ request: { POST: { body: true } },
158
+ response: [200],
159
+ includeErrors: true,
160
+ });
161
+ ```
162
+
136
163
  ## Runtime 모듈
137
164
 
138
165
  서버 시작 및 라우팅.
package/README.md CHANGED
@@ -160,7 +160,7 @@ listPresets().forEach(p => console.log(p.name, p.description));
160
160
 
161
161
  | Preset | Layers | Use Case |
162
162
  |--------|--------|----------|
163
- | `mandu` | app, pages, widgets, features, entities, api, application, domain, infra, core, shared | Fullstack (default) |
163
+ | `mandu` | client/*, shared/(contracts, types, utils/*, schema, env), server/* | Fullstack (default) |
164
164
  | `fsd` | app, pages, widgets, features, entities, shared | Frontend |
165
165
  | `clean` | api, application, domain, infra, shared | Backend |
166
166
  | `hexagonal` | adapters, ports, application, domain | DDD |
@@ -295,10 +295,26 @@ const handlers = Mandu.handler(userContract, {
295
295
  POST: (ctx) => ({ data: createUser(ctx.body) })
296
296
  });
297
297
 
298
- // Type-safe client
299
- const client = Mandu.client(userContract, { baseUrl: "/api/users" });
300
- const result = await client.GET({ query: { id: "123" } });
301
- ```
298
+ // Type-safe client
299
+ const client = Mandu.client(userContract, { baseUrl: "/api/users" });
300
+ const result = await client.GET({ query: { id: "123" } });
301
+ ```
302
+
303
+ ### Client-safe Contract
304
+
305
+ Limit schemas exposed to client-side usage (forms/UI validation):
306
+
307
+ ```typescript
308
+ const clientContract = Mandu.clientContract(userContract, {
309
+ request: {
310
+ POST: { body: true },
311
+ },
312
+ response: [201],
313
+ includeErrors: true,
314
+ });
315
+
316
+ const client = Mandu.client(clientContract, { baseUrl: "/api/users" });
317
+ ```
302
318
 
303
319
  ---
304
320
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mandujs/core",
3
- "version": "0.9.39",
3
+ "version": "0.9.40",
4
4
  "description": "Mandu Framework Core - Spec, Generator, Guard, Runtime",
5
5
  "type": "module",
6
6
  "main": "./src/index.ts",
@@ -0,0 +1 @@
1
+ export * from "./mandu";
@@ -0,0 +1,60 @@
1
+ import path from "path";
2
+ import fs from "fs/promises";
3
+
4
+ export type GuardRuleSeverity = "error" | "warn" | "off";
5
+
6
+ export interface ManduConfig {
7
+ guard?: {
8
+ rules?: Record<string, GuardRuleSeverity>;
9
+ contractRequired?: GuardRuleSeverity;
10
+ };
11
+ }
12
+
13
+ const CONFIG_FILES = [
14
+ "mandu.config.ts",
15
+ "mandu.config.js",
16
+ "mandu.config.json",
17
+ path.join(".mandu", "guard.json"),
18
+ ];
19
+
20
+ function coerceConfig(raw: unknown, source: string): ManduConfig {
21
+ if (!raw || typeof raw !== "object") return {};
22
+
23
+ // .mandu/guard.json can be guard-only
24
+ if (source.endsWith("guard.json") && !("guard" in (raw as Record<string, unknown>))) {
25
+ return { guard: raw as ManduConfig["guard"] };
26
+ }
27
+
28
+ return raw as ManduConfig;
29
+ }
30
+
31
+ export async function loadManduConfig(rootDir: string): Promise<ManduConfig> {
32
+ for (const fileName of CONFIG_FILES) {
33
+ const filePath = path.join(rootDir, fileName);
34
+ try {
35
+ await fs.access(filePath);
36
+ } catch {
37
+ continue;
38
+ }
39
+
40
+ if (fileName.endsWith(".json")) {
41
+ try {
42
+ const content = await Bun.file(filePath).text();
43
+ const parsed = JSON.parse(content);
44
+ return coerceConfig(parsed, fileName);
45
+ } catch {
46
+ return {};
47
+ }
48
+ }
49
+
50
+ try {
51
+ const module = await import(filePath);
52
+ const raw = module?.default ?? module;
53
+ return coerceConfig(raw, fileName);
54
+ } catch {
55
+ return {};
56
+ }
57
+ }
58
+
59
+ return {};
60
+ }
@@ -0,0 +1,42 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { z } from "zod";
3
+ import { Mandu, createClientContract } from "../index";
4
+
5
+ describe("Client-safe contract", () => {
6
+ const contract = Mandu.contract({
7
+ request: {
8
+ GET: {
9
+ query: z.object({ id: z.string() }),
10
+ },
11
+ POST: {
12
+ body: z.object({ name: z.string() }),
13
+ },
14
+ },
15
+ response: {
16
+ 200: z.object({ ok: z.boolean() }),
17
+ 201: z.object({ id: z.string() }),
18
+ 400: z.object({ error: z.string() }),
19
+ },
20
+ });
21
+
22
+ it("should pick only selected schemas", () => {
23
+ const clientContract = createClientContract(contract, {
24
+ request: {
25
+ POST: { body: true },
26
+ },
27
+ response: [201],
28
+ includeErrors: true,
29
+ });
30
+
31
+ expect(clientContract.request.GET).toBeUndefined();
32
+ expect(clientContract.request.POST?.body).toBeDefined();
33
+ expect(clientContract.response[200]).toBeUndefined();
34
+ expect(clientContract.response[201]).toBeDefined();
35
+ expect(clientContract.response[400]).toBeDefined();
36
+ });
37
+
38
+ it("should return original contract when no options are provided", () => {
39
+ const clientContract = createClientContract(contract);
40
+ expect(clientContract).toBe(contract);
41
+ });
42
+ });
@@ -0,0 +1,114 @@
1
+ /**
2
+ * Mandu Client-safe Contract Utilities
3
+ * Reduce contract exposure for client usage (forms, UI validation)
4
+ */
5
+
6
+ import type {
7
+ ContractSchema,
8
+ ContractMethod,
9
+ MethodRequestSchema,
10
+ ClientSafeOptions,
11
+ ContractRequestSchema,
12
+ ContractResponseSchema,
13
+ } from "./schema";
14
+
15
+ const ERROR_STATUS_CODES = [400, 401, 403, 404, 500] as const;
16
+
17
+ function normalizeResponseSelection(
18
+ selection: ClientSafeOptions["response"]
19
+ ): number[] {
20
+ if (!selection) return [];
21
+ if (Array.isArray(selection)) return selection;
22
+
23
+ const result: number[] = [];
24
+ for (const [code, enabled] of Object.entries(selection)) {
25
+ if (enabled) {
26
+ const num = Number(code);
27
+ if (!Number.isNaN(num)) {
28
+ result.push(num);
29
+ }
30
+ }
31
+ }
32
+ return result;
33
+ }
34
+
35
+ function pickRequestSchema(
36
+ methodSchema: MethodRequestSchema,
37
+ selection: NonNullable<ClientSafeOptions["request"]>[ContractMethod]
38
+ ): MethodRequestSchema | undefined {
39
+ if (!selection) return undefined;
40
+
41
+ const picked: MethodRequestSchema = {};
42
+
43
+ if (selection.query && methodSchema.query) {
44
+ picked.query = methodSchema.query;
45
+ }
46
+ if (selection.body && methodSchema.body) {
47
+ picked.body = methodSchema.body;
48
+ }
49
+ if (selection.params && methodSchema.params) {
50
+ picked.params = methodSchema.params;
51
+ }
52
+ if (selection.headers && methodSchema.headers) {
53
+ picked.headers = methodSchema.headers;
54
+ }
55
+
56
+ return Object.keys(picked).length > 0 ? picked : undefined;
57
+ }
58
+
59
+ /**
60
+ * Create a client-safe contract by selecting exposed schemas.
61
+ * If options are omitted and contract.clientSafe is not defined,
62
+ * the original contract is returned (with a warning).
63
+ */
64
+ export function createClientContract<T extends ContractSchema>(
65
+ contract: T,
66
+ options?: ClientSafeOptions
67
+ ): ContractSchema {
68
+ const resolved = options ?? contract.clientSafe;
69
+
70
+ if (!resolved) {
71
+ console.warn(
72
+ "[Mandu] clientContract: no clientSafe options provided. Returning original contract."
73
+ );
74
+ return contract;
75
+ }
76
+
77
+ const requestSelection = resolved.request ?? {};
78
+ const responseSelection = normalizeResponseSelection(resolved.response);
79
+ const safeRequest: ContractRequestSchema = {};
80
+ const safeResponse: ContractResponseSchema = {};
81
+
82
+ for (const method of Object.keys(requestSelection) as ContractMethod[]) {
83
+ const methodSchema = contract.request[method] as MethodRequestSchema | undefined;
84
+ if (!methodSchema) continue;
85
+
86
+ const picked = pickRequestSchema(methodSchema, requestSelection[method]);
87
+ if (picked) {
88
+ safeRequest[method] = picked;
89
+ }
90
+ }
91
+
92
+ const allowedResponses = new Set(responseSelection);
93
+
94
+ if (resolved.includeErrors) {
95
+ for (const code of ERROR_STATUS_CODES) {
96
+ if (contract.response[code]) {
97
+ allowedResponses.add(code);
98
+ }
99
+ }
100
+ }
101
+
102
+ for (const code of allowedResponses) {
103
+ const schema = contract.response[code];
104
+ if (schema) {
105
+ safeResponse[code] = schema;
106
+ }
107
+ }
108
+
109
+ return {
110
+ ...contract,
111
+ request: safeRequest,
112
+ response: safeResponse,
113
+ };
114
+ }
@@ -8,11 +8,12 @@
8
8
  */
9
9
 
10
10
  import type { z } from "zod";
11
- import type {
12
- ContractSchema,
13
- ContractMethod,
14
- MethodRequestSchema,
15
- } from "./schema";
11
+ import type {
12
+ ContractSchema,
13
+ ContractMethod,
14
+ MethodRequestSchema,
15
+ } from "./schema";
16
+ import type { InferResponseSchema } from "./types";
16
17
 
17
18
  /**
18
19
  * Client options for making requests
@@ -72,12 +73,12 @@ type InferRequestOptions<T extends MethodRequestSchema | undefined> =
72
73
  /**
73
74
  * Infer success response from contract
74
75
  */
75
- type InferSuccessResponse<TResponse extends ContractSchema["response"]> =
76
- TResponse[200] extends z.ZodTypeAny
77
- ? z.infer<TResponse[200]>
78
- : TResponse[201] extends z.ZodTypeAny
79
- ? z.infer<TResponse[201]>
80
- : unknown;
76
+ type InferSuccessResponse<TResponse extends ContractSchema["response"]> =
77
+ InferResponseSchema<TResponse[200]> extends never
78
+ ? InferResponseSchema<TResponse[201]> extends never
79
+ ? unknown
80
+ : InferResponseSchema<TResponse[201]>
81
+ : InferResponseSchema<TResponse[200]>;
81
82
 
82
83
  /**
83
84
  * Contract client method
@@ -5,12 +5,13 @@
5
5
  * Elysia 패턴 채택: Contract → Handler 타입 자동 추론
6
6
  */
7
7
 
8
- import type { z } from "zod";
9
- import type {
10
- ContractSchema,
11
- ContractMethod,
12
- MethodRequestSchema,
13
- } from "./schema";
8
+ import type { z } from "zod";
9
+ import type {
10
+ ContractSchema,
11
+ ContractMethod,
12
+ MethodRequestSchema,
13
+ } from "./schema";
14
+ import type { InferResponseSchema } from "./types";
14
15
 
15
16
  /**
16
17
  * Typed request context for a handler
@@ -61,11 +62,9 @@ export type HandlerFn<TContext, TResponse> = (
61
62
  /**
62
63
  * Infer response type union from contract response schema
63
64
  */
64
- type InferResponseUnion<TResponse extends ContractSchema["response"]> = {
65
- [K in keyof TResponse]: TResponse[K] extends z.ZodTypeAny
66
- ? z.infer<TResponse[K]>
67
- : never;
68
- }[keyof TResponse];
65
+ type InferResponseUnion<TResponse extends ContractSchema["response"]> = {
66
+ [K in keyof TResponse]: InferResponseSchema<TResponse[K]>;
67
+ }[keyof TResponse];
69
68
 
70
69
  /**
71
70
  * Handler definition for all methods in a contract
@@ -10,15 +10,18 @@
10
10
 
11
11
  export * from "./schema";
12
12
  export * from "./types";
13
- export * from "./validator";
14
- export * from "./handler";
15
- export * from "./client";
16
- export * from "./normalize";
17
-
18
- import type { ContractDefinition, ContractInstance, ContractSchema } from "./schema";
19
- import type { ContractHandlers, RouteDefinition } from "./handler";
20
- import { defineHandler, defineRoute } from "./handler";
21
- import { createClient, contractFetch, type ClientOptions } from "./client";
13
+ export * from "./validator";
14
+ export * from "./handler";
15
+ export * from "./client";
16
+ export * from "./normalize";
17
+ export * from "./registry";
18
+ export * from "./client-safe";
19
+
20
+ import type { ContractDefinition, ContractInstance, ContractSchema } from "./schema";
21
+ import type { ContractHandlers, RouteDefinition } from "./handler";
22
+ import { defineHandler, defineRoute } from "./handler";
23
+ import { createClient, contractFetch, type ClientOptions } from "./client";
24
+ import { createClientContract } from "./client-safe";
22
25
 
23
26
  /**
24
27
  * Create a Mandu API Contract
@@ -120,7 +123,7 @@ export function createContract<T extends ContractDefinition>(definition: T): T &
120
123
  * Contract-specific Mandu functions
121
124
  * Note: Use `ManduContract` to avoid conflict with other Mandu exports
122
125
  */
123
- export const ManduContract = {
126
+ export const ManduContract = {
124
127
  /**
125
128
  * Create a typed Contract
126
129
  * Contract 스키마 정의 및 타입 추론
@@ -162,9 +165,9 @@ export const ManduContract = {
162
165
  */
163
166
  route: defineRoute,
164
167
 
165
- /**
166
- * Create a type-safe API client from contract
167
- * Contract 기반 타입 안전 클라이언트 생성
168
+ /**
169
+ * Create a type-safe API client from contract
170
+ * Contract 기반 타입 안전 클라이언트 생성
168
171
  *
169
172
  * @example
170
173
  * ```typescript
@@ -177,7 +180,13 @@ export const ManduContract = {
177
180
  * const newUser = await client.POST({ body: { name: "Alice" } });
178
181
  * ```
179
182
  */
180
- client: createClient,
183
+ client: createClient,
184
+
185
+ /**
186
+ * Create a client-safe contract
187
+ * Client에서 노출할 스키마만 선택
188
+ */
189
+ clientContract: createClientContract,
181
190
 
182
191
  /**
183
192
  * Single type-safe fetch call
@@ -190,8 +199,8 @@ export const ManduContract = {
190
199
  * });
191
200
  * ```
192
201
  */
193
- fetch: contractFetch,
194
- } as const;
202
+ fetch: contractFetch,
203
+ } as const;
195
204
 
196
205
  /**
197
206
  * Alias for backward compatibility within contract module
@@ -0,0 +1,206 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { diffContractRegistry, type ContractRegistry } from "./registry";
3
+
4
+ describe("Contract registry diff", () => {
5
+ it("should mark added optional fields as minor", () => {
6
+ const prev: ContractRegistry = {
7
+ version: 1,
8
+ generatedAt: "",
9
+ contracts: [
10
+ {
11
+ id: "users",
12
+ routeId: "users",
13
+ file: "spec/contracts/users.contract.ts",
14
+ methods: ["POST"],
15
+ request: {
16
+ POST: { query: false, body: true, params: false, headers: false },
17
+ },
18
+ response: [201],
19
+ hash: null,
20
+ schemas: {
21
+ request: {
22
+ POST: {
23
+ body: {
24
+ type: "object",
25
+ keys: ["name"],
26
+ required: ["name"],
27
+ },
28
+ },
29
+ },
30
+ response: {
31
+ 201: {
32
+ type: "object",
33
+ keys: ["id"],
34
+ required: ["id"],
35
+ },
36
+ },
37
+ },
38
+ },
39
+ ],
40
+ };
41
+
42
+ const next: ContractRegistry = {
43
+ version: 1,
44
+ generatedAt: "",
45
+ contracts: [
46
+ {
47
+ id: "users",
48
+ routeId: "users",
49
+ file: "spec/contracts/users.contract.ts",
50
+ methods: ["POST"],
51
+ request: {
52
+ POST: { query: false, body: true, params: false, headers: false },
53
+ },
54
+ response: [201],
55
+ hash: null,
56
+ schemas: {
57
+ request: {
58
+ POST: {
59
+ body: {
60
+ type: "object",
61
+ keys: ["age", "name"],
62
+ required: ["name"],
63
+ },
64
+ },
65
+ },
66
+ response: {
67
+ 201: {
68
+ type: "object",
69
+ keys: ["id"],
70
+ required: ["id"],
71
+ },
72
+ },
73
+ },
74
+ },
75
+ ],
76
+ };
77
+
78
+ const diff = diffContractRegistry(prev, next);
79
+ expect(diff.summary.major).toBe(0);
80
+ expect(diff.summary.minor).toBeGreaterThan(0);
81
+ });
82
+
83
+ it("should mark required field addition as major", () => {
84
+ const prev: ContractRegistry = {
85
+ version: 1,
86
+ generatedAt: "",
87
+ contracts: [
88
+ {
89
+ id: "users",
90
+ routeId: "users",
91
+ file: "spec/contracts/users.contract.ts",
92
+ methods: ["POST"],
93
+ request: {
94
+ POST: { query: false, body: true, params: false, headers: false },
95
+ },
96
+ response: [201],
97
+ hash: null,
98
+ schemas: {
99
+ request: {
100
+ POST: {
101
+ body: {
102
+ type: "object",
103
+ keys: ["name", "age"],
104
+ required: ["name"],
105
+ },
106
+ },
107
+ },
108
+ },
109
+ },
110
+ ],
111
+ };
112
+
113
+ const next: ContractRegistry = {
114
+ version: 1,
115
+ generatedAt: "",
116
+ contracts: [
117
+ {
118
+ id: "users",
119
+ routeId: "users",
120
+ file: "spec/contracts/users.contract.ts",
121
+ methods: ["POST"],
122
+ request: {
123
+ POST: { query: false, body: true, params: false, headers: false },
124
+ },
125
+ response: [201],
126
+ hash: null,
127
+ schemas: {
128
+ request: {
129
+ POST: {
130
+ body: {
131
+ type: "object",
132
+ keys: ["name", "age"],
133
+ required: ["name", "age"],
134
+ },
135
+ },
136
+ },
137
+ },
138
+ },
139
+ ],
140
+ };
141
+
142
+ const diff = diffContractRegistry(prev, next);
143
+ expect(diff.summary.major).toBeGreaterThan(0);
144
+ });
145
+
146
+ it("should mark enum value removal as major", () => {
147
+ const prev: ContractRegistry = {
148
+ version: 1,
149
+ generatedAt: "",
150
+ contracts: [
151
+ {
152
+ id: "users",
153
+ routeId: "users",
154
+ file: "spec/contracts/users.contract.ts",
155
+ methods: ["GET"],
156
+ request: {
157
+ GET: { query: true, body: false, params: false, headers: false },
158
+ },
159
+ response: [200],
160
+ hash: null,
161
+ schemas: {
162
+ request: {
163
+ GET: {
164
+ query: {
165
+ type: "enum",
166
+ values: ["active", "inactive"],
167
+ },
168
+ },
169
+ },
170
+ },
171
+ },
172
+ ],
173
+ };
174
+
175
+ const next: ContractRegistry = {
176
+ version: 1,
177
+ generatedAt: "",
178
+ contracts: [
179
+ {
180
+ id: "users",
181
+ routeId: "users",
182
+ file: "spec/contracts/users.contract.ts",
183
+ methods: ["GET"],
184
+ request: {
185
+ GET: { query: true, body: false, params: false, headers: false },
186
+ },
187
+ response: [200],
188
+ hash: null,
189
+ schemas: {
190
+ request: {
191
+ GET: {
192
+ query: {
193
+ type: "enum",
194
+ values: ["active"],
195
+ },
196
+ },
197
+ },
198
+ },
199
+ },
200
+ ],
201
+ };
202
+
203
+ const diff = diffContractRegistry(prev, next);
204
+ expect(diff.summary.major).toBeGreaterThan(0);
205
+ });
206
+ });