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:
@@ -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);
|
||||
});
|
||||
Reference in New Issue
Block a user