Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4dd7681bf7 | ||
|
|
843db2815f | ||
|
|
17aa3b98eb |
@@ -973,6 +973,10 @@
|
||||
"EffectBlock",
|
||||
"EventDecl",
|
||||
"FormatWrnOptions",
|
||||
"IslandBuildResult",
|
||||
"IslandDiagnostic",
|
||||
"IslandInput",
|
||||
"IslandStrategy",
|
||||
"LexError",
|
||||
"Lexer",
|
||||
"LoadBlock",
|
||||
@@ -999,7 +1003,9 @@
|
||||
"analyzeOptimizations",
|
||||
"analyzeRuntimeImports",
|
||||
"analyzeRuntimeRequirements",
|
||||
"assertReactAvailable",
|
||||
"assertValidAst",
|
||||
"buildIslands",
|
||||
"compilationKey",
|
||||
"compile",
|
||||
"compileNativeWrnFile",
|
||||
@@ -1015,19 +1021,26 @@
|
||||
"generate",
|
||||
"generateBrowserModule",
|
||||
"generateDeclarations",
|
||||
"generateIslandEntry",
|
||||
"generateNative",
|
||||
"generateServerFunctionsModule",
|
||||
"generateStoreBrowserModule",
|
||||
"generateStoreModule",
|
||||
"generateTargets",
|
||||
"inferredRuntimeType",
|
||||
"islandNamesFrom",
|
||||
"islandPropValue",
|
||||
"optimizeAst",
|
||||
"parse",
|
||||
"parseIslandStrategy",
|
||||
"renderIslandMarker",
|
||||
"resolveWrnImport",
|
||||
"resolveWrnImports",
|
||||
"routeNeedsIslands",
|
||||
"rpcManifest",
|
||||
"runtimeCapabilities",
|
||||
"runtimeTypeOf"
|
||||
"runtimeTypeOf",
|
||||
"serializeIslandProps"
|
||||
]
|
||||
},
|
||||
"@wrnexus/content": {
|
||||
@@ -2271,6 +2284,18 @@
|
||||
"StoreResolver",
|
||||
"createSelectorCache",
|
||||
"createSnapshotCache",
|
||||
"discardDetachedRoots",
|
||||
"islandRootCount",
|
||||
"mountIslands",
|
||||
"remountIslands",
|
||||
"setStoreResolver",
|
||||
"unmountIslands",
|
||||
"useWrnActions",
|
||||
"useWrnStore"
|
||||
],
|
||||
"./browser": [
|
||||
"MountOptions",
|
||||
"discardDetachedRoots",
|
||||
"islandRootCount",
|
||||
"mountIslands",
|
||||
"remountIslands",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use strict";
|
||||
// Generated by scripts/build-editor-compiler.mjs. Do not edit directly.
|
||||
// WRN editor compiler source hash: 182fd799ca860d927879d4259c182ea61cbd89d913758cc9f690e1ae4a35d90f
|
||||
// WRN editor compiler generator hash: 2690208ba65bb00d9fea3e08cb3ab324cfda77792021cd46785814fadf41c1bc
|
||||
// WRN editor compiler source hash: fd183ab8c54df72c779d099d7625ce0068e49bea458052335c77cbf31ccf9179
|
||||
// WRN editor compiler generator hash: a54ca847c758bc98d8e353ad6d70088df31de1820f6cf9d1c3462505f563e6b8
|
||||
// Generated with TypeScript: 6.0.3
|
||||
const __nodeRequire = require;
|
||||
const __path = __nodeRequire("node:path");
|
||||
@@ -789,7 +789,46 @@ const types_ts_1 = require("./types.js");
|
||||
const syntax_1 = require("@wrnexus/syntax");
|
||||
const store_codegen_ts_1 = require("./store-codegen.js");
|
||||
const analysis_ts_1 = require("./analysis.js");
|
||||
const island_codegen_ts_1 = require("./island-codegen.js");
|
||||
const client_codegen_ts_1 = require("./client-codegen.js");
|
||||
/**
|
||||
* Island names for the file currently being generated.
|
||||
*
|
||||
* Codegen is a synchronous single pass, so a module-scoped set avoids threading
|
||||
* an extra parameter through every render function. Always reset in generate().
|
||||
*/
|
||||
let currentIslands = new Set();
|
||||
/**
|
||||
* Builds the island placeholder for a component tag that was imported from a
|
||||
* .tsx file. Returns null for ordinary .wrn components.
|
||||
*/
|
||||
function islandMarkerFor(node) {
|
||||
if (!currentIslands.has(node.tag))
|
||||
return null;
|
||||
const directives = node.attrs
|
||||
.map((attr) => attr.name)
|
||||
.filter((name) => name.startsWith("client:"));
|
||||
const props = {};
|
||||
for (const attr of node.attrs) {
|
||||
if (attr.name.startsWith("client:"))
|
||||
continue;
|
||||
const parsed = (0, island_codegen_ts_1.islandPropValue)(attr.value);
|
||||
if ("dynamic" in parsed) {
|
||||
throw new Error(`WRN-ISLAND-PROPS: Island '${node.tag}' received a runtime expression for prop '${attr.name}'. ` +
|
||||
`Island props are serialized at build time, so they must be literal values ` +
|
||||
`(for example start={3} or title="Revenue"), not ${attr.value}.`);
|
||||
}
|
||||
props[attr.name] = parsed.value;
|
||||
}
|
||||
const serialized = (0, island_codegen_ts_1.serializeIslandProps)(node.tag, props);
|
||||
if ("diagnostic" in serialized)
|
||||
throw new Error(serialized.diagnostic.message);
|
||||
return (0, island_codegen_ts_1.renderIslandMarker)({
|
||||
name: node.tag,
|
||||
strategy: (0, island_codegen_ts_1.parseIslandStrategy)(directives),
|
||||
propsJson: serialized.json,
|
||||
});
|
||||
}
|
||||
function isComponentTag(tag) {
|
||||
return /^[A-Z][A-Za-z0-9_$]*$/.test(tag);
|
||||
}
|
||||
@@ -1137,6 +1176,9 @@ function renderLoopBody(node) {
|
||||
escLit("</div>"));
|
||||
}
|
||||
if (componentTag) {
|
||||
const island = islandMarkerFor(node);
|
||||
if (island)
|
||||
return escLit(island);
|
||||
return (escLit(`<div data-component="${attrEscape(node.tag)}"`) +
|
||||
attrs +
|
||||
escLit(">") +
|
||||
@@ -1330,6 +1372,9 @@ function renderNode(node, ssrBindings, csrBindings, apiBindings, loops, reactive
|
||||
return `<${node.tag}${renderAttrs(node.attrs, csrId, reactive, loops)}>${inner}</${node.tag}>`;
|
||||
}
|
||||
function renderPageComponentInvocation(node, ssrBindings, csrBindings, apiBindings, loops, reactive) {
|
||||
const island = islandMarkerFor(node);
|
||||
if (island)
|
||||
return island;
|
||||
const attrs = node.attrs
|
||||
.filter((attr) => attr.name !== "data-component")
|
||||
.map((attr) => renderPageComponentAttr(attr, loops))
|
||||
@@ -1340,6 +1385,10 @@ function renderPageComponentInvocation(node, ssrBindings, csrBindings, apiBindin
|
||||
return `<div data-component="${attrEscape(node.tag)}"` + `${attrs}>${inner}</div>`;
|
||||
}
|
||||
function renderNestedComponentInvocation(node, ctx) {
|
||||
// Islands work inside .wrn components too, not just pages.
|
||||
const island = islandMarkerFor(node);
|
||||
if (island)
|
||||
return island;
|
||||
let bindIndex = 0;
|
||||
const attrs = node.attrs
|
||||
.filter((attr) => attr.name !== "data-component")
|
||||
@@ -1796,7 +1845,16 @@ function markServerAsyncBoundaries(nodes, serverLoads) {
|
||||
markServerAsyncBoundaries(node.children, serverLoads);
|
||||
}
|
||||
}
|
||||
function generate(ast) {
|
||||
function generate(ast, options = {}) {
|
||||
currentIslands = options.islands ?? new Set();
|
||||
try {
|
||||
return generateInner(ast);
|
||||
}
|
||||
finally {
|
||||
currentIslands = new Set();
|
||||
}
|
||||
}
|
||||
function generateInner(ast) {
|
||||
ast = (0, analysis_ts_1.optimizeAst)(ast).ast;
|
||||
if (ast.kind === "global-store" || ast.kind === "page-store")
|
||||
return (0, store_codegen_ts_1.generateStoreModule)(ast);
|
||||
@@ -3184,7 +3242,7 @@ function resolveWrnImports(declarations, importer, options) {
|
||||
* `@wrnexus/syntax` package. This package owns platform-specific codegen.
|
||||
*/
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.DependencyGraph = exports.createCompilationCache = exports.compilationKey = exports.runtimeTypeOf = exports.inferredRuntimeType = exports.eraseFunctionTypes = exports.LexError = exports.Lexer = exports.NativeCompileError = exports.generateNative = exports.runtimeCapabilities = exports.analyzeRuntimeImports = exports.optimizeAst = exports.analyzeRuntimeRequirements = exports.analyzeOptimizations = exports.createWrnSourceMap = exports.resolveWrnImports = exports.resolveWrnImport = exports.createComponentContract = exports.generateStoreModule = exports.generateStoreBrowserModule = exports.generateDeclarations = exports.rpcManifest = exports.generateServerFunctionsModule = exports.generateBrowserModule = exports.generateTargets = exports.generate = exports.ParseError = exports.parse = exports.formatDiagnostic = exports.diagnosticFromError = exports.diagnose = exports.assertValidAst = exports.formatWrn = void 0;
|
||||
exports.routeNeedsIslands = exports.generateIslandEntry = exports.buildIslands = exports.assertReactAvailable = exports.serializeIslandProps = exports.renderIslandMarker = exports.parseIslandStrategy = exports.islandPropValue = exports.islandNamesFrom = exports.DependencyGraph = exports.createCompilationCache = exports.compilationKey = exports.runtimeTypeOf = exports.inferredRuntimeType = exports.eraseFunctionTypes = exports.LexError = exports.Lexer = exports.NativeCompileError = exports.generateNative = exports.runtimeCapabilities = exports.analyzeRuntimeImports = exports.optimizeAst = exports.analyzeRuntimeRequirements = exports.analyzeOptimizations = exports.createWrnSourceMap = exports.resolveWrnImports = exports.resolveWrnImport = exports.createComponentContract = exports.generateStoreModule = exports.generateStoreBrowserModule = exports.generateDeclarations = exports.rpcManifest = exports.generateServerFunctionsModule = exports.generateBrowserModule = exports.generateTargets = exports.generate = exports.ParseError = exports.parse = exports.formatDiagnostic = exports.diagnosticFromError = exports.diagnose = exports.assertValidAst = exports.formatWrn = void 0;
|
||||
exports.compileNativeWrnFile = compileNativeWrnFile;
|
||||
exports.compileWrnFile = compileWrnFile;
|
||||
exports.compile = compile;
|
||||
@@ -3280,6 +3338,281 @@ var cache_ts_1 = require("./cache.js");
|
||||
Object.defineProperty(exports, "compilationKey", { enumerable: true, get: function () { return cache_ts_1.compilationKey; } });
|
||||
Object.defineProperty(exports, "createCompilationCache", { enumerable: true, get: function () { return cache_ts_1.createCompilationCache; } });
|
||||
Object.defineProperty(exports, "DependencyGraph", { enumerable: true, get: function () { return cache_ts_1.DependencyGraph; } });
|
||||
var island_codegen_ts_1 = require("./island-codegen.js");
|
||||
Object.defineProperty(exports, "islandNamesFrom", { enumerable: true, get: function () { return island_codegen_ts_1.islandNamesFrom; } });
|
||||
Object.defineProperty(exports, "islandPropValue", { enumerable: true, get: function () { return island_codegen_ts_1.islandPropValue; } });
|
||||
Object.defineProperty(exports, "parseIslandStrategy", { enumerable: true, get: function () { return island_codegen_ts_1.parseIslandStrategy; } });
|
||||
Object.defineProperty(exports, "renderIslandMarker", { enumerable: true, get: function () { return island_codegen_ts_1.renderIslandMarker; } });
|
||||
Object.defineProperty(exports, "serializeIslandProps", { enumerable: true, get: function () { return island_codegen_ts_1.serializeIslandProps; } });
|
||||
var island_bundle_ts_1 = require("./island-bundle.js");
|
||||
Object.defineProperty(exports, "assertReactAvailable", { enumerable: true, get: function () { return island_bundle_ts_1.assertReactAvailable; } });
|
||||
Object.defineProperty(exports, "buildIslands", { enumerable: true, get: function () { return island_bundle_ts_1.buildIslands; } });
|
||||
Object.defineProperty(exports, "generateIslandEntry", { enumerable: true, get: function () { return island_bundle_ts_1.generateIslandEntry; } });
|
||||
var analysis_ts_2 = require("./analysis.js");
|
||||
Object.defineProperty(exports, "routeNeedsIslands", { enumerable: true, get: function () { return analysis_ts_2.routeNeedsIslands; } });
|
||||
|
||||
},
|
||||
"packages/compiler/src/island-bundle.ts": function (module, exports, require, __filename, __dirname) {
|
||||
"use strict";
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.generateIslandEntry = generateIslandEntry;
|
||||
exports.reactJsxPlugin = reactJsxPlugin;
|
||||
exports.assertReactAvailable = assertReactAvailable;
|
||||
exports.buildIslands = buildIslands;
|
||||
const node_crypto_1 = require("node:crypto");
|
||||
const node_fs_1 = require("node:fs");
|
||||
const node_module_1 = require("node:module");
|
||||
const node_path_1 = require("node:path");
|
||||
/**
|
||||
* Generates the per-island browser entry.
|
||||
*
|
||||
* Never imports react-dom/server — islands are client-only.
|
||||
*/
|
||||
function generateIslandEntry(input) {
|
||||
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`.
|
||||
*/
|
||||
function reactJsxPlugin() {
|
||||
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",
|
||||
};
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
function assertReactAvailable(appRoot) {
|
||||
const require = (0, node_module_1.createRequire)((0, node_path_1.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.
|
||||
*/
|
||||
async function buildIslands(input) {
|
||||
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 = (0, node_path_1.join)(input.outDir, ".entries");
|
||||
(0, node_fs_1.mkdirSync)(entryDir, { recursive: true });
|
||||
const islandNames = new Set(input.islands.map((island) => island.name));
|
||||
for (const island of input.islands) {
|
||||
(0, node_fs_1.writeFileSync)((0, node_path_1.join)(entryDir, `${island.name}.tsx`), generateIslandEntry(island), "utf8");
|
||||
}
|
||||
const entrypoints = input.islands.map((island) => (0, node_path_1.join)(entryDir, `${island.name}.tsx`));
|
||||
if (input.appRoot) {
|
||||
const resolveFrom = (0, node_module_1.createRequire)((0, node_path_1.join)(input.appRoot, "package.json"));
|
||||
const runtimeEntry = (0, node_path_1.join)(entryDir, "runtime.ts");
|
||||
(0, node_fs_1.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 = [];
|
||||
const sharedChunks = [];
|
||||
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 = (0, node_path_1.basename)(output.path).replace(/\.js$/, "");
|
||||
if (stem === "runtime")
|
||||
continue;
|
||||
if (!islandNames.has(stem))
|
||||
continue;
|
||||
assets.push({
|
||||
name: stem,
|
||||
hash: (0, node_crypto_1.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 {
|
||||
(0, node_fs_1.rmSync)(entryDir, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
},
|
||||
"packages/compiler/src/island-codegen.ts": function (module, exports, require, __filename, __dirname) {
|
||||
"use strict";
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.parseIslandStrategy = parseIslandStrategy;
|
||||
exports.islandPropValue = islandPropValue;
|
||||
exports.serializeIslandProps = serializeIslandProps;
|
||||
exports.renderIslandMarker = renderIslandMarker;
|
||||
exports.islandNamesFrom = islandNamesFrom;
|
||||
// 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 = {
|
||||
"&": "&",
|
||||
"<": "<",
|
||||
">": ">",
|
||||
'"': """,
|
||||
"'": "'",
|
||||
};
|
||||
function escapeHtml(value) {
|
||||
return value.replace(/[&<>"']/g, (character) => HTML_ESCAPES[character]);
|
||||
}
|
||||
const SAFE_ISLAND_NAME = /^[A-Za-z0-9_-]+$/;
|
||||
function isSafeIslandName(name) {
|
||||
return SAFE_ISLAND_NAME.test(name);
|
||||
}
|
||||
const STRATEGIES = {
|
||||
"client:only": "only",
|
||||
"client:load": "load",
|
||||
"client:visible": "visible",
|
||||
"client:idle": "idle",
|
||||
};
|
||||
function parseIslandStrategy(directives) {
|
||||
for (const directive of directives) {
|
||||
const match = STRATEGIES[directive];
|
||||
if (match)
|
||||
return match;
|
||||
}
|
||||
return "only";
|
||||
}
|
||||
function unsupportedProp(value) {
|
||||
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.some(unsupportedProp);
|
||||
const proto = Object.getPrototypeOf(value);
|
||||
if (proto !== Object.prototype && proto !== null)
|
||||
return true;
|
||||
return Object.values(value).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.
|
||||
*/
|
||||
function islandPropValue(raw) {
|
||||
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) };
|
||||
}
|
||||
catch {
|
||||
return { dynamic: inner };
|
||||
}
|
||||
}
|
||||
function serializeIslandProps(componentName, props) {
|
||||
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) };
|
||||
}
|
||||
function renderIslandMarker(input) {
|
||||
// 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.
|
||||
*/
|
||||
function islandNamesFrom(imports) {
|
||||
const names = new Set();
|
||||
for (const entry of imports) {
|
||||
if (entry.kind !== "island")
|
||||
continue;
|
||||
const local = entry.declaration.defaultImport;
|
||||
if (local)
|
||||
names.add(local);
|
||||
}
|
||||
return names;
|
||||
}
|
||||
|
||||
},
|
||||
"packages/compiler/src/native-codegen.ts": function (module, exports, require, __filename, __dirname) {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// WRN editor extension source hash: 4d9518778cef65c0400da0e8be9ece65be85acbd581e3024d484b7cbd2fb8116
|
||||
// WRN editor extension source hash: c9938fa5f643ca435ead2c8dd5b545c6906c37c0024cf3ea788666236c28ddb6
|
||||
// WRN editor extension generator hash: 456d1d614e44e5fb1f19b784176c09cf2ade9b64ef73a17934c2698150b62728
|
||||
"use strict";
|
||||
var __commonJS = (cb, mod) => () => (mod || cb((mod = { exports: {} }).exports, mod), mod.exports);
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
import Counter from "../islands/Counter"
|
||||
|
||||
// React island demo. Route: /island-demo
|
||||
//
|
||||
// Counter.tsx is a plain React component: the compiler emits an island
|
||||
// placeholder here instead of a server-rendered component mount, and the
|
||||
// browser mounts it with createRoot.
|
||||
page IslandDemo {
|
||||
seo {
|
||||
title = "Island demo"
|
||||
description = "Mounts a React island inside a server-rendered WRNexus page."
|
||||
canonical = "/island-demo"
|
||||
}
|
||||
|
||||
view {
|
||||
<main>
|
||||
<h1>React island</h1>
|
||||
<Counter start={3} client:visible />
|
||||
</main>
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,7 @@ export interface Routes {
|
||||
"/client-only": Record<string, never>;
|
||||
"/dashboard": Record<string, never>;
|
||||
"/hello": Record<string, never>;
|
||||
"/island-demo": Record<string, never>;
|
||||
"/language-tools": Record<string, never>;
|
||||
"/layout": Record<string, never>;
|
||||
"/login": Record<string, never>;
|
||||
@@ -31,6 +32,7 @@ export interface RouteNames {
|
||||
"client.only": "/client-only";
|
||||
"dashboard": "/dashboard";
|
||||
"hello": "/hello";
|
||||
"island.demo": "/island-demo";
|
||||
"language.tools": "/language-tools";
|
||||
"layout": "/layout";
|
||||
"login": "/login";
|
||||
@@ -122,6 +124,7 @@ export function route<N extends RouteName>(
|
||||
"client.only": "/client-only",
|
||||
"dashboard": "/dashboard",
|
||||
"hello": "/hello",
|
||||
"island.demo": "/island-demo",
|
||||
"language.tools": "/language-tools",
|
||||
"layout": "/layout",
|
||||
"login": "/login",
|
||||
|
||||
+1
-1
@@ -13,7 +13,7 @@ declare namespace WRNexusGenerated {
|
||||
: never;
|
||||
type RealtimeMessage<T> = T extends import("@wrnexus/core").RoomDefinition<any, infer M> ? M : unknown;
|
||||
type QueuePayload<T> = T extends import("@wrnexus/queue").JobDefinition<infer I> ? I : unknown;
|
||||
type RouteName = "about" | "async.data" | "chat" | "client.only" | "dashboard" | "hello" | "index" | "language.tools" | "layout" | "login" | "modal" | "navigation" | "partial.static" | "platform.showcase" | "reactive" | "server.actions" | "table" | "test" | "ui";
|
||||
type RouteName = "about" | "async.data" | "chat" | "client.only" | "dashboard" | "hello" | "index" | "island.demo" | "language.tools" | "layout" | "login" | "modal" | "navigation" | "partial.static" | "platform.showcase" | "reactive" | "server.actions" | "table" | "test" | "ui";
|
||||
type ApiRoute = "/api/accounts" | "/api/echo" | "/api/graphql-example" | "/api/hello" | "/api/invite" | "/api/login" | "/api/logout" | "/api/me" | "/api/typed-user" | "/api/users/csr" | "/api/users/ssr" | "/api/webhooks/payment";
|
||||
type RealtimeRoute = "/realtime/chat" | "/realtime/hello";
|
||||
type EnvironmentKey = "APP_LABEL" | "DATABASE_URL" | "DEMO_SHARED" | "HOST" | "NODE_ENV" | "PORT" | "SESSION_SECRET" | "UAT_ONLY";
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@wrnexus/cli",
|
||||
"version": "0.8.34",
|
||||
"version": "0.8.36",
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
"exports": {
|
||||
|
||||
+51
-10
@@ -28,6 +28,9 @@ import { getIslandRuntime } from "@wrnexus/react/runtime";
|
||||
import {
|
||||
analyzeRuntimeImports,
|
||||
analyzeRuntimeRequirements,
|
||||
assertReactAvailable,
|
||||
buildIslands,
|
||||
islandNamesFrom,
|
||||
assertValidAst,
|
||||
generate,
|
||||
generateTargets,
|
||||
@@ -185,6 +188,8 @@ export async function runBuild(appRoot: string): Promise<void> {
|
||||
const clientFiles = new Map<string, string>();
|
||||
const runtimeAnalysis = new Map<string, RuntimeRequirements>();
|
||||
const partialStaticFiles = new Set<string>();
|
||||
/** Island component name -> resolved .tsx source, collected across all routes. */
|
||||
const discoveredIslands = new Map<string, string>();
|
||||
const compileWrn = async (file: string): Promise<void> => {
|
||||
if (!file.endsWith(".wrn") || compiledFiles.has(file)) return;
|
||||
const source = readFileSync(file, "utf8");
|
||||
@@ -209,7 +214,25 @@ export async function runBuild(appRoot: string): Promise<void> {
|
||||
assertValidAst(ast, { file, accessibility: true });
|
||||
ast = await pluginRunner.transformAst(ast, file);
|
||||
if (ast.renderMode === "partial-static") partialStaticFiles.add(file);
|
||||
runtimeAnalysis.set(file, analyzeRuntimeRequirements(ast));
|
||||
|
||||
// Islands must be known before codegen (to emit markers instead of
|
||||
// component mounts) and before route analysis (an island route ships JS).
|
||||
const fileImports = resolveWrnImports(ast.structuredImports, file, {
|
||||
appRoot: root,
|
||||
mode: config.imports?.mode ?? "compatible",
|
||||
aliases: config.imports?.aliases,
|
||||
});
|
||||
const fileIslands = islandNamesFrom(fileImports);
|
||||
for (const imported of fileImports) {
|
||||
if (imported.kind === "island" && imported.resolved) {
|
||||
discoveredIslands.set(imported.declaration.defaultImport!, imported.resolved);
|
||||
}
|
||||
}
|
||||
|
||||
runtimeAnalysis.set(
|
||||
file,
|
||||
analyzeRuntimeRequirements(ast, { hasIslands: fileIslands.size > 0 }),
|
||||
);
|
||||
const pluginDiagnostics = await pluginRunner.diagnostics(ast, file);
|
||||
const errors = pluginDiagnostics.filter((diagnostic) => diagnostic.severity === "error");
|
||||
for (const diagnostic of pluginDiagnostics.filter((item) => item.severity !== "error")) {
|
||||
@@ -223,10 +246,11 @@ export async function runBuild(appRoot: string): Promise<void> {
|
||||
|
||||
try {
|
||||
const targets = generateTargets(ast);
|
||||
let code = `// compiled from ${fwd(relative(root, file))}\n${generate(ast)}`.replaceAll(
|
||||
"__WRNEXUS_CLIENT_MODULE__",
|
||||
clientUrl,
|
||||
);
|
||||
let code =
|
||||
`// compiled from ${fwd(relative(root, file))}\n${generate(ast, { islands: fileIslands })}`.replaceAll(
|
||||
"__WRNEXUS_CLIENT_MODULE__",
|
||||
clientUrl,
|
||||
);
|
||||
let browserCode = `// browser module compiled from ${fwd(relative(root, file))}\n${targets.browser}`;
|
||||
code = await pluginRunner.transformCode(code, file);
|
||||
browserCode = await pluginRunner.transformCode(browserCode, file);
|
||||
@@ -249,11 +273,7 @@ export async function runBuild(appRoot: string): Promise<void> {
|
||||
);
|
||||
}
|
||||
|
||||
const resolvedImports = resolveWrnImports(ast.structuredImports, file, {
|
||||
appRoot: root,
|
||||
mode: config.imports?.mode ?? "compatible",
|
||||
aliases: config.imports?.aliases,
|
||||
});
|
||||
const resolvedImports = fileImports;
|
||||
for (const imported of resolvedImports) {
|
||||
if (imported.diagnostic?.severity === "error") {
|
||||
throw new Error(`${imported.diagnostic.code}: ${imported.diagnostic.message}`);
|
||||
@@ -505,6 +525,26 @@ export async function runBuild(appRoot: string): Promise<void> {
|
||||
assetHash.update(islandCode);
|
||||
console.log(`✓ Islands: ${islandsPath}`);
|
||||
|
||||
// Island bundles are emitted only when a route actually imported a .tsx, so a
|
||||
// build with no islands produces no React and no island assets at all.
|
||||
const islandsDir = join(distDir, "island");
|
||||
if (discoveredIslands.size > 0) {
|
||||
const missingReact = assertReactAvailable(root);
|
||||
if (missingReact) {
|
||||
throw new Error(`${missingReact.code}: ${missingReact.message}`);
|
||||
}
|
||||
mkdirSync(islandsDir, { recursive: true });
|
||||
const islandBuild = await buildIslands({
|
||||
islands: [...discoveredIslands].map(([name, sourcePath]) => ({ name, sourcePath })),
|
||||
outDir: islandsDir,
|
||||
appRoot: root,
|
||||
});
|
||||
for (const asset of islandBuild.assets) assetHash.update(asset.hash);
|
||||
console.log(
|
||||
`✓ Island bundles: ${islandBuild.assets.length} (${islandBuild.sharedChunks.length} shared chunks)`,
|
||||
);
|
||||
}
|
||||
|
||||
// 1a) Theme tokens + client switcher (always emitted; built-in light/dark).
|
||||
const theme = resolveThemeConfig(config.theme, config.cookies);
|
||||
const themeCss = renderThemeCss(theme);
|
||||
@@ -747,6 +787,7 @@ await createProductionServer(
|
||||
reactivePath: join(import.meta.dir, "reactive.js"),
|
||||
controllersPath: join(import.meta.dir, "controllers.js"),
|
||||
clientModulesDir: join(import.meta.dir, "client"),
|
||||
islandsDir: join(import.meta.dir, "island"),
|
||||
themePath: join(import.meta.dir, "theme.css"),
|
||||
themeAssetsDir: join(import.meta.dir, "theme"),
|
||||
themeJsPath: join(import.meta.dir, "theme.js"),
|
||||
|
||||
@@ -121,16 +121,16 @@ Thumbs.db
|
||||
},
|
||||
"devDependencies": {
|
||||
"@wrnexus/cli": "${cliVersion}",
|
||||
"@eslint/js": "^9.0.0",
|
||||
"@iconify-json/lucide": "^1.2.118",
|
||||
"@eslint/js": "^10.0.1",
|
||||
"@iconify-json/lucide": "^1.2.123",
|
||||
"@iconify/tailwind4": "^1.2.3",
|
||||
"@tailwindcss/cli": "^4.0.0",
|
||||
"@types/bun": "latest",
|
||||
"eslint": "^9.0.0",
|
||||
"prettier": "latest",
|
||||
"tailwindcss": "^4.0.0",
|
||||
"typescript": "^5.5.0",
|
||||
"typescript-eslint": "latest"
|
||||
"@tailwindcss/cli": "^4.3.3",
|
||||
"@types/bun": "^1.3.14",
|
||||
"eslint": "^10.8.1",
|
||||
"prettier": "^3.9.6",
|
||||
"tailwindcss": "^4.3.3",
|
||||
"typescript": "^6.0.3",
|
||||
"typescript-eslint": "^8.67.0"
|
||||
}
|
||||
}
|
||||
`,
|
||||
|
||||
@@ -92,7 +92,7 @@ export async function generateMobile(appRoot: string, options: MobileOptions = {
|
||||
devDependencies: {
|
||||
"@capacitor/cli": "^8.0.0",
|
||||
"@capacitor/assets": "^3.0.0",
|
||||
typescript: "^5.5.0",
|
||||
typescript: "^6.0.3",
|
||||
},
|
||||
};
|
||||
|
||||
@@ -237,7 +237,7 @@ function generateNativeMobile(
|
||||
"react-native-safe-area-context": "^5.6.0",
|
||||
"react-native-screens": "^4.23.0",
|
||||
},
|
||||
devDependencies: { "@types/react": "^19.2.0", typescript: "^5.9.0" },
|
||||
devDependencies: { "@types/react": "^19.2.0", typescript: "^6.0.3" },
|
||||
};
|
||||
const expo = `import type { ExpoConfig } from "expo/config";\nimport appConfig from "../wrnexus.config.ts";\n\nconst mobile = appConfig.mobile ?? {};\nconst config: ExpoConfig = {\n name: mobile.appName ?? ${JSON.stringify(name)},\n slug: ${JSON.stringify(slug(name))},\n scheme: mobile.scheme ?? ${JSON.stringify(scheme)},\n ios: { bundleIdentifier: mobile.appId ?? ${JSON.stringify(appId)} },\n android: { package: mobile.appId ?? ${JSON.stringify(appId)} },\n plugins: ["expo-router"],\n ...(mobile.expo ?? {}),\n};\nexport default config;\n`;
|
||||
const env = `/** Shared connection settings for native screens. */\nexport const API_URL = process.env.EXPO_PUBLIC_WRNEXUS_URL ?? ${JSON.stringify(apiUrl)};\nexport async function api<T>(path: string, init?: RequestInit): Promise<T> {\n const response = await fetch(new URL(path, API_URL), init);\n if (!response.ok) throw new Error(\`WrNexus request failed: \${response.status}\`);\n return response.json() as Promise<T>;\n}\nexport function realtimeUrl(path: string): string {\n const url = new URL(path, API_URL);\n url.protocol = url.protocol === "https:" ? "wss:" : "ws:";\n return url.toString();\n}\n`;
|
||||
|
||||
@@ -53,7 +53,7 @@ export function generateSystem(rootDir: string, input: string): string[] {
|
||||
"@wrnexus/core": "workspace:*",
|
||||
"@wrnexus/plugin": "workspace:*",
|
||||
},
|
||||
devDependencies: { "@types/bun": "latest", typescript: "^5.9.2" },
|
||||
devDependencies: { "@types/bun": "^1.3.14", typescript: "^6.0.3" },
|
||||
wrnexus: {
|
||||
plugin: { plugin: "./src/plugin.ts", export: "default", factory: true },
|
||||
},
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
import { existsSync, mkdirSync, readFileSync, readdirSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { dirname, join, resolve } from "node:path";
|
||||
import { pathToFileURL } from "node:url";
|
||||
import { scaffoldApp } from "./create.ts";
|
||||
import { scaffoldApp, scaffoldFrameworkRange } from "./create.ts";
|
||||
import { currentCliVersion } from "./update-notifier.ts";
|
||||
import type { GatewayAuth, GatewaySecurity } from "@wrnexus/dev-server";
|
||||
|
||||
@@ -88,12 +88,12 @@ export const workspaceFiles = (name: string): Record<string, string> => ({
|
||||
},
|
||||
"devDependencies": {
|
||||
"@wrnexus/cli": "${frameworkVersion}",
|
||||
"@eslint/js": "^9.0.0",
|
||||
"@types/bun": "latest",
|
||||
"eslint": "^9.0.0",
|
||||
"prettier": "latest",
|
||||
"typescript": "^5.5.0",
|
||||
"typescript-eslint": "latest"
|
||||
"@eslint/js": "^10.0.1",
|
||||
"@types/bun": "^1.3.14",
|
||||
"eslint": "^10.8.1",
|
||||
"prettier": "^3.9.6",
|
||||
"typescript": "^6.0.3",
|
||||
"typescript-eslint": "^8.67.0"
|
||||
}
|
||||
}
|
||||
`,
|
||||
@@ -250,7 +250,7 @@ export default tseslint.config(
|
||||
"test": "bun test"
|
||||
},
|
||||
"dependencies": {
|
||||
"@wrnexus/pubsub": "${frameworkVersion}"
|
||||
"@wrnexus/pubsub": "${scaffoldFrameworkRange}"
|
||||
}
|
||||
}
|
||||
`,
|
||||
|
||||
@@ -147,7 +147,7 @@ test("scaffoldApp uses compatible framework packages and pins the current CLI",
|
||||
}
|
||||
expect(pkg.devDependencies["@wrnexus/cli"]).toBe(version);
|
||||
expect(pkg.devDependencies["@iconify/tailwind4"]).toBe("^1.2.3");
|
||||
expect(pkg.devDependencies["@iconify-json/lucide"]).toBe("^1.2.118");
|
||||
expect(pkg.devDependencies["@iconify-json/lucide"]).toBe("^1.2.123");
|
||||
const globalCss = readFileSync(join(root, "app", "styles", "global.css"), "utf8");
|
||||
expect(globalCss).toContain('@plugin "@iconify/tailwind4";');
|
||||
expect(globalCss).toContain('"Plus Jakarta Sans"');
|
||||
|
||||
@@ -69,7 +69,9 @@ export default {
|
||||
}
|
||||
|
||||
if (port === undefined) {
|
||||
throw new Error(`production server failed to start: ${await new Response(proc.stderr).text()}`);
|
||||
throw new Error(
|
||||
`production server failed to start: ${await new Response(proc.stderr).text()}`,
|
||||
);
|
||||
}
|
||||
|
||||
const page = await fetch(`http://127.0.0.1:${port}/`);
|
||||
|
||||
@@ -3,6 +3,7 @@ import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { currentCliVersion } from "../src/update-notifier.ts";
|
||||
import { scaffoldFrameworkRange } from "../src/create.ts";
|
||||
import {
|
||||
addWorkspaceApp,
|
||||
insertWorkspaceApp,
|
||||
@@ -36,14 +37,14 @@ test("workspace environments preserve runtime and HMR policy", () => {
|
||||
expect(resolved.apps[0]?.publicOrigin).toBe("https://www.staging.example.com");
|
||||
});
|
||||
|
||||
test("workspace templates pin the running framework release", () => {
|
||||
test("workspace templates pin the CLI and use compatible independent package ranges", () => {
|
||||
const files = workspaceFiles("acme");
|
||||
const rootPackage = JSON.parse(files["package.json"]!);
|
||||
const sharedPackage = JSON.parse(files["packages/shared/package.json"]!);
|
||||
const version = currentCliVersion();
|
||||
|
||||
expect(rootPackage.devDependencies["@wrnexus/cli"]).toBe(version);
|
||||
expect(sharedPackage.dependencies["@wrnexus/pubsub"]).toBe(version);
|
||||
expect(sharedPackage.dependencies["@wrnexus/pubsub"]).toBe(scaffoldFrameworkRange);
|
||||
expect(files["README.md"]).toContain("http://127.0.0.1:3000");
|
||||
expect(files["README.md"]).toContain("internal gateway targets");
|
||||
expect(JSON.parse(files["package.json"]!).scripts.production).toBe("wrnexus production");
|
||||
|
||||
@@ -7,7 +7,6 @@
|
||||
".": "./src/index.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@wrnexus/core": "workspace:*",
|
||||
"@wrnexus/csr": "workspace:*",
|
||||
"@wrnexus/store": "workspace:*",
|
||||
"@wrnexus/syntax": "workspace:*",
|
||||
|
||||
@@ -22,6 +22,12 @@ import { eraseFunctionTypes, runtimeTypeOf } from "./types.ts";
|
||||
import { stripRuntimeFunctionModifiers } from "@wrnexus/syntax";
|
||||
import { generateStoreModule } from "./store-codegen.ts";
|
||||
import { optimizeAst } from "./analysis.ts";
|
||||
import {
|
||||
islandPropValue,
|
||||
parseIslandStrategy,
|
||||
renderIslandMarker,
|
||||
serializeIslandProps,
|
||||
} from "./island-codegen.ts";
|
||||
import { browserModuleRequired } from "./client-codegen.ts";
|
||||
|
||||
interface RenderBinding {
|
||||
@@ -43,6 +49,48 @@ interface NamedDataBinding extends RenderBinding {
|
||||
mode: DataMode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Island names for the file currently being generated.
|
||||
*
|
||||
* Codegen is a synchronous single pass, so a module-scoped set avoids threading
|
||||
* an extra parameter through every render function. Always reset in generate().
|
||||
*/
|
||||
let currentIslands: ReadonlySet<string> = new Set<string>();
|
||||
|
||||
/**
|
||||
* Builds the island placeholder for a component tag that was imported from a
|
||||
* .tsx file. Returns null for ordinary .wrn components.
|
||||
*/
|
||||
function islandMarkerFor(node: {
|
||||
tag: string;
|
||||
attrs: Array<{ name: string; value?: string }>;
|
||||
}): string | null {
|
||||
if (!currentIslands.has(node.tag)) return null;
|
||||
const directives = node.attrs
|
||||
.map((attr) => attr.name)
|
||||
.filter((name) => name.startsWith("client:"));
|
||||
const props: Record<string, unknown> = {};
|
||||
for (const attr of node.attrs) {
|
||||
if (attr.name.startsWith("client:")) continue;
|
||||
const parsed = islandPropValue(attr.value);
|
||||
if ("dynamic" in parsed) {
|
||||
throw new Error(
|
||||
`WRN-ISLAND-PROPS: Island '${node.tag}' received a runtime expression for prop '${attr.name}'. ` +
|
||||
`Island props are serialized at build time, so they must be literal values ` +
|
||||
`(for example start={3} or title="Revenue"), not ${attr.value}.`,
|
||||
);
|
||||
}
|
||||
props[attr.name] = parsed.value;
|
||||
}
|
||||
const serialized = serializeIslandProps(node.tag, props);
|
||||
if ("diagnostic" in serialized) throw new Error(serialized.diagnostic.message);
|
||||
return renderIslandMarker({
|
||||
name: node.tag,
|
||||
strategy: parseIslandStrategy(directives),
|
||||
propsJson: serialized.json,
|
||||
});
|
||||
}
|
||||
|
||||
function isComponentTag(tag: string): boolean {
|
||||
return /^[A-Z][A-Za-z0-9_$]*$/.test(tag);
|
||||
}
|
||||
@@ -448,6 +496,8 @@ function renderLoopBody(node: ViewNode): string {
|
||||
}
|
||||
|
||||
if (componentTag) {
|
||||
const island = islandMarkerFor(node);
|
||||
if (island) return escLit(island);
|
||||
return (
|
||||
escLit(`<div data-component="${attrEscape(node.tag)}"`) +
|
||||
attrs +
|
||||
@@ -704,6 +754,9 @@ function renderPageComponentInvocation(
|
||||
loops: string[],
|
||||
reactive: PageReactive | null,
|
||||
): string {
|
||||
const island = islandMarkerFor(node);
|
||||
if (island) return island;
|
||||
|
||||
const attrs = node.attrs
|
||||
.filter((attr) => attr.name !== "data-component")
|
||||
.map((attr) => renderPageComponentAttr(attr, loops))
|
||||
@@ -720,6 +773,10 @@ function renderNestedComponentInvocation(
|
||||
node: Extract<ViewNode, { type: "element" }>,
|
||||
ctx: CompCtx,
|
||||
): string {
|
||||
// Islands work inside .wrn components too, not just pages.
|
||||
const island = islandMarkerFor(node);
|
||||
if (island) return island;
|
||||
|
||||
let bindIndex = 0;
|
||||
|
||||
const attrs = node.attrs
|
||||
@@ -1255,7 +1312,21 @@ function markServerAsyncBoundaries(nodes: ViewNode[], serverLoads: ReadonlySet<s
|
||||
}
|
||||
}
|
||||
|
||||
export function generate(ast: PageAst): string {
|
||||
export interface GenerateOptions {
|
||||
/** Local names bound to .tsx island imports in this file. */
|
||||
islands?: ReadonlySet<string>;
|
||||
}
|
||||
|
||||
export function generate(ast: PageAst, options: GenerateOptions = {}): string {
|
||||
currentIslands = options.islands ?? new Set<string>();
|
||||
try {
|
||||
return generateInner(ast);
|
||||
} finally {
|
||||
currentIslands = new Set<string>();
|
||||
}
|
||||
}
|
||||
|
||||
function generateInner(ast: PageAst): string {
|
||||
ast = optimizeAst(ast).ast;
|
||||
if (ast.kind === "global-store" || ast.kind === "page-store") return generateStoreModule(ast);
|
||||
if (ast.kind === "component" || ast.kind === "layout") {
|
||||
|
||||
@@ -126,3 +126,15 @@ export function compile(source: string, filePath = "<inline .wrn>"): CompileResu
|
||||
}
|
||||
export { compilationKey, createCompilationCache, DependencyGraph } from "./cache.ts";
|
||||
export type { CompilationCache, CompilationCacheEntry, CompilationCacheOptions } from "./cache.ts";
|
||||
|
||||
export {
|
||||
islandNamesFrom,
|
||||
islandPropValue,
|
||||
parseIslandStrategy,
|
||||
renderIslandMarker,
|
||||
serializeIslandProps,
|
||||
} from "./island-codegen.ts";
|
||||
export type { IslandDiagnostic, IslandStrategy } from "./island-codegen.ts";
|
||||
export { assertReactAvailable, buildIslands, generateIslandEntry } from "./island-bundle.ts";
|
||||
export type { IslandBuildResult, IslandInput } from "./island-bundle.ts";
|
||||
export { routeNeedsIslands } from "./analysis.ts";
|
||||
|
||||
@@ -82,6 +82,16 @@ export function assertReactAvailable(
|
||||
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: [] };
|
||||
|
||||
@@ -100,9 +110,22 @@ export async function buildIslands(input: {
|
||||
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: input.islands.map((island) => join(entryDir, `${island.name}.tsx`)),
|
||||
entrypoints,
|
||||
outdir: input.outDir,
|
||||
target: "browser",
|
||||
format: "esm",
|
||||
@@ -123,6 +146,7 @@ export async function buildIslands(input: {
|
||||
// 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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -12,9 +12,9 @@ test("a route with an island import needs client JavaScript", () => {
|
||||
});
|
||||
|
||||
test("a route with no island imports stays zero-JS", () => {
|
||||
expect(routeNeedsIslands([{ declaration: { source: "./a" } as any, resolved: "/app/a.ts" }])).toBe(
|
||||
false,
|
||||
);
|
||||
expect(
|
||||
routeNeedsIslands([{ declaration: { source: "./a" } as any, resolved: "/app/a.ts" }]),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
test("an empty import list stays zero-JS", () => {
|
||||
|
||||
@@ -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("<");
|
||||
expect(html).toContain(""");
|
||||
});
|
||||
|
||||
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()" });
|
||||
});
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
import { expect, test } from "bun:test";
|
||||
import { parse } from "@wrnexus/syntax";
|
||||
import { generate } from "../src/codegen.ts";
|
||||
|
||||
const SOURCE = `page Home {
|
||||
view {
|
||||
<Chart title="Revenue" client:visible />
|
||||
<Card>plain</Card>
|
||||
}
|
||||
}`;
|
||||
|
||||
test("an island tag emits an island marker instead of a component mount", () => {
|
||||
const out = generate(parse(SOURCE), { islands: new Set(["Chart"]) });
|
||||
|
||||
expect(out).toContain("data-wrn-island=");
|
||||
expect(out).toContain("Chart");
|
||||
expect(out).toContain('data-wrn-island-strategy="visible"');
|
||||
// Non-island components still mount the normal way.
|
||||
expect(out).toContain('data-component="Card"');
|
||||
});
|
||||
|
||||
test("without the island set the same tag stays a normal component", () => {
|
||||
const out = generate(parse(SOURCE));
|
||||
|
||||
expect(out).not.toContain("data-wrn-island=");
|
||||
expect(out).toContain('data-component="Chart"');
|
||||
});
|
||||
|
||||
test("island props are serialized into the marker", () => {
|
||||
const out = generate(parse(SOURCE), { islands: new Set(["Chart"]) });
|
||||
expect(out).toContain("Revenue");
|
||||
});
|
||||
|
||||
test("numeric and boolean island props keep their types through the marker", () => {
|
||||
const source = `page Home {
|
||||
view { <Chart start={3} live={true} title="Revenue" /> }
|
||||
}`;
|
||||
const out = generate(parse(source), { islands: new Set(["Chart"]) });
|
||||
|
||||
expect(out).toContain(""start":3");
|
||||
expect(out).toContain(""live":true");
|
||||
expect(out).toContain(""title":"Revenue"");
|
||||
});
|
||||
|
||||
test("a runtime expression prop fails the build with WRN-ISLAND-PROPS", () => {
|
||||
const source = `page Home {
|
||||
state count = 1
|
||||
view { <Chart value={count} /> }
|
||||
}`;
|
||||
expect(() => generate(parse(source), { islands: new Set(["Chart"]) })).toThrow(
|
||||
/WRN-ISLAND-PROPS/,
|
||||
);
|
||||
});
|
||||
|
||||
test("an island inside a .wrn component also emits a marker", () => {
|
||||
const source = `component Panel {
|
||||
view { <Chart start={1} /> }
|
||||
}`;
|
||||
const out = generate(parse(source), { islands: new Set(["Chart"]) });
|
||||
expect(out).toContain("data-wrn-island=");
|
||||
expect(out).not.toContain('data-component="Chart"');
|
||||
});
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@wrnexus/dev-server",
|
||||
"version": "0.8.32",
|
||||
"version": "0.8.33",
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
"exports": {
|
||||
|
||||
@@ -43,6 +43,7 @@ import {
|
||||
loadWrnServerModule,
|
||||
setCompileCacheDir,
|
||||
setCompileImportOptions,
|
||||
rebuildChangedIslands,
|
||||
setDevCompilerPipeline,
|
||||
wrnBrowserArtifactUrlAsync,
|
||||
} from "./pipeline.ts";
|
||||
@@ -685,6 +686,18 @@ export async function startServer(opts: ServeOptions): Promise<RunningServer> {
|
||||
|
||||
console.log(`[wrnexus] hot update — ${files.join(", ")}`);
|
||||
await pluginRunner.hook("hmrUpdate", files);
|
||||
// Island .tsx sources are not .wrn files, so nothing below would rebuild
|
||||
// them; page modules are cached, so no compile runs on the next request.
|
||||
const islandFiles = files
|
||||
.map((changed) => (isAbsolute(changed) ? changed : resolve(appDir, changed)))
|
||||
.filter((changed) => changed.endsWith(".tsx"));
|
||||
if (islandFiles.length > 0) {
|
||||
try {
|
||||
await rebuildChangedIslands(islandFiles);
|
||||
} catch (error) {
|
||||
console.warn("[wrnexus] island rebuild failed", error);
|
||||
}
|
||||
}
|
||||
const storeUpdates: Array<{ name: string; url: string; kind: string }> = [];
|
||||
for (const changed of files) {
|
||||
const absolute = isAbsolute(changed) ? changed : resolve(appDir, changed);
|
||||
|
||||
@@ -19,6 +19,8 @@ import {
|
||||
compile,
|
||||
generate,
|
||||
generateTargets,
|
||||
buildIslands,
|
||||
islandNamesFrom,
|
||||
resolveWrnImports,
|
||||
type PageAst,
|
||||
type ViewNode,
|
||||
@@ -126,6 +128,102 @@ function importOptionsHash(file: string): string {
|
||||
return hashPath(JSON.stringify({ ...options, aliases }));
|
||||
}
|
||||
|
||||
/** Island bundles already built this dev session, keyed by resolved source. */
|
||||
const builtIslands = new Map<string, string>();
|
||||
/** Island name -> resolved .tsx source, so the watcher can rebuild on edit. */
|
||||
const islandSources = new Map<string, string>();
|
||||
let islandAppRoot: string | null = null;
|
||||
let islandRuntimeBuilt = false;
|
||||
|
||||
/**
|
||||
* Builds island bundles on demand in dev and registers them under
|
||||
* /__wrnexus/island/. Without this the browser bootstrap 404s and no island
|
||||
* ever mounts.
|
||||
*/
|
||||
async function ensureIslandArtifacts(
|
||||
islands: Array<{ name: string; sourcePath: string }>,
|
||||
appRoot: string,
|
||||
): Promise<void> {
|
||||
if (islands.length === 0) return;
|
||||
const outDir = join(appRoot, ".wrnexus", "island");
|
||||
islandAppRoot = appRoot;
|
||||
for (const island of islands) islandSources.set(island.name, island.sourcePath);
|
||||
|
||||
// Keyed by source AND mtime: keying on the path alone serves a stale bundle
|
||||
// forever once an island .tsx is edited.
|
||||
const stamp = (island: { name: string; sourcePath: string }) => {
|
||||
let mtime: number;
|
||||
try {
|
||||
mtime = statSync(island.sourcePath).mtimeMs;
|
||||
} catch {
|
||||
mtime = 0;
|
||||
}
|
||||
return `${island.sourcePath}:${mtime}`;
|
||||
};
|
||||
const pending = islands.filter((island) => builtIslands.get(island.name) !== stamp(island));
|
||||
if (pending.length === 0 && islandRuntimeBuilt) return;
|
||||
|
||||
// The runtime is built alongside the islands so they share one React copy.
|
||||
const result = await buildIslands({
|
||||
islands: pending.length ? pending : islands,
|
||||
outDir,
|
||||
appRoot,
|
||||
});
|
||||
registerIslandArtifact("/__wrnexus/island/runtime.js", join(outDir, "runtime.js"));
|
||||
islandRuntimeBuilt = true;
|
||||
for (const asset of result.assets) {
|
||||
registerIslandArtifact(`/__wrnexus/island/${asset.name}.js`, asset.path);
|
||||
const rebuilt = pending.find((island) => island.name === asset.name);
|
||||
if (rebuilt) builtIslands.set(asset.name, stamp(rebuilt));
|
||||
}
|
||||
for (const chunk of result.sharedChunks) {
|
||||
registerIslandArtifact(`/__wrnexus/island/${basename(chunk)}`, chunk);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Island imports in this file: names so codegen emits placeholders instead of
|
||||
* component mounts, and sources so the bundles can be built.
|
||||
*/
|
||||
/**
|
||||
* Rebuilds islands whose .tsx source changed.
|
||||
*
|
||||
* Page modules are cached after the first request, so no compile runs on a
|
||||
* later request and nothing else would notice an island edit.
|
||||
*/
|
||||
export async function rebuildChangedIslands(changed: string[]): Promise<boolean> {
|
||||
if (!islandAppRoot) return false;
|
||||
const touched = new Set(changed.map((file) => resolve(file)));
|
||||
const affected = [...islandSources]
|
||||
.filter(([, sourcePath]) => touched.has(resolve(sourcePath)))
|
||||
.map(([name, sourcePath]) => ({ name, sourcePath }));
|
||||
if (affected.length === 0) return false;
|
||||
await ensureIslandArtifacts(affected, islandAppRoot);
|
||||
return true;
|
||||
}
|
||||
|
||||
function islandsForFile(
|
||||
ast: PageAst,
|
||||
importer: string,
|
||||
): { names: Set<string>; inputs: Array<{ name: string; sourcePath: string }> } {
|
||||
if (!ast.structuredImports.length) return { names: new Set(), inputs: [] };
|
||||
const root = projectRootForFile(importer);
|
||||
const importOptions = compileImportOptions.get(resolve(root)) ?? {
|
||||
mode: "compatible" as const,
|
||||
aliases: { "@": "./app" },
|
||||
autoImport: true,
|
||||
};
|
||||
const resolved = resolveWrnImports(ast.structuredImports, importer, {
|
||||
appRoot: root,
|
||||
mode: importOptions.mode,
|
||||
aliases: importOptions.aliases,
|
||||
});
|
||||
const inputs = resolved
|
||||
.filter((entry) => entry.kind === "island" && entry.resolved && entry.declaration.defaultImport)
|
||||
.map((entry) => ({ name: entry.declaration.defaultImport!, sourcePath: entry.resolved! }));
|
||||
return { names: islandNamesFrom(resolved), inputs };
|
||||
}
|
||||
|
||||
function rewriteArtifactImports(
|
||||
code: string,
|
||||
ast: PageAst,
|
||||
@@ -396,6 +494,8 @@ export interface WrnCompileArtifacts {
|
||||
declarations: string;
|
||||
contract: string;
|
||||
rpc: string;
|
||||
/** Island inputs for this file, so a cache hit can still build islands. */
|
||||
islands: string;
|
||||
}
|
||||
|
||||
export interface WrnCompileMetrics {
|
||||
@@ -486,15 +586,20 @@ export function compileWrnArtifactsAsync(file: string, version = 0): Promise<Wrn
|
||||
declarations: join(cacheDir, `${stem}.d.ts`),
|
||||
contract: join(cacheDir, `${stem}.contract.json`),
|
||||
rpc: join(cacheDir, `${stem}.rpc.json`),
|
||||
islands: join(cacheDir, `${stem}.islands.json`),
|
||||
};
|
||||
const result = compile(source, file);
|
||||
validateConfiguredImports(source, result.ast, file);
|
||||
const ast = await devCompilerPipeline!.transformAst(result.ast, file);
|
||||
const { names: islands, inputs: islandInputs } = islandsForFile(ast, file);
|
||||
mkdirSync(cacheDir, { recursive: true });
|
||||
writeFileSync(artifacts.islands, JSON.stringify(islandInputs), "utf8");
|
||||
await ensureIslandArtifacts(islandInputs, projectRootForFile(file));
|
||||
const targets = generateTargets(ast);
|
||||
mkdirSync(cacheDir, { recursive: true });
|
||||
const browserPath = `/__wrnexus/client/${stem}.mjs`;
|
||||
const outputs = {
|
||||
main: `// compiled from .wrn\n${generate(ast)}`.replaceAll(
|
||||
main: `// compiled from .wrn\n${generate(ast, { islands })}`.replaceAll(
|
||||
"__WRNEXUS_CLIENT_MODULE__",
|
||||
browserPath,
|
||||
),
|
||||
@@ -555,13 +660,36 @@ export function compileWrnArtifacts(file: string, version = 0): WrnCompileArtifa
|
||||
declarations: join(cacheDir, `${stem}.d.ts`),
|
||||
contract: join(cacheDir, `${stem}.contract.json`),
|
||||
rpc: join(cacheDir, `${stem}.rpc.json`),
|
||||
islands: join(cacheDir, `${stem}.islands.json`),
|
||||
};
|
||||
compileInProgress.set(file, artifacts);
|
||||
try {
|
||||
try {
|
||||
if (Object.values(artifacts).every((path) => statSync(path).isFile())) {
|
||||
// The islands manifest is written only by the async compile path, so it
|
||||
// is not part of the completeness check — a missing manifest means "no
|
||||
// islands known for this file", not a stale cache.
|
||||
const requiredArtifacts = Object.entries(artifacts)
|
||||
.filter(([key]) => key !== "islands")
|
||||
.map(([, path]) => path);
|
||||
if (requiredArtifacts.every((path) => statSync(path).isFile())) {
|
||||
compileMetrics.hits++;
|
||||
browserArtifactPaths.set(`/__wrnexus/client/${stem}.mjs`, artifacts.browser);
|
||||
// A cached .wrn still needs its island bundles: the .tsx may have changed
|
||||
// since, and after a restart with a warm cache nothing else would build them.
|
||||
let cachedIslands: Array<{ name: string; sourcePath: string }> = [];
|
||||
try {
|
||||
cachedIslands = JSON.parse(readFileSync(artifacts.islands, "utf8")) as Array<{
|
||||
name: string;
|
||||
sourcePath: string;
|
||||
}>;
|
||||
} catch {
|
||||
cachedIslands = [];
|
||||
}
|
||||
// This variant is synchronous, so the rebuild is kicked off rather than
|
||||
// awaited. The async compile path awaits it before serving a page.
|
||||
void ensureIslandArtifacts(cachedIslands, projectRootForFile(file)).catch((error) => {
|
||||
console.warn("[wrnexus] island rebuild failed", error);
|
||||
});
|
||||
return artifacts;
|
||||
}
|
||||
} catch {
|
||||
|
||||
@@ -674,10 +674,10 @@ export const HMR_CLIENT_JS = `
|
||||
|
||||
var i18nScript = Array.prototype.find.call(
|
||||
doc.querySelectorAll("script:not([src])"),
|
||||
function (node) { return /^window\.__wrnI18n=/.test(String(node.textContent || "").trim()); },
|
||||
function (node) { return /^window[.]__wrnI18n=/.test(String(node.textContent || "").trim()); },
|
||||
);
|
||||
if (i18nScript) {
|
||||
var i18nMatch = /^window\.__wrnI18n=([\s\S]*);\s*$/.exec(String(i18nScript.textContent || "").trim());
|
||||
var i18nMatch = /^window[.]__wrnI18n=([^]*);\\s*$/.exec(String(i18nScript.textContent || "").trim());
|
||||
if (i18nMatch) {
|
||||
try {
|
||||
var incomingI18n = JSON.parse(i18nMatch[1]);
|
||||
@@ -805,6 +805,30 @@ export const HMR_CLIENT_JS = `
|
||||
// Preserve a hydrated subtree only while its server hydration signature and
|
||||
// behavior are unchanged. Component edits must replace and re-hydrate the
|
||||
// old subtree or HMR will keep stale markup indefinitely.
|
||||
// HMR fetches fresh HTML whose inline scripts carry a NEW server nonce, but a
|
||||
// document's CSP nonce is fixed at load and cannot be updated. Any node moved
|
||||
// across therefore has to be re-stamped with the live document's nonce or the
|
||||
// browser blocks it.
|
||||
function adoptNonce(node) {
|
||||
var nonce = currentDocumentNonce();
|
||||
if (!nonce || !node || node.nodeType !== 1) return node;
|
||||
var stamp = function (element) {
|
||||
if (element.getAttribute("src")) return;
|
||||
element.setAttribute("nonce", nonce);
|
||||
try {
|
||||
element.nonce = nonce;
|
||||
} catch (error) {
|
||||
// Read-only in some engines; the attribute above is what CSP checks.
|
||||
}
|
||||
};
|
||||
if (node.nodeName === "SCRIPT" || node.nodeName === "STYLE") stamp(node);
|
||||
if (node.querySelectorAll) {
|
||||
var nested = node.querySelectorAll("script,style");
|
||||
for (var i = 0; i < nested.length; i++) stamp(nested[i]);
|
||||
}
|
||||
return node;
|
||||
}
|
||||
|
||||
function morph(from, to) {
|
||||
if (from.__wrnexusHydrated) {
|
||||
var sameHydration =
|
||||
@@ -813,16 +837,16 @@ export const HMR_CLIENT_JS = `
|
||||
from.getAttribute("data-scope") === to.getAttribute("data-scope");
|
||||
if (sameHydration) return;
|
||||
if (window.__wrnexusDisposeBehaviors) window.__wrnexusDisposeBehaviors(from);
|
||||
from.replaceWith(to.cloneNode(true));
|
||||
from.replaceWith(adoptNonce(to.cloneNode(true)));
|
||||
return;
|
||||
}
|
||||
syncAttrs(from, to);
|
||||
var fc = from.childNodes, tc = to.childNodes, i;
|
||||
for (i = 0; i < tc.length; i++) {
|
||||
var t = tc[i], f = fc[i];
|
||||
if (!f) { from.appendChild(t.cloneNode(true)); continue; }
|
||||
if (!f) { from.appendChild(adoptNonce(t.cloneNode(true))); continue; }
|
||||
if (f.nodeType !== t.nodeType || (f.nodeType === 1 && f.nodeName !== t.nodeName)) {
|
||||
from.replaceChild(t.cloneNode(true), f); continue;
|
||||
from.replaceChild(adoptNonce(t.cloneNode(true)), f); continue;
|
||||
}
|
||||
if (f.nodeType === 3 || f.nodeType === 8) { if (f.nodeValue !== t.nodeValue) f.nodeValue = t.nodeValue; continue; }
|
||||
if (f.nodeType === 1) morph(f, t);
|
||||
@@ -831,8 +855,10 @@ export const HMR_CLIENT_JS = `
|
||||
}
|
||||
function syncAttrs(from, to) {
|
||||
var ta = to.attributes, fa = from.attributes, i;
|
||||
for (i = 0; i < ta.length; i++) if (from.getAttribute(ta[i].name) !== ta[i].value) from.setAttribute(ta[i].name, ta[i].value);
|
||||
for (i = fa.length - 1; i >= 0; i--) if (!to.hasAttribute(fa[i].name)) from.removeAttribute(fa[i].name);
|
||||
// Never copy the incoming nonce: it belongs to the fetched document and
|
||||
// would replace the live nonce this document's CSP actually allows.
|
||||
for (i = 0; i < ta.length; i++) if (ta[i].name !== "nonce" && from.getAttribute(ta[i].name) !== ta[i].value) from.setAttribute(ta[i].name, ta[i].value);
|
||||
for (i = fa.length - 1; i >= 0; i--) if (fa[i].name !== "nonce" && !to.hasAttribute(fa[i].name)) from.removeAttribute(fa[i].name);
|
||||
}
|
||||
|
||||
// Exposed for tests; harmless (the client is injected only in dev).
|
||||
|
||||
@@ -19,6 +19,9 @@ export function collectScripts(
|
||||
) {
|
||||
scripts.push("/__wrnexus/reactive.js");
|
||||
}
|
||||
// Islands ship their own bootstrap, not the reactive runtime — a page whose
|
||||
// only interactivity is an island must not pull in WRNexus's client runtime.
|
||||
if (/\bdata-wrn-island=/.test(body)) scripts.push("/__wrnexus/islands.js");
|
||||
if (/\bdata-wrn-action=/.test(body)) scripts.push("/__wrnexus/actions.js");
|
||||
if (
|
||||
/\bdata-wrn-theme-(toggle|set)\b/.test(body) ||
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
import { expect, test } from "bun:test";
|
||||
import { HMR_CLIENT_JS } from "../src/runtime.ts";
|
||||
|
||||
test("the HMR client script is syntactically valid JavaScript", () => {
|
||||
// HMR_CLIENT_JS is a TypeScript template literal, so an escape like \t or \n
|
||||
// written unescaped is expanded by TypeScript into a real control character.
|
||||
// Inside a regex literal that produces a raw newline, which is a syntax error
|
||||
// that silently kills the whole HMR client in the browser.
|
||||
expect(() => new Function(HMR_CLIENT_JS)).not.toThrow();
|
||||
});
|
||||
|
||||
test("the HMR client contains no raw control characters inside regex literals", () => {
|
||||
const regexLiterals = HMR_CLIENT_JS.match(/\/(?![/*])(?:\.|\[[^\]]*\]|[^/\n\r])+\//g) ?? [];
|
||||
expect(regexLiterals.length).toBeGreaterThan(0);
|
||||
for (const literal of regexLiterals) {
|
||||
expect(literal).not.toMatch(/[\n\r\t]/);
|
||||
}
|
||||
});
|
||||
@@ -14,5 +14,6 @@ test("HMR replaces hydrated components when their server signature changes", ()
|
||||
expect(HMR_CLIENT_JS).toContain('from.getAttribute("data-wrn-behavior")');
|
||||
expect(HMR_CLIENT_JS).toContain('from.getAttribute("data-scope")');
|
||||
expect(HMR_CLIENT_JS).toContain("window.__wrnexusDisposeBehaviors(from)");
|
||||
expect(HMR_CLIENT_JS).toContain("from.replaceWith(to.cloneNode(true))");
|
||||
// Cloned nodes are re-stamped with the live document nonce before insertion.
|
||||
expect(HMR_CLIENT_JS).toContain("from.replaceWith(adoptNonce(to.cloneNode(true)))");
|
||||
});
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
import { afterAll, expect, test } from "bun:test";
|
||||
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { compile, generate, islandNamesFrom, resolveWrnImports } from "@wrnexus/compiler";
|
||||
import { collectScripts } from "../src/script-selection.ts";
|
||||
|
||||
const created: string[] = [];
|
||||
afterAll(() => {
|
||||
for (const dir of created) rmSync(dir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
function app() {
|
||||
const root = mkdtempSync(join(process.cwd(), ".island-e2e-"));
|
||||
created.push(root);
|
||||
mkdirSync(join(root, "app"), { recursive: true });
|
||||
writeFileSync(
|
||||
join(root, "app", "Chart.tsx"),
|
||||
`export default function Chart({ title }: { title: string }) { return <div>{title}</div>; }`,
|
||||
);
|
||||
writeFileSync(join(root, "app", "Card.wrn"), `component Card { view { <div>card</div> } }`);
|
||||
return root;
|
||||
}
|
||||
|
||||
test("a .wrn importing a .tsx emits an island marker and requests the bootstrap", () => {
|
||||
const root = app();
|
||||
const page = join(root, "app", "page.wrn");
|
||||
const source = [
|
||||
'import Chart from "./Chart"',
|
||||
'import Card from "./Card"',
|
||||
"page Home {",
|
||||
" view {",
|
||||
' <Chart title="Revenue" client:visible />',
|
||||
" <Card />",
|
||||
" }",
|
||||
"}",
|
||||
].join("\n");
|
||||
writeFileSync(page, source);
|
||||
|
||||
const ast = compile(source, page).ast;
|
||||
const islands = islandNamesFrom(
|
||||
resolveWrnImports(ast.structuredImports, page, { appRoot: root }),
|
||||
);
|
||||
|
||||
// Only the .tsx import is an island; the .wrn component is not.
|
||||
expect([...islands]).toEqual(["Chart"]);
|
||||
|
||||
const out = generate(ast, { islands });
|
||||
expect(out).toContain('data-wrn-island="Chart"');
|
||||
expect(out).toContain('data-wrn-island-strategy="visible"');
|
||||
expect(out).toContain('data-component="Card"');
|
||||
|
||||
// Rendered island markup must pull in the island bootstrap.
|
||||
expect(collectScripts('<div data-wrn-island="Chart"></div>')).toContain("/__wrnexus/islands.js");
|
||||
});
|
||||
|
||||
test("a page with no .tsx imports emits no island markup and no island script", () => {
|
||||
const root = app();
|
||||
const page = join(root, "app", "plain.wrn");
|
||||
const source = ['import Card from "./Card"', "page Plain {", " view { <Card /> }", "}"].join(
|
||||
"\n",
|
||||
);
|
||||
writeFileSync(page, source);
|
||||
|
||||
const ast = compile(source, page).ast;
|
||||
const islands = islandNamesFrom(
|
||||
resolveWrnImports(ast.structuredImports, page, { appRoot: root }),
|
||||
);
|
||||
expect(islands.size).toBe(0);
|
||||
|
||||
const out = generate(ast, { islands });
|
||||
expect(out).not.toContain("data-wrn-island");
|
||||
expect(collectScripts(out)).not.toContain("/__wrnexus/islands.js");
|
||||
});
|
||||
@@ -0,0 +1,16 @@
|
||||
import { expect, test } from "bun:test";
|
||||
import { collectScripts } from "../src/script-selection.ts";
|
||||
|
||||
test("island markup pulls in the island bootstrap", () => {
|
||||
const scripts = collectScripts(`<div data-wrn-island="Chart"></div>`);
|
||||
expect(scripts).toContain("/__wrnexus/islands.js");
|
||||
});
|
||||
|
||||
test("markup without islands ships no island bootstrap", () => {
|
||||
expect(collectScripts(`<p>plain server html</p>`)).toEqual([]);
|
||||
});
|
||||
|
||||
test("islands alone do not pull in the reactive runtime", () => {
|
||||
const scripts = collectScripts(`<div data-wrn-island="Chart"></div>`);
|
||||
expect(scripts).not.toContain("/__wrnexus/reactive.js");
|
||||
});
|
||||
@@ -22,7 +22,10 @@ function customNotFoundRuntime() {
|
||||
router: buildRouter(app),
|
||||
loadModule: async (file) =>
|
||||
file.includes(`${join("api", "404")}.ts`)
|
||||
? { GET: () => Response.json({ code: "CUSTOM_NOT_FOUND" }, { headers: { "x-custom": "yes" } }) }
|
||||
? {
|
||||
GET: () =>
|
||||
Response.json({ code: "CUSTOM_NOT_FOUND" }, { headers: { "x-custom": "yes" } }),
|
||||
}
|
||||
: { default: () => "<main><h1>That page is gone</h1></main>" },
|
||||
getMiddleware: async () => [],
|
||||
assets: { serve: async () => null },
|
||||
|
||||
@@ -5,15 +5,20 @@
|
||||
"main": "src/index.ts",
|
||||
"exports": {
|
||||
".": "./src/index.ts",
|
||||
"./runtime": "./src/runtime-source.ts"
|
||||
"./runtime": "./src/runtime-source.ts",
|
||||
"./browser": "./src/browser.ts"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"react": { "optional": true },
|
||||
"react-dom": { "optional": true }
|
||||
"react": {
|
||||
"optional": true
|
||||
},
|
||||
"react-dom": {
|
||||
"optional": true
|
||||
}
|
||||
},
|
||||
"dependencies": {
|
||||
"@wrnexus/store": "workspace:*"
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
/**
|
||||
* Browser entry for the island mount runtime, served at
|
||||
* `/__wrnexus/island/runtime.js` and imported by the bootstrap only when a
|
||||
* `data-wrn-island` marker is present.
|
||||
*/
|
||||
export {
|
||||
discardDetachedRoots,
|
||||
islandRootCount,
|
||||
mountIslands,
|
||||
remountIslands,
|
||||
unmountIslands,
|
||||
} from "./island-runtime.ts";
|
||||
export type { MountOptions } from "./island-runtime.ts";
|
||||
export { setStoreResolver, useWrnActions, useWrnStore } from "./store-bridge.ts";
|
||||
@@ -41,4 +41,4 @@ export class IslandErrorBoundary extends Component<
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,4 +6,4 @@ export { IslandErrorBoundary } from "./error-boundary.tsx";
|
||||
export type { IslandErrorBoundaryProps } from "./error-boundary.tsx";
|
||||
export { islandRootCount, mountIslands, unmountIslands } from "./island-runtime.ts";
|
||||
export type { MountOptions } from "./island-runtime.ts";
|
||||
export { remountIslands } from "./island-runtime.ts";
|
||||
export { discardDetachedRoots, remountIslands } from "./island-runtime.ts";
|
||||
|
||||
@@ -5,6 +5,14 @@ import { IslandErrorBoundary } from "./error-boundary.tsx";
|
||||
export interface MountOptions {
|
||||
loader: (name: string) => Promise<{ default: ComponentType<any> }>;
|
||||
development?: boolean;
|
||||
/**
|
||||
* Re-render islands that are already mounted instead of skipping them.
|
||||
*
|
||||
* Used by HMR: the container element usually survives the morph, and React
|
||||
* refuses a second `createRoot` on the same container, so the existing root
|
||||
* has to be re-rendered rather than replaced.
|
||||
*/
|
||||
remount?: boolean;
|
||||
}
|
||||
|
||||
const roots = new Map<Element, Root>();
|
||||
@@ -43,7 +51,7 @@ function whenReady(element: Element, strategy: string): Promise<void> {
|
||||
}
|
||||
|
||||
async function mountOne(element: Element, options: MountOptions): Promise<void> {
|
||||
if (roots.has(element)) return;
|
||||
if (roots.has(element) && !options.remount) return;
|
||||
|
||||
const name = element.getAttribute("data-wrn-island");
|
||||
if (!name) return;
|
||||
@@ -53,7 +61,7 @@ async function mountOne(element: Element, options: MountOptions): Promise<void>
|
||||
|
||||
// Re-check: an await point means a concurrent mount may have claimed this
|
||||
// element while the strategy was resolving.
|
||||
if (roots.has(element)) return;
|
||||
if (roots.has(element) && !options.remount) return;
|
||||
|
||||
let Component: ComponentType<any>;
|
||||
try {
|
||||
@@ -63,9 +71,11 @@ async function mountOne(element: Element, options: MountOptions): Promise<void>
|
||||
return;
|
||||
}
|
||||
|
||||
if (roots.has(element)) return;
|
||||
if (roots.has(element) && !options.remount) return;
|
||||
|
||||
const root = createRoot(element);
|
||||
// Reuse an existing root: React rejects a second createRoot on the same
|
||||
// container, and HMR keeps the container across a morph.
|
||||
const root = roots.get(element) ?? createRoot(element);
|
||||
roots.set(element, root);
|
||||
root.render(
|
||||
createElement(
|
||||
@@ -102,14 +112,41 @@ export function unmountIslands(root: ParentNode): void {
|
||||
}
|
||||
|
||||
/**
|
||||
* Dev-only: dispose and re-create island roots after a source change.
|
||||
* Dev-only: re-render islands after a source change.
|
||||
*
|
||||
* Island state resets by design — Fast Refresh needs a Babel/SWC transform plus
|
||||
* a runtime and is out of scope. `unmountIslands` clears each element from the
|
||||
* root registry, so the following `mountIslands` is not short-circuited by the
|
||||
* already-mounted guard.
|
||||
* a runtime and is out of scope.
|
||||
*/
|
||||
export async function remountIslands(root: ParentNode, options: MountOptions): Promise<void> {
|
||||
unmountIslands(root);
|
||||
// Every mounted container is swapped for a bare clone before remounting.
|
||||
//
|
||||
// Re-rendering the existing root is not enough: HMR wipes the container's
|
||||
// children externally, and React — whose virtual tree is unchanged — treats
|
||||
// the re-render as a no-op and leaves the island blank. Unmounting instead
|
||||
// throws asynchronously, because the DOM it wants to remove is already gone.
|
||||
// A fresh container sidesteps both, and React accepts createRoot on a node it
|
||||
// has never seen.
|
||||
for (const [element] of [...roots]) {
|
||||
if (element !== (root as unknown as Element) && !(root as unknown as Node).contains(element)) {
|
||||
continue;
|
||||
}
|
||||
roots.delete(element);
|
||||
if (element.isConnected) element.replaceWith(element.cloneNode(false));
|
||||
}
|
||||
discardDetachedRoots();
|
||||
await mountIslands(root, options);
|
||||
}
|
||||
|
||||
/**
|
||||
* Forgets roots whose container left the document.
|
||||
*
|
||||
* HMR morphs server markup over the mounted island, so React's DOM is already
|
||||
* gone by the time we get here; calling unmount then throws asynchronously with
|
||||
* "The node to be removed is not a child of this node". Navigation still uses
|
||||
* `unmountIslands`, where the DOM is intact and cleanup must actually run.
|
||||
*/
|
||||
export function discardDetachedRoots(): void {
|
||||
for (const element of [...roots.keys()]) {
|
||||
if (!element.isConnected) roots.delete(element);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,6 +25,11 @@ export function getIslandRuntime(development = false): string {
|
||||
development: ${development}
|
||||
});
|
||||
};
|
||||
// HMR morphs the server placeholder back over the mounted island, which
|
||||
// discards the React tree. Remount once the DOM has settled.
|
||||
window.addEventListener("wrnexus:hmr-updated", function () {
|
||||
window.__wrnexusRemountIslands();
|
||||
});
|
||||
}).catch(function (error) {
|
||||
console.error("[wrnexus] failed to load the island runtime", error);
|
||||
});
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
import { afterEach, expect, test } from "bun:test";
|
||||
import { Window } from "happy-dom";
|
||||
import { act, createElement } from "react";
|
||||
import { islandRootCount, mountIslands, remountIslands, unmountIslands } from "../src/island-runtime.ts";
|
||||
import {
|
||||
islandRootCount,
|
||||
mountIslands,
|
||||
remountIslands,
|
||||
unmountIslands,
|
||||
} from "../src/island-runtime.ts";
|
||||
|
||||
(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
|
||||
@@ -65,3 +70,25 @@ test("repeated remounts stay at one root", async () => {
|
||||
|
||||
expect(islandRootCount()).toBe(1);
|
||||
});
|
||||
|
||||
test("remount re-renders in place instead of creating a second root", async () => {
|
||||
const window = domWith(marker);
|
||||
const loader = async () => ({ default: () => createElement("span", null, "v1") });
|
||||
|
||||
await act(async () => {
|
||||
await mountIslands(host(window), { loader });
|
||||
});
|
||||
expect(islandRootCount()).toBe(1);
|
||||
|
||||
// Simulate HMR morphing server markup back over the mounted island: React's
|
||||
// rendered DOM is gone, so unmounting it would throw.
|
||||
const el = window.document.querySelector("[data-wrn-island]")!;
|
||||
el.innerHTML = "";
|
||||
|
||||
await act(async () => {
|
||||
await remountIslands(host(window), { loader });
|
||||
});
|
||||
|
||||
expect(islandRootCount()).toBe(1);
|
||||
expect(window.document.body.textContent).toContain("v1");
|
||||
});
|
||||
|
||||
@@ -23,3 +23,11 @@ test("threads the development flag into the mount options", () => {
|
||||
test("encodes the island name before using it as a URL path segment", () => {
|
||||
expect(getIslandRuntime(false)).toContain("encodeURIComponent");
|
||||
});
|
||||
|
||||
test("the runtime remounts islands after an HMR update", () => {
|
||||
// HMR morphs the server placeholder over the mounted island, discarding the
|
||||
// React tree; without this the island silently disappears on every edit.
|
||||
const source = getIslandRuntime(true);
|
||||
expect(source).toContain("wrnexus:hmr-updated");
|
||||
expect(source).toContain("__wrnexusRemountIslands");
|
||||
});
|
||||
|
||||
@@ -34,28 +34,13 @@ function loadTypeScript() {
|
||||
|
||||
const ts = loadTypeScript();
|
||||
|
||||
// React island modules are not used by the editor: island-bundle.ts calls
|
||||
// Bun.build and island-codegen.ts imports @wrnexus/core, neither of which
|
||||
// exists in this Node-only bundle. They are unreachable from the editor entry,
|
||||
// so excluding them keeps Bun-only code out of the extension entirely.
|
||||
const EDITOR_EXCLUDED = ["island-bundle.ts", "island-codegen.ts"];
|
||||
function isEditorExcluded(path) {
|
||||
return EDITOR_EXCLUDED.some((name) => path.endsWith(name));
|
||||
}
|
||||
|
||||
function walk(dir) {
|
||||
const files = [];
|
||||
for (const entry of readdirSync(dir)) {
|
||||
const path = join(dir, entry);
|
||||
const stat = statSync(path);
|
||||
if (stat.isDirectory()) files.push(...walk(path));
|
||||
else if (
|
||||
stat.isFile() &&
|
||||
path.endsWith(".ts") &&
|
||||
!path.endsWith(".test.ts") &&
|
||||
!isEditorExcluded(path)
|
||||
)
|
||||
files.push(path);
|
||||
else if (stat.isFile() && path.endsWith(".ts") && !path.endsWith(".test.ts")) files.push(path);
|
||||
}
|
||||
return files;
|
||||
}
|
||||
|
||||
@@ -20,6 +20,7 @@
|
||||
"@wrnexus/core/jsx-dev-runtime": ["./packages/core/src/jsx-dev-runtime.ts"],
|
||||
"@wrnexus/react": ["./packages/react/src/index.ts"],
|
||||
"@wrnexus/react/runtime": ["./packages/react/src/runtime-source.ts"],
|
||||
"@wrnexus/react/browser": ["./packages/react/src/browser.ts"],
|
||||
"@wrnexus/reactive": ["./packages/reactive/src/index.ts"],
|
||||
"@wrnexus/graphql": ["./packages/graphql/src/index.ts"],
|
||||
"@wrnexus/graphql/*": ["./packages/graphql/src/*.ts"],
|
||||
|
||||
Reference in New Issue
Block a user