feat(compiler): support the three api render-binding forms

- Parse api="name", api="name()", and api="name({ ... })" render bindings
  for apis {} (mode "any") blocks, mirroring @click="fn()" syntax.
- Render-bind by calling Task 4's generated server `api` object directly
  (api.<name>(args)) rather than re-implementing the fetch/response
  transport, spliced into the SSR template via the existing loop/expression
  sentinel mechanism so the call runs inside the async render function with
  await support.
- A block that is both render-bound and called from code runs twice by
  design (no dedup); pinned with a test.
- Fix packages/syntax's attribute-value lexer (readQuoted) to honor
  backslash-escaped quotes, needed so an api="..." call expression can
  itself contain a quoted string/object literal.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-20 07:38:32 +05:30
co-authored by Claude Opus 5
parent a08dfa5322
commit 442c058d0c
6 changed files with 189 additions and 42 deletions
+60 -18
View File
@@ -1,6 +1,6 @@
"use strict";
// Generated by scripts/build-editor-compiler.mjs. Do not edit directly.
// WRN editor compiler source hash: 3b1bf12fc638343d15eb8502a2b10df4ad909529de8c94b612cb567959324f5d
// WRN editor compiler source hash: 9203410b358f7f7f824f49e1e793d2281403faab7097d2664761d5dbe3b8e957
// WRN editor compiler generator hash: a54ca847c758bc98d8e353ad6d70088df31de1820f6cf9d1c3462505f563e6b8
// Generated with TypeScript: 6.0.3
const __nodeRequire = require;
@@ -1505,10 +1505,14 @@ function renderNode(node, ssrBindings, csrBindings, apiBindings, loops, reactive
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 apiAttr = attrValue(node.attrs, "api");
const parsedApi = apiAttr ? parseApiBinding(apiAttr) : null;
if (apiAttr && !parsedApi) {
throw new Error(`Invalid .wrn api binding "${apiAttr}"`);
}
const apiBinding = parsedApi ? apiBindings.get(parsedApi.name) : undefined;
if (parsedApi && !apiBinding) {
throw new Error(`Unknown .wrn api binding "${parsedApi.name}"`);
}
const ssrGet = attrValue(node.attrs, "ssrGet");
const ssrText = attrValue(node.attrs, "ssrText");
@@ -1530,16 +1534,18 @@ function renderNode(node, ssrBindings, csrBindings, apiBindings, loops, reactive
}
const inner = apiBinding?.mode === "ssr"
? ssrMarker(ssrBindings, renderBinding(apiBinding))
: ssrGet && ssrText
? ssrMarker(ssrBindings, {
method: "GET",
path: apiRoutePath(ssrGet),
body: expressionBody(ssrText),
helpers: "",
})
: node.children
.map((child) => renderNode(child, ssrBindings, csrBindings, apiBindings, loops, reactive))
.join("");
: apiBinding?.mode === "any"
? apiCallMarker(loops, parsedApi.name, parsedApi.args)
: 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, ssrBindings, csrBindings, apiBindings, loops, reactive) {
@@ -1602,6 +1608,33 @@ function renderNestedComponentInvocation(node, ctx) {
`${ctx.forwardRestAttrs ? "${__wrnSpreadAttrs(__attrs)}" : ""}` +
`${attrs}>${inner}</div>`);
}
/**
* Parses the three `api="…"` render-binding forms: a bare name, an empty call,
* or a call carrying an argument expression. Mirrors the shape of `@click="fn()"`,
* so no new escaping or attribute-naming rules are introduced. The argument
* capture is greedy up to the outer parens, so nested braces/parens/quotes in
* the argument expression (object literals, arrays, strings) are carried
* through untouched rather than truncated at the first `)`.
*/
function parseApiBinding(value) {
const match = /^\s*([A-Za-z_$][A-Za-z0-9_$]*)\s*(?:\(([\s\S]*)\))?\s*$/.exec(value);
if (!match)
return null;
return { name: match[1], args: (match[2] ?? "").trim() };
}
/**
* Emit a render-time call into the server `api` object (Task 4's generated
* transport) for an `apis {}` (mode "any") binding, and return the sentinel
* that the loop/expression-splicing mechanism swaps for the real `${}` code.
* This calls the same server `api.<name>()` member a `load`/action block would
* call -- it does not reimplement fetch/response handling -- so a block that is
* both render-bound and called from code runs its own call each time (no
* dedup is attempted; see apis-render-binding.test.ts).
*/
function apiCallMarker(loops, name, args) {
loops.push(`\${__wrnexusEscapeHtml(await api.${name}(${args}))}`);
return `\x00WRNEACH${loops.length - 1}\x00`;
}
function ssrMarker(bindings, binding) {
const marker = `<!--wrnexus-ssr:${bindings.length}-->`;
bindings.push({ marker, ...binding });
@@ -7141,12 +7174,21 @@ function parseHtmlView(src, pos) {
if (quote !== '"' && quote !== "'")
return fail("Expected a quoted attribute value");
i++;
const start = i;
while (i < src.length && src[i] !== quote)
// Backslash-escapes the delimiter (and anything else) so an attribute
// value -- e.g. an `api="fn({ a: \"b\" })"` call expression -- can carry
// the same quote character it's wrapped in.
let value = "";
while (i < src.length && src[i] !== quote) {
if (src[i] === "\\" && i + 1 < src.length) {
value += src[i + 1];
i += 2;
continue;
}
value += src[i];
i++;
}
if (i >= src.length)
return fail("Unterminated attribute value");
const value = src.slice(start, i);
i++; // closing quote
return value;
};
+1 -1
View File
@@ -1,4 +1,4 @@
// WRN editor extension source hash: 42ceb9645e98cbc79abd3104e8426da535affc52327fb9e42ce658888d1b9941
// WRN editor extension source hash: 0da6296bd5dd1b84aece4ad5aed742c81deefefc4189ead29706be24c22d1cf9
// WRN editor extension generator hash: 456d1d614e44e5fb1f19b784176c09cf2ade9b64ef73a17934c2698150b62728
"use strict";
var __commonJS = (cb, mod) => () => (mod || cb((mod = { exports: {} }).exports, mod), mod.exports);
+10 -4
View File
@@ -1,5 +1,5 @@
#!/usr/bin/env node
// WRN editor language server source hash: d42dc4f557c0cb990f1d164708ca5d9ed8ddeabca3270f1b99f175b1f342572e
// WRN editor language server source hash: 9614834cb7909e77f707aa31a8cc87ec6193ad642a81ee52870fd6872785fa4e
// WRN editor language server generator hash: f593a44aaf05495b789ce7a3086bee1eebb951b884d41c0e017bbcfe5f547e72
// @bun @bun-cjs
(function(exports, require, module, __filename, __dirname) {var __create = Object.create;
@@ -172082,12 +172082,18 @@ function parseHtmlView(src, pos) {
if (quote !== '"' && quote !== "'")
return fail("Expected a quoted attribute value");
i++;
const start = i;
while (i < src.length && src[i] !== quote)
let value = "";
while (i < src.length && src[i] !== quote) {
if (src[i] === "\\" && i + 1 < src.length) {
value += src[i + 1];
i += 2;
continue;
}
value += src[i];
i++;
}
if (i >= src.length)
return fail("Unterminated attribute value");
const value = src.slice(start, i);
i++;
return value;
};
+50 -16
View File
@@ -725,10 +725,14 @@ function renderNode(
);
}
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 apiAttr = attrValue(node.attrs, "api");
const parsedApi = apiAttr ? parseApiBinding(apiAttr) : null;
if (apiAttr && !parsedApi) {
throw new Error(`Invalid .wrn api binding "${apiAttr}"`);
}
const apiBinding = parsedApi ? apiBindings.get(parsedApi.name) : undefined;
if (parsedApi && !apiBinding) {
throw new Error(`Unknown .wrn api binding "${parsedApi.name}"`);
}
const ssrGet = attrValue(node.attrs, "ssrGet");
@@ -756,18 +760,20 @@ function renderNode(
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("");
: apiBinding?.mode === "any"
? apiCallMarker(loops, parsedApi!.name, parsedApi!.args)
: 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}>`;
}
@@ -866,6 +872,34 @@ function renderNestedComponentInvocation(
);
}
/**
* Parses the three `api="…"` render-binding forms: a bare name, an empty call,
* or a call carrying an argument expression. Mirrors the shape of `@click="fn()"`,
* so no new escaping or attribute-naming rules are introduced. The argument
* capture is greedy up to the outer parens, so nested braces/parens/quotes in
* the argument expression (object literals, arrays, strings) are carried
* through untouched rather than truncated at the first `)`.
*/
function parseApiBinding(value: string): { name: string; args: string } | null {
const match = /^\s*([A-Za-z_$][A-Za-z0-9_$]*)\s*(?:\(([\s\S]*)\))?\s*$/.exec(value);
if (!match) return null;
return { name: match[1]!, args: (match[2] ?? "").trim() };
}
/**
* Emit a render-time call into the server `api` object (Task 4's generated
* transport) for an `apis {}` (mode "any") binding, and return the sentinel
* that the loop/expression-splicing mechanism swaps for the real `${…}` code.
* This calls the same server `api.<name>()` member a `load`/action block would
* call -- it does not reimplement fetch/response handling -- so a block that is
* both render-bound and called from code runs its own call each time (no
* dedup is attempted; see apis-render-binding.test.ts).
*/
function apiCallMarker(loops: string[], name: string, args: string): string {
loops.push(`\${__wrnexusEscapeHtml(await api.${name}(${args}))}`);
return `\x00WRNEACH${loops.length - 1}\x00`;
}
function ssrMarker(bindings: SsrBinding[], binding: RenderBinding): string {
const marker = `<!--wrnexus-ssr:${bindings.length}-->`;
bindings.push({ marker, ...binding });
@@ -0,0 +1,55 @@
import { expect, test } from "bun:test";
import { parse } from "@wrnexus/syntax";
import { generate } from "../src/codegen.ts";
const page = (attr: string) => `page Probe {
apis {
listTeams GET /api/teams {
request { parameters { team?: string } }
response { return data.teams }
}
}
view { <main><p ${attr}>loading</p></main> }
}
`;
test("a bare name binds", () => {
expect(generate(parse(page('api="listTeams"')))).toContain('"/api/teams"');
});
test("an empty call binds identically to a bare name", () => {
const bare = generate(parse(page('api="listTeams"')));
const called = generate(parse(page('api="listTeams()"')));
expect(called).toContain('"/api/teams"');
expect(called.length).toBeGreaterThan(0);
expect(bare).toContain('"/api/teams"');
});
test("an argument expression is carried into the binding", () => {
const generated = generate(parse(page('api="listTeams({ team: \\"platform\\" })"')));
expect(generated).toContain("platform");
});
test("a block that is both bound and called is invoked twice", () => {
// Deliberate: a render-time fetch and a user-triggered fetch are usually
// meant to be different requests. Collapsing them silently would be worse
// than the duplication.
const generated = generate(
parse(`page P {
apis { listTeams GET /api/teams { response { return data.teams } } }
load server x { return await api.listTeams() }
view { <main><p api="listTeams">loading</p></main> }
}
`),
);
// Both call paths are emitted: the load block's call and the binding's.
// The route path itself is declared once, inside the shared server `api`
// object (Task 4) that both call sites invoke -- so the route string alone
// isn't a reliable proxy for "called twice". The call expression is.
const occurrences = generated.split("api.listTeams()").length - 1;
expect(occurrences).toBeGreaterThanOrEqual(2);
});
+13 -3
View File
@@ -1067,10 +1067,20 @@ export function parseHtmlView(src: string, pos: number): { nodes: ViewNode[]; en
const quote = src[i];
if (quote !== '"' && quote !== "'") return fail("Expected a quoted attribute value");
i++;
const start = i;
while (i < src.length && src[i] !== quote) i++;
// Backslash-escapes the delimiter (and anything else) so an attribute
// value -- e.g. an `api="fn({ a: \"b\" })"` call expression -- can carry
// the same quote character it's wrapped in.
let value = "";
while (i < src.length && src[i] !== quote) {
if (src[i] === "\\" && i + 1 < src.length) {
value += src[i + 1];
i += 2;
continue;
}
value += src[i];
i++;
}
if (i >= src.length) return fail("Unterminated attribute value");
const value = src.slice(start, i);
i++; // closing quote
return value;
};