@invisibleloop/pulse 0.15.1 → 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 +35 -17
- 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 +2 -2
- 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/guide-routing.md +91 -0
- 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 +219 -0
- package/src/mcp/server.js +24 -3
- package/src/mcp/validate-worker.js +18 -0
- 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
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
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MCP integrity tests — run with: node src/mcp/mcp.test.js
|
|
3
|
+
*
|
|
4
|
+
* Guards against guide file drift: every guide-*.md file under src/agent/
|
|
5
|
+
* must be registered as a named MCP resource in server.js, and every
|
|
6
|
+
* readFileSync call in server.js that references a guide-*.md must point
|
|
7
|
+
* to a file that actually exists.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import fs from 'node:fs'
|
|
11
|
+
import path from 'node:path'
|
|
12
|
+
import { fileURLToPath } from 'node:url'
|
|
13
|
+
|
|
14
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url))
|
|
15
|
+
const ROOT = path.resolve(__dirname, '../..')
|
|
16
|
+
|
|
17
|
+
let passed = 0
|
|
18
|
+
let failed = 0
|
|
19
|
+
|
|
20
|
+
function test(label, fn) {
|
|
21
|
+
try {
|
|
22
|
+
fn()
|
|
23
|
+
console.log(` ✓ ${label}`)
|
|
24
|
+
passed++
|
|
25
|
+
} catch (e) {
|
|
26
|
+
console.log(` ✗ ${label}`)
|
|
27
|
+
console.log(` ${e.message}`)
|
|
28
|
+
failed++
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function assert(condition, msg) {
|
|
33
|
+
if (!condition) throw new Error(msg || 'Assertion failed')
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// ── Read source files ────────────────────────────────────────────────────────
|
|
37
|
+
|
|
38
|
+
const serverSrc = fs.readFileSync(path.join(__dirname, 'server.js'), 'utf8')
|
|
39
|
+
const agentDir = path.join(ROOT, 'src/agent')
|
|
40
|
+
|
|
41
|
+
// All guide-*.md files that exist on disk
|
|
42
|
+
const guideFiles = fs.readdirSync(agentDir)
|
|
43
|
+
.filter(f => f.startsWith('guide-') && f.endsWith('.md'))
|
|
44
|
+
.sort()
|
|
45
|
+
|
|
46
|
+
// All guide-*.md files referenced via readFileSync in server.js
|
|
47
|
+
// Pattern: new URL('../agent/guide-*.md', import.meta.url)
|
|
48
|
+
const referencedFiles = [...serverSrc.matchAll(/new URL\(['"`][^'"`]*?(guide-[^'"`/]+\.md)['"`]/g)]
|
|
49
|
+
.map(m => m[1])
|
|
50
|
+
|
|
51
|
+
// All guide-*.md filenames registered as MCP resource URIs
|
|
52
|
+
const registeredUris = [...serverSrc.matchAll(/uri:\s*['"`](pulse:\/\/guide\/[^'"`]+)['"`]/g)]
|
|
53
|
+
.map(m => m[1])
|
|
54
|
+
|
|
55
|
+
// ── Tests ─────────────────────────────────────────────────────────────────────
|
|
56
|
+
|
|
57
|
+
console.log('\nGuide file coverage\n')
|
|
58
|
+
|
|
59
|
+
test('guide-routing.md exists on disk', () => {
|
|
60
|
+
assert(fs.existsSync(path.join(agentDir, 'guide-routing.md')), 'guide-routing.md not found')
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
test('guide-components.md exists on disk', () => {
|
|
64
|
+
assert(fs.existsSync(path.join(agentDir, 'guide-components.md')), 'guide-components.md not found')
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
for (const file of guideFiles) {
|
|
68
|
+
test(`${file} is referenced by readFileSync in server.js`, () => {
|
|
69
|
+
assert(
|
|
70
|
+
referencedFiles.includes(file),
|
|
71
|
+
`${file} exists in src/agent/ but is not loaded by server.js via readFileSync. ` +
|
|
72
|
+
`Either register it as a guide resource or delete it.`
|
|
73
|
+
)
|
|
74
|
+
})
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
for (const file of referencedFiles) {
|
|
78
|
+
test(`${file} (referenced in server.js) exists on disk`, () => {
|
|
79
|
+
assert(
|
|
80
|
+
fs.existsSync(path.join(agentDir, file)),
|
|
81
|
+
`server.js references ${file} via readFileSync but the file does not exist in src/agent/`
|
|
82
|
+
)
|
|
83
|
+
})
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
test('every guide-*.md on disk has a corresponding pulse://guide/* MCP resource', () => {
|
|
87
|
+
// Map file names to expected URI slugs: guide-design-references.md → pulse://guide/design-references
|
|
88
|
+
const fileToUri = (f) => 'pulse://guide/' + f.replace(/^guide-/, '').replace(/\.md$/, '')
|
|
89
|
+
const missing = guideFiles.filter(f => !registeredUris.includes(fileToUri(f)))
|
|
90
|
+
assert(
|
|
91
|
+
missing.length === 0,
|
|
92
|
+
`These guide files have no registered MCP resource URI:\n ${missing.join('\n ')}\n` +
|
|
93
|
+
`Add them to GUIDE_RESOURCES in server.js or delete them.`
|
|
94
|
+
)
|
|
95
|
+
})
|
|
96
|
+
|
|
97
|
+
test('no orphaned guide.md (monolithic legacy file should not exist)', () => {
|
|
98
|
+
assert(
|
|
99
|
+
!fs.existsSync(path.join(agentDir, 'guide.md')),
|
|
100
|
+
'guide.md exists in src/agent/ — this is the old monolithic guide and should be deleted. ' +
|
|
101
|
+
'Content should live in the split guide-*.md files.'
|
|
102
|
+
)
|
|
103
|
+
})
|
|
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
|
+
|
|
216
|
+
// ── Result ───────────────────────────────────────────────────────────────────
|
|
217
|
+
|
|
218
|
+
console.log(`\n${passed} passed, ${failed} failed\n`)
|
|
219
|
+
if (failed > 0) process.exit(1)
|
package/src/mcp/server.js
CHANGED
|
@@ -1057,12 +1057,33 @@ ${(() => {
|
|
|
1057
1057
|
checks.push('✓ No obvious hex colours in view')
|
|
1058
1058
|
}
|
|
1059
1059
|
|
|
1060
|
+
// XSS test coverage — check whether the test file includes a <script>alert assertion
|
|
1061
|
+
// for each user-controlled string interpolated into the view.
|
|
1062
|
+
// Heuristic: if the view interpolates any exported string variable (escape(), or direct
|
|
1063
|
+
// string props), the test file should contain a '<script>' XSS assertion.
|
|
1064
|
+
if (file) {
|
|
1065
|
+
const testFile = file.replace(/\.js$/, '.test.js')
|
|
1066
|
+
let xssNote = ''
|
|
1067
|
+
if (fs.existsSync(testFile)) {
|
|
1068
|
+
const testSrc = fs.readFileSync(testFile, 'utf8')
|
|
1069
|
+
const hasXssAssertion = testSrc.includes('<script>') || testSrc.includes('xss') || testSrc.includes('script>alert')
|
|
1070
|
+
if (!hasXssAssertion) {
|
|
1071
|
+
xssNote = '⚠ **No XSS test found** — test file exists but has no `<script>alert` assertion. Add at least one test that passes a `\'<script>alert(1)</script>\'` string as a user-controlled input and asserts it does not appear unescaped in the output.'
|
|
1072
|
+
} else {
|
|
1073
|
+
xssNote = '✓ XSS test assertion present'
|
|
1074
|
+
}
|
|
1075
|
+
} else {
|
|
1076
|
+
xssNote = '○ No test file found — create one at ' + path.basename(testFile)
|
|
1077
|
+
}
|
|
1078
|
+
checks.push(xssNote)
|
|
1079
|
+
}
|
|
1080
|
+
|
|
1060
1081
|
// Creative override detection — check if spec declares component-free mode
|
|
1061
1082
|
const creativeOverride = /component.free|creative\s+override|raw\s+HTML\s+throughout/i.test(source)
|
|
1062
1083
|
checks.push(creativeOverride
|
|
1063
|
-
? '⚡ **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.'
|
|
1064
1085
|
: '○ No creative override declared — component checks apply')
|
|
1065
|
-
|
|
1086
|
+
|
|
1066
1087
|
return checks.join('\n')
|
|
1067
1088
|
})()}
|
|
1068
1089
|
|
|
@@ -1101,7 +1122,7 @@ Work through every item. Fix anything that fails. Refer to the spec source at ${
|
|
|
1101
1122
|
|
|
1102
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.
|
|
1103
1124
|
|
|
1104
|
-
- [ ] **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.
|
|
1105
1126
|
- [ ] No \`data-event\` on text inputs — this destroys focus on every keystroke
|
|
1106
1127
|
- [ ] No \`className\`, \`htmlFor\`, \`onClick=\`, or other React patterns
|
|
1107
1128
|
- [ ] No hardcoded hex colours in view — only \`var(--ui-*)\` tokens (anchor \`href="#id"\` values are fine — those are not colours)
|
|
@@ -156,6 +156,24 @@ try {
|
|
|
156
156
|
warnings.push('Inline <script> block detected in view — Pulse\'s CSP (script-src with nonces) will block these scripts at runtime. Move JavaScript to a file in public/ and reference it via meta.scripts: [\'/my-script.js\'] in the spec instead.')
|
|
157
157
|
}
|
|
158
158
|
|
|
159
|
+
// External image URLs in src attributes — will be blocked by Pulse's default CSP (img-src 'self')
|
|
160
|
+
// unless the domain is explicitly listed in pulse.config.js under csp['img-src'].
|
|
161
|
+
// Flag any http/https src that isn't a data URI or relative path.
|
|
162
|
+
const externalImgSrcs = [...html.matchAll(/\bsrc=["'](https?:\/\/[^"']+)["']/gi)]
|
|
163
|
+
.map(m => m[1])
|
|
164
|
+
if (externalImgSrcs.length) {
|
|
165
|
+
const domains = [...new Set(externalImgSrcs.map(u => {
|
|
166
|
+
try { return new URL(u).origin } catch { return u }
|
|
167
|
+
}))].slice(0, 3)
|
|
168
|
+
warnings.push(
|
|
169
|
+
`External image URL${externalImgSrcs.length > 1 ? 's' : ''} in view (${domains.join(', ')}) — ` +
|
|
170
|
+
`Pulse's default CSP blocks cross-origin images. ` +
|
|
171
|
+
`Either download the images to public/ and use a relative path (/images/foo.jpg), ` +
|
|
172
|
+
`or allow the domain in pulse.config.js:\n` +
|
|
173
|
+
` export default { csp: { 'img-src': ['${domains[0]}'] } }`
|
|
174
|
+
)
|
|
175
|
+
}
|
|
176
|
+
|
|
159
177
|
// Hardcoded hex colours in view HTML — must use var(--ui-*) tokens instead.
|
|
160
178
|
// Matches: style="...color:#hex..." or style="...background:#hex..."
|
|
161
179
|
// Allows: href="#section-id" (anchor links 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
|