@webjsdev/cli 0.10.51 → 0.10.53
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 +307 -5
- package/lib/app-name.js +73 -0
- package/lib/create.js +15 -4
- package/lib/doctor.js +168 -28
- package/package.json +1 -1
- package/templates/.agents/skills/webjs/SKILL.md +6 -3
- package/templates/.agents/skills/webjs/references/built-ins.md +1 -1
- package/templates/.agents/skills/webjs/references/client-router-and-streaming.md +10 -0
- package/templates/.agents/skills/webjs/references/components.md +140 -3
- package/templates/.agents/skills/webjs/references/data-and-actions.md +53 -1
- package/templates/.agents/skills/webjs/references/muscle-memory-gotchas.md +28 -8
- package/templates/.agents/skills/webjs/references/optimistic-ui.md +1 -1
- package/templates/.agents/skills/webjs/references/routing-and-pages.md +3 -1
- package/templates/.agents/skills/webjs/references/styling.md +1 -1
- package/templates/.agents/skills/webjs/references/testing.md +27 -0
- package/templates/.agents/skills/webjs/references/typescript.md +17 -3
- package/templates/.claude/hooks/block-prose-punctuation.sh +66 -24
- package/templates/gallery/modules/async-render/components/server-clock.ts +3 -2
- package/templates/gallery/modules/directives/components/directive-demo.ts +4 -1
- package/templates/gallery/modules/gallery/components/gallery-nav.ts +5 -0
- package/templates/gallery/modules/todo/actions/submit-todo.server.ts +5 -3
- package/templates/test/hello/e2e/hello.test.ts +26 -1
|
@@ -135,7 +135,7 @@ import { createPost } from '#modules/posts/actions/create-post.server.ts';
|
|
|
135
135
|
html`<form action=${createPost}><input name="title"></form>`;
|
|
136
136
|
```
|
|
137
137
|
|
|
138
|
-
The renderer omits the `action` attribute so the form posts to the page's own url, supplies `method="post"` and an enctype, and emits a hidden `__webjs_action` field carrying the action's `<hash>/<fn>` identity, the same identity the RPC endpoint resolves. Nothing about the action's source reaches the browser. With JS off this is an ordinary HTML submission; with JS the client router posts the same body to the same url, so the two paths are identical by construction.
|
|
138
|
+
The renderer omits the `action` attribute so the form posts to the page's own url, supplies `method="post"` and an enctype, and emits a hidden `__webjs_action` field carrying the action's `<hash>/<fn>` identity, the same identity the RPC endpoint resolves. Nothing about the action's source reaches the browser. With JS off this is an ordinary HTML submission; with JS the client router posts the same body to the same url, encoded per the declared `enctype` (#1307: multipart stays `FormData`, urlencoded, which is the HTML default, is sent as `URLSearchParams`), so the two paths are identical by construction.
|
|
139
139
|
|
|
140
140
|
**A form-bound action always receives the `FormData`**, which is where it differs from the same function called over RPC (rich arguments) or server-to-server. `validate` is the typing seam: it takes the `FormData` and its transform-return becomes the action's typed input.
|
|
141
141
|
|
|
@@ -236,3 +236,55 @@ import { posts } from '#db/schema.server.ts';
|
|
|
236
236
|
```
|
|
237
237
|
|
|
238
238
|
Keep the wire shape in a browser-safe `modules/<feature>/types.ts` with NO runtime import from a `.server.ts` file or from `db/`. Define a hand-written DTO, or a type-only derivation (`import type { Post } ...; export type PostFormatted = Omit<Post, 'createdAt'> & { createdAt: string }`). Never `export *` or a value re-export from a `.server.ts` in `types.ts`; that carries the runtime table bindings and breaks any component importing the types. Full reference at https://webjs.dev/docs.
|
|
239
|
+
|
|
240
|
+
## SSR action seeding, and how to tell it is working
|
|
241
|
+
|
|
242
|
+
When a shipping component's `async render()` awaits an action during SSR, WebJs serializes that result into the page and the generated RPC stub reads it on its FIRST client call. So `const u = await getUser(this.id)` runs once, on the server, and hydration reuses the result with no network round-trip.
|
|
243
|
+
|
|
244
|
+
**You write nothing for this.** It is automatic, on by default, and there is no API to call. The only thing you can do is break it, so the section below is about noticing when you have.
|
|
245
|
+
|
|
246
|
+
### The correctness boundary
|
|
247
|
+
|
|
248
|
+
A seed hit returns the value the SSR render that produced this page computed for exactly this action, function, and argument list, so a hit cannot show the user something different from the HTML they are already looking at. A page navigation evicts whatever the outgoing page left unconsumed, both the block still in the DOM and anything already ingested from it, so a departed render's value is never served. On an HTML-cached page (`export const revalidate`) the seed rides inside the cached bytes, so it is exactly as fresh as the HTML it came with. A miss simply re-fetches.
|
|
249
|
+
|
|
250
|
+
There is one shape where a hit can differ from the paint, and WebJs warns about it in dev: **an action that returns a DIFFERENT result for the SAME arguments twice in one render.** The seed carries the last result while the first component painted the first one. So keep an action deterministic for a given argument list. A counter, a `Math.random()`, a `new Date()` in the return value, or a read of mutable module state all break that rule, and dev prints:
|
|
251
|
+
|
|
252
|
+
```
|
|
253
|
+
[webjs] SSR action seeding: "getUser" returned two DIFFERENT results for the SAME arguments during one render. ...
|
|
254
|
+
```
|
|
255
|
+
|
|
256
|
+
The fix is to make the action deterministic, or to move the varying part into an argument so the two calls get different keys.
|
|
257
|
+
|
|
258
|
+
### Reading the dev diagnostics
|
|
259
|
+
|
|
260
|
+
A miss is invisible from the outside: the page still renders correctly, it just pays a round-trip per async component on every first load. Two channels make it visible in dev, and neither exists in production.
|
|
261
|
+
|
|
262
|
+
**Server side, per request.** The `X-Webjs-Seed` response header, also folded into the dev access-log line as a `seed` field:
|
|
263
|
+
|
|
264
|
+
| Value | What it means |
|
|
265
|
+
|---|---|
|
|
266
|
+
| `off` | Seeding is switched off (`"webjs": { "seed": false }` or `WEBJS_SEED=0`). Not a defect. |
|
|
267
|
+
| `html-cache` | The #241 HTML response cache answered. The seeds rode inside the cached bytes. |
|
|
268
|
+
| `collected=3, emitted=3` | Healthy. Three action results were captured and all three reached the page. |
|
|
269
|
+
| `collected=3, emitted=0` | The serializer threw and dropped the whole block. Something in a returned value is not serializer-safe. |
|
|
270
|
+
| `collected=3, emitted=0, streamed` | The page streams, so nothing could be emitted (see below). |
|
|
271
|
+
|
|
272
|
+
Check it with `curl -sSI localhost:3000/` or in the network tab.
|
|
273
|
+
|
|
274
|
+
**Browser side, per page view.** One `console.warn` at the first idle after hydration, and only when a call missed AND the client can be certain why. It stays silent otherwise, including on a page that emitted no seeds at all: every action call routes through the seed lookup, including ones that were never SSR-invoked and never could have been seeded (a mutation, a `Task` autorun, a `connectedCallback` read), so a miss there is not evidence of a defect. That case is the server header's job, where `collected=0` is unambiguous. The line names one of these:
|
|
275
|
+
|
|
276
|
+
- *"This page streams"*, so no seeds could be emitted. Expected, not a bug (see below).
|
|
277
|
+
- *"The page's seeds could not be serialized."* Something an action returned is not serializer-safe, so the whole block was dropped. The response header shows `collected` above `emitted` for the same reason.
|
|
278
|
+
- *"The page seeded these actions under DIFFERENT arguments."* The key is `hash(action file) / function name / serialized arguments`, so the client asked with an argument the SSR render never used. Common cause: the component computes its argument from browser-only state (a `localStorage` read, a `connectedCallback` assignment), which the server render could not have known. A miss on an action the page never seeded at all is NOT reported, because a mutation or a client-only read routes through the same lookup and could never have been seeded.
|
|
279
|
+
|
|
280
|
+
A miss AFTER hydration is correct and is not reported: the seed is consume-once, so a deliberate refetch or an argument change is supposed to go to the network.
|
|
281
|
+
|
|
282
|
+
`seedStats()` from `@webjsdev/core` returns `{ ingested, replaced, hits, misses, keyMisses, pending }` (`keyMisses` being the provable subset of `misses`, a call for an action the page seeded under other arguments) if you want to assert this in a browser test or read it from the console. A non-zero `pending` at rest usually means the seeding component ELIDED, so its module never shipped and nothing on the client was ever going to consume the seed. `pending` covers the page you are on: a page navigation evicts whatever the outgoing page left unconsumed, both the block still sitting in the DOM and anything already ingested from it, since those values belong to a render no longer on screen.
|
|
283
|
+
|
|
284
|
+
### The streamed-page exception
|
|
285
|
+
|
|
286
|
+
A page carrying a `Suspense` or `<webjs-suspense>` boundary emits NO seed block at all, not just none for the streamed region: a streamed render's deferred boundaries resolve after the first flush, so their results cannot ride the block. Every action call on that page goes to the network on hydration. That is a real trade, so make it deliberately: reach for a streaming boundary when a slow region would otherwise block the first byte, and leave a fast page buffered so it seeds.
|
|
287
|
+
|
|
288
|
+
### Switching it off
|
|
289
|
+
|
|
290
|
+
`"webjs": { "seed": false }` in `package.json`, or `WEBJS_SEED=0`. The client then re-fetches on hydration exactly as it did before the feature, and stale-while-revalidate hides the flicker. Turn it off only to isolate a problem; there is no reason to ship with it off.
|
|
@@ -64,7 +64,21 @@ html`<form action=${saveDraft}>
|
|
|
64
64
|
</form>`;
|
|
65
65
|
```
|
|
66
66
|
|
|
67
|
-
The identity rides the pressed button's own `name`/`value` pair, which a browser submits for that button alone, so this works with JS off exactly as it does with JS on. Both entries reach the server and the LAST wins, which is always the submitter's when one was pressed.
|
|
67
|
+
The identity rides the pressed button's own `name`/`value` pair, which a browser submits for that button alone, so this works with JS off exactly as it does with JS on. Both entries reach the server and the LAST wins, which is always the submitter's when one was pressed.
|
|
68
|
+
|
|
69
|
+
**A bound submitter is self-sufficient, so the enclosing form does not have to be bound** (#1307). The renderer puts `formmethod="post"` and `formenctype="multipart/form-data"` on the button itself, alongside the identity, exactly as React emits `formMethod` and `formEncType` on a button carrying 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:
|
|
70
|
+
|
|
71
|
+
```js
|
|
72
|
+
// Every button below submits a POST its action can read, with JS on or off.
|
|
73
|
+
html`<form>
|
|
74
|
+
<button formaction=${saveDraft}>Save</button>
|
|
75
|
+
<button formaction=${publishPost}>Publish</button>
|
|
76
|
+
</form>`;
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
One consequence worth knowing rather than discovering: no `formaction` url is emitted (an empty one is an HTML conformance error), so the submission targets whatever the FORM targets. A form declaring its own `action="/x"` sends its buttons to `/x`, which is native precedence. The action still runs if `/x` is a PAGE route, because the identity travels in the body, but against a `route.ts` or another origin the identity is ignored and nothing runs. In dev the client logs a warning at submit time naming the url. Leaving the form's `action` off, the ordinary shape, keeps the submission on the current page.
|
|
80
|
+
|
|
81
|
+
The submitter must be a `<button>` and cannot carry its own `name`, `value`, or `form` attribute, because the identity already occupies that pair. `<input type="submit">` is refused for the binding: the identity has to occupy its `value`, which on that control is also the visible caption, so the button would render captioned with the action id and could never be labelled. A `<button>` has no such conflict, since its label is its children.
|
|
68
82
|
|
|
69
83
|
**Only the bare, unquoted `action=${fn}` on a `<form>` binds.** Every near-miss is a hard render error rather than a silently-inert form, and the reason is a source leak. During SSR a `.server.ts` import is the ACTUAL function (the RPC stub exists only in the browser), and `action=` is an ordinary attribute hole, so stringifying it would write the function's body into the HTML every visitor downloads, including any literal inside it. The renderer throws instead, on the server and on the client, for `action=` and `formaction=` alike.
|
|
70
84
|
|
|
@@ -95,21 +109,21 @@ The bound, refused, and allowed shapes in full. Every "no" row is a binding that
|
|
|
95
109
|
| `action=${fn}` unquoted, on a `<form>` | **no, it BINDS** | the one supported shape: the identity is resolved and emitted as a hidden field, nothing is stringified |
|
|
96
110
|
| `action=${fn}` on any other tag | yes | `action` submits nothing off a `<form>`, so it is an ordinary attribute and the function would be stringified |
|
|
97
111
|
| `action="${fn}"`, or a mixed `action="/x/${fn}"` | yes | quoting turns a binding hole back into a plain attribute |
|
|
98
|
-
| `formaction=${fn}` unquoted, on a submitter
|
|
99
|
-
| `formaction=${fn}` inside an UNBOUND `<form>` | yes | `method="post"` and the enctype are forced on the FORM's start tag, which SSR has already emitted by the time it reaches the button, so a per-button action cannot retrofit them |
|
|
112
|
+
| `formaction=${fn}` unquoted, on a submitter, ANYWHERE | **no, it BINDS** | the second supported shape (#1207, #1307). A bound submitter carries its WHOLE submission: the identity rides the button's own `name`/`value` pair, the one channel a browser submits for the pressed button alone, and the renderer adds `formmethod="post"` and `formenctype="multipart/form-data"` to the button itself. So it works inside a bound form, an unbound form, a `method="get"` form, or a form with no method at all, and it asks NOTHING of the element around it. No `formaction` url is emitted, and the server takes the LAST `__webjs_action` entry |
|
|
100
113
|
| `formaction=${fn}` on a submitter carrying its own `name` or `value` | yes | the identity IS that name/value pair, so both halves are already spoken for. Bind one action on the form and dispatch on `name="intent"` if you need the button's own value |
|
|
101
114
|
| `formaction=${fn}` on a non-submit control, or `<input type="image">` | yes | `formaction` is inert on anything that does not submit, and an image submitter sends `name.x` / `name.y` coordinates instead of `name=value`, so the identity would never arrive |
|
|
102
|
-
| `formaction=${fn}` on a submitter with `form="other"` | yes | it re-points the submitter at a form
|
|
103
|
-
| `formmethod
|
|
115
|
+
| `formaction=${fn}` on a submitter with `form="other"` | yes | it re-points the submitter at a different form owner, which may not be where the identity field it needs lives |
|
|
116
|
+
| `formmethod` / `formenctype` the BOUND submitter cannot submit with (`get`, `PATCH`, `text/plain`, `dialog`, a padded `" post "`) | yes | a same-element contradiction: you attached an action to THIS button and told THIS button to submit in a way that action could never read. The renderer supplies `formmethod="post"` and the enctype only where you supplied neither, so your own parseable value always wins |
|
|
117
|
+
| `formmethod="get"` / `formenctype="text/plain"` on a PLAIN submitter inside a bound form | **no** | #1307 reversed this. Native HTML says the submitter's override wins, you typed it deliberately, and the form's action simply does not run, exactly as the same markup behaves anywhere else. In dev the client logs a console error at submit time if the submission is carrying an identity it cannot deliver |
|
|
104
118
|
| `formmethod="dialog"` on a submitter that binds nothing | **no** | a native `<dialog>` dismissal, never a submission, so there is no body for the action to miss. It IS refused on a button that also binds an action, which is a straight contradiction |
|
|
105
|
-
| a plain `formaction="/url"` on a submitter inside a bound form | **no** | it retargets the submission away from the page's bound action entirely, so
|
|
119
|
+
| a plain `formaction="/url"` on a submitter inside a bound form | **no** | it retargets the submission away from the page's bound action entirely, so where it goes and how is your business |
|
|
106
120
|
| `.action=` on a native form | yes | the supported binding is the plain attribute, and a `.prop` on a native element drops at SSR, so accepting it would mean a form that submits under JS and does nothing without it |
|
|
107
121
|
| `.method=` / `.enctype=` / `.encoding=` on a BOUND form | yes | the same reason one level over. All three are reflected IDL attributes, so SSR drops the binding and emits `method="post"` while a browser ends at what you assigned. Write them as plain attributes |
|
|
108
122
|
| a second `action=${fn}` on one form | yes | SSR emits the second as a plain url next to the identity field, the client takes the last. Bind exactly one, in either position |
|
|
109
123
|
| a plain `action="/url"` beside the bound hole | yes | the hole drops only its OWN attribute, so SSR keeps the static one while the client removes it: without JS the browser posts to `/url`, with JS to the page |
|
|
110
124
|
| `method=" post "` / `enctype=" multipart/form-data "` | yes | `method` and `enctype` are enumerated attributes matched against exact keywords with no whitespace stripping, so a padded value falls to the invalid-value default and the form submits as a GET with no body. Trimming it for you would emit the padded value anyway |
|
|
111
125
|
| `encoding="..."` as an ATTRIBUTE on a bound form | **no** | inert in HTML (`form.encoding` reads back `enctype`), so both renderers ignore it and still supply `enctype`. Only the `.encoding` PROPERTY aliases enctype, and that spelling IS refused, one row up |
|
|
112
|
-
| `.formAction=` on a
|
|
126
|
+
| `.formAction=` / `.formMethod=` / `.formEnctype=` on a BOUND submitter | yes | same reason, that is where they reflect: SSR drops the property and the browser applies it, so the button would submit one way with JS and another way without. On a PLAIN button they are ordinary native properties and are left alone |
|
|
113
127
|
| `.action=` on any other native tag | **no** | a plain expando (`<div .action=${fn}>`, `<button .action=${fn}>`), reflecting nothing, so nothing reaches the markup |
|
|
114
128
|
| `.action=` on a custom element | **no** | an author-defined property, not a reflected IDL attribute, so a function is a legitimate value. One declared `reflect: true` reflects on a path outside these commit sites, which used to write `String(value)` and emit the source. It now removes the attribute and warns instead, for a bare function and for an array carrying one, unless the prop supplies its own `converter.toAttribute`, which runs first and stays the author's call |
|
|
115
129
|
| `?action=` | yes | a function never leaked through a boolean hole, but the binding is meaningless, so it is refused rather than emitting the bare `action=""` that ANY truthy value produces there. Two separate facts worth carrying: `action=""` is a conformance error (the spec wants a valid non-empty URL whenever the attribute is present), and deleting the attribute is still not the WebJs fix, since a page has no `action` export and an unbound `method="post"` form is a 405 (a bare GET form just re-renders). Bind it: `<form action=${fn}>` |
|
|
@@ -170,6 +184,12 @@ The file stays `middleware.ts`, NOT Next 16's renamed `proxy.ts`. WebJs middlewa
|
|
|
170
184
|
|
|
171
185
|
Navigation is automatic. The client router auto-enables when `@webjsdev/core` loads (any page with a component), so a plain `<a href>` gets soft navigation for free. There is no `<Link>` to import and no `useRouter`. For programmatic navigation import `navigate()` / `revalidate()` from `@webjsdev/core`. There is no `next/image`, `next/font`, `next/script`, or `next/dynamic`. WebJs is no-build: use a plain `<img>`, a `<link>` / `@font-face`, a component's `static lazy = true` for viewport lazy-loading, and a dynamic `import()` where code should load lazily.
|
|
172
186
|
|
|
187
|
+
### No `<ScrollRestoration>`, and no scroll restore of your own
|
|
188
|
+
|
|
189
|
+
Remix ships a `<ScrollRestoration />` component, Next has a `scrollRestoration` flag and a pile of community `useEffect` + `scrollTo` recipes, and every one of them is a thing to NOT port. WebJs restores scroll on Back/Forward automatically: the router sets `history.scrollRestoration = 'manual'` on boot and is the sole authority on scroll for the whole navigation. There is no component to render and no option to enable. An app-level `popstate` listener that calls `scrollTo`, a remembered offset in `sessionStorage`, or a `scrollIntoView` on a saved element all race the router and win sometimes, which is worse than losing consistently.
|
|
190
|
+
|
|
191
|
+
This includes the case that most tempts a hand-rolled fix: Back landing BELOW where the reader left, on a page whose components size themselves after they render. The router already handles it, by suppressing the browser's scroll anchoring across the restore so late growth above the viewport is not added to the offset it just replayed (see `client-router-and-streaming.md`). If a restore still lands wrong, report it rather than patching around it in app code.
|
|
192
|
+
|
|
173
193
|
### Server-only code: the `.server.ts` boundary, not a `server-only` package
|
|
174
194
|
|
|
175
195
|
Next poisons a client-imported module with the `server-only` package. WebJs uses the file extension: `*.server.ts` is the path-level boundary (the file router refuses to serve the source). A `'use server'` file's exports are RPC-callable; a `.server.ts` file WITHOUT `'use server'` is a server-only utility whose browser import throws at load. Reach a no-`'use server'` utility through a `'use server'` action, `route.ts`, or `middleware`, never by direct import into a shipping page or component.
|
|
@@ -268,4 +288,4 @@ Context providers publish on connect via `hostConnected`, which does not run at
|
|
|
268
288
|
|
|
269
289
|
### Vanilla DOM instead of Lit idioms
|
|
270
290
|
|
|
271
|
-
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.
|
|
@@ -104,7 +104,7 @@ render() {
|
|
|
104
104
|
|
|
105
105
|
`method` and the enctype are supplied by the renderer, and the hidden identity field is re-inserted as the form's first child on every client render, so there is nothing to manage by hand.
|
|
106
106
|
|
|
107
|
-
When a page owns SEVERAL mutations (create, toggle, delete), give each form its OWN binding (`action=${createTodo}` / `action=${toggleTodo}` / `action=${deleteTodo}`), or use per-button submitter server action bindings via `formaction=${action}` on submitter buttons
|
|
107
|
+
When a page owns SEVERAL mutations (create, toggle, delete), give each form its OWN binding (`action=${createTodo}` / `action=${toggleTodo}` / `action=${deleteTodo}`), or use per-button submitter server action bindings via `formaction=${action}` on submitter buttons (#1207, #1307: a bound submitter carries its own submission, so the enclosing form need not be bound). Alternatively, a form that dispatches dynamically can bind ONE action and inspect a submit button's `name="intent"`.
|
|
108
108
|
|
|
109
109
|
## Seed the list from the server for SSR plus optimistic
|
|
110
110
|
|
|
@@ -179,7 +179,9 @@ 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
|
-
|
|
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
|
+
|
|
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
|
|
185
187
|
|
|
@@ -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)
|
|
@@ -170,6 +170,33 @@ A cross-runtime proof is often a plain assert script rather than a test file, so
|
|
|
170
170
|
- **Assert the exit code, never the logs.** These scripts conventionally pass a `quiet` logger, so anything that only logs is invisible. If your script catches its own failure, report it with an explicit `process.exit(1)` rather than a `console.error` alone, guarded as `if (import.meta.main) process.exit(1); else throw failure;`. The guard matters when a `.test.mjs` wrapper imports the script under `node --test`: an unguarded exit kills the whole single-process run and hides every other file's results, while the throw lets the harness report one failed test.
|
|
171
171
|
- **Prove the script can FAIL before you trust it passing.** Break one assertion on purpose and confirm the run exits non-zero. A proof that cannot go red is worse than no proof: it reports success forever.
|
|
172
172
|
|
|
173
|
+
## Proving display-only elision did not break anything
|
|
174
|
+
|
|
175
|
+
WebJs strips the JavaScript of every component that does no client work, so a wrong verdict costs an app real interactivity and does it silently. Two commands cover the two halves, and you need both.
|
|
176
|
+
|
|
177
|
+
```sh
|
|
178
|
+
webjs elision --verify
|
|
179
|
+
```
|
|
180
|
+
|
|
181
|
+
renders every static page route with elision on and off and diffs the served bytes. It is the framework's own differential guard pointed at your route table, and it exits non-zero on a divergence AND on a corpus where nothing could be compared, so it belongs in CI. It forces the ON side on rather than reading your config, and reports how many modules elision actually dropped, so a pass that compared two identical renders is visible rather than silent. Dynamic routes are skipped by name; add real paths with `--routes /,/blog/hello`.
|
|
182
|
+
|
|
183
|
+
That proves the bytes you SERVE did not change. It cannot prove post-hydration behaviour, because a wrongly dropped module shows up as a dead click, not as different bytes. Run your own browser or e2e suite twice for that half:
|
|
184
|
+
|
|
185
|
+
```sh
|
|
186
|
+
WEBJS_ELIDE=1 npm run test:e2e
|
|
187
|
+
WEBJS_ELIDE=0 npm run test:e2e
|
|
188
|
+
```
|
|
189
|
+
|
|
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
|
+
|
|
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
|
+
|
|
173
200
|
## Convention validation (`webjs check`)
|
|
174
201
|
|
|
175
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`
|
|
@@ -6,16 +6,18 @@
|
|
|
6
6
|
#
|
|
7
7
|
# 1. U+2014 em-dash, anywhere.
|
|
8
8
|
# 2. Space-hyphen-space " - " in PROSE contexts (comment lines, markdown
|
|
9
|
-
# lines, headings, blockquotes
|
|
9
|
+
# lines, headings, blockquotes, a JSON "description" / "title" /
|
|
10
|
+
# "displayName" string value, and a column-0 YAML front-matter
|
|
11
|
+
# description: / title: / displayName: line). Math expressions in code like
|
|
10
12
|
# `Math.abs(a - b)` or `arr.length - 1` are NOT flagged.
|
|
11
|
-
# 3. Space-semicolon-space " ; " in PROSE contexts
|
|
12
|
-
# terminators (`;\n`) are NOT flagged.
|
|
13
|
+
# 3. Space-semicolon-space " ; " in the same PROSE contexts as rule 2.
|
|
14
|
+
# JS / CSS statement terminators (`;\n`) are NOT flagged.
|
|
13
15
|
# 4. Code-shaped left-hand side immediately followed by a colon and prose:
|
|
14
16
|
# - `<code>foo()</code>:` (markdown code-LHS in docs)
|
|
15
17
|
# - `<my-tag>:` (custom-element tag with hyphen)
|
|
16
18
|
# - Inline comment `// foo(): description`
|
|
17
19
|
#
|
|
18
|
-
# Why this exists: see AGENTS.md "Invariants", item
|
|
20
|
+
# Why this exists: see AGENTS.md "Invariants", item 11. These patterns
|
|
19
21
|
# confuse AI agents that try to parse the prose as TypeScript / shorthand-
|
|
20
22
|
# method / object-literal syntax, and trip humans reading API docs.
|
|
21
23
|
#
|
|
@@ -46,8 +48,13 @@ if [ -z "$new_content" ]; then
|
|
|
46
48
|
exit 0
|
|
47
49
|
fi
|
|
48
50
|
|
|
51
|
+
# Every match below reads from a here-string, never a pipe. `grep -q` exits on
|
|
52
|
+
# the first match, which closes a pipe under `printf`, and with `set -o pipefail`
|
|
53
|
+
# that SIGPIPE became the pipeline status, so the rule silently skipped on any
|
|
54
|
+
# payload past the pipe buffer (measured: 0 of 8 blocks at 128 KB).
|
|
55
|
+
|
|
49
56
|
# --- 1. U+2014 em-dash --------------------------------------------------
|
|
50
|
-
if
|
|
57
|
+
if grep -q $'\xe2\x80\x94' <<< "$new_content"; then
|
|
51
58
|
cat >&2 <<'EOF'
|
|
52
59
|
BLOCKED: em-dash (U+2014) detected in this tool call.
|
|
53
60
|
|
|
@@ -57,7 +64,7 @@ restructured sentence. Do NOT replace it with " - " or " ; " or a
|
|
|
57
64
|
trailing colon on code: those are also banned. See rule 2 / 3 / 4
|
|
58
65
|
below for the alternatives.
|
|
59
66
|
|
|
60
|
-
Rule: AGENTS.md, Invariants section, item
|
|
67
|
+
Rule: AGENTS.md, Invariants section, item 11.
|
|
61
68
|
Hook: .claude/hooks/block-prose-punctuation.sh.
|
|
62
69
|
EOF
|
|
63
70
|
exit 2
|
|
@@ -81,25 +88,42 @@ block_pause_hyphen=0
|
|
|
81
88
|
# `*` (markdown bold-start would have a letter after, distinguishable),
|
|
82
89
|
# followed by prose with `\w+ - \w+` pattern. Specifically: catch lines
|
|
83
90
|
# like `// foo - bar`, ` * foo - bar`, `* foo - bar`.
|
|
84
|
-
if
|
|
91
|
+
if grep -qE '^[[:space:]]*(//|\*)[[:space:]].*[A-Za-z`)>][[:space:]]-[[:space:]][A-Za-z`(<]' <<< "$new_content"; then
|
|
85
92
|
block_pause_hyphen=1
|
|
86
93
|
fi
|
|
87
94
|
|
|
88
95
|
# Markdown heading " - " pause: line starts with `#` followed by prose
|
|
89
96
|
# and ` - ` pattern.
|
|
90
|
-
if
|
|
97
|
+
if grep -qE '^#{1,6}[[:space:]].*[A-Za-z`)>][[:space:]]-[[:space:]][A-Za-z`(<]' <<< "$new_content"; then
|
|
91
98
|
block_pause_hyphen=1
|
|
92
99
|
fi
|
|
93
100
|
|
|
94
101
|
# Markdown blockquote " - " pause: line starts with `>` followed by prose
|
|
95
102
|
# and ` - ` pattern. (Single `>` blockquote, not table.)
|
|
96
|
-
if
|
|
103
|
+
if grep -qE '^>[[:space:]].*[A-Za-z`)>][[:space:]]-[[:space:]][A-Za-z`(<]' <<< "$new_content"; then
|
|
97
104
|
block_pause_hyphen=1
|
|
98
105
|
fi
|
|
99
106
|
|
|
100
107
|
# HTML / markdown <p>, <li>, <td> body " - " pause: line contains a
|
|
101
108
|
# closing HTML tag from a prose context, then prose-style ` - `.
|
|
102
|
-
if
|
|
109
|
+
if grep -qE '<(p|li|td|h[1-6]|strong|em|blockquote)[^>]*>[^<]*[A-Za-z`)>][[:space:]]-[[:space:]][A-Za-z`(<]' <<< "$new_content"; then
|
|
110
|
+
block_pause_hyphen=1
|
|
111
|
+
fi
|
|
112
|
+
|
|
113
|
+
# JSON prose-value " - " pause: a string assignment whose KEY is one of the
|
|
114
|
+
# three prose-bearing keys this project's JSON uses. Scoping to the key is what
|
|
115
|
+
# keeps this off semver ranges, script commands, urls, paths and globs, every
|
|
116
|
+
# one of which lives under a different key. Shape, not file path: the Bash
|
|
117
|
+
# payload carries no file_path, so a heredoc writing a manifest is covered too.
|
|
118
|
+
if grep -qE '^[[:space:]]*"(description|title|displayName)"[[:space:]]*:[[:space:]]*".*[A-Za-z`)>][[:space:]]-[[:space:]][A-Za-z`(<]' <<< "$new_content"; then
|
|
119
|
+
block_pause_hyphen=1
|
|
120
|
+
fi
|
|
121
|
+
|
|
122
|
+
# YAML front-matter " - " pause, same three keys. Anchored at column 0 with no
|
|
123
|
+
# leading whitespace, which is what confines it to document front matter: every
|
|
124
|
+
# nested YAML mapping is indented, including the workflow-input `description:`
|
|
125
|
+
# values in .github/workflows/release.yml.
|
|
126
|
+
if grep -qE '^(description|title|displayName):[[:space:]].*[A-Za-z`)>][[:space:]]-[[:space:]][A-Za-z`(<]' <<< "$new_content"; then
|
|
103
127
|
block_pause_hyphen=1
|
|
104
128
|
fi
|
|
105
129
|
|
|
@@ -118,13 +142,17 @@ restructured phrasing.
|
|
|
118
142
|
Bad: <li>Foo - bar.</li>
|
|
119
143
|
Good: <li>Foo, with bar.</li>
|
|
120
144
|
|
|
145
|
+
Bad: "description": "A library - for things"
|
|
146
|
+
Good: "description": "A library for things"
|
|
147
|
+
|
|
121
148
|
Plain hyphens are still fine in compound words (`AI-first`), CLI
|
|
122
149
|
flags (`--http2`), filenames, ranges, and math expressions in code
|
|
123
150
|
(`arr.length - 1`, `Math.abs(a - b)`). The hook only flags the
|
|
124
151
|
` < word > - < word > ` pause-pattern in prose contexts (comments,
|
|
125
|
-
markdown headings, blockquotes, HTML prose tags
|
|
152
|
+
markdown headings, blockquotes, HTML prose tags, and a JSON or
|
|
153
|
+
front-matter description / title / displayName value).
|
|
126
154
|
|
|
127
|
-
Rule: AGENTS.md, Invariants section, item
|
|
155
|
+
Rule: AGENTS.md, Invariants section, item 11.
|
|
128
156
|
Hook: .claude/hooks/block-prose-punctuation.sh.
|
|
129
157
|
EOF
|
|
130
158
|
exit 2
|
|
@@ -134,19 +162,29 @@ fi
|
|
|
134
162
|
# Same prose-context guard as #2.
|
|
135
163
|
block_pause_semicolon=0
|
|
136
164
|
|
|
137
|
-
if
|
|
165
|
+
if grep -qE '^[[:space:]]*(//|\*)[[:space:]].*[A-Za-z`)][[:space:]];[[:space:]][A-Za-z`(]' <<< "$new_content"; then
|
|
138
166
|
block_pause_semicolon=1
|
|
139
167
|
fi
|
|
140
168
|
|
|
141
|
-
if
|
|
169
|
+
if grep -qE '^#{1,6}[[:space:]].*[A-Za-z`)][[:space:]];[[:space:]][A-Za-z`(]' <<< "$new_content"; then
|
|
142
170
|
block_pause_semicolon=1
|
|
143
171
|
fi
|
|
144
172
|
|
|
145
|
-
if
|
|
173
|
+
if grep -qE '^>[[:space:]].*[A-Za-z`)][[:space:]];[[:space:]][A-Za-z`(]' <<< "$new_content"; then
|
|
146
174
|
block_pause_semicolon=1
|
|
147
175
|
fi
|
|
148
176
|
|
|
149
|
-
if
|
|
177
|
+
if grep -qE '<(p|li|td|h[1-6]|strong|em|blockquote)[^>]*>[^<]*[A-Za-z`)][[:space:]];[[:space:]][A-Za-z`(]' <<< "$new_content"; then
|
|
178
|
+
block_pause_semicolon=1
|
|
179
|
+
fi
|
|
180
|
+
|
|
181
|
+
# JSON prose-value " ; " pause, same three keys as rule 2.
|
|
182
|
+
if grep -qE '^[[:space:]]*"(description|title|displayName)"[[:space:]]*:[[:space:]]*".*[A-Za-z`)][[:space:]];[[:space:]][A-Za-z`(]' <<< "$new_content"; then
|
|
183
|
+
block_pause_semicolon=1
|
|
184
|
+
fi
|
|
185
|
+
|
|
186
|
+
# YAML front-matter " ; " pause, column-0 anchored like rule 2.
|
|
187
|
+
if grep -qE '^(description|title|displayName):[[:space:]].*[A-Za-z`)][[:space:]];[[:space:]][A-Za-z`(]' <<< "$new_content"; then
|
|
150
188
|
block_pause_semicolon=1
|
|
151
189
|
fi
|
|
152
190
|
|
|
@@ -161,10 +199,14 @@ two sentences (period) or with a conjunction (", and", ", but", ", so").
|
|
|
161
199
|
Good: // Forms work. Links work too.
|
|
162
200
|
Good: // Forms work, and links work too.
|
|
163
201
|
|
|
202
|
+
Bad: "description": "Forms work ; links work too."
|
|
203
|
+
Good: "description": "Forms work. Links work too."
|
|
204
|
+
|
|
164
205
|
Semicolons stay fine inside code (JS statement terminators, CSS
|
|
165
|
-
declarations) since those are not flagged.
|
|
206
|
+
declarations) since those are not flagged. Only the space-surrounded
|
|
207
|
+
form is banned, so an ordinary English semicolon is untouched.
|
|
166
208
|
|
|
167
|
-
Rule: AGENTS.md, Invariants section, item
|
|
209
|
+
Rule: AGENTS.md, Invariants section, item 11.
|
|
168
210
|
Hook: .claude/hooks/block-prose-punctuation.sh.
|
|
169
211
|
EOF
|
|
170
212
|
exit 2
|
|
@@ -175,7 +217,7 @@ fi
|
|
|
175
217
|
# lowercase prose. The `)</code>:` shape is unambiguous: this is markdown,
|
|
176
218
|
# not code, AND the inner code ends in `()` so the colon visually parses
|
|
177
219
|
# as a return-type annotation.
|
|
178
|
-
if
|
|
220
|
+
if grep -qE '\)</code>:[[:space:]][a-z]' <<< "$new_content"; then
|
|
179
221
|
cat >&2 <<'EOF'
|
|
180
222
|
BLOCKED: code-LHS colon-then-prose detected ("<code>foo()</code>: ...").
|
|
181
223
|
|
|
@@ -186,7 +228,7 @@ parses as a TypeScript return-type annotation. Rewrite verb-led.
|
|
|
186
228
|
Good: <code>repeat()</code> is the keyed list directive
|
|
187
229
|
Good: <code>startServer()</code> creates an HTTP(S) server
|
|
188
230
|
|
|
189
|
-
Rule: AGENTS.md, Invariants section, item
|
|
231
|
+
Rule: AGENTS.md, Invariants section, item 11.
|
|
190
232
|
Hook: .claude/hooks/block-prose-punctuation.sh.
|
|
191
233
|
EOF
|
|
192
234
|
exit 2
|
|
@@ -195,7 +237,7 @@ fi
|
|
|
195
237
|
# --- 4b. Custom-element-tag <my-tag>: prose ------------------------------
|
|
196
238
|
# HTML reserves hyphenated tag names for custom elements (W3C spec), so
|
|
197
239
|
# `<x-y>:` is unambiguous prose, never JSX / TS / CSS.
|
|
198
|
-
if
|
|
240
|
+
if grep -qE '<[a-z][a-z0-9]*(-[a-z0-9]+)+([[:space:]][^>]*)?>:[[:space:]][a-z]' <<< "$new_content"; then
|
|
199
241
|
cat >&2 <<'EOF'
|
|
200
242
|
BLOCKED: custom-element-tag colon-then-prose detected ("<my-tag>: ...").
|
|
201
243
|
|
|
@@ -206,7 +248,7 @@ webjs bans `<my-tag>: <prose>` in comments and docs. Rewrite verb-led.
|
|
|
206
248
|
Bad: // <ui-dialog-content>: the centered panel.
|
|
207
249
|
Good: // <ui-dialog-content> is the centered panel.
|
|
208
250
|
|
|
209
|
-
Rule: AGENTS.md, Invariants section, item
|
|
251
|
+
Rule: AGENTS.md, Invariants section, item 11.
|
|
210
252
|
Hook: .claude/hooks/block-prose-punctuation.sh.
|
|
211
253
|
EOF
|
|
212
254
|
exit 2
|
|
@@ -216,7 +258,7 @@ fi
|
|
|
216
258
|
# Match comment-line prefix (`//` or leading `*`) before `\w+(...): ` and
|
|
217
259
|
# lowercase prose. Avoids TS return-type annotations because those never
|
|
218
260
|
# appear inside comment lines.
|
|
219
|
-
if
|
|
261
|
+
if grep -qE '^[[:space:]]*(//|\*)[[:space:]][^(]*[A-Za-z_][A-Za-z0-9_]*\([^)]*\):[[:space:]][a-z]' <<< "$new_content"; then
|
|
220
262
|
cat >&2 <<'EOF'
|
|
221
263
|
BLOCKED: comment-line code-LHS colon-then-prose detected ("// foo(): ...").
|
|
222
264
|
|
|
@@ -227,7 +269,7 @@ webjs bans `xyz(): <prose>` inside comments and JSDoc. Rewrite verb-led.
|
|
|
227
269
|
Bad: // closest(): null if the click wasn't inside a frame
|
|
228
270
|
Good: // closest() returns null when the click wasn't inside a frame
|
|
229
271
|
|
|
230
|
-
Rule: AGENTS.md, Invariants section, item
|
|
272
|
+
Rule: AGENTS.md, Invariants section, item 11.
|
|
231
273
|
Hook: .claude/hooks/block-prose-punctuation.sh.
|
|
232
274
|
EOF
|
|
233
275
|
exit 2
|
|
@@ -16,8 +16,9 @@ export class ServerClock extends WebComponent {
|
|
|
16
16
|
// serves it with ZERO JavaScript and skips the redundant on-hydration
|
|
17
17
|
// re-fetch. This is the common fetch-and-display leaf shape. If you ever need
|
|
18
18
|
// to force a component to ship when the analyser would elide it (for
|
|
19
|
-
// interactivity static analysis cannot see, like
|
|
20
|
-
//
|
|
19
|
+
// interactivity static analysis cannot see, like an observer that computes
|
|
20
|
+
// the tag it waits for), declare `static interactive = true`. Run
|
|
21
|
+
// `webjs elision` to see the verdict for every component in this app.
|
|
21
22
|
async render() {
|
|
22
23
|
const info = await serverGreeting();
|
|
23
24
|
return html`<p class="font-mono text-sm">server rendered this at
|
|
@@ -31,7 +31,10 @@ export class DirectiveDemo extends WebComponent {
|
|
|
31
31
|
// A controlled value (for `live`) and a key (for `keyed`).
|
|
32
32
|
private text = signal('type here');
|
|
33
33
|
private variant = signal(0);
|
|
34
|
-
// A handle to the input node, attached by `ref` in the browser.
|
|
34
|
+
// A handle to the input node, attached by `ref` in the browser. A ref rather
|
|
35
|
+
// than a `querySelector` because the template already owns the node, so the
|
|
36
|
+
// handle flows out of `render()` instead of being re-found by a selector that
|
|
37
|
+
// could match someone else's markup (or nothing at all after a rename).
|
|
35
38
|
private inputRef = createRef<HTMLInputElement>();
|
|
36
39
|
// Created ONCE (not per render), so `until` keeps the resolved value across
|
|
37
40
|
// re-renders instead of flashing back to the fallback each time.
|
|
@@ -6,6 +6,11 @@
|
|
|
6
6
|
// active item from location.pathname, so the highlight follows soft-nav. SSR is
|
|
7
7
|
// still correct: `render()` reads the `current` prop (the pathname the layout
|
|
8
8
|
// passes) for the first paint, and the client takes over from location after.
|
|
9
|
+
// The document LISTENER below is the legitimate case, not a reach across the
|
|
10
|
+
// app: the router has no element to dispatch from, and the handler reads only
|
|
11
|
+
// location.pathname and writes only this module's own signal, so it queries
|
|
12
|
+
// nothing outside itself. Querying the document for another file's markup is
|
|
13
|
+
// the shape to avoid.
|
|
9
14
|
import { WebComponent, prop, html, signal } from '@webjsdev/core';
|
|
10
15
|
import { FEATURE_GROUPS } from '#modules/gallery/nav.ts';
|
|
11
16
|
|
|
@@ -12,9 +12,11 @@ import { deleteTodo } from './delete-todo.server.ts';
|
|
|
12
12
|
// action: this form carries the todo's `id` on a hidden input and needs the
|
|
13
13
|
// SAME id for whichever mutation runs, so one action reading both fields is the
|
|
14
14
|
// simpler shape. When the buttons need no shared payload, bind each one
|
|
15
|
-
// directly instead, with `formaction=${action}` on a <button
|
|
16
|
-
// form
|
|
17
|
-
//
|
|
15
|
+
// directly instead, with `formaction=${action}` on a <button>. The enclosing
|
|
16
|
+
// <form> does NOT have to be bound: a bound submitter carries its own
|
|
17
|
+
// `formmethod` and enctype, so it works in any form or none. The identity rides
|
|
18
|
+
// that button's own name/value pair, so it works with JS off too. Two things to
|
|
19
|
+
// know: it must be a <button> (on an
|
|
18
20
|
// <input type="submit"> the identity would occupy `value`, which is also that
|
|
19
21
|
// control's visible label), and a bound submitter cannot carry its own
|
|
20
22
|
// `name`/`value`, which is exactly the channel `name="intent"` uses below.
|
|
@@ -20,6 +20,13 @@ import { createServer } from 'node:net';
|
|
|
20
20
|
// minimal structural types keep the file typed in the meantime; swap them for
|
|
21
21
|
// the real imports once puppeteer-core is in package.json. Reaching for `any`
|
|
22
22
|
// here would silently un-type every call below.
|
|
23
|
+
//
|
|
24
|
+
// They also stay in force when the package IS present, which is the case a
|
|
25
|
+
// generated app usually hits, since @web/test-runner pulls puppeteer-core in
|
|
26
|
+
// transitively. The real Page / Browser are far richer than these, so letting
|
|
27
|
+
// them flow in would fail against the narrow shapes here for the goto return
|
|
28
|
+
// type and the event-handler signature. The single import below is the one
|
|
29
|
+
// boundary where that is resolved, and it is the only suppressed line.
|
|
23
30
|
type Page = {
|
|
24
31
|
// goto resolves an HTTPResponse this file never reads, and modelling that
|
|
25
32
|
// type would mean re-declaring puppeteer's. Returning void is the honest
|
|
@@ -30,6 +37,10 @@ type Page = {
|
|
|
30
37
|
removeAllListeners(event: string): void;
|
|
31
38
|
};
|
|
32
39
|
type Browser = { newPage(): Promise<Page>; close(): Promise<void> };
|
|
40
|
+
// The module's own default export, narrowed to the one call this file makes.
|
|
41
|
+
type Puppeteer = {
|
|
42
|
+
launch(opts: { executablePath?: string; headless?: boolean; args?: string[] }): Promise<Browser>;
|
|
43
|
+
};
|
|
33
44
|
|
|
34
45
|
let browser: Browser, page: Page, serverProcess: ChildProcess, baseUrl: string;
|
|
35
46
|
|
|
@@ -46,9 +57,23 @@ function freePort(): Promise<number> {
|
|
|
46
57
|
}
|
|
47
58
|
|
|
48
59
|
before(async () => {
|
|
49
|
-
let puppeteer;
|
|
60
|
+
let puppeteer: Puppeteer | undefined;
|
|
61
|
+
// The next line is where the optional dependency enters, and what it reports
|
|
62
|
+
// depends on whether puppeteer-core is installed: an unresolved specifier
|
|
63
|
+
// when it is absent, a type mismatch against the structural shapes above
|
|
64
|
+
// when it is present. Suppressing it keeps the rest of the file checked
|
|
65
|
+
// against those shapes either way.
|
|
66
|
+
//
|
|
67
|
+
// It is deliberately ts-ignore rather than the expect-error directive, whose
|
|
68
|
+
// name is spelled out here rather than written, because a comment line
|
|
69
|
+
// starting with that token IS a live directive to tsc even inside prose. The
|
|
70
|
+
// expect-error form is wrong on its own merits too: it errors when there is
|
|
71
|
+
// nothing to suppress, so it would break whenever the package resolves
|
|
72
|
+
// cleanly.
|
|
73
|
+
// @ts-ignore
|
|
50
74
|
try { puppeteer = (await import('puppeteer-core')).default; }
|
|
51
75
|
catch { console.log('# Skipping: puppeteer-core not installed'); return; }
|
|
76
|
+
if (!puppeteer) return;
|
|
52
77
|
|
|
53
78
|
const port = await freePort();
|
|
54
79
|
baseUrl = `http://localhost:${port}`;
|