diff --git a/editors/vscode/src/compiler.cjs b/editors/vscode/src/compiler.cjs index 7e430886..316056df 100644 --- a/editors/vscode/src/compiler.cjs +++ b/editors/vscode/src/compiler.cjs @@ -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}`; } function renderPageComponentInvocation(node, ssrBindings, csrBindings, apiBindings, loops, reactive) { @@ -1602,6 +1608,33 @@ function renderNestedComponentInvocation(node, ctx) { `${ctx.forwardRestAttrs ? "${__wrnSpreadAttrs(__attrs)}" : ""}` + `${attrs}>${inner}`); } +/** + * 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.()` 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 = ``; 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; }; diff --git a/editors/vscode/src/extension.bundle.cjs b/editors/vscode/src/extension.bundle.cjs index e09285a1..5fd103a7 100644 --- a/editors/vscode/src/extension.bundle.cjs +++ b/editors/vscode/src/extension.bundle.cjs @@ -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); diff --git a/editors/vscode/src/language-server.cjs b/editors/vscode/src/language-server.cjs index 9db289e5..5b140c8f 100644 --- a/editors/vscode/src/language-server.cjs +++ b/editors/vscode/src/language-server.cjs @@ -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; }; diff --git a/packages/compiler/src/codegen.ts b/packages/compiler/src/codegen.ts index c0f5d653..888908a4 100644 --- a/packages/compiler/src/codegen.ts +++ b/packages/compiler/src/codegen.ts @@ -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}`; } @@ -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.()` 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 = ``; bindings.push({ marker, ...binding }); diff --git a/packages/compiler/test/apis-render-binding.test.ts b/packages/compiler/test/apis-render-binding.test.ts new file mode 100644 index 00000000..78fe4d35 --- /dev/null +++ b/packages/compiler/test/apis-render-binding.test.ts @@ -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 {

loading

} +} +`; + +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 {

loading

} +} +`), + ); + + // 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); +}); diff --git a/packages/syntax/src/parser.ts b/packages/syntax/src/parser.ts index bb5c29bd..36e8910f 100644 --- a/packages/syntax/src/parser.ts +++ b/packages/syntax/src/parser.ts @@ -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; };