release: WRNexusJS 0.8.3
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@wrnexus/compiler",
|
||||
"version": "0.8.2",
|
||||
"version": "0.8.3",
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
"exports": {
|
||||
|
||||
@@ -1,4 +1,10 @@
|
||||
import { eraseFunctionTypes, type PageAst, type RuntimeFunctionDecl } from "@wrnexus/syntax";
|
||||
import {
|
||||
eraseFunctionTypes,
|
||||
type PageAst,
|
||||
type RuntimeFunctionDecl,
|
||||
type StructuredImportDecl,
|
||||
type ViewNode,
|
||||
} from "@wrnexus/syntax";
|
||||
|
||||
const RESERVED_BINDINGS = new Set([
|
||||
"await",
|
||||
@@ -63,6 +69,111 @@ function safeIdentifier(name: string): boolean {
|
||||
return /^[A-Za-z_$][A-Za-z0-9_$]*$/.test(name) && !RESERVED_BINDINGS.has(name);
|
||||
}
|
||||
|
||||
function identifierReferenced(source: string, name: string): boolean {
|
||||
const escaped = name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
return new RegExp(`(?:^|[^A-Za-z0-9_$])${escaped}(?![A-Za-z0-9_$])`).test(source);
|
||||
}
|
||||
|
||||
function viewReferenceSource(nodes: ViewNode[]): string[] {
|
||||
return nodes.flatMap((node): string[] => {
|
||||
if (node.type === "text") return [node.value];
|
||||
if (node.type === "each") {
|
||||
return [
|
||||
node.list,
|
||||
node.key ?? "",
|
||||
...viewReferenceSource(node.body),
|
||||
...viewReferenceSource(node.empty),
|
||||
];
|
||||
}
|
||||
if (node.type === "if") {
|
||||
return node.branches.flatMap((branch) => [
|
||||
branch.cond ?? "",
|
||||
...viewReferenceSource(branch.body),
|
||||
]);
|
||||
}
|
||||
return [...node.attrs.map((attr) => attr.value), ...viewReferenceSource(node.children)];
|
||||
});
|
||||
}
|
||||
|
||||
function browserReferenceSource(ast: PageAst, functions: RuntimeFunctionDecl[]): string {
|
||||
return [
|
||||
...functions.flatMap((fn) => [fn.source, fn.body]),
|
||||
...ast.computed.map((entry) => entry.expr),
|
||||
...ast.effects,
|
||||
ast.lifecycle.mount ?? "",
|
||||
ast.lifecycle.update ?? "",
|
||||
ast.lifecycle.unmount ?? "",
|
||||
...viewReferenceSource(ast.view),
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
function renderSelectedImport(
|
||||
entry: StructuredImportDecl,
|
||||
source: string,
|
||||
): { code: string; bindings: string[] } | null {
|
||||
if (entry.typeOnly) return null;
|
||||
const isStore = entry.source.endsWith(".wrn") && /(?:^|\/)stores?\//.test(entry.source);
|
||||
if (entry.source.endsWith(".wrn") && !isStore) return null;
|
||||
|
||||
const defaultImport =
|
||||
entry.defaultImport && (isStore || identifierReferenced(source, entry.defaultImport))
|
||||
? entry.defaultImport
|
||||
: undefined;
|
||||
const namespaceImport =
|
||||
entry.namespaceImport && (isStore || identifierReferenced(source, entry.namespaceImport))
|
||||
? entry.namespaceImport
|
||||
: undefined;
|
||||
const namedImports = entry.namedImports.filter(
|
||||
(item) => !item.typeOnly && (isStore || identifierReferenced(source, item.local)),
|
||||
);
|
||||
|
||||
const bindings = [
|
||||
...(defaultImport ? [defaultImport] : []),
|
||||
...(namespaceImport ? [namespaceImport] : []),
|
||||
...namedImports.map((item) => item.local),
|
||||
].filter(safeIdentifier);
|
||||
|
||||
const hasBindings = Boolean(defaultImport || namespaceImport || namedImports.length);
|
||||
const sideEffectOnly =
|
||||
!entry.defaultImport && !entry.namespaceImport && entry.namedImports.length === 0;
|
||||
if (!hasBindings && !sideEffectOnly) return null;
|
||||
if (sideEffectOnly) return { code: entry.raw, bindings: [] };
|
||||
|
||||
const clauses: string[] = [];
|
||||
if (defaultImport) clauses.push(defaultImport);
|
||||
if (namespaceImport) clauses.push(`* as ${namespaceImport}`);
|
||||
if (namedImports.length) {
|
||||
clauses.push(
|
||||
`{ ${namedImports
|
||||
.map((item) =>
|
||||
item.imported === item.local ? item.imported : `${item.imported} as ${item.local}`,
|
||||
)
|
||||
.join(", ")} }`,
|
||||
);
|
||||
}
|
||||
return {
|
||||
code: `import ${clauses.join(", ")} from ${JSON.stringify(entry.source)};`,
|
||||
bindings,
|
||||
};
|
||||
}
|
||||
|
||||
function selectedBrowserImports(
|
||||
ast: PageAst,
|
||||
functions: RuntimeFunctionDecl[],
|
||||
): { code: string; bindings: string[] }[] {
|
||||
const referenceSource = browserReferenceSource(ast, functions);
|
||||
return ast.structuredImports
|
||||
.map((entry) => renderSelectedImport(entry, referenceSource))
|
||||
.filter((entry): entry is { code: string; bindings: string[] } => entry !== null);
|
||||
}
|
||||
|
||||
export function browserModuleRequired(ast: PageAst): boolean {
|
||||
const functions = ast.runtimeFunctions.filter((fn) =>
|
||||
["legacy", "client", "shared"].includes(fn.runtime),
|
||||
);
|
||||
return functions.length > 0 || selectedBrowserImports(ast, functions).length > 0;
|
||||
}
|
||||
|
||||
function functionEntry(
|
||||
ast: PageAst,
|
||||
fn: RuntimeFunctionDecl,
|
||||
@@ -164,22 +275,9 @@ export function generateBrowserModule(ast: PageAst): string {
|
||||
);
|
||||
const functionNames = functions.map((fn) => fn.name);
|
||||
const state = ast.states.filter((entry) => entry.runtime !== "server").map((entry) => entry.name);
|
||||
const storeImports = ast.structuredImports.filter(
|
||||
(entry) =>
|
||||
!entry.typeOnly && entry.source.endsWith(".wrn") && /(?:^|\/)stores?\//.test(entry.source),
|
||||
);
|
||||
const imports = ast.structuredImports
|
||||
.filter((entry) => !entry.typeOnly)
|
||||
.filter((entry) => !entry.source.endsWith(".wrn") || /(?:^|\/)stores?\//.test(entry.source))
|
||||
.map((entry) => entry.raw)
|
||||
.join("\n");
|
||||
const importedBindings = storeImports
|
||||
.flatMap((entry) => [
|
||||
...(entry.defaultImport ? [entry.defaultImport] : []),
|
||||
...(entry.namespaceImport ? [entry.namespaceImport] : []),
|
||||
...entry.namedImports.map((item) => item.local),
|
||||
])
|
||||
.filter(safeIdentifier);
|
||||
const selectedImports = selectedBrowserImports(ast, functions);
|
||||
const imports = selectedImports.map((entry) => entry.code).join("\n");
|
||||
const importedBindings = [...new Set(selectedImports.flatMap((entry) => entry.bindings))];
|
||||
return `// generated WRNexusJS browser module for ${ast.name}
|
||||
${imports}
|
||||
export const __wrnexusClientFunctions = {
|
||||
|
||||
@@ -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(", ")
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { existsSync, realpathSync } from "node:fs";
|
||||
import { existsSync, realpathSync, statSync } from "node:fs";
|
||||
import { dirname, extname, join, resolve } from "node:path";
|
||||
import type { StructuredImportDecl } from "@wrnexus/syntax";
|
||||
|
||||
@@ -33,12 +33,29 @@ export function resolveWrnImport(
|
||||
options: ImportResolverOptions,
|
||||
): ResolvedImport {
|
||||
const source = declaration.source;
|
||||
if (!source.startsWith(".") && !source.startsWith("@/")) return { declaration, resolved: source };
|
||||
const aliasRoot = options.aliases?.["@"] ?? "./app";
|
||||
const base = source.startsWith("@/")
|
||||
? resolve(options.appRoot, aliasRoot, source.slice(2))
|
||||
const aliases: Record<string, string> = {
|
||||
"@": "./app",
|
||||
...(options.aliases ?? {}),
|
||||
};
|
||||
const alias = Object.keys(aliases)
|
||||
.filter((key) => key.length > 0 && (source === key || source.startsWith(`${key}/`)))
|
||||
.sort((left, right) => right.length - left.length)[0];
|
||||
if (!source.startsWith(".") && !alias) return { declaration, resolved: source };
|
||||
const base = alias
|
||||
? resolve(
|
||||
options.appRoot,
|
||||
aliases[alias]!,
|
||||
source === alias ? "" : source.slice(alias.length + 1),
|
||||
)
|
||||
: resolve(dirname(importer), source);
|
||||
const found = candidates(base).find(existsSync);
|
||||
const found = candidates(base).find((candidate) => {
|
||||
if (!existsSync(candidate)) return false;
|
||||
try {
|
||||
return statSync(candidate).isFile();
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
});
|
||||
if (found) return { declaration, resolved: realpathSync(found) };
|
||||
const severity = (options.mode ?? "compatible") === "explicit" ? "error" : "warning";
|
||||
return {
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
import { expect, test } from "bun:test";
|
||||
import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { parse, resolveWrnImports } from "../src/index.ts";
|
||||
|
||||
test("configured import aliases resolve application client modules", () => {
|
||||
const root = mkdtempSync(join(tmpdir(), "wrnexus-import-alias-"));
|
||||
const app = join(root, "app");
|
||||
const page = join(app, "settings.wrn");
|
||||
const helper = join(app, "client", "storage.ts");
|
||||
const clientIndex = join(app, "client", "index.ts");
|
||||
mkdirSync(join(app, "client"), { recursive: true });
|
||||
writeFileSync(helper, "export function persist(value: unknown) { return value; }\n");
|
||||
writeFileSync(clientIndex, "export const storageReady = true;\n");
|
||||
|
||||
try {
|
||||
const ast = parse(`import { persist } from "~/client/storage.ts"
|
||||
page Settings { view { <button>Save</button> } }`);
|
||||
const [resolved] = resolveWrnImports(ast.structuredImports, page, {
|
||||
appRoot: root,
|
||||
mode: "explicit",
|
||||
aliases: { "~": "./app" },
|
||||
});
|
||||
expect(resolved?.diagnostic).toBeUndefined();
|
||||
expect(resolved?.resolved).toBe(helper);
|
||||
|
||||
const directoryAst = parse(`import { storageReady } from "~client"
|
||||
page ClientIndex { view { <p>Ready</p> } }`);
|
||||
const [directoryResolved] = resolveWrnImports(directoryAst.structuredImports, page, {
|
||||
appRoot: root,
|
||||
mode: "explicit",
|
||||
aliases: { "~client": "./app/client" },
|
||||
});
|
||||
expect(directoryResolved?.diagnostic).toBeUndefined();
|
||||
expect(directoryResolved?.resolved).toBe(clientIndex);
|
||||
} finally {
|
||||
rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,160 @@
|
||||
import { expect, test } from "bun:test";
|
||||
import { generate, generateTargets, parse } from "../src/index.ts";
|
||||
|
||||
test("page computed values derived from request state are declared during SSR", () => {
|
||||
const code = generate(
|
||||
parse(`import UserCard from "./UserCard.wrn"
|
||||
page Chat {
|
||||
state {
|
||||
currentUserId = ctx.url.searchParams.get("as") ?? "asha"
|
||||
users = [{ id: "asha", name: "Asha" }, { id: "rohan", name: "Rohan" }]
|
||||
}
|
||||
computed {
|
||||
currentUser = users.find((user) => user.id === currentUserId) ?? users[0]
|
||||
}
|
||||
view {
|
||||
<h1>{currentUser.name}</h1>
|
||||
{#each users as user}
|
||||
<UserCard user='{user}' />
|
||||
<div data-user='{user.id}'></div>
|
||||
{/each}
|
||||
}
|
||||
}`),
|
||||
);
|
||||
|
||||
expect(code).toContain("const currentUser =");
|
||||
expect(code).toContain("__wrnexusEscapeHtml(currentUser.name)");
|
||||
expect(code).toContain("__wrnexusPropAttr(user)");
|
||||
expect(code).toContain("__wrnexusEscapeHtml(user.id)");
|
||||
expect(code).not.toContain("__wrnexusPropAttr(user.id)");
|
||||
expect(code).toContain('encoded = value === undefined ? "undefined" : JSON.stringify(value)');
|
||||
expect(code).not.toContain("String((__state as any)[key])");
|
||||
});
|
||||
|
||||
test("computed values that reference ctx directly stay dynamic during SSR", () => {
|
||||
const code = generate(
|
||||
parse(`page DirectRequestComputed {
|
||||
computed { identity = ctx.url.searchParams.get("as") ?? "asha" }
|
||||
view { <p>{identity}</p> }
|
||||
}`),
|
||||
);
|
||||
expect(code).toContain("const identity =");
|
||||
expect(code).toContain("__wrnexusEscapeHtml(identity)");
|
||||
});
|
||||
|
||||
test("computed values derived from server loads stay dynamic during SSR", () => {
|
||||
const code = generate(
|
||||
parse(`page LoadedComputed {
|
||||
load server overview { return { count: 4 } }
|
||||
computed { total = overview.count + 1 }
|
||||
view { <p>{total}</p> }
|
||||
}`),
|
||||
);
|
||||
expect(code).toContain('const overview = ctx["overview"]');
|
||||
expect(code).toContain("__wrnexusEscapeHtml(total)");
|
||||
});
|
||||
|
||||
test("request-derived state dependencies stay dynamic during SSR", async () => {
|
||||
const code = generate(
|
||||
parse(`page Identity {
|
||||
state {
|
||||
userId = ctx.url.searchParams.get("as") ?? "asha"
|
||||
label = "User: " + userId
|
||||
}
|
||||
view { <p>{label}</p> }
|
||||
}`),
|
||||
);
|
||||
expect(code).toContain("__wrnexusEscapeHtml(label)");
|
||||
expect(code).not.toContain('<span data-text="label"></span>');
|
||||
const javascript = new Bun.Transpiler({ loader: "ts" }).transformSync(code);
|
||||
const moduleUrl = `data:text/javascript;base64,${Buffer.from(javascript).toString("base64")}`;
|
||||
const rendered = await (
|
||||
await import(moduleUrl)
|
||||
).default({
|
||||
url: new URL("http://localhost/?as=rohan"),
|
||||
});
|
||||
expect(rendered).toContain("User: rohan");
|
||||
});
|
||||
|
||||
test("Async aliases and invalidation tags are scoped in generated templates", () => {
|
||||
const code = generate(
|
||||
parse(`page Uploads {
|
||||
load client uploads { return { name: "report.pdf" } }
|
||||
view {
|
||||
<Async source="uploads" tags="uploads,files">
|
||||
<Loading>Loading</Loading>
|
||||
<Success data="file"><a href="/files/{file.name}">{file.name}</a></Success>
|
||||
<Error error="problem"><p>{problem.message}</p></Error>
|
||||
</Async>
|
||||
}
|
||||
}`),
|
||||
);
|
||||
|
||||
expect(code).toContain('data-wrn-async-tags="uploads,files"');
|
||||
expect(code).toContain('data-wrn-async-alias="file"');
|
||||
expect(code).toContain('data-wrn-async-alias="problem"');
|
||||
expect(code).toContain("const file =");
|
||||
expect(code).toContain("const problem =");
|
||||
});
|
||||
|
||||
test("browser codegen prunes SSR-only UI imports and keeps client dependencies", () => {
|
||||
const browser = generateTargets(
|
||||
parse(`import { Button } from "@wrnexus/ui"
|
||||
import { save } from "./helper.ts"
|
||||
component Settings {
|
||||
state { enabled = true }
|
||||
functions {
|
||||
client function persist(): void { save(enabled) }
|
||||
}
|
||||
view {
|
||||
<Button label="Save" />
|
||||
<button @click='persist()'>Save</button>
|
||||
}
|
||||
}`),
|
||||
).browser;
|
||||
|
||||
expect(browser).not.toContain("@wrnexus/ui");
|
||||
expect(browser).toContain('import { save } from "./helper.ts"');
|
||||
expect(browser).toContain('"persist": function');
|
||||
});
|
||||
|
||||
test("reactive view imports trigger a browser module without hydrating static SSR helpers", () => {
|
||||
const reactiveAst = parse(`import { formatName } from "./format.ts"
|
||||
page Profile {
|
||||
state { name = "Asha" }
|
||||
view { <p>{formatName(name)}</p> }
|
||||
}`);
|
||||
const staticAst = parse(`import { readFileSync } from "node:fs"
|
||||
page StaticProfile {
|
||||
runtime = "server"
|
||||
view { <p>{readFileSync("profile.txt", "utf8")}</p> }
|
||||
}`);
|
||||
|
||||
expect(generate(reactiveAst)).toContain('data-wrn-client-module="__WRNEXUS_CLIENT_MODULE__"');
|
||||
expect(generateTargets(reactiveAst).browser).toContain(
|
||||
'import { formatName } from "./format.ts"',
|
||||
);
|
||||
expect(generate(staticAst)).not.toContain("data-wrn-client-module");
|
||||
});
|
||||
|
||||
test("state dependency cycles fail code generation", () => {
|
||||
expect(() =>
|
||||
generate(
|
||||
parse(`page CyclicState {
|
||||
state { first = second + 1 second = first + 1 }
|
||||
view { <p>{first}</p> }
|
||||
}`),
|
||||
),
|
||||
).toThrow("WRN-STATE-CYCLE");
|
||||
});
|
||||
|
||||
test("computed dependency cycles fail code generation", () => {
|
||||
expect(() =>
|
||||
generate(
|
||||
parse(`page Cyclic {
|
||||
computed { first = second + 1 second = first + 1 }
|
||||
view { <p>{first}</p> }
|
||||
}`),
|
||||
),
|
||||
).toThrow("WRN-COMPUTED-CYCLE");
|
||||
});
|
||||
Reference in New Issue
Block a user