@invisibleloop/pulse 0.20.38 → 0.20.39

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.20.38",
3
+ "version": "0.20.39",
4
4
  "type": "module",
5
5
  "description": "AI-first frontend framework. The spec is the source of truth.",
6
6
  "license": "MIT",
@@ -1 +1 @@
1
- 0.20.38
1
+ 0.20.39
@@ -1,147 +0,0 @@
1
- /**
2
- * CSS purge/minify tests — run with: node scripts/css-purge.test.js
3
- *
4
- * Regression focus: @import URLs containing `;` and `&` in their query string
5
- * (Google Fonts) were truncated by the production build — the block parser
6
- * split the statement at the `;` inside the quoted URL, silently breaking the
7
- * theme in production while dev (which serves CSS unprocessed) looked fine.
8
- */
9
-
10
- import { test } from 'node:test'
11
- import assert from 'node:assert/strict'
12
- import { parseCssBlocks, purgeCss, minifyCss, extractUsedClasses, selectorUsed } from './css-purge.js'
13
-
14
- const FONT_IMPORT = `@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;600&display=swap');`
15
-
16
- // ---------------------------------------------------------------------------
17
- // The @import regression
18
- // ---------------------------------------------------------------------------
19
-
20
- test('parseCssBlocks: does not split @import at a ; inside a quoted URL', () => {
21
- const css = `${FONT_IMPORT}\n:root { --accent: #e25; }`
22
- const blocks = parseCssBlocks(css)
23
- assert.equal(blocks.length, 2, `Expected 2 blocks, got ${blocks.length}: ${JSON.stringify(blocks.map(b => b.raw))}`)
24
- assert.equal(blocks[0].type, 'at-simple')
25
- assert.equal(blocks[0].raw, FONT_IMPORT)
26
- })
27
-
28
- test('parseCssBlocks: handles double-quoted URLs and escapes', () => {
29
- const css = `@import url("https://example.com/a;b&c=d");\n.x { color: red; }`
30
- const blocks = parseCssBlocks(css)
31
- assert.equal(blocks[0].raw, `@import url("https://example.com/a;b&c=d");`)
32
- })
33
-
34
- test('purgeCss + minifyCss: Google Fonts @import survives the full pipeline intact', () => {
35
- const css = `${FONT_IMPORT}\n:root { --font: 'Inter', sans-serif; }\n.unused { color: red; }`
36
- const out = minifyCss(purgeCss(css, new Set([])))
37
- assert.ok(
38
- out.includes(`url('https://fonts.googleapis.com/css2?family=Inter:wght@400;600&display=swap')`),
39
- `@import URL was mangled by the build pipeline. Output: ${out}`
40
- )
41
- })
42
-
43
- test('minifyCss: quoted strings are untouched by punctuation collapsing', () => {
44
- const css = `.x { content: "a ; b : c , d" ; }`
45
- const out = minifyCss(css)
46
- assert.ok(out.includes(`"a ; b : c , d"`), `Quoted string was altered: ${out}`)
47
- })
48
-
49
- // ---------------------------------------------------------------------------
50
- // Behaviour preserved through the extraction refactor
51
- // ---------------------------------------------------------------------------
52
-
53
- test('purgeCss: drops unused class rules, keeps used ones', () => {
54
- const css = `.used { color: red; }\n.unused { color: blue; }`
55
- const out = purgeCss(css, new Set(['used']))
56
- assert.ok(out.includes('.used'))
57
- assert.ok(!out.includes('.unused'))
58
- })
59
-
60
- test('purgeCss: element and :root rules are always kept', () => {
61
- const css = `:root { --x: 1; }\nbody { margin: 0; }`
62
- const out = purgeCss(css, new Set([]))
63
- assert.ok(out.includes(':root'))
64
- assert.ok(out.includes('body'))
65
- })
66
-
67
- test('purgeCss: recurses into @media and keeps @font-face', () => {
68
- const css = `@media (min-width: 600px) { .used { color: red; } .unused { color: blue; } }\n@font-face { font-family: X; src: url('/x.woff2'); }`
69
- const out = purgeCss(css, new Set(['used']))
70
- assert.ok(out.includes('.used'))
71
- assert.ok(!out.includes('.unused'))
72
- assert.ok(out.includes('@font-face'))
73
- })
74
-
75
- test('selectorUsed: rightmost class is the subject', () => {
76
- assert.equal(selectorUsed('.nav .link', new Set(['link'])), true)
77
- assert.equal(selectorUsed('.nav .link', new Set(['nav'])), false)
78
- assert.equal(selectorUsed('.btn:hover', new Set(['btn'])), true)
79
- })
80
-
81
- test('extractUsedClasses: finds class attributes and hyphenated JS strings', () => {
82
- const used = extractUsedClasses(
83
- ['<div class="hero card">'],
84
- [`el.classList.add('ui-btn--active')`]
85
- )
86
- assert.ok(used.has('hero'))
87
- assert.ok(used.has('card'))
88
- assert.ok(used.has('ui-btn--active'))
89
- })
90
-
91
- test('minifyCss: calc() expressions keep their operator spacing', () => {
92
- const out = minifyCss(`.x { width: calc(100% - 2rem); }`)
93
- assert.ok(out.includes('calc(100% - 2rem)'), `calc broken: ${out}`)
94
- })
95
-
96
- // ---------------------------------------------------------------------------
97
- // Runtime state classes — classList arguments
98
- // ---------------------------------------------------------------------------
99
- // Regression: the JS string scan only keeps hyphenated names (to avoid keeping
100
- // every English word), so runtime state classes like classList.add('open') were
101
- // never collected — and selectorUsed checks the rightmost class token, so
102
- // `.docs-sidebar.open { … }` was purged from production CSS. The mobile docs
103
- // hamburger toggled the class, but the rule that slides the sidebar in was gone.
104
-
105
- test('classList method arguments are collected regardless of hyphens', () => {
106
- const used = extractUsedClasses([], [
107
- `sidebar.classList.add('open')`,
108
- `overlay.classList.remove("visible")`,
109
- `el.classList.toggle('active', isOn)`,
110
- `el.classList.replace('open', 'closed')`,
111
- ])
112
- for (const cls of ['open', 'visible', 'active', 'closed']) {
113
- assert.ok(used.has(cls), `Expected classList arg "${cls}" to be collected`)
114
- }
115
- })
116
-
117
- test('multiple classList arguments in one call are all collected', () => {
118
- const used = extractUsedClasses([], [`el.classList.add('one', 'two-x', 'three')`])
119
- for (const cls of ['one', 'two-x', 'three']) assert.ok(used.has(cls), cls)
120
- })
121
-
122
- // Regression: class name immediately followed by ${ (no space before the expression)
123
- // was not extracted — "saved-palette${...}" yielded "saved-palette${expanded" as a
124
- // single token, so the rule was silently purged in prod while dev (no purging) looked fine.
125
- test('class name immediately before ${ is extracted correctly', () => {
126
- const js = [
127
- // No space before ${ — the broken pattern
128
- `const v = \`<div class="saved-palette\${expanded ? ' saved-palette--expanded' : ''}"></div>\``,
129
- // Multiple static classes, last one butts into ${
130
- `const v = \`<button class="btn btn--primary\${loading ? ' btn--loading' : ''}"></button>\``,
131
- ]
132
- const used = extractUsedClasses([], js)
133
- for (const cls of ['saved-palette', 'btn', 'btn--primary']) {
134
- assert.ok(used.has(cls), `Expected "${cls}" to be extracted but it was missing`)
135
- }
136
- })
137
-
138
- test('runtime-toggled compound rule survives the purge', () => {
139
- const css = `.docs-sidebar { transform: translateX(-100%); }\n.docs-sidebar.open { transform: translateX(0); }\n.unused { color: red; }`
140
- const used = extractUsedClasses(
141
- [`<aside class="docs-sidebar">`],
142
- [`document.querySelector('.docs-sidebar').classList.add('open')`]
143
- )
144
- const out = purgeCss(css, used)
145
- assert.ok(out.includes('.docs-sidebar.open'), `Compound state rule was purged: ${out}`)
146
- assert.ok(!out.includes('.unused'), 'Unused rule should still be purged')
147
- })
@@ -1,491 +0,0 @@
1
- /**
2
- * Tests for the server-only property stripper in build.js
3
- * Run: node scripts/strip-server.test.js
4
- */
5
-
6
- // ---------------------------------------------------------------------------
7
- // Inline the scanner functions (duplicated here to keep test self-contained)
8
- // ---------------------------------------------------------------------------
9
-
10
- const SERVER_ONLY_KEYS = ['server', 'meta', 'guard', 'serverTimeout', 'contentType', 'render', 'sitemap']
11
- const SERVER_ONLY_IMPORTS = ['@invisibleloop/pulse/md', '@invisibleloop/pulse']
12
-
13
- function stripServerOnlyKeys(source) {
14
- for (const key of SERVER_ONLY_KEYS) source = removeObjectKey(source, key)
15
- return source
16
- }
17
-
18
- function stripServerOnlyImports(source) {
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
- }
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
- }
46
- return source
47
- }
48
-
49
- function strip(source) {
50
- return stripServerOnlyImports(stripServerOnlyKeys(source))
51
- }
52
-
53
- // Mirrors needsClientBundle() in build.js exactly — see its doc comment there.
54
- function needsClientBundle(strippedSource) {
55
- return /\b(mutations|actions|persist|store)\s*:/.test(strippedSource)
56
- }
57
-
58
- function isInsideString(source, pos) {
59
- let i = 0
60
- const stack = []
61
- while (i < pos) {
62
- const c = source[i]
63
- const top = stack[stack.length - 1]
64
- if (top === '"' || top === "'") {
65
- if (c === '\\') { i += 2; continue }
66
- if (c === top) { stack.pop() }
67
- i++; continue
68
- }
69
- if (top === '`') {
70
- if (c === '\\') { i += 2; continue }
71
- if (c === '`') { stack.pop(); i++; continue }
72
- if (c === '$' && source[i + 1] === '{') { stack.push('{'); i += 2; continue }
73
- i++; continue
74
- }
75
- if (top === '{') {
76
- if (c === '{') { stack.push('{'); i++; continue }
77
- if (c === '}') { stack.pop(); i++; continue }
78
- if (c === '"' || c === "'" || c === '`') { stack.push(c); i++; continue }
79
- i++; continue
80
- }
81
- if (c === '/') {
82
- if (source[i + 1] === '/') { while (i < pos && source[i] !== '\n') i++; continue }
83
- if (source[i + 1] === '*') { const end = source.indexOf('*/', i + 2); i = end < 0 ? pos : end + 2; continue }
84
- let prev = ''
85
- for (let k = i - 1; k >= 0; k--) { if (source[k] !== ' ' && source[k] !== '\t') { prev = source[k]; break } }
86
- if (!/[a-zA-Z0-9_$)\]]/.test(prev)) {
87
- i++
88
- while (i < pos) { if (source[i] === '\\') { i += 2; continue } if (source[i] === '/') { i++; break } if (source[i] === '\n') break; i++ }
89
- while (i < pos && /[gimsuy]/.test(source[i])) i++
90
- continue
91
- }
92
- }
93
- if (c === '"' || c === "'" || c === '`') { stack.push(c) }
94
- i++
95
- }
96
- return stack.length > 0
97
- }
98
-
99
- function removeObjectKey(source, key) {
100
- const keyRe = new RegExp(`^([ \\t]*)(${key})([ \\t]*:)`, 'gm')
101
- let match
102
- while ((match = keyRe.exec(source)) !== null) {
103
- if (isInsideString(source, match.index)) { continue }
104
- const removeStart = match.index
105
- const afterColon = match.index + match[0].length
106
- let pos = afterColon
107
- while (pos < source.length && (source[pos] === ' ' || source[pos] === '\t')) pos++
108
- pos = scanJsValue(source, pos)
109
- while (pos < source.length && (source[pos] === ' ' || source[pos] === '\t')) pos++
110
- if (pos < source.length && source[pos] === ',') pos++
111
- if (pos < source.length && source[pos] === '\n') pos++
112
- source = source.slice(0, removeStart) + source.slice(pos)
113
- keyRe.lastIndex = removeStart
114
- }
115
- return source
116
- }
117
-
118
- function scanJsValue(src, pos) {
119
- let i = pos, depth = 0
120
- while (i < src.length) {
121
- const c = src[i]
122
- if (c === '/' && src[i + 1] === '/') { while (i < src.length && src[i] !== '\n') i++; continue }
123
- if (c === '/' && src[i + 1] === '*') { const e = src.indexOf('*/', i + 2); i = e < 0 ? src.length : e + 2; continue }
124
- if (c === '"' || c === "'") {
125
- i++
126
- while (i < src.length) { if (src[i] === '\\') { i += 2; continue } if (src[i] === c) { i++; break } i++ }
127
- if (depth === 0) return i
128
- continue
129
- }
130
- if (c === '`') { i = scanTemplateLiteral(src, i + 1); if (depth === 0) return i; continue }
131
- if (c === '{' || c === '[' || c === '(') { depth++; i++; continue }
132
- if (c === '}' || c === ']' || c === ')') {
133
- if (depth === 0) return i
134
- depth--; i++
135
- if (depth === 0) {
136
- let j = i
137
- while (j < src.length && (src[j] === ' ' || src[j] === '\t')) j++
138
- const next = src[j]
139
- if (!next || next === ',' || next === '}' || next === ']' || next === ')' || next === '\n') return i
140
- if (next === '=' && src[j + 1] === '>') continue
141
- continue
142
- }
143
- continue
144
- }
145
- if (depth === 0 && (c === ',' || c === '\n')) return i
146
- i++
147
- }
148
- return i
149
- }
150
-
151
- function scanTemplateLiteral(src, pos) {
152
- let i = pos
153
- while (i < src.length) {
154
- if (src[i] === '\\') { i += 2; continue }
155
- if (src[i] === '`') { return i + 1 }
156
- if (src[i] === '$' && src[i + 1] === '{') {
157
- i += 2; let depth = 1
158
- while (i < src.length && depth > 0) {
159
- const c = src[i]
160
- if (c === '{') { depth++; i++; continue }
161
- if (c === '}') { depth--; i++; continue }
162
- if (c === '"' || c === "'") { const q = c; i++; while (i < src.length) { if (src[i] === '\\') { i += 2; continue } if (src[i] === q) { i++; break } i++ } continue }
163
- if (c === '`') { i = scanTemplateLiteral(src, i + 1); continue }
164
- i++
165
- }
166
- continue
167
- }
168
- i++
169
- }
170
- return i
171
- }
172
-
173
- // ---------------------------------------------------------------------------
174
- // Test runner
175
- // ---------------------------------------------------------------------------
176
-
177
- let pass = 0, fail = 0
178
-
179
- function test(label, input, expected) {
180
- const got = strip(input).trim()
181
- const exp = expected.trim()
182
- if (got === exp) {
183
- console.log(' \u2713 ' + label)
184
- pass++
185
- } else {
186
- console.log(' \u2717 ' + label)
187
- console.log(' expected: ' + JSON.stringify(exp))
188
- console.log(' got: ' + JSON.stringify(got))
189
- fail++
190
- }
191
- }
192
-
193
- // ---------------------------------------------------------------------------
194
-
195
- console.log('\nServer-only key stripping\n')
196
-
197
- test('object value with trailing comma',
198
- `export default {\n route: '/',\n server: { data: async (ctx) => 'hello' },\n view: () => ''\n}`,
199
- `export default {\n route: '/',\n view: () => ''\n}`
200
- )
201
-
202
- test('async arrow with block body',
203
- `export default {\n server: {\n data: async (ctx) => {\n return 'x'\n }\n },\n view: () => ''\n}`,
204
- `export default {\n view: () => ''\n}`
205
- )
206
-
207
- test('serverTimeout simple number',
208
- `export default {\n serverTimeout: 5000,\n view: () => ''\n}`,
209
- `export default {\n view: () => ''\n}`
210
- )
211
-
212
- test('guard async arrow with block body containing nested object',
213
- `export default {\n guard: async (ctx) => {\n if (!ctx.session) return { redirect: '/login' }\n },\n view: () => ''\n}`,
214
- `export default {\n view: () => ''\n}`
215
- )
216
-
217
- test('guard inline arrow expression',
218
- `export default {\n guard: async (ctx) => { if (!ctx.session) return { redirect: '/login' } },\n view: () => ''\n}`,
219
- `export default {\n view: () => ''\n}`
220
- )
221
-
222
- test('contentType and render are both stripped (raw content specs are never hydrated)',
223
- `export default {\n route: '/feed.xml',\n contentType: 'application/rss+xml',\n render: (ctx) => '<rss/>'\n}`,
224
- `export default {\n route: '/feed.xml',\n}`
225
- )
226
-
227
- test('render with template literal containing ${...}',
228
- 'export default {\n render: (ctx, srv) => `<rss>${srv.items.map(i => i.title).join(\',\')}</rss>`,\n view: () => \'\'\n}',
229
- "export default {\n view: () => ''\n}"
230
- )
231
-
232
- test('last property — no trailing comma',
233
- `export default {\n view: () => '',\n server: { data: async () => [] }\n}`,
234
- `export default {\n view: () => '',\n}`
235
- )
236
-
237
- test('multiple fetchers in server object',
238
- `export default {\n server: {\n user: async (ctx) => ctx.user,\n posts: async (ctx) => []\n },\n view: () => ''\n}`,
239
- `export default {\n view: () => ''\n}`
240
- )
241
-
242
- test('does not strip non-server keys',
243
- `export default {\n route: '/',\n state: {},\n view: () => ''\n}`,
244
- `export default {\n route: '/',\n state: {},\n view: () => ''\n}`
245
- )
246
-
247
- test('strips all server-only keys in one pass',
248
- `export default {\n route: '/admin',\n guard: async (ctx) => { if (!ctx.user) return { redirect: '/login' } },\n serverTimeout: 3000,\n server: { profile: async (ctx) => ctx.user },\n view: () => ''\n}`,
249
- `export default {\n route: '/admin',\n view: () => ''\n}`
250
- )
251
-
252
- test('does not touch "server" inside a view string',
253
- `export default {\n view: (state, server) => \`<p>\${server.name}</p>\`\n}`,
254
- `export default {\n view: (state, server) => \`<p>\${server.name}</p>\`\n}`
255
- )
256
-
257
- test('async function expression (not arrow)',
258
- `export default {\n server: {\n data: async function(ctx) { return ctx.db.query() }\n },\n view: () => ''\n}`,
259
- `export default {\n view: () => ''\n}`
260
- )
261
-
262
- test('server with deeply nested template literal in view is not affected',
263
- 'export default {\n server: { items: async () => [] },\n view: (s, srv) => `<ul>${srv.items.map(i => `<li>${i}</li>`).join(\'\')}</ul>`\n}',
264
- 'export default {\n view: (s, srv) => `<ul>${srv.items.map(i => `<li>${i}</li>`).join(\'\')}</ul>`\n}'
265
- )
266
-
267
- test('render: inside a template literal string (docs code example) is not stripped',
268
- 'export default {\n route: \'/auth\',\n view: () => `${highlight(`export default {\n render: (ctx) => ctx.user\n}`)}`\n}',
269
- 'export default {\n route: \'/auth\',\n view: () => `${highlight(`export default {\n render: (ctx) => ctx.user\n}`)}`\n}'
270
- )
271
-
272
- test('server: inside a double-quoted string is not stripped',
273
- 'export default {\n route: \'/docs\',\n view: () => "<pre>server: { data: async () => {} }</pre>"\n}',
274
- 'export default {\n route: \'/docs\',\n view: () => "<pre>server: { data: async () => {} }</pre>"\n}'
275
- )
276
-
277
- test('server/meta not fooled by regex literal containing double quote (/"/g)',
278
- 'const esc = s => s.replace(/"/g, \'&quot;\')\nexport default {\n meta: { title: \'Page\', description: \'Desc\' },\n server: { data: async () => [] },\n view: () => \'\'\n}',
279
- 'const esc = s => s.replace(/"/g, \'&quot;\')\nexport default {\n view: () => \'\'\n}'
280
- )
281
-
282
- test('server not fooled by line 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
-
287
- test('server not fooled by block comment containing double quote',
288
- 'export default {\n /* server: "comment" */\n server: { data: async () => [] },\n view: () => \'\'\n}',
289
- 'export default {\n /* server: "comment" */\n view: () => \'\'\n}'
290
- )
291
-
292
- test('meta object is stripped (client runtime never reads spec.meta)',
293
- `export default {\n route: '/blog',\n meta: {\n title: async (ctx) => fetchPost(ctx).frontmatter.title,\n styles: ['/pulse-ui.css'],\n },\n view: () => ''\n}`,
294
- `export default {\n route: '/blog',\n view: () => ''\n}`
295
- )
296
-
297
- test('meta: inside a template literal string is not stripped',
298
- 'export default {\n view: () => `<pre>meta: { title: \'Home\' }</pre>`\n}',
299
- 'export default {\n view: () => `<pre>meta: { title: \'Home\' }</pre>`\n}'
300
- )
301
-
302
- // ---------------------------------------------------------------------------
303
- // Server-only import stripping
304
- // ---------------------------------------------------------------------------
305
-
306
- console.log('\nServer-only import stripping\n')
307
-
308
- test('strips @invisibleloop/pulse/md import and const declaration',
309
- `import { md } from '@invisibleloop/pulse/md'\nconst fetchPost = md('src/content/blog.md')\nexport default {\n route: '/blog',\n view: () => ''\n}`,
310
- `export default {\n route: '/blog',\n view: () => ''\n}`
311
- )
312
-
313
- test('strips named import with multiple bindings',
314
- `import { md, parseMd } from '@invisibleloop/pulse/md'\nconst post = md('src/content/post.md')\nconst result = parseMd('# Hello')\nexport default {\n view: () => ''\n}`,
315
- `export default {\n view: () => ''\n}`
316
- )
317
-
318
- test('strips import + const then strips meta that used the const',
319
- `import { md } from '@invisibleloop/pulse/md'\nconst fetchPost = md('src/content/trees.md')\nexport default {\n route: '/blog',\n meta: {\n title: async (ctx) => (await fetchPost(ctx)).frontmatter.title,\n styles: ['/pulse-ui.css'],\n },\n server: { post: fetchPost },\n view: () => ''\n}`,
320
- `export default {\n route: '/blog',\n view: () => ''\n}`
321
- )
322
-
323
- test('does not strip non-server imports',
324
- `import { nav, footer } from '@invisibleloop/pulse/ui'\nexport default {\n view: () => nav({})\n}`,
325
- `import { nav, footer } from '@invisibleloop/pulse/ui'\nexport default {\n view: () => nav({})\n}`
326
- )
327
-
328
- test('strips node:fs default import',
329
- `import fs from 'node:fs'\nexport default {\n server: { data: async () => fs.readFileSync('x') },\n view: () => ''\n}`,
330
- `export default {\n view: () => ''\n}`
331
- )
332
-
333
- test('strips node:fs/promises named import',
334
- `import { readFile } from 'node:fs/promises'\nexport default {\n server: { data: async () => readFile('x') },\n view: () => ''\n}`,
335
- `export default {\n view: () => ''\n}`
336
- )
337
-
338
- test('strips node:path and node:url together',
339
- `import path from 'node:path'\nimport { fileURLToPath } from 'node:url'\nexport default {\n server: { data: async (ctx) => path.join('a', 'b') },\n view: () => ''\n}`,
340
- `export default {\n view: () => ''\n}`
341
- )
342
-
343
- test('strips node:fs default import and top-level const using it',
344
- `import fs from 'node:fs'\nconst DIR = fs.readdirSync('.')\nexport default {\n view: () => ''\n}`,
345
- `export default {\n view: () => ''\n}`
346
- )
347
-
348
- // ---------------------------------------------------------------------------
349
- // Dev-mode node:* import stripping (same regex used in src/cli/dev.js serveFile)
350
- // ---------------------------------------------------------------------------
351
-
352
- console.log('\nDev-mode node:* import stripping\n')
353
-
354
- const DEV_NODE_RE = /^[ \t]*import\s+(?:\{[^}]*\}|[\w*]+(?:\s+as\s+\w+)?)\s+from\s+['"]node:[^'"]+['"]\s*;?[ \t]*\n?/gm
355
-
356
- function devStrip(source) {
357
- return source.replace(DEV_NODE_RE, '')
358
- }
359
-
360
- function devTest(label, input, expected) {
361
- const got = devStrip(input).trim()
362
- const exp = expected.trim()
363
- if (got === exp) {
364
- console.log(' \u2713 ' + label)
365
- pass++
366
- } else {
367
- console.log(' \u2717 ' + label)
368
- console.log(' expected: ' + JSON.stringify(exp))
369
- console.log(' got: ' + JSON.stringify(got))
370
- fail++
371
- }
372
- }
373
-
374
- devTest('strips default import (node:fs)',
375
- `import fs from 'node:fs'\nconst DIR = '/journal'\nexport default { view: () => '' }`,
376
- `const DIR = '/journal'\nexport default { view: () => '' }`
377
- )
378
-
379
- devTest('strips named import (node:path)',
380
- `import { join, resolve } from 'node:path'\nexport default { view: () => '' }`,
381
- `export default { view: () => '' }`
382
- )
383
-
384
- devTest('strips multiple node: imports',
385
- `import fs from 'node:fs'\nimport path from 'node:path'\nexport default { view: () => '' }`,
386
- `export default { view: () => '' }`
387
- )
388
-
389
- devTest('strips node:fs/promises named import',
390
- `import { readFile } from 'node:fs/promises'\nexport default { view: () => '' }`,
391
- `export default { view: () => '' }`
392
- )
393
-
394
- devTest('does not strip non-node imports',
395
- `import { nav } from '@invisibleloop/pulse/ui'\nexport default { view: () => '' }`,
396
- `import { nav } from '@invisibleloop/pulse/ui'\nexport default { view: () => '' }`
397
- )
398
-
399
- devTest('leaves module-level constants intact',
400
- `import fs from 'node:fs'\nconst JOURNAL_DIR = '/Users/andy/journal'\nexport default { view: () => '' }`,
401
- `const JOURNAL_DIR = '/Users/andy/journal'\nexport default { view: () => '' }`
402
- )
403
-
404
- // ---------------------------------------------------------------------------
405
-
406
- console.log('\nStore + new spec fields\n')
407
-
408
- test('sitemap enumerator is stripped (server-only)',
409
- `export default {\n route: '/blog/:slug',\n sitemap: async () => ['/blog/a'],\n view: () => '',\n}`,
410
- `export default {\n route: '/blog/:slug',\n view: () => '',\n}`
411
- )
412
-
413
- test('submit is NOT stripped — it collides with the common submit action name',
414
- `export default {\n route: '/x',\n actions: {\n submit: {\n run: async () => 1,\n },\n },\n view: () => '',\n}`,
415
- `export default {\n route: '/x',\n actions: {\n submit: {\n run: async () => 1,\n },\n },\n view: () => '',\n}`
416
- )
417
-
418
- test('store definition: server fetchers stripped, state and mutations survive',
419
- `export default {\n state: { basket: [] },\n server: {\n nav: async () => fetchNav(),\n },\n mutations: {\n add: (store, item) => ({ basket: [...store.basket, item] }),\n },\n}`,
420
- `export default {\n state: { basket: [] },\n mutations: {\n add: (store, item) => ({ basket: [...store.basket, item] }),\n },\n}`
421
- )
422
-
423
- test('package-root import (pushStore) is stripped — the root export is the server itself',
424
- `import { pushStore } from '@invisibleloop/pulse'\nexport default {\n route: '/staff',\n view: () => '',\n}`,
425
- `export default {\n route: '/staff',\n view: () => '',\n}`
426
- )
427
-
428
- test('subpath imports (/ui) are NOT stripped by the root entry',
429
- `import { button } from '@invisibleloop/pulse/ui'\nexport default {\n route: '/x',\n view: () => button({ label: 'x' }),\n}`,
430
- `import { button } from '@invisibleloop/pulse/ui'\nexport default {\n route: '/x',\n view: () => button({ label: 'x' }),\n}`
431
- )
432
-
433
- // ---------------------------------------------------------------------------
434
- // needsClientBundle — must match discover.js's needsHydration() exactly.
435
- // Regression: found via dogfooding. This check used to be "does the spec have
436
- // a `view:` key" alone, so a purely server-rendered page (a redirect handler,
437
- // a custom 404, a spec.submit-only form) still got a client bundle generated —
438
- // and if that page imported a local server-only helper module (e.g. a
439
- // node:fs-based persistence layer), the production build failed outright when
440
- // esbuild tried to bundle Node built-ins for the browser.
441
- // ---------------------------------------------------------------------------
442
-
443
- console.log('\nneedsClientBundle — matches discover.js hydration logic\n')
444
-
445
- function boolTest(label, input, expected) {
446
- const got = needsClientBundle(strip(input))
447
- if (got === expected) {
448
- console.log(' ✓ ' + label)
449
- pass++
450
- } else {
451
- console.log(' ✗ ' + label)
452
- console.log(' expected: ' + expected)
453
- console.log(' got: ' + got)
454
- fail++
455
- }
456
- }
457
-
458
- boolTest('a view-only spec (no mutations/actions/persist) does not need a bundle — the exact bug this fixes',
459
- `export default {\n route: '/:code',\n guard: async (ctx) => {},\n view: () => '',\n}`,
460
- false
461
- )
462
-
463
- boolTest('a spec.submit-only page does not need a bundle',
464
- `export default {\n route: '/',\n submit: async (ctx) => ({ redirect: '/' }),\n view: () => '',\n}`,
465
- false
466
- )
467
-
468
- boolTest('mutations require a bundle',
469
- `export default {\n route: '/x',\n state: {},\n mutations: {\n inc: (s) => ({ n: s.n + 1 }),\n },\n view: () => '',\n}`,
470
- true
471
- )
472
-
473
- boolTest('actions require a bundle',
474
- `export default {\n route: '/x',\n state: {},\n actions: {\n save: { run: async () => {} },\n },\n view: () => '',\n}`,
475
- true
476
- )
477
-
478
- boolTest('persist requires a bundle',
479
- `export default {\n route: '/x',\n state: { count: 0 },\n persist: ['count'],\n view: () => '',\n}`,
480
- true
481
- )
482
-
483
- boolTest('store alone (no local mutations/actions) requires a bundle — regression: mount() is what wires up data-store-event dispatch AND the live SSE store-push subscription; a store-only page with no client bundle would silently never receive pushStore() broadcasts and have a dead data-store-event button',
484
- `export default {\n route: '/x',\n store: ['count'],\n state: {},\n view: (state, server) => \`\${server.count}\`,\n}`,
485
- true
486
- )
487
-
488
- // ---------------------------------------------------------------------------
489
-
490
- console.log(`\n${pass + fail} tests: ${pass} passed, ${fail} failed\n`)
491
- if (fail > 0) process.exit(1)