@mandujs/core 0.54.8 → 0.54.10

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.54.8",
3
+ "version": "0.54.10",
4
4
  "description": "Mandu Framework Core - Spec, Generator, Guard, Runtime",
5
5
  "type": "module",
6
6
  "main": "./src/index.ts",
@@ -27,13 +27,31 @@ import {
27
27
  writeAgentVerifyReport,
28
28
  } from "../verify";
29
29
 
30
- async function writeFile(root: string, rel: string, content: string): Promise<void> {
31
- const abs = path.join(root, rel);
32
- await fs.mkdir(path.dirname(abs), { recursive: true });
33
- await fs.writeFile(abs, content, "utf8");
34
- }
35
-
36
- describe("agent context", () => {
30
+ async function writeFile(root: string, rel: string, content: string): Promise<void> {
31
+ const abs = path.join(root, rel);
32
+ await fs.mkdir(path.dirname(abs), { recursive: true });
33
+ await fs.writeFile(abs, content, "utf8");
34
+ }
35
+
36
+ async function runGit(root: string, args: string[]): Promise<{ ok: boolean; stdout: string; stderr: string }> {
37
+ try {
38
+ const proc = Bun.spawn(["git", ...args], {
39
+ cwd: root,
40
+ stdout: "pipe",
41
+ stderr: "pipe",
42
+ });
43
+ const [stdout, stderr, exitCode] = await Promise.all([
44
+ new Response(proc.stdout).text(),
45
+ new Response(proc.stderr).text(),
46
+ proc.exited,
47
+ ]);
48
+ return { ok: exitCode === 0, stdout, stderr };
49
+ } catch (error) {
50
+ return { ok: false, stdout: "", stderr: error instanceof Error ? error.message : String(error) };
51
+ }
52
+ }
53
+
54
+ describe("agent context", () => {
37
55
  let root: string;
38
56
 
39
57
  beforeEach(async () => {
@@ -172,19 +190,48 @@ describe("agent context", () => {
172
190
  includeContract: false,
173
191
  });
174
192
 
175
- expect(report.framework).toBe("mandu");
176
- expect(report.ok).toBe(true);
177
- expect(report.checks.map((check) => check.id)).toEqual(["manifest"]);
193
+ expect(report.framework).toBe("mandu");
194
+ expect(report.ok).toBe(true);
195
+ expect(report.checks.map((check) => check.id)).toEqual(["internal-api", "manifest"]);
178
196
  expect(report.suggestedCommands.map((cmd) => cmd.command)).toContain("bun run typecheck");
179
197
  expect(report.nextRepairInput).toBe(".mandu/agent-verify.json");
180
198
 
181
199
  const result = await writeAgentVerifyReport(root, report);
182
200
  expect(result.path).toBe(agentVerifyReportPath(root));
183
201
  const parsed = JSON.parse(await fs.readFile(result.path, "utf8"));
184
- expect(parsed.project.name).toBe("agent-app");
185
- });
186
-
187
- it("turns a verify report into repair actions", async () => {
202
+ expect(parsed.project.name).toBe("agent-app");
203
+ });
204
+
205
+ it("records changed file reasons and warns on internal API edits", async () => {
206
+ const version = await runGit(root, ["--version"]);
207
+ if (!version.ok) return;
208
+
209
+ expect((await runGit(root, ["init"])).ok).toBe(true);
210
+ await runGit(root, ["config", "user.email", "agent@example.com"]);
211
+ await runGit(root, ["config", "user.name", "Agent"]);
212
+ expect((await runGit(root, ["add", "."])).ok).toBe(true);
213
+ expect((await runGit(root, ["commit", "-m", "initial"])).ok).toBe(true);
214
+
215
+ await writeFile(root, "packages/core/src/runtime/internal-change.ts", "export const value = 1;\n");
216
+
217
+ const report = await buildAgentVerifyReport(root, {
218
+ includeDiagnose: false,
219
+ includeGuard: false,
220
+ includeContract: false,
221
+ });
222
+
223
+ const reason = report.changedFileReasons.find(
224
+ (entry) => entry.file === "packages/core/src/runtime/internal-change.ts",
225
+ );
226
+ expect(reason?.internalApi).toBe(true);
227
+ expect(reason?.recommendedChecks).toContain("bun run check:public-api && bun run check:target-boundaries");
228
+ expect(report.diagnostics.some((diag) => diag.code === "MANDU_VERIFY_INTERNAL_API_EDIT")).toBe(true);
229
+ expect(report.suggestedCommands.map((cmd) => cmd.command)).toContain(
230
+ "bun run check:public-api && bun run check:target-boundaries",
231
+ );
232
+ });
233
+
234
+ it("turns a verify report into repair actions", async () => {
188
235
  await writeAgentVerifyReport(root, {
189
236
  schemaVersion: 1,
190
237
  framework: "mandu",
@@ -195,9 +242,10 @@ describe("agent context", () => {
195
242
  root,
196
243
  packageManager: "bun",
197
244
  configFile: null,
198
- },
199
- changedFiles: [],
200
- gitAvailable: false,
245
+ },
246
+ changedFiles: [],
247
+ changedFileReasons: [],
248
+ gitAvailable: false,
201
249
  notes: [],
202
250
  ok: false,
203
251
  checks: [],
@@ -153,19 +153,27 @@ export interface AgentVerifyCheck {
153
153
  details?: Record<string, unknown>;
154
154
  }
155
155
 
156
- export interface AgentSuggestedCommand {
157
- command: string;
158
- reason: string;
159
- required: boolean;
160
- }
161
-
162
- export interface AgentVerifyReport {
156
+ export interface AgentSuggestedCommand {
157
+ command: string;
158
+ reason: string;
159
+ required: boolean;
160
+ }
161
+
162
+ export interface AgentChangedFileReason {
163
+ file: string;
164
+ reasons: string[];
165
+ recommendedChecks: string[];
166
+ internalApi: boolean;
167
+ }
168
+
169
+ export interface AgentVerifyReport {
163
170
  schemaVersion: 1;
164
171
  framework: "mandu";
165
172
  generatedAt: string;
166
- project: AgentProjectSummary;
167
- changedFiles: string[];
168
- gitAvailable: boolean;
173
+ project: AgentProjectSummary;
174
+ changedFiles: string[];
175
+ changedFileReasons: AgentChangedFileReason[];
176
+ gitAvailable: boolean;
169
177
  notes: string[];
170
178
  ok: boolean;
171
179
  checks: AgentVerifyCheck[];
@@ -7,8 +7,9 @@ import type { ContractViolation } from "../guard/contract-guard";
7
7
  import {
8
8
  buildAgentContext,
9
9
  } from "./context";
10
- import type {
11
- AgentDiagnostic,
10
+ import type {
11
+ AgentChangedFileReason,
12
+ AgentDiagnostic,
12
13
  AgentDiagnosticSeverity,
13
14
  AgentSuggestedCommand,
14
15
  AgentVerifyCheck,
@@ -208,7 +209,11 @@ function matchesChanged(
208
209
  return candidates.some((file) => changed.has(file));
209
210
  }
210
211
 
211
- function commandSuggestions(changedFiles: string[], diagnostics: AgentDiagnostic[]): AgentSuggestedCommand[] {
212
+ function commandSuggestions(
213
+ changedFiles: string[],
214
+ diagnostics: AgentDiagnostic[],
215
+ changedFileReasons: AgentChangedFileReason[] = [],
216
+ ): AgentSuggestedCommand[] {
212
217
  const files = changedFiles.map(normalizePath).filter((value): value is string => Boolean(value));
213
218
  const out: AgentSuggestedCommand[] = [];
214
219
  const add = (command: string, reason: string, required: boolean) => {
@@ -240,15 +245,101 @@ function commandSuggestions(changedFiles: string[], diagnostics: AgentDiagnostic
240
245
  if (files.some((file) => file.startsWith("packages/mcp/"))) {
241
246
  add("bun test packages/mcp/tests", "MCP package files changed.", true);
242
247
  }
243
- if (files.some((file) => file.includes("package.json") || file === "bun.lock")) {
244
- add("bun run check:publish", "Package metadata or lockfile changed.", true);
245
- }
246
- if (diagnostics.some((d) => d.severity === "error" || d.severity === "fatal")) {
248
+ if (files.some((file) => file.includes("package.json") || file === "bun.lock")) {
249
+ add("bun run check:publish", "Package metadata or lockfile changed.", true);
250
+ }
251
+ if (changedFileReasons.some((entry) => entry.internalApi)) {
252
+ add("bun run check:public-api && bun run check:target-boundaries", "Internal framework boundaries changed.", true);
253
+ }
254
+ if (diagnostics.some((d) => d.severity === "error" || d.severity === "fatal")) {
247
255
  add("mandu agent repair --from .mandu/agent-verify.json", "Verification produced repairable diagnostics.", false);
248
256
  }
249
257
 
250
258
  return out;
251
- }
259
+ }
260
+
261
+ function isInternalApiEdit(file: string): boolean {
262
+ return [
263
+ "packages/core/src/runtime/",
264
+ "packages/core/src/bundler/",
265
+ "packages/core/src/server/",
266
+ "packages/core/src/guard/",
267
+ "packages/core/src/spec/",
268
+ "packages/core/src/router/",
269
+ "packages/core/src/internal/",
270
+ ].some((prefix) => file.startsWith(prefix));
271
+ }
272
+
273
+ function changedFileReason(file: string): AgentChangedFileReason {
274
+ const normalized = normalizePath(file) ?? file;
275
+ const reasons: string[] = [];
276
+ const recommendedChecks: string[] = [];
277
+
278
+ if (/\.(ts|tsx|js|jsx)$/.test(normalized)) {
279
+ reasons.push("Source code changed.");
280
+ recommendedChecks.push("bun run typecheck");
281
+ }
282
+ if (/\.test\.(ts|tsx|js|jsx)$/.test(normalized)) {
283
+ reasons.push("Test code changed.");
284
+ recommendedChecks.push(`bun test ${normalized}`);
285
+ }
286
+ if (normalized.startsWith("packages/cli/")) {
287
+ reasons.push("CLI behavior or documentation changed.");
288
+ recommendedChecks.push("bun test packages/cli/src");
289
+ }
290
+ if (normalized.startsWith("packages/mcp/")) {
291
+ reasons.push("MCP tool surface changed.");
292
+ recommendedChecks.push("bun test packages/mcp/tests");
293
+ }
294
+ if (normalized.startsWith("docs/") || normalized.endsWith("README.md") || normalized.endsWith("README.ko.md")) {
295
+ reasons.push("User-facing documentation changed.");
296
+ recommendedChecks.push("bun run check:docs-drift");
297
+ }
298
+ if (normalized === "package.json" || normalized === "bun.lock" || normalized.endsWith("/package.json")) {
299
+ reasons.push("Package metadata or dependency graph changed.");
300
+ recommendedChecks.push("bun run check:publish");
301
+ }
302
+
303
+ const internalApi = isInternalApiEdit(normalized);
304
+ if (internalApi) {
305
+ reasons.push("Framework internal API changed.");
306
+ recommendedChecks.push("bun run check:public-api && bun run check:target-boundaries");
307
+ }
308
+
309
+ return {
310
+ file: normalized,
311
+ reasons: reasons.length > 0 ? reasons : ["Changed file requires standard verification."],
312
+ recommendedChecks: [...new Set(recommendedChecks)],
313
+ internalApi,
314
+ };
315
+ }
316
+
317
+ function buildChangedFileReasons(changedFiles: string[]): AgentChangedFileReason[] {
318
+ return changedFiles
319
+ .map(normalizePath)
320
+ .filter((file): file is string => Boolean(file))
321
+ .map(changedFileReason);
322
+ }
323
+
324
+ function internalApiDiagnostics(changedFileReasons: AgentChangedFileReason[]): AgentDiagnostic[] {
325
+ return changedFileReasons
326
+ .filter((entry) => entry.internalApi)
327
+ .map((entry) => ({
328
+ code: "MANDU_VERIFY_INTERNAL_API_EDIT",
329
+ severity: "warning" as const,
330
+ title: "Internal framework API changed",
331
+ file: entry.file,
332
+ cause: "This file is part of Mandu's internal runtime/bundler/guard surface and can affect public behavior indirectly.",
333
+ suggestedFix: {
334
+ type: "run_command" as const,
335
+ command: "bun run check:public-api && bun run check:target-boundaries",
336
+ description: "Verify public API classification and target-safe import boundaries.",
337
+ },
338
+ docs: "docs/architect/public-api-boundary.md",
339
+ repairable: false,
340
+ source: "agent.verify",
341
+ }));
342
+ }
252
343
 
253
344
  function check(
254
345
  id: string,
@@ -281,16 +372,22 @@ export async function buildAgentVerifyReport(
281
372
  rootDir: string = process.cwd(),
282
373
  options: BuildAgentVerifyOptions = {},
283
374
  ): Promise<AgentVerifyReport> {
284
- const root = path.resolve(rootDir);
285
- const changed = await collectChangedFiles(root, options);
286
- const changedSet = new Set(changed.files.map(normalizePath).filter((value): value is string => Boolean(value)));
375
+ const root = path.resolve(rootDir);
376
+ const changed = await collectChangedFiles(root, options);
377
+ const changedSet = new Set(changed.files.map(normalizePath).filter((value): value is string => Boolean(value)));
378
+ const changedFileReasons = buildChangedFileReasons(changed.files);
287
379
  const context = await buildAgentContext(root, {
288
380
  includeDiagnose: false,
289
381
  includeGit: false,
290
382
  });
291
383
  const notes = [...changed.notes];
292
- const checks: AgentVerifyCheck[] = [];
293
- const diagnostics: AgentDiagnostic[] = [];
384
+ const checks: AgentVerifyCheck[] = [];
385
+ const diagnostics: AgentDiagnostic[] = [];
386
+ const internalDiagnostics = internalApiDiagnostics(changedFileReasons);
387
+ diagnostics.push(...internalDiagnostics);
388
+ checks.push(check("internal-api", "Internal API boundary", internalDiagnostics, {
389
+ changedFiles: changedFileReasons.filter((entry) => entry.internalApi).length,
390
+ }));
294
391
 
295
392
  if (options.includeDiagnose !== false) {
296
393
  try {
@@ -382,14 +479,15 @@ export async function buildAgentVerifyReport(
382
479
  schemaVersion: 1,
383
480
  framework: "mandu",
384
481
  generatedAt: new Date().toISOString(),
385
- project: context.project,
386
- changedFiles: changed.files,
387
- gitAvailable: changed.gitAvailable,
482
+ project: context.project,
483
+ changedFiles: changed.files,
484
+ changedFileReasons,
485
+ gitAvailable: changed.gitAvailable,
388
486
  notes,
389
487
  ok,
390
488
  checks,
391
489
  diagnostics,
392
- suggestedCommands: commandSuggestions(changed.files, diagnostics),
490
+ suggestedCommands: commandSuggestions(changed.files, diagnostics, changedFileReasons),
393
491
  nextRepairInput: AGENT_VERIFY_RELATIVE_PATH,
394
492
  };
395
493
  }
@@ -248,7 +248,7 @@ describe("buildClientBundles vendor shims", () => {
248
248
  expect(noClientResult.success).toBe(false);
249
249
  expect(errors).toContain("no clientModule could be resolved");
250
250
  expect(errors).toContain("LoginForm.client");
251
- expect(errors).toContain("partial({ component }).Render");
251
+ expect(errors).toContain("run mandu generate");
252
252
  } finally {
253
253
  await rm(missingRoot, { recursive: true, force: true });
254
254
  }
@@ -21,15 +21,20 @@ export function formatErrorResponse(error: ManduError, options: FormatOptions =
21
21
 
22
22
  const response: Record<string, unknown> = {
23
23
  errorType: error.errorType,
24
- code: error.code,
25
- message: error.message,
26
- summary: error.summary,
27
- fix: error.fix,
28
- };
29
-
30
- if (error.route) {
31
- response.route = error.route;
32
- }
24
+ code: error.code,
25
+ message: error.message,
26
+ summary: error.summary,
27
+ cause: error.summary,
28
+ fix: error.fix,
29
+ filePath: error.fix.file,
30
+ solution: error.fix.suggestion,
31
+ };
32
+
33
+ if (error.route) {
34
+ response.route = error.route;
35
+ response.routeId = error.route.id;
36
+ response.routePattern = error.route.pattern;
37
+ }
33
38
 
34
39
  // 개발 모드에서만 디버그 정보 포함
35
40
  if (isDev && error.debug) {
@@ -60,27 +65,31 @@ export function formatErrorForConsole(error: ManduError, options: FormatOptions
60
65
  lines.push(` ${error.message}`);
61
66
 
62
67
  // 요약
63
- if (useColors) {
64
- lines.push(` ${CYAN}→ ${error.summary}${RESET}`);
65
- } else {
66
- lines.push(` → ${error.summary}`);
67
- }
68
+ if (useColors) {
69
+ lines.push(` ${CYAN}→ ${error.summary}${RESET}`);
70
+ } else {
71
+ lines.push(` → ${error.summary}`);
72
+ }
73
+ lines.push(` Cause: ${error.summary}`);
68
74
 
69
75
  // 수정 안내
70
76
  lines.push("");
71
77
  if (useColors) {
72
78
  lines.push(` ${YELLOW}Fix:${RESET} ${error.fix.file}${error.fix.line ? `:${error.fix.line}` : ""}`);
73
79
  lines.push(` ${error.fix.suggestion}`);
74
- } else {
75
- lines.push(` Fix: ${error.fix.file}${error.fix.line ? `:${error.fix.line}` : ""}`);
76
- lines.push(` ${error.fix.suggestion}`);
77
- }
80
+ } else {
81
+ lines.push(` Fix: ${error.fix.file}${error.fix.line ? `:${error.fix.line}` : ""}`);
82
+ lines.push(` ${error.fix.suggestion}`);
83
+ }
84
+ lines.push(` File: ${error.fix.file}${error.fix.line ? `:${error.fix.line}` : ""}`);
85
+ lines.push(` Solution: ${error.fix.suggestion}`);
78
86
 
79
87
  // 라우트 컨텍스트
80
- if (error.route) {
81
- lines.push("");
82
- lines.push(` Route: ${error.route.id} (${error.route.pattern})`);
83
- }
88
+ if (error.route) {
89
+ lines.push("");
90
+ lines.push(` Route ID: ${error.route.id}`);
91
+ lines.push(` Route: ${error.route.pattern}`);
92
+ }
84
93
 
85
94
  // 디버그 정보 (개발 모드)
86
95
  if (isDev && includeStack && error.debug?.stack) {
@@ -20,7 +20,7 @@ describe("generatePageComponent", () => {
20
20
  expect(() => generatePageComponent(route)).toThrow("no clientModule");
21
21
  });
22
22
 
23
- test("still emits placeholders for static routes without hydration", () => {
23
+ test("imports the real page module for static routes instead of emitting a placeholder", () => {
24
24
  const route: RouteSpec = {
25
25
  id: "about",
26
26
  kind: "page",
@@ -29,6 +29,37 @@ describe("generatePageComponent", () => {
29
29
  componentModule: "app/about/page.tsx",
30
30
  };
31
31
 
32
- expect(generatePageComponent(route)).toContain("About Page");
32
+ const generated = generatePageComponent(route);
33
+
34
+ expect(generated).toContain("Page Module: app/about/page.tsx");
35
+ expect(generated).toContain('import pageModule from "../../../../app/about/page.tsx"');
36
+ expect(generated).toContain("React.createElement(pageModule");
37
+ expect(generated).not.toContain("About Page");
38
+ expect(generated).not.toContain('React.createElement("p", null, "Route ID: about")');
39
+ });
40
+
41
+ test("renders route-level default-imported client components through the page module", () => {
42
+ const route: RouteSpec = {
43
+ id: "login",
44
+ kind: "page",
45
+ pattern: "/login",
46
+ module: "app/login/page.tsx",
47
+ componentModule: "app/login/page.tsx",
48
+ clientModule: "app/login/page.tsx",
49
+ hydration: {
50
+ strategy: "island",
51
+ priority: "immediate",
52
+ preload: false,
53
+ },
54
+ };
55
+
56
+ const generated = generatePageComponent(route);
57
+
58
+ expect(generated).toContain("Client Module: app/login/page.tsx");
59
+ expect(generated).toContain("Page Module: app/login/page.tsx");
60
+ expect(generated).toContain('import islandModule from "../../../../app/login/page.tsx"');
61
+ expect(generated).toContain("islandModule.definition.render");
62
+ expect(generated).toContain("React.createElement(islandModule");
63
+ expect(generated).not.toContain("Login Page");
33
64
  });
34
65
  });
@@ -253,15 +253,19 @@ export function generatePageComponent(route: RouteSpec): string {
253
253
 
254
254
  // slotModule이 있으면 PageHandler 형식으로 생성 (filling 포함)
255
255
  if (route.slotModule) {
256
- return generatePageHandlerWithSlot(route);
257
- }
258
-
259
- const pageName = toPascalCase(route.id);
260
-
261
- // slotModule이 없으면 기존 방식
262
- return `// Generated by Mandu - DO NOT EDIT DIRECTLY
263
- // Route ID: ${route.id}
264
- // Pattern: ${route.pattern}
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}
265
269
 
266
270
  import React from "react";
267
271
 
@@ -277,8 +281,31 @@ export default function ${pageName}Page({ params }: Props): React.ReactElement {
277
281
  React.createElement("p", null, "Pattern: ${route.pattern}")
278
282
  );
279
283
  }
280
- `;
281
- }
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
+ }
282
309
 
283
310
  /**
284
311
  * Island-First Rendering: SSR이 island의 render 함수를 직접 사용
@@ -286,37 +313,51 @@ export default function ${pageName}Page({ params }: Props): React.ReactElement {
286
313
  * - SSR과 클라이언트가 동일한 렌더링 로직 사용 → 불일치 구조적 방지
287
314
  * - slotModule 유무에 따라 두 가지 변형 생성
288
315
  */
289
- export function generatePageComponentWithIsland(route: RouteSpec): string {
290
- const pageName = toPascalCase(route.id);
291
- const clientImportPath = computeSlotImportPath(route.clientModule!, GENERATED_RELATIVE_PATHS.webRoutes);
292
-
293
- // clientModule + slotModule → PageRegistration 형식
294
- if (route.slotModule) {
295
- 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);
296
334
 
297
335
  return `// Generated by Mandu - DO NOT EDIT DIRECTLY
298
336
  // Island-First Rendering + Slot Module
299
337
  // Route ID: ${route.id}
300
- // Pattern: ${route.pattern}
301
- // Client Module: ${route.clientModule}
302
- // Slot Module: ${route.slotModule}
303
-
304
- import React from "react";
305
- import filling from "${slotImportPath}";
306
- import islandModule from "${clientImportPath}";
307
-
308
- interface Props {
309
- params: Record<string, string>;
310
- loaderData?: unknown;
311
- }
312
-
313
- function ${pageName}Page({ params, loaderData }: Props): React.ReactElement {
314
- const serverData = (loaderData || {}) as Record<string, unknown>;
315
- const setupResult = islandModule.definition.setup(serverData);
316
- return islandModule.definition.render(setupResult) as React.ReactElement;
317
- }
318
-
319
- // 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용)
320
361
  export default {
321
362
  component: ${pageName}Page,
322
363
  filling: filling,
@@ -327,67 +368,124 @@ export default {
327
368
  // clientModule만 (slotModule 없음) → default export 컴포넌트
328
369
  return `// Generated by Mandu - DO NOT EDIT DIRECTLY
329
370
  // Island-First Rendering: SSR이 island render 직접 사용
330
- // Route ID: ${route.id}
331
- // Pattern: ${route.pattern}
332
- // Client Module: ${route.clientModule}
333
-
334
- import React from "react";
335
- import islandModule from "${clientImportPath}";
336
-
337
- interface Props {
338
- params: Record<string, string>;
339
- loaderData?: unknown;
340
- }
341
-
342
- export default function ${pageName}Page({ params, loaderData }: Props): React.ReactElement {
343
- const serverData = (loaderData || {}) as Record<string, unknown>;
344
- const setupResult = islandModule.definition.setup(serverData);
345
- return islandModule.definition.render(setupResult) as React.ReactElement;
346
- }
347
- `;
348
- }
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
+ }
349
393
 
350
394
  /**
351
395
  * slotModule이 있는 Page Route용 Handler 생성
352
396
  * - component와 filling을 함께 export
353
397
  * - server.ts에서 filling.executeLoader() 호출 가능
354
398
  */
355
- export function generatePageHandlerWithSlot(route: RouteSpec): string {
356
- const pageName = toPascalCase(route.id);
357
- const slotImportPath = computeSlotImportPath(route.slotModule!, GENERATED_RELATIVE_PATHS.serverRoutes);
358
-
359
- return `// Generated by Mandu - DO NOT EDIT DIRECTLY
360
- // Route ID: ${route.id}
361
- // Pattern: ${route.pattern}
362
- // Slot Module: ${route.slotModule}
363
-
364
- import React from "react";
365
- import filling from "${slotImportPath}";
366
-
367
- interface Props {
368
- params: Record<string, string>;
369
- loaderData?: unknown;
370
- }
371
-
372
- function ${pageName}Page({ params, loaderData }: Props): React.ReactElement {
373
- return React.createElement("div", null,
374
- React.createElement("h1", null, "${pageName} Page"),
375
- React.createElement("p", null, "Route ID: ${route.id}"),
376
- React.createElement("p", null, "Pattern: ${route.pattern}"),
377
- loaderData ? React.createElement("pre", null, JSON.stringify(loaderData, null, 2)) : null
378
- );
379
- }
380
-
381
- // 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용)
382
434
  export default {
383
435
  component: ${pageName}Page,
384
436
  filling: filling,
385
437
  };
386
- `;
387
- }
388
-
389
- /**
390
- * 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)
391
489
  * "todo-page" → "TodoPage"
392
490
  * "user_profile" → "UserProfile"
393
491
  */
@@ -398,6 +496,10 @@ function toPascalCase(str: string): string {
398
496
  .join("");
399
497
  }
400
498
 
401
- function pathDirname(filePath: string): string {
402
- return filePath.replace(/\\/g, "/").split("/").slice(0, -1).join("/");
403
- }
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
+ }
@@ -2,6 +2,7 @@ import { describe, expect, it } from "bun:test";
2
2
  import {
3
3
  findClientComponentImports,
4
4
  findRouteLevelClientComponentImport,
5
+ findRouteLevelClientComponentImports,
5
6
  } from "./client-entry";
6
7
 
7
8
  describe("findClientComponentImports", () => {
@@ -15,7 +16,7 @@ describe("findClientComponentImports", () => {
15
16
  {
16
17
  module: "@/client/widgets/login-form/LoginForm.client",
17
18
  kind: "named",
18
- names: ["LoginForm", "SubmitButton"],
19
+ names: ["LoginForm", "Button"],
19
20
  },
20
21
  {
21
22
  module: "./Header.client.tsx",
@@ -42,7 +43,7 @@ describe("findClientComponentImports", () => {
42
43
  });
43
44
  });
44
45
 
45
- it("does not promote embedded client imports inside a larger server page", () => {
46
+ it("promotes a fragment wrapper with head-only elements and a bare client component", () => {
46
47
  const routeClient = findRouteLevelClientComponentImport(`
47
48
  import HomeApp from "@/client/pages/home/HomeApp.client";
48
49
 
@@ -54,10 +55,46 @@ describe("findClientComponentImports", () => {
54
55
  }
55
56
  `);
56
57
 
57
- expect(routeClient).toBeNull();
58
+ expect(routeClient).toEqual({
59
+ module: "@/client/pages/home/HomeApp.client",
60
+ localName: "HomeApp",
61
+ });
62
+ });
63
+
64
+ it("detects a named-imported client component when the page returns only that component", () => {
65
+ const routeClient = findRouteLevelClientComponentImport(`
66
+ import { NotificationsPage } from "@/client/pages/notifications/NotificationsPage.client";
67
+
68
+ export default function Page() {
69
+ return <NotificationsPage />;
70
+ }
71
+ `);
72
+
73
+ expect(routeClient).toEqual({
74
+ module: "@/client/pages/notifications/NotificationsPage.client",
75
+ localName: "NotificationsPage",
76
+ });
58
77
  });
59
78
 
60
- it("does not promote client imports when the page wrapper passes props", () => {
79
+ it("promotes embedded client imports inside a larger server page", () => {
80
+ const routeClient = findRouteLevelClientComponentImport(`
81
+ import HomeApp from "@/client/pages/home/HomeApp.client";
82
+
83
+ export default function HomePage() {
84
+ return <>
85
+ <header>Server shell</header>
86
+ <HomeApp />
87
+ </>;
88
+ }
89
+ `);
90
+
91
+ expect(routeClient).toEqual({
92
+ module: "@/client/pages/home/HomeApp.client",
93
+ localName: "HomeApp",
94
+ });
95
+ });
96
+
97
+ it("promotes client imports when the page wrapper passes props", () => {
61
98
  const routeClient = findRouteLevelClientComponentImport(`
62
99
  import PledgePage from "@/client/pages/pledges/PledgePage.client";
63
100
 
@@ -66,6 +103,38 @@ describe("findClientComponentImports", () => {
66
103
  }
67
104
  `);
68
105
 
69
- expect(routeClient).toBeNull();
106
+ expect(routeClient).toEqual({
107
+ module: "@/client/pages/pledges/PledgePage.client",
108
+ localName: "PledgePage",
109
+ });
110
+ });
111
+
112
+ it("detects multiple rendered client imports in a server shell", () => {
113
+ const routeClients = findRouteLevelClientComponentImports(`
114
+ import { CommentsSection } from "@/client/widgets/comments-section/CommentsSection.client";
115
+ import { PledgeActions } from "@/client/widgets/pledge-actions/PledgeActions.client";
116
+
117
+ export default async function PledgePage({ params }) {
118
+ const pledge = await Promise.resolve(params.id);
119
+ return (
120
+ <main>
121
+ <article>{pledge}</article>
122
+ <PledgeActions pledgeId={params.id} />
123
+ <CommentsSection pledgeId={params.id} />
124
+ </main>
125
+ );
126
+ }
127
+ `);
128
+
129
+ expect(routeClients).toEqual([
130
+ {
131
+ module: "@/client/widgets/comments-section/CommentsSection.client",
132
+ localName: "CommentsSection",
133
+ },
134
+ {
135
+ module: "@/client/widgets/pledge-actions/PledgeActions.client",
136
+ localName: "PledgeActions",
137
+ },
138
+ ]);
70
139
  });
71
140
  });
@@ -62,7 +62,8 @@ export function findClientComponentImports(source: string): ClientComponentImpor
62
62
  for (const rawName of namedMatch[1].split(",")) {
63
63
  const name = rawName.trim();
64
64
  if (!name) continue;
65
- names.push(name.split(/\s+as\s+/i)[0].trim());
65
+ const parts = name.split(/\s+as\s+/i).map((part) => part.trim()).filter(Boolean);
66
+ names.push(parts[1] ?? parts[0]);
66
67
  }
67
68
  }
68
69
 
@@ -100,19 +101,18 @@ export function findClientComponentImports(source: string): ClientComponentImpor
100
101
  }
101
102
 
102
103
  export function findRouteLevelClientComponentImport(source: string): RouteLevelClientComponentImport | null {
103
- const defaultImports = findClientComponentImports(source).filter((entry) => {
104
- const localName = entry.names[0] ?? "";
105
- return entry.kind === "default" && /^[A-Za-z_$][A-Za-z0-9_$]*$/.test(localName);
106
- });
107
-
108
- if (defaultImports.length !== 1) return null;
104
+ return findRouteLevelClientComponentImports(source)[0] ?? null;
105
+ }
109
106
 
110
- const entry = defaultImports[0];
111
- const localName = entry.names[0];
112
- if (!entry.module || !localName) return null;
113
- if (!defaultExportReturnsOnlyClientComponent(source, localName)) return null;
107
+ export function findRouteLevelClientComponentImports(source: string): RouteLevelClientComponentImport[] {
108
+ const candidates = findClientComponentImports(source).flatMap((entry) =>
109
+ entry.names
110
+ .filter((localName) => /^[A-Za-z_$][A-Za-z0-9_$]*$/.test(localName))
111
+ .map((localName) => ({ module: entry.module, localName }))
112
+ );
114
113
 
115
- return { module: entry.module, localName };
114
+ if (candidates.length === 0) return [];
115
+ return defaultExportRendersClientComponents(source, candidates);
116
116
  }
117
117
 
118
118
  export async function resolveClientImportModulePath(
@@ -141,7 +141,8 @@ export async function shouldPreserveExistingClientModule(
141
141
  if (source === null) return false;
142
142
  if (hasUseServerDirective(source)) return false;
143
143
  if (clientModuleIsRouteComponent(route, clientModule)) {
144
- return hasUseClientDirective(source);
144
+ if (hasUseClientDirective(source)) return true;
145
+ return await routeComponentHasResolvableClientEntry(rootDir, clientModule, source);
145
146
  }
146
147
  return true;
147
148
  }
@@ -167,15 +168,28 @@ function expandClientModuleCandidates(basePath: string): string[] {
167
168
  ];
168
169
  }
169
170
 
170
- function defaultExportReturnsOnlyClientComponent(source: string, localName: string): boolean {
171
+ function defaultExportRendersClientComponents(
172
+ source: string,
173
+ candidates: RouteLevelClientComponentImport[],
174
+ ): RouteLevelClientComponentImport[] {
171
175
  const functionBody = extractDefaultExportFunctionBody(source);
172
176
  if (functionBody !== null) {
173
- const returned = extractOnlyReturnExpression(functionBody);
174
- return returned !== null && isSelfClosingJsxElement(returned, localName);
177
+ const returned = extractTopLevelReturnExpression(functionBody);
178
+ return returned !== null ? jsxExpressionRendersClientComponents(returned, candidates) : [];
175
179
  }
176
180
 
177
181
  const arrowExpression = extractDefaultExportArrowExpression(source);
178
- return arrowExpression !== null && isSelfClosingJsxElement(arrowExpression, localName);
182
+ if (arrowExpression !== null) {
183
+ return jsxExpressionRendersClientComponents(arrowExpression, candidates);
184
+ }
185
+
186
+ const arrowBody = extractDefaultExportArrowFunctionBody(source);
187
+ if (arrowBody !== null) {
188
+ const returned = extractTopLevelReturnExpression(arrowBody);
189
+ return returned !== null ? jsxExpressionRendersClientComponents(returned, candidates) : [];
190
+ }
191
+
192
+ return [];
179
193
  }
180
194
 
181
195
  function extractDefaultExportFunctionBody(source: string): string | null {
@@ -200,15 +214,109 @@ function extractDefaultExportArrowExpression(source: string): string | null {
200
214
  return semicolon === -1 ? rest : rest.slice(0, semicolon);
201
215
  }
202
216
 
203
- function extractOnlyReturnExpression(body: string): string | null {
204
- const match = /^\s*return\s+([\s\S]*?)\s*;?\s*$/.exec(body);
205
- return match?.[1]?.trim() ?? null;
217
+ function extractDefaultExportArrowFunctionBody(source: string): string | null {
218
+ const match = /export\s+default\s+(?:async\s+)?(?:\([^)]*\)|[A-Za-z_$][A-Za-z0-9_$]*)\s*=>\s*\{/m.exec(source);
219
+ if (!match) return null;
220
+
221
+ const openBrace = match.index + match[0].lastIndexOf("{");
222
+ const closeBrace = findMatchingBrace(source, openBrace);
223
+ if (closeBrace === -1) return null;
224
+ return source.slice(openBrace + 1, closeBrace);
225
+ }
226
+
227
+ function extractTopLevelReturnExpression(body: string): string | null {
228
+ let quote: '"' | "'" | "`" | null = null;
229
+ let lineComment = false;
230
+ let blockComment = false;
231
+ let braceDepth = 0;
232
+ let parenDepth = 0;
233
+ let bracketDepth = 0;
234
+
235
+ for (let i = 0; i < body.length; i++) {
236
+ const char = body[i];
237
+ const next = body[i + 1];
238
+ const prev = body[i - 1];
239
+
240
+ if (lineComment) {
241
+ if (char === "\n" || char === "\r") lineComment = false;
242
+ continue;
243
+ }
244
+
245
+ if (blockComment) {
246
+ if (char === "*" && next === "/") {
247
+ blockComment = false;
248
+ i++;
249
+ }
250
+ continue;
251
+ }
252
+
253
+ if (quote) {
254
+ if (char === quote && prev !== "\\") quote = null;
255
+ continue;
256
+ }
257
+
258
+ if (char === "/" && next === "/") {
259
+ lineComment = true;
260
+ i++;
261
+ continue;
262
+ }
263
+ if (char === "/" && next === "*") {
264
+ blockComment = true;
265
+ i++;
266
+ continue;
267
+ }
268
+ if (char === '"' || char === "'" || char === "`") {
269
+ quote = char;
270
+ continue;
271
+ }
272
+
273
+ if (braceDepth === 0 && parenDepth === 0 && bracketDepth === 0 && body.startsWith("return", i)) {
274
+ const before = body[i - 1] ?? "";
275
+ const after = body[i + "return".length] ?? "";
276
+ if (!isIdentifierChar(before) && !isIdentifierChar(after)) {
277
+ const expr = body.slice(i + "return".length).trim();
278
+ return trimTrailingSemicolon(expr);
279
+ }
280
+ }
281
+
282
+ if (char === "{") braceDepth++;
283
+ if (char === "}") braceDepth = Math.max(0, braceDepth - 1);
284
+ if (char === "(") parenDepth++;
285
+ if (char === ")") parenDepth = Math.max(0, parenDepth - 1);
286
+ if (char === "[") bracketDepth++;
287
+ if (char === "]") bracketDepth = Math.max(0, bracketDepth - 1);
288
+ }
289
+
290
+ return null;
291
+ }
292
+
293
+ function trimTrailingSemicolon(value: string): string {
294
+ const trimmed = value.trim();
295
+ return trimmed.endsWith(";") ? trimmed.slice(0, -1).trim() : trimmed;
206
296
  }
207
297
 
208
- function isSelfClosingJsxElement(expression: string, localName: string): boolean {
298
+ function jsxExpressionRendersClientComponents(
299
+ expression: string,
300
+ candidates: RouteLevelClientComponentImport[],
301
+ ): RouteLevelClientComponentImport[] {
209
302
  const expr = stripWrappingParentheses(expression.trim());
303
+ const seen = new Set<string>();
304
+ const rendered: RouteLevelClientComponentImport[] = [];
305
+
306
+ for (const candidate of candidates) {
307
+ const key = `${candidate.module}\0${candidate.localName}`;
308
+ if (seen.has(key)) continue;
309
+ if (!jsxExpressionContainsClientElement(expr, candidate.localName)) continue;
310
+ seen.add(key);
311
+ rendered.push(candidate);
312
+ }
313
+
314
+ return rendered;
315
+ }
316
+
317
+ function jsxExpressionContainsClientElement(expression: string, localName: string): boolean {
210
318
  const escaped = escapeRegExp(localName);
211
- return new RegExp(`^<${escaped}\\s*/>$`).test(expr);
319
+ return new RegExp(`<${escaped}(?:\\s|/|>)`).test(expression);
212
320
  }
213
321
 
214
322
  function stripWrappingParentheses(value: string): string {
@@ -287,6 +395,10 @@ function escapeRegExp(value: string): string {
287
395
  return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
288
396
  }
289
397
 
398
+ function isIdentifierChar(value: string): boolean {
399
+ return /[A-Za-z0-9_$]/.test(value);
400
+ }
401
+
290
402
  export async function validateClientModuleForBrowserBundle(
291
403
  route: RouteSpec,
292
404
  rootDir: string,
@@ -301,6 +413,9 @@ export async function validateClientModuleForBrowserBundle(
301
413
  }
302
414
 
303
415
  if (clientModuleIsRouteComponent(route) && !hasUseClientDirective(source)) {
416
+ if (await routeComponentHasResolvableClientEntry(rootDir, route.clientModule, source)) {
417
+ return null;
418
+ }
304
419
  return (
305
420
  `[${route.id}] Route component "${route.clientModule}" is configured as clientModule, ` +
306
421
  `but it is a server page (missing "use client"). Mandu will not bundle server pages into client islands. ` +
@@ -311,6 +426,20 @@ export async function validateClientModuleForBrowserBundle(
311
426
  return null;
312
427
  }
313
428
 
429
+ async function routeComponentHasResolvableClientEntry(
430
+ rootDir: string,
431
+ routeModule: string,
432
+ source: string,
433
+ ): Promise<boolean> {
434
+ const routeLevelClientImports = findRouteLevelClientComponentImports(source);
435
+ for (const routeLevelClientImport of routeLevelClientImports) {
436
+ if ((await resolveClientImportModulePath(rootDir, routeModule, routeLevelClientImport.module)) !== null) {
437
+ return true;
438
+ }
439
+ }
440
+ return false;
441
+ }
442
+
314
443
  export async function describeMissingHydrationClientModule(
315
444
  route: RouteSpec,
316
445
  rootDir: string,
@@ -350,8 +479,8 @@ export async function describeMissingHydrationClientModule(
350
479
 
351
480
  return (
352
481
  `${base}\n` +
353
- ` The page imports client-looking modules, but inline .client.tsx imports are not route bundles:\n` +
482
+ ` The page imports client-looking modules, but none was linked into the route manifest:\n` +
354
483
  `${importList}\n` +
355
- ` Fix: use partial({ component }).Render for embedded client regions, or expose a route-level client module.`
484
+ ` Fix: run mandu generate with the current source, or set an explicit route-level clientModule.`
356
485
  );
357
486
  }
@@ -31,7 +31,7 @@ import {
31
31
  import { mark, measure } from "../perf";
32
32
  import { METADATA_ROUTES } from "../routes/types";
33
33
  import {
34
- findRouteLevelClientComponentImport,
34
+ findRouteLevelClientComponentImports,
35
35
  hasUseClientDirective,
36
36
  resolveClientImportModulePath,
37
37
  } from "./client-entry";
@@ -399,13 +399,17 @@ export class FSScanner {
399
399
  if (hasUseClient) {
400
400
  clientModule = modulePath;
401
401
  } else {
402
- const routeLevelClientImport = findRouteLevelClientComponentImport(pageFileContent);
403
- if (routeLevelClientImport) {
404
- clientModule = await resolveClientImportModulePath(
402
+ const routeLevelClientImports = findRouteLevelClientComponentImports(pageFileContent);
403
+ for (const routeLevelClientImport of routeLevelClientImports) {
404
+ const resolvedClientImport = await resolveClientImportModulePath(
405
405
  rootDir,
406
406
  modulePath,
407
407
  routeLevelClientImport.module,
408
- ) ?? undefined;
408
+ );
409
+ if (resolvedClientImport) {
410
+ clientModule = modulePath;
411
+ break;
412
+ }
409
413
  }
410
414
  }
411
415
  }