@symbo.ls/brender 3.8.9 → 3.14.1

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/load.js DELETED
@@ -1,122 +0,0 @@
1
- import { resolve, join } from 'path'
2
- import { existsSync, mkdirSync, writeFileSync, unlinkSync } from 'fs'
3
- import { tmpdir } from 'os'
4
- import { randomBytes } from 'crypto'
5
-
6
- /**
7
- * Bundles a module entry point with esbuild so that extensionless imports,
8
- * bare specifiers, and other bundler conventions resolve correctly.
9
- * Returns the default + named exports of the bundled module, or null on failure.
10
- */
11
- const bundleAndImport = async (entryPath) => {
12
- if (!existsSync(entryPath)) return null
13
-
14
- let esbuild
15
- try {
16
- esbuild = await import('esbuild')
17
- } catch {
18
- // Fallback: try raw import if esbuild is not available
19
- try { return await import(entryPath) } catch { return null }
20
- }
21
-
22
- const outFile = join(tmpdir(), `brender_${randomBytes(8).toString('hex')}.mjs`)
23
-
24
- try {
25
- await esbuild.build({
26
- entryPoints: [entryPath],
27
- bundle: true,
28
- format: 'esm',
29
- platform: 'node',
30
- outfile: outFile,
31
- write: true,
32
- logLevel: 'silent',
33
- // Mark node builtins as external
34
- external: ['fs', 'path', 'os', 'crypto', 'url', 'http', 'https', 'stream', 'util', 'events', 'buffer', 'child_process', 'worker_threads', 'net', 'tls', 'dns', 'dgram', 'zlib', 'assert', 'querystring', 'string_decoder', 'readline', 'perf_hooks', 'async_hooks', 'v8', 'vm', 'cluster', 'inspector', 'module', 'process', 'tty'],
35
- })
36
-
37
- const mod = await import(`file://${outFile}`)
38
- return mod
39
- } catch {
40
- // Fallback: try raw import
41
- try { return await import(entryPath) } catch { return null } // fallback: module not found via this resolver, trying next
42
- } finally {
43
- try { unlinkSync(outFile) } catch {} // cleanup: ignore if temp file already removed
44
- }
45
- }
46
-
47
- /**
48
- * Loads a Symbols project from a filesystem path.
49
- * Expects the standard symbols/ directory structure.
50
- *
51
- * Uses esbuild to bundle each module so that extensionless imports
52
- * and other bundler conventions work in Node.js.
53
- *
54
- * Used for prebuild scenarios where brender runs locally
55
- * against a project directory (e.g. `smbls build --prerender`).
56
- *
57
- * For server runtime rendering, pass the project data
58
- * directly to render() instead.
59
- */
60
- export const loadProject = async (projectPath) => {
61
- const symbolsDir = resolve(projectPath, 'symbols')
62
-
63
- const [
64
- appModule,
65
- stateModule,
66
- configModule,
67
- depsModule,
68
- componentsModule,
69
- snippetsModule,
70
- pagesModule,
71
- functionsModule,
72
- methodsModule,
73
- designSystemModule,
74
- filesModule
75
- ] = await Promise.all([
76
- bundleAndImport(join(symbolsDir, 'app.js')),
77
- bundleAndImport(join(symbolsDir, 'state.js')),
78
- bundleAndImport(join(symbolsDir, 'config.js')),
79
- bundleAndImport(join(symbolsDir, 'dependencies.js')),
80
- bundleAndImport(join(symbolsDir, 'components', 'index.js')),
81
- bundleAndImport(join(symbolsDir, 'snippets', 'index.js')),
82
- bundleAndImport(join(symbolsDir, 'pages', 'index.js')),
83
- bundleAndImport(join(symbolsDir, 'functions', 'index.js')),
84
- bundleAndImport(join(symbolsDir, 'methods', 'index.js')),
85
- bundleAndImport(join(symbolsDir, 'designSystem', 'index.js')),
86
- bundleAndImport(join(symbolsDir, 'files', 'index.js'))
87
- ])
88
-
89
- // Spread into plain objects — ESM module namespaces are non-extensible,
90
- // which breaks downstream code that adds properties (e.g. polyglot functions).
91
- return {
92
- app: { ...(appModule?.default || {}) },
93
- state: { ...(stateModule?.default || {}) },
94
- dependencies: { ...(depsModule?.default || {}) },
95
- components: { ...(componentsModule || {}) },
96
- snippets: { ...(snippetsModule || {}) },
97
- pages: { ...(pagesModule?.default || {}) },
98
- functions: { ...(functionsModule || {}) },
99
- methods: { ...(methodsModule || {}) },
100
- designSystem: { ...(designSystemModule?.default || {}) },
101
- files: { ...(filesModule?.default || {}) },
102
- config: { ...(configModule?.default || {}) }
103
- }
104
- }
105
-
106
- /**
107
- * Renders all routes from a project directory and returns
108
- * a map of route -> { html, metadata }.
109
- * Useful for static prebuilding.
110
- */
111
- export const loadAndRenderAll = async (projectPath, renderFn) => {
112
- const data = await loadProject(projectPath)
113
- const pages = data.pages || {}
114
- const routes = Object.keys(pages)
115
- const results = {}
116
-
117
- for (const route of routes) {
118
- results[route] = await renderFn(data, { route })
119
- }
120
-
121
- return results
122
- }
package/metadata.js DELETED
@@ -1,5 +0,0 @@
1
- /**
2
- * Re-exports metadata utilities from the shared helmet plugin.
3
- * Brender uses these for SSR head generation.
4
- */
5
- export { extractMetadata, generateHeadHtml, resolveMetadata, applyMetadata } from '@symbo.ls/helmet'
package/prefetch.js DELETED
@@ -1,365 +0,0 @@
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
- // Always import @supabase/supabase-js for SSR — the serialized createClient
122
- // from project data is a no-op placeholder that won't produce a real client.
123
- let clientFactory
124
- try {
125
- const mod = await import('@supabase/supabase-js')
126
- clientFactory = mod.createClient
127
- } catch {
128
- // Fall back to provided createClient only if import fails
129
- clientFactory = createClient
130
- }
131
- if (!clientFactory) return null
132
-
133
- const client = clientFactory(supabaseUrl, key)
134
-
135
- return {
136
- rpc: ({ from, params }) => client.rpc(from, params),
137
- select: async ({ from, select: sel, params, limit, offset, order, single }) => {
138
- let q = client.from(from).select(sel || '*')
139
- if (params) {
140
- for (const k in params) {
141
- const v = params[k]
142
- if (v === null) q = q.is(k, null)
143
- else if (Array.isArray(v)) q = q.in(k, v)
144
- else q = q.eq(k, v)
145
- }
146
- }
147
- if (order) {
148
- const orderBy = typeof order === 'string' ? order : order.by
149
- q = q.order(orderBy, { ascending: order.asc !== false })
150
- }
151
- if (limit) q = q.limit(limit)
152
- if (offset) q = q.range(offset, offset + (limit || 20) - 1)
153
- if (single) q = q.single()
154
- return q
155
- }
156
- }
157
- }
158
-
159
- /**
160
- * Execute a single fetch config against the adapter.
161
- * Returns the fetched data, or null on error.
162
- */
163
- const executeSingle = async (adapter, config) => {
164
- try {
165
- const { method, from, params, transform, limit, offset, order, single } = config
166
- let result
167
-
168
- if (method === 'rpc') {
169
- result = await adapter.rpc({ from, params })
170
- } else {
171
- result = await adapter.select({ from, select: config.select, params, limit, offset, order, single })
172
- }
173
-
174
- let data = result?.data ?? null
175
- if (result?.error) {
176
- return null
177
- }
178
-
179
- // Apply transform
180
- if (data && transform && isFunction(transform)) {
181
- try { data = transform(data) } catch { /* skip transform errors */ }
182
- }
183
-
184
- return data
185
- } catch {
186
- return null
187
- }
188
- }
189
-
190
- /**
191
- * Prefetch all data for a page route.
192
- *
193
- * @param {object} data - Full project data (from loadProject)
194
- * @param {string} route - Route to prefetch for (e.g. '/', '/blog')
195
- * @param {object} [options]
196
- * @returns {Promise<Map<string, object>>} Map of element path → { [stateKey]: data }
197
- */
198
- export const prefetchPageData = async (data, route = '/', options = {}) => {
199
- const pages = data.pages || {}
200
- const pageDef = pages[route]
201
- if (!pageDef) return new Map()
202
-
203
- const config = data.config || data.settings || {}
204
- const dbConfig = config.fetch || data.fetch || config.db || data.db
205
- if (!dbConfig) return new Map()
206
-
207
- const adapter = await createSSRAdapter(dbConfig)
208
- if (!adapter) return new Map()
209
-
210
- const declarations = collectFetchDeclarations(pageDef)
211
- if (!declarations.length) return new Map()
212
-
213
- const stateUpdates = new Map()
214
-
215
- // Execute all fetches in parallel
216
- const results = await Promise.allSettled(
217
- declarations.map(async ({ config, stateKey, path }) => {
218
- const fetchedData = await executeSingle(adapter, config)
219
- if (fetchedData !== null) {
220
- const existing = stateUpdates.get(path) || {}
221
- if (stateKey) {
222
- // Named: store under the `as` key
223
- existing[stateKey] = fetchedData
224
- } else if (isObject(fetchedData)) {
225
- // No `as` key + transform returned an object: spread into state
226
- Object.assign(existing, fetchedData)
227
- }
228
- stateUpdates.set(path, existing)
229
- }
230
- })
231
- )
232
-
233
- return stateUpdates
234
- }
235
-
236
- /**
237
- * Inject prefetched data into a page definition's state objects.
238
- * Mutates the definition in place (caller should deep-clone first).
239
- *
240
- * @param {object} pageDef - Page definition (will be mutated)
241
- * @param {Map<string, object>} stateUpdates - Map from prefetchPageData
242
- */
243
- /**
244
- * Fetch polyglot translations from the DB for SSR use.
245
- * Returns a map of { [lang]: { key: text, ... } } for all configured languages.
246
- *
247
- * @param {object} data - Full project data (from loadProject)
248
- * @returns {Promise<object|null>} Translation map keyed by language, or null on failure
249
- */
250
- export const fetchSSRTranslations = async (data) => {
251
- // Config fields may be nested under data.config or spread at the top level
252
- const config = data.config || {}
253
- const polyglot = config.polyglot || data.polyglot
254
- if (!polyglot?.fetch) return null
255
-
256
- const dbConfig = config.fetch || data.fetch || config.db || data.db
257
- if (!dbConfig) return null
258
-
259
- const adapter = await createSSRAdapter(dbConfig)
260
- if (!adapter) return null
261
-
262
- const fetchConfig = polyglot.fetch
263
- const rpcName = fetchConfig.rpc || fetchConfig.from || 'get_translations_if_changed'
264
- const languages = polyglot.languages || [polyglot.defaultLang || 'en']
265
-
266
- const translations = {}
267
-
268
- // Fetch translations for all languages in parallel
269
- const results = await Promise.allSettled(
270
- languages.map(async (lang) => {
271
- try {
272
- const res = await adapter.rpc({
273
- from: rpcName,
274
- params: { p_lang: lang, p_cached_version: 0 }
275
- })
276
- if (res.error || !res.data) return
277
- const result = res.data
278
- if (result.translations) {
279
- translations[lang] = result.translations
280
- }
281
- } catch (e) {
282
- console.warn('[brender] SSR translation fetch failed:', e.message)
283
- }
284
- })
285
- )
286
-
287
- return Object.keys(translations).length ? translations : null
288
- }
289
-
290
- /**
291
- * Pre-evaluate children functions and replace them with static results.
292
- * During SSR, DOMQL's runtime state cascading and async re-render cycle
293
- * may not work correctly (trackSourcemapDeep stack overflows, etc.).
294
- * By pre-evaluating the children functions, we produce static element
295
- * definitions that DOMQL can render directly.
296
- */
297
- const preEvaluateChildren = (def, inheritedState) => {
298
- if (!def || typeof def !== 'object') return
299
- for (const key in def) {
300
- if (key === 'state' || key === 'fetch' || key === 'props' ||
301
- key === 'attr' || key === 'on' || key === 'define' ||
302
- key === 'childExtends' || key === 'childProps' || key === 'childrenAs') continue
303
- if (key.charAt(0) >= 'A' && key.charAt(0) <= 'Z' && isObject(def[key])) {
304
- const child = def[key]
305
- // Determine effective state for this element (own state or inherited)
306
- const effectiveState = child.state && typeof child.state === 'object'
307
- ? { ...inheritedState, ...child.state }
308
- : inheritedState
309
-
310
- // Pre-evaluate children function
311
- if (isFunction(child.children)) {
312
- try {
313
- const mockEl = {
314
- state: effectiveState,
315
- props: {},
316
- call: (fn) => {
317
- if (fn === 'getActiveLang' || fn === 'getLang') return effectiveState?.lang || 'ka'
318
- if (fn === 'polyglot') return arguments[1] || ''
319
- return undefined
320
- },
321
- __ref: {}
322
- }
323
- const result = child.children(mockEl, effectiveState)
324
- if (isArray(result) && result.length > 0) {
325
- // Replace children function with static array
326
- child.children = result
327
- }
328
- } catch {
329
- // If evaluation fails, leave the function as-is
330
- }
331
- }
332
-
333
- // Recurse deeper
334
- preEvaluateChildren(child, effectiveState)
335
- }
336
- }
337
- }
338
-
339
- export const injectPrefetchedState = (pageDef, stateUpdates) => {
340
- if (!stateUpdates || !stateUpdates.size) return
341
-
342
- for (const [path, data] of stateUpdates) {
343
- // Navigate to the element at the path
344
- let target = pageDef
345
- if (path) {
346
- const parts = path.split('.')
347
- for (const part of parts) {
348
- if (!target || typeof target !== 'object') break
349
- target = target[part]
350
- }
351
- }
352
-
353
- if (target && typeof target === 'object') {
354
- // Merge fetched data into the element's state
355
- if (!target.state || typeof target.state !== 'object') {
356
- target.state = {}
357
- }
358
- Object.assign(target.state, data)
359
-
360
- // Pre-evaluate children functions with the injected state
361
- // so DOMQL gets static element definitions instead of functions
362
- preEvaluateChildren(target, target.state)
363
- }
364
- }
365
- }