@invisibleloop/pulse 0.1.28 → 0.1.30

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.
Files changed (124) hide show
  1. package/.github/workflows/publish.yml +11 -20
  2. package/README.md +1 -1
  3. package/docs/public/.pulse-ui-version +1 -1
  4. package/docs/public/docs.css +19 -1
  5. package/docs/public/pulse-ui.css +1 -0
  6. package/docs/server.js +5 -2
  7. package/docs/src/lib/highlight.js +57 -13
  8. package/docs/src/lib/layout.js +5 -2
  9. package/docs/src/pages/faq.js +5 -2
  10. package/docs/src/pages/home.js +9 -5
  11. package/docs/src/pages/meta.js +21 -0
  12. package/docs/src/pages/routing.js +12 -1
  13. package/package.json +1 -1
  14. package/public/pulse-ui.css +2 -2
  15. package/src/agent/guide-components.md +1 -1
  16. package/src/agent/guide-routing.md +20 -0
  17. package/src/agent/guide-spec.md +10 -1
  18. package/src/agent/guide-styles.md +16 -1
  19. package/src/agent/workflow.md +1 -1
  20. package/src/cli/scaffold.js +63 -2
  21. package/src/mcp/server.js +34 -18
  22. package/src/server/index.js +26 -7
  23. package/src/server/server.test.js +47 -0
  24. package/src/ui/stat.js +1 -1
  25. package/src/ui/ui.test.js +6 -0
  26. package/docs/public/dist/accessibility.boot-5DVTARJU.js +0 -115
  27. package/docs/public/dist/actions.boot-P66HKQEM.js +0 -164
  28. package/docs/public/dist/auth.boot-IMAJAUPH.js +0 -140
  29. package/docs/public/dist/caching.boot-DVR6KDE7.js +0 -53
  30. package/docs/public/dist/components--accordion.boot-3HVKMNWC.js +0 -11
  31. package/docs/public/dist/components--alert.boot-GCEXOZAC.js +0 -6
  32. package/docs/public/dist/components--app-badge.boot-DVT3GCHJ.js +0 -6
  33. package/docs/public/dist/components--avatar.boot-PSW24EVA.js +0 -5
  34. package/docs/public/dist/components--badge.boot-TYDY2RMK.js +0 -7
  35. package/docs/public/dist/components--banner.boot-EI5PZSZK.js +0 -7
  36. package/docs/public/dist/components--breadcrumbs.boot-SMA2E2GO.js +0 -34
  37. package/docs/public/dist/components--button.boot-J54BQM2E.js +0 -23
  38. package/docs/public/dist/components--card.boot-PZGNDIB6.js +0 -138
  39. package/docs/public/dist/components--carousel.boot-TP6LPFZZ.js +0 -12
  40. package/docs/public/dist/components--charts.boot-2EOYQWKL.js +0 -108
  41. package/docs/public/dist/components--checkbox.boot-DS5BSL6T.js +0 -54
  42. package/docs/public/dist/components--cluster.boot-HHVIBBJG.js +0 -9
  43. package/docs/public/dist/components--code-window.boot-2GR2DV33.js +0 -20
  44. package/docs/public/dist/components--container.boot-7LOOGK2K.js +0 -5
  45. package/docs/public/dist/components--cta.boot-FSNZ5YRT.js +0 -11
  46. package/docs/public/dist/components--divider.boot-3NI2C3QG.js +0 -6
  47. package/docs/public/dist/components--empty.boot-YX2UR3PV.js +0 -7
  48. package/docs/public/dist/components--feature.boot-MUD7NSUO.js +0 -13
  49. package/docs/public/dist/components--fieldset.boot-J7BYHMKF.js +0 -19
  50. package/docs/public/dist/components--fileupload.boot-NIKVTTPD.js +0 -52
  51. package/docs/public/dist/components--footer.boot-EYUK5FRG.js +0 -14
  52. package/docs/public/dist/components--grid.boot-URDQVDDR.js +0 -59
  53. package/docs/public/dist/components--heading.boot-BPQKU43E.js +0 -44
  54. package/docs/public/dist/components--hero.boot-4RAPRGAB.js +0 -17
  55. package/docs/public/dist/components--icons.boot-ZITNU5JP.js +0 -68
  56. package/docs/public/dist/components--image.boot-XEEGHQZF.js +0 -19
  57. package/docs/public/dist/components--input.boot-SGASZG5K.js +0 -7
  58. package/docs/public/dist/components--list.boot-W3XC5MHD.js +0 -55
  59. package/docs/public/dist/components--media.boot-5VFIETZO.js +0 -13
  60. package/docs/public/dist/components--modal.boot-RZUYXBN2.js +0 -47
  61. package/docs/public/dist/components--nav.boot-ODBOHU7O.js +0 -33
  62. package/docs/public/dist/components--pricing.boot-4AQ4ZVBY.js +0 -21
  63. package/docs/public/dist/components--progress.boot-GHAGYZOK.js +0 -30
  64. package/docs/public/dist/components--prose.boot-QANJL6JI.js +0 -67
  65. package/docs/public/dist/components--pullquote.boot-Q2WMNAZU.js +0 -22
  66. package/docs/public/dist/components--radio.boot-TJRDQ2OL.js +0 -75
  67. package/docs/public/dist/components--rating.boot-QBAN6DEL.js +0 -38
  68. package/docs/public/dist/components--search.boot-PXH5O5AG.js +0 -17
  69. package/docs/public/dist/components--section.boot-AQGIYHWW.js +0 -12
  70. package/docs/public/dist/components--segmented.boot-BEVTKEJO.js +0 -33
  71. package/docs/public/dist/components--select.boot-47X5RHOC.js +0 -10
  72. package/docs/public/dist/components--slider.boot-PSRRX7XL.js +0 -47
  73. package/docs/public/dist/components--spinner.boot-MZ5MO2OH.js +0 -22
  74. package/docs/public/dist/components--stack.boot-DI4NJXBF.js +0 -9
  75. package/docs/public/dist/components--stat.boot-QMFUWBQT.js +0 -9
  76. package/docs/public/dist/components--stepper.boot-34PP2NEV.js +0 -22
  77. package/docs/public/dist/components--table.boot-FCQGSFIQ.js +0 -11
  78. package/docs/public/dist/components--testimonial.boot-DWQPDKYG.js +0 -11
  79. package/docs/public/dist/components--textarea.boot-QVXLBOJ5.js +0 -4
  80. package/docs/public/dist/components--timeline.boot-26LN52P2.js +0 -95
  81. package/docs/public/dist/components--toggle.boot-IQQEI76S.js +0 -29
  82. package/docs/public/dist/components--tooltip.boot-LGHCO6NN.js +0 -9
  83. package/docs/public/dist/components.boot-SE6PQ4P7.js +0 -103
  84. package/docs/public/dist/config.boot-DTRRWUE6.js +0 -126
  85. package/docs/public/dist/constraints.boot-DUHDZBMC.js +0 -71
  86. package/docs/public/dist/deploy.boot-SLAD3NI2.js +0 -163
  87. package/docs/public/dist/docs-8e3d4b5c.css +0 -1
  88. package/docs/public/dist/extending.boot-UA3CN243.js +0 -159
  89. package/docs/public/dist/faq.boot-6EQAWLQR.js +0 -43
  90. package/docs/public/dist/getting-started.boot-TDKIFL5U.js +0 -86
  91. package/docs/public/dist/guard.boot-AUHAWTG4.js +0 -80
  92. package/docs/public/dist/home.boot-BVQXRH32.js +0 -383
  93. package/docs/public/dist/how-it-works.boot-LTWAKWKW.js +0 -104
  94. package/docs/public/dist/hydration.boot-JRM6IPJL.js +0 -78
  95. package/docs/public/dist/images.boot-M6ZVKTZS.js +0 -80
  96. package/docs/public/dist/manifest.json +0 -94
  97. package/docs/public/dist/meta.boot-7NXGPHR4.js +0 -79
  98. package/docs/public/dist/mutations.boot-F6F43UDX.js +0 -79
  99. package/docs/public/dist/navigation.boot-AOXWS3ZF.js +0 -57
  100. package/docs/public/dist/performance.boot-C3UPCOBK.js +0 -98
  101. package/docs/public/dist/persist.boot-WT32PQOQ.js +0 -61
  102. package/docs/public/dist/project-structure.boot-FB3LRVJ4.js +0 -63
  103. package/docs/public/dist/prompt-examples.boot-YKR4VDK4.js +0 -31
  104. package/docs/public/dist/pulse-ui-81a85c03.css +0 -1
  105. package/docs/public/dist/raw-responses.boot-M4KA5YXL.js +0 -104
  106. package/docs/public/dist/routing.boot-FNX5FDGH.js +0 -70
  107. package/docs/public/dist/runtime-B73WLANC.js +0 -1
  108. package/docs/public/dist/runtime-KO4BHUQ3.js +0 -49
  109. package/docs/public/dist/runtime-L2HNXIHW.js +0 -59
  110. package/docs/public/dist/runtime-QFURDKA2.js +0 -5
  111. package/docs/public/dist/runtime-UVPXO4IR.js +0 -375
  112. package/docs/public/dist/runtime-VMJA3Z4N.js +0 -10
  113. package/docs/public/dist/runtime-ZJ4FXT5O.js +0 -11
  114. package/docs/public/dist/server-api.boot-K7X3LCFB.js +0 -219
  115. package/docs/public/dist/server-data.boot-Y7HQYC4R.js +0 -157
  116. package/docs/public/dist/slash-commands.boot-V2UV7OW2.js +0 -26
  117. package/docs/public/dist/spec.boot-2WU7ZHCV.js +0 -159
  118. package/docs/public/dist/state.boot-B24GUE3R.js +0 -73
  119. package/docs/public/dist/store.boot-TLIB4XHH.js +0 -150
  120. package/docs/public/dist/streaming.boot-W2DZSMW4.js +0 -80
  121. package/docs/public/dist/stripe.boot-QN3C2GEL.js +0 -164
  122. package/docs/public/dist/supabase.boot-BG4XXLZE.js +0 -303
  123. package/docs/public/dist/testing.boot-6U4WKMTE.js +0 -130
  124. package/docs/public/dist/validation.boot-PQHYGW5B.js +0 -100
@@ -1,303 +0,0 @@
1
- import{a as t}from"./runtime-QFURDKA2.js";import{a as n,b as l,c as u,d as c,e as s,g as e,i as a}from"./runtime-L2HNXIHW.js";import{a as o,b as p}from"./runtime-B73WLANC.js";var{prev:d,next:m}=n("/supabase"),i={route:"/supabase",meta:{title:"Supabase \u2014 Pulse Docs",description:"Integrate Supabase database queries, authentication, and file storage with Pulse server fetchers, actions, and guard.",styles:["/docs.css"]},state:{},view:()=>l({currentHref:"/supabase",prev:d,next:m,content:`
2
- ${u("Supabase")}
3
- ${c("Supabase provides Postgres, authentication, and file storage. In Pulse, all Supabase queries run in server fetchers \u2014 credentials stay on the server, query results are filtered before serialisation, and <code>guard</code> enforces session checks before any data is fetched.")}
4
-
5
- ${s("setup","Setup")}
6
- ${e(t("npm install @supabase/supabase-js","bash"))}
7
- ${e(t(`# .env
8
- SUPABASE_URL=https://your-project.supabase.co
9
- SUPABASE_ANON_KEY=your-anon-key
10
- SUPABASE_SERVICE_KEY=your-service-role-key # server-side only`,"bash"))}
11
- <p>Create two client helpers \u2014 one for public queries (respects Row Level Security), one for admin operations:</p>
12
- ${e(t(`// src/lib/supabase.js
13
- import { createClient } from '@supabase/supabase-js'
14
-
15
- const { SUPABASE_URL, SUPABASE_ANON_KEY, SUPABASE_SERVICE_KEY } = process.env
16
-
17
- // Public client \u2014 use in server fetchers for user-scoped queries
18
- export function supabase(accessToken) {
19
- const client = createClient(SUPABASE_URL, SUPABASE_ANON_KEY, {
20
- global: accessToken
21
- ? { headers: { Authorization: \`Bearer \${accessToken}\` } }
22
- : {},
23
- })
24
- return client
25
- }
26
-
27
- // Admin client \u2014 bypasses RLS; use only for trusted server operations
28
- export const admin = createClient(SUPABASE_URL, SUPABASE_SERVICE_KEY)`,"js"))}
29
- ${a("warning","The service key bypasses Row Level Security and has full database access. Keep it server-side only \u2014 in environment variables that are never sent to the browser or included in logs.")}
30
-
31
- ${s("querying","Querying data")}
32
- <p>Call Supabase inside <code>server</code> fetchers \u2014 they run on the server before every render. The result is passed to <code>view</code> as the second argument.</p>
33
- ${e(t(`// src/pages/posts.js
34
- import { supabase } from '../lib/supabase.js'
35
- import { escHtml } from '@invisibleloop/pulse/html'
36
-
37
- export default {
38
- route: '/posts',
39
-
40
- server: {
41
- posts: async () => {
42
- const { data, error } = await supabase().from('posts')
43
- .select('id, title, slug, created_at')
44
- .order('created_at', { ascending: false })
45
- .limit(20)
46
-
47
- if (error) throw new Error(error.message)
48
- return data
49
- },
50
- },
51
-
52
- state: {},
53
-
54
- view: (_state, server) => \`
55
- <main id="main-content">
56
- <h1>Posts</h1>
57
- <ul>
58
- \${server.posts.map(p => \`
59
- <li><a href="/posts/\${escHtml(p.slug)}">\${escHtml(p.title)}</a></li>
60
- \`).join('')}
61
- </ul>
62
- </main>
63
- \`,
64
- }`,"js"))}
65
- ${a("tip","Add <code>serverTtl</code> to cache the Supabase query result in-process. A 60-second TTL on a public listing page means one database hit per minute across all visitors \u2014 Supabase stays fast even under load.")}
66
-
67
- ${s("auth-setup","Authentication")}
68
- <p>Supabase Auth issues a JWT access token and a refresh token on login. Store both in <code>httpOnly</code> cookies \u2014 they are never accessible to JavaScript and survive page navigations.</p>
69
-
70
- ${s("login","Login page")}
71
- ${e(t(`// src/pages/auth/login.js
72
- import { supabase } from '../../lib/supabase.js'
73
- import { escHtml } from '@invisibleloop/pulse/html'
74
-
75
- export default {
76
- route: '/login',
77
-
78
- guard: async (ctx) => {
79
- // Already logged in \u2014 send to dashboard
80
- if (ctx.cookies.access_token) return { redirect: '/dashboard' }
81
- },
82
-
83
- state: { status: 'idle', error: '' },
84
-
85
- view: (state) => \`
86
- <main id="main-content">
87
- <h1>Sign in</h1>
88
- <form data-action="login">
89
- <label for="email">Email</label>
90
- <input id="email" name="email" type="email" required autocomplete="email">
91
-
92
- <label for="password">Password</label>
93
- <input id="password" name="password" type="password" required autocomplete="current-password">
94
-
95
- \${state.error ? \`<p role="alert">\${escHtml(state.error)}</p>\` : ''}
96
-
97
- <button type="submit">
98
- \${state.status === 'loading' ? 'Signing in\u2026' : 'Sign in'}
99
- </button>
100
- </form>
101
- </main>
102
- \`,
103
-
104
- actions: {
105
- login: {
106
- onStart: () => ({ status: 'loading', error: '' }),
107
-
108
- run: async (_state, _server, formData) => {
109
- const { data, error } = await supabase().auth.signInWithPassword({
110
- email: formData.get('email'),
111
- password: formData.get('password'),
112
- })
113
- if (error) throw new Error(error.message)
114
- return data.session
115
- },
116
-
117
- onSuccess: (state, session, ctx) => {
118
- const opts = { httpOnly: true, sameSite: 'Lax', path: '/' }
119
- ctx.setCookie('access_token', session.access_token, { ...opts, maxAge: 3600 })
120
- ctx.setCookie('refresh_token', session.refresh_token, { ...opts, maxAge: 604800 })
121
- ctx.setHeader('Location', '/dashboard')
122
- return { status: 'success' }
123
- },
124
-
125
- onError: (_state, err) => ({
126
- status: 'idle',
127
- error: err.message || 'Sign in failed',
128
- }),
129
- },
130
- },
131
- }`,"js"))}
132
-
133
- ${s("guard","Protecting routes")}
134
- <p>Use <code>guard</code> to verify the session before any server data is fetched. Pass the token to your fetchers so Supabase enforces Row Level Security for that user.</p>
135
- ${e(t(`// src/pages/dashboard.js
136
- import { supabase, admin } from '../lib/supabase.js'
137
-
138
- export default {
139
- route: '/dashboard',
140
-
141
- guard: async (ctx) => {
142
- const token = ctx.cookies.access_token
143
- if (!token) return { redirect: '/login' }
144
-
145
- // Verify the token is still valid
146
- const { error } = await supabase(token).auth.getUser()
147
- if (error) return { redirect: '/login' }
148
- },
149
-
150
- server: {
151
- // Token is available in guard ctx \u2014 pass it through server state or
152
- // re-read from cookies in the fetcher
153
- profile: async (ctx) => {
154
- const { data } = await supabase(ctx.cookies.access_token)
155
- .from('profiles')
156
- .select('name, plan')
157
- .single()
158
- return data
159
- },
160
- },
161
-
162
- state: {},
163
- view: (_state, server) => \`
164
- <main id="main-content">
165
- <h1>Dashboard</h1>
166
- <p>Welcome, \${server.profile.name}</p>
167
- </main>
168
- \`,
169
- }`,"js"))}
170
- ${a("note","Row Level Security is enforced by Postgres, not by your application code. A policy that checks <code>auth.uid() = user_id</code> means a bug in your server code cannot accidentally expose another user's data \u2014 the database rejects the query before it returns anything.")}
171
-
172
- ${s("logout","Logout")}
173
- ${e(t(`// src/pages/auth/logout.js
174
- export default {
175
- route: '/logout',
176
- contentType: 'text/html',
177
-
178
- render: (ctx) => {
179
- const opts = { httpOnly: true, sameSite: 'Lax', path: '/', maxAge: 0 }
180
- ctx.setCookie('access_token', '', opts)
181
- ctx.setCookie('refresh_token', '', opts)
182
- return { redirect: '/login' }
183
- },
184
- }`,"js"))}
185
-
186
- ${s("signup","Sign up")}
187
- ${e(t(`// src/pages/auth/signup.js
188
- import { supabase } from '../../lib/supabase.js'
189
- import { escHtml } from '@invisibleloop/pulse/html'
190
-
191
- export default {
192
- route: '/signup',
193
-
194
- state: { status: 'idle', error: '' },
195
-
196
- view: (state) => \`
197
- <main id="main-content">
198
- <h1>Create account</h1>
199
- \${state.status === 'success'
200
- ? '<p role="status">Check your email to confirm your account.</p>'
201
- : \`
202
- <form data-action="signup">
203
- <label for="email">Email</label>
204
- <input id="email" name="email" type="email" required>
205
-
206
- <label for="password">Password</label>
207
- <input id="password" name="password" type="password" required minlength="8">
208
-
209
- \${state.error ? \`<p role="alert">\${escHtml(state.error)}</p>\` : ''}
210
-
211
- <button type="submit">
212
- \${state.status === 'loading' ? 'Creating account\u2026' : 'Create account'}
213
- </button>
214
- </form>
215
- \`}
216
- </main>
217
- \`,
218
-
219
- actions: {
220
- signup: {
221
- onStart: () => ({ status: 'loading', error: '' }),
222
-
223
- run: async (_state, _server, formData) => {
224
- const { error } = await supabase().auth.signUp({
225
- email: formData.get('email'),
226
- password: formData.get('password'),
227
- })
228
- if (error) throw new Error(error.message)
229
- },
230
-
231
- onSuccess: () => ({ status: 'success', error: '' }),
232
- onError: (_state, err) => ({ status: 'idle', error: err.message }),
233
- },
234
- },
235
- }`,"js"))}
236
-
237
- ${s("storage","File storage")}
238
- <p>Upload files to Supabase Storage from an action. The file arrives in <code>FormData</code> \u2014 convert it to an <code>ArrayBuffer</code> and pass it to the storage client.</p>
239
- ${e(t(`// src/pages/upload.js
240
- import { admin } from '../lib/supabase.js'
241
- import { escHtml } from '@invisibleloop/pulse/html'
242
-
243
- export default {
244
- route: '/upload',
245
-
246
- state: { status: 'idle', url: '', error: '' },
247
-
248
- view: (state) => \`
249
- <main id="main-content">
250
- <h1>Upload file</h1>
251
- <form data-action="upload" enctype="multipart/form-data">
252
- <input name="file" type="file" accept="image/*" required>
253
- <button type="submit">
254
- \${state.status === 'loading' ? 'Uploading\u2026' : 'Upload'}
255
- </button>
256
- </form>
257
- \${state.url ? \`<img src="\${escHtml(state.url)}" alt="Uploaded file" width="400" height="300">\` : ''}
258
- \${state.error ? \`<p role="alert">\${escHtml(state.error)}</p>\` : ''}
259
- </main>
260
- \`,
261
-
262
- actions: {
263
- upload: {
264
- onStart: () => ({ status: 'loading', error: '' }),
265
-
266
- run: async (_state, _server, formData) => {
267
- const file = formData.get('file')
268
- const buffer = await file.arrayBuffer()
269
- const ext = file.name.split('.').pop()
270
- const path = \`\${crypto.randomUUID()}.\${ext}\`
271
-
272
- const { error } = await admin.storage
273
- .from('uploads')
274
- .upload(path, buffer, { contentType: file.type })
275
-
276
- if (error) throw new Error(error.message)
277
-
278
- const { data } = admin.storage.from('uploads').getPublicUrl(path)
279
- return data.publicUrl
280
- },
281
-
282
- onSuccess: (_state, url) => ({ status: 'idle', url }),
283
- onError: (_state, err) => ({ status: 'idle', error: err.message }),
284
- },
285
- },
286
- }`,"js"))}
287
-
288
- ${s("rls","Row Level Security")}
289
- <p>Always enable RLS on tables that hold user data. A minimal policy that lets users read only their own rows:</p>
290
- ${e(t(`-- Enable RLS on the table
291
- alter table posts enable row level security;
292
-
293
- -- Users can only select their own posts
294
- create policy "users read own posts"
295
- on posts for select
296
- using (auth.uid() = user_id);
297
-
298
- -- Users can only insert rows for themselves
299
- create policy "users insert own posts"
300
- on posts for insert
301
- with check (auth.uid() = user_id);`,"bash"))}
302
- ${a("tip","The public client (with the user's access token) applies Row Level Security \u2014 queries are automatically scoped to that user's data. The admin client bypasses RLS entirely, which is what you want for webhook handlers, background jobs, and admin operations, but not for user-scoped queries.")}
303
- `})};var r=document.getElementById("pulse-root");r&&!r.dataset.pulseMounted&&(r.dataset.pulseMounted="1",o(i,r,window.__PULSE_SERVER__||{},{ssr:!0}),p(r,o));var _=i;export{_ as default};
@@ -1,130 +0,0 @@
1
- import{a as o}from"./runtime-QFURDKA2.js";import{a as d,b as a,c as i,d as l,e,g as t,h as s,i as u}from"./runtime-L2HNXIHW.js";import{a as c,b as m}from"./runtime-B73WLANC.js";var{prev:p,next:h}=d("/testing"),n={route:"/testing",meta:{title:"Testing \u2014 Pulse Docs",description:"Test Pulse view functions with renderSync and render \u2014 query the HTML output with CSS-like selectors, no DOM required.",styles:["/docs.css"]},state:{},view:()=>a({currentHref:"/testing",prev:p,next:h,content:`
2
- ${i("Testing")}
3
- ${l("Pulse ships a built-in testing helper at <code>@invisibleloop/pulse/testing</code>. Render a spec's view in tests and query the HTML output with CSS-like selectors \u2014 no DOM, no jsdom, no extra dependencies.")}
4
-
5
- ${e("quick-start","Quick start")}
6
- ${t(o(`import { renderSync, render } from '@invisibleloop/pulse/testing'
7
- import assert from 'node:assert/strict'
8
- import spec from './src/pages/counter.js'
9
-
10
- // Synchronous \u2014 calls view directly, mock state and server
11
- const result = renderSync(spec, { state: { count: 5 } })
12
- assert(result.has('button'))
13
- assert.equal(result.get('#count').text, '5')
14
-
15
- // Async \u2014 runs real spec.server fetchers (or pass server to mock them)
16
- const result = await render(productSpec, {
17
- server: { product: { id: 1, name: 'Widget', price: 9.99 } }
18
- })
19
- assert.equal(result.get('h1').text, 'Widget')
20
- assert.equal(result.count('li'), 3)`,"js"))}
21
-
22
- ${e("render-sync","renderSync(spec, options?)")}
23
- <p>Synchronous. Calls the view function directly \u2014 no server fetcher resolution. The fastest path for unit testing pure view functions.</p>
24
- ${s(["Option","Type","Default","Description"],[["<code>state</code>","<code>object</code>","<code>{}</code>","State overrides merged with <code>spec.state</code>."],["<code>server</code>","<code>object</code>","<code>{}</code>","Server state passed directly to the view. Fetchers are never called."]])}
25
- ${t(o(`import { renderSync } from '@invisibleloop/pulse/testing'
26
-
27
- const result = renderSync(formSpec, {
28
- state: { name: 'Alice', email: 'alice@example.com' },
29
- server: { plans: [{ id: 'pro', label: 'Pro' }] },
30
- })`,"js"))}
31
-
32
- ${e("render-async","render(spec, options?)")}
33
- <p>Async. Two modes \u2014 mock or integration:</p>
34
- <ul>
35
- <li><strong>Mock mode</strong> \u2014 pass <code>server</code> to use that data directly. Fetchers are not called. Fast and deterministic.</li>
36
- <li><strong>Integration mode</strong> \u2014 omit <code>server</code> and real <code>spec.server</code> fetchers run. Pass <code>ctx</code> to set params, cookies, headers, etc.</li>
37
- </ul>
38
- ${s(["Option","Type","Default","Description"],[["<code>state</code>","<code>object</code>","<code>{}</code>","State overrides merged with <code>spec.state</code>."],["<code>server</code>","<code>object</code>","<code>undefined</code>","Server state passed directly to the view. When set, fetchers are skipped entirely."],["<code>ctx</code>","<code>object</code>","<code>{}</code>","Request context passed to <code>spec.server</code> fetchers (integration mode only). Accepts <code>params</code>, <code>query</code>, <code>cookies</code>, <code>headers</code>, etc."]])}
39
- ${t(o(`import { render } from '@invisibleloop/pulse/testing'
40
-
41
- // Mock \u2014 skip fetchers
42
- const result = await render(productSpec, {
43
- server: { product: { id: 42, name: 'Gadget' } }
44
- })
45
-
46
- // Integration \u2014 real fetchers, with ctx
47
- const result = await render(productSpec, {
48
- ctx: { params: { id: '42' }, cookies: { session: 'abc' } }
49
- })`,"js"))}
50
-
51
- ${e("render-result","RenderResult")}
52
- <p>Both functions return the same <code>RenderResult</code> object.</p>
53
- ${s(["Property / method","Returns","Description"],[["<code>.html</code>","<code>string</code>","Raw HTML string from the view."],["<code>.state</code>","<code>object</code>","Client state used for rendering."],["<code>.server</code>","<code>object</code>","Server state used for rendering."],["<code>.text()</code>","<code>string</code>","All text content \u2014 tags stripped, entities decoded, whitespace collapsed."],["<code>.has(selector)</code>","<code>boolean</code>","True if any element matches selector."],["<code>.find(selector)</code>","<code>Element | null</code>","First matching element, or null."],["<code>.get(selector)</code>","<code>Element</code>","First matching element. Throws with a clear message if not found."],["<code>.findAll(selector)</code>","<code>Element[]</code>","All matching elements."],["<code>.count(selector)</code>","<code>number</code>","Number of matching elements."],["<code>.attr(selector, name)</code>","<code>string | null</code>","Attribute value of the first matching element. Null if element or attribute absent."]])}
54
-
55
- ${e("element","Element")}
56
- <p>Elements returned by <code>find()</code>, <code>get()</code>, and <code>findAll()</code> support the same query methods scoped to their own subtree.</p>
57
- ${s(["Property / method","Returns","Description"],[["<code>.tag</code>","<code>string</code>","Tag name (lowercase)."],["<code>.text</code>","<code>string</code>","All text content within the element, whitespace-collapsed."],["<code>.attrs</code>","<code>object</code>","Parsed attribute map. Boolean attrs (e.g. <code>disabled</code>) have value <code>true</code>."],["<code>.attr(name)</code>","<code>string | null</code>",'Get one attribute. Returns <code>""</code> for boolean attrs, <code>null</code> if absent \u2014 mirrors <code>getAttribute()</code>.'],["<code>.find(selector)</code>","<code>Element | null</code>","First matching descendant."],["<code>.findAll(selector)</code>","<code>Element[]</code>","All matching descendants."],["<code>.has(selector)</code>","<code>boolean</code>","True if any descendant matches selector."]])}
58
-
59
- ${e("selectors","Supported selectors")}
60
- <p>The selector engine supports the most common patterns. Descendant combinators (<code>div p</code>) are not supported \u2014 use <code>element.findAll()</code> to search within a matched element instead.</p>
61
- ${s(["Selector","Example","Matches"],[["Tag","<code>button</code>","Any <code>&lt;button&gt;</code>"],["Class","<code>.ui-btn</code>","Elements with that class"],["ID","<code>#submit</code>","Element with that id"],["Attribute present","<code>[disabled]</code>","Elements with a <code>disabled</code> attribute"],["Attribute value",'<code>[type="submit"]</code>',"Elements where <code>type</code> equals <code>submit</code>"],["Compound","<code>button.primary[disabled]</code>","All conditions on the same element"]])}
62
- ${t(o(`result.has('button') // any <button>
63
- result.has('.ui-btn--primary') // BEM modifier class
64
- result.has('[data-action="submit"]') // data attribute
65
- result.has('input[type="email"][required]') // compound
66
- result.get('form').findAll('input') // inputs inside form`,"js"))}
67
-
68
- ${e("patterns","Common patterns")}
69
- ${t(o(`// Assert an element exists and check its text
70
- assert.equal(result.get('h1').text, 'Page Title')
71
-
72
- // Assert an element does NOT exist
73
- assert(!result.has('.error-message'))
74
-
75
- // Check an attribute value
76
- assert.equal(result.attr('input[name="email"]', 'type'), 'email')
77
-
78
- // Boolean attributes \u2014 attr() returns '' (not 'disabled')
79
- assert.equal(result.attr('[disabled]', 'disabled'), '')
80
- assert(result.get('[disabled]').attr('disabled') === '')
81
-
82
- // Count elements
83
- assert.equal(result.count('li'), 3)
84
-
85
- // Inspect all items
86
- const items = result.findAll('li')
87
- assert.equal(items[0].text, 'Alpha')
88
- assert.equal(items[1].text, 'Beta')
89
-
90
- // Scope a search to a subtree
91
- const form = result.get('form')
92
- assert(form.has('button[type="submit"]'))
93
- assert.equal(form.count('input'), 2)
94
-
95
- // Text content decodes entities
96
- // <p>&lt;b&gt;bold&lt;/b&gt;</p> \u2192 text === '<b>bold</b>'
97
- assert.equal(result.get('p').text, '<b>bold</b>')`,"js"))}
98
-
99
- ${e("test-file","Example test file")}
100
- ${t(o(`/**
101
- * src/pages/counter.test.js
102
- * run: node src/pages/counter.test.js
103
- */
104
- import { test } from 'node:test'
105
- import assert from 'node:assert/strict'
106
- import { renderSync } from '@invisibleloop/pulse/testing'
107
- import spec from './counter.js'
108
-
109
- test('renders the current count', () => {
110
- const result = renderSync(spec, { state: { count: 7 } })
111
- assert.equal(result.get('#count').text, '7')
112
- })
113
-
114
- test('increment mutation returns count + 1', () => {
115
- const next = spec.mutations.increment({ count: 0 })
116
- assert.equal(next.count, 1)
117
- })
118
-
119
- test('decrement mutation returns count - 1', () => {
120
- const next = spec.mutations.decrement({ count: 5 })
121
- assert.equal(next.count, 4)
122
- })
123
-
124
- test('view renders increment and decrement buttons', () => {
125
- const result = renderSync(spec)
126
- assert(result.has('[data-event="increment"]'))
127
- assert(result.has('[data-event="decrement"]'))
128
- })`,"js"))}
129
- ${u("note","Use <code>renderSync</code> for mutations and pure view tests \u2014 it's synchronous and needs no <code>await</code>. Use <code>render</code> when your spec has <code>server</code> fetchers you want to exercise for integration coverage.")}
130
- `})};var r=document.getElementById("pulse-root");r&&!r.dataset.pulseMounted&&(r.dataset.pulseMounted="1",c(n,r,window.__PULSE_SERVER__||{},{ssr:!0}),m(r,c));var S=n;export{S as default};
@@ -1,100 +0,0 @@
1
- import{a as r}from"./runtime-QFURDKA2.js";import{a as s,b as l,c as n,d as c,e,g as t,h as o,i as u}from"./runtime-L2HNXIHW.js";import{a as i,b as m}from"./runtime-B73WLANC.js";var{prev:p,next:f}=s("/validation"),d={route:"/validation",meta:{title:"Validation \u2014 Pulse Docs",description:"Declarative validation rules in Pulse \u2014 syntax, formats, dot-path notation, and error handling.",styles:["/docs.css"]},state:{},view:()=>l({currentHref:"/validation",prev:p,next:f,content:`
2
- ${n("Validation")}
3
- ${c("Validation rules are declared in the spec, co-located with the state they guard. When an action sets <code>validate: true</code>, Pulse enforces every rule before the async work runs. Invalid data cannot reach <code>run()</code>.")}
4
-
5
- ${e("declaring","Declaring validation rules")}
6
- <p>The <code>validation</code> field maps dot-path state keys to rule objects:</p>
7
- ${t(r(`export default {
8
- route: '/signup',
9
- state: {
10
- fields: { name: '', email: '', age: '', website: '' },
11
- },
12
- validation: {
13
- 'fields.name': { required: true, minLength: 2, maxLength: 100 },
14
- 'fields.email': { required: true, format: 'email' },
15
- 'fields.age': { required: true, min: 18, max: 120 },
16
- 'fields.website': { format: 'url' }, // optional field, but must be valid URL if provided
17
- },
18
- // ...
19
- }`,"js"))}
20
-
21
- ${e("rules","Available rules")}
22
- ${o(["Rule","Type","Description"],[["<code>required</code>","<code>boolean</code>","Field must be present and non-empty."],["<code>minLength</code>","<code>number</code>","String must be at least N characters."],["<code>maxLength</code>","<code>number</code>","String must be at most N characters."],["<code>min</code>","<code>number</code>","Numeric value must be \u2265 N."],["<code>max</code>","<code>number</code>","Numeric value must be \u2264 N."],["<code>pattern</code>","<code>RegExp | string</code>","Value must match the regular expression."],["<code>format</code>","<code>string</code>","Named format: <code>email</code>, <code>url</code>, or <code>numeric</code>."]])}
23
-
24
- ${e("formats","Named formats")}
25
- ${o(["Format","What it checks"],[["<code>email</code>","Basic email structure \u2014 must contain <code>@</code> and a domain."],["<code>url</code>","Must start with <code>http://</code> or <code>https://</code>."],["<code>numeric</code>","Must consist entirely of digit characters."]])}
26
-
27
- ${e("dot-paths","Dot-path notation")}
28
- <p>Validation keys are dot-paths into the current <code>state</code>, allowing nested fields to be validated without any special syntax:</p>
29
- ${t(r(`state: {
30
- billing: {
31
- address: { street: '', city: '', postcode: '' },
32
- card: { number: '', expiry: '' },
33
- },
34
- }
35
-
36
- validation: {
37
- 'billing.address.street': { required: true },
38
- 'billing.address.city': { required: true },
39
- 'billing.address.postcode': { required: true, pattern: /^[A-Z]{1,2}\\d[A-Z\\d]? \\d[A-Z]{2}$/i },
40
- 'billing.card.number': { required: true, format: 'numeric', minLength: 16, maxLength: 16 },
41
- 'billing.card.expiry': { required: true },
42
- }`,"js"))}
43
-
44
- ${e("when-runs","When validation runs")}
45
- <p>Validation only runs when an action declares <code>validate: true</code>. The order is enforced by the framework:</p>
46
- <ol>
47
- <li><code>onStart</code> captures <code>FormData</code> values into state</li>
48
- <li>Validation reads those values from state using dot-paths</li>
49
- <li>If any rules fail, <code>onError</code> is called immediately \u2014 <code>run</code> is skipped</li>
50
- </ol>
51
- ${u("note","Validation reads from <strong>state</strong>, not from raw <code>FormData</code>. <code>onStart</code> must copy form values into state first \u2014 this is what makes them available to dot-path rules.")}
52
-
53
- ${e("error-structure","Error structure")}
54
- <p>When validation fails, the runtime throws an error object with a <code>validation</code> array:</p>
55
- ${t(r(`{
56
- message: 'Validation failed',
57
- validation: [
58
- { field: 'fields.email', rule: 'format', message: 'Must be a valid email address' },
59
- { field: 'fields.name', rule: 'required', message: 'Required' },
60
- { field: 'fields.age', rule: 'min', message: 'Must be at least 18' },
61
- ]
62
- }`,"js"))}
63
- <p>In your action's <code>onError</code>, check for <code>err?.validation</code> to distinguish validation errors from other failures:</p>
64
- ${t(r(`onError: (state, err) => ({
65
- status: 'error',
66
- errors: err?.validation ?? [{ message: err.message }],
67
- })`,"js"))}
68
-
69
- ${e("rendering","Rendering errors")}
70
- <p>The errors array maps to UI in the view \u2014 a global error list, or inline errors using the <code>field</code> property to place them next to each input:</p>
71
- ${t(r(`view: (state) => {
72
- const errFor = (field) => {
73
- const e = state.errors.find(e => e.field === field)
74
- return e ? \`<p class="field-error">\${e.message}</p>\` : ''
75
- }
76
-
77
- return \`
78
- <form data-action="submit">
79
- <label>
80
- Email
81
- <input name="email" type="email" value="\${state.fields.email}">
82
- \${errFor('fields.email')}
83
- </label>
84
- <label>
85
- Name
86
- <input name="name" type="text" value="\${state.fields.name}">
87
- \${errFor('fields.name')}
88
- </label>
89
- <button>Submit</button>
90
- </form>
91
- \`
92
- }`,"js"))}
93
-
94
- ${e("optional-fields","Optional fields")}
95
- <p>Omit <code>required: true</code> for optional fields. Other rules (format, minLength, etc.) are only enforced when the field has a value \u2014 empty optional fields always pass:</p>
96
- ${t(r(`validation: {
97
- // website is optional, but must be a valid URL if provided
98
- 'fields.website': { format: 'url' },
99
- }`,"js"))}
100
- `})};var a=document.getElementById("pulse-root");a&&!a.dataset.pulseMounted&&(a.dataset.pulseMounted="1",i(d,a,window.__PULSE_SERVER__||{},{ssr:!0}),m(a,i));var q=d;export{q as default};