684 lines
22 KiB
TypeScript
684 lines
22 KiB
TypeScript
/**
|
|
* `wrnexus create <app-name>` — scaffold a new app from an inline template.
|
|
*
|
|
* Kept dependency-free and explicit: the template files live here as strings so
|
|
* scaffolding works without copying from anywhere on disk.
|
|
*/
|
|
|
|
import { existsSync, mkdirSync, writeFileSync } from "node:fs";
|
|
import { dirname, join, resolve } from "node:path";
|
|
import { AI_GUIDE, CLAUDE_MD } from "./ai-guide.ts";
|
|
import { currentCliVersion } from "./update-notifier.ts";
|
|
|
|
const cliVersion = currentCliVersion();
|
|
|
|
// Framework packages use independent patch versions. Keep scaffolds on the
|
|
// compatible 0.8 line instead of assuming every package shares the CLI patch.
|
|
export const scaffoldFrameworkRange = "^0.8.8";
|
|
|
|
/** Map of file (relative to app root) -> contents. */
|
|
const TEMPLATE: Record<string, string> = {
|
|
// AI/agent context: teaches Claude Code / Cursor / Copilot the WrNexus conventions.
|
|
"CLAUDE.md": CLAUDE_MD,
|
|
"public/llms.txt": AI_GUIDE,
|
|
".gitignore": `# Dependencies
|
|
node_modules/
|
|
|
|
# WRNexusJS and production builds
|
|
dist/
|
|
.wrnexus/
|
|
**/.wrnexus/
|
|
coverage/
|
|
|
|
# Environment files and local secrets
|
|
.env
|
|
.env.*
|
|
!.env.example
|
|
!.env.*.example
|
|
|
|
# Logs and runtime files
|
|
*.log
|
|
logs/
|
|
*.pid
|
|
*.pid.lock
|
|
|
|
# Local databases
|
|
*.db
|
|
*.db-shm
|
|
*.db-wal
|
|
*.sqlite
|
|
*.sqlite3
|
|
uploads/
|
|
|
|
# Generated native projects
|
|
mobile/android/
|
|
mobile/ios/
|
|
mobile/.expo/
|
|
|
|
# Editors and operating systems
|
|
.idea/
|
|
.vscode/*
|
|
!.vscode/settings.json
|
|
!.vscode/extensions.json
|
|
*.swp
|
|
*.swo
|
|
.DS_Store
|
|
Thumbs.db
|
|
|
|
# TypeScript and test caches
|
|
*.tsbuildinfo
|
|
.eslintcache
|
|
.nyc_output/
|
|
`,
|
|
"package.json": `{
|
|
"name": "APP_NAME",
|
|
"version": "0.1.0",
|
|
"private": true,
|
|
"type": "module",
|
|
"wrnexus": {
|
|
"version": "${cliVersion}"
|
|
},
|
|
"scripts": {
|
|
"dev": "wrnexus dev .",
|
|
"build": "wrnexus build .",
|
|
"start": "bun dist/server.js",
|
|
"production": "bun run build && bun run start",
|
|
"typecheck": "tsc --noEmit",
|
|
"test": "wrnexus test .",
|
|
"test:watch": "wrnexus test . --watch",
|
|
"lint": "eslint .",
|
|
"lint:fix": "eslint . --fix",
|
|
"format": "prettier . --write",
|
|
"format:check": "prettier . --check",
|
|
"doctor": "wrnexus doctor .",
|
|
"analyze": "wrnexus analyze .",
|
|
"inspect": "wrnexus inspect packages .",
|
|
"check": "bun run typecheck && bun run lint && bun run test && bun run format:check"
|
|
},
|
|
"dependencies": {
|
|
"@wrnexus/ai": "${scaffoldFrameworkRange}",
|
|
"@wrnexus/auth": "${scaffoldFrameworkRange}",
|
|
"@wrnexus/captcha": "${scaffoldFrameworkRange}",
|
|
"@wrnexus/core": "${scaffoldFrameworkRange}",
|
|
"@wrnexus/csr": "${scaffoldFrameworkRange}",
|
|
"@wrnexus/db": "${scaffoldFrameworkRange}",
|
|
"@wrnexus/dev-server": "${scaffoldFrameworkRange}",
|
|
"@wrnexus/encryption": "${scaffoldFrameworkRange}",
|
|
"@wrnexus/helpers": "${scaffoldFrameworkRange}",
|
|
"@wrnexus/i18n": "${scaffoldFrameworkRange}",
|
|
"@wrnexus/image": "${scaffoldFrameworkRange}",
|
|
"@wrnexus/jwt": "${scaffoldFrameworkRange}",
|
|
"@wrnexus/observability": "${scaffoldFrameworkRange}",
|
|
"@wrnexus/realtime": "${scaffoldFrameworkRange}",
|
|
"@wrnexus/security": "${scaffoldFrameworkRange}",
|
|
"@wrnexus/store": "${scaffoldFrameworkRange}",
|
|
"@wrnexus/styles": "${scaffoldFrameworkRange}",
|
|
"@wrnexus/tracking": "${scaffoldFrameworkRange}",
|
|
"@wrnexus/ui": "${scaffoldFrameworkRange}",
|
|
"@wrnexus/uploader": "${scaffoldFrameworkRange}",
|
|
"@wrnexus/validation": "${scaffoldFrameworkRange}",
|
|
"@wrnexus/authz": "${scaffoldFrameworkRange}"
|
|
},
|
|
"devDependencies": {
|
|
"@wrnexus/cli": "${cliVersion}",
|
|
"@eslint/js": "^10.0.1",
|
|
"@iconify-json/lucide": "^1.2.123",
|
|
"@iconify/tailwind4": "^1.2.3",
|
|
"@tailwindcss/cli": "^4.3.3",
|
|
"@types/bun": "^1.3.14",
|
|
"eslint": "^10.8.1",
|
|
"prettier": "^3.9.6",
|
|
"tailwindcss": "^4.3.3",
|
|
"typescript": "^6.0.3",
|
|
"typescript-eslint": "^8.67.0"
|
|
}
|
|
}
|
|
`,
|
|
"tsconfig.json": `{
|
|
"compilerOptions": {
|
|
"target": "ESNext",
|
|
"module": "ESNext",
|
|
"moduleResolution": "bundler",
|
|
"lib": ["ESNext", "DOM", "DOM.Iterable"],
|
|
"types": ["bun"],
|
|
"strict": true,
|
|
"skipLibCheck": true,
|
|
"noEmit": true,
|
|
"allowImportingTsExtensions": true,
|
|
"verbatimModuleSyntax": false,
|
|
"esModuleInterop": true,
|
|
"resolveJsonModule": true,
|
|
"jsx": "react-jsx",
|
|
"jsxImportSource": "@wrnexus/core"
|
|
},
|
|
"include": ["app", "test", "wrnexus.config.ts"],
|
|
"exclude": ["node_modules", "dist", "**/dist", "**/.wrnexus"]
|
|
}
|
|
`,
|
|
".env.example": `# Copy to .env for local development. Never commit real secrets.
|
|
WRNEXUS_PUBLIC_ORIGIN=http://localhost:3000
|
|
DATABASE_URL=file:./dev.db
|
|
REDIS_URL=redis://localhost:6379
|
|
AUTH_SECRET=replace-with-at-least-32-random-characters
|
|
ENCRYPTION_KEY=replace-with-a-base64-encoded-32-byte-key
|
|
ANTHROPIC_API_KEY=
|
|
OTEL_EXPORTER_OTLP_ENDPOINT=
|
|
`,
|
|
".env.test.example": `WRNEXUS_PUBLIC_ORIGIN=http://localhost:3000
|
|
DATABASE_URL=file:./test.db
|
|
AUTH_SECRET=test-only-secret-replace-outside-tests
|
|
`,
|
|
"eslint.config.js": `import { dirname } from "node:path";
|
|
import { fileURLToPath } from "node:url";
|
|
import js from "@eslint/js";
|
|
import tseslint from "typescript-eslint";
|
|
|
|
const tsconfigRootDir = dirname(fileURLToPath(import.meta.url));
|
|
|
|
export default tseslint.config(
|
|
{
|
|
ignores: [
|
|
"node_modules/**",
|
|
"dist/**",
|
|
".wrnexus/**",
|
|
"**/.wrnexus/**",
|
|
"mobile/android/**",
|
|
"mobile/ios/**",
|
|
],
|
|
},
|
|
{
|
|
languageOptions: {
|
|
parserOptions: {
|
|
tsconfigRootDir,
|
|
},
|
|
},
|
|
},
|
|
js.configs.recommended,
|
|
...tseslint.configs.recommended,
|
|
{
|
|
files: ["**/*.{ts,tsx}"],
|
|
rules: {
|
|
"no-undef": "off",
|
|
"no-console": "off",
|
|
"@typescript-eslint/no-explicit-any": "off",
|
|
"@typescript-eslint/no-unused-vars": [
|
|
"warn",
|
|
{
|
|
argsIgnorePattern: "^_",
|
|
varsIgnorePattern: "^_",
|
|
caughtErrorsIgnorePattern: "^_",
|
|
},
|
|
],
|
|
},
|
|
},
|
|
);
|
|
`,
|
|
".prettierrc.json": `{
|
|
"printWidth": 100,
|
|
"tabWidth": 2,
|
|
"useTabs": false,
|
|
"semi": true,
|
|
"singleQuote": false,
|
|
"trailingComma": "all",
|
|
"endOfLine": "lf"
|
|
}
|
|
`,
|
|
".prettierignore": `node_modules/
|
|
dist/
|
|
.wrnexus/
|
|
**/.wrnexus/
|
|
*.log
|
|
CLAUDE.md
|
|
`,
|
|
".editorconfig": `root = true
|
|
|
|
[*]
|
|
charset = utf-8
|
|
end_of_line = lf
|
|
indent_style = space
|
|
indent_size = 2
|
|
insert_final_newline = true
|
|
trim_trailing_whitespace = true
|
|
`,
|
|
".vscode/settings.json": `{
|
|
"editor.defaultFormatter": "esbenp.prettier-vscode",
|
|
"editor.formatOnSave": true,
|
|
"editor.codeActionsOnSave": {
|
|
"source.fixAll.eslint": "explicit"
|
|
},
|
|
"prettier.requireConfig": true,
|
|
"[wrn]": {
|
|
"editor.defaultFormatter": "wrnexus.wrnexus",
|
|
"editor.formatOnSave": true
|
|
}
|
|
}
|
|
`,
|
|
".vscode/extensions.json": `{
|
|
"recommendations": ["wrnexus.wrnexus", "esbenp.prettier-vscode", "dbaeumer.vscode-eslint"]
|
|
}
|
|
`,
|
|
"wrnexus.config.ts": `import type { AppConfig } from "@wrnexus/styles";
|
|
|
|
const config: AppConfig = {
|
|
// v0.8 defaults: explicit imports, strict template types, safe stores, and
|
|
// automatic progressive navigation. Package plugins are discovered from the
|
|
// installed packages above; add custom plugins to this array when needed.
|
|
plugins: [],
|
|
imports: { mode: "explicit", autoImport: true, aliases: { "@": "./app" } },
|
|
types: {
|
|
strict: true,
|
|
noImplicitAny: true,
|
|
strictNullChecks: true,
|
|
checkTemplates: true,
|
|
checkComponentProps: true,
|
|
generateDeclarations: true,
|
|
},
|
|
stores: { strictMutations: true, persistence: true },
|
|
experimental: {},
|
|
|
|
performance: {
|
|
enforcement: "warn",
|
|
analyze: true,
|
|
budgets: {
|
|
routeJsBytes: 50 * 1024,
|
|
routeCssBytes: 25 * 1024,
|
|
lcpMs: 2_500,
|
|
inpMs: 200,
|
|
cls: 0.1,
|
|
},
|
|
},
|
|
observability: {
|
|
enabled: true,
|
|
serviceName: "APP_SLUG",
|
|
serverTiming: true,
|
|
sampleRate: 1,
|
|
exporter: process.env.OTEL_EXPORTER_OTLP_ENDPOINT ? "otlp" : "none",
|
|
endpoint: process.env.OTEL_EXPORTER_OTLP_ENDPOINT,
|
|
webVitals: true,
|
|
},
|
|
tenancy: { mode: "domain", required: false, rootDomains: ["localhost"] },
|
|
build: { cache: true, sourceMaps: true, report: true, adapter: "bun" },
|
|
navigation: { mode: "auto" },
|
|
devToolbar: { enabled: true, position: "bottom-center", openEditor: true },
|
|
|
|
mobile: {
|
|
enabled: true,
|
|
appId: "com.example.APP_SLUG",
|
|
appName: "APP_NAME",
|
|
userAgent: "WrNexusMobile",
|
|
backgroundColor: "#0f172a",
|
|
// layout: "mobile", // app/layouts/mobile.wrn
|
|
// icon: "resources/icon.png",
|
|
},
|
|
|
|
// PWA support is enabled automatically. Override any install metadata here.
|
|
pwa: {
|
|
name: "APP_NAME",
|
|
shortName: "APP_NAME",
|
|
display: "standalone",
|
|
themeColor: "#6366f1",
|
|
backgroundColor: "#0f172a",
|
|
},
|
|
|
|
seo: {
|
|
title: "APP_NAME",
|
|
titleTemplate: "%s | APP_NAME",
|
|
// Set WRNEXUS_PUBLIC_ORIGIN in production when TLS terminates at a proxy.
|
|
canonicalBase: process.env.WRNEXUS_PUBLIC_ORIGIN,
|
|
description: "An SSR-first WrNexus app.",
|
|
robots: "index,follow",
|
|
themeColor: "#6366f1",
|
|
},
|
|
|
|
styles: {
|
|
entry: "app/styles/global.css",
|
|
|
|
// Tailwind v4 build. Runs once at dev-serve time (cached; re-run on restart)
|
|
// and at \`wrnexus build\`. \`@tailwindcss/cli\` writes to stdout, so we capture
|
|
// and return the final CSS. Delete this hook to drop Tailwind — global.css is
|
|
// still bundled and served as-is.
|
|
process: async ({ entryPath, appRoot, mode }) => {
|
|
const args = ["@tailwindcss/cli", "-i", entryPath!];
|
|
if (mode === "production") args.push("--minify");
|
|
return await Bun.$.cwd(appRoot)\`bunx \${args}\`.text();
|
|
},
|
|
},
|
|
|
|
// Fonts — optimized preconnect, subsetted weights, font-display, and CSP.
|
|
fonts: {
|
|
sans: '"Plus Jakarta Sans", ui-sans-serif, system-ui, sans-serif',
|
|
google: [{ family: "Plus Jakarta Sans", weights: [400, 500, 600, 700] }],
|
|
},
|
|
//
|
|
// // Or self-host (fastest, no third party) — drop files in public/fonts/:
|
|
// // local: [{ family: "Inter", src: "/fonts/inter.woff2", weight: "100 900", preload: true }],
|
|
|
|
theme: { palette: "violet", default: "light" },
|
|
i18n: { default: "en", locales: ["en"] },
|
|
db: { driver: "sqlite", url: process.env.DATABASE_URL ?? "file:./dev.db" },
|
|
databases: {},
|
|
storage: {
|
|
default: "public",
|
|
stores: {
|
|
public: {
|
|
driver: "local",
|
|
access: "public",
|
|
dir: "uploads/public",
|
|
maxBytes: 10_000_000,
|
|
accept: ["image/*", "application/pdf"],
|
|
},
|
|
private: {
|
|
driver: "local",
|
|
access: "private",
|
|
dir: "uploads/private",
|
|
maxBytes: 10_000_000,
|
|
},
|
|
},
|
|
},
|
|
realtime: { scale: Boolean(process.env.REDIS_URL), redisUrl: process.env.REDIS_URL },
|
|
port: Number(process.env.PORT ?? 3000),
|
|
security: {
|
|
cors: { enabled: false },
|
|
},
|
|
profiles: {
|
|
development: {},
|
|
test: {
|
|
db: { driver: "sqlite", url: "file:./test.db" },
|
|
observability: { exporter: "none", sampleRate: 0 },
|
|
},
|
|
staging: {
|
|
seo: { robots: "noindex,nofollow" },
|
|
performance: { enforcement: "error" },
|
|
build: { sourceMaps: true, report: true },
|
|
},
|
|
production: {
|
|
seo: { canonicalBase: process.env.WRNEXUS_PUBLIC_ORIGIN },
|
|
performance: { enforcement: "error" },
|
|
build: { sourceMaps: false, report: true },
|
|
devToolbar: false,
|
|
},
|
|
},
|
|
};
|
|
|
|
export default config;
|
|
`,
|
|
"public/robots.txt": `User-agent: *
|
|
Allow: /
|
|
`,
|
|
"app/locales/en.json": `{
|
|
"common": {
|
|
"appName": "APP_NAME",
|
|
"welcome": "Welcome to APP_NAME"
|
|
}
|
|
}
|
|
`,
|
|
"app/db/migrations/0001_init.sql": `-- Create application tables here.
|
|
-- Run with: bunx wrnexus db migrate
|
|
`,
|
|
"app/db/seed.ts": `// Add deterministic development seed data here.
|
|
export async function seed(): Promise<void> {}
|
|
`,
|
|
"app/schemas/contact.ts": `import { v } from "@wrnexus/validation";
|
|
|
|
export const contactSchema = v.object({
|
|
email: v.string().email(),
|
|
message: v.string().min(10).max(2_000),
|
|
});
|
|
`,
|
|
"app/styles/global.css": `/*
|
|
* Global stylesheet. Tailwind v4 is compiled by the styles.process hook in
|
|
* wrnexus.config.ts and served at /__wrnexus/styles.css on every page.
|
|
*
|
|
* @source tells Tailwind which files to scan for class names.
|
|
*/
|
|
@import "tailwindcss";
|
|
@plugin "@iconify/tailwind4";
|
|
@source "../**/*.wrn";
|
|
@source "../**/*.tsx";
|
|
|
|
/* Make Tailwind's \`dark:\` variant follow the framework's data-theme attribute
|
|
* (set on <html> by the theme system), not the OS setting. Any element with
|
|
* data-wrn-theme-toggle flips it. */
|
|
@custom-variant dark (&:where([data-theme="dark"], [data-theme="dark"] *));
|
|
|
|
body {
|
|
font-family: var(--wrn-font-sans, "Plus Jakarta Sans", ui-sans-serif, system-ui, sans-serif);
|
|
}
|
|
`,
|
|
"app/layouts/document.wrn": `// Global document layout. The framework renders this once around the selected
|
|
// page layout and merges SEO metadata, styles, and scripts into <head>/<body>.
|
|
// Resolved theme, language, URL, and pathname are available as safe SSR props,
|
|
// so document attributes do not need a client-side correction. Request cookies
|
|
// remain server-private and are never passed into document hydration.
|
|
layout Document {
|
|
runtime = "server"
|
|
|
|
props {
|
|
theme = "light"
|
|
language = "en"
|
|
url = ""
|
|
pathname = "/"
|
|
}
|
|
|
|
view {
|
|
<html>
|
|
<head></head>
|
|
<body>
|
|
<div id="app"><slot /></div>
|
|
</body>
|
|
</html>
|
|
}
|
|
}
|
|
`,
|
|
"app/pages/index.wrn": `// Home page (route: /). SSR-first: the view is server-rendered, then components
|
|
// (.wrn files under app/components) hydrate in the browser. Styled with Tailwind.
|
|
page Home {
|
|
seo {
|
|
title = "Home"
|
|
description = "APP_NAME — built with WrNexus, an SSR-first Bun framework."
|
|
}
|
|
|
|
view {
|
|
<main class="relative min-h-screen overflow-hidden bg-white text-slate-900 dark:bg-[#0b0f1e] dark:text-slate-100">
|
|
<div aria-hidden="true" class="pointer-events-none absolute inset-x-0 -top-40 mx-auto h-96 max-w-2xl rounded-full bg-indigo-500/20 blur-3xl"></div>
|
|
|
|
<div class="relative mx-auto flex min-h-screen max-w-3xl flex-col px-6">
|
|
<header class="flex items-center justify-between py-6">
|
|
<span class="flex items-center gap-2.5 font-semibold tracking-tight">
|
|
<span class="grid h-7 w-7 place-items-center rounded-md bg-gradient-to-br from-indigo-500 to-violet-600 text-sm font-bold text-white">W</span>
|
|
APP_NAME
|
|
</span>
|
|
<button data-wrn-theme-toggle class="rounded-md border border-slate-200 px-3 py-1.5 text-sm text-slate-600 transition hover:border-slate-300 hover:text-slate-900 dark:border-white/10 dark:text-slate-400 dark:hover:border-white/20 dark:hover:text-white">
|
|
Toggle theme
|
|
</button>
|
|
</header>
|
|
|
|
<section class="flex flex-1 flex-col items-center justify-center py-16 text-center">
|
|
<p class="font-mono text-xs uppercase tracking-[0.2em] text-indigo-500 dark:text-indigo-400">SSR-first · Bun-native</p>
|
|
|
|
<h1 class="mt-5 text-4xl font-bold leading-[1.1] tracking-tight sm:text-6xl">
|
|
Server-rendered.<br />
|
|
Instantly <span class="bg-gradient-to-r from-indigo-500 to-violet-500 bg-clip-text text-transparent">interactive</span>.
|
|
</h1>
|
|
|
|
<p class="mt-5 max-w-md text-base leading-relaxed text-slate-600 dark:text-slate-400">
|
|
APP_NAME runs on WrNexus — write <code class="rounded bg-slate-100 px-1.5 py-0.5 font-mono text-[0.85em] text-slate-800 dark:bg-white/10 dark:text-slate-200">.wrn</code> components, ship no client boilerplate, and let the server do the work.
|
|
</p>
|
|
|
|
<div class="mt-8 flex flex-wrap items-center justify-center gap-3">
|
|
<a href="/about" class="rounded-lg bg-slate-900 px-5 py-2.5 text-sm font-medium text-white shadow-sm transition hover:bg-slate-700 dark:bg-white dark:text-slate-900 dark:hover:bg-slate-200">Get started</a>
|
|
<a href="/api/hello" class="rounded-lg border border-slate-200 px-5 py-2.5 text-sm font-medium text-slate-700 transition hover:border-slate-300 dark:border-white/10 dark:text-slate-300 dark:hover:border-white/20">View API</a>
|
|
</div>
|
|
|
|
<div class="mt-14 w-full max-w-md rounded-2xl border border-slate-200 bg-white p-6 text-left shadow-sm dark:border-white/10 dark:bg-white/5">
|
|
<div class="flex items-center gap-2 font-mono text-xs text-slate-400">
|
|
<span class="h-2 w-2 rounded-full bg-emerald-400"></span>
|
|
live · hydrated on the server
|
|
</div>
|
|
<div class="mt-4 flex items-center justify-between gap-4">
|
|
<div data-component="counter" start="0" label="Clicks"></div>
|
|
<span class="max-w-[10rem] text-right text-xs leading-snug text-slate-500">This button works. You wrote zero client JavaScript.</span>
|
|
</div>
|
|
</div>
|
|
|
|
<p class="mt-10 font-mono text-xs text-slate-400 dark:text-slate-600">
|
|
edit <span class="text-slate-600 dark:text-slate-400">app/pages/index.wrn</span> to make it yours
|
|
</p>
|
|
</section>
|
|
|
|
<footer class="border-t border-slate-100 py-6 text-center text-xs text-slate-400 dark:border-white/5 dark:text-slate-600">
|
|
Built with <a href="https://www.npmjs.com/package/@wrnexus/cli" class="text-slate-600 underline-offset-2 hover:underline dark:text-slate-400">WrNexus</a>
|
|
</footer>
|
|
</div>
|
|
</main>
|
|
}
|
|
}
|
|
`,
|
|
"app/pages/about.wrn": `page About {
|
|
seo {
|
|
title = "About"
|
|
description = "Learn how APP_NAME is built with WrNexus."
|
|
}
|
|
|
|
view {
|
|
<main class="min-h-screen bg-white px-6 py-20 text-slate-900 dark:bg-[#0b0f1e] dark:text-slate-100">
|
|
<article class="mx-auto max-w-2xl">
|
|
<a href="/" class="text-sm text-indigo-600 hover:underline dark:text-indigo-400">← Home</a>
|
|
<p class="mt-12 font-mono text-xs uppercase tracking-[0.2em] text-indigo-500">WrNexus application</p>
|
|
<h1 class="mt-4 text-4xl font-bold tracking-tight">About APP_NAME</h1>
|
|
<p class="mt-6 text-lg leading-8 text-slate-600 dark:text-slate-400">
|
|
This page is server-rendered from <code>app/pages/about.wrn</code>. Add state,
|
|
events, components, APIs, and data without switching to another UI framework.
|
|
</p>
|
|
</article>
|
|
</main>
|
|
}
|
|
}
|
|
`,
|
|
"app/components/counter.wrn": `// A reusable component. Route: none — mounted inside a page with
|
|
// <div data-component="counter" ...props></div>.
|
|
//
|
|
// Components render on the SERVER (with their props applied) and are hydrated in
|
|
// the browser by the generic reactive runtime — they ship no JS of their own.
|
|
component Counter {
|
|
// Props arrive as mount attributes, each coerced to the type of its default
|
|
// (so start="5" arrives as the number 5).
|
|
props {
|
|
start = 0
|
|
label = "Count"
|
|
}
|
|
|
|
// State can reference props. \`count\` seeds the reactive scope.
|
|
state count = start
|
|
|
|
view {
|
|
<button @click="count++" class="rounded-lg bg-indigo-600 px-4 py-2 text-sm font-medium text-white shadow-sm transition hover:bg-indigo-500 active:scale-[0.98]">{label}: {count}</button>
|
|
}
|
|
}
|
|
`,
|
|
"app/api/hello.ts": `export const GET = async () => {
|
|
return Response.json({ message: "Hello API" });
|
|
};
|
|
`,
|
|
"app/api/ai.ts": `// POST /api/ai { "prompt": "..." } → Claude's reply.
|
|
// Set ANTHROPIC_API_KEY in your environment (e.g. a .env file) to enable this.
|
|
import { createAI } from "@wrnexus/ai";
|
|
import type { Context } from "@wrnexus/core";
|
|
|
|
const ai = createAI(); // reads ANTHROPIC_API_KEY; defaults to claude-opus-4-8
|
|
|
|
export const POST = async (ctx: Context) => {
|
|
if (!process.env.ANTHROPIC_API_KEY) {
|
|
return Response.json({ error: "Set ANTHROPIC_API_KEY to use AI." }, { status: 501 });
|
|
}
|
|
const { prompt } = await ctx.req.json().catch(() => ({}));
|
|
if (!prompt) return Response.json({ error: "Provide a 'prompt'." }, { status: 400 });
|
|
|
|
// Stream the reply back as plain text. Use \`ai.generate(prompt)\` for a one-shot string.
|
|
return ai.streamResponse(prompt);
|
|
};
|
|
`,
|
|
"app/middleware/logger.ts": `import type { Middleware } from "@wrnexus/core";
|
|
|
|
const logger: Middleware = async (ctx, next) => {
|
|
console.log(ctx.req.method, ctx.url.pathname);
|
|
return next();
|
|
};
|
|
|
|
export default logger;
|
|
`,
|
|
"app/realtime/chat.ts": `// ws://<host>/realtime/chat — a simple broadcast room.
|
|
//
|
|
// The client side is the framework's realtime runtime; a page opts in with
|
|
// \`data-room="chat"\`. Here we only handle room events.
|
|
//
|
|
// client.send(msg) → just this connection
|
|
// client.broadcast(msg) → everyone else in the room
|
|
// client.room.broadcast(msg) → everyone, including the sender
|
|
import { defineRoom } from "@wrnexus/core";
|
|
|
|
export default defineRoom({
|
|
onConnect(client) {
|
|
client.send({ type: "system", text: "connected" });
|
|
},
|
|
|
|
onMessage(client, msg) {
|
|
// Echo each message to the whole room so every tab stays in sync.
|
|
client.room.broadcast({ type: "message", data: msg });
|
|
},
|
|
});
|
|
`,
|
|
"test/smoke.test.ts": `import { expect, test } from "bun:test";
|
|
import { parseOrThrow } from "@wrnexus/validation";
|
|
import { contactSchema } from "../app/schemas/contact.ts";
|
|
|
|
test("starter validation schema accepts a contact request", () => {
|
|
expect(
|
|
parseOrThrow(contactSchema, {
|
|
email: "hello@example.com",
|
|
message: "Hello from the generated application.",
|
|
}),
|
|
).toEqual({
|
|
email: "hello@example.com",
|
|
message: "Hello from the generated application.",
|
|
});
|
|
});
|
|
`,
|
|
};
|
|
|
|
/**
|
|
* Write the app template into `root` (absolute), substituting the app name.
|
|
* Reused by `wrnexus create` and the workspace scaffolder. Refuses to overwrite.
|
|
*/
|
|
export function scaffoldApp(root: string, appName: string): void {
|
|
if (existsSync(root)) {
|
|
console.error(`Refusing to overwrite existing directory: ${root}`);
|
|
process.exit(1);
|
|
}
|
|
for (const [rel, contents] of Object.entries(TEMPLATE)) {
|
|
const target = join(root, rel);
|
|
mkdirSync(dirname(target), { recursive: true });
|
|
const appSlug = appName.toLowerCase().replace(/[^a-z0-9]+/g, "") || "app";
|
|
writeFileSync(
|
|
target,
|
|
contents.replaceAll("APP_NAME", appName).replaceAll("APP_SLUG", appSlug),
|
|
"utf8",
|
|
);
|
|
}
|
|
}
|
|
|
|
export function createApp(name: string): void {
|
|
if (!name) {
|
|
console.error("Usage: wrnexus create <app-name>");
|
|
process.exit(1);
|
|
}
|
|
|
|
scaffoldApp(resolve(process.cwd(), name), name);
|
|
|
|
console.log(`✓ Created ${name}`);
|
|
console.log(`\nNext steps:`);
|
|
console.log(` cd ${name}`);
|
|
console.log(` bun install`);
|
|
console.log(` bun run dev`);
|
|
}
|