@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/README.md +76 -6
- package/dist/cjs/index.js +8 -1
- package/dist/cjs/prefetch.js +199 -0
- package/dist/cjs/render.js +335 -36
- package/dist/esm/index.js +9 -2
- package/dist/esm/prefetch.js +170 -0
- package/dist/esm/render.js +335 -36
- package/index.js +10 -3
- package/package.json +3 -3
- package/prefetch.js +256 -0
- package/render.js +374 -29
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@symbo.ls/brender",
|
|
3
|
-
"version": "3.6.
|
|
4
|
-
"license": "
|
|
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.
|
|
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
|
+
}
|