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:
@@ -1,4 +1,23 @@
|
||||
import { escapeHtml, isSafeIslandName } from "@wrnexus/core";
|
||||
// Implemented locally rather than imported from @wrnexus/core: compiler modules
|
||||
// are bundled into the Node-only VS Code extension, which contains no other
|
||||
// packages, so a runtime import of core would break the editor compiler.
|
||||
const HTML_ESCAPES: Record<string, string> = {
|
||||
"&": "&",
|
||||
"<": "<",
|
||||
">": ">",
|
||||
'"': """,
|
||||
"'": "'",
|
||||
};
|
||||
|
||||
function escapeHtml(value: string): string {
|
||||
return value.replace(/[&<>"']/g, (character) => HTML_ESCAPES[character]!);
|
||||
}
|
||||
|
||||
const SAFE_ISLAND_NAME = /^[A-Za-z0-9_-]+$/;
|
||||
|
||||
function isSafeIslandName(name: string): boolean {
|
||||
return SAFE_ISLAND_NAME.test(name);
|
||||
}
|
||||
|
||||
export type IslandStrategy = "only" | "load" | "visible" | "idle";
|
||||
|
||||
@@ -35,6 +54,28 @@ function unsupportedProp(value: unknown): boolean {
|
||||
return Object.values(value as Record<string, unknown>).some(unsupportedProp);
|
||||
}
|
||||
|
||||
/**
|
||||
* Interprets an island attribute value with JSX semantics.
|
||||
*
|
||||
* `title="Revenue"` is a string, `start={3}` is a number, `flag` alone is
|
||||
* `true`. Without this every prop arrives as a string, so `start={3}` would be
|
||||
* `"3"` and arithmetic in the island silently concatenates.
|
||||
*
|
||||
* Returns `dynamic` for a `{…}` value that is not JSON: such expressions are
|
||||
* evaluated at runtime and cannot cross the serialization boundary.
|
||||
*/
|
||||
export function islandPropValue(raw: string | undefined): { value: unknown } | { dynamic: string } {
|
||||
if (raw === undefined || raw === "") return { value: true };
|
||||
const expression = /^\{([\s\S]*)\}$/.exec(raw);
|
||||
if (!expression) return { value: raw };
|
||||
const inner = expression[1]!.trim();
|
||||
try {
|
||||
return { value: JSON.parse(inner) as unknown };
|
||||
} catch {
|
||||
return { dynamic: inner };
|
||||
}
|
||||
}
|
||||
|
||||
export function serializeIslandProps(
|
||||
componentName: string,
|
||||
props: Record<string, unknown>,
|
||||
@@ -80,3 +121,21 @@ export function renderIslandMarker(input: {
|
||||
` data-wrn-island-props="${escapeHtml(input.propsJson)}"></div>`
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Local binding names introduced by island imports.
|
||||
*
|
||||
* Codegen sees only component tag names, so it needs the set of names that came
|
||||
* from `.tsx` imports to tell an island apart from a `.wrn` component.
|
||||
*/
|
||||
export function islandNamesFrom(
|
||||
imports: Array<{ kind?: "island"; declaration: { defaultImport?: string } }>,
|
||||
): Set<string> {
|
||||
const names = new Set<string>();
|
||||
for (const entry of imports) {
|
||||
if (entry.kind !== "island") continue;
|
||||
const local = entry.declaration.defaultImport;
|
||||
if (local) names.add(local);
|
||||
}
|
||||
return names;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user