feat(islands): wire islands end to end

The island pieces existed but nothing connected .wrn compilation to island
emission. Now:

- codegen emits a data-wrn-island placeholder for component tags bound to
  .tsx imports, keeping .wrn components on the normal mount path
- the dev pipeline and static build resolve island imports, thread the
  names into codegen, and build the bundles
- collectScripts adds /__wrnexus/islands.js only when island markup is
  present, so island-free pages still ship nothing
- island routes classify as static-interactive via hasIslands

Three bugs found by driving a real page in the browser:

1. The mount runtime was never built anywhere, so the bootstrap 404'd and
   no island mounted.
2. Building the runtime separately from the islands gave each its own copy
   of React: "Cannot read properties of null (reading 'useState')". The
   runtime is now an entrypoint of the same build so React stays in one
   shared chunk. The existing single-React test only compared bundles
   within one build and could not see across build boundaries.
3. Island props arrived as attribute strings, so start={3} was "3" and
   incrementing produced "31" then "311". Props now follow JSX semantics:
   {…} parses as JSON, quoted values stay strings, and a runtime
   expression is a WRN-ISLAND-PROPS build error rather than a silent
   wrong value.

island-codegen.ts no longer imports @wrnexus/core. Compiler modules are
bundled into the Node-only VS Code extension, which contains no other
packages, so a runtime import of core broke the editor compiler; the two
helpers are implemented locally and the core dependency is dropped again.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-18 16:16:03 +05:30
co-authored by Claude Opus 5
parent 442a3106ed
commit 17aa3b98eb
36 changed files with 902 additions and 75 deletions
@@ -1,5 +1,7 @@
import { expect, test } from "bun:test";
import {
islandNamesFrom,
islandPropValue,
parseIslandStrategy,
renderIslandMarker,
serializeIslandProps,
@@ -71,3 +73,28 @@ test("renders a marker with escaped props", () => {
expect(html).toContain("&lt;");
expect(html).toContain("&quot;");
});
test("collects local binding names from island imports only", () => {
const names = islandNamesFrom([
{ kind: "island", declaration: { defaultImport: "Chart" } },
{ declaration: { defaultImport: "Card" } },
{ kind: "island", declaration: {} },
]);
expect([...names]).toEqual(["Chart"]);
});
test("island prop values follow JSX semantics, not raw attribute strings", () => {
// Without this, start={3} arrives as the string "3" and arithmetic inside the
// island concatenates: 3 -> "31" -> "311".
expect(islandPropValue("{3}")).toEqual({ value: 3 });
expect(islandPropValue("{true}")).toEqual({ value: true });
expect(islandPropValue("{[1,2]}")).toEqual({ value: [1, 2] });
expect(islandPropValue('{"a"}')).toEqual({ value: "a" });
expect(islandPropValue("Revenue")).toEqual({ value: "Revenue" });
expect(islandPropValue(undefined)).toEqual({ value: true });
});
test("a runtime expression prop is reported as dynamic", () => {
expect(islandPropValue("{someVariable}")).toEqual({ dynamic: "someVariable" });
expect(islandPropValue("{fn()}")).toEqual({ dynamic: "fn()" });
});