@webjsdev/cli 0.10.68 → 0.10.69
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/bin/webjs.js +1 -1
- package/lib/create.js +16 -11
- package/lib/dev-supervisor.js +16 -1
- package/lib/resolve-bin.js +26 -0
- package/package.json +2 -2
- package/templates/.agents/rules/workflow.md +14 -17
- package/templates/.agents/skills/webjs/SKILL.md +2 -1
- package/templates/.agents/skills/webjs/references/runtime.md +2 -0
- package/templates/.claude/hooks/nudge-uncommitted.sh +7 -0
- package/templates/AGENTS.md +47 -77
- package/templates/CLAUDE.md +14 -16
- package/templates/CONVENTIONS.md +10 -11
- package/templates/partials/agents-playbook-fullstack.md +566 -130
- package/templates/scripts/clear-gallery.mjs +3 -3
package/bin/webjs.js
CHANGED
|
@@ -591,7 +591,7 @@ async function main() {
|
|
|
591
591
|
superviseDevServer({
|
|
592
592
|
cwd: process.cwd(),
|
|
593
593
|
plan,
|
|
594
|
-
env: { ...process.env, __WEBJS_DEV_CHILD: '1' },
|
|
594
|
+
env: { ...process.env, ...plan.env, __WEBJS_DEV_CHILD: '1' },
|
|
595
595
|
onExit: (code) => { killTasks(); process.exit(code); },
|
|
596
596
|
});
|
|
597
597
|
break;
|
package/lib/create.js
CHANGED
|
@@ -383,10 +383,15 @@ export async function scaffoldApp(name, cwd, opts = {}) {
|
|
|
383
383
|
// would exec WebJs under Node, silently running the "bun" app on Node).
|
|
384
384
|
// Baking it into the script body means a plain `bun run dev` (or even
|
|
385
385
|
// `npm run dev`) starts on Bun, so a user never has to remember the flag.
|
|
386
|
-
// The
|
|
387
|
-
//
|
|
388
|
-
//
|
|
389
|
-
//
|
|
386
|
+
// The db scripts force it too (#1598): `webjs db` runs drizzle-kit and the
|
|
387
|
+
// seed with the CLI's own runtime, and through the node shebang that was
|
|
388
|
+
// Node, at 2.5 to 4 times the CPU of the same command on Bun (generate
|
|
389
|
+
// about 3 CPU-s against 1, migrate 1.2 against 0.5, seed 1 against 0.2,
|
|
390
|
+
// measured on the Postgres scaffold); on Bun the seed also sees `.env`.
|
|
391
|
+
// It is the path a Node-less oven/bun image already takes (#570). The
|
|
392
|
+
// other tooling scripts (test / check / typecheck / doctor / ci) stay
|
|
393
|
+
// plain `webjs ...`: `webjs test` shells `node --test`, which a
|
|
394
|
+
// `bun --test` would not be, and `check` costs the same on both.
|
|
390
395
|
// Compile Tailwind from public/input.css to a STATIC public/tailwind.css
|
|
391
396
|
// that app/layout.ts links, so the app is fully styled with JavaScript
|
|
392
397
|
// DISABLED (a real stylesheet, not an in-browser compile). Runs inside the
|
|
@@ -419,11 +424,11 @@ export async function scaffoldApp(name, cwd, opts = {}) {
|
|
|
419
424
|
// from the step list in the `webjs.ci` block below. Runtime-neutral like
|
|
420
425
|
// the other tooling scripts (it spawns `webjs ...` children).
|
|
421
426
|
ci: 'webjs ci',
|
|
422
|
-
'db:generate': 'webjs db generate',
|
|
423
|
-
'db:migrate': 'webjs db migrate',
|
|
424
|
-
'db:push': 'webjs db push',
|
|
425
|
-
'db:studio': 'webjs db studio',
|
|
426
|
-
'db:seed': 'webjs db seed',
|
|
427
|
+
'db:generate': isBun ? 'bun --bun webjs db generate' : 'webjs db generate',
|
|
428
|
+
'db:migrate': isBun ? 'bun --bun webjs db migrate' : 'webjs db migrate',
|
|
429
|
+
'db:push': isBun ? 'bun --bun webjs db push' : 'webjs db push',
|
|
430
|
+
'db:studio': isBun ? 'bun --bun webjs db studio' : 'webjs db studio',
|
|
431
|
+
'db:seed': isBun ? 'bun --bun webjs db seed' : 'webjs db seed',
|
|
427
432
|
},
|
|
428
433
|
dependencies: {
|
|
429
434
|
// Drizzle ORM (no codegen, no engine binary). Pinned to the 1.0 line
|
|
@@ -1716,8 +1721,8 @@ ThemeToggle.register('theme-toggle');
|
|
|
1716
1721
|
`);
|
|
1717
1722
|
}
|
|
1718
1723
|
console.log(`For AI agents, read this before editing:
|
|
1719
|
-
• Read AGENTS.md
|
|
1720
|
-
|
|
1724
|
+
• Read AGENTS.md first: it carries the build steps and a worked example of
|
|
1725
|
+
every common pattern. .agents/skills/webjs/ is the deeper reference.
|
|
1721
1726
|
• This scaffold is a minimal starting point, not a demo to prune. Grow the app
|
|
1722
1727
|
in place: add routes under app/, components under components/, and features
|
|
1723
1728
|
under modules/<feature>/, and keep server-only code behind .server.ts.
|
package/lib/dev-supervisor.js
CHANGED
|
@@ -78,7 +78,7 @@ export function isBootFile(path) {
|
|
|
78
78
|
* @param {string[]} opts.argv `process.argv.slice(1)` (the script path followed by its args), forwarded to the child verbatim.
|
|
79
79
|
* @param {boolean} opts.noHot Whether `--no-hot` was passed (opt out of the supervisor entirely).
|
|
80
80
|
* @param {boolean} [opts.sourceLocations] Whether dev source locations are on (`WEBJS_SOURCE_LOCATIONS` / `webjs.dev.sourceLocations`), which puts every app module behind a Bun plugin.
|
|
81
|
-
* @returns {{ mode: 'inline' } | { mode: 'supervise', args: string[], restartOnChange: boolean, restartFor?: (path: string) => boolean, inPlaceRestartFor?: (path: string) => boolean, watchDirs: string[], watchFiles: string[] }}
|
|
81
|
+
* @returns {{ mode: 'inline' } | { mode: 'supervise', args: string[], env?: Record<string, string>, restartOnChange: boolean, restartFor?: (path: string) => boolean, inPlaceRestartFor?: (path: string) => boolean, watchDirs: string[], watchFiles: string[] }}
|
|
82
82
|
* `inline` runs the server in this process (no reload watcher); `supervise`
|
|
83
83
|
* spawns `process.execPath` with `args` and `__WEBJS_DEV_CHILD=1` under the
|
|
84
84
|
* supervisor, which watches `watchDirs` (recursively) and `watchFiles` (at
|
|
@@ -101,6 +101,7 @@ export function planDevSupervisor({ isBun, argv, noHot, sourceLocations = false
|
|
|
101
101
|
return {
|
|
102
102
|
mode: 'supervise',
|
|
103
103
|
args: ['--hot', ...argv],
|
|
104
|
+
env: BUN_CHILD_ENV,
|
|
104
105
|
restartOnChange: true,
|
|
105
106
|
restartFor: (p) => plugin(p) || isBootFile(p),
|
|
106
107
|
inPlaceRestartFor: isBootFile,
|
|
@@ -110,6 +111,20 @@ export function planDevSupervisor({ isBun, argv, noHot, sourceLocations = false
|
|
|
110
111
|
return { mode: 'supervise', args: [...argv], restartOnChange: true, ...watch };
|
|
111
112
|
}
|
|
112
113
|
|
|
114
|
+
/**
|
|
115
|
+
* Extra env for the Bun dev child: turn off Bun's runtime transpiler cache.
|
|
116
|
+
*
|
|
117
|
+
* Bun caches the transpile of every source over 50KB on disk, keyed by the
|
|
118
|
+
* file's CONTENT, with the import paths a `Bun.plugin` `onResolve` returned
|
|
119
|
+
* baked in. The dev alias resolver (#1575) returns absolute paths, so the
|
|
120
|
+
* cached output of a large app module pins its `#` imports to the checkout
|
|
121
|
+
* that first ran `webjs dev`. Any other copy of the same file (a git worktree,
|
|
122
|
+
* a moved or copied app, a later `webjs start`) then imports from that old
|
|
123
|
+
* directory: a 500 when it is gone, the other copy's code when it is not. The
|
|
124
|
+
* variable is read at process start, so it has to be set on the child.
|
|
125
|
+
*/
|
|
126
|
+
const BUN_CHILD_ENV = Object.freeze({ BUN_RUNTIME_TRANSPILER_CACHE_PATH: '0' });
|
|
127
|
+
|
|
113
128
|
const SERVER_MODULE = /\.server\.m?[jt]s$/;
|
|
114
129
|
const APP_MODULE = /\.m?[jt]s$/;
|
|
115
130
|
|
package/lib/resolve-bin.js
CHANGED
|
@@ -13,6 +13,14 @@
|
|
|
13
13
|
* 'drizzle-kit/bin.cjs')` throws `ERR_PACKAGE_PATH_NOT_EXPORTED`. The `.` main
|
|
14
14
|
* entry DOES resolve, so resolve that, walk up to the package root (the nearest
|
|
15
15
|
* dir with a package.json), and read the `bin` field, which is version-robust.
|
|
16
|
+
*
|
|
17
|
+
* Installed means reachable through a `node_modules/<pkg>` entry in `cwd` or an
|
|
18
|
+
* ancestor, the lookup Node's resolver does. That is checked BEFORE resolving
|
|
19
|
+
* because Bun auto-installs: when no `node_modules` sits above `cwd`, Bun's
|
|
20
|
+
* `require.resolve` fetches an undeclared package into its global cache and
|
|
21
|
+
* returns that path. Without the check, an app that never installed
|
|
22
|
+
* @web/test-runner would launch whatever version Bun downloaded instead of
|
|
23
|
+
* getting the "not installed" remedy.
|
|
16
24
|
*/
|
|
17
25
|
import { createRequire } from 'node:module';
|
|
18
26
|
import { readFileSync, existsSync } from 'node:fs';
|
|
@@ -27,6 +35,9 @@ import { join, dirname, resolve } from 'node:path';
|
|
|
27
35
|
* @throws if the package is not installed or has no matching bin
|
|
28
36
|
*/
|
|
29
37
|
export function resolveBin(cwd, pkgName, binName) {
|
|
38
|
+
if (!hasNodeModulesEntry(cwd, pkgName)) {
|
|
39
|
+
throw new Error(`Cannot find package '${pkgName}' in a node_modules above ${cwd}`);
|
|
40
|
+
}
|
|
30
41
|
const req = createRequire(join(cwd, 'package.json'));
|
|
31
42
|
// `.` (the main entry) is exported even when subpaths are not.
|
|
32
43
|
let pkgDir = dirname(req.resolve(pkgName));
|
|
@@ -40,3 +51,18 @@ export function resolveBin(cwd, pkgName, binName) {
|
|
|
40
51
|
if (!binRel) throw new Error(`bin '${binName}' not found in ${pkgName}`);
|
|
41
52
|
return resolve(pkgDir, binRel);
|
|
42
53
|
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* @param {string} cwd
|
|
57
|
+
* @param {string} pkgName
|
|
58
|
+
* @returns {boolean} whether `node_modules/<pkgName>` exists in cwd or an ancestor
|
|
59
|
+
*/
|
|
60
|
+
function hasNodeModulesEntry(cwd, pkgName) {
|
|
61
|
+
let dir = resolve(cwd);
|
|
62
|
+
for (;;) {
|
|
63
|
+
if (existsSync(join(dir, 'node_modules', pkgName))) return true;
|
|
64
|
+
const parent = dirname(dir);
|
|
65
|
+
if (parent === dir) return false;
|
|
66
|
+
dir = parent;
|
|
67
|
+
}
|
|
68
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@webjsdev/cli",
|
|
3
|
-
"version": "0.10.
|
|
3
|
+
"version": "0.10.69",
|
|
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.87",
|
|
22
22
|
"@webjsdev/ui": "^0.3.15"
|
|
23
23
|
},
|
|
24
24
|
"publishConfig": {
|
|
@@ -2,23 +2,21 @@
|
|
|
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
|
-
`.agents/skills/webjs/SKILL.md
|
|
7
|
-
|
|
5
|
+
components, actions, styling, the framework API), read `AGENTS.md`; the
|
|
6
|
+
deeper reference set is `.agents/skills/webjs/SKILL.md`. Full hosted docs are
|
|
7
|
+
at https://webjs.dev/docs.
|
|
8
8
|
|
|
9
9
|
## Grow the app in place (non-negotiable)
|
|
10
10
|
|
|
11
|
-
- **
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
`app/
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
`AGENTS.md` carries the full template-specific build playbook and the order to
|
|
21
|
-
follow.
|
|
11
|
+
- **Clear the showcase, then build.** The scaffold is a starting point with a
|
|
12
|
+
browsable demo showcase plus a database wired up. A full-stack app ships a UI
|
|
13
|
+
feature gallery (`app/features/`, `app/examples/todo`); the api template ships
|
|
14
|
+
a backend-features showcase (`app/api/features/`), with logic in `modules/`.
|
|
15
|
+
Building a real app: run `npm run gallery:clear` to shed the showcase (it
|
|
16
|
+
keeps the agent docs and the database wiring, and resets to a clean base),
|
|
17
|
+
then regenerate the database and grow the app in place under `app/`,
|
|
18
|
+
`components/`, and `modules/<feature>/`. `AGENTS.md` carries the
|
|
19
|
+
template-specific build steps and the order to follow.
|
|
22
20
|
- **Use the wired-up database (Drizzle), never JSON files.** For any data the app
|
|
23
21
|
stores, define a Drizzle table in `db/schema.server.ts`, then
|
|
24
22
|
`npm run db:generate` and `npm run db:migrate`. Never use a JSON file, a
|
|
@@ -26,9 +24,8 @@ Read `AGENTS.md` first. Full hosted docs are at https://webjs.dev/docs.
|
|
|
26
24
|
- **`app/` is routing-only.** Only routing files live in `app/` (page, layout,
|
|
27
25
|
route, middleware, metadata routes). Browser-safe helpers go in `lib/utils/`,
|
|
28
26
|
feature logic in `modules/`, server-only code behind `.server.ts`.
|
|
29
|
-
- **For a UI app, render and LOOK before calling it done.**
|
|
30
|
-
in `
|
|
31
|
-
(`.agents/skills/webjs/references/styling.md` is the guide), then open every
|
|
27
|
+
- **For a UI app, render and LOOK before calling it done.** Give the design
|
|
28
|
+
tokens in `public/input.css` a palette that fits the app, then open every
|
|
32
29
|
route you changed in a real browser and play through its states.
|
|
33
30
|
`npm run check` and `npm run typecheck` pass even when a layout collapses, so
|
|
34
31
|
the browser is the real check.
|
|
@@ -9,6 +9,8 @@ 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
|
+
|
|
12
14
|
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.
|
|
13
15
|
|
|
14
16
|
## What WebJs Is
|
|
@@ -44,7 +46,6 @@ Rows point rather than explain. The reference is the authority on the rule, and
|
|
|
44
46
|
| add a URL, static or with a dynamic segment | a file at `app/<path>/page.ts`, `[id]` for a param | registering the route in a table or config | `references/routing-and-pages.md` | `app/features/routing` |
|
|
45
47
|
| abandon a render because something is missing or not allowed | throw `notFound()` / `forbidden()` / `unauthorized()` | returning an error object and branching in the template | `references/routing-and-pages.md` | `app/features/boundaries` |
|
|
46
48
|
| set a page's title, description, or social preview | `export const metadata` or `generateMetadata()` | writing `<head>` tags in the page | `references/routing-and-pages.md` | `app/features/metadata` |
|
|
47
|
-
| give the app its own favicon, home-screen icon and manifest | replace the placeholder `app/icon.svg` with a simple symbol for the app in its colours, add `app/apple-icon.png`, edit `app/manifest.webmanifest` | leaving the scaffold placeholder, or a hand-written `<link rel="icon">` | `references/routing-and-pages.md` (App icon and manifest) | `app/icon.ts` |
|
|
48
49
|
| make part of the page respond to a click or hold state | a `WebComponent` custom element | expecting the page's own markup to hydrate | `references/components.md` | `app/features/components` |
|
|
49
50
|
| render a keyed list, or swap one node when state changes | `repeat()` / `watch()` from `/directives` | re-rendering the component or diffing by hand | `references/components.md` | `app/features/directives` |
|
|
50
51
|
| get server data into a component's first paint | `async render()` awaiting an action | fetching in `connectedCallback`, which SSR never calls | `references/components.md` | `app/features/async-render` |
|
|
@@ -44,6 +44,8 @@ Three seams pick a runtime-specific implementation, all inside the framework, no
|
|
|
44
44
|
|
|
45
45
|
Bun keeps ONE dev server process for the whole session (#1575), so it gets the refresh. `bun --hot` re-runs the CLI on a change; the first run's server owns the process (its listener, live-reload stream, watchers and analysis caches) and a re-run only tells it the module registry was reset, so nothing is started twice and memory stays flat over hundreds of edits (it used to grow about 25 MB an edit until `bun --hot` stopped reloading). The app's modules load through a `Bun.plugin` that reads them fresh, and its `#` imports resolve through the app's own `imports` map, because Bun keeps the old source of a file that was replaced (an atomic save) and a stale directory listing for a new file next to a `*.server.*` module. So `bun --hot` no longer sees app edits itself, and the dev server asks it for a registry reset after an edit to a module some other module imports, a new module, or a `*.server.*` module, all without a process restart. A page, layout or route handler nothing imports needs no reset: the dev re-import is keyed by the file's content, so an unchanged file reuses its loaded module (no new module instance per request) and an edited one is a new import. `instrumentation.*` and `env.*` run once per process, so an edit to one restarts the server on both runtimes. On Node the `webjs dev` supervisor (it replaced `node --watch` in #1521) RESTARTS the server process on a change under `app`, `components`, `modules`, `lib`, or `actions`, or to a root `middleware.{ts,js,mts,mjs}`, and a fresh process holds no record of what changed, so those edits are always a full reload. Two Node cases still refresh in place: an edit OUTSIDE that watched set (`db/schema.server.ts`, a `webjs.dev.watch` content dir), and running `npm run dev -- --no-hot`, which keeps the server in one process on either runtime. An in-place refresh loads the rebuilt stylesheets (a `webjs.dev.regenerate` compile runs on that request) BEFORE it swaps the new markup in, and drops the old sheets only after, so an element that gained a utility class never paints without its rule (#1535; a full reload never had the gap, since a head stylesheet is render-blocking). A component edit is a full reload everywhere by design, because `customElements.define` is once-per-tag and swapping fresh markup onto the old class would be worse than the reload.
|
|
46
46
|
|
|
47
|
+
After a reset, app code that imports `@webjsdev/server` gets a fresh copy of the package while the first run's server keeps handling requests. Everything the two copies must agree on (the request `auth()`, `cookies()` and `headers()` read, the action signal, the seed collector and action identity, the default cache store, sessions, broadcast clients) lives in process-wide state keyed by `Symbol.for`, so a signed-in page and the `'use server'` queries it calls still see the request after any number of edits (#1590). Before that fix, `auth()` without an explicit request returned `null` in app code after the first edit.
|
|
48
|
+
|
|
47
49
|
**`webjs dev` and `webjs start` serve the directory they are started in, and refuse anywhere else (#1526).** Run them in the app directory, the one holding `app/`. In a workspace (`apps/web` under a root `package.json` with `workspaces`) that is the member, even when the CLI is hoisted to the root `node_modules`: the hoisted bin and the Bun `--hot` child both keep the directory they were started in. Started where there is no `app/` (the workspace root, or a subdirectory such as `app/` itself), both exit 1 before any `before` step runs, naming the app to start (`cd apps/web && webjs dev`), instead of booting a server that answers 404 for every route.
|
|
48
50
|
|
|
49
51
|
**`webjs dev` does not stay down (#1521).** The supervisor and the server's own watcher handle every watcher error: a file in a watched dir the dev server cannot read or watch (the 0600 temp file `sed -i` creates when another user runs it, a file removed mid-scan) logs one `file watcher skipped <path> (EACCES)` warning and both keep running, where `node --watch` used to crash and leave the preview dead. Edits that REPLACE a file (`sed -i`, an editor saving through a temp file, an atomic write) are heard every time, however often the same file is replaced (#1529: on Linux under Node the watchers watch each directory, since Node 24's own recursive watcher stopped hearing a file after its first replacement). A server process that crashes is started again on the next file change, or by itself after a backoff of 0.5s growing to 10s for repeated crashes. On Bun the supervisor restarts the server only for an `instrumentation.*` / `env.*` edit (#1575), and otherwise does only the crash recovery. An agent writing files the way an AI editor does (bursts, partial writes, syntax errors then fixes, renames, deletes, atomic writes) is exercised by `scripts/dev-reload-stress.mjs` (`node scripts/dev-reload-stress.mjs <appDir> <url>` against any running app). Stopping `webjs dev` (Ctrl-C, SIGTERM) stops the server child too, and a child whose supervisor was killed outright exits on its own, so nothing is left holding the port.
|
|
@@ -30,6 +30,13 @@ 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
|
+
|
|
33
40
|
CHANGED=$(git status --porcelain 2>/dev/null | wc -l | tr -d ' ')
|
|
34
41
|
|
|
35
42
|
if [ -z "$CHANGED" ] || [ "$CHANGED" -lt "$THRESHOLD" ]; then
|
package/templates/AGENTS.md
CHANGED
|
@@ -1,82 +1,52 @@
|
|
|
1
1
|
# AGENTS.md for {{APP_NAME}}
|
|
2
2
|
|
|
3
|
-
This is a WebJs app:
|
|
4
|
-
|
|
5
|
-
|
|
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.
|
|
3
|
+
This is a WebJs app: server-rendered pages, web components for interactivity,
|
|
4
|
+
server actions, Drizzle, and no build step. WebJs is its own framework, not
|
|
5
|
+
React, Next.js or Lit, so write it from the patterns in this file rather than
|
|
6
|
+
from that memory. Read this whole file before you edit anything.
|
|
29
7
|
|
|
30
8
|
{{PLAYBOOK}}
|
|
31
9
|
|
|
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
|
-
|
|
68
|
-
|
|
69
|
-
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
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.
|
|
10
|
+
## Rules that hold everywhere
|
|
11
|
+
|
|
12
|
+
- **Type every boundary from its source.** A row is
|
|
13
|
+
`typeof posts.$inferSelect` (exported from `db/schema.server.ts`, carried
|
|
14
|
+
into browser code with `import type`), an action's input is a named
|
|
15
|
+
`interface`, a routing file uses `PageProps<'/posts/[id]'>` / `LayoutProps` /
|
|
16
|
+
`RouteHandlerContext` from `@webjsdev/core`, and a component prop that holds
|
|
17
|
+
an object is `prop<Post>(Object)`. Never reach for `any` or `as any`, and
|
|
18
|
+
keep `unknown` for an untrusted payload you narrow on the next line.
|
|
19
|
+
- **Server-only code stays behind `.server.ts`.** A file WITH `'use server'`
|
|
20
|
+
exposes its exported async functions as actions; a file WITHOUT it is a
|
|
21
|
+
server-only utility that only other server code may import. Never add
|
|
22
|
+
`'use server'` to a file only server code imports (the DB connection, the
|
|
23
|
+
schema).
|
|
24
|
+
- **Store data with Drizzle** in the wired-up database (`db/`), never in a
|
|
25
|
+
JSON file, an in-memory array or Map, or localStorage. Every schema change is
|
|
26
|
+
`npm run db:generate` then `npm run db:migrate`, and the generated
|
|
27
|
+
migrations are committed.
|
|
28
|
+
- **TypeScript is erasable:** no `enum`, no `namespace`, no constructor
|
|
29
|
+
parameter properties, no decorators. Never put a backtick inside an
|
|
30
|
+
`html` template body, even in a comment.
|
|
31
|
+
- **Errors tell you the fix.** `npm run check` and `npm run typecheck` name
|
|
32
|
+
the file, the rule and the fix; do what they say rather than searching.
|
|
33
|
+
|
|
34
|
+
## Git
|
|
35
|
+
|
|
36
|
+
Commit per logical unit (CLAUDE.md has the rule). When you build a whole app
|
|
37
|
+
from a spec in one session, the build is one unit: work on a feature branch
|
|
38
|
+
(commits to `main` are refused), and commit once at the end after the checks
|
|
39
|
+
pass (`git add -A && git commit -m "<imperative subject>"`). Team workflow
|
|
40
|
+
(pull requests, CI, worktrees) is in `.agents/rules/workflow.md`.
|
|
41
|
+
|
|
42
|
+
## When you need more
|
|
43
|
+
|
|
44
|
+
The reference set is `.agents/skills/webjs/` (`SKILL.md` routes to
|
|
45
|
+
`references/*.md`). Open one only for a surface this file does not show:
|
|
46
|
+
streaming and Suspense (`client-router-and-streaming.md`), optimistic UI
|
|
47
|
+
(`optimistic-ui.md`), caching, rate limits, file uploads, env vars
|
|
48
|
+
(`built-ins.md`), OAuth providers and sessions (`auth-and-sessions.md`),
|
|
49
|
+
browser and e2e tests (`testing.md`), deeper component topics such as slots,
|
|
50
|
+
shadow DOM, context and directives (`components.md`). The exact framework
|
|
51
|
+
source is under `node_modules/@webjsdev/*`, and the hosted docs are at
|
|
52
|
+
https://webjs.dev/docs.
|
package/templates/CLAUDE.md
CHANGED
|
@@ -3,21 +3,19 @@
|
|
|
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
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
history is the user's revert and cherry-pick safety net.
|
|
6
|
+
asks. **For this project that default does NOT apply.** Commit per logical
|
|
7
|
+
unit (one feature, one fix, one rename, one doc rewrite) as soon as it is
|
|
8
|
+
complete and its checks pass, WITHOUT being asked, then push when there is a
|
|
9
|
+
remote. Git history is the user's revert and cherry-pick safety net.
|
|
11
10
|
|
|
12
|
-
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
11
|
+
- Building a whole app from a spec in one session is ONE unit: build it,
|
|
12
|
+
verify it, then commit once. Later changes are one commit each.
|
|
13
|
+
- Never commit to `main`. Work on a feature branch (the pre-commit hook
|
|
14
|
+
refuses `main`).
|
|
15
|
+
- Imperative subject under 72 characters. No AI-attribution trailers
|
|
16
|
+
(`Co-Authored-By`, `Generated by`).
|
|
18
17
|
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
with a pile of uncommitted work still on a feature branch.
|
|
18
|
+
Two hooks back this up: `.claude/hooks/nudge-uncommitted.sh` reminds you while
|
|
19
|
+
uncommitted changes pile up on a branch that already has commits, and
|
|
20
|
+
`.claude/hooks/commit-before-stop.sh` stops you from ending a turn with
|
|
21
|
+
uncommitted work 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
|
-
|
|
6
|
-
is the short version.
|
|
3
|
+
The conventions for building a WebJs app live in **`AGENTS.md`**, which shows
|
|
4
|
+
every common pattern as a worked example. The deeper reference set is
|
|
5
|
+
`.agents/skills/webjs/` (`SKILL.md` routes to `references/*.md`), for the rarer
|
|
6
|
+
surfaces. This file is the short version.
|
|
7
7
|
|
|
8
8
|
## The essentials
|
|
9
9
|
|
|
@@ -17,13 +17,12 @@ 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 showcase
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
When you build a real app,
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
template-specific playbook.
|
|
20
|
+
- **The scaffold ships a demo showcase.** A full-stack app ships a UI feature
|
|
21
|
+
gallery (`app/features/`, `app/examples/todo`); the api template ships a
|
|
22
|
+
backend-features showcase (`app/api/features/`), with logic in `modules/`.
|
|
23
|
+
When you build a real app, run `npm run gallery:clear` first to shed the
|
|
24
|
+
showcase, then grow the app in place. `AGENTS.md` has the template-specific
|
|
25
|
+
build steps.
|
|
27
26
|
- **Derive types at every boundary.** Rows from `$inferSelect`, action inputs
|
|
28
27
|
from an `interface`, routing files from `PageProps` / `LayoutProps`. Never
|
|
29
28
|
`any`, and never `unknown` where a real type exists.
|
|
@@ -1,135 +1,571 @@
|
|
|
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
|
-
|
|
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
|
-
|
|
1
|
+
## Build an app (full-stack template)
|
|
2
|
+
|
|
3
|
+
### Build steps
|
|
4
|
+
|
|
5
|
+
Everything a typical app needs (pages, forms, validation, auth, owner-scoped
|
|
6
|
+
CRUD, a component, Drizzle) is in this file, so build straight from it instead
|
|
7
|
+
of exploring. Write in a few large steps (one shell heredoc or one write per
|
|
8
|
+
group of files), not one file per turn.
|
|
9
|
+
|
|
10
|
+
1. Branch, clear the demo gallery, and add the UI kit, in one command:
|
|
11
|
+
`git checkout -b feat/<name> && npm run gallery:clear && npx webjsdev ui add button input label textarea native-select card badge`.
|
|
12
|
+
The gallery (`app/features/`, `app/examples/`, the demo `modules/`) is only a
|
|
13
|
+
demo: never read it, everything it teaches is below.
|
|
14
|
+
2. Write `db/schema.server.ts` (replace the whole file: its `users` table is a
|
|
15
|
+
placeholder), then `npm run db:generate && npm run db:migrate`.
|
|
16
|
+
3. Write every `modules/` file (auth, queries, actions, components, utils).
|
|
17
|
+
4. Write `app/layout.ts` and `app/page.ts` (replace both whole; no need to
|
|
18
|
+
read them first), every other page, `app/not-found.ts`, and
|
|
19
|
+
`test/<feature>/*.test.ts`.
|
|
20
|
+
5. `npm run check && npm run typecheck && npm run test:server`, and fix what
|
|
21
|
+
they report.
|
|
22
|
+
6. Walk the app once in a real browser. `curl` cannot submit a bound form
|
|
23
|
+
(the form carries a hidden action field), so use Playwright, which is
|
|
24
|
+
installed (if Chromium is missing: `npx playwright install chromium`).
|
|
25
|
+
First write one script, `walk.mjs` in the app folder, that signs up and
|
|
26
|
+
drives each feature once with `page.getByLabel(...)` and
|
|
27
|
+
`page.getByRole('button', { name })`. With JavaScript on, a submit is
|
|
28
|
+
applied in place, so wait for its outcome (`await page.waitForURL(...)` or
|
|
29
|
+
`await page.getByText('...').waitFor()`), never a fixed timeout. Save a
|
|
30
|
+
phone-width and a desktop-width screenshot under `/tmp`. Then start
|
|
31
|
+
`PORT=<port> npm run dev > dev.log 2>&1 &` (`*.log` is gitignored), run
|
|
32
|
+
`node walk.mjs`, look at the screenshots, fix what the walk shows in the
|
|
33
|
+
app, stop the server you started, and delete `walk.mjs`.
|
|
34
|
+
7. Commit (see Git below).
|
|
35
|
+
|
|
36
|
+
### How WebJs works
|
|
37
|
+
|
|
38
|
+
- **Pages and layouts run only on the server.** They return `html` and never
|
|
39
|
+
hydrate: an `@click` in a page does nothing. Interactivity lives in a
|
|
40
|
+
`WebComponent` custom element; a page imports the component file to
|
|
41
|
+
register it and writes its tag.
|
|
42
|
+
- **`*.server.ts` is the server boundary.** With `'use server';` as the first
|
|
43
|
+
line, its exported async functions are server actions: a page calls them
|
|
44
|
+
directly on the server, a component calls them over RPC (the import becomes
|
|
45
|
+
a typed stub). WITHOUT `'use server'` the file is a server-only utility (the
|
|
46
|
+
DB, secrets, `node:*`, `createAuth`): import it only from other `.server.ts`
|
|
47
|
+
files, `route.ts` or `middleware.ts`, never from a page, layout or component
|
|
48
|
+
(it crashes the browser). So a page reaches data and the session only
|
|
49
|
+
through `'use server'` queries.
|
|
50
|
+
- **Forms post to actions.** `<form action=${someAction}>` is the whole wiring
|
|
51
|
+
(no `method`, no `fetch`, works without JavaScript; with JavaScript the router
|
|
52
|
+
applies the result in place). The action receives the `FormData` and returns:
|
|
53
|
+
`{ success: true, redirect: '/path' }` (a 303 to that path), or
|
|
54
|
+
`{ success: false, error?, fieldErrors?, status? }`, which re-renders the
|
|
55
|
+
same page (422) with the result on the page's `actionData`;
|
|
56
|
+
`actionData.values` already holds every submitted text field. A returned
|
|
57
|
+
`Response` (for example from `signIn`) is sent as is.
|
|
58
|
+
- **Control flow:** `notFound()` and `redirect(url)` from `@webjsdev/core`
|
|
59
|
+
throw; use them in pages, layouts and form actions. In a `route.ts` return a
|
|
60
|
+
`Response` instead, and in an action called over RPC return
|
|
61
|
+
`{ success: false, error }` instead of throwing.
|
|
62
|
+
|
|
63
|
+
### File map
|
|
64
|
+
|
|
65
|
+
```
|
|
66
|
+
app/layout.ts root layout: the only file that writes <head> content
|
|
67
|
+
app/page.ts /
|
|
68
|
+
app/<seg>/[id]/page.ts dynamic route; params.id is a string
|
|
69
|
+
app/<seg>/[id]/edit/page.ts nested route
|
|
70
|
+
app/not-found.ts the 404 page, also rendered by notFound()
|
|
71
|
+
app/<path>/route.ts HTTP endpoint: export async function GET(req, { params })
|
|
72
|
+
modules/<feature>/queries/<verb-noun>.server.ts reads, 'use server', one function per file
|
|
73
|
+
modules/<feature>/actions/<verb-noun>.server.ts writes, 'use server', one function per file
|
|
74
|
+
modules/<feature>/components/<tag>.ts one custom element per file
|
|
75
|
+
modules/<feature>/utils/*.ts, types.ts pure browser-safe helpers and types
|
|
76
|
+
lib/utils/*.ts app-wide browser-safe helpers
|
|
77
|
+
db/schema.server.ts tables; `db` is in db/connection.server.ts
|
|
78
|
+
test/<feature>/*.test.ts server tests (node:test), run by `npm run test:server`
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
Import app files through the `#` root alias with the `.ts` extension:
|
|
82
|
+
`import { db } from '#db/connection.server.ts'`.
|
|
83
|
+
|
|
84
|
+
### Worked example: a signed-in CRUD feature
|
|
85
|
+
|
|
86
|
+
Each block is a whole file. Copy the shape and rename (`posts` becomes your
|
|
87
|
+
resource). Child resources (a project's tasks) follow the same pattern: the
|
|
88
|
+
child table references the parent with `onDelete: 'cascade'`, and every query
|
|
89
|
+
and action checks that the parent belongs to the signed-in user.
|
|
90
|
+
|
|
91
|
+
```ts
|
|
92
|
+
// db/schema.server.ts (columns.server.ts provides table, pk, text, integer, createdAt, index)
|
|
93
|
+
import { defineRelations } from 'drizzle-orm';
|
|
94
|
+
import { table, pk, text, integer, createdAt, index } from './columns.server.ts';
|
|
95
|
+
import { POST_STATUSES } from '#modules/posts/types.ts';
|
|
96
|
+
|
|
97
|
+
export const users = table('users', {
|
|
98
|
+
id: pk(),
|
|
99
|
+
email: text().notNull().unique(),
|
|
100
|
+
passwordHash: text().notNull(),
|
|
101
|
+
createdAt: createdAt(),
|
|
102
|
+
});
|
|
103
|
+
export const posts = table('posts', {
|
|
104
|
+
id: pk(),
|
|
105
|
+
ownerId: integer().notNull().references(() => users.id, { onDelete: 'cascade' }),
|
|
106
|
+
title: text().notNull(),
|
|
107
|
+
body: text().notNull().default(''),
|
|
108
|
+
status: text({ enum: POST_STATUSES }).notNull().default('draft'),
|
|
109
|
+
publishOn: text(), // 'YYYY-MM-DD' from <input type="date">, or null
|
|
110
|
+
createdAt: createdAt(),
|
|
111
|
+
}, (t) => [index(t.ownerId)]);
|
|
112
|
+
export const relations = defineRelations({ users, posts }, () => ({}));
|
|
113
|
+
export type User = typeof users.$inferSelect;
|
|
114
|
+
export type Post = typeof posts.$inferSelect;
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
```ts
|
|
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.
|
|
118
560
|
|
|
119
561
|
### Commands
|
|
120
562
|
|
|
121
563
|
```sh
|
|
122
|
-
npm
|
|
123
|
-
npm run
|
|
124
|
-
npm run
|
|
125
|
-
npm run
|
|
126
|
-
npm test
|
|
127
|
-
npm run
|
|
128
|
-
|
|
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
|
|
564
|
+
npm run dev # dev server; PORT=<port> to choose the port
|
|
565
|
+
npm run db:generate && npm run db:migrate # after every schema change
|
|
566
|
+
npm run check # framework rules (boundaries, forms, components)
|
|
567
|
+
npm run typecheck # TypeScript
|
|
568
|
+
npm run test:server # node:test files under test/
|
|
569
|
+
npm run ci # every gate, before you push
|
|
570
|
+
npx webjsdev ui add <name> # copy a UI kit primitive into components/ui/
|
|
135
571
|
```
|
|
@@ -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 docs and your database wiring are kept.`);
|
|
151
|
+
console.log('Next: follow the build steps in AGENTS.md (schema, then db:generate and db:migrate, then modules/ and app/).');
|
|
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.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>
|