@webjsdev/cli 0.10.67 → 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/app-icon.js +55 -0
- package/lib/create.js +30 -20
- package/lib/dev-supervisor.js +16 -1
- package/lib/doctor/codes.js +1 -0
- package/lib/doctor/probes/app-icon.js +36 -0
- package/lib/doctor/runner.js +2 -0
- package/lib/gallery-shell-files.js +1 -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 +3 -1
- package/templates/.agents/skills/webjs/references/built-ins.md +2 -0
- package/templates/.agents/skills/webjs/references/routing-and-pages.md +18 -4
- 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/gallery/app/icon.ts +8 -6
- package/templates/gallery/app/manifest.ts +4 -1
- package/templates/partials/agents-playbook-fullstack.md +566 -130
- package/templates/scripts/clear-gallery.mjs +6 -6
- package/templates/public/favicon.svg +0 -12
|
@@ -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
|
```
|