From 028c2a6d649df53c72c2e2e1aa0c0dec63b2a4fa Mon Sep 17 00:00:00 2001 From: Ajay Ghanwat Date: Wed, 19 Aug 2026 15:21:10 +0530 Subject: [PATCH] feat(syntax): parse sectioned api blocks Co-Authored-By: Claude Opus 5 --- packages/syntax/src/api-sections.ts | 79 +++++++++++++++++++++ packages/syntax/src/parser.ts | 19 ++++- packages/syntax/test/api-block.test.ts | 98 ++++++++++++++++++++++++++ 3 files changed, 195 insertions(+), 1 deletion(-) create mode 100644 packages/syntax/src/api-sections.ts create mode 100644 packages/syntax/test/api-block.test.ts 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); +});