@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.
- package/.github/workflows/publish.yml +11 -20
- package/README.md +1 -1
- package/docs/public/.pulse-ui-version +1 -1
- package/docs/public/docs.css +19 -1
- package/docs/public/pulse-ui.css +1 -0
- package/docs/server.js +5 -2
- package/docs/src/lib/highlight.js +57 -13
- package/docs/src/lib/layout.js +5 -2
- package/docs/src/pages/faq.js +5 -2
- package/docs/src/pages/home.js +9 -5
- package/docs/src/pages/meta.js +21 -0
- package/docs/src/pages/routing.js +12 -1
- package/package.json +1 -1
- package/public/pulse-ui.css +2 -2
- package/src/agent/guide-components.md +1 -1
- package/src/agent/guide-routing.md +20 -0
- package/src/agent/guide-spec.md +10 -1
- package/src/agent/guide-styles.md +16 -1
- package/src/agent/workflow.md +1 -1
- package/src/cli/scaffold.js +63 -2
- package/src/mcp/server.js +34 -18
- package/src/server/index.js +26 -7
- package/src/server/server.test.js +47 -0
- package/src/ui/stat.js +1 -1
- package/src/ui/ui.test.js +6 -0
- package/docs/public/dist/accessibility.boot-5DVTARJU.js +0 -115
- package/docs/public/dist/actions.boot-P66HKQEM.js +0 -164
- package/docs/public/dist/auth.boot-IMAJAUPH.js +0 -140
- package/docs/public/dist/caching.boot-DVR6KDE7.js +0 -53
- package/docs/public/dist/components--accordion.boot-3HVKMNWC.js +0 -11
- package/docs/public/dist/components--alert.boot-GCEXOZAC.js +0 -6
- package/docs/public/dist/components--app-badge.boot-DVT3GCHJ.js +0 -6
- package/docs/public/dist/components--avatar.boot-PSW24EVA.js +0 -5
- package/docs/public/dist/components--badge.boot-TYDY2RMK.js +0 -7
- package/docs/public/dist/components--banner.boot-EI5PZSZK.js +0 -7
- package/docs/public/dist/components--breadcrumbs.boot-SMA2E2GO.js +0 -34
- package/docs/public/dist/components--button.boot-J54BQM2E.js +0 -23
- package/docs/public/dist/components--card.boot-PZGNDIB6.js +0 -138
- package/docs/public/dist/components--carousel.boot-TP6LPFZZ.js +0 -12
- package/docs/public/dist/components--charts.boot-2EOYQWKL.js +0 -108
- package/docs/public/dist/components--checkbox.boot-DS5BSL6T.js +0 -54
- package/docs/public/dist/components--cluster.boot-HHVIBBJG.js +0 -9
- package/docs/public/dist/components--code-window.boot-2GR2DV33.js +0 -20
- package/docs/public/dist/components--container.boot-7LOOGK2K.js +0 -5
- package/docs/public/dist/components--cta.boot-FSNZ5YRT.js +0 -11
- package/docs/public/dist/components--divider.boot-3NI2C3QG.js +0 -6
- package/docs/public/dist/components--empty.boot-YX2UR3PV.js +0 -7
- package/docs/public/dist/components--feature.boot-MUD7NSUO.js +0 -13
- package/docs/public/dist/components--fieldset.boot-J7BYHMKF.js +0 -19
- package/docs/public/dist/components--fileupload.boot-NIKVTTPD.js +0 -52
- package/docs/public/dist/components--footer.boot-EYUK5FRG.js +0 -14
- package/docs/public/dist/components--grid.boot-URDQVDDR.js +0 -59
- package/docs/public/dist/components--heading.boot-BPQKU43E.js +0 -44
- package/docs/public/dist/components--hero.boot-4RAPRGAB.js +0 -17
- package/docs/public/dist/components--icons.boot-ZITNU5JP.js +0 -68
- package/docs/public/dist/components--image.boot-XEEGHQZF.js +0 -19
- package/docs/public/dist/components--input.boot-SGASZG5K.js +0 -7
- package/docs/public/dist/components--list.boot-W3XC5MHD.js +0 -55
- package/docs/public/dist/components--media.boot-5VFIETZO.js +0 -13
- package/docs/public/dist/components--modal.boot-RZUYXBN2.js +0 -47
- package/docs/public/dist/components--nav.boot-ODBOHU7O.js +0 -33
- package/docs/public/dist/components--pricing.boot-4AQ4ZVBY.js +0 -21
- package/docs/public/dist/components--progress.boot-GHAGYZOK.js +0 -30
- package/docs/public/dist/components--prose.boot-QANJL6JI.js +0 -67
- package/docs/public/dist/components--pullquote.boot-Q2WMNAZU.js +0 -22
- package/docs/public/dist/components--radio.boot-TJRDQ2OL.js +0 -75
- package/docs/public/dist/components--rating.boot-QBAN6DEL.js +0 -38
- package/docs/public/dist/components--search.boot-PXH5O5AG.js +0 -17
- package/docs/public/dist/components--section.boot-AQGIYHWW.js +0 -12
- package/docs/public/dist/components--segmented.boot-BEVTKEJO.js +0 -33
- package/docs/public/dist/components--select.boot-47X5RHOC.js +0 -10
- package/docs/public/dist/components--slider.boot-PSRRX7XL.js +0 -47
- package/docs/public/dist/components--spinner.boot-MZ5MO2OH.js +0 -22
- package/docs/public/dist/components--stack.boot-DI4NJXBF.js +0 -9
- package/docs/public/dist/components--stat.boot-QMFUWBQT.js +0 -9
- package/docs/public/dist/components--stepper.boot-34PP2NEV.js +0 -22
- package/docs/public/dist/components--table.boot-FCQGSFIQ.js +0 -11
- package/docs/public/dist/components--testimonial.boot-DWQPDKYG.js +0 -11
- package/docs/public/dist/components--textarea.boot-QVXLBOJ5.js +0 -4
- package/docs/public/dist/components--timeline.boot-26LN52P2.js +0 -95
- package/docs/public/dist/components--toggle.boot-IQQEI76S.js +0 -29
- package/docs/public/dist/components--tooltip.boot-LGHCO6NN.js +0 -9
- package/docs/public/dist/components.boot-SE6PQ4P7.js +0 -103
- package/docs/public/dist/config.boot-DTRRWUE6.js +0 -126
- package/docs/public/dist/constraints.boot-DUHDZBMC.js +0 -71
- package/docs/public/dist/deploy.boot-SLAD3NI2.js +0 -163
- package/docs/public/dist/docs-8e3d4b5c.css +0 -1
- package/docs/public/dist/extending.boot-UA3CN243.js +0 -159
- package/docs/public/dist/faq.boot-6EQAWLQR.js +0 -43
- package/docs/public/dist/getting-started.boot-TDKIFL5U.js +0 -86
- package/docs/public/dist/guard.boot-AUHAWTG4.js +0 -80
- package/docs/public/dist/home.boot-BVQXRH32.js +0 -383
- package/docs/public/dist/how-it-works.boot-LTWAKWKW.js +0 -104
- package/docs/public/dist/hydration.boot-JRM6IPJL.js +0 -78
- package/docs/public/dist/images.boot-M6ZVKTZS.js +0 -80
- package/docs/public/dist/manifest.json +0 -94
- package/docs/public/dist/meta.boot-7NXGPHR4.js +0 -79
- package/docs/public/dist/mutations.boot-F6F43UDX.js +0 -79
- package/docs/public/dist/navigation.boot-AOXWS3ZF.js +0 -57
- package/docs/public/dist/performance.boot-C3UPCOBK.js +0 -98
- package/docs/public/dist/persist.boot-WT32PQOQ.js +0 -61
- package/docs/public/dist/project-structure.boot-FB3LRVJ4.js +0 -63
- package/docs/public/dist/prompt-examples.boot-YKR4VDK4.js +0 -31
- package/docs/public/dist/pulse-ui-81a85c03.css +0 -1
- package/docs/public/dist/raw-responses.boot-M4KA5YXL.js +0 -104
- package/docs/public/dist/routing.boot-FNX5FDGH.js +0 -70
- package/docs/public/dist/runtime-B73WLANC.js +0 -1
- package/docs/public/dist/runtime-KO4BHUQ3.js +0 -49
- package/docs/public/dist/runtime-L2HNXIHW.js +0 -59
- package/docs/public/dist/runtime-QFURDKA2.js +0 -5
- package/docs/public/dist/runtime-UVPXO4IR.js +0 -375
- package/docs/public/dist/runtime-VMJA3Z4N.js +0 -10
- package/docs/public/dist/runtime-ZJ4FXT5O.js +0 -11
- package/docs/public/dist/server-api.boot-K7X3LCFB.js +0 -219
- package/docs/public/dist/server-data.boot-Y7HQYC4R.js +0 -157
- package/docs/public/dist/slash-commands.boot-V2UV7OW2.js +0 -26
- package/docs/public/dist/spec.boot-2WU7ZHCV.js +0 -159
- package/docs/public/dist/state.boot-B24GUE3R.js +0 -73
- package/docs/public/dist/store.boot-TLIB4XHH.js +0 -150
- package/docs/public/dist/streaming.boot-W2DZSMW4.js +0 -80
- package/docs/public/dist/stripe.boot-QN3C2GEL.js +0 -164
- package/docs/public/dist/supabase.boot-BG4XXLZE.js +0 -303
- package/docs/public/dist/testing.boot-6U4WKMTE.js +0 -130
- 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><button></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><b>bold</b></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};
|