@webjskit/cli 0.4.4 → 0.5.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 +13 -2
- package/bin/webjs.js +31 -20
- package/lib/create.js +230 -7
- package/lib/saas-template.js +134 -23
- package/package.json +3 -2
- package/templates/AGENTS.md +177 -3
- package/templates/CONVENTIONS.md +58 -0
package/README.md
CHANGED
|
@@ -36,13 +36,24 @@ webjs create <name> --template api # backend-only API app
|
|
|
36
36
|
webjs create <name> --template saas # auth + dashboard + Prisma User model
|
|
37
37
|
|
|
38
38
|
webjs dev # dev server with live reload
|
|
39
|
-
webjs start # production server
|
|
40
|
-
webjs build # production bundle
|
|
39
|
+
webjs start # production server (no build step — serves source directly)
|
|
41
40
|
webjs check # validate project conventions
|
|
42
41
|
webjs test # run server + browser tests
|
|
43
42
|
webjs db <prisma-subcommand> # prisma passthrough (saas template)
|
|
43
|
+
|
|
44
|
+
webjs ui init # initialise @webjskit/ui in this project
|
|
45
|
+
webjs ui add <names...> # copy components from the registry (https://ui.webjs.dev/registry/<name>.json)
|
|
46
|
+
webjs ui list # list every component available in the registry
|
|
44
47
|
```
|
|
45
48
|
|
|
49
|
+
`webjs ui` proxies to [`@webjskit/ui`](https://www.npmjs.com/package/@webjskit/ui),
|
|
50
|
+
an AI-first component library + CLI that copies sources into your project — class
|
|
51
|
+
helpers (`buttonClass`, `cardClass`, …) for the visual primitives and a small set
|
|
52
|
+
of stateful custom elements (`<ui-dialog>`, `<ui-tabs>`, `<ui-popover>`) where
|
|
53
|
+
state matters. The package is a hard dependency of `@webjskit/cli` — installing
|
|
54
|
+
the CLI gives you `webjs ui` automatically. See
|
|
55
|
+
[https://ui.webjs.dev](https://ui.webjs.dev) for the catalogue.
|
|
56
|
+
|
|
46
57
|
## Scaffolded templates
|
|
47
58
|
|
|
48
59
|
The scaffold seeds opinionated defaults so AI agents produce consistent code:
|
package/bin/webjs.js
CHANGED
|
@@ -13,9 +13,7 @@ const TEMPLATES = ['full-stack', 'api', 'saas'];
|
|
|
13
13
|
|
|
14
14
|
const USAGE = `webjs — commands:
|
|
15
15
|
webjs dev [--port 3000] Start dev server with live reload
|
|
16
|
-
webjs start [--port 3000] Start production server (serves source directly; no build
|
|
17
|
-
[--http2 --cert <path> --key <path>] Serve HTTP/2 over TLS (falls back to h1.1)
|
|
18
|
-
webjs build Optional: bundle client modules into a single file (advanced/perf opt-in)
|
|
16
|
+
webjs start [--port 3000] Start production server (serves source directly; no build step)
|
|
19
17
|
webjs test [--server|--browser] Run server + browser tests
|
|
20
18
|
webjs check Validate app against conventions
|
|
21
19
|
webjs create <name> [--template full-stack|api|saas] Scaffold a new webjs app
|
|
@@ -23,6 +21,9 @@ const USAGE = `webjs — commands:
|
|
|
23
21
|
webjs db generate Run \`prisma generate\`
|
|
24
22
|
webjs db migrate [name] Run \`prisma migrate dev\`
|
|
25
23
|
webjs db studio Run \`prisma studio\`
|
|
24
|
+
webjs ui <subcmd> AI-first component library CLI
|
|
25
|
+
(init / add / list / view / diff / info)
|
|
26
|
+
Requires @webjskit/ui installed in the project
|
|
26
27
|
webjs help Show this help`;
|
|
27
28
|
|
|
28
29
|
/** @param {string[]} args */
|
|
@@ -79,23 +80,7 @@ async function main() {
|
|
|
79
80
|
case 'start': {
|
|
80
81
|
const { startServer } = await import('@webjskit/server');
|
|
81
82
|
const port = Number(flag(rest, '--port', process.env.PORT || 3000));
|
|
82
|
-
|
|
83
|
-
const cert = flag(rest, '--cert');
|
|
84
|
-
const key = flag(rest, '--key');
|
|
85
|
-
await startServer({ appDir: process.cwd(), port, dev: false, http2, cert, key });
|
|
86
|
-
break;
|
|
87
|
-
}
|
|
88
|
-
case 'build': {
|
|
89
|
-
const { buildBundle } = await import('@webjskit/server');
|
|
90
|
-
const t = Date.now();
|
|
91
|
-
const result = await buildBundle({
|
|
92
|
-
appDir: process.cwd(),
|
|
93
|
-
minify: rest.includes('--no-minify') ? false : true,
|
|
94
|
-
sourcemap: rest.includes('--no-sourcemap') ? false : true,
|
|
95
|
-
});
|
|
96
|
-
if (result.bundleFile) {
|
|
97
|
-
console.log(`webjs: bundled ${result.entries.length} entries → ${result.bundleFile} (${Date.now() - t}ms)`);
|
|
98
|
-
}
|
|
83
|
+
await startServer({ appDir: process.cwd(), port, dev: false });
|
|
99
84
|
break;
|
|
100
85
|
}
|
|
101
86
|
case 'db': {
|
|
@@ -108,6 +93,32 @@ async function main() {
|
|
|
108
93
|
child.on('exit', (code) => process.exit(code ?? 0));
|
|
109
94
|
break;
|
|
110
95
|
}
|
|
96
|
+
case 'ui': {
|
|
97
|
+
// Delegate to @webjskit/ui. Bundled as a hard dependency of
|
|
98
|
+
// @webjskit/cli — `npm install -g @webjskit/cli` pulls it in
|
|
99
|
+
// automatically, so `webjs ui add button` works out of the box
|
|
100
|
+
// without an extra install in user projects.
|
|
101
|
+
const { createRequire } = await import('node:module');
|
|
102
|
+
const req = createRequire(import.meta.url);
|
|
103
|
+
let entry;
|
|
104
|
+
try {
|
|
105
|
+
entry = req.resolve('@webjskit/ui/bin/webjsui.js');
|
|
106
|
+
} catch {
|
|
107
|
+
// Fallback: try resolving from the user's cwd in case of weird
|
|
108
|
+
// workspace setups.
|
|
109
|
+
try {
|
|
110
|
+
const userReq = createRequire(join(process.cwd(), 'package.json'));
|
|
111
|
+
entry = userReq.resolve('@webjskit/ui/bin/webjsui.js');
|
|
112
|
+
} catch {
|
|
113
|
+
console.error('@webjskit/ui could not be resolved.');
|
|
114
|
+
console.error('Reinstall the CLI: npm install -g @webjskit/cli');
|
|
115
|
+
process.exit(1);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
const child = spawn('node', [entry, ...rest], { stdio: 'inherit', cwd: process.cwd() });
|
|
119
|
+
child.on('exit', (code) => process.exit(code ?? 0));
|
|
120
|
+
break;
|
|
121
|
+
}
|
|
111
122
|
case 'test': {
|
|
112
123
|
const cwd = process.cwd();
|
|
113
124
|
const { existsSync } = await import('node:fs');
|
package/lib/create.js
CHANGED
|
@@ -19,6 +19,122 @@ import { existsSync } from 'node:fs';
|
|
|
19
19
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
20
20
|
const TEMPLATES = resolve(__dirname, '..', 'templates');
|
|
21
21
|
|
|
22
|
+
// Root of the @webjskit/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 @webjskit/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 (which puts utils at
|
|
39
|
+
* `lib/utils.ts`, i.e. two levels up), and return the rewritten source.
|
|
40
|
+
*
|
|
41
|
+
* @param {string} name component name without `.ts` (e.g. 'button')
|
|
42
|
+
* @returns {Promise<string|null>} source or null if not found
|
|
43
|
+
*/
|
|
44
|
+
async function readUiComponent(name) {
|
|
45
|
+
const src = join(UI_REGISTRY_ROOT, 'components', `${name}.ts`);
|
|
46
|
+
if (!existsSync(src)) return null;
|
|
47
|
+
const raw = await readFile(src, 'utf8');
|
|
48
|
+
// The registry source lives at <registry>/components/<x>.ts and imports
|
|
49
|
+
// its sibling utils via '../lib/utils.ts'. Once copied to the user's
|
|
50
|
+
// project at components/ui/<x>.ts, the equivalent path is two-up:
|
|
51
|
+
// '../../lib/utils.ts'. Same rewrite for the unquoted form.
|
|
52
|
+
return raw
|
|
53
|
+
.replaceAll("'../lib/utils.ts'", "'../../lib/utils.ts'")
|
|
54
|
+
.replaceAll('"../lib/utils.ts"', '"../../lib/utils.ts"');
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Copy a list of @webjskit/ui registry components into the scaffolded app
|
|
59
|
+
* under `components/ui/`. Silently skips any name that isn't in the registry.
|
|
60
|
+
*
|
|
61
|
+
* @param {string} appDir destination app root
|
|
62
|
+
* @param {string[]} names list of component file basenames (without `.ts`)
|
|
63
|
+
*/
|
|
64
|
+
async function copyUiComponents(appDir, names) {
|
|
65
|
+
const uiDir = join(appDir, 'components', 'ui');
|
|
66
|
+
await mkdir(uiDir, { recursive: true });
|
|
67
|
+
for (const n of names) {
|
|
68
|
+
const content = await readUiComponent(n);
|
|
69
|
+
if (content == null) continue;
|
|
70
|
+
await writeFile(join(uiDir, `${n}.ts`), content);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Write `lib/utils.ts` (the `cn()` helper) and `components.json` so the
|
|
76
|
+
* scaffolded app is pre-initialised for `webjs ui add`. Reads `lib/utils.ts`
|
|
77
|
+
* verbatim from the registry source so we never drift.
|
|
78
|
+
*
|
|
79
|
+
* @param {string} appDir
|
|
80
|
+
*/
|
|
81
|
+
async function writeUiBootstrap(appDir) {
|
|
82
|
+
// 1) lib/utils.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'), { recursive: true });
|
|
87
|
+
await writeFile(join(appDir, 'lib', 'utils.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).
|
|
92
|
+
const componentsJson = {
|
|
93
|
+
$schema: 'https://ui.webjs.dev/schema.json',
|
|
94
|
+
style: 'default',
|
|
95
|
+
tailwind: {
|
|
96
|
+
css: 'app/globals.css',
|
|
97
|
+
baseColor: 'neutral',
|
|
98
|
+
cssVariables: true,
|
|
99
|
+
},
|
|
100
|
+
aliases: {
|
|
101
|
+
components: 'components',
|
|
102
|
+
utils: 'lib/utils',
|
|
103
|
+
ui: 'components/ui',
|
|
104
|
+
lib: 'lib',
|
|
105
|
+
},
|
|
106
|
+
iconLibrary: 'lucide',
|
|
107
|
+
};
|
|
108
|
+
await writeFile(
|
|
109
|
+
join(appDir, 'components.json'),
|
|
110
|
+
JSON.stringify(componentsJson, null, 2) + '\n',
|
|
111
|
+
);
|
|
112
|
+
|
|
113
|
+
// 3) app/globals.css — copy the neutral theme verbatim. components.json
|
|
114
|
+
// references this path; future `webjs ui add` calls append to it.
|
|
115
|
+
const themeSrc = join(UI_REGISTRY_ROOT, 'themes', 'index.css');
|
|
116
|
+
if (existsSync(themeSrc)) {
|
|
117
|
+
const css = await readFile(themeSrc, 'utf8');
|
|
118
|
+
await mkdir(join(appDir, 'app'), { recursive: true });
|
|
119
|
+
await writeFile(join(appDir, 'app', 'globals.css'), css);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Read the shadcn theme CSS so we can inline it into the layout's
|
|
125
|
+
* `<style type="text/tailwindcss">` block. The Tailwind browser runtime
|
|
126
|
+
* picks up inline `<style type="text/tailwindcss">` content, so the theme
|
|
127
|
+
* tokens (`--color-primary`, `--color-card`, …) the registry components
|
|
128
|
+
* consume are available at runtime without a build step.
|
|
129
|
+
*
|
|
130
|
+
* @returns {Promise<string>} theme CSS source, or '' if registry missing
|
|
131
|
+
*/
|
|
132
|
+
async function readThemeCss() {
|
|
133
|
+
const src = join(UI_REGISTRY_ROOT, 'themes', 'index.css');
|
|
134
|
+
if (!existsSync(src)) return '';
|
|
135
|
+
return await readFile(src, 'utf8');
|
|
136
|
+
}
|
|
137
|
+
|
|
22
138
|
/**
|
|
23
139
|
* @param {string} name App directory name
|
|
24
140
|
* @param {string} cwd Current working directory
|
|
@@ -91,6 +207,10 @@ export async function scaffoldApp(name, cwd, opts = {}) {
|
|
|
91
207
|
// @webjskit/ts-plugin bundles ts-lit-plugin internally, so just one
|
|
92
208
|
// plugin entry is needed in tsconfig (see below).
|
|
93
209
|
'@webjskit/ts-plugin': 'latest',
|
|
210
|
+
// AI-first component library CLI — preinstalled so `webjs ui add button`
|
|
211
|
+
// works immediately after scaffold. Users can remove if they prefer
|
|
212
|
+
// to add it later.
|
|
213
|
+
'@webjskit/ui': 'latest',
|
|
94
214
|
},
|
|
95
215
|
}, null, 2) + '\n');
|
|
96
216
|
|
|
@@ -257,8 +377,8 @@ export async function createUser(input: { name: string; email: string }) {
|
|
|
257
377
|
* /api/users — thin route wrapper over typed server actions.
|
|
258
378
|
* Business logic lives in modules/users/, not here.
|
|
259
379
|
*/
|
|
260
|
-
import { listUsers } from '
|
|
261
|
-
import { createUser } from '
|
|
380
|
+
import { listUsers } from '../../../modules/users/queries/list-users.server.ts';
|
|
381
|
+
import { createUser } from '../../../modules/users/actions/create-user.server.ts';
|
|
262
382
|
|
|
263
383
|
export async function GET() {
|
|
264
384
|
return Response.json(await listUsers());
|
|
@@ -322,9 +442,45 @@ export type ActionResult<T> =
|
|
|
322
442
|
await cp(uiSrc, join(utilsDir, 'ui.ts'));
|
|
323
443
|
}
|
|
324
444
|
|
|
445
|
+
// Pre-initialise @webjskit/ui so the scaffold boots ready for
|
|
446
|
+
// `webjs ui add <name>`: writes components.json + lib/utils.ts +
|
|
447
|
+
// app/globals.css (the shadcn theme).
|
|
448
|
+
await writeUiBootstrap(appDir);
|
|
449
|
+
|
|
450
|
+
// Copy the standard ui-* component kit the scaffold's example pages
|
|
451
|
+
// use. Sources are read from packages/ui/packages/registry/ in this
|
|
452
|
+
// monorepo. Users can `webjs ui add <name>` for anything else.
|
|
453
|
+
await copyUiComponents(appDir, [
|
|
454
|
+
'button', 'card', 'alert', 'badge', 'separator', 'label', 'input',
|
|
455
|
+
]);
|
|
456
|
+
|
|
457
|
+
// The shadcn theme tokens (`--color-primary`, `--color-card`, …) the
|
|
458
|
+
// ui-* components consume. We read the registry's themes/index.css at
|
|
459
|
+
// create time and inline it into the layout's
|
|
460
|
+
// `<style type="text/tailwindcss">` block so the Tailwind browser
|
|
461
|
+
// runtime picks it up. Same content also lives at app/globals.css for
|
|
462
|
+
// `webjsui` tooling.
|
|
463
|
+
const SHADCN_THEME = (await readThemeCss())
|
|
464
|
+
// Escape backticks + ${} so the CSS survives interpolation into the
|
|
465
|
+
// layout's template literal below.
|
|
466
|
+
.replace(/\\/g, '\\\\')
|
|
467
|
+
.replace(/`/g, '\\`')
|
|
468
|
+
.replace(/\$\{/g, '\\${');
|
|
469
|
+
|
|
325
470
|
await writeFile(join(appDir, 'app', 'layout.ts'), `import { html } from '@webjskit/core';
|
|
326
471
|
import '@webjskit/core/client-router';
|
|
327
472
|
import '../components/theme-toggle.ts';
|
|
473
|
+
// Webjs UI components are tiered:
|
|
474
|
+
// - Tier 1 (button, card, input, label, alert, badge, separator, etc.) are
|
|
475
|
+
// class-helper FUNCTIONS — no custom element to register. Each page
|
|
476
|
+
// imports the specific helpers it needs (e.g.
|
|
477
|
+
// \`import { buttonClass } from '../components/ui/button.ts'\`).
|
|
478
|
+
// - Tier 2 (dialog, popover, tooltip, tabs, accordion, etc.) ARE custom
|
|
479
|
+
// elements. Register them by side-effect-importing here once so they
|
|
480
|
+
// work transitively across every page:
|
|
481
|
+
// import '../components/ui/dialog.ts';
|
|
482
|
+
// The example app/page.ts below uses only Tier-1 helpers, so nothing
|
|
483
|
+
// extra needs to be registered. Add Tier-2 imports as you 'webjs ui add'.
|
|
328
484
|
|
|
329
485
|
/**
|
|
330
486
|
* Root layout — globals + chrome.
|
|
@@ -355,6 +511,16 @@ export default function RootLayout({ children }: { children: unknown }) {
|
|
|
355
511
|
})();
|
|
356
512
|
</script>
|
|
357
513
|
<script src="/public/tailwind-browser.js"></script>
|
|
514
|
+
<!--
|
|
515
|
+
Webjs UI theme — design tokens (--color-primary,
|
|
516
|
+
--color-card, --radius, etc.) the ui-* components consume.
|
|
517
|
+
The same content is also at app/globals.css; we inline it here so
|
|
518
|
+
the Tailwind browser runtime resolves the tokens without a build step.
|
|
519
|
+
Edit base palette via the :root / .dark blocks below.
|
|
520
|
+
-->
|
|
521
|
+
<style type="text/tailwindcss">
|
|
522
|
+
${SHADCN_THEME}
|
|
523
|
+
</style>
|
|
358
524
|
<style type="text/tailwindcss">
|
|
359
525
|
@theme {
|
|
360
526
|
--color-fg: var(--fg);
|
|
@@ -460,6 +626,17 @@ export default function RootLayout({ children }: { children: unknown }) {
|
|
|
460
626
|
|
|
461
627
|
await writeFile(join(appDir, 'app', 'page.ts'), `import { html } from '@webjskit/core';
|
|
462
628
|
import { rubric, displayH1, accentLink } from './_utils/ui.ts';
|
|
629
|
+
import { buttonClass } from '../components/ui/button.ts';
|
|
630
|
+
import { badgeClass } from '../components/ui/badge.ts';
|
|
631
|
+
import {
|
|
632
|
+
cardClass,
|
|
633
|
+
cardHeaderClass,
|
|
634
|
+
cardTitleClass,
|
|
635
|
+
cardDescriptionClass,
|
|
636
|
+
cardContentClass,
|
|
637
|
+
} from '../components/ui/card.ts';
|
|
638
|
+
import { alertClass, alertTitleClass, alertDescriptionClass } from '../components/ui/alert.ts';
|
|
639
|
+
import { separatorClass } from '../components/ui/separator.ts';
|
|
463
640
|
|
|
464
641
|
export const metadata = {
|
|
465
642
|
title: '${name} — built with webjs',
|
|
@@ -470,14 +647,42 @@ export default function Home() {
|
|
|
470
647
|
<section class="mb-18">
|
|
471
648
|
\${rubric('welcome')}
|
|
472
649
|
\${displayH1(html\`Hello from <span class="text-accent italic">${name}</span>.\`)}
|
|
473
|
-
<p class="text-lede leading-[1.5] text-fg-muted max-w-[56ch] m-0">
|
|
650
|
+
<p class="text-lede leading-[1.5] text-fg-muted max-w-[56ch] m-0 mb-6">
|
|
474
651
|
Edit <code class="font-mono text-[0.9em]">app/page.ts</code> to get started.
|
|
475
652
|
Run \${accentLink('#', 'webjs test')} to run tests and
|
|
476
653
|
\${accentLink('#', 'webjs check')} to validate conventions.
|
|
477
654
|
</p>
|
|
655
|
+
<div class="flex gap-3 items-center">
|
|
656
|
+
<button class=\${buttonClass()}>Get started</button>
|
|
657
|
+
<button class=\${buttonClass({ variant: 'outline' })}>View docs</button>
|
|
658
|
+
<span class=\${badgeClass({ variant: 'secondary' })}>v0.1</span>
|
|
659
|
+
</div>
|
|
478
660
|
</section>
|
|
479
661
|
|
|
480
|
-
<
|
|
662
|
+
<div class=\${cardClass()} style="margin-bottom: 3rem">
|
|
663
|
+
<div class=\${cardHeaderClass()}>
|
|
664
|
+
<h3 class=\${cardTitleClass()}>Web Components + Server Actions</h3>
|
|
665
|
+
<p class=\${cardDescriptionClass()}>
|
|
666
|
+
Drop a custom element anywhere. Call a server action like a local
|
|
667
|
+
function — webjs rewrites the import into a typed RPC stub.
|
|
668
|
+
</p>
|
|
669
|
+
</div>
|
|
670
|
+
<div class=\${cardContentClass()}>
|
|
671
|
+
<div class=\${alertClass()}>
|
|
672
|
+
<h5 class=\${alertTitleClass()}>AI-first component kit included</h5>
|
|
673
|
+
<div class=\${alertDescriptionClass()}>
|
|
674
|
+
button, card, alert, badge, separator, label, input are already
|
|
675
|
+
in <code class="font-mono text-[0.9em]">components/ui/</code> as
|
|
676
|
+
class-helper functions you call from a native element. Add more
|
|
677
|
+
with <code class="font-mono text-[0.9em]">webjs ui add <name></code>.
|
|
678
|
+
</div>
|
|
679
|
+
</div>
|
|
680
|
+
</div>
|
|
681
|
+
</div>
|
|
682
|
+
|
|
683
|
+
<div class=\${separatorClass()} style="margin: 2.5rem 0"></div>
|
|
684
|
+
|
|
685
|
+
<section class="mt-10">
|
|
481
686
|
<h2 class="font-serif text-[1.6rem] tracking-[-0.02em] font-bold m-0 mb-2">Light DOM + Tailwind</h2>
|
|
482
687
|
<p class="text-fg-muted text-sm m-0 mb-4">
|
|
483
688
|
Components render into light DOM by default. Tailwind utility classes
|
|
@@ -592,25 +797,43 @@ ThemeToggle.register('theme-toggle');
|
|
|
592
797
|
app/layout.ts, page.ts, login/, signup/
|
|
593
798
|
app/dashboard/{page,settings,middleware}.ts ← protected
|
|
594
799
|
app/api/auth/[...path]/route.ts ← auth API
|
|
800
|
+
app/globals.css ← @webjskit/ui theme tokens
|
|
801
|
+
components.json ← preconfigured for \`webjs ui add\`
|
|
802
|
+
components/ui/{button,card,alert,badge,separator,label,input,
|
|
803
|
+
dialog,form,field,switch,checkbox}.ts
|
|
804
|
+
components/theme-toggle.ts
|
|
595
805
|
modules/auth/{actions,queries,types.ts}
|
|
596
|
-
lib/{auth,prisma,password}.ts
|
|
806
|
+
lib/{auth,prisma,password,utils}.ts ← utils.ts is the cn() helper
|
|
597
807
|
prisma/schema.prisma ← User model
|
|
598
|
-
components/theme-toggle.ts
|
|
599
808
|
CONVENTIONS.md, AGENTS.md, CLAUDE.md
|
|
600
809
|
`);
|
|
601
810
|
} else {
|
|
602
811
|
console.log(` ${name}/
|
|
603
812
|
app/layout.ts, page.ts ← light DOM + Tailwind + @theme tokens
|
|
604
813
|
app/_utils/ui.ts ← JS helpers for repeated class bundles
|
|
605
|
-
|
|
814
|
+
app/globals.css ← @webjskit/ui theme tokens
|
|
815
|
+
components.json ← preconfigured for \`webjs ui add\`
|
|
816
|
+
components/ui/{button,card,alert,badge,separator,label,input}.ts
|
|
606
817
|
components/theme-toggle.ts ← light DOM web component
|
|
818
|
+
lib/utils.ts ← cn() helper for ui-* components
|
|
819
|
+
public/tailwind-browser.js ← Tailwind runtime
|
|
607
820
|
modules/
|
|
608
821
|
CONVENTIONS.md, AGENTS.md, CLAUDE.md
|
|
609
822
|
`);
|
|
610
823
|
}
|
|
824
|
+
// Post-scaffold guidance. The full-stack and saas templates ship with
|
|
825
|
+
// @webjskit/ui already initialised (components.json, lib/utils.ts, the
|
|
826
|
+
// standard kit under components/ui/), so the user only runs `webjs dev`.
|
|
827
|
+
// The api template has no UI; we only mention `webjs ui` in case the
|
|
828
|
+
// user later adds one.
|
|
829
|
+
const uiNote = isApi
|
|
830
|
+
? `# If you later add a UI to this API project:
|
|
831
|
+
# webjs ui init && webjs ui add button card dialog`
|
|
832
|
+
: `webjs ui add <name> # optional — add more ui-* components later`;
|
|
611
833
|
console.log(`Next steps:
|
|
612
834
|
cd ${name}
|
|
613
835
|
npm install${isSaas ? '\n npx prisma migrate dev --name init' : ''}
|
|
836
|
+
${uiNote}
|
|
614
837
|
webjs dev
|
|
615
838
|
|
|
616
839
|
AI-driven development (enforced for all AI agents):
|
package/lib/saas-template.js
CHANGED
|
@@ -3,13 +3,53 @@
|
|
|
3
3
|
* Extracted to avoid nested template literal escaping issues.
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
|
-
import { mkdir, writeFile } from 'node:fs/promises';
|
|
7
|
-
import {
|
|
6
|
+
import { mkdir, writeFile, readFile } from 'node:fs/promises';
|
|
7
|
+
import { existsSync } from 'node:fs';
|
|
8
|
+
import { join, resolve, dirname } from 'node:path';
|
|
9
|
+
import { fileURLToPath } from 'node:url';
|
|
10
|
+
|
|
11
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
12
|
+
const UI_REGISTRY_ROOT = resolve(
|
|
13
|
+
__dirname, '..', '..', 'ui', 'packages', 'registry',
|
|
14
|
+
);
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Read a registry component and rewrite its `'../lib/utils.ts'` import for
|
|
18
|
+
* the scaffolded app's `components/ui/<name>.ts` layout (two-up to lib/).
|
|
19
|
+
* Mirrors the helper in `create.js` — kept private here to avoid coupling.
|
|
20
|
+
*/
|
|
21
|
+
async function readUiComponent(name) {
|
|
22
|
+
const src = join(UI_REGISTRY_ROOT, 'components', `${name}.ts`);
|
|
23
|
+
if (!existsSync(src)) return null;
|
|
24
|
+
const raw = await readFile(src, 'utf8');
|
|
25
|
+
return raw
|
|
26
|
+
.replaceAll("'../lib/utils.ts'", "'../../lib/utils.ts'")
|
|
27
|
+
.replaceAll('"../lib/utils.ts"', '"../../lib/utils.ts"');
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Copy named registry components into `<appDir>/components/ui/`. */
|
|
31
|
+
async function copyUiComponents(appDir, names) {
|
|
32
|
+
const uiDir = join(appDir, 'components', 'ui');
|
|
33
|
+
await mkdir(uiDir, { recursive: true });
|
|
34
|
+
for (const n of names) {
|
|
35
|
+
const content = await readUiComponent(n);
|
|
36
|
+
if (content == null) continue;
|
|
37
|
+
await writeFile(join(uiDir, `${n}.ts`), content);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
8
40
|
|
|
9
41
|
/**
|
|
10
42
|
* @param {string} appDir
|
|
11
43
|
*/
|
|
12
44
|
export async function writeSaasFiles(appDir) {
|
|
45
|
+
// SaaS pages use auth forms — copy the extra ui-* components on top of
|
|
46
|
+
// the standard set the full-stack scaffold already wrote. Pre-importing
|
|
47
|
+
// them in login/signup/dashboard pages below means the dev server will
|
|
48
|
+
// SSR these elements with full styling on first paint.
|
|
49
|
+
// `form` and `field` are deferred to v2 (see packages/ui/AGENTS.md) —
|
|
50
|
+
// the saas auth pages use raw <form> + label/input class helpers instead.
|
|
51
|
+
await copyUiComponents(appDir, ['dialog', 'switch', 'checkbox']);
|
|
52
|
+
|
|
13
53
|
// lib/prisma.ts
|
|
14
54
|
await mkdir(join(appDir, 'lib'), { recursive: true });
|
|
15
55
|
await writeFile(join(appDir, 'lib', 'prisma.ts'), [
|
|
@@ -194,18 +234,39 @@ export async function writeSaasFiles(appDir) {
|
|
|
194
234
|
await mkdir(join(appDir, 'app', 'login'), { recursive: true });
|
|
195
235
|
await writeFile(join(appDir, 'app', 'login', 'page.ts'), [
|
|
196
236
|
"import { html } from '@webjskit/core';",
|
|
237
|
+
"import { cardClass, cardHeaderClass, cardTitleClass, cardDescriptionClass, cardContentClass, cardFooterClass } from '../../components/ui/card.ts';",
|
|
238
|
+
"import { buttonClass } from '../../components/ui/button.ts';",
|
|
239
|
+
"import { inputClass } from '../../components/ui/input.ts';",
|
|
240
|
+
"import { labelClass } from '../../components/ui/label.ts';",
|
|
197
241
|
"",
|
|
198
242
|
"export const metadata = { title: 'Login' };",
|
|
199
243
|
"",
|
|
200
244
|
"export default function LoginPage() {",
|
|
201
245
|
" return html`",
|
|
202
|
-
" <
|
|
203
|
-
"
|
|
204
|
-
"
|
|
205
|
-
"
|
|
206
|
-
"
|
|
207
|
-
"
|
|
208
|
-
"
|
|
246
|
+
" <div class=\"max-w-sm mx-auto mt-12\">",
|
|
247
|
+
" <div class=${cardClass()}>",
|
|
248
|
+
" <div class=${cardHeaderClass()}>",
|
|
249
|
+
" <h3 class=${cardTitleClass()}>Sign in</h3>",
|
|
250
|
+
" <p class=${cardDescriptionClass()}>Welcome back — log in to continue.</p>",
|
|
251
|
+
" </div>",
|
|
252
|
+
" <div class=${cardContentClass()}>",
|
|
253
|
+
" <form method=\"POST\" action=\"/api/auth/callback/credentials\" class=\"flex flex-col gap-4\">",
|
|
254
|
+
" <div class=\"flex flex-col gap-1.5\">",
|
|
255
|
+
" <label class=${labelClass()} for=\"email\">Email</label>",
|
|
256
|
+
" <input class=${inputClass()} id=\"email\" name=\"email\" type=\"email\" required>",
|
|
257
|
+
" </div>",
|
|
258
|
+
" <div class=\"flex flex-col gap-1.5\">",
|
|
259
|
+
" <label class=${labelClass()} for=\"password\">Password</label>",
|
|
260
|
+
" <input class=${inputClass()} id=\"password\" name=\"password\" type=\"password\" required>",
|
|
261
|
+
" </div>",
|
|
262
|
+
" <button class=${buttonClass()} type=\"submit\">Sign in</button>",
|
|
263
|
+
" </form>",
|
|
264
|
+
" </div>",
|
|
265
|
+
" <div class=${cardFooterClass()}>",
|
|
266
|
+
" <p class=\"text-sm text-muted-foreground\">Don't have an account? <a href=\"/signup\" class=\"underline\">Sign up</a></p>",
|
|
267
|
+
" </div>",
|
|
268
|
+
" </div>",
|
|
269
|
+
" </div>",
|
|
209
270
|
" `;",
|
|
210
271
|
"}",
|
|
211
272
|
"",
|
|
@@ -215,19 +276,43 @@ export async function writeSaasFiles(appDir) {
|
|
|
215
276
|
await mkdir(join(appDir, 'app', 'signup'), { recursive: true });
|
|
216
277
|
await writeFile(join(appDir, 'app', 'signup', 'page.ts'), [
|
|
217
278
|
"import { html } from '@webjskit/core';",
|
|
279
|
+
"import { cardClass, cardHeaderClass, cardTitleClass, cardDescriptionClass, cardContentClass, cardFooterClass } from '../../components/ui/card.ts';",
|
|
280
|
+
"import { buttonClass } from '../../components/ui/button.ts';",
|
|
281
|
+
"import { inputClass } from '../../components/ui/input.ts';",
|
|
282
|
+
"import { labelClass } from '../../components/ui/label.ts';",
|
|
218
283
|
"",
|
|
219
284
|
"export const metadata = { title: 'Sign up' };",
|
|
220
285
|
"",
|
|
221
286
|
"export default function SignupPage() {",
|
|
222
287
|
" return html`",
|
|
223
|
-
" <
|
|
224
|
-
"
|
|
225
|
-
"
|
|
226
|
-
"
|
|
227
|
-
"
|
|
228
|
-
"
|
|
229
|
-
"
|
|
230
|
-
"
|
|
288
|
+
" <div class=\"max-w-sm mx-auto mt-12\">",
|
|
289
|
+
" <div class=${cardClass()}>",
|
|
290
|
+
" <div class=${cardHeaderClass()}>",
|
|
291
|
+
" <h3 class=${cardTitleClass()}>Create an account</h3>",
|
|
292
|
+
" <p class=${cardDescriptionClass()}>Get started with your new workspace.</p>",
|
|
293
|
+
" </div>",
|
|
294
|
+
" <div class=${cardContentClass()}>",
|
|
295
|
+
" <form id=\"signup-form\" class=\"flex flex-col gap-4\">",
|
|
296
|
+
" <div class=\"flex flex-col gap-1.5\">",
|
|
297
|
+
" <label class=${labelClass()} for=\"name\">Name</label>",
|
|
298
|
+
" <input class=${inputClass()} id=\"name\" name=\"name\" type=\"text\" required>",
|
|
299
|
+
" </div>",
|
|
300
|
+
" <div class=\"flex flex-col gap-1.5\">",
|
|
301
|
+
" <label class=${labelClass()} for=\"email\">Email</label>",
|
|
302
|
+
" <input class=${inputClass()} id=\"email\" name=\"email\" type=\"email\" required>",
|
|
303
|
+
" </div>",
|
|
304
|
+
" <div class=\"flex flex-col gap-1.5\">",
|
|
305
|
+
" <label class=${labelClass()} for=\"password\">Password</label>",
|
|
306
|
+
" <input class=${inputClass()} id=\"password\" name=\"password\" type=\"password\" required>",
|
|
307
|
+
" </div>",
|
|
308
|
+
" <button class=${buttonClass()} type=\"submit\">Create account</button>",
|
|
309
|
+
" </form>",
|
|
310
|
+
" </div>",
|
|
311
|
+
" <div class=${cardFooterClass()}>",
|
|
312
|
+
" <p class=\"text-sm text-muted-foreground\">Already have an account? <a href=\"/login\" class=\"underline\">Log in</a></p>",
|
|
313
|
+
" </div>",
|
|
314
|
+
" </div>",
|
|
315
|
+
" </div>",
|
|
231
316
|
" `;",
|
|
232
317
|
"}",
|
|
233
318
|
"",
|
|
@@ -252,15 +337,28 @@ export async function writeSaasFiles(appDir) {
|
|
|
252
337
|
await writeFile(join(appDir, 'app', 'dashboard', 'page.ts'), [
|
|
253
338
|
"import { html } from '@webjskit/core';",
|
|
254
339
|
"import { currentUser } from '../../modules/auth/queries/current-user.server.ts';",
|
|
340
|
+
"import { cardClass, cardHeaderClass, cardTitleClass, cardDescriptionClass, cardContentClass } from '../../components/ui/card.ts';",
|
|
341
|
+
"import { buttonClass } from '../../components/ui/button.ts';",
|
|
342
|
+
"import { badgeClass } from '../../components/ui/badge.ts';",
|
|
255
343
|
"",
|
|
256
344
|
"export const metadata = { title: 'Dashboard' };",
|
|
257
345
|
"",
|
|
258
346
|
"export default async function Dashboard() {",
|
|
259
347
|
" const user = await currentUser();",
|
|
260
348
|
" return html`",
|
|
261
|
-
" <
|
|
262
|
-
"
|
|
263
|
-
"
|
|
349
|
+
" <div class=\"flex items-center justify-between mb-6\">",
|
|
350
|
+
" <h1 class=\"text-2xl font-semibold\">Dashboard</h1>",
|
|
351
|
+
" <span class=${badgeClass({ variant: 'secondary' })}>Signed in</span>",
|
|
352
|
+
" </div>",
|
|
353
|
+
" <div class=${cardClass()}>",
|
|
354
|
+
" <div class=${cardHeaderClass()}>",
|
|
355
|
+
" <h3 class=${cardTitleClass()}>Welcome, ${`\\$\\{user?.name || user?.email\\}`}!</h3>",
|
|
356
|
+
" <p class=${cardDescriptionClass()}>You're authenticated. Replace this scaffold with your real app.</p>",
|
|
357
|
+
" </div>",
|
|
358
|
+
" <div class=${cardContentClass()}>",
|
|
359
|
+
" <a class=${buttonClass({ variant: 'outline' })} href=\"/dashboard/settings\">Settings</a>",
|
|
360
|
+
" </div>",
|
|
361
|
+
" </div>",
|
|
264
362
|
" `;",
|
|
265
363
|
"}",
|
|
266
364
|
"",
|
|
@@ -270,15 +368,28 @@ export async function writeSaasFiles(appDir) {
|
|
|
270
368
|
await writeFile(join(appDir, 'app', 'dashboard', 'settings', 'page.ts'), [
|
|
271
369
|
"import { html } from '@webjskit/core';",
|
|
272
370
|
"import { currentUser } from '../../../modules/auth/queries/current-user.server.ts';",
|
|
371
|
+
"import { cardClass, cardHeaderClass, cardTitleClass, cardDescriptionClass, cardContentClass } from '../../../components/ui/card.ts';",
|
|
273
372
|
"",
|
|
274
373
|
"export const metadata = { title: 'Settings' };",
|
|
275
374
|
"",
|
|
276
375
|
"export default async function Settings() {",
|
|
277
376
|
" const user = await currentUser();",
|
|
278
377
|
" return html`",
|
|
279
|
-
" <h1>Settings</h1>",
|
|
280
|
-
" <
|
|
281
|
-
"
|
|
378
|
+
" <h1 class=\"text-2xl font-semibold mb-6\">Settings</h1>",
|
|
379
|
+
" <div class=${cardClass()}>",
|
|
380
|
+
" <div class=${cardHeaderClass()}>",
|
|
381
|
+
" <h3 class=${cardTitleClass()}>Account</h3>",
|
|
382
|
+
" <p class=${cardDescriptionClass()}>Your basic profile information.</p>",
|
|
383
|
+
" </div>",
|
|
384
|
+
" <div class=${cardContentClass()}>",
|
|
385
|
+
" <dl class=\"grid grid-cols-[max-content_1fr] gap-x-6 gap-y-2 text-sm\">",
|
|
386
|
+
" <dt class=\"text-muted-foreground\">Email</dt>",
|
|
387
|
+
" <dd>${`\\$\\{user?.email\\}`}</dd>",
|
|
388
|
+
" <dt class=\"text-muted-foreground\">Name</dt>",
|
|
389
|
+
" <dd>${`\\$\\{user?.name || 'Not set'\\}`}</dd>",
|
|
390
|
+
" </dl>",
|
|
391
|
+
" </div>",
|
|
392
|
+
" </div>",
|
|
282
393
|
" `;",
|
|
283
394
|
"}",
|
|
284
395
|
"",
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@webjskit/cli",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "webjs CLI — dev, start, create, db",
|
|
6
6
|
"bin": {
|
|
@@ -13,7 +13,8 @@
|
|
|
13
13
|
"README.md"
|
|
14
14
|
],
|
|
15
15
|
"dependencies": {
|
|
16
|
-
"@webjskit/server": "0.
|
|
16
|
+
"@webjskit/server": "0.5.0",
|
|
17
|
+
"@webjskit/ui": "0.1.0"
|
|
17
18
|
},
|
|
18
19
|
"publishConfig": {
|
|
19
20
|
"access": "public"
|
package/templates/AGENTS.md
CHANGED
|
@@ -112,6 +112,113 @@ layered on top:
|
|
|
112
112
|
See [docs.webjs.com → Editor setup](https://docs.webjs.com/docs/editor-setup)
|
|
113
113
|
for the full walkthrough.
|
|
114
114
|
|
|
115
|
+
## UI components — Webjs UI (preinstalled)
|
|
116
|
+
|
|
117
|
+
This scaffold ships with the standard Webjs UI component kit
|
|
118
|
+
**already installed at `components/ui/`**. The kit is **AI-first** and
|
|
119
|
+
splits into two tiers. Internalise the split — picking the wrong tier
|
|
120
|
+
produces broken markup.
|
|
121
|
+
|
|
122
|
+
### Tier 1 — class-helper functions (the majority)
|
|
123
|
+
|
|
124
|
+
Pure functions that return Tailwind class strings. You apply them to
|
|
125
|
+
**raw native HTML elements** that you write yourself. Examples:
|
|
126
|
+
`button`, `card`, `input`, `label`, `alert`, `badge`, `separator`,
|
|
127
|
+
`skeleton`, `kbd`, `table`, `breadcrumb`, `pagination`, `native-select`,
|
|
128
|
+
`avatar`, `checkbox`, `switch`, `radio-group`, `textarea`, `toggle`,
|
|
129
|
+
`aspect-ratio`.
|
|
130
|
+
|
|
131
|
+
```ts
|
|
132
|
+
import {
|
|
133
|
+
cardClass, cardHeaderClass, cardTitleClass,
|
|
134
|
+
cardContentClass, cardFooterClass,
|
|
135
|
+
} from '../../components/ui/card.ts';
|
|
136
|
+
import { inputClass } from '../../components/ui/input.ts';
|
|
137
|
+
import { labelClass } from '../../components/ui/label.ts';
|
|
138
|
+
import { buttonClass } from '../../components/ui/button.ts';
|
|
139
|
+
|
|
140
|
+
return html`
|
|
141
|
+
<div class=${cardClass()}>
|
|
142
|
+
<div class=${cardHeaderClass()}>
|
|
143
|
+
<h3 class=${cardTitleClass()}>Profile</h3>
|
|
144
|
+
</div>
|
|
145
|
+
<div class=${cardContentClass()}>
|
|
146
|
+
<label class=${labelClass()} for="name">Name</label>
|
|
147
|
+
<input class=${inputClass()} id="name" name="name">
|
|
148
|
+
</div>
|
|
149
|
+
<div class=${cardFooterClass()}>
|
|
150
|
+
<button class=${buttonClass()}>Save</button>
|
|
151
|
+
</div>
|
|
152
|
+
</div>
|
|
153
|
+
`;
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
Helpers with variants take an options object:
|
|
157
|
+
`buttonClass({ variant: 'outline', size: 'sm' })`.
|
|
158
|
+
|
|
159
|
+
### Tier 2 — stateful custom elements
|
|
160
|
+
|
|
161
|
+
For things the browser doesn't provide natively (focus traps, portaled
|
|
162
|
+
overlays, keyboard-navigated lists): `dialog`, `alert-dialog`, `popover`,
|
|
163
|
+
`tooltip`, `hover-card`, `tabs`, `accordion`, `collapsible`,
|
|
164
|
+
`dropdown-menu`, `progress`, `sonner`, `toggle-group`. These ARE custom
|
|
165
|
+
elements — import them once (typically in `app/layout.ts`) and use
|
|
166
|
+
`<ui-X>` tags:
|
|
167
|
+
|
|
168
|
+
```ts
|
|
169
|
+
// app/layout.ts (registers the custom elements for every page)
|
|
170
|
+
import '../components/ui/dialog.ts';
|
|
171
|
+
import '../components/ui/tabs.ts';
|
|
172
|
+
```
|
|
173
|
+
|
|
174
|
+
```ts
|
|
175
|
+
// app/some-page/page.ts (uses the registered elements)
|
|
176
|
+
import { buttonClass } from '../../components/ui/button.ts';
|
|
177
|
+
|
|
178
|
+
return html`
|
|
179
|
+
<ui-dialog>
|
|
180
|
+
<ui-dialog-trigger>
|
|
181
|
+
<button class=${buttonClass({ variant: 'outline' })}>Edit</button>
|
|
182
|
+
</ui-dialog-trigger>
|
|
183
|
+
<ui-dialog-content>
|
|
184
|
+
<h2>Edit profile</h2>
|
|
185
|
+
...
|
|
186
|
+
</ui-dialog-content>
|
|
187
|
+
</ui-dialog>
|
|
188
|
+
`;
|
|
189
|
+
```
|
|
190
|
+
|
|
191
|
+
### Adding more components
|
|
192
|
+
|
|
193
|
+
```sh
|
|
194
|
+
webjs ui add dialog dropdown-menu tabs progress
|
|
195
|
+
```
|
|
196
|
+
|
|
197
|
+
Each `webjs ui add` call fetches the component source from
|
|
198
|
+
`https://ui.webjs.dev/registry/<name>.json`, copies it into
|
|
199
|
+
`components/ui/`, and installs any required npm deps. Run
|
|
200
|
+
`webjs ui list` to browse the catalogue or visit
|
|
201
|
+
[https://ui.webjs.dev](https://ui.webjs.dev).
|
|
202
|
+
|
|
203
|
+
### AI agents — picking the right tier
|
|
204
|
+
|
|
205
|
+
For forms, dashboards, settings pages, marketing layouts: **call the
|
|
206
|
+
Tier-1 class helpers on raw native elements**. You get accessibility,
|
|
207
|
+
visual consistency, and form submission semantics for free —
|
|
208
|
+
`<input class=${inputClass()}>` is a real `<input>`, with native
|
|
209
|
+
autofill, browser validation, and `<form>` submission unchanged.
|
|
210
|
+
|
|
211
|
+
For modals, dropdowns, tooltips, tab strips, accordions: use the
|
|
212
|
+
Tier-2 `<ui-X>` custom element tags after importing the corresponding
|
|
213
|
+
module.
|
|
214
|
+
|
|
215
|
+
The composition style is deliberately **not** shadcn's
|
|
216
|
+
component-everything React API. We use native elements + class helpers
|
|
217
|
+
for the visual stuff because hiding a `<button>` inside a `<Button>`
|
|
218
|
+
wrapper adds zero value and obscures the real element from inspection,
|
|
219
|
+
form submission, and screen readers. Custom elements are reserved for
|
|
220
|
+
behavior the browser can't deliver natively.
|
|
221
|
+
|
|
115
222
|
## File conventions
|
|
116
223
|
|
|
117
224
|
```
|
|
@@ -235,18 +342,85 @@ type-safe RPC stub automatically.
|
|
|
235
342
|
|
|
236
343
|
## Metadata (per-page)
|
|
237
344
|
|
|
345
|
+
The `metadata` export is Next.js-compatible. Common fields shown below;
|
|
346
|
+
the full surface includes `title.template / .default / .absolute`,
|
|
347
|
+
`metadataBase`, `alternates: { canonical, languages, media, types }`,
|
|
348
|
+
`robots`, `keywords`, `authors`, `creator`, `publisher`, `verification`,
|
|
349
|
+
`icons`, `manifest`, `appleWebApp`, `formatDetection`, `itunes`, and
|
|
350
|
+
the typed `other: { '<meta-name>': value }` escape hatch.
|
|
351
|
+
|
|
238
352
|
```ts
|
|
239
353
|
export const metadata = {
|
|
240
354
|
title: 'My page',
|
|
355
|
+
// OR: title: { template: '%s — {{APP_NAME}}', default: '{{APP_NAME}}' }
|
|
241
356
|
description: 'A page in {{APP_NAME}}',
|
|
242
|
-
|
|
357
|
+
metadataBase: 'https://example.com', // base for relative URLs below
|
|
358
|
+
openGraph: { type: 'website', image: '/og.png' },
|
|
243
359
|
twitter: { card: 'summary_large_image' },
|
|
244
|
-
|
|
360
|
+
icons: { icon: '/favicon.svg', apple: '/apple.png' },
|
|
361
|
+
alternates: { canonical: '/post' }, // → <link rel="canonical">
|
|
362
|
+
robots: { index: true, follow: true },
|
|
363
|
+
cacheControl: 'public, max-age=60', // opt into caching (default: no-store)
|
|
245
364
|
};
|
|
246
365
|
```
|
|
247
366
|
|
|
248
367
|
Use `generateMetadata(ctx)` when you need request-scoped values (e.g.
|
|
249
|
-
absolute URLs from `ctx.url`)
|
|
368
|
+
absolute URLs from `ctx.url`):
|
|
369
|
+
|
|
370
|
+
```ts
|
|
371
|
+
export function generateMetadata(ctx: { url: string }) {
|
|
372
|
+
return { metadataBase: new URL(ctx.url).origin, title: 'Hello' };
|
|
373
|
+
}
|
|
374
|
+
```
|
|
375
|
+
|
|
376
|
+
Viewport may be split into its own export (Next.js 14+ pattern):
|
|
377
|
+
|
|
378
|
+
```ts
|
|
379
|
+
export const viewport = {
|
|
380
|
+
width: 'device-width',
|
|
381
|
+
initialScale: 1,
|
|
382
|
+
themeColor: '#1c1613',
|
|
383
|
+
colorScheme: 'light dark',
|
|
384
|
+
};
|
|
385
|
+
```
|
|
386
|
+
|
|
387
|
+
## Document shell (`<html>` / `<head>` / `<body>`)
|
|
388
|
+
|
|
389
|
+
The framework owns the shell by default. The SSR pipeline auto-emits
|
|
390
|
+
`<!doctype html><html lang="en"><head>…</head><body>` around every
|
|
391
|
+
composition, and auto-hoists `<link>` / `<style>` / `<meta>` / `<script>`
|
|
392
|
+
tags returned anywhere in a layout/page into the real `<head>`. The
|
|
393
|
+
`metadata` export drives `<title>` and `<meta>` tags.
|
|
394
|
+
|
|
395
|
+
**Only `app/layout.ts` (the root layout)** may optionally write its
|
|
396
|
+
own `<!doctype><html><head>…</head><body>` shell to override `<html lang>`,
|
|
397
|
+
`<html dir>`, `<html data-*>`, `<body class>`, or add a custom
|
|
398
|
+
`<link rel="preconnect">` etc. When the root layout supplies a shell,
|
|
399
|
+
the framework respects it and splices its required tags into the
|
|
400
|
+
user's `<head>`.
|
|
401
|
+
|
|
402
|
+
```ts
|
|
403
|
+
// app/layout.ts — root, optionally owning the shell
|
|
404
|
+
export default function RootLayout({ children }) {
|
|
405
|
+
return html`
|
|
406
|
+
<!doctype html>
|
|
407
|
+
<html lang="es" data-theme="dark">
|
|
408
|
+
<head>
|
|
409
|
+
<link rel="preconnect" href="https://cdn.example.com">
|
|
410
|
+
</head>
|
|
411
|
+
<body class="min-h-screen bg-bg">
|
|
412
|
+
<main>${children}</main>
|
|
413
|
+
</body>
|
|
414
|
+
</html>
|
|
415
|
+
`;
|
|
416
|
+
}
|
|
417
|
+
```
|
|
418
|
+
|
|
419
|
+
**Non-root layouts** (`app/<segment>/layout.ts`) and **pages**
|
|
420
|
+
(`app/**/page.ts`) **must NOT** write `<!doctype>` / `<html>` / `<head>`
|
|
421
|
+
/ `<body>`. The framework auto-emits the wrapper around the whole
|
|
422
|
+
composition, so a nested shell ends up dropped by the HTML parser.
|
|
423
|
+
`webjs check` enforces this via the `shell-in-non-root-layout` rule.
|
|
250
424
|
|
|
251
425
|
## Invariants (do not violate)
|
|
252
426
|
|
package/templates/CONVENTIONS.md
CHANGED
|
@@ -345,6 +345,64 @@ with puppeteer or playwright imports.
|
|
|
345
345
|
|
|
346
346
|
---
|
|
347
347
|
|
|
348
|
+
## UI components — prefer the Webjs UI kit over raw Tailwind
|
|
349
|
+
|
|
350
|
+
<!-- OVERRIDE -->
|
|
351
|
+
|
|
352
|
+
This scaffold ships with the Webjs UI kit preinstalled at `components/ui/`.
|
|
353
|
+
The kit splits into **two tiers** — picking the wrong tier produces
|
|
354
|
+
broken markup.
|
|
355
|
+
|
|
356
|
+
**Tier 1 — class helpers** (button, card, input, label, alert, badge,
|
|
357
|
+
separator, skeleton, table, etc.): pure functions that return Tailwind
|
|
358
|
+
class strings. Call them and spread onto a **raw native element**.
|
|
359
|
+
|
|
360
|
+
**Tier 2 — custom elements** (dialog, popover, tooltip, dropdown-menu,
|
|
361
|
+
tabs, accordion, collapsible, progress, etc.): real `<ui-X>` tags. Import
|
|
362
|
+
the module once (typically in `app/layout.ts`) and use the tag.
|
|
363
|
+
|
|
364
|
+
```ts
|
|
365
|
+
// Tier 1 — class helpers on native elements (use this for forms,
|
|
366
|
+
// dashboards, cards, layouts — anywhere the value is purely visual)
|
|
367
|
+
import { buttonClass } from '../components/ui/button.ts';
|
|
368
|
+
import { inputClass } from '../components/ui/input.ts';
|
|
369
|
+
return html`
|
|
370
|
+
<button class=${buttonClass({ size: 'lg' })}>Save</button>
|
|
371
|
+
<input class=${inputClass()} placeholder="Email">
|
|
372
|
+
`;
|
|
373
|
+
|
|
374
|
+
// Tier 2 — custom elements (modals, dropdowns, tab strips, tooltips —
|
|
375
|
+
// state the browser doesn't give you natively)
|
|
376
|
+
return html`
|
|
377
|
+
<ui-dialog>
|
|
378
|
+
<ui-dialog-trigger>
|
|
379
|
+
<button class=${buttonClass({ variant: 'outline' })}>Edit</button>
|
|
380
|
+
</ui-dialog-trigger>
|
|
381
|
+
<ui-dialog-content>…</ui-dialog-content>
|
|
382
|
+
</ui-dialog>
|
|
383
|
+
`;
|
|
384
|
+
|
|
385
|
+
// Avoid — hand-rolled Tailwind on every <button> loses visual
|
|
386
|
+
// consistency. Tier-1 helpers give you the same control with one import.
|
|
387
|
+
return html`
|
|
388
|
+
<button class="px-4 py-2 rounded-md bg-accent text-accent-fg">Save</button>
|
|
389
|
+
`;
|
|
390
|
+
```
|
|
391
|
+
|
|
392
|
+
Add more components with `webjs ui add <name>` (e.g. `webjs ui add dialog
|
|
393
|
+
tabs popover`). The catalogue lives at
|
|
394
|
+
[https://ui.webjs.dev](https://ui.webjs.dev).
|
|
395
|
+
|
|
396
|
+
**Hand-rolled Tailwind is still appropriate for:**
|
|
397
|
+
- One-off marketing pages, hero sections, landing CTAs.
|
|
398
|
+
- Anywhere the visual design intentionally diverges from the kit baseline.
|
|
399
|
+
- Layout primitives (`<div class="grid grid-cols-3 gap-4">`).
|
|
400
|
+
|
|
401
|
+
The convention: any visual element with a Tier-1 helper uses the helper.
|
|
402
|
+
Any stateful behavior with a Tier-2 element uses the element.
|
|
403
|
+
|
|
404
|
+
---
|
|
405
|
+
|
|
348
406
|
## Components
|
|
349
407
|
|
|
350
408
|
<!-- OVERRIDE -->
|