@invisibleloop/pulse 0.6.1 → 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 +2 -2
- package/public/.pulse-ui-version +1 -1
- package/public/pulse-ui.css +2 -0
- package/scripts/build.js +47 -32
- package/scripts/strip-server.test.js +100 -24
- package/src/cli/dev.js +8 -1
- package/src/mcp/validate-worker.js +13 -0
- package/src/md/browser-stub.js +11 -0
- package/src/md/index.js +12 -2
- package/src/md/md.test.js +173 -0
- package/src/runtime/ssr.js +4 -2
- package/src/runtime/toast.js +3 -0
- package/src/server/index.js +15 -5
- package/src/spec/schema.js +5 -1
- package/src/spec/schema.test.js +40 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@invisibleloop/pulse",
|
|
3
|
-
"version": "0.6.
|
|
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"
|
package/public/.pulse-ui-version
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
0.6.
|
|
1
|
+
0.6.2
|
package/public/pulse-ui.css
CHANGED
|
@@ -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
|
|
58
|
-
* variable declarations
|
|
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
|
-
*
|
|
61
|
-
*
|
|
62
|
-
*
|
|
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
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
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
|
-
|
|
101
|
+
source = source.replace(combinedRe, '')
|
|
87
102
|
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
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
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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('<script>'), `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)
|
package/src/runtime/ssr.js
CHANGED
|
@@ -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>
|
package/src/runtime/toast.js
CHANGED
|
@@ -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
|
}
|
package/src/server/index.js
CHANGED
|
@@ -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 = {
|
|
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
|
-
|
|
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>
|
package/src/spec/schema.js
CHANGED
|
@@ -410,7 +410,11 @@ export function validateSpec(spec) {
|
|
|
410
410
|
* @throws {Error}
|
|
411
411
|
*/
|
|
412
412
|
export function assertValidSpec(spec) {
|
|
413
|
-
|
|
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` +
|
package/src/spec/schema.test.js
CHANGED
|
@@ -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`)
|