@mandujs/core 0.19.0 → 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.
- package/README.ko.md +0 -14
- package/package.json +4 -1
- package/src/brain/architecture/analyzer.ts +4 -4
- package/src/brain/doctor/analyzer.ts +18 -14
- package/src/bundler/build.test.ts +127 -0
- package/src/bundler/build.ts +291 -113
- package/src/bundler/css.ts +20 -5
- package/src/bundler/dev.ts +55 -2
- package/src/bundler/prerender.ts +195 -0
- package/src/change/snapshot.ts +4 -23
- package/src/change/types.ts +2 -3
- package/src/client/Form.tsx +105 -0
- package/src/client/__tests__/use-sse.test.ts +153 -0
- package/src/client/hooks.ts +105 -6
- package/src/client/index.ts +35 -6
- package/src/client/router.ts +670 -433
- package/src/client/rpc.ts +140 -0
- package/src/client/runtime.ts +24 -21
- package/src/client/use-fetch.ts +239 -0
- package/src/client/use-head.ts +197 -0
- package/src/client/use-sse.ts +378 -0
- package/src/components/Image.tsx +162 -0
- package/src/config/mandu.ts +5 -0
- package/src/config/validate.ts +34 -0
- package/src/content/index.ts +5 -1
- package/src/devtools/client/catchers/error-catcher.ts +17 -0
- package/src/devtools/client/catchers/network-proxy.ts +390 -367
- package/src/devtools/client/components/kitchen-root.tsx +479 -467
- package/src/devtools/client/components/panel/diff-viewer.tsx +219 -0
- package/src/devtools/client/components/panel/guard-panel.tsx +374 -244
- package/src/devtools/client/components/panel/index.ts +45 -32
- package/src/devtools/client/components/panel/panel-container.tsx +332 -312
- package/src/devtools/client/components/panel/preview-panel.tsx +188 -0
- package/src/devtools/client/state-manager.ts +535 -478
- package/src/devtools/design-tokens.ts +265 -264
- package/src/devtools/types.ts +345 -319
- package/src/filling/filling.ts +336 -14
- package/src/filling/index.ts +5 -1
- package/src/filling/session.ts +216 -0
- package/src/filling/ws.ts +78 -0
- package/src/generator/generate.ts +2 -2
- package/src/guard/auto-correct.ts +0 -29
- package/src/guard/check.ts +14 -31
- package/src/guard/presets/index.ts +296 -294
- package/src/guard/rules.ts +15 -19
- package/src/guard/validator.ts +834 -834
- package/src/index.ts +5 -1
- package/src/island/index.ts +373 -304
- package/src/kitchen/api/contract-api.ts +225 -0
- package/src/kitchen/api/diff-parser.ts +108 -0
- package/src/kitchen/api/file-api.ts +273 -0
- package/src/kitchen/api/guard-api.ts +83 -0
- package/src/kitchen/api/guard-decisions.ts +100 -0
- package/src/kitchen/api/routes-api.ts +50 -0
- package/src/kitchen/index.ts +21 -0
- package/src/kitchen/kitchen-handler.ts +256 -0
- package/src/kitchen/kitchen-ui.ts +1732 -0
- package/src/kitchen/stream/activity-sse.ts +145 -0
- package/src/kitchen/stream/file-tailer.ts +99 -0
- package/src/middleware/compress.ts +62 -0
- package/src/middleware/cors.ts +47 -0
- package/src/middleware/index.ts +10 -0
- package/src/middleware/jwt.ts +134 -0
- package/src/middleware/logger.ts +58 -0
- package/src/middleware/timeout.ts +55 -0
- package/src/paths.ts +0 -4
- package/src/plugins/hooks.ts +64 -0
- package/src/plugins/index.ts +3 -0
- package/src/plugins/types.ts +5 -0
- package/src/report/build.ts +0 -6
- package/src/resource/__tests__/backward-compat.test.ts +0 -1
- package/src/router/fs-patterns.ts +11 -1
- package/src/router/fs-routes.ts +78 -14
- package/src/router/fs-scanner.ts +2 -2
- package/src/router/fs-types.ts +2 -1
- package/src/runtime/adapter-bun.ts +62 -0
- package/src/runtime/adapter.ts +47 -0
- package/src/runtime/cache.ts +310 -0
- package/src/runtime/handler.ts +65 -0
- package/src/runtime/image-handler.ts +195 -0
- package/src/runtime/index.ts +12 -0
- package/src/runtime/middleware.ts +263 -0
- package/src/runtime/server.ts +662 -83
- package/src/runtime/ssr.ts +55 -29
- package/src/runtime/streaming-ssr.ts +106 -82
- package/src/spec/index.ts +0 -1
- package/src/spec/schema.ts +1 -0
- package/src/testing/index.ts +144 -0
- package/src/watcher/watcher.ts +27 -1
- 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
|
+
}
|
package/src/config/mandu.ts
CHANGED
|
@@ -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 = [
|
package/src/config/validate.ts
CHANGED
|
@@ -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
|
|
package/src/content/index.ts
CHANGED
|
@@ -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
|
// --------------------------------------------------------------------------
|