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