@mandujs/core 0.18.22 → 0.19.2

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 (91) hide show
  1. package/README.ko.md +0 -14
  2. package/package.json +4 -1
  3. package/src/brain/architecture/analyzer.ts +4 -4
  4. package/src/brain/doctor/analyzer.ts +18 -14
  5. package/src/bundler/build.test.ts +127 -0
  6. package/src/bundler/build.ts +291 -113
  7. package/src/bundler/css.ts +20 -5
  8. package/src/bundler/dev.ts +55 -2
  9. package/src/bundler/prerender.ts +195 -0
  10. package/src/change/snapshot.ts +4 -23
  11. package/src/change/types.ts +2 -3
  12. package/src/client/Form.tsx +105 -0
  13. package/src/client/__tests__/use-sse.test.ts +153 -0
  14. package/src/client/hooks.ts +105 -6
  15. package/src/client/index.ts +35 -6
  16. package/src/client/router.ts +670 -433
  17. package/src/client/rpc.ts +140 -0
  18. package/src/client/runtime.ts +24 -21
  19. package/src/client/use-fetch.ts +239 -0
  20. package/src/client/use-head.ts +197 -0
  21. package/src/client/use-sse.ts +378 -0
  22. package/src/components/Image.tsx +162 -0
  23. package/src/config/mandu.ts +5 -0
  24. package/src/config/validate.ts +34 -0
  25. package/src/content/index.ts +5 -1
  26. package/src/devtools/client/catchers/error-catcher.ts +17 -0
  27. package/src/devtools/client/catchers/network-proxy.ts +390 -367
  28. package/src/devtools/client/components/kitchen-root.tsx +479 -467
  29. package/src/devtools/client/components/panel/diff-viewer.tsx +219 -0
  30. package/src/devtools/client/components/panel/guard-panel.tsx +374 -244
  31. package/src/devtools/client/components/panel/index.ts +45 -32
  32. package/src/devtools/client/components/panel/panel-container.tsx +332 -312
  33. package/src/devtools/client/components/panel/preview-panel.tsx +188 -0
  34. package/src/devtools/client/state-manager.ts +535 -478
  35. package/src/devtools/design-tokens.ts +265 -264
  36. package/src/devtools/types.ts +345 -319
  37. package/src/filling/context.ts +65 -0
  38. package/src/filling/filling.ts +336 -14
  39. package/src/filling/index.ts +5 -1
  40. package/src/filling/session.ts +216 -0
  41. package/src/filling/ws.ts +78 -0
  42. package/src/generator/generate.ts +2 -2
  43. package/src/guard/auto-correct.ts +0 -29
  44. package/src/guard/check.ts +14 -31
  45. package/src/guard/presets/index.ts +296 -294
  46. package/src/guard/rules.ts +15 -19
  47. package/src/guard/validator.ts +834 -834
  48. package/src/index.ts +5 -1
  49. package/src/island/index.ts +373 -304
  50. package/src/kitchen/api/contract-api.ts +225 -0
  51. package/src/kitchen/api/diff-parser.ts +108 -0
  52. package/src/kitchen/api/file-api.ts +273 -0
  53. package/src/kitchen/api/guard-api.ts +83 -0
  54. package/src/kitchen/api/guard-decisions.ts +100 -0
  55. package/src/kitchen/api/routes-api.ts +50 -0
  56. package/src/kitchen/index.ts +21 -0
  57. package/src/kitchen/kitchen-handler.ts +256 -0
  58. package/src/kitchen/kitchen-ui.ts +1732 -0
  59. package/src/kitchen/stream/activity-sse.ts +145 -0
  60. package/src/kitchen/stream/file-tailer.ts +99 -0
  61. package/src/middleware/compress.ts +62 -0
  62. package/src/middleware/cors.ts +47 -0
  63. package/src/middleware/index.ts +10 -0
  64. package/src/middleware/jwt.ts +134 -0
  65. package/src/middleware/logger.ts +58 -0
  66. package/src/middleware/timeout.ts +55 -0
  67. package/src/paths.ts +0 -4
  68. package/src/plugins/hooks.ts +64 -0
  69. package/src/plugins/index.ts +3 -0
  70. package/src/plugins/types.ts +5 -0
  71. package/src/report/build.ts +0 -6
  72. package/src/resource/__tests__/backward-compat.test.ts +0 -1
  73. package/src/router/fs-patterns.ts +11 -1
  74. package/src/router/fs-routes.ts +78 -14
  75. package/src/router/fs-scanner.ts +2 -2
  76. package/src/router/fs-types.ts +2 -1
  77. package/src/runtime/adapter-bun.ts +62 -0
  78. package/src/runtime/adapter.ts +47 -0
  79. package/src/runtime/cache.ts +310 -0
  80. package/src/runtime/handler.ts +65 -0
  81. package/src/runtime/image-handler.ts +195 -0
  82. package/src/runtime/index.ts +12 -0
  83. package/src/runtime/middleware.ts +263 -0
  84. package/src/runtime/server.ts +686 -92
  85. package/src/runtime/ssr.ts +55 -29
  86. package/src/runtime/streaming-ssr.ts +106 -82
  87. package/src/spec/index.ts +0 -1
  88. package/src/spec/schema.ts +1 -0
  89. package/src/testing/index.ts +144 -0
  90. package/src/watcher/watcher.ts +27 -1
  91. package/src/spec/lock.ts +0 -56
@@ -0,0 +1,378 @@
1
+ /**
2
+ * Mandu useSSE Hook - ReadableStream SSE reading for island components
3
+ *
4
+ * Problem: When a React component (inside createRoot) reads a ReadableStream
5
+ * in a tight `while(true) { await reader.read(); setState(...); }` loop,
6
+ * React 19's internal scheduler uses `queueMicrotask` to process state updates.
7
+ * Combined with fast SSE token streams, the microtask queue never drains,
8
+ * starving the macrotask queue (DOM painting, user input, MessageChannel).
9
+ * This effectively freezes the main thread.
10
+ *
11
+ * Solution: This hook yields to the macrotask queue between chunks using
12
+ * `setTimeout(0)`, allowing the browser to paint and process user input
13
+ * between state updates. Chunks arriving during the yield are coalesced
14
+ * into a single state update to minimize re-renders.
15
+ *
16
+ * @module client/use-sse
17
+ */
18
+
19
+ import { useState, useCallback, useRef, useEffect } from "react";
20
+
21
+ // ============================================================================
22
+ // Types
23
+ // ============================================================================
24
+
25
+ export interface UseSSEOptions {
26
+ /**
27
+ * Called for each SSE chunk (after TextDecoder).
28
+ * Return the new accumulated value to set as state.
29
+ * @param accumulated - Current accumulated value
30
+ * @param chunk - New text chunk from the stream
31
+ * @returns Updated accumulated value
32
+ */
33
+ onChunk?: (accumulated: string, chunk: string) => string;
34
+
35
+ /**
36
+ * Called when the stream completes.
37
+ * @param finalValue - The final accumulated value
38
+ */
39
+ onComplete?: (finalValue: string) => void;
40
+
41
+ /**
42
+ * Called when the stream errors.
43
+ * @param error - The error that occurred
44
+ */
45
+ onError?: (error: Error) => void;
46
+
47
+ /**
48
+ * Minimum interval between state updates in ms.
49
+ * Higher values reduce re-renders but increase perceived latency.
50
+ * Default: 0 (yield once per chunk via setTimeout(0))
51
+ */
52
+ throttleMs?: number;
53
+ }
54
+
55
+ export interface UseSSEReturn {
56
+ /** Current accumulated stream data */
57
+ data: string;
58
+
59
+ /** Whether the stream is currently active */
60
+ isStreaming: boolean;
61
+
62
+ /** Error if the stream failed */
63
+ error: Error | null;
64
+
65
+ /**
66
+ * Start reading an SSE stream.
67
+ * Pass a fetch Response or a ReadableStream<Uint8Array>.
68
+ */
69
+ start: (source: Response | ReadableStream<Uint8Array>) => void;
70
+
71
+ /**
72
+ * Abort the current stream.
73
+ */
74
+ abort: () => void;
75
+
76
+ /** Reset state (data, error, isStreaming) */
77
+ reset: () => void;
78
+ }
79
+
80
+ // ============================================================================
81
+ // Helpers
82
+ // ============================================================================
83
+
84
+ /**
85
+ * Yield to the macrotask queue, allowing the browser to paint and
86
+ * process user input. This breaks the microtask starvation cycle
87
+ * caused by tight `await reader.read()` + `setState()` loops.
88
+ */
89
+ function yieldToMacrotask(): Promise<void> {
90
+ return new Promise((resolve) => setTimeout(resolve, 0));
91
+ }
92
+
93
+ // ============================================================================
94
+ // Hook
95
+ // ============================================================================
96
+
97
+ /**
98
+ * React hook for reading SSE / ReadableStream data inside island components
99
+ * without blocking the main thread.
100
+ *
101
+ * @example
102
+ * ```tsx
103
+ * import { useSSE } from "@mandujs/core/client";
104
+ *
105
+ * function ChatIsland() {
106
+ * const { data, isStreaming, start } = useSSE({
107
+ * onComplete: (text) => console.log("Done:", text),
108
+ * });
109
+ *
110
+ * const sendMessage = async () => {
111
+ * const res = await fetch("/api/chat", { method: "POST", body: ... });
112
+ * start(res);
113
+ * };
114
+ *
115
+ * return (
116
+ * <div>
117
+ * <p>{data}</p>
118
+ * {isStreaming && <span>streaming...</span>}
119
+ * <button onClick={sendMessage} disabled={isStreaming}>Send</button>
120
+ * </div>
121
+ * );
122
+ * }
123
+ * ```
124
+ */
125
+ export function useSSE(options: UseSSEOptions = {}): UseSSEReturn {
126
+ const {
127
+ onChunk = (acc: string, chunk: string) => acc + chunk,
128
+ onComplete,
129
+ onError,
130
+ throttleMs = 0,
131
+ } = options;
132
+
133
+ const [data, setData] = useState("");
134
+ const [isStreaming, setIsStreaming] = useState(false);
135
+ const [error, setError] = useState<Error | null>(null);
136
+
137
+ // Refs for the current stream session (allow abort & prevent stale closures)
138
+ const abortRef = useRef<AbortController | null>(null);
139
+ const accumulatorRef = useRef("");
140
+ const pendingChunksRef = useRef("");
141
+ const flushScheduledRef = useRef(false);
142
+ const optionsRef = useRef({ onChunk, onComplete, onError, throttleMs });
143
+
144
+ // Keep options ref in sync
145
+ useEffect(() => {
146
+ optionsRef.current = { onChunk, onComplete, onError, throttleMs };
147
+ });
148
+
149
+ // Cleanup on unmount
150
+ useEffect(() => {
151
+ return () => {
152
+ if (abortRef.current) {
153
+ abortRef.current.abort();
154
+ abortRef.current = null;
155
+ }
156
+ };
157
+ }, []);
158
+
159
+ /**
160
+ * Flush pending chunks as a single batched state update.
161
+ * Uses setTimeout(0) to yield to the macrotask queue first.
162
+ */
163
+ const flushPending = useCallback(() => {
164
+ if (!flushScheduledRef.current) return;
165
+
166
+ const pending = pendingChunksRef.current;
167
+ if (pending) {
168
+ const { onChunk: chunkFn } = optionsRef.current;
169
+ accumulatorRef.current = chunkFn(accumulatorRef.current, pending);
170
+ pendingChunksRef.current = "";
171
+ setData(accumulatorRef.current);
172
+ }
173
+ flushScheduledRef.current = false;
174
+ }, []);
175
+
176
+ /**
177
+ * Schedule a flush via setTimeout to yield to macrotask queue.
178
+ */
179
+ const scheduleFlush = useCallback(() => {
180
+ if (!flushScheduledRef.current) {
181
+ flushScheduledRef.current = true;
182
+ setTimeout(flushPending, optionsRef.current.throttleMs);
183
+ }
184
+ }, [flushPending]);
185
+
186
+ const start = useCallback(
187
+ (source: Response | ReadableStream<Uint8Array>) => {
188
+ // Abort any existing stream
189
+ if (abortRef.current) {
190
+ abortRef.current.abort();
191
+ }
192
+
193
+ const controller = new AbortController();
194
+ abortRef.current = controller;
195
+ accumulatorRef.current = "";
196
+ pendingChunksRef.current = "";
197
+ flushScheduledRef.current = false;
198
+
199
+ setData("");
200
+ setError(null);
201
+ setIsStreaming(true);
202
+
203
+ const body =
204
+ source instanceof Response ? source.body : source;
205
+
206
+ if (!body) {
207
+ setIsStreaming(false);
208
+ setError(new Error("Response has no body"));
209
+ return;
210
+ }
211
+
212
+ // Start reading in a properly-yielding async loop
213
+ (async () => {
214
+ const reader = body.getReader();
215
+ const decoder = new TextDecoder();
216
+
217
+ try {
218
+ while (true) {
219
+ // Check for abort
220
+ if (controller.signal.aborted) {
221
+ reader.cancel();
222
+ break;
223
+ }
224
+
225
+ const { done, value } = await reader.read();
226
+ if (done) break;
227
+
228
+ // Check for abort again (may have been aborted during read)
229
+ if (controller.signal.aborted) {
230
+ reader.cancel();
231
+ break;
232
+ }
233
+
234
+ const text = decoder.decode(value, { stream: true });
235
+ if (text) {
236
+ // Accumulate chunks and schedule a batched flush
237
+ pendingChunksRef.current += text;
238
+ scheduleFlush();
239
+ }
240
+
241
+ // CRITICAL: Yield to the macrotask queue to prevent microtask
242
+ // starvation. Without this, the tight loop of
243
+ // `await reader.read() -> setState -> React microtask -> next read`
244
+ // starves the browser's macrotask queue, freezing the UI.
245
+ await yieldToMacrotask();
246
+ }
247
+
248
+ // Final flush for any remaining chunks
249
+ if (pendingChunksRef.current) {
250
+ flushScheduledRef.current = true;
251
+ flushPending();
252
+ }
253
+
254
+ if (!controller.signal.aborted) {
255
+ setIsStreaming(false);
256
+ const opts = optionsRef.current;
257
+ if (opts.onComplete) {
258
+ opts.onComplete(accumulatorRef.current);
259
+ }
260
+ }
261
+ } catch (err) {
262
+ if (
263
+ !controller.signal.aborted &&
264
+ !(err instanceof DOMException && err.name === "AbortError")
265
+ ) {
266
+ const error =
267
+ err instanceof Error ? err : new Error(String(err));
268
+ setError(error);
269
+ setIsStreaming(false);
270
+ const opts = optionsRef.current;
271
+ if (opts.onError) {
272
+ opts.onError(error);
273
+ }
274
+ }
275
+ }
276
+ })();
277
+ },
278
+ [scheduleFlush, flushPending]
279
+ );
280
+
281
+ const abort = useCallback(() => {
282
+ if (abortRef.current) {
283
+ abortRef.current.abort();
284
+ abortRef.current = null;
285
+ }
286
+ setIsStreaming(false);
287
+ }, []);
288
+
289
+ const reset = useCallback(() => {
290
+ abort();
291
+ setData("");
292
+ setError(null);
293
+ accumulatorRef.current = "";
294
+ pendingChunksRef.current = "";
295
+ flushScheduledRef.current = false;
296
+ }, [abort]);
297
+
298
+ return { data, isStreaming, error, start, abort, reset };
299
+ }
300
+
301
+ // ============================================================================
302
+ // Low-level utility (for non-hook usage)
303
+ // ============================================================================
304
+
305
+ /**
306
+ * Read a ReadableStream with macrotask yielding, suitable for use
307
+ * outside of React components (e.g., in plain event handlers).
308
+ *
309
+ * @example
310
+ * ```ts
311
+ * import { readStreamWithYield } from "@mandujs/core/client";
312
+ *
313
+ * const response = await fetch("/api/stream");
314
+ * await readStreamWithYield(response.body!, {
315
+ * onChunk: (text) => {
316
+ * // update DOM directly
317
+ * el.textContent += text;
318
+ * },
319
+ * onDone: () => console.log("done"),
320
+ * });
321
+ * ```
322
+ */
323
+ export interface ReadStreamOptions {
324
+ /** Called for each decoded text chunk */
325
+ onChunk: (text: string) => void;
326
+ /** Called when the stream is complete */
327
+ onDone?: () => void;
328
+ /** Called on error */
329
+ onError?: (error: Error) => void;
330
+ /** AbortSignal to cancel reading */
331
+ signal?: AbortSignal;
332
+ }
333
+
334
+ export async function readStreamWithYield(
335
+ stream: ReadableStream<Uint8Array>,
336
+ options: ReadStreamOptions
337
+ ): Promise<void> {
338
+ const { onChunk, onDone, onError, signal } = options;
339
+ const reader = stream.getReader();
340
+ const decoder = new TextDecoder();
341
+
342
+ try {
343
+ while (true) {
344
+ if (signal?.aborted) {
345
+ reader.cancel();
346
+ break;
347
+ }
348
+
349
+ const { done, value } = await reader.read();
350
+ if (done) break;
351
+
352
+ if (signal?.aborted) {
353
+ reader.cancel();
354
+ break;
355
+ }
356
+
357
+ const text = decoder.decode(value, { stream: true });
358
+ if (text) {
359
+ onChunk(text);
360
+ }
361
+
362
+ // Yield to macrotask queue
363
+ await yieldToMacrotask();
364
+ }
365
+
366
+ if (!signal?.aborted) {
367
+ onDone?.();
368
+ }
369
+ } catch (err) {
370
+ if (
371
+ !signal?.aborted &&
372
+ !(err instanceof DOMException && err.name === "AbortError")
373
+ ) {
374
+ const error = err instanceof Error ? err : new Error(String(err));
375
+ onError?.(error);
376
+ }
377
+ }
378
+ }
@@ -0,0 +1,162 @@
1
+ /**
2
+ * Mandu <Image> Component
3
+ * 서버 사이드 이미지 최적화 — srcset, lazy loading, LCP preload
4
+ */
5
+
6
+ import React from "react";
7
+
8
+ // ========== Types ==========
9
+
10
+ export interface ImageProps extends Omit<React.ImgHTMLAttributes<HTMLImageElement>, "src" | "srcSet"> {
11
+ /** 이미지 소스 경로 (/public 기준) */
12
+ src: string;
13
+ /** alt 텍스트 (필수 — 접근성) */
14
+ alt: string;
15
+ /** 너비 (px) */
16
+ width: number;
17
+ /** 높이 (px) */
18
+ height: number;
19
+ /** 반응형 sizes 속성 */
20
+ sizes?: string;
21
+ /** LCP 이미지 — preload 힌트 삽입 (기본: false) */
22
+ priority?: boolean;
23
+ /** 이미지 품질 (1-100, 기본: 80) */
24
+ quality?: number;
25
+ /** 플레이스홀더 (기본: "empty") */
26
+ placeholder?: "empty" | "blur";
27
+ /** 생성할 srcset 너비 목록 (기본: [640, 750, 828, 1080, 1200]) */
28
+ widths?: number[];
29
+ }
30
+
31
+ // ========== Constants ==========
32
+
33
+ const DEFAULT_WIDTHS = [640, 750, 828, 1080, 1200];
34
+ const IMAGE_HANDLER_PATH = "/_mandu/image";
35
+
36
+ // ========== Helper ==========
37
+
38
+ function buildImageUrl(src: string, width: number, quality: number): string {
39
+ return `${IMAGE_HANDLER_PATH}?url=${encodeURIComponent(src)}&w=${width}&q=${quality}`;
40
+ }
41
+
42
+ function buildSrcSet(src: string, widths: number[], quality: number): string {
43
+ return widths
44
+ .map(w => `${buildImageUrl(src, w, quality)} ${w}w`)
45
+ .join(", ");
46
+ }
47
+
48
+ // ========== Component ==========
49
+
50
+ /**
51
+ * 최적화된 이미지 컴포넌트
52
+ *
53
+ * @example
54
+ * ```tsx
55
+ * import { Image } from "@mandujs/core/components/Image";
56
+ *
57
+ * <Image
58
+ * src="/photos/hero.jpg"
59
+ * alt="Hero"
60
+ * width={800}
61
+ * height={400}
62
+ * sizes="(max-width: 768px) 100vw, 800px"
63
+ * priority
64
+ * />
65
+ * ```
66
+ */
67
+ export function Image({
68
+ src,
69
+ alt,
70
+ width,
71
+ height,
72
+ sizes,
73
+ priority = false,
74
+ quality = 80,
75
+ placeholder = "empty",
76
+ widths = DEFAULT_WIDTHS,
77
+ style,
78
+ ...rest
79
+ }: ImageProps) {
80
+ const optimizedSrc = buildImageUrl(src, width, quality);
81
+ const srcSet = buildSrcSet(src, widths, quality);
82
+ const blurPlaceholderSrc = buildImageUrl(src, Math.min(width, 32), Math.max(10, Math.min(quality, 30)));
83
+
84
+ const imgStyle: React.CSSProperties = {
85
+ display: "block",
86
+ aspectRatio: `${width}/${height}`,
87
+ maxWidth: "100%",
88
+ height: "auto",
89
+ position: placeholder === "blur" ? "relative" : undefined,
90
+ zIndex: placeholder === "blur" ? 1 : undefined,
91
+ ...style,
92
+ };
93
+
94
+ const wrapperStyle: React.CSSProperties | undefined = placeholder === "blur"
95
+ ? {
96
+ position: "relative",
97
+ display: "inline-block",
98
+ overflow: "hidden",
99
+ maxWidth: "100%",
100
+ }
101
+ : undefined;
102
+
103
+ const blurPlaceholderStyle: React.CSSProperties | undefined = placeholder === "blur"
104
+ ? {
105
+ position: "absolute",
106
+ inset: 0,
107
+ backgroundImage: `url("${blurPlaceholderSrc}")`,
108
+ backgroundSize: "cover",
109
+ backgroundPosition: "center",
110
+ filter: "blur(16px)",
111
+ transform: "scale(1.05)",
112
+ }
113
+ : undefined;
114
+
115
+ return (
116
+ <>
117
+ {/* React 19: <link> 자동 <head> 호이스팅 지원 */}
118
+ {priority && (
119
+ React.createElement("link", {
120
+ rel: "preload",
121
+ as: "image",
122
+ href: optimizedSrc,
123
+ imageSrcSet: srcSet,
124
+ imageSizes: sizes,
125
+ fetchPriority: "high",
126
+ })
127
+ )}
128
+ {placeholder === "blur" ? (
129
+ <span style={wrapperStyle}>
130
+ <span aria-hidden="true" style={blurPlaceholderStyle} />
131
+ <img
132
+ src={optimizedSrc}
133
+ srcSet={srcSet}
134
+ sizes={sizes ?? `${width}px`}
135
+ alt={alt}
136
+ width={width}
137
+ height={height}
138
+ loading={priority ? "eager" : "lazy"}
139
+ decoding={priority ? "sync" : "async"}
140
+ fetchPriority={priority ? "high" : undefined}
141
+ style={imgStyle}
142
+ {...rest}
143
+ />
144
+ </span>
145
+ ) : (
146
+ <img
147
+ src={optimizedSrc}
148
+ srcSet={srcSet}
149
+ sizes={sizes ?? `${width}px`}
150
+ alt={alt}
151
+ width={width}
152
+ height={height}
153
+ loading={priority ? "eager" : "lazy"}
154
+ decoding={priority ? "sync" : "async"}
155
+ fetchPriority={priority ? "high" : undefined}
156
+ style={imgStyle}
157
+ {...rest}
158
+ />
159
+ )}
160
+ </>
161
+ );
162
+ }
@@ -1,9 +1,12 @@
1
1
  import path from "path";
2
2
  import { readJsonFile } from "../utils/bun";
3
+ import type { ManduAdapter } from "../runtime/adapter";
4
+ import type { ManduPlugin, ManduHooks } from "../plugins/hooks";
3
5
 
4
6
  export type GuardRuleSeverity = "error" | "warn" | "warning" | "off";
5
7
 
6
8
  export interface ManduConfig {
9
+ adapter?: ManduAdapter;
7
10
  server?: {
8
11
  port?: number;
9
12
  hostname?: string;
@@ -54,6 +57,8 @@ export interface ManduConfig {
54
57
  defaultTitle?: string;
55
58
  titleTemplate?: string;
56
59
  };
60
+ plugins?: ManduPlugin[];
61
+ hooks?: Partial<ManduHooks>;
57
62
  }
58
63
 
59
64
  export const CONFIG_FILES = [
@@ -3,6 +3,8 @@ import path from "path";
3
3
  import { pathToFileURL } from "url";
4
4
  import { CONFIG_FILES, coerceConfig } from "./mandu";
5
5
  import { readJsonFile } from "../utils/bun";
6
+ import type { ManduAdapter } from "../runtime/adapter";
7
+ import type { ManduPlugin, ManduHooks } from "../plugins/hooks";
6
8
 
7
9
  /**
8
10
  * DNA-003: Strict mode schema helper
@@ -124,20 +126,52 @@ const SeoConfigSchema = z
124
126
  })
125
127
  .strict();
126
128
 
129
+ const AdapterConfigSchema = z.custom<ManduAdapter | undefined>(
130
+ (value) =>
131
+ value === undefined ||
132
+ (typeof value === "object" &&
133
+ value !== null &&
134
+ typeof (value as { name?: unknown }).name === "string" &&
135
+ typeof (value as { createServer?: unknown }).createServer === "function"),
136
+ {
137
+ message: "adapter must be a ManduAdapter with name and createServer()",
138
+ }
139
+ );
140
+
127
141
  /**
128
142
  * Mandu 설정 스키마 (DNA-003: strict mode)
129
143
  *
130
144
  * 알 수 없는 키가 있으면 오류 발생 → 오타 즉시 감지
131
145
  * MANDU_STRICT=0 으로 비활성화 가능
132
146
  */
147
+ /**
148
+ * Plugin schema — array of objects with `name` (string) and optional `hooks`/`setup`.
149
+ * Validated structurally; hook functions are opaque to Zod.
150
+ */
151
+ const ManduPluginSchema = z.custom<ManduPlugin>(
152
+ (v) =>
153
+ typeof v === "object" &&
154
+ v !== null &&
155
+ typeof (v as { name?: unknown }).name === "string",
156
+ { message: "Each plugin must be an object with a `name` string" }
157
+ );
158
+
159
+ const ManduHooksSchema = z.custom<Partial<ManduHooks>>(
160
+ (v) => typeof v === "object" && v !== null,
161
+ { message: "hooks must be an object" }
162
+ );
163
+
133
164
  export const ManduConfigSchema = z
134
165
  .object({
166
+ adapter: AdapterConfigSchema.optional(),
135
167
  server: ServerConfigSchema.default({}),
136
168
  guard: GuardConfigSchema.default({}),
137
169
  build: BuildConfigSchema.default({}),
138
170
  dev: DevConfigSchema.default({}),
139
171
  fsRoutes: FsRoutesConfigSchema.default({}),
140
172
  seo: SeoConfigSchema.default({}),
173
+ plugins: z.array(ManduPluginSchema).optional(),
174
+ hooks: ManduHooksSchema.optional(),
141
175
  })
142
176
  .strict();
143
177
 
@@ -157,8 +157,12 @@ export {
157
157
  * });
158
158
  * ```
159
159
  */
160
- import type { ContentConfig as ContentConfigType } from "./types";
160
+ import type { CollectionConfig, ContentConfig as ContentConfigType } from "./types";
161
161
 
162
162
  export function defineContentConfig<T extends ContentConfigType>(config: T): T {
163
163
  return config;
164
164
  }
165
+
166
+ export function defineCollection<T extends CollectionConfig>(config: T): T {
167
+ return config;
168
+ }
@@ -254,6 +254,23 @@ export class ErrorCatcher {
254
254
 
255
255
  const hook = getOrCreateHook();
256
256
  hook.emit(createErrorEvent(error));
257
+
258
+ // Kitchen → MCP bridge: POST error to dev server for MCP tool access
259
+ this.reportToServer(error);
260
+ }
261
+
262
+ private reportToServer(error: NormalizedError): void {
263
+ try {
264
+ fetch("/__kitchen/api/errors", {
265
+ method: "POST",
266
+ headers: { "Content-Type": "application/json" },
267
+ body: JSON.stringify(error),
268
+ }).catch(() => {
269
+ // Silently fail — dev server may not be running
270
+ });
271
+ } catch {
272
+ // Ignore
273
+ }
257
274
  }
258
275
 
259
276
  // --------------------------------------------------------------------------