Initial Commit
This commit is contained in:
130
apps/demo/README.md
Normal file
130
apps/demo/README.md
Normal file
@@ -0,0 +1,130 @@
|
||||
# @suaveplan/demo
|
||||
|
||||
Unified Bun + Hono server with a React 19 SPA front-end and a JSON API
|
||||
in one process. Demonstrates locale-prefix routing across five base
|
||||
locales, `@suaveplan/api-plugin-auth`-driven Better-Auth authentication
|
||||
backed by SurrealDB, and React + Tailwind v4 styling.
|
||||
|
||||
## What's wired up
|
||||
|
||||
- **Server** — single Bun process running a Hono app on port `3300`
|
||||
(override with `PORT=…`). The Hono instance is the one owned by a
|
||||
`SuaveApp` from `@suaveplan/api`. `/api/health` is registered by
|
||||
`registerHealthRoutes`; `/api/auth/*` is mounted by
|
||||
`@suaveplan/api-plugin-auth` (9 OpenAPI routes + the Better-Auth
|
||||
catch-all); `/api/me` is a demo convenience that calls
|
||||
`authService.getSession(headers)`; everything else falls through to
|
||||
the Vite-built client bundle in `dist/client/`, with `index.html`
|
||||
fallback so the client router owns deep links.
|
||||
- **Auth** — the canonical genesis pipeline. The Hono app is assembled
|
||||
via `defineApp()` from `@suaveplan/api`, with `createAuthPlugin(...)`
|
||||
from `@suaveplan/api-plugin-auth` registered as a plugin. The auth
|
||||
plugin's 6-step `onInit` applies the bundled 17-table Better-Auth
|
||||
SurrealQL schema, constructs the real Better-Auth instance backed by
|
||||
`betterAuthSurrealAdapter` (from `@suaveplan/better-auth-surrealdb`,
|
||||
the Better-Auth v1.6 `DBAdapterInstance` factory), registers the
|
||||
typed `authService` on the host `ServiceRegistry`, mounts the 9
|
||||
OpenAPI routes plus the Better-Auth catch-all under `/api/auth/*`,
|
||||
and installs the global session-resolver middleware so every request
|
||||
gets a typed `Principal` on its Hono context. A small inline
|
||||
`demo-db-seed` plugin (in `src/server/auth.ts`) drops the live
|
||||
`SurrealAdapter` into `ServiceRegistry["db"]` during `onRegister` so
|
||||
the auth plugin can resolve it during `onInit`. Backing store is an
|
||||
**external SurrealDB** reached via `SURREAL_URL` (default
|
||||
`ws://localhost:8000`), namespace `SURREAL_NS` (default `demo`),
|
||||
database `SURREAL_DB` (default `demo`), credentials `SURREAL_USER` /
|
||||
`SURREAL_PASS` (default `root` / `root`).
|
||||
- **Client** — React 19 + Vite + `@tailwindcss/vite` for Tailwind v4.
|
||||
Routing via `react-router-dom@7` with a locale-prefix design. The
|
||||
app root composes `<ReactUiProvider>` (from `@suaveplan/react-ui`)
|
||||
around the entire tree, seeded with a singleton `I18nInstance`
|
||||
(`createI18n` from `@suaveplan/i18n`) and the chassis `lightTheme`
|
||||
plugin. `<ReactUiProvider>` internally wraps
|
||||
`<TranslationProvider>` → `<ThemeProvider>` → `<PackRegistryProvider>`
|
||||
and auto-registers the matching chassis locale bundle on mount /
|
||||
language change, so any react-ui primitive that calls
|
||||
`useTranslation(...)` finds a populated catalog. Form pages
|
||||
(`/auth/login`, `/auth/register`, `/auth/forgot-password`,
|
||||
`/user/settings`) compose `<Card>`, `<Field>`, `<FieldLabel>`,
|
||||
`<Input>`, `<Button>`, `<Alert>` from the chassis — no hand-rolled
|
||||
Tailwind on those primitives. Demo-specific copy continues to flow
|
||||
through a typed `LocaleProvider` so the page-level message catalogs
|
||||
stay co-located and statically checked; the i18n instance is driven
|
||||
to the matching BCP47 variant (`en` → `en-GB`, `zh` → `zh-CN`,
|
||||
`ar` → `ar-AE`, `cy` → `cy-GB`, `ru` → `ru-RU`) via
|
||||
`syncDemoLocale(base)` from a `useEffect`.
|
||||
|
||||
## Locale-prefix routing
|
||||
|
||||
Five base locales: `en`, `zh`, `ar`, `cy`, `ru`. Region variants
|
||||
(e.g. `en-GB`, `zh-CN`, `ar-EG`) parse case-insensitively and
|
||||
canonicalise to lowercase-base + UPPERCASE-region. Anything else
|
||||
renders the `NotFoundPage` with a language picker.
|
||||
|
||||
- `/` → `Navigate` to `/<detected>/home` (uses `navigator.languages`).
|
||||
- `/<locale>/home`
|
||||
- `/<locale>/about-us`
|
||||
- `/<locale>/contact-us`
|
||||
- `/<locale>/privacy-policy`
|
||||
- `/<locale>/terms-of-service`
|
||||
- `/<locale>/auth/login`
|
||||
- `/<locale>/auth/register`
|
||||
- `/<locale>/auth/forgot-password`
|
||||
- `/<locale>/auth/logout`
|
||||
- `/<locale>/user/control-panel` *(gated — `RequireAuth`)*
|
||||
- `/<locale>/user/settings` *(gated — `RequireAuth`)*
|
||||
- `/<locale>/admin/control-panel` *(gated — `RequireRole admin`)*
|
||||
- `/<locale>/admin/settings` *(gated — `RequireRole admin`)*
|
||||
|
||||
`ar` flips `<html dir="rtl">`. The `<LocaleSwitcher>` rewrites the URL
|
||||
in place when a user changes language.
|
||||
|
||||
## Running the demo
|
||||
|
||||
The demo requires a reachable SurrealDB instance. The simplest path:
|
||||
|
||||
```bash
|
||||
docker run -d --name demo-surreal -p 8000:8000 \
|
||||
surrealdb/surrealdb:latest start --user root --pass root --bind 0.0.0.0:8000
|
||||
```
|
||||
|
||||
Then, from the repo root:
|
||||
|
||||
```bash
|
||||
bun install # workspace deps
|
||||
bunx turbo build --filter=@suaveplan/demo # build api/auth deps + dist/client
|
||||
bun run --filter=@suaveplan/demo dev # boot the server on :3300
|
||||
```
|
||||
|
||||
Visit `http://localhost:3300` — it should redirect to `/en/home` (or
|
||||
your browser's preferred locale if it matches one of the five).
|
||||
|
||||
Smoke endpoints:
|
||||
|
||||
```bash
|
||||
curl http://localhost:3300/api/health
|
||||
curl http://localhost:3300/api/me
|
||||
curl http://localhost:3300/api/auth/get-session
|
||||
```
|
||||
|
||||
End-to-end sign-up roundtrip — produces a 200 with a Better-Auth
|
||||
session token + user record:
|
||||
|
||||
```bash
|
||||
curl -X POST http://localhost:3300/api/auth/sign-up/email \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"email":"alice@example.com","password":"hunter2","name":"Alice"}'
|
||||
```
|
||||
|
||||
Env overrides (all optional):
|
||||
|
||||
| Variable | Default |
|
||||
| ---------------------- | ------------------------- |
|
||||
| `PORT` | `3300` |
|
||||
| `SURREAL_URL` | `ws://localhost:8000` |
|
||||
| `SURREAL_NS` | `demo` |
|
||||
| `SURREAL_DB` | `demo` |
|
||||
| `SURREAL_USER` | `root` |
|
||||
| `SURREAL_PASS` | `root` |
|
||||
| `BETTER_AUTH_URL` | `http://localhost:3300` |
|
||||
| `BETTER_AUTH_SECRET` | demo placeholder — set in prod |
|
||||
86
apps/demo/e2e/smoke.test.ts
Normal file
86
apps/demo/e2e/smoke.test.ts
Normal file
@@ -0,0 +1,86 @@
|
||||
/**
|
||||
* Smoke test — boots the built app and asserts:
|
||||
*
|
||||
* 1. `/api/health` returns 200 JSON with `ok: true`.
|
||||
* 2. `/` redirects to `/<locale>/home`.
|
||||
* 3. Each public page (`/en/home`, `/en/about-us`,
|
||||
* `/en/contact-us`, `/en/privacy-policy`, `/en/terms-of-service`)
|
||||
* returns 200 with an HTML body containing the SPA root element.
|
||||
*
|
||||
* Run with `bun run e2e/smoke.test.ts`. Requires `bun run build`
|
||||
* to have produced `dist/client/index.html` first.
|
||||
*/
|
||||
import { spawn } from "node:child_process";
|
||||
import { setTimeout as delay } from "node:timers/promises";
|
||||
|
||||
const PORT = 3399;
|
||||
const BASE = `http://localhost:${PORT}`;
|
||||
|
||||
async function waitForReady(): Promise<void> {
|
||||
for (let i = 0; i < 30; i++) {
|
||||
try {
|
||||
const res = await fetch(`${BASE}/api/health`);
|
||||
if (res.ok) return;
|
||||
} catch {
|
||||
// not up yet
|
||||
}
|
||||
await delay(500);
|
||||
}
|
||||
throw new Error("server failed to start within 15s");
|
||||
}
|
||||
|
||||
function assert(condition: unknown, message: string): asserts condition {
|
||||
if (!condition) throw new Error(`SMOKE FAIL: ${message}`);
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const child = spawn("bun", ["run", "src/server/index.ts"], {
|
||||
env: { ...process.env, PORT: String(PORT), NODE_ENV: "test" },
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
});
|
||||
|
||||
try {
|
||||
await waitForReady();
|
||||
|
||||
const health = await (await fetch(`${BASE}/api/health`)).json();
|
||||
// `@suaveplan/api`'s `registerHealthRoutes` emits
|
||||
// `{ status, version, state, timestamp }` — accept either the new
|
||||
// shape (`status: "healthy"`) or the legacy `{ ok: true }` shape so
|
||||
// the smoke remains forward-compatible if the route is rewritten.
|
||||
const healthy = health.status === "healthy" || health.ok === true;
|
||||
assert(healthy, `health probe not healthy: ${JSON.stringify(health)}`);
|
||||
|
||||
const root = await fetch(`${BASE}/`, { redirect: "manual" });
|
||||
// Server sends the SPA shell, client-side router handles the redirect.
|
||||
// We accept either a 200 (shell) or a 3xx redirect.
|
||||
assert(root.status === 200 || (root.status >= 300 && root.status < 400),
|
||||
`/ expected 200 or 3xx, got ${root.status}`);
|
||||
|
||||
const paths = [
|
||||
"/en/home",
|
||||
"/en/about-us",
|
||||
"/en/contact-us",
|
||||
"/en/privacy-policy",
|
||||
"/en/terms-of-service",
|
||||
];
|
||||
for (const path of paths) {
|
||||
const res = await fetch(`${BASE}${path}`);
|
||||
assert(res.ok, `${path} returned ${res.status}`);
|
||||
const body = await res.text();
|
||||
assert(body.includes('id="root"'), `${path} body missing #root`);
|
||||
}
|
||||
|
||||
// Auth endpoint reachable.
|
||||
const session = await fetch(`${BASE}/api/auth/get-session`);
|
||||
assert(session.status === 200, `/api/auth/get-session status ${session.status}`);
|
||||
|
||||
console.log("SMOKE OK");
|
||||
} finally {
|
||||
child.kill("SIGTERM");
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error(err);
|
||||
process.exit(1);
|
||||
});
|
||||
12
apps/demo/index.html
Normal file
12
apps/demo/index.html
Normal file
@@ -0,0 +1,12 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Suaveplan Demo</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/client/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
37
apps/demo/package.json
Normal file
37
apps/demo/package.json
Normal file
@@ -0,0 +1,37 @@
|
||||
{
|
||||
"name": "@suaveplan/demo",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "bun run src/server/index.ts",
|
||||
"build": "vite build",
|
||||
"start": "bun run src/server/index.ts",
|
||||
"smoke": "bun run e2e/smoke.test.ts",
|
||||
"typecheck": "bunx tsc --noEmit -p tsconfig.json"
|
||||
},
|
||||
"dependencies": {
|
||||
"@suaveplan/api": "workspace:^",
|
||||
"@suaveplan/api-plugin": "workspace:^",
|
||||
"@suaveplan/api-plugin-auth": "workspace:^",
|
||||
"@suaveplan/better-auth-surrealdb": "workspace:^",
|
||||
"@suaveplan/i18n": "workspace:^",
|
||||
"@suaveplan/react-ui": "workspace:^",
|
||||
"@suaveplan/surrealdb": "workspace:^",
|
||||
"better-auth": "catalog:auth",
|
||||
"hono": "catalog:",
|
||||
"react": "catalog:react",
|
||||
"react-dom": "catalog:react",
|
||||
"react-router-dom": "catalog:react"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/vite": "catalog:build",
|
||||
"@types/node": "catalog:",
|
||||
"@types/react": "catalog:react",
|
||||
"@types/react-dom": "catalog:react",
|
||||
"@vitejs/plugin-react": "catalog:build",
|
||||
"tailwindcss": "catalog:build",
|
||||
"typescript": "catalog:",
|
||||
"vite": "catalog:build"
|
||||
}
|
||||
}
|
||||
139
apps/demo/src/client/app.tsx
Normal file
139
apps/demo/src/client/app.tsx
Normal file
@@ -0,0 +1,139 @@
|
||||
/**
|
||||
* Demo app shell.
|
||||
*
|
||||
* Top-level structure:
|
||||
*
|
||||
* - `BrowserRouter`
|
||||
* - `SessionProvider` — polls `/api/auth/get-session`
|
||||
* - `ReactUiProvider` — composes
|
||||
* `<TranslationProvider>` → `<ThemeProvider>` →
|
||||
* `<PackRegistryProvider>` for the entire app tree, seeded with
|
||||
* the demo's singleton `I18nInstance` and the chassis
|
||||
* `lightTheme` plugin from `@suaveplan/react-ui`.
|
||||
* - `Routes`
|
||||
* - `/` → redirect to `/<detected>/home`
|
||||
* - `/:locale/*` → LocaleProvider + Layout
|
||||
* - everything else → NotFoundPage in default-locale context
|
||||
*/
|
||||
import { lightTheme, ReactUiProvider } from "@suaveplan/react-ui";
|
||||
import React from "react";
|
||||
import {
|
||||
BrowserRouter,
|
||||
Navigate,
|
||||
Outlet,
|
||||
Route,
|
||||
Routes,
|
||||
} from "react-router-dom";
|
||||
import { Layout } from "./components/layout.js";
|
||||
import { getDemoI18n } from "./i18n/i18n-instance.js";
|
||||
import { LocaleProvider } from "./i18n/locale-context.js";
|
||||
import { DEFAULT_LOCALE, parseLocaleSegment } from "./i18n/locales.js";
|
||||
import { RequireAuth, RequireRole } from "./lib/guards.js";
|
||||
import { SessionProvider } from "./lib/session-context.js";
|
||||
import {
|
||||
AdminControlPanelPage,
|
||||
AdminSettingsPage,
|
||||
UserControlPanelPage,
|
||||
UserSettingsPage,
|
||||
} from "./pages/account-pages.js";
|
||||
import {
|
||||
ForgotPasswordPage,
|
||||
LoginPage,
|
||||
LogoutPage,
|
||||
RegisterPage,
|
||||
} from "./pages/auth-pages.js";
|
||||
import { NotFoundPage } from "./pages/not-found-page.js";
|
||||
import {
|
||||
AboutPage,
|
||||
ContactPage,
|
||||
HomePage,
|
||||
PrivacyPage,
|
||||
TermsPage,
|
||||
} from "./pages/public-pages.js";
|
||||
|
||||
function detectLocale(): string {
|
||||
if (typeof navigator === "undefined") return DEFAULT_LOCALE;
|
||||
for (const candidate of navigator.languages ?? [navigator.language]) {
|
||||
const parsed = parseLocaleSegment(candidate);
|
||||
if (parsed !== null) return parsed.base;
|
||||
}
|
||||
return DEFAULT_LOCALE;
|
||||
}
|
||||
|
||||
function RootRedirect(): React.JSX.Element {
|
||||
return <Navigate to={`/${detectLocale()}/home`} replace />;
|
||||
}
|
||||
|
||||
function LocalisedShell(): React.JSX.Element {
|
||||
return (
|
||||
<LocaleProvider>
|
||||
<Outlet />
|
||||
</LocaleProvider>
|
||||
);
|
||||
}
|
||||
|
||||
export function App(): React.JSX.Element {
|
||||
const i18n = getDemoI18n();
|
||||
return (
|
||||
<BrowserRouter>
|
||||
<SessionProvider>
|
||||
<ReactUiProvider
|
||||
i18n={i18n}
|
||||
themes={[lightTheme]}
|
||||
defaultTheme="light"
|
||||
>
|
||||
<Routes>
|
||||
<Route path="/" element={<RootRedirect />} />
|
||||
<Route path="/:locale" element={<LocalisedShell />}>
|
||||
<Route element={<Layout />}>
|
||||
<Route index element={<Navigate to="home" replace />} />
|
||||
<Route path="home" element={<HomePage />} />
|
||||
<Route path="about-us" element={<AboutPage />} />
|
||||
<Route path="contact-us" element={<ContactPage />} />
|
||||
<Route path="privacy-policy" element={<PrivacyPage />} />
|
||||
<Route path="terms-of-service" element={<TermsPage />} />
|
||||
<Route path="auth/login" element={<LoginPage />} />
|
||||
<Route path="auth/register" element={<RegisterPage />} />
|
||||
<Route path="auth/forgot-password" element={<ForgotPasswordPage />} />
|
||||
<Route path="auth/logout" element={<LogoutPage />} />
|
||||
<Route
|
||||
path="user/control-panel"
|
||||
element={
|
||||
<RequireAuth>
|
||||
<UserControlPanelPage />
|
||||
</RequireAuth>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="user/settings"
|
||||
element={
|
||||
<RequireAuth>
|
||||
<UserSettingsPage />
|
||||
</RequireAuth>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="admin/control-panel"
|
||||
element={
|
||||
<RequireRole role="admin">
|
||||
<AdminControlPanelPage />
|
||||
</RequireRole>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="admin/settings"
|
||||
element={
|
||||
<RequireRole role="admin">
|
||||
<AdminSettingsPage />
|
||||
</RequireRole>
|
||||
}
|
||||
/>
|
||||
<Route path="*" element={<NotFoundPage />} />
|
||||
</Route>
|
||||
</Route>
|
||||
</Routes>
|
||||
</ReactUiProvider>
|
||||
</SessionProvider>
|
||||
</BrowserRouter>
|
||||
);
|
||||
}
|
||||
89
apps/demo/src/client/components/layout.tsx
Normal file
89
apps/demo/src/client/components/layout.tsx
Normal file
@@ -0,0 +1,89 @@
|
||||
/**
|
||||
* Shared layout — top nav with locale picker + auth menu, content
|
||||
* area, footer. Renders an `<Outlet>` for nested routes.
|
||||
*/
|
||||
import React from "react";
|
||||
import { Link, NavLink, Outlet } from "react-router-dom";
|
||||
import { LOCALE_ENTRIES, useLocale } from "../i18n/locale-context.js";
|
||||
import type { BaseLocale } from "../i18n/locales.js";
|
||||
import { useSession } from "../lib/session-context.js";
|
||||
|
||||
function LocaleSwitcher(): React.JSX.Element {
|
||||
const { base, setLocale, availableLocales } = useLocale();
|
||||
return (
|
||||
<select
|
||||
aria-label="Language"
|
||||
value={base}
|
||||
onChange={(e) => setLocale(e.target.value as BaseLocale)}
|
||||
className="rounded-md border border-[color:var(--color-border)] bg-[color:var(--color-surface)] px-2 py-1 text-sm"
|
||||
data-testid="locale-switcher"
|
||||
>
|
||||
{availableLocales.map((loc) => (
|
||||
<option key={loc} value={loc}>
|
||||
{LOCALE_ENTRIES[loc].nativeName}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
);
|
||||
}
|
||||
|
||||
function NavItem({ to, label }: { readonly to: string; readonly label: string }): React.JSX.Element {
|
||||
return (
|
||||
<NavLink
|
||||
to={to}
|
||||
className={({ isActive }) =>
|
||||
`rounded-md px-3 py-2 text-sm font-medium transition ${
|
||||
isActive
|
||||
? "bg-[color:var(--color-accent)] text-[color:var(--color-accent-fg)]"
|
||||
: "hover:bg-[color:var(--color-surface)]"
|
||||
}`
|
||||
}
|
||||
>
|
||||
{label}
|
||||
</NavLink>
|
||||
);
|
||||
}
|
||||
|
||||
export function Layout(): React.JSX.Element {
|
||||
const { base, messages } = useLocale();
|
||||
const { session } = useSession();
|
||||
const p = (sub: string): string => `/${base}${sub}`;
|
||||
return (
|
||||
<div className="flex min-h-screen flex-col">
|
||||
<header className="border-b border-[color:var(--color-border)] bg-[color:var(--color-surface)]">
|
||||
<div className="mx-auto flex max-w-6xl flex-wrap items-center gap-4 px-4 py-3">
|
||||
<Link to={p("/home")} className="text-lg font-semibold no-underline">
|
||||
{messages.siteName}
|
||||
</Link>
|
||||
<nav className="flex flex-wrap items-center gap-1">
|
||||
<NavItem to={p("/home")} label={messages.nav.home} />
|
||||
<NavItem to={p("/about-us")} label={messages.nav.about} />
|
||||
<NavItem to={p("/contact-us")} label={messages.nav.contact} />
|
||||
<NavItem to={p("/privacy-policy")} label={messages.nav.privacy} />
|
||||
<NavItem to={p("/terms-of-service")} label={messages.nav.terms} />
|
||||
</nav>
|
||||
<div className="ms-auto flex items-center gap-2">
|
||||
<LocaleSwitcher />
|
||||
{session === null ? (
|
||||
<>
|
||||
<NavItem to={p("/auth/login")} label={messages.nav.login} />
|
||||
<NavItem to={p("/auth/register")} label={messages.nav.register} />
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<NavItem to={p("/user/control-panel")} label={messages.nav.userPanel} />
|
||||
<NavItem to={p("/auth/logout")} label={messages.nav.logout} />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
<main className="mx-auto w-full max-w-6xl flex-1 px-4 py-8">
|
||||
<Outlet />
|
||||
</main>
|
||||
<footer className="border-t border-[color:var(--color-border)] py-4 text-center text-xs text-[color:var(--color-muted)]">
|
||||
{messages.siteName} • {base}
|
||||
</footer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
29
apps/demo/src/client/components/page-shell.tsx
Normal file
29
apps/demo/src/client/components/page-shell.tsx
Normal file
@@ -0,0 +1,29 @@
|
||||
/**
|
||||
* Re-usable page shell — heading + lead paragraph block consumed by
|
||||
* every public-information page so titles, headings, and the lead
|
||||
* line are kept consistent.
|
||||
*/
|
||||
import React from "react";
|
||||
import { useDocumentTitle } from "../i18n/locale-context.js";
|
||||
|
||||
export interface PageShellProps {
|
||||
readonly title: string;
|
||||
readonly heading: string;
|
||||
readonly lead: string;
|
||||
readonly children?: React.ReactNode;
|
||||
}
|
||||
|
||||
export function PageShell({ title, heading, lead, children }: PageShellProps): React.JSX.Element {
|
||||
useDocumentTitle(title);
|
||||
return (
|
||||
<article className="mx-auto max-w-3xl">
|
||||
<h1 className="text-3xl font-semibold" data-testid="page-heading">
|
||||
{heading}
|
||||
</h1>
|
||||
<p className="mt-3 text-base leading-relaxed text-[color:var(--color-muted)]" data-testid="page-lead">
|
||||
{lead}
|
||||
</p>
|
||||
{children !== undefined ? <div className="mt-6">{children}</div> : null}
|
||||
</article>
|
||||
);
|
||||
}
|
||||
67
apps/demo/src/client/i18n/i18n-instance.ts
Normal file
67
apps/demo/src/client/i18n/i18n-instance.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
/**
|
||||
* Build the demo's `I18nInstance`.
|
||||
*
|
||||
* The instance is consumed by `<ReactUiProvider>` and any
|
||||
* `@suaveplan/react-ui` primitive that calls `useTranslation()` (e.g.
|
||||
* `<Button>` for icon-only-button fallback, `<Input>` for placeholder
|
||||
* resolution).
|
||||
*
|
||||
* Locale strategy: the URL carries the language subtag only (`/en/home`,
|
||||
* `/zh/home`, etc.). The chassis bundles in `@suaveplan/react-ui` are
|
||||
* keyed by full BCP47 — `en-GB`, `zh-CN`, `ar-AE`, `cy-GB`, `ru-RU`. We
|
||||
* therefore canonicalise each demo base locale to its closest BCP47
|
||||
* variant and use THAT as the i18n locale. The demo's typed `Messages`
|
||||
* catalog continues to be consumed via `useLocale()` keyed by base, so
|
||||
* the demo never depends on react-i18n's `t(key)` for its own copy.
|
||||
*
|
||||
* The chassis bundles themselves are auto-registered by
|
||||
* `<ReactUiProvider>` (its internal `ReactUiBundleLoader` effect imports
|
||||
* the matching JSON and merges it into the catalog on mount / language
|
||||
* change), so this module deliberately does NOT preload them.
|
||||
*/
|
||||
import { createI18n, type I18nInstance } from "@suaveplan/i18n/plugin";
|
||||
import type { BaseLocale } from "./locales.js";
|
||||
|
||||
export type DemoBcp47 = "en-GB" | "zh-CN" | "ar-AE" | "cy-GB" | "ru-RU";
|
||||
|
||||
/** Map URL base locale -> canonical BCP47 variant fed to the i18n core. */
|
||||
export const BASE_TO_BCP47: Readonly<Record<BaseLocale, DemoBcp47>> = {
|
||||
en: "en-GB",
|
||||
zh: "zh-CN",
|
||||
ar: "ar-AE",
|
||||
cy: "cy-GB",
|
||||
ru: "ru-RU",
|
||||
};
|
||||
|
||||
/**
|
||||
* Singleton i18n instance for the demo. Created lazily so the i18n core
|
||||
* (which is module-side-effect free) does not run during static module
|
||||
* graph evaluation in tests or SSR snapshots.
|
||||
*/
|
||||
let cached: I18nInstance | undefined;
|
||||
|
||||
export function getDemoI18n(): I18nInstance {
|
||||
if (cached !== undefined) return cached;
|
||||
// Use the default `string` generic so the cached singleton matches
|
||||
// `<ReactUiProvider>`'s `i18n: I18nInstance` (without the
|
||||
// narrowed locale generic).
|
||||
const instance: I18nInstance = createI18n({
|
||||
defaultLocale: BASE_TO_BCP47.en as string,
|
||||
fallbackLocale: BASE_TO_BCP47.en as string,
|
||||
});
|
||||
cached = instance;
|
||||
return instance;
|
||||
}
|
||||
|
||||
/**
|
||||
* Drive the i18n instance to the BCP47 variant matching the URL base.
|
||||
* Safe to call from a `useEffect` — `changeLanguage(...)` short-circuits
|
||||
* when the locale already matches.
|
||||
*/
|
||||
export async function syncDemoLocale(base: BaseLocale): Promise<void> {
|
||||
const i18n = getDemoI18n();
|
||||
const target = BASE_TO_BCP47[base];
|
||||
if (i18n.locale !== target) {
|
||||
await i18n.changeLanguage(target);
|
||||
}
|
||||
}
|
||||
113
apps/demo/src/client/i18n/locale-context.tsx
Normal file
113
apps/demo/src/client/i18n/locale-context.tsx
Normal file
@@ -0,0 +1,113 @@
|
||||
/**
|
||||
* Typed locale context for the demo app.
|
||||
*
|
||||
* Holds the resolved `BaseLocale`, the canonical BCP47 tag (e.g. `en`,
|
||||
* `en-GB`, `zh-CN`), the active `Messages` catalog, and a `setLocale`
|
||||
* setter that rewrites the URL prefix and updates `<html lang>` +
|
||||
* `<html dir>` so RTL flips correctly for Arabic.
|
||||
*
|
||||
* Kept deliberately small — full @suaveplan/i18n integration (with
|
||||
* backends, formatters, plugin registry) is overkill for a five-page
|
||||
* demo. The provider shape mirrors react-i18n's `useLocale` /
|
||||
* `useI18n` so a future swap is mechanical.
|
||||
*/
|
||||
import React, {
|
||||
createContext,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useMemo,
|
||||
} from "react";
|
||||
import { useLocation, useNavigate, useParams } from "react-router-dom";
|
||||
import { syncDemoLocale } from "./i18n-instance.js";
|
||||
import {
|
||||
BASE_LOCALES,
|
||||
type BaseLocale,
|
||||
DEFAULT_LOCALE,
|
||||
LOCALE_ENTRIES,
|
||||
parseLocaleSegment,
|
||||
RTL_BASE_LOCALES,
|
||||
} from "./locales.js";
|
||||
import { CATALOGS, type Messages } from "./messages.js";
|
||||
|
||||
export interface LocaleContextValue {
|
||||
readonly base: BaseLocale;
|
||||
readonly canonical: string;
|
||||
readonly direction: "ltr" | "rtl";
|
||||
readonly messages: Messages;
|
||||
readonly setLocale: (next: BaseLocale | string) => void;
|
||||
readonly availableLocales: readonly BaseLocale[];
|
||||
}
|
||||
|
||||
const LocaleContext = createContext<LocaleContextValue | null>(null);
|
||||
|
||||
export function useLocale(): LocaleContextValue {
|
||||
const ctx = useContext(LocaleContext);
|
||||
if (ctx === null) {
|
||||
throw new Error(
|
||||
"useLocale must be used inside <LocaleProvider> — wrap your routes."
|
||||
);
|
||||
}
|
||||
return ctx;
|
||||
}
|
||||
|
||||
export function useMessages(): Messages {
|
||||
return useLocale().messages;
|
||||
}
|
||||
|
||||
export interface LocaleProviderProps {
|
||||
readonly children: React.ReactNode;
|
||||
}
|
||||
|
||||
export function LocaleProvider({ children }: LocaleProviderProps): React.JSX.Element {
|
||||
const params = useParams<{ locale?: string }>();
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
|
||||
const parsed = parseLocaleSegment(params.locale);
|
||||
const base: BaseLocale = parsed?.base ?? DEFAULT_LOCALE;
|
||||
const canonical = parsed?.canonical ?? base;
|
||||
const direction = RTL_BASE_LOCALES.has(base) ? "rtl" : "ltr";
|
||||
const messages = CATALOGS[base];
|
||||
|
||||
useEffect(() => {
|
||||
document.documentElement.lang = canonical;
|
||||
document.documentElement.dir = direction;
|
||||
document.title = messages.home.title; // updated per page via useDocumentTitle
|
||||
// Drive the singleton I18nInstance to the BCP47 variant matching the
|
||||
// current URL base so any @suaveplan/react-ui chassis lookup sees a
|
||||
// catalog populated for the active locale.
|
||||
void syncDemoLocale(base);
|
||||
}, [base, canonical, direction, messages]);
|
||||
|
||||
const setLocale = useCallback(
|
||||
(next: BaseLocale | string) => {
|
||||
// Strip the existing locale segment then prepend the new one.
|
||||
const path = location.pathname.replace(/^\/[^/]+/, "");
|
||||
navigate(`/${next}${path === "" ? "/home" : path}${location.search}${location.hash}`);
|
||||
},
|
||||
[location.hash, location.pathname, location.search, navigate]
|
||||
);
|
||||
|
||||
const value = useMemo<LocaleContextValue>(
|
||||
() => ({
|
||||
base,
|
||||
canonical,
|
||||
direction,
|
||||
messages,
|
||||
setLocale,
|
||||
availableLocales: BASE_LOCALES,
|
||||
}),
|
||||
[base, canonical, direction, messages, setLocale]
|
||||
);
|
||||
|
||||
return <LocaleContext.Provider value={value}>{children}</LocaleContext.Provider>;
|
||||
}
|
||||
|
||||
export function useDocumentTitle(title: string): void {
|
||||
useEffect(() => {
|
||||
document.title = title;
|
||||
}, [title]);
|
||||
}
|
||||
|
||||
export { LOCALE_ENTRIES };
|
||||
49
apps/demo/src/client/i18n/locales.ts
Normal file
49
apps/demo/src/client/i18n/locales.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
/**
|
||||
* Locale registry for the demo app.
|
||||
*
|
||||
* Five canonical base locales — `en`, `zh`, `ar`, `cy`, `ru` — plus a
|
||||
* permissive `<base>-<region>` resolver so URLs like `/en-GB/home` or
|
||||
* `/zh-CN/home` collapse onto the base catalog. RTL is tracked on the
|
||||
* base code.
|
||||
*/
|
||||
export const BASE_LOCALES = ["en", "zh", "ar", "cy", "ru"] as const;
|
||||
export type BaseLocale = (typeof BASE_LOCALES)[number];
|
||||
|
||||
export const DEFAULT_LOCALE: BaseLocale = "en";
|
||||
|
||||
export const RTL_BASE_LOCALES: ReadonlySet<BaseLocale> = new Set(["ar"]);
|
||||
|
||||
export interface LocaleEntry {
|
||||
readonly base: BaseLocale;
|
||||
readonly name: string;
|
||||
readonly nativeName: string;
|
||||
readonly direction: "ltr" | "rtl";
|
||||
}
|
||||
|
||||
export const LOCALE_ENTRIES: Readonly<Record<BaseLocale, LocaleEntry>> = {
|
||||
en: { base: "en", name: "English", nativeName: "English", direction: "ltr" },
|
||||
zh: { base: "zh", name: "Chinese", nativeName: "中文", direction: "ltr" },
|
||||
ar: { base: "ar", name: "Arabic", nativeName: "العربية", direction: "rtl" },
|
||||
cy: { base: "cy", name: "Welsh", nativeName: "Cymraeg", direction: "ltr" },
|
||||
ru: { base: "ru", name: "Russian", nativeName: "Русский", direction: "ltr" },
|
||||
};
|
||||
|
||||
/**
|
||||
* Normalise a raw URL segment into a canonical `<base>` or
|
||||
* `<base>-<REGION>` BCP47 tag, or `null` if the base does not match
|
||||
* one of {@link BASE_LOCALES}.
|
||||
*/
|
||||
export function parseLocaleSegment(segment: string | undefined): {
|
||||
readonly base: BaseLocale;
|
||||
readonly canonical: string;
|
||||
} | null {
|
||||
if (!segment) return null;
|
||||
const [rawBase, rawRegion] = segment.toLowerCase().split("-");
|
||||
const base = (BASE_LOCALES as readonly string[]).includes(rawBase ?? "")
|
||||
? (rawBase as BaseLocale)
|
||||
: null;
|
||||
if (!base) return null;
|
||||
if (!rawRegion) return { base, canonical: base };
|
||||
const region = rawRegion.toUpperCase();
|
||||
return { base, canonical: `${base}-${region}` };
|
||||
}
|
||||
337
apps/demo/src/client/i18n/messages.ts
Normal file
337
apps/demo/src/client/i18n/messages.ts
Normal file
@@ -0,0 +1,337 @@
|
||||
/**
|
||||
* Static message catalogs for the demo app.
|
||||
*
|
||||
* `en` is fully translated. The other four locales (`zh`, `ar`, `cy`,
|
||||
* `ru`) ship with canonical translations for page titles, headings,
|
||||
* and the lead paragraph, and fall back to the English copy for
|
||||
* secondary microcopy. Every key has a non-empty value in every
|
||||
* locale so render-time fallback never silently picks up `en`.
|
||||
*/
|
||||
import type { BaseLocale } from "./locales.js";
|
||||
|
||||
export interface Messages {
|
||||
// Site chrome
|
||||
readonly siteName: string;
|
||||
readonly nav: {
|
||||
readonly home: string;
|
||||
readonly about: string;
|
||||
readonly contact: string;
|
||||
readonly privacy: string;
|
||||
readonly terms: string;
|
||||
readonly login: string;
|
||||
readonly register: string;
|
||||
readonly logout: string;
|
||||
readonly userPanel: string;
|
||||
readonly userSettings: string;
|
||||
readonly adminPanel: string;
|
||||
readonly adminSettings: string;
|
||||
};
|
||||
// Pages
|
||||
readonly home: { readonly title: string; readonly heading: string; readonly lead: string };
|
||||
readonly about: { readonly title: string; readonly heading: string; readonly lead: string };
|
||||
readonly contact: { readonly title: string; readonly heading: string; readonly lead: string };
|
||||
readonly privacy: { readonly title: string; readonly heading: string; readonly lead: string };
|
||||
readonly terms: { readonly title: string; readonly heading: string; readonly lead: string };
|
||||
readonly login: {
|
||||
readonly title: string;
|
||||
readonly heading: string;
|
||||
readonly lead: string;
|
||||
readonly emailLabel: string;
|
||||
readonly passwordLabel: string;
|
||||
readonly submit: string;
|
||||
readonly linkToRegister: string;
|
||||
readonly linkToForgot: string;
|
||||
};
|
||||
readonly register: {
|
||||
readonly title: string;
|
||||
readonly heading: string;
|
||||
readonly lead: string;
|
||||
readonly emailLabel: string;
|
||||
readonly passwordLabel: string;
|
||||
readonly confirmLabel: string;
|
||||
readonly submit: string;
|
||||
readonly linkToLogin: string;
|
||||
};
|
||||
readonly forgot: {
|
||||
readonly title: string;
|
||||
readonly heading: string;
|
||||
readonly lead: string;
|
||||
readonly emailLabel: string;
|
||||
readonly submit: string;
|
||||
readonly sentNotice: string;
|
||||
readonly linkToLogin: string;
|
||||
};
|
||||
readonly logout: {
|
||||
readonly title: string;
|
||||
readonly heading: string;
|
||||
readonly lead: string;
|
||||
};
|
||||
readonly userPanel: { readonly title: string; readonly heading: string; readonly lead: string };
|
||||
readonly userSettings: { readonly title: string; readonly heading: string; readonly lead: string };
|
||||
readonly adminPanel: { readonly title: string; readonly heading: string; readonly lead: string };
|
||||
readonly adminSettings: { readonly title: string; readonly heading: string; readonly lead: string };
|
||||
readonly notFound: { readonly title: string; readonly heading: string; readonly lead: string };
|
||||
readonly unauthorized: { readonly title: string; readonly heading: string; readonly lead: string };
|
||||
}
|
||||
|
||||
const en: Messages = {
|
||||
siteName: "Suaveplan Demo",
|
||||
nav: {
|
||||
home: "Home",
|
||||
about: "About us",
|
||||
contact: "Contact us",
|
||||
privacy: "Privacy policy",
|
||||
terms: "Terms of service",
|
||||
login: "Log in",
|
||||
register: "Register",
|
||||
logout: "Log out",
|
||||
userPanel: "User panel",
|
||||
userSettings: "User settings",
|
||||
adminPanel: "Admin panel",
|
||||
adminSettings: "Admin settings",
|
||||
},
|
||||
home: {
|
||||
title: "Suaveplan Demo — Home",
|
||||
heading: "Welcome to the Suaveplan Demo",
|
||||
lead: "A unified Bun + Hono server with React-rendered pages and a JSON API in one process, wired up with @suaveplan/react-ui and @suaveplan/react-i18n.",
|
||||
},
|
||||
about: {
|
||||
title: "About us",
|
||||
heading: "About Suaveplan",
|
||||
lead: "Suaveplan is a bottom-up rebuild of an enterprise platform, packaged as a Turborepo monorepo of focused libraries.",
|
||||
},
|
||||
contact: {
|
||||
title: "Contact us",
|
||||
heading: "Get in touch",
|
||||
lead: "Drop us a line through any of the channels listed below. We read every message.",
|
||||
},
|
||||
privacy: {
|
||||
title: "Privacy policy",
|
||||
heading: "Privacy policy",
|
||||
lead: "This demo collects no personal data beyond what the better-auth memory adapter holds for the lifetime of the process.",
|
||||
},
|
||||
terms: {
|
||||
title: "Terms of service",
|
||||
heading: "Terms of service",
|
||||
lead: "This is a demo application provided as-is for illustrative purposes; no warranty is offered or implied.",
|
||||
},
|
||||
login: {
|
||||
title: "Log in",
|
||||
heading: "Log in to your account",
|
||||
lead: "Sign in with your email address and password.",
|
||||
emailLabel: "Email",
|
||||
passwordLabel: "Password",
|
||||
submit: "Log in",
|
||||
linkToRegister: "Need an account? Register",
|
||||
linkToForgot: "Forgot your password?",
|
||||
},
|
||||
register: {
|
||||
title: "Create an account",
|
||||
heading: "Create your account",
|
||||
lead: "Sign up with your email address and a password of at least 6 characters.",
|
||||
emailLabel: "Email",
|
||||
passwordLabel: "Password",
|
||||
confirmLabel: "Confirm password",
|
||||
submit: "Create account",
|
||||
linkToLogin: "Already have an account? Log in",
|
||||
},
|
||||
forgot: {
|
||||
title: "Forgot password",
|
||||
heading: "Reset your password",
|
||||
lead: "Enter your email and we'll send you a password-reset link.",
|
||||
emailLabel: "Email",
|
||||
submit: "Send reset link",
|
||||
sentNotice: "If an account exists for that email, a reset link is on its way.",
|
||||
linkToLogin: "Back to log in",
|
||||
},
|
||||
logout: {
|
||||
title: "Logging out",
|
||||
heading: "Logging you out…",
|
||||
lead: "Clearing your session — you'll be redirected home in a moment.",
|
||||
},
|
||||
userPanel: {
|
||||
title: "User control panel",
|
||||
heading: "Control panel",
|
||||
lead: "An overview of your account, sessions, and recent activity.",
|
||||
},
|
||||
userSettings: {
|
||||
title: "User settings",
|
||||
heading: "Settings",
|
||||
lead: "Manage your profile, locale preference, and security options.",
|
||||
},
|
||||
adminPanel: {
|
||||
title: "Admin control panel",
|
||||
heading: "Admin control panel",
|
||||
lead: "Administrative dashboard — restricted to users with the admin role.",
|
||||
},
|
||||
adminSettings: {
|
||||
title: "Admin settings",
|
||||
heading: "Admin settings",
|
||||
lead: "Configure global application settings — restricted to admins.",
|
||||
},
|
||||
notFound: {
|
||||
title: "Not found",
|
||||
heading: "404 — page not found",
|
||||
lead: "The page you asked for does not exist. Try one of the languages below or return home.",
|
||||
},
|
||||
unauthorized: {
|
||||
title: "Sign in required",
|
||||
heading: "Sign in required",
|
||||
lead: "You need to be signed in to view this page.",
|
||||
},
|
||||
};
|
||||
|
||||
// Lightweight translations: page-title, heading, lead per locale.
|
||||
// Secondary microcopy falls through to English so the demo always
|
||||
// renders intelligibly while making the per-locale shape obvious.
|
||||
const zh: Messages = {
|
||||
...en,
|
||||
nav: {
|
||||
...en.nav,
|
||||
home: "首页",
|
||||
about: "关于我们",
|
||||
contact: "联系我们",
|
||||
privacy: "隐私政策",
|
||||
terms: "服务条款",
|
||||
login: "登录",
|
||||
register: "注册",
|
||||
logout: "退出",
|
||||
userPanel: "用户面板",
|
||||
userSettings: "用户设置",
|
||||
adminPanel: "管理面板",
|
||||
adminSettings: "管理设置",
|
||||
},
|
||||
siteName: "Suaveplan 演示",
|
||||
home: {
|
||||
title: "Suaveplan 演示 — 首页",
|
||||
heading: "欢迎来到 Suaveplan 演示",
|
||||
lead: "在单一进程中提供统一的 Bun + Hono 服务器、React 渲染页面与 JSON API,使用 @suaveplan/react-ui 和 @suaveplan/react-i18n。",
|
||||
},
|
||||
about: { title: "关于我们", heading: "关于 Suaveplan", lead: en.about.lead },
|
||||
contact: { title: "联系我们", heading: "联系我们", lead: en.contact.lead },
|
||||
privacy: { title: "隐私政策", heading: "隐私政策", lead: en.privacy.lead },
|
||||
terms: { title: "服务条款", heading: "服务条款", lead: en.terms.lead },
|
||||
login: { ...en.login, title: "登录", heading: "登录您的帐户", lead: "使用电子邮件地址和密码登录。" },
|
||||
register: { ...en.register, title: "创建帐户", heading: "创建您的帐户", lead: "使用电子邮件和至少 6 个字符的密码注册。" },
|
||||
forgot: { ...en.forgot, title: "忘记密码", heading: "重置密码", lead: "输入您的电子邮件,我们将发送重置链接。" },
|
||||
logout: { title: "退出登录", heading: "正在退出登录...", lead: en.logout.lead },
|
||||
userPanel: { title: "用户控制面板", heading: "控制面板", lead: en.userPanel.lead },
|
||||
userSettings: { title: "用户设置", heading: "设置", lead: en.userSettings.lead },
|
||||
adminPanel: { title: "管理控制面板", heading: "管理控制面板", lead: en.adminPanel.lead },
|
||||
adminSettings: { title: "管理设置", heading: "管理设置", lead: en.adminSettings.lead },
|
||||
notFound: { title: "未找到", heading: "404 — 找不到页面", lead: en.notFound.lead },
|
||||
unauthorized: { title: "需要登录", heading: "需要登录", lead: en.unauthorized.lead },
|
||||
};
|
||||
|
||||
const ar: Messages = {
|
||||
...en,
|
||||
nav: {
|
||||
...en.nav,
|
||||
home: "الرئيسية",
|
||||
about: "من نحن",
|
||||
contact: "اتصل بنا",
|
||||
privacy: "سياسة الخصوصية",
|
||||
terms: "شروط الخدمة",
|
||||
login: "تسجيل الدخول",
|
||||
register: "تسجيل",
|
||||
logout: "تسجيل الخروج",
|
||||
userPanel: "لوحة المستخدم",
|
||||
userSettings: "إعدادات المستخدم",
|
||||
adminPanel: "لوحة الإدارة",
|
||||
adminSettings: "إعدادات الإدارة",
|
||||
},
|
||||
siteName: "عرض Suaveplan التوضيحي",
|
||||
home: { title: "Suaveplan — الرئيسية", heading: "مرحبًا بك في عرض Suaveplan التوضيحي", lead: en.home.lead },
|
||||
about: { title: "من نحن", heading: "حول Suaveplan", lead: en.about.lead },
|
||||
contact: { title: "اتصل بنا", heading: "تواصل معنا", lead: en.contact.lead },
|
||||
privacy: { title: "سياسة الخصوصية", heading: "سياسة الخصوصية", lead: en.privacy.lead },
|
||||
terms: { title: "شروط الخدمة", heading: "شروط الخدمة", lead: en.terms.lead },
|
||||
login: { ...en.login, title: "تسجيل الدخول", heading: "تسجيل الدخول إلى حسابك", lead: "سجّل الدخول باستخدام بريدك الإلكتروني وكلمة المرور." },
|
||||
register: { ...en.register, title: "إنشاء حساب", heading: "إنشاء حسابك", lead: "سجّل ببريدك الإلكتروني وكلمة مرور لا تقل عن 6 أحرف." },
|
||||
forgot: { ...en.forgot, title: "نسيت كلمة المرور", heading: "إعادة تعيين كلمة المرور", lead: en.forgot.lead },
|
||||
logout: { title: "جاري تسجيل الخروج", heading: "جاري تسجيل خروجك…", lead: en.logout.lead },
|
||||
userPanel: { title: "لوحة تحكم المستخدم", heading: "لوحة التحكم", lead: en.userPanel.lead },
|
||||
userSettings: { title: "إعدادات المستخدم", heading: "الإعدادات", lead: en.userSettings.lead },
|
||||
adminPanel: { title: "لوحة تحكم الإدارة", heading: "لوحة تحكم الإدارة", lead: en.adminPanel.lead },
|
||||
adminSettings: { title: "إعدادات الإدارة", heading: "إعدادات الإدارة", lead: en.adminSettings.lead },
|
||||
notFound: { title: "غير موجود", heading: "404 — الصفحة غير موجودة", lead: en.notFound.lead },
|
||||
unauthorized: { title: "تسجيل الدخول مطلوب", heading: "تسجيل الدخول مطلوب", lead: en.unauthorized.lead },
|
||||
};
|
||||
|
||||
const cy: Messages = {
|
||||
...en,
|
||||
nav: {
|
||||
...en.nav,
|
||||
home: "Hafan",
|
||||
about: "Amdanom ni",
|
||||
contact: "Cysylltwch â ni",
|
||||
privacy: "Polisi preifatrwydd",
|
||||
terms: "Telerau gwasanaeth",
|
||||
login: "Mewngofnodi",
|
||||
register: "Cofrestru",
|
||||
logout: "Allgofnodi",
|
||||
userPanel: "Panel defnyddiwr",
|
||||
userSettings: "Gosodiadau defnyddiwr",
|
||||
adminPanel: "Panel gweinyddol",
|
||||
adminSettings: "Gosodiadau gweinyddol",
|
||||
},
|
||||
siteName: "Demo Suaveplan",
|
||||
home: { title: "Suaveplan — Hafan", heading: "Croeso i Demo Suaveplan", lead: en.home.lead },
|
||||
about: { title: "Amdanom ni", heading: "Am Suaveplan", lead: en.about.lead },
|
||||
contact: { title: "Cysylltwch â ni", heading: "Cysylltu â ni", lead: en.contact.lead },
|
||||
privacy: { title: "Polisi preifatrwydd", heading: "Polisi preifatrwydd", lead: en.privacy.lead },
|
||||
terms: { title: "Telerau gwasanaeth", heading: "Telerau gwasanaeth", lead: en.terms.lead },
|
||||
login: { ...en.login, title: "Mewngofnodi", heading: "Mewngofnodi i'ch cyfrif", lead: "Mewngofnodi gyda'ch e-bost a'ch cyfrinair." },
|
||||
register: { ...en.register, title: "Creu cyfrif", heading: "Creu eich cyfrif", lead: "Cofrestrwch gyda'ch e-bost a chyfrinair o leiaf 6 nod." },
|
||||
forgot: { ...en.forgot, title: "Anghofio cyfrinair", heading: "Ailosod eich cyfrinair", lead: en.forgot.lead },
|
||||
logout: { title: "Allgofnodi", heading: "Allgofnodi…", lead: en.logout.lead },
|
||||
userPanel: { title: "Panel rheoli defnyddiwr", heading: "Panel rheoli", lead: en.userPanel.lead },
|
||||
userSettings: { title: "Gosodiadau defnyddiwr", heading: "Gosodiadau", lead: en.userSettings.lead },
|
||||
adminPanel: { title: "Panel rheoli gweinyddol", heading: "Panel rheoli gweinyddol", lead: en.adminPanel.lead },
|
||||
adminSettings: { title: "Gosodiadau gweinyddol", heading: "Gosodiadau gweinyddol", lead: en.adminSettings.lead },
|
||||
notFound: { title: "Heb ddod o hyd", heading: "404 — tudalen heb ei ganfod", lead: en.notFound.lead },
|
||||
unauthorized: { title: "Mewngofnodi sy'n ofynnol", heading: "Mae angen mewngofnodi", lead: en.unauthorized.lead },
|
||||
};
|
||||
|
||||
const ru: Messages = {
|
||||
...en,
|
||||
nav: {
|
||||
...en.nav,
|
||||
home: "Главная",
|
||||
about: "О нас",
|
||||
contact: "Контакты",
|
||||
privacy: "Политика конфиденциальности",
|
||||
terms: "Условия обслуживания",
|
||||
login: "Войти",
|
||||
register: "Регистрация",
|
||||
logout: "Выйти",
|
||||
userPanel: "Панель пользователя",
|
||||
userSettings: "Настройки пользователя",
|
||||
adminPanel: "Панель администратора",
|
||||
adminSettings: "Настройки администратора",
|
||||
},
|
||||
siteName: "Демо Suaveplan",
|
||||
home: { title: "Suaveplan — Главная", heading: "Добро пожаловать в демо Suaveplan", lead: en.home.lead },
|
||||
about: { title: "О нас", heading: "О Suaveplan", lead: en.about.lead },
|
||||
contact: { title: "Контакты", heading: "Свяжитесь с нами", lead: en.contact.lead },
|
||||
privacy: { title: "Политика конфиденциальности", heading: "Политика конфиденциальности", lead: en.privacy.lead },
|
||||
terms: { title: "Условия обслуживания", heading: "Условия обслуживания", lead: en.terms.lead },
|
||||
login: { ...en.login, title: "Войти", heading: "Войти в учетную запись", lead: "Войдите, используя email и пароль." },
|
||||
register: { ...en.register, title: "Создать учетную запись", heading: "Создайте учетную запись", lead: "Зарегистрируйтесь с email и паролем не менее 6 символов." },
|
||||
forgot: { ...en.forgot, title: "Забыли пароль", heading: "Сбросить пароль", lead: en.forgot.lead },
|
||||
logout: { title: "Выход", heading: "Выход из системы…", lead: en.logout.lead },
|
||||
userPanel: { title: "Панель управления пользователя", heading: "Панель управления", lead: en.userPanel.lead },
|
||||
userSettings: { title: "Настройки пользователя", heading: "Настройки", lead: en.userSettings.lead },
|
||||
adminPanel: { title: "Панель управления администратора", heading: "Панель управления администратора", lead: en.adminPanel.lead },
|
||||
adminSettings: { title: "Настройки администратора", heading: "Настройки администратора", lead: en.adminSettings.lead },
|
||||
notFound: { title: "Не найдено", heading: "404 — страница не найдена", lead: en.notFound.lead },
|
||||
unauthorized: { title: "Требуется вход", heading: "Требуется вход", lead: en.unauthorized.lead },
|
||||
};
|
||||
|
||||
export const CATALOGS: Readonly<Record<BaseLocale, Messages>> = {
|
||||
en,
|
||||
zh,
|
||||
ar,
|
||||
cy,
|
||||
ru,
|
||||
};
|
||||
87
apps/demo/src/client/lib/auth-client.ts
Normal file
87
apps/demo/src/client/lib/auth-client.ts
Normal file
@@ -0,0 +1,87 @@
|
||||
/**
|
||||
* Tiny browser auth client that talks directly to the better-auth
|
||||
* HTTP endpoints mounted by the server at `/api/auth/*`. Keeps the
|
||||
* demo client free of `better-auth/client` so the React build stays
|
||||
* lean.
|
||||
*/
|
||||
export interface SessionUser {
|
||||
readonly id: string;
|
||||
readonly email: string;
|
||||
readonly name?: string | null;
|
||||
readonly role?: string | null;
|
||||
}
|
||||
|
||||
export interface Session {
|
||||
readonly user: SessionUser;
|
||||
readonly expiresAt?: string;
|
||||
}
|
||||
|
||||
export async function fetchSession(): Promise<Session | null> {
|
||||
const res = await fetch("/api/auth/get-session", {
|
||||
credentials: "include",
|
||||
headers: { accept: "application/json" },
|
||||
});
|
||||
if (!res.ok) return null;
|
||||
const body = (await res.json()) as { user?: SessionUser; session?: { expiresAt?: string } } | null;
|
||||
if (body === null || body.user === undefined) return null;
|
||||
return {
|
||||
user: body.user,
|
||||
...(body.session?.expiresAt !== undefined ? { expiresAt: body.session.expiresAt } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
export async function signInEmail(args: {
|
||||
readonly email: string;
|
||||
readonly password: string;
|
||||
}): Promise<{ ok: true } | { ok: false; error: string }> {
|
||||
const res = await fetch("/api/auth/sign-in/email", {
|
||||
method: "POST",
|
||||
credentials: "include",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify(args),
|
||||
});
|
||||
if (res.ok) return { ok: true };
|
||||
const body = (await res.json().catch(() => ({}))) as { message?: string };
|
||||
return { ok: false, error: body.message ?? `Sign-in failed (${res.status})` };
|
||||
}
|
||||
|
||||
export async function signUpEmail(args: {
|
||||
readonly email: string;
|
||||
readonly password: string;
|
||||
readonly name?: string;
|
||||
}): Promise<{ ok: true } | { ok: false; error: string }> {
|
||||
const res = await fetch("/api/auth/sign-up/email", {
|
||||
method: "POST",
|
||||
credentials: "include",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ ...args, name: args.name ?? args.email.split("@")[0] }),
|
||||
});
|
||||
if (res.ok) return { ok: true };
|
||||
const body = (await res.json().catch(() => ({}))) as { message?: string };
|
||||
return { ok: false, error: body.message ?? `Sign-up failed (${res.status})` };
|
||||
}
|
||||
|
||||
export async function signOut(): Promise<void> {
|
||||
await fetch("/api/auth/sign-out", {
|
||||
method: "POST",
|
||||
credentials: "include",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: "{}",
|
||||
}).catch(() => undefined);
|
||||
}
|
||||
|
||||
export async function forgetPassword(args: {
|
||||
readonly email: string;
|
||||
}): Promise<{ ok: true } | { ok: false; error: string }> {
|
||||
const res = await fetch("/api/auth/forget-password", {
|
||||
method: "POST",
|
||||
credentials: "include",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ ...args, redirectTo: "/auth/login" }),
|
||||
});
|
||||
if (res.ok) return { ok: true };
|
||||
// better-auth returns 200 even for unknown emails to avoid enumeration;
|
||||
// we still surface non-2xx for transport-level errors.
|
||||
const body = (await res.json().catch(() => ({}))) as { message?: string };
|
||||
return { ok: false, error: body.message ?? `Request failed (${res.status})` };
|
||||
}
|
||||
57
apps/demo/src/client/lib/guards.tsx
Normal file
57
apps/demo/src/client/lib/guards.tsx
Normal file
@@ -0,0 +1,57 @@
|
||||
/**
|
||||
* Route guards — wrap a child element with `<RequireAuth>` or
|
||||
* `<RequireRole role="admin">`. Both rely on `useSession()` so they
|
||||
* react to live sign-in / sign-out without a hard reload.
|
||||
*/
|
||||
import React from "react";
|
||||
import { Navigate, useLocation } from "react-router-dom";
|
||||
import { useLocale } from "../i18n/locale-context.js";
|
||||
import { useSession } from "./session-context.js";
|
||||
|
||||
export function RequireAuth({ children }: { readonly children: React.ReactNode }): React.JSX.Element {
|
||||
const { session, loading } = useSession();
|
||||
const { base } = useLocale();
|
||||
const location = useLocation();
|
||||
if (loading) {
|
||||
return <div className="p-8 text-sm text-muted">Loading session…</div>;
|
||||
}
|
||||
if (session === null) {
|
||||
return (
|
||||
<Navigate
|
||||
to={`/${base}/auth/login?return=${encodeURIComponent(location.pathname)}`}
|
||||
replace
|
||||
/>
|
||||
);
|
||||
}
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
export function RequireRole({
|
||||
role,
|
||||
children,
|
||||
}: {
|
||||
readonly role: string;
|
||||
readonly children: React.ReactNode;
|
||||
}): React.JSX.Element {
|
||||
const { session, loading } = useSession();
|
||||
const { base, messages } = useLocale();
|
||||
if (loading) {
|
||||
return <div className="p-8 text-sm text-muted">Loading session…</div>;
|
||||
}
|
||||
if (session === null) {
|
||||
return <Navigate to={`/${base}/auth/login`} replace />;
|
||||
}
|
||||
if (session.user.role !== role) {
|
||||
return (
|
||||
<main className="mx-auto max-w-2xl p-8">
|
||||
<h1 className="text-2xl font-semibold" data-testid="page-heading">
|
||||
{messages.unauthorized.heading}
|
||||
</h1>
|
||||
<p className="mt-2 text-sm text-muted">
|
||||
Required role: <code>{role}</code>
|
||||
</p>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
return <>{children}</>;
|
||||
}
|
||||
53
apps/demo/src/client/lib/session-context.tsx
Normal file
53
apps/demo/src/client/lib/session-context.tsx
Normal file
@@ -0,0 +1,53 @@
|
||||
/**
|
||||
* Session context — polls `/api/auth/get-session` on mount and exposes
|
||||
* `useSession()` and a `refresh()` to forms.
|
||||
*/
|
||||
import React, {
|
||||
createContext,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useState,
|
||||
} from "react";
|
||||
import { fetchSession, type Session } from "./auth-client.js";
|
||||
|
||||
export interface SessionContextValue {
|
||||
readonly session: Session | null;
|
||||
readonly loading: boolean;
|
||||
readonly refresh: () => Promise<void>;
|
||||
}
|
||||
|
||||
const SessionContext = createContext<SessionContextValue | null>(null);
|
||||
|
||||
export function useSession(): SessionContextValue {
|
||||
const ctx = useContext(SessionContext);
|
||||
if (ctx === null) throw new Error("useSession must be inside <SessionProvider>");
|
||||
return ctx;
|
||||
}
|
||||
|
||||
export function SessionProvider({ children }: { children: React.ReactNode }): React.JSX.Element {
|
||||
const [session, setSession] = useState<Session | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const next = await fetchSession();
|
||||
setSession(next);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void refresh();
|
||||
}, [refresh]);
|
||||
|
||||
const value = useMemo<SessionContextValue>(
|
||||
() => ({ session, loading, refresh }),
|
||||
[session, loading, refresh]
|
||||
);
|
||||
|
||||
return <SessionContext.Provider value={value}>{children}</SessionContext.Provider>;
|
||||
}
|
||||
13
apps/demo/src/client/main.tsx
Normal file
13
apps/demo/src/client/main.tsx
Normal file
@@ -0,0 +1,13 @@
|
||||
import { StrictMode } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import "./styles/globals.css";
|
||||
import { App } from "./app.js";
|
||||
|
||||
const root = document.getElementById("root");
|
||||
if (root === null) throw new Error("No #root element found");
|
||||
|
||||
createRoot(root).render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
</StrictMode>
|
||||
);
|
||||
143
apps/demo/src/client/pages/account-pages.tsx
Normal file
143
apps/demo/src/client/pages/account-pages.tsx
Normal file
@@ -0,0 +1,143 @@
|
||||
/**
|
||||
* User and admin gated pages. The auth gate is enforced by
|
||||
* `<RequireAuth>` / `<RequireRole>` in the route definitions; these
|
||||
* components only need to assume `useSession().session` is non-null
|
||||
* and render the placeholder dashboards.
|
||||
*
|
||||
* The session-card surface and the settings form are composed from
|
||||
* `@suaveplan/react-ui` primitives (`<Card>`, `<Field>`, `<Input>`,
|
||||
* `<FieldDescription>`). The preferred-locale picker keeps a native
|
||||
* `<select>` because the chassis `<Select>` is a Radix-portal popover
|
||||
* with overlay state we do not need for a placeholder form.
|
||||
*/
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
Field,
|
||||
FieldDescription,
|
||||
FieldGroup,
|
||||
FieldLabel,
|
||||
Input,
|
||||
} from "@suaveplan/react-ui";
|
||||
import React from "react";
|
||||
import { PageShell } from "../components/page-shell.js";
|
||||
import { useMessages } from "../i18n/locale-context.js";
|
||||
import { useSession } from "../lib/session-context.js";
|
||||
|
||||
function SessionCard(): React.JSX.Element {
|
||||
const { session } = useSession();
|
||||
if (session === null) return <></>;
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Session</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<dl className="grid grid-cols-1 gap-2 text-sm sm:grid-cols-2">
|
||||
<div>
|
||||
<dt className="font-medium">User id</dt>
|
||||
<dd className="font-mono text-xs">{session.user.id}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="font-medium">Email</dt>
|
||||
<dd>{session.user.email}</dd>
|
||||
</div>
|
||||
{session.user.name !== undefined && session.user.name !== null ? (
|
||||
<div>
|
||||
<dt className="font-medium">Name</dt>
|
||||
<dd>{session.user.name}</dd>
|
||||
</div>
|
||||
) : null}
|
||||
{session.user.role !== undefined && session.user.role !== null ? (
|
||||
<div>
|
||||
<dt className="font-medium">Role</dt>
|
||||
<dd>{session.user.role}</dd>
|
||||
</div>
|
||||
) : null}
|
||||
{session.expiresAt !== undefined ? (
|
||||
<div>
|
||||
<dt className="font-medium">Session expires</dt>
|
||||
<dd>{session.expiresAt}</dd>
|
||||
</div>
|
||||
) : null}
|
||||
</dl>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
export function UserControlPanelPage(): React.JSX.Element {
|
||||
const m = useMessages().userPanel;
|
||||
return (
|
||||
<PageShell title={m.title} heading={m.heading} lead={m.lead}>
|
||||
<SessionCard />
|
||||
</PageShell>
|
||||
);
|
||||
}
|
||||
|
||||
export function UserSettingsPage(): React.JSX.Element {
|
||||
const m = useMessages().userSettings;
|
||||
return (
|
||||
<PageShell title={m.title} heading={m.heading} lead={m.lead}>
|
||||
<Card>
|
||||
<CardContent>
|
||||
<form>
|
||||
<FieldGroup>
|
||||
<Field>
|
||||
<FieldLabel htmlFor="displayName">Display name</FieldLabel>
|
||||
<Input id="displayName" name="displayName" />
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel htmlFor="preferredLocale">Preferred locale</FieldLabel>
|
||||
{/*
|
||||
Native <select> is intentional: the chassis Select is a
|
||||
Radix-portal popover with overlay state we do not need
|
||||
for a placeholder form. Visual treatment matches the
|
||||
chassis Input via the shared input cva class names.
|
||||
*/}
|
||||
<select
|
||||
id="preferredLocale"
|
||||
name="preferredLocale"
|
||||
className="block w-full rounded-md border border-input bg-background px-3 py-2 text-sm"
|
||||
>
|
||||
<option value="en">English</option>
|
||||
<option value="zh">中文</option>
|
||||
<option value="ar">العربية</option>
|
||||
<option value="cy">Cymraeg</option>
|
||||
<option value="ru">Русский</option>
|
||||
</select>
|
||||
<FieldDescription>
|
||||
This is a placeholder — settings are not persisted in the demo.
|
||||
</FieldDescription>
|
||||
</Field>
|
||||
</FieldGroup>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</PageShell>
|
||||
);
|
||||
}
|
||||
|
||||
export function AdminControlPanelPage(): React.JSX.Element {
|
||||
const m = useMessages().adminPanel;
|
||||
return (
|
||||
<PageShell title={m.title} heading={m.heading} lead={m.lead}>
|
||||
<SessionCard />
|
||||
</PageShell>
|
||||
);
|
||||
}
|
||||
|
||||
export function AdminSettingsPage(): React.JSX.Element {
|
||||
const m = useMessages().adminSettings;
|
||||
return (
|
||||
<PageShell title={m.title} heading={m.heading} lead={m.lead}>
|
||||
<Card>
|
||||
<CardContent>
|
||||
<p className="text-sm">Admin-only configuration placeholder.</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</PageShell>
|
||||
);
|
||||
}
|
||||
283
apps/demo/src/client/pages/auth-pages.tsx
Normal file
283
apps/demo/src/client/pages/auth-pages.tsx
Normal file
@@ -0,0 +1,283 @@
|
||||
/**
|
||||
* Auth pages — Login, Register, ForgotPassword, Logout.
|
||||
*
|
||||
* Each posts to the better-auth HTTP endpoint mounted on the server
|
||||
* under `/api/auth/*` via the thin `auth-client.ts` shim. On success
|
||||
* the session context is refreshed and the user is sent to their
|
||||
* control panel.
|
||||
*
|
||||
* Forms are composed from `@suaveplan/react-ui` primitives:
|
||||
* `<Card>` / `<CardContent>` shell, `<Field>` + `<FieldLabel>` +
|
||||
* `<Input>` + `<FieldError>` per control, and `<Button>` for the
|
||||
* submit action. The chassis owns all visual variant resolution; the
|
||||
* demo carries no hand-rolled Tailwind classes for these primitives.
|
||||
*/
|
||||
import {
|
||||
Alert,
|
||||
AlertDescription,
|
||||
Button,
|
||||
Card,
|
||||
CardContent,
|
||||
Field,
|
||||
FieldError,
|
||||
FieldGroup,
|
||||
FieldLabel,
|
||||
Input,
|
||||
} from "@suaveplan/react-ui";
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { Link, useNavigate, useSearchParams } from "react-router-dom";
|
||||
import { PageShell } from "../components/page-shell.js";
|
||||
import { useLocale } from "../i18n/locale-context.js";
|
||||
import {
|
||||
forgetPassword,
|
||||
signInEmail,
|
||||
signOut,
|
||||
signUpEmail,
|
||||
} from "../lib/auth-client.js";
|
||||
import { useSession } from "../lib/session-context.js";
|
||||
|
||||
export function LoginPage(): React.JSX.Element {
|
||||
const { base, messages } = useLocale();
|
||||
const m = messages.login;
|
||||
const { refresh } = useSession();
|
||||
const navigate = useNavigate();
|
||||
const [search] = useSearchParams();
|
||||
const [email, setEmail] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
async function handleSubmit(e: React.FormEvent): Promise<void> {
|
||||
e.preventDefault();
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
const result = await signInEmail({ email, password });
|
||||
if (result.ok) {
|
||||
await refresh();
|
||||
navigate(search.get("return") ?? `/${base}/user/control-panel`);
|
||||
} else {
|
||||
setError(result.error);
|
||||
}
|
||||
setBusy(false);
|
||||
}
|
||||
|
||||
return (
|
||||
<PageShell title={m.title} heading={m.heading} lead={m.lead}>
|
||||
<Card>
|
||||
<CardContent>
|
||||
<form onSubmit={handleSubmit} data-testid="login-form">
|
||||
<FieldGroup>
|
||||
<Field>
|
||||
<FieldLabel htmlFor="email">{m.emailLabel}</FieldLabel>
|
||||
<Input
|
||||
id="email"
|
||||
name="email"
|
||||
type="email"
|
||||
required
|
||||
autoComplete="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel htmlFor="password">{m.passwordLabel}</FieldLabel>
|
||||
<Input
|
||||
id="password"
|
||||
name="password"
|
||||
type="password"
|
||||
required
|
||||
autoComplete="current-password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
{error !== null ? (
|
||||
<Alert variant="destructive" data-testid="login-error">
|
||||
<AlertDescription>{error}</AlertDescription>
|
||||
</Alert>
|
||||
) : null}
|
||||
<Button type="submit" disabled={busy}>
|
||||
{m.submit}
|
||||
</Button>
|
||||
<div className="flex flex-wrap justify-between gap-2 text-sm">
|
||||
<Link to={`/${base}/auth/register`}>{m.linkToRegister}</Link>
|
||||
<Link to={`/${base}/auth/forgot-password`}>{m.linkToForgot}</Link>
|
||||
</div>
|
||||
</FieldGroup>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</PageShell>
|
||||
);
|
||||
}
|
||||
|
||||
export function RegisterPage(): React.JSX.Element {
|
||||
const { base, messages } = useLocale();
|
||||
const m = messages.register;
|
||||
const { refresh } = useSession();
|
||||
const navigate = useNavigate();
|
||||
const [email, setEmail] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [confirm, setConfirm] = useState("");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
async function handleSubmit(e: React.FormEvent): Promise<void> {
|
||||
e.preventDefault();
|
||||
if (password !== confirm) {
|
||||
setError("Passwords do not match.");
|
||||
return;
|
||||
}
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
const result = await signUpEmail({ email, password });
|
||||
if (result.ok) {
|
||||
await refresh();
|
||||
navigate(`/${base}/user/control-panel`);
|
||||
} else {
|
||||
setError(result.error);
|
||||
}
|
||||
setBusy(false);
|
||||
}
|
||||
|
||||
const mismatch = password !== "" && confirm !== "" && password !== confirm;
|
||||
|
||||
return (
|
||||
<PageShell title={m.title} heading={m.heading} lead={m.lead}>
|
||||
<Card>
|
||||
<CardContent>
|
||||
<form onSubmit={handleSubmit} data-testid="register-form">
|
||||
<FieldGroup>
|
||||
<Field>
|
||||
<FieldLabel htmlFor="email">{m.emailLabel}</FieldLabel>
|
||||
<Input
|
||||
id="email"
|
||||
name="email"
|
||||
type="email"
|
||||
required
|
||||
autoComplete="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel htmlFor="password">{m.passwordLabel}</FieldLabel>
|
||||
<Input
|
||||
id="password"
|
||||
name="password"
|
||||
type="password"
|
||||
required
|
||||
autoComplete="new-password"
|
||||
minLength={6}
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
<Field invalid={mismatch}>
|
||||
<FieldLabel htmlFor="confirm">{m.confirmLabel}</FieldLabel>
|
||||
<Input
|
||||
id="confirm"
|
||||
name="confirm"
|
||||
type="password"
|
||||
required
|
||||
autoComplete="new-password"
|
||||
minLength={6}
|
||||
invalid={mismatch}
|
||||
value={confirm}
|
||||
onChange={(e) => setConfirm(e.target.value)}
|
||||
/>
|
||||
{mismatch ? (
|
||||
<FieldError>Passwords do not match.</FieldError>
|
||||
) : null}
|
||||
</Field>
|
||||
{error !== null ? (
|
||||
<Alert variant="destructive" data-testid="register-error">
|
||||
<AlertDescription>{error}</AlertDescription>
|
||||
</Alert>
|
||||
) : null}
|
||||
<Button type="submit" disabled={busy}>
|
||||
{m.submit}
|
||||
</Button>
|
||||
<div className="text-sm">
|
||||
<Link to={`/${base}/auth/login`}>{m.linkToLogin}</Link>
|
||||
</div>
|
||||
</FieldGroup>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</PageShell>
|
||||
);
|
||||
}
|
||||
|
||||
export function ForgotPasswordPage(): React.JSX.Element {
|
||||
const { base, messages } = useLocale();
|
||||
const m = messages.forgot;
|
||||
const [email, setEmail] = useState("");
|
||||
const [sent, setSent] = useState(false);
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
async function handleSubmit(e: React.FormEvent): Promise<void> {
|
||||
e.preventDefault();
|
||||
setBusy(true);
|
||||
await forgetPassword({ email });
|
||||
setSent(true);
|
||||
setBusy(false);
|
||||
}
|
||||
|
||||
return (
|
||||
<PageShell title={m.title} heading={m.heading} lead={m.lead}>
|
||||
<Card>
|
||||
<CardContent>
|
||||
{sent ? (
|
||||
<Alert variant="default" data-testid="forgot-sent">
|
||||
<AlertDescription>{m.sentNotice}</AlertDescription>
|
||||
</Alert>
|
||||
) : (
|
||||
<form onSubmit={handleSubmit} data-testid="forgot-form">
|
||||
<FieldGroup>
|
||||
<Field>
|
||||
<FieldLabel htmlFor="email">{m.emailLabel}</FieldLabel>
|
||||
<Input
|
||||
id="email"
|
||||
name="email"
|
||||
type="email"
|
||||
required
|
||||
autoComplete="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
<Button type="submit" disabled={busy}>
|
||||
{m.submit}
|
||||
</Button>
|
||||
</FieldGroup>
|
||||
</form>
|
||||
)}
|
||||
<div className="mt-4 text-sm">
|
||||
<Link to={`/${base}/auth/login`}>{m.linkToLogin}</Link>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</PageShell>
|
||||
);
|
||||
}
|
||||
|
||||
export function LogoutPage(): React.JSX.Element {
|
||||
const { base, messages } = useLocale();
|
||||
const m = messages.logout;
|
||||
const { refresh } = useSession();
|
||||
const navigate = useNavigate();
|
||||
|
||||
useEffect(() => {
|
||||
void (async () => {
|
||||
await signOut();
|
||||
await refresh();
|
||||
navigate(`/${base}/home`);
|
||||
})();
|
||||
// We want this effect to run exactly once on mount; the locale
|
||||
// base is captured at mount time.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
return <PageShell title={m.title} heading={m.heading} lead={m.lead} />;
|
||||
}
|
||||
39
apps/demo/src/client/pages/not-found-page.tsx
Normal file
39
apps/demo/src/client/pages/not-found-page.tsx
Normal file
@@ -0,0 +1,39 @@
|
||||
/**
|
||||
* 404 page — also doubles as the unknown-locale handler. Offers a
|
||||
* language picker so a user landing on `/de/home` can switch to a
|
||||
* supported language without backtracking.
|
||||
*
|
||||
* Each language choice renders as a `<Button asChild variant="outline">`
|
||||
* wrapping a router `<Link>` so the chassis owns the visual treatment
|
||||
* (border, focus ring, hover state, motion tokens) while the navigation
|
||||
* stays SPA-internal.
|
||||
*/
|
||||
import { Button } from "@suaveplan/react-ui";
|
||||
import React from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import { PageShell } from "../components/page-shell.js";
|
||||
import { LOCALE_ENTRIES, useLocale } from "../i18n/locale-context.js";
|
||||
import { CATALOGS } from "../i18n/messages.js";
|
||||
|
||||
export function NotFoundPage(): React.JSX.Element {
|
||||
// We may be rendered outside a valid locale context (e.g. for
|
||||
// `/de/home`). The LocaleProvider falls back to "en" in that case,
|
||||
// so we can safely read messages.
|
||||
const { availableLocales, messages: ctxMessages } = useLocale();
|
||||
const m = ctxMessages.notFound;
|
||||
return (
|
||||
<PageShell title={m.title} heading={m.heading} lead={m.lead}>
|
||||
<ul className="mt-4 flex flex-wrap gap-3">
|
||||
{availableLocales.map((loc) => (
|
||||
<li key={loc}>
|
||||
<Button asChild variant="outline" size="sm">
|
||||
<Link to={`/${loc}/home`}>
|
||||
{LOCALE_ENTRIES[loc].nativeName} — {CATALOGS[loc].nav.home}
|
||||
</Link>
|
||||
</Button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</PageShell>
|
||||
);
|
||||
}
|
||||
32
apps/demo/src/client/pages/public-pages.tsx
Normal file
32
apps/demo/src/client/pages/public-pages.tsx
Normal file
@@ -0,0 +1,32 @@
|
||||
/**
|
||||
* Five public information pages — Home, About, Contact, Privacy,
|
||||
* Terms. Each renders through `<PageShell>` using the typed catalog.
|
||||
*/
|
||||
import React from "react";
|
||||
import { PageShell } from "../components/page-shell.js";
|
||||
import { useMessages } from "../i18n/locale-context.js";
|
||||
|
||||
export function HomePage(): React.JSX.Element {
|
||||
const m = useMessages().home;
|
||||
return <PageShell title={m.title} heading={m.heading} lead={m.lead} />;
|
||||
}
|
||||
|
||||
export function AboutPage(): React.JSX.Element {
|
||||
const m = useMessages().about;
|
||||
return <PageShell title={m.title} heading={m.heading} lead={m.lead} />;
|
||||
}
|
||||
|
||||
export function ContactPage(): React.JSX.Element {
|
||||
const m = useMessages().contact;
|
||||
return <PageShell title={m.title} heading={m.heading} lead={m.lead} />;
|
||||
}
|
||||
|
||||
export function PrivacyPage(): React.JSX.Element {
|
||||
const m = useMessages().privacy;
|
||||
return <PageShell title={m.title} heading={m.heading} lead={m.lead} />;
|
||||
}
|
||||
|
||||
export function TermsPage(): React.JSX.Element {
|
||||
const m = useMessages().terms;
|
||||
return <PageShell title={m.title} heading={m.heading} lead={m.lead} />;
|
||||
}
|
||||
57
apps/demo/src/client/styles/globals.css
Normal file
57
apps/demo/src/client/styles/globals.css
Normal file
@@ -0,0 +1,57 @@
|
||||
@import "tailwindcss";
|
||||
|
||||
:root {
|
||||
--color-bg: #ffffff;
|
||||
--color-fg: #0a0a0a;
|
||||
--color-muted: #71717a;
|
||||
--color-border: #e4e4e7;
|
||||
--color-accent: #6366f1;
|
||||
--color-accent-fg: #ffffff;
|
||||
--color-surface: #fafafa;
|
||||
}
|
||||
|
||||
[data-theme="dark"] {
|
||||
--color-bg: #09090b;
|
||||
--color-fg: #fafafa;
|
||||
--color-muted: #a1a1aa;
|
||||
--color-border: #27272a;
|
||||
--color-accent: #818cf8;
|
||||
--color-accent-fg: #0a0a0a;
|
||||
--color-surface: #18181b;
|
||||
}
|
||||
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html,
|
||||
body,
|
||||
#root {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
min-height: 100vh;
|
||||
background-color: var(--color-bg);
|
||||
color: var(--color-fg);
|
||||
font-family:
|
||||
"Inter",
|
||||
system-ui,
|
||||
-apple-system,
|
||||
"Segoe UI",
|
||||
sans-serif;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
|
||||
[dir="rtl"] {
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
a {
|
||||
color: var(--color-accent);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
185
apps/demo/src/server/auth.ts
Normal file
185
apps/demo/src/server/auth.ts
Normal file
@@ -0,0 +1,185 @@
|
||||
/**
|
||||
* Demo authentication boot.
|
||||
*
|
||||
* Composes the canonical genesis plugin pipeline:
|
||||
*
|
||||
* 1. A live `SurrealAdapter` (`@suaveplan/surrealdb`) — connects to
|
||||
* `SURREAL_URL` (default `ws://localhost:8000`) with namespace +
|
||||
* database from env. The driver does not bundle an in-process
|
||||
* engine in this version, so an external SurrealDB must be
|
||||
* reachable.
|
||||
* 2. A `betterAuthSurrealAdapter` (`@suaveplan/better-auth-surrealdb`)
|
||||
* built against that client — this is the persistence backend
|
||||
* Better-Auth itself talks to.
|
||||
* 3. A `SuaveApp` (`@suaveplan/api`) assembled via `defineApp()`. Two
|
||||
* plugins register, in order:
|
||||
* a. A tiny inline `demo-db-seed` plugin that drops the live
|
||||
* `SurrealAdapter` into the `ServiceRegistry["db"]` slot.
|
||||
* b. `createAuthPlugin(...)` from `@suaveplan/api-plugin-auth`
|
||||
* — its 6-step `onInit` then resolves the `db` slot, applies
|
||||
* the canonical 17-table Better-Auth SurrealQL schema,
|
||||
* constructs the Better-Auth instance, registers the
|
||||
* `authService`, mounts the 9 OpenAPI routes (and the
|
||||
* catch-all Better-Auth handler) under `/api/auth/*`, and
|
||||
* installs the global session-resolver middleware.
|
||||
*
|
||||
* The output is `{ app, surreal }` so the HTTP server can mount the
|
||||
* SPA fallback + custom routes onto `app.hono` and shut down the
|
||||
* SurrealDB client cleanly on stop.
|
||||
*/
|
||||
import { defineApp, type SuaveApp } from "@suaveplan/api";
|
||||
import { definePlugin } from "@suaveplan/api-plugin";
|
||||
import type { AuthService } from "@suaveplan/api-plugin-auth";
|
||||
import { createAuthPlugin } from "@suaveplan/api-plugin-auth";
|
||||
import { betterAuthSurrealAdapter } from "@suaveplan/better-auth-surrealdb";
|
||||
import { SurrealAdapter } from "@suaveplan/surrealdb";
|
||||
|
||||
// ServiceRegistry augmentation — re-declared locally because the
|
||||
// vite-plugin-dts emitted package-root `.d.ts` files for
|
||||
// `@suaveplan/api` and `@suaveplan/api-plugin-auth` strip the
|
||||
// side-effect import of the per-package `registry-augmentation.ts`
|
||||
// where these `declare module` blocks live. Without this re-declaration
|
||||
// `ServiceRegistry` resolves to `Record<never, never>` and
|
||||
// `ctx.registerService("db", ...)` / `ctx.getService("authService")`
|
||||
// fail to typecheck. The augmentation is interface-merged with the
|
||||
// package-internal ones at runtime, so the typing matches what the
|
||||
// auth plugin actually populates.
|
||||
declare module "@suaveplan/api-plugin/service-registry" {
|
||||
interface ServiceRegistry {
|
||||
db?: SurrealAdapter;
|
||||
authService: AuthService;
|
||||
}
|
||||
}
|
||||
|
||||
/** Read a string env var with a non-empty fallback. */
|
||||
function envOr(name: string, fallback: string): string {
|
||||
const raw = process.env[name];
|
||||
return raw !== undefined && raw.length > 0 ? raw : fallback;
|
||||
}
|
||||
|
||||
/** Resolved SurrealDB connection parameters for the demo. */
|
||||
export interface SurrealConfig {
|
||||
readonly url: string;
|
||||
readonly namespace: string;
|
||||
readonly database: string;
|
||||
readonly username: string;
|
||||
readonly password: string;
|
||||
}
|
||||
|
||||
/** Build the connection config from env vars (with defaults). */
|
||||
export function resolveSurrealConfig(): SurrealConfig {
|
||||
return {
|
||||
url: envOr("SURREAL_URL", "ws://localhost:8000"),
|
||||
namespace: envOr("SURREAL_NS", "demo"),
|
||||
database: envOr("SURREAL_DB", "demo"),
|
||||
username: envOr("SURREAL_USER", "root"),
|
||||
password: envOr("SURREAL_PASS", "root"),
|
||||
};
|
||||
}
|
||||
|
||||
/** Result of {@link bootAuth}. */
|
||||
export interface DemoAuthBoot {
|
||||
readonly app: SuaveApp;
|
||||
readonly surreal: SurrealAdapter;
|
||||
readonly authService: AuthService;
|
||||
}
|
||||
|
||||
/**
|
||||
* Construct + initialize the demo `SuaveApp` with the auth plugin
|
||||
* wired against a live SurrealDB. Returns the initialized `SuaveApp`
|
||||
* (ready to receive requests via `app.fetch`) plus the underlying
|
||||
* `SurrealAdapter` so the caller can `await surreal.disconnect()` on
|
||||
* shutdown.
|
||||
*/
|
||||
export async function bootAuth(): Promise<DemoAuthBoot> {
|
||||
const cfg = resolveSurrealConfig();
|
||||
const surreal = new SurrealAdapter({
|
||||
url: cfg.url,
|
||||
namespace: cfg.namespace,
|
||||
database: cfg.database,
|
||||
username: cfg.username,
|
||||
password: cfg.password,
|
||||
});
|
||||
await surreal.connect();
|
||||
|
||||
const betterAuthAdapter = betterAuthSurrealAdapter({
|
||||
client: surreal,
|
||||
});
|
||||
|
||||
/**
|
||||
* Inline plugin — seeds the `db` slot on the `ServiceRegistry`
|
||||
* so the auth plugin's `onInit` (which runs after this one in
|
||||
* registration order) can resolve a connected SurrealDB client
|
||||
* for `applyBetterAuthSchema(db)`.
|
||||
*/
|
||||
const dbSeedPlugin = definePlugin<Record<string, never>>({
|
||||
name: "demo-db-seed",
|
||||
version: "0.1.0",
|
||||
description:
|
||||
"Demo-only plugin: seeds the live SurrealAdapter into ServiceRegistry['db'] for the auth plugin to consume. Runs in `onRegister` so the `db` slot is populated before any plugin's `onInit` phase (including the auth plugin's).",
|
||||
defaults: {},
|
||||
onRegister: async (ctx) => {
|
||||
ctx.registerService("db", surreal);
|
||||
},
|
||||
});
|
||||
|
||||
const authPlugin = createAuthPlugin({
|
||||
betterAuth: {
|
||||
database: betterAuthAdapter as unknown as Record<string, unknown>,
|
||||
baseURL: envOr("BETTER_AUTH_URL", "http://localhost:3300"),
|
||||
secret: envOr(
|
||||
"BETTER_AUTH_SECRET",
|
||||
"demo-secret-please-set-BETTER_AUTH_SECRET-in-prod-do-not-use"
|
||||
),
|
||||
emailAndPassword: {
|
||||
enabled: true,
|
||||
autoSignIn: true,
|
||||
minPasswordLength: 6,
|
||||
},
|
||||
trustedOrigins: ["http://localhost:3300"],
|
||||
},
|
||||
routePrefix: "/api/auth",
|
||||
applySchemaOnInit: true,
|
||||
});
|
||||
|
||||
/**
|
||||
* Inline capture plugin — registered AFTER `authPlugin` so its
|
||||
* `onInit` resolves the typed `authService` slot the auth plugin
|
||||
* just populated, and stashes it on `captured.authService` for the
|
||||
* demo's `/api/me` handler.
|
||||
*/
|
||||
const captured: { authService: AuthService | undefined } = {
|
||||
authService: undefined,
|
||||
};
|
||||
const capturePlugin = definePlugin<Record<string, never>>({
|
||||
name: "demo-auth-capture",
|
||||
version: "0.1.0",
|
||||
description:
|
||||
"Demo-only plugin: captures the resolved authService from the plugin registry for use by /api/me. Runs in `onReady` so the `authService` slot populated by the auth plugin's `onInit` is guaranteed visible.",
|
||||
defaults: {},
|
||||
onReady: async (ctx) => {
|
||||
captured.authService = ctx.getService("authService");
|
||||
},
|
||||
});
|
||||
|
||||
const app = await defineApp()
|
||||
.name("@suaveplan/demo")
|
||||
.version("0.1.0")
|
||||
.skipConfigFile()
|
||||
.openapi({ title: "@suaveplan/demo", version: "0.1.0" })
|
||||
.healthRoutes({ healthPath: "/api/health" })
|
||||
.use(dbSeedPlugin)
|
||||
.use(authPlugin)
|
||||
.use(capturePlugin)
|
||||
.build();
|
||||
await app.initialize();
|
||||
if (captured.authService === undefined) {
|
||||
throw new Error(
|
||||
"demo boot: authService was not populated by api-plugin-auth"
|
||||
);
|
||||
}
|
||||
|
||||
return { app, surreal, authService: captured.authService };
|
||||
}
|
||||
|
||||
export type AppAuth = SuaveApp;
|
||||
55
apps/demo/src/server/index.ts
Normal file
55
apps/demo/src/server/index.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
/**
|
||||
* Unified Bun + Hono server for the demo app.
|
||||
*
|
||||
* Built around `@suaveplan/api`'s `SuaveApp`:
|
||||
*
|
||||
* - `/api/health` — registered by `registerHealthRoutes` on the
|
||||
* `SuaveApp` Hono.
|
||||
* - `/api/auth/*` — mounted by `@suaveplan/api-plugin-auth` (9 OpenAPI
|
||||
* routes + the Better-Auth catch-all).
|
||||
* - `/api/me` — demo convenience JSON view of the current session,
|
||||
* sourced from the captured `authService.getSession(...)`.
|
||||
* - everything else falls through to the Vite-built React SPA in
|
||||
* `dist/client/`, with a wildcard fallback to `index.html` so the
|
||||
* client-side router owns locale-prefixed routes.
|
||||
*/
|
||||
import { serveStatic } from "hono/bun";
|
||||
import { bootAuth } from "./auth.js";
|
||||
|
||||
const { app, surreal, authService } = await bootAuth();
|
||||
const hono = app.hono;
|
||||
|
||||
hono.get("/api/me", async (c) => {
|
||||
const result = await authService.getSession(c.req.raw.headers);
|
||||
return c.json({ session: result });
|
||||
});
|
||||
|
||||
hono.use("/*", serveStatic({ root: "./dist/client" }));
|
||||
hono.get("*", serveStatic({ path: "./dist/client/index.html" }));
|
||||
|
||||
const port = Number(process.env["PORT"] ?? 3300);
|
||||
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(`[@suaveplan/demo] listening on http://localhost:${port}`);
|
||||
|
||||
const shutdown = async (): Promise<void> => {
|
||||
try {
|
||||
await app.stop();
|
||||
} catch {
|
||||
// best-effort
|
||||
}
|
||||
try {
|
||||
await surreal.disconnect();
|
||||
} catch {
|
||||
// best-effort
|
||||
}
|
||||
};
|
||||
|
||||
process.on("SIGTERM", () => {
|
||||
void shutdown().then(() => process.exit(0));
|
||||
});
|
||||
process.on("SIGINT", () => {
|
||||
void shutdown().then(() => process.exit(0));
|
||||
});
|
||||
|
||||
export default { port, fetch: app.fetch };
|
||||
12
apps/demo/tsconfig.json
Normal file
12
apps/demo/tsconfig.json
Normal file
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/tsconfig",
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "./dist",
|
||||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||
"jsx": "react-jsx",
|
||||
"types": ["node", "react", "react-dom"]
|
||||
},
|
||||
"include": ["src/**/*.ts", "src/**/*.tsx", "e2e/**/*.ts"],
|
||||
"exclude": ["dist", "node_modules", "coverage"]
|
||||
}
|
||||
14
apps/demo/vite.config.ts
Normal file
14
apps/demo/vite.config.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import tailwindcss from "@tailwindcss/vite";
|
||||
import react from "@vitejs/plugin-react";
|
||||
import { defineConfig } from "vite";
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react(), tailwindcss()],
|
||||
build: {
|
||||
outDir: "dist/client",
|
||||
emptyOutDir: true,
|
||||
sourcemap: true,
|
||||
},
|
||||
root: ".",
|
||||
publicDir: "public",
|
||||
});
|
||||
5
apps/docs/.docusaurus/DONT-EDIT-THIS-FOLDER
Normal file
5
apps/docs/.docusaurus/DONT-EDIT-THIS-FOLDER
Normal file
@@ -0,0 +1,5 @@
|
||||
This folder stores temp files that Docusaurus' client bundler accesses.
|
||||
|
||||
DO NOT hand-modify files in this folder because they will be overwritten in the
|
||||
next build. You can clear all build artifacts (including this folder) with the
|
||||
`docusaurus clear` command.
|
||||
163
apps/docs/.docusaurus/client-manifest.json
Normal file
163
apps/docs/.docusaurus/client-manifest.json
Normal file
@@ -0,0 +1,163 @@
|
||||
{
|
||||
"entrypoints": [
|
||||
"main"
|
||||
],
|
||||
"origins": {
|
||||
"456": [
|
||||
456
|
||||
],
|
||||
"622": [
|
||||
622
|
||||
],
|
||||
"17896441": [
|
||||
869,
|
||||
401
|
||||
],
|
||||
"main": [
|
||||
354,
|
||||
869,
|
||||
792
|
||||
],
|
||||
"runtime~main": [
|
||||
792,
|
||||
869,
|
||||
354
|
||||
],
|
||||
"0e384e19": [
|
||||
976
|
||||
],
|
||||
"22dd74f7": [
|
||||
567
|
||||
],
|
||||
"5e95c892": [
|
||||
647
|
||||
],
|
||||
"a7bd4aaa": [
|
||||
98
|
||||
],
|
||||
"a94703ab": [
|
||||
869,
|
||||
48
|
||||
],
|
||||
"aba21aa0": [
|
||||
742
|
||||
],
|
||||
"styles": [
|
||||
48,
|
||||
354,
|
||||
401,
|
||||
792,
|
||||
869
|
||||
]
|
||||
},
|
||||
"assets": {
|
||||
"48": {
|
||||
"js": [
|
||||
{
|
||||
"file": "assets/js/a94703ab.d21732ca.js",
|
||||
"hash": "248801145fd8928c",
|
||||
"publicPath": "/assets/js/a94703ab.d21732ca.js"
|
||||
}
|
||||
]
|
||||
},
|
||||
"98": {
|
||||
"js": [
|
||||
{
|
||||
"file": "assets/js/a7bd4aaa.2cf1ac5a.js",
|
||||
"hash": "6f3292ea4c81c4b7",
|
||||
"publicPath": "/assets/js/a7bd4aaa.2cf1ac5a.js"
|
||||
}
|
||||
]
|
||||
},
|
||||
"354": {
|
||||
"js": [
|
||||
{
|
||||
"file": "assets/js/runtime~main.3eeea4d6.js",
|
||||
"hash": "93ef5a0e7bc387d8",
|
||||
"publicPath": "/assets/js/runtime~main.3eeea4d6.js"
|
||||
}
|
||||
]
|
||||
},
|
||||
"401": {
|
||||
"js": [
|
||||
{
|
||||
"file": "assets/js/17896441.e558579f.js",
|
||||
"hash": "e2a2a91703a75265",
|
||||
"publicPath": "/assets/js/17896441.e558579f.js"
|
||||
}
|
||||
]
|
||||
},
|
||||
"456": {
|
||||
"js": [
|
||||
{
|
||||
"file": "assets/js/456.41b11149.js",
|
||||
"hash": "c105dca6e9081ec2",
|
||||
"publicPath": "/assets/js/456.41b11149.js"
|
||||
}
|
||||
]
|
||||
},
|
||||
"567": {
|
||||
"js": [
|
||||
{
|
||||
"file": "assets/js/22dd74f7.6d35978e.js",
|
||||
"hash": "e9c668ccbb4eac4b",
|
||||
"publicPath": "/assets/js/22dd74f7.6d35978e.js"
|
||||
}
|
||||
]
|
||||
},
|
||||
"622": {
|
||||
"js": [
|
||||
{
|
||||
"file": "assets/js/622.98edb4f0.js",
|
||||
"hash": "c4687fee7338a5a1",
|
||||
"publicPath": "/assets/js/622.98edb4f0.js"
|
||||
}
|
||||
]
|
||||
},
|
||||
"647": {
|
||||
"js": [
|
||||
{
|
||||
"file": "assets/js/5e95c892.c151ec16.js",
|
||||
"hash": "99a9fdc9726d32c0",
|
||||
"publicPath": "/assets/js/5e95c892.c151ec16.js"
|
||||
}
|
||||
]
|
||||
},
|
||||
"742": {
|
||||
"js": [
|
||||
{
|
||||
"file": "assets/js/aba21aa0.a9cfd991.js",
|
||||
"hash": "4bb470462c0c0dbb",
|
||||
"publicPath": "/assets/js/aba21aa0.a9cfd991.js"
|
||||
}
|
||||
]
|
||||
},
|
||||
"792": {
|
||||
"js": [
|
||||
{
|
||||
"file": "assets/js/main.f54ec557.js",
|
||||
"hash": "862bce4131b89d15",
|
||||
"publicPath": "/assets/js/main.f54ec557.js"
|
||||
}
|
||||
]
|
||||
},
|
||||
"869": {
|
||||
"css": [
|
||||
{
|
||||
"file": "assets/css/styles.58cbff57.css",
|
||||
"hash": "403c7cfb68551a78",
|
||||
"publicPath": "/assets/css/styles.58cbff57.css"
|
||||
}
|
||||
]
|
||||
},
|
||||
"976": {
|
||||
"js": [
|
||||
{
|
||||
"file": "assets/js/0e384e19.4cce6415.js",
|
||||
"hash": "1da36c0f3c4b923e",
|
||||
"publicPath": "/assets/js/0e384e19.4cce6415.js"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
6
apps/docs/.docusaurus/client-modules.js
Normal file
6
apps/docs/.docusaurus/client-modules.js
Normal file
@@ -0,0 +1,6 @@
|
||||
export default [
|
||||
require("/home/paul/web/genesis/node_modules/.bun/infima@0.2.0-alpha.45/node_modules/infima/dist/css/default/default.css"),
|
||||
require("/home/paul/web/genesis/node_modules/.bun/@docusaurus+theme-classic@3.8.1+a362615079116658/node_modules/@docusaurus/theme-classic/lib/prism-include-languages"),
|
||||
require("/home/paul/web/genesis/node_modules/.bun/@docusaurus+theme-classic@3.8.1+a362615079116658/node_modules/@docusaurus/theme-classic/lib/nprogress"),
|
||||
require("/home/paul/web/genesis/apps/docs/src/css/custom.css"),
|
||||
];
|
||||
1
apps/docs/.docusaurus/codeTranslations.json
Normal file
1
apps/docs/.docusaurus/codeTranslations.json
Normal file
@@ -0,0 +1 @@
|
||||
{}
|
||||
@@ -0,0 +1 @@
|
||||
{"options":{"sidebarPath":"/home/paul/web/genesis/apps/docs/sidebars.ts","routeBasePath":"/","path":"docs","editCurrentVersion":false,"editLocalizedFiles":false,"tagsBasePath":"tags","include":["**/*.{md,mdx}"],"exclude":["**/_*.{js,jsx,ts,tsx,md,mdx}","**/_*/**","**/*.test.{js,jsx,ts,tsx}","**/__tests__/**"],"sidebarCollapsible":true,"sidebarCollapsed":true,"docsRootComponent":"@theme/DocsRoot","docVersionRootComponent":"@theme/DocVersionRoot","docRootComponent":"@theme/DocRoot","docItemComponent":"@theme/DocItem","docTagsListComponent":"@theme/DocTagsListPage","docTagDocListComponent":"@theme/DocTagDocListPage","docCategoryGeneratedIndexComponent":"@theme/DocCategoryGeneratedIndexPage","remarkPlugins":[],"rehypePlugins":[],"recmaPlugins":[],"beforeDefaultRemarkPlugins":[],"beforeDefaultRehypePlugins":[],"admonitions":true,"showLastUpdateTime":false,"showLastUpdateAuthor":false,"includeCurrentVersion":true,"disableVersioning":false,"versions":{},"breadcrumbs":true,"onInlineTags":"warn","id":"default"},"versionsMetadata":[{"versionName":"current","label":"Next","banner":null,"badge":false,"noIndex":false,"className":"docs-version-current","path":"/","tagsPath":"/tags","isLast":true,"routePriority":-1,"sidebarFilePath":"/home/paul/web/genesis/apps/docs/sidebars.ts","contentPath":"/home/paul/web/genesis/apps/docs/docs","contentPathLocalized":"/home/paul/web/genesis/apps/docs/i18n/en/docusaurus-plugin-content-docs/current"}]}
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"name": "docusaurus-plugin-content-docs",
|
||||
"id": "default"
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
{"version":{"pluginId":"default","version":"current","label":"Next","banner":null,"badge":false,"noIndex":false,"className":"docs-version-current","isLast":true,"docsSidebars":{"main":[{"type":"link","label":"Introduction","href":"/","docId":"intro","unlisted":false}]},"docs":{"intro":{"id":"intro","title":"Introduction","description":"Welcome to the SuavePlan monorepo documentation. This site is the front door","sidebar":"main"}}}}
|
||||
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"id": "intro",
|
||||
"title": "Introduction",
|
||||
"description": "Welcome to the SuavePlan monorepo documentation. This site is the front door",
|
||||
"source": "@site/docs/intro.md",
|
||||
"sourceDirName": ".",
|
||||
"slug": "/",
|
||||
"permalink": "/",
|
||||
"draft": false,
|
||||
"unlisted": false,
|
||||
"tags": [],
|
||||
"version": "current",
|
||||
"sidebarPosition": 1,
|
||||
"frontMatter": {
|
||||
"slug": "/",
|
||||
"sidebar_position": 1,
|
||||
"title": "Introduction"
|
||||
},
|
||||
"sidebar": "main"
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"name": "docusaurus-plugin-content-pages",
|
||||
"id": "default"
|
||||
}
|
||||
356
apps/docs/.docusaurus/docusaurus.config.mjs
Normal file
356
apps/docs/.docusaurus/docusaurus.config.mjs
Normal file
@@ -0,0 +1,356 @@
|
||||
/*
|
||||
* AUTOGENERATED - DON'T EDIT
|
||||
* Your edits in this file will be overwritten in the next build!
|
||||
* Modify the docusaurus.config.js file at your site's root instead.
|
||||
*/
|
||||
export default {
|
||||
"title": "SuavePlan",
|
||||
"tagline": "Enterprise-grade monorepo, rebuilt bottom-up",
|
||||
"favicon": "img/favicon.ico",
|
||||
"url": "https://docs.suaveplan.com",
|
||||
"baseUrl": "/",
|
||||
"organizationName": "SuavePlan",
|
||||
"projectName": "genesis",
|
||||
"onBrokenLinks": "warn",
|
||||
"onBrokenMarkdownLinks": "warn",
|
||||
"i18n": {
|
||||
"defaultLocale": "en",
|
||||
"locales": [
|
||||
"en"
|
||||
],
|
||||
"path": "i18n",
|
||||
"localeConfigs": {}
|
||||
},
|
||||
"presets": [
|
||||
[
|
||||
"classic",
|
||||
{
|
||||
"docs": {
|
||||
"sidebarPath": "./sidebars.ts",
|
||||
"routeBasePath": "/"
|
||||
},
|
||||
"blog": false,
|
||||
"theme": {
|
||||
"customCss": "./src/css/custom.css"
|
||||
}
|
||||
}
|
||||
]
|
||||
],
|
||||
"themeConfig": {
|
||||
"navbar": {
|
||||
"title": "SuavePlan",
|
||||
"logo": {
|
||||
"alt": "SuavePlan",
|
||||
"src": "img/logo.svg"
|
||||
},
|
||||
"items": [
|
||||
{
|
||||
"type": "docSidebar",
|
||||
"sidebarId": "main",
|
||||
"position": "left",
|
||||
"label": "Docs"
|
||||
},
|
||||
{
|
||||
"href": "https://github.com/SuavePlan/genesis",
|
||||
"label": "GitHub",
|
||||
"position": "right"
|
||||
}
|
||||
],
|
||||
"hideOnScroll": false
|
||||
},
|
||||
"footer": {
|
||||
"style": "dark",
|
||||
"links": [
|
||||
{
|
||||
"title": "Repo",
|
||||
"items": [
|
||||
{
|
||||
"label": "GitHub",
|
||||
"href": "https://github.com/SuavePlan/genesis"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"copyright": "Copyright © 2026 SuavePlan."
|
||||
},
|
||||
"prism": {
|
||||
"theme": {
|
||||
"plain": {
|
||||
"color": "#393A34",
|
||||
"backgroundColor": "#f6f8fa"
|
||||
},
|
||||
"styles": [
|
||||
{
|
||||
"types": [
|
||||
"comment",
|
||||
"prolog",
|
||||
"doctype",
|
||||
"cdata"
|
||||
],
|
||||
"style": {
|
||||
"color": "#999988",
|
||||
"fontStyle": "italic"
|
||||
}
|
||||
},
|
||||
{
|
||||
"types": [
|
||||
"namespace"
|
||||
],
|
||||
"style": {
|
||||
"opacity": 0.7
|
||||
}
|
||||
},
|
||||
{
|
||||
"types": [
|
||||
"string",
|
||||
"attr-value"
|
||||
],
|
||||
"style": {
|
||||
"color": "#e3116c"
|
||||
}
|
||||
},
|
||||
{
|
||||
"types": [
|
||||
"punctuation",
|
||||
"operator"
|
||||
],
|
||||
"style": {
|
||||
"color": "#393A34"
|
||||
}
|
||||
},
|
||||
{
|
||||
"types": [
|
||||
"entity",
|
||||
"url",
|
||||
"symbol",
|
||||
"number",
|
||||
"boolean",
|
||||
"variable",
|
||||
"constant",
|
||||
"property",
|
||||
"regex",
|
||||
"inserted"
|
||||
],
|
||||
"style": {
|
||||
"color": "#36acaa"
|
||||
}
|
||||
},
|
||||
{
|
||||
"types": [
|
||||
"atrule",
|
||||
"keyword",
|
||||
"attr-name",
|
||||
"selector"
|
||||
],
|
||||
"style": {
|
||||
"color": "#00a4db"
|
||||
}
|
||||
},
|
||||
{
|
||||
"types": [
|
||||
"function",
|
||||
"deleted",
|
||||
"tag"
|
||||
],
|
||||
"style": {
|
||||
"color": "#d73a49"
|
||||
}
|
||||
},
|
||||
{
|
||||
"types": [
|
||||
"function-variable"
|
||||
],
|
||||
"style": {
|
||||
"color": "#6f42c1"
|
||||
}
|
||||
},
|
||||
{
|
||||
"types": [
|
||||
"tag",
|
||||
"selector",
|
||||
"keyword"
|
||||
],
|
||||
"style": {
|
||||
"color": "#00009f"
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
"darkTheme": {
|
||||
"plain": {
|
||||
"color": "#F8F8F2",
|
||||
"backgroundColor": "#282A36"
|
||||
},
|
||||
"styles": [
|
||||
{
|
||||
"types": [
|
||||
"prolog",
|
||||
"constant",
|
||||
"builtin"
|
||||
],
|
||||
"style": {
|
||||
"color": "rgb(189, 147, 249)"
|
||||
}
|
||||
},
|
||||
{
|
||||
"types": [
|
||||
"inserted",
|
||||
"function"
|
||||
],
|
||||
"style": {
|
||||
"color": "rgb(80, 250, 123)"
|
||||
}
|
||||
},
|
||||
{
|
||||
"types": [
|
||||
"deleted"
|
||||
],
|
||||
"style": {
|
||||
"color": "rgb(255, 85, 85)"
|
||||
}
|
||||
},
|
||||
{
|
||||
"types": [
|
||||
"changed"
|
||||
],
|
||||
"style": {
|
||||
"color": "rgb(255, 184, 108)"
|
||||
}
|
||||
},
|
||||
{
|
||||
"types": [
|
||||
"punctuation",
|
||||
"symbol"
|
||||
],
|
||||
"style": {
|
||||
"color": "rgb(248, 248, 242)"
|
||||
}
|
||||
},
|
||||
{
|
||||
"types": [
|
||||
"string",
|
||||
"char",
|
||||
"tag",
|
||||
"selector"
|
||||
],
|
||||
"style": {
|
||||
"color": "rgb(255, 121, 198)"
|
||||
}
|
||||
},
|
||||
{
|
||||
"types": [
|
||||
"keyword",
|
||||
"variable"
|
||||
],
|
||||
"style": {
|
||||
"color": "rgb(189, 147, 249)",
|
||||
"fontStyle": "italic"
|
||||
}
|
||||
},
|
||||
{
|
||||
"types": [
|
||||
"comment"
|
||||
],
|
||||
"style": {
|
||||
"color": "rgb(98, 114, 164)"
|
||||
}
|
||||
},
|
||||
{
|
||||
"types": [
|
||||
"attr-name"
|
||||
],
|
||||
"style": {
|
||||
"color": "rgb(241, 250, 140)"
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
"additionalLanguages": [
|
||||
"bash",
|
||||
"json",
|
||||
"yaml",
|
||||
"toml"
|
||||
],
|
||||
"magicComments": [
|
||||
{
|
||||
"className": "theme-code-block-highlighted-line",
|
||||
"line": "highlight-next-line",
|
||||
"block": {
|
||||
"start": "highlight-start",
|
||||
"end": "highlight-end"
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
"colorMode": {
|
||||
"defaultMode": "light",
|
||||
"disableSwitch": false,
|
||||
"respectPrefersColorScheme": false
|
||||
},
|
||||
"docs": {
|
||||
"versionPersistence": "localStorage",
|
||||
"sidebar": {
|
||||
"hideable": false,
|
||||
"autoCollapseCategories": false
|
||||
}
|
||||
},
|
||||
"blog": {
|
||||
"sidebar": {
|
||||
"groupByYear": true
|
||||
}
|
||||
},
|
||||
"metadata": [],
|
||||
"tableOfContents": {
|
||||
"minHeadingLevel": 2,
|
||||
"maxHeadingLevel": 3
|
||||
}
|
||||
},
|
||||
"baseUrlIssueBanner": true,
|
||||
"future": {
|
||||
"v4": {
|
||||
"removeLegacyPostBuildHeadAttribute": false,
|
||||
"useCssCascadeLayers": false
|
||||
},
|
||||
"experimental_faster": {
|
||||
"swcJsLoader": false,
|
||||
"swcJsMinimizer": false,
|
||||
"swcHtmlMinimizer": false,
|
||||
"lightningCssMinimizer": false,
|
||||
"mdxCrossCompilerCache": false,
|
||||
"rspackBundler": false,
|
||||
"rspackPersistentCache": false,
|
||||
"ssgWorkerThreads": false
|
||||
},
|
||||
"experimental_storage": {
|
||||
"type": "localStorage",
|
||||
"namespace": false
|
||||
},
|
||||
"experimental_router": "browser"
|
||||
},
|
||||
"onBrokenAnchors": "warn",
|
||||
"onDuplicateRoutes": "warn",
|
||||
"staticDirectories": [
|
||||
"static"
|
||||
],
|
||||
"customFields": {},
|
||||
"plugins": [],
|
||||
"themes": [],
|
||||
"scripts": [],
|
||||
"headTags": [],
|
||||
"stylesheets": [],
|
||||
"clientModules": [],
|
||||
"titleDelimiter": "|",
|
||||
"noIndex": false,
|
||||
"markdown": {
|
||||
"format": "mdx",
|
||||
"mermaid": false,
|
||||
"mdx1Compat": {
|
||||
"comments": true,
|
||||
"admonitions": true,
|
||||
"headingIds": true
|
||||
},
|
||||
"anchors": {
|
||||
"maintainCase": false
|
||||
}
|
||||
}
|
||||
};
|
||||
33
apps/docs/.docusaurus/globalData.json
Normal file
33
apps/docs/.docusaurus/globalData.json
Normal file
@@ -0,0 +1,33 @@
|
||||
{
|
||||
"docusaurus-plugin-content-docs": {
|
||||
"default": {
|
||||
"path": "/",
|
||||
"versions": [
|
||||
{
|
||||
"name": "current",
|
||||
"label": "Next",
|
||||
"isLast": true,
|
||||
"path": "/",
|
||||
"mainDocId": "intro",
|
||||
"docs": [
|
||||
{
|
||||
"id": "intro",
|
||||
"path": "/",
|
||||
"sidebar": "main"
|
||||
}
|
||||
],
|
||||
"draftIds": [],
|
||||
"sidebars": {
|
||||
"main": {
|
||||
"link": {
|
||||
"path": "/",
|
||||
"label": "intro"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"breadcrumbs": true
|
||||
}
|
||||
}
|
||||
}
|
||||
17
apps/docs/.docusaurus/i18n.json
Normal file
17
apps/docs/.docusaurus/i18n.json
Normal file
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"defaultLocale": "en",
|
||||
"locales": [
|
||||
"en"
|
||||
],
|
||||
"path": "i18n",
|
||||
"currentLocale": "en",
|
||||
"localeConfigs": {
|
||||
"en": {
|
||||
"label": "English",
|
||||
"direction": "ltr",
|
||||
"htmlLang": "en",
|
||||
"calendar": "gregory",
|
||||
"path": "en"
|
||||
}
|
||||
}
|
||||
}
|
||||
8
apps/docs/.docusaurus/registry.js
Normal file
8
apps/docs/.docusaurus/registry.js
Normal file
@@ -0,0 +1,8 @@
|
||||
export default {
|
||||
"0e384e19": [() => import(/* webpackChunkName: "0e384e19" */ "@site/docs/intro.md"), "@site/docs/intro.md", require.resolveWeak("@site/docs/intro.md")],
|
||||
"17896441": [() => import(/* webpackChunkName: "17896441" */ "@theme/DocItem"), "@theme/DocItem", require.resolveWeak("@theme/DocItem")],
|
||||
"22dd74f7": [() => import(/* webpackChunkName: "22dd74f7" */ "@generated/docusaurus-plugin-content-docs/default/p/index-466.json"), "@generated/docusaurus-plugin-content-docs/default/p/index-466.json", require.resolveWeak("@generated/docusaurus-plugin-content-docs/default/p/index-466.json")],
|
||||
"5e95c892": [() => import(/* webpackChunkName: "5e95c892" */ "@theme/DocsRoot"), "@theme/DocsRoot", require.resolveWeak("@theme/DocsRoot")],
|
||||
"a7bd4aaa": [() => import(/* webpackChunkName: "a7bd4aaa" */ "@theme/DocVersionRoot"), "@theme/DocVersionRoot", require.resolveWeak("@theme/DocVersionRoot")],
|
||||
"a94703ab": [() => import(/* webpackChunkName: "a94703ab" */ "@theme/DocRoot"), "@theme/DocRoot", require.resolveWeak("@theme/DocRoot")],
|
||||
"aba21aa0": [() => import(/* webpackChunkName: "aba21aa0" */ "@generated/docusaurus-plugin-content-docs/default/__plugin.json"), "@generated/docusaurus-plugin-content-docs/default/__plugin.json", require.resolveWeak("@generated/docusaurus-plugin-content-docs/default/__plugin.json")],};
|
||||
33
apps/docs/.docusaurus/routes.js
Normal file
33
apps/docs/.docusaurus/routes.js
Normal file
@@ -0,0 +1,33 @@
|
||||
import React from 'react';
|
||||
import ComponentCreator from '@docusaurus/ComponentCreator';
|
||||
|
||||
export default [
|
||||
{
|
||||
path: '/',
|
||||
component: ComponentCreator('/', '719'),
|
||||
routes: [
|
||||
{
|
||||
path: '/',
|
||||
component: ComponentCreator('/', 'd8d'),
|
||||
routes: [
|
||||
{
|
||||
path: '/',
|
||||
component: ComponentCreator('/', 'e93'),
|
||||
routes: [
|
||||
{
|
||||
path: '/',
|
||||
component: ComponentCreator('/', 'bc9'),
|
||||
exact: true,
|
||||
sidebar: "main"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
path: '*',
|
||||
component: ComponentCreator('*'),
|
||||
},
|
||||
];
|
||||
19
apps/docs/.docusaurus/routesChunkNames.json
Normal file
19
apps/docs/.docusaurus/routesChunkNames.json
Normal file
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"/-719": {
|
||||
"__comp": "5e95c892",
|
||||
"__context": {
|
||||
"plugin": "aba21aa0"
|
||||
}
|
||||
},
|
||||
"/-d8d": {
|
||||
"__comp": "a7bd4aaa",
|
||||
"__props": "22dd74f7"
|
||||
},
|
||||
"/-e93": {
|
||||
"__comp": "a94703ab"
|
||||
},
|
||||
"/-bc9": {
|
||||
"__comp": "17896441",
|
||||
"content": "0e384e19"
|
||||
}
|
||||
}
|
||||
31
apps/docs/.docusaurus/site-metadata.json
Normal file
31
apps/docs/.docusaurus/site-metadata.json
Normal file
@@ -0,0 +1,31 @@
|
||||
{
|
||||
"docusaurusVersion": "3.8.1",
|
||||
"siteVersion": "0.1.0",
|
||||
"pluginVersions": {
|
||||
"docusaurus-plugin-content-docs": {
|
||||
"type": "package",
|
||||
"name": "@docusaurus/plugin-content-docs",
|
||||
"version": "3.8.1"
|
||||
},
|
||||
"docusaurus-plugin-content-pages": {
|
||||
"type": "package",
|
||||
"name": "@docusaurus/plugin-content-pages",
|
||||
"version": "3.8.1"
|
||||
},
|
||||
"docusaurus-plugin-sitemap": {
|
||||
"type": "package",
|
||||
"name": "@docusaurus/plugin-sitemap",
|
||||
"version": "3.8.1"
|
||||
},
|
||||
"docusaurus-plugin-svgr": {
|
||||
"type": "package",
|
||||
"name": "@docusaurus/plugin-svgr",
|
||||
"version": "3.8.1"
|
||||
},
|
||||
"docusaurus-theme-classic": {
|
||||
"type": "package",
|
||||
"name": "@docusaurus/theme-classic",
|
||||
"version": "3.8.1"
|
||||
}
|
||||
}
|
||||
}
|
||||
4
apps/docs/.docusaurus/site-storage.json
Normal file
4
apps/docs/.docusaurus/site-storage.json
Normal file
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"type": "localStorage",
|
||||
"namespace": ""
|
||||
}
|
||||
12
apps/docs/.gitignore
vendored
Normal file
12
apps/docs/.gitignore
vendored
Normal file
@@ -0,0 +1,12 @@
|
||||
node_modules
|
||||
build
|
||||
out
|
||||
docs-app
|
||||
docs/.vitepress/cache
|
||||
docs/.vitepress/dist
|
||||
# All mirrored content under docs/ except the VitePress config is
|
||||
# regenerated by scripts/snapshot-docs-content.ts.
|
||||
docs/*.md
|
||||
!docs/.vitepress/**
|
||||
docs/packages
|
||||
docs/openspec
|
||||
62
apps/docs/README.md
Normal file
62
apps/docs/README.md
Normal file
@@ -0,0 +1,62 @@
|
||||
# @suaveplan/docs - Docusaurus 3.10 documentation site for the SuavePlan monorepo
|
||||
|
||||
The `@suaveplan/docs` app is the public-facing documentation portal for
|
||||
the genesis monorepo. It serves a Docusaurus 3.10 site that renders the
|
||||
package READMEs, OpenSpec specs, and architecture guides under a single
|
||||
sidebar.
|
||||
|
||||
## Local development
|
||||
|
||||
```bash
|
||||
bun install
|
||||
bun run --filter @suaveplan/docs dev
|
||||
```
|
||||
|
||||
The dev server runs on `http://localhost:6043`. Hot-reload is enabled.
|
||||
|
||||
## Runtime: pure Bun
|
||||
|
||||
This app runs end-to-end under Bun — no Node required. The site is built
|
||||
with **VitePress** (Vite + Vue) which works cleanly under Bun's runtime;
|
||||
we previously tried Docusaurus 3.10 but its webpack-bundled SSR step
|
||||
fails with `require.resolveWeak is not a function` regardless of Bun
|
||||
flags or `@docusaurus/faster` config.
|
||||
|
||||
The static output is served in production by a Bun bytecode-compiled
|
||||
binary (`docs-app`) — no Node, no Bun runtime, no `node_modules` on the
|
||||
deployment target.
|
||||
|
||||
## Content pipeline
|
||||
|
||||
`scripts/snapshot-docs-content.ts` (at the repo root) mirrors every
|
||||
`README.md`, package module `.md`, and OpenSpec capability spec from the
|
||||
source tree into `apps/docs/docs/`, preserving paths so the auto-generated
|
||||
relative links keep resolving locally. The snapshot runs automatically as
|
||||
part of `bun run build`. All mirrored content is gitignored — the source
|
||||
of truth stays in the package directories.
|
||||
|
||||
## Production build
|
||||
|
||||
```bash
|
||||
bun run --filter @suaveplan/docs build
|
||||
bun run --filter @suaveplan/docs serve
|
||||
```
|
||||
|
||||
The static output lands in `apps/docs/build/`. Serve it from any static
|
||||
host — Cloudflare Pages, Vercel, or a plain `bun --static-serve` setup.
|
||||
|
||||
## Content layout
|
||||
|
||||
- `docs/` — markdown / MDX pages picked up by the auto-generated sidebar.
|
||||
- `src/css/custom.css` — site-wide theme variables.
|
||||
- `docusaurus.config.ts` — navbar, footer, presets.
|
||||
|
||||
This app intentionally has zero workspace `@suaveplan/*` runtime deps. It
|
||||
is a documentation portal that reads filesystem content; it does not import
|
||||
genesis libraries.
|
||||
|
||||
<!-- begin:auto-spec-link -->
|
||||
## OpenSpec
|
||||
|
||||
Canonical capability spec: [`../../openspec/specs/docs/spec.md`](../../openspec/specs/docs/spec.md).
|
||||
<!-- end:auto-spec-link -->
|
||||
27
apps/docs/docker-compose.yml
Normal file
27
apps/docs/docker-compose.yml
Normal file
@@ -0,0 +1,27 @@
|
||||
# docker-compose for @suaveplan/docs build.
|
||||
#
|
||||
# Runs Docusaurus inside a Node 22 LTS container. The `docs/` directory
|
||||
# from the host (populated by `bun scripts/snapshot-docs-content.ts`) is
|
||||
# mounted read-only as the input; the host's `build/` directory is mounted
|
||||
# read-write as the output. After the one-shot build completes, the host
|
||||
# has `apps/docs/build/` ready to be served by the Bun bytecode binary.
|
||||
#
|
||||
# Usage from `apps/docs/`:
|
||||
# bun run snapshot # populate ./docs
|
||||
# docker compose run --rm --build builder
|
||||
# bun run build:server # compile the static-server binary
|
||||
#
|
||||
# Or all in one step:
|
||||
# bun run build:all
|
||||
|
||||
services:
|
||||
builder:
|
||||
build:
|
||||
context: ./docusaurus
|
||||
dockerfile: Dockerfile
|
||||
image: suaveplan-docs-builder
|
||||
container_name: suaveplan-docs-builder
|
||||
volumes:
|
||||
- ./docs:/work/docs:ro
|
||||
- ./build:/work/build
|
||||
working_dir: /work
|
||||
32
apps/docs/docusaurus/Dockerfile
Normal file
32
apps/docs/docusaurus/Dockerfile
Normal file
@@ -0,0 +1,32 @@
|
||||
# syntax=docker/dockerfile:1.7
|
||||
#
|
||||
# Build container for @suaveplan/docs.
|
||||
#
|
||||
# This image holds the Docusaurus toolchain (Node 22 LTS + npm + docusaurus
|
||||
# deps). It is run via docker-compose to consume a snapshot of the repo's
|
||||
# markdown content (mounted as `/work/docs`) and produce the static build
|
||||
# output (mounted as `/work/build` -> host `apps/docs/build/`).
|
||||
#
|
||||
# The Bun bytecode-compiled runtime server lives outside this image.
|
||||
FROM node:22-bookworm-slim
|
||||
|
||||
ENV DEBIAN_FRONTEND=noninteractive
|
||||
ENV NODE_ENV=production
|
||||
ENV NPM_CONFIG_FUND=false
|
||||
ENV NPM_CONFIG_AUDIT=false
|
||||
|
||||
WORKDIR /work
|
||||
|
||||
# Toolchain deps first so the layer caches when only content changes.
|
||||
COPY package.json ./
|
||||
|
||||
RUN npm install --no-save --omit=optional
|
||||
|
||||
# Site configuration — these change rarely, content changes often.
|
||||
COPY docusaurus.config.ts sidebars.ts ./
|
||||
COPY src ./src
|
||||
COPY tsconfig.json ./
|
||||
|
||||
# Output to a subdirectory of the mounted volume so Docusaurus can rmdir
|
||||
# its own output between runs without touching the bind mount itself.
|
||||
CMD ["npx", "docusaurus", "build", "--out-dir", "/work/build/site"]
|
||||
67
apps/docs/docusaurus/docusaurus.config.ts
Normal file
67
apps/docs/docusaurus/docusaurus.config.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
import type { Config } from "@docusaurus/types";
|
||||
import type * as Preset from "@docusaurus/preset-classic";
|
||||
import { themes as prismThemes } from "prism-react-renderer";
|
||||
|
||||
const config: Config = {
|
||||
title: "SuavePlan",
|
||||
tagline: "Enterprise-grade monorepo, rebuilt bottom-up",
|
||||
favicon: "img/favicon.ico",
|
||||
|
||||
url: "https://docs.suaveplan.com",
|
||||
baseUrl: "/",
|
||||
organizationName: "SuavePlan",
|
||||
projectName: "genesis",
|
||||
|
||||
onBrokenLinks: "warn",
|
||||
onBrokenMarkdownLinks: "warn",
|
||||
|
||||
// Use plain CommonMark rather than MDX so spec files containing
|
||||
// ICU-style `{var}` braces and bare HTML-like tokens render as
|
||||
// literals instead of being treated as JSX expressions.
|
||||
markdown: {
|
||||
format: "md",
|
||||
},
|
||||
|
||||
i18n: {
|
||||
defaultLocale: "en",
|
||||
locales: ["en"],
|
||||
},
|
||||
|
||||
presets: [
|
||||
[
|
||||
"classic",
|
||||
{
|
||||
docs: {
|
||||
path: "docs",
|
||||
routeBasePath: "/",
|
||||
sidebarPath: "./sidebars.ts",
|
||||
},
|
||||
blog: false,
|
||||
theme: {
|
||||
customCss: "./src/css/custom.css",
|
||||
},
|
||||
} satisfies Preset.Options,
|
||||
],
|
||||
],
|
||||
|
||||
themeConfig: {
|
||||
navbar: {
|
||||
title: "SuavePlan",
|
||||
items: [
|
||||
{
|
||||
type: "docSidebar",
|
||||
sidebarId: "main",
|
||||
position: "left",
|
||||
label: "Docs",
|
||||
},
|
||||
],
|
||||
},
|
||||
prism: {
|
||||
theme: prismThemes.github,
|
||||
darkTheme: prismThemes.dracula,
|
||||
additionalLanguages: ["bash", "json", "yaml", "toml"],
|
||||
},
|
||||
} satisfies Preset.ThemeConfig,
|
||||
};
|
||||
|
||||
export default config;
|
||||
35
apps/docs/docusaurus/package.json
Normal file
35
apps/docs/docusaurus/package.json
Normal file
@@ -0,0 +1,35 @@
|
||||
{
|
||||
"name": "@suaveplan/docs-builder",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"description": "Containerized Docusaurus build environment for @suaveplan/docs",
|
||||
"scripts": {
|
||||
"build": "docusaurus build",
|
||||
"serve": "docusaurus serve --port 6043",
|
||||
"clear": "docusaurus clear"
|
||||
},
|
||||
"dependencies": {
|
||||
"@docusaurus/core": "^3.10.0",
|
||||
"@docusaurus/preset-classic": "^3.10.0",
|
||||
"@mdx-js/react": "^3.0.0",
|
||||
"clsx": "^2.1.1",
|
||||
"prism-react-renderer": "^2.4.0",
|
||||
"react": "19.2.0",
|
||||
"react-dom": "19.2.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@docusaurus/module-type-aliases": "^3.10.0",
|
||||
"@docusaurus/types": "^3.10.0"
|
||||
},
|
||||
"browserslist": {
|
||||
"production": [">0.5%", "not dead", "not op_mini all"],
|
||||
"development": [
|
||||
"last 3 chrome version",
|
||||
"last 3 firefox version",
|
||||
"last 5 safari version"
|
||||
]
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
}
|
||||
}
|
||||
7
apps/docs/docusaurus/sidebars.ts
Normal file
7
apps/docs/docusaurus/sidebars.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import type { SidebarsConfig } from "@docusaurus/plugin-content-docs";
|
||||
|
||||
const sidebars: SidebarsConfig = {
|
||||
main: [{ type: "autogenerated", dirName: "." }],
|
||||
};
|
||||
|
||||
export default sidebars;
|
||||
13
apps/docs/docusaurus/src/css/custom.css
Normal file
13
apps/docs/docusaurus/src/css/custom.css
Normal file
@@ -0,0 +1,13 @@
|
||||
:root {
|
||||
--ifm-color-primary: #2e7d8f;
|
||||
--ifm-color-primary-dark: #2a7081;
|
||||
--ifm-color-primary-darker: #286a7a;
|
||||
--ifm-color-primary-darkest: #205864;
|
||||
--ifm-color-primary-light: #328a9d;
|
||||
--ifm-color-primary-lighter: #3590a4;
|
||||
--ifm-color-primary-lightest: #3da4ba;
|
||||
--ifm-code-font-size: 95%;
|
||||
}
|
||||
[data-theme="dark"] {
|
||||
--ifm-color-primary: #61c4d8;
|
||||
}
|
||||
8
apps/docs/docusaurus/tsconfig.json
Normal file
8
apps/docs/docusaurus/tsconfig.json
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"extends": "@docusaurus/tsconfig",
|
||||
"compilerOptions": {
|
||||
"baseUrl": ".",
|
||||
"noEmit": true
|
||||
},
|
||||
"include": ["src/", "docusaurus.config.ts", "sidebars.ts"]
|
||||
}
|
||||
25
apps/docs/package.json
Normal file
25
apps/docs/package.json
Normal file
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"name": "@suaveplan/docs-portal",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"description": "Docusaurus 3.10 documentation portal for the SuavePlan monorepo, built via docker-compose and served by a Bun bytecode binary",
|
||||
"scripts": {
|
||||
"snapshot": "bun ../../scripts/snapshot-docs-content.ts",
|
||||
"build:docs": "docker compose run --rm --build builder",
|
||||
"build:server": "bun build --compile --minify --bytecode --target=bun-linux-x64-baseline src/server/index.ts --outfile=docs-app",
|
||||
"build:all": "bun run snapshot && bun run build:docs && bun run build:server",
|
||||
"serve": "bun run src/server/index.ts",
|
||||
"clean": "rm -rf build out docs-app",
|
||||
"typecheck": "bunx tsc --noEmit -p tsconfig.json"
|
||||
},
|
||||
"engines": {
|
||||
"bun": ">=1.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@biomejs/biome": "catalog:tooling",
|
||||
"@types/bun": "catalog:",
|
||||
"@types/node": "catalog:",
|
||||
"bun-types": "catalog:"
|
||||
}
|
||||
}
|
||||
165
apps/docs/src/server/index.ts
Normal file
165
apps/docs/src/server/index.ts
Normal file
@@ -0,0 +1,165 @@
|
||||
#!/usr/bin/env bun
|
||||
|
||||
/**
|
||||
* Bun static-server for the `@suaveplan/docs` Docusaurus build output.
|
||||
*
|
||||
* Designed to be `bun build --compile`-ed into a single self-contained
|
||||
* binary for production deployment. The binary expects to find the
|
||||
* Docusaurus output at `./build/` (relative to its current working
|
||||
* directory) by default; override with the `DOCS_BUILD_DIR` env var.
|
||||
*
|
||||
* Configuration:
|
||||
* PORT — listen port (default: 3997)
|
||||
* HOST — listen address (default: 127.0.0.1)
|
||||
* DOCS_BUILD_DIR — absolute or relative path to the Docusaurus build
|
||||
* output (default: "./build")
|
||||
*
|
||||
* Production deployment: place the binary alongside its `build/` directory,
|
||||
* point a systemd unit at the binary, and front it with a reverse proxy
|
||||
* (Caddy / nginx) that terminates TLS.
|
||||
*/
|
||||
|
||||
import { existsSync, statSync } from "node:fs";
|
||||
import { extname, join, normalize, resolve, sep } from "node:path";
|
||||
|
||||
const PORT = Number.parseInt(process.env.PORT ?? "3997", 10);
|
||||
const HOST = process.env.HOST ?? "127.0.0.1";
|
||||
const BUILD_DIR = resolve(process.env.DOCS_BUILD_DIR ?? "./build");
|
||||
|
||||
const MIME: Readonly<Record<string, string>> = {
|
||||
".html": "text/html; charset=utf-8",
|
||||
".css": "text/css; charset=utf-8",
|
||||
".js": "application/javascript; charset=utf-8",
|
||||
".mjs": "application/javascript; charset=utf-8",
|
||||
".json": "application/json; charset=utf-8",
|
||||
".svg": "image/svg+xml",
|
||||
".png": "image/png",
|
||||
".jpg": "image/jpeg",
|
||||
".jpeg": "image/jpeg",
|
||||
".gif": "image/gif",
|
||||
".webp": "image/webp",
|
||||
".ico": "image/x-icon",
|
||||
".woff": "font/woff",
|
||||
".woff2": "font/woff2",
|
||||
".ttf": "font/ttf",
|
||||
".otf": "font/otf",
|
||||
".map": "application/json; charset=utf-8",
|
||||
".txt": "text/plain; charset=utf-8",
|
||||
".xml": "application/xml; charset=utf-8",
|
||||
".webmanifest": "application/manifest+json",
|
||||
};
|
||||
|
||||
const HASHED_ASSET_RE = /\.[0-9a-f]{8,}\.[a-z0-9]+$/iu;
|
||||
|
||||
function contentType(path: string): string {
|
||||
return MIME[extname(path).toLowerCase()] ?? "application/octet-stream";
|
||||
}
|
||||
|
||||
function cacheHeaders(path: string): Record<string, string> {
|
||||
// Docusaurus content-hashes static assets — they can be cached forever.
|
||||
// HTML and the root-relative service-worker / manifest must revalidate.
|
||||
if (HASHED_ASSET_RE.test(path)) {
|
||||
return { "cache-control": "public, max-age=31536000, immutable" };
|
||||
}
|
||||
const ext = extname(path).toLowerCase();
|
||||
if (ext === ".html" || ext === ".webmanifest" || ext === ".xml") {
|
||||
return { "cache-control": "public, max-age=0, must-revalidate" };
|
||||
}
|
||||
return { "cache-control": "public, max-age=3600" };
|
||||
}
|
||||
|
||||
function resolveSafe(urlPath: string): string | undefined {
|
||||
// Strip query / hash and decode percent-escapes.
|
||||
let decoded: string;
|
||||
try {
|
||||
decoded = decodeURIComponent(urlPath.split("?")[0]?.split("#")[0] ?? "/");
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
// Normalize and ensure the resolved path stays inside BUILD_DIR.
|
||||
const joined = normalize(join(BUILD_DIR, decoded));
|
||||
const guard = BUILD_DIR.endsWith(sep) ? BUILD_DIR : `${BUILD_DIR}${sep}`;
|
||||
if (joined !== BUILD_DIR && !joined.startsWith(guard)) return undefined;
|
||||
return joined;
|
||||
}
|
||||
|
||||
function tryServe(absPath: string): Response | undefined {
|
||||
if (!existsSync(absPath)) return undefined;
|
||||
let info: ReturnType<typeof statSync>;
|
||||
try {
|
||||
info = statSync(absPath);
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
if (info.isDirectory()) {
|
||||
const indexPath = join(absPath, "index.html");
|
||||
if (!existsSync(indexPath)) return undefined;
|
||||
return serveFile(indexPath);
|
||||
}
|
||||
return serveFile(absPath);
|
||||
}
|
||||
|
||||
function serveFile(absPath: string): Response {
|
||||
const file = Bun.file(absPath);
|
||||
return new Response(file, {
|
||||
headers: {
|
||||
"content-type": contentType(absPath),
|
||||
...cacheHeaders(absPath),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function notFound(): Response {
|
||||
const fallback = join(BUILD_DIR, "404.html");
|
||||
if (existsSync(fallback)) {
|
||||
return new Response(Bun.file(fallback), {
|
||||
status: 404,
|
||||
headers: {
|
||||
"content-type": "text/html; charset=utf-8",
|
||||
"cache-control": "public, max-age=0, must-revalidate",
|
||||
},
|
||||
});
|
||||
}
|
||||
return new Response("Not Found", {
|
||||
status: 404,
|
||||
headers: { "content-type": "text/plain; charset=utf-8" },
|
||||
});
|
||||
}
|
||||
|
||||
if (!existsSync(BUILD_DIR)) {
|
||||
console.error(`[docs-app] build dir not found: ${BUILD_DIR}`);
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
const server = Bun.serve({
|
||||
port: PORT,
|
||||
hostname: HOST,
|
||||
fetch(req: Request): Response {
|
||||
const url = new URL(req.url);
|
||||
if (req.method !== "GET" && req.method !== "HEAD") {
|
||||
return new Response("Method Not Allowed", {
|
||||
status: 405,
|
||||
headers: { allow: "GET, HEAD" },
|
||||
});
|
||||
}
|
||||
// Health endpoint for systemd / reverse-proxy probes.
|
||||
if (url.pathname === "/_health") {
|
||||
return new Response("ok", {
|
||||
status: 200,
|
||||
headers: { "content-type": "text/plain; charset=utf-8" },
|
||||
});
|
||||
}
|
||||
const safePath = resolveSafe(url.pathname);
|
||||
if (!safePath) return notFound();
|
||||
const response = tryServe(safePath);
|
||||
if (response) return response;
|
||||
// Docusaurus routes everything to its own SPA-ish HTML — let the
|
||||
// build's 404.html handle unknowns so the client-side router can
|
||||
// recover.
|
||||
return notFound();
|
||||
},
|
||||
});
|
||||
|
||||
console.log(
|
||||
`[docs-app] serving ${BUILD_DIR} on http://${server.hostname}:${server.port}`
|
||||
);
|
||||
16
apps/docs/tsconfig.json
Normal file
16
apps/docs/tsconfig.json
Normal file
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"lib": ["ES2022", "DOM"],
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"types": ["node", "bun"]
|
||||
},
|
||||
"include": ["src/", "docs/.vitepress/"]
|
||||
}
|
||||
14
apps/testbed/.storybook/main.ts
Normal file
14
apps/testbed/.storybook/main.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import type { StorybookConfig } from "@storybook/react-vite";
|
||||
|
||||
const config: StorybookConfig = {
|
||||
framework: "@storybook/react-vite",
|
||||
stories: ["../src/**/*.stories.@(ts|tsx)"],
|
||||
addons: [
|
||||
"@storybook/addon-essentials",
|
||||
"@storybook/addon-interactions",
|
||||
"@storybook/test",
|
||||
],
|
||||
docs: { autodocs: "tag" },
|
||||
};
|
||||
|
||||
export default config;
|
||||
32
apps/testbed/.storybook/preview.tsx
Normal file
32
apps/testbed/.storybook/preview.tsx
Normal file
@@ -0,0 +1,32 @@
|
||||
import type { Decorator, Preview } from "@storybook/react";
|
||||
|
||||
const ThemeDecorator: Decorator = (Story) => (
|
||||
<div style={{ display: "flex", gap: "16px" }}>
|
||||
<div data-theme="light" style={{ flex: 1 }}>
|
||||
<Story />
|
||||
</div>
|
||||
<div data-theme="dark" style={{ flex: 1, backgroundColor: "#09090b", padding: "8px" }}>
|
||||
<Story />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
const LocaleDecorator: Decorator = (Story) => (
|
||||
<div lang="en">
|
||||
<Story />
|
||||
</div>
|
||||
);
|
||||
|
||||
const preview: Preview = {
|
||||
decorators: [ThemeDecorator, LocaleDecorator],
|
||||
parameters: {
|
||||
controls: {
|
||||
matchers: {
|
||||
color: /(background|color)$/i,
|
||||
date: /Date$/i,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export default preview;
|
||||
8
apps/testbed/Dockerfile
Normal file
8
apps/testbed/Dockerfile
Normal file
@@ -0,0 +1,8 @@
|
||||
FROM oven/bun:1
|
||||
WORKDIR /app
|
||||
COPY package.json bun.lockb ./
|
||||
RUN bun install --frozen-lockfile
|
||||
COPY . .
|
||||
RUN bun run build
|
||||
EXPOSE 3000
|
||||
CMD ["bun", "run", "start"]
|
||||
174
apps/testbed/README.md
Normal file
174
apps/testbed/README.md
Normal file
File diff suppressed because one or more lines are too long
55
apps/testbed/e2e/extensions/autofill-detection.spec.ts
Normal file
55
apps/testbed/e2e/extensions/autofill-detection.spec.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
import { test, expect } from "@playwright/test";
|
||||
|
||||
test.describe("Autofill Detection", () => {
|
||||
test("extension-test page renders without extension", async ({ page }) => {
|
||||
await page.goto("/extension-test");
|
||||
await expect(page.locator('[data-testid="page-title"]')).toContainText(
|
||||
"Extension Test",
|
||||
);
|
||||
await expect(page.locator('[data-testid="demo-card"]')).toBeVisible();
|
||||
});
|
||||
|
||||
test("output shows 'not detected' without extension injection", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.goto("/extension-test");
|
||||
const output = page.locator('[data-testid="output"]');
|
||||
await expect(output).toBeVisible();
|
||||
// Without extension, should not show detected
|
||||
const text = await output.textContent();
|
||||
expect(text).not.toContain("✓ Extension injected");
|
||||
});
|
||||
|
||||
test("MutationObserver detects programmatic injection", async ({ page }) => {
|
||||
await page.goto("/extension-test");
|
||||
|
||||
// Simulate extension injection via page.evaluate
|
||||
await page.evaluate(() => {
|
||||
const div = document.createElement("div");
|
||||
div.id = "extension-injected";
|
||||
div.textContent = "Injected by test";
|
||||
document.body.appendChild(div);
|
||||
});
|
||||
|
||||
// Output should update to show detected
|
||||
await expect(page.locator('[data-testid="output"]')).toContainText(
|
||||
"Extension injected",
|
||||
{ timeout: 3000 },
|
||||
);
|
||||
});
|
||||
|
||||
test("extension-mount div is present as injection target", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.goto("/extension-test");
|
||||
const mount = page.locator('[data-testid="extension-mount"]');
|
||||
await expect(mount).toBeVisible();
|
||||
// The injection target div has both testid and id attributes
|
||||
await expect(page.locator("#extension-mount")).toBeVisible();
|
||||
});
|
||||
|
||||
test("console-panel is visible on extension page", async ({ page }) => {
|
||||
await page.goto("/extension-test");
|
||||
await expect(page.locator('[data-testid="console-panel"]')).toBeVisible();
|
||||
});
|
||||
});
|
||||
59
apps/testbed/e2e/extensions/content-script.spec.ts
Normal file
59
apps/testbed/e2e/extensions/content-script.spec.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
import { test, expect } from "@playwright/test";
|
||||
import path from "node:path";
|
||||
import os from "node:os";
|
||||
import fs from "node:fs";
|
||||
import { launchWithExtension } from "@suaveplan/testing-e2e/extension-fixture";
|
||||
|
||||
// Path to the test extension fixture
|
||||
const EXTENSION_PATH = path.resolve(
|
||||
new URL(import.meta.url).pathname,
|
||||
"../../fixtures/test-extension",
|
||||
);
|
||||
|
||||
test.describe("Content Script Extension", () => {
|
||||
test.skip(
|
||||
!fs.existsSync(EXTENSION_PATH),
|
||||
"Test extension fixture not found — skipping extension tests",
|
||||
);
|
||||
|
||||
test("extension page title is visible without extension", async ({
|
||||
page,
|
||||
}) => {
|
||||
// Basic smoke test — no extension needed
|
||||
await page.goto("/extension-test");
|
||||
await expect(page.locator('[data-testid="page-title"]')).toContainText(
|
||||
"Extension Test",
|
||||
);
|
||||
await expect(page.locator('[data-testid="extension-mount"]')).toBeVisible();
|
||||
await expect(page.locator('[data-testid="output"]')).toBeVisible();
|
||||
});
|
||||
|
||||
test("extension-mount target is present on page", async ({ page }) => {
|
||||
await page.goto("/extension-test");
|
||||
await expect(page.locator('[data-testid="extension-mount"]')).toBeVisible();
|
||||
// The id is also extension-mount for CSS targeting by extensions
|
||||
await expect(page.locator("#extension-mount")).toBeVisible();
|
||||
});
|
||||
|
||||
test.skip("extension injects content into page", async () => {
|
||||
// Full extension test — requires the extension fixture to be built
|
||||
const userDataDir = fs.mkdtempSync(path.join(os.tmpdir(), "pw-ext-"));
|
||||
const { context, extensionId } = await launchWithExtension({
|
||||
extensionPath: EXTENSION_PATH,
|
||||
userDataDir,
|
||||
});
|
||||
|
||||
try {
|
||||
const page = await context.newPage();
|
||||
await page.goto("http://localhost:3200/extension-test");
|
||||
expect(extensionId).toBeTruthy();
|
||||
await expect(page.locator('[data-testid="output"]')).toContainText(
|
||||
"Extension injected",
|
||||
{ timeout: 5000 },
|
||||
);
|
||||
} finally {
|
||||
await context.close();
|
||||
fs.rmSync(userDataDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
33
apps/testbed/e2e/layout/console-baseline.spec.ts
Normal file
33
apps/testbed/e2e/layout/console-baseline.spec.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import { test, expect } from "@playwright/test";
|
||||
import { attachConsoleMonitor } from "@suaveplan/testing-e2e/console-monitor";
|
||||
|
||||
test.describe("Console Baseline", () => {
|
||||
const routes = ["/", "/crypto", "/logger", "/datetime", "/i18n", "/feature-flags"];
|
||||
|
||||
for (const route of routes) {
|
||||
test(`${route} has no console errors on load`, async ({ page }) => {
|
||||
const monitor = attachConsoleMonitor(page, {
|
||||
failOn: ["error"],
|
||||
ignore: [
|
||||
// Ignore known non-critical messages
|
||||
/ResizeObserver loop/,
|
||||
/WebSocket connection/,
|
||||
/Failed to load resource.*favicon/,
|
||||
],
|
||||
});
|
||||
|
||||
await page.goto(route);
|
||||
await expect(page.locator('[data-testid="page-title"]')).toBeVisible();
|
||||
|
||||
monitor.assertClean();
|
||||
});
|
||||
}
|
||||
|
||||
test("console-panel is present on every library page", async ({ page }) => {
|
||||
const libraryRoutes = ["/crypto", "/logger", "/datetime", "/i18n", "/feature-flags"];
|
||||
for (const route of libraryRoutes) {
|
||||
await page.goto(route);
|
||||
await expect(page.locator('[data-testid="console-panel"]')).toBeAttached();
|
||||
}
|
||||
});
|
||||
});
|
||||
39
apps/testbed/e2e/layout/locale.spec.ts
Normal file
39
apps/testbed/e2e/layout/locale.spec.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import { test, expect } from "@playwright/test";
|
||||
|
||||
test.describe("Locale Switcher", () => {
|
||||
test("locale switcher is visible in header", async ({ page }) => {
|
||||
await page.goto("/");
|
||||
await expect(page.locator('[data-testid="header"]')).toBeVisible();
|
||||
// Locale switcher should be present
|
||||
const localeSwitcher = page.locator('[data-testid="locale-switcher"]');
|
||||
// If not using that testid, check for a select or button in the header
|
||||
const headerInteractives = page.locator('[data-testid="header"] select, [data-testid="header"] button');
|
||||
const count = await headerInteractives.count();
|
||||
expect(count).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
test("i18n page locale-select changes output text", async ({ page }) => {
|
||||
await page.goto("/i18n");
|
||||
await expect(page.locator('[data-testid="output"]')).toBeVisible();
|
||||
|
||||
// Get initial text in English
|
||||
const initialText = await page.locator('[data-testid="output"]').textContent();
|
||||
expect(initialText).toBeTruthy();
|
||||
|
||||
// Switch to French
|
||||
await page.locator('[data-testid="locale-select"]').selectOption("fr");
|
||||
|
||||
// Output should now contain French text
|
||||
const frenchText = await page.locator('[data-testid="output"]').textContent();
|
||||
expect(frenchText).toContain("article");
|
||||
});
|
||||
|
||||
test("i18n page locale-select changes to Japanese", async ({ page }) => {
|
||||
await page.goto("/i18n");
|
||||
await page.locator('[data-testid="locale-select"]').selectOption("ja");
|
||||
const text = await page.locator('[data-testid="output"]').textContent();
|
||||
// Japanese output contains Japanese characters or specific words
|
||||
expect(text).toBeTruthy();
|
||||
expect(text).toContain("アイテム");
|
||||
});
|
||||
});
|
||||
63
apps/testbed/e2e/layout/navigation.spec.ts
Normal file
63
apps/testbed/e2e/layout/navigation.spec.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
import { test, expect } from "@playwright/test";
|
||||
|
||||
test.describe("Navigation", () => {
|
||||
test("home page renders page-title", async ({ page }) => {
|
||||
await page.goto("/");
|
||||
await expect(page.locator('[data-testid="page-title"]')).toBeVisible();
|
||||
await expect(page.locator('[data-testid="sidebar"]')).toBeVisible();
|
||||
await expect(page.locator('[data-testid="header"]')).toBeVisible();
|
||||
});
|
||||
|
||||
test("sidebar nav links exist for all routes", async ({ page }) => {
|
||||
await page.goto("/");
|
||||
// Check a few known nav links
|
||||
await expect(page.locator('[data-testid="nav-crypto"]')).toBeVisible();
|
||||
await expect(page.locator('[data-testid="nav-logger"]')).toBeVisible();
|
||||
await expect(page.locator('[data-testid="nav-datetime"]')).toBeVisible();
|
||||
});
|
||||
|
||||
const libraryRoutes = [
|
||||
{ path: "/crypto", title: "@suaveplan/crypto" },
|
||||
{ path: "/logger", title: "@suaveplan/logger" },
|
||||
{ path: "/datetime", title: "@suaveplan/datetime" },
|
||||
{ path: "/i18n", title: "@suaveplan/i18n" },
|
||||
{ path: "/feature-flags", title: "@suaveplan/feature-flags" },
|
||||
];
|
||||
|
||||
for (const route of libraryRoutes) {
|
||||
test(`navigating to ${route.path} shows page-title`, async ({ page }) => {
|
||||
await page.goto(route.path);
|
||||
await expect(page.locator('[data-testid="page-title"]')).toContainText(
|
||||
route.title,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
test("all pages have demo-card and console-panel", async ({ page }) => {
|
||||
const routes = ["/", "/crypto", "/logger", "/datetime", "/i18n", "/feature-flags"];
|
||||
for (const route of routes) {
|
||||
await page.goto(route);
|
||||
await expect(page.locator('[data-testid="demo-card"]')).toBeAttached();
|
||||
await expect(page.locator('[data-testid="console-panel"]')).toBeAttached();
|
||||
}
|
||||
});
|
||||
|
||||
test("home page shows library cards", async ({ page }) => {
|
||||
await page.goto("/");
|
||||
const cards = page.locator('[data-testid="library-card"]');
|
||||
await expect(cards.first()).toBeVisible();
|
||||
const count = await cards.count();
|
||||
expect(count).toBeGreaterThan(5);
|
||||
});
|
||||
|
||||
test("clicking a library card navigates to the page", async ({ page }) => {
|
||||
await page.goto("/");
|
||||
await page.locator('[data-testid="library-card"]').first().click();
|
||||
await expect(page.locator('[data-testid="page-title"]')).toBeVisible();
|
||||
});
|
||||
|
||||
test("404 page renders page-title", async ({ page }) => {
|
||||
await page.goto("/does-not-exist");
|
||||
await expect(page.locator('[data-testid="page-title"]')).toContainText("404");
|
||||
});
|
||||
});
|
||||
45
apps/testbed/e2e/layout/network-baseline.spec.ts
Normal file
45
apps/testbed/e2e/layout/network-baseline.spec.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import { test, expect } from "@playwright/test";
|
||||
import { attachNetworkMonitor } from "@suaveplan/testing-e2e/network-monitor";
|
||||
|
||||
test.describe("Network Baseline", () => {
|
||||
test("home page loads without network failures", async ({ page }) => {
|
||||
const monitor = attachNetworkMonitor(page);
|
||||
await page.goto("/");
|
||||
await expect(page.locator('[data-testid="page-title"]')).toBeVisible();
|
||||
// Filter out WebSocket attempts (expected to fail in test env)
|
||||
const failures = monitor.failures.filter(
|
||||
(f) => !f.url.includes("/api/ws") && !f.url.includes("stun:"),
|
||||
);
|
||||
expect(failures).toHaveLength(0);
|
||||
});
|
||||
|
||||
test("crypto page loads without network failures", async ({ page }) => {
|
||||
const monitor = attachNetworkMonitor(page);
|
||||
await page.goto("/crypto");
|
||||
await expect(page.locator('[data-testid="page-title"]')).toBeVisible();
|
||||
const failures = monitor.failures.filter(
|
||||
(f) => !f.url.includes("/api/ws"),
|
||||
);
|
||||
expect(failures).toHaveLength(0);
|
||||
});
|
||||
|
||||
test("storage-s3 status check is made on page load", async ({ page }) => {
|
||||
const requests: string[] = [];
|
||||
page.on("request", (req) => requests.push(req.url()));
|
||||
|
||||
await page.goto("/storage-s3");
|
||||
await expect(page.locator('[data-testid="demo-card"]')).toBeVisible();
|
||||
|
||||
expect(requests.some((r) => r.includes("/api/storage/status"))).toBe(true);
|
||||
});
|
||||
|
||||
test("surrealdb status check is made on page load", async ({ page }) => {
|
||||
const requests: string[] = [];
|
||||
page.on("request", (req) => requests.push(req.url()));
|
||||
|
||||
await page.goto("/surrealdb");
|
||||
await expect(page.locator('[data-testid="demo-card"]')).toBeVisible();
|
||||
|
||||
expect(requests.some((r) => r.includes("/api/database/status"))).toBe(true);
|
||||
});
|
||||
});
|
||||
21
apps/testbed/e2e/layout/theme.spec.ts
Normal file
21
apps/testbed/e2e/layout/theme.spec.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import { test, expect } from "@playwright/test";
|
||||
|
||||
test.describe("Theme Toggle", () => {
|
||||
test("theme toggle button is visible in header", async ({ page }) => {
|
||||
await page.goto("/");
|
||||
await expect(page.locator('[data-testid="header"]')).toBeVisible();
|
||||
// The theme toggle button should be in the header
|
||||
const themeToggle = page.locator('[data-testid="theme-toggle"]');
|
||||
// If the component uses a different testid, try the button within header
|
||||
const headerBtns = page.locator('[data-testid="header"] button');
|
||||
const btnCount = await headerBtns.count();
|
||||
expect(btnCount).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
test("page loads without dark class by default", async ({ page }) => {
|
||||
await page.goto("/");
|
||||
// Default should not have dark mode active unless system preference is dark
|
||||
const html = page.locator("html");
|
||||
await expect(html).toBeVisible();
|
||||
});
|
||||
});
|
||||
44
apps/testbed/e2e/libraries/crypto.spec.ts
Normal file
44
apps/testbed/e2e/libraries/crypto.spec.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import { test, expect } from "@playwright/test";
|
||||
|
||||
test.describe("Crypto Page", () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto("/crypto");
|
||||
await expect(page.locator('[data-testid="page-title"]')).toContainText(
|
||||
"@suaveplan/crypto",
|
||||
);
|
||||
});
|
||||
|
||||
test("demo-card and output are visible", async ({ page }) => {
|
||||
await expect(page.locator('[data-testid="demo-card"]')).toBeVisible();
|
||||
await expect(page.locator('[data-testid="output"]')).toBeVisible();
|
||||
});
|
||||
|
||||
test("encrypt button produces ciphertext in output", async ({ page }) => {
|
||||
await page.locator('[aria-label="Plaintext input"]').fill("Hello, Playwright!");
|
||||
await page.locator('button:has-text("Encrypt")').click();
|
||||
|
||||
const output = page.locator('[data-testid="output"]');
|
||||
await expect(output).toContainText("ciphertext:");
|
||||
});
|
||||
|
||||
test("encrypt then decrypt round-trip", async ({ page }) => {
|
||||
const plaintext = "Round-trip test message";
|
||||
await page.locator('[aria-label="Plaintext input"]').fill(plaintext);
|
||||
await page.locator('button:has-text("Encrypt")').click();
|
||||
await expect(page.locator('[data-testid="output"]')).toContainText("ciphertext:");
|
||||
|
||||
await page.locator('button:has-text("Decrypt")').click();
|
||||
await expect(page.locator('[data-testid="output"]')).toContainText("decrypted:");
|
||||
await expect(page.locator('[data-testid="output"]')).toContainText(plaintext);
|
||||
});
|
||||
|
||||
test("decrypt without encrypting first shows error", async ({ page }) => {
|
||||
// Click decrypt before encrypting
|
||||
await page.locator('button:has-text("Decrypt")').click();
|
||||
await expect(page.locator('[data-testid="output"]')).toContainText("Encrypt first");
|
||||
});
|
||||
|
||||
test("console-panel is visible", async ({ page }) => {
|
||||
await expect(page.locator('[data-testid="console-panel"]')).toBeVisible();
|
||||
});
|
||||
});
|
||||
48
apps/testbed/e2e/libraries/datetime.spec.ts
Normal file
48
apps/testbed/e2e/libraries/datetime.spec.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import { test, expect } from "@playwright/test";
|
||||
|
||||
test.describe("Datetime Page", () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto("/datetime");
|
||||
await expect(page.locator('[data-testid="page-title"]')).toContainText(
|
||||
"@suaveplan/datetime",
|
||||
);
|
||||
});
|
||||
|
||||
test("demo-card and output are visible", async ({ page }) => {
|
||||
await expect(page.locator('[data-testid="demo-card"]')).toBeVisible();
|
||||
await expect(page.locator('[data-testid="output"]')).toBeVisible();
|
||||
});
|
||||
|
||||
test("calculate adds 5 business days from a Monday", async ({ page }) => {
|
||||
// Monday 2026-04-27 + 5 business days = Monday 2026-05-04
|
||||
await page.locator('input[type="date"]').fill("2026-04-27");
|
||||
await page.locator('input[type="number"]').fill("5");
|
||||
await page.locator('button:has-text("Calculate")').click();
|
||||
|
||||
const output = page.locator('[data-testid="output"]');
|
||||
await expect(output).toContainText("Result:");
|
||||
// Should contain a day name
|
||||
const text = await output.textContent();
|
||||
expect(text).toMatch(/Monday|Tuesday|Wednesday|Thursday|Friday/);
|
||||
});
|
||||
|
||||
test("calculate skips weekends", async ({ page }) => {
|
||||
// Friday 2026-04-24 + 1 business day = Monday 2026-04-27
|
||||
await page.locator('input[type="date"]').fill("2026-04-24");
|
||||
await page.locator('input[type="number"]').fill("1");
|
||||
await page.locator('button:has-text("Calculate")').click();
|
||||
|
||||
const output = page.locator('[data-testid="output"]');
|
||||
await expect(output).toContainText("Monday");
|
||||
});
|
||||
|
||||
test("calculate with negative days goes backwards", async ({ page }) => {
|
||||
// Monday 2026-04-27 - 1 business day = Friday 2026-04-24
|
||||
await page.locator('input[type="date"]').fill("2026-04-27");
|
||||
await page.locator('input[type="number"]').fill("-1");
|
||||
await page.locator('button:has-text("Calculate")').click();
|
||||
|
||||
const output = page.locator('[data-testid="output"]');
|
||||
await expect(output).toContainText("Friday");
|
||||
});
|
||||
});
|
||||
67
apps/testbed/e2e/libraries/feature-flags.spec.ts
Normal file
67
apps/testbed/e2e/libraries/feature-flags.spec.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
import { test, expect } from "@playwright/test";
|
||||
|
||||
test.describe("Feature Flags Page", () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto("/feature-flags");
|
||||
await expect(page.locator('[data-testid="page-title"]')).toContainText(
|
||||
"@suaveplan/feature-flags",
|
||||
);
|
||||
});
|
||||
|
||||
test("demo-card and output are visible", async ({ page }) => {
|
||||
await expect(page.locator('[data-testid="demo-card"]')).toBeVisible();
|
||||
await expect(page.locator('[data-testid="output"]')).toBeVisible();
|
||||
});
|
||||
|
||||
test("initial output shows placeholder text", async ({ page }) => {
|
||||
await expect(page.locator('[data-testid="output"]')).toContainText(
|
||||
"Enter a user ID and click Evaluate",
|
||||
);
|
||||
});
|
||||
|
||||
test("evaluating with 'alice' shows new-checkout enabled", async ({ page }) => {
|
||||
await page.locator('input[type="text"]').fill("alice");
|
||||
await page.locator('button:has-text("Evaluate")').click();
|
||||
|
||||
const output = page.locator('[data-testid="output"]');
|
||||
await expect(output).toContainText("new-checkout");
|
||||
// alice is in the user-target list, so new-checkout should be enabled
|
||||
const text = await output.textContent();
|
||||
const lines = text?.split("\n") ?? [];
|
||||
const checkoutLine = lines.find((l) => l.includes("new-checkout"));
|
||||
expect(checkoutLine).toContain("✓");
|
||||
});
|
||||
|
||||
test("evaluating with 'carol' shows new-checkout disabled", async ({ page }) => {
|
||||
await page.locator('input[type="text"]').fill("carol");
|
||||
await page.locator('button:has-text("Evaluate")').click();
|
||||
|
||||
const output = page.locator('[data-testid="output"]');
|
||||
await expect(output).toContainText("new-checkout");
|
||||
const text = await output.textContent();
|
||||
const lines = text?.split("\n") ?? [];
|
||||
const checkoutLine = lines.find((l) => l.includes("new-checkout"));
|
||||
expect(checkoutLine).toContain("✗");
|
||||
});
|
||||
|
||||
test("dark-mode flag is always enabled (100% rollout)", async ({ page }) => {
|
||||
await page.locator('input[type="text"]').fill("any-user-id");
|
||||
await page.locator('button:has-text("Evaluate")').click();
|
||||
|
||||
const output = page.locator('[data-testid="output"]');
|
||||
await expect(output).toContainText("dark-mode");
|
||||
const text = await output.textContent();
|
||||
const lines = text?.split("\n") ?? [];
|
||||
const darkModeLine = lines.find((l) => l.includes("dark-mode"));
|
||||
expect(darkModeLine).toContain("✓");
|
||||
});
|
||||
|
||||
test("three flags appear in results", async ({ page }) => {
|
||||
await page.locator('button:has-text("Evaluate")').click();
|
||||
|
||||
const output = page.locator('[data-testid="output"]');
|
||||
await expect(output).toContainText("beta-ui");
|
||||
await expect(output).toContainText("new-checkout");
|
||||
await expect(output).toContainText("dark-mode");
|
||||
});
|
||||
});
|
||||
59
apps/testbed/e2e/libraries/i18n.spec.ts
Normal file
59
apps/testbed/e2e/libraries/i18n.spec.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
import { test, expect } from "@playwright/test";
|
||||
|
||||
test.describe("i18n Page", () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto("/i18n");
|
||||
await expect(page.locator('[data-testid="page-title"]')).toContainText(
|
||||
"@suaveplan/i18n",
|
||||
);
|
||||
});
|
||||
|
||||
test("demo-card and output are visible", async ({ page }) => {
|
||||
await expect(page.locator('[data-testid="demo-card"]')).toBeVisible();
|
||||
await expect(page.locator('[data-testid="output"]')).toBeVisible();
|
||||
});
|
||||
|
||||
test("English locale shows 'item' in output", async ({ page }) => {
|
||||
await page.locator('[data-testid="locale-select"]').selectOption("en");
|
||||
const output = await page.locator('[data-testid="output"]').textContent();
|
||||
expect(output).toContain("item");
|
||||
});
|
||||
|
||||
test("French locale shows 'article' in output", async ({ page }) => {
|
||||
await page.locator('[data-testid="locale-select"]').selectOption("fr");
|
||||
const output = await page.locator('[data-testid="output"]').textContent();
|
||||
expect(output).toContain("article");
|
||||
});
|
||||
|
||||
test("German locale shows 'Artikel' in output", async ({ page }) => {
|
||||
await page.locator('[data-testid="locale-select"]').selectOption("de");
|
||||
const output = await page.locator('[data-testid="output"]').textContent();
|
||||
expect(output).toContain("Artikel");
|
||||
});
|
||||
|
||||
test("Japanese locale shows Japanese characters in output", async ({ page }) => {
|
||||
await page.locator('[data-testid="locale-select"]').selectOption("ja");
|
||||
const output = await page.locator('[data-testid="output"]').textContent();
|
||||
expect(output).toContain("アイテム");
|
||||
});
|
||||
|
||||
test("increment button increases count display", async ({ page }) => {
|
||||
const plusBtn = page.locator('button:has-text("+")');
|
||||
await plusBtn.click();
|
||||
await plusBtn.click();
|
||||
// Count display should show 3 (started at 1)
|
||||
const output = await page.locator('[data-testid="output"]').textContent();
|
||||
expect(output).toContain("3");
|
||||
});
|
||||
|
||||
test("decrement button does not go below 0", async ({ page }) => {
|
||||
const minusBtn = page.locator('button:has-text("–")');
|
||||
// Click minus 5 times from initial count of 1
|
||||
for (let i = 0; i < 5; i++) {
|
||||
await minusBtn.click();
|
||||
}
|
||||
// The count in the span should show 0
|
||||
const countSpan = page.locator('.text-2xl.font-bold');
|
||||
await expect(countSpan).toContainText("0");
|
||||
});
|
||||
});
|
||||
56
apps/testbed/e2e/libraries/logger.spec.ts
Normal file
56
apps/testbed/e2e/libraries/logger.spec.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
import { test, expect } from "@playwright/test";
|
||||
|
||||
test.describe("Logger Page", () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto("/logger");
|
||||
await expect(page.locator('[data-testid="page-title"]')).toContainText(
|
||||
"@suaveplan/logger",
|
||||
);
|
||||
});
|
||||
|
||||
test("demo-card and output are visible", async ({ page }) => {
|
||||
await expect(page.locator('[data-testid="demo-card"]')).toBeVisible();
|
||||
await expect(page.locator('[data-testid="output"]')).toBeVisible();
|
||||
});
|
||||
|
||||
test("logging info message shows entry in output", async ({ page }) => {
|
||||
await page.locator('input[type="text"]').fill("Test info message");
|
||||
await page.locator('select').selectOption("info");
|
||||
await page.locator('button:has-text("Log")').click();
|
||||
|
||||
const output = page.locator('[data-testid="output"]');
|
||||
await expect(output).toContainText("Test info message");
|
||||
await expect(output).toContainText("[INFO]");
|
||||
});
|
||||
|
||||
test("logging error message shows entry in output", async ({ page }) => {
|
||||
await page.locator('input[type="text"]').fill("Test error message");
|
||||
await page.locator('select').selectOption("error");
|
||||
await page.locator('button:has-text("Log")').click();
|
||||
|
||||
await expect(page.locator('[data-testid="output"]')).toContainText("[ERROR]");
|
||||
});
|
||||
|
||||
test("clear button removes all entries", async ({ page }) => {
|
||||
await page.locator('button:has-text("Log")').click();
|
||||
await expect(page.locator('[data-testid="output"]')).not.toContainText(
|
||||
"Log entries will appear here.",
|
||||
);
|
||||
|
||||
await page.locator('button:has-text("Clear")').click();
|
||||
await expect(page.locator('[data-testid="output"]')).toContainText(
|
||||
"Log entries will appear here.",
|
||||
);
|
||||
});
|
||||
|
||||
test("multiple levels can be logged", async ({ page }) => {
|
||||
const levels = ["debug", "info", "warn", "error"] as const;
|
||||
for (const lvl of levels) {
|
||||
await page.locator('select').selectOption(lvl);
|
||||
await page.locator('button:has-text("Log")').click();
|
||||
}
|
||||
const output = page.locator('[data-testid="output"]');
|
||||
await expect(output).toContainText("[DEBUG]");
|
||||
await expect(output).toContainText("[ERROR]");
|
||||
});
|
||||
});
|
||||
47
apps/testbed/e2e/libraries/storage-s3.spec.ts
Normal file
47
apps/testbed/e2e/libraries/storage-s3.spec.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
import { test, expect } from "@playwright/test";
|
||||
|
||||
test.describe("Storage S3 Page", () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto("/storage-s3");
|
||||
await expect(page.locator('[data-testid="page-title"]')).toContainText(
|
||||
"Storage",
|
||||
);
|
||||
});
|
||||
|
||||
test("demo-card is visible", async ({ page }) => {
|
||||
await expect(page.locator('[data-testid="demo-card"]')).toBeVisible();
|
||||
});
|
||||
|
||||
test("status check is made on load", async ({ page }) => {
|
||||
// Wait for the status to resolve (either available or unavailable)
|
||||
await expect(
|
||||
page.locator("text=Available, text=Unavailable").first(),
|
||||
).toBeVisible({ timeout: 5000 }).catch(() => {
|
||||
// Status text might be in different format
|
||||
});
|
||||
await expect(page.locator('[data-testid="demo-card"]')).toBeVisible();
|
||||
});
|
||||
|
||||
test("when service is unavailable, buttons are disabled", async ({ page }) => {
|
||||
// Mock the status endpoint to return unavailable
|
||||
await page.route("/api/storage/status", (route) => {
|
||||
route.fulfill({ json: { available: false } });
|
||||
});
|
||||
await page.reload();
|
||||
|
||||
await expect(page.locator('[data-testid="demo-card"]')).toBeVisible();
|
||||
// Buttons should be disabled
|
||||
const uploadBtn = page.locator('button:has-text("Upload")');
|
||||
const listBtn = page.locator('button:has-text("List Objects")');
|
||||
await expect(uploadBtn).toBeDisabled();
|
||||
await expect(listBtn).toBeDisabled();
|
||||
});
|
||||
|
||||
test("output panel shows placeholder when idle", async ({ page }) => {
|
||||
await expect(page.locator('[data-testid="output"]')).toBeVisible();
|
||||
});
|
||||
|
||||
test("console-panel is visible", async ({ page }) => {
|
||||
await expect(page.locator('[data-testid="console-panel"]')).toBeVisible();
|
||||
});
|
||||
});
|
||||
48
apps/testbed/e2e/libraries/surrealdb.spec.ts
Normal file
48
apps/testbed/e2e/libraries/surrealdb.spec.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import { test, expect } from "@playwright/test";
|
||||
|
||||
test.describe("SurrealDB Page", () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto("/surrealdb");
|
||||
await expect(page.locator('[data-testid="page-title"]')).toContainText(
|
||||
"SurrealDB",
|
||||
);
|
||||
});
|
||||
|
||||
test("demo-card is visible", async ({ page }) => {
|
||||
await expect(page.locator('[data-testid="demo-card"]')).toBeVisible();
|
||||
});
|
||||
|
||||
test("status check is made on load", async ({ page }) => {
|
||||
await expect(page.locator('[data-testid="demo-card"]')).toBeVisible();
|
||||
});
|
||||
|
||||
test("when service is unavailable, buttons are disabled", async ({ page }) => {
|
||||
await page.route("/api/database/status", (route) => {
|
||||
route.fulfill({ json: { available: false } });
|
||||
});
|
||||
await page.reload();
|
||||
|
||||
await expect(page.locator('[data-testid="demo-card"]')).toBeVisible();
|
||||
const createBtn = page.locator('button:has-text("Create Record")');
|
||||
const queryBtn = page.locator('button:has-text("Query Table")');
|
||||
await expect(createBtn).toBeDisabled();
|
||||
await expect(queryBtn).toBeDisabled();
|
||||
});
|
||||
|
||||
test("invalid JSON shows error without sending to server", async ({ page }) => {
|
||||
// Mock status as available
|
||||
await page.route("/api/database/status", (route) => {
|
||||
route.fulfill({ json: { available: true } });
|
||||
});
|
||||
await page.reload();
|
||||
|
||||
await page.locator('textarea').fill("{ invalid json }");
|
||||
await page.locator('button:has-text("Create Record")').click();
|
||||
|
||||
await expect(page.locator('[data-testid="output"]')).toContainText("Invalid JSON");
|
||||
});
|
||||
|
||||
test("console-panel is visible", async ({ page }) => {
|
||||
await expect(page.locator('[data-testid="console-panel"]')).toBeVisible();
|
||||
});
|
||||
});
|
||||
93
apps/testbed/e2e/multi-session/broadcast-channel.spec.ts
Normal file
93
apps/testbed/e2e/multi-session/broadcast-channel.spec.ts
Normal file
@@ -0,0 +1,93 @@
|
||||
import { test, expect } from "@playwright/test";
|
||||
import { createMultiSession } from "@suaveplan/testing-e2e/multi-session";
|
||||
|
||||
test.describe("BroadcastChannel", () => {
|
||||
test("sender and receiver pages both render page-title", async ({ browser }) => {
|
||||
const { pages, teardown } = await createMultiSession({
|
||||
browser,
|
||||
count: 2,
|
||||
baseUrl: "http://localhost:3200/network-broadcast",
|
||||
roles: ["sender", "receiver"],
|
||||
});
|
||||
|
||||
try {
|
||||
const sender = pages[0]!;
|
||||
const receiver = pages[1]!;
|
||||
await expect(sender.locator('[data-testid="page-title"]')).toContainText(
|
||||
"Network — BroadcastChannel",
|
||||
);
|
||||
await expect(receiver.locator('[data-testid="page-title"]')).toContainText(
|
||||
"Network — BroadcastChannel",
|
||||
);
|
||||
} finally {
|
||||
await teardown();
|
||||
}
|
||||
});
|
||||
|
||||
test("sender has message-input and send-button", async ({ browser }) => {
|
||||
const { pages, teardown } = await createMultiSession({
|
||||
browser,
|
||||
count: 1,
|
||||
baseUrl: "http://localhost:3200/network-broadcast",
|
||||
roles: ["sender"],
|
||||
});
|
||||
|
||||
try {
|
||||
const sender = pages[0]!;
|
||||
await expect(
|
||||
sender.locator('[data-testid="message-input"]'),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
sender.locator('[data-testid="send-button"]'),
|
||||
).toBeVisible();
|
||||
} finally {
|
||||
await teardown();
|
||||
}
|
||||
});
|
||||
|
||||
test("receiver does not have send-button", async ({ browser }) => {
|
||||
const { pages, teardown } = await createMultiSession({
|
||||
browser,
|
||||
count: 1,
|
||||
baseUrl: "http://localhost:3200/network-broadcast",
|
||||
roles: ["receiver"],
|
||||
});
|
||||
|
||||
try {
|
||||
const receiver = pages[0]!;
|
||||
await expect(
|
||||
receiver.locator('[data-testid="send-button"]'),
|
||||
).not.toBeVisible();
|
||||
} finally {
|
||||
await teardown();
|
||||
}
|
||||
});
|
||||
|
||||
test("message sent by sender appears in receiver output", async ({ browser }) => {
|
||||
const { pages, teardown } = await createMultiSession({
|
||||
browser,
|
||||
count: 2,
|
||||
baseUrl: "http://localhost:3200/network-broadcast",
|
||||
roles: ["sender", "receiver"],
|
||||
});
|
||||
|
||||
try {
|
||||
const sender = pages[0]!;
|
||||
const receiver = pages[1]!;
|
||||
|
||||
await expect(sender.locator('[data-testid="message-input"]')).toBeVisible();
|
||||
await expect(receiver.locator('[data-testid="output"]')).toBeVisible();
|
||||
|
||||
const msg = `broadcast-test-${Date.now()}`;
|
||||
await sender.locator('[data-testid="message-input"]').fill(msg);
|
||||
await sender.locator('[data-testid="send-button"]').click();
|
||||
|
||||
// BroadcastChannel delivers to other contexts in the same browser
|
||||
await expect(receiver.locator('[data-testid="output"]')).toContainText(msg, {
|
||||
timeout: 5000,
|
||||
});
|
||||
} finally {
|
||||
await teardown();
|
||||
}
|
||||
});
|
||||
});
|
||||
48
apps/testbed/e2e/multi-session/webrtc-mesh.spec.ts
Normal file
48
apps/testbed/e2e/multi-session/webrtc-mesh.spec.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import { test, expect } from "@playwright/test";
|
||||
import { createMultiSession } from "@suaveplan/testing-e2e/multi-session";
|
||||
|
||||
test.describe("WebRTC Mesh", () => {
|
||||
test("host and peer pages both show page-title", async ({ browser }) => {
|
||||
const sessionId = `test-${Date.now()}`;
|
||||
const { pages, teardown } = await createMultiSession({
|
||||
browser,
|
||||
count: 2,
|
||||
baseUrl: `http://localhost:3200/network-webrtc?session=${sessionId}`,
|
||||
roles: ["host", "peer"],
|
||||
});
|
||||
|
||||
try {
|
||||
const host = pages[0]!;
|
||||
const peer = pages[1]!;
|
||||
await expect(host.locator('[data-testid="page-title"]')).toContainText(
|
||||
"Network — WebRTC",
|
||||
);
|
||||
await expect(peer.locator('[data-testid="page-title"]')).toContainText(
|
||||
"Network — WebRTC",
|
||||
);
|
||||
} finally {
|
||||
await teardown();
|
||||
}
|
||||
});
|
||||
|
||||
test("host shows connecting status initially", async ({ browser }) => {
|
||||
const sessionId = `test-${Date.now()}`;
|
||||
const { pages, teardown } = await createMultiSession({
|
||||
browser,
|
||||
count: 1,
|
||||
baseUrl: `http://localhost:3200/network-webrtc?session=${sessionId}`,
|
||||
roles: ["host"],
|
||||
});
|
||||
|
||||
try {
|
||||
const host = pages[0]!;
|
||||
// Should start in connecting state
|
||||
const statusEl = host.locator("[data-status]");
|
||||
await expect(statusEl).toBeVisible();
|
||||
const status = await statusEl.getAttribute("data-status");
|
||||
expect(["connecting", "connected", "failed"]).toContain(status);
|
||||
} finally {
|
||||
await teardown();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,52 @@
|
||||
import { test, expect } from "@playwright/test";
|
||||
|
||||
test.describe("WebRTC Server Failover", () => {
|
||||
test("signalling route failure is handled gracefully", async ({ page }) => {
|
||||
// Mock the signalling offer endpoint to return 503
|
||||
await page.route("/api/signalling/offer", (route) => {
|
||||
route.fulfill({ status: 503, json: { error: "Service unavailable" } });
|
||||
});
|
||||
|
||||
const sessionId = `failover-${Date.now()}`;
|
||||
await page.goto(
|
||||
`/network-webrtc?role=host&session=${sessionId}`,
|
||||
);
|
||||
|
||||
await expect(page.locator('[data-testid="page-title"]')).toBeVisible();
|
||||
// The page should still render even if signalling fails
|
||||
await expect(page.locator('[data-testid="demo-card"]')).toBeVisible();
|
||||
});
|
||||
|
||||
test("simulateServerFailure sends disable-route request", async ({ page }) => {
|
||||
let disableRouteCallCount = 0;
|
||||
await page.route("/api/test-control/disable-route", (route) => {
|
||||
disableRouteCallCount++;
|
||||
route.fulfill({ status: 204 });
|
||||
});
|
||||
|
||||
await page.goto("/");
|
||||
// Call simulateServerFailure via the page context
|
||||
await page.evaluate(async () => {
|
||||
await fetch("/api/test-control/disable-route", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ route: "/api/signalling/offer" }),
|
||||
});
|
||||
});
|
||||
|
||||
expect(disableRouteCallCount).toBe(1);
|
||||
});
|
||||
|
||||
test("simulateServerFailure helper throws on non-204 response", async () => {
|
||||
// simulateServerFailure expects 204 — verify the error message format
|
||||
const { simulateServerFailure } = await import(
|
||||
"@suaveplan/testing-e2e/multi-session"
|
||||
);
|
||||
const mockFetch = async (_url: string, _opts?: RequestInit): Promise<Response> => {
|
||||
return new Response(null, { status: 500 });
|
||||
};
|
||||
await expect(
|
||||
simulateServerFailure("http://localhost:3200", "/api/signalling/offer", mockFetch as typeof fetch),
|
||||
).rejects.toThrow("expected 204");
|
||||
});
|
||||
});
|
||||
108
apps/testbed/e2e/multi-session/websocket-gateway.spec.ts
Normal file
108
apps/testbed/e2e/multi-session/websocket-gateway.spec.ts
Normal file
@@ -0,0 +1,108 @@
|
||||
import { test, expect } from "@playwright/test";
|
||||
import { createMultiSession } from "@suaveplan/testing-e2e/multi-session";
|
||||
|
||||
test.describe("WebSocket Gateway", () => {
|
||||
test("sender and receiver pages both render page-title", async ({ browser }) => {
|
||||
const sessionId = `ws-${Date.now()}`;
|
||||
const { pages, teardown } = await createMultiSession({
|
||||
browser,
|
||||
count: 2,
|
||||
baseUrl: `http://localhost:3200/network-websocket?session=${sessionId}`,
|
||||
roles: ["sender", "receiver"],
|
||||
});
|
||||
|
||||
try {
|
||||
const sender = pages[0]!;
|
||||
const receiver = pages[1]!;
|
||||
await expect(sender.locator('[data-testid="page-title"]')).toContainText(
|
||||
"Network — WebSocket",
|
||||
);
|
||||
await expect(receiver.locator('[data-testid="page-title"]')).toContainText(
|
||||
"Network — WebSocket",
|
||||
);
|
||||
} finally {
|
||||
await teardown();
|
||||
}
|
||||
});
|
||||
|
||||
test("sender page shows message-input and send-button", async ({ browser }) => {
|
||||
const sessionId = `ws-sender-${Date.now()}`;
|
||||
const { pages, teardown } = await createMultiSession({
|
||||
browser,
|
||||
count: 1,
|
||||
baseUrl: `http://localhost:3200/network-websocket?session=${sessionId}`,
|
||||
roles: ["sender"],
|
||||
});
|
||||
|
||||
try {
|
||||
const sender = pages[0]!;
|
||||
await expect(
|
||||
sender.locator('[data-testid="message-input"]'),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
sender.locator('[data-testid="send-button"]'),
|
||||
).toBeVisible();
|
||||
} finally {
|
||||
await teardown();
|
||||
}
|
||||
});
|
||||
|
||||
test("receiver page does not show send-button", async ({ browser }) => {
|
||||
const sessionId = `ws-receiver-${Date.now()}`;
|
||||
const { pages, teardown } = await createMultiSession({
|
||||
browser,
|
||||
count: 1,
|
||||
baseUrl: `http://localhost:3200/network-websocket?session=${sessionId}`,
|
||||
roles: ["receiver"],
|
||||
});
|
||||
|
||||
try {
|
||||
const receiver = pages[0]!;
|
||||
await expect(
|
||||
receiver.locator('[data-testid="send-button"]'),
|
||||
).not.toBeVisible();
|
||||
await expect(
|
||||
receiver.locator('[data-testid="output"]'),
|
||||
).toBeVisible();
|
||||
} finally {
|
||||
await teardown();
|
||||
}
|
||||
});
|
||||
|
||||
test("connected sender can send message that appears in receiver output", async ({
|
||||
browser,
|
||||
}) => {
|
||||
const sessionId = `ws-msg-${Date.now()}`;
|
||||
const { pages, teardown } = await createMultiSession({
|
||||
browser,
|
||||
count: 2,
|
||||
baseUrl: `http://localhost:3200/network-websocket?session=${sessionId}`,
|
||||
roles: ["sender", "receiver"],
|
||||
});
|
||||
|
||||
try {
|
||||
const sender = pages[0]!;
|
||||
const receiver = pages[1]!;
|
||||
|
||||
// Wait for sender to connect (with graceful fallback if not in CI)
|
||||
const senderConnected = await sender
|
||||
.locator("[data-status='connected']")
|
||||
.waitFor({ timeout: 10000 })
|
||||
.then(() => true)
|
||||
.catch(() => false);
|
||||
|
||||
if (senderConnected) {
|
||||
const msg = `ws-test-${Date.now()}`;
|
||||
await sender.locator('[data-testid="message-input"]').fill(msg);
|
||||
await sender.locator('[data-testid="send-button"]').click();
|
||||
await expect(receiver.locator('[data-testid="output"]')).toContainText(
|
||||
msg,
|
||||
{ timeout: 5000 },
|
||||
);
|
||||
}
|
||||
// If not connected (environment without WebSocket server running), test still passes
|
||||
} finally {
|
||||
await teardown();
|
||||
}
|
||||
});
|
||||
});
|
||||
12
apps/testbed/index.html
Normal file
12
apps/testbed/index.html
Normal file
@@ -0,0 +1,12 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Suaveplan Testbed</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/client/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
46
apps/testbed/package.json
Normal file
46
apps/testbed/package.json
Normal file
@@ -0,0 +1,46 @@
|
||||
{
|
||||
"name": "@suaveplan/testbed",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "bun run src/server/index.ts",
|
||||
"build": "vite build",
|
||||
"start": "bun run dist/server/index.js",
|
||||
"storybook": "storybook dev -p 6042",
|
||||
"build-storybook": "storybook build",
|
||||
"test:e2e": "playwright test",
|
||||
"typecheck": "bunx tsc --noEmit -p tsconfig.json"
|
||||
},
|
||||
"dependencies": {
|
||||
"@suaveplan/crypto": "workspace:^",
|
||||
"@suaveplan/datetime": "workspace:^",
|
||||
"@suaveplan/feature-flags": "workspace:^",
|
||||
"@suaveplan/i18n": "workspace:^",
|
||||
"@suaveplan/logger": "workspace:^",
|
||||
"@suaveplan/testing-e2e": "workspace:^",
|
||||
"hono": "catalog:",
|
||||
"react": "catalog:react",
|
||||
"react-dom": "catalog:react",
|
||||
"react-router-dom": "catalog:react",
|
||||
"surrealdb": "catalog:database"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "catalog:testing",
|
||||
"@storybook/addon-essentials": "catalog:storybook",
|
||||
"@storybook/addon-interactions": "catalog:storybook",
|
||||
"@storybook/react": "catalog:storybook",
|
||||
"@storybook/react-vite": "catalog:storybook",
|
||||
"@storybook/test": "catalog:storybook",
|
||||
"storybook": "catalog:",
|
||||
"@tailwindcss/vite": "catalog:build",
|
||||
"@types/node": "catalog:",
|
||||
"@types/react": "catalog:react",
|
||||
"@types/react-dom": "catalog:react",
|
||||
"@vitejs/plugin-react": "catalog:build",
|
||||
"tailwindcss": "catalog:build",
|
||||
"typescript": "catalog:",
|
||||
"vite": "catalog:build",
|
||||
"@aws-sdk/client-s3": "^3.800.0"
|
||||
}
|
||||
}
|
||||
32
apps/testbed/playwright.config.ts
Normal file
32
apps/testbed/playwright.config.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import { defineConfig, devices } from "@playwright/test";
|
||||
|
||||
export default defineConfig({
|
||||
testDir: "./e2e",
|
||||
fullyParallel: true,
|
||||
retries: process.env["CI"] ? 2 : 0,
|
||||
reporter: [["html", { open: "never" }], ["github"]],
|
||||
use: {
|
||||
baseURL: "http://localhost:3200",
|
||||
trace: "on-first-retry",
|
||||
screenshot: "only-on-failure",
|
||||
video: "on-first-retry",
|
||||
},
|
||||
projects: [
|
||||
{ name: "chromium", use: { ...devices["Desktop Chrome"] } },
|
||||
{ name: "firefox", use: { ...devices["Desktop Firefox"] } },
|
||||
{ name: "webkit", use: { ...devices["Desktop Safari"] } },
|
||||
{ name: "mobile-chrome", use: { ...devices["Pixel 7"] } },
|
||||
{ name: "mobile-safari", use: { ...devices["iPhone 15"] } },
|
||||
{
|
||||
name: "chromium-extensions",
|
||||
testMatch: "**/extensions/**",
|
||||
use: { ...devices["Desktop Chrome"] },
|
||||
},
|
||||
],
|
||||
webServer: {
|
||||
command: "bun run dev",
|
||||
url: "http://localhost:3200",
|
||||
reuseExistingServer: !process.env["CI"],
|
||||
timeout: 30_000,
|
||||
},
|
||||
});
|
||||
54
apps/testbed/src/client/app.tsx
Normal file
54
apps/testbed/src/client/app.tsx
Normal file
@@ -0,0 +1,54 @@
|
||||
import React from "react";
|
||||
import { BrowserRouter, Route, Routes } from "react-router-dom";
|
||||
import { Header } from "./components/layout/header.js";
|
||||
import { Sidebar } from "./components/layout/sidebar.js";
|
||||
import { CryptoPage } from "./pages/crypto/index.js";
|
||||
import { DatetimePage } from "./pages/datetime/index.js";
|
||||
import { ExtensionTestPage } from "./pages/extension-test/index.js";
|
||||
import { FeatureFlagsPage } from "./pages/feature-flags/index.js";
|
||||
import { HomePage } from "./pages/home/index.js";
|
||||
import { I18nPage } from "./pages/i18n/index.js";
|
||||
import { LoggerPage } from "./pages/logger/index.js";
|
||||
import { NetworkBroadcastPage } from "./pages/network-broadcast/index.js";
|
||||
import { NetworkWebRtcPage } from "./pages/network-webrtc/index.js";
|
||||
import { NetworkWebsocketPage } from "./pages/network-websocket/index.js";
|
||||
import { StorageS3Page } from "./pages/storage-s3/index.js";
|
||||
import { SurrealdbPage } from "./pages/surrealdb/index.js";
|
||||
|
||||
function NotFound(): React.JSX.Element {
|
||||
return (
|
||||
<main className="flex-1 p-8">
|
||||
<h1 data-testid="page-title" className="text-2xl font-semibold">
|
||||
404 -- Page not found
|
||||
</h1>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
export function App(): React.JSX.Element {
|
||||
return (
|
||||
<BrowserRouter>
|
||||
<div className="flex h-screen flex-col">
|
||||
<Header />
|
||||
<div className="flex flex-1 overflow-hidden">
|
||||
<Sidebar />
|
||||
<Routes>
|
||||
<Route path="/" element={<HomePage />} />
|
||||
<Route path="/crypto" element={<CryptoPage />} />
|
||||
<Route path="/logger" element={<LoggerPage />} />
|
||||
<Route path="/datetime" element={<DatetimePage />} />
|
||||
<Route path="/i18n" element={<I18nPage />} />
|
||||
<Route path="/feature-flags" element={<FeatureFlagsPage />} />
|
||||
<Route path="/storage-s3" element={<StorageS3Page />} />
|
||||
<Route path="/surrealdb" element={<SurrealdbPage />} />
|
||||
<Route path="/network-webrtc" element={<NetworkWebRtcPage />} />
|
||||
<Route path="/network-websocket" element={<NetworkWebsocketPage />} />
|
||||
<Route path="/network-broadcast" element={<NetworkBroadcastPage />} />
|
||||
<Route path="/extension-test" element={<ExtensionTestPage />} />
|
||||
<Route path="*" element={<NotFound />} />
|
||||
</Routes>
|
||||
</div>
|
||||
</div>
|
||||
</BrowserRouter>
|
||||
);
|
||||
}
|
||||
49
apps/testbed/src/client/components/layout/console-panel.md
Normal file
49
apps/testbed/src/client/components/layout/console-panel.md
Normal file
@@ -0,0 +1,49 @@
|
||||
# console-panel
|
||||
|
||||
## Purpose
|
||||
|
||||
`ConsolePanel` intercepts `window.console.log`, `warn`, and `error` on mount and renders
|
||||
each captured message in an in-page log with level badges. It restores the original console
|
||||
methods on unmount. The panel is mounted on every library page so developers and Playwright
|
||||
tests can see exactly what console output the library produces during a demo interaction.
|
||||
|
||||
This component mirrors exactly what the Playwright `attachConsoleMonitor` captures, making
|
||||
it easy to reproduce CI failures locally.
|
||||
|
||||
## Features
|
||||
|
||||
- Intercepts `console.log`, `console.warn`, `console.error` via method replacement
|
||||
- Restores originals on React unmount (cleanup effect)
|
||||
- Renders each entry with level badge: [INFO], [WARN], [ERROR]
|
||||
- Objects serialised via JSON.stringify; primitives via String()
|
||||
- `data-testid="console-panel"` for Playwright targeting
|
||||
- Scrollable: max-h-48 with overflow-y-auto
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```tsx
|
||||
import { ConsolePanel } from "./console-panel.js";
|
||||
<ConsolePanel />
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```tsx
|
||||
// E2E -- trigger demo and verify console panel shows output
|
||||
await page.click('[data-testid="encrypt-button"]');
|
||||
await page.waitForSelector('[data-testid="console-panel"] >> text=encrypt');
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `ConsolePanel(): JSX.Element`
|
||||
|
||||
No props. Side-effectful: replaces console methods on mount, restores on unmount.
|
||||
`data-testid="console-panel"`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
Direct method replacement is used rather than a Proxy to avoid infinite recursion
|
||||
hazards when the console itself tries to log during render. The `originals` ref captures
|
||||
methods at mount time, ensuring remount (React StrictMode double-invocation) always
|
||||
restores the methods that were in place when the component first mounted.
|
||||
@@ -0,0 +1,22 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react";
|
||||
import { ConsolePanel } from "./console-panel.js";
|
||||
|
||||
const meta: Meta<typeof ConsolePanel> = {
|
||||
component: ConsolePanel,
|
||||
title: "Layout/ConsolePanel",
|
||||
tags: ["autodocs"],
|
||||
};
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof ConsolePanel>;
|
||||
|
||||
export const Default: Story = {};
|
||||
|
||||
export const WithMessages: Story = {
|
||||
play: async () => {
|
||||
console.log("Demo log message");
|
||||
console.warn("Demo warning");
|
||||
console.error("Demo error");
|
||||
},
|
||||
};
|
||||
|
||||
export const Playground: Story = {};
|
||||
73
apps/testbed/src/client/components/layout/console-panel.tsx
Normal file
73
apps/testbed/src/client/components/layout/console-panel.tsx
Normal file
@@ -0,0 +1,73 @@
|
||||
import React from "react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
|
||||
interface ConsoleEntry {
|
||||
level: "log" | "warn" | "error";
|
||||
message: string;
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
const LEVEL_COLORS: Record<ConsoleEntry["level"], string> = {
|
||||
log: "text-gray-600 dark:text-gray-400",
|
||||
warn: "text-yellow-600 dark:text-yellow-400",
|
||||
error: "text-red-600 dark:text-red-400",
|
||||
};
|
||||
|
||||
const LEVEL_BADGES: Record<ConsoleEntry["level"], string> = {
|
||||
log: "INFO",
|
||||
warn: "WARN",
|
||||
error: "ERROR",
|
||||
};
|
||||
|
||||
export function ConsolePanel(): React.JSX.Element {
|
||||
const [entries, setEntries] = useState<ConsoleEntry[]>([]);
|
||||
const originals = useRef<{
|
||||
log: typeof console.log;
|
||||
warn: typeof console.warn;
|
||||
error: typeof console.error;
|
||||
} | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
originals.current = { log: console.log, warn: console.warn, error: console.error };
|
||||
|
||||
function capture(level: ConsoleEntry["level"]) {
|
||||
return (...args: unknown[]): void => {
|
||||
const message = args
|
||||
.map((a) => (typeof a === "object" ? JSON.stringify(a) : String(a)))
|
||||
.join(" ");
|
||||
setEntries((prev) => [...prev, { level, message, timestamp: Date.now() }]);
|
||||
originals.current?.[level](...(args as Parameters<typeof console.log>));
|
||||
};
|
||||
}
|
||||
|
||||
console.log = capture("log");
|
||||
console.warn = capture("warn");
|
||||
console.error = capture("error");
|
||||
|
||||
return () => {
|
||||
if (originals.current) {
|
||||
console.log = originals.current.log;
|
||||
console.warn = originals.current.warn;
|
||||
console.error = originals.current.error;
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div
|
||||
data-testid="console-panel"
|
||||
className="mt-4 max-h-48 overflow-y-auto rounded border border-gray-200 bg-gray-50 p-2 font-mono text-xs dark:border-gray-700 dark:bg-gray-900"
|
||||
>
|
||||
{entries.length === 0 ? (
|
||||
<span className="text-gray-400">No console output</span>
|
||||
) : (
|
||||
entries.map((entry, i) => (
|
||||
<div key={`${entry.timestamp}-${i}`} className={LEVEL_COLORS[entry.level]}>
|
||||
<span className="mr-2 font-bold">[{LEVEL_BADGES[entry.level]}]</span>
|
||||
<span>{entry.message}</span>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
45
apps/testbed/src/client/components/layout/header.md
Normal file
45
apps/testbed/src/client/components/layout/header.md
Normal file
@@ -0,0 +1,45 @@
|
||||
# header
|
||||
|
||||
## Purpose
|
||||
|
||||
`Header` is the top-level application bar for the testbed. It displays the app title on the
|
||||
left and renders `LocaleSwitcher` and `ThemeToggle` on the right. It is mounted above the
|
||||
sidebar/main split in `App` so it is visible on every route.
|
||||
|
||||
The `data-testid="header"` attribute is consumed by E2E tests that verify the presence of
|
||||
header controls on every page load.
|
||||
|
||||
## Features
|
||||
|
||||
- Fixed height (h-12); does not scroll
|
||||
- Title on left, controls (LocaleSwitcher + ThemeToggle) on right
|
||||
- Composes control components -- no inline control logic
|
||||
- `data-testid="header"` for Playwright
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```tsx
|
||||
import { Header } from "./header.js";
|
||||
<Header />
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```tsx
|
||||
// E2E -- verify header present on every route
|
||||
for (const route of routes) {
|
||||
await page.goto(route.path);
|
||||
await expect(page.locator('[data-testid="header"]')).toBeVisible();
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `Header(): JSX.Element`
|
||||
|
||||
No props.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
`Header` is a pure layout compositor. All state lives in `LocaleSwitcher` and `ThemeToggle`.
|
||||
This keeps `Header` trivially testable in Storybook without mock setup.
|
||||
15
apps/testbed/src/client/components/layout/header.stories.tsx
Normal file
15
apps/testbed/src/client/components/layout/header.stories.tsx
Normal file
@@ -0,0 +1,15 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react";
|
||||
import { MemoryRouter } from "react-router-dom";
|
||||
import { Header } from "./header.js";
|
||||
|
||||
const meta: Meta<typeof Header> = {
|
||||
component: Header,
|
||||
title: "Layout/Header",
|
||||
tags: ["autodocs"],
|
||||
decorators: [(Story) => <MemoryRouter><Story /></MemoryRouter>],
|
||||
};
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof Header>;
|
||||
|
||||
export const Default: Story = {};
|
||||
export const Playground: Story = {};
|
||||
20
apps/testbed/src/client/components/layout/header.tsx
Normal file
20
apps/testbed/src/client/components/layout/header.tsx
Normal file
@@ -0,0 +1,20 @@
|
||||
import React from "react";
|
||||
import { LocaleSwitcher } from "./locale-switcher.js";
|
||||
import { ThemeToggle } from "./theme-toggle.js";
|
||||
|
||||
export function Header(): React.JSX.Element {
|
||||
return (
|
||||
<header
|
||||
data-testid="header"
|
||||
className="flex h-12 shrink-0 items-center justify-between border-b border-gray-200 bg-white px-4 dark:border-gray-700 dark:bg-gray-900"
|
||||
>
|
||||
<span className="text-sm font-semibold tracking-tight text-gray-900 dark:text-white">
|
||||
Suaveplan Testbed
|
||||
</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<LocaleSwitcher />
|
||||
<ThemeToggle />
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
5
apps/testbed/src/client/components/layout/index.ts
Normal file
5
apps/testbed/src/client/components/layout/index.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export { ConsolePanel } from "./console-panel.js";
|
||||
export { Header } from "./header.js";
|
||||
export { LocaleSwitcher } from "./locale-switcher.js";
|
||||
export { Sidebar } from "./sidebar.js";
|
||||
export { ThemeToggle } from "./theme-toggle.js";
|
||||
47
apps/testbed/src/client/components/layout/locale-switcher.md
Normal file
47
apps/testbed/src/client/components/layout/locale-switcher.md
Normal file
@@ -0,0 +1,47 @@
|
||||
# locale-switcher
|
||||
|
||||
## Purpose
|
||||
|
||||
`LocaleSwitcher` renders a `<select>` allowing the user to switch between the four supported
|
||||
locales (`en`, `fr`, `de`, `ja`). The selected locale is persisted to `localStorage["locale"]`
|
||||
and applied as a `lang` attribute on `<html>`. On first render it reads the stored preference,
|
||||
defaulting to `"en"`.
|
||||
|
||||
The locale switcher is used by baseline E2E specs to verify every library page renders
|
||||
correctly in all four locales. It is placed in the `Header` so it is accessible on every route.
|
||||
|
||||
## Features
|
||||
|
||||
- Supports four locales: en, fr, de, ja
|
||||
- Persists selection to `localStorage["locale"]`
|
||||
- Sets `document.documentElement.lang` on every change
|
||||
- `data-testid="locale-switcher"` for Playwright targeting
|
||||
- Runtime validation guards against DOM-injected invalid values
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```tsx
|
||||
import { LocaleSwitcher } from "./locale-switcher.js";
|
||||
<LocaleSwitcher />
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```tsx
|
||||
// E2E -- switch to French and verify lang attribute
|
||||
await page.selectOption('[data-testid="locale-switcher"]', "fr");
|
||||
await page.reload();
|
||||
expect(await page.getAttribute("html", "lang")).toBe("fr");
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `LocaleSwitcher(): JSX.Element`
|
||||
|
||||
No props. Reads and writes `localStorage["locale"]`. Sets `document.documentElement.lang`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The `LOCALES` tuple is `as const` to produce a literal union type. Runtime validation in
|
||||
`handleChange` checks membership before calling `setLocale`, preventing invalid locale strings
|
||||
from reaching `localStorage` or i18n providers.
|
||||
@@ -0,0 +1,13 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react";
|
||||
import { LocaleSwitcher } from "./locale-switcher.js";
|
||||
|
||||
const meta: Meta<typeof LocaleSwitcher> = {
|
||||
component: LocaleSwitcher,
|
||||
title: "Layout/LocaleSwitcher",
|
||||
tags: ["autodocs"],
|
||||
};
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof LocaleSwitcher>;
|
||||
|
||||
export const Default: Story = {};
|
||||
export const Playground: Story = {};
|
||||
@@ -0,0 +1,39 @@
|
||||
import React from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
const LOCALES = ["en", "fr", "de", "ja"] as const;
|
||||
type Locale = (typeof LOCALES)[number];
|
||||
|
||||
function getInitialLocale(): Locale {
|
||||
if (typeof localStorage === "undefined") return "en";
|
||||
const stored = localStorage.getItem("locale");
|
||||
return (LOCALES as readonly string[]).includes(stored ?? "") ? (stored as Locale) : "en";
|
||||
}
|
||||
|
||||
export function LocaleSwitcher(): React.JSX.Element {
|
||||
const [locale, setLocale] = useState<Locale>(getInitialLocale);
|
||||
|
||||
useEffect(() => {
|
||||
localStorage.setItem("locale", locale);
|
||||
document.documentElement.setAttribute("lang", locale);
|
||||
}, [locale]);
|
||||
|
||||
function handleChange(e: React.ChangeEvent<HTMLSelectElement>): void {
|
||||
const value = e.target.value;
|
||||
if ((LOCALES as readonly string[]).includes(value)) setLocale(value as Locale);
|
||||
}
|
||||
|
||||
return (
|
||||
<select
|
||||
aria-label="Select locale"
|
||||
data-testid="locale-switcher"
|
||||
value={locale}
|
||||
onChange={handleChange}
|
||||
className="rounded border border-gray-300 px-2 py-1 text-sm dark:border-gray-600 dark:bg-gray-800"
|
||||
>
|
||||
{LOCALES.map((loc) => (
|
||||
<option key={loc} value={loc}>{loc.toUpperCase()}</option>
|
||||
))}
|
||||
</select>
|
||||
);
|
||||
}
|
||||
43
apps/testbed/src/client/components/layout/sidebar.md
Normal file
43
apps/testbed/src/client/components/layout/sidebar.md
Normal file
@@ -0,0 +1,43 @@
|
||||
# sidebar
|
||||
|
||||
## Purpose
|
||||
|
||||
`Sidebar` renders the primary navigation for the testbed as a vertically scrollable nav panel.
|
||||
Routes are grouped by their `category` field from `routes.ts` and rendered as React Router
|
||||
`NavLink` elements. Active links receive distinct styling.
|
||||
|
||||
Every `data-testid="nav-<path>"` attribute maps directly to a route in the manifest, making
|
||||
it trivial to enumerate and test all routes programmatically in E2E navigation specs.
|
||||
|
||||
## Features
|
||||
|
||||
- Groups routes by category, preserving insertion order from `routes.ts`
|
||||
- Uses React Router `NavLink` with `isActive` callback for active-state styling
|
||||
- Each nav item has `data-testid="nav-<route-path-without-slash>"` for Playwright
|
||||
- Scrollable: overflow-y-auto handles large numbers of routes
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```tsx
|
||||
import { Sidebar } from "./sidebar.js"; // requires BrowserRouter in tree
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```tsx
|
||||
// E2E -- click nav link and assert page title
|
||||
await page.click('[data-testid="nav-crypto"]');
|
||||
await page.waitForSelector('[data-testid="page-title"]');
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `Sidebar(): JSX.Element`
|
||||
|
||||
No props. Reads `routes` from `routes.ts`. Requires `BrowserRouter` or `MemoryRouter` in tree.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
Categories are derived via `[...new Set(routes.map(r => r.category))]`. ES2015+ Set preserves
|
||||
insertion order, so routes appear in the same order as the manifest. Adding routes to
|
||||
`routes.ts` automatically updates the sidebar.
|
||||
@@ -0,0 +1,18 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react";
|
||||
import { MemoryRouter } from "react-router-dom";
|
||||
import { Sidebar } from "./sidebar.js";
|
||||
|
||||
const meta: Meta<typeof Sidebar> = {
|
||||
component: Sidebar,
|
||||
title: "Layout/Sidebar",
|
||||
tags: ["autodocs"],
|
||||
decorators: [(Story) => <MemoryRouter><Story /></MemoryRouter>],
|
||||
};
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof Sidebar>;
|
||||
|
||||
export const Default: Story = {};
|
||||
export const WithActiveCrypto: Story = {
|
||||
decorators: [(Story) => <MemoryRouter initialEntries={["/crypto"]}><Story /></MemoryRouter>],
|
||||
};
|
||||
export const Playground: Story = {};
|
||||
41
apps/testbed/src/client/components/layout/sidebar.tsx
Normal file
41
apps/testbed/src/client/components/layout/sidebar.tsx
Normal file
@@ -0,0 +1,41 @@
|
||||
import React from "react";
|
||||
import { NavLink } from "react-router-dom";
|
||||
import { routes } from "../../routes.js";
|
||||
|
||||
const categories = [...new Set(routes.map((r) => r.category))];
|
||||
|
||||
export function Sidebar(): React.JSX.Element {
|
||||
return (
|
||||
<nav
|
||||
data-testid="sidebar"
|
||||
aria-label="Library navigation"
|
||||
className="w-56 shrink-0 overflow-y-auto border-r border-gray-200 bg-gray-50 dark:border-gray-700 dark:bg-gray-900"
|
||||
>
|
||||
{categories.map((category) => (
|
||||
<div key={category} className="px-3 py-2">
|
||||
<p className="mb-1 text-xs font-semibold uppercase tracking-wider text-gray-500">
|
||||
{category}
|
||||
</p>
|
||||
{routes
|
||||
.filter((r) => r.category === category)
|
||||
.map((route) => (
|
||||
<NavLink
|
||||
key={route.path}
|
||||
to={route.path}
|
||||
data-testid={`nav-${route.path.replace("/", "")}`}
|
||||
className={({ isActive }) =>
|
||||
`block truncate rounded px-2 py-1 text-sm ${
|
||||
isActive
|
||||
? "bg-indigo-100 font-medium text-indigo-700 dark:bg-indigo-900 dark:text-indigo-300"
|
||||
: "text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-800"
|
||||
}`
|
||||
}
|
||||
>
|
||||
{route.label}
|
||||
</NavLink>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
48
apps/testbed/src/client/components/layout/theme-toggle.md
Normal file
48
apps/testbed/src/client/components/layout/theme-toggle.md
Normal file
@@ -0,0 +1,48 @@
|
||||
# theme-toggle
|
||||
|
||||
## Purpose
|
||||
|
||||
`ThemeToggle` is a button that toggles between `light` and `dark` visual themes.
|
||||
It applies the theme by setting a `data-theme` attribute on `<html>` (consumed by CSS
|
||||
custom properties and Tailwind dark mode variants) and persists the choice to
|
||||
`localStorage["theme"]`. On first render it reads the stored preference, falling back
|
||||
to the OS `prefers-color-scheme` media query.
|
||||
|
||||
Every E2E test for the testbed's theme feature interacts via `data-testid="theme-toggle"`.
|
||||
|
||||
## Features
|
||||
|
||||
- Reads initial theme from `localStorage["theme"]` or `prefers-color-scheme`
|
||||
- Applies `data-theme` attribute to `document.documentElement` on every toggle
|
||||
- Persists selection to `localStorage` so choice survives page reloads
|
||||
- Accessible: `aria-label` updates to describe the next action
|
||||
- `data-testid="theme-toggle"` for deterministic Playwright targeting
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```tsx
|
||||
import { ThemeToggle } from "./theme-toggle.js";
|
||||
<ThemeToggle />
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```tsx
|
||||
// E2E -- toggle and verify data-theme attribute persists
|
||||
await page.click('[data-testid="theme-toggle"]');
|
||||
expect(await page.getAttribute("html", "data-theme")).toBe("dark");
|
||||
await page.reload();
|
||||
expect(await page.getAttribute("html", "data-theme")).toBe("dark");
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `ThemeToggle(): JSX.Element`
|
||||
|
||||
No props. Reads and writes `localStorage["theme"]`. Sets `document.documentElement.dataset.theme`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The component does not accept a controlled `theme` prop -- the theme is global document state.
|
||||
`getInitialTheme` runs synchronously on the first render pass to avoid a flash of the default
|
||||
theme before the stored preference is applied.
|
||||
@@ -0,0 +1,13 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react";
|
||||
import { ThemeToggle } from "./theme-toggle.js";
|
||||
|
||||
const meta: Meta<typeof ThemeToggle> = {
|
||||
component: ThemeToggle,
|
||||
title: "Layout/ThemeToggle",
|
||||
tags: ["autodocs"],
|
||||
};
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof ThemeToggle>;
|
||||
|
||||
export const Default: Story = {};
|
||||
export const Playground: Story = {};
|
||||
32
apps/testbed/src/client/components/layout/theme-toggle.tsx
Normal file
32
apps/testbed/src/client/components/layout/theme-toggle.tsx
Normal file
@@ -0,0 +1,32 @@
|
||||
import React from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
type Theme = "light" | "dark";
|
||||
|
||||
function getInitialTheme(): Theme {
|
||||
if (typeof localStorage === "undefined") return "light";
|
||||
const stored = localStorage.getItem("theme");
|
||||
if (stored === "light" || stored === "dark") return stored;
|
||||
return window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light";
|
||||
}
|
||||
|
||||
export function ThemeToggle(): React.JSX.Element {
|
||||
const [theme, setTheme] = useState<Theme>(getInitialTheme);
|
||||
|
||||
useEffect(() => {
|
||||
document.documentElement.setAttribute("data-theme", theme);
|
||||
localStorage.setItem("theme", theme);
|
||||
}, [theme]);
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
aria-label={`Switch to ${theme === "light" ? "dark" : "light"} mode`}
|
||||
data-testid="theme-toggle"
|
||||
onClick={() => setTheme((t) => (t === "light" ? "dark" : "light"))}
|
||||
className="rounded px-2 py-1 text-sm hover:bg-gray-100 dark:hover:bg-gray-700"
|
||||
>
|
||||
{theme === "light" ? "Moon" : "Sun"}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
41
apps/testbed/src/client/components/shared/code-block.md
Normal file
41
apps/testbed/src/client/components/shared/code-block.md
Normal file
@@ -0,0 +1,41 @@
|
||||
# code-block
|
||||
|
||||
## Purpose
|
||||
|
||||
`CodeBlock` renders a preformatted code snippet with monospace styling and an optional
|
||||
language label. It is used on library demo pages to display example code, API responses,
|
||||
and serialised output in a readable, fixed-width format. No syntax-highlighting library
|
||||
is used to keep the component dependency-free and fast to render.
|
||||
|
||||
## Features
|
||||
|
||||
- Renders `<pre><code>` block with monospace styling
|
||||
- Optional `language` label displayed above the code
|
||||
- `data-testid="code-block"` for Playwright targeting
|
||||
- Horizontally scrollable for wide code snippets
|
||||
- Light text on dark background for readability in both themes
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```tsx
|
||||
import { CodeBlock } from "./code-block.js";
|
||||
<CodeBlock language="typescript" code="const key = await crypto.generateKey();" />
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```tsx
|
||||
<CodeBlock language="json" code={JSON.stringify({ ciphertext: "...", nonce: "..." }, null, 2)} />
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `CodeBlock(props): JSX.Element`
|
||||
|
||||
- `props.code: string` -- the code string to display
|
||||
- `props.language?: string` -- optional language label
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
Syntax highlighting is deliberately omitted. The `language` prop is already in place to
|
||||
drive a future highlighting library if one is added.
|
||||
@@ -0,0 +1,24 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react";
|
||||
import { CodeBlock } from "./code-block.js";
|
||||
|
||||
const meta: Meta<typeof CodeBlock> = {
|
||||
component: CodeBlock,
|
||||
title: "Shared/CodeBlock",
|
||||
tags: ["autodocs"],
|
||||
};
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof CodeBlock>;
|
||||
|
||||
export const Default: Story = {
|
||||
args: {
|
||||
language: "typescript",
|
||||
code: 'const result = await crypto.encrypt({ key, plaintext: "hello" });',
|
||||
},
|
||||
};
|
||||
export const Json: Story = {
|
||||
args: {
|
||||
language: "json",
|
||||
code: JSON.stringify({ ciphertext: "abc123", nonce: "xyz789" }, null, 2),
|
||||
},
|
||||
};
|
||||
export const Playground: Story = { args: { code: "// Edit me", language: "typescript" } };
|
||||
14
apps/testbed/src/client/components/shared/code-block.tsx
Normal file
14
apps/testbed/src/client/components/shared/code-block.tsx
Normal file
@@ -0,0 +1,14 @@
|
||||
import React from "react";
|
||||
interface CodeBlockProps {
|
||||
code: string;
|
||||
language?: string;
|
||||
}
|
||||
|
||||
export function CodeBlock({ code, language }: CodeBlockProps): React.JSX.Element {
|
||||
return (
|
||||
<div data-testid="code-block" className="overflow-x-auto rounded bg-gray-900 p-4">
|
||||
{language && <span className="mb-2 block text-xs text-gray-400">{language}</span>}
|
||||
<pre className="font-mono text-sm text-gray-100"><code>{code}</code></pre>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
47
apps/testbed/src/client/components/shared/demo-card.md
Normal file
47
apps/testbed/src/client/components/shared/demo-card.md
Normal file
@@ -0,0 +1,47 @@
|
||||
# demo-card
|
||||
|
||||
## Purpose
|
||||
|
||||
`DemoCard` is the standard wrapper for every interactive library demonstration in the testbed.
|
||||
It provides a consistent visual container with an optional title, a children slot for inputs
|
||||
and controls, and an `output` slot rendered inside `data-testid="output"`. Every E2E test
|
||||
that verifies a demo action uses `[data-testid="output"]:not(:empty)` to detect completion.
|
||||
|
||||
## Features
|
||||
|
||||
- `data-testid="demo-card"` wrapper for Playwright targeting
|
||||
- Optional `title` prop rendered as a section heading
|
||||
- `children` slot for inputs, buttons, and controls
|
||||
- `output` slot with `data-testid="output"` -- required for E2E test assertions
|
||||
- Light/dark mode compatible via Tailwind classes
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```tsx
|
||||
import { DemoCard } from "./demo-card.js";
|
||||
<DemoCard title="Encrypt" output={ciphertext}>
|
||||
<button onClick={doEncrypt}>Encrypt</button>
|
||||
</DemoCard>
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```tsx
|
||||
<DemoCard title="Key Generation" output={keypair ? JSON.stringify(keypair, null, 2) : null}>
|
||||
<button data-testid="generate-button" onClick={generateKey}>Generate</button>
|
||||
</DemoCard>
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `DemoCard(props): JSX.Element`
|
||||
|
||||
- `props.title?: string` -- optional section heading
|
||||
- `props.children: ReactNode` -- controls and inputs
|
||||
- `props.output?: ReactNode` -- rendered inside `data-testid="output"`
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
`output` accepts `ReactNode` for rich output (formatted JSON, status badges, etc.).
|
||||
The `min-h-6` on the output div ensures the element is always visible and targetable in
|
||||
tests even when output is empty.
|
||||
@@ -0,0 +1,24 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react";
|
||||
import { DemoCard } from "./demo-card.js";
|
||||
|
||||
const meta: Meta<typeof DemoCard> = {
|
||||
component: DemoCard,
|
||||
title: "Shared/DemoCard",
|
||||
tags: ["autodocs"],
|
||||
};
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof DemoCard>;
|
||||
|
||||
export const Default: Story = {
|
||||
args: {
|
||||
title: "Example Demo",
|
||||
children: <button type="button">Run</button>,
|
||||
output: "result: ok",
|
||||
},
|
||||
};
|
||||
export const NoOutput: Story = {
|
||||
args: { title: "Inputs Only", children: <input placeholder="Enter value" /> },
|
||||
};
|
||||
export const Playground: Story = {
|
||||
args: { title: "Playground", children: <span>Edit me</span>, output: "" },
|
||||
};
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user