@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
@@ -1,486 +1,516 @@
1
- import { readFile } from "fs/promises";
2
- import path from "path";
3
- import type { RouteSpec } from "../spec/schema";
4
-
5
- export interface ClientComponentImport {
6
- module: string;
7
- kind: "default" | "named" | "namespace" | "side-effect" | "mixed";
8
- names: string[];
9
- }
10
-
11
- export interface RouteLevelClientComponentImport {
12
- module: string;
13
- localName: string;
14
- }
15
-
16
- export function normalizeRouteModulePath(value: string | undefined): string {
17
- return (value ?? "").replace(/\\/g, "/").replace(/^\.\//, "");
18
- }
19
-
20
- export function hasUseClientDirective(source: string): boolean {
21
- return /^(?:\uFEFF)?\s*(?:(?:\/\/[^\r\n]*(?:\r?\n|$))|(?:\/\*[\s\S]*?\*\/\s*))*["']use client["']\s*;?/.test(source);
22
- }
23
-
24
- export function hasUseServerDirective(source: string): boolean {
25
- return /^(?:\uFEFF)?\s*(?:(?:\/\/[^\r\n]*(?:\r?\n|$))|(?:\/\*[\s\S]*?\*\/\s*))*["']use server["']\s*;?/.test(source);
26
- }
27
-
28
- export function clientModuleIsRouteComponent(route: RouteSpec, clientModule = route.clientModule): boolean {
29
- if (route.kind !== "page" || !clientModule) return false;
30
-
31
- const client = normalizeRouteModulePath(clientModule);
32
- return (
33
- client === normalizeRouteModulePath(route.componentModule) ||
34
- client === normalizeRouteModulePath(route.module)
35
- );
36
- }
37
-
38
- async function readRouteModule(rootDir: string, modulePath: string): Promise<string | null> {
39
- try {
40
- return await readFile(path.resolve(rootDir, modulePath), "utf-8");
41
- } catch {
42
- return null;
43
- }
44
- }
45
-
46
- export function findClientComponentImports(source: string): ClientComponentImport[] {
47
- const imports: ClientComponentImport[] = [];
48
- const importFromPattern = /import\s+([\s\S]*?)\s+from\s+["']([^"']*\.client(?:\.[tj]sx?)?)["']/g;
49
- const sideEffectPattern = /import\s+["']([^"']*\.client(?:\.[tj]sx?)?)["']/g;
50
-
51
- for (const match of source.matchAll(importFromPattern)) {
52
- const clause = (match[1] ?? "").trim();
53
- const module = match[2] ?? "";
54
- const names: string[] = [];
55
- let hasDefault = false;
56
- let hasNamed = false;
57
- let hasNamespace = false;
58
-
59
- const namedMatch = clause.match(/\{([^}]*)\}/);
60
- if (namedMatch) {
61
- hasNamed = true;
62
- for (const rawName of namedMatch[1].split(",")) {
63
- const name = rawName.trim();
64
- if (!name) continue;
65
- const parts = name.split(/\s+as\s+/i).map((part) => part.trim()).filter(Boolean);
66
- names.push(parts[1] ?? parts[0]);
67
- }
68
- }
69
-
70
- if (/\*\s+as\s+/.test(clause)) {
71
- hasNamespace = true;
72
- const namespaceMatch = clause.match(/\*\s+as\s+([A-Za-z_$][A-Za-z0-9_$]*)/);
73
- if (namespaceMatch?.[1]) names.push(namespaceMatch[1]);
74
- }
75
-
76
- const beforeNamed = clause.split("{")[0]?.replace(/,\s*$/, "").trim() ?? "";
77
- if (beforeNamed && !beforeNamed.startsWith("*")) {
78
- hasDefault = true;
79
- names.push(beforeNamed.split(",")[0].trim());
80
- }
81
-
82
- const kind =
83
- (hasDefault && (hasNamed || hasNamespace))
84
- ? "mixed"
85
- : hasNamed
86
- ? "named"
87
- : hasNamespace
88
- ? "namespace"
89
- : "default";
90
-
91
- imports.push({ module, kind, names });
92
- }
93
-
94
- for (const match of source.matchAll(sideEffectPattern)) {
95
- const module = match[1] ?? "";
96
- if (imports.some((entry) => entry.module === module)) continue;
97
- imports.push({ module, kind: "side-effect", names: [] });
98
- }
99
-
100
- return imports;
101
- }
102
-
103
- export function findRouteLevelClientComponentImport(source: string): RouteLevelClientComponentImport | null {
104
- return findRouteLevelClientComponentImports(source)[0] ?? null;
105
- }
106
-
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
- );
113
-
114
- if (candidates.length === 0) return [];
115
- return defaultExportRendersClientComponents(source, candidates);
116
- }
117
-
118
- export async function resolveClientImportModulePath(
119
- rootDir: string,
120
- importerModule: string,
121
- specifier: string,
122
- ): Promise<string | null> {
123
- const base = resolveImportBasePath(rootDir, importerModule, specifier);
124
- if (!base) return null;
125
-
126
- for (const candidate of expandClientModuleCandidates(base)) {
127
- if (await Bun.file(candidate).exists()) {
128
- return path.relative(rootDir, candidate).replace(/\\/g, "/");
129
- }
130
- }
131
-
132
- return null;
133
- }
134
-
135
- export async function shouldPreserveExistingClientModule(
136
- route: RouteSpec,
137
- clientModule: string,
138
- rootDir: string,
139
- ): Promise<boolean> {
140
- const source = await readRouteModule(rootDir, clientModule);
141
- if (source === null) return false;
142
- if (hasUseServerDirective(source)) return false;
143
- if (clientModuleIsRouteComponent(route, clientModule)) {
144
- if (hasUseClientDirective(source)) return true;
145
- return await routeComponentHasResolvableClientEntry(rootDir, clientModule, source);
146
- }
147
- return true;
148
- }
149
-
150
- function resolveImportBasePath(rootDir: string, importerModule: string, specifier: string): string | null {
151
- const normalized = specifier.replace(/\\/g, "/");
152
- if (normalized.startsWith("@/") || normalized.startsWith("~/")) {
153
- return path.resolve(rootDir, "src", normalized.slice(2));
154
- }
155
- if (normalized.startsWith("./") || normalized.startsWith("../")) {
156
- return path.resolve(rootDir, path.dirname(importerModule), normalized);
157
- }
158
- return null;
159
- }
160
-
161
- function expandClientModuleCandidates(basePath: string): string[] {
162
- if (/\.[cm]?[jt]sx?$/.test(basePath)) return [basePath];
163
- return [
164
- `${basePath}.tsx`,
165
- `${basePath}.ts`,
166
- `${basePath}.jsx`,
167
- `${basePath}.js`,
168
- ];
169
- }
170
-
171
- function defaultExportRendersClientComponents(
172
- source: string,
173
- candidates: RouteLevelClientComponentImport[],
174
- ): RouteLevelClientComponentImport[] {
175
- const functionBody = extractDefaultExportFunctionBody(source);
176
- if (functionBody !== null) {
177
- const returned = extractTopLevelReturnExpression(functionBody);
178
- return returned !== null ? jsxExpressionRendersClientComponents(returned, candidates) : [];
179
- }
180
-
181
- const arrowExpression = extractDefaultExportArrowExpression(source);
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 [];
193
- }
194
-
195
- function extractDefaultExportFunctionBody(source: string): string | null {
196
- const match = /export\s+default\s+(?:async\s+)?function(?:\s+[A-Za-z_$][A-Za-z0-9_$]*)?\s*\([^)]*\)\s*(?::\s*[^{=]+)?\{/m.exec(source);
197
- if (!match) return null;
198
-
199
- const openBrace = match.index + match[0].lastIndexOf("{");
200
- const closeBrace = findMatchingBrace(source, openBrace);
201
- if (closeBrace === -1) return null;
202
- return source.slice(openBrace + 1, closeBrace);
203
- }
204
-
205
- function extractDefaultExportArrowExpression(source: string): string | null {
206
- const match = /export\s+default\s+(?:async\s+)?(?:\([^)]*\)|[A-Za-z_$][A-Za-z0-9_$]*)\s*=>\s*/m.exec(source);
207
- if (!match) return null;
208
-
209
- const start = match.index + match[0].length;
210
- const rest = source.slice(start).trim();
211
- if (rest.startsWith("{")) return null;
212
-
213
- const semicolon = rest.indexOf(";");
214
- return semicolon === -1 ? rest : rest.slice(0, semicolon);
215
- }
216
-
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;
296
- }
297
-
298
- function jsxExpressionRendersClientComponents(
299
- expression: string,
300
- candidates: RouteLevelClientComponentImport[],
301
- ): RouteLevelClientComponentImport[] {
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 {
318
- const escaped = escapeRegExp(localName);
319
- return new RegExp(`<${escaped}(?:\\s|/|>)`).test(expression);
320
- }
321
-
322
- function stripWrappingParentheses(value: string): string {
323
- let current = value.trim();
324
- while (current.startsWith("(") && current.endsWith(")")) {
325
- const close = findMatchingParen(current, 0);
326
- if (close !== current.length - 1) break;
327
- current = current.slice(1, -1).trim();
328
- }
329
- return current;
330
- }
331
-
332
- function findMatchingBrace(source: string, openIndex: number): number {
333
- return findMatchingDelimiter(source, openIndex, "{", "}");
334
- }
335
-
336
- function findMatchingParen(source: string, openIndex: number): number {
337
- return findMatchingDelimiter(source, openIndex, "(", ")");
338
- }
339
-
340
- function findMatchingDelimiter(source: string, openIndex: number, open: string, close: string): number {
341
- let depth = 0;
342
- let quote: '"' | "'" | "`" | null = null;
343
- let lineComment = false;
344
- let blockComment = false;
345
-
346
- for (let i = openIndex; i < source.length; i++) {
347
- const char = source[i];
348
- const next = source[i + 1];
349
- const prev = source[i - 1];
350
-
351
- if (lineComment) {
352
- if (char === "\n" || char === "\r") lineComment = false;
353
- continue;
354
- }
355
-
356
- if (blockComment) {
357
- if (char === "*" && next === "/") {
358
- blockComment = false;
359
- i++;
360
- }
361
- continue;
362
- }
363
-
364
- if (quote) {
365
- if (char === quote && prev !== "\\") quote = null;
366
- continue;
367
- }
368
-
369
- if (char === "/" && next === "/") {
370
- lineComment = true;
371
- i++;
372
- continue;
373
- }
374
- if (char === "/" && next === "*") {
375
- blockComment = true;
376
- i++;
377
- continue;
378
- }
379
- if (char === '"' || char === "'" || char === "`") {
380
- quote = char;
381
- continue;
382
- }
383
-
384
- if (char === open) depth++;
385
- if (char === close) {
386
- depth--;
387
- if (depth === 0) return i;
388
- }
389
- }
390
-
391
- return -1;
392
- }
393
-
394
- function escapeRegExp(value: string): string {
395
- return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
396
- }
397
-
398
- function isIdentifierChar(value: string): boolean {
399
- return /[A-Za-z0-9_$]/.test(value);
400
- }
401
-
402
- export async function validateClientModuleForBrowserBundle(
403
- route: RouteSpec,
404
- rootDir: string,
405
- ): Promise<string | null> {
406
- if (!route.clientModule) return null;
407
-
408
- const source = await readRouteModule(rootDir, route.clientModule);
409
- if (source === null) return null;
410
-
411
- if (hasUseServerDirective(source)) {
412
- return `[${route.id}] Client module "${route.clientModule}" has a "use server" directive and cannot be bundled for the browser.`;
413
- }
414
-
415
- if (clientModuleIsRouteComponent(route) && !hasUseClientDirective(source)) {
416
- if (await routeComponentHasResolvableClientEntry(rootDir, route.clientModule, source)) {
417
- return null;
418
- }
419
- return (
420
- `[${route.id}] Route component "${route.clientModule}" is configured as clientModule, ` +
421
- `but it is a server page (missing "use client"). Mandu will not bundle server pages into client islands. ` +
422
- `Remove the stale clientModule from .mandu/routes.manifest.json or use a *.partial.tsx / spec/slots/${route.id}.client.tsx client entry.`
423
- );
424
- }
425
-
426
- return null;
427
- }
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
-
443
- export async function describeMissingHydrationClientModule(
444
- route: RouteSpec,
445
- rootDir: string,
446
- options: { allowPartialOnly?: boolean } = {},
447
- ): Promise<string | null> {
448
- const hydration = route.hydration?.strategy ?? "island";
449
- const base =
450
- `[${route.id}] Route has hydration strategy "${hydration}" but no clientModule could be resolved. ` +
451
- `Mandu cannot emit a working data-mandu-src for this route.`;
452
-
453
- const componentModule = route.kind === "page" ? route.componentModule : undefined;
454
- if (!componentModule) {
455
- return options.allowPartialOnly && hydration === "island" ? null : base;
456
- }
457
-
458
- const source = await readRouteModule(rootDir, componentModule);
459
- if (source === null) {
460
- return options.allowPartialOnly && hydration === "island" ? null : base;
461
- }
462
-
463
- const clientImports = findClientComponentImports(source);
464
- if (clientImports.length === 0) {
465
- if (options.allowPartialOnly && hydration === "island") return null;
466
- return (
467
- `${base}\n` +
468
- ` Fix: add a route-level client module (for example app/*.island.tsx or spec/slots/${route.id}.client.tsx) ` +
469
- `or set hydration.strategy to "none".`
470
- );
471
- }
472
-
473
- const importList = clientImports
474
- .map((entry) => {
475
- const suffix = entry.names.length > 0 ? ` (${entry.kind}: ${entry.names.join(", ")})` : ` (${entry.kind})`;
476
- return ` - ${entry.module}${suffix}`;
477
- })
478
- .join("\n");
479
-
480
- return (
481
- `${base}\n` +
482
- ` The page imports client-looking modules, but none was linked into the route manifest:\n` +
483
- `${importList}\n` +
484
- ` Fix: run mandu generate with the current source, or set an explicit route-level clientModule.`
485
- );
486
- }
1
+ import { readFile } from "fs/promises";
2
+ import path from "path";
3
+ import type { RouteSpec } from "../spec/schema";
4
+
5
+ export interface ClientComponentImport {
6
+ module: string;
7
+ kind: "default" | "named" | "namespace" | "side-effect" | "mixed";
8
+ names: string[];
9
+ }
10
+
11
+ export interface RouteLevelClientComponentImport {
12
+ module: string;
13
+ localName: string;
14
+ }
15
+
16
+ const CLIENT_ENTRY_SPECIFIER_PATTERN = /\.(?:client|island)(?:\.[tj]sx?)?$/;
17
+
18
+ export function normalizeRouteModulePath(value: string | undefined): string {
19
+ return (value ?? "").replace(/\\/g, "/").replace(/^\.\//, "");
20
+ }
21
+
22
+ export function hasUseClientDirective(source: string): boolean {
23
+ return /^(?:\uFEFF)?\s*(?:(?:\/\/[^\r\n]*(?:\r?\n|$))|(?:\/\*[\s\S]*?\*\/\s*))*["']use client["']\s*;?/.test(source);
24
+ }
25
+
26
+ export function hasUseServerDirective(source: string): boolean {
27
+ return /^(?:\uFEFF)?\s*(?:(?:\/\/[^\r\n]*(?:\r?\n|$))|(?:\/\*[\s\S]*?\*\/\s*))*["']use server["']\s*;?/.test(source);
28
+ }
29
+
30
+ export function clientModuleIsRouteComponent(route: RouteSpec, clientModule = route.clientModule): boolean {
31
+ if (route.kind !== "page" || !clientModule) return false;
32
+
33
+ const client = normalizeRouteModulePath(clientModule);
34
+ return (
35
+ client === normalizeRouteModulePath(route.componentModule) ||
36
+ client === normalizeRouteModulePath(route.module)
37
+ );
38
+ }
39
+
40
+ async function readRouteModule(rootDir: string, modulePath: string): Promise<string | null> {
41
+ try {
42
+ return await readFile(path.resolve(rootDir, modulePath), "utf-8");
43
+ } catch {
44
+ return null;
45
+ }
46
+ }
47
+
48
+ export function findClientComponentImports(source: string): ClientComponentImport[] {
49
+ return findComponentImports(source).filter((entry) => clientSpecifierLooksBrowserOnly(entry.module));
50
+ }
51
+
52
+ function findComponentImports(source: string): ClientComponentImport[] {
53
+ const imports: ClientComponentImport[] = [];
54
+ const importFromPattern = /import\s+(?!type\b)([\s\S]*?)\s+from\s+["']([^"']+)["']/g;
55
+ const sideEffectPattern = /import\s+["']([^"']+)["']/g;
56
+
57
+ for (const match of source.matchAll(importFromPattern)) {
58
+ const clause = (match[1] ?? "").trim();
59
+ const module = match[2] ?? "";
60
+ const names: string[] = [];
61
+ let hasDefault = false;
62
+ let hasNamed = false;
63
+ let hasNamespace = false;
64
+
65
+ const namedMatch = clause.match(/\{([^}]*)\}/);
66
+ if (namedMatch) {
67
+ hasNamed = true;
68
+ for (const rawName of namedMatch[1].split(",")) {
69
+ const name = rawName.trim();
70
+ if (!name) continue;
71
+ const parts = name.split(/\s+as\s+/i).map((part) => part.trim()).filter(Boolean);
72
+ names.push(parts[1] ?? parts[0]);
73
+ }
74
+ }
75
+
76
+ if (/\*\s+as\s+/.test(clause)) {
77
+ hasNamespace = true;
78
+ const namespaceMatch = clause.match(/\*\s+as\s+([A-Za-z_$][A-Za-z0-9_$]*)/);
79
+ if (namespaceMatch?.[1]) names.push(namespaceMatch[1]);
80
+ }
81
+
82
+ const beforeNamed = clause.split("{")[0]?.replace(/,\s*$/, "").trim() ?? "";
83
+ if (beforeNamed && !beforeNamed.startsWith("*")) {
84
+ hasDefault = true;
85
+ names.push(beforeNamed.split(",")[0].trim());
86
+ }
87
+
88
+ const kind =
89
+ (hasDefault && (hasNamed || hasNamespace))
90
+ ? "mixed"
91
+ : hasNamed
92
+ ? "named"
93
+ : hasNamespace
94
+ ? "namespace"
95
+ : "default";
96
+
97
+ imports.push({ module, kind, names });
98
+ }
99
+
100
+ for (const match of source.matchAll(sideEffectPattern)) {
101
+ const module = match[1] ?? "";
102
+ if (imports.some((entry) => entry.module === module)) continue;
103
+ imports.push({ module, kind: "side-effect", names: [] });
104
+ }
105
+
106
+ return imports;
107
+ }
108
+
109
+ export function findRouteLevelClientComponentImport(source: string): RouteLevelClientComponentImport | null {
110
+ return findRouteLevelClientComponentImports(source)[0] ?? null;
111
+ }
112
+
113
+ export function findRouteLevelClientComponentImports(source: string): RouteLevelClientComponentImport[] {
114
+ const candidates = findComponentImports(source).flatMap((entry) =>
115
+ entry.names
116
+ .filter((localName) => /^[A-Za-z_$][A-Za-z0-9_$]*$/.test(localName))
117
+ .map((localName) => ({ module: entry.module, localName }))
118
+ );
119
+
120
+ if (candidates.length === 0) return [];
121
+ return defaultExportRendersClientComponents(source, candidates);
122
+ }
123
+
124
+ export async function resolveClientImportModulePath(
125
+ rootDir: string,
126
+ importerModule: string,
127
+ specifier: string,
128
+ ): Promise<string | null> {
129
+ const base = resolveImportBasePath(rootDir, importerModule, specifier);
130
+ if (!base) return null;
131
+
132
+ for (const candidate of expandClientModuleCandidates(base)) {
133
+ if (await Bun.file(candidate).exists()) {
134
+ return path.relative(rootDir, candidate).replace(/\\/g, "/");
135
+ }
136
+ }
137
+
138
+ return null;
139
+ }
140
+
141
+ export async function resolveRouteLevelClientEntryPath(
142
+ rootDir: string,
143
+ routeModule: string,
144
+ source: string,
145
+ ): Promise<string | null> {
146
+ const routeLevelClientImports = findRouteLevelClientComponentImports(source);
147
+ for (const routeLevelClientImport of routeLevelClientImports) {
148
+ const resolved = await resolveClientImportModulePath(rootDir, routeModule, routeLevelClientImport.module);
149
+ if (!resolved) continue;
150
+ if (clientSpecifierLooksBrowserOnly(routeLevelClientImport.module)) return resolved;
151
+
152
+ const importedSource = await readRouteModule(rootDir, resolved);
153
+ if (importedSource !== null && hasUseClientDirective(importedSource)) {
154
+ return resolved;
155
+ }
156
+ }
157
+
158
+ return null;
159
+ }
160
+
161
+ export async function shouldPreserveExistingClientModule(
162
+ route: RouteSpec,
163
+ clientModule: string,
164
+ rootDir: string,
165
+ ): Promise<boolean> {
166
+ const source = await readRouteModule(rootDir, clientModule);
167
+ if (source === null) return false;
168
+ if (hasUseServerDirective(source)) return false;
169
+ if (clientModuleIsRouteComponent(route, clientModule)) {
170
+ if (hasUseClientDirective(source)) return true;
171
+ return await routeComponentHasResolvableClientEntry(rootDir, clientModule, source);
172
+ }
173
+ return true;
174
+ }
175
+
176
+ function resolveImportBasePath(rootDir: string, importerModule: string, specifier: string): string | null {
177
+ const normalized = specifier.replace(/\\/g, "/");
178
+ if (normalized.startsWith("@/") || normalized.startsWith("~/")) {
179
+ return path.resolve(rootDir, "src", normalized.slice(2));
180
+ }
181
+ if (normalized.startsWith("./") || normalized.startsWith("../")) {
182
+ return path.resolve(rootDir, path.dirname(importerModule), normalized);
183
+ }
184
+ return null;
185
+ }
186
+
187
+ function expandClientModuleCandidates(basePath: string): string[] {
188
+ if (/\.[cm]?[jt]sx?$/.test(basePath)) return [basePath];
189
+ return [
190
+ `${basePath}.tsx`,
191
+ `${basePath}.ts`,
192
+ `${basePath}.jsx`,
193
+ `${basePath}.js`,
194
+ path.join(basePath, "index.tsx"),
195
+ path.join(basePath, "index.ts"),
196
+ path.join(basePath, "index.jsx"),
197
+ path.join(basePath, "index.js"),
198
+ ];
199
+ }
200
+
201
+ function clientSpecifierLooksBrowserOnly(specifier: string): boolean {
202
+ return CLIENT_ENTRY_SPECIFIER_PATTERN.test(specifier.replace(/\\/g, "/"));
203
+ }
204
+
205
+ function defaultExportRendersClientComponents(
206
+ source: string,
207
+ candidates: RouteLevelClientComponentImport[],
208
+ ): RouteLevelClientComponentImport[] {
209
+ const functionBody = extractDefaultExportFunctionBody(source);
210
+ if (functionBody !== null) {
211
+ const returned = extractTopLevelReturnExpression(functionBody);
212
+ return returned !== null ? jsxExpressionRendersClientComponents(returned, candidates) : [];
213
+ }
214
+
215
+ const arrowExpression = extractDefaultExportArrowExpression(source);
216
+ if (arrowExpression !== null) {
217
+ return jsxExpressionRendersClientComponents(arrowExpression, candidates);
218
+ }
219
+
220
+ const arrowBody = extractDefaultExportArrowFunctionBody(source);
221
+ if (arrowBody !== null) {
222
+ const returned = extractTopLevelReturnExpression(arrowBody);
223
+ return returned !== null ? jsxExpressionRendersClientComponents(returned, candidates) : [];
224
+ }
225
+
226
+ return [];
227
+ }
228
+
229
+ function extractDefaultExportFunctionBody(source: string): string | null {
230
+ const match = /export\s+default\s+(?:async\s+)?function(?:\s+[A-Za-z_$][A-Za-z0-9_$]*)?\s*\([^)]*\)\s*(?::\s*[^{=]+)?\{/m.exec(source);
231
+ if (!match) return null;
232
+
233
+ const openBrace = match.index + match[0].lastIndexOf("{");
234
+ const closeBrace = findMatchingBrace(source, openBrace);
235
+ if (closeBrace === -1) return null;
236
+ return source.slice(openBrace + 1, closeBrace);
237
+ }
238
+
239
+ function extractDefaultExportArrowExpression(source: string): string | null {
240
+ const match = /export\s+default\s+(?:async\s+)?(?:\([^)]*\)|[A-Za-z_$][A-Za-z0-9_$]*)\s*=>\s*/m.exec(source);
241
+ if (!match) return null;
242
+
243
+ const start = match.index + match[0].length;
244
+ const rest = source.slice(start).trim();
245
+ if (rest.startsWith("{")) return null;
246
+
247
+ const semicolon = rest.indexOf(";");
248
+ return semicolon === -1 ? rest : rest.slice(0, semicolon);
249
+ }
250
+
251
+ function extractDefaultExportArrowFunctionBody(source: string): string | null {
252
+ const match = /export\s+default\s+(?:async\s+)?(?:\([^)]*\)|[A-Za-z_$][A-Za-z0-9_$]*)\s*=>\s*\{/m.exec(source);
253
+ if (!match) return null;
254
+
255
+ const openBrace = match.index + match[0].lastIndexOf("{");
256
+ const closeBrace = findMatchingBrace(source, openBrace);
257
+ if (closeBrace === -1) return null;
258
+ return source.slice(openBrace + 1, closeBrace);
259
+ }
260
+
261
+ function extractTopLevelReturnExpression(body: string): string | null {
262
+ let quote: '"' | "'" | "`" | null = null;
263
+ let lineComment = false;
264
+ let blockComment = false;
265
+ let braceDepth = 0;
266
+ let parenDepth = 0;
267
+ let bracketDepth = 0;
268
+
269
+ for (let i = 0; i < body.length; i++) {
270
+ const char = body[i];
271
+ const next = body[i + 1];
272
+ const prev = body[i - 1];
273
+
274
+ if (lineComment) {
275
+ if (char === "\n" || char === "\r") lineComment = false;
276
+ continue;
277
+ }
278
+
279
+ if (blockComment) {
280
+ if (char === "*" && next === "/") {
281
+ blockComment = false;
282
+ i++;
283
+ }
284
+ continue;
285
+ }
286
+
287
+ if (quote) {
288
+ if (char === quote && prev !== "\\") quote = null;
289
+ continue;
290
+ }
291
+
292
+ if (char === "/" && next === "/") {
293
+ lineComment = true;
294
+ i++;
295
+ continue;
296
+ }
297
+ if (char === "/" && next === "*") {
298
+ blockComment = true;
299
+ i++;
300
+ continue;
301
+ }
302
+ if (char === '"' || char === "'" || char === "`") {
303
+ quote = char;
304
+ continue;
305
+ }
306
+
307
+ if (braceDepth === 0 && parenDepth === 0 && bracketDepth === 0 && body.startsWith("return", i)) {
308
+ const before = body[i - 1] ?? "";
309
+ const after = body[i + "return".length] ?? "";
310
+ if (!isIdentifierChar(before) && !isIdentifierChar(after)) {
311
+ const expr = body.slice(i + "return".length).trim();
312
+ return trimTrailingSemicolon(expr);
313
+ }
314
+ }
315
+
316
+ if (char === "{") braceDepth++;
317
+ if (char === "}") braceDepth = Math.max(0, braceDepth - 1);
318
+ if (char === "(") parenDepth++;
319
+ if (char === ")") parenDepth = Math.max(0, parenDepth - 1);
320
+ if (char === "[") bracketDepth++;
321
+ if (char === "]") bracketDepth = Math.max(0, bracketDepth - 1);
322
+ }
323
+
324
+ return null;
325
+ }
326
+
327
+ function trimTrailingSemicolon(value: string): string {
328
+ const trimmed = value.trim();
329
+ return trimmed.endsWith(";") ? trimmed.slice(0, -1).trim() : trimmed;
330
+ }
331
+
332
+ function jsxExpressionRendersClientComponents(
333
+ expression: string,
334
+ candidates: RouteLevelClientComponentImport[],
335
+ ): RouteLevelClientComponentImport[] {
336
+ const expr = stripWrappingParentheses(expression.trim());
337
+ const seen = new Set<string>();
338
+ const rendered: RouteLevelClientComponentImport[] = [];
339
+
340
+ for (const candidate of candidates) {
341
+ const key = `${candidate.module}\0${candidate.localName}`;
342
+ if (seen.has(key)) continue;
343
+ if (!jsxExpressionContainsClientElement(expr, candidate.localName)) continue;
344
+ seen.add(key);
345
+ rendered.push(candidate);
346
+ }
347
+
348
+ return rendered;
349
+ }
350
+
351
+ function jsxExpressionContainsClientElement(expression: string, localName: string): boolean {
352
+ const escaped = escapeRegExp(localName);
353
+ return new RegExp(`<${escaped}(?:\\s|/|>)`).test(expression);
354
+ }
355
+
356
+ function stripWrappingParentheses(value: string): string {
357
+ let current = value.trim();
358
+ while (current.startsWith("(") && current.endsWith(")")) {
359
+ const close = findMatchingParen(current, 0);
360
+ if (close !== current.length - 1) break;
361
+ current = current.slice(1, -1).trim();
362
+ }
363
+ return current;
364
+ }
365
+
366
+ function findMatchingBrace(source: string, openIndex: number): number {
367
+ return findMatchingDelimiter(source, openIndex, "{", "}");
368
+ }
369
+
370
+ function findMatchingParen(source: string, openIndex: number): number {
371
+ return findMatchingDelimiter(source, openIndex, "(", ")");
372
+ }
373
+
374
+ function findMatchingDelimiter(source: string, openIndex: number, open: string, close: string): number {
375
+ let depth = 0;
376
+ let quote: '"' | "'" | "`" | null = null;
377
+ let lineComment = false;
378
+ let blockComment = false;
379
+
380
+ for (let i = openIndex; i < source.length; i++) {
381
+ const char = source[i];
382
+ const next = source[i + 1];
383
+ const prev = source[i - 1];
384
+
385
+ if (lineComment) {
386
+ if (char === "\n" || char === "\r") lineComment = false;
387
+ continue;
388
+ }
389
+
390
+ if (blockComment) {
391
+ if (char === "*" && next === "/") {
392
+ blockComment = false;
393
+ i++;
394
+ }
395
+ continue;
396
+ }
397
+
398
+ if (quote) {
399
+ if (char === quote && prev !== "\\") quote = null;
400
+ continue;
401
+ }
402
+
403
+ if (char === "/" && next === "/") {
404
+ lineComment = true;
405
+ i++;
406
+ continue;
407
+ }
408
+ if (char === "/" && next === "*") {
409
+ blockComment = true;
410
+ i++;
411
+ continue;
412
+ }
413
+ if (char === '"' || char === "'" || char === "`") {
414
+ quote = char;
415
+ continue;
416
+ }
417
+
418
+ if (char === open) depth++;
419
+ if (char === close) {
420
+ depth--;
421
+ if (depth === 0) return i;
422
+ }
423
+ }
424
+
425
+ return -1;
426
+ }
427
+
428
+ function escapeRegExp(value: string): string {
429
+ return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
430
+ }
431
+
432
+ function isIdentifierChar(value: string): boolean {
433
+ return /[A-Za-z0-9_$]/.test(value);
434
+ }
435
+
436
+ export async function validateClientModuleForBrowserBundle(
437
+ route: RouteSpec,
438
+ rootDir: string,
439
+ ): Promise<string | null> {
440
+ if (!route.clientModule) return null;
441
+
442
+ const source = await readRouteModule(rootDir, route.clientModule);
443
+ if (source === null) return null;
444
+
445
+ if (hasUseServerDirective(source)) {
446
+ return `[${route.id}] Client module "${route.clientModule}" has a "use server" directive and cannot be bundled for the browser.`;
447
+ }
448
+
449
+ if (clientModuleIsRouteComponent(route) && !hasUseClientDirective(source)) {
450
+ const realClientEntry = await resolveRouteLevelClientEntryPath(rootDir, route.clientModule, source);
451
+ if (realClientEntry) {
452
+ route.clientModule = realClientEntry;
453
+ return null;
454
+ }
455
+ return (
456
+ `[${route.id}] Route component "${route.clientModule}" is configured as clientModule, ` +
457
+ `but it is a server page (missing "use client"). Mandu will not bundle server pages into client islands. ` +
458
+ `Remove the stale clientModule from .mandu/routes.manifest.json or use a *.partial.tsx / spec/slots/${route.id}.client.tsx client entry.`
459
+ );
460
+ }
461
+
462
+ return null;
463
+ }
464
+
465
+ async function routeComponentHasResolvableClientEntry(
466
+ rootDir: string,
467
+ routeModule: string,
468
+ source: string,
469
+ ): Promise<boolean> {
470
+ return (await resolveRouteLevelClientEntryPath(rootDir, routeModule, source)) !== null;
471
+ }
472
+
473
+ export async function describeMissingHydrationClientModule(
474
+ route: RouteSpec,
475
+ rootDir: string,
476
+ options: { allowPartialOnly?: boolean } = {},
477
+ ): Promise<string | null> {
478
+ const hydration = route.hydration?.strategy ?? "island";
479
+ const base =
480
+ `[${route.id}] Route has hydration strategy "${hydration}" but no clientModule could be resolved. ` +
481
+ `Mandu cannot emit a working data-mandu-src for this route.`;
482
+
483
+ const componentModule = route.kind === "page" ? route.componentModule : undefined;
484
+ if (!componentModule) {
485
+ return options.allowPartialOnly && hydration === "island" ? null : base;
486
+ }
487
+
488
+ const source = await readRouteModule(rootDir, componentModule);
489
+ if (source === null) {
490
+ return options.allowPartialOnly && hydration === "island" ? null : base;
491
+ }
492
+
493
+ const clientImports = findClientComponentImports(source);
494
+ if (clientImports.length === 0) {
495
+ if (options.allowPartialOnly && hydration === "island") return null;
496
+ return (
497
+ `${base}\n` +
498
+ ` Fix: add a route-level client module (for example app/*.island.tsx or spec/slots/${route.id}.client.tsx) ` +
499
+ `or set hydration.strategy to "none".`
500
+ );
501
+ }
502
+
503
+ const importList = clientImports
504
+ .map((entry) => {
505
+ const suffix = entry.names.length > 0 ? ` (${entry.kind}: ${entry.names.join(", ")})` : ` (${entry.kind})`;
506
+ return ` - ${entry.module}${suffix}`;
507
+ })
508
+ .join("\n");
509
+
510
+ return (
511
+ `${base}\n` +
512
+ ` The page imports client-looking modules, but none was linked into the route manifest:\n` +
513
+ `${importList}\n` +
514
+ ` Fix: run mandu generate with the current source, or set an explicit route-level clientModule.`
515
+ );
516
+ }