2721 lines
87 KiB
TypeScript
2721 lines
87 KiB
TypeScript
/**
|
|
* Code generation: lower a `.wrn` AST to TypeScript that targets the framework's
|
|
* existing primitives.
|
|
*
|
|
* state -> a `data-scope` declaration consumed by the runtime
|
|
* view -> an HTML string returned by a page component
|
|
* @event="..." -> data-on-<event>="..."
|
|
* "...{expr}..." -> text kept verbatim ({expr} is mustache for runtime)
|
|
* api="<name>" -> SSR/client data binding declared in a mode block
|
|
* ssrGet/ssrText -> legacy server-side API fetch + render
|
|
* csrGet/csrText -> legacy browser-side API fetch + render
|
|
* style -> 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 { 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;
|
|
}
|
|
|
|
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, "<")
|
|
.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}`;
|
|
}
|
|
|
|
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 `<span data-t="key">` marker the
|
|
* runtime resolves server-side. Other `{expr}` mustaches are left untouched.
|
|
*/
|
|
function substituteTMarkers(text: string): string {
|
|
return text.replace(
|
|
/\{t:([^{}]+)\}/g,
|
|
(_m, key: string) => `<span data-t="${attrEscape(key.trim())}"></span>`,
|
|
);
|
|
}
|
|
|
|
/** Escape a value for safe embedding in HTML text. */
|
|
function htmlTextEscape(value: string): string {
|
|
return value.replace(/[&<>]/g, (c) => (c === "&" ? "&" : c === "<" ? "<" : ">"));
|
|
}
|
|
|
|
/** Reactive page context: state names + their initial (SSR) values. */
|
|
interface PageReactive {
|
|
stateNames: Set<string>;
|
|
runtimeStateNames: Set<string>;
|
|
scope: Record<string, unknown>;
|
|
}
|
|
|
|
/**
|
|
* Evaluate a page's `state` seed expressions at compile time to obtain the
|
|
* initial SSR values used to bake `data-text` spans. Seeds may reference
|
|
* earlier ones; anything that can't be evaluated becomes `undefined`.
|
|
*/
|
|
function evalStateSeeds(states: { name: string; expr: string }[]): Record<string, unknown> {
|
|
const scope: Record<string, unknown> = {};
|
|
for (const s of states) {
|
|
try {
|
|
scope[s.name] = new Function("with(this){return (" + s.expr + ");}").call(scope);
|
|
} catch {
|
|
scope[s.name] = undefined;
|
|
}
|
|
}
|
|
return scope;
|
|
}
|
|
|
|
/**
|
|
* Page text compilation: resolve `{t:key}` i18n markers, then bake state
|
|
* interpolations (`{count}`, `{count * 2}`) into `data-text` spans carrying the
|
|
* evaluated initial value — so no-JS clients see real content and the reactive
|
|
* runtime keeps it live. Non-state `{expr}` and un-evaluable expressions are
|
|
* left as literal client mustaches.
|
|
*/
|
|
function substituteReactiveText(
|
|
raw: string,
|
|
reactive: PageReactive | null,
|
|
dynamicExpressions?: string[],
|
|
): string {
|
|
const text = substituteTMarkers(raw);
|
|
if (!reactive || reactive.stateNames.size === 0) return text;
|
|
return text.replace(/\{([^{}]+)\}/g, (whole, inner: string) => {
|
|
const expr = inner.trim();
|
|
if (expr.startsWith("t:") || !exprRefsState(expr, reactive.stateNames)) return whole;
|
|
if (exprRefsState(expr, reactive.runtimeStateNames) && dynamicExpressions) {
|
|
dynamicExpressions.push(`\${__wrnexusEscapeHtml(${expr})}`);
|
|
const sentinel = `\x00WRNEACH${dynamicExpressions.length - 1}\x00`;
|
|
return `<span data-text="${attrEscape(expr)}">${sentinel}</span>`;
|
|
}
|
|
let value: unknown;
|
|
try {
|
|
value = new Function("with(this){return (" + expr + ");}").call(reactive.scope);
|
|
} catch {
|
|
return whole; // can't evaluate → keep as a client-only mustache
|
|
}
|
|
const baked = htmlTextEscape(value == null ? "" : String(value));
|
|
return `<span data-text="${attrEscape(expr)}">${baked}</span>`;
|
|
});
|
|
}
|
|
|
|
type EachNode = Extract<ViewNode, { type: "each" }>;
|
|
type IfNode = Extract<ViewNode, { type: "if" }>;
|
|
|
|
/**
|
|
* Bake a loop-body text run into template-literal source: static text is escaped
|
|
* for the literal, `{expr}` becomes `${__wrnexusEscapeHtml(expr)}` (server-rendered,
|
|
* escaped), and `{t:key}` becomes a `data-t` marker resolved later by translateHtml.
|
|
*/
|
|
function bakeLoopText(raw: string): string {
|
|
let out = "";
|
|
let last = 0;
|
|
let m: RegExpExecArray | null;
|
|
const re = /\{([^{}]+)\}/g;
|
|
while ((m = re.exec(raw))) {
|
|
out += escLit(raw.slice(last, m.index));
|
|
const expr = m[1]!.trim();
|
|
if (expr.startsWith("t:")) {
|
|
out += escLit(`<span data-t="${attrEscape(expr.slice(2).trim())}"></span>`);
|
|
} else {
|
|
out += "${__wrnexusEscapeHtml(" + expr + ")}";
|
|
}
|
|
last = m.index + m[0].length;
|
|
}
|
|
return out + escLit(raw.slice(last));
|
|
}
|
|
|
|
/** Bake a loop-body attribute value (same rules as text; escapeHtml is attribute-safe). */
|
|
function bakeLoopAttr(raw: string, 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 ? 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('<wrn-dynamic-region data-wrn-dynamic="true">') +
|
|
inner +
|
|
escLit("</wrn-dynamic-region>")
|
|
);
|
|
if (node.tag === "KeepAlive") {
|
|
const key = node.attrs.find((attribute) => attribute.name === "key")?.value ?? "default";
|
|
return (
|
|
escLit('<div data-wrn-keepalive="') +
|
|
bakeLoopAttr(key) +
|
|
escLit(`">`) +
|
|
inner +
|
|
escLit("</div>")
|
|
);
|
|
}
|
|
if (node.tag === "Portal") {
|
|
const target = node.attrs.find((attribute) => attribute.name === "to")?.value ?? "body";
|
|
return (
|
|
escLit('<div data-wrn-portal="') +
|
|
bakeLoopAttr(target) +
|
|
escLit('">') +
|
|
inner +
|
|
escLit("</div>")
|
|
);
|
|
}
|
|
if (node.tag === "Transition") {
|
|
const name =
|
|
node.attrs.find((attribute) => attribute.name === "name")?.value ?? "wrn-transition";
|
|
return (
|
|
escLit('<div data-wrn-transition="') +
|
|
bakeLoopAttr(name) +
|
|
escLit('">') +
|
|
inner +
|
|
escLit("</div>")
|
|
);
|
|
}
|
|
if (node.tag === "Component") {
|
|
const selected = node.attrs.find((attribute) => attribute.name === "is")?.value ?? "";
|
|
return (
|
|
escLit('<div data-wrn-dynamic-component="') +
|
|
bakeLoopAttr(selected) +
|
|
escLit('">') +
|
|
inner +
|
|
escLit("</div>")
|
|
);
|
|
}
|
|
|
|
if (componentTag) {
|
|
return (
|
|
escLit(`<div data-component="${attrEscape(node.tag)}"`) +
|
|
attrs +
|
|
escLit(">") +
|
|
inner +
|
|
escLit("</div>")
|
|
);
|
|
}
|
|
|
|
if (VOID_ELEMENTS.has(node.tag.toLowerCase())) {
|
|
return escLit(`<${node.tag}`) + attrs + escLit(">");
|
|
}
|
|
|
|
return escLit(`<${node.tag}`) + attrs + escLit(">") + inner + escLit(`</${node.tag}>`);
|
|
}
|
|
|
|
/**
|
|
* Compile a `{#each list as item}` block to a `${…}` template-literal interpolation
|
|
* that iterates the (server-evaluated) list and joins the per-item body. `list` is a
|
|
* JS expression evaluated where `ssr` data bindings are in scope as raw named values.
|
|
*/
|
|
function compileEachExpr(node: EachNode): string {
|
|
const item = node.item;
|
|
const index = node.index ?? "__wi";
|
|
const body = node.body.map(renderLoopBody).join("");
|
|
const empty = node.empty.map(renderLoopBody).join("");
|
|
return (
|
|
"${(() => { const __wl = Array.isArray(" +
|
|
node.list +
|
|
") ? (" +
|
|
node.list +
|
|
") : []; return __wl.length ? __wl.map((" +
|
|
item +
|
|
", " +
|
|
index +
|
|
") => `" +
|
|
body +
|
|
'`).join("") : `' +
|
|
empty +
|
|
"`; })()}"
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Compile a `{#if}` block to a `${…}` template-literal interpolation: a nested ternary
|
|
* that renders the first truthy branch's body (or the `{:else}` body, or "" when neither).
|
|
* Conditions are JS expressions evaluated in the surrounding server scope.
|
|
*/
|
|
function compileIfExpr(node: IfNode): string {
|
|
let expr = "``"; // no matching branch → empty string
|
|
for (let k = node.branches.length - 1; k >= 0; k--) {
|
|
const b = node.branches[k]!;
|
|
const bodySrc = "`" + b.body.map(renderLoopBody).join("") + "`";
|
|
expr = b.cond === null ? bodySrc : "(" + b.cond + ") ? " + bodySrc + " : " + expr;
|
|
}
|
|
return "${" + expr + "}";
|
|
}
|
|
|
|
/**
|
|
* Collect every server-control expression in a view (recursively): `{#each}` list
|
|
* expressions and `{#if}` conditions. Used to wire up raw SSR data consts.
|
|
*/
|
|
function collectControlExprs(nodes: ViewNode[], out: string[] = []): string[] {
|
|
for (const node of nodes) {
|
|
if (node.type === "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<string, NamedDataBinding>,
|
|
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
|
|
: `<wrn-dynamic-region data-wrn-dynamic="true">${inner}</wrn-dynamic-region>`;
|
|
}
|
|
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 `<div${rendered}>${inner}</div>`;
|
|
}
|
|
|
|
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<ViewNode, { type: "element" }> =>
|
|
child.type === "element" && child.tag === name,
|
|
);
|
|
const branch = (name: string) => {
|
|
const element = branchElement(name);
|
|
return (element?.children ?? [])
|
|
.map((child) => renderNode(child, ssrBindings, csrBindings, apiBindings, loops, reactive))
|
|
.join("");
|
|
};
|
|
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 `<section data-wrn-async="${attrEscape(source)}" data-wrn-async-tags="${attrEscape(tags)}" data-wrn-async-retries="${attrEscape(retries)}"${serverResolved ? ' data-wrn-async-resolved="true"' : ""} aria-busy="${serverResolved ? "false" : "true"}"><div data-wrn-async-content>${initial}</div><template data-wrn-async-loading>${loading}</template><template data-wrn-async-success data-wrn-async-alias="${attrEscape(successAlias)}">${successTemplate}</template><template data-wrn-async-error data-wrn-async-alias="${attrEscape(errorAlias)}">${errorTemplate}</template></section>`;
|
|
}
|
|
|
|
if (node.tag === "KeepAlive") {
|
|
const key = attrValue(node.attrs, "key") ?? "default";
|
|
const inner = node.children
|
|
.map((child) => renderNode(child, ssrBindings, csrBindings, apiBindings, loops, reactive))
|
|
.join("");
|
|
return `<div data-wrn-keepalive="${attrEscape(key)}">${inner}</div>`;
|
|
}
|
|
if (isComponentTag(node.tag)) {
|
|
return renderPageComponentInvocation(
|
|
node,
|
|
ssrBindings,
|
|
csrBindings,
|
|
apiBindings,
|
|
loops,
|
|
reactive,
|
|
);
|
|
}
|
|
|
|
const apiName = attrValue(node.attrs, "api");
|
|
const apiBinding = apiName ? apiBindings.get(apiName) : undefined;
|
|
if (apiName && !apiBinding) {
|
|
throw new Error(`Unknown .wrn api binding "${apiName}"`);
|
|
}
|
|
|
|
const ssrGet = attrValue(node.attrs, "ssrGet");
|
|
const ssrText = attrValue(node.attrs, "ssrText");
|
|
const csrGet = attrValue(node.attrs, "csrGet");
|
|
const csrText = attrValue(node.attrs, "csrText");
|
|
|
|
const csrId =
|
|
apiBinding?.mode === "client"
|
|
? csrMarker(csrBindings, renderBinding(apiBinding))
|
|
: csrGet && csrText
|
|
? csrMarker(csrBindings, {
|
|
method: "GET",
|
|
path: apiRoutePath(csrGet),
|
|
body: expressionBody(csrText),
|
|
helpers: "",
|
|
})
|
|
: undefined;
|
|
|
|
// Void elements (<br>, <img>, …) have no closing tag and no children.
|
|
if (VOID_ELEMENTS.has(node.tag.toLowerCase())) {
|
|
return `<${node.tag}${renderAttrs(node.attrs, csrId, reactive, 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}</${node.tag}>`;
|
|
}
|
|
|
|
function renderPageComponentInvocation(
|
|
node: Extract<ViewNode, { type: "element" }>,
|
|
ssrBindings: SsrBinding[],
|
|
csrBindings: CsrBinding[],
|
|
apiBindings: Map<string, NamedDataBinding>,
|
|
loops: string[],
|
|
reactive: PageReactive | null,
|
|
): string {
|
|
const attrs = node.attrs
|
|
.filter((attr) => attr.name !== "data-component")
|
|
.map((attr) => renderPageComponentAttr(attr, loops))
|
|
.join("");
|
|
|
|
const inner = node.children
|
|
.map((child) => renderNode(child, ssrBindings, csrBindings, apiBindings, loops, reactive))
|
|
.join("");
|
|
|
|
return `<div data-component="${attrEscape(node.tag)}"` + `${attrs}>${inner}</div>`;
|
|
}
|
|
|
|
function renderNestedComponentInvocation(
|
|
node: Extract<ViewNode, { type: "element" }>,
|
|
ctx: CompCtx,
|
|
): string {
|
|
let bindIndex = 0;
|
|
|
|
const attrs = node.attrs
|
|
.filter((attr) => attr.name !== "data-component")
|
|
.map((attr) => {
|
|
const spread = /^\{\.\.\.([A-Za-z_$][\w$]*)\}$/.exec(attr.name);
|
|
if (spread) {
|
|
return `\${__wireSpreadAttrs(${ctx.resolveExpr(spread[1]!)})}`;
|
|
}
|
|
|
|
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,
|
|
forwardRestAttrs: false,
|
|
loopVars: new Set([...(ctx.loopVars ?? []), ...loops]),
|
|
}
|
|
: { ...ctx, forwardRestAttrs: false };
|
|
|
|
const inner = node.children.map((child) => renderComponentNode(child, childCtx)).join("");
|
|
|
|
return (
|
|
`<div data-component="${attrEscape(node.tag)}"` +
|
|
`${ctx.forwardRestAttrs ? "${__wireSpreadAttrs(__attrs)}" : ""}` +
|
|
`${attrs}>${inner}</div>`
|
|
);
|
|
}
|
|
|
|
function ssrMarker(bindings: SsrBinding[], binding: RenderBinding): string {
|
|
const marker = `<!--wrnexus-ssr:${bindings.length}-->`;
|
|
bindings.push({ marker, ...binding });
|
|
return marker;
|
|
}
|
|
|
|
function csrMarker(bindings: CsrBinding[], binding: RenderBinding): string {
|
|
const id = String(bindings.length);
|
|
bindings.push({ id, ...binding });
|
|
return id;
|
|
}
|
|
|
|
function renderBinding(binding: NamedDataBinding): RenderBinding {
|
|
return {
|
|
method: binding.method,
|
|
path: binding.path,
|
|
body: binding.body,
|
|
helpers: binding.helpers,
|
|
};
|
|
}
|
|
|
|
function hasClientBehavior(nodes: ViewNode[]): boolean {
|
|
return nodes.some((node) => {
|
|
// `{t:key}` is i18n sugar resolved server-side — not client reactivity.
|
|
if (node.type === "text") return /\{(?!t:)[^{}]+\}/.test(node.value);
|
|
// Server control blocks render on the server; they don't add client reactivity.
|
|
if (node.type === "each") {
|
|
return hasClientBehavior(node.body) || hasClientBehavior(node.empty);
|
|
}
|
|
|
|
if (node.type === "if") {
|
|
return node.branches.some((branch) => hasClientBehavior(branch.body));
|
|
}
|
|
return (
|
|
node.attrs.some((attr) => attr.event || attr.name === "csrGet" || attr.name === "csrText") ||
|
|
hasClientBehavior(node.children)
|
|
);
|
|
});
|
|
}
|
|
|
|
function apiRoutePath(path: string): string {
|
|
const trimmed = path.trim();
|
|
if (!trimmed.startsWith("/")) {
|
|
throw new Error(`.wrn API paths must start with "/": ${path}`);
|
|
}
|
|
if (trimmed.includes("\0") || trimmed.includes("\\") || /(^|\/)\.\.(\/|$)/.test(trimmed)) {
|
|
throw new Error(`Unsafe .wrn API path: ${path}`);
|
|
}
|
|
if (trimmed === "/api" || trimmed.startsWith("/api/")) return trimmed;
|
|
return `/api${trimmed}`;
|
|
}
|
|
|
|
function expressionBody(expr: string): string {
|
|
return `return (${expr});`;
|
|
}
|
|
|
|
function dataBody(source: string): string {
|
|
const trimmed = source.trim();
|
|
if (!trimmed) return "return undefined;";
|
|
return /\breturn\b/.test(trimmed) ? trimmed : expressionBody(trimmed);
|
|
}
|
|
|
|
function modeHelpers(ast: PageAst, mode: DataMode, sharedHelpers: string): string {
|
|
return [
|
|
sharedHelpers,
|
|
...ast.modeFunctions
|
|
.filter((block) => block.mode === mode)
|
|
.map((block) => block.body.trim())
|
|
.filter(Boolean),
|
|
]
|
|
.filter(Boolean)
|
|
.join("\n\n");
|
|
}
|
|
|
|
function apiBindingMap(ast: PageAst, sharedHelpers: string): Map<string, NamedDataBinding> {
|
|
const bindings = new Map<string, NamedDataBinding>();
|
|
|
|
for (const block of ast.dataApis) {
|
|
if (bindings.has(block.name)) {
|
|
throw new Error(`Duplicate .wrn api binding "${block.name}"`);
|
|
}
|
|
bindings.set(block.name, {
|
|
mode: block.mode,
|
|
method: block.method,
|
|
path: apiRoutePath(block.path),
|
|
body: dataBody(block.body),
|
|
helpers: modeHelpers(ast, block.mode, sharedHelpers),
|
|
});
|
|
}
|
|
|
|
return bindings;
|
|
}
|
|
|
|
function ssrRuntimeSource(): string {
|
|
return `const __wrnexusHtmlEscapes = { "&": "&", "<": "<", ">": ">", "\\"": """, "'": "'" };
|
|
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<unknown> {
|
|
if (typeof ctx.__wrnexusCallApi === "function") {
|
|
return await ctx.__wrnexusCallApi(path, method);
|
|
}
|
|
|
|
const url = new URL(path, ctx.req.url);
|
|
const res = await fetch(new Request(url, { method, headers: ctx.req.headers }));
|
|
if (!res.ok) {
|
|
throw new Error(".wrn data API request failed with status " + res.status);
|
|
}
|
|
|
|
const type = res.headers.get("content-type") || "";
|
|
return type.includes("application/json") ? await res.json() : await res.text();
|
|
}
|
|
|
|
async function __wrnexusRenderSsrBindings(html: string, ctx: any): Promise<string> {
|
|
for (const binding of __wrnexusSsrBindings) {
|
|
const data = await __wrnexusCallApi(binding.path, binding.method, ctx);
|
|
const value = __wrnexusEvalData(data, binding.body, binding.helpers, ctx);
|
|
html = html.replace(binding.marker, __wrnexusEscapeHtml(value));
|
|
}
|
|
return html;
|
|
}`;
|
|
}
|
|
|
|
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 `<style data-wrnexus-style="${attrEscape(ast.name)}" data-wrnexus-style-id="${attrEscape(id)}" data-wrnexus-style-owner="${attrEscape(ast.name)}" data-wrnexus-style-kind="${attrEscape(ast.kind)}">\n${css}\n</style>`;
|
|
}
|
|
|
|
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<string>();
|
|
const visited = new Set<string>();
|
|
const ordered: PageAst["states"] = [];
|
|
|
|
const visit = (name: string): void => {
|
|
if (visited.has(name)) return;
|
|
if (visiting.has(name)) {
|
|
throw new Error(`WRN-STATE-CYCLE: state value '${name}' has a dependency cycle.`);
|
|
}
|
|
const entry = byName.get(name);
|
|
if (!entry) return;
|
|
visiting.add(name);
|
|
for (const dependency of byName.keys()) {
|
|
if (
|
|
dependency !== name &&
|
|
new RegExp(`\\b${dependency.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\b`).test(entry.expr)
|
|
) {
|
|
visit(dependency);
|
|
}
|
|
}
|
|
visiting.delete(name);
|
|
visited.add(name);
|
|
ordered.push(entry);
|
|
};
|
|
|
|
for (const entry of entries) visit(entry.name);
|
|
return ordered;
|
|
}
|
|
|
|
function generateSsrStateInitializer(entries: PageAst["states"]): string {
|
|
const ordered = orderPageStates(entries);
|
|
const declarations = ordered
|
|
.filter((entry) => isSafeGeneratedIdentifier(entry.name))
|
|
.map(
|
|
(entry) =>
|
|
`const ${entry.name} = (() => { try { return (${entry.expr}); } catch { return undefined; } })();`,
|
|
)
|
|
.join(" ");
|
|
const values = entries
|
|
.map((entry) => {
|
|
if (isSafeGeneratedIdentifier(entry.name)) {
|
|
return `${JSON.stringify(entry.name)}: ${entry.name}`;
|
|
}
|
|
return `${JSON.stringify(entry.name)}: (() => { try { return (${entry.expr}); } catch { return undefined; } })()`;
|
|
})
|
|
.join(", ");
|
|
return `(() => { ${declarations} return { ${values} }; })()`;
|
|
}
|
|
|
|
function orderPageComputed(entries: PageAst["computed"]): PageAst["computed"] {
|
|
if (entries.length < 2) return entries;
|
|
const byName = new Map(entries.map((entry) => [entry.name, entry]));
|
|
const visiting = new Set<string>();
|
|
const visited = new Set<string>();
|
|
const ordered: PageAst["computed"] = [];
|
|
|
|
const visit = (name: string): void => {
|
|
if (visited.has(name)) return;
|
|
if (visiting.has(name)) {
|
|
throw new Error(`WRN-COMPUTED-CYCLE: computed value '${name}' has a dependency cycle.`);
|
|
}
|
|
const entry = byName.get(name);
|
|
if (!entry) return;
|
|
visiting.add(name);
|
|
for (const dependency of byName.keys()) {
|
|
if (
|
|
dependency !== name &&
|
|
new RegExp(`\\b${dependency.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\b`).test(entry.expr)
|
|
) {
|
|
visit(dependency);
|
|
}
|
|
}
|
|
visiting.delete(name);
|
|
visited.add(name);
|
|
ordered.push(entry);
|
|
};
|
|
|
|
for (const entry of entries) visit(entry.name);
|
|
return ordered;
|
|
}
|
|
|
|
function generateSsrComputedAliases(entries: PageAst["computed"]): string {
|
|
return orderPageComputed(entries)
|
|
.filter((entry) => isSafeGeneratedIdentifier(entry.name))
|
|
.map(
|
|
(entry) =>
|
|
`const ${entry.name} = (() => { try { return (${entry.expr}); } catch { return undefined; } })();`,
|
|
)
|
|
.join("\n");
|
|
}
|
|
|
|
function runtimeComputedNames(
|
|
states: PageAst["states"],
|
|
computed: PageAst["computed"],
|
|
runtimeRoots: Iterable<string> = [],
|
|
): Set<string> {
|
|
const entries = [...states, ...computed];
|
|
const runtime = new Set([
|
|
...runtimeRoots,
|
|
...entries.filter((entry) => /\bctx\b/.test(entry.expr)).map((entry) => entry.name),
|
|
]);
|
|
let changed = true;
|
|
while (changed) {
|
|
changed = false;
|
|
for (const entry of entries) {
|
|
if (runtime.has(entry.name)) continue;
|
|
if (
|
|
[...runtime].some((name) =>
|
|
new RegExp(`\\b${name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\b`).test(entry.expr),
|
|
)
|
|
) {
|
|
runtime.add(entry.name);
|
|
changed = true;
|
|
}
|
|
}
|
|
}
|
|
return runtime;
|
|
}
|
|
|
|
function hydrationAttribute(ast: PageAst): string {
|
|
const strategy = ["static", "server"].includes(ast.renderMode ?? "")
|
|
? "none"
|
|
: (ast.hydrate ?? "load");
|
|
const hasBrowserModule = 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<string>): 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<string>): 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 function generate(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 = `<div data-scope="${scopePlaceholder}"${behaviorAttribute(pageBehavior)}${hydrationAttribute(ast)}>${html}</div>`;
|
|
}
|
|
|
|
const pageStyleTag = localStyleTag(ast, styles);
|
|
if (pageStyleTag) {
|
|
html = `${pageStyleTag}${html}`;
|
|
}
|
|
if (ast.renderMode === "client") {
|
|
const clientRoot = hydrationId(ast);
|
|
html = `<div data-wrn-client-root="${clientRoot}" aria-busy="true"></div><template data-wrn-client-template="${clientRoot}">${html}</template>`;
|
|
}
|
|
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(
|
|
/<wrn-dynamic-region\b[^>]*>[\s\S]*?<\/wrn-dynamic-region>/gi,
|
|
'<wrn-dynamic-region data-wrn-dynamic="true"></wrn-dynamic-region>',
|
|
);
|
|
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<string, never>";
|
|
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 <name> as …}` can iterate the real value.
|
|
const loopConsts: string[] = [];
|
|
if (loops.length > 0) {
|
|
const lists = collectControlExprs(ast.view);
|
|
for (const [name, binding] of apiBindings) {
|
|
if (binding.mode !== "ssr") continue;
|
|
if (!lists.some((expr) => new RegExp(`\\b${name}\\b`).test(expr))) continue;
|
|
loopConsts.push(
|
|
` const ${name} = __wrnexusEvalData(await __wrnexusCallApi(${JSON.stringify(binding.path)}, ${JSON.stringify(binding.method)}, ctx), ${JSON.stringify(binding.body)}, ${JSON.stringify(binding.helpers)}, ctx);`,
|
|
);
|
|
}
|
|
}
|
|
|
|
const needsSsrRuntime = ssrBindings.length > 0 || loops.length > 0 || 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: any) {
|
|
${storeDeclarations}
|
|
${serverLoadAliases}
|
|
${decls}
|
|
const __state: ${stateType} = ${dynamicStateInitializer};
|
|
${ssrStateAliases}
|
|
${ssrComputedAliases}
|
|
const __hydrationState = Object.fromEntries(${hydrationStateNames}.map((key) => [key, (__state as any)[key]]));
|
|
const __scopeValue = Object.entries(__hydrationState)
|
|
.map(([key, value]) => {
|
|
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, "<")
|
|
.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: any) {
|
|
${storeDeclarations}
|
|
${serverLoadAliases}
|
|
const __state: ${stateType} = ${dynamicStateInitializer};
|
|
${ssrStateAliases}
|
|
${ssrComputedAliases}
|
|
const __hydrationState = Object.fromEntries(${hydrationStateNames}.map((key) => [key, (__state as any)[key]]));
|
|
const __scopeValue = Object.entries(__hydrationState)
|
|
.map(([key, value]) => {
|
|
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, "<")
|
|
.replace(/>/g, ">");
|
|
|
|
return \`${body}\`.replace(
|
|
"__WRNEXUS_DYNAMIC_SCOPE__",
|
|
__scopeValue,
|
|
);
|
|
}`,
|
|
);
|
|
}
|
|
|
|
if (staticShellBody !== undefined) {
|
|
out.push(
|
|
`export async function __wrnexusBuildStaticShell(ctx: any = {}) {
|
|
${storeDeclarations}
|
|
${serverLoadAliases}
|
|
${loopConsts.length > 0 ? loopConsts.join("\n") : ""}
|
|
const __state: ${stateType} = ${dynamicStateInitializer};
|
|
${ssrStateAliases}
|
|
${ssrComputedAliases}
|
|
const __hydrationState = Object.fromEntries(${hydrationStateNames}.map((key) => [key, (__state as any)[key]]));
|
|
const __scopeValue = Object.entries(__hydrationState)
|
|
.map(([key, value]) => {
|
|
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, "<")
|
|
.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<unknown> | 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: any) {
|
|
${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: any, ctx: any) {
|
|
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<typeof ${action.schema}>` : "Record<string, unknown>"}, Awaited<ReturnType<typeof ${action.name}>>>("", ${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: any) => {${api.body}};`);
|
|
});
|
|
|
|
const entries = ast.apis.map(
|
|
(api, index) =>
|
|
` ${JSON.stringify(`${api.method} ${apiRoutePath(api.path)}`)}: __wrnexusApi_${api.method}_${index},`,
|
|
);
|
|
out.push(`export const __wrnexusApi = {\n${entries.join("\n")}\n};`);
|
|
|
|
const exported = new Set<string>();
|
|
ast.apis.forEach((api, index) => {
|
|
if (exported.has(api.method)) return;
|
|
exported.add(api.method);
|
|
out.push(`export const ${api.method} = __wrnexusApi_${api.method}_${index};`);
|
|
});
|
|
}
|
|
|
|
// --- Realtime blocks -> a websocket export ---
|
|
if (ast.realtimes.length > 0) {
|
|
const handlers = ast.realtimes.flatMap((rt) =>
|
|
rt.handlers.map((h) => {
|
|
const params = ["ws", ...h.args].join(", ");
|
|
return ` ${h.event}(${params}: any) {${h.body}},`;
|
|
}),
|
|
);
|
|
out.push(`export const websocket = {\n${handlers.join("\n")}\n};`);
|
|
}
|
|
|
|
return out.join("\n\n") + "\n";
|
|
}
|
|
|
|
/**
|
|
* Lower a `component` AST to a module exporting `render(props)`.
|
|
*
|
|
* A component is server-rendered on demand at each `data-component` mount and
|
|
* hydrated on the browser by the generic reactive runtime — it ships no JS of
|
|
* its own. Declared props are coerced to the type of their default value, then
|
|
* seeded (with any `state`) into the `data-scope` the reactive runtime reads.
|
|
*/
|
|
interface CompCtx {
|
|
/** State names — text referencing any of them stays a reactive client mustache. */
|
|
stateNames: Set<string>;
|
|
/** Component functions can read state and therefore make their callers reactive. */
|
|
functionNames: Set<string>;
|
|
/** Rewrite reserved-word prop/state identifiers to their safe const names. */
|
|
resolveExpr: (expr: string) => string;
|
|
/** `data-for` loop variables in scope — their mustaches stay literal for the
|
|
* client's list renderer (never baked server-side, since they have no value). */
|
|
loopVars?: Set<string>;
|
|
/** Local identifiers introduced by server-rendered `{#each}` blocks. */
|
|
serverLocals?: Set<string>;
|
|
/** 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<string>): 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.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 (`${__wireHtml(expr)}`), so static
|
|
* components render correct HTML with zero JavaScript.
|
|
*/
|
|
function compileText(raw: string, ctx: CompCtx): string {
|
|
let out = "";
|
|
let last = 0;
|
|
let m: RegExpExecArray | null;
|
|
INTERP_RE.lastIndex = 0;
|
|
while ((m = INTERP_RE.exec(raw))) {
|
|
out += escLit(raw.slice(last, m.index));
|
|
const expr = m[1]!.trim();
|
|
if (expr.startsWith("t:")) {
|
|
// i18n sugar: {t:key} → a marker resolved server-side by translateHtml.
|
|
out += escLit(`<span data-t="${attrEscape(expr.slice(2).trim())}"></span>`);
|
|
} else if (ctx.loopVars && exprRefsState(expr, ctx.loopVars)) {
|
|
// Loop variable (from data-for): leave a literal client mustache — the
|
|
// list renderer fills it per item; it has no server-side value.
|
|
out += escLit(`{${expr}}`);
|
|
} else if (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` → `<span data-text="count">0</span>`.
|
|
out +=
|
|
escLit(`<span data-text="${attrEscape(expr)}">`) +
|
|
`\${__wireHtml(${ctx.resolveExpr(expr)})}` +
|
|
escLit(`</span>`);
|
|
} else if (expr === "content") {
|
|
out += `\${__wireRaw(${ctx.resolveExpr(expr)})}`;
|
|
} else {
|
|
out += `\${__wireHtml(${ctx.resolveExpr(expr)})}`;
|
|
}
|
|
last = m.index + m[0].length;
|
|
}
|
|
return out + escLit(raw.slice(last));
|
|
}
|
|
|
|
/** Compile an attribute value; `{expr}` is baked server-side (loop vars stay literal). */
|
|
function compileAttrValue(raw: string, ctx: CompCtx): string {
|
|
if (!raw.includes("{")) return escLit(attrEscape(raw));
|
|
let out = "";
|
|
let last = 0;
|
|
let m: RegExpExecArray | null;
|
|
INTERP_RE.lastIndex = 0;
|
|
while ((m = INTERP_RE.exec(raw))) {
|
|
out += escLit(attrEscape(raw.slice(last, m.index)));
|
|
const expr = m[1]!.trim();
|
|
if (ctx.loopVars && exprRefsState(expr, ctx.loopVars)) {
|
|
out += escLit(`{${expr}}`); // hydrated per-item by the list renderer
|
|
} else {
|
|
out += `\${__wireAttr(${ctx.resolveExpr(expr)})}`;
|
|
}
|
|
last = m.index + m[0].length;
|
|
}
|
|
return out + escLit(attrEscape(raw.slice(last)));
|
|
}
|
|
|
|
function renderComponentIfNode(node: IfNode, ctx: CompCtx): string {
|
|
let expression = "``";
|
|
|
|
for (let index = node.branches.length - 1; index >= 0; index--) {
|
|
const branch = node.branches[index]!;
|
|
const body = branch.body.map((child) => renderComponentNode(child, ctx)).join("");
|
|
const bodyExpression = "`" + body + "`";
|
|
|
|
expression =
|
|
branch.cond === null
|
|
? bodyExpression
|
|
: `(${ctx.resolveExpr(branch.cond)}) ? ${bodyExpression} : ${expression}`;
|
|
}
|
|
|
|
return "${" + expression + "}";
|
|
}
|
|
|
|
function renderComponentEachNode(node: EachNode, ctx: CompCtx): string {
|
|
const item = node.item;
|
|
const index = node.index ?? "__wi";
|
|
const list = ctx.resolveExpr(node.list);
|
|
|
|
const childCtx: CompCtx = {
|
|
...ctx,
|
|
serverLocals: new Set([...(ctx.serverLocals ?? []), item, index]),
|
|
};
|
|
|
|
const body = node.body.map((child) => renderComponentNode(child, childCtx)).join("");
|
|
const empty = node.empty.map((child) => renderComponentNode(child, ctx)).join("");
|
|
|
|
return (
|
|
"${(() => { const __wl = Array.isArray(" +
|
|
list +
|
|
") ? (" +
|
|
list +
|
|
") : []; return __wl.length ? __wl.map((" +
|
|
item +
|
|
", " +
|
|
index +
|
|
") => `" +
|
|
body +
|
|
'`).join("") : `' +
|
|
empty +
|
|
"`; })()}"
|
|
);
|
|
}
|
|
|
|
function serverLoopLocalsAttribute(ctx: CompCtx): string {
|
|
const locals = [...(ctx.serverLocals ?? [])];
|
|
|
|
if (locals.length === 0) {
|
|
return "";
|
|
}
|
|
|
|
const entries = locals.map((name) => `${JSON.stringify(name)}: ${name}`).join(", ");
|
|
|
|
return ` data-wrn-loop-locals="\${__wrnexusEncodeLoopLocals({ ${entries} })}"`;
|
|
}
|
|
|
|
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
|
|
: `<wrn-dynamic-region data-wrn-dynamic="true">${inner}</wrn-dynamic-region>`;
|
|
}
|
|
|
|
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 `<div data-wrn-keepalive="${compileAttrValue(key, ctx)}">${inner}</div>`;
|
|
}
|
|
|
|
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 `<div ${attribute}="${compileAttrValue(raw, ctx)}">${inner}</div>`;
|
|
}
|
|
|
|
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 `\${__wireSpreadAttrs(${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])),
|
|
)}"`
|
|
: "";
|
|
return `\${__wireBooleanAttr(${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);
|
|
const compiledValue =
|
|
isExplicitComponentMount && wholeExpression
|
|
? `\${__wireProp(${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 ? "${__wireSpreadAttrs(__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}</${node.tag}>`;
|
|
}
|
|
|
|
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<string, string>();
|
|
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,
|
|
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));
|
|
|
|
// 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.runtime !== "server" &&
|
|
(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 ?? "any"} = __coerce(__p[${JSON.stringify(prop.name)}], (${resolveExpr(prop.default)}), ${JSON.stringify(runtimeTypeOf(prop.valueType))});`,
|
|
);
|
|
}
|
|
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 +
|
|
`<div data-scope="\${__scope}" data-wrn-scope="\${__scopePayload}"${behaviorAttr}${hydrationAttribute(ast)}>` +
|
|
viewCode +
|
|
"</div>`"
|
|
: "`" + 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: any, def: any, declared: string = "unknown"): any {
|
|
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);
|
|
return Array.isArray(parsed) ? parsed : def;
|
|
} catch {
|
|
if (declared === "array") throw new TypeError("Expected an array prop");
|
|
return def;
|
|
}
|
|
}
|
|
|
|
return def;
|
|
}
|
|
|
|
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);
|
|
|
|
return (
|
|
parsed !== null &&
|
|
typeof parsed === "object" &&
|
|
!Array.isArray(parsed)
|
|
)
|
|
? parsed
|
|
: def;
|
|
} catch {
|
|
if (declared === "object") throw new TypeError("Expected an object prop");
|
|
return def;
|
|
}
|
|
}
|
|
|
|
return def;
|
|
}
|
|
|
|
if (declared === "bigint") return BigInt(v);
|
|
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<string, any>,
|
|
declared: Set<string>,
|
|
): Record<string, any> {
|
|
return Object.fromEntries(
|
|
Object.entries(props).filter(([name]) => !declared.has(name)),
|
|
);
|
|
}
|
|
|
|
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 __wireBooleanAttr(name: string, value: any): string {
|
|
return value === true ||
|
|
value === "true" ||
|
|
value === "" ||
|
|
value === 1 ||
|
|
value === "1" ||
|
|
value === name
|
|
? " " + name
|
|
: "";
|
|
}
|
|
|
|
function __wireSpreadAttrs(value: any): 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" ||
|
|
lowerName.startsWith("data-wrn")
|
|
) {
|
|
continue;
|
|
}
|
|
|
|
if (booleanAttributes.has(lowerName)) {
|
|
attributes.push(__wireBooleanAttr(name, raw));
|
|
continue;
|
|
}
|
|
|
|
if (raw === false || raw === null || raw === undefined) continue;
|
|
attributes.push(" " + name + '="' + __wireAttr(raw) + '"');
|
|
}
|
|
|
|
return attributes.join("");
|
|
}
|
|
|
|
function __wireProp(v: any): string {
|
|
const value =
|
|
v !== null && typeof v === "object"
|
|
? JSON.stringify(v)
|
|
: String(v == null ? "" : v);
|
|
|
|
return __wireAttr(value);
|
|
}
|
|
|
|
function __wireRaw(v: any): string {
|
|
return String(v == null ? "" : v);
|
|
}`);
|
|
|
|
if (hasServerEach) {
|
|
out.push(`function __wrnexusEncodeLoopLocals(value: Record<string, any>): string {
|
|
return __WrnexusBuffer.from(JSON.stringify(value), "utf8").toString("base64");
|
|
}`);
|
|
}
|
|
|
|
if (needsScope) {
|
|
out.push(`function __wrnexusSerializeScopeValue(value: any): string {
|
|
if (value === undefined) {
|
|
return "undefined";
|
|
}
|
|
|
|
if (value === null) {
|
|
return "null";
|
|
}
|
|
|
|
if (typeof value === "number") {
|
|
return Number.isFinite(value)
|
|
? String(value)
|
|
: "null";
|
|
}
|
|
|
|
if (typeof value === "boolean") {
|
|
return value ? "true" : "false";
|
|
}
|
|
|
|
if (typeof value === "string") {
|
|
return JSON.stringify(value);
|
|
}
|
|
|
|
try {
|
|
const serialized = JSON.stringify(value);
|
|
|
|
return serialized === undefined
|
|
? "undefined"
|
|
: serialized;
|
|
} catch {
|
|
return "null";
|
|
}
|
|
}
|
|
|
|
function __wrnexusScopeDecl(obj: Record<string, any>): string {
|
|
return Object.keys(obj)
|
|
.map(
|
|
(key) =>
|
|
key +
|
|
": " +
|
|
__wrnexusSerializeScopeValue(
|
|
obj[key],
|
|
),
|
|
)
|
|
.join(", ")
|
|
.replace(/&/g, "&")
|
|
.replace(/"/g, """)
|
|
.replace(/</g, "<")
|
|
.replace(/>/g, ">");
|
|
}`);
|
|
}
|
|
|
|
const serverFunctionSource = serverFunctions ? `${serverFunctions}\n` : "";
|
|
|
|
out.push(
|
|
`export function render(props: ${effectiveProps.length > 0 ? `${ast.name}Props` : "Record<string, any>"} = {} as ${effectiveProps.length > 0 ? `${ast.name}Props` : "Record<string, any>"}): 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 __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(safeAttributeValue(attr.name, attr.value))}"`;
|
|
}
|
|
|
|
dynamicExpressions.push(`\${__wrnexusPropAttr(${expression})}`);
|
|
|
|
const marker = `\x00WRNEACH${dynamicExpressions.length - 1}\x00`;
|
|
|
|
return ` ${attr.name}="${marker}"`;
|
|
}
|