@symbo.ls/brender 3.14.7 → 3.14.8

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 ADDED
@@ -0,0 +1,141 @@
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
+ assetsModule,
76
+ sharedLibsModule
77
+ ] = await Promise.all([
78
+ bundleAndImport(join(symbolsDir, 'app.js')),
79
+ bundleAndImport(join(symbolsDir, 'state.js')),
80
+ bundleAndImport(join(symbolsDir, 'config.js')),
81
+ bundleAndImport(join(symbolsDir, 'dependencies.js')),
82
+ bundleAndImport(join(symbolsDir, 'components', 'index.js')),
83
+ bundleAndImport(join(symbolsDir, 'snippets', 'index.js')),
84
+ bundleAndImport(join(symbolsDir, 'pages', 'index.js')),
85
+ bundleAndImport(join(symbolsDir, 'functions', 'index.js')),
86
+ bundleAndImport(join(symbolsDir, 'methods', 'index.js')),
87
+ bundleAndImport(join(symbolsDir, 'designSystem', 'index.js')),
88
+ bundleAndImport(join(symbolsDir, 'files', 'index.js')),
89
+ bundleAndImport(join(symbolsDir, 'assets', 'index.js')).catch(() => null),
90
+ bundleAndImport(join(symbolsDir, 'sharedLibraries.js')).catch(() => null)
91
+ ])
92
+
93
+ // Spread into plain objects — ESM module namespaces are non-extensible,
94
+ // which breaks downstream code that adds properties (e.g. polyglot functions).
95
+ const result = {
96
+ app: { ...(appModule?.default || {}) },
97
+ state: { ...(stateModule?.default || {}) },
98
+ dependencies: { ...(depsModule?.default || {}) },
99
+ components: { ...(componentsModule || {}) },
100
+ snippets: { ...(snippetsModule || {}) },
101
+ pages: { ...(pagesModule?.default || {}) },
102
+ functions: { ...(functionsModule || {}) },
103
+ methods: { ...(methodsModule || {}) },
104
+ designSystem: { ...(designSystemModule?.default || {}) },
105
+ files: { ...(filesModule?.default || {}) },
106
+ assets: { ...(assetsModule?.default || {}) },
107
+ config: { ...(configModule?.default || {}) },
108
+ sharedLibraries: sharedLibsModule?.default || []
109
+ }
110
+
111
+ // Pre-resolve string library references and merge into context
112
+ // so production builds have zero runtime overhead
113
+ if (result.sharedLibraries.length) {
114
+ const { resolveSharedLibraries, mergeSharedLibraries } = await import('@symbo.ls/utils')
115
+ const hasStrings = result.sharedLibraries.some(lib => typeof lib === 'string')
116
+ if (hasStrings) {
117
+ result.sharedLibraries = await resolveSharedLibraries(result.sharedLibraries)
118
+ }
119
+ mergeSharedLibraries(result, result.sharedLibraries)
120
+ }
121
+
122
+ return result
123
+ }
124
+
125
+ /**
126
+ * Renders all routes from a project directory and returns
127
+ * a map of route -> { html, metadata }.
128
+ * Useful for static prebuilding.
129
+ */
130
+ export const loadAndRenderAll = async (projectPath, renderFn) => {
131
+ const data = await loadProject(projectPath)
132
+ const pages = data.pages || {}
133
+ const routes = Object.keys(pages)
134
+ const results = {}
135
+
136
+ for (const route of routes) {
137
+ results[route] = await renderFn(data, { route })
138
+ }
139
+
140
+ return results
141
+ }
package/metadata.js ADDED
@@ -0,0 +1,5 @@
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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@symbo.ls/brender",
3
- "version": "3.14.7",
3
+ "version": "3.14.8",
4
4
  "license": "CC-BY-NC-4.0",
5
5
  "type": "module",
6
6
  "module": "./dist/esm/index.js",
@@ -25,6 +25,7 @@
25
25
  "source": "index.js",
26
26
  "files": [
27
27
  "dist",
28
+ "*.js",
28
29
  "*.md",
29
30
  "LICENSE"
30
31
  ],
@@ -40,8 +41,8 @@
40
41
  "prepublishOnly": "npm run build"
41
42
  },
42
43
  "dependencies": {
43
- "@symbo.ls/css": "^3.14.9",
44
- "@symbo.ls/helmet": "^3.14.7",
44
+ "@symbo.ls/css": "^3.14.10",
45
+ "@symbo.ls/helmet": "^3.14.8",
45
46
  "linkedom": "^0.16.8"
46
47
  },
47
48
  "devDependencies": {
package/prefetch.js ADDED
@@ -0,0 +1,363 @@
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
+ call: () => undefined,
28
+ __ref: {}
29
+ }
30
+ return params(mockEl, mockState || {})
31
+ } catch {
32
+ return undefined
33
+ }
34
+ }
35
+ return params
36
+ }
37
+
38
+ /**
39
+ * Normalize a single fetch declaration to a standard config object.
40
+ */
41
+ const normalizeFetchConfig = (cfg, elementState) => {
42
+ if (!cfg) return null
43
+ if (typeof cfg === 'string') return { from: cfg, method: 'select' }
44
+
45
+ const resolved = isFunction(cfg) ? null : { ...cfg }
46
+ if (!resolved) return null
47
+
48
+ // Default method
49
+ if (!resolved.method) resolved.method = 'select'
50
+
51
+ // Resolve function params
52
+ if (isFunction(resolved.params)) {
53
+ resolved.params = resolveParams(resolved.params, elementState)
54
+ }
55
+
56
+ // Skip mutations and event-bound fetches
57
+ const isMutation = resolved.method === 'insert' || resolved.method === 'update' ||
58
+ resolved.method === 'upsert' || resolved.method === 'delete'
59
+ if (isMutation) return null
60
+ if (resolved.on && resolved.on !== 'create') return null
61
+
62
+ return resolved
63
+ }
64
+
65
+ /**
66
+ * Walk a page definition tree and collect all fetch declarations
67
+ * along with the path to the element's state.
68
+ *
69
+ * Returns: [{ config, stateKey, path, elementState }]
70
+ */
71
+ const collectFetchDeclarations = (def, path = '') => {
72
+ if (!def || typeof def !== 'object') return []
73
+ // Skip function values and arrays of primitives
74
+ if (isFunction(def)) return []
75
+
76
+ const results = []
77
+ const elementState = def.state || {}
78
+
79
+ if (def.fetch) {
80
+ const fetchDefs = isArray(def.fetch) ? def.fetch : [def.fetch]
81
+ for (const fd of fetchDefs) {
82
+ const config = normalizeFetchConfig(fd, elementState)
83
+ if (config) {
84
+ results.push({
85
+ config,
86
+ stateKey: config.as,
87
+ path,
88
+ elementState
89
+ })
90
+ }
91
+ }
92
+ }
93
+
94
+ // Recurse into child elements (capitalized keys = child elements)
95
+ for (const key in def) {
96
+ if (key === 'fetch' || key === 'state' || key === 'props' ||
97
+ key === 'attr' || key === 'on' || key === 'define' ||
98
+ key === 'childExtends' || key === 'childProps' || key === 'childrenAs') continue
99
+ // Child elements have capitalized keys
100
+ if (key.charAt(0) >= 'A' && key.charAt(0) <= 'Z' && isObject(def[key])) {
101
+ results.push(...collectFetchDeclarations(def[key], path ? `${path}.${key}` : key))
102
+ }
103
+ }
104
+
105
+ return results
106
+ }
107
+
108
+ /**
109
+ * Create a Supabase adapter from project config for SSR use.
110
+ */
111
+ const createSSRAdapter = async (dbConfig) => {
112
+ if (!dbConfig) return null
113
+
114
+ const { adapter, createClient, url, key, projectId } = dbConfig
115
+ if (adapter !== 'supabase') return null
116
+
117
+ const supabaseUrl = url || (projectId && `https://${projectId}.supabase.co`)
118
+ if (!supabaseUrl || !key) return null
119
+
120
+ // Always import @supabase/supabase-js for SSR — the serialized createClient
121
+ // from project data is a no-op placeholder that won't produce a real client.
122
+ let clientFactory
123
+ try {
124
+ const mod = await import('@supabase/supabase-js')
125
+ clientFactory = mod.createClient
126
+ } catch {
127
+ // Fall back to provided createClient only if import fails
128
+ clientFactory = createClient
129
+ }
130
+ if (!clientFactory) return null
131
+
132
+ const client = clientFactory(supabaseUrl, key)
133
+
134
+ return {
135
+ rpc: ({ from, params }) => client.rpc(from, params),
136
+ select: async ({ from, select: sel, params, limit, offset, order, single }) => {
137
+ let q = client.from(from).select(sel || '*')
138
+ if (params) {
139
+ for (const k in params) {
140
+ const v = params[k]
141
+ if (v === null) q = q.is(k, null)
142
+ else if (Array.isArray(v)) q = q.in(k, v)
143
+ else q = q.eq(k, v)
144
+ }
145
+ }
146
+ if (order) {
147
+ const orderBy = typeof order === 'string' ? order : order.by
148
+ q = q.order(orderBy, { ascending: order.asc !== false })
149
+ }
150
+ if (limit) q = q.limit(limit)
151
+ if (offset) q = q.range(offset, offset + (limit || 20) - 1)
152
+ if (single) q = q.single()
153
+ return q
154
+ }
155
+ }
156
+ }
157
+
158
+ /**
159
+ * Execute a single fetch config against the adapter.
160
+ * Returns the fetched data, or null on error.
161
+ */
162
+ const executeSingle = async (adapter, config) => {
163
+ try {
164
+ const { method, from, params, transform, limit, offset, order, single } = config
165
+ let result
166
+
167
+ if (method === 'rpc') {
168
+ result = await adapter.rpc({ from, params })
169
+ } else {
170
+ result = await adapter.select({ from, select: config.select, params, limit, offset, order, single })
171
+ }
172
+
173
+ let data = result?.data ?? null
174
+ if (result?.error) {
175
+ return null
176
+ }
177
+
178
+ // Apply transform
179
+ if (data && transform && isFunction(transform)) {
180
+ try { data = transform(data) } catch { /* skip transform errors */ }
181
+ }
182
+
183
+ return data
184
+ } catch {
185
+ return null
186
+ }
187
+ }
188
+
189
+ /**
190
+ * Prefetch all data for a page route.
191
+ *
192
+ * @param {object} data - Full project data (from loadProject)
193
+ * @param {string} route - Route to prefetch for (e.g. '/', '/blog')
194
+ * @param {object} [options]
195
+ * @returns {Promise<Map<string, object>>} Map of element path → { [stateKey]: data }
196
+ */
197
+ export const prefetchPageData = async (data, route = '/', options = {}) => {
198
+ const pages = data.pages || {}
199
+ const pageDef = pages[route]
200
+ if (!pageDef) return new Map()
201
+
202
+ const config = data.config || data.settings || {}
203
+ const dbConfig = config.fetch || data.fetch || config.db || data.db
204
+ if (!dbConfig) return new Map()
205
+
206
+ const adapter = await createSSRAdapter(dbConfig)
207
+ if (!adapter) return new Map()
208
+
209
+ const declarations = collectFetchDeclarations(pageDef)
210
+ if (!declarations.length) return new Map()
211
+
212
+ const stateUpdates = new Map()
213
+
214
+ // Execute all fetches in parallel
215
+ const results = await Promise.allSettled(
216
+ declarations.map(async ({ config, stateKey, path }) => {
217
+ const fetchedData = await executeSingle(adapter, config)
218
+ if (fetchedData !== null) {
219
+ const existing = stateUpdates.get(path) || {}
220
+ if (stateKey) {
221
+ // Named: store under the `as` key
222
+ existing[stateKey] = fetchedData
223
+ } else if (isObject(fetchedData)) {
224
+ // No `as` key + transform returned an object: spread into state
225
+ Object.assign(existing, fetchedData)
226
+ }
227
+ stateUpdates.set(path, existing)
228
+ }
229
+ })
230
+ )
231
+
232
+ return stateUpdates
233
+ }
234
+
235
+ /**
236
+ * Inject prefetched data into a page definition's state objects.
237
+ * Mutates the definition in place (caller should deep-clone first).
238
+ *
239
+ * @param {object} pageDef - Page definition (will be mutated)
240
+ * @param {Map<string, object>} stateUpdates - Map from prefetchPageData
241
+ */
242
+ /**
243
+ * Fetch polyglot translations from the DB for SSR use.
244
+ * Returns a map of { [lang]: { key: text, ... } } for all configured languages.
245
+ *
246
+ * @param {object} data - Full project data (from loadProject)
247
+ * @returns {Promise<object|null>} Translation map keyed by language, or null on failure
248
+ */
249
+ export const fetchSSRTranslations = async (data) => {
250
+ // Config fields may be nested under data.config or spread at the top level
251
+ const config = data.config || {}
252
+ const polyglot = config.polyglot || data.polyglot
253
+ if (!polyglot?.fetch) return null
254
+
255
+ const dbConfig = config.fetch || data.fetch || config.db || data.db
256
+ if (!dbConfig) return null
257
+
258
+ const adapter = await createSSRAdapter(dbConfig)
259
+ if (!adapter) return null
260
+
261
+ const fetchConfig = polyglot.fetch
262
+ const rpcName = fetchConfig.rpc || fetchConfig.from || 'get_translations_if_changed'
263
+ const languages = polyglot.languages || [polyglot.defaultLang || 'en']
264
+
265
+ const translations = {}
266
+
267
+ // Fetch translations for all languages in parallel
268
+ const results = await Promise.allSettled(
269
+ languages.map(async (lang) => {
270
+ try {
271
+ const res = await adapter.rpc({
272
+ from: rpcName,
273
+ params: { p_lang: lang, p_cached_version: 0 }
274
+ })
275
+ if (res.error || !res.data) return
276
+ const result = res.data
277
+ if (result.translations) {
278
+ translations[lang] = result.translations
279
+ }
280
+ } catch (e) {
281
+ console.warn('[brender] SSR translation fetch failed:', e.message)
282
+ }
283
+ })
284
+ )
285
+
286
+ return Object.keys(translations).length ? translations : null
287
+ }
288
+
289
+ /**
290
+ * Pre-evaluate children functions and replace them with static results.
291
+ * During SSR, DOMQL's runtime state cascading and async re-render cycle
292
+ * may not work correctly (trackSourcemapDeep stack overflows, etc.).
293
+ * By pre-evaluating the children functions, we produce static element
294
+ * definitions that DOMQL can render directly.
295
+ */
296
+ const preEvaluateChildren = (def, inheritedState) => {
297
+ if (!def || typeof def !== 'object') return
298
+ for (const key in def) {
299
+ if (key === 'state' || key === 'fetch' || key === 'props' ||
300
+ key === 'attr' || key === 'on' || key === 'define' ||
301
+ key === 'childExtends' || key === 'childProps' || key === 'childrenAs') continue
302
+ if (key.charAt(0) >= 'A' && key.charAt(0) <= 'Z' && isObject(def[key])) {
303
+ const child = def[key]
304
+ // Determine effective state for this element (own state or inherited)
305
+ const effectiveState = child.state && typeof child.state === 'object'
306
+ ? { ...inheritedState, ...child.state }
307
+ : inheritedState
308
+
309
+ // Pre-evaluate children function
310
+ if (isFunction(child.children)) {
311
+ try {
312
+ const mockEl = {
313
+ state: effectiveState,
314
+ call: (fn) => {
315
+ if (fn === 'getActiveLang' || fn === 'getLang') return effectiveState?.lang || 'ka'
316
+ if (fn === 'polyglot') return arguments[1] || ''
317
+ return undefined
318
+ },
319
+ __ref: {}
320
+ }
321
+ const result = child.children(mockEl, effectiveState)
322
+ if (isArray(result) && result.length > 0) {
323
+ // Replace children function with static array
324
+ child.children = result
325
+ }
326
+ } catch {
327
+ // If evaluation fails, leave the function as-is
328
+ }
329
+ }
330
+
331
+ // Recurse deeper
332
+ preEvaluateChildren(child, effectiveState)
333
+ }
334
+ }
335
+ }
336
+
337
+ export const injectPrefetchedState = (pageDef, stateUpdates) => {
338
+ if (!stateUpdates || !stateUpdates.size) return
339
+
340
+ for (const [path, data] of stateUpdates) {
341
+ // Navigate to the element at the path
342
+ let target = pageDef
343
+ if (path) {
344
+ const parts = path.split('.')
345
+ for (const part of parts) {
346
+ if (!target || typeof target !== 'object') break
347
+ target = target[part]
348
+ }
349
+ }
350
+
351
+ if (target && typeof target === 'object') {
352
+ // Merge fetched data into the element's state
353
+ if (!target.state || typeof target.state !== 'object') {
354
+ target.state = {}
355
+ }
356
+ Object.assign(target.state, data)
357
+
358
+ // Pre-evaluate children functions with the injected state
359
+ // so DOMQL gets static element definitions instead of functions
360
+ preEvaluateChildren(target, target.state)
361
+ }
362
+ }
363
+ }