@invisibleloop/pulse 0.6.1 → 0.7.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@invisibleloop/pulse",
3
- "version": "0.6.1",
3
+ "version": "0.7.0",
4
4
  "type": "module",
5
5
  "description": "AI-first frontend framework. The spec is the source of truth.",
6
6
  "license": "MIT",
@@ -57,7 +57,7 @@
57
57
  "scripts": {
58
58
  "dev": "node --watch src/cli/dev.js",
59
59
  "build": "node scripts/build.js",
60
- "test": "node src/spec/schema.test.js && node src/runtime/runtime.test.js && node src/runtime/morph.test.js && node src/runtime/ssr.test.js && node src/runtime/image.test.js && node src/server/server.test.js && node src/cli/cli.test.js && node src/ui/ui.test.js && node src/testing/testing.test.js && node src/store/store.test.js",
60
+ "test": "node src/spec/schema.test.js && node src/runtime/runtime.test.js && node src/runtime/morph.test.js && node src/runtime/ssr.test.js && node src/runtime/image.test.js && node src/server/server.test.js && node src/cli/cli.test.js && node src/ui/ui.test.js && node src/testing/testing.test.js && node src/store/store.test.js && node src/md/md.test.js && node scripts/strip-server.test.js",
61
61
  "typecheck": "tsc --noEmit",
62
62
  "docs": "node --watch src/cli/dev.js --root docs --port 4000",
63
63
  "docs:start": "node docs/server.js"
@@ -1 +1 @@
1
- 0.6.1
1
+ 0.7.0
@@ -2729,6 +2729,8 @@ fieldset[disabled] .ui-rating-stars .ui-rating-star:hover ~ .ui-rating-star { co
2729
2729
  .ui-prose ul { list-style-type: disc; }
2730
2730
  .ui-prose ol { list-style-type: decimal; }
2731
2731
  .ui-prose ul ul { list-style-type: circle; }
2732
+ .ui-prose ul.contains-task-list { list-style: none; padding-left: 0; }
2733
+ .ui-prose li.task-list-item input[type="checkbox"] { margin-right: .4em; }
2732
2734
  .ui-prose li { margin-bottom: .375em; }
2733
2735
  .ui-prose li > ul,
2734
2736
  .ui-prose li > ol { margin-top: .375em; margin-bottom: .375em; }
package/scripts/build.js CHANGED
@@ -38,6 +38,7 @@ const SERVER_ONLY_KEYS = ['server', 'meta', 'guard', 'serverTimeout', 'contentTy
38
38
  * Pulse subpath exports that are server-only (use Node built-ins).
39
39
  * Imports from these paths are removed from client bundles along with any
40
40
  * top-level variable declarations whose right-hand side calls the imported names.
41
+ * All `node:*` built-ins are also stripped automatically via the import regex.
41
42
  */
42
43
  const SERVER_ONLY_IMPORTS = ['@invisibleloop/pulse/md']
43
44
 
@@ -54,46 +55,60 @@ function stripServerOnlyKeys(source) {
54
55
  }
55
56
 
56
57
  /**
57
- * Strip import statements for known server-only modules and any top-level
58
- * variable declarations that called those imported functions.
58
+ * Strip import statements for server-only modules and any top-level
59
+ * variable declarations whose right-hand side calls the imported names.
59
60
  *
60
- * Handles patterns like:
61
- * import { md } from '@invisibleloop/pulse/md'
62
- * const fetchPost = md('src/content/blog/:slug.md')
61
+ * Strips:
62
+ * - All `node:*` built-in imports (fs, path, url, crypto, …)
63
+ * - Named entries in SERVER_ONLY_IMPORTS (@invisibleloop/pulse/md, …)
64
+ *
65
+ * Also removes associated top-level declarations, e.g.:
66
+ * import fs from 'node:fs' → removed
67
+ * import { md } from '@invisibleloop/pulse/md' → removed
68
+ * const fetchPost = md('src/content/blog.md') → removed
63
69
  */
64
70
  function stripServerOnlyImports(source) {
65
- for (const mod of SERVER_ONLY_IMPORTS) {
66
- const escaped = mod.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
67
- const importRe = new RegExp(
68
- `^[ \\t]*import\\s+(?:\\{[^}]*\\}|[\\w*]+(?:\\s+as\\s+\\w+)?)\\s+from\\s+['"]${escaped}['"]\\s*;?[ \\t]*\\n?`,
69
- 'gm'
70
- )
71
-
72
- // Collect imported binding names before removing the statement
73
- const importedNames = new Set()
74
- for (const m of source.matchAll(importRe)) {
75
- const named = m[0].match(/\{\s*([^}]*)\s*\}/)
76
- if (named) {
77
- for (const part of named[1].split(',')) {
78
- const name = part.trim().split(/\s+as\s+/).pop().trim()
79
- if (name) importedNames.add(name)
80
- }
71
+ // Match any import whose specifier starts with `node:` OR is in SERVER_ONLY_IMPORTS.
72
+ // Covers: default imports, named imports, namespace imports.
73
+ const importRe =
74
+ /^[ \t]*import\s+(?:\{[^}]*\}|[\w*]+(?:\s+as\s+\w+)?)\s+from\s+['"](?:node:[^'"]+|__SERVER_ONLY__)['"]\s*;?[ \t]*\n?/gm
75
+
76
+ // Build a combined pattern for SERVER_ONLY_IMPORTS entries
77
+ const extraPattern = SERVER_ONLY_IMPORTS
78
+ .map(m => m.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'))
79
+ .join('|')
80
+
81
+ // Single regex covering node:* and every listed server-only module
82
+ const combinedRe = new RegExp(
83
+ importRe.source.replace('__SERVER_ONLY__', extraPattern),
84
+ 'gm'
85
+ )
86
+
87
+ // Collect all imported binding names before stripping
88
+ const importedNames = new Set()
89
+ for (const m of source.matchAll(combinedRe)) {
90
+ const named = m[0].match(/\{\s*([^}]*)\s*\}/)
91
+ if (named) {
92
+ for (const part of named[1].split(',')) {
93
+ const name = part.trim().split(/\s+as\s+/).pop().trim()
94
+ if (name) importedNames.add(name)
81
95
  }
82
- const def = m[0].match(/import\s+([\w]+)\s+from/)
83
- if (def) importedNames.add(def[1])
84
96
  }
97
+ const def = m[0].match(/import\s+([\w]+)\s+from/)
98
+ if (def) importedNames.add(def[1])
99
+ }
85
100
 
86
- source = source.replace(importRe, '')
101
+ source = source.replace(combinedRe, '')
87
102
 
88
- // Remove top-level: const/let/var X = importedFn(...)
89
- for (const name of importedNames) {
90
- const declRe = new RegExp(
91
- `^[ \\t]*(?:const|let|var)\\s+\\w+\\s*=\\s*${name}\\s*\\([^\\n]*\\)\\s*;?[ \\t]*\\n?`,
92
- 'gm'
93
- )
94
- source = source.replace(declRe, '')
95
- }
103
+ // Remove top-level: const/let/var X = importedName... (call, method call, or property)
104
+ for (const name of importedNames) {
105
+ const declRe = new RegExp(
106
+ `^[ \\t]*(?:const|let|var)\\s+\\w+\\s*=\\s*${name}\\b[^\\n]*;?[ \\t]*\\n?`,
107
+ 'gm'
108
+ )
109
+ source = source.replace(declRe, '')
96
110
  }
111
+
97
112
  return source
98
113
  }
99
114
 
@@ -130,7 +145,38 @@ function isInsideString(source, pos) {
130
145
  if (c === '"' || c === "'" || c === '`') { stack.push(c); i++; continue }
131
146
  i++; continue
132
147
  }
133
- // top-level
148
+ // Top level — handle comments and regex literals before string delimiters.
149
+ // Without this, a `"` inside a regex literal (e.g. /"/g) or a comment would
150
+ // be mistaken for an opening string delimiter, causing all subsequent keys to
151
+ // be treated as "inside a string" and never stripped.
152
+ if (c === '/') {
153
+ if (source[i + 1] === '/') { // line comment — skip to end of line
154
+ while (i < pos && source[i] !== '\n') i++
155
+ continue
156
+ }
157
+ if (source[i + 1] === '*') { // block comment — skip to */
158
+ const end = source.indexOf('*/', i + 2)
159
+ i = end < 0 ? pos : end + 2
160
+ continue
161
+ }
162
+ // Regex literal heuristic: / is a regex when not preceded by ) ] or alnum.
163
+ // Division (a / b) always follows one of those; regex follows operators, keywords, etc.
164
+ let prev = ''
165
+ for (let k = i - 1; k >= 0; k--) {
166
+ if (source[k] !== ' ' && source[k] !== '\t') { prev = source[k]; break }
167
+ }
168
+ if (!/[a-zA-Z0-9_$)\]]/.test(prev)) {
169
+ i++ // skip opening /
170
+ while (i < pos) {
171
+ if (source[i] === '\\') { i += 2; continue }
172
+ if (source[i] === '/') { i++; break } // closing /
173
+ if (source[i] === '\n') break // regex can't span lines
174
+ i++
175
+ }
176
+ while (i < pos && /[gimsuy]/.test(source[i])) i++ // skip flags
177
+ continue
178
+ }
179
+ }
134
180
  if (c === '"' || c === "'" || c === '`') { stack.push(c) }
135
181
  i++
136
182
  }
@@ -364,7 +410,15 @@ const NAVIGATE_PATH = new URL('../src/runtime/navigate.js', import.meta.url).pat
364
410
 
365
411
  const PAGES_DIR = path.join(ROOT, 'src', 'pages')
366
412
 
367
- const bootstrapFiles = pages.map(({ filePath }) => {
413
+ const bootstrapFiles = pages.flatMap(({ filePath }) => {
414
+ // Strip server-only keys/imports to check what's left for the client.
415
+ // Skip specs with no view — these are API routes or raw content specs
416
+ // (using render: instead of view:) that have no client-side code. Including
417
+ // them in the bundle fragments the shared runtime chunk unnecessarily.
418
+ const source = fs.readFileSync(filePath, 'utf8')
419
+ const stripped = stripServerOnlyImports(stripServerOnlyKeys(source))
420
+ if (!/\bview\s*:/.test(stripped)) return []
421
+
368
422
  // Use the path relative to src/pages/ so nested pages get unique names.
369
423
  // e.g. src/pages/api/products.js → 'api--products' (not 'products')
370
424
  const relToPages = path.relative(PAGES_DIR, filePath)
@@ -389,7 +443,7 @@ if (root && !root.dataset.pulseMounted) {
389
443
  export default spec
390
444
  `)
391
445
 
392
- return { filePath, bootstrapPath, name }
446
+ return [{ filePath, bootstrapPath, name }]
393
447
  })
394
448
 
395
449
  // ---------------------------------------------------------------------------
@@ -16,32 +16,32 @@ function stripServerOnlyKeys(source) {
16
16
  }
17
17
 
18
18
  function stripServerOnlyImports(source) {
19
- for (const mod of SERVER_ONLY_IMPORTS) {
20
- const escaped = mod.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
21
- const importRe = new RegExp(
22
- `^[ \\t]*import\\s+(?:\\{[^}]*\\}|[\\w*]+(?:\\s+as\\s+\\w+)?)\\s+from\\s+['"]${escaped}['"]\\s*;?[ \\t]*\\n?`,
23
- 'gm'
24
- )
25
- const importedNames = new Set()
26
- for (const m of source.matchAll(importRe)) {
27
- const named = m[0].match(/\{\s*([^}]*)\s*\}/)
28
- if (named) {
29
- for (const part of named[1].split(',')) {
30
- const name = part.trim().split(/\s+as\s+/).pop().trim()
31
- if (name) importedNames.add(name)
32
- }
19
+ const extraPattern = SERVER_ONLY_IMPORTS
20
+ .map(m => m.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'))
21
+ .join('|')
22
+ const combinedRe = new RegExp(
23
+ `^[ \\t]*import\\s+(?:\\{[^}]*\\}|[\\w*]+(?:\\s+as\\s+\\w+)?)\\s+from\\s+['"](?:node:[^'"]+|${extraPattern})['"]\\s*;?[ \\t]*\\n?`,
24
+ 'gm'
25
+ )
26
+ const importedNames = new Set()
27
+ for (const m of source.matchAll(combinedRe)) {
28
+ const named = m[0].match(/\{\s*([^}]*)\s*\}/)
29
+ if (named) {
30
+ for (const part of named[1].split(',')) {
31
+ const name = part.trim().split(/\s+as\s+/).pop().trim()
32
+ if (name) importedNames.add(name)
33
33
  }
34
- const def = m[0].match(/import\s+([\w]+)\s+from/)
35
- if (def) importedNames.add(def[1])
36
- }
37
- source = source.replace(importRe, '')
38
- for (const name of importedNames) {
39
- const declRe = new RegExp(
40
- `^[ \\t]*(?:const|let|var)\\s+\\w+\\s*=\\s*${name}\\s*\\([^\\n]*\\)\\s*;?[ \\t]*\\n?`,
41
- 'gm'
42
- )
43
- source = source.replace(declRe, '')
44
34
  }
35
+ const def = m[0].match(/import\s+([\w]+)\s+from/)
36
+ if (def) importedNames.add(def[1])
37
+ }
38
+ source = source.replace(combinedRe, '')
39
+ for (const name of importedNames) {
40
+ const declRe = new RegExp(
41
+ `^[ \\t]*(?:const|let|var)\\s+\\w+\\s*=\\s*${name}\\b[^\\n]*;?[ \\t]*\\n?`,
42
+ 'gm'
43
+ )
44
+ source = source.replace(declRe, '')
45
45
  }
46
46
  return source
47
47
  }
@@ -73,6 +73,18 @@ function isInsideString(source, pos) {
73
73
  if (c === '"' || c === "'" || c === '`') { stack.push(c); i++; continue }
74
74
  i++; continue
75
75
  }
76
+ if (c === '/') {
77
+ if (source[i + 1] === '/') { while (i < pos && source[i] !== '\n') i++; continue }
78
+ if (source[i + 1] === '*') { const end = source.indexOf('*/', i + 2); i = end < 0 ? pos : end + 2; continue }
79
+ let prev = ''
80
+ for (let k = i - 1; k >= 0; k--) { if (source[k] !== ' ' && source[k] !== '\t') { prev = source[k]; break } }
81
+ if (!/[a-zA-Z0-9_$)\]]/.test(prev)) {
82
+ i++
83
+ while (i < pos) { if (source[i] === '\\') { i += 2; continue } if (source[i] === '/') { i++; break } if (source[i] === '\n') break; i++ }
84
+ while (i < pos && /[gimsuy]/.test(source[i])) i++
85
+ continue
86
+ }
87
+ }
76
88
  if (c === '"' || c === "'" || c === '`') { stack.push(c) }
77
89
  i++
78
90
  }
@@ -257,6 +269,21 @@ test('server: inside a double-quoted string is not stripped',
257
269
  'export default {\n route: \'/docs\',\n view: () => "<pre>server: { data: async () => {} }</pre>"\n}'
258
270
  )
259
271
 
272
+ test('server/meta not fooled by regex literal containing double quote (/"/g)',
273
+ 'const esc = s => s.replace(/"/g, \'&quot;\')\nexport default {\n meta: { title: \'Page\', description: \'Desc\' },\n server: { data: async () => [] },\n view: () => \'\'\n}',
274
+ 'const esc = s => s.replace(/"/g, \'&quot;\')\nexport default {\n view: () => \'\'\n}'
275
+ )
276
+
277
+ test('server not fooled by line comment containing double quote',
278
+ 'export default {\n // server: "comment"\n server: { data: async () => [] },\n view: () => \'\'\n}',
279
+ 'export default {\n // server: "comment"\n view: () => \'\'\n}'
280
+ )
281
+
282
+ test('server not fooled by block comment containing double quote',
283
+ 'export default {\n /* server: "comment" */\n server: { data: async () => [] },\n view: () => \'\'\n}',
284
+ 'export default {\n /* server: "comment" */\n view: () => \'\'\n}'
285
+ )
286
+
260
287
  test('meta object is stripped (client runtime never reads spec.meta)',
261
288
  `export default {\n route: '/blog',\n meta: {\n title: async (ctx) => fetchPost(ctx).frontmatter.title,\n styles: ['/pulse-ui.css'],\n },\n view: () => ''\n}`,
262
289
  `export default {\n route: '/blog',\n view: () => ''\n}`
@@ -293,6 +320,82 @@ test('does not strip non-server imports',
293
320
  `import { nav, footer } from '@invisibleloop/pulse/ui'\nexport default {\n view: () => nav({})\n}`
294
321
  )
295
322
 
323
+ test('strips node:fs default import',
324
+ `import fs from 'node:fs'\nexport default {\n server: { data: async () => fs.readFileSync('x') },\n view: () => ''\n}`,
325
+ `export default {\n view: () => ''\n}`
326
+ )
327
+
328
+ test('strips node:fs/promises named import',
329
+ `import { readFile } from 'node:fs/promises'\nexport default {\n server: { data: async () => readFile('x') },\n view: () => ''\n}`,
330
+ `export default {\n view: () => ''\n}`
331
+ )
332
+
333
+ test('strips node:path and node:url together',
334
+ `import path from 'node:path'\nimport { fileURLToPath } from 'node:url'\nexport default {\n server: { data: async (ctx) => path.join('a', 'b') },\n view: () => ''\n}`,
335
+ `export default {\n view: () => ''\n}`
336
+ )
337
+
338
+ test('strips node:fs default import and top-level const using it',
339
+ `import fs from 'node:fs'\nconst DIR = fs.readdirSync('.')\nexport default {\n view: () => ''\n}`,
340
+ `export default {\n view: () => ''\n}`
341
+ )
342
+
343
+ // ---------------------------------------------------------------------------
344
+ // Dev-mode node:* import stripping (same regex used in src/cli/dev.js serveFile)
345
+ // ---------------------------------------------------------------------------
346
+
347
+ console.log('\nDev-mode node:* import stripping\n')
348
+
349
+ const DEV_NODE_RE = /^[ \t]*import\s+(?:\{[^}]*\}|[\w*]+(?:\s+as\s+\w+)?)\s+from\s+['"]node:[^'"]+['"]\s*;?[ \t]*\n?/gm
350
+
351
+ function devStrip(source) {
352
+ return source.replace(DEV_NODE_RE, '')
353
+ }
354
+
355
+ function devTest(label, input, expected) {
356
+ const got = devStrip(input).trim()
357
+ const exp = expected.trim()
358
+ if (got === exp) {
359
+ console.log(' \u2713 ' + label)
360
+ pass++
361
+ } else {
362
+ console.log(' \u2717 ' + label)
363
+ console.log(' expected: ' + JSON.stringify(exp))
364
+ console.log(' got: ' + JSON.stringify(got))
365
+ fail++
366
+ }
367
+ }
368
+
369
+ devTest('strips default import (node:fs)',
370
+ `import fs from 'node:fs'\nconst DIR = '/journal'\nexport default { view: () => '' }`,
371
+ `const DIR = '/journal'\nexport default { view: () => '' }`
372
+ )
373
+
374
+ devTest('strips named import (node:path)',
375
+ `import { join, resolve } from 'node:path'\nexport default { view: () => '' }`,
376
+ `export default { view: () => '' }`
377
+ )
378
+
379
+ devTest('strips multiple node: imports',
380
+ `import fs from 'node:fs'\nimport path from 'node:path'\nexport default { view: () => '' }`,
381
+ `export default { view: () => '' }`
382
+ )
383
+
384
+ devTest('strips node:fs/promises named import',
385
+ `import { readFile } from 'node:fs/promises'\nexport default { view: () => '' }`,
386
+ `export default { view: () => '' }`
387
+ )
388
+
389
+ devTest('does not strip non-node imports',
390
+ `import { nav } from '@invisibleloop/pulse/ui'\nexport default { view: () => '' }`,
391
+ `import { nav } from '@invisibleloop/pulse/ui'\nexport default { view: () => '' }`
392
+ )
393
+
394
+ devTest('leaves module-level constants intact',
395
+ `import fs from 'node:fs'\nconst JOURNAL_DIR = '/Users/andy/journal'\nexport default { view: () => '' }`,
396
+ `const JOURNAL_DIR = '/Users/andy/journal'\nexport default { view: () => '' }`
397
+ )
398
+
296
399
  // ---------------------------------------------------------------------------
297
400
 
298
401
  console.log(`\n${pass + fail} tests: ${pass} passed, ${fail} failed\n`)
package/src/cli/dev.js CHANGED
@@ -187,11 +187,18 @@ const { updateSpecs } = createServer(specs, {
187
187
 
188
188
  const serveFile = (filePath) => {
189
189
  if (!fs.existsSync(filePath) || !fs.statSync(filePath).isFile()) return false
190
+ // Strip node:* imports — they don't resolve in the browser. Server-only imports
191
+ // are only used inside spec.server functions which the client runtime never calls.
192
+ let content = fs.readFileSync(filePath, 'utf-8')
193
+ content = content.replace(
194
+ /^[ \t]*import\s+(?:\{[^}]*\}|[\w*]+(?:\s+as\s+\w+)?)\s+from\s+['"]node:[^'"]+['"]\s*;?[ \t]*\n?/gm,
195
+ ''
196
+ )
190
197
  res.writeHead(200, {
191
198
  'Content-Type': 'application/javascript',
192
199
  'Cache-Control': 'no-store',
193
200
  })
194
- fs.createReadStream(filePath).pipe(res)
201
+ res.end(content)
195
202
  return true
196
203
  }
197
204
 
@@ -95,6 +95,9 @@ ${port !== 3000 ? ` port: ${port},\n` : ''}}
95
95
 
96
96
  // settings.json — hooks that enforce correct agent behaviour
97
97
  write(targetDir, '.claude/settings.json', JSON.stringify({
98
+ enabledPlugins: {
99
+ 'frontend-design@claude-plugins-official': true,
100
+ },
98
101
  hooks: {
99
102
  SessionStart: [
100
103
  {
@@ -346,6 +349,19 @@ public/app.css ← global stylesheet
346
349
 
347
350
  The MCP guide is the single source of truth. Follow it for all technical decisions, component usage, and the mandatory verification workflow.
348
351
 
352
+ ## Design — do this before writing any HTML
353
+
354
+ Before planning any new page or component, use the \`frontend-design\` plugin to establish the visual direction:
355
+
356
+ 1. Call the plugin's design tools with the page brief (what it does, who it's for, the tone)
357
+ 2. The plugin will return decisions — colour palette, typography, spacing scale, component variants, layout approach
358
+ 3. **Apply those decisions as CSS custom property overrides in \`public/app.css\`** — not as inline styles or class hacks
359
+ 4. Document the chosen tokens at the top of \`public/app.css\` so every page stays consistent
360
+
361
+ The plugin informs *what* to build visually. The Pulse component library is *how* you build it. Use both — the plugin gives direction, the components implement it.
362
+
363
+ **Do not default to stock component styles.** If the design plugin gives you a colour, use it. If it suggests a layout pattern, follow it. Every page should reflect deliberate design decisions, not the component library defaults.
364
+
349
365
  ## Before writing any code
350
366
 
351
367
  **Always present a plan and wait for the user to confirm before writing a single line of code.**
@@ -36,6 +36,19 @@ if (!valid) {
36
36
 
37
37
  // Render the view and run additional HTML checks
38
38
  const warnings = [...schemaWarnings]
39
+
40
+ // Raw-response specs (contentType + render) produce non-HTML output —
41
+ // skip all view rendering and HTML-specific checks for them.
42
+ const isRawResponse = typeof spec.render === 'function' ||
43
+ (spec.contentType && !spec.contentType.startsWith('text/html'))
44
+
45
+ if (isRawResponse) {
46
+ process.stdout.write(warnings.length > 0
47
+ ? 'Valid ✓ — but fix these issues:\n' + warnings.map(w => ` ⚠ ${w}`).join('\n')
48
+ : 'Valid ✓')
49
+ process.exit(0)
50
+ }
51
+
39
52
  try {
40
53
  // Pass null for every declared server key so views that check `=== null`
41
54
  // degrade gracefully (showing error branches) rather than throwing on
@@ -0,0 +1,11 @@
1
+ /**
2
+ * Browser stub for @invisibleloop/pulse/md
3
+ *
4
+ * In dev mode, spec files are served raw to the browser for hydration. Any
5
+ * top-level import of @invisibleloop/pulse/md must resolve to something — but
6
+ * the server fetchers that actually call md()/parseMd() never run in the
7
+ * browser. This stub satisfies the import without pulling in node:fs/path/url.
8
+ */
9
+
10
+ export const md = () => async () => ({ html: '', frontmatter: {} })
11
+ export const parseMd = () => ({ html: '', frontmatter: {} })
package/src/md/index.js CHANGED
@@ -245,8 +245,18 @@ function renderBlocks(blocks) {
245
245
  return '<hr>'
246
246
  case 'blockquote':
247
247
  return `<blockquote>${markdownToHtml(block.content)}</blockquote>`
248
- case 'ul':
249
- return `<ul>\n${block.items.map(item => `<li>${renderListItem(item)}</li>`).join('\n')}\n</ul>`
248
+ case 'ul': {
249
+ const isTaskList = block.items.some(item => /^\[[ xX]\] /.test(item))
250
+ const items = block.items.map(item => {
251
+ const task = item.match(/^\[([ xX])\] (.*)/)
252
+ if (task) {
253
+ const checked = task[1].toLowerCase() === 'x' ? ' checked' : ''
254
+ return `<li class="task-list-item"><label class="ui-checkbox ui-checkbox--disabled"><input type="checkbox" class="ui-checkbox-input" disabled${checked}><span class="ui-checkbox-box" aria-hidden="true"></span><span class="ui-checkbox-label">${renderListItem(task[2])}</span></label></li>`
255
+ }
256
+ return `<li>${renderListItem(item)}</li>`
257
+ })
258
+ return `<ul${isTaskList ? ' class="contains-task-list"' : ''}>\n${items.join('\n')}\n</ul>`
259
+ }
250
260
  case 'ol':
251
261
  return `<ol>\n${block.items.map(item => `<li>${renderListItem(item)}</li>`).join('\n')}\n</ol>`
252
262
  case 'table':
@@ -0,0 +1,181 @@
1
+ /**
2
+ * Markdown parser tests — run with: node src/md/md.test.js
3
+ */
4
+ import { parseMd } from './index.js'
5
+
6
+ let passed = 0
7
+ let failed = 0
8
+
9
+ function test(label, fn) {
10
+ try {
11
+ fn()
12
+ console.log(` ✓ ${label}`)
13
+ passed++
14
+ } catch (e) {
15
+ console.log(` ✗ ${label}`)
16
+ console.log(` ${e.message}`)
17
+ failed++
18
+ }
19
+ }
20
+
21
+ function assert(condition, msg) {
22
+ if (!condition) throw new Error(msg || 'Assertion failed')
23
+ }
24
+
25
+ // ---------------------------------------------------------------------------
26
+ // Task lists (GFM checkboxes)
27
+ // ---------------------------------------------------------------------------
28
+
29
+ console.log('\nTask list (checkbox) rendering\n')
30
+
31
+ test('unchecked item renders disabled checkbox', () => {
32
+ const { html } = parseMd('- [ ] Buy milk')
33
+ assert(html.includes('<input type="checkbox" class="ui-checkbox-input" disabled>'), `Got: ${html}`)
34
+ assert(html.includes('Buy milk'), `Got: ${html}`)
35
+ })
36
+
37
+ test('checked item renders checked disabled checkbox', () => {
38
+ const { html } = parseMd('- [x] Done')
39
+ assert(html.includes('<input type="checkbox" class="ui-checkbox-input" disabled checked>'), `Got: ${html}`)
40
+ })
41
+
42
+ test('uppercase X is also treated as checked', () => {
43
+ const { html } = parseMd('- [X] Done')
44
+ assert(html.includes('<input type="checkbox" class="ui-checkbox-input" disabled checked>'), `Got: ${html}`)
45
+ })
46
+
47
+ test('task list items get task-list-item class', () => {
48
+ const { html } = parseMd('- [ ] Item')
49
+ assert(html.includes('class="task-list-item"'), `Got: ${html}`)
50
+ })
51
+
52
+ test('task item checkbox is wrapped in a label for accessibility', () => {
53
+ const { html } = parseMd('- [ ] Buy milk')
54
+ assert(html.includes('class="ui-checkbox ui-checkbox--disabled"'), `Got: ${html}`)
55
+ assert(html.includes('<input type="checkbox" class="ui-checkbox-input" disabled>'), `Got: ${html}`)
56
+ assert(html.includes('<span class="ui-checkbox-box" aria-hidden="true">'), `Got: ${html}`)
57
+ assert(html.includes('<span class="ui-checkbox-label">Buy milk</span>'), `Got: ${html}`)
58
+ })
59
+
60
+ test('task list ul gets contains-task-list class', () => {
61
+ const { html } = parseMd('- [ ] Item one\n- [x] Item two')
62
+ assert(html.includes('class="contains-task-list"'), `Got: ${html}`)
63
+ })
64
+
65
+ test('mixed task and regular items — ul gets contains-task-list class', () => {
66
+ const { html } = parseMd('- [ ] Task\n- Regular item')
67
+ assert(html.includes('class="contains-task-list"'), `Got: ${html}`)
68
+ })
69
+
70
+ test('regular list has no task-list classes', () => {
71
+ const { html } = parseMd('- Apple\n- Banana')
72
+ assert(!html.includes('contains-task-list'), `Got: ${html}`)
73
+ assert(!html.includes('task-list-item'), `Got: ${html}`)
74
+ assert(!html.includes('checkbox'), `Got: ${html}`)
75
+ })
76
+
77
+ test('task item label text is rendered as inline markdown', () => {
78
+ const { html } = parseMd('- [ ] Read **important** docs')
79
+ assert(html.includes('<strong>important</strong>'), `Got: ${html}`)
80
+ })
81
+
82
+ test('multiple items preserve checked state independently', () => {
83
+ const { html } = parseMd('- [ ] Unchecked\n- [x] Checked\n- [ ] Also unchecked')
84
+ const checkedCount = (html.match(/disabled checked>/g) || []).length
85
+ const uncheckedCount = (html.match(/disabled>/g) || []).length
86
+ assert(checkedCount === 1, `Expected 1 checked, got ${checkedCount}`)
87
+ assert(uncheckedCount === 2, `Expected 2 unchecked, got ${uncheckedCount}`)
88
+ })
89
+
90
+ // ---------------------------------------------------------------------------
91
+ // Frontmatter
92
+ // ---------------------------------------------------------------------------
93
+
94
+ console.log('\nFrontmatter\n')
95
+
96
+ test('extracts frontmatter key/value pairs', () => {
97
+ const { frontmatter } = parseMd('---\ntitle: Hello World\ndate: 2026-01-01\n---\nBody text')
98
+ assert(frontmatter.title === 'Hello World', `Got: ${JSON.stringify(frontmatter)}`)
99
+ assert(frontmatter.date === '2026-01-01', `Got: ${JSON.stringify(frontmatter)}`)
100
+ })
101
+
102
+ test('body is parsed without frontmatter block', () => {
103
+ const { html } = parseMd('---\ntitle: Test\n---\n# Heading')
104
+ assert(html.includes('<h1'), `Got: ${html}`)
105
+ assert(!html.includes('title:'), `Got: ${html}`)
106
+ })
107
+
108
+ test('no frontmatter — returns empty object', () => {
109
+ const { frontmatter } = parseMd('# Just a heading')
110
+ assert(Object.keys(frontmatter).length === 0, `Got: ${JSON.stringify(frontmatter)}`)
111
+ })
112
+
113
+ // ---------------------------------------------------------------------------
114
+ // Core inline / block rendering
115
+ // ---------------------------------------------------------------------------
116
+
117
+ console.log('\nInline and block rendering\n')
118
+
119
+ test('bold', () => {
120
+ const { html } = parseMd('**bold**')
121
+ assert(html.includes('<strong>bold</strong>'), `Got: ${html}`)
122
+ })
123
+
124
+ test('italic', () => {
125
+ const { html } = parseMd('*italic*')
126
+ assert(html.includes('<em>italic</em>'), `Got: ${html}`)
127
+ })
128
+
129
+ test('strikethrough', () => {
130
+ const { html } = parseMd('~~strike~~')
131
+ assert(html.includes('<del>strike</del>'), `Got: ${html}`)
132
+ })
133
+
134
+ test('inline code', () => {
135
+ const { html } = parseMd('Use `foo()` here')
136
+ assert(html.includes('<code>foo()</code>'), `Got: ${html}`)
137
+ })
138
+
139
+ test('heading levels', () => {
140
+ const { html } = parseMd('# H1\n## H2\n### H3')
141
+ assert(html.includes('<h1'), `Got: ${html}`)
142
+ assert(html.includes('<h2'), `Got: ${html}`)
143
+ assert(html.includes('<h3'), `Got: ${html}`)
144
+ })
145
+
146
+ test('heading gets id from text', () => {
147
+ const { html } = parseMd('## My Section')
148
+ assert(html.includes('id="my-section"'), `Got: ${html}`)
149
+ })
150
+
151
+ test('ordered list', () => {
152
+ const { html } = parseMd('1. First\n2. Second')
153
+ assert(html.includes('<ol>'), `Got: ${html}`)
154
+ assert(html.includes('<li>First</li>'), `Got: ${html}`)
155
+ })
156
+
157
+ test('blockquote', () => {
158
+ const { html } = parseMd('> A quote')
159
+ assert(html.includes('<blockquote>'), `Got: ${html}`)
160
+ })
161
+
162
+ test('horizontal rule', () => {
163
+ const { html } = parseMd('---')
164
+ assert(html.includes('<hr>'), `Got: ${html}`)
165
+ })
166
+
167
+ test('link', () => {
168
+ const { html } = parseMd('[Pulse](https://example.com)')
169
+ assert(html.includes('<a href="https://example.com">Pulse</a>'), `Got: ${html}`)
170
+ })
171
+
172
+ test('HTML is escaped in inline text', () => {
173
+ const { html } = parseMd('A <script>alert(1)</script> here')
174
+ assert(!html.includes('<script>'), `Got: ${html}`)
175
+ assert(html.includes('&lt;script&gt;'), `Got: ${html}`)
176
+ })
177
+
178
+ // ---------------------------------------------------------------------------
179
+
180
+ console.log(`\n${passed + failed} tests: ${passed} passed, ${failed} failed\n`)
181
+ if (failed > 0) process.exit(1)
@@ -323,9 +323,11 @@ export function wrapDocument({ content, spec = {}, serverState = {}, storeState
323
323
  // Serialise store state so the client store singleton can be initialised.
324
324
  // Also exposes window.__updatePulseStore__ so navigate.js can refresh the
325
325
  // singleton with fresh server data on client-side navigations.
326
+ // window.__PULSE_NONCE__ lets the toast runtime inject a nonce'd <style> tag
327
+ // to satisfy the style-src CSP directive.
326
328
  const storeStateScript = storeState && Object.keys(storeState).length > 0
327
- ? `<script nonce="${nonce}">window.__PULSE_STORE__=${JSON.stringify(storeState)};window.__updatePulseStore__=function(s){window.__PULSE_STORE__=Object.assign(window.__PULSE_STORE__||{},s);};</script>`
328
- : ''
329
+ ? `<script nonce="${nonce}">window.__PULSE_NONCE__='${nonce}';window.__PULSE_STORE__=${JSON.stringify(storeState)};window.__updatePulseStore__=function(s){window.__PULSE_STORE__=Object.assign(window.__PULSE_STORE__||{},s);};</script>`
330
+ : `<script nonce="${nonce}">window.__PULSE_NONCE__='${nonce}';</script>`
329
331
 
330
332
  // Hydration bootstrap — makes the server-rendered HTML interactive.
331
333
  // When hydrate points to a self-executing bundle (/dist/…) a single <script>
@@ -80,6 +80,9 @@ function injectStyles() {
80
80
  if (_stylesInjected) return
81
81
  _stylesInjected = true
82
82
  const style = document.createElement('style')
83
+ // Apply the page nonce so the style tag passes the style-src CSP directive.
84
+ const nonce = typeof window !== 'undefined' && window.__PULSE_NONCE__
85
+ if (nonce) style.setAttribute('nonce', nonce)
83
86
  style.textContent = TOAST_CSS
84
87
  document.head.appendChild(style)
85
88
  }
@@ -79,7 +79,11 @@ function serializeCsp(directives) {
79
79
  * @param {Record<string,string[]>} [ext] Extra sources to merge in per directive.
80
80
  */
81
81
  function buildCsp(nonce, ext = {}) {
82
- const d = { ...BASE_CSP, 'script-src': ["'self'", `'nonce-${nonce}'`] }
82
+ const d = {
83
+ ...BASE_CSP,
84
+ 'script-src': ["'self'", `'nonce-${nonce}'`],
85
+ 'style-src': ["'self'", `'nonce-${nonce}'`], // allows toast's runtime-injected <style nonce>
86
+ }
83
87
  for (const [k, sources] of Object.entries(ext)) {
84
88
  d[k] = [...(d[k] || []), ...sources]
85
89
  }
@@ -317,7 +321,8 @@ function devImportMap(nonce) {
317
321
  {
318
322
  "imports": {
319
323
  "@invisibleloop/pulse/image": "/@pulse/runtime/image.js",
320
- "@invisibleloop/pulse/ui": "/@pulse/ui/index.js"
324
+ "@invisibleloop/pulse/ui": "/@pulse/ui/index.js",
325
+ "@invisibleloop/pulse/md": "/@pulse/md/browser-stub.js"
321
326
  }
322
327
  }
323
328
  </script>`
@@ -499,7 +504,10 @@ export async function createServer(entries, options = {}) {
499
504
 
500
505
  // Validate all specs upfront — fail at startup, not at request time
501
506
  for (const spec of specs) {
502
- const { valid, errors } = validateSpec(spec)
507
+ // Strip framework-injected `hydrate` before validation — the check for manually-set
508
+ // hydrate only makes sense on raw source files (MCP validate tool), not here.
509
+ const { hydrate: _h, ...specToValidate } = spec
510
+ const { valid, errors } = validateSpec(specToValidate)
503
511
  const routeErrors = []
504
512
  if (!spec.route || typeof spec.route !== 'string') {
505
513
  routeErrors.push('spec.route is required and must be a string (e.g. "/contact")')
@@ -992,9 +1000,11 @@ ${stylePreloads ? stylePreloads + '\n' : ''}${runtimePreload ? runtimePreload +
992
1000
 
993
1001
  // Emit window.__PULSE_STORE__ so the client store singleton can be initialised.
994
1002
  // Also exposes __updatePulseStore__ for navigate.js to refresh store on navigation.
1003
+ // window.__PULSE_NONCE__ lets the toast runtime inject a nonce'd <style> tag to
1004
+ // satisfy the style-src CSP directive (which disallows uninonce'd inline styles).
995
1005
  const storeScript = ctx.store && Object.keys(ctx.store).length > 0
996
- ? `\n <script nonce="${nonce}">window.__PULSE_STORE__=${JSON.stringify(ctx.store)};window.__updatePulseStore__=function(s){window.__PULSE_STORE__=Object.assign(window.__PULSE_STORE__||{},s);};</script>`
997
- : ''
1006
+ ? `\n <script nonce="${nonce}">window.__PULSE_NONCE__='${nonce}';window.__PULSE_STORE__=${JSON.stringify(ctx.store)};window.__updatePulseStore__=function(s){window.__PULSE_STORE__=Object.assign(window.__PULSE_STORE__||{},s);};</script>`
1007
+ : `\n <script nonce="${nonce}">window.__PULSE_NONCE__='${nonce}';</script>`
998
1008
 
999
1009
  const docClose = `
1000
1010
  </div>
@@ -410,7 +410,11 @@ export function validateSpec(spec) {
410
410
  * @throws {Error}
411
411
  */
412
412
  export function assertValidSpec(spec) {
413
- const { valid, errors, warnings } = validateSpec(spec)
413
+ // Strip framework-injected `hydrate` before validating — the hydrate check is only
414
+ // meaningful on raw source files (MCP validate tool). At render time hydrate is always
415
+ // set by the framework, so validating it here produces a false positive warning.
416
+ const { hydrate: _h, ...specToValidate } = spec
417
+ const { valid, errors, warnings } = validateSpec(specToValidate)
414
418
  if (!valid) {
415
419
  throw new Error(
416
420
  `Invalid Pulse spec${spec?.route ? ` for route "${spec.route}"` : ''}:\n` +
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * Spec schema tests — run with: node src/spec/schema.test.js
3
3
  */
4
- import { validateSpec, getStreamOrder, getViewSegments } from './schema.js'
4
+ import { validateSpec, assertValidSpec, getStreamOrder, getViewSegments } from './schema.js'
5
5
 
6
6
  let passed = 0
7
7
  let failed = 0
@@ -345,6 +345,45 @@ test('rejects guard that is an object', () => {
345
345
  )
346
346
  })
347
347
 
348
+ // ---------------------------------------------------------------------------
349
+ // assertValidSpec — framework-injected hydrate must not produce a warning
350
+ // ---------------------------------------------------------------------------
351
+
352
+ console.log('\nassertValidSpec\n')
353
+
354
+ test('does not warn when hydrate is framework-injected', () => {
355
+ // The server injects hydrate onto specs that need it. assertValidSpec is called
356
+ // on every render — it must not print a false-positive warning about hydrate.
357
+ const warnings = []
358
+ const orig = console.warn
359
+ console.warn = (...args) => warnings.push(args.join(' '))
360
+ try {
361
+ assertValidSpec({
362
+ route: '/entry',
363
+ hydrate: '/src/pages/entry.js',
364
+ state: { editing: false },
365
+ view: () => '<main id="main-content"></main>',
366
+ mutations: { startEdit: () => ({ editing: true }) },
367
+ meta: { title: 'Entry', description: 'A page' },
368
+ })
369
+ } finally {
370
+ console.warn = orig
371
+ }
372
+ const hydrateWarning = warnings.some(w => w.includes('hydrate'))
373
+ assert(!hydrateWarning, `Unexpected hydrate warning: ${warnings.join('; ')}`)
374
+ })
375
+
376
+ test('validateSpec still warns when hydrate is set manually in source', () => {
377
+ // validateSpec (used by the MCP tool on raw source files) should still warn.
378
+ const { warnings } = validateSpec({
379
+ route: '/entry',
380
+ hydrate: '/src/pages/entry.js',
381
+ view: () => '<main id="main-content"></main>',
382
+ meta: { title: 'Entry', description: 'A page' },
383
+ })
384
+ assert(warnings.some(w => w.includes('hydrate')), 'Expected hydrate warning from validateSpec')
385
+ })
386
+
348
387
  // ---------------------------------------------------------------------------
349
388
 
350
389
  console.log(`\n${passed + failed} tests: ${passed} passed, ${failed} failed\n`)