@symbo.ls/brender 3.14.664 → 3.14.665

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/env.js CHANGED
@@ -82,8 +82,14 @@ export const createEnv = (html = '<!DOCTYPE html><html><head></head><body></body
82
82
  key: (i) => Object.keys(store)[i] ?? null
83
83
  }
84
84
  }
85
- if (!window.localStorage) window.localStorage = createStorage()
86
- if (!window.sessionStorage) window.sessionStorage = createStorage()
85
+ // Every environment gets its own, empty storage. linkedom's window reads an
86
+ // unknown property through to globalThis, so `window.localStorage` found the
87
+ // FIRST environment's store (published on globalThis below) and every later
88
+ // render shared it: polyglot keeps the language a page switched to and the
89
+ // labels it loaded there, and the next page of a build booted with them
90
+ // (xma.info report, brender build item 7 — reset state between renders).
91
+ window.localStorage = createStorage()
92
+ window.sessionStorage = createStorage()
87
93
  if (!globalThis.localStorage) globalThis.localStorage = window.localStorage
88
94
  if (!globalThis.sessionStorage) globalThis.sessionStorage = window.sessionStorage
89
95
 
package/index.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import { createEnv } from './env.js'
2
2
  import { resetKeys, assignKeys, mapKeysToElements } from './keys.js'
3
3
  import { loadProject, loadAndRenderAll } from './load.js'
4
- import { render, renderElement, renderRoute, renderPage, resetGlobalCSSCache, getAccumulatedEmotionCSS, replaceEmotionCSS, adoptionScripts } from './render.js'
4
+ import { render, renderElement, renderRoute, renderPage, resetGlobalCSSCache, getAccumulatedEmotionCSS, replaceEmotionCSS, adoptionScripts, prerenderParts } from './render.js'
5
5
  import { extractMetadata, generateHeadHtml } from './metadata.js'
6
6
  import { collectBrNodes, hydrate } from './hydrate.js'
7
7
  import { generateSitemap } from './sitemap.js'
@@ -30,7 +30,8 @@ export {
30
30
  prefetchPageData,
31
31
  injectPrefetchedState,
32
32
  composeIntoShell,
33
- adoptionScripts
33
+ adoptionScripts,
34
+ prerenderParts
34
35
  }
35
36
 
36
37
  export default {
@@ -55,5 +56,6 @@ export default {
55
56
  prefetchPageData,
56
57
  injectPrefetchedState,
57
58
  composeIntoShell,
58
- adoptionScripts
59
+ adoptionScripts,
60
+ prerenderParts
59
61
  }
package/load.js CHANGED
@@ -1,15 +1,51 @@
1
- import { resolve, join } from 'path'
2
- import { existsSync, mkdirSync, writeFileSync, unlinkSync } from 'fs'
1
+ import { resolve, join, extname, sep } from 'path'
2
+ import { existsSync, readFileSync, unlinkSync } from 'fs'
3
3
  import { tmpdir } from 'os'
4
4
  import { randomBytes } from 'crypto'
5
+ import { pathToFileURL } from 'url'
6
+
7
+ // ── import.meta.url in project code ─────────────────────────────────────────
8
+ // `new URL('../../assets/fonts/x.woff2', import.meta.url)` is how a project
9
+ // names a file next to its source; the bundler turns it into the built
10
+ // asset's address. Bundled into ONE temp module here, every project file's
11
+ // `import.meta.url` became that temp file's, so the URL pointed into the
12
+ // build machine's temp folder — every pre-rendered @font-face read
13
+ // `file:///var/folders/…`, the page drew a fallback font, then shifted (CLS
14
+ // 0.40; xma.info report, brender build item 6). Each project file (anything
15
+ // outside node_modules) gets its OWN source address instead: the URL then
16
+ // names the real source file, which a build maps to its output (the CLI's
17
+ // brenderAssets) and refuses to ship as a file: URL.
18
+ const IMPORT_META_URL = /\bimport\.meta\.url\b/g
19
+ const HAS_IMPORT_META_URL = /\bimport\.meta\.url\b/
20
+ const LOADERS = { '.js': 'js', '.mjs': 'js', '.cjs': 'js', '.jsx': 'jsx', '.ts': 'ts', '.mts': 'ts', '.tsx': 'tsx' }
21
+
22
+ export const sourceUrlPlugin = {
23
+ name: 'brender-source-import-meta-url',
24
+ setup (build) {
25
+ build.onLoad({ filter: /\.[cm]?[jt]sx?$/ }, (args) => {
26
+ if (args.path.includes(`${sep}node_modules${sep}`)) return
27
+ const contents = readFileSync(args.path, 'utf8')
28
+ if (!HAS_IMPORT_META_URL.test(contents)) return
29
+ const url = JSON.stringify(pathToFileURL(args.path).href)
30
+ return {
31
+ contents: `const __brenderImportMetaUrl = ${url};\n` + contents.replace(IMPORT_META_URL, '__brenderImportMetaUrl'),
32
+ loader: LOADERS[extname(args.path)] || 'js'
33
+ }
34
+ })
35
+ }
36
+ }
5
37
 
6
38
  /**
7
39
  * Bundles a module entry point with esbuild so that extensionless imports,
8
40
  * bare specifiers, and other bundler conventions resolve correctly.
9
- * Returns the default + named exports of the bundled module, or null on failure.
41
+ * Returns the default + named exports of the bundled module, or null on failure
42
+ * (or, with `strict`, throws what went wrong).
10
43
  */
11
- const bundleAndImport = async (entryPath) => {
12
- if (!existsSync(entryPath)) return null
44
+ const bundleAndImport = async (entryPath, { strict = false } = {}) => {
45
+ if (!existsSync(entryPath)) {
46
+ if (strict) throw new Error(`${entryPath} does not exist`)
47
+ return null
48
+ }
13
49
 
14
50
  let esbuild
15
51
  try {
@@ -30,20 +66,69 @@ const bundleAndImport = async (entryPath) => {
30
66
  outfile: outFile,
31
67
  write: true,
32
68
  logLevel: 'silent',
69
+ plugins: [sourceUrlPlugin],
33
70
  // Mark node builtins as external
34
71
  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
72
  })
36
73
 
37
74
  const mod = await import(`file://${outFile}`)
38
75
  return mod
39
- } catch {
76
+ } catch (err) {
40
77
  // Fallback: try raw import
41
- try { return await import(entryPath) } catch { return null } // fallback: module not found via this resolver, trying next
78
+ try { return await import(entryPath) } catch (rawErr) { // fallback: module not found via this resolver, trying next
79
+ if (strict) throw err
80
+ return null
81
+ }
42
82
  } finally {
43
83
  try { unlinkSync(outFile) } catch {} // cleanup: ignore if temp file already removed
44
84
  }
45
85
  }
46
86
 
87
+ // A project's own context module (`context`): the object its entry hands to
88
+ // `create(app, context)` — `{ state, components, pages, functions,
89
+ // designSystem, files, sharedLibraries, ...config }`. The build's entry is
90
+ // what decides which pages a site has; a project whose public entry boots a
91
+ // smaller context than symbols/pages/index.js (xma.info: `siteContext.js`,
92
+ // no admin pages) got every page pre-rendered, admin ones included (xma.info
93
+ // report, brender build item 10). Its keys are spread into plain objects (a
94
+ // module namespace is not extensible); config keys stay at the top level,
95
+ // where render() reads them, as in a frank payload.
96
+ const CONTEXT_OBJECT_KEYS = ['state', 'dependencies', 'components', 'snippets', 'pages', 'functions', 'methods', 'designSystem', 'files', 'assets']
97
+
98
+ const loadContextModule = async (projectPath, contextPath) => {
99
+ const entry = resolve(projectPath, contextPath)
100
+ let mod
101
+ try {
102
+ mod = await bundleAndImport(entry, { strict: true })
103
+ } catch (err) {
104
+ throw new Error(`brender: cannot load the context module ${contextPath} (${err.message})`)
105
+ }
106
+ const context = mod && (mod.default || mod)
107
+ if (!context || typeof context !== 'object') {
108
+ throw new Error(`brender: the context module ${contextPath} has no default export object`)
109
+ }
110
+ const out = { ...context }
111
+ for (const key of CONTEXT_OBJECT_KEYS) {
112
+ if (context[key] && typeof context[key] === 'object') out[key] = { ...context[key] }
113
+ }
114
+ out.sharedLibraries = Array.isArray(context.sharedLibraries) ? context.sharedLibraries : []
115
+ return out
116
+ }
117
+
118
+ const mergeLibraries = async (result) => {
119
+ // Pre-resolve string library references and merge into context
120
+ // so production builds have zero runtime overhead
121
+ if (result.sharedLibraries.length) {
122
+ const { resolveSharedLibraries, mergeSharedLibraries } = await import('@symbo.ls/utils')
123
+ const hasStrings = result.sharedLibraries.some(lib => typeof lib === 'string')
124
+ if (hasStrings) {
125
+ result.sharedLibraries = await resolveSharedLibraries(result.sharedLibraries)
126
+ }
127
+ mergeSharedLibraries(result, result.sharedLibraries)
128
+ }
129
+ return result
130
+ }
131
+
47
132
  /**
48
133
  * Loads a Symbols project from a filesystem path.
49
134
  * Expects the standard symbols/ directory structure.
@@ -52,13 +137,28 @@ const bundleAndImport = async (entryPath) => {
52
137
  * and other bundler conventions work in Node.js.
53
138
  *
54
139
  * Used for prebuild scenarios where brender runs locally
55
- * against a project directory (e.g. `smbls build --prerender`).
140
+ * against a project directory (e.g. `smbls build`).
56
141
  *
57
142
  * For server runtime rendering, pass the project data
58
143
  * directly to render() instead.
144
+ *
145
+ * @param {string} projectPath
146
+ * @param {object} [options]
147
+ * @param {string} [options.dir='symbols'] - the symbols directory (symbols.json `dir`)
148
+ * @param {string} [options.context] - a module, relative to `projectPath`,
149
+ * whose default export is the context the build's entry boots with
150
+ * (symbols.json `brender.context`). Everything but `app` comes from it.
59
151
  */
60
- export const loadProject = async (projectPath) => {
61
- const symbolsDir = resolve(projectPath, 'symbols')
152
+ export const loadProject = async (projectPath, options = {}) => {
153
+ const symbolsDir = resolve(projectPath, options.dir || 'symbols')
154
+
155
+ if (options.context) {
156
+ const [appModule, context] = await Promise.all([
157
+ bundleAndImport(join(symbolsDir, 'app.js')),
158
+ loadContextModule(projectPath, options.context)
159
+ ])
160
+ return mergeLibraries({ app: { ...(appModule?.default || {}) }, ...context })
161
+ }
62
162
 
63
163
  const [
64
164
  appModule,
@@ -108,18 +208,7 @@ export const loadProject = async (projectPath) => {
108
208
  sharedLibraries: sharedLibsModule?.default || []
109
209
  }
110
210
 
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
211
+ return mergeLibraries(result)
123
212
  }
124
213
 
125
214
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@symbo.ls/brender",
3
- "version": "3.14.664",
3
+ "version": "3.14.665",
4
4
  "license": "CC-BY-NC-4.0",
5
5
  "type": "module",
6
6
  "module": "./dist/esm/index.js",
@@ -46,18 +46,18 @@
46
46
  "prepublishOnly": "npm run build"
47
47
  },
48
48
  "dependencies": {
49
- "@symbo.ls/css": "^3.14.777",
50
- "@symbo.ls/default-config": "^3.14.785",
51
- "@symbo.ls/element": "^3.14.803",
52
- "@symbo.ls/fetch": "^3.14.664",
49
+ "@symbo.ls/css": "^3.14.779",
50
+ "@symbo.ls/default-config": "^3.14.787",
51
+ "@symbo.ls/element": "^3.14.805",
52
+ "@symbo.ls/fetch": "^3.14.665",
53
53
  "@symbo.ls/helmet": "^3.14.670",
54
- "@symbo.ls/scratch": "^3.14.788",
55
- "@symbo.ls/utils": "^3.14.786",
54
+ "@symbo.ls/scratch": "^3.14.789",
55
+ "@symbo.ls/utils": "^3.14.787",
56
56
  "esbuild": "^0.28.1",
57
57
  "linkedom": "^0.16.8"
58
58
  },
59
59
  "peerDependencies": {
60
- "smbls": "^3.14.833"
60
+ "smbls": "^3.14.838"
61
61
  },
62
62
  "optionalDependencies": {
63
63
  "@symbo.ls/funcql": "^3.14.0"
package/prefetch.js CHANGED
@@ -1,12 +1,13 @@
1
1
  /**
2
2
  * SSR data prefetching for brender.
3
3
  *
4
- * Walks a page definition tree, collects `fetch` declarations,
5
- * executes them against the configured DB adapter,
6
- * and returns the fetched data keyed by element path + `as` field.
4
+ * `fetchSSRTranslations` loads a project's table/RPC labels before a render.
7
5
  *
8
- * This allows brender to inject fetched data into element state
9
- * before rendering, so the SSR output matches the client-side SPA.
6
+ * `prefetchPageData` walks a page definition tree, collects `fetch`
7
+ * declarations, executes them against the configured DB adapter, and returns
8
+ * the fetched data keyed by element path + `as` field. render() does NOT use
9
+ * it: the fetch plugin loads a page's data during the render, with the real
10
+ * element (a `transform` here gets no element). Kept for direct callers.
10
11
  */
11
12
 
12
13
  const isFunction = (v) => typeof v === 'function'
@@ -192,6 +193,10 @@ const executeSingle = async (adapter, config) => {
192
193
  /**
193
194
  * Prefetch all data for a page route.
194
195
  *
196
+ * @deprecated render() and renderPage() no longer use it: the fetch plugin
197
+ * loads a page's data during the render, with the real element (a `transform`
198
+ * here gets none). The `prefetch` render option does not call it. Kept for
199
+ * direct callers; to be removed.
195
200
  * @param {object} data - Full project data (from loadProject)
196
201
  * @param {string} route - Route to prefetch for (e.g. '/', '/blog')
197
202
  * @param {object} [options]
@@ -235,13 +240,6 @@ export const prefetchPageData = async (data, route = '/', options = {}) => {
235
240
  return stateUpdates
236
241
  }
237
242
 
238
- /**
239
- * Inject prefetched data into a page definition's state objects.
240
- * Mutates the definition in place (caller should deep-clone first).
241
- *
242
- * @param {object} pageDef - Page definition (will be mutated)
243
- * @param {Map<string, object>} stateUpdates - Map from prefetchPageData
244
- */
245
243
  /**
246
244
  * Fetch polyglot translations from the DB for SSR use.
247
245
  * Returns a map of { [lang]: { key: text, ... } } for all configured languages.
@@ -254,6 +252,14 @@ export const fetchSSRTranslations = async (data) => {
254
252
  const config = data.config || {}
255
253
  const polyglot = config.polyglot || data.polyglot
256
254
  if (!polyglot?.fetch) return null
255
+ // A FUNCTION is the project's own loader — `(lang, version, el) => …`,
256
+ // called by the polyglot plugin. It runs inside the render, with the real
257
+ // root element (its el.call / el.getDB), and render() reads its labels back
258
+ // from the root state. This used to fall through to the RPC branch below
259
+ // with brender's default name, `get_translations_if_changed`: one request
260
+ // per language per route that the project never serves — 44 answered 404
261
+ // in one build (xma.info report, brender build item 11).
262
+ if (typeof polyglot.fetch === 'function') return null
257
263
 
258
264
  const dbConfig = config.fetch || data.fetch || config.db || data.db
259
265
  if (!dbConfig) return null
@@ -291,53 +297,19 @@ export const fetchSSRTranslations = async (data) => {
291
297
  }
292
298
 
293
299
  /**
294
- * Pre-evaluate children functions and replace them with static results.
295
- * During SSR, DOMQL's runtime state cascading and async re-render cycle
296
- * may not work correctly (trackSourcemapDeep stack overflows, etc.).
297
- * By pre-evaluating the children functions, we produce static element
298
- * definitions that DOMQL can render directly.
300
+ * Merge prefetched data into a page definition's element states. Mutates the
301
+ * definition in place (the caller deep-clones first).
302
+ *
303
+ * @deprecated render() no longer uses it (the fetch plugin loads a page's data
304
+ * during the render); kept for callers of prefetchPageData, to be removed.
305
+ *
306
+ * It used to go on and evaluate every `children` FUNCTION under the target
307
+ * with a stand-in element — whose call() answered 'ka' for any language and
308
+ * returned the enclosing arrow function's `arguments[1]` for a label — and
309
+ * freeze the result into a static array the real data could never replace
310
+ * (xma.info report, brender build items 5 and 13). A children function stays
311
+ * a function now: the render runs it with the real element.
299
312
  */
300
- const preEvaluateChildren = (def, inheritedState) => {
301
- if (!def || typeof def !== 'object') return
302
- for (const key in def) {
303
- if (key === 'state' || key === 'fetch' || key === 'props' ||
304
- key === 'attr' || key === 'on' || key === 'define' ||
305
- key === 'childExtends' || key === 'childProps' || key === 'childrenAs') continue
306
- if (key.charAt(0) >= 'A' && key.charAt(0) <= 'Z' && isObject(def[key])) {
307
- const child = def[key]
308
- // Determine effective state for this element (own state or inherited)
309
- const effectiveState = child.state && typeof child.state === 'object'
310
- ? { ...inheritedState, ...child.state }
311
- : inheritedState
312
-
313
- // Pre-evaluate children function
314
- if (isFunction(child.children)) {
315
- try {
316
- const mockEl = {
317
- state: effectiveState,
318
- call: (fn) => {
319
- if (fn === 'getActiveLang' || fn === 'getLang') return effectiveState?.lang || 'ka'
320
- if (fn === 'polyglot') return arguments[1] || ''
321
- return undefined
322
- },
323
- __ref: {}
324
- }
325
- const result = child.children(mockEl, effectiveState)
326
- if (isArray(result) && result.length > 0) {
327
- // Replace children function with static array
328
- child.children = result
329
- }
330
- } catch {
331
- // If evaluation fails, leave the function as-is
332
- }
333
- }
334
-
335
- // Recurse deeper
336
- preEvaluateChildren(child, effectiveState)
337
- }
338
- }
339
- }
340
-
341
313
  export const injectPrefetchedState = (pageDef, stateUpdates) => {
342
314
  if (!stateUpdates || !stateUpdates.size) return
343
315
 
@@ -358,10 +330,6 @@ export const injectPrefetchedState = (pageDef, stateUpdates) => {
358
330
  target.state = {}
359
331
  }
360
332
  Object.assign(target.state, data)
361
-
362
- // Pre-evaluate children functions with the injected state
363
- // so DOMQL gets static element definitions instead of functions
364
- preEvaluateChildren(target, target.state)
365
333
  }
366
334
  }
367
335
  }