@webjsdev/cli 0.8.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +71 -0
- package/bin/webjs.js +279 -0
- package/lib/create.js +898 -0
- package/lib/saas-template.js +397 -0
- package/package.json +39 -0
- package/templates/.claude/hooks/block-prose-punctuation.sh +236 -0
- package/templates/.claude/hooks/guard-branch-context.sh +39 -0
- package/templates/.claude/hooks/nudge-uncommitted.sh +46 -0
- package/templates/.claude/settings.json +35 -0
- package/templates/.claude.json +9 -0
- package/templates/.cursor/hooks/nudge-uncommitted.sh +38 -0
- package/templates/.cursor/hooks.json +8 -0
- package/templates/.cursorrules +99 -0
- package/templates/.editorconfig +18 -0
- package/templates/.env.example +27 -0
- package/templates/.gemini/hooks/nudge-uncommitted.sh +42 -0
- package/templates/.gemini/settings.json +15 -0
- package/templates/.github/copilot-instructions.md +85 -0
- package/templates/.github/pull_request_template.md +14 -0
- package/templates/.hooks/pre-commit +48 -0
- package/templates/.opencode/plugins/nudge-uncommitted.ts +62 -0
- package/templates/.windsurfrules +91 -0
- package/templates/AGENTS.md +816 -0
- package/templates/CLAUDE.md +2 -0
- package/templates/CONVENTIONS.md +901 -0
- package/templates/lib/utils/ui.ts +83 -0
- package/templates/public/tailwind-browser.js +947 -0
- package/templates/test/hello/browser/hello.test.js +40 -0
- package/templates/test/hello/e2e/hello.test.ts +87 -0
- package/templates/test/hello/hello.test.ts +24 -0
- package/templates/web-test-runner.config.js +33 -0
|
@@ -0,0 +1,901 @@
|
|
|
1
|
+
# CONVENTIONS.md for {{APP_NAME}}
|
|
2
|
+
|
|
3
|
+
This file defines the conventions for this webjs app. **AI agents MUST read
|
|
4
|
+
this file before writing any code.** It is the single source of truth for
|
|
5
|
+
how code should be structured, tested, and organized.
|
|
6
|
+
|
|
7
|
+
Sections marked `<!-- OVERRIDE -->` contain defaults you can customize.
|
|
8
|
+
Edit the content below the marker to change the convention for your project.
|
|
9
|
+
|
|
10
|
+
---
|
|
11
|
+
|
|
12
|
+
## How `CONVENTIONS.md` relates to `webjs check`
|
|
13
|
+
|
|
14
|
+
This markdown file holds **architectural conventions** (modules layout,
|
|
15
|
+
styling, testing, git workflow) that the linter can't enforce
|
|
16
|
+
programmatically. The `<!-- OVERRIDE -->` markers let you customize
|
|
17
|
+
those for this project, and AI agents read them when writing code.
|
|
18
|
+
|
|
19
|
+
The **lint rules** are a separate, narrower thing: the boolean checks
|
|
20
|
+
that `webjs check` runs (one function per action, components register
|
|
21
|
+
themselves, tag names have hyphens, etc.). They are NOT documented in
|
|
22
|
+
this file. Their **single source of truth** is the
|
|
23
|
+
`"webjs": { "conventions": { … } }` key in `package.json`.
|
|
24
|
+
|
|
25
|
+
If that key is absent, **every default rule is enabled** and AI agents
|
|
26
|
+
must follow all of them.
|
|
27
|
+
|
|
28
|
+
### Discovering the active rules
|
|
29
|
+
|
|
30
|
+
```sh
|
|
31
|
+
webjs check --rules
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
prints every available rule with its description and shows which ones
|
|
35
|
+
are currently disabled by this project's overrides. That command is the
|
|
36
|
+
**authoritative** list. Do not maintain a copy elsewhere; it will drift.
|
|
37
|
+
|
|
38
|
+
### Disabling a rule
|
|
39
|
+
|
|
40
|
+
Add the rule name to `package.json` with a value of `false`:
|
|
41
|
+
|
|
42
|
+
```jsonc
|
|
43
|
+
{
|
|
44
|
+
"webjs": {
|
|
45
|
+
"conventions": {
|
|
46
|
+
"tests-exist": false,
|
|
47
|
+
"actions-in-modules": false
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
Only `false` is meaningful. There's no way to tweak rule *behaviour*
|
|
54
|
+
via config. A rule is either on or off.
|
|
55
|
+
|
|
56
|
+
### Rule for AI agents
|
|
57
|
+
|
|
58
|
+
1. Run `webjs check --rules` to learn the active rule set for this
|
|
59
|
+
project.
|
|
60
|
+
2. Treat every rule not explicitly disabled as binding when writing
|
|
61
|
+
code.
|
|
62
|
+
3. To change which rules are active, edit the `webjs.conventions`
|
|
63
|
+
block in `package.json`. Never inline a rule list into prose, since
|
|
64
|
+
it will drift.
|
|
65
|
+
|
|
66
|
+
---
|
|
67
|
+
|
|
68
|
+
## AI agent workflow (non-negotiable)
|
|
69
|
+
|
|
70
|
+
**These rules apply to ALL AI agents (Claude, Cursor, Copilot, etc.)
|
|
71
|
+
working on this codebase. They are not optional and must not be skipped
|
|
72
|
+
even if the user doesn't explicitly ask.**
|
|
73
|
+
|
|
74
|
+
### Before starting ANY work: verify and sync the branch
|
|
75
|
+
|
|
76
|
+
1. Check `git branch --show-current`
|
|
77
|
+
2. If on `main`/`master` → create a feature branch first
|
|
78
|
+
3. If on a feature branch → verify it matches the current task
|
|
79
|
+
4. Sync with parent: `git fetch origin && git rebase origin/main` if behind
|
|
80
|
+
5. Don't mix unrelated work on the wrong branch
|
|
81
|
+
|
|
82
|
+
### Every code change must include:
|
|
83
|
+
|
|
84
|
+
1. **Commit and push per logical unit, not at the end.** A logical unit is
|
|
85
|
+
one feature, one fix, one rename, one doc rewrite. Small, focused commits
|
|
86
|
+
with meaningful messages. Always `git push` after committing. Do not
|
|
87
|
+
accumulate uncommitted or unpushed changes. If you have 5+ unstaged files
|
|
88
|
+
spanning different concerns, commit before continuing. The
|
|
89
|
+
`.claude/hooks/nudge-uncommitted.sh` hook fires at threshold 4 to remind
|
|
90
|
+
you, and ignoring it means you are batching. The user should never have
|
|
91
|
+
to ask for a commit.
|
|
92
|
+
|
|
93
|
+
2. **Tests.** Unit test for logic, E2E test for user-facing behavior.
|
|
94
|
+
See the "Testing" section below for what type of test each change needs.
|
|
95
|
+
Run `webjs test` after every change. Never mark work as done with
|
|
96
|
+
failing tests.
|
|
97
|
+
|
|
98
|
+
3. **Documentation updates.** When adding or modifying features:
|
|
99
|
+
- Update `AGENTS.md` if the change affects the framework API surface.
|
|
100
|
+
- Update `CONVENTIONS.md` only if the change introduces a new convention.
|
|
101
|
+
- If a `docs/` directory exists, add or update the relevant doc page.
|
|
102
|
+
- If a `website/` directory exists, update the landing page for
|
|
103
|
+
user-facing features.
|
|
104
|
+
|
|
105
|
+
3. **Convention check.** Run `webjs check` after changes and fix
|
|
106
|
+
any violations before reporting the task as done.
|
|
107
|
+
|
|
108
|
+
### Autonomous mode (sandbox / bypass permissions)
|
|
109
|
+
|
|
110
|
+
When running without interactive approval, agents must NOT ask questions.
|
|
111
|
+
Instead, auto-decide using best practices:
|
|
112
|
+
- On `main`? → Auto-create `feature/<task-slug>` branch
|
|
113
|
+
- Parent branch has new commits? → Auto-rebase before starting
|
|
114
|
+
- Ready to merge? → Auto-merge, delete feature/fix branches, keep
|
|
115
|
+
long-lived branches (dev, staging, release/*)
|
|
116
|
+
- Commit message? → Auto-generate: what changed and why
|
|
117
|
+
- Tests failing? → Fix them, don't report the failure and stop
|
|
118
|
+
- Convention violations? → Fix them silently
|
|
119
|
+
|
|
120
|
+
The quality bar is the same. Autonomous mode means faster, not sloppier.
|
|
121
|
+
|
|
122
|
+
### What "automatically" means:
|
|
123
|
+
|
|
124
|
+
When a user says "add a contact page" or "add a delete button to posts",
|
|
125
|
+
the AI agent must deliver:
|
|
126
|
+
- The implementation (page, component, action, etc.)
|
|
127
|
+
- Unit tests for any new server actions/queries/components
|
|
128
|
+
- E2E test if the feature involves user interaction
|
|
129
|
+
- Documentation updates if applicable
|
|
130
|
+
|
|
131
|
+
The user should never have to say "also write tests" or "also update the
|
|
132
|
+
docs". That is the agent's default behavior in a webjs project.
|
|
133
|
+
|
|
134
|
+
---
|
|
135
|
+
|
|
136
|
+
## Data persistence: Prisma + SQLite, never JSON files
|
|
137
|
+
|
|
138
|
+
<!-- OVERRIDE -->
|
|
139
|
+
|
|
140
|
+
Every webjs app uses **Prisma + SQLite** for persistence by default. The
|
|
141
|
+
scaffold ships `prisma/schema.prisma`, `lib/prisma.server.ts` (singleton), the
|
|
142
|
+
`predev` / `prestart` hooks that run `prisma generate` / `prisma migrate
|
|
143
|
+
deploy`, and `npm run db:migrate` / `db:generate` / `db:studio` scripts.
|
|
144
|
+
|
|
145
|
+
**AI agents: these rules are absolute.**
|
|
146
|
+
|
|
147
|
+
1. For ANY data the app stores (todos, posts, messages, products,
|
|
148
|
+
comments, users…), define a Prisma model in `prisma/schema.prisma`
|
|
149
|
+
and persist there.
|
|
150
|
+
2. **NEVER** create JSON files under `data/`, `db.json`, `posts.json`,
|
|
151
|
+
`todos.json`, etc. as a fake database. The `no-json-data-files`
|
|
152
|
+
convention check flags this and `webjs check` will fail.
|
|
153
|
+
3. **NEVER** use module-scope arrays or `Map`s as a "store". They
|
|
154
|
+
reset on every dev-server reload and can't scale beyond one process.
|
|
155
|
+
4. **NEVER** use `localStorage` / `sessionStorage` to persist app data -
|
|
156
|
+
it's per-browser and never reaches the server. Use it only for UI
|
|
157
|
+
preferences (theme, sidebar collapsed, etc.).
|
|
158
|
+
5. To add a model: edit `prisma/schema.prisma`, then `npm run db:migrate
|
|
159
|
+
-- --name <description>`. Access via `import { prisma } from
|
|
160
|
+
'../../../lib/prisma.server.ts'` **only inside `.server.{js,ts}` files,
|
|
161
|
+
`route.ts` handlers, or `middleware.ts`**. Never new `PrismaClient()`.
|
|
162
|
+
Components, pages, and layouts call into the wrapped server query
|
|
163
|
+
instead; the framework rewrites that import to an RPC stub on the
|
|
164
|
+
browser side, so prisma source never reaches the client.
|
|
165
|
+
|
|
166
|
+
To switch to Postgres or MySQL: change `provider` in
|
|
167
|
+
`prisma/schema.prisma` and the `DATABASE_URL` in `.env`. Do this only
|
|
168
|
+
if the user explicitly asks for it. SQLite is the right default for
|
|
169
|
+
dev and small production workloads.
|
|
170
|
+
|
|
171
|
+
---
|
|
172
|
+
|
|
173
|
+
## The scaffold is reference, not the final product
|
|
174
|
+
|
|
175
|
+
<!-- OVERRIDE -->
|
|
176
|
+
|
|
177
|
+
This project was created with `webjs create`. Every file you see right
|
|
178
|
+
now (the `app/page.ts` "Hello from …" homepage, the example `User`
|
|
179
|
+
model, the `theme-toggle` component, the example users module in api /
|
|
180
|
+
saas templates) is a **starting point**.
|
|
181
|
+
|
|
182
|
+
When the user asks the agent to build their actual app:
|
|
183
|
+
|
|
184
|
+
1. **Replace the example `User` model** in `prisma/schema.prisma` with
|
|
185
|
+
the real domain models the app needs (e.g. `Todo`, `Post`, `Message`)
|
|
186
|
+
- unless the app actually has users.
|
|
187
|
+
2. **Replace `app/page.ts`** with the app's real homepage. Don't ship
|
|
188
|
+
"Hello from …" as the deliverable.
|
|
189
|
+
3. **Delete or replace `components/theme-toggle.ts`** if the app doesn't
|
|
190
|
+
need a theme picker.
|
|
191
|
+
4. **Delete the example users module** (api/saas templates) if the app
|
|
192
|
+
doesn't use it.
|
|
193
|
+
5. **Keep:** the Prisma setup, the test config, the agent config files
|
|
194
|
+
(`AGENTS.md`, `CONVENTIONS.md`, `CLAUDE.md`, `.cursorrules`, etc.),
|
|
195
|
+
`lib/prisma.server.ts`, the directory conventions, the design tokens in
|
|
196
|
+
`app/layout.ts`. These are the infrastructure, not the example app.
|
|
197
|
+
|
|
198
|
+
The scaffold exists so the agent doesn't reinvent the directory layout,
|
|
199
|
+
the Prisma wiring, the test runner config, or the convention files. It
|
|
200
|
+
does NOT exist so the agent ships the example homepage.
|
|
201
|
+
|
|
202
|
+
---
|
|
203
|
+
|
|
204
|
+
## Sensible defaults
|
|
205
|
+
|
|
206
|
+
<!-- OVERRIDE -->
|
|
207
|
+
webjs uses sensible defaults. Environment
|
|
208
|
+
variables control infrastructure (no config files needed):
|
|
209
|
+
|
|
210
|
+
| Environment variable | Effect |
|
|
211
|
+
|---|---|
|
|
212
|
+
| `REDIS_URL` | Connection string consumed by `redisStore({ url: process.env.REDIS_URL })`. Not auto-wired. Call `setStore(redisStore())` once at app startup to put cache / sessions / rate-limit on Redis. |
|
|
213
|
+
| `AUTH_SECRET` | Required for auth JWT signing (32+ random chars) |
|
|
214
|
+
| `AUTH_GOOGLE_ID` | Google OAuth client ID (optional) |
|
|
215
|
+
| `AUTH_GITHUB_ID` | GitHub OAuth client ID (optional) |
|
|
216
|
+
| `PORT` | Server port (default: 3000) |
|
|
217
|
+
| `WEBJS_PUBLIC_*` | Any env var starting with this prefix is exposed to the browser as `process.env.WEBJS_PUBLIC_X`. Components can read it directly. No build step, no transform. Use for API base URLs, Stripe publishable keys, analytics IDs, anything that is intended to be visible client-side. |
|
|
218
|
+
|
|
219
|
+
**Server-only by default.** Any env var without the `WEBJS_PUBLIC_` prefix never reaches the browser. Reading `process.env.DATABASE_URL` from a component returns `undefined`, the same as a typo. The prefix is fail-closed: secrets cannot accidentally leak.
|
|
220
|
+
|
|
221
|
+
**Development:** zero env vars needed. Everything works with memory/cookie/disk.
|
|
222
|
+
**Production:** set `AUTH_SECRET` + `SESSION_SECRET`. For horizontal scaling, also set `REDIS_URL` and add one line at app startup:
|
|
223
|
+
|
|
224
|
+
```js
|
|
225
|
+
import { setStore, redisStore } from '@webjsdev/server';
|
|
226
|
+
setStore(redisStore({ url: process.env.REDIS_URL }));
|
|
227
|
+
```
|
|
228
|
+
|
|
229
|
+
---
|
|
230
|
+
|
|
231
|
+
## Architecture: Modules
|
|
232
|
+
|
|
233
|
+
<!-- OVERRIDE -->
|
|
234
|
+
This app uses the **modules architecture** for feature-scoped code:
|
|
235
|
+
|
|
236
|
+
```
|
|
237
|
+
modules/
|
|
238
|
+
<feature>/
|
|
239
|
+
actions/ Server mutations (one async function per file, *.server.ts)
|
|
240
|
+
queries/ Server reads (one async function per file, *.server.ts)
|
|
241
|
+
components/ Feature-owned web components
|
|
242
|
+
utils/ Pure helper functions
|
|
243
|
+
types.ts Shared TypeScript types / JSDoc typedefs
|
|
244
|
+
```
|
|
245
|
+
|
|
246
|
+
**Rules:**
|
|
247
|
+
- One exported function per server action/query file
|
|
248
|
+
- Server actions need BOTH the `.server.{js,ts}` extension AND a `'use server'` directive at the top. Extension alone marks a server-only utility (source-protected, not RPC-callable). Directive alone is a lint violation (`use-server-needs-extension`).
|
|
249
|
+
- Components must call `Class.register('tag')`
|
|
250
|
+
- **Server-only code goes in `.server.{js,ts}` files, `route.ts` handlers, or `middleware.ts`. Never in pages, layouts, or components.** Direct imports of `@prisma/client` or `node:*` from pages, layouts, or components crash the browser at module load. Wrap in a `.server.{js,ts}` file; the framework rewrites that import to an RPC stub on the browser side. `lib/` holds both server-only infra (`lib/prisma.server.ts`) and browser-safe utilities (`lib/utils/cn.ts` with `cn`); the convention is "if a `lib/` file needs Node APIs, only import it from server-only files."
|
|
251
|
+
- Routes (`app/**/page.ts`, `app/**/route.ts`) must be thin: import logic from modules
|
|
252
|
+
|
|
253
|
+
---
|
|
254
|
+
|
|
255
|
+
## Architecture: Routes
|
|
256
|
+
|
|
257
|
+
<!-- OVERRIDE -->
|
|
258
|
+
Routes live under `app/` and follow NextJs App Router conventions:
|
|
259
|
+
|
|
260
|
+
- `app/page.ts`: Homepage
|
|
261
|
+
- `app/<segment>/page.ts`: Static route
|
|
262
|
+
- `app/[param]/page.ts`: Dynamic route
|
|
263
|
+
- `app/[...rest]/page.ts`: Catch-all
|
|
264
|
+
- `app/(group)/...`: Route group (folder not in URL)
|
|
265
|
+
- `app/**/route.ts`: API endpoint
|
|
266
|
+
- `app/**/layout.ts`: Layout wrapper
|
|
267
|
+
- `app/**/error.ts`: Error boundary
|
|
268
|
+
- `app/**/middleware.ts`: Per-segment middleware
|
|
269
|
+
|
|
270
|
+
**Special route files:**
|
|
271
|
+
- `app/**/error.ts`: Error boundary. Default export receives `{ error }`, returns `TemplateResult`. Nearest boundary catches errors from pages below it.
|
|
272
|
+
- `app/**/loading.ts`: Loading state. Auto-wraps the sibling page in a `Suspense` boundary. Shown while async page functions resolve.
|
|
273
|
+
- `app/**/not-found.ts`: 404 page. Nearest wins when `notFound()` is thrown.
|
|
274
|
+
- `app/sitemap.ts`: Dynamic sitemap at `/sitemap.xml`. Export a function returning an array of `{ url, lastModified }`.
|
|
275
|
+
- `app/robots.ts`: Dynamic robots.txt at `/robots.txt`.
|
|
276
|
+
- `app/manifest.ts`: Web app manifest at `/manifest.json`.
|
|
277
|
+
|
|
278
|
+
**Rules:**
|
|
279
|
+
- A folder cannot have both `page.ts` and `route.ts`
|
|
280
|
+
- Page/layout default exports must be functions (possibly async)
|
|
281
|
+
- Route handlers export named methods: `GET`, `POST`, `PUT`, `DELETE`, `WS`
|
|
282
|
+
|
|
283
|
+
---
|
|
284
|
+
|
|
285
|
+
## Testing
|
|
286
|
+
|
|
287
|
+
<!-- OVERRIDE -->
|
|
288
|
+
Tests are organised by **feature**, mirroring `modules/<feature>/`.
|
|
289
|
+
Each feature gets its own folder under `test/`, even when it starts
|
|
290
|
+
with a single file. Test KIND (browser / e2e / smoke) lives in a
|
|
291
|
+
subfolder inside the feature, and only appears when there is a real
|
|
292
|
+
test of that kind.
|
|
293
|
+
|
|
294
|
+
```
|
|
295
|
+
test/
|
|
296
|
+
<feature>/
|
|
297
|
+
<name>.test.ts ← node test: unit + integration
|
|
298
|
+
browser/<name>.test.js ← real-browser test (web-test-runner)
|
|
299
|
+
e2e/<name>.test.ts ← full-app end-to-end (opt in: WEBJS_E2E=1)
|
|
300
|
+
smoke/<name>.test.ts ← fast post-deploy sanity check
|
|
301
|
+
```
|
|
302
|
+
|
|
303
|
+
Concrete example:
|
|
304
|
+
|
|
305
|
+
```
|
|
306
|
+
test/
|
|
307
|
+
auth/
|
|
308
|
+
auth.test.ts # signup / login / currentUser, node
|
|
309
|
+
password.test.ts # hashing / verify, node
|
|
310
|
+
browser/login-form.test.js # real-browser, only if exercising DOM
|
|
311
|
+
posts/
|
|
312
|
+
posts.test.ts # CRUD via actions
|
|
313
|
+
browser/post-editor.test.js
|
|
314
|
+
hello/
|
|
315
|
+
hello.test.ts # the scaffold's starter test
|
|
316
|
+
browser/hello.test.js
|
|
317
|
+
e2e/hello.test.ts
|
|
318
|
+
```
|
|
319
|
+
|
|
320
|
+
### Test kinds
|
|
321
|
+
|
|
322
|
+
| Kind | Where | What it does |
|
|
323
|
+
|------|-------|--------------|
|
|
324
|
+
| node (unit + integration) | `test/<feature>/*.test.ts` | Fast, no spawned process. Import server actions/queries/utilities and call them directly. Use `renderToString` for SSR HTML assertions. |
|
|
325
|
+
| browser | `test/<feature>/browser/*.test.js` | Real Chromium via web-test-runner + Playwright. Shadow DOM, events, `adoptedStyleSheets`, `IntersectionObserver`. |
|
|
326
|
+
| e2e | `test/<feature>/e2e/*.test.ts` | Boots the app and drives it through HTTP / a real browser. Gated behind `WEBJS_E2E=1` so it doesn't run on every `webjs test`. |
|
|
327
|
+
| smoke | `test/<feature>/smoke/*.test.ts` | Fast deploy-time sanity check (single critical path; "does this surface still return 200"). |
|
|
328
|
+
|
|
329
|
+
### Running
|
|
330
|
+
|
|
331
|
+
- `webjs test` (or `node --test`) runs the node tests (unit + integration + smoke).
|
|
332
|
+
- `webjs test --browser` (or `npx wtr`) runs the browser tests.
|
|
333
|
+
- `WEBJS_E2E=1 webjs test` adds the e2e tests.
|
|
334
|
+
|
|
335
|
+
### Choosing a feature folder
|
|
336
|
+
|
|
337
|
+
Use the same name as the matching module folder when one exists:
|
|
338
|
+
`modules/posts/` ↔ `test/posts/`. If the test spans more than one
|
|
339
|
+
module (a full-stack flow), pick the most prominent one or create a
|
|
340
|
+
new feature folder (`test/checkout/`, `test/onboarding/`).
|
|
341
|
+
|
|
342
|
+
If you find yourself reaching for `test/utils/` or `test/misc/`,
|
|
343
|
+
the feature folder is missing. Name it after the user-facing concern.
|
|
344
|
+
|
|
345
|
+
### Debugging with Playwright MCP
|
|
346
|
+
|
|
347
|
+
This project includes a Playwright MCP server (`.claude.json`). When
|
|
348
|
+
debugging UI issues, AI agents can use the Playwright MCP tools to:
|
|
349
|
+
- Navigate to pages in a real browser
|
|
350
|
+
- Click elements, fill forms, interact with the UI
|
|
351
|
+
- Take screenshots to see what the user sees
|
|
352
|
+
- Inspect the accessibility tree for element discovery
|
|
353
|
+
|
|
354
|
+
Use `Playwright MCP` tools instead of writing one-shot Bash scripts
|
|
355
|
+
with puppeteer or playwright imports.
|
|
356
|
+
|
|
357
|
+
### When to write tests
|
|
358
|
+
|
|
359
|
+
| Change | Server test (node:test) | Browser test (WTR) |
|
|
360
|
+
|--------|------------------------|-------------------|
|
|
361
|
+
| New server action | Required | - |
|
|
362
|
+
| New component | Required (SSR output) | Required (interaction) |
|
|
363
|
+
| New page/route | - | Required |
|
|
364
|
+
| Bug fix | Required (regression) | If user-facing |
|
|
365
|
+
| Refactor | Existing tests must pass | Existing tests must pass |
|
|
366
|
+
|
|
367
|
+
---
|
|
368
|
+
|
|
369
|
+
## UI components: prefer the Webjs UI kit over raw Tailwind
|
|
370
|
+
|
|
371
|
+
<!-- OVERRIDE -->
|
|
372
|
+
|
|
373
|
+
This scaffold ships with the Webjs UI kit preinstalled at `components/ui/`.
|
|
374
|
+
The kit splits into **two tiers**. Picking the wrong tier produces
|
|
375
|
+
broken markup.
|
|
376
|
+
|
|
377
|
+
**Tier 1: class helpers** (button, card, input, label, alert, badge,
|
|
378
|
+
separator, skeleton, table, etc.): pure functions that return Tailwind
|
|
379
|
+
class strings. Call them and spread onto a **raw native element**.
|
|
380
|
+
|
|
381
|
+
**Tier 2: custom elements** (dialog, popover, tooltip, dropdown-menu,
|
|
382
|
+
tabs, accordion, collapsible, progress, etc.): real `<ui-X>` tags. Import
|
|
383
|
+
the module once (typically in `app/layout.ts`) and use the tag.
|
|
384
|
+
|
|
385
|
+
```ts
|
|
386
|
+
// Tier 1: class helpers on native elements (use this for forms,
|
|
387
|
+
// dashboards, cards, layouts, anywhere the value is purely visual)
|
|
388
|
+
import { buttonClass } from '../components/ui/button.ts';
|
|
389
|
+
import { inputClass } from '../components/ui/input.ts';
|
|
390
|
+
return html`
|
|
391
|
+
<button class=${buttonClass({ size: 'lg' })}>Save</button>
|
|
392
|
+
<input class=${inputClass()} placeholder="Email">
|
|
393
|
+
`;
|
|
394
|
+
|
|
395
|
+
// Tier 2: custom elements (modals, dropdowns, tab strips, tooltips,
|
|
396
|
+
// state the browser doesn't give you natively)
|
|
397
|
+
return html`
|
|
398
|
+
<ui-dialog>
|
|
399
|
+
<ui-dialog-trigger>
|
|
400
|
+
<button class=${buttonClass({ variant: 'outline' })}>Edit</button>
|
|
401
|
+
</ui-dialog-trigger>
|
|
402
|
+
<ui-dialog-content>…</ui-dialog-content>
|
|
403
|
+
</ui-dialog>
|
|
404
|
+
`;
|
|
405
|
+
|
|
406
|
+
// Avoid: hand-rolled Tailwind on every <button> loses visual
|
|
407
|
+
// consistency. Tier-1 helpers give you the same control with one import.
|
|
408
|
+
return html`
|
|
409
|
+
<button class="px-4 py-2 rounded-md bg-accent text-accent-fg">Save</button>
|
|
410
|
+
`;
|
|
411
|
+
```
|
|
412
|
+
|
|
413
|
+
Add more components with `webjs ui add <name>` (e.g. `webjs ui add dialog
|
|
414
|
+
tabs popover`). The catalogue lives at
|
|
415
|
+
[https://ui.webjs.dev](https://ui.webjs.dev).
|
|
416
|
+
|
|
417
|
+
**Hand-rolled Tailwind is still appropriate for:**
|
|
418
|
+
- One-off marketing pages, hero sections, landing CTAs.
|
|
419
|
+
- Anywhere the visual design intentionally diverges from the kit baseline.
|
|
420
|
+
- Layout primitives (`<div class="grid grid-cols-3 gap-4">`).
|
|
421
|
+
|
|
422
|
+
The convention: any visual element with a Tier-1 helper uses the helper.
|
|
423
|
+
Any stateful behavior with a Tier-2 element uses the element.
|
|
424
|
+
|
|
425
|
+
---
|
|
426
|
+
|
|
427
|
+
## Components
|
|
428
|
+
|
|
429
|
+
<!-- OVERRIDE -->
|
|
430
|
+
|
|
431
|
+
```ts
|
|
432
|
+
import { WebComponent, html } from '@webjsdev/core';
|
|
433
|
+
|
|
434
|
+
export class MyWidget extends WebComponent {
|
|
435
|
+
static properties = { label: { type: String }, count: { type: Number } };
|
|
436
|
+
declare label: string;
|
|
437
|
+
declare count: number;
|
|
438
|
+
// Light DOM is the default; Tailwind utility classes apply directly.
|
|
439
|
+
|
|
440
|
+
constructor() {
|
|
441
|
+
super();
|
|
442
|
+
// Defaults go here, never as class-field initializers
|
|
443
|
+
// (`label = ''` would clobber the framework's reactive accessor).
|
|
444
|
+
this.label = '';
|
|
445
|
+
this.count = 0;
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
render() {
|
|
449
|
+
return html`
|
|
450
|
+
<div class="p-4 border border-border rounded-lg">
|
|
451
|
+
<p class="font-serif text-fg">${this.label}: ${this.count}</p>
|
|
452
|
+
</div>
|
|
453
|
+
`;
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
MyWidget.register('my-widget');
|
|
457
|
+
```
|
|
458
|
+
|
|
459
|
+
`static properties` is the runtime declaration (reactive accessor,
|
|
460
|
+
attribute coercion, reflection). `declare` types the field for
|
|
461
|
+
TypeScript without emitting a class-field initializer that would
|
|
462
|
+
clobber the reactive accessor at construction time. The two
|
|
463
|
+
declarations together give you full intelligence in any tsserver-backed
|
|
464
|
+
editor. See the Editor Setup docs for the `ts-lit-plugin` +
|
|
465
|
+
`@webjsdev/ts-plugin` setup that extends this to tag / attribute
|
|
466
|
+
intelligence inside `html\`…\`` templates (go-to-definition, attribute
|
|
467
|
+
auto-complete from `static properties`, no "Unknown tag" red-squiggle on
|
|
468
|
+
registered webjs elements).
|
|
469
|
+
|
|
470
|
+
**Rules:**
|
|
471
|
+
- One component per file
|
|
472
|
+
- **Light DOM by default.** Opt in to shadow DOM with `static shadow = true` when you need scoped styles (via `static styles = css\`...\``) or third-party-embed isolation. `<slot>` projection works identically in both modes (named slots, fallback content, `assignedNodes` / `slotchange`, first-wins resolution), so slot usage alone is never a reason to opt into shadow DOM.
|
|
473
|
+
- Prefer Tailwind utility classes for styling. They're unique by construction (`p-4`, `font-semibold`) so they can't collide across components.
|
|
474
|
+
- **If a light-DOM component authors its own custom CSS (a `<style>` block in `render()` or an imported stylesheet), every class selector MUST be prefixed with the component's tag name.** Either pattern works. Pick one and stay consistent:
|
|
475
|
+
- `.my-widget__body`, `.my-widget__title` (BEM-ish)
|
|
476
|
+
- `my-widget .body`, `my-widget .title` (descendant selector)
|
|
477
|
+
- Tag name must contain a hyphen (HTML spec)
|
|
478
|
+
- Always call `Class.register('tag')`. That's the standard DOM API.
|
|
479
|
+
- **Reactive props use `declare propName: Type` (no value) plus a default in `constructor()` after `super()`.** Never write `propName = value` or `propName: Type = value` as a class-field initializer. It compiles to `Object.defineProperty(this, …)` after `super()` and clobbers the framework's reactive accessor, silently breaking re-renders. `webjs check` flags this via the `reactive-props-use-declare` rule.
|
|
480
|
+
- Component state lives in signals. Import `signal` from
|
|
481
|
+
`@webjsdev/core`, read via `signal.get()` inside `render()`, write
|
|
482
|
+
via `signal.set(value)`. Module-scope signals share state across
|
|
483
|
+
components; instance signals (created in the constructor) carry
|
|
484
|
+
component-local state. Reactive properties (`static properties =
|
|
485
|
+
{ foo: { type: ... } }` with a sibling `declare foo: T`) wrap HTML
|
|
486
|
+
attributes, attribute reflection, and `.prop=${value}` SSR
|
|
487
|
+
hydration.
|
|
488
|
+
- Use lifecycle hooks (`firstUpdated`, `updated`) only when needed
|
|
489
|
+
|
|
490
|
+
---
|
|
491
|
+
|
|
492
|
+
## Components: Light DOM (default) vs Shadow DOM (opt-in)
|
|
493
|
+
|
|
494
|
+
<!-- OVERRIDE -->
|
|
495
|
+
|
|
496
|
+
| Use case | Mode | How |
|
|
497
|
+
|---|---|---|
|
|
498
|
+
| Global / Tailwind CSS, simple composition | **Light DOM** (default) | Write `class="..."` in your template. Plain children, global styles apply. |
|
|
499
|
+
| Scoped styles via `static styles = css\`\`` | Shadow DOM | Set `static shadow = true`. `adoptedStyleSheets` scopes bare selectors. |
|
|
500
|
+
| `<slot>` content projection | **Either** | Same `<slot>` / `<slot name="x">` / fallback / `assignedNodes` / `slotchange` API in both modes. Light DOM uses framework projection; shadow DOM uses native browser projection. |
|
|
501
|
+
| Third-party embed isolation | Shadow DOM | CSS can't leak in or out. |
|
|
502
|
+
|
|
503
|
+
**Light DOM** = the component renders as plain HTML. Global CSS and
|
|
504
|
+
Tailwind utility classes apply directly. Use `document.querySelector`
|
|
505
|
+
to find elements. No `:host`, no `::part`, no CSS-variable plumbing.
|
|
506
|
+
|
|
507
|
+
**Shadow DOM** = opt-in style encapsulation. Declare `static shadow = true`
|
|
508
|
+
and author styles via `static styles = css\`...\`` (adopted via
|
|
509
|
+
`adoptedStyleSheets`). The browser enforces the boundary, and nothing
|
|
510
|
+
leaks in or out.
|
|
511
|
+
|
|
512
|
+
Both modes are fully SSR'd. Light DOM emits content as direct children
|
|
513
|
+
with a `<!--webjs-hydrate-->` marker. Shadow DOM emits a
|
|
514
|
+
`<template shadowrootmode="open">` that the browser attaches automatically.
|
|
515
|
+
Both hydrate without flash on the client.
|
|
516
|
+
|
|
517
|
+
---
|
|
518
|
+
|
|
519
|
+
## Styling: Tailwind + JS helpers
|
|
520
|
+
|
|
521
|
+
<!-- OVERRIDE -->
|
|
522
|
+
|
|
523
|
+
The scaffold ships with the **Tailwind CSS browser runtime** + `@theme`
|
|
524
|
+
design tokens defined in the root layout. Every colour, font family,
|
|
525
|
+
fluid type scale value, and motion duration is declared once in `@theme`
|
|
526
|
+
and available everywhere via utility classes (`text-fg`, `bg-bg-elev`,
|
|
527
|
+
`font-serif`, `duration-fast`, `text-display`).
|
|
528
|
+
|
|
529
|
+
**Dedup repeated Tailwind class bundles with JS helpers, not `@apply`.**
|
|
530
|
+
When the same string of classes appears in 2+ places, extract it into a
|
|
531
|
+
small function in `lib/utils/ui.ts`:
|
|
532
|
+
|
|
533
|
+
```ts
|
|
534
|
+
// lib/utils/ui.ts
|
|
535
|
+
import { html } from '@webjsdev/core';
|
|
536
|
+
|
|
537
|
+
export function rubric(label: string) {
|
|
538
|
+
return html`
|
|
539
|
+
<span class="block font-mono text-[11px] leading-none font-semibold tracking-[0.2em] uppercase text-accent mb-4">● ${label}</span>
|
|
540
|
+
`;
|
|
541
|
+
}
|
|
542
|
+
```
|
|
543
|
+
|
|
544
|
+
Consume:
|
|
545
|
+
|
|
546
|
+
```ts
|
|
547
|
+
// app/page.ts
|
|
548
|
+
import { rubric } from '../lib/utils/ui.ts';
|
|
549
|
+
|
|
550
|
+
export default function Home() {
|
|
551
|
+
return html`
|
|
552
|
+
${rubric('welcome')}
|
|
553
|
+
<h1 class="font-serif text-display">Hello</h1>
|
|
554
|
+
`;
|
|
555
|
+
}
|
|
556
|
+
```
|
|
557
|
+
|
|
558
|
+
Helpers run at SSR time inside `html\`\``, so the output is identical
|
|
559
|
+
to writing the classes inline. No client-side runtime.
|
|
560
|
+
|
|
561
|
+
**Why not `@apply`?** `@apply` hides which utilities back a class and
|
|
562
|
+
creates a second source of truth. JS helpers keep the class bundle
|
|
563
|
+
visible at the definition site and compose naturally with conditional
|
|
564
|
+
classes and active states.
|
|
565
|
+
|
|
566
|
+
**Custom CSS is still supported.** Plain `<style>` blocks, CSS modules,
|
|
567
|
+
or a build-step pipeline. The framework has no hard dependency on Tailwind.
|
|
568
|
+
If you mix custom CSS into a light-DOM component, apply the class-prefix
|
|
569
|
+
rule (see Components section above).
|
|
570
|
+
|
|
571
|
+
---
|
|
572
|
+
|
|
573
|
+
## Styling alternative: vanilla CSS end-to-end
|
|
574
|
+
|
|
575
|
+
<!-- OVERRIDE -->
|
|
576
|
+
|
|
577
|
+
If you'd rather skip Tailwind, webjs works with plain CSS as long as you
|
|
578
|
+
wrap pages, layouts, and components so class names don't collide in the
|
|
579
|
+
global light-DOM namespace.
|
|
580
|
+
|
|
581
|
+
**Convention: three scopes**
|
|
582
|
+
|
|
583
|
+
| Scope | Wrapper | Derivation |
|
|
584
|
+
|---|---|---|
|
|
585
|
+
| **Component** | Custom-element tag | Tag is already unique |
|
|
586
|
+
| **Page** | `.page-<route>` | `app/dashboard/page.ts` → `.page-dashboard`; `app/blog/[slug]/page.ts` → `.page-blog-slug`; root `app/page.ts` → `.page-home` |
|
|
587
|
+
| **Layout** | `.layout-<name>` | `app/layout.ts` → `.layout-root`; `app/admin/layout.ts` → `.layout-admin` |
|
|
588
|
+
|
|
589
|
+
Every page wraps its output in `<div class="page-<route>">`. Every
|
|
590
|
+
layout wraps in `<div class="layout-<name>">`. Components scope via
|
|
591
|
+
their tag. Styles colocate as `const STYLES = css\`…\`` + `<style>${'$'}{STYLES.text}</style>`.
|
|
592
|
+
|
|
593
|
+
```ts
|
|
594
|
+
// app/dashboard/page.ts
|
|
595
|
+
import { html, css } from '@webjsdev/core';
|
|
596
|
+
|
|
597
|
+
const STYLES = css\`
|
|
598
|
+
.page-dashboard {
|
|
599
|
+
.actions { display: flex; gap: 12px; }
|
|
600
|
+
.btn { padding: 12px 24px; border-radius: 999px; }
|
|
601
|
+
.btn-primary { background: var(--accent); color: var(--accent-fg); }
|
|
602
|
+
}
|
|
603
|
+
\`;
|
|
604
|
+
|
|
605
|
+
export default function Dashboard() {
|
|
606
|
+
return html\`
|
|
607
|
+
<style>${'$'}{STYLES.text}</style>
|
|
608
|
+
<div class="page-dashboard">
|
|
609
|
+
<div class="actions">
|
|
610
|
+
<a class="btn btn-primary" href="/new">+ New</a>
|
|
611
|
+
</div>
|
|
612
|
+
</div>
|
|
613
|
+
\`;
|
|
614
|
+
}
|
|
615
|
+
```
|
|
616
|
+
|
|
617
|
+
Inside each scope, `.btn` / `.input` / `.form` / `.item` are free
|
|
618
|
+
names. CSS descendant combinators stop them at the scope boundary.
|
|
619
|
+
A small curated set of **primitives** (`rubric`, `banner`,
|
|
620
|
+
`accent-link`, `display-h1`, …) can live global in the root layout
|
|
621
|
+
as your design system.
|
|
622
|
+
|
|
623
|
+
**When you'd pick this over Tailwind:**
|
|
624
|
+
- You want zero runtime scripts and zero build step.
|
|
625
|
+
- You prefer idiomatic CSS and plain-cascade debugging.
|
|
626
|
+
- You already have a design system in CSS custom properties.
|
|
627
|
+
|
|
628
|
+
**Costs:**
|
|
629
|
+
- Write more per-file CSS (no utility ecosystem).
|
|
630
|
+
- Discipline: every page/layout remembers to wrap.
|
|
631
|
+
- Renaming a route folder = 2 textual edits in one file (the wrapper class + the matching `class=` attribute).
|
|
632
|
+
|
|
633
|
+
Pick one convention per project and stay consistent.
|
|
634
|
+
|
|
635
|
+
---
|
|
636
|
+
|
|
637
|
+
## Rate limiting & middleware
|
|
638
|
+
|
|
639
|
+
<!-- OVERRIDE -->
|
|
640
|
+
Use `rateLimit()` as per-segment middleware to protect routes:
|
|
641
|
+
|
|
642
|
+
```ts
|
|
643
|
+
// app/api/auth/middleware.ts: protect auth endpoints
|
|
644
|
+
import { rateLimit } from '@webjsdev/server';
|
|
645
|
+
export default rateLimit({ window: '10s', max: 5 });
|
|
646
|
+
```
|
|
647
|
+
|
|
648
|
+
Place `middleware.ts` at any route level. It applies to that subtree only.
|
|
649
|
+
Chain runs outermost → innermost.
|
|
650
|
+
|
|
651
|
+
---
|
|
652
|
+
|
|
653
|
+
## Lazy loading
|
|
654
|
+
|
|
655
|
+
<!-- OVERRIDE -->
|
|
656
|
+
For below-the-fold components with heavy JS, defer loading until visible:
|
|
657
|
+
|
|
658
|
+
```ts
|
|
659
|
+
class HeavyChart extends WebComponent {
|
|
660
|
+
static lazy = true; // module loaded on scroll, not on page load
|
|
661
|
+
// ...
|
|
662
|
+
}
|
|
663
|
+
```
|
|
664
|
+
|
|
665
|
+
SSR content is visible immediately. Only the JS download is deferred.
|
|
666
|
+
**Do NOT use** for above-the-fold or critical UI (navigation, forms).
|
|
667
|
+
|
|
668
|
+
---
|
|
669
|
+
|
|
670
|
+
## expose(): REST endpoints from server actions
|
|
671
|
+
|
|
672
|
+
<!-- OVERRIDE -->
|
|
673
|
+
Tag a server action to also be reachable over HTTP:
|
|
674
|
+
|
|
675
|
+
```ts
|
|
676
|
+
import { expose } from '@webjsdev/core';
|
|
677
|
+
export const createPost = expose('POST /api/posts', async ({ title, body }) => {
|
|
678
|
+
return prisma.post.create({ data: { title, body } });
|
|
679
|
+
});
|
|
680
|
+
```
|
|
681
|
+
|
|
682
|
+
The same function works via RPC (from components) and HTTP (for external
|
|
683
|
+
callers). Use `expose()` when mobile apps, webhooks, or third parties need
|
|
684
|
+
to call your action. For internal-only actions, plain server actions are
|
|
685
|
+
simpler and CSRF-protected.
|
|
686
|
+
|
|
687
|
+
**Security:** `expose()`d endpoints are NOT CSRF-protected. Authenticate
|
|
688
|
+
via bearer tokens, API keys, or auth middleware.
|
|
689
|
+
|
|
690
|
+
---
|
|
691
|
+
|
|
692
|
+
## Progressive enhancement (write HTML-first)
|
|
693
|
+
|
|
694
|
+
<!-- OVERRIDE -->
|
|
695
|
+
|
|
696
|
+
webjs pages work without JavaScript by design. Read-paths render to
|
|
697
|
+
real HTML on the server. Write-paths run through plain `<form>` plus
|
|
698
|
+
server actions, and navigation is a real `<a href>`. Every web component
|
|
699
|
+
is SSR'd too. Its `render()` runs on the server, so the component's
|
|
700
|
+
initial markup is in the response before any script loads. With JS
|
|
701
|
+
disabled, a display-only custom element looks correct, and an
|
|
702
|
+
interactive one (counter, dropdown, tabs) still paints its initial
|
|
703
|
+
state. Only the *interactivity itself* (the +/- click, the open/close
|
|
704
|
+
toggle, the tab switch) requires JS.
|
|
705
|
+
|
|
706
|
+
**Default rules:**
|
|
707
|
+
- **Forms must work as plain HTML POSTs.** Use `<form action=…>` bound
|
|
708
|
+
to a server action. Never `fetch` + a JS click handler for the
|
|
709
|
+
happy path. The framework upgrades the form to a partial-swap
|
|
710
|
+
submission automatically when the client router is active, and with
|
|
711
|
+
JS disabled the same form does a full-page POST and works identically
|
|
712
|
+
end-to-end.
|
|
713
|
+
- **Links must be real `<a href="…">`.** Don't roll a JS-only click
|
|
714
|
+
handler for navigation. The client router intercepts `<a>` clicks and
|
|
715
|
+
enhances them into SPA transitions. Without JS, the browser navigates
|
|
716
|
+
the old-fashioned way.
|
|
717
|
+
- **Custom elements are the only place JS is allowed to be required.**
|
|
718
|
+
If a feature works without state (a styled card, a layout, a list,
|
|
719
|
+
a marketing section), it should not be a custom element with
|
|
720
|
+
lifecycle. Use a plain function returning `html\`…\`` or a Tier-1
|
|
721
|
+
Webjs-UI class helper (`buttonClass`, `cardClass`).
|
|
722
|
+
- **Test JS-off explicitly before marking a feature done.** Open the
|
|
723
|
+
page in a browser with JS disabled (DevTools → Settings → Debugger
|
|
724
|
+
→ "Disable JavaScript") and exercise the user's read + write paths.
|
|
725
|
+
If a write fails without JS, you've reached for `fetch` where a
|
|
726
|
+
server action would have done the job.
|
|
727
|
+
- **Don't gate read-paths on hydration.** Never write components whose
|
|
728
|
+
SSR'd HTML is empty or wrong on purpose with the expectation that
|
|
729
|
+
JS will fill it in. The first paint must be the right content.
|
|
730
|
+
|
|
731
|
+
**SSR-meaningful component state.** The SSR pipeline constructs the
|
|
732
|
+
component, applies its attributes, and calls `render()`. It does NOT
|
|
733
|
+
call `connectedCallback`, `firstUpdated`, or any other browser-only
|
|
734
|
+
lifecycle hook. Whatever state should appear on first paint MUST be
|
|
735
|
+
set in the constructor (after `super()`) or be derivable from
|
|
736
|
+
`static properties` + attributes on the rendered tag.
|
|
737
|
+
|
|
738
|
+
```ts
|
|
739
|
+
import { WebComponent, html, signal } from '@webjsdev/core';
|
|
740
|
+
|
|
741
|
+
class Cart extends WebComponent {
|
|
742
|
+
items = signal<Item[]>([]); // instance signal, SSR uses this for first paint
|
|
743
|
+
|
|
744
|
+
connectedCallback() {
|
|
745
|
+
super.connectedCallback();
|
|
746
|
+
// Browser-only refinement, read localStorage and write the
|
|
747
|
+
// signal. The component re-renders automatically.
|
|
748
|
+
const stored = readFromLocalStorage();
|
|
749
|
+
if (stored) this.items.set(stored);
|
|
750
|
+
}
|
|
751
|
+
|
|
752
|
+
render() {
|
|
753
|
+
return html`<ul>${this.items.get().map(/* … */)}</ul>`;
|
|
754
|
+
}
|
|
755
|
+
}
|
|
756
|
+
```
|
|
757
|
+
|
|
758
|
+
Where the data lives, where to read it:
|
|
759
|
+
|
|
760
|
+
| Data source | Where to read it |
|
|
761
|
+
|---|---|
|
|
762
|
+
| Database, session, cookies, request headers | Page function (server). Pass to component as attribute / property. |
|
|
763
|
+
| Component's own initial defaults | Component `constructor()` after `super()`. |
|
|
764
|
+
| Browser-only: `localStorage`, viewport, `matchMedia`, `navigator.*` | Component `connectedCallback()`, then write a signal (instance-scoped in the constructor, or module-scope if shared) to refine. |
|
|
765
|
+
| Theme color, RTL direction (flash-sensitive) | Synchronous inline `<script>` in root layout that sets `document.documentElement` attributes before custom elements upgrade. |
|
|
766
|
+
|
|
767
|
+
---
|
|
768
|
+
|
|
769
|
+
## Server actions
|
|
770
|
+
|
|
771
|
+
<!-- OVERRIDE -->
|
|
772
|
+
|
|
773
|
+
```ts
|
|
774
|
+
// modules/posts/actions/create-post.server.ts
|
|
775
|
+
'use server';
|
|
776
|
+
import { prisma } from '../../../lib/prisma.server.ts';
|
|
777
|
+
import type { ActionResult } from '../types.ts';
|
|
778
|
+
|
|
779
|
+
export async function createPost(input: {
|
|
780
|
+
title: string;
|
|
781
|
+
body: string;
|
|
782
|
+
}): Promise<ActionResult<Post>> {
|
|
783
|
+
// validate, create, return
|
|
784
|
+
}
|
|
785
|
+
```
|
|
786
|
+
|
|
787
|
+
**Rules:**
|
|
788
|
+
- One function per file (greppable, AI-agent friendly)
|
|
789
|
+
- File name matches function name: `create-post.server.ts` → `createPost`
|
|
790
|
+
- Return `ActionResult<T>` envelope for actions that can fail
|
|
791
|
+
- Never throw for expected errors. Return `{ success: false, error, status }`
|
|
792
|
+
- Validate input at the top of the function
|
|
793
|
+
|
|
794
|
+
---
|
|
795
|
+
|
|
796
|
+
## Code style
|
|
797
|
+
|
|
798
|
+
<!-- OVERRIDE -->
|
|
799
|
+
- TypeScript with explicit `.ts` extensions in imports
|
|
800
|
+
- **Erasable TypeScript only.** The framework strips types via Node 24+'s built-in `module.stripTypeScriptTypes` (whitespace replacement, byte-exact line + column preservation, no sourcemap shipped). Your `tsconfig.json` sets `erasableSyntaxOnly: true`, so the compiler rejects: `enum`, `namespace` with values, constructor parameter properties, legacy decorators with `emitDecoratorMetadata`, and `import = require`. Write the erasable equivalents:
|
|
801
|
+
```ts
|
|
802
|
+
// Not allowed
|
|
803
|
+
enum Color { Red, Green, Blue }
|
|
804
|
+
class Foo { constructor(public x: number) {} }
|
|
805
|
+
|
|
806
|
+
// Erasable equivalents
|
|
807
|
+
const Color = { Red: 'Red', Green: 'Green', Blue: 'Blue' } as const;
|
|
808
|
+
type Color = typeof Color[keyof typeof Color];
|
|
809
|
+
|
|
810
|
+
class Foo {
|
|
811
|
+
x: number;
|
|
812
|
+
constructor(x: number) { this.x = x; }
|
|
813
|
+
}
|
|
814
|
+
```
|
|
815
|
+
If you turn `erasableSyntaxOnly` off and use non-erasable syntax, the dev server falls back to esbuild and ships inline sourcemaps for those files (~3x wire bytes per request and stack traces lose strict accuracy). The `erasable-typescript-only` convention check warns when the flag is off.
|
|
816
|
+
- No semicolons (or with semicolons, pick one and stay consistent)
|
|
817
|
+
- `const` by default, `let` when needed, never `var`
|
|
818
|
+
- Prefer `async/await` over `.then()` chains
|
|
819
|
+
- Minimal comments. Code should be self-documenting
|
|
820
|
+
- No barrel files (`index.ts` re-exporting everything). Import from the source directly
|
|
821
|
+
|
|
822
|
+
---
|
|
823
|
+
|
|
824
|
+
## Git workflow
|
|
825
|
+
|
|
826
|
+
<!-- OVERRIDE -->
|
|
827
|
+
|
|
828
|
+
This project enforces a git workflow via agent-specific config files
|
|
829
|
+
(`.claude/settings.json`, `.cursorrules`, `.windsurfrules`,
|
|
830
|
+
`.github/copilot-instructions.md`). These rules apply to ALL AI agents:
|
|
831
|
+
|
|
832
|
+
**Commit rules:**
|
|
833
|
+
- **Commit per logical unit, not at the end.** One feature, one fix, one
|
|
834
|
+
rename, one doc rewrite per commit. Push after each commit.
|
|
835
|
+
- **Hard limit.** If you have 5+ unstaged files spanning different concerns,
|
|
836
|
+
commit before continuing. The `.claude/hooks/nudge-uncommitted.sh` hook
|
|
837
|
+
fires at threshold 4 to enforce this. Do not ignore the reminder.
|
|
838
|
+
- **Meaningful messages.** Imperative mood, what changed and why
|
|
839
|
+
(`Add contact form with email validation`, not `update files`).
|
|
840
|
+
- **NEVER add AI attribution.** No `Co-Authored-By: Claude`, no
|
|
841
|
+
`Generated by AI`, no `AI-assisted` trailers or prefixes.
|
|
842
|
+
- **Committing is automatic.** The user should never have to ask
|
|
843
|
+
"please commit". Commit after completing each logical unit.
|
|
844
|
+
|
|
845
|
+
**Branch rules:**
|
|
846
|
+
- **Feature branches.** Never commit directly to main
|
|
847
|
+
- **Branch naming.** `feature/<name>`, `fix/<name>`, `refactor/<name>`
|
|
848
|
+
- **Pull requests.** Always create a PR, never push to main directly
|
|
849
|
+
- **NEVER merge without user permission.** Before merging ANY branch
|
|
850
|
+
into ANY other branch, ask: "Ready to merge `<branch>` into `<target>`?
|
|
851
|
+
Delete or keep `<branch>` after?" Wait for approval AND the preference.
|
|
852
|
+
- **Claude Code hook** (`.claude/hooks/guard-main-merge.sh`) enforces
|
|
853
|
+
merge/push-to-main approval programmatically for Claude agents.
|
|
854
|
+
Other agents enforce this via `.cursorrules`, `.windsurfrules`,
|
|
855
|
+
`.github/copilot-instructions.md`.
|
|
856
|
+
|
|
857
|
+
**Pre-commit checks:**
|
|
858
|
+
- `webjs test` must pass
|
|
859
|
+
- `webjs check` must pass
|
|
860
|
+
- No unrelated files in the commit
|
|
861
|
+
|
|
862
|
+
---
|
|
863
|
+
|
|
864
|
+
## Overriding conventions
|
|
865
|
+
|
|
866
|
+
See the **"How `CONVENTIONS.md` relates to `webjs check`"** section at
|
|
867
|
+
the top of this file. Short version: set a rule to `false` in
|
|
868
|
+
`package.json` under `"webjs": { "conventions": { … } }`. With no
|
|
869
|
+
override, every default rule is on.
|
|
870
|
+
|
|
871
|
+
Run `webjs check` to validate. Run `webjs check --rules` to list every
|
|
872
|
+
rule with its description and current enabled state.
|
|
873
|
+
|
|
874
|
+
---
|
|
875
|
+
|
|
876
|
+
## Scaffold
|
|
877
|
+
|
|
878
|
+
Create new projects with `webjs create`:
|
|
879
|
+
|
|
880
|
+
```sh
|
|
881
|
+
webjs create <name> # full-stack (default)
|
|
882
|
+
webjs create <name> --template api # backend-only API
|
|
883
|
+
webjs create <name> --template saas # auth + dashboard + Prisma User model
|
|
884
|
+
```
|
|
885
|
+
|
|
886
|
+
**Route-wrapping pattern (especially for `--template api` apps):**
|
|
887
|
+
Routes are thin wrappers over typed server actions. Business logic lives in
|
|
888
|
+
`modules/`, routes just import and call the action/query:
|
|
889
|
+
|
|
890
|
+
```ts
|
|
891
|
+
// app/api/users/route.ts: thin wrapper
|
|
892
|
+
import { listUsers } from '../../../modules/users/queries/list-users.server.ts';
|
|
893
|
+
import { createUser } from '../../../modules/users/actions/create-user.server.ts';
|
|
894
|
+
|
|
895
|
+
export async function GET() { return Response.json(await listUsers()); }
|
|
896
|
+
export async function POST(req: Request) {
|
|
897
|
+
const result = await createUser(await req.json());
|
|
898
|
+
if (!result.success) return Response.json({ error: result.error }, { status: result.status });
|
|
899
|
+
return Response.json(result.data, { status: 201 });
|
|
900
|
+
}
|
|
901
|
+
```
|