feat(syntax): parse the apis container block

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-20 02:28:23 +05:30
co-authored by Claude Opus 5
parent d069f5ddd7
commit 87a00de5f3
6 changed files with 222 additions and 5 deletions
+48
View File
@@ -171,3 +171,51 @@ export function parseApiSections(source: string): ApiSections | null {
export function hasRequestSection(source: string): boolean {
return scanTopLevelBlocks(source, SECTION_NAMES).has("request");
}
const emptySections: ApiSections = { parameters: [], body: [], response: "", error: "" };
/** Shape of an entry parsed from an `apis { }` container body. */
export interface ApiEntry {
mode: "any";
name: string;
method: string;
path: string;
body: string;
sections: ApiSections;
}
/**
* Parse the body of a page-level `apis { }` container: a sequence of
* `<name> <METHOD> <path> { ... }` entries with no leading `api` keyword
* (the container supplies it). Each entry's braces are sliced with the
* tokenizer's own `Lexer.readBalancedBraces()`, so the same string- and
* comment-aware rules that protect `parseApiSections` apply here too.
*/
export function parseApiEntries(source: string): ApiEntry[] {
const entries: ApiEntry[] = [];
const lx = new Lexer(source);
while (lx.peek().type !== "eof") {
const nameToken = lx.next();
if (nameToken.type !== "ident") {
throw new LexError(`Expected an api entry name at offset ${nameToken.pos}`);
}
const methodToken = lx.next();
if (methodToken.type !== "ident") {
throw new LexError(`Expected an HTTP method after "${nameToken.value}"`);
}
const path = lx.readPath();
const entryBody = lx.readBalancedBraces();
entries.push({
mode: "any",
name: nameToken.value,
method: methodToken.value.toUpperCase(),
path,
body: "",
sections: parseApiSections(entryBody) ?? emptySections,
});
}
return entries;
}
+18 -2
View File
@@ -1,5 +1,10 @@
import { WRN_RUNTIME_TARGETS } from "./spec.ts";
import { parseApiSections, hasRequestSection, type ApiSections } from "./api-sections.ts";
import {
parseApiSections,
parseApiEntries,
hasRequestSection,
type ApiSections,
} from "./api-sections.ts";
/**
* Recursive-descent parser for `.wrn`, producing a small AST.
@@ -142,7 +147,7 @@ export interface ApiBlock {
export type SeoBlock = Record<string, string>;
export type DataMode = "ssr" | "client";
export type DataMode = "ssr" | "client" | "any";
export interface DataApiBlock {
mode: DataMode;
@@ -845,6 +850,17 @@ export function parse(source: string): PageAst {
persist = parsePersist(lx.readBalancedBraces());
break;
}
case "apis": {
lx.next();
const body = lx.readBalancedBraces();
for (const entry of parseApiEntries(body)) {
if (dataApis.some((block) => block.name === entry.name)) {
throw new ParseError(`Duplicate api entry "${entry.name}" in apis block`);
}
dataApis.push(entry);
}
break;
}
default:
throw new ParseError(`Unknown page member '${kw.value}' at offset ${kw.pos}`);
}
+71
View File
@@ -0,0 +1,71 @@
import { expect, test } from "bun:test";
import { parse } from "../src/index.ts";
const page = (inner: string) => `page Repro {
apis {
${inner}
}
view { <main>x</main> }
}
`;
test("parses a mode-less entry with its sections", () => {
const ast = parse(
page(` searchUsers POST /api/users {
request {
body {
name?: string
}
}
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.mode).toBe("any");
expect(block.sections?.body).toEqual([{ name: "name", optional: true, type: "string" }]);
expect(block.sections?.response.trim()).toBe("return data.users");
expect(block.sections?.error.trim()).toBe("return []");
});
test("parses several entries in one container", () => {
const ast = parse(
page(` a GET /api/a { response { return data } }
b POST /api/b { response { return data } }`),
);
expect(ast.dataApis.map((block) => block.name)).toEqual(["a", "b"]);
});
test("a GET entry declares parameters", () => {
const ast = parse(
page(` listTeams GET /api/teams {
request {
parameters {
team: string
}
}
response { return data.teams }
}`),
);
expect(ast.dataApis[0]!.sections?.parameters).toEqual([
{ name: "team", optional: false, type: "string" },
]);
});
test("duplicate names inside one container are rejected", () => {
expect(() =>
parse(
page(` dup GET /api/a { response { return data } }
dup POST /api/b { response { return data } }`),
),
).toThrow(/duplicate/i);
});