@invisibleloop/pulse 0.17.1 → 0.17.3
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/.claude/commands/build-page.md +1 -1
- package/.claude/commands/verify.md +6 -4
- package/.claude/pulse-checklist.md +10 -0
- package/CLAUDE.md +56 -2
- package/docs/.claude/pulse-checklist.md +10 -0
- package/docs/public/pulse-ui.css +8 -2
- package/docs/server.js +2 -0
- package/docs/src/lib/nav.js +2 -0
- package/docs/src/pages/actions.js +2 -0
- package/docs/src/pages/error-pages.js +98 -0
- package/docs/src/pages/forms.js +128 -0
- package/docs/src/pages/guard.js +1 -0
- package/docs/src/pages/routing.js +7 -9
- package/docs/src/pages/server-api.js +2 -1
- package/examples/.claude/pulse-checklist.md +10 -0
- package/examples/public/pulse-ui.css +52 -2
- package/package.json +2 -2
- package/public/.pulse-ui-version +1 -1
- package/public/pulse-ui.css +8 -2
- package/scripts/build.js +1 -204
- package/scripts/css-purge.js +241 -0
- package/scripts/css-purge.test.js +94 -0
- package/src/agent/checklist.md +10 -0
- package/src/agent/commands/verify.md +6 -4
- package/src/agent/guide-routing.md +21 -0
- package/src/agent/guide-server.md +34 -0
- package/src/agent/guide-spec.md +25 -0
- package/src/agent/guide-styles.md +3 -1
- package/src/agent/skills/build-page/SKILL.md +1 -1
- package/src/agent/skills/verify/SKILL.md +2 -2
- package/src/agent/workflow.md +15 -3
- package/src/mcp/mcp.test.js +55 -1
- package/src/mcp/server.js +82 -5
- package/src/runtime/ssr.js +30 -7
- package/src/server/index.js +119 -16
- package/src/server/server.test.js +200 -0
- package/src/spec/schema.js +20 -3
- package/src/spec/schema.test.js +33 -0
- package/src/ui/drop-cap.js +1 -1
- package/src/ui/gallery.js +5 -1
- package/src/ui/ui.test.js +34 -0
- package/types/schema.d.ts +31 -2
- package/types/server.d.ts +7 -0
|
@@ -50,7 +50,7 @@ Check for:
|
|
|
50
50
|
- Layout looks correct
|
|
51
51
|
- No console errors via `mcp__chrome-devtools__list_console_messages`
|
|
52
52
|
|
|
53
|
-
Then ask the user: "Happy with the design and layout, or would you like any changes before I run tests and Lighthouse?" — if your host provides a question tool (e.g. AskUserQuestion), offer the choices `["Yes, looks good — run tests and Lighthouse", "I'd like some changes first"]`;
|
|
53
|
+
Then **call `pulse_await_approval`** and ask the user: "Happy with the design and layout, or would you like any changes before I run tests and Lighthouse?" — if your host provides a question tool (e.g. AskUserQuestion), offer the choices `["Yes, looks good — run tests and Lighthouse", "I'd like some changes first"]`; otherwise ask in plain prose. Always call the tool before asking, whichever way you ask: in some hosts (including Claude Code) even a question tool ends the turn and fires the Stop hooks; the marker is harmless if the turn doesn't end. If a `VERIFY REQUIRED` stop-hook block fires while your question is unanswered, that is not permission to continue — call `pulse_await_approval`, re-ask, and end your turn.
|
|
54
54
|
|
|
55
55
|
**Do not proceed to step 7 until the user explicitly confirms.** If they want changes, make them and return to step 6.
|
|
56
56
|
|
|
@@ -15,7 +15,7 @@ If $ARGUMENTS is provided, use it as the file path or route. Otherwise, identify
|
|
|
15
15
|
If this is a new page build (came through `pulse_intake` or `build-page`):
|
|
16
16
|
|
|
17
17
|
1. Take a screenshot and describe what you see to the user.
|
|
18
|
-
2.
|
|
18
|
+
2. **Call `pulse_await_approval` first**, then ask the user: "Happy with the design and layout, or would you like any changes before I run Lighthouse?" — if your host provides a question tool (e.g. AskUserQuestion), offer the choices `["Yes, looks good — proceed", "I'd like some changes first"]`; otherwise ask in plain prose. Always call the tool before asking, whichever way you ask: in some hosts (including Claude Code) even a question tool ends the turn and fires the Stop hooks. The marker is harmless if the turn doesn't end — it is consumed when the user replies.
|
|
19
19
|
3. **Do not proceed to step 3 until the user explicitly confirms.** If they want changes, stop here, make the changes, then restart from step 2. Lighthouse takes ~90 seconds — do not waste it on a design the user hasn't approved. If a `VERIFY REQUIRED` stop-hook block fires while your question is unanswered, that is not permission to continue — call `pulse_await_approval`, re-ask, and end your turn.
|
|
20
20
|
|
|
21
21
|
### 3. Validate the spec
|
|
@@ -46,7 +46,7 @@ The browser should still be on `http://localhost:3001/` from step 6. Run `mcp__c
|
|
|
46
46
|
|
|
47
47
|
**Same pass bar: Accessibility, Best Practices, and SEO must all be 100.** Fix any failures and restart from step 3.
|
|
48
48
|
|
|
49
|
-
|
|
49
|
+
**Stay on the production server (port 3001)** — steps 8 and 9 use it too. Do not call `pulse_restart_server` yet.
|
|
50
50
|
|
|
51
51
|
### 8. Mobile layout check
|
|
52
52
|
|
|
@@ -54,7 +54,7 @@ Emulate a mobile viewport and take a screenshot to catch wrapping, overflow, and
|
|
|
54
54
|
|
|
55
55
|
```
|
|
56
56
|
mcp__chrome-devtools__emulate viewport: "390x844x2,mobile,touch"
|
|
57
|
-
mcp__chrome-devtools__navigate_page url: "http://localhost:
|
|
57
|
+
mcp__chrome-devtools__navigate_page url: "http://localhost:3001/"
|
|
58
58
|
mcp__chrome-devtools__take_screenshot
|
|
59
59
|
```
|
|
60
60
|
|
|
@@ -72,7 +72,9 @@ mcp__chrome-devtools__emulate viewport: "1440x900x1"
|
|
|
72
72
|
|
|
73
73
|
### 9. Performance
|
|
74
74
|
|
|
75
|
-
Navigate to the
|
|
75
|
+
**Run the trace against the production server, not dev.** Navigate to `http://localhost:3001/<route>` first — if the production server is no longer running, run `pulse_build` again. Tracing the dev server (port 3000) reports misleading LCP/CLS. Then run `mcp__chrome-devtools__performance_start_trace` with `reload: true` and `autoStop: true`. Report LCP and CLS. Flag any LCP insight that suggests a fixable problem (render-blocking resources, large image load delay, etc.).
|
|
76
|
+
|
|
77
|
+
After the trace, call `pulse_restart_server` to return to the dev server.
|
|
76
78
|
|
|
77
79
|
### 10. Console errors
|
|
78
80
|
|
|
@@ -20,6 +20,8 @@ Before finishing any spec, verify every point below. Fix anything that fails.
|
|
|
20
20
|
|
|
21
21
|
- **Do not set `hydrate` in specs.** It is auto-derived by the framework from the URL entry passed to `createServer`. Specs with `mutations`, `actions`, or `persist` are hydrated automatically. Purely server-rendered specs get zero JavaScript — no configuration needed.
|
|
22
22
|
|
|
23
|
+
- **The default theme is DARK.** When `meta.theme` is unset, the page renders with the dark palette. If the design calls for a light page, set `meta.theme: 'light'` when you **first write the spec** — do not assume light and discover dark at the screenshot, which costs an edit → restart → re-approval cycle. Declare the theme in your plan/build brief, and ask the user if the brief doesn't say.
|
|
24
|
+
|
|
23
25
|
- **Always restart the server and reload the browser after every file edit — before checking the result.** The dev server restarts on file changes but the browser tab stays stale. The required sequence is: `pulse_restart_server` → `navigate_page`. Never attempt to debug a visual problem without doing both steps first.
|
|
24
26
|
|
|
25
27
|
### Components first — or creative override
|
|
@@ -111,6 +113,14 @@ Before finishing any spec, verify every point below. Fix anything that fails.
|
|
|
111
113
|
### Security
|
|
112
114
|
|
|
113
115
|
- Any value from user input (URL params, form fields, external APIs) interpolated into view HTML must be escaped.
|
|
116
|
+
- **Every `<form method="POST">` on a `submit` page includes `${server.csrf}`.** CSRF is enforced — a form without the hidden token field gets 403 on submit. Never set `csrf: false` to "fix" a 403; that disables the protection. `csrf: false` is only for endpoints with their own authentication (e.g. signed webhooks).
|
|
117
|
+
- **`submit` returns `{ redirect }` after a successful mutation** (POST-redirect-GET). Rendering success directly from the POST means browser refresh resubmits the form.
|
|
118
|
+
|
|
119
|
+
### Server-side forms & error pages
|
|
120
|
+
|
|
121
|
+
- **Forms that must work without JavaScript use `spec.submit`** + `<form method="POST">` with `${server.csrf}` inside. Hydrated `data-action` on the same form is the enhancement, not the requirement.
|
|
122
|
+
- On a validation re-render, show `server.form?.errors` and echo `server.form?.values` back into the inputs via `value=` — failed validation must not wipe what the user typed.
|
|
123
|
+
- **Every site has a custom 404 page** — a spec with `route: '*'`, using the shared `layout()` so it carries the site's nav and footer. Without it, bad URLs show the framework's unbranded default.
|
|
114
124
|
|
|
115
125
|
### Tests
|
|
116
126
|
|
package/CLAUDE.md
CHANGED
|
@@ -41,12 +41,15 @@ A spec is a plain JS object. Every property is optional except `route` and `view
|
|
|
41
41
|
|
|
42
42
|
```js
|
|
43
43
|
export const mySpec = {
|
|
44
|
-
route: '/path', // URL pattern, supports :params
|
|
44
|
+
route: '/path', // URL pattern, supports :params. Use '*' for the custom
|
|
45
|
+
// not-found page — rendered with status 404 when no route matches
|
|
45
46
|
|
|
46
47
|
meta: {
|
|
47
48
|
title: 'Page Title',
|
|
48
49
|
description: 'Meta description',
|
|
49
50
|
styles: ['/pulse.css'],
|
|
51
|
+
theme: 'light', // 'light' | 'dark' — ⚠ default is DARK when unset; decide at write time
|
|
52
|
+
vibe: 'minimal', // optional preset: radius, weight, spacing
|
|
50
53
|
ogTitle: '...',
|
|
51
54
|
ogImage: '...',
|
|
52
55
|
},
|
|
@@ -119,6 +122,19 @@ export const mySpec = {
|
|
|
119
122
|
}
|
|
120
123
|
},
|
|
121
124
|
|
|
125
|
+
// Server-side form handling — POST to this route, no client JS required.
|
|
126
|
+
// CSRF protection is automatic: include ${server.csrf} inside the <form>.
|
|
127
|
+
// { redirect: '/path' } → 303 POST-redirect-GET
|
|
128
|
+
// anything else → page re-renders; return value appears as server.form
|
|
129
|
+
// (optional status field sets the response status, e.g. 422)
|
|
130
|
+
submit: async (ctx) => {
|
|
131
|
+
const data = await ctx.formData()
|
|
132
|
+
if (!data?.email) return { errors: { email: 'Required' }, values: data ?? {} }
|
|
133
|
+
await saveSignup(data)
|
|
134
|
+
return { redirect: '/thanks' }
|
|
135
|
+
},
|
|
136
|
+
// csrf: false, // opt out ONLY for endpoints with their own auth (e.g. signed webhooks)
|
|
137
|
+
|
|
122
138
|
// Streaming SSR — split view into shell (instant) + deferred segments
|
|
123
139
|
stream: {
|
|
124
140
|
shell: ['header', 'nav'],
|
|
@@ -139,6 +155,42 @@ export const mySpec = {
|
|
|
139
155
|
export default mySpec // required for hydration imports
|
|
140
156
|
```
|
|
141
157
|
|
|
158
|
+
## Server-Side Forms (no client JS)
|
|
159
|
+
|
|
160
|
+
A spec with `submit` accepts POST on its route — the form works with JavaScript disabled. **Always include `${server.csrf}` inside the form** (CSRF is enforced; POSTs without the token get 403):
|
|
161
|
+
|
|
162
|
+
```js
|
|
163
|
+
view: (state, server) => `
|
|
164
|
+
<main id="main-content">
|
|
165
|
+
${server.form?.errors?.email ? alert({ variant: 'error', message: server.form.errors.email }) : ''}
|
|
166
|
+
<form method="POST">
|
|
167
|
+
${server.csrf}
|
|
168
|
+
${input({ label: 'Email', name: 'email', type: 'email', required: true, value: server.form?.values?.email ?? '' })}
|
|
169
|
+
${button({ label: 'Sign up', type: 'submit' })}
|
|
170
|
+
</form>
|
|
171
|
+
</main>`,
|
|
172
|
+
```
|
|
173
|
+
|
|
174
|
+
- `server.form` is the return value of `submit()` on a re-render (validation errors, submitted values) — `undefined` on plain GET.
|
|
175
|
+
- **Progressive enhancement:** the same form can also carry `data-action="signup"` — hydrated visitors get the async action (no page reload), no-JS visitors fall back to the POST. Echo submitted values back into inputs via `value=` so a failed validation doesn't wipe the form.
|
|
176
|
+
- Always `return { redirect }` after a successful mutation (POST-redirect-GET) — never render success directly from the POST, or refresh resubmits the form.
|
|
177
|
+
- Multi-instance deployments: set a stable `secret` in `createServer` so CSRF tokens validate across instances.
|
|
178
|
+
|
|
179
|
+
## Custom 404 Page
|
|
180
|
+
|
|
181
|
+
Create a spec with `route: '*'` — it renders through the normal pipeline (layout, styles, hydration, validation) with status 404 whenever no route matches:
|
|
182
|
+
|
|
183
|
+
```js
|
|
184
|
+
// src/pages/not-found.js
|
|
185
|
+
export default {
|
|
186
|
+
route: '*',
|
|
187
|
+
meta: { title: 'Page not found', styles: ['/pulse-ui.css', '/theme.css', '/app.css'] },
|
|
188
|
+
view: () => `<main id="main-content"><h1>Page not found</h1><p><a href="/">Back home</a></p></main>`,
|
|
189
|
+
}
|
|
190
|
+
```
|
|
191
|
+
|
|
192
|
+
Without a `'*'` spec, the framework's plain default 404 is served. 500s are customised via `createServer`'s `onError` option.
|
|
193
|
+
|
|
142
194
|
## HTML Event Binding
|
|
143
195
|
|
|
144
196
|
Pulse binds to DOM attributes — no JSX, no templates.
|
|
@@ -228,6 +280,8 @@ await createServer(
|
|
|
228
280
|
// spec.serverTimeout overrides per-page
|
|
229
281
|
shutdownTimeout: 30000, // ms to wait for in-flight requests before force-exit on SIGTERM/SIGINT
|
|
230
282
|
healthCheck: '/healthz', // built-in health endpoint path, or false to disable
|
|
283
|
+
secret: process.env.PULSE_SECRET, // HMAC secret for CSRF tokens — set a stable value
|
|
284
|
+
// when running multiple instances (default: random per boot)
|
|
231
285
|
csp: { // extra sources merged into the framework's default CSP
|
|
232
286
|
'style-src': ['https://fonts.googleapis.com'],
|
|
233
287
|
'font-src': ['https://fonts.gstatic.com'],
|
|
@@ -468,7 +522,7 @@ For any **new page, landing page, or branded site**, run this sequence first. Do
|
|
|
468
522
|
3. pulse_intent(description) → archetype + scaffold + guide list
|
|
469
523
|
```
|
|
470
524
|
|
|
471
|
-
**Before calling `pulse_intake`**, gather the required information by asking the user — one question at a time, in plain prose, no bullet lists. You need at minimum: the product name, what it does (pitch), and its key features. **Always ask first whether they have design inspiration** (a site they love, a screenshot, a mood board — dropped into `public/intake/` or pasted as a URL), and check `public/intake/` for images already there. Then ask for palette, vibe, and anti-style if not mentioned.
|
|
525
|
+
**Before calling `pulse_intake`**, gather the required information by asking the user — one question at a time, in plain prose, no bullet lists. You need at minimum: the product name, what it does (pitch), and its key features. **Always ask first whether they have design inspiration** (a site they love, a screenshot, a mood board — dropped into `public/intake/` or pasted as a URL), and check `public/intake/` for images already there. Then ask for palette, **theme (light or dark — Pulse defaults to dark when unset)**, vibe, and anti-style if not mentioned.
|
|
472
526
|
|
|
473
527
|
**`pulse_sketch` is mandatory** for `playful`, `bold`, `brutalist`, `retro`, or `neon` vibes — those vibes fight the default centred-hero layout. Skip it only for `corporate` or `minimal` when no structural preference has been stated.
|
|
474
528
|
|
|
@@ -20,6 +20,8 @@ Before finishing any spec, verify every point below. Fix anything that fails.
|
|
|
20
20
|
|
|
21
21
|
- **Do not set `hydrate` in specs.** It is auto-derived by the framework from the URL entry passed to `createServer`. Specs with `mutations`, `actions`, or `persist` are hydrated automatically. Purely server-rendered specs get zero JavaScript — no configuration needed.
|
|
22
22
|
|
|
23
|
+
- **The default theme is DARK.** When `meta.theme` is unset, the page renders with the dark palette. If the design calls for a light page, set `meta.theme: 'light'` when you **first write the spec** — do not assume light and discover dark at the screenshot, which costs an edit → restart → re-approval cycle. Declare the theme in your plan/build brief, and ask the user if the brief doesn't say.
|
|
24
|
+
|
|
23
25
|
- **Always restart the server and reload the browser after every file edit — before checking the result.** The dev server restarts on file changes but the browser tab stays stale. The required sequence is: `pulse_restart_server` → `navigate_page`. Never attempt to debug a visual problem without doing both steps first.
|
|
24
26
|
|
|
25
27
|
### Components first — or creative override
|
|
@@ -111,6 +113,14 @@ Before finishing any spec, verify every point below. Fix anything that fails.
|
|
|
111
113
|
### Security
|
|
112
114
|
|
|
113
115
|
- Any value from user input (URL params, form fields, external APIs) interpolated into view HTML must be escaped.
|
|
116
|
+
- **Every `<form method="POST">` on a `submit` page includes `${server.csrf}`.** CSRF is enforced — a form without the hidden token field gets 403 on submit. Never set `csrf: false` to "fix" a 403; that disables the protection. `csrf: false` is only for endpoints with their own authentication (e.g. signed webhooks).
|
|
117
|
+
- **`submit` returns `{ redirect }` after a successful mutation** (POST-redirect-GET). Rendering success directly from the POST means browser refresh resubmits the form.
|
|
118
|
+
|
|
119
|
+
### Server-side forms & error pages
|
|
120
|
+
|
|
121
|
+
- **Forms that must work without JavaScript use `spec.submit`** + `<form method="POST">` with `${server.csrf}` inside. Hydrated `data-action` on the same form is the enhancement, not the requirement.
|
|
122
|
+
- On a validation re-render, show `server.form?.errors` and echo `server.form?.values` back into the inputs via `value=` — failed validation must not wipe what the user typed.
|
|
123
|
+
- **Every site has a custom 404 page** — a spec with `route: '*'`, using the shared `layout()` so it carries the site's nav and footer. Without it, bad URLs show the framework's unbranded default.
|
|
114
124
|
|
|
115
125
|
### Tests
|
|
116
126
|
|
package/docs/public/pulse-ui.css
CHANGED
|
@@ -4031,8 +4031,14 @@ fieldset[disabled] .ui-rating-stars .ui-rating-star:hover ~ .ui-rating-star { co
|
|
|
4031
4031
|
}
|
|
4032
4032
|
|
|
4033
4033
|
.ui-drop-cap-letter {
|
|
4034
|
-
|
|
4035
|
-
|
|
4034
|
+
/* line-height ~cap height keeps the float box hugging the glyph so the cap
|
|
4035
|
+
top-aligns with the first text line — a relative line-height makes serif
|
|
4036
|
+
caps float below the opening line. Component sets font-size inline. */
|
|
4037
|
+
float: left;
|
|
4038
|
+
font-weight: 700;
|
|
4039
|
+
line-height: 0.82;
|
|
4040
|
+
margin-right: 0.1em;
|
|
4041
|
+
color: var(--ui-text);
|
|
4036
4042
|
}
|
|
4037
4043
|
|
|
4038
4044
|
/* Colophon — minimal footer for editorial pages */
|
package/docs/server.js
CHANGED
|
@@ -45,9 +45,11 @@ await createServer(
|
|
|
45
45
|
new URL('./src/pages/markdown.js', import.meta.url),
|
|
46
46
|
new URL('./src/pages/store.js', import.meta.url),
|
|
47
47
|
new URL('./src/pages/routing.js', import.meta.url),
|
|
48
|
+
new URL('./src/pages/error-pages.js', import.meta.url),
|
|
48
49
|
new URL('./src/pages/streaming.js', import.meta.url),
|
|
49
50
|
new URL('./src/pages/caching.js', import.meta.url),
|
|
50
51
|
new URL('./src/pages/guard.js', import.meta.url),
|
|
52
|
+
new URL('./src/pages/forms.js', import.meta.url),
|
|
51
53
|
new URL('./src/pages/raw-responses.js', import.meta.url),
|
|
52
54
|
new URL('./src/pages/server-api.js', import.meta.url),
|
|
53
55
|
new URL('./src/pages/extending.js', import.meta.url),
|
package/docs/src/lib/nav.js
CHANGED
|
@@ -41,9 +41,11 @@ export const NAV = [
|
|
|
41
41
|
{ label: 'Markdown', href: '/markdown' },
|
|
42
42
|
{ label: 'Global Store', href: '/store' },
|
|
43
43
|
{ label: 'Routing', href: '/routing' },
|
|
44
|
+
{ label: 'Error Pages', href: '/error-pages' },
|
|
44
45
|
{ label: 'Streaming SSR', href: '/streaming' },
|
|
45
46
|
{ label: 'Caching', href: '/caching' },
|
|
46
47
|
{ label: 'Guard', href: '/guard' },
|
|
48
|
+
{ label: 'Forms', href: '/forms' },
|
|
47
49
|
{ label: 'Raw Responses', href: '/raw-responses' },
|
|
48
50
|
{ label: 'Server API', href: '/server-api' },
|
|
49
51
|
{ label: 'Extending Pulse', href: '/extending' },
|
|
@@ -20,6 +20,8 @@ export default {
|
|
|
20
20
|
${h1('Actions')}
|
|
21
21
|
${lead('Actions handle async operations with an enforced lifecycle. The order of steps — capture inputs, validate, run, succeed or fail — is fixed. Skipping validation or running async work before showing a loading state is not possible within the action structure.')}
|
|
22
22
|
|
|
23
|
+
${callout('tip', 'Actions run client-side and need hydration. For forms that must also work with JavaScript disabled, pair the action with a server-side <a href="/forms"><code>submit</code> handler</a> — the same form serves both paths.')}
|
|
24
|
+
|
|
23
25
|
${section('lifecycle', 'The action lifecycle')}
|
|
24
26
|
<p>When a form with <code>data-action</code> is submitted, Pulse runs the action through a fixed sequence of steps:</p>
|
|
25
27
|
${codeBlock(highlight(`onStart(state, formData)
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import { renderLayout, h1, lead, section, codeBlock, callout, table } from '../lib/layout.js'
|
|
2
|
+
import { prevNext } from '../lib/nav.js'
|
|
3
|
+
import { highlight } from '../lib/highlight.js'
|
|
4
|
+
|
|
5
|
+
const { prev, next } = prevNext('/error-pages')
|
|
6
|
+
|
|
7
|
+
export default {
|
|
8
|
+
route: '/error-pages',
|
|
9
|
+
meta: {
|
|
10
|
+
title: 'Error Pages — Pulse Docs',
|
|
11
|
+
description: 'Custom 404 pages in Pulse. A spec with route "*" renders through the normal pipeline with status 404 whenever no route matches.',
|
|
12
|
+
styles: ['/docs.css'],
|
|
13
|
+
},
|
|
14
|
+
view: () => renderLayout({
|
|
15
|
+
currentHref: '/error-pages',
|
|
16
|
+
prev,
|
|
17
|
+
next,
|
|
18
|
+
content: `
|
|
19
|
+
${h1('Error Pages')}
|
|
20
|
+
${lead('A spec with <code>route: \'*\'</code> is your site\'s custom 404 page. It renders through the normal pipeline — layout, styles, components, hydration — with status <strong>404</strong> whenever no route matches. Without one, visitors to a bad URL get the framework\'s unbranded default.')}
|
|
21
|
+
|
|
22
|
+
${section('not-found', 'The custom 404 page')}
|
|
23
|
+
<p>Create a spec like any other page, with <code>'*'</code> as the route. Use the shared <code>layout()</code> so the page carries the site's nav and footer:</p>
|
|
24
|
+
${codeBlock(highlight(`import { layout } from '../components/layout.js'
|
|
25
|
+
|
|
26
|
+
export default {
|
|
27
|
+
route: '*',
|
|
28
|
+
meta: {
|
|
29
|
+
title: 'Page not found',
|
|
30
|
+
styles: ['/pulse-ui.css', '/theme.css', '/app.css'],
|
|
31
|
+
},
|
|
32
|
+
view: () => layout({
|
|
33
|
+
content: \`
|
|
34
|
+
<h1>Page not found</h1>
|
|
35
|
+
<p>That page doesn't exist. <a href="/">Back home</a></p>
|
|
36
|
+
\`,
|
|
37
|
+
}),
|
|
38
|
+
}`, 'js', ), 'src/pages/not-found.js')}
|
|
39
|
+
<p>The filename doesn't matter — <code>not-found.js</code> is just a readable convention. The <code>route: '*'</code> property is what registers it as the fallback.</p>
|
|
40
|
+
|
|
41
|
+
${callout('tip', 'Every site should have one. The framework\'s default 404 is a bare unstyled page with no navigation — a dead end for visitors and a missed signal for search engines crawling old URLs.')}
|
|
42
|
+
|
|
43
|
+
${section('behaviour', 'How it behaves')}
|
|
44
|
+
${table(
|
|
45
|
+
['Aspect', 'Behaviour'],
|
|
46
|
+
[
|
|
47
|
+
['Status code', 'Always <strong>404</strong> — correct for SEO; crawlers de-index dead URLs'],
|
|
48
|
+
['Pipeline', 'Full normal render: <code>meta</code>, styles, components, hydration if it has mutations/actions'],
|
|
49
|
+
['Validation', 'Validated at startup like every spec — a broken 404 page fails fast'],
|
|
50
|
+
['Matching', 'Only when no other route matches. It is a fallback, not a route — it never shadows real pages'],
|
|
51
|
+
['Client-side navigation', 'A bad link falls back to a full page load, which renders the custom 404'],
|
|
52
|
+
['Caching', '404 responses are never stored in the in-process page cache'],
|
|
53
|
+
]
|
|
54
|
+
)}
|
|
55
|
+
|
|
56
|
+
${section('useful-404', 'Making the 404 useful')}
|
|
57
|
+
<p>The best 404 pages help the visitor recover. Since the page is a normal spec, it can use any component — search, popular links, even server data:</p>
|
|
58
|
+
${codeBlock(highlight(`export default {
|
|
59
|
+
route: '*',
|
|
60
|
+
meta: { title: 'Page not found', styles: ['/pulse-ui.css', '/theme.css', '/app.css'] },
|
|
61
|
+
server: {
|
|
62
|
+
popular: async () => getPopularPages(), // suggest somewhere to go
|
|
63
|
+
},
|
|
64
|
+
view: (state, server) => layout({
|
|
65
|
+
content: \`
|
|
66
|
+
<h1>Page not found</h1>
|
|
67
|
+
<p>Try one of these instead:</p>
|
|
68
|
+
\${list({ items: (server.popular ?? []).map(p => \`<a href="\${p.href}">\${p.title}</a>\`) })}
|
|
69
|
+
\`,
|
|
70
|
+
}),
|
|
71
|
+
}`, 'js'))}
|
|
72
|
+
|
|
73
|
+
${section('server-errors', '500s and other errors')}
|
|
74
|
+
<p>Server errors are handled by <code>createServer</code>'s <code>onError</code> option, not a spec — when a render throws, the spec pipeline itself may be the thing that is broken, so the handler works at the HTTP level:</p>
|
|
75
|
+
${codeBlock(highlight(`await createServer(pages, {
|
|
76
|
+
onError: (err, req, res) => {
|
|
77
|
+
console.error(err)
|
|
78
|
+
if (res.headersSent) return
|
|
79
|
+
res.writeHead(500, { 'Content-Type': 'text/html; charset=utf-8' })
|
|
80
|
+
res.end('<h1>Something went wrong</h1><p><a href="/">Back home</a></p>')
|
|
81
|
+
},
|
|
82
|
+
})`, 'js'))}
|
|
83
|
+
<p>For errors thrown inside a <em>view</em> specifically, prefer <code>onViewError</code> on the spec — it returns a 200 with fallback HTML instead of a 500, keeping the page alive when one piece of data is malformed.</p>
|
|
84
|
+
|
|
85
|
+
${callout('note', 'Three layers, three jobs: <code>route: \'*\'</code> for URLs that don\'t exist, <code>onViewError</code> for views that throw on bad data, <code>onError</code> for everything else that goes wrong server-side.')}
|
|
86
|
+
|
|
87
|
+
${section('reference', 'Reference')}
|
|
88
|
+
${table(
|
|
89
|
+
['Property', 'Where', 'Notes'],
|
|
90
|
+
[
|
|
91
|
+
['<code>route: \'*\'</code>', 'spec', 'Custom not-found page — rendered with status 404 when no route matches'],
|
|
92
|
+
['<code>onViewError</code>', 'spec', 'Fallback HTML when the view throws — returns 200'],
|
|
93
|
+
['<code>onError</code>', '<code>createServer</code> option', 'HTTP-level handler for unhandled server errors (500s)'],
|
|
94
|
+
]
|
|
95
|
+
)}
|
|
96
|
+
`,
|
|
97
|
+
}),
|
|
98
|
+
}
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import { renderLayout, h1, lead, section, sub, codeBlock, callout, table } from '../lib/layout.js'
|
|
2
|
+
import { prevNext } from '../lib/nav.js'
|
|
3
|
+
import { highlight } from '../lib/highlight.js'
|
|
4
|
+
|
|
5
|
+
const { prev, next } = prevNext('/forms')
|
|
6
|
+
|
|
7
|
+
export default {
|
|
8
|
+
route: '/forms',
|
|
9
|
+
meta: {
|
|
10
|
+
title: 'Forms — Pulse Docs',
|
|
11
|
+
description: 'Server-side form handling in Pulse. spec.submit processes POST requests without client JavaScript, with automatic CSRF protection and POST-redirect-GET.',
|
|
12
|
+
styles: ['/docs.css'],
|
|
13
|
+
},
|
|
14
|
+
view: () => renderLayout({
|
|
15
|
+
currentHref: '/forms',
|
|
16
|
+
prev,
|
|
17
|
+
next,
|
|
18
|
+
content: `
|
|
19
|
+
${h1('Forms')}
|
|
20
|
+
${lead('A spec with a <code>submit</code> handler accepts POST requests on its own route — the form works with JavaScript disabled. CSRF protection is automatic, and successful submissions follow POST-redirect-GET. Client-side <code>actions</code> become an enhancement, not a requirement.')}
|
|
21
|
+
|
|
22
|
+
${section('basics', 'Basic usage')}
|
|
23
|
+
<p>Add a <code>submit</code> function to the spec and render a plain <code><form method="POST"></code>. The route accepts POST automatically — no <code>methods</code> declaration needed.</p>
|
|
24
|
+
${codeBlock(highlight(`export default {
|
|
25
|
+
route: '/contact',
|
|
26
|
+
|
|
27
|
+
submit: async (ctx) => {
|
|
28
|
+
const data = await ctx.formData()
|
|
29
|
+
if (!data?.email) return { errors: { email: 'Email is required' }, values: data ?? {} }
|
|
30
|
+
await sendMessage(data)
|
|
31
|
+
return { redirect: '/contact?sent=1' } // 303 POST-redirect-GET
|
|
32
|
+
},
|
|
33
|
+
|
|
34
|
+
view: (state, server) => \`
|
|
35
|
+
<main id="main-content">
|
|
36
|
+
\${server.form?.errors?.email ? \`<p class="error">\${escHtml(server.form.errors.email)}</p>\` : ''}
|
|
37
|
+
<form method="POST">
|
|
38
|
+
\${server.csrf}
|
|
39
|
+
\${input({ label: 'Email', name: 'email', type: 'email', required: true,
|
|
40
|
+
value: server.form?.values?.email ?? '' })}
|
|
41
|
+
\${button({ label: 'Send', type: 'submit' })}
|
|
42
|
+
</form>
|
|
43
|
+
</main>
|
|
44
|
+
\`,
|
|
45
|
+
}`, 'js'))}
|
|
46
|
+
<p>The flow: the browser POSTs the form, <code>submit(ctx)</code> runs server-side, and either redirects (success) or re-renders the page with the returned value available to the view as <code>server.form</code> (validation failure).</p>
|
|
47
|
+
|
|
48
|
+
${callout('warning', '<strong><code>\${server.csrf}</code> must be inside every POST form.</strong> It renders the hidden CSRF token input — submissions without a valid token are rejected with <strong>403</strong> before <code>submit</code> runs.')}
|
|
49
|
+
|
|
50
|
+
${section('return-values', 'Return values')}
|
|
51
|
+
${table(
|
|
52
|
+
['Return', 'Response', 'Use for'],
|
|
53
|
+
[
|
|
54
|
+
['<code>{ redirect: \'/path\' }</code>', '<strong>303 See Other</strong>', 'Successful submissions — POST-redirect-GET means refresh never resubmits'],
|
|
55
|
+
['<code>{ errors, values, … }</code>', 'Page re-renders (200), value exposed as <code>server.form</code>', 'Validation failures — show errors, echo values back into inputs'],
|
|
56
|
+
['<code>{ status: 422, errors, … }</code>', 'Same re-render with status <strong>422</strong>', 'Validation failures where the status code matters (API clients, tests)'],
|
|
57
|
+
['<code>undefined</code>', 'Page re-renders (200), <code>server.form</code> is <code>{}</code>', 'Fire-and-forget side effects'],
|
|
58
|
+
]
|
|
59
|
+
)}
|
|
60
|
+
<p><strong>Always redirect after a successful mutation.</strong> Rendering a success message directly from the POST means a browser refresh resubmits the form. Redirect to a URL that shows the confirmation instead (e.g. <code>/contact?sent=1</code>).</p>
|
|
61
|
+
|
|
62
|
+
${section('server-form', 'server.form — errors and echoed values')}
|
|
63
|
+
<p>On a re-render, whatever <code>submit</code> returned is available to the view as <code>server.form</code>. On a plain GET it is <code>undefined</code> — always use optional chaining.</p>
|
|
64
|
+
${codeBlock(highlight(`// In submit — return errors AND the submitted values:
|
|
65
|
+
return { errors: { email: 'Invalid email' }, values: data }
|
|
66
|
+
|
|
67
|
+
// In the view — show the error, echo the value back so the form isn't wiped:
|
|
68
|
+
\${server.form?.errors?.email ? \`<p class="error">\${escHtml(server.form.errors.email)}</p>\` : ''}
|
|
69
|
+
\${input({ name: 'email', label: 'Email', value: server.form?.values?.email ?? '' })}`, 'js'))}
|
|
70
|
+
${callout('warning', 'Escape user-submitted values before interpolating them into HTML — <code>server.form.values</code> is raw user input. Component props like <code>input({ value })</code> are auto-escaped; manual interpolation needs <code>escHtml()</code>.')}
|
|
71
|
+
|
|
72
|
+
${section('csrf', 'CSRF protection')}
|
|
73
|
+
<p>Every <code>submit</code> spec gets CSRF protection automatically — there is nothing to configure. Under the hood Pulse uses an HMAC double-submit pattern:</p>
|
|
74
|
+
${table(
|
|
75
|
+
['Piece', 'Where it lives', 'What it does'],
|
|
76
|
+
[
|
|
77
|
+
['Random id', '<code>pulse_csrf</code> cookie (HttpOnly, SameSite=Lax)', 'Set on the first GET of the form page'],
|
|
78
|
+
['Token', 'Hidden <code>_csrf</code> input via <code>\${server.csrf}</code>', '<code>HMAC-SHA256(secret, id)</code> — embedded into the form server-side'],
|
|
79
|
+
['Verification', 'On every POST, before <code>submit</code> runs', 'Recomputes the HMAC from the cookie and compares in constant time — mismatch is 403'],
|
|
80
|
+
]
|
|
81
|
+
)}
|
|
82
|
+
<p>An attacker on another origin can cause the cookie to be sent, but cannot read it or compute the HMAC — so they cannot forge the pair.</p>
|
|
83
|
+
|
|
84
|
+
${sub('multi-instance', 'Multiple server instances')}
|
|
85
|
+
<p>The HMAC secret defaults to a random value per server boot. Behind a load balancer, pin it so tokens issued by one instance validate on another:</p>
|
|
86
|
+
${codeBlock(highlight(`await createServer(pages, {
|
|
87
|
+
secret: process.env.PULSE_SECRET, // stable across instances and restarts
|
|
88
|
+
})`, 'js'))}
|
|
89
|
+
|
|
90
|
+
${sub('opt-out', 'Opting out')}
|
|
91
|
+
<p>Set <code>csrf: false</code> on the spec to disable protection — only for endpoints with their own authentication, such as signed webhooks:</p>
|
|
92
|
+
${codeBlock(highlight(`export default {
|
|
93
|
+
route: '/webhooks/stripe',
|
|
94
|
+
csrf: false, // Stripe signs its requests — verify the signature instead
|
|
95
|
+
submit: async (ctx) => {
|
|
96
|
+
verifyStripeSignature(ctx.headers['stripe-signature'], await ctx.text())
|
|
97
|
+
// ...
|
|
98
|
+
},
|
|
99
|
+
view: () => '<main id="main-content">ok</main>',
|
|
100
|
+
}`, 'js'))}
|
|
101
|
+
${callout('warning', 'Never set <code>csrf: false</code> to "fix" a 403. The 403 means the form is missing <code>\${server.csrf}</code> — add the field, don\'t remove the protection.')}
|
|
102
|
+
|
|
103
|
+
${section('progressive-enhancement', 'Progressive enhancement')}
|
|
104
|
+
<p>The same form can serve both worlds: <code>method="POST"</code> is the no-JS fallback, <code>data-action</code> is the hydrated path. With JavaScript enabled the runtime intercepts the submit and runs the async action (no page reload); without it the browser POSTs to <code>submit</code>.</p>
|
|
105
|
+
${codeBlock(highlight(`<form method="POST" data-action="send">
|
|
106
|
+
\${server.csrf}
|
|
107
|
+
\${input({ label: 'Email', name: 'email', type: 'email', required: true })}
|
|
108
|
+
\${button({ label: 'Send', type: 'submit' })}
|
|
109
|
+
</form>`, 'html'))}
|
|
110
|
+
<p>Keep the two paths consistent: <code>submit</code> and the action's <code>run</code> should call the same underlying function so validation and side effects cannot drift apart.</p>
|
|
111
|
+
|
|
112
|
+
${section('caching', 'Interaction with caching')}
|
|
113
|
+
<p>Pages with <code>submit</code> are automatically excluded from the in-process page cache — the CSRF token is per-visitor, and a cached page would serve one visitor's token to everyone. POST responses are never cached.</p>
|
|
114
|
+
|
|
115
|
+
${section('reference', 'Reference')}
|
|
116
|
+
${table(
|
|
117
|
+
['Property', 'Type', 'Notes'],
|
|
118
|
+
[
|
|
119
|
+
['<code>submit</code>', '<code>async (ctx) => { redirect } | object | void</code>', 'Handles POST on the route. Guard runs first.'],
|
|
120
|
+
['<code>csrf</code>', '<code>boolean</code>', 'Default <code>true</code>. Set <code>false</code> only for self-authenticated endpoints.'],
|
|
121
|
+
['<code>server.csrf</code>', 'string (view)', 'Hidden <code>_csrf</code> input — required inside every POST form.'],
|
|
122
|
+
['<code>server.form</code>', 'object | undefined (view)', 'Return value of <code>submit</code> on a re-render; <code>undefined</code> on GET.'],
|
|
123
|
+
['<code>secret</code>', 'string (createServer option)', 'Stable HMAC secret for multi-instance deployments.'],
|
|
124
|
+
]
|
|
125
|
+
)}
|
|
126
|
+
`,
|
|
127
|
+
}),
|
|
128
|
+
}
|
package/docs/src/pages/guard.js
CHANGED
|
@@ -105,6 +105,7 @@ export default {
|
|
|
105
105
|
// return nothing to let a GET request proceed to the view
|
|
106
106
|
}`, 'js'))}
|
|
107
107
|
${callout('note', 'To use <code>guard</code> as a POST handler, the spec must declare <code>methods: [\'GET\', \'POST\']</code>. Without it, POST requests are rejected with 405 before guard runs.')}
|
|
108
|
+
${callout('tip', 'For HTML form submissions, prefer <a href="/forms"><code>spec.submit</code></a> — it accepts POST automatically, gets CSRF protection for free, and re-renders the page with validation errors. Guard-based POST handling is best for API-style endpoints.')}
|
|
108
109
|
|
|
109
110
|
${section('reference', 'Reference')}
|
|
110
111
|
${table(
|
|
@@ -80,15 +80,13 @@ server: {
|
|
|
80
80
|
}`, 'js'))}
|
|
81
81
|
|
|
82
82
|
${section('not-found', '404 handling')}
|
|
83
|
-
<p>If no spec matches the incoming request path, Pulse returns a 404
|
|
84
|
-
${codeBlock(highlight(`
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
}
|
|
91
|
-
})`, 'js'))}
|
|
83
|
+
<p>If no spec matches the incoming request path, Pulse returns a 404. To customise it, create a spec with <code>route: '*'</code> — it renders through the normal pipeline (layout, styles, components) with status 404:</p>
|
|
84
|
+
${codeBlock(highlight(`export default {
|
|
85
|
+
route: '*',
|
|
86
|
+
meta: { title: 'Page not found', styles: ['/pulse-ui.css', '/theme.css', '/app.css'] },
|
|
87
|
+
view: () => \`<main id="main-content"><h1>Page not found</h1><p><a href="/">Back home</a></p></main>\`,
|
|
88
|
+
}`, 'js'), 'src/pages/not-found.js')}
|
|
89
|
+
<p>See <a href="/error-pages">Error Pages</a> for the full behaviour, including 500 handling.</p>
|
|
92
90
|
|
|
93
91
|
${section('conventions', 'File naming conventions')}
|
|
94
92
|
<p>While Pulse does not auto-discover files, the recommended convention maps file names to routes:</p>
|
|
@@ -48,8 +48,9 @@ await createServer(entries, options)`, 'js'))}
|
|
|
48
48
|
['<code>shutdownTimeout</code>', '<code>number</code>', '<code>30000</code>', 'Milliseconds to wait for in-flight requests to finish during graceful shutdown before force-exiting. See <a href="#graceful-shutdown">Graceful shutdown</a>.'],
|
|
49
49
|
['<code>healthCheck</code>', '<code>string | false</code>', '<code>"/healthz"</code>', 'Path for the built-in health check endpoint. Returns <code>{ status: "ok", uptime }</code>. Set to <code>false</code> to disable. The endpoint bypasses <code>onRequest</code> so load balancers always get a response.'],
|
|
50
50
|
['<code>resolveBrand</code>', '<code>async (host) => any</code>', '<code>undefined</code>', 'Multi-brand support. Called once per host (cached 60s). Result is attached to <code>ctx.brand</code> and available in <code>guard</code>, <code>server</code>, and <code>meta</code> functions.'],
|
|
51
|
+
['<code>secret</code>', '<code>string</code>', 'random per boot', 'HMAC secret for CSRF tokens (<a href="/forms">Forms</a>). Set a stable value when running multiple instances so tokens issued by one validate on another.'],
|
|
51
52
|
['<code>onRequest</code>', '<code>function</code>', '<code>undefined</code>', 'Called on every request before routing. Return <code>false</code> to short-circuit Pulse handling.'],
|
|
52
|
-
['<code>onError</code>', '<code>function</code>', '<code>undefined</code>', 'Called on unhandled errors. Receives <code>(err, req, res)</code>.'],
|
|
53
|
+
['<code>onError</code>', '<code>function</code>', '<code>undefined</code>', 'Called on unhandled errors. Receives <code>(err, req, res)</code>. See <a href="/error-pages">Error Pages</a>.'],
|
|
53
54
|
]
|
|
54
55
|
)}
|
|
55
56
|
|
|
@@ -20,6 +20,8 @@ Before finishing any spec, verify every point below. Fix anything that fails.
|
|
|
20
20
|
|
|
21
21
|
- **Do not set `hydrate` in specs.** It is auto-derived by the framework from the URL entry passed to `createServer`. Specs with `mutations`, `actions`, or `persist` are hydrated automatically. Purely server-rendered specs get zero JavaScript — no configuration needed.
|
|
22
22
|
|
|
23
|
+
- **The default theme is DARK.** When `meta.theme` is unset, the page renders with the dark palette. If the design calls for a light page, set `meta.theme: 'light'` when you **first write the spec** — do not assume light and discover dark at the screenshot, which costs an edit → restart → re-approval cycle. Declare the theme in your plan/build brief, and ask the user if the brief doesn't say.
|
|
24
|
+
|
|
23
25
|
- **Always restart the server and reload the browser after every file edit — before checking the result.** The dev server restarts on file changes but the browser tab stays stale. The required sequence is: `pulse_restart_server` → `navigate_page`. Never attempt to debug a visual problem without doing both steps first.
|
|
24
26
|
|
|
25
27
|
### Components first — or creative override
|
|
@@ -111,6 +113,14 @@ Before finishing any spec, verify every point below. Fix anything that fails.
|
|
|
111
113
|
### Security
|
|
112
114
|
|
|
113
115
|
- Any value from user input (URL params, form fields, external APIs) interpolated into view HTML must be escaped.
|
|
116
|
+
- **Every `<form method="POST">` on a `submit` page includes `${server.csrf}`.** CSRF is enforced — a form without the hidden token field gets 403 on submit. Never set `csrf: false` to "fix" a 403; that disables the protection. `csrf: false` is only for endpoints with their own authentication (e.g. signed webhooks).
|
|
117
|
+
- **`submit` returns `{ redirect }` after a successful mutation** (POST-redirect-GET). Rendering success directly from the POST means browser refresh resubmits the form.
|
|
118
|
+
|
|
119
|
+
### Server-side forms & error pages
|
|
120
|
+
|
|
121
|
+
- **Forms that must work without JavaScript use `spec.submit`** + `<form method="POST">` with `${server.csrf}` inside. Hydrated `data-action` on the same form is the enhancement, not the requirement.
|
|
122
|
+
- On a validation re-render, show `server.form?.errors` and echo `server.form?.values` back into the inputs via `value=` — failed validation must not wipe what the user typed.
|
|
123
|
+
- **Every site has a custom 404 page** — a spec with `route: '*'`, using the shared `layout()` so it carries the site's nav and footer. Without it, bad URLs show the framework's unbranded default.
|
|
114
124
|
|
|
115
125
|
### Tests
|
|
116
126
|
|