release: WRNexusJS 0.8.0
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "auth-showcase",
|
||||
"version": "0.1.0",
|
||||
"version": "0.8.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
node_modules
|
||||
dist
|
||||
**/.wrnexus
|
||||
.git
|
||||
*.log
|
||||
*.db
|
||||
*.db-shm
|
||||
*.db-wal
|
||||
.DS_Store
|
||||
@@ -0,0 +1,7 @@
|
||||
# Copy to .env.production and replace every required value.
|
||||
NODE_ENV=production
|
||||
PORT=3000
|
||||
HOST=0.0.0.0
|
||||
DATABASE_URL=postgres://USER:PASSWORD@HOST:5432/DB
|
||||
SESSION_SECRET=REPLACE_WITH_AT_LEAST_32_RANDOM_BYTES
|
||||
# OTEL_EXPORTER_OTLP_ENDPOINT=https://collector.example.com
|
||||
@@ -1,5 +1,8 @@
|
||||
node_modules/
|
||||
dist/
|
||||
.wrnexus/
|
||||
.wirefw/
|
||||
**/.wirefw/
|
||||
**/*.gen.ts
|
||||
**/*.generated.d.ts
|
||||
*.log
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
# syntax=docker/dockerfile:1
|
||||
# --- build stage: install deps + produce dist/server.js ---
|
||||
FROM oven/bun:1 AS build
|
||||
WORKDIR /app
|
||||
COPY package.json bun.lock* bun.lockb* ./
|
||||
RUN bun install
|
||||
COPY . .
|
||||
RUN bun run build
|
||||
|
||||
# --- runtime stage: slim image with only the built server + migrations ---
|
||||
FROM oven/bun:1-slim AS runtime
|
||||
WORKDIR /app
|
||||
ENV NODE_ENV=production
|
||||
ENV PORT=3000
|
||||
COPY --from=build /app/dist ./dist
|
||||
COPY --from=build /app/app/db/migrations ./app/db/migrations
|
||||
EXPOSE 3000
|
||||
HEALTHCHECK --interval=30s --timeout=3s --retries=3 \
|
||||
CMD bun -e "fetch('http://localhost:'+(process.env.PORT||3000)+'/healthz').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"
|
||||
CMD ["bun", "dist/server.js"]
|
||||
@@ -8,6 +8,11 @@ export const GET = async () => {
|
||||
};
|
||||
|
||||
export const POST = async (ctx: Context) => {
|
||||
const body = await ctx.req.json();
|
||||
const body = await ctx.req.json().catch(() => undefined);
|
||||
if (body === undefined)
|
||||
return Response.json(
|
||||
{ error: "Request body must be valid JSON." },
|
||||
{ status: 400, headers: { "cache-control": "no-store" } },
|
||||
);
|
||||
return Response.json({ received: body });
|
||||
};
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
import { createGraphqlHandler } from "@wrnexus/graphql";
|
||||
|
||||
const graphql = createGraphqlHandler({
|
||||
maxDepth: 8,
|
||||
maxAliases: 20,
|
||||
allowIntrospection: false,
|
||||
async execute(request) {
|
||||
return { data: { example: request.operationName ?? "anonymous" } };
|
||||
},
|
||||
});
|
||||
|
||||
export function POST(ctx: { req: Request }): Promise<Response> {
|
||||
return graphql(ctx.req);
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { defineEndpoint } from "@wrnexus/core";
|
||||
import { CreateUserSchema } from "../schemas/create-user.ts";
|
||||
|
||||
/** Schema-derived request and response types are emitted into wrnexus.generated.d.ts. */
|
||||
export const POST = defineEndpoint({
|
||||
input: CreateUserSchema,
|
||||
output: CreateUserSchema,
|
||||
description: "Validate and echo a typed user payload.",
|
||||
handler(input) {
|
||||
return input;
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,11 @@
|
||||
export const webhook = {
|
||||
event: "payment.completed",
|
||||
summary: "Payment completed",
|
||||
description: "Sent after a payment reaches its settled state.",
|
||||
payloadSchema: "#/components/schemas/PaymentCompleted",
|
||||
signatureHeader: "x-payment-signature",
|
||||
};
|
||||
|
||||
export async function POST(): Promise<Response> {
|
||||
return Response.json({ accepted: true });
|
||||
}
|
||||
@@ -73,4 +73,42 @@ describe("full app", () => {
|
||||
expect(typeof body.message).toBe("string");
|
||||
expect(body.lang).toBeTruthy();
|
||||
});
|
||||
|
||||
test("language-server playground renders its real interactive output", async () => {
|
||||
const res = await app.fetch("/language-tools");
|
||||
expect(res.status).toBe(200);
|
||||
const html = await res.text();
|
||||
expect(html).toContain("Language server playground");
|
||||
expect(html).toContain("computed double:");
|
||||
expect(html).toContain('data-text="doubled">0</span>');
|
||||
expect(html).toContain('aria-live="polite"');
|
||||
});
|
||||
|
||||
test("login renders real accessible fields without an accidental Card component", async () => {
|
||||
const res = await app.fetch("/login?next=/dashboard");
|
||||
expect(res.status).toBe(200);
|
||||
const html = await res.text();
|
||||
expect(html).toContain('name="email"');
|
||||
expect(html).toContain('name="password"');
|
||||
expect(html).toContain('autocomplete="current-password"');
|
||||
expect(html).not.toContain("Card title");
|
||||
});
|
||||
|
||||
test("platform showcase uses the public layout and styled reactive primitives", async () => {
|
||||
const res = await app.fetch("/platform-showcase");
|
||||
expect(res.status).toBe(200);
|
||||
const html = await res.text();
|
||||
expect(html).toContain("WrNexus");
|
||||
expect(html).toContain('data-wrn-dynamic-component="Admin"');
|
||||
expect(html).toContain('data-wrn-portal="#showcase-modal"');
|
||||
expect(html).toContain("platform-showcase");
|
||||
});
|
||||
|
||||
test("async data page renders its server loader without a cache failure", async () => {
|
||||
const res = await app.fetch("/async-data");
|
||||
expect(res.status).toBe(200);
|
||||
const html = await res.text();
|
||||
expect(html).toContain("Rendered by the server loader");
|
||||
expect(html).toContain("Loading the browser profile");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,9 +3,27 @@
|
||||
component AuthLayout {
|
||||
view {
|
||||
<div class="auth-wrap">
|
||||
<div data-component="card" class="auth-card">
|
||||
<section class="auth-card" aria-label="Authentication">
|
||||
<slot></slot>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
}
|
||||
|
||||
style {
|
||||
.auth-wrap {
|
||||
display: grid;
|
||||
min-height: 100vh;
|
||||
place-items: center;
|
||||
padding: 1.5rem;
|
||||
}
|
||||
|
||||
.auth-card {
|
||||
width: min(100%, 28rem);
|
||||
padding: 2rem;
|
||||
border: 1px solid var(--wire-color-border);
|
||||
border-radius: var(--wire-radius-lg);
|
||||
background: var(--wire-color-surface);
|
||||
box-shadow: 0 1.25rem 3rem rgb(0 0 0 / 0.18);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@ component DashboardLayout {
|
||||
<a href="/ui">Components</a>
|
||||
<a href="/">Home</a>
|
||||
</nav>
|
||||
<div data-component="theme-toggle" label="Toggle theme"></div>
|
||||
<button type="button" data-wire-theme-toggle aria-label="Toggle color theme">Theme</button>
|
||||
</aside>
|
||||
|
||||
<main class="dash-main">
|
||||
|
||||
@@ -11,7 +11,7 @@ component PublicLayout {
|
||||
<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 type="button" data-wire-theme-toggle aria-label="Toggle color theme">Theme</button>
|
||||
<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>
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
page AsyncData {
|
||||
layout = "public"
|
||||
|
||||
load server summary {
|
||||
return { message: "Rendered by the server loader" }
|
||||
}
|
||||
|
||||
load client profile {
|
||||
return { name: "Ada", role: "Administrator" }
|
||||
}
|
||||
|
||||
view {
|
||||
<section class="stack">
|
||||
<h1>Async data boundaries</h1>
|
||||
|
||||
<Async source="summary">
|
||||
<Loading><p>Preparing the server summary…</p></Loading>
|
||||
<Success data="summary"><p>{summary.message}</p></Success>
|
||||
<Error error="error"><p>{error.message}</p></Error>
|
||||
</Async>
|
||||
|
||||
<Async source="profile" retries="2">
|
||||
<Loading><p>Loading the browser profile…</p></Loading>
|
||||
<Success data="profile"><p>Welcome {profile.name} — {profile.role}</p></Success>
|
||||
<Error error="error"><p>Profile failed: {error.message}</p></Error>
|
||||
</Async>
|
||||
</section>
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
page ClientOnly {
|
||||
layout = "public"
|
||||
render = "client"
|
||||
hydrate = "load"
|
||||
|
||||
state count: number = 0
|
||||
|
||||
view {
|
||||
<section class="wire-card stack">
|
||||
<p class="wire-badge">Mounted in the browser</p>
|
||||
<h1>Client-only page</h1>
|
||||
<button type="button" class="wire-btn" @click="count++">Count: {count}</button>
|
||||
</section>
|
||||
}
|
||||
}
|
||||
@@ -27,10 +27,11 @@ page Home {
|
||||
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>
|
||||
<button type="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>
|
||||
<p><a class="hover:underline" href="/language-tools">Language server playground</a></p>
|
||||
}
|
||||
|
||||
style {
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
// Real page for checking compiler, formatter, LSP navigation, rename, and reactivity.
|
||||
page LanguageTools {
|
||||
layout = "public"
|
||||
|
||||
state count: number = 0
|
||||
state message: string = "The shared language server is active."
|
||||
|
||||
computed doubled: number = count * 2
|
||||
|
||||
seo {
|
||||
title = "WRN language tools"
|
||||
description = "Interactive WRN page used to verify editor and compiler features."
|
||||
canonical = "/language-tools"
|
||||
}
|
||||
|
||||
functions {
|
||||
function increment() {
|
||||
count++
|
||||
message = `Renaming count updates all ${count} references safely.`
|
||||
}
|
||||
|
||||
function reset() {
|
||||
count = 0
|
||||
message = "State reset. Try hover, definition, references, and rename in your editor."
|
||||
}
|
||||
}
|
||||
|
||||
view {
|
||||
<main class="language-tools">
|
||||
<header>
|
||||
<span class="eyebrow">Editor-neutral WRN tooling</span>
|
||||
<h1>Language server playground</h1>
|
||||
<p>{message}</p>
|
||||
</header>
|
||||
|
||||
<section aria-labelledby="counter-heading" class="demo-card">
|
||||
<h2 id="counter-heading">Reactive Data symbol test</h2>
|
||||
<p aria-live="polite">Count: {count}; computed double: {doubled}</p>
|
||||
<div class="actions">
|
||||
<button type="button" @click="increment()">Increment</button>
|
||||
<button type="button" class="secondary" @click="reset()">Reset</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section aria-labelledby="features-heading" class="demo-card">
|
||||
<h2 id="features-heading">Features exercised by this file</h2>
|
||||
<ul>
|
||||
<li>Compiler and accessibility diagnostics</li>
|
||||
<li>Canonical formatting and completion</li>
|
||||
<li>Hover, symbols, definition, references, and rename</li>
|
||||
<li>Typed state, computed values, and browser events</li>
|
||||
</ul>
|
||||
</section>
|
||||
</main>
|
||||
}
|
||||
|
||||
style {
|
||||
.language-tools {
|
||||
display: grid;
|
||||
max-width: 58rem;
|
||||
gap: 1.25rem;
|
||||
margin: 3rem auto;
|
||||
}
|
||||
|
||||
.language-tools header,
|
||||
.demo-card {
|
||||
display: grid;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.eyebrow {
|
||||
color: var(--wire-color-primary);
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.demo-card {
|
||||
padding: 1.25rem;
|
||||
border: 1px solid var(--wire-color-border);
|
||||
border-radius: var(--wire-radius-lg);
|
||||
background: var(--wire-color-surface);
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.actions button {
|
||||
padding: 0.65rem 1rem;
|
||||
border: 1px solid var(--wire-color-primary);
|
||||
border-radius: var(--wire-radius-md);
|
||||
background: var(--wire-color-primary);
|
||||
color: var(--wire-color-primary-contrast);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.actions .secondary {
|
||||
background: transparent;
|
||||
color: var(--wire-color-primary);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -11,24 +11,49 @@ page Login {
|
||||
|
||||
view {
|
||||
<form data-schema="login" method="post" action="/api/login" data-redirect="/dashboard">
|
||||
<div data-component="stack" gap="4">
|
||||
<div class="auth-stack">
|
||||
<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>
|
||||
<label class="auth-field">
|
||||
<span>Email address</span>
|
||||
<input type="email" name="email" autocomplete="email" placeholder="you@example.com" required />
|
||||
<span class="wire-field-error" data-error="email"></span>
|
||||
</div>
|
||||
</label>
|
||||
|
||||
<div>
|
||||
<div data-component="input" type="password" name="password" placeholder="Password"></div>
|
||||
<label class="auth-field">
|
||||
<span>Password</span>
|
||||
<input type="password" name="password" autocomplete="current-password" placeholder="Password" required />
|
||||
<span class="wire-field-error" data-error="password"></span>
|
||||
</div>
|
||||
</label>
|
||||
|
||||
<button type="submit" class="wire-btn wire-btn--primary wire-btn--lg">Sign in</button>
|
||||
<p><a href="/">Back home</a></p>
|
||||
</div>
|
||||
</form>
|
||||
}
|
||||
|
||||
style {
|
||||
form,
|
||||
.auth-stack,
|
||||
.auth-field {
|
||||
display: grid;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
form,
|
||||
.auth-stack {
|
||||
gap: 1.25rem;
|
||||
}
|
||||
|
||||
.auth-field input {
|
||||
min-height: 2.75rem;
|
||||
padding: 0.7rem 0.8rem;
|
||||
border: 1px solid var(--wire-color-border);
|
||||
border-radius: var(--wire-radius-md);
|
||||
background: var(--wire-color-surface);
|
||||
color: inherit;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
page PartialStatic {
|
||||
layout = "public"
|
||||
render = "partial-static"
|
||||
|
||||
seo {
|
||||
title = "Partial-static streaming"
|
||||
description = "A build-time WRNexus shell with request-streamed dynamic content."
|
||||
}
|
||||
|
||||
view {
|
||||
<Static>
|
||||
<section class="wire-card stack">
|
||||
<p class="wire-badge">Built once</p>
|
||||
<h1>Build-time static shell</h1>
|
||||
<p>This heading and page structure are emitted into dist/partial-shells.json.</p>
|
||||
</section>
|
||||
</Static>
|
||||
|
||||
<Dynamic>
|
||||
<section class="wire-card stack" aria-live="polite">
|
||||
<p class="wire-badge">Streamed per request</p>
|
||||
<h2>Dynamic account region</h2>
|
||||
<p>Personalized data belongs inside this boundary.</p>
|
||||
</section>
|
||||
</Dynamic>
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
page PlatformShowcase {
|
||||
layout = "public"
|
||||
render = "hybrid"
|
||||
hydrate = "load"
|
||||
|
||||
state active = "Admin"
|
||||
|
||||
view {
|
||||
<main class="platform-showcase">
|
||||
<header>
|
||||
<span class="eyebrow">Reactive primitives</span>
|
||||
<h1>Reactive platform showcase</h1>
|
||||
<p>Switch a dynamic component and verify transition and portal rendering.</p>
|
||||
</header>
|
||||
<section class="showcase-card">
|
||||
<button type="button" @click='active = active === "Admin" ? "Guest" : "Admin"'>Switch component</button>
|
||||
<Transition name="fade">
|
||||
<Component is={active}>
|
||||
<section class="component-case" data-component-case="Admin"><strong>Administrator tools</strong><p>Manage users, permissions, and platform settings.</p></section>
|
||||
<section class="component-case" data-component-case="Guest"><strong>Guest dashboard</strong><p>Explore the public workspace and available resources.</p></section>
|
||||
</Component>
|
||||
</Transition>
|
||||
</section>
|
||||
<div id="showcase-modal" class="portal-target" aria-live="polite"></div>
|
||||
<Portal to="#showcase-modal"><p>Portaled content rendered in its target.</p></Portal>
|
||||
</main>
|
||||
}
|
||||
|
||||
style {
|
||||
.platform-showcase {
|
||||
display: grid;
|
||||
max-width: 58rem;
|
||||
gap: 1.25rem;
|
||||
margin: 3rem auto;
|
||||
}
|
||||
|
||||
.platform-showcase header,
|
||||
.showcase-card,
|
||||
.component-case {
|
||||
display: grid;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.eyebrow {
|
||||
color: var(--wire-color-primary);
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.showcase-card,
|
||||
.portal-target {
|
||||
padding: 1.25rem;
|
||||
border: 1px solid var(--wire-color-border);
|
||||
border-radius: var(--wire-radius-lg);
|
||||
background: var(--wire-color-surface);
|
||||
}
|
||||
|
||||
.showcase-card button {
|
||||
width: fit-content;
|
||||
padding: 0.7rem 1rem;
|
||||
border: 0;
|
||||
border-radius: var(--wire-radius-md);
|
||||
background: var(--wire-color-primary);
|
||||
color: var(--wire-color-primary-contrast);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.fade-enter-active {
|
||||
animation: platform-fade 180ms ease-out;
|
||||
}
|
||||
|
||||
@keyframes platform-fade {
|
||||
from { opacity: 0; transform: translateY(0.25rem); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import { CreateUserSchema } from "../schemas/create-user";
|
||||
|
||||
page ServerActions {
|
||||
seo {
|
||||
title = "Typed server actions"
|
||||
description = "Schema validation, CSRF, invalidation and progressive enhancement"
|
||||
}
|
||||
|
||||
action createUser using CreateUserSchema {
|
||||
invalidate("users")
|
||||
return { id: crypto.randomUUID(), name: input.name, email: input.email }
|
||||
}
|
||||
|
||||
view {
|
||||
<main>
|
||||
<h1>Typed server actions</h1>
|
||||
<p>The form works with or without JavaScript.</p>
|
||||
<form @submit="createUser">
|
||||
<label for="action-name">Name</label>
|
||||
<input id="action-name" name="name" autocomplete="name" required />
|
||||
<label for="action-email">Email</label>
|
||||
<input id="action-email" name="email" type="email" autocomplete="email" required />
|
||||
<button type="submit">Create user</button>
|
||||
</form>
|
||||
</main>
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { defineJob } from "@wrnexus/queue";
|
||||
|
||||
export interface WelcomeEmailPayload {
|
||||
userId: number;
|
||||
email: string;
|
||||
}
|
||||
|
||||
export default defineJob<WelcomeEmailPayload>({
|
||||
name: "welcome-email",
|
||||
async run(job) {
|
||||
console.log(`Welcome email queued for ${job.data.email}`);
|
||||
},
|
||||
});
|
||||
@@ -4,16 +4,50 @@
|
||||
export interface Routes {
|
||||
"/": Record<string, never>;
|
||||
"/about": Record<string, never>;
|
||||
"/async-data": Record<string, never>;
|
||||
"/chat": Record<string, never>;
|
||||
"/client-only": Record<string, never>;
|
||||
"/dashboard": Record<string, never>;
|
||||
"/hello": Record<string, never>;
|
||||
"/language-tools": Record<string, never>;
|
||||
"/login": Record<string, never>;
|
||||
"/modal": Record<string, never>;
|
||||
"/partial-static": Record<string, never>;
|
||||
"/platform-showcase": Record<string, never>;
|
||||
"/reactive": Record<string, never>;
|
||||
"/server-actions": Record<string, never>;
|
||||
"/test": Record<string, never>;
|
||||
"/ui": Record<string, never>;
|
||||
}
|
||||
|
||||
export interface RouteNames {
|
||||
"index": "/";
|
||||
"about": "/about";
|
||||
"async.data": "/async-data";
|
||||
"chat": "/chat";
|
||||
"client.only": "/client-only";
|
||||
"dashboard": "/dashboard";
|
||||
"hello": "/hello";
|
||||
"language.tools": "/language-tools";
|
||||
"login": "/login";
|
||||
"modal": "/modal";
|
||||
"partial.static": "/partial-static";
|
||||
"platform.showcase": "/platform-showcase";
|
||||
"reactive": "/reactive";
|
||||
"server.actions": "/server-actions";
|
||||
"test": "/test";
|
||||
"ui": "/ui";
|
||||
}
|
||||
|
||||
export interface RouteQueries {
|
||||
[path: string]: Record<string, string | number | boolean | null | undefined>;
|
||||
}
|
||||
|
||||
export type RoutePath = keyof Routes;
|
||||
export type RouteName = keyof RouteNames;
|
||||
export type RouteQuery<P extends RoutePath> = P extends keyof RouteQueries
|
||||
? RouteQueries[P]
|
||||
: Record<string, string | number | boolean | null | undefined>;
|
||||
type RouteValue = string | readonly string[] | undefined;
|
||||
|
||||
function encodeRouteValue(value: RouteValue, catchAll: boolean): string {
|
||||
@@ -22,17 +56,9 @@ function encodeRouteValue(value: RouteValue, catchAll: boolean): string {
|
||||
return values.map((part) => encodeURIComponent(part)).join("/");
|
||||
}
|
||||
|
||||
export function href<P extends RoutePath>(
|
||||
path: P,
|
||||
...args: keyof Routes[P] extends never
|
||||
? []
|
||||
: Record<string, never> extends Routes[P]
|
||||
? [params?: Routes[P]]
|
||||
: [params: Routes[P]]
|
||||
): string {
|
||||
const params = (args[0] ?? {}) as Record<string, RouteValue>;
|
||||
function buildHref(path: string, params: Record<string, RouteValue> = {}): string {
|
||||
const output: string[] = [];
|
||||
for (const segment of String(path).split("/").filter(Boolean)) {
|
||||
for (const segment of path.split("/").filter(Boolean)) {
|
||||
let name: string | undefined;
|
||||
let optional = false;
|
||||
let catchAll = false;
|
||||
@@ -61,3 +87,51 @@ export function href<P extends RoutePath>(
|
||||
}
|
||||
return "/" + output.filter(Boolean).join("/");
|
||||
}
|
||||
|
||||
export function href<P extends RoutePath>(
|
||||
path: P,
|
||||
...args: keyof Routes[P] extends never
|
||||
? []
|
||||
: Record<string, never> extends Routes[P]
|
||||
? [params?: Routes[P]]
|
||||
: [params: Routes[P]]
|
||||
): string {
|
||||
const params = (args[0] ?? {}) as Record<string, RouteValue>;
|
||||
return buildHref(String(path), params);
|
||||
}
|
||||
|
||||
export function route<N extends RouteName>(
|
||||
name: N,
|
||||
...args: keyof Routes[RouteNames[N]] extends never
|
||||
? [params?: Routes[RouteNames[N]], query?: RouteQuery<RouteNames[N]>]
|
||||
: Record<string, never> extends Routes[RouteNames[N]]
|
||||
? [params?: Routes[RouteNames[N]], query?: RouteQuery<RouteNames[N]>]
|
||||
: [params: Routes[RouteNames[N]], query?: RouteQuery<RouteNames[N]>]
|
||||
): string {
|
||||
const paths: Record<RouteName, RoutePath> = {
|
||||
"index": "/",
|
||||
"about": "/about",
|
||||
"async.data": "/async-data",
|
||||
"chat": "/chat",
|
||||
"client.only": "/client-only",
|
||||
"dashboard": "/dashboard",
|
||||
"hello": "/hello",
|
||||
"language.tools": "/language-tools",
|
||||
"login": "/login",
|
||||
"modal": "/modal",
|
||||
"partial.static": "/partial-static",
|
||||
"platform.showcase": "/platform-showcase",
|
||||
"reactive": "/reactive",
|
||||
"server.actions": "/server-actions",
|
||||
"test": "/test",
|
||||
"ui": "/ui"
|
||||
} as Record<RouteName, RoutePath>;
|
||||
const output = buildHref(paths[name], (args[0] ?? {}) as Record<string, RouteValue>);
|
||||
const query = args[1];
|
||||
if (!query) return output;
|
||||
const search = new URLSearchParams();
|
||||
for (const [key, value] of Object.entries(query))
|
||||
if (value !== undefined && value !== null) search.set(key, String(value));
|
||||
const text = search.toString();
|
||||
return text ? `${output}?${text}` : output;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
import { v } from "@wrnexus/validation";
|
||||
|
||||
export const CreateUserSchema = v.object({
|
||||
name: v.string().trim().min(2),
|
||||
email: v.string().trim().email(),
|
||||
});
|
||||
@@ -0,0 +1,86 @@
|
||||
import {
|
||||
afterAll,
|
||||
beforeAll,
|
||||
createHarness,
|
||||
describe,
|
||||
expect,
|
||||
test,
|
||||
type Harness,
|
||||
} from "@wrnexus/test";
|
||||
|
||||
describe("real application security abuse checks", () => {
|
||||
let app: Harness;
|
||||
beforeAll(async () => {
|
||||
app = await createHarness(import.meta.dir + "/..");
|
||||
});
|
||||
afterAll(() => app?.close());
|
||||
|
||||
test("sets browser hardening headers on rendered pages", async () => {
|
||||
const response = await app.fetch("/");
|
||||
expect(response.headers.get("content-security-policy")).toContain("default-src");
|
||||
expect(response.headers.get("x-content-type-options")).toBe("nosniff");
|
||||
expect(response.headers.get("x-frame-options")).toBeTruthy();
|
||||
expect(response.headers.get("referrer-policy")).toBeTruthy();
|
||||
});
|
||||
|
||||
test("does not reflect script payloads into HTML", async () => {
|
||||
const payload = `<script>globalThis.__attacked=true</script>`;
|
||||
const response = await app.fetch(`/?search=${encodeURIComponent(payload)}`);
|
||||
expect(response.status).toBe(200);
|
||||
expect(await response.text()).not.toContain(payload);
|
||||
});
|
||||
|
||||
test("rejects traversal attempts without exposing source files", async () => {
|
||||
for (const path of ["/../../package.json", "/%2e%2e/%2e%2e/package.json", "/..%5c..%5c.env"]) {
|
||||
const response = await app.fetch(path);
|
||||
expect([400, 404]).toContain(response.status);
|
||||
const body = await response.text();
|
||||
expect(body).not.toContain("DATABASE_URL");
|
||||
expect(body).not.toContain('"workspaces"');
|
||||
}
|
||||
});
|
||||
|
||||
test("does not grant CORS credentials to an untrusted origin", async () => {
|
||||
const response = await app.fetch("/api/hello", {
|
||||
headers: { origin: "https://evil.example" },
|
||||
});
|
||||
expect(response.headers.get("access-control-allow-origin")).toBeNull();
|
||||
expect(response.headers.get("access-control-allow-credentials")).toBeNull();
|
||||
});
|
||||
|
||||
test("requires CSRF for login and avoids credential oracle details", async () => {
|
||||
const response = await app.fetch("/api/login", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ email: "victim@example.com", password: "wrong-password" }),
|
||||
});
|
||||
expect(response.status).toBe(403);
|
||||
expect(await response.text()).not.toContain("passwordHash");
|
||||
});
|
||||
|
||||
test("handles malformed JSON without a stack trace", async () => {
|
||||
const response = await app.fetch("/api/echo", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: "{broken",
|
||||
});
|
||||
expect(response.status).toBe(400);
|
||||
const body = await response.text();
|
||||
expect(body).not.toContain(" at ");
|
||||
expect(body).not.toContain("node_modules");
|
||||
});
|
||||
|
||||
test("refuses actual request bodies over the configured limit", async () => {
|
||||
try {
|
||||
const response = await app.fetch("/api/echo", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: `"${"x".repeat(10 * 1024 * 1024)}"`,
|
||||
});
|
||||
expect(response.status).toBe(413);
|
||||
} catch (error) {
|
||||
// Bun rejects the oversized socket before application dispatch on some versions.
|
||||
expect(String(error)).toMatch(/ECONNRESET|socket connection was closed/i);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,30 @@
|
||||
import type { Context } from "@wrnexus/core";
|
||||
|
||||
const apiInput: WRNexusGenerated.ApiContracts["/api/typed-user"]["POST"]["input"] = {
|
||||
name: "Ada",
|
||||
email: "ada@example.test",
|
||||
};
|
||||
const queuePayload: WRNexusGenerated.QueuePayloads["welcome-email"] = {
|
||||
userId: 42,
|
||||
email: "ada@example.test",
|
||||
};
|
||||
const queryArgs: WRNexusGenerated.DatabaseQueries["GetUserByEmail"]["args"] = {
|
||||
email: "ada@example.test",
|
||||
};
|
||||
const realtimeMessage: WRNexusGenerated.RealtimeMessages["/realtime/chat"] = {
|
||||
user: "ada",
|
||||
text: "hello",
|
||||
};
|
||||
const middlewareContext: WRNexusGenerated.MiddlewareContexts["auth"] = {} as Context;
|
||||
|
||||
void [apiInput, queuePayload, queryArgs, realtimeMessage, middlewareContext];
|
||||
|
||||
// @ts-expect-error schema-derived input requires an email.
|
||||
const invalidApiInput: WRNexusGenerated.ApiContracts["/api/typed-user"]["POST"]["input"] = {
|
||||
name: "Missing email",
|
||||
};
|
||||
void invalidApiInput;
|
||||
|
||||
// @ts-expect-error generated SQL query arguments require an email string.
|
||||
const invalidQuery: WRNexusGenerated.DatabaseQueries["GetUserByEmail"]["args"] = {};
|
||||
void invalidQuery;
|
||||
@@ -0,0 +1,59 @@
|
||||
// AUTO-GENERATED by `wrnexus generate types` - do not edit.
|
||||
declare namespace WRNexusGenerated {
|
||||
type ApiContract<T> = T extends import("@wrnexus/core").DefinedEndpoint<infer I, infer O>
|
||||
? { input: I; output: O }
|
||||
: T extends (...args: infer A) => infer R
|
||||
? { input: A extends [any, infer I, ...any[]] ? I : unknown; output: Awaited<R> }
|
||||
: { input: unknown; output: unknown };
|
||||
type MiddlewareContext<T> = T extends (ctx: infer C, ...args: any[]) => any ? C : never;
|
||||
type QueryContract<T> = T extends (db: any, args: infer A, ...rest: any[]) => infer R
|
||||
? { args: A; result: Awaited<R> }
|
||||
: T extends (db: any, ...rest: any[]) => infer R
|
||||
? { args: Record<string, never>; result: Awaited<R> }
|
||||
: never;
|
||||
type RealtimeMessage<T> = T extends import("@wrnexus/core").RoomDefinition<any, infer M> ? M : unknown;
|
||||
type QueuePayload<T> = T extends import("@wrnexus/queue").JobDefinition<infer I> ? I : unknown;
|
||||
type RouteName = "about" | "async.data" | "chat" | "client.only" | "dashboard" | "hello" | "index" | "language.tools" | "login" | "modal" | "partial.static" | "platform.showcase" | "reactive" | "server.actions" | "test" | "ui";
|
||||
type ApiRoute = "/api/echo" | "/api/graphql-example" | "/api/hello" | "/api/login" | "/api/logout" | "/api/me" | "/api/typed-user" | "/api/users/csr" | "/api/users/ssr" | "/api/webhooks/payment";
|
||||
type RealtimeRoute = "/realtime/chat" | "/realtime/hello";
|
||||
type EnvironmentKey = "APP_LABEL" | "DATABASE_URL" | "DEMO_SHARED" | "HOST" | "NODE_ENV" | "PORT" | "SESSION_SECRET" | "UAT_ONLY";
|
||||
type TranslationKey = "api.greeting" | "home.intro" | "home.title" | "nav.about" | "nav.chat" | "nav.dashboard" | "nav.home" | "nav.ui";
|
||||
type QueueName = "welcome-email";
|
||||
type CacheKey = "users";
|
||||
interface Components {
|
||||
"Modal": { props: Record<string, never>; outputs: Record<string, never> };
|
||||
"Counter": { props: { "start"?: unknown; "label"?: unknown }; outputs: Record<string, never> };
|
||||
}
|
||||
interface ApiContracts {
|
||||
"/api/webhooks/payment": { POST: ApiContract<typeof import("../api/webhooks/payment.ts")["POST"]> };
|
||||
"/api/users/csr": { GET: ApiContract<typeof import("../api/users/csr.ts")["GET"]> };
|
||||
"/api/users/ssr": { GET: ApiContract<typeof import("../api/users/ssr.ts")["GET"]> };
|
||||
"/api/graphql-example": { POST: ApiContract<typeof import("../api/graphql-example.ts")["POST"]> };
|
||||
"/api/typed-user": { POST: ApiContract<typeof import("../api/typed-user.ts")["POST"]> };
|
||||
"/api/logout": { POST: ApiContract<typeof import("../api/logout.ts")["POST"]> };
|
||||
"/api/hello": { GET: ApiContract<typeof import("../api/hello.ts")["GET"]> };
|
||||
"/api/login": { POST: ApiContract<typeof import("../api/login.ts")["POST"]> };
|
||||
"/api/echo": { GET: ApiContract<typeof import("../api/echo.ts")["GET"]>; POST: ApiContract<typeof import("../api/echo.ts")["POST"]> };
|
||||
"/api/me": { GET: ApiContract<typeof import("../api/me.ts")["GET"]> };
|
||||
}
|
||||
interface MiddlewareContexts {
|
||||
"auth": MiddlewareContext<(typeof import("../middleware/auth.ts"))["default"]>;
|
||||
"logger": MiddlewareContext<(typeof import("../middleware/logger.ts"))["default"]>;
|
||||
"ratelimit": MiddlewareContext<(typeof import("../middleware/ratelimit.ts"))["default"]>;
|
||||
}
|
||||
interface DatabaseQueries {
|
||||
"GetUserByEmail": QueryContract<typeof import("../db/queries.gen.ts")["GetUserByEmail"]>;
|
||||
"ListUsers": QueryContract<typeof import("../db/queries.gen.ts")["ListUsers"]>;
|
||||
"CountActive": QueryContract<typeof import("../db/queries.gen.ts")["CountActive"]>;
|
||||
"CreateUser": QueryContract<typeof import("../db/queries.gen.ts")["CreateUser"]>;
|
||||
"DeactivateUser": QueryContract<typeof import("../db/queries.gen.ts")["DeactivateUser"]>;
|
||||
}
|
||||
interface RealtimeMessages {
|
||||
"/realtime/hello": RealtimeMessage<(typeof import("../pages/hello.wrn"))["default"]>;
|
||||
"/realtime/chat": RealtimeMessage<(typeof import("../realtime/chat.ts"))["default"]>;
|
||||
}
|
||||
interface QueuePayloads {
|
||||
"welcome-email": QueuePayload<(typeof import("../queues/welcome-email.ts"))["default"]>;
|
||||
}
|
||||
type ApplicationConfig = (typeof import("../../wrnexus.config.ts"))["default"];
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
// AUTO-GENERATED plugin type aggregation - do not edit.
|
||||
@@ -0,0 +1,10 @@
|
||||
# WRNexus deployment operations
|
||||
|
||||
- Liveness: `GET /healthz`
|
||||
- Readiness: `GET /readyz` (includes registered dependency checks)
|
||||
- Migrations: run `bunx wrnexus db migrate --profile=production` once per release before scaling.
|
||||
- Shutdown: the Bun production server drains on SIGTERM/SIGINT.
|
||||
- Assets: `dist/public` files are content-addressed and may be cached immutably by a CDN.
|
||||
- Secrets: provide `DATABASE_URL` and `SESSION_SECRET` through the platform secret store; never commit production env files.
|
||||
- Logs: stdout/stderr are structured for platform collection. Configure OTLP for centralized telemetry.
|
||||
- Scaling: start with 250m CPU/256Mi memory, use readiness probes, and scale horizontally from request latency and CPU.
|
||||
@@ -0,0 +1,44 @@
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: wrnexus
|
||||
spec:
|
||||
selector: { app: wrnexus }
|
||||
ports: [{ name: http, port: 80, targetPort: 3000 }]
|
||||
---
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: wrnexus
|
||||
spec:
|
||||
replicas: 2
|
||||
selector: { matchLabels: { app: wrnexus } }
|
||||
template:
|
||||
metadata: { labels: { app: wrnexus } }
|
||||
spec:
|
||||
containers:
|
||||
- name: app
|
||||
image: ghcr.io/OWNER/APP:latest
|
||||
ports: [{ containerPort: 3000 }]
|
||||
envFrom: [{ secretRef: { name: wrnexus-secrets } }]
|
||||
livenessProbe: { httpGet: { path: /healthz, port: 3000 }, initialDelaySeconds: 5 }
|
||||
readinessProbe: { httpGet: { path: /readyz, port: 3000 }, initialDelaySeconds: 5 }
|
||||
resources:
|
||||
requests: { cpu: 250m, memory: 256Mi }
|
||||
limits: { cpu: "1", memory: 512Mi }
|
||||
lifecycle: { preStop: { exec: { command: ["sh", "-c", "sleep 5"] } } }
|
||||
terminationGracePeriodSeconds: 30
|
||||
---
|
||||
apiVersion: batch/v1
|
||||
kind: Job
|
||||
metadata:
|
||||
name: wrnexus-migrate
|
||||
spec:
|
||||
template:
|
||||
spec:
|
||||
restartPolicy: Never
|
||||
containers:
|
||||
- name: migrate
|
||||
image: ghcr.io/OWNER/APP:latest
|
||||
command: ["bunx", "wrnexus", "db", "migrate", "--profile=production"]
|
||||
envFrom: [{ secretRef: { name: wrnexus-secrets } }]
|
||||
@@ -0,0 +1,6 @@
|
||||
server {
|
||||
listen 80;
|
||||
server_name example.com;
|
||||
location /assets/ { root /srv/wrnexus/dist/public; expires 1y; add_header Cache-Control "public, immutable"; }
|
||||
location / { proxy_pass http://127.0.0.1:3000; proxy_http_version 1.1; proxy_set_header Host $host; proxy_set_header X-Forwarded-Proto $scheme; proxy_set_header X-Request-ID $request_id; }
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
[Unit]
|
||||
Description=WRNexus application
|
||||
After=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
WorkingDirectory=/srv/wrnexus
|
||||
EnvironmentFile=/etc/wrnexus/wrnexus.env
|
||||
ExecStartPre=/usr/bin/bunx wrnexus db migrate --profile=production
|
||||
ExecStart=/usr/bin/bun dist/server.js
|
||||
Restart=on-failure
|
||||
RestartSec=5
|
||||
TimeoutStopSec=30
|
||||
User=wrnexus
|
||||
Group=wrnexus
|
||||
NoNewPrivileges=true
|
||||
PrivateTmp=true
|
||||
ProtectSystem=strict
|
||||
ReadWritePaths=/srv/wrnexus
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
@@ -0,0 +1,30 @@
|
||||
services:
|
||||
app:
|
||||
build: .
|
||||
ports:
|
||||
- "3000:3000"
|
||||
environment:
|
||||
NODE_ENV: production
|
||||
PORT: "3000"
|
||||
DATABASE_URL: postgres://wire:wire@db:5432/app
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
restart: unless-stopped
|
||||
|
||||
db:
|
||||
image: postgres:16-alpine
|
||||
environment:
|
||||
POSTGRES_USER: wire
|
||||
POSTGRES_PASSWORD: wire
|
||||
POSTGRES_DB: app
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U wire -d app"]
|
||||
interval: 3s
|
||||
timeout: 3s
|
||||
retries: 20
|
||||
volumes:
|
||||
- pgdata:/var/lib/postgresql/data
|
||||
|
||||
volumes:
|
||||
pgdata:
|
||||
@@ -0,0 +1,19 @@
|
||||
app = "wrnexus-app"
|
||||
primary_region = "bom"
|
||||
|
||||
[build]
|
||||
dockerfile = "Dockerfile"
|
||||
[env]
|
||||
PORT = "3000"
|
||||
[http_service]
|
||||
internal_port = 3000
|
||||
force_https = true
|
||||
auto_stop_machines = "stop"
|
||||
auto_start_machines = true
|
||||
min_machines_running = 1
|
||||
[[http_service.checks]]
|
||||
path = "/readyz"
|
||||
interval = "15s"
|
||||
timeout = "2s"
|
||||
[deploy]
|
||||
release_command = "bunx wrnexus db migrate --profile=production"
|
||||
@@ -0,0 +1,67 @@
|
||||
# API examples
|
||||
|
||||
## postWebhooksPayment
|
||||
|
||||
```bash
|
||||
curl -X POST "http://localhost:3000/api/webhooks/payment"
|
||||
```
|
||||
|
||||
## getUsersCsr
|
||||
|
||||
```bash
|
||||
curl -X GET "http://localhost:3000/api/users/csr"
|
||||
```
|
||||
|
||||
## getUsersSsr
|
||||
|
||||
```bash
|
||||
curl -X GET "http://localhost:3000/api/users/ssr"
|
||||
```
|
||||
|
||||
## postGraphqlExample
|
||||
|
||||
```bash
|
||||
curl -X POST "http://localhost:3000/api/graphql-example"
|
||||
```
|
||||
|
||||
## postTypedUser
|
||||
|
||||
```bash
|
||||
curl -X POST "http://localhost:3000/api/typed-user"
|
||||
```
|
||||
|
||||
## postLogout
|
||||
|
||||
```bash
|
||||
curl -X POST "http://localhost:3000/api/logout"
|
||||
```
|
||||
|
||||
## getHello
|
||||
|
||||
```bash
|
||||
curl -X GET "http://localhost:3000/api/hello"
|
||||
```
|
||||
|
||||
## postLogin
|
||||
|
||||
```bash
|
||||
curl -X POST "http://localhost:3000/api/login"
|
||||
```
|
||||
|
||||
## getEcho
|
||||
|
||||
```bash
|
||||
curl -X GET "http://localhost:3000/api/echo"
|
||||
```
|
||||
|
||||
## postEcho
|
||||
|
||||
```bash
|
||||
curl -X POST "http://localhost:3000/api/echo"
|
||||
```
|
||||
|
||||
## getMe
|
||||
|
||||
```bash
|
||||
curl -X GET "http://localhost:3000/api/me"
|
||||
```
|
||||
@@ -0,0 +1,79 @@
|
||||
<!doctype html><meta charset="utf-8" /><meta name="viewport" content="width=device-width" /><title>
|
||||
basic-app API</title
|
||||
><style>
|
||||
body {
|
||||
font: 16px system-ui;
|
||||
max-width: 960px;
|
||||
margin: auto;
|
||||
padding: 2rem;
|
||||
}
|
||||
code,
|
||||
pre {
|
||||
background: #f4f4f5;
|
||||
padding: 0.2rem 0.4rem;
|
||||
}
|
||||
article {
|
||||
border-bottom: 1px solid #ddd;
|
||||
padding: 1rem 0;
|
||||
}
|
||||
</style>
|
||||
<h1>basic-app API</h1>
|
||||
<p>OpenAPI 3.1 · 11 operations · <a href="openapi.json">specification</a></p>
|
||||
<article>
|
||||
<h2><code>POST</code> /api/webhooks/payment</h2>
|
||||
<p>Payment completed</p>
|
||||
<p>Sent after a payment reaches its settled state.</p>
|
||||
<p>Webhook event: <code>payment.completed</code> · signature: <code>x-payment-signature</code></p>
|
||||
<small>app/api/webhooks/payment.ts</small>
|
||||
</article>
|
||||
<article>
|
||||
<h2><code>GET</code> /api/users/csr</h2>
|
||||
<p>getUsersCsr</p>
|
||||
<small>app/api/users/csr.ts</small>
|
||||
</article>
|
||||
<article>
|
||||
<h2><code>GET</code> /api/users/ssr</h2>
|
||||
<p>getUsersSsr</p>
|
||||
<small>app/api/users/ssr.ts</small>
|
||||
</article>
|
||||
<article>
|
||||
<h2><code>POST</code> /api/graphql-example</h2>
|
||||
<p>postGraphqlExample</p>
|
||||
<small>app/api/graphql-example.ts</small>
|
||||
</article>
|
||||
<article>
|
||||
<h2><code>POST</code> /api/typed-user</h2>
|
||||
<p>postTypedUser</p>
|
||||
<p>Validate and echo a typed user payload.</p>
|
||||
<small>app/api/typed-user.ts</small>
|
||||
</article>
|
||||
<article>
|
||||
<h2><code>POST</code> /api/logout</h2>
|
||||
<p>postLogout</p>
|
||||
<small>app/api/logout.ts</small>
|
||||
</article>
|
||||
<article>
|
||||
<h2><code>GET</code> /api/hello</h2>
|
||||
<p>getHello</p>
|
||||
<small>app/api/hello.ts</small>
|
||||
</article>
|
||||
<article>
|
||||
<h2><code>POST</code> /api/login</h2>
|
||||
<p>postLogin</p>
|
||||
<small>app/api/login.ts</small>
|
||||
</article>
|
||||
<article>
|
||||
<h2><code>GET</code> /api/echo</h2>
|
||||
<p>getEcho</p>
|
||||
<small>app/api/echo.ts</small>
|
||||
</article>
|
||||
<article>
|
||||
<h2><code>POST</code> /api/echo</h2>
|
||||
<p>postEcho</p>
|
||||
<small>app/api/echo.ts</small>
|
||||
</article>
|
||||
<article>
|
||||
<h2><code>GET</code> /api/me</h2>
|
||||
<p>getMe</p>
|
||||
<small>app/api/me.ts</small>
|
||||
</article>
|
||||
@@ -0,0 +1,318 @@
|
||||
{
|
||||
"openapi": "3.1.0",
|
||||
"info": {
|
||||
"title": "basic-app API",
|
||||
"version": "0.8.0"
|
||||
},
|
||||
"paths": {
|
||||
"/api/webhooks/payment": {
|
||||
"post": {
|
||||
"operationId": "postWebhooksPayment",
|
||||
"summary": "Payment completed",
|
||||
"description": "Sent after a payment reaches its settled state.",
|
||||
"tags": ["API"],
|
||||
"parameters": [],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Successful response",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {}
|
||||
}
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "Invalid request"
|
||||
},
|
||||
"500": {
|
||||
"description": "Internal error"
|
||||
}
|
||||
},
|
||||
"x-wrnexus-source": "app/api/webhooks/payment.ts"
|
||||
}
|
||||
},
|
||||
"/api/users/csr": {
|
||||
"get": {
|
||||
"operationId": "getUsersCsr",
|
||||
"summary": "GET /api/users/csr",
|
||||
"tags": ["API"],
|
||||
"parameters": [],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Successful response",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {}
|
||||
}
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "Invalid request"
|
||||
},
|
||||
"500": {
|
||||
"description": "Internal error"
|
||||
}
|
||||
},
|
||||
"x-wrnexus-source": "app/api/users/csr.ts"
|
||||
}
|
||||
},
|
||||
"/api/users/ssr": {
|
||||
"get": {
|
||||
"operationId": "getUsersSsr",
|
||||
"summary": "GET /api/users/ssr",
|
||||
"tags": ["API"],
|
||||
"parameters": [],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Successful response",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {}
|
||||
}
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "Invalid request"
|
||||
},
|
||||
"500": {
|
||||
"description": "Internal error"
|
||||
}
|
||||
},
|
||||
"x-wrnexus-source": "app/api/users/ssr.ts"
|
||||
}
|
||||
},
|
||||
"/api/graphql-example": {
|
||||
"post": {
|
||||
"operationId": "postGraphqlExample",
|
||||
"summary": "POST /api/graphql-example",
|
||||
"tags": ["API"],
|
||||
"parameters": [],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Successful response",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {}
|
||||
}
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "Invalid request"
|
||||
},
|
||||
"500": {
|
||||
"description": "Internal error"
|
||||
}
|
||||
},
|
||||
"x-wrnexus-source": "app/api/graphql-example.ts"
|
||||
}
|
||||
},
|
||||
"/api/typed-user": {
|
||||
"post": {
|
||||
"operationId": "postTypedUser",
|
||||
"summary": "POST /api/typed-user",
|
||||
"description": "Validate and echo a typed user payload.",
|
||||
"tags": ["API"],
|
||||
"parameters": [],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Successful response",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {}
|
||||
}
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "Invalid request"
|
||||
},
|
||||
"500": {
|
||||
"description": "Internal error"
|
||||
}
|
||||
},
|
||||
"x-wrnexus-source": "app/api/typed-user.ts"
|
||||
}
|
||||
},
|
||||
"/api/logout": {
|
||||
"post": {
|
||||
"operationId": "postLogout",
|
||||
"summary": "POST /api/logout",
|
||||
"tags": ["API"],
|
||||
"parameters": [],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Successful response",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {}
|
||||
}
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "Invalid request"
|
||||
},
|
||||
"500": {
|
||||
"description": "Internal error"
|
||||
}
|
||||
},
|
||||
"x-wrnexus-source": "app/api/logout.ts"
|
||||
}
|
||||
},
|
||||
"/api/hello": {
|
||||
"get": {
|
||||
"operationId": "getHello",
|
||||
"summary": "GET /api/hello",
|
||||
"tags": ["API"],
|
||||
"parameters": [],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Successful response",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {}
|
||||
}
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "Invalid request"
|
||||
},
|
||||
"500": {
|
||||
"description": "Internal error"
|
||||
}
|
||||
},
|
||||
"x-wrnexus-source": "app/api/hello.ts"
|
||||
}
|
||||
},
|
||||
"/api/login": {
|
||||
"post": {
|
||||
"operationId": "postLogin",
|
||||
"summary": "POST /api/login",
|
||||
"tags": ["API"],
|
||||
"parameters": [],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Successful response",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {}
|
||||
}
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "Invalid request"
|
||||
},
|
||||
"500": {
|
||||
"description": "Internal error"
|
||||
}
|
||||
},
|
||||
"x-wrnexus-source": "app/api/login.ts"
|
||||
}
|
||||
},
|
||||
"/api/echo": {
|
||||
"get": {
|
||||
"operationId": "getEcho",
|
||||
"summary": "GET /api/echo",
|
||||
"tags": ["API"],
|
||||
"parameters": [],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Successful response",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {}
|
||||
}
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "Invalid request"
|
||||
},
|
||||
"500": {
|
||||
"description": "Internal error"
|
||||
}
|
||||
},
|
||||
"x-wrnexus-source": "app/api/echo.ts"
|
||||
},
|
||||
"post": {
|
||||
"operationId": "postEcho",
|
||||
"summary": "POST /api/echo",
|
||||
"tags": ["API"],
|
||||
"parameters": [],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Successful response",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {}
|
||||
}
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "Invalid request"
|
||||
},
|
||||
"500": {
|
||||
"description": "Internal error"
|
||||
}
|
||||
},
|
||||
"x-wrnexus-source": "app/api/echo.ts"
|
||||
}
|
||||
},
|
||||
"/api/me": {
|
||||
"get": {
|
||||
"operationId": "getMe",
|
||||
"summary": "GET /api/me",
|
||||
"tags": ["API"],
|
||||
"parameters": [],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Successful response",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {}
|
||||
}
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "Invalid request"
|
||||
},
|
||||
"500": {
|
||||
"description": "Internal error"
|
||||
}
|
||||
},
|
||||
"x-wrnexus-source": "app/api/me.ts"
|
||||
}
|
||||
}
|
||||
},
|
||||
"webhooks": {
|
||||
"payment.completed": {
|
||||
"post": {
|
||||
"summary": "Payment completed",
|
||||
"description": "Sent after a payment reaches its settled state.",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "x-payment-signature",
|
||||
"in": "header",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
],
|
||||
"requestBody": {
|
||||
"required": true,
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/PaymentCompleted"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Webhook accepted"
|
||||
}
|
||||
},
|
||||
"x-wrnexus-source": "app/api/webhooks/payment.ts"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
{
|
||||
"info": {
|
||||
"name": "basic-app API",
|
||||
"schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json"
|
||||
},
|
||||
"item": [
|
||||
{
|
||||
"name": "postWebhooksPayment",
|
||||
"request": {
|
||||
"method": "POST",
|
||||
"url": "{{baseUrl}}/api/webhooks/payment"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "getUsersCsr",
|
||||
"request": {
|
||||
"method": "GET",
|
||||
"url": "{{baseUrl}}/api/users/csr"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "getUsersSsr",
|
||||
"request": {
|
||||
"method": "GET",
|
||||
"url": "{{baseUrl}}/api/users/ssr"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "postGraphqlExample",
|
||||
"request": {
|
||||
"method": "POST",
|
||||
"url": "{{baseUrl}}/api/graphql-example"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "postTypedUser",
|
||||
"request": {
|
||||
"method": "POST",
|
||||
"url": "{{baseUrl}}/api/typed-user"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "postLogout",
|
||||
"request": {
|
||||
"method": "POST",
|
||||
"url": "{{baseUrl}}/api/logout"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "getHello",
|
||||
"request": {
|
||||
"method": "GET",
|
||||
"url": "{{baseUrl}}/api/hello"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "postLogin",
|
||||
"request": {
|
||||
"method": "POST",
|
||||
"url": "{{baseUrl}}/api/login"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "getEcho",
|
||||
"request": {
|
||||
"method": "GET",
|
||||
"url": "{{baseUrl}}/api/echo"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "postEcho",
|
||||
"request": {
|
||||
"method": "POST",
|
||||
"url": "{{baseUrl}}/api/echo"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "getMe",
|
||||
"request": {
|
||||
"method": "GET",
|
||||
"url": "{{baseUrl}}/api/me"
|
||||
}
|
||||
}
|
||||
],
|
||||
"variable": [
|
||||
{
|
||||
"key": "baseUrl",
|
||||
"value": "http://localhost:3000"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
package wrnexussdk
|
||||
|
||||
import ("bytes"; "encoding/json"; "fmt"; "net/http")
|
||||
type Client struct { BaseURL string; HTTP *http.Client }
|
||||
func (c *Client) Request(method, path string, body any) (map[string]any, error) { data,_:=json.Marshal(body); req,_:=http.NewRequest(method,c.BaseURL+path,bytes.NewReader(data)); req.Header.Set("content-type","application/json"); client:=c.HTTP;if client==nil{client=http.DefaultClient};res,err:=client.Do(req);if err!=nil{return nil,err};defer res.Body.Close();if res.StatusCode>=400{return nil,fmt.Errorf("API status %d",res.StatusCode)};var out map[string]any;err=json.NewDecoder(res.Body).Decode(&out);return out,err }
|
||||
@@ -0,0 +1,3 @@
|
||||
package dev.wrnexus.sdk;
|
||||
import java.net.URI; import java.net.http.*;
|
||||
public final class WrnexusApi { private final String baseUrl; private final HttpClient http = HttpClient.newHttpClient(); public WrnexusApi(String baseUrl){this.baseUrl=baseUrl;} public String request(String method,String path,String json)throws Exception{var request=HttpRequest.newBuilder(URI.create(baseUrl+path)).header("content-type","application/json").method(method,HttpRequest.BodyPublishers.ofString(json==null?"":json)).build();var response=http.send(request,HttpResponse.BodyHandlers.ofString());if(response.statusCode()>=400)throw new IllegalStateException("API status "+response.statusCode());return response.body();} }
|
||||
@@ -0,0 +1,58 @@
|
||||
const request = async (method, path, body, options = {}) => {
|
||||
const response = await globalThis.fetch((options.baseUrl || "") + path, {
|
||||
method,
|
||||
headers: { "content-type": "application/json", ...(options.headers || {}) },
|
||||
body: body === undefined ? undefined : JSON.stringify(body),
|
||||
});
|
||||
const value = await response.json();
|
||||
if (!response.ok)
|
||||
throw Object.assign(new Error(value?.error?.message || "API request failed"), {
|
||||
status: response.status,
|
||||
body: value,
|
||||
});
|
||||
return value.data ?? value;
|
||||
};
|
||||
export const postWebhooksPayment = (params = {}, body, options = {}) => {
|
||||
void params;
|
||||
return request("POST", `/api/webhooks/payment`, body, options);
|
||||
};
|
||||
export const getUsersCsr = (params = {}, body, options = {}) => {
|
||||
void params;
|
||||
return request("GET", `/api/users/csr`, body, options);
|
||||
};
|
||||
export const getUsersSsr = (params = {}, body, options = {}) => {
|
||||
void params;
|
||||
return request("GET", `/api/users/ssr`, body, options);
|
||||
};
|
||||
export const postGraphqlExample = (params = {}, body, options = {}) => {
|
||||
void params;
|
||||
return request("POST", `/api/graphql-example`, body, options);
|
||||
};
|
||||
export const postTypedUser = (params = {}, body, options = {}) => {
|
||||
void params;
|
||||
return request("POST", `/api/typed-user`, body, options);
|
||||
};
|
||||
export const postLogout = (params = {}, body, options = {}) => {
|
||||
void params;
|
||||
return request("POST", `/api/logout`, body, options);
|
||||
};
|
||||
export const getHello = (params = {}, body, options = {}) => {
|
||||
void params;
|
||||
return request("GET", `/api/hello`, body, options);
|
||||
};
|
||||
export const postLogin = (params = {}, body, options = {}) => {
|
||||
void params;
|
||||
return request("POST", `/api/login`, body, options);
|
||||
};
|
||||
export const getEcho = (params = {}, body, options = {}) => {
|
||||
void params;
|
||||
return request("GET", `/api/echo`, body, options);
|
||||
};
|
||||
export const postEcho = (params = {}, body, options = {}) => {
|
||||
void params;
|
||||
return request("POST", `/api/echo`, body, options);
|
||||
};
|
||||
export const getMe = (params = {}, body, options = {}) => {
|
||||
void params;
|
||||
return request("GET", `/api/me`, body, options);
|
||||
};
|
||||
@@ -0,0 +1,19 @@
|
||||
import json, urllib.request
|
||||
|
||||
class WrnexusApi:
|
||||
def __init__(self, base_url): self.base_url = base_url.rstrip('/')
|
||||
def request(self, method, path, body=None):
|
||||
data = None if body is None else json.dumps(body).encode()
|
||||
request = urllib.request.Request(self.base_url + path, data=data, method=method, headers={'content-type':'application/json'})
|
||||
with urllib.request.urlopen(request) as response: return json.load(response)
|
||||
def postWebhooksPayment(self, path, body=None): return self.request('POST', path, body)
|
||||
def getUsersCsr(self, path, body=None): return self.request('GET', path, body)
|
||||
def getUsersSsr(self, path, body=None): return self.request('GET', path, body)
|
||||
def postGraphqlExample(self, path, body=None): return self.request('POST', path, body)
|
||||
def postTypedUser(self, path, body=None): return self.request('POST', path, body)
|
||||
def postLogout(self, path, body=None): return self.request('POST', path, body)
|
||||
def getHello(self, path, body=None): return self.request('GET', path, body)
|
||||
def postLogin(self, path, body=None): return self.request('POST', path, body)
|
||||
def getEcho(self, path, body=None): return self.request('GET', path, body)
|
||||
def postEcho(self, path, body=None): return self.request('POST', path, body)
|
||||
def getMe(self, path, body=None): return self.request('GET', path, body)
|
||||
@@ -0,0 +1,109 @@
|
||||
export type RequestOptions = { baseUrl?: string; headers?: HeadersInit };
|
||||
type ApiEnvelope = { data?: unknown; error?: { message?: string } };
|
||||
const request = async (
|
||||
method: string,
|
||||
path: string,
|
||||
body: unknown,
|
||||
options: RequestOptions = {},
|
||||
) => {
|
||||
const response = await globalThis.fetch((options.baseUrl || "") + path, {
|
||||
method,
|
||||
headers: { "content-type": "application/json", ...(options.headers || {}) },
|
||||
body: body === undefined ? undefined : JSON.stringify(body),
|
||||
});
|
||||
const value: ApiEnvelope = await response.json();
|
||||
if (!response.ok)
|
||||
throw Object.assign(new Error(value?.error?.message || "API request failed"), {
|
||||
status: response.status,
|
||||
body: value,
|
||||
});
|
||||
return value.data ?? value;
|
||||
};
|
||||
export const postWebhooksPayment = (
|
||||
params: Record<string, string> = {},
|
||||
body: unknown,
|
||||
options: RequestOptions = {},
|
||||
) => {
|
||||
void params;
|
||||
return request("POST", `/api/webhooks/payment`, body, options);
|
||||
};
|
||||
export const getUsersCsr = (
|
||||
params: Record<string, string> = {},
|
||||
body: unknown,
|
||||
options: RequestOptions = {},
|
||||
) => {
|
||||
void params;
|
||||
return request("GET", `/api/users/csr`, body, options);
|
||||
};
|
||||
export const getUsersSsr = (
|
||||
params: Record<string, string> = {},
|
||||
body: unknown,
|
||||
options: RequestOptions = {},
|
||||
) => {
|
||||
void params;
|
||||
return request("GET", `/api/users/ssr`, body, options);
|
||||
};
|
||||
export const postGraphqlExample = (
|
||||
params: Record<string, string> = {},
|
||||
body: unknown,
|
||||
options: RequestOptions = {},
|
||||
) => {
|
||||
void params;
|
||||
return request("POST", `/api/graphql-example`, body, options);
|
||||
};
|
||||
export const postTypedUser = (
|
||||
params: Record<string, string> = {},
|
||||
body: unknown,
|
||||
options: RequestOptions = {},
|
||||
) => {
|
||||
void params;
|
||||
return request("POST", `/api/typed-user`, body, options);
|
||||
};
|
||||
export const postLogout = (
|
||||
params: Record<string, string> = {},
|
||||
body: unknown,
|
||||
options: RequestOptions = {},
|
||||
) => {
|
||||
void params;
|
||||
return request("POST", `/api/logout`, body, options);
|
||||
};
|
||||
export const getHello = (
|
||||
params: Record<string, string> = {},
|
||||
body: unknown,
|
||||
options: RequestOptions = {},
|
||||
) => {
|
||||
void params;
|
||||
return request("GET", `/api/hello`, body, options);
|
||||
};
|
||||
export const postLogin = (
|
||||
params: Record<string, string> = {},
|
||||
body: unknown,
|
||||
options: RequestOptions = {},
|
||||
) => {
|
||||
void params;
|
||||
return request("POST", `/api/login`, body, options);
|
||||
};
|
||||
export const getEcho = (
|
||||
params: Record<string, string> = {},
|
||||
body: unknown,
|
||||
options: RequestOptions = {},
|
||||
) => {
|
||||
void params;
|
||||
return request("GET", `/api/echo`, body, options);
|
||||
};
|
||||
export const postEcho = (
|
||||
params: Record<string, string> = {},
|
||||
body: unknown,
|
||||
options: RequestOptions = {},
|
||||
) => {
|
||||
void params;
|
||||
return request("POST", `/api/echo`, body, options);
|
||||
};
|
||||
export const getMe = (
|
||||
params: Record<string, string> = {},
|
||||
body: unknown,
|
||||
options: RequestOptions = {},
|
||||
) => {
|
||||
void params;
|
||||
return request("GET", `/api/me`, body, options);
|
||||
};
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "basic-app",
|
||||
"version": "0.1.0",
|
||||
"version": "0.8.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
@@ -22,13 +22,13 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@wrnexus/test": "workspace:*",
|
||||
"@eslint/js": "latest",
|
||||
"@eslint/js": "^10.0.1",
|
||||
"@iconify-json/lucide": "^1.2.118",
|
||||
"@iconify/tailwind4": "^1.2.3",
|
||||
"@tailwindcss/cli": "^4.0.0",
|
||||
"eslint": "latest",
|
||||
"eslint": "^10.8.0",
|
||||
"prettier": "^3.9.4",
|
||||
"tailwindcss": "^4.0.0",
|
||||
"typescript-eslint": "latest"
|
||||
"typescript-eslint": "^8.65.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
[build]
|
||||
builder = "DOCKERFILE"
|
||||
|
||||
[deploy]
|
||||
startCommand = "bun dist/server.js"
|
||||
healthcheckPath = "/readyz"
|
||||
restartPolicyType = "ON_FAILURE"
|
||||
preDeployCommand = ["bunx wrnexus db migrate --profile=production"]
|
||||
@@ -0,0 +1,11 @@
|
||||
services:
|
||||
- type: web
|
||||
name: wrnexus
|
||||
runtime: docker
|
||||
healthCheckPath: /readyz
|
||||
preDeployCommand: bunx wrnexus db migrate --profile=production
|
||||
envVars:
|
||||
- key: DATABASE_URL
|
||||
sync: false
|
||||
- key: SESSION_SECRET
|
||||
sync: false
|
||||
@@ -10,6 +10,8 @@ import type { AppConfig } from "@wrnexus/styles";
|
||||
* framework-level headers and optional CORS.
|
||||
*/
|
||||
const config: AppConfig = {
|
||||
frameworkBehaviour: 1,
|
||||
compatibilityDate: "2026-08-02",
|
||||
head: [
|
||||
// --- Use a CSS framework via CDN (uncomment one) ---
|
||||
// Bootstrap:
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "captcha-showcase",
|
||||
"version": "0.1.0",
|
||||
"version": "0.8.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "component-showcase",
|
||||
"version": "0.1.0",
|
||||
"version": "0.8.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
@@ -187,7 +187,7 @@ test("every declared public event is documented and visible in the playground",
|
||||
expect(source).toContain("data-playground-event-log");
|
||||
for (const event of component.events) {
|
||||
expect(source).toContain(`<code>@${event}</code>`);
|
||||
expect(source).toContain(`@${event}='console.log(event.detail)'`);
|
||||
expect(source).toContain(`@${event}='console.log(payload)'`);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
# WRNexus i18n showcase
|
||||
|
||||
A complete English, Hindi, and Marathi example covering SSR translations, translated attributes,
|
||||
interpolation, plural rules, numbers, INR currency, percentages, dates, relative time, lists,
|
||||
fallback chains, browser language negotiation, and cookie-persisted language selection.
|
||||
|
||||
```bash
|
||||
bun install
|
||||
bun run --cwd examples/i18n-showcase dev
|
||||
```
|
||||
|
||||
Open `http://localhost:3000`, change the language with the packaged `LanguageSwitcher`, and inspect
|
||||
the document `<html lang>` attribute and `wire-lang` cookie. The `/api/formats` endpoint demonstrates
|
||||
request-aware `Intl` formatting using the same selected locale.
|
||||
@@ -0,0 +1,22 @@
|
||||
import type { Context } from "@wrnexus/core";
|
||||
import { createLocaleFormatter, formatMessage, plural } from "@wrnexus/i18n";
|
||||
|
||||
export const GET = async (ctx: Context) => {
|
||||
const locale = ctx.lang || "en";
|
||||
const format = createLocaleFormatter(locale, "Asia/Kolkata");
|
||||
const count = 3;
|
||||
|
||||
return Response.json({
|
||||
locale,
|
||||
direction: ["ar", "fa", "he", "ur"].includes(locale.split("-")[0]!) ? "rtl" : "ltr",
|
||||
translated: ctx.t("api.greeting", { name: "Asha" }),
|
||||
interpolated: formatMessage(ctx.t("api.inbox"), { name: "Asha", count }, locale),
|
||||
plural: plural(count, { one: ctx.t("api.itemOne"), other: ctx.t("api.itemOther") }, locale),
|
||||
number: format.number(1_234_567.89),
|
||||
currency: format.currency(1_234.5, "INR"),
|
||||
percent: format.number(0.78, { style: "percent" }),
|
||||
date: format.date("2026-08-15T09:30:00+05:30", { dateStyle: "full" }),
|
||||
relativeTime: format.relative(-3, "day", { numeric: "auto" }),
|
||||
list: format.list(["Mumbai", "Pune", "Nagpur"], { style: "long", type: "conjunction" }),
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,14 @@
|
||||
layout Document {
|
||||
props {
|
||||
language: string = "en"
|
||||
}
|
||||
|
||||
view {
|
||||
<html>
|
||||
<head></head>
|
||||
<body>
|
||||
<div id="app"><slot /></div>
|
||||
</body>
|
||||
</html>
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
{
|
||||
"header": {
|
||||
"eyebrow": "WRNexus i18n",
|
||||
"title": "Internationalization showcase"
|
||||
},
|
||||
"hero": {
|
||||
"kicker": "Three languages, one page",
|
||||
"title": "Everything needed for an international product",
|
||||
"description": "The server chooses a language from the cookie or Accept-Language header and renders translated HTML."
|
||||
},
|
||||
"sections": { "content": "Translated content" },
|
||||
"switcher": {
|
||||
"label": "Language",
|
||||
"placeholder": "Choose language",
|
||||
"helper": "Saved securely in your language cookie",
|
||||
"variantsTitle": "Responsive switcher variants",
|
||||
"variantsDescription": "Every variant reads locales from configuration and the active language from the request cookie.",
|
||||
"compactTitle": "Compact",
|
||||
"segmentedTitle": "Segmented"
|
||||
},
|
||||
"cards": {
|
||||
"text": {
|
||||
"title": "Text translation",
|
||||
"body": "Headings, paragraphs, buttons, and labels are rendered on the server."
|
||||
},
|
||||
"attributes": {
|
||||
"title": "Attribute translation",
|
||||
"label": "Search label",
|
||||
"placeholder": "Search in English"
|
||||
},
|
||||
"interpolation": {
|
||||
"title": "Interpolation",
|
||||
"body": "Parameters such as a person's name are inserted safely.",
|
||||
"action": "Open the dynamic API"
|
||||
},
|
||||
"plural": {
|
||||
"title": "Plural rules",
|
||||
"body": "One item and many items follow locale-aware CLDR rules."
|
||||
},
|
||||
"numbers": { "title": "Numbers and currency" },
|
||||
"time": { "title": "Dates and relative time" },
|
||||
"list": { "title": "List formatting" },
|
||||
"fallback": {
|
||||
"title": "Fallback chains",
|
||||
"body": "Missing regional content falls back to the base language and then English."
|
||||
},
|
||||
"document": {
|
||||
"title": "Document language",
|
||||
"body": "The same cookie sets the server-rendered html lang and dir attributes."
|
||||
}
|
||||
},
|
||||
"samples": {
|
||||
"number": "1,234,567.89",
|
||||
"currency": "₹1,234.50",
|
||||
"percent": "78%",
|
||||
"date": "15 August 2026",
|
||||
"relative": "3 days ago",
|
||||
"list": "Mumbai, Pune, and Nagpur"
|
||||
},
|
||||
"api": {
|
||||
"title": "Try request-aware formatting",
|
||||
"description": "The JSON endpoint uses ctx.lang, translations, Intl formatters, interpolation, plurals, and lists.",
|
||||
"action": "View localized JSON",
|
||||
"greeting": "Hello, {name}!",
|
||||
"inbox": "{name}, you have {count, plural, one {# message} other {# messages}}.",
|
||||
"itemOne": "# item",
|
||||
"itemOther": "# items"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
{
|
||||
"header": {
|
||||
"eyebrow": "WRNexus अंतर्राष्ट्रीयकरण",
|
||||
"title": "बहुभाषी उदाहरण"
|
||||
},
|
||||
"hero": {
|
||||
"kicker": "तीन भाषाएँ, एक पृष्ठ",
|
||||
"title": "एक अंतर्राष्ट्रीय उत्पाद के लिए आवश्यक सब कुछ",
|
||||
"description": "सर्वर कुकी या Accept-Language हेडर से भाषा चुनता है और अनुवादित HTML प्रस्तुत करता है।"
|
||||
},
|
||||
"sections": { "content": "अनुवादित सामग्री" },
|
||||
"switcher": {
|
||||
"label": "भाषा",
|
||||
"placeholder": "भाषा चुनें",
|
||||
"helper": "आपकी भाषा कुकी में सुरक्षित रूप से सहेजा गया",
|
||||
"variantsTitle": "रेस्पॉन्सिव स्विचर प्रकार",
|
||||
"variantsDescription": "हर प्रकार कॉन्फ़िगरेशन से भाषाएँ और अनुरोध कुकी से सक्रिय भाषा पढ़ता है।",
|
||||
"compactTitle": "कॉम्पैक्ट",
|
||||
"segmentedTitle": "खंडित"
|
||||
},
|
||||
"cards": {
|
||||
"text": {
|
||||
"title": "पाठ अनुवाद",
|
||||
"body": "शीर्षक, अनुच्छेद, बटन और लेबल सर्वर पर प्रस्तुत होते हैं।"
|
||||
},
|
||||
"attributes": {
|
||||
"title": "एट्रिब्यूट अनुवाद",
|
||||
"label": "खोज लेबल",
|
||||
"placeholder": "हिन्दी में खोजें"
|
||||
},
|
||||
"interpolation": {
|
||||
"title": "इंटरपोलेशन",
|
||||
"body": "व्यक्ति के नाम जैसे पैरामीटर सुरक्षित रूप से जोड़े जाते हैं।",
|
||||
"action": "डायनेमिक API खोलें"
|
||||
},
|
||||
"plural": {
|
||||
"title": "बहुवचन नियम",
|
||||
"body": "एक और अनेक वस्तुएँ भाषा के CLDR नियमों का पालन करती हैं।"
|
||||
},
|
||||
"numbers": { "title": "संख्याएँ और मुद्रा" },
|
||||
"time": { "title": "तिथियाँ और सापेक्ष समय" },
|
||||
"list": { "title": "सूची स्वरूपण" },
|
||||
"fallback": {
|
||||
"title": "फॉलबैक श्रृंखला",
|
||||
"body": "अनुपलब्ध क्षेत्रीय सामग्री पहले मूल भाषा और फिर अंग्रेज़ी में मिलती है।"
|
||||
},
|
||||
"document": {
|
||||
"title": "दस्तावेज़ की भाषा",
|
||||
"body": "यही कुकी सर्वर द्वारा प्रस्तुत html lang और dir एट्रिब्यूट तय करती है।"
|
||||
}
|
||||
},
|
||||
"samples": {
|
||||
"number": "12,34,567.89",
|
||||
"currency": "₹1,234.50",
|
||||
"percent": "78%",
|
||||
"date": "15 अगस्त 2026",
|
||||
"relative": "3 दिन पहले",
|
||||
"list": "मुंबई, पुणे और नागपुर"
|
||||
},
|
||||
"api": {
|
||||
"title": "अनुरोध के अनुसार स्वरूपण आज़माएँ",
|
||||
"description": "JSON एंडपॉइंट ctx.lang, अनुवाद, Intl फॉर्मैटर, इंटरपोलेशन, बहुवचन और सूचियों का उपयोग करता है।",
|
||||
"action": "स्थानीयकृत JSON देखें",
|
||||
"greeting": "नमस्ते, {name}!",
|
||||
"inbox": "{name}, आपके पास {count, plural, one {# संदेश} other {# संदेश}} हैं।",
|
||||
"itemOne": "# वस्तु",
|
||||
"itemOther": "# वस्तुएँ"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
{
|
||||
"header": {
|
||||
"eyebrow": "WRNexus आंतरराष्ट्रीयीकरण",
|
||||
"title": "बहुभाषिक उदाहरण"
|
||||
},
|
||||
"hero": {
|
||||
"kicker": "तीन भाषा, एक पृष्ठ",
|
||||
"title": "आंतरराष्ट्रीय उत्पादनासाठी आवश्यक असलेले सर्व काही",
|
||||
"description": "सर्व्हर कुकी किंवा Accept-Language हेडरवरून भाषा निवडतो आणि भाषांतरित HTML प्रस्तुत करतो."
|
||||
},
|
||||
"sections": { "content": "भाषांतरित मजकूर" },
|
||||
"switcher": {
|
||||
"label": "भाषा",
|
||||
"placeholder": "भाषा निवडा",
|
||||
"helper": "तुमच्या भाषा कुकीमध्ये सुरक्षितपणे जतन केले",
|
||||
"variantsTitle": "प्रतिसादक्षम स्विचर प्रकार",
|
||||
"variantsDescription": "प्रत्येक प्रकार कॉन्फिगरेशनमधून भाषा आणि विनंती कुकीमधून सक्रिय भाषा घेतो.",
|
||||
"compactTitle": "संक्षिप्त",
|
||||
"segmentedTitle": "विभाजित"
|
||||
},
|
||||
"cards": {
|
||||
"text": {
|
||||
"title": "मजकूर भाषांतर",
|
||||
"body": "शीर्षके, परिच्छेद, बटणे आणि लेबले सर्व्हरवर प्रस्तुत होतात."
|
||||
},
|
||||
"attributes": {
|
||||
"title": "गुणधर्म भाषांतर",
|
||||
"label": "शोध लेबल",
|
||||
"placeholder": "मराठीत शोधा"
|
||||
},
|
||||
"interpolation": {
|
||||
"title": "इंटरपोलेशन",
|
||||
"body": "व्यक्तीच्या नावासारखे पॅरामीटर सुरक्षितपणे जोडले जातात.",
|
||||
"action": "डायनॅमिक API उघडा"
|
||||
},
|
||||
"plural": {
|
||||
"title": "अनेकवचन नियम",
|
||||
"body": "एक आणि अनेक वस्तू स्थानिक CLDR नियमांचे पालन करतात."
|
||||
},
|
||||
"numbers": { "title": "संख्या आणि चलन" },
|
||||
"time": { "title": "दिनांक आणि सापेक्ष वेळ" },
|
||||
"list": { "title": "यादी स्वरूपण" },
|
||||
"fallback": {
|
||||
"title": "फॉलबॅक साखळी",
|
||||
"body": "प्रादेशिक मजकूर उपलब्ध नसल्यास मूळ भाषा आणि नंतर इंग्रजी वापरली जाते."
|
||||
},
|
||||
"document": {
|
||||
"title": "दस्तावेजाची भाषा",
|
||||
"body": "हीच कुकी सर्व्हरने प्रस्तुत केलेले html lang आणि dir गुणधर्म ठरवते."
|
||||
}
|
||||
},
|
||||
"samples": {
|
||||
"number": "12,34,567.89",
|
||||
"currency": "₹1,234.50",
|
||||
"percent": "78%",
|
||||
"date": "15 ऑगस्ट 2026",
|
||||
"relative": "3 दिवसांपूर्वी",
|
||||
"list": "मुंबई, पुणे आणि नागपूर"
|
||||
},
|
||||
"api": {
|
||||
"title": "विनंतीनुसार स्वरूपण वापरून पहा",
|
||||
"description": "JSON एंडपॉइंट ctx.lang, भाषांतरे, Intl फॉर्मॅटर, इंटरपोलेशन, अनेकवचन आणि याद्या वापरतो.",
|
||||
"action": "स्थानिकीकरण केलेला JSON पहा",
|
||||
"greeting": "नमस्कार, {name}!",
|
||||
"inbox": "{name}, तुमच्याकडे {count, plural, one {# संदेश} other {# संदेश}} आहेत.",
|
||||
"itemOne": "# वस्तू",
|
||||
"itemOther": "# वस्तू"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import LanguageSwitcher from "@wrnexus/i18n/components/LanguageSwitcher.wrn"
|
||||
|
||||
page InternationalizationShowcase {
|
||||
seo {
|
||||
title = "Internationalization Showcase"
|
||||
description = "English, Hindi, and Marathi localization with WRNexusJS."
|
||||
}
|
||||
|
||||
view {
|
||||
<main class="min-h-screen bg-[var(--wire-color-bg)] text-[var(--wire-color-text)]">
|
||||
<header class="border-b border-[var(--wire-color-border)] bg-[var(--wire-color-surface)]">
|
||||
<div class="mx-auto flex max-w-6xl flex-wrap items-center justify-between gap-5 px-6 py-5">
|
||||
<div>
|
||||
<p class="m-0 text-xs font-semibold uppercase tracking-[0.2em] text-[var(--wire-color-primary)]" data-t="header.eyebrow">WRNexus i18n</p>
|
||||
<h1 class="m-0 mt-1 text-xl font-bold" data-t="header.title">Internationalization showcase</h1>
|
||||
</div>
|
||||
<LanguageSwitcher
|
||||
label="Language / भाषा"
|
||||
placeholder="Choose language"
|
||||
helperText="Saved in your language cookie"
|
||||
/>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="mx-auto grid max-w-6xl gap-8 px-6 py-10">
|
||||
<section class="rounded-3xl border border-[var(--wire-color-border)] bg-[var(--wire-color-surface)] p-8 shadow-sm">
|
||||
<p class="m-0 text-sm font-medium text-[var(--wire-color-primary)]" data-t="hero.kicker">Three languages, one page</p>
|
||||
<h2 class="mb-3 mt-2 text-4xl font-bold tracking-tight" data-t="hero.title">Everything needed for an international product</h2>
|
||||
<p class="m-0 max-w-3xl text-lg leading-8 text-[var(--wire-color-muted)]" data-t="hero.description">The server chooses a language from the cookie or Accept-Language header and renders translated HTML.</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2 class="mb-2 text-2xl font-bold" data-t="switcher.variantsTitle">Responsive switcher variants</h2>
|
||||
<p class="mb-5 mt-0 text-[var(--wire-color-muted)]" data-t="switcher.variantsDescription">Every variant reads locales from configuration and the active language from the request cookie.</p>
|
||||
<div class="grid items-start gap-5 rounded-3xl border border-[var(--wire-color-border)] bg-[var(--wire-color-surface)] p-6 md:grid-cols-2">
|
||||
<div class="grid gap-2"><strong data-t="switcher.compactTitle">Compact</strong><LanguageSwitcher variant="compact" showHelper="false" label="Language / भाषा" /></div>
|
||||
<div class="grid gap-2"><strong data-t="switcher.segmentedTitle">Segmented</strong><LanguageSwitcher variant="segmented" showHelper="false" label="Language / भाषा" /></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2 class="mb-4 text-2xl font-bold" data-t="sections.content">Translated content</h2>
|
||||
<div class="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||
<article class="demo-card"><span class="icon-[lucide--languages] demo-icon"></span><h3 data-t="cards.text.title">Text translation</h3><p data-t="cards.text.body">Headings, paragraphs, buttons, and labels are rendered on the server.</p></article>
|
||||
<article class="demo-card"><span class="icon-[lucide--text-cursor-input] demo-icon"></span><h3 data-t="cards.attributes.title">Attribute translation</h3><label class="mt-3 grid gap-1 text-sm"><span data-t="cards.attributes.label">Search label</span><input class="rounded-xl border border-[var(--wire-color-border)] bg-transparent px-3 py-2" t:placeholder="cards.attributes.placeholder" /></label></article>
|
||||
<article class="demo-card"><span class="icon-[lucide--message-square-more] demo-icon"></span><h3 data-t="cards.interpolation.title">Interpolation</h3><p data-t="cards.interpolation.body">Parameters such as a person's name are inserted safely.</p><a class="demo-link" href="/api/formats" data-t="cards.interpolation.action">Open the dynamic API</a></article>
|
||||
<article class="demo-card"><span class="icon-[lucide--copy-plus] demo-icon"></span><h3 data-t="cards.plural.title">Plural rules</h3><p data-t="cards.plural.body">One item and many items follow locale-aware CLDR rules.</p></article>
|
||||
<article class="demo-card"><span class="icon-[lucide--indian-rupee] demo-icon"></span><h3 data-t="cards.numbers.title">Numbers and currency</h3><p data-t="samples.number">12,34,567.89</p><p data-t="samples.currency">₹1,234.50</p><p data-t="samples.percent">78%</p></article>
|
||||
<article class="demo-card"><span class="icon-[lucide--calendar-days] demo-icon"></span><h3 data-t="cards.time.title">Dates and relative time</h3><p data-t="samples.date">15 August 2026</p><p data-t="samples.relative">3 days ago</p></article>
|
||||
<article class="demo-card"><span class="icon-[lucide--list] demo-icon"></span><h3 data-t="cards.list.title">List formatting</h3><p data-t="samples.list">Mumbai, Pune, and Nagpur</p></article>
|
||||
<article class="demo-card"><span class="icon-[lucide--route] demo-icon"></span><h3 data-t="cards.fallback.title">Fallback chains</h3><p data-t="cards.fallback.body">Missing regional content falls back to the base language and then English.</p></article>
|
||||
<article class="demo-card"><span class="icon-[lucide--file-code-2] demo-icon"></span><h3 data-t="cards.document.title">Document language</h3><p data-t="cards.document.body">The same cookie sets the server-rendered html lang and dir attributes.</p></article>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="rounded-3xl bg-[var(--wire-color-primary)] p-8 text-[var(--wire-color-primary-contrast)]">
|
||||
<h2 class="m-0 text-2xl font-bold" data-t="api.title">Try request-aware formatting</h2>
|
||||
<p class="mb-5 mt-2 opacity-85" data-t="api.description">The JSON endpoint uses ctx.lang, translations, Intl formatters, interpolation, plurals, and lists.</p>
|
||||
<a href="/api/formats" class="inline-flex rounded-xl bg-white px-4 py-2 font-semibold text-slate-900" data-t="api.action">View localized JSON</a>
|
||||
</section>
|
||||
</div>
|
||||
</main>
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
// AUTO-GENERATED by `wrnexus dev` - do not edit.
|
||||
// Typed routes support required, optional, and catch-all parameters.
|
||||
|
||||
export interface Routes {
|
||||
"/": Record<string, never>;
|
||||
}
|
||||
|
||||
export type RoutePath = keyof Routes;
|
||||
type RouteValue = string | readonly string[] | undefined;
|
||||
|
||||
function encodeRouteValue(value: RouteValue, catchAll: boolean): string {
|
||||
if (value === undefined) return "";
|
||||
const values = Array.isArray(value) ? value : catchAll ? String(value).split("/") : [String(value)];
|
||||
return values.map((part) => encodeURIComponent(part)).join("/");
|
||||
}
|
||||
|
||||
export function href<P extends RoutePath>(
|
||||
path: P,
|
||||
...args: keyof Routes[P] extends never
|
||||
? []
|
||||
: Record<string, never> extends Routes[P]
|
||||
? [params?: Routes[P]]
|
||||
: [params: Routes[P]]
|
||||
): string {
|
||||
const params = (args[0] ?? {}) as Record<string, RouteValue>;
|
||||
const output: string[] = [];
|
||||
for (const segment of String(path).split("/").filter(Boolean)) {
|
||||
let name: string | undefined;
|
||||
let optional = false;
|
||||
let catchAll = false;
|
||||
if (segment.startsWith("[[") && segment.endsWith("]]")) {
|
||||
optional = true;
|
||||
name = segment.slice(2, -2);
|
||||
} else if (segment.startsWith("[") && segment.endsWith("]")) {
|
||||
name = segment.slice(1, -1);
|
||||
if (name.endsWith("?")) {
|
||||
optional = true;
|
||||
name = name.slice(0, -1);
|
||||
}
|
||||
}
|
||||
if (!name) {
|
||||
output.push(segment);
|
||||
continue;
|
||||
}
|
||||
if (name.startsWith("...")) {
|
||||
catchAll = true;
|
||||
name = name.slice(3);
|
||||
}
|
||||
const value = params[name];
|
||||
if (value === undefined && optional) continue;
|
||||
if (value === undefined) throw new Error(`WRN-ROUTE-MISSING-PARAM: Missing route parameter '${name}'.`);
|
||||
output.push(encodeRouteValue(value, catchAll));
|
||||
}
|
||||
return "/" + output.filter(Boolean).join("/");
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
@import "tailwindcss";
|
||||
@plugin "@iconify/tailwind4";
|
||||
@source "../**/*.wrn";
|
||||
@source "../../../packages/i18n/components/*.wrn";
|
||||
|
||||
html {
|
||||
color-scheme: light dark;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: "Plus Jakarta Sans", "Noto Sans Devanagari", ui-sans-serif, system-ui, sans-serif;
|
||||
}
|
||||
|
||||
/* The server sets <html lang> from the language cookie, so each script can use
|
||||
a typography stack designed for its glyph shapes and metrics. */
|
||||
html:lang(hi) body,
|
||||
html:lang(mr) body {
|
||||
font-family: "Noto Sans Devanagari", "Nirmala UI", Mangal, sans-serif;
|
||||
}
|
||||
|
||||
html[dir="rtl"] body {
|
||||
text-align: start;
|
||||
}
|
||||
|
||||
.demo-card {
|
||||
display: flex;
|
||||
min-height: 12rem;
|
||||
flex-direction: column;
|
||||
border: 1px solid var(--wire-color-border);
|
||||
border-radius: var(--wire-radius-xl);
|
||||
background: var(--wire-color-surface);
|
||||
padding: 1.25rem;
|
||||
}
|
||||
|
||||
.demo-card h3 {
|
||||
margin: 0.75rem 0 0.35rem;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.demo-card p {
|
||||
margin: 0.2rem 0;
|
||||
color: var(--wire-color-muted);
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.demo-icon {
|
||||
width: 1.4rem;
|
||||
height: 1.4rem;
|
||||
color: var(--wire-color-primary);
|
||||
}
|
||||
|
||||
.demo-link {
|
||||
margin-top: auto;
|
||||
padding-top: 1rem;
|
||||
color: var(--wire-color-primary);
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"name": "i18n-showcase",
|
||||
"version": "0.8.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "bun run ../../packages/cli/src/index.ts dev .",
|
||||
"build": "bun run ../../packages/cli/src/index.ts build .",
|
||||
"test": "bun test test",
|
||||
"typecheck": "tsc --noEmit -p tsconfig.json",
|
||||
"check": "bun run typecheck && bun run test && bun run build"
|
||||
},
|
||||
"dependencies": {
|
||||
"@wrnexus/core": "workspace:*",
|
||||
"@wrnexus/i18n": "workspace:*",
|
||||
"@wrnexus/ui": "workspace:*"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@iconify-json/lucide": "^1.2.118",
|
||||
"@iconify/tailwind4": "^1.2.3",
|
||||
"@tailwindcss/cli": "^4.0.0",
|
||||
"tailwindcss": "^4.0.0",
|
||||
"typescript": "^5.9.2"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import { expect, test } from "bun:test";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { loadLocales, makeT, resolveI18n, resolveLang, translationCoverage } from "@wrnexus/i18n";
|
||||
|
||||
const localeDir = join(import.meta.dir, "../app/locales");
|
||||
const i18n = resolveI18n(loadLocales(localeDir, { strict: true }), {
|
||||
default: "en",
|
||||
locales: ["en", "hi", "mr"],
|
||||
strict: true,
|
||||
});
|
||||
|
||||
test("ships complete English, Hindi, and Marathi translations", () => {
|
||||
expect(i18n.langs).toEqual(["en", "hi", "mr"]);
|
||||
const coverage = translationCoverage(i18n);
|
||||
expect(coverage.en?.percentage).toBe(100);
|
||||
expect(coverage.hi?.percentage).toBe(100);
|
||||
expect(coverage.mr?.percentage).toBe(100);
|
||||
expect(makeT(i18n, "hi")("header.title")).toBe("बहुभाषी उदाहरण");
|
||||
expect(makeT(i18n, "mr")("header.title")).toBe("बहुभाषिक उदाहरण");
|
||||
});
|
||||
|
||||
test("resolves browser negotiation and persisted cookie preferences", () => {
|
||||
expect(resolveLang(i18n, undefined, "mr-IN,hi;q=0.8,en;q=0.5")).toBe("mr");
|
||||
expect(resolveLang(i18n, "hi", "mr;q=1")).toBe("hi");
|
||||
});
|
||||
|
||||
test("uses the packaged language switcher and document shell", () => {
|
||||
const page = readFileSync(join(import.meta.dir, "../app/pages/index.wrn"), "utf8");
|
||||
const document = readFileSync(join(import.meta.dir, "../app/layouts/document.wrn"), "utf8");
|
||||
expect(page).toContain(
|
||||
'import LanguageSwitcher from "@wrnexus/i18n/components/LanguageSwitcher.wrn"',
|
||||
);
|
||||
expect(page).toContain("<LanguageSwitcher");
|
||||
expect(page).not.toContain("locales=");
|
||||
expect(page).not.toContain("current=");
|
||||
expect(page).toContain('variant="compact"');
|
||||
expect(page).toContain('variant="segmented"');
|
||||
expect(page).toContain('t:placeholder="cards.attributes.placeholder"');
|
||||
expect(document).toContain("<html>");
|
||||
});
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"extends": "../../tsconfig.json",
|
||||
"compilerOptions": { "noEmit": true },
|
||||
"include": ["app/**/*.ts", "test/**/*.ts", "wrnexus.config.ts"]
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import type { AppConfig } from "@wrnexus/styles";
|
||||
|
||||
const config = {
|
||||
seo: {
|
||||
title: "WRNexus i18n Showcase",
|
||||
description: "English, Hindi, and Marathi internationalization examples.",
|
||||
robots: "noindex,nofollow",
|
||||
},
|
||||
theme: { palette: "violet", default: "light" },
|
||||
fonts: {
|
||||
google: [
|
||||
{ family: "Plus Jakarta Sans", weights: [400, 500, 600, 700] },
|
||||
{ family: "Noto Sans Devanagari", weights: [400, 500, 600, 700] },
|
||||
],
|
||||
display: "swap",
|
||||
sans: '"Plus Jakarta Sans", ui-sans-serif, system-ui, sans-serif',
|
||||
},
|
||||
// Keep localhost development deterministic. A service-worker navigation cache
|
||||
// is URL-based and cannot distinguish pages rendered from different cookies.
|
||||
pwa: { serviceWorker: false },
|
||||
i18n: {
|
||||
default: "en",
|
||||
locales: ["en", "hi", "mr"],
|
||||
labels: { en: "English", hi: "हिन्दी", mr: "मराठी" },
|
||||
fallbacks: { hi: ["en"], mr: ["en"] },
|
||||
cookie: { name: "wire-lang", path: "/", maxAge: 31_536_000, sameSite: "Lax" },
|
||||
strict: true,
|
||||
},
|
||||
styles: {
|
||||
entry: "app/styles/global.css",
|
||||
failureMode: "throw",
|
||||
process: async ({ entryPath, appRoot, mode }) => {
|
||||
if (!entryPath) throw new Error("The i18n showcase stylesheet was not resolved.");
|
||||
const args = ["@tailwindcss/cli", "-i", entryPath];
|
||||
if (mode === "production") args.push("--minify");
|
||||
return await Bun.$.cwd(appRoot)`bunx ${args}`.text();
|
||||
},
|
||||
},
|
||||
} satisfies AppConfig;
|
||||
|
||||
export default config;
|
||||
Reference in New Issue
Block a user