release: WRNexusJS 0.8.0
This commit is contained in:
@@ -15,6 +15,185 @@ export interface RuntimeRequirements {
|
||||
needsServerRuntime: boolean;
|
||||
hydrationStrategy: string | null;
|
||||
reasons: string[];
|
||||
optimization: OptimizationReport;
|
||||
cachePolicy: Record<string, string>;
|
||||
requiredPermission: string | null;
|
||||
}
|
||||
|
||||
export interface OptimizationReport {
|
||||
staticNodes: number;
|
||||
reactiveRegions: number;
|
||||
eliminatedBranches: number;
|
||||
unusedState: string[];
|
||||
unusedHandlers: string[];
|
||||
constantProps: string[];
|
||||
unusedLocalCssClasses: string[];
|
||||
batchableStateUpdates: number;
|
||||
memoizableComponents: string[];
|
||||
preloadDependencies: string[];
|
||||
serverOnlyModules: string[];
|
||||
}
|
||||
|
||||
function identifiers(value: string): Set<string> {
|
||||
return new Set(value.match(/[A-Za-z_$][\w$]*/g) ?? []);
|
||||
}
|
||||
|
||||
function literalBoolean(expression: string | null): boolean | undefined {
|
||||
if (expression === null) return true;
|
||||
const value = expression.trim();
|
||||
if (value === "true") return true;
|
||||
if (
|
||||
value === "false" ||
|
||||
value === "null" ||
|
||||
value === "undefined" ||
|
||||
value === "0" ||
|
||||
value === "''" ||
|
||||
value === '""'
|
||||
)
|
||||
return false;
|
||||
if (/^-?(?:[1-9]\d*|0?\.\d+)$/.test(value) || /^(['"]).+\1$/.test(value)) return true;
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function optimizeNodes(nodes: ViewNode[], report: { eliminated: number }): ViewNode[] {
|
||||
const output: ViewNode[] = [];
|
||||
for (const node of nodes) {
|
||||
if (node.type === "element")
|
||||
output.push({
|
||||
...node,
|
||||
attrs: node.attrs.map((attribute) => ({ ...attribute })),
|
||||
children: optimizeNodes(node.children, report),
|
||||
});
|
||||
else if (node.type === "each")
|
||||
output.push({
|
||||
...node,
|
||||
body: optimizeNodes(node.body, report),
|
||||
empty: optimizeNodes(node.empty, report),
|
||||
});
|
||||
else if (node.type === "if") {
|
||||
let selected: ViewNode[] | undefined;
|
||||
let dynamic = false;
|
||||
for (const branch of node.branches) {
|
||||
const value = literalBoolean(branch.cond);
|
||||
if (value === undefined) {
|
||||
dynamic = true;
|
||||
break;
|
||||
}
|
||||
report.eliminated++;
|
||||
if (value) {
|
||||
selected = branch.body;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (dynamic)
|
||||
output.push({
|
||||
...node,
|
||||
branches: node.branches.map((branch) => ({
|
||||
...branch,
|
||||
body: optimizeNodes(branch.body, report),
|
||||
})),
|
||||
});
|
||||
else if (selected) output.push(...optimizeNodes(selected, report));
|
||||
} else output.push({ ...node });
|
||||
}
|
||||
return output;
|
||||
}
|
||||
|
||||
/** Safe compile-time folding for literal conditional branches. */
|
||||
export function optimizeAst(ast: PageAst): { ast: PageAst; eliminatedBranches: number } {
|
||||
const report = { eliminated: 0 };
|
||||
return {
|
||||
ast: { ...ast, view: optimizeNodes(ast.view, report) },
|
||||
eliminatedBranches: report.eliminated,
|
||||
};
|
||||
}
|
||||
|
||||
export function analyzeOptimizations(ast: PageAst): OptimizationReport {
|
||||
const used = new Set<string>();
|
||||
let staticNodes = 0;
|
||||
let reactiveRegions = 0;
|
||||
const componentNames = new Set<string>();
|
||||
const staticClasses = new Set<string>();
|
||||
const visit = (nodes: ViewNode[]) => {
|
||||
for (const node of nodes) {
|
||||
if (node.type === "text") {
|
||||
const refs = identifiers(node.value);
|
||||
refs.forEach((name) => used.add(name));
|
||||
if (node.value.includes("{")) reactiveRegions++;
|
||||
else staticNodes++;
|
||||
} else if (node.type === "element") {
|
||||
if (/^[A-Z]/.test(node.tag)) componentNames.add(node.tag);
|
||||
let reactive = false;
|
||||
for (const attribute of node.attrs) {
|
||||
identifiers(attribute.value).forEach((name) => used.add(name));
|
||||
reactive ||= attribute.event || attribute.value.includes("{");
|
||||
if (attribute.name === "class" && !attribute.value.includes("{"))
|
||||
for (const name of attribute.value.split(/\s+/)) if (name) staticClasses.add(name);
|
||||
}
|
||||
if (reactive) reactiveRegions++;
|
||||
else staticNodes++;
|
||||
visit(node.children);
|
||||
} else if (node.type === "each") {
|
||||
identifiers(`${node.list} ${node.key ?? ""}`).forEach((name) => used.add(name));
|
||||
reactiveRegions++;
|
||||
visit(node.body);
|
||||
visit(node.empty);
|
||||
} else {
|
||||
for (const branch of node.branches) {
|
||||
identifiers(branch.cond ?? "").forEach((name) => used.add(name));
|
||||
visit(branch.body);
|
||||
}
|
||||
reactiveRegions++;
|
||||
}
|
||||
}
|
||||
};
|
||||
visit(ast.view);
|
||||
const handlerReferences = new Set(used);
|
||||
const executable = [
|
||||
...ast.runtimeFunctions.map((fn) => fn.body),
|
||||
...ast.functions,
|
||||
...ast.effects.map((effect) => effect.body),
|
||||
...ast.watches.map((watch) => watch.body),
|
||||
...ast.actions.map((action) => action.body),
|
||||
].join("\n");
|
||||
identifiers(executable).forEach((name) => used.add(name));
|
||||
const localCss = new Set(
|
||||
ast.styles.flatMap((style) =>
|
||||
[...style.matchAll(/\.([_a-zA-Z][\w-]*)/g)].map((match) => match[1]!),
|
||||
),
|
||||
);
|
||||
const optimized = optimizeAst(ast);
|
||||
const assignmentCounts = ast.runtimeFunctions.map(
|
||||
(fn) =>
|
||||
ast.states.filter((state) =>
|
||||
new RegExp(`\\b${state.name}\\s*(?:[+*/-]?=|\\+\\+|--)`).test(fn.body),
|
||||
).length,
|
||||
);
|
||||
return {
|
||||
staticNodes,
|
||||
reactiveRegions,
|
||||
eliminatedBranches: optimized.eliminatedBranches,
|
||||
unusedState: ast.states.filter((state) => !used.has(state.name)).map((state) => state.name),
|
||||
unusedHandlers: ast.runtimeFunctions
|
||||
.filter((fn) => fn.runtime !== "server" && !handlerReferences.has(fn.name))
|
||||
.map((fn) => fn.name),
|
||||
constantProps: ast.props
|
||||
.filter((prop) =>
|
||||
/^(?:-?\d+(?:\.\d+)?|true|false|null|(['"]).*\1)$/.test(prop.default.trim()),
|
||||
)
|
||||
.map((prop) => prop.name),
|
||||
unusedLocalCssClasses: [...localCss].filter((name) => !staticClasses.has(name)).sort(),
|
||||
batchableStateUpdates: assignmentCounts
|
||||
.filter((count) => count > 1)
|
||||
.reduce((sum, count) => sum + count - 1, 0),
|
||||
memoizableComponents: [...componentNames].sort(),
|
||||
preloadDependencies: ast.structuredImports
|
||||
.filter((entry) => !entry.typeOnly && !entry.source.startsWith("node:"))
|
||||
.map((entry) => entry.source),
|
||||
serverOnlyModules: ast.structuredImports
|
||||
.filter((entry) => entry.source.startsWith("node:") || ast.runtime === "server")
|
||||
.map((entry) => entry.source),
|
||||
};
|
||||
}
|
||||
|
||||
function hasEvent(nodes: ViewNode[]): boolean {
|
||||
@@ -67,12 +246,42 @@ export function analyzeRuntimeRequirements(ast: PageAst): RuntimeRequirements {
|
||||
else if (interactive) kind = "static-interactive";
|
||||
else kind = "static";
|
||||
|
||||
if (ast.renderMode === "static") {
|
||||
kind = "static";
|
||||
reasons.push("explicit static rendering");
|
||||
} else if (ast.renderMode === "server") {
|
||||
kind = requestData ? "request-ssr" : "static";
|
||||
reasons.push("explicit server rendering");
|
||||
} else if (ast.renderMode === "client") {
|
||||
kind = "static-interactive";
|
||||
reasons.push("explicit client rendering");
|
||||
} else if (ast.renderMode === "partial-static") {
|
||||
kind = "streaming-ssr";
|
||||
reasons.push("partial-static shell with streamed dynamic regions");
|
||||
}
|
||||
|
||||
const clientDisabled = ast.renderMode === "static" || ast.renderMode === "server";
|
||||
const serverDisabled = ast.renderMode === "client";
|
||||
|
||||
return {
|
||||
kind,
|
||||
canPrerender: kind === "static" || kind === "static-interactive",
|
||||
needsClientRuntime: interactive && ast.hydrate !== "none" && ast.runtime !== "server",
|
||||
needsServerRuntime: requestData || authenticated || streaming || ast.runtime === "server",
|
||||
hydrationStrategy: interactive ? (ast.hydrate ?? "load") : null,
|
||||
needsClientRuntime:
|
||||
!clientDisabled &&
|
||||
(interactive || ast.renderMode === "client") &&
|
||||
ast.hydrate !== "none" &&
|
||||
ast.runtime !== "server",
|
||||
needsServerRuntime:
|
||||
!serverDisabled &&
|
||||
(requestData ||
|
||||
authenticated ||
|
||||
streaming ||
|
||||
ast.renderMode === "server" ||
|
||||
["server", "edge", "worker", "service-worker"].includes(ast.runtime ?? "")),
|
||||
hydrationStrategy: clientDisabled ? null : interactive ? (ast.hydrate ?? "load") : null,
|
||||
reasons,
|
||||
optimization: analyzeOptimizations(ast),
|
||||
cachePolicy: { ...(ast.cache ?? {}) },
|
||||
requiredPermission: ast.security.permission ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -21,6 +21,7 @@ import { VOID_ELEMENTS, type Attr, type DataMode, type PageAst, type ViewNode }
|
||||
import { eraseFunctionTypes, runtimeTypeOf } from "./types.ts";
|
||||
import { stripRuntimeFunctionModifiers } from "@wrnexus/syntax";
|
||||
import { generateStoreModule } from "./store-codegen.ts";
|
||||
import { optimizeAst } from "./analysis.ts";
|
||||
|
||||
interface RenderBinding {
|
||||
method: string;
|
||||
@@ -352,6 +353,55 @@ function renderLoopBody(node: ViewNode): string {
|
||||
|
||||
const inner = node.children.map(renderLoopBody).join("");
|
||||
|
||||
if (node.tag === "Static") return inner;
|
||||
if (node.tag === "Dynamic")
|
||||
return (
|
||||
escLit('<wrn-dynamic-region data-wrn-dynamic="true">') +
|
||||
inner +
|
||||
escLit("</wrn-dynamic-region>")
|
||||
);
|
||||
if (node.tag === "KeepAlive") {
|
||||
const key = node.attrs.find((attribute) => attribute.name === "key")?.value ?? "default";
|
||||
return (
|
||||
escLit('<div data-wrn-keepalive="') +
|
||||
bakeLoopAttr(key) +
|
||||
escLit(`">`) +
|
||||
inner +
|
||||
escLit("</div>")
|
||||
);
|
||||
}
|
||||
if (node.tag === "Portal") {
|
||||
const target = node.attrs.find((attribute) => attribute.name === "to")?.value ?? "body";
|
||||
return (
|
||||
escLit('<div data-wrn-portal="') +
|
||||
bakeLoopAttr(target) +
|
||||
escLit('">') +
|
||||
inner +
|
||||
escLit("</div>")
|
||||
);
|
||||
}
|
||||
if (node.tag === "Transition") {
|
||||
const name =
|
||||
node.attrs.find((attribute) => attribute.name === "name")?.value ?? "wrn-transition";
|
||||
return (
|
||||
escLit('<div data-wrn-transition="') +
|
||||
bakeLoopAttr(name) +
|
||||
escLit('">') +
|
||||
inner +
|
||||
escLit("</div>")
|
||||
);
|
||||
}
|
||||
if (node.tag === "Component") {
|
||||
const selected = node.attrs.find((attribute) => attribute.name === "is")?.value ?? "";
|
||||
return (
|
||||
escLit('<div data-wrn-dynamic-component="') +
|
||||
bakeLoopAttr(selected) +
|
||||
escLit('">') +
|
||||
inner +
|
||||
escLit("</div>")
|
||||
);
|
||||
}
|
||||
|
||||
if (componentTag) {
|
||||
return (
|
||||
escLit(`<div data-component="${attrEscape(node.tag)}"`) +
|
||||
@@ -417,6 +467,7 @@ function compileIfExpr(node: IfNode): string {
|
||||
*/
|
||||
function collectControlExprs(nodes: ViewNode[], out: string[] = []): string[] {
|
||||
for (const node of nodes) {
|
||||
if (node.type === "text") continue;
|
||||
if (node.type === "each") {
|
||||
out.push(node.list);
|
||||
collectControlExprs(node.body, out);
|
||||
@@ -450,6 +501,73 @@ function renderNode(
|
||||
return `\x00WRNEACH${loops.length - 1}\x00`;
|
||||
}
|
||||
|
||||
if (node.tag === "Static" || node.tag === "Dynamic") {
|
||||
const inner = node.children
|
||||
.map((child) => renderNode(child, ssrBindings, csrBindings, apiBindings, loops, reactive))
|
||||
.join("");
|
||||
return node.tag === "Static"
|
||||
? inner
|
||||
: `<wrn-dynamic-region data-wrn-dynamic="true">${inner}</wrn-dynamic-region>`;
|
||||
}
|
||||
if (node.tag === "Portal" || node.tag === "Transition" || node.tag === "Component") {
|
||||
const inner = node.children
|
||||
.map((child) => renderNode(child, ssrBindings, csrBindings, apiBindings, loops, reactive))
|
||||
.join("");
|
||||
const attribute =
|
||||
node.tag === "Portal"
|
||||
? "data-wrn-portal"
|
||||
: node.tag === "Transition"
|
||||
? "data-wrn-transition"
|
||||
: "data-wrn-dynamic-component";
|
||||
const source = node.tag === "Portal" ? "to" : node.tag === "Transition" ? "name" : "is";
|
||||
const fallback =
|
||||
node.tag === "Portal" ? "body" : node.tag === "Transition" ? "wrn-transition" : "";
|
||||
const original = node.attrs.find((item) => item.name === source);
|
||||
const rendered = original
|
||||
? renderAttrs([{ ...original, name: attribute }], undefined, reactive, loops)
|
||||
: ` ${attribute}="${attrEscape(fallback)}"`;
|
||||
return `<div${rendered}>${inner}</div>`;
|
||||
}
|
||||
|
||||
if (node.tag === "Async") {
|
||||
const source = attrValue(node.attrs, "source") ?? "data";
|
||||
const retries = attrValue(node.attrs, "retries") ?? "2";
|
||||
const serverResolved = attrValue(node.attrs, "data-wrn-async-server") === "true";
|
||||
const asyncIndex = serverResolved ? loops.push("") - 1 : -1;
|
||||
const branch = (name: string) => {
|
||||
const element = node.children.find(
|
||||
(child): child is Extract<ViewNode, { type: "element" }> =>
|
||||
child.type === "element" && child.tag === name,
|
||||
);
|
||||
return (element?.children ?? [])
|
||||
.map((child) => renderNode(child, ssrBindings, csrBindings, apiBindings, loops, reactive))
|
||||
.join("");
|
||||
};
|
||||
const loading = branch("Loading");
|
||||
const success = branch("Success");
|
||||
const error = branch("Error");
|
||||
let initial = loading;
|
||||
if (serverResolved) {
|
||||
const nested = (value: string) => value.replace(/\\/g, "\\\\").replace(/`/g, "\\`");
|
||||
const sourcePattern = source.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
const serverSuccess = success.replace(
|
||||
new RegExp(`\\{\\s*(${sourcePattern}(?:\\.[A-Za-z_$][\\w$]*)*)\\s*\\}`, "g"),
|
||||
(_whole, expression: string) => `\${__wrnexusEscapeHtml(${expression})}`,
|
||||
);
|
||||
loops[asyncIndex] =
|
||||
`\${ctx[${JSON.stringify(source)}] !== undefined ? \`${nested(serverSuccess)}\` : \`${nested(loading)}\`}`;
|
||||
initial = `\x00WRNEACH${asyncIndex}\x00`;
|
||||
}
|
||||
return `<section data-wrn-async="${attrEscape(source)}" data-wrn-async-retries="${attrEscape(retries)}"${serverResolved ? ' data-wrn-async-resolved="true"' : ""} aria-busy="${serverResolved ? "false" : "true"}"><div data-wrn-async-content>${initial}</div><template data-wrn-async-loading>${loading}</template><template data-wrn-async-success>${success}</template><template data-wrn-async-error>${error}</template></section>`;
|
||||
}
|
||||
|
||||
if (node.tag === "KeepAlive") {
|
||||
const key = attrValue(node.attrs, "key") ?? "default";
|
||||
const inner = node.children
|
||||
.map((child) => renderNode(child, ssrBindings, csrBindings, apiBindings, loops, reactive))
|
||||
.join("");
|
||||
return `<div data-wrn-keepalive="${attrEscape(key)}">${inner}</div>`;
|
||||
}
|
||||
if (isComponentTag(node.tag)) {
|
||||
return renderPageComponentInvocation(
|
||||
node,
|
||||
@@ -848,7 +966,9 @@ function generateSsrStateAliases(stateNames: string[]): string {
|
||||
}
|
||||
|
||||
function hydrationAttribute(ast: PageAst): string {
|
||||
const strategy = ast.hydrate ?? "load";
|
||||
const strategy = ["static", "server"].includes(ast.renderMode ?? "")
|
||||
? "none"
|
||||
: (ast.hydrate ?? "load");
|
||||
const hasBrowserModule = ast.runtimeFunctions.some((fn) =>
|
||||
["legacy", "client", "shared"].includes(fn.runtime),
|
||||
);
|
||||
@@ -879,13 +999,88 @@ function publicOutputNames(ast: PageAst): string[] {
|
||||
];
|
||||
}
|
||||
|
||||
function prepareActionForms(nodes: ViewNode[], actions: ReadonlySet<string>): void {
|
||||
for (const node of nodes) {
|
||||
if (node.type === "text") continue;
|
||||
if (node.type === "each") {
|
||||
prepareActionForms(node.body, actions);
|
||||
prepareActionForms(node.empty, actions);
|
||||
continue;
|
||||
}
|
||||
if (node.type === "if") {
|
||||
node.branches.forEach((branch) => prepareActionForms(branch.body, actions));
|
||||
continue;
|
||||
}
|
||||
prepareActionForms(node.children, actions);
|
||||
if (node.tag.toLowerCase() !== "form") continue;
|
||||
const submit = node.attrs.find((attr) => attr.event && attr.name === "submit");
|
||||
if (!submit || !actions.has(submit.value.trim())) continue;
|
||||
const name = submit.value.trim();
|
||||
node.attrs = node.attrs.filter((attr) => attr !== submit);
|
||||
if (!node.attrs.some((attr) => !attr.event && attr.name === "method")) {
|
||||
node.attrs.push({ name: "method", value: "post", event: false });
|
||||
}
|
||||
node.attrs.push({ name: "data-wrn-action", value: name, event: false });
|
||||
node.children.unshift({
|
||||
type: "element",
|
||||
tag: "input",
|
||||
attrs: [
|
||||
{ name: "type", value: "hidden", event: false },
|
||||
{ name: "name", value: "_wrnexus_action", event: false },
|
||||
{ name: "value", value: name, event: false },
|
||||
],
|
||||
children: [],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function markServerAsyncBoundaries(nodes: ViewNode[], serverLoads: ReadonlySet<string>): void {
|
||||
for (const node of nodes) {
|
||||
if (node.type === "text") continue;
|
||||
if (node.type === "each") {
|
||||
markServerAsyncBoundaries(node.body, serverLoads);
|
||||
markServerAsyncBoundaries(node.empty, serverLoads);
|
||||
continue;
|
||||
}
|
||||
if (node.type === "if") {
|
||||
node.branches.forEach((branch) => markServerAsyncBoundaries(branch.body, serverLoads));
|
||||
continue;
|
||||
}
|
||||
if (node.tag === "Async") {
|
||||
const source = attrValue(node.attrs, "source") ?? "data";
|
||||
if (
|
||||
serverLoads.has(source) &&
|
||||
!node.attrs.some((attribute) => attribute.name === "data-wrn-async-server")
|
||||
) {
|
||||
node.attrs.push({ name: "data-wrn-async-server", value: "true", event: false });
|
||||
}
|
||||
}
|
||||
markServerAsyncBoundaries(node.children, serverLoads);
|
||||
}
|
||||
}
|
||||
|
||||
export function generate(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") {
|
||||
return generateComponent(ast);
|
||||
}
|
||||
|
||||
const out: string[] = [];
|
||||
prepareActionForms(ast.view, new Set(ast.actions.map((action) => action.name)));
|
||||
markServerAsyncBoundaries(
|
||||
ast.view,
|
||||
new Set(
|
||||
ast.loads
|
||||
.filter((load) => load.mode === "server" && !load.deferred && load.name)
|
||||
.map((load) => load.name!),
|
||||
),
|
||||
);
|
||||
if (ast.actions.length > 0) {
|
||||
out.push(
|
||||
`import { createActionClient } from "@wrnexus/csr";\nimport type { InferSchema } from "@wrnexus/validation";`,
|
||||
);
|
||||
}
|
||||
if (ast.imports.length > 0) out.push(generatedImports(ast).join("\n"));
|
||||
const ssrBindings: SsrBinding[] = [];
|
||||
const csrBindings: CsrBinding[] = [];
|
||||
@@ -909,11 +1104,19 @@ export function generate(ast: PageAst): string {
|
||||
`export const layout = ${ast.layoutIsSymbol ? ast.layout : JSON.stringify(ast.layout)};`,
|
||||
);
|
||||
out.push(`export const __wrnexusRuntime = ${JSON.stringify(ast.runtime ?? "universal")};`);
|
||||
out.push(`export const __wrnexusHydrate = ${JSON.stringify(ast.hydrate ?? "load")};`);
|
||||
out.push(`export const __wrnexusRender = ${JSON.stringify(ast.renderMode ?? "hybrid")};`);
|
||||
out.push(
|
||||
`export const __wrnexusHydrate = ${JSON.stringify(["static", "server"].includes(ast.renderMode ?? "") ? "none" : (ast.hydrate ?? "load"))};`,
|
||||
);
|
||||
out.push(`export const __wrnexusHydrationId = ${JSON.stringify(hydrationId(ast))};`);
|
||||
if (Object.keys(ast.cache ?? {}).length > 0)
|
||||
out.push(`export const __wrnexusCache = ${JSON.stringify(ast.cache, null, 2)};`);
|
||||
if (Object.keys(ast.security).length > 0) {
|
||||
out.push(`export const __wrnexusSecurity = ${JSON.stringify(ast.security, null, 2)};`);
|
||||
}
|
||||
if (Object.keys(ast.navigation).length > 0) {
|
||||
out.push(`export const __wrnexusNavigation = ${JSON.stringify(ast.navigation, null, 2)};`);
|
||||
}
|
||||
|
||||
// --- View -> default page component ---
|
||||
const browserStates = ast.states.filter((state) => state.runtime !== "server");
|
||||
@@ -960,6 +1163,10 @@ export function generate(ast: PageAst): string {
|
||||
if (pageStyleTag) {
|
||||
html = `${pageStyleTag}${html}`;
|
||||
}
|
||||
if (ast.renderMode === "client") {
|
||||
const clientRoot = hydrationId(ast);
|
||||
html = `<div data-wrn-client-root="${clientRoot}" aria-busy="true"></div><template data-wrn-client-template="${clientRoot}">${html}</template>`;
|
||||
}
|
||||
const pageStyleExport = localStyleExport(ast, styles);
|
||||
if (pageStyleExport) out.push(pageStyleExport);
|
||||
if (csrBindings.length > 0) {
|
||||
@@ -972,6 +1179,14 @@ export function generate(ast: PageAst): string {
|
||||
// Escape the static HTML for the template literal, then swap loop sentinels for
|
||||
// their real `${…}` code (which must NOT be escaped).
|
||||
let body = templateEscape(html);
|
||||
let staticShellBody: string | undefined;
|
||||
if (ast.renderMode === "partial-static") {
|
||||
const shellHtml = html.replace(
|
||||
/<wrn-dynamic-region\b[^>]*>[\s\S]*?<\/wrn-dynamic-region>/gi,
|
||||
'<wrn-dynamic-region data-wrn-dynamic="true"></wrn-dynamic-region>',
|
||||
);
|
||||
staticShellBody = templateEscape(shellHtml);
|
||||
}
|
||||
const dynamicStateScope = ast.states
|
||||
.map(
|
||||
(state) =>
|
||||
@@ -994,9 +1209,16 @@ export function generate(ast: PageAst): string {
|
||||
(entry) => ` const ${entry.local} = await ctx.__wrnexusUseStore(${entry.internal});`,
|
||||
)
|
||||
.join("\n");
|
||||
const serverLoadAliases = ast.loads
|
||||
.filter((load) => load.mode === "server" && !load.deferred && load.name)
|
||||
.map((load) => ` const ${load.name} = ctx[${JSON.stringify(load.name)}];`)
|
||||
.join("\n");
|
||||
|
||||
loops.forEach((code, idx) => {
|
||||
body = body.replace(`\x00WRNEACH${idx}\x00`, () => code);
|
||||
if (staticShellBody?.includes(`\x00WRNEACH${idx}\x00`)) {
|
||||
staticShellBody = staticShellBody.replace(`\x00WRNEACH${idx}\x00`, () => code);
|
||||
}
|
||||
});
|
||||
|
||||
// Server loops iterate raw SSR data. Declare a named const for every `ssr` data
|
||||
@@ -1024,6 +1246,7 @@ export function generate(ast: PageAst): string {
|
||||
out.push(
|
||||
`export default async function ${ast.name}(ctx: any) {
|
||||
${storeDeclarations}
|
||||
${serverLoadAliases}
|
||||
${decls}
|
||||
const __state: ${stateType} = { ${dynamicStateScope} };
|
||||
${ssrStateAliases}
|
||||
@@ -1055,6 +1278,7 @@ export function generate(ast: PageAst): string {
|
||||
out.push(
|
||||
`export default ${storeBindings.length > 0 ? "async " : ""}function ${ast.name}(ctx: any) {
|
||||
${storeDeclarations}
|
||||
${serverLoadAliases}
|
||||
const __state: ${stateType} = { ${dynamicStateScope} };
|
||||
${ssrStateAliases}
|
||||
const __hydrationState = Object.fromEntries(${hydrationStateNames}.map((key) => [key, (__state as any)[key]]));
|
||||
@@ -1081,30 +1305,122 @@ export function generate(ast: PageAst): string {
|
||||
);
|
||||
}
|
||||
|
||||
if (staticShellBody !== undefined) {
|
||||
out.push(
|
||||
`export async function __wrnexusBuildStaticShell(ctx: any = {}) {
|
||||
${storeDeclarations}
|
||||
${serverLoadAliases}
|
||||
${loopConsts.length > 0 ? loopConsts.join("\n") : ""}
|
||||
const __state: ${stateType} = { ${dynamicStateScope} };
|
||||
${ssrStateAliases}
|
||||
const __hydrationState = Object.fromEntries(${hydrationStateNames}.map((key) => [key, (__state as any)[key]]));
|
||||
const __scopeValue = Object.entries(__hydrationState)
|
||||
.map(([key, value]) => {
|
||||
const encoded = typeof value === "number" || typeof value === "boolean"
|
||||
? String(value)
|
||||
: JSON.stringify(value == null ? "" : String(value));
|
||||
return key + ": " + encoded;
|
||||
})
|
||||
.join(", ")
|
||||
.replace(/&/g, "&")
|
||||
.replace(/"/g, """)
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">");
|
||||
return \`${staticShellBody}\`.replace("__WRNEXUS_DYNAMIC_SCOPE__", __scopeValue);
|
||||
}`,
|
||||
);
|
||||
}
|
||||
|
||||
if (ast.loads.length > 0) {
|
||||
const serverLoads = ast.loads.filter((entry) => entry.mode === "server");
|
||||
const clientLoads = ast.loads.filter((entry) => entry.mode === "client");
|
||||
if (serverLoads.length > 0) {
|
||||
out.push(
|
||||
`export async function __wrnexusLoad(ctx: any) {\n${serverLoads.map((entry) => entry.body).join("\n")}\n}`,
|
||||
);
|
||||
}
|
||||
if (clientLoads.length > 0) {
|
||||
out.push(
|
||||
`export async function __wrnexusClientLoad(ctx: any) {
|
||||
${clientLoads.map((entry) => entry.body).join("\n")}
|
||||
}`,
|
||||
);
|
||||
}
|
||||
const serverLoads = ast.loads.filter((entry) => entry.mode === "server" && !entry.deferred);
|
||||
const publicClientLoads = ast.loads.filter(
|
||||
(entry) => entry.mode === "client" || entry.deferred,
|
||||
);
|
||||
const namedByName = new Map(
|
||||
ast.loads.filter((entry) => entry.name).map((entry) => [entry.name!, entry]),
|
||||
);
|
||||
const clientNames = new Set(
|
||||
publicClientLoads.flatMap((entry) => (entry.name ? [entry.name] : [])),
|
||||
);
|
||||
const includeDependencies = (name: string): void => {
|
||||
for (const dependency of namedByName.get(name)?.dependsOn ?? []) {
|
||||
if (clientNames.has(dependency)) continue;
|
||||
clientNames.add(dependency);
|
||||
includeDependencies(dependency);
|
||||
}
|
||||
};
|
||||
for (const name of [...clientNames]) includeDependencies(name);
|
||||
const clientLoads = ast.loads.filter((entry) => !entry.name || clientNames.has(entry.name));
|
||||
const renderLoads = (
|
||||
exportName: string,
|
||||
execution: typeof ast.loads,
|
||||
exposed: typeof ast.loads,
|
||||
): string => {
|
||||
const declarations = execution
|
||||
.filter((entry) => entry.name)
|
||||
.map((entry) => {
|
||||
const dependencies = (entry.dependsOn ?? [])
|
||||
.map((dependency) => `const ${dependency} = await __load_${dependency}();`)
|
||||
.join("\n");
|
||||
return ` let __promise_${entry.name}: Promise<unknown> | undefined;
|
||||
const __load_${entry.name} = () => (__promise_${entry.name} ??= (async () => {
|
||||
${dependencies}
|
||||
${entry.body}
|
||||
})());`;
|
||||
})
|
||||
.join("\n");
|
||||
const visible = exposed.filter((entry) => entry.name);
|
||||
return `export async function ${exportName}(ctx: any) {
|
||||
${exposed
|
||||
.filter((entry) => !entry.name)
|
||||
.map((entry) => entry.body)
|
||||
.join("\n")}
|
||||
${declarations}
|
||||
${
|
||||
visible.length
|
||||
? ` const __values = await Promise.all([${visible.map((entry) => `__load_${entry.name}()`).join(", ")}]);
|
||||
return { ${visible.map((entry, index) => `${JSON.stringify(entry.name)}: __values[${index}]`).join(", ")} };`
|
||||
: ""
|
||||
}
|
||||
}`;
|
||||
};
|
||||
if (serverLoads.length > 0) out.push(renderLoads("__wrnexusLoad", serverLoads, serverLoads));
|
||||
if (publicClientLoads.length > 0)
|
||||
out.push(renderLoads("__wrnexusClientLoad", clientLoads, publicClientLoads));
|
||||
}
|
||||
|
||||
if (ast.actions.length > 0) {
|
||||
for (const action of ast.actions) {
|
||||
out.push(`export async function ${action.name}(${action.args.join(", ")}) {${action.body}}`);
|
||||
if (!action.schema) {
|
||||
out.push(
|
||||
`export async function ${action.name}(${action.args.join(", ")}) {${action.body}}`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
out.push(`export async function ${action.name}(input: any, ctx: any) {
|
||||
const invalidate = (...tags: string[]) => {
|
||||
const bucket = (ctx.locals.__wrnexusInvalidatedTags ??= []);
|
||||
bucket.push(...tags.flat());
|
||||
};
|
||||
${action.body}
|
||||
}`);
|
||||
}
|
||||
out.push(
|
||||
`export const __wrnexusActions = { ${ast.actions.map((action) => action.name).join(", ")} };`,
|
||||
`export const __wrnexusActions = { ${ast.actions
|
||||
.map(
|
||||
(action) =>
|
||||
`${action.name}: { run: ${action.name}, schema: ${action.schema ?? "undefined"} }`,
|
||||
)
|
||||
.join(", ")} };`,
|
||||
);
|
||||
out.push(`export const __wrnexusActionClients = {
|
||||
${ast.actions
|
||||
.map(
|
||||
(action) =>
|
||||
` ${action.name}: createActionClient<${action.schema ? `InferSchema<typeof ${action.schema}>` : "Record<string, unknown>"}, Awaited<ReturnType<typeof ${action.name}>>>("", ${JSON.stringify(action.name)}),`,
|
||||
)
|
||||
.join("\n")}
|
||||
};`);
|
||||
}
|
||||
|
||||
// --- API blocks -> method handlers ---
|
||||
@@ -1545,6 +1861,34 @@ function renderComponentNode(node: ViewNode, ctx: CompCtx): string {
|
||||
return renderComponentIfNode(node, ctx);
|
||||
}
|
||||
|
||||
if (node.tag === "Static" || node.tag === "Dynamic") {
|
||||
const inner = node.children.map((child) => renderComponentNode(child, ctx)).join("");
|
||||
return node.tag === "Static"
|
||||
? inner
|
||||
: `<wrn-dynamic-region data-wrn-dynamic="true">${inner}</wrn-dynamic-region>`;
|
||||
}
|
||||
|
||||
if (node.tag === "KeepAlive") {
|
||||
const key = node.attrs.find((attribute) => attribute.name === "key")?.value ?? "default";
|
||||
const inner = node.children.map((child) => renderComponentNode(child, ctx)).join("");
|
||||
return `<div data-wrn-keepalive="${compileAttrValue(key, ctx)}">${inner}</div>`;
|
||||
}
|
||||
|
||||
if (node.tag === "Portal" || node.tag === "Transition" || node.tag === "Component") {
|
||||
const inner = node.children.map((child) => renderComponentNode(child, ctx)).join("");
|
||||
const attribute =
|
||||
node.tag === "Portal"
|
||||
? "data-wrn-portal"
|
||||
: node.tag === "Transition"
|
||||
? "data-wrn-transition"
|
||||
: "data-wrn-dynamic-component";
|
||||
const source = node.tag === "Portal" ? "to" : node.tag === "Transition" ? "name" : "is";
|
||||
const fallback =
|
||||
node.tag === "Portal" ? "body" : node.tag === "Transition" ? "wrn-transition" : "";
|
||||
const raw = node.attrs.find((item) => item.name === source)?.value ?? fallback;
|
||||
return `<div ${attribute}="${compileAttrValue(raw, ctx)}">${inner}</div>`;
|
||||
}
|
||||
|
||||
if (isComponentTag(node.tag)) {
|
||||
return renderNestedComponentInvocation(node, ctx);
|
||||
}
|
||||
@@ -1865,11 +2209,19 @@ function generateComponent(ast: PageAst): string {
|
||||
out.push(`export const __wrnexusComponent = ${JSON.stringify(ast.name)};`);
|
||||
}
|
||||
out.push(`export const __wrnexusRuntime = ${JSON.stringify(ast.runtime ?? "universal")};`);
|
||||
out.push(`export const __wrnexusHydrate = ${JSON.stringify(ast.hydrate ?? "load")};`);
|
||||
out.push(`export const __wrnexusRender = ${JSON.stringify(ast.renderMode ?? "hybrid")};`);
|
||||
out.push(
|
||||
`export const __wrnexusHydrate = ${JSON.stringify(["static", "server"].includes(ast.renderMode ?? "") ? "none" : (ast.hydrate ?? "load"))};`,
|
||||
);
|
||||
out.push(`export const __wrnexusHydrationId = ${JSON.stringify(hydrationId(ast))};`);
|
||||
if (Object.keys(ast.cache ?? {}).length > 0)
|
||||
out.push(`export const __wrnexusCache = ${JSON.stringify(ast.cache, null, 2)};`);
|
||||
if (Object.keys(ast.security).length > 0) {
|
||||
out.push(`export const __wrnexusSecurity = ${JSON.stringify(ast.security, null, 2)};`);
|
||||
}
|
||||
if (Object.keys(ast.navigation).length > 0) {
|
||||
out.push(`export const __wrnexusNavigation = ${JSON.stringify(ast.navigation, null, 2)};`);
|
||||
}
|
||||
const componentStyleExport = localStyleExport(ast, styles);
|
||||
if (componentStyleExport) out.push(componentStyleExport);
|
||||
|
||||
|
||||
@@ -15,6 +15,8 @@ import {
|
||||
type PageAst,
|
||||
type WrnDiagnostic,
|
||||
} from "@wrnexus/syntax";
|
||||
export { formatWrn } from "@wrnexus/syntax";
|
||||
export type { FormatWrnOptions } from "@wrnexus/syntax";
|
||||
import { generate } from "./codegen.ts";
|
||||
import { generateNative } from "./native-codegen.ts";
|
||||
|
||||
@@ -35,8 +37,14 @@ export { generateStoreBrowserModule, generateStoreModule } from "./store-codegen
|
||||
export { createComponentContract } from "./component-contract.ts";
|
||||
export { resolveWrnImport, resolveWrnImports } from "./import-resolver.ts";
|
||||
export { createWrnSourceMap } from "./source-map.ts";
|
||||
export { analyzeRuntimeRequirements } from "./analysis.ts";
|
||||
export type { RouteExecutionKind, RuntimeRequirements } from "./analysis.ts";
|
||||
export { analyzeOptimizations, analyzeRuntimeRequirements, optimizeAst } from "./analysis.ts";
|
||||
export type { OptimizationReport, RouteExecutionKind, RuntimeRequirements } from "./analysis.ts";
|
||||
export { analyzeRuntimeImports, runtimeCapabilities } from "./runtime-capabilities.ts";
|
||||
export type {
|
||||
DeploymentRuntime,
|
||||
RuntimeCapability,
|
||||
RuntimeCapabilityDiagnostic,
|
||||
} from "./runtime-capabilities.ts";
|
||||
export { generateNative, NativeCompileError } from "./native-codegen.ts";
|
||||
export { Lexer, LexError } from "@wrnexus/syntax";
|
||||
export { eraseFunctionTypes, inferredRuntimeType, runtimeTypeOf } from "@wrnexus/syntax";
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
export type DeploymentRuntime = "bun" | "node" | "edge" | "worker" | "service-worker" | "browser";
|
||||
export type RuntimeCapability =
|
||||
"filesystem" | "tcp" | "process" | "websocket" | "crypto" | "streams" | "background-tasks";
|
||||
|
||||
const CAPABILITIES: Record<DeploymentRuntime, ReadonlySet<RuntimeCapability>> = {
|
||||
bun: new Set([
|
||||
"filesystem",
|
||||
"tcp",
|
||||
"process",
|
||||
"websocket",
|
||||
"crypto",
|
||||
"streams",
|
||||
"background-tasks",
|
||||
]),
|
||||
node: new Set([
|
||||
"filesystem",
|
||||
"tcp",
|
||||
"process",
|
||||
"websocket",
|
||||
"crypto",
|
||||
"streams",
|
||||
"background-tasks",
|
||||
]),
|
||||
edge: new Set(["websocket", "crypto", "streams", "background-tasks"]),
|
||||
worker: new Set(["websocket", "crypto", "streams", "background-tasks"]),
|
||||
"service-worker": new Set(["crypto", "streams", "background-tasks"]),
|
||||
browser: new Set(["websocket", "crypto", "streams"]),
|
||||
};
|
||||
|
||||
const MODULE_CAPABILITIES: Array<[RegExp, RuntimeCapability]> = [
|
||||
[/^(?:node:)?(?:fs|path|os)(?:\/|$)/, "filesystem"],
|
||||
[/^(?:node:)?(?:net|tls|dgram|http2)(?:\/|$)/, "tcp"],
|
||||
[/^(?:node:)?(?:child_process|cluster|worker_threads)(?:\/|$)/, "process"],
|
||||
];
|
||||
|
||||
export interface RuntimeCapabilityDiagnostic {
|
||||
code: "WRN-RUNTIME-CAPABILITY";
|
||||
runtime: DeploymentRuntime;
|
||||
module: string;
|
||||
capability: RuntimeCapability;
|
||||
message: string;
|
||||
}
|
||||
|
||||
export function runtimeCapabilities(runtime: DeploymentRuntime): ReadonlySet<RuntimeCapability> {
|
||||
return CAPABILITIES[runtime];
|
||||
}
|
||||
|
||||
export function analyzeRuntimeImports(
|
||||
source: string,
|
||||
runtime: DeploymentRuntime,
|
||||
): RuntimeCapabilityDiagnostic[] {
|
||||
const modules = [
|
||||
...source.matchAll(/\b(?:import\s+(?:[\s\S]*?\s+from\s+)?|require\s*\()\s*["']([^"']+)["']/g),
|
||||
].map((match) => match[1]!);
|
||||
const available = runtimeCapabilities(runtime);
|
||||
return modules.flatMap((module) => {
|
||||
const requirement = MODULE_CAPABILITIES.find(([pattern]) => pattern.test(module));
|
||||
if (!requirement || available.has(requirement[1])) return [];
|
||||
return [
|
||||
{
|
||||
code: "WRN-RUNTIME-CAPABILITY" as const,
|
||||
runtime,
|
||||
module,
|
||||
capability: requirement[1],
|
||||
message: `Module '${module}' requires ${requirement[1]}, which is unavailable in the ${runtime} runtime.`,
|
||||
},
|
||||
];
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user