first commit

This commit is contained in:
2026-07-12 15:55:18 +05:30
commit ee98026cc5
404 changed files with 44522 additions and 0 deletions
+13
View File
@@ -0,0 +1,13 @@
import type { Context } from "@wrnexus/core";
// Demonstrates method-specific handlers on a single route file.
// GET /api/echo -> usage hint
// POST /api/echo -> echoes the JSON body back
export const GET = async () => {
return Response.json({ usage: "POST JSON here and it will be echoed back" });
};
export const POST = async (ctx: Context) => {
const body = await ctx.req.json();
return Response.json({ received: body });
};
+7
View File
@@ -0,0 +1,7 @@
// GET /api/hello -> { message: "<translated>", lang: "<active>" }
// Uses ctx.t / ctx.lang, resolved from the wire-lang cookie or Accept-Language.
import type { Context } from "@wrnexus/core";
export const GET = async (ctx: Context) => {
return Response.json({ message: ctx.t("api.greeting"), lang: ctx.lang });
};
+24
View File
@@ -0,0 +1,24 @@
// API route: POST /api/login. Validates the body with the SAME schema the form
// uses on the client, then checks the password hash and starts a session.
import { verifyCsrf, verifyPassword, logIn, type Context } from "@wrnexus/core";
import { getDb } from "@wrnexus/db";
import { parseBody } from "@wrnexus/validation";
import login from "../schemas/login.ts";
import { GetUserByEmail } from "../db/queries.gen.ts";
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; // 400 { ok:false, errors }
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 });
}
// Store only safe fields in the session — never the password hash.
logIn(ctx, { id: user.id, email: user.email, name: user.name });
return Response.json({ ok: true, user: { id: user.id, email: user.email, name: user.name } });
}
+8
View File
@@ -0,0 +1,8 @@
// API route: POST /api/logout. Clears the session.
import { verifyCsrf, logOut, type Context } from "@wrnexus/core";
export function POST(ctx: Context): Response {
if (!verifyCsrf(ctx)) return new Response("Invalid CSRF token", { status: 403 });
logOut(ctx);
return Response.json({ ok: true });
}
+11
View File
@@ -0,0 +1,11 @@
// API route: GET /api/me. Protected by requireAuth — returns 401 when anonymous,
// otherwise the current user hydrated onto ctx by the auth middleware.
import { requireAuth, getUser, type Context } from "@wrnexus/core";
const guard = requireAuth();
export async function GET(ctx: Context): Promise<Response> {
const denied = await guard(ctx, () => new Response(null));
if (denied.status === 401) return denied;
return Response.json({ ok: true, user: getUser(ctx) });
}
+8
View File
@@ -0,0 +1,8 @@
// GET /api/users/csr — real users from the database (client-side data binding).
import { getDb } from "@wrnexus/db";
import { ListUsers } from "../../db/queries.gen.ts";
export const GET = async () => {
const users = await ListUsers(getDb());
return Response.json({ users });
};
+8
View File
@@ -0,0 +1,8 @@
// GET /api/users/ssr — real users from the database (server-side data binding).
import { getDb } from "@wrnexus/db";
import { ListUsers } from "../../db/queries.gen.ts";
export const GET = async () => {
const users = await ListUsers(getDb());
return Response.json({ users });
};
@@ -0,0 +1,20 @@
// A reusable component. Route: none — mounted inside a page with
// <div data-component="counter" ...props></div>.
//
// Components are rendered on the SERVER (with their props applied) and hydrated
// in the browser by the generic reactive runtime — they ship no JS of their own.
component Counter {
// Props are passed as attributes on the mount element. Each is coerced to the
// type of its default value (so `start="5"` arrives as the number 5).
props {
start = 0
label = "Count"
}
// State can reference props. `count` seeds the reactive scope.
state count = start
view {
<button @click="count++">{label}: {count}</button>
}
}
@@ -0,0 +1,19 @@
-- +up
CREATE TABLE IF NOT EXISTS "posts" (
"id" INTEGER PRIMARY KEY AUTOINCREMENT,
"userId" INTEGER NOT NULL REFERENCES "users"("id"),
"title" TEXT NOT NULL,
"body" TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS "users" (
"id" INTEGER PRIMARY KEY AUTOINCREMENT,
"email" TEXT NOT NULL UNIQUE,
"name" TEXT NOT NULL,
"active" INTEGER NOT NULL DEFAULT 1,
"createdAt" TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
);
-- +down
DROP TABLE IF EXISTS "users";
DROP TABLE IF EXISTS "posts";
@@ -0,0 +1,7 @@
-- +up
INSERT INTO users (email, name, active) VALUES ('ada@wire.dev', 'Ada Lovelace', 1);
INSERT INTO users (email, name, active) VALUES ('grace@wire.dev', 'Grace Hopper', 1);
INSERT INTO users (email, name, active) VALUES ('linus@wire.dev', 'Linus Torvalds', 0);
-- +down
DELETE FROM users WHERE email IN ('ada@wire.dev', 'grace@wire.dev', 'linus@wire.dev');
@@ -0,0 +1,5 @@
-- +up
ALTER TABLE "users" ADD COLUMN "passwordHash" TEXT NOT NULL DEFAULT '';
-- +down
ALTER TABLE "users" DROP COLUMN "passwordHash";
+23
View File
@@ -0,0 +1,23 @@
// AUTO-GENERATED by `wrnexus db generate` — do not edit.
import type { Db, ExecResult } from "@wrnexus/db";
import { users } from "./schema.ts";
export async function GetUserByEmail(db: Db, args: { email: string }): Promise<{ id: number; email: string; name: string; active: boolean; passwordHash: string; createdAt: Date } | null> {
return (await db.one("SELECT * FROM users WHERE email = $1", [args.email], users)) as { id: number; email: string; name: string; active: boolean; passwordHash: string; createdAt: Date } | null;
}
export async function ListUsers(db: Db): Promise<{ id: number; name: string; active: boolean }[]> {
return (await db.all("SELECT id, name, active FROM users ORDER BY name", [], users)) as { id: number; name: string; active: boolean }[];
}
export async function CountActive(db: Db, args: { active: boolean }): Promise<{ n: number } | null> {
return (await db.one("SELECT COUNT(*) AS n FROM users WHERE active = $1", [args.active])) as { n: number } | null;
}
export async function CreateUser(db: Db, args: { email: string; name: string; active: boolean }): Promise<ExecResult> {
return db.exec("INSERT INTO users (email, name, active) VALUES ($1, $2, $3)", [args.email, args.name, args.active]);
}
export async function DeactivateUser(db: Db, args: { id: number }): Promise<ExecResult> {
return db.exec("UPDATE users SET active = 0 WHERE id = $1", [args.id]);
}
@@ -0,0 +1,14 @@
-- name: GetUserByEmail :one
SELECT * FROM users WHERE email = :email;
-- name: ListUsers :many
SELECT id, name, active FROM users ORDER BY name;
-- name: CountActive :one
SELECT COUNT(*) AS n FROM users WHERE active = :active;
-- name: CreateUser :exec
INSERT INTO users (email, name, active) VALUES (:email, :name, :active);
-- name: DeactivateUser :exec
UPDATE users SET active = 0 WHERE id = :id;
+35
View File
@@ -0,0 +1,35 @@
// Database models — the source of truth. `wrnexus db new --from-models` generates
// migrations from these, and query results are mapped back through them.
import { v, table } from "@wrnexus/db";
export type User = {
id: number;
email: string;
name: string;
active: boolean;
passwordHash: string;
createdAt: Date;
};
export const users = table<User>("users", {
id: v.id(),
email: v.string().unique(),
name: v.string(),
active: v.boolean().default(true),
passwordHash: v.string().default(""),
createdAt: v.timestamp().default("now"),
});
export type Post = {
id: number;
userId: number;
title: string;
body: string;
};
export const posts = table<Post>("posts", {
id: v.id(),
userId: v.int().references("users", "id"),
title: v.string(),
body: v.string(),
});
+23
View File
@@ -0,0 +1,23 @@
// Re-runnable dev seed data. Run with: wrnexus db seed
// Every seeded user has the password "password123" for local testing.
import { hashPassword } from "@wrnexus/core";
import type { Db } from "@wrnexus/db";
export default async function seed(db: Db): Promise<void> {
const passwordHash = await hashPassword("password123");
await db.exec("DELETE FROM users");
const rows: [string, string, number][] = [
["ada@wire.dev", "Ada Lovelace", 1],
["grace@wire.dev", "Grace Hopper", 1],
["linus@wire.dev", "Linus Torvalds", 0],
["margaret@wire.dev", "Margaret Hamilton", 1],
];
for (const [email, name, active] of rows) {
await db.exec("INSERT INTO users (email, name, active, passwordHash) VALUES (?, ?, ?, ?)", [
email,
name,
active,
passwordHash,
]);
}
}
+14
View File
@@ -0,0 +1,14 @@
// Validated environment configuration. `parseEnv` reads Bun.env / process.env,
// coerces values by the schema, and throws ONE readable error at startup if
// anything is missing or the wrong type — so misconfiguration fails fast.
//
// These are all optional so the example runs with zero setup; in a real app you
// would make secrets/URLs required, e.g. `DATABASE_URL: v.string().min(1)`.
import { v, parseEnv } from "@wrnexus/validation";
export const env = parseEnv<{ NODE_ENV?: string; PORT?: number }>(
v.object({
NODE_ENV: v.string().optional(),
PORT: v.number().optional(),
}),
);
+76
View File
@@ -0,0 +1,76 @@
/**
* Example WrNexus tests. Run with `wrnexus test` (or `bun test`).
*
* - renderComponent: compile + render a .wrn component to HTML
* - callRoute: call an API handler with a fake Request
* - createHarness: boot the whole app on an ephemeral port and fetch real routes
*
* Everything comes from one import: `@wrnexus/test`.
*/
import {
test,
expect,
describe,
beforeAll,
afterAll,
renderComponent,
callRoute,
createHarness,
type Harness,
} from "@wrnexus/test";
// --- Component-level (fast, no server) -------------------------------------
const COUNTER = `component Counter {
props {
start = 0
label = "Count"
}
state count = start
view { <button @click="count++">{label}: {count}</button> }
}`;
test("Counter renders its label and initial value", async () => {
const html = await renderComponent(COUNTER, { start: 5, label: "Clicks" });
expect(html).toContain("Clicks");
expect(html).toContain("5");
});
// --- Route-level (fast, no server) -----------------------------------------
test("POST /api/echo echoes the JSON body", async () => {
const { POST } = await import("./api/echo.ts");
const res = await callRoute(
POST,
new Request("http://test/api/echo", {
method: "POST",
body: JSON.stringify({ hi: "there" }),
headers: { "content-type": "application/json" },
}),
);
expect(await res.json()).toEqual({ received: { hi: "there" } });
});
// --- App-level integration (real in-process server) ------------------------
describe("full app", () => {
let app: Harness;
beforeAll(async () => {
app = await createHarness(import.meta.dir + "/..");
});
afterAll(() => app?.close());
test("home page responds with HTML", async () => {
const res = await app.fetch("/");
expect(res.status).toBe(200);
expect(res.headers.get("content-type")).toContain("text/html");
});
test("GET /api/hello returns a translated greeting", async () => {
const res = await app.fetch("/api/hello");
expect(res.status).toBe(200);
const body = (await res.json()) as { message: string; lang: string };
expect(typeof body.message).toBe("string");
expect(body.lang).toBeTruthy();
});
});
+11
View File
@@ -0,0 +1,11 @@
// Auth layout: centers a single card (login/signup). Uses the Wire UI card,
// passing an extra `class` so we can constrain its width.
component AuthLayout {
view {
<div class="auth-wrap">
<div data-component="card" class="auth-card">
<slot></slot>
</div>
</div>
}
}
@@ -0,0 +1,22 @@
// Dashboard layout: fixed sidebar nav + main content area.
component DashboardLayout {
view {
<div class="dash">
<aside class="dash-side">
<strong class="site-brand">WrNexus</strong>
<nav class="dash-nav">
<a href="/dashboard">Overview</a>
<a href="/ui">Components</a>
<a href="/">Home</a>
</nav>
<div data-component="theme-toggle" label="Toggle theme"></div>
</aside>
<main class="dash-main">
<!-- Named slot: pages fill this with data-slot="actions". -->
<div class="dash-topbar"><slot name="actions"></slot></div>
<slot></slot>
</main>
</div>
}
}
+27
View File
@@ -0,0 +1,27 @@
// Public site layout: top nav + footer. Pages opt in with `layout = "public"`.
// A layout is a `component` with a <slot> where the page body is injected.
component PublicLayout {
view {
<div data-component="container">
<header class="site-header">
<strong class="site-brand">WrNexus</strong>
<nav class="site-nav">
<a href="/">{t:nav.home}</a>
<a href="/ui">{t:nav.ui}</a>
<a href="/chat">{t:nav.chat}</a>
<a href="/dashboard">{t:nav.dashboard}</a>
<a href="/about">{t:nav.about}</a>
<div data-component="theme-toggle" label="Theme"></div>
<button class="wire-btn wire-btn--ghost wire-btn--sm" data-wire-lang-set="en">EN</button>
<button class="wire-btn wire-btn--ghost wire-btn--sm" data-wire-lang-set="es">ES</button>
</nav>
</header>
<main>
<slot></slot>
</main>
<footer class="site-footer">Built with WrNexus · Wire UI + theming</footer>
</div>
}
}
+16
View File
@@ -0,0 +1,16 @@
{
"nav": {
"home": "Home",
"ui": "UI",
"chat": "Chat",
"dashboard": "Dashboard",
"about": "About"
},
"home": {
"title": "Hello from WrNexus",
"intro": "An SSR-first framework with reactive components. Try the counters:"
},
"api": {
"greeting": "Hello from the API (en)"
}
}
+16
View File
@@ -0,0 +1,16 @@
{
"nav": {
"home": "Inicio",
"ui": "UI",
"chat": "Chat",
"dashboard": "Panel",
"about": "Acerca de"
},
"home": {
"title": "Hola desde WrNexus",
"intro": "Un framework SSR-first con componentes reactivos. Prueba los contadores:"
},
"api": {
"greeting": "Hola desde la API (es)"
}
}
+16
View File
@@ -0,0 +1,16 @@
import { getUser, type Context, type Next } from "@wrnexus/core";
// Hydrates ctx.user from the session on every request, then guards protected
// routes. `getUser(ctx)` is then available to every downstream page/API route.
export default async function auth(ctx: Context, next: Next) {
// Populate ctx.user from the session (equivalent to the sessionAuth() helper).
ctx.user = ctx.session.get("user") ?? null;
// Protect the dashboard: send anonymous visitors to the login page.
if (ctx.url.pathname.startsWith("/dashboard") && getUser(ctx) == null) {
return new Response(null, { status: 302, headers: { Location: "/login?next=/dashboard" } });
}
ctx.locals.requestId = crypto.randomUUID();
return next();
}
@@ -0,0 +1,6 @@
import { requestLogger } from "@wrnexus/core";
import { env } from "../env.ts"; // validated at startup (throws on misconfiguration)
// Structured request logging: pretty in dev, JSON in production. Runs before
// every page and API route; the request id is stored on ctx.locals.requestId.
export default requestLogger({ format: env.NODE_ENV === "production" ? "json" : "pretty" });
@@ -0,0 +1,16 @@
import { rateLimit, type Context, type Next } from "@wrnexus/core";
// Scoped rate limiting: throttle POST /api/login to blunt brute-force attempts.
// The limiter keeps per-IP counters; other routes pass straight through.
const loginLimiter = rateLimit({
max: 5,
windowMs: 60_000,
message: "Too many login attempts. Please wait a minute and try again.",
});
export default async function ratelimit(ctx: Context, next: Next) {
if (ctx.url.pathname === "/api/login" && ctx.req.method === "POST") {
return loginLimiter(ctx, next);
}
return next();
}
+15
View File
@@ -0,0 +1,15 @@
// About page. Route: /about
// Pure SSR — ships zero JavaScript (no state, no directives).
page About {
layout = "public"
seo {
title = "About"
}
view {
<h1>About Page</h1>
<p>This page ships zero JavaScript — pure SSR.</p>
<p><a href="/">Home</a></p>
}
}
+58
View File
@@ -0,0 +1,58 @@
// Realtime chat. Route: /chat. There is NO client JS file — the page just
// declares `data-room="chat"` and the framework's realtime runtime handles the
// WebSocket, rendering incoming messages into the `<template>`s below and
// sending the form on submit. Server side: app/realtime/chat.ts.
page Chat {
layout = "public"
seo {
title = "Realtime chat"
description = "A broadcast chat room — declared with data-room, zero client JS."
}
style {
.chat-log {
height: 320px;
overflow-y: auto;
display: flex;
flex-direction: column;
gap: 0.35rem;
font-size: 0.95rem;
}
.chat-row--system { color: var(--wire-color-muted); font-size: 0.85rem; }
.chat-form { display: flex; gap: 0.5rem; flex-wrap: wrap; }
.chat-form .chat-name { max-width: 150px; }
.chat-form .chat-msg { flex: 1 1 12rem; }
[data-room-status].is-connected { background: var(--wire-color-success); color: #05210f; }
[data-room-status].is-disconnected,
[data-room-status].is-error { background: var(--wire-color-danger); color: #fff; }
}
view {
<h1>Realtime chat</h1>
<p>
Open this page in two tabs. The page ships <strong>no client JS</strong> —
it just declares <code>data-room="chat"</code>; the framework connects,
renders messages, and sends the form. Server side is a <code>defineRoom</code>
handler in <code>app/realtime/chat.ts</code>.
</p>
<div data-room="chat">
<div data-component="stack" gap="4">
<span data-room-status data-room-status-class="wire-badge" class="wire-badge wire-badge--default">connecting…</span>
<div data-room-log class="chat-log wire-card"></div>
<template data-room-item="message"><div><strong>%user%</strong>: %text%</div></template>
<template data-room-item="system"><div class="chat-row--system"><em>%text%</em></div></template>
<form data-room-send class="chat-form">
<input name="user" class="wire-input chat-name" placeholder="Your name" autocomplete="off">
<input name="text" class="wire-input chat-msg" placeholder="Type a message…" data-room-reset autocomplete="off">
<button type="submit" class="wire-btn wire-btn--primary">Send</button>
</form>
</div>
</div>
<p><a href="/">← Home</a></p>
}
}
@@ -0,0 +1,34 @@
// Dashboard page. Route: /dashboard. Uses the sidebar layout.
page Dashboard {
layout = "dashboard"
seo {
title = "Dashboard"
}
view {
<!-- Fills the layout's named "actions" slot; the rest is the default slot. -->
<div data-slot="actions">
<div data-component="button" variant="primary" label="+ New"></div>
</div>
<h1>Dashboard</h1>
<p>This page selects the <code>dashboard</code> layout (sidebar nav), and fills
its named <code>actions</code> slot with a button.</p>
<div data-component="grid" cols="3" gap="4">
<div data-component="card">
<strong>Users</strong>
<div data-component="badge" variant="success" label="1,204"></div>
</div>
<div data-component="card">
<strong>Revenue</strong>
<div data-component="badge" variant="primary" label="$8.2k"></div>
</div>
<div data-component="card">
<strong>Errors</strong>
<div data-component="badge" variant="danger" label="3"></div>
</div>
</div>
}
}
+130
View File
@@ -0,0 +1,130 @@
// A page written in the .wrn language. Route: /hello
//
// SSR:
// `view` compiles to server-rendered HTML and is returned on the first request.
//
// CSR:
// `state`, `{expr}`, and `@click` compile to hydrated behavior.
// The browser hydrates those directives with /__wrnexus/reactive.js.
page Hello {
layout = "public"
// Client state seed. Without this block the page is pure SSR.
state count = 0
seo {
title = "Hello from .wrn"
description = "A WrNexus .wrn page showing SSR data, CSR hydration, cookies, sessions, and localStorage."
canonical = "/hello"
}
// SSR data bindings run on the server before the HTML is sent.
// The API itself lives in app/api/users/ssr.ts; this block only calls it
// and renders the response into HTML.
ssr {
functions {
function userNames(users) {
return users.map((user) => user.name).join(", ")
}
}
api ssrUsers GET /api/users/ssr {
const visits = Number(cookies.get("hello_visits") ?? "0") + 1
cookies.set("hello_visits", String(visits), { sameSite: "Lax" })
session.set("lastHelloVisit", visits)
return `${userNames(users)} - visit ${visits}`
}
}
// Client data bindings hydrate after the first paint. The browser only sees
// an opaque data-wrnexus-csr id; WrNexus calls app/api/users/csr.ts on the server.
client {
functions {
function userNames(users) {
return users.map((user) => user.name).join(", ")
}
}
api csrUsers GET /api/users/csr {
const label = localStorage.get("wrnexus.label") ?? "browser"
session.set("lastClientLabel", label)
return `${userNames(users)} - ${label}`
}
}
view {
<!-- These elements are rendered on the server first. -->
<h1>Hello from .wrn</h1>
<!-- `{count}` and `{count * 2}` update in the browser after hydration. -->
<p>Hello, WrNexus. Count is {count}, doubled is {count * 2}.</p>
<div class="data-grid">
<div class="data-panel">
<h2>SSR API data</h2>
<p>This is fetched from app/api before HTML is sent.</p>
<div class="user-list" api="ssrUsers">Loading SSR users...</div>
</div>
<div class="data-panel">
<h2>CSR API data</h2>
<p>This is fetched from app/api after hydration.</p>
<div class="user-list" api="csrUsers">Loading CSR users...</div>
</div>
</div>
<!-- `@click` becomes data-on-click and runs against the client scope. -->
<div class="my-actions">
<button @click="count++">Increment</button>
<button @click="count = 0">Reset</button>
</div>
}
// Page-local CSS is inlined with the server-rendered HTML.
style {
h1 {
color: #2563eb;
}
.my-actions {
display: flex;
gap: 0.5rem;
margin-top: 0.75rem;
}
.data-grid {
display: grid;
gap: 1rem;
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
margin: 1rem 0;
}
.data-panel {
border: 1px solid #d1d5db;
border-radius: 8px;
padding: 1rem;
}
.data-panel h2 {
font-size: 1rem;
margin: 0 0 0.5rem;
}
.user-list {
font-weight: 700;
margin-top: 0.5rem;
}
.my-actions button {
border: 1px solid #d1d5db;
border-radius: 6px;
padding: 0.45rem 0.7rem;
}
}
realtime hello {
on message(data) {
console.log(data)
}
}
}
+58
View File
@@ -0,0 +1,58 @@
// Home page. Route: / (a trailing `index` segment is dropped from the route).
//
// SSR-first: the body is server-rendered. Interactivity comes from components —
// `.wrn` files under app/components, rendered on the server and hydrated in the
// browser. Mount one with data-component="<name>" and pass props as attributes.
page Home {
layout = "public"
seo {
title = "Home"
description = "Welcome to the WrNexus basic app, an SSR-first framework demo."
}
view {
<h1>{t:home.title}</h1>
<p>{t:home.intro}</p>
<!-- Tailwind utility classes (flex layout + spacing) style the page; the
mount divs are replaced by each component's server-rendered HTML. -->
<div class="flex flex-wrap items-center gap-3 my-4">
<div data-component="counter" start="0" label="Count"></div>
<!-- The label is localized: `{t:key}` in a prop is resolved per-request. -->
<div data-component="counter" start="10" label="{t:nav.dashboard}"></div>
</div>
<!-- This box is styled entirely with theme tokens (var(--wire-*)), so it
restyles instantly when the theme changes. -->
<div class="themed-box">
This box uses theme tokens. Current theme controls its colors.
<button data-wire-theme-toggle class="theme-toggle">Toggle theme</button>
</div>
<p><a class="text-red-600 hover:underline" href="/about">About</a></p>
}
style {
.themed-box {
background: var(--wire-color-surface);
color: var(--wire-color-text);
border: 1px solid var(--wire-color-border);
border-radius: var(--wire-radius);
padding: 1rem;
margin: 1rem 0;
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
}
.theme-toggle {
background: var(--wire-color-primary);
color: var(--wire-color-primary-contrast);
border: 0;
border-radius: var(--wire-radius-sm);
padding: 0.5rem 0.9rem;
cursor: pointer;
}
}
}
+34
View File
@@ -0,0 +1,34 @@
// Login page. Route: /login. Uses the centered `auth` layout.
// The form declares data-schema="login" — the framework injects the schema
// descriptor + the generic validator, which validates on submit/blur and writes
// messages into the [data-error] spans. The same schema guards POST /api/login.
page Login {
layout = "auth"
seo {
title = "Sign in"
}
view {
<form data-schema="login" method="post" action="/api/login" data-redirect="/dashboard">
<div data-component="stack" gap="4">
<h1>Sign in</h1>
<div class="wire-alert wire-alert--success" data-success="Signed in! The form posted JSON and was validated server-side." hidden></div>
<div>
<div data-component="input" type="email" name="email" placeholder="you@example.com"></div>
<span class="wire-field-error" data-error="email"></span>
</div>
<div>
<div data-component="input" type="password" name="password" placeholder="Password"></div>
<span class="wire-field-error" data-error="password"></span>
</div>
<button type="submit" class="wire-btn wire-btn--primary wire-btn--lg">Sign in</button>
<p><a href="/">Back home</a></p>
</div>
</form>
}
}
+29
View File
@@ -0,0 +1,29 @@
// Reactive directives demo. Route: /reactive
//
// No client island file is needed — the generic reactive runtime
// (/__wrnexus/reactive.js) hydrates the [data-scope] that `state` compiles to.
// `count` becomes a signal; `{expr}` text interpolation and `@click` bind to it.
page Reactive {
layout = "public"
// Seeds the reactive scope. The compiler wraps the view in
// <div data-scope="count: 0">, which the runtime hydrates.
state count = 0
seo {
title = "Reactive"
description = "Directive-driven reactivity bound to signals"
}
view {
<h1>Reactive directives</h1>
<div class="card">
<p>Count is <strong>{count}</strong>, doubled is <strong>{count * 2}</strong>.</p>
<button @click="count++">increment</button>
<button @click="count = 0">reset</button>
</div>
<p><a href="/">Home</a> · <a href="/about">About</a></p>
}
}
+85
View File
@@ -0,0 +1,85 @@
// Wire UI showcase. Route: /ui. Every element below is a server-rendered
// component from @wrnexus/ui (auto-discovered), styled by theme tokens.
page UI {
layout = "public"
seo {
title = "Wire UI"
description = "Built-in Wire UI components and layout primitives."
}
view {
<h1>Wire UI components</h1>
<div data-component="stack" gap="5">
<div data-component="alert" variant="info" title="Server-rendered" message="These components render on the server and ship no JS unless they are interactive."></div>
<div data-component="card">
<div data-component="stack" gap="3">
<strong>Buttons</strong>
<div data-component="hstack" gap="3">
<div data-component="button" variant="primary" label="Primary"></div>
<div data-component="button" variant="default" label="Default"></div>
<div data-component="button" variant="danger" label="Danger"></div>
<div data-component="button" variant="ghost" label="Ghost"></div>
</div>
</div>
</div>
<div data-component="grid" cols="3" gap="4">
<div data-component="card">
<strong>Badges</strong>
<div data-component="hstack" gap="2">
<div data-component="badge" variant="success" label="Live"></div>
<div data-component="badge" variant="warning" label="Beta"></div>
<div data-component="badge" variant="danger" label="Off"></div>
</div>
</div>
<div data-component="card">
<strong>Input</strong>
<div data-component="input" placeholder="Type here..."></div>
</div>
<div data-component="card">
<strong>Spinner</strong>
<div data-component="spinner"></div>
</div>
</div>
<div data-component="divider"></div>
<div data-component="card">
<div data-component="stack" gap="3">
<strong>More components</strong>
<div data-component="hstack" gap="3">
<div data-component="tag" label="default"></div>
<div data-component="tag" variant="primary" label="primary"></div>
<div data-component="tag" variant="success" label="success"></div>
<div data-component="tag" variant="danger" label="danger"></div>
</div>
<div data-component="select" name="fruit">
<option>Apple</option>
<option>Banana</option>
<option>Cherry</option>
</div>
<div data-component="switch" name="notify" label="Email notifications"></div>
<div data-component="radio" name="plan" value="pro" label="Pro plan"></div>
<div data-component="progress" value="65"></div>
<div data-component="skeleton" width="60%" height="1.2rem"></div>
<p>Hover this <div data-component="tooltip" text="I am a CSS tooltip"><strong>tooltip trigger</strong></div>.</p>
<div data-component="table">
<thead><tr><th>Name</th><th>Role</th></tr></thead>
<tbody><tr><td>Ada</td><td>Admin</td></tr><tr><td>Grace</td><td>Editor</td></tr></tbody>
</div>
</div>
</div>
<div data-component="divider"></div>
<div data-component="disclosure" summary="How do I override component styles?">
Four ways, least to most control: change a theme token, redefine a
<code>.wire-*</code> class in your CSS, pass a <code>class</code> prop, or run
<code>wrnexus eject &lt;name&gt;</code> to copy the component into app/components.
</div>
</div>
}
}
+28
View File
@@ -0,0 +1,28 @@
// ws://<host>/realtime/chat — a broadcast chat room.
//
// The whole client side is the framework's realtime runtime (the /chat page just
// declares `data-room="chat"`). Here we only handle room events.
import { defineRoom } from "@wrnexus/core";
type ChatIn = { user?: string; text?: string };
export default defineRoom({
onConnect(client) {
// Tell everyone else someone joined (the joiner sees the status badge).
client.broadcast({ type: "system", text: "A user joined", online: client.room.count() });
},
onMessage(client, msg: ChatIn) {
const text = String(msg.text ?? "")
.slice(0, 500)
.trim();
if (!text) return;
const user = String(msg.user ?? "anon").slice(0, 40) || "anon";
// Broadcast to the whole room, including the sender, so everyone stays in sync.
client.room.broadcast({ type: "message", user, text });
},
onLeave(client) {
client.broadcast({ type: "system", text: "A user left", online: client.room.count() - 1 });
},
});
+31
View File
@@ -0,0 +1,31 @@
// AUTO-GENERATED by `wrnexus dev` — do not edit.
// Typed routes: a compile-time map of every page path to its [param] types,
// plus an href() builder that fills params and rejects unknown paths.
export interface Routes {
"/": Record<string, never>;
"/about": Record<string, never>;
"/chat": Record<string, never>;
"/dashboard": Record<string, never>;
"/hello": Record<string, never>;
"/login": Record<string, never>;
"/reactive": Record<string, never>;
"/ui": Record<string, never>;
}
export type RoutePath = keyof Routes;
export function href<P extends RoutePath>(
path: P,
...args: Routes[P] extends Record<string, never> ? [] : [params: Routes[P]]
): string {
const params = (args[0] ?? {}) as Record<string, string>;
return String(path)
.split("/")
.map((seg) =>
seg.startsWith("[") && seg.endsWith("]")
? encodeURIComponent(params[seg.slice(1, -1)] ?? "")
: seg,
)
.join("/");
}
+7
View File
@@ -0,0 +1,7 @@
// One schema, used by both the /login form (client) and /api/login (server).
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"),
});
+170
View File
@@ -0,0 +1,170 @@
/*
* Global stylesheet. Tailwind v4 is compiled by the `styles.process` hook in
* wrnexus.config.ts (@tailwindcss/cli) and served at /__wrnexus/styles.css, linked
* into every page. It styles SSR markup and hydrated components alike.
*
* `@source` tells Tailwind which files to scan for class names — our pages and
* components are `.wrn`/`.tsx` files under app/. Paths are relative to this file.
*/
@import "tailwindcss";
@source "../**/*.wrn";
@source "../**/*.tsx";
/* --- App theme layered on top of Tailwind's utilities & preflight --- */
:root {
--bg: #0b1020;
--surface: #141a30;
--text: #e7ecff;
--muted: #9aa6d0;
--brand: #6c8cff;
--radius: 10px;
--maxw: 720px;
}
* {
box-sizing: border-box;
}
body {
margin: 0;
font:
16px/1.6 system-ui,
-apple-system,
Segoe UI,
Roboto,
sans-serif;
color: var(--text);
background: radial-gradient(1200px 600px at 50% -10%, #1b2750 0%, var(--bg) 60%);
background-color: var(--bg);
}
#app {
box-sizing: border-box;
min-height: 100vh;
display: flex;
flex-direction: column;
max-width: var(--maxw);
margin: 0 auto;
padding: 3rem 1.25rem;
}
h1 {
font-size: 1.9rem;
letter-spacing: -0.02em;
margin: 0 0 0.5rem;
}
p {
color: var(--muted);
}
a {
color: var(--brand);
text-decoration: none;
}
a:hover {
text-decoration: underline;
}
button {
font: inherit;
color: white;
background: var(--brand);
border: 0;
padding: 0.5rem 1rem;
margin: 0.25rem 0.25rem 0.25rem 0;
border-radius: var(--radius);
cursor: pointer;
transition:
transform 0.05s ease,
filter 0.15s ease;
}
button:hover {
filter: brightness(1.08);
}
button:active {
transform: translateY(1px);
}
/* Page-layout chrome (app/layout.wrn). */
.site-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 1rem 0;
border-bottom: 1px solid var(--wire-color-border);
margin-bottom: 1.5rem;
}
.site-brand {
font-size: 1.15rem;
color: var(--wire-color-text);
}
.site-nav {
display: flex;
align-items: center;
gap: 1rem;
}
.site-footer {
margin-top: 2rem;
padding: 1.25rem 0;
border-top: 1px solid var(--wire-color-border);
color: var(--wire-color-muted);
font-size: 0.9rem;
}
/* Dashboard layout (app/layouts/dashboard.wrn). */
.dash {
display: grid;
grid-template-columns: 200px 1fr;
gap: 1.5rem;
min-height: 60vh;
}
.dash-side {
display: flex;
flex-direction: column;
gap: 1rem;
padding: 1rem 0;
border-right: 1px solid var(--wire-color-border);
}
.dash-nav {
display: flex;
flex-direction: column;
gap: 0.4rem;
}
.dash-main {
padding: 1rem 0;
}
.dash-topbar {
display: flex;
justify-content: flex-end;
margin-bottom: 1rem;
}
@media (max-width: 640px) {
.dash {
grid-template-columns: 1fr;
}
}
/* Auth layout (app/layouts/auth.wrn). */
.auth-wrap {
min-height: 70vh;
display: flex;
align-items: center;
justify-content: center;
}
.auth-card {
width: 100%;
max-width: 360px;
}
/* A small utility, to show CSS nesting works through the bundler. */
.card {
background: var(--surface);
border: 1px solid #ffffff14;
border-radius: var(--radius);
padding: 1.25rem 1.5rem;
& strong {
color: var(--text);
}
}