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>
142 lines
4.5 KiB
TypeScript
142 lines
4.5 KiB
TypeScript
// 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";
|
|
|
|
export interface IslandDiagnostic {
|
|
code: "WRN-ISLAND-PROPS";
|
|
message: string;
|
|
severity: "error";
|
|
}
|
|
|
|
const STRATEGIES: Record<string, IslandStrategy> = {
|
|
"client:only": "only",
|
|
"client:load": "load",
|
|
"client:visible": "visible",
|
|
"client:idle": "idle",
|
|
};
|
|
|
|
export function parseIslandStrategy(directives: string[]): IslandStrategy {
|
|
for (const directive of directives) {
|
|
const match = STRATEGIES[directive];
|
|
if (match) return match;
|
|
}
|
|
return "only";
|
|
}
|
|
|
|
function unsupportedProp(value: unknown): boolean {
|
|
const type = typeof value;
|
|
if (type === "function" || type === "symbol" || type === "bigint" || type === "undefined") {
|
|
return true;
|
|
}
|
|
if (value === null || type !== "object") return false;
|
|
if (Array.isArray(value)) return (value as unknown[]).some(unsupportedProp);
|
|
const proto = Object.getPrototypeOf(value);
|
|
if (proto !== Object.prototype && proto !== null) return true;
|
|
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>,
|
|
): { json: string } | { diagnostic: IslandDiagnostic } {
|
|
const offenders = Object.entries(props)
|
|
.filter(([, value]) => unsupportedProp(value))
|
|
.map(([key]) => key);
|
|
|
|
if (offenders.length > 0) {
|
|
return {
|
|
diagnostic: {
|
|
code: "WRN-ISLAND-PROPS",
|
|
severity: "error",
|
|
message:
|
|
`Island '${componentName}' received non-serializable prop(s): ${offenders.join(", ")}. ` +
|
|
`Island props cross a serialization boundary and must be JSON-safe ` +
|
|
`(no functions, symbols, bigints, undefined, or class instances).`,
|
|
},
|
|
};
|
|
}
|
|
|
|
return { json: JSON.stringify(props) };
|
|
}
|
|
|
|
export function renderIslandMarker(input: {
|
|
name: string;
|
|
strategy: IslandStrategy;
|
|
propsJson: string;
|
|
}): string {
|
|
// The name becomes a path segment when the browser fetches
|
|
// /__wrnexus/island/<name>.js, so reuse the framework's conservative charset
|
|
// rather than relying on escaping alone.
|
|
if (!isSafeIslandName(input.name)) {
|
|
throw new Error(
|
|
`Island name '${input.name}' is not a safe identifier. ` +
|
|
`Island names may only contain letters, digits, underscores, and hyphens.`,
|
|
);
|
|
}
|
|
|
|
return (
|
|
`<div data-wrn-island="${escapeHtml(input.name)}"` +
|
|
` data-wrn-island-strategy="${input.strategy}"` +
|
|
` 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;
|
|
}
|