@webjsdev/cli 0.10.50 → 0.10.52
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +3 -1
- package/bin/webjs.js +437 -32
- package/lib/api-gallery.js +6 -7
- package/lib/app-name.js +208 -0
- package/lib/create.js +37 -9
- package/lib/doctor.js +566 -21
- package/package.json +2 -2
- package/templates/.agents/rules/workflow.md +9 -1
- package/templates/.agents/skills/webjs/SKILL.md +26 -11
- package/templates/.agents/skills/webjs/references/auth-and-sessions.md +2 -2
- package/templates/.agents/skills/webjs/references/built-ins.md +26 -7
- package/templates/.agents/skills/webjs/references/client-router-and-streaming.md +16 -2
- package/templates/.agents/skills/webjs/references/components.md +59 -2
- package/templates/.agents/skills/webjs/references/data-and-actions.md +92 -7
- package/templates/.agents/skills/webjs/references/muscle-memory-gotchas.md +75 -19
- package/templates/.agents/skills/webjs/references/optimistic-ui.md +35 -14
- package/templates/.agents/skills/webjs/references/routing-and-pages.md +34 -15
- package/templates/.agents/skills/webjs/references/runtime.md +5 -1
- package/templates/.agents/skills/webjs/references/styling.md +1 -1
- package/templates/.agents/skills/webjs/references/testing.md +80 -3
- package/templates/.agents/skills/webjs/references/typescript.md +71 -2
- package/templates/.agents/skills/webjs/references/ui-kit.md +5 -2
- package/templates/.github/pull_request_template.md +1 -0
- package/templates/.github/workflows/ci.yml +13 -0
- package/templates/AGENTS.md +31 -5
- package/templates/CONVENTIONS.md +4 -1
- package/templates/gallery/app/examples/layout.ts +2 -1
- package/templates/gallery/app/examples/todo/page.ts +3 -16
- package/templates/gallery/app/features/auth/dashboard/layout.ts +2 -1
- package/templates/gallery/app/features/auth/signup/page.ts +4 -23
- package/templates/gallery/app/features/caching/page.ts +6 -6
- package/templates/gallery/app/features/file-storage/page.ts +8 -19
- package/templates/gallery/app/features/forms/page.ts +12 -38
- package/templates/gallery/app/features/layout.ts +6 -2
- package/templates/gallery/app/features/route-handler/data/route.ts +2 -1
- package/templates/gallery/app/features/view-transitions/page.ts +1 -1
- package/templates/gallery/app/global-error.ts +7 -4
- package/templates/gallery/modules/async-render/components/server-clock.ts +3 -2
- package/templates/gallery/modules/auth/actions/signup.server.ts +27 -11
- package/templates/gallery/modules/file-storage/actions/store-upload.server.ts +21 -10
- package/templates/gallery/modules/forms/actions/send-message.server.ts +34 -0
- package/templates/gallery/modules/gallery/nav.ts +1 -1
- package/templates/gallery/modules/server-actions/actions/greet.test.ts +6 -5
- package/templates/gallery/modules/todo/actions/submit-todo.server.ts +33 -0
- package/templates/gallery/modules/todo/components/todo-app.ts +8 -5
- package/templates/gallery/modules/todo/types.ts +15 -10
- package/templates/gallery/test/auth/auth.test.ts +31 -16
- package/templates/partials/agents-playbook-api.md +5 -0
- package/templates/partials/agents-playbook-fullstack.md +5 -0
- package/templates/scripts/clear-gallery.mjs +5 -4
- package/templates/test/hello/e2e/hello.test.ts +18 -1
|
@@ -5,8 +5,8 @@
|
|
|
5
5
|
- TypeScript at runtime with **no build step**: `.ts` / `.mts` is stripped in place, not compiled.
|
|
6
6
|
- **Erasable syntax only** (`erasableSyntaxOnly: true`) and the exact list of banned constructs, with their allowed rewrites.
|
|
7
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
|
|
9
|
-
- **Typing pages, layouts, and route handlers** with `PageProps` / `LayoutProps` / `RouteHandlerContext` and the generated route union (`
|
|
8
|
+
- **Full-stack type safety**: the rule (derive the type at every boundary, never `unknown` / `any`), server-action types flowing to the call site, and the `import type` carrier rule across the `.server` boundary.
|
|
9
|
+
- **Typing pages, layouts, and route handlers** with `PageProps` / `LayoutProps` / `RouteHandlerContext` and the generated route union (`npx webjsdev types`).
|
|
10
10
|
|
|
11
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
12
|
|
|
@@ -77,6 +77,75 @@ Prefer explicit `.ts` extensions in imports. A `.js` specifier pointing at a `.t
|
|
|
77
77
|
|
|
78
78
|
## Full-stack type safety
|
|
79
79
|
|
|
80
|
+
### The rule: derive the type, never `unknown` or `any`
|
|
81
|
+
|
|
82
|
+
Every value crossing an app boundary in WebJs already has a type you can reach, so reach for it. Writing `unknown` or `any` at a boundary throws away the framework's central guarantee, and it does so silently: the app still runs, the checker just stops helping. Nothing catches this for you. Both are valid TypeScript, so `webjs check` will not flag them (it is a correctness tool, and this is a convention), and `tsc --noEmit` passes happily.
|
|
83
|
+
|
|
84
|
+
`unknown` is the more dangerous of the two, because it reads as the safe choice. It is safe only in the sense that it forces a narrow at the read site. As a boundary type it is exactly as uninformative as `any`, and it pushes a cast into every consumer.
|
|
85
|
+
|
|
86
|
+
The ladder, in the order to climb it:
|
|
87
|
+
|
|
88
|
+
| Boundary | Write this | Not this |
|
|
89
|
+
|---|---|---|
|
|
90
|
+
| A database row | `export type Todo = typeof todos.$inferSelect` in `db/schema.server.ts` (`$inferInsert` for a write) | a hand-written interface that drifts from the schema |
|
|
91
|
+
| That row inside a shipping component | `import type { Todo } from '#db/schema.server.ts'` | `any[]`, or re-declaring the shape by hand |
|
|
92
|
+
| A server action's input | a named `interface CreatePostInput` | `input: unknown` / `input: any` |
|
|
93
|
+
| A server action's result | `Promise<ActionResult<Post>>` | `Promise<any>` |
|
|
94
|
+
| `export const validate` | `(input: unknown)`, narrowed in the body, returning `data` that `satisfies` the action's input type | `(input: any)`, which un-types the returned `data` too |
|
|
95
|
+
| A page | `PageProps<'/blog/[slug]'>` | `{ params: Record<string, any> }` |
|
|
96
|
+
| A layout | `LayoutProps` (whose `children` is a `TemplateResult`) | `{ children: unknown }` |
|
|
97
|
+
| A route handler's 2nd argument | `RouteHandlerContext<'/api/users/[id]'>` | `{ params: any }` |
|
|
98
|
+
| A client-router href | the generated `Route` union (`npx webjsdev types`; `npm run dev` also emits it) | a bare `string` |
|
|
99
|
+
| A reactive property | `prop<Student>(Object)`, `prop<Tag[]>(Array)` | `prop(Object)` plus a cast at every read |
|
|
100
|
+
| An optimistic temp row | the pending shape on the row type (`pending?: boolean`) | `as any` on the temp id |
|
|
101
|
+
|
|
102
|
+
One flow, end to end, with no escape hatch anywhere in it:
|
|
103
|
+
|
|
104
|
+
```ts
|
|
105
|
+
// db/schema.server.ts
|
|
106
|
+
export const posts = table('posts', { id: uuidPk(), title: text().notNull(), body: text().notNull() });
|
|
107
|
+
export type Post = typeof posts.$inferSelect; // derived, never hand-written
|
|
108
|
+
|
|
109
|
+
// modules/posts/actions/create-post.server.ts
|
|
110
|
+
'use server';
|
|
111
|
+
import type { Post } from '#db/schema.server.ts'; // type-only: erased before the browser sees it
|
|
112
|
+
export interface CreatePostInput { title: string; body: string }
|
|
113
|
+
export const validate = (input: unknown) => { /* narrows, returns data satisfying CreatePostInput */ };
|
|
114
|
+
export async function createPost(input: CreatePostInput): Promise<ActionResult<Post>> { /* ... */ }
|
|
115
|
+
|
|
116
|
+
// modules/posts/components/new-post.ts
|
|
117
|
+
import { createPost } from '#modules/posts/actions/create-post.server.ts';
|
|
118
|
+
const r = await createPost({ title, body });
|
|
119
|
+
if (r.success && r.data) r.data.title; // Post.title: string, checked at the call site
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
The payoff is not stylistic. A typo in `r.data.titel`, a renamed column, a changed action signature, and a page reading `params.slugg` are all compile errors in that version and all silent runtime `undefined` in the `unknown` version.
|
|
123
|
+
|
|
124
|
+
### Where `unknown` is still the right type
|
|
125
|
+
|
|
126
|
+
There are two cases, and only the first is about narrowing.
|
|
127
|
+
|
|
128
|
+
**Case one: a value that genuinely has no type yet, at the moment before it is narrowed.**
|
|
129
|
+
|
|
130
|
+
```ts
|
|
131
|
+
// app/api/webhook/route.ts
|
|
132
|
+
export async function POST(req: Request) {
|
|
133
|
+
const body: unknown = await req.json(); // correct: nothing has vouched for this yet
|
|
134
|
+
const parsed = parseWebhook(body); // narrowed on the very next line
|
|
135
|
+
return Response.json({ ok: parsed.kind });
|
|
136
|
+
}
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
The test is what the next line does. Correct `unknown` here is narrowed immediately by a parse, a validator, or a type guard, and the narrowed type is what the rest of the function sees. Anything standing at that boundary qualifies: a `route.ts` handler's `await req.json()` (above), a `catch (e)` binding (already `unknown` under `strict`), an action's `export const validate`, and any validator function those delegate to. In a validator, returning `data` that `satisfies` the action's input type is what carries a real type into the action body. See `data-and-actions.md`.
|
|
140
|
+
|
|
141
|
+
**Case two: a parameter of YOUR OWN helper that forwards its argument into an `html` template hole, which is NOT narrowed.** A hole renders a string, a number, a `TemplateResult`, an array of those, a directive result, or nothing, so a helper like `lede(content: unknown)` is correctly typed and `TemplateResult` alone would be too narrow. Narrow it only when the helper genuinely accepts one shape (`backLink(href: string, ...)`).
|
|
142
|
+
|
|
143
|
+
This case is about a value YOU accept, never one the framework hands you. A layout's `children` also ends up in a hole, but the framework already types it (`LayoutProps.children` is a `TemplateResult`), so `{ children: unknown }` is a discarded type, not this carve-out.
|
|
144
|
+
|
|
145
|
+
Everywhere else `unknown` is a missing type, not a safe one: surviving into a return type, a component prop, a layout's `children`, or an action signature is the shape to fix.
|
|
146
|
+
|
|
147
|
+
`any` gets no carve-out at all in app code. It does not defer checking, it disables it, so a validator typed `(input: any)` un-types everything downstream of the call.
|
|
148
|
+
|
|
80
149
|
### Server actions type-check automatically
|
|
81
150
|
|
|
82
151
|
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).
|
|
@@ -40,10 +40,13 @@ So the loop is: `add` the component, then query `ui <name>` (MCP) or
|
|
|
40
40
|
|
|
41
41
|
## Setup and resolution
|
|
42
42
|
|
|
43
|
-
- `npx webjsdev ui init` writes `components.json`, `lib/utils.ts`, and the CSS design
|
|
43
|
+
- `npx webjsdev ui init` writes `components.json`, `lib/utils/cn.ts`, and the CSS design
|
|
44
44
|
tokens the helpers render against (`--background`, `--foreground`,
|
|
45
45
|
`--destructive`, ...). It HARD-FAILS if the tokens cannot be written, so a
|
|
46
|
-
clean exit means the kit is styled.
|
|
46
|
+
clean exit means the kit is styled. Re-running it is safe on an existing
|
|
47
|
+
project: it keeps the aliases, stylesheet path, and base color already in
|
|
48
|
+
`components.json` and leaves an edited `cn.ts` / `dom.ts` alone, so it only
|
|
49
|
+
fills in what is missing (`--overwrite` resets them instead). `add` self-heals the tokens if they go
|
|
47
50
|
missing.
|
|
48
51
|
- Resolution is LOCAL-FIRST: `init` / `add` / `list` / `view` read the registry
|
|
49
52
|
that ships inside the installed `@webjsdev/ui`, with no network. This pins you
|
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
- [ ] Unit tests added/updated (`webjs test` passes)
|
|
8
8
|
- [ ] E2E tests added/updated for user-facing changes (`webjs test --e2e` passes)
|
|
9
9
|
- [ ] `webjs check` passes (no convention violations)
|
|
10
|
+
- [ ] `webjs doctor` passes (project health; it fails on whatever `webjs.doctor.gate` marks `error`, plus the hard `NODE_VERSION` / `TSCONFIG_ERASABLE` checks)
|
|
10
11
|
|
|
11
12
|
## Definition of done
|
|
12
13
|
|
|
@@ -34,6 +34,19 @@ jobs:
|
|
|
34
34
|
cache: npm
|
|
35
35
|
- run: npm ci
|
|
36
36
|
- run: npm run check
|
|
37
|
+
# Project health, on top of the correctness checks. WHICH findings are
|
|
38
|
+
# fatal is your call, declared in package.json under
|
|
39
|
+
# "webjs": { "doctor": { "gate": { "<CODE>": "off" | "warn" | "error" } } },
|
|
40
|
+
# so this step and a local `npm run doctor` always agree. The scaffold
|
|
41
|
+
# starts with UNMARKED_ASSET_LINKS at error (an un-versioned /public url
|
|
42
|
+
# is a real deploy-staleness bug). Two checks fail with no gate entry at
|
|
43
|
+
# all, NODE_VERSION and TSCONFIG_ERASABLE, because either would 500 the
|
|
44
|
+
# app at runtime; everything else stays a warn and cannot fail this job.
|
|
45
|
+
# Widen or narrow the gate in package.json, not
|
|
46
|
+
# here. Deliberately not --strict: the git-hook, env-drift, vendor-pin,
|
|
47
|
+
# and framework-resolve checks are environment-shaped and would fail a
|
|
48
|
+
# perfectly healthy runner.
|
|
49
|
+
- run: npm run doctor
|
|
37
50
|
|
|
38
51
|
unit:
|
|
39
52
|
name: Unit + integration (node --test)
|
package/templates/AGENTS.md
CHANGED
|
@@ -31,11 +31,37 @@ This is what separates a working app from a broken one.
|
|
|
31
31
|
|
|
32
32
|
## Type everything (all templates)
|
|
33
33
|
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
34
|
+
Full-stack type safety is what the `.server.ts` boundary buys you: a client
|
|
35
|
+
component importing a server action resolves to that action's real signature at
|
|
36
|
+
type-check time, with no build step and no code generation in between. So
|
|
37
|
+
DERIVE the type at every boundary instead of widening it:
|
|
38
|
+
|
|
39
|
+
- A database row: `export type Todo = typeof todos.$inferSelect` in
|
|
40
|
+
`db/schema.server.ts` (`$inferInsert` for a write), carried into a
|
|
41
|
+
browser-shipped component with `import type` (erased before it reaches the
|
|
42
|
+
browser, so it does not trip the server-import boundary).
|
|
43
|
+
- An action's input: a named `interface`. Its result: `ActionResult<T>`.
|
|
44
|
+
Narrow with `if (result.success && result.data)`.
|
|
45
|
+
- Routing files: `PageProps<'/blog/[slug]'>`, `LayoutProps`,
|
|
46
|
+
`RouteHandlerContext`, all from `@webjsdev/core`. Run `npx webjsdev types`
|
|
47
|
+
for the typed `Route` union and per-route `params`.
|
|
48
|
+
- A reactive property: `prop<Student>(Object)`, `prop<Tag[]>(Array)`.
|
|
49
|
+
|
|
50
|
+
Never reach for `any` or a loose `as any` cast, and do not reach for `unknown`
|
|
51
|
+
either just because it looks safer. `unknown` is right for a payload nothing
|
|
52
|
+
has vouched for yet, narrowed on the very next line (a `route.ts` `await
|
|
53
|
+
req.json()`, an action's `export const validate` or a validator it delegates
|
|
54
|
+
to, a `catch` binding), and for a parameter of YOUR OWN helper that forwards
|
|
55
|
+
into an `html` template hole (a hole renders a string, a number, a
|
|
56
|
+
`TemplateResult`, or an array of those, so `TemplateResult` alone is too
|
|
57
|
+
narrow). That second case is about a value you accept, never one the framework
|
|
58
|
+
already types. Everywhere else it is a missing type, not a safe one: `unknown`
|
|
59
|
+
that survives into a return type, a component prop, a layout's `children`, or
|
|
60
|
+
an action signature is the shape to fix.
|
|
61
|
+
Nothing enforces this (both are valid TypeScript, so `webjs check` and `tsc`
|
|
62
|
+
pass either way), which is exactly why it is written down. The full ladder,
|
|
63
|
+
with an end-to-end example, is in
|
|
64
|
+
`.agents/skills/webjs/references/typescript.md`.
|
|
39
65
|
|
|
40
66
|
Keep server-only code (database drivers, secrets, `node:*` builtins) in
|
|
41
67
|
`.server.ts` modules. There are exactly two kinds:
|
package/templates/CONVENTIONS.md
CHANGED
|
@@ -24,8 +24,11 @@ is the short version.
|
|
|
24
24
|
teaches the same and survives the clear), run `npm run gallery:clear` to shed
|
|
25
25
|
the showcase, then grow the app in place. `AGENTS.md` has the full
|
|
26
26
|
template-specific playbook.
|
|
27
|
+
- **Derive types at every boundary.** Rows from `$inferSelect`, action inputs
|
|
28
|
+
from an `interface`, routing files from `PageProps` / `LayoutProps`. Never
|
|
29
|
+
`any`, and never `unknown` where a real type exists.
|
|
27
30
|
- **Progressive enhancement is the default.** Pages render as HTML, `<a>`
|
|
28
|
-
navigates, `<form
|
|
31
|
+
navigates, a `<form action=${importedAction}>` submits, all with JavaScript off; opt into
|
|
29
32
|
interactivity per behaviour inside a component.
|
|
30
33
|
- **Commit per logical unit** as soon as it is complete, and never push to `main`.
|
|
31
34
|
|
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
import { html } from '@webjsdev/core';
|
|
2
|
+
import type { LayoutProps } from '@webjsdev/core';
|
|
2
3
|
import { backLink } from '#lib/utils/ui.ts';
|
|
3
4
|
|
|
4
5
|
// Shared layout for every gallery example app under /examples/*. It adds the same
|
|
5
6
|
// slim "back to the gallery" link the feature demos get, so an example is never a
|
|
6
7
|
// dead end. A non-root layout, so it never writes the document shell.
|
|
7
|
-
export default function ExamplesLayout({ children }:
|
|
8
|
+
export default function ExamplesLayout({ children }: LayoutProps) {
|
|
8
9
|
// An example app has no sidebar, so center it in a focused reading column
|
|
9
10
|
// (the root centers the whole page; this narrows the example within it).
|
|
10
11
|
return html`
|
|
@@ -1,14 +1,12 @@
|
|
|
1
1
|
// A THIN route adapter: app/ is routing only. It fetches the initial data
|
|
2
2
|
// (server-side) via the 'use server' query and renders the interactive
|
|
3
|
-
// component
|
|
4
|
-
//
|
|
3
|
+
// component. All the real logic lives in modules/todo/, including the action
|
|
4
|
+
// the component's forms bind to. This is the idiomatic app-thin +
|
|
5
|
+
// modules-logic split.
|
|
5
6
|
import { html } from '@webjsdev/core';
|
|
6
7
|
import type { Metadata } from '@webjsdev/core'; // Metadata is a @webjsdev/core type
|
|
7
8
|
import { pageHeading } from '#lib/utils/ui.ts';
|
|
8
9
|
import { listTodos } from '#modules/todo/queries/list-todos.server.ts';
|
|
9
|
-
import { createTodo } from '#modules/todo/actions/create-todo.server.ts';
|
|
10
|
-
import { toggleTodo } from '#modules/todo/actions/toggle-todo.server.ts';
|
|
11
|
-
import { deleteTodo } from '#modules/todo/actions/delete-todo.server.ts';
|
|
12
10
|
import '#modules/todo/components/todo-app.ts';
|
|
13
11
|
|
|
14
12
|
export const metadata: Metadata = { title: 'Todo (optimistic UI) | examples' };
|
|
@@ -22,14 +20,3 @@ export default async function TodoExample() {
|
|
|
22
20
|
<todo-app .todos=${todos}></todo-app>
|
|
23
21
|
`;
|
|
24
22
|
}
|
|
25
|
-
|
|
26
|
-
// No-JS write path: the component's <form>s post here; with JS the component
|
|
27
|
-
// intercepts and mutates optimistically instead. Success is a 303 PRG.
|
|
28
|
-
export async function action({ formData }: { formData: FormData }) {
|
|
29
|
-
const intent = String(formData.get('intent') ?? '');
|
|
30
|
-
const id = String(formData.get('id') ?? '');
|
|
31
|
-
if (intent === 'create') return createTodo({ title: String(formData.get('title') ?? '') });
|
|
32
|
-
if (intent === 'toggle') return toggleTodo({ id });
|
|
33
|
-
if (intent === 'delete') return deleteTodo({ id });
|
|
34
|
-
return { success: false as const, error: 'Unknown action.', status: 400 };
|
|
35
|
-
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { html } from '@webjsdev/core';
|
|
2
|
+
import type { LayoutProps } from '@webjsdev/core';
|
|
2
3
|
import { buttonClass } from '#components/ui/button.ts';
|
|
3
4
|
|
|
4
5
|
// Nested layout for the protected dashboard subtree. Logout is a plain
|
|
@@ -7,7 +8,7 @@ import { buttonClass } from '#components/ui/button.ts';
|
|
|
7
8
|
// default). signOut is server-only (modules/auth/auth.server.ts), so we POST to
|
|
8
9
|
// its route rather than import it into a browser-shipping page. After signout the
|
|
9
10
|
// dashboard middleware bounces any later visit to login.
|
|
10
|
-
export default function DashboardLayout({ children }:
|
|
11
|
+
export default function DashboardLayout({ children }: LayoutProps) {
|
|
11
12
|
return html`
|
|
12
13
|
<nav class="flex items-center gap-4 mb-6 pb-4 border-b border-border">
|
|
13
14
|
<a href="/features/auth/dashboard" class="text-sm font-medium text-foreground hover:underline">Dashboard</a>
|
|
@@ -8,28 +8,6 @@ export const metadata = { title: 'Sign up' };
|
|
|
8
8
|
|
|
9
9
|
const inputCls = inputClass();
|
|
10
10
|
|
|
11
|
-
// Page server action: handles the POST from the form below. With JS disabled this
|
|
12
|
-
// is a plain <form> round-trip; with JS the client router swaps the 422 re-render
|
|
13
|
-
// (errors) or follows the 302 (success) in place. A validation failure returns
|
|
14
|
-
// fieldErrors + values so the page re-renders with messages and the user's typed
|
|
15
|
-
// input preserved.
|
|
16
|
-
export async function action({ formData }: { formData: FormData }) {
|
|
17
|
-
const name = String(formData.get('name') || '').trim();
|
|
18
|
-
const email = String(formData.get('email') || '').trim();
|
|
19
|
-
const password = String(formData.get('password') || '');
|
|
20
|
-
const values = { name, email };
|
|
21
|
-
const fieldErrors: Record<string, string> = {};
|
|
22
|
-
if (!name) fieldErrors.name = 'Name is required';
|
|
23
|
-
if (!email.includes('@')) fieldErrors.email = 'Enter a valid email';
|
|
24
|
-
if (password.length < 8) fieldErrors.password = 'At least 8 characters';
|
|
25
|
-
if (Object.keys(fieldErrors).length) return { success: false, fieldErrors, values, status: 422 };
|
|
26
|
-
const result = await signup({ name, email, password });
|
|
27
|
-
// On success signup returns signIn's 302 Response (auto-login -> dashboard); a
|
|
28
|
-
// page action may return a Response, so pass it straight through.
|
|
29
|
-
if (result instanceof Response) return result;
|
|
30
|
-
return { success: false, fieldErrors: { email: result.error }, values, status: result.status };
|
|
31
|
-
}
|
|
32
|
-
|
|
33
11
|
export default function SignupPage({ actionData }: { actionData?: { fieldErrors?: Record<string, string>; values?: Record<string, string> } }) {
|
|
34
12
|
const errors = actionData?.fieldErrors || {};
|
|
35
13
|
const values = actionData?.values || {};
|
|
@@ -37,7 +15,10 @@ export default function SignupPage({ actionData }: { actionData?: { fieldErrors?
|
|
|
37
15
|
<div class="max-w-[420px] mx-auto">
|
|
38
16
|
<h1 class="text-h2 font-bold mb-2">Create an account</h1>
|
|
39
17
|
<p class="text-muted-foreground mb-5">Get started with your new workspace.</p>
|
|
40
|
-
|
|
18
|
+
<!-- The form is bound to the action: no adapter, no fetch handler. With JS
|
|
19
|
+
disabled this is a plain round-trip; with JS the client router swaps
|
|
20
|
+
the 422 re-render (errors) or follows the 302 (success) in place. -->
|
|
21
|
+
<form action=${signup} class="${cardClass()} grid gap-4 p-5">
|
|
41
22
|
<div class="grid gap-1.5">
|
|
42
23
|
<label for="name" class="text-[13px] font-medium text-muted-foreground">Name</label>
|
|
43
24
|
<input id="name" name="name" type="text" value=${values.name || ''} required class=${inputCls} placeholder="Ada Lovelace" />
|
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
// Caching: `export const revalidate = N` opts the page into the server HTML
|
|
2
|
-
// response cache, keyed by URL for N seconds. The
|
|
3
|
-
// changes once per window: reload inside 10s and
|
|
4
|
-
// and it refreshes. SAFETY: only cache a page that
|
|
5
|
-
// visitor (no cookies(), no session, no per-user data),
|
|
6
|
-
//
|
|
2
|
+
// response cache, keyed by the request origin plus the URL for N seconds. The
|
|
3
|
+
// rendered timestamp below only changes once per window: reload inside 10s and
|
|
4
|
+
// it is identical, reload after and it refreshes. SAFETY: only cache a page that
|
|
5
|
+
// is identical for every visitor (no cookies(), no session, no per-user data),
|
|
6
|
+
// since the key carries no per-user component. For per-query reads use cache() + tags with revalidateTag; for a
|
|
7
7
|
// public/ asset use asset(), demonstrated at the bottom of this page.
|
|
8
8
|
import { html, asset } from '@webjsdev/core';
|
|
9
9
|
import type { Metadata } from '@webjsdev/core';
|
|
@@ -23,7 +23,7 @@ export default function CachingExample() {
|
|
|
23
23
|
${pageHeading('Caching')}
|
|
24
24
|
${lede(html`
|
|
25
25
|
This page sets <code>export const revalidate = 10</code>, so its
|
|
26
|
-
server-rendered HTML is cached per URL for ten seconds.
|
|
26
|
+
server-rendered HTML is cached per origin and URL for ten seconds.
|
|
27
27
|
`)}
|
|
28
28
|
<p class="mb-4">
|
|
29
29
|
Rendered at
|
|
@@ -1,9 +1,10 @@
|
|
|
1
|
-
// File storage: a no-JS upload. A
|
|
2
|
-
// (the progressive-enhancement write path)
|
|
3
|
-
//
|
|
4
|
-
// (PRG) with the new key in the
|
|
5
|
-
//
|
|
6
|
-
//
|
|
1
|
+
// File storage: a no-JS upload. A <form> bound to a 'use server' action streams
|
|
2
|
+
// the bytes into the FileStore (the progressive-enhancement write path). The
|
|
3
|
+
// framework emits the multipart enctype an upload needs, so the binding is the
|
|
4
|
+
// whole wiring. On success the action redirects (PRG) with the new key in the
|
|
5
|
+
// query, and the page renders a download link that streams the file back
|
|
6
|
+
// through file/[key]/route.ts. Works with JS off; the client router applies the
|
|
7
|
+
// same flow in place with JS on.
|
|
7
8
|
import { html } from '@webjsdev/core';
|
|
8
9
|
import { buttonClass } from '#components/ui/button.ts';
|
|
9
10
|
import { cardClass } from '#components/ui/card.ts';
|
|
@@ -13,18 +14,6 @@ import { storeUpload } from '#modules/file-storage/actions/store-upload.server.t
|
|
|
13
14
|
|
|
14
15
|
export const metadata: Metadata = { title: 'File storage (upload + serve) | features' };
|
|
15
16
|
|
|
16
|
-
export async function action({ formData }: { formData: FormData }) {
|
|
17
|
-
const file = formData.get('file');
|
|
18
|
-
if (!(file instanceof File) || file.size === 0) {
|
|
19
|
-
return { success: false, error: 'Choose a file to upload.' };
|
|
20
|
-
}
|
|
21
|
-
const result = await storeUpload(file);
|
|
22
|
-
if (!result.success) return result;
|
|
23
|
-
const { key, name, size } = result.data;
|
|
24
|
-
const q = new URLSearchParams({ key, name, size: String(size) });
|
|
25
|
-
return { success: true, redirect: '/features/file-storage?' + q.toString() };
|
|
26
|
-
}
|
|
27
|
-
|
|
28
17
|
export default function FileStorageExample({
|
|
29
18
|
searchParams,
|
|
30
19
|
actionData,
|
|
@@ -43,7 +32,7 @@ export default function FileStorageExample({
|
|
|
43
32
|
gitignored). Swap the backend for S3/R2 with one
|
|
44
33
|
<code class="font-mono">setFileStore()</code> call, no call-site change.
|
|
45
34
|
`)}
|
|
46
|
-
<form
|
|
35
|
+
<form action=${storeUpload} class="flex flex-wrap gap-3 items-center mb-4">
|
|
47
36
|
<input type="file" name="file" required aria-label="Choose a file to upload"
|
|
48
37
|
class="text-sm text-muted-foreground file:mr-3 file:px-3.5 file:py-2 file:rounded-xl file:border-0 file:bg-card file:border file:border-border file:text-foreground file:text-sm file:cursor-pointer" />
|
|
49
38
|
<button type="submit"
|
|
@@ -1,11 +1,14 @@
|
|
|
1
|
-
// forms: the no-JS write path.
|
|
2
|
-
// `action`
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
1
|
+
// forms: the no-JS write path. Bind a server action straight into the form with
|
|
2
|
+
// `action=${sendMessage}` and that is the whole wiring: the framework posts to
|
|
3
|
+
// this page's own url, runs the action, and re-renders the SAME page with the
|
|
4
|
+
// result on `actionData`. WHY it matters: the form works with JS OFF (a plain
|
|
5
|
+
// server round-trip), and with JS the client router applies the response in
|
|
6
|
+
// place (no full reload). Never reach for fetch() + a click handler where a
|
|
7
|
+
// bound <form> does. On failure the framework re-renders at 422 with the
|
|
8
|
+
// result; on success it does a 303 Post-Redirect-Get, so we redirect to ?sent=1
|
|
9
|
+
// to show a confirmation.
|
|
8
10
|
import { html } from '@webjsdev/core';
|
|
11
|
+
import { sendMessage, type Result } from '#modules/forms/actions/send-message.server.ts';
|
|
9
12
|
import { cardClass } from '#components/ui/card.ts';
|
|
10
13
|
import { inputClass } from '#components/ui/input.ts';
|
|
11
14
|
import { buttonClass } from '#components/ui/button.ts';
|
|
@@ -14,13 +17,6 @@ import type { Metadata } from '@webjsdev/core';
|
|
|
14
17
|
|
|
15
18
|
export const metadata: Metadata = { title: 'Forms (no-JS PE) | features' };
|
|
16
19
|
|
|
17
|
-
interface Result {
|
|
18
|
-
success: boolean;
|
|
19
|
-
fieldErrors?: Record<string, string>;
|
|
20
|
-
values?: Record<string, string>;
|
|
21
|
-
redirect?: string;
|
|
22
|
-
}
|
|
23
|
-
|
|
24
20
|
const field = (label: string, name: string, input: unknown, error?: string) => html`
|
|
25
21
|
<div class="grid gap-1.5">
|
|
26
22
|
<label for=${name} class="text-[13px] font-medium text-muted-foreground">${label}</label>
|
|
@@ -48,8 +44,8 @@ export default function FormsFeature({ searchParams, actionData }: { searchParam
|
|
|
48
44
|
const v = actionData?.values ?? {};
|
|
49
45
|
return html`
|
|
50
46
|
<h1 class="text-h2 font-bold mb-2">Forms</h1>
|
|
51
|
-
<p class="text-muted-foreground mb-5 max-w-[460px]">A real <code><form></code>
|
|
52
|
-
<form
|
|
47
|
+
<p class="text-muted-foreground mb-5 max-w-[460px]">A real <code><form></code> bound to a server action. It works with JS off; validation errors come back on <code>actionData</code>.</p>
|
|
48
|
+
<form action=${sendMessage} class="${cardClass()} max-w-[460px] grid gap-4 p-5">
|
|
53
49
|
${field('Name', 'name', html`<input id="name" name="name" value=${v.name ?? ''} class=${inputCls} placeholder="Ada Lovelace" />`, errs.name)}
|
|
54
50
|
${field('Email', 'email', html`<input id="email" name="email" type="email" value=${v.email ?? ''} class=${inputCls} placeholder="ada@example.com" />`, errs.email)}
|
|
55
51
|
${field('Message', 'message', html`<textarea id="message" name="message" rows="3" class=${inputCls} placeholder="Say hello...">${v.message ?? ''}</textarea>`, errs.message)}
|
|
@@ -57,25 +53,3 @@ export default function FormsFeature({ searchParams, actionData }: { searchParam
|
|
|
57
53
|
</form>
|
|
58
54
|
`;
|
|
59
55
|
}
|
|
60
|
-
|
|
61
|
-
// The page action runs on a non-GET submission to this URL (the no-JS write
|
|
62
|
-
// path). Validate, then return a failure (re-renders at 422 with fieldErrors +
|
|
63
|
-
// values) or a success with a same-site `redirect` (a 303 PRG to the confirmation).
|
|
64
|
-
//
|
|
65
|
-
// FOOTGUN: to redirect on success, RETURN `{ success: true, redirect: '/path' }`
|
|
66
|
-
// (a 303 See Other, so the browser follows with a GET). Do NOT THROW `redirect()`
|
|
67
|
-
// from a page action, that is a 307 which PRESERVES the POST method and body, so
|
|
68
|
-
// the browser re-POSTs to the target and re-runs the mutation (a duplicate write).
|
|
69
|
-
// Throw `redirect()` only from a page render / GET context, never a page action.
|
|
70
|
-
export async function action({ formData }: { formData: FormData }): Promise<Result> {
|
|
71
|
-
const name = String(formData.get('name') ?? '').trim();
|
|
72
|
-
const email = String(formData.get('email') ?? '').trim();
|
|
73
|
-
const message = String(formData.get('message') ?? '').trim();
|
|
74
|
-
const fieldErrors: Record<string, string> = {};
|
|
75
|
-
if (!name) fieldErrors.name = 'Your name is required.';
|
|
76
|
-
if (!/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(email)) fieldErrors.email = 'A valid email is required.';
|
|
77
|
-
if (message.length < 5) fieldErrors.message = 'Message must be at least 5 characters.';
|
|
78
|
-
if (Object.keys(fieldErrors).length) return { success: false, fieldErrors, values: { name, email, message } };
|
|
79
|
-
// A real app would persist / email here. We just confirm.
|
|
80
|
-
return { success: true, redirect: '/features/forms?sent=1' };
|
|
81
|
-
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { html } from '@webjsdev/core';
|
|
2
|
+
import type { LayoutProps } from '@webjsdev/core';
|
|
2
3
|
import { backLink } from '#lib/utils/ui.ts';
|
|
3
4
|
import '#modules/gallery/components/gallery-nav.ts';
|
|
4
5
|
|
|
@@ -45,8 +46,11 @@ const STACK_CSS = `
|
|
|
45
46
|
// sidebar is hidden, so a slim back link keeps a demo from being a dead end.
|
|
46
47
|
// Nested layouts (like the auth dashboard's sub-nav) render inside ${children}.
|
|
47
48
|
// A non-root layout, so it never writes the document shell (the framework does).
|
|
48
|
-
|
|
49
|
-
|
|
49
|
+
// LayoutProps types every argument a layout receives, so `url` is known to be
|
|
50
|
+
// an absolute URL string (the framework passes url.toString()) and needs no
|
|
51
|
+
// defensive widening or runtime typeof check.
|
|
52
|
+
export default function FeaturesLayout({ children, url }: LayoutProps) {
|
|
53
|
+
const path = new URL(url).pathname;
|
|
50
54
|
return html`
|
|
51
55
|
<style>${SIDENAV_CSS}${STACK_CSS}</style>
|
|
52
56
|
<div class="lg:hidden mb-6">${backLink('/', html`← Gallery`)}</div>
|
|
@@ -24,7 +24,8 @@ export async function GET(req: Request) {
|
|
|
24
24
|
// not a cookie, so do not read it here.
|
|
25
25
|
cookieCount: cookies().entries().length,
|
|
26
26
|
// cspNonce() reads the request's CSP nonce ('' with CSP off). Server-side
|
|
27
|
-
// you use it to nonce a server-rendered inline <script
|
|
27
|
+
// you use it to nonce a server-rendered inline <script> under CSP (a <style>
|
|
28
|
+
// only needs it if you tighten style-src, which by default allows inline).
|
|
28
29
|
hasNonce: cspNonce().length > 0,
|
|
29
30
|
});
|
|
30
31
|
}
|
|
@@ -22,7 +22,7 @@ export default function ViewTransitionsExample() {
|
|
|
22
22
|
${pageHeading('View transitions')}
|
|
23
23
|
<div class="rounded-2xl bg-primary/10 border border-primary/30 p-6 mb-6">
|
|
24
24
|
<p class="text-foreground m-0">Page one. Navigate to page two: with the
|
|
25
|
-
<code class="font-mono"><meta name="view-transition"></code> opt-in,
|
|
25
|
+
<code class="font-mono"><meta name="view-transition" content="same-origin"></code> opt-in,
|
|
26
26
|
the swap cross-fades.</p>
|
|
27
27
|
</div>
|
|
28
28
|
<label class="block mb-6">
|
|
@@ -5,8 +5,9 @@
|
|
|
5
5
|
// returned verbatim with NO framework <head> splice, so it ships no importmap
|
|
6
6
|
// and no boot script. Keep it static HTML with no components or hydration: a
|
|
7
7
|
// last-resort page must not depend on the module system that may have just
|
|
8
|
-
// failed. (Under an opt-in CSP,
|
|
9
|
-
//
|
|
8
|
+
// failed. (Under an opt-in CSP, an inline <script> here needs a nonce via
|
|
9
|
+
// cspNonce() from @webjsdev/core. An inline <style> only needs one if you
|
|
10
|
+
// tighten style-src, since the default policy allows inline style outright.)
|
|
10
11
|
//
|
|
11
12
|
// Distinct from error.ts (a nested, per-segment boundary that renders a body
|
|
12
13
|
// fragment the framework wraps) and from global-not-found.ts (the unmatched-URL
|
|
@@ -17,8 +18,10 @@ export default function GlobalError({ error }: { error: Error }) {
|
|
|
17
18
|
const message = process.env.NODE_ENV === 'production'
|
|
18
19
|
? 'Something went wrong. Please try again.'
|
|
19
20
|
: error?.message || 'Unknown error';
|
|
20
|
-
// cspNonce() is '' with CSP off (the default), so this is safe as-is
|
|
21
|
-
// opt-in
|
|
21
|
+
// cspNonce() is '' with CSP off (the default), so this is safe as-is. Under the
|
|
22
|
+
// default opt-in policy the inline <style> is allowed by 'unsafe-inline' rather
|
|
23
|
+
// than by this nonce, which is stamped so the page still works if you tighten
|
|
24
|
+
// style-src to a nonce source.
|
|
22
25
|
return html`<!doctype html>
|
|
23
26
|
<html lang="en">
|
|
24
27
|
<head>
|
|
@@ -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
|
|
@@ -5,15 +5,31 @@ import { users } from '#db/schema.server.ts';
|
|
|
5
5
|
import { hash } from '../password.server.ts';
|
|
6
6
|
import { signIn } from '../auth.server.ts';
|
|
7
7
|
|
|
8
|
-
//
|
|
9
|
-
//
|
|
10
|
-
//
|
|
11
|
-
//
|
|
12
|
-
//
|
|
13
|
-
//
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
8
|
+
// The action the signup <form> is bound to. It takes the FormData directly:
|
|
9
|
+
// that is what a form-bound action always receives, on the JS path and the
|
|
10
|
+
// no-JS path alike, so validation lives HERE rather than in a per-page adapter.
|
|
11
|
+
//
|
|
12
|
+
// A validation failure returns fieldErrors + values, which re-renders the SAME
|
|
13
|
+
// page at 422 with the messages and the user's typed input preserved. On
|
|
14
|
+
// success it signs the new user in: signIn returns a 302 Response carrying the
|
|
15
|
+
// session cookie, and an action may return a Response, which the framework
|
|
16
|
+
// honors verbatim. signIn lives in the server-only auth module, imported here
|
|
17
|
+
// server-to-server, so it never reaches the browser.
|
|
18
|
+
export async function signup(formData: FormData) {
|
|
19
|
+
const name = String(formData.get('name') || '').trim();
|
|
20
|
+
const email = String(formData.get('email') || '').trim();
|
|
21
|
+
const password = String(formData.get('password') || '');
|
|
22
|
+
const values = { name, email };
|
|
23
|
+
const fieldErrors: Record<string, string> = {};
|
|
24
|
+
if (!name) fieldErrors.name = 'Name is required';
|
|
25
|
+
if (!email.includes('@')) fieldErrors.email = 'Enter a valid email';
|
|
26
|
+
if (password.length < 8) fieldErrors.password = 'At least 8 characters';
|
|
27
|
+
if (Object.keys(fieldErrors).length) return { success: false as const, fieldErrors, values, status: 422 };
|
|
28
|
+
|
|
29
|
+
const exists = await db.query.users.findFirst({ where: { email }, columns: { id: true } });
|
|
30
|
+
if (exists) {
|
|
31
|
+
return { success: false as const, fieldErrors: { email: 'Email already registered' }, values, status: 409 };
|
|
32
|
+
}
|
|
33
|
+
await db.insert(users).values({ name, email, passwordHash: await hash(password) });
|
|
34
|
+
return signIn('credentials', { email, password }, { redirectTo: '/features/auth/dashboard' });
|
|
19
35
|
}
|