release: WRNexusJS 0.8.3
Quality / quality (ubuntu-latest) (push) Failing after 12m9s
Quality / quality (windows-latest) (push) Canceled after 0s

This commit is contained in:
2026-08-03 19:47:30 +05:30
parent e8f630f12d
commit 4cebacadfe
156 changed files with 2608 additions and 473 deletions
+220 -52
View File
@@ -22,6 +22,7 @@ import { eraseFunctionTypes, runtimeTypeOf } from "./types.ts";
import { stripRuntimeFunctionModifiers } from "@wrnexus/syntax";
import { generateStoreModule } from "./store-codegen.ts";
import { optimizeAst } from "./analysis.ts";
import { browserModuleRequired } from "./client-codegen.ts";
interface RenderBinding {
method: string;
@@ -264,12 +265,21 @@ function evalStateSeeds(states: { name: string; expr: string }[]): Record<string
* runtime keeps it live. Non-state `{expr}` and un-evaluable expressions are
* left as literal client mustaches.
*/
function substituteReactiveText(raw: string, reactive: PageReactive | null): string {
function substituteReactiveText(
raw: string,
reactive: PageReactive | null,
dynamicExpressions?: string[],
): string {
const text = substituteTMarkers(raw);
if (!reactive || reactive.stateNames.size === 0) return text;
return text.replace(/\{([^{}]+)\}/g, (whole, inner: string) => {
const expr = inner.trim();
if (expr.startsWith("t:") || !exprRefsState(expr, reactive.stateNames)) return whole;
if (exprRefsState(expr, reactive.runtimeStateNames) && dynamicExpressions) {
dynamicExpressions.push(`\${__wrnexusEscapeHtml(${expr})}`);
const sentinel = `\x00WRNEACH${dynamicExpressions.length - 1}\x00`;
return `<span data-text="${attrEscape(expr)}">${sentinel}</span>`;
}
let value: unknown;
try {
value = new Function("with(this){return (" + expr + ");}").call(reactive.scope);
@@ -308,7 +318,9 @@ function bakeLoopText(raw: string): string {
}
/** Bake a loop-body attribute value (same rules as text; escapeHtml is attribute-safe). */
function bakeLoopAttr(raw: string): string {
function bakeLoopAttr(raw: string, typed = false): string {
const wholeExpression = wholeAttributeExpression(raw);
if (typed && wholeExpression) return "${__wrnexusPropAttr(" + wholeExpression + ")}";
if (!raw.includes("{")) return escLit(attrEscape(raw));
let out = "";
let last = 0;
@@ -347,7 +359,7 @@ function renderLoopBody(node: ViewNode): string {
return escLit(` ${name}`);
}
return escLit(` ${name}="`) + bakeLoopAttr(attr.value) + escLit(`"`);
return escLit(` ${name}="`) + bakeLoopAttr(attr.value, componentTag) + escLit(`"`);
})
.join("");
@@ -492,7 +504,7 @@ function renderNode(
loops: string[],
reactive: PageReactive | null = null,
): string {
if (node.type === "text") return substituteReactiveText(node.value, reactive); // {t:key} + state baking
if (node.type === "text") return substituteReactiveText(node.value, reactive, loops); // {t:key} + state baking
// Server control block (loop / conditional) → a sentinel that survives
// templateEscape, swapped for its real `${…}` code after escaping.
@@ -532,13 +544,15 @@ function renderNode(
if (node.tag === "Async") {
const source = attrValue(node.attrs, "source") ?? "data";
const retries = attrValue(node.attrs, "retries") ?? "2";
const tags = attrValue(node.attrs, "tags") ?? source;
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(
const branchElement = (name: string) =>
node.children.find(
(child): child is Extract<ViewNode, { type: "element" }> =>
child.type === "element" && child.tag === name,
);
const branch = (name: string) => {
const element = branchElement(name);
return (element?.children ?? [])
.map((child) => renderNode(child, ssrBindings, csrBindings, apiBindings, loops, reactive))
.join("");
@@ -546,19 +560,42 @@ function renderNode(
const loading = branch("Loading");
const success = branch("Success");
const error = branch("Error");
const successElement = branchElement("Success");
const errorElement = branchElement("Error");
const identifier = (value: string | undefined, fallback: string) =>
value && isSafeGeneratedIdentifier(value) ? value : fallback;
const successAlias = identifier(
successElement ? attrValue(successElement.attrs, "data") : undefined,
identifier(source, "data"),
);
const errorAlias = identifier(
errorElement ? attrValue(errorElement.attrs, "error") : undefined,
"error",
);
const nested = (value: string) => value.replace(/\\/g, "\\\\").replace(/`/g, "\\`");
const scoped = (value: string, alias: string, expression: string) => {
const index =
loops.push(
`\${(() => { const ${alias} = ${expression}; return \`${nested(value)}\`; })()}`,
) - 1;
return `\x00WRNEACH${index}\x00`;
};
const successTemplate = scoped(success, successAlias, `(ctx[${JSON.stringify(source)}] ?? {})`);
const errorTemplate = scoped(error, errorAlias, `{ message: "" }`);
let initial = loading;
if (serverResolved) {
const nested = (value: string) => value.replace(/\\/g, "\\\\").replace(/`/g, "\\`");
const sourcePattern = source.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
const sourcePattern = successAlias.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`;
const index =
loops.push(
`\${ctx[${JSON.stringify(source)}] !== undefined ? (() => { const ${successAlias} = ctx[${JSON.stringify(source)}]; return \`${nested(serverSuccess)}\`; })() : \`${nested(loading)}\`}`,
) - 1;
initial = `\x00WRNEACH${index}\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>`;
return `<section data-wrn-async="${attrEscape(source)}" data-wrn-async-tags="${attrEscape(tags)}" 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 data-wrn-async-alias="${attrEscape(successAlias)}">${successTemplate}</template><template data-wrn-async-error data-wrn-async-alias="${attrEscape(errorAlias)}">${errorTemplate}</template></section>`;
}
if (node.tag === "KeepAlive") {
@@ -965,13 +1002,133 @@ function generateSsrStateAliases(stateNames: string[]): string {
return `const { ${names.join(", ")} } = __state;\n`;
}
function orderPageStates(entries: PageAst["states"]): PageAst["states"] {
if (entries.length < 2) return entries;
const byName = new Map(entries.map((entry) => [entry.name, entry]));
const visiting = new Set<string>();
const visited = new Set<string>();
const ordered: PageAst["states"] = [];
const visit = (name: string): void => {
if (visited.has(name)) return;
if (visiting.has(name)) {
throw new Error(`WRN-STATE-CYCLE: state value '${name}' has a dependency cycle.`);
}
const entry = byName.get(name);
if (!entry) return;
visiting.add(name);
for (const dependency of byName.keys()) {
if (
dependency !== name &&
new RegExp(`\\b${dependency.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\b`).test(entry.expr)
) {
visit(dependency);
}
}
visiting.delete(name);
visited.add(name);
ordered.push(entry);
};
for (const entry of entries) visit(entry.name);
return ordered;
}
function generateSsrStateInitializer(entries: PageAst["states"]): string {
const ordered = orderPageStates(entries);
const declarations = ordered
.filter((entry) => isSafeGeneratedIdentifier(entry.name))
.map(
(entry) =>
`const ${entry.name} = (() => { try { return (${entry.expr}); } catch { return undefined; } })();`,
)
.join(" ");
const values = entries
.map((entry) => {
if (isSafeGeneratedIdentifier(entry.name)) {
return `${JSON.stringify(entry.name)}: ${entry.name}`;
}
return `${JSON.stringify(entry.name)}: (() => { try { return (${entry.expr}); } catch { return undefined; } })()`;
})
.join(", ");
return `(() => { ${declarations} return { ${values} }; })()`;
}
function orderPageComputed(entries: PageAst["computed"]): PageAst["computed"] {
if (entries.length < 2) return entries;
const byName = new Map(entries.map((entry) => [entry.name, entry]));
const visiting = new Set<string>();
const visited = new Set<string>();
const ordered: PageAst["computed"] = [];
const visit = (name: string): void => {
if (visited.has(name)) return;
if (visiting.has(name)) {
throw new Error(`WRN-COMPUTED-CYCLE: computed value '${name}' has a dependency cycle.`);
}
const entry = byName.get(name);
if (!entry) return;
visiting.add(name);
for (const dependency of byName.keys()) {
if (
dependency !== name &&
new RegExp(`\\b${dependency.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\b`).test(entry.expr)
) {
visit(dependency);
}
}
visiting.delete(name);
visited.add(name);
ordered.push(entry);
};
for (const entry of entries) visit(entry.name);
return ordered;
}
function generateSsrComputedAliases(entries: PageAst["computed"]): string {
return orderPageComputed(entries)
.filter((entry) => isSafeGeneratedIdentifier(entry.name))
.map(
(entry) =>
`const ${entry.name} = (() => { try { return (${entry.expr}); } catch { return undefined; } })();`,
)
.join("\n");
}
function runtimeComputedNames(
states: PageAst["states"],
computed: PageAst["computed"],
runtimeRoots: Iterable<string> = [],
): Set<string> {
const entries = [...states, ...computed];
const runtime = new Set([
...runtimeRoots,
...entries.filter((entry) => /\bctx\b/.test(entry.expr)).map((entry) => entry.name),
]);
let changed = true;
while (changed) {
changed = false;
for (const entry of entries) {
if (runtime.has(entry.name)) continue;
if (
[...runtime].some((name) =>
new RegExp(`\\b${name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\b`).test(entry.expr),
)
) {
runtime.add(entry.name);
changed = true;
}
}
}
return runtime;
}
function hydrationAttribute(ast: PageAst): string {
const strategy = ["static", "server"].includes(ast.renderMode ?? "")
? "none"
: (ast.hydrate ?? "load");
const hasBrowserModule = ast.runtimeFunctions.some((fn) =>
["legacy", "client", "shared"].includes(fn.runtime),
);
const hasBrowserModule = browserModuleRequired(ast);
const moduleAttribute = hasBrowserModule
? ' data-wrn-client-module="__WRNEXUS_CLIENT_MODULE__"'
: "";
@@ -1119,9 +1276,11 @@ export function generate(ast: PageAst): string {
}
// --- View -> default page component ---
const orderedStates = orderPageStates(ast.states);
const browserStates = ast.states.filter((state) => state.runtime !== "server");
const seedScope = evalStateSeeds(ast.states);
for (const entry of ast.computed) {
const seedScope = evalStateSeeds(orderedStates);
const orderedComputed = orderPageComputed(ast.computed);
for (const entry of orderedComputed) {
try {
seedScope[entry.name] = new Function("with(this){return (" + entry.expr + ");}").call(
seedScope,
@@ -1134,9 +1293,14 @@ export function generate(ast: PageAst): string {
...browserStates.map((entry) => entry.name),
...ast.computed.map((entry) => entry.name),
];
const runtimeStateNames = new Set(
ast.states.filter((entry) => /\bctx\b/.test(entry.expr)).map((entry) => entry.name),
);
const storeBindings = importedStoreBindings(ast);
const runtimeRoots = [
...storeBindings.map((entry) => entry.local),
...ast.loads
.filter((load) => load.mode === "server" && !load.deferred && load.name)
.map((load) => load.name!),
];
const runtimeStateNames = runtimeComputedNames(orderedStates, orderedComputed, runtimeRoots);
const reactive: PageReactive | null =
reactiveNames.length > 0
? { stateNames: new Set(reactiveNames), runtimeStateNames, scope: seedScope }
@@ -1187,12 +1351,7 @@ export function generate(ast: PageAst): string {
);
staticShellBody = templateEscape(shellHtml);
}
const dynamicStateScope = ast.states
.map(
(state) =>
`${JSON.stringify(state.name)}: (() => { try { return (${state.expr}); } catch { return undefined; } })()`,
)
.join(", ");
const dynamicStateInitializer = generateSsrStateInitializer(orderedStates);
const stateType =
ast.states.length > 0
? `{ ${ast.states
@@ -1202,8 +1361,8 @@ export function generate(ast: PageAst): string {
const hydrationStateNames = JSON.stringify(browserStates.map((state) => state.name));
const ssrStateAliases = generateSsrStateAliases(ast.states.map((state) => state.name));
const ssrComputedAliases = generateSsrComputedAliases(orderedComputed);
const storeBindings = importedStoreBindings(ast);
const storeDeclarations = storeBindings
.map(
(entry) => ` const ${entry.local} = await ctx.__wrnexusUseStore(${entry.internal});`,
@@ -1214,12 +1373,16 @@ export function generate(ast: PageAst): string {
.map((load) => ` const ${load.name} = ctx[${JSON.stringify(load.name)}];`)
.join("\n");
loops.forEach((code, idx) => {
body = body.replace(`\x00WRNEACH${idx}\x00`, () => code);
// Inner dynamic expressions are registered before the wrapper that scopes
// them (for example an Async branch alias). Resolve from the outside in so a
// wrapper sentinel is expanded before its nested sentinels are visited.
for (let idx = loops.length - 1; idx >= 0; idx--) {
const code = loops[idx]!;
body = body.replaceAll(`\x00WRNEACH${idx}\x00`, code);
if (staticShellBody?.includes(`\x00WRNEACH${idx}\x00`)) {
staticShellBody = staticShellBody.replace(`\x00WRNEACH${idx}\x00`, () => code);
staticShellBody = staticShellBody.replaceAll(`\x00WRNEACH${idx}\x00`, code);
}
});
}
// Server loops iterate raw SSR data. Declare a named const for every `ssr` data
// binding a loop references, so `{#each <name> as …}` can iterate the real value.
@@ -1235,10 +1398,7 @@ export function generate(ast: PageAst): string {
}
}
const needsSsrRuntime =
ssrBindings.length > 0 ||
loops.length > 0 ||
ast.states.some((state) => /\bctx\b/.test(state.expr));
const needsSsrRuntime = ssrBindings.length > 0 || loops.length > 0 || runtimeStateNames.size > 0;
if (needsSsrRuntime) {
out.push(ssrRuntimeSource());
out.push(`const __wrnexusSsrBindings = ${JSON.stringify(ssrBindings, null, 2)};`);
@@ -1248,16 +1408,18 @@ export function generate(ast: PageAst): string {
${storeDeclarations}
${serverLoadAliases}
${decls}
const __state: ${stateType} = { ${dynamicStateScope} };
const __state: ${stateType} = ${dynamicStateInitializer};
${ssrStateAliases}
${ssrComputedAliases}
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));
let encoded: string;
try {
encoded = value === undefined ? "undefined" : JSON.stringify(value);
} catch {
encoded = JSON.stringify(String(value));
}
return key + ": " + encoded;
})
.join(", ")
@@ -1279,16 +1441,18 @@ export function generate(ast: PageAst): string {
`export default ${storeBindings.length > 0 ? "async " : ""}function ${ast.name}(ctx: any) {
${storeDeclarations}
${serverLoadAliases}
const __state: ${stateType} = { ${dynamicStateScope} };
const __state: ${stateType} = ${dynamicStateInitializer};
${ssrStateAliases}
${ssrComputedAliases}
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));
let encoded: string;
try {
encoded = value === undefined ? "undefined" : JSON.stringify(value);
} catch {
encoded = JSON.stringify(String(value));
}
return key + ": " + encoded;
})
.join(", ")
@@ -1311,14 +1475,18 @@ export function generate(ast: PageAst): string {
${storeDeclarations}
${serverLoadAliases}
${loopConsts.length > 0 ? loopConsts.join("\n") : ""}
const __state: ${stateType} = { ${dynamicStateScope} };
const __state: ${stateType} = ${dynamicStateInitializer};
${ssrStateAliases}
${ssrComputedAliases}
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));
let encoded: string;
try {
encoded = value === undefined ? "undefined" : JSON.stringify(value);
} catch {
encoded = JSON.stringify(String(value));
}
return key + ": " + encoded;
})
.join(", ")