@webjskit/cli 0.1.0
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 +55 -0
- package/bin/webjs.js +226 -0
- package/lib/create.js +519 -0
- package/lib/saas-template.js +238 -0
- package/package.json +35 -0
- package/templates/.claude/hooks/guard-branch-context.sh +39 -0
- package/templates/.claude/hooks/guard-main-merge.sh +44 -0
- package/templates/.claude/settings.json +24 -0
- package/templates/.claude.json +9 -0
- package/templates/.cursorrules +63 -0
- package/templates/.editorconfig +18 -0
- package/templates/.env.example +27 -0
- package/templates/.github/copilot-instructions.md +59 -0
- package/templates/.github/pull_request_template.md +14 -0
- package/templates/.hooks/pre-commit +24 -0
- package/templates/.windsurfrules +53 -0
- package/templates/CLAUDE.md +70 -0
- package/templates/CONVENTIONS.md +589 -0
- package/templates/app/_utils/ui.ts +83 -0
- package/templates/public/tailwind-browser.js +947 -0
- package/templates/test/browser/example.test.js +40 -0
- package/templates/test/e2e/example.test.ts +87 -0
- package/templates/test/unit/example.test.ts +24 -0
- package/templates/web-test-runner.config.js +26 -0
package/lib/create.js
ADDED
|
@@ -0,0 +1,519 @@
|
|
|
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
|
+
/**
|
|
23
|
+
* @param {string} name App directory name
|
|
24
|
+
* @param {string} cwd Current working directory
|
|
25
|
+
*/
|
|
26
|
+
export async function scaffoldApp(name, cwd, opts = {}) {
|
|
27
|
+
const template = opts.template || 'full-stack';
|
|
28
|
+
const isApi = template === 'api';
|
|
29
|
+
const isSaas = template === 'saas';
|
|
30
|
+
const appDir = join(cwd, name);
|
|
31
|
+
if (existsSync(appDir)) {
|
|
32
|
+
console.error(`Error: directory '${name}' already exists.`);
|
|
33
|
+
process.exit(1);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
console.log(`\nwebjs create: scaffolding '${name}' (${template})...\n`);
|
|
37
|
+
|
|
38
|
+
// Create directory structure
|
|
39
|
+
const dirs = [
|
|
40
|
+
'app',
|
|
41
|
+
'components',
|
|
42
|
+
'modules',
|
|
43
|
+
'lib',
|
|
44
|
+
'public',
|
|
45
|
+
'test/unit',
|
|
46
|
+
'test/e2e',
|
|
47
|
+
];
|
|
48
|
+
for (const d of dirs) await mkdir(join(appDir, d), { recursive: true });
|
|
49
|
+
|
|
50
|
+
// --- Root files ---
|
|
51
|
+
|
|
52
|
+
await writeFile(join(appDir, 'package.json'), JSON.stringify({
|
|
53
|
+
name,
|
|
54
|
+
version: '0.1.0',
|
|
55
|
+
type: 'module',
|
|
56
|
+
private: true,
|
|
57
|
+
scripts: {
|
|
58
|
+
dev: 'webjs dev',
|
|
59
|
+
build: 'webjs build',
|
|
60
|
+
start: 'webjs start',
|
|
61
|
+
test: 'webjs test',
|
|
62
|
+
'test:server': 'webjs test --server',
|
|
63
|
+
'test:browser': 'webjs test --browser',
|
|
64
|
+
check: 'webjs check',
|
|
65
|
+
},
|
|
66
|
+
dependencies: {
|
|
67
|
+
'@webjskit/cli': 'latest',
|
|
68
|
+
'@webjskit/core': 'latest',
|
|
69
|
+
'@webjskit/server': 'latest',
|
|
70
|
+
...(isSaas ? { '@prisma/client': '^6.0.0' } : {}),
|
|
71
|
+
},
|
|
72
|
+
devDependencies: {
|
|
73
|
+
esbuild: '^0.28.0',
|
|
74
|
+
'@web/test-runner': '^0.20.0',
|
|
75
|
+
'@web/test-runner-playwright': '^0.11.0',
|
|
76
|
+
'playwright': '^1.59.0',
|
|
77
|
+
...(isSaas ? { prisma: '^6.0.0' } : {}),
|
|
78
|
+
},
|
|
79
|
+
}, null, 2) + '\n');
|
|
80
|
+
|
|
81
|
+
await writeFile(join(appDir, 'tsconfig.json'), JSON.stringify({
|
|
82
|
+
compilerOptions: {
|
|
83
|
+
target: 'ES2022',
|
|
84
|
+
module: 'NodeNext',
|
|
85
|
+
moduleResolution: 'NodeNext',
|
|
86
|
+
lib: ['ES2022', 'DOM', 'DOM.Iterable'],
|
|
87
|
+
strict: true,
|
|
88
|
+
noEmit: true,
|
|
89
|
+
allowImportingTsExtensions: true,
|
|
90
|
+
skipLibCheck: true,
|
|
91
|
+
// ts-lit-plugin gives tag/attribute intelligence inside html`` templates
|
|
92
|
+
// (autocomplete, type-check, go-to-definition for <my-element>).
|
|
93
|
+
// Install: `npm i -D ts-lit-plugin`. Remove this plugin entry if you don't want it.
|
|
94
|
+
plugins: [{ name: 'ts-lit-plugin', strict: true }],
|
|
95
|
+
},
|
|
96
|
+
}, null, 2) + '\n');
|
|
97
|
+
|
|
98
|
+
// --- Templates (CONVENTIONS.md, CLAUDE.md, test files, Claude hooks) ---
|
|
99
|
+
|
|
100
|
+
const templateFiles = [
|
|
101
|
+
'CONVENTIONS.md',
|
|
102
|
+
'CLAUDE.md',
|
|
103
|
+
'test/unit/example.test.ts',
|
|
104
|
+
'test/browser/example.test.js',
|
|
105
|
+
'web-test-runner.config.js',
|
|
106
|
+
// Environment variables
|
|
107
|
+
'.env.example',
|
|
108
|
+
// Git hooks (blocks commits on main)
|
|
109
|
+
'.hooks/pre-commit',
|
|
110
|
+
// Claude Code config + hooks
|
|
111
|
+
'.claude.json',
|
|
112
|
+
'.claude/settings.json',
|
|
113
|
+
'.claude/hooks/guard-main-merge.sh',
|
|
114
|
+
'.claude/hooks/guard-branch-context.sh',
|
|
115
|
+
// Cross-agent config files
|
|
116
|
+
'.cursorrules',
|
|
117
|
+
'.windsurfrules',
|
|
118
|
+
'.github/copilot-instructions.md',
|
|
119
|
+
'.github/pull_request_template.md',
|
|
120
|
+
'.editorconfig',
|
|
121
|
+
];
|
|
122
|
+
for (const f of templateFiles) {
|
|
123
|
+
const src = join(TEMPLATES, f);
|
|
124
|
+
if (existsSync(src)) {
|
|
125
|
+
await mkdir(dirname(join(appDir, f)), { recursive: true });
|
|
126
|
+
let content = await readFile(src, 'utf8');
|
|
127
|
+
content = content.replace(/\{\{APP_NAME\}\}/g, name);
|
|
128
|
+
await writeFile(join(appDir, f), content);
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// Make hook scripts executable
|
|
133
|
+
const { chmod } = await import('node:fs/promises');
|
|
134
|
+
for (const hook of ['guard-main-merge.sh', 'guard-branch-context.sh']) {
|
|
135
|
+
const hookPath = join(appDir, '.claude', 'hooks', hook);
|
|
136
|
+
if (existsSync(hookPath)) await chmod(hookPath, 0o755);
|
|
137
|
+
}
|
|
138
|
+
// Make git pre-commit hook executable
|
|
139
|
+
const preCommitPath = join(appDir, '.hooks', 'pre-commit');
|
|
140
|
+
if (existsSync(preCommitPath)) await chmod(preCommitPath, 0o755);
|
|
141
|
+
|
|
142
|
+
// --- App files (template-specific) ---
|
|
143
|
+
|
|
144
|
+
if (isApi) {
|
|
145
|
+
// API-only template: no layout, no page, no components.
|
|
146
|
+
// Just a health route and an example module with route wrapper.
|
|
147
|
+
await mkdir(join(appDir, 'app', 'api', 'health'), { recursive: true });
|
|
148
|
+
await mkdir(join(appDir, 'app', 'api', 'users'), { recursive: true });
|
|
149
|
+
await writeFile(join(appDir, 'app', 'api', 'health', 'route.ts'), `export async function GET() {
|
|
150
|
+
return Response.json({ status: 'ok', timestamp: Date.now() });
|
|
151
|
+
}
|
|
152
|
+
`);
|
|
153
|
+
await mkdir(join(appDir, 'modules', 'users', 'actions'), { recursive: true });
|
|
154
|
+
await mkdir(join(appDir, 'modules', 'users', 'queries'), { recursive: true });
|
|
155
|
+
|
|
156
|
+
await writeFile(join(appDir, 'modules', 'users', 'queries', 'list-users.server.ts'), `'use server';
|
|
157
|
+
|
|
158
|
+
export async function listUsers() {
|
|
159
|
+
// TODO: replace with real data source
|
|
160
|
+
return [
|
|
161
|
+
{ id: '1', name: 'Alice', email: 'alice@example.com' },
|
|
162
|
+
{ id: '2', name: 'Bob', email: 'bob@example.com' },
|
|
163
|
+
];
|
|
164
|
+
}
|
|
165
|
+
`);
|
|
166
|
+
await writeFile(join(appDir, 'modules', 'users', 'actions', 'create-user.server.ts'), `'use server';
|
|
167
|
+
|
|
168
|
+
export async function createUser(input: { name: string; email: string }) {
|
|
169
|
+
// TODO: validate input, persist to database
|
|
170
|
+
return { success: true, data: { id: Date.now().toString(), ...input } };
|
|
171
|
+
}
|
|
172
|
+
`);
|
|
173
|
+
await writeFile(join(appDir, 'app', 'api', 'users', 'route.ts'), `/**
|
|
174
|
+
* /api/users — thin route wrapper over typed server actions.
|
|
175
|
+
* Business logic lives in modules/users/, not here.
|
|
176
|
+
*/
|
|
177
|
+
import { listUsers } from '../../../../modules/users/queries/list-users.server.ts';
|
|
178
|
+
import { createUser } from '../../../../modules/users/actions/create-user.server.ts';
|
|
179
|
+
|
|
180
|
+
export async function GET() {
|
|
181
|
+
return Response.json(await listUsers());
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
export async function POST(req: Request) {
|
|
185
|
+
const body = await req.json();
|
|
186
|
+
return Response.json(await createUser(body));
|
|
187
|
+
}
|
|
188
|
+
`);
|
|
189
|
+
await writeFile(join(appDir, 'modules', 'users', 'types.ts'), `export interface User {
|
|
190
|
+
id: string;
|
|
191
|
+
name: string;
|
|
192
|
+
email: string;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
export type ActionResult<T> =
|
|
196
|
+
| { success: true; data: T }
|
|
197
|
+
| { success: false; error: string; status: number };
|
|
198
|
+
`);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
if (!isApi) {
|
|
202
|
+
// Full-stack and SaaS templates: layout + page + theme toggle + Tailwind
|
|
203
|
+
|
|
204
|
+
// Copy the Tailwind browser runtime + _utils/ui.ts helpers from the
|
|
205
|
+
// scaffold templates directory so the app boots with the exact blog
|
|
206
|
+
// example architecture: light DOM + Tailwind + JS helpers.
|
|
207
|
+
const publicDir = join(appDir, 'public');
|
|
208
|
+
await mkdir(publicDir, { recursive: true });
|
|
209
|
+
const tailwindSrc = join(TEMPLATES, 'public', 'tailwind-browser.js');
|
|
210
|
+
if (existsSync(tailwindSrc)) {
|
|
211
|
+
await cp(tailwindSrc, join(publicDir, 'tailwind-browser.js'));
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
const utilsDir = join(appDir, 'app', '_utils');
|
|
215
|
+
await mkdir(utilsDir, { recursive: true });
|
|
216
|
+
const uiSrc = join(TEMPLATES, 'app', '_utils', 'ui.ts');
|
|
217
|
+
if (existsSync(uiSrc)) {
|
|
218
|
+
await cp(uiSrc, join(utilsDir, 'ui.ts'));
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
await writeFile(join(appDir, 'app', 'layout.ts'), `import { html } from '@webjskit/core';
|
|
222
|
+
import '@webjskit/core/client-router';
|
|
223
|
+
import '../components/theme-toggle.ts';
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* Root layout — globals + chrome.
|
|
227
|
+
*
|
|
228
|
+
* Light DOM + Tailwind by default. Design tokens live in :root and are
|
|
229
|
+
* mapped into the Tailwind palette via @theme, so classes like
|
|
230
|
+
* text-fg, bg-bg-elev, font-serif, duration-fast, text-display all work.
|
|
231
|
+
*
|
|
232
|
+
* Nav + footer links repeat the same class bundle, so they're extracted
|
|
233
|
+
* into small JS helpers below. Each helper runs at SSR time inside
|
|
234
|
+
* html\\\`\\\`, producing static HTML in the response — no client runtime.
|
|
235
|
+
*/
|
|
236
|
+
|
|
237
|
+
const navLink = (href: string, label: string) => html\`
|
|
238
|
+
<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>
|
|
239
|
+
\`;
|
|
240
|
+
|
|
241
|
+
export default function RootLayout({ children }: { children: unknown }) {
|
|
242
|
+
return html\`
|
|
243
|
+
<script>
|
|
244
|
+
(function(){
|
|
245
|
+
try {
|
|
246
|
+
var t = localStorage.getItem('webjs_theme');
|
|
247
|
+
if (t === 'light' || t === 'dark') {
|
|
248
|
+
document.documentElement.dataset.theme = t;
|
|
249
|
+
}
|
|
250
|
+
} catch (_) {}
|
|
251
|
+
})();
|
|
252
|
+
</script>
|
|
253
|
+
<script src="/public/tailwind-browser.js"></script>
|
|
254
|
+
<style type="text/tailwindcss">
|
|
255
|
+
@theme {
|
|
256
|
+
--color-fg: var(--fg);
|
|
257
|
+
--color-fg-muted: var(--fg-muted);
|
|
258
|
+
--color-fg-subtle: var(--fg-subtle);
|
|
259
|
+
--color-bg: var(--bg);
|
|
260
|
+
--color-bg-elev: var(--bg-elev);
|
|
261
|
+
--color-bg-subtle: var(--bg-subtle);
|
|
262
|
+
--color-border: var(--border);
|
|
263
|
+
--color-border-strong: var(--border-strong);
|
|
264
|
+
--color-accent: var(--accent);
|
|
265
|
+
--color-accent-hover: var(--accent-hover);
|
|
266
|
+
--color-accent-fg: var(--accent-fg);
|
|
267
|
+
--color-accent-tint: var(--accent-tint);
|
|
268
|
+
--font-sans: var(--font-sans);
|
|
269
|
+
--font-serif: var(--font-serif);
|
|
270
|
+
--font-mono: var(--font-mono);
|
|
271
|
+
--text-display: clamp(2.6rem, 1.6rem + 3.2vw, 4.25rem);
|
|
272
|
+
--text-h1: clamp(2rem, 1.5rem + 1.6vw, 2.85rem);
|
|
273
|
+
--text-h2: clamp(1.35rem, 1.15rem + 0.7vw, 1.7rem);
|
|
274
|
+
--text-lede: clamp(1.05rem, 0.95rem + 0.3vw, 1.2rem);
|
|
275
|
+
--duration-fast: 140ms;
|
|
276
|
+
--duration-slow: 380ms;
|
|
277
|
+
}
|
|
278
|
+
</style>
|
|
279
|
+
<style>
|
|
280
|
+
:root {
|
|
281
|
+
color-scheme: light dark;
|
|
282
|
+
/* ---------- dark (default) ---------- */
|
|
283
|
+
--fg: oklch(0.96 0.015 60);
|
|
284
|
+
--fg-muted: oklch(0.72 0.02 60);
|
|
285
|
+
--fg-subtle: oklch(0.55 0.02 60);
|
|
286
|
+
--bg: oklch(0.14 0.01 55);
|
|
287
|
+
--bg-elev: oklch(0.18 0.01 55);
|
|
288
|
+
--bg-subtle: oklch(0.16 0.01 55);
|
|
289
|
+
--border: oklch(0.26 0.012 55 / 0.9);
|
|
290
|
+
--border-strong: oklch(0.38 0.012 55 / 0.9);
|
|
291
|
+
--accent: oklch(0.78 0.14 55);
|
|
292
|
+
--accent-hover: oklch(0.85 0.14 55);
|
|
293
|
+
--accent-fg: oklch(0.15 0.01 55);
|
|
294
|
+
--accent-tint: oklch(0.78 0.14 55 / 0.14);
|
|
295
|
+
--font-sans: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
|
296
|
+
--font-serif: ui-serif, 'Iowan Old Style', Palatino, Georgia, serif;
|
|
297
|
+
--font-mono: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
|
298
|
+
}
|
|
299
|
+
:root[data-theme='light'] {
|
|
300
|
+
--fg: oklch(0.18 0.015 60);
|
|
301
|
+
--fg-muted: oklch(0.42 0.02 65);
|
|
302
|
+
--fg-subtle: oklch(0.62 0.015 70);
|
|
303
|
+
--bg: oklch(0.985 0.008 80);
|
|
304
|
+
--bg-elev: oklch(1 0 0);
|
|
305
|
+
--bg-subtle: oklch(0.96 0.008 80);
|
|
306
|
+
--border: oklch(0.88 0.01 75 / 0.95);
|
|
307
|
+
--border-strong: oklch(0.78 0.01 75 / 0.95);
|
|
308
|
+
--accent: oklch(0.58 0.15 55);
|
|
309
|
+
--accent-hover: oklch(0.5 0.15 55);
|
|
310
|
+
--accent-fg: oklch(1 0 0);
|
|
311
|
+
--accent-tint: oklch(0.58 0.15 55 / 0.1);
|
|
312
|
+
}
|
|
313
|
+
@media (prefers-color-scheme: light) {
|
|
314
|
+
:root:not([data-theme='dark']) {
|
|
315
|
+
--fg: oklch(0.18 0.015 60);
|
|
316
|
+
--fg-muted: oklch(0.42 0.02 65);
|
|
317
|
+
--fg-subtle: oklch(0.62 0.015 70);
|
|
318
|
+
--bg: oklch(0.985 0.008 80);
|
|
319
|
+
--bg-elev: oklch(1 0 0);
|
|
320
|
+
--bg-subtle: oklch(0.96 0.008 80);
|
|
321
|
+
--border: oklch(0.88 0.01 75 / 0.95);
|
|
322
|
+
--border-strong: oklch(0.78 0.01 75 / 0.95);
|
|
323
|
+
--accent: oklch(0.58 0.15 55);
|
|
324
|
+
--accent-hover: oklch(0.5 0.15 55);
|
|
325
|
+
--accent-fg: oklch(1 0 0);
|
|
326
|
+
--accent-tint: oklch(0.58 0.15 55 / 0.1);
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
/* Body + pseudo-elements utility classes can't reach. */
|
|
330
|
+
html, body { margin: 0; }
|
|
331
|
+
body {
|
|
332
|
+
background: var(--bg);
|
|
333
|
+
color: var(--fg);
|
|
334
|
+
font: 16px/1.65 var(--font-sans);
|
|
335
|
+
-webkit-font-smoothing: antialiased;
|
|
336
|
+
}
|
|
337
|
+
::selection { background: var(--accent-tint); color: var(--fg); }
|
|
338
|
+
</style>
|
|
339
|
+
|
|
340
|
+
<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]">
|
|
341
|
+
<a href="/" class="mr-auto inline-flex items-center gap-2 no-underline text-fg font-semibold text-[15px] leading-none tracking-tight">
|
|
342
|
+
<span>${name}</span>
|
|
343
|
+
</a>
|
|
344
|
+
<nav class="flex gap-4 items-center">
|
|
345
|
+
\${navLink('/', 'Home')}
|
|
346
|
+
<theme-toggle></theme-toggle>
|
|
347
|
+
</nav>
|
|
348
|
+
</header>
|
|
349
|
+
|
|
350
|
+
<main class="block max-w-[760px] mx-auto px-4 sm:px-6 pt-[72px] pb-12 min-h-screen">
|
|
351
|
+
\${children}
|
|
352
|
+
</main>
|
|
353
|
+
\`;
|
|
354
|
+
}
|
|
355
|
+
`);
|
|
356
|
+
|
|
357
|
+
await writeFile(join(appDir, 'app', 'page.ts'), `import { html } from '@webjskit/core';
|
|
358
|
+
import { rubric, displayH1, accentLink } from './_utils/ui.ts';
|
|
359
|
+
|
|
360
|
+
export const metadata = {
|
|
361
|
+
title: '${name} — built with webjs',
|
|
362
|
+
};
|
|
363
|
+
|
|
364
|
+
export default function Home() {
|
|
365
|
+
return html\`
|
|
366
|
+
<section class="mb-18">
|
|
367
|
+
\${rubric('welcome')}
|
|
368
|
+
\${displayH1(html\`Hello from <span class="text-accent italic">${name}</span>.\`)}
|
|
369
|
+
<p class="text-lede leading-[1.5] text-fg-muted max-w-[56ch] m-0">
|
|
370
|
+
Edit <code class="font-mono text-[0.9em]">app/page.ts</code> to get started.
|
|
371
|
+
Run \${accentLink('#', 'npx webjs test')} to run tests and
|
|
372
|
+
\${accentLink('#', 'npx webjs check')} to validate conventions.
|
|
373
|
+
</p>
|
|
374
|
+
</section>
|
|
375
|
+
|
|
376
|
+
<section class="mt-18 pt-6 border-t border-border">
|
|
377
|
+
<h2 class="font-serif text-[1.6rem] tracking-[-0.02em] font-bold m-0 mb-2">Light DOM + Tailwind</h2>
|
|
378
|
+
<p class="text-fg-muted text-sm m-0 mb-4">
|
|
379
|
+
Components render into light DOM by default. Tailwind utility classes
|
|
380
|
+
apply directly. Set <code class="font-mono text-[0.9em]">static shadow = true</code>
|
|
381
|
+
on a component when you need scoped styles, <slot> projection,
|
|
382
|
+
or third-party-embed isolation.
|
|
383
|
+
</p>
|
|
384
|
+
</section>
|
|
385
|
+
\`;
|
|
386
|
+
}
|
|
387
|
+
`);
|
|
388
|
+
|
|
389
|
+
// --- AGENTS.md (copy from framework root) ---
|
|
390
|
+
|
|
391
|
+
const agentsSrc = resolve(__dirname, '..', '..', '..', 'AGENTS.md');
|
|
392
|
+
if (existsSync(agentsSrc)) {
|
|
393
|
+
await cp(agentsSrc, join(appDir, 'AGENTS.md'));
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
// --- Theme toggle component ---
|
|
397
|
+
|
|
398
|
+
await writeFile(join(appDir, 'components', 'theme-toggle.ts'), `import { WebComponent, html } from '@webjskit/core';
|
|
399
|
+
|
|
400
|
+
type Theme = 'system' | 'light' | 'dark';
|
|
401
|
+
|
|
402
|
+
/**
|
|
403
|
+
* <theme-toggle> — light DOM component styled with Tailwind utilities.
|
|
404
|
+
*
|
|
405
|
+
* Light DOM is the default: no static shadow = true, no static styles.
|
|
406
|
+
* Because this component has no custom CSS (only Tailwind classes,
|
|
407
|
+
* which are already unique by construction), the class-prefix rule
|
|
408
|
+
* doesn't apply here. If you ever add a <style> block, prefix every
|
|
409
|
+
* selector with 'theme-toggle' (e.g. .theme-toggle__btn or
|
|
410
|
+
* \`theme-toggle .btn\`).
|
|
411
|
+
*/
|
|
412
|
+
export class ThemeToggle extends WebComponent {
|
|
413
|
+
declare state: { theme: Theme };
|
|
414
|
+
|
|
415
|
+
constructor() {
|
|
416
|
+
super();
|
|
417
|
+
this.state = { theme: 'system' };
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
connectedCallback() {
|
|
421
|
+
super.connectedCallback();
|
|
422
|
+
let saved: string | null = null;
|
|
423
|
+
try { saved = localStorage.getItem('webjs_theme'); } catch {}
|
|
424
|
+
this.setState({ theme: saved === 'light' || saved === 'dark' ? saved : 'system' });
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
cycle() {
|
|
428
|
+
const next: Theme = this.state.theme === 'system' ? 'light'
|
|
429
|
+
: this.state.theme === 'light' ? 'dark' : 'system';
|
|
430
|
+
this.setState({ theme: next });
|
|
431
|
+
try {
|
|
432
|
+
if (next === 'system') localStorage.removeItem('webjs_theme');
|
|
433
|
+
else localStorage.setItem('webjs_theme', next);
|
|
434
|
+
} catch {}
|
|
435
|
+
if (next === 'system') delete document.documentElement.dataset.theme;
|
|
436
|
+
else document.documentElement.dataset.theme = next;
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
render() {
|
|
440
|
+
return html\`
|
|
441
|
+
<button
|
|
442
|
+
class="inline-flex items-center px-3 py-1.5 rounded-full border border-border bg-bg-elev text-fg-muted font-mono text-[11px] leading-none tracking-wider uppercase duration-fast hover:text-fg hover:border-border-strong"
|
|
443
|
+
@click=\${() => this.cycle()}
|
|
444
|
+
>
|
|
445
|
+
\${this.state.theme === 'system' ? 'Auto'
|
|
446
|
+
: this.state.theme === 'light' ? 'Light' : 'Dark'}
|
|
447
|
+
</button>
|
|
448
|
+
\`;
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
ThemeToggle.register('theme-toggle');
|
|
453
|
+
`);
|
|
454
|
+
} // end if (!isApi)
|
|
455
|
+
|
|
456
|
+
// --- SaaS template extras: auth, dashboard, prisma ---
|
|
457
|
+
if (isSaas) {
|
|
458
|
+
const { writeSaasFiles } = await import('./saas-template.js');
|
|
459
|
+
await writeSaasFiles(appDir);
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
// --- AGENTS.md (always copy) ---
|
|
463
|
+
const agentsSrc2 = resolve(__dirname, '..', '..', '..', 'AGENTS.md');
|
|
464
|
+
if (!existsSync(join(appDir, 'AGENTS.md')) && existsSync(agentsSrc2)) {
|
|
465
|
+
await cp(agentsSrc2, join(appDir, 'AGENTS.md'));
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
// --- Git init + configure hooks directory ---
|
|
469
|
+
const { execSync } = await import('node:child_process');
|
|
470
|
+
try {
|
|
471
|
+
execSync('git init', { cwd: appDir, stdio: 'pipe' });
|
|
472
|
+
// Tell git to use .hooks/ as the hooks directory (tracked in the repo)
|
|
473
|
+
execSync('git config core.hooksPath .hooks', { cwd: appDir, stdio: 'pipe' });
|
|
474
|
+
} catch { /* git not available — skip */ }
|
|
475
|
+
|
|
476
|
+
// --- Print success ---
|
|
477
|
+
|
|
478
|
+
if (isApi) {
|
|
479
|
+
console.log(` ${name}/
|
|
480
|
+
app/api/health/route.ts
|
|
481
|
+
app/api/users/route.ts ← thin wrapper over server actions
|
|
482
|
+
modules/users/{actions,queries,types.ts}
|
|
483
|
+
CONVENTIONS.md, AGENTS.md, CLAUDE.md
|
|
484
|
+
`);
|
|
485
|
+
} else if (isSaas) {
|
|
486
|
+
console.log(` ${name}/
|
|
487
|
+
app/layout.ts, page.ts, login/, signup/
|
|
488
|
+
app/dashboard/{page,settings,middleware}.ts ← protected
|
|
489
|
+
app/api/auth/[...path]/route.ts ← auth API
|
|
490
|
+
modules/auth/{actions,queries,types.ts}
|
|
491
|
+
lib/{auth,prisma,password}.ts
|
|
492
|
+
prisma/schema.prisma ← User model
|
|
493
|
+
components/theme-toggle.ts
|
|
494
|
+
CONVENTIONS.md, AGENTS.md, CLAUDE.md
|
|
495
|
+
`);
|
|
496
|
+
} else {
|
|
497
|
+
console.log(` ${name}/
|
|
498
|
+
app/layout.ts, page.ts ← light DOM + Tailwind + @theme tokens
|
|
499
|
+
app/_utils/ui.ts ← JS helpers for repeated class bundles
|
|
500
|
+
public/tailwind-browser.js ← Tailwind runtime
|
|
501
|
+
components/theme-toggle.ts ← light DOM web component
|
|
502
|
+
modules/
|
|
503
|
+
CONVENTIONS.md, AGENTS.md, CLAUDE.md
|
|
504
|
+
`);
|
|
505
|
+
}
|
|
506
|
+
console.log(`Next steps:
|
|
507
|
+
cd ${name}
|
|
508
|
+
npm install${isSaas ? '\n npx prisma migrate dev --name init' : ''}
|
|
509
|
+
npx webjs dev
|
|
510
|
+
|
|
511
|
+
AI-driven development (enforced for all AI agents):
|
|
512
|
+
✓ Tests auto-generated with every feature
|
|
513
|
+
✓ Docs auto-updated with every change
|
|
514
|
+
✓ Git merges/pushes to main require approval
|
|
515
|
+
✓ Commits are automatic, small, and meaningful
|
|
516
|
+
✓ No AI attribution in commit messages
|
|
517
|
+
✓ Convention validation via \`npx webjs check\`
|
|
518
|
+
`);
|
|
519
|
+
}
|