@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/render.js CHANGED
@@ -4,15 +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'
14
15
  import { headerEntries } from '@symbo.ls/fetch/rest'
15
- import { jsonForScript } from '@symbo.ls/utils'
16
+ import { brenderRegistryBlock, escapeRegExp, jsonForScript, polyglotFallbackLang } from '@symbo.ls/utils'
16
17
 
17
18
  // funcql plugin — enables evaluation of funcql schemas as property values
18
19
  // during SSR. Async-imported to avoid polluting non-funcql codepaths.
@@ -28,6 +29,7 @@ const getFuncqlPlugin = async () => {
28
29
  }
29
30
  }
30
31
  import { parseHTML } from 'linkedom'
32
+ import { createEarlySeedBlock } from '@symbo.ls/fetch'
31
33
  import { css, injectGlobal, reset as resetCss } from '@symbo.ls/css'
32
34
 
33
35
  // Lightweight SSR polyglot functions — resolve translations from context
@@ -45,17 +47,17 @@ const ssrResolve = (map, key) => {
45
47
  return v
46
48
  }
47
49
 
48
- const ssrTranslate = function (key, lang) {
49
- if (!key) return ''
50
- const ctx = this?.context
51
- const poly = ctx?.polyglot
52
- // Default lang fallback must match the polyglot plugin's contract
53
- // (`'en'`) — when a project doesn't set polyglot.defaultLang, SSR was
54
- // resolving against KA translations while client hydrate resolved
55
- // against EN, producing different text in the SSR shell vs the
56
- // hydrated DOM. See plugins/polyglot/index.js:41.
57
- const activeLang = lang || poly?.defaultLang || 'en'
58
-
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) => {
59
61
  // Static translations from context.polyglot.translations
60
62
  if (poly?.translations) {
61
63
  const langMap = poly.translations[activeLang]
@@ -66,7 +68,6 @@ const ssrTranslate = function (key, lang) {
66
68
  }
67
69
 
68
70
  // Server-loaded translations in root state
69
- const root = this?.state?.root || ctx?.state?.root
70
71
  if (root?.translations) {
71
72
  const langMap = root.translations[activeLang]
72
73
  if (langMap) {
@@ -76,7 +77,7 @@ const ssrTranslate = function (key, lang) {
76
77
  }
77
78
 
78
79
  // Fallback to default lang
79
- const defaultLang = poly?.defaultLang || 'en'
80
+ const defaultLang = polyglotFallbackLang(undefined, poly)
80
81
  if (defaultLang !== activeLang && poly?.translations) {
81
82
  const fallback = poly.translations[defaultLang]
82
83
  if (fallback) {
@@ -84,15 +85,30 @@ const ssrTranslate = function (key, lang) {
84
85
  if (val !== undefined) return val
85
86
  }
86
87
  }
88
+ return undefined
89
+ }
87
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)
88
107
  return key
89
108
  }
90
109
 
91
110
  const ssrGetActiveLang = function () {
92
- const ctx = this?.context
93
- // 'en' matches plugins/polyglot/index.js#getActiveLang — diverging here
94
- // produces SSR/hydrate text mismatches.
95
- return this?.state?.root?.lang || ctx?.polyglot?.defaultLang || 'en'
111
+ return activeLangOf(this?.state?.root, this?.context?.polyglot)
96
112
  }
97
113
 
98
114
  // Deep clone that preserves functions and avoids circular refs
@@ -326,9 +342,44 @@ const bundleCreateDomql = async () => {
326
342
 
327
343
  const tryResolve = resolvePackageEntry
328
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
+
329
366
  const workspacePlugin = {
330
367
  name: 'workspace-resolve',
331
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
+ }
332
383
  // Resolve smbls bare import
333
384
  build.onResolve({ filter: /^smbls/ }, (args) => {
334
385
  const subpath = args.path.replace(/^smbls\/?/, '')
@@ -634,17 +685,24 @@ const UIKIT_STUBS = {
634
685
  */
635
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
+ }
695
+
696
+ // Every element of a rendered tree, each before its children, in the order of
697
+ // `__ref.__children`.
698
+ const walkElements = (root, visit) => {
637
699
  const walk = (el) => {
638
700
  const ref = el && el.__ref
639
701
  if (!ref) return
640
- if (ref.__brKey) {
641
- const path = elementPath(el)
642
- registry[path === '' ? '__root' : path] = ref.__brKey
643
- }
702
+ visit(el, ref)
644
703
  if (ref.__children) for (const childKey of ref.__children) walk(el[childKey])
645
704
  }
646
- walk(element)
647
- return registry
705
+ walk(root)
648
706
  }
649
707
 
650
708
  /**
@@ -697,13 +755,64 @@ const inFlightRequests = () => _inFlight
697
755
  const SEED_ENTRY_MAX = 256 * 1024
698
756
  const SEED_TOTAL_MAX = 1024 * 1024
699
757
  const SEED_HEADERS_OK = new Set(['accept-language', 'accept'])
700
- let _activeRenders = 0
701
- 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)
702
811
 
703
812
  const requestShape = (args) => {
704
813
  const [input, init] = args
705
814
  const isRequest = input && typeof input === 'object' && typeof input.url === 'string'
706
- const url = isRequest ? input.url : String(input)
815
+ const url = requestUrl(input)
707
816
  const method = String((init && init.method) || (isRequest && input.method) || 'GET').toUpperCase()
708
817
  const headers = {}
709
818
  // Any fetch HeadersInit, read like the REST adapter reads one (headerEntries).
@@ -743,7 +852,8 @@ const trackFetches = () => {
743
852
  const done = () => { if (!settled) { settled = true; _inFlight-- } }
744
853
  // a pending body read counts as in flight: the flush waits for it too
745
854
  const track = (promise) => { _inFlight++; promise.then(() => { _inFlight-- }, () => { _inFlight-- }) }
746
- const seed = _seed && !_seed.tainted ? _seed : null
855
+ const seed = liveRecord(SEED)
856
+ const diag = liveRecord(DIAG)
747
857
  let shape = null
748
858
  if (seed) {
749
859
  try {
@@ -752,18 +862,24 @@ const trackFetches = () => {
752
862
  Object.keys(shape.headers).some((h) => !SEED_HEADERS_OK.has(h))) shape = null
753
863
  } catch { shape = null }
754
864
  }
865
+ const urlOf = () => requestUrl(args[0])
755
866
  let result
756
867
  try {
757
868
  result = inner.apply(this, args)
758
869
  } catch (err) {
870
+ recordFailure(diag, urlOf(), (err && err.message) || 'failed')
759
871
  done()
760
872
  throw err
761
873
  }
762
874
  if (result && typeof result.then === 'function') {
763
875
  result.then((response) => {
764
876
  if (shape) recordAnswer(seed, shape, response, track)
877
+ if (diag && response && response.ok === false) recordFailure(diag, urlOf(), 'HTTP ' + response.status)
878
+ done()
879
+ }, (err) => {
880
+ recordFailure(diag, urlOf(), (err && err.message) || 'network error')
765
881
  done()
766
- }, done)
882
+ })
767
883
  } else done()
768
884
  return result
769
885
  }
@@ -795,55 +911,35 @@ export const render = async (data, options = {}) => {
795
911
  // pathname is the actual URL path (e.g. /podcast/abc-123), route is the page pattern (e.g. /podcast/:id)
796
912
  const locationPath = pathname || route
797
913
 
798
- // ── SSR data prefetching ──
799
- // When prefetch is enabled, walk the page definition to find fetch
800
- // declarations, execute them against the DB adapter, and inject
801
- // the results into element state before rendering.
802
- // Set up globalThis.location early so fetch params that reference
803
- // window.location.pathname (e.g. to extract :id from URL) work during prefetch.
804
- const _prevLocPrefetch = globalThis.location
805
- const _prevWinPrefetch = globalThis.window
806
- if (!globalThis.location || globalThis.location.pathname !== locationPath) {
807
- globalThis.location = { pathname: locationPath, href: locationPath, search: '', hash: '', origin: '' }
808
- }
809
- if (!globalThis.window) {
810
- globalThis.window = { location: globalThis.location }
811
- }
812
- let prefetchedPages
813
- if (prefetch) {
814
- try {
815
- const pages = data.pages || {}
816
- prefetchedPages = { ...pages }
817
- const stateUpdates = await prefetchPageData(data, route)
818
- if (stateUpdates.size) {
819
- // Deep clone the page def to avoid mutating the original
820
- const pageDef = JSON.parse(JSON.stringify(pages[route], (key, value) => {
821
- if (typeof value === 'function') return undefined
822
- return value
823
- }))
824
- // Re-attach functions from original
825
- const copyFunctions = (src, dst) => {
826
- if (!src || !dst) return
827
- for (const k in src) {
828
- if (typeof src[k] === 'function') {
829
- dst[k] = src[k]
830
- } else if (typeof src[k] === 'object' && src[k] !== null && !Array.isArray(src[k]) && typeof dst[k] === 'object' && dst[k] !== null) {
831
- copyFunctions(src[k], dst[k])
832
- }
833
- }
834
- }
835
- copyFunctions(pages[route], pageDef)
836
- injectPrefetchedState(pageDef, stateUpdates)
837
- prefetchedPages[route] = pageDef
838
- }
839
- } catch (prefetchErr) {
840
- console.error('[brender] Prefetch error:', prefetchErr)
841
- prefetchedPages = data.pages
842
- }
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()
843
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).
844
935
 
845
936
  // ── SSR polyglot translations ──
846
- // 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).
847
943
  let ssrTranslations
848
944
  if (prefetch) {
849
945
  try {
@@ -853,24 +949,27 @@ export const render = async (data, options = {}) => {
853
949
  }
854
950
  }
855
951
 
856
- // Restore location/window before createEnv sets them properly
857
- if (_prevLocPrefetch !== undefined) globalThis.location = _prevLocPrefetch
858
- else delete globalThis.location
859
- if (_prevWinPrefetch !== undefined) globalThis.window = _prevWinPrefetch
860
- else delete globalThis.window
861
-
862
952
  const { window, document } = createEnv()
863
953
  const body = document.body
864
954
 
865
955
  // Set route on location so the router picks it up
866
956
  window.location.pathname = locationPath
867
957
 
868
- // Set globalThis.document/location so the bundled smbls code
869
- // (which uses `window = globalThis`) can access them during SSR.
870
- const _prevDoc = globalThis.document
871
- const _prevLoc = globalThis.location
872
- globalThis.document = document
873
- 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 {
874
973
 
875
974
  // Import createDomqlElement via bundled smbls source.
876
975
  // The smbls monorepo uses extensionless/directory imports that Node.js ESM
@@ -903,7 +1002,7 @@ export const render = async (data, options = {}) => {
903
1002
  if (ssrTranslations || polyglotConfig) {
904
1003
  if (!baseState.root) baseState.root = {}
905
1004
  if (polyglotConfig) {
906
- baseState.root.lang = baseState.root.lang || polyglotConfig.defaultLang || 'en'
1005
+ baseState.root.lang = polyglotFallbackLang(baseState.root.lang, polyglotConfig)
907
1006
  }
908
1007
  if (ssrTranslations) {
909
1008
  baseState.root.translations = {
@@ -939,7 +1038,7 @@ export const render = async (data, options = {}) => {
939
1038
  dependencies: structuredCloneDeep(data.dependencies || {}),
940
1039
  components: structuredCloneDeep(data.components || {}),
941
1040
  snippets: structuredCloneDeep(data.snippets || {}),
942
- pages: structuredCloneDeep(prefetchedPages || data.pages || {}),
1041
+ pages: structuredCloneDeep(data.pages || {}),
943
1042
  functions: {
944
1043
  ...(data.functions || {}),
945
1044
  // SSR polyglot functions — enable {{ key | polyglot }} resolution during render
@@ -962,6 +1061,11 @@ export const render = async (data, options = {}) => {
962
1061
  // Disable sourcemap tracking in SSR — it causes stack overflows
963
1062
  // when state contains large data arrays (articles, events, etc.)
964
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,
965
1069
  // Editor/live-sync, inspector and toast plugins have no place in server
966
1070
  // markup. The source-bundle path used to stub @symbo.ls/sync to no-ops at
967
1071
  // bundle time; the installed `smbls/ssr` entry carries the real plugins,
@@ -971,6 +1075,19 @@ export const render = async (data, options = {}) => {
971
1075
  sync: false,
972
1076
  inspect: false,
973
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,
974
1091
  // Caller overrides
975
1092
  ...(contextOverrides || {})
976
1093
  }
@@ -986,21 +1103,19 @@ export const render = async (data, options = {}) => {
986
1103
 
987
1104
  resetKeys()
988
1105
 
989
- trackFetches()
990
- // 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
991
1107
  // another render overlaps it
992
- _activeRenders++
993
- if (_activeRenders === 1) _seed = { entries: [], bytes: 0, tainted: false }
994
- else if (_seed) _seed.tainted = true
995
- const seed = _seed
1108
+ const recording = openRenderRecord(SEED, () => ({ entries: [], bytes: 0, tainted: false }))
1109
+ const seed = recording.record
996
1110
  let element
997
1111
  try {
998
1112
  element = await createDomqlElement(app, ctx)
999
1113
 
1000
1114
  // Allow async operations (fetch callbacks, state updates, re-renders) to flush.
1001
- // DOMQL's fetch plugin fires on element creation and updates state asynchronously.
1002
- // With prefetch enabled, data is pre-injected but DOMQL's fetch may also fire
1003
- // 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.
1004
1119
  //
1005
1120
  // It used to sleep a flat 2000 ms with prefetch — measured in workerd on
1006
1121
  // six published projects, `flush` was 2000 ms on every route, 40–90 % of
@@ -1019,8 +1134,7 @@ export const render = async (data, options = {}) => {
1019
1134
  if (inFlightRequests() === 0) await sleep(Math.max(0, Math.min(50, flushCap - (Date.now() - flushStart))))
1020
1135
  }
1021
1136
  } finally {
1022
- _activeRenders--
1023
- if (_seed === seed) _seed = null
1137
+ recording.close()
1024
1138
  }
1025
1139
  const fetchSeed = seed && !seed.tainted ? seed.entries.slice() : []
1026
1140
 
@@ -1062,24 +1176,106 @@ export const render = async (data, options = {}) => {
1062
1176
 
1063
1177
  let html = fixSvgContent(body.innerHTML)
1064
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
+
1065
1186
  // Post-process: resolve any remaining {{ key | polyglot }} templates
1066
- // that weren't resolved during DOMQL rendering (e.g. due to timing)
1067
- if (ssrTranslations) {
1068
- const defaultLang = polyglotConfig?.defaultLang || 'en'
1069
- const langMap = ssrTranslations[defaultLang] || Object.values(ssrTranslations)[0] || {}
1070
- html = html.replace(/\{\{\s*([^|{}]+?)\s*\|\s*polyglot\s*\}\}/g, (match, key) => {
1071
- const trimmed = key.trim()
1072
- return langMap[trimmed] ?? match
1073
- })
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()
1203
+ }
1204
+ }
1205
+
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
+ }
1074
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] || {}
1075
1249
 
1076
- // Restore globalThis after render
1077
- if (_prevDoc !== undefined) globalThis.document = _prevDoc
1078
- else delete globalThis.document
1079
- if (_prevLoc !== undefined) globalThis.location = _prevLoc
1080
- else delete globalThis.location
1250
+ const resolveLabelTemplates = (html, langMap) =>
1251
+ html.replace(LABEL_TEMPLATE, (match, key) => {
1252
+ const val = ssrResolve(langMap, key.trim())
1253
+ return val ?? match
1254
+ })
1081
1255
 
1082
- 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]
1083
1279
  }
1084
1280
 
1085
1281
  /**
@@ -1284,8 +1480,9 @@ export const replaceEmotionCSS = (html, newCSS) => {
1284
1480
  * @returns {Promise<{ html: string, css: string, resetCss: string, fontLinks: string, metadata: object, brKeyCount: number }>}
1285
1481
  */
1286
1482
  export const renderRoute = async (data, options = {}) => {
1287
- // `prefetch: false` renders without the DB prefetch and translation fetch
1288
- // — 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.
1289
1486
  const { route = '/', pathname, prefetch = true } = options
1290
1487
 
1291
1488
  // Use the full render pipeline which handles polyglot, prefetch, emotion, etc.
@@ -1296,39 +1493,16 @@ export const renderRoute = async (data, options = {}) => {
1296
1493
  const ds = data.designSystem || {}
1297
1494
  const globalCSS = result.globalCSS
1298
1495
 
1299
- // 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.
1300
1500
  let prefetchedState = null
1301
- let activeLang = null
1302
1501
  try {
1303
- const el = result.element
1304
- const polyglot = el?.context?.polyglot || data.polyglot || data.config?.polyglot
1305
- activeLang = el?.state?.root?.lang || polyglot?.defaultLang || 'en'
1306
-
1307
- // Get prefetched data from the injected page definitions (pre-DOMQL proxy)
1308
- if (result.prefetchedPages && result.prefetchedPages[route]) {
1309
- const pageDef = result.prefetchedPages[route]
1310
- // Collect all state entries from the page definition tree
1311
- const collectStates = (def, result = {}) => {
1312
- if (!def || typeof def !== 'object') return result
1313
- if (def.state && typeof def.state === 'object') {
1314
- for (const [k, v] of Object.entries(def.state)) {
1315
- if (v !== undefined && v !== null && typeof v !== 'function') {
1316
- result[k] = v
1317
- }
1318
- }
1319
- }
1320
- // Recurse into all child definitions (not just those with state)
1321
- for (const [key, child] of Object.entries(def)) {
1322
- if (key === 'state' || key === 'props' || key === 'attr' || key === 'on' || key === 'define' || key === '__ref' || key.startsWith('__')) continue
1323
- if (child && typeof child === 'object' && !Array.isArray(child)) {
1324
- collectStates(child, result)
1325
- }
1326
- }
1327
- return result
1328
- }
1329
- prefetchedState = collectStates(pageDef)
1330
- }
1502
+ const fetched = collectFetchedState(result.element)
1503
+ if (Object.keys(fetched).length) prefetchedState = fetched
1331
1504
  } catch (e) { /* ignore */ }
1505
+ const activeLang = result.activeLang
1332
1506
 
1333
1507
  return {
1334
1508
  html: result.html,
@@ -1342,14 +1516,39 @@ export const renderRoute = async (data, options = {}) => {
1342
1516
  metadata: result.metadata || extractMetadata(data, route),
1343
1517
  brKeyCount: result.registry ? Object.keys(result.registry).length : 0,
1344
1518
  brRegistry: result.brRegistry || {},
1345
- // anonymous GET answers this render got (see `_seed`) — for adoptionScripts
1519
+ // anonymous GET answers this render got (see `SEED`) — for adoptionScripts
1346
1520
  fetchSeed: result.fetchSeed || [],
1347
1521
  ssrTranslations: result.ssrTranslations,
1348
1522
  prefetchedState,
1349
- 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
1350
1527
  }
1351
1528
  }
1352
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
+
1353
1552
  // ── Full page SSR ─────────────────────────────────────────────────────────────
1354
1553
 
1355
1554
  /**
@@ -1366,12 +1565,20 @@ export const renderRoute = async (data, options = {}) => {
1366
1565
  * @param {string} [options.themeColor] - theme-color meta
1367
1566
  * @param {object} [options.isr] - ISR options with clientScript path
1368
1567
  * @param {boolean} [options.hydrate=true] - With `isr`: boot the client over the
1369
- * pre-rendered markup (`__BRENDER__`; the client replaces it with a fresh
1370
- * render) instead of the legacy swap script
1371
- * @param {boolean} [options.adoptDom=false] - Opt-in hydration: also ship the
1372
- * element-path registry, `__BRENDER_HYDRATE__` and the recorded anonymous
1373
- * fetch answers, so the client ADOPTS the server DOM (smbls hydrateRender)
1374
- * @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.
1375
1582
  * @param {string} [options.shell] - A built app document (e.g. dist/index.html)
1376
1583
  * to render INTO instead of emitting a standalone document (see shell.js).
1377
1584
  * The result then carries `shell: true`.
@@ -1381,13 +1588,18 @@ export const renderRoute = async (data, options = {}) => {
1381
1588
  * @returns {Promise<{ html: string, route: string, brKeyCount: number, shell?: true }>}
1382
1589
  */
1383
1590
  export const renderPage = async (data, route = '/', options = {}) => {
1384
- 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)
1385
1593
 
1386
1594
  // Detect lang from project config, app metadata, or default
1387
1595
  const htmlLang = lang || data.state?.lang || data.app?.metadata?.lang || 'en'
1388
1596
 
1389
- // Use the full smbls pipeline for rendering
1390
- 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 })
1391
1603
  if (!result) return null
1392
1604
 
1393
1605
  const metadata = { ...result.metadata }
@@ -1431,9 +1643,11 @@ export const renderPage = async (data, route = '/', options = {}) => {
1431
1643
 
1432
1644
  const brKeyCount = Object.keys(result.registry).length
1433
1645
 
1434
- // The element-path registry — with the opt-in hydration flag and the
1435
- // recorded fetch answers under `adoptDom` (adoptionScripts).
1436
- const bootScripts = adoptionScripts({ brRegistry: result.brRegistry, fetchSeed: result.fetchSeed, hydrateFlag: adoptDom })
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 })
1437
1651
 
1438
1652
  // ISR: include client SPA bundle for hydration + data fetching
1439
1653
  let isrBody = ''
@@ -1447,9 +1661,9 @@ export const renderPage = async (data, route = '/', options = {}) => {
1447
1661
  const prefix = depth > 0 ? '../'.repeat(depth) : './'
1448
1662
 
1449
1663
  if (hydrate) {
1450
- // True hydration: signal the SPA to adopt existing DOM nodes
1451
- // instead of creating new ones. The SPA detects __BRENDER__ flag
1452
- // 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).
1453
1667
  //
1454
1668
  // Seed client-side polyglot with SSR translations so that
1455
1669
  // el.call('polyglot', key) resolves immediately during hydration
@@ -1467,20 +1681,20 @@ export const renderPage = async (data, route = '/', options = {}) => {
1467
1681
  }
1468
1682
  }
1469
1683
  if (storageLangKey) {
1470
- const defaultLang = polyglotCfg?.defaultLang || 'en'
1684
+ const defaultLang = polyglotFallbackLang(undefined, polyglotCfg)
1471
1685
  seedEntries.push(`if(!localStorage.getItem(${JSON.stringify(storageLangKey)}))localStorage.setItem(${JSON.stringify(storageLangKey)},${JSON.stringify(defaultLang)})`)
1472
1686
  }
1473
1687
  if (seedEntries.length) {
1474
- translationSeed = `<script>try{${seedEntries.join(';')}}catch(e){}</script>\n`
1688
+ translationSeed = `<script${nonceAttr}>try{${seedEntries.join(';')}}catch(e){}</script>\n`
1475
1689
  }
1476
1690
  }
1477
- // Embed BR registry for true DOM-adoption hydration
1478
- const brRegistryScript = bootScripts ? bootScripts + '\n' : ''
1479
- isrBody = `${translationSeed}${brRegistryScript}<script>window.__BRENDER__=true</script>
1480
- <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>`
1481
1695
  } else {
1482
1696
  // Legacy swap mode: SPA creates new DOM, MutationObserver removes brender nodes
1483
- isrBody = `<script type="module">
1697
+ isrBody = `<script type="module"${nonceAttr}>
1484
1698
  {
1485
1699
  const brEls = document.querySelectorAll('body > :not(script):not(style)')
1486
1700
  const observer = new MutationObserver((mutations) => {
@@ -1497,42 +1711,56 @@ export const renderPage = async (data, route = '/', options = {}) => {
1497
1711
  observer.observe(document.body, { childList: true })
1498
1712
  }
1499
1713
  </script>
1500
- <script type="module" src="${prefix}${isr.clientScript}"></script>`
1714
+ <script type="module" src="${prefix}${isr.clientScript}"${nonceAttr}></script>`
1501
1715
  }
1502
1716
  }
1503
1717
 
1504
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.
1505
1720
  const headConfig = { ...(data.config || {}) }
1506
1721
  if (data.polyglot && !headConfig.polyglot) headConfig.polyglot = data.polyglot
1507
1722
  const polyglotCfg = headConfig.polyglot
1508
1723
  let resolvedHeadTags = headTags
1509
1724
  if (polyglotCfg) {
1510
- const defaultLang = polyglotCfg.defaultLang || 'en'
1511
1725
  // Use SSR-fetched translations (from render result) merged with static translations
1512
- const translations = {
1513
- ...(polyglotCfg.translations || {}),
1514
- ...(result.ssrTranslations || {})
1726
+ const translations = { ...(polyglotCfg.translations || {}) }
1727
+ for (const lang in result.ssrTranslations || {}) {
1728
+ translations[lang] = { ...(translations[lang] || {}), ...result.ssrTranslations[lang] }
1515
1729
  }
1516
- const langMap = translations[defaultLang] || {}
1517
- resolvedHeadTags = headTags.replace(/\{\{\s*([^|{}]+?)\s*\|\s*polyglot\s*\}\}/g, (match, key) => {
1518
- const trimmed = key.trim()
1519
- return langMap[trimmed] ?? match
1520
- })
1730
+ resolvedHeadTags = resolveLabelTemplates(headTags, labelMapFor(translations, result.activeLang, polyglotCfg))
1521
1731
  }
1522
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
+
1523
1746
  // Build path: render INTO the app's own built document (shell.js) — the
1524
1747
  // standalone document below carries no app code unless `isr` adds a
1525
1748
  // client bundle, so writing it over a bundler's dist/index.html shipped
1526
1749
  // pages that never booted.
1527
1750
  if (typeof shell === 'string') {
1528
1751
  const html = composeIntoShell(shell, {
1529
- headTags: resolvedHeadTags,
1752
+ headTags: `${resolvedHeadTags}\n${prerender.meta}`,
1530
1753
  headEnd: [fontLinks, brenderStyle, emotionStyle].filter(Boolean).join('\n'),
1531
- body: result.html,
1754
+ body: bodyHtml,
1532
1755
  depth: Number.isInteger(options.depth) ? options.depth : 0,
1533
- bootScripts: adoptDom ? bootScripts : ''
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
1534
1762
  })
1535
- return { html, route, brKeyCount, shell: true }
1763
+ return { html, route, brKeyCount, shell: true, diagnostics }
1536
1764
  }
1537
1765
 
1538
1766
  // (The empty line after the font links held a @font-face <style> that
@@ -1540,19 +1768,43 @@ export const renderPage = async (data, route = '/', options = {}) => {
1540
1768
  const html = `<!DOCTYPE html>
1541
1769
  <html lang="${htmlLang}">
1542
1770
  <head>
1543
- ${resolvedHeadTags}
1771
+ ${resolvedHeadTags}${isr && isr.clientScript && hydrate ? `\n${prerender.meta}` : ''}
1544
1772
  ${fontLinks}
1545
1773
 
1546
1774
  ${brenderStyle}
1547
1775
  ${emotionStyle}
1548
1776
  </head>
1549
1777
  <body>
1550
- ${result.html}
1778
+ ${bodyHtml}
1551
1779
  ${isrBody}
1552
1780
  </body>
1553
1781
  </html>`
1554
1782
 
1555
- return { html, route, brKeyCount }
1783
+ return { html, route, brKeyCount, diagnostics }
1784
+ }
1785
+
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
+ })
1556
1808
  }
1557
1809
 
1558
1810
  const generateFontLinks = (ds) => {
@@ -1581,3 +1833,38 @@ const generateFontLinks = (ds) => {
1581
1833
  `<link href="https://fonts.googleapis.com/css2?${params}&display=swap" rel="stylesheet">`
1582
1834
  ].join('\n')
1583
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
+ }