@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/CHANGELOG.md +53 -0
- package/dist/esm/env.js +1 -1
- package/dist/esm/index.js +1 -1
- package/dist/esm/render.js +30 -113
- package/dist/esm/shell.js +6 -5
- package/env.js +59 -1
- package/index.js +5 -3
- package/package.json +8 -8
- package/render.js +333 -758
- package/shell.js +5 -2
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
|
-
|
|
306
|
-
|
|
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,
|
|
647
|
-
if (!element || !element.__ref) return {}
|
|
635
|
+
const buildPathRegistry = (element, elementPath) => {
|
|
648
636
|
const registry = {}
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
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
|
|
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
|
-
|
|
861
|
-
|
|
862
|
-
//
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
const
|
|
867
|
-
|
|
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
|
|
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
|
|
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)
|
|
1049
|
+
if (rule.cssText) capturedRules.push(rule)
|
|
891
1050
|
}
|
|
892
1051
|
}
|
|
893
|
-
if (!
|
|
1052
|
+
if (!capturedRules.length) {
|
|
894
1053
|
const content = style.textContent || ''
|
|
895
|
-
if (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
|
-
//
|
|
1006
|
-
//
|
|
1007
|
-
//
|
|
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
|
-
//
|
|
1034
|
-
//
|
|
1035
|
-
//
|
|
1036
|
-
|
|
1037
|
-
|
|
1038
|
-
|
|
1039
|
-
|
|
1040
|
-
|
|
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
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
|
|
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 = () => {
|
|
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
|
-
|
|
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
|
|
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 =
|
|
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.
|
|
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
|
|
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] -
|
|
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
|
-
|
|
1418
|
-
|
|
1419
|
-
|
|
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
|
-
//
|
|
1425
|
-
|
|
1426
|
-
const globalCSS =
|
|
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(
|
|
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
|
|
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,
|
|
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
|
-
|
|
1548
|
-
|
|
1549
|
-
${
|
|
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 || {}
|