@webjsdev/mcp 0.1.12 → 0.1.13
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +25 -0
- package/package.json +1 -1
- package/resources/AGENTS.md +22 -7
- package/resources/SKILL.md +3 -1
- package/resources/corpus.json +6 -0
- package/resources/references/components.md +89 -1
- package/resources/references/muscle-memory-gotchas.md +1 -1
- package/resources/references/routing-and-pages.md +2 -0
- package/resources/references/styling.md +1 -1
- package/resources/references/testing.md +8 -0
- package/resources/references/typescript.md +17 -3
package/README.md
CHANGED
|
@@ -47,4 +47,29 @@ subcommand) delegates to this same server, so both routes run identical code.
|
|
|
47
47
|
The docs corpus is bundled into the package at `prepack`, so `npx @webjsdev/mcp`
|
|
48
48
|
is self-contained; in the monorepo it falls back to the live repo-root docs.
|
|
49
49
|
|
|
50
|
+
That bundle is a snapshot frozen at publish time, so a published tarball keeps
|
|
51
|
+
serving the docs as they read on the day it was cut. `prepack` stamps it with
|
|
52
|
+
`resources/corpus.json` so the snapshot can say which docs it holds:
|
|
53
|
+
|
|
54
|
+
```json
|
|
55
|
+
{
|
|
56
|
+
"package": "@webjsdev/mcp",
|
|
57
|
+
"version": "0.1.12",
|
|
58
|
+
"sha": "e5806e2400000000000000000000000000000000",
|
|
59
|
+
"copiedAt": "2026-08-08T09:14:22.031Z"
|
|
60
|
+
}
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
`sha` is the full commit the docs were copied from, so it resolves straight to a
|
|
64
|
+
GitHub diff. Every field is `null` rather than a plausible-looking default when
|
|
65
|
+
it cannot be established, so a consumer can always tell a real answer from no
|
|
66
|
+
answer: `sha` when the source tree is not itself a git checkout root, and
|
|
67
|
+
`package` / `version` when the manifest cannot be read. None of those fails the
|
|
68
|
+
publish. A dev checkout has no bundle and so no stamp.
|
|
69
|
+
|
|
70
|
+
The SHA is deliberately refused when the tree merely SITS inside some other
|
|
71
|
+
checkout, because `git rev-parse` walks up to an ancestor and would otherwise
|
|
72
|
+
report an unrelated repository's HEAD as the commit these docs came from. That
|
|
73
|
+
answer is a well-formed SHA, so nothing downstream could catch it.
|
|
74
|
+
|
|
50
75
|
STDOUT is the JSON-RPC channel; every diagnostic goes to stderr.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@webjsdev/mcp",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.13",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "The Model Context Protocol server for WebJs, a full-stack JavaScript framework built on web components with server-side rendering and no build step. Gives AI coding agents live app introspection over routes, actions, components, and convention checks, plus a knowledge layer of docs, recipes, and framework source.",
|
|
6
6
|
"bin": {
|
package/resources/AGENTS.md
CHANGED
|
@@ -13,7 +13,7 @@ to these `references/`:
|
|
|
13
13
|
| Reference | Topic |
|
|
14
14
|
|---|---|
|
|
15
15
|
| `references/routing-and-pages.md` | Pages, layouts, dynamic routes, route handlers, metadata, redirects, 404s |
|
|
16
|
-
| `references/components.md` | Writing components: reactive props, signals, lifecycle, light vs shadow DOM, inherited members not to shadow |
|
|
16
|
+
| `references/components.md` | Writing components: what a component owns, reactive props, signals, lifecycle, light vs shadow DOM, inherited members not to shadow |
|
|
17
17
|
| `references/data-and-actions.md` | Server actions, mutations, queries, validation, the `ActionResult` envelope, Drizzle surface, where server-derived types live |
|
|
18
18
|
| `references/auth-and-sessions.md` | Sessions, login flows, route protection, `forbidden()` / `unauthorized()` |
|
|
19
19
|
| `references/styling.md` | Tailwind, light-DOM tag-prefix rule, tokens, fixed headers, no-reflow layout |
|
|
@@ -55,12 +55,13 @@ cd ../<repo>-<slug> # do ALL work for the task here
|
|
|
55
55
|
|
|
56
56
|
**A fresh worktree has NO `node_modules`** (git worktrees do not copy it), so running an app from one (`webjs dev` / `webjs start`, the test runner, a scaffolded app) fails to resolve `@webjsdev/*` until you install or link it. `webjs doctor` warns for this exact case (#954) and `webjs dev` / `webjs start` print the cause + remedy instead of a raw `ERR_MODULE_NOT_FOUND`.
|
|
57
57
|
|
|
58
|
-
Fix it with **`npm run worktree:link`** from inside the worktree (or a full `npm install` there, which is correct but slow and duplicates a large tree per worktree). **Do NOT hand-symlink only the root `node_modules`.** That is the obvious move and it produces a worktree that looks set up and then fails dozens of tests for reasons that point nowhere near the real cause.
|
|
58
|
+
Fix it with **`npm run worktree:link`** from inside the worktree (or a full `npm install` there, which is correct but slow and duplicates a large tree per worktree). **Do NOT hand-symlink only the root `node_modules`.** That is the obvious move and it produces a worktree that looks set up and then fails dozens of tests for reasons that point nowhere near the real cause. Three things beyond the root tree are needed, and the script handles all three:
|
|
59
59
|
|
|
60
60
|
- **Every NESTED `node_modules`, not just the root.** npm hoists what it can, but a workspace whose range conflicts with the hoisted copy keeps its own nested tree. `packages/server` is the live example: the root carries `ws@7` (hoisted for another dependent) while `packages/server` declares `^8.20.0` and keeps `ws@8` nested. Link only the root and `WebSocketServer`, a ws@8-only named export, resolves up to ws@7 and throws at module load. That single miss failed hundreds of assertions across the server, integration, and smoke suites, none of them naming ws.
|
|
61
61
|
- **`packages/core/dist`**, which is built rather than committed, so a fresh worktree has none and every test importing the built bundle fails to resolve it.
|
|
62
|
+
- **`examples/blog`'s seeded SQLite database.** `db/dev.db` is gitignored, so a worktree has none, and an empty `posts` table fails three blog tests plus their enclosing progressive-enhancement suite with nothing in the output naming the database (#1323). The script runs the blog's `db:migrate` then `db:seed` when the `posts` table is missing or empty, which takes about two and a half seconds and is a no-op once there are rows. A file-existence check would not do, because `webjs dev` and `webjs start` both run `webjs db migrate` as a before-step and leave the file there with no rows. Seeding failures WARN and never fail the link. Skip it with `WEBJS_NO_WORKTREE_SEED=1`.
|
|
62
63
|
|
|
63
|
-
The script discovers the `node_modules` set from the primary checkout rather than hardcoding a list (it changes whenever a package gains a nested tree), never overwrites an existing path, and never creates a dangling link, so it is safe to re-run and safe in a worktree where you already ran a real `npm install`.
|
|
64
|
+
The script discovers the `node_modules` set from the primary checkout rather than hardcoding a list (it changes whenever a package gains a nested tree), never overwrites an existing path, and never creates a dangling link, so it is safe to re-run and safe in a worktree where you already ran a real `npm install`. The seed step keeps the same contract: it only ever applies pending migrations and inserts demo rows that are not there, so a database with rows in it is left untouched.
|
|
64
65
|
|
|
65
66
|
**Know what this does NOT give you.** The worktree then runs the PRIMARY checkout's framework source through every bare `@webjsdev/*` specifier, because `<primary>/node_modules/@webjsdev/core` is a relative symlink into `<primary>/packages/core` and resolving through the linked root lands there. Relative imports (`../../../src/x.js`) and the browser suite, which web-test-runner serves from the worktree, do use the worktree's own files. So linking makes the suite RUNNABLE, not self-testing: if you are editing `packages/core/src` or `packages/server/src` and need a bare-specifier consumer to exercise YOUR copy, run a real `npm install` in the worktree, or repoint the individual `@webjsdev/<pkg>` entries at it. CI always builds from the branch, so it is unaffected either way.
|
|
66
67
|
|
|
@@ -74,6 +75,20 @@ Git enforces one-branch-per-worktree, so separate worktrees make the collision i
|
|
|
74
75
|
|
|
75
76
|
A Skill is model-invoked, so it fires only when the model judges a match. The `.claude/hooks/route-skills.sh` `UserPromptSubmit` hook makes routing deterministic: it keyword-matches each prompt against every skill's documented triggers and injects a directive to invoke the matched skill before other work. Check the available skills and invoke a matching one before starting. The skills themselves are committed under `.claude/skills/` (alongside the hooks), so a fresh clone has both the router and the skills it routes to (no machine-local dependency). Tests in `test/hooks/route-skills.test.mjs`, which also asserts every skill the hook references is committed in-repo.
|
|
76
77
|
|
|
78
|
+
### Talking to GitHub: `gh api` over REST, never the porcelain
|
|
79
|
+
|
|
80
|
+
GitHub scores two independent budgets, and `gh` routes to them in a way the command names hide. **Most `gh` porcelain commands issue `POST /graphql`**, whose 5000/hour budget is scored in **points** rather than requests, so one query walking a large connection can cost hundreds. Sessions here exhaust it routinely, and when it is gone the project board becomes unreachable and two PostToolUse hooks fail silently. The REST budget (`gh api <path>`, 5000 requests/hour) sits idle meanwhile.
|
|
81
|
+
|
|
82
|
+
Convert these, which is the whole high-volume surface. Reads: `gh issue view` / `gh issue list` / `gh pr view` / `gh pr list` / `gh pr diff` / `gh pr status` / `gh search issues` / `gh label list`. Writes: `gh issue create` / `gh issue edit` / `gh issue close` / `gh issue comment`. The writes are lower volume, but a task whose whole deliverable is a write is blocked outright once the budget is gone.
|
|
83
|
+
|
|
84
|
+
So: **go through `gh api` over REST, and reserve GraphQL for the two things only it can do**, Projects V2 (which has no REST API) and `resolveReviewThread`.
|
|
85
|
+
|
|
86
|
+
**Leave these on the porcelain**, even though they do route to GraphQL. `gh pr merge`, because two PostToolUse hooks detect a merge by matching that literal string. `gh pr create`, which resolves base and head from local git state. And `gh pr checks`, which is the merge gate: it folds check-runs and legacy commit statuses into one verdict, and the obvious REST replacement reports a green PR as pending, so a miss there lets a red build onto `main`. All three run once per PR, so converting them buys nothing and risks a lot.
|
|
87
|
+
|
|
88
|
+
Do NOT extend either list by assuming a command family routes one way. `gh label create` looks like it belongs beside `gh label list` and does not, since it issues a plain `POST /repos/{owner}/{repo}/labels`, so it needs no conversion. Measure with `GH_DEBUG=api` first.
|
|
89
|
+
|
|
90
|
+
The full substitution table, the traps in each replacement, and the reasoning behind every exception live in **`.claude/gh-budget.md`**. That file is the single source; skills and hooks link to it rather than restating it, because an earlier copy inside one skill drifted from the rule it stated.
|
|
91
|
+
|
|
77
92
|
### Autonomous mode (sandbox / bypass permissions)
|
|
78
93
|
|
|
79
94
|
When interactive approval is disabled, never block on questions. Auto-decide: cut the task's worktree from `origin/main` (auto-create `<prefix>/<task-slug>` per the label scheme); auto-rebase if the parent moved; auto-merge when ready; **delete** feature/fix branches after merge but **keep** long-lived ones (dev, staging, release/*); auto-generate meaningful commit messages; fix failing tests / convention violations rather than asking. Autonomous mode is MORE disciplined, not less, with the same quality bar.
|
|
@@ -264,7 +279,7 @@ MyThing.register('my-thing');
|
|
|
264
279
|
|
|
265
280
|
**Signals are the default state primitive.** Import `signal` / `computed` from `@webjsdev/core`, read with `signal.get()` inside `render()`, and the built-in `SignalWatcher` re-renders on change. Module-scope signals share state across components and survive navigations; instance signals (constructor) are component-local.
|
|
266
281
|
|
|
267
|
-
**Reactive properties ride HTML attributes, reflect to them, or arrive via SSR hydration.** They are declared ONLY by passing the property shape into the base-class factory `WebComponent({ ... })`. There is no `static properties` block and no `declare` line. The bare form takes a type constructor (`count: Number`, `label: String`, `open: Boolean`); the `prop()` helper carries options (`count: prop(Number, { reflect: true })`, `name: prop(String, { attribute: 'show-close-button' })`, internal state `prop({ state: true })` with no type), and narrows the TS type (`student: prop<Student>(Object)`, `mode: prop<'a' | 'b'>(String, { reflect: true })`). The types flow automatically to `this.<prop>`. Set defaults in the constructor after `super()` (a declarative `default` option also exists for lit-parity, a function default running per instance for a fresh object / array, but the constructor is the recommended way). NEVER use a class-field declaration OR initializer (e.g., `count = 0` or `todos!: Todo[]`), which compiles to define an own property on the instance and clobbers the reactive accessor (caught by `reactive-props-no-class-field`). Declaring your own `static properties` THROWS at runtime and is flagged by `no-static-properties`. Declare an array-typed prop with the `Array` constructor, not `Object` (`items: prop<Tag[]>(Array)`): the two share one JSON converter so neither crashes, but `Array` states the prop's shape and `array-prop-uses-array-type` flags the `Object` form. Property options: `type` (default `String`), `reflect`, `state`, `hasChanged`, `converter`, `default`, `attribute`.
|
|
282
|
+
**Reactive properties ride HTML attributes, reflect to them, or arrive via SSR hydration.** They are declared ONLY by passing the property shape into the base-class factory `WebComponent({ ... })`. There is no `static properties` block and no `declare` line. The bare form takes a type constructor (`count: Number`, `label: String`, `open: Boolean`); the `prop()` helper carries options (`count: prop(Number, { reflect: true })`, `name: prop(String, { attribute: 'show-close-button' })`, internal state `prop({ state: true })` with no type), and narrows the TS type (`student: prop<Student>(Object)`, `mode: prop<'a' | 'b'>(String, { reflect: true })`). The types flow automatically to `this.<prop>`. Set defaults in the constructor after `super()` (a declarative `default` option also exists for lit-parity, a function default running per instance for a fresh object / array, but the constructor is the recommended way). NEVER use a class-field declaration OR initializer (e.g., `count = 0` or `todos!: Todo[]`), which compiles to define an own property on the instance and clobbers the reactive accessor (caught by `reactive-props-no-class-field`). Declaring your own `static properties` THROWS at runtime and is flagged by `no-static-properties`. Declare an array-typed prop with the `Array` constructor, not `Object` (`items: prop<Tag[]>(Array)`): the two share one JSON converter so neither crashes, but `Array` states the prop's shape and `array-prop-uses-array-type` flags the `Object` form. Property options: `type` (default `String`), `reflect`, `state`, `hasChanged`, `converter`, `default`, `attribute`. A `reflect: true` property whose value has no attribute representation REMOVES its attribute and warns rather than writing one, which covers a function (or an array carrying one, whose source would otherwise ship to every visitor) and a JSON-typed value `JSON.stringify` cannot serialize (a cycle, a `BigInt`, a throwing `toJSON`); a value merely CARRYING a function still reflects whole, since `JSON.stringify` drops it to `null` without losing the rest.
|
|
268
283
|
|
|
269
284
|
**Lifecycle (lit-aligned), in order:** `shouldUpdate`, `willUpdate`, controllers' `hostUpdate()`, `update` (calls `render()` + commits), controllers' `hostUpdated()`, `firstUpdated`, `updated`, `updateComplete`, each receiving a `changedProperties` Map. **SSR runs only the constructor, attribute application, the pre-render hooks (`willUpdate` / `hostUpdate`), `reflect: true` reflection, and `render()`; it does NOT call `connectedCallback`, `firstUpdated`, `updated`, or any browser-only hook.** So defaults for first paint go in the constructor; browser-only data (localStorage, viewport, `navigator.*`) goes in `connectedCallback` writing a signal; server-known data arrives via the page function. Never ship a placeholder first paint that fetches in `connectedCallback`. A browser-only global in the constructor/`render()` throws at SSR (flagged by `no-browser-globals-in-render`; attribute methods and `closest()` are shimmed).
|
|
270
285
|
|
|
@@ -367,7 +382,7 @@ Derive the type at every boundary: a DB row from the schema (`typeof todos.$infe
|
|
|
367
382
|
|
|
368
383
|
## Client navigation: automatic, nothing to opt into
|
|
369
384
|
|
|
370
|
-
The router auto-enables when `@webjsdev/core` loads (any page with a component), so there is nothing to opt INTO. An app that wants plain full-page (MPA) navigation can opt OUT app-wide with `{ "webjs": { "clientRouter": false } }` (#629), or per-moment at runtime with `disableClientRouter()`. SSR auto-emits KEYED boundary comment pairs around each layout's children AND the page itself (open `<!--wj:children:<segment>:<route-key>-->`, close `<!--/wj:children:<segment>-->`; the route-key is the resolved path with param values percent-encoded, #1015). The router strictly scans both DOMs (any truncated, mispaired, or duplicated boundary poisons the scan) and applies a two-tier swap with Next.js remount parity: a changed route-key REPLACES (remounts) at the PARENT of the shallowest changed boundary (the range that contains the changed layout's own markup, exact Next remount scope), an unchanged one MORPHS the deepest shared boundary in place (a searchParams-only nav preserves hydrated component state). A poisoned or disjoint scan degrades to a full page load, never a guessed recovery, so silent DOM corruption is structurally impossible; outer-layout DOM identity is preserved on every soft path. **Every degradation dispatches `webjs:navigation-fallback` on `document` in ALL environments** (detail `{ cause, href, willReload }`, not cancelable), so a full page load on a click is observable in production rather than silent (#1114). Form submissions ride the same pipeline (`data-no-router` opts out). Wire bytes are minimized via the `X-Webjs-Have` header (`segment:route-key` entries, so a dynamic layout held for OTHER params is re-rendered rather than short-circuited; the server returns only the divergent fragment, served `private` so a shared cache can never store the reduced body and serve it to a full-page navigation, and additionally marked `Vary: X-Webjs-Have` for caches that honour it (#1140; `Vary` alone was not enough, since Cloudflare honours only `Accept-Encoding`)); scroll is restored on back/forward. **The link-prefetch cache is ANCHOR-VALIDATED** (#1114): a reduced fragment begins at the boundary the server short-circuited on, and on consume the router checks that boundary is still live with the same route-key. A root-anchored fragment therefore survives an unrelated navigation (still a cache hit), while one anchored deeper is discarded once that layout is gone, because applying it would share no boundary with the live DOM and force a full page load. The router also never prefetches the page it is already on (#1106): that request can never serve a later navigation and only occupies a capped cache slot. A non-GET `<form>` that BINDS a server action (`action=${fn}`) is the no-JS write-path (with JS the router posts the same body to the same url and applies the response in place: a `422` swaps without reload, a `303` is followed via fetch). **The router ENCODES that body per the declared `enctype`** (#1307), resolved with native precedence (a submitter's `formenctype` over the form's): `multipart/form-data` sends `FormData`, and `application/x-www-form-urlencoded`, the HTML default and therefore what a plain `<form method="post">` means, sends `URLSearchParams` (a `File` serializes as its name, as the platform does). It previously built `FormData` for everything, so an ordinary POST form sent a urlencoded body with JS off and a multipart body with JS on. A `text/plain` POST is the one encoding the server cannot parse, so the router declines it and lets the browser submit natively, which keeps both paths doing the same thing. A failed navigation recovers in place (a cancelable `webjs:navigation-error` event, else a minimal in-place alert), never a destructive full reload.
|
|
385
|
+
The router auto-enables when `@webjsdev/core` loads (any page with a component), so there is nothing to opt INTO. An app that wants plain full-page (MPA) navigation can opt OUT app-wide with `{ "webjs": { "clientRouter": false } }` (#629), or per-moment at runtime with `disableClientRouter()`. SSR auto-emits KEYED boundary comment pairs around each layout's children AND the page itself (open `<!--wj:children:<segment>:<route-key>-->`, close `<!--/wj:children:<segment>-->`; the route-key is the resolved path with param values percent-encoded, #1015). The router strictly scans both DOMs (any truncated, mispaired, or duplicated boundary poisons the scan) and applies a two-tier swap with Next.js remount parity: a changed route-key REPLACES (remounts) at the PARENT of the shallowest changed boundary (the range that contains the changed layout's own markup, exact Next remount scope), an unchanged one MORPHS the deepest shared boundary in place (a searchParams-only nav preserves hydrated component state). A poisoned or disjoint scan degrades to a full page load, never a guessed recovery, so silent DOM corruption is structurally impossible; outer-layout DOM identity is preserved on every soft path. **Every degradation dispatches `webjs:navigation-fallback` on `document` in ALL environments** (detail `{ cause, href, willReload }`, not cancelable), so a full page load on a click is observable in production rather than silent (#1114). Form submissions ride the same pipeline (`data-no-router` opts out). Wire bytes are minimized via the `X-Webjs-Have` header (`segment:route-key` entries, so a dynamic layout held for OTHER params is re-rendered rather than short-circuited; the server returns only the divergent fragment, served `private` so a shared cache can never store the reduced body and serve it to a full-page navigation, and additionally marked `Vary: X-Webjs-Have` for caches that honour it (#1140; `Vary` alone was not enough, since Cloudflare honours only `Accept-Encoding`)); scroll is restored on back/forward. **The link-prefetch cache is ANCHOR-VALIDATED** (#1114): a reduced fragment begins at the boundary the server short-circuited on, and on consume the router checks that boundary is still live with the same route-key. A root-anchored fragment therefore survives an unrelated navigation (still a cache hit), while one anchored deeper is discarded once that layout is gone, because applying it would share no boundary with the live DOM and force a full page load. The router also never prefetches the page it is already on (#1106): that request can never serve a later navigation and only occupies a capped cache slot. A non-GET `<form>` that BINDS a server action (`action=${fn}`) is the no-JS write-path (with JS the router posts the same body to the same url and applies the response in place: a `422` swaps without reload, a `303` is followed via fetch). **The router ENCODES that body per the declared `enctype`** (#1307), resolved with native precedence (a submitter's `formenctype` over the form's, decided on PRESENCE rather than on the value being non-empty, #1322: a `formenctype=""` or `formmethod=""` overrides the form and then falls to its OWN invalid-value default, urlencoded and GET respectively, while `formtarget=""` is a plain string reflection with no enumerated states, so it overrides the form and means the current browsing context): `multipart/form-data` sends `FormData`, and `application/x-www-form-urlencoded`, the HTML default and therefore what a plain `<form method="post">` means, sends `URLSearchParams` (a `File` serializes as its name, as the platform does). It previously built `FormData` for everything, so an ordinary POST form sent a urlencoded body with JS off and a multipart body with JS on. A `text/plain` POST is the one encoding the server cannot parse, so the router declines it and lets the browser submit natively, which keeps both paths doing the same thing. A failed navigation recovers in place (a cancelable `webjs:navigation-error` event, else a minimal in-place alert), never a destructive full reload.
|
|
371
386
|
|
|
372
387
|
The advanced client-router surface is in `references/client-router-and-streaming.md`: **link prefetch** (on by default, device-adaptive default: `intent` on a hover pointer, `viewport` (dwell-gated, cancel-on-scroll-out) on touch, per-link `data-prefetch` override), **`<webjs-frame>`** partial-swap regions, **View Transitions** (opt-in via `<meta name="view-transition" content="same-origin">`, where the router checks that exact `content` value and a bare tag enables nothing, plus `data-webjs-permanent` to persist a live element), **stream actions** (`<webjs-stream>` element-level updates, #248), and the **opt-in nav-loading indicator** (`<html data-webjs-nav-progress>` exposes a `data-navigating` attribute during a nav so you can style a CSS-only progress affordance, off by default because toggling a root attribute re-resolves `oklch()` tokens to a one-frame repaint flash on iOS WebKit, #610). Production benefits from HTTP/2 at the edge; `npm run start` speaks plain HTTP/1.1 (put a reverse proxy in front for TLS + HTTP/2).
|
|
373
388
|
|
|
@@ -481,7 +496,7 @@ const result = await optimistic(liked, true, () => likePost(postId));
|
|
|
481
496
|
9. **No backtick characters inside `html\`...\`` template bodies**, even inside CSS / HTML comments. A nested backtick closes the literal at JS-parse time and 500s in prod.
|
|
482
497
|
10. **TypeScript must be erasable.** Set `compilerOptions.erasableSyntaxOnly: true`. No `enum`, no value `namespace`, no constructor parameter properties, no legacy decorators with `emitDecoratorMetadata`, no `import = require`. Types are stripped via Node 24+'s `module.stripTypeScriptTypes` (buildless, no bundler fallback); non-erasable syntax 500s at strip time. Enforced by `erasable-typescript-only` (tsconfig flag) and `no-non-erasable-typescript` (source scan). See `references/typescript.md`.
|
|
483
498
|
|
|
484
|
-
11. **No em-dashes (U+2014), no hyphen or semicolon used as pause-punctuation in prose, and no colon attached to a code-shaped LHS.** Banned as a pause: U+2014, a space-surrounded hyphen between words, a space-surrounded semicolon between words. Banned colon attachments: a colon-then-prose after `xyz()`, a `<my-tag>`, an `[expr]` subscript, or a `<code>foo()</code>` definition list (rephrase verb-led). Prefer a period, comma, a colon on a plain-noun LHS, parentheses, or a restructure. Plain hyphens stay fine in compound words, flags, filenames, ranges
|
|
499
|
+
11. **No em-dashes (U+2014), no hyphen or semicolon used as pause-punctuation in prose, and no colon attached to a code-shaped LHS.** Banned as a pause: U+2014, a space-surrounded hyphen between words, a space-surrounded semicolon between words. Banned colon attachments: a colon-then-prose after `xyz()`, a `<my-tag>`, an `[expr]` subscript, or a `<code>foo()</code>` definition list (rephrase verb-led). Prefer a period, comma, a colon on a plain-noun LHS, parentheses, or a restructure. Plain hyphens stay fine in compound words, flags, filenames, ranges, and semicolons and colons stay fine inside code / TS / CSS and inside JSON SYNTAX. A JSON or front-matter `description`, `title`, or `displayName` VALUE is prose, not code, and is scanned like any other prose. The same hook also enforces brand casing with one simple rule: `WebJs` is a proper noun, so write it capitalized wherever it NAMES the project in prose, at a sentence start AND mid-sentence (`WebJs ships`, `Most WebJs apps`, `the WebJs serializer`). It stays lowercase `webjs` ONLY as a literal code token: a `webjs <subcommand>` CLI command (`webjs dev`, `webjs db migrate`), a `webjs.dev` domain, an `@webjsdev` package, a `"webjs"` config key, a `WEBJS_*` env var, the `webjsdev/webjs` org path, or anything inside a `` `code` `` span or fenced block. If you mean the literal config key or command in prose, wrap it in backticks. Enforced via `.claude/hooks/block-prose-punctuation.sh`, which scans only NEW content (you can still edit an existing line to fix a glyph or casing).
|
|
485
500
|
|
|
486
501
|
12. **A form that writes binds its action: `<form action=${importedAction}>`, and a form whose buttons run different actions binds each on its submitter, `<button formaction=${importedAction}>`** (#1155, #1207, #1307). Those are the two shapes the renderer reads, and every near-miss throws rather than producing a form that posts nowhere. The submitter identity rides the pressed button's own `name`/`value` pair (the one channel a browser submits for that button alone), so no `formaction` url is emitted, both entries reach the server, and the dispatcher takes the LAST `__webjs_action` in DOM order, which is the submitter's whenever one was pressed. **Refused on a form:** a quoted `action="${fn}"`, `action=${fn}` on any tag other than `<form>`, `method="get"` or an enctype the server cannot parse, a `.method` / `.enctype` / `.encoding` PROPERTY binding (a `.prop` drops at SSR and applies in the browser, so the form would submit differently with JS than without), a second `action` hole, a plain `action="/url"` alongside the bound hole (SSR keeps it and the client drops it), a whitespace-padded `method=" post "` (an enumerated attribute is matched against exact keywords, so a padded value falls to the invalid-value default and submits as a GET), and a function that is not a `'use server'` export. **Refused on a submitter:** a control that is not a submit control, an `<input type="image">` (it submits `name.x` / `name.y` coordinates, so the identity never arrives), an `<input type="submit">` (the identity has to occupy its `value`, which on that control is also its visible label, so it would render captioned with the action id and could never be labelled; a `<button>` has no such conflict), a submitter carrying its own `name`, `value`, `form`, or static `formaction`, a `.prop` spelling of any of those (`.name` / `.value` / `.formAction` / `.formMethod` / `.formEnctype` all REFLECT on a submitter, so the write is dropped at SSR and lands in the attribute in the browser), and a second `formaction` hole. **Refused on a BOUND submitter, as a same-element contradiction:** its own `formmethod` other than post, an unparseable `formenctype` such as `text/plain`, and `formmethod="dialog"` (which dismisses a `<dialog>` instead of submitting, so the bound action could never run). A PLAIN submitter's own `formmethod` / `formenctype` is NOT refused: native HTML says the submitter's override wins, the author typed it deliberately, and the form's action simply does not run, so the renderer honours it and the dev-time client guard reports at submit time when a submission holds an identity it cannot deliver. A plain `formaction="/url"` likewise retargets away from the bound action and is the author's business. WebJs supplies `method` and `enctype` (and, on a bound submitter, `formmethod` and `formenctype`) only where your template supplies neither, judged from the TEMPLATE rather than the rendered element: `?method=${false}` emits nothing so it is supplied, while `method=${null}` emits `method=""` and is refused. An `encoding=` attribute is inert in HTML (only the `.encoding` PROPERTY aliases `enctype`), so both renderers ignore it. A page has no `action` export, so a bare `<form method="post">` is a `405`. **A bound submitter is SELF-SUFFICIENT and asks nothing of the form around it** (#1307). The renderer supplies submission attributes at the level where the action is BOUND, and never overrides what you wrote at that same level: a bound `<form>` gains `method="post"` plus `enctype`, and a bound `<button>` gains `formmethod="post"` plus `formenctype` ON THE BUTTON, which is what React does for a function `formAction`. So a per-button action works inside a bound form, an unbound form, a `method="get"` form, or a form with no method at all. That is why the refusal list above contains no rule about a submitter's NEIGHBOURS: the renderer refuses only a SAME-ELEMENT contradiction, which has no correct fallback, and never a cross-element rule, which always has one (whatever native HTML would do). It also removed a question neither renderer could answer honestly, since a COMPONENT renders its own template in a separate pass with no view of the host page and the client may reconcile a submitter whose form is not in the tree yet. One consequence: no `formaction` url is emitted (an empty one is a conformance error), so the submission targets whatever the FORM targets, and a form declaring `action="/x"` sends its buttons there. The action still runs if `/x` is a PAGE route, since the identity travels in the body; against a `route.ts` or another origin the identity is ignored and nothing runs, which the dev-time client guard reports at submit time.
|
|
487
502
|
|
|
@@ -491,7 +506,7 @@ const result = await optimistic(liked, true, () => likePost(postId));
|
|
|
491
506
|
|
|
492
507
|
Two scaffolds exist (do not invent template names): `webjs create <name>` (full-stack: layout, page, components, modules, Drizzle+SQLite, plus a browsable feature gallery), and `webjs create <name> --template api` (backend-only routes + modules + Drizzle, no SSR). Auth is one of the full-stack gallery cards (login + a signed session + a real protected route), so a full-stack app already carries a promotable auth baseline (this replaced the former `--template saas`). The `--db sqlite|postgres` flag (default sqlite) picks the dialect; the schema/queries/actions are identical across dialects (see #563). The `--runtime node|bun` flag (default node, #541) is ORTHOGONAL to the template and re-flavors either for Bun (`bun --bun` dev/start scripts so the SERVER runs on Bun, `bun.lock`, a pure `oven/bun:1` Dockerfile (#595; safe since cli@0.10.20's npx-free `webjs db migrate` (#570) needs no Node in the image) + bun-install CI, bun-command agent docs; the test/db/check tooling stays on Node); `bun create webjs <name>` auto-detects it. Pick from the request: default (full-stack) for any product with UI (todo, blog, dashboard, marketplace, social, e-commerce, a SaaS with accounts/login), `api` for an HTTP/JSON API with no UI; default to full-stack when ambiguous.
|
|
493
508
|
|
|
494
|
-
The `<name>` must be a valid package name (letters, digits, and the separators `-`, `.`, `_`, starting with a letter or a digit, at most 214 characters, and not `node_modules` or `favicon.ico`, which npm reserves). It becomes the app directory, the generated `package.json` `name`, AND a value the scaffold writes into generated source, so anything else is rejected before a single file is written (#1066). Uppercase is fine (`webjs create MyApp` works), since the scaffold's manifest is private and never published.
|
|
509
|
+
The `<name>` must be a valid package name (letters, digits, and the separators `-`, `.`, `_`, starting with a letter or a digit, at most 214 characters, and not `node_modules` or `favicon.ico`, which npm reserves). It becomes the app directory, the generated `package.json` `name`, AND a value the scaffold writes into generated source, so anything else is rejected before a single file is written (#1066). Uppercase is fine (`webjs create MyApp` works), since the scaffold's manifest is private and never published, and the Postgres `DATABASE_URL` the scaffold emits uses a lowercased, separator-folded form of the name (`MyApp` gets `myapp`) so it matches what an unquoted `CREATE DATABASE` produces.
|
|
495
510
|
|
|
496
511
|
Rules: **always scaffold via `webjs create`** (never hand-roll). **Default to a real database (Drizzle + SQLite); NEVER use JSON files, in-memory arrays, or localStorage for persistence.** Update `db/schema.server.ts` to real models FIRST, then `webjs db generate` + `webjs db migrate`, then build pages/actions/queries. **The scaffold ships a gallery index home, a neutral-palette root layout, db wiring, and a densely-commented feature gallery** (single-concept demos under `app/features/` plus the `app/examples/todo` app, with logic in `modules/`), alongside the one cross-agent skill at `.agents/skills/webjs/` (a routing `SKILL.md` + on-demand `references/`) that teaches how to build. Treat the gallery as browsable reference: read the demos to learn the idioms, then build the app the user asked for by growing the scaffold (add routes under `app/`, components under `components/`, features under `modules/<feature>/`, keep server-only code behind `.server.ts`) and prune the demos the app does not use (delete the `app/features/<x>` route AND its `modules/<x>`). Give a UI app its own palette by setting the token values in `app/layout.ts`. There is no design gate and no placeholder gate: taste is the agent's job, not the checker's. Docs at https://webjs.dev/docs.
|
|
497
512
|
|
package/resources/SKILL.md
CHANGED
|
@@ -38,7 +38,7 @@ Classify the task first, then load the smallest useful reference set. Each refer
|
|
|
38
38
|
| Task involves... | Start with |
|
|
39
39
|
| --------------------------------------------------------------------------- | --------------------------------------------- |
|
|
40
40
|
| Pages, layouts, dynamic routes, route handlers, metadata, redirects, 404s | `references/routing-and-pages.md` |
|
|
41
|
-
| Writing components: reactive props, signals, lifecycle, light vs shadow DOM | `references/components.md` |
|
|
41
|
+
| Writing components: what a component owns, reactive props, signals, lifecycle, light vs shadow DOM | `references/components.md` |
|
|
42
42
|
| Why a component's JS was or was not downloaded, `webjs elision`, `static interactive = true` | `references/components.md` |
|
|
43
43
|
| Server actions, mutations, queries, validation, the `ActionResult` envelope | `references/data-and-actions.md` |
|
|
44
44
|
| Sessions, login flows, route protection, `forbidden()` / `unauthorized()` | `references/auth-and-sessions.md` |
|
|
@@ -243,3 +243,5 @@ Success is a 303 (PRG); failure re-renders the page at 422 with the result on `a
|
|
|
243
243
|
- A placeholder first paint that fetches in `connectedCallback`. SSR does not call `connectedCallback`; put first-paint data in the constructor (server-known inputs) or use `async render()`.
|
|
244
244
|
- A browser global (`window`, `document`, `localStorage`) in the constructor or `render()`. It throws at SSR; do browser-only work in `connectedCallback`.
|
|
245
245
|
- Interpolating into a component's `<style>` / `<script>` body. Use `static styles` or Tailwind.
|
|
246
|
+
- Driving a component's markup from a delegated `document` listener in a page or layout, coupled by a class selector. The markup, the state, and the listener belong in one component.
|
|
247
|
+
- Parking one component's UI state on `<body>` or `<html>`. The router's swap range never covers the document shell, so the flag outlives the markup it described. A document-wide SETTING such as the theme is the exception, and a transient effect such as a scroll lock is released in `disconnectedCallback`.
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
## What This Covers
|
|
4
4
|
|
|
5
|
+
- What a component owns (markup, state, listeners, styling), and the rules that follow from it: refs over selectors, no state on `<body>`, ARIA derived in `render()`
|
|
5
6
|
- Declaring reactive properties through the `WebComponent({ ... })` factory and `prop()`, with options (`reflect`, `state`, `attribute`, `default`, `converter`, `hasChanged`)
|
|
6
7
|
- Signals as the default state primitive for component-local and shared state, plus `effect` / `batch`
|
|
7
8
|
- The Lit-aligned lifecycle and exactly which hooks SSR runs versus skips
|
|
@@ -15,6 +16,91 @@
|
|
|
15
16
|
|
|
16
17
|
Read this when you are authoring or reviewing a `WebComponent`. For styling a component (Tailwind, the tag-prefix rule, host sizing) see `styling.md`. For streaming a slow region or programmatic navigation see `client-router-and-streaming.md`. For Lit habits that break WebJs see `muscle-memory-gotchas.md`.
|
|
17
18
|
|
|
19
|
+
## Ownership: what a component owns
|
|
20
|
+
|
|
21
|
+
A component owns four things together: its markup, its state, its listeners, and its styling. The moment one of them lives in a different file from the rest, the feature can no longer be read, tested, or deleted as a unit, and the parts drift apart. These are CONVENTIONS, judged by a reader. `webjs check` has no rule for any of them, and adding one would be wrong, because a sensible app can legitimately want a delegated listener to pass.
|
|
22
|
+
|
|
23
|
+
Most of what follows restates widely held component-model advice, ported. Lit's base class exists to hold reactive state, scoped styles, and a declarative template TOGETHER (the `lit` package README), and Lit documents a ref's value as `undefined` once the node "is no longer rendered", which is precisely the signal a selector lookup cannot give you. React frames the same ideas as lifting state to the closest common owner (react.dev, "Sharing State Between Components") and treating a ref as an escape hatch rather than the normal way to reach a node (react.dev, "Escape Hatches"). Where WebJs moves the boundary, the rule that needs it says so inline and names the mechanism.
|
|
24
|
+
|
|
25
|
+
**1. Markup and the code that drives it live in the same component.** A class selector is not an interface. `document.querySelector('.nav-toggle')` keeps compiling, keeps type-checking, and keeps passing `webjs check` after someone renames the class in the other file. It just starts returning `null` at runtime. If you are writing a selector to find markup that another file rendered, write the component that renders it instead. Where a value genuinely has to exist in two places (a layout's pre-paint inline script cannot import), the second place READS the first declaration rather than restating it.
|
|
26
|
+
|
|
27
|
+
**2. Reach your own rendered node with a ref, never with a selector.** `render()` already owns the node, so let the handle flow out of the template with `ref()` / `createRef()` from `@webjsdev/core/directives` (the directives table below carries the one-line summary). A ref is scoped to the component, so it cannot match a node some other component rendered, and it goes `undefined` when the node stops being rendered, which makes a stale handle visible instead of silent. Two reads a ref cannot express stay vanilla: `this.closest('parent-tag')` for compound-component ancestor lookup, and `assignedNodes()` for slotted content.
|
|
28
|
+
|
|
29
|
+
**3. State lives on the component, never on `<body>` or `<html>`.** The client router swaps a range INSIDE the document, so the document shell sits outside every swap. An open flag parked on `<body>` therefore survives a navigation that removed the markup it described, and it re-opens a panel over the next page or leaves scrolling locked on a page with nothing open. State held in a reactive property or an INSTANCE signal dies with the element, which is the behaviour you wanted in the first place. A module-scope signal deliberately outlives it, which is what rule 7 reaches for, so it is the right home for state genuinely shared between components and the wrong one for one element's own open flag. The carve-out is a document-level EFFECT rather than one component's state, and it comes in two shapes. A TRANSIENT effect, a scroll lock being the usual case, belongs to the element that opened it and must be released in `disconnectedCallback`. A PERSISTENT one is a document-wide SETTING, the theme being the case the framework itself ships: the scaffold's theme toggle writes `data-theme` on `<html>` and persists it, deliberately without releasing it on disconnect, because it describes the document rather than the element (`styling.md` carries that pattern). What the rule forbids is neither of those. It is one component's own open / selected / active flag parked on the shell because that was the convenient place to reach it from.
|
|
30
|
+
|
|
31
|
+
**4. ARIA state is a hole in `render()`, derived from the same state that drives behaviour.** `aria-expanded=${this.open ? 'true' : 'false'}` cannot disagree with `this.open`. A second function that re-finds the button and calls `setAttribute` can, and does, the first time someone adds a close path that forgets to call it. The same holds for `class`, `?disabled`, and any `.prop`. Two caveats ride this rule:
|
|
32
|
+
|
|
33
|
+
- Write the string explicitly for a tri-state ARIA attribute. A plain-attribute hole holding `false` serves `aria-expanded="false"` from the server and hydrates to NO attribute, because the client removes an attribute for `null` / `undefined` / `false` while the server stringifies it. `?attr=${bool}` is not a substitute, since a boolean binding omits the attribute in BOTH renderers.
|
|
34
|
+
- A hole commits on the next render, one microtask later. The one place a direct write is still correct is a synchronous snapshot read such as `webjs:before-cache`, where the router reads `outerHTML` in the same task. That is a documented exception, not the normal path.
|
|
35
|
+
|
|
36
|
+
**5. Behaviour needs an importable surface, or its test is a copy of it.** An inline `<script>` in a layout has no module identity, so a browser test cannot import it. It can only transcribe the listener into the test file and assert against the transcription, which then needs a SECOND test to grep the original for drift. Two tests, neither running shipping code. A component is importable, so its browser test mounts the real element and drives real events. A page or layout may still carry an inline `<script>`, but only for pre-paint boot work no module can do: reading a stored theme before first paint so the wrong palette never flashes, or measuring the header height into a CSS custom property. It must not be interactivity, and WHERE it sits decides how often it runs. The ROOT layout's markup sits OUTSIDE every swap range, so a soft navigation does not re-run its script, which is what makes it the right home for boot work and the wrong home for anything that has to respond to a later navigation. A page or a NESTED layout sits inside the swap range instead, so its script re-executes on every navigation that swaps that range (#1102), which means it has to be idempotent or guard on a flag it sets the first time. Neither shape gives you a listener that simply works, which is what a custom element is for. Under an opt-in CSP the script also needs the nonce from `cspNonce()`. `client-router-and-streaming.md` carries the full re-execution rule.
|
|
37
|
+
|
|
38
|
+
**6. Listening on `document` is legitimate. Querying `document` usually is not.** An outside-click dismissal or an Escape handler has no choice, because the event happens outside the element, so the listener has to be global. What decides whether that is ownership or a reach across the app is what the handler then READS. `this.contains(e.target)` is a decision about the component's own subtree. `document.querySelector('.other-thing')` is a decision about someone else's markup. Add the listener in `connectedCallback`, remove it in `disconnectedCallback`, and store the handler in a field so `removeEventListener` gets the same reference back (a function created inline at add time can never be removed).
|
|
39
|
+
|
|
40
|
+
**7. Talk to an ancestor with an event, and to a stranger with a module-scope signal.** A child telling its own ancestor something dispatches a `CustomEvent` with `bubbles: true`, and the ancestor binds `@my-event=${...}` in the template that rendered it. Add `composed: true` as well when the component sets `static shadow = true`, or the event stops at the shadow boundary. Two components with NO ancestor relationship share a module-scope `signal` that both import, which is typed, greppable, and owned by a module. What neither case is: a made-up event name on `document` used as a global bus, which is a global variable with extra steps. Framework events such as `webjs:navigate` ride `document` because the router has no element to dispatch from, and that is not a licence to add your own.
|
|
41
|
+
|
|
42
|
+
The shape to fix, all four pieces in different places:
|
|
43
|
+
|
|
44
|
+
```js
|
|
45
|
+
// In a layout's inline script, driving markup that another file rendered.
|
|
46
|
+
document.addEventListener('click', (e) => {
|
|
47
|
+
if (e.target.closest('.nav-toggle')) document.body.toggleAttribute('data-nav-open');
|
|
48
|
+
});
|
|
49
|
+
function syncNav() {
|
|
50
|
+
const btn = document.querySelector('.nav-toggle'); // another file's markup
|
|
51
|
+
const open = document.body.hasAttribute('data-nav-open'); // outlives the markup
|
|
52
|
+
if (btn) btn.setAttribute('aria-expanded', String(open)); // a second home for the state
|
|
53
|
+
}
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
The shape to write, one component owning all four:
|
|
57
|
+
|
|
58
|
+
```ts
|
|
59
|
+
import { WebComponent, prop, html } from '@webjsdev/core';
|
|
60
|
+
import { createRef, ref } from '@webjsdev/core/directives';
|
|
61
|
+
|
|
62
|
+
class NavDrawer extends WebComponent({ open: prop(Boolean, { reflect: true }) }) {
|
|
63
|
+
private toggleRef = createRef<HTMLButtonElement>();
|
|
64
|
+
// Stored in a field, so removeEventListener gets the same reference back.
|
|
65
|
+
private onDocClick = (e: MouseEvent) => {
|
|
66
|
+
if (!this.contains(e.target as Node)) this.open = false; // reads its OWN subtree
|
|
67
|
+
};
|
|
68
|
+
private onDocKeydown = (e: KeyboardEvent) => {
|
|
69
|
+
if (e.key !== 'Escape' || !this.open) return;
|
|
70
|
+
this.open = false;
|
|
71
|
+
// The ref lands after the FIRST client commit, and `ref()` is a no-op at
|
|
72
|
+
// SSR, so read `.value` from a handler or `firstUpdated`, never from the
|
|
73
|
+
// constructor. This is the reach a selector would otherwise have done.
|
|
74
|
+
this.toggleRef.value?.focus();
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
constructor() { super(); this.open = false; } // SSR runs the constructor
|
|
78
|
+
|
|
79
|
+
connectedCallback() {
|
|
80
|
+
super.connectedCallback();
|
|
81
|
+
document.addEventListener('click', this.onDocClick); // listening globally is fine
|
|
82
|
+
document.addEventListener('keydown', this.onDocKeydown);
|
|
83
|
+
}
|
|
84
|
+
disconnectedCallback() {
|
|
85
|
+
super.disconnectedCallback();
|
|
86
|
+
document.removeEventListener('click', this.onDocClick); // the state dies with the element
|
|
87
|
+
document.removeEventListener('keydown', this.onDocKeydown);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
render() {
|
|
91
|
+
return html`
|
|
92
|
+
<button ${ref(this.toggleRef)}
|
|
93
|
+
aria-expanded=${this.open ? 'true' : 'false'}
|
|
94
|
+
@click=${() => { this.open = !this.open; }}>Menu</button>
|
|
95
|
+
<nav ?hidden=${!this.open}><slot></slot></nav>
|
|
96
|
+
`;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
NavDrawer.register('nav-drawer');
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
This repo's own website is the worked example. Before commit `b80de906` the docs drawer and the header menu were exactly the first shape, and every accessibility bug their tests now pin came out of the split. `website/components/docs-drawer.ts` and `website/components/site-nav-menu.ts` are the second shape, and `website/AGENTS.md` records the app-level version of these rules under "What stays inline script in the root layout".
|
|
103
|
+
|
|
18
104
|
## Reactive properties: the base-class factory
|
|
19
105
|
|
|
20
106
|
Reactive properties are declared by passing their shape into `WebComponent({ ... })`. The types flow automatically to `this.<prop>`, so there is NO `static properties` block and NO `declare` line (a `static properties` block throws at runtime, caught by `no-static-properties`).
|
|
@@ -48,7 +134,7 @@ The bare form is shorthand: `count: Number` means `prop(Number)`. Use `prop()` t
|
|
|
48
134
|
| Option | Default | Meaning |
|
|
49
135
|
|---|---|---|
|
|
50
136
|
| `type` | `String` | Constructor feeding the default attribute converter |
|
|
51
|
-
| `reflect` | `false` | Property changes write back to the HTML attribute (a
|
|
137
|
+
| `reflect` | `false` | Property changes write back to the HTML attribute (a value with no attribute representation removes it instead, see below) |
|
|
52
138
|
| `state` | `false` | Internal-only. No attribute, not observed |
|
|
53
139
|
| `attribute` | derived from name | The HTML attribute name the property rides |
|
|
54
140
|
| `default` | none | Declarative initial value (a function runs per instance for a fresh object / array) |
|
|
@@ -59,6 +145,8 @@ For an array-typed prop pass `Array`, not `Object` (`array-prop-uses-array-type`
|
|
|
59
145
|
|
|
60
146
|
**A `reflect: true` property holding a FUNCTION drops its attribute instead of writing one, and so does one holding an array that carries a function, unless the prop is `Object` or `Array` typed.** A function has no HTML attribute representation, and the serializations it would otherwise get are both useless and dangerous. `String(fn)` is the function's SOURCE, so a reflected `'use server'` action would ship its whole body, closure secrets included, to every visitor, and `JSON.stringify(fn)` is `undefined`, which lands in the attribute as the literal four-character string. So the reflection path treats a function like `null`, removes the attribute, and warns naming the property, the tag, and the attribute. This holds on both sides, since SSR and the client-side setter run the same path, and it holds for every property name (the leak was never specific to one called `action`). Two exceptions. A property with a custom `converter.toAttribute` runs that converter first and is left alone, because an author who writes one has taken responsibility for serializing whatever they are handed. And an `Object` or `Array` typed property CARRYING a function keeps its data, because `JSON.stringify` drops the function to `null` and omits the key, so `[1, 2, fn]` reflects as `[1,2,null]` with no source and nothing else lost. If you need a function on a component, use a plain property or a signal and do not mark it `reflect`.
|
|
61
147
|
|
|
148
|
+
**An `Object` or `Array` typed reflected property whose value `JSON.stringify` cannot serialize AT ALL drops its attribute the same way, and warns.** Three shapes do this: a cycle (an object or array that reaches itself, which arrives from a parent/child graph, a linked node, a memo table, or anything a library hands back with a back-reference), a `BigInt` anywhere inside the value, and an author `toJSON()` that throws. The line to keep straight is that a value which serializes WITH A GAP in it keeps its data (the carried-function case above), while one that does not serialize at all has no string to put in the attribute and so has no attribute representation, exactly like a function. The property itself is untouched and still holds the value; only the attribute goes. Before this guard the throw escaped reflection entirely, which meant a client upgrade threw before the component's first render, and an SSR render was swallowed by per-component error isolation, which shows an error box in dev and renders the component EMPTY on a page that still returned 200 in production. To reflect something about a graph-shaped value, reflect a derived scalar (an id, a count) and keep the graph on a non-reflected property. On the read side an attribute that is PRESENT but not parseable JSON reads back as `null` rather than as the raw string, on both the SSR and the client reader. An ABSENT attribute is a different case: neither reader sees it, so the property keeps its constructor value.
|
|
149
|
+
|
|
62
150
|
**Never use a class-field declaration OR initializer** (`count = 0`, `student: Student = {...}`, `todos!: Todo[]`). Under `useDefineForClassFields` even a type-only `todos!: Todo[]` compiles to define an own property after `super()`, which clobbers the prototype's reactive accessor and silently breaks reactivity. Only declare props in the factory and read/write them off `this`. The `reactive-props-no-class-field` rule catches this.
|
|
63
151
|
|
|
64
152
|
## Signals are the default state primitive
|
|
@@ -288,4 +288,4 @@ Context providers publish on connect via `hostConnected`, which does not run at
|
|
|
288
288
|
|
|
289
289
|
### Vanilla DOM instead of Lit idioms
|
|
290
290
|
|
|
291
|
-
WebJs components are Lit-shaped on purpose: the value is the declarative DX. Prefer a factory-declared reactive prop over `this.getAttribute`, a `signal` over a `state: true` prop for internal state, a `class=${...}` binding over `this.classList`, a `@click=${...}` binding over `this.addEventListener`, and `C.register('x')` over `customElements.define`. Vanilla DOM stays right only where the platform offers nothing declarative: `this.closest('ui-tabs')` for compound-component ancestor lookup (resolves at SSR too), slotted-content queries, global `document` / `window` listeners, and imperative `el.focus()`. This is a convention, not a lint rule.
|
|
291
|
+
WebJs components are Lit-shaped on purpose: the value is the declarative DX. Prefer a factory-declared reactive prop over `this.getAttribute`, a `signal` over a `state: true` prop for internal state, a `class=${...}` binding over `this.classList`, a `@click=${...}` binding over `this.addEventListener`, and `C.register('x')` over `customElements.define`. Vanilla DOM stays right only where the platform offers nothing declarative: `this.closest('ui-tabs')` for compound-component ancestor lookup (resolves at SSR too), slotted-content queries, global `document` / `window` listeners, and imperative `el.focus()`. This is a convention, not a lint rule. A global `document` / `window` LISTENER is one of those legitimate cases, because the event happens outside the element. A document QUERY is not: reaching for markup that another component rendered is the jQuery habit to drop, and for your OWN rendered node a `ref` replaces the selector entirely. The ownership rules at the top of `components.md` state the full test.
|
|
@@ -179,6 +179,8 @@ Three responses that are not the happy path:
|
|
|
179
179
|
|
|
180
180
|
The submission is Origin-verified (the same `Sec-Fetch-Site` / `Origin` check the RPC endpoint applies), so a no-JS form needs no CSRF token field.
|
|
181
181
|
|
|
182
|
+
A submitter's own `formmethod` / `formenctype` / `formtarget` overrides the form's on PRESENCE, not on the value being non-empty, and the client router resolves them the same way (#1322). So `<button type="submit" formmethod="">` really does submit as a GET, because a present-but-empty enumerated attribute falls to its own invalid-value default rather than inheriting the form's `method="post"`.
|
|
183
|
+
|
|
182
184
|
Refusals worth knowing: `formaction=${fn}` is supported on a `<button>` anywhere, bound form or not (#1307: the renderer gives the button its own `formmethod` and `formenctype`), and that button may not carry `name`, `value`, `form`, or a static `formaction` attribute (`<input type="submit">` is refused, because the identity needs its `value`, which is also its label). A bound form may not declare `method="get"`, and a function bound to `action=` that is not a `'use server'` export throws at render rather than producing a form that posts nowhere. See `muscle-memory-gotchas.md` for the full table.
|
|
183
185
|
|
|
184
186
|
## Error, loading, and 404 boundaries
|
|
@@ -70,7 +70,7 @@ Avoid `@apply`: it hides which utilities a class uses and creates a second sourc
|
|
|
70
70
|
|
|
71
71
|
### A design system for repeated PRIMITIVES: class helpers built on `@webjsdev/ui`
|
|
72
72
|
|
|
73
|
-
An `html`-fragment helper is right for a repeated CHUNK of markup (the rubric above). For a repeated UI PRIMITIVE (button, input, card, badge) that needs variants and sizes, use a class helper instead: a function that returns a Tailwind class STRING you spread onto a native element. That is exactly what `@webjsdev/ui` ships (`buttonClass({ variant, size })`, `cardClass()`, `inputClass()`, `badgeClass({ variant })`), and it is what the scaffold gallery uses in `components/ui/`. To style a ONE-OFF that a variant does not cover (a circular icon button, a pill), compose the helper and override the bespoke bits with `cn()`: `cn(buttonClass({ variant: 'secondary', size: 'none' }), 'w-9 h-9 rounded-full')`. `cn` resolves Tailwind conflicts so a later class wins, including a shorthand over the axis it subsumes (`p-0` beats an earlier `px-4 py-2`), so an override just works. Conflicts are keyed on the CSS PROPERTY wherever `cn` can tell the properties apart, rather than on the shared class prefix, so the common prefix collisions do NOT evict: `cn('border-2', 'border-primary')` keeps both (a width and a colour), `cn('flex', 'flex-1')` keeps both (a `display` and a `flex-grow`, the shape an element that is both a flex container and a flex child needs), and an arbitrary value carrying a type hint is read as the property the hint names (`cn('shadow-lg', 'shadow-[color:red]')` keeps both). It is a small hand-rolled merger, not `tailwind-merge`, so
|
|
73
|
+
An `html`-fragment helper is right for a repeated CHUNK of markup (the rubric above). For a repeated UI PRIMITIVE (button, input, card, badge) that needs variants and sizes, use a class helper instead: a function that returns a Tailwind class STRING you spread onto a native element. That is exactly what `@webjsdev/ui` ships (`buttonClass({ variant, size })`, `cardClass()`, `inputClass()`, `badgeClass({ variant })`), and it is what the scaffold gallery uses in `components/ui/`. To style a ONE-OFF that a variant does not cover (a circular icon button, a pill), compose the helper and override the bespoke bits with `cn()`: `cn(buttonClass({ variant: 'secondary', size: 'none' }), 'w-9 h-9 rounded-full')`. `cn` resolves Tailwind conflicts so a later class wins, including a shorthand over the axis it subsumes (`p-0` beats an earlier `px-4 py-2`), so an override just works. Conflicts are keyed on the CSS PROPERTY wherever `cn` can tell the properties apart, rather than on the shared class prefix, so the common prefix collisions do NOT evict: `cn('border-2', 'border-primary')` keeps both (a width and a colour), `cn('flex', 'flex-1')` keeps both (a `display` and a `flex-grow`, the shape an element that is both a flex container and a flex child needs), `cn('shadow-lg', 'shadow-red-500')` keeps both (a box-shadow and its colour), `cn('bg-clip-text', 'bg-primary')` keeps both (a clip and a colour, so the gradient-text idiom survives a later background), and an arbitrary value carrying a type hint is read as the property the hint names (`cn('shadow-lg', 'shadow-[color:red]')` keeps both). It is a small hand-rolled merger, not `tailwind-merge`, so it is still coarse in two ways. A prefix outside the families it knows is not grouped at all, so both classes are emitted and the winner is left to compiled stylesheet order (`inset-shadow-sm` against `inset-shadow-red-500`, `ring-2` against `ring-red-500`). And where one prefix carries two properties it reads the value against Tailwind's DEFAULT scales, so a `@theme`-extended name it cannot know about can still be misread and evict the wrong class: a custom `--shadow-card` makes `shadow-card` a box-shadow, but `cn` sees an unfamiliar name under a prefix whose bare names are usually colours and treats it as one. When an override has to win and you are unsure, pass the one class rather than layering, or install `clsx` + `tailwind-merge` and replace the helper (its header comment shows the swap). For an icon button prefer `size: 'none'` (it states "I supply my own box" by dropping the helper's padding + radius) over layering a `p-0` on top of the default size.
|
|
74
74
|
|
|
75
75
|
```ts
|
|
76
76
|
// components/ui/button.ts (npx webjsdev ui add button, themed to your app)
|
|
@@ -189,6 +189,14 @@ WEBJS_ELIDE=0 npm run test:e2e
|
|
|
189
189
|
|
|
190
190
|
A test that passes under one and fails under the other is a wrong verdict, and `webjs elision` tells you which module and on what evidence. If the component's interactivity is genuinely invisible to static analysis, the fix is `static interactive = true` on it; see `components.md` for what that override does and does not rescue.
|
|
191
191
|
|
|
192
|
+
## Type-checking your tests (`webjs typecheck`)
|
|
193
|
+
|
|
194
|
+
Your tests are inside the tsconfig `include`, so `npm run typecheck` reads them (#1299). Treat a type error in a test as a failed gate, not a review catch: the checker sees a wrong argument shape or an unannotated parameter in a test the same way it sees one in `app/`.
|
|
195
|
+
|
|
196
|
+
Write them to the same bar as app code, then. No `any`, no blanket `@ts-expect-error`. When a test needs a complete props object the framework would normally build, put a small typed helper in `test/helpers/` and import it rather than reaching for a cast; a cast in a test silences the one thing that would have told you the call was wrong.
|
|
197
|
+
|
|
198
|
+
`.js` test files follow whatever `checkJs` says. With it off they are parsed and not checked, which is the usual setup for browser tests a real browser runs.
|
|
199
|
+
|
|
192
200
|
## Convention validation (`webjs check`)
|
|
193
201
|
|
|
194
202
|
`npm run check` is the correctness validator. Every rule catches code that is wrong to ship, a crash, a security leak, a reactive prop that silently stops re-rendering, or a type-strip failure. Run it and fix every violation before considering the change done (`npm run check -- --json` for an agent loop, `npm run check -- --rules` to list the rules). It is separate from `CONVENTIONS.md`, which carries the customizable project conventions you follow by judgment.
|
|
@@ -62,19 +62,33 @@ Prefer explicit `.ts` extensions in imports. A `.js` specifier pointing at a `.t
|
|
|
62
62
|
"module": "NodeNext",
|
|
63
63
|
"moduleResolution": "NodeNext",
|
|
64
64
|
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
|
65
|
+
"types": ["node"],
|
|
65
66
|
"strict": true,
|
|
66
67
|
"noEmit": true,
|
|
67
|
-
"checkJs": true,
|
|
68
|
-
"allowJs": true,
|
|
69
68
|
"allowImportingTsExtensions": true,
|
|
70
69
|
"skipLibCheck": true,
|
|
71
70
|
"erasableSyntaxOnly": true
|
|
72
|
-
}
|
|
71
|
+
},
|
|
72
|
+
"include": [
|
|
73
|
+
"app/**/*",
|
|
74
|
+
"components/**/*",
|
|
75
|
+
"modules/**/*",
|
|
76
|
+
"lib/**/*",
|
|
77
|
+
"test/**/*",
|
|
78
|
+
"middleware.js",
|
|
79
|
+
"middleware.ts",
|
|
80
|
+
".webjs/routes.d.ts"
|
|
81
|
+
],
|
|
82
|
+
"exclude": ["node_modules", ".webjs/vendor", "db/migrations"]
|
|
73
83
|
}
|
|
74
84
|
```
|
|
75
85
|
|
|
76
86
|
`erasableSyntaxOnly: true` is the non-negotiable line. It aligns the compiler's accepted syntax with the stripper's, so violations surface as diagnostics instead of a runtime 500.
|
|
77
87
|
|
|
88
|
+
`test/**/*` is in the `include` on purpose (#1299), the way Next / Remix / Astro's generated configs cover the whole tree. Leave it there. A test file outside the `include` is a file `webjs typecheck` never opens, so an implicitly-`any` parameter or a wrong argument shape in a test survives until somebody reads the line, which is exactly how one reached review here. Do not add a second `tsconfig.test.json` either: a config nobody remembers to run reproduces the same gap in a new place.
|
|
89
|
+
|
|
90
|
+
Note what is absent: `checkJs`, the flag mentioned at the top of this file for a JSDoc-typed codebase (it implies `allowJs`, so it is the only one you add). Turning it on makes `tsc` read your `.js` files, which is the point, but it also pulls in browser tests written as `.js`. Those run in a real browser through web-test-runner, so their test globals are not in scope for `tsc` and each one reports a `Cannot find name 'test'`. Turn it on deliberately, and give the browser tests a `types` entry or their own exclude when you do.
|
|
91
|
+
|
|
78
92
|
## Full-stack type safety
|
|
79
93
|
|
|
80
94
|
### The rule: derive the type, never `unknown` or `any`
|