@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 +1 -1
- package/src/agent/__tests__/context.test.ts +65 -17
- package/src/agent/types.ts +18 -10
- package/src/agent/verify.ts +115 -17
- package/src/bundler/build.test.ts +1 -1
- package/src/error/formatter.ts +31 -22
- package/src/generator/templates.test.ts +33 -2
- package/src/generator/templates.ts +194 -92
- package/src/router/client-entry.test.ts +74 -5
- package/src/router/client-entry.ts +153 -24
- package/src/router/fs-scanner.ts +9 -5
package/package.json
CHANGED
|
@@ -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
|
-
|
|
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("
|
|
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
|
-
|
|
245
|
+
},
|
|
246
|
+
changedFiles: [],
|
|
247
|
+
changedFileReasons: [],
|
|
248
|
+
gitAvailable: false,
|
|
201
249
|
notes: [],
|
|
202
250
|
ok: false,
|
|
203
251
|
checks: [],
|
package/src/agent/types.ts
CHANGED
|
@@ -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
|
|
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
|
-
|
|
173
|
+
project: AgentProjectSummary;
|
|
174
|
+
changedFiles: string[];
|
|
175
|
+
changedFileReasons: AgentChangedFileReason[];
|
|
176
|
+
gitAvailable: boolean;
|
|
169
177
|
notes: string[];
|
|
170
178
|
ok: boolean;
|
|
171
179
|
checks: AgentVerifyCheck[];
|
package/src/agent/verify.ts
CHANGED
|
@@ -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
|
-
|
|
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(
|
|
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 (
|
|
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
|
-
|
|
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("
|
|
251
|
+
expect(errors).toContain("run mandu generate");
|
|
252
252
|
} finally {
|
|
253
253
|
await rm(missingRoot, { recursive: true, force: true });
|
|
254
254
|
}
|
package/src/error/formatter.ts
CHANGED
|
@@ -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
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
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}
|
|
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("
|
|
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
|
-
|
|
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
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
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
|
-
|
|
294
|
-
|
|
295
|
-
|
|
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
|
-
|
|
305
|
-
import
|
|
306
|
-
import
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
}
|
|
318
|
-
|
|
319
|
-
|
|
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
|
-
|
|
335
|
-
import
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
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
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
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
|
-
|
|
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", "
|
|
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("
|
|
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).
|
|
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("
|
|
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).
|
|
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
|
-
|
|
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
|
-
|
|
104
|
-
|
|
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
|
-
|
|
111
|
-
const
|
|
112
|
-
|
|
113
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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 =
|
|
174
|
-
return returned !== null
|
|
177
|
+
const returned = extractTopLevelReturnExpression(functionBody);
|
|
178
|
+
return returned !== null ? jsxExpressionRendersClientComponents(returned, candidates) : [];
|
|
175
179
|
}
|
|
176
180
|
|
|
177
181
|
const arrowExpression = extractDefaultExportArrowExpression(source);
|
|
178
|
-
|
|
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
|
|
204
|
-
const match =
|
|
205
|
-
|
|
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
|
|
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(
|
|
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
|
|
482
|
+
` The page imports client-looking modules, but none was linked into the route manifest:\n` +
|
|
354
483
|
`${importList}\n` +
|
|
355
|
-
` Fix:
|
|
484
|
+
` Fix: run mandu generate with the current source, or set an explicit route-level clientModule.`
|
|
356
485
|
);
|
|
357
486
|
}
|
package/src/router/fs-scanner.ts
CHANGED
|
@@ -31,7 +31,7 @@ import {
|
|
|
31
31
|
import { mark, measure } from "../perf";
|
|
32
32
|
import { METADATA_ROUTES } from "../routes/types";
|
|
33
33
|
import {
|
|
34
|
-
|
|
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
|
|
403
|
-
|
|
404
|
-
|
|
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
|
-
)
|
|
408
|
+
);
|
|
409
|
+
if (resolvedClientImport) {
|
|
410
|
+
clientModule = modulePath;
|
|
411
|
+
break;
|
|
412
|
+
}
|
|
409
413
|
}
|
|
410
414
|
}
|
|
411
415
|
}
|