@mandujs/core 0.54.12 → 0.54.14

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 (80) hide show
  1. package/package.json +9 -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 +49 -1
  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 +8 -0
  11. package/src/agent/verify.ts +100 -2
  12. package/src/brain/doctor/analyzer.ts +7 -7
  13. package/src/bundler/__tests__/build-runner.ts +36 -16
  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/__tests__/fast-refresh.test.ts +24 -17
  17. package/src/bundler/analyzer.ts +15 -15
  18. package/src/bundler/build.test.ts +136 -48
  19. package/src/bundler/build.ts +193 -122
  20. package/src/bundler/css.ts +42 -42
  21. package/src/bundler/manifest-schema.ts +21 -21
  22. package/src/bundler/plugins/__tests__/block-generated-imports.test.ts +13 -13
  23. package/src/bundler/plugins/block-generated-imports.ts +13 -13
  24. package/src/bundler/types.ts +31 -31
  25. package/src/client/island.ts +79 -79
  26. package/src/config/validate.ts +1 -1
  27. package/src/contract/schema.ts +7 -0
  28. package/src/deploy/inference/context.ts +82 -82
  29. package/src/devtools/client/components/panel/islands-panel.tsx +16 -16
  30. package/src/devtools/client/components/panel/panel-container.tsx +1 -1
  31. package/src/error/formatter.ts +10 -1
  32. package/src/experimental/index.ts +10 -0
  33. package/src/filling/context.ts +17 -17
  34. package/src/filling/filling.ts +22 -1
  35. package/src/filling/index.ts +15 -1
  36. package/src/generator/generate.ts +30 -30
  37. package/src/generator/index.ts +3 -3
  38. package/src/generator/templates.ts +210 -210
  39. package/src/guard/check.ts +9 -9
  40. package/src/guard/config-guard.ts +13 -13
  41. package/src/guard/fs-routes-policy.ts +51 -51
  42. package/src/guard/index.ts +11 -11
  43. package/src/index.ts +0 -10
  44. package/src/internal/index.ts +25 -0
  45. package/src/kitchen/api/file-api.ts +11 -11
  46. package/src/report/index.ts +1 -1
  47. package/src/resource/__tests__/generator.test.ts +6 -6
  48. package/src/resource/__tests__/schema.test.ts +14 -14
  49. package/src/resource/ddl/__tests__/emit.test.ts +165 -165
  50. package/src/resource/ddl/emit.ts +146 -146
  51. package/src/resource/generator-schema.ts +11 -11
  52. package/src/resource/generators/slot.ts +72 -72
  53. package/src/resource/schema.ts +21 -21
  54. package/src/router/client-entry.test.ts +84 -41
  55. package/src/router/client-entry.ts +156 -89
  56. package/src/router/fs-routes.test.ts +90 -0
  57. package/src/router/fs-routes.ts +37 -29
  58. package/src/router/fs-scanner.ts +81 -33
  59. package/src/router/fs-types.ts +8 -5
  60. package/src/runtime/__tests__/devtools-adapter.test.ts +68 -68
  61. package/src/runtime/__tests__/observability-lifecycle.test.ts +103 -103
  62. package/src/runtime/__tests__/page-render-response.test.ts +164 -103
  63. package/src/runtime/__tests__/request-middleware.test.ts +70 -70
  64. package/src/runtime/devtools-adapter.ts +68 -68
  65. package/src/runtime/escape.ts +34 -34
  66. package/src/runtime/image-feature.ts +15 -0
  67. package/src/runtime/observability-lifecycle.ts +290 -290
  68. package/src/runtime/page-render-response.ts +208 -106
  69. package/src/runtime/rate-limit.ts +231 -0
  70. package/src/runtime/request-middleware.ts +31 -31
  71. package/src/runtime/router.test.ts +4 -4
  72. package/src/runtime/router.ts +10 -12
  73. package/src/runtime/scheduler-lifecycle.ts +64 -0
  74. package/src/runtime/server.ts +148 -353
  75. package/src/runtime/ssr.ts +59 -59
  76. package/src/runtime/static-files.ts +289 -289
  77. package/src/runtime/streaming-ssr.ts +22 -22
  78. package/src/spec/schema.ts +4 -3
  79. package/src/watcher/__tests__/watcher.test.ts +59 -59
  80. package/src/watcher/watcher.ts +61 -61
@@ -28,13 +28,13 @@ import { describe, it, expect, beforeEach, afterEach } from "bun:test";
28
28
  import { mkdtempSync, writeFileSync, mkdirSync, rmSync } from "fs";
29
29
  import { tmpdir } from "os";
30
30
  import path from "path";
31
- import type { RouteSpec, RoutesManifest } from "../../spec/schema";
32
- import {
33
- _testOnly_scanIslandFiles,
34
- _testOnly_scanPartialFiles,
35
- _testOnly_getHydratedRoutes,
36
- _testOnly_getHydrationRoutesMissingClientModule,
37
- } from "../build";
31
+ import type { RouteSpec, RoutesManifest } from "../../spec/schema";
32
+ import {
33
+ _testOnly_scanIslandFiles,
34
+ _testOnly_scanPartialFiles,
35
+ _testOnly_getHydratedRoutes,
36
+ _testOnly_getHydrationRoutesMissingClientModule,
37
+ } from "../build";
38
38
  import { HMR_PERF } from "../../perf/hmr-markers";
39
39
  import {
40
40
  _resetCacheForTesting as _resetPerfCache,
@@ -374,7 +374,7 @@ describe("Phase 7.1 R1 Agent C — getHydratedRoutes filter", () => {
374
374
  });
375
375
  });
376
376
 
377
- describe("Phase 7.1 R1 Agent C — per-island scan skips non-hydrated routes", () => {
377
+ describe("Phase 7.1 R1 Agent C — per-island scan skips non-hydrated routes", () => {
378
378
  let project: ReturnType<typeof createProject>;
379
379
 
380
380
  beforeEach(() => {
@@ -502,55 +502,55 @@ describe("Phase 7.1 R1 Agent C — per-island scan skips non-hydrated routes", (
502
502
  ];
503
503
  const result = await _testOnly_scanIslandFiles(routes, project.rootDir);
504
504
  expect(result).toEqual([]);
505
- });
506
- });
507
-
508
- describe("hydration route validation", () => {
509
- it("finds pages that request hydration without a client module", () => {
510
- const manifest = {
511
- version: 1,
512
- routes: [
513
- {
514
- id: "login",
515
- pattern: "/login",
516
- kind: "page",
517
- module: "app/login/page.tsx",
518
- componentModule: "app/login/page.tsx",
519
- hydration: { strategy: "full", priority: "immediate", preload: false },
520
- },
521
- pureSsrRoute("about", "/about", "app/about"),
522
- pageRoute("dashboard", "/dashboard", "app/dashboard"),
523
- ],
524
- } as RoutesManifest;
525
-
526
- const missing = _testOnly_getHydrationRoutesMissingClientModule(manifest);
527
- expect(missing.map((route) => route.id)).toEqual(["login"]);
528
- });
529
- });
530
-
531
- describe("partial bundle scan", () => {
532
- let project: ReturnType<typeof createProject>;
533
-
534
- beforeEach(() => {
535
- project = createProject();
536
- });
537
-
538
- afterEach(() => {
539
- try {
540
- rmSync(project.rootDir, { recursive: true, force: true });
541
- } catch {
542
- /* Windows lock tolerance */
543
- }
544
- });
545
-
546
- it("discovers *.partial.tsx files outside .mandu and node_modules", async () => {
547
- project.writeIslandFile("app", "Home.partial.tsx");
548
- project.writeIslandFile(".mandu/client", "Stale.partial.tsx");
549
- project.writeIslandFile("node_modules/pkg", "Ignored.partial.tsx");
550
-
551
- const result = await _testOnly_scanPartialFiles(project.rootDir);
552
-
553
- expect(result.map((entry) => entry.name)).toEqual(["Home"]);
554
- expect(result[0].priority).toBe("visible");
555
- });
556
- });
505
+ });
506
+ });
507
+
508
+ describe("hydration route validation", () => {
509
+ it("finds pages that request hydration without a client module", () => {
510
+ const manifest = {
511
+ version: 1,
512
+ routes: [
513
+ {
514
+ id: "login",
515
+ pattern: "/login",
516
+ kind: "page",
517
+ module: "app/login/page.tsx",
518
+ componentModule: "app/login/page.tsx",
519
+ hydration: { strategy: "full", priority: "immediate", preload: false },
520
+ },
521
+ pureSsrRoute("about", "/about", "app/about"),
522
+ pageRoute("dashboard", "/dashboard", "app/dashboard"),
523
+ ],
524
+ } as RoutesManifest;
525
+
526
+ const missing = _testOnly_getHydrationRoutesMissingClientModule(manifest);
527
+ expect(missing.map((route) => route.id)).toEqual(["login"]);
528
+ });
529
+ });
530
+
531
+ describe("partial bundle scan", () => {
532
+ let project: ReturnType<typeof createProject>;
533
+
534
+ beforeEach(() => {
535
+ project = createProject();
536
+ });
537
+
538
+ afterEach(() => {
539
+ try {
540
+ rmSync(project.rootDir, { recursive: true, force: true });
541
+ } catch {
542
+ /* Windows lock tolerance */
543
+ }
544
+ });
545
+
546
+ it("discovers *.partial.tsx files outside .mandu and node_modules", async () => {
547
+ project.writeIslandFile("app", "Home.partial.tsx");
548
+ project.writeIslandFile(".mandu/client", "Stale.partial.tsx");
549
+ project.writeIslandFile("node_modules/pkg", "Ignored.partial.tsx");
550
+
551
+ const result = await _testOnly_scanPartialFiles(project.rootDir);
552
+
553
+ expect(result.map((entry) => entry.name)).toEqual(["Home"]);
554
+ expect(result[0].priority).toBe("visible");
555
+ });
556
+ });
@@ -1,20 +1,20 @@
1
- import { describe, expect, test } from "bun:test";
2
- import { __private } from "../css";
3
-
4
- describe("CSS Tailwind command resolution", () => {
5
- test("uses process.execPath when it is Bun", () => {
6
- expect(
7
- __private.getTailwindCommand(["@tailwindcss/cli"], "C:\\tools\\bun.exe"),
8
- ).toEqual(["C:\\tools\\bun.exe", "x", "@tailwindcss/cli"]);
9
- });
10
-
11
- test("does not treat standalone mandu.exe as Bun", () => {
12
- expect(
13
- __private.getTailwindCommand(
14
- ["@tailwindcss/cli"],
15
- "C:\\Users\\User\\AppData\\Local\\Mandu\\bin\\mandu.exe",
16
- () => "C:\\Users\\User\\.bun\\bin\\bun.exe",
17
- ),
18
- ).toEqual(["C:\\Users\\User\\.bun\\bin\\bun.exe", "x", "@tailwindcss/cli"]);
19
- });
20
- });
1
+ import { describe, expect, test } from "bun:test";
2
+ import { __private } from "../css";
3
+
4
+ describe("CSS Tailwind command resolution", () => {
5
+ test("uses process.execPath when it is Bun", () => {
6
+ expect(
7
+ __private.getTailwindCommand(["@tailwindcss/cli"], "C:\\tools\\bun.exe"),
8
+ ).toEqual(["C:\\tools\\bun.exe", "x", "@tailwindcss/cli"]);
9
+ });
10
+
11
+ test("does not treat standalone mandu.exe as Bun", () => {
12
+ expect(
13
+ __private.getTailwindCommand(
14
+ ["@tailwindcss/cli"],
15
+ "C:\\Users\\User\\AppData\\Local\\Mandu\\bin\\mandu.exe",
16
+ () => "C:\\Users\\User\\.bun\\bin\\bun.exe",
17
+ ),
18
+ ).toEqual(["C:\\Users\\User\\.bun\\bin\\bun.exe", "x", "@tailwindcss/cli"]);
19
+ });
20
+ });
@@ -19,9 +19,9 @@
19
19
  * packages/core/src/runtime/fast-refresh-runtime.ts
20
20
  */
21
21
 
22
- import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
23
- import { mkdtemp, mkdir, rm, writeFile, readFile } from "fs/promises";
24
- import path from "path";
22
+ import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
23
+ import { mkdtemp, mkdir, rm, writeFile, readFile } from "fs/promises";
24
+ import path from "path";
25
25
 
26
26
  import {
27
27
  appendBoundary,
@@ -40,12 +40,19 @@ import {
40
40
  _isRefreshScheduledForTests,
41
41
  type ReactRefreshRuntime,
42
42
  } from "../../runtime/fast-refresh-runtime";
43
- import {
44
- createManduHot,
45
- dispatchReplacement,
46
- _resetRegistryForTests,
47
- } from "../../runtime/hmr-client";
48
- import { generateFastRefreshPreamble } from "../dev";
43
+ import {
44
+ createManduHot,
45
+ dispatchReplacement,
46
+ _resetRegistryForTests,
47
+ } from "../../runtime/hmr-client";
48
+ import { generateFastRefreshPreamble } from "../dev";
49
+
50
+ const repoTempRoot = path.resolve(import.meta.dir, "../../../../..", ".tmp-test-artifacts");
51
+
52
+ async function mkRepoTempDir(prefix: string): Promise<string> {
53
+ await mkdir(repoTempRoot, { recursive: true });
54
+ return mkdtemp(path.join(repoTempRoot, prefix));
55
+ }
49
56
 
50
57
  // ═══════════════════════════════════════════════════════════════════
51
58
  // Section A — plugin pure unit tests
@@ -263,10 +270,10 @@ describe("manduHMR — __MANDU_HMR__ global behavior", () => {
263
270
  describe.skipIf(process.env.MANDU_SKIP_BUNDLER_TESTS === "1")(
264
271
  "Bun.build({ reactFastRefresh: true }) + fastRefreshPlugin()",
265
272
  () => {
266
- let rootDir: string;
267
-
268
- beforeAll(async () => {
269
- rootDir = await mkdtemp(path.join(import.meta.dir, ".tmp-fr-build-"));
273
+ let rootDir: string;
274
+
275
+ beforeAll(async () => {
276
+ rootDir = await mkRepoTempDir("fr-build-");
270
277
  // Three source files — one boundary, one plain, one .island.tsx —
271
278
  // give us the minimal matrix for the plugin's include filter.
272
279
  await writeFile(
@@ -512,10 +519,10 @@ describe("dispatchReplacement + __MANDU_HMR__ integration", () => {
512
519
  describe.skipIf(process.env.MANDU_SKIP_BUNDLER_TESTS === "1")(
513
520
  "buildVendorShims emits fast-refresh shims in dev mode",
514
521
  () => {
515
- let rootDir: string;
516
-
517
- beforeAll(async () => {
518
- rootDir = await mkdtemp(path.join(import.meta.dir, ".tmp-fr-vendor-"));
522
+ let rootDir: string;
523
+
524
+ beforeAll(async () => {
525
+ rootDir = await mkRepoTempDir("fr-vendor-");
519
526
  await mkdir(path.join(rootDir, "app"), { recursive: true });
520
527
  await writeFile(
521
528
  path.join(rootDir, "package.json"),
@@ -341,26 +341,26 @@ export async function analyzeBundle(
341
341
  deps: entry.dependencies ?? [],
342
342
  });
343
343
  }
344
- for (const [islandName, entry] of Object.entries(manifest.islands ?? {})) {
345
- // Avoid double-counting: if a per-island chunk shares its route id with
346
- // a route-level bundle, we prefer the island entry (finer granularity).
347
- const existing = islandSources.findIndex((s) => s.name === islandName);
344
+ for (const [islandName, entry] of Object.entries(manifest.islands ?? {})) {
345
+ // Avoid double-counting: if a per-island chunk shares its route id with
346
+ // a route-level bundle, we prefer the island entry (finer granularity).
347
+ const existing = islandSources.findIndex((s) => s.name === islandName);
348
348
  if (existing !== -1) islandSources.splice(existing, 1);
349
349
  islandSources.push({
350
350
  name: islandName,
351
351
  url: entry.js,
352
352
  priority: entry.priority,
353
- deps: [],
354
- });
355
- }
356
- for (const [partialName, entry] of Object.entries(manifest.partials ?? {})) {
357
- islandSources.push({
358
- name: `partial:${partialName}`,
359
- url: entry.js,
360
- priority: entry.priority,
361
- deps: [],
362
- });
363
- }
353
+ deps: [],
354
+ });
355
+ }
356
+ for (const [partialName, entry] of Object.entries(manifest.partials ?? {})) {
357
+ islandSources.push({
358
+ name: `partial:${partialName}`,
359
+ url: entry.js,
360
+ priority: entry.priority,
361
+ deps: [],
362
+ });
363
+ }
364
364
 
365
365
  const islands: AnalyzeIsland[] = [];
366
366
  for (const src of islandSources) {
@@ -1,11 +1,17 @@
1
- import { afterAll, beforeAll, describe, expect, test } from "bun:test";
2
- import { mkdtemp, mkdir, readFile, rm, writeFile } from "fs/promises";
3
- import path from "path";
4
- import { pathToFileURL } from "url";
5
-
6
- // 모든 테스트가 하나의 빌드 결과를 공유 — 병렬 Bun.build 충돌 방지
7
- let rootDir: string;
8
- let result: { success: boolean; errors: string[] };
1
+ import { afterAll, beforeAll, describe, expect, test } from "bun:test";
2
+ import { mkdtemp, mkdir, readFile, rm, writeFile } from "fs/promises";
3
+ import path from "path";
4
+ import { pathToFileURL } from "url";
5
+
6
+ // 모든 테스트가 하나의 빌드 결과를 공유 — 병렬 Bun.build 충돌 방지
7
+ let rootDir: string;
8
+ let result: { success: boolean; errors: string[] };
9
+ const repoTempRoot = path.resolve(import.meta.dir, "../../../..", ".tmp-test-artifacts");
10
+
11
+ async function mkRepoTempDir(prefix: string): Promise<string> {
12
+ await mkdir(repoTempRoot, { recursive: true });
13
+ return mkdtemp(path.join(repoTempRoot, prefix));
14
+ }
9
15
 
10
16
  async function importBuiltModule(relativePath: string): Promise<Record<string, unknown>> {
11
17
  const fileUrl = pathToFileURL(path.join(rootDir, relativePath)).href;
@@ -24,10 +30,13 @@ async function importBuiltModule(relativePath: string): Promise<Record<string, u
24
30
  * See `__tests__/build-runner.ts` for the subprocess entrypoint and more
25
31
  * background.
26
32
  */
27
- async function runBuildInSubprocess(root: string, mode?: string): Promise<{
28
- success: boolean;
29
- errors: string[];
30
- }> {
33
+ async function runBuildInSubprocess(root: string, mode?: string): Promise<{
34
+ success: boolean;
35
+ errors: string[];
36
+ manifest?: {
37
+ bundles?: Record<string, { js?: string }>;
38
+ } | null;
39
+ }> {
31
40
  const runner = path.join(
32
41
  import.meta.dir,
33
42
  "__tests__",
@@ -52,25 +61,27 @@ async function runBuildInSubprocess(root: string, mode?: string): Promise<{
52
61
  const last = lines[lines.length - 1] ?? "";
53
62
  try {
54
63
  const parsed = JSON.parse(last);
55
- return {
56
- success: parsed.success === true,
57
- errors: Array.isArray(parsed.errors) ? parsed.errors : [],
58
- };
64
+ return {
65
+ success: parsed.success === true,
66
+ errors: Array.isArray(parsed.errors) ? parsed.errors : [],
67
+ manifest: parsed.manifest ?? null,
68
+ };
59
69
  } catch (e) {
60
- return {
61
- success: false,
62
- errors: [
63
- `build-runner output could not be parsed as JSON: ${String(e)}\nLast stdout line: ${last}`,
64
- ],
65
- };
66
- }
67
- } catch (err) {
68
- return { success: false, errors: [`spawn failed: ${String(err)}`] };
69
- }
70
- }
70
+ return {
71
+ success: false,
72
+ errors: [
73
+ `build-runner output could not be parsed as JSON: ${String(e)}\nLast stdout line: ${last}`,
74
+ ],
75
+ manifest: null,
76
+ };
77
+ }
78
+ } catch (err) {
79
+ return { success: false, errors: [`spawn failed: ${String(err)}`], manifest: null };
80
+ }
81
+ }
71
82
 
72
- beforeAll(async () => {
73
- rootDir = await mkdtemp(path.join(import.meta.dir, ".tmp-bundler-"));
83
+ beforeAll(async () => {
84
+ rootDir = await mkRepoTempDir("bundler-");
74
85
 
75
86
  await mkdir(path.join(rootDir, "app"), { recursive: true });
76
87
  await writeFile(
@@ -186,8 +197,8 @@ describe("buildClientBundles vendor shims", () => {
186
197
  expect(runtimeSource).toContain("JSON.parse");
187
198
  });
188
199
 
189
- test("does not bundle a server page when stale manifest marks page.tsx as clientModule", async () => {
190
- const staleRoot = await mkdtemp(path.join(import.meta.dir, ".tmp-stale-client-module-"));
200
+ test("does not bundle a server page when stale manifest marks page.tsx as clientModule", async () => {
201
+ const staleRoot = await mkRepoTempDir("stale-client-module-");
191
202
  try {
192
203
  await mkdir(path.join(staleRoot, "app"), { recursive: true });
193
204
  await mkdir(path.join(staleRoot, "src", "shared", "contracts"), { recursive: true });
@@ -219,8 +230,8 @@ describe("buildClientBundles vendor shims", () => {
219
230
  }
220
231
  });
221
232
 
222
- test("rewrites route-component clientModule to the real client import before bundling", async () => {
223
- const routeClientRoot = await mkdtemp(path.join(import.meta.dir, ".tmp-route-client-import-"));
233
+ test("rewrites route-component clientModule to the real client import before bundling", async () => {
234
+ const routeClientRoot = await mkRepoTempDir("route-client-import-");
224
235
  try {
225
236
  await mkdir(path.join(routeClientRoot, "app", "login"), { recursive: true });
226
237
  await mkdir(path.join(routeClientRoot, "src", "client", "pages", "login"), { recursive: true });
@@ -237,16 +248,16 @@ describe("buildClientBundles vendor shims", () => {
237
248
  "}\n",
238
249
  "utf-8",
239
250
  );
240
- await writeFile(
241
- path.join(routeClientRoot, "src", "client", "pages", "login", "LoginPage.client.tsx"),
242
- '"use client";\n' +
243
- 'import { useState } from "react";\n' +
244
- "export default function LoginPage() {\n" +
245
- ' const [email, setEmail] = useState("");\n' +
246
- ' return <form><input value={email} onChange={(event) => setEmail(event.currentTarget.value)} /></form>;\n' +
247
- "}\n",
248
- "utf-8",
249
- );
251
+ await writeFile(
252
+ path.join(routeClientRoot, "src", "client", "pages", "login", "LoginPage.client.tsx"),
253
+ '"use client";\n' +
254
+ 'import { useState } from "react";\n' +
255
+ "export default function LoginPage() {\n" +
256
+ ' const [email, setEmail] = useState("");\n' +
257
+ ' return <form><input value={email} onChange={(event) => setEmail(event.currentTarget.value)} /></form>;\n' +
258
+ "}\n",
259
+ "utf-8",
260
+ );
250
261
 
251
262
  const routeClientResult = await runBuildInSubprocess(routeClientRoot, "server-page-route-client-import");
252
263
  expect(routeClientResult.success).toBe(true);
@@ -263,11 +274,88 @@ describe("buildClientBundles vendor shims", () => {
263
274
  expect(parseResult.success).toBe(true);
264
275
  } finally {
265
276
  await rm(routeClientRoot, { recursive: true, force: true });
266
- }
267
- });
268
-
269
- test("fails when hydration is enabled but no clientModule can be resolved", async () => {
270
- const missingRoot = await mkdtemp(path.join(import.meta.dir, ".tmp-hydration-no-client-"));
277
+ }
278
+ });
279
+
280
+ test("bundles route-level named client exports without requiring a default export", async () => {
281
+ const routeClientRoot = await mkRepoTempDir("route-named-client-import-");
282
+ try {
283
+ await mkdir(path.join(routeClientRoot, "app", "login"), { recursive: true });
284
+ await mkdir(path.join(routeClientRoot, "src", "client", "pages", "login"), { recursive: true });
285
+ await writeFile(
286
+ path.join(routeClientRoot, "package.json"),
287
+ JSON.stringify({ name: "mandu-route-named-client-import-test", type: "module" }, null, 2),
288
+ "utf-8",
289
+ );
290
+ await writeFile(
291
+ path.join(routeClientRoot, "app", "login", "page.tsx"),
292
+ 'import { LoginPage } from "@/client/pages/login/LoginPage.client";\n' +
293
+ "export default function Page() {\n" +
294
+ " return <LoginPage />;\n" +
295
+ "}\n",
296
+ "utf-8",
297
+ );
298
+ await writeFile(
299
+ path.join(routeClientRoot, "src", "client", "pages", "login", "LoginPage.client.tsx"),
300
+ '"use client";\n' +
301
+ 'import { useState } from "react";\n' +
302
+ "export function LoginPage() {\n" +
303
+ ' const [email, setEmail] = useState("");\n' +
304
+ ' return <form><input value={email} onChange={(event) => setEmail(event.currentTarget.value)} /></form>;\n' +
305
+ "}\n",
306
+ "utf-8",
307
+ );
308
+
309
+ const routeClientResult = await runBuildInSubprocess(routeClientRoot, "server-page-route-named-client-import");
310
+ if (!routeClientResult.success) {
311
+ console.error("[named route client] errors:", routeClientResult.errors);
312
+ }
313
+ expect(routeClientResult.success).toBe(true);
314
+ const bundlePath = path.join(routeClientRoot, ".mandu", "client", "login.island.js");
315
+ expect(await Bun.file(bundlePath).exists()).toBe(true);
316
+
317
+ const bundleSource = await readFile(bundlePath, "utf-8");
318
+ expect(bundleSource).toContain("LoginPage");
319
+ expect(bundleSource).not.toContain("Client islands cannot use server-side modules");
320
+ const parseResult = await Bun.build({
321
+ entrypoints: [bundlePath],
322
+ target: "browser",
323
+ external: ["react", "react-dom", "react-dom/client", "react/jsx-dev-runtime"],
324
+ });
325
+ expect(parseResult.success).toBe(true);
326
+ } finally {
327
+ await rm(routeClientRoot, { recursive: true, force: true });
328
+ }
329
+ });
330
+
331
+ test("uses Windows-safe asset filenames for locale-prefixed route ids", async () => {
332
+ const localeRoot = await mkRepoTempDir("i18n-locale-route-id-");
333
+ try {
334
+ await mkdir(path.join(localeRoot, "app"), { recursive: true });
335
+ await writeFile(
336
+ path.join(localeRoot, "package.json"),
337
+ JSON.stringify({ name: "mandu-i18n-route-id-test", type: "module" }, null, 2),
338
+ "utf-8",
339
+ );
340
+ await writeFile(
341
+ path.join(localeRoot, "app", "demo.client.tsx"),
342
+ "export default function DemoIsland() { return null; }\n",
343
+ "utf-8",
344
+ );
345
+
346
+ const localeResult = await runBuildInSubprocess(localeRoot, "i18n-locale-route-id");
347
+ expect(localeResult.success).toBe(true);
348
+ const bundle = localeResult.manifest?.bundles?.["ko::demo"];
349
+ expect(bundle?.js).toBe("/.mandu/client/ko_3a__3a_demo.island.js");
350
+ expect(bundle?.js).not.toContain(":");
351
+ expect(await Bun.file(path.join(localeRoot, ".mandu", "client", "ko_3a__3a_demo.island.js")).exists()).toBe(true);
352
+ } finally {
353
+ await rm(localeRoot, { recursive: true, force: true });
354
+ }
355
+ });
356
+
357
+ test("fails when hydration is enabled but no clientModule can be resolved", async () => {
358
+ const missingRoot = await mkRepoTempDir("hydration-no-client-");
271
359
  try {
272
360
  await mkdir(path.join(missingRoot, "app", "login"), { recursive: true });
273
361
  await mkdir(path.join(missingRoot, "src", "client", "widgets", "login-form"), { recursive: true });