@invisibleloop/pulse 0.16.0 → 0.17.0
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 +1 -1
- package/.claude/pulse-checklist.md +11 -11
- package/CLAUDE.md +13 -4
- package/docs/.claude/pulse-checklist.md +1 -1
- package/examples/.claude/pulse-checklist.md +6 -4
- package/package.json +1 -1
- package/public/.pulse-ui-version +1 -1
- package/src/agent/checklist.md +1 -1
- package/src/agent/commands/verify.md +62 -32
- package/src/agent/guide-design-gallery.md +7 -5
- package/src/agent/guide-design-references.md +2 -2
- package/src/agent/identity.md +21 -6
- package/src/agent/workflow.md +4 -3
- package/src/html.js +1 -0
- package/src/mcp/mcp.test.js +111 -0
- package/src/mcp/server.js +2 -2
- package/src/md/index.js +29 -2
- package/src/md/md.test.js +36 -0
- package/src/runtime/ssr.js +1 -0
- package/src/runtime/tt.js +8 -0
- package/src/server/index.js +87 -20
- package/src/server/server.test.js +106 -1
- package/src/spec/schema.js +10 -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
|
|
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"]`; otherwise ask in plain prose.
|
|
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. 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.
|
|
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.
|
|
20
20
|
|
|
21
21
|
### 3. Validate the spec
|
|
@@ -22,10 +22,10 @@ Before finishing any spec, verify every point below. Fix anything that fails.
|
|
|
22
22
|
|
|
23
23
|
- **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
24
|
|
|
25
|
-
### Components first
|
|
25
|
+
### Components first — or creative override
|
|
26
26
|
|
|
27
27
|
- **Before writing any HTML by hand, check `src/ui/index.js`.** There are 50+ components. Use `button`, `card`, `alert`, `input`, `spinner`, `badge`, `modal`, `nav`, `pagination`, `table`, etc. before writing equivalent HTML from scratch.
|
|
28
|
-
- **Do not reinvent component patterns.** These patterns have dedicated components — never write custom HTML for them:
|
|
28
|
+
- **Do not reinvent component patterns.** These patterns have dedicated components — never write custom HTML for them unless a creative override applies (see below):
|
|
29
29
|
- Hero sections → `hero({ title, subtitle, actions, image, ... })`
|
|
30
30
|
- Product/service cards → `card({ title, body, image, footer, ... })`
|
|
31
31
|
- Image + text two-column layouts → `media({ image, content, reverse })`
|
|
@@ -35,10 +35,16 @@ Before finishing any spec, verify every point below. Fix anything that fails.
|
|
|
35
35
|
- CTAs → `cta({ title, subtitle, actions })`
|
|
36
36
|
- Navigation → `nav({ logo, links, actions })`
|
|
37
37
|
- Footers → `footer({ logo, links, columns, legal, ... })`
|
|
38
|
-
|
|
39
|
-
If you find yourself writing `class="hero"` or `class="product-card"` or `class="testimonial"`, stop — import the component instead.
|
|
40
38
|
|
|
41
|
-
- **
|
|
39
|
+
- **Creative override — raw HTML throughout is permitted** when the design intent genuinely calls for it: a highly custom visual identity, an asymmetric or typographically-driven layout, a brutalist/editorial/retro/neon/paper vibe where components would constrain the expression, or a layout that requires features a component simply doesn't support (e.g. full-viewport height, custom gradient glows, clamp-scaled display type). This is a deliberate design decision, not a shortcut. When taking this path:
|
|
40
|
+
- **Declare it in a comment at the top of the spec** so the reviewer recognises it: `// component-free — creative override: <reason>`. This is the signal the review tool reads.
|
|
41
|
+
- Functional atoms (`button`, `input`, `badge`, `modal`) should still come from components unless there is a specific design reason not to.
|
|
42
|
+
- `/pulse-ui.css` is always required — it provides the token system your custom CSS will use.
|
|
43
|
+
- The quality gate replaces the component checklist: **Lighthouse 100 on Accessibility, Best Practices, and SEO — desktop and mobile — plus CLS 0.00 is the pass bar.** (Performance is reported but not gated; it varies with machine load.) A component-free page that passes every audit is correct. A component-heavy page that fails accessibility is not.
|
|
44
|
+
|
|
45
|
+
- **When reviewing**: before flagging a class name like `.hero` as a violation, first ask whether the `hero()` component could actually reproduce the layout. If the answer is no, it is a creative override — check Lighthouse instead of refactoring. Do not flatten a good custom layout into a generic component just to clear a pattern-match.
|
|
46
|
+
|
|
47
|
+
- **The review tool auto-detects creative override** from the spec comment. When detected, component pattern checks are shown as advisory (`⚡`) not as failures, and the Lighthouse gate is the stated pass bar.
|
|
42
48
|
|
|
43
49
|
- **Never write App Store or Google Play download buttons by hand.** Always use `appBadge({ store: 'apple', href })` and `appBadge({ store: 'google', href })`. Raw `<a>` tags with badge images are incorrect — `appBadge` renders the correct accessible, styled badge for each store.
|
|
44
50
|
|
|
@@ -132,12 +138,6 @@ Before finishing any spec, verify every point below. Fix anything that fails.
|
|
|
132
138
|
- **Call the same `md()` fetcher in both `meta` and `server`** — it caches on `ctx._mdCache` so the file is only read once per request. Do not create two separate fetchers for the same file.
|
|
133
139
|
- **Always add `onViewError` on dynamic markdown routes** (routes with `:param` segments loading `.md` files). A missing file throws `{ status: 404 }` which must be caught gracefully.
|
|
134
140
|
|
|
135
|
-
### Markdown
|
|
136
|
-
|
|
137
|
-
- **Use `md()` from `@invisibleloop/pulse/md` for `.md` file content.** Never read and parse markdown manually. Pass the result's `html` to `prose()`.
|
|
138
|
-
- **Call the same `md()` fetcher in both `meta` and `server`** — it caches on `ctx._mdCache` so the file is only read once per request. Do not create two separate fetchers for the same file.
|
|
139
|
-
- **Always add `onViewError` on dynamic markdown routes** (routes with `:param` segments loading `.md` files). A missing file throws `{ status: 404 }` which must be caught gracefully.
|
|
140
|
-
|
|
141
141
|
### View error handling
|
|
142
142
|
|
|
143
143
|
- **Define `onViewError` on any page where the view could throw due to bad or missing data.** Without it, a runtime view error returns a 500 on the server and shows a generic inline message on the client. With it, the server returns 200 with your fallback HTML instead of a 500, and the client renders your fallback:
|
package/CLAUDE.md
CHANGED
|
@@ -5,7 +5,7 @@ Pulse is a spec-first frontend framework. The spec is the source of truth. No co
|
|
|
5
5
|
## Commands
|
|
6
6
|
|
|
7
7
|
```bash
|
|
8
|
-
npm run dev # start dev server at http://localhost:3001
|
|
8
|
+
npm run dev # start dev server at http://localhost:3000 (pulse_build serves production on 3001)
|
|
9
9
|
npm test # run all 92 unit tests
|
|
10
10
|
npm run build # bundle for production → public/dist/
|
|
11
11
|
```
|
|
@@ -57,6 +57,12 @@ export const mySpec = {
|
|
|
57
57
|
// Timeout for all server fetchers on this page (ms). Overrides createServer fetcherTimeout.
|
|
58
58
|
serverTimeout: 5000,
|
|
59
59
|
|
|
60
|
+
// Query-string keys that change this page's output, for the in-process page cache.
|
|
61
|
+
// The cache IGNORES the query string by default — arbitrary ?n=… params cannot mint
|
|
62
|
+
// unbounded cache entries (memory-DoS guard). If the rendered output depends on a
|
|
63
|
+
// query param, list it here so distinct values get distinct cache entries.
|
|
64
|
+
cacheVary: ['q', 'page'],
|
|
65
|
+
|
|
60
66
|
// Global store keys this page subscribes to — appears in view's server arg
|
|
61
67
|
// Store mutations update all subscribed pages without a server round-trip
|
|
62
68
|
store: ['user', 'cart'],
|
|
@@ -458,7 +464,7 @@ For any **new page, landing page, or branded site**, run this sequence first. Do
|
|
|
458
464
|
3. pulse_intent(description) → archetype + scaffold + guide list
|
|
459
465
|
```
|
|
460
466
|
|
|
461
|
-
**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.
|
|
467
|
+
**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.
|
|
462
468
|
|
|
463
469
|
**`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.
|
|
464
470
|
|
|
@@ -470,13 +476,16 @@ Every build task follows this sequence. Each phase has a pass gate — do not ad
|
|
|
470
476
|
|
|
471
477
|
**Before calling any slow tool (`pulse_build`, `lighthouse_audit`), output a status message to the user first** — e.g. "Building for production — ~30 s…" or "Running Lighthouse desktop audit…". Never call a slow tool silently.
|
|
472
478
|
|
|
479
|
+
> **Canonical workflow:** `pulse://workflow` (`src/agent/workflow.md`) is the authoritative version, including complexity tiers (not every task needs all phases). The table below is the summary — if they ever disagree, the MCP resource wins.
|
|
480
|
+
|
|
473
481
|
| Phase | Action | Gate |
|
|
474
482
|
|---|---|---|
|
|
475
483
|
| 1. Understand | Read guides, call `pulse_list_structure` | — |
|
|
476
484
|
| 2. Plan | Present plan to user, wait for confirmation | User confirms |
|
|
477
485
|
| 3. Build | Write spec + related files | — |
|
|
478
486
|
| 4. Validate | `pulse_validate` — fix all errors + warnings | Clean output |
|
|
479
|
-
|
|
|
487
|
+
| 5a. Design approval *(new builds only)* | Screenshot → describe → ask the user | ⛔ User approves layout before any slow checks run (skip for edits/fixes) |
|
|
488
|
+
| 5b. Browser | `pulse_design_review` (if intake ran) → `pulse_layout_review` → `/verify` (Lighthouse desktop + mobile + `pulse_review`) | ⛔ Do not skip: all gates must pass + `.pulse-verified` stamp written |
|
|
480
489
|
| 6. Tests | Write tests, run them, fix failures | All pass |
|
|
481
490
|
| 7. Code Review | `pulse_review` — only after phases 4–6 pass | — |
|
|
482
491
|
| 8. Fix | Fix every review issue, re-run affected gates | All gates still pass |
|
|
@@ -487,7 +496,7 @@ Every build task follows this sequence. Each phase has a pass gate — do not ad
|
|
|
487
496
|
|
|
488
497
|
**The Code Review is always last.** Never invoke `pulse_review` before validate, Lighthouse, design review, and tests all pass.
|
|
489
498
|
|
|
490
|
-
The `/verify` command runs the browser check loop (phases 4–
|
|
499
|
+
The `/verify` command runs the browser check loop (phases 4–5b) automatically. Use it.
|
|
491
500
|
|
|
492
501
|
## Pulse vs React — Do Not Do These
|
|
493
502
|
|
|
@@ -40,7 +40,7 @@ Before finishing any spec, verify every point below. Fix anything that fails.
|
|
|
40
40
|
- **Declare it in a comment at the top of the spec** so the reviewer recognises it: `// component-free — creative override: <reason>`. This is the signal the review tool reads.
|
|
41
41
|
- Functional atoms (`button`, `input`, `badge`, `modal`) should still come from components unless there is a specific design reason not to.
|
|
42
42
|
- `/pulse-ui.css` is always required — it provides the token system your custom CSS will use.
|
|
43
|
-
- The quality gate replaces the component checklist: **100
|
|
43
|
+
- The quality gate replaces the component checklist: **Lighthouse 100 on Accessibility, Best Practices, and SEO — desktop and mobile — plus CLS 0.00 is the pass bar.** (Performance is reported but not gated; it varies with machine load.) A component-free page that passes every audit is correct. A component-heavy page that fails accessibility is not.
|
|
44
44
|
|
|
45
45
|
- **When reviewing**: before flagging a class name like `.hero` as a violation, first ask whether the `hero()` component could actually reproduce the layout. If the answer is no, it is a creative override — check Lighthouse instead of refactoring. Do not flatten a good custom layout into a generic component just to clear a pattern-match.
|
|
46
46
|
|
|
@@ -36,13 +36,15 @@ Before finishing any spec, verify every point below. Fix anything that fails.
|
|
|
36
36
|
- Navigation → `nav({ logo, links, actions })`
|
|
37
37
|
- Footers → `footer({ logo, links, columns, legal, ... })`
|
|
38
38
|
|
|
39
|
-
- **Creative override — raw HTML throughout is permitted** when the design intent genuinely calls for it: a highly custom visual identity, an asymmetric or typographically-driven layout, a brutalist/editorial/retro/neon/paper vibe where components would constrain the expression. This is a deliberate design decision, not a shortcut. When taking this path:
|
|
40
|
-
-
|
|
39
|
+
- **Creative override — raw HTML throughout is permitted** when the design intent genuinely calls for it: a highly custom visual identity, an asymmetric or typographically-driven layout, a brutalist/editorial/retro/neon/paper vibe where components would constrain the expression, or a layout that requires features a component simply doesn't support (e.g. full-viewport height, custom gradient glows, clamp-scaled display type). This is a deliberate design decision, not a shortcut. When taking this path:
|
|
40
|
+
- **Declare it in a comment at the top of the spec** so the reviewer recognises it: `// component-free — creative override: <reason>`. This is the signal the review tool reads.
|
|
41
41
|
- Functional atoms (`button`, `input`, `badge`, `modal`) should still come from components unless there is a specific design reason not to.
|
|
42
42
|
- `/pulse-ui.css` is always required — it provides the token system your custom CSS will use.
|
|
43
|
-
- The quality gate replaces the component checklist: **100
|
|
43
|
+
- The quality gate replaces the component checklist: **Lighthouse 100 on Accessibility, Best Practices, and SEO — desktop and mobile — plus CLS 0.00 is the pass bar.** (Performance is reported but not gated; it varies with machine load.) A component-free page that passes every audit is correct. A component-heavy page that fails accessibility is not.
|
|
44
44
|
|
|
45
|
-
- When reviewing a
|
|
45
|
+
- **When reviewing**: before flagging a class name like `.hero` as a violation, first ask whether the `hero()` component could actually reproduce the layout. If the answer is no, it is a creative override — check Lighthouse instead of refactoring. Do not flatten a good custom layout into a generic component just to clear a pattern-match.
|
|
46
|
+
|
|
47
|
+
- **The review tool auto-detects creative override** from the spec comment. When detected, component pattern checks are shown as advisory (`⚡`) not as failures, and the Lighthouse gate is the stated pass bar.
|
|
46
48
|
|
|
47
49
|
- **Never write App Store or Google Play download buttons by hand.** Always use `appBadge({ store: 'apple', href })` and `appBadge({ store: 'google', href })`. Raw `<a>` tags with badge images are incorrect — `appBadge` renders the correct accessible, styled badge for each store.
|
|
48
50
|
|
package/package.json
CHANGED
package/public/.pulse-ui-version
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
0.
|
|
1
|
+
0.17.0
|
package/src/agent/checklist.md
CHANGED
|
@@ -40,7 +40,7 @@ Before finishing any spec, verify every point below. Fix anything that fails.
|
|
|
40
40
|
- **Declare it in a comment at the top of the spec** so the reviewer recognises it: `// component-free — creative override: <reason>`. This is the signal the review tool reads.
|
|
41
41
|
- Functional atoms (`button`, `input`, `badge`, `modal`) should still come from components unless there is a specific design reason not to.
|
|
42
42
|
- `/pulse-ui.css` is always required — it provides the token system your custom CSS will use.
|
|
43
|
-
- The quality gate replaces the component checklist: **100
|
|
43
|
+
- The quality gate replaces the component checklist: **Lighthouse 100 on Accessibility, Best Practices, and SEO — desktop and mobile — plus CLS 0.00 is the pass bar.** (Performance is reported but not gated; it varies with machine load.) A component-free page that passes every audit is correct. A component-heavy page that fails accessibility is not.
|
|
44
44
|
|
|
45
45
|
- **When reviewing**: before flagging a class name like `.hero` as a violation, first ask whether the `hero()` component could actually reproduce the layout. If the answer is no, it is a creative override — check Lighthouse instead of refactoring. Do not flatten a good custom layout into a generic component just to clear a pattern-match.
|
|
46
46
|
|
|
@@ -8,73 +8,103 @@ Run the full verification loop on the current page or the page specified in $ARG
|
|
|
8
8
|
|
|
9
9
|
If $ARGUMENTS is provided, use it as the file path or route. Otherwise, identify the most recently edited spec file from context.
|
|
10
10
|
|
|
11
|
-
### 2.
|
|
11
|
+
### 2. Design approval (new builds only)
|
|
12
|
+
|
|
13
|
+
**Skip this step for edits, bug fixes, or "add X to existing Y" tasks.**
|
|
14
|
+
|
|
15
|
+
If this is a new page build (came through `pulse_intake` or `build-page`):
|
|
16
|
+
|
|
17
|
+
1. Take a screenshot and describe what you see to the user.
|
|
18
|
+
2. 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.
|
|
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.
|
|
20
|
+
|
|
21
|
+
### 3. Validate the spec
|
|
12
22
|
|
|
13
23
|
Use `pulse_validate` with the spec file content. If validation fails, report every error clearly and stop — do not proceed to screenshot until the spec is valid.
|
|
14
24
|
|
|
15
|
-
###
|
|
25
|
+
### 4. Check the dev server
|
|
16
26
|
|
|
17
27
|
Use `pulse_fetch_page` to confirm the server is responding for the route. If it errors, use `pulse_restart_server` and retry once.
|
|
18
28
|
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
### 4. Screenshot
|
|
29
|
+
### 5. Screenshot
|
|
22
30
|
|
|
23
31
|
Use `mcp__chrome-devtools__navigate_page` to load the page route, then `mcp__chrome-devtools__take_screenshot` to capture the result. Describe what you see — layout, content, any obvious rendering issues.
|
|
24
32
|
|
|
25
|
-
###
|
|
33
|
+
### 6. Lighthouse — desktop
|
|
34
|
+
|
|
35
|
+
**Pre-flight (required before every Lighthouse run):**
|
|
36
|
+
1. Call `pulse_build` to produce a production build and start the production server on port 3001.
|
|
37
|
+
2. Call `navigate_page` with `url: "http://localhost:3001/"` so the browser is on the production server.
|
|
38
|
+
|
|
39
|
+
Then run `mcp__chrome-devtools__lighthouse_audit` with `{ "device": "desktop" }`.
|
|
40
|
+
|
|
41
|
+
**Pass bar: Accessibility, Best Practices, and SEO must all be 100.** Performance is measured and reported but is not a hard requirement (it varies with machine load). Report the actual scores. If Accessibility, Best Practices, or SEO is below 100, identify the failing audit(s), fix the issue, and restart from step 3.
|
|
26
42
|
|
|
27
|
-
|
|
43
|
+
### 7. Lighthouse — mobile
|
|
28
44
|
|
|
29
|
-
|
|
45
|
+
The browser should still be on `http://localhost:3001/` from step 6. Run `mcp__chrome-devtools__lighthouse_audit` with `{ "device": "mobile" }`.
|
|
30
46
|
|
|
31
|
-
|
|
47
|
+
**Same pass bar: Accessibility, Best Practices, and SEO must all be 100.** Fix any failures and restart from step 3.
|
|
32
48
|
|
|
33
|
-
|
|
49
|
+
After both Lighthouse runs pass, call `pulse_restart_server` to return to the dev server.
|
|
34
50
|
|
|
35
|
-
|
|
51
|
+
### 8. Mobile layout check
|
|
36
52
|
|
|
37
|
-
|
|
53
|
+
Emulate a mobile viewport and take a screenshot to catch wrapping, overflow, and layout issues before they become Lighthouse failures.
|
|
38
54
|
|
|
39
|
-
|
|
55
|
+
```
|
|
56
|
+
mcp__chrome-devtools__emulate viewport: "390x844x2,mobile,touch"
|
|
57
|
+
mcp__chrome-devtools__navigate_page url: "http://localhost:3000/"
|
|
58
|
+
mcp__chrome-devtools__take_screenshot
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
Describe what you see. Look specifically for:
|
|
62
|
+
- Text or buttons overflowing the viewport
|
|
63
|
+
- Content too small to read or tap
|
|
64
|
+
- Navigation that overlaps content
|
|
65
|
+
- Images that break the layout
|
|
66
|
+
|
|
67
|
+
Fix any issues, then reset to desktop before continuing:
|
|
68
|
+
|
|
69
|
+
```
|
|
70
|
+
mcp__chrome-devtools__emulate viewport: "1440x900x1"
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
### 9. Performance
|
|
40
74
|
|
|
41
75
|
Navigate to the page route, 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.).
|
|
42
76
|
|
|
43
|
-
###
|
|
77
|
+
### 10. Console errors
|
|
44
78
|
|
|
45
79
|
Use `mcp__chrome-devtools__list_console_messages` — report any errors or unexpected warnings.
|
|
46
80
|
|
|
47
|
-
###
|
|
81
|
+
### 11. Code review
|
|
48
82
|
|
|
49
|
-
Call `pulse_review` with the
|
|
83
|
+
Call `pulse_review` with the spec file path. This runs a full code review AND atomically writes the `.pulse-verified` stamp server-side, eliminating any race condition with the stop hook.
|
|
50
84
|
|
|
51
|
-
|
|
85
|
+
Work through every item the review returns. Fix anything that fails before proceeding.
|
|
52
86
|
|
|
53
|
-
|
|
87
|
+
### 12. Close extra browser tabs
|
|
54
88
|
|
|
55
|
-
|
|
89
|
+
Use `mcp__chrome-devtools__list_pages` to get all open pages. Close every page **except the last one** — `close_page` refuses to close the final tab. `pageId` must be a number, not a string.
|
|
90
|
+
|
|
91
|
+
If there is only one page open, skip this step — there is nothing to close.
|
|
92
|
+
|
|
93
|
+
### 13. Write verification stamp
|
|
56
94
|
|
|
57
95
|
Call `pulse_stamp`. This writes `.pulse-verified` via the MCP server, which is more reliable than the `date +%s` shell command — the MCP write always lands after all spec edits, avoiding the mtime race condition that can cause the stop hook to block immediately after verification.
|
|
58
96
|
|
|
59
97
|
**This must be the last operation before stopping.** The stop hook compares each edited spec's mtime against this stamp — if any spec is newer than the stamp, the hook blocks. Do not edit any spec file after calling `pulse_stamp`.
|
|
60
98
|
|
|
61
|
-
###
|
|
99
|
+
### 14. Report
|
|
62
100
|
|
|
63
101
|
Summarise:
|
|
64
102
|
- Validation: pass or fail (with errors if any)
|
|
65
|
-
- Visual:
|
|
66
|
-
- Lighthouse desktop: scores for
|
|
67
|
-
- Lighthouse mobile: scores for
|
|
103
|
+
- Visual: desktop screenshot and mobile layout check — any issues found and fixed
|
|
104
|
+
- Lighthouse desktop: scores for Accessibility, Best Practices, SEO
|
|
105
|
+
- Lighthouse mobile: scores for Accessibility, Best Practices, SEO
|
|
68
106
|
- Performance: LCP and CLS values, any flagged insights
|
|
69
107
|
- Console: any errors
|
|
70
108
|
- Review: pass or issues found and fixed
|
|
71
109
|
|
|
72
|
-
|
|
73
|
-
- Validation: clean (no errors, no warnings)
|
|
74
|
-
- Lighthouse desktop: Accessibility, Best Practices, SEO all 100
|
|
75
|
-
- Lighthouse mobile: same
|
|
76
|
-
- CLS: 0.00 — any layout shift is a blocker
|
|
77
|
-
- LCP: report the value; flag and fix any insight that identifies a fixable cause
|
|
78
|
-
- Console: no errors
|
|
79
|
-
|
|
80
|
-
If any gate fails, fix the issue and run `/verify` again.
|
|
110
|
+
Only confirm the page is good when validation passes, both Lighthouse runs are 100/100/100, CLS is 0.00, and there are no console errors. Otherwise, fix and run `/verify` again.
|
|
@@ -15,7 +15,7 @@ Warm cream background, hand-crafted feel. Photo-forward hero with a split layout
|
|
|
15
15
|
**Key components:** `hero({ layout:'split' })`, `section`, `container`, `grid`, `card` (with `footer`), `stat`, `testimonial`, `cta`, `badge`
|
|
16
16
|
|
|
17
17
|
**Distinguishing moves:**
|
|
18
|
-
- Gradient hero `linear-gradient(135deg, #fdf6ee, #fdebd0)` with `color:#3d2b1e`
|
|
18
|
+
- Gradient hero `linear-gradient(135deg, #fdf6ee, #fdebd0)` with `color:#3d2b1e` — define these hex values as tokens in `public/theme.css` and reference them via `var()`; the lint hook blocks raw hex in any other stylesheet
|
|
19
19
|
- Nav with `background: rgba(255,250,245,0.92)` and matching text colour
|
|
20
20
|
- Cards with price footer: `<div class="u-flex u-items-center u-justify-between"><span>From</span><strong>£35</strong></div>`
|
|
21
21
|
- Credentials list: icon + span with `iconCheckCircle`
|
|
@@ -105,6 +105,8 @@ Long-form article layout with `prose` component for markdown rendering, breadcru
|
|
|
105
105
|
|
|
106
106
|
## Component Combinations by Use Case
|
|
107
107
|
|
|
108
|
+
> Inline `style=""` in these snippets is limited to structural image properties (`aspect-ratio`, `object-fit`, fixed heights) that have no utility-class equivalent — this is the permitted exception to the no-inline-style rule. Colours and theming still belong in tokens.
|
|
109
|
+
|
|
108
110
|
### Image Card (Card with Cover Photo)
|
|
109
111
|
Card doesn't have a native image slot — use `flush:true` and put the image inside `content`:
|
|
110
112
|
|
|
@@ -218,16 +220,16 @@ section({
|
|
|
218
220
|
```
|
|
219
221
|
|
|
220
222
|
### Booking / Contact Form Inside a Card
|
|
223
|
+
Use the `input()` component for fields — it renders the label, id wiring, and required marker accessibly. Do not write raw `<input>`/`<label>` HTML:
|
|
224
|
+
|
|
221
225
|
```js
|
|
222
226
|
card({
|
|
223
227
|
title: 'Book an Appointment',
|
|
224
228
|
content: `
|
|
225
229
|
<p class="u-text-muted u-text-sm u-mb-4">Fill in the form and we'll confirm within a few hours.</p>
|
|
226
230
|
<form class="u-flex u-flex-col u-gap-3" data-action="submit">
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
<input id="name" name="name" type="text" class="u-input" required>
|
|
230
|
-
</div>
|
|
231
|
+
${input({ label: 'Your name', name: 'name', required: true })}
|
|
232
|
+
${input({ label: 'Email', name: 'email', type: 'email', required: true })}
|
|
231
233
|
${button({ label: 'Send Enquiry', type: 'submit', variant: 'primary', fullWidth: true })}
|
|
232
234
|
</form>
|
|
233
235
|
`,
|
|
@@ -62,12 +62,12 @@ When you choose a design direction, tell the user which one you're using and why
|
|
|
62
62
|
**Feels like:** Duolingo, Headspace, Loom, Figma landing page, pop-art toy brand
|
|
63
63
|
**vibe:** `playful`
|
|
64
64
|
**Geometry:** very rounded (18–24px), friendly icons, colourful
|
|
65
|
-
**Typography:** rounded or friendly sans, loose spacing, emoji
|
|
65
|
+
**Typography:** rounded or friendly sans, loose spacing, friendly icon accents (icon library, never emoji)
|
|
66
66
|
**Palette pattern:** bright bg (coral / mint / lavender) + dark text
|
|
67
67
|
**Signature moves (use these, not 3-col feature grids):**
|
|
68
68
|
- **Full-colour section blocks** — each section has its own bg colour (yellow stats strip, cyan testimonials, hot-pink CTA). Palette is structure, not accent.
|
|
69
69
|
- **Oversized hero element** — the product's key visual (a game board, an illustration, an icon) fills most of the hero viewport. `phoneFrame` is one option, not the default.
|
|
70
|
-
- **Marquee strip** — giant repeated glyphs,
|
|
70
|
+
- **Marquee strip** — giant repeated icon-library glyphs, brand marks, or product icons scrolling across the page between sections (`marquee` component). Use icons from `src/ui/icons.js`, not emoji — emoji are banned in UI output (not accessible, not theme-aware).
|
|
71
71
|
- **Tilted `photoCard`s** — testimonial or feature cards at ±2–4° with `photoCard` or CSS `transform: rotate()`
|
|
72
72
|
- **Memphis-style geometric shapes** between sections — use `decorate(pattern:'circles')` or CSS background shapes
|
|
73
73
|
- **Social proof as ticker** — a `marquee` of short quotes or star ratings, not a 3-col grid
|
package/src/agent/identity.md
CHANGED
|
@@ -14,7 +14,7 @@ Every page you ship must meet all of the following. These are not aspirational
|
|
|
14
14
|
|
|
15
15
|
**Lighthouse 100 on Accessibility, Best Practices, and SEO.** The \`lighthouse_audit\` tool returns these three scores — all must be 100. If any cannot hit 100, something is wrong with the spec. (Performance is not part of the audit tool and is not required.) The audit is slow — ~30–60 s per run. Always tell the user before calling it.
|
|
16
16
|
|
|
17
|
-
**Polished and considered.** Use the component library — `button`, `card`, `input`, `alert`, `stat`, `empty`, `table`, and the rest. Use the spacing scale (`u-mt-*`, `u-gap-*`, `u-py-*`). Use the type scale (`u-text-xl`, `u-font-semibold`). Use the colour tokens (`var(--ui-accent)`, `var(--ui-muted)`). Do not leave raw unstyled HTML. Do not invent a layout from scratch when the grid, stack, cluster, container, and section components already solve it
|
|
17
|
+
**Polished and considered.** Use the component library — `button`, `card`, `input`, `alert`, `stat`, `empty`, `table`, and the rest. Use the spacing scale (`u-mt-*`, `u-gap-*`, `u-py-*`). Use the type scale (`u-text-xl`, `u-font-semibold`). Use the colour tokens (`var(--ui-accent)`, `var(--ui-muted)`). Do not leave raw unstyled HTML. Do not invent a layout from scratch when the grid, stack, cluster, container, and section components already solve it — *unless you have declared a creative override (see "Design Freedom" below), in which case a custom layout is the deliberate, correct choice.*
|
|
18
18
|
|
|
19
19
|
**Works correctly on first load.** SSR is always on. The page must be readable and navigable before any JavaScript executes. Do not build pages that require client JS to render content.
|
|
20
20
|
|
|
@@ -22,15 +22,30 @@ Every page you ship must meet all of the following. These are not aspirational
|
|
|
22
22
|
|
|
23
23
|
**Accessible form labels.** Every `input`, `select`, and `textarea` has a label. Every form has a submit button. Required fields are marked required. Error messages are associated with the field that caused them.
|
|
24
24
|
|
|
25
|
+
# Design Freedom — The Rule
|
|
26
|
+
|
|
27
|
+
There are two valid build modes. Knowing which one you are in tells you exactly which rules apply.
|
|
28
|
+
|
|
29
|
+
**Mode A — components first (the default).** All standard UI patterns come from the component library. The component rules in "What You Will Not Do" below apply in full. This is the right mode for most pages.
|
|
30
|
+
|
|
31
|
+
**Mode B — creative override.** When the design intent genuinely calls for it — a highly custom visual identity, an asymmetric or typographically-driven layout, a brutalist/editorial/retro/neon/paper vibe that components would constrain, or a layout a component simply cannot reproduce — you may build with raw HTML throughout. This is a deliberate design decision available to you, not a rule violation and not a shortcut. To use it:
|
|
32
|
+
|
|
33
|
+
1. **Declare it in a comment at the top of the spec:** `// component-free — creative override: <reason>`. The review tool reads this comment from the source file — announcing the mode in chat is not enough.
|
|
34
|
+
2. Functional atoms (`button`, `input`, `badge`, `modal`) still come from components unless there is a specific design reason not to.
|
|
35
|
+
3. `/pulse-ui.css` stays in `meta.styles` — your custom CSS builds on its token system.
|
|
36
|
+
4. The pass bar changes: instead of the component checklist, **Lighthouse 100 on Accessibility, Best Practices, and SEO, desktop and mobile**, plus CLS 0.00. A component-free page that passes every audit is correct. A component-heavy page that fails accessibility is not.
|
|
37
|
+
|
|
38
|
+
The component rules marked *(Mode A)* below are suspended under a declared creative override. Everything else — accessibility, security, testing, escaping, error handling — applies in both modes, always.
|
|
39
|
+
|
|
25
40
|
# What You Will Not Do
|
|
26
41
|
|
|
27
42
|
- Install client-side JS dependencies. No React, Vue, Alpine, htmx, Tailwind, Lodash, Axios, or any other package that runs in the browser. Pulse handles rendering, state, actions, navigation, and SSR. If you need a utility, write it.
|
|
28
43
|
- Use emoji characters in UI output. Use icons from the icon library instead — `iconCheck`, `iconStar`, `iconZap`, etc. If the right icon does not exist, create it in `src/ui/icons.js` following the existing pattern (see the guide for how). Emoji are not accessible, are not theme-aware, and render inconsistently across platforms.
|
|
29
|
-
- Hardcode hex colours in CSS. Use `var(--ui-*)` tokens
|
|
30
|
-
- Use inline `style=""` attributes. Use utility classes or `var(--ui-*)` tokens in a stylesheet.
|
|
31
|
-
- Write raw `<button>`, `<input>`, `<select>`, or `<textarea>` HTML when the component library already provides accessible, styled versions.
|
|
32
|
-
- Write raw `<h1>`–`<h6>` without styling. Use `heading({ level, text })` instead.
|
|
33
|
-
- Write raw `<ul>` or `<ol>` without styling. Use `list({ items })` instead.
|
|
44
|
+
- Hardcode hex colours in CSS. Use `var(--ui-*)` tokens — they cascade through every component automatically and make theming possible. The one place hex values belong is `public/theme.css`, as token *definitions*; every other stylesheet references them via `var()`. A lint hook enforces this.
|
|
45
|
+
- Use inline `style=""` attributes. Use utility classes or `var(--ui-*)` tokens in a stylesheet. *Exception:* structural image styles with no utility-class equivalent (`aspect-ratio`, `object-fit`) are permitted inline — these are layout facts, not theming, and the design gallery uses them this way.
|
|
46
|
+
- Write raw `<button>`, `<input>`, `<select>`, or `<textarea>` HTML when the component library already provides accessible, styled versions. *(Mode A — under a creative override, functional atoms should still come from components unless there is a specific design reason.)*
|
|
47
|
+
- Write raw `<h1>`–`<h6>` without styling. Use `heading({ level, text })` instead. *(Mode A)*
|
|
48
|
+
- Write raw `<ul>` or `<ol>` without styling. Use `list({ items })` instead. *(Mode A)*
|
|
34
49
|
- Output CMS or database HTML without a `prose()` wrapper. Raw HTML from external sources has no styling — always wrap it in `prose({ content: html })`.
|
|
35
50
|
- Use `data-event` on text inputs. Re-rendering on every keystroke destroys focus. Use uncontrolled inputs and read values from `FormData` in `action.onStart` or `action.run`.
|
|
36
51
|
- Skip `onError` in an action. It is required. Always handle failure.
|
package/src/agent/workflow.md
CHANGED
|
@@ -73,7 +73,7 @@ Decide the complexity tier (see below) and confirm with the user if the task is
|
|
|
73
73
|
|
|
74
74
|
**Skip confirmation for trivially small tasks** (add a button, fix a label, swap a component). When in doubt, confirm.
|
|
75
75
|
|
|
76
|
-
**When asking the user questions:** ask one question at a time. If you present choices, use at most 4 options —
|
|
76
|
+
**When asking the user questions:** ask one question at a time. If you present choices, use at most 4 options — question tools in agent hosts (e.g. Claude Code's AskUserQuestion) cap the number of choices and will error if exceeded. For open-ended questions (names, copy, features, hex colours), ask as plain prose with no choices at all.
|
|
77
77
|
|
|
78
78
|
---
|
|
79
79
|
|
|
@@ -161,11 +161,12 @@ If you're about to type `class="hero"` or `class="product-card"` in Mode A, stop
|
|
|
161
161
|
When the design intent calls for a more expressive, unconventional, or typographically-driven layout that components would constrain, you may build with raw HTML throughout. This is a deliberate design decision, not a shortcut.
|
|
162
162
|
|
|
163
163
|
Rules for Mode B:
|
|
164
|
+
- **Declare it in a comment at the top of the spec file:** `// component-free — creative override: <reason>`. The review tool detects creative override by reading this comment from the source — without it, `pulse_review` will flag every component pattern as a violation even though you chose Mode B deliberately. Announcing the mode in chat is not enough; the comment must be in the file.
|
|
164
165
|
- Load `/pulse-ui.css` in `meta.styles` — the token system is still required
|
|
165
166
|
- Functional atoms (`button`, `input`, `badge`, `modal`) should still come from components unless there is a specific design reason
|
|
166
|
-
- The quality gate is 100
|
|
167
|
+
- The quality gate is Lighthouse 100 on Accessibility, Best Practices, and SEO (desktop and mobile) plus CLS 0.00 — this replaces the component checklist as the pass bar. Performance is reported but not gated.
|
|
167
168
|
|
|
168
|
-
Announce Mode B explicitly in the build brief
|
|
169
|
+
Announce Mode B explicitly in the build brief (*"Mode B — component-free, raw HTML for creative control"*) **and** write the override comment into the spec.
|
|
169
170
|
|
|
170
171
|
### 3b — Announce before writing
|
|
171
172
|
|
package/src/html.js
CHANGED
package/src/mcp/mcp.test.js
CHANGED
|
@@ -102,6 +102,117 @@ test('no orphaned guide.md (monolithic legacy file should not exist)', () => {
|
|
|
102
102
|
)
|
|
103
103
|
})
|
|
104
104
|
|
|
105
|
+
// ── Duplicated doc sync ──────────────────────────────────────────────────────
|
|
106
|
+
// Several agent docs exist in two places (a canonical source plus a copy that is
|
|
107
|
+
// scaffolded into consumer projects or kept in this repo's own .claude/). These
|
|
108
|
+
// pairs must stay byte-identical or the two audiences receive drifting rules.
|
|
109
|
+
|
|
110
|
+
console.log('\nDuplicated doc sync\n')
|
|
111
|
+
|
|
112
|
+
const SYNC_PAIRS = [
|
|
113
|
+
['src/agent/checklist.md', '.claude/pulse-checklist.md'],
|
|
114
|
+
['src/agent/checklist.md', 'docs/.claude/pulse-checklist.md'],
|
|
115
|
+
['src/agent/checklist.md', 'examples/.claude/pulse-checklist.md'],
|
|
116
|
+
['src/agent/commands/verify.md', '.claude/commands/verify.md'],
|
|
117
|
+
]
|
|
118
|
+
|
|
119
|
+
for (const [a, b] of SYNC_PAIRS) {
|
|
120
|
+
test(`${a} is in sync with ${b}`, () => {
|
|
121
|
+
const fa = path.join(ROOT, a)
|
|
122
|
+
const fb = path.join(ROOT, b)
|
|
123
|
+
assert(fs.existsSync(fa), `${a} does not exist`)
|
|
124
|
+
assert(fs.existsSync(fb), `${b} does not exist`)
|
|
125
|
+
assert(
|
|
126
|
+
fs.readFileSync(fa, 'utf8') === fs.readFileSync(fb, 'utf8'),
|
|
127
|
+
`${a} and ${b} have drifted apart. Edit the canonical file and copy it over the other ` +
|
|
128
|
+
`(cp ${a} ${b} or vice versa) so both audiences get the same rules.`
|
|
129
|
+
)
|
|
130
|
+
})
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// ── Agent doc consistency guards ─────────────────────────────────────────────
|
|
134
|
+
// Contradictions between agent docs leave the agent unable to know what it can
|
|
135
|
+
// and cannot do. These guards pin the resolved decisions:
|
|
136
|
+
// - The Lighthouse pass bar is THREE gated scores (Accessibility, Best
|
|
137
|
+
// Practices, SEO) — Performance is reported, never gated. "100/100/100/100"
|
|
138
|
+
// reintroduces a four-score bar the audit tooling cannot verify.
|
|
139
|
+
// - "ask_user" is not a real tool in any host — docs must not name it.
|
|
140
|
+
// - Emoji are banned in UI output — design guides must not suggest them.
|
|
141
|
+
|
|
142
|
+
console.log('\nAgent doc consistency\n')
|
|
143
|
+
|
|
144
|
+
const agentDocs = [
|
|
145
|
+
...fs.readdirSync(agentDir).filter(f => f.endsWith('.md')).map(f => path.join('src/agent', f)),
|
|
146
|
+
...fs.readdirSync(path.join(agentDir, 'commands')).filter(f => f.endsWith('.md')).map(f => path.join('src/agent/commands', f)),
|
|
147
|
+
...fs.readdirSync(path.join(agentDir, 'skills')).flatMap(d => {
|
|
148
|
+
const p = path.join(agentDir, 'skills', d, 'SKILL.md')
|
|
149
|
+
return fs.existsSync(p) ? [path.join('src/agent/skills', d, 'SKILL.md')] : []
|
|
150
|
+
}),
|
|
151
|
+
'.claude/commands/build-page.md',
|
|
152
|
+
'.claude/commands/verify.md',
|
|
153
|
+
'.claude/commands/new-doc-page.md',
|
|
154
|
+
'CLAUDE.md',
|
|
155
|
+
]
|
|
156
|
+
|
|
157
|
+
test('no agent doc states a four-score Lighthouse bar (100/100/100/100)', () => {
|
|
158
|
+
const offenders = agentDocs.filter(f =>
|
|
159
|
+
fs.existsSync(path.join(ROOT, f)) &&
|
|
160
|
+
fs.readFileSync(path.join(ROOT, f), 'utf8').includes('100/100/100/100')
|
|
161
|
+
)
|
|
162
|
+
if (serverSrc.includes('100/100/100/100')) offenders.push('src/mcp/server.js')
|
|
163
|
+
assert(
|
|
164
|
+
offenders.length === 0,
|
|
165
|
+
`Four-score Lighthouse bar found in: ${offenders.join(', ')}. ` +
|
|
166
|
+
`The gated scores are Accessibility, Best Practices, and SEO (three) — Performance is reported, not gated.`
|
|
167
|
+
)
|
|
168
|
+
})
|
|
169
|
+
|
|
170
|
+
test('no agent doc references a literal ask_user tool', () => {
|
|
171
|
+
const offenders = agentDocs.filter(f =>
|
|
172
|
+
fs.existsSync(path.join(ROOT, f)) &&
|
|
173
|
+
/\bask_user\b/.test(fs.readFileSync(path.join(ROOT, f), 'utf8'))
|
|
174
|
+
)
|
|
175
|
+
assert(
|
|
176
|
+
offenders.length === 0,
|
|
177
|
+
`"ask_user" referenced in: ${offenders.join(', ')}. ` +
|
|
178
|
+
`No host exposes a tool by that name — phrase host-agnostically (e.g. "your host's question tool").`
|
|
179
|
+
)
|
|
180
|
+
})
|
|
181
|
+
|
|
182
|
+
test('design guides do not suggest emoji in UI output', () => {
|
|
183
|
+
const designGuides = ['src/agent/guide-design-references.md', 'src/agent/guide-design-gallery.md']
|
|
184
|
+
const offenders = designGuides.filter(f => {
|
|
185
|
+
const src = fs.readFileSync(path.join(ROOT, f), 'utf8')
|
|
186
|
+
// Allow lines that mention emoji only to ban them; flag lines that suggest using them
|
|
187
|
+
return src.split('\n').some(line =>
|
|
188
|
+
/emoji/i.test(line) && !/never|not |banned|instead of emoji/i.test(line)
|
|
189
|
+
)
|
|
190
|
+
})
|
|
191
|
+
assert(
|
|
192
|
+
offenders.length === 0,
|
|
193
|
+
`Emoji suggested (without a ban qualifier) in: ${offenders.join(', ')}. ` +
|
|
194
|
+
`Emoji are banned in UI output (identity.md) — suggest icon-library glyphs instead.`
|
|
195
|
+
)
|
|
196
|
+
})
|
|
197
|
+
|
|
198
|
+
test('workflow.md Mode B instructs writing the creative-override spec comment', () => {
|
|
199
|
+
const workflow = fs.readFileSync(path.join(agentDir, 'workflow.md'), 'utf8')
|
|
200
|
+
assert(
|
|
201
|
+
/component.free — creative override/.test(workflow),
|
|
202
|
+
'workflow.md Mode B must instruct writing the `// component-free — creative override: <reason>` ' +
|
|
203
|
+
'comment into the spec — pulse_review detects creative override by reading the source file.'
|
|
204
|
+
)
|
|
205
|
+
})
|
|
206
|
+
|
|
207
|
+
test('identity.md declares the creative override (Design Freedom) carve-out', () => {
|
|
208
|
+
const identity = fs.readFileSync(path.join(agentDir, 'identity.md'), 'utf8')
|
|
209
|
+
assert(
|
|
210
|
+
/creative override/i.test(identity),
|
|
211
|
+
'identity.md must include the creative-override carve-out — without it the persona ' +
|
|
212
|
+
'unconditionally bans raw HTML that workflow.md Mode B explicitly permits.'
|
|
213
|
+
)
|
|
214
|
+
})
|
|
215
|
+
|
|
105
216
|
// ── Result ───────────────────────────────────────────────────────────────────
|
|
106
217
|
|
|
107
218
|
console.log(`\n${passed} passed, ${failed} failed\n`)
|
package/src/mcp/server.js
CHANGED
|
@@ -1081,7 +1081,7 @@ ${(() => {
|
|
|
1081
1081
|
// Creative override detection — check if spec declares component-free mode
|
|
1082
1082
|
const creativeOverride = /component.free|creative\s+override|raw\s+HTML\s+throughout/i.test(source)
|
|
1083
1083
|
checks.push(creativeOverride
|
|
1084
|
-
? '⚡ **Creative override declared** — component pattern checks are advisory only. Lighthouse 100
|
|
1084
|
+
? '⚡ **Creative override declared** — component pattern checks are advisory only. Lighthouse 100 on Accessibility, Best Practices, and SEO (desktop + mobile) plus CLS 0.00 is the pass bar.'
|
|
1085
1085
|
: '○ No creative override declared — component checks apply')
|
|
1086
1086
|
|
|
1087
1087
|
return checks.join('\n')
|
|
@@ -1122,7 +1122,7 @@ Work through every item. Fix anything that fails. Refer to the spec source at ${
|
|
|
1122
1122
|
|
|
1123
1123
|
If no creative override is declared and a component *could* reproduce the design, refactor to use it. Custom utility classes on top of components are fine (\`hero({ ... })\` + override CSS) — but do not write the entire structure from scratch when the component supports the layout.
|
|
1124
1124
|
|
|
1125
|
-
- [ ] **Creative override** (fill in if applicable): *State the override reason here — e.g. "full-viewport gradient hero not achievable with hero() component"*. Confirm Lighthouse 100
|
|
1125
|
+
- [ ] **Creative override** (fill in if applicable): *State the override reason here — e.g. "full-viewport gradient hero not achievable with hero() component"*. Confirm Lighthouse 100 on Accessibility, Best Practices, and SEO (desktop and mobile) plus CLS 0.00 before closing the review.
|
|
1126
1126
|
- [ ] No \`data-event\` on text inputs — this destroys focus on every keystroke
|
|
1127
1127
|
- [ ] No \`className\`, \`htmlFor\`, \`onClick=\`, or other React patterns
|
|
1128
1128
|
- [ ] No hardcoded hex colours in view — only \`var(--ui-*)\` tokens (anchor \`href="#id"\` values are fine — those are not colours)
|
package/src/md/index.js
CHANGED
|
@@ -25,6 +25,33 @@ function esc(s) {
|
|
|
25
25
|
.replace(/"/g, '"')
|
|
26
26
|
}
|
|
27
27
|
|
|
28
|
+
/**
|
|
29
|
+
* Sanitise a markdown link/image URL.
|
|
30
|
+
*
|
|
31
|
+
* Allows http(s), mailto, tel, relative paths, fragment/query refs, and
|
|
32
|
+
* image data: URIs. Any other scheme — notably javascript:, vbscript:, and
|
|
33
|
+
* non-image data: URIs — is neutralised to '#', preventing XSS when markdown
|
|
34
|
+
* comes from an untrusted source (database, user submission, external API).
|
|
35
|
+
*
|
|
36
|
+
* The value has already passed through esc(), so it is safe to embed in an
|
|
37
|
+
* attribute; this guard only blocks dangerous URL schemes.
|
|
38
|
+
*
|
|
39
|
+
* @param {string} url
|
|
40
|
+
* @param {boolean} [isImage=false] - permit image data: URIs when true
|
|
41
|
+
* @returns {string}
|
|
42
|
+
*/
|
|
43
|
+
function safeUrl(url, isImage = false) {
|
|
44
|
+
// Strip leading control chars/whitespace that browsers ignore when parsing
|
|
45
|
+
// the scheme (e.g. "java\tscript:"), then test for a scheme prefix.
|
|
46
|
+
const stripped = url.replace(/[\x00-\x20]+/g, '')
|
|
47
|
+
const scheme = stripped.match(/^([a-zA-Z][a-zA-Z0-9+.\-]*):/)
|
|
48
|
+
if (!scheme) return url // no scheme → relative / fragment / query, safe
|
|
49
|
+
const proto = scheme[1].toLowerCase()
|
|
50
|
+
if (proto === 'http' || proto === 'https' || proto === 'mailto' || proto === 'tel') return url
|
|
51
|
+
if (isImage && /^data:image\/(png|jpe?g|gif|webp|avif|svg\+xml);/i.test(stripped)) return url
|
|
52
|
+
return '#'
|
|
53
|
+
}
|
|
54
|
+
|
|
28
55
|
// ---------------------------------------------------------------------------
|
|
29
56
|
// Frontmatter — --- key: value --- block at top of file
|
|
30
57
|
// ---------------------------------------------------------------------------
|
|
@@ -69,12 +96,12 @@ function renderInline(raw) {
|
|
|
69
96
|
// 3. Images (before links — same syntax with leading !)
|
|
70
97
|
s = s.replace(/!\[([^\]]*)\]\(([^)\s"]+)(?:\s+"([^"]*)")?\)/g,
|
|
71
98
|
(_, alt, src, title) =>
|
|
72
|
-
`<img src="${src}" alt="${alt}"${title ? ` title="${title}"` : ''}>`)
|
|
99
|
+
`<img src="${safeUrl(src, true)}" alt="${alt}"${title ? ` title="${title}"` : ''}>`)
|
|
73
100
|
|
|
74
101
|
// 4. Links
|
|
75
102
|
s = s.replace(/\[([^\]]+)\]\(([^)\s"]+)(?:\s+"([^"]*)")?\)/g,
|
|
76
103
|
(_, label, href, title) =>
|
|
77
|
-
`<a href="${href}"${title ? ` title="${title}"` : ''}>${label}</a>`)
|
|
104
|
+
`<a href="${safeUrl(href)}"${title ? ` title="${title}"` : ''}>${label}</a>`)
|
|
78
105
|
|
|
79
106
|
// 5. Bold + italic (order: *** before ** before *)
|
|
80
107
|
s = s.replace(/\*{3}([^*\n]+)\*{3}/g, (_, t) => `<strong><em>${t}</em></strong>`)
|
package/src/md/md.test.js
CHANGED
|
@@ -175,6 +175,42 @@ test('HTML is escaped in inline text', () => {
|
|
|
175
175
|
assert(html.includes('<script>'), `Got: ${html}`)
|
|
176
176
|
})
|
|
177
177
|
|
|
178
|
+
// --- URL scheme sanitisation (XSS) -----------------------------------------
|
|
179
|
+
|
|
180
|
+
test('link with javascript: scheme is neutralised to #', () => {
|
|
181
|
+
const { html } = parseMd('[x](javascript:alert(1))')
|
|
182
|
+
assert(!html.toLowerCase().includes('javascript:'), `Got: ${html}`)
|
|
183
|
+
assert(html.includes('href="#"'), `Got: ${html}`)
|
|
184
|
+
})
|
|
185
|
+
|
|
186
|
+
test('javascript: scheme neutralised regardless of case', () => {
|
|
187
|
+
const { html } = parseMd('[x](JaVaScRiPt:alert(1))')
|
|
188
|
+
assert(!html.toLowerCase().includes('javascript:'), `Got: ${html}`)
|
|
189
|
+
})
|
|
190
|
+
|
|
191
|
+
test('image with javascript: scheme is neutralised', () => {
|
|
192
|
+
const { html } = parseMd(')')
|
|
193
|
+
assert(!html.toLowerCase().includes('javascript:'), `Got: ${html}`)
|
|
194
|
+
assert(html.includes('src="#"'), `Got: ${html}`)
|
|
195
|
+
})
|
|
196
|
+
|
|
197
|
+
test('non-image data: URI in image is neutralised', () => {
|
|
198
|
+
const { html } = parseMd('</script>)')
|
|
199
|
+
assert(html.includes('src="#"'), `Got: ${html}`)
|
|
200
|
+
})
|
|
201
|
+
|
|
202
|
+
test('image data: URI is allowed', () => {
|
|
203
|
+
const { html } = parseMd('')
|
|
204
|
+
assert(html.includes('src="data:image/png;base64,iVBOR"'), `Got: ${html}`)
|
|
205
|
+
})
|
|
206
|
+
|
|
207
|
+
test('safe schemes and relative/fragment URLs are preserved', () => {
|
|
208
|
+
for (const url of ['https://ok.com', 'http://ok.com', '/relative', '#frag', 'mailto:a@b.com', 'tel:+15551234']) {
|
|
209
|
+
const { html } = parseMd(`[x](${url})`)
|
|
210
|
+
assert(html.includes(`href="${url}"`), `Expected ${url} preserved, got: ${html}`)
|
|
211
|
+
}
|
|
212
|
+
})
|
|
213
|
+
|
|
178
214
|
// ---------------------------------------------------------------------------
|
|
179
215
|
|
|
180
216
|
console.log(`\n${passed + failed} tests: ${passed} passed, ${failed} failed\n`)
|
package/src/runtime/ssr.js
CHANGED
package/src/runtime/tt.js
CHANGED
|
@@ -4,6 +4,14 @@
|
|
|
4
4
|
* everywhere else — behaviour is identical, the protection is simply absent.
|
|
5
5
|
*
|
|
6
6
|
* Policy name 'pulse' must match the trusted-types CSP directive on the server.
|
|
7
|
+
*
|
|
8
|
+
* SECURITY NOTE: createHTML/createScriptURL are intentional pass-throughs. The
|
|
9
|
+
* framework owns the HTML it renders, so this policy does NOT sanitise — it
|
|
10
|
+
* exists to satisfy `require-trusted-types-for 'script'` and to confine HTML
|
|
11
|
+
* sinks to a single audited policy (preventing *other* code on the page from
|
|
12
|
+
* creating its own policy). It is NOT a defence against unescaped user data in
|
|
13
|
+
* a view: view authors remain responsible for escaping untrusted values with
|
|
14
|
+
* escHtml() before interpolating them into markup.
|
|
7
15
|
*/
|
|
8
16
|
|
|
9
17
|
let _policy = null
|
package/src/server/index.js
CHANGED
|
@@ -119,16 +119,25 @@ function negotiateEncoding(req) {
|
|
|
119
119
|
return null
|
|
120
120
|
}
|
|
121
121
|
|
|
122
|
+
// Brotli quality for on-the-fly (per-request) compression. The library default
|
|
123
|
+
// is 11 — the maximum, intended for build-time compression of static assets.
|
|
124
|
+
// At q11 each dynamic response costs ~10x the CPU of q5 for a marginal size gain
|
|
125
|
+
// on HTML, inflating TTFB and limiting throughput. q5 matches what CDNs/nginx use
|
|
126
|
+
// for dynamic content. Pre-built /dist/* bundles should be compressed at build time.
|
|
127
|
+
const BROTLI_DYNAMIC = {
|
|
128
|
+
params: { [zlib.constants.BROTLI_PARAM_QUALITY]: 5 },
|
|
129
|
+
}
|
|
130
|
+
|
|
122
131
|
/** Compress a Buffer using the given encoding. Returns the original if none. */
|
|
123
132
|
async function compressBuffer(buf, encoding) {
|
|
124
|
-
if (encoding === 'br') return brotliAsync(buf)
|
|
133
|
+
if (encoding === 'br') return brotliAsync(buf, BROTLI_DYNAMIC)
|
|
125
134
|
if (encoding === 'gzip') return gzipAsync(buf)
|
|
126
135
|
return buf
|
|
127
136
|
}
|
|
128
137
|
|
|
129
138
|
/** Create a transform stream for the given encoding, or null. */
|
|
130
139
|
function createCompressor(encoding) {
|
|
131
|
-
if (encoding === 'br') return zlib.createBrotliCompress()
|
|
140
|
+
if (encoding === 'br') return zlib.createBrotliCompress(BROTLI_DYNAMIC)
|
|
132
141
|
if (encoding === 'gzip') return zlib.createGzip()
|
|
133
142
|
return null
|
|
134
143
|
}
|
|
@@ -211,21 +220,38 @@ function parseMultipart(buf, boundary) {
|
|
|
211
220
|
// In-process TTL cache — server data memoization
|
|
212
221
|
// ---------------------------------------------------------------------------
|
|
213
222
|
|
|
214
|
-
class TtlCache {
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
223
|
+
export class TtlCache {
|
|
224
|
+
/**
|
|
225
|
+
* @param {number} [maxEntries=1000] - hard cap on stored entries. When exceeded,
|
|
226
|
+
* the least-recently-used entry is evicted. Bounds memory so cache keys derived
|
|
227
|
+
* from attacker-controlled input (query strings, Host header) cannot grow the
|
|
228
|
+
* cache without limit.
|
|
229
|
+
*/
|
|
230
|
+
constructor(maxEntries = 1000) {
|
|
231
|
+
this._store = new Map() // Map preserves insertion order → used for LRU
|
|
232
|
+
this._interval = null
|
|
233
|
+
this._maxEntries = maxEntries
|
|
218
234
|
}
|
|
219
235
|
|
|
220
236
|
get(key) {
|
|
221
237
|
const entry = this._store.get(key)
|
|
222
238
|
if (!entry) return undefined
|
|
223
239
|
if (Date.now() > entry.expiresAt) { this._store.delete(key); return undefined }
|
|
240
|
+
// Mark as most-recently-used: re-insert to move to the end of the Map.
|
|
241
|
+
this._store.delete(key)
|
|
242
|
+
this._store.set(key, entry)
|
|
224
243
|
return entry.value
|
|
225
244
|
}
|
|
226
245
|
|
|
227
246
|
set(key, value, ttlSeconds) {
|
|
247
|
+
// Refresh position if it already exists so LRU ordering stays correct.
|
|
248
|
+
this._store.delete(key)
|
|
228
249
|
this._store.set(key, { value, expiresAt: Date.now() + ttlSeconds * 1000 })
|
|
250
|
+
// Evict the least-recently-used entry (first in insertion order) when over cap.
|
|
251
|
+
if (this._store.size > this._maxEntries) {
|
|
252
|
+
const oldest = this._store.keys().next().value
|
|
253
|
+
this._store.delete(oldest)
|
|
254
|
+
}
|
|
229
255
|
}
|
|
230
256
|
|
|
231
257
|
/** Remove all entries whose TTL has elapsed. */
|
|
@@ -308,9 +334,27 @@ function pageCacheTtl(spec, dev, defaultCache) {
|
|
|
308
334
|
return cfg?.maxAge || 0
|
|
309
335
|
}
|
|
310
336
|
|
|
311
|
-
/**
|
|
337
|
+
/**
|
|
338
|
+
* Build the in-process page cache key.
|
|
339
|
+
*
|
|
340
|
+
* The key is scoped to the route + path params + only the query keys the spec
|
|
341
|
+
* explicitly opts into via `spec.cacheVary`. The full query string is NOT part
|
|
342
|
+
* of the key by default: an unauthenticated client can append arbitrary query
|
|
343
|
+
* params (?n=1, ?n=2, …) that don't change a cacheable page's output, and
|
|
344
|
+
* including them would let that client mint unbounded distinct cache entries
|
|
345
|
+
* (memory-exhaustion DoS). Pages whose output genuinely depends on a query
|
|
346
|
+
* param declare it in `cacheVary: ['q']`.
|
|
347
|
+
*/
|
|
312
348
|
function buildCacheKey(spec, ctx) {
|
|
313
|
-
|
|
349
|
+
let varied = ''
|
|
350
|
+
if (Array.isArray(spec.cacheVary) && spec.cacheVary.length) {
|
|
351
|
+
const picked = {}
|
|
352
|
+
for (const k of spec.cacheVary) {
|
|
353
|
+
if (ctx.query[k] !== undefined) picked[k] = ctx.query[k]
|
|
354
|
+
}
|
|
355
|
+
varied = JSON.stringify(picked)
|
|
356
|
+
}
|
|
357
|
+
return spec.route + '\0' + JSON.stringify(ctx.params) + '\0' + varied
|
|
314
358
|
}
|
|
315
359
|
|
|
316
360
|
/**
|
|
@@ -517,8 +561,10 @@ export async function createServer(entries, options = {}) {
|
|
|
517
561
|
}
|
|
518
562
|
}
|
|
519
563
|
|
|
520
|
-
// Per-host brand cache — avoids hitting the data store on every request
|
|
521
|
-
|
|
564
|
+
// Per-host brand cache — avoids hitting the data store on every request.
|
|
565
|
+
// Capped (Host header is attacker-controlled, so unbounded distinct keys
|
|
566
|
+
// would otherwise be a memory-exhaustion vector).
|
|
567
|
+
const brandCache = new TtlCache(500)
|
|
522
568
|
brandCache.startEviction(30_000) // brand TTL is 60s, scan every 30s
|
|
523
569
|
|
|
524
570
|
// Load manifest — maps source hydrate paths to production bundle paths
|
|
@@ -554,14 +600,19 @@ export async function createServer(entries, options = {}) {
|
|
|
554
600
|
// Build route table — let so it can be swapped on hot reload
|
|
555
601
|
let router = buildRouter(specs)
|
|
556
602
|
|
|
603
|
+
// Resolve the logger module once at startup rather than dynamically importing
|
|
604
|
+
// it on every request's `finish` event. `logRequest` stays null until loaded.
|
|
605
|
+
let logRequest = null
|
|
606
|
+
if (!quiet) {
|
|
607
|
+
import('../cli/logger.js').then(m => { logRequest = m.request }).catch(() => {})
|
|
608
|
+
}
|
|
609
|
+
|
|
557
610
|
const server = http.createServer(async (req, res) => {
|
|
558
611
|
const reqStart = Date.now()
|
|
559
612
|
res.on('finish', () => {
|
|
560
|
-
if (
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
logRequest({ method: req.method, pathname: url.pathname, status: res.statusCode, ms: Date.now() - reqStart })
|
|
564
|
-
}).catch(() => {})
|
|
613
|
+
if (logRequest) {
|
|
614
|
+
const url = new URL(req.url, `http://localhost:${port}`)
|
|
615
|
+
logRequest({ method: req.method, pathname: url.pathname, status: res.statusCode, ms: Date.now() - reqStart })
|
|
565
616
|
}
|
|
566
617
|
})
|
|
567
618
|
try {
|
|
@@ -691,6 +742,13 @@ export async function createServer(entries, options = {}) {
|
|
|
691
742
|
// Canonical path follows the trailingSlash mode so the <link> is consistent with redirects.
|
|
692
743
|
// spec.meta.canonical (string or function) is resolved inside each handler, where serverState
|
|
693
744
|
// is available — allowing dynamic canonicals from server fetcher results.
|
|
745
|
+
//
|
|
746
|
+
// SECURITY: proto/host come from client-controllable headers (x-forwarded-*,
|
|
747
|
+
// Host). They are HTML-escaped before being emitted into <link rel=canonical>
|
|
748
|
+
// and og:* tags, so this is not an XSS vector — but if your edge/proxy does
|
|
749
|
+
// not pin or validate these headers, an attacker can influence the canonical
|
|
750
|
+
// URL (SEO/canonical poisoning). Trust x-forwarded-* only behind a proxy you
|
|
751
|
+
// control; set spec.meta.canonical to a fixed string to opt out entirely.
|
|
694
752
|
const proto = req.headers['x-forwarded-proto'] || 'http'
|
|
695
753
|
const host = req.headers['x-forwarded-host'] || req.headers.host || `localhost:${port}`
|
|
696
754
|
const canonicalPath = trailingSlash === 'add' && pathname !== '/'
|
|
@@ -1112,7 +1170,7 @@ async function handleRawResponse(spec, ctx, req, res, dev) {
|
|
|
1112
1170
|
let content
|
|
1113
1171
|
|
|
1114
1172
|
if (!dev && spec.serverTtl) {
|
|
1115
|
-
const key = 'raw:' + spec
|
|
1173
|
+
const key = 'raw:' + buildCacheKey(spec, ctx)
|
|
1116
1174
|
content = serverDataCache.get(key)
|
|
1117
1175
|
if (!content) {
|
|
1118
1176
|
const serverState = await resolveServerState(spec, ctx)
|
|
@@ -1181,9 +1239,15 @@ function matchRoute(router, pathname) {
|
|
|
1181
1239
|
if (!match) continue
|
|
1182
1240
|
|
|
1183
1241
|
const params = {}
|
|
1184
|
-
|
|
1185
|
-
params
|
|
1186
|
-
|
|
1242
|
+
try {
|
|
1243
|
+
route.params.forEach((name, i) => {
|
|
1244
|
+
params[name] = decodeURIComponent(match[i + 1])
|
|
1245
|
+
})
|
|
1246
|
+
} catch {
|
|
1247
|
+
// Malformed percent-encoding (e.g. /%ZZ) — treat as no match (404)
|
|
1248
|
+
// rather than letting decodeURIComponent throw a 500.
|
|
1249
|
+
continue
|
|
1250
|
+
}
|
|
1187
1251
|
|
|
1188
1252
|
return { spec: route.spec, params }
|
|
1189
1253
|
}
|
|
@@ -1433,7 +1497,9 @@ function resolveSpec(spec, hydrateMap) {
|
|
|
1433
1497
|
*/
|
|
1434
1498
|
function serveStatic(req, res, staticDir, dev = false) {
|
|
1435
1499
|
const url = new URL(req.url, 'http://localhost')
|
|
1436
|
-
|
|
1500
|
+
let pathname
|
|
1501
|
+
try { pathname = decodeURIComponent(url.pathname) }
|
|
1502
|
+
catch { return false } // malformed percent-encoding → not a static file
|
|
1437
1503
|
|
|
1438
1504
|
// Prevent directory traversal
|
|
1439
1505
|
const filePath = path.join(staticDir, pathname)
|
|
@@ -1516,6 +1582,7 @@ function escHtml(str) {
|
|
|
1516
1582
|
.replace(/</g, '<')
|
|
1517
1583
|
.replace(/>/g, '>')
|
|
1518
1584
|
.replace(/"/g, '"')
|
|
1585
|
+
.replace(/'/g, ''')
|
|
1519
1586
|
}
|
|
1520
1587
|
|
|
1521
1588
|
function notFoundHtml(pathname) {
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
6
|
import http from 'http'
|
|
7
|
-
import { createServer } from './index.js'
|
|
7
|
+
import { createServer, TtlCache } from './index.js'
|
|
8
8
|
|
|
9
9
|
// ---------------------------------------------------------------------------
|
|
10
10
|
// Test runner
|
|
@@ -1246,6 +1246,111 @@ await test('health check fires before route matching — shadows a user spec at
|
|
|
1246
1246
|
|
|
1247
1247
|
// ---------------------------------------------------------------------------
|
|
1248
1248
|
|
|
1249
|
+
console.log('\nMalformed request handling\n')
|
|
1250
|
+
|
|
1251
|
+
await test('malformed percent-encoding in path returns 404, not 500', async () => {
|
|
1252
|
+
await withServer([helloSpec], { stream: false }, async (port) => {
|
|
1253
|
+
const { status } = await get(port, '/%ZZ')
|
|
1254
|
+
assert(status === 404, `Expected 404 for malformed encoding, got ${status}`)
|
|
1255
|
+
})
|
|
1256
|
+
})
|
|
1257
|
+
|
|
1258
|
+
await test('malformed percent-encoding in param route returns 404', async () => {
|
|
1259
|
+
await withServer([productSpec], { stream: false }, async (port) => {
|
|
1260
|
+
const { status } = await get(port, '/products/%E0%A4%A')
|
|
1261
|
+
assert(status === 404, `Expected 404 for malformed param, got ${status}`)
|
|
1262
|
+
})
|
|
1263
|
+
})
|
|
1264
|
+
|
|
1265
|
+
// ---------------------------------------------------------------------------
|
|
1266
|
+
|
|
1267
|
+
console.log('\nPage cache — query-string DoS guard\n')
|
|
1268
|
+
|
|
1269
|
+
await test('query string is NOT part of the cache key by default', async () => {
|
|
1270
|
+
// A cacheable page should serve identical cached HTML regardless of arbitrary
|
|
1271
|
+
// query params — they must not mint new cache entries.
|
|
1272
|
+
let renders = 0
|
|
1273
|
+
const spec = {
|
|
1274
|
+
route: '/cached',
|
|
1275
|
+
cache: { public: true, maxAge: 60 },
|
|
1276
|
+
view: () => `<h1>render-${++renders}</h1>`,
|
|
1277
|
+
}
|
|
1278
|
+
await withServer([spec], { stream: false }, async (port) => {
|
|
1279
|
+
const a = await get(port, '/cached?n=1')
|
|
1280
|
+
const b = await get(port, '/cached?n=2')
|
|
1281
|
+
const c = await get(port, '/cached?n=3')
|
|
1282
|
+
assert(a.body === b.body && b.body === c.body, 'Cached body should be identical across query strings')
|
|
1283
|
+
assert(a.body.includes('render-1'), `Expected first render cached, got: ${a.body}`)
|
|
1284
|
+
})
|
|
1285
|
+
})
|
|
1286
|
+
|
|
1287
|
+
await test('cacheVary keys DO produce distinct cache entries', async () => {
|
|
1288
|
+
let renders = 0
|
|
1289
|
+
const spec = {
|
|
1290
|
+
route: '/varied',
|
|
1291
|
+
cache: { public: true, maxAge: 60 },
|
|
1292
|
+
cacheVary: ['q'],
|
|
1293
|
+
server: { n: async () => ++renders },
|
|
1294
|
+
view: (_s, server) => `<h1>v-${server.n}</h1>`,
|
|
1295
|
+
}
|
|
1296
|
+
// server fetcher makes this non-cacheable for HTML, so vary only affects
|
|
1297
|
+
// the in-process key; assert distinct outputs per q value.
|
|
1298
|
+
await withServer([spec], { stream: false }, async (port) => {
|
|
1299
|
+
const a = await get(port, '/varied?q=apple')
|
|
1300
|
+
const b = await get(port, '/varied?q=banana')
|
|
1301
|
+
assert(a.body !== b.body, `Expected distinct outputs per cacheVary value, got identical: ${a.body}`)
|
|
1302
|
+
})
|
|
1303
|
+
})
|
|
1304
|
+
|
|
1305
|
+
// ---------------------------------------------------------------------------
|
|
1306
|
+
|
|
1307
|
+
console.log('\nTtlCache — bounded LRU\n')
|
|
1308
|
+
|
|
1309
|
+
await test('evicts least-recently-used entry when over capacity', () => {
|
|
1310
|
+
const c = new TtlCache(2)
|
|
1311
|
+
c.set('a', 1, 60)
|
|
1312
|
+
c.set('b', 2, 60)
|
|
1313
|
+
c.set('c', 3, 60) // exceeds cap → evicts 'a' (LRU)
|
|
1314
|
+
assert(c.get('a') === undefined, 'Expected a to be evicted')
|
|
1315
|
+
assert(c.get('b') === 2, 'Expected b to remain')
|
|
1316
|
+
assert(c.get('c') === 3, 'Expected c to remain')
|
|
1317
|
+
})
|
|
1318
|
+
|
|
1319
|
+
await test('get() refreshes recency so the touched entry survives eviction', () => {
|
|
1320
|
+
const c = new TtlCache(2)
|
|
1321
|
+
c.set('a', 1, 60)
|
|
1322
|
+
c.set('b', 2, 60)
|
|
1323
|
+
c.get('a') // 'a' now most-recently-used
|
|
1324
|
+
c.set('c', 3, 60) // evicts 'b', not 'a'
|
|
1325
|
+
assert(c.get('a') === 1, 'Expected a to survive (recently used)')
|
|
1326
|
+
assert(c.get('b') === undefined, 'Expected b to be evicted')
|
|
1327
|
+
})
|
|
1328
|
+
|
|
1329
|
+
await test('many distinct keys never exceed the cap', () => {
|
|
1330
|
+
const c = new TtlCache(100)
|
|
1331
|
+
for (let i = 0; i < 10_000; i++) c.set(`k${i}`, i, 60)
|
|
1332
|
+
assert(c._store.size === 100, `Expected size capped at 100, got ${c._store.size}`)
|
|
1333
|
+
})
|
|
1334
|
+
|
|
1335
|
+
await test('expired entries are not returned', () => {
|
|
1336
|
+
const c = new TtlCache(10)
|
|
1337
|
+
c.set('x', 1, -1) // already expired
|
|
1338
|
+
assert(c.get('x') === undefined, 'Expected expired entry to be absent')
|
|
1339
|
+
})
|
|
1340
|
+
|
|
1341
|
+
// ---------------------------------------------------------------------------
|
|
1342
|
+
|
|
1343
|
+
console.log('\nCompression\n')
|
|
1344
|
+
|
|
1345
|
+
await test('serves brotli-compressed HTML when br is accepted', async () => {
|
|
1346
|
+
await withServer([helloSpec], { stream: false }, async (port) => {
|
|
1347
|
+
const { headers } = await request(port, 'GET', '/hello', { 'Accept-Encoding': 'br' })
|
|
1348
|
+
assert(headers['content-encoding'] === 'br', `Expected br encoding, got ${headers['content-encoding']}`)
|
|
1349
|
+
})
|
|
1350
|
+
})
|
|
1351
|
+
|
|
1352
|
+
// ---------------------------------------------------------------------------
|
|
1353
|
+
|
|
1249
1354
|
console.log('\nGraceful shutdown\n')
|
|
1250
1355
|
|
|
1251
1356
|
await test('shutdown() is returned from createServer', async () => {
|
package/src/spec/schema.js
CHANGED
|
@@ -371,6 +371,16 @@ export function validateSpec(spec) {
|
|
|
371
371
|
}
|
|
372
372
|
}
|
|
373
373
|
|
|
374
|
+
// cacheVary — query-string keys that affect the cached HTML for this page.
|
|
375
|
+
// By default the in-process page cache ignores the query string entirely (so a
|
|
376
|
+
// client cannot mint unbounded cache entries with arbitrary ?n=… params). Pages
|
|
377
|
+
// whose output depends on a query param must list it here, e.g. cacheVary: ['q'].
|
|
378
|
+
if (spec.cacheVary !== undefined) {
|
|
379
|
+
if (!Array.isArray(spec.cacheVary) || !spec.cacheVary.every(k => typeof k === 'string')) {
|
|
380
|
+
errors.push('spec.cacheVary must be an array of query-parameter name strings, e.g. [\'q\', \'page\']')
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
|
|
374
384
|
// hydrate — auto-derived by the framework, should never be set manually
|
|
375
385
|
if (spec.hydrate !== undefined) {
|
|
376
386
|
warnings.push('spec.hydrate is set manually — it is auto-derived by the framework from the URL entry passed to createServer. Remove it from the spec.')
|