@invisibleloop/pulse 0.6.0 → 0.6.2

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.0",
3
+ "version": "0.6.2",
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.0
1
+ 0.6.2
@@ -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
 
@@ -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
  }
@@ -293,6 +293,82 @@ test('does not strip non-server imports',
293
293
  `import { nav, footer } from '@invisibleloop/pulse/ui'\nexport default {\n view: () => nav({})\n}`
294
294
  )
295
295
 
296
+ test('strips node:fs default import',
297
+ `import fs from 'node:fs'\nexport default {\n server: { data: async () => fs.readFileSync('x') },\n view: () => ''\n}`,
298
+ `export default {\n view: () => ''\n}`
299
+ )
300
+
301
+ test('strips node:fs/promises named import',
302
+ `import { readFile } from 'node:fs/promises'\nexport default {\n server: { data: async () => readFile('x') },\n view: () => ''\n}`,
303
+ `export default {\n view: () => ''\n}`
304
+ )
305
+
306
+ test('strips node:path and node:url together',
307
+ `import path from 'node:path'\nimport { fileURLToPath } from 'node:url'\nexport default {\n server: { data: async (ctx) => path.join('a', 'b') },\n view: () => ''\n}`,
308
+ `export default {\n view: () => ''\n}`
309
+ )
310
+
311
+ test('strips node:fs default import and top-level const using it',
312
+ `import fs from 'node:fs'\nconst DIR = fs.readdirSync('.')\nexport default {\n view: () => ''\n}`,
313
+ `export default {\n view: () => ''\n}`
314
+ )
315
+
316
+ // ---------------------------------------------------------------------------
317
+ // Dev-mode node:* import stripping (same regex used in src/cli/dev.js serveFile)
318
+ // ---------------------------------------------------------------------------
319
+
320
+ console.log('\nDev-mode node:* import stripping\n')
321
+
322
+ const DEV_NODE_RE = /^[ \t]*import\s+(?:\{[^}]*\}|[\w*]+(?:\s+as\s+\w+)?)\s+from\s+['"]node:[^'"]+['"]\s*;?[ \t]*\n?/gm
323
+
324
+ function devStrip(source) {
325
+ return source.replace(DEV_NODE_RE, '')
326
+ }
327
+
328
+ function devTest(label, input, expected) {
329
+ const got = devStrip(input).trim()
330
+ const exp = expected.trim()
331
+ if (got === exp) {
332
+ console.log(' \u2713 ' + label)
333
+ pass++
334
+ } else {
335
+ console.log(' \u2717 ' + label)
336
+ console.log(' expected: ' + JSON.stringify(exp))
337
+ console.log(' got: ' + JSON.stringify(got))
338
+ fail++
339
+ }
340
+ }
341
+
342
+ devTest('strips default import (node:fs)',
343
+ `import fs from 'node:fs'\nconst DIR = '/journal'\nexport default { view: () => '' }`,
344
+ `const DIR = '/journal'\nexport default { view: () => '' }`
345
+ )
346
+
347
+ devTest('strips named import (node:path)',
348
+ `import { join, resolve } from 'node:path'\nexport default { view: () => '' }`,
349
+ `export default { view: () => '' }`
350
+ )
351
+
352
+ devTest('strips multiple node: imports',
353
+ `import fs from 'node:fs'\nimport path from 'node:path'\nexport default { view: () => '' }`,
354
+ `export default { view: () => '' }`
355
+ )
356
+
357
+ devTest('strips node:fs/promises named import',
358
+ `import { readFile } from 'node:fs/promises'\nexport default { view: () => '' }`,
359
+ `export default { view: () => '' }`
360
+ )
361
+
362
+ devTest('does not strip non-node imports',
363
+ `import { nav } from '@invisibleloop/pulse/ui'\nexport default { view: () => '' }`,
364
+ `import { nav } from '@invisibleloop/pulse/ui'\nexport default { view: () => '' }`
365
+ )
366
+
367
+ devTest('leaves module-level constants intact',
368
+ `import fs from 'node:fs'\nconst JOURNAL_DIR = '/Users/andy/journal'\nexport default { view: () => '' }`,
369
+ `const JOURNAL_DIR = '/Users/andy/journal'\nexport default { view: () => '' }`
370
+ )
371
+
296
372
  // ---------------------------------------------------------------------------
297
373
 
298
374
  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
 
@@ -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"><input type="checkbox" disabled${checked}> ${renderListItem(task[2])}</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,173 @@
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" 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" 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" 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 list ul gets contains-task-list class', () => {
53
+ const { html } = parseMd('- [ ] Item one\n- [x] Item two')
54
+ assert(html.includes('class="contains-task-list"'), `Got: ${html}`)
55
+ })
56
+
57
+ test('mixed task and regular items — ul gets contains-task-list class', () => {
58
+ const { html } = parseMd('- [ ] Task\n- Regular item')
59
+ assert(html.includes('class="contains-task-list"'), `Got: ${html}`)
60
+ })
61
+
62
+ test('regular list has no task-list classes', () => {
63
+ const { html } = parseMd('- Apple\n- Banana')
64
+ assert(!html.includes('contains-task-list'), `Got: ${html}`)
65
+ assert(!html.includes('task-list-item'), `Got: ${html}`)
66
+ assert(!html.includes('checkbox'), `Got: ${html}`)
67
+ })
68
+
69
+ test('task item label text is rendered as inline markdown', () => {
70
+ const { html } = parseMd('- [ ] Read **important** docs')
71
+ assert(html.includes('<strong>important</strong>'), `Got: ${html}`)
72
+ })
73
+
74
+ test('multiple items preserve checked state independently', () => {
75
+ const { html } = parseMd('- [ ] Unchecked\n- [x] Checked\n- [ ] Also unchecked')
76
+ const checkedCount = (html.match(/checked>/g) || []).length
77
+ const uncheckedCount = (html.match(/<input type="checkbox" disabled>/g) || []).length
78
+ assert(checkedCount === 1, `Expected 1 checked, got ${checkedCount}`)
79
+ assert(uncheckedCount === 2, `Expected 2 unchecked, got ${uncheckedCount}`)
80
+ })
81
+
82
+ // ---------------------------------------------------------------------------
83
+ // Frontmatter
84
+ // ---------------------------------------------------------------------------
85
+
86
+ console.log('\nFrontmatter\n')
87
+
88
+ test('extracts frontmatter key/value pairs', () => {
89
+ const { frontmatter } = parseMd('---\ntitle: Hello World\ndate: 2026-01-01\n---\nBody text')
90
+ assert(frontmatter.title === 'Hello World', `Got: ${JSON.stringify(frontmatter)}`)
91
+ assert(frontmatter.date === '2026-01-01', `Got: ${JSON.stringify(frontmatter)}`)
92
+ })
93
+
94
+ test('body is parsed without frontmatter block', () => {
95
+ const { html } = parseMd('---\ntitle: Test\n---\n# Heading')
96
+ assert(html.includes('<h1'), `Got: ${html}`)
97
+ assert(!html.includes('title:'), `Got: ${html}`)
98
+ })
99
+
100
+ test('no frontmatter — returns empty object', () => {
101
+ const { frontmatter } = parseMd('# Just a heading')
102
+ assert(Object.keys(frontmatter).length === 0, `Got: ${JSON.stringify(frontmatter)}`)
103
+ })
104
+
105
+ // ---------------------------------------------------------------------------
106
+ // Core inline / block rendering
107
+ // ---------------------------------------------------------------------------
108
+
109
+ console.log('\nInline and block rendering\n')
110
+
111
+ test('bold', () => {
112
+ const { html } = parseMd('**bold**')
113
+ assert(html.includes('<strong>bold</strong>'), `Got: ${html}`)
114
+ })
115
+
116
+ test('italic', () => {
117
+ const { html } = parseMd('*italic*')
118
+ assert(html.includes('<em>italic</em>'), `Got: ${html}`)
119
+ })
120
+
121
+ test('strikethrough', () => {
122
+ const { html } = parseMd('~~strike~~')
123
+ assert(html.includes('<del>strike</del>'), `Got: ${html}`)
124
+ })
125
+
126
+ test('inline code', () => {
127
+ const { html } = parseMd('Use `foo()` here')
128
+ assert(html.includes('<code>foo()</code>'), `Got: ${html}`)
129
+ })
130
+
131
+ test('heading levels', () => {
132
+ const { html } = parseMd('# H1\n## H2\n### H3')
133
+ assert(html.includes('<h1'), `Got: ${html}`)
134
+ assert(html.includes('<h2'), `Got: ${html}`)
135
+ assert(html.includes('<h3'), `Got: ${html}`)
136
+ })
137
+
138
+ test('heading gets id from text', () => {
139
+ const { html } = parseMd('## My Section')
140
+ assert(html.includes('id="my-section"'), `Got: ${html}`)
141
+ })
142
+
143
+ test('ordered list', () => {
144
+ const { html } = parseMd('1. First\n2. Second')
145
+ assert(html.includes('<ol>'), `Got: ${html}`)
146
+ assert(html.includes('<li>First</li>'), `Got: ${html}`)
147
+ })
148
+
149
+ test('blockquote', () => {
150
+ const { html } = parseMd('> A quote')
151
+ assert(html.includes('<blockquote>'), `Got: ${html}`)
152
+ })
153
+
154
+ test('horizontal rule', () => {
155
+ const { html } = parseMd('---')
156
+ assert(html.includes('<hr>'), `Got: ${html}`)
157
+ })
158
+
159
+ test('link', () => {
160
+ const { html } = parseMd('[Pulse](https://example.com)')
161
+ assert(html.includes('<a href="https://example.com">Pulse</a>'), `Got: ${html}`)
162
+ })
163
+
164
+ test('HTML is escaped in inline text', () => {
165
+ const { html } = parseMd('A <script>alert(1)</script> here')
166
+ assert(!html.includes('<script>'), `Got: ${html}`)
167
+ assert(html.includes('&lt;script&gt;'), `Got: ${html}`)
168
+ })
169
+
170
+ // ---------------------------------------------------------------------------
171
+
172
+ console.log(`\n${passed + failed} tests: ${passed} passed, ${failed} failed\n`)
173
+ if (failed > 0) process.exit(1)
@@ -146,7 +146,7 @@ export function renderToStream(spec, ctx = {}, nonce = '') {
146
146
  ? Object.fromEntries(await Promise.all([...fetcherCache.entries()].map(async ([k, p]) => [k, await p])))
147
147
  : {}
148
148
  const mergedAllState = mergeStoreKeys(spec, allServerState, ctx.store)
149
- if (Object.keys(mergedAllState).length > 0) {
149
+ if (spec.hydrate && Object.keys(mergedAllState).length > 0) {
150
150
  controller.enqueue(encode(
151
151
  `<script nonce="${nonce}">window.__PULSE_SERVER__ = ${JSON.stringify(mergedAllState)};</script>`
152
152
  ))
@@ -314,17 +314,20 @@ export function wrapDocument({ content, spec = {}, serverState = {}, storeState
314
314
  : ''
315
315
 
316
316
  // Serialise server state into the page so the client runtime can read it
317
- // without making a second request
318
- const serverStateScript = Object.keys(serverState).length > 0
317
+ // without making a second request. Only needed when the page is hydrated —
318
+ // purely server-rendered pages have no JS to read it.
319
+ const serverStateScript = spec.hydrate && Object.keys(serverState).length > 0
319
320
  ? `<script nonce="${nonce}">window.__PULSE_SERVER__ = ${JSON.stringify(serverState)};</script>`
320
321
  : ''
321
322
 
322
323
  // Serialise store state so the client store singleton can be initialised.
323
324
  // Also exposes window.__updatePulseStore__ so navigate.js can refresh the
324
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.
325
328
  const storeStateScript = storeState && Object.keys(storeState).length > 0
326
- ? `<script nonce="${nonce}">window.__PULSE_STORE__=${JSON.stringify(storeState)};window.__updatePulseStore__=function(s){window.__PULSE_STORE__=Object.assign(window.__PULSE_STORE__||{},s);};</script>`
327
- : ''
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>`
328
331
 
329
332
  // Hydration bootstrap — makes the server-rendered HTML interactive.
330
333
  // When hydrate points to a self-executing bundle (/dist/…) a single <script>
@@ -61,7 +61,8 @@ const counterSpec = {
61
61
  }
62
62
 
63
63
  const serverDataSpec = {
64
- route: '/products',
64
+ route: '/products',
65
+ hydrate: '/src/pages/products.js',
65
66
  state: {},
66
67
  server: {
67
68
  products: async (ctx) => [
@@ -314,7 +315,8 @@ await test('stream.scope: shell does not wait for deferred-only fetchers', async
314
315
 
315
316
  await test('stream.scope: __PULSE_SERVER__ includes state from all scoped fetchers', async () => {
316
317
  const spec = {
317
- route: '/scoped-state',
318
+ route: '/scoped-state',
319
+ hydrate: '/src/pages/scoped-state.js',
318
320
  stream: {
319
321
  shell: ['header'],
320
322
  deferred: ['feed'],
@@ -366,6 +368,7 @@ await test('sets og:title meta tag', async () => {
366
368
  await test('serialises server state into page', async () => {
367
369
  const { html } = wrapDocument({
368
370
  content: '',
371
+ spec: { hydrate: '/src/pages/products.js' },
369
372
  serverState: { products: [{ id: 1 }] }
370
373
  })
371
374
  assert(html.includes('__PULSE_SERVER__'), `Expected server state script: ${html}`)
@@ -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`)