@symbo.ls/brender 3.14.662 → 3.14.664

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
@@ -10,6 +10,9 @@ import { extractMetadata, generateHeadHtml } from './metadata.js'
10
10
  import { hydrate } from './hydrate.js'
11
11
  import { composeIntoShell } from './shell.js'
12
12
  import { prefetchPageData, injectPrefetchedState, fetchSSRTranslations } from './prefetch.js'
13
+ import { createEarlySeedScript } from '@symbo.ls/fetch'
14
+ import { headerEntries } from '@symbo.ls/fetch/rest'
15
+ import { jsonForScript } from '@symbo.ls/utils'
13
16
 
14
17
  // funcql plugin — enables evaluation of funcql schemas as property values
15
18
  // during SSR. Async-imported to avoid polluting non-funcql codepaths.
@@ -111,19 +114,6 @@ const structuredCloneDeep = (obj, seen = new WeakMap()) => {
111
114
  return clone
112
115
  }
113
116
 
114
- // JSON replacer that drops functions, circular refs, and non-serializable values
115
- const safeJsonReplacer = () => {
116
- const seen = new WeakSet()
117
- return (key, value) => {
118
- if (typeof value === 'function') return undefined
119
- if (typeof value === 'object' && value !== null) {
120
- if (seen.has(value)) return undefined
121
- seen.add(value)
122
- }
123
- return value
124
- }
125
- }
126
-
127
117
  // ── Workspace detection ──────────────────────────────────────────────────────
128
118
  // Detect whether brender is running inside the monorepo or as an installed
129
119
  // npm package, and resolve paths accordingly.
@@ -302,18 +292,10 @@ const bundleCreateDomql = async () => {
302
292
 
303
293
  const ws = detectWorkspace()
304
294
 
305
- if (!_brenderRequire) {
306
- try {
307
- _cachedCreateDomql = await importSmblsSsr()
308
- return _cachedCreateDomql
309
- } catch (err) {
310
- 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.`)
311
- }
312
- }
313
-
314
- // Resolve entry point
295
+ // Resolve entry point: the monorepo's source, else `smbls/ssr` (one import
296
+ // for both a bundled runtime and an installed smbls).
315
297
  let entry
316
- if (ws.isMonorepo) {
298
+ if (_brenderRequire && ws.isMonorepo) {
317
299
  entry = resolve(ws.monorepoRoot, 'packages', 'smbls', 'src', 'createDomql.js')
318
300
  } else {
319
301
  let ssrError
@@ -323,6 +305,9 @@ const bundleCreateDomql = async () => {
323
305
  } catch (err) {
324
306
  ssrError = err
325
307
  }
308
+ if (!_brenderRequire) {
309
+ throw new Error(`brender: ${SMBLS_SSR_SPECIFIER} is not available in this bundled runtime (${ssrError.message}). Bundle smbls ≥ the release that adds the smbls/ssr export alongside @symbo.ls/brender.`)
310
+ }
326
311
  // Legacy: an smbls that predates the ssr entry but still ships its source.
327
312
  const srcEntry = ws.smblsRoot && resolve(ws.smblsRoot, 'src', 'createDomql.js')
328
313
  if (srcEntry && existsSync(srcEntry)) {
@@ -642,24 +627,23 @@ const UIKIT_STUBS = {
642
627
  *
643
628
  * The registry is serialized into the pre-rendered HTML so the browser can
644
629
  * assign the correct brKey to each skeleton element during true hydration.
630
+ * A path is @symbo.ls/element elementPath — the very function the hydrating
631
+ * client looks each element up with (adoptHydrationNode), so the two sides
632
+ * cannot spell a path differently. One flat object, filled in place (the
633
+ * recursive walk merged a fresh object per element into its parent's).
645
634
  */
646
- const buildPathRegistry = (element, path = '') => {
647
- if (!element || !element.__ref) return {}
635
+ const buildPathRegistry = (element, elementPath) => {
648
636
  const registry = {}
649
-
650
- const brKey = element.__ref.__brKey
651
- if (brKey) registry[path === '' ? '__root' : path] = brKey
652
-
653
- if (element.__ref.__children) {
654
- for (const childKey of element.__ref.__children) {
655
- const child = element[childKey]
656
- if (child && child.__ref) {
657
- const childPath = path === '' ? childKey : `${path}.${childKey}`
658
- Object.assign(registry, buildPathRegistry(child, childPath))
659
- }
637
+ const walk = (el) => {
638
+ const ref = el && el.__ref
639
+ if (!ref) return
640
+ if (ref.__brKey) {
641
+ const path = elementPath(el)
642
+ registry[path === '' ? '__root' : path] = ref.__brKey
660
643
  }
644
+ if (ref.__children) for (const childKey of ref.__children) walk(el[childKey])
661
645
  }
662
-
646
+ walk(element)
663
647
  return registry
664
648
  }
665
649
 
@@ -678,6 +662,134 @@ const buildPathRegistry = (element, path = '') => {
678
662
  * @param {object} [options.context] - Additional context overrides
679
663
  * @returns {Promise<{ html: string, metadata: object, registry: object, element: object }>}
680
664
  */
665
+ // The payload keys render() puts on the context itself (below `ctx`): the
666
+ // generic copy of every other top-level key skips them — cloning one only for
667
+ // the explicit key to replace it was a deep clone of `functions`, `methods`
668
+ // and `files` thrown away on every render.
669
+ const CONTEXT_KEYS_SET_BELOW = new Set([
670
+ 'app', 'config', 'pages', 'components', 'state', 'snippets', 'designSystem', 'dependencies',
671
+ 'functions', 'methods', 'files', 'sharedLibraries'
672
+ ])
673
+
674
+ // ── In-flight request tracking (the post-create flush) ──────────────────────
675
+ // The render's flush waits for the requests a page starts while it renders
676
+ // (fetch plugin, SDK, project code — all end in globalThis.fetch). One
677
+ // counting wrapper is installed around whatever globalThis.fetch is at render
678
+ // time and left in place: it returns the inner call's own promise untouched,
679
+ // and re-installing per render would nest wrappers across concurrent renders
680
+ // in one isolate. A host that swaps globalThis.fetch later gets wrapped again
681
+ // on its next render. The count is process-wide — a concurrent render's
682
+ // request can only make a flush wait longer, never cut one short, and the
683
+ // flush is capped.
684
+ let _inFlight = 0
685
+ const FETCH_TRACKED = Symbol.for('brender.fetchTracked')
686
+ const inFlightRequests = () => _inFlight
687
+ // ── Recorded answers (the fetch seed, phase B) ──────────────────────────────
688
+ // While exactly ONE render is running, the anonymous GETs it makes are
689
+ // recorded — url, Accept-Language, status, content type, body — so the page
690
+ // can carry them (@symbo.ls/fetch createEarlySeedScript) and the hydrating
691
+ // client adopts them instead of asking again. A second render overlapping
692
+ // in the same isolate taints the recording: another project's answers must
693
+ // never land in this page, so an overlapped render carries none.
694
+ // Only a GET whose headers are Accept-Language / Accept at most is recorded
695
+ // (never a signed-in or keyed call), only an ok JSON or text answer, at most
696
+ // SEED_ENTRY_MAX bytes each and SEED_TOTAL_MAX per page.
697
+ const SEED_ENTRY_MAX = 256 * 1024
698
+ const SEED_TOTAL_MAX = 1024 * 1024
699
+ const SEED_HEADERS_OK = new Set(['accept-language', 'accept'])
700
+ let _activeRenders = 0
701
+ let _seed = null
702
+
703
+ const requestShape = (args) => {
704
+ const [input, init] = args
705
+ const isRequest = input && typeof input === 'object' && typeof input.url === 'string'
706
+ const url = isRequest ? input.url : String(input)
707
+ const method = String((init && init.method) || (isRequest && input.method) || 'GET').toUpperCase()
708
+ const headers = {}
709
+ // Any fetch HeadersInit, read like the REST adapter reads one (headerEntries).
710
+ // A plain object's null / undefined value is no header.
711
+ const add = (h) => {
712
+ if (!h) return
713
+ const plain = !Array.isArray(h) && typeof h.entries !== 'function'
714
+ for (const [k, v] of headerEntries(h)) {
715
+ if (plain && v == null) continue
716
+ headers[String(k).toLowerCase()] = String(v)
717
+ }
718
+ }
719
+ if (isRequest) add(input.headers)
720
+ if (init) add(init.headers)
721
+ return { url, method, headers }
722
+ }
723
+
724
+ const recordAnswer = (seed, shape, response, track) => {
725
+ if (!response || !response.ok || typeof response.clone !== 'function') return
726
+ const type = (response.headers && response.headers.get && response.headers.get('content-type')) || ''
727
+ if (!/json|^text\//i.test(type)) return
728
+ let copy
729
+ try { copy = response.clone() } catch { return }
730
+ track(copy.text().then((body) => {
731
+ if (seed.tainted || body.length > SEED_ENTRY_MAX || seed.bytes + body.length > SEED_TOTAL_MAX) return
732
+ seed.bytes += body.length
733
+ seed.entries.push({ url: shape.url, lang: shape.headers['accept-language'] || '', status: response.status, type, body })
734
+ }, () => {}))
735
+ }
736
+
737
+ const trackFetches = () => {
738
+ const inner = globalThis.fetch
739
+ if (typeof inner !== 'function' || inner[FETCH_TRACKED]) return
740
+ const tracked = function (...args) {
741
+ _inFlight++
742
+ let settled = false
743
+ const done = () => { if (!settled) { settled = true; _inFlight-- } }
744
+ // a pending body read counts as in flight: the flush waits for it too
745
+ const track = (promise) => { _inFlight++; promise.then(() => { _inFlight-- }, () => { _inFlight-- }) }
746
+ const seed = _seed && !_seed.tainted ? _seed : null
747
+ let shape = null
748
+ if (seed) {
749
+ try {
750
+ shape = requestShape(args)
751
+ if (shape.method !== 'GET' || !/^https?:/i.test(shape.url) ||
752
+ Object.keys(shape.headers).some((h) => !SEED_HEADERS_OK.has(h))) shape = null
753
+ } catch { shape = null }
754
+ }
755
+ let result
756
+ try {
757
+ result = inner.apply(this, args)
758
+ } catch (err) {
759
+ done()
760
+ throw err
761
+ }
762
+ if (result && typeof result.then === 'function') {
763
+ result.then((response) => {
764
+ if (shape) recordAnswer(seed, shape, response, track)
765
+ done()
766
+ }, done)
767
+ } else done()
768
+ return result
769
+ }
770
+ tracked[FETCH_TRACKED] = true
771
+ globalThis.fetch = tracked
772
+ }
773
+
774
+ // ── Opt-in hydration boot scripts (phase B) ────────────────────────────────
775
+ // What a page needs for the client to ADOPT the server DOM instead of
776
+ // replacing it (smbls createDomql hydrateRender): the element-path registry,
777
+ // the opt-in flag, and the recorded anonymous answers for the fetch plugin
778
+ // (@symbo.ls/fetch createEarlySeedScript). Default off everywhere — a page
779
+ // without these keeps the fresh client render.
780
+ // FW-BRENDER-HYDRATION-AND-CF-DEPLOY-PROVIDER-1.
781
+ // `hydrateFlag: false` — the registry alone, for a client that replaces the
782
+ // server DOM but still reads the registry (renderPage's default ISR path). It
783
+ // inlined its own JSON.stringify there, without the `<` escape, so an element
784
+ // key carrying `</script>` closed the tag.
785
+ export const adoptionScripts = ({ brRegistry, fetchSeed, hydrateFlag = true } = {}) => {
786
+ if (!brRegistry || !Object.keys(brRegistry).length) return ''
787
+ const registry = jsonForScript(brRegistry)
788
+ if (!hydrateFlag) return `<script>window.__BR_REGISTRY__=${registry}</script>`
789
+ return `<script>window.__BR_REGISTRY__=${registry};window.__BRENDER_HYDRATE__=true</script>` +
790
+ (fetchSeed && fetchSeed.length ? '\n' + createEarlySeedScript(fetchSeed) : '')
791
+ }
792
+
681
793
  export const render = async (data, options = {}) => {
682
794
  const { route = '/', pathname, state: stateOverrides, context: contextOverrides, prefetch = false } = options
683
795
  // pathname is the actual URL path (e.g. /podcast/abc-123), route is the page pattern (e.g. /podcast/:id)
@@ -773,7 +885,7 @@ export const render = async (data, options = {}) => {
773
885
  if (data.polyglot && !config.polyglot) config.polyglot = data.polyglot
774
886
  if (data.fetch && !config.fetch) config.fetch = data.fetch
775
887
  if (data.router && !config.router) config.router = data.router
776
- for (const k of ['useReset', 'useVariable', 'useFontImport', 'useIconSprite', 'useSvgSprite', 'useDefaultConfig', 'useDocumentTheme']) {
888
+ for (const k of SCRATCH_CONFIG_FLAGS) {
777
889
  if (data[k] != null && config[k] == null) config[k] = data[k]
778
890
  }
779
891
 
@@ -804,7 +916,24 @@ export const render = async (data, options = {}) => {
804
916
  // Reset the atomic CSS engine for this render pass
805
917
  resetCss()
806
918
 
919
+ // Every other top-level key of the payload, as the client gets it: the
920
+ // client boots with the WHOLE published payload as its context (mermaid
921
+ // bundle.js), so `globalScope` (frank's hoisted helpers behind `__scope.X`)
922
+ // and project-level context keys (`catalog`, `imageBase`, `siteUrl`, …)
923
+ // must be there on the server too — without them docs.symbols.app threw
924
+ // "__scope.isViewNavRoute is not a function" and shaker.ge read
925
+ // `this.context.catalog` as undefined (FW-BRENDER-HYDRATION-AND-CF-DEPLOY-
926
+ // PROVIDER-1). Cloned: a render must not write into a payload a multi-page
927
+ // build reuses. The explicit keys below still win.
928
+ const projectContext = {}
929
+ for (const key in data) {
930
+ if (CONTEXT_KEYS_SET_BELOW.has(key)) continue
931
+ const value = data[key]
932
+ projectContext[key] = value && typeof value === 'object' ? structuredCloneDeep(value) : value
933
+ }
934
+
807
935
  const ctx = {
936
+ ...projectContext,
808
937
  state: baseState,
809
938
  ...(stateOverrides ? { state: { ...baseState, ...stateOverrides } } : {}),
810
939
  dependencies: structuredCloneDeep(data.dependencies || {}),
@@ -857,14 +986,43 @@ export const render = async (data, options = {}) => {
857
986
 
858
987
  resetKeys()
859
988
 
860
- const element = await createDomqlElement(app, ctx)
861
-
862
- // Allow async operations (fetch callbacks, state updates, re-renders) to flush.
863
- // DOMQL's fetch plugin fires on element creation and updates state asynchronously.
864
- // With prefetch enabled, data is pre-injected but DOMQL's fetch may also fire
865
- // and trigger state updates. Give enough time for these to complete.
866
- const flushDelay = prefetch ? 2000 : 50
867
- await new Promise(r => setTimeout(r, flushDelay))
989
+ trackFetches()
990
+ // the recording of this render's answers (see `_seed`): tainted the moment
991
+ // 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
996
+ let element
997
+ try {
998
+ element = await createDomqlElement(app, ctx)
999
+
1000
+ // 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.
1004
+ //
1005
+ // It used to sleep a flat 2000 ms with prefetch — measured in workerd on
1006
+ // six published projects, `flush` was 2000 ms on every route, 40–90 % of
1007
+ // each platform pre-render. Now: a 50 ms quiet period, then — with
1008
+ // prefetch — keep waiting while requests are in flight (the answers the
1009
+ // flush exists for), each settled batch followed by another quiet period
1010
+ // for the state updates and follow-up requests it triggers. The old
1011
+ // 2000 ms stays the cap (FW-BRENDER-HYDRATION-AND-CF-DEPLOY-PROVIDER-1).
1012
+ const flushStart = Date.now()
1013
+ const flushCap = prefetch ? 2000 : 50
1014
+ const sleep = (ms) => new Promise(r => setTimeout(r, ms))
1015
+ await sleep(Math.min(50, flushCap))
1016
+ while (prefetch && inFlightRequests() > 0 && Date.now() - flushStart < flushCap) {
1017
+ await sleep(10)
1018
+ // a settled batch: another quiet period for what it triggers
1019
+ if (inFlightRequests() === 0) await sleep(Math.max(0, Math.min(50, flushCap - (Date.now() - flushStart))))
1020
+ }
1021
+ } finally {
1022
+ _activeRenders--
1023
+ if (_seed === seed) _seed = null
1024
+ }
1025
+ const fetchSeed = seed && !seed.tainted ? seed.entries.slice() : []
868
1026
 
869
1027
  // Assign data-br keys for hydration
870
1028
  assignKeys(body)
@@ -873,7 +1031,8 @@ export const render = async (data, options = {}) => {
873
1031
 
874
1032
  // Build element path → brKey registry for client-side true hydration.
875
1033
  // Walks the element tree after mapKeysToElements has set __brKey on each element.
876
- const brRegistry = buildPathRegistry(element)
1034
+ const { elementPath } = await import('@symbo.ls/element')
1035
+ const brRegistry = buildPathRegistry(element, elementPath)
877
1036
 
878
1037
  // Extract metadata for the rendered route
879
1038
  // Pass the rendered element and its state so function-valued metadata
@@ -881,21 +1040,25 @@ export const render = async (data, options = {}) => {
881
1040
  const metadata = extractMetadata(data, route, element, element?.state)
882
1041
 
883
1042
  // Extract CSS from style tags in virtual head (atomic CSS engine writes here)
884
- const emotionCSS = []
1043
+ const capturedRules = []
885
1044
  const head = document.head || document.querySelector('head')
886
1045
  if (head) {
887
1046
  for (const style of head.querySelectorAll('style')) {
888
1047
  if (style.sheet && style.sheet.cssRules) {
889
1048
  for (const rule of style.sheet.cssRules) {
890
- if (rule.cssText) emotionCSS.push(rule.cssText)
1049
+ if (rule.cssText) capturedRules.push(rule)
891
1050
  }
892
1051
  }
893
- if (!emotionCSS.length) {
1052
+ if (!capturedRules.length) {
894
1053
  const content = style.textContent || ''
895
- if (content) emotionCSS.push(content)
1054
+ if (content) capturedRules.push({ cssText: content })
896
1055
  }
897
1056
  }
898
1057
  }
1058
+ const emotionCSS = capturedRules.map((rule) => rule.cssText)
1059
+ // globalCSS: the init-injected globals; classRules: everything else, in
1060
+ // capture order (see splitCapturedCSS).
1061
+ const { globalCSS, classRules } = splitCapturedCSS(capturedRules)
899
1062
 
900
1063
  let html = fixSvgContent(body.innerHTML)
901
1064
 
@@ -916,7 +1079,7 @@ export const render = async (data, options = {}) => {
916
1079
  if (_prevLoc !== undefined) globalThis.location = _prevLoc
917
1080
  else delete globalThis.location
918
1081
 
919
- return { html, metadata, registry, brRegistry, element, emotionCSS, document, window, ssrTranslations, prefetchedPages }
1082
+ return { html, metadata, registry, brRegistry, element, emotionCSS, globalCSS, classRules, fetchSeed, document, window, ssrTranslations, prefetchedPages }
920
1083
  }
921
1084
 
922
1085
  /**
@@ -990,268 +1153,92 @@ const fixSvgContent = (html) => {
990
1153
  )
991
1154
  }
992
1155
 
993
- // ── Global CSS generation ─────────────────────────────────────────────────────
994
-
995
- /**
996
- * Runs the scratch design-system pipeline (via esbuild bundling to work around
997
- * bare-import issues) to produce CSS variables and reset styles — the same
998
- * globals that the SPA runtime injects via emotion.injectGlobal.
999
- */
1000
- let _cachedGlobalCSS = null
1001
-
1002
1156
  // Frank serializes a project's `config.js` exports at the TOP LEVEL of the
1003
1157
  // data payload (not under `data.config`), so `globalTheme` / `themeStorageKey`
1004
1158
  // / `useReset` / etc. live alongside `components` / `pages` / `designSystem`.
1005
- // Both renderRoute and renderPage used to call `generateGlobalCSS(ds,
1006
- // data.config || data.settings)` which resolved to undefined for any project
1007
- // pushed through frank — every config flag silently dropped, including
1008
- // `globalTheme: 'light'`. The hardcoded `globalTheme: 'auto'` default in
1009
- // generateGlobalCSS then won, prod always rendered in matchMedia-detected
1010
- // theme regardless of what the project declared. Helper picks the config
1011
- // flags from `data` whichever shape they arrive in.
1159
+ // render() lifts these into the render context, as the client's context
1160
+ // carries them — the global CSS is captured from that render, so a project's
1161
+ // `globalTheme: 'light'` decides which scheme lands in the :root fallback.
1012
1162
  const SCRATCH_CONFIG_FLAGS = [
1013
1163
  'globalTheme', 'themeStorageKey', 'themeRoot',
1014
1164
  'useReset', 'useVariable', 'useFontImport', 'useIconSprite', 'useSvgSprite',
1015
1165
  'useDocumentTheme', 'useDefaultConfig', 'useDefaultIcons',
1016
1166
  'useThemeSuffixedVars', 'verbose', 'semanticIcons',
1017
1167
  ]
1018
- const pickProjectConfig = (data) => {
1019
- if (!data || typeof data !== 'object') return null
1020
- if (data.config && typeof data.config === 'object') return data.config
1021
- const out = {}
1022
- let any = false
1023
- for (const flag of SCRATCH_CONFIG_FLAGS) {
1024
- if (Object.prototype.hasOwnProperty.call(data, flag)) {
1025
- out[flag] = data[flag]
1026
- any = true
1027
- }
1028
- }
1029
- if (any) return out
1030
- return data.settings || null
1031
- }
1032
1168
 
1033
- // Serialise scratch `buildSkinVarStyles` output — `{ selector: { var: value },
1034
- // '@media …': { selector: { var: value } } }` — as CSS text
1035
- // (FW-SCRATCH-SKINS-DIMENSION-1).
1036
- const declarationBlock = (selector, vars, indent) => {
1037
- const decls = Object.entries(vars || {})
1038
- .map(([k, v]) => `${indent} ${k}: ${v}`)
1039
- .join(';\n')
1040
- return decls ? `${indent}${selector} {\n${decls};\n${indent}}` : ''
1169
+ // ── Global CSS: the render's own capture, split ────────────────────────────
1170
+ // The rules smbls init injects into the virtual <head> during render() ARE
1171
+ // the page's global CSS — the :root variables, scheme and skin blocks, the
1172
+ // processed reset and every @keyframes — exactly what the client injects,
1173
+ // for THIS project and render. They are split off the captured sheet so a
1174
+ // page emits them once, ahead of the atomic classes, and so renderPage's
1175
+ // cross-page accumulation keeps only the classes.
1176
+ //
1177
+ // This replaced generateGlobalCSS, which re-derived the same CSS by bundling
1178
+ // a temp module with esbuild into tmpdir. It read data.RESET/data.ANIMATION
1179
+ // where the module exported reset/animation (resetRules and keyframeRules
1180
+ // were always '', and renderRoute fell back to the RAW design-system reset,
1181
+ // tokens unresolved); it threw on every cold Cloudflare isolate ("[unenv]
1182
+ // fs.writeFileSync is not implemented yet"); and its result was cached per
1183
+ // PROCESS, keyed on nothing, so a server rendering two projects gave the
1184
+ // second one the first one's :root (FW-BRENDER-HYDRATION-AND-CF-DEPLOY-PROVIDER-1).
1185
+
1186
+ // A selector is global when no selector in its list targets a class — the
1187
+ // atomic engine's every rule does (`._p-a`, `.c1x:hover`, `[data-theme] .c2`).
1188
+ // Quoted strings and attribute brackets are masked first: a dot inside
1189
+ // `[href$=".pdf"]` is not a class.
1190
+ const CLASS_IN_SELECTOR = /\.[A-Za-z_\\-]/
1191
+ const isGlobalSelector = (selectorText) => {
1192
+ if (typeof selectorText !== 'string' || !selectorText) return false
1193
+ const masked = selectorText
1194
+ .replace(/"(?:[^"\\]|\\.)*"|'(?:[^'\\]|\\.)*'/g, '""')
1195
+ .replace(/\[[^\]]*\]/g, '[]')
1196
+ return !CLASS_IN_SELECTOR.test(masked)
1197
+ }
1198
+ const VARIABLE_SCOPE = /^\s*(:root|\[data-theme|\[data-skin)/
1199
+
1200
+ // Classify one CSSOM rule: 'root' (variables, scheme and skin blocks),
1201
+ // 'keyframes', 'reset' (any other global rule), or null (stays with the
1202
+ // atomic classes).
1203
+ const classifyRule = (rule) => {
1204
+ const text = rule && rule.cssText
1205
+ if (typeof text !== 'string') return null
1206
+ if (/^@(-webkit-)?keyframes\b/.test(text)) return 'keyframes'
1207
+ // @font-face stays in the class stream (`css` / data-emotion), where every
1208
+ // consumer — including a mermaid that predates this split and emits only
1209
+ // rootRule/resetCss/keyframeRules + css — has always found it.
1210
+ if (text.startsWith('@font-face')) return null
1211
+ if (typeof rule.selectorText === 'string') {
1212
+ if (!isGlobalSelector(rule.selectorText)) return null
1213
+ return VARIABLE_SCOPE.test(rule.selectorText) ? 'root' : 'reset'
1214
+ }
1215
+ // A grouping rule (@media/@supports/@container) is global when all of its
1216
+ // rules are; it joins the group its rules agree on.
1217
+ const inner = rule.cssRules && Array.from(rule.cssRules)
1218
+ if (inner && inner.length && /^@(media|supports|container)\b/.test(text)) {
1219
+ const kinds = new Set(inner.map(classifyRule))
1220
+ if (kinds.has(null) || kinds.size !== 1) return kinds.has(null) ? null : 'reset'
1221
+ return [...kinds][0]
1222
+ }
1223
+ return null
1041
1224
  }
1042
1225
 
1043
- export const skinRulesToCSS = (rules) => Object.entries(rules || {})
1044
- .map(([key, body]) => {
1045
- if (!key.startsWith('@')) return declarationBlock(key, body, '')
1046
- const inner = Object.entries(body || {})
1047
- .map(([selector, vars]) => declarationBlock(selector, vars, ' '))
1048
- .filter(Boolean)
1049
- .join('\n')
1050
- return inner ? `${key} {\n${inner}\n}` : ''
1051
- })
1052
- .filter(Boolean)
1053
- .join('\n\n')
1054
-
1055
- const generateGlobalCSS = async (ds, config) => {
1056
- if (_cachedGlobalCSS) return _cachedGlobalCSS
1057
-
1058
- try {
1059
- const { existsSync, writeFileSync, unlinkSync } = await import('fs')
1060
- const { tmpdir } = await import('os')
1061
- const { randomBytes } = await import('crypto')
1062
-
1063
- // Guard: skip if filesystem APIs aren't available (e.g. CF Workers)
1064
- try { tmpdir() } catch { return {} }
1065
-
1066
- const esbuild = await import('esbuild')
1067
-
1068
- // Write a temporary script that imports scratch, runs set(), and
1069
- // serialises the CSS_VARS + RESET objects as JSON.
1070
- const dsJson = JSON.stringify(ds || {}, safeJsonReplacer())
1071
- // Config may contain non-serializable values (e.g. a DB client with
1072
- // circular refs, functions). Strip those for the CSS generation script.
1073
- const cfgJson = JSON.stringify(config || {}, safeJsonReplacer())
1074
- const tmpEntry = join(tmpdir(), `br_global_${randomBytes(6).toString('hex')}.mjs`)
1075
- const tmpOut = join(tmpdir(), `br_global_${randomBytes(6).toString('hex')}_out.mjs`)
1076
-
1077
- writeFileSync(tmpEntry, `
1078
- import { set, getActiveConfig, getFontFaceString } from '@symbo.ls/scratch'
1079
- // Namespace access for the skin builder: a scratch without it (version
1080
- // skew) gives an empty skin block instead of failing this whole bundle
1081
- // and, with it, every global rule.
1082
- import * as scratchNs from '@symbo.ls/scratch'
1083
- import { DEFAULT_CONFIG } from '@symbo.ls/default-config'
1084
-
1085
- const ds = ${dsJson}
1086
- const cfg = ${cfgJson}
1087
-
1088
- // Merge with defaults (same as initEmotion)
1089
- const merged = {}
1090
- for (const k in DEFAULT_CONFIG) merged[k] = DEFAULT_CONFIG[k]
1091
- for (const k in ds) {
1092
- if (typeof ds[k] === 'object' && !Array.isArray(ds[k]) && typeof merged[k] === 'object' && !Array.isArray(merged[k])) {
1093
- merged[k] = { ...merged[k], ...ds[k] }
1094
- } else {
1095
- merged[k] = ds[k]
1096
- }
1097
- }
1098
-
1099
- const conf = set({
1100
- useReset: true,
1101
- useVariable: true,
1102
- useFontImport: true,
1103
- useDocumentTheme: true,
1104
- useDefaultConfig: true,
1105
- globalTheme: 'auto',
1106
- ...merged,
1107
- ...cfg
1108
- }, { newConfig: {} })
1109
-
1110
- const result = {
1111
- CSS_VARS: conf.CSS_VARS || {},
1112
- CSS_MEDIA_VARS: conf.CSS_MEDIA_VARS || {},
1113
- // Skin rules — the same builder smbls init() injects on the client
1114
- // (FW-SCRATCH-SKINS-DIMENSION-1).
1115
- CSS_SKIN_RULES: typeof scratchNs.buildSkinVarStyles === 'function'
1116
- ? scratchNs.buildSkinVarStyles(conf.cssSkinVars, conf.cssSkinFallback, ':root')
1117
- : {},
1118
- reset: conf.reset || {},
1119
- animation: conf.animation || {}
1120
- }
1121
- // Export as globalThis so we can read it
1122
- globalThis.__BR_GLOBAL_CSS__ = result
1123
- export default result
1124
- `)
1125
-
1126
- // Detect workspace layout (monorepo vs npm install)
1127
- const ws = detectWorkspace()
1128
-
1129
- // Workspace resolve plugin: maps @symbo.ls/* and @symbo.ls/* to source paths
1130
- const workspacePlugin = {
1131
- name: 'workspace-resolve',
1132
- setup (build) {
1133
- build.onResolve({ filter: /^@symbo\.ls\// }, args => {
1134
- const pkg = args.path.replace('@symbo.ls/', '')
1135
- if (ws.isMonorepo) {
1136
- for (const dir of ['packages', 'plugins']) {
1137
- const src = resolve(ws.monorepoRoot, dir, pkg, 'src', 'index.js')
1138
- if (existsSync(src)) return { path: src }
1139
- const dist = resolve(ws.monorepoRoot, dir, pkg, 'index.js')
1140
- if (existsSync(dist)) return { path: dist }
1141
- }
1142
- const blank = resolve(ws.monorepoRoot, 'packages', 'default-config', 'blank', 'index.js')
1143
- if (pkg === 'default-config' && existsSync(blank)) return { path: blank }
1144
- }
1145
- // An npm install: no override — esbuild resolves the package's
1146
- // published `exports` (dist/esm). Forcing the root index.js here
1147
- // broke every npm consumer: published @symbo.ls/scratch and
1148
- // @symbo.ls/default-config ship a root index.js that re-exports
1149
- // ./src/… and ./designSystem/…, which their tarballs do not
1150
- // contain, so this bundle failed ("Could not resolve
1151
- // ./src/index.js") and every page lost its :root variables, reset
1152
- // and @keyframes (found with the xma.info report item 1 repro).
1153
- })
1154
- build.onResolve({ filter: /^@domql\// }, args => {
1155
- const pkg = args.path.replace('@symbo.ls/', '')
1156
- if (ws.isMonorepo) {
1157
- const src = resolve(ws.monorepoRoot, 'packages', 'domql', 'packages', pkg, 'src', 'index.js')
1158
- if (existsSync(src)) return { path: src }
1159
- } else {
1160
- const resolved = resolveDomqlPackage(ws, pkg, 'src', 'index.js')
1161
- if (resolved && existsSync(resolved)) return { path: resolved }
1162
- const resolvedIdx = resolveDomqlPackage(ws, pkg, 'index.js')
1163
- if (resolvedIdx && existsSync(resolvedIdx)) return { path: resolvedIdx }
1164
- }
1165
- })
1166
- }
1167
- }
1168
-
1169
- await esbuild.build({
1170
- entryPoints: [tmpEntry],
1171
- bundle: true,
1172
- format: 'esm',
1173
- platform: 'node',
1174
- outfile: tmpOut,
1175
- write: true,
1176
- logLevel: 'silent',
1177
- plugins: [workspacePlugin],
1178
- nodePaths: ws.isMonorepo
1179
- ? [resolve(ws.monorepoRoot, 'node_modules')]
1180
- : [
1181
- ...(ws.smblsRoot ? [resolve(ws.smblsRoot, 'node_modules')] : []),
1182
- ...(ws.projectRoot ? [resolve(ws.projectRoot, 'node_modules')] : []),
1183
- ...(ws.smblsRoot ? [resolve(ws.smblsRoot, '..', '..', 'node_modules')] : [])
1184
- ].filter(p => existsSync(p)),
1185
- 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']
1186
- })
1187
-
1188
- const mod = await import(`file://${tmpOut}`)
1189
- const data = mod.default || {}
1190
- try { unlinkSync(tmpEntry) } catch {} // cleanup: ignore if temp file already removed
1191
- try { unlinkSync(tmpOut) } catch {} // cleanup: ignore if temp file already removed
1192
-
1193
- const cssVars = data.CSS_VARS || {}
1194
- const cssMediaVars = data.CSS_MEDIA_VARS || {}
1195
- const reset = data.RESET || {}
1196
- const animations = data.ANIMATION || {}
1197
-
1198
- // ── :root CSS variables ──
1199
- const varDecls = Object.entries(cssVars)
1200
- .map(([k, v]) => ` ${k}: ${v}`)
1201
- .join(';\n')
1202
- let rootRule = varDecls ? `:root {\n${varDecls};\n}` : ''
1203
-
1204
- // ── Theme-switching CSS vars (media queries + data-theme selectors) ──
1205
- const themeVarRules = Object.entries(cssMediaVars)
1206
- .map(([key, vars]) => {
1207
- const decls = Object.entries(vars)
1208
- .map(([k, v]) => ` ${k}: ${v}`)
1209
- .join(';\n')
1210
- if (!decls) return ''
1211
- if (key.startsWith('@media')) {
1212
- // Media query — only when no data-theme forces a theme
1213
- return `${key} {\n :root:not([data-theme]) {\n${decls};\n }\n}`
1214
- }
1215
- // Selector ([data-theme="..."]) — apply directly
1216
- return `${key} {\n${decls};\n}`
1217
- })
1218
- .filter(Boolean)
1219
- .join('\n\n')
1220
- if (themeVarRules) rootRule += '\n\n' + themeVarRules
1221
-
1222
- // ── Skin rules ([data-skin] selectors + the reduced-transparency block) ──
1223
- // After the scheme blocks: on an equal specificity the skin wins, as it
1224
- // does in the hydrated page (smbls init injects them in the same order).
1225
- const skinRules = skinRulesToCSS(data.CSS_SKIN_RULES)
1226
- if (skinRules) rootRule += (rootRule ? '\n\n' : '') + skinRules
1227
-
1228
- // ── Reset styles ──
1229
- const resetRules = generateResetCSS(reset)
1230
-
1231
- // ── @keyframes animations ──
1232
- const keyframeRules = []
1233
- for (const name in animations) {
1234
- const frames = animations[name]
1235
- if (!frames || typeof frames !== 'object') continue
1236
- const frameRules = Object.entries(frames).map(([step, p]) => {
1237
- if (typeof p !== 'object') return ''
1238
- const decls = Object.entries(p).map(([k, v]) => `${camelToKebab(k)}: ${v}`).join('; ')
1239
- return ` ${step} { ${decls}; }`
1240
- }).join('\n')
1241
- keyframeRules.push(`@keyframes ${name} {\n${frameRules}\n}`)
1242
- }
1243
-
1244
- _cachedGlobalCSS = {
1245
- rootRule,
1246
- resetRules,
1247
- fontFaceCSS: '',
1248
- keyframeRules: keyframeRules.join('\n')
1249
- }
1250
- return _cachedGlobalCSS
1251
- } catch (err) {
1252
- console.warn('generateGlobalCSS failed:', err.message, err.stack)
1253
- _cachedGlobalCSS = { rootRule: '', resetRules: '', fontFaceCSS: '', keyframeRules: '' }
1254
- return _cachedGlobalCSS
1226
+ export const splitCapturedCSS = (rules) => {
1227
+ const groups = { root: [], reset: [], keyframes: [] }
1228
+ const rest = []
1229
+ for (const rule of rules || []) {
1230
+ const kind = classifyRule(rule)
1231
+ if (kind) groups[kind].push(rule.cssText)
1232
+ else if (rule && rule.cssText) rest.push(rule.cssText)
1233
+ }
1234
+ return {
1235
+ globalCSS: {
1236
+ rootRule: groups.root.join('\n'),
1237
+ resetRules: groups.reset.join('\n'),
1238
+ keyframeRules: groups.keyframes.join('\n'),
1239
+ fontFaceCSS: ''
1240
+ },
1241
+ classRules: rest
1255
1242
  }
1256
1243
  }
1257
1244
 
@@ -1265,7 +1252,7 @@ let _accumulatedEmotionCSS = new Set()
1265
1252
  /**
1266
1253
  * Reset the cached global CSS and emotion CSS (useful when rendering multiple projects).
1267
1254
  */
1268
- export const resetGlobalCSSCache = () => { _cachedGlobalCSS = null; _accumulatedEmotionCSS = new Set() }
1255
+ export const resetGlobalCSSCache = () => { _accumulatedEmotionCSS = new Set() }
1269
1256
 
1270
1257
  /**
1271
1258
  * Returns the complete accumulated emotion CSS from all renders so far.
@@ -1297,15 +1284,17 @@ export const replaceEmotionCSS = (html, newCSS) => {
1297
1284
  * @returns {Promise<{ html: string, css: string, resetCss: string, fontLinks: string, metadata: object, brKeyCount: number }>}
1298
1285
  */
1299
1286
  export const renderRoute = async (data, options = {}) => {
1300
- const { route = '/', pathname } = options
1287
+ // `prefetch: false` renders without the DB prefetch and translation fetch
1288
+ // — the publish-time pre-render (smbls publish → ssrCache) renders data-free.
1289
+ const { route = '/', pathname, prefetch = true } = options
1301
1290
 
1302
1291
  // Use the full render pipeline which handles polyglot, prefetch, emotion, etc.
1303
1292
  // Pass pathname so the virtual DOM location reflects the actual URL (for dynamic routes)
1304
- const result = await render(data, { route, pathname, prefetch: true })
1293
+ const result = await render(data, { route, pathname, prefetch })
1305
1294
  if (!result) return null
1306
1295
 
1307
1296
  const ds = data.designSystem || {}
1308
- const globalCSS = await generateGlobalCSS(ds, pickProjectConfig(data))
1297
+ const globalCSS = result.globalCSS
1309
1298
 
1310
1299
  // Extract prefetched state and language for metadata resolution
1311
1300
  let prefetchedState = null
@@ -1343,13 +1332,18 @@ export const renderRoute = async (data, options = {}) => {
1343
1332
 
1344
1333
  return {
1345
1334
  html: result.html,
1346
- css: result.emotionCSS ? result.emotionCSS.join('\n') : '',
1335
+ css: result.classRules.join('\n'),
1336
+ // the same rules as a list — a caller that unions several routes' CSS
1337
+ // (the publish-time ssrCache) dedupes by rule
1338
+ classRules: result.classRules.slice(),
1347
1339
  globalCSS,
1348
- resetCss: globalCSS.resetRules || generateResetCSS(ds.reset),
1340
+ resetCss: globalCSS.resetRules,
1349
1341
  fontLinks: generateFontLinks(ds),
1350
1342
  metadata: result.metadata || extractMetadata(data, route),
1351
1343
  brKeyCount: result.registry ? Object.keys(result.registry).length : 0,
1352
1344
  brRegistry: result.brRegistry || {},
1345
+ // anonymous GET answers this render got (see `_seed`) — for adoptionScripts
1346
+ fetchSeed: result.fetchSeed || [],
1353
1347
  ssrTranslations: result.ssrTranslations,
1354
1348
  prefetchedState,
1355
1349
  activeLang
@@ -1371,7 +1365,12 @@ export const renderRoute = async (data, options = {}) => {
1371
1365
  * @param {string} [options.lang='en'] - HTML lang attribute
1372
1366
  * @param {string} [options.themeColor] - theme-color meta
1373
1367
  * @param {object} [options.isr] - ISR options with clientScript path
1374
- * @param {boolean} [options.hydrate=true] - Use true hydration (attach to existing DOM) instead of full SPA re-render
1368
+ * @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)
1375
1374
  * @param {boolean} [options.prefetch=true] - Whether to prefetch data via DB adapter
1376
1375
  * @param {string} [options.shell] - A built app document (e.g. dist/index.html)
1377
1376
  * to render INTO instead of emitting a standalone document (see shell.js).
@@ -1382,7 +1381,7 @@ export const renderRoute = async (data, options = {}) => {
1382
1381
  * @returns {Promise<{ html: string, route: string, brKeyCount: number, shell?: true }>}
1383
1382
  */
1384
1383
  export const renderPage = async (data, route = '/', options = {}) => {
1385
- const { lang, themeColor, isr, hydrate = true, prefetch = true, shell } = options
1384
+ const { lang, themeColor, isr, hydrate = true, prefetch = true, shell, adoptDom = false } = options
1386
1385
 
1387
1386
  // Detect lang from project config, app metadata, or default
1388
1387
  const htmlLang = lang || data.state?.lang || data.app?.metadata?.lang || 'en'
@@ -1414,22 +1413,28 @@ export const renderPage = async (data, route = '/', options = {}) => {
1414
1413
  // Each page may introduce unique CSS classes not seen on previous pages.
1415
1414
  // Emotion's singleton cache only emits NEW classes per render, so we
1416
1415
  // collect all rules across renders to build the complete stylesheet.
1417
- if (result.emotionCSS && result.emotionCSS.length) {
1418
- for (const rule of result.emotionCSS) {
1419
- if (rule) _accumulatedEmotionCSS.add(rule)
1420
- }
1416
+ // Only the classes accumulate: the globals are this page's own (one :root,
1417
+ // one copy of each @keyframes — they used to pile up across pages).
1418
+ for (const rule of result.classRules) {
1419
+ if (rule) _accumulatedEmotionCSS.add(rule)
1421
1420
  }
1422
1421
  const emotionCSS = Array.from(_accumulatedEmotionCSS).join('\n')
1423
1422
 
1424
- // Generate global CSS (variables, reset, keyframes) via scratch pipeline
1425
- const ds = data.designSystem || {}
1426
- const globalCSS = await generateGlobalCSS(ds, pickProjectConfig(data))
1423
+ // Global CSS (variables, reset, keyframes): this render's own init output.
1424
+ // The style tags, built once for both documents below.
1425
+ const globalCSS = result.globalCSS
1426
+ const brenderStyle = `<style data-brender>\n${globalCSS.rootRule || ''}\n${globalCSS.resetRules || ''}\n${globalCSS.keyframeRules || ''}\n</style>`
1427
+ const emotionStyle = emotionCSS ? `<style data-emotion="smbls">\n${emotionCSS}\n</style>` : ''
1427
1428
 
1428
1429
  // Generate font links from design system
1429
- const fontLinks = generateFontLinks(ds)
1430
+ const fontLinks = generateFontLinks(data.designSystem || {})
1430
1431
 
1431
1432
  const brKeyCount = Object.keys(result.registry).length
1432
1433
 
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 })
1437
+
1433
1438
  // ISR: include client SPA bundle for hydration + data fetching
1434
1439
  let isrBody = ''
1435
1440
  if (isr && isr.clientScript) {
@@ -1470,12 +1475,7 @@ export const renderPage = async (data, route = '/', options = {}) => {
1470
1475
  }
1471
1476
  }
1472
1477
  // Embed BR registry for true DOM-adoption hydration
1473
- const brRegistryJson = result.brRegistry && Object.keys(result.brRegistry).length
1474
- ? JSON.stringify(result.brRegistry)
1475
- : null
1476
- const brRegistryScript = brRegistryJson
1477
- ? `<script>window.__BR_REGISTRY__=${brRegistryJson}</script>\n`
1478
- : ''
1478
+ const brRegistryScript = bootScripts ? bootScripts + '\n' : ''
1479
1479
  isrBody = `${translationSeed}${brRegistryScript}<script>window.__BRENDER__=true</script>
1480
1480
  <script type="module" src="${prefix}${isr.clientScript}"></script>`
1481
1481
  } else {
@@ -1525,32 +1525,26 @@ export const renderPage = async (data, route = '/', options = {}) => {
1525
1525
  // client bundle, so writing it over a bundler's dist/index.html shipped
1526
1526
  // pages that never booted.
1527
1527
  if (typeof shell === 'string') {
1528
- const styles = [
1529
- globalCSS.fontFaceCSS ? `<style>${globalCSS.fontFaceCSS}</style>` : '',
1530
- `<style>\n${globalCSS.rootRule || ''}\n${globalCSS.resetRules || ''}\n${globalCSS.keyframeRules || ''}\n</style>`,
1531
- emotionCSS ? `<style data-emotion="smbls">\n${emotionCSS}\n</style>` : ''
1532
- ]
1533
1528
  const html = composeIntoShell(shell, {
1534
1529
  headTags: resolvedHeadTags,
1535
- headEnd: [fontLinks, ...styles].filter(Boolean).join('\n'),
1530
+ headEnd: [fontLinks, brenderStyle, emotionStyle].filter(Boolean).join('\n'),
1536
1531
  body: result.html,
1537
- depth: Number.isInteger(options.depth) ? options.depth : 0
1532
+ depth: Number.isInteger(options.depth) ? options.depth : 0,
1533
+ bootScripts: adoptDom ? bootScripts : ''
1538
1534
  })
1539
1535
  return { html, route, brKeyCount, shell: true }
1540
1536
  }
1541
1537
 
1538
+ // (The empty line after the font links held a @font-face <style> that
1539
+ // splitCapturedCSS never fills — @font-face stays in the class stream.)
1542
1540
  const html = `<!DOCTYPE html>
1543
1541
  <html lang="${htmlLang}">
1544
1542
  <head>
1545
1543
  ${resolvedHeadTags}
1546
1544
  ${fontLinks}
1547
- ${globalCSS.fontFaceCSS ? `<style>${globalCSS.fontFaceCSS}</style>` : ''}
1548
- <style>
1549
- ${globalCSS.rootRule || ''}
1550
- ${globalCSS.resetRules || ''}
1551
- ${globalCSS.keyframeRules || ''}
1552
- </style>
1553
- ${emotionCSS ? `<style data-emotion="smbls">\n${emotionCSS}\n</style>` : ''}
1545
+
1546
+ ${brenderStyle}
1547
+ ${emotionStyle}
1554
1548
  </head>
1555
1549
  <body>
1556
1550
  ${result.html}
@@ -1561,425 +1555,6 @@ ${isrBody}
1561
1555
  return { html, route, brKeyCount }
1562
1556
  }
1563
1557
 
1564
- // ── Design system token resolution ──────────────────────────────────────────
1565
-
1566
- const LETTER_TO_INDEX = {
1567
- U: -6, V: -5, W: -4, X: -3, Y: -2, Z: -1,
1568
- A: 0, B: 1, C: 2, D: 3, E: 4, F: 5, G: 6, H: 7, I: 8, J: 9,
1569
- K: 10, L: 11, M: 12, N: 13, O: 14, P: 15
1570
- }
1571
-
1572
- const SPACING_PROPS = new Set([
1573
- 'padding', 'paddingTop', 'paddingRight', 'paddingBottom', 'paddingLeft',
1574
- 'paddingBlock', 'paddingInline', 'paddingBlockStart', 'paddingBlockEnd',
1575
- 'paddingInlineStart', 'paddingInlineEnd',
1576
- 'margin', 'marginTop', 'marginRight', 'marginBottom', 'marginLeft',
1577
- 'marginBlock', 'marginInline', 'marginBlockStart', 'marginBlockEnd',
1578
- 'marginInlineStart', 'marginInlineEnd',
1579
- 'gap', 'rowGap', 'columnGap',
1580
- 'top', 'right', 'bottom', 'left',
1581
- 'width', 'height', 'minWidth', 'maxWidth', 'minHeight', 'maxHeight',
1582
- 'flexBasis', 'fontSize', 'lineHeight', 'letterSpacing',
1583
- 'borderWidth', 'borderRadius', 'outlineWidth', 'outlineOffset',
1584
- 'inset', 'insetBlock', 'insetInline',
1585
- 'boxSize', 'round'
1586
- ])
1587
-
1588
- /**
1589
- * Resolves a spacing token like 'B2', 'A', 'E3' to a px/em value.
1590
- * Uses base * ratio^index for main steps (A=0, B=1, etc.)
1591
- * and sub-ratio interpolation for sub-steps (B1, B2, B3).
1592
- */
1593
- const resolveSpacingToken = (token, spacingConfig) => {
1594
- if (!token || typeof token !== 'string') return null
1595
- if (!spacingConfig) return null
1596
-
1597
- const base = spacingConfig.base || 16
1598
- const ratio = spacingConfig.ratio || 1.618
1599
- const unit = spacingConfig.unit || 'px'
1600
- const hasSubSequence = spacingConfig.subSequence !== false
1601
-
1602
- // Handle compound values like 'B2 - -' or 'A1 B C1'
1603
- if (token.includes(' ')) {
1604
- const parts = token.split(' ').map(part => {
1605
- if (part === '-' || part === '') return part
1606
- return resolveSpacingToken(part, spacingConfig) || part
1607
- })
1608
- return parts.join(' ')
1609
- }
1610
-
1611
- // Skip CSS keywords and values with units
1612
- if (/^(none|auto|inherit|initial|unset|0)$/i.test(token)) return null
1613
- if (/\d+(px|em|rem|%|vh|vw|vmin|vmax|ch|ex|cm|mm|in|pt|pc|fr|s|ms)$/i.test(token)) return null
1614
- // Skip hex colors, rgb(), etc.
1615
- if (/^(#|rgb|hsl|var\()/i.test(token)) return null
1616
-
1617
- const isNegative = token.startsWith('-')
1618
- const abs = isNegative ? token.slice(1) : token
1619
-
1620
- // Match letter + optional digit: A, B, B2, E3, etc.
1621
- const m = abs.match(/^([A-Z])(\d)?$/i)
1622
- if (!m) return null
1623
-
1624
- const letter = m[1].toUpperCase()
1625
- const subStep = m[2] ? parseInt(m[2]) : 0
1626
- const idx = LETTER_TO_INDEX[letter]
1627
- if (idx === undefined) return null
1628
-
1629
- let value = base * Math.pow(ratio, idx)
1630
-
1631
- if (subStep > 0 && hasSubSequence) {
1632
- const next = base * Math.pow(ratio, idx + 1)
1633
- const diff = next - value
1634
- const subRatio = diff / ratio
1635
- // Sub-steps: 1 = value + (diff - subRatio), 2 = midpoint, 3 = value + subRatio
1636
- const first = next - subRatio
1637
- const second = value + subRatio
1638
- const middle = (first + second) / 2
1639
- const subs = (~~next - ~~value > 16) ? [first, middle, second] : [first, second]
1640
- if (subStep <= subs.length) {
1641
- value = subs[subStep - 1]
1642
- }
1643
- }
1644
-
1645
- const rounded = Math.round(value * 100) / 100
1646
- const sign = isNegative ? '-' : ''
1647
- return `${sign}${rounded}${unit}`
1648
- }
1649
-
1650
- // Kebab-case versions of spacing props for post-shorthand resolution
1651
- const SPACING_PROPS_KEBAB = new Set(
1652
- [...SPACING_PROPS].map(k => k.replace(/[A-Z]/g, m => '-' + m.toLowerCase()))
1653
- )
1654
-
1655
- /**
1656
- * Try to resolve a CSS value through the design system.
1657
- * Returns the resolved value or the original if not a token.
1658
- */
1659
- const resolveDSValue = (key, val, ds) => {
1660
- if (typeof val !== 'string') return val
1661
-
1662
- // Color resolution
1663
- if (CSS_COLOR_PROPS.has(key)) {
1664
- const colorMap = ds?.color || {}
1665
- if (colorMap[val]) return colorMap[val]
1666
- }
1667
-
1668
- // Spacing resolution (check both camelCase and kebab-case keys)
1669
- if (SPACING_PROPS.has(key) || SPACING_PROPS_KEBAB.has(key)) {
1670
- const spacing = ds?.spacing || {}
1671
- const resolved = resolveSpacingToken(val, spacing)
1672
- if (resolved) return resolved
1673
- }
1674
-
1675
- return val
1676
- }
1677
-
1678
- // ── CSS helpers ─────────────────────────────────────────────────────────────
1679
-
1680
- const CSS_COLOR_PROPS = new Set([
1681
- 'color', 'background', 'backgroundColor', 'borderColor',
1682
- 'borderTopColor', 'borderRightColor', 'borderBottomColor', 'borderLeftColor',
1683
- 'outlineColor', 'fill', 'stroke'
1684
- ])
1685
-
1686
- const NON_CSS_PROPS = new Set([
1687
- 'href', 'src', 'alt', 'title', 'id', 'name', 'type', 'value', 'placeholder',
1688
- 'target', 'rel', 'loading', 'srcset', 'sizes', 'media', 'role', 'tabindex',
1689
- 'for', 'action', 'method', 'enctype', 'autocomplete', 'autofocus',
1690
- 'theme', '__element', 'update',
1691
- 'childrenAs', 'childExtends', 'childProps', 'children'
1692
- ])
1693
-
1694
- const camelToKebab = (str) => str.replace(/[A-Z]/g, m => '-' + m.toLowerCase())
1695
-
1696
- const resolveShorthand = (key, val) => {
1697
- if (typeof val === 'undefined' || val === null) return null
1698
-
1699
- // Flex shorthands
1700
- if (key === 'flow' && typeof val === 'string') {
1701
- let [direction, wrap] = (val || 'row').split(' ')
1702
- if (val.startsWith('x') || val === 'row') direction = 'row'
1703
- if (val.startsWith('y') || val === 'column') direction = 'column'
1704
- return { display: 'flex', 'flex-flow': (direction || '') + ' ' + (wrap || '') }
1705
- }
1706
- if (key === 'wrap') {
1707
- return { display: 'flex', 'flex-wrap': val }
1708
- }
1709
- if ((key === 'align' || key === 'flexAlign') && typeof val === 'string') {
1710
- const [alignItems, justifyContent] = val.split(' ')
1711
- const result = { display: 'flex', 'align-items': alignItems }
1712
- if (justifyContent) result['justify-content'] = justifyContent
1713
- return result
1714
- }
1715
- if (key === 'gridAlign' && typeof val === 'string') {
1716
- const [alignItems, justifyContent] = val.split(' ')
1717
- const result = { display: 'grid', 'align-items': alignItems }
1718
- if (justifyContent) result['justify-content'] = justifyContent
1719
- return result
1720
- }
1721
- if (key === 'flexFlow' && typeof val === 'string') {
1722
- let [direction, wrap] = (val || 'row').split(' ')
1723
- if (val.startsWith('x') || val === 'row') direction = 'row'
1724
- if (val.startsWith('y') || val === 'column') direction = 'column'
1725
- return { display: 'flex', 'flex-flow': (direction || '') + ' ' + (wrap || '') }
1726
- }
1727
- if (key === 'flexWrap') {
1728
- return { display: 'flex', 'flex-wrap': val }
1729
- }
1730
-
1731
- // Background image shorthand
1732
- if (key === 'backgroundImage' && typeof val === 'string' && !val.startsWith('url(') && !val.startsWith('linear-gradient') && !val.startsWith('radial-gradient') && !val.startsWith('none')) {
1733
- return { 'background-image': `url(${val})` }
1734
- }
1735
-
1736
- // Box/size shorthands
1737
- if (key === 'round' || (key === 'borderRadius' && val)) {
1738
- return { 'border-radius': typeof val === 'number' ? val + 'px' : val }
1739
- }
1740
- if (key === 'boxSize' && typeof val === 'string') {
1741
- const [height, width] = val.split(' ')
1742
- return { height, width: width || height }
1743
- }
1744
- if (key === 'widthRange' && typeof val === 'string') {
1745
- const [minWidth, maxWidth] = val.split(' ')
1746
- return { 'min-width': minWidth, 'max-width': maxWidth || minWidth }
1747
- }
1748
- if (key === 'heightRange' && typeof val === 'string') {
1749
- const [minHeight, maxHeight] = val.split(' ')
1750
- return { 'min-height': minHeight, 'max-height': maxHeight || minHeight }
1751
- }
1752
-
1753
- // Grid aliases
1754
- if (key === 'column') return { 'grid-column': val }
1755
- if (key === 'columns') return { 'grid-template-columns': val }
1756
- if (key === 'templateColumns') return { 'grid-template-columns': val }
1757
- if (key === 'row') return { 'grid-row': val }
1758
- if (key === 'rows') return { 'grid-template-rows': val }
1759
- if (key === 'templateRows') return { 'grid-template-rows': val }
1760
- if (key === 'area') return { 'grid-area': val }
1761
- if (key === 'template') return { 'grid-template': val }
1762
- if (key === 'templateAreas') return { 'grid-template-areas': val }
1763
- if (key === 'autoColumns') return { 'grid-auto-columns': val }
1764
- if (key === 'autoRows') return { 'grid-auto-rows': val }
1765
- if (key === 'autoFlow') return { 'grid-auto-flow': val }
1766
- if (key === 'columnStart') return { 'grid-column-start': val }
1767
- if (key === 'rowStart') return { 'grid-row-start': val }
1768
-
1769
- return null
1770
- }
1771
-
1772
- const resolveInnerProps = (obj, ds) => {
1773
- const result = {}
1774
- for (const k in obj) {
1775
- const v = obj[k]
1776
- const expanded = resolveShorthand(k, v)
1777
- if (expanded) {
1778
- for (const ek in expanded) {
1779
- result[ek] = resolveDSValue(ek, expanded[ek], ds)
1780
- }
1781
- continue
1782
- }
1783
- if (typeof v !== 'string' && typeof v !== 'number') continue
1784
- result[camelToKebab(k)] = resolveDSValue(k, v, ds)
1785
- }
1786
- return result
1787
- }
1788
-
1789
- const buildCSSFromProps = (props, ds, mediaMap) => {
1790
- const base = {}
1791
- const mediaRules = {}
1792
- const pseudoRules = {}
1793
-
1794
- for (const key in props) {
1795
- const val = props[key]
1796
-
1797
- if (key.charCodeAt(0) === 64 && typeof val === 'object') {
1798
- const bp = mediaMap?.[key.slice(1)]
1799
- if (bp) {
1800
- const inner = resolveInnerProps(val, ds)
1801
- if (Object.keys(inner).length) mediaRules[bp] = inner
1802
- }
1803
- continue
1804
- }
1805
-
1806
- if (key.charCodeAt(0) === 58 && typeof val === 'object') {
1807
- const inner = resolveInnerProps(val, ds)
1808
- if (Object.keys(inner).length) pseudoRules[key] = inner
1809
- continue
1810
- }
1811
-
1812
- if (typeof val !== 'string' && typeof val !== 'number') continue
1813
- if (key.charCodeAt(0) >= 65 && key.charCodeAt(0) <= 90) continue
1814
- if (NON_CSS_PROPS.has(key)) continue
1815
-
1816
- const expanded = resolveShorthand(key, val)
1817
- if (expanded) {
1818
- for (const ek in expanded) {
1819
- base[ek] = resolveDSValue(ek, expanded[ek], ds)
1820
- }
1821
- continue
1822
- }
1823
-
1824
- base[camelToKebab(key)] = resolveDSValue(key, val, ds)
1825
- }
1826
-
1827
- return { base, mediaRules, pseudoRules }
1828
- }
1829
-
1830
- const renderCSSRule = (selector, { base, mediaRules, pseudoRules }) => {
1831
- const lines = []
1832
- const baseDecls = Object.entries(base).map(([k, v]) => `${k}: ${v}`).join('; ')
1833
- if (baseDecls) lines.push(`${selector} { ${baseDecls}; }`)
1834
-
1835
- for (const [pseudo, p] of Object.entries(pseudoRules)) {
1836
- const decls = Object.entries(p).map(([k, v]) => `${k}: ${v}`).join('; ')
1837
- if (decls) lines.push(`${selector}${pseudo} { ${decls}; }`)
1838
- }
1839
-
1840
- for (const [query, p] of Object.entries(mediaRules)) {
1841
- const decls = Object.entries(p).map(([k, v]) => `${k}: ${v}`).join('; ')
1842
- const mq = query.startsWith('@') ? query : `@media ${query}`
1843
- if (decls) lines.push(`${mq} { ${selector} { ${decls}; } }`)
1844
- }
1845
-
1846
- return lines.join('\n')
1847
- }
1848
-
1849
- // Map of component names to their implicit CSS from extends
1850
- const EXTENDS_CSS = {
1851
- Flex: { display: 'flex' },
1852
- InlineFlex: { display: 'inline-flex' },
1853
- Grid: { display: 'grid' },
1854
- InlineGrid: { display: 'inline-grid' },
1855
- Block: { display: 'block' },
1856
- Inline: { display: 'inline' }
1857
- }
1858
-
1859
- const getExtendsCSS = (el) => {
1860
- const exts = el.__ref?.__extends
1861
- if (!exts || !Array.isArray(exts)) return null
1862
- for (const ext of exts) {
1863
- if (EXTENDS_CSS[ext]) return EXTENDS_CSS[ext]
1864
- }
1865
- return null
1866
- }
1867
-
1868
- /**
1869
- * Resolve function-valued CSS props by evaluating them with (element, state).
1870
- * In SSR, this gives correct initial values (e.g. display: 'none' when no auth).
1871
- * Uses fallback mock state if element state is incomplete.
1872
- */
1873
- const resolveElementProps = (el) => {
1874
- let resolved
1875
- for (const key in el) {
1876
- if (typeof el[key] !== 'function') continue
1877
- // Skip non-CSS props and component children
1878
- if (NON_CSS_PROPS.has(key)) continue
1879
- if (key.charCodeAt(0) >= 65 && key.charCodeAt(0) <= 90) continue
1880
- if (key.startsWith('on')) continue
1881
- if (key.startsWith('__')) continue
1882
- if (!resolved) resolved = {}
1883
- let result
1884
- try {
1885
- result = el[key](el, el.state || {})
1886
- } catch {
1887
- // State prototype chain may be incomplete in SSR — try with mock
1888
- try {
1889
- const mockState = { root: {}, ...(el.state || {}) }
1890
- result = el[key](el, mockState)
1891
- } catch { /* skip prop */ }
1892
- }
1893
- if (result !== undefined && result !== null && result !== false) {
1894
- resolved[key] = result
1895
- }
1896
- }
1897
- return resolved || el
1898
- }
1899
-
1900
- const extractCSS = (element, ds) => {
1901
- const mediaMap = ds?.media || {}
1902
- const animations = ds?.animation || {}
1903
- const rules = []
1904
- const seen = new Set()
1905
- const usedAnimations = new Set()
1906
-
1907
- const walk = (el) => {
1908
- if (!el || !el.__ref) return
1909
- const props = resolveElementProps(el)
1910
- if (props && el.node) {
1911
- const cls = el.node.getAttribute?.('class')
1912
- if (cls && !seen.has(cls)) {
1913
- seen.add(cls)
1914
- const cssResult = buildCSSFromProps(props, ds, mediaMap)
1915
-
1916
- // Inject CSS from extends chain (e.g. extends: 'Flex' → display: flex)
1917
- const extsCss = getExtendsCSS(el)
1918
- if (extsCss) {
1919
- for (const [k, v] of Object.entries(extsCss)) {
1920
- const kebab = camelToKebab(k)
1921
- if (!cssResult.base[kebab]) cssResult.base[kebab] = v
1922
- }
1923
- }
1924
-
1925
- const has = Object.keys(cssResult.base).length || Object.keys(cssResult.mediaRules).length || Object.keys(cssResult.pseudoRules).length
1926
- if (has) rules.push(renderCSSRule('.' + cls.split(' ')[0], cssResult))
1927
-
1928
- const anim = props.animation || props.animationName
1929
- if (typeof anim === 'string') {
1930
- const name = anim.split(' ')[0]
1931
- if (animations[name]) usedAnimations.add(name)
1932
- }
1933
- }
1934
- }
1935
- if (el.__ref?.__children) {
1936
- for (const ck of el.__ref.__children) {
1937
- if (el[ck]?.__ref) walk(el[ck])
1938
- }
1939
- }
1940
- }
1941
- walk(element)
1942
-
1943
- const keyframes = []
1944
- for (const name of usedAnimations) {
1945
- const frames = animations[name]
1946
- const frameRules = Object.entries(frames).map(([step, p]) => {
1947
- const decls = Object.entries(p).map(([k, v]) => `${camelToKebab(k)}: ${v}`).join('; ')
1948
- return ` ${step} { ${decls}; }`
1949
- }).join('\n')
1950
- keyframes.push(`@keyframes ${name} {\n${frameRules}\n}`)
1951
- }
1952
-
1953
- return [...keyframes, ...rules].join('\n')
1954
- }
1955
-
1956
- const generateResetCSS = (reset) => {
1957
- if (!reset) return ''
1958
- const rules = []
1959
- for (const [selector, props] of Object.entries(reset)) {
1960
- if (!props || typeof props !== 'object') continue
1961
- const baseDecls = []
1962
- const mediaRules = []
1963
- for (const [k, v] of Object.entries(props)) {
1964
- if (typeof v === 'object' && v !== null) {
1965
- // Nested object: @media query or sub-selector
1966
- if (k.startsWith('@media') || k.startsWith('@')) {
1967
- const inner = Object.entries(v)
1968
- .filter(([, iv]) => typeof iv !== 'object')
1969
- .map(([ik, iv]) => `${camelToKebab(ik)}: ${iv}`)
1970
- .join('; ')
1971
- if (inner) mediaRules.push(`${k} { ${selector} { ${inner}; } }`)
1972
- }
1973
- continue
1974
- }
1975
- baseDecls.push(`${camelToKebab(k)}: ${v}`)
1976
- }
1977
- if (baseDecls.length) rules.push(`${selector} { ${baseDecls.join('; ')}; }`)
1978
- rules.push(...mediaRules)
1979
- }
1980
- return rules.join('\n')
1981
- }
1982
-
1983
1558
  const generateFontLinks = (ds) => {
1984
1559
  if (!ds) return ''
1985
1560
  const families = ds.font_family || ds.fontFamily || {}