feat: replace the ssr/client data blocks with apis blocks
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,10 +1,5 @@
|
||||
import { WRN_RUNTIME_TARGETS } from "./spec.ts";
|
||||
import {
|
||||
parseApiSections,
|
||||
parseApiEntries,
|
||||
hasRequestSection,
|
||||
type ApiSections,
|
||||
} from "./api-sections.ts";
|
||||
import { parseApiEntries, type ApiSections } from "./api-sections.ts";
|
||||
|
||||
/**
|
||||
* Recursive-descent parser for `.wrn`, producing a small AST.
|
||||
@@ -147,7 +142,7 @@ export interface ApiBlock {
|
||||
|
||||
export type SeoBlock = Record<string, string>;
|
||||
|
||||
export type DataMode = "ssr" | "client" | "any";
|
||||
export type DataMode = "any";
|
||||
|
||||
export interface DataApiBlock {
|
||||
mode: DataMode;
|
||||
@@ -669,7 +664,6 @@ export function parse(source: string): PageAst {
|
||||
case "client":
|
||||
case "server": {
|
||||
const rawMode = kw.value;
|
||||
const mode: DataMode = rawMode === "client" ? "client" : "ssr";
|
||||
lx.next();
|
||||
if (
|
||||
(rawMode === "client" || rawMode === "server") &&
|
||||
@@ -684,56 +678,17 @@ export function parse(source: string): PageAst {
|
||||
);
|
||||
break;
|
||||
}
|
||||
if (mode === "client" && lx.peek().type === "eq") {
|
||||
if (rawMode === "client" && lx.peek().type === "eq") {
|
||||
lx.next();
|
||||
hydrate = expect("string").value;
|
||||
break;
|
||||
}
|
||||
expect("lbrace");
|
||||
while (lx.peek().type !== "rbrace") {
|
||||
const member = lx.peek();
|
||||
if (member.type === "eof") {
|
||||
throw new ParseError(`Unexpected end of input inside ${mode} block`);
|
||||
}
|
||||
if (member.type !== "ident") {
|
||||
throw new ParseError(`Expected a ${mode} member keyword at offset ${member.pos}`);
|
||||
}
|
||||
switch (member.value) {
|
||||
case "api": {
|
||||
lx.next();
|
||||
const name = expect("ident").value;
|
||||
const method = expect("ident").value.toUpperCase();
|
||||
const path = lx.readPath();
|
||||
const body = lx.readBalancedBraces();
|
||||
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": {
|
||||
lx.next();
|
||||
modeFunctions.push({ mode, body: lx.readBalancedBraces() });
|
||||
break;
|
||||
}
|
||||
default:
|
||||
throw new ParseError(
|
||||
`Unknown ${mode} member '${member.value}' at offset ${member.pos}`,
|
||||
);
|
||||
}
|
||||
if (lx.peek().type === "lbrace") {
|
||||
throw new ParseError(
|
||||
`"${rawMode} { … }" data blocks were removed. Declare API calls in a page-level "apis { }" block, and move mode-scoped helpers into "functions { shared function … }".`,
|
||||
);
|
||||
}
|
||||
expect("rbrace");
|
||||
break;
|
||||
throw new ParseError(`Expected a ${rawMode} state block or hydrate assignment`);
|
||||
}
|
||||
case "shared": {
|
||||
lx.next();
|
||||
|
||||
@@ -1,137 +0,0 @@
|
||||
import { expect, test } from "bun:test";
|
||||
import { parse } from "../src/index.ts";
|
||||
|
||||
const page = (inner: string) => `page Repro {
|
||||
client {
|
||||
${inner}
|
||||
}
|
||||
|
||||
view { <main>x</main> }
|
||||
}
|
||||
`;
|
||||
|
||||
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 { <main>x</main> }
|
||||
}
|
||||
`;
|
||||
|
||||
expect(() => parse(source)).toThrow(/ssr[\s\S]*request/i);
|
||||
});
|
||||
|
||||
test("a brace inside a string literal in the response body does not truncate the section", () => {
|
||||
const ast = parse(
|
||||
page(` api searchUsers POST /api/users {
|
||||
request {
|
||||
body {
|
||||
name?: string
|
||||
}
|
||||
}
|
||||
|
||||
response {
|
||||
return "a } weird string"
|
||||
}
|
||||
|
||||
error {
|
||||
return []
|
||||
}
|
||||
}`),
|
||||
);
|
||||
|
||||
const block = ast.dataApis[0]!;
|
||||
expect(block.sections?.response.trim()).toBe('return "a } weird string"');
|
||||
expect(block.sections?.error.trim()).toBe("return []");
|
||||
});
|
||||
|
||||
test("a legacy block whose comment or string mentions a section keyword stays legacy", () => {
|
||||
const ast = parse(
|
||||
page(` api legacyUsers GET /api/users {
|
||||
// fall back to a manual request { } if this fails
|
||||
return "response { not a section }"
|
||||
}`),
|
||||
);
|
||||
|
||||
const block = ast.dataApis[0]!;
|
||||
expect(block.sections).toBeUndefined();
|
||||
expect(block.body.trim()).toBe(
|
||||
'// fall back to a manual request { } if this fails\n return "response { not a section }"',
|
||||
);
|
||||
});
|
||||
@@ -74,28 +74,12 @@ test("a bare body inside apis {} is a parse error naming response", () => {
|
||||
expect(() => parse(page(` bare GET /api/z { return data }`))).toThrow(/response/i);
|
||||
});
|
||||
|
||||
test("apis entry followed by an ssr api of the same name is rejected", () => {
|
||||
test("two apis entries of the same name across separate blocks are rejected", () => {
|
||||
const src = `page Repro {
|
||||
apis {
|
||||
foo GET /api/foo { response { return data } }
|
||||
}
|
||||
|
||||
ssr {
|
||||
api foo GET /api/foo { return data }
|
||||
}
|
||||
|
||||
view { <main>x</main> }
|
||||
}
|
||||
`;
|
||||
expect(() => parse(src)).toThrow(/duplicate/i);
|
||||
});
|
||||
|
||||
test("ssr api followed by an apis entry of the same name is rejected", () => {
|
||||
const src = `page Repro {
|
||||
ssr {
|
||||
api foo GET /api/foo { return data }
|
||||
}
|
||||
|
||||
apis {
|
||||
foo GET /api/foo { response { return data } }
|
||||
}
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
import { expect, test } from "bun:test";
|
||||
import { parse } from "../src/index.ts";
|
||||
|
||||
test("an ssr data block is rejected and names the replacement", () => {
|
||||
expect(() =>
|
||||
parse(`page P {
|
||||
ssr { api x GET /api/x { return users } }
|
||||
view { <main>x</main> }
|
||||
}
|
||||
`),
|
||||
).toThrow(/apis/);
|
||||
});
|
||||
|
||||
test("a client data block is rejected and names the replacement", () => {
|
||||
expect(() =>
|
||||
parse(`page P {
|
||||
client { api x GET /api/x { return users } }
|
||||
view { <main>x</main> }
|
||||
}
|
||||
`),
|
||||
).toThrow(/apis/);
|
||||
});
|
||||
|
||||
test("client state is unaffected", () => {
|
||||
// Different construct sharing the keyword. It must keep working.
|
||||
const ast = parse(`page P {
|
||||
client state { count = 0 }
|
||||
view { <main>x</main> }
|
||||
}
|
||||
`);
|
||||
|
||||
expect(ast.states.some((state) => state.name === "count")).toBe(true);
|
||||
});
|
||||
Reference in New Issue
Block a user