@mandujs/core 0.54.12 → 0.54.14

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 (80) hide show
  1. package/package.json +9 -1
  2. package/scripts/postinstall-lock.ts +153 -153
  3. package/src/a11y/run-audit.ts +15 -15
  4. package/src/agent/__tests__/context.test.ts +49 -1
  5. package/src/agent/context.ts +535 -535
  6. package/src/agent/index.ts +6 -6
  7. package/src/agent/plan.ts +282 -282
  8. package/src/agent/repair.ts +171 -171
  9. package/src/agent/sync.ts +200 -200
  10. package/src/agent/types.ts +8 -0
  11. package/src/agent/verify.ts +100 -2
  12. package/src/brain/doctor/analyzer.ts +7 -7
  13. package/src/bundler/__tests__/build-runner.ts +36 -16
  14. package/src/bundler/__tests__/cold-start.test.ts +60 -60
  15. package/src/bundler/__tests__/css.test.ts +20 -20
  16. package/src/bundler/__tests__/fast-refresh.test.ts +24 -17
  17. package/src/bundler/analyzer.ts +15 -15
  18. package/src/bundler/build.test.ts +136 -48
  19. package/src/bundler/build.ts +193 -122
  20. package/src/bundler/css.ts +42 -42
  21. package/src/bundler/manifest-schema.ts +21 -21
  22. package/src/bundler/plugins/__tests__/block-generated-imports.test.ts +13 -13
  23. package/src/bundler/plugins/block-generated-imports.ts +13 -13
  24. package/src/bundler/types.ts +31 -31
  25. package/src/client/island.ts +79 -79
  26. package/src/config/validate.ts +1 -1
  27. package/src/contract/schema.ts +7 -0
  28. package/src/deploy/inference/context.ts +82 -82
  29. package/src/devtools/client/components/panel/islands-panel.tsx +16 -16
  30. package/src/devtools/client/components/panel/panel-container.tsx +1 -1
  31. package/src/error/formatter.ts +10 -1
  32. package/src/experimental/index.ts +10 -0
  33. package/src/filling/context.ts +17 -17
  34. package/src/filling/filling.ts +22 -1
  35. package/src/filling/index.ts +15 -1
  36. package/src/generator/generate.ts +30 -30
  37. package/src/generator/index.ts +3 -3
  38. package/src/generator/templates.ts +210 -210
  39. package/src/guard/check.ts +9 -9
  40. package/src/guard/config-guard.ts +13 -13
  41. package/src/guard/fs-routes-policy.ts +51 -51
  42. package/src/guard/index.ts +11 -11
  43. package/src/index.ts +0 -10
  44. package/src/internal/index.ts +25 -0
  45. package/src/kitchen/api/file-api.ts +11 -11
  46. package/src/report/index.ts +1 -1
  47. package/src/resource/__tests__/generator.test.ts +6 -6
  48. package/src/resource/__tests__/schema.test.ts +14 -14
  49. package/src/resource/ddl/__tests__/emit.test.ts +165 -165
  50. package/src/resource/ddl/emit.ts +146 -146
  51. package/src/resource/generator-schema.ts +11 -11
  52. package/src/resource/generators/slot.ts +72 -72
  53. package/src/resource/schema.ts +21 -21
  54. package/src/router/client-entry.test.ts +84 -41
  55. package/src/router/client-entry.ts +156 -89
  56. package/src/router/fs-routes.test.ts +90 -0
  57. package/src/router/fs-routes.ts +37 -29
  58. package/src/router/fs-scanner.ts +81 -33
  59. package/src/router/fs-types.ts +8 -5
  60. package/src/runtime/__tests__/devtools-adapter.test.ts +68 -68
  61. package/src/runtime/__tests__/observability-lifecycle.test.ts +103 -103
  62. package/src/runtime/__tests__/page-render-response.test.ts +164 -103
  63. package/src/runtime/__tests__/request-middleware.test.ts +70 -70
  64. package/src/runtime/devtools-adapter.ts +68 -68
  65. package/src/runtime/escape.ts +34 -34
  66. package/src/runtime/image-feature.ts +15 -0
  67. package/src/runtime/observability-lifecycle.ts +290 -290
  68. package/src/runtime/page-render-response.ts +208 -106
  69. package/src/runtime/rate-limit.ts +231 -0
  70. package/src/runtime/request-middleware.ts +31 -31
  71. package/src/runtime/router.test.ts +4 -4
  72. package/src/runtime/router.ts +10 -12
  73. package/src/runtime/scheduler-lifecycle.ts +64 -0
  74. package/src/runtime/server.ts +148 -353
  75. package/src/runtime/ssr.ts +59 -59
  76. package/src/runtime/static-files.ts +289 -289
  77. package/src/runtime/streaming-ssr.ts +22 -22
  78. package/src/spec/schema.ts +4 -3
  79. package/src/watcher/__tests__/watcher.test.ts +59 -59
  80. package/src/watcher/watcher.ts +61 -61
@@ -62,9 +62,17 @@ export interface MiddlewarePlugin {
62
62
  * `redirect(url)` helper for the common case; throwing a `Response` is
63
63
  * also accepted (Remix idiom).
64
64
  */
65
+ export type LoaderResult<T = unknown> = T | Response;
66
+
65
67
  export type Loader<T = unknown> = (
66
68
  ctx: ManduContext
67
- ) => T | Response | Promise<T | Response>;
69
+ ) => LoaderResult<T> | Promise<LoaderResult<T>>;
70
+
71
+ /**
72
+ * Page-level SSR data reader. Prefer this name in public docs when the
73
+ * distinction from mutation actions or schema contracts matters.
74
+ */
75
+ export type RouteDataLoader<T = unknown> = Loader<T>;
68
76
 
69
77
  /** Loader 실행 옵션 */
70
78
  export interface LoaderOptions<T = unknown> {
@@ -105,6 +113,19 @@ export class LoaderTimeoutError extends Error {
105
113
  /** Action handler type — named mutation handler */
106
114
  export type ActionHandler = (ctx: ManduContext) => Response | Promise<Response>;
107
115
 
116
+ /**
117
+ * Named mutation/interaction handler. Alias of `ActionHandler`, exported so
118
+ * docs and generated examples can name the action responsibility directly.
119
+ */
120
+ export type MutationAction = ActionHandler;
121
+
122
+ /**
123
+ * Executable route pipeline: handlers, loader, actions, middleware, cache,
124
+ * render mode, and deploy intent. This is intentionally separate from the
125
+ * API schema contract.
126
+ */
127
+ export type RouteFilling<TLoaderData = unknown> = ManduFilling<TLoaderData>;
128
+
108
129
  interface FillingConfig<TLoaderData = unknown> {
109
130
  handlers: Map<HttpMethod, Handler>;
110
131
  actions: Map<string, ActionHandler>;
@@ -7,7 +7,21 @@
7
7
  export { ManduContext, ValidationError, CookieManager } from "./context";
8
8
  export type { CookieOptions } from "./context";
9
9
  export { ManduFilling, ManduFillingFactory, LoaderTimeoutError } from "./filling";
10
- export type { Handler, Guard, ActionHandler, HttpMethod, Loader, LoaderOptions, LoaderCacheOptions, RenderMode, MiddlewarePlugin } from "./filling";
10
+ export type {
11
+ Handler,
12
+ Guard,
13
+ ActionHandler,
14
+ MutationAction,
15
+ HttpMethod,
16
+ Loader,
17
+ RouteDataLoader,
18
+ LoaderResult,
19
+ LoaderOptions,
20
+ LoaderCacheOptions,
21
+ RenderMode,
22
+ MiddlewarePlugin,
23
+ RouteFilling,
24
+ } from "./filling";
11
25
  export { createCookieSessionStorage, Session } from "./session";
12
26
  export type { SessionStorage, SessionData, CookieSessionOptions } from "./session";
13
27
  export { wrapBunWebSocket } from "./ws";
@@ -99,25 +99,25 @@ export interface GeneratedMap {
99
99
  frameworkPaths: string[];
100
100
  }
101
101
 
102
- async function ensureDir(dirPath: string): Promise<void> {
103
- try {
104
- await fs.mkdir(dirPath, { recursive: true });
105
- } catch {
106
- // ignore if exists
107
- }
108
- }
109
-
110
- function touchGenerateStamp(rootDir: string): void {
111
- const stampDir = path.join(rootDir, ".mandu");
112
- if (!fsSync.existsSync(stampDir)) {
113
- fsSync.mkdirSync(stampDir, { recursive: true });
114
- }
115
- fsSync.writeFileSync(path.join(stampDir, "generate.stamp"), Date.now().toString());
116
- }
117
-
118
- async function getExistingFiles(dir: string): Promise<string[]> {
119
- try {
120
- const files = await fs.readdir(dir);
102
+ async function ensureDir(dirPath: string): Promise<void> {
103
+ try {
104
+ await fs.mkdir(dirPath, { recursive: true });
105
+ } catch {
106
+ // ignore if exists
107
+ }
108
+ }
109
+
110
+ function touchGenerateStamp(rootDir: string): void {
111
+ const stampDir = path.join(rootDir, ".mandu");
112
+ if (!fsSync.existsSync(stampDir)) {
113
+ fsSync.mkdirSync(stampDir, { recursive: true });
114
+ }
115
+ fsSync.writeFileSync(path.join(stampDir, "generate.stamp"), Date.now().toString());
116
+ }
117
+
118
+ async function getExistingFiles(dir: string): Promise<string[]> {
119
+ try {
120
+ const files = await fs.readdir(dir);
121
121
  return files.filter((f) => f.endsWith(".route.ts") || f.endsWith(".route.tsx"));
122
122
  } catch {
123
123
  return [];
@@ -146,10 +146,10 @@ export async function generateRoutes(
146
146
  warnings: [],
147
147
  };
148
148
 
149
- // Suppress watcher during generation to avoid false positives
150
- const watcher = getWatcher();
151
- watcher?.suppress();
152
- touchGenerateStamp(rootDir);
149
+ // Suppress watcher during generation to avoid false positives
150
+ const watcher = getWatcher();
151
+ watcher?.suppress();
152
+ touchGenerateStamp(rootDir);
153
153
 
154
154
  const generatedPaths = resolveGeneratedPaths(rootDir);
155
155
  const serverRoutesDir = generatedPaths.serverRoutesDir;
@@ -363,10 +363,10 @@ export async function generateRoutes(
363
363
  const mapPath = path.join(mapDir, "generated.map.json");
364
364
  await Bun.write(mapPath, JSON.stringify(generatedMap, null, 2));
365
365
 
366
- // Cross-process timestamp: watcher skips warnings if generate finished recently
367
- touchGenerateStamp(rootDir);
368
- // Resume watcher after the stamp is visible.
369
- watcher?.resume();
370
-
371
- return result;
372
- }
366
+ // Cross-process timestamp: watcher skips warnings if generate finished recently
367
+ touchGenerateStamp(rootDir);
368
+ // Resume watcher after the stamp is visible.
369
+ watcher?.resume();
370
+
371
+ return result;
372
+ }
@@ -1,3 +1,3 @@
1
- export * from "./generate";
2
- export * from "./templates";
3
- export * from "./contract-glue";
1
+ export * from "./generate";
2
+ export * from "./templates";
3
+ export * from "./contract-glue";
@@ -1,4 +1,4 @@
1
- import { needsHydration, type RouteSpec } from "../spec/schema";
1
+ import { needsHydration, type RouteSpec } from "../spec/schema";
2
2
  import { GENERATED_RELATIVE_PATHS } from "../paths";
3
3
 
4
4
  export function generateApiHandler(route: RouteSpec): string {
@@ -238,34 +238,34 @@ function computeSlotImportPath(slotModule: string, fromDir: string): string {
238
238
  return result;
239
239
  }
240
240
 
241
- export function generatePageComponent(route: RouteSpec): string {
242
- // Island-First: clientModule이 있으면 Island render를 SSR에서 직접 사용
243
- if (route.clientModule) {
244
- return generatePageComponentWithIsland(route);
245
- }
246
-
247
- if (needsHydration(route)) {
248
- throw new Error(
249
- `[${route.id}] Route has hydration strategy "${route.hydration?.strategy}" but no clientModule. ` +
250
- "Refusing to generate a placeholder page because it would disagree with runtime hydration state.",
251
- );
252
- }
253
-
254
- // slotModule이 있으면 PageHandler 형식으로 생성 (filling 포함)
255
- if (route.slotModule) {
256
- return generatePageHandlerWithSlot(route);
257
- }
258
-
259
- if (route.kind === "page" && route.componentModule) {
260
- return generatePageComponentFromModule(route);
261
- }
262
-
263
- const pageName = toPascalCase(route.id);
264
-
265
- // Legacy fallback for malformed historical manifests that lack componentModule.
266
- return `// Generated by Mandu - DO NOT EDIT DIRECTLY
267
- // Route ID: ${route.id}
268
- // Pattern: ${route.pattern}
241
+ export function generatePageComponent(route: RouteSpec): string {
242
+ // Island-First: clientModule이 있으면 Island render를 SSR에서 직접 사용
243
+ if (route.clientModule) {
244
+ return generatePageComponentWithIsland(route);
245
+ }
246
+
247
+ if (needsHydration(route)) {
248
+ throw new Error(
249
+ `[${route.id}] Route has hydration strategy "${route.hydration?.strategy}" but no clientModule. ` +
250
+ "Refusing to generate a placeholder page because it would disagree with runtime hydration state.",
251
+ );
252
+ }
253
+
254
+ // slotModule이 있으면 PageHandler 형식으로 생성 (filling 포함)
255
+ if (route.slotModule) {
256
+ return generatePageHandlerWithSlot(route);
257
+ }
258
+
259
+ if (route.kind === "page" && route.componentModule) {
260
+ return generatePageComponentFromModule(route);
261
+ }
262
+
263
+ const pageName = toPascalCase(route.id);
264
+
265
+ // Legacy fallback for malformed historical manifests that lack componentModule.
266
+ return `// Generated by Mandu - DO NOT EDIT DIRECTLY
267
+ // Route ID: ${route.id}
268
+ // Pattern: ${route.pattern}
269
269
 
270
270
  import React from "react";
271
271
 
@@ -281,31 +281,31 @@ export default function ${pageName}Page({ params }: Props): React.ReactElement {
281
281
  React.createElement("p", null, "Pattern: ${route.pattern}")
282
282
  );
283
283
  }
284
- `;
285
- }
286
-
287
- export function generatePageComponentFromModule(route: RouteSpec): string {
288
- const pageName = toPascalCase(route.id);
289
- const pageImportPath = computeSlotImportPath(route.componentModule!, GENERATED_RELATIVE_PATHS.webRoutes);
290
-
291
- return `// Generated by Mandu - DO NOT EDIT DIRECTLY
292
- // Route ID: ${route.id}
293
- // Pattern: ${route.pattern}
294
- // Page Module: ${route.componentModule}
295
-
296
- import React from "react";
297
- import pageModule from "${pageImportPath}";
298
-
299
- interface Props {
300
- params: Record<string, string>;
301
- loaderData?: unknown;
302
- }
303
-
304
- export default function ${pageName}Page(props: Props): React.ReactElement {
305
- return React.createElement(pageModule as React.ComponentType<Props>, props);
306
- }
307
- `;
308
- }
284
+ `;
285
+ }
286
+
287
+ export function generatePageComponentFromModule(route: RouteSpec): string {
288
+ const pageName = toPascalCase(route.id);
289
+ const pageImportPath = computeSlotImportPath(route.componentModule!, GENERATED_RELATIVE_PATHS.webRoutes);
290
+
291
+ return `// Generated by Mandu - DO NOT EDIT DIRECTLY
292
+ // Route ID: ${route.id}
293
+ // Pattern: ${route.pattern}
294
+ // Page Module: ${route.componentModule}
295
+
296
+ import React from "react";
297
+ import pageModule from "${pageImportPath}";
298
+
299
+ interface Props {
300
+ params: Record<string, string>;
301
+ loaderData?: unknown;
302
+ }
303
+
304
+ export default function ${pageName}Page(props: Props): React.ReactElement {
305
+ return React.createElement(pageModule as React.ComponentType<Props>, props);
306
+ }
307
+ `;
308
+ }
309
309
 
310
310
  /**
311
311
  * Island-First Rendering: SSR이 island의 render 함수를 직접 사용
@@ -313,51 +313,51 @@ export default function ${pageName}Page(props: Props): React.ReactElement {
313
313
  * - SSR과 클라이언트가 동일한 렌더링 로직 사용 → 불일치 구조적 방지
314
314
  * - slotModule 유무에 따라 두 가지 변형 생성
315
315
  */
316
- export function generatePageComponentWithIsland(route: RouteSpec): string {
317
- const pageName = toPascalCase(route.id);
318
- const clientImportPath = computeSlotImportPath(route.clientModule!, GENERATED_RELATIVE_PATHS.webRoutes);
319
- const pageImportPath = route.kind === "page" && route.componentModule
320
- ? computeSlotImportPath(route.componentModule, GENERATED_RELATIVE_PATHS.webRoutes)
321
- : null;
322
- const pageModuleComment = route.kind === "page" && route.componentModule
323
- ? `// Page Module: ${route.componentModule}\n`
324
- : "";
325
- const shouldImportPageModule = !!pageImportPath &&
326
- normalizeRouteModulePath(route.componentModule) !== normalizeRouteModulePath(route.clientModule);
327
- const pageImport = shouldImportPageModule ? `import pageModule from "${pageImportPath}";\n` : "";
328
- const pageRenderTarget = shouldImportPageModule ? "pageModule" : "islandModule";
329
- const renderHelper = generateClientBackedPageRenderHelper(pageRenderTarget, route.id);
330
-
331
- // clientModule + slotModule → PageRegistration 형식
332
- if (route.slotModule) {
333
- const slotImportPath = computeSlotImportPath(route.slotModule!, GENERATED_RELATIVE_PATHS.webRoutes);
316
+ export function generatePageComponentWithIsland(route: RouteSpec): string {
317
+ const pageName = toPascalCase(route.id);
318
+ const clientImportPath = computeSlotImportPath(route.clientModule!, GENERATED_RELATIVE_PATHS.webRoutes);
319
+ const pageImportPath = route.kind === "page" && route.componentModule
320
+ ? computeSlotImportPath(route.componentModule, GENERATED_RELATIVE_PATHS.webRoutes)
321
+ : null;
322
+ const pageModuleComment = route.kind === "page" && route.componentModule
323
+ ? `// Page Module: ${route.componentModule}\n`
324
+ : "";
325
+ const shouldImportPageModule = !!pageImportPath &&
326
+ normalizeRouteModulePath(route.componentModule) !== normalizeRouteModulePath(route.clientModule);
327
+ const pageImport = shouldImportPageModule ? `import pageModule from "${pageImportPath}";\n` : "";
328
+ const pageRenderTarget = shouldImportPageModule ? "pageModule" : "islandModule";
329
+ const renderHelper = generateClientBackedPageRenderHelper(pageRenderTarget, route.id);
330
+
331
+ // clientModule + slotModule → PageRegistration 형식
332
+ if (route.slotModule) {
333
+ const slotImportPath = computeSlotImportPath(route.slotModule!, GENERATED_RELATIVE_PATHS.webRoutes);
334
334
 
335
335
  return `// Generated by Mandu - DO NOT EDIT DIRECTLY
336
336
  // Island-First Rendering + Slot Module
337
337
  // Route ID: ${route.id}
338
- // Pattern: ${route.pattern}
339
- // Client Module: ${route.clientModule}
340
- // Slot Module: ${route.slotModule}
341
- ${pageModuleComment}
342
-
343
- import React from "react";
344
- import filling from "${slotImportPath}";
345
- import islandModule from "${clientImportPath}";
346
- ${pageImport}
347
-
348
- interface Props {
349
- params: Record<string, string>;
350
- loaderData?: unknown;
351
- }
352
-
353
- ${renderHelper}
354
-
355
- function ${pageName}Page({ params, loaderData }: Props): React.ReactElement {
356
- const serverData = (loaderData || {}) as Record<string, unknown>;
357
- return renderClientBackedPage({ params, loaderData }, serverData);
358
- }
359
-
360
- // PageRegistration 형식으로 export (server.ts의 registerPageHandler용)
338
+ // Pattern: ${route.pattern}
339
+ // Client Module: ${route.clientModule}
340
+ // Slot Module: ${route.slotModule}
341
+ ${pageModuleComment}
342
+
343
+ import React from "react";
344
+ import filling from "${slotImportPath}";
345
+ import islandModule from "${clientImportPath}";
346
+ ${pageImport}
347
+
348
+ interface Props {
349
+ params: Record<string, string>;
350
+ loaderData?: unknown;
351
+ }
352
+
353
+ ${renderHelper}
354
+
355
+ function ${pageName}Page({ params, loaderData }: Props): React.ReactElement {
356
+ const serverData = (loaderData || {}) as Record<string, unknown>;
357
+ return renderClientBackedPage({ params, loaderData }, serverData);
358
+ }
359
+
360
+ // PageRegistration 형식으로 export (server.ts의 registerPageHandler용)
361
361
  export default {
362
362
  component: ${pageName}Page,
363
363
  filling: filling,
@@ -368,124 +368,124 @@ export default {
368
368
  // clientModule만 (slotModule 없음) → default export 컴포넌트
369
369
  return `// Generated by Mandu - DO NOT EDIT DIRECTLY
370
370
  // Island-First Rendering: SSR이 island render 직접 사용
371
- // Route ID: ${route.id}
372
- // Pattern: ${route.pattern}
373
- // Client Module: ${route.clientModule}
374
- ${pageModuleComment}
375
-
376
- import React from "react";
377
- import islandModule from "${clientImportPath}";
378
- ${pageImport}
379
-
380
- interface Props {
381
- params: Record<string, string>;
382
- loaderData?: unknown;
383
- }
384
-
385
- ${renderHelper}
386
-
387
- export default function ${pageName}Page({ params, loaderData }: Props): React.ReactElement {
388
- const serverData = (loaderData || {}) as Record<string, unknown>;
389
- return renderClientBackedPage({ params, loaderData }, serverData);
390
- }
391
- `;
392
- }
371
+ // Route ID: ${route.id}
372
+ // Pattern: ${route.pattern}
373
+ // Client Module: ${route.clientModule}
374
+ ${pageModuleComment}
375
+
376
+ import React from "react";
377
+ import islandModule from "${clientImportPath}";
378
+ ${pageImport}
379
+
380
+ interface Props {
381
+ params: Record<string, string>;
382
+ loaderData?: unknown;
383
+ }
384
+
385
+ ${renderHelper}
386
+
387
+ export default function ${pageName}Page({ params, loaderData }: Props): React.ReactElement {
388
+ const serverData = (loaderData || {}) as Record<string, unknown>;
389
+ return renderClientBackedPage({ params, loaderData }, serverData);
390
+ }
391
+ `;
392
+ }
393
393
 
394
394
  /**
395
395
  * slotModule이 있는 Page Route용 Handler 생성
396
396
  * - component와 filling을 함께 export
397
397
  * - server.ts에서 filling.executeLoader() 호출 가능
398
398
  */
399
- export function generatePageHandlerWithSlot(route: RouteSpec): string {
400
- const pageName = toPascalCase(route.id);
401
- const slotImportPath = computeSlotImportPath(route.slotModule!, GENERATED_RELATIVE_PATHS.serverRoutes);
402
- const pageImportPath = route.kind === "page" && route.componentModule
403
- ? computeSlotImportPath(route.componentModule, GENERATED_RELATIVE_PATHS.serverRoutes)
404
- : null;
405
- const pageImport = pageImportPath ? `import pageModule from "${pageImportPath}";\n` : "";
406
- const pageRender = pageImportPath
407
- ? ` return React.createElement(pageModule as React.ComponentType<Props>, { params, loaderData });`
408
- : ` return React.createElement("div", null,
409
- React.createElement("h1", null, "${pageName} Page"),
410
- React.createElement("p", null, "Route ID: ${route.id}"),
411
- React.createElement("p", null, "Pattern: ${route.pattern}"),
412
- loaderData ? React.createElement("pre", null, JSON.stringify(loaderData, null, 2)) : null
413
- );`;
414
-
415
- return `// Generated by Mandu - DO NOT EDIT DIRECTLY
416
- // Route ID: ${route.id}
417
- // Pattern: ${route.pattern}
418
- // Slot Module: ${route.slotModule}
419
-
420
- import React from "react";
421
- import filling from "${slotImportPath}";
422
- ${pageImport}
423
-
424
- interface Props {
425
- params: Record<string, string>;
426
- loaderData?: unknown;
427
- }
428
-
429
- function ${pageName}Page({ params, loaderData }: Props): React.ReactElement {
430
- ${pageRender}
431
- }
432
-
433
- // PageRegistration 형식으로 export (server.ts의 registerPageHandler용)
399
+ export function generatePageHandlerWithSlot(route: RouteSpec): string {
400
+ const pageName = toPascalCase(route.id);
401
+ const slotImportPath = computeSlotImportPath(route.slotModule!, GENERATED_RELATIVE_PATHS.serverRoutes);
402
+ const pageImportPath = route.kind === "page" && route.componentModule
403
+ ? computeSlotImportPath(route.componentModule, GENERATED_RELATIVE_PATHS.serverRoutes)
404
+ : null;
405
+ const pageImport = pageImportPath ? `import pageModule from "${pageImportPath}";\n` : "";
406
+ const pageRender = pageImportPath
407
+ ? ` return React.createElement(pageModule as React.ComponentType<Props>, { params, loaderData });`
408
+ : ` return React.createElement("div", null,
409
+ React.createElement("h1", null, "${pageName} Page"),
410
+ React.createElement("p", null, "Route ID: ${route.id}"),
411
+ React.createElement("p", null, "Pattern: ${route.pattern}"),
412
+ loaderData ? React.createElement("pre", null, JSON.stringify(loaderData, null, 2)) : null
413
+ );`;
414
+
415
+ return `// Generated by Mandu - DO NOT EDIT DIRECTLY
416
+ // Route ID: ${route.id}
417
+ // Pattern: ${route.pattern}
418
+ // Slot Module: ${route.slotModule}
419
+
420
+ import React from "react";
421
+ import filling from "${slotImportPath}";
422
+ ${pageImport}
423
+
424
+ interface Props {
425
+ params: Record<string, string>;
426
+ loaderData?: unknown;
427
+ }
428
+
429
+ function ${pageName}Page({ params, loaderData }: Props): React.ReactElement {
430
+ ${pageRender}
431
+ }
432
+
433
+ // PageRegistration 형식으로 export (server.ts의 registerPageHandler용)
434
434
  export default {
435
435
  component: ${pageName}Page,
436
436
  filling: filling,
437
437
  };
438
- `;
439
- }
440
-
441
- function generateClientBackedPageRenderHelper(pageRenderTarget: string, routeId: string): string {
442
- return `type ManduIslandModule = {
443
- __mandu_island: true;
444
- definition: {
445
- setup: (serverData: Record<string, unknown>) => unknown;
446
- render: (props: unknown) => React.ReactNode;
447
- };
448
- };
449
-
450
- function isManduIslandModule(value: unknown): value is ManduIslandModule {
451
- const candidate = value as Partial<ManduIslandModule> | null;
452
- return !!(
453
- candidate &&
454
- typeof candidate === "object" &&
455
- candidate.__mandu_island === true &&
456
- typeof candidate.definition?.setup === "function" &&
457
- typeof candidate.definition?.render === "function"
458
- );
459
- }
460
-
461
- function renderClientBackedPage(props: Props, serverData: Record<string, unknown>): React.ReactElement {
462
- if (isManduIslandModule(islandModule)) {
463
- const setupResult = islandModule.definition.setup(serverData);
464
- return islandModule.definition.render(setupResult) as React.ReactElement;
465
- }
466
-
467
- if (typeof ${pageRenderTarget} === "function") {
468
- return React.createElement(${pageRenderTarget} as React.ComponentType<Props>, props);
469
- }
470
-
471
- if (typeof islandModule === "function") {
472
- return React.createElement(islandModule as React.ComponentType<Record<string, unknown>>, serverData);
473
- }
474
-
475
- if (React.isValidElement(${pageRenderTarget})) {
476
- return ${pageRenderTarget} as React.ReactElement;
477
- }
478
-
479
- if (React.isValidElement(islandModule)) {
480
- return islandModule as React.ReactElement;
481
- }
482
-
483
- throw new Error("[Mandu] Route ${routeId} clientModule must export a Mandu island or React component.");
484
- }`;
485
- }
486
-
487
- /**
488
- * Convert string to PascalCase (handles kebab-case, snake_case)
438
+ `;
439
+ }
440
+
441
+ function generateClientBackedPageRenderHelper(pageRenderTarget: string, routeId: string): string {
442
+ return `type ManduIslandModule = {
443
+ __mandu_island: true;
444
+ definition: {
445
+ setup: (serverData: Record<string, unknown>) => unknown;
446
+ render: (props: unknown) => React.ReactNode;
447
+ };
448
+ };
449
+
450
+ function isManduIslandModule(value: unknown): value is ManduIslandModule {
451
+ const candidate = value as Partial<ManduIslandModule> | null;
452
+ return !!(
453
+ candidate &&
454
+ typeof candidate === "object" &&
455
+ candidate.__mandu_island === true &&
456
+ typeof candidate.definition?.setup === "function" &&
457
+ typeof candidate.definition?.render === "function"
458
+ );
459
+ }
460
+
461
+ function renderClientBackedPage(props: Props, serverData: Record<string, unknown>): React.ReactElement {
462
+ if (isManduIslandModule(islandModule)) {
463
+ const setupResult = islandModule.definition.setup(serverData);
464
+ return islandModule.definition.render(setupResult) as React.ReactElement;
465
+ }
466
+
467
+ if (typeof ${pageRenderTarget} === "function") {
468
+ return React.createElement(${pageRenderTarget} as React.ComponentType<Props>, props);
469
+ }
470
+
471
+ if (typeof islandModule === "function") {
472
+ return React.createElement(islandModule as React.ComponentType<Record<string, unknown>>, serverData);
473
+ }
474
+
475
+ if (React.isValidElement(${pageRenderTarget})) {
476
+ return ${pageRenderTarget} as React.ReactElement;
477
+ }
478
+
479
+ if (React.isValidElement(islandModule)) {
480
+ return islandModule as React.ReactElement;
481
+ }
482
+
483
+ throw new Error("[Mandu] Route ${routeId} clientModule must export a Mandu island or React component.");
484
+ }`;
485
+ }
486
+
487
+ /**
488
+ * Convert string to PascalCase (handles kebab-case, snake_case)
489
489
  * "todo-page" → "TodoPage"
490
490
  * "user_profile" → "UserProfile"
491
491
  */
@@ -496,10 +496,10 @@ function toPascalCase(str: string): string {
496
496
  .join("");
497
497
  }
498
498
 
499
- function pathDirname(filePath: string): string {
500
- return filePath.replace(/\\/g, "/").split("/").slice(0, -1).join("/");
501
- }
502
-
503
- function normalizeRouteModulePath(filePath: string | undefined): string {
504
- return (filePath ?? "").replace(/\\/g, "/").replace(/^\.\//, "");
505
- }
499
+ function pathDirname(filePath: string): string {
500
+ return filePath.replace(/\\/g, "/").split("/").slice(0, -1).join("/");
501
+ }
502
+
503
+ function normalizeRouteModulePath(filePath: string | undefined): string {
504
+ return (filePath ?? "").replace(/\\/g, "/").replace(/^\.\//, "");
505
+ }
@@ -30,21 +30,21 @@ export const GENERATED_IMPORT_DOCS_URL =
30
30
  "https://mandujs.com/docs/architect/generated-access";
31
31
 
32
32
  /**
33
- * Build the user-facing message for a detected direct generated-artifact
34
- * import. `specifier` is the literal import string that tripped the guard
35
- * (not the resolved path).
33
+ * Build the user-facing message for a detected direct generated-artifact
34
+ * import. `specifier` is the literal import string that tripped the guard
35
+ * (not the resolved path).
36
36
  *
37
37
  * This helper is the single source of truth for the message text — both
38
38
  * the static Guard pass (`checkInvalidGeneratedImport`) and the bundler
39
39
  * plugin (`blockGeneratedImports`) call through it so the two paths
40
40
  * cannot drift.
41
41
  */
42
- export function buildForbiddenGeneratedImportMessage(specifier: string): string {
43
- return (
44
- `Direct generated artifact imports are forbidden: ${specifier}. ` +
45
- `Use the runtime registry: see ${GENERATED_IMPORT_DOCS_URL}`
46
- );
47
- }
42
+ export function buildForbiddenGeneratedImportMessage(specifier: string): string {
43
+ return (
44
+ `Direct generated artifact imports are forbidden: ${specifier}. ` +
45
+ `Use the runtime registry: see ${GENERATED_IMPORT_DOCS_URL}`
46
+ );
47
+ }
48
48
 
49
49
  /**
50
50
  * Shared remediation hint. Points at `getGenerated()` from