@mandujs/core 0.5.6 → 0.5.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mandujs/core",
3
- "version": "0.5.6",
3
+ "version": "0.5.7",
4
4
  "description": "Mandu Framework Core - Spec, Generator, Guard, Runtime",
5
5
  "type": "module",
6
6
  "main": "./src/index.ts",
@@ -249,6 +249,36 @@ export default ReactDOMClient;
249
249
  `;
250
250
  }
251
251
 
252
+ /**
253
+ * JSX Runtime shim 소스 생성
254
+ */
255
+ function generateJsxRuntimeShimSource(): string {
256
+ return `
257
+ /**
258
+ * Mandu JSX Runtime Shim (Generated)
259
+ * Production JSX 변환용
260
+ */
261
+ import * as jsxRuntime from 'react/jsx-runtime';
262
+ export * from 'react/jsx-runtime';
263
+ export default jsxRuntime;
264
+ `;
265
+ }
266
+
267
+ /**
268
+ * JSX Dev Runtime shim 소스 생성
269
+ */
270
+ function generateJsxDevRuntimeShimSource(): string {
271
+ return `
272
+ /**
273
+ * Mandu JSX Dev Runtime Shim (Generated)
274
+ * Development JSX 변환용
275
+ */
276
+ import * as jsxDevRuntime from 'react/jsx-dev-runtime';
277
+ export * from 'react/jsx-dev-runtime';
278
+ export default jsxDevRuntime;
279
+ `;
280
+ }
281
+
252
282
  /**
253
283
  * Island 엔트리 래퍼 생성
254
284
  */
@@ -332,6 +362,8 @@ interface VendorBuildResult {
332
362
  react: string;
333
363
  reactDom: string;
334
364
  reactDomClient: string;
365
+ jsxRuntime: string;
366
+ jsxDevRuntime: string;
335
367
  errors: string[];
336
368
  }
337
369
 
@@ -348,12 +380,16 @@ async function buildVendorShims(
348
380
  react: "",
349
381
  reactDom: "",
350
382
  reactDomClient: "",
383
+ jsxRuntime: "",
384
+ jsxDevRuntime: "",
351
385
  };
352
386
 
353
387
  const shims = [
354
388
  { name: "_react", source: generateReactShimSource(), key: "react" },
355
389
  { name: "_react-dom", source: generateReactDOMShimSource(), key: "reactDom" },
356
390
  { name: "_react-dom-client", source: generateReactDOMClientShimSource(), key: "reactDomClient" },
391
+ { name: "_jsx-runtime", source: generateJsxRuntimeShimSource(), key: "jsxRuntime" },
392
+ { name: "_jsx-dev-runtime", source: generateJsxDevRuntimeShimSource(), key: "jsxDevRuntime" },
357
393
  ];
358
394
 
359
395
  for (const shim of shims) {
@@ -394,6 +430,8 @@ async function buildVendorShims(
394
430
  react: results.react,
395
431
  reactDom: results.reactDom,
396
432
  reactDomClient: results.reactDomClient,
433
+ jsxRuntime: results.jsxRuntime,
434
+ jsxDevRuntime: results.jsxDevRuntime,
397
435
  errors,
398
436
  };
399
437
  }
@@ -494,6 +532,8 @@ function createBundleManifest(
494
532
  "react": vendorResult.react,
495
533
  "react-dom": vendorResult.reactDom,
496
534
  "react-dom/client": vendorResult.reactDomClient,
535
+ "react/jsx-runtime": vendorResult.jsxRuntime,
536
+ "react/jsx-dev-runtime": vendorResult.jsxDevRuntime,
497
537
  },
498
538
  },
499
539
  };
@@ -190,6 +190,13 @@ function computeSlotImportPath(slotModule: string, fromDir: string): string {
190
190
 
191
191
  export function generatePageComponent(route: RouteSpec): string {
192
192
  const pageName = toPascalCase(route.id);
193
+
194
+ // slotModule이 있으면 PageHandler 형식으로 생성 (filling 포함)
195
+ if (route.slotModule) {
196
+ return generatePageHandlerWithSlot(route);
197
+ }
198
+
199
+ // slotModule이 없으면 기존 방식
193
200
  return `// Generated by Mandu - DO NOT EDIT DIRECTLY
194
201
  // Route ID: ${route.id}
195
202
  // Pattern: ${route.pattern}
@@ -198,6 +205,7 @@ import React from "react";
198
205
 
199
206
  interface Props {
200
207
  params: Record<string, string>;
208
+ loaderData?: unknown;
201
209
  }
202
210
 
203
211
  export default function ${pageName}Page({ params }: Props): React.ReactElement {
@@ -210,6 +218,45 @@ export default function ${pageName}Page({ params }: Props): React.ReactElement {
210
218
  `;
211
219
  }
212
220
 
221
+ /**
222
+ * slotModule이 있는 Page Route용 Handler 생성
223
+ * - component와 filling을 함께 export
224
+ * - server.ts에서 filling.executeLoader() 호출 가능
225
+ */
226
+ export function generatePageHandlerWithSlot(route: RouteSpec): string {
227
+ const pageName = toPascalCase(route.id);
228
+ const slotImportPath = computeSlotImportPath(route.slotModule!, "apps/server/generated/routes");
229
+
230
+ return `// Generated by Mandu - DO NOT EDIT DIRECTLY
231
+ // Route ID: ${route.id}
232
+ // Pattern: ${route.pattern}
233
+ // Slot Module: ${route.slotModule}
234
+
235
+ import React from "react";
236
+ import filling from "${slotImportPath}";
237
+
238
+ interface Props {
239
+ params: Record<string, string>;
240
+ loaderData?: unknown;
241
+ }
242
+
243
+ function ${pageName}Page({ params, loaderData }: Props): React.ReactElement {
244
+ return React.createElement("div", null,
245
+ React.createElement("h1", null, "${pageName} Page"),
246
+ React.createElement("p", null, "Route ID: ${route.id}"),
247
+ React.createElement("p", null, "Pattern: ${route.pattern}"),
248
+ loaderData ? React.createElement("pre", null, JSON.stringify(loaderData, null, 2)) : null
249
+ );
250
+ }
251
+
252
+ // PageRegistration 형식으로 export (server.ts의 registerPageHandler용)
253
+ export default {
254
+ component: ${pageName}Page,
255
+ filling: filling,
256
+ };
257
+ `;
258
+ }
259
+
213
260
  /**
214
261
  * Convert string to PascalCase (handles kebab-case, snake_case)
215
262
  * "todo-page" → "TodoPage"
@@ -1,6 +1,8 @@
1
1
  import type { Server } from "bun";
2
2
  import type { RoutesManifest } from "../spec/schema";
3
3
  import type { BundleManifest } from "../bundler/types";
4
+ import type { ManduFilling } from "../filling/filling";
5
+ import { ManduContext } from "../filling/context";
4
6
  import { Router } from "./router";
5
7
  import { renderSSR } from "./ssr";
6
8
  import React from "react";
@@ -103,18 +105,36 @@ export interface ManduServer {
103
105
  export type ApiHandler = (req: Request, params: Record<string, string>) => Response | Promise<Response>;
104
106
  export type PageLoader = () => Promise<{ default: React.ComponentType<{ params: Record<string, string> }> }>;
105
107
 
108
+ /**
109
+ * Page 등록 정보
110
+ * - component: React 컴포넌트
111
+ * - filling: Slot의 ManduFilling 인스턴스 (loader 포함)
112
+ */
113
+ export interface PageRegistration {
114
+ component: React.ComponentType<{ params: Record<string, string>; loaderData?: unknown }>;
115
+ filling?: ManduFilling<unknown>;
116
+ }
117
+
118
+ /**
119
+ * Page Handler - 컴포넌트와 filling을 함께 반환
120
+ */
121
+ export type PageHandler = () => Promise<PageRegistration>;
122
+
106
123
  export interface AppContext {
107
124
  routeId: string;
108
125
  url: string;
109
126
  params: Record<string, string>;
127
+ /** SSR loader에서 로드한 데이터 */
128
+ loaderData?: unknown;
110
129
  }
111
130
 
112
- type RouteComponent = (props: { params: Record<string, string> }) => React.ReactElement;
131
+ type RouteComponent = (props: { params: Record<string, string>; loaderData?: unknown }) => React.ReactElement;
113
132
  type CreateAppFn = (context: AppContext) => React.ReactElement;
114
133
 
115
134
  // Registry
116
135
  const apiHandlers: Map<string, ApiHandler> = new Map();
117
136
  const pageLoaders: Map<string, PageLoader> = new Map();
137
+ const pageHandlers: Map<string, PageHandler> = new Map();
118
138
  const routeComponents: Map<string, RouteComponent> = new Map();
119
139
  let createAppFn: CreateAppFn | null = null;
120
140
 
@@ -141,6 +161,14 @@ export function registerPageLoader(routeId: string, loader: PageLoader): void {
141
161
  pageLoaders.set(routeId, loader);
142
162
  }
143
163
 
164
+ /**
165
+ * Page Handler 등록 (컴포넌트 + filling)
166
+ * filling이 있으면 loader를 실행하여 serverData 전달
167
+ */
168
+ export function registerPageHandler(routeId: string, handler: PageHandler): void {
169
+ pageHandlers.set(routeId, handler);
170
+ }
171
+
144
172
  export function registerRouteComponent(routeId: string, component: RouteComponent): void {
145
173
  routeComponents.set(routeId, component);
146
174
  }
@@ -160,7 +188,10 @@ function defaultCreateApp(context: AppContext): React.ReactElement {
160
188
  );
161
189
  }
162
190
 
163
- return React.createElement(Component, { params: context.params });
191
+ return React.createElement(Component, {
192
+ params: context.params,
193
+ loaderData: context.loaderData,
194
+ });
164
195
  }
165
196
 
166
197
  // ========== Static File Serving ==========
@@ -281,11 +312,22 @@ async function handleRequest(req: Request, router: Router): Promise<Response> {
281
312
  }
282
313
 
283
314
  if (route.kind === "page") {
284
- const loader = pageLoaders.get(route.id);
285
- if (loader) {
315
+ let loaderData: unknown;
316
+ let component: RouteComponent | undefined;
317
+
318
+ // 1. PageHandler 방식 (신규 - filling 포함)
319
+ const pageHandler = pageHandlers.get(route.id);
320
+ if (pageHandler) {
286
321
  try {
287
- const module = await loader();
288
- registerRouteComponent(route.id, module.default);
322
+ const registration = await pageHandler();
323
+ component = registration.component as RouteComponent;
324
+ registerRouteComponent(route.id, component);
325
+
326
+ // Filling의 loader 실행
327
+ if (registration.filling?.hasLoader()) {
328
+ const ctx = new ManduContext(req, params);
329
+ loaderData = await registration.filling.executeLoader(ctx);
330
+ }
289
331
  } catch (err) {
290
332
  const pageError = createPageLoadErrorResponse(
291
333
  route.id,
@@ -299,6 +341,27 @@ async function handleRequest(req: Request, router: Router): Promise<Response> {
299
341
  return Response.json(response, { status: 500 });
300
342
  }
301
343
  }
344
+ // 2. PageLoader 방식 (레거시 호환)
345
+ else {
346
+ const loader = pageLoaders.get(route.id);
347
+ if (loader) {
348
+ try {
349
+ const module = await loader();
350
+ registerRouteComponent(route.id, module.default);
351
+ } catch (err) {
352
+ const pageError = createPageLoadErrorResponse(
353
+ route.id,
354
+ route.pattern,
355
+ err instanceof Error ? err : new Error(String(err))
356
+ );
357
+ console.error(`[Mandu] ${pageError.errorType}:`, pageError.message);
358
+ const response = formatErrorResponse(pageError, {
359
+ isDev: process.env.NODE_ENV !== "production",
360
+ });
361
+ return Response.json(response, { status: 500 });
362
+ }
363
+ }
364
+ }
302
365
 
303
366
  const appCreator = createAppFn || defaultCreateApp;
304
367
  try {
@@ -306,8 +369,14 @@ async function handleRequest(req: Request, router: Router): Promise<Response> {
306
369
  routeId: route.id,
307
370
  url: req.url,
308
371
  params,
372
+ loaderData,
309
373
  });
310
374
 
375
+ // serverData 구조: { [routeId]: { serverData: loaderData } }
376
+ const serverData = loaderData
377
+ ? { [route.id]: { serverData: loaderData } }
378
+ : undefined;
379
+
311
380
  return renderSSR(app, {
312
381
  title: `${route.id} - Mandu`,
313
382
  isDev: serverSettings.isDev,
@@ -315,6 +384,7 @@ async function handleRequest(req: Request, router: Router): Promise<Response> {
315
384
  routeId: route.id,
316
385
  hydration: route.hydration,
317
386
  bundleManifest: serverSettings.bundleManifest,
387
+ serverData,
318
388
  });
319
389
  } catch (err) {
320
390
  const ssrError = createSSRErrorResponse(
@@ -418,8 +488,9 @@ export function startServer(manifest: RoutesManifest, options: ServerOptions = {
418
488
  export function clearRegistry(): void {
419
489
  apiHandlers.clear();
420
490
  pageLoaders.clear();
491
+ pageHandlers.clear();
421
492
  routeComponents.clear();
422
493
  createAppFn = null;
423
494
  }
424
495
 
425
- export { apiHandlers, pageLoaders, routeComponents };
496
+ export { apiHandlers, pageLoaders, pageHandlers, routeComponents };