/** * 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 -> tagged local stylesheet metadata promoted by SSR * 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"; import { eraseFunctionTypes, runtimeTypeOf } from "./types.ts"; import { stripRuntimeFunctionModifiers } from "@wrnexus/syntax"; import { generateStoreModule } from "./store-codegen.ts"; import { optimizeAst } from "./analysis.ts"; import { islandPropValue, parseIslandStrategy, renderIslandMarker, serializeIslandProps, } from "./island-codegen.ts"; import { browserModuleRequired } from "./client-codegen.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; } /** * Island names for the file currently being generated. * * Codegen is a synchronous single pass, so a module-scoped set avoids threading * an extra parameter through every render function. Always reset in generate(). */ let currentIslands: ReadonlySet = new Set(); /** * Builds the island placeholder for a component tag that was imported from a * .tsx file. Returns null for ordinary .wrn components. */ function islandMarkerFor(node: { tag: string; attrs: Array<{ name: string; value?: string }>; }): string | null { if (!currentIslands.has(node.tag)) return null; const directives = node.attrs .map((attr) => attr.name) .filter((name) => name.startsWith("client:")); const props: Record = {}; for (const attr of node.attrs) { if (attr.name.startsWith("client:")) continue; const parsed = islandPropValue(attr.value); if ("dynamic" in parsed) { throw new Error( `WRN-ISLAND-PROPS: Island '${node.tag}' received a runtime expression for prop '${attr.name}'. ` + `Island props are serialized at build time, so they must be literal values ` + `(for example start={3} or title="Revenue"), not ${attr.value}.`, ); } props[attr.name] = parsed.value; } const serialized = serializeIslandProps(node.tag, props); if ("diagnostic" in serialized) throw new Error(serialized.diagnostic.message); return renderIslandMarker({ name: node.tag, strategy: parseIslandStrategy(directives), propsJson: serialized.json, }); } function isComponentTag(tag: string): boolean { return /^[A-Z][A-Za-z0-9_$]*$/.test(tag); } const HTML_BOOLEAN_ATTRIBUTES = new Set([ "allowfullscreen", "async", "autofocus", "autoplay", "checked", "controls", "default", "defer", "disabled", "formnovalidate", "hidden", "inert", "ismap", "itemscope", "loop", "multiple", "muted", "nomodule", "novalidate", "open", "playsinline", "readonly", "required", "reversed", "selected", ]); function isHtmlBooleanAttribute(name: string): boolean { return HTML_BOOLEAN_ATTRIBUTES.has(name.toLowerCase()); } const URL_ATTRIBUTES = new Set([ "href", "src", "action", "formaction", "poster", "cite", "background", "xlink:href", ]); function stripAsciiControlAndSpace(value: string): string { let result = ""; for (const character of value) { if (character.charCodeAt(0) > 0x20) result += character; } return result; } function sanitizeUrlAttribute(value: string): string { const compact = stripAsciiControlAndSpace(value.trim()); const lower = compact.toLowerCase(); if (/^(?:javascript|vbscript|file):/.test(lower)) return "about:blank"; if (/^data:(?!image\/(?:png|gif|jpeg|webp|avif);)/.test(lower)) return "about:blank"; return value; } function safeAttributeValue(name: string, value: string): string { if (!URL_ATTRIBUTES.has(name.toLowerCase()) || value.includes("{")) return value; return sanitizeUrlAttribute(value); } /** 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(safeAttributeValue(attr.name, 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}`; } /** * Event attribute for a handler written on a *component tag* * (``). * * These need their own attribute name. The mount's attributes are forwarded * into the component and land on its view root, i.e. inside the component's * own `data-scope` -- but the statement (`save()`) belongs to the parent that * wrote the tag. Emitting `data-on-confirm` makes the child's runtime bind it * against the child's scope, where the parent's functions and state do not * exist, so the handler silently does nothing. `data-wrn-out-*` is ignored by * the child and claimed by the mounting scope instead. * * `window:`/`document:` (and the browser/mobile bridges) keep the plain * `data-on-*` form: those bind to a global target rather than to the element, * and the runtime has no component-output path for them. */ function componentEventAttribute(name: string): string { if ( name.startsWith("window:") || name.startsWith("document:") || name.startsWith("browser-") || name.startsWith("mobile-") ) { return eventAttribute(name); } return `data-wrn-out-${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, dynamicExpressions?: string[], ): 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 expression = wholeAttributeExpression(attr.value); if ( expression && exprRefsState(expression, reactive.runtimeStateNames) && dynamicExpressions ) { dynamicExpressions.push(`\${__wrnexusPropAttr(${expression})}`); const sentinel = `\x00WRNEACH${dynamicExpressions.length - 1}\x00`; const marker = JSON.stringify([attr.name, attr.value]); return ` ${attr.name}="${sentinel}" data-wrn-bind-${bindIndex++}="${attrEscape(marker)}"`; } const initial = reactiveAttrValue(attr.value, reactive); if (initial === null) return base; const marker = JSON.stringify([attr.name, attr.value]); return ` ${attr.name}="${attrEscape(URL_ATTRIBUTES.has(attr.name.toLowerCase()) ? sanitizeUrlAttribute(initial) : 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; runtimeStateNames: 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, 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 `${sentinel}`; } 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, 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; 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 ? componentTag ? componentEventAttribute(attr.name) : eventAttribute(attr.name) : attr.name; if (attr.boolean) { return escLit(` ${name}`); } return escLit(` ${name}="`) + bakeLoopAttr(attr.value, componentTag) + escLit(`"`); }) .join(""); const inner = node.children.map(renderLoopBody).join(""); if (node.tag === "Static") return inner; if (node.tag === "Dynamic") return ( escLit('') + inner + escLit("") ); if (node.tag === "KeepAlive") { const key = node.attrs.find((attribute) => attribute.name === "key")?.value ?? "default"; return ( escLit('
`) + inner + escLit("
") ); } if (node.tag === "Portal") { const target = node.attrs.find((attribute) => attribute.name === "to")?.value ?? "body"; return ( escLit('
') + inner + escLit("
") ); } if (node.tag === "Transition") { const name = node.attrs.find((attribute) => attribute.name === "name")?.value ?? "wrn-transition"; return ( escLit('
') + inner + escLit("
") ); } if (node.tag === "Component") { const selected = node.attrs.find((attribute) => attribute.name === "is")?.value ?? ""; return ( escLit('
') + inner + escLit("
") ); } if (componentTag) { const island = islandMarkerFor(node); if (island) return escLit(island); 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 wrn up raw SSR data consts. */ function collectControlExprs(nodes: ViewNode[], out: string[] = []): string[] { for (const node of nodes) { if (node.type === "text") continue; if (node.type === "each") { out.push(node.list); collectControlExprs(node.body, out); 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, loops); // {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 (node.tag === "Static" || node.tag === "Dynamic") { const inner = node.children .map((child) => renderNode(child, ssrBindings, csrBindings, apiBindings, loops, reactive)) .join(""); return node.tag === "Static" ? inner : `${inner}`; } if (node.tag === "Portal" || node.tag === "Transition" || node.tag === "Component") { const inner = node.children .map((child) => renderNode(child, ssrBindings, csrBindings, apiBindings, loops, reactive)) .join(""); const attribute = node.tag === "Portal" ? "data-wrn-portal" : node.tag === "Transition" ? "data-wrn-transition" : "data-wrn-dynamic-component"; const source = node.tag === "Portal" ? "to" : node.tag === "Transition" ? "name" : "is"; const fallback = node.tag === "Portal" ? "body" : node.tag === "Transition" ? "wrn-transition" : ""; const original = node.attrs.find((item) => item.name === source); const rendered = original ? renderAttrs([{ ...original, name: attribute }], undefined, reactive, loops) : ` ${attribute}="${attrEscape(fallback)}"`; return `${inner}`; } 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 branchElement = (name: string) => node.children.find( (child): child is Extract => 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(""); }; 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 sourcePattern = successAlias.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); const serverSuccess = success.replace( new RegExp(`\\{\\s*(${sourcePattern}(?:\\.[A-Za-z_$][\\w$]*)*)\\s*\\}`, "g"), (_whole, expression: string) => `\${__wrnexusEscapeHtml(${expression})}`, ); 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 `
${initial}
`; } if (node.tag === "KeepAlive") { const key = attrValue(node.attrs, "key") ?? "default"; const inner = node.children .map((child) => renderNode(child, ssrBindings, csrBindings, apiBindings, loops, reactive)) .join(""); return `
${inner}
`; } 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, loops)}>`; } 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, loops)}>${inner}`; } function renderPageComponentInvocation( node: Extract, ssrBindings: SsrBinding[], csrBindings: CsrBinding[], apiBindings: Map, loops: string[], reactive: PageReactive | null, ): string { const island = islandMarkerFor(node); if (island) return island; 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) => { const spread = /^\{\.\.\.([A-Za-z_$][\w$]*)\}$/.exec(attr.name); if (spread) { return `\${__wrnSpreadAttrs(${ctx.resolveExpr(spread[1]!)})}`; } if (attr.event) { return ( escLit(` ${componentEventAttribute(attr.name)}="`) + escLit(attrEscape(attr.value)) + escLit(`"`) ); } if (attr.boolean) { return ` ${attr.name}`; } const wholeExpression = wholeAttributeExpression(attr.value); const compiledValue = wholeExpression ? `\${__wrnProp(${ctx.resolveExpr(wholeExpression)})}` : compileAttrValue(attr.value, ctx); const rendered = ` ${attr.name}="${compiledValue}"`; if ( !attr.value.includes("{") || (!exprRefsState(attr.value, ctx.stateNames) && !exprRefsState(attr.value, ctx.propNames)) ) { return rendered; } const marker = attrEscape(JSON.stringify([attr.name, attr.value])); return rendered + ` data-wrn-prop-bind-${bindIndex++}="${escLit(marker)}"`; }) .join(""); const loops = loopVarsOf(node); const childCtx = loops.length > 0 ? { ...ctx, forwardRestAttrs: false, loopVars: new Set([...(ctx.loopVars ?? []), ...loops]), } : { ...ctx, forwardRestAttrs: false }; 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); } type __WrnexusContext = import("@wrnexus/core").Context & { __wrnexusCallApi?: (path: string, method: string) => Promise; localStorage?: unknown; }; function __wrnexusEvalData(data: unknown, body: string, helpers = "", ctx: __WrnexusContext): 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: __WrnexusContext): 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: __WrnexusContext): 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; }`; } function stableHash(value: string): string { let hash = 0x811c9dc5; for (let index = 0; index < value.length; index++) { hash ^= value.charCodeAt(index); hash = Math.imul(hash, 0x01000193); } return (hash >>> 0).toString(36); } function hydrationId(ast: PageAst): string { const shape = JSON.stringify({ kind: ast.kind, name: ast.name, props: ast.props.map((entry) => entry.name), events: ast.events.map((entry) => entry.name), states: ast.states.map((entry) => entry.name), computed: ast.computed.map((entry) => entry.name), view: ast.view, }); return `${ast.name}:${stableHash(shape)}`; } function localStyleId(ast: PageAst): string { return `wrn-${ast.kind}-${stableHash(`${ast.kind}:${ast.name}`)}`; } function localStyleTag(ast: PageAst, styles: string[]): string { if (!styles.length) return ""; const id = localStyleId(ast); const css = styles.map(styleEscape).join("\n"); return ``; } function localStyleExport(ast: PageAst, styles: string[]): string | null { if (!styles.length) return null; return `export const __wrnexusStyles = ${JSON.stringify( [ { id: localStyleId(ast), owner: ast.name, kind: ast.kind, css: styles.join("\n"), }, ], null, 2, )};`; } function isStoreImportSource(source: string): boolean { return /(?:^|\/)stores?\//.test(source) || source.startsWith("@wrnexus/store"); } function importedStoreBindings(ast: PageAst): Array<{ local: string; internal: string }> { return ast.structuredImports .filter( (entry) => entry.defaultImport && entry.source.endsWith(".wrn") && isStoreImportSource(entry.source), ) .map((entry) => ({ local: entry.defaultImport!, internal: `__wrnexusStoreDefinition_${entry.defaultImport}`, })); } function generatedImports(ast: PageAst): string[] { const stores = new Map(importedStoreBindings(ast).map((entry) => [entry.local, entry.internal])); return ast.structuredImports.map((entry) => { if (!entry.defaultImport) return entry.raw; const internal = stores.get(entry.defaultImport); return internal ? entry.raw.replace( new RegExp( `^(\\s*import\\s+(?:type\\s+)?)(?:${entry.defaultImport.replace(/[.*+?^${}()|[\\]\\]/g, "\\$&")})(\\s+from\\s+)`, ), `$1${internal}$2`, ) : entry.raw; }); } function isSafeGeneratedIdentifier(name: string): boolean { return /^[A-Za-z_$][A-Za-z0-9_$]*$/.test(name); } function generateSsrStateAliases(stateNames: string[]): string { const names = [...new Set(stateNames)].filter(isSafeGeneratedIdentifier); if (!names.length) { return ""; } 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(); const visited = new Set(); 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(); const visited = new Set(); 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 = [], ): Set { 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 = browserModuleRequired(ast); const moduleAttribute = hasBrowserModule ? ' data-wrn-client-module="__WRNEXUS_CLIENT_MODULE__"' : ""; return ` data-wrn-hydration="${attrEscape(hydrationId(ast))}" data-wrn-hydrate="${attrEscape(strategy)}" data-wrn-runtime="${attrEscape(ast.runtime ?? "universal")}"${moduleAttribute}`; } function targetFunctions(ast: PageAst, target: "browser" | "server"): string { const runtimes = target === "browser" ? (["legacy", "client", "shared"] as const) : (["legacy", "server", "shared"] as const); return ast.functions .map((body) => stripRuntimeFunctionModifiers(body, [...runtimes])) .map((body) => body.trim()) .filter(Boolean) .join("\n\n"); } function publicOutputNames(ast: PageAst): string[] { return [ ...new Set([ ...ast.outputs.map((output) => output.name), ...ast.events.map((event) => event.name), ]), ]; } function prepareActionForms(nodes: ViewNode[], actions: ReadonlySet): void { for (const node of nodes) { if (node.type === "text") continue; if (node.type === "each") { prepareActionForms(node.body, actions); prepareActionForms(node.empty, actions); continue; } if (node.type === "if") { node.branches.forEach((branch) => prepareActionForms(branch.body, actions)); continue; } prepareActionForms(node.children, actions); if (node.tag.toLowerCase() !== "form") continue; const submit = node.attrs.find((attr) => attr.event && attr.name === "submit"); if (!submit || !actions.has(submit.value.trim())) continue; const name = submit.value.trim(); node.attrs = node.attrs.filter((attr) => attr !== submit); if (!node.attrs.some((attr) => !attr.event && attr.name === "method")) { node.attrs.push({ name: "method", value: "post", event: false }); } node.attrs.push({ name: "data-wrn-action", value: name, event: false }); node.children.unshift({ type: "element", tag: "input", attrs: [ { name: "type", value: "hidden", event: false }, { name: "name", value: "_wrnexus_action", event: false }, { name: "value", value: name, event: false }, ], children: [], }); } } function markServerAsyncBoundaries(nodes: ViewNode[], serverLoads: ReadonlySet): void { for (const node of nodes) { if (node.type === "text") continue; if (node.type === "each") { markServerAsyncBoundaries(node.body, serverLoads); markServerAsyncBoundaries(node.empty, serverLoads); continue; } if (node.type === "if") { node.branches.forEach((branch) => markServerAsyncBoundaries(branch.body, serverLoads)); continue; } if (node.tag === "Async") { const source = attrValue(node.attrs, "source") ?? "data"; if ( serverLoads.has(source) && !node.attrs.some((attribute) => attribute.name === "data-wrn-async-server") ) { node.attrs.push({ name: "data-wrn-async-server", value: "true", event: false }); } } markServerAsyncBoundaries(node.children, serverLoads); } } export interface GenerateOptions { /** Local names bound to .tsx island imports in this file. */ islands?: ReadonlySet; } export function generate(ast: PageAst, options: GenerateOptions = {}): string { currentIslands = options.islands ?? new Set(); try { return generateInner(ast); } finally { currentIslands = new Set(); } } function generateInner(ast: PageAst): string { ast = optimizeAst(ast).ast; if (ast.kind === "global-store" || ast.kind === "page-store") return generateStoreModule(ast); if (ast.kind === "component" || ast.kind === "layout") { return generateComponent(ast); } const out: string[] = []; prepareActionForms(ast.view, new Set(ast.actions.map((action) => action.name))); markServerAsyncBoundaries( ast.view, new Set( ast.loads .filter((load) => load.mode === "server" && !load.deferred && load.name) .map((load) => load.name!), ), ); if (ast.actions.length > 0) { out.push( `import { createActionClient } from "@wrnexus/csr";\nimport type { InferSchema } from "@wrnexus/validation";`, ); } if (ast.imports.length > 0) out.push(generatedImports(ast).join("\n")); const ssrBindings: SsrBinding[] = []; const csrBindings: CsrBinding[] = []; const helpers = targetFunctions(ast, "server"); const apiBindings = apiBindingMap(ast, helpers); const typeSource = ast.types .map((body) => body.trim()) .filter(Boolean) .join("\n\n"); if (typeSource) out.push(typeSource); 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 = ${ast.layoutIsSymbol ? ast.layout : JSON.stringify(ast.layout)};`, ); out.push(`export const __wrnexusRuntime = ${JSON.stringify(ast.runtime ?? "universal")};`); out.push(`export const __wrnexusRender = ${JSON.stringify(ast.renderMode ?? "hybrid")};`); out.push( `export const __wrnexusHydrate = ${JSON.stringify(["static", "server"].includes(ast.renderMode ?? "") ? "none" : (ast.hydrate ?? "load"))};`, ); out.push(`export const __wrnexusHydrationId = ${JSON.stringify(hydrationId(ast))};`); if (Object.keys(ast.cache ?? {}).length > 0) out.push(`export const __wrnexusCache = ${JSON.stringify(ast.cache, null, 2)};`); if (Object.keys(ast.security).length > 0) { out.push(`export const __wrnexusSecurity = ${JSON.stringify(ast.security, null, 2)};`); } if (Object.keys(ast.navigation).length > 0) { out.push(`export const __wrnexusNavigation = ${JSON.stringify(ast.navigation, null, 2)};`); } // --- View -> default page component --- const orderedStates = orderPageStates(ast.states); const browserStates = ast.states.filter((state) => state.runtime !== "server"); 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, ); } catch { seedScope[entry.name] = undefined; } } const reactiveNames = [ ...browserStates.map((entry) => entry.name), ...ast.computed.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 } : 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 pageBehavior = ast.runtime === "server" ? null : componentBehavior(ast); const needsClientRuntime = ast.runtime !== "server" && (browserStates.length > 0 || ast.computed.length > 0 || hasClientBehavior(ast.view) || pageBehavior !== null); if (needsClientRuntime) { const scopePlaceholder = "__WRNEXUS_DYNAMIC_SCOPE__"; html = `
${html}
`; } const pageStyleTag = localStyleTag(ast, styles); if (pageStyleTag) { html = `${pageStyleTag}${html}`; } if (ast.renderMode === "client") { const clientRoot = hydrationId(ast); html = `
`; } const pageStyleExport = localStyleExport(ast, styles); if (pageStyleExport) out.push(pageStyleExport); if (csrBindings.length > 0) { out.push(`export const __wrnexusCsr = ${JSON.stringify(csrBindings, null, 2)};`); } if (pageBehavior) { out.push(`export const __wrnexusBehavior = ${JSON.stringify(pageBehavior, 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); let staticShellBody: string | undefined; if (ast.renderMode === "partial-static") { const shellHtml = html.replace( /]*>[\s\S]*?<\/wrn-dynamic-region>/gi, '', ); staticShellBody = templateEscape(shellHtml); } const dynamicStateInitializer = generateSsrStateInitializer(orderedStates); const stateType = ast.states.length > 0 ? `{ ${ast.states .map((state) => `${JSON.stringify(state.name)}: ${state.valueType ?? "unknown"}`) .join("; ")} }` : "Record"; const hydrationStateNames = JSON.stringify(browserStates.map((state) => state.name)); const ssrStateAliases = generateSsrStateAliases(ast.states.map((state) => state.name)); const ssrComputedAliases = generateSsrComputedAliases(orderedComputed); const storeDeclarations = storeBindings .map( (entry) => ` const ${entry.local} = await ctx.__wrnexusUseStore(${entry.internal});`, ) .join("\n"); const serverLoadAliases = ast.loads .filter((load) => load.mode === "server" && !load.deferred && load.name) .map((load) => ` const ${load.name} = ctx[${JSON.stringify(load.name)}];`) .join("\n"); // 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.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 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 || runtimeStateNames.size > 0; 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: __WrnexusContext) { ${storeDeclarations} ${serverLoadAliases} ${decls} const __state: ${stateType} = ${dynamicStateInitializer}; ${ssrStateAliases} ${ssrComputedAliases} const __hydrationState = Object.fromEntries(${hydrationStateNames}.map((key) => [key, Reflect.get(__state, key)])); const __scopeValue = Object.entries(__hydrationState) .map(([key, value]) => { let encoded: string; try { encoded = value === undefined ? "undefined" : JSON.stringify(value); } catch { encoded = JSON.stringify(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 ${storeBindings.length > 0 ? "async " : ""}function ${ast.name}(ctx: import("@wrnexus/core").Context) { ${storeDeclarations} ${serverLoadAliases} const __state: ${stateType} = ${dynamicStateInitializer}; ${ssrStateAliases} ${ssrComputedAliases} const __hydrationState = Object.fromEntries(${hydrationStateNames}.map((key) => [key, Reflect.get(__state, key)])); const __scopeValue = Object.entries(__hydrationState) .map(([key, value]) => { let encoded: string; try { encoded = value === undefined ? "undefined" : JSON.stringify(value); } catch { encoded = JSON.stringify(String(value)); } return key + ": " + encoded; }) .join(", ") .replace(/&/g, "&") .replace(/"/g, """) .replace(//g, ">"); return \`${body}\`.replace( "__WRNEXUS_DYNAMIC_SCOPE__", __scopeValue, ); }`, ); } if (staticShellBody !== undefined) { out.push( `export async function __wrnexusBuildStaticShell(ctx: import("@wrnexus/core").Context = {} as import("@wrnexus/core").Context) { ${storeDeclarations} ${serverLoadAliases} ${loopConsts.length > 0 ? loopConsts.join("\n") : ""} const __state: ${stateType} = ${dynamicStateInitializer}; ${ssrStateAliases} ${ssrComputedAliases} const __hydrationState = Object.fromEntries(${hydrationStateNames}.map((key) => [key, Reflect.get(__state, key)])); const __scopeValue = Object.entries(__hydrationState) .map(([key, value]) => { let encoded: string; try { encoded = value === undefined ? "undefined" : JSON.stringify(value); } catch { encoded = JSON.stringify(String(value)); } return key + ": " + encoded; }) .join(", ") .replace(/&/g, "&") .replace(/"/g, """) .replace(//g, ">"); return \`${staticShellBody}\`.replace("__WRNEXUS_DYNAMIC_SCOPE__", __scopeValue); }`, ); } if (ast.loads.length > 0) { const serverLoads = ast.loads.filter((entry) => entry.mode === "server" && !entry.deferred); const publicClientLoads = ast.loads.filter( (entry) => entry.mode === "client" || entry.deferred, ); const namedByName = new Map( ast.loads.filter((entry) => entry.name).map((entry) => [entry.name!, entry]), ); const clientNames = new Set( publicClientLoads.flatMap((entry) => (entry.name ? [entry.name] : [])), ); const includeDependencies = (name: string): void => { for (const dependency of namedByName.get(name)?.dependsOn ?? []) { if (clientNames.has(dependency)) continue; clientNames.add(dependency); includeDependencies(dependency); } }; for (const name of [...clientNames]) includeDependencies(name); const clientLoads = ast.loads.filter((entry) => !entry.name || clientNames.has(entry.name)); const renderLoads = ( exportName: string, execution: typeof ast.loads, exposed: typeof ast.loads, ): string => { const declarations = execution .filter((entry) => entry.name) .map((entry) => { const dependencies = (entry.dependsOn ?? []) .map((dependency) => `const ${dependency} = await __load_${dependency}();`) .join("\n"); return ` let __promise_${entry.name}: Promise | undefined; const __load_${entry.name} = () => (__promise_${entry.name} ??= (async () => { ${dependencies} ${entry.body} })());`; }) .join("\n"); const visible = exposed.filter((entry) => entry.name); return `export async function ${exportName}(ctx: import("@wrnexus/core").Context) { ${exposed .filter((entry) => !entry.name) .map((entry) => entry.body) .join("\n")} ${declarations} ${ visible.length ? ` const __values = await Promise.all([${visible.map((entry) => `__load_${entry.name}()`).join(", ")}]); return { ${visible.map((entry, index) => `${JSON.stringify(entry.name)}: __values[${index}]`).join(", ")} };` : "" } }`; }; if (serverLoads.length > 0) out.push(renderLoads("__wrnexusLoad", serverLoads, serverLoads)); if (publicClientLoads.length > 0) out.push(renderLoads("__wrnexusClientLoad", clientLoads, publicClientLoads)); } if (ast.actions.length > 0) { for (const action of ast.actions) { if (!action.schema) { out.push( `export async function ${action.name}(${action.args.join(", ")}) {${action.body}}`, ); continue; } out.push(`export async function ${action.name}(input: InferSchema, ctx: import("@wrnexus/core").Context) { const invalidate = (...tags: string[]) => { const bucket = (ctx.locals.__wrnexusInvalidatedTags ??= []); bucket.push(...tags.flat()); }; ${action.body} }`); } out.push( `export const __wrnexusActions = { ${ast.actions .map( (action) => `${action.name}: { run: ${action.name}, schema: ${action.schema ?? "undefined"} }`, ) .join(", ")} };`, ); out.push(`export const __wrnexusActionClients = { ${ast.actions .map( (action) => ` ${action.name}: createActionClient<${action.schema ? `InferSchema` : "Record"}, Awaited>>("", ${JSON.stringify(action.name)}),`, ) .join("\n")} };`); } // --- 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: import("@wrnexus/core").Context) => {${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}: Event) {${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; /** Props are signals too, allowing a parent to drive a mounted child. */ propNames: Set; /** Component functions can read state and therefore make their callers reactive. */ functionNames: 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; /** Forward undeclared component attributes to this element only. */ forwardRestAttrs?: boolean; /** Public component events exposed from the component root. */ eventNames?: string[]; } interface ComponentBehavior { functions: string; outputs: Array<{ name: string; payload?: { name: string; valueType: string; optional: boolean }; }>; computed: Array<{ name: string; expr: string }>; effects: string[]; lifecycle: { mount?: string; update?: string; unmount?: string; }; watches: Array<{ state: string; body: string; }>; } /** Parse a `data-for="item in list [key expr]"` / `"item, i in list [key expr]"` directive. */ export function parseForExpr( value: string, ): { item: string; index?: string; list: string; key?: string } | null { const m = /^\s*([A-Za-z_$][\w$]*)\s*(?:,\s*([A-Za-z_$][\w$]*)\s*)?\s+in\s+([\s\S]+?)(?:\s+key\s+([\s\S]+?))?\s*$/.exec( value, ); if (!m) return null; return { item: m[1]!, index: m[2], list: m[3]!.trim(), key: m[4]?.trim() }; } /** 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", "implements", "interface", "package", "private", "protected", "public", "static", ]); /** 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 = eraseFunctionTypes(targetFunctions(ast, "browser")); const computed = ast.computed.map((entry) => ({ name: entry.name, expr: entry.expr.trim() })); const effects = ast.effects.map((entry) => entry.body.trim()).filter(Boolean); 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 && ast.outputs.length === 0 && computed.length === 0 && effects.length === 0 && Object.keys(lifecycle).length === 0 && watches.length === 0 ) { return null; } return { functions, outputs: ast.outputs, computed, effects, 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 exprRefsComponentReactiveValue(expr: string, ctx: CompCtx): boolean { return ( exprRefsState(expr, ctx.stateNames) || exprRefsState(expr, ctx.propNames) || exprRefsState(expr, ctx.functionNames) ); } 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); }); } function viewHasRestAttributeSpread(nodes: ViewNode[]): boolean { return nodes.some((node) => { if (node.type === "text") return false; if (node.type === "each") { return viewHasRestAttributeSpread(node.body) || viewHasRestAttributeSpread(node.empty); } if (node.type === "if") { return node.branches.some((branch) => viewHasRestAttributeSpread(branch.body)); } return ( node.attrs.some((attr) => /^\{\.\.\.([A-Za-z_$][\w$]*)\}$/.test(attr.name)) || viewHasRestAttributeSpread(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 (`${__wrnHtml(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 (expr === "content") { out += `\${__wrnRaw(${ctx.resolveExpr(expr)})}`; } else if (exprRefsComponentReactiveValue(expr, ctx)) { // 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(``) + `\${__wrnHtml(${ctx.resolveExpr(expr)})}` + escLit(``); } else { out += `\${__wrnHtml(${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 += `\${__wrnAttr(${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} })}"`; } function unwrapDirectiveExpression(raw: string): string { const value = raw.trim(); if (!value.startsWith("{") || !value.endsWith("}")) { return value; } let depth = 0; let quote: '"' | "'" | "`" | null = null; let escaped = false; for (let index = 0; index < value.length; index++) { const char = value[index]!; if (escaped) { escaped = false; continue; } if (quote) { if (char === "\\") { escaped = true; } else if (char === quote) { quote = null; } continue; } if (char === '"' || char === "'" || char === "`") { quote = char; continue; } if (char === "{") depth++; if (char === "}") depth--; if (depth === 0 && index < value.length - 1) { return value; } } return depth === 0 ? value.slice(1, -1).trim() : value; } /** 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 (node.tag === "Static" || node.tag === "Dynamic") { const inner = node.children.map((child) => renderComponentNode(child, ctx)).join(""); return node.tag === "Static" ? inner : `${inner}`; } if (node.tag === "KeepAlive") { const key = node.attrs.find((attribute) => attribute.name === "key")?.value ?? "default"; const inner = node.children.map((child) => renderComponentNode(child, ctx)).join(""); return `
${inner}
`; } if (node.tag === "Portal" || node.tag === "Transition" || node.tag === "Component") { const inner = node.children.map((child) => renderComponentNode(child, ctx)).join(""); const attribute = node.tag === "Portal" ? "data-wrn-portal" : node.tag === "Transition" ? "data-wrn-transition" : "data-wrn-dynamic-component"; const source = node.tag === "Portal" ? "to" : node.tag === "Transition" ? "name" : "is"; const fallback = node.tag === "Portal" ? "body" : node.tag === "Transition" ? "wrn-transition" : ""; const raw = node.attrs.find((item) => item.name === source)?.value ?? fallback; return `
${inner}
`; } if (isComponentTag(node.tag)) { return renderNestedComponentInvocation(node, ctx); } const loopVariables = loopVarsOf(node); const elementContext = { ...ctx, forwardRestAttrs: false, ...(loopVariables.length > 0 ? { loopVars: new Set([...(ctx.loopVars ?? []), ...loopVariables]) } : {}), }; 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: unwrapDirectiveExpression(attr.value), }); } } const isExplicitComponentMount = node.attrs.some( (attribute) => attribute.name === "data-component", ); const attrs = node.attrs .filter((a) => a.name !== "class" && !a.name.startsWith("class:")) .map((a) => { const spread = /^\{\.\.\.([A-Za-z_$][\w$]*)\}$/.exec(a.name); if (spread) { return `\${__wrnSpreadAttrs(${elementContext.resolveExpr(spread[1]!)})}`; } if (a.event) { return ` ${eventAttribute(a.name)}="${escLit(attrEscape(a.value))}"`; } if (a.boolean) { return ` ${a.name}`; } if (isHtmlBooleanAttribute(a.name)) { const expression = wholeAttributeExpression(a.value); if (expression) { const referencesState = exprRefsComponentReactiveValue(a.value, ctx); const referencesLoopVariable = elementContext.loopVars ? exprRefsState(a.value, elementContext.loopVars) : false; const referencesServerLocal = ctx.serverLocals ? exprRefsState(a.value, ctx.serverLocals) : false; const marker = referencesState || referencesLoopVariable || referencesServerLocal ? ` data-wrn-bind-${bindIndex++}="${escLit( attrEscape(JSON.stringify([a.name, a.value])), )}"` : ""; /* * A boolean attribute whose expression names a loop variable cannot * be resolved on the server: __wrnBooleanAttr runs at render time, * where `row` or `item` simply does not exist, and the emitted * module blew up. Leave the attribute off the server output and let * the client bind set it -- the runtime toggles boolean attributes * rather than stringifying them, so `checked={isSelected(row)}` * behaves correctly once hydrated. */ if (referencesLoopVariable) { return ` data-wrn-bind-${bindIndex++}="${escLit( attrEscape(JSON.stringify([a.name, a.value])), )}"`; } return `\${__wrnBooleanAttr(${JSON.stringify(a.name)}, ${elementContext.resolveExpr(expression)})}${marker}`; } if (a.value === "false") return ""; if (a.value === "true" || a.value === "") return ` ${a.name}`; } const wholeExpression = wholeAttributeExpression(a.value); /* * data-show carries an EXPRESSION, not a value. The client re-evaluates * whatever string it finds in the attribute on every state change, so * interpolating `{open || visible}` down to the literal "false" at * render time froze the directive: the element could never be shown * again, no matter what the state did. A data-wrn-bind marker did not * save it either -- the bind rewrites the same attribute the directive * reads, and the directive had already captured "false" as its * expression. Emitting the expression verbatim (the form Modal uses, * data-show="isOpen()") makes both authoring styles behave the same. */ if (a.name === "data-show" && wholeExpression) { return ` data-show="${escLit(attrEscape(wholeExpression))}"`; } const compiledValue = isExplicitComponentMount && wholeExpression ? `\${__wrnProp(${elementContext.resolveExpr(wholeExpression)})}` : compileAttrValue(a.value, elementContext); const rendered = ` ${a.name}="${compiledValue}"`; const referencesState = exprRefsComponentReactiveValue(a.value, ctx); 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 = exprRefsComponentReactiveValue(staticClassValue, ctx); 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}` + `${ctx.forwardRestAttrs ? "${__wrnSpreadAttrs(__attrs)}" : ""}` + `${ ctx.eventNames?.length ? ` data-wrn-events="${attrEscape(ctx.eventNames.join(","))}"` : "" }` + `${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[] = []; if (ast.imports.length > 0) out.push(generatedImports(ast).join("\n")); const hasServerEach = viewHasServerEach(ast.view); const effectiveProps = ast.kind === "layout" && !ast.props.some((prop) => prop.name === "content") ? [ { name: "content", default: '""', valueType: "string", required: false, }, ...ast.props, ] : ast.props; const browserStates = ast.states.filter((state) => state.runtime !== "server"); const stateNames = new Set([ ...browserStates.map((entry) => entry.name), ...ast.computed.map((entry) => entry.name), ]); const nameRefs = new Map(); for (const p of effectiveProps) { nameRefs.set(p.name, safeRef(p.name)); } if (!nameRefs.has("attrs")) { nameRefs.set("attrs", "__attrs"); } for (const s of ast.states) nameRefs.set(s.name, safeRef(s.name)); for (const entry of ast.computed) nameRefs.set(entry.name, safeRef(entry.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, propNames: new Set(effectiveProps.map((entry) => entry.name)), functionNames: new Set( ast.runtimeFunctions.filter((fn) => fn.runtime !== "server").map((fn) => fn.name), ), resolveExpr, eventNames: publicOutputNames(ast), }; const serverFunctions = targetFunctions(ast, "server"); const hasExplicitRestSpread = viewHasRestAttributeSpread(ast.view); const rootElementIndex = ast.view.findIndex((node) => node.type === "element"); const automaticallyForwardRootAttrs = !hasExplicitRestSpread && !effectiveProps.some((prop) => prop.name === "attrs") && rootElementIndex >= 0; const viewCode = ast.view .map((node, index) => renderComponentNode( node, automaticallyForwardRootAttrs && index === rootElementIndex ? { ...ctx, forwardRestAttrs: true } : ctx, ), ) .join(""); const styles = ast.styles.map((body) => body.trim()).filter(Boolean); const styleTag = escLit(localStyleTag(ast, styles)); // Props remain server-rendered and also become signals so a parent can drive // a mounted child after hydration. const behavior = componentBehavior(ast); const needsScope = ast.runtime !== "server" && (effectiveProps.length > 0 || browserStates.length > 0 || ast.computed.length > 0 || viewHasEvents(ast.view) || behavior !== null); if (hasServerEach || needsScope) { out.push(`import { Buffer as __WrnexusBuffer } from "node:buffer";`); } const scopeKeys = [ ...effectiveProps.map((prop) => prop.name), ...browserStates.map((state) => state.name), ]; const behaviorAttr = behaviorAttribute(behavior); const decls: string[] = []; for (const prop of effectiveProps) { if (prop.required) { decls.push( ` if (__p[${JSON.stringify(prop.name)}] === undefined) throw new TypeError(${JSON.stringify( `${ast.name} requires prop '${prop.name}' (${prop.valueType ?? "unknown"})`, )});`, ); } decls.push( ` const ${nameRefs.get(prop.name)}: ${prop.valueType ?? "unknown"} = __coerce(__p[${JSON.stringify(prop.name)}], (${resolveExpr(prop.default)}), ${JSON.stringify(runtimeTypeOf(prop.valueType))}, ${JSON.stringify(prop.name)}) as ${prop.valueType ?? "unknown"};`, ); } if (!effectiveProps.some((prop) => prop.name === "attrs")) { decls.push( ` const __attrs = __restProps(__p, new Set(${JSON.stringify(effectiveProps.map((prop) => prop.name))}));`, ); } for (const state of ast.states) { decls.push( ` let ${nameRefs.get(state.name)}${state.valueType ? `: ${state.valueType}` : ""} = (${resolveExpr(state.expr)});`, ); } for (const entry of ast.computed) { decls.push(` const ${nameRefs.get(entry.name)} = (${resolveExpr(entry.expr)});`); } const returnExpr = needsScope ? "`" + styleTag + `
` + viewCode + "
`" : "`" + styleTag + viewCode + "`"; const scopeLine = needsScope && scopeKeys.length > 0 ? ` const __scopeState = { ${scopeKeys .map((key) => `${JSON.stringify(key)}: ${nameRefs.get(key)}`) .join( ", ", )} };\n const __scope = __wrnexusScopeDecl(__scopeState);\n const __scopePayload = __WrnexusBuffer.from(JSON.stringify(__scopeState), "utf8").toString("base64");\n` : needsScope ? ` const __scopeState = {};\n const __scope = "";\n const __scopePayload = __WrnexusBuffer.from("{}", "utf8").toString("base64");\n` : ""; if (ast.kind === "layout") { out.push(`export const __wrnexusLayout = ${JSON.stringify(ast.name)};`); } else { out.push(`export const __wrnexusComponent = ${JSON.stringify(ast.name)};`); } out.push(`export const __wrnexusRuntime = ${JSON.stringify(ast.runtime ?? "universal")};`); out.push(`export const __wrnexusRender = ${JSON.stringify(ast.renderMode ?? "hybrid")};`); out.push( `export const __wrnexusHydrate = ${JSON.stringify(["static", "server"].includes(ast.renderMode ?? "") ? "none" : (ast.hydrate ?? "load"))};`, ); out.push(`export const __wrnexusHydrationId = ${JSON.stringify(hydrationId(ast))};`); if (Object.keys(ast.cache ?? {}).length > 0) out.push(`export const __wrnexusCache = ${JSON.stringify(ast.cache, null, 2)};`); if (Object.keys(ast.security).length > 0) { out.push(`export const __wrnexusSecurity = ${JSON.stringify(ast.security, null, 2)};`); } if (Object.keys(ast.navigation).length > 0) { out.push(`export const __wrnexusNavigation = ${JSON.stringify(ast.navigation, null, 2)};`); } const componentStyleExport = localStyleExport(ast, styles); if (componentStyleExport) out.push(componentStyleExport); if (behavior) { out.push(`export const __wrnexusBehavior = ${JSON.stringify(behavior, null, 2)};`); } const typeSource = ast.types .map((body) => body.trim()) .filter(Boolean) .join("\n\n"); if (typeSource) out.push(typeSource); if (effectiveProps.length > 0) { out.push( `export interface ${ast.name}Props {\n [attribute: string]: unknown;\n${effectiveProps .map( (prop) => ` ${JSON.stringify(prop.name)}${prop.required ? "" : "?"}: ${prop.valueType ?? "unknown"};`, ) .join("\n")}\n}`, ); } if (ast.outputs.length > 0) { out.push( `export interface ${ast.name}Outputs {\n${ast.outputs .map( (output) => ` ${JSON.stringify(output.name)}(${output.payload ? `${output.payload.name}${output.payload.optional ? "?" : ""}: ${output.payload.valueType}` : ""}): void;`, ) .join("\n")}\n}`, ); } out.push(`function __coerce(v: unknown, def: unknown, declared: string = "unknown", propName: string = "prop"): unknown { if (v === undefined || v === null) { return def; } if (declared === "number" || typeof def === "number") { const parsed = Number(v); if (!Number.isFinite(parsed)) throw new TypeError("Expected a finite number prop"); return parsed; } if (declared === "boolean" || typeof def === "boolean") { if (v === true || v === "" || v === "true" || v === 1 || v === "1") return true; if (v === false || v === "false" || v === 0 || v === "0") return false; throw new TypeError("Expected a boolean prop"); } if (declared === "array" || Array.isArray(def)) { if (Array.isArray(v)) { return v; } if (typeof v === "string") { try { const parsed = JSON.parse(v); if (!Array.isArray(parsed)) throw new TypeError("Expected an array prop '" + propName + "'"); return parsed; } catch { throw new TypeError("Expected an array prop '" + propName + "'"); } } throw new TypeError("Expected an array prop '" + propName + "'"); } if (declared === "object" || (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); if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) { throw new TypeError("Expected an object prop '" + propName + "'"); } return parsed; } catch { throw new TypeError("Expected an object prop '" + propName + "'"); } } throw new TypeError("Expected an object prop '" + propName + "'"); } if (declared === "bigint") return BigInt(v as string | number | bigint | boolean); if (declared === "function" && typeof v !== "function") { throw new TypeError("Expected a function prop"); } return declared === "unknown" && def === undefined ? v : String(v); } function __restProps( props: Record, declared: Set, ): Record { return Object.fromEntries( Object.entries(props).filter(([name]) => !declared.has(name)), ); } function __wrnHtml(v: unknown): string { return String(v == null ? "" : v).replace( /[&<>]/g, (c) => c === "&" ? "&" : c === "<" ? "<" : ">", ); } function __wrnAttr(v: unknown): string { return String(v == null ? "" : v).replace( /[&<>"]/g, (c) => c === "&" ? "&" : c === "<" ? "<" : c === ">" ? ">" : """, ); } function __wrnBooleanAttr(name: string, value: unknown): string { return value === true || value === "true" || value === "" || value === 1 || value === "1" || value === name ? " " + name : ""; } function __wrnSpreadAttrs(value: unknown): string { if (value === null || typeof value !== "object" || Array.isArray(value)) return ""; const booleanAttributes = new Set(${JSON.stringify([...HTML_BOOLEAN_ATTRIBUTES])}); const attributes: string[] = []; for (const [name, raw] of Object.entries(value)) { const lowerName = name.toLowerCase(); if ( !/^[A-Za-z_:][A-Za-z0-9_.:-]*$/.test(name) || lowerName.startsWith("on") || lowerName === "style" || lowerName === "slot" || lowerName === "data-component" || // Internal markers must not leak through a spread. Parent-owned output // and prop bindings ride from the mount onto the // rendered child root so the mounting scope can bind them there. (lowerName.startsWith("data-wrn") && !lowerName.startsWith("data-wrn-out-") && !lowerName.startsWith("data-wrn-prop-bind-")) ) { continue; } if (booleanAttributes.has(lowerName)) { attributes.push(__wrnBooleanAttr(name, raw)); continue; } if (raw === false || raw === null || raw === undefined) continue; attributes.push(" " + name + '="' + __wrnAttr(raw) + '"'); } return attributes.join(""); } function __wrnProp(v: unknown): string { const value = v !== null && typeof v === "object" ? JSON.stringify(v) : String(v == null ? "" : v); return __wrnAttr(value); } function __wrnRaw(v: unknown): string { return String(v == null ? "" : v); }`); if (hasServerEach) { out.push(`function __wrnexusEncodeLoopLocals(value: Record): string { return __WrnexusBuffer.from(JSON.stringify(value), "utf8").toString("base64"); }`); } if (needsScope) { out.push(`function __wrnexusSerializeScopeValue(value: unknown): 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: ${effectiveProps.length > 0 ? `${ast.name}Props` : "Record"} = {} as ${effectiveProps.length > 0 ? `${ast.name}Props` : "Record"}): string {\n` + ` const __p = props || {};\n` + (decls.length > 0 ? decls.join("\n") + "\n" : "") + serverFunctionSource + scopeLine + ` return ${returnExpr};\n` + `}`, ); out.push( `export default { name: ${JSON.stringify(ast.name)}, kind: ${JSON.stringify(ast.kind)}, render };`, ); return out.join("\n\n") + "\n"; } function __wrnRaw(v: unknown): 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 __wrnHtml(v: unknown): string { return String(v == null ? "" : v).replace(/[&<>]/g, (c) => c === "&" ? "&" : c === "<" ? "<" : ">", ); } function __wrnAttr(v: unknown): string { return String(v == null ? "" : v).replace(/[&<>"]/g, (c) => c === "&" ? "&" : c === "<" ? "<" : c === ">" ? ">" : """, ); } function __wrnProp(v: unknown): string { const value = v !== null && typeof v === "object" ? JSON.stringify(v) : String(v == null ? "" : v); return __wrnAttr(value); } function renderPageComponentAttr(attr: Attr, dynamicExpressions: string[]): string { if (attr.event) { return ` ${componentEventAttribute(attr.name)}="${attrEscape(attr.value)}"`; } if (attr.boolean) { return ` ${attr.name}`; } const expression = wholeAttributeExpression(attr.value); if (!expression) { return ` ${attr.name}="${attrEscape(safeAttributeValue(attr.name, attr.value))}"`; } dynamicExpressions.push(`\${__wrnexusPropAttr(${expression})}`); const marker = `\x00WRNEACH${dynamicExpressions.length - 1}\x00`; return ` ${attr.name}="${marker}"`; }