feat(editor): complete tooling support for the apis block

Task 6 Step 2 was already performed and confirmed the assertion error
from the apis-block type checks lands on the apis { } block itself
(WRN-TYPE-2344), not on the offending entry, so it already surfaces
usefully and needed no relocation mapping.

That observation surfaced a real pre-existing bug: the virtual
TypeScript document built for type checking declared `server` from
ast.dataApis-adjacent runtime functions but never declared `api`,
so every api.<name>(...) call raised a false 'Cannot find name apis'
plus a knock-on implicit-any on its result. Fixes it by declaring
`api` from ast.dataApis, mirroring the existing `server` declaration:
each entry gets an input parameter shaped from its request
parameters/body fields (optional when the entry declares none) and a
Promise<any> return. The binding is only emitted when the page has an
apis { } block, so pages without one keep the legitimate 'Cannot find
name api' diagnostic and 'state api' stays legal.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-20 18:51:19 +05:30
co-authored by Claude Opus 5
parent 861444b8a3
commit e944fd4496
3 changed files with 171 additions and 1 deletions
+17
View File
@@ -280,6 +280,22 @@ export function virtualTypeScriptModule(
`${fn.name}: (${fn.parameters.map((param) => `${param.name}${param.optional ? "?" : ""}: ${param.valueType ?? "unknown"}`).join(", ")}) => ${fn.returnType ?? (fn.async ? "Promise<unknown>" : "unknown")}`,
)
.join("; ");
// `apis { }` introduces an `api.<name>(...)` binding, callable from server,
// client, and shared functions alike (see codegen.ts/client-codegen.ts). It
// must be typed here or every `api.<name>()` call raises a false "Cannot
// find name 'api'" plus a knock-on implicit-any on its result. A page with
// no `apis { }` block must NOT get this binding — that would mask the
// legitimate "Cannot find name 'api'" error on a stray reference, and would
// shadow the otherwise-legal `state api`.
const apiType = ast.dataApis
.map((block) => {
const fields = block.sections ? [...block.sections.parameters, ...block.sections.body] : [];
const inputType = fields.length
? `{ ${fields.map((f) => `${f.name}${f.optional ? "?" : ""}: ${f.type}`).join("; ")} }`
: "Record<string, never>";
return `${block.name}: (input${fields.length ? "" : "?"}: ${inputType}) => Promise<any>`;
})
.join("; ");
append(`declare const output: { [name: string]: (...args: any[]) => void; ${outputType} };`);
for (const output of ast.outputs)
append(
@@ -288,6 +304,7 @@ export function virtualTypeScriptModule(
for (const match of source.matchAll(/@event\s+([A-Za-z_$][\w$]*)\s*=\s*function/g))
append(`declare const ${match[1]}: ((...args: unknown[]) => void) | undefined;`);
append(`declare const server: { ${serverType} };`);
if (ast.dataApis.length) append(`declare const api: { ${apiType} };`);
append(`declare const props: Readonly<${ast.name}Props>;`);
append("declare const refs: Record<string, Element | null>;");
append(
@@ -0,0 +1,146 @@
import { expect, test } from "bun:test";
import { mkdirSync, mkdtempSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { checkWrnSource } from "../src/index.ts";
function app(): string {
const root = mkdtempSync(join(tmpdir(), "wrn-typecheck-api-"));
mkdirSync(join(root, "app", "types"), { recursive: true });
return root;
}
test("api.<name>(...) in a client function produces no diagnostic", () => {
const root = app();
const source = `page Demo {
apis {
searchDirectory POST /api/directory {
request {
body {
name?: string
}
}
response {
return data.data.users
}
}
}
functions {
client async function search(): Promise<void> {
await api.searchDirectory({ name: "a" })
}
}
view { <div></div> }
}`;
const diagnostics = checkWrnSource(source, {
appRoot: root,
filePath: join(root, "app", "pages", "Demo.wrn"),
});
expect(diagnostics.some((d) => /Cannot find name 'api'/.test(d.message))).toBe(false);
expect(diagnostics).toHaveLength(0);
});
test("the implicit-any knock-on from an undeclared api binding is gone", () => {
const root = app();
const source = `page Demo {
apis {
searchDirectory POST /api/directory {
request {
body {
name?: string
}
}
response {
return data.data.users
}
}
}
functions {
client async function search(): Promise<void> {
const result = await api.searchDirectory({ name: "a" })
}
}
view { <div></div> }
}`;
const diagnostics = checkWrnSource(source, {
appRoot: root,
filePath: join(root, "app", "pages", "Demo.wrn"),
});
expect(diagnostics.some((d) => d.code === "WRN-TYPE-7006")).toBe(false);
});
test("a page without an apis {} block still errors on a bare api. reference", () => {
const root = app();
const source = `page Demo {
functions {
client async function search(): Promise<void> {
await api.searchDirectory({ name: "a" })
}
}
view { <div></div> }
}`;
const diagnostics = checkWrnSource(source, {
appRoot: root,
filePath: join(root, "app", "pages", "Demo.wrn"),
});
expect(diagnostics.some((d) => /Cannot find name 'api'/.test(d.message))).toBe(true);
});
test("state api on a page without an apis {} block stays legal", () => {
const root = app();
const source = `page Demo {
state api = "idle"
functions {
client function reset(): void {
api = "idle"
}
}
view { <div>{api}</div> }
}`;
const diagnostics = checkWrnSource(source, {
appRoot: root,
filePath: join(root, "app", "pages", "Demo.wrn"),
});
expect(diagnostics.some((d) => /Cannot find name 'api'/.test(d.message))).toBe(false);
});
test("calling an undeclared block name on api errors", () => {
const root = app();
const source = `page Demo {
apis {
searchDirectory POST /api/directory {
request {
body {
name?: string
}
}
response {
return data.data.users
}
}
}
functions {
client async function search(): Promise<void> {
await api.nope({ name: "a" })
}
}
view { <div></div> }
}`;
const diagnostics = checkWrnSource(source, {
appRoot: root,
filePath: join(root, "app", "pages", "Demo.wrn"),
});
expect(diagnostics.some((d) => /Property 'nope' does not exist/.test(d.message))).toBe(true);
});