Compare commits

...
5 Commits
Author SHA1 Message Date
ClintchizandClaude Opus 5 5429057a38 fix(language-server): gate api hover to actual api.<name> references
Quality / quality (ubuntu-latest) (push) Failing after 9m55s
Quality / quality (windows-latest) (push) Canceled after 0s
Hover fired on whatever word was under the cursor, so a local variable
colliding with a declared block name reported the block's method and path
instead of its own hover info. Completion was already gated this way.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-20 19:03:46 +05:30
ClintchizandClaude Opus 5 e944fd4496 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>
2026-08-20 18:51:19 +05:30
ClintchizandClaude Opus 5 861444b8a3 feat(language-server): flag the removed data blocks
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-20 18:40:08 +05:30
ClintchizandClaude Opus 5 ba957e861e chore(ui): re-baseline the visual contract after the auth UI refinements
cd0dffa8 changed StrongPassword, TogglePassword and button but left the
hash manifest untouched, so check:ui-visual has been red since. The three
hashes here are exactly those components -- no unrelated drift.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-20 16:50:44 +05:30
ClintchizandClaude Opus 5 29febb2e0c feat(language-server): understand the api binding attribute
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-20 16:49:06 +05:30
13 changed files with 541 additions and 15 deletions
+2 -1
View File
@@ -1864,7 +1864,8 @@
"./server": [
"ApiCallCompletionItem",
"apiCallCompletions",
"apiCallHover"
"apiCallHover",
"isApiReferenceAt"
]
},
"@wrnexus/mcp": {
+3 -3
View File
@@ -78,7 +78,7 @@
"packages/ui/components/SplitHero.wrn": "843387dc7b920b3cf6a1b2b785b8653c281982e524479edc2638a89381ff3577",
"packages/ui/components/StatsBar.wrn": "c273bd6ca417a0a20e5e6f1214414f678741b73bedd885fea1037575fa8c5b95",
"packages/ui/components/Stepper.wrn": "7ec0902acd7cb74b44d490215b6421dae9a41731735ad1e9aec17e8bfc3fa142",
"packages/ui/components/StrongPassword.wrn": "d5ea906fafe268b070a26ab551a252c0575d899c9d2b03b337bd047688b85dc5",
"packages/ui/components/StrongPassword.wrn": "407eabcc13affc9323a346ddba8e39c81b885a59ef7043d2bd5567d4b7553282",
"packages/ui/components/StyledIcon.wrn": "0874f9a234b053a7c8b9c6983be5d664453a64c4576dff1a049101e249a2866d",
"packages/ui/components/Tabs.wrn": "f85214960e62da2fac41228deba511a50c57fd94f2830a8233b75048f6a542e9",
"packages/ui/components/TextLink.wrn": "9d561631c5f65a2bcdc59f5c0b166a5dd794a396f42093f2cd26388386f6f954",
@@ -86,14 +86,14 @@
"packages/ui/components/Timeline.wrn": "fb553f69763840532eef1c0aafa79013f57411dc5335e7cb7e7c20c5a3753008",
"packages/ui/components/Toaster.wrn": "5e4c9b2c1bfd5675a81a32e57684b99393ac7fe45d8420c7409a4e95d47d080c",
"packages/ui/components/ToggleCount.wrn": "e577195ee05ddb43cceec47145f6b90c0149f2f17055733d58911fcd02de2c7f",
"packages/ui/components/TogglePassword.wrn": "b6176453cb2c586fe88c3db6bdd734ae2b4c95db46d57e0de298dfba3d0dd0c2",
"packages/ui/components/TogglePassword.wrn": "6bd4d9fa82b0c8e80178430d5a42b1b3ec9725fb62be158585f68af9338c901a",
"packages/ui/components/TreeView.wrn": "f54d8495730203b923629bcfb167d5ca41236af0101ea2723d33354413555b4b",
"packages/ui/components/Typography.wrn": "9676af57f2a937b21f45512e0a290b5bee4a16267fbf404850297d29645d88b5",
"packages/ui/components/WysiwygEditor.wrn": "3f9d1765184581e3c1f335769a9f19969917450258bc95ba076980d99f5610b5",
"packages/ui/components/alert.wrn": "d42287e41b918b1e19962e9462ef203002eb5480acac984f115b711e9dbe663d",
"packages/ui/components/avatar.wrn": "8bc705e459b13c7f063c57e1506ce540e50fa8ef9020ae85b6e8b6a3579fc5dc",
"packages/ui/components/badge.wrn": "e44e33633fb34e897696cd9290f210108e35a3e2dc3b2a4a367411f45c69a1e1",
"packages/ui/components/button.wrn": "cba4ccbbd23bb75b3836ec7a53673e40f1e043d18bfcee3d613db96c318f4ba8",
"packages/ui/components/button.wrn": "65570c58cb2547f1e17cd158eec7cf7b1ffc721e6976c6486c9df564627b1219",
"packages/ui/components/checkbox.wrn": "18046396b75d0b9c6bb2bdb09dbff1846c84fb07490f390a25ca7b5367fd2ef4",
"packages/ui/components/input.wrn": "e4c14527f009b610b4aa96d8d7b7d5ac3ca1ffd34237bab348ee9dc4ddc55847",
"packages/ui/components/progress.wrn": "6307a90585197d7aab19a8710b2430f5d4ed27ce77e9b90b1414ea0eed876492",
+2 -2
View File
@@ -1,6 +1,6 @@
"use strict";
// Generated by scripts/build-editor-compiler.mjs. Do not edit directly.
// WRN editor compiler source hash: 2987e8e9d91b793818a195395e740d35d0d5c2a320ff1c3a5ae77363850c7b8e
// WRN editor compiler source hash: 4ba6ddaf22c0a33fa1c2a108a97f077a69c0a46de9fe031c0a00e38863fab613
// WRN editor compiler generator hash: a54ca847c758bc98d8e353ad6d70088df31de1820f6cf9d1c3462505f563e6b8
// Generated with TypeScript: 6.0.3
const __nodeRequire = require;
@@ -6762,7 +6762,7 @@ function parse(source) {
break;
}
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 … }".`);
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 … }" (at offset ${kw.pos}).`);
}
throw new ParseError(`Expected a ${rawMode} state block or hydrate assignment`);
}
+1 -1
View File
@@ -1,4 +1,4 @@
// WRN editor extension source hash: d0ca0feee6516ae43e902e6aea0424bc5c3d05790708eb8a8c68c98162eb99bb
// WRN editor extension source hash: 566bce98338a6a99c6c5c15017d92b3a5fd8ed376dd6abd932179a1e79c00f70
// WRN editor extension generator hash: 456d1d614e44e5fb1f19b784176c09cf2ade9b64ef73a17934c2698150b62728
"use strict";
var __commonJS = (cb, mod) => () => (mod || cb((mod = { exports: {} }).exports, mod), mod.exports);
+40 -4
View File
@@ -1,5 +1,5 @@
#!/usr/bin/env node
// WRN editor language server source hash: 70d2454817cc3aa546304c880e0a82313bb73b50051556bf7febd12e6a46c78c
// WRN editor language server source hash: 1efd569f289f736d7dc094e4165d11490593f1c4534ae460a5122bf6dd45344b
// WRN editor language server generator hash: f593a44aaf05495b789ce7a3086bee1eebb951b884d41c0e017bbcfe5f547e72
// @bun @bun-cjs
(function(exports, require, module, __filename, __dirname) {var __create = Object.create;
@@ -169658,6 +169658,7 @@ var require_main = __commonJS((exports2, module2) => {
// packages/language-server/src/server.ts
var exports_server = {};
__export(exports_server, {
isApiReferenceAt: () => isApiReferenceAt,
apiCallHover: () => apiCallHover,
apiCallCompletions: () => apiCallCompletions
});
@@ -171799,7 +171800,7 @@ function parse(source) {
break;
}
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 … }".`);
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 … }" (at offset ${kw.pos}).`);
}
throw new ParseError(`Expected a ${rawMode} state block or hydrate assignment`);
}
@@ -172995,12 +172996,19 @@ function virtualTypeScriptModule(source, filePath = "component.wrn", appRoot = f
const outputType = ast.outputs.map((output) => `${output.name}: (${output.payload ? `${output.payload.name}${output.payload.optional ? "?" : ""}: ${output.payload.valueType}` : ""}) => void`).join("; ");
const serverFunctions = ast.runtimeFunctions.filter((fn) => fn.runtime === "server");
const serverType = serverFunctions.map((fn) => `${fn.name}: (${fn.parameters.map((param) => `${param.name}${param.optional ? "?" : ""}: ${param.valueType ?? "unknown"}`).join(", ")}) => ${fn.returnType ?? (fn.async ? "Promise<unknown>" : "unknown")}`).join("; ");
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(`declare const ${output.name}: (${output.payload ? `${output.payload.name}${output.payload.optional ? "?" : ""}: ${output.payload.valueType}` : ""}) => void;`);
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(ast.kind === "global-store" || ast.kind === "page-store" ? storeContract(ast) : componentContract(ast));
@@ -196009,6 +196017,9 @@ function getLanguageService(options = defaultLanguageServiceOptions) {
findLinkedEditingRanges
};
}
function newHTMLDataProvider(id, customData) {
return new HTMLDataProvider(id, customData);
}
// packages/language-server/src/html-regions.ts
function viewRegions(text) {
@@ -196098,7 +196109,13 @@ var semanticTokensLegend2 = {
};
// packages/language-server/src/html-service.ts
var service = getLanguageService();
var wrnexusDataProvider = newHTMLDataProvider("wrnexus", {
version: 1,
globalAttributes: [
{ name: "api", description: "Binds this element to a declared `apis { }` entry." }
]
});
var service = getLanguageService({ customDataProviders: [wrnexusDataProvider] });
function htmlDocument(document) {
const virtual = virtualHtmlDocument(document);
return TextDocument2.create(virtual.uri, "html", document.version ?? 1, virtual.text);
@@ -196111,9 +196128,18 @@ function markdown(value) {
}
return;
}
function isApiAttributeValuePosition(document, position) {
const offset = offsetAt2(document.text, position);
if (!isInsideHtml(document, offset))
return false;
const before = document.text.slice(0, offset);
return /\bapi\s*=\s*(["'])(?:(?!\1)[^\n])*$/.test(before);
}
function htmlCompletions(document, position) {
if (!isInsideHtml(document, offsetAt2(document.text, position)))
return [];
if (isApiAttributeValuePosition(document, position))
return [];
const virtual = htmlDocument(document);
const parsed = service.parseHTMLDocument(virtual);
const list = service.doComplete(virtual, position, parsed);
@@ -196243,6 +196269,12 @@ function isApiCallPosition(text, offset) {
const before = text.slice(0, offset);
return /\bapi\.\w*$/.test(before);
}
function isApiReferenceAt(text, offset) {
let start = offset;
while (start > 0 && /\w/.test(text[start - 1]))
start--;
return /\bapi\.$/.test(text.slice(0, start));
}
var documents = new Map;
var diagnosticTimers = new Map;
var MAX_OPEN_DOCUMENTS = 256;
@@ -196419,6 +196451,10 @@ async function handle(message) {
result(message.id, apiCallCompletions(document.text));
break;
}
if (document && isApiAttributeValuePosition(document, params.position)) {
result(message.id, apiCallCompletions(document.text));
break;
}
const wrnexus = [...completionItems(), ...workspaceCompletionItems(workspaceRoot)];
const html = document ? htmlCompletions(document, params.position) : [];
result(message.id, html.length ? mergeCompletions(wrnexus, html) : wrnexus);
@@ -196437,7 +196473,7 @@ async function handle(message) {
case "textDocument/hover": {
const document = documents.get(params.textDocument.uri);
const word = document ? wordAt(document.text, params.position)?.word : undefined;
const apiHover = word ? apiCallHover(document.text, word) : undefined;
const apiHover = word && isApiReferenceAt(document.text, offsetAt(document.text, params.position)) ? apiCallHover(document.text, word) : undefined;
if (apiHover) {
result(message.id, { contents: apiHover });
break;
+32 -1
View File
@@ -5,6 +5,7 @@
// cleanly, so import it explicitly.
import {
getLanguageService,
newHTMLDataProvider,
TextDocument as HtmlTextDocument,
} from "vscode-html-languageservice/lib/esm/htmlLanguageService.js";
import { isInsideHtml, virtualHtmlDocument } from "./html-regions.ts";
@@ -19,7 +20,21 @@ export interface HtmlCompletionItem {
insertText?: string;
}
const service = getLanguageService();
/**
* `api="<name>"` / `api="<name>()"` / `api="<name>({ field: value })"` is a
* WRNexus data-api binding, not arbitrary markup — declaring it here keeps
* the HTML service from treating it as an unrecognized attribute on any
* element, without inventing an attribute-value grammar the library would
* try to spell-check as prose.
*/
const wrnexusDataProvider = newHTMLDataProvider("wrnexus", {
version: 1,
globalAttributes: [
{ name: "api", description: "Binds this element to a declared `apis { }` entry." },
],
});
const service = getLanguageService({ customDataProviders: [wrnexusDataProvider] });
/** The virtual document as the HTML service's own document type. */
function htmlDocument(document: TextDocument) {
@@ -41,8 +56,24 @@ function markdown(value: unknown): string | undefined {
* Every item carries the `1` sortText prefix so the server can rank WRNexus
* entries above these without filtering either list.
*/
/**
* True when `offset` sits inside the quotes of an `api="…"` attribute value.
*
* The value is a call expression (`name`, `name()`, `name({ field: value })`),
* not prose, so completion there is routed to `apiCallCompletions` instead of
* the HTML service's own (text-oriented) attribute-value completion.
*/
export function isApiAttributeValuePosition(document: TextDocument, position: Position): boolean {
const offset = offsetAt(document.text, position);
if (!isInsideHtml(document, offset)) return false;
const before = document.text.slice(0, offset);
return /\bapi\s*=\s*(["'])(?:(?!\1)[^\n])*$/.test(before);
}
export function htmlCompletions(document: TextDocument, position: Position): HtmlCompletionItem[] {
if (!isInsideHtml(document, offsetAt(document.text, position))) return [];
if (isApiAttributeValuePosition(document, position)) return [];
const virtual = htmlDocument(document);
const parsed = service.parseHTMLDocument(virtual);
+21 -1
View File
@@ -26,6 +26,7 @@ import {
htmlHover,
htmlLinkedEditingRanges,
htmlTagComplete,
isApiAttributeValuePosition,
mergeCompletions,
} from "./html-service.ts";
import { clearHtmlRegionCache } from "./html-regions.ts";
@@ -102,6 +103,18 @@ function isApiCallPosition(text: string, offset: number): boolean {
return /\bapi\.\w*$/.test(before);
}
/**
* True when the identifier containing `offset` is the `<name>` of an `api.<name>`
* reference. Hover fires on whatever word is under the cursor, so without this a local
* variable that merely collides with a block name would report the block's method and
* path instead of its own hover info.
*/
export function isApiReferenceAt(text: string, offset: number): boolean {
let start = offset;
while (start > 0 && /\w/.test(text[start - 1]!)) start--;
return /\bapi\.$/.test(text.slice(0, start));
}
type JsonRpc = { jsonrpc?: string; id?: number | string; method?: string; params?: any };
const documents = new Map<string, TextDocument>();
const diagnosticTimers = new Map<string, ReturnType<typeof setTimeout>>();
@@ -288,6 +301,10 @@ async function handle(message: JsonRpc): Promise<void> {
result(message.id, apiCallCompletions(document.text));
break;
}
if (document && isApiAttributeValuePosition(document, params.position)) {
result(message.id, apiCallCompletions(document.text));
break;
}
const wrnexus = [...completionItems(), ...workspaceCompletionItems(workspaceRoot)];
const html = document ? htmlCompletions(document, params.position) : [];
result(message.id, html.length ? mergeCompletions(wrnexus, html) : wrnexus);
@@ -306,7 +323,10 @@ async function handle(message: JsonRpc): Promise<void> {
case "textDocument/hover": {
const document = documents.get(params.textDocument.uri);
const word = document ? wordAt(document.text, params.position)?.word : undefined;
const apiHover = word ? apiCallHover(document!.text, word) : undefined;
const apiHover =
word && isApiReferenceAt(document!.text, offsetAt(document!.text, params.position))
? apiCallHover(document!.text, word)
: undefined;
if (apiHover) {
result(message.id, { contents: apiHover });
break;
@@ -0,0 +1,126 @@
import { expect, test } from "bun:test";
import { documentDiagnostics } from "../src/index.ts";
import { htmlCompletions, isApiAttributeValuePosition } from "../src/html-service.ts";
import { apiCallCompletions } from "../src/server.ts";
const SOURCE = `page Search {
apis {
searchUsers POST /api/users {
request { body { name?: string } }
response { return data.users }
}
}
view { <button api="searchUsers">Go</button> }
}
`;
// `html-regions.ts` caches view regions by `uri` + `version`, so each fixture
// needs its own uri — otherwise a later document reuses an earlier one's
// cached regions and the position checks below silently look at stale spans.
let nextDocId = 0;
function doc(text: string) {
nextDocId += 1;
return { uri: `file:///Page-${nextDocId}.wrn`, text, version: 1 };
}
function positionOf(text: string, needle: string) {
const offset = text.indexOf(needle) + needle.length;
const before = text.slice(0, offset);
const lines = before.split("\n");
return { line: lines.length - 1, character: lines[lines.length - 1]!.length };
}
test('api="searchUsers" produces no unknown-attribute diagnostic', () => {
const diagnostics = documentDiagnostics(doc(SOURCE));
expect(diagnostics.filter((item) => item.severity === 1)).toEqual([]);
expect(
diagnostics.some((item) => /unknown/i.test(item.message) && /api/i.test(item.message)),
).toBe(false);
});
test("`api` is offered as an attribute name on any element, not flagged unknown", () => {
const opening = " <button ";
const text = `page A {
view {
${opening}
}
}
`;
const items = htmlCompletions(doc(text), positionOf(text, opening));
expect(items.some((item) => item.label === "api")).toBe(true);
});
test('completion inside api="…" quotes offers the page\'s block names for all three call forms', () => {
const bare = `page Search {
apis {
searchUsers POST /api/users {
response { return data.users }
}
}
view { <button api="sear"></button> }
}
`;
const barePosition = isApiAttributeValuePosition(doc(bare), positionOf(bare, 'api="sear'));
expect(barePosition).toBe(true);
expect(apiCallCompletions(bare).map((item) => item.label)).toContain("searchUsers");
const call = `page Search {
apis {
searchUsers POST /api/users {
response { return data.users }
}
}
view { <button api="searchUsers("></button> }
}
`;
expect(isApiAttributeValuePosition(doc(call), positionOf(call, 'api="searchUsers('))).toBe(true);
const callWithArgs = `page Search {
apis {
searchUsers POST /api/users {
request { body { name?: string } }
response { return data.users }
}
}
view { <button api="searchUsers({ name: '"></button> }
}
`;
expect(
isApiAttributeValuePosition(doc(callWithArgs), positionOf(callWithArgs, "{ name: '")),
).toBe(true);
});
test('html completion defers to apiCallCompletions inside api="…" — it does not offer its own value completion', () => {
const items = htmlCompletions(doc(SOURCE), positionOf(SOURCE, 'api="search'));
expect(items).toEqual([]);
});
test("a badly-broken document (unclosed braces, truncated apis block) still answers rather than throwing", () => {
const broken = `page Search {
apis {
searchUsers POST /api/users {
request { body { name?: string
view { <button api="sear"></button>
`;
expect(() => apiCallCompletions(broken)).not.toThrow();
expect(apiCallCompletions(broken)).toEqual([]);
expect(() => documentDiagnostics(doc(broken))).not.toThrow();
expect(() =>
isApiAttributeValuePosition(doc(broken), positionOf(broken, 'api="sear')),
).not.toThrow();
});
test("an ssr {} data block, which the parser now rejects with a ParseError, still answers rather than throwing", () => {
const legacy = `page Search {
ssr { api x GET /api/x { response { return data } } }
view { <button api="x"></button> }
}
`;
expect(() => apiCallCompletions(legacy)).not.toThrow();
expect(apiCallCompletions(legacy)).toEqual([]);
expect(() => documentDiagnostics(doc(legacy))).not.toThrow();
});
@@ -1,5 +1,5 @@
import { expect, test } from "bun:test";
import { apiCallCompletions, apiCallHover } from "../src/server.ts";
import { apiCallCompletions, apiCallHover, isApiReferenceAt } from "../src/server.ts";
const SOURCE = `page Search {
apis {
@@ -71,3 +71,32 @@ test("an ssr {} data block, which the parser now rejects with a ParseError, stil
expect(() => apiCallCompletions(legacy)).not.toThrow();
expect(apiCallCompletions(legacy)).toEqual([]);
});
test("a bare identifier that merely collides with a block name is not an api reference", () => {
// `searchUsers` here is a local variable, not `api.searchUsers` -- hovering it must
// not report the API block's method and path.
const source = `page Search {
apis {
searchUsers POST /api/users {
response { return data.users }
}
}
functions {
client function go() {
const searchUsers = 1
void api.searchUsers({ name: "x" })
return searchUsers
}
}
view { <main>x</main> }
}
`;
const collision = source.indexOf("const searchUsers") + "const ".length;
const reference = source.indexOf("searchUsers POST");
expect(isApiReferenceAt(source, collision)).toBe(false);
expect(isApiReferenceAt(source, source.indexOf("api.searchUsers") + 4)).toBe(true);
expect(reference).toBeGreaterThan(-1);
});
@@ -0,0 +1,120 @@
import { expect, test } from "bun:test";
import { documentDiagnostics } from "../src/index.ts";
// documentDiagnostics is the language server's actual diagnostic entry point.
// The plan's draft guessed a `diagnoseWrn` export that does not exist.
// These two tests are the plan's original assertions, kept verbatim. They
// already passed before this change — the message produced by the parser
// named "apis" from the start — so on their own they are a false-green:
// they would not catch a regression to the fix below. They stay as cheap
// regression guards, not as the load-bearing coverage for this task.
test("an ssr data block is flagged and names the replacement", () => {
const diagnostics = documentDiagnostics({
uri: "file:///removed-ssr.wrn",
text: `page P {
ssr { api x GET /api/x { response { return data } } }
view { <main>x</main> }
}
`,
});
expect(diagnostics.length).toBeGreaterThan(0);
expect(diagnostics[0]!.message).toContain("apis");
});
test("client state is not flagged", () => {
const diagnostics = documentDiagnostics({
uri: "file:///client-state.wrn",
text: `page P {
client state { count = 0 }
view { <main>x</main> }
}
`,
});
expect(diagnostics.filter((item) => item.severity === 1)).toEqual([]);
});
// --- Load-bearing tests -----------------------------------------------
//
// The message alone doesn't prove the diagnostic is useful: before the fix,
// documentDiagnostics reported this error at line 0 / character 0 for every
// input, no matter where the offending block actually was. These tests pin
// the range to the real position of the "ssr" / "client" keyword, with the
// block moved off line 1 so a hardcoded line number cannot pass.
test("an ssr block diagnostic lands on the ssr keyword, not offset zero", () => {
const diagnostics = documentDiagnostics({
uri: "file:///removed-ssr-position.wrn",
text: `page P {
ssr { api x GET /api/x { response { return data } } }
view { <main>x</main> }
}
`,
});
expect(diagnostics.length).toBeGreaterThan(0);
// " ssr { ... }" — the keyword starts at line 1 (0-indexed), character 2.
expect(diagnostics[0]!.range.start).toEqual({ line: 1, character: 2 });
});
test("a client block diagnostic lands on the client keyword when it is not on line 1", () => {
const diagnostics = documentDiagnostics({
uri: "file:///removed-client-position.wrn",
text: `page P {
view { <main>x</main> }
client { api x GET /api/x { response { return data } } }
}
`,
});
expect(diagnostics.length).toBeGreaterThan(0);
// The client block is on line 2 (0-indexed) here, preceded by "view { ... }"
// on line 1 — a diagnostic hardcoded to line 1 would fail this assertion.
expect(diagnostics[0]!.range.start).toEqual({ line: 2, character: 2 });
});
// --- Legal `client` forms stay legal ------------------------------------
//
// `client` is one word with several jobs. Only the `ssr { }` / `client { }`
// data-block form was removed; these forms must never be flagged.
test("client state block produces no error-severity diagnostic", () => {
const diagnostics = documentDiagnostics({
uri: "file:///client-state-2.wrn",
text: `page P {
client state { count = 0 }
view { <main>x</main> }
}
`,
});
expect(diagnostics.filter((item) => item.severity === 1)).toEqual([]);
});
test('runtime = "client" produces no error-severity diagnostic', () => {
const diagnostics = documentDiagnostics({
uri: "file:///runtime-client.wrn",
text: `page P {
runtime = "client"
view { <main>x</main> }
}
`,
});
expect(diagnostics.filter((item) => item.severity === 1)).toEqual([]);
});
test("client function inside functions { } produces no error-severity diagnostic", () => {
const diagnostics = documentDiagnostics({
uri: "file:///client-function.wrn",
text: `page P {
functions { client function go() { console.log(1) } }
view { <main>x</main> }
}
`,
});
expect(diagnostics.filter((item) => item.severity === 1)).toEqual([]);
});
+1 -1
View File
@@ -685,7 +685,7 @@ export function parse(source: string): PageAst {
}
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 … }".`,
`"${rawMode} { … }" data blocks were removed. Declare API calls in a page-level "apis { }" block, and move mode-scoped helpers into "functions { shared function … }" (at offset ${kw.pos}).`,
);
}
throw new ParseError(`Expected a ${rawMode} state block or hydrate assignment`);
+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);
});