/** * 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-="..." * "...{expr}..." -> text kept verbatim ({expr} is mustache for runtime) * api="" -> 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, "&") .replace(/"/g, """) .replace(//g, ">"); } /** 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 `` 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) => ``, ); } /** Escape a value for safe embedding in HTML text. */ function htmlTextEscape(value: string): string { return value.replace(/[&<>]/g, (c) => (c === "&" ? "&" : c === "<" ? "<" : ">")); } /** Reactive page context: state names + their initial (SSR) values. */ interface PageReactive { stateNames: Set; scope: Record; } /** * 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 { const scope: Record = {}; 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 `${baked}`; }); } type EachNode = Extract; type IfNode = Extract; /** * 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(``); } 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(`
") + inner + escLit("
") ); } if (VOID_ELEMENTS.has(node.tag.toLowerCase())) { return escLit(`<${node.tag}`) + attrs + escLit(">"); } return escLit(`<${node.tag}`) + attrs + escLit(">") + inner + escLit(``); } /** * 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, 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 (
, , …) 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}`; } function renderPageComponentInvocation( node: Extract, ssrBindings: SsrBinding[], csrBindings: CsrBinding[], apiBindings: Map, 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 `
${inner}
`; } function renderNestedComponentInvocation( node: Extract, 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 `
${inner}
`; } function ssrMarker(bindings: SsrBinding[], binding: RenderBinding): string { const marker = ``; 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 { const bindings = new Map(); 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 = { "&": "&", "<": "<", ">": ">", "\\"": """, "'": "'" }; 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 === "&" ? "&" : character === "<" ? "<" : character === ">" ? ">" : """, ); } async function __wrnexusCallApi(path: string, method: string, ctx: any): Promise { 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 { 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 = `
${html}
`; } if (styles.length > 0) { const css = styles.map(styleEscape).join("\n"); html = `${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 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, "&") .replace(/"/g, """) .replace(//g, ">"); 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, "&") .replace(/"/g, """) .replace(//g, ">"); 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(); 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; /** 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; /** Local identifiers introduced by server-rendered `{#each}` blocks. */ serverLocals?: Set; } 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): 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(``); } 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` → `0`. out += escLit(``) + `\${__wireHtml(${ctx.resolveExpr(expr)})}` + escLit(``); } 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}`; } 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(); 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( ``, ) : ""; // 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 + `
` + viewCode + "
`" : "`" + 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 === "&" ? "&" : c === "<" ? "<" : ">", ); } function __wireAttr(v: any): string { return String(v == null ? "" : v).replace( /[&<>"]/g, (c) => c === "&" ? "&" : c === "<" ? "<" : c === ">" ? ">" : """, ); } 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 { 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 { return Object.keys(obj) .map( (key) => key + ": " + __wrnexusSerializeScopeValue( obj[key], ), ) .join(", ") .replace(/&/g, "&") .replace(/"/g, """) .replace(//g, ">"); }`); } const serverFunctionSource = serverFunctions ? `${serverFunctions}\n` : ""; out.push( `export function render(props: Record = {}): 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 === "&" ? "&" : c === "<" ? "<" : ">", ); } function __wireAttr(v: any): string { return String(v == null ? "" : v).replace(/[&<>"]/g, (c) => c === "&" ? "&" : c === "<" ? "<" : c === ">" ? ">" : """, ); } 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}"`; }