@mandujs/core 0.54.10 → 0.54.12

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 (67) hide show
  1. package/package.json +1 -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 +17 -65
  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 +10 -18
  11. package/src/agent/verify.ts +17 -115
  12. package/src/brain/doctor/analyzer.ts +7 -7
  13. package/src/bundler/__tests__/build-runner.ts +81 -62
  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/analyzer.ts +15 -15
  17. package/src/bundler/build.test.ts +130 -83
  18. package/src/bundler/build.ts +570 -524
  19. package/src/bundler/css.ts +42 -42
  20. package/src/bundler/manifest-schema.ts +21 -21
  21. package/src/bundler/plugins/__tests__/block-generated-imports.test.ts +13 -13
  22. package/src/bundler/plugins/block-generated-imports.ts +13 -13
  23. package/src/bundler/types.ts +31 -31
  24. package/src/client/island.ts +79 -79
  25. package/src/config/validate.ts +1 -1
  26. package/src/deploy/inference/context.ts +82 -82
  27. package/src/devtools/client/components/panel/islands-panel.tsx +16 -16
  28. package/src/devtools/client/components/panel/panel-container.tsx +1 -1
  29. package/src/error/formatter.ts +22 -31
  30. package/src/filling/context.ts +17 -17
  31. package/src/generator/generate.ts +30 -30
  32. package/src/generator/index.ts +3 -3
  33. package/src/generator/templates.test.ts +66 -65
  34. package/src/generator/templates.ts +210 -210
  35. package/src/guard/check.ts +9 -9
  36. package/src/guard/config-guard.ts +13 -13
  37. package/src/guard/fs-routes-policy.ts +51 -51
  38. package/src/guard/index.ts +11 -11
  39. package/src/index.ts +3 -3
  40. package/src/kitchen/api/file-api.ts +11 -11
  41. package/src/report/index.ts +1 -1
  42. package/src/resource/__tests__/generator.test.ts +6 -6
  43. package/src/resource/__tests__/schema.test.ts +14 -14
  44. package/src/resource/ddl/__tests__/emit.test.ts +165 -165
  45. package/src/resource/ddl/emit.ts +146 -146
  46. package/src/resource/generator-schema.ts +11 -11
  47. package/src/resource/generators/slot.ts +72 -72
  48. package/src/resource/schema.ts +21 -21
  49. package/src/router/client-entry.test.ts +192 -140
  50. package/src/router/client-entry.ts +516 -486
  51. package/src/router/fs-routes.ts +16 -16
  52. package/src/router/fs-scanner.ts +16 -28
  53. package/src/runtime/__tests__/devtools-adapter.test.ts +68 -68
  54. package/src/runtime/__tests__/observability-lifecycle.test.ts +103 -103
  55. package/src/runtime/__tests__/page-render-response.test.ts +103 -103
  56. package/src/runtime/__tests__/request-middleware.test.ts +70 -70
  57. package/src/runtime/devtools-adapter.ts +68 -68
  58. package/src/runtime/escape.ts +34 -34
  59. package/src/runtime/observability-lifecycle.ts +290 -290
  60. package/src/runtime/page-render-response.ts +106 -106
  61. package/src/runtime/request-middleware.ts +31 -31
  62. package/src/runtime/server.ts +243 -243
  63. package/src/runtime/ssr.ts +59 -59
  64. package/src/runtime/static-files.ts +289 -289
  65. package/src/runtime/streaming-ssr.ts +22 -22
  66. package/src/watcher/__tests__/watcher.test.ts +59 -59
  67. package/src/watcher/watcher.ts +61 -61
@@ -78,8 +78,8 @@ export async function buildDeployInferenceContext(
78
78
  // still gets the manifest metadata and falls back to defaults.
79
79
  }
80
80
 
81
- const imports = extractImports(source);
82
- const dependencyClasses = classifySourceDependencies(source, imports);
81
+ const imports = extractImports(source);
82
+ const dependencyClasses = classifySourceDependencies(source, imports);
83
83
  // Mandu's manifest patterns use `:param` / `*` (path-pattern style),
84
84
  // not bracket-form. Detect both shapes so the heuristic doesn't
85
85
  // misclassify dynamic routes as prerenderable. Examples:
@@ -139,82 +139,82 @@ export function extractImports(source: string): string[] {
139
139
  }
140
140
 
141
141
  /** Map import specifiers to coarse dependency classes. */
142
- export function classifyImports(imports: string[]): ReadonlySet<DependencyClass> {
143
- const classes = new Set<DependencyClass>();
144
- for (const spec of imports) {
142
+ export function classifyImports(imports: string[]): ReadonlySet<DependencyClass> {
143
+ const classes = new Set<DependencyClass>();
144
+ for (const spec of imports) {
145
145
  const cls = classifyOne(spec);
146
146
  if (cls) classes.add(cls);
147
147
  }
148
148
  if (classes.size === 0) classes.add("fetch-only");
149
- return classes;
150
- }
151
-
152
- /**
153
- * Classify dependency signals that do not always appear as bare imports.
154
- *
155
- * Dogfooding surfaced Mandu API routes importing project-local
156
- * `src/server/infra/db` helpers and using `db` tagged templates. Those
157
- * are server-only even when the bare imports look edge-safe.
158
- */
159
- export function classifySourceDependencies(
160
- source: string,
161
- imports: string[] = extractImports(source),
162
- ): ReadonlySet<DependencyClass> {
163
- const classes = new Set<DependencyClass>(classifyImports(imports));
164
- if (classes.size === 1 && classes.has("fetch-only")) {
165
- classes.delete("fetch-only");
166
- }
167
-
168
- const allImports = extractAllImportSpecifiers(source);
169
- if (allImports.some(isServerInfraImport)) {
170
- classes.add("db");
171
- }
172
-
173
- if (/\bBun\.SQL\b|\bBun\.sqlite\b/i.test(source)) {
174
- classes.add("bun-native");
175
- }
176
-
177
- if (
178
- /(?:^|[^\w$])db\s*`/.test(source) ||
179
- /\bctx\.deps\.db\b/.test(source) ||
180
- /\bdb\.(?:query|execute|select|insert|update|delete)\b/.test(source)
181
- ) {
182
- classes.add("db");
183
- }
184
-
185
- if (classes.size === 0) {
186
- classes.add("fetch-only");
187
- }
188
- return classes;
189
- }
190
-
191
- function extractAllImportSpecifiers(source: string): string[] {
192
- const out = new Set<string>();
193
- const staticImport = /^\s*import\b[^"']*?["']([^"']+)["']/gm;
194
- const dynamicImport = /\bimport\(\s*["']([^"']+)["']\s*\)/g;
195
- for (const re of [staticImport, dynamicImport]) {
196
- let m: RegExpExecArray | null;
197
- while ((m = re.exec(source)) !== null) {
198
- out.add(m[1]!);
199
- }
200
- }
201
- return [...out].sort();
202
- }
203
-
204
- function isServerInfraImport(specifier: string): boolean {
205
- const s = specifier.replace(/\\/g, "/").toLowerCase();
206
- return (
207
- s === "@/server/infra" ||
208
- s.startsWith("@/server/infra/") ||
209
- s === "src/server/infra" ||
210
- s.startsWith("src/server/infra/") ||
211
- s.endsWith("/server/infra") ||
212
- s.includes("/server/infra/")
213
- );
214
- }
215
-
216
- function classifyOne(spec: string): DependencyClass | null {
217
- const s = spec.toLowerCase();
149
+ return classes;
150
+ }
151
+
152
+ /**
153
+ * Classify dependency signals that do not always appear as bare imports.
154
+ *
155
+ * Dogfooding surfaced Mandu API routes importing project-local
156
+ * `src/server/infra/db` helpers and using `db` tagged templates. Those
157
+ * are server-only even when the bare imports look edge-safe.
158
+ */
159
+ export function classifySourceDependencies(
160
+ source: string,
161
+ imports: string[] = extractImports(source),
162
+ ): ReadonlySet<DependencyClass> {
163
+ const classes = new Set<DependencyClass>(classifyImports(imports));
164
+ if (classes.size === 1 && classes.has("fetch-only")) {
165
+ classes.delete("fetch-only");
166
+ }
167
+
168
+ const allImports = extractAllImportSpecifiers(source);
169
+ if (allImports.some(isServerInfraImport)) {
170
+ classes.add("db");
171
+ }
172
+
173
+ if (/\bBun\.SQL\b|\bBun\.sqlite\b/i.test(source)) {
174
+ classes.add("bun-native");
175
+ }
176
+
177
+ if (
178
+ /(?:^|[^\w$])db\s*`/.test(source) ||
179
+ /\bctx\.deps\.db\b/.test(source) ||
180
+ /\bdb\.(?:query|execute|select|insert|update|delete)\b/.test(source)
181
+ ) {
182
+ classes.add("db");
183
+ }
184
+
185
+ if (classes.size === 0) {
186
+ classes.add("fetch-only");
187
+ }
188
+ return classes;
189
+ }
190
+
191
+ function extractAllImportSpecifiers(source: string): string[] {
192
+ const out = new Set<string>();
193
+ const staticImport = /^\s*import\b[^"']*?["']([^"']+)["']/gm;
194
+ const dynamicImport = /\bimport\(\s*["']([^"']+)["']\s*\)/g;
195
+ for (const re of [staticImport, dynamicImport]) {
196
+ let m: RegExpExecArray | null;
197
+ while ((m = re.exec(source)) !== null) {
198
+ out.add(m[1]!);
199
+ }
200
+ }
201
+ return [...out].sort();
202
+ }
203
+
204
+ function isServerInfraImport(specifier: string): boolean {
205
+ const s = specifier.replace(/\\/g, "/").toLowerCase();
206
+ return (
207
+ s === "@/server/infra" ||
208
+ s.startsWith("@/server/infra/") ||
209
+ s === "src/server/infra" ||
210
+ s.startsWith("src/server/infra/") ||
211
+ s.endsWith("/server/infra") ||
212
+ s.includes("/server/infra/")
213
+ );
214
+ }
215
+
216
+ function classifyOne(spec: string): DependencyClass | null {
217
+ const s = spec.toLowerCase();
218
218
  if (
219
219
  s === "bun:sqlite" ||
220
220
  s === "bun:ffi" ||
@@ -231,14 +231,14 @@ function classifyOne(spec: string): DependencyClass | null {
231
231
  if (s === "node:child_process" || s === "child_process" || s === "node:worker_threads" || s === "worker_threads") {
232
232
  return "node-child";
233
233
  }
234
- if (
235
- /^(postgres|pg|mysql2?|drizzle-orm(\/.*)?|@prisma\/client|prisma|mongodb|mongoose|@neondatabase\/.+|kysely|sqlite3|better-sqlite3|@planetscale\/.+)$/.test(s)
236
- ) {
237
- return "db";
238
- }
239
- if (s === "@mandujs/core/db" || s.startsWith("@mandujs/core/db/")) {
240
- return "db";
241
- }
234
+ if (
235
+ /^(postgres|pg|mysql2?|drizzle-orm(\/.*)?|@prisma\/client|prisma|mongodb|mongoose|@neondatabase\/.+|kysely|sqlite3|better-sqlite3|@planetscale\/.+)$/.test(s)
236
+ ) {
237
+ return "db";
238
+ }
239
+ if (s === "@mandujs/core/db" || s.startsWith("@mandujs/core/db/")) {
240
+ return "db";
241
+ }
242
242
  if (/^(@anthropic-ai\/sdk|openai|ai|@ai-sdk\/.+|@google\/generative-ai|cohere-ai)$/.test(s)) {
243
243
  return "ai-sdk";
244
244
  }
@@ -183,28 +183,28 @@ export function IslandsPanel({ islands }: IslandsPanelProps): React.ReactElement
183
183
  );
184
184
  }, [islands]);
185
185
 
186
- if (islands.length === 0) {
187
- return (
188
- <div style={styles.container}>
189
- <div style={styles.emptyState}>
190
- <p>
191
- 아직 hydration 이벤트가 없습니다.<br />
192
- 정적 상태는 mandu.runtime.status에서 확인하세요.
193
- </p>
194
- </div>
195
- </div>
196
- );
197
- }
186
+ if (islands.length === 0) {
187
+ return (
188
+ <div style={styles.container}>
189
+ <div style={styles.emptyState}>
190
+ <p>
191
+ 아직 hydration 이벤트가 없습니다.<br />
192
+ 정적 상태는 mandu.runtime.status에서 확인하세요.
193
+ </p>
194
+ </div>
195
+ </div>
196
+ );
197
+ }
198
198
 
199
199
  return (
200
200
  <div style={styles.container}>
201
201
  {/* Header Stats */}
202
202
  <div style={styles.header}>
203
203
  <div style={styles.stats}>
204
- <div style={styles.stat}>
205
- <span>Mounts:</span>
206
- <span style={styles.statValue}>{stats.total}</span>
207
- </div>
204
+ <div style={styles.stat}>
205
+ <span>Mounts:</span>
206
+ <span style={styles.statValue}>{stats.total}</span>
207
+ </div>
208
208
  <div style={styles.stat}>
209
209
  <span>Ready</span>
210
210
  <span style={styles.statValue}>{stats.hydrated}</span>
@@ -28,7 +28,7 @@ export interface PanelContainerProps {
28
28
 
29
29
  export const TABS: TabDefinition[] = [
30
30
  { id: 'errors', label: 'Issues', icon: 'ERR', testId: testIds.tabErrors },
31
- { id: 'islands', label: 'Client Mounts', icon: 'HYD', testId: testIds.tabIslands },
31
+ { id: 'islands', label: 'Client Mounts', icon: 'HYD', testId: testIds.tabIslands },
32
32
  { id: 'network', label: 'Network', icon: 'NET', testId: testIds.tabNetwork },
33
33
  { id: 'guard', label: 'Guard', icon: 'GRD', testId: testIds.tabGuard },
34
34
  { id: 'preview', label: 'Changes', icon: 'CHG', testId: testIds.tabPreview },
@@ -21,20 +21,15 @@ 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
- 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
- }
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
+ }
38
33
 
39
34
  // 개발 모드에서만 디버그 정보 포함
40
35
  if (isDev && error.debug) {
@@ -65,31 +60,27 @@ export function formatErrorForConsole(error: ManduError, options: FormatOptions
65
60
  lines.push(` ${error.message}`);
66
61
 
67
62
  // 요약
68
- if (useColors) {
69
- lines.push(` ${CYAN}→ ${error.summary}${RESET}`);
70
- } else {
71
- lines.push(` → ${error.summary}`);
72
- }
73
- lines.push(` Cause: ${error.summary}`);
63
+ if (useColors) {
64
+ lines.push(` ${CYAN}→ ${error.summary}${RESET}`);
65
+ } else {
66
+ lines.push(` → ${error.summary}`);
67
+ }
74
68
 
75
69
  // 수정 안내
76
70
  lines.push("");
77
71
  if (useColors) {
78
72
  lines.push(` ${YELLOW}Fix:${RESET} ${error.fix.file}${error.fix.line ? `:${error.fix.line}` : ""}`);
79
73
  lines.push(` ${error.fix.suggestion}`);
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}`);
74
+ } else {
75
+ lines.push(` Fix: ${error.fix.file}${error.fix.line ? `:${error.fix.line}` : ""}`);
76
+ lines.push(` ${error.fix.suggestion}`);
77
+ }
86
78
 
87
79
  // 라우트 컨텍스트
88
- if (error.route) {
89
- lines.push("");
90
- lines.push(` Route ID: ${error.route.id}`);
91
- lines.push(` Route: ${error.route.pattern}`);
92
- }
80
+ if (error.route) {
81
+ lines.push("");
82
+ lines.push(` Route: ${error.route.id} (${error.route.pattern})`);
83
+ }
93
84
 
94
85
  // 디버그 정보 (개발 모드)
95
86
  if (isDev && includeStack && error.debug?.stack) {
@@ -628,23 +628,23 @@ export class ManduContext {
628
628
  return this.withCookies(new Response(null, { status: 204 }));
629
629
  }
630
630
 
631
- /** 400 Bad Request, or custom 4xx/5xx error with ctx.error(status, message). */
632
- error(message: string, details?: unknown): Response;
633
- error(status: number, message: string, details?: unknown): Response;
634
- error(
635
- statusOrMessage: number | string,
636
- messageOrDetails?: string | unknown,
637
- maybeDetails?: unknown
638
- ): Response {
639
- if (typeof statusOrMessage === "number") {
640
- const status = Number.isInteger(statusOrMessage) && statusOrMessage >= 400 && statusOrMessage <= 599
641
- ? statusOrMessage
642
- : 400;
643
- const message = typeof messageOrDetails === "string" ? messageOrDetails : "Error";
644
- return this.json({ status: "error", message, details: maybeDetails }, status);
645
- }
646
- return this.json({ status: "error", message: statusOrMessage, details: messageOrDetails }, 400);
647
- }
631
+ /** 400 Bad Request, or custom 4xx/5xx error with ctx.error(status, message). */
632
+ error(message: string, details?: unknown): Response;
633
+ error(status: number, message: string, details?: unknown): Response;
634
+ error(
635
+ statusOrMessage: number | string,
636
+ messageOrDetails?: string | unknown,
637
+ maybeDetails?: unknown
638
+ ): Response {
639
+ if (typeof statusOrMessage === "number") {
640
+ const status = Number.isInteger(statusOrMessage) && statusOrMessage >= 400 && statusOrMessage <= 599
641
+ ? statusOrMessage
642
+ : 400;
643
+ const message = typeof messageOrDetails === "string" ? messageOrDetails : "Error";
644
+ return this.json({ status: "error", message, details: maybeDetails }, status);
645
+ }
646
+ return this.json({ status: "error", message: statusOrMessage, details: messageOrDetails }, 400);
647
+ }
648
648
 
649
649
  /** 401 Unauthorized */
650
650
  unauthorized(message: string = "Unauthorized"): Response {
@@ -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,65 +1,66 @@
1
- import { describe, expect, test } from "bun:test";
2
- import type { RouteSpec } from "../spec/schema";
3
- import { generatePageComponent } from "./templates";
4
-
5
- describe("generatePageComponent", () => {
6
- test("refuses to emit a placeholder for hydrating routes without a clientModule", () => {
7
- const route: RouteSpec = {
8
- id: "login",
9
- kind: "page",
10
- pattern: "/login",
11
- module: "app/login/page.tsx",
12
- componentModule: "app/login/page.tsx",
13
- hydration: {
14
- strategy: "full",
15
- priority: "immediate",
16
- preload: false,
17
- },
18
- };
19
-
20
- expect(() => generatePageComponent(route)).toThrow("no clientModule");
21
- });
22
-
23
- test("imports the real page module for static routes instead of emitting a placeholder", () => {
24
- const route: RouteSpec = {
25
- id: "about",
26
- kind: "page",
27
- pattern: "/about",
28
- module: "app/about/page.tsx",
29
- componentModule: "app/about/page.tsx",
30
- };
31
-
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");
64
- });
65
- });
1
+ import { describe, expect, test } from "bun:test";
2
+ import type { RouteSpec } from "../spec/schema";
3
+ import { generatePageComponent } from "./templates";
4
+
5
+ describe("generatePageComponent", () => {
6
+ test("refuses to emit a placeholder for hydrating routes without a clientModule", () => {
7
+ const route: RouteSpec = {
8
+ id: "login",
9
+ kind: "page",
10
+ pattern: "/login",
11
+ module: "app/login/page.tsx",
12
+ componentModule: "app/login/page.tsx",
13
+ hydration: {
14
+ strategy: "full",
15
+ priority: "immediate",
16
+ preload: false,
17
+ },
18
+ };
19
+
20
+ expect(() => generatePageComponent(route)).toThrow("no clientModule");
21
+ });
22
+
23
+ test("imports the real page module for static routes instead of emitting a placeholder", () => {
24
+ const route: RouteSpec = {
25
+ id: "about",
26
+ kind: "page",
27
+ pattern: "/about",
28
+ module: "app/about/page.tsx",
29
+ componentModule: "app/about/page.tsx",
30
+ };
31
+
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: "src/client/pages/login/LoginPage.client.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: src/client/pages/login/LoginPage.client.tsx");
59
+ expect(generated).toContain("Page Module: app/login/page.tsx");
60
+ expect(generated).toContain('import islandModule from "../../../../src/client/pages/login/LoginPage.client.tsx"');
61
+ expect(generated).toContain('import pageModule from "../../../../app/login/page.tsx"');
62
+ expect(generated).toContain("islandModule.definition.render");
63
+ expect(generated).toContain("React.createElement(pageModule");
64
+ expect(generated).not.toContain("Login Page");
65
+ });
66
+ });