Skip to content

Commit b8f569b

Browse files
committed
feat: scaffold templates (full-stack + API) and code generators
webjs create <name> --template api Backend-only: route wrappers over typed server actions, no pages/SSR webjs generate page|module|action|query|component|route <name> Generates files following CONVENTIONS.md patterns: - page: app/<path>/page.ts - module: modules/<name>/{actions,queries,components,utils,types.ts} - action: modules/<module>/actions/<name>.server.ts - query: modules/<module>/queries/<name>.server.ts - component: components/<tag-name>.ts with WebComponent boilerplate - route: app/<path>/route.ts with GET/POST stubs Convention: routes are thin wrappers over typed server actions. Business logic in modules/, routing in app/.
1 parent 5f16443 commit b8f569b

3 files changed

Lines changed: 303 additions & 16 deletions

File tree

packages/cli/bin/webjs.js

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,8 @@ const USAGE = `webjs — commands:
1313
[--http2 --cert <path> --key <path>] Serve HTTP/2 over TLS (falls back to h1.1)
1414
webjs test [--server|--browser] Run server + browser tests
1515
webjs check Validate app against conventions
16-
webjs create <name> Scaffold a new webjs app
16+
webjs create <name> [--template full-stack|api] Scaffold a new webjs app
17+
webjs generate <type> <name> Generate code (page, module, action, query, component, route)
1718
webjs db generate Run \`prisma generate\`
1819
webjs db migrate [name] Run \`prisma migrate dev\`
1920
webjs db studio Run \`prisma studio\`
@@ -202,11 +203,18 @@ async function main() {
202203
case 'create': {
203204
const name = rest[0];
204205
if (!name) {
205-
console.error('Usage: webjs create <app-name>');
206+
console.error('Usage: webjs create <app-name> [--template full-stack|api]');
206207
process.exit(1);
207208
}
209+
const template = flag(rest, '--template', 'full-stack');
208210
const { scaffoldApp } = await import('../lib/create.js');
209-
await scaffoldApp(name, process.cwd());
211+
await scaffoldApp(name, process.cwd(), { template });
212+
break;
213+
}
214+
case 'generate':
215+
case 'g': {
216+
const { generate } = await import('../lib/generate.js');
217+
await generate(rest, process.cwd());
210218
break;
211219
}
212220
case 'help':

packages/cli/lib/create.js

Lines changed: 86 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -23,14 +23,16 @@ const TEMPLATES = resolve(__dirname, '..', 'templates');
2323
* @param {string} name App directory name
2424
* @param {string} cwd Current working directory
2525
*/
26-
export async function scaffoldApp(name, cwd) {
26+
export async function scaffoldApp(name, cwd, opts = {}) {
27+
const template = opts.template || 'full-stack';
28+
const isApi = template === 'api';
2729
const appDir = join(cwd, name);
2830
if (existsSync(appDir)) {
2931
console.error(`Error: directory '${name}' already exists.`);
3032
process.exit(1);
3133
}
3234

33-
console.log(`\nwebjs create: scaffolding '${name}'...\n`);
35+
console.log(`\nwebjs create: scaffolding '${name}' (${template})...\n`);
3436

3537
// Create directory structure
3638
const dirs = [
@@ -130,7 +132,67 @@ export async function scaffoldApp(name, cwd) {
130132
const preCommitPath = join(appDir, '.hooks', 'pre-commit');
131133
if (existsSync(preCommitPath)) await chmod(preCommitPath, 0o755);
132134

133-
// --- App files ---
135+
// --- App files (template-specific) ---
136+
137+
if (isApi) {
138+
// API-only template: no layout, no page, no components.
139+
// Just a health route and an example module with route wrapper.
140+
await mkdir(join(appDir, 'app', 'api', 'health'), { recursive: true });
141+
await mkdir(join(appDir, 'app', 'api', 'users'), { recursive: true });
142+
await writeFile(join(appDir, 'app', 'api', 'health', 'route.ts'), `export async function GET() {
143+
return Response.json({ status: 'ok', timestamp: Date.now() });
144+
}
145+
`);
146+
await mkdir(join(appDir, 'modules', 'users', 'actions'), { recursive: true });
147+
await mkdir(join(appDir, 'modules', 'users', 'queries'), { recursive: true });
148+
149+
await writeFile(join(appDir, 'modules', 'users', 'queries', 'list-users.server.ts'), `'use server';
150+
151+
export async function listUsers() {
152+
// TODO: replace with real data source
153+
return [
154+
{ id: '1', name: 'Alice', email: 'alice@example.com' },
155+
{ id: '2', name: 'Bob', email: 'bob@example.com' },
156+
];
157+
}
158+
`);
159+
await writeFile(join(appDir, 'modules', 'users', 'actions', 'create-user.server.ts'), `'use server';
160+
161+
export async function createUser(input: { name: string; email: string }) {
162+
// TODO: validate input, persist to database
163+
return { success: true, data: { id: Date.now().toString(), ...input } };
164+
}
165+
`);
166+
await writeFile(join(appDir, 'app', 'api', 'users', 'route.ts'), `/**
167+
* /api/users — thin route wrapper over typed server actions.
168+
* Business logic lives in modules/users/, not here.
169+
*/
170+
import { listUsers } from '../../../../modules/users/queries/list-users.server.ts';
171+
import { createUser } from '../../../../modules/users/actions/create-user.server.ts';
172+
173+
export async function GET() {
174+
return Response.json(await listUsers());
175+
}
176+
177+
export async function POST(req: Request) {
178+
const body = await req.json();
179+
return Response.json(await createUser(body));
180+
}
181+
`);
182+
await writeFile(join(appDir, 'modules', 'users', 'types.ts'), `export interface User {
183+
id: string;
184+
name: string;
185+
email: string;
186+
}
187+
188+
export type ActionResult<T> =
189+
| { success: true; data: T }
190+
| { success: false; error: string; status: number };
191+
`);
192+
}
193+
194+
if (!isApi) {
195+
// Full-stack template: layout + page + theme toggle
134196

135197
await writeFile(join(appDir, 'app', 'layout.ts'), `import { html } from 'webjs';
136198
import 'webjs/client-router';
@@ -269,6 +331,13 @@ export class ThemeToggle extends WebComponent {
269331
270332
ThemeToggle.register(import.meta.url);
271333
`);
334+
} // end if (!isApi)
335+
336+
// --- AGENTS.md (always copy) ---
337+
const agentsSrc2 = resolve(__dirname, '..', '..', '..', 'AGENTS.md');
338+
if (!existsSync(join(appDir, 'AGENTS.md')) && existsSync(agentsSrc2)) {
339+
await cp(agentsSrc2, join(appDir, 'AGENTS.md'));
340+
}
272341

273342
// --- Git init + configure hooks directory ---
274343
const { execSync } = await import('node:child_process');
@@ -280,21 +349,25 @@ ThemeToggle.register(import.meta.url);
280349

281350
// --- Print success ---
282351

283-
console.log(` ${name}/
352+
if (isApi) {
353+
console.log(` ${name}/
354+
app/api/health/route.ts
355+
app/api/users/route.ts ← thin wrapper over server actions
356+
modules/users/actions/create-user.server.ts
357+
modules/users/queries/list-users.server.ts
358+
modules/users/types.ts
359+
CONVENTIONS.md, AGENTS.md, CLAUDE.md
360+
package.json, tsconfig.json
361+
`);
362+
} else {
363+
console.log(` ${name}/
284364
app/layout.ts, page.ts
285365
components/theme-toggle.ts
286366
modules/
287-
test/unit/example.test.ts
288-
test/browser/example.test.js
289-
web-test-runner.config.js
290367
CONVENTIONS.md, AGENTS.md, CLAUDE.md
291-
package.json, tsconfig.json, .editorconfig
292-
.claude/settings.json, .claude/hooks/ ← Claude Code guardrails
293-
.cursorrules ← Cursor AI guardrails
294-
.windsurfrules ← Windsurf AI guardrails
295-
.github/copilot-instructions.md ← GitHub Copilot guardrails
296-
.github/pull_request_template.md ← PR template
368+
package.json, tsconfig.json
297369
`);
370+
}
298371
console.log(`Next steps:
299372
cd ${name}
300373
npm install

packages/cli/lib/generate.js

Lines changed: 206 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,206 @@
1+
/**
2+
* `webjs generate <type> <name>` — code generators following CONVENTIONS.md.
3+
*
4+
* Generates files with sensible defaults matching the module architecture:
5+
* webjs generate page contact → app/contact/page.ts
6+
* webjs generate module posts → modules/posts/{actions,queries,components,utils,types.ts}
7+
* webjs generate action posts/create → modules/posts/actions/create.server.ts
8+
* webjs generate query posts/list → modules/posts/queries/list.server.ts
9+
* webjs generate component my-widget → components/my-widget.ts
10+
* webjs generate route api/webhooks → app/api/webhooks/route.ts
11+
*/
12+
13+
import { mkdir, writeFile } from 'node:fs/promises';
14+
import { join, dirname } from 'node:path';
15+
import { existsSync } from 'node:fs';
16+
17+
const USAGE = `Usage: webjs generate <type> <name>
18+
19+
Types:
20+
page <path> app/<path>/page.ts
21+
module <name> modules/<name>/{actions,queries,components,utils,types.ts}
22+
action <module/name> modules/<module>/actions/<name>.server.ts
23+
query <module/name> modules/<module>/queries/<name>.server.ts
24+
component <tag-name> components/<tag-name>.ts
25+
route <path> app/<path>/route.ts
26+
27+
Examples:
28+
webjs generate page contact
29+
webjs generate module posts
30+
webjs generate action posts/create
31+
webjs generate query posts/list
32+
webjs generate component my-widget
33+
webjs generate route api/webhooks`;
34+
35+
/**
36+
* @param {string[]} args
37+
* @param {string} cwd
38+
*/
39+
export async function generate(args, cwd) {
40+
const [type, name] = args;
41+
if (!type || !name) { console.error(USAGE); process.exit(1); }
42+
43+
switch (type) {
44+
case 'page': return genPage(name, cwd);
45+
case 'module': return genModule(name, cwd);
46+
case 'action': return genAction(name, cwd);
47+
case 'query': return genQuery(name, cwd);
48+
case 'component': return genComponent(name, cwd);
49+
case 'route': return genRoute(name, cwd);
50+
default:
51+
console.error(`Unknown type: ${type}\n${USAGE}`);
52+
process.exit(1);
53+
}
54+
}
55+
56+
async function write(file, content) {
57+
await mkdir(dirname(file), { recursive: true });
58+
if (existsSync(file)) {
59+
console.error(` ✗ ${file} already exists — skipping`);
60+
return;
61+
}
62+
await writeFile(file, content);
63+
console.log(` ✓ ${file}`);
64+
}
65+
66+
function toPascal(s) {
67+
return s.replace(/(^|[-_/])(\w)/g, (_, __, c) => c.toUpperCase());
68+
}
69+
70+
function toCamel(s) {
71+
const p = toPascal(s);
72+
return p[0].toLowerCase() + p.slice(1);
73+
}
74+
75+
async function genPage(path, cwd) {
76+
const file = join(cwd, 'app', path, 'page.ts');
77+
const name = toPascal(path.split('/').pop() || path);
78+
console.log(`Generating page: /${path}`);
79+
await write(file, `import { html } from 'webjs';
80+
81+
export const metadata = { title: '${name}' };
82+
83+
export default function ${name}Page() {
84+
return html\`
85+
<h1>${name}</h1>
86+
<p>Edit <code>app/${path}/page.ts</code></p>
87+
\`;
88+
}
89+
`);
90+
}
91+
92+
async function genModule(name, cwd) {
93+
const base = join(cwd, 'modules', name);
94+
console.log(`Generating module: ${name}`);
95+
await mkdir(join(base, 'actions'), { recursive: true });
96+
await mkdir(join(base, 'queries'), { recursive: true });
97+
await mkdir(join(base, 'components'), { recursive: true });
98+
await mkdir(join(base, 'utils'), { recursive: true });
99+
100+
await write(join(base, 'types.ts'), `/**
101+
* Shared types for the ${name} module.
102+
*/
103+
104+
export interface ActionResult<T> {
105+
success: true; data: T;
106+
} | {
107+
success: false; error: string; status: number;
108+
}
109+
`);
110+
111+
console.log(` ✓ modules/${name}/actions/`);
112+
console.log(` ✓ modules/${name}/queries/`);
113+
console.log(` ✓ modules/${name}/components/`);
114+
console.log(` ✓ modules/${name}/utils/`);
115+
}
116+
117+
async function genAction(path, cwd) {
118+
const parts = path.split('/');
119+
if (parts.length < 2) {
120+
console.error('Usage: webjs generate action <module>/<name>\n e.g. webjs generate action posts/create');
121+
process.exit(1);
122+
}
123+
const mod = parts[0];
124+
const name = parts.slice(1).join('-');
125+
const fnName = toCamel(name);
126+
const file = join(cwd, 'modules', mod, 'actions', `${name}.server.ts`);
127+
console.log(`Generating action: ${mod}/${name}`);
128+
await write(file, `'use server';
129+
130+
// import { prisma } from '../../../lib/prisma.ts';
131+
132+
export async function ${fnName}(input: unknown) {
133+
// TODO: implement
134+
return { success: true, data: null };
135+
}
136+
`);
137+
}
138+
139+
async function genQuery(path, cwd) {
140+
const parts = path.split('/');
141+
if (parts.length < 2) {
142+
console.error('Usage: webjs generate query <module>/<name>\n e.g. webjs generate query posts/list');
143+
process.exit(1);
144+
}
145+
const mod = parts[0];
146+
const name = parts.slice(1).join('-');
147+
const fnName = toCamel(name);
148+
const file = join(cwd, 'modules', mod, 'queries', `${name}.server.ts`);
149+
console.log(`Generating query: ${mod}/${name}`);
150+
await write(file, `'use server';
151+
152+
// import { prisma } from '../../../lib/prisma.ts';
153+
154+
export async function ${fnName}() {
155+
// TODO: implement
156+
return [];
157+
}
158+
`);
159+
}
160+
161+
async function genComponent(tagName, cwd) {
162+
if (!tagName.includes('-')) {
163+
console.error(`Component tag name must contain a hyphen: ${tagName}`);
164+
process.exit(1);
165+
}
166+
const className = toPascal(tagName);
167+
const file = join(cwd, 'components', `${tagName}.ts`);
168+
console.log(`Generating component: <${tagName}>`);
169+
await write(file, `import { WebComponent, html, css } from 'webjs';
170+
171+
export class ${className} extends WebComponent {
172+
static tag = '${tagName}';
173+
static styles = css\`
174+
:host { display: block; }
175+
\`;
176+
177+
render() {
178+
return html\`<p>${tagName} works</p>\`;
179+
}
180+
}
181+
${className}.register(import.meta.url);
182+
`);
183+
}
184+
185+
async function genRoute(path, cwd) {
186+
const file = join(cwd, 'app', path, 'route.ts');
187+
console.log(`Generating route: /${path}`);
188+
await write(file, `/**
189+
* ${path} route handler.
190+
*
191+
* Convention: routes are thin wrappers over typed server actions.
192+
* Business logic lives in modules/, not here.
193+
*/
194+
195+
export async function GET(req: Request) {
196+
return Response.json({ status: 'ok' });
197+
}
198+
199+
export async function POST(req: Request) {
200+
const body = await req.json();
201+
// import { myAction } from '.../.server.ts';
202+
// return Response.json(await myAction(body));
203+
return Response.json({ received: body });
204+
}
205+
`);
206+
}

0 commit comments

Comments
 (0)