feat(syntax): parse sectioned api blocks

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-19 15:21:10 +05:30
co-authored by Claude Opus 5
parent 2f0f82b29f
commit 028c2a6d64
3 changed files with 195 additions and 1 deletions
+79
View File
@@ -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;
}
+18 -1
View File
@@ -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": {