@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/README.md +71 -0
- package/bin/webjs.js +279 -0
- package/lib/create.js +898 -0
- package/lib/saas-template.js +397 -0
- package/package.json +39 -0
- package/templates/.claude/hooks/block-prose-punctuation.sh +236 -0
- package/templates/.claude/hooks/guard-branch-context.sh +39 -0
- package/templates/.claude/hooks/nudge-uncommitted.sh +46 -0
- package/templates/.claude/settings.json +35 -0
- package/templates/.claude.json +9 -0
- package/templates/.cursor/hooks/nudge-uncommitted.sh +38 -0
- package/templates/.cursor/hooks.json +8 -0
- package/templates/.cursorrules +99 -0
- package/templates/.editorconfig +18 -0
- package/templates/.env.example +27 -0
- package/templates/.gemini/hooks/nudge-uncommitted.sh +42 -0
- package/templates/.gemini/settings.json +15 -0
- package/templates/.github/copilot-instructions.md +85 -0
- package/templates/.github/pull_request_template.md +14 -0
- package/templates/.hooks/pre-commit +48 -0
- package/templates/.opencode/plugins/nudge-uncommitted.ts +62 -0
- package/templates/.windsurfrules +91 -0
- package/templates/AGENTS.md +816 -0
- package/templates/CLAUDE.md +2 -0
- package/templates/CONVENTIONS.md +901 -0
- package/templates/lib/utils/ui.ts +83 -0
- package/templates/public/tailwind-browser.js +947 -0
- package/templates/test/hello/browser/hello.test.js +40 -0
- package/templates/test/hello/e2e/hello.test.ts +87 -0
- package/templates/test/hello/hello.test.ts +24 -0
- package/templates/web-test-runner.config.js +33 -0
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 <name></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. <slot> 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
|
+
}
|