Compare commits
17
Commits
2cbc3e43e1
...
b3ed9689fa
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b3ed9689fa | ||
|
|
e5de7b54a5 | ||
|
|
3252b1b20e | ||
|
|
7601477f7d | ||
|
|
847b6dbe59 | ||
|
|
04be24ddd8 | ||
|
|
785e8a65bd | ||
|
|
a2698fb51a | ||
|
|
419614d9d1 | ||
|
|
70777e4a45 | ||
|
|
bd2f6ac5e3 | ||
|
|
b457ad1d54 | ||
|
|
323f57b32b | ||
|
|
028c2a6d64 | ||
|
|
2f0f82b29f | ||
|
|
50097ec4b4 | ||
|
|
f026415ba6 |
@@ -25,3 +25,6 @@ tsconfig.focus.json
|
||||
# "@wrnexus/*" bare specifiers (resolved via the root tsconfig.json `paths`,
|
||||
# which requires the scaffold to live inside the repo tree).
|
||||
**/test/.tmp-*/
|
||||
|
||||
# Subagent-driven-development scratch (ledger, briefs, review packages)
|
||||
.superpowers/
|
||||
|
||||
@@ -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
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -2854,7 +2854,10 @@
|
||||
"LexError",
|
||||
"Lexer",
|
||||
"Token",
|
||||
"TokenType"
|
||||
"TokenType",
|
||||
"isIdentPart",
|
||||
"isIdentStart",
|
||||
"skipLiteralOrComment"
|
||||
],
|
||||
"./types": [
|
||||
"RuntimeType",
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,260 @@
|
||||
# Typed, callable `api` blocks for `.wrn` files — Design
|
||||
|
||||
**Date:** 2026-08-19
|
||||
**Status:** Approved for implementation
|
||||
**Scope:** A sectioned `api` block that declares a typed request, transforms the response, and
|
||||
handles failure — callable on demand from client code.
|
||||
|
||||
## Goal
|
||||
|
||||
Calling this application's own API routes from a `.wrn` page should be declarative and
|
||||
type-checked. Today it is neither: the `api` block takes no parameters at all, so anything
|
||||
carrying a value from the page is written as a hand-rolled `fetch` — query-string assembly,
|
||||
JSON headers, CSRF, status checks, and a `try/catch` repeated at every call site.
|
||||
|
||||
### What the current block cannot do
|
||||
|
||||
These are implementation facts, not gaps in documentation:
|
||||
|
||||
- **No query string.** `isSafeApiPath` (`packages/dev-server/src/runtime.ts`) rejects any path
|
||||
containing `?` or `#`.
|
||||
- **No interpolation.** `readPath()` reads until whitespace or `{`, so `/api/users?name={filter}`
|
||||
ends the path at the brace and the remainder is parsed as the block body.
|
||||
- **No request body.** The caller builds `new Request(apiUrl, { method, headers })` — there is no
|
||||
parameter a payload could occupy, whatever method is named.
|
||||
- **Fetch-once.** `setupCsrFetch` sets an `__wrnexusCsrFetch` flag and returns early on any later
|
||||
pass, so a binding cannot be re-run.
|
||||
|
||||
### Non-goals
|
||||
|
||||
- External or third-party APIs. Targets are restricted to this app's `/api/*` routes, preserving
|
||||
the existing `isSafeApiPath` guarantee.
|
||||
- Replacing `server function`. That remains the way to run arbitrary server logic over RPC.
|
||||
- Author-settable headers. See "Why `headers` is excluded".
|
||||
- Parameterised server-render fetching. See "The SSR boundary".
|
||||
|
||||
## Decisions
|
||||
|
||||
| Question | Decision |
|
||||
| ---------------- | ------------------------------------------------------------------------ |
|
||||
| Trigger | `client {}` blocks are callable on demand; `ssr {}` stays render-time |
|
||||
| Targets | This app's `/api/*` routes only |
|
||||
| Execution | Decided by the enclosing mode, not a modifier |
|
||||
| Request values | Declared fields, supplied at the call site |
|
||||
| Type source | Route contract when available, declared types otherwise (with a warning) |
|
||||
| Type enforcement | `tsc`, via assertions generated into `wrnexus.generated.api-checks.ts` |
|
||||
| Failure | `error {}` converts a failure to a value; without it, the call rejects |
|
||||
|
||||
## Syntax
|
||||
|
||||
```wrn
|
||||
client {
|
||||
api searchUsers POST /api/users {
|
||||
request {
|
||||
body {
|
||||
name?: string
|
||||
age?: number
|
||||
designation?: string
|
||||
}
|
||||
}
|
||||
|
||||
response {
|
||||
return data.users
|
||||
}
|
||||
|
||||
error {
|
||||
return []
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Called as `const users = await api.searchUsers({ name: nameFilter.trim() })`. The `api` namespace
|
||||
joins those already in client scope (`server`, `output`, `props`, `refs`), so it reads the same way
|
||||
as `server.searchUsers()`.
|
||||
|
||||
`GET` blocks declare `parameters` rather than `body`; the compiler appends them as a query string at
|
||||
call time. The path in source stays a plain literal, so `isSafeApiPath` is satisfied without
|
||||
relaxing it.
|
||||
|
||||
### Backward compatibility
|
||||
|
||||
A bare body keeps meaning "this is the response block", unchanged:
|
||||
|
||||
```wrn
|
||||
ssr {
|
||||
api ssrUsers GET /api/users/ssr {
|
||||
return users.map((user) => user.name).join(", ")
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
The rule is **bare body = legacy untyped block; sections = typed block.**
|
||||
|
||||
The two forms reach the payload differently, and the reason is load-bearing rather than cosmetic.
|
||||
The legacy form injects the response with `with ($data ?? {})`, which is why bare `users` resolves.
|
||||
**`with` is untypeable** — TypeScript cannot see through it — so a typed `response` block is
|
||||
impossible in that form. Sectioned blocks therefore bind the payload to `data`, specifically so
|
||||
`tsc` can check `data.users` against the route's contract.
|
||||
|
||||
### Why `headers` is excluded
|
||||
|
||||
Own-route calls are same-origin, so cookies are already attached; `content-type` and `accept` follow
|
||||
from whether the block has a body; and CSRF is attached by the runtime (below). What remains for an
|
||||
author to set is mostly credentials, which do not belong in page source. Excluded from v1 pending a
|
||||
concrete case.
|
||||
|
||||
## Type safety
|
||||
|
||||
### The constraint that shapes this
|
||||
|
||||
`examples/basic-app/tsconfig.json` uses `include: ["app"]` and excludes `.wrnexus-*`, and
|
||||
`wrnexus build` never invokes `tsc`. **Generated build artifacts are not type-checked.** Compiling
|
||||
the block into a typed client and expecting `tsc` to catch mismatches would therefore check nothing.
|
||||
|
||||
What _is_ type-checked is application source under `app/`. Enforcement goes there.
|
||||
|
||||
**Corrected 2026-08-19, during implementation.** This section originally placed the assertions in
|
||||
`app/types/wrnexus.generated.d.ts`. That is inert: the root `tsconfig.json` sets
|
||||
`skipLibCheck: true`, which exempts the _contents_ of every `.d.ts`, so an assertion written there
|
||||
can never raise a `tsc` error. Proven by forcing `skipLibCheck: false`, under which the same
|
||||
assertion fires as `TS2344`. The reasoning was right and the file was wrong. Per-block assertions
|
||||
are emitted into a real `.ts` file instead — `app/types/wrnexus.generated.api-checks.ts` — which
|
||||
`skipLibCheck` does not exempt and which `include: ["app"]` compiles. The helper types stay in the
|
||||
`.d.ts`, where being declarations is correct.
|
||||
|
||||
### Three pieces
|
||||
|
||||
**1. Helper types**, extending what the generator already emits (`ApiRoute`, `ApiContracts`,
|
||||
`ApiContract`):
|
||||
|
||||
```ts
|
||||
type AssertAssignable<Actual, Expected> = Actual extends Expected ? true : never;
|
||||
type ApiInput<P extends ApiRoute, M> = ApiContracts[P][M]["input"];
|
||||
type ApiOutput<P extends ApiRoute, M> = ApiContracts[P][M]["output"];
|
||||
```
|
||||
|
||||
**2. Per-block assertions**, generated into `app/types/wrnexus.generated.api-checks.ts`. `wrnexus generate types` already parses
|
||||
`.wrn` sources to build the route list, so it can read each block's declared fields and emit:
|
||||
|
||||
```ts
|
||||
type __wrn_check_searchUsers = AssertAssignable<
|
||||
{ name?: string; age?: number },
|
||||
ApiInput<"/api/users", "POST">
|
||||
>;
|
||||
```
|
||||
|
||||
This is what makes the safety real. It sits in a file the project's own `tsc` already compiles, so
|
||||
`bun run typecheck` fails when a block sends a field the endpoint rejects. No bespoke type
|
||||
comparison inside the WRNexus compiler, and no need to type-check build output. The language server
|
||||
already runs TypeScript diagnostics on `.wrn` documents, so the same error appears inline.
|
||||
|
||||
**3. A generic runtime**, `callApi(path, method, input)`, typed by those contracts, so the fetch,
|
||||
JSON handling, and failure branch live in one tested place instead of being re-emitted per block.
|
||||
|
||||
### Routes without a contract
|
||||
|
||||
A plain handler returning `Response.json` has no `defineEndpoint` contract, so `ApiInput` resolves
|
||||
to `unknown`. The block's declared types are used directly and the generator emits a warning naming
|
||||
the route. Untyped endpoints stay visible rather than silently passing.
|
||||
|
||||
### Staleness
|
||||
|
||||
Checking is only as current as the generated file, so this stays wired into the existing
|
||||
`check:generated-types` gate, which already verifies those artifacts match their sources.
|
||||
|
||||
## Compilation and runtime
|
||||
|
||||
### Client mode
|
||||
|
||||
Each `client { api name ... }` becomes an entry on an `api` namespace in the generated browser
|
||||
module, beside the existing `__wrnexusClientFunctions`, with `const api = context.api` added to
|
||||
client scope exactly as `server` is today.
|
||||
|
||||
Declared field types are **type-only**. The generator uses them for the `.d.ts` assertions and
|
||||
codegen drops them before emit. A client function body that carried TypeScript into a `.mjs`
|
||||
artifact is a bug this repository has already shipped once (fixed 2026-08-19, `55fed217`); the same
|
||||
discipline applies here.
|
||||
|
||||
`setupCsrFetch` is untouched. Callable blocks are a separate mechanism, so the existing render-time
|
||||
binding needs no rework.
|
||||
|
||||
### Request assembly
|
||||
|
||||
`callApi` builds the request:
|
||||
|
||||
- **GET** — declared `parameters` become a query string; `undefined` fields are omitted, which
|
||||
removes the `if (filter.trim())` ladder authors write by hand.
|
||||
- **Everything else** — a JSON body with `content-type: application/json`.
|
||||
- Always `credentials: "same-origin"` and `accept: application/json`.
|
||||
- **Non-GET requests attach `x-csrf-token`**, read from the `wrn-csrf` cookie or the
|
||||
`wrnexus-csrf` meta tag, reusing the logic already at `packages/csr/src/reactive-runtime.ts:4221`
|
||||
for RPC. Hand-written `fetch` calls in application code generally omit this, so it is a
|
||||
correctness gain rather than only less typing.
|
||||
|
||||
### Failure
|
||||
|
||||
**`error {}` converts a failure into a value; without it, the call rejects.**
|
||||
|
||||
- 2xx — the JSON is parsed and bound as `data`, `response {}` runs, and its return value is the
|
||||
call's result. With no `response` block, `data` is returned unchanged.
|
||||
- Non-2xx, network failure, or an unparseable body — `error {}` runs with `status`, `message`, and
|
||||
`data` in scope. `return []` yields an empty list and no exception.
|
||||
- No `error {}` block — the promise rejects, so `try/catch` at the call site keeps working.
|
||||
|
||||
A block must never quietly return `undefined` on failure. Success-shaped failure is the defect class
|
||||
this design is most concerned with, so the absence of an `error` block means throw, never swallow.
|
||||
|
||||
### The SSR boundary
|
||||
|
||||
`ssr {}` blocks accept `response {}` and `error {}`, but **not** `request {}`. There is no caller at
|
||||
render time to supply arguments, and inferring an implicit source — page state, query parameters —
|
||||
would be a guess. Parameterised requests are a client-mode feature; parameterised server-side
|
||||
fetching stays with `server function`.
|
||||
|
||||
## Testing
|
||||
|
||||
### Parser (`packages/syntax/test/`)
|
||||
|
||||
- A sectioned block parses into `request`/`response`/`error` parts.
|
||||
- A bare body still parses as the response block (the backward-compatibility guarantee).
|
||||
- `request` inside an `ssr {}` block is a parse error naming the restriction.
|
||||
- A malformed section reports the offending offset rather than failing later in codegen.
|
||||
|
||||
### Type generation (`packages/cli/test/` or the types generator's suite)
|
||||
|
||||
- A block targeting a `defineEndpoint` route emits an assertion referencing that contract.
|
||||
- A field the endpoint does not accept makes `bun run typecheck` fail — asserted by running `tsc`
|
||||
over a fixture, not by string-matching the generated file.
|
||||
- A block targeting a contract-less route emits the warning and falls back to declared types.
|
||||
- `check:generated-types` still passes with blocks present.
|
||||
|
||||
### Codegen (`packages/compiler/test/`)
|
||||
|
||||
- A client-mode block emits an `api` namespace entry and valid JavaScript — no TypeScript survives
|
||||
into the browser module (the guard for the `55fed217` defect class).
|
||||
- Declared types do not appear in the emitted module.
|
||||
- An `ssr` block's output is unchanged from today for a bare body.
|
||||
|
||||
### Runtime (`packages/csr/test/`)
|
||||
|
||||
- GET omits `undefined` parameters and includes the rest.
|
||||
- Non-GET attaches `x-csrf-token` from cookie and from meta.
|
||||
- 2xx runs `response`; its return value is the result.
|
||||
- Non-2xx runs `error`; its return value is the result.
|
||||
- With no `error` block, a non-2xx rejects rather than resolving to `undefined`.
|
||||
|
||||
### End to end (`examples/basic-app`)
|
||||
|
||||
A page calling a typed block against a real route, driven in a browser: the request carries the
|
||||
declared fields, the response block's value reaches page state, and a deliberately failing call
|
||||
takes the `error` path. Tests that pass while the feature does not work have been a recurring
|
||||
failure in this repository, so browser verification is part of the definition of done.
|
||||
|
||||
## Deferred
|
||||
|
||||
- External and third-party API targets, with the allowlist and credential handling they require.
|
||||
- Author-settable request headers.
|
||||
- Re-runnable `ssr` bindings — `setupCsrFetch`'s fetch-once guard stays as it is.
|
||||
- Parameterised server-render fetching.
|
||||
- Response caching and request de-duplication.
|
||||
+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",
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
// AUTO-GENERATED by `wrnexus generate types` - do not edit.
|
||||
//
|
||||
// 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 {};
|
||||
+13
-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"]> };
|
||||
@@ -57,4 +58,14 @@ declare namespace WRNexusGenerated {
|
||||
"welcome-email": QueuePayload<(typeof import("../queues/welcome-email.ts"))["default"]>;
|
||||
}
|
||||
type ApplicationConfig = (typeof import("../../wrnexus.config.ts"))["default"];
|
||||
type AssertAssignable<Actual, Expected> = unknown extends Expected
|
||||
? true
|
||||
: [Actual] extends [Expected]
|
||||
? [Exclude<keyof Actual, keyof Expected>] extends [never]
|
||||
? true
|
||||
: false
|
||||
: false;
|
||||
type __wrn_expect_true<T extends true> = T;
|
||||
type ApiInput<P extends ApiRoute, M> = ApiContracts[P][M]["input"];
|
||||
type ApiOutput<P extends ApiRoute, M> = ApiContracts[P][M]["output"];
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ import { buildRouter, createRouteManifest, nameRoutes } from "@wrnexus/router";
|
||||
// Keep the CLI checker sourced from the package contract so typecheck fixes are
|
||||
// included in each published CLI bundle.
|
||||
import { checkWrnFile, type WrnTypeDiagnostic } from "@wrnexus/typecheck";
|
||||
import { parse } from "@wrnexus/syntax";
|
||||
import { parse, type PageAst } from "@wrnexus/syntax";
|
||||
import { generate, generateTargets } from "@wrnexus/compiler";
|
||||
import { regenerateRoutes } from "./routes.ts";
|
||||
import { loadAppConfig } from "@wrnexus/styles";
|
||||
@@ -99,6 +99,42 @@ function writePluginArtifacts(root: string, contributions?: PluginContributions)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Type assertions for sectioned api blocks.
|
||||
*
|
||||
* Enforcement lives here rather than in the compiler because this file is under
|
||||
* `app/` and is therefore compiled by the project's own tsc, while generated
|
||||
* build artifacts are not type-checked at all.
|
||||
*/
|
||||
function apiBlockAssertions(pages: { path: string; ast: PageAst }[], apiContracts: string): string {
|
||||
const lines: string[] = [];
|
||||
|
||||
for (const page of pages) {
|
||||
for (const block of page.ast.dataApis) {
|
||||
if (!block.sections) continue;
|
||||
|
||||
if (!apiContracts.includes(JSON.stringify(block.path))) {
|
||||
console.warn(
|
||||
`[wrnexus] api block "${block.name}" targets ${block.path}, which has no defineEndpoint contract — its declared types are not checked.`,
|
||||
);
|
||||
}
|
||||
|
||||
const fields = [...block.sections.parameters, ...block.sections.body];
|
||||
const shape = fields.length
|
||||
? `{ ${fields.map((f) => `${f.name}${f.optional ? "?" : ""}: ${f.type}`).join("; ")} }`
|
||||
: "Record<string, never>";
|
||||
|
||||
lines.push(
|
||||
`type __wrn_api_check_${block.name} = WRNexusGenerated.__wrn_expect_true<WRNexusGenerated.AssertAssignable<${shape}, WRNexusGenerated.ApiInput<${JSON.stringify(
|
||||
block.path,
|
||||
)}, ${JSON.stringify(block.method)}>>>;`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
export function generateApplicationTypes(
|
||||
appRoot: string,
|
||||
pluginContributions?: PluginContributions,
|
||||
@@ -145,6 +181,10 @@ export function generateApplicationTypes(
|
||||
return ` ${JSON.stringify(component.name)}: { props: ${props ? `{ ${props} }` : "Record<string, never>"}; outputs: ${outputs ? `{ ${outputs} }` : "Record<string, never>"} };`;
|
||||
})
|
||||
.join("\n");
|
||||
const pageAsts = files(app, (path) => extname(path) === ".wrn").map((file) => ({
|
||||
path: file,
|
||||
ast: parse(readFileSync(file, "utf8")),
|
||||
}));
|
||||
const typeDir = join(app, "types");
|
||||
const apiContracts = router.api
|
||||
.map((route) => {
|
||||
@@ -218,11 +258,33 @@ declare namespace WRNexusGenerated {
|
||||
${generatedContractMap("RealtimeMessages", realtimeContracts)}
|
||||
${generatedContractMap("QueuePayloads", queueContracts)}
|
||||
type ApplicationConfig = ${configFile ? `(typeof import(${typeImport(typeDir, configFile)}))["default"]` : "Record<string, never>"};
|
||||
type AssertAssignable<Actual, Expected> = unknown extends Expected
|
||||
? true
|
||||
: [Actual] extends [Expected]
|
||||
? [Exclude<keyof Actual, keyof Expected>] extends [never]
|
||||
? true
|
||||
: false
|
||||
: false;
|
||||
type __wrn_expect_true<T extends true> = T;
|
||||
type ApiInput<P extends ApiRoute, M> = ApiContracts[P][M]["input"];
|
||||
type ApiOutput<P extends ApiRoute, M> = ApiContracts[P][M]["output"];
|
||||
}
|
||||
`;
|
||||
mkdirSync(typeDir, { recursive: true });
|
||||
const output = join(typeDir, "wrnexus.generated.d.ts");
|
||||
writeFileSync(output, code, "utf8");
|
||||
// `.d.ts` contents are exempt from checking under `skipLibCheck: true` (set in the
|
||||
// repo/app tsconfig), so the per-block assertions are written into a real `.ts` file
|
||||
// instead — only genuine `.ts`/`.tsx` sources are compiled and checked.
|
||||
const apiChecksCode = `// AUTO-GENERATED by \`wrnexus generate types\` - do not edit.
|
||||
//
|
||||
// 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.
|
||||
${apiBlockAssertions(pageAsts, apiContracts)}
|
||||
export {};
|
||||
`;
|
||||
writeFileSync(join(typeDir, "wrnexus.generated.api-checks.ts"), apiChecksCode, "utf8");
|
||||
writePluginArtifacts(root, pluginContributions);
|
||||
return {
|
||||
file: relative(root, output).replace(/\\/g, "/"),
|
||||
|
||||
@@ -0,0 +1,236 @@
|
||||
import { afterEach, expect, test } from "bun:test";
|
||||
import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { generateApplicationTypes } from "../src/types.ts";
|
||||
|
||||
const roots: string[] = [];
|
||||
afterEach(() => {
|
||||
for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
/** Minimal app with one typed endpoint and one page that calls it. */
|
||||
function fixture(block: string): string {
|
||||
const root = mkdtempSync(join(tmpdir(), "wrnexus-api-types-"));
|
||||
roots.push(root);
|
||||
mkdirSync(join(root, "app/pages"), { recursive: true });
|
||||
mkdirSync(join(root, "app/api"), { recursive: true });
|
||||
writeFileSync(
|
||||
join(root, "app/api/users.ts"),
|
||||
`export const POST = async () => Response.json({ users: [] });\n`,
|
||||
);
|
||||
writeFileSync(
|
||||
join(root, "app/pages/search.wrn"),
|
||||
`page Search {\n client {\n${block}\n }\n\n view { <main>x</main> }\n}\n`,
|
||||
);
|
||||
return root;
|
||||
}
|
||||
|
||||
const BLOCK = ` api searchUsers POST /api/users {
|
||||
request {
|
||||
body {
|
||||
name?: string
|
||||
age?: number
|
||||
}
|
||||
}
|
||||
|
||||
response {
|
||||
return data.users
|
||||
}
|
||||
}`;
|
||||
|
||||
test("emits the ApiInput, ApiOutput and AssertAssignable helpers", () => {
|
||||
const root = fixture(BLOCK);
|
||||
generateApplicationTypes(root);
|
||||
const generated = readFileSync(join(root, "app/types/wrnexus.generated.d.ts"), "utf8");
|
||||
|
||||
expect(generated).toContain("type AssertAssignable<");
|
||||
expect(generated).toContain('type ApiInput<P extends ApiRoute, M> = ApiContracts[P][M]["input"]');
|
||||
expect(generated).toContain(
|
||||
'type ApiOutput<P extends ApiRoute, M> = ApiContracts[P][M]["output"]',
|
||||
);
|
||||
});
|
||||
|
||||
// The per-block assertions live in a plain .ts file, not the .d.ts: `skipLibCheck: true`
|
||||
// (set repo-wide) exempts .d.ts *contents* from being checked at all, so a `.d.ts` can
|
||||
// never actually enforce anything here. A real .ts file under app/ is compiled and
|
||||
// checked normally.
|
||||
test("emits one assertion per sectioned block, naming its route and method", () => {
|
||||
const root = fixture(BLOCK);
|
||||
generateApplicationTypes(root);
|
||||
const checks = readFileSync(join(root, "app/types/wrnexus.generated.api-checks.ts"), "utf8");
|
||||
|
||||
expect(checks).toContain("__wrn_api_check_searchUsers");
|
||||
expect(checks).toContain('WRNexusGenerated.ApiInput<"/api/users", "POST">');
|
||||
expect(checks).toContain("name?: string");
|
||||
expect(checks).toContain("age?: number");
|
||||
});
|
||||
|
||||
test("a legacy bare-body block produces no assertion", () => {
|
||||
const root = fixture(` api legacyUsers GET /api/users {
|
||||
return users.length
|
||||
}`);
|
||||
generateApplicationTypes(root);
|
||||
const checks = readFileSync(join(root, "app/types/wrnexus.generated.api-checks.ts"), "utf8");
|
||||
|
||||
expect(checks).not.toContain("__wrn_api_check_legacyUsers");
|
||||
});
|
||||
|
||||
test("the api-checks file has no runtime code and is a module", () => {
|
||||
const root = fixture(BLOCK);
|
||||
generateApplicationTypes(root);
|
||||
const checks = readFileSync(join(root, "app/types/wrnexus.generated.api-checks.ts"), "utf8");
|
||||
|
||||
expect(checks).toContain("AUTO-GENERATED");
|
||||
expect(checks.trim().endsWith("export {};")).toBe(true);
|
||||
});
|
||||
|
||||
// --- Real-compiler enforcement tests ---------------------------------------------
|
||||
//
|
||||
// Everything above only asserts on the emitted *text*. That proves nothing about
|
||||
// whether the assertions actually make `tsc` fail — a build that reverted to the
|
||||
// original inert `never`-based design, or one where `AssertAssignable` is merely
|
||||
// one-directional (so it misses an *extra* declared field), would pass every test
|
||||
// above unchanged. These tests instead run the real TypeScript compiler over the
|
||||
// generated output and assert on its diagnostics.
|
||||
//
|
||||
// The fixture endpoint takes a second (body) parameter so `ApiContract`'s fallback
|
||||
// branch infers a real input type (`{ name: string; email: string }`) instead of
|
||||
// `unknown` — with `unknown`, `AssertAssignable`'s untyped-route bypass means nothing
|
||||
// could ever fail, which would make these tests meaningless.
|
||||
function typedFixture(block: string): string {
|
||||
const root = mkdtempSync(join(tmpdir(), "wrnexus-api-types-tsc-"));
|
||||
roots.push(root);
|
||||
mkdirSync(join(root, "app/pages"), { recursive: true });
|
||||
mkdirSync(join(root, "app/api"), { recursive: true });
|
||||
writeFileSync(
|
||||
join(root, "app/api/users.ts"),
|
||||
`export const POST = async (ctx: unknown, body: { name: string; email: string }) => Response.json(body);\n`,
|
||||
);
|
||||
writeFileSync(
|
||||
join(root, "app/pages/search.wrn"),
|
||||
`page Search {\n client {\n${block}\n }\n\n view { <main>x</main> }\n}\n`,
|
||||
);
|
||||
return root;
|
||||
}
|
||||
|
||||
/**
|
||||
* Compiles the two generated files (and whatever they reference on disk) with the
|
||||
* real TypeScript compiler and returns its stdout plus whether it reported any
|
||||
* diagnostics.
|
||||
*/
|
||||
function typecheckGenerated(root: string): { ok: boolean; output: string } {
|
||||
const dts = join(root, "app/types/wrnexus.generated.d.ts");
|
||||
const checks = join(root, "app/types/wrnexus.generated.api-checks.ts");
|
||||
const result = Bun.spawnSync(
|
||||
[
|
||||
"bunx",
|
||||
"tsc",
|
||||
"--noEmit",
|
||||
"--strict",
|
||||
"--skipLibCheck",
|
||||
"--moduleResolution",
|
||||
"bundler",
|
||||
"--target",
|
||||
"ES2022",
|
||||
"--module",
|
||||
"ESNext",
|
||||
dts,
|
||||
checks,
|
||||
],
|
||||
{ cwd: root, stdout: "pipe", stderr: "pipe" },
|
||||
);
|
||||
const output = `${result.stdout?.toString() ?? ""}${result.stderr?.toString() ?? ""}`;
|
||||
return { ok: result.exitCode === 0, output };
|
||||
}
|
||||
|
||||
const MATCHING_BLOCK = ` api searchUsers POST /api/users {
|
||||
request {
|
||||
body {
|
||||
name: string
|
||||
email: string
|
||||
}
|
||||
}
|
||||
|
||||
response {
|
||||
return data
|
||||
}
|
||||
}`;
|
||||
|
||||
test("tsc: a block whose fields match the contract has no diagnostics", () => {
|
||||
const root = typedFixture(MATCHING_BLOCK);
|
||||
generateApplicationTypes(root);
|
||||
const { ok, output } = typecheckGenerated(root);
|
||||
|
||||
expect(output.trim()).toBe("");
|
||||
expect(ok).toBe(true);
|
||||
});
|
||||
|
||||
test("tsc: a field with the wrong type fails, naming the block's assertion", () => {
|
||||
const root = typedFixture(` api searchUsers POST /api/users {
|
||||
request {
|
||||
body {
|
||||
name: number
|
||||
email: string
|
||||
}
|
||||
}
|
||||
|
||||
response {
|
||||
return data
|
||||
}
|
||||
}`);
|
||||
generateApplicationTypes(root);
|
||||
const { ok, output } = typecheckGenerated(root);
|
||||
|
||||
expect(ok).toBe(false);
|
||||
expect(output).toContain("wrnexus.generated.api-checks.ts");
|
||||
const checks = readFileSync(join(root, "app/types/wrnexus.generated.api-checks.ts"), "utf8");
|
||||
const failingLine = Number(output.match(/api-checks\.ts\((\d+),\d+\)/)?.[1]);
|
||||
expect(checks.split(/\r?\n/)[failingLine - 1]).toContain("__wrn_api_check_searchUsers");
|
||||
});
|
||||
|
||||
test("tsc: an extra field the contract does not accept fails (Finding A regression guard)", () => {
|
||||
const root = typedFixture(` api searchUsers POST /api/users {
|
||||
request {
|
||||
body {
|
||||
name: string
|
||||
email: string
|
||||
extra: string
|
||||
}
|
||||
}
|
||||
|
||||
response {
|
||||
return data
|
||||
}
|
||||
}`);
|
||||
generateApplicationTypes(root);
|
||||
const { ok, output } = typecheckGenerated(root);
|
||||
|
||||
expect(ok).toBe(false);
|
||||
expect(output).toContain("wrnexus.generated.api-checks.ts");
|
||||
const checks = readFileSync(join(root, "app/types/wrnexus.generated.api-checks.ts"), "utf8");
|
||||
const failingLine = Number(output.match(/api-checks\.ts\((\d+),\d+\)/)?.[1]);
|
||||
expect(checks.split(/\r?\n/)[failingLine - 1]).toContain("__wrn_api_check_searchUsers");
|
||||
});
|
||||
|
||||
test("tsc: a missing required field fails", () => {
|
||||
const root = typedFixture(` api searchUsers POST /api/users {
|
||||
request {
|
||||
body {
|
||||
name: string
|
||||
}
|
||||
}
|
||||
|
||||
response {
|
||||
return data
|
||||
}
|
||||
}`);
|
||||
generateApplicationTypes(root);
|
||||
const { ok, output } = typecheckGenerated(root);
|
||||
|
||||
expect(ok).toBe(false);
|
||||
expect(output).toContain("wrnexus.generated.api-checks.ts");
|
||||
const checks = readFileSync(join(root, "app/types/wrnexus.generated.api-checks.ts"), "utf8");
|
||||
const failingLine = Number(output.match(/api-checks\.ts\((\d+),\d+\)/)?.[1]);
|
||||
expect(checks.split(/\r?\n/)[failingLine - 1]).toContain("__wrn_api_check_searchUsers");
|
||||
});
|
||||
@@ -61,6 +61,7 @@ const RUNTIME_BINDINGS = new Set([
|
||||
"server",
|
||||
"props",
|
||||
"refs",
|
||||
"api",
|
||||
"event",
|
||||
"payload",
|
||||
]);
|
||||
@@ -308,6 +309,32 @@ function _functionEntry(
|
||||
}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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: PageAst): string {
|
||||
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 };` : "";
|
||||
}
|
||||
|
||||
export function generateBrowserModule(ast: PageAst): string {
|
||||
const functions = ast.runtimeFunctions.filter((fn) =>
|
||||
["legacy", "client", "shared"].includes(fn.runtime),
|
||||
@@ -365,6 +392,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;" : ""}
|
||||
|
||||
@@ -35,6 +35,10 @@ interface RenderBinding {
|
||||
path: string;
|
||||
body: string;
|
||||
helpers: string;
|
||||
// Present only for a sectioned ssr block with a non-empty `error {}` section.
|
||||
// When set, a failed API call runs this body (with `status`/`message`/`data`
|
||||
// bound) instead of propagating. Absent -> failures propagate, unchanged.
|
||||
errorBody?: string;
|
||||
}
|
||||
|
||||
interface SsrBinding extends RenderBinding {
|
||||
@@ -873,6 +877,7 @@ function renderBinding(binding: NamedDataBinding): RenderBinding {
|
||||
path: binding.path,
|
||||
body: binding.body,
|
||||
helpers: binding.helpers,
|
||||
...(binding.errorBody ? { errorBody: binding.errorBody } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -936,11 +941,21 @@ function apiBindingMap(ast: PageAst, sharedHelpers: string): Map<string, NamedDa
|
||||
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),
|
||||
});
|
||||
}
|
||||
@@ -949,7 +964,7 @@ function apiBindingMap(ast: PageAst, sharedHelpers: string): Map<string, NamedDa
|
||||
}
|
||||
|
||||
function ssrRuntimeSource(): string {
|
||||
return `const __wrnexusHtmlEscapes = { "&": "&", "<": "<", ">": ">", "\\"": """, "'": "'" };
|
||||
return `const __wrnexusHtmlEscapes: Record<string, string> = { "&": "&", "<": "<", ">": ">", "\\"": """, "'": "'" };
|
||||
function __wrnexusEscapeHtml(value: unknown): string {
|
||||
return String(value).replace(/[&<>"']/g, (ch) => __wrnexusHtmlEscapes[ch] ?? ch);
|
||||
}
|
||||
@@ -968,6 +983,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 {
|
||||
@@ -997,18 +1024,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;
|
||||
@@ -1517,8 +1578,11 @@ function generateInner(ast: PageAst): string {
|
||||
for (const [name, binding] of apiBindings) {
|
||||
if (binding.mode !== "ssr") continue;
|
||||
if (!lists.some((expr) => new RegExp(`\\b${name}\\b`).test(expr))) continue;
|
||||
const errorBodyProp = binding.errorBody
|
||||
? `, errorBody: ${JSON.stringify(binding.errorBody)}`
|
||||
: "";
|
||||
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 ${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);`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1526,7 +1590,9 @@ function generateInner(ast: PageAst): string {
|
||||
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) {
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
import { expect, test } from "bun:test";
|
||||
import { parse } from "@wrnexus/syntax";
|
||||
import { generateTargets } from "../src/targets.ts";
|
||||
|
||||
function browserModule(inner: string): string {
|
||||
return generateTargets(
|
||||
parse(`page Repro {
|
||||
client {
|
||||
${inner}
|
||||
}
|
||||
|
||||
functions {
|
||||
client async function run(): Promise<void> {
|
||||
const users = await api.searchUsers({ name: "Ajay" })
|
||||
console.log(users)
|
||||
}
|
||||
}
|
||||
|
||||
view { <main><button @click="run()">go</button></main> }
|
||||
}
|
||||
`),
|
||||
).browser;
|
||||
}
|
||||
|
||||
const BLOCK = ` api searchUsers POST /api/users {
|
||||
request {
|
||||
body {
|
||||
name?: string
|
||||
age?: number
|
||||
}
|
||||
}
|
||||
|
||||
response {
|
||||
return data.users
|
||||
}
|
||||
|
||||
error {
|
||||
return []
|
||||
}
|
||||
}`;
|
||||
|
||||
test("emits an api member that calls the transport with the block's path and method", () => {
|
||||
const generated = browserModule(BLOCK);
|
||||
|
||||
expect(generated).toContain("const api =");
|
||||
expect(generated).toContain("searchUsers");
|
||||
expect(generated).toContain('"/api/users"');
|
||||
expect(generated).toContain('"POST"');
|
||||
});
|
||||
|
||||
test("declared field types never reach the browser module", () => {
|
||||
// The artifact is written as .mjs and parsed as JavaScript.
|
||||
const generated = browserModule(BLOCK);
|
||||
|
||||
expect(generated).not.toContain("name?: string");
|
||||
expect(generated).not.toContain("age?: number");
|
||||
});
|
||||
|
||||
test("the emitted module is valid JavaScript", () => {
|
||||
const generated = browserModule(BLOCK);
|
||||
|
||||
expect(() => {
|
||||
new Function(generated.replace(/^\s*import[^\n]*$/gm, "").replace(/\bexport\s+/g, ""));
|
||||
}).not.toThrow();
|
||||
});
|
||||
|
||||
test("a block without an error section still emits its response body", () => {
|
||||
const generated = browserModule(` api plainUsers GET /api/users {
|
||||
request {
|
||||
parameters {
|
||||
team: string
|
||||
}
|
||||
}
|
||||
|
||||
response {
|
||||
return data.users
|
||||
}
|
||||
}`);
|
||||
|
||||
expect(generated).toContain("plainUsers");
|
||||
expect(generated).toContain("data.users");
|
||||
});
|
||||
|
||||
test("a state field named api does not collide with the emitted api object", () => {
|
||||
const generated = generateTargets(
|
||||
parse(`page Repro {
|
||||
state {
|
||||
api = ""
|
||||
}
|
||||
|
||||
client {
|
||||
${BLOCK}
|
||||
}
|
||||
|
||||
functions {
|
||||
client async function run(): Promise<void> {
|
||||
const users = await api.searchUsers({ name: "Ajay" })
|
||||
console.log(users)
|
||||
}
|
||||
}
|
||||
|
||||
view { <main><button @click="run()">go</button></main> }
|
||||
}
|
||||
`),
|
||||
).browser;
|
||||
|
||||
expect(() => {
|
||||
new Function(generated.replace(/^\s*import[^\n]*$/gm, "").replace(/\bexport\s+/g, ""));
|
||||
}).not.toThrow();
|
||||
});
|
||||
@@ -0,0 +1,205 @@
|
||||
import { afterEach, expect, test } from "bun:test";
|
||||
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { parse } from "@wrnexus/syntax";
|
||||
import { generate } from "../src/codegen.ts";
|
||||
|
||||
const ROOT_TSCONFIG = join(import.meta.dir, "../../../tsconfig.json").replace(/\\/g, "/");
|
||||
// The repo's own tsc, not a `bunx`-fetched one — `bunx tsc` can resolve an
|
||||
// unrelated TypeScript version that doesn't understand this repo's tsconfig
|
||||
// options (observed: it rejected `ignoreDeprecations: "6.0"` and couldn't
|
||||
// find the `bun` type-definition entry point), unlike `bun run typecheck`,
|
||||
// which uses this same local binary.
|
||||
const LOCAL_TSC = join(import.meta.dir, "../../../node_modules/.bin/tsc").replace(/\\/g, "/");
|
||||
// `types`/`typeRoots` in an extended tsconfig resolve relative to the config
|
||||
// file that's actually invoked (our temp one), not the base file — so the
|
||||
// ambient `bun` types need an explicit path back to the repo's node_modules.
|
||||
const TYPE_ROOTS = join(import.meta.dir, "../../../node_modules/@types").replace(/\\/g, "/");
|
||||
|
||||
const roots: string[] = [];
|
||||
afterEach(() => {
|
||||
for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
/**
|
||||
* Runs the real TypeScript compiler over a generated server module. Proves
|
||||
* the emitted `__wrnexusSsrBindings` annotation (and everything else in the
|
||||
* module) actually type-checks — string-containment assertions alone can't
|
||||
* catch a declared type that omits a field every emitted object literal has.
|
||||
*/
|
||||
function typecheckGenerated(source: string): { ok: boolean; output: string } {
|
||||
const root = mkdtempSync(join(tmpdir(), "wrnexus-ssr-tsc-"));
|
||||
roots.push(root);
|
||||
const file = join(root, "page.ts");
|
||||
writeFileSync(file, source);
|
||||
// Reuse the repo's own tsconfig (paths, lib, types, jsx, ...) so this only
|
||||
// checks the one file we care about instead of hand-duplicating the whole
|
||||
// compiler configuration (and drifting from it over time).
|
||||
writeFileSync(
|
||||
join(root, "tsconfig.json"),
|
||||
JSON.stringify({
|
||||
extends: ROOT_TSCONFIG,
|
||||
compilerOptions: { noEmit: true, typeRoots: [TYPE_ROOTS] },
|
||||
include: ["page.ts"],
|
||||
}),
|
||||
);
|
||||
const result = Bun.spawnSync([LOCAL_TSC, "--project", join(root, "tsconfig.json")], {
|
||||
cwd: root,
|
||||
stdout: "pipe",
|
||||
stderr: "pipe",
|
||||
});
|
||||
const output = `${result.stdout?.toString() ?? ""}${result.stderr?.toString() ?? ""}`;
|
||||
return { ok: result.exitCode === 0, output };
|
||||
}
|
||||
|
||||
function serverModule(inner: string): string {
|
||||
return generate(
|
||||
parse(`page Repro {
|
||||
ssr {
|
||||
${inner}
|
||||
}
|
||||
|
||||
view { <main><p api="ssrUsers">loading</p></main> }
|
||||
}
|
||||
`),
|
||||
);
|
||||
}
|
||||
|
||||
test("a sectioned ssr block binds the payload to data", () => {
|
||||
const generated = serverModule(` api ssrUsers GET /api/users {
|
||||
response {
|
||||
return data.users.length
|
||||
}
|
||||
}`);
|
||||
|
||||
expect(generated).toContain("data.users.length");
|
||||
});
|
||||
|
||||
test("a legacy ssr block is unchanged", () => {
|
||||
const generated = serverModule(` api ssrUsers GET /api/users {
|
||||
return users.length
|
||||
}`);
|
||||
|
||||
expect(generated).toContain("users.length");
|
||||
});
|
||||
|
||||
test("an ssr block with an error section emits the error body and binds status/message/data", () => {
|
||||
const generated = serverModule(` api ssrUsers GET /api/users {
|
||||
response {
|
||||
return data.users.length
|
||||
}
|
||||
error {
|
||||
return message + status + data
|
||||
}
|
||||
}`);
|
||||
|
||||
expect(generated).toContain('"errorBody"');
|
||||
expect(generated).toContain("return message + status + data");
|
||||
expect(generated).toContain("const status = $status");
|
||||
expect(generated).toContain("const message = $message");
|
||||
expect(generated).toContain("const data = $data");
|
||||
expect(generated).toContain("__wrnexusEvalError");
|
||||
});
|
||||
|
||||
test("an ssr block without an error section emits no catch entry for that binding", () => {
|
||||
const generated = serverModule(` api ssrUsers GET /api/users {
|
||||
response {
|
||||
return data.users.length
|
||||
}
|
||||
}`);
|
||||
|
||||
expect(generated).not.toContain('"errorBody"');
|
||||
});
|
||||
|
||||
test("tsc: a sectioned ssr block's generated module has no diagnostics", () => {
|
||||
const generated = serverModule(` api ssrUsers GET /api/users {
|
||||
response {
|
||||
return data.users.length
|
||||
}
|
||||
error {
|
||||
return message + status + data
|
||||
}
|
||||
}`);
|
||||
|
||||
const { ok, output } = typecheckGenerated(generated);
|
||||
|
||||
expect(output.trim()).toBe("");
|
||||
expect(ok).toBe(true);
|
||||
});
|
||||
|
||||
test("an ssr block used in {#each} with an error section runs the error body on failure", async () => {
|
||||
const generated = generate(
|
||||
parse(`page Repro {
|
||||
ssr {
|
||||
api ssrUsers GET /api/users {
|
||||
response {
|
||||
return data.users
|
||||
}
|
||||
error {
|
||||
return ["fallback"]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
view { <main>{#each ssrUsers as u}<p>{u}</p>{/each}</main> }
|
||||
}
|
||||
`),
|
||||
);
|
||||
|
||||
const root = mkdtempSync(join(tmpdir(), "wrnexus-ssr-each-"));
|
||||
roots.push(root);
|
||||
mkdirSync(root, { recursive: true });
|
||||
const file = join(root, "page.ts");
|
||||
writeFileSync(file, generated);
|
||||
|
||||
const mod = await import(`${file.replace(/\\/g, "/")}?t=${Date.now()}`);
|
||||
const html = await mod.default({
|
||||
req: { url: "http://localhost/", headers: new Headers() },
|
||||
cookies: {},
|
||||
session: {},
|
||||
localStorage: {},
|
||||
__wrnexusCallApi: async () => {
|
||||
throw new Error("boom");
|
||||
},
|
||||
});
|
||||
|
||||
expect(html).toContain("fallback");
|
||||
});
|
||||
|
||||
test("an ssr block used in {#each} without an error section still propagates a failure", async () => {
|
||||
const generated = generate(
|
||||
parse(`page Repro {
|
||||
ssr {
|
||||
api ssrUsers GET /api/users {
|
||||
response {
|
||||
return data.users
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
view { <main>{#each ssrUsers as u}<p>{u}</p>{/each}</main> }
|
||||
}
|
||||
`),
|
||||
);
|
||||
|
||||
const root = mkdtempSync(join(tmpdir(), "wrnexus-ssr-each-propagate-"));
|
||||
roots.push(root);
|
||||
mkdirSync(root, { recursive: true });
|
||||
const file = join(root, "page.ts");
|
||||
writeFileSync(file, generated);
|
||||
|
||||
const mod = await import(`${file.replace(/\\/g, "/")}?t=${Date.now()}`);
|
||||
|
||||
await expect(
|
||||
mod.default({
|
||||
req: { url: "http://localhost/", headers: new Headers() },
|
||||
cookies: {},
|
||||
session: {},
|
||||
localStorage: {},
|
||||
__wrnexusCallApi: async () => {
|
||||
throw new Error("boom");
|
||||
},
|
||||
}),
|
||||
).rejects.toThrow("boom");
|
||||
});
|
||||
@@ -1380,6 +1380,7 @@ export const REACTIVE_RUNTIME = String.raw`
|
||||
state: stateProxy,
|
||||
output: outputProxy,
|
||||
server: serverProxy,
|
||||
callApi: wrnexusCallApi,
|
||||
props: propsProxy,
|
||||
refs: refsProxy,
|
||||
};
|
||||
@@ -4246,6 +4247,65 @@ export const REACTIVE_RUNTIME = String.raw`
|
||||
});
|
||||
}
|
||||
|
||||
/*
|
||||
* Transport for compiled api blocks.
|
||||
*
|
||||
* Only the request and the failure shape live here. A block's response and
|
||||
* error bodies are page code, so they are emitted into the browser module
|
||||
* and applied by the caller.
|
||||
*/
|
||||
function readCsrfToken() {
|
||||
var meta = document.querySelector('meta[name="wrnexus-csrf"]');
|
||||
if (meta) return meta.getAttribute("content") || "";
|
||||
var match = /(?:^|;\s*)wrn-csrf=([^;]+)/.exec(document.cookie || "");
|
||||
return match ? decodeURIComponent(match[1]) : "";
|
||||
}
|
||||
|
||||
function wrnexusCallApi(path, method, input) {
|
||||
var verb = String(method || "GET").toUpperCase();
|
||||
var values = input || {};
|
||||
var url = path;
|
||||
var headers = { accept: "application/json" };
|
||||
var init = { method: verb, credentials: "same-origin", headers: headers };
|
||||
|
||||
if (verb === "GET" || verb === "HEAD") {
|
||||
var query = [];
|
||||
Object.keys(values).forEach(function (key) {
|
||||
var value = values[key];
|
||||
// An omitted filter must not become "name=undefined".
|
||||
if (value === undefined || value === null || value === "") return;
|
||||
query.push(encodeURIComponent(key) + "=" + encodeURIComponent(String(value)));
|
||||
});
|
||||
if (query.length) url = path + "?" + query.join("&");
|
||||
} else {
|
||||
headers["content-type"] = "application/json";
|
||||
headers["x-csrf-token"] = readCsrfToken();
|
||||
init.body = JSON.stringify(values);
|
||||
}
|
||||
|
||||
return fetch(url, init).then(function (response) {
|
||||
return response.json().then(
|
||||
function (data) {
|
||||
if (response.ok) return data;
|
||||
var message =
|
||||
data && data.error ? String(data.error) : "Request failed with " + response.status;
|
||||
var failure = new Error(message);
|
||||
failure.status = response.status;
|
||||
failure.data = data;
|
||||
throw failure;
|
||||
},
|
||||
function () {
|
||||
var failure = new Error("Response was not valid JSON");
|
||||
failure.status = response.status;
|
||||
failure.data = undefined;
|
||||
throw failure;
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
window.__wrnexusCallApi = wrnexusCallApi;
|
||||
|
||||
function dispatchComponentEvent(root, name, detail) {
|
||||
if (!root || !name) return null;
|
||||
var EventConstructor =
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
import { expect, test, beforeEach } from "bun:test";
|
||||
import { Window } from "happy-dom";
|
||||
import { REACTIVE_RUNTIME } from "../src/reactive-runtime.ts";
|
||||
import { restoreGlobalsAfterAll } from "./global-restore.ts";
|
||||
|
||||
const REPLACED_GLOBALS = ["window", "document", "location", "fetch", "NodeFilter"];
|
||||
restoreGlobalsAfterAll(REPLACED_GLOBALS);
|
||||
|
||||
beforeEach(() => {
|
||||
for (const name of REPLACED_GLOBALS) delete (globalThis as Record<string, unknown>)[name];
|
||||
});
|
||||
|
||||
interface Call {
|
||||
url: string;
|
||||
init: RequestInit;
|
||||
}
|
||||
|
||||
/** Mount the runtime with a recording fetch and return its callApi plus the calls made. */
|
||||
function harness(response: { status: number; payload: unknown }) {
|
||||
const win = new Window() as unknown as Window & Record<string, unknown>;
|
||||
win.document.body.innerHTML = `<div data-scope="x: 1"></div>`;
|
||||
const calls: Call[] = [];
|
||||
|
||||
(globalThis as Record<string, unknown>).window = win;
|
||||
(globalThis as Record<string, unknown>).document = win.document;
|
||||
(globalThis as Record<string, unknown>).location = win.location;
|
||||
(globalThis as Record<string, unknown>).NodeFilter = (
|
||||
win as unknown as { NodeFilter: unknown }
|
||||
).NodeFilter;
|
||||
(globalThis as Record<string, unknown>).fetch = (url: string, init: RequestInit) => {
|
||||
calls.push({ url, init });
|
||||
return Promise.resolve({
|
||||
ok: response.status >= 200 && response.status < 300,
|
||||
status: response.status,
|
||||
json: () => Promise.resolve(response.payload),
|
||||
});
|
||||
};
|
||||
|
||||
(0, eval)(REACTIVE_RUNTIME);
|
||||
const callApi = (win as unknown as { __wrnexusCallApi: (...args: any[]) => Promise<any> })
|
||||
.__wrnexusCallApi;
|
||||
return { callApi, calls, win };
|
||||
}
|
||||
|
||||
test("GET builds a query string and omits undefined fields", async () => {
|
||||
const { callApi, calls } = harness({ status: 200, payload: { users: [] } });
|
||||
|
||||
await callApi("/api/users", "GET", { name: "Ajay", age: undefined });
|
||||
|
||||
expect(calls[0]!.url).toBe("/api/users?name=Ajay");
|
||||
expect(calls[0]!.init.method).toBe("GET");
|
||||
expect(calls[0]!.init.body).toBeUndefined();
|
||||
});
|
||||
|
||||
test("POST sends a JSON body", async () => {
|
||||
const { callApi, calls } = harness({ status: 200, payload: { ok: true } });
|
||||
|
||||
await callApi("/api/users", "POST", { name: "Ajay" });
|
||||
|
||||
expect(calls[0]!.url).toBe("/api/users");
|
||||
expect(calls[0]!.init.body).toBe(JSON.stringify({ name: "Ajay" }));
|
||||
expect((calls[0]!.init.headers as Record<string, string>)["content-type"]).toBe(
|
||||
"application/json",
|
||||
);
|
||||
});
|
||||
|
||||
test("a non-GET request carries the CSRF token from the cookie", async () => {
|
||||
const { callApi, calls, win } = harness({ status: 200, payload: {} });
|
||||
win.document.cookie = "wrn-csrf=token-123";
|
||||
|
||||
await callApi("/api/users", "POST", {});
|
||||
|
||||
expect((calls[0]!.init.headers as Record<string, string>)["x-csrf-token"]).toBe("token-123");
|
||||
});
|
||||
|
||||
test("a 2xx resolves to the parsed payload", async () => {
|
||||
const { callApi } = harness({ status: 200, payload: { users: [{ name: "Ajay" }] } });
|
||||
|
||||
expect(await callApi("/api/users", "GET", {})).toEqual({ users: [{ name: "Ajay" }] });
|
||||
});
|
||||
|
||||
test("a non-2xx rejects with status, message and data", async () => {
|
||||
const { callApi } = harness({ status: 400, payload: { error: "Bad filter" } });
|
||||
|
||||
const failure = await callApi("/api/users", "GET", {}).catch(
|
||||
(error: Error & { status?: number; data?: unknown }) => error,
|
||||
);
|
||||
|
||||
expect(failure.status).toBe(400);
|
||||
expect(failure.message).toContain("Bad filter");
|
||||
expect(failure.data).toEqual({ error: "Bad filter" });
|
||||
});
|
||||
@@ -0,0 +1,173 @@
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
import { Lexer, LexError, isIdentPart, isIdentStart, skipLiteralOrComment } from "./tokenizer.ts";
|
||||
|
||||
export interface ApiFieldDecl {
|
||||
name: string;
|
||||
optional: boolean;
|
||||
type: string;
|
||||
}
|
||||
|
||||
export interface ApiSections {
|
||||
parameters: ApiFieldDecl[];
|
||||
body: ApiFieldDecl[];
|
||||
response: string;
|
||||
error: string;
|
||||
}
|
||||
|
||||
const SECTION_NAMES = ["request", "response", "error"] as const;
|
||||
const REQUEST_SUBSECTION_NAMES = ["parameters", "body"] as const;
|
||||
|
||||
interface Span {
|
||||
text: string;
|
||||
/** Offset of `text[0]` within the source that was scanned. */
|
||||
start: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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: string, names: readonly string[]): Map<string, Span> {
|
||||
const found = new Map<string, Span>();
|
||||
const lx = new 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 = 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);
|
||||
|
||||
// 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 = 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: Span | undefined, outer: Span | undefined): Span | undefined {
|
||||
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: Span | undefined): ApiFieldDecl[] {
|
||||
if (!span) return [];
|
||||
const fields: ApiFieldDecl[] = [];
|
||||
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 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;
|
||||
}
|
||||
|
||||
export function parseApiSections(source: string): ApiSections | null {
|
||||
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<string, Span>();
|
||||
|
||||
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. */
|
||||
export function hasRequestSection(source: string): boolean {
|
||||
return scanTopLevelBlocks(source, SECTION_NAMES).has("request");
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { WRN_RUNTIME_TARGETS } from "./spec.ts";
|
||||
import { parseApiSections, hasRequestSection, type ApiSections } from "./api-sections.ts";
|
||||
|
||||
/**
|
||||
* Recursive-descent parser for `.wrn`, producing a small AST.
|
||||
@@ -148,7 +149,10 @@ export interface DataApiBlock {
|
||||
name: string;
|
||||
method: string;
|
||||
path: string;
|
||||
/** Legacy bare body. Empty string when `sections` is set. */
|
||||
body: string;
|
||||
/** Present only for the sectioned, typed form. */
|
||||
sections?: ApiSections;
|
||||
}
|
||||
|
||||
export interface ModeFunctionsBlock {
|
||||
@@ -696,7 +700,20 @@ export function parse(source: string): PageAst {
|
||||
const method = expect("ident").value.toUpperCase();
|
||||
const path = lx.readPath();
|
||||
const body = lx.readBalancedBraces();
|
||||
dataApis.push({ mode, name, 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,
|
||||
method,
|
||||
path,
|
||||
body: sections ? "" : body,
|
||||
...(sections ? { sections } : {}),
|
||||
});
|
||||
break;
|
||||
}
|
||||
case "functions": {
|
||||
|
||||
@@ -31,8 +31,47 @@ export interface Token {
|
||||
export class LexError extends Error {}
|
||||
|
||||
const isWs = (c: string) => c === " " || c === "\t" || c === "\n" || c === "\r";
|
||||
const isIdentStart = (c: string) => /[A-Za-z_]/.test(c);
|
||||
const isIdentPart = (c: string) => /[A-Za-z0-9_]/.test(c);
|
||||
export const isIdentStart = (c: string) => /[A-Za-z_]/.test(c);
|
||||
export const isIdentPart = (c: string) => /[A-Za-z0-9_]/.test(c);
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
export function skipLiteralOrComment(src: string, i: number, atLineStart: boolean): number | null {
|
||||
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;
|
||||
}
|
||||
|
||||
export class Lexer {
|
||||
pos = 0;
|
||||
@@ -312,46 +351,26 @@ export class Lexer {
|
||||
const start = this.pos + 1;
|
||||
let depth = 0;
|
||||
let i = this.pos;
|
||||
let str: string | null = 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 === "}") {
|
||||
depth--;
|
||||
@@ -360,6 +379,7 @@ export class Lexer {
|
||||
return src.slice(start, i);
|
||||
}
|
||||
}
|
||||
i++;
|
||||
}
|
||||
throw new LexError(`Unbalanced braces starting at offset ${this.pos}`);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
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 }"',
|
||||
);
|
||||
});
|
||||
@@ -8,6 +8,7 @@ const scratchParent = join(root, ".wrnexus-type-check");
|
||||
const scratch = mkdtempSync(`${scratchParent}-`);
|
||||
const generated = [
|
||||
join("app", "types", "wrnexus.generated.d.ts"),
|
||||
join("app", "types", "wrnexus.generated.api-checks.ts"),
|
||||
join("app", "types", "wrnexus.plugins.generated.d.ts"),
|
||||
];
|
||||
|
||||
|
||||
@@ -0,0 +1,302 @@
|
||||
import { readFileSync, readdirSync, statSync, writeFileSync } from "node:fs";
|
||||
import { join, relative } from "node:path";
|
||||
import console from "node:console";
|
||||
import process from "node:process";
|
||||
import { format } from "prettier";
|
||||
|
||||
const root = process.cwd();
|
||||
const json = (path) => JSON.parse(readFileSync(join(root, path), "utf8"));
|
||||
const rootPackage = json("package.json");
|
||||
const publicApi = json("docs/public-api-0.8.json");
|
||||
const uiReference = json("packages/ui/component-reference.json");
|
||||
|
||||
function walk(dir, extension, out = []) {
|
||||
for (const entry of readdirSync(dir)) {
|
||||
const path = join(dir, entry);
|
||||
const info = statSync(path);
|
||||
if (info.isDirectory() && !["node_modules", "dist", ".wrnexus"].includes(entry))
|
||||
walk(path, extension, out);
|
||||
else if (path.endsWith(extension)) out.push(path);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
const packageRows = readdirSync(join(root, "packages"), { withFileTypes: true })
|
||||
.filter((entry) => entry.isDirectory())
|
||||
.map((entry) => {
|
||||
const manifest = json(`packages/${entry.name}/package.json`);
|
||||
return {
|
||||
name: manifest.name,
|
||||
version: manifest.version,
|
||||
description: manifest.description ?? "—",
|
||||
};
|
||||
})
|
||||
.sort((a, b) => a.name.localeCompare(b.name));
|
||||
|
||||
const wrnFiles = walk(join(root, "packages"), ".wrn").sort();
|
||||
const packageBlocks = new Map();
|
||||
for (const file of wrnFiles) {
|
||||
const packageName = relative(join(root, "packages"), file).split(/[\\/]/)[0];
|
||||
const list = packageBlocks.get(packageName) ?? [];
|
||||
list.push(relative(root, file).replaceAll("\\", "/"));
|
||||
packageBlocks.set(packageName, list);
|
||||
}
|
||||
|
||||
const apiPackages = Object.entries(publicApi.packages);
|
||||
const apiSymbolCount = apiPackages.reduce(
|
||||
(total, [, entries]) =>
|
||||
total + Object.values(entries).reduce((sum, symbols) => sum + symbols.length, 0),
|
||||
0,
|
||||
);
|
||||
|
||||
const lines = [];
|
||||
const add = (...value) => lines.push(...value);
|
||||
|
||||
add(
|
||||
"# WRNexusJS complete framework and `.wrn` report",
|
||||
"",
|
||||
`Generated for workspace version **${rootPackage.version}** from the checked-out source and generated references.`,
|
||||
"",
|
||||
"> Scope and source of truth: this report describes the checked-out implementation, not only the prose docs. The parser in `packages/syntax`, compiler/runtime code, package manifests, `docs/public-api-0.8.json`, and `packages/ui/component-reference.json` take precedence when older documents disagree.",
|
||||
"",
|
||||
"## 1. Executive summary",
|
||||
"",
|
||||
`- Workspace framework version: **${rootPackage.version}**. The 48 independently published packages have their own patch versions; see Appendix A.`,
|
||||
"- Architecture: compiler-driven, SSR-first, Bun-native/full-stack, with Node-friendly selected tooling.",
|
||||
"- Static routes retain the zero-framework-JavaScript goal; hydration is selective and islands are loaded only where declared.",
|
||||
"- Current headline features include typed callable API blocks, reactive `if`/`each` control blocks, runtime-scoped state and functions, typed outputs, stores, React islands, HTML-aware editor tooling, package/plugin discovery, generated contracts, security gates, and multi-app RPC.",
|
||||
`- Audited public API baseline: **${apiPackages.length} packages / ${apiSymbolCount} exported symbols** across root and subpath exports.`,
|
||||
`- Audited packaged \`.wrn\` sources: **${wrnFiles.length}**; generated UI reference: **${uiReference.components.length} components**.`,
|
||||
"",
|
||||
"## 2. What a `.wrn` file is",
|
||||
"",
|
||||
"A `.wrn` file is a single compiler-owned source unit combining imports, a root declaration, typed data/state, runtime behavior, HTML view markup, styles, metadata, API handlers/bindings, and realtime handlers. It is parsed into the canonical AST owned by `@wrnexus/syntax`; `@wrnexus/compiler` turns that AST into SSR, browser, route, style, and metadata artifacts.",
|
||||
"",
|
||||
"Valid roots:",
|
||||
"",
|
||||
"| Root | Purpose |",
|
||||
"| --- | --- |",
|
||||
"| `page Name {}` | Routed page. |",
|
||||
"| `component Name {}` | Reusable server-rendered component. |",
|
||||
"| `layout Name {}` | Reusable page wrapper. |",
|
||||
"| `global store Name {}` | Application-wide store. |",
|
||||
"| `page store Name {}` | Page-lifetime store. |",
|
||||
"",
|
||||
"A file may start with static TypeScript imports. Component and layout symbols can be imported explicitly; compatibility discovery remains configurable for upgraded applications.",
|
||||
"",
|
||||
"## 3. Complete `.wrn` block and declaration catalog",
|
||||
"",
|
||||
"| Declaration/block | Shape | Meaning and current behavior |",
|
||||
"| --- | --- | --- |",
|
||||
"| `layout = LayoutSymbol` | root member | Preferred imported layout reference. String layout names remain a compatibility path. |",
|
||||
'| `runtime = "…"` | root member | Targets: `server`, `client`, `universal`, `edge`, `worker`, `service-worker`. |',
|
||||
'| `render = "…"` | root member | Modes: `static`, `server`, `hybrid`, `client`, `partial-static`. |',
|
||||
'| `hydrate = "…"` | root member | `load`, `idle`, `visible`, `interaction`, `none`, or `media:<query>`; legacy `never` normalizes to `none`. |',
|
||||
'| `client = "…"` | root member | Legacy alias for hydration configuration; `client {}` remains a different runtime-mode block. |',
|
||||
"| `types {}` | raw TypeScript | Local type declarations emitted for checking. |",
|
||||
"| `props {}` | typed declarations | Required without default; optional via `?`; defaults supported; legacy `@event name = function` is parsed for compatibility. |",
|
||||
"| `outputs {}` | typed declarations | Canonical child-to-parent callable output contract, zero or one typed payload. |",
|
||||
"| `state name = expr` / `state {}` | reactive data | Shared state; type annotation optional, initializer required. Arrays, objects, and multiline expressions are supported. |",
|
||||
"| `server state {}` / `client state {}` | runtime-scoped data | State visible only in the declared runtime boundary. |",
|
||||
"| `computed name = expr` / `computed {}` | derived data | Dependency-tracked cached values. |",
|
||||
"| `effect {}` | reactive side effect | Runs after batched updates when referenced reactive values change. |",
|
||||
"| `load server {}` / `load client {}` | loader | Runtime-specific loading; named/dependent/deferred forms are represented in the AST. |",
|
||||
"| `action name(args) {}` | action | Named action, optionally schema-backed, exported for adapters. |",
|
||||
"| `view {}` | HTML/template | HTML/component tree with expressions, events, directives, and reactive control blocks. |",
|
||||
"| `style {}` | scoped CSS | Multiple blocks allowed; promoted into the document head with CSP/HMR/navigation support. |",
|
||||
"| `seo {}` | metadata | Key/value SEO metadata. |",
|
||||
"| `security {}` | policy metadata | Auth, CSRF, roles, rate-limit and organization-specific enforcement metadata. |",
|
||||
"| `navigation {}` | navigation metadata | Page navigation policy/configuration consumed by runtime tooling. |",
|
||||
"| `cache {}` | cache metadata | Declarative framework cache policy. |",
|
||||
"| `functions {}` | shared helpers | Legacy/general shared helper body; runtime-specific function grammar is preferred where applicable. |",
|
||||
"| `server { functions {} }` / `client { functions {} }` | mode helper block | Raw helpers scoped to SSR or browser execution. |",
|
||||
"| `[async] server function name(args) {}` | callable function | Explicit server RPC boundary. |",
|
||||
"| `[async] client function name(args) {}` | browser function | Explicit client callable function. |",
|
||||
"| `[async] shared function name(args) {}` | universal helper | Explicit shared function. |",
|
||||
"| `ssr { api … }` | render-time own-route data | Executes during render. Legacy bare response body is supported; sectioned `response`/`error` is supported; request parameters are intentionally forbidden. |",
|
||||
"| `client { api … }` | callable own-route data | Sectioned form creates `api.name(input)` in browser scope; GET uses query parameters, other methods use JSON and CSRF. |",
|
||||
"| `api METHOD /path {}` | route handler | Defines an application API endpoint/handler. Distinct from named data API bindings inside `ssr`/`client`. |",
|
||||
"| `lifecycle { mount/update/unmount {} }` | component lifecycle | Hydrated lifecycle hooks. |",
|
||||
"| `watch stateName {}` | watcher | Runs for changes to the named state. |",
|
||||
"| `realtime name { on event(args) {} }` | websocket behavior | Declares named realtime handlers. |",
|
||||
"| `persist {}` | store persistence | Storage (`memory`, `session`, `local`), included keys, version, migrations, and validation. |",
|
||||
"| `lifecycle { serverInit/clientInit/hydrate/dispose {} }` | store lifecycle | Store-specific lifecycle form. |",
|
||||
"",
|
||||
"### View/template features",
|
||||
"",
|
||||
"- Standard HTML and custom/component tags; HTML void elements follow the platform list.",
|
||||
"- Escaped `{expression}` interpolation. Raw HTML is an explicit security boundary.",
|
||||
"- Browser event attributes: `@click`, `@window:scroll`, `@document:click`, and other event names.",
|
||||
"- Conditional classes through `class:name='expression'`; visibility through `data-show`.",
|
||||
'- Legacy loop attribute: `data-for="item, index in items key item.id"`, with optional `data-key`.',
|
||||
"- Canonical control blocks: `{#if}`, `{:else if}`, `{:else}`, `{/if}` and `{#each list as item, index key expr}`, `{:empty}`, `{/each}`. Initial output is SSR and remains reactive after hydration.",
|
||||
"- JSX-style expression props (`items={items}`, object/array expressions) are current; quoted expressions remain compatible. Literal HTML attributes remain quoted.",
|
||||
"- React/TSX islands use imported `.tsx` components and `client:only`, `client:load`, `client:visible`, or `client:idle`. They are client-only in v1; island props must be JSON-serializable.",
|
||||
"",
|
||||
"### Typed callable API block (latest form)",
|
||||
"",
|
||||
"```wrn",
|
||||
"client {",
|
||||
" api searchUsers POST /api/users {",
|
||||
" request { body { name?: string age?: number } }",
|
||||
" response { return data.users }",
|
||||
" error { return [] }",
|
||||
" }",
|
||||
"}",
|
||||
"```",
|
||||
"",
|
||||
"Call it with `await api.searchUsers({ name })`. GET uses `request { parameters {} }`; non-GET uses `body {}`. Fields are type-only declarations checked against generated route contracts in `app/types/wrnexus.generated.api-checks.ts`. Success binds parsed JSON as `data`. `error {}` converts failure to its returned value; without it, non-2xx, network, and parse failures reject. Targets are restricted to the current app’s `/api/*` routes. External APIs, custom headers, parameterized SSR calls, caching, and deduplication are deferred.",
|
||||
"",
|
||||
"Legacy API binding remains valid: `ssr { api users GET /api/users { return users } }`. Its bare body receives payload fields through the legacy dynamic scope. The sectioned form deliberately uses `data` so TypeScript can check it.",
|
||||
"",
|
||||
"## 4. Runtime, rendering, and data flow",
|
||||
"",
|
||||
"1. `@wrnexus/syntax` tokenizes/parses and emits stable diagnostics and AST nodes.",
|
||||
"2. The compiler resolves imports/components/islands and generates SSR HTML functions, client modules, route/API exports, styles, metadata, and contracts.",
|
||||
"3. Static pages ship no framework JS. Interactive pages receive only the required CSR runtime; island routes lazily receive React/island assets.",
|
||||
"4. State changes batch, invalidate computed values, run effects/watchers, update expressions/classes/visibility, and rerender `if`/`each` regions.",
|
||||
"5. Server functions use the RPC boundary; typed API blocks call same-app API routes; realtime blocks produce websocket handlers; stores bridge SSR and client state.",
|
||||
"6. Generated types validate component props, outputs, functions, routes, and typed API-block request contracts during `tsc` and release checks.",
|
||||
"",
|
||||
"## 5. Framework feature inventory",
|
||||
"",
|
||||
"- Routing and rendering: filesystem pages/layouts, static/request/hybrid/client/partial-static rendering, route analysis, advanced routing, CSR navigation, layouts, streaming/SSR packages.",
|
||||
"- Reactivity: state, computed values, effects, watchers, reactive attributes/events, SSR-to-client control blocks, loaders/actions, explicit hydration.",
|
||||
"- Components/UI: application components, package-owned blocks, generated prop/output references, theming/tokens, 102 audited first-party UI components, app overrides/ejection.",
|
||||
"- Data/backend: database drivers and migrations, repositories, cache, queue, pub/sub, realtime, GraphQL, route APIs, server functions, workspace RPC.",
|
||||
"- Identity/security: auth, authorization, OAuth, JWT/JWKS, MFA/passkeys/recovery, CAPTCHA, encryption, SSRF defenses, CSP/CSRF, request limits, audit/security gates.",
|
||||
"- Product capabilities: AI/RAG/provider adapters, content, i18n, image optimization, uploads, validation, PWA, native/mobile, analytics/tracking, observability.",
|
||||
"- Developer experience: CLI create/dev/build/update/doctor/inspect/generate/eject/db/workspace operations, HMR, dev toolbar, language server, VS Code completion/HTML editing/formatting/diagnostics, playground, MCP, tests/benchmarks/release validation.",
|
||||
"- Deployment: production builds, Docker and platform examples, migrations, package staging/integrity checks, SBOM and security/performance reports.",
|
||||
"",
|
||||
"## 6. Legacy-to-current migration map",
|
||||
"",
|
||||
"| Legacy/earlier approach | Current approach | Compatibility/status |",
|
||||
"| --- | --- | --- |",
|
||||
"| Compiler-owned/internal parsing imports | Canonical `@wrnexus/syntax` lexer/parser/AST/diagnostics | Compiler re-exports remain for compatibility; direct internals are deprecated. |",
|
||||
"| Implicit component discovery everywhere | Explicit imports and generated contracts | Upgraded apps can retain `legacyComponentDiscovery`; unresolved symbols are reported rather than guessed. |",
|
||||
'| `layout = "PublicLayout"` | Import layout and use `layout = PublicLayout` | String layouts remain behind compatibility configuration. |',
|
||||
"| `@event changed = function` in props | `outputs { changed(payload: Type) }` | v0.6 migration converts declarations; ambiguous payloads become `unknown`. |",
|
||||
'| `$emit("changed", value)` and `event.detail` | `output.changed(value)` and direct `payload` | Static cases auto-migrated; dynamic emit names require review. |',
|
||||
"| Unclassified functions | `server function`, `client function`, or `shared function` | v0.6 classifies unambiguous cases; compatibility default can preserve ambiguous behavior. |",
|
||||
"| Manually copied CAPTCHA JS/script tags | Package-discovered client runtime/assets | v0.4 removes tags and archives old assets under `.wrnexus/legacy-assets/0.4.0`. |",
|
||||
"| Package components/routes/assets wired manually | Automatic package/plugin discovery and contribution registry | Current CLI/build/dev server inspect and consume contributions. |",
|
||||
"| Only scalar/quoted dynamic props | Native arrays/objects and JSX-style unquoted expressions | Quoted expression attributes remain supported. |",
|
||||
"| `data-for` and older each forms | `{#each …}{:empty}{/each}` | Legacy loop forms remain supported; canonical blocks offer keyed/empty/reactive behavior. |",
|
||||
"| Static server-only `if`/`each` after hydration | Reactive client rerendering of control blocks | Current runtime updates branches/rows after state changes. |",
|
||||
"| Bare `api` binding bodies and hand-written `fetch` for inputs | Sectioned typed callable client API blocks | Bare body stays supported; new form adds inputs, route-contract checking, CSRF, response/error transforms. |",
|
||||
"| Generated API assertions in `.d.ts` | Assertions in real `wrnexus.generated.api-checks.ts` | Changed because `skipLibCheck` made `.d.ts` assertions inert. |",
|
||||
"| Client functions accidentally retaining TypeScript | Compiler strips type syntax before browser-module emission | Fixed and regression-tested. |",
|
||||
"| Markup merely highlighted as embedded HTML | Virtual HTML document plus HTML language service | Current editor adds tag/attribute completion, auto-close/rename, hover, Emmet, and folding; WRN formatter still owns formatting. |",
|
||||
"| Framework-only component ecosystem | Optional React `.tsx` islands | React is isolated and lazy; zero-JS routes remain unchanged; island SSR/Fast Refresh are deferred. |",
|
||||
"| Per-component/global style placement inconsistencies | `style {}` promoted to document head | Current pipeline supports CSP, HMR and CSR navigation. |",
|
||||
"| Manually maintained API/component knowledge | Generated public API and component references plus validation gates | `check:public-api`, generated-type checks, UI visual contract and package audits detect drift. |",
|
||||
"",
|
||||
"Compatibility flags visible in generated/upgraded config include `legacyEmit`, `legacyEventProps`, `legacyComponentDiscovery`, `stringLayouts`, and `functions.legacyDefaultRuntime`. New projects default legacy flags off; migration-created configs may enable them to preserve behavior until source modernization is complete.",
|
||||
"",
|
||||
"## 7. Diagnostics, security, and correctness guarantees",
|
||||
"",
|
||||
"Stable diagnostics include parse/member/prop/state/hydration/runtime/accessibility codes and feature-specific diagnostics such as island prop or missing-React errors. Compiler, CLI doctor/build, type generation, and editor tooling share syntax ownership to reduce parser drift.",
|
||||
"",
|
||||
"Security properties include escaped output by default, CSP-aware styles/scripts, same-origin API restriction, CSRF on non-GET callable API requests, credential handling via same-origin cookies, safe serialization, SSRF policies, request limits, auth/authz metadata and middleware, secret/audit gates, and application-layer encryption where explicitly needed. Security metadata is declarative input; enforcement still belongs to installed middleware/plugins and route policy.",
|
||||
"",
|
||||
"## 8. Current limitations and deferred work",
|
||||
"",
|
||||
"- Typed API blocks do not target third-party URLs, accept custom author headers, parameterize SSR requests, or provide built-in request caching/deduplication.",
|
||||
"- React islands are client-rendered in v1; island SSR/hydration and React Fast Refresh are deferred.",
|
||||
"- Native compilation does not directly port data API blocks; native screens use generated backend helpers.",
|
||||
"- HTML language features intentionally do not replace the WRN formatter.",
|
||||
"- Generated type checks must stay fresh; `check:generated-types` is the enforcement gate.",
|
||||
"",
|
||||
"## 9. Documentation drift discovered by this audit",
|
||||
"",
|
||||
`- Root ` +
|
||||
"`README.md`" +
|
||||
` says 0.8.0, while the workspace manifest is ${rootPackage.version}.`,
|
||||
`- ` +
|
||||
"`packages/ui/README.md`" +
|
||||
` says 85 components and ` +
|
||||
"`docs/UI-COMPONENT-INVENTORY.md`" +
|
||||
` says 891; the current generated component reference contains ${uiReference.components.length}.`,
|
||||
"- `docs/WRN-LANGUAGE-SPEC-1.0.md` calls itself the 0.3.x contract and predates several implemented roots/members (stores, rendering modes, outputs, runtime-scoped state/functions, React islands, sectioned callable API blocks). Use it as historical baseline, not a complete 0.8.8 reference.",
|
||||
"- Package patch versions are intentionally ahead of the workspace umbrella version in many packages. Consumers should use the actual package manifest/version selected by the release process.",
|
||||
"",
|
||||
"## 10. Recommended release verification",
|
||||
"",
|
||||
"Run `bun run check:production` for the complete production gate. Its chain covers workspace repair, generated types, public API, UI visual contract, 0.8 validation, framework/ASVS security, editor bundle freshness, typecheck, lint, component imports, package tests, formatting, and examples. Additional focused commands include `bun run test:all`, `bun run audit:packages`, `bun run test:package-kits`, `bun run validate:staging`, `bun run sbom`, and `bun run benchmark:framework`.",
|
||||
"",
|
||||
"## Appendix A — package/version inventory",
|
||||
"",
|
||||
"| Package | Version | Purpose |",
|
||||
"| --- | --- | --- |",
|
||||
);
|
||||
for (const item of packageRows)
|
||||
add(`| \`${item.name}\` | ${item.version} | ${item.description.replaceAll("|", "\\|")} |`);
|
||||
|
||||
add("", "## Appendix B — complete audited public export inventory", "");
|
||||
for (const [packageName, entries] of apiPackages) {
|
||||
const count = Object.values(entries).reduce((sum, symbols) => sum + symbols.length, 0);
|
||||
add(`### \`${packageName}\` (${count} symbols)`, "");
|
||||
for (const [subpath, symbols] of Object.entries(entries)) {
|
||||
add(`- **${subpath}:** ${symbols.map((symbol) => `\`${symbol}\``).join(", ")}`);
|
||||
}
|
||||
add("");
|
||||
}
|
||||
|
||||
add("## Appendix C — current UI component/block reference", "");
|
||||
for (const component of uiReference.components) {
|
||||
const props = component.props.length
|
||||
? component.props
|
||||
.map(
|
||||
(prop) =>
|
||||
`${prop.name}: ${prop.type}${prop.required ? " (required)" : ` = ${prop.default}`}`,
|
||||
)
|
||||
.join("; ")
|
||||
: "none";
|
||||
const outputs = component.outputs?.length
|
||||
? component.outputs.map((output) => `${output.name}(${output.payloadType})`).join("; ")
|
||||
: "none";
|
||||
const slots = component.slots?.length ? component.slots.join(", ") : "none";
|
||||
add(
|
||||
`### \`${component.name}\``,
|
||||
"",
|
||||
`- Category: ${component.category}; mount: \`${component.mount}\`; source: \`${component.source}\`.`,
|
||||
`- Purpose: ${component.purpose}`,
|
||||
`- Props: ${props}`,
|
||||
`- Outputs/events: ${outputs}`,
|
||||
`- Slots: ${slots}`,
|
||||
"",
|
||||
);
|
||||
}
|
||||
|
||||
add("## Appendix D — all other package-owned `.wrn` blocks", "");
|
||||
for (const [packageName, files] of [...packageBlocks.entries()].sort(([a], [b]) =>
|
||||
a.localeCompare(b),
|
||||
)) {
|
||||
if (packageName === "ui") continue;
|
||||
add(
|
||||
`### \`@wrnexus/${packageName}\` (${files.length})`,
|
||||
"",
|
||||
...files.map((file) => `- \`${file}\``),
|
||||
"",
|
||||
);
|
||||
}
|
||||
|
||||
add(
|
||||
"## Appendix E — authoritative files",
|
||||
"",
|
||||
"- Language parser/AST: `packages/syntax/src/parser.ts`, `v060.ts`, `api-sections.ts`, `spec.ts`.",
|
||||
"- Compiler/runtime: `packages/compiler/src`, `packages/csr/src`, `packages/ssr/src`, `packages/dev-server/src`.",
|
||||
"- Migration registry: `packages/cli/src/update.ts`.",
|
||||
"- Public exports: `docs/public-api-0.8.json` (checked by `scripts/check-public-api.mjs`).",
|
||||
"- UI blocks: `packages/ui/component-reference.json` and `packages/ui/COMPONENTS.md`.",
|
||||
"- Latest feature designs: `docs/superpowers/specs/2026-08-19-typed-api-block-design.md`, `2026-08-18-react-islands-design.md`, and `2026-08-18-wrn-html-editing-design.md`.",
|
||||
"",
|
||||
"---",
|
||||
"",
|
||||
"This report is reproducible: run `node scripts/generate-complete-framework-report.mjs` after implementation or generated-reference changes.",
|
||||
);
|
||||
|
||||
const report = await format(`${lines.join("\n")}\n`, { parser: "markdown" });
|
||||
writeFileSync(join(root, "docs", "WRNEXUS-COMPLETE-FEATURE-REPORT.md"), report, "utf8");
|
||||
console.log(`Wrote docs/WRNEXUS-COMPLETE-FEATURE-REPORT.md (${lines.length} lines).`);
|
||||
@@ -181,7 +181,9 @@ const runtimeBudgets = {
|
||||
* 50,156 minified is ~16,000 gzipped, once, behind an immutable year-long
|
||||
* cache.
|
||||
*/
|
||||
"reactive-runtime.ts": 50_500,
|
||||
// Raised to 51_400: the callApi transport (query building, CSRF header,
|
||||
// JSON body, success/failure contract) for compiled api blocks bought ~1,025 bytes.
|
||||
"reactive-runtime.ts": 51_400,
|
||||
"component-controllers.ts": 24_100,
|
||||
"nav-runtime.ts": 12_000,
|
||||
"realtime-runtime.ts": 8_000,
|
||||
|
||||
Reference in New Issue
Block a user