@webjsdev/cli 0.8.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/create.js ADDED
@@ -0,0 +1,898 @@
1
+ /**
2
+ * `webjs create <name>`: scaffold a new webjs app with opinionated defaults.
3
+ *
4
+ * Creates a directory with:
5
+ * - app/ with a root layout + page
6
+ * - modules/ skeleton
7
+ * - components/ with a theme toggle
8
+ * - test/unit/ and test/e2e/ with example tests
9
+ * - CONVENTIONS.md, AGENTS.md, CLAUDE.md
10
+ * - package.json with webjs deps + test scripts
11
+ * - tsconfig.json for editor support
12
+ */
13
+
14
+ import { mkdir, writeFile, readFile, cp } from 'node:fs/promises';
15
+ import { join, resolve, dirname } from 'node:path';
16
+ import { fileURLToPath } from 'node:url';
17
+ import { existsSync } from 'node:fs';
18
+
19
+ const __dirname = dirname(fileURLToPath(import.meta.url));
20
+ const TEMPLATES = resolve(__dirname, '..', 'templates');
21
+
22
+ // Root of the @webjsdev/ui registry workspace. We read component sources
23
+ // directly from disk at create time so the scaffolded app boots ready for
24
+ // `webjs ui add` without an HTTP round-trip during scaffolding.
25
+ //
26
+ // Layout in the monorepo:
27
+ // packages/cli/lib/create.js ← __dirname
28
+ // packages/ui/packages/registry/components/*.ts
29
+ // packages/ui/packages/registry/lib/utils.ts
30
+ // packages/ui/packages/registry/themes/index.css
31
+ const UI_REGISTRY_ROOT = resolve(
32
+ __dirname, '..', '..', 'ui', 'packages', 'registry',
33
+ );
34
+
35
+ /**
36
+ * Read a single @webjsdev/ui registry component, rewrite its relative import
37
+ * of `../lib/utils.ts` so it resolves correctly when written to
38
+ * `components/ui/<name>.ts` in the scaffolded app. The scaffold puts cn()
39
+ * at `lib/utils/cn.ts` (folder-grouped with other browser-safe helpers).
40
+ * From `components/ui/<x>.ts`, the equivalent path is two-up plus into the
41
+ * utils/ folder: `../../lib/utils/cn.ts`.
42
+ *
43
+ * @param {string} name component name without `.ts` (e.g. 'button')
44
+ * @returns {Promise<string|null>} source or null if not found
45
+ */
46
+ async function readUiComponent(name) {
47
+ const src = join(UI_REGISTRY_ROOT, 'components', `${name}.ts`);
48
+ if (!existsSync(src)) return null;
49
+ const raw = await readFile(src, 'utf8');
50
+ return raw
51
+ .replaceAll("'../lib/utils.ts'", "'../../lib/utils/cn.ts'")
52
+ .replaceAll('"../lib/utils.ts"', '"../../lib/utils/cn.ts"');
53
+ }
54
+
55
+ /**
56
+ * Copy a list of @webjsdev/ui registry components into the scaffolded app
57
+ * under `components/ui/`. Silently skips any name that isn't in the registry.
58
+ *
59
+ * @param {string} appDir destination app root
60
+ * @param {string[]} names list of component file basenames (without `.ts`)
61
+ */
62
+ async function copyUiComponents(appDir, names) {
63
+ const uiDir = join(appDir, 'components', 'ui');
64
+ await mkdir(uiDir, { recursive: true });
65
+ for (const n of names) {
66
+ const content = await readUiComponent(n);
67
+ if (content == null) continue;
68
+ await writeFile(join(uiDir, `${n}.ts`), content);
69
+ }
70
+ }
71
+
72
+ /**
73
+ * Write `lib/utils/cn.ts` (the `cn()` helper) and `components.json` so the
74
+ * scaffolded app is pre-initialised for `webjs ui add`. Reads the registry's
75
+ * `lib/utils.ts` verbatim and writes it under `lib/utils/cn.ts` in the
76
+ * scaffolded app so cn() sits in the same folder as the other browser-safe
77
+ * helpers (ui.ts, format.ts).
78
+ *
79
+ * @param {string} appDir
80
+ */
81
+ async function writeUiBootstrap(appDir) {
82
+ // 1) lib/utils/cn.ts: the cn() helper
83
+ const utilsSrc = join(UI_REGISTRY_ROOT, 'lib', 'utils.ts');
84
+ if (existsSync(utilsSrc)) {
85
+ const content = await readFile(utilsSrc, 'utf8');
86
+ await mkdir(join(appDir, 'lib', 'utils'), { recursive: true });
87
+ await writeFile(join(appDir, 'lib', 'utils', 'cn.ts'), content);
88
+ }
89
+
90
+ // 2) components.json: the same shape `webjsui init` writes for webjs
91
+ // projects (see packages/ui/src/utils/detect-project.js). The utils alias
92
+ // is lib/utils/cn so get-config.js's `+ '.ts'` resolves to lib/utils/cn.ts.
93
+ const componentsJson = {
94
+ $schema: 'https://ui.webjs.dev/schema.json',
95
+ style: 'default',
96
+ tailwind: {
97
+ css: 'app/globals.css',
98
+ baseColor: 'neutral',
99
+ cssVariables: true,
100
+ },
101
+ aliases: {
102
+ components: 'components',
103
+ utils: 'lib/utils/cn',
104
+ ui: 'components/ui',
105
+ lib: 'lib',
106
+ },
107
+ iconLibrary: 'lucide',
108
+ };
109
+ await writeFile(
110
+ join(appDir, 'components.json'),
111
+ JSON.stringify(componentsJson, null, 2) + '\n',
112
+ );
113
+
114
+ // 3) app/globals.css: copy the neutral theme verbatim. components.json
115
+ // references this path, and future `webjs ui add` calls append to it.
116
+ const themeSrc = join(UI_REGISTRY_ROOT, 'themes', 'index.css');
117
+ if (existsSync(themeSrc)) {
118
+ const css = await readFile(themeSrc, 'utf8');
119
+ await mkdir(join(appDir, 'app'), { recursive: true });
120
+ await writeFile(join(appDir, 'app', 'globals.css'), css);
121
+ }
122
+ }
123
+
124
+ /**
125
+ * Read the shadcn theme CSS so we can inline it into the layout's
126
+ * `<style type="text/tailwindcss">` block. The Tailwind browser runtime
127
+ * picks up inline `<style type="text/tailwindcss">` content, so the theme
128
+ * tokens (`--color-primary`, `--color-card`, …) the registry components
129
+ * consume are available at runtime without a build step.
130
+ *
131
+ * @returns {Promise<string>} theme CSS source, or '' if registry missing
132
+ */
133
+ async function readThemeCss() {
134
+ const src = join(UI_REGISTRY_ROOT, 'themes', 'index.css');
135
+ if (!existsSync(src)) return '';
136
+ return await readFile(src, 'utf8');
137
+ }
138
+
139
+ /**
140
+ * @param {string} name App directory name
141
+ * @param {string} cwd Current working directory
142
+ */
143
+ export async function scaffoldApp(name, cwd, opts = {}) {
144
+ const template = opts.template || 'full-stack';
145
+ // Defence in depth. The CLI already validates this, but library
146
+ // callers (tests, programmatic use) might pass anything.
147
+ const VALID_TEMPLATES = ['full-stack', 'api', 'saas'];
148
+ if (!VALID_TEMPLATES.includes(template)) {
149
+ throw new Error(
150
+ `Unknown template '${template}'. Only ${VALID_TEMPLATES.join(' / ')} exist.`,
151
+ );
152
+ }
153
+ const isApi = template === 'api';
154
+ const isSaas = template === 'saas';
155
+ const appDir = join(cwd, name);
156
+ if (existsSync(appDir)) {
157
+ console.error(`Error: directory '${name}' already exists.`);
158
+ process.exit(1);
159
+ }
160
+
161
+ console.log(`\nwebjs create: scaffolding '${name}' (${template})...\n`);
162
+
163
+ // Create directory structure
164
+ const dirs = [
165
+ 'app',
166
+ 'components',
167
+ 'modules',
168
+ 'lib',
169
+ 'public',
170
+ 'prisma',
171
+ 'test/unit',
172
+ 'test/e2e',
173
+ ];
174
+ for (const d of dirs) await mkdir(join(appDir, d), { recursive: true });
175
+
176
+ // --- Root files ---
177
+
178
+ await writeFile(join(appDir, 'package.json'), JSON.stringify({
179
+ name,
180
+ version: '0.1.0',
181
+ type: 'module',
182
+ private: true,
183
+ scripts: {
184
+ predev: 'prisma generate',
185
+ prestart: 'prisma migrate deploy',
186
+ dev: 'webjs dev',
187
+ start: 'webjs start',
188
+ test: 'webjs test',
189
+ 'test:server': 'webjs test --server',
190
+ 'test:browser': 'webjs test --browser',
191
+ check: 'webjs check',
192
+ 'db:migrate': 'prisma migrate dev',
193
+ 'db:generate': 'prisma generate',
194
+ 'db:studio': 'prisma studio',
195
+ },
196
+ dependencies: {
197
+ '@prisma/client': '^6.0.0',
198
+ '@webjsdev/cli': 'latest',
199
+ '@webjsdev/core': 'latest',
200
+ '@webjsdev/server': 'latest',
201
+ },
202
+ devDependencies: {
203
+ prisma: '^6.0.0',
204
+ '@web/test-runner': '^0.20.0',
205
+ '@web/test-runner-playwright': '^0.11.0',
206
+ 'playwright': '^1.59.0',
207
+ // tsserver plugin for editor intelligence inside html`` templates.
208
+ // @webjsdev/ts-plugin bundles ts-lit-plugin internally, so just one
209
+ // plugin entry is needed in tsconfig (see below).
210
+ '@webjsdev/ts-plugin': 'latest',
211
+ // AI-first component library CLI, preinstalled so `webjs ui add button`
212
+ // works immediately after scaffold. Users can remove if they prefer
213
+ // to add it later.
214
+ '@webjsdev/ui': 'latest',
215
+ },
216
+ }, null, 2) + '\n');
217
+
218
+ await writeFile(join(appDir, 'tsconfig.json'), JSON.stringify({
219
+ compilerOptions: {
220
+ target: 'ES2022',
221
+ module: 'NodeNext',
222
+ moduleResolution: 'NodeNext',
223
+ lib: ['ES2022', 'DOM', 'DOM.Iterable'],
224
+ strict: true,
225
+ noEmit: true,
226
+ allowImportingTsExtensions: true,
227
+ skipLibCheck: true,
228
+ // webjs uses Node's built-in type-stripping (`process.features.
229
+ // typescript === 'strip'`) which preserves source positions
230
+ // byte-exactly. The constraint is that TypeScript must be
231
+ // "erasable": no `enum`, no `namespace` with values, no
232
+ // constructor parameter properties, no legacy decorators with
233
+ // `emitDecoratorMetadata`. erasableSyntaxOnly makes the
234
+ // compiler reject those at edit time so violations surface as
235
+ // red squiggles instead of runtime ERR_UNSUPPORTED_TYPESCRIPT_
236
+ // SYNTAX errors. Use a `const` object + union for enum-shaped
237
+ // values; write fields + constructor assignments explicitly.
238
+ erasableSyntaxOnly: true,
239
+ // @webjsdev/ts-plugin gives the editor:
240
+ // • type-check + diagnostics inside html`` templates (via the
241
+ // ts-lit-plugin it bundles internally)
242
+ // • webjs-aware go-to-definition on custom-element tags
243
+ // • "Unknown tag/attribute" suppression for elements registered
244
+ // via Class.register('tag-name')
245
+ // • attribute auto-complete sourced from `static properties`
246
+ // • attribute-value type-check against `declare` annotations
247
+ // Editor-only. The framework runs without it.
248
+ plugins: [
249
+ { name: '@webjsdev/ts-plugin' },
250
+ ],
251
+ },
252
+ }, null, 2) + '\n');
253
+
254
+ // --- Templates (AGENTS.md, CONVENTIONS.md, CLAUDE.md, test files, Claude hooks) ---
255
+
256
+ const templateFiles = [
257
+ 'AGENTS.md',
258
+ 'CONVENTIONS.md',
259
+ 'CLAUDE.md',
260
+ // Starter tests under the new feature-folder layout.
261
+ 'test/hello/hello.test.ts',
262
+ 'test/hello/browser/hello.test.js',
263
+ 'test/hello/e2e/hello.test.ts',
264
+ 'web-test-runner.config.js',
265
+ // Environment variables
266
+ '.env.example',
267
+ // Project-level gitignore (node_modules, .webjs, .env, OS junk).
268
+ // The Prisma dev.db rule is appended programmatically below so it
269
+ // only appears in templates that actually use SQLite.
270
+ '.gitignore',
271
+ // Git hooks (blocks commits on main)
272
+ '.hooks/pre-commit',
273
+ // Claude Code config + hooks
274
+ '.claude.json',
275
+ '.claude/settings.json',
276
+ '.claude/hooks/block-prose-punctuation.sh',
277
+ '.claude/hooks/guard-branch-context.sh',
278
+ '.claude/hooks/nudge-uncommitted.sh',
279
+ // Gemini CLI config + hooks
280
+ '.gemini/settings.json',
281
+ '.gemini/hooks/nudge-uncommitted.sh',
282
+ // Cursor config + hooks
283
+ '.cursor/hooks.json',
284
+ '.cursor/hooks/nudge-uncommitted.sh',
285
+ // OpenCode plugins (loaded as TS by Bun at runtime)
286
+ '.opencode/plugins/nudge-uncommitted.ts',
287
+ // Cross-agent config files
288
+ '.cursorrules',
289
+ '.windsurfrules',
290
+ '.github/copilot-instructions.md',
291
+ '.github/pull_request_template.md',
292
+ '.editorconfig',
293
+ ];
294
+ for (const f of templateFiles) {
295
+ const src = join(TEMPLATES, f);
296
+ if (existsSync(src)) {
297
+ await mkdir(dirname(join(appDir, f)), { recursive: true });
298
+ let content = await readFile(src, 'utf8');
299
+ content = content.replace(/\{\{APP_NAME\}\}/g, name);
300
+ await writeFile(join(appDir, f), content);
301
+ }
302
+ }
303
+
304
+ // Make hook scripts executable
305
+ const { chmod } = await import('node:fs/promises');
306
+ for (const hook of ['block-prose-punctuation.sh', 'guard-branch-context.sh', 'nudge-uncommitted.sh']) {
307
+ const hookPath = join(appDir, '.claude', 'hooks', hook);
308
+ if (existsSync(hookPath)) await chmod(hookPath, 0o755);
309
+ }
310
+ for (const hook of ['nudge-uncommitted.sh']) {
311
+ const hookPath = join(appDir, '.gemini', 'hooks', hook);
312
+ if (existsSync(hookPath)) await chmod(hookPath, 0o755);
313
+ }
314
+ for (const hook of ['nudge-uncommitted.sh']) {
315
+ const hookPath = join(appDir, '.cursor', 'hooks', hook);
316
+ if (existsSync(hookPath)) await chmod(hookPath, 0o755);
317
+ }
318
+ // Make git pre-commit hook executable
319
+ const preCommitPath = join(appDir, '.hooks', 'pre-commit');
320
+ if (existsSync(preCommitPath)) await chmod(preCommitPath, 0o755);
321
+
322
+ // --- Prisma schema + client singleton (all templates) ---
323
+
324
+ await writeFile(join(appDir, 'prisma', 'schema.prisma'), `generator client {
325
+ provider = "prisma-client-js"
326
+ }
327
+
328
+ datasource db {
329
+ // Defaults to SQLite at ./prisma/dev.db. Switch to postgresql / mysql
330
+ // by changing the provider + DATABASE_URL in .env.
331
+ provider = "sqlite"
332
+ url = env("DATABASE_URL")
333
+ }
334
+
335
+ // Example model. Feel free to delete or extend.
336
+ model User {
337
+ id Int @id @default(autoincrement())
338
+ email String @unique
339
+ name String?
340
+ createdAt DateTime @default(now())
341
+ }
342
+ `);
343
+
344
+ await writeFile(join(appDir, 'lib', 'prisma.server.ts'), `/**
345
+ * Prisma client singleton. The \`globalThis\` trick keeps a single
346
+ * instance across dev-server module reloads, so we don't open a new
347
+ * DB connection on every file change.
348
+ */
349
+ import { PrismaClient } from '@prisma/client';
350
+
351
+ const g = globalThis as unknown as { __prisma?: PrismaClient };
352
+
353
+ export const prisma = g.__prisma ?? new PrismaClient();
354
+ if (process.env.NODE_ENV !== 'production') g.__prisma = prisma;
355
+ `);
356
+
357
+ // Env vars: append DATABASE_URL to the .env.example the template
358
+ // already copied (if present). The scaffold's root .env.example
359
+ // lists auth secrets etc.; we just add the DB line idempotently.
360
+ const envExample = join(appDir, '.env.example');
361
+ if (existsSync(envExample)) {
362
+ const cur = await readFile(envExample, 'utf8');
363
+ if (!cur.includes('DATABASE_URL')) {
364
+ await writeFile(envExample, cur.replace(/\n?$/, '\n') + '\nDATABASE_URL=file:./prisma/dev.db\n');
365
+ }
366
+ } else {
367
+ await writeFile(envExample, 'DATABASE_URL=file:./prisma/dev.db\n');
368
+ }
369
+
370
+ // .gitignore the generated SQLite file.
371
+ const gitignore = join(appDir, '.gitignore');
372
+ const gitignoreExtra = '\n# SQLite dev database\nprisma/dev.db\nprisma/dev.db-journal\n';
373
+ if (existsSync(gitignore)) {
374
+ const cur = await readFile(gitignore, 'utf8');
375
+ if (!cur.includes('prisma/dev.db')) await writeFile(gitignore, cur + gitignoreExtra);
376
+ } else {
377
+ await writeFile(gitignore, 'node_modules\n.webjs\n' + gitignoreExtra);
378
+ }
379
+
380
+ // --- App files (template-specific) ---
381
+
382
+ if (isApi) {
383
+ // API-only template: no layout, no page, no components.
384
+ // Just a health route and an example module with route wrapper.
385
+ await mkdir(join(appDir, 'app', 'api', 'health'), { recursive: true });
386
+ await mkdir(join(appDir, 'app', 'api', 'users'), { recursive: true });
387
+ await writeFile(join(appDir, 'app', 'api', 'health', 'route.ts'), `export async function GET() {
388
+ return Response.json({ status: 'ok', timestamp: Date.now() });
389
+ }
390
+ `);
391
+ await mkdir(join(appDir, 'modules', 'users', 'actions'), { recursive: true });
392
+ await mkdir(join(appDir, 'modules', 'users', 'queries'), { recursive: true });
393
+
394
+ await writeFile(join(appDir, 'modules', 'users', 'queries', 'list-users.server.ts'), `'use server';
395
+
396
+ export async function listUsers() {
397
+ // TODO: replace with real data source
398
+ return [
399
+ { id: '1', name: 'Alice', email: 'alice@example.com' },
400
+ { id: '2', name: 'Bob', email: 'bob@example.com' },
401
+ ];
402
+ }
403
+ `);
404
+ await writeFile(join(appDir, 'modules', 'users', 'actions', 'create-user.server.ts'), `'use server';
405
+
406
+ export async function createUser(input: { name: string; email: string }) {
407
+ // TODO: validate input, persist to database
408
+ return { success: true, data: { id: Date.now().toString(), ...input } };
409
+ }
410
+ `);
411
+ await writeFile(join(appDir, 'app', 'api', 'users', 'route.ts'), `/**
412
+ * /api/users: thin route wrapper over typed server actions.
413
+ * Business logic lives in modules/users/, not here.
414
+ */
415
+ import { listUsers } from '../../../modules/users/queries/list-users.server.ts';
416
+ import { createUser } from '../../../modules/users/actions/create-user.server.ts';
417
+
418
+ export async function GET() {
419
+ return Response.json(await listUsers());
420
+ }
421
+
422
+ export async function POST(req: Request) {
423
+ const body = await req.json();
424
+ return Response.json(await createUser(body));
425
+ }
426
+ `);
427
+ // Minimal test stub so the scaffold passes `webjs check` (tests-exist)
428
+ // and `webjs test` runs cleanly. Replace these with real assertions
429
+ // once you wire the action/query to a real data source.
430
+ await writeFile(join(appDir, 'test', 'unit', 'users.test.ts'), `import { test } from 'node:test';
431
+ import assert from 'node:assert/strict';
432
+
433
+ import { listUsers } from '../../modules/users/queries/list-users.server.ts';
434
+ import { createUser } from '../../modules/users/actions/create-user.server.ts';
435
+
436
+ test('listUsers returns an array', async () => {
437
+ const users = await listUsers();
438
+ assert.ok(Array.isArray(users));
439
+ });
440
+
441
+ test('createUser returns a success envelope with the input echoed back', async () => {
442
+ const result = await createUser({ name: 'Test', email: 'test@example.com' });
443
+ assert.equal(result.success, true);
444
+ assert.equal(result.data.name, 'Test');
445
+ assert.equal(result.data.email, 'test@example.com');
446
+ });
447
+ `);
448
+ await writeFile(join(appDir, 'modules', 'users', 'types.ts'), `export interface User {
449
+ id: string;
450
+ name: string;
451
+ email: string;
452
+ }
453
+
454
+ export type ActionResult<T> =
455
+ | { success: true; data: T }
456
+ | { success: false; error: string; status: number };
457
+ `);
458
+ }
459
+
460
+ if (!isApi) {
461
+ // Full-stack and SaaS templates: layout + page + theme toggle + Tailwind
462
+
463
+ // Copy the Tailwind browser runtime + lib/utils/ui.ts helpers from
464
+ // the scaffold templates directory so the app boots with the exact
465
+ // blog example architecture: light DOM + Tailwind + JS helpers.
466
+ const publicDir = join(appDir, 'public');
467
+ await mkdir(publicDir, { recursive: true });
468
+ const tailwindSrc = join(TEMPLATES, 'public', 'tailwind-browser.js');
469
+ if (existsSync(tailwindSrc)) {
470
+ await cp(tailwindSrc, join(publicDir, 'tailwind-browser.js'));
471
+ }
472
+
473
+ const utilsDir = join(appDir, 'lib', 'utils');
474
+ await mkdir(utilsDir, { recursive: true });
475
+ const uiSrc = join(TEMPLATES, 'lib', 'utils', 'ui.ts');
476
+ if (existsSync(uiSrc)) {
477
+ await cp(uiSrc, join(utilsDir, 'ui.ts'));
478
+ }
479
+
480
+ // Pre-initialise @webjsdev/ui so the scaffold boots ready for
481
+ // `webjs ui add <name>`: writes components.json + lib/utils/cn.ts +
482
+ // app/globals.css (the shadcn theme).
483
+ await writeUiBootstrap(appDir);
484
+
485
+ // Copy the standard ui-* component kit the scaffold's example pages
486
+ // use. Sources are read from packages/ui/packages/registry/ in this
487
+ // monorepo. Users can `webjs ui add <name>` for anything else.
488
+ await copyUiComponents(appDir, [
489
+ 'button', 'card', 'alert', 'badge', 'separator', 'label', 'input',
490
+ ]);
491
+
492
+ // The shadcn theme tokens (`--color-primary`, `--color-card`, …) the
493
+ // ui-* components consume. We read the registry's themes/index.css at
494
+ // create time and inline it into the layout's
495
+ // `<style type="text/tailwindcss">` block so the Tailwind browser
496
+ // runtime picks it up. Same content also lives at app/globals.css for
497
+ // `webjsui` tooling.
498
+ const SHADCN_THEME = (await readThemeCss())
499
+ // Escape backticks + ${} so the CSS survives interpolation into the
500
+ // layout's template literal below.
501
+ .replace(/\\/g, '\\\\')
502
+ .replace(/`/g, '\\`')
503
+ .replace(/\$\{/g, '\\${');
504
+
505
+ await writeFile(join(appDir, 'app', 'layout.ts'), `import { html } from '@webjsdev/core';
506
+ import '@webjsdev/core/client-router';
507
+ import '../components/theme-toggle.ts';
508
+ // Webjs UI components are tiered:
509
+ // - Tier 1 (button, card, input, label, alert, badge, separator, etc.) are
510
+ // class-helper FUNCTIONS, with no custom element to register. Each page
511
+ // imports the specific helpers it needs (e.g.
512
+ // \`import { buttonClass } from '../components/ui/button.ts'\`).
513
+ // - Tier 2 (dialog, popover, tooltip, tabs, accordion, etc.) ARE custom
514
+ // elements. Register them by side-effect-importing here once so they
515
+ // work transitively across every page:
516
+ // import '../components/ui/dialog.ts';
517
+ // The example app/page.ts below uses only Tier-1 helpers, so nothing
518
+ // extra needs to be registered. Add Tier-2 imports as you 'webjs ui add'.
519
+
520
+ /**
521
+ * Root layout: globals + chrome.
522
+ *
523
+ * Light DOM + Tailwind by default. Design tokens live in :root and are
524
+ * mapped into the Tailwind palette via @theme, so classes like
525
+ * text-fg, bg-bg-elev, font-serif, duration-fast, text-display all work.
526
+ *
527
+ * Nav + footer links repeat the same class bundle, so they're extracted
528
+ * into small JS helpers below. Each helper runs at SSR time inside
529
+ * html\\\`\\\`, producing static HTML in the response with no client runtime.
530
+ */
531
+
532
+ const navLink = (href: string, label: string) => html\`
533
+ <a href=\${href} class="text-fg-muted no-underline font-medium text-[13px] leading-none tracking-[0.005em] transition-colors duration-fast hover:text-fg">\${label}</a>
534
+ \`;
535
+
536
+ export default function RootLayout({ children }: { children: unknown }) {
537
+ return html\`
538
+ <script>
539
+ (function(){
540
+ try {
541
+ var t = localStorage.getItem('webjs_theme');
542
+ if (t === 'light' || t === 'dark') {
543
+ document.documentElement.dataset.theme = t;
544
+ }
545
+ } catch (_) {}
546
+ })();
547
+ </script>
548
+ <script src="/public/tailwind-browser.js"></script>
549
+ <!--
550
+ Webjs UI theme. Design tokens (--color-primary,
551
+ --color-card, --radius, etc.) the ui-* components consume.
552
+ The same content is also at app/globals.css; we inline it here so
553
+ the Tailwind browser runtime resolves the tokens without a build step.
554
+ Edit base palette via the :root / .dark blocks below.
555
+ -->
556
+ <style type="text/tailwindcss">
557
+ ${SHADCN_THEME}
558
+ </style>
559
+ <style type="text/tailwindcss">
560
+ @theme {
561
+ --color-fg: var(--fg);
562
+ --color-fg-muted: var(--fg-muted);
563
+ --color-fg-subtle: var(--fg-subtle);
564
+ --color-bg: var(--bg);
565
+ --color-bg-elev: var(--bg-elev);
566
+ --color-bg-subtle: var(--bg-subtle);
567
+ --color-border: var(--border);
568
+ --color-border-strong: var(--border-strong);
569
+ --color-accent: var(--accent);
570
+ --color-accent-hover: var(--accent-hover);
571
+ --color-accent-fg: var(--accent-fg);
572
+ --color-accent-tint: var(--accent-tint);
573
+ --font-sans: var(--font-sans);
574
+ --font-serif: var(--font-serif);
575
+ --font-mono: var(--font-mono);
576
+ --text-display: clamp(2.6rem, 1.6rem + 3.2vw, 4.25rem);
577
+ --text-h1: clamp(2rem, 1.5rem + 1.6vw, 2.85rem);
578
+ --text-h2: clamp(1.35rem, 1.15rem + 0.7vw, 1.7rem);
579
+ --text-lede: clamp(1.05rem, 0.95rem + 0.3vw, 1.2rem);
580
+ --duration-fast: 140ms;
581
+ --duration-slow: 380ms;
582
+ }
583
+ </style>
584
+ <style>
585
+ :root {
586
+ color-scheme: light dark;
587
+ /* ---------- dark (default) ---------- */
588
+ --fg: oklch(0.96 0.015 60);
589
+ --fg-muted: oklch(0.72 0.02 60);
590
+ --fg-subtle: oklch(0.55 0.02 60);
591
+ --bg: oklch(0.14 0.01 55);
592
+ --bg-elev: oklch(0.18 0.01 55);
593
+ --bg-subtle: oklch(0.16 0.01 55);
594
+ --border: oklch(0.26 0.012 55 / 0.9);
595
+ --border-strong: oklch(0.38 0.012 55 / 0.9);
596
+ --accent: oklch(0.78 0.14 55);
597
+ --accent-hover: oklch(0.85 0.14 55);
598
+ --accent-fg: oklch(0.15 0.01 55);
599
+ --accent-tint: oklch(0.78 0.14 55 / 0.14);
600
+ --font-sans: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
601
+ --font-serif: ui-serif, 'Iowan Old Style', Palatino, Georgia, serif;
602
+ --font-mono: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
603
+ }
604
+ :root[data-theme='light'] {
605
+ --fg: oklch(0.18 0.015 60);
606
+ --fg-muted: oklch(0.42 0.02 65);
607
+ --fg-subtle: oklch(0.62 0.015 70);
608
+ --bg: oklch(0.985 0.008 80);
609
+ --bg-elev: oklch(1 0 0);
610
+ --bg-subtle: oklch(0.96 0.008 80);
611
+ --border: oklch(0.88 0.01 75 / 0.95);
612
+ --border-strong: oklch(0.78 0.01 75 / 0.95);
613
+ --accent: oklch(0.58 0.15 55);
614
+ --accent-hover: oklch(0.5 0.15 55);
615
+ --accent-fg: oklch(1 0 0);
616
+ --accent-tint: oklch(0.58 0.15 55 / 0.1);
617
+ }
618
+ @media (prefers-color-scheme: light) {
619
+ :root:not([data-theme='dark']) {
620
+ --fg: oklch(0.18 0.015 60);
621
+ --fg-muted: oklch(0.42 0.02 65);
622
+ --fg-subtle: oklch(0.62 0.015 70);
623
+ --bg: oklch(0.985 0.008 80);
624
+ --bg-elev: oklch(1 0 0);
625
+ --bg-subtle: oklch(0.96 0.008 80);
626
+ --border: oklch(0.88 0.01 75 / 0.95);
627
+ --border-strong: oklch(0.78 0.01 75 / 0.95);
628
+ --accent: oklch(0.58 0.15 55);
629
+ --accent-hover: oklch(0.5 0.15 55);
630
+ --accent-fg: oklch(1 0 0);
631
+ --accent-tint: oklch(0.58 0.15 55 / 0.1);
632
+ }
633
+ }
634
+ /* Body + pseudo-elements utility classes can't reach. */
635
+ html, body { margin: 0; }
636
+ body {
637
+ background: var(--bg);
638
+ color: var(--fg);
639
+ font: 16px/1.65 var(--font-sans);
640
+ -webkit-font-smoothing: antialiased;
641
+ }
642
+ ::selection { background: var(--accent-tint); color: var(--fg); }
643
+ </style>
644
+
645
+ <header class="sticky top-0 z-20 flex items-center gap-6 px-4 sm:px-6 py-3 border-b border-border bg-[color-mix(in_oklch,var(--bg)_75%,transparent)] backdrop-blur-[18px]">
646
+ <a href="/" class="mr-auto inline-flex items-center gap-2 no-underline text-fg font-semibold text-[15px] leading-none tracking-tight">
647
+ <span>${name}</span>
648
+ </a>
649
+ <nav class="flex gap-4 items-center">
650
+ \${navLink('/', 'Home')}
651
+ <theme-toggle></theme-toggle>
652
+ </nav>
653
+ </header>
654
+
655
+ <main class="block max-w-[760px] mx-auto px-4 sm:px-6 pt-[72px] pb-12 min-h-screen">
656
+ \${children}
657
+ </main>
658
+ \`;
659
+ }
660
+ `);
661
+
662
+ await writeFile(join(appDir, 'app', 'page.ts'), `import { html } from '@webjsdev/core';
663
+ import { rubric, displayH1, accentLink } from '../lib/utils/ui.ts';
664
+ import { buttonClass } from '../components/ui/button.ts';
665
+ import { badgeClass } from '../components/ui/badge.ts';
666
+ import {
667
+ cardClass,
668
+ cardHeaderClass,
669
+ cardTitleClass,
670
+ cardDescriptionClass,
671
+ cardContentClass,
672
+ } from '../components/ui/card.ts';
673
+ import { alertClass, alertTitleClass, alertDescriptionClass } from '../components/ui/alert.ts';
674
+ import { separatorClass } from '../components/ui/separator.ts';
675
+
676
+ export const metadata = {
677
+ title: '${name}: built with webjs',
678
+ };
679
+
680
+ export default function Home() {
681
+ return html\`
682
+ <section class="mb-18">
683
+ \${rubric('welcome')}
684
+ \${displayH1(html\`Hello from <span class="text-accent italic">${name}</span>.\`)}
685
+ <p class="text-lede leading-[1.5] text-fg-muted max-w-[56ch] m-0 mb-6">
686
+ Edit <code class="font-mono text-[0.9em]">app/page.ts</code> to get started.
687
+ Run \${accentLink('#', 'webjs test')} to run tests and
688
+ \${accentLink('#', 'webjs check')} to validate conventions.
689
+ </p>
690
+ <div class="flex gap-3 items-center">
691
+ <button class=\${buttonClass()}>Get started</button>
692
+ <button class=\${buttonClass({ variant: 'outline' })}>View docs</button>
693
+ <span class=\${badgeClass({ variant: 'secondary' })}>v0.1</span>
694
+ </div>
695
+ </section>
696
+
697
+ <div class=\${cardClass()} style="margin-bottom: 3rem">
698
+ <div class=\${cardHeaderClass()}>
699
+ <h3 class=\${cardTitleClass()}>Web Components + Server Actions</h3>
700
+ <p class=\${cardDescriptionClass()}>
701
+ Drop a custom element anywhere. Call a server action like a local
702
+ function. webjs rewrites the import into a typed RPC stub.
703
+ </p>
704
+ </div>
705
+ <div class=\${cardContentClass()}>
706
+ <div class=\${alertClass()}>
707
+ <h5 class=\${alertTitleClass()}>AI-first component kit included</h5>
708
+ <div class=\${alertDescriptionClass()}>
709
+ button, card, alert, badge, separator, label, input are already
710
+ in <code class="font-mono text-[0.9em]">components/ui/</code> as
711
+ class-helper functions you call from a native element. Add more
712
+ with <code class="font-mono text-[0.9em]">webjs ui add &lt;name&gt;</code>.
713
+ </div>
714
+ </div>
715
+ </div>
716
+ </div>
717
+
718
+ <div class=\${separatorClass()} style="margin: 2.5rem 0"></div>
719
+
720
+ <section class="mt-10">
721
+ <h2 class="font-serif text-[1.6rem] tracking-[-0.02em] font-bold m-0 mb-2">Light DOM + Tailwind</h2>
722
+ <p class="text-fg-muted text-sm m-0 mb-4">
723
+ Components render into light DOM by default. Tailwind utility classes
724
+ apply directly. Set <code class="font-mono text-[0.9em]">static shadow = true</code>
725
+ on a component when you need scoped styles or third-party-embed
726
+ isolation. &lt;slot&gt; projection works identically in both modes,
727
+ including named slots, fallback content, and the full
728
+ assignedNodes / slotchange API.
729
+ </p>
730
+ </section>
731
+ \`;
732
+ }
733
+ `);
734
+
735
+ // AGENTS.md is copied via the `templateFiles` loop above, from
736
+ // `packages/cli/templates/AGENTS.md` with `{{APP_NAME}}` substitution.
737
+
738
+ // --- Theme toggle component ---
739
+
740
+ await writeFile(join(appDir, 'components', 'theme-toggle.ts'), `import { WebComponent, html, signal } from '@webjsdev/core';
741
+
742
+ type Theme = 'system' | 'light' | 'dark';
743
+
744
+ /**
745
+ * <theme-toggle> is a light-DOM component styled with Tailwind utilities.
746
+ *
747
+ * Light DOM is the default: no static shadow = true, no static styles.
748
+ * Because this component has no custom CSS (only Tailwind classes,
749
+ * which are already unique by construction), the class-prefix rule
750
+ * doesn't apply here. If you ever add a <style> block, prefix every
751
+ * selector with 'theme-toggle' (e.g. .theme-toggle__btn or
752
+ * \`theme-toggle .btn\`).
753
+ */
754
+ export class ThemeToggle extends WebComponent {
755
+ theme = signal<Theme>('system');
756
+
757
+ connectedCallback() {
758
+ super.connectedCallback();
759
+ let saved: string | null = null;
760
+ try { saved = localStorage.getItem('webjs_theme'); } catch {}
761
+ this.theme.set(saved === 'light' || saved === 'dark' ? saved : 'system');
762
+ }
763
+
764
+ cycle() {
765
+ const t = this.theme.get();
766
+ const next: Theme = t === 'system' ? 'light'
767
+ : t === 'light' ? 'dark' : 'system';
768
+ this.theme.set(next);
769
+ try {
770
+ if (next === 'system') localStorage.removeItem('webjs_theme');
771
+ else localStorage.setItem('webjs_theme', next);
772
+ } catch {}
773
+ if (next === 'system') delete document.documentElement.dataset.theme;
774
+ else document.documentElement.dataset.theme = next;
775
+ }
776
+
777
+ render() {
778
+ const t = this.theme.get();
779
+ const label = t === 'system' ? 'AUTO' : t === 'light' ? 'LIGHT' : 'DARK';
780
+ const icon = t === 'light' ? ICONS.sun : t === 'dark' ? ICONS.moon : ICONS.system;
781
+ return html\`
782
+ <button
783
+ class="inline-flex items-center justify-center w-9 h-9 p-0 border border-border rounded-full bg-bg-elev text-fg-muted cursor-pointer transition-all duration-150 hover:text-fg hover:border-border-strong active:scale-[0.94] focus-visible:outline-none focus-visible:border-accent focus-visible:ring-[3px] focus-visible:ring-accent-tint"
784
+ @click=\${() => this.cycle()}
785
+ aria-label="Cycle theme (currently \${label})"
786
+ title="Theme: \${label.toLowerCase()}"
787
+ >\${icon}</button>
788
+ \`;
789
+ }
790
+ }
791
+
792
+ const ICONS = {
793
+ sun: html\`<svg class="w-4 h-4 stroke-current fill-none" style="stroke-width:1.8;stroke-linecap:round;stroke-linejoin:round" viewBox="0 0 24 24"><circle cx="12" cy="12" r="4"/><path d="M12 3v2M12 19v2M4.93 4.93l1.41 1.41M17.66 17.66l1.41 1.41M3 12h2M19 12h2M4.93 19.07l1.41-1.41M17.66 6.34l1.41-1.41"/></svg>\`,
794
+ moon: html\`<svg class="w-4 h-4 stroke-current fill-none" style="stroke-width:1.8;stroke-linecap:round;stroke-linejoin:round" viewBox="0 0 24 24"><path d="M21 12.8A9 9 0 1 1 11.2 3a7 7 0 0 0 9.8 9.8Z"/></svg>\`,
795
+ system: html\`<svg class="w-4 h-4 stroke-current fill-none" style="stroke-width:1.8;stroke-linecap:round;stroke-linejoin:round" viewBox="0 0 24 24"><path d="M3 5h18v11H3zM8 20h8M12 16v4"/></svg>\`,
796
+ };
797
+
798
+ ThemeToggle.register('theme-toggle');
799
+ `);
800
+ } // end if (!isApi)
801
+
802
+ // --- SaaS template extras: auth, dashboard, prisma ---
803
+ if (isSaas) {
804
+ const { writeSaasFiles } = await import('./saas-template.js');
805
+ await writeSaasFiles(appDir);
806
+ }
807
+
808
+ // AGENTS.md is already in place via the shared `templateFiles` loop
809
+ // earlier in this function, so no framework-root fallback needed.
810
+
811
+ // --- Git init + configure hooks directory ---
812
+ const { execSync } = await import('node:child_process');
813
+ try {
814
+ execSync('git init', { cwd: appDir, stdio: 'pipe' });
815
+ // Tell git to use .hooks/ as the hooks directory (tracked in the repo)
816
+ execSync('git config core.hooksPath .hooks', { cwd: appDir, stdio: 'pipe' });
817
+ } catch { /* git not available: skip */ }
818
+
819
+ // --- Print success ---
820
+
821
+ if (isApi) {
822
+ console.log(` ${name}/
823
+ app/api/health/route.ts
824
+ app/api/users/route.ts ← thin wrapper over server actions
825
+ modules/users/{actions,queries,types.ts}
826
+ CONVENTIONS.md, AGENTS.md, CLAUDE.md
827
+ `);
828
+ } else if (isSaas) {
829
+ console.log(` ${name}/
830
+ app/layout.ts, page.ts, login/, signup/
831
+ app/dashboard/{page,settings,middleware}.ts ← protected
832
+ app/api/auth/[...path]/route.ts ← auth API
833
+ app/globals.css ← @webjsdev/ui theme tokens
834
+ components.json ← preconfigured for \`webjs ui add\`
835
+ components/ui/{button,card,alert,badge,separator,label,input,
836
+ dialog,form,field,switch,checkbox}.ts
837
+ components/theme-toggle.ts
838
+ modules/auth/{actions,queries,types.ts}
839
+ lib/{auth,prisma,password}.server.ts
840
+ lib/utils/cn.ts ← cn() helper for ui-* components
841
+ prisma/schema.prisma ← User model
842
+ CONVENTIONS.md, AGENTS.md, CLAUDE.md
843
+ `);
844
+ } else {
845
+ console.log(` ${name}/
846
+ app/layout.ts, page.ts ← light DOM + Tailwind + @theme tokens
847
+ app/globals.css ← @webjsdev/ui theme tokens
848
+ components.json ← preconfigured for \`webjs ui add\`
849
+ components/ui/{button,card,alert,badge,separator,label,input}.ts
850
+ components/theme-toggle.ts ← light DOM web component
851
+ lib/utils/cn.ts ← cn() helper for ui-* components
852
+ lib/utils/ui.ts ← Tailwind class-bundle helpers
853
+ public/tailwind-browser.js ← Tailwind runtime
854
+ modules/
855
+ CONVENTIONS.md, AGENTS.md, CLAUDE.md
856
+ `);
857
+ }
858
+ // Post-scaffold guidance. The full-stack and saas templates ship with
859
+ // @webjsdev/ui already initialised (components.json, lib/utils/cn.ts, the
860
+ // standard kit under components/ui/), so the user only runs `webjs dev`.
861
+ // The api template has no UI; we only mention `webjs ui` in case the
862
+ // user later adds one.
863
+ const uiNote = isApi
864
+ ? `# If you later add a UI to this API project:
865
+ # webjs ui init && webjs ui add button card dialog`
866
+ : `webjs ui add <name> # optional: add more ui-* components later`;
867
+ console.log(`Next steps:
868
+ cd ${name}
869
+ npm install${isSaas ? '\n npx prisma migrate dev --name init' : ''}
870
+ ${uiNote}
871
+ webjs dev
872
+
873
+ AI-driven development (enforced for all AI agents):
874
+ ✓ Tests auto-generated with every feature
875
+ ✓ Docs auto-updated with every change
876
+ ✓ Git merges/pushes to main require approval
877
+ ✓ Commits are automatic, small, and meaningful
878
+ ✓ No AI attribution in commit messages
879
+ ✓ Convention validation via \`webjs check\`
880
+
881
+ For AI agents, read this before editing scaffolded files:
882
+ • The example layout / page / components / schema are REFERENCE ONLY.
883
+ Replace them with the app the user actually asked for. Don't ship
884
+ the scaffold's example User model or "Hello from …" page as the
885
+ final product.
886
+ • Use Prisma + SQLite for app data. It's already wired up. Define
887
+ real models in prisma/schema.prisma and run \`webjs db migrate\`.
888
+ NEVER store app data in JSON files, in-memory arrays, or
889
+ localStorage as a substitute for the database.
890
+ • Only three scaffolds exist: full-stack (default), api, saas. Don't
891
+ invent template names. If you need a different kind of app, pick
892
+ the closest scaffold and adapt it.
893
+ • Read AGENTS.md + CONVENTIONS.md in the new project before writing
894
+ any code. They are the contract.
895
+ • Need more detail? Full hosted docs are at https://docs.webjs.com
896
+ (every API, directive, recipe, and deployment guide).
897
+ `);
898
+ }