@webjsdev/cli 0.10.69 → 0.10.70
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/lib/create.js +14 -12
- package/lib/dev-reload.js +3 -1
- package/lib/runtime-rewrite.js +11 -5
- package/package.json +2 -2
- package/templates/.agents/rules/workflow.md +17 -14
- package/templates/.agents/skills/webjs/SKILL.md +0 -2
- package/templates/.agents/skills/webjs/references/runtime.md +5 -3
- package/templates/.claude/hooks/nudge-uncommitted.sh +0 -7
- package/templates/AGENTS.md +77 -47
- package/templates/CLAUDE.md +16 -14
- package/templates/CONVENTIONS.md +11 -10
- package/templates/Dockerfile +7 -1
- package/templates/partials/agents-playbook-fullstack.md +130 -566
- package/templates/scripts/clear-gallery.mjs +3 -3
package/lib/create.js
CHANGED
|
@@ -444,9 +444,20 @@ export async function scaffoldApp(name, cwd, opts = {}) {
|
|
|
444
444
|
'@webjsdev/cli': 'latest',
|
|
445
445
|
'@webjsdev/core': 'latest',
|
|
446
446
|
'@webjsdev/server': 'latest',
|
|
447
|
+
// What the production BOOT runs, so production dependencies (#1606):
|
|
448
|
+
// `webjs start` runs the `webjs.start.before` steps at every boot,
|
|
449
|
+
// `webjs db migrate` (drizzle-kit) and, on a UI app, the Tailwind
|
|
450
|
+
// compile. The Dockerfile installs production dependencies only, so as
|
|
451
|
+
// devDependencies these would be missing from the image and the boot
|
|
452
|
+
// would fail. `tailwindcss` itself is declared too (#1493):
|
|
453
|
+
// public/input.css starts with `@import "tailwindcss"`, and bun's
|
|
454
|
+
// isolated linker and pnpm link only declared packages, so leaving it
|
|
455
|
+
// transitive (via @tailwindcss/cli) fails the compile with
|
|
456
|
+
// `Can't resolve 'tailwindcss'`. The api template has no CSS.
|
|
457
|
+
'drizzle-kit': '1.0.0-rc.3',
|
|
458
|
+
...(isApi ? {} : { '@tailwindcss/cli': '^4.1.0', tailwindcss: '^4.1.0' }),
|
|
447
459
|
},
|
|
448
460
|
devDependencies: {
|
|
449
|
-
'drizzle-kit': '1.0.0-rc.3',
|
|
450
461
|
...(dialect === 'postgres' ? { '@types/pg': '^8.11.0' } : {}),
|
|
451
462
|
// The TypeScript compiler, for `npm run typecheck` (webjs typecheck runs
|
|
452
463
|
// tsc --noEmit). Not needed at runtime (Node strips types in place), only
|
|
@@ -467,15 +478,6 @@ export async function scaffoldApp(name, cwd, opts = {}) {
|
|
|
467
478
|
// assertNoA11yViolations() test helper from @webjsdev/core/testing.
|
|
468
479
|
// Test-only: dynamically imported, never shipped to the app runtime.
|
|
469
480
|
'axe-core': '^4.10.0',
|
|
470
|
-
// The Tailwind v4 CLI that css:build runs to compile public/input.css into
|
|
471
|
-
// the static public/tailwind.css the layout links. UI templates only (the
|
|
472
|
-
// api template has no CSS). Build tooling, never shipped to the runtime.
|
|
473
|
-
// `tailwindcss` itself is declared too (#1493): public/input.css starts
|
|
474
|
-
// with `@import "tailwindcss"`, so the app imports that package directly.
|
|
475
|
-
// Leaving it transitive (via @tailwindcss/cli) breaks under bun's isolated
|
|
476
|
-
// linker and pnpm, which link only declared packages into the app's
|
|
477
|
-
// node_modules, so the compile fails with `Can't resolve 'tailwindcss'`.
|
|
478
|
-
...(isApi ? {} : { '@tailwindcss/cli': '^4.1.0', tailwindcss: '^4.1.0' }),
|
|
479
481
|
// tsserver plugin, wired into tsconfig below. Gives the language
|
|
480
482
|
// INTELLIGENCE (go-to-def, completions, diagnostics, hover inside html``
|
|
481
483
|
// templates) in any tsserver editor with NO editor plugin installed,
|
|
@@ -1721,8 +1723,8 @@ ThemeToggle.register('theme-toggle');
|
|
|
1721
1723
|
`);
|
|
1722
1724
|
}
|
|
1723
1725
|
console.log(`For AI agents, read this before editing:
|
|
1724
|
-
• Read AGENTS.md
|
|
1725
|
-
|
|
1726
|
+
• Read AGENTS.md, then .agents/skills/webjs/SKILL.md. The skill is the guide
|
|
1727
|
+
to building a WebJs app and routes to focused references on demand.
|
|
1726
1728
|
• This scaffold is a minimal starting point, not a demo to prune. Grow the app
|
|
1727
1729
|
in place: add routes under app/, components under components/, and features
|
|
1728
1730
|
under modules/<feature>/, and keep server-only code behind .server.ts.
|
package/lib/dev-reload.js
CHANGED
|
@@ -68,7 +68,9 @@ export const KILL_TIMEOUT_MS = 2000;
|
|
|
68
68
|
* @returns {boolean}
|
|
69
69
|
*/
|
|
70
70
|
export function shouldIgnoreRestartPath(rel) {
|
|
71
|
-
return /(?:^|[\\/])(?:node_modules|\.git|\.webjs)(?:[\\/]|$)|(?:^|[\\/])db[\\/](?:dev\.db|migrations)/.test(rel || '')
|
|
71
|
+
return /(?:^|[\\/])(?:node_modules|\.git|\.webjs)(?:[\\/]|$)|(?:^|[\\/])db[\\/](?:dev\.db|migrations)/.test(rel || '')
|
|
72
|
+
// Tool output nothing serves, as the server's watcher skips it (watch-ignore.js).
|
|
73
|
+
|| /(?:^|[\\/])(?:coverage|\.cache|\.nyc_output|test-results|playwright-report|\.turbo)(?:[\\/]|$)|\.log$|(?:^|[\\/])\.DS_Store$|\.swp$|~$/.test(rel || '');
|
|
72
74
|
}
|
|
73
75
|
|
|
74
76
|
/**
|
package/lib/runtime-rewrite.js
CHANGED
|
@@ -23,7 +23,7 @@
|
|
|
23
23
|
* - the `dev` / `start` scripts force `bun --bun` (the server is Bun),
|
|
24
24
|
* - every other command stays `bun run` / `webjs ...` (runs on Node via the
|
|
25
25
|
* `webjs` bin's `#!/usr/bin/env node` shebang),
|
|
26
|
-
* - the Dockerfile is a pure `oven/bun:1` base (#595). This is safe as of
|
|
26
|
+
* - the Dockerfile is a pure `oven/bun:1-slim` base (#595, #1606). This is safe as of
|
|
27
27
|
* `@webjsdev/cli@0.10.20` (#570): `webjs db migrate` resolves drizzle-kit
|
|
28
28
|
* and runs it under Bun (no `npx`), so a Node-less image works. (Before
|
|
29
29
|
* #570 shipped as `latest`, this stayed on `node:24-alpine` + a copied Bun
|
|
@@ -96,7 +96,7 @@ export function bunifyProse(s) {
|
|
|
96
96
|
/**
|
|
97
97
|
* Rewrite the scaffolded Dockerfile for Bun.
|
|
98
98
|
*
|
|
99
|
-
* Base decision (acceptance criterion): a pure `oven/bun:1` image (no Node).
|
|
99
|
+
* Base decision (acceptance criterion): a pure `oven/bun:1-slim` image (no Node).
|
|
100
100
|
* Safe as of `@webjsdev/cli@0.10.20` (#570): `webjs db` / `webjs test` resolve
|
|
101
101
|
* their tools (drizzle-kit, wtr) and spawn them with the current runtime instead
|
|
102
102
|
* of `npx`, so the boot-time `webjs db migrate` runs under Bun with no Node
|
|
@@ -123,7 +123,9 @@ export function bunifyDockerfile(s) {
|
|
|
123
123
|
'# runs on Node 24+; for a Node base instead, swap to `node:24-alpine` and start with\n' +
|
|
124
124
|
'# `npm start`.\n',
|
|
125
125
|
)
|
|
126
|
-
|
|
126
|
+
// The slim variant: the same Bun on a Debian slim base (ca-certificates
|
|
127
|
+
// included), without the full image's extra system packages.
|
|
128
|
+
.replace('FROM node:24-alpine', 'FROM oven/bun:1-slim')
|
|
127
129
|
// Debian base: ca-certificates already present, no `apk`. Drop the alpine line.
|
|
128
130
|
.replace(
|
|
129
131
|
/# ca-certificates for outbound TLS \(e\.g\. a managed Postgres\)\. SQLite uses the\n# built-in node:sqlite \(no native module, no build toolchain needed\)\.\nRUN apk add --no-cache ca-certificates\n\n/,
|
|
@@ -131,8 +133,12 @@ export function bunifyDockerfile(s) {
|
|
|
131
133
|
)
|
|
132
134
|
// Lockfile + install (bun.lock, bun install).
|
|
133
135
|
.replace(
|
|
134
|
-
'# package-lock.json is optional (it\'s absent when the app was scaffolded with\n# --no-install); the glob keeps the COPY working with or without it.\
|
|
135
|
-
'# bun.lock is optional (absent when scaffolded with --no-install); the glob keeps\n# the COPY working with or without it. SQLite uses the built-in bun:sqlite, so no\n# native dependency or postinstall is involved.\
|
|
136
|
+
'# package-lock.json is optional (it\'s absent when the app was scaffolded with\n# --no-install); the glob keeps the COPY working with or without it.\n',
|
|
137
|
+
'# bun.lock is optional (absent when scaffolded with --no-install); the glob keeps\n# the COPY working with or without it. SQLite uses the built-in bun:sqlite, so no\n# native dependency or postinstall is involved.\n',
|
|
138
|
+
)
|
|
139
|
+
.replace(
|
|
140
|
+
'reason. The npm cache is emptied in the same layer: it is a second copy of\n# every package fetched and would otherwise ship in the image.\nCOPY package.json package-lock.json* ./\nRUN npm install --omit=dev --no-audit --no-fund && npm cache clean --force',
|
|
141
|
+
'reason. bun\'s package cache is emptied in the same layer: it is a second copy\n# of every package fetched and would otherwise ship in the image.\nCOPY package.json bun.lock* ./\nRUN bun install --production && bun pm cache rm',
|
|
136
142
|
)
|
|
137
143
|
// Healthcheck: the pure Bun image has no node; use `bun -e`. Keep the
|
|
138
144
|
// dependency-free-probe comment accurate (the probe runs under Bun now).
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@webjsdev/cli",
|
|
3
|
-
"version": "0.10.
|
|
3
|
+
"version": "0.10.70",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "The CLI for WebJs, a full-stack JavaScript framework built on web components with server-side rendering and no build step. Runs the dev and production servers, scaffolds apps, validates conventions, and drives the database. Node 24+ or Bun.",
|
|
6
6
|
"bin": {
|
|
@@ -18,7 +18,7 @@
|
|
|
18
18
|
],
|
|
19
19
|
"dependencies": {
|
|
20
20
|
"@webjsdev/mcp": "^0.1.0",
|
|
21
|
-
"@webjsdev/server": "^0.8.
|
|
21
|
+
"@webjsdev/server": "^0.8.88",
|
|
22
22
|
"@webjsdev/ui": "^0.3.15"
|
|
23
23
|
},
|
|
24
24
|
"publishConfig": {
|
|
@@ -2,21 +2,23 @@
|
|
|
2
2
|
|
|
3
3
|
You are working on a WebJs app (AI-first, no-build, web-components-first). This
|
|
4
4
|
file is the WORKFLOW contract (git, tests, review). For HOW to build (routing,
|
|
5
|
-
components, actions, styling, the framework API), read
|
|
6
|
-
|
|
7
|
-
at https://webjs.dev/docs.
|
|
5
|
+
components, actions, styling, the framework API), read
|
|
6
|
+
`.agents/skills/webjs/SKILL.md`, which routes to focused references on demand.
|
|
7
|
+
Read `AGENTS.md` first. Full hosted docs are at https://webjs.dev/docs.
|
|
8
8
|
|
|
9
9
|
## Grow the app in place (non-negotiable)
|
|
10
10
|
|
|
11
|
-
- **
|
|
12
|
-
|
|
13
|
-
feature gallery (`app/features/`,
|
|
14
|
-
|
|
15
|
-
Building a real app:
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
11
|
+
- **Study the shipped examples, then clear them and build.** The scaffold is a
|
|
12
|
+
starting point with a browsable showcase to learn the real idioms from, plus a
|
|
13
|
+
database wired up. A full-stack app ships a UI feature gallery (`app/features/`,
|
|
14
|
+
`app/examples/todo`); the api template ships a backend-features showcase
|
|
15
|
+
(`app/api/features/`), with logic in `modules/`. Building a real app: study the
|
|
16
|
+
parts that match your task (the skill teaches the same and SURVIVES the clear),
|
|
17
|
+
run `npm run gallery:clear` to shed the showcase (it keeps the agent skill and
|
|
18
|
+
the database wiring, and resets to a clean base), then regenerate the database
|
|
19
|
+
and grow the app in place under `app/`, `components/`, and `modules/<feature>/`.
|
|
20
|
+
`AGENTS.md` carries the full template-specific build playbook and the order to
|
|
21
|
+
follow.
|
|
20
22
|
- **Use the wired-up database (Drizzle), never JSON files.** For any data the app
|
|
21
23
|
stores, define a Drizzle table in `db/schema.server.ts`, then
|
|
22
24
|
`npm run db:generate` and `npm run db:migrate`. Never use a JSON file, a
|
|
@@ -24,8 +26,9 @@ at https://webjs.dev/docs.
|
|
|
24
26
|
- **`app/` is routing-only.** Only routing files live in `app/` (page, layout,
|
|
25
27
|
route, middleware, metadata routes). Browser-safe helpers go in `lib/utils/`,
|
|
26
28
|
feature logic in `modules/`, server-only code behind `.server.ts`.
|
|
27
|
-
- **For a UI app, render and LOOK before calling it done.**
|
|
28
|
-
|
|
29
|
+
- **For a UI app, render and LOOK before calling it done.** Define design tokens
|
|
30
|
+
in `app/layout.ts` with a palette that fits the app
|
|
31
|
+
(`.agents/skills/webjs/references/styling.md` is the guide), then open every
|
|
29
32
|
route you changed in a real browser and play through its states.
|
|
30
33
|
`npm run check` and `npm run typecheck` pass even when a layout collapses, so
|
|
31
34
|
the browser is the real check.
|
|
@@ -9,8 +9,6 @@ Use this skill for end-to-end WebJs app work. It helps you choose the right laye
|
|
|
9
9
|
|
|
10
10
|
## Full Documentation
|
|
11
11
|
|
|
12
|
-
In a scaffolded app, `AGENTS.md` carries the build steps and a worked example of every common pattern (pages, layouts, form-bound actions with validation, queries, owner-scoped CRUD, `createAuth`, a component with signals, a test). Build from it first, and come here for a surface it does not show.
|
|
13
|
-
|
|
14
12
|
This skill is the quick guide. When you need the full API reference for a surface, load the matching file in `references/` (listed below). For even deeper framework detail, WebJs ships buildless, so the source you run IS the source you read: look in `node_modules/@webjsdev/{core,server,cli}/` (each package ships its own `AGENTS.md`). The complete hosted docs live at https://webjs.dev/docs.
|
|
15
13
|
|
|
16
14
|
## What WebJs Is
|
|
@@ -68,7 +68,7 @@ One limit worth knowing: the rewrite belongs to `startServer`. An app embedded t
|
|
|
68
68
|
webjs create my-app --runtime bun
|
|
69
69
|
```
|
|
70
70
|
|
|
71
|
-
`--runtime` is orthogonal to `--template`, so it re-flavors either full-stack or api. A Bun scaffold emits a `bun.lock`, a pure `oven/bun:1` Dockerfile plus a bun-install CI, and bun-command agent docs. The test, db, and check tooling still runs on Node.
|
|
71
|
+
`--runtime` is orthogonal to `--template`, so it re-flavors either full-stack or api. A Bun scaffold emits a `bun.lock`, a pure `oven/bun:1-slim` Dockerfile plus a bun-install CI, and bun-command agent docs. The test, db, and check tooling still runs on Node.
|
|
72
72
|
|
|
73
73
|
## Running on Bun
|
|
74
74
|
|
|
@@ -79,13 +79,15 @@ bun install
|
|
|
79
79
|
bun run dev # or: bun run start
|
|
80
80
|
```
|
|
81
81
|
|
|
82
|
-
`bun --bun` overrides the `webjs` bin's Node shebang so the server runs on Bun, selecting the native `Bun.serve` listener and `amaro` type stripping. The app's dependencies resolve from `node_modules` exactly as on Node. The `start.before` migrate step (`webjs db migrate`) runs under Bun too. Commit the `bun.lock` for reproducible, offline installs. The scaffold's Bun Dockerfile runs `bun install` and serves via `CMD ["bun", "--bun", "run", "start"]`.
|
|
82
|
+
`bun --bun` overrides the `webjs` bin's Node shebang so the server runs on Bun, selecting the native `Bun.serve` listener and `amaro` type stripping. The app's dependencies resolve from `node_modules` exactly as on Node. The `start.before` migrate step (`webjs db migrate`) runs under Bun too. Commit the `bun.lock` for reproducible, offline installs. The scaffold's Bun Dockerfile runs `bun install --production` and serves via `CMD ["bun", "--bun", "run", "start"]`.
|
|
83
|
+
|
|
84
|
+
**What the dev watcher ignores.** On both runtimes a change to a file nothing serves never reloads the page: `*.log`, `coverage/`, `.cache/`, `test-results/`, `playwright-report/`, and anything the app's `.gitignore` ignores (except `.env*`). So `npm run dev > dev.log` in the app folder is safe.
|
|
83
85
|
|
|
84
86
|
## Deploying either runtime
|
|
85
87
|
|
|
86
88
|
Production runs `npm run start` (Node) or `bun run start` (Bun), which serves the source directly with no build step. Both speak plain HTTP/1.1, so put a reverse proxy or platform edge in front for TLS and HTTP/2 (production perf leans on HTTP/2 multiplexing plus `modulepreload` hints, not a bundle). A `start.before` migrate runs first on both runtimes.
|
|
87
89
|
|
|
88
|
-
The scaffold ships a matching Dockerfile per runtime: a Node image for the default, a pure `oven/bun:1` image for `--runtime bun`. Commit the lockfile the runtime uses (`package-lock.json` for Node, `bun.lock` for Bun) so the deploy install is reproducible and offline.
|
|
90
|
+
The scaffold ships a matching Dockerfile per runtime: a Node image for the default, a pure `oven/bun:1-slim` image for `--runtime bun`. Both install production dependencies only and leave the package manager's cache out of the image. What the boot runs is a production dependency for that reason: `drizzle-kit` (the `start.before` migrate) and, on a UI app, `@tailwindcss/cli` + `tailwindcss` (the CSS compile). Keep a tool the boot runs in `dependencies`; one moved to `devDependencies` is missing from the image and the boot fails. Commit the lockfile the runtime uses (`package-lock.json` for Node, `bun.lock` for Bun) so the deploy install is reproducible and offline.
|
|
89
91
|
|
|
90
92
|
## SQLite busy_timeout
|
|
91
93
|
|
|
@@ -30,13 +30,6 @@ fi
|
|
|
30
30
|
# Read stdin so we don't break Claude Code's hook contract.
|
|
31
31
|
cat /dev/stdin >/dev/null 2>&1 || true
|
|
32
32
|
|
|
33
|
-
# A repository with no commit yet is a first build from the scaffold: the whole
|
|
34
|
-
# build is one logical unit (CLAUDE.md), committed once at the end, so nudging
|
|
35
|
-
# mid-build would only split it. The Stop hook still asks for that commit.
|
|
36
|
-
if ! git rev-parse --verify -q HEAD >/dev/null 2>&1; then
|
|
37
|
-
exit 0
|
|
38
|
-
fi
|
|
39
|
-
|
|
40
33
|
CHANGED=$(git status --porcelain 2>/dev/null | wc -l | tr -d ' ')
|
|
41
34
|
|
|
42
35
|
if [ -z "$CHANGED" ] || [ "$CHANGED" -lt "$THRESHOLD" ]; then
|
package/templates/AGENTS.md
CHANGED
|
@@ -1,52 +1,82 @@
|
|
|
1
1
|
# AGENTS.md for {{APP_NAME}}
|
|
2
2
|
|
|
3
|
-
This is a WebJs app:
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
3
|
+
This is a WebJs app: AI-first, web-components-first, buildless, and
|
|
4
|
+
progressively enhanced. Read this whole file before you edit anything, then
|
|
5
|
+
follow it. The steps here are required, not optional.
|
|
6
|
+
|
|
7
|
+
## Gather context BEFORE you build (required)
|
|
8
|
+
|
|
9
|
+
WebJs is its own framework. It is not React, Next, or Lit, so writing code from
|
|
10
|
+
that muscle memory produces broken WebJs code. Before you write or change
|
|
11
|
+
anything, gather context from these sources. Do not skip a step to save time.
|
|
12
|
+
This is what separates a working app from a broken one.
|
|
13
|
+
|
|
14
|
+
1. **Read the skill.** Start with `.agents/skills/webjs/SKILL.md`, then load the
|
|
15
|
+
`references/*.md` files it routes to for the surface you are touching. The
|
|
16
|
+
skill is the guide to building a WebJs app: it helps you choose the right
|
|
17
|
+
layer, reach for the right export, and avoid the mistakes Next.js or Lit
|
|
18
|
+
habits cause. Reading it is never wasted work: it survives the
|
|
19
|
+
gallery-clearing step in the playbook below.
|
|
20
|
+
2. **Study the shipped examples, then build on a clean slate.** The template
|
|
21
|
+
playbook below says what ships and the exact order to follow. The workflow
|
|
22
|
+
rules (git, tests, review) are in `.agents/rules/workflow.md`; follow them
|
|
23
|
+
too.
|
|
24
|
+
3. **Read the framework source for exact contracts.** WebJs is 100% buildless
|
|
25
|
+
native ES modules, so the source you run IS the source you read. When you
|
|
26
|
+
need a precise API signature or behavior, open the package source under
|
|
27
|
+
`node_modules/@webjsdev/*` directly (each package ships its own `AGENTS.md`).
|
|
28
|
+
The full hosted docs are at https://webjs.dev/docs.
|
|
7
29
|
|
|
8
30
|
{{PLAYBOOK}}
|
|
9
31
|
|
|
10
|
-
##
|
|
11
|
-
|
|
12
|
-
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
`
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
32
|
+
## Type everything (all templates)
|
|
33
|
+
|
|
34
|
+
Full-stack type safety is what the `.server.ts` boundary buys you: a client
|
|
35
|
+
component importing a server action resolves to that action's real signature at
|
|
36
|
+
type-check time, with no build step and no code generation in between. So
|
|
37
|
+
DERIVE the type at every boundary instead of widening it:
|
|
38
|
+
|
|
39
|
+
- A database row: `export type Todo = typeof todos.$inferSelect` in
|
|
40
|
+
`db/schema.server.ts` (`$inferInsert` for a write), carried into a
|
|
41
|
+
browser-shipped component with `import type` (erased before it reaches the
|
|
42
|
+
browser, so it does not trip the server-import boundary).
|
|
43
|
+
- An action's input: a named `interface`. Its result: `ActionResult<T>`.
|
|
44
|
+
Narrow with `if (result.success && result.data)`.
|
|
45
|
+
- Routing files: `PageProps<'/blog/[slug]'>`, `LayoutProps`,
|
|
46
|
+
`RouteHandlerContext`, all from `@webjsdev/core`. Run `npx webjsdev types`
|
|
47
|
+
for the typed `Route` union and per-route `params`.
|
|
48
|
+
- A reactive property: `prop<Student>(Object)`, `prop<Tag[]>(Array)`.
|
|
49
|
+
|
|
50
|
+
Never reach for `any` or a loose `as any` cast, and do not reach for `unknown`
|
|
51
|
+
either just because it looks safer. `unknown` is right for a payload nothing
|
|
52
|
+
has vouched for yet, narrowed on the very next line (a `route.ts` `await
|
|
53
|
+
req.json()`, an action's `export const validate` or a validator it delegates
|
|
54
|
+
to, a `catch` binding), and for a parameter of YOUR OWN helper that forwards
|
|
55
|
+
into an `html` template hole (a hole renders a string, a number, a
|
|
56
|
+
`TemplateResult`, or an array of those, so `TemplateResult` alone is too
|
|
57
|
+
narrow). That second case is about a value you accept, never one the framework
|
|
58
|
+
already types. Everywhere else it is a missing type, not a safe one: `unknown`
|
|
59
|
+
that survives into a return type, a component prop, a layout's `children`, or
|
|
60
|
+
an action signature is the shape to fix.
|
|
61
|
+
Nothing enforces this (both are valid TypeScript, so `webjs check` and `tsc`
|
|
62
|
+
pass either way), which is exactly why it is written down. The full ladder,
|
|
63
|
+
with an end-to-end example, is in
|
|
64
|
+
`.agents/skills/webjs/references/typescript.md`.
|
|
65
|
+
|
|
66
|
+
Keep server-only code (database drivers, secrets, `node:*` builtins) in
|
|
67
|
+
`.server.ts` modules. There are exactly two kinds:
|
|
68
|
+
|
|
69
|
+
- A `.server.ts` file WITH `'use server';` as its first line is a server
|
|
70
|
+
action: WebJs exposes its exported async functions to browser code as RPC
|
|
71
|
+
calls, so browser modules may import it directly.
|
|
72
|
+
- A `.server.ts` file WITHOUT `'use server'` is a server-only utility:
|
|
73
|
+
importing it from a page, layout, or component CRASHES in the browser at
|
|
74
|
+
module load. Reach it only from `'use server'` actions, `route.ts` handlers,
|
|
75
|
+
or middleware. Never add `'use server'` to a file only other server code
|
|
76
|
+
imports (the DB connection, the schema).
|
|
77
|
+
|
|
78
|
+
## Data (all templates)
|
|
79
|
+
|
|
80
|
+
Use the wired-up database (Drizzle) for every piece of data the app stores;
|
|
81
|
+
the playbook above has the modeling step. Never store app data in a JSON file,
|
|
82
|
+
an in-memory array, or localStorage.
|
package/templates/CLAUDE.md
CHANGED
|
@@ -3,19 +3,21 @@
|
|
|
3
3
|
# Committing per logical unit (this OVERRIDES Claude Code's default)
|
|
4
4
|
|
|
5
5
|
Claude Code's built-in default is to NEVER commit unless the user explicitly
|
|
6
|
-
asks. **For this project that default does NOT apply.** Commit per
|
|
7
|
-
unit (one feature, one fix, one rename, one doc rewrite) as soon as it
|
|
8
|
-
complete
|
|
9
|
-
|
|
6
|
+
asks. **For this project that default does NOT apply.** Commit and push per
|
|
7
|
+
logical unit (one feature, one fix, one rename, one doc rewrite) as soon as it
|
|
8
|
+
is complete, WITHOUT being asked. Do not save all the work for one commit at the
|
|
9
|
+
end. A finished implementation with zero commits is a mistake here, because git
|
|
10
|
+
history is the user's revert and cherry-pick safety net.
|
|
10
11
|
|
|
11
|
-
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
-
|
|
16
|
-
|
|
12
|
+
- After each completed unit whose tests pass, `git add` the related files and
|
|
13
|
+
`git commit` with an imperative subject under 72 chars, then push. If 5+ files
|
|
14
|
+
span more than one concern, you already waited too long.
|
|
15
|
+
- Never commit to `main`. Work on a feature branch (the
|
|
16
|
+
`.claude/hooks/guard-branch-context.sh` hook enforces this).
|
|
17
|
+
- No AI-attribution trailers (`Co-Authored-By`, `Generated by`).
|
|
17
18
|
|
|
18
|
-
Two hooks back this up:
|
|
19
|
-
uncommitted
|
|
20
|
-
|
|
21
|
-
|
|
19
|
+
See AGENTS.md "Git workflow" for the full contract. Two hooks back this up: the
|
|
20
|
+
`.claude/hooks/nudge-uncommitted.sh` PostToolUse hook reminds you while
|
|
21
|
+
uncommitted changes pile up during work, and the
|
|
22
|
+
`.claude/hooks/commit-before-stop.sh` Stop hook stops you from ending a turn
|
|
23
|
+
with a pile of uncommitted work still on a feature branch.
|
package/templates/CONVENTIONS.md
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
# Conventions for {{APP_NAME}}
|
|
2
2
|
|
|
3
|
-
The conventions for building a WebJs app live in
|
|
4
|
-
|
|
5
|
-
`.agents/skills/webjs
|
|
6
|
-
|
|
3
|
+
The conventions for building a WebJs app live in the agent skill. **Read
|
|
4
|
+
`AGENTS.md` first, then `.agents/skills/webjs/SKILL.md`** (it routes to focused
|
|
5
|
+
references under `.agents/skills/webjs/references/`, loaded on demand). This file
|
|
6
|
+
is the short version.
|
|
7
7
|
|
|
8
8
|
## The essentials
|
|
9
9
|
|
|
@@ -17,12 +17,13 @@ surfaces. This file is the short version.
|
|
|
17
17
|
- **Use the wired-up database (Drizzle).** Define real models in
|
|
18
18
|
`db/schema.server.ts`, then `npm run db:generate` and `npm run db:migrate`.
|
|
19
19
|
Never persist to a JSON file, an in-memory array or Map, or localStorage.
|
|
20
|
-
- **The scaffold ships a
|
|
21
|
-
gallery (`app/features/`, `app/examples/todo`); the api template ships
|
|
22
|
-
backend-features showcase (`app/api/features/`), with logic in `modules/`.
|
|
23
|
-
When you build a real app,
|
|
24
|
-
|
|
25
|
-
|
|
20
|
+
- **The scaffold ships a showcase to learn from.** A full-stack app ships a UI
|
|
21
|
+
feature gallery (`app/features/`, `app/examples/todo`); the api template ships
|
|
22
|
+
a backend-features showcase (`app/api/features/`), with logic in `modules/`.
|
|
23
|
+
When you build a real app, study the parts that match your task (the skill
|
|
24
|
+
teaches the same and survives the clear), run `npm run gallery:clear` to shed
|
|
25
|
+
the showcase, then grow the app in place. `AGENTS.md` has the full
|
|
26
|
+
template-specific playbook.
|
|
26
27
|
- **Derive types at every boundary.** Rows from `$inferSelect`, action inputs
|
|
27
28
|
from an `interface`, routing files from `PageProps` / `LayoutProps`. Never
|
|
28
29
|
`any`, and never `unknown` where a real type exists.
|
package/templates/Dockerfile
CHANGED
|
@@ -30,8 +30,14 @@ WORKDIR /app
|
|
|
30
30
|
# Install deps first so this layer is cached unless the manifests change.
|
|
31
31
|
# package-lock.json is optional (it's absent when the app was scaffolded with
|
|
32
32
|
# --no-install); the glob keeps the COPY working with or without it.
|
|
33
|
+
# Production dependencies only: the image never runs the type checker, the
|
|
34
|
+
# test runner or the browser tests, and leaving them out keeps it a fraction
|
|
35
|
+
# of the size. What the boot itself runs (drizzle-kit for `webjs db migrate`,
|
|
36
|
+
# the Tailwind CLI for the CSS compile) is a production dependency for that
|
|
37
|
+
# reason. The npm cache is emptied in the same layer: it is a second copy of
|
|
38
|
+
# every package fetched and would otherwise ship in the image.
|
|
33
39
|
COPY package.json package-lock.json* ./
|
|
34
|
-
RUN npm install --no-audit --no-fund
|
|
40
|
+
RUN npm install --omit=dev --no-audit --no-fund && npm cache clean --force
|
|
35
41
|
|
|
36
42
|
# App source. node_modules and local state are excluded via .dockerignore.
|
|
37
43
|
COPY . .
|
|
@@ -1,571 +1,135 @@
|
|
|
1
|
-
## Build
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
`
|
|
41
|
-
|
|
42
|
-
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
app
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
`
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
// modules/posts/types.ts (browser-safe: components import this, never the schema)
|
|
119
|
-
export const POST_STATUSES = ['draft', 'review', 'published'] as const;
|
|
120
|
-
export type PostStatus = (typeof POST_STATUSES)[number];
|
|
121
|
-
export interface StatusCounts { draft: number; review: number; published: number; total: number }
|
|
122
|
-
```
|
|
123
|
-
|
|
124
|
-
```ts
|
|
125
|
-
// lib/utils/form.ts
|
|
126
|
-
import { html } from '@webjsdev/core';
|
|
127
|
-
import { labelClass } from '#components/ui/label.ts';
|
|
128
|
-
import { inputClass } from '#components/ui/input.ts';
|
|
129
|
-
|
|
130
|
-
/** What a failed form action hands back to the page as `actionData`. */
|
|
131
|
-
export interface FormState { error?: string; fieldErrors?: Record<string, string>; values?: Record<string, string> }
|
|
132
|
-
export const isEmail = (s: string) => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(s);
|
|
133
|
-
export const str = (fd: FormData, k: string) => String(fd.get(k) ?? '').trim();
|
|
134
|
-
export const toId = (v: unknown) => { const n = Number(v); return Number.isInteger(n) && n > 0 ? n : null; };
|
|
135
|
-
|
|
136
|
-
/** A labelled input with its server error under it and the typed value kept. */
|
|
137
|
-
export function field(o: { label: string; name: string; type?: string; value?: string; error?: string; required?: boolean }) {
|
|
138
|
-
return html`
|
|
139
|
-
<div class="grid gap-1.5">
|
|
140
|
-
<label for=${o.name} class=${labelClass()}>${o.label}</label>
|
|
141
|
-
<input id=${o.name} name=${o.name} type=${o.type ?? 'text'} value=${o.value ?? ''} ?required=${o.required}
|
|
142
|
-
aria-invalid=${o.error ? 'true' : 'false'} class=${inputClass()}>
|
|
143
|
-
${o.error ? html`<p class="text-sm text-destructive">${o.error}</p>` : ''}
|
|
144
|
-
</div>`;
|
|
145
|
-
}
|
|
146
|
-
```
|
|
147
|
-
|
|
148
|
-
Auth uses the built-in `createAuth` (a signed session cookie) and `node:crypto`
|
|
149
|
-
scrypt. No extra package is needed.
|
|
150
|
-
|
|
151
|
-
```ts
|
|
152
|
-
// modules/auth/password.server.ts
|
|
153
|
-
import { scrypt, randomBytes, timingSafeEqual } from 'node:crypto';
|
|
154
|
-
import { promisify } from 'node:util';
|
|
155
|
-
const scryptAsync = promisify(scrypt);
|
|
156
|
-
export async function hashPassword(pw: string) {
|
|
157
|
-
const salt = randomBytes(16).toString('hex');
|
|
158
|
-
return salt + ':' + ((await scryptAsync(pw, salt, 64)) as Buffer).toString('hex');
|
|
159
|
-
}
|
|
160
|
-
export async function verifyPassword(pw: string, stored: string) {
|
|
161
|
-
const [salt, key] = stored.split(':');
|
|
162
|
-
return timingSafeEqual((await scryptAsync(pw, salt, 64)) as Buffer, Buffer.from(key, 'hex'));
|
|
163
|
-
}
|
|
164
|
-
```
|
|
165
|
-
|
|
166
|
-
```ts
|
|
167
|
-
// modules/auth/auth.server.ts (server-only: no 'use server')
|
|
168
|
-
import { createAuth, Credentials } from '@webjsdev/server';
|
|
169
|
-
import { db } from '#db/connection.server.ts';
|
|
170
|
-
import { verifyPassword } from './password.server.ts';
|
|
171
|
-
|
|
172
|
-
const secret = process.env.AUTH_SECRET;
|
|
173
|
-
if (!secret) throw new Error('AUTH_SECRET is not set');
|
|
174
|
-
export const { auth, signIn, signOut } = createAuth({
|
|
175
|
-
secret,
|
|
176
|
-
pages: { signIn: '/signin', error: '/signin' },
|
|
177
|
-
providers: [Credentials({
|
|
178
|
-
async authorize(c: { email: string; password: string }) {
|
|
179
|
-
const user = await db.query.users.findFirst({ where: { email: c.email } });
|
|
180
|
-
if (!user || !(await verifyPassword(c.password, user.passwordHash))) return null;
|
|
181
|
-
return { id: String(user.id), email: user.email };
|
|
182
|
-
},
|
|
183
|
-
})],
|
|
184
|
-
});
|
|
185
|
-
export interface SessionUser { id: number; email: string }
|
|
186
|
-
export async function getUser(): Promise<SessionUser | null> {
|
|
187
|
-
const u = (await auth())?.user;
|
|
188
|
-
return u?.id ? { id: Number(u.id), email: String(u.email) } : null;
|
|
189
|
-
}
|
|
190
|
-
```
|
|
191
|
-
|
|
192
|
-
```ts
|
|
193
|
-
// modules/auth/queries/current-user.server.ts (for the layout and public pages)
|
|
194
|
-
'use server';
|
|
195
|
-
import { getUser, type SessionUser } from '../auth.server.ts';
|
|
196
|
-
export async function currentUser(): Promise<SessionUser | null> {
|
|
197
|
-
return getUser();
|
|
198
|
-
}
|
|
199
|
-
|
|
200
|
-
// modules/auth/queries/require-user.server.ts (call first in every signed-in page)
|
|
201
|
-
'use server';
|
|
202
|
-
import { redirect } from '@webjsdev/core';
|
|
203
|
-
import { getUser, type SessionUser } from '../auth.server.ts';
|
|
204
|
-
export async function requireUser(): Promise<SessionUser> {
|
|
205
|
-
return (await getUser()) ?? redirect('/signin');
|
|
206
|
-
}
|
|
207
|
-
```
|
|
208
|
-
|
|
209
|
-
```ts
|
|
210
|
-
// modules/auth/actions/sign-up.server.ts
|
|
211
|
-
'use server';
|
|
212
|
-
import { db } from '#db/connection.server.ts';
|
|
213
|
-
import { users } from '#db/schema.server.ts';
|
|
214
|
-
import { isEmail, str } from '#lib/utils/form.ts';
|
|
215
|
-
import { hashPassword } from '../password.server.ts';
|
|
216
|
-
import { signIn } from '../auth.server.ts';
|
|
217
|
-
|
|
218
|
-
export async function signUp(fd: FormData) {
|
|
219
|
-
const email = str(fd, 'email').toLowerCase();
|
|
220
|
-
const password = String(fd.get('password') ?? '');
|
|
221
|
-
const fieldErrors: Record<string, string> = {};
|
|
222
|
-
if (!isEmail(email)) fieldErrors.email = 'Enter a valid email address.';
|
|
223
|
-
if (password.length < 8) fieldErrors.password = 'Password must be at least 8 characters.';
|
|
224
|
-
if (!fieldErrors.email && (await db.query.users.findFirst({ where: { email } }))) {
|
|
225
|
-
fieldErrors.email = 'An account with this email already exists.';
|
|
226
|
-
}
|
|
227
|
-
if (Object.keys(fieldErrors).length) return { success: false, fieldErrors };
|
|
228
|
-
await db.insert(users).values({ email, passwordHash: await hashPassword(password) });
|
|
229
|
-
return signIn('credentials', { email, password }, { redirectTo: '/posts' }); // sets the cookie, 302
|
|
230
|
-
}
|
|
231
|
-
```
|
|
232
|
-
|
|
233
|
-
Sign-in is the same shape: look the user up, `verifyPassword`, return
|
|
234
|
-
`{ success: false, error: 'Invalid email or password.' }` on a mismatch, else
|
|
235
|
-
`return signIn('credentials', { email, password }, { redirectTo: '/posts' })`.
|
|
236
|
-
Sign-out is an action bound to a form in the layout:
|
|
237
|
-
|
|
238
|
-
```ts
|
|
239
|
-
// modules/auth/actions/sign-out.server.ts
|
|
240
|
-
'use server';
|
|
241
|
-
import { signOut } from '../auth.server.ts';
|
|
242
|
-
export async function signOutUser(_fd: FormData) {
|
|
243
|
-
return signOut({ redirectTo: '/signin' }); // clears the cookie, 302
|
|
244
|
-
}
|
|
245
|
-
```
|
|
246
|
-
|
|
247
|
-
```ts
|
|
248
|
-
// modules/posts/utils/validate-post.ts (pure: shared by create and update, unit-tested)
|
|
249
|
-
import { str } from '#lib/utils/form.ts';
|
|
250
|
-
import { POST_STATUSES, type PostStatus } from '../types.ts';
|
|
251
|
-
export interface PostInput { title: string; body: string; status: PostStatus; publishOn: string | null }
|
|
252
|
-
export function validatePost(fd: FormData) {
|
|
253
|
-
const values = { title: str(fd, 'title'), body: str(fd, 'body'), status: str(fd, 'status') || 'draft', publishOn: str(fd, 'publishOn') };
|
|
254
|
-
const fieldErrors: Record<string, string> = {};
|
|
255
|
-
if (!values.title) fieldErrors.title = 'Title is required.';
|
|
256
|
-
if (!(POST_STATUSES as readonly string[]).includes(values.status)) fieldErrors.status = 'Pick a status.';
|
|
257
|
-
if (values.publishOn && !/^\d{4}-\d{2}-\d{2}$/.test(values.publishOn)) fieldErrors.publishOn = 'Use a valid date.';
|
|
258
|
-
if (Object.keys(fieldErrors).length) return { ok: false as const, fieldErrors, values };
|
|
259
|
-
const data: PostInput = { ...values, status: values.status as PostStatus, publishOn: values.publishOn || null };
|
|
260
|
-
return { ok: true as const, data };
|
|
261
|
-
}
|
|
262
|
-
```
|
|
263
|
-
|
|
264
|
-
Reads use the relational API (`db.query.<table>.findMany/findFirst` with an
|
|
265
|
-
object `where` and `orderBy`) and always filter by the owner:
|
|
266
|
-
|
|
267
|
-
```ts
|
|
268
|
-
// modules/posts/queries/get-post.server.ts (list-posts.server.ts is the same with findMany + orderBy: { createdAt: 'desc' })
|
|
269
|
-
'use server';
|
|
270
|
-
import { db } from '#db/connection.server.ts';
|
|
271
|
-
import type { Post } from '#db/schema.server.ts';
|
|
272
|
-
import { getUser } from '#modules/auth/auth.server.ts';
|
|
273
|
-
import { toId } from '#lib/utils/form.ts';
|
|
274
|
-
|
|
275
|
-
/** The post when it exists AND belongs to the signed-in user, else null (the page throws notFound()). */
|
|
276
|
-
export async function getPost(id: string): Promise<Post | null> {
|
|
277
|
-
const user = await getUser();
|
|
278
|
-
const postId = toId(id);
|
|
279
|
-
if (!user || !postId) return null;
|
|
280
|
-
return (await db.query.posts.findFirst({ where: { id: postId, ownerId: user.id } })) ?? null;
|
|
281
|
-
}
|
|
282
|
-
```
|
|
283
|
-
|
|
284
|
-
```ts
|
|
285
|
-
// modules/posts/queries/count-posts.server.ts (one grouped query, never one per row)
|
|
286
|
-
'use server';
|
|
287
|
-
import { count, eq } from 'drizzle-orm';
|
|
288
|
-
import { db } from '#db/connection.server.ts';
|
|
289
|
-
import { posts } from '#db/schema.server.ts';
|
|
290
|
-
import { getUser } from '#modules/auth/auth.server.ts';
|
|
291
|
-
import type { StatusCounts } from '../types.ts';
|
|
292
|
-
|
|
293
|
-
export async function countPosts(): Promise<StatusCounts> {
|
|
294
|
-
const c: StatusCounts = { draft: 0, review: 0, published: 0, total: 0 };
|
|
295
|
-
const user = await getUser();
|
|
296
|
-
if (!user) return c;
|
|
297
|
-
const rows = await db.select({ status: posts.status, n: count() }).from(posts)
|
|
298
|
-
.where(eq(posts.ownerId, user.id)).groupBy(posts.status);
|
|
299
|
-
for (const r of rows) { c[r.status] = r.n; c.total += r.n; }
|
|
300
|
-
return c;
|
|
301
|
-
}
|
|
302
|
-
```
|
|
303
|
-
|
|
304
|
-
Writes use the query builder with `eq` / `and`, and put the owner in the
|
|
305
|
-
`where` so another user's id changes nothing. `create-post.server.ts` is
|
|
306
|
-
`validatePost`, then `db.insert(posts).values({ ...v.data, ownerId: user.id }).returning()`,
|
|
307
|
-
then `{ success: true, redirect: '/posts/' + post.id }`. `delete-post.server.ts`
|
|
308
|
-
reads the id from a hidden input and returns `{ success: true, redirect: '/posts' }`.
|
|
309
|
-
|
|
310
|
-
```ts
|
|
311
|
-
// modules/posts/actions/update-post.server.ts
|
|
312
|
-
'use server';
|
|
313
|
-
import { and, eq } from 'drizzle-orm';
|
|
314
|
-
import { db } from '#db/connection.server.ts';
|
|
315
|
-
import { posts } from '#db/schema.server.ts';
|
|
316
|
-
import { getUser } from '#modules/auth/auth.server.ts';
|
|
317
|
-
import { toId } from '#lib/utils/form.ts';
|
|
318
|
-
import { validatePost } from '../utils/validate-post.ts';
|
|
319
|
-
|
|
320
|
-
export async function updatePost(fd: FormData) {
|
|
321
|
-
const user = await getUser();
|
|
322
|
-
const id = toId(fd.get('id'));
|
|
323
|
-
if (!user || !id) return { success: false, error: 'Not found.', status: 404 };
|
|
324
|
-
const v = validatePost(fd);
|
|
325
|
-
if (!v.ok) return { success: false, fieldErrors: v.fieldErrors, values: v.values };
|
|
326
|
-
const rows = await db.update(posts).set(v.data).where(and(eq(posts.id, id), eq(posts.ownerId, user.id))).returning();
|
|
327
|
-
if (!rows.length) return { success: false, error: 'Not found.', status: 404 };
|
|
328
|
-
return { success: true, redirect: `/posts/${id}` };
|
|
329
|
-
}
|
|
330
|
-
```
|
|
331
|
-
|
|
332
|
-
An action a component calls over RPC takes a typed object, checks it, and
|
|
333
|
-
returns a result (it never throws or redirects):
|
|
334
|
-
|
|
335
|
-
```ts
|
|
336
|
-
// modules/posts/actions/set-post-status.server.ts
|
|
337
|
-
'use server';
|
|
338
|
-
import { and, eq } from 'drizzle-orm';
|
|
339
|
-
import { db } from '#db/connection.server.ts';
|
|
340
|
-
import { posts } from '#db/schema.server.ts';
|
|
341
|
-
import { getUser } from '#modules/auth/auth.server.ts';
|
|
342
|
-
import { POST_STATUSES, type PostStatus } from '../types.ts';
|
|
343
|
-
|
|
344
|
-
export interface SetStatusInput { id: number; status: PostStatus }
|
|
345
|
-
export async function setPostStatus(input: SetStatusInput) {
|
|
346
|
-
const user = await getUser();
|
|
347
|
-
if (!user) return { success: false, error: 'Sign in first.', status: 401 };
|
|
348
|
-
if (!POST_STATUSES.includes(input.status)) return { success: false, error: 'Bad status.', status: 400 };
|
|
349
|
-
const rows = await db.update(posts).set({ status: input.status })
|
|
350
|
-
.where(and(eq(posts.id, Number(input.id)), eq(posts.ownerId, user.id))).returning();
|
|
351
|
-
return rows.length ? { success: true } : { success: false, error: 'Not found.', status: 404 };
|
|
352
|
-
}
|
|
353
|
-
```
|
|
354
|
-
|
|
355
|
-
A component declares reactive properties in the `WebComponent({...})` factory
|
|
356
|
-
(attributes arrive kebab-cased: `postId` is `post-id`), keeps local state in
|
|
357
|
-
signals, and binds events with an unquoted `@event=${fn}`:
|
|
358
|
-
|
|
359
|
-
```ts
|
|
360
|
-
// modules/posts/components/post-status.ts
|
|
361
|
-
import { WebComponent, html, signal } from '@webjsdev/core';
|
|
362
|
-
import { setPostStatus } from '../actions/set-post-status.server.ts';
|
|
363
|
-
import { POST_STATUSES, type PostStatus } from '../types.ts';
|
|
364
|
-
import { labelClass } from '#components/ui/label.ts';
|
|
365
|
-
import { nativeSelectClass } from '#components/ui/native-select.ts';
|
|
366
|
-
|
|
367
|
-
/** Status select that saves on change over RPC, with no page reload. */
|
|
368
|
-
export class PostStatusSelect extends WebComponent({ postId: Number, status: String }) {
|
|
369
|
-
note = signal('');
|
|
370
|
-
async onChange(e: Event) {
|
|
371
|
-
const select = e.target as HTMLSelectElement;
|
|
372
|
-
const before = this.status;
|
|
373
|
-
this.status = select.value;
|
|
374
|
-
const res = await setPostStatus({ id: this.postId, status: select.value as PostStatus });
|
|
375
|
-
if (res.success) this.note.set('Saved');
|
|
376
|
-
else { this.status = before; select.value = before; this.note.set(res.error ?? 'Could not save'); }
|
|
377
|
-
}
|
|
378
|
-
render() {
|
|
379
|
-
const id = `status-${this.postId}`;
|
|
380
|
-
return html`
|
|
381
|
-
<div class="flex items-center gap-2">
|
|
382
|
-
<label for=${id} class=${labelClass()}>Status</label>
|
|
383
|
-
<select id=${id} class=${nativeSelectClass()} @change=${(e: Event) => this.onChange(e)}>
|
|
384
|
-
${POST_STATUSES.map((s) => html`<option value=${s} ?selected=${s === this.status}>${s}</option>`)}
|
|
385
|
-
</select>
|
|
386
|
-
<span class="text-xs text-muted-foreground" aria-live="polite">${this.note.get()}</span>
|
|
387
|
-
</div>`;
|
|
388
|
-
}
|
|
389
|
-
}
|
|
390
|
-
PostStatusSelect.register('post-status');
|
|
391
|
-
```
|
|
392
|
-
|
|
393
|
-
```ts
|
|
394
|
-
// app/layout.ts
|
|
395
|
-
import { html, asset } from '@webjsdev/core';
|
|
396
|
-
import type { LayoutProps } from '@webjsdev/core';
|
|
397
|
-
import { buttonClass } from '#components/ui/button.ts';
|
|
398
|
-
import { currentUser } from '#modules/auth/queries/current-user.server.ts';
|
|
399
|
-
import { signOutUser } from '#modules/auth/actions/sign-out.server.ts';
|
|
400
|
-
|
|
401
|
-
export const metadata = { title: { default: 'Posts', template: '%s | Posts' } };
|
|
402
|
-
export default async function RootLayout({ children }: LayoutProps) {
|
|
403
|
-
const user = await currentUser();
|
|
404
|
-
return html`
|
|
405
|
-
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
406
|
-
<link rel="stylesheet" href=${asset('/public/tailwind.css')}>
|
|
407
|
-
<script>if (matchMedia('(prefers-color-scheme: dark)').matches) document.documentElement.classList.add('dark');</script>
|
|
408
|
-
<style>
|
|
409
|
-
:root {
|
|
410
|
-
color-scheme: light dark;
|
|
411
|
-
--background: light-dark(#ffffff, #14161a); --foreground: light-dark(#17191c, #e6e8eb);
|
|
412
|
-
--card: light-dark(#f7f8fa, #1c1f24); --card-foreground: var(--foreground);
|
|
413
|
-
--primary: light-dark(#2f5bd3, #8fb0ff); --primary-foreground: light-dark(#ffffff, #0b1530);
|
|
414
|
-
--secondary: light-dark(#eef0f3, #2a2e34); --secondary-foreground: var(--foreground);
|
|
415
|
-
--muted: light-dark(#f1f3f5, #23272d); --muted-foreground: light-dark(#5b626b, #9aa1aa);
|
|
416
|
-
--accent: light-dark(#e9edf5, #2a3140); --accent-foreground: var(--foreground);
|
|
417
|
-
--border: light-dark(#e2e5e9, #343a42); --input: var(--border); --ring: light-dark(#8aa4e8, #5b78c4);
|
|
418
|
-
--destructive: light-dark(#c0362c, #f28b82);
|
|
419
|
-
}
|
|
420
|
-
body { margin: 0; background: var(--background); color: var(--foreground); font: 15px/1.6 system-ui, sans-serif; }
|
|
421
|
-
</style>
|
|
422
|
-
<header class="fixed inset-x-0 top-0 z-40 h-14 border-b border-border bg-background/95 backdrop-blur">
|
|
423
|
-
<nav class="mx-auto flex h-full max-w-4xl items-center gap-4 px-4">
|
|
424
|
-
<a href="/" class="font-semibold text-foreground no-underline">Posts</a>
|
|
425
|
-
${user ? html`
|
|
426
|
-
<span class="ml-auto hidden text-sm text-muted-foreground sm:inline">${user.email}</span>
|
|
427
|
-
<form action=${signOutUser} class="ml-auto sm:ml-0"><button class=${buttonClass({ variant: 'outline', size: 'sm' })}>Sign out</button></form>`
|
|
428
|
-
: html`<a href="/signin" class="ml-auto text-sm">Sign in</a>`}
|
|
429
|
-
</nav>
|
|
430
|
-
</header>
|
|
431
|
-
<main class="mx-auto min-h-dvh max-w-4xl px-4 pb-16 pt-20 text-foreground">${children}</main>`;
|
|
432
|
-
}
|
|
433
|
-
```
|
|
434
|
-
|
|
435
|
-
```ts
|
|
436
|
-
// app/page.ts
|
|
437
|
-
import { redirect } from '@webjsdev/core';
|
|
438
|
-
import { currentUser } from '#modules/auth/queries/current-user.server.ts';
|
|
439
|
-
export default async function Home() {
|
|
440
|
-
redirect((await currentUser()) ? '/posts' : '/signin');
|
|
441
|
-
}
|
|
442
|
-
```
|
|
443
|
-
|
|
444
|
-
A page with a form reads `actionData` (typed with `FormState`). The sign-in
|
|
445
|
-
and sign-up pages are this shape too, with `if (await currentUser()) redirect('/posts');`
|
|
446
|
-
first and `actionData.error` shown above the fields.
|
|
447
|
-
|
|
448
|
-
```ts
|
|
449
|
-
// app/posts/page.ts
|
|
450
|
-
import { html } from '@webjsdev/core';
|
|
451
|
-
import type { PageProps } from '@webjsdev/core';
|
|
452
|
-
import { buttonClass } from '#components/ui/button.ts';
|
|
453
|
-
import { cardClass } from '#components/ui/card.ts';
|
|
454
|
-
import { field, type FormState } from '#lib/utils/form.ts';
|
|
455
|
-
import { requireUser } from '#modules/auth/queries/require-user.server.ts';
|
|
456
|
-
import { listPosts } from '#modules/posts/queries/list-posts.server.ts';
|
|
457
|
-
import { countPosts } from '#modules/posts/queries/count-posts.server.ts';
|
|
458
|
-
import { createPost } from '#modules/posts/actions/create-post.server.ts';
|
|
459
|
-
|
|
460
|
-
export const metadata = { title: 'Your posts' };
|
|
461
|
-
export default async function PostsPage({ actionData }: PageProps<'/posts'> & { actionData?: FormState }) {
|
|
462
|
-
await requireUser();
|
|
463
|
-
const [items, counts] = await Promise.all([listPosts(), countPosts()]);
|
|
464
|
-
const e = actionData?.fieldErrors ?? {};
|
|
465
|
-
const v = actionData?.values ?? {};
|
|
466
|
-
return html`
|
|
467
|
-
<h1 class="text-2xl font-semibold">Your posts</h1>
|
|
468
|
-
<p class="mt-1 text-sm text-muted-foreground">${counts.total} total, ${counts.published} published</p>
|
|
469
|
-
<form action=${createPost} class="${cardClass()} mt-6 grid gap-3 p-4 sm:grid-cols-[1fr_auto] sm:items-end">
|
|
470
|
-
${field({ label: 'Title', name: 'title', value: v.title, error: e.title, required: true })}
|
|
471
|
-
<button class=${buttonClass()}>Create post</button>
|
|
472
|
-
</form>
|
|
473
|
-
<ul class="mt-6 grid gap-3 sm:grid-cols-2">
|
|
474
|
-
${items.map((p) => html`
|
|
475
|
-
<li class="${cardClass()} p-4">
|
|
476
|
-
<a href="/posts/${p.id}" class="font-medium text-foreground">${p.title}</a>
|
|
477
|
-
<p class="mt-1 text-sm text-muted-foreground">${p.status}</p>
|
|
478
|
-
</li>`)}
|
|
479
|
-
</ul>
|
|
480
|
-
${items.length ? '' : html`<p class="mt-6 text-muted-foreground">No posts yet.</p>`}`;
|
|
481
|
-
}
|
|
482
|
-
```
|
|
483
|
-
|
|
484
|
-
```ts
|
|
485
|
-
// app/posts/[id]/page.ts
|
|
486
|
-
import { html, notFound } from '@webjsdev/core';
|
|
487
|
-
import type { PageProps } from '@webjsdev/core';
|
|
488
|
-
import { buttonClass } from '#components/ui/button.ts';
|
|
489
|
-
import { requireUser } from '#modules/auth/queries/require-user.server.ts';
|
|
490
|
-
import { getPost } from '#modules/posts/queries/get-post.server.ts';
|
|
491
|
-
import { deletePost } from '#modules/posts/actions/delete-post.server.ts';
|
|
492
|
-
import '#modules/posts/components/post-status.ts'; // registers <post-status>
|
|
493
|
-
|
|
494
|
-
export default async function PostPage({ params }: PageProps<'/posts/[id]'>) {
|
|
495
|
-
await requireUser();
|
|
496
|
-
const post = await getPost(params.id);
|
|
497
|
-
if (!post) notFound();
|
|
498
|
-
return html`
|
|
499
|
-
<h1 class="text-2xl font-semibold">${post.title}</h1>
|
|
500
|
-
${post.body ? html`<p class="mt-3 whitespace-pre-line">${post.body}</p>` : ''}
|
|
501
|
-
<div class="mt-6 flex flex-wrap items-center gap-3">
|
|
502
|
-
<post-status post-id=${post.id} status=${post.status}></post-status>
|
|
503
|
-
<a href="/posts/${post.id}/edit" class=${buttonClass({ variant: 'outline', size: 'sm' })}>Edit</a>
|
|
504
|
-
<form action=${deletePost} onsubmit="return confirm('Delete this post?')">
|
|
505
|
-
<input type="hidden" name="id" value=${post.id}>
|
|
506
|
-
<button class=${buttonClass({ variant: 'destructive', size: 'sm' })}>Delete</button>
|
|
507
|
-
</form>
|
|
508
|
-
</div>`;
|
|
509
|
-
}
|
|
510
|
-
```
|
|
511
|
-
|
|
512
|
-
The edit page loads the row the same way, pre-fills from it
|
|
513
|
-
(`const v = actionData?.values ?? { title: post.title, ... }`), and posts a
|
|
514
|
-
hidden `id` to `updatePost`. A `<textarea class=${textareaClass()}>` holds its
|
|
515
|
-
value as text content; a `<select class=${nativeSelectClass()}>` marks the
|
|
516
|
-
current option with `?selected=${s === v.status}`. Both need a `<label for>`.
|
|
517
|
-
|
|
518
|
-
```ts
|
|
519
|
-
// app/not-found.ts
|
|
520
|
-
import { html } from '@webjsdev/core';
|
|
521
|
-
export default function NotFound() {
|
|
522
|
-
return html`<h1 class="text-2xl font-semibold">Not found</h1><p class="mt-2 text-muted-foreground"><a href="/">Go home</a></p>`;
|
|
523
|
-
}
|
|
524
|
-
```
|
|
525
|
-
|
|
526
|
-
```ts
|
|
527
|
-
// test/posts/validate-post.test.ts
|
|
528
|
-
import { test } from 'node:test';
|
|
529
|
-
import assert from 'node:assert/strict';
|
|
530
|
-
import { validatePost } from '#modules/posts/utils/validate-post.ts';
|
|
531
|
-
const fd = (o: Record<string, string>) => { const f = new FormData(); for (const [k, v] of Object.entries(o)) f.set(k, v); return f; };
|
|
532
|
-
test('a post needs a title', () => {
|
|
533
|
-
const r = validatePost(fd({ title: ' ' }));
|
|
534
|
-
assert.equal(r.ok ? '' : r.fieldErrors.title, 'Title is required.');
|
|
535
|
-
});
|
|
536
|
-
```
|
|
537
|
-
|
|
538
|
-
### Look and the UI kit
|
|
539
|
-
|
|
540
|
-
- The palette is the token block in the layout's `<style>`, each colour
|
|
541
|
-
written once as `light-dark(LIGHT, DARK)`; `public/input.css` maps the tokens
|
|
542
|
-
into Tailwind. Pick values that fit the product, and style only with token
|
|
543
|
-
utilities:
|
|
544
|
-
`bg-background text-foreground bg-card text-card-foreground bg-primary
|
|
545
|
-
text-primary-foreground bg-muted text-muted-foreground border-border
|
|
546
|
-
text-destructive ring-ring`. Never a raw colour such as `bg-blue-600`.
|
|
547
|
-
- Pin the header with `position: fixed` (never `sticky`) and offset the
|
|
548
|
-
content by its height, as the layout above does. Mobile first: one column
|
|
549
|
-
that widens at `sm:` / `md:`.
|
|
550
|
-
- The kit copies class helpers into `components/ui/` (you own them; no need to
|
|
551
|
-
open them): `buttonClass({ variant?: 'default' | 'destructive' | 'outline' |
|
|
552
|
-
'secondary' | 'ghost' | 'link', size?: 'default' | 'xs' | 'sm' | 'lg' |
|
|
553
|
-
'icon' })`, `inputClass()`, `textareaClass()`, `labelClass()`,
|
|
554
|
-
`nativeSelectClass()`, `cardClass({ size?: 'default' | 'sm' })`,
|
|
555
|
-
`badgeClass({ variant?: 'default' | 'secondary' | 'destructive' | 'outline' })`.
|
|
556
|
-
Use them as `class=${buttonClass({ variant: 'outline' })}` on native
|
|
557
|
-
elements. Stateful widgets (dialog, tabs, dropdown menu, tooltip, toasts) are
|
|
558
|
-
custom elements: `npx webjsdev ui add dialog`, then `npx webjsdev ui view dialog`
|
|
559
|
-
for the tags.
|
|
1
|
+
## Build a full-stack app (default template)
|
|
2
|
+
|
|
3
|
+
This scaffold ships a browsable feature gallery to learn from: single-concept
|
|
4
|
+
demos under `app/features/`, the `app/examples/todo` app, and an example design
|
|
5
|
+
system under `components/ui/`, with logic in `modules/`. Build in this order.
|
|
6
|
+
|
|
7
|
+
### 1. Study the gallery, then clear it
|
|
8
|
+
|
|
9
|
+
Read the demos under `app/features/` (and `app/examples/todo`) that match what
|
|
10
|
+
you are building, so you copy the real idiom: server actions, queries,
|
|
11
|
+
optimistic UI, component hydration, design tokens. Then run
|
|
12
|
+
`npm run gallery:clear` to shed the whole gallery and reset `app/page.ts` and
|
|
13
|
+
`app/layout.ts` to a blank slate. The clear also removes the example
|
|
14
|
+
`components/ui/` primitives, the demo `todos` table, and the demo migrations;
|
|
15
|
+
it keeps the agent skill, the database wiring, and `lib/utils/cn.ts` (needed by
|
|
16
|
+
`npx webjsdev ui add`). The skill teaches the same patterns, so the gallery is
|
|
17
|
+
a runnable copy you study first, not something you lose.
|
|
18
|
+
|
|
19
|
+
### 2. Model the data
|
|
20
|
+
|
|
21
|
+
Define real models in `db/schema.server.ts`, then run `npm run db:generate` and
|
|
22
|
+
`npm run db:migrate` (required after the clear, which removed the demo table and
|
|
23
|
+
migrations). Write a seed script at `db/seed.server.ts` and run
|
|
24
|
+
`npm run db:seed` so list and detail pages render real rows while you build,
|
|
25
|
+
instead of empty states. Put reads in `modules/<feature>/queries/*.server.ts`
|
|
26
|
+
and writes in `modules/<feature>/actions/*.server.ts`, one function per file.
|
|
27
|
+
|
|
28
|
+
### 3. Build a token-based design system
|
|
29
|
+
|
|
30
|
+
Full reference: `.agents/skills/webjs/references/styling.md`.
|
|
31
|
+
|
|
32
|
+
- Define your color tokens as CSS custom properties in `app/layout.ts`, each
|
|
33
|
+
written ONCE with the native CSS `light-dark(LIGHT, DARK)` function, so light
|
|
34
|
+
and dark modes come from one declaration.
|
|
35
|
+
- Define at least: `--background`, `--foreground`, `--card`, `--primary`,
|
|
36
|
+
`--secondary`, `--muted`, `--muted-foreground`, `--accent`, `--border`,
|
|
37
|
+
`--ring`, `--destructive`. Add the matching `*-foreground` pair for each
|
|
38
|
+
surface token you use, following the styling guide's reference palette.
|
|
39
|
+
- Consume colors ONLY as token utilities: `bg-background`, `text-foreground`,
|
|
40
|
+
`bg-card`, `border-border`, `text-primary`, `text-muted-foreground`,
|
|
41
|
+
`bg-destructive`.
|
|
42
|
+
- NEVER put a raw un-themed Tailwind color (`red-500`, `blue-600`, `gray-100`)
|
|
43
|
+
on an element or a `@webjsdev/ui` helper.
|
|
44
|
+
- Add an inline theme-detection script in the layout `<head>` so the first
|
|
45
|
+
paint matches the saved theme with no flash.
|
|
46
|
+
|
|
47
|
+
### 4. Use the UI kit, do not hand-roll primitives
|
|
48
|
+
|
|
49
|
+
Pull primitives with `npx webjsdev ui add <name>`; the source is copied into
|
|
50
|
+
`components/ui/`, so you own it fully and can add, remove, restructure, or theme
|
|
51
|
+
it however your app needs. Do NOT guess a helper or tag signature. Inspect the
|
|
52
|
+
copied file `components/ui/<name>.ts`, or run
|
|
53
|
+
`npx webjsdev ui view <name>`, for the exact exported names, variants, and
|
|
54
|
+
sizes. The kit has two tiers:
|
|
55
|
+
|
|
56
|
+
- **Tier 1, class helpers** for static primitives (button, card, input, badge,
|
|
57
|
+
native-select, textarea). Spread the helper onto a native element, for example
|
|
58
|
+
`class=${buttonClass({ variant: 'outline', size: 'sm' })}`.
|
|
59
|
+
- **Tier 2, custom elements** for stateful controls and overlays (`<ui-tabs>`,
|
|
60
|
+
`<ui-dialog>`, `<ui-dropdown-menu>`, `<ui-tooltip>`, sonner toasts). Use the
|
|
61
|
+
registered tag; it owns its ARIA, focus trap, and keyboard navigation out of
|
|
62
|
+
the box. Never hand-author a tab strip or a modal when a Tier-2 element
|
|
63
|
+
covers it.
|
|
64
|
+
|
|
65
|
+
Full reference: `.agents/skills/webjs/references/ui-kit.md`.
|
|
66
|
+
|
|
67
|
+
### 5. Build a multi-page app (MPA), not a single page
|
|
68
|
+
|
|
69
|
+
Structure the product as real routes, not one page that swaps client state:
|
|
70
|
+
|
|
71
|
+
- `/` a home or overview page.
|
|
72
|
+
- `/<resource>` a list page with search, filters, sorting, and a create form or
|
|
73
|
+
modal.
|
|
74
|
+
- `/<resource>/[id]` a detail page for one item.
|
|
75
|
+
- a couple of additional feature pages as the product needs.
|
|
76
|
+
|
|
77
|
+
Give `app/layout.ts` a navbar that links the main pages, pinned with
|
|
78
|
+
`position: fixed` (never `position: sticky`, which flickers on iOS during a
|
|
79
|
+
client-router navigation), and reserve its height on the content with a
|
|
80
|
+
`--header-height` offset. In a list or table, clicking a row or card navigates
|
|
81
|
+
to that item's detail page. Wrap each row action button (edit, delete, status)
|
|
82
|
+
so its handler calls `event.stopPropagation()`, letting the button run its own
|
|
83
|
+
action without also triggering the row navigation.
|
|
84
|
+
|
|
85
|
+
### 6. Build components for interactivity
|
|
86
|
+
|
|
87
|
+
Pages and layouts (`app/**/page.ts`, `app/**/layout.ts`) are server-only HTML
|
|
88
|
+
generators, so put every interactive behavior inside a `WebComponent` custom
|
|
89
|
+
element. Declare a component's reactive properties in the base-class factory,
|
|
90
|
+
never as a class-field initializer (`items = []` clobbers the reactive
|
|
91
|
+
accessor). Use the shorthand for primitives
|
|
92
|
+
(`extends WebComponent({ name: String, count: Number, open: Boolean })`) and the
|
|
93
|
+
`prop<T>()` helper for typed objects and arrays
|
|
94
|
+
(`extends WebComponent({ items: prop<Item[]>(Array), user: prop<User>(Object) })`).
|
|
95
|
+
|
|
96
|
+
### 7. Verify before you call it done
|
|
97
|
+
|
|
98
|
+
Run `npm run ci` and fix what it reports. It is one command for every gate,
|
|
99
|
+
the step list declared in `package.json` under `webjs.ci`, with a result line
|
|
100
|
+
per step:
|
|
101
|
+
|
|
102
|
+
- `webjs check` (correctness: no browser-import or boundary violation).
|
|
103
|
+
- `webjs doctor` (project health). It fails on whatever `package.json`
|
|
104
|
+
`webjs.doctor.gate` marks `error`, plus the two hard toolchain checks that
|
|
105
|
+
are fatal with no gate entry, `NODE_VERSION` and `TSCONFIG_ERASABLE`.
|
|
106
|
+
- `webjs typecheck` (zero type errors).
|
|
107
|
+
- A dependency audit.
|
|
108
|
+
- The server, browser, and e2e test layers for the features you built.
|
|
109
|
+
|
|
110
|
+
The GitHub workflow runs the same list, so a green local run predicts CI.
|
|
111
|
+
While iterating, `npm run ci -- --only Tests` runs one layer. Then
|
|
112
|
+
`npm run css:build` (compile Tailwind).
|
|
113
|
+
|
|
114
|
+
Then boot `npm run dev`, confirm every page route returns HTTP 200, and open
|
|
115
|
+
every route you changed in a real browser and play through its states: `check`
|
|
116
|
+
and `typecheck` pass even when a layout collapses, so the browser is the real
|
|
117
|
+
check for UI work.
|
|
560
118
|
|
|
561
119
|
### Commands
|
|
562
120
|
|
|
563
121
|
```sh
|
|
564
|
-
npm
|
|
565
|
-
npm run
|
|
566
|
-
npm run
|
|
567
|
-
npm run
|
|
568
|
-
npm
|
|
569
|
-
npm run
|
|
570
|
-
|
|
122
|
+
npm install
|
|
123
|
+
npm run gallery:clear # shed the demo gallery before building a real app
|
|
124
|
+
npm run dev # dev server at http://localhost:8080
|
|
125
|
+
npm run start # production server
|
|
126
|
+
npm test # unit + browser tests
|
|
127
|
+
npm run typecheck
|
|
128
|
+
npm run css:build # compile Tailwind
|
|
129
|
+
npm run ci # every gate, one command (the webjs.ci steps in package.json)
|
|
130
|
+
npm run check # correctness checks
|
|
131
|
+
npm run doctor # project health (severity per check: webjs.doctor.gate)
|
|
132
|
+
npx webjsdev ui add <name> # copy a ui primitive into components/ui/
|
|
133
|
+
npx webjsdev ui view <name> # inspect a primitive's exact signature
|
|
134
|
+
npm run db:generate && npm run db:migrate
|
|
571
135
|
```
|
|
@@ -147,8 +147,8 @@ for (const f of ['db/dev.db', 'db/dev.db-shm', 'db/dev.db-wal']) rm(f);
|
|
|
147
147
|
// children before test/ so it reads as empty.
|
|
148
148
|
for (const d of ['app/api', 'test/unit', 'test/e2e', 'test']) if (pruneEmpty(d)) removed++;
|
|
149
149
|
|
|
150
|
-
console.log(`Gallery cleared (${removed} paths removed). The agent
|
|
151
|
-
console.log('Next:
|
|
150
|
+
console.log(`Gallery cleared (${removed} paths removed). The agent skill and your database wiring are kept. Build your own design system: run \`npx webjsdev ui add <name>\` and theme it (see .agents/skills/webjs/references/styling.md).`);
|
|
151
|
+
console.log('Next: regenerate the database (db:generate then db:migrate), then start the dev server and build your app in app/ and modules/.');
|
|
152
152
|
|
|
153
153
|
function MINIMAL_PAGE() {
|
|
154
154
|
return `import { html } from '@webjsdev/core';
|
|
@@ -163,7 +163,7 @@ export default function Home() {
|
|
|
163
163
|
<h1 class="text-4xl font-bold tracking-tight m-0">Your app</h1>
|
|
164
164
|
<p class="text-base leading-relaxed m-0 opacity-70">
|
|
165
165
|
The gallery is cleared. This is <code class="text-[0.9em]">app/page.ts</code>. Build your
|
|
166
|
-
app from here. The guide is <code class="text-[0.9em]"
|
|
166
|
+
app from here. The guide is <code class="text-[0.9em]">.agents/skills/webjs/SKILL.md</code>.
|
|
167
167
|
</p>
|
|
168
168
|
<nav class="flex items-center gap-5 text-sm opacity-70">
|
|
169
169
|
<a href="https://webjs.dev/docs" target="_blank" rel="noopener" class="hover:opacity-100 transition-opacity no-underline">Docs</a>
|