@symbo.ls/brender 3.6.4 → 3.6.7

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,7 +1,7 @@
1
1
  {
2
2
  "name": "@symbo.ls/brender",
3
- "version": "3.6.4",
4
- "license": "MIT",
3
+ "version": "3.6.7",
4
+ "license": "CC-BY-NC-4.0",
5
5
  "type": "module",
6
6
  "module": "./dist/esm/index.js",
7
7
  "main": "./dist/cjs/index.js",
@@ -36,7 +36,7 @@
36
36
  "dev:rita": "node examples/serve-rita.js"
37
37
  },
38
38
  "dependencies": {
39
- "@symbo.ls/helmet": "^3.6.4",
39
+ "@symbo.ls/helmet": "^3.6.7",
40
40
  "linkedom": "^0.16.8"
41
41
  },
42
42
  "devDependencies": {
package/prefetch.js ADDED
@@ -0,0 +1,256 @@
1
+ /**
2
+ * SSR data prefetching for brender.
3
+ *
4
+ * Walks a page definition tree, collects `fetch` declarations,
5
+ * executes them against the configured DB adapter (e.g. Supabase),
6
+ * and returns the fetched data keyed by element path + `as` field.
7
+ *
8
+ * This allows brender to inject fetched data into element state
9
+ * before rendering, so the SSR output matches the client-side SPA.
10
+ */
11
+
12
+ const isFunction = (v) => typeof v === 'function'
13
+ const isArray = (v) => Array.isArray(v)
14
+ const isObject = (v) => v !== null && typeof v === 'object' && !Array.isArray(v)
15
+
16
+ /**
17
+ * Resolve a fetch config's params — if it's a function, call it with
18
+ * a mock element and state to get static params for SSR.
19
+ */
20
+ const resolveParams = (params, mockState) => {
21
+ if (!params) return undefined
22
+ if (isFunction(params)) {
23
+ try {
24
+ // Build a mock element with basic call() support
25
+ const mockEl = {
26
+ state: mockState || {},
27
+ props: {},
28
+ call: () => undefined,
29
+ __ref: {}
30
+ }
31
+ return params(mockEl, mockState || {})
32
+ } catch {
33
+ return undefined
34
+ }
35
+ }
36
+ return params
37
+ }
38
+
39
+ /**
40
+ * Normalize a single fetch declaration to a standard config object.
41
+ */
42
+ const normalizeFetchConfig = (cfg, elementState) => {
43
+ if (!cfg) return null
44
+ if (typeof cfg === 'string') return { from: cfg, method: 'select' }
45
+
46
+ const resolved = isFunction(cfg) ? null : { ...cfg }
47
+ if (!resolved) return null
48
+
49
+ // Default method
50
+ if (!resolved.method) resolved.method = 'select'
51
+
52
+ // Resolve function params
53
+ if (isFunction(resolved.params)) {
54
+ resolved.params = resolveParams(resolved.params, elementState)
55
+ }
56
+
57
+ // Skip mutations and event-bound fetches
58
+ const isMutation = resolved.method === 'insert' || resolved.method === 'update' ||
59
+ resolved.method === 'upsert' || resolved.method === 'delete'
60
+ if (isMutation) return null
61
+ if (resolved.on && resolved.on !== 'create') return null
62
+
63
+ return resolved
64
+ }
65
+
66
+ /**
67
+ * Walk a page definition tree and collect all fetch declarations
68
+ * along with the path to the element's state.
69
+ *
70
+ * Returns: [{ config, stateKey, path, elementState }]
71
+ */
72
+ const collectFetchDeclarations = (def, path = '') => {
73
+ if (!def || typeof def !== 'object') return []
74
+ // Skip function values and arrays of primitives
75
+ if (isFunction(def)) return []
76
+
77
+ const results = []
78
+ const elementState = def.state || {}
79
+
80
+ if (def.fetch) {
81
+ const fetchDefs = isArray(def.fetch) ? def.fetch : [def.fetch]
82
+ for (const fd of fetchDefs) {
83
+ const config = normalizeFetchConfig(fd, elementState)
84
+ if (config) {
85
+ results.push({
86
+ config,
87
+ stateKey: config.as,
88
+ path,
89
+ elementState
90
+ })
91
+ }
92
+ }
93
+ }
94
+
95
+ // Recurse into child elements (capitalized keys = child elements)
96
+ for (const key in def) {
97
+ if (key === 'fetch' || key === 'state' || key === 'props' ||
98
+ key === 'attr' || key === 'on' || key === 'define' ||
99
+ key === 'childExtends' || key === 'childProps' || key === 'childrenAs') continue
100
+ // Child elements have capitalized keys
101
+ if (key.charAt(0) >= 'A' && key.charAt(0) <= 'Z' && isObject(def[key])) {
102
+ results.push(...collectFetchDeclarations(def[key], path ? `${path}.${key}` : key))
103
+ }
104
+ }
105
+
106
+ return results
107
+ }
108
+
109
+ /**
110
+ * Create a Supabase adapter from project config for SSR use.
111
+ */
112
+ const createSSRAdapter = async (dbConfig) => {
113
+ if (!dbConfig) return null
114
+
115
+ const { adapter, createClient, url, key, projectId } = dbConfig
116
+ if (adapter !== 'supabase') return null
117
+
118
+ const supabaseUrl = url || (projectId && `https://${projectId}.supabase.co`)
119
+ if (!supabaseUrl || !key) return null
120
+
121
+ let clientFactory = createClient
122
+ if (!clientFactory) {
123
+ try {
124
+ const mod = await import('@supabase/supabase-js')
125
+ clientFactory = mod.createClient
126
+ } catch {
127
+ return null
128
+ }
129
+ }
130
+
131
+ const client = clientFactory(supabaseUrl, key)
132
+
133
+ return {
134
+ rpc: ({ from, params }) => client.rpc(from, params),
135
+ select: async ({ from, select: sel, params, limit, offset, order, single }) => {
136
+ let q = client.from(from).select(sel || '*')
137
+ if (params) {
138
+ for (const k in params) {
139
+ const v = params[k]
140
+ if (v === null) q = q.is(k, null)
141
+ else if (Array.isArray(v)) q = q.in(k, v)
142
+ else q = q.eq(k, v)
143
+ }
144
+ }
145
+ if (order) {
146
+ const orderBy = typeof order === 'string' ? order : order.by
147
+ q = q.order(orderBy, { ascending: order.asc !== false })
148
+ }
149
+ if (limit) q = q.limit(limit)
150
+ if (offset) q = q.range(offset, offset + (limit || 20) - 1)
151
+ if (single) q = q.single()
152
+ return q
153
+ }
154
+ }
155
+ }
156
+
157
+ /**
158
+ * Execute a single fetch config against the adapter.
159
+ * Returns the fetched data, or null on error.
160
+ */
161
+ const executeSingle = async (adapter, config) => {
162
+ try {
163
+ const { method, from, params, transform, limit, offset, order, single } = config
164
+ let result
165
+
166
+ if (method === 'rpc') {
167
+ result = await adapter.rpc({ from, params })
168
+ } else {
169
+ result = await adapter.select({ from, select: config.select, params, limit, offset, order, single })
170
+ }
171
+
172
+ let data = result?.data ?? null
173
+ if (result?.error) {
174
+ return null
175
+ }
176
+
177
+ // Apply transform
178
+ if (data && transform && isFunction(transform)) {
179
+ try { data = transform(data) } catch { /* skip transform errors */ }
180
+ }
181
+
182
+ return data
183
+ } catch {
184
+ return null
185
+ }
186
+ }
187
+
188
+ /**
189
+ * Prefetch all data for a page route.
190
+ *
191
+ * @param {object} data - Full project data (from loadProject)
192
+ * @param {string} route - Route to prefetch for (e.g. '/', '/blog')
193
+ * @param {object} [options]
194
+ * @returns {Promise<Map<string, object>>} Map of element path → { [stateKey]: data }
195
+ */
196
+ export const prefetchPageData = async (data, route = '/', options = {}) => {
197
+ const pages = data.pages || {}
198
+ const pageDef = pages[route]
199
+ if (!pageDef) return new Map()
200
+
201
+ const dbConfig = data.config?.db || data.settings?.db || data.db
202
+ if (!dbConfig) return new Map()
203
+
204
+ const adapter = await createSSRAdapter(dbConfig)
205
+ if (!adapter) return new Map()
206
+
207
+ const declarations = collectFetchDeclarations(pageDef)
208
+ if (!declarations.length) return new Map()
209
+
210
+ const stateUpdates = new Map()
211
+
212
+ // Execute all fetches in parallel
213
+ const results = await Promise.allSettled(
214
+ declarations.map(async ({ config, stateKey, path }) => {
215
+ const fetchedData = await executeSingle(adapter, config)
216
+ if (fetchedData !== null && stateKey) {
217
+ const existing = stateUpdates.get(path) || {}
218
+ existing[stateKey] = fetchedData
219
+ stateUpdates.set(path, existing)
220
+ }
221
+ })
222
+ )
223
+
224
+ return stateUpdates
225
+ }
226
+
227
+ /**
228
+ * Inject prefetched data into a page definition's state objects.
229
+ * Mutates the definition in place (caller should deep-clone first).
230
+ *
231
+ * @param {object} pageDef - Page definition (will be mutated)
232
+ * @param {Map<string, object>} stateUpdates - Map from prefetchPageData
233
+ */
234
+ export const injectPrefetchedState = (pageDef, stateUpdates) => {
235
+ if (!stateUpdates || !stateUpdates.size) return
236
+
237
+ for (const [path, data] of stateUpdates) {
238
+ // Navigate to the element at the path
239
+ let target = pageDef
240
+ if (path) {
241
+ const parts = path.split('.')
242
+ for (const part of parts) {
243
+ if (!target || typeof target !== 'object') break
244
+ target = target[part]
245
+ }
246
+ }
247
+
248
+ if (target && typeof target === 'object') {
249
+ // Merge fetched data into the element's state
250
+ if (!target.state || typeof target.state !== 'object') {
251
+ target.state = {}
252
+ }
253
+ Object.assign(target.state, data)
254
+ }
255
+ }
256
+ }