@symbo.ls/brender 3.14.663 → 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/render.js CHANGED
@@ -4,13 +4,16 @@ import { resolvePackageEntry } from './packageEntry.js'
4
4
  import { tmpdir } from 'os'
5
5
  import { randomBytes } from 'crypto'
6
6
  import { createRequire } from 'module'
7
+ import { pathToFileURL } from 'url'
7
8
  import { createEnv } from './env.js'
8
9
  import { resetKeys, assignKeys, mapKeysToElements } from './keys.js'
9
10
  import { extractMetadata, generateHeadHtml } from './metadata.js'
10
11
  import { hydrate } from './hydrate.js'
11
12
  import { composeIntoShell } from './shell.js'
12
- import { prefetchPageData, injectPrefetchedState, fetchSSRTranslations } from './prefetch.js'
13
+ import { fetchSSRTranslations } from './prefetch.js'
13
14
  import { createEarlySeedScript } from '@symbo.ls/fetch'
15
+ import { headerEntries } from '@symbo.ls/fetch/rest'
16
+ import { brenderRegistryBlock, escapeRegExp, jsonForScript, polyglotFallbackLang } from '@symbo.ls/utils'
14
17
 
15
18
  // funcql plugin — enables evaluation of funcql schemas as property values
16
19
  // during SSR. Async-imported to avoid polluting non-funcql codepaths.
@@ -26,6 +29,7 @@ const getFuncqlPlugin = async () => {
26
29
  }
27
30
  }
28
31
  import { parseHTML } from 'linkedom'
32
+ import { createEarlySeedBlock } from '@symbo.ls/fetch'
29
33
  import { css, injectGlobal, reset as resetCss } from '@symbo.ls/css'
30
34
 
31
35
  // Lightweight SSR polyglot functions — resolve translations from context
@@ -43,17 +47,17 @@ const ssrResolve = (map, key) => {
43
47
  return v
44
48
  }
45
49
 
46
- const ssrTranslate = function (key, lang) {
47
- if (!key) return ''
48
- const ctx = this?.context
49
- const poly = ctx?.polyglot
50
- // Default lang fallback must match the polyglot plugin's contract
51
- // (`'en'`) — when a project doesn't set polyglot.defaultLang, SSR was
52
- // resolving against KA translations while client hydrate resolved
53
- // against EN, producing different text in the SSR shell vs the
54
- // hydrated DOM. See plugins/polyglot/index.js:41.
55
- const activeLang = lang || poly?.defaultLang || 'en'
56
-
50
+ // The language a server render shows: the root state's — a page under a
51
+ // language prefix sets it (a router guard reading `/en/…`) — else the
52
+ // project's default, else 'en' (@symbo.ls/utils polyglotFallbackLang, the
53
+ // order plugins/polyglot getActiveLang resolves it in). Diverging produces
54
+ // SSR/hydrate text mismatches.
55
+ const activeLangOf = (root, poly) => polyglotFallbackLang(root?.lang, poly)
56
+
57
+ // One label lookup, the order the client's translate() reads: static
58
+ // translations, then the labels loaded into the root state, then the
59
+ // default language's static ones. `undefined` when no table has the key.
60
+ const findLabel = (key, activeLang, poly, root) => {
57
61
  // Static translations from context.polyglot.translations
58
62
  if (poly?.translations) {
59
63
  const langMap = poly.translations[activeLang]
@@ -64,7 +68,6 @@ const ssrTranslate = function (key, lang) {
64
68
  }
65
69
 
66
70
  // Server-loaded translations in root state
67
- const root = this?.state?.root || ctx?.state?.root
68
71
  if (root?.translations) {
69
72
  const langMap = root.translations[activeLang]
70
73
  if (langMap) {
@@ -74,7 +77,7 @@ const ssrTranslate = function (key, lang) {
74
77
  }
75
78
 
76
79
  // Fallback to default lang
77
- const defaultLang = poly?.defaultLang || 'en'
80
+ const defaultLang = polyglotFallbackLang(undefined, poly)
78
81
  if (defaultLang !== activeLang && poly?.translations) {
79
82
  const fallback = poly.translations[defaultLang]
80
83
  if (fallback) {
@@ -82,15 +85,30 @@ const ssrTranslate = function (key, lang) {
82
85
  if (val !== undefined) return val
83
86
  }
84
87
  }
88
+ return undefined
89
+ }
85
90
 
91
+ const ssrTranslate = function (key, lang) {
92
+ if (!key) return ''
93
+ const ctx = this?.context
94
+ const poly = ctx?.polyglot
95
+ const root = this?.state?.root || ctx?.state?.root
96
+ // The ROOT's language, as the client's translate() asks getActiveLang.
97
+ // This used to read `poly.defaultLang` straight, so a page rendered for
98
+ // /en/about had `en` in its state and Georgian text in its markup
99
+ // (xma.info report, brender build item 8).
100
+ const val = findLabel(key, lang || activeLangOf(root, poly), poly, root)
101
+ if (val !== undefined) return val
102
+ // A key with no label yet. The labels may still be on their way (a
103
+ // project's own polyglot.fetch loads them during the render), so this only
104
+ // notes the key; the end of the render checks it again (see rawLabelKeys).
105
+ const diag = liveRecord(DIAG)
106
+ if (poly && diag) diag.labelKeys.add(key)
86
107
  return key
87
108
  }
88
109
 
89
110
  const ssrGetActiveLang = function () {
90
- const ctx = this?.context
91
- // 'en' matches plugins/polyglot/index.js#getActiveLang — diverging here
92
- // produces SSR/hydrate text mismatches.
93
- return this?.state?.root?.lang || ctx?.polyglot?.defaultLang || 'en'
111
+ return activeLangOf(this?.state?.root, this?.context?.polyglot)
94
112
  }
95
113
 
96
114
  // Deep clone that preserves functions and avoids circular refs
@@ -290,18 +308,10 @@ const bundleCreateDomql = async () => {
290
308
 
291
309
  const ws = detectWorkspace()
292
310
 
293
- if (!_brenderRequire) {
294
- try {
295
- _cachedCreateDomql = await importSmblsSsr()
296
- return _cachedCreateDomql
297
- } catch (err) {
298
- throw new Error(`brender: ${SMBLS_SSR_SPECIFIER} is not available in this bundled runtime (${err.message}). Bundle smbls ≥ the release that adds the smbls/ssr export alongside @symbo.ls/brender.`)
299
- }
300
- }
301
-
302
- // Resolve entry point
311
+ // Resolve entry point: the monorepo's source, else `smbls/ssr` (one import
312
+ // for both a bundled runtime and an installed smbls).
303
313
  let entry
304
- if (ws.isMonorepo) {
314
+ if (_brenderRequire && ws.isMonorepo) {
305
315
  entry = resolve(ws.monorepoRoot, 'packages', 'smbls', 'src', 'createDomql.js')
306
316
  } else {
307
317
  let ssrError
@@ -311,6 +321,9 @@ const bundleCreateDomql = async () => {
311
321
  } catch (err) {
312
322
  ssrError = err
313
323
  }
324
+ if (!_brenderRequire) {
325
+ throw new Error(`brender: ${SMBLS_SSR_SPECIFIER} is not available in this bundled runtime (${ssrError.message}). Bundle smbls ≥ the release that adds the smbls/ssr export alongside @symbo.ls/brender.`)
326
+ }
314
327
  // Legacy: an smbls that predates the ssr entry but still ships its source.
315
328
  const srcEntry = ws.smblsRoot && resolve(ws.smblsRoot, 'src', 'createDomql.js')
316
329
  if (srcEntry && existsSync(srcEntry)) {
@@ -329,9 +342,44 @@ const bundleCreateDomql = async () => {
329
342
 
330
343
  const tryResolve = resolvePackageEntry
331
344
 
345
+ // In the monorepo, every workspace specifier the handlers below do not own
346
+ // — a SUBPATH of a sibling (`@symbo.ls/dropdown/components`,
347
+ // `@symbo.ls/sync/applier`, `@symbo.ls/utils/function.js`) and an unscoped
348
+ // sibling (`attrs-in-props`) — resolves to its source through the map the
349
+ // test runner and smbls's own build resolve with
350
+ // (scripts/vite-workspace-source.mjs). They fell through to esbuild, which
351
+ // followed each package's `exports` into its dist/: a render ran whatever
352
+ // build was left on disk (the legacy Dropdown kit of an earlier build, a
353
+ // sync applier without the exports createDomql imports now), and a
354
+ // checkout without dist/ could not render at all. A dist input now fails
355
+ // the bundle.
356
+ const workspaceSource = ws.isMonorepo
357
+ ? await import(pathToFileURL(resolve(ws.monorepoRoot, 'scripts', 'vite-workspace-source.mjs')).href)
358
+ : null
359
+ const workspaceNames = workspaceSource
360
+ ? new Set(workspaceSource.workspacePackageRoots().map((dir) => workspaceSource.workspacePackageAt(dir).name))
361
+ : null
362
+ // Bare specifiers the handlers below resolve themselves (the sync stub
363
+ // among them).
364
+ const OWN_HANDLERS = /^(?:smbls(?:\/.*)?|domql|css-in-props(?:\/.*)?|@symbo\.ls\/[^/]+)$/
365
+
332
366
  const workspacePlugin = {
333
367
  name: 'workspace-resolve',
334
368
  setup (build) {
369
+ if (workspaceSource) {
370
+ build.onResolve({ filter: /^[^./]/ }, (args) => {
371
+ if (OWN_HANDLERS.test(args.path)) return
372
+ const parts = args.path.split('/')
373
+ const name = args.path.startsWith('@') ? parts.slice(0, 2).join('/') : parts[0]
374
+ if (!workspaceNames.has(name)) return
375
+ const source = workspaceSource.resolveWorkspaceSource(args.path, args.importer || entry)
376
+ if (source) return { path: source }
377
+ return { errors: [{ text: `"${args.path}" names no source file in the workspace` }] }
378
+ })
379
+ build.onLoad({ filter: /[\\/]dist[\\/]/ }, (args) => workspaceSource.isWorkspaceDist(args.path)
380
+ ? { errors: [{ text: `${args.path} is a BUILD of a workspace package — the render bundles source` }] }
381
+ : undefined)
382
+ }
335
383
  // Resolve smbls bare import
336
384
  build.onResolve({ filter: /^smbls/ }, (args) => {
337
385
  const subpath = args.path.replace(/^smbls\/?/, '')
@@ -630,25 +678,31 @@ const UIKIT_STUBS = {
630
678
  *
631
679
  * The registry is serialized into the pre-rendered HTML so the browser can
632
680
  * assign the correct brKey to each skeleton element during true hydration.
681
+ * A path is @symbo.ls/element elementPath — the very function the hydrating
682
+ * client looks each element up with (adoptHydrationNode), so the two sides
683
+ * cannot spell a path differently. One flat object, filled in place (the
684
+ * recursive walk merged a fresh object per element into its parent's).
633
685
  */
634
- const buildPathRegistry = (element, path = '') => {
635
- if (!element || !element.__ref) return {}
686
+ const buildPathRegistry = (element, elementPath) => {
636
687
  const registry = {}
688
+ walkElements(element, (el, ref) => {
689
+ if (!ref.__brKey) return
690
+ const path = elementPath(el)
691
+ registry[path === '' ? '__root' : path] = ref.__brKey
692
+ })
693
+ return registry
694
+ }
637
695
 
638
- const brKey = element.__ref.__brKey
639
- if (brKey) registry[path === '' ? '__root' : path] = brKey
640
-
641
- if (element.__ref.__children) {
642
- for (const childKey of element.__ref.__children) {
643
- const child = element[childKey]
644
- if (child && child.__ref) {
645
- const childPath = path === '' ? childKey : `${path}.${childKey}`
646
- Object.assign(registry, buildPathRegistry(child, childPath))
647
- }
648
- }
696
+ // Every element of a rendered tree, each before its children, in the order of
697
+ // `__ref.__children`.
698
+ const walkElements = (root, visit) => {
699
+ const walk = (el) => {
700
+ const ref = el && el.__ref
701
+ if (!ref) return
702
+ visit(el, ref)
703
+ if (ref.__children) for (const childKey of ref.__children) walk(el[childKey])
649
704
  }
650
-
651
- return registry
705
+ walk(root)
652
706
  }
653
707
 
654
708
  /**
@@ -666,6 +720,15 @@ const buildPathRegistry = (element, path = '') => {
666
720
  * @param {object} [options.context] - Additional context overrides
667
721
  * @returns {Promise<{ html: string, metadata: object, registry: object, element: object }>}
668
722
  */
723
+ // The payload keys render() puts on the context itself (below `ctx`): the
724
+ // generic copy of every other top-level key skips them — cloning one only for
725
+ // the explicit key to replace it was a deep clone of `functions`, `methods`
726
+ // and `files` thrown away on every render.
727
+ const CONTEXT_KEYS_SET_BELOW = new Set([
728
+ 'app', 'config', 'pages', 'components', 'state', 'snippets', 'designSystem', 'dependencies',
729
+ 'functions', 'methods', 'files', 'sharedLibraries'
730
+ ])
731
+
669
732
  // ── In-flight request tracking (the post-create flush) ──────────────────────
670
733
  // The render's flush waits for the requests a page starts while it renders
671
734
  // (fetch plugin, SDK, project code — all end in globalThis.fetch). One
@@ -692,20 +755,75 @@ const inFlightRequests = () => _inFlight
692
755
  const SEED_ENTRY_MAX = 256 * 1024
693
756
  const SEED_TOTAL_MAX = 1024 * 1024
694
757
  const SEED_HEADERS_OK = new Set(['accept-language', 'accept'])
695
- let _activeRenders = 0
696
- let _seed = null
758
+ const SEED = { renders: 0, record: null }
759
+ // ── What went wrong in a render (its diagnostics) ───────────────────────────
760
+ // Kept like the seed — one record while exactly ONE render runs, tainted by
761
+ // an overlapping one — so a build can tell a page made from data from a page
762
+ // made from nothing: the requests that failed (a non-ok status or a network
763
+ // error) and the label keys that missed (ssrTranslate notes them; the end of
764
+ // the render checks them again, see rawLabelKeys). A build with its database
765
+ // unreachable printed "✓ 22 of 22 routes" and exited 0 while every page
766
+ // carried raw keys (xma.info report, brender build item 9).
767
+ const DIAG_FAILURES_MAX = 50
768
+ const DIAG = { renders: 0, record: null }
769
+
770
+ // One record per window of overlapping renders (the seed's and the
771
+ // diagnostics' windows open at different points of a render, so each has its
772
+ // own slot): the first render of a window opens it, a render that overlaps it
773
+ // taints it — another project's answers or failures must never land in this
774
+ // page — and it goes as soon as one of the renders holding it ends. A render
775
+ // that starts inside a window whose record is gone gets none.
776
+ const openRenderRecord = (slot, make) => {
777
+ slot.renders++
778
+ if (slot.renders === 1) slot.record = make()
779
+ else if (slot.record) slot.record.tainted = true
780
+ const record = slot.record
781
+ const close = () => {
782
+ slot.renders--
783
+ if (slot.record === record) slot.record = null
784
+ }
785
+ return { record, close }
786
+ }
787
+
788
+ // The slot's record while it is the only render's, else null.
789
+ const liveRecord = (slot) => slot.record && !slot.record.tainted ? slot.record : null
790
+
791
+ // A request as a diagnostic names it: origin and path. The query is left out
792
+ // — a public read can carry its key there (`?apikey=…`), and a log line is no
793
+ // place for one.
794
+ const requestLabel = (url) => {
795
+ try {
796
+ const u = new URL(url)
797
+ return u.origin + u.pathname
798
+ } catch {
799
+ return String(url).split('?')[0]
800
+ }
801
+ }
802
+
803
+ const recordFailure = (diag, url, reason) => {
804
+ if (!diag || diag.tainted || diag.failures.length >= DIAG_FAILURES_MAX) return
805
+ diag.failures.push({ url: requestLabel(url), reason })
806
+ }
807
+
808
+ // The URL a fetch() call asks for: a Request's own, else its input as text.
809
+ const requestUrl = (input) =>
810
+ input && typeof input === 'object' && typeof input.url === 'string' ? input.url : String(input)
697
811
 
698
812
  const requestShape = (args) => {
699
813
  const [input, init] = args
700
814
  const isRequest = input && typeof input === 'object' && typeof input.url === 'string'
701
- const url = isRequest ? input.url : String(input)
815
+ const url = requestUrl(input)
702
816
  const method = String((init && init.method) || (isRequest && input.method) || 'GET').toUpperCase()
703
817
  const headers = {}
818
+ // Any fetch HeadersInit, read like the REST adapter reads one (headerEntries).
819
+ // A plain object's null / undefined value is no header.
704
820
  const add = (h) => {
705
821
  if (!h) return
706
- if (typeof h.forEach === 'function' && !Array.isArray(h)) h.forEach((v, k) => { headers[String(k).toLowerCase()] = String(v) })
707
- else if (Array.isArray(h)) for (const [k, v] of h) headers[String(k).toLowerCase()] = String(v)
708
- else for (const k in h) if (h[k] != null) headers[k.toLowerCase()] = String(h[k])
822
+ const plain = !Array.isArray(h) && typeof h.entries !== 'function'
823
+ for (const [k, v] of headerEntries(h)) {
824
+ if (plain && v == null) continue
825
+ headers[String(k).toLowerCase()] = String(v)
826
+ }
709
827
  }
710
828
  if (isRequest) add(input.headers)
711
829
  if (init) add(init.headers)
@@ -734,7 +852,8 @@ const trackFetches = () => {
734
852
  const done = () => { if (!settled) { settled = true; _inFlight-- } }
735
853
  // a pending body read counts as in flight: the flush waits for it too
736
854
  const track = (promise) => { _inFlight++; promise.then(() => { _inFlight-- }, () => { _inFlight-- }) }
737
- const seed = _seed && !_seed.tainted ? _seed : null
855
+ const seed = liveRecord(SEED)
856
+ const diag = liveRecord(DIAG)
738
857
  let shape = null
739
858
  if (seed) {
740
859
  try {
@@ -743,18 +862,24 @@ const trackFetches = () => {
743
862
  Object.keys(shape.headers).some((h) => !SEED_HEADERS_OK.has(h))) shape = null
744
863
  } catch { shape = null }
745
864
  }
865
+ const urlOf = () => requestUrl(args[0])
746
866
  let result
747
867
  try {
748
868
  result = inner.apply(this, args)
749
869
  } catch (err) {
870
+ recordFailure(diag, urlOf(), (err && err.message) || 'failed')
750
871
  done()
751
872
  throw err
752
873
  }
753
874
  if (result && typeof result.then === 'function') {
754
875
  result.then((response) => {
755
876
  if (shape) recordAnswer(seed, shape, response, track)
877
+ if (diag && response && response.ok === false) recordFailure(diag, urlOf(), 'HTTP ' + response.status)
756
878
  done()
757
- }, done)
879
+ }, (err) => {
880
+ recordFailure(diag, urlOf(), (err && err.message) || 'network error')
881
+ done()
882
+ })
758
883
  } else done()
759
884
  return result
760
885
  }
@@ -769,9 +894,14 @@ const trackFetches = () => {
769
894
  // (@symbo.ls/fetch createEarlySeedScript). Default off everywhere — a page
770
895
  // without these keeps the fresh client render.
771
896
  // FW-BRENDER-HYDRATION-AND-CF-DEPLOY-PROVIDER-1.
772
- export const adoptionScripts = ({ brRegistry, fetchSeed } = {}) => {
897
+ // `hydrateFlag: false` — the registry alone, for a client that replaces the
898
+ // server DOM but still reads the registry (renderPage's default ISR path). It
899
+ // inlined its own JSON.stringify there, without the `<` escape, so an element
900
+ // key carrying `</script>` closed the tag.
901
+ export const adoptionScripts = ({ brRegistry, fetchSeed, hydrateFlag = true } = {}) => {
773
902
  if (!brRegistry || !Object.keys(brRegistry).length) return ''
774
- const registry = JSON.stringify(brRegistry).replace(/</g, '\\u003c')
903
+ const registry = jsonForScript(brRegistry)
904
+ if (!hydrateFlag) return `<script>window.__BR_REGISTRY__=${registry}</script>`
775
905
  return `<script>window.__BR_REGISTRY__=${registry};window.__BRENDER_HYDRATE__=true</script>` +
776
906
  (fetchSeed && fetchSeed.length ? '\n' + createEarlySeedScript(fetchSeed) : '')
777
907
  }
@@ -781,55 +911,35 @@ export const render = async (data, options = {}) => {
781
911
  // pathname is the actual URL path (e.g. /podcast/abc-123), route is the page pattern (e.g. /podcast/:id)
782
912
  const locationPath = pathname || route
783
913
 
784
- // ── SSR data prefetching ──
785
- // When prefetch is enabled, walk the page definition to find fetch
786
- // declarations, execute them against the DB adapter, and inject
787
- // the results into element state before rendering.
788
- // Set up globalThis.location early so fetch params that reference
789
- // window.location.pathname (e.g. to extract :id from URL) work during prefetch.
790
- const _prevLocPrefetch = globalThis.location
791
- const _prevWinPrefetch = globalThis.window
792
- if (!globalThis.location || globalThis.location.pathname !== locationPath) {
793
- globalThis.location = { pathname: locationPath, href: locationPath, search: '', hash: '', origin: '' }
794
- }
795
- if (!globalThis.window) {
796
- globalThis.window = { location: globalThis.location }
797
- }
798
- let prefetchedPages
799
- if (prefetch) {
800
- try {
801
- const pages = data.pages || {}
802
- prefetchedPages = { ...pages }
803
- const stateUpdates = await prefetchPageData(data, route)
804
- if (stateUpdates.size) {
805
- // Deep clone the page def to avoid mutating the original
806
- const pageDef = JSON.parse(JSON.stringify(pages[route], (key, value) => {
807
- if (typeof value === 'function') return undefined
808
- return value
809
- }))
810
- // Re-attach functions from original
811
- const copyFunctions = (src, dst) => {
812
- if (!src || !dst) return
813
- for (const k in src) {
814
- if (typeof src[k] === 'function') {
815
- dst[k] = src[k]
816
- } else if (typeof src[k] === 'object' && src[k] !== null && !Array.isArray(src[k]) && typeof dst[k] === 'object' && dst[k] !== null) {
817
- copyFunctions(src[k], dst[k])
818
- }
819
- }
820
- }
821
- copyFunctions(pages[route], pageDef)
822
- injectPrefetchedState(pageDef, stateUpdates)
823
- prefetchedPages[route] = pageDef
824
- }
825
- } catch (prefetchErr) {
826
- console.error('[brender] Prefetch error:', prefetchErr)
827
- prefetchedPages = data.pages
828
- }
914
+ // This render's diagnostics (see `DIAG`), open before its first request.
915
+ trackFetches()
916
+ const diag = openRenderRecord(DIAG, () => ({ failures: [], labelKeys: new Set(), tainted: false }))
917
+ try {
918
+ return await renderWithDiagnostics(data, { route, locationPath, stateOverrides, contextOverrides, prefetch }, diag.record)
919
+ } finally {
920
+ diag.close()
829
921
  }
922
+ }
923
+
924
+ const renderWithDiagnostics = async (data, { route, locationPath, stateOverrides, contextOverrides, prefetch }, diag) => {
925
+ // The page's data is NOT fetched here. The fetch plugin runs during the
926
+ // render below with the real element — its `transform(data, el, state)`,
927
+ // its `children` re-run on the answer — and the post-create flush waits
928
+ // for its requests. A pre-fetch used to run every page `fetch` through a
929
+ // second code path: `transform(data)` without the element (a transform
930
+ // reading `el.call(...)` threw, so the RAW rows landed in state) and
931
+ // `children` functions evaluated once with a stand-in element whose
932
+ // call() hard-coded 'ka' and read the wrong argument, then frozen into
933
+ // static arrays the real answer could no longer replace — cards without
934
+ // titles, a calendar of NaN (xma.info report, brender build items 5, 13).
830
935
 
831
936
  // ── SSR polyglot translations ──
832
- // Fetch translations from the DB so polyglot resolves during render
937
+ // Labels a project serves through a table/RPC (`polyglot.fetch: { rpc }`)
938
+ // are fetched before the render, for every language. A FUNCTION
939
+ // `polyglot.fetch` is the project's own loader: it runs inside the render,
940
+ // called by the polyglot plugin with the real root element (see
941
+ // fetchSSRTranslations), and its labels are read back from the root state
942
+ // afterwards (renderedLabels).
833
943
  let ssrTranslations
834
944
  if (prefetch) {
835
945
  try {
@@ -839,24 +949,27 @@ export const render = async (data, options = {}) => {
839
949
  }
840
950
  }
841
951
 
842
- // Restore location/window before createEnv sets them properly
843
- if (_prevLocPrefetch !== undefined) globalThis.location = _prevLocPrefetch
844
- else delete globalThis.location
845
- if (_prevWinPrefetch !== undefined) globalThis.window = _prevWinPrefetch
846
- else delete globalThis.window
847
-
848
952
  const { window, document } = createEnv()
849
953
  const body = document.body
850
954
 
851
955
  // Set route on location so the router picks it up
852
956
  window.location.pathname = locationPath
853
957
 
854
- // Set globalThis.document/location so the bundled smbls code
855
- // (which uses `window = globalThis`) can access them during SSR.
856
- const _prevDoc = globalThis.document
857
- const _prevLoc = globalThis.location
858
- globalThis.document = document
859
- globalThis.location = window.location
958
+ // globalThis.document/location for the bundled smbls code (which uses
959
+ // `window = globalThis`) during SSR, and the render's own storage behind
960
+ // the bare `localStorage` the framework reads (smbls prepareState):
961
+ // polyglot persists the language a page switched to and the labels it
962
+ // loaded there, and one shared store handed them to the next page of the
963
+ // build (createEnv). Swapped by descriptor and put back however the render
964
+ // ends, a throw included — a host's own Web Storage global (Node's is a
965
+ // lazy accessor) is never read or lost.
966
+ const restoreGlobals = [
967
+ swapGlobal('document', document),
968
+ swapGlobal('location', window.location),
969
+ swapGlobal('localStorage', window.localStorage),
970
+ swapGlobal('sessionStorage', window.sessionStorage)
971
+ ]
972
+ try {
860
973
 
861
974
  // Import createDomqlElement via bundled smbls source.
862
975
  // The smbls monorepo uses extensionless/directory imports that Node.js ESM
@@ -889,7 +1002,7 @@ export const render = async (data, options = {}) => {
889
1002
  if (ssrTranslations || polyglotConfig) {
890
1003
  if (!baseState.root) baseState.root = {}
891
1004
  if (polyglotConfig) {
892
- baseState.root.lang = baseState.root.lang || polyglotConfig.defaultLang || 'en'
1005
+ baseState.root.lang = polyglotFallbackLang(baseState.root.lang, polyglotConfig)
893
1006
  }
894
1007
  if (ssrTranslations) {
895
1008
  baseState.root.translations = {
@@ -913,8 +1026,7 @@ export const render = async (data, options = {}) => {
913
1026
  // build reuses. The explicit keys below still win.
914
1027
  const projectContext = {}
915
1028
  for (const key in data) {
916
- if (key === 'app' || key === 'config' || key === 'pages' || key === 'components' ||
917
- key === 'state' || key === 'snippets' || key === 'designSystem' || key === 'dependencies') continue
1029
+ if (CONTEXT_KEYS_SET_BELOW.has(key)) continue
918
1030
  const value = data[key]
919
1031
  projectContext[key] = value && typeof value === 'object' ? structuredCloneDeep(value) : value
920
1032
  }
@@ -926,7 +1038,7 @@ export const render = async (data, options = {}) => {
926
1038
  dependencies: structuredCloneDeep(data.dependencies || {}),
927
1039
  components: structuredCloneDeep(data.components || {}),
928
1040
  snippets: structuredCloneDeep(data.snippets || {}),
929
- pages: structuredCloneDeep(prefetchedPages || data.pages || {}),
1041
+ pages: structuredCloneDeep(data.pages || {}),
930
1042
  functions: {
931
1043
  ...(data.functions || {}),
932
1044
  // SSR polyglot functions — enable {{ key | polyglot }} resolution during render
@@ -949,6 +1061,11 @@ export const render = async (data, options = {}) => {
949
1061
  // Disable sourcemap tracking in SSR — it causes stack overflows
950
1062
  // when state contains large data arrays (articles, events, etc.)
951
1063
  domqlOptions: { sourcemap: false },
1064
+ // The server builds the page in one go: the browser boot's short tasks
1065
+ // (smbls firstRender.js) serve input and paint, and a server has neither.
1066
+ // smbls skips them by itself in a realm without requestAnimationFrame;
1067
+ // this document shims it (env.js), so it says so here.
1068
+ firstRender: false,
952
1069
  // Editor/live-sync, inspector and toast plugins have no place in server
953
1070
  // markup. The source-bundle path used to stub @symbo.ls/sync to no-ops at
954
1071
  // bundle time; the installed `smbls/ssr` entry carries the real plugins,
@@ -958,6 +1075,19 @@ export const render = async (data, options = {}) => {
958
1075
  sync: false,
959
1076
  inspect: false,
960
1077
  notifications: false,
1078
+ // Every server render processes the design system the way the page's
1079
+ // own single boot does, on a config of its own — the first render of a
1080
+ // process used to share the global config and every later one took the
1081
+ // iframe path, so their theme variables differed (smbls
1082
+ // prepareDesignSystem; xma.info report, brender build item 7).
1083
+ isolateDesignSystem: true,
1084
+ // Every server render starts from an empty query cache (@symbo.ls/fetch
1085
+ // enterContext). The fetch plugin's cache outlives a render, so a page
1086
+ // rendered after another one that read the same key showed those rows
1087
+ // without a request, and its fetch seed — recorded from requests — did
1088
+ // not carry them: the browser asked for them again after boot (xma.info,
1089
+ // 2026-10-16: index.html 10 seed entries, about.html 3).
1090
+ isolateFetchCache: true,
961
1091
  // Caller overrides
962
1092
  ...(contextOverrides || {})
963
1093
  }
@@ -973,21 +1103,19 @@ export const render = async (data, options = {}) => {
973
1103
 
974
1104
  resetKeys()
975
1105
 
976
- trackFetches()
977
- // the recording of this render's answers (see `_seed`): tainted the moment
1106
+ // the recording of this render's answers (see `SEED`): tainted the moment
978
1107
  // another render overlaps it
979
- _activeRenders++
980
- if (_activeRenders === 1) _seed = { entries: [], bytes: 0, tainted: false }
981
- else if (_seed) _seed.tainted = true
982
- const seed = _seed
1108
+ const recording = openRenderRecord(SEED, () => ({ entries: [], bytes: 0, tainted: false }))
1109
+ const seed = recording.record
983
1110
  let element
984
1111
  try {
985
1112
  element = await createDomqlElement(app, ctx)
986
1113
 
987
1114
  // Allow async operations (fetch callbacks, state updates, re-renders) to flush.
988
- // DOMQL's fetch plugin fires on element creation and updates state asynchronously.
989
- // With prefetch enabled, data is pre-injected but DOMQL's fetch may also fire
990
- // and trigger state updates. Give enough time for these to complete.
1115
+ // DOMQL's fetch plugin fires on element creation and updates state
1116
+ // asynchronously — it is what loads the page's data (nothing is
1117
+ // pre-injected) — and a function polyglot.fetch loads the labels the
1118
+ // same way. Give enough time for these to complete.
991
1119
  //
992
1120
  // It used to sleep a flat 2000 ms with prefetch — measured in workerd on
993
1121
  // six published projects, `flush` was 2000 ms on every route, 40–90 % of
@@ -1001,12 +1129,12 @@ export const render = async (data, options = {}) => {
1001
1129
  const sleep = (ms) => new Promise(r => setTimeout(r, ms))
1002
1130
  await sleep(Math.min(50, flushCap))
1003
1131
  while (prefetch && inFlightRequests() > 0 && Date.now() - flushStart < flushCap) {
1004
- while (inFlightRequests() > 0 && Date.now() - flushStart < flushCap) await sleep(10)
1005
- await sleep(Math.max(0, Math.min(50, flushCap - (Date.now() - flushStart))))
1132
+ await sleep(10)
1133
+ // a settled batch: another quiet period for what it triggers
1134
+ if (inFlightRequests() === 0) await sleep(Math.max(0, Math.min(50, flushCap - (Date.now() - flushStart))))
1006
1135
  }
1007
1136
  } finally {
1008
- _activeRenders--
1009
- if (_seed === seed) _seed = null
1137
+ recording.close()
1010
1138
  }
1011
1139
  const fetchSeed = seed && !seed.tainted ? seed.entries.slice() : []
1012
1140
 
@@ -1017,7 +1145,8 @@ export const render = async (data, options = {}) => {
1017
1145
 
1018
1146
  // Build element path → brKey registry for client-side true hydration.
1019
1147
  // Walks the element tree after mapKeysToElements has set __brKey on each element.
1020
- const brRegistry = buildPathRegistry(element)
1148
+ const { elementPath } = await import('@symbo.ls/element')
1149
+ const brRegistry = buildPathRegistry(element, elementPath)
1021
1150
 
1022
1151
  // Extract metadata for the rendered route
1023
1152
  // Pass the rendered element and its state so function-valued metadata
@@ -1025,52 +1154,128 @@ export const render = async (data, options = {}) => {
1025
1154
  const metadata = extractMetadata(data, route, element, element?.state)
1026
1155
 
1027
1156
  // Extract CSS from style tags in virtual head (atomic CSS engine writes here)
1028
- const emotionCSS = []
1029
1157
  const capturedRules = []
1030
1158
  const head = document.head || document.querySelector('head')
1031
1159
  if (head) {
1032
1160
  for (const style of head.querySelectorAll('style')) {
1033
1161
  if (style.sheet && style.sheet.cssRules) {
1034
1162
  for (const rule of style.sheet.cssRules) {
1035
- if (rule.cssText) {
1036
- emotionCSS.push(rule.cssText)
1037
- capturedRules.push(rule)
1038
- }
1163
+ if (rule.cssText) capturedRules.push(rule)
1039
1164
  }
1040
1165
  }
1041
- if (!emotionCSS.length) {
1166
+ if (!capturedRules.length) {
1042
1167
  const content = style.textContent || ''
1043
- if (content) {
1044
- emotionCSS.push(content)
1045
- capturedRules.push({ cssText: content })
1046
- }
1168
+ if (content) capturedRules.push({ cssText: content })
1047
1169
  }
1048
1170
  }
1049
1171
  }
1172
+ const emotionCSS = capturedRules.map((rule) => rule.cssText)
1050
1173
  // globalCSS: the init-injected globals; classRules: everything else, in
1051
1174
  // capture order (see splitCapturedCSS).
1052
1175
  const { globalCSS, classRules } = splitCapturedCSS(capturedRules)
1053
1176
 
1054
1177
  let html = fixSvgContent(body.innerHTML)
1055
1178
 
1179
+ // The language this page was rendered in and the labels it ended with:
1180
+ // the ones fetched before the render plus whatever the project's own
1181
+ // polyglot.fetch loaded into the root state during it.
1182
+ const root = element?.state?.root
1183
+ const activeLang = activeLangOf(root, polyglotConfig)
1184
+ const labels = renderedLabels(ssrTranslations, root)
1185
+
1056
1186
  // Post-process: resolve any remaining {{ key | polyglot }} templates
1057
- // that weren't resolved during DOMQL rendering (e.g. due to timing)
1058
- if (ssrTranslations) {
1059
- const defaultLang = polyglotConfig?.defaultLang || 'en'
1060
- const langMap = ssrTranslations[defaultLang] || Object.values(ssrTranslations)[0] || {}
1061
- html = html.replace(/\{\{\s*([^|{}]+?)\s*\|\s*polyglot\s*\}\}/g, (match, key) => {
1062
- const trimmed = key.trim()
1063
- return langMap[trimmed] ?? match
1064
- })
1187
+ // that weren't resolved during DOMQL rendering (e.g. due to timing) — in
1188
+ // the page's language. It used the default language, so a page rendered
1189
+ // for /en/… got its leftover templates in the default language's words
1190
+ // (xma.info report, brender build item 8).
1191
+ if (labels) html = resolveLabelTemplates(html, labelMapFor(labels, activeLang, polyglotConfig))
1192
+
1193
+ const diagnostics = diag && !diag.tainted
1194
+ ? {
1195
+ failedRequests: diag.failures.slice(),
1196
+ rawLabelKeys: rawLabelKeys(diag.labelKeys, html, activeLang, polyglotConfig, root)
1197
+ }
1198
+ : null
1199
+
1200
+ return { html, metadata, registry, brRegistry, element, emotionCSS, globalCSS, classRules, fetchSeed, document, window, ssrTranslations: labels || undefined, activeLang, diagnostics }
1201
+ } finally {
1202
+ for (const restore of restoreGlobals) restore()
1065
1203
  }
1204
+ }
1066
1205
 
1067
- // Restore globalThis after render
1068
- if (_prevDoc !== undefined) globalThis.document = _prevDoc
1069
- else delete globalThis.document
1070
- if (_prevLoc !== undefined) globalThis.location = _prevLoc
1071
- else delete globalThis.location
1206
+ // Put `value` on globalThis[name] for a render and hand back what puts the
1207
+ // old property back, descriptor and all. Never reads the old value — a host
1208
+ // global can be a lazy accessor that warns or throws when read.
1209
+ const swapGlobal = (name, value) => {
1210
+ const previous = Object.getOwnPropertyDescriptor(globalThis, name)
1211
+ if (previous && !previous.configurable) {
1212
+ if (!previous.writable && !previous.set) return () => {}
1213
+ globalThis[name] = value
1214
+ return () => {}
1215
+ }
1216
+ Object.defineProperty(globalThis, name, { value, writable: true, configurable: true, enumerable: true })
1217
+ return () => {
1218
+ if (previous) Object.defineProperty(globalThis, name, previous)
1219
+ else delete globalThis[name]
1220
+ }
1221
+ }
1222
+
1223
+ // ── Labels after a render ───────────────────────────────────────────────────
1224
+
1225
+ const LABEL_TEMPLATE = /\{\{\s*([^|{}]+?)\s*\|\s*polyglot\s*\}\}/g
1226
+
1227
+ const plainOf = (value) => value && typeof value.parse === 'function' ? value.parse() : value
1228
+
1229
+ // The labels a render ended with, by language: the ones fetched before it
1230
+ // (`ssrTranslations`), with the root state's own — loaded during the render
1231
+ // by the project's polyglot.fetch — over them. Null when there are none.
1232
+ const renderedLabels = (ssrTranslations, root) => {
1233
+ const loaded = plainOf(root?.translations)
1234
+ const out = {}
1235
+ for (const source of [ssrTranslations, loaded]) {
1236
+ if (!source || typeof source !== 'object') continue
1237
+ for (const lang in source) {
1238
+ const map = plainOf(source[lang])
1239
+ if (map && typeof map === 'object') out[lang] = { ...(out[lang] || {}), ...map }
1240
+ }
1241
+ }
1242
+ return Object.keys(out).length ? out : null
1243
+ }
1244
+
1245
+ // One language's labels: the page's, else the default language's, else the
1246
+ // first there is — the order the post-render pass always fell back in.
1247
+ const labelMapFor = (labels, activeLang, poly) =>
1248
+ labels[activeLang] || labels[polyglotFallbackLang(undefined, poly)] || Object.values(labels)[0] || {}
1249
+
1250
+ const resolveLabelTemplates = (html, langMap) =>
1251
+ html.replace(LABEL_TEMPLATE, (match, key) => {
1252
+ const val = ssrResolve(langMap, key.trim())
1253
+ return val ?? match
1254
+ })
1072
1255
 
1073
- return { html, metadata, registry, brRegistry, element, emotionCSS, globalCSS, classRules, fetchSeed, document, window, ssrTranslations, prefetchedPages }
1256
+ // JSON data blocks (registry, fetch seed) are data, not what a visitor reads.
1257
+ const DATA_BLOCK = /<script\b[^>]*\btype\s*=\s*["']?application\/json["']?[^>]*>[\s\S]*?<\/script\s*>/gi
1258
+ // The key as a whole token: not part of a longer key or word.
1259
+ const keyInMarkup = (markup, key) =>
1260
+ new RegExp(`(?:^|[^\\w.-])${escapeRegExp(key)}(?![\\w-]|\\.\\w)`).test(markup)
1261
+
1262
+ // The label keys a page shows raw: every key ssrTranslate missed that the
1263
+ // labels the render ended with still do not have AND that reached the
1264
+ // markup, plus every `{{ key | polyglot }}` template left in it. A lookup
1265
+ // whose result never rendered (a discarded value, a branch not taken) is no
1266
+ // raw key on screen — XMA /vlog failed `--strict` on `ui.vlog.desc`, which
1267
+ // appeared 0 times in the page. A miss with whitespace in it is text handed
1268
+ // to `polyglot`, not a key.
1269
+ const rawLabelKeys = (missed, html, activeLang, poly, root) => {
1270
+ const raw = new Set()
1271
+ const markup = html.replace(DATA_BLOCK, '')
1272
+ for (const key of missed) {
1273
+ if (/\s/.test(key)) continue
1274
+ if (findLabel(key, activeLang, poly, root) !== undefined) continue
1275
+ if (keyInMarkup(markup, key)) raw.add(key)
1276
+ }
1277
+ for (const m of markup.matchAll(LABEL_TEMPLATE)) raw.add(m[1].trim())
1278
+ return [...raw]
1074
1279
  }
1075
1280
 
1076
1281
  /**
@@ -1156,27 +1361,6 @@ const SCRATCH_CONFIG_FLAGS = [
1156
1361
  'useDocumentTheme', 'useDefaultConfig', 'useDefaultIcons',
1157
1362
  'useThemeSuffixedVars', 'verbose', 'semanticIcons',
1158
1363
  ]
1159
- // Serialise scratch `buildSkinVarStyles` output — `{ selector: { var: value },
1160
- // '@media …': { selector: { var: value } } }` — as CSS text
1161
- // (FW-SCRATCH-SKINS-DIMENSION-1).
1162
- const declarationBlock = (selector, vars, indent) => {
1163
- const decls = Object.entries(vars || {})
1164
- .map(([k, v]) => `${indent} ${k}: ${v}`)
1165
- .join(';\n')
1166
- return decls ? `${indent}${selector} {\n${decls};\n${indent}}` : ''
1167
- }
1168
-
1169
- export const skinRulesToCSS = (rules) => Object.entries(rules || {})
1170
- .map(([key, body]) => {
1171
- if (!key.startsWith('@')) return declarationBlock(key, body, '')
1172
- const inner = Object.entries(body || {})
1173
- .map(([selector, vars]) => declarationBlock(selector, vars, ' '))
1174
- .filter(Boolean)
1175
- .join('\n')
1176
- return inner ? `${key} {\n${inner}\n}` : ''
1177
- })
1178
- .filter(Boolean)
1179
- .join('\n\n')
1180
1364
 
1181
1365
  // ── Global CSS: the render's own capture, split ────────────────────────────
1182
1366
  // The rules smbls init injects into the virtual <head> during render() ARE
@@ -1296,8 +1480,9 @@ export const replaceEmotionCSS = (html, newCSS) => {
1296
1480
  * @returns {Promise<{ html: string, css: string, resetCss: string, fontLinks: string, metadata: object, brKeyCount: number }>}
1297
1481
  */
1298
1482
  export const renderRoute = async (data, options = {}) => {
1299
- // `prefetch: false` renders without the DB prefetch and translation fetch
1300
- // — the publish-time pre-render (smbls publish → ssrCache) renders data-free.
1483
+ // `prefetch: false` fetches no table/RPC labels first and does not wait for
1484
+ // the page's requests — the publish-time pre-render (smbls publish →
1485
+ // ssrCache) renders data-free.
1301
1486
  const { route = '/', pathname, prefetch = true } = options
1302
1487
 
1303
1488
  // Use the full render pipeline which handles polyglot, prefetch, emotion, etc.
@@ -1308,39 +1493,16 @@ export const renderRoute = async (data, options = {}) => {
1308
1493
  const ds = data.designSystem || {}
1309
1494
  const globalCSS = result.globalCSS
1310
1495
 
1311
- // Extract prefetched state and language for metadata resolution
1496
+ // The page data the render ended with and its language, for a caller's
1497
+ // metadata (mermaid's fallback reads them). The data is what the fetch
1498
+ // plugin put into state during the render (collectFetchedState) — it used
1499
+ // to be the RAW rows a pre-fetch had injected into the page definition.
1312
1500
  let prefetchedState = null
1313
- let activeLang = null
1314
1501
  try {
1315
- const el = result.element
1316
- const polyglot = el?.context?.polyglot || data.polyglot || data.config?.polyglot
1317
- activeLang = el?.state?.root?.lang || polyglot?.defaultLang || 'en'
1318
-
1319
- // Get prefetched data from the injected page definitions (pre-DOMQL proxy)
1320
- if (result.prefetchedPages && result.prefetchedPages[route]) {
1321
- const pageDef = result.prefetchedPages[route]
1322
- // Collect all state entries from the page definition tree
1323
- const collectStates = (def, result = {}) => {
1324
- if (!def || typeof def !== 'object') return result
1325
- if (def.state && typeof def.state === 'object') {
1326
- for (const [k, v] of Object.entries(def.state)) {
1327
- if (v !== undefined && v !== null && typeof v !== 'function') {
1328
- result[k] = v
1329
- }
1330
- }
1331
- }
1332
- // Recurse into all child definitions (not just those with state)
1333
- for (const [key, child] of Object.entries(def)) {
1334
- if (key === 'state' || key === 'props' || key === 'attr' || key === 'on' || key === 'define' || key === '__ref' || key.startsWith('__')) continue
1335
- if (child && typeof child === 'object' && !Array.isArray(child)) {
1336
- collectStates(child, result)
1337
- }
1338
- }
1339
- return result
1340
- }
1341
- prefetchedState = collectStates(pageDef)
1342
- }
1502
+ const fetched = collectFetchedState(result.element)
1503
+ if (Object.keys(fetched).length) prefetchedState = fetched
1343
1504
  } catch (e) { /* ignore */ }
1505
+ const activeLang = result.activeLang
1344
1506
 
1345
1507
  return {
1346
1508
  html: result.html,
@@ -1354,14 +1516,39 @@ export const renderRoute = async (data, options = {}) => {
1354
1516
  metadata: result.metadata || extractMetadata(data, route),
1355
1517
  brKeyCount: result.registry ? Object.keys(result.registry).length : 0,
1356
1518
  brRegistry: result.brRegistry || {},
1357
- // anonymous GET answers this render got (see `_seed`) — for adoptionScripts
1519
+ // anonymous GET answers this render got (see `SEED`) — for adoptionScripts
1358
1520
  fetchSeed: result.fetchSeed || [],
1359
1521
  ssrTranslations: result.ssrTranslations,
1360
1522
  prefetchedState,
1361
- activeLang
1523
+ activeLang,
1524
+ // failed requests and raw label keys (see `DIAG`); null when an
1525
+ // overlapping render made them unknowable
1526
+ diagnostics: result.diagnostics
1362
1527
  }
1363
1528
  }
1364
1529
 
1530
+ // What the fetch plugin put into state during a render, by the `as` key each
1531
+ // `fetch` config names — a `~/key` one read off the root state. Configs
1532
+ // without `as` (a transform spreading into the element's state) and function
1533
+ // configs are not read: a function would have to run again.
1534
+ const collectFetchedState = (element) => {
1535
+ const out = {}
1536
+ walkElements(element, (el) => {
1537
+ const decl = el.fetch
1538
+ if (!decl || typeof decl !== 'object' || !el.state) return
1539
+ for (const cfg of Array.isArray(decl) ? decl : [decl]) {
1540
+ const as = cfg && typeof cfg === 'object' && typeof cfg.as === 'string' ? cfg.as : null
1541
+ if (!as) continue
1542
+ const fromRoot = as.startsWith('~/')
1543
+ const name = fromRoot ? as.slice(2) : as
1544
+ const source = fromRoot ? el.state.root : el.state
1545
+ const value = plainOf(source && source[name])
1546
+ if (name && value != null && typeof value !== 'function') out[name] = value
1547
+ }
1548
+ })
1549
+ return out
1550
+ }
1551
+
1365
1552
  // ── Full page SSR ─────────────────────────────────────────────────────────────
1366
1553
 
1367
1554
  /**
@@ -1378,12 +1565,20 @@ export const renderRoute = async (data, options = {}) => {
1378
1565
  * @param {string} [options.themeColor] - theme-color meta
1379
1566
  * @param {object} [options.isr] - ISR options with clientScript path
1380
1567
  * @param {boolean} [options.hydrate=true] - With `isr`: boot the client over the
1381
- * pre-rendered markup (`__BRENDER__`; the client replaces it with a fresh
1382
- * render) instead of the legacy swap script
1383
- * @param {boolean} [options.adoptDom=false] - Opt-in hydration: also ship the
1384
- * element-path registry, `__BRENDER_HYDRATE__` and the recorded anonymous
1385
- * fetch answers, so the client ADOPTS the server DOM (smbls hydrateRender)
1386
- * @param {boolean} [options.prefetch=true] - Whether to prefetch data via DB adapter
1568
+ * pre-rendered markup (the page's `<meta name="brender">`) instead of the
1569
+ * legacy swap script
1570
+ * @param {boolean} [options.adoptDom=false] - Hydration: also ship the
1571
+ * element-path registry (a JSON data block), so the client ADOPTS the
1572
+ * server DOM (smbls prerender.js) instead of replacing it. `smbls build`
1573
+ * passes symbols.json `brender.hydrate` (cli helpers/runBrender.js).
1574
+ * @param {string} [options.nonce] - The Content-Security-Policy nonce for the
1575
+ * inline scripts the page needs (the ISR paths only: a page rendered into a
1576
+ * shell carries none — its flag is a <meta>, its registry and fetch seed are
1577
+ * data blocks, which no policy blocks)
1578
+ * @param {boolean} [options.prefetch=true] - Fetch table/RPC labels before the
1579
+ * render and wait (up to 2 s) for the requests the page starts; false waits
1580
+ * only a 50 ms quiet period. The page's data always comes from the fetch
1581
+ * plugin during the render.
1387
1582
  * @param {string} [options.shell] - A built app document (e.g. dist/index.html)
1388
1583
  * to render INTO instead of emitting a standalone document (see shell.js).
1389
1584
  * The result then carries `shell: true`.
@@ -1393,13 +1588,18 @@ export const renderRoute = async (data, options = {}) => {
1393
1588
  * @returns {Promise<{ html: string, route: string, brKeyCount: number, shell?: true }>}
1394
1589
  */
1395
1590
  export const renderPage = async (data, route = '/', options = {}) => {
1396
- const { lang, themeColor, isr, hydrate = true, prefetch = true, shell, adoptDom = false } = options
1591
+ const { lang, themeColor, isr, hydrate = true, prefetch = true, shell, adoptDom = false, pathname, eagerImages = EAGER_IMAGES_DEFAULT, preloadScripts = true, nonce } = options
1592
+ const nonceAttr = nonceAttribute(nonce)
1397
1593
 
1398
1594
  // Detect lang from project config, app metadata, or default
1399
1595
  const htmlLang = lang || data.state?.lang || data.app?.metadata?.lang || 'en'
1400
1596
 
1401
- // Use the full smbls pipeline for rendering
1402
- const result = await render(data, { route, prefetch })
1597
+ // Use the full smbls pipeline for rendering. `pathname` is the address the
1598
+ // page is rendered at — the router matches it, location and every
1599
+ // canonical/og:url built from it read it. Without it a route was rendered
1600
+ // at its own pattern: the `/*` page wrote a `/*` canonical into 404.html
1601
+ // (xma.info report, brender build item 7).
1602
+ const result = await render(data, { route, pathname, prefetch })
1403
1603
  if (!result) return null
1404
1604
 
1405
1605
  const metadata = { ...result.metadata }
@@ -1432,15 +1632,23 @@ export const renderPage = async (data, route = '/', options = {}) => {
1432
1632
  }
1433
1633
  const emotionCSS = Array.from(_accumulatedEmotionCSS).join('\n')
1434
1634
 
1435
- // Global CSS (variables, reset, keyframes): this render's own init output
1436
- const ds = data.designSystem || {}
1635
+ // Global CSS (variables, reset, keyframes): this render's own init output.
1636
+ // The style tags, built once for both documents below.
1437
1637
  const globalCSS = result.globalCSS
1638
+ const brenderStyle = `<style data-brender>\n${globalCSS.rootRule || ''}\n${globalCSS.resetRules || ''}\n${globalCSS.keyframeRules || ''}\n</style>`
1639
+ const emotionStyle = emotionCSS ? `<style data-emotion="smbls">\n${emotionCSS}\n</style>` : ''
1438
1640
 
1439
1641
  // Generate font links from design system
1440
- const fontLinks = generateFontLinks(ds)
1642
+ const fontLinks = generateFontLinks(data.designSystem || {})
1441
1643
 
1442
1644
  const brKeyCount = Object.keys(result.registry).length
1443
1645
 
1646
+ // What the client boots with (prerenderParts): the page's mark for <head>,
1647
+ // and the data blocks after the markup — the registry under `adoptDom`,
1648
+ // and the recorded anonymous answers, so the client renders from the data
1649
+ // the server used instead of asking for all of it again.
1650
+ const prerender = prerenderParts({ brRegistry: result.brRegistry, fetchSeed: result.fetchSeed, adoptDom })
1651
+
1444
1652
  // ISR: include client SPA bundle for hydration + data fetching
1445
1653
  let isrBody = ''
1446
1654
  if (isr && isr.clientScript) {
@@ -1453,9 +1661,9 @@ export const renderPage = async (data, route = '/', options = {}) => {
1453
1661
  const prefix = depth > 0 ? '../'.repeat(depth) : './'
1454
1662
 
1455
1663
  if (hydrate) {
1456
- // True hydration: signal the SPA to adopt existing DOM nodes
1457
- // instead of creating new ones. The SPA detects __BRENDER__ flag
1458
- // and uses onlyResolveExtends + node adoption.
1664
+ // The client boots over the markup: it knows the page by its
1665
+ // `<meta name="brender">` and adopts the markup when the page carries
1666
+ // the registry (`adoptDom`), else replaces it (smbls prerender.js).
1459
1667
  //
1460
1668
  // Seed client-side polyglot with SSR translations so that
1461
1669
  // el.call('polyglot', key) resolves immediately during hydration
@@ -1473,27 +1681,20 @@ export const renderPage = async (data, route = '/', options = {}) => {
1473
1681
  }
1474
1682
  }
1475
1683
  if (storageLangKey) {
1476
- const defaultLang = polyglotCfg?.defaultLang || 'en'
1684
+ const defaultLang = polyglotFallbackLang(undefined, polyglotCfg)
1477
1685
  seedEntries.push(`if(!localStorage.getItem(${JSON.stringify(storageLangKey)}))localStorage.setItem(${JSON.stringify(storageLangKey)},${JSON.stringify(defaultLang)})`)
1478
1686
  }
1479
1687
  if (seedEntries.length) {
1480
- translationSeed = `<script>try{${seedEntries.join(';')}}catch(e){}</script>\n`
1688
+ translationSeed = `<script${nonceAttr}>try{${seedEntries.join(';')}}catch(e){}</script>\n`
1481
1689
  }
1482
1690
  }
1483
- // Embed BR registry for true DOM-adoption hydration
1484
- const brRegistryJson = result.brRegistry && Object.keys(result.brRegistry).length
1485
- ? JSON.stringify(result.brRegistry)
1486
- : null
1487
- const brRegistryScript = adoptDom
1488
- ? (adoptionScripts(result) ? adoptionScripts(result) + '\n' : '')
1489
- : brRegistryJson
1490
- ? `<script>window.__BR_REGISTRY__=${brRegistryJson}</script>\n`
1491
- : ''
1492
- isrBody = `${translationSeed}${brRegistryScript}<script>window.__BRENDER__=true</script>
1493
- <script type="module" src="${prefix}${isr.clientScript}"></script>`
1691
+ // The registry and the fetch seed as data blocks; the page's mark is
1692
+ // its <meta name="brender"> (no inline flag a policy could block).
1693
+ const blocks = prerender.blocks ? prerender.blocks + '\n' : ''
1694
+ isrBody = `${translationSeed}${blocks}<script type="module" src="${prefix}${isr.clientScript}"${nonceAttr}></script>`
1494
1695
  } else {
1495
1696
  // Legacy swap mode: SPA creates new DOM, MutationObserver removes brender nodes
1496
- isrBody = `<script type="module">
1697
+ isrBody = `<script type="module"${nonceAttr}>
1497
1698
  {
1498
1699
  const brEls = document.querySelectorAll('body > :not(script):not(style)')
1499
1700
  const observer = new MutationObserver((mutations) => {
@@ -1510,461 +1711,100 @@ export const renderPage = async (data, route = '/', options = {}) => {
1510
1711
  observer.observe(document.body, { childList: true })
1511
1712
  }
1512
1713
  </script>
1513
- <script type="module" src="${prefix}${isr.clientScript}"></script>`
1714
+ <script type="module" src="${prefix}${isr.clientScript}"${nonceAttr}></script>`
1514
1715
  }
1515
1716
  }
1516
1717
 
1517
1718
  // Resolve any {{ key | polyglot }} templates in head tags (title, meta, etc.)
1719
+ // — in the page's language, with the labels the render ended with.
1518
1720
  const headConfig = { ...(data.config || {}) }
1519
1721
  if (data.polyglot && !headConfig.polyglot) headConfig.polyglot = data.polyglot
1520
1722
  const polyglotCfg = headConfig.polyglot
1521
1723
  let resolvedHeadTags = headTags
1522
1724
  if (polyglotCfg) {
1523
- const defaultLang = polyglotCfg.defaultLang || 'en'
1524
1725
  // Use SSR-fetched translations (from render result) merged with static translations
1525
- const translations = {
1526
- ...(polyglotCfg.translations || {}),
1527
- ...(result.ssrTranslations || {})
1726
+ const translations = { ...(polyglotCfg.translations || {}) }
1727
+ for (const lang in result.ssrTranslations || {}) {
1728
+ translations[lang] = { ...(translations[lang] || {}), ...result.ssrTranslations[lang] }
1528
1729
  }
1529
- const langMap = translations[defaultLang] || {}
1530
- resolvedHeadTags = headTags.replace(/\{\{\s*([^|{}]+?)\s*\|\s*polyglot\s*\}\}/g, (match, key) => {
1531
- const trimmed = key.trim()
1532
- return langMap[trimmed] ?? match
1533
- })
1730
+ resolvedHeadTags = resolveLabelTemplates(headTags, labelMapFor(translations, result.activeLang, polyglotCfg))
1534
1731
  }
1535
1732
 
1733
+ // A template the head still carries is a raw key too.
1734
+ const diagnostics = result.diagnostics
1735
+ if (diagnostics) {
1736
+ for (const m of resolvedHeadTags.matchAll(LABEL_TEMPLATE)) {
1737
+ const key = m[1].trim()
1738
+ if (!diagnostics.rawLabelKeys.includes(key)) diagnostics.rawLabelKeys.push(key)
1739
+ }
1740
+ }
1741
+
1742
+ // Images past the first `eagerImages` load lazily unless they say how to
1743
+ // load (see markLazyImages).
1744
+ const bodyHtml = markLazyImages(result.html, eagerImages)
1745
+
1536
1746
  // Build path: render INTO the app's own built document (shell.js) — the
1537
1747
  // standalone document below carries no app code unless `isr` adds a
1538
1748
  // client bundle, so writing it over a bundler's dist/index.html shipped
1539
1749
  // pages that never booted.
1540
1750
  if (typeof shell === 'string') {
1541
- const styles = [
1542
- globalCSS.fontFaceCSS ? `<style data-brender>${globalCSS.fontFaceCSS}</style>` : '',
1543
- `<style data-brender>\n${globalCSS.rootRule || ''}\n${globalCSS.resetRules || ''}\n${globalCSS.keyframeRules || ''}\n</style>`,
1544
- emotionCSS ? `<style data-emotion="smbls">\n${emotionCSS}\n</style>` : ''
1545
- ]
1546
1751
  const html = composeIntoShell(shell, {
1547
- headTags: resolvedHeadTags,
1548
- headEnd: [fontLinks, ...styles].filter(Boolean).join('\n'),
1549
- body: result.html,
1752
+ headTags: `${resolvedHeadTags}\n${prerender.meta}`,
1753
+ headEnd: [fontLinks, brenderStyle, emotionStyle].filter(Boolean).join('\n'),
1754
+ body: bodyHtml,
1550
1755
  depth: Number.isInteger(options.depth) ? options.depth : 0,
1551
- bootScripts: adoptDom ? adoptionScripts(result) : ''
1756
+ bootScripts: prerender.blocks,
1757
+ // The page's language on <html>, as the client's polyglot sets it at
1758
+ // boot — a page rendered for /en/… kept the shell's `lang="ka"`.
1759
+ // Only when the project has languages (or the caller names one).
1760
+ lang: lang || (polyglotCfg ? result.activeLang : undefined),
1761
+ preloadScripts
1552
1762
  })
1553
- return { html, route, brKeyCount, shell: true }
1763
+ return { html, route, brKeyCount, shell: true, diagnostics }
1554
1764
  }
1555
1765
 
1766
+ // (The empty line after the font links held a @font-face <style> that
1767
+ // splitCapturedCSS never fills — @font-face stays in the class stream.)
1556
1768
  const html = `<!DOCTYPE html>
1557
1769
  <html lang="${htmlLang}">
1558
1770
  <head>
1559
- ${resolvedHeadTags}
1771
+ ${resolvedHeadTags}${isr && isr.clientScript && hydrate ? `\n${prerender.meta}` : ''}
1560
1772
  ${fontLinks}
1561
- ${globalCSS.fontFaceCSS ? `<style data-brender>${globalCSS.fontFaceCSS}</style>` : ''}
1562
- <style data-brender>
1563
- ${globalCSS.rootRule || ''}
1564
- ${globalCSS.resetRules || ''}
1565
- ${globalCSS.keyframeRules || ''}
1566
- </style>
1567
- ${emotionCSS ? `<style data-emotion="smbls">\n${emotionCSS}\n</style>` : ''}
1773
+
1774
+ ${brenderStyle}
1775
+ ${emotionStyle}
1568
1776
  </head>
1569
1777
  <body>
1570
- ${result.html}
1778
+ ${bodyHtml}
1571
1779
  ${isrBody}
1572
1780
  </body>
1573
1781
  </html>`
1574
1782
 
1575
- return { html, route, brKeyCount }
1576
- }
1577
-
1578
- // ── Design system token resolution ──────────────────────────────────────────
1579
-
1580
- const LETTER_TO_INDEX = {
1581
- U: -6, V: -5, W: -4, X: -3, Y: -2, Z: -1,
1582
- A: 0, B: 1, C: 2, D: 3, E: 4, F: 5, G: 6, H: 7, I: 8, J: 9,
1583
- K: 10, L: 11, M: 12, N: 13, O: 14, P: 15
1783
+ return { html, route, brKeyCount, diagnostics }
1584
1784
  }
1585
1785
 
1586
- const SPACING_PROPS = new Set([
1587
- 'padding', 'paddingTop', 'paddingRight', 'paddingBottom', 'paddingLeft',
1588
- 'paddingBlock', 'paddingInline', 'paddingBlockStart', 'paddingBlockEnd',
1589
- 'paddingInlineStart', 'paddingInlineEnd',
1590
- 'margin', 'marginTop', 'marginRight', 'marginBottom', 'marginLeft',
1591
- 'marginBlock', 'marginInline', 'marginBlockStart', 'marginBlockEnd',
1592
- 'marginInlineStart', 'marginInlineEnd',
1593
- 'gap', 'rowGap', 'columnGap',
1594
- 'top', 'right', 'bottom', 'left',
1595
- 'width', 'height', 'minWidth', 'maxWidth', 'minHeight', 'maxHeight',
1596
- 'flexBasis', 'fontSize', 'lineHeight', 'letterSpacing',
1597
- 'borderWidth', 'borderRadius', 'outlineWidth', 'outlineOffset',
1598
- 'inset', 'insetBlock', 'insetInline',
1599
- 'boxSize', 'round'
1600
- ])
1601
-
1602
- /**
1603
- * Resolves a spacing token like 'B2', 'A', 'E3' to a px/em value.
1604
- * Uses base * ratio^index for main steps (A=0, B=1, etc.)
1605
- * and sub-ratio interpolation for sub-steps (B1, B2, B3).
1606
- */
1607
- const resolveSpacingToken = (token, spacingConfig) => {
1608
- if (!token || typeof token !== 'string') return null
1609
- if (!spacingConfig) return null
1610
-
1611
- const base = spacingConfig.base || 16
1612
- const ratio = spacingConfig.ratio || 1.618
1613
- const unit = spacingConfig.unit || 'px'
1614
- const hasSubSequence = spacingConfig.subSequence !== false
1615
-
1616
- // Handle compound values like 'B2 - -' or 'A1 B C1'
1617
- if (token.includes(' ')) {
1618
- const parts = token.split(' ').map(part => {
1619
- if (part === '-' || part === '') return part
1620
- return resolveSpacingToken(part, spacingConfig) || part
1621
- })
1622
- return parts.join(' ')
1623
- }
1624
-
1625
- // Skip CSS keywords and values with units
1626
- if (/^(none|auto|inherit|initial|unset|0)$/i.test(token)) return null
1627
- if (/\d+(px|em|rem|%|vh|vw|vmin|vmax|ch|ex|cm|mm|in|pt|pc|fr|s|ms)$/i.test(token)) return null
1628
- // Skip hex colors, rgb(), etc.
1629
- if (/^(#|rgb|hsl|var\()/i.test(token)) return null
1630
-
1631
- const isNegative = token.startsWith('-')
1632
- const abs = isNegative ? token.slice(1) : token
1633
-
1634
- // Match letter + optional digit: A, B, B2, E3, etc.
1635
- const m = abs.match(/^([A-Z])(\d)?$/i)
1636
- if (!m) return null
1637
-
1638
- const letter = m[1].toUpperCase()
1639
- const subStep = m[2] ? parseInt(m[2]) : 0
1640
- const idx = LETTER_TO_INDEX[letter]
1641
- if (idx === undefined) return null
1642
-
1643
- let value = base * Math.pow(ratio, idx)
1644
-
1645
- if (subStep > 0 && hasSubSequence) {
1646
- const next = base * Math.pow(ratio, idx + 1)
1647
- const diff = next - value
1648
- const subRatio = diff / ratio
1649
- // Sub-steps: 1 = value + (diff - subRatio), 2 = midpoint, 3 = value + subRatio
1650
- const first = next - subRatio
1651
- const second = value + subRatio
1652
- const middle = (first + second) / 2
1653
- const subs = (~~next - ~~value > 16) ? [first, middle, second] : [first, second]
1654
- if (subStep <= subs.length) {
1655
- value = subs[subStep - 1]
1656
- }
1657
- }
1658
-
1659
- const rounded = Math.round(value * 100) / 100
1660
- const sign = isNegative ? '-' : ''
1661
- return `${sign}${rounded}${unit}`
1662
- }
1663
-
1664
- // Kebab-case versions of spacing props for post-shorthand resolution
1665
- const SPACING_PROPS_KEBAB = new Set(
1666
- [...SPACING_PROPS].map(k => k.replace(/[A-Z]/g, m => '-' + m.toLowerCase()))
1667
- )
1668
-
1669
- /**
1670
- * Try to resolve a CSS value through the design system.
1671
- * Returns the resolved value or the original if not a token.
1672
- */
1673
- const resolveDSValue = (key, val, ds) => {
1674
- if (typeof val !== 'string') return val
1675
-
1676
- // Color resolution
1677
- if (CSS_COLOR_PROPS.has(key)) {
1678
- const colorMap = ds?.color || {}
1679
- if (colorMap[val]) return colorMap[val]
1680
- }
1681
-
1682
- // Spacing resolution (check both camelCase and kebab-case keys)
1683
- if (SPACING_PROPS.has(key) || SPACING_PROPS_KEBAB.has(key)) {
1684
- const spacing = ds?.spacing || {}
1685
- const resolved = resolveSpacingToken(val, spacing)
1686
- if (resolved) return resolved
1687
- }
1688
-
1689
- return val
1690
- }
1691
-
1692
- // ── CSS helpers ─────────────────────────────────────────────────────────────
1693
-
1694
- const CSS_COLOR_PROPS = new Set([
1695
- 'color', 'background', 'backgroundColor', 'borderColor',
1696
- 'borderTopColor', 'borderRightColor', 'borderBottomColor', 'borderLeftColor',
1697
- 'outlineColor', 'fill', 'stroke'
1698
- ])
1699
-
1700
- const NON_CSS_PROPS = new Set([
1701
- 'href', 'src', 'alt', 'title', 'id', 'name', 'type', 'value', 'placeholder',
1702
- 'target', 'rel', 'loading', 'srcset', 'sizes', 'media', 'role', 'tabindex',
1703
- 'for', 'action', 'method', 'enctype', 'autocomplete', 'autofocus',
1704
- 'theme', '__element', 'update',
1705
- 'childrenAs', 'childExtends', 'childProps', 'children'
1706
- ])
1707
-
1708
- const camelToKebab = (str) => str.replace(/[A-Z]/g, m => '-' + m.toLowerCase())
1709
-
1710
- const resolveShorthand = (key, val) => {
1711
- if (typeof val === 'undefined' || val === null) return null
1712
-
1713
- // Flex shorthands
1714
- if (key === 'flow' && typeof val === 'string') {
1715
- let [direction, wrap] = (val || 'row').split(' ')
1716
- if (val.startsWith('x') || val === 'row') direction = 'row'
1717
- if (val.startsWith('y') || val === 'column') direction = 'column'
1718
- return { display: 'flex', 'flex-flow': (direction || '') + ' ' + (wrap || '') }
1719
- }
1720
- if (key === 'wrap') {
1721
- return { display: 'flex', 'flex-wrap': val }
1722
- }
1723
- if ((key === 'align' || key === 'flexAlign') && typeof val === 'string') {
1724
- const [alignItems, justifyContent] = val.split(' ')
1725
- const result = { display: 'flex', 'align-items': alignItems }
1726
- if (justifyContent) result['justify-content'] = justifyContent
1727
- return result
1728
- }
1729
- if (key === 'gridAlign' && typeof val === 'string') {
1730
- const [alignItems, justifyContent] = val.split(' ')
1731
- const result = { display: 'grid', 'align-items': alignItems }
1732
- if (justifyContent) result['justify-content'] = justifyContent
1733
- return result
1734
- }
1735
- if (key === 'flexFlow' && typeof val === 'string') {
1736
- let [direction, wrap] = (val || 'row').split(' ')
1737
- if (val.startsWith('x') || val === 'row') direction = 'row'
1738
- if (val.startsWith('y') || val === 'column') direction = 'column'
1739
- return { display: 'flex', 'flex-flow': (direction || '') + ' ' + (wrap || '') }
1740
- }
1741
- if (key === 'flexWrap') {
1742
- return { display: 'flex', 'flex-wrap': val }
1743
- }
1744
-
1745
- // Background image shorthand
1746
- if (key === 'backgroundImage' && typeof val === 'string' && !val.startsWith('url(') && !val.startsWith('linear-gradient') && !val.startsWith('radial-gradient') && !val.startsWith('none')) {
1747
- return { 'background-image': `url(${val})` }
1748
- }
1749
-
1750
- // Box/size shorthands
1751
- if (key === 'round' || (key === 'borderRadius' && val)) {
1752
- return { 'border-radius': typeof val === 'number' ? val + 'px' : val }
1753
- }
1754
- if (key === 'boxSize' && typeof val === 'string') {
1755
- const [height, width] = val.split(' ')
1756
- return { height, width: width || height }
1757
- }
1758
- if (key === 'widthRange' && typeof val === 'string') {
1759
- const [minWidth, maxWidth] = val.split(' ')
1760
- return { 'min-width': minWidth, 'max-width': maxWidth || minWidth }
1761
- }
1762
- if (key === 'heightRange' && typeof val === 'string') {
1763
- const [minHeight, maxHeight] = val.split(' ')
1764
- return { 'min-height': minHeight, 'max-height': maxHeight || minHeight }
1765
- }
1766
-
1767
- // Grid aliases
1768
- if (key === 'column') return { 'grid-column': val }
1769
- if (key === 'columns') return { 'grid-template-columns': val }
1770
- if (key === 'templateColumns') return { 'grid-template-columns': val }
1771
- if (key === 'row') return { 'grid-row': val }
1772
- if (key === 'rows') return { 'grid-template-rows': val }
1773
- if (key === 'templateRows') return { 'grid-template-rows': val }
1774
- if (key === 'area') return { 'grid-area': val }
1775
- if (key === 'template') return { 'grid-template': val }
1776
- if (key === 'templateAreas') return { 'grid-template-areas': val }
1777
- if (key === 'autoColumns') return { 'grid-auto-columns': val }
1778
- if (key === 'autoRows') return { 'grid-auto-rows': val }
1779
- if (key === 'autoFlow') return { 'grid-auto-flow': val }
1780
- if (key === 'columnStart') return { 'grid-column-start': val }
1781
- if (key === 'rowStart') return { 'grid-row-start': val }
1782
-
1783
- return null
1784
- }
1785
-
1786
- const resolveInnerProps = (obj, ds) => {
1787
- const result = {}
1788
- for (const k in obj) {
1789
- const v = obj[k]
1790
- const expanded = resolveShorthand(k, v)
1791
- if (expanded) {
1792
- for (const ek in expanded) {
1793
- result[ek] = resolveDSValue(ek, expanded[ek], ds)
1794
- }
1795
- continue
1796
- }
1797
- if (typeof v !== 'string' && typeof v !== 'number') continue
1798
- result[camelToKebab(k)] = resolveDSValue(k, v, ds)
1799
- }
1800
- return result
1801
- }
1802
-
1803
- const buildCSSFromProps = (props, ds, mediaMap) => {
1804
- const base = {}
1805
- const mediaRules = {}
1806
- const pseudoRules = {}
1807
-
1808
- for (const key in props) {
1809
- const val = props[key]
1810
-
1811
- if (key.charCodeAt(0) === 64 && typeof val === 'object') {
1812
- const bp = mediaMap?.[key.slice(1)]
1813
- if (bp) {
1814
- const inner = resolveInnerProps(val, ds)
1815
- if (Object.keys(inner).length) mediaRules[bp] = inner
1816
- }
1817
- continue
1818
- }
1819
-
1820
- if (key.charCodeAt(0) === 58 && typeof val === 'object') {
1821
- const inner = resolveInnerProps(val, ds)
1822
- if (Object.keys(inner).length) pseudoRules[key] = inner
1823
- continue
1824
- }
1825
-
1826
- if (typeof val !== 'string' && typeof val !== 'number') continue
1827
- if (key.charCodeAt(0) >= 65 && key.charCodeAt(0) <= 90) continue
1828
- if (NON_CSS_PROPS.has(key)) continue
1829
-
1830
- const expanded = resolveShorthand(key, val)
1831
- if (expanded) {
1832
- for (const ek in expanded) {
1833
- base[ek] = resolveDSValue(ek, expanded[ek], ds)
1834
- }
1835
- continue
1836
- }
1837
-
1838
- base[camelToKebab(key)] = resolveDSValue(key, val, ds)
1839
- }
1840
-
1841
- return { base, mediaRules, pseudoRules }
1842
- }
1843
-
1844
- const renderCSSRule = (selector, { base, mediaRules, pseudoRules }) => {
1845
- const lines = []
1846
- const baseDecls = Object.entries(base).map(([k, v]) => `${k}: ${v}`).join('; ')
1847
- if (baseDecls) lines.push(`${selector} { ${baseDecls}; }`)
1848
-
1849
- for (const [pseudo, p] of Object.entries(pseudoRules)) {
1850
- const decls = Object.entries(p).map(([k, v]) => `${k}: ${v}`).join('; ')
1851
- if (decls) lines.push(`${selector}${pseudo} { ${decls}; }`)
1852
- }
1853
-
1854
- for (const [query, p] of Object.entries(mediaRules)) {
1855
- const decls = Object.entries(p).map(([k, v]) => `${k}: ${v}`).join('; ')
1856
- const mq = query.startsWith('@') ? query : `@media ${query}`
1857
- if (decls) lines.push(`${mq} { ${selector} { ${decls}; } }`)
1858
- }
1859
-
1860
- return lines.join('\n')
1861
- }
1862
-
1863
- // Map of component names to their implicit CSS from extends
1864
- const EXTENDS_CSS = {
1865
- Flex: { display: 'flex' },
1866
- InlineFlex: { display: 'inline-flex' },
1867
- Grid: { display: 'grid' },
1868
- InlineGrid: { display: 'inline-grid' },
1869
- Block: { display: 'block' },
1870
- Inline: { display: 'inline' }
1871
- }
1872
-
1873
- const getExtendsCSS = (el) => {
1874
- const exts = el.__ref?.__extends
1875
- if (!exts || !Array.isArray(exts)) return null
1876
- for (const ext of exts) {
1877
- if (EXTENDS_CSS[ext]) return EXTENDS_CSS[ext]
1878
- }
1879
- return null
1880
- }
1881
-
1882
- /**
1883
- * Resolve function-valued CSS props by evaluating them with (element, state).
1884
- * In SSR, this gives correct initial values (e.g. display: 'none' when no auth).
1885
- * Uses fallback mock state if element state is incomplete.
1886
- */
1887
- const resolveElementProps = (el) => {
1888
- let resolved
1889
- for (const key in el) {
1890
- if (typeof el[key] !== 'function') continue
1891
- // Skip non-CSS props and component children
1892
- if (NON_CSS_PROPS.has(key)) continue
1893
- if (key.charCodeAt(0) >= 65 && key.charCodeAt(0) <= 90) continue
1894
- if (key.startsWith('on')) continue
1895
- if (key.startsWith('__')) continue
1896
- if (!resolved) resolved = {}
1897
- let result
1898
- try {
1899
- result = el[key](el, el.state || {})
1900
- } catch {
1901
- // State prototype chain may be incomplete in SSR — try with mock
1902
- try {
1903
- const mockState = { root: {}, ...(el.state || {}) }
1904
- result = el[key](el, mockState)
1905
- } catch { /* skip prop */ }
1906
- }
1907
- if (result !== undefined && result !== null && result !== false) {
1908
- resolved[key] = result
1909
- }
1910
- }
1911
- return resolved || el
1912
- }
1913
-
1914
- const extractCSS = (element, ds) => {
1915
- const mediaMap = ds?.media || {}
1916
- const animations = ds?.animation || {}
1917
- const rules = []
1918
- const seen = new Set()
1919
- const usedAnimations = new Set()
1920
-
1921
- const walk = (el) => {
1922
- if (!el || !el.__ref) return
1923
- const props = resolveElementProps(el)
1924
- if (props && el.node) {
1925
- const cls = el.node.getAttribute?.('class')
1926
- if (cls && !seen.has(cls)) {
1927
- seen.add(cls)
1928
- const cssResult = buildCSSFromProps(props, ds, mediaMap)
1929
-
1930
- // Inject CSS from extends chain (e.g. extends: 'Flex' → display: flex)
1931
- const extsCss = getExtendsCSS(el)
1932
- if (extsCss) {
1933
- for (const [k, v] of Object.entries(extsCss)) {
1934
- const kebab = camelToKebab(k)
1935
- if (!cssResult.base[kebab]) cssResult.base[kebab] = v
1936
- }
1937
- }
1938
-
1939
- const has = Object.keys(cssResult.base).length || Object.keys(cssResult.mediaRules).length || Object.keys(cssResult.pseudoRules).length
1940
- if (has) rules.push(renderCSSRule('.' + cls.split(' ')[0], cssResult))
1941
-
1942
- const anim = props.animation || props.animationName
1943
- if (typeof anim === 'string') {
1944
- const name = anim.split(' ')[0]
1945
- if (animations[name]) usedAnimations.add(name)
1946
- }
1947
- }
1948
- }
1949
- if (el.__ref?.__children) {
1950
- for (const ck of el.__ref.__children) {
1951
- if (el[ck]?.__ref) walk(el[ck])
1952
- }
1953
- }
1954
- }
1955
- walk(element)
1956
-
1957
- const keyframes = []
1958
- for (const name of usedAnimations) {
1959
- const frames = animations[name]
1960
- const frameRules = Object.entries(frames).map(([step, p]) => {
1961
- const decls = Object.entries(p).map(([k, v]) => `${camelToKebab(k)}: ${v}`).join('; ')
1962
- return ` ${step} { ${decls}; }`
1963
- }).join('\n')
1964
- keyframes.push(`@keyframes ${name} {\n${frameRules}\n}`)
1965
- }
1966
-
1967
- return [...keyframes, ...rules].join('\n')
1786
+ // ── Images below the fold ───────────────────────────────────────────────────
1787
+ // A pre-rendered page's images are in the markup before the app's script,
1788
+ // and on a slow connection they took its bandwidth: measured on a throttled
1789
+ // phone, the app's 214 KB script finished at 2.65 s instead of 1.75 s while
1790
+ // 165–191 KB of images loaded (xma.info report, brender build item 14). The
1791
+ // server cannot see the fold, so the first `eager` images in document order
1792
+ // load as written and every later one that does not say how to load gets
1793
+ // `loading="lazy"` — a lazy image inside the viewport still loads right after
1794
+ // layout. An image that names its own `loading` is left alone. `eager:
1795
+ // false` turns this off.
1796
+ export const EAGER_IMAGES_DEFAULT = 2
1797
+ const IMG_TAG = /<img\b(?:[^>"']|"[^"]*"|'[^']*')*>/gi
1798
+ const HAS_LOADING = /\sloading\s*=/i
1799
+
1800
+ export const markLazyImages = (html, eager = EAGER_IMAGES_DEFAULT) => {
1801
+ if (eager === false || eager == null || typeof html !== 'string') return html
1802
+ const keep = Math.max(0, Number(eager) || 0)
1803
+ let seen = 0
1804
+ return html.replace(IMG_TAG, (tag) => {
1805
+ if (seen++ < keep || HAS_LOADING.test(tag)) return tag
1806
+ return tag.replace(/^<img\b/i, '<img loading="lazy"')
1807
+ })
1968
1808
  }
1969
1809
 
1970
1810
  const generateFontLinks = (ds) => {
@@ -1993,3 +1833,38 @@ const generateFontLinks = (ds) => {
1993
1833
  `<link href="https://fonts.googleapis.com/css2?${params}&display=swap" rel="stylesheet">`
1994
1834
  ].join('\n')
1995
1835
  }
1836
+
1837
+ // ── The page's boot data (CSP-safe) ─────────────────────────────────────────
1838
+ // A pre-rendered page tells the client what it is without one inline script:
1839
+ // XMA serves `script-src 'self'` (no 'unsafe-inline'), which blocked the
1840
+ // `<script>window.__BRENDER__=true</script>` flag — the client then created
1841
+ // the app from scratch and appended it after the server markup, two app
1842
+ // roots (XMA report, brender item 1). A policy blocks inline scripts, never
1843
+ // <meta> tags or data blocks. The format smbls reads (smbls prerender.js):
1844
+ // meta `<meta name="brender" content="hydrate|replace">` for <head>
1845
+ // blocks after the markup — the element-path registry under `adoptDom`
1846
+ // (`<script type="application/json" data-brender="registry">`),
1847
+ // and the recorded anonymous answers (@symbo.ls/fetch
1848
+ // createEarlySeedBlock), which the client adopts instead of asking
1849
+ // again. Placed after the markup, so they never delay its paint;
1850
+ // the app's scripts run after them either way.
1851
+ // `adoptionScripts` above stays for older callers (the platform's renderer).
1852
+ export const prerenderParts = ({ brRegistry, fetchSeed, adoptDom = false } = {}) => {
1853
+ const registry = adoptDom && brRegistry && Object.keys(brRegistry).length
1854
+ ? brenderRegistryBlock(brRegistry)
1855
+ : ''
1856
+ const seed = createEarlySeedBlock(fetchSeed)
1857
+ return {
1858
+ meta: `<meta name="brender" content="${registry ? 'hydrate' : 'replace'}">`,
1859
+ blocks: [registry, seed].filter(Boolean).join('\n')
1860
+ }
1861
+ }
1862
+
1863
+ // ` nonce="…"` for an inline script, or '' — a value that could break out of
1864
+ // the attribute is refused.
1865
+ const nonceAttribute = (nonce) => {
1866
+ if (nonce == null || nonce === '') return ''
1867
+ const value = String(nonce)
1868
+ if (!/^[A-Za-z0-9+/=_-]+$/.test(value)) throw new Error('brender: a nonce is base64 or base64url text')
1869
+ return ` nonce="${value}"`
1870
+ }