first commit
This commit is contained in:
@@ -0,0 +1,862 @@
|
||||
/**
|
||||
* Code generation: lower a `.wrn` AST to TypeScript that targets the framework's
|
||||
* existing primitives.
|
||||
*
|
||||
* state -> a `data-scope` declaration consumed by the runtime
|
||||
* view -> an HTML string returned by a page component
|
||||
* @event="..." -> data-on-<event>="..."
|
||||
* "...{expr}..." -> text kept verbatim ({expr} is mustache for runtime)
|
||||
* api="<name>" -> SSR/client data binding declared in a mode block
|
||||
* ssrGet/ssrText -> legacy server-side API fetch + render
|
||||
* csrGet/csrText -> legacy browser-side API fetch + render
|
||||
* style -> an inline page stylesheet
|
||||
* functions -> server-only helpers for API/realtime code
|
||||
* api M /p {b} -> export const M = async (ctx) => { b }
|
||||
* realtime {..} -> export const websocket = { evt(ws, ...args) { b } }
|
||||
*/
|
||||
|
||||
import { VOID_ELEMENTS, type Attr, type DataMode, type PageAst, type ViewNode } from "./parser.ts";
|
||||
|
||||
interface RenderBinding {
|
||||
method: string;
|
||||
path: string;
|
||||
body: string;
|
||||
helpers: string;
|
||||
}
|
||||
|
||||
interface SsrBinding extends RenderBinding {
|
||||
marker: string;
|
||||
}
|
||||
|
||||
interface CsrBinding extends RenderBinding {
|
||||
id: string;
|
||||
}
|
||||
|
||||
interface NamedDataBinding extends RenderBinding {
|
||||
mode: DataMode;
|
||||
}
|
||||
|
||||
/** 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(attr.value)}"`;
|
||||
}
|
||||
}
|
||||
|
||||
function eventAttribute(name: string): string {
|
||||
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 renderAttrs(attrs: Attr[], csrId?: string): string {
|
||||
const rendered = attrs.map(renderAttr).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>;
|
||||
scope: Record<string, unknown>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Evaluate a page's `state` seed expressions at compile time to obtain the
|
||||
* initial SSR values used to bake `data-text` spans. Seeds may reference
|
||||
* earlier ones; anything that can't be evaluated becomes `undefined`.
|
||||
*/
|
||||
function evalStateSeeds(states: { name: string; expr: string }[]): Record<string, unknown> {
|
||||
const scope: Record<string, unknown> = {};
|
||||
for (const s of states) {
|
||||
try {
|
||||
scope[s.name] = new Function("with(this){return (" + s.expr + ");}").call(scope);
|
||||
} catch {
|
||||
scope[s.name] = undefined;
|
||||
}
|
||||
}
|
||||
return scope;
|
||||
}
|
||||
|
||||
/**
|
||||
* Page text compilation: resolve `{t:key}` i18n markers, then bake state
|
||||
* interpolations (`{count}`, `{count * 2}`) into `data-text` spans carrying the
|
||||
* evaluated initial value — so no-JS clients see real content and the reactive
|
||||
* runtime keeps it live. Non-state `{expr}` and un-evaluable expressions are
|
||||
* left as literal client mustaches.
|
||||
*/
|
||||
function substituteReactiveText(raw: string, reactive: PageReactive | null): string {
|
||||
const text = substituteTMarkers(raw);
|
||||
if (!reactive || reactive.stateNames.size === 0) return text;
|
||||
return text.replace(/\{([^{}]+)\}/g, (whole, inner: string) => {
|
||||
const expr = inner.trim();
|
||||
if (expr.startsWith("t:") || !exprRefsState(expr, reactive.stateNames)) return whole;
|
||||
let value: unknown;
|
||||
try {
|
||||
value = new Function("with(this){return (" + expr + ");}").call(reactive.scope);
|
||||
} catch {
|
||||
return whole; // can't evaluate → keep as a client-only mustache
|
||||
}
|
||||
const baked = htmlTextEscape(value == null ? "" : String(value));
|
||||
return `<span data-text="${attrEscape(expr)}">${baked}</span>`;
|
||||
});
|
||||
}
|
||||
|
||||
type EachNode = Extract<ViewNode, { type: "each" }>;
|
||||
type IfNode = Extract<ViewNode, { type: "if" }>;
|
||||
|
||||
/**
|
||||
* Bake a loop-body text run into template-literal source: static text is escaped
|
||||
* for the literal, `{expr}` becomes `${__wrnexusEscapeHtml(expr)}` (server-rendered,
|
||||
* escaped), and `{t:key}` becomes a `data-t` marker resolved later by translateHtml.
|
||||
*/
|
||||
function bakeLoopText(raw: string): string {
|
||||
let out = "";
|
||||
let last = 0;
|
||||
let m: RegExpExecArray | null;
|
||||
const re = /\{([^{}]+)\}/g;
|
||||
while ((m = re.exec(raw))) {
|
||||
out += escLit(raw.slice(last, m.index));
|
||||
const expr = m[1]!.trim();
|
||||
if (expr.startsWith("t:")) {
|
||||
out += escLit(`<span data-t="${attrEscape(expr.slice(2).trim())}"></span>`);
|
||||
} else {
|
||||
out += "${__wrnexusEscapeHtml(" + expr + ")}";
|
||||
}
|
||||
last = m.index + m[0].length;
|
||||
}
|
||||
return out + escLit(raw.slice(last));
|
||||
}
|
||||
|
||||
/** Bake a loop-body attribute value (same rules as text; escapeHtml is attribute-safe). */
|
||||
function bakeLoopAttr(raw: string): string {
|
||||
if (!raw.includes("{")) return escLit(attrEscape(raw));
|
||||
let out = "";
|
||||
let last = 0;
|
||||
let m: RegExpExecArray | null;
|
||||
const re = /\{([^{}]+)\}/g;
|
||||
while ((m = re.exec(raw))) {
|
||||
out += escLit(attrEscape(raw.slice(last, m.index)));
|
||||
out += "${__wrnexusEscapeHtml(" + m[1]!.trim() + ")}";
|
||||
last = m.index + m[0].length;
|
||||
}
|
||||
return out + escLit(attrEscape(raw.slice(last)));
|
||||
}
|
||||
|
||||
/** Render one loop-body node to template-literal source (nested loops inline). */
|
||||
function renderLoopBody(node: ViewNode): string {
|
||||
if (node.type === "text") return bakeLoopText(node.value);
|
||||
if (node.type === "each") return compileEachExpr(node);
|
||||
if (node.type === "if") return compileIfExpr(node);
|
||||
const attrs = node.attrs
|
||||
.map((a) => {
|
||||
const name = a.event ? eventAttribute(a.name) : a.name;
|
||||
if (a.boolean) return escLit(` ${name}`);
|
||||
return escLit(` ${name}="`) + bakeLoopAttr(a.value) + escLit(`"`);
|
||||
})
|
||||
.join("");
|
||||
if (VOID_ELEMENTS.has(node.tag.toLowerCase()))
|
||||
return escLit(`<${node.tag}`) + attrs + escLit(">");
|
||||
const inner = node.children.map(renderLoopBody).join("");
|
||||
return escLit(`<${node.tag}`) + attrs + escLit(">") + inner + escLit(`</${node.tag}>`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Compile a `{#each list as item}` block to a `${…}` template-literal interpolation
|
||||
* that iterates the (server-evaluated) list and joins the per-item body. `list` is a
|
||||
* JS expression evaluated where `ssr` data bindings are in scope as raw named values.
|
||||
*/
|
||||
function compileEachExpr(node: EachNode): string {
|
||||
const item = node.item;
|
||||
const index = node.index ?? "__wi";
|
||||
const body = node.body.map(renderLoopBody).join("");
|
||||
const empty = node.empty.map(renderLoopBody).join("");
|
||||
return (
|
||||
"${(() => { const __wl = Array.isArray(" +
|
||||
node.list +
|
||||
") ? (" +
|
||||
node.list +
|
||||
") : []; return __wl.length ? __wl.map((" +
|
||||
item +
|
||||
", " +
|
||||
index +
|
||||
") => `" +
|
||||
body +
|
||||
'`).join("") : `' +
|
||||
empty +
|
||||
"`; })()}"
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Compile a `{#if}` block to a `${…}` template-literal interpolation: a nested ternary
|
||||
* that renders the first truthy branch's body (or the `{:else}` body, or "" when neither).
|
||||
* Conditions are JS expressions evaluated in the surrounding server scope.
|
||||
*/
|
||||
function compileIfExpr(node: IfNode): string {
|
||||
let expr = "``"; // no matching branch → empty string
|
||||
for (let k = node.branches.length - 1; k >= 0; k--) {
|
||||
const b = node.branches[k]!;
|
||||
const bodySrc = "`" + b.body.map(renderLoopBody).join("") + "`";
|
||||
expr = b.cond === null ? bodySrc : "(" + b.cond + ") ? " + bodySrc + " : " + expr;
|
||||
}
|
||||
return "${" + expr + "}";
|
||||
}
|
||||
|
||||
/**
|
||||
* Collect every server-control expression in a view (recursively): `{#each}` list
|
||||
* expressions and `{#if}` conditions. Used to wire up raw SSR data consts.
|
||||
*/
|
||||
function collectControlExprs(nodes: ViewNode[], out: string[] = []): string[] {
|
||||
for (const node of nodes) {
|
||||
if (node.type === "each") {
|
||||
out.push(node.list);
|
||||
collectControlExprs(node.body, out);
|
||||
collectControlExprs(node.empty, out);
|
||||
} else if (node.type === "if") {
|
||||
for (const b of node.branches) {
|
||||
if (b.cond) out.push(b.cond);
|
||||
collectControlExprs(b.body, out);
|
||||
}
|
||||
} else if (node.type === "element") {
|
||||
collectControlExprs(node.children, out);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function renderNode(
|
||||
node: ViewNode,
|
||||
ssrBindings: SsrBinding[],
|
||||
csrBindings: CsrBinding[],
|
||||
apiBindings: Map<string, NamedDataBinding>,
|
||||
loops: string[],
|
||||
reactive: PageReactive | null = null,
|
||||
): string {
|
||||
if (node.type === "text") return substituteReactiveText(node.value, reactive); // {t:key} + state baking
|
||||
|
||||
// Server control block (loop / conditional) → a sentinel that survives
|
||||
// templateEscape, swapped for its real `${…}` code after escaping.
|
||||
if (node.type === "each" || node.type === "if") {
|
||||
loops.push(node.type === "each" ? compileEachExpr(node) : compileIfExpr(node));
|
||||
return `\x00WRNEACH${loops.length - 1}\x00`;
|
||||
}
|
||||
|
||||
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)}>`;
|
||||
}
|
||||
|
||||
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)}>${inner}</${node.tag}>`;
|
||||
}
|
||||
|
||||
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" || node.type === "if") return false;
|
||||
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);
|
||||
}
|
||||
|
||||
async function __wrnexusCallApi(path: string, method: string, ctx: any): Promise<unknown> {
|
||||
if (typeof ctx.__wrnexusCallApi === "function") {
|
||||
return await ctx.__wrnexusCallApi(path, method);
|
||||
}
|
||||
|
||||
const url = new URL(path, ctx.req.url);
|
||||
const res = await fetch(new Request(url, { method, headers: ctx.req.headers }));
|
||||
if (!res.ok) {
|
||||
throw new Error(".wrn data API request failed with status " + res.status);
|
||||
}
|
||||
|
||||
const type = res.headers.get("content-type") || "";
|
||||
return type.includes("application/json") ? await res.json() : await res.text();
|
||||
}
|
||||
|
||||
async function __wrnexusRenderSsrBindings(html: string, ctx: any): Promise<string> {
|
||||
for (const binding of __wrnexusSsrBindings) {
|
||||
const data = await __wrnexusCallApi(binding.path, binding.method, ctx);
|
||||
const value = __wrnexusEvalData(data, binding.body, binding.helpers, ctx);
|
||||
html = html.replace(binding.marker, __wrnexusEscapeHtml(value));
|
||||
}
|
||||
return html;
|
||||
}`;
|
||||
}
|
||||
|
||||
export function generate(ast: PageAst): string {
|
||||
if (ast.kind === "component") return generateComponent(ast);
|
||||
|
||||
const out: string[] = [];
|
||||
const ssrBindings: SsrBinding[] = [];
|
||||
const csrBindings: CsrBinding[] = [];
|
||||
const helpers = ast.functions
|
||||
.map((body) => body.trim())
|
||||
.filter(Boolean)
|
||||
.join("\n\n");
|
||||
const apiBindings = apiBindingMap(ast, helpers);
|
||||
|
||||
if (helpers) {
|
||||
out.push(`// --- .wrn functions ---\n${helpers}`);
|
||||
}
|
||||
|
||||
// --- Page metadata / SEO ---
|
||||
out.push(`export const meta = ${JSON.stringify({ title: ast.name, ...ast.seo }, null, 2)};`);
|
||||
if (ast.layout) out.push(`export const layout = ${JSON.stringify(ast.layout)};`);
|
||||
|
||||
// --- View -> default page component ---
|
||||
const reactive: PageReactive | null =
|
||||
ast.states.length > 0
|
||||
? { stateNames: new Set(ast.states.map((s) => s.name)), scope: evalStateSeeds(ast.states) }
|
||||
: null;
|
||||
const loops: string[] = [];
|
||||
let html = ast.view
|
||||
.map((node) => renderNode(node, ssrBindings, csrBindings, apiBindings, loops, reactive))
|
||||
.join("");
|
||||
const styles = ast.styles.map((body) => body.trim()).filter(Boolean);
|
||||
const needsClientRuntime = ast.states.length > 0 || hasClientBehavior(ast.view);
|
||||
|
||||
if (needsClientRuntime) {
|
||||
const scope = ast.states.map((s) => `${s.name}: ${s.expr}`).join(", ");
|
||||
html = `<div data-scope="${attrEscape(scope)}">${html}</div>`;
|
||||
}
|
||||
|
||||
if (styles.length > 0) {
|
||||
const css = styles.map(styleEscape).join("\n");
|
||||
html = `<style data-wrnexus-style="${attrEscape(ast.name)}">\n${css}\n</style>${html}`;
|
||||
}
|
||||
if (csrBindings.length > 0) {
|
||||
out.push(`export const __wrnexusCsr = ${JSON.stringify(csrBindings, null, 2)};`);
|
||||
}
|
||||
|
||||
// Escape the static HTML for the template literal, then swap loop sentinels for
|
||||
// their real `${…}` code (which must NOT be escaped).
|
||||
let body = templateEscape(html);
|
||||
loops.forEach((code, idx) => {
|
||||
body = body.replace(`\x00WRNEACH${idx}\x00`, () => code);
|
||||
});
|
||||
|
||||
// Server loops iterate raw SSR data. Declare a named const for every `ssr` data
|
||||
// binding a loop references, so `{#each <name> as …}` can iterate the real value.
|
||||
const loopConsts: string[] = [];
|
||||
if (loops.length > 0) {
|
||||
const lists = collectControlExprs(ast.view);
|
||||
for (const [name, binding] of apiBindings) {
|
||||
if (binding.mode !== "ssr") continue;
|
||||
if (!lists.some((expr) => new RegExp(`\\b${name}\\b`).test(expr))) continue;
|
||||
loopConsts.push(
|
||||
` const ${name} = __wrnexusEvalData(await __wrnexusCallApi(${JSON.stringify(binding.path)}, ${JSON.stringify(binding.method)}, ctx), ${JSON.stringify(binding.body)}, ${JSON.stringify(binding.helpers)}, ctx);`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const needsSsrRuntime = ssrBindings.length > 0 || loops.length > 0;
|
||||
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) {\n${decls} const html = \`${body}\`;\n return await __wrnexusRenderSsrBindings(html, ctx);\n}`,
|
||||
);
|
||||
} else {
|
||||
out.push(`export default function ${ast.name}() {\n return \`${body}\`;\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>;
|
||||
/** 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>;
|
||||
}
|
||||
|
||||
/** Parse a `data-for="item in list"` / `"item, i in list"` directive value. */
|
||||
export function parseForExpr(value: string): { item: string; index?: string; list: string } | null {
|
||||
const m = /^\s*([A-Za-z_$][\w$]*)\s*(?:,\s*([A-Za-z_$][\w$]*)\s*)?\s+in\s+([\s\S]+?)\s*$/.exec(
|
||||
value,
|
||||
);
|
||||
if (!m) return null;
|
||||
return { item: m[1]!, index: m[2], list: m[3]! };
|
||||
}
|
||||
|
||||
/** The loop variables a node introduces via `data-for`, if any. */
|
||||
function loopVarsOf(node: ViewNode): string[] {
|
||||
if (node.type !== "element") return [];
|
||||
const attr = node.attrs.find((a) => !a.event && a.name === "data-for");
|
||||
if (!attr) return [];
|
||||
const parsed = parseForExpr(attr.value);
|
||||
return parsed ? [parsed.item, ...(parsed.index ? [parsed.index] : [])] : [];
|
||||
}
|
||||
|
||||
/** JS reserved words that cannot be used as a plain `const` name. */
|
||||
const JS_RESERVED = new Set([
|
||||
"class",
|
||||
"for",
|
||||
"default",
|
||||
"function",
|
||||
"return",
|
||||
"if",
|
||||
"else",
|
||||
"new",
|
||||
"delete",
|
||||
"typeof",
|
||||
"in",
|
||||
"instanceof",
|
||||
"void",
|
||||
"do",
|
||||
"while",
|
||||
"switch",
|
||||
"case",
|
||||
"break",
|
||||
"continue",
|
||||
"this",
|
||||
"super",
|
||||
"import",
|
||||
"export",
|
||||
"extends",
|
||||
"var",
|
||||
"let",
|
||||
"const",
|
||||
"null",
|
||||
"true",
|
||||
"false",
|
||||
"try",
|
||||
"catch",
|
||||
"finally",
|
||||
"throw",
|
||||
"yield",
|
||||
"await",
|
||||
"enum",
|
||||
"with",
|
||||
"debugger",
|
||||
]);
|
||||
|
||||
/** A JS reference for a prop/state name (reserved words get a `__p_` prefix). */
|
||||
function safeRef(name: string): string {
|
||||
return JS_RESERVED.has(name) ? `__p_${name}` : name;
|
||||
}
|
||||
|
||||
/** Escape a literal segment so it is safe inside a JS template literal. */
|
||||
function escLit(s: string): string {
|
||||
return s.replace(/\\/g, "\\\\").replace(/`/g, "\\`").replace(/\$\{/g, "\\${");
|
||||
}
|
||||
|
||||
const INTERP_RE = /\{([^{}]+)\}/g;
|
||||
|
||||
function exprRefsState(expr: string, stateNames: Set<string>): boolean {
|
||||
for (const name of stateNames) {
|
||||
if (new RegExp(`\\b${name}\\b`).test(expr)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function viewHasEvents(nodes: ViewNode[]): boolean {
|
||||
return nodes.some(
|
||||
(n) => n.type === "element" && (n.attrs.some((a) => a.event) || viewHasEvents(n.children)),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Compile a text node. Interpolations that reference state stay as client
|
||||
* mustaches (`{expr}`, hydrated by the reactive runtime); interpolations of
|
||||
* props/constants are baked server-side (`${__wireHtml(expr)}`), so static
|
||||
* components render correct HTML with zero JavaScript.
|
||||
*/
|
||||
function compileText(raw: string, ctx: CompCtx): string {
|
||||
let out = "";
|
||||
let last = 0;
|
||||
let m: RegExpExecArray | null;
|
||||
INTERP_RE.lastIndex = 0;
|
||||
while ((m = INTERP_RE.exec(raw))) {
|
||||
out += escLit(raw.slice(last, m.index));
|
||||
const expr = m[1]!.trim();
|
||||
if (expr.startsWith("t:")) {
|
||||
// i18n sugar: {t:key} → a marker resolved server-side by translateHtml.
|
||||
out += escLit(`<span data-t="${attrEscape(expr.slice(2).trim())}"></span>`);
|
||||
} else if (ctx.loopVars && exprRefsState(expr, ctx.loopVars)) {
|
||||
// Loop variable (from data-for): leave a literal client mustache — the
|
||||
// list renderer fills it per item; it has no server-side value.
|
||||
out += escLit(`{${expr}}`);
|
||||
} else if (exprRefsState(expr, ctx.stateNames)) {
|
||||
// State interpolation: bake the initial value AND keep it reactive via a
|
||||
// data-text span, so no-JS clients see the real value and hydration
|
||||
// updates it in place. `count` → `<span data-text="count">0</span>`.
|
||||
out +=
|
||||
escLit(`<span data-text="${attrEscape(expr)}">`) +
|
||||
`\${__wireHtml(${ctx.resolveExpr(expr)})}` +
|
||||
escLit(`</span>`);
|
||||
} else {
|
||||
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)));
|
||||
}
|
||||
|
||||
/** 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" || node.type === "if") {
|
||||
throw new Error(
|
||||
"Server `{#each}` / `{#if}` blocks are supported in pages, not components. Move them into a page (or use data-for / data-show on the client).",
|
||||
);
|
||||
}
|
||||
|
||||
const attrs = node.attrs
|
||||
.map((a) =>
|
||||
a.event
|
||||
? ` ${eventAttribute(a.name)}="${compileAttrValue(a.value, ctx)}"`
|
||||
: a.boolean
|
||||
? ` ${a.name}`
|
||||
: ` ${a.name}="${compileAttrValue(a.value, ctx)}"`,
|
||||
)
|
||||
.join("");
|
||||
|
||||
// A `data-for` element introduces loop variables for its subtree.
|
||||
const loops = loopVarsOf(node);
|
||||
const childCtx =
|
||||
loops.length > 0 ? { ...ctx, loopVars: new Set([...(ctx.loopVars ?? []), ...loops]) } : ctx;
|
||||
|
||||
if (VOID_ELEMENTS.has(node.tag.toLowerCase())) return `<${node.tag}${attrs}>`;
|
||||
const inner = node.children.map((c) => renderComponentNode(c, childCtx)).join("");
|
||||
return `<${node.tag}${attrs}>${inner}</${node.tag}>`;
|
||||
}
|
||||
|
||||
function generateComponent(ast: PageAst): string {
|
||||
const out: string[] = [];
|
||||
|
||||
const stateNames = new Set(ast.states.map((s) => s.name));
|
||||
const nameRefs = new Map<string, string>();
|
||||
for (const p of ast.props) nameRefs.set(p.name, safeRef(p.name));
|
||||
for (const s of ast.states) nameRefs.set(s.name, safeRef(s.name));
|
||||
const resolveExpr = (expr: string): string => {
|
||||
let result = expr;
|
||||
for (const [name, ref] of nameRefs) {
|
||||
if (name !== ref) result = result.replace(new RegExp(`\\b${name}\\b`, "g"), ref);
|
||||
}
|
||||
return result;
|
||||
};
|
||||
const ctx: CompCtx = { stateNames, resolveExpr };
|
||||
|
||||
const viewCode = ast.view.map((node) => renderComponentNode(node, ctx)).join("");
|
||||
const styles = ast.styles.map((body) => body.trim()).filter(Boolean);
|
||||
const styleTag =
|
||||
styles.length > 0
|
||||
? escLit(
|
||||
`<style data-wrnexus-style="${attrEscape(ast.name)}">\n${styles.map(styleEscape).join("\n")}\n</style>`,
|
||||
)
|
||||
: "";
|
||||
|
||||
// A component needs a reactive scope only when it has state or event handlers.
|
||||
// Prop-driven text/attributes are baked server-side, so static components ship
|
||||
// no JavaScript at all.
|
||||
const needsScope = ast.states.length > 0 || viewHasEvents(ast.view);
|
||||
const scopeKeys = [...ast.props.map((p) => p.name), ...ast.states.map((s) => s.name)];
|
||||
|
||||
const decls: string[] = [];
|
||||
for (const prop of ast.props) {
|
||||
decls.push(
|
||||
` const ${nameRefs.get(prop.name)} = __coerce(__p[${JSON.stringify(prop.name)}], (${resolveExpr(prop.default)}));`,
|
||||
);
|
||||
}
|
||||
for (const state of ast.states) {
|
||||
decls.push(` const ${nameRefs.get(state.name)} = (${resolveExpr(state.expr)});`);
|
||||
}
|
||||
|
||||
const returnExpr = needsScope
|
||||
? "`" + styleTag + '<div data-scope="${__scope}">' + viewCode + "</div>`"
|
||||
: "`" + styleTag + viewCode + "`";
|
||||
|
||||
const scopeLine =
|
||||
needsScope && scopeKeys.length > 0
|
||||
? ` const __scope = __wrnexusScopeDecl({ ${scopeKeys.map((k) => `${JSON.stringify(k)}: ${nameRefs.get(k)}`).join(", ")} });\n`
|
||||
: needsScope
|
||||
? ` const __scope = "";\n`
|
||||
: "";
|
||||
|
||||
out.push(`export const __wrnexusComponent = ${JSON.stringify(ast.name)};`);
|
||||
out.push(`function __coerce(v: any, def: any): any {
|
||||
if (v === undefined || v === null) return def;
|
||||
if (typeof def === "number") return Number(v);
|
||||
if (typeof def === "boolean") return v === true || v === "" || v === "true";
|
||||
return String(v);
|
||||
}
|
||||
function __wireHtml(v: any): string {
|
||||
return String(v == null ? "" : v).replace(/[&<>]/g, (c) => (c === "&" ? "&" : c === "<" ? "<" : ">"));
|
||||
}
|
||||
function __wireAttr(v: any): string {
|
||||
return String(v == null ? "" : v).replace(/[&<>"]/g, (c) =>
|
||||
c === "&" ? "&" : c === "<" ? "<" : c === ">" ? ">" : """,
|
||||
);
|
||||
}`);
|
||||
|
||||
if (needsScope) {
|
||||
out.push(`function __wrnexusScopeDecl(obj: Record<string, any>): string {
|
||||
const lit = (v: any) =>
|
||||
typeof v === "number" || typeof v === "boolean"
|
||||
? String(v)
|
||||
: "'" + String(v).replace(/\\\\/g, "\\\\\\\\").replace(/'/g, "\\\\'").replace(/\\n/g, "\\\\n") + "'";
|
||||
return Object.keys(obj)
|
||||
.map((k) => k + ": " + lit(obj[k]))
|
||||
.join(", ")
|
||||
.replace(/&/g, "&")
|
||||
.replace(/"/g, """)
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">");
|
||||
}`);
|
||||
}
|
||||
|
||||
out.push(
|
||||
`export function render(props: Record<string, any> = {}): string {\n` +
|
||||
` const __p = props || {};\n` +
|
||||
(decls.length > 0 ? decls.join("\n") + "\n" : "") +
|
||||
scopeLine +
|
||||
` return ${returnExpr};\n` +
|
||||
`}`,
|
||||
);
|
||||
|
||||
return out.join("\n\n") + "\n";
|
||||
}
|
||||
Reference in New Issue
Block a user