diff --git a/packages/syntax/src/api-sections.ts b/packages/syntax/src/api-sections.ts
new file mode 100644
index 00000000..bfa76b5f
--- /dev/null
+++ b/packages/syntax/src/api-sections.ts
@@ -0,0 +1,79 @@
+/**
+ * Parse the sectioned form of an `api` block body.
+ *
+ * Returns null when no section keyword is present, which is how the legacy
+ * bare-body form stays valid: the caller keeps treating the body as the
+ * response expression.
+ */
+export interface ApiFieldDecl {
+ name: string;
+ optional: boolean;
+ type: string;
+}
+
+export interface ApiSections {
+ parameters: ApiFieldDecl[];
+ body: ApiFieldDecl[];
+ response: string;
+ error: string;
+}
+
+const SECTION_NAMES = ["request", "response", "error"] as const;
+
+/** Slice the balanced `{ ... }` that follows `keyword`, or null when absent. */
+function sectionBody(source: string, keyword: string): string | null {
+ const match = new RegExp(`(^|[^A-Za-z0-9_$])${keyword}\\s*\\{`).exec(source);
+ if (!match) return null;
+
+ const open = source.indexOf("{", match.index + match[1]!.length);
+ let depth = 0;
+
+ for (let index = open; index < source.length; index++) {
+ const character = source[index];
+ if (character === "{") depth++;
+ else if (character === "}") {
+ depth--;
+ if (depth === 0) return source.slice(open + 1, index);
+ }
+ }
+
+ throw new Error(`Unclosed "${keyword}" section in an api block`);
+}
+
+/** `name?: string` -> { name, optional, type }. Blank lines and comments are skipped. */
+function parseFields(source: string): ApiFieldDecl[] {
+ const fields: ApiFieldDecl[] = [];
+
+ for (const rawLine of source.split("\n")) {
+ const line = rawLine.trim().replace(/,$/, "");
+ if (!line || line.startsWith("//")) continue;
+
+ const match = /^([A-Za-z_$][A-Za-z0-9_$]*)(\?)?\s*:\s*(.+)$/.exec(line);
+ if (!match) {
+ throw new Error(`Expected "name: type" in an api request section, got "${line}"`);
+ }
+
+ fields.push({ name: match[1]!, optional: match[2] === "?", type: match[3]!.trim() });
+ }
+
+ return fields;
+}
+
+export function parseApiSections(source: string): ApiSections | null {
+ const present = SECTION_NAMES.some((name) => sectionBody(source, name) !== null);
+ if (!present) return null;
+
+ const request = sectionBody(source, "request");
+
+ return {
+ parameters: request ? parseFields(sectionBody(request, "parameters") ?? "") : [],
+ body: request ? parseFields(sectionBody(request, "body") ?? "") : [],
+ response: sectionBody(source, "response") ?? "",
+ error: sectionBody(source, "error") ?? "",
+ };
+}
+
+/** True when the block declares a `request` section. */
+export function hasRequestSection(source: string): boolean {
+ return sectionBody(source, "request") !== null;
+}
diff --git a/packages/syntax/src/parser.ts b/packages/syntax/src/parser.ts
index c5ce52bf..576f871d 100644
--- a/packages/syntax/src/parser.ts
+++ b/packages/syntax/src/parser.ts
@@ -1,4 +1,5 @@
import { WRN_RUNTIME_TARGETS } from "./spec.ts";
+import { parseApiSections, hasRequestSection, type ApiSections } from "./api-sections.ts";
/**
* Recursive-descent parser for `.wrn`, producing a small AST.
@@ -148,7 +149,10 @@ export interface DataApiBlock {
name: string;
method: string;
path: string;
+ /** Legacy bare body. Empty string when `sections` is set. */
body: string;
+ /** Present only for the sectioned, typed form. */
+ sections?: ApiSections;
}
export interface ModeFunctionsBlock {
@@ -696,7 +700,20 @@ export function parse(source: string): PageAst {
const method = expect("ident").value.toUpperCase();
const path = lx.readPath();
const body = lx.readBalancedBraces();
- dataApis.push({ mode, name, method, path, body });
+ const sections = parseApiSections(body);
+ if (sections && mode !== "client" && hasRequestSection(body)) {
+ throw new ParseError(
+ `An ssr api block cannot declare "request": there is no caller at render time to supply it. Use a client block, or a server function.`,
+ );
+ }
+ dataApis.push({
+ mode,
+ name,
+ method,
+ path,
+ body: sections ? "" : body,
+ ...(sections ? { sections } : {}),
+ });
break;
}
case "functions": {
diff --git a/packages/syntax/test/api-block.test.ts b/packages/syntax/test/api-block.test.ts
new file mode 100644
index 00000000..83bd6b87
--- /dev/null
+++ b/packages/syntax/test/api-block.test.ts
@@ -0,0 +1,98 @@
+import { expect, test } from "bun:test";
+import { parse } from "../src/index.ts";
+
+const page = (inner: string) => `page Repro {
+ client {
+${inner}
+ }
+
+ view { x }
+}
+`;
+
+test("parses a sectioned api block into request, response and error", () => {
+ const ast = parse(
+ page(` api searchUsers POST /api/users {
+ request {
+ body {
+ name?: string
+ age?: number
+ }
+ }
+
+ response {
+ return data.users
+ }
+
+ error {
+ return []
+ }
+ }`),
+ );
+
+ const block = ast.dataApis[0]!;
+ expect(block.name).toBe("searchUsers");
+ expect(block.method).toBe("POST");
+ expect(block.path).toBe("/api/users");
+ expect(block.sections?.body).toEqual([
+ { name: "name", optional: true, type: "string" },
+ { name: "age", optional: true, type: "number" },
+ ]);
+ expect(block.sections?.response.trim()).toBe("return data.users");
+ expect(block.sections?.error.trim()).toBe("return []");
+});
+
+test("a bare body still parses as the legacy response body", () => {
+ const ast = parse(
+ page(` api legacyUsers GET /api/users {
+ return users.length
+ }`),
+ );
+
+ const block = ast.dataApis[0]!;
+ expect(block.sections).toBeUndefined();
+ expect(block.body.trim()).toBe("return users.length");
+});
+
+test("GET parameters are parsed as required when not marked optional", () => {
+ const ast = parse(
+ page(` api listUsers GET /api/users {
+ request {
+ parameters {
+ team: string
+ }
+ }
+
+ response {
+ return data.users
+ }
+ }`),
+ );
+
+ expect(ast.dataApis[0]!.sections?.parameters).toEqual([
+ { name: "team", optional: false, type: "string" },
+ ]);
+});
+
+test("request inside an ssr block is rejected with a message naming the restriction", () => {
+ const source = `page Repro {
+ ssr {
+ api ssrUsers GET /api/users {
+ request {
+ parameters {
+ team: string
+ }
+ }
+
+ response {
+ return data.users
+ }
+ }
+ }
+
+ view { x }
+}
+`;
+
+ expect(() => parse(source)).toThrow(/ssr[\s\S]*request/i);
+});