57 KiB
WrNexus — The Complete Guide
One document to build WrNexus apps fast and correctly. It covers every package, the whole
.wrnlanguage, all attributes and config properties, and a step‑by‑step path fromcreateto a full app: pages, components, reactivity, APIs, validation, database, auth, i18n, theming, realtime, testing, and deploy.
WrNexus is an SSR‑first, Bun‑first full‑stack framework. Pages render to HTML on the
server; interactivity is added by server‑rendered components hydrated in the browser by a
single generic runtime (no per‑component bundles). The .wrn language compiles to TypeScript.
Conventions used here
app/…paths are relative to your app root (wherewrnexus.config.tslives).- ⚠️ marks a gotcha that bites people.
- Reactive/runtime attributes owned by WrNexus are called directives (
data-for,@event, …).
Table of contents
Part I — Getting started
- Install & create · 2. Run · 3. Project structure · 4. The request lifecycle
Part II — The .wrn language (complete)
-
pagevscomponent· 6. Every block keyword · 7. Theview: interpolation, events, directives · 8. Slots · 9. Gotchas
Part III — Build an app, feature by feature
-
- A page · 11. Layouts · 12. Components & props · 13. Reactivity · 14. Wire UI · 15. Theming · 16. Styles · 17. API routes · 18. Validation (forms + API) · 19. Database · 20. Auth, sessions & CSRF · 21. Middleware · 22. i18n · 23. SEO · 24. Security headers & CORS · 25. Realtime · 26. SSR/CSR data bindings · 27. Optional packages · 28. Config profiles & env · 29. Testing · 30. Build & deploy
Part IV — Reference
-
- CLI · 32.
wrnexus.config.tsschema · 33. Directive & attribute cheat sheet · 34. Package API index · 35. TheContextobject · 36. Editor support
- CLI · 32.
Part I — Getting started
1. Install & create
Requires Bun ≥ 1.1.
# From the WrNexus repo, the CLI is packages/cli. In a published setup:
wrnexus create my-app
cd my-app
bun install
wrnexus create <name> scaffolds: package.json, ESLint/Prettier config, wrnexus.config.ts
(minimal), public/robots.txt, and starter app/pages/index.tsx, app/pages/about.tsx,
app/api/hello.ts, app/middleware/logger.ts, app/realtime/chat.ts, app/client/counter.ts.
It refuses to overwrite an existing directory.
2. Run
wrnexus dev . # dev server, live reload/HMR, auto-migrates DB, regenerates typed routes+queries
wrnexus dev . --port=4000 # pick a port (default 3000)
wrnexus build . # production bundle → dist/server.js (production profile auto-applied)
bun dist/server.js # run the built server (honors PORT env)
wrnexus dev is a supervisor: it regenerates typed DB queries and typed routes, then spawns the
dev server child (which owns file watching + HMR — CSS/component edits stream over WebSocket with
no full reload). On a crash it respawns.
3. Project structure
my-app/
wrnexus.config.ts # optional: seo, head, security, theme, i18n, db, styles, profiles, port
public/ # static assets → served at / (robots.txt, images, …)
app/
pages/ # file-based pages → routes (index.wrn → /, about.wrn → /about, users/[id].wrn → /users/:id)
components/ # reusable .wrn components (mount with data-component="name")
layouts/ # layout .wrn files (a component with a <slot>); page picks via layout = "name"
api/ # file-based API routes → /api/* (export GET/POST/… handlers)
middleware/ # global middleware (one file = one middleware; run in the chain)
realtime/ # WebSocket rooms → /realtime/* (export default defineRoom({...}))
schemas/ # validation schemas (v.object(...)) shared by forms + APIs
styles/ # global CSS (global.css → every page)
locales/ # i18n dictionaries <lang>.json (opt-in i18n)
db/
schema.ts # TS models — the source of truth
migrations/ # *.sql (-- +up / -- +down)
queries/ # *.sql (typed query definitions)
queries.gen.ts # AUTO-GENERATED from queries/*.sql
seed.ts # re-runnable dev seed data
routes.gen.ts # AUTO-GENERATED typed route table
The app folder is convention‑based: drop a file in the right directory and it becomes a route. Requests are matched against a table scanned at startup — user input never becomes a file path.
4. The request lifecycle
- Middleware chain (
app/middleware/*) runs in order; any middleware may short‑circuit. - Router matches the path → page, API route, or realtime room.
- Pages: the page renders HTML → components (
data-component) are rendered on the server and spliced in → the layout wraps it → SEO<head>is built → the reactive runtime is injected only if the page contains reactive markup. - API routes: your
GET/POST/… handler returns aResponse. - Security headers are applied; the response is sent.
Part II — The .wrn language (complete)
5. page vs component
A .wrn file is exactly one top‑level block:
page Home { … } // a route (file path under app/pages/ → URL)
component Counter { … } // a reusable, prop-driven fragment (mounted via data-component)
Both parse to the same shape but compile differently:
page |
component |
|
|---|---|---|
| Becomes a route | ✅ (from file path) | ❌ (mounted in a page) |
props |
ignored | ✅ coerced + injected |
layout |
✅ selects a layout | ignored |
api / ssr / client / realtime |
✅ emitted | ignored |
Non‑state {expr} in view |
live client mustache | baked server‑side (HTML‑escaped) |
| Ships JS? | only if it has state, @event, {expr}, or CSR bindings |
only if it has state/@event (pure prop components ship zero JS) |
Layouts are components. A layout file is a component with a <slot> where the page body goes.
A page selects one with layout = "public" (→ app/layouts/public.wrn); layout = "none" opts out.
6. Every block keyword
Inside page/component { … }, these members are allowed (zero or more of each unless noted):
layout (page only)
layout = "public"
props { } (component) — ⚠️ one declaration per line
props {
start = 0 // default's TYPE drives coercion: start="5" arrives as number 5
label = "Count" // string
disabled = false // boolean
}
state — ⚠️ one per line
state count = start // may reference a prop or earlier state
state total = 0
State seeds the reactive scope. On pages, seeds are also evaluated at compile time for SSR baking.
view { }
view { <html> … </html> }
Plain HTML with interpolation, events, and directives — see §7.
seo { } (page)
seo {
title = "Home"
description = "Welcome"
canonical = "/hello"
}
Keys are arbitrary and land in the page meta (merged with global seo config). Values may be
quoted or bare‑to‑end‑of‑line.
style { } — scoped CSS, inlined with the page/component
style {
.box { background: var(--wire-color-surface); border-radius: var(--wire-radius); }
}
Multiple style blocks accumulate. Use theme tokens (var(--wire-*)) so styles restyle on theme
change.
functions { } — shared server‑only helpers
functions {
function slug(s) { return s.toLowerCase().replace(/\s+/g, "-"); }
}
Available to api, ssr/client, and realtime bodies.
api <METHOD> <path> { } — colocated API route
api POST /subscribe {
const body = await ctx.req.json();
return Response.json({ ok: true });
}
Method is upper‑cased; path is auto‑prefixed to /api and traversal‑checked. ctx is in scope.
ssr { } / client { } — named data bindings (see §26)
ssr {
functions { function names(u){ return u.map(x=>x.name).join(", "); } }
api userList GET /api/users/ssr { // ⚠️ note the NAME before the method
return names(users);
}
}
Each may contain only api <name> <METHOD> <path> { } and functions { }. Reference a binding
from the view with api="userList" on an element.
realtime <name> { on <evt>(args) { } } — inline WS handlers (legacy form)
realtime chat {
on message(data) { broadcast(data); }
}
Compiles to export const websocket = { message(ws, data) { … } }. For real apps prefer a
standalone app/realtime/<name>.ts with defineRoom (§25); the inline form is the compiler's
original shape.
7. The view: interpolation, events, directives
The view body is a lenient HTML parser. Elements, self‑closing <tag/>, void elements
(<br>, <img>, …), and <!-- comments --> (dropped) all work. Attribute values must be
quoted. A valueless attribute is boolean (<button disabled>).
Text interpolation {expr}
- Page + references
state→ baked to<span data-text="expr">value</span>; no‑JS clients see the real value, the runtime keeps it live. - Page + non‑state → left as a literal mustache; the runtime fills it at hydration.
- Component + prop/constant → baked server‑side (HTML‑escaped), not reactive.
- Component + state → reactive
data-textspan.
Both {expr} and {{ expr }} are accepted.
⚠️ Attribute interpolation (href="{url}") is compiled only in components. On a page,
{expr} inside a normal attribute is inert (except inside a data-for item). Prefer components for
attribute interpolation.
Translations {t:key}
<h1>{t:home.title}</h1>
<div data-component="counter" label="{t:nav.dashboard}"></div> <!-- works in prop values too -->
Compiles to <span data-t="key"> and is resolved per request against the active language. It also
resolves inside component prop values.
Event bindings @event="statement"
<button @click="count++">+</button>
<button @click="count = 0">reset</button>
<input @input="name = 'x'">
@name compiles to data-on-name. The event name is arbitrary (any DOM event, hyphens allowed).
The statement runs in the element's reactive scope. Supported statements: x++/x--,
x = expr, compound assign (+= -= *= /= %=), bare expressions/method calls, and multiple
statements separated by ;. Expressions use a CSP‑safe, eval‑free parser: literals,
identifiers, member/index access, calls, arrays/objects, + - * / %, comparisons, == != === !==,
&& ||, unary ! - +, and ternary ?:.
Directives (the data-* the runtime understands)
| Directive | Syntax | What it does |
|---|---|---|
data-scope |
data-scope="count: 0, name: 'x'" |
Declares reactive state on a subtree. The compiler emits this automatically when a page/component has state. A binding is owned by its nearest data-scope ancestor (nesting is safe). |
data-on-<event> |
data-on-click="count++" |
Event handler (the compiled form of @event). |
data-text |
data-text="count * 2" |
textContent follows the expression. Emitted by state interpolation; you can also hand‑write it. |
data-show |
data-show="open" |
Toggles display on truthiness. Hand‑authored (no {} sugar). |
data-for |
data-for="item in items" or data-for="item, i in items" |
Repeats the element per list item. Inside, item/i are locals; per‑item data-text, data-on-*, attribute mustaches, and text mustaches are filled. Hand‑authored. |
data-component |
data-component="counter" |
Mounts a component (server‑rendered, then hydrated). See §12. |
data-slot |
<div data-slot="header">…</div> |
Fills a named <slot name="header"> of a component (see §8). |
Example — a reactive list you write by hand:
view {
<div data-scope="items: [{t:'a'},{t:'b'}], open: true">
<button @click="open = !open">toggle</button>
<ul data-show="open">
<li data-for="i in items" data-text="i.t"></li>
</ul>
</div>
}
There are also framework attributes consumed by other runtimes (loaded only when present):
data-wire-theme-toggle/data-wire-theme-set (theme, §15), data-schema/data-error/
data-success/data-redirect (forms, §18), data-wire-lang-set/data-wire-lang (i18n, §22),
data-room* (realtime, §25).
Cross-platform pages can use data-native-browser="capability" and
data-native-mobile="capability" with JSON data-native-options. Use
data-native-only="browser|mobile|ios|android" for platform visibility,
data-native-requires="capability" for support gating, and @browser-event /
@mobile-event for platform-specific reactive handlers. These declarative capability
actions run in browsers and Capacitor WebViews. Expo compilation selects mobile markup
and handlers but requires installed Expo APIs to be called from native screen code.
8. Slots
A component's output may include slots; the mount's children fill them.
// app/layouts/dashboard.wrn
component Dashboard {
view {
<header><slot name="actions"></slot></header>
<main><slot></slot></main> <!-- default slot: the page body -->
}
}
// a page using it
page Reports {
layout = "dashboard"
view {
<div data-slot="actions"><button>New</button></div> <!-- fills the named slot; wrapper is dropped -->
<h1>Reports</h1> <!-- non-slotted content → default slot -->
}
}
<slot>fallback</slot> keeps its fallback when nothing is provided.
9. Gotchas
props/stateare one‑per‑line (the value is read to end‑of‑line). No commas/semicolons.- Comments:
//outsideview;<!-- … -->insideview(a//inviewis literal text). {/}are reserved in view text for interpolation.- Void elements take no closing tag.
ssr/clientapihas a name before the method; top‑levelapidoes not.propson apageare ignored — they only matter forcomponent.- Reserved JS words as prop/state names are fine (auto‑renamed internally in components).
Part III — Build an app, feature by feature
Each step is self‑contained. Do them in any order you need.
10. A page
app/pages/index.wrn → /. A trailing index segment is dropped; [param] makes a dynamic
segment (app/pages/users/[id].wrn → /users/:id, ctx.params.id).
page Home {
layout = "public"
seo { title = "Home" description = "Welcome to my app." }
view {
<h1>Hello WrNexus</h1>
<p><a href="/about">About</a></p>
}
}
Scaffold: wrnexus generate page home (alias g p).
11. Layouts
app/layouts/public.wrn:
component Public {
view {
<nav><a href="/">Home</a> · <a href="/about">About</a></nav>
<main><slot></slot></main>
}
}
A page opts in with layout = "public". Missing → a default layout if present; layout = "none"
disables layouts for that page.
12. Components & props
app/components/counter.wrn:
component Counter {
props {
start = 0
label = "Count"
}
state count = start
view {
<button @click="count++">{label}: {count}</button>
}
}
Mount it in any page/view — pass props as attributes:
<div data-component="counter" start="5" label="Clicks"></div>
Prop coercion: each prop is coerced to the type of its default — number default → Number(v)
(so start="5" → 5), boolean default → truthy check (""/"true"/true → true), else String.
Missing attribute → the default. Components can mount components (up to depth 15). Scaffold:
wrnexus generate component counter.
13. Reactivity
Everything is driven by state + directives (§7). Cheat sheet:
component Demo {
state count = 0
state open = true
state items = ["a", "b", "c"]
view {
<p>Count doubled: {count * 2}</p> <!-- reactive data-text -->
<button @click="count++">+</button>
<button @click="count = 0">reset</button>
<button @click="open = !open">toggle</button>
<ul data-show="open">
<li data-for="x in items" data-text="x"></li>
</ul>
}
}
The runtime tracks dependencies automatically and re‑renders only what changed. No new Function,
no eval — CSP‑safe.
14. Wire UI
@wrnexus/ui ships 24 themed components, auto‑discovered like your own. Mount with
data-component; every component accepts a class prop (appended to the root) and is styled via
--wire-* tokens + .wire-* classes you can override.
Layout: container, stack (gap), hstack (gap, align), grid (cols, gap),
spacer, divider, card.
Form/controls: button (label, variant, size, type), input (type, name, value,
placeholder), textarea (name, placeholder, rows), select (name; options in slot),
checkbox (name, label), radio (name, value, label), switch (name, label).
Display/feedback: badge (label, variant), tag (label, variant), alert
(variant, title, message), avatar (src, alt), spinner, progress (value, max),
skeleton (width, height), tooltip (text; trigger in slot), disclosure (summary),
table (thead/tbody in slot), theme-toggle (label).
Variants: buttons default|primary|danger|ghost, sizes sm|md|lg; badge/tag/alert
default|primary|success|danger|warning (alert uses info|success|danger|warning).
<div data-component="button" variant="primary" label="Save"></div>
<div data-component="alert" variant="success" title="Done" message="Saved."></div>
<div data-component="grid" cols="3" gap="4">…</div>
Override styling, in priority order: (1) theme tokens --wire-*, (2) redefine .wire-* in your
CSS (loads after ui.css), (3) the class prop, (4) wrnexus eject <name> to copy the component's
.wrn source into app/components/ (your copy shadows the library one).
15. Theming
Themes are flat token maps rendered as --wire-<key> CSS variables (server + client). Built‑in
light and dark; the cookie wire-theme selects one.
// wrnexus.config.ts
theme: {
palette: "violet", // blue | indigo | violet | emerald | cyan | rose | amber | slate
default: "dark", // used when no cookie
themes: {
light: { "color-primary": "#2563eb" }, // deep-merged over built-in light
dark: { "color-primary": "#6c8cff" },
},
}
Built‑in token keys include: color-scheme, color-bg, color-surface, color-surface-2,
color-text, color-muted, color-border, color-primary, color-primary-hover,
color-primary-contrast, color-danger, color-success, color-warning, radius, radius-sm,
font-sans, shadow-1. (The reserved key color-scheme emits the native property.)
Toggle from the view with zero JS:
<button data-wire-theme-toggle>Toggle theme</button>
<button data-wire-theme-set="light">Light</button>
The client exposes window.wireTheme = { get, set, toggle, bind, themes }.
16. Styles
Global CSS entry defaults to app/styles/global.css (or all app/styles/*.css aggregated). The
built‑in Bun bundler resolves @import (including node_modules) and minifies in production.
Add a CSS framework two ways:
- CDN (zero build):
head: ['<link rel="stylesheet" href="https://cdn…/bootstrap.min.css">']. - Custom processor (Tailwind/PostCSS/Sass):
styles: {
entry: "app/styles/global.css",
process: async ({ entryPath, appRoot, mode }) => {
const args = ["@tailwindcss/cli", "-i", entryPath!];
if (mode === "production") args.push("--minify");
return await Bun.$.cwd(appRoot)`bunx ${args}`.text();
},
}
17. API routes
app/api/<name>.ts → /api/<name>. Export one function per HTTP method. Nested folders and
[param] segments work like pages.
// app/api/echo.ts → /api/echo
import type { Context } from "@wrnexus/core";
export const GET = async () => Response.json({ usage: "POST JSON here" });
export const POST = async (ctx: Context) => {
const body = await ctx.req.json();
return Response.json({ received: body });
};
ctx gives you ctx.req, ctx.url, ctx.params, ctx.session, ctx.user, ctx.t, ctx.lang,
ctx.locals, etc. (§35). Scaffold: wrnexus generate api echo.
You can also colocate an API in a page with the api block (§6).
18. Validation (forms + API)
One schema, enforced on both sides. Define it once in app/schemas/<name>.ts:
// app/schemas/login.ts
import { v } from "@wrnexus/validation";
export default v.object({
email: v.string().email(),
password: v.string().min(8, "Password must be at least 8 characters"),
});
Builder API:
v.string()→.min(n) .max(n) .length(n) .email() .url() .uuid() .date() .pattern(re,msg?) .oneOf([...]) .trim()v.number()→.min(n) .max(n) .integer() .positive() .oneOf([...])v.boolean()- All types →
.optional() .label(text) .default(value) .refine(fn, msg?)(⚠️refineis server‑only, not mirrored to the client). v.object(fields)→.parse(input)→{ ok, value, errors }, and.describe()(JSON descriptor for the client).
Server — in an API handler:
import { parseBody } from "@wrnexus/validation";
import login from "../schemas/login.ts";
export async function POST(ctx: Context): Promise<Response> {
const result = await parseBody(login, ctx.req); // reads JSON / urlencoded / multipart
if (!result.ok) return result.response; // ready 400: { ok:false, errors:{field:msg} }
const { email, password } = result.value as { email: string; password: string };
// …
}
parseEnv(schema, source?) validates env vars and throws one readable multi‑line error.
Client form flow (zero JS you write). The schema's descriptor is baked into
window.__wireSchemas by name (filename). Wire a form up with attributes:
<form data-schema="login" method="post" action="/api/login" data-redirect="/dashboard">
<div data-component="input" type="email" name="email"></div>
<span class="wire-field-error" data-error="email"></span>
<div data-component="input" type="password" name="password"></div>
<span class="wire-field-error" data-error="password"></span>
<div class="wire-alert wire-alert--success" data-success="Signed in!" hidden></div>
<button type="submit" class="wire-btn wire-btn--primary">Sign in</button>
</form>
data-schema="login"— picks the descriptor.name="…"— matched to schema fields; validated onblurandsubmit.data-error="field"— receives the message; the input getsaria-invalid+.wire-invalid.data-success— shown on success when there's no redirect.data-redirect="/path"(orredirectin the JSON response) — navigates on success.
On submit it validates client‑side, then POSTs JSON with x-csrf-token (from the wire-csrf
cookie) and credentials: same-origin, and re‑surfaces server errors into the data-error
spans. It also dispatches wire:success / wire:error events. Scaffold: wrnexus generate schema login.
19. Database
Models in app/db/schema.ts are the source of truth for DDL, typing, and row mapping.
⚠️ ctx.db does not exist yet — use getDb() from @wrnexus/db in handlers.
Define models
// app/db/schema.ts
import { v, table } from "@wrnexus/db";
export type User = { id: number; email: string; name: string; active: boolean; createdAt: Date };
export const users = table<User>("users", {
id: v.id(), // auto-increment PK
email: v.string().unique(),
name: v.string(),
active: v.boolean().default(true),
createdAt: v.timestamp().default("now"), // "now" → CURRENT_TIMESTAMP
});
Column builders: id, text, string(=text), int, number(=real), real, bool, boolean(=bool), timestamp, json. Modifiers: .optional() .unique() .default(value|"now") .primaryKey() .references(table, column="id"). ⚠️ export const each table so the CLI can discover it.
Migrations
wrnexus db new init --from-models # scaffold up/down CREATE TABLE from schema.ts (topo-sorted)
wrnexus db migrate # apply pending (each in a transaction, recorded once)
wrnexus db status # [x]/[ ] applied
wrnexus db rollback # revert the last one
A migration is .sql split by markers:
-- +up
ALTER TABLE "users" ADD COLUMN "passwordHash" TEXT NOT NULL DEFAULT '';
-- +down
ALTER TABLE "users" DROP COLUMN "passwordHash";
In dev the server auto‑migrates at startup; in production run wrnexus db migrate explicitly.
Typed queries
-- app/db/queries/users.sql
-- name: GetUserByEmail :one
SELECT * FROM users WHERE email = :email;
-- name: ListUsers :many
SELECT id, name, active FROM users ORDER BY name;
-- name: CreateUser :exec
INSERT INTO users (email, name, active) VALUES (:email, :name, :active);
:one → Row | null, :many → Row[], :exec → ExecResult. :param placeholders become
positional. Run wrnexus db generate → app/db/queries.gen.ts with fully‑typed functions:
import { getDb } from "@wrnexus/db";
import { ListUsers, GetUserByEmail } from "../db/queries.gen.ts";
const all = await ListUsers(getDb()); // typed rows
const user = await GetUserByEmail(getDb(), { email }); // args object; User | null
(SELECT * rows are mapped through the model; aggregates like COUNT(*) AS n are typed number.)
Runtime DB API
getDb() returns the process‑wide Db (set once at startup from config.db). Db:
db.all<T>(sql, params?, model?): Promise<T[]>
db.one<T>(sql, params?, model?): Promise<T | null>
db.exec(sql, params?): Promise<{ changes: number; lastInsertId?: number }>
db.tx(async (tx) => { … }) // transaction, rolls back on throw (nested reuses current)
db.createTable(model): Promise<void> // CREATE TABLE IF NOT EXISTS
await getDb().exec("INSERT INTO users (email, name) VALUES (?, ?)", [email, name]);
const rows = await getDb().all("SELECT * FROM users", [], users); // User[]
Pagination & relations
import { paginate, loadRelated } from "@wrnexus/db";
const page = await paginate(
getDb(),
{ sql: "SELECT * FROM users ORDER BY name", model: users }, // no LIMIT — it's added
{ page: 2, perPage: 25 },
); // → { items, page, perPage, total, totalPages, hasNext, hasPrev }
const list = await getDb().all("SELECT * FROM users", []);
await loadRelated(getDb(), list, { table: "posts", foreignKey: "userId", as: "posts" }); // hasMany → user.posts
// belongsTo: { table:"users", localKey:"userId", foreignKey:"id", as:"author", single:true, model:users }
loadRelated batches with one WHERE fk IN (…) (no N+1).
Seeding
// app/db/seed.ts → wrnexus db seed
import type { Db } from "@wrnexus/db";
export default async function seed(db: Db): Promise<void> {
await db.exec("DELETE FROM users"); // make it re-runnable
await db.exec("INSERT INTO users (email, name, active) VALUES (?, ?, ?)", [
"ada@x.dev",
"Ada",
1,
]);
}
Config: db: { driver: "sqlite" | "postgres" | "mysql" | "mongo", url }; sqlite file: URLs
resolve relative to the app root. wrnexus db studio [table] lists tables/row counts or dumps rows.
Multiple databases
Connect to as many databases as you want and read/write any of them per request. The
db setting is the default; add named connections under databases:
// wrnexus.config.ts
db: { driver: "sqlite", url: "file:./dev.db" }, // default → getDb()
databases: {
analytics: { driver: "postgres", url: process.env.ANALYTICS_URL! }, // → getDb("analytics")
},
Reach them by name at runtime:
const users = await getDb().all("SELECT * FROM users"); // default
const hits = await getDb("analytics").all("SELECT * FROM events"); // named
Each named database has its own files under app/db/<name>/ (schema.ts,
migrations/, queries/ → queries.gen.ts, seed.ts). Target one with --db=<name>:
wrnexus db new init --from-models --db=analytics # scaffold app/db/analytics/migrations
wrnexus db migrate --db=analytics # migrate the named db
wrnexus db generate --db=analytics # regenerate its typed queries
wrnexus db studio users --db=analytics # inspect it
The generated query functions take a Db as their first argument, so pass the connection
you want: await ListEvents(getDb("analytics")). In dev, every configured database is
auto-migrated at startup.
20. Auth, sessions & CSRF
Primitives from @wrnexus/core:
import {
hashPassword,
verifyPassword,
logIn,
logOut,
getUser,
requireAuth,
verifyCsrf,
} from "@wrnexus/core";
await hashPassword("secret"); // argon2id (Bun.password)
await verifyPassword(plain, hash); // boolean
logIn(ctx, { id, email, name }); // regenerates session id (fixation defense), stores user
logOut(ctx); // clears session
getUser(ctx); // current user or null
Login route (canonical):
export async function POST(ctx: Context): Promise<Response> {
if (!verifyCsrf(ctx)) return new Response("Invalid CSRF token", { status: 403 });
const result = await parseBody(login, ctx.req);
if (!result.ok) return result.response;
const { email, password } = result.value as { email: string; password: string };
const user = await GetUserByEmail(getDb(), { email });
if (!user || !(await verifyPassword(password, user.passwordHash)))
return Response.json({ ok: false, error: "Invalid email or password" }, { status: 401 });
logIn(ctx, { id: user.id, email: user.email, name: user.name }); // store only safe fields
return Response.json({ ok: true });
}
Guarding: requireAuth({ loginPath: "/login" }) as middleware — API/JSON → 401, page nav → 302
to ${loginPath}?next=<target>. Or inline: if (getUser(ctx) == null) ….
CSRF (double‑submit): cookie wire-csrf (JS‑readable), header x-csrf-token. csrfToken(ctx)
returns/creates the token; verifyCsrf(ctx) passes GET/HEAD/OPTIONS, else compares header to cookie.
The Wire UI form runtime sends the header automatically. Add csrfProtection() middleware to enforce
globally.
Sessions: cookie wrnexus.sid (HttpOnly, Lax, Secure on HTTPS), 24h sliding TTL, 256‑bit id.
Default backend is in‑memory. Swap it:
import { setSessionBackend } from "@wrnexus/core";
import { sqliteSessionStore } from "@wrnexus/db/session";
setSessionBackend(sqliteSessionStore("./sessions.db")); // sync backend
// async/Redis-style: use the loadSession(asyncBackend) middleware instead
21. Middleware
Each app/middleware/*.ts file export defaults one middleware — a value or a function
(ctx, next) => Response | Promise<Response>. Return next() to continue or a Response to
short‑circuit. Scope a middleware to a path by checking ctx.url.pathname / ctx.req.method.
// app/middleware/ratelimit.ts
import { rateLimit, type Context, type Next } from "@wrnexus/core";
const limiter = rateLimit({ max: 5, windowMs: 60_000, message: "Slow down" });
export default async function (ctx: Context, next: Next) {
if (ctx.req.method === "POST" && ctx.url.pathname === "/api/login") return limiter(ctx, next);
return next();
}
Core factories and their key options:
rateLimit({ windowMs=60000, max=60, key?, trustProxy=false, message?, headers=true, store? })— emitsRateLimit-*headers + 429 withRetry-After.requestLogger({ format="pretty"|"json", sink?, requestIdKey="requestId" }).csrfProtection()— 403s unsafe requests failing CSRF.sessionAuth()— setsctx.userfrom the session.requireAuth({ loginPath="/login" })— 401 (API) / 302 (page).loadSession(backend, { ttlMs? })— async session backend; register early.authorize(policy),requireRole(...roles),requirePermission(rbac, permission)from@wrnexus/authz.jwtAuth({ secret, getToken?, required=true })from@wrnexus/jwt.createTracker({ sinks }).middleware()from@wrnexus/tracking.
22. i18n
Opt‑in: add dictionaries under app/locales/<lang>.json. Language resolves per request from the
wire-lang cookie → Accept-Language → default.
// app/locales/en.json
{ "home": { "title": "Home", "intro": "Welcome" }, "api": { "greeting": "Hello" } }
In views: {t:home.title}. In handlers: ctx.t("api.greeting") and ctx.lang.
export const GET = async (ctx: Context) =>
Response.json({ message: ctx.t("api.greeting"), lang: ctx.lang });
Config: i18n: { default: "en", locales: ["en", "fr"] } (both optional; inferred from files).
Switch language from the view with data-wire-lang-set="fr". Formatting helpers (Intl‑based) are
exported from @wrnexus/i18n: formatNumber, formatCurrency, formatDate, formatRelativeTime,
plural.
23. SEO
Per‑page seo { } merges over global seo config. Supported keys: title, titleTemplate
("%s | Site"), description, canonical, canonicalBase, robots, keywords, image,
siteName, type, locale, twitterCard, twitterSite, themeColor. The server builds an
escaped <head> from the merged metadata.
24. Security headers & CORS
Secure defaults are on. Configure under security (§32 for the full shape):
security: {
cors: { enabled: true, origin: ["http://localhost:5173"], credentials: true, maxAge: 600 },
contentSecurityPolicy: { directives: { "script-src": ["'self'", "https://cdn.example.com"] } },
hsts: { maxAge: 31536000 }, // on in prod by default
frameOptions: "DENY",
}
Defaults include a strict CSP (default-src 'self', script-src 'self', style-src 'self' 'unsafe-inline', frame-ancestors 'none', …), X-Frame-Options: DENY, restrictive
Permissions‑Policy, COOP same-origin, and Referrer‑Policy. Set any directive/section to false
to remove it, or security.headers = false to disable all framework headers. WebSocket upgrades are
guarded against cross‑site hijacking (same‑origin + configured CORS origins + non‑browser clients).
25. Realtime
Author a room in app/realtime/<name>.ts → served at ws://host/realtime/<name>. Use [room].ts
for dynamic multi‑room.
// app/realtime/chat.ts
import { defineRoom } from "@wrnexus/core";
export default defineRoom({
authorize: (info) => true, // { user?, query, headers } — false → 403
onConnect(client) {
client.broadcast({ type: "system", text: "joined", online: client.room.count() });
},
onMessage(client, message) {
// JSON auto-parsed
client.room.broadcast({ type: "message", user: client.user, text: String(message.text) });
},
onLeave(client) {
client.broadcast({ type: "system", text: "left", online: client.room.count() - 1 });
},
});
client API: send(msg) (this conn), broadcast(msg) (others), client.room.broadcast(msg)
(everyone), client.to(id|ids).send(msg), client.toUser(u|users).send(msg), client.close().
Set client.user = "u1" to enable toUser. State: client.data (per conn), client.room.state
(shared while ≥1 connected); client.room.count(), client.room.clients().
Client view (zero JS):
<div data-room="chat" data-room-user="Ada">
<span data-room-status data-room-status-class="badge"></span>
<div data-room-log></div>
<template data-room-item="message"><div><strong>%user%</strong>: %text%</div></template>
<template data-room-item="system"><em>%text% (%online% online)</em></template>
<form data-room-send>
<input name="text" data-room-reset placeholder="Say something">
<button type="submit">Send</button>
</form>
</div>
data-room="name"connects; optionaldata-room-user→?user=.[data-room-log]receives messages;[data-room-status]reflectsconnected|disconnected|error(withis-*classes whendata-room-status-classis set).<template data-room-item="type">renders each message of thattype;%field%placeholders are HTML‑escaped.data-room-item=""is the fallback template.<form data-room-send>sends all named fields as one JSON object;data-room-resetfields clear after send.
Realtime across multiple app processes
By default a room's broadcast/toUser reach only clients on that process. To make them reach
clients on every running app process/instance (multiple runs, or multiple apps behind the
gateway), enable realtime scaling — broadcasts are bridged over Redis pub/sub:
// wrnexus.config.ts
realtime: { scale: true, redisUrl: process.env.REDIS_URL }, // defaults to redis://localhost:6379
Now client.room.broadcast(msg) in one app instance is delivered to subscribers in all the
others. Connection‑targeted send/to(id) stay local (ids are per‑process). Under the hood it's
bridgeRealtime(registry, bus) from @wrnexus/core + the @wrnexus/pubsub Redis driver — you can
also wire it manually for custom buses.
26. SSR/CSR data bindings
Fetch data for a page without hand‑writing fetch calls, using ssr/client blocks (§6) + api="…"
on an element:
page Users {
ssr {
api list GET /api/users/ssr { return users.map(u => u.name).join(", "); }
}
client {
api live GET /api/users/csr { return $data.count; }
}
view {
<p api="list">loading…</p> <!-- SSR: rendered before HTML is sent -->
<p api="live">…</p> <!-- CSR: fetched after hydration -->
}
}
ssrbindings render on the server (replace an HTML comment marker with the value).clientbindings render after hydration (the browser seesdata-wrnexus-csrand fetches/__wrnexus/csr?…). Inside a binding body,$datais the response, andcookies/session/localStorageadapters are in scope.
27. Optional packages
Convenience helpers and dependency-free feature packages. Import only what you need.
@wrnexus/helpers— context helpers includinggetOriginalRequestUrl,getOriginalRequestOrigin,getOriginalRequestPath,getOriginalRequestMethod, and the safe forward-authredirectToLogin(ctx, "/login", { allowedHosts })response helper.@wrnexus/jwt—signJwt(payload, secret, { expiresIn?, now? }),verifyJwt(token, secret)(throwsJwtError),jwtAuth({ secret, getToken?, required? })middleware.@wrnexus/oauth— PKCE OAuth 2.0. Presetsgoogle/github/discord(creds)+defineProvider.const p = google({ clientId, clientSecret }); const { url, state, verifier } = await startAuth(p, { redirectUri }); // store state+verifier, 302 to url const { profile } = await completeAuth(p, { code, redirectUri, verifier }); // then logIn(ctx, profile)@wrnexus/authz—defineRbac({ admin: ["post:*"] }),requireRole,requirePermission, policy combinatorsany/all/attr,authorize(policy).@wrnexus/encryption—encrypt/decrypt(text, key)(AES‑256‑GCM),generateKey,deriveKey(PBKDF2),sha256,hmacSign/hmacVerify.@wrnexus/pubsub—createPubSub()→publish(topic, msg)/subscribe(pattern, handler)(supportsns:*and*). Default driver is in‑process. For cross‑process messaging (multiple app runs / multiple domains) use the Redis driver — a self‑contained RESP client, no extra dependency:import { createPubSub } from "@wrnexus/pubsub"; import { redisDriver } from "@wrnexus/pubsub/redis"; const bus = createPubSub(redisDriver(process.env.REDIS_URL)); // defaults to redis://localhost:6379 bus.subscribe("order:*", (msg, topic) => { /* any app process receives it */ }); await bus.publish("order:created", { id: 7 });@wrnexus/queue—createQueue()→add(name, data, { delayMs?, maxAttempts?, repeat? }),process(name, handler); retries + backoff, recurring jobs,drain()for tests.@wrnexus/tracking—createTracker({ sinks: [consoleSink] })→capture(err, ctx?)andmiddleware().
28. Config profiles & env
Run the same app under named profiles (dev/prod/uat/test/…):
// wrnexus.config.ts
profiles: {
production: { db: { driver: "postgres", url: process.env.DATABASE_URL! } }, // auto-applied by `wrnexus build`
uat: { seo: { robots: "noindex,nofollow" } },
test: { db: { driver: "sqlite", url: "file:./test.db" } },
}
- Select:
--profile=<name>ondev/build/db/test, orWRNEXUS_PROFILE.builddefaults toproduction,testtotest. - Merge:
profiles.<name>is deep‑merged over the base config (objects merge; arrays/scalars replace). - Env cascade (low → high):
.env<.env.<profile><.env.local<.env.<profile>.local. ⚠️ Real environment variables always win over any.envfile. wrnexus profileslists profiles and which.envfiles exist.
29. Testing
Write app tests with @wrnexus/test (one import: bun:test primitives + helpers). Run with
wrnexus test (defaults to the test profile, loading .env.test).
// app/example.test.ts
import { test, expect, renderComponent, callRoute, createHarness } from "@wrnexus/test";
test("component renders", async () => {
const html = await renderComponent(COUNTER_SRC, { start: 5, label: "Clicks" });
expect(html).toContain("Clicks");
});
test("api handler", async () => {
const { POST } = await import("./api/echo.ts");
const res = await callRoute(
POST,
new Request("http://t/api/echo", { method: "POST", body: "{}" }),
);
expect(res.status).toBe(200);
});
test("full app", async () => {
const app = await createHarness(import.meta.dir + "/.."); // boots on an ephemeral port
const res = await app.fetch("/");
expect(res.status).toBe(200);
app.close();
});
Also mountHtml(html) — mounts server HTML in happy‑dom with the reactive runtime hydrated, to
assert on data-for/data-show/data-text.
wrnexus test # once (test profile)
wrnexus test --watch # re-run on change
30. Build & deploy
wrnexus build . # → dist/server.js (self-contained, minified) + islands/reactive/theme/ui/styles + public/
bun dist/server.js # honors PORT
Containerize:
wrnexus generate docker # Dockerfile (multi-stage Bun), .dockerignore, docker-compose.yml (app + Postgres)
docker compose up --build
The Dockerfile builds with oven/bun:1 → runs on oven/bun:1-slim, EXPOSE 3000, health‑checks
/healthz. Run migrations in production explicitly (wrnexus db migrate).
Monorepo & multi‑domain SaaS
Build several interconnected WrNexus apps in one repo, and serve them behind one port routed by domain — the foundation for multi‑domain SaaS.
wrnexus workspace acme # scaffold a monorepo
cd acme && bun install
bun run dev # = wrnexus gateway → http://localhost:3000
The scaffold:
acme/
wrnexus.workspace.ts # maps each app → the domains it serves
apps/
web/ # a WrNexus app → localhost, web.localhost
admin/ # a WrNexus app → admin.localhost
packages/
shared/ # @app/shared — shared code + a cross-app pubsub bus
wrnexus.workspace.ts:
import type { WorkspaceConfig } from "@wrnexus/cli/workspace";
export default {
apps: [
{ name: "web", dir: "apps/web", domains: ["localhost", "web.localhost"] },
{ name: "admin", dir: "apps/admin", domains: ["admin.localhost"] },
],
} satisfies WorkspaceConfig;
The gateway (wrnexus gateway) boots each app as its own process (full isolation — its
own database registry, pubsub, in‑memory state) and reverse‑proxies HTTP and WebSocket by the
Host header. Add the hosts to your machine (/etc/hosts): 127.0.0.1 web.localhost admin.localhost.
Because apps are process‑isolated, one deploy can serve many tenants/domains safely.
Gateway security & auth. The gateway is the edge, so it can enforce access control before any
request reaches an app — set security (gateway‑wide) and per‑app auth in wrnexus.workspace.ts:
export default {
security: {
trustedHostsOnly: true, // reject unknown domains (404)
rateLimit: { max: 300, windowMs: 60_000 }, // per client IP → 429
headers: true, // baseline edge security headers
accessLog: true, // log host → app, method, path, status
// forwardedHeaders: true, // X-Forwarded-For/Host/Proto (default on)
},
apps: [
{ name: "web", dir: "apps/web", domains: ["localhost"] },
{
name: "admin",
dir: "apps/admin",
domains: ["admin.localhost"],
auth: { basic: { user: "admin", pass: "…" } }, // HTTP Basic
// auth: { allowIps: ["127.0.0.1", "::1"] }, // IP allowlist
// auth: { forward: { url: "https://auth/verify" } }, // forward-auth (SSO): 2xx = allow
},
],
} satisfies WorkspaceConfig;
Per‑app auth supports HTTP Basic, an IP allowlist, and forward‑auth (the gateway calls
your verify endpoint with the request's cookies/Authorization; a 2xx allows it — the hook for SSO).
The gateway also serves a health/status endpoint at /__gateway/health.
Interconnecting apps:
- Shared code: import a workspace package (
@app/shared) from any app. - Runtime messaging: the shared package exposes a pubsub
busbacked by Redis, so an event published in one app reaches subscribers in another:import { bus } from "@app/shared"; await bus.publish("tenant:created", { id }); // app A bus.subscribe("tenant:*", (msg) => { … }); // app B (needs Redis) - Shared databases: point apps at the same
db/databasesin their configs.
Add another app any time: wrnexus create apps/reports, then list it in wrnexus.workspace.ts.
Part IV — Reference
31. CLI reference
wrnexus dev [app-dir] [--port=3000] [--profile=<name>] Dev server (live reload, auto-migrate, regen routes/queries)
wrnexus build [app-dir] [--profile=<name>] Production bundle → dist/ (production profile by default)
wrnexus create <app-name> Scaffold a new app
wrnexus workspace <name> Scaffold a monorepo (apps/* + shared packages/*)
wrnexus workspace add <name> [--domain=name.localhost] Add an app to the current workspace
wrnexus gateway [--port=3000] [--prod] Serve every workspace app behind one port, routed by domain
wrnexus generate <type> <name> (alias: g) Scaffold: page | component | api | schema
wrnexus generate routes Regenerate app/routes.gen.ts
wrnexus generate docker Scaffold Dockerfile + compose
wrnexus eject <name...> Copy a Wire UI component into app/components/
wrnexus db new [name] [--from-models] [--db=<name>] Scaffold a migration (optionally from schema.ts)
wrnexus db generate [--db=<name>] Regenerate queries.gen.ts from queries/*.sql
wrnexus db migrate | rollback | status [--db=<name>] Apply / revert / list migrations
wrnexus db seed [--db=<name>] Run the seed script
wrnexus db studio [table] [--db=<name>] List tables + counts, or dump a table
(--db=<name> targets a named database under app/db/<name>/)
wrnexus test [app-dir] [--watch] [--profile=test] Run the app's tests (bun test, test profile)
wrnexus profiles [app-dir] List config profiles + their .env files
wrnexus help | --help | -h
generate types & aliases: page(p) → app/pages/<name>.wrn; component(c) →
app/components/<name>.wrn; api(a) → app/api/<name>.ts; schema(s) →
app/schemas/<name>.ts. Names may include slashes for nesting.
32. wrnexus.config.ts schema
export default an AppConfig (all fields optional). Searched: wrnexus.config.{ts,js,mjs}.
import type { AppConfig } from "@wrnexus/styles";
const config: AppConfig = {
head: [ '<link rel="stylesheet" href="…">' ], // string | string[] → appended to every <head>
seo: { // SeoConfig (global defaults, merged per page)
title, titleTemplate, description, canonical, canonicalBase, robots,
keywords, image, siteName, type, locale, twitterCard, twitterSite, themeColor,
},
theme: {
palette: "violet",
default: "dark", // theme when no wire-theme cookie
themes: { light: { "color-primary": "#2563eb" }, dark: { "color-primary": "#6c8cff" } },
},
i18n: { default: "en", locales: ["en", "fr"] }, // both optional (inferred from app/locales)
db: { driver: "sqlite" | "postgres" | "mysql" | "mongo", url: "file:./dev.db" }, // default → getDb()
databases: { // named connections → getDb("<name>")
analytics: { driver: "postgres", url: "…" }, // files under app/db/analytics/
},
realtime: { scale: true, redisUrl: "…" }, // bridge room broadcasts across app processes
port: 3000, // ⚠️ dev reads port only from --port=, not this
styles: {
entry: "app/styles/global.css", // default
process: async ({ entryPath, appDir, appRoot, mode }) => "/* final css */", // optional
},
security: { // SecurityConfig (secure defaults)
headers: true, // false → disable ALL framework headers
cors: false | { // opt-in
enabled: true, origin: "*" | string | string[], methods, allowedHeaders,
exposedHeaders, credentials, maxAge,
},
contentSecurityPolicy: false | { enabled, reportOnly, directives, useDefaults },
hsts: false | { enabled, maxAge, includeSubDomains, preload }, // on in prod
trustedTypes: false | { enabled, policyNames, requireForScript, allowDuplicates },
crossOriginOpenerPolicy: false | "same-origin" | "same-origin-allow-popups" | "unsafe-none",
frameOptions: false | "DENY" | "SAMEORIGIN",
referrerPolicy: false | "strict-origin-when-cross-origin",
permissionsPolicy: false | { camera: [], geolocation: [], fullscreen: ["self"], … },
extraHeaders: { "X-Custom": "…" },
},
profiles: { // deep-merged over base when active
production: { /* Partial<AppConfig> */ },
uat: { … }, test: { … },
},
};
export default config;
CSP defaults: default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline';
img-src 'self' data: blob:; font-src 'self' data:; connect-src 'self' ws: wss:;
object-src 'none'; base-uri 'self'; frame-ancestors 'none'; form-action 'self'. Set a
directive to false/null to remove it.
33. Directive & attribute cheat sheet
Reactive (client runtime): data-scope="k: v, …" · data-on-<event> (via @event) ·
data-text · data-show · data-for="item[, i] in list" · data-wrnexus-csr (generated).
Server/compiler: data-component · data-slot · <slot [name]> · {expr} · {{expr}} ·
{t:key} (→ data-t) · @event="stmt" · api="binding".
Forms: data-schema · name · data-error · data-success · data-redirect.
Theme: data-wire-theme-toggle · data-wire-theme-set="name".
i18n: data-wire-lang-set="lang" · data-wire-lang.
Realtime: data-room · data-room-user · data-room-log · data-room-status
(+data-room-status-class) · <template data-room-item="type"> (%field%) · data-room-send ·
data-room-reset.
34. Package API index
Core / always present
@wrnexus/core—Context,createContext; security (escapeHtml,isSafeRequestPath); CSRF (csrfToken,verifyCsrf,csrfProtection,CSRF_COOKIE,CSRF_HEADER); auth (hashPassword,verifyPassword,logIn,logOut,getUser,sessionAuth,requireAuth); middleware (rateLimit,requestLogger); cache (TTLCache,cacheControl,etag,notModified); uploads (saveUpload,collectUploads,sanitizeFilename); streaming (streamResponse,sse); realtime (defineRoom,createRealtimeRegistry,bridgeRealtime); errors (renderError,renderNotFound); headers (withSecurityHeaders, CORS); sessions (setSessionBackend,loadSession); JSX runtime.@wrnexus/compiler—compileWireFile,compile,parse,generate,Lexer,ParseError.@wrnexus/router—buildRouter,matchRoute,generateRoutesFile.@wrnexus/ssr—renderDocument.@wrnexus/csr—getReactiveRuntime,getNavRuntime,getRealtimeRuntime.@wrnexus/reactive—signal.@wrnexus/styles—loadAppConfig,loadRawConfig,resolveProfile,loadEnv,headToString,findStyleEntry,bundleCss,renderStyles, theme helpers,DEFAULT_THEMES,AppConfig.@wrnexus/dev-server—startServer(opts) → { url, router, stop },createProductionServer, node adapter.@wrnexus/cli— thewrnexusbinary.
Feature packages
@wrnexus/db—v,table,getDb(name?)/setDb/registerDb/hasDb/databaseNames,createDb,paginate,loadRelated, migrations, query generation; subpaths/session,/sqlite,/postgres,/mysql,/mongo.@wrnexus/dev-server—startServer,startGateway(multi-app host router), prod handler.@wrnexus/cli— thewrnexusbinary; subpath/workspace(WorkspaceConfig,startGatewaywiring).@wrnexus/validation—v,parseBody,parseEnv,invalid,renderSchemasScript.@wrnexus/helpers— original gateway URL/path/method helpers andredirectToLogin.@wrnexus/ui—uiComponentsDir,uiCssPath,uiCss,uiComponentNames.@wrnexus/i18n—loadLocales,resolveI18n,makeT,resolveLang,translateHtml,formatNumber,formatCurrency,formatDate,formatRelativeTime,plural.@wrnexus/pubsub—createPubSub,memoryDriver; subpath/redis→redisDriver(url?)for cross-process messaging.@wrnexus/jwt,@wrnexus/oauth,@wrnexus/authz,@wrnexus/encryption,@wrnexus/queue,@wrnexus/tracking— see §27.@wrnexus/test—renderComponent,mountHtml,callRoute,createHarness+bun:test.
35. The Context object
Available in API handlers, page api blocks, and middleware (ctx):
ctx.req: Request,ctx.url: URL,ctx.params: Record<string,string>(dynamic route segments).ctx.session— get/set/clear session values;ctx.user— the logged‑in user (or null).ctx.t(key, params?)— translate;ctx.lang— active language.ctx.locals— per‑request scratch space (e.g.requestId).- Helpers:
verifyCsrf(ctx),csrfToken(ctx),getUser(ctx),logIn/logOut(ctx, …). - DB: use
getDb()(⚠️ notctx.db).
36. Editor support (VS Code)
The editors/vscode extension provides .wrn highlighting (embedded HTML/CSS/TypeScript), live
diagnostics from the real compiler, snippets, completions, and distinct colors for WrNexus's own
attributes (@event/{t:} in one accent, data-* directives in another). See
editors/vscode/README.md. The repo's .vscode/settings.json applies those attribute colors when
you edit .wrn files here.
This guide is generated to be self‑contained. When in doubt, the source of truth is the code under
packages/* and the runnable reference app under examples/basic-app.