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:
@@ -1,6 +1,6 @@
|
|||||||
"use strict";
|
"use strict";
|
||||||
// Generated by scripts/build-editor-compiler.mjs. Do not edit directly.
|
// 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
|
// WRN editor compiler generator hash: a54ca847c758bc98d8e353ad6d70088df31de1820f6cf9d1c3462505f563e6b8
|
||||||
// Generated with TypeScript: 6.0.3
|
// Generated with TypeScript: 6.0.3
|
||||||
const __nodeRequire = require;
|
const __nodeRequire = require;
|
||||||
@@ -1505,10 +1505,14 @@ function renderNode(node, ssrBindings, csrBindings, apiBindings, loops, reactive
|
|||||||
if (isComponentTag(node.tag)) {
|
if (isComponentTag(node.tag)) {
|
||||||
return renderPageComponentInvocation(node, ssrBindings, csrBindings, apiBindings, loops, reactive);
|
return renderPageComponentInvocation(node, ssrBindings, csrBindings, apiBindings, loops, reactive);
|
||||||
}
|
}
|
||||||
const apiName = attrValue(node.attrs, "api");
|
const apiAttr = attrValue(node.attrs, "api");
|
||||||
const apiBinding = apiName ? apiBindings.get(apiName) : undefined;
|
const parsedApi = apiAttr ? parseApiBinding(apiAttr) : null;
|
||||||
if (apiName && !apiBinding) {
|
if (apiAttr && !parsedApi) {
|
||||||
throw new Error(`Unknown .wrn api binding "${apiName}"`);
|
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 ssrGet = attrValue(node.attrs, "ssrGet");
|
||||||
const ssrText = attrValue(node.attrs, "ssrText");
|
const ssrText = attrValue(node.attrs, "ssrText");
|
||||||
@@ -1530,6 +1534,8 @@ function renderNode(node, ssrBindings, csrBindings, apiBindings, loops, reactive
|
|||||||
}
|
}
|
||||||
const inner = apiBinding?.mode === "ssr"
|
const inner = apiBinding?.mode === "ssr"
|
||||||
? ssrMarker(ssrBindings, renderBinding(apiBinding))
|
? ssrMarker(ssrBindings, renderBinding(apiBinding))
|
||||||
|
: apiBinding?.mode === "any"
|
||||||
|
? apiCallMarker(loops, parsedApi.name, parsedApi.args)
|
||||||
: ssrGet && ssrText
|
: ssrGet && ssrText
|
||||||
? ssrMarker(ssrBindings, {
|
? ssrMarker(ssrBindings, {
|
||||||
method: "GET",
|
method: "GET",
|
||||||
@@ -1602,6 +1608,33 @@ function renderNestedComponentInvocation(node, ctx) {
|
|||||||
`${ctx.forwardRestAttrs ? "${__wrnSpreadAttrs(__attrs)}" : ""}` +
|
`${ctx.forwardRestAttrs ? "${__wrnSpreadAttrs(__attrs)}" : ""}` +
|
||||||
`${attrs}>${inner}</div>`);
|
`${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) {
|
function ssrMarker(bindings, binding) {
|
||||||
const marker = `<!--wrnexus-ssr:${bindings.length}-->`;
|
const marker = `<!--wrnexus-ssr:${bindings.length}-->`;
|
||||||
bindings.push({ marker, ...binding });
|
bindings.push({ marker, ...binding });
|
||||||
@@ -7141,12 +7174,21 @@ function parseHtmlView(src, pos) {
|
|||||||
if (quote !== '"' && quote !== "'")
|
if (quote !== '"' && quote !== "'")
|
||||||
return fail("Expected a quoted attribute value");
|
return fail("Expected a quoted attribute value");
|
||||||
i++;
|
i++;
|
||||||
const start = i;
|
// Backslash-escapes the delimiter (and anything else) so an attribute
|
||||||
while (i < src.length && src[i] !== quote)
|
// 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++;
|
i++;
|
||||||
|
}
|
||||||
if (i >= src.length)
|
if (i >= src.length)
|
||||||
return fail("Unterminated attribute value");
|
return fail("Unterminated attribute value");
|
||||||
const value = src.slice(start, i);
|
|
||||||
i++; // closing quote
|
i++; // closing quote
|
||||||
return value;
|
return value;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
// WRN editor extension source hash: 42ceb9645e98cbc79abd3104e8426da535affc52327fb9e42ce658888d1b9941
|
// WRN editor extension source hash: 0da6296bd5dd1b84aece4ad5aed742c81deefefc4189ead29706be24c22d1cf9
|
||||||
// WRN editor extension generator hash: 456d1d614e44e5fb1f19b784176c09cf2ade9b64ef73a17934c2698150b62728
|
// WRN editor extension generator hash: 456d1d614e44e5fb1f19b784176c09cf2ade9b64ef73a17934c2698150b62728
|
||||||
"use strict";
|
"use strict";
|
||||||
var __commonJS = (cb, mod) => () => (mod || cb((mod = { exports: {} }).exports, mod), mod.exports);
|
var __commonJS = (cb, mod) => () => (mod || cb((mod = { exports: {} }).exports, mod), mod.exports);
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
#!/usr/bin/env node
|
#!/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
|
// WRN editor language server generator hash: f593a44aaf05495b789ce7a3086bee1eebb951b884d41c0e017bbcfe5f547e72
|
||||||
// @bun @bun-cjs
|
// @bun @bun-cjs
|
||||||
(function(exports, require, module, __filename, __dirname) {var __create = Object.create;
|
(function(exports, require, module, __filename, __dirname) {var __create = Object.create;
|
||||||
@@ -172082,12 +172082,18 @@ function parseHtmlView(src, pos) {
|
|||||||
if (quote !== '"' && quote !== "'")
|
if (quote !== '"' && quote !== "'")
|
||||||
return fail("Expected a quoted attribute value");
|
return fail("Expected a quoted attribute value");
|
||||||
i++;
|
i++;
|
||||||
const start = i;
|
let value = "";
|
||||||
while (i < src.length && src[i] !== quote)
|
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++;
|
i++;
|
||||||
|
}
|
||||||
if (i >= src.length)
|
if (i >= src.length)
|
||||||
return fail("Unterminated attribute value");
|
return fail("Unterminated attribute value");
|
||||||
const value = src.slice(start, i);
|
|
||||||
i++;
|
i++;
|
||||||
return value;
|
return value;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -725,10 +725,14 @@ function renderNode(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const apiName = attrValue(node.attrs, "api");
|
const apiAttr = attrValue(node.attrs, "api");
|
||||||
const apiBinding = apiName ? apiBindings.get(apiName) : undefined;
|
const parsedApi = apiAttr ? parseApiBinding(apiAttr) : null;
|
||||||
if (apiName && !apiBinding) {
|
if (apiAttr && !parsedApi) {
|
||||||
throw new Error(`Unknown .wrn api binding "${apiName}"`);
|
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 ssrGet = attrValue(node.attrs, "ssrGet");
|
||||||
@@ -756,6 +760,8 @@ function renderNode(
|
|||||||
const inner =
|
const inner =
|
||||||
apiBinding?.mode === "ssr"
|
apiBinding?.mode === "ssr"
|
||||||
? ssrMarker(ssrBindings, renderBinding(apiBinding))
|
? ssrMarker(ssrBindings, renderBinding(apiBinding))
|
||||||
|
: apiBinding?.mode === "any"
|
||||||
|
? apiCallMarker(loops, parsedApi!.name, parsedApi!.args)
|
||||||
: ssrGet && ssrText
|
: ssrGet && ssrText
|
||||||
? ssrMarker(ssrBindings, {
|
? ssrMarker(ssrBindings, {
|
||||||
method: "GET",
|
method: "GET",
|
||||||
@@ -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 {
|
function ssrMarker(bindings: SsrBinding[], binding: RenderBinding): string {
|
||||||
const marker = `<!--wrnexus-ssr:${bindings.length}-->`;
|
const marker = `<!--wrnexus-ssr:${bindings.length}-->`;
|
||||||
bindings.push({ marker, ...binding });
|
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);
|
||||||
|
});
|
||||||
@@ -1067,10 +1067,20 @@ export function parseHtmlView(src: string, pos: number): { nodes: ViewNode[]; en
|
|||||||
const quote = src[i];
|
const quote = src[i];
|
||||||
if (quote !== '"' && quote !== "'") return fail("Expected a quoted attribute value");
|
if (quote !== '"' && quote !== "'") return fail("Expected a quoted attribute value");
|
||||||
i++;
|
i++;
|
||||||
const start = i;
|
// Backslash-escapes the delimiter (and anything else) so an attribute
|
||||||
while (i < src.length && src[i] !== quote) i++;
|
// 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");
|
if (i >= src.length) return fail("Unterminated attribute value");
|
||||||
const value = src.slice(start, i);
|
|
||||||
i++; // closing quote
|
i++; // closing quote
|
||||||
return value;
|
return value;
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user