@webjsdev/cli 0.10.40 → 0.10.41
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/webjs.js +4 -46
- package/lib/create.js +282 -479
- package/lib/doctor.js +1 -38
- package/package.json +5 -1
- package/templates/.agents/rules/workflow.md +61 -271
- package/templates/.agents/skills/webjs/SKILL.md +226 -0
- package/templates/.agents/skills/webjs/references/auth-and-sessions.md +220 -0
- package/templates/.agents/skills/webjs/references/built-ins.md +200 -0
- package/templates/.agents/skills/webjs/references/client-router-and-streaming.md +204 -0
- package/templates/.agents/skills/webjs/references/components.md +167 -0
- package/templates/.agents/skills/webjs/references/data-and-actions.md +187 -0
- package/templates/.agents/skills/webjs/references/muscle-memory-gotchas.md +170 -0
- package/templates/.agents/skills/webjs/references/optimistic-ui.md +128 -0
- package/templates/.agents/skills/webjs/references/routing-and-pages.md +158 -0
- package/templates/.agents/skills/webjs/references/runtime.md +80 -0
- package/templates/.agents/skills/webjs/references/service-worker.md +78 -0
- package/templates/.agents/skills/webjs/references/styling.md +123 -0
- package/templates/.agents/skills/webjs/references/testing.md +125 -0
- package/templates/.agents/skills/webjs/references/typescript.md +148 -0
- package/templates/.claude/hooks/check-server-imports.mjs +1 -1
- package/templates/.claude/hooks/require-tests-with-src.sh +1 -1
- package/templates/.claude/settings.json +0 -14
- package/templates/.cursorrules +21 -189
- package/templates/.github/copilot-instructions.md +7 -185
- package/templates/.github/pull_request_template.md +1 -1
- package/templates/AGENTS.md +59 -1494
- package/templates/CLAUDE.md +0 -1
- package/templates/CONVENTIONS.md +32 -1383
- package/templates/GEMINI.md +11 -0
- package/templates/gallery/app/apple-icon.ts +0 -1
- package/templates/gallery/app/examples/todo/page.ts +0 -1
- package/templates/gallery/app/features/async-render/page.ts +0 -1
- package/templates/gallery/app/features/boundaries/page.ts +0 -1
- package/templates/gallery/app/features/broadcast/page.ts +0 -1
- package/templates/gallery/app/features/caching/page.ts +0 -1
- package/templates/gallery/app/features/client-router/page.ts +0 -1
- package/templates/gallery/app/features/client-router/second/page.ts +0 -1
- package/templates/gallery/app/features/components/page.ts +0 -1
- package/templates/gallery/app/features/directives/page.ts +0 -1
- package/templates/gallery/app/features/env/page.ts +0 -1
- package/templates/gallery/app/features/file-storage/page.ts +0 -1
- package/templates/gallery/app/features/forms/page.ts +0 -1
- package/templates/gallery/app/features/metadata/page.ts +0 -1
- package/templates/gallery/app/features/optimistic-ui/page.ts +0 -1
- package/templates/gallery/app/features/rate-limit/page.ts +0 -1
- package/templates/gallery/app/features/route-handler/page.ts +0 -1
- package/templates/gallery/app/features/routing/page.ts +0 -1
- package/templates/gallery/app/features/server-actions/page.ts +0 -1
- package/templates/gallery/app/features/service-worker/page.ts +0 -1
- package/templates/gallery/app/features/sessions/page.ts +0 -1
- package/templates/gallery/app/features/websockets/page.ts +0 -1
- package/templates/gallery/app/global-error.ts +0 -1
- package/templates/gallery/app/global-not-found.ts +0 -1
- package/templates/gallery/app/icon.ts +0 -1
- package/templates/gallery/app/manifest.ts +0 -1
- package/templates/gallery/app/opengraph-image.ts +0 -1
- package/templates/gallery/app/robots.ts +0 -1
- package/templates/gallery/app/sitemap.ts +0 -1
- package/templates/gallery/app/twitter-image.ts +0 -1
- package/templates/public/favicon.svg +5 -0
- package/templates/public/sw.js +1 -1
- package/templates/scripts/clear-gallery.mjs +95 -0
- package/lib/clear-placeholders.js +0 -98
- package/lib/design-bar.js +0 -67
- package/templates/.claude/hooks/design-review-before-stop.sh +0 -36
- package/templates/.claude/hooks/route-skills.sh +0 -35
- package/templates/.claude/skills/webjs-design-review/SKILL.md +0 -84
- package/templates/LAYOUT-REFERENCE.md +0 -96
- package/templates/lib/utils/ui.ts +0 -83
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
# Styling
|
|
2
|
+
|
|
3
|
+
## What This Covers
|
|
4
|
+
|
|
5
|
+
- Tailwind-first: the strong default for pages AND light-DOM components, and the Lit reflex it counters
|
|
6
|
+
- The light-DOM tag-prefix invariant when raw CSS is unavoidable
|
|
7
|
+
- Extracting a repeated Tailwind bundle into a `lib/utils/ui.ts` `html` fragment (not `@apply`)
|
|
8
|
+
- Design tokens: `:root` / `@theme` in the root layout
|
|
9
|
+
- Light-DOM host `display: block` behaviour (and shadow hosts via `:host`)
|
|
10
|
+
- When to use `static styles` (shadow DOM)
|
|
11
|
+
- `position: fixed`, not `sticky`, for a pinned header (the iOS WebKit flicker)
|
|
12
|
+
- Even-grid / no-reflow layout tips
|
|
13
|
+
|
|
14
|
+
Read this when a task touches a class list, a `<style>`, a design token, a pinned header, or a grid/board/card layout. Sibling ref: `components.md` (light vs shadow DOM, `static styles`, host behaviour in depth).
|
|
15
|
+
|
|
16
|
+
## Tailwind-first is the strong default
|
|
17
|
+
|
|
18
|
+
Use Tailwind utilities for pages AND light-DOM components (the default DOM mode). Layout, spacing, color (via `@theme` tokens), typography, borders, radius, shadows, and interaction states (hover/focus/active/disabled, dark mode) are all utility-expressible. Light DOM does not scope styles, so a utility class on a light-DOM element resolves against the global stylesheet exactly as it does on a page. That is why utilities are the right tool there, not an exception.
|
|
19
|
+
|
|
20
|
+
### The Lit muscle-memory trap
|
|
21
|
+
|
|
22
|
+
In Lit a component owns a shadow root and scopes its CSS with `static styles = css\`\``, so the reflex is to author scoped CSS or an inline `<style>` with semantic class names (`.hero`, `.card`, `.btn`) per component. In WebJs the default is light DOM, which does NOT scope. A `css` block does nothing without `static shadow = true` (the framework warns at runtime), and an inline `<style>` with bare semantic class names leaks those names into the global namespace. Reach for Tailwind utilities instead. When the same bundle repeats, extract a `lib/utils/ui.ts` helper returning an `html` fragment (below), NOT a CSS class.
|
|
23
|
+
|
|
24
|
+
### The custom-CSS allowlist
|
|
25
|
+
|
|
26
|
+
Reserve raw CSS for what utilities genuinely cannot express. This is the exhaustive list, anything outside it should be a utility or a `lib/utils/ui.ts` helper:
|
|
27
|
+
|
|
28
|
+
- design-token `:root` + `@theme` definitions (palette, fonts, fluid type scale, motion durations, declared once in the root layout),
|
|
29
|
+
- `@property` animated custom properties paired with `@keyframes`,
|
|
30
|
+
- `::-webkit-scrollbar` and `scrollbar-color` (no utility surface),
|
|
31
|
+
- `prefers-reduced-motion` blocks,
|
|
32
|
+
- complex `color-mix()` or gradient effects a utility cannot spell.
|
|
33
|
+
|
|
34
|
+
When custom CSS IS unavoidable inside a light-DOM component, the tag-prefix invariant holds (every class selector is prefixed with the component tag). Shadow-DOM components (`static shadow = true`) legitimately author `static styles = css\`\``, the right home for scoped CSS. The Tailwind-first steer is about the LIGHT-DOM default, not shadow DOM.
|
|
35
|
+
|
|
36
|
+
## DRY via a JS helper, not `@apply`
|
|
37
|
+
|
|
38
|
+
When the same Tailwind bundle repeats across 2+ places, extract it into a helper in `lib/utils/ui.ts` that returns an `html` fragment (SSR-time, no client runtime, output identical to inline classes):
|
|
39
|
+
|
|
40
|
+
```ts
|
|
41
|
+
import { html } from '@webjsdev/core';
|
|
42
|
+
|
|
43
|
+
/** `● label` kicker: small caps, accent colour, above headings. */
|
|
44
|
+
export function rubric(label: string, mb: 'sm' | 'md' = 'md') {
|
|
45
|
+
const mbCls = mb === 'sm' ? 'mb-3' : 'mb-4';
|
|
46
|
+
return html`
|
|
47
|
+
<span class="block font-mono text-[11px] leading-none font-semibold tracking-[0.2em] uppercase text-primary ${mbCls}">● ${label}</span>
|
|
48
|
+
`;
|
|
49
|
+
}
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
```ts
|
|
53
|
+
// app/blog/[slug]/page.ts
|
|
54
|
+
import { rubric } from '#lib/utils/ui.ts';
|
|
55
|
+
|
|
56
|
+
export default function Post({ params }) {
|
|
57
|
+
return html`${rubric('post')}<h1 class="font-serif ...">${title}</h1>`;
|
|
58
|
+
}
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
| Repeats | Action |
|
|
62
|
+
|---|---|
|
|
63
|
+
| Once | Inline the classes. |
|
|
64
|
+
| 2 to 3 times, identical | Extract to `lib/utils/ui.ts`. |
|
|
65
|
+
| Varies by 1 to 2 props | Extract with a small parameter (`mb: 'sm' \| 'md'`). |
|
|
66
|
+
| Radically different per call site | Keep inline, do not force-fit. |
|
|
67
|
+
|
|
68
|
+
Avoid `@apply`: it hides which utilities a class uses and creates a second source of truth. A JS helper keeps the bundle visible at the definition site, composes with conditional classes and active states, and runs at SSR time.
|
|
69
|
+
|
|
70
|
+
## Design tokens
|
|
71
|
+
|
|
72
|
+
The default stack is a static compiled Tailwind stylesheet (`css:build` compiles `public/input.css` to the linked `public/tailwind.css`, so it works with JS off) plus `@theme` design tokens (palette, fonts, fluid type, motion durations) declared once in the root layout. Consume them as utility classes (`text-foreground`, `bg-card`, `font-serif`, `duration-fast`). If you wire your own theme switch, drive BOTH signals on `<html>` (the `data-theme` attribute for the app palette blocks AND the `.dark` class for the `@webjsdev/ui` kit), or half the UI renders stale tokens. Verify dark mode in a real browser, light mode passing proves nothing about dark.
|
|
73
|
+
|
|
74
|
+
## Light-DOM host display, and shadow hosts
|
|
75
|
+
|
|
76
|
+
A custom element is `display: inline` in plain CSS, which collapses a component used as a block container to its content size. WebJs marks every LIGHT-DOM host `data-wj-host` and defaults it to `display: block` via one head rule in a low-priority cascade layer (`@layer webjs-host { :where([data-wj-host]) { display: block } }`), so a container component does not collapse. The layer keeps it overridable: any author style INCLUDING a Tailwind utility (`class="flex"`, `grid`, `hidden`) wins over it, and `[hidden]` still hides the host so `?hidden=${cond}` works. Opt into an inline light component with a tag-prefixed rule (`my-badge { display: inline }`).
|
|
77
|
+
|
|
78
|
+
Shadow-DOM hosts are NOT marked (a document rule would override the shadow tree's own `:host`), so a shadow component sets its host display the idiomatic way in `static styles`:
|
|
79
|
+
|
|
80
|
+
```ts
|
|
81
|
+
static styles = css`:host { display: block }`; // a shadow host with no :host display stays inline
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
**Size the HOST, not just an inner wrapper.** The host custom element is the box the parent lays out. A host that is a flex/grid item in a centering parent (`flex justify-center`, `grid place-items-center`) is sized to its content unless it carries width itself. Put the sizing classes on the host (`w-full max-w-[400px]`), not only on an inner `<div>`. Symptom: a board or card renders tiny even though its inner grid says `w-full max-w-[400px]`. Fix: move the sizing onto the host.
|
|
85
|
+
|
|
86
|
+
## Even grids, no reflow
|
|
87
|
+
|
|
88
|
+
The reflow bug (a cell grows when it gets content while the others shrink) comes from `auto`-sized grid rows. Size the tracks explicitly so every cell is an equal fraction regardless of content:
|
|
89
|
+
|
|
90
|
+
```html
|
|
91
|
+
<!-- a 3x3 board whose cells stay equal and square as it fills -->
|
|
92
|
+
<div class="grid gap-2 aspect-square [grid-template-columns:repeat(3,1fr)] [grid-template-rows:repeat(3,1fr)]">
|
|
93
|
+
${cells.map((c) => html`
|
|
94
|
+
<button class="grid place-items-center min-h-0 overflow-hidden text-[clamp(1rem,8cqi,3rem)]">${c}</button>
|
|
95
|
+
`)}
|
|
96
|
+
</div>
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
- `aspect-ratio` (e.g. `aspect-square`) on the CONTAINER plus `repeat(N,1fr)` columns AND rows keeps every cell an equal square that does not resize as marks are placed. Putting `aspect-square` on the CELLS is the common mistake that produces uneven rows.
|
|
100
|
+
- `min-h-0` + `overflow-hidden` on a cell stops its content forcing the track taller (a grid/flex child has an implicit `min-height: auto`).
|
|
101
|
+
- Size text relative to the cell (`clamp()`, container-query units `cqi`) so the glyph scales with the board rather than dictating the cell size.
|
|
102
|
+
|
|
103
|
+
Verify a layout by USING it, not by glancing at the first paint. A layout bug only shows mid-interaction: play through every state (fill the board, win, reload) and confirm nothing resizes.
|
|
104
|
+
|
|
105
|
+
## Pin a header with `position: fixed`, never `sticky`
|
|
106
|
+
|
|
107
|
+
A `position: sticky` header (the common `sticky top-0` pattern) flickers its background for one frame on iOS WebKit (every iOS browser uses WebKit) during a client-router forward navigation. The router's scroll-to-top after the content swap drives a sticky recompute that WebKit mis-repaints. It is iOS-only (fine on desktop and Android, invisible in DevTools emulation), and neither compositor promotion (`translateZ(0)` / `will-change`) nor changing the swap paint timing fixes it. Preserving the header across nav is correct and standard, only the `sticky` positioning is the problem.
|
|
108
|
+
|
|
109
|
+
The fix is `position: fixed`. A fixed header is always pinned and never does the scroll-relative recompute, so the repaint bug never fires. Because fixed leaves normal flow, reserve the header height on the content below with a single `--header-h` custom property (kept exact with a `ResizeObserver`, degrading fine with no JS):
|
|
110
|
+
|
|
111
|
+
```css
|
|
112
|
+
:root { --header-h: 56px; } /* sane SSR first-paint default */
|
|
113
|
+
header { position: fixed; inset-inline: 0; top: 0; }
|
|
114
|
+
body { padding-top: var(--header-h); }
|
|
115
|
+
```
|
|
116
|
+
```js
|
|
117
|
+
const hdr = document.querySelector('header');
|
|
118
|
+
const apply = () => document.documentElement.style.setProperty('--header-h', hdr.offsetHeight + 'px');
|
|
119
|
+
apply();
|
|
120
|
+
new ResizeObserver(apply).observe(hdr);
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
For a dashboard, an alternative is an app-shell scroll container (a non-scrolling `100dvh` flex column with `<main>` as the internal scroller), which needs no offset but changes the scroll model.
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
# Testing
|
|
2
|
+
|
|
3
|
+
## What This Covers
|
|
4
|
+
|
|
5
|
+
- The four test layers (unit, browser, e2e, smoke) and where each file lives.
|
|
6
|
+
- The `handle()` harness from `@webjsdev/server/testing` for driving the real request pipeline against a native `Response`.
|
|
7
|
+
- `webjs test` and `webjs test --browser`, plus when a browser or e2e test is REQUIRED (hydration, client router, slots, custom-element upgrade).
|
|
8
|
+
- Bun cross-runtime parity for runtime-sensitive code.
|
|
9
|
+
- Rendering the app and LOOKING for visual defects a static check cannot catch (a collapsed or reflowing layout).
|
|
10
|
+
- Convention validation with `webjs check`.
|
|
11
|
+
|
|
12
|
+
Read this when you are adding tests for a feature, deciding which layer a test belongs in, or verifying a UI or theming change. For component mount and hydration helpers see `components.md`. For testing actions and the `ActionResult` envelope see `data-and-actions.md`.
|
|
13
|
+
|
|
14
|
+
## Test layers
|
|
15
|
+
|
|
16
|
+
Feature folders are primary, and the test kind is a subfolder inside the feature ONLY when that kind is present. Never create an empty `browser/` or `e2e/` folder.
|
|
17
|
+
|
|
18
|
+
| Kind | Location | What it does |
|
|
19
|
+
|---|---|---|
|
|
20
|
+
| unit + integration | `test/<feature>/<name>.test.{js,ts,mjs}` | Imports modules and asserts. No spawned process, no network. |
|
|
21
|
+
| browser | `test/<feature>/browser/<name>.test.js` | Real DOM, events, shadow / light DOM, in Chromium, Firefox, and WebKit. |
|
|
22
|
+
| e2e | `test/<feature>/e2e/<name>.test.{ts,mjs}` | Boots a real process and drives it over HTTP / browser / stdout. Opt in with `WEBJS_E2E=1`. |
|
|
23
|
+
| smoke | `test/<feature>/smoke/<name>.test.{js,ts}` | Fast deploy-time sanity check, a subset of e2e in spirit. |
|
|
24
|
+
|
|
25
|
+
Assert only on what the layer needs. A block that inspects only the HTTP response, the SSR HTML string, headers, or the importmap does NOT need a browser. Keep in the browser suite only blocks that genuinely need a DOM (live state via `page.evaluate`, hydration, client-router nav, slots, view transitions, streaming into the DOM, custom-element upgrade).
|
|
26
|
+
|
|
27
|
+
## App runners (`webjs test`)
|
|
28
|
+
|
|
29
|
+
```sh
|
|
30
|
+
webjs test # runtime test runner over everything not under browser/ or e2e/
|
|
31
|
+
webjs test --browser # web-test-runner against test/**/browser/**
|
|
32
|
+
WEBJS_E2E=1 webjs test # adds the e2e layer
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
`webjs test` dispatches on the runtime (`node --test` on Node, `bun test` on Bun). The scaffold's `web-test-runner.config.js` globs `test/**/browser/**/*.test.js` and is already wired, so you do not set it up.
|
|
36
|
+
|
|
37
|
+
A scaffolded app has one root `test/` directory shaped the same way (feature first, kind second):
|
|
38
|
+
|
|
39
|
+
```
|
|
40
|
+
test/
|
|
41
|
+
auth/
|
|
42
|
+
auth.test.ts # signup / login / currentUser
|
|
43
|
+
browser/login-form.test.js # only if exercising DOM
|
|
44
|
+
posts/
|
|
45
|
+
posts.test.ts
|
|
46
|
+
browser/post-editor.test.js
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
## The `handle()` harness (`@webjsdev/server/testing`)
|
|
50
|
+
|
|
51
|
+
`createRequestHandler({ appDir }).handle(request)` drives the FULL request pipeline (middleware, routing, SSR, page actions, server-action RPC, auth, CSRF) and returns a native `Response`. It is the same entry the framework's own suite uses, so the most realistic way to test an app is to fire a `Request` through it and assert on the `Response`, with no spawned process and no network. `@webjsdev/server/testing` ships thin builders over that `handle()`, each a few lines over native `Request` / `Response` that reuse the REAL cookie names, header names, and wire serializer. For a browser test that needs to drive the app in a real DOM, `createBrowserTestHandler()` from `@webjsdev/server/testing` exposes the same `handle()` pipeline to the WTR Chromium session.
|
|
52
|
+
|
|
53
|
+
```js
|
|
54
|
+
import { createRequestHandler } from '@webjsdev/server';
|
|
55
|
+
import { testRequest, invokeActionForTest, rawActionRequest, loginAndGetCookies, withSessionCookie }
|
|
56
|
+
from '@webjsdev/server/testing';
|
|
57
|
+
|
|
58
|
+
const app = await createRequestHandler({ appDir: process.cwd(), dev: true });
|
|
59
|
+
|
|
60
|
+
const res = await testRequest(app.handle, '/about');
|
|
61
|
+
assert.equal(res.status, 200);
|
|
62
|
+
assert.match(await res.text(), /About/);
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
A bare path is prefixed with a dummy origin (the pipeline reads only `pathname` and `search`); a full URL string or a pre-built `Request` also works. The optional third arg is a standard `RequestInit`.
|
|
66
|
+
|
|
67
|
+
### Auth and session helpers
|
|
68
|
+
|
|
69
|
+
Server-action CSRF is an Origin / `Sec-Fetch-Site` check, so a test needs no CSRF setup. `loginAndGetCookies` drives the REAL credentials login through `handle()` and captures the genuine signed session cookie, so a follow-up request can hit a protected route as the logged-in user.
|
|
70
|
+
|
|
71
|
+
```js
|
|
72
|
+
const gated = await testRequest(app.handle, '/dashboard');
|
|
73
|
+
assert.equal(gated.status, 302); // -> /login
|
|
74
|
+
|
|
75
|
+
const { cookies } = await loginAndGetCookies(app.handle, { email, password });
|
|
76
|
+
const dash = await testRequest(app.handle, '/dashboard', withSessionCookie({}, cookies));
|
|
77
|
+
assert.equal(dash.status, 200);
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
### `invokeActionForTest`: round-trip an action through the REAL endpoint
|
|
81
|
+
|
|
82
|
+
```js
|
|
83
|
+
// modules/posts/actions/create.server.ts exports createPost
|
|
84
|
+
const out = await invokeActionForTest(app, 'modules/posts/actions/create.server.ts', 'createPost', [input]);
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
It serializes the args with the WebJs serializer exactly as the generated client stub does, POSTs them same-origin to `/__webjs/action/<hash>/<fn>`, and parses the response. Prefer this over a direct import of the action. A direct import bypasses three production concerns the endpoint enforces (the wire serializer, CSRF, and prod error sanitization), so `invokeActionForTest` catches a regression a direct import cannot see. For negative cases, `rawActionRequest(app, file, fn, args, { crossOrigin: true })` returns the raw `Response` and never throws on a non-2xx (pass `{ omitCsrf: true }` to drop the CSRF pair).
|
|
88
|
+
|
|
89
|
+
## When a browser or e2e test is REQUIRED
|
|
90
|
+
|
|
91
|
+
A unit test is necessary but NOT sufficient for any change to hydration, the client router, slots, or custom-element upgrade. The headline behaviour of these is a browser or e2e assertion, so ship one:
|
|
92
|
+
|
|
93
|
+
- Hydration and the SSR-then-hydrate agreement.
|
|
94
|
+
- Client-router navigation, form submissions through the router, prefetch.
|
|
95
|
+
- Slots and light / shadow DOM projection.
|
|
96
|
+
- Custom-element upgrade of the SSR'd tag.
|
|
97
|
+
- Progressive soft-nav streaming (assert the fallback is live at the moment the URL advances) belongs in e2e (`WEBJS_E2E=1`).
|
|
98
|
+
|
|
99
|
+
Component mount helpers (`fixture`, `ssrFixture`, `waitForUpdate`) come from `@webjsdev/core/testing`; see `components.md`.
|
|
100
|
+
|
|
101
|
+
## Rendering the app and looking for UI defects
|
|
102
|
+
|
|
103
|
+
A layout bug (a board that collapses, cells of unequal size, a grid that resizes as it fills) is invisible to `webjs check`, `webjs typecheck`, and a glance at the empty first paint. Static tools give no signal for a visual defect, so render the app in a real browser and look.
|
|
104
|
+
|
|
105
|
+
- A browser test can measure real geometry with `getBoundingClientRect()` and FAIL on the defect. There is no framework helper (it is a few lines); write it against your component and assert its children stay the same size and do not resize as the grid fills. Ship one for any grid, board, or gallery layout.
|
|
106
|
+
- Test both light AND dark mode. Light mode passing proves nothing about dark mode. Emulate dark (a `newContext({ colorScheme: 'dark' })` or the theme toggle) and inspect a component's COMPUTED `background-color` and `color`, not just the page chrome.
|
|
107
|
+
- Read the screenshot. Capture `page.screenshot({ fullPage: true })` and open the PNG. White-on-white or a stray light box is obvious visually and invisible in the markup.
|
|
108
|
+
|
|
109
|
+
## Bun cross-runtime parity
|
|
110
|
+
|
|
111
|
+
WebJs runs on Node 24+ or Bun. The Node suite is the source of truth; an additive Bun matrix re-runs the runtime-sensitive suite under Bun to catch the long tail of cross-runtime incompatibilities (a `node:*` API Bun implements differently, a crypto or stream edge case, an error-message-format quirk).
|
|
112
|
+
|
|
113
|
+
Bun parity is part of the definition of done. A change to a runtime-sensitive surface (the serializer, the `node:http` vs `Bun.serve` listener and request path, SSR / action / CSRF dispatch, streams, `node:crypto`, the TS stripper, auth / session / cors) is NOT done until you run the Bun matrix green AND add or update a `test/bun/<feature>.mjs` cross-runtime assertion. Run it with `node scripts/run-bun-tests.js` (needs `bun` on PATH).
|
|
114
|
+
|
|
115
|
+
## Convention validation (`webjs check`)
|
|
116
|
+
|
|
117
|
+
`webjs check` is the correctness validator. Every rule catches code that is wrong to ship (a crash, a security leak, a type-strip failure), plus the `no-scaffold-placeholder` sentinel for unreplaced scaffold content. Run it and fix every violation before considering the change done (`webjs check --json` for an agent loop, `webjs check --rules` to list the rules). It is separate from `CONVENTIONS.md`, which carries the customizable project conventions you follow by judgment.
|
|
118
|
+
|
|
119
|
+
## What NOT to do
|
|
120
|
+
|
|
121
|
+
- Do not recreate a top-level `test/{unit,browser,e2e}/` shape. Kind is a child of feature, never the reverse.
|
|
122
|
+
- Do not create empty kind folders.
|
|
123
|
+
- Do not import from another package's `test/` directory. Test code is not a public surface.
|
|
124
|
+
- Do not add `.unit` / `.integration` filename suffixes. The folder tells you the kind.
|
|
125
|
+
- Do not run WTR or Playwright inside a headless sandbox that lacks the transform plugins or native browser libraries. Instead, extract the reconciliation or optimistic-update logic into a pure browser-safe utility and cover it with a Node unit test.
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
# TypeScript
|
|
2
|
+
|
|
3
|
+
## What This Covers
|
|
4
|
+
|
|
5
|
+
- TypeScript at runtime with **no build step**: `.ts` / `.mts` is stripped in place, not compiled.
|
|
6
|
+
- **Erasable syntax only** (`erasableSyntaxOnly: true`) and the exact list of banned constructs, with their allowed rewrites.
|
|
7
|
+
- The **pluggable stripper** (Node 24+ built-in vs `amaro` on Bun) and how the browser gets stripped source.
|
|
8
|
+
- **Full-stack type safety**: server-action types flow to the call site, `import type` crosses the `.server` boundary, plus the carrier rule.
|
|
9
|
+
- **Typing pages, layouts, and route handlers** with `PageProps` / `LayoutProps` / `RouteHandlerContext` and the generated route union (`webjs types`).
|
|
10
|
+
|
|
11
|
+
Read this when you are writing `.ts` in a WebJs app, hit a strip-time 500, or want typed params and hrefs. For action signatures and the serializer wire see `data-and-actions.md`. For typing reactive props see `components.md`.
|
|
12
|
+
|
|
13
|
+
TypeScript is optional. JS + JSDoc gets the same call-site safety (the language server reads `@typedef` / `@param` / `@returns` identically). Add `"checkJs": true` to enforce it.
|
|
14
|
+
|
|
15
|
+
## No build step: how `.ts` runs
|
|
16
|
+
|
|
17
|
+
`.ts` works everywhere `.js` does, same routing conventions, same server-action behaviour. There is no user-visible `tsc` run and no build output.
|
|
18
|
+
|
|
19
|
+
- **Server-side** `.ts` imports are stripped by the runtime automatically (Node exposes `process.features.typescript === 'strip'`, Bun runs `.ts` natively).
|
|
20
|
+
- **Browser-bound** `.ts` requests go through the pluggable stripper on the dev server, which does whitespace replacement (every source position maps to the same output position, so stack traces stay byte-exact with no sourcemap shipped). Cached by mtime.
|
|
21
|
+
|
|
22
|
+
The stripper backs onto **Node 24+'s built-in `module.stripTypeScriptTypes`** (itself SWC's WASM transform in strip-only mode) or, on **Bun**, `amaro` loaded directly (byte-identical output). Force one with `WEBJS_TS_STRIPPER=builtin|amaro`.
|
|
23
|
+
|
|
24
|
+
## Erasable syntax only
|
|
25
|
+
|
|
26
|
+
The stripper supports **erasable TypeScript only**: type annotations, `interface`, `type`, `declare`, generics, `import type`, `as` casts, and `satisfies`. Non-erasable syntax is rejected at strip time (a 500 naming the file), so set `erasableSyntaxOnly: true` in `tsconfig.json` to catch it as an editor squiggle first. `webjs check`'s `erasable-typescript-only` rule verifies the flag is set.
|
|
27
|
+
|
|
28
|
+
Banned constructs and their erasable rewrites:
|
|
29
|
+
|
|
30
|
+
```ts
|
|
31
|
+
// BANNED (rejected at compile + runtime)
|
|
32
|
+
enum Color { Red, Green, Blue }
|
|
33
|
+
class Foo { constructor(public x: number) {} } // parameter property
|
|
34
|
+
namespace Util { export const helper = 1; } // value namespace
|
|
35
|
+
import fs = require('fs'); // import = require
|
|
36
|
+
@legacyDecorator class C {} // legacy decorator + emitDecoratorMetadata
|
|
37
|
+
|
|
38
|
+
// ALLOWED (canonical erasable forms)
|
|
39
|
+
const Color = { Red: 'Red', Green: 'Green', Blue: 'Blue' } as const;
|
|
40
|
+
type Color = typeof Color[keyof typeof Color];
|
|
41
|
+
|
|
42
|
+
class Foo {
|
|
43
|
+
x: number;
|
|
44
|
+
constructor(x: number) { this.x = x; }
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const Util = { helper: 1 };
|
|
48
|
+
|
|
49
|
+
import * as fs from 'fs';
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
A third-party `.ts` dependency shipping non-erasable syntax fails the same way (rare, most npm packages publish compiled `.js`). WebJs is buildless end-to-end with no bundler fallback, so keep `erasableSyntaxOnly` on and your own code never hits it.
|
|
53
|
+
|
|
54
|
+
Prefer explicit `.ts` extensions in imports. A `.js` specifier pointing at a `.ts` sibling also resolves in the dev server, but explicit `.ts` is clearer.
|
|
55
|
+
|
|
56
|
+
## Minimum `tsconfig.json`
|
|
57
|
+
|
|
58
|
+
```json
|
|
59
|
+
{
|
|
60
|
+
"compilerOptions": {
|
|
61
|
+
"target": "ES2022",
|
|
62
|
+
"module": "NodeNext",
|
|
63
|
+
"moduleResolution": "NodeNext",
|
|
64
|
+
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
|
65
|
+
"strict": true,
|
|
66
|
+
"noEmit": true,
|
|
67
|
+
"checkJs": true,
|
|
68
|
+
"allowJs": true,
|
|
69
|
+
"allowImportingTsExtensions": true,
|
|
70
|
+
"skipLibCheck": true,
|
|
71
|
+
"erasableSyntaxOnly": true
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
`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
|
+
|
|
78
|
+
## Full-stack type safety
|
|
79
|
+
|
|
80
|
+
### Server actions type-check automatically
|
|
81
|
+
|
|
82
|
+
Calling a server action from a client component resolves at type-check time to the action's real source file. The runtime stub swap is invisible to the checker, and the RPC serializer makes runtime match the types (`Date` stays `Date`, `Map` stays `Map`, `BigInt` stays `BigInt`; see `data-and-actions.md` for the full supported set).
|
|
83
|
+
|
|
84
|
+
```ts
|
|
85
|
+
// modules/posts/actions/create-post.server.ts
|
|
86
|
+
export async function createPost(
|
|
87
|
+
input: { title: string; body: string },
|
|
88
|
+
): Promise<ActionResult<PostFormatted>> { /* ... */ }
|
|
89
|
+
|
|
90
|
+
// modules/posts/components/new-post.ts
|
|
91
|
+
import { createPost } from '#modules/posts/actions/create-post.server.ts';
|
|
92
|
+
const r = await createPost({ title, body });
|
|
93
|
+
// ^ Promise<ActionResult<PostFormatted>>
|
|
94
|
+
if (r.success) r.data.title; // PostFormatted.title: string
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
Class instances arrive as plain objects (prototypes and methods lost, matching React Server Actions). The opt-in `SerializableActionFn` annotation turns that silent loss into a compile error (`Serializable<T>` / `SerializableArgs` / `SerializableResult` are also exported, all types-only).
|
|
98
|
+
|
|
99
|
+
### The carrier rule: `import type` across the `.server` boundary
|
|
100
|
+
|
|
101
|
+
A `.server.ts` file WITHOUT `'use server'` is a server-only utility whose browser stub throws at load. But a **type-only** `import type { Row } from '#db/schema.server.ts'` is safe: the stripper erases it before it can reach the browser, so sharing a derived row type from a `.server.ts` into a shipping component is fine and is not flagged. A **value** import of that same file into a shipping module is the crash the `no-server-import-in-browser-module` check catches. So carry TYPES over the boundary with `import type`, and carry DATA over it through a `'use server'` action (whose RPC stub loads safely client-side).
|
|
102
|
+
|
|
103
|
+
### Typed page / layout / route-handler props
|
|
104
|
+
|
|
105
|
+
Type each routing entry with the exported helpers so a param typo is a compile error.
|
|
106
|
+
|
|
107
|
+
```ts
|
|
108
|
+
import type { PageProps, LayoutProps, RouteHandlerContext } from '@webjsdev/core';
|
|
109
|
+
|
|
110
|
+
// Static route: params is Record<string, string>.
|
|
111
|
+
export default function About({ searchParams }: PageProps) { /* ... */ }
|
|
112
|
+
|
|
113
|
+
// Dynamic route: pass the route literal to narrow params.
|
|
114
|
+
export default function Post({ params }: PageProps<'/blog/[slug]'>) {
|
|
115
|
+
const slug = params.slug; // typed string
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// Layout adds children.
|
|
119
|
+
export default function RootLayout({ children }: LayoutProps) { /* ... */ }
|
|
120
|
+
|
|
121
|
+
// Route handler's 2nd arg.
|
|
122
|
+
export async function GET(req: Request, ctx: RouteHandlerContext) {
|
|
123
|
+
return Response.json({ id: ctx.params.id });
|
|
124
|
+
}
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
With no route literal (or before you generate route types), `params` is `Record<string, string>`, the runtime default. With `R` set to a generated dynamic route, `params` narrows to its exact shape (`{ slug: string }`, `{ rest: string[] }`, `{ slug?: string[] }`). These are pure types, erased at runtime.
|
|
128
|
+
|
|
129
|
+
Type page metadata with the exported `Metadata` type (and `MetadataContext` for the `generateMetadata` argument), the same ergonomics as Next.js's `import type { Metadata } from 'next'`.
|
|
130
|
+
|
|
131
|
+
### The generated route union (`webjs types`)
|
|
132
|
+
|
|
133
|
+
Run `webjs types` to write `.webjs/routes.d.ts`, an opt-in overlay augmenting `@webjsdev/core` with one key per route in `app/`. It narrows two things at tsserver time:
|
|
134
|
+
|
|
135
|
+
- The `Route` href type: `navigate('/blog/anything')` passes, `navigate('/nonexistent')` is an error. Until you generate the types, `Route` is `string` (unconstrained, non-breaking for JSDoc and un-generated apps).
|
|
136
|
+
- Per-route `params`: `PageProps<'/blog/[slug]'>['params']` becomes `{ slug: string }`.
|
|
137
|
+
|
|
138
|
+
```sh
|
|
139
|
+
webjs types # writes .webjs/routes.d.ts (route count printed)
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
`webjs dev` emits it at startup and re-emits after each route rebuild, so the editor always has fresh types. The file is gitignored (regenerated per machine, like Next's `.next/types`); the scaffold `tsconfig.json` already lists it in `include`. To opt in for an existing app, run `webjs types` once and add `.webjs/routes.d.ts` to `include`. This is webjs's no-build equivalent of Next 15's `typedRoutes`, achieved via interface declaration-merging rather than a bundler.
|
|
143
|
+
|
|
144
|
+
### The `webjs` config block and auth user
|
|
145
|
+
|
|
146
|
+
The `webjs` object in `package.json` has two typed references so a typo'd key is diagnosed instead of dropped: a JSON Schema (VS Code flags an unknown key while you edit) and the `WebjsConfig` type from `@webjsdev/core`. Type `auth()`'s session user by augmenting the `AuthUser` interface (types every `auth()` call) or by parameterising `createAuth<AppUser>(...)` (types one instance), both types-only. Un-augmented, `user` resolves to `Record<string, unknown>`.
|
|
147
|
+
|
|
148
|
+
Both `@webjsdev/core` and `@webjsdev/server` ship hand-authored `.d.ts` overlays with a `types` export condition, so a `strict` + `nodenext` app resolves real types for either import with no TS7016 error. The runtime stays plain `.js` + JSDoc; the overlays cost nothing at runtime.
|
|
@@ -75,7 +75,7 @@ const msg =
|
|
|
75
75
|
`A .server.{ts,js} file with NO 'use server' directive throws at load in the browser, ` +
|
|
76
76
|
`so this would crash the page (webjs check flags it as no-server-import-in-browser-module). ` +
|
|
77
77
|
`Fix: add 'use server' to make it an RPC action, or reach it from a 'use server' action / route.ts / ` +
|
|
78
|
-
`middleware.ts, or share only a type via 'import type'. See
|
|
78
|
+
`middleware.ts, or share only a type via 'import type'. See the skill's references/data-and-actions.md.`;
|
|
79
79
|
|
|
80
80
|
if (process.env.WEBJS_SERVER_IMPORT_GATE === 'block') {
|
|
81
81
|
process.stderr.write(`BLOCKED: ${msg}\n`);
|
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
# default. But it is a CONVENTION, not a correctness check: a sensible
|
|
8
8
|
# app can legitimately want a test-less commit (a spike, a vendored
|
|
9
9
|
# file, a pure refactor). The convention-vs-check principle in this
|
|
10
|
-
# app's AGENTS.md
|
|
10
|
+
# app's AGENTS.md says guidance like this WARNS, it
|
|
11
11
|
# does not hard-block by default. So this hook surfaces a loud reminder
|
|
12
12
|
# and lets the commit proceed.
|
|
13
13
|
#
|
|
@@ -1,15 +1,5 @@
|
|
|
1
1
|
{
|
|
2
2
|
"hooks": {
|
|
3
|
-
"UserPromptSubmit": [
|
|
4
|
-
{
|
|
5
|
-
"hooks": [
|
|
6
|
-
{
|
|
7
|
-
"type": "command",
|
|
8
|
-
"command": ".claude/hooks/route-skills.sh"
|
|
9
|
-
}
|
|
10
|
-
]
|
|
11
|
-
}
|
|
12
|
-
],
|
|
13
3
|
"PreToolUse": [
|
|
14
4
|
{
|
|
15
5
|
"matcher": "Write|Edit|MultiEdit",
|
|
@@ -83,10 +73,6 @@
|
|
|
83
73
|
{
|
|
84
74
|
"type": "command",
|
|
85
75
|
"command": ".claude/hooks/commit-before-stop.sh"
|
|
86
|
-
},
|
|
87
|
-
{
|
|
88
|
-
"type": "command",
|
|
89
|
-
"command": ".claude/hooks/design-review-before-stop.sh"
|
|
90
76
|
}
|
|
91
77
|
]
|
|
92
78
|
}
|