Files
WRNexusJS/packages/compiler/src/codegen.ts
T
2026-07-18 13:12:16 +05:30

1595 lines
47 KiB
TypeScript

/**
* Code generation: lower a `.wrn` AST to TypeScript that targets the framework's
* existing primitives.
*
* state -> a `data-scope` declaration consumed by the runtime
* view -> an HTML string returned by a page component
* @event="..." -> data-on-<event>="..."
* "...{expr}..." -> text kept verbatim ({expr} is mustache for runtime)
* api="<name>" -> SSR/client data binding declared in a mode block
* ssrGet/ssrText -> legacy server-side API fetch + render
* csrGet/csrText -> legacy browser-side API fetch + render
* style -> an inline page stylesheet
* functions -> server-only helpers for API/realtime code
* api M /p {b} -> export const M = async (ctx) => { b }
* realtime {..} -> export const websocket = { evt(ws, ...args) { b } }
*/
import { Buffer } from "node:buffer";
import { VOID_ELEMENTS, type Attr, type DataMode, type PageAst, type ViewNode } from "./parser.ts";
interface RenderBinding {
method: string;
path: string;
body: string;
helpers: string;
}
interface SsrBinding extends RenderBinding {
marker: string;
}
interface CsrBinding extends RenderBinding {
id: string;
}
interface NamedDataBinding extends RenderBinding {
mode: DataMode;
}
function isComponentTag(tag: string): boolean {
return /^[A-Z][A-Za-z0-9_$]*$/.test(tag);
}
/** Escape a value placed inside a double-quoted HTML attribute. */
function attrEscape(value: string): string {
return value
.replace(/&/g, "&amp;")
.replace(/"/g, "&quot;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;");
}
/** Make HTML safe to embed inside a JS template literal. */
function templateEscape(html: string): string {
return html.replace(/\\/g, "\\\\").replace(/`/g, "\\`").replace(/\$\{/g, "\\${");
}
function styleEscape(css: string): string {
return css.replace(/<\/style/gi, "<\\/style");
}
function attrValue(attrs: Attr[], name: string): string | undefined {
return attrs.find((attr) => !attr.event && attr.name === name)?.value;
}
function renderAttr(attr: Attr): string {
if (attr.event) return ` ${eventAttribute(attr.name)}="${attrEscape(attr.value)}"`;
switch (attr.name) {
case "api":
case "ssrGet":
case "ssrText":
case "csrGet":
case "csrText":
return "";
default:
return attr.boolean ? ` ${attr.name}` : ` ${attr.name}="${attrEscape(attr.value)}"`;
}
}
function eventAttribute(name: string): string {
if (name.startsWith("window:")) {
return `data-on-window-${name.slice("window:".length)}`;
}
if (name.startsWith("document:")) {
return `data-on-document-${name.slice("document:".length)}`;
}
if (name.startsWith("browser-")) {
return `data-on-wrnexus-browser-${name.slice(8)}`;
}
if (name.startsWith("mobile-")) {
return `data-on-wrnexus-mobile-${name.slice(7)}`;
}
return `data-on-${name}`;
}
function reactiveAttrValue(raw: string, reactive: PageReactive): string | null {
let found = false;
const value = raw.replace(/\{([^{}]+)\}/g, (whole, inner: string) => {
const expr = inner.trim();
if (!exprRefsState(expr, reactive.stateNames)) return whole;
found = true;
try {
const result = new Function("with(this){return (" + expr + ");}").call(reactive.scope);
return result == null ? "" : String(result);
} catch {
return whole;
}
});
return found ? value : null;
}
function renderAttrs(attrs: Attr[], csrId?: string, reactive: PageReactive | null = null): string {
let bindIndex = 0;
const rendered = attrs
.map((attr) => {
const base = renderAttr(attr);
if (!reactive || attr.event || attr.boolean || !base || !attr.value.includes("{"))
return base;
const initial = reactiveAttrValue(attr.value, reactive);
if (initial === null) return base;
const marker = JSON.stringify([attr.name, attr.value]);
return ` ${attr.name}="${attrEscape(initial)}" data-wrn-bind-${bindIndex++}="${attrEscape(marker)}"`;
})
.join("");
return csrId ? `${rendered} data-wrnexus-csr="${attrEscape(csrId)}"` : rendered;
}
/**
* Replace i18n text sugar `{t:key}` with a `<span data-t="key">` marker the
* runtime resolves server-side. Other `{expr}` mustaches are left untouched.
*/
function substituteTMarkers(text: string): string {
return text.replace(
/\{t:([^{}]+)\}/g,
(_m, key: string) => `<span data-t="${attrEscape(key.trim())}"></span>`,
);
}
/** Escape a value for safe embedding in HTML text. */
function htmlTextEscape(value: string): string {
return value.replace(/[&<>]/g, (c) => (c === "&" ? "&amp;" : c === "<" ? "&lt;" : "&gt;"));
}
/** Reactive page context: state names + their initial (SSR) values. */
interface PageReactive {
stateNames: Set<string>;
scope: Record<string, unknown>;
}
/**
* Evaluate a page's `state` seed expressions at compile time to obtain the
* initial SSR values used to bake `data-text` spans. Seeds may reference
* earlier ones; anything that can't be evaluated becomes `undefined`.
*/
function evalStateSeeds(states: { name: string; expr: string }[]): Record<string, unknown> {
const scope: Record<string, unknown> = {};
for (const s of states) {
try {
scope[s.name] = new Function("with(this){return (" + s.expr + ");}").call(scope);
} catch {
scope[s.name] = undefined;
}
}
return scope;
}
/**
* Page text compilation: resolve `{t:key}` i18n markers, then bake state
* interpolations (`{count}`, `{count * 2}`) into `data-text` spans carrying the
* evaluated initial value — so no-JS clients see real content and the reactive
* 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 {
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;
let value: unknown;
try {
value = new Function("with(this){return (" + expr + ");}").call(reactive.scope);
} catch {
return whole; // can't evaluate → keep as a client-only mustache
}
const baked = htmlTextEscape(value == null ? "" : String(value));
return `<span data-text="${attrEscape(expr)}">${baked}</span>`;
});
}
type EachNode = Extract<ViewNode, { type: "each" }>;
type IfNode = Extract<ViewNode, { type: "if" }>;
/**
* Bake a loop-body text run into template-literal source: static text is escaped
* for the literal, `{expr}` becomes `${__wrnexusEscapeHtml(expr)}` (server-rendered,
* escaped), and `{t:key}` becomes a `data-t` marker resolved later by translateHtml.
*/
function bakeLoopText(raw: string): string {
let out = "";
let last = 0;
let m: RegExpExecArray | null;
const re = /\{([^{}]+)\}/g;
while ((m = re.exec(raw))) {
out += escLit(raw.slice(last, m.index));
const expr = m[1]!.trim();
if (expr.startsWith("t:")) {
out += escLit(`<span data-t="${attrEscape(expr.slice(2).trim())}"></span>`);
} else {
out += "${__wrnexusEscapeHtml(" + expr + ")}";
}
last = m.index + m[0].length;
}
return out + escLit(raw.slice(last));
}
/** Bake a loop-body attribute value (same rules as text; escapeHtml is attribute-safe). */
function bakeLoopAttr(raw: string): string {
if (!raw.includes("{")) return escLit(attrEscape(raw));
let out = "";
let last = 0;
let m: RegExpExecArray | null;
const re = /\{([^{}]+)\}/g;
while ((m = re.exec(raw))) {
out += escLit(attrEscape(raw.slice(last, m.index)));
out += "${__wrnexusEscapeHtml(" + m[1]!.trim() + ")}";
last = m.index + m[0].length;
}
return out + escLit(attrEscape(raw.slice(last)));
}
/** Render one loop-body node to template-literal source (nested loops inline). */
function renderLoopBody(node: ViewNode): string {
if (node.type === "text") {
return bakeLoopText(node.value);
}
if (node.type === "each") {
return compileEachExpr(node);
}
if (node.type === "if") {
return compileIfExpr(node);
}
const componentTag = isComponentTag(node.tag);
const attrs = node.attrs
.filter((attr) => attr.name !== "data-component")
.map((attr) => {
const name = attr.event ? eventAttribute(attr.name) : attr.name;
if (attr.boolean) {
return escLit(` ${name}`);
}
return escLit(` ${name}="`) + bakeLoopAttr(attr.value) + escLit(`"`);
})
.join("");
const inner = node.children.map(renderLoopBody).join("");
if (componentTag) {
return (
escLit(`<div data-component="${attrEscape(node.tag)}"`) +
attrs +
escLit(">") +
inner +
escLit("</div>")
);
}
if (VOID_ELEMENTS.has(node.tag.toLowerCase())) {
return escLit(`<${node.tag}`) + attrs + escLit(">");
}
return escLit(`<${node.tag}`) + attrs + escLit(">") + inner + escLit(`</${node.tag}>`);
}
/**
* Compile a `{#each list as item}` block to a `${…}` template-literal interpolation
* that iterates the (server-evaluated) list and joins the per-item body. `list` is a
* JS expression evaluated where `ssr` data bindings are in scope as raw named values.
*/
function compileEachExpr(node: EachNode): string {
const item = node.item;
const index = node.index ?? "__wi";
const body = node.body.map(renderLoopBody).join("");
const empty = node.empty.map(renderLoopBody).join("");
return (
"${(() => { const __wl = Array.isArray(" +
node.list +
") ? (" +
node.list +
") : []; return __wl.length ? __wl.map((" +
item +
", " +
index +
") => `" +
body +
'`).join("") : `' +
empty +
"`; })()}"
);
}
/**
* Compile a `{#if}` block to a `${…}` template-literal interpolation: a nested ternary
* that renders the first truthy branch's body (or the `{:else}` body, or "" when neither).
* Conditions are JS expressions evaluated in the surrounding server scope.
*/
function compileIfExpr(node: IfNode): string {
let expr = "``"; // no matching branch → empty string
for (let k = node.branches.length - 1; k >= 0; k--) {
const b = node.branches[k]!;
const bodySrc = "`" + b.body.map(renderLoopBody).join("") + "`";
expr = b.cond === null ? bodySrc : "(" + b.cond + ") ? " + bodySrc + " : " + expr;
}
return "${" + expr + "}";
}
/**
* Collect every server-control expression in a view (recursively): `{#each}` list
* expressions and `{#if}` conditions. Used to wire up raw SSR data consts.
*/
function collectControlExprs(nodes: ViewNode[], out: string[] = []): string[] {
for (const node of nodes) {
if (node.type === "each") {
out.push(node.list);
collectControlExprs(node.body, out);
collectControlExprs(node.empty, out);
} else if (node.type === "if") {
for (const b of node.branches) {
if (b.cond) out.push(b.cond);
collectControlExprs(b.body, out);
}
} else if (node.type === "element") {
collectControlExprs(node.children, out);
}
}
return out;
}
function renderNode(
node: ViewNode,
ssrBindings: SsrBinding[],
csrBindings: CsrBinding[],
apiBindings: Map<string, NamedDataBinding>,
loops: string[],
reactive: PageReactive | null = null,
): string {
if (node.type === "text") return substituteReactiveText(node.value, reactive); // {t:key} + state baking
// Server control block (loop / conditional) → a sentinel that survives
// templateEscape, swapped for its real `${…}` code after escaping.
if (node.type === "each" || node.type === "if") {
loops.push(node.type === "each" ? compileEachExpr(node) : compileIfExpr(node));
return `\x00WRNEACH${loops.length - 1}\x00`;
}
if (isComponentTag(node.tag)) {
return renderPageComponentInvocation(
node,
ssrBindings,
csrBindings,
apiBindings,
loops,
reactive,
);
}
const apiName = attrValue(node.attrs, "api");
const apiBinding = apiName ? apiBindings.get(apiName) : undefined;
if (apiName && !apiBinding) {
throw new Error(`Unknown .wrn api binding "${apiName}"`);
}
const ssrGet = attrValue(node.attrs, "ssrGet");
const ssrText = attrValue(node.attrs, "ssrText");
const csrGet = attrValue(node.attrs, "csrGet");
const csrText = attrValue(node.attrs, "csrText");
const csrId =
apiBinding?.mode === "client"
? csrMarker(csrBindings, renderBinding(apiBinding))
: csrGet && csrText
? csrMarker(csrBindings, {
method: "GET",
path: apiRoutePath(csrGet),
body: expressionBody(csrText),
helpers: "",
})
: undefined;
// Void elements (<br>, <img>, …) have no closing tag and no children.
if (VOID_ELEMENTS.has(node.tag.toLowerCase())) {
return `<${node.tag}${renderAttrs(node.attrs, csrId, reactive)}>`;
}
const inner =
apiBinding?.mode === "ssr"
? ssrMarker(ssrBindings, renderBinding(apiBinding))
: ssrGet && ssrText
? ssrMarker(ssrBindings, {
method: "GET",
path: apiRoutePath(ssrGet),
body: expressionBody(ssrText),
helpers: "",
})
: node.children
.map((child) =>
renderNode(child, ssrBindings, csrBindings, apiBindings, loops, reactive),
)
.join("");
return `<${node.tag}${renderAttrs(node.attrs, csrId, reactive)}>${inner}</${node.tag}>`;
}
function renderPageComponentInvocation(
node: Extract<ViewNode, { type: "element" }>,
ssrBindings: SsrBinding[],
csrBindings: CsrBinding[],
apiBindings: Map<string, NamedDataBinding>,
loops: string[],
reactive: PageReactive | null,
): string {
const attrs = node.attrs
.filter((attr) => attr.name !== "data-component")
.map((attr) => renderPageComponentAttr(attr, loops))
.join("");
const inner = node.children
.map((child) => renderNode(child, ssrBindings, csrBindings, apiBindings, loops, reactive))
.join("");
return `<div data-component="${attrEscape(node.tag)}"` + `${attrs}>${inner}</div>`;
}
function renderNestedComponentInvocation(
node: Extract<ViewNode, { type: "element" }>,
ctx: CompCtx,
): string {
let bindIndex = 0;
const attrs = node.attrs
.filter((attr) => attr.name !== "data-component")
.map((attr) => {
if (attr.event) {
return (
escLit(` ${eventAttribute(attr.name)}="`) + escLit(attrEscape(attr.value)) + escLit(`"`)
);
}
if (attr.boolean) {
return ` ${attr.name}`;
}
const wholeExpression = wholeAttributeExpression(attr.value);
const compiledValue = wholeExpression
? `\${__wireProp(${ctx.resolveExpr(wholeExpression)})}`
: compileAttrValue(attr.value, ctx);
const rendered = ` ${attr.name}="${compiledValue}"`;
if (
wholeExpression ||
!attr.value.includes("{") ||
!exprRefsState(attr.value, ctx.stateNames)
) {
return rendered;
}
const marker = attrEscape(JSON.stringify([attr.name, attr.value]));
return rendered + ` data-wrn-bind-${bindIndex++}="${escLit(marker)}"`;
})
.join("");
const loops = loopVarsOf(node);
const childCtx =
loops.length > 0
? {
...ctx,
loopVars: new Set([...(ctx.loopVars ?? []), ...loops]),
}
: ctx;
const inner = node.children.map((child) => renderComponentNode(child, childCtx)).join("");
return `<div data-component="${attrEscape(node.tag)}"${attrs}>${inner}</div>`;
}
function ssrMarker(bindings: SsrBinding[], binding: RenderBinding): string {
const marker = `<!--wrnexus-ssr:${bindings.length}-->`;
bindings.push({ marker, ...binding });
return marker;
}
function csrMarker(bindings: CsrBinding[], binding: RenderBinding): string {
const id = String(bindings.length);
bindings.push({ id, ...binding });
return id;
}
function renderBinding(binding: NamedDataBinding): RenderBinding {
return {
method: binding.method,
path: binding.path,
body: binding.body,
helpers: binding.helpers,
};
}
function hasClientBehavior(nodes: ViewNode[]): boolean {
return nodes.some((node) => {
// `{t:key}` is i18n sugar resolved server-side — not client reactivity.
if (node.type === "text") return /\{(?!t:)[^{}]+\}/.test(node.value);
// Server control blocks render on the server; they don't add client reactivity.
if (node.type === "each") {
return hasClientBehavior(node.body) || hasClientBehavior(node.empty);
}
if (node.type === "if") {
return node.branches.some((branch) => hasClientBehavior(branch.body));
}
return (
node.attrs.some((attr) => attr.event || attr.name === "csrGet" || attr.name === "csrText") ||
hasClientBehavior(node.children)
);
});
}
function apiRoutePath(path: string): string {
const trimmed = path.trim();
if (!trimmed.startsWith("/")) {
throw new Error(`.wrn API paths must start with "/": ${path}`);
}
if (trimmed.includes("\0") || trimmed.includes("\\") || /(^|\/)\.\.(\/|$)/.test(trimmed)) {
throw new Error(`Unsafe .wrn API path: ${path}`);
}
if (trimmed === "/api" || trimmed.startsWith("/api/")) return trimmed;
return `/api${trimmed}`;
}
function expressionBody(expr: string): string {
return `return (${expr});`;
}
function dataBody(source: string): string {
const trimmed = source.trim();
if (!trimmed) return "return undefined;";
return /\breturn\b/.test(trimmed) ? trimmed : expressionBody(trimmed);
}
function modeHelpers(ast: PageAst, mode: DataMode, sharedHelpers: string): string {
return [
sharedHelpers,
...ast.modeFunctions
.filter((block) => block.mode === mode)
.map((block) => block.body.trim())
.filter(Boolean),
]
.filter(Boolean)
.join("\n\n");
}
function apiBindingMap(ast: PageAst, sharedHelpers: string): Map<string, NamedDataBinding> {
const bindings = new Map<string, NamedDataBinding>();
for (const block of ast.dataApis) {
if (bindings.has(block.name)) {
throw new Error(`Duplicate .wrn api binding "${block.name}"`);
}
bindings.set(block.name, {
mode: block.mode,
method: block.method,
path: apiRoutePath(block.path),
body: dataBody(block.body),
helpers: modeHelpers(ast, block.mode, sharedHelpers),
});
}
return bindings;
}
function ssrRuntimeSource(): string {
return `const __wrnexusHtmlEscapes = { "&": "&amp;", "<": "&lt;", ">": "&gt;", "\\"": "&quot;", "'": "&#39;" };
function __wrnexusEscapeHtml(value: unknown): string {
return String(value).replace(/[&<>"']/g, (ch) => __wrnexusHtmlEscapes[ch] ?? ch);
}
function __wrnexusEvalData(data: unknown, body: string, helpers = "", ctx: any): unknown {
const adapters = {
cookies: ctx.cookies,
session: ctx.session,
localStorage: ctx.localStorage,
};
return new Function("$data", "$adapters", "const cookies = $adapters.cookies;\\nconst session = $adapters.session;\\nconst localStorage = $adapters.localStorage;\\nwith ($data ?? {}) {\\n" + helpers + "\\n" + body + "\\n}")(data, adapters);
}
function __wrnexusPropAttr(
value: unknown,
): string {
const serialized =
value !== null &&
typeof value === "object"
? JSON.stringify(value)
: String(value == null ? "" : value);
return serialized.replace(
/[&<>"]/g,
(character) =>
character === "&"
? "&amp;"
: character === "<"
? "&lt;"
: character === ">"
? "&gt;"
: "&quot;",
);
}
async function __wrnexusCallApi(path: string, method: string, ctx: any): Promise<unknown> {
if (typeof ctx.__wrnexusCallApi === "function") {
return await ctx.__wrnexusCallApi(path, method);
}
const url = new URL(path, ctx.req.url);
const res = await fetch(new Request(url, { method, headers: ctx.req.headers }));
if (!res.ok) {
throw new Error(".wrn data API request failed with status " + res.status);
}
const type = res.headers.get("content-type") || "";
return type.includes("application/json") ? await res.json() : await res.text();
}
async function __wrnexusRenderSsrBindings(html: string, ctx: any): Promise<string> {
for (const binding of __wrnexusSsrBindings) {
const data = await __wrnexusCallApi(binding.path, binding.method, ctx);
const value = __wrnexusEvalData(data, binding.body, binding.helpers, ctx);
html = html.replace(binding.marker, __wrnexusEscapeHtml(value));
}
return html;
}`;
}
export function generate(ast: PageAst): string {
if (ast.kind === "component" || ast.kind === "layout") {
return generateComponent(ast);
}
const out: string[] = [];
const ssrBindings: SsrBinding[] = [];
const csrBindings: CsrBinding[] = [];
const helpers = ast.functions
.map((body) => body.trim())
.filter(Boolean)
.join("\n\n");
const apiBindings = apiBindingMap(ast, helpers);
if (helpers) {
out.push(`// --- .wrn functions ---\n${helpers}`);
}
// --- Page metadata / SEO ---
out.push(`export const meta = ${JSON.stringify({ title: ast.name, ...ast.seo }, null, 2)};`);
if (ast.layout) out.push(`export const layout = ${JSON.stringify(ast.layout)};`);
// --- View -> default page component ---
const reactive: PageReactive | null =
ast.states.length > 0
? { stateNames: new Set(ast.states.map((s) => s.name)), scope: evalStateSeeds(ast.states) }
: null;
const loops: string[] = [];
let html = ast.view
.map((node) => renderNode(node, ssrBindings, csrBindings, apiBindings, loops, reactive))
.join("");
const styles = ast.styles.map((body) => body.trim()).filter(Boolean);
const needsClientRuntime = ast.states.length > 0 || hasClientBehavior(ast.view);
if (needsClientRuntime) {
const scopePlaceholder = "__WRNEXUS_DYNAMIC_SCOPE__";
html = `<div data-scope="${scopePlaceholder}">${html}</div>`;
}
if (styles.length > 0) {
const css = styles.map(styleEscape).join("\n");
html = `<style data-wrnexus-style="${attrEscape(ast.name)}">\n${css}\n</style>${html}`;
}
if (csrBindings.length > 0) {
out.push(`export const __wrnexusCsr = ${JSON.stringify(csrBindings, null, 2)};`);
}
// 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);
const dynamicStateScope = ast.states
.map(
(state) =>
`${JSON.stringify(state.name)}: (() => { try { return (${state.expr}); } catch { return undefined; } })()`,
)
.join(", ");
loops.forEach((code, idx) => {
body = body.replace(`\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.
const loopConsts: string[] = [];
if (loops.length > 0) {
const lists = collectControlExprs(ast.view);
for (const [name, binding] of apiBindings) {
if (binding.mode !== "ssr") continue;
if (!lists.some((expr) => new RegExp(`\\b${name}\\b`).test(expr))) continue;
loopConsts.push(
` const ${name} = __wrnexusEvalData(await __wrnexusCallApi(${JSON.stringify(binding.path)}, ${JSON.stringify(binding.method)}, ctx), ${JSON.stringify(binding.body)}, ${JSON.stringify(binding.helpers)}, ctx);`,
);
}
}
const needsSsrRuntime =
ssrBindings.length > 0 ||
loops.length > 0 ||
ast.states.some((state) => /\bctx\b/.test(state.expr));
if (needsSsrRuntime) {
out.push(ssrRuntimeSource());
out.push(`const __wrnexusSsrBindings = ${JSON.stringify(ssrBindings, null, 2)};`);
const decls = loopConsts.length > 0 ? loopConsts.join("\n") + "\n" : "";
out.push(
`export default async function ${ast.name}(ctx: any) {
${decls}
const __state = { ${dynamicStateScope} };
const __scopeValue = Object.entries(__state)
.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, "&amp;")
.replace(/"/g, "&quot;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;");
const html = \`${body}\`.replace(
"__WRNEXUS_DYNAMIC_SCOPE__",
__scopeValue,
);
return await __wrnexusRenderSsrBindings(html, ctx);
}`,
);
} else {
out.push(
`export default function ${ast.name}(ctx: any) {
const __state = { ${dynamicStateScope} };
const __scopeValue = Object.entries(__state)
.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, "&amp;")
.replace(/"/g, "&quot;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;");
return \`${body}\`.replace(
"__WRNEXUS_DYNAMIC_SCOPE__",
__scopeValue,
);
}`,
);
}
// --- API blocks -> method handlers ---
if (ast.apis.length > 0) {
ast.apis.forEach((api, index) => {
const name = `__wrnexusApi_${api.method}_${index}`;
out.push(`// ${api.method} ${apiRoutePath(api.path)}
const ${name} = async (ctx: any) => {${api.body}};`);
});
const entries = ast.apis.map(
(api, index) =>
` ${JSON.stringify(`${api.method} ${apiRoutePath(api.path)}`)}: __wrnexusApi_${api.method}_${index},`,
);
out.push(`export const __wrnexusApi = {\n${entries.join("\n")}\n};`);
const exported = new Set<string>();
ast.apis.forEach((api, index) => {
if (exported.has(api.method)) return;
exported.add(api.method);
out.push(`export const ${api.method} = __wrnexusApi_${api.method}_${index};`);
});
}
// --- Realtime blocks -> a websocket export ---
if (ast.realtimes.length > 0) {
const handlers = ast.realtimes.flatMap((rt) =>
rt.handlers.map((h) => {
const params = ["ws", ...h.args].join(", ");
return ` ${h.event}(${params}: any) {${h.body}},`;
}),
);
out.push(`export const websocket = {\n${handlers.join("\n")}\n};`);
}
return out.join("\n\n") + "\n";
}
/**
* Lower a `component` AST to a module exporting `render(props)`.
*
* A component is server-rendered on demand at each `data-component` mount and
* hydrated on the browser by the generic reactive runtime — it ships no JS of
* its own. Declared props are coerced to the type of their default value, then
* seeded (with any `state`) into the `data-scope` the reactive runtime reads.
*/
interface CompCtx {
/** State names — text referencing any of them stays a reactive client mustache. */
stateNames: Set<string>;
/** Rewrite reserved-word prop/state identifiers to their safe const names. */
resolveExpr: (expr: string) => string;
/** `data-for` loop variables in scope — their mustaches stay literal for the
* client's list renderer (never baked server-side, since they have no value). */
loopVars?: Set<string>;
/** Local identifiers introduced by server-rendered `{#each}` blocks. */
serverLocals?: Set<string>;
}
interface ComponentBehavior {
functions: string;
lifecycle: {
mount?: string;
update?: string;
unmount?: string;
};
watches: Array<{
state: string;
body: string;
}>;
}
/** Parse a `data-for="item in list"` / `"item, i in list"` directive value. */
export function parseForExpr(value: string): { item: string; index?: string; list: string } | null {
const m = /^\s*([A-Za-z_$][\w$]*)\s*(?:,\s*([A-Za-z_$][\w$]*)\s*)?\s+in\s+([\s\S]+?)\s*$/.exec(
value,
);
if (!m) return null;
return { item: m[1]!, index: m[2], list: m[3]! };
}
/** The loop variables a node introduces via `data-for`, if any. */
function loopVarsOf(node: ViewNode): string[] {
if (node.type !== "element") return [];
const attr = node.attrs.find((a) => !a.event && a.name === "data-for");
if (!attr) return [];
const parsed = parseForExpr(attr.value);
return parsed ? [parsed.item, ...(parsed.index ? [parsed.index] : [])] : [];
}
/** JS reserved words that cannot be used as a plain `const` name. */
const JS_RESERVED = new Set([
"class",
"for",
"default",
"function",
"return",
"if",
"else",
"new",
"delete",
"typeof",
"in",
"instanceof",
"void",
"do",
"while",
"switch",
"case",
"break",
"continue",
"this",
"super",
"import",
"export",
"extends",
"var",
"let",
"const",
"null",
"true",
"false",
"try",
"catch",
"finally",
"throw",
"yield",
"await",
"enum",
"with",
"debugger",
]);
/** A JS reference for a prop/state name (reserved words get a `__p_` prefix). */
function safeRef(name: string): string {
return JS_RESERVED.has(name) ? `__p_${name}` : name;
}
/** Escape a literal segment so it is safe inside a JS template literal. */
function escLit(s: string): string {
return s.replace(/\\/g, "\\\\").replace(/`/g, "\\`").replace(/\$\{/g, "\\${");
}
function componentBehavior(ast: PageAst): ComponentBehavior | null {
const functions = ast.functions
.map((body) => body.trim())
.filter(Boolean)
.join("\n\n");
const lifecycle = {
...(ast.lifecycle.mount?.trim() ? { mount: ast.lifecycle.mount.trim() } : {}),
...(ast.lifecycle.update?.trim() ? { update: ast.lifecycle.update.trim() } : {}),
...(ast.lifecycle.unmount?.trim() ? { unmount: ast.lifecycle.unmount.trim() } : {}),
};
const watches = ast.watches.map((watch) => ({
state: watch.state,
body: watch.body.trim(),
}));
if (!functions && Object.keys(lifecycle).length === 0 && watches.length === 0) {
return null;
}
return {
functions,
lifecycle,
watches,
};
}
function behaviorAttribute(behavior: ComponentBehavior | null): string {
if (!behavior) {
return "";
}
const encoded = Buffer.from(JSON.stringify(behavior), "utf8").toString("base64");
return ` data-wrn-behavior="${encoded}"`;
}
const INTERP_RE = /\{([^{}]+)\}/g;
function exprRefsState(expr: string, stateNames: Set<string>): boolean {
for (const name of stateNames) {
if (new RegExp(`\\b${name}\\b`).test(expr)) return true;
}
return false;
}
function viewHasEvents(nodes: ViewNode[]): boolean {
return nodes.some((node) => {
if (node.type === "text") return false;
if (node.type === "each") {
return viewHasEvents(node.body) || viewHasEvents(node.empty);
}
if (node.type === "if") {
return node.branches.some((branch) => viewHasEvents(branch.body));
}
return node.attrs.some((attr) => attr.event) || viewHasEvents(node.children);
});
}
function viewHasServerEach(nodes: ViewNode[]): boolean {
return nodes.some((node) => {
if (node.type === "text") return false;
if (node.type === "each") return true;
if (node.type === "if") {
return node.branches.some((branch) => viewHasServerEach(branch.body));
}
return viewHasServerEach(node.children);
});
}
/**
* Compile a text node. Interpolations that reference state stay as client
* mustaches (`{expr}`, hydrated by the reactive runtime); interpolations of
* props/constants are baked server-side (`${__wireHtml(expr)}`), so static
* components render correct HTML with zero JavaScript.
*/
function compileText(raw: string, ctx: CompCtx): string {
let out = "";
let last = 0;
let m: RegExpExecArray | null;
INTERP_RE.lastIndex = 0;
while ((m = INTERP_RE.exec(raw))) {
out += escLit(raw.slice(last, m.index));
const expr = m[1]!.trim();
if (expr.startsWith("t:")) {
// i18n sugar: {t:key} → a marker resolved server-side by translateHtml.
out += escLit(`<span data-t="${attrEscape(expr.slice(2).trim())}"></span>`);
} else if (ctx.loopVars && exprRefsState(expr, ctx.loopVars)) {
// Loop variable (from data-for): leave a literal client mustache — the
// list renderer fills it per item; it has no server-side value.
out += escLit(`{${expr}}`);
} else if (exprRefsState(expr, ctx.stateNames)) {
// State interpolation: bake the initial value AND keep it reactive via a
// data-text span, so no-JS clients see the real value and hydration
// updates it in place. `count` → `<span data-text="count">0</span>`.
out +=
escLit(`<span data-text="${attrEscape(expr)}">`) +
`\${__wireHtml(${ctx.resolveExpr(expr)})}` +
escLit(`</span>`);
} else if (expr === "content") {
out += `\${__wireRaw(${ctx.resolveExpr(expr)})}`;
} else {
out += `\${__wireHtml(${ctx.resolveExpr(expr)})}`;
}
last = m.index + m[0].length;
}
return out + escLit(raw.slice(last));
}
/** Compile an attribute value; `{expr}` is baked server-side (loop vars stay literal). */
function compileAttrValue(raw: string, ctx: CompCtx): string {
if (!raw.includes("{")) return escLit(attrEscape(raw));
let out = "";
let last = 0;
let m: RegExpExecArray | null;
INTERP_RE.lastIndex = 0;
while ((m = INTERP_RE.exec(raw))) {
out += escLit(attrEscape(raw.slice(last, m.index)));
const expr = m[1]!.trim();
if (ctx.loopVars && exprRefsState(expr, ctx.loopVars)) {
out += escLit(`{${expr}}`); // hydrated per-item by the list renderer
} else {
out += `\${__wireAttr(${ctx.resolveExpr(expr)})}`;
}
last = m.index + m[0].length;
}
return out + escLit(attrEscape(raw.slice(last)));
}
function renderComponentIfNode(node: IfNode, ctx: CompCtx): string {
let expression = "``";
for (let index = node.branches.length - 1; index >= 0; index--) {
const branch = node.branches[index]!;
const body = branch.body.map((child) => renderComponentNode(child, ctx)).join("");
const bodyExpression = "`" + body + "`";
expression =
branch.cond === null
? bodyExpression
: `(${ctx.resolveExpr(branch.cond)}) ? ${bodyExpression} : ${expression}`;
}
return "${" + expression + "}";
}
function renderComponentEachNode(node: EachNode, ctx: CompCtx): string {
const item = node.item;
const index = node.index ?? "__wi";
const list = ctx.resolveExpr(node.list);
const childCtx: CompCtx = {
...ctx,
serverLocals: new Set([...(ctx.serverLocals ?? []), item, index]),
};
const body = node.body.map((child) => renderComponentNode(child, childCtx)).join("");
const empty = node.empty.map((child) => renderComponentNode(child, ctx)).join("");
return (
"${(() => { const __wl = Array.isArray(" +
list +
") ? (" +
list +
") : []; return __wl.length ? __wl.map((" +
item +
", " +
index +
") => `" +
body +
'`).join("") : `' +
empty +
"`; })()}"
);
}
function serverLoopLocalsAttribute(ctx: CompCtx): string {
const locals = [...(ctx.serverLocals ?? [])];
if (locals.length === 0) {
return "";
}
const entries = locals.map((name) => `${JSON.stringify(name)}: ${name}`).join(", ");
return ` data-wrn-loop-locals="\${__wrnexusEncodeLoopLocals({ ${entries} })}"`;
}
/** Render a component view node into template-literal-ready source. */
function renderComponentNode(node: ViewNode, ctx: CompCtx): string {
if (node.type === "text") return compileText(node.value, ctx);
if (node.type === "each") {
return renderComponentEachNode(node, ctx);
}
if (node.type === "if") {
return renderComponentIfNode(node, ctx);
}
if (isComponentTag(node.tag)) {
return renderNestedComponentInvocation(node, ctx);
}
const loopVariables = loopVarsOf(node);
const elementContext =
loopVariables.length > 0
? {
...ctx,
loopVars: new Set([...(ctx.loopVars ?? []), ...loopVariables]),
}
: ctx;
let bindIndex = 0;
const staticClasses: string[] = [];
const conditionalClasses: Array<{
className: string;
expression: string;
}> = [];
for (const attr of node.attrs) {
if (!attr.event && attr.name === "class") {
staticClasses.push(attr.value);
}
if (!attr.event && attr.name.startsWith("class:")) {
conditionalClasses.push({
className: attr.name.slice("class:".length),
expression: attr.value,
});
}
}
const attrs = node.attrs
.filter((a) => a.name !== "class" && !a.name.startsWith("class:"))
.map((a) => {
if (a.event) {
return ` ${eventAttribute(a.name)}="${escLit(attrEscape(a.value))}"`;
}
if (a.boolean) {
return ` ${a.name}`;
}
const rendered = ` ${a.name}="${compileAttrValue(a.value, elementContext)}"`;
const referencesState = exprRefsState(a.value, ctx.stateNames);
const referencesLoopVariable = elementContext.loopVars
? exprRefsState(a.value, elementContext.loopVars)
: false;
const referencesServerLocal = ctx.serverLocals
? exprRefsState(a.value, ctx.serverLocals)
: false;
if (
!a.value.includes("{") ||
(!referencesState && !referencesLoopVariable && !referencesServerLocal)
) {
return rendered;
}
const marker = attrEscape(JSON.stringify([a.name, a.value]));
return `${rendered} data-wrn-bind-${bindIndex++}="${escLit(marker)}"`;
})
.join("");
const initialConditionalClasses = conditionalClasses
.map(({ className, expression }) => {
const referencesLoopVariable = elementContext.loopVars
? exprRefsState(expression, elementContext.loopVars)
: false;
// data-for variables do not exist during
// initial server rendering.
if (referencesLoopVariable) {
return "";
}
return `\${(${ctx.resolveExpr(expression)}) ? ${JSON.stringify(` ${className}`)} : ""}`;
})
.join("");
const staticClassValue = staticClasses.join(" ");
const classReferencesState = exprRefsState(staticClassValue, ctx.stateNames);
const classReferencesLoopVariable = elementContext.loopVars
? exprRefsState(staticClassValue, elementContext.loopVars)
: false;
const classReferencesServerLocal = ctx.serverLocals
? exprRefsState(staticClassValue, ctx.serverLocals)
: false;
const classHasReactiveExpression =
staticClassValue.includes("{") &&
(classReferencesState || classReferencesLoopVariable || classReferencesServerLocal);
const classAttribute =
staticClasses.length > 0 || conditionalClasses.length > 0
? ` class="${compileAttrValue(staticClassValue, elementContext)}${initialConditionalClasses}"`
: "";
const classReactiveBinding = classHasReactiveExpression
? ` data-wrn-bind-class="${escLit(attrEscape(JSON.stringify(["class", staticClassValue])))}"`
: "";
const classBindings = conditionalClasses
.map(({ className, expression }, index) => {
const marker = attrEscape(JSON.stringify([className, expression]));
return ` data-wrn-class-${index}="${escLit(marker)}"`;
})
.join("");
const loopLocalsAttribute = serverLoopLocalsAttribute(ctx);
const allAttrs =
`${loopLocalsAttribute}` +
`${classAttribute}` +
`${classReactiveBinding}` +
`${classBindings}` +
`${attrs}`;
if (VOID_ELEMENTS.has(node.tag.toLowerCase())) {
return `<${node.tag}${allAttrs}>`;
}
const inner = node.children.map((child) => renderComponentNode(child, elementContext)).join("");
return `<${node.tag}${allAttrs}>${inner}</${node.tag}>`;
}
function generateComponent(ast: PageAst): string {
const out: string[] = [];
const hasServerEach = viewHasServerEach(ast.view);
if (hasServerEach) {
out.push(`import { Buffer } from "node:buffer";`);
}
const effectiveProps =
ast.kind === "layout" && !ast.props.some((prop) => prop.name === "content")
? [
{
name: "content",
default: '""',
},
...ast.props,
]
: ast.props;
const stateNames = new Set(ast.states.map((s) => s.name));
const nameRefs = new Map<string, string>();
for (const p of effectiveProps) {
nameRefs.set(p.name, safeRef(p.name));
}
for (const s of ast.states) nameRefs.set(s.name, safeRef(s.name));
const resolveExpr = (expr: string): string => {
let result = expr;
for (const [name, ref] of nameRefs) {
if (name !== ref) result = result.replace(new RegExp(`\\b${name}\\b`, "g"), ref);
}
return result;
};
const ctx: CompCtx = { stateNames, resolveExpr };
const serverFunctions = ast.functions
.map((body) => body.trim())
.filter(Boolean)
.join("\n\n");
const viewCode = ast.view.map((node) => renderComponentNode(node, ctx)).join("");
const styles = ast.styles.map((body) => body.trim()).filter(Boolean);
const styleTag =
styles.length > 0
? escLit(
`<style data-wrnexus-style="${attrEscape(ast.name)}">\n${styles.map(styleEscape).join("\n")}\n</style>`,
)
: "";
// A component needs a reactive scope only when it has state or event handlers.
// Prop-driven text/attributes are baked server-side, so static components ship
// no JavaScript at all.
const behavior = componentBehavior(ast);
const needsScope = ast.states.length > 0 || viewHasEvents(ast.view) || behavior !== null;
const scopeKeys = [
...effectiveProps.map((prop) => prop.name),
...ast.states.map((state) => state.name),
];
const behaviorAttr = behaviorAttribute(behavior);
const decls: string[] = [];
for (const prop of effectiveProps) {
decls.push(
` const ${nameRefs.get(prop.name)} = __coerce(__p[${JSON.stringify(prop.name)}], (${resolveExpr(prop.default)}));`,
);
}
for (const state of ast.states) {
decls.push(` let ${nameRefs.get(state.name)} = (${resolveExpr(state.expr)});`);
}
const returnExpr = needsScope
? "`" + styleTag + `<div data-scope="\${__scope}"${behaviorAttr}>` + viewCode + "</div>`"
: "`" + styleTag + viewCode + "`";
const scopeLine =
needsScope && scopeKeys.length > 0
? ` const __scope = __wrnexusScopeDecl({ ${scopeKeys
.map((key) => `${JSON.stringify(key)}: ${nameRefs.get(key)}`)
.join(", ")} });\n`
: needsScope
? ` const __scope = "";\n`
: "";
if (ast.kind === "layout") {
out.push(`export const __wrnexusLayout = ${JSON.stringify(ast.name)};`);
} else {
out.push(`export const __wrnexusComponent = ${JSON.stringify(ast.name)};`);
}
if (behavior) {
out.push(`export const __wrnexusBehavior = ${JSON.stringify(behavior, null, 2)};`);
}
out.push(`function __coerce(v: any, def: any): any {
if (v === undefined || v === null) {
return def;
}
if (typeof def === "number") {
return Number(v);
}
if (typeof def === "boolean") {
return v === true || v === "" || v === "true";
}
if (Array.isArray(def)) {
if (Array.isArray(v)) {
return v;
}
if (typeof v === "string") {
try {
const parsed = JSON.parse(v);
return Array.isArray(parsed) ? parsed : def;
} catch {
return def;
}
}
return def;
}
if (def !== null && typeof def === "object") {
if (
v !== null &&
typeof v === "object" &&
!Array.isArray(v)
) {
return v;
}
if (typeof v === "string") {
try {
const parsed = JSON.parse(v);
return (
parsed !== null &&
typeof parsed === "object" &&
!Array.isArray(parsed)
)
? parsed
: def;
} catch {
return def;
}
}
return def;
}
return String(v);
}
function __wireHtml(v: any): string {
return String(v == null ? "" : v).replace(
/[&<>]/g,
(c) =>
c === "&"
? "&amp;"
: c === "<"
? "&lt;"
: "&gt;",
);
}
function __wireAttr(v: any): string {
return String(v == null ? "" : v).replace(
/[&<>"]/g,
(c) =>
c === "&"
? "&amp;"
: c === "<"
? "&lt;"
: c === ">"
? "&gt;"
: "&quot;",
);
}
function __wireProp(v: any): string {
const value =
v !== null && typeof v === "object"
? JSON.stringify(v)
: String(v == null ? "" : v);
return __wireAttr(value);
}
function __wireRaw(v: any): string {
return String(v == null ? "" : v);
}`);
if (hasServerEach) {
out.push(`function __wrnexusEncodeLoopLocals(value: Record<string, any>): string {
return Buffer.from(JSON.stringify(value), "utf8").toString("base64");
}`);
}
if (needsScope) {
out.push(`function __wrnexusSerializeScopeValue(value: any): string {
if (value === undefined) {
return "undefined";
}
if (value === null) {
return "null";
}
if (typeof value === "number") {
return Number.isFinite(value)
? String(value)
: "null";
}
if (typeof value === "boolean") {
return value ? "true" : "false";
}
if (typeof value === "string") {
return JSON.stringify(value);
}
try {
const serialized = JSON.stringify(value);
return serialized === undefined
? "undefined"
: serialized;
} catch {
return "null";
}
}
function __wrnexusScopeDecl(obj: Record<string, any>): string {
return Object.keys(obj)
.map(
(key) =>
key +
": " +
__wrnexusSerializeScopeValue(
obj[key],
),
)
.join(", ")
.replace(/&/g, "&amp;")
.replace(/"/g, "&quot;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;");
}`);
}
const serverFunctionSource = serverFunctions ? `${serverFunctions}\n` : "";
out.push(
`export function render(props: Record<string, any> = {}): string {\n` +
` const __p = props || {};\n` +
(decls.length > 0 ? decls.join("\n") + "\n" : "") +
serverFunctionSource +
scopeLine +
` return ${returnExpr};\n` +
`}`,
);
return out.join("\n\n") + "\n";
}
function __wireRaw(v: any): string {
return String(v == null ? "" : v);
}
function wholeAttributeExpression(value: string): string | null {
const match = /^\s*\{([\s\S]+)\}\s*$/.exec(value);
return match?.[1]?.trim() || null;
}
function __wireHtml(v: any): string {
return String(v == null ? "" : v).replace(/[&<>]/g, (c) =>
c === "&" ? "&amp;" : c === "<" ? "&lt;" : "&gt;",
);
}
function __wireAttr(v: any): string {
return String(v == null ? "" : v).replace(/[&<>"]/g, (c) =>
c === "&" ? "&amp;" : c === "<" ? "&lt;" : c === ">" ? "&gt;" : "&quot;",
);
}
function __wireProp(v: any): string {
const value =
v !== null && typeof v === "object" ? JSON.stringify(v) : String(v == null ? "" : v);
return __wireAttr(value);
}
function renderPageComponentAttr(attr: Attr, dynamicExpressions: string[]): string {
if (attr.event) {
return ` ${eventAttribute(attr.name)}="${attrEscape(attr.value)}"`;
}
if (attr.boolean) {
return ` ${attr.name}`;
}
const expression = wholeAttributeExpression(attr.value);
if (!expression) {
return ` ${attr.name}="${attrEscape(attr.value)}"`;
}
dynamicExpressions.push(`\${__wrnexusPropAttr(${expression})}`);
const marker = `\x00WRNEACH${dynamicExpressions.length - 1}\x00`;
return ` ${attr.name}="${marker}"`;
}