Expands 3.1 and 3.2 so the work can be done without re-deriving anything. 3.1 now records what 0.8.6 already fixed, separated into the ten components that were miswired and the five that gained outputs they had been firing undeclared, with the caveat that Map's three were converted but never confirmed in a browser. For the 22 that remain it adds the finding that changes the decision: all nine are pure scaffolds with no state, functions or handlers, and five of them duplicate a component that already works -- FileUpload against FileInput and FileUploadProgress, Toast and ToastNotifications against Toaster, AdvancedDatePicker against DatePicker, AdvancedRangeSlider against RangeSlider. Superseding those is a migration entry rather than new code, and leaves Chart, TreeView, Confetti and CopyMarkup as the only ones needing to be built. 3.2 corrects the scaffold count from 23 to 28; the earlier figure used a looser rule. Nine of the 28 are the 3.1 components, so the two items must be planned together, and several of the rest are primitives that need only their styles moved out of ui.css rather than any behaviour. Also corrects the dead-output component count from 11 to 9 in both documents. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
277 lines
12 KiB
Plaintext
277 lines
12 KiB
Plaintext
# WrNexus
|
|
|
|
> WrNexus is an SSR-first, **Bun-native** full-stack web framework. UI is written in
|
|
> `.wrn` files (its own component language — NOT React/JSX/Vue). Routing is file-based.
|
|
> This document teaches an AI how to write correct WrNexus code. It is private and
|
|
> post-dates model training data, so rely on THIS document, not prior web-framework
|
|
> assumptions.
|
|
|
|
## Golden rules
|
|
|
|
- **Pages, components, and layouts are `.wrn` files.** Do NOT write `.tsx`/`.jsx`/React
|
|
for UI. Do NOT use `useState`, hooks, JSX, or a client bundler.
|
|
- **Routing is file-based** under `app/`. The filename is the route. No router config.
|
|
- **Interactivity** lives in `state` + `{expr}` + `@event` inside `.wrn`. Components render
|
|
on the server and hydrate automatically — you never write client-side JS islands.
|
|
- **Runtime is Bun only** (uses `Bun.serve`, `bun:sqlite`, `Bun.password`, …). Node is not supported.
|
|
- To add files, prefer the CLI: `wrnexus generate page <Name>` / `component <name>` / `api <path>` / `schema <name>`.
|
|
|
|
## Project layout
|
|
|
|
```
|
|
app/
|
|
pages/ *.wrn → routes: index.wrn = "/", about.wrn = "/about", blog/[slug].wrn = "/blog/:slug"
|
|
components/ *.wrn → reusable UI, mounted in a page/component via <div data-component="name" ...props>
|
|
layouts/ *.wrn → named layouts; a page opts in with layout = "name"
|
|
api/ *.ts → HTTP handlers: export const GET/POST/PUT/PATCH/DELETE = async (ctx) => Response
|
|
middleware/ *.ts → export default async (ctx, next) => next()
|
|
realtime/ *.ts → export default defineRoom({ ... }) from "@wrnexus/core" (ws://host/realtime/<name>)
|
|
schemas/ *.ts → validation schemas (the `v` builder), used by forms + parseBody
|
|
locales/ *.json → i18n messages per language
|
|
db/ schema.ts, queries/*.sql, migrations/*.sql
|
|
styles/ global.css → Tailwind (default) or plain CSS
|
|
wrnexus.config.ts → app config (AppConfig from "@wrnexus/styles")
|
|
public/ → static assets served at /
|
|
```
|
|
|
|
## `.wrn` page
|
|
|
|
```wrn
|
|
page Home {
|
|
layout = "public" // optional: a component in app/layouts/<name>.wrn ("none" to skip)
|
|
|
|
state count = 0 // optional: seeds client-reactive state (omit for pure SSR)
|
|
|
|
seo {
|
|
title = "Home"
|
|
description = "..."
|
|
canonical = "/"
|
|
}
|
|
|
|
view {
|
|
<h1>Hello</h1>
|
|
<p>Count is {count}, doubled is {count * 2}.</p>
|
|
<button @click="count++">Increment</button>
|
|
<div data-component="counter" start="5" label="Clicks"></div>
|
|
}
|
|
|
|
style {
|
|
h1 { color: var(--wire-color-text); }
|
|
}
|
|
}
|
|
```
|
|
|
|
## `.wrn` component
|
|
|
|
```wrn
|
|
component Counter {
|
|
props { // props come from mount attributes; each is coerced to the
|
|
start = 0 // TYPE of its default (so start="5" arrives as the number 5)
|
|
label = "Count"
|
|
}
|
|
state count = start // state may reference props
|
|
view {
|
|
<button @click="count++">{label}: {count}</button>
|
|
}
|
|
}
|
|
```
|
|
|
|
Mount it from any page/component: `<div data-component="counter" start="0" label="Clicks"></div>`.
|
|
Components render on the server with their props, then hydrate — no per-component JS.
|
|
|
|
## The `view { }` block (plain HTML + a few directives)
|
|
|
|
- `{expr}` — interpolate a JS expression. Reactive if it references `state`: `{count}`, `{count * 2}`, `{user.name}`.
|
|
- `@event="expr"` — bind a DOM event; the expression runs in the reactive scope: `@click="count++"`, `@input="name = event.target.value"`.
|
|
- `<div data-component="name" prop="v">` — mount a component (attrs become string props, coerced).
|
|
- `<slot></slot>` / `<slot name="x"></slot>` — component/layout slots; fill with `<div data-slot="x">…</div>`.
|
|
- **Server loop (DB/list/table):** `{#each <list> as <item>[, <i>]} …rows… {:empty} …fallback… {/each}` — iterates SSR data on the server and renders markup per item. `{item.field}` interpolates (HTML-escaped, XSS-safe). `<list>` is a JS expression, usually an `ssr` data binding (see "Data-driven tables" below). This is how you render a database table in `.wrn`.
|
|
- **Server conditional:** `{#if <expr>} … {:else if <expr>} … {:else} … {/if}` — renders the first truthy branch on the server. `<expr>` can reference `ssr` data, or the `item`/`index` of an enclosing `{#each}`. Works at page level and inside loops (e.g. `{#if r.active}<span>●</span>{:else}<span>○</span>{/if}` per row). For client-side show/hide based on reactive `state`, use `data-show="expr"` instead.
|
|
- i18n: `{t:home.title}` in text, `t:placeholder="form.name"` on attributes — resolved per request from `app/locales/`.
|
|
- Theme: any element with `data-wire-theme-toggle` toggles light/dark; `data-wire-theme-set="dark"` sets it.
|
|
- Void/self-closing tags are fine: `<br />`, `<img src="..." />`.
|
|
- Only `{` and `}` are special (interpolation). Don't use a bare `}` in view text.
|
|
|
|
## Data-driven tables / lists (server-rendered `.wrn`)
|
|
|
|
Use an `ssr` data binding to fetch rows on the server, then `{#each}` to render them.
|
|
This renders on the **server** (SSR-first) and is HTML-escaped by default.
|
|
|
|
```wrn
|
|
page Admin {
|
|
layout = "dashboard"
|
|
|
|
// Fetch on the server. The api handler at /api/contacts returns { contacts: [...] };
|
|
// this block's `return contacts` exposes that array (via `$data`) as the binding `rows`.
|
|
ssr {
|
|
api rows GET /api/contacts { return contacts }
|
|
}
|
|
|
|
view {
|
|
<table>
|
|
<tbody>
|
|
{#each rows as r, i}
|
|
<tr>
|
|
<td>#{i}</td>
|
|
<td>{r.name}</td>
|
|
<td><a href="mailto:{r.email}">{r.email}</a></td>
|
|
</tr>
|
|
{:empty}
|
|
<tr><td colspan="3">No submissions yet.</td></tr>
|
|
{/each}
|
|
</tbody>
|
|
</table>
|
|
}
|
|
}
|
|
```
|
|
|
|
The matching API returns the array under a key the `ssr` block reads:
|
|
|
|
```ts
|
|
// app/api/contacts.ts → GET /api/contacts
|
|
import { getDb } from "@wrnexus/db";
|
|
export const GET = async () => {
|
|
const contacts = await getDb().all("SELECT id, name, email FROM contacts ORDER BY id DESC");
|
|
return Response.json({ contacts }); // ssr block does `return contacts`
|
|
};
|
|
```
|
|
|
|
**Prefer this `.wrn` + `{#each}` approach for DB-backed tables and lists.** (`.ts`/`.tsx`
|
|
pages returning an HTML string are also supported for fully-custom programmatic rendering,
|
|
but a `.wrn` page with `ssr` data + `{#each}` is the idiomatic, SSR-first way.)
|
|
|
|
## API routes (`app/api/*.ts`)
|
|
|
|
```ts
|
|
// app/api/users/list.ts → GET /api/users/list
|
|
import { getDb } from "@wrnexus/db";
|
|
|
|
export const GET = async (ctx) => {
|
|
return Response.json({ users: await ListUsers(getDb()) });
|
|
};
|
|
|
|
export const POST = async (ctx) => {
|
|
const body = await ctx.req.json();
|
|
return Response.json({ ok: true, body }, { status: 201 });
|
|
};
|
|
```
|
|
|
|
`ctx` (the `Context` from `@wrnexus/core`) has:
|
|
`req: Request`, `url: URL`, `params: Record<string,string>` (dynamic route params, e.g. `/users/[id]` → `ctx.params.id`),
|
|
`lang: string`, `t(key, params?)` (i18n), `cookies` (get/set), `session` (get/set). Auth: `getUser(ctx)` after `sessionAuth`/`logIn`.
|
|
|
|
When an SSO forward-auth verifier needs the URL that originally reached the gateway, use
|
|
`@wrnexus/helpers` instead of constructing it from untrusted headers:
|
|
|
|
```ts
|
|
import { redirectToLogin } from "@wrnexus/helpers";
|
|
|
|
return redirectToLogin(ctx, "/login", {
|
|
allowedHosts: ["admin.localhost:3000", "reports.localhost:3000"],
|
|
});
|
|
```
|
|
|
|
The package also exports `getOriginalRequestUrl`, `getOriginalRequestOrigin`,
|
|
`getOriginalRequestPath`, and `getOriginalRequestMethod`. Always pass `allowedHosts` when
|
|
using forwarded gateway URLs; the helper rejects untrusted redirect destinations.
|
|
|
|
## Middleware & realtime
|
|
|
|
```ts
|
|
// app/middleware/logger.ts
|
|
export default async function logger(ctx, next) {
|
|
console.log(ctx.req.method, ctx.url.pathname);
|
|
return next(); // return a Response WITHOUT calling next() to short-circuit
|
|
}
|
|
```
|
|
|
|
```ts
|
|
// app/realtime/chat.ts → ws://host/realtime/chat
|
|
import { defineRoom } from "@wrnexus/core";
|
|
export default defineRoom({
|
|
onConnect(client) { client.send({ type: "system", text: "connected" }); },
|
|
onMessage(client, msg) { client.room.broadcast({ type: "message", data: msg }); },
|
|
});
|
|
```
|
|
Client side: a page opts in with `data-room="chat"` (handled by the realtime runtime).
|
|
|
|
## Config (`wrnexus.config.ts`)
|
|
|
|
```ts
|
|
import type { AppConfig } from "@wrnexus/styles";
|
|
const config: AppConfig = {
|
|
seo: { title: "App", titleTemplate: "%s | App", description: "..." },
|
|
styles: { entry: "app/styles/global.css", process: async ({ entryPath, mode }) => /* Tailwind */ "" },
|
|
fonts: { sans: '"Inter", system-ui, sans-serif', google: [{ family: "Inter", weights: [400, 600] }] },
|
|
theme: { default: "dark", themes: { light: { "color-primary": "#2563eb" } } },
|
|
i18n: { default: "en", locales: ["en", "es"] },
|
|
db: { driver: "sqlite", url: "file:./dev.db" },
|
|
security: { cors: { enabled: true, origin: ["http://localhost:5173"] } },
|
|
// profiles: { production: { db: { driver: "postgres", url: process.env.DATABASE_URL } } },
|
|
};
|
|
export default config;
|
|
```
|
|
|
|
## Database (`@wrnexus/db`)
|
|
|
|
```ts
|
|
// app/db/schema.ts
|
|
import { v, table } from "@wrnexus/db";
|
|
export const users = table("users", {
|
|
id: v.id(),
|
|
name: v.string(),
|
|
email: v.string().unique(),
|
|
createdAt: v.timestamp(),
|
|
});
|
|
```
|
|
- Queries: write `app/db/queries/*.sql` with `-- name: ListUsers :many` blocks; `wrnexus db generate` emits typed functions.
|
|
- Access at runtime: `import { getDb } from "@wrnexus/db"; const rows = await ListUsers(getDb());`
|
|
- Migrations in `app/db/migrations/`; run `wrnexus db migrate` (dev auto-migrates sqlite).
|
|
|
|
## Validation (`@wrnexus/validation`)
|
|
|
|
```ts
|
|
// app/schemas/login.ts
|
|
import { v } from "@wrnexus/validation";
|
|
export default v.object({
|
|
email: v.string().email(),
|
|
password: v.string().min(8),
|
|
});
|
|
```
|
|
In an API route: `import s from "../schemas/login"; import { parseBody } from "@wrnexus/validation"; const r = await parseBody(s, ctx.req);` → `r.ok ? r.value : r.response`.
|
|
In a form: `<form data-schema="login" action="/api/login" method="post">` + `<span data-error="email"></span>` (client + server validation wired automatically).
|
|
|
|
## AI / LLM (`@wrnexus/ai`)
|
|
|
|
```ts
|
|
// app/api/ai.ts
|
|
import { createAI } from "@wrnexus/ai";
|
|
const ai = createAI(); // reads ANTHROPIC_API_KEY; default model claude-opus-4-8
|
|
export const POST = async (ctx) => {
|
|
const { prompt } = await ctx.req.json();
|
|
return ai.streamResponse(prompt); // or: return Response.json({ text: await ai.generate(prompt) })
|
|
};
|
|
```
|
|
|
|
## CLI
|
|
|
|
```
|
|
wrnexus dev . # dev server + HMR
|
|
wrnexus build . # production build → dist/server.js
|
|
bun dist/server.js # run the production server (or npm start)
|
|
wrnexus create <name> # scaffold a new app
|
|
wrnexus update --latest # deps + syntax/config migrations + verification
|
|
wrnexus generate page <Name> # scaffold a page (aliases: g p)
|
|
wrnexus generate component <name> | api <path> | schema <name>
|
|
wrnexus db migrate | rollback | status | new [--from-models] | generate | seed
|
|
wrnexus eject <component> # copy a Wire UI component's .wrn into app/components to customize
|
|
```
|
|
|
|
## When asked to "create a page/component/feature"
|
|
|
|
1. Create the `.wrn` file under `app/pages/` (or `app/components/`) with a `page`/`component` block — or run `wrnexus generate page <Name>`.
|
|
2. Put markup in `view { }`, interactive bits in `state` + `{expr}` + `@event`, reusable UI as components mounted via `data-component`.
|
|
3. For data, add an `app/api/*.ts` route and `getDb()`; for forms, add an `app/schemas/*.ts` and `data-schema`.
|
|
4. Style with Tailwind utility classes in the view, or theme tokens (`var(--wire-*)`), or `style { }`.
|
|
5. Never emit React/JSX, a manual router, or client-side island JS — the framework handles hydration.
|