@symbo.ls/brender 3.14.661 → 3.14.663

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
@@ -8,7 +8,9 @@ import { createEnv } from './env.js'
8
8
  import { resetKeys, assignKeys, mapKeysToElements } from './keys.js'
9
9
  import { extractMetadata, generateHeadHtml } from './metadata.js'
10
10
  import { hydrate } from './hydrate.js'
11
+ import { composeIntoShell } from './shell.js'
11
12
  import { prefetchPageData, injectPrefetchedState, fetchSSRTranslations } from './prefetch.js'
13
+ import { createEarlySeedScript } from '@symbo.ls/fetch'
12
14
 
13
15
  // funcql plugin — enables evaluation of funcql schemas as property values
14
16
  // during SSR. Async-imported to avoid polluting non-funcql codepaths.
@@ -110,19 +112,6 @@ const structuredCloneDeep = (obj, seen = new WeakMap()) => {
110
112
  return clone
111
113
  }
112
114
 
113
- // JSON replacer that drops functions, circular refs, and non-serializable values
114
- const safeJsonReplacer = () => {
115
- const seen = new WeakSet()
116
- return (key, value) => {
117
- if (typeof value === 'function') return undefined
118
- if (typeof value === 'object' && value !== null) {
119
- if (seen.has(value)) return undefined
120
- seen.add(value)
121
- }
122
- return value
123
- }
124
- }
125
-
126
115
  // ── Workspace detection ──────────────────────────────────────────────────────
127
116
  // Detect whether brender is running inside the monorepo or as an installed
128
117
  // npm package, and resolve paths accordingly.
@@ -257,26 +246,56 @@ const resolveDomqlPackage = (ws, pkg, ...subpath) => {
257
246
  return null
258
247
  }
259
248
 
260
- // ── Bundled import of createDomqlElement ──────────────────────────────────────
261
- // The smbls source tree uses extensionless/directory imports that Node.js ESM
262
- // cannot resolve natively. We bundle createDomql.js with esbuild (once, cached)
263
- // so all bare/directory specifiers are resolved at bundle time.
249
+ // ── Loading createDomqlElement ────────────────────────────────────────────────
250
+ // Three sources, in this order:
251
+ //
252
+ // 1. The smbls MONOREPO (brender running from plugins/brender next to
253
+ // packages/smbls/src): esbuild-bundle the live SOURCE createDomql.js, so
254
+ // framework development renders what is on disk.
255
+ // 2. An INSTALLED smbls: its supported server-render entry, `smbls/ssr`
256
+ // (createDomqlElement + prepareContext, the same module instance as the
257
+ // `smbls` main entry). The published smbls ships bundles only — no src/,
258
+ // no dist/esm/src/ — so the old "bundle smbls's source" path failed every
259
+ // route of every npm install with "brender: cannot find createDomql.js"
260
+ // (xma.info report item 1). Rendering with the consumer's OWN smbls also
261
+ // keeps the server markup and the client that boots over it on one
262
+ // framework version; a createDomql prebuilt into brender's package would
263
+ // freeze whichever smbls brender was published against.
264
+ // 3. Bundled runtimes (Cloudflare Workers — no import.meta.url, no
265
+ // filesystem): the same `smbls/ssr` specifier, a string literal the
266
+ // worker's bundler resolves and inlines. render.js used to import
267
+ // `./dist/createDomql.bundled.mjs` here, a file nothing ever built, so
268
+ // the mermaid worker fell back to client render on every request
269
+ // (FW-BRENDER-PREBUNDLED-CREATEDOMQL-NEVER-BUILT-WORKER-SSR-FALLS-BACK-1).
270
+ //
271
+ // An installed smbls OLDER than the ssr entry that still carries
272
+ // src/createDomql.js (pre-54131b34d tarballs) keeps the source-bundle path.
264
273
  let _cachedCreateDomql = null
265
274
 
275
+ // Exported for the packaging lock in __tests__/ssrEntryPackaging.test.js —
276
+ // the specifier that test proves the packed smbls tarball serves.
277
+ export const SMBLS_SSR_SPECIFIER = 'smbls/ssr'
278
+
279
+ const importSmblsSsr = async () => {
280
+ // The literal (not the constant) is what a bundler can statically resolve.
281
+ const mod = await import('smbls/ssr')
282
+ if (typeof mod?.createDomqlElement !== 'function') {
283
+ throw new Error('smbls/ssr does not export createDomqlElement')
284
+ }
285
+ return mod
286
+ }
287
+
266
288
  const bundleCreateDomql = async () => {
267
289
  if (_cachedCreateDomql) return _cachedCreateDomql
268
290
 
269
291
  const ws = detectWorkspace()
270
292
 
271
- // In bundled environments (CF Workers), use the pre-bundled createDomql.
272
- // Generated by: node scripts/prebundle-createDomql.js (or during npm prepublish)
273
293
  if (!_brenderRequire) {
274
294
  try {
275
- const mod = await import('./dist/createDomql.bundled.mjs')
276
- _cachedCreateDomql = mod
277
- return mod
295
+ _cachedCreateDomql = await importSmblsSsr()
296
+ return _cachedCreateDomql
278
297
  } catch (err) {
279
- throw new Error(`brender: pre-bundled createDomql not available in bundled runtime: ${err.message}`)
298
+ throw new Error(`brender: ${SMBLS_SSR_SPECIFIER} is not available in this bundled runtime (${err.message}). Bundle smbls ≥ the release that adds the smbls/ssr export alongside @symbo.ls/brender.`)
280
299
  }
281
300
  }
282
301
 
@@ -284,15 +303,25 @@ const bundleCreateDomql = async () => {
284
303
  let entry
285
304
  if (ws.isMonorepo) {
286
305
  entry = resolve(ws.monorepoRoot, 'packages', 'smbls', 'src', 'createDomql.js')
287
- } else if (ws.smblsRoot) {
288
- // Prefer src/ (shipped in smbls package), fall back to dist
289
- const srcEntry = resolve(ws.smblsRoot, 'src', 'createDomql.js')
290
- const distEntry = resolve(ws.smblsRoot, 'dist', 'esm', 'src', 'createDomql.js')
291
- entry = existsSync(srcEntry) ? srcEntry : distEntry
292
- }
293
-
294
- if (!entry || !existsSync(entry)) {
295
- throw new Error(`brender: cannot find createDomql.js (isMonorepo=${ws.isMonorepo}, entry=${entry})`)
306
+ } else {
307
+ let ssrError
308
+ try {
309
+ _cachedCreateDomql = await importSmblsSsr()
310
+ return _cachedCreateDomql
311
+ } catch (err) {
312
+ ssrError = err
313
+ }
314
+ // Legacy: an smbls that predates the ssr entry but still ships its source.
315
+ const srcEntry = ws.smblsRoot && resolve(ws.smblsRoot, 'src', 'createDomql.js')
316
+ if (srcEntry && existsSync(srcEntry)) {
317
+ entry = srcEntry
318
+ } else {
319
+ throw new Error(
320
+ `brender: cannot load ${SMBLS_SSR_SPECIFIER} (${ssrError.message}). ` +
321
+ 'Server rendering needs smbls installed in this project, at a release that exports smbls/ssr' +
322
+ (ws.smblsRoot ? ` (found smbls at ${ws.smblsRoot}).` : ' (no smbls package found).')
323
+ )
324
+ }
296
325
  }
297
326
 
298
327
  const esbuild = await import('esbuild')
@@ -637,6 +666,116 @@ const buildPathRegistry = (element, path = '') => {
637
666
  * @param {object} [options.context] - Additional context overrides
638
667
  * @returns {Promise<{ html: string, metadata: object, registry: object, element: object }>}
639
668
  */
669
+ // ── In-flight request tracking (the post-create flush) ──────────────────────
670
+ // The render's flush waits for the requests a page starts while it renders
671
+ // (fetch plugin, SDK, project code — all end in globalThis.fetch). One
672
+ // counting wrapper is installed around whatever globalThis.fetch is at render
673
+ // time and left in place: it returns the inner call's own promise untouched,
674
+ // and re-installing per render would nest wrappers across concurrent renders
675
+ // in one isolate. A host that swaps globalThis.fetch later gets wrapped again
676
+ // on its next render. The count is process-wide — a concurrent render's
677
+ // request can only make a flush wait longer, never cut one short, and the
678
+ // flush is capped.
679
+ let _inFlight = 0
680
+ const FETCH_TRACKED = Symbol.for('brender.fetchTracked')
681
+ const inFlightRequests = () => _inFlight
682
+ // ── Recorded answers (the fetch seed, phase B) ──────────────────────────────
683
+ // While exactly ONE render is running, the anonymous GETs it makes are
684
+ // recorded — url, Accept-Language, status, content type, body — so the page
685
+ // can carry them (@symbo.ls/fetch createEarlySeedScript) and the hydrating
686
+ // client adopts them instead of asking again. A second render overlapping
687
+ // in the same isolate taints the recording: another project's answers must
688
+ // never land in this page, so an overlapped render carries none.
689
+ // Only a GET whose headers are Accept-Language / Accept at most is recorded
690
+ // (never a signed-in or keyed call), only an ok JSON or text answer, at most
691
+ // SEED_ENTRY_MAX bytes each and SEED_TOTAL_MAX per page.
692
+ const SEED_ENTRY_MAX = 256 * 1024
693
+ const SEED_TOTAL_MAX = 1024 * 1024
694
+ const SEED_HEADERS_OK = new Set(['accept-language', 'accept'])
695
+ let _activeRenders = 0
696
+ let _seed = null
697
+
698
+ const requestShape = (args) => {
699
+ const [input, init] = args
700
+ const isRequest = input && typeof input === 'object' && typeof input.url === 'string'
701
+ const url = isRequest ? input.url : String(input)
702
+ const method = String((init && init.method) || (isRequest && input.method) || 'GET').toUpperCase()
703
+ const headers = {}
704
+ const add = (h) => {
705
+ if (!h) return
706
+ if (typeof h.forEach === 'function' && !Array.isArray(h)) h.forEach((v, k) => { headers[String(k).toLowerCase()] = String(v) })
707
+ else if (Array.isArray(h)) for (const [k, v] of h) headers[String(k).toLowerCase()] = String(v)
708
+ else for (const k in h) if (h[k] != null) headers[k.toLowerCase()] = String(h[k])
709
+ }
710
+ if (isRequest) add(input.headers)
711
+ if (init) add(init.headers)
712
+ return { url, method, headers }
713
+ }
714
+
715
+ const recordAnswer = (seed, shape, response, track) => {
716
+ if (!response || !response.ok || typeof response.clone !== 'function') return
717
+ const type = (response.headers && response.headers.get && response.headers.get('content-type')) || ''
718
+ if (!/json|^text\//i.test(type)) return
719
+ let copy
720
+ try { copy = response.clone() } catch { return }
721
+ track(copy.text().then((body) => {
722
+ if (seed.tainted || body.length > SEED_ENTRY_MAX || seed.bytes + body.length > SEED_TOTAL_MAX) return
723
+ seed.bytes += body.length
724
+ seed.entries.push({ url: shape.url, lang: shape.headers['accept-language'] || '', status: response.status, type, body })
725
+ }, () => {}))
726
+ }
727
+
728
+ const trackFetches = () => {
729
+ const inner = globalThis.fetch
730
+ if (typeof inner !== 'function' || inner[FETCH_TRACKED]) return
731
+ const tracked = function (...args) {
732
+ _inFlight++
733
+ let settled = false
734
+ const done = () => { if (!settled) { settled = true; _inFlight-- } }
735
+ // a pending body read counts as in flight: the flush waits for it too
736
+ const track = (promise) => { _inFlight++; promise.then(() => { _inFlight-- }, () => { _inFlight-- }) }
737
+ const seed = _seed && !_seed.tainted ? _seed : null
738
+ let shape = null
739
+ if (seed) {
740
+ try {
741
+ shape = requestShape(args)
742
+ if (shape.method !== 'GET' || !/^https?:/i.test(shape.url) ||
743
+ Object.keys(shape.headers).some((h) => !SEED_HEADERS_OK.has(h))) shape = null
744
+ } catch { shape = null }
745
+ }
746
+ let result
747
+ try {
748
+ result = inner.apply(this, args)
749
+ } catch (err) {
750
+ done()
751
+ throw err
752
+ }
753
+ if (result && typeof result.then === 'function') {
754
+ result.then((response) => {
755
+ if (shape) recordAnswer(seed, shape, response, track)
756
+ done()
757
+ }, done)
758
+ } else done()
759
+ return result
760
+ }
761
+ tracked[FETCH_TRACKED] = true
762
+ globalThis.fetch = tracked
763
+ }
764
+
765
+ // ── Opt-in hydration boot scripts (phase B) ────────────────────────────────
766
+ // What a page needs for the client to ADOPT the server DOM instead of
767
+ // replacing it (smbls createDomql hydrateRender): the element-path registry,
768
+ // the opt-in flag, and the recorded anonymous answers for the fetch plugin
769
+ // (@symbo.ls/fetch createEarlySeedScript). Default off everywhere — a page
770
+ // without these keeps the fresh client render.
771
+ // FW-BRENDER-HYDRATION-AND-CF-DEPLOY-PROVIDER-1.
772
+ export const adoptionScripts = ({ brRegistry, fetchSeed } = {}) => {
773
+ if (!brRegistry || !Object.keys(brRegistry).length) return ''
774
+ const registry = JSON.stringify(brRegistry).replace(/</g, '\\u003c')
775
+ return `<script>window.__BR_REGISTRY__=${registry};window.__BRENDER_HYDRATE__=true</script>` +
776
+ (fetchSeed && fetchSeed.length ? '\n' + createEarlySeedScript(fetchSeed) : '')
777
+ }
778
+
640
779
  export const render = async (data, options = {}) => {
641
780
  const { route = '/', pathname, state: stateOverrides, context: contextOverrides, prefetch = false } = options
642
781
  // pathname is the actual URL path (e.g. /podcast/abc-123), route is the page pattern (e.g. /podcast/:id)
@@ -732,7 +871,7 @@ export const render = async (data, options = {}) => {
732
871
  if (data.polyglot && !config.polyglot) config.polyglot = data.polyglot
733
872
  if (data.fetch && !config.fetch) config.fetch = data.fetch
734
873
  if (data.router && !config.router) config.router = data.router
735
- for (const k of ['useReset', 'useVariable', 'useFontImport', 'useIconSprite', 'useSvgSprite', 'useDefaultConfig', 'useDocumentTheme']) {
874
+ for (const k of SCRATCH_CONFIG_FLAGS) {
736
875
  if (data[k] != null && config[k] == null) config[k] = data[k]
737
876
  }
738
877
 
@@ -763,7 +902,25 @@ export const render = async (data, options = {}) => {
763
902
  // Reset the atomic CSS engine for this render pass
764
903
  resetCss()
765
904
 
905
+ // Every other top-level key of the payload, as the client gets it: the
906
+ // client boots with the WHOLE published payload as its context (mermaid
907
+ // bundle.js), so `globalScope` (frank's hoisted helpers behind `__scope.X`)
908
+ // and project-level context keys (`catalog`, `imageBase`, `siteUrl`, …)
909
+ // must be there on the server too — without them docs.symbols.app threw
910
+ // "__scope.isViewNavRoute is not a function" and shaker.ge read
911
+ // `this.context.catalog` as undefined (FW-BRENDER-HYDRATION-AND-CF-DEPLOY-
912
+ // PROVIDER-1). Cloned: a render must not write into a payload a multi-page
913
+ // build reuses. The explicit keys below still win.
914
+ const projectContext = {}
915
+ for (const key in data) {
916
+ if (key === 'app' || key === 'config' || key === 'pages' || key === 'components' ||
917
+ key === 'state' || key === 'snippets' || key === 'designSystem' || key === 'dependencies') continue
918
+ const value = data[key]
919
+ projectContext[key] = value && typeof value === 'object' ? structuredCloneDeep(value) : value
920
+ }
921
+
766
922
  const ctx = {
923
+ ...projectContext,
767
924
  state: baseState,
768
925
  ...(stateOverrides ? { state: { ...baseState, ...stateOverrides } } : {}),
769
926
  dependencies: structuredCloneDeep(data.dependencies || {}),
@@ -792,6 +949,15 @@ export const render = async (data, options = {}) => {
792
949
  // Disable sourcemap tracking in SSR — it causes stack overflows
793
950
  // when state contains large data arrays (articles, events, etc.)
794
951
  domqlOptions: { sourcemap: false },
952
+ // Editor/live-sync, inspector and toast plugins have no place in server
953
+ // markup. The source-bundle path used to stub @symbo.ls/sync to no-ops at
954
+ // bundle time; the installed `smbls/ssr` entry carries the real plugins,
955
+ // so they are switched off here instead — after `...config`, because a
956
+ // project config that enables them for the browser must not attach
957
+ // SyncComponent / overlays to the pre-rendered DOM.
958
+ sync: false,
959
+ inspect: false,
960
+ notifications: false,
795
961
  // Caller overrides
796
962
  ...(contextOverrides || {})
797
963
  }
@@ -807,14 +973,42 @@ export const render = async (data, options = {}) => {
807
973
 
808
974
  resetKeys()
809
975
 
810
- const element = await createDomqlElement(app, ctx)
811
-
812
- // Allow async operations (fetch callbacks, state updates, re-renders) to flush.
813
- // DOMQL's fetch plugin fires on element creation and updates state asynchronously.
814
- // With prefetch enabled, data is pre-injected but DOMQL's fetch may also fire
815
- // and trigger state updates. Give enough time for these to complete.
816
- const flushDelay = prefetch ? 2000 : 50
817
- await new Promise(r => setTimeout(r, flushDelay))
976
+ trackFetches()
977
+ // the recording of this render's answers (see `_seed`): tainted the moment
978
+ // another render overlaps it
979
+ _activeRenders++
980
+ if (_activeRenders === 1) _seed = { entries: [], bytes: 0, tainted: false }
981
+ else if (_seed) _seed.tainted = true
982
+ const seed = _seed
983
+ let element
984
+ try {
985
+ element = await createDomqlElement(app, ctx)
986
+
987
+ // Allow async operations (fetch callbacks, state updates, re-renders) to flush.
988
+ // DOMQL's fetch plugin fires on element creation and updates state asynchronously.
989
+ // With prefetch enabled, data is pre-injected but DOMQL's fetch may also fire
990
+ // and trigger state updates. Give enough time for these to complete.
991
+ //
992
+ // It used to sleep a flat 2000 ms with prefetch — measured in workerd on
993
+ // six published projects, `flush` was 2000 ms on every route, 40–90 % of
994
+ // each platform pre-render. Now: a 50 ms quiet period, then — with
995
+ // prefetch — keep waiting while requests are in flight (the answers the
996
+ // flush exists for), each settled batch followed by another quiet period
997
+ // for the state updates and follow-up requests it triggers. The old
998
+ // 2000 ms stays the cap (FW-BRENDER-HYDRATION-AND-CF-DEPLOY-PROVIDER-1).
999
+ const flushStart = Date.now()
1000
+ const flushCap = prefetch ? 2000 : 50
1001
+ const sleep = (ms) => new Promise(r => setTimeout(r, ms))
1002
+ await sleep(Math.min(50, flushCap))
1003
+ while (prefetch && inFlightRequests() > 0 && Date.now() - flushStart < flushCap) {
1004
+ while (inFlightRequests() > 0 && Date.now() - flushStart < flushCap) await sleep(10)
1005
+ await sleep(Math.max(0, Math.min(50, flushCap - (Date.now() - flushStart))))
1006
+ }
1007
+ } finally {
1008
+ _activeRenders--
1009
+ if (_seed === seed) _seed = null
1010
+ }
1011
+ const fetchSeed = seed && !seed.tainted ? seed.entries.slice() : []
818
1012
 
819
1013
  // Assign data-br keys for hydration
820
1014
  assignKeys(body)
@@ -832,20 +1026,30 @@ export const render = async (data, options = {}) => {
832
1026
 
833
1027
  // Extract CSS from style tags in virtual head (atomic CSS engine writes here)
834
1028
  const emotionCSS = []
1029
+ const capturedRules = []
835
1030
  const head = document.head || document.querySelector('head')
836
1031
  if (head) {
837
1032
  for (const style of head.querySelectorAll('style')) {
838
1033
  if (style.sheet && style.sheet.cssRules) {
839
1034
  for (const rule of style.sheet.cssRules) {
840
- if (rule.cssText) emotionCSS.push(rule.cssText)
1035
+ if (rule.cssText) {
1036
+ emotionCSS.push(rule.cssText)
1037
+ capturedRules.push(rule)
1038
+ }
841
1039
  }
842
1040
  }
843
1041
  if (!emotionCSS.length) {
844
1042
  const content = style.textContent || ''
845
- if (content) emotionCSS.push(content)
1043
+ if (content) {
1044
+ emotionCSS.push(content)
1045
+ capturedRules.push({ cssText: content })
1046
+ }
846
1047
  }
847
1048
  }
848
1049
  }
1050
+ // globalCSS: the init-injected globals; classRules: everything else, in
1051
+ // capture order (see splitCapturedCSS).
1052
+ const { globalCSS, classRules } = splitCapturedCSS(capturedRules)
849
1053
 
850
1054
  let html = fixSvgContent(body.innerHTML)
851
1055
 
@@ -866,7 +1070,7 @@ export const render = async (data, options = {}) => {
866
1070
  if (_prevLoc !== undefined) globalThis.location = _prevLoc
867
1071
  else delete globalThis.location
868
1072
 
869
- return { html, metadata, registry, brRegistry, element, emotionCSS, document, window, ssrTranslations, prefetchedPages }
1073
+ return { html, metadata, registry, brRegistry, element, emotionCSS, globalCSS, classRules, fetchSeed, document, window, ssrTranslations, prefetchedPages }
870
1074
  }
871
1075
 
872
1076
  /**
@@ -940,46 +1144,18 @@ const fixSvgContent = (html) => {
940
1144
  )
941
1145
  }
942
1146
 
943
- // ── Global CSS generation ─────────────────────────────────────────────────────
944
-
945
- /**
946
- * Runs the scratch design-system pipeline (via esbuild bundling to work around
947
- * bare-import issues) to produce CSS variables and reset styles — the same
948
- * globals that the SPA runtime injects via emotion.injectGlobal.
949
- */
950
- let _cachedGlobalCSS = null
951
-
952
1147
  // Frank serializes a project's `config.js` exports at the TOP LEVEL of the
953
1148
  // data payload (not under `data.config`), so `globalTheme` / `themeStorageKey`
954
1149
  // / `useReset` / etc. live alongside `components` / `pages` / `designSystem`.
955
- // Both renderRoute and renderPage used to call `generateGlobalCSS(ds,
956
- // data.config || data.settings)` which resolved to undefined for any project
957
- // pushed through frank — every config flag silently dropped, including
958
- // `globalTheme: 'light'`. The hardcoded `globalTheme: 'auto'` default in
959
- // generateGlobalCSS then won, prod always rendered in matchMedia-detected
960
- // theme regardless of what the project declared. Helper picks the config
961
- // flags from `data` whichever shape they arrive in.
1150
+ // render() lifts these into the render context, as the client's context
1151
+ // carries them — the global CSS is captured from that render, so a project's
1152
+ // `globalTheme: 'light'` decides which scheme lands in the :root fallback.
962
1153
  const SCRATCH_CONFIG_FLAGS = [
963
1154
  'globalTheme', 'themeStorageKey', 'themeRoot',
964
1155
  'useReset', 'useVariable', 'useFontImport', 'useIconSprite', 'useSvgSprite',
965
1156
  'useDocumentTheme', 'useDefaultConfig', 'useDefaultIcons',
966
1157
  'useThemeSuffixedVars', 'verbose', 'semanticIcons',
967
1158
  ]
968
- const pickProjectConfig = (data) => {
969
- if (!data || typeof data !== 'object') return null
970
- if (data.config && typeof data.config === 'object') return data.config
971
- const out = {}
972
- let any = false
973
- for (const flag of SCRATCH_CONFIG_FLAGS) {
974
- if (Object.prototype.hasOwnProperty.call(data, flag)) {
975
- out[flag] = data[flag]
976
- any = true
977
- }
978
- }
979
- if (any) return out
980
- return data.settings || null
981
- }
982
-
983
1159
  // Serialise scratch `buildSkinVarStyles` output — `{ selector: { var: value },
984
1160
  // '@media …': { selector: { var: value } } }` — as CSS text
985
1161
  // (FW-SCRATCH-SKINS-DIMENSION-1).
@@ -1002,207 +1178,79 @@ export const skinRulesToCSS = (rules) => Object.entries(rules || {})
1002
1178
  .filter(Boolean)
1003
1179
  .join('\n\n')
1004
1180
 
1005
- const generateGlobalCSS = async (ds, config) => {
1006
- if (_cachedGlobalCSS) return _cachedGlobalCSS
1007
-
1008
- try {
1009
- const { existsSync, writeFileSync, unlinkSync } = await import('fs')
1010
- const { tmpdir } = await import('os')
1011
- const { randomBytes } = await import('crypto')
1012
-
1013
- // Guard: skip if filesystem APIs aren't available (e.g. CF Workers)
1014
- try { tmpdir() } catch { return {} }
1015
-
1016
- const esbuild = await import('esbuild')
1017
-
1018
- // Write a temporary script that imports scratch, runs set(), and
1019
- // serialises the CSS_VARS + RESET objects as JSON.
1020
- const dsJson = JSON.stringify(ds || {}, safeJsonReplacer())
1021
- // Config may contain non-serializable values (e.g. a DB client with
1022
- // circular refs, functions). Strip those for the CSS generation script.
1023
- const cfgJson = JSON.stringify(config || {}, safeJsonReplacer())
1024
- const tmpEntry = join(tmpdir(), `br_global_${randomBytes(6).toString('hex')}.mjs`)
1025
- const tmpOut = join(tmpdir(), `br_global_${randomBytes(6).toString('hex')}_out.mjs`)
1026
-
1027
- writeFileSync(tmpEntry, `
1028
- import { set, getActiveConfig, getFontFaceString } from '@symbo.ls/scratch'
1029
- // Namespace access for the skin builder: a scratch without it (version
1030
- // skew) gives an empty skin block instead of failing this whole bundle
1031
- // and, with it, every global rule.
1032
- import * as scratchNs from '@symbo.ls/scratch'
1033
- import { DEFAULT_CONFIG } from '@symbo.ls/default-config'
1034
-
1035
- const ds = ${dsJson}
1036
- const cfg = ${cfgJson}
1037
-
1038
- // Merge with defaults (same as initEmotion)
1039
- const merged = {}
1040
- for (const k in DEFAULT_CONFIG) merged[k] = DEFAULT_CONFIG[k]
1041
- for (const k in ds) {
1042
- if (typeof ds[k] === 'object' && !Array.isArray(ds[k]) && typeof merged[k] === 'object' && !Array.isArray(merged[k])) {
1043
- merged[k] = { ...merged[k], ...ds[k] }
1044
- } else {
1045
- merged[k] = ds[k]
1046
- }
1047
- }
1048
-
1049
- const conf = set({
1050
- useReset: true,
1051
- useVariable: true,
1052
- useFontImport: true,
1053
- useDocumentTheme: true,
1054
- useDefaultConfig: true,
1055
- globalTheme: 'auto',
1056
- ...merged,
1057
- ...cfg
1058
- }, { newConfig: {} })
1059
-
1060
- const result = {
1061
- CSS_VARS: conf.CSS_VARS || {},
1062
- CSS_MEDIA_VARS: conf.CSS_MEDIA_VARS || {},
1063
- // Skin rules — the same builder smbls init() injects on the client
1064
- // (FW-SCRATCH-SKINS-DIMENSION-1).
1065
- CSS_SKIN_RULES: typeof scratchNs.buildSkinVarStyles === 'function'
1066
- ? scratchNs.buildSkinVarStyles(conf.cssSkinVars, conf.cssSkinFallback, ':root')
1067
- : {},
1068
- reset: conf.reset || {},
1069
- animation: conf.animation || {}
1070
- }
1071
- // Export as globalThis so we can read it
1072
- globalThis.__BR_GLOBAL_CSS__ = result
1073
- export default result
1074
- `)
1075
-
1076
- // Detect workspace layout (monorepo vs npm install)
1077
- const ws = detectWorkspace()
1078
-
1079
- // Workspace resolve plugin: maps @symbo.ls/* and @symbo.ls/* to source paths
1080
- const workspacePlugin = {
1081
- name: 'workspace-resolve',
1082
- setup (build) {
1083
- build.onResolve({ filter: /^@symbo\.ls\// }, args => {
1084
- const pkg = args.path.replace('@symbo.ls/', '')
1085
- if (ws.isMonorepo) {
1086
- for (const dir of ['packages', 'plugins']) {
1087
- const src = resolve(ws.monorepoRoot, dir, pkg, 'src', 'index.js')
1088
- if (existsSync(src)) return { path: src }
1089
- const dist = resolve(ws.monorepoRoot, dir, pkg, 'index.js')
1090
- if (existsSync(dist)) return { path: dist }
1091
- }
1092
- const blank = resolve(ws.monorepoRoot, 'packages', 'default-config', 'blank', 'index.js')
1093
- if (pkg === 'default-config' && existsSync(blank)) return { path: blank }
1094
- } else {
1095
- const resolved = resolveSymbolsPackage(ws, pkg, 'src', 'index.js')
1096
- if (resolved && existsSync(resolved)) return { path: resolved }
1097
- const resolvedIdx = resolveSymbolsPackage(ws, pkg, 'index.js')
1098
- if (resolvedIdx && existsSync(resolvedIdx)) return { path: resolvedIdx }
1099
- if (pkg === 'default-config') {
1100
- const blank = resolveSymbolsPackage(ws, 'default-config', 'blank', 'index.js')
1101
- if (blank && existsSync(blank)) return { path: blank }
1102
- }
1103
- }
1104
- })
1105
- build.onResolve({ filter: /^@domql\// }, args => {
1106
- const pkg = args.path.replace('@symbo.ls/', '')
1107
- if (ws.isMonorepo) {
1108
- const src = resolve(ws.monorepoRoot, 'packages', 'domql', 'packages', pkg, 'src', 'index.js')
1109
- if (existsSync(src)) return { path: src }
1110
- } else {
1111
- const resolved = resolveDomqlPackage(ws, pkg, 'src', 'index.js')
1112
- if (resolved && existsSync(resolved)) return { path: resolved }
1113
- const resolvedIdx = resolveDomqlPackage(ws, pkg, 'index.js')
1114
- if (resolvedIdx && existsSync(resolvedIdx)) return { path: resolvedIdx }
1115
- }
1116
- })
1117
- }
1118
- }
1119
-
1120
- await esbuild.build({
1121
- entryPoints: [tmpEntry],
1122
- bundle: true,
1123
- format: 'esm',
1124
- platform: 'node',
1125
- outfile: tmpOut,
1126
- write: true,
1127
- logLevel: 'silent',
1128
- plugins: [workspacePlugin],
1129
- nodePaths: ws.isMonorepo
1130
- ? [resolve(ws.monorepoRoot, 'node_modules')]
1131
- : [
1132
- ...(ws.smblsRoot ? [resolve(ws.smblsRoot, 'node_modules')] : []),
1133
- ...(ws.projectRoot ? [resolve(ws.projectRoot, 'node_modules')] : []),
1134
- ...(ws.smblsRoot ? [resolve(ws.smblsRoot, '..', '..', 'node_modules')] : [])
1135
- ].filter(p => existsSync(p)),
1136
- external: ['fs', 'path', 'os', 'crypto', 'url', 'http', 'https', 'stream', 'util', 'events', 'buffer', 'child_process', 'worker_threads', 'net', 'tls', 'dns', 'dgram', 'zlib', 'assert', 'querystring', 'string_decoder', 'readline', 'perf_hooks', 'async_hooks', 'v8', 'vm', 'cluster', 'inspector', 'module', 'process', 'tty', 'color-contrast-checker']
1137
- })
1138
-
1139
- const mod = await import(`file://${tmpOut}`)
1140
- const data = mod.default || {}
1141
- try { unlinkSync(tmpEntry) } catch {} // cleanup: ignore if temp file already removed
1142
- try { unlinkSync(tmpOut) } catch {} // cleanup: ignore if temp file already removed
1143
-
1144
- const cssVars = data.CSS_VARS || {}
1145
- const cssMediaVars = data.CSS_MEDIA_VARS || {}
1146
- const reset = data.RESET || {}
1147
- const animations = data.ANIMATION || {}
1148
-
1149
- // ── :root CSS variables ──
1150
- const varDecls = Object.entries(cssVars)
1151
- .map(([k, v]) => ` ${k}: ${v}`)
1152
- .join(';\n')
1153
- let rootRule = varDecls ? `:root {\n${varDecls};\n}` : ''
1154
-
1155
- // ── Theme-switching CSS vars (media queries + data-theme selectors) ──
1156
- const themeVarRules = Object.entries(cssMediaVars)
1157
- .map(([key, vars]) => {
1158
- const decls = Object.entries(vars)
1159
- .map(([k, v]) => ` ${k}: ${v}`)
1160
- .join(';\n')
1161
- if (!decls) return ''
1162
- if (key.startsWith('@media')) {
1163
- // Media query — only when no data-theme forces a theme
1164
- return `${key} {\n :root:not([data-theme]) {\n${decls};\n }\n}`
1165
- }
1166
- // Selector ([data-theme="..."]) — apply directly
1167
- return `${key} {\n${decls};\n}`
1168
- })
1169
- .filter(Boolean)
1170
- .join('\n\n')
1171
- if (themeVarRules) rootRule += '\n\n' + themeVarRules
1172
-
1173
- // ── Skin rules ([data-skin] selectors + the reduced-transparency block) ──
1174
- // After the scheme blocks: on an equal specificity the skin wins, as it
1175
- // does in the hydrated page (smbls init injects them in the same order).
1176
- const skinRules = skinRulesToCSS(data.CSS_SKIN_RULES)
1177
- if (skinRules) rootRule += (rootRule ? '\n\n' : '') + skinRules
1178
-
1179
- // ── Reset styles ──
1180
- const resetRules = generateResetCSS(reset)
1181
-
1182
- // ── @keyframes animations ──
1183
- const keyframeRules = []
1184
- for (const name in animations) {
1185
- const frames = animations[name]
1186
- if (!frames || typeof frames !== 'object') continue
1187
- const frameRules = Object.entries(frames).map(([step, p]) => {
1188
- if (typeof p !== 'object') return ''
1189
- const decls = Object.entries(p).map(([k, v]) => `${camelToKebab(k)}: ${v}`).join('; ')
1190
- return ` ${step} { ${decls}; }`
1191
- }).join('\n')
1192
- keyframeRules.push(`@keyframes ${name} {\n${frameRules}\n}`)
1193
- }
1181
+ // ── Global CSS: the render's own capture, split ────────────────────────────
1182
+ // The rules smbls init injects into the virtual <head> during render() ARE
1183
+ // the page's global CSS — the :root variables, scheme and skin blocks, the
1184
+ // processed reset and every @keyframes — exactly what the client injects,
1185
+ // for THIS project and render. They are split off the captured sheet so a
1186
+ // page emits them once, ahead of the atomic classes, and so renderPage's
1187
+ // cross-page accumulation keeps only the classes.
1188
+ //
1189
+ // This replaced generateGlobalCSS, which re-derived the same CSS by bundling
1190
+ // a temp module with esbuild into tmpdir. It read data.RESET/data.ANIMATION
1191
+ // where the module exported reset/animation (resetRules and keyframeRules
1192
+ // were always '', and renderRoute fell back to the RAW design-system reset,
1193
+ // tokens unresolved); it threw on every cold Cloudflare isolate ("[unenv]
1194
+ // fs.writeFileSync is not implemented yet"); and its result was cached per
1195
+ // PROCESS, keyed on nothing, so a server rendering two projects gave the
1196
+ // second one the first one's :root (FW-BRENDER-HYDRATION-AND-CF-DEPLOY-PROVIDER-1).
1197
+
1198
+ // A selector is global when no selector in its list targets a class — the
1199
+ // atomic engine's every rule does (`._p-a`, `.c1x:hover`, `[data-theme] .c2`).
1200
+ // Quoted strings and attribute brackets are masked first: a dot inside
1201
+ // `[href$=".pdf"]` is not a class.
1202
+ const CLASS_IN_SELECTOR = /\.[A-Za-z_\\-]/
1203
+ const isGlobalSelector = (selectorText) => {
1204
+ if (typeof selectorText !== 'string' || !selectorText) return false
1205
+ const masked = selectorText
1206
+ .replace(/"(?:[^"\\]|\\.)*"|'(?:[^'\\]|\\.)*'/g, '""')
1207
+ .replace(/\[[^\]]*\]/g, '[]')
1208
+ return !CLASS_IN_SELECTOR.test(masked)
1209
+ }
1210
+ const VARIABLE_SCOPE = /^\s*(:root|\[data-theme|\[data-skin)/
1211
+
1212
+ // Classify one CSSOM rule: 'root' (variables, scheme and skin blocks),
1213
+ // 'keyframes', 'reset' (any other global rule), or null (stays with the
1214
+ // atomic classes).
1215
+ const classifyRule = (rule) => {
1216
+ const text = rule && rule.cssText
1217
+ if (typeof text !== 'string') return null
1218
+ if (/^@(-webkit-)?keyframes\b/.test(text)) return 'keyframes'
1219
+ // @font-face stays in the class stream (`css` / data-emotion), where every
1220
+ // consumer — including a mermaid that predates this split and emits only
1221
+ // rootRule/resetCss/keyframeRules + css — has always found it.
1222
+ if (text.startsWith('@font-face')) return null
1223
+ if (typeof rule.selectorText === 'string') {
1224
+ if (!isGlobalSelector(rule.selectorText)) return null
1225
+ return VARIABLE_SCOPE.test(rule.selectorText) ? 'root' : 'reset'
1226
+ }
1227
+ // A grouping rule (@media/@supports/@container) is global when all of its
1228
+ // rules are; it joins the group its rules agree on.
1229
+ const inner = rule.cssRules && Array.from(rule.cssRules)
1230
+ if (inner && inner.length && /^@(media|supports|container)\b/.test(text)) {
1231
+ const kinds = new Set(inner.map(classifyRule))
1232
+ if (kinds.has(null) || kinds.size !== 1) return kinds.has(null) ? null : 'reset'
1233
+ return [...kinds][0]
1234
+ }
1235
+ return null
1236
+ }
1194
1237
 
1195
- _cachedGlobalCSS = {
1196
- rootRule,
1197
- resetRules,
1198
- fontFaceCSS: '',
1199
- keyframeRules: keyframeRules.join('\n')
1200
- }
1201
- return _cachedGlobalCSS
1202
- } catch (err) {
1203
- console.warn('generateGlobalCSS failed:', err.message, err.stack)
1204
- _cachedGlobalCSS = { rootRule: '', resetRules: '', fontFaceCSS: '', keyframeRules: '' }
1205
- return _cachedGlobalCSS
1238
+ export const splitCapturedCSS = (rules) => {
1239
+ const groups = { root: [], reset: [], keyframes: [] }
1240
+ const rest = []
1241
+ for (const rule of rules || []) {
1242
+ const kind = classifyRule(rule)
1243
+ if (kind) groups[kind].push(rule.cssText)
1244
+ else if (rule && rule.cssText) rest.push(rule.cssText)
1245
+ }
1246
+ return {
1247
+ globalCSS: {
1248
+ rootRule: groups.root.join('\n'),
1249
+ resetRules: groups.reset.join('\n'),
1250
+ keyframeRules: groups.keyframes.join('\n'),
1251
+ fontFaceCSS: ''
1252
+ },
1253
+ classRules: rest
1206
1254
  }
1207
1255
  }
1208
1256
 
@@ -1216,7 +1264,7 @@ let _accumulatedEmotionCSS = new Set()
1216
1264
  /**
1217
1265
  * Reset the cached global CSS and emotion CSS (useful when rendering multiple projects).
1218
1266
  */
1219
- export const resetGlobalCSSCache = () => { _cachedGlobalCSS = null; _accumulatedEmotionCSS = new Set() }
1267
+ export const resetGlobalCSSCache = () => { _accumulatedEmotionCSS = new Set() }
1220
1268
 
1221
1269
  /**
1222
1270
  * Returns the complete accumulated emotion CSS from all renders so far.
@@ -1248,15 +1296,17 @@ export const replaceEmotionCSS = (html, newCSS) => {
1248
1296
  * @returns {Promise<{ html: string, css: string, resetCss: string, fontLinks: string, metadata: object, brKeyCount: number }>}
1249
1297
  */
1250
1298
  export const renderRoute = async (data, options = {}) => {
1251
- const { route = '/', pathname } = options
1299
+ // `prefetch: false` renders without the DB prefetch and translation fetch
1300
+ // — the publish-time pre-render (smbls publish → ssrCache) renders data-free.
1301
+ const { route = '/', pathname, prefetch = true } = options
1252
1302
 
1253
1303
  // Use the full render pipeline which handles polyglot, prefetch, emotion, etc.
1254
1304
  // Pass pathname so the virtual DOM location reflects the actual URL (for dynamic routes)
1255
- const result = await render(data, { route, pathname, prefetch: true })
1305
+ const result = await render(data, { route, pathname, prefetch })
1256
1306
  if (!result) return null
1257
1307
 
1258
1308
  const ds = data.designSystem || {}
1259
- const globalCSS = await generateGlobalCSS(ds, pickProjectConfig(data))
1309
+ const globalCSS = result.globalCSS
1260
1310
 
1261
1311
  // Extract prefetched state and language for metadata resolution
1262
1312
  let prefetchedState = null
@@ -1294,13 +1344,18 @@ export const renderRoute = async (data, options = {}) => {
1294
1344
 
1295
1345
  return {
1296
1346
  html: result.html,
1297
- css: result.emotionCSS ? result.emotionCSS.join('\n') : '',
1347
+ css: result.classRules.join('\n'),
1348
+ // the same rules as a list — a caller that unions several routes' CSS
1349
+ // (the publish-time ssrCache) dedupes by rule
1350
+ classRules: result.classRules.slice(),
1298
1351
  globalCSS,
1299
- resetCss: globalCSS.resetRules || generateResetCSS(ds.reset),
1352
+ resetCss: globalCSS.resetRules,
1300
1353
  fontLinks: generateFontLinks(ds),
1301
1354
  metadata: result.metadata || extractMetadata(data, route),
1302
1355
  brKeyCount: result.registry ? Object.keys(result.registry).length : 0,
1303
1356
  brRegistry: result.brRegistry || {},
1357
+ // anonymous GET answers this render got (see `_seed`) — for adoptionScripts
1358
+ fetchSeed: result.fetchSeed || [],
1304
1359
  ssrTranslations: result.ssrTranslations,
1305
1360
  prefetchedState,
1306
1361
  activeLang
@@ -1322,12 +1377,23 @@ export const renderRoute = async (data, options = {}) => {
1322
1377
  * @param {string} [options.lang='en'] - HTML lang attribute
1323
1378
  * @param {string} [options.themeColor] - theme-color meta
1324
1379
  * @param {object} [options.isr] - ISR options with clientScript path
1325
- * @param {boolean} [options.hydrate=true] - Use true hydration (attach to existing DOM) instead of full SPA re-render
1380
+ * @param {boolean} [options.hydrate=true] - With `isr`: boot the client over the
1381
+ * pre-rendered markup (`__BRENDER__`; the client replaces it with a fresh
1382
+ * render) instead of the legacy swap script
1383
+ * @param {boolean} [options.adoptDom=false] - Opt-in hydration: also ship the
1384
+ * element-path registry, `__BRENDER_HYDRATE__` and the recorded anonymous
1385
+ * fetch answers, so the client ADOPTS the server DOM (smbls hydrateRender)
1326
1386
  * @param {boolean} [options.prefetch=true] - Whether to prefetch data via DB adapter
1327
- * @returns {Promise<{ html: string, route: string, brKeyCount: number }>}
1387
+ * @param {string} [options.shell] - A built app document (e.g. dist/index.html)
1388
+ * to render INTO instead of emitting a standalone document (see shell.js).
1389
+ * The result then carries `shell: true`.
1390
+ * @param {number} [options.depth] - Directory depth of the output file: rebases
1391
+ * the shell's relative asset URLs (with `shell`, default 0) and the ISR
1392
+ * client script path (default: derived from the route).
1393
+ * @returns {Promise<{ html: string, route: string, brKeyCount: number, shell?: true }>}
1328
1394
  */
1329
1395
  export const renderPage = async (data, route = '/', options = {}) => {
1330
- const { lang, themeColor, isr, hydrate = true, prefetch = true } = options
1396
+ const { lang, themeColor, isr, hydrate = true, prefetch = true, shell, adoptDom = false } = options
1331
1397
 
1332
1398
  // Detect lang from project config, app metadata, or default
1333
1399
  const htmlLang = lang || data.state?.lang || data.app?.metadata?.lang || 'en'
@@ -1338,22 +1404,37 @@ export const renderPage = async (data, route = '/', options = {}) => {
1338
1404
 
1339
1405
  const metadata = { ...result.metadata }
1340
1406
  if (themeColor) metadata['theme-color'] = themeColor
1407
+ // Rendering into a built shell: a title the project never declared is
1408
+ // extractMetadata's fallback (`data.name || 'Symbols'`), and it must not
1409
+ // replace the title the shell's author wrote — the client keeps that one
1410
+ // too (helmet applies declared metadata only). Probed by handing
1411
+ // extractMetadata a name nothing else can produce; the element and state
1412
+ // go along so a function-valued title still counts as declared.
1413
+ if (typeof shell === 'string' && !data.name) {
1414
+ const probe = `\u0000brender-undeclared-title\u0000`
1415
+ const probed = extractMetadata({ ...data, name: probe }, route, result.element, result.element?.state)
1416
+ if (probed.title === probe) {
1417
+ for (const key of ['title', 'og:title', 'twitter:title']) {
1418
+ if (metadata[key] === result.metadata.title) delete metadata[key]
1419
+ }
1420
+ }
1421
+ }
1341
1422
  const headTags = generateHeadHtml(metadata)
1342
1423
 
1343
1424
  // Accumulate emotion CSS from each page render.
1344
1425
  // Each page may introduce unique CSS classes not seen on previous pages.
1345
1426
  // Emotion's singleton cache only emits NEW classes per render, so we
1346
1427
  // collect all rules across renders to build the complete stylesheet.
1347
- if (result.emotionCSS && result.emotionCSS.length) {
1348
- for (const rule of result.emotionCSS) {
1349
- if (rule) _accumulatedEmotionCSS.add(rule)
1350
- }
1428
+ // Only the classes accumulate: the globals are this page's own (one :root,
1429
+ // one copy of each @keyframes — they used to pile up across pages).
1430
+ for (const rule of result.classRules) {
1431
+ if (rule) _accumulatedEmotionCSS.add(rule)
1351
1432
  }
1352
1433
  const emotionCSS = Array.from(_accumulatedEmotionCSS).join('\n')
1353
1434
 
1354
- // Generate global CSS (variables, reset, keyframes) via scratch pipeline
1435
+ // Global CSS (variables, reset, keyframes): this render's own init output
1355
1436
  const ds = data.designSystem || {}
1356
- const globalCSS = await generateGlobalCSS(ds, pickProjectConfig(data))
1437
+ const globalCSS = result.globalCSS
1357
1438
 
1358
1439
  // Generate font links from design system
1359
1440
  const fontLinks = generateFontLinks(ds)
@@ -1363,8 +1444,12 @@ export const renderPage = async (data, route = '/', options = {}) => {
1363
1444
  // ISR: include client SPA bundle for hydration + data fetching
1364
1445
  let isrBody = ''
1365
1446
  if (isr && isr.clientScript) {
1366
- // Calculate relative path from route directory to root
1367
- const depth = route === '/' ? 0 : route.replace(/^\/|\/$/g, '').split('/').length
1447
+ // Calculate relative path from route directory to root — or take the
1448
+ // caller's: the file a route is written to decides it ('/*' → 404.html
1449
+ // sits at the root, not in a '*' directory).
1450
+ const depth = Number.isInteger(options.depth)
1451
+ ? options.depth
1452
+ : route === '/' ? 0 : route.replace(/^\/|\/$/g, '').split('/').length
1368
1453
  const prefix = depth > 0 ? '../'.repeat(depth) : './'
1369
1454
 
1370
1455
  if (hydrate) {
@@ -1399,9 +1484,11 @@ export const renderPage = async (data, route = '/', options = {}) => {
1399
1484
  const brRegistryJson = result.brRegistry && Object.keys(result.brRegistry).length
1400
1485
  ? JSON.stringify(result.brRegistry)
1401
1486
  : null
1402
- const brRegistryScript = brRegistryJson
1403
- ? `<script>window.__BR_REGISTRY__=${brRegistryJson}</script>\n`
1404
- : ''
1487
+ const brRegistryScript = adoptDom
1488
+ ? (adoptionScripts(result) ? adoptionScripts(result) + '\n' : '')
1489
+ : brRegistryJson
1490
+ ? `<script>window.__BR_REGISTRY__=${brRegistryJson}</script>\n`
1491
+ : ''
1405
1492
  isrBody = `${translationSeed}${brRegistryScript}<script>window.__BRENDER__=true</script>
1406
1493
  <script type="module" src="${prefix}${isr.clientScript}"></script>`
1407
1494
  } else {
@@ -1446,13 +1533,33 @@ export const renderPage = async (data, route = '/', options = {}) => {
1446
1533
  })
1447
1534
  }
1448
1535
 
1536
+ // Build path: render INTO the app's own built document (shell.js) — the
1537
+ // standalone document below carries no app code unless `isr` adds a
1538
+ // client bundle, so writing it over a bundler's dist/index.html shipped
1539
+ // pages that never booted.
1540
+ if (typeof shell === 'string') {
1541
+ const styles = [
1542
+ globalCSS.fontFaceCSS ? `<style data-brender>${globalCSS.fontFaceCSS}</style>` : '',
1543
+ `<style data-brender>\n${globalCSS.rootRule || ''}\n${globalCSS.resetRules || ''}\n${globalCSS.keyframeRules || ''}\n</style>`,
1544
+ emotionCSS ? `<style data-emotion="smbls">\n${emotionCSS}\n</style>` : ''
1545
+ ]
1546
+ const html = composeIntoShell(shell, {
1547
+ headTags: resolvedHeadTags,
1548
+ headEnd: [fontLinks, ...styles].filter(Boolean).join('\n'),
1549
+ body: result.html,
1550
+ depth: Number.isInteger(options.depth) ? options.depth : 0,
1551
+ bootScripts: adoptDom ? adoptionScripts(result) : ''
1552
+ })
1553
+ return { html, route, brKeyCount, shell: true }
1554
+ }
1555
+
1449
1556
  const html = `<!DOCTYPE html>
1450
1557
  <html lang="${htmlLang}">
1451
1558
  <head>
1452
1559
  ${resolvedHeadTags}
1453
1560
  ${fontLinks}
1454
- ${globalCSS.fontFaceCSS ? `<style>${globalCSS.fontFaceCSS}</style>` : ''}
1455
- <style>
1561
+ ${globalCSS.fontFaceCSS ? `<style data-brender>${globalCSS.fontFaceCSS}</style>` : ''}
1562
+ <style data-brender>
1456
1563
  ${globalCSS.rootRule || ''}
1457
1564
  ${globalCSS.resetRules || ''}
1458
1565
  ${globalCSS.keyframeRules || ''}
@@ -1860,33 +1967,6 @@ const extractCSS = (element, ds) => {
1860
1967
  return [...keyframes, ...rules].join('\n')
1861
1968
  }
1862
1969
 
1863
- const generateResetCSS = (reset) => {
1864
- if (!reset) return ''
1865
- const rules = []
1866
- for (const [selector, props] of Object.entries(reset)) {
1867
- if (!props || typeof props !== 'object') continue
1868
- const baseDecls = []
1869
- const mediaRules = []
1870
- for (const [k, v] of Object.entries(props)) {
1871
- if (typeof v === 'object' && v !== null) {
1872
- // Nested object: @media query or sub-selector
1873
- if (k.startsWith('@media') || k.startsWith('@')) {
1874
- const inner = Object.entries(v)
1875
- .filter(([, iv]) => typeof iv !== 'object')
1876
- .map(([ik, iv]) => `${camelToKebab(ik)}: ${iv}`)
1877
- .join('; ')
1878
- if (inner) mediaRules.push(`${k} { ${selector} { ${inner}; } }`)
1879
- }
1880
- continue
1881
- }
1882
- baseDecls.push(`${camelToKebab(k)}: ${v}`)
1883
- }
1884
- if (baseDecls.length) rules.push(`${selector} { ${baseDecls.join('; ')}; }`)
1885
- rules.push(...mediaRules)
1886
- }
1887
- return rules.join('\n')
1888
- }
1889
-
1890
1970
  const generateFontLinks = (ds) => {
1891
1971
  if (!ds) return ''
1892
1972
  const families = ds.font_family || ds.fontFamily || {}