feat(examples): worked example for typed api blocks
- Add examples/basic-app/app/api/directory.ts and app/pages/api-block-demo.wrn as the end-to-end worked example for typed api blocks (Task 6). - Add **/*.generated.api-checks.ts to .prettierignore: this generated file must match the CLI's raw output byte-for-byte for check:generated-types, and prettier was reformatting it. - Fix packages/csr/test/api-call.test.ts: no-unsafe-function-type lint error from the raw Function type, uncovered while running the full gate. - Rebuild editors/vscode bundles (packages/compiler and packages/syntax changed in Tasks 1-5). - Regenerate docs/public-api-0.8.json (additive: isIdentPart, isIdentStart, skipLiteralOrComment newly exported from packages/syntax). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -13,6 +13,7 @@ bun.lockb
|
||||
# Generated code (queries.gen.ts, routes.gen.ts, etc.)
|
||||
**/*.gen.ts
|
||||
**/*.generated.d.ts
|
||||
**/*.generated.api-checks.ts
|
||||
|
||||
# Bundled .wrn compiler for the VS Code extension (generated)
|
||||
editors/vscode/src/compiler.cjs
|
||||
|
||||
@@ -2854,7 +2854,10 @@
|
||||
"LexError",
|
||||
"Lexer",
|
||||
"Token",
|
||||
"TokenType"
|
||||
"TokenType",
|
||||
"isIdentPart",
|
||||
"isIdentStart",
|
||||
"skipLiteralOrComment"
|
||||
],
|
||||
"./types": [
|
||||
"RuntimeType",
|
||||
|
||||
+301
-40
@@ -1,6 +1,6 @@
|
||||
"use strict";
|
||||
// Generated by scripts/build-editor-compiler.mjs. Do not edit directly.
|
||||
// WRN editor compiler source hash: f474a2710a8861a4e863af13954e202e41a94b160c43c20297a7dfdd94b9ea8b
|
||||
// WRN editor compiler source hash: 38942756eaa627215931b5268592f6e0825f8a8011a25ee7d532f2547176757e
|
||||
// WRN editor compiler generator hash: a54ca847c758bc98d8e353ad6d70088df31de1820f6cf9d1c3462505f563e6b8
|
||||
// Generated with TypeScript: 6.0.3
|
||||
const __nodeRequire = require;
|
||||
@@ -511,6 +511,7 @@ const RUNTIME_BINDINGS = new Set([
|
||||
"server",
|
||||
"props",
|
||||
"refs",
|
||||
"api",
|
||||
"event",
|
||||
"payload",
|
||||
]);
|
||||
@@ -711,6 +712,27 @@ function _functionEntry(ast, fn, availableFunctions) {
|
||||
}
|
||||
}`;
|
||||
}
|
||||
/**
|
||||
* Client-mode api blocks become members of an `api` object in client scope.
|
||||
*
|
||||
* Only the response and error bodies are emitted; the declared field types are
|
||||
* type-only and are consumed by the types generator instead. Anything
|
||||
* TypeScript reaching this module would be a syntax error in the .mjs artifact.
|
||||
*/
|
||||
function apiBindings(ast) {
|
||||
const members = ast.dataApis
|
||||
.filter((block) => block.mode === "client" && block.sections)
|
||||
.map((block) => {
|
||||
const sections = block.sections;
|
||||
const response = sections.response.trim() || "return data;";
|
||||
const error = sections.error.trim();
|
||||
const failure = error
|
||||
? `(error) => { const status = error.status; const message = error.message; const data = error.data; ${error} }`
|
||||
: `(error) => { throw error; }`;
|
||||
return ` ${JSON.stringify(block.name)}: async (input) => context.callApi(${JSON.stringify(block.path)}, ${JSON.stringify(block.method)}, input).then((data) => { ${response} }).catch(${failure})`;
|
||||
});
|
||||
return members.length ? `const api = {\n${members.join(",\n")}\n };` : "";
|
||||
}
|
||||
function generateBrowserModule(ast) {
|
||||
const functions = ast.runtimeFunctions.filter((fn) => ["legacy", "client", "shared"].includes(fn.runtime));
|
||||
const functionNames = functions.map((fn) => fn.name);
|
||||
@@ -758,6 +780,7 @@ function __wrnexusCreateClientFunctions(context) {
|
||||
const server = context.server;
|
||||
const props = context.props;
|
||||
const refs = context.refs;
|
||||
${apiBindings(ast)}
|
||||
const __wrnexusCommit = () => { ${sharedCommit} };
|
||||
const __wrnexusRestore = () => { ${sharedRestore} };
|
||||
${!hasAuthoredCommit ? "const commit = __wrnexusCommit;" : ""}
|
||||
@@ -1489,6 +1512,7 @@ function renderBinding(binding) {
|
||||
path: binding.path,
|
||||
body: binding.body,
|
||||
helpers: binding.helpers,
|
||||
...(binding.errorBody ? { errorBody: binding.errorBody } : {}),
|
||||
};
|
||||
}
|
||||
function hasClientBehavior(nodes) {
|
||||
@@ -1545,18 +1569,28 @@ function apiBindingMap(ast, sharedHelpers) {
|
||||
if (bindings.has(block.name)) {
|
||||
throw new Error(`Duplicate .wrn api binding "${block.name}"`);
|
||||
}
|
||||
const sectioned = block.sections;
|
||||
const errorSection = sectioned?.error.trim();
|
||||
bindings.set(block.name, {
|
||||
mode: block.mode,
|
||||
method: block.method,
|
||||
path: apiRoutePath(block.path),
|
||||
body: dataBody(block.body),
|
||||
// A sectioned block binds the payload to `data`; the legacy form keeps
|
||||
// the `with ($data)` injection, which cannot be typed.
|
||||
body: sectioned
|
||||
? `const data = $data; ${sectioned.response.trim() || "return data;"}`
|
||||
: dataBody(block.body),
|
||||
// Only a sectioned block with a non-empty `error {}` gets a fallback —
|
||||
// legacy blocks and sectioned blocks without `error` keep failures
|
||||
// propagating exactly as before.
|
||||
...(errorSection ? { errorBody: errorSection } : {}),
|
||||
helpers: modeHelpers(ast, block.mode, sharedHelpers),
|
||||
});
|
||||
}
|
||||
return bindings;
|
||||
}
|
||||
function ssrRuntimeSource() {
|
||||
return `const __wrnexusHtmlEscapes = { "&": "&", "<": "<", ">": ">", "\\"": """, "'": "'" };
|
||||
return `const __wrnexusHtmlEscapes: Record<string, string> = { "&": "&", "<": "<", ">": ">", "\\"": """, "'": "'" };
|
||||
function __wrnexusEscapeHtml(value: unknown): string {
|
||||
return String(value).replace(/[&<>"']/g, (ch) => __wrnexusHtmlEscapes[ch] ?? ch);
|
||||
}
|
||||
@@ -1575,6 +1609,18 @@ function __wrnexusEvalData(data: unknown, body: string, helpers = "", ctx: __Wrn
|
||||
return new Function("$data", "$adapters", "const cookies = $adapters.cookies;\\nconst session = $adapters.session;\\nconst localStorage = $adapters.localStorage;\\nwith ($data ?? {}) {\\n" + helpers + "\\n" + body + "\\n}")(data, adapters);
|
||||
}
|
||||
|
||||
function __wrnexusEvalError(err: unknown, body: string, helpers = "", ctx: __WrnexusContext): unknown {
|
||||
const adapters = {
|
||||
cookies: ctx.cookies,
|
||||
session: ctx.session,
|
||||
localStorage: ctx.localStorage,
|
||||
};
|
||||
const status = (err as { status?: unknown } | null | undefined)?.status;
|
||||
const data = (err as { data?: unknown } | null | undefined)?.data;
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
return new Function("$status", "$message", "$data", "$adapters", "const cookies = $adapters.cookies;\\nconst session = $adapters.session;\\nconst localStorage = $adapters.localStorage;\\nconst status = $status;\\nconst message = $message;\\nconst data = $data;\\n" + helpers + "\\n" + body)(status, message, data, adapters);
|
||||
}
|
||||
|
||||
function __wrnexusPropAttr(
|
||||
value: unknown,
|
||||
): string {
|
||||
@@ -1604,18 +1650,52 @@ async function __wrnexusCallApi(path: string, method: string, ctx: __WrnexusCont
|
||||
|
||||
const url = new URL(path, ctx.req.url);
|
||||
const res = await fetch(new Request(url, { method, headers: ctx.req.headers }));
|
||||
const type = res.headers.get("content-type") || "";
|
||||
if (!res.ok) {
|
||||
throw new Error(".wrn data API request failed with status " + res.status);
|
||||
const data = type.includes("application/json")
|
||||
? await res.json().catch(() => undefined)
|
||||
: await res.text().catch(() => undefined);
|
||||
throw Object.assign(new Error(".wrn data API request failed with status " + res.status), {
|
||||
status: res.status,
|
||||
data,
|
||||
});
|
||||
}
|
||||
|
||||
const type = res.headers.get("content-type") || "";
|
||||
return type.includes("application/json") ? await res.json() : await res.text();
|
||||
}
|
||||
|
||||
type __WrnexusApiCall = {
|
||||
path: string;
|
||||
method: string;
|
||||
body: string;
|
||||
helpers: string;
|
||||
errorBody?: string;
|
||||
};
|
||||
|
||||
type __WrnexusSsrBinding = __WrnexusApiCall & { marker: string };
|
||||
|
||||
// Shared by every ssr api-binding consumption site (marker replacement,
|
||||
// #each loop consts, ...) so the narrow try/catch -- only active when the
|
||||
// block declared an error section -- cannot drift between call sites.
|
||||
async function __wrnexusResolveApiBinding(
|
||||
binding: __WrnexusApiCall,
|
||||
ctx: __WrnexusContext,
|
||||
): Promise<unknown> {
|
||||
if (binding.errorBody) {
|
||||
try {
|
||||
const data = await __wrnexusCallApi(binding.path, binding.method, ctx);
|
||||
return __wrnexusEvalData(data, binding.body, binding.helpers, ctx);
|
||||
} catch (err) {
|
||||
return __wrnexusEvalError(err, binding.errorBody, binding.helpers, ctx);
|
||||
}
|
||||
}
|
||||
const data = await __wrnexusCallApi(binding.path, binding.method, ctx);
|
||||
return __wrnexusEvalData(data, binding.body, binding.helpers, ctx);
|
||||
}
|
||||
|
||||
async function __wrnexusRenderSsrBindings(html: string, ctx: __WrnexusContext): Promise<string> {
|
||||
for (const binding of __wrnexusSsrBindings) {
|
||||
const data = await __wrnexusCallApi(binding.path, binding.method, ctx);
|
||||
const value = __wrnexusEvalData(data, binding.body, binding.helpers, ctx);
|
||||
const value = await __wrnexusResolveApiBinding(binding, ctx);
|
||||
html = html.replace(binding.marker, __wrnexusEscapeHtml(value));
|
||||
}
|
||||
return html;
|
||||
@@ -2046,13 +2126,16 @@ function generateInner(ast) {
|
||||
continue;
|
||||
if (!lists.some((expr) => new RegExp(`\\b${name}\\b`).test(expr)))
|
||||
continue;
|
||||
loopConsts.push(` const ${name} = __wrnexusEvalData(await __wrnexusCallApi(${JSON.stringify(binding.path)}, ${JSON.stringify(binding.method)}, ctx), ${JSON.stringify(binding.body)}, ${JSON.stringify(binding.helpers)}, ctx);`);
|
||||
const errorBodyProp = binding.errorBody
|
||||
? `, errorBody: ${JSON.stringify(binding.errorBody)}`
|
||||
: "";
|
||||
loopConsts.push(` const ${name} = await __wrnexusResolveApiBinding({ path: ${JSON.stringify(binding.path)}, method: ${JSON.stringify(binding.method)}, body: ${JSON.stringify(binding.body)}, helpers: ${JSON.stringify(binding.helpers)}${errorBodyProp} }, ctx);`);
|
||||
}
|
||||
}
|
||||
const needsSsrRuntime = ssrBindings.length > 0 || loops.length > 0 || runtimeStateNames.size > 0;
|
||||
if (needsSsrRuntime) {
|
||||
out.push(ssrRuntimeSource());
|
||||
out.push(`const __wrnexusSsrBindings = ${JSON.stringify(ssrBindings, null, 2)};`);
|
||||
out.push(`const __wrnexusSsrBindings: __WrnexusSsrBinding[] = ${JSON.stringify(ssrBindings, null, 2)};`);
|
||||
const decls = loopConsts.length > 0 ? loopConsts.join("\n") + "\n" : "";
|
||||
out.push(`export default async function ${ast.name}(ctx: __WrnexusContext) {
|
||||
${storeDeclarations}
|
||||
@@ -4627,6 +4710,151 @@ Object.defineProperty(exports, "__esModule", { value: true });
|
||||
/** @deprecated Import language type utilities from @wrnexus/syntax. */
|
||||
__exportStar(require("@wrnexus/syntax/types"), exports);
|
||||
|
||||
},
|
||||
"packages/syntax/src/api-sections.ts": function (module, exports, require, __filename, __dirname) {
|
||||
"use strict";
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.parseApiSections = parseApiSections;
|
||||
exports.hasRequestSection = hasRequestSection;
|
||||
/**
|
||||
* Parse the sectioned form of an `api` block body.
|
||||
*
|
||||
* Returns null when no section keyword is present, which is how the legacy
|
||||
* bare-body form stays valid: the caller keeps treating the body as the
|
||||
* response expression.
|
||||
*
|
||||
* Detection and slicing both drive the tokenizer's own string/comment-aware
|
||||
* scanning (`skipLiteralOrComment`, `Lexer.readBalancedBraces`) instead of a
|
||||
* second hand-rolled brace counter, so a `}` inside a string or a `request {`
|
||||
* mentioned in a comment can't be mistaken for a real section.
|
||||
*/
|
||||
const tokenizer_ts_1 = require("./tokenizer.js");
|
||||
const SECTION_NAMES = ["request", "response", "error"];
|
||||
const REQUEST_SUBSECTION_NAMES = ["parameters", "body"];
|
||||
/**
|
||||
* Walk `source` at brace-depth 0, looking for `name { ... }` where `name` is
|
||||
* one of `names`. Strings, template literals, and comments are skipped via
|
||||
* `skipLiteralOrComment` — the same rules `readBalancedBraces` uses — so a
|
||||
* keyword mentioned inside a string or comment, or nested inside an unrelated
|
||||
* `{ }` (e.g. an object literal in a legacy body), is never mistaken for a
|
||||
* section. Matched blocks are sliced via `Lexer.readBalancedBraces()` itself,
|
||||
* not a reimplementation of it.
|
||||
*/
|
||||
function scanTopLevelBlocks(source, names) {
|
||||
const found = new Map();
|
||||
const lx = new tokenizer_ts_1.Lexer(source);
|
||||
let depth = 0;
|
||||
let i = 0;
|
||||
let atLineStart = true;
|
||||
while (i < source.length) {
|
||||
const c = source[i];
|
||||
if (c === "\n") {
|
||||
atLineStart = true;
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
const skipped = (0, tokenizer_ts_1.skipLiteralOrComment)(source, i, atLineStart);
|
||||
if (skipped !== null) {
|
||||
i = skipped;
|
||||
atLineStart = false;
|
||||
continue;
|
||||
}
|
||||
if (c !== " " && c !== "\t" && c !== "\r")
|
||||
atLineStart = false;
|
||||
if (depth === 0 && (0, tokenizer_ts_1.isIdentStart)(c)) {
|
||||
let j = i + 1;
|
||||
while (j < source.length && (0, tokenizer_ts_1.isIdentPart)(source[j]))
|
||||
j++;
|
||||
const word = source.slice(i, j);
|
||||
// Skip trivia between the identifier and a possible '{' without
|
||||
// treating anything in between as significant yet.
|
||||
let k = j;
|
||||
let lineStartAtK = false;
|
||||
while (k < source.length) {
|
||||
const kc = source[k];
|
||||
if (kc === " " || kc === "\t" || kc === "\r") {
|
||||
k++;
|
||||
continue;
|
||||
}
|
||||
if (kc === "\n") {
|
||||
lineStartAtK = true;
|
||||
k++;
|
||||
continue;
|
||||
}
|
||||
const kSkipped = (0, tokenizer_ts_1.skipLiteralOrComment)(source, k, lineStartAtK);
|
||||
if (kSkipped !== null) {
|
||||
k = kSkipped;
|
||||
lineStartAtK = false;
|
||||
continue;
|
||||
}
|
||||
break;
|
||||
}
|
||||
if (names.includes(word) && source[k] === "{") {
|
||||
lx.pos = k;
|
||||
const start = k + 1;
|
||||
const text = lx.readBalancedBraces();
|
||||
if (!found.has(word))
|
||||
found.set(word, { text, start });
|
||||
i = lx.pos;
|
||||
continue;
|
||||
}
|
||||
i = j;
|
||||
continue;
|
||||
}
|
||||
if (c === "{")
|
||||
depth++;
|
||||
else if (c === "}")
|
||||
depth = Math.max(0, depth - 1);
|
||||
i++;
|
||||
}
|
||||
return found;
|
||||
}
|
||||
/** Rebase a span captured from `outer.text` back onto the original source. */
|
||||
function absolutize(span, outer) {
|
||||
if (!span)
|
||||
return undefined;
|
||||
return outer ? { text: span.text, start: outer.start + span.start } : span;
|
||||
}
|
||||
/** `name?: string` -> { name, optional, type }. Blank lines and comments are skipped. */
|
||||
function parseFields(span) {
|
||||
if (!span)
|
||||
return [];
|
||||
const fields = [];
|
||||
let cursor = 0;
|
||||
for (const rawLine of span.text.split("\n")) {
|
||||
const lineOffset = span.start + cursor;
|
||||
cursor += rawLine.length + 1;
|
||||
const line = rawLine.trim().replace(/,$/, "");
|
||||
if (!line || line.startsWith("//"))
|
||||
continue;
|
||||
const match = /^([A-Za-z_$][A-Za-z0-9_$]*)(\?)?\s*:\s*(.+)$/.exec(line);
|
||||
if (!match) {
|
||||
throw new tokenizer_ts_1.LexError(`Expected "name: type" in an api request section, got "${line}" at offset ${lineOffset}`);
|
||||
}
|
||||
fields.push({ name: match[1], optional: match[2] === "?", type: match[3].trim() });
|
||||
}
|
||||
return fields;
|
||||
}
|
||||
function parseApiSections(source) {
|
||||
const top = scanTopLevelBlocks(source, SECTION_NAMES);
|
||||
if (top.size === 0)
|
||||
return null;
|
||||
const request = top.get("request");
|
||||
const sub = request
|
||||
? scanTopLevelBlocks(request.text, REQUEST_SUBSECTION_NAMES)
|
||||
: new Map();
|
||||
return {
|
||||
parameters: parseFields(absolutize(sub.get("parameters"), request)),
|
||||
body: parseFields(absolutize(sub.get("body"), request)),
|
||||
response: top.get("response")?.text ?? "",
|
||||
error: top.get("error")?.text ?? "",
|
||||
};
|
||||
}
|
||||
/** True when the block declares a `request` section. */
|
||||
function hasRequestSection(source) {
|
||||
return scanTopLevelBlocks(source, SECTION_NAMES).has("request");
|
||||
}
|
||||
|
||||
},
|
||||
"packages/syntax/src/diagnostics.ts": function (module, exports, require, __filename, __dirname) {
|
||||
"use strict";
|
||||
@@ -5975,6 +6203,7 @@ exports.ParseError = exports.VOID_ELEMENTS = void 0;
|
||||
exports.parse = parse;
|
||||
exports.parseHtmlView = parseHtmlView;
|
||||
const spec_ts_1 = require("./spec.js");
|
||||
const api_sections_ts_1 = require("./api-sections.js");
|
||||
/**
|
||||
* Recursive-descent parser for `.wrn`, producing a small AST.
|
||||
*
|
||||
@@ -6447,7 +6676,18 @@ function parse(source) {
|
||||
const method = expect("ident").value.toUpperCase();
|
||||
const path = lx.readPath();
|
||||
const body = lx.readBalancedBraces();
|
||||
dataApis.push({ mode, name, method, path, body });
|
||||
const sections = (0, api_sections_ts_1.parseApiSections)(body);
|
||||
if (sections && mode !== "client" && (0, api_sections_ts_1.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": {
|
||||
@@ -7082,13 +7322,55 @@ exports.WRN_DIAGNOSTIC_CODES = {
|
||||
* `readToLineEnd()` for the parser to grab raw spans when grammar demands it.
|
||||
*/
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.Lexer = exports.LexError = void 0;
|
||||
exports.Lexer = exports.isIdentPart = exports.isIdentStart = exports.LexError = void 0;
|
||||
exports.skipLiteralOrComment = skipLiteralOrComment;
|
||||
class LexError extends Error {
|
||||
}
|
||||
exports.LexError = LexError;
|
||||
const isWs = (c) => c === " " || c === "\t" || c === "\n" || c === "\r";
|
||||
const isIdentStart = (c) => /[A-Za-z_]/.test(c);
|
||||
exports.isIdentStart = isIdentStart;
|
||||
const isIdentPart = (c) => /[A-Za-z0-9_]/.test(c);
|
||||
exports.isIdentPart = isIdentPart;
|
||||
/**
|
||||
* Skip over a string/template literal or comment starting at `src[i]`, using
|
||||
* the exact rules `readBalancedBraces` needs to stay comment- and
|
||||
* string-aware: `/* block *\/` comments anywhere, `//` line comments only at
|
||||
* the start of a line (so a bare `https://…` in view text isn't mistaken for
|
||||
* one), and `"`, `'`, `` ` `` strings with backslash escapes.
|
||||
*
|
||||
* Returns the index just past what it skipped, or `null` when `src[i]` isn't
|
||||
* the start of one of those. Exported so any other raw-body scanner that
|
||||
* needs to walk `.wrn` source without tripping over strings or comments
|
||||
* (e.g. the `api` section scanner) shares this logic instead of
|
||||
* reimplementing it — a second hand-rolled scanner is how apostrophes in
|
||||
* prose used to swallow braces.
|
||||
*/
|
||||
function skipLiteralOrComment(src, i, atLineStart) {
|
||||
const c = src[i];
|
||||
if (c === "/" && src[i + 1] === "*") {
|
||||
const close = src.indexOf("*/", i + 2);
|
||||
return close === -1 ? src.length : close + 2;
|
||||
}
|
||||
if (atLineStart && c === "/" && src[i + 1] === "/") {
|
||||
const newline = src.indexOf("\n", i + 2);
|
||||
return newline === -1 ? src.length : newline;
|
||||
}
|
||||
if (c === '"' || c === "'" || c === "`") {
|
||||
let j = i + 1;
|
||||
while (j < src.length) {
|
||||
if (src[j] === "\\") {
|
||||
j += 2;
|
||||
continue;
|
||||
}
|
||||
if (src[j] === c)
|
||||
return j + 1;
|
||||
j++;
|
||||
}
|
||||
return src.length;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
class Lexer {
|
||||
src;
|
||||
pos = 0;
|
||||
@@ -7157,9 +7439,9 @@ class Lexer {
|
||||
case "'":
|
||||
return this.readString(c, pos);
|
||||
}
|
||||
if (isIdentStart(c)) {
|
||||
if ((0, exports.isIdentStart)(c)) {
|
||||
let v = "";
|
||||
while (this.pos < src.length && isIdentPart(src[this.pos]))
|
||||
while (this.pos < src.length && (0, exports.isIdentPart)(src[this.pos]))
|
||||
v += src[this.pos++];
|
||||
return { type: "ident", value: v, pos };
|
||||
}
|
||||
@@ -7379,45 +7661,23 @@ class Lexer {
|
||||
const start = this.pos + 1;
|
||||
let depth = 0;
|
||||
let i = this.pos;
|
||||
let str = null;
|
||||
/** True while only whitespace has been seen since the last newline. */
|
||||
let atLineStart = false;
|
||||
for (; i < src.length; i++) {
|
||||
while (i < src.length) {
|
||||
const c = src[i];
|
||||
if (str) {
|
||||
if (c === "\\") {
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
if (c === str)
|
||||
str = null;
|
||||
continue;
|
||||
}
|
||||
if (c === "\n") {
|
||||
atLineStart = true;
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
if (c === "/" && src[i + 1] === "*") {
|
||||
const close = src.indexOf("*/", i + 2);
|
||||
if (close === -1)
|
||||
break; // unterminated: fall through to the error
|
||||
i = close + 1;
|
||||
const skipped = skipLiteralOrComment(src, i, atLineStart);
|
||||
if (skipped !== null) {
|
||||
i = skipped;
|
||||
atLineStart = false;
|
||||
continue;
|
||||
}
|
||||
if (atLineStart && c === "/" && src[i + 1] === "/") {
|
||||
const newline = src.indexOf("\n", i + 2);
|
||||
if (newline === -1)
|
||||
break;
|
||||
i = newline - 1; // let the loop's own increment land on the newline
|
||||
continue;
|
||||
}
|
||||
if (c !== " " && c !== "\t" && c !== "\r")
|
||||
atLineStart = false;
|
||||
if (c === '"' || c === "'" || c === "`") {
|
||||
str = c;
|
||||
continue;
|
||||
}
|
||||
if (c === "{")
|
||||
depth++;
|
||||
else if (c === "}") {
|
||||
@@ -7427,6 +7687,7 @@ class Lexer {
|
||||
return src.slice(start, i);
|
||||
}
|
||||
}
|
||||
i++;
|
||||
}
|
||||
throw new LexError(`Unbalanced braces starting at offset ${this.pos}`);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// WRN editor extension source hash: 0a678785f34a594b64f06ca6e39cf7bea49127ddf344d585e52cc1b3eda9a82a
|
||||
// WRN editor extension source hash: 7ecb4672607b87fdb848f1b52e80430129a5bfda31c9724e14595e4acc1fb1b7
|
||||
// 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: afce4195f664e656dc8f38283bda8118637d977cd2eee8ba08c80906681510cd
|
||||
// WRN editor language server source hash: cf98947fd66392200bdfb18524a13e8bbb77d4920ba65a4d724e1124152571a3
|
||||
// WRN editor language server generator hash: f593a44aaf05495b789ce7a3086bee1eebb951b884d41c0e017bbcfe5f547e72
|
||||
// @bun @bun-cjs
|
||||
(function(exports, require, module, __filename, __dirname) {var __create = Object.create;
|
||||
@@ -169631,6 +169631,32 @@ var isWs = (c) => c === " " || c === "\t" || c === `
|
||||
` || c === "\r";
|
||||
var isIdentStart = (c) => /[A-Za-z_]/.test(c);
|
||||
var isIdentPart = (c) => /[A-Za-z0-9_]/.test(c);
|
||||
function skipLiteralOrComment(src, i, atLineStart) {
|
||||
const c = src[i];
|
||||
if (c === "/" && src[i + 1] === "*") {
|
||||
const close = src.indexOf("*/", i + 2);
|
||||
return close === -1 ? src.length : close + 2;
|
||||
}
|
||||
if (atLineStart && c === "/" && src[i + 1] === "/") {
|
||||
const newline = src.indexOf(`
|
||||
`, i + 2);
|
||||
return newline === -1 ? src.length : newline;
|
||||
}
|
||||
if (c === '"' || c === "'" || c === "`") {
|
||||
let j = i + 1;
|
||||
while (j < src.length) {
|
||||
if (src[j] === "\\") {
|
||||
j += 2;
|
||||
continue;
|
||||
}
|
||||
if (src[j] === c)
|
||||
return j + 1;
|
||||
j++;
|
||||
}
|
||||
return src.length;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
class Lexer {
|
||||
src;
|
||||
@@ -169892,46 +169918,23 @@ class Lexer {
|
||||
const start = this.pos + 1;
|
||||
let depth = 0;
|
||||
let i = this.pos;
|
||||
let str = null;
|
||||
let atLineStart = false;
|
||||
for (;i < src.length; i++) {
|
||||
while (i < src.length) {
|
||||
const c = src[i];
|
||||
if (str) {
|
||||
if (c === "\\") {
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
if (c === str)
|
||||
str = null;
|
||||
continue;
|
||||
}
|
||||
if (c === `
|
||||
`) {
|
||||
atLineStart = true;
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
if (c === "/" && src[i + 1] === "*") {
|
||||
const close = src.indexOf("*/", i + 2);
|
||||
if (close === -1)
|
||||
break;
|
||||
i = close + 1;
|
||||
const skipped = skipLiteralOrComment(src, i, atLineStart);
|
||||
if (skipped !== null) {
|
||||
i = skipped;
|
||||
atLineStart = false;
|
||||
continue;
|
||||
}
|
||||
if (atLineStart && c === "/" && src[i + 1] === "/") {
|
||||
const newline = src.indexOf(`
|
||||
`, i + 2);
|
||||
if (newline === -1)
|
||||
break;
|
||||
i = newline - 1;
|
||||
continue;
|
||||
}
|
||||
if (c !== " " && c !== "\t" && c !== "\r")
|
||||
atLineStart = false;
|
||||
if (c === '"' || c === "'" || c === "`") {
|
||||
str = c;
|
||||
continue;
|
||||
}
|
||||
if (c === "{")
|
||||
depth++;
|
||||
else if (c === "}") {
|
||||
@@ -169941,6 +169944,7 @@ class Lexer {
|
||||
return src.slice(start, i);
|
||||
}
|
||||
}
|
||||
i++;
|
||||
}
|
||||
throw new LexError(`Unbalanced braces starting at offset ${this.pos}`);
|
||||
}
|
||||
@@ -170752,6 +170756,120 @@ var WRN_DIAGNOSTIC_CODES = {
|
||||
migration: "WRN-MIGRATION-001"
|
||||
};
|
||||
|
||||
// packages/syntax/src/api-sections.ts
|
||||
var SECTION_NAMES = ["request", "response", "error"];
|
||||
var REQUEST_SUBSECTION_NAMES = ["parameters", "body"];
|
||||
function scanTopLevelBlocks(source, names) {
|
||||
const found = new Map;
|
||||
const lx = new Lexer(source);
|
||||
let depth = 0;
|
||||
let i = 0;
|
||||
let atLineStart = true;
|
||||
while (i < source.length) {
|
||||
const c = source[i];
|
||||
if (c === `
|
||||
`) {
|
||||
atLineStart = true;
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
const skipped = skipLiteralOrComment(source, i, atLineStart);
|
||||
if (skipped !== null) {
|
||||
i = skipped;
|
||||
atLineStart = false;
|
||||
continue;
|
||||
}
|
||||
if (c !== " " && c !== "\t" && c !== "\r")
|
||||
atLineStart = false;
|
||||
if (depth === 0 && isIdentStart(c)) {
|
||||
let j = i + 1;
|
||||
while (j < source.length && isIdentPart(source[j]))
|
||||
j++;
|
||||
const word = source.slice(i, j);
|
||||
let k = j;
|
||||
let lineStartAtK = false;
|
||||
while (k < source.length) {
|
||||
const kc = source[k];
|
||||
if (kc === " " || kc === "\t" || kc === "\r") {
|
||||
k++;
|
||||
continue;
|
||||
}
|
||||
if (kc === `
|
||||
`) {
|
||||
lineStartAtK = true;
|
||||
k++;
|
||||
continue;
|
||||
}
|
||||
const kSkipped = skipLiteralOrComment(source, k, lineStartAtK);
|
||||
if (kSkipped !== null) {
|
||||
k = kSkipped;
|
||||
lineStartAtK = false;
|
||||
continue;
|
||||
}
|
||||
break;
|
||||
}
|
||||
if (names.includes(word) && source[k] === "{") {
|
||||
lx.pos = k;
|
||||
const start = k + 1;
|
||||
const text = lx.readBalancedBraces();
|
||||
if (!found.has(word))
|
||||
found.set(word, { text, start });
|
||||
i = lx.pos;
|
||||
continue;
|
||||
}
|
||||
i = j;
|
||||
continue;
|
||||
}
|
||||
if (c === "{")
|
||||
depth++;
|
||||
else if (c === "}")
|
||||
depth = Math.max(0, depth - 1);
|
||||
i++;
|
||||
}
|
||||
return found;
|
||||
}
|
||||
function absolutize(span, outer) {
|
||||
if (!span)
|
||||
return;
|
||||
return outer ? { text: span.text, start: outer.start + span.start } : span;
|
||||
}
|
||||
function parseFields(span) {
|
||||
if (!span)
|
||||
return [];
|
||||
const fields = [];
|
||||
let cursor = 0;
|
||||
for (const rawLine of span.text.split(`
|
||||
`)) {
|
||||
const lineOffset = span.start + cursor;
|
||||
cursor += rawLine.length + 1;
|
||||
const line = rawLine.trim().replace(/,$/, "");
|
||||
if (!line || line.startsWith("//"))
|
||||
continue;
|
||||
const match = /^([A-Za-z_$][A-Za-z0-9_$]*)(\?)?\s*:\s*(.+)$/.exec(line);
|
||||
if (!match) {
|
||||
throw new LexError(`Expected "name: type" in an api request section, got "${line}" at offset ${lineOffset}`);
|
||||
}
|
||||
fields.push({ name: match[1], optional: match[2] === "?", type: match[3].trim() });
|
||||
}
|
||||
return fields;
|
||||
}
|
||||
function parseApiSections(source) {
|
||||
const top = scanTopLevelBlocks(source, SECTION_NAMES);
|
||||
if (top.size === 0)
|
||||
return null;
|
||||
const request = top.get("request");
|
||||
const sub = request ? scanTopLevelBlocks(request.text, REQUEST_SUBSECTION_NAMES) : new Map;
|
||||
return {
|
||||
parameters: parseFields(absolutize(sub.get("parameters"), request)),
|
||||
body: parseFields(absolutize(sub.get("body"), request)),
|
||||
response: top.get("response")?.text ?? "",
|
||||
error: top.get("error")?.text ?? ""
|
||||
};
|
||||
}
|
||||
function hasRequestSection(source) {
|
||||
return scanTopLevelBlocks(source, SECTION_NAMES).has("request");
|
||||
}
|
||||
|
||||
// packages/syntax/src/types.ts
|
||||
function runtimeTypeOf(annotation) {
|
||||
if (!annotation)
|
||||
@@ -171632,7 +171750,18 @@ function parse(source) {
|
||||
const method = expect("ident").value.toUpperCase();
|
||||
const path = lx.readPath();
|
||||
const body = lx.readBalancedBraces();
|
||||
dataApis.push({ mode, name: name2, method, path, body });
|
||||
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: name2,
|
||||
method,
|
||||
path,
|
||||
body: sections ? "" : body,
|
||||
...sections ? { sections } : {}
|
||||
});
|
||||
break;
|
||||
}
|
||||
case "functions": {
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
import { defineEndpoint } from "@wrnexus/core";
|
||||
import type { Context } from "@wrnexus/core";
|
||||
|
||||
const ALL = [
|
||||
{ name: "Ajay", designation: "UI" },
|
||||
{ name: "Asha", designation: "Backend" },
|
||||
{ name: "Chen", designation: "UI" },
|
||||
];
|
||||
|
||||
export const POST = async (ctx: Context) => {
|
||||
const body = (await ctx.req.json().catch(() => ({}))) as { name?: string };
|
||||
const needle = String(body.name ?? "").toLowerCase();
|
||||
return Response.json({ users: ALL.filter((user) => user.name.toLowerCase().includes(needle)) });
|
||||
};
|
||||
@@ -0,0 +1,38 @@
|
||||
page ApiBlockDemo {
|
||||
state nameFilter = "a"
|
||||
state found = ""
|
||||
state failed = ""
|
||||
|
||||
client {
|
||||
api searchDirectory POST /api/directory {
|
||||
request {
|
||||
body {
|
||||
name?: string
|
||||
}
|
||||
}
|
||||
|
||||
response {
|
||||
return data.users
|
||||
}
|
||||
|
||||
error {
|
||||
return []
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
functions {
|
||||
client async function search(): Promise<void> {
|
||||
const users = await api.searchDirectory({ name: nameFilter })
|
||||
found = users.map((user) => user.name).join(", ")
|
||||
}
|
||||
}
|
||||
|
||||
view {
|
||||
<main>
|
||||
<button @click="search()">Search</button>
|
||||
<p class="found" data-text="found">{found}</p>
|
||||
<p class="failed" data-text="failed">{failed}</p>
|
||||
</main>
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@
|
||||
export interface Routes {
|
||||
"/": Record<string, never>;
|
||||
"/about": Record<string, never>;
|
||||
"/api-block-demo": Record<string, never>;
|
||||
"/async-data": Record<string, never>;
|
||||
"/chat": Record<string, never>;
|
||||
"/client-only": Record<string, never>;
|
||||
@@ -27,6 +28,7 @@ export interface Routes {
|
||||
export interface RouteNames {
|
||||
"index": "/";
|
||||
"about": "/about";
|
||||
"api.block.demo": "/api-block-demo";
|
||||
"async.data": "/async-data";
|
||||
"chat": "/chat";
|
||||
"client.only": "/client-only";
|
||||
@@ -119,6 +121,7 @@ export function route<N extends RouteName>(
|
||||
const paths: Record<RouteName, RoutePath> = {
|
||||
"index": "/",
|
||||
"about": "/about",
|
||||
"api.block.demo": "/api-block-demo",
|
||||
"async.data": "/async-data",
|
||||
"chat": "/chat",
|
||||
"client.only": "/client-only",
|
||||
|
||||
@@ -3,5 +3,5 @@
|
||||
// Type-only assertions for sectioned `api` blocks. Kept as a real .ts file (not
|
||||
// wrnexus.generated.d.ts) because `skipLibCheck` exempts .d.ts contents from being
|
||||
// checked; this file is compiled and checked normally by the project's own tsc.
|
||||
|
||||
type __wrn_api_check_searchDirectory = WRNexusGenerated.__wrn_expect_true<WRNexusGenerated.AssertAssignable<{ name?: string }, WRNexusGenerated.ApiInput<"/api/directory", "POST">>>;
|
||||
export {};
|
||||
|
||||
+3
-2
@@ -13,8 +13,8 @@ declare namespace WRNexusGenerated {
|
||||
: never;
|
||||
type RealtimeMessage<T> = T extends import("@wrnexus/core").RoomDefinition<any, infer M> ? M : unknown;
|
||||
type QueuePayload<T> = T extends import("@wrnexus/queue").JobDefinition<infer I> ? I : unknown;
|
||||
type RouteName = "about" | "async.data" | "chat" | "client.only" | "dashboard" | "hello" | "index" | "island.demo" | "language.tools" | "layout" | "login" | "modal" | "navigation" | "partial.static" | "platform.showcase" | "reactive" | "server.actions" | "table" | "test" | "ui";
|
||||
type ApiRoute = "/api/accounts" | "/api/echo" | "/api/graphql-example" | "/api/hello" | "/api/invite" | "/api/login" | "/api/logout" | "/api/me" | "/api/typed-user" | "/api/users/csr" | "/api/users/ssr" | "/api/webhooks/payment";
|
||||
type RouteName = "about" | "api.block.demo" | "async.data" | "chat" | "client.only" | "dashboard" | "hello" | "index" | "island.demo" | "language.tools" | "layout" | "login" | "modal" | "navigation" | "partial.static" | "platform.showcase" | "reactive" | "server.actions" | "table" | "test" | "ui";
|
||||
type ApiRoute = "/api/accounts" | "/api/directory" | "/api/echo" | "/api/graphql-example" | "/api/hello" | "/api/invite" | "/api/login" | "/api/logout" | "/api/me" | "/api/typed-user" | "/api/users/csr" | "/api/users/ssr" | "/api/webhooks/payment";
|
||||
type RealtimeRoute = "/realtime/chat" | "/realtime/hello";
|
||||
type EnvironmentKey = "APP_LABEL" | "DATABASE_URL" | "DEMO_SHARED" | "HOST" | "NODE_ENV" | "PORT" | "SESSION_SECRET" | "UAT_ONLY";
|
||||
type TranslationKey = "api.greeting" | "home.intro" | "home.title" | "nav.about" | "nav.chat" | "nav.dashboard" | "nav.home" | "nav.layout" | "nav.navigation" | "nav.ui";
|
||||
@@ -29,6 +29,7 @@ declare namespace WRNexusGenerated {
|
||||
"/api/users/ssr": { GET: ApiContract<typeof import("../api/users/ssr.ts")["GET"]> };
|
||||
"/api/graphql-example": { POST: ApiContract<typeof import("../api/graphql-example.ts")["POST"]> };
|
||||
"/api/typed-user": { POST: ApiContract<typeof import("../api/typed-user.ts")["POST"]> };
|
||||
"/api/directory": { POST: ApiContract<typeof import("../api/directory.ts")["POST"]> };
|
||||
"/api/accounts": { GET: ApiContract<typeof import("../api/accounts.ts")["GET"]> };
|
||||
"/api/invite": { POST: ApiContract<typeof import("../api/invite.ts")["POST"]> };
|
||||
"/api/logout": { POST: ApiContract<typeof import("../api/logout.ts")["POST"]> };
|
||||
|
||||
@@ -37,7 +37,8 @@ function harness(response: { status: number; payload: unknown }) {
|
||||
};
|
||||
|
||||
(0, eval)(REACTIVE_RUNTIME);
|
||||
const callApi = (win as unknown as { __wrnexusCallApi: Function }).__wrnexusCallApi;
|
||||
const callApi = (win as unknown as { __wrnexusCallApi: (...args: any[]) => Promise<any> })
|
||||
.__wrnexusCallApi;
|
||||
return { callApi, calls, win };
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user