feat(syntax): parse the apis container block
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
"use strict";
|
||||
// Generated by scripts/build-editor-compiler.mjs. Do not edit directly.
|
||||
// WRN editor compiler source hash: 59b39cb658ef79c3ee7deb2147a92ac8bbb6c4427d732bf1de63e489c2471df8
|
||||
// WRN editor compiler source hash: ed63852e856493749abcfd8160c27519e4b7e0d38e93e2b5cf64e94e29bf581f
|
||||
// WRN editor compiler generator hash: a54ca847c758bc98d8e353ad6d70088df31de1820f6cf9d1c3462505f563e6b8
|
||||
// Generated with TypeScript: 6.0.3
|
||||
const __nodeRequire = require;
|
||||
@@ -4659,6 +4659,7 @@ function generateDeclarations(ast) {
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.parseApiSections = parseApiSections;
|
||||
exports.hasRequestSection = hasRequestSection;
|
||||
exports.parseApiEntries = parseApiEntries;
|
||||
/**
|
||||
* Parse the sectioned form of an `api` block body.
|
||||
*
|
||||
@@ -4797,6 +4798,39 @@ function parseApiSections(source) {
|
||||
function hasRequestSection(source) {
|
||||
return scanTopLevelBlocks(source, SECTION_NAMES).has("request");
|
||||
}
|
||||
const emptySections = { parameters: [], body: [], response: "", error: "" };
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
function parseApiEntries(source) {
|
||||
const entries = [];
|
||||
const lx = new tokenizer_ts_1.Lexer(source);
|
||||
while (lx.peek().type !== "eof") {
|
||||
const nameToken = lx.next();
|
||||
if (nameToken.type !== "ident") {
|
||||
throw new tokenizer_ts_1.LexError(`Expected an api entry name at offset ${nameToken.pos}`);
|
||||
}
|
||||
const methodToken = lx.next();
|
||||
if (methodToken.type !== "ident") {
|
||||
throw new tokenizer_ts_1.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;
|
||||
}
|
||||
|
||||
},
|
||||
"packages/syntax/src/diagnostics.ts": function (module, exports, require, __filename, __dirname) {
|
||||
@@ -6756,6 +6790,17 @@ function parse(source) {
|
||||
persist = (0, v060_ts_1.parsePersist)(lx.readBalancedBraces());
|
||||
break;
|
||||
}
|
||||
case "apis": {
|
||||
lx.next();
|
||||
const body = lx.readBalancedBraces();
|
||||
for (const entry of (0, api_sections_ts_1.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}`);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// WRN editor extension source hash: 0192e263f6213e194dc903c4c4d6436e4ab05cbfe06d2d865a3de7ca1507d3d6
|
||||
// WRN editor extension source hash: e37604bc3978c372d7b76ee019cb883a5c284877a9be16e8740d7e1bddfde5c4
|
||||
// WRN editor extension generator hash: 456d1d614e44e5fb1f19b784176c09cf2ade9b64ef73a17934c2698150b62728
|
||||
"use strict";
|
||||
var __commonJS = (cb, mod) => () => (mod || cb((mod = { exports: {} }).exports, mod), mod.exports);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
#!/usr/bin/env node
|
||||
// WRN editor language server source hash: 8f158f0d23d75de9b08a12cef6e20d2e5cdb48b6242db3b724634880277acd8c
|
||||
// WRN editor language server source hash: a5d091c01e106fd98dba6ef8bda030e821bf1f04ebeb63a6d12cc2d0783e93e2
|
||||
// WRN editor language server generator hash: f593a44aaf05495b789ce7a3086bee1eebb951b884d41c0e017bbcfe5f547e72
|
||||
// @bun @bun-cjs
|
||||
(function(exports, require, module, __filename, __dirname) {var __create = Object.create;
|
||||
@@ -170869,6 +170869,32 @@ function parseApiSections(source) {
|
||||
function hasRequestSection(source) {
|
||||
return scanTopLevelBlocks(source, SECTION_NAMES).has("request");
|
||||
}
|
||||
var emptySections = { parameters: [], body: [], response: "", error: "" };
|
||||
function parseApiEntries(source) {
|
||||
const entries = [];
|
||||
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;
|
||||
}
|
||||
|
||||
// packages/syntax/src/types.ts
|
||||
function runtimeTypeOf(annotation) {
|
||||
@@ -171881,6 +171907,17 @@ function parse(source) {
|
||||
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}`);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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}`);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
Reference in New Issue
Block a user