@symbo.ls/brender 3.14.664 → 3.14.666

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
@@ -272,6 +288,13 @@ const resolveDomqlPackage = (ws, pkg, ...subpath) => {
272
288
  //
273
289
  // An installed smbls OLDER than the ssr entry that still carries
274
290
  // src/createDomql.js (pre-54131b34d tarballs) keeps the source-bundle path.
291
+ //
292
+ // Loaded once per process, and the load in progress is shared: it kept the
293
+ // loaded module, set when the load finished, so renders that started together
294
+ // before then loaded one each — two esbuild runs, and two framework instances
295
+ // in one process (two signal and CSS engines, two @symbo.ls/fetch copies with
296
+ // their own query caches); the renders after them got whichever finished
297
+ // last. A failed load is not kept: the next render tries again.
275
298
  let _cachedCreateDomql = null
276
299
 
277
300
  // Exported for the packaging lock in __tests__/ssrEntryPackaging.test.js —
@@ -287,9 +310,17 @@ const importSmblsSsr = async () => {
287
310
  return mod
288
311
  }
289
312
 
290
- const bundleCreateDomql = async () => {
291
- if (_cachedCreateDomql) return _cachedCreateDomql
313
+ const bundleCreateDomql = () => {
314
+ if (!_cachedCreateDomql) {
315
+ _cachedCreateDomql = loadCreateDomql().catch((err) => {
316
+ _cachedCreateDomql = null
317
+ throw err
318
+ })
319
+ }
320
+ return _cachedCreateDomql
321
+ }
292
322
 
323
+ const loadCreateDomql = async () => {
293
324
  const ws = detectWorkspace()
294
325
 
295
326
  // Resolve entry point: the monorepo's source, else `smbls/ssr` (one import
@@ -300,8 +331,7 @@ const bundleCreateDomql = async () => {
300
331
  } else {
301
332
  let ssrError
302
333
  try {
303
- _cachedCreateDomql = await importSmblsSsr()
304
- return _cachedCreateDomql
334
+ return await importSmblsSsr()
305
335
  } catch (err) {
306
336
  ssrError = err
307
337
  }
@@ -326,9 +356,44 @@ const bundleCreateDomql = async () => {
326
356
 
327
357
  const tryResolve = resolvePackageEntry
328
358
 
359
+ // In the monorepo, every workspace specifier the handlers below do not own
360
+ // — a SUBPATH of a sibling (`@symbo.ls/dropdown/components`,
361
+ // `@symbo.ls/sync/applier`, `@symbo.ls/utils/function.js`) and an unscoped
362
+ // sibling (`attrs-in-props`) — resolves to its source through the map the
363
+ // test runner and smbls's own build resolve with
364
+ // (scripts/vite-workspace-source.mjs). They fell through to esbuild, which
365
+ // followed each package's `exports` into its dist/: a render ran whatever
366
+ // build was left on disk (the legacy Dropdown kit of an earlier build, a
367
+ // sync applier without the exports createDomql imports now), and a
368
+ // checkout without dist/ could not render at all. A dist input now fails
369
+ // the bundle.
370
+ const workspaceSource = ws.isMonorepo
371
+ ? await import(pathToFileURL(resolve(ws.monorepoRoot, 'scripts', 'vite-workspace-source.mjs')).href)
372
+ : null
373
+ const workspaceNames = workspaceSource
374
+ ? new Set(workspaceSource.workspacePackageRoots().map((dir) => workspaceSource.workspacePackageAt(dir).name))
375
+ : null
376
+ // Bare specifiers the handlers below resolve themselves (the sync stub
377
+ // among them).
378
+ const OWN_HANDLERS = /^(?:smbls(?:\/.*)?|domql|css-in-props(?:\/.*)?|@symbo\.ls\/[^/]+)$/
379
+
329
380
  const workspacePlugin = {
330
381
  name: 'workspace-resolve',
331
382
  setup (build) {
383
+ if (workspaceSource) {
384
+ build.onResolve({ filter: /^[^./]/ }, (args) => {
385
+ if (OWN_HANDLERS.test(args.path)) return
386
+ const parts = args.path.split('/')
387
+ const name = args.path.startsWith('@') ? parts.slice(0, 2).join('/') : parts[0]
388
+ if (!workspaceNames.has(name)) return
389
+ const source = workspaceSource.resolveWorkspaceSource(args.path, args.importer || entry)
390
+ if (source) return { path: source }
391
+ return { errors: [{ text: `"${args.path}" names no source file in the workspace` }] }
392
+ })
393
+ build.onLoad({ filter: /[\\/]dist[\\/]/ }, (args) => workspaceSource.isWorkspaceDist(args.path)
394
+ ? { errors: [{ text: `${args.path} is a BUILD of a workspace package — the render bundles source` }] }
395
+ : undefined)
396
+ }
332
397
  // Resolve smbls bare import
333
398
  build.onResolve({ filter: /^smbls/ }, (args) => {
334
399
  const subpath = args.path.replace(/^smbls\/?/, '')
@@ -543,7 +608,6 @@ const bundleCreateDomql = async () => {
543
608
  console.log('[brender] Bundle saved:', outFile)
544
609
  }
545
610
 
546
- _cachedCreateDomql = mod
547
611
  return mod
548
612
  }
549
613
 
@@ -634,17 +698,24 @@ const UIKIT_STUBS = {
634
698
  */
635
699
  const buildPathRegistry = (element, elementPath) => {
636
700
  const registry = {}
701
+ walkElements(element, (el, ref) => {
702
+ if (!ref.__brKey) return
703
+ const path = elementPath(el)
704
+ registry[path === '' ? '__root' : path] = ref.__brKey
705
+ })
706
+ return registry
707
+ }
708
+
709
+ // Every element of a rendered tree, each before its children, in the order of
710
+ // `__ref.__children`.
711
+ const walkElements = (root, visit) => {
637
712
  const walk = (el) => {
638
713
  const ref = el && el.__ref
639
714
  if (!ref) return
640
- if (ref.__brKey) {
641
- const path = elementPath(el)
642
- registry[path === '' ? '__root' : path] = ref.__brKey
643
- }
715
+ visit(el, ref)
644
716
  if (ref.__children) for (const childKey of ref.__children) walk(el[childKey])
645
717
  }
646
- walk(element)
647
- return registry
718
+ walk(root)
648
719
  }
649
720
 
650
721
  /**
@@ -697,13 +768,64 @@ const inFlightRequests = () => _inFlight
697
768
  const SEED_ENTRY_MAX = 256 * 1024
698
769
  const SEED_TOTAL_MAX = 1024 * 1024
699
770
  const SEED_HEADERS_OK = new Set(['accept-language', 'accept'])
700
- let _activeRenders = 0
701
- let _seed = null
771
+ const SEED = { renders: 0, record: null }
772
+ // ── What went wrong in a render (its diagnostics) ───────────────────────────
773
+ // Kept like the seed — one record while exactly ONE render runs, tainted by
774
+ // an overlapping one — so a build can tell a page made from data from a page
775
+ // made from nothing: the requests that failed (a non-ok status or a network
776
+ // error) and the label keys that missed (ssrTranslate notes them; the end of
777
+ // the render checks them again, see rawLabelKeys). A build with its database
778
+ // unreachable printed "✓ 22 of 22 routes" and exited 0 while every page
779
+ // carried raw keys (xma.info report, brender build item 9).
780
+ const DIAG_FAILURES_MAX = 50
781
+ const DIAG = { renders: 0, record: null }
782
+
783
+ // One record per window of overlapping renders (the seed's and the
784
+ // diagnostics' windows open at different points of a render, so each has its
785
+ // own slot): the first render of a window opens it, a render that overlaps it
786
+ // taints it — another project's answers or failures must never land in this
787
+ // page — and it goes as soon as one of the renders holding it ends. A render
788
+ // that starts inside a window whose record is gone gets none.
789
+ const openRenderRecord = (slot, make) => {
790
+ slot.renders++
791
+ if (slot.renders === 1) slot.record = make()
792
+ else if (slot.record) slot.record.tainted = true
793
+ const record = slot.record
794
+ const close = () => {
795
+ slot.renders--
796
+ if (slot.record === record) slot.record = null
797
+ }
798
+ return { record, close }
799
+ }
800
+
801
+ // The slot's record while it is the only render's, else null.
802
+ const liveRecord = (slot) => slot.record && !slot.record.tainted ? slot.record : null
803
+
804
+ // A request as a diagnostic names it: origin and path. The query is left out
805
+ // — a public read can carry its key there (`?apikey=…`), and a log line is no
806
+ // place for one.
807
+ const requestLabel = (url) => {
808
+ try {
809
+ const u = new URL(url)
810
+ return u.origin + u.pathname
811
+ } catch {
812
+ return String(url).split('?')[0]
813
+ }
814
+ }
815
+
816
+ const recordFailure = (diag, url, reason) => {
817
+ if (!diag || diag.tainted || diag.failures.length >= DIAG_FAILURES_MAX) return
818
+ diag.failures.push({ url: requestLabel(url), reason })
819
+ }
820
+
821
+ // The URL a fetch() call asks for: a Request's own, else its input as text.
822
+ const requestUrl = (input) =>
823
+ input && typeof input === 'object' && typeof input.url === 'string' ? input.url : String(input)
702
824
 
703
825
  const requestShape = (args) => {
704
826
  const [input, init] = args
705
827
  const isRequest = input && typeof input === 'object' && typeof input.url === 'string'
706
- const url = isRequest ? input.url : String(input)
828
+ const url = requestUrl(input)
707
829
  const method = String((init && init.method) || (isRequest && input.method) || 'GET').toUpperCase()
708
830
  const headers = {}
709
831
  // Any fetch HeadersInit, read like the REST adapter reads one (headerEntries).
@@ -743,7 +865,8 @@ const trackFetches = () => {
743
865
  const done = () => { if (!settled) { settled = true; _inFlight-- } }
744
866
  // a pending body read counts as in flight: the flush waits for it too
745
867
  const track = (promise) => { _inFlight++; promise.then(() => { _inFlight-- }, () => { _inFlight-- }) }
746
- const seed = _seed && !_seed.tainted ? _seed : null
868
+ const seed = liveRecord(SEED)
869
+ const diag = liveRecord(DIAG)
747
870
  let shape = null
748
871
  if (seed) {
749
872
  try {
@@ -752,18 +875,24 @@ const trackFetches = () => {
752
875
  Object.keys(shape.headers).some((h) => !SEED_HEADERS_OK.has(h))) shape = null
753
876
  } catch { shape = null }
754
877
  }
878
+ const urlOf = () => requestUrl(args[0])
755
879
  let result
756
880
  try {
757
881
  result = inner.apply(this, args)
758
882
  } catch (err) {
883
+ recordFailure(diag, urlOf(), (err && err.message) || 'failed')
759
884
  done()
760
885
  throw err
761
886
  }
762
887
  if (result && typeof result.then === 'function') {
763
888
  result.then((response) => {
764
889
  if (shape) recordAnswer(seed, shape, response, track)
890
+ if (diag && response && response.ok === false) recordFailure(diag, urlOf(), 'HTTP ' + response.status)
891
+ done()
892
+ }, (err) => {
893
+ recordFailure(diag, urlOf(), (err && err.message) || 'network error')
765
894
  done()
766
- }, done)
895
+ })
767
896
  } else done()
768
897
  return result
769
898
  }
@@ -795,55 +924,35 @@ export const render = async (data, options = {}) => {
795
924
  // pathname is the actual URL path (e.g. /podcast/abc-123), route is the page pattern (e.g. /podcast/:id)
796
925
  const locationPath = pathname || route
797
926
 
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
- }
927
+ // This render's diagnostics (see `DIAG`), open before its first request.
928
+ trackFetches()
929
+ const diag = openRenderRecord(DIAG, () => ({ failures: [], labelKeys: new Set(), tainted: false }))
930
+ try {
931
+ return await renderWithDiagnostics(data, { route, locationPath, stateOverrides, contextOverrides, prefetch }, diag.record)
932
+ } finally {
933
+ diag.close()
843
934
  }
935
+ }
936
+
937
+ const renderWithDiagnostics = async (data, { route, locationPath, stateOverrides, contextOverrides, prefetch }, diag) => {
938
+ // The page's data is NOT fetched here. The fetch plugin runs during the
939
+ // render below with the real element — its `transform(data, el, state)`,
940
+ // its `children` re-run on the answer — and the post-create flush waits
941
+ // for its requests. A pre-fetch used to run every page `fetch` through a
942
+ // second code path: `transform(data)` without the element (a transform
943
+ // reading `el.call(...)` threw, so the RAW rows landed in state) and
944
+ // `children` functions evaluated once with a stand-in element whose
945
+ // call() hard-coded 'ka' and read the wrong argument, then frozen into
946
+ // static arrays the real answer could no longer replace — cards without
947
+ // titles, a calendar of NaN (xma.info report, brender build items 5, 13).
844
948
 
845
949
  // ── SSR polyglot translations ──
846
- // Fetch translations from the DB so polyglot resolves during render
950
+ // Labels a project serves through a table/RPC (`polyglot.fetch: { rpc }`)
951
+ // are fetched before the render, for every language. A FUNCTION
952
+ // `polyglot.fetch` is the project's own loader: it runs inside the render,
953
+ // called by the polyglot plugin with the real root element (see
954
+ // fetchSSRTranslations), and its labels are read back from the root state
955
+ // afterwards (renderedLabels).
847
956
  let ssrTranslations
848
957
  if (prefetch) {
849
958
  try {
@@ -853,24 +962,27 @@ export const render = async (data, options = {}) => {
853
962
  }
854
963
  }
855
964
 
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
965
  const { window, document } = createEnv()
863
966
  const body = document.body
864
967
 
865
968
  // Set route on location so the router picks it up
866
969
  window.location.pathname = locationPath
867
970
 
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
971
+ // globalThis.document/location for the bundled smbls code (which uses
972
+ // `window = globalThis`) during SSR, and the render's own storage behind
973
+ // the bare `localStorage` the framework reads (smbls prepareState):
974
+ // polyglot persists the language a page switched to and the labels it
975
+ // loaded there, and one shared store handed them to the next page of the
976
+ // build (createEnv). Swapped by descriptor and put back however the render
977
+ // ends, a throw included — a host's own Web Storage global (Node's is a
978
+ // lazy accessor) is never read or lost.
979
+ const restoreGlobals = [
980
+ swapGlobal('document', document),
981
+ swapGlobal('location', window.location),
982
+ swapGlobal('localStorage', window.localStorage),
983
+ swapGlobal('sessionStorage', window.sessionStorage)
984
+ ]
985
+ try {
874
986
 
875
987
  // Import createDomqlElement via bundled smbls source.
876
988
  // The smbls monorepo uses extensionless/directory imports that Node.js ESM
@@ -903,7 +1015,7 @@ export const render = async (data, options = {}) => {
903
1015
  if (ssrTranslations || polyglotConfig) {
904
1016
  if (!baseState.root) baseState.root = {}
905
1017
  if (polyglotConfig) {
906
- baseState.root.lang = baseState.root.lang || polyglotConfig.defaultLang || 'en'
1018
+ baseState.root.lang = polyglotFallbackLang(baseState.root.lang, polyglotConfig)
907
1019
  }
908
1020
  if (ssrTranslations) {
909
1021
  baseState.root.translations = {
@@ -939,7 +1051,7 @@ export const render = async (data, options = {}) => {
939
1051
  dependencies: structuredCloneDeep(data.dependencies || {}),
940
1052
  components: structuredCloneDeep(data.components || {}),
941
1053
  snippets: structuredCloneDeep(data.snippets || {}),
942
- pages: structuredCloneDeep(prefetchedPages || data.pages || {}),
1054
+ pages: structuredCloneDeep(data.pages || {}),
943
1055
  functions: {
944
1056
  ...(data.functions || {}),
945
1057
  // SSR polyglot functions — enable {{ key | polyglot }} resolution during render
@@ -962,6 +1074,11 @@ export const render = async (data, options = {}) => {
962
1074
  // Disable sourcemap tracking in SSR — it causes stack overflows
963
1075
  // when state contains large data arrays (articles, events, etc.)
964
1076
  domqlOptions: { sourcemap: false },
1077
+ // The server builds the page in one go: the browser boot's short tasks
1078
+ // (smbls firstRender.js) serve input and paint, and a server has neither.
1079
+ // smbls skips them by itself in a realm without requestAnimationFrame;
1080
+ // this document shims it (env.js), so it says so here.
1081
+ firstRender: false,
965
1082
  // Editor/live-sync, inspector and toast plugins have no place in server
966
1083
  // markup. The source-bundle path used to stub @symbo.ls/sync to no-ops at
967
1084
  // bundle time; the installed `smbls/ssr` entry carries the real plugins,
@@ -971,6 +1088,19 @@ export const render = async (data, options = {}) => {
971
1088
  sync: false,
972
1089
  inspect: false,
973
1090
  notifications: false,
1091
+ // Every server render processes the design system the way the page's
1092
+ // own single boot does, on a config of its own — the first render of a
1093
+ // process used to share the global config and every later one took the
1094
+ // iframe path, so their theme variables differed (smbls
1095
+ // prepareDesignSystem; xma.info report, brender build item 7).
1096
+ isolateDesignSystem: true,
1097
+ // Every server render starts from an empty query cache (@symbo.ls/fetch
1098
+ // enterContext). The fetch plugin's cache outlives a render, so a page
1099
+ // rendered after another one that read the same key showed those rows
1100
+ // without a request, and its fetch seed — recorded from requests — did
1101
+ // not carry them: the browser asked for them again after boot (xma.info,
1102
+ // 2026-10-16: index.html 10 seed entries, about.html 3).
1103
+ isolateFetchCache: true,
974
1104
  // Caller overrides
975
1105
  ...(contextOverrides || {})
976
1106
  }
@@ -986,21 +1116,19 @@ export const render = async (data, options = {}) => {
986
1116
 
987
1117
  resetKeys()
988
1118
 
989
- trackFetches()
990
- // the recording of this render's answers (see `_seed`): tainted the moment
1119
+ // the recording of this render's answers (see `SEED`): tainted the moment
991
1120
  // 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
1121
+ const recording = openRenderRecord(SEED, () => ({ entries: [], bytes: 0, tainted: false }))
1122
+ const seed = recording.record
996
1123
  let element
997
1124
  try {
998
1125
  element = await createDomqlElement(app, ctx)
999
1126
 
1000
1127
  // 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.
1128
+ // DOMQL's fetch plugin fires on element creation and updates state
1129
+ // asynchronously — it is what loads the page's data (nothing is
1130
+ // pre-injected) — and a function polyglot.fetch loads the labels the
1131
+ // same way. Give enough time for these to complete.
1004
1132
  //
1005
1133
  // It used to sleep a flat 2000 ms with prefetch — measured in workerd on
1006
1134
  // six published projects, `flush` was 2000 ms on every route, 40–90 % of
@@ -1019,8 +1147,7 @@ export const render = async (data, options = {}) => {
1019
1147
  if (inFlightRequests() === 0) await sleep(Math.max(0, Math.min(50, flushCap - (Date.now() - flushStart))))
1020
1148
  }
1021
1149
  } finally {
1022
- _activeRenders--
1023
- if (_seed === seed) _seed = null
1150
+ recording.close()
1024
1151
  }
1025
1152
  const fetchSeed = seed && !seed.tainted ? seed.entries.slice() : []
1026
1153
 
@@ -1062,24 +1189,106 @@ export const render = async (data, options = {}) => {
1062
1189
 
1063
1190
  let html = fixSvgContent(body.innerHTML)
1064
1191
 
1192
+ // The language this page was rendered in and the labels it ended with:
1193
+ // the ones fetched before the render plus whatever the project's own
1194
+ // polyglot.fetch loaded into the root state during it.
1195
+ const root = element?.state?.root
1196
+ const activeLang = activeLangOf(root, polyglotConfig)
1197
+ const labels = renderedLabels(ssrTranslations, root)
1198
+
1065
1199
  // 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
- })
1200
+ // that weren't resolved during DOMQL rendering (e.g. due to timing) — in
1201
+ // the page's language. It used the default language, so a page rendered
1202
+ // for /en/… got its leftover templates in the default language's words
1203
+ // (xma.info report, brender build item 8).
1204
+ if (labels) html = resolveLabelTemplates(html, labelMapFor(labels, activeLang, polyglotConfig))
1205
+
1206
+ const diagnostics = diag && !diag.tainted
1207
+ ? {
1208
+ failedRequests: diag.failures.slice(),
1209
+ rawLabelKeys: rawLabelKeys(diag.labelKeys, html, activeLang, polyglotConfig, root)
1210
+ }
1211
+ : null
1212
+
1213
+ return { html, metadata, registry, brRegistry, element, emotionCSS, globalCSS, classRules, fetchSeed, document, window, ssrTranslations: labels || undefined, activeLang, diagnostics }
1214
+ } finally {
1215
+ for (const restore of restoreGlobals) restore()
1074
1216
  }
1217
+ }
1075
1218
 
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
1219
+ // Put `value` on globalThis[name] for a render and hand back what puts the
1220
+ // old property back, descriptor and all. Never reads the old value — a host
1221
+ // global can be a lazy accessor that warns or throws when read.
1222
+ const swapGlobal = (name, value) => {
1223
+ const previous = Object.getOwnPropertyDescriptor(globalThis, name)
1224
+ if (previous && !previous.configurable) {
1225
+ if (!previous.writable && !previous.set) return () => {}
1226
+ globalThis[name] = value
1227
+ return () => {}
1228
+ }
1229
+ Object.defineProperty(globalThis, name, { value, writable: true, configurable: true, enumerable: true })
1230
+ return () => {
1231
+ if (previous) Object.defineProperty(globalThis, name, previous)
1232
+ else delete globalThis[name]
1233
+ }
1234
+ }
1235
+
1236
+ // ── Labels after a render ───────────────────────────────────────────────────
1237
+
1238
+ const LABEL_TEMPLATE = /\{\{\s*([^|{}]+?)\s*\|\s*polyglot\s*\}\}/g
1239
+
1240
+ const plainOf = (value) => value && typeof value.parse === 'function' ? value.parse() : value
1241
+
1242
+ // The labels a render ended with, by language: the ones fetched before it
1243
+ // (`ssrTranslations`), with the root state's own — loaded during the render
1244
+ // by the project's polyglot.fetch — over them. Null when there are none.
1245
+ const renderedLabels = (ssrTranslations, root) => {
1246
+ const loaded = plainOf(root?.translations)
1247
+ const out = {}
1248
+ for (const source of [ssrTranslations, loaded]) {
1249
+ if (!source || typeof source !== 'object') continue
1250
+ for (const lang in source) {
1251
+ const map = plainOf(source[lang])
1252
+ if (map && typeof map === 'object') out[lang] = { ...(out[lang] || {}), ...map }
1253
+ }
1254
+ }
1255
+ return Object.keys(out).length ? out : null
1256
+ }
1257
+
1258
+ // One language's labels: the page's, else the default language's, else the
1259
+ // first there is — the order the post-render pass always fell back in.
1260
+ const labelMapFor = (labels, activeLang, poly) =>
1261
+ labels[activeLang] || labels[polyglotFallbackLang(undefined, poly)] || Object.values(labels)[0] || {}
1081
1262
 
1082
- return { html, metadata, registry, brRegistry, element, emotionCSS, globalCSS, classRules, fetchSeed, document, window, ssrTranslations, prefetchedPages }
1263
+ const resolveLabelTemplates = (html, langMap) =>
1264
+ html.replace(LABEL_TEMPLATE, (match, key) => {
1265
+ const val = ssrResolve(langMap, key.trim())
1266
+ return val ?? match
1267
+ })
1268
+
1269
+ // JSON data blocks (registry, fetch seed) are data, not what a visitor reads.
1270
+ const DATA_BLOCK = /<script\b[^>]*\btype\s*=\s*["']?application\/json["']?[^>]*>[\s\S]*?<\/script\s*>/gi
1271
+ // The key as a whole token: not part of a longer key or word.
1272
+ const keyInMarkup = (markup, key) =>
1273
+ new RegExp(`(?:^|[^\\w.-])${escapeRegExp(key)}(?![\\w-]|\\.\\w)`).test(markup)
1274
+
1275
+ // The label keys a page shows raw: every key ssrTranslate missed that the
1276
+ // labels the render ended with still do not have AND that reached the
1277
+ // markup, plus every `{{ key | polyglot }}` template left in it. A lookup
1278
+ // whose result never rendered (a discarded value, a branch not taken) is no
1279
+ // raw key on screen — XMA /vlog failed `--strict` on `ui.vlog.desc`, which
1280
+ // appeared 0 times in the page. A miss with whitespace in it is text handed
1281
+ // to `polyglot`, not a key.
1282
+ const rawLabelKeys = (missed, html, activeLang, poly, root) => {
1283
+ const raw = new Set()
1284
+ const markup = html.replace(DATA_BLOCK, '')
1285
+ for (const key of missed) {
1286
+ if (/\s/.test(key)) continue
1287
+ if (findLabel(key, activeLang, poly, root) !== undefined) continue
1288
+ if (keyInMarkup(markup, key)) raw.add(key)
1289
+ }
1290
+ for (const m of markup.matchAll(LABEL_TEMPLATE)) raw.add(m[1].trim())
1291
+ return [...raw]
1083
1292
  }
1084
1293
 
1085
1294
  /**
@@ -1284,8 +1493,9 @@ export const replaceEmotionCSS = (html, newCSS) => {
1284
1493
  * @returns {Promise<{ html: string, css: string, resetCss: string, fontLinks: string, metadata: object, brKeyCount: number }>}
1285
1494
  */
1286
1495
  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.
1496
+ // `prefetch: false` fetches no table/RPC labels first and does not wait for
1497
+ // the page's requests — the publish-time pre-render (smbls publish →
1498
+ // ssrCache) renders data-free.
1289
1499
  const { route = '/', pathname, prefetch = true } = options
1290
1500
 
1291
1501
  // Use the full render pipeline which handles polyglot, prefetch, emotion, etc.
@@ -1296,39 +1506,16 @@ export const renderRoute = async (data, options = {}) => {
1296
1506
  const ds = data.designSystem || {}
1297
1507
  const globalCSS = result.globalCSS
1298
1508
 
1299
- // Extract prefetched state and language for metadata resolution
1509
+ // The page data the render ended with and its language, for a caller's
1510
+ // metadata (mermaid's fallback reads them). The data is what the fetch
1511
+ // plugin put into state during the render (collectFetchedState) — it used
1512
+ // to be the RAW rows a pre-fetch had injected into the page definition.
1300
1513
  let prefetchedState = null
1301
- let activeLang = null
1302
1514
  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
- }
1515
+ const fetched = collectFetchedState(result.element)
1516
+ if (Object.keys(fetched).length) prefetchedState = fetched
1331
1517
  } catch (e) { /* ignore */ }
1518
+ const activeLang = result.activeLang
1332
1519
 
1333
1520
  return {
1334
1521
  html: result.html,
@@ -1342,14 +1529,39 @@ export const renderRoute = async (data, options = {}) => {
1342
1529
  metadata: result.metadata || extractMetadata(data, route),
1343
1530
  brKeyCount: result.registry ? Object.keys(result.registry).length : 0,
1344
1531
  brRegistry: result.brRegistry || {},
1345
- // anonymous GET answers this render got (see `_seed`) — for adoptionScripts
1532
+ // anonymous GET answers this render got (see `SEED`) — for adoptionScripts
1346
1533
  fetchSeed: result.fetchSeed || [],
1347
1534
  ssrTranslations: result.ssrTranslations,
1348
1535
  prefetchedState,
1349
- activeLang
1536
+ activeLang,
1537
+ // failed requests and raw label keys (see `DIAG`); null when an
1538
+ // overlapping render made them unknowable
1539
+ diagnostics: result.diagnostics
1350
1540
  }
1351
1541
  }
1352
1542
 
1543
+ // What the fetch plugin put into state during a render, by the `as` key each
1544
+ // `fetch` config names — a `~/key` one read off the root state. Configs
1545
+ // without `as` (a transform spreading into the element's state) and function
1546
+ // configs are not read: a function would have to run again.
1547
+ const collectFetchedState = (element) => {
1548
+ const out = {}
1549
+ walkElements(element, (el) => {
1550
+ const decl = el.fetch
1551
+ if (!decl || typeof decl !== 'object' || !el.state) return
1552
+ for (const cfg of Array.isArray(decl) ? decl : [decl]) {
1553
+ const as = cfg && typeof cfg === 'object' && typeof cfg.as === 'string' ? cfg.as : null
1554
+ if (!as) continue
1555
+ const fromRoot = as.startsWith('~/')
1556
+ const name = fromRoot ? as.slice(2) : as
1557
+ const source = fromRoot ? el.state.root : el.state
1558
+ const value = plainOf(source && source[name])
1559
+ if (name && value != null && typeof value !== 'function') out[name] = value
1560
+ }
1561
+ })
1562
+ return out
1563
+ }
1564
+
1353
1565
  // ── Full page SSR ─────────────────────────────────────────────────────────────
1354
1566
 
1355
1567
  /**
@@ -1366,12 +1578,20 @@ export const renderRoute = async (data, options = {}) => {
1366
1578
  * @param {string} [options.themeColor] - theme-color meta
1367
1579
  * @param {object} [options.isr] - ISR options with clientScript path
1368
1580
  * @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
1581
+ * pre-rendered markup (the page's `<meta name="brender">`) instead of the
1582
+ * legacy swap script
1583
+ * @param {boolean} [options.adoptDom=false] - Hydration: also ship the
1584
+ * element-path registry (a JSON data block), so the client ADOPTS the
1585
+ * server DOM (smbls prerender.js) instead of replacing it. `smbls build`
1586
+ * passes symbols.json `brender.hydrate` (cli helpers/runBrender.js).
1587
+ * @param {string} [options.nonce] - The Content-Security-Policy nonce for the
1588
+ * inline scripts the page needs (the ISR paths only: a page rendered into a
1589
+ * shell carries none — its flag is a <meta>, its registry and fetch seed are
1590
+ * data blocks, which no policy blocks)
1591
+ * @param {boolean} [options.prefetch=true] - Fetch table/RPC labels before the
1592
+ * render and wait (up to 2 s) for the requests the page starts; false waits
1593
+ * only a 50 ms quiet period. The page's data always comes from the fetch
1594
+ * plugin during the render.
1375
1595
  * @param {string} [options.shell] - A built app document (e.g. dist/index.html)
1376
1596
  * to render INTO instead of emitting a standalone document (see shell.js).
1377
1597
  * The result then carries `shell: true`.
@@ -1381,13 +1601,18 @@ export const renderRoute = async (data, options = {}) => {
1381
1601
  * @returns {Promise<{ html: string, route: string, brKeyCount: number, shell?: true }>}
1382
1602
  */
1383
1603
  export const renderPage = async (data, route = '/', options = {}) => {
1384
- const { lang, themeColor, isr, hydrate = true, prefetch = true, shell, adoptDom = false } = options
1604
+ const { lang, themeColor, isr, hydrate = true, prefetch = true, shell, adoptDom = false, pathname, eagerImages = EAGER_IMAGES_DEFAULT, preloadScripts = true, nonce } = options
1605
+ const nonceAttr = nonceAttribute(nonce)
1385
1606
 
1386
1607
  // Detect lang from project config, app metadata, or default
1387
1608
  const htmlLang = lang || data.state?.lang || data.app?.metadata?.lang || 'en'
1388
1609
 
1389
- // Use the full smbls pipeline for rendering
1390
- const result = await render(data, { route, prefetch })
1610
+ // Use the full smbls pipeline for rendering. `pathname` is the address the
1611
+ // page is rendered at — the router matches it, location and every
1612
+ // canonical/og:url built from it read it. Without it a route was rendered
1613
+ // at its own pattern: the `/*` page wrote a `/*` canonical into 404.html
1614
+ // (xma.info report, brender build item 7).
1615
+ const result = await render(data, { route, pathname, prefetch })
1391
1616
  if (!result) return null
1392
1617
 
1393
1618
  const metadata = { ...result.metadata }
@@ -1431,9 +1656,11 @@ export const renderPage = async (data, route = '/', options = {}) => {
1431
1656
 
1432
1657
  const brKeyCount = Object.keys(result.registry).length
1433
1658
 
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 })
1659
+ // What the client boots with (prerenderParts): the page's mark for <head>,
1660
+ // and the data blocks after the markup — the registry under `adoptDom`,
1661
+ // and the recorded anonymous answers, so the client renders from the data
1662
+ // the server used instead of asking for all of it again.
1663
+ const prerender = prerenderParts({ brRegistry: result.brRegistry, fetchSeed: result.fetchSeed, adoptDom })
1437
1664
 
1438
1665
  // ISR: include client SPA bundle for hydration + data fetching
1439
1666
  let isrBody = ''
@@ -1447,9 +1674,9 @@ export const renderPage = async (data, route = '/', options = {}) => {
1447
1674
  const prefix = depth > 0 ? '../'.repeat(depth) : './'
1448
1675
 
1449
1676
  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.
1677
+ // The client boots over the markup: it knows the page by its
1678
+ // `<meta name="brender">` and adopts the markup when the page carries
1679
+ // the registry (`adoptDom`), else replaces it (smbls prerender.js).
1453
1680
  //
1454
1681
  // Seed client-side polyglot with SSR translations so that
1455
1682
  // el.call('polyglot', key) resolves immediately during hydration
@@ -1467,20 +1694,20 @@ export const renderPage = async (data, route = '/', options = {}) => {
1467
1694
  }
1468
1695
  }
1469
1696
  if (storageLangKey) {
1470
- const defaultLang = polyglotCfg?.defaultLang || 'en'
1697
+ const defaultLang = polyglotFallbackLang(undefined, polyglotCfg)
1471
1698
  seedEntries.push(`if(!localStorage.getItem(${JSON.stringify(storageLangKey)}))localStorage.setItem(${JSON.stringify(storageLangKey)},${JSON.stringify(defaultLang)})`)
1472
1699
  }
1473
1700
  if (seedEntries.length) {
1474
- translationSeed = `<script>try{${seedEntries.join(';')}}catch(e){}</script>\n`
1701
+ translationSeed = `<script${nonceAttr}>try{${seedEntries.join(';')}}catch(e){}</script>\n`
1475
1702
  }
1476
1703
  }
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>`
1704
+ // The registry and the fetch seed as data blocks; the page's mark is
1705
+ // its <meta name="brender"> (no inline flag a policy could block).
1706
+ const blocks = prerender.blocks ? prerender.blocks + '\n' : ''
1707
+ isrBody = `${translationSeed}${blocks}<script type="module" src="${prefix}${isr.clientScript}"${nonceAttr}></script>`
1481
1708
  } else {
1482
1709
  // Legacy swap mode: SPA creates new DOM, MutationObserver removes brender nodes
1483
- isrBody = `<script type="module">
1710
+ isrBody = `<script type="module"${nonceAttr}>
1484
1711
  {
1485
1712
  const brEls = document.querySelectorAll('body > :not(script):not(style)')
1486
1713
  const observer = new MutationObserver((mutations) => {
@@ -1497,42 +1724,56 @@ export const renderPage = async (data, route = '/', options = {}) => {
1497
1724
  observer.observe(document.body, { childList: true })
1498
1725
  }
1499
1726
  </script>
1500
- <script type="module" src="${prefix}${isr.clientScript}"></script>`
1727
+ <script type="module" src="${prefix}${isr.clientScript}"${nonceAttr}></script>`
1501
1728
  }
1502
1729
  }
1503
1730
 
1504
1731
  // Resolve any {{ key | polyglot }} templates in head tags (title, meta, etc.)
1732
+ // — in the page's language, with the labels the render ended with.
1505
1733
  const headConfig = { ...(data.config || {}) }
1506
1734
  if (data.polyglot && !headConfig.polyglot) headConfig.polyglot = data.polyglot
1507
1735
  const polyglotCfg = headConfig.polyglot
1508
1736
  let resolvedHeadTags = headTags
1509
1737
  if (polyglotCfg) {
1510
- const defaultLang = polyglotCfg.defaultLang || 'en'
1511
1738
  // Use SSR-fetched translations (from render result) merged with static translations
1512
- const translations = {
1513
- ...(polyglotCfg.translations || {}),
1514
- ...(result.ssrTranslations || {})
1739
+ const translations = { ...(polyglotCfg.translations || {}) }
1740
+ for (const lang in result.ssrTranslations || {}) {
1741
+ translations[lang] = { ...(translations[lang] || {}), ...result.ssrTranslations[lang] }
1515
1742
  }
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
- })
1743
+ resolvedHeadTags = resolveLabelTemplates(headTags, labelMapFor(translations, result.activeLang, polyglotCfg))
1521
1744
  }
1522
1745
 
1746
+ // A template the head still carries is a raw key too.
1747
+ const diagnostics = result.diagnostics
1748
+ if (diagnostics) {
1749
+ for (const m of resolvedHeadTags.matchAll(LABEL_TEMPLATE)) {
1750
+ const key = m[1].trim()
1751
+ if (!diagnostics.rawLabelKeys.includes(key)) diagnostics.rawLabelKeys.push(key)
1752
+ }
1753
+ }
1754
+
1755
+ // Images past the first `eagerImages` load lazily unless they say how to
1756
+ // load (see markLazyImages).
1757
+ const bodyHtml = markLazyImages(result.html, eagerImages)
1758
+
1523
1759
  // Build path: render INTO the app's own built document (shell.js) — the
1524
1760
  // standalone document below carries no app code unless `isr` adds a
1525
1761
  // client bundle, so writing it over a bundler's dist/index.html shipped
1526
1762
  // pages that never booted.
1527
1763
  if (typeof shell === 'string') {
1528
1764
  const html = composeIntoShell(shell, {
1529
- headTags: resolvedHeadTags,
1765
+ headTags: `${resolvedHeadTags}\n${prerender.meta}`,
1530
1766
  headEnd: [fontLinks, brenderStyle, emotionStyle].filter(Boolean).join('\n'),
1531
- body: result.html,
1767
+ body: bodyHtml,
1532
1768
  depth: Number.isInteger(options.depth) ? options.depth : 0,
1533
- bootScripts: adoptDom ? bootScripts : ''
1769
+ bootScripts: prerender.blocks,
1770
+ // The page's language on <html>, as the client's polyglot sets it at
1771
+ // boot — a page rendered for /en/… kept the shell's `lang="ka"`.
1772
+ // Only when the project has languages (or the caller names one).
1773
+ lang: lang || (polyglotCfg ? result.activeLang : undefined),
1774
+ preloadScripts
1534
1775
  })
1535
- return { html, route, brKeyCount, shell: true }
1776
+ return { html, route, brKeyCount, shell: true, diagnostics }
1536
1777
  }
1537
1778
 
1538
1779
  // (The empty line after the font links held a @font-face <style> that
@@ -1540,19 +1781,43 @@ export const renderPage = async (data, route = '/', options = {}) => {
1540
1781
  const html = `<!DOCTYPE html>
1541
1782
  <html lang="${htmlLang}">
1542
1783
  <head>
1543
- ${resolvedHeadTags}
1784
+ ${resolvedHeadTags}${isr && isr.clientScript && hydrate ? `\n${prerender.meta}` : ''}
1544
1785
  ${fontLinks}
1545
1786
 
1546
1787
  ${brenderStyle}
1547
1788
  ${emotionStyle}
1548
1789
  </head>
1549
1790
  <body>
1550
- ${result.html}
1791
+ ${bodyHtml}
1551
1792
  ${isrBody}
1552
1793
  </body>
1553
1794
  </html>`
1554
1795
 
1555
- return { html, route, brKeyCount }
1796
+ return { html, route, brKeyCount, diagnostics }
1797
+ }
1798
+
1799
+ // ── Images below the fold ───────────────────────────────────────────────────
1800
+ // A pre-rendered page's images are in the markup before the app's script,
1801
+ // and on a slow connection they took its bandwidth: measured on a throttled
1802
+ // phone, the app's 214 KB script finished at 2.65 s instead of 1.75 s while
1803
+ // 165–191 KB of images loaded (xma.info report, brender build item 14). The
1804
+ // server cannot see the fold, so the first `eager` images in document order
1805
+ // load as written and every later one that does not say how to load gets
1806
+ // `loading="lazy"` — a lazy image inside the viewport still loads right after
1807
+ // layout. An image that names its own `loading` is left alone. `eager:
1808
+ // false` turns this off.
1809
+ export const EAGER_IMAGES_DEFAULT = 2
1810
+ const IMG_TAG = /<img\b(?:[^>"']|"[^"]*"|'[^']*')*>/gi
1811
+ const HAS_LOADING = /\sloading\s*=/i
1812
+
1813
+ export const markLazyImages = (html, eager = EAGER_IMAGES_DEFAULT) => {
1814
+ if (eager === false || eager == null || typeof html !== 'string') return html
1815
+ const keep = Math.max(0, Number(eager) || 0)
1816
+ let seen = 0
1817
+ return html.replace(IMG_TAG, (tag) => {
1818
+ if (seen++ < keep || HAS_LOADING.test(tag)) return tag
1819
+ return tag.replace(/^<img\b/i, '<img loading="lazy"')
1820
+ })
1556
1821
  }
1557
1822
 
1558
1823
  const generateFontLinks = (ds) => {
@@ -1581,3 +1846,38 @@ const generateFontLinks = (ds) => {
1581
1846
  `<link href="https://fonts.googleapis.com/css2?${params}&display=swap" rel="stylesheet">`
1582
1847
  ].join('\n')
1583
1848
  }
1849
+
1850
+ // ── The page's boot data (CSP-safe) ─────────────────────────────────────────
1851
+ // A pre-rendered page tells the client what it is without one inline script:
1852
+ // XMA serves `script-src 'self'` (no 'unsafe-inline'), which blocked the
1853
+ // `<script>window.__BRENDER__=true</script>` flag — the client then created
1854
+ // the app from scratch and appended it after the server markup, two app
1855
+ // roots (XMA report, brender item 1). A policy blocks inline scripts, never
1856
+ // <meta> tags or data blocks. The format smbls reads (smbls prerender.js):
1857
+ // meta `<meta name="brender" content="hydrate|replace">` for <head>
1858
+ // blocks after the markup — the element-path registry under `adoptDom`
1859
+ // (`<script type="application/json" data-brender="registry">`),
1860
+ // and the recorded anonymous answers (@symbo.ls/fetch
1861
+ // createEarlySeedBlock), which the client adopts instead of asking
1862
+ // again. Placed after the markup, so they never delay its paint;
1863
+ // the app's scripts run after them either way.
1864
+ // `adoptionScripts` above stays for older callers (the platform's renderer).
1865
+ export const prerenderParts = ({ brRegistry, fetchSeed, adoptDom = false } = {}) => {
1866
+ const registry = adoptDom && brRegistry && Object.keys(brRegistry).length
1867
+ ? brenderRegistryBlock(brRegistry)
1868
+ : ''
1869
+ const seed = createEarlySeedBlock(fetchSeed)
1870
+ return {
1871
+ meta: `<meta name="brender" content="${registry ? 'hydrate' : 'replace'}">`,
1872
+ blocks: [registry, seed].filter(Boolean).join('\n')
1873
+ }
1874
+ }
1875
+
1876
+ // ` nonce="…"` for an inline script, or '' — a value that could break out of
1877
+ // the attribute is refused.
1878
+ const nonceAttribute = (nonce) => {
1879
+ if (nonce == null || nonce === '') return ''
1880
+ const value = String(nonce)
1881
+ if (!/^[A-Za-z0-9+/=_-]+$/.test(value)) throw new Error('brender: a nonce is base64 or base64url text')
1882
+ return ` nonce="${value}"`
1883
+ }