@webjsdev/cli 0.10.40 → 0.10.41
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 +4 -46
- package/lib/create.js +282 -479
- package/lib/doctor.js +1 -38
- package/package.json +5 -1
- package/templates/.agents/rules/workflow.md +61 -271
- package/templates/.agents/skills/webjs/SKILL.md +226 -0
- package/templates/.agents/skills/webjs/references/auth-and-sessions.md +220 -0
- package/templates/.agents/skills/webjs/references/built-ins.md +200 -0
- package/templates/.agents/skills/webjs/references/client-router-and-streaming.md +204 -0
- package/templates/.agents/skills/webjs/references/components.md +167 -0
- package/templates/.agents/skills/webjs/references/data-and-actions.md +187 -0
- package/templates/.agents/skills/webjs/references/muscle-memory-gotchas.md +170 -0
- package/templates/.agents/skills/webjs/references/optimistic-ui.md +128 -0
- package/templates/.agents/skills/webjs/references/routing-and-pages.md +158 -0
- package/templates/.agents/skills/webjs/references/runtime.md +80 -0
- package/templates/.agents/skills/webjs/references/service-worker.md +78 -0
- package/templates/.agents/skills/webjs/references/styling.md +123 -0
- package/templates/.agents/skills/webjs/references/testing.md +125 -0
- package/templates/.agents/skills/webjs/references/typescript.md +148 -0
- package/templates/.claude/hooks/check-server-imports.mjs +1 -1
- package/templates/.claude/hooks/require-tests-with-src.sh +1 -1
- package/templates/.claude/settings.json +0 -14
- package/templates/.cursorrules +21 -189
- package/templates/.github/copilot-instructions.md +7 -185
- package/templates/.github/pull_request_template.md +1 -1
- package/templates/AGENTS.md +59 -1494
- package/templates/CLAUDE.md +0 -1
- package/templates/CONVENTIONS.md +32 -1383
- package/templates/GEMINI.md +11 -0
- package/templates/gallery/app/apple-icon.ts +0 -1
- package/templates/gallery/app/examples/todo/page.ts +0 -1
- package/templates/gallery/app/features/async-render/page.ts +0 -1
- package/templates/gallery/app/features/boundaries/page.ts +0 -1
- package/templates/gallery/app/features/broadcast/page.ts +0 -1
- package/templates/gallery/app/features/caching/page.ts +0 -1
- package/templates/gallery/app/features/client-router/page.ts +0 -1
- package/templates/gallery/app/features/client-router/second/page.ts +0 -1
- package/templates/gallery/app/features/components/page.ts +0 -1
- package/templates/gallery/app/features/directives/page.ts +0 -1
- package/templates/gallery/app/features/env/page.ts +0 -1
- package/templates/gallery/app/features/file-storage/page.ts +0 -1
- package/templates/gallery/app/features/forms/page.ts +0 -1
- package/templates/gallery/app/features/metadata/page.ts +0 -1
- package/templates/gallery/app/features/optimistic-ui/page.ts +0 -1
- package/templates/gallery/app/features/rate-limit/page.ts +0 -1
- package/templates/gallery/app/features/route-handler/page.ts +0 -1
- package/templates/gallery/app/features/routing/page.ts +0 -1
- package/templates/gallery/app/features/server-actions/page.ts +0 -1
- package/templates/gallery/app/features/service-worker/page.ts +0 -1
- package/templates/gallery/app/features/sessions/page.ts +0 -1
- package/templates/gallery/app/features/websockets/page.ts +0 -1
- package/templates/gallery/app/global-error.ts +0 -1
- package/templates/gallery/app/global-not-found.ts +0 -1
- package/templates/gallery/app/icon.ts +0 -1
- package/templates/gallery/app/manifest.ts +0 -1
- package/templates/gallery/app/opengraph-image.ts +0 -1
- package/templates/gallery/app/robots.ts +0 -1
- package/templates/gallery/app/sitemap.ts +0 -1
- package/templates/gallery/app/twitter-image.ts +0 -1
- package/templates/public/favicon.svg +5 -0
- package/templates/public/sw.js +1 -1
- package/templates/scripts/clear-gallery.mjs +95 -0
- package/lib/clear-placeholders.js +0 -98
- package/lib/design-bar.js +0 -67
- package/templates/.claude/hooks/design-review-before-stop.sh +0 -36
- package/templates/.claude/hooks/route-skills.sh +0 -35
- package/templates/.claude/skills/webjs-design-review/SKILL.md +0 -84
- package/templates/LAYOUT-REFERENCE.md +0 -96
- package/templates/lib/utils/ui.ts +0 -83
package/templates/AGENTS.md
CHANGED
|
@@ -1,1496 +1,61 @@
|
|
|
1
1
|
# AGENTS.md for {{APP_NAME}}
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
lives
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
`app/
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
app
|
|
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
|
-
**Non-negotiables for every webjs app:**
|
|
64
|
-
|
|
65
|
-
1. **Use Drizzle + SQLite for persistence.** It's already wired up
|
|
66
|
-
(`db/schema.server.ts`, `db/connection.server.ts`, `npm run db:generate`
|
|
67
|
-
+ `npm run db:migrate`). For any data the app stores (todos, posts,
|
|
68
|
-
messages, products, comments, anything), define a Drizzle table and
|
|
69
|
-
persist there.
|
|
70
|
-
- **NEVER** store app data in JSON files (`data/todos.json`,
|
|
71
|
-
`db.json`, …). It resets on reload and cannot scale. This is a project convention,
|
|
72
|
-
and the user's prompt explicitly forbids it.
|
|
73
|
-
- **NEVER** use in-memory arrays or `Map`s as a substitute for the
|
|
74
|
-
database. They vanish on every dev-server reload and aren't
|
|
75
|
-
shared across processes.
|
|
76
|
-
- **NEVER** use `localStorage` to persist app data. It's per-browser
|
|
77
|
-
and doesn't reach the server.
|
|
78
|
-
2. **One of three scaffolds only.** The CLI exposes exactly three:
|
|
79
|
-
`full-stack` (default), `--template api`, `--template saas`. Don't
|
|
80
|
-
reach for a `--template blog` / `--template todo` / `--template
|
|
81
|
-
ecommerce`. They don't exist and the CLI will reject them.
|
|
82
|
-
3. **First step after scaffolding:** edit `db/schema.server.ts` to the
|
|
83
|
-
app's real domain models (delete the example `User` model unless the
|
|
84
|
-
app actually needs users), run `webjs db generate` then
|
|
85
|
-
`webjs db migrate`, then build pages / actions / queries against them.
|
|
86
|
-
4. **Prune what the app does not use.** The scaffold is reference, so keep
|
|
87
|
-
the infrastructure the app USES and delete the rest, both files AND
|
|
88
|
-
folders. No persistence means delete `db/`, `drizzle.config.ts`, and
|
|
89
|
-
the `db:*` scripts. No UI kit used means delete `components/ui/`,
|
|
90
|
-
`components.json`, and `lib/utils/cn.ts`. No PWA means delete
|
|
91
|
-
`public/sw.js` and `offline.html`. Always KEEP the durable knowledge
|
|
92
|
-
(`AGENTS.md`, `CONVENTIONS.md`, the per-agent rule files, the MCP
|
|
93
|
-
wiring), never prune it, so removing example code never removes your
|
|
94
|
-
context. Prune AFTER you have used the features and examples as reference, never
|
|
95
|
-
blindly up front. This is a no-op for the `api` template (no UI kit,
|
|
96
|
-
no PWA files).
|
|
97
|
-
|
|
98
|
-
**Picking the right scaffold from the user's prompt** (you do this BEFORE
|
|
99
|
-
running `webjs create`; if you're reading this you've already scaffolded.
|
|
100
|
-
Verify the choice was correct, otherwise re-scaffold in a fresh dir):
|
|
101
|
-
|
|
102
|
-
| User asks for… | Scaffold |
|
|
103
|
-
|---|---|
|
|
104
|
-
| Todo app, blog, notes, dashboard, marketplace, social feed, e-commerce, any product with a UI | `webjs create <name>` (default full-stack) |
|
|
105
|
-
| HTTP/JSON API only, no UI | `webjs create <name> --template api` |
|
|
106
|
-
| Anything with login / signup / accounts / protected pages / SaaS | `webjs create <name> --template saas` |
|
|
107
|
-
|
|
108
|
-
When in doubt, **full-stack is the default**. Pick `api` only if the user
|
|
109
|
-
is explicit about wanting a backend-only API. Pick `saas` only if the user
|
|
110
|
-
is explicit about auth / accounts / SaaS.
|
|
111
|
-
|
|
112
|
-
## Framework source is in `node_modules/`
|
|
113
|
-
|
|
114
|
-
No build step, no bundler, no minification. What you read is what
|
|
115
|
-
runs. When in doubt, grep the framework:
|
|
116
|
-
|
|
117
|
-
```
|
|
118
|
-
node_modules/@webjsdev/
|
|
119
|
-
core/ renderer, WebComponent, directives, client router,
|
|
120
|
-
Task, context, testing helpers
|
|
121
|
-
src/component.js ← lifecycle, properties, light vs shadow DOM
|
|
122
|
-
src/render-client.js ← client-side DOM patching + hydration
|
|
123
|
-
src/render-server.js ← renderToString / renderToStream
|
|
124
|
-
src/router-client.js ← Turbo-Drive-style client navigation
|
|
125
|
-
src/directives.js ← unsafeHTML, live
|
|
126
|
-
src/context.js ← Context Protocol
|
|
127
|
-
src/task.js ← async data with states
|
|
128
|
-
server/ dev + prod server, SSR, file router, actions,
|
|
129
|
-
auth, sessions, cache, rate-limit, WebSocket
|
|
130
|
-
src/ssr.js ← how metadata becomes <head> tags
|
|
131
|
-
src/router.js ← file convention → route table
|
|
132
|
-
src/actions.js ← .server.ts scanner, RPC stubs, action endpoint
|
|
133
|
-
src/action-route.js ← route() adapter (action over REST via route.ts)
|
|
134
|
-
src/auth.js, session.js, cache.js, rate-limit.js, csrf.js
|
|
135
|
-
cli/ webjs CLI (dev / start / build / test / check / create / db)
|
|
136
|
-
intellisense/ tsserver plugin: go-to-definition + diagnostic suppression
|
|
137
|
-
+ attribute auto-complete for Class.register('tag') elements
|
|
138
|
-
```
|
|
139
|
-
|
|
140
|
-
Reaching straight for the source is the fastest way to resolve "why
|
|
141
|
-
doesn't X work?" with no documentation guesswork and no stale blog posts.
|
|
142
|
-
|
|
143
|
-
## Use the webjs MCP server (introspection + framework knowledge)
|
|
144
|
-
|
|
145
|
-
This project ships a **read-only Model Context Protocol server** that gives
|
|
146
|
-
you (the AI agent) live, version-accurate facts about THIS app and the
|
|
147
|
-
framework. Prefer it over guessing or recalling webjs from training data,
|
|
148
|
-
which drifts. It mutates nothing.
|
|
149
|
-
|
|
150
|
-
**It is already available, no install needed:** the webjs CLI (a project
|
|
151
|
-
dependency) has it built in as `webjs mcp`. It is an MCP STDIO server (JSON-RPC
|
|
152
|
-
over stdout), so you do not run it in a terminal and read its output. Your MCP
|
|
153
|
-
host (Claude Code, Cursor, etc.) launches it and surfaces its tools, then you
|
|
154
|
-
invoke those tools through the MCP protocol.
|
|
155
|
-
|
|
156
|
-
Claude Code is pre-wired (see `.claude.json`). For another host, register the
|
|
157
|
-
server by pointing it at the CLI (or the equivalent standalone package):
|
|
158
|
-
|
|
159
|
-
```jsonc
|
|
160
|
-
// Cursor: .cursor/mcp.json (or your host's MCP config)
|
|
161
|
-
{ "mcpServers": { "webjs": {
|
|
162
|
-
"command": "npx", "args": ["@webjsdev/cli", "mcp"] // the built-in CLI route
|
|
163
|
-
// equivalent: "command": "npx", "args": ["@webjsdev/mcp"]
|
|
164
|
-
} } }
|
|
165
|
-
```
|
|
166
|
-
|
|
167
|
-
What it serves:
|
|
168
|
-
|
|
169
|
-
- **Introspection of this app** (read-only, no module load, no DB side
|
|
170
|
-
effects): `list_routes` (the route table), `list_actions` (server actions
|
|
171
|
-
with their `/__webjs/action/<hash>/<fn>` RPC endpoints), `list_components`
|
|
172
|
-
(registered custom-element tags), `check` (the structured `webjs check`
|
|
173
|
-
violations). Use these to learn the real route/action/component surface
|
|
174
|
-
before editing, instead of grepping or assuming.
|
|
175
|
-
- **Framework knowledge**: an `init` primer (the read-first mental model +
|
|
176
|
-
invariants), a `docs` tool (retrieve a topic or search the `agent-docs`
|
|
177
|
-
corpus), MCP `resources` (the docs corpus + this AGENTS.md), recipe
|
|
178
|
-
`prompts` (guided page/route/action/component workflows), and a `source`
|
|
179
|
-
tool that reads the framework's OWN no-build source from
|
|
180
|
-
`node_modules/@webjsdev/*/src` (what actually runs).
|
|
181
|
-
|
|
182
|
-
You have TWO complementary ways to understand the framework, use whichever
|
|
183
|
-
helps (or both): (1) **grep the full framework source** under
|
|
184
|
-
`node_modules/@webjsdev/*/src`, which is the real no-build code that runs (no
|
|
185
|
-
sourcemaps, no guessing), and (2) **the MCP** for live app introspection plus
|
|
186
|
-
the curated `init` / `docs` / `source` knowledge tools. Reach for either before
|
|
187
|
-
guessing from training data or asking the user.
|
|
188
|
-
|
|
189
|
-
## Editor TS plugin: `@webjsdev/intellisense`
|
|
190
|
-
|
|
191
|
-
This scaffold's `tsconfig.json` lists a single tsserver plugin. It is
|
|
192
|
-
editor-only, not required for the framework to run.
|
|
193
|
-
|
|
194
|
-
```jsonc
|
|
195
|
-
// tsconfig.json (already wired by the scaffold)
|
|
196
|
-
"plugins": [
|
|
197
|
-
{ "name": "@webjsdev/intellisense" }
|
|
198
|
-
]
|
|
199
|
-
```
|
|
200
|
-
|
|
201
|
-
`@webjsdev/intellisense` is **standalone** (no Lit dependency): one plugin
|
|
202
|
-
entry, its own template parser. Inside `` html`…` `` templates you get:
|
|
203
|
-
|
|
204
|
-
- Go-to-definition on custom-element tags, attribute / property / event
|
|
205
|
-
names, and CSS classes in `class="…"`.
|
|
206
|
-
- Binding-aware completions: reachable tag names after `<`, and
|
|
207
|
-
prefix-keyed attributes (`.prop` property names, `?bool` / plain
|
|
208
|
-
hyphenated attribute names).
|
|
209
|
-
- Diagnostics: value type-checks against the reactive props declared in
|
|
210
|
-
`WebComponent({ ... })`, unquoted `@`/`.`/`?` bindings, and
|
|
211
|
-
expressionless `.prop` bindings.
|
|
212
|
-
- Hover showing the component class / declared member type.
|
|
213
|
-
|
|
214
|
-
In VS Code / Cursor / Windsurf, the **`webjs` extension** bundles this
|
|
215
|
-
automatically (no `tsconfig.json` edit, no separate Lit extension).
|
|
216
|
-
|
|
217
|
-
See [docs.webjs.dev → Editor setup](https://docs.webjs.dev/docs/editor-setup)
|
|
218
|
-
for the full walkthrough.
|
|
219
|
-
|
|
220
|
-
**Config validation in `package.json`.** The scaffold ships
|
|
221
|
-
`.vscode/settings.json`, which associates the published webjs-config JSON
|
|
222
|
-
Schema (`@webjsdev/server/webjs-config.schema.json`) with the `webjs` block
|
|
223
|
-
of `package.json`. In VS Code an unknown / typo'd `webjs.*` key (`redirect`
|
|
224
|
-
for `redirects`, say) is then flagged inline instead of silently dropped to
|
|
225
|
-
the default. The same shape is typed by the `WebjsConfig` type from
|
|
226
|
-
`@webjsdev/core` (`import type { WebjsConfig } from '@webjsdev/core'`) for a
|
|
227
|
-
typed reference.
|
|
228
|
-
|
|
229
|
-
## UI components: Webjs UI (preinstalled)
|
|
230
|
-
|
|
231
|
-
This scaffold ships with the standard Webjs UI component kit
|
|
232
|
-
**already installed at `components/ui/`**. The kit is **AI-first** and
|
|
233
|
-
splits into two tiers. Internalise the split. Picking the wrong tier
|
|
234
|
-
produces broken markup.
|
|
235
|
-
|
|
236
|
-
### Tier 1: class-helper functions (the majority)
|
|
237
|
-
|
|
238
|
-
Pure functions that return Tailwind class strings. You apply them to
|
|
239
|
-
**raw native HTML elements** that you write yourself. Examples:
|
|
240
|
-
`button`, `card`, `input`, `label`, `alert`, `badge`, `separator`,
|
|
241
|
-
`skeleton`, `kbd`, `table`, `breadcrumb`, `pagination`, `native-select`,
|
|
242
|
-
`avatar`, `checkbox`, `switch`, `radio-group`, `textarea`, `toggle`,
|
|
243
|
-
`aspect-ratio`.
|
|
244
|
-
|
|
245
|
-
```ts
|
|
246
|
-
import {
|
|
247
|
-
cardClass, cardHeaderClass, cardTitleClass,
|
|
248
|
-
cardContentClass, cardFooterClass,
|
|
249
|
-
} from '#components/ui/card.ts';
|
|
250
|
-
import { inputClass } from '#components/ui/input.ts';
|
|
251
|
-
import { labelClass } from '#components/ui/label.ts';
|
|
252
|
-
import { buttonClass } from '#components/ui/button.ts';
|
|
253
|
-
|
|
254
|
-
return html`
|
|
255
|
-
<div class=${cardClass()}>
|
|
256
|
-
<div class=${cardHeaderClass()}>
|
|
257
|
-
<h3 class=${cardTitleClass()}>Profile</h3>
|
|
258
|
-
</div>
|
|
259
|
-
<div class=${cardContentClass()}>
|
|
260
|
-
<label class=${labelClass()} for="name">Name</label>
|
|
261
|
-
<input class=${inputClass()} id="name" name="name">
|
|
262
|
-
</div>
|
|
263
|
-
<div class=${cardFooterClass()}>
|
|
264
|
-
<button class=${buttonClass()}>Save</button>
|
|
265
|
-
</div>
|
|
266
|
-
</div>
|
|
267
|
-
`;
|
|
268
|
-
```
|
|
269
|
-
|
|
270
|
-
Helpers with variants take an options object:
|
|
271
|
-
`buttonClass({ variant: 'outline', size: 'sm' })`.
|
|
272
|
-
|
|
273
|
-
### Tier 2: stateful custom elements
|
|
274
|
-
|
|
275
|
-
For things the browser doesn't provide natively (focus traps, portaled
|
|
276
|
-
overlays, keyboard-navigated lists): `dialog`, `alert-dialog`, `popover`,
|
|
277
|
-
`tooltip`, `hover-card`, `tabs`, `accordion`, `collapsible`,
|
|
278
|
-
`dropdown-menu`, `progress`, `sonner`, `toggle-group`. These ARE custom
|
|
279
|
-
elements. Import them once (typically in `app/layout.ts`) and use
|
|
280
|
-
`<ui-X>` tags:
|
|
281
|
-
|
|
282
|
-
```ts
|
|
283
|
-
// app/layout.ts (registers the custom elements for every page)
|
|
284
|
-
import '#components/ui/dialog.ts';
|
|
285
|
-
import '#components/ui/tabs.ts';
|
|
286
|
-
```
|
|
287
|
-
|
|
288
|
-
```ts
|
|
289
|
-
// app/some-page/page.ts (uses the registered elements)
|
|
290
|
-
import { buttonClass } from '#components/ui/button.ts';
|
|
291
|
-
|
|
292
|
-
return html`
|
|
293
|
-
<ui-dialog>
|
|
294
|
-
<ui-dialog-trigger>
|
|
295
|
-
<button class=${buttonClass({ variant: 'outline' })}>Edit</button>
|
|
296
|
-
</ui-dialog-trigger>
|
|
297
|
-
<ui-dialog-content>
|
|
298
|
-
<h2>Edit profile</h2>
|
|
299
|
-
...
|
|
300
|
-
</ui-dialog-content>
|
|
301
|
-
</ui-dialog>
|
|
302
|
-
`;
|
|
303
|
-
```
|
|
304
|
-
|
|
305
|
-
### Adding more components
|
|
306
|
-
|
|
307
|
-
```sh
|
|
308
|
-
webjs ui add dialog dropdown-menu tabs progress
|
|
309
|
-
```
|
|
310
|
-
|
|
311
|
-
Each `webjs ui add` call fetches the component source from
|
|
312
|
-
`https://ui.webjs.dev/registry/<name>.json`, copies it into
|
|
313
|
-
`components/ui/`, and installs any required npm deps. Run
|
|
314
|
-
`webjs ui list` to browse the catalogue or visit
|
|
315
|
-
[https://ui.webjs.dev](https://ui.webjs.dev).
|
|
316
|
-
|
|
317
|
-
### AI agents, picking the right tier
|
|
318
|
-
|
|
319
|
-
For forms, dashboards, settings pages, marketing layouts: **call the
|
|
320
|
-
Tier-1 class helpers on raw native elements**. You get accessibility,
|
|
321
|
-
visual consistency, and form submission semantics for free.
|
|
322
|
-
`<input class=${inputClass()}>` is a real `<input>` with native
|
|
323
|
-
autofill, browser validation, and `<form>` submission unchanged.
|
|
324
|
-
|
|
325
|
-
Because Tier-1 helpers wrap *real* HTML elements, a `buttonClass()`
|
|
326
|
-
button inside a `<form action="/posts" method="post">` participates
|
|
327
|
-
in the client router's partial-swap submission automatically. No JS
|
|
328
|
-
handler, no `fetch`. See *Client navigation patterns* below for the
|
|
329
|
-
full form-submission + 4xx-HTML-render-in-place pattern.
|
|
330
|
-
|
|
331
|
-
For modals, dropdowns, tooltips, tab strips, accordions: use the
|
|
332
|
-
Tier-2 `<ui-X>` custom element tags after importing the corresponding
|
|
333
|
-
module.
|
|
334
|
-
|
|
335
|
-
The composition style is deliberately **not** shadcn's
|
|
336
|
-
component-everything React API. We use native elements + class helpers
|
|
337
|
-
for the visual stuff because hiding a `<button>` inside a `<Button>`
|
|
338
|
-
wrapper adds zero value and obscures the real element from inspection,
|
|
339
|
-
form submission, and screen readers. Custom elements are reserved for
|
|
340
|
-
behavior the browser can't deliver natively.
|
|
341
|
-
|
|
342
|
-
### Accessible control labeling
|
|
343
|
-
|
|
344
|
-
Give every interactive control an accessible name, and make clickable
|
|
345
|
-
text a `<label for="control-id">` (or the control itself) so a text click
|
|
346
|
-
activates the control on BOTH the JS path and the no-JS form-submit path.
|
|
347
|
-
Use `aria-label` and `aria-pressed` on icon-only controls (a toggle
|
|
348
|
-
button, an icon-only close/menu button). A native `<input>` under a
|
|
349
|
-
`<label>` gets this for free, which is another reason to reach for the
|
|
350
|
-
Tier-1 class helpers on real elements. In a browser test,
|
|
351
|
-
`assertNoA11yViolations(el)` from `@webjsdev/core/testing` catches
|
|
352
|
-
missing labels.
|
|
353
|
-
|
|
354
|
-
## File conventions
|
|
355
|
-
|
|
356
|
-
```
|
|
357
|
-
app/ ROUTING ONLY: thin route adapters (import from modules/).
|
|
358
|
-
No CSS, helpers, or constants here; those live in
|
|
359
|
-
styles/, lib/utils/, and modules/. globals.css is at
|
|
360
|
-
styles/, NOT app/.
|
|
361
|
-
page.ts → / (the scaffold home links to the gallery)
|
|
362
|
-
features/<name>/ single-feature demos (routing, boundaries, components,
|
|
363
|
-
server-actions, optimistic-ui, async-render,
|
|
364
|
-
directives, route-handler, forms, metadata, caching,
|
|
365
|
-
env, client-router, service-worker); prune what you skip
|
|
366
|
-
examples/<name>/ whole example apps that compose features (todo);
|
|
367
|
-
prune what you skip
|
|
368
|
-
layout.ts root layout, wraps every page
|
|
369
|
-
error.ts error boundary (render failures → user-friendly)
|
|
370
|
-
loading.ts Suspense fallback for sibling page
|
|
371
|
-
not-found.ts custom 404 page (nearest wins on notFound())
|
|
372
|
-
forbidden.ts 403 page (nearest wins on forbidden())
|
|
373
|
-
unauthorized.ts 401 page (nearest wins on unauthorized())
|
|
374
|
-
global-error.ts root-only app-wide error boundary (owns its <html>)
|
|
375
|
-
global-not-found.ts root-only 404 for an unmatched-anywhere URL
|
|
376
|
-
middleware.ts global request middleware
|
|
377
|
-
[slug]/page.ts dynamic route segment
|
|
378
|
-
[...rest]/page.ts catch-all
|
|
379
|
-
(group)/ route group (parens not in URL)
|
|
380
|
-
_private/ underscore = not routable
|
|
381
|
-
api/
|
|
382
|
-
<path>/route.ts GET / POST / PUT / DELETE / WS handlers
|
|
383
|
-
sitemap.ts metadata route → /sitemap.xml
|
|
384
|
-
robots.ts metadata route → /robots.txt
|
|
385
|
-
opengraph-image.ts metadata route → /opengraph-image
|
|
386
|
-
components/ web components (extend WebComponent, call .register())
|
|
387
|
-
modules/<feature>/
|
|
388
|
-
actions/*.server.ts server actions (one function per file)
|
|
389
|
-
queries/*.server.ts data reads (one function per file)
|
|
390
|
-
components/*.ts feature-scoped components
|
|
391
|
-
utils/*.ts feature-scoped helpers
|
|
392
|
-
types.ts feature types
|
|
393
|
-
lib/
|
|
394
|
-
... cross-cutting infra (session, auth config, etc.)
|
|
395
|
-
styles/
|
|
396
|
-
globals.css @webjsdev/ui theme tokens (NOT in app/; app/ is routing-only)
|
|
397
|
-
db/
|
|
398
|
-
schema.server.ts Drizzle models + relations (your data layer)
|
|
399
|
-
columns.server.ts column helpers (dialect-specific; the only file to swap for Postgres)
|
|
400
|
-
connection.server.ts opens the driver, exports the \`db\` singleton (import \`db\` from here)
|
|
401
|
-
seed.server.ts optional seed (run via \`webjs db seed\`)
|
|
402
|
-
dev.db SQLite file (gitignored); created when migrations apply (\`dev\`/\`start\` run \`webjs db migrate\`)
|
|
403
|
-
migrations/ generated migration SQL (committed)
|
|
404
|
-
drizzle.config.ts drizzle-kit config (root; SQLite by default, --db postgres to switch)
|
|
405
|
-
public/ static assets at /public/* (favicon, sw.js, offline.html serve at root)
|
|
406
|
-
test/<feature>/ feature-scoped tests, one folder per concern
|
|
407
|
-
<name>.test.ts node unit / integration test (node --test)
|
|
408
|
-
browser/<name>.test.js real-browser test (web-test-runner); may ALSO be
|
|
409
|
-
co-located next to a component, e.g.
|
|
410
|
-
modules/<feature>/components/browser/<name>.test.js
|
|
411
|
-
(see the gallery counter-card test for the idioms:
|
|
412
|
-
suite/test, ssrFixture, inline assert, no chai)
|
|
413
|
-
e2e/<name>.test.ts end-to-end test (full app boot, opt in via WEBJS_E2E=1)
|
|
414
|
-
smoke/<name>.test.ts fast post-deploy sanity check
|
|
415
|
-
middleware.ts root middleware (optional, outermost)
|
|
416
|
-
instrumentation.ts optional boot hook: register() runs once; wire APM via setOnError
|
|
417
|
-
instrumentation-client.ts optional client boot hook, runs first before app modules
|
|
418
|
-
```
|
|
419
|
-
|
|
420
|
-
### The gallery (reference content, prune it)
|
|
421
|
-
|
|
422
|
-
The scaffold ships a gallery organized by KIND, so features and whole apps are
|
|
423
|
-
not mixed:
|
|
424
|
-
- `app/features/<name>/` are single-feature demos, one webjs concept each
|
|
425
|
-
(routing, boundaries, components, server-actions, optimistic-ui, async-render,
|
|
426
|
-
directives, route-handler, forms, metadata, caching, env, client-router,
|
|
427
|
-
service-worker, plus the infra demos websockets, file-storage, rate-limit,
|
|
428
|
-
broadcast).
|
|
429
|
-
- `app/examples/<name>/` are whole example apps that compose several features
|
|
430
|
-
(todo: optimistic UI + progressive enhancement + a11y + db + modules).
|
|
431
|
-
|
|
432
|
-
Both keep their logic in `modules/<name>/`. Each route is small, idiomatic, and
|
|
433
|
-
heavily commented, and the gallery is your PRIMARY reference for how webjs works.
|
|
434
|
-
|
|
435
|
-
**Study the whole gallery FIRST, prune SECOND.** Before you write or delete
|
|
436
|
-
anything, read every feature demo and the example app end to end (the code AND
|
|
437
|
-
the comments) to absorb the idioms you will reuse: the modules split, signals,
|
|
438
|
-
the `optimistic()` API, `async render()`, the `.server.ts` vs `'use server'`
|
|
439
|
-
boundary, progressive-enhancement forms, `<label for>` a11y, dynamic routes, and
|
|
440
|
-
`route.ts` handlers. Only AFTER you have internalised the patterns should you
|
|
441
|
-
prune. Never delete the examples blindly up front (that throws away your context
|
|
442
|
-
before you have read it), and never prune the durable knowledge surfaces
|
|
443
|
-
(`AGENTS.md`, `CONVENTIONS.md`, the per-agent rule files), which stay as context
|
|
444
|
-
for every future iteration.
|
|
445
|
-
|
|
446
|
-
Then prune: the examples are REFERENCE, not your app, so keep and adapt the ones
|
|
447
|
-
you need and delete the rest. Pruning a route means deleting its
|
|
448
|
-
`app/features/<name>` or `app/examples/<name>` folder AND its `modules/<name>`
|
|
449
|
-
folder (and, for the todo app, the `todos` table in `db/schema.server.ts`), then
|
|
450
|
-
removing its link from `app/page.ts`. Each route page carries a
|
|
451
|
-
`webjs-scaffold-placeholder` marker so `webjs check` fails until you have
|
|
452
|
-
consciously kept-and-adapted or pruned it. After pruning, delete any now-empty
|
|
453
|
-
directories (an empty `lib/utils/` or `modules/<name>/` is leftover scaffolding,
|
|
454
|
-
not structure).
|
|
455
|
-
|
|
456
|
-
### Typed page / layout / route-handler props
|
|
457
|
-
|
|
458
|
-
Type page / layout / route-handler arguments with the exported helpers so a
|
|
459
|
-
param typo is a compile-time error:
|
|
460
|
-
|
|
461
|
-
```ts
|
|
462
|
-
import type { PageProps, LayoutProps, RouteHandlerContext } from '@webjsdev/core';
|
|
463
|
-
|
|
464
|
-
export default function Post({ params }: PageProps<'/blog/[slug]'>) {
|
|
465
|
-
return html`<h1>${params.slug}</h1>`; // params typed { slug: string }
|
|
466
|
-
}
|
|
467
|
-
export default function RootLayout({ children }: LayoutProps) { /* ... */ }
|
|
468
|
-
export async function GET(req: Request, ctx: RouteHandlerContext) { /* ctx.params */ }
|
|
469
|
-
```
|
|
470
|
-
|
|
471
|
-
Run `webjs types` once (and ensure `tsconfig.json` `include` lists
|
|
472
|
-
`.webjs/routes.d.ts`, the scaffold already does) to generate the route union:
|
|
473
|
-
`PageProps<'/blog/[slug]'>['params']` then narrows to `{ slug: string }` and
|
|
474
|
-
`navigate()` only accepts real app routes. `webjs dev` regenerates the file on
|
|
475
|
-
startup, so it stays current. Without it, `params` is `Record<string, string>`
|
|
476
|
-
and `navigate()` accepts any string (non-breaking).
|
|
477
|
-
|
|
478
|
-
## Database (Drizzle + SQLite by default)
|
|
479
|
-
|
|
480
|
-
Every scaffold includes a Drizzle setup pointed at a local SQLite file,
|
|
481
|
-
under a `db/` folder (`schema.server.ts`, `columns.server.ts`,
|
|
482
|
-
`connection.server.ts`). Drizzle has no codegen and no engine binary.
|
|
483
|
-
First-run workflow:
|
|
484
|
-
|
|
485
|
-
```sh
|
|
486
|
-
cp .env.example .env # DATABASE_URL is pre-filled for SQLite
|
|
487
|
-
npm run db:generate # schema -> SQL migration (drizzle-kit)
|
|
488
|
-
npm run db:migrate # apply it (creates db/dev.db)
|
|
489
|
-
npm run dev # webjs dev, then serves
|
|
490
|
-
```
|
|
491
|
-
|
|
492
|
-
### `npm run dev` / `npm start` and `webjs dev` / `webjs start` behave identically
|
|
493
|
-
|
|
494
|
-
`npm run dev` and `npm start` are the documented entrypoints, and they
|
|
495
|
-
are thin aliases for `webjs dev` / `webjs start`. The start orchestration
|
|
496
|
-
(applying migrations, and compiling Tailwind) lives in the `webjs` block
|
|
497
|
-
of `package.json` and runs INSIDE `webjs dev` / `webjs start`:
|
|
498
|
-
|
|
499
|
-
```jsonc
|
|
500
|
-
"webjs": {
|
|
501
|
-
"dev": {
|
|
502
|
-
"before": ["webjs db migrate", "tailwindcss -i ./public/input.css -o ./public/tailwind.css --minify"],
|
|
503
|
-
"regenerate": [
|
|
504
|
-
{ "output": "public/tailwind.css",
|
|
505
|
-
"command": "tailwindcss -i ./public/input.css -o ./public/tailwind.css --minify",
|
|
506
|
-
"inputs": ["app", "components", "modules", "lib", "public/input.css"] }
|
|
507
|
-
]
|
|
508
|
-
},
|
|
509
|
-
"start": { "before": ["webjs db migrate", "tailwindcss -i ./public/input.css -o ./public/tailwind.css --minify"] }
|
|
510
|
-
}
|
|
511
|
-
```
|
|
512
|
-
|
|
513
|
-
Both `dev` and `start` apply pending migrations via `webjs db migrate`
|
|
514
|
-
(idempotent, a no-op when the db is current) and compile the static
|
|
515
|
-
`public/tailwind.css`, so a freshly generated migration is applied and
|
|
516
|
-
the app is fully styled with no manual step. In dev the stylesheet is
|
|
517
|
-
then kept fresh by `webjs.dev.regenerate`: the dev server recompiles it
|
|
518
|
-
ON REQUEST whenever a source changes, so a newly added utility class is
|
|
519
|
-
never served stale and there is no `tailwindcss --watch` process that can
|
|
520
|
-
die mid-session (`webjs.dev.parallel` still exists for a genuinely
|
|
521
|
-
long-lived side process, torn down on exit).
|
|
522
|
-
`before` steps run to completion first; a failed `webjs db migrate`
|
|
523
|
-
aborts the boot with a clear message rather than serving a stale schema.
|
|
524
|
-
|
|
525
|
-
In Docker / Railway, `CMD ["npm", "start"]` and `CMD ["webjs", "start"]`
|
|
526
|
-
are equivalent: `webjs start` runs `webjs.start.before` (`webjs db
|
|
527
|
-
migrate`) in-process before serving, so the migrate no longer depends on
|
|
528
|
-
an npm `prestart` hook.
|
|
529
|
-
|
|
530
|
-
### Running on Bun instead of Node
|
|
531
|
-
|
|
532
|
-
WebJs runs on **Node 24+ or Bun**. The same `package.json` scripts work on
|
|
533
|
-
either; to run under Bun, force it with `--bun` so the server executes on Bun
|
|
534
|
-
rather than the `webjs` bin's Node shebang:
|
|
535
|
-
|
|
536
|
-
```sh
|
|
537
|
-
bun install
|
|
538
|
-
bun --bun run dev # or: bun --bun run start
|
|
539
|
-
```
|
|
540
|
-
|
|
541
|
-
On Node the `.ts` type-stripping is the built-in `module.stripTypeScriptTypes`;
|
|
542
|
-
on Bun (which has no built-in) it comes from `amaro` automatically, so the same
|
|
543
|
-
source serves identically. SSR action-result seeding (an internal hydration
|
|
544
|
-
optimization) works on both runtimes: Node installs it via `module.registerHooks`,
|
|
545
|
-
Bun via a `Bun.plugin` `onLoad`, so an async-render component does not re-fetch
|
|
546
|
-
on hydration on either runtime.
|
|
547
|
-
|
|
548
|
-
**Containerized deploy ships with the scaffold.** `Dockerfile`,
|
|
549
|
-
`compose.yaml`, and `.dockerignore` are scaffolded at the app root. The
|
|
550
|
-
Dockerfile pins `node:24-alpine` (the same Node major CI uses), installs
|
|
551
|
-
deps (no build step, since Drizzle has no codegen), and starts via
|
|
552
|
-
`npm start` (`webjs start` runs `webjs.start.before` = `webjs db migrate`
|
|
553
|
-
before serving). Run it locally with `docker compose up --build` (the
|
|
554
|
-
app comes up on http://localhost:8080 against a SQLite file on a named
|
|
555
|
-
volume). For production, point `DATABASE_URL` at managed Postgres and set
|
|
556
|
-
`AUTH_SECRET`. The `.dockerignore` keeps the `.webjs/vendor/` importmap in
|
|
557
|
-
the image while excluding `node_modules`, tests, and local state.
|
|
558
|
-
|
|
559
|
-
**Health and readiness probes.** Every webjs server answers two endpoints:
|
|
560
|
-
`/__webjs/health` (liveness, 200 once the process is listening) and
|
|
561
|
-
`/__webjs/ready` (readiness, 503 until the instance is fully warm, then 200).
|
|
562
|
-
Fully warm means the deterministic analysis AND the first vendor attempt have
|
|
563
|
-
both completed, so the importmap and its build id are settled. Point your
|
|
564
|
-
platform's readiness check at `/__webjs/ready` so it holds traffic off a
|
|
565
|
-
not-yet-warmed instance instead of routing the first user request into the cold
|
|
566
|
-
analysis or the brief window where the importmap is still resolving. The
|
|
567
|
-
scaffolded `Dockerfile` and `compose.yaml` already wire this up with a
|
|
568
|
-
`HEALTHCHECK` that probes `/__webjs/ready`, so any Docker-based deploy gets the
|
|
569
|
-
gate with no extra config. On a platform that reads its own config instead,
|
|
570
|
-
point its equivalent knob at the same path: Railway `"healthcheckPath":
|
|
571
|
-
"/__webjs/ready"`, Render `healthCheckPath: /__webjs/ready`, Fly a
|
|
572
|
-
`[[http_service.checks]]` on `/__webjs/ready`, or a Kubernetes `readinessProbe`
|
|
573
|
-
with `httpGet.path: /__webjs/ready`. For dependency-aware readiness (gate on a
|
|
574
|
-
live DB ping), add an optional `readiness.{js,ts}` at the app root that
|
|
575
|
-
default-exports an async check; `/__webjs/ready` runs it once warm and reports
|
|
576
|
-
503 if it returns `false` or throws.
|
|
577
|
-
|
|
578
|
-
Scripts (all wrap `drizzle-kit`):
|
|
579
|
-
|
|
580
|
-
- `npm run db:generate`: `webjs db generate` (schema -> SQL migration)
|
|
581
|
-
- `npm run db:migrate`: `webjs db migrate` (apply pending migrations)
|
|
582
|
-
- `npm run db:push`: `webjs db push` (push the schema straight to the dev DB)
|
|
583
|
-
- `npm run db:studio`: `webjs db studio` (visual DB browser)
|
|
584
|
-
- `npm run db:seed`: `webjs db seed` (run `db/seed.server.ts`)
|
|
585
|
-
- `webjs.dev.before` and `webjs.start.before` both run `webjs db migrate` inside `webjs dev` / `webjs start` (idempotent; replaces the old `prestart` hook), so after you `db:generate` a migration it is applied on the next boot with no manual `db:migrate` step.
|
|
586
|
-
- The INITIAL migration for the shipped schema is authored by `webjs create` at setup time (right after install), so `db/migrations/` is populated and the first `run dev` works with no manual database step. You run `db:generate` yourself only when you CHANGE `db/schema.server.ts` (a new table or column), then the next `run dev` applies it.
|
|
587
|
-
|
|
588
|
-
Always import `db` from `db/connection.server.ts` (the globalThis-cached
|
|
589
|
-
singleton avoids opening a new connection on every dev-server reload), and
|
|
590
|
-
the tables from `db/schema.server.ts`:
|
|
591
|
-
|
|
592
|
-
```ts
|
|
593
|
-
import { db } from '#db/connection.server.ts';
|
|
594
|
-
const users = await db.query.users.findMany();
|
|
595
|
-
```
|
|
596
|
-
|
|
597
|
-
To switch to Postgres: scaffold with `--db postgres`, or swap
|
|
598
|
-
`db/columns.server.ts` + `db/connection.server.ts` for the Postgres
|
|
599
|
-
variants and point `DATABASE_URL` at Postgres. The schema, queries, and
|
|
600
|
-
actions are unchanged.
|
|
601
|
-
|
|
602
|
-
## NPM packages (vendor pipeline)
|
|
603
|
-
|
|
604
|
-
Adding a third-party npm package follows the same `npm install` flow
|
|
605
|
-
as any Node project, with one webjs-specific concern: how the BROWSER
|
|
606
|
-
fetches that package.
|
|
607
|
-
|
|
608
|
-
```sh
|
|
609
|
-
npm install dayjs # standard npm install
|
|
610
|
-
```
|
|
611
|
-
|
|
612
|
-
Now write `import dayjs from 'dayjs'` in any component or page. The
|
|
613
|
-
import works in dev immediately. webjs's scanner discovers bare
|
|
614
|
-
imports on the first request (memoized for the process) and asks
|
|
615
|
-
`api.jspm.io` to resolve them to CDN URLs (jspm.io serves pre-bundled
|
|
616
|
-
ESM for every npm package). The browser fetches the bundle directly
|
|
617
|
-
from `https://ga.jspm.io`.
|
|
618
|
-
|
|
619
|
-
**For production deploys**, run `webjs vendor pin` once and commit
|
|
620
|
-
the result:
|
|
621
|
-
|
|
622
|
-
```sh
|
|
623
|
-
webjs vendor pin # writes .webjs/vendor/importmap.json
|
|
624
|
-
git add .webjs/vendor/
|
|
625
|
-
git commit -m "vendor dayjs"
|
|
626
|
-
```
|
|
627
|
-
|
|
628
|
-
The pin file holds the resolved jspm.io URLs. Server reads it from
|
|
629
|
-
disk on the first request (memoized); no `api.jspm.io` call needed in
|
|
630
|
-
production. Deterministic across deploys.
|
|
631
|
-
|
|
632
|
-
**For offline-capable / strict-CSP production**, use `--download`:
|
|
633
|
-
|
|
634
|
-
```sh
|
|
635
|
-
webjs vendor pin --download # also vendors bundle bytes locally
|
|
636
|
-
git add .webjs/vendor/
|
|
637
|
-
git commit -m "vendor + download dayjs"
|
|
638
|
-
```
|
|
639
|
-
|
|
640
|
-
Bundle files land in `.webjs/vendor/<pkg>@<version>.js`. importmap
|
|
641
|
-
points at local `/__webjs/vendor/` paths. Browser fetches from your
|
|
642
|
-
own origin. Suitable for `script-src 'self'` CSP, air-gapped deploys,
|
|
643
|
-
or compliance environments. See [docs.webjs.dev Deployment → CSP](https://docs.webjs.dev/docs/deployment#csp).
|
|
644
|
-
|
|
645
|
-
**Other CLI commands:**
|
|
646
|
-
|
|
647
|
-
```sh
|
|
648
|
-
webjs vendor list # show pinned packages with versions
|
|
649
|
-
webjs vendor unpin <pkg> # remove one entry from pin file
|
|
650
|
-
webjs vendor audit # npm security advisories against pinned versions
|
|
651
|
-
webjs vendor outdated # list pinned packages with newer versions on npm
|
|
652
|
-
webjs vendor update # re-pin every outdated package to its latest
|
|
653
|
-
|
|
654
|
-
# Switch CDN at pin time (default: jspm.io). Resolver options:
|
|
655
|
-
# jspm, jsdelivr, unpkg, skypack. Useful for jspm.io incident response.
|
|
656
|
-
webjs vendor pin --from jsdelivr
|
|
657
|
-
webjs vendor update --from jsdelivr
|
|
658
|
-
```
|
|
659
|
-
|
|
660
|
-
Same posture as Rails 7 + importmap-rails: explicit pin command,
|
|
661
|
-
committed manifest, optional `--download` for full offline capability,
|
|
662
|
-
and a `--from` knob to swap the resolver CDN if jspm.io has an
|
|
663
|
-
incident.
|
|
664
|
-
|
|
665
|
-
**Don't auto-run `webjs vendor pin` in a `webjs.dev.before` / `webjs.start.before`
|
|
666
|
-
step.** Auto-pin would silently churn the committed importmap.json as jspm.io
|
|
667
|
-
resolves URLs or transitive deps drift. Pin is a deliberate developer action,
|
|
668
|
-
like `npm install` itself.
|
|
669
|
-
|
|
670
|
-
**Do NOT modify the `.webjs/` lines in `.gitignore` / `.dockerignore`.**
|
|
671
|
-
The scaffolded `.gitignore` pattern is three lines (`**/.webjs/*` +
|
|
672
|
-
`!**/.webjs/vendor/` + `!**/.webjs/vendor/**`) and is structurally
|
|
673
|
-
load-bearing. Collapsing it to a single `.webjs/` excludes the parent
|
|
674
|
-
directory; once the parent is excluded, git cannot re-include
|
|
675
|
-
`.webjs/vendor/` via a child negation (gitignore semantics: parent
|
|
676
|
-
exclusion blocks child negations). The breakage is invisible: `webjs
|
|
677
|
-
vendor pin` runs, writes files, and git silently ignores them.
|
|
678
|
-
Production then has no importmap.json and the server falls back to
|
|
679
|
-
calling api.jspm.io on every cold start. The `**/` prefix matters too:
|
|
680
|
-
it ignores `.webjs/` at any depth, so an app nested below its repo root
|
|
681
|
-
(a monorepo package) does not leak its generated `.webjs/routes.d.ts`
|
|
682
|
-
into `git status`. The `vendor-gitignore` check (`webjs doctor`)
|
|
683
|
-
verifies the pattern with `git check-ignore` and warns if it regresses
|
|
684
|
-
(it is a project-config / setup concern, not a source-code-correctness
|
|
685
|
-
CI gate).
|
|
686
|
-
|
|
687
|
-
## Imports
|
|
688
|
-
|
|
689
|
-
```ts
|
|
690
|
-
import { html, css, WebComponent } from '@webjsdev/core';
|
|
691
|
-
import { unsafeHTML, live } from '@webjsdev/core/directives';
|
|
692
|
-
import { createContext } from '@webjsdev/core/context';
|
|
693
|
-
import { Task } from '@webjsdev/core/task';
|
|
694
|
-
import { fixture, ssrFixture, waitForUpdate, assertNoA11yViolations } from '@webjsdev/core/testing';
|
|
695
|
-
|
|
696
|
-
import { rateLimit, cors, cache, createAuth, Credentials, Session } from '@webjsdev/server';
|
|
697
|
-
```
|
|
698
|
-
|
|
699
|
-
## Environment variables (server vs browser)
|
|
700
|
-
|
|
701
|
-
Server-only is the default. Any `process.env.X` read on the server stays on the server. Names that start with `WEBJS_PUBLIC_` are also exposed in the browser as `process.env.X`, via an inline script injected at SSR time. No build step.
|
|
702
|
-
|
|
703
|
-
```sh
|
|
704
|
-
# .env
|
|
705
|
-
DATABASE_URL=postgres://... # server-only
|
|
706
|
-
AUTH_SECRET=... # server-only
|
|
707
|
-
WEBJS_PUBLIC_API_URL=https://x.com # browser too
|
|
708
|
-
```
|
|
709
|
-
|
|
710
|
-
```ts
|
|
711
|
-
// Server-side (page function, action, middleware, route handler):
|
|
712
|
-
const dburl = process.env.DATABASE_URL; // works
|
|
713
|
-
|
|
714
|
-
// Browser-side (component render method, client-only utilities):
|
|
715
|
-
const url = process.env.WEBJS_PUBLIC_API_URL; // works
|
|
716
|
-
const secret = process.env.AUTH_SECRET; // undefined (fail-closed)
|
|
717
|
-
```
|
|
718
|
-
|
|
719
|
-
`process.env.NODE_ENV` is also defined in the browser (`'development'` in `webjs dev`, `'production'` in `webjs start`), so vendor bundles that probe it work without setup. Full docs: [Configuration](https://docs.webjs.dev/docs/configuration).
|
|
720
|
-
|
|
721
|
-
## Component pattern
|
|
722
|
-
|
|
723
|
-
```ts
|
|
724
|
-
import { WebComponent, html, css } from '@webjsdev/core';
|
|
725
|
-
|
|
726
|
-
// Recommended declare-free base-class factory style
|
|
727
|
-
export class Counter extends WebComponent({
|
|
728
|
-
count: Number
|
|
729
|
-
}) {
|
|
730
|
-
static styles = css`button { padding: 8px 12px; }`; // shadow-DOM only
|
|
731
|
-
// static shadow = true; // opt into shadow DOM (default: light DOM)
|
|
732
|
-
// static lazy = true; // download JS only when scrolled into view
|
|
733
|
-
|
|
734
|
-
constructor() {
|
|
735
|
-
super();
|
|
736
|
-
this.count = 0; // SSR-meaningful default, see below
|
|
737
|
-
}
|
|
738
|
-
|
|
739
|
-
render() {
|
|
740
|
-
return html`
|
|
741
|
-
<button @click=${() => { this.count = this.count + 1; }}>
|
|
742
|
-
${this.count}
|
|
743
|
-
</button>
|
|
744
|
-
`;
|
|
745
|
-
}
|
|
746
|
-
}
|
|
747
|
-
Counter.register('my-counter');
|
|
748
|
-
```
|
|
749
|
-
|
|
750
|
-
**Progressive-enhancement rule for components.** Every webjs component
|
|
751
|
-
is SSR'd. The server constructs the component, applies attributes,
|
|
752
|
-
and runs `render()`. With JS disabled, the component's initial HTML
|
|
753
|
-
still paints (an unstyled counter still shows the number, and only
|
|
754
|
-
the click handler is inert). Two consequences for how you write code:
|
|
755
|
-
|
|
756
|
-
1. **Defaults for the first paint go in `constructor()`** (after
|
|
757
|
-
`super()`), never as class-field initializers (which break
|
|
758
|
-
reactivity) and never in `connectedCallback` (which the server
|
|
759
|
-
doesn't run). For reactive properties declared via the
|
|
760
|
-
`WebComponent({ ... })` factory, set the default in the constructor
|
|
761
|
-
after `super()`.
|
|
762
|
-
2. **`connectedCallback` is browser-only.** Use it for
|
|
763
|
-
`localStorage`, viewport size, online status, or anything that
|
|
764
|
-
genuinely can't be known on the server. Read the value, then
|
|
765
|
-
assign it to a reactive property (`this.items = stored`) or write
|
|
766
|
-
to a signal to refine the render. The SSR'd first paint shows the
|
|
767
|
-
constructor default. The browser refines after hydration.
|
|
768
|
-
3. **Server-known data goes through the page function**, not into
|
|
769
|
-
`connectedCallback`. Fetch in the page (which runs on the server),
|
|
770
|
-
pass the result down via `.prop=${value}` (custom elements) or
|
|
771
|
-
`attr=${string}` (native elements). For custom elements, the wire
|
|
772
|
-
serializer round-trips Array / Object / Date / Map / Set / BigInt
|
|
773
|
-
through the SSR `data-webjs-prop-*` side-channel, so the
|
|
774
|
-
component's first paint already has the rich-typed value with no
|
|
775
|
-
flash. The framework owns the attribute, applies it on
|
|
776
|
-
`connectedCallback`, then strips it from the live DOM. For native
|
|
777
|
-
elements use `value=${v}` / `checked=${b}` etc.; `.value` on a
|
|
778
|
-
native element drops at SSR (the property form is for client-only
|
|
779
|
-
re-render scenarios like controlled inputs via `.value=${live(v)}`).
|
|
780
|
-
4. **For write-paths, prefer `<form>` + server action over `fetch`.**
|
|
781
|
-
Plain forms POST without JS; the client router upgrades them to
|
|
782
|
-
partial-swaps automatically when scripts are active. One
|
|
783
|
-
implementation covers both.
|
|
784
|
-
|
|
785
|
-
See [Progressive Enhancement](https://docs.webjs.dev/docs/progressive-enhancement) for the full design rationale.
|
|
786
|
-
|
|
787
|
-
## Lit muscle-memory gotchas (read if you have written lit before)
|
|
788
|
-
|
|
789
|
-
Webjs's runtime API matches lit. The `WebComponent` base class,
|
|
790
|
-
reactive properties (declared via the `WebComponent({ ... })` factory),
|
|
791
|
-
the lifecycle hooks, ReactiveControllers, the
|
|
792
|
-
directive set, `html` / `css` tagged templates. The **rendering
|
|
793
|
-
model**, however, is different. Pure-lit patterns that work fine in a
|
|
794
|
-
client-only lit app break in webjs's SSR pipeline or its reactivity
|
|
795
|
-
system. Read this section before reaching for lit idioms.
|
|
796
|
-
|
|
797
|
-
### Mental model. JS opt-in per behavior, not per component
|
|
798
|
-
|
|
799
|
-
Lit hydrates per component. You decide at the component boundary
|
|
800
|
-
whether JS ships and runs for that island.
|
|
801
|
-
|
|
802
|
-
Webjs ships JS per **interactive behavior**, not per component. Every
|
|
803
|
-
component is server-rendered. JavaScript is requested by the specific
|
|
804
|
-
holes you write in the template.
|
|
805
|
-
|
|
806
|
-
- `@click=${...}`, `@input=${...}`, any event binding requests JS.
|
|
807
|
-
- A reactive property assignment (`this.count = …`) or a signal
|
|
808
|
-
`set()` that the component reads requests JS for reactive updates.
|
|
809
|
-
- `.prop=${richObject}` requests JS for property hydration.
|
|
810
|
-
- A controller like `Task` requests JS for that async behavior.
|
|
811
|
-
- A plain `<a href>`, a `<form action method>` submission, or a
|
|
812
|
-
purely display-time component (no event listeners, no property
|
|
813
|
-
mutations, no signal subscriptions, no property bindings) does
|
|
814
|
-
**not** request JS.
|
|
815
|
-
|
|
816
|
-
A single component can mix both. A product card with server-rendered
|
|
817
|
-
title, price, image, plus a "View" link (no JS) and an "Add to cart"
|
|
818
|
-
button with a `@click` (JS for that one behavior) is correct webjs
|
|
819
|
-
style. The framework loads JS for the component because of the
|
|
820
|
-
`@click` and runs it, while the rest of the card stays exactly as the
|
|
821
|
-
server painted it.
|
|
822
|
-
|
|
823
|
-
Practical consequences for agents writing webjs code.
|
|
824
|
-
|
|
825
|
-
1. Never reach for `fetch()` plus a `@click` handler when a `<form>`
|
|
826
|
-
plus a server action would do. The form is free (no JS), the
|
|
827
|
-
server action is typed and CSRF-protected, the result reaches the
|
|
828
|
-
page through normal navigation.
|
|
829
|
-
2. Never make first paint depend on hydration. A blank skeleton until
|
|
830
|
-
JS runs means the feature was written wrong.
|
|
831
|
-
3. Don't think binary about "static vs interactive components." Pick
|
|
832
|
-
interactive primitives per behavior. A page with ten components
|
|
833
|
-
can ship zero JS for eight of them and handlers only for the two
|
|
834
|
-
that need it.
|
|
835
|
-
|
|
836
|
-
### Gotchas at a glance
|
|
837
|
-
|
|
838
|
-
| Lit pattern | What breaks in webjs | Webjs equivalent |
|
|
839
|
-
|---|---|---|
|
|
840
|
-
| Fetch in `connectedCallback` / `firstUpdated` | Empty first paint (neither hook runs in SSR) | Fetch in the page function, pass as props |
|
|
841
|
-
| `Task` for initial-paint data | SSR ships the pending state, flashes to resolved on hydration | Page function fetch, pass as props, OR an `async render()` in the component (`Task` is fine for client-time async) |
|
|
842
|
-
| Expecting a sync `render()` only | webjs allows `async render() { const d = await getData(); ... }`; SSR bakes the data into the first paint | Use it for request-time server data; `renderFallback()` is the re-fetch loading UI (never first paint); error isolation is automatic |
|
|
843
|
-
| Assuming an `async render()` always ships its module | A bare one (no other client signal) is ELIDED, so it costs zero JS and skips the on-hydration re-fetch, first paint unchanged | Rely on it for a fetch-and-display leaf. `static interactive = true` forces a ship the analyser would otherwise elide, `static shadow = true` always ships |
|
|
844
|
-
| `window.X` / `document.X` in constructor or `render()` | SSR crash | Move to `connectedCallback` |
|
|
845
|
-
| Top-level `import` of a browser-only library | SSR crash | Dynamic `import()` inside `connectedCallback` |
|
|
846
|
-
| Class-field initializer for a reactive property (`student: Student = {...}`) | Silently breaks reactivity (overwrites the framework accessor) | Pass the shape to the base-class factory `WebComponent({ student: Object })` and set the default in the constructor (flagged by `reactive-props-no-class-field`) |
|
|
847
|
-
| `@property()` decorator | Banned by invariant 10 (erasable TS) | Pass the shape to the base-class factory `WebComponent({ ... })` (the only supported form) |
|
|
848
|
-
| Hand-written `static properties = { ... }` | Throws at construction (the factory owns property setup) | Pass the same shape to the base-class factory `WebComponent({ ... })` (flagged by `no-static-properties`) |
|
|
849
|
-
| Array-typed prop declared with `Object` (`items: prop<Tag[]>(Object)`) | Works (Object and Array share one JSON converter), but misstates the prop's shape | Pass the `Array` constructor (`items: prop<Tag[]>(Array)`), flagged by `array-prop-uses-array-type` |
|
|
850
|
-
| Extending raw `HTMLElement` directly | Bypasses SSR, reactive properties, elision, and lifecycle hooks; keeps the component from being elided | Always subclass `WebComponent` (or the factory form `WebComponent({...})`) |
|
|
851
|
-
| Module-scope client work in a `page.ts` / `layout.ts` (a top-level call, a `window` / `document` / `customElements` access, a `@webjsdev/core/client-router` import), or importing a client-global-touching non-component util into one | The page/layout module stops being a droppable carrier and SHIPS its own JS to the browser (it shows up in the network tab); invisible in tests because it is an elision verdict, not a behaviour change | Keep pages/layouts pure carriers (their only browser job is registering the components they import; routing is automatic). Put client behaviour in a component, server-only code in `.server.{js,ts}`. Self-check: `page.ts` / `layout.ts` should not appear in the network tab |
|
|
852
|
-
| Scoped `static styles = css` or an inline `<style>` with semantic class names (`.hero`, `.card`) in a light-DOM component | Scoped block does nothing without `static shadow = true`; inline `<style>` class names leak globally | Tailwind utilities (the light-DOM default); or `static shadow = true` for genuinely scoped CSS |
|
|
853
|
-
| `willUpdate` computing SSR-visible derived state | Works (runs at SSR), but overriding it opts the component out of elision | Fine for interactive components; for display-only, derive inline in `render()` |
|
|
854
|
-
| `this.hasAttribute` / `getAttribute` in `render()` | Works (server attribute shim backs the attribute methods at SSR) | Read attributes directly, or declare a reactive prop via the base-class factory `WebComponent({ ... })` |
|
|
855
|
-
| `ContextProvider` for server-known data | Default value during SSR, content shift on hydration | Pass via props from the page function |
|
|
856
|
-
|
|
857
|
-
The full annotated catalog with code examples lives in the framework
|
|
858
|
-
repo at
|
|
859
|
-
[`agent-docs/lit-muscle-memory-gotchas.md`](https://github.com/webjsdev/webjs/blob/main/agent-docs/lit-muscle-memory-gotchas.md).
|
|
860
|
-
|
|
861
|
-
### Styling: Tailwind-first (the most common lit reflex to unlearn)
|
|
862
|
-
|
|
863
|
-
**Tailwind utilities are the strong default for pages AND light-DOM
|
|
864
|
-
components (the default DOM mode).** Use them for layout, spacing, color
|
|
865
|
-
(via the `@theme` tokens), typography, borders, radius, shadows, and
|
|
866
|
-
interaction states (hover/focus/active/disabled, dark mode). Light DOM
|
|
867
|
-
does not scope styles, so utilities apply directly.
|
|
868
|
-
|
|
869
|
-
The lit habit is to scope CSS in a shadow root (`static styles =
|
|
870
|
-
css\`\``) or write an inline `<style>` with semantic class names
|
|
871
|
-
(`.hero`, `.card`). In a light-DOM webjs component the scoped block does
|
|
872
|
-
nothing without `static shadow = true`, and the inline class names leak
|
|
873
|
-
globally. Prefer Tailwind. When a utility bundle repeats, extract it into
|
|
874
|
-
a `lib/utils/ui.ts` helper returning an `` html`...` `` fragment, not a
|
|
875
|
-
CSS class.
|
|
876
|
-
|
|
877
|
-
#### Design tokens: ONE theme, shadcn-canonical
|
|
878
|
-
|
|
879
|
-
The app has a SINGLE theme, defined once in `app/layout.ts`. It uses the
|
|
880
|
-
standard `@webjsdev/ui` (shadcn-compatible) semantic tokens, set to this app's
|
|
881
|
-
brand palette. Use the canonical utility names everywhere, in the page chrome
|
|
882
|
-
AND inside components: `bg-background`, `text-foreground`, `bg-card`, `bg-muted`,
|
|
883
|
-
`text-muted-foreground`, `bg-primary`, `text-primary-foreground`, `bg-accent`,
|
|
884
|
-
`text-accent-foreground`, `border-border`, `ring-ring`. These are exactly the
|
|
885
|
-
tokens the components copied in by `webjs ui add <name>` read, so a scaffolded
|
|
886
|
-
page and a later-added ui component share one coherent theme automatically.
|
|
887
|
-
|
|
888
|
-
- **Never invent a parallel token vocabulary** (`--fg`, `--bg`, `text-fg`,
|
|
889
|
-
`bg-elev`, a separate `--brand`). It collides with the ui tokens (the accent
|
|
890
|
-
once flipped to neutral on navigation for exactly this reason) and diverges
|
|
891
|
-
from the shadcn conventions the ui kit and AI agents both expect.
|
|
892
|
-
- **Reach for opacity modifiers before a new token**: `bg-primary/10` for a
|
|
893
|
-
tint, `hover:bg-primary/90` for a hover, `text-muted-foreground/70` for a
|
|
894
|
-
subtler text level.
|
|
895
|
-
- **Edit the palette in one place** (`app/layout.ts`). To ADD a token, do it
|
|
896
|
-
the canonical way: a `--x` variable in the `:root` / `.dark` blocks plus a
|
|
897
|
-
`--color-x: var(--x)` line in the `@theme inline` block, then use it as
|
|
898
|
-
`bg-x` / `text-x`.
|
|
899
|
-
- Dark mode is a `.dark` class the theme toggle sets. Tokens switch by theme
|
|
900
|
-
automatically, so a component written with these names works in both.
|
|
901
|
-
|
|
902
|
-
Reserve raw CSS for what utilities cannot express: design-token `:root` /
|
|
903
|
-
`@theme` definitions, `@property` + `@keyframes` animations,
|
|
904
|
-
`::-webkit-scrollbar`, `prefers-reduced-motion` blocks, and complex
|
|
905
|
-
`color-mix()` / gradient effects. When custom CSS is unavoidable in a
|
|
906
|
-
light-DOM component, prefix every class selector with the component tag
|
|
907
|
-
(invariant below). Shadow-DOM components (`static shadow = true`)
|
|
908
|
-
legitimately use `static styles = css\`\`` for scoped CSS.
|
|
909
|
-
|
|
910
|
-
## Server action pattern
|
|
911
|
-
|
|
912
|
-
**The `.server.ts` vs `'use server'` decision, in one question.** Will the
|
|
913
|
-
client call it? Add `'use server'` and the file becomes an RPC action
|
|
914
|
-
(the browser import is rewritten to a typed stub). Is it server-only
|
|
915
|
-
infra instead (a DB driver, secrets, `node:*`)? Use NO directive, and
|
|
916
|
-
never import it into a page, layout, or component. Reach it from a
|
|
917
|
-
`'use server'` action, a `route.ts` handler, or `middleware.ts`. A
|
|
918
|
-
`.server.ts` file WITHOUT the directive is a server-only utility whose
|
|
919
|
-
browser import throws at module load (invariant 2 below), so a
|
|
920
|
-
page/component that imports it directly crashes on the client.
|
|
921
|
-
|
|
922
|
-
```ts
|
|
923
|
-
// modules/posts/actions/create-post.server.ts
|
|
924
|
-
'use server';
|
|
925
|
-
import { db } from '#db/connection.server.ts';
|
|
926
|
-
import { posts } from '#db/schema.server.ts';
|
|
927
|
-
|
|
928
|
-
export async function createPost(input: { title: string; body: string }) {
|
|
929
|
-
if (!input.title) return { success: false, error: 'title required', status: 400 };
|
|
930
|
-
const [post] = await db.insert(posts).values(input).returning();
|
|
931
|
-
return { success: true, data: post };
|
|
932
|
-
}
|
|
933
|
-
```
|
|
934
|
-
|
|
935
|
-
Import it from a client component. The framework rewrites it into a
|
|
936
|
-
type-safe RPC stub automatically.
|
|
937
|
-
|
|
938
|
-
A server action is a POST by default, but reserved sibling exports change its
|
|
939
|
-
HTTP semantics without changing the call site (`await getUser(7)` stays the
|
|
940
|
-
same): `export const method = 'GET'` (a read; rides args in the URL, CSRF-exempt,
|
|
941
|
-
cacheable), `export const cache = 60` + `export const tags` (GET response
|
|
942
|
-
caching), `export const invalidates` (a mutation's tags to evict), `export const
|
|
943
|
-
middleware` (a per-action chain, `actionContext()`), and `export const validate`
|
|
944
|
-
(the boundary validator). One callable function per configured file. An action
|
|
945
|
-
that RETURNS a `ReadableStream` / async generator streams its chunks (consume
|
|
946
|
-
with `for await`); read the request `AbortSignal` via `actionSignal()` to cancel
|
|
947
|
-
on disconnect. **SAFETY:** a `cache` with `public: true` shares one response
|
|
948
|
-
across all users, so use it only for data identical for every visitor. Full
|
|
949
|
-
reference: https://docs.webjs.dev/docs/server-actions
|
|
950
|
-
|
|
951
|
-
## Mutations: default to optimistic UI
|
|
952
|
-
|
|
953
|
-
Default to optimistic UI for every feasible mutation. Use `optimistic()`
|
|
954
|
-
from `@webjsdev/core` (create, toggle, like, follow, reorder, rename,
|
|
955
|
-
status change) so the UI updates instantly and rolls back automatically
|
|
956
|
-
on failure. The declarative form queues an update on a component with
|
|
957
|
-
auto-release when the action promise settles, no hand-written try-catch,
|
|
958
|
-
cache-and-restore, or temp-id bookkeeping.
|
|
959
|
-
|
|
960
|
-
```ts
|
|
961
|
-
import { WebComponent, prop, optimistic, html } from '@webjsdev/core';
|
|
962
|
-
import { createTodo } from '#modules/todos/actions/create-todo.server.ts';
|
|
963
|
-
|
|
964
|
-
class TodoList extends WebComponent({ todos: prop<Todo[]>(Array) }) {
|
|
965
|
-
private optimisticTodos = optimistic(this, {
|
|
966
|
-
source: () => this.todos,
|
|
967
|
-
update: (state, title: string) => [...state, { title, pending: true }],
|
|
968
|
-
});
|
|
969
|
-
async handleSubmit(title: string) {
|
|
970
|
-
const promise = createTodo({ title });
|
|
971
|
-
this.optimisticTodos.add(title, promise); // auto-releases on settle
|
|
972
|
-
await promise;
|
|
973
|
-
}
|
|
974
|
-
render() {
|
|
975
|
-
return html`<ul>${this.optimisticTodos.value.map(t => html`
|
|
976
|
-
<li class=${t.pending ? 'opacity-50' : ''}>${t.title}</li>`)}</ul>`;
|
|
977
|
-
}
|
|
978
|
-
}
|
|
979
|
-
```
|
|
980
|
-
|
|
981
|
-
Do NOT use optimistic UI where it hurts: unpredictable or server-computed
|
|
982
|
-
results (AI output, server-assigned values the client cannot guess),
|
|
983
|
-
side-effectful mutations the user must wait on (payment, email, OAuth),
|
|
984
|
-
and destructive irreversible actions (a confirm-first UX is better). Full
|
|
985
|
-
reference: https://docs.webjs.com/docs/optimistic-ui
|
|
986
|
-
|
|
987
|
-
## Client navigation patterns (auto-magic)
|
|
988
|
-
|
|
989
|
-
The client router enables itself automatically: it turns on whenever
|
|
990
|
-
`@webjsdev/core` loads in the browser, which happens on any page that
|
|
991
|
-
ships a component, so there is no import to add. **Every `<a href>` and
|
|
992
|
-
`<form action>` on the page is enhanced into a partial-swap navigation
|
|
993
|
-
or submission automatically**. You don't call a router API. Write
|
|
994
|
-
standard HTML; the swap happens.
|
|
995
|
-
|
|
996
|
-
What this changes for how you write apps:
|
|
997
|
-
|
|
998
|
-
### 1. Put shared chrome in `layout.ts`, not in every page
|
|
999
|
-
|
|
1000
|
-
When you navigate from `/posts` to `/posts/123`, the framework swaps
|
|
1001
|
-
only the deepest layout's `${'${children}'}` slot. Outer layouts stay
|
|
1002
|
-
mounted. The sidenav's scroll position, an open `<details>`, a focused
|
|
1003
|
-
input, and an inflight `<video>` are all preserved across the navigation
|
|
1004
|
-
without you writing any code.
|
|
1005
|
-
|
|
1006
|
-
The rule: anything that should persist across navigations within a
|
|
1007
|
-
section lives in that section's `layout.ts`. Page-specific content
|
|
1008
|
-
lives in `page.ts`. Don't duplicate a sidenav into every page.
|
|
1009
|
-
|
|
1010
|
-
### 2. Forms POST through `<form action>` (no `fetch` for write-paths)
|
|
1011
|
-
|
|
1012
|
-
A `<form action=${'${createPost}'} method="post">` works as a plain
|
|
1013
|
-
HTML form when JS is disabled and as a partial-swap submission when JS
|
|
1014
|
-
is active. **The same form covers both paths.** Don't reach for
|
|
1015
|
-
`fetch` + a click handler unless you genuinely need to.
|
|
1016
|
-
|
|
1017
|
-
### 3. Server-side validation: re-render the form with errors
|
|
1018
|
-
|
|
1019
|
-
The router applies any `text/html` response to the DOM regardless of
|
|
1020
|
-
status code (4xx, 422, etc.). This is the Rails / Django / Phoenix
|
|
1021
|
-
server-side validation pattern. Pair a `<form action="/posts" method="post">`
|
|
1022
|
-
with a `route.ts` POST handler:
|
|
1023
|
-
|
|
1024
|
-
```ts
|
|
1025
|
-
// app/posts/route.ts
|
|
1026
|
-
import { redirect, html } from '@webjsdev/core';
|
|
1027
|
-
import { createPost } from '#modules/posts/actions/create-post.server.ts';
|
|
1028
|
-
|
|
1029
|
-
export async function POST(req: Request) {
|
|
1030
|
-
const form = await req.formData();
|
|
1031
|
-
const result = await createPost({
|
|
1032
|
-
title: String(form.get('title') ?? ''),
|
|
1033
|
-
body: String(form.get('body') ?? ''),
|
|
1034
|
-
});
|
|
1035
|
-
if (!result.success) {
|
|
1036
|
-
// Re-render the form page with the user's input + inline errors.
|
|
1037
|
-
// The client router applies this HTML in place, no full reload.
|
|
1038
|
-
return new Response(renderNewPostForm(result.errors, form), {
|
|
1039
|
-
status: 422,
|
|
1040
|
-
headers: { 'content-type': 'text/html; charset=utf-8' },
|
|
1041
|
-
});
|
|
1042
|
-
}
|
|
1043
|
-
// Success → PRG redirect; fetch follows, history records /posts/<id>
|
|
1044
|
-
redirect(`/posts/${result.data.id}`);
|
|
1045
|
-
}
|
|
1046
|
-
```
|
|
1047
|
-
|
|
1048
|
-
```html
|
|
1049
|
-
<!-- The form: standard HTML, no JS handler needed -->
|
|
1050
|
-
<form action="/posts" method="post">
|
|
1051
|
-
<input name="title" required />
|
|
1052
|
-
<textarea name="body" required></textarea>
|
|
1053
|
-
<button>Publish</button>
|
|
1054
|
-
</form>
|
|
1055
|
-
```
|
|
1056
|
-
|
|
1057
|
-
With JS active: router intercepts the submit, sends the POST, applies
|
|
1058
|
-
the response in place (2xx + redirect for success, 4xx HTML for
|
|
1059
|
-
errors). With JS disabled: browser performs the same POST as a normal
|
|
1060
|
-
form submission and renders the response page. Same code, both paths.
|
|
1061
|
-
|
|
1062
|
-
(For RPC-style server actions that return typed values to client
|
|
1063
|
-
components. See *Server action pattern* above. The HTML-form pattern
|
|
1064
|
-
here is for the "submit → server processes → render new page" flow.)
|
|
1065
|
-
|
|
1066
|
-
### 4. `<webjs-frame id="...">` for non-layout swap regions
|
|
1067
|
-
|
|
1068
|
-
`<webjs-frame>` is webjs's take on **Turbo Frames** (Hotwire Turbo), so
|
|
1069
|
-
`<turbo-frame>` muscle memory transfers directly: a lazy, URL-addressable
|
|
1070
|
-
region that swaps on its own, driven by a link/form targeting its id. Use it
|
|
1071
|
-
for a region that loads or refreshes INDEPENDENTLY of a full navigation
|
|
1072
|
-
(a self-refreshing widget, a `loading="lazy"` below-the-fold region, a
|
|
1073
|
-
URL-addressable panel); it ships zero component JS. Its route can itself use
|
|
1074
|
-
`<webjs-suspense>` so a lazy frame's slow data streams in behind a fallback.
|
|
1075
|
-
|
|
1076
|
-
For a widget that should swap on click but isn't a route boundary
|
|
1077
|
-
(e.g. a tab strip inside a page), wrap it:
|
|
1078
|
-
|
|
1079
|
-
```ts
|
|
1080
|
-
return html`
|
|
1081
|
-
<nav>
|
|
1082
|
-
<a href=${'${path + "?tab=overview"}'}>Overview</a>
|
|
1083
|
-
<a href=${'${path + "?tab=stats"}'}>Stats</a>
|
|
1084
|
-
</nav>
|
|
1085
|
-
<webjs-frame id="tab-content">
|
|
1086
|
-
${'${tab === "stats" ? renderStats() : renderOverview()}'}
|
|
1087
|
-
</webjs-frame>
|
|
1088
|
-
`;
|
|
1089
|
-
```
|
|
1090
|
-
|
|
1091
|
-
The router's `closest('webjs-frame')` detection takes precedence over
|
|
1092
|
-
layout markers. Only the frame's content swaps. Use this sparingly,
|
|
1093
|
-
folder-based layouts handle 99% of cases.
|
|
1094
|
-
|
|
1095
|
-
**External targeting + `_top` (Turbo-style).** A trigger does not have to be
|
|
1096
|
-
nested in the frame it drives. An `<a>` or `<form>` (or any ancestor)
|
|
1097
|
-
carrying `data-webjs-frame="<id>"` drives the frame with that id from
|
|
1098
|
-
anywhere (an external sidebar/nav link, a filter form), resolved via
|
|
1099
|
-
`getElementById`. The reserved token `data-webjs-frame="_top"` on a trigger
|
|
1100
|
-
INSIDE a frame breaks OUT to a full-page navigation. An id that does not
|
|
1101
|
-
resolve to a live `<webjs-frame>` warns once and falls back to a normal nav
|
|
1102
|
-
(never throws). With JS disabled a `data-webjs-frame` link is an inert
|
|
1103
|
-
attribute on a plain `<a href>`, so the click is a normal full navigation.
|
|
1104
|
-
|
|
1105
|
-
**Busy state.** While a frame nav is in flight the router sets the native
|
|
1106
|
-
`aria-busy="true"` on the frame (cleared to `"false"` on any exit: success,
|
|
1107
|
-
error, abort, or a missing frame), so AT announces it and CSS can style
|
|
1108
|
-
`webjs-frame[aria-busy="true"]`. It also dispatches a bubbling
|
|
1109
|
-
`webjs:frame-busy` event on the frame at start and finish (detail
|
|
1110
|
-
`{ frameId, busy }`).
|
|
1111
|
-
|
|
1112
|
-
**Self-loading (`src` + `loading`).** A frame can fetch its OWN content:
|
|
1113
|
-
`<webjs-frame id="comments" src="/posts/42/comments" loading="lazy">` self-fetches
|
|
1114
|
-
that URL as a frame nav and applies the matching `<webjs-frame id>` subtree into
|
|
1115
|
-
itself, through the same frame-swap path (so the busy lifecycle + navigation-error
|
|
1116
|
-
recovery + frame-missing fallback all apply). `loading="eager"` (or absent)
|
|
1117
|
-
fetches on connect; `loading="lazy"` fetches on viewport entry. The request sends
|
|
1118
|
-
the `x-webjs-frame` header, so the SERVER returns ONLY the matched subtree (not
|
|
1119
|
-
the full page), falling back to the full page when the frame is absent. A `src` is
|
|
1120
|
-
JS-DEPENDENT (the browser does not natively fetch a `<webjs-frame src>`), so with
|
|
1121
|
-
JS off the frame shows only the children rendered into it; use it for DEFERRED
|
|
1122
|
-
content (comments, a recommendations rail) where a no-JS placeholder is fine, and
|
|
1123
|
-
render content server-side into the frame when it must exist without JS.
|
|
1124
|
-
|
|
1125
|
-
**View Transitions + persistent elements (opt-in).** Add
|
|
1126
|
-
`<meta name="view-transition" content="same-origin">` to the page head and the
|
|
1127
|
-
router wraps every swap (the layout-marker swap, the `<webjs-frame>` swap, and
|
|
1128
|
-
the full-body fallback) in `document.startViewTransition` for an animated
|
|
1129
|
-
crossfade. OFF by default (no animation surprise); a browser without the API
|
|
1130
|
-
falls back to the identical synchronous swap. To keep a live element running
|
|
1131
|
-
across a navigation (a playing `<audio>` / `<video>`, a map, a stateful
|
|
1132
|
-
widget), mark it `data-webjs-permanent` AND give it an `id`: the router keeps
|
|
1133
|
-
the SAME DOM node by identity across the swap instead of recreating it (Turbo's
|
|
1134
|
-
permanent-element behaviour). Inert with JS off.
|
|
1135
|
-
|
|
1136
|
-
When a frame nav's response lacks the matching `<webjs-frame id>` (e.g. an
|
|
1137
|
-
auth redirect), the router fires a cancelable, bubbling `webjs:frame-missing`
|
|
1138
|
-
event (detail `{ frameId, url, document }`) and leaves the frame unchanged
|
|
1139
|
-
rather than silently swapping the whole page; call `preventDefault()` to take
|
|
1140
|
-
over the outcome (e.g. `location.assign(e.detail.url)`).
|
|
1141
|
-
|
|
1142
|
-
### 5. Stream actions for surgical element-level updates
|
|
1143
|
-
|
|
1144
|
-
`<webjs-stream>` is webjs's take on **Turbo Streams** (Hotwire Turbo); the
|
|
1145
|
-
action set (`append` / `prepend` / `before` / `after` / `replace` / `update` /
|
|
1146
|
-
`remove`) mirrors `<turbo-stream>`, so that muscle memory transfers directly.
|
|
1147
|
-
It is the ONLY surgical single-element update primitive AND the live-channel
|
|
1148
|
-
applier (`connectWS` / `broadcast` -> `renderStream`); a region swap or a
|
|
1149
|
-
`<webjs-frame>` reload redraws a whole region, so reach for `<webjs-stream>`
|
|
1150
|
-
when only one element changes.
|
|
1151
|
-
|
|
1152
|
-
When a region swap is too coarse (append ONE comment, remove ONE row, bump a
|
|
1153
|
-
count, insert a toast), a server response can declare per-element actions as
|
|
1154
|
-
plain HTML, a `<webjs-stream action target>` wrapping one `<template>`:
|
|
1155
|
-
|
|
1156
|
-
```html
|
|
1157
|
-
<webjs-stream action="append" target="comments">
|
|
1158
|
-
<template><li>Nice post!</li></template>
|
|
1159
|
-
</webjs-stream>
|
|
1160
|
-
```
|
|
1161
|
-
|
|
1162
|
-
Actions (Turbo's set): `append` / `prepend` (last / first child of the target
|
|
1163
|
-
id), `before` / `after` (sibling), `replace` (the target element), `update`
|
|
1164
|
-
(its children), `remove` (delete it). The `<webjs-stream>` element self-applies
|
|
1165
|
-
on connect and removes itself. ONE applier serves two paths:
|
|
1166
|
-
|
|
1167
|
-
- **A content-negotiated `<form>`.** The router adds `Accept:
|
|
1168
|
-
text/vnd.webjs-stream.html` on a JS-driven submission, so the server returns a
|
|
1169
|
-
stream only then (apply it surgically) and a JS-OFF form gets a normal
|
|
1170
|
-
render/redirect. Additive and progressive-enhancement-safe.
|
|
1171
|
-
- **A live channel.** `renderStream(message)` from a `connectWS` handler applies
|
|
1172
|
-
a `broadcast()`ed payload, so chat / notifications reuse the same applier.
|
|
1173
|
-
|
|
1174
|
-
Build the payload server-side and apply it client-side:
|
|
1175
|
-
|
|
1176
|
-
```ts
|
|
1177
|
-
// app/posts/[id]/route.ts
|
|
1178
|
-
import { stream, streamResponse, acceptsStream, broadcast } from '@webjsdev/server';
|
|
1179
|
-
export async function POST(req: Request, { params }) {
|
|
1180
|
-
const c = await addComment(params.id, await req.formData());
|
|
1181
|
-
const html = stream.append('comments', `<li>${escapeHtml(c.text)}</li>`);
|
|
1182
|
-
broadcast(`post:${params.id}`, html); // fan out to other viewers
|
|
1183
|
-
if (acceptsStream(req)) return streamResponse(html); // JS client: surgical
|
|
1184
|
-
return Response.redirect(`/posts/${params.id}`, 303); // no-JS: normal render
|
|
1185
|
-
}
|
|
1186
|
-
```
|
|
1187
|
-
|
|
1188
|
-
```ts
|
|
1189
|
-
// a component, for the live channel
|
|
1190
|
-
import { connectWS, renderStream } from '@webjsdev/core';
|
|
1191
|
-
connectWS(`/posts/${id}/feed`, { onMessage: (m) => renderStream(m) });
|
|
1192
|
-
```
|
|
1193
|
-
|
|
1194
|
-
`stream.*` escapes the target id but NOT the content (server-authored HTML, like
|
|
1195
|
-
an `html` hole, so escape any user substring yourself). `renderStream` is
|
|
1196
|
-
auto-registered by the client router.
|
|
1197
|
-
|
|
1198
|
-
**Failed navigations recover in place, never a destructive full reload.** A
|
|
1199
|
-
successful swap and an HTML error body of any status (e.g. a `422` re-rendered
|
|
1200
|
-
form) both apply in place. For the remaining failure cases (a non-HTML error
|
|
1201
|
-
response like a `500` with a JSON body, or a transport/parse failure) the
|
|
1202
|
-
router fires a cancelable, bubbling `webjs:navigation-error` event on
|
|
1203
|
-
`document` (detail `{ url, status, error }`, where `status` is the HTTP status
|
|
1204
|
-
or `null`, and `error` is the `Error` or `null`). `preventDefault()` hands
|
|
1205
|
-
recovery to you and leaves the page exactly as it is (shell, scroll, focus,
|
|
1206
|
-
client state preserved); otherwise the router renders a minimal in-place
|
|
1207
|
-
`<div role="alert">` into the deepest layout children slot (outer chrome
|
|
1208
|
-
preserved), only hard-loading as a last resort when there is no shared layout
|
|
1209
|
-
marker. An AbortError (a superseding nav) is a normal supersede and never fires
|
|
1210
|
-
the event.
|
|
1211
|
-
|
|
1212
|
-
### 5. `loading.ts` for per-segment skeletons
|
|
1213
|
-
|
|
1214
|
-
Drop a `loading.ts` in any route segment. The framework auto-wraps the
|
|
1215
|
-
sibling `page.ts` in a Suspense boundary with `loading.ts`'s default
|
|
1216
|
-
export as the fallback. On navigation, the client router clones the
|
|
1217
|
-
deepest matching loading template into the swap slot immediately -
|
|
1218
|
-
the user sees a skeleton during the fetch, then the real content.
|
|
1219
|
-
|
|
1220
|
-
### 6. `error.ts` for per-segment error boundaries
|
|
1221
|
-
|
|
1222
|
-
Drop an `error.ts` in any route segment. Render-time exceptions in
|
|
1223
|
-
that segment's tree are caught and rendered through `error.ts`'s
|
|
1224
|
-
default export, scoped to that boundary (outer layouts stay alive).
|
|
1225
|
-
|
|
1226
|
-
### What you do NOT need to write
|
|
1227
|
-
|
|
1228
|
-
- Manual fetch / DOM-swap code for SPA-style navigation
|
|
1229
|
-
- An "active link" highlight handler. Use `aria-current="page"`
|
|
1230
|
-
derived from the request URL on the server.
|
|
1231
|
-
- Loading spinners on `<a>` clicks. `loading.ts` handles it.
|
|
1232
|
-
- Cancellation when the user clicks faster than the network. The
|
|
1233
|
-
router's nav-token + AbortController combo guarantees stale
|
|
1234
|
-
responses never overwrite a newer settled page.
|
|
1235
|
-
- Scroll-position save/restore for back/forward. The snapshot cache
|
|
1236
|
-
handles window scroll. Inner scrollables persist via DOM identity.
|
|
1237
|
-
|
|
1238
|
-
Full reference: see the [Client Router docs](https://docs.webjs.dev/docs/client-router) and the framework AGENTS.md "Client navigation" section.
|
|
1239
|
-
|
|
1240
|
-
## Offline support (opt-in service worker)
|
|
1241
|
-
|
|
1242
|
-
The UI scaffolds (full-stack and saas) ship a progressive-enhancement service
|
|
1243
|
-
worker at `public/sw.js` plus a `public/offline.html` fallback (the api template
|
|
1244
|
-
has no UI, so it omits them). They are **dormant until you register them**, so
|
|
1245
|
-
the JS-disabled baseline is unchanged. To enable offline support, add the opt-in
|
|
1246
|
-
registration snippet to the root layout `<head>`:
|
|
1247
|
-
|
|
1248
|
-
```html
|
|
1249
|
-
<script>
|
|
1250
|
-
if ('serviceWorker' in navigator) {
|
|
1251
|
-
addEventListener('load', () => {
|
|
1252
|
-
const tag = document.querySelector('script[type="importmap"]');
|
|
1253
|
-
const build = (tag && tag.dataset.webjsBuild) || '';
|
|
1254
|
-
navigator.serviceWorker.register('/sw.js' + (build ? '?v=' + build : ''));
|
|
1255
|
-
});
|
|
1256
|
-
}
|
|
1257
|
-
</script>
|
|
1258
|
-
```
|
|
1259
|
-
|
|
1260
|
-
Navigations become network-first (fresh server HTML, with an offline fallback to
|
|
1261
|
-
a cached page or `/offline.html`); same-origin assets are stale-while-revalidate.
|
|
1262
|
-
The cache version ties to the deploy via the `?v=<build>` id, so a new deploy
|
|
1263
|
-
evicts the old cache automatically. `sw.js` is YOUR file, so edit the strategy as
|
|
1264
|
-
needed. Full reference: `agent-docs/service-worker.md`.
|
|
1265
|
-
|
|
1266
|
-
## Metadata (per-page)
|
|
1267
|
-
|
|
1268
|
-
The `metadata` export is Next.js-compatible. Common fields shown below;
|
|
1269
|
-
the full surface includes `title.template / .default / .absolute`,
|
|
1270
|
-
`metadataBase`, `alternates: { canonical, languages, media, types }`,
|
|
1271
|
-
`robots`, `keywords`, `authors`, `creator`, `publisher`, `verification`,
|
|
1272
|
-
`icons`, `manifest`, `appleWebApp`, `formatDetection`, `itunes`, and
|
|
1273
|
-
the typed `other: { '<meta-name>': value }` escape hatch.
|
|
1274
|
-
|
|
1275
|
-
```ts
|
|
1276
|
-
export const metadata = {
|
|
1277
|
-
title: 'My page',
|
|
1278
|
-
// OR: title: { template: '%s | {{APP_NAME}}', default: '{{APP_NAME}}' }
|
|
1279
|
-
description: 'A page in {{APP_NAME}}',
|
|
1280
|
-
metadataBase: 'https://example.com', // base for relative URLs below
|
|
1281
|
-
openGraph: { type: 'website', image: '/og.png' },
|
|
1282
|
-
twitter: { card: 'summary_large_image' },
|
|
1283
|
-
icons: { icon: '/favicon.svg', apple: '/apple.png' },
|
|
1284
|
-
alternates: { canonical: '/post' }, // → <link rel="canonical">
|
|
1285
|
-
robots: { index: true, follow: true },
|
|
1286
|
-
cacheControl: 'public, max-age=60', // opt into caching (default: no-store)
|
|
1287
|
-
};
|
|
1288
|
-
```
|
|
1289
|
-
|
|
1290
|
-
Use `generateMetadata(ctx)` when you need request-scoped values (e.g.
|
|
1291
|
-
absolute URLs from `ctx.url`):
|
|
1292
|
-
|
|
1293
|
-
```ts
|
|
1294
|
-
export function generateMetadata(ctx: { url: string }) {
|
|
1295
|
-
return { metadataBase: new URL(ctx.url).origin, title: 'Hello' };
|
|
1296
|
-
}
|
|
1297
|
-
```
|
|
1298
|
-
|
|
1299
|
-
Viewport may be split into its own export (Next.js 14+ pattern):
|
|
1300
|
-
|
|
1301
|
-
```ts
|
|
1302
|
-
export const viewport = {
|
|
1303
|
-
width: 'device-width',
|
|
1304
|
-
initialScale: 1,
|
|
1305
|
-
themeColor: '#1c1613',
|
|
1306
|
-
colorScheme: 'light dark',
|
|
1307
|
-
};
|
|
1308
|
-
```
|
|
1309
|
-
|
|
1310
|
-
## Document shell (`<html>` / `<head>` / `<body>`)
|
|
1311
|
-
|
|
1312
|
-
The framework owns the shell by default. The SSR pipeline auto-emits
|
|
1313
|
-
`<!doctype html><html lang="en"><head>…</head><body>` around every
|
|
1314
|
-
composition, and auto-hoists `<link>` / `<style>` / `<meta>` / `<script>`
|
|
1315
|
-
tags returned anywhere in a layout/page into the real `<head>`. The
|
|
1316
|
-
`metadata` export drives `<title>` and `<meta>` tags.
|
|
1317
|
-
|
|
1318
|
-
**Only `app/layout.ts` (the root layout)** may optionally write its
|
|
1319
|
-
own `<!doctype><html><head>…</head><body>` shell to override `<html lang>`,
|
|
1320
|
-
`<html dir>`, `<html data-*>`, `<body class>`, or add a custom
|
|
1321
|
-
`<link rel="preconnect">` etc. When the root layout supplies a shell,
|
|
1322
|
-
the framework respects it and splices its required tags into the
|
|
1323
|
-
user's `<head>`.
|
|
1324
|
-
|
|
1325
|
-
```ts
|
|
1326
|
-
// app/layout.ts (root, optionally owning the shell)
|
|
1327
|
-
export default function RootLayout({ children }) {
|
|
1328
|
-
return html`
|
|
1329
|
-
<!doctype html>
|
|
1330
|
-
<html lang="es" data-theme="dark">
|
|
1331
|
-
<head>
|
|
1332
|
-
<link rel="preconnect" href="https://cdn.example.com">
|
|
1333
|
-
</head>
|
|
1334
|
-
<body class="min-h-screen bg-bg">
|
|
1335
|
-
<main>${children}</main>
|
|
1336
|
-
</body>
|
|
1337
|
-
</html>
|
|
1338
|
-
`;
|
|
1339
|
-
}
|
|
1340
|
-
```
|
|
1341
|
-
|
|
1342
|
-
**Non-root layouts** (`app/<segment>/layout.ts`) and **pages**
|
|
1343
|
-
(`app/**/page.ts`) **must NOT** write `<!doctype>` / `<html>` / `<head>`
|
|
1344
|
-
/ `<body>`. The framework auto-emits the wrapper around the whole
|
|
1345
|
-
composition, so a nested shell ends up dropped by the HTML parser.
|
|
1346
|
-
`webjs check` enforces this via the `shell-in-non-root-layout` rule.
|
|
1347
|
-
|
|
1348
|
-
## Invariants (do not violate)
|
|
1349
|
-
|
|
1350
|
-
1. Custom element tags must contain a hyphen. Pass the tag to `.register('tag-name')` at the bottom of the file. The tag is not a static field.
|
|
1351
|
-
2. **Server-only code goes in `.server.{js,ts}` files, `route.ts`
|
|
1352
|
-
handlers, or `middleware.ts`. Never in pages, layouts, or
|
|
1353
|
-
components.** Direct imports of a DB driver (`pg`),
|
|
1354
|
-
`node:*`, or any server-only dependency from a page, layout, loading.ts,
|
|
1355
|
-
error.ts, not-found.ts, or component will crash the browser at module load.
|
|
1356
|
-
Wrap the access in a `.server.{js,ts}` file; the framework
|
|
1357
|
-
rewrites that import into an RPC stub for the browser. Server-only
|
|
1358
|
-
infra lives in `db/*.server.ts` (the DB) and `lib/*.server.ts`
|
|
1359
|
-
(`lib/session.server.ts`); browser-safe utilities live in
|
|
1360
|
-
`lib/utils/cn.ts` with `cn`, design-
|
|
1361
|
-
system helpers). Server-only `lib/*` files must only be imported
|
|
1362
|
-
from `.server.ts`/`route.ts`/`middleware.ts`; browser-safe `lib/*`
|
|
1363
|
-
files (like `lib/utils/cn.ts`) can be imported anywhere. A TYPE-ONLY
|
|
1364
|
-
import is the exception: `import type { Todo } from
|
|
1365
|
-
'#db/schema.server.ts'` is fine in a page or component, because the
|
|
1366
|
-
TypeScript stripper erases it before it reaches the browser, so
|
|
1367
|
-
sharing a derived row type is safe and is not flagged.
|
|
1368
|
-
3. Event / property / boolean holes in `` html`` `` are unquoted:
|
|
1369
|
-
`@click=${fn}`, not `@click="${fn}"`.
|
|
1370
|
-
4. Component state lives in signals. Import `signal` from
|
|
1371
|
-
`@webjsdev/core`, read with `signal.get()` inside `render()`, and
|
|
1372
|
-
write with `signal.set(value)`. Module-scope signals share state
|
|
1373
|
-
across components; instance signals (created in the constructor)
|
|
1374
|
-
carry component-local state. Reactive properties (`static
|
|
1375
|
-
properties = { ... }` with a sibling `declare`) are for values
|
|
1376
|
-
that ride an HTML attribute or `.prop=${...}` SSR hydration.
|
|
1377
|
-
5. Pages / layouts / metadata routes default-export a server-only function.
|
|
1378
|
-
6. One exported function per action / query file. Name the file after it.
|
|
1379
|
-
7. **Components must render meaningful HTML on first paint** (SSR
|
|
1380
|
-
uses constructor defaults + attributes, while `connectedCallback` is
|
|
1381
|
-
browser-only). Never fetch initial data in `connectedCallback` /
|
|
1382
|
-
`firstUpdated`. Fetch in the page function (server) and pass it as
|
|
1383
|
-
a prop. See *Component pattern* above.
|
|
1384
|
-
8. **Erasable TypeScript only.** The runtime strips types at the runtime
|
|
1385
|
-
layer (Node 24+'s built-in `module.stripTypeScriptTypes`, or `amaro`
|
|
1386
|
-
on Bun, which is byte-identical), with whitespace replacement so
|
|
1387
|
-
line and column positions are byte-exact and no sourcemap ships to
|
|
1388
|
-
the browser. Your `tsconfig.json` sets `erasableSyntaxOnly: true`, so
|
|
1389
|
-
the TS compiler rejects: `enum`, `namespace` with values,
|
|
1390
|
-
constructor parameter properties, legacy decorators with
|
|
1391
|
-
`emitDecoratorMetadata`, and `import = require`. Use the erasable
|
|
1392
|
-
equivalents:
|
|
1393
|
-
|
|
1394
|
-
```ts
|
|
1395
|
-
// ❌ enum
|
|
1396
|
-
enum Color { Red, Green, Blue }
|
|
1397
|
-
|
|
1398
|
-
// ✅ const object + union type
|
|
1399
|
-
const Color = { Red: 'Red', Green: 'Green', Blue: 'Blue' } as const;
|
|
1400
|
-
type Color = typeof Color[keyof typeof Color];
|
|
1401
|
-
|
|
1402
|
-
// ❌ parameter property
|
|
1403
|
-
class Foo { constructor(public x: number) {} }
|
|
1404
|
-
|
|
1405
|
-
// ✅ explicit field + assignment
|
|
1406
|
-
class Foo {
|
|
1407
|
-
x: number;
|
|
1408
|
-
constructor(x: number) { this.x = x; }
|
|
1409
|
-
}
|
|
1410
|
-
```
|
|
1411
|
-
|
|
1412
|
-
If you turn `erasableSyntaxOnly` off and use non-erasable syntax,
|
|
1413
|
-
the dev server fails at strip time and returns a 500 naming the
|
|
1414
|
-
file and pointing at the `no-non-erasable-typescript` lint rule.
|
|
1415
|
-
WebJs is buildless end-to-end and has no bundler fallback. The
|
|
1416
|
-
`erasable-typescript-only` convention check warns when the flag
|
|
1417
|
-
is missing or set to false.
|
|
1418
|
-
9. **No em-dashes (U+2014) anywhere, and no hyphen or semicolon used
|
|
1419
|
-
as a pause-punctuation substitute.** Prose, comments, code, JSON
|
|
1420
|
-
descriptions, commit messages. Rewrite the sentence so no
|
|
1421
|
-
pause-punctuation crutch is needed. Banned as pause punctuation:
|
|
1422
|
-
the em-dash (`-`), a plain hyphen used in place of one (` - `), and
|
|
1423
|
-
a semicolon used in place of one (` ; `). Use a period, comma,
|
|
1424
|
-
colon, parentheses, or a restructured phrasing. Plain hyphens stay
|
|
1425
|
-
fine in compound words (`AI-first`), CLI flags (`--http2`),
|
|
1426
|
-
filenames, and ranges. Semicolons stay fine inside code.
|
|
1427
|
-
|
|
1428
|
-
## Workflow expectations for AI agents
|
|
1429
|
-
|
|
1430
|
-
1. Branch before editing. Never push to `main` directly. **If more than one
|
|
1431
|
-
agent may work this repo at once, give each task its own git worktree, not a
|
|
1432
|
-
shared checkout** (`git worktree add -b <branch> ../<repo>-<slug> origin/main`,
|
|
1433
|
-
`cd` in, work there, `git worktree remove` after merge). Two agents in one
|
|
1434
|
-
working directory collide: a `git checkout` in one moves `HEAD` under the
|
|
1435
|
-
other, so the next commit lands on the wrong branch. Git enforces
|
|
1436
|
-
one-branch-per-worktree, so worktrees prevent it; a lone agent in a clean
|
|
1437
|
-
checkout may use a plain branch.
|
|
1438
|
-
2. Every code change comes with a test, AGENTS.md / docs updates if the
|
|
1439
|
-
feature surface changed, `webjs check` passing. A unit test is not
|
|
1440
|
-
always enough: a component, hydration, the client router, or a server
|
|
1441
|
-
action called from the client needs a browser test
|
|
1442
|
-
(`webjs test --browser`) asserting the behaviour in a real browser. For
|
|
1443
|
-
Claude Code, a commit that stages app code (`app/`, `modules/`,
|
|
1444
|
-
`components/`, `lib/`) with no test WARNS via
|
|
1445
|
-
`.claude/hooks/require-tests-with-src.sh` (every change should still ship
|
|
1446
|
-
with a test, but that is a convention, not a hard gate). A project that
|
|
1447
|
-
wants the strict floor opts into a hard block by setting
|
|
1448
|
-
`WEBJS_TEST_GATE=block` (in `.claude/settings.json` env, your shell, or
|
|
1449
|
-
CI). The real enforcement is CI: the test suite runs in
|
|
1450
|
-
`.github/workflows/ci.yml`, not in the pre-commit hook, so `git commit`
|
|
1451
|
-
stays fast and the gate cannot be skipped with a local `--no-verify`.
|
|
1452
|
-
3. Commit and push **per logical unit**, not at the end. A logical unit is one
|
|
1453
|
-
feature, one fix, one rename, one doc rewrite. If you have 5+ unstaged files
|
|
1454
|
-
spanning different concerns, commit the current group before continuing.
|
|
1455
|
-
For Claude Code, its `CLAUDE.md` explicitly OVERRIDES Claude Code's built-in
|
|
1456
|
-
never-commit default, so it commits per unit without waiting to be asked. The
|
|
1457
|
-
framework also ships a `nudge-uncommitted` hook for several agents that fires
|
|
1458
|
-
at threshold 4:
|
|
1459
|
-
|
|
1460
|
-
| Agent | Hook path | Doc |
|
|
1461
|
-
|---|---|---|
|
|
1462
|
-
| Claude Code | `.claude/hooks/nudge-uncommitted.sh` (`PostToolUse`) | `.claude/settings.json` |
|
|
1463
|
-
| Gemini CLI | `.gemini/hooks/nudge-uncommitted.sh` (`AfterTool`) | `.gemini/settings.json` |
|
|
1464
|
-
| Cursor 1.7+ | `.cursor/hooks/nudge-uncommitted.sh` (`afterFileEdit`) | `.cursor/hooks.json` |
|
|
1465
|
-
| OpenCode | `.opencode/plugins/nudge-uncommitted.ts` (`tool.execute.after`) | `.opencode/plugins/` |
|
|
1466
|
-
| Antigravity (Google) | text rule only (post-write hooks not yet exposed) | `.agents/rules/workflow.md` |
|
|
1467
|
-
| GitHub Copilot | text rule only (no hooks API) | `.github/copilot-instructions.md` |
|
|
1468
|
-
|
|
1469
|
-
Claude Code adds two more backstops of its own. A `commit-before-stop.sh`
|
|
1470
|
-
Stop hook refuses to end a turn with a pile of uncommitted work on a feature
|
|
1471
|
-
branch (loop-safe, disable with `WEBJS_NO_COMMIT_STOP=1`), and a
|
|
1472
|
-
`cleanup-merged-worktree.sh` PostToolUse hook removes a merged branch's
|
|
1473
|
-
worktree after a `gh pr merge`.
|
|
1474
|
-
|
|
1475
|
-
The `.hooks/pre-commit` hook blocks commits to main and nothing else;
|
|
1476
|
-
`webjs test` + `webjs check` run in CI (`.github/workflows/ci.yml`) on
|
|
1477
|
-
every PR and push to main, regardless of which agent (or human) made
|
|
1478
|
-
the commit. No AI attribution trailers in commit messages.
|
|
1479
|
-
4. Run the **pre-merge self-review loop** before signaling the PR is
|
|
1480
|
-
ready. After committing the work, trigger a fresh-context review
|
|
1481
|
-
pass (a new chat / composer tab / subagent / Cascade thread
|
|
1482
|
-
depending on your tool) and iterate fix-then-review rounds until
|
|
1483
|
-
one round finds zero issues. Minimum two rounds; rotate focus each
|
|
1484
|
-
round so the reviewer does not rediscover the same surface twice.
|
|
1485
|
-
Skip the loop only for one-line trivial changes; skipping on a
|
|
1486
|
-
change that touches logic, public surface, build, security, or
|
|
1487
|
-
multiple files is the exact failure mode the loop exists to
|
|
1488
|
-
prevent. The full rule, prompt template, and reporting contract
|
|
1489
|
-
live in the **Pre-merge self-review loop** section of
|
|
1490
|
-
`CONVENTIONS.md`.
|
|
1491
|
-
5. When unsure how a framework feature works, `grep` or `cat` the
|
|
1492
|
-
relevant `node_modules/@webjsdev/*/src/` file before asking the user.
|
|
1493
|
-
|
|
1494
|
-
Project conventions live in [CONVENTIONS.md](./CONVENTIONS.md) (guidance
|
|
1495
|
-
you follow by judgment). `webjs check` is separate: correctness checks
|
|
1496
|
-
only, always on, no per-project disabling.
|
|
3
|
+
This is a WebJs app: AI-first, web-components-first, no build step. Read this
|
|
4
|
+
before editing any file. It is deliberately short. The framework knowledge
|
|
5
|
+
lives in one place that every AI tool can read.
|
|
6
|
+
|
|
7
|
+
## Building features
|
|
8
|
+
|
|
9
|
+
Read `.agents/skills/webjs/SKILL.md` first. It is the guide to building a WebJs
|
|
10
|
+
app: it helps you choose the right layer, reach for the right export, and avoid
|
|
11
|
+
the WebJs-specific mistakes that Next.js or Lit habits cause. It routes to
|
|
12
|
+
focused references under `.agents/skills/webjs/references/` that you load only
|
|
13
|
+
when a task needs them. The full hosted docs are at https://docs.webjs.dev.
|
|
14
|
+
|
|
15
|
+
## Grow this app in place
|
|
16
|
+
|
|
17
|
+
This scaffold is a starting point. It ships a gallery index home
|
|
18
|
+
(`app/page.ts`), a root layout with a neutral design-token palette
|
|
19
|
+
(`app/layout.ts`), a database wired up (`db/`), and a densely-commented feature
|
|
20
|
+
gallery: single-concept demos under `app/features/` plus the `app/examples/todo`
|
|
21
|
+
app, with logic in `modules/`. The gallery is reference to learn the idioms
|
|
22
|
+
from, not part of your product.
|
|
23
|
+
|
|
24
|
+
**Building a real app? Learn from the gallery FIRST, then clear it, then build.**
|
|
25
|
+
The order matters:
|
|
26
|
+
|
|
27
|
+
1. **Gather context.** Skim the demos relevant to your task under
|
|
28
|
+
`app/features/<x>` (and `app/examples/todo`) for the runnable idiom. You do
|
|
29
|
+
not have to read all of it, and you never lose it: the skill at
|
|
30
|
+
`.agents/skills/webjs/` teaches the same patterns and SURVIVES the clear, so
|
|
31
|
+
clearing is not a knowledge-loss event, the gallery is just a runnable bonus.
|
|
32
|
+
2. **Clear it.** Run `npm run gallery:clear` to shed the whole gallery in one
|
|
33
|
+
step (removes `app/features/`, `app/examples/`, the demo `modules/`, the demo
|
|
34
|
+
`todos` table, and resets `app/page.ts` to a minimal home), while KEEPING the
|
|
35
|
+
agent skill, the layout, and the database wiring.
|
|
36
|
+
3. **Build.** Regenerate the database (`npm run db:generate` then `npm run
|
|
37
|
+
db:migrate`), then grow the app in place: routes under `app/`, components
|
|
38
|
+
under `components/`, features under `modules/<feature>/`, server-only code
|
|
39
|
+
behind `.server.ts`, and the app's own palette via the tokens in
|
|
40
|
+
`app/layout.ts`.
|
|
41
|
+
|
|
42
|
+
If you are only exploring, keep the gallery and browse it.
|
|
43
|
+
|
|
44
|
+
## Commands
|
|
45
|
+
|
|
46
|
+
```sh
|
|
47
|
+
npm install
|
|
48
|
+
npm run gallery:clear # shed the demo gallery before building a real app
|
|
49
|
+
npm run dev # dev server at http://localhost:8080
|
|
50
|
+
npm run start # production server
|
|
51
|
+
npm test # unit + browser tests
|
|
52
|
+
npm run typecheck
|
|
53
|
+
npx webjsdev check # correctness checks
|
|
54
|
+
npx webjsdev ui add <name> # add a ui-* component on demand
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
## Data
|
|
58
|
+
|
|
59
|
+
Use the wired-up database (Drizzle). Define real models in
|
|
60
|
+
`db/schema.server.ts`, then `npm run db:generate` and `npm run db:migrate`.
|
|
61
|
+
Never store app data in JSON files, in-memory arrays, or localStorage.
|