@mandujs/core 0.54.7 → 0.54.9

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,200 +1,200 @@
1
- {
2
- "name": "@mandujs/core",
3
- "version": "0.54.7",
4
- "description": "Mandu Framework Core - Spec, Generator, Guard, Runtime",
5
- "type": "module",
6
- "main": "./src/index.ts",
7
- "types": "./src/index.ts",
8
- "exports": {
9
- ".": "./src/index.ts",
10
- "./a11y": "./src/a11y/index.ts",
11
- "./agent": "./src/agent/index.ts",
12
- "./auth": "./src/auth/index.ts",
13
- "./auth/login": "./src/auth/login.ts",
14
- "./auth/password": "./src/auth/password.ts",
15
- "./auth/reset": "./src/auth/reset.ts",
16
- "./auth/verification": "./src/auth/verification.ts",
17
- "./client": "./src/client/index.ts",
18
- "./client/rpc": "./src/client/rpc.ts",
19
- "./contract": "./src/contract/index.ts",
20
- "./contract/rpc": "./src/contract/rpc.ts",
21
- "./content": "./src/content/index.ts",
22
- "./content/prebuild": "./src/content/prebuild.ts",
23
- "./content/collection": "./src/content/collection.ts",
24
- "./content/sidebar": "./src/content/sidebar.ts",
25
- "./content/slug": "./src/content/slug.ts",
26
- "./content/llms-txt": "./src/content/llms-txt.ts",
27
- "./content/schema": "./src/content/schema.ts",
28
- "./config": "./src/config/index.ts",
29
- "./config/mandu": "./src/config/mandu.ts",
30
- "./config/validate": "./src/config/validate.ts",
31
- "./db": "./src/db/index.ts",
32
- "./db/migrations": "./src/db/migrations/index.ts",
33
- "./db/migrations/history-table": "./src/db/migrations/history-table.ts",
34
- "./db/migrations/runner": "./src/db/migrations/runner.ts",
35
- "./deploy": "./src/deploy/index.ts",
36
- "./design": "./src/design/index.ts",
37
- "./desktop": "./src/desktop/index.ts",
38
- "./desktop/worker": "./src/desktop/worker.ts",
39
- "./email": "./src/email/index.ts",
40
- "./filling": "./src/filling/index.ts",
41
- "./filling/cookie-codec": "./src/filling/cookie-codec.ts",
42
- "./filling/session-sqlite": "./src/filling/session-sqlite.ts",
43
- "./kitchen": "./src/kitchen/index.ts",
44
- "./logging": "./src/logging/index.ts",
45
- "./middleware": "./src/middleware/index.ts",
46
- "./middleware/csrf": "./src/middleware/csrf.ts",
47
- "./middleware/oauth": "./src/middleware/oauth/index.ts",
48
- "./middleware/secure": "./src/middleware/secure/index.ts",
49
- "./middleware/rate-limit": "./src/middleware/rate-limit/index.ts",
50
- "./middleware/session": "./src/middleware/session.ts",
51
- "./testing": "./src/testing/index.ts",
52
- "./testing/db": "./src/testing/db.ts",
53
- "./testing/mocks": "./src/testing/mocks.ts",
54
- "./testing/reporter": "./src/testing/reporter.ts",
55
- "./testing/server": "./src/testing/server.ts",
56
- "./testing/session": "./src/testing/session.ts",
57
- "./testing/snapshot": "./src/testing/snapshot.ts",
58
- "./plugins": "./src/plugins/index.ts",
59
- "./plugins/define": "./src/plugins/define.ts",
60
- "./plugins/runner": "./src/plugins/runner.ts",
61
- "./error": "./src/error/index.ts",
62
- "./i18n": "./src/i18n/index.ts",
63
- "./id": "./src/id/index.ts",
64
- "./observability": "./src/observability/index.ts",
65
- "./openapi/generator": "./src/openapi/generator.ts",
66
- "./perf": "./src/perf/index.ts",
67
- "./perf/hmr-markers": "./src/perf/hmr-markers.ts",
68
- "./perf/user-marks": "./src/perf/user-marks.ts",
69
- "./resource": "./src/resource/index.ts",
70
- "./resource/ddl/diff": "./src/resource/ddl/diff.ts",
71
- "./resource/ddl/emit": "./src/resource/ddl/emit.ts",
72
- "./resource/ddl/snapshot": "./src/resource/ddl/snapshot.ts",
73
- "./resource/ddl/type-map": "./src/resource/ddl/type-map.ts",
74
- "./resource/ddl/types": "./src/resource/ddl/types.ts",
75
- "./resource/generator-repo": "./src/resource/generator-repo.ts",
76
- "./resource/parser": "./src/resource/parser.ts",
77
- "./resource/schema": "./src/resource/schema.ts",
78
- "./router": "./src/router/index.ts",
79
- "./routes": "./src/routes/index.ts",
80
- "./runtime": "./src/runtime/index.ts",
81
- "./runtime/cache": "./src/runtime/cache.ts",
82
- "./runtime/router": "./src/runtime/router.ts",
83
- "./runtime/server": "./src/runtime/server.ts",
84
- "./scheduler": "./src/scheduler/index.ts",
85
- "./storage/s3": "./src/storage/s3/index.ts",
86
- "./guard": "./src/guard/index.ts",
87
- "./guard/define-rule": "./src/guard/define-rule.ts",
88
- "./guard/design-inline-class": "./src/guard/design-inline-class.ts",
89
- "./guard/rule-presets": "./src/guard/rule-presets.ts",
90
- "./guard/tsgolint-bridge": "./src/guard/tsgolint-bridge.ts",
91
- "./bundler": "./src/bundler/index.ts",
92
- "./bundler/prerender": "./src/bundler/prerender.ts",
93
- "./bundler/safe-build": "./src/bundler/safe-build.ts",
94
- "./bundler/types": "./src/bundler/types.ts",
95
- "./bundler/hmr-types": "./src/bundler/hmr-types.ts",
96
- "./bundler/scenario-matrix": "./src/bundler/scenario-matrix.ts",
97
- "./bundler/vendor-cache-types": "./src/bundler/vendor-cache-types.ts",
98
- "./bundler/manifest-schema": "./src/bundler/manifest-schema.ts",
99
- "./bundler/analyzer": "./src/bundler/analyzer.ts",
100
- "./bundler/budget": "./src/bundler/budget.ts",
101
- "./bundler/plugins": "./src/bundler/plugins/index.ts",
102
- "./bundler/plugins/block-generated-imports": "./src/bundler/plugins/block-generated-imports.ts",
103
- "./bundler/generate-static-params": "./src/bundler/generate-static-params.ts",
104
- "./diagnose": "./src/diagnose/index.ts",
105
- "./dev-error-overlay": "./src/dev-error-overlay/index.ts",
106
- "./middleware/compose": "./src/middleware/compose.ts",
107
- "./middleware/define": "./src/middleware/define.ts",
108
- "./middleware/bridge": "./src/middleware/bridge.ts",
109
- "./client/hydrate": "./src/client/hydrate.ts",
110
- "./runtime/fast-refresh-types": "./src/runtime/fast-refresh-types.ts",
111
- "./components/Image": "./src/components/Image.tsx"
112
- },
113
- "files": [
114
- "src/**/*",
115
- "scripts/postinstall-lock.ts"
116
- ],
117
- "scripts": {
118
- "postinstall": "bun ./scripts/postinstall-lock.ts",
119
- "test": "bun test tests/streaming-ssr && bun test tests/hydration tests/typing src",
120
- "test:hydration": "bun test tests/hydration",
121
- "test:streaming": "bun test tests/streaming-ssr",
122
- "test:watch": "bun test --watch"
123
- },
124
- "devDependencies": {
125
- "@happy-dom/global-registrator": "^15.0.0"
126
- },
127
- "keywords": [
128
- "mandu",
129
- "framework",
130
- "agent",
131
- "ai",
132
- "code-generation"
133
- ],
134
- "repository": {
135
- "type": "git",
136
- "url": "git+https://github.com/konamgil/mandu.git",
137
- "directory": "packages/core"
138
- },
139
- "author": "konamgil",
140
- "license": "MPL-2.0",
141
- "publishConfig": {
142
- "access": "public"
143
- },
144
- "engines": {
145
- "bun": ">=1.3.12"
146
- },
147
- "peerDependencies": {
148
- "react": "^19.2.0",
149
- "react-dom": "^19.2.0",
150
- "react-refresh": ">=0.18.0",
151
- "@tailwindcss/cli": ">=4.0.0",
152
- "webview-bun": "^2.4.0",
153
- "axe-core": ">=4.8.0",
154
- "jsdom": ">=24.0.0",
155
- "happy-dom": ">=15.0.0",
156
- "@babel/core": "^7.29.0",
157
- "babel-plugin-react-compiler": "^1.0.0",
158
- "eslint": "^9.0.0",
159
- "eslint-plugin-react-compiler": "^19.0.0 || ^1.0.0"
160
- },
161
- "peerDependenciesMeta": {
162
- "@tailwindcss/cli": {
163
- "optional": true
164
- },
165
- "react-refresh": {
166
- "optional": true
167
- },
168
- "webview-bun": {
169
- "optional": true
170
- },
171
- "axe-core": {
172
- "optional": true
173
- },
174
- "jsdom": {
175
- "optional": true
176
- },
177
- "happy-dom": {
178
- "optional": true
179
- },
180
- "@babel/core": {
181
- "optional": true
182
- },
183
- "babel-plugin-react-compiler": {
184
- "optional": true
185
- },
186
- "eslint": {
187
- "optional": true
188
- },
189
- "eslint-plugin-react-compiler": {
190
- "optional": true
191
- }
192
- },
193
- "dependencies": {
194
- "chokidar": "^5.0.0",
195
- "fast-glob": "^3.3.2",
196
- "glob": "^13.0.0",
197
- "minimatch": "^10.1.1",
198
- "zod": "^3.23.8"
199
- }
200
- }
1
+ {
2
+ "name": "@mandujs/core",
3
+ "version": "0.54.9",
4
+ "description": "Mandu Framework Core - Spec, Generator, Guard, Runtime",
5
+ "type": "module",
6
+ "main": "./src/index.ts",
7
+ "types": "./src/index.ts",
8
+ "exports": {
9
+ ".": "./src/index.ts",
10
+ "./a11y": "./src/a11y/index.ts",
11
+ "./agent": "./src/agent/index.ts",
12
+ "./auth": "./src/auth/index.ts",
13
+ "./auth/login": "./src/auth/login.ts",
14
+ "./auth/password": "./src/auth/password.ts",
15
+ "./auth/reset": "./src/auth/reset.ts",
16
+ "./auth/verification": "./src/auth/verification.ts",
17
+ "./client": "./src/client/index.ts",
18
+ "./client/rpc": "./src/client/rpc.ts",
19
+ "./contract": "./src/contract/index.ts",
20
+ "./contract/rpc": "./src/contract/rpc.ts",
21
+ "./content": "./src/content/index.ts",
22
+ "./content/prebuild": "./src/content/prebuild.ts",
23
+ "./content/collection": "./src/content/collection.ts",
24
+ "./content/sidebar": "./src/content/sidebar.ts",
25
+ "./content/slug": "./src/content/slug.ts",
26
+ "./content/llms-txt": "./src/content/llms-txt.ts",
27
+ "./content/schema": "./src/content/schema.ts",
28
+ "./config": "./src/config/index.ts",
29
+ "./config/mandu": "./src/config/mandu.ts",
30
+ "./config/validate": "./src/config/validate.ts",
31
+ "./db": "./src/db/index.ts",
32
+ "./db/migrations": "./src/db/migrations/index.ts",
33
+ "./db/migrations/history-table": "./src/db/migrations/history-table.ts",
34
+ "./db/migrations/runner": "./src/db/migrations/runner.ts",
35
+ "./deploy": "./src/deploy/index.ts",
36
+ "./design": "./src/design/index.ts",
37
+ "./desktop": "./src/desktop/index.ts",
38
+ "./desktop/worker": "./src/desktop/worker.ts",
39
+ "./email": "./src/email/index.ts",
40
+ "./filling": "./src/filling/index.ts",
41
+ "./filling/cookie-codec": "./src/filling/cookie-codec.ts",
42
+ "./filling/session-sqlite": "./src/filling/session-sqlite.ts",
43
+ "./kitchen": "./src/kitchen/index.ts",
44
+ "./logging": "./src/logging/index.ts",
45
+ "./middleware": "./src/middleware/index.ts",
46
+ "./middleware/csrf": "./src/middleware/csrf.ts",
47
+ "./middleware/oauth": "./src/middleware/oauth/index.ts",
48
+ "./middleware/secure": "./src/middleware/secure/index.ts",
49
+ "./middleware/rate-limit": "./src/middleware/rate-limit/index.ts",
50
+ "./middleware/session": "./src/middleware/session.ts",
51
+ "./testing": "./src/testing/index.ts",
52
+ "./testing/db": "./src/testing/db.ts",
53
+ "./testing/mocks": "./src/testing/mocks.ts",
54
+ "./testing/reporter": "./src/testing/reporter.ts",
55
+ "./testing/server": "./src/testing/server.ts",
56
+ "./testing/session": "./src/testing/session.ts",
57
+ "./testing/snapshot": "./src/testing/snapshot.ts",
58
+ "./plugins": "./src/plugins/index.ts",
59
+ "./plugins/define": "./src/plugins/define.ts",
60
+ "./plugins/runner": "./src/plugins/runner.ts",
61
+ "./error": "./src/error/index.ts",
62
+ "./i18n": "./src/i18n/index.ts",
63
+ "./id": "./src/id/index.ts",
64
+ "./observability": "./src/observability/index.ts",
65
+ "./openapi/generator": "./src/openapi/generator.ts",
66
+ "./perf": "./src/perf/index.ts",
67
+ "./perf/hmr-markers": "./src/perf/hmr-markers.ts",
68
+ "./perf/user-marks": "./src/perf/user-marks.ts",
69
+ "./resource": "./src/resource/index.ts",
70
+ "./resource/ddl/diff": "./src/resource/ddl/diff.ts",
71
+ "./resource/ddl/emit": "./src/resource/ddl/emit.ts",
72
+ "./resource/ddl/snapshot": "./src/resource/ddl/snapshot.ts",
73
+ "./resource/ddl/type-map": "./src/resource/ddl/type-map.ts",
74
+ "./resource/ddl/types": "./src/resource/ddl/types.ts",
75
+ "./resource/generator-repo": "./src/resource/generator-repo.ts",
76
+ "./resource/parser": "./src/resource/parser.ts",
77
+ "./resource/schema": "./src/resource/schema.ts",
78
+ "./router": "./src/router/index.ts",
79
+ "./routes": "./src/routes/index.ts",
80
+ "./runtime": "./src/runtime/index.ts",
81
+ "./runtime/cache": "./src/runtime/cache.ts",
82
+ "./runtime/router": "./src/runtime/router.ts",
83
+ "./runtime/server": "./src/runtime/server.ts",
84
+ "./scheduler": "./src/scheduler/index.ts",
85
+ "./storage/s3": "./src/storage/s3/index.ts",
86
+ "./guard": "./src/guard/index.ts",
87
+ "./guard/define-rule": "./src/guard/define-rule.ts",
88
+ "./guard/design-inline-class": "./src/guard/design-inline-class.ts",
89
+ "./guard/rule-presets": "./src/guard/rule-presets.ts",
90
+ "./guard/tsgolint-bridge": "./src/guard/tsgolint-bridge.ts",
91
+ "./bundler": "./src/bundler/index.ts",
92
+ "./bundler/prerender": "./src/bundler/prerender.ts",
93
+ "./bundler/safe-build": "./src/bundler/safe-build.ts",
94
+ "./bundler/types": "./src/bundler/types.ts",
95
+ "./bundler/hmr-types": "./src/bundler/hmr-types.ts",
96
+ "./bundler/scenario-matrix": "./src/bundler/scenario-matrix.ts",
97
+ "./bundler/vendor-cache-types": "./src/bundler/vendor-cache-types.ts",
98
+ "./bundler/manifest-schema": "./src/bundler/manifest-schema.ts",
99
+ "./bundler/analyzer": "./src/bundler/analyzer.ts",
100
+ "./bundler/budget": "./src/bundler/budget.ts",
101
+ "./bundler/plugins": "./src/bundler/plugins/index.ts",
102
+ "./bundler/plugins/block-generated-imports": "./src/bundler/plugins/block-generated-imports.ts",
103
+ "./bundler/generate-static-params": "./src/bundler/generate-static-params.ts",
104
+ "./diagnose": "./src/diagnose/index.ts",
105
+ "./dev-error-overlay": "./src/dev-error-overlay/index.ts",
106
+ "./middleware/compose": "./src/middleware/compose.ts",
107
+ "./middleware/define": "./src/middleware/define.ts",
108
+ "./middleware/bridge": "./src/middleware/bridge.ts",
109
+ "./client/hydrate": "./src/client/hydrate.ts",
110
+ "./runtime/fast-refresh-types": "./src/runtime/fast-refresh-types.ts",
111
+ "./components/Image": "./src/components/Image.tsx"
112
+ },
113
+ "files": [
114
+ "src/**/*",
115
+ "scripts/postinstall-lock.ts"
116
+ ],
117
+ "scripts": {
118
+ "postinstall": "bun ./scripts/postinstall-lock.ts",
119
+ "test": "bun test tests/streaming-ssr && bun test tests/hydration tests/typing src",
120
+ "test:hydration": "bun test tests/hydration",
121
+ "test:streaming": "bun test tests/streaming-ssr",
122
+ "test:watch": "bun test --watch"
123
+ },
124
+ "devDependencies": {
125
+ "@happy-dom/global-registrator": "^15.0.0"
126
+ },
127
+ "keywords": [
128
+ "mandu",
129
+ "framework",
130
+ "agent",
131
+ "ai",
132
+ "code-generation"
133
+ ],
134
+ "repository": {
135
+ "type": "git",
136
+ "url": "git+https://github.com/konamgil/mandu.git",
137
+ "directory": "packages/core"
138
+ },
139
+ "author": "konamgil",
140
+ "license": "MPL-2.0",
141
+ "publishConfig": {
142
+ "access": "public"
143
+ },
144
+ "engines": {
145
+ "bun": ">=1.3.12"
146
+ },
147
+ "peerDependencies": {
148
+ "react": "^19.2.0",
149
+ "react-dom": "^19.2.0",
150
+ "react-refresh": ">=0.18.0",
151
+ "@tailwindcss/cli": ">=4.0.0",
152
+ "webview-bun": "^2.4.0",
153
+ "axe-core": ">=4.8.0",
154
+ "jsdom": ">=24.0.0",
155
+ "happy-dom": ">=15.0.0",
156
+ "@babel/core": "^7.29.0",
157
+ "babel-plugin-react-compiler": "^1.0.0",
158
+ "eslint": "^9.0.0",
159
+ "eslint-plugin-react-compiler": "^19.0.0 || ^1.0.0"
160
+ },
161
+ "peerDependenciesMeta": {
162
+ "@tailwindcss/cli": {
163
+ "optional": true
164
+ },
165
+ "react-refresh": {
166
+ "optional": true
167
+ },
168
+ "webview-bun": {
169
+ "optional": true
170
+ },
171
+ "axe-core": {
172
+ "optional": true
173
+ },
174
+ "jsdom": {
175
+ "optional": true
176
+ },
177
+ "happy-dom": {
178
+ "optional": true
179
+ },
180
+ "@babel/core": {
181
+ "optional": true
182
+ },
183
+ "babel-plugin-react-compiler": {
184
+ "optional": true
185
+ },
186
+ "eslint": {
187
+ "optional": true
188
+ },
189
+ "eslint-plugin-react-compiler": {
190
+ "optional": true
191
+ }
192
+ },
193
+ "dependencies": {
194
+ "chokidar": "^5.0.0",
195
+ "fast-glob": "^3.3.2",
196
+ "glob": "^13.0.0",
197
+ "minimatch": "^10.1.1",
198
+ "zod": "^3.23.8"
199
+ }
200
+ }
@@ -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
- 아직 등록된 Island가 없습니다.<br />
192
- Island 컴포넌트를 사용하면 여기에 상태가 표시됩니다.
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>전체:</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: 'Islands', icon: 'ISL', 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 },
@@ -0,0 +1,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: "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,4 +1,4 @@
1
- import type { RouteSpec } from "../spec/schema";
1
+ import { needsHydration, type RouteSpec } from "../spec/schema";
2
2
  import { GENERATED_RELATIVE_PATHS } from "../paths";
3
3
 
4
4
  export function generateApiHandler(route: RouteSpec): string {
@@ -238,23 +238,34 @@ function computeSlotImportPath(slotModule: string, fromDir: string): string {
238
238
  return result;
239
239
  }
240
240
 
241
- export function generatePageComponent(route: RouteSpec): string {
242
- // Island-First: clientModule이 있으면 Island render를 SSR에서 직접 사용
243
- if (route.clientModule) {
244
- return generatePageComponentWithIsland(route);
245
- }
246
-
247
- // slotModule이 있으면 PageHandler 형식으로 생성 (filling 포함)
248
- if (route.slotModule) {
249
- return generatePageHandlerWithSlot(route);
250
- }
251
-
252
- const pageName = toPascalCase(route.id);
253
-
254
- // slotModule이 없으면 기존 방식
255
- return `// Generated by Mandu - DO NOT EDIT DIRECTLY
256
- // Route ID: ${route.id}
257
- // Pattern: ${route.pattern}
241
+ export function generatePageComponent(route: RouteSpec): string {
242
+ // Island-First: clientModule이 있으면 Island render를 SSR에서 직접 사용
243
+ if (route.clientModule) {
244
+ return generatePageComponentWithIsland(route);
245
+ }
246
+
247
+ if (needsHydration(route)) {
248
+ throw new Error(
249
+ `[${route.id}] Route has hydration strategy "${route.hydration?.strategy}" but no clientModule. ` +
250
+ "Refusing to generate a placeholder page because it would disagree with runtime hydration state.",
251
+ );
252
+ }
253
+
254
+ // slotModule이 있으면 PageHandler 형식으로 생성 (filling 포함)
255
+ if (route.slotModule) {
256
+ return generatePageHandlerWithSlot(route);
257
+ }
258
+
259
+ if (route.kind === "page" && route.componentModule) {
260
+ return generatePageComponentFromModule(route);
261
+ }
262
+
263
+ const pageName = toPascalCase(route.id);
264
+
265
+ // Legacy fallback for malformed historical manifests that lack componentModule.
266
+ return `// Generated by Mandu - DO NOT EDIT DIRECTLY
267
+ // Route ID: ${route.id}
268
+ // Pattern: ${route.pattern}
258
269
 
259
270
  import React from "react";
260
271
 
@@ -270,8 +281,31 @@ export default function ${pageName}Page({ params }: Props): React.ReactElement {
270
281
  React.createElement("p", null, "Pattern: ${route.pattern}")
271
282
  );
272
283
  }
273
- `;
274
- }
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
+ }
275
309
 
276
310
  /**
277
311
  * Island-First Rendering: SSR이 island의 render 함수를 직접 사용
@@ -279,37 +313,51 @@ export default function ${pageName}Page({ params }: Props): React.ReactElement {
279
313
  * - SSR과 클라이언트가 동일한 렌더링 로직 사용 → 불일치 구조적 방지
280
314
  * - slotModule 유무에 따라 두 가지 변형 생성
281
315
  */
282
- export function generatePageComponentWithIsland(route: RouteSpec): string {
283
- const pageName = toPascalCase(route.id);
284
- const clientImportPath = computeSlotImportPath(route.clientModule!, GENERATED_RELATIVE_PATHS.webRoutes);
285
-
286
- // clientModule + slotModule → PageRegistration 형식
287
- if (route.slotModule) {
288
- 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);
289
334
 
290
335
  return `// Generated by Mandu - DO NOT EDIT DIRECTLY
291
336
  // Island-First Rendering + Slot Module
292
337
  // Route ID: ${route.id}
293
- // Pattern: ${route.pattern}
294
- // Client Module: ${route.clientModule}
295
- // Slot Module: ${route.slotModule}
296
-
297
- import React from "react";
298
- import filling from "${slotImportPath}";
299
- import islandModule from "${clientImportPath}";
300
-
301
- interface Props {
302
- params: Record<string, string>;
303
- loaderData?: unknown;
304
- }
305
-
306
- function ${pageName}Page({ params, loaderData }: Props): React.ReactElement {
307
- const serverData = (loaderData || {}) as Record<string, unknown>;
308
- const setupResult = islandModule.definition.setup(serverData);
309
- return islandModule.definition.render(setupResult) as React.ReactElement;
310
- }
311
-
312
- // 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용)
313
361
  export default {
314
362
  component: ${pageName}Page,
315
363
  filling: filling,
@@ -320,67 +368,124 @@ export default {
320
368
  // clientModule만 (slotModule 없음) → default export 컴포넌트
321
369
  return `// Generated by Mandu - DO NOT EDIT DIRECTLY
322
370
  // Island-First Rendering: SSR이 island render 직접 사용
323
- // Route ID: ${route.id}
324
- // Pattern: ${route.pattern}
325
- // Client Module: ${route.clientModule}
326
-
327
- import React from "react";
328
- import islandModule from "${clientImportPath}";
329
-
330
- interface Props {
331
- params: Record<string, string>;
332
- loaderData?: unknown;
333
- }
334
-
335
- export default function ${pageName}Page({ params, loaderData }: Props): React.ReactElement {
336
- const serverData = (loaderData || {}) as Record<string, unknown>;
337
- const setupResult = islandModule.definition.setup(serverData);
338
- return islandModule.definition.render(setupResult) as React.ReactElement;
339
- }
340
- `;
341
- }
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
+ }
342
393
 
343
394
  /**
344
395
  * slotModule이 있는 Page Route용 Handler 생성
345
396
  * - component와 filling을 함께 export
346
397
  * - server.ts에서 filling.executeLoader() 호출 가능
347
398
  */
348
- export function generatePageHandlerWithSlot(route: RouteSpec): string {
349
- const pageName = toPascalCase(route.id);
350
- const slotImportPath = computeSlotImportPath(route.slotModule!, GENERATED_RELATIVE_PATHS.serverRoutes);
351
-
352
- return `// Generated by Mandu - DO NOT EDIT DIRECTLY
353
- // Route ID: ${route.id}
354
- // Pattern: ${route.pattern}
355
- // Slot Module: ${route.slotModule}
356
-
357
- import React from "react";
358
- import filling from "${slotImportPath}";
359
-
360
- interface Props {
361
- params: Record<string, string>;
362
- loaderData?: unknown;
363
- }
364
-
365
- function ${pageName}Page({ params, loaderData }: Props): React.ReactElement {
366
- return React.createElement("div", null,
367
- React.createElement("h1", null, "${pageName} Page"),
368
- React.createElement("p", null, "Route ID: ${route.id}"),
369
- React.createElement("p", null, "Pattern: ${route.pattern}"),
370
- loaderData ? React.createElement("pre", null, JSON.stringify(loaderData, null, 2)) : null
371
- );
372
- }
373
-
374
- // 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용)
375
434
  export default {
376
435
  component: ${pageName}Page,
377
436
  filling: filling,
378
437
  };
379
- `;
380
- }
381
-
382
- /**
383
- * 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)
384
489
  * "todo-page" → "TodoPage"
385
490
  * "user_profile" → "UserProfile"
386
491
  */
@@ -391,6 +496,10 @@ function toPascalCase(str: string): string {
391
496
  .join("");
392
497
  }
393
498
 
394
- function pathDirname(filePath: string): string {
395
- return filePath.replace(/\\/g, "/").split("/").slice(0, -1).join("/");
396
- }
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
+ }
@@ -42,7 +42,7 @@ describe("findClientComponentImports", () => {
42
42
  });
43
43
  });
44
44
 
45
- it("does not promote embedded client imports inside a larger server page", () => {
45
+ it("promotes a fragment wrapper with head-only elements and a bare client component", () => {
46
46
  const routeClient = findRouteLevelClientComponentImport(`
47
47
  import HomeApp from "@/client/pages/home/HomeApp.client";
48
48
 
@@ -54,6 +54,24 @@ describe("findClientComponentImports", () => {
54
54
  }
55
55
  `);
56
56
 
57
+ expect(routeClient).toEqual({
58
+ module: "@/client/pages/home/HomeApp.client",
59
+ localName: "HomeApp",
60
+ });
61
+ });
62
+
63
+ it("does not promote embedded client imports inside a larger server page", () => {
64
+ const routeClient = findRouteLevelClientComponentImport(`
65
+ import HomeApp from "@/client/pages/home/HomeApp.client";
66
+
67
+ export default function HomePage() {
68
+ return <>
69
+ <header>Server shell</header>
70
+ <HomeApp />
71
+ </>;
72
+ }
73
+ `);
74
+
57
75
  expect(routeClient).toBeNull();
58
76
  });
59
77
 
@@ -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
  }
@@ -171,11 +172,11 @@ function defaultExportReturnsOnlyClientComponent(source: string, localName: stri
171
172
  const functionBody = extractDefaultExportFunctionBody(source);
172
173
  if (functionBody !== null) {
173
174
  const returned = extractOnlyReturnExpression(functionBody);
174
- return returned !== null && isSelfClosingJsxElement(returned, localName);
175
+ return returned !== null && jsxExpressionRendersClientComponent(returned, localName);
175
176
  }
176
177
 
177
178
  const arrowExpression = extractDefaultExportArrowExpression(source);
178
- return arrowExpression !== null && isSelfClosingJsxElement(arrowExpression, localName);
179
+ return arrowExpression !== null && jsxExpressionRendersClientComponent(arrowExpression, localName);
179
180
  }
180
181
 
181
182
  function extractDefaultExportFunctionBody(source: string): string | null {
@@ -205,12 +206,41 @@ function extractOnlyReturnExpression(body: string): string | null {
205
206
  return match?.[1]?.trim() ?? null;
206
207
  }
207
208
 
208
- function isSelfClosingJsxElement(expression: string, localName: string): boolean {
209
+ function jsxExpressionRendersClientComponent(expression: string, localName: string): boolean {
210
+ const expr = stripWrappingParentheses(expression.trim());
211
+ return isBareClientElement(expr, localName) || isHeadOnlyFragmentWrapper(expr, localName);
212
+ }
213
+
214
+ function isBareClientElement(expression: string, localName: string): boolean {
209
215
  const expr = stripWrappingParentheses(expression.trim());
210
216
  const escaped = escapeRegExp(localName);
211
217
  return new RegExp(`^<${escaped}\\s*/>$`).test(expr);
212
218
  }
213
219
 
220
+ function isHeadOnlyFragmentWrapper(expression: string, localName: string): boolean {
221
+ const expr = stripWrappingParentheses(expression.trim());
222
+ const fragmentMatch = /^<>\s*([\s\S]*?)\s*<\/>$/.exec(expr);
223
+ if (!fragmentMatch) return false;
224
+
225
+ const escaped = escapeRegExp(localName);
226
+ const clientElementPattern = new RegExp(`<${escaped}(?:\\s[^>]*)?(?:/>|>)`, "g");
227
+ const matches = [...(fragmentMatch[1] ?? "").matchAll(clientElementPattern)];
228
+ if (matches.length !== 1) return false;
229
+ const clientElement = matches[0][0];
230
+ if (!isBareClientElement(clientElement, localName)) return false;
231
+
232
+ const rest = (fragmentMatch[1] ?? "")
233
+ .replace(clientElement, "")
234
+ .replace(/<meta\b[^>]*\/>/gi, "")
235
+ .replace(/<link\b[^>]*\/>/gi, "")
236
+ .replace(/<base\b[^>]*\/>/gi, "")
237
+ .replace(/<title\b[^>]*>[\s\S]*?<\/title>/gi, "")
238
+ .replace(/<script\b[^>]*>[\s\S]*?<\/script>/gi, "")
239
+ .trim();
240
+
241
+ return rest.length === 0;
242
+ }
243
+
214
244
  function stripWrappingParentheses(value: string): string {
215
245
  let current = value.trim();
216
246
  while (current.startsWith("(") && current.endsWith(")")) {
@@ -301,6 +331,9 @@ export async function validateClientModuleForBrowserBundle(
301
331
  }
302
332
 
303
333
  if (clientModuleIsRouteComponent(route) && !hasUseClientDirective(source)) {
334
+ if (await routeComponentHasResolvableClientEntry(rootDir, route.clientModule, source)) {
335
+ return null;
336
+ }
304
337
  return (
305
338
  `[${route.id}] Route component "${route.clientModule}" is configured as clientModule, ` +
306
339
  `but it is a server page (missing "use client"). Mandu will not bundle server pages into client islands. ` +
@@ -311,6 +344,16 @@ export async function validateClientModuleForBrowserBundle(
311
344
  return null;
312
345
  }
313
346
 
347
+ async function routeComponentHasResolvableClientEntry(
348
+ rootDir: string,
349
+ routeModule: string,
350
+ source: string,
351
+ ): Promise<boolean> {
352
+ const routeLevelClientImport = findRouteLevelClientComponentImport(source);
353
+ if (!routeLevelClientImport) return false;
354
+ return (await resolveClientImportModulePath(rootDir, routeModule, routeLevelClientImport.module)) !== null;
355
+ }
356
+
314
357
  export async function describeMissingHydrationClientModule(
315
358
  route: RouteSpec,
316
359
  rootDir: string,
@@ -401,11 +401,14 @@ export class FSScanner {
401
401
  } else {
402
402
  const routeLevelClientImport = findRouteLevelClientComponentImport(pageFileContent);
403
403
  if (routeLevelClientImport) {
404
- clientModule = await resolveClientImportModulePath(
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
+ }
409
412
  }
410
413
  }
411
414
  }