@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
|
@@ -1,21 +1,32 @@
|
|
|
1
|
-
// 'use server' so the page's action can call it (server-side, a direct call) and
|
|
2
|
-
// it never crashes the browser module that imports it (the client gets a safe
|
|
3
|
-
// RPC stub, not the node:fs code). getFileStore() is the pluggable storage
|
|
4
|
-
// singleton: a local diskStore rooted at <cwd>/.webjs/uploads by default
|
|
5
|
-
// (gitignored), swappable for S3/R2/GCS with one setFileStore() call at boot.
|
|
6
|
-
// generateKey() mints a collision-free, traversal-safe key preserving a
|
|
7
|
-
// whitelisted extension.
|
|
8
1
|
'use server';
|
|
2
|
+
|
|
3
|
+
// The directive comes FIRST, ahead of this header: the framework reads it from
|
|
4
|
+
// the file's first few lines, so a long comment block above it would push it
|
|
5
|
+
// out of range and the file would not register as an action at all.
|
|
6
|
+
//
|
|
7
|
+
// The action the upload <form> is bound to. 'use server' means it never
|
|
8
|
+
// crashes the browser module that imports it (the client gets a safe RPC stub,
|
|
9
|
+
// not the node:fs code). getFileStore() is the pluggable storage singleton: a
|
|
10
|
+
// local diskStore rooted at <cwd>/.webjs/uploads by default (gitignored),
|
|
11
|
+
// swappable for S3/R2/GCS with one setFileStore() call at boot. generateKey()
|
|
12
|
+
// mints a collision-free, traversal-safe key preserving a whitelisted
|
|
13
|
+
// extension.
|
|
14
|
+
//
|
|
15
|
+
// It takes the FormData, which is what a form-bound action always receives, and
|
|
16
|
+
// pulls the File out of it. The framework emits the multipart enctype the
|
|
17
|
+
// upload needs, so a bound form carries a file with no extra attribute.
|
|
9
18
|
import { getFileStore, generateKey } from '@webjsdev/server';
|
|
10
19
|
// Importing the config module runs setFileStore(diskStore(...)) once at load,
|
|
11
20
|
// so uploads land in the configured store. See ../store.server.ts.
|
|
12
21
|
import '../store.server.ts';
|
|
13
22
|
|
|
14
|
-
export async function storeUpload(
|
|
23
|
+
export async function storeUpload(formData: FormData) {
|
|
24
|
+
const file = formData.get('file');
|
|
15
25
|
if (!(file instanceof File) || file.size === 0) {
|
|
16
|
-
return { success: false as const, error: '
|
|
26
|
+
return { success: false as const, error: 'Choose a file to upload.' };
|
|
17
27
|
}
|
|
18
28
|
const key = generateKey(file.name);
|
|
19
29
|
const { size, contentType } = await getFileStore().put(key, file, { contentType: file.type });
|
|
20
|
-
|
|
30
|
+
const q = new URLSearchParams({ key, name: file.name, size: String(size) });
|
|
31
|
+
return { success: true as const, redirect: '/features/file-storage?' + q.toString(), data: { key, name: file.name, size, contentType } };
|
|
21
32
|
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
'use server';
|
|
2
|
+
|
|
3
|
+
// The action a <form action=${sendMessage}> submits to. It receives the
|
|
4
|
+
// FormData directly: a form-bound action always does, on the JS path and the
|
|
5
|
+
// no-JS path alike.
|
|
6
|
+
//
|
|
7
|
+
// Return a FAILURE to re-render the SAME page at 422 with the result on
|
|
8
|
+
// `actionData` (so the fields repopulate), or a SUCCESS with a same-site
|
|
9
|
+
// `redirect` for a 303 Post-Redirect-Get.
|
|
10
|
+
//
|
|
11
|
+
// FOOTGUN: to redirect on success, RETURN `{ success: true, redirect: '/path' }`
|
|
12
|
+
// (a 303 See Other, so the browser follows with a GET). Do NOT THROW `redirect()`
|
|
13
|
+
// from a form action, that is a 307 which PRESERVES the POST method and body, so
|
|
14
|
+
// the browser re-POSTs to the target and re-runs the mutation (a duplicate
|
|
15
|
+
// write). Throw `redirect()` only from a page render / GET context.
|
|
16
|
+
export interface Result {
|
|
17
|
+
success: boolean;
|
|
18
|
+
fieldErrors?: Record<string, string>;
|
|
19
|
+
values?: Record<string, string>;
|
|
20
|
+
redirect?: string;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export async function sendMessage(formData: FormData): Promise<Result> {
|
|
24
|
+
const name = String(formData.get('name') ?? '').trim();
|
|
25
|
+
const email = String(formData.get('email') ?? '').trim();
|
|
26
|
+
const message = String(formData.get('message') ?? '').trim();
|
|
27
|
+
const fieldErrors: Record<string, string> = {};
|
|
28
|
+
if (!name) fieldErrors.name = 'Your name is required.';
|
|
29
|
+
if (!/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(email)) fieldErrors.email = 'A valid email is required.';
|
|
30
|
+
if (message.length < 5) fieldErrors.message = 'Message must be at least 5 characters.';
|
|
31
|
+
if (Object.keys(fieldErrors).length) return { success: false, fieldErrors, values: { name, email, message } };
|
|
32
|
+
// A real app would persist / email here. We just confirm.
|
|
33
|
+
return { success: true, redirect: '/features/forms?sent=1' };
|
|
34
|
+
}
|
|
@@ -29,7 +29,7 @@ export const FEATURE_GROUPS: NavGroup[] = [
|
|
|
29
29
|
items: [
|
|
30
30
|
{ href: '/features/server-actions', title: 'Server actions', blurb: 'A use-server RPC action next to a server-only .server.ts utility, plus the HTTP-verb config exports that make a read a cached GET.' },
|
|
31
31
|
{ href: '/features/route-handler', title: 'Route handlers', blurb: 'A server-only route.ts HTTP endpoint returning JSON, the WebJs equivalent of a Next route handler.' },
|
|
32
|
-
{ href: '/features/forms', title: 'Forms', blurb: 'A no-JS progressive-enhancement form
|
|
32
|
+
{ href: '/features/forms', title: 'Forms', blurb: 'A no-JS progressive-enhancement form bound to a server action, with server-side validation errors.' },
|
|
33
33
|
{ href: '/features/optimistic-ui', title: 'Optimistic UI', blurb: 'The imperative optimistic(signal, value, action) flip: instant update, automatic rollback on failure.' },
|
|
34
34
|
],
|
|
35
35
|
},
|
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
import { test } from 'node:test';
|
|
14
14
|
import assert from 'node:assert/strict';
|
|
15
15
|
import { createRequestHandler, buildRouteTable, matchPage, matchApi, invokeActionForTest } from '@webjsdev/server';
|
|
16
|
-
import {
|
|
16
|
+
import { submitForm, loginAndGetCookies } from '@webjsdev/server/testing';
|
|
17
17
|
|
|
18
18
|
const appDir = process.cwd();
|
|
19
19
|
process.env.AUTH_SECRET ||= 'test-secret-at-least-32-characters-long!!';
|
|
@@ -54,10 +54,11 @@ test('an authenticated greet reads the caller off the session via actionContext(
|
|
|
54
54
|
// migrated (run db:generate + db:migrate) rather than fail misleadingly.
|
|
55
55
|
const email = `greet+${Date.now()}@example.com`;
|
|
56
56
|
const password = 'password123';
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
57
|
+
// `submitForm` renders the signup page and reuses the identity the server put
|
|
58
|
+
// in the form's hidden `__webjs_action` field, which is what tells the
|
|
59
|
+
// dispatcher which action to run; a POST without it is answered 405.
|
|
60
|
+
const signupRes = await submitForm(app.handle, '/features/auth/signup', {
|
|
61
|
+
name: 'Ada', email, password,
|
|
61
62
|
});
|
|
62
63
|
if (signupRes.status !== 302) { t.skip('app deps/db not ready; run db:generate + db:migrate'); return; }
|
|
63
64
|
const { cookies } = await loginAndGetCookies(app.handle, { email, password });
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
'use server';
|
|
2
|
+
|
|
3
|
+
import { createTodo } from './create-todo.server.ts';
|
|
4
|
+
import { toggleTodo } from './toggle-todo.server.ts';
|
|
5
|
+
import { deleteTodo } from './delete-todo.server.ts';
|
|
6
|
+
|
|
7
|
+
// The no-JS write path for the todo forms. Every <form> in <todo-app> binds
|
|
8
|
+
// THIS action, and the submit button's own `name="intent"` says which mutation
|
|
9
|
+
// to run, which is how one form serves several buttons.
|
|
10
|
+
//
|
|
11
|
+
// Why an intent dispatcher here rather than binding each button to its own
|
|
12
|
+
// action: this form carries the todo's `id` on a hidden input and needs the
|
|
13
|
+
// SAME id for whichever mutation runs, so one action reading both fields is the
|
|
14
|
+
// simpler shape. When the buttons need no shared payload, bind each one
|
|
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
|
|
20
|
+
// <input type="submit"> the identity would occupy `value`, which is also that
|
|
21
|
+
// control's visible label), and a bound submitter cannot carry its own
|
|
22
|
+
// `name`/`value`, which is exactly the channel `name="intent"` uses below.
|
|
23
|
+
//
|
|
24
|
+
// With JS the component intercepts the submit and calls the underlying action
|
|
25
|
+
// directly for the optimistic path, so this runs only with JS off.
|
|
26
|
+
export async function submitTodo(formData: FormData) {
|
|
27
|
+
const intent = String(formData.get('intent') ?? '');
|
|
28
|
+
const id = String(formData.get('id') ?? '');
|
|
29
|
+
if (intent === 'create') return createTodo({ title: String(formData.get('title') ?? '') });
|
|
30
|
+
if (intent === 'toggle') return toggleTodo({ id });
|
|
31
|
+
if (intent === 'delete') return deleteTodo({ id });
|
|
32
|
+
return { success: false as const, error: 'Unknown action.', status: 400 };
|
|
33
|
+
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
// The interactive surface. Demonstrates: the WebComponent factory + reactive
|
|
2
2
|
// prop, the DECLARATIVE optimistic() API (instant update, auto-rollback), and
|
|
3
|
-
// progressive enhancement (each mutation is a <form>
|
|
3
|
+
// progressive enhancement (each mutation is a <form action=${submitTodo}> bound to a server action,
|
|
4
4
|
// intercepted by JS for the optimistic path). All interactivity lives in a
|
|
5
5
|
// component; a page/layout cannot be interactive in its own markup.
|
|
6
6
|
//
|
|
@@ -17,6 +17,7 @@ import { cn } from '#lib/utils/cn.ts';
|
|
|
17
17
|
import { createTodo } from '../actions/create-todo.server.ts';
|
|
18
18
|
import { toggleTodo } from '../actions/toggle-todo.server.ts';
|
|
19
19
|
import { deleteTodo } from '../actions/delete-todo.server.ts';
|
|
20
|
+
import { submitTodo } from '../actions/submit-todo.server.ts';
|
|
20
21
|
import type { Todo } from '../types.ts';
|
|
21
22
|
|
|
22
23
|
type Op =
|
|
@@ -95,9 +96,9 @@ export class TodoApp extends WebComponent({
|
|
|
95
96
|
</div>
|
|
96
97
|
</header>
|
|
97
98
|
|
|
98
|
-
<!-- Add: a real <form> so it works with JS
|
|
99
|
-
with JS, @submit intercepts and runs the optimistic path. -->
|
|
100
|
-
<form
|
|
99
|
+
<!-- Add: a real <form> bound to the server action, so it works with JS
|
|
100
|
+
off; with JS, @submit intercepts and runs the optimistic path. -->
|
|
101
|
+
<form action=${submitTodo} @submit=${(e: SubmitEvent) => this.add(e)}
|
|
101
102
|
class="${cardClass()} flex items-center gap-2 p-2 pl-4 shadow-[0_1px_0_0_color-mix(in_oklch,var(--foreground)_5%,transparent)]">
|
|
102
103
|
<input type="hidden" name="intent" value="create" />
|
|
103
104
|
<input name="title" required maxlength="280" autocomplete="off" placeholder="What needs doing?"
|
|
@@ -109,7 +110,9 @@ export class TodoApp extends WebComponent({
|
|
|
109
110
|
<ul class="list-none m-0 p-0 grid gap-2">
|
|
110
111
|
${list.length ? list.map((todo) => html`
|
|
111
112
|
<li>
|
|
112
|
-
|
|
113
|
+
<!-- One form, two submit buttons: each carries its own
|
|
114
|
+
name="intent", which the bound action dispatches on. -->
|
|
115
|
+
<form action=${submitTodo}
|
|
113
116
|
class="group flex items-center gap-3 px-3 py-2.5 rounded-xl bg-card border border-border transition-colors hover:border-border-strong ${todo.pending ? 'opacity-55' : ''}">
|
|
114
117
|
<input type="hidden" name="id" value=${todo.id} />
|
|
115
118
|
<!-- Toggle is a submit button (degrades to a form POST no-JS); with JS
|
|
@@ -1,12 +1,17 @@
|
|
|
1
|
-
//
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
1
|
+
// The row shape is DERIVED from the schema, never re-declared: rename a column
|
|
2
|
+
// in db/schema.server.ts and every consumer of this type is a compile error
|
|
3
|
+
// instead of a silent `undefined` at runtime.
|
|
4
|
+
//
|
|
5
|
+
// `import type` is what makes that safe here. This type is imported by the
|
|
6
|
+
// browser-shipped <todo-app> component, and a VALUE import from a
|
|
7
|
+
// `db/*.server.ts` file would pin the component to a server module and crash it
|
|
8
|
+
// at load (webjs check's no-server-import-in-browser-module flags it). A
|
|
9
|
+
// type-only import is erased by the TypeScript stripper before it can reach the
|
|
10
|
+
// browser, so it is exempt from that rule and costs the client nothing.
|
|
11
|
+
import type { todos } from '#db/schema.server.ts';
|
|
12
|
+
|
|
13
|
+
type TodoRow = typeof todos.$inferSelect;
|
|
14
|
+
|
|
15
|
+
export interface Todo extends TodoRow {
|
|
11
16
|
pending?: boolean; // client-only: true while an optimistic create is in flight
|
|
12
17
|
}
|
|
@@ -4,7 +4,7 @@ import { fileURLToPath } from 'node:url';
|
|
|
4
4
|
import { dirname, resolve } from 'node:path';
|
|
5
5
|
|
|
6
6
|
import { createRequestHandler } from '@webjsdev/server';
|
|
7
|
-
import { testRequest, loginAndGetCookies, withSessionCookie } from '@webjsdev/server/testing';
|
|
7
|
+
import { testRequest, submitForm, loginAndGetCookies, withSessionCookie } from '@webjsdev/server/testing';
|
|
8
8
|
|
|
9
9
|
const appDir = resolve(dirname(fileURLToPath(import.meta.url)), '..', '..');
|
|
10
10
|
|
|
@@ -14,7 +14,11 @@ const appDir = resolve(dirname(fileURLToPath(import.meta.url)), '..', '..');
|
|
|
14
14
|
// we detect that at the RESPONSE level (a 5xx on the dashboard) and SKIP with a
|
|
15
15
|
// clear message rather than report a misleading failure. After the db is set up
|
|
16
16
|
// every assertion runs for real.
|
|
17
|
-
|
|
17
|
+
// The SAME path `.env.example` and `drizzle.config.ts` use, so `db:migrate`
|
|
18
|
+
// prepares the database this test connects to. Pointing somewhere else made the
|
|
19
|
+
// skip below permanent: it told you to run `db:migrate`, and running it
|
|
20
|
+
// migrated a different file.
|
|
21
|
+
process.env.DATABASE_URL ||= 'file:./db/dev.db';
|
|
18
22
|
process.env.AUTH_SECRET ||= 'test-secret-at-least-32-characters-long!!';
|
|
19
23
|
|
|
20
24
|
function makeHandler() {
|
|
@@ -47,24 +51,35 @@ test('signup -> login -> dashboard renders for the authenticated user', async (t
|
|
|
47
51
|
const email = `harness+${Date.now()}@example.com`;
|
|
48
52
|
const password = 'password123';
|
|
49
53
|
|
|
50
|
-
// Real signup through the
|
|
51
|
-
|
|
54
|
+
// Real signup through the bound server action (the no-JS form write-path).
|
|
55
|
+
// `submitForm` renders the page and reuses the identity the server put in the
|
|
56
|
+
// form's hidden field, exactly as a browser with JS off submits it; a POST
|
|
57
|
+
// without that field is not a form submission and is answered 405.
|
|
58
|
+
// Only the REQUEST is guarded: an unmigrated table makes the action throw, and
|
|
59
|
+
// that is the one condition worth skipping for. The assertions below stay
|
|
60
|
+
// outside the try on purpose, so a genuine regression fails loudly instead of
|
|
61
|
+
// being caught and reported as a database that was never set up.
|
|
62
|
+
let signupRes: Response | null = null;
|
|
52
63
|
try {
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
headers: { 'content-type': 'application/x-www-form-urlencoded' },
|
|
56
|
-
body: new URLSearchParams({ name: 'Harness', email, password }).toString(),
|
|
64
|
+
signupRes = await submitForm(app.handle, '/features/auth/signup', {
|
|
65
|
+
name: 'Harness', email, password,
|
|
57
66
|
});
|
|
58
|
-
// Success auto-logs-in and 302s to the dashboard (carrying the session
|
|
59
|
-
// cookie); a 422 means validation failed. Either way the action ran.
|
|
60
|
-
assert.ok([302, 422].includes(signupRes.status), 'signup action ran');
|
|
61
|
-
if (signupRes.status === 302) assert.equal(signupRes.headers.get('location'), '/features/auth/dashboard', 'signup lands on the dashboard');
|
|
62
|
-
if (signupRes.status !== 302) canSignup = false;
|
|
63
67
|
} catch {
|
|
64
|
-
|
|
65
|
-
|
|
68
|
+
signupRes = null;
|
|
69
|
+
}
|
|
70
|
+
if (!signupRes || signupRes.status >= 500) {
|
|
71
|
+
t.skip('no migrated DB; run db:migrate to enable the full flow');
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
// Success auto-logs-in and 302s to the dashboard (carrying the session
|
|
75
|
+
// cookie); a 422 means validation failed. Either way the action ran.
|
|
76
|
+
assert.ok([302, 422].includes(signupRes.status), 'signup action ran');
|
|
77
|
+
if (signupRes.status === 302) {
|
|
78
|
+
assert.equal(signupRes.headers.get('location'), '/features/auth/dashboard', 'signup lands on the dashboard');
|
|
79
|
+
} else {
|
|
80
|
+
t.skip('signup was rejected by validation; run db:migrate to enable the full flow');
|
|
81
|
+
return;
|
|
66
82
|
}
|
|
67
|
-
if (!canSignup) { t.skip('no migrated DB; run db:migrate to enable the full flow'); return; }
|
|
68
83
|
|
|
69
84
|
// Real login captures the genuine signed session cookie.
|
|
70
85
|
const { cookies } = await loginAndGetCookies(app.handle, { email, password });
|
|
@@ -47,6 +47,10 @@ cross-origin access use the `cors()` middleware from `@webjsdev/server`; with
|
|
|
47
47
|
Run each of these and fix what it reports, in order:
|
|
48
48
|
|
|
49
49
|
- `npm run check` (correctness: no browser-import or boundary violation).
|
|
50
|
+
- `npm run doctor` (project health; CI runs it too). It fails on whatever
|
|
51
|
+
`package.json` `webjs.doctor.gate` marks `error`, plus the two hard toolchain
|
|
52
|
+
checks that are fatal with no gate entry, `NODE_VERSION` and
|
|
53
|
+
`TSCONFIG_ERASABLE`.
|
|
50
54
|
- `npm run typecheck` (zero type errors).
|
|
51
55
|
- `npm test` (unit tests for the endpoints and modules you built).
|
|
52
56
|
|
|
@@ -63,5 +67,6 @@ npm run start # production server
|
|
|
63
67
|
npm test # unit + browser tests
|
|
64
68
|
npm run typecheck
|
|
65
69
|
npm run check # correctness checks
|
|
70
|
+
npm run doctor # project health (severity per check: webjs.doctor.gate)
|
|
66
71
|
npm run db:generate && npm run db:migrate
|
|
67
72
|
```
|
|
@@ -98,6 +98,10 @@ accessor). Use the shorthand for primitives
|
|
|
98
98
|
Run each of these and fix what it reports, in order:
|
|
99
99
|
|
|
100
100
|
- `npm run check` (correctness: no browser-import or boundary violation).
|
|
101
|
+
- `npm run doctor` (project health; CI runs it too). It fails on whatever
|
|
102
|
+
`package.json` `webjs.doctor.gate` marks `error`, plus the two hard toolchain
|
|
103
|
+
checks that are fatal with no gate entry, `NODE_VERSION` and
|
|
104
|
+
`TSCONFIG_ERASABLE`.
|
|
101
105
|
- `npm run typecheck` (zero type errors).
|
|
102
106
|
- `npm test` (unit and browser tests for the features you built).
|
|
103
107
|
- `npm run css:build` (compile Tailwind).
|
|
@@ -118,6 +122,7 @@ npm test # unit + browser tests
|
|
|
118
122
|
npm run typecheck
|
|
119
123
|
npm run css:build # compile Tailwind
|
|
120
124
|
npm run check # correctness checks
|
|
125
|
+
npm run doctor # project health (severity per check: webjs.doctor.gate)
|
|
121
126
|
npx webjsdev ui add <name> # copy a ui primitive into components/ui/
|
|
122
127
|
npx webjsdev ui view <name> # inspect a primitive's exact signature
|
|
123
128
|
npm run db:generate && npm run db:migrate
|
|
@@ -79,9 +79,9 @@ const galleryPaths = [
|
|
|
79
79
|
// query), pruned with the rest of the card.
|
|
80
80
|
const galleryModules = [
|
|
81
81
|
'async-render', 'auth', 'broadcast', 'caching', 'client-router', 'components',
|
|
82
|
-
'directives', 'file-storage', 'frames', 'gallery', 'optimistic-ui',
|
|
83
|
-
'route-handler', 'server-actions', 'sessions', 'stream',
|
|
84
|
-
'todo', 'websockets',
|
|
82
|
+
'directives', 'file-storage', 'forms', 'frames', 'gallery', 'optimistic-ui',
|
|
83
|
+
'rate-limit', 'route-handler', 'server-actions', 'sessions', 'stream',
|
|
84
|
+
'streaming', 'suspense', 'todo', 'websockets',
|
|
85
85
|
].map((m) => `modules/${m}`);
|
|
86
86
|
|
|
87
87
|
let removed = 0;
|
|
@@ -174,6 +174,7 @@ export default function Home() {
|
|
|
174
174
|
|
|
175
175
|
function MINIMAL_LAYOUT() {
|
|
176
176
|
return `import { html, asset } from '@webjsdev/core';
|
|
177
|
+
import type { LayoutProps } from '@webjsdev/core';
|
|
177
178
|
|
|
178
179
|
/**
|
|
179
180
|
* Root layout: the ONLY file that writes the document shell. It links the
|
|
@@ -191,7 +192,7 @@ function MINIMAL_LAYOUT() {
|
|
|
191
192
|
// hand-written <link> in the template body is ignored by browsers).
|
|
192
193
|
export const metadata = { icons: '/public/favicon.svg' };
|
|
193
194
|
|
|
194
|
-
export default function RootLayout({ children }:
|
|
195
|
+
export default function RootLayout({ children }: LayoutProps) {
|
|
195
196
|
return html\`
|
|
196
197
|
<meta name="color-scheme" content="light dark">
|
|
197
198
|
<!-- asset() content-hashes the url in production, so a deploy that changes
|
|
@@ -12,9 +12,26 @@
|
|
|
12
12
|
import { test, describe, before, after } from 'node:test';
|
|
13
13
|
import assert from 'node:assert/strict';
|
|
14
14
|
import { spawn } from 'node:child_process';
|
|
15
|
+
import type { ChildProcess } from 'node:child_process';
|
|
15
16
|
import { createServer } from 'node:net';
|
|
16
17
|
|
|
17
|
-
|
|
18
|
+
// puppeteer-core is an optional dev dependency, so `import type { Browser,
|
|
19
|
+
// Page } from 'puppeteer-core'` does not resolve until you install it. These
|
|
20
|
+
// minimal structural types keep the file typed in the meantime; swap them for
|
|
21
|
+
// the real imports once puppeteer-core is in package.json. Reaching for `any`
|
|
22
|
+
// here would silently un-type every call below.
|
|
23
|
+
type Page = {
|
|
24
|
+
// goto resolves an HTTPResponse this file never reads, and modelling that
|
|
25
|
+
// type would mean re-declaring puppeteer's. Returning void is the honest
|
|
26
|
+
// narrow shape for the surface actually used.
|
|
27
|
+
goto(url: string, opts?: { waitUntil?: string; timeout?: number }): Promise<void>;
|
|
28
|
+
title(): Promise<string>;
|
|
29
|
+
on(event: string, handler: (e: Error) => void): void;
|
|
30
|
+
removeAllListeners(event: string): void;
|
|
31
|
+
};
|
|
32
|
+
type Browser = { newPage(): Promise<Page>; close(): Promise<void> };
|
|
33
|
+
|
|
34
|
+
let browser: Browser, page: Page, serverProcess: ChildProcess, baseUrl: string;
|
|
18
35
|
|
|
19
36
|
function freePort(): Promise<number> {
|
|
20
37
|
return new Promise((resolve, reject) => {
|