import { needsHydration, type RouteSpec } from "../spec/schema"; import { GENERATED_RELATIVE_PATHS } from "../paths"; export function generateApiHandler(route: RouteSpec): string { // contractModule + slotModule이 있으면 contract 검증 버전 생성 if (route.contractModule && route.slotModule) { return generateApiHandlerWithContract(route); } // slotModule만 있으면 slot을 호출하는 버전 생성 if (route.slotModule) { return generateApiHandlerWithSlot(route); } return `// Generated by Mandu - DO NOT EDIT DIRECTLY // Route ID: ${route.id} // Pattern: ${route.pattern} import type { Request } from "bun"; export default function handler(req: Request, params: Record): Response { return Response.json({ status: "ok", routeId: "${route.id}", pattern: "${route.pattern}", timestamp: new Date().toISOString(), }); } `; } export function generateApiHandlerWithSlot(route: RouteSpec): string { const slotImportPath = computeSlotImportPath(route.slotModule!, GENERATED_RELATIVE_PATHS.serverRoutes); return `// Generated by Mandu - DO NOT EDIT DIRECTLY // Route ID: ${route.id} // Pattern: ${route.pattern} // Slot Module: ${route.slotModule} import type { Request } from "bun"; import filling from "${slotImportPath}"; export default async function handler( req: Request, params: Record ): Promise { return filling.handle(req, params); } `; } export function generateApiHandlerWithContract(route: RouteSpec): string { const slotImportPath = computeSlotImportPath(route.slotModule!, GENERATED_RELATIVE_PATHS.serverRoutes); const contractImportPath = computeSlotImportPath(route.contractModule!, GENERATED_RELATIVE_PATHS.serverRoutes); return `// Generated by Mandu - DO NOT EDIT DIRECTLY // Route ID: ${route.id} // Pattern: ${route.pattern} // Contract Module: ${route.contractModule} // Slot Module: ${route.slotModule} import type { Request } from "bun"; import { ContractValidator, formatValidationErrors } from "@mandujs/core"; import contract from "${contractImportPath}"; import filling from "${slotImportPath}"; const validator = new ContractValidator(contract); const isDev = process.env.NODE_ENV !== "production"; export default async function handler( req: Request, params: Record ): Promise { const method = req.method; // 1. Request Validation const reqValidation = await validator.validateRequest(req, method, params); if (!reqValidation.success) { return Response.json( formatValidationErrors(reqValidation.errors!), { status: 400 } ); } // 2. Execute Filling const response = await filling.handle(req, params, { routeId: "${route.id}", pattern: "${route.pattern}", }); // 3. Response Validation (dev only) if (isDev && response.headers.get("content-type")?.includes("application/json")) { try { const cloned = response.clone(); const body = await cloned.json(); const resValidation = validator.validateResponse(body, response.status); if (!resValidation.success) { console.warn( "\\x1b[33m[Mandu] Contract violation in response:\\x1b[0m", "${route.id}", resValidation.errors ); } } catch { // Ignore JSON parse errors for non-JSON responses } } return response; } `; } export function generateSlotLogic(route: RouteSpec): string { if (route.contractModule) { return generateSlotLogicWithContract(route); } return `// 🥟 Mandu Filling - ${route.id} // Pattern: ${route.pattern} // 이 파일에서 비즈니스 로직을 구현하세요. import { Mandu } from "@mandujs/core"; export default Mandu.filling() // 📋 GET ${route.pattern} .get((ctx) => { return ctx.ok({ message: "Hello from ${route.id}!", timestamp: new Date().toISOString(), }); }) // ➕ POST ${route.pattern} .post(async (ctx) => { const body = await ctx.body(); return ctx.created({ message: "Created!", received: body, }); }); // 💡 사용 가능한 메서드: // .get(ctx => ...) - GET 요청 처리 // .post(ctx => ...) - POST 요청 처리 // .put(ctx => ...) - PUT 요청 처리 // .delete(ctx => ...) - DELETE 요청 처리 // .guard(ctx => ...) - 인증/검증 미들웨어 // // 💡 Context (ctx) API: // ctx.query - Query parameters { name: 'value' } // ctx.params - Path parameters { id: '123' } // ctx.body() - Request body (자동 파싱) // ctx.body(zodSchema) - Body with validation // ctx.headers - Request headers // ctx.ok(data) - 200 OK // ctx.created(data) - 201 Created // ctx.error(msg) - 400 Bad Request // ctx.notFound(msg) - 404 Not Found // ctx.set(key, value) - Guard에서 Handler로 데이터 전달 // ctx.get(key) - Guard에서 설정한 데이터 읽기 `; } export function generateSlotLogicWithContract(route: RouteSpec): string { const contractImportPath = computeSlotImportPath( route.contractModule!, pathDirname(route.slotModule ?? "spec/slots") ); return `// 🥟 Mandu Filling - ${route.id} // Pattern: ${route.pattern} // Contract Module: ${route.contractModule} // 이 파일에서 비즈니스 로직을 구현하세요. import { Mandu } from "@mandujs/core"; import contract from "${contractImportPath}"; export default Mandu.filling() // 📋 GET ${route.pattern} .get(async (ctx) => { const input = await ctx.input(contract, "GET", ctx.params); // TODO: 계약의 응답 코드에 맞게 status를 조정하세요 return ctx.output(contract, 200, { message: "Hello from ${route.id}!", input, timestamp: new Date().toISOString(), }); }) // ➕ POST ${route.pattern} .post(async (ctx) => { const input = await ctx.input(contract, "POST", ctx.params); // TODO: 계약의 응답 코드에 맞게 status를 조정하세요 return ctx.output(contract, 201, { message: "Created!", input, timestamp: new Date().toISOString(), }); }); // 💡 Contract 기반 사용법: // ctx.input(contract, "GET") - Contract로 요청 검증 + 정규화 // ctx.output(contract, 200, data) - Contract로 응답 검증 // ctx.okContract(contract, data) - 200 OK (Contract 검증) // ctx.createdContract(contract, data) - 201 Created (Contract 검증) `; } function computeSlotImportPath(slotModule: string, fromDir: string): string { // slotModule: "spec/slots/users.slot.ts" // fromDir: ".mandu/generated/server/routes" // result: "../../../../spec/slots/users.slot" const slotParts = slotModule.replace(/\\/g, "/").split("/"); const fromParts = fromDir.replace(/\\/g, "/").split("/"); // Find common prefix length let commonLength = 0; while ( commonLength < slotParts.length && commonLength < fromParts.length && slotParts[commonLength] === fromParts[commonLength] ) { commonLength++; } // Calculate relative path const upCount = fromParts.length - commonLength; const relativeParts = slotParts.slice(commonLength); const ups = Array(upCount).fill(".."); let result = [...ups, ...relativeParts].join("/"); // Remove .ts extension result = result.replace(/\.ts$/, ""); return result; } export function generatePageComponent(route: RouteSpec): string { // Island-First: clientModule이 있으면 Island render를 SSR에서 직접 사용 if (route.clientModule) { return generatePageComponentWithIsland(route); } const hasCompilerOwnedBoundaries = (route.boundaries?.length ?? 0) > 0; if (needsHydration(route) && !hasCompilerOwnedBoundaries) { throw new Error( `[${route.id}] Route has hydration strategy "${route.hydration?.strategy}" but no clientModule. ` + "Refusing to generate a placeholder page because it would disagree with runtime hydration state.", ); } // slotModule이 있으면 PageHandler 형식으로 생성 (filling 포함) if (route.slotModule) { return generatePageHandlerWithSlot(route); } if (route.kind === "page" && route.componentModule) { return generatePageComponentFromModule(route); } if (needsHydration(route)) { throw new Error( `[${route.id}] Route has hydration strategy "${route.hydration?.strategy}" and compiler-owned boundaries, ` + "but no componentModule. Refusing to generate a placeholder page because it would disagree with runtime hydration state.", ); } const pageName = toPascalCase(route.id); // Legacy fallback for malformed historical manifests that lack componentModule. return `// Generated by Mandu - DO NOT EDIT DIRECTLY // Route ID: ${route.id} // Pattern: ${route.pattern} import React from "react"; interface Props { params: Record; loaderData?: unknown; } export default function ${pageName}Page({ params }: Props): React.ReactElement { return React.createElement("div", null, React.createElement("h1", null, "${pageName} Page"), React.createElement("p", null, "Route ID: ${route.id}"), React.createElement("p", null, "Pattern: ${route.pattern}") ); } `; } export function generatePageComponentFromModule(route: RouteSpec): string { const pageName = toPascalCase(route.id); const pageImportPath = computeSlotImportPath(route.componentModule!, GENERATED_RELATIVE_PATHS.webRoutes); return `// Generated by Mandu - DO NOT EDIT DIRECTLY // Route ID: ${route.id} // Pattern: ${route.pattern} // Page Module: ${route.componentModule} import React from "react"; import pageModule from "${pageImportPath}"; interface Props { params: Record; loaderData?: unknown; } export default function ${pageName}Page(props: Props): React.ReactElement { return React.createElement(pageModule as React.ComponentType, props); } `; } /** * Island-First Rendering: SSR이 island의 render 함수를 직접 사용 * - clientModule의 definition.setup() + definition.render() 호출 * - SSR과 클라이언트가 동일한 렌더링 로직 사용 → 불일치 구조적 방지 * - slotModule 유무에 따라 두 가지 변형 생성 */ export function generatePageComponentWithIsland(route: RouteSpec): string { const pageName = toPascalCase(route.id); const clientImportPath = computeSlotImportPath(route.clientModule!, GENERATED_RELATIVE_PATHS.webRoutes); const pageImportPath = route.kind === "page" && route.componentModule ? computeSlotImportPath(route.componentModule, GENERATED_RELATIVE_PATHS.webRoutes) : null; const pageModuleComment = route.kind === "page" && route.componentModule ? `// Page Module: ${route.componentModule}\n` : ""; const shouldImportPageModule = !!pageImportPath && normalizeRouteModulePath(route.componentModule) !== normalizeRouteModulePath(route.clientModule); const pageImport = shouldImportPageModule ? `import pageModule from "${pageImportPath}";\n` : ""; const pageRenderTarget = shouldImportPageModule ? "pageModule" : "islandModule"; const renderHelper = generateClientBackedPageRenderHelper(pageRenderTarget, route.id); // clientModule + slotModule → PageRegistration 형식 if (route.slotModule) { const slotImportPath = computeSlotImportPath(route.slotModule!, GENERATED_RELATIVE_PATHS.webRoutes); return `// Generated by Mandu - DO NOT EDIT DIRECTLY // Island-First Rendering + Slot Module // Route ID: ${route.id} // Pattern: ${route.pattern} // Client Module: ${route.clientModule} // Slot Module: ${route.slotModule} ${pageModuleComment} import React from "react"; import filling from "${slotImportPath}"; import islandModule from "${clientImportPath}"; ${pageImport} interface Props { params: Record; loaderData?: unknown; } ${renderHelper} function ${pageName}Page({ params, loaderData }: Props): React.ReactElement { const serverData = (loaderData || {}) as Record; return renderClientBackedPage({ params, loaderData }, serverData); } // PageRegistration 형식으로 export (server.ts의 registerPageHandler용) export default { component: ${pageName}Page, filling: filling, }; `; } // clientModule만 (slotModule 없음) → default export 컴포넌트 return `// Generated by Mandu - DO NOT EDIT DIRECTLY // Island-First Rendering: SSR이 island render 직접 사용 // Route ID: ${route.id} // Pattern: ${route.pattern} // Client Module: ${route.clientModule} ${pageModuleComment} import React from "react"; import islandModule from "${clientImportPath}"; ${pageImport} interface Props { params: Record; loaderData?: unknown; } ${renderHelper} export default function ${pageName}Page({ params, loaderData }: Props): React.ReactElement { const serverData = (loaderData || {}) as Record; return renderClientBackedPage({ params, loaderData }, serverData); } `; } /** * slotModule이 있는 Page Route용 Handler 생성 * - component와 filling을 함께 export * - server.ts에서 filling.executeLoader() 호출 가능 */ export function generatePageHandlerWithSlot(route: RouteSpec): string { const pageName = toPascalCase(route.id); const slotImportPath = computeSlotImportPath(route.slotModule!, GENERATED_RELATIVE_PATHS.serverRoutes); const pageImportPath = route.kind === "page" && route.componentModule ? computeSlotImportPath(route.componentModule, GENERATED_RELATIVE_PATHS.serverRoutes) : null; const pageImport = pageImportPath ? `import pageModule from "${pageImportPath}";\n` : ""; const pageRender = pageImportPath ? ` return React.createElement(pageModule as React.ComponentType, { params, loaderData });` : ` return React.createElement("div", null, React.createElement("h1", null, "${pageName} Page"), React.createElement("p", null, "Route ID: ${route.id}"), React.createElement("p", null, "Pattern: ${route.pattern}"), loaderData ? React.createElement("pre", null, JSON.stringify(loaderData, null, 2)) : null );`; return `// Generated by Mandu - DO NOT EDIT DIRECTLY // Route ID: ${route.id} // Pattern: ${route.pattern} // Slot Module: ${route.slotModule} import React from "react"; import filling from "${slotImportPath}"; ${pageImport} interface Props { params: Record; loaderData?: unknown; } function ${pageName}Page({ params, loaderData }: Props): React.ReactElement { ${pageRender} } // PageRegistration 형식으로 export (server.ts의 registerPageHandler용) export default { component: ${pageName}Page, filling: filling, }; `; } function generateClientBackedPageRenderHelper(pageRenderTarget: string, routeId: string): string { return `type ManduIslandModule = { __mandu_island: true; definition: { setup: (serverData: Record) => unknown; render: (props: unknown) => React.ReactNode; }; }; function isManduIslandModule(value: unknown): value is ManduIslandModule { const candidate = value as Partial | null; return !!( candidate && typeof candidate === "object" && candidate.__mandu_island === true && typeof candidate.definition?.setup === "function" && typeof candidate.definition?.render === "function" ); } function renderClientBackedPage(props: Props, serverData: Record): React.ReactElement { if (isManduIslandModule(islandModule)) { const setupResult = islandModule.definition.setup(serverData); return islandModule.definition.render(setupResult) as React.ReactElement; } if (typeof ${pageRenderTarget} === "function") { return React.createElement(${pageRenderTarget} as React.ComponentType, props); } if (typeof islandModule === "function") { return React.createElement(islandModule as React.ComponentType>, serverData); } if (React.isValidElement(${pageRenderTarget})) { return ${pageRenderTarget} as React.ReactElement; } if (React.isValidElement(islandModule)) { return islandModule as React.ReactElement; } throw new Error("[Mandu] Route ${routeId} clientModule must export a Mandu island or React component."); }`; } /** * Convert string to PascalCase (handles kebab-case, snake_case) * "todo-page" → "TodoPage" * "user_profile" → "UserProfile" */ function toPascalCase(str: string): string { return str .split(/[-_]/) .map((part) => part.charAt(0).toUpperCase() + part.slice(1)) .join(""); } function pathDirname(filePath: string): string { return filePath.replace(/\\/g, "/").split("/").slice(0, -1).join("/"); } function normalizeRouteModulePath(filePath: string | undefined): string { return (filePath ?? "").replace(/\\/g, "/").replace(/^\.\//, ""); }