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>
166 lines
5.5 KiB
TypeScript
166 lines
5.5 KiB
TypeScript
import type { BunPlugin } from "bun";
|
|
import { createHash } from "node:crypto";
|
|
import { mkdirSync, rmSync, writeFileSync } from "node:fs";
|
|
import { createRequire } from "node:module";
|
|
import { basename, join } from "node:path";
|
|
|
|
export interface IslandInput {
|
|
name: string;
|
|
sourcePath: string;
|
|
}
|
|
|
|
export interface IslandBuildResult {
|
|
assets: Array<{ name: string; hash: string; path: string }>;
|
|
sharedChunks: string[];
|
|
}
|
|
|
|
/**
|
|
* Generates the per-island browser entry.
|
|
*
|
|
* Never imports react-dom/server — islands are client-only.
|
|
*/
|
|
export function generateIslandEntry(input: IslandInput): string {
|
|
return [
|
|
"/** @jsxImportSource react */",
|
|
`import Component from ${JSON.stringify(input.sourcePath)};`,
|
|
`export const name = ${JSON.stringify(input.name)};`,
|
|
`export default Component;`,
|
|
"",
|
|
].join("\n");
|
|
}
|
|
|
|
/**
|
|
* Compiles every island `.tsx` against React's JSX runtime.
|
|
*
|
|
* The repo's root tsconfig sets `jsxImportSource` to `@wrnexus/core`, so an
|
|
* island would otherwise compile to WRNexus's HTML-string renderer and
|
|
* silently never mount. A `@jsxImportSource` pragma applies only to the file
|
|
* that carries it, so putting one in the generated entry does nothing for the
|
|
* author's own component — the injection has to happen per source file, which
|
|
* is what this plugin does. App-authored islands stay plain `.tsx`.
|
|
*/
|
|
export function reactJsxPlugin(): BunPlugin {
|
|
return {
|
|
name: "wrnexus-island-jsx",
|
|
setup(build) {
|
|
build.onLoad({ filter: /\.tsx$/ }, async (args) => {
|
|
// Only first-party island sources need the pragma. Third-party .tsx
|
|
// under node_modules is left alone so Bun's own handling is untouched.
|
|
if (args.path.includes("node_modules")) return undefined;
|
|
return {
|
|
contents: `/** @jsxImportSource react */\n${await Bun.file(args.path).text()}`,
|
|
loader: "tsx" as const,
|
|
};
|
|
});
|
|
},
|
|
};
|
|
}
|
|
|
|
export function assertReactAvailable(
|
|
appRoot: string,
|
|
): { code: "WRN-ISLAND-REACT-MISSING"; message: string; severity: "error" } | null {
|
|
const require = createRequire(join(appRoot, "package.json"));
|
|
try {
|
|
require.resolve("react");
|
|
require.resolve("react-dom");
|
|
return null;
|
|
} catch {
|
|
return {
|
|
code: "WRN-ISLAND-REACT-MISSING",
|
|
severity: "error",
|
|
message:
|
|
"This app imports a .tsx island but react and react-dom are not installed. " +
|
|
"Run: bun add react react-dom",
|
|
};
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Bundles island entries. `splitting: true` is required so React is emitted
|
|
* once as a shared chunk rather than duplicated into every island.
|
|
*/
|
|
export async function buildIslands(input: {
|
|
islands: IslandInput[];
|
|
outDir: string;
|
|
/**
|
|
* App root used to resolve the island mount runtime. When given, the runtime
|
|
* is emitted as `runtime.js` in the SAME build as the islands.
|
|
*
|
|
* This is not a convenience: building the runtime separately gives it its own
|
|
* copy of React, and a component rendered by one copy while importing hooks
|
|
* from another fails with "Cannot read properties of null (reading
|
|
* 'useState')". One build with splitting keeps React in a single shared chunk.
|
|
*/
|
|
appRoot?: string;
|
|
}): Promise<IslandBuildResult> {
|
|
if (input.islands.length === 0) return { assets: [], sharedChunks: [] };
|
|
|
|
// Each island gets its own generated entry file named after the island.
|
|
// Passing the component sources directly would dedupe two islands that share
|
|
// a source file, and output order is not guaranteed to match input order —
|
|
// both of which silently mismatch island names to bundles.
|
|
//
|
|
// The entries live inside outDir so `react` resolves from the app that
|
|
// installed it, exactly as the island's own imports do.
|
|
const entryDir = join(input.outDir, ".entries");
|
|
mkdirSync(entryDir, { recursive: true });
|
|
|
|
const islandNames = new Set(input.islands.map((island) => island.name));
|
|
for (const island of input.islands) {
|
|
writeFileSync(join(entryDir, `${island.name}.tsx`), generateIslandEntry(island), "utf8");
|
|
}
|
|
|
|
const entrypoints = input.islands.map((island) => join(entryDir, `${island.name}.tsx`));
|
|
if (input.appRoot) {
|
|
const resolveFrom = createRequire(join(input.appRoot, "package.json"));
|
|
const runtimeEntry = join(entryDir, "runtime.ts");
|
|
writeFileSync(
|
|
runtimeEntry,
|
|
`export * from ${JSON.stringify(resolveFrom.resolve("@wrnexus/react/browser"))};
|
|
`,
|
|
"utf8",
|
|
);
|
|
entrypoints.push(runtimeEntry);
|
|
}
|
|
|
|
try {
|
|
const result = await Bun.build({
|
|
entrypoints,
|
|
outdir: input.outDir,
|
|
target: "browser",
|
|
format: "esm",
|
|
splitting: true,
|
|
minify: true,
|
|
plugins: [reactJsxPlugin()],
|
|
});
|
|
|
|
if (!result.success) {
|
|
throw new AggregateError(result.logs, "Island bundling failed");
|
|
}
|
|
|
|
const assets: IslandBuildResult["assets"] = [];
|
|
const sharedChunks: string[] = [];
|
|
|
|
for (const output of result.outputs) {
|
|
if (output.kind === "entry-point") {
|
|
// Bun names an entry's output after its entry file, so the basename
|
|
// identifies the island unambiguously.
|
|
const stem = basename(output.path).replace(/\.js$/, "");
|
|
if (stem === "runtime") continue;
|
|
if (!islandNames.has(stem)) continue;
|
|
assets.push({
|
|
name: stem,
|
|
hash: createHash("sha256").update(output.path).digest("hex").slice(0, 16),
|
|
path: output.path,
|
|
});
|
|
} else if (output.kind === "chunk") {
|
|
sharedChunks.push(output.path);
|
|
}
|
|
}
|
|
|
|
return { assets, sharedChunks };
|
|
} finally {
|
|
rmSync(entryDir, { recursive: true, force: true });
|
|
}
|
|
}
|