@symbo.ls/brender 3.14.663 → 3.14.665
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 +46 -0
- package/README.md +106 -23
- package/dist/esm/env.js +1 -1
- package/dist/esm/index.js +1 -1
- package/dist/esm/load.js +2 -1
- package/dist/esm/prefetch.js +1 -1
- package/dist/esm/render.js +33 -51
- package/dist/esm/shell.js +9 -8
- package/env.js +8 -2
- package/index.js +5 -3
- package/load.js +111 -22
- package/package.json +9 -9
- package/prefetch.js +30 -62
- package/render.js +533 -658
- package/shell.js +74 -12
package/render.js
CHANGED
|
@@ -4,13 +4,16 @@ import { resolvePackageEntry } from './packageEntry.js'
|
|
|
4
4
|
import { tmpdir } from 'os'
|
|
5
5
|
import { randomBytes } from 'crypto'
|
|
6
6
|
import { createRequire } from 'module'
|
|
7
|
+
import { pathToFileURL } from 'url'
|
|
7
8
|
import { createEnv } from './env.js'
|
|
8
9
|
import { resetKeys, assignKeys, mapKeysToElements } from './keys.js'
|
|
9
10
|
import { extractMetadata, generateHeadHtml } from './metadata.js'
|
|
10
11
|
import { hydrate } from './hydrate.js'
|
|
11
12
|
import { composeIntoShell } from './shell.js'
|
|
12
|
-
import {
|
|
13
|
+
import { fetchSSRTranslations } from './prefetch.js'
|
|
13
14
|
import { createEarlySeedScript } from '@symbo.ls/fetch'
|
|
15
|
+
import { headerEntries } from '@symbo.ls/fetch/rest'
|
|
16
|
+
import { brenderRegistryBlock, escapeRegExp, jsonForScript, polyglotFallbackLang } from '@symbo.ls/utils'
|
|
14
17
|
|
|
15
18
|
// funcql plugin — enables evaluation of funcql schemas as property values
|
|
16
19
|
// during SSR. Async-imported to avoid polluting non-funcql codepaths.
|
|
@@ -26,6 +29,7 @@ const getFuncqlPlugin = async () => {
|
|
|
26
29
|
}
|
|
27
30
|
}
|
|
28
31
|
import { parseHTML } from 'linkedom'
|
|
32
|
+
import { createEarlySeedBlock } from '@symbo.ls/fetch'
|
|
29
33
|
import { css, injectGlobal, reset as resetCss } from '@symbo.ls/css'
|
|
30
34
|
|
|
31
35
|
// Lightweight SSR polyglot functions — resolve translations from context
|
|
@@ -43,17 +47,17 @@ const ssrResolve = (map, key) => {
|
|
|
43
47
|
return v
|
|
44
48
|
}
|
|
45
49
|
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
50
|
+
// The language a server render shows: the root state's — a page under a
|
|
51
|
+
// language prefix sets it (a router guard reading `/en/…`) — else the
|
|
52
|
+
// project's default, else 'en' (@symbo.ls/utils polyglotFallbackLang, the
|
|
53
|
+
// order plugins/polyglot getActiveLang resolves it in). Diverging produces
|
|
54
|
+
// SSR/hydrate text mismatches.
|
|
55
|
+
const activeLangOf = (root, poly) => polyglotFallbackLang(root?.lang, poly)
|
|
56
|
+
|
|
57
|
+
// One label lookup, the order the client's translate() reads: static
|
|
58
|
+
// translations, then the labels loaded into the root state, then the
|
|
59
|
+
// default language's static ones. `undefined` when no table has the key.
|
|
60
|
+
const findLabel = (key, activeLang, poly, root) => {
|
|
57
61
|
// Static translations from context.polyglot.translations
|
|
58
62
|
if (poly?.translations) {
|
|
59
63
|
const langMap = poly.translations[activeLang]
|
|
@@ -64,7 +68,6 @@ const ssrTranslate = function (key, lang) {
|
|
|
64
68
|
}
|
|
65
69
|
|
|
66
70
|
// Server-loaded translations in root state
|
|
67
|
-
const root = this?.state?.root || ctx?.state?.root
|
|
68
71
|
if (root?.translations) {
|
|
69
72
|
const langMap = root.translations[activeLang]
|
|
70
73
|
if (langMap) {
|
|
@@ -74,7 +77,7 @@ const ssrTranslate = function (key, lang) {
|
|
|
74
77
|
}
|
|
75
78
|
|
|
76
79
|
// Fallback to default lang
|
|
77
|
-
const defaultLang = poly
|
|
80
|
+
const defaultLang = polyglotFallbackLang(undefined, poly)
|
|
78
81
|
if (defaultLang !== activeLang && poly?.translations) {
|
|
79
82
|
const fallback = poly.translations[defaultLang]
|
|
80
83
|
if (fallback) {
|
|
@@ -82,15 +85,30 @@ const ssrTranslate = function (key, lang) {
|
|
|
82
85
|
if (val !== undefined) return val
|
|
83
86
|
}
|
|
84
87
|
}
|
|
88
|
+
return undefined
|
|
89
|
+
}
|
|
85
90
|
|
|
91
|
+
const ssrTranslate = function (key, lang) {
|
|
92
|
+
if (!key) return ''
|
|
93
|
+
const ctx = this?.context
|
|
94
|
+
const poly = ctx?.polyglot
|
|
95
|
+
const root = this?.state?.root || ctx?.state?.root
|
|
96
|
+
// The ROOT's language, as the client's translate() asks getActiveLang.
|
|
97
|
+
// This used to read `poly.defaultLang` straight, so a page rendered for
|
|
98
|
+
// /en/about had `en` in its state and Georgian text in its markup
|
|
99
|
+
// (xma.info report, brender build item 8).
|
|
100
|
+
const val = findLabel(key, lang || activeLangOf(root, poly), poly, root)
|
|
101
|
+
if (val !== undefined) return val
|
|
102
|
+
// A key with no label yet. The labels may still be on their way (a
|
|
103
|
+
// project's own polyglot.fetch loads them during the render), so this only
|
|
104
|
+
// notes the key; the end of the render checks it again (see rawLabelKeys).
|
|
105
|
+
const diag = liveRecord(DIAG)
|
|
106
|
+
if (poly && diag) diag.labelKeys.add(key)
|
|
86
107
|
return key
|
|
87
108
|
}
|
|
88
109
|
|
|
89
110
|
const ssrGetActiveLang = function () {
|
|
90
|
-
|
|
91
|
-
// 'en' matches plugins/polyglot/index.js#getActiveLang — diverging here
|
|
92
|
-
// produces SSR/hydrate text mismatches.
|
|
93
|
-
return this?.state?.root?.lang || ctx?.polyglot?.defaultLang || 'en'
|
|
111
|
+
return activeLangOf(this?.state?.root, this?.context?.polyglot)
|
|
94
112
|
}
|
|
95
113
|
|
|
96
114
|
// Deep clone that preserves functions and avoids circular refs
|
|
@@ -290,18 +308,10 @@ const bundleCreateDomql = async () => {
|
|
|
290
308
|
|
|
291
309
|
const ws = detectWorkspace()
|
|
292
310
|
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
_cachedCreateDomql = await importSmblsSsr()
|
|
296
|
-
return _cachedCreateDomql
|
|
297
|
-
} catch (err) {
|
|
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.`)
|
|
299
|
-
}
|
|
300
|
-
}
|
|
301
|
-
|
|
302
|
-
// Resolve entry point
|
|
311
|
+
// Resolve entry point: the monorepo's source, else `smbls/ssr` (one import
|
|
312
|
+
// for both a bundled runtime and an installed smbls).
|
|
303
313
|
let entry
|
|
304
|
-
if (ws.isMonorepo) {
|
|
314
|
+
if (_brenderRequire && ws.isMonorepo) {
|
|
305
315
|
entry = resolve(ws.monorepoRoot, 'packages', 'smbls', 'src', 'createDomql.js')
|
|
306
316
|
} else {
|
|
307
317
|
let ssrError
|
|
@@ -311,6 +321,9 @@ const bundleCreateDomql = async () => {
|
|
|
311
321
|
} catch (err) {
|
|
312
322
|
ssrError = err
|
|
313
323
|
}
|
|
324
|
+
if (!_brenderRequire) {
|
|
325
|
+
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.`)
|
|
326
|
+
}
|
|
314
327
|
// Legacy: an smbls that predates the ssr entry but still ships its source.
|
|
315
328
|
const srcEntry = ws.smblsRoot && resolve(ws.smblsRoot, 'src', 'createDomql.js')
|
|
316
329
|
if (srcEntry && existsSync(srcEntry)) {
|
|
@@ -329,9 +342,44 @@ const bundleCreateDomql = async () => {
|
|
|
329
342
|
|
|
330
343
|
const tryResolve = resolvePackageEntry
|
|
331
344
|
|
|
345
|
+
// In the monorepo, every workspace specifier the handlers below do not own
|
|
346
|
+
// — a SUBPATH of a sibling (`@symbo.ls/dropdown/components`,
|
|
347
|
+
// `@symbo.ls/sync/applier`, `@symbo.ls/utils/function.js`) and an unscoped
|
|
348
|
+
// sibling (`attrs-in-props`) — resolves to its source through the map the
|
|
349
|
+
// test runner and smbls's own build resolve with
|
|
350
|
+
// (scripts/vite-workspace-source.mjs). They fell through to esbuild, which
|
|
351
|
+
// followed each package's `exports` into its dist/: a render ran whatever
|
|
352
|
+
// build was left on disk (the legacy Dropdown kit of an earlier build, a
|
|
353
|
+
// sync applier without the exports createDomql imports now), and a
|
|
354
|
+
// checkout without dist/ could not render at all. A dist input now fails
|
|
355
|
+
// the bundle.
|
|
356
|
+
const workspaceSource = ws.isMonorepo
|
|
357
|
+
? await import(pathToFileURL(resolve(ws.monorepoRoot, 'scripts', 'vite-workspace-source.mjs')).href)
|
|
358
|
+
: null
|
|
359
|
+
const workspaceNames = workspaceSource
|
|
360
|
+
? new Set(workspaceSource.workspacePackageRoots().map((dir) => workspaceSource.workspacePackageAt(dir).name))
|
|
361
|
+
: null
|
|
362
|
+
// Bare specifiers the handlers below resolve themselves (the sync stub
|
|
363
|
+
// among them).
|
|
364
|
+
const OWN_HANDLERS = /^(?:smbls(?:\/.*)?|domql|css-in-props(?:\/.*)?|@symbo\.ls\/[^/]+)$/
|
|
365
|
+
|
|
332
366
|
const workspacePlugin = {
|
|
333
367
|
name: 'workspace-resolve',
|
|
334
368
|
setup (build) {
|
|
369
|
+
if (workspaceSource) {
|
|
370
|
+
build.onResolve({ filter: /^[^./]/ }, (args) => {
|
|
371
|
+
if (OWN_HANDLERS.test(args.path)) return
|
|
372
|
+
const parts = args.path.split('/')
|
|
373
|
+
const name = args.path.startsWith('@') ? parts.slice(0, 2).join('/') : parts[0]
|
|
374
|
+
if (!workspaceNames.has(name)) return
|
|
375
|
+
const source = workspaceSource.resolveWorkspaceSource(args.path, args.importer || entry)
|
|
376
|
+
if (source) return { path: source }
|
|
377
|
+
return { errors: [{ text: `"${args.path}" names no source file in the workspace` }] }
|
|
378
|
+
})
|
|
379
|
+
build.onLoad({ filter: /[\\/]dist[\\/]/ }, (args) => workspaceSource.isWorkspaceDist(args.path)
|
|
380
|
+
? { errors: [{ text: `${args.path} is a BUILD of a workspace package — the render bundles source` }] }
|
|
381
|
+
: undefined)
|
|
382
|
+
}
|
|
335
383
|
// Resolve smbls bare import
|
|
336
384
|
build.onResolve({ filter: /^smbls/ }, (args) => {
|
|
337
385
|
const subpath = args.path.replace(/^smbls\/?/, '')
|
|
@@ -630,25 +678,31 @@ const UIKIT_STUBS = {
|
|
|
630
678
|
*
|
|
631
679
|
* The registry is serialized into the pre-rendered HTML so the browser can
|
|
632
680
|
* assign the correct brKey to each skeleton element during true hydration.
|
|
681
|
+
* A path is @symbo.ls/element elementPath — the very function the hydrating
|
|
682
|
+
* client looks each element up with (adoptHydrationNode), so the two sides
|
|
683
|
+
* cannot spell a path differently. One flat object, filled in place (the
|
|
684
|
+
* recursive walk merged a fresh object per element into its parent's).
|
|
633
685
|
*/
|
|
634
|
-
const buildPathRegistry = (element,
|
|
635
|
-
if (!element || !element.__ref) return {}
|
|
686
|
+
const buildPathRegistry = (element, elementPath) => {
|
|
636
687
|
const registry = {}
|
|
688
|
+
walkElements(element, (el, ref) => {
|
|
689
|
+
if (!ref.__brKey) return
|
|
690
|
+
const path = elementPath(el)
|
|
691
|
+
registry[path === '' ? '__root' : path] = ref.__brKey
|
|
692
|
+
})
|
|
693
|
+
return registry
|
|
694
|
+
}
|
|
637
695
|
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
Object.assign(registry, buildPathRegistry(child, childPath))
|
|
647
|
-
}
|
|
648
|
-
}
|
|
696
|
+
// Every element of a rendered tree, each before its children, in the order of
|
|
697
|
+
// `__ref.__children`.
|
|
698
|
+
const walkElements = (root, visit) => {
|
|
699
|
+
const walk = (el) => {
|
|
700
|
+
const ref = el && el.__ref
|
|
701
|
+
if (!ref) return
|
|
702
|
+
visit(el, ref)
|
|
703
|
+
if (ref.__children) for (const childKey of ref.__children) walk(el[childKey])
|
|
649
704
|
}
|
|
650
|
-
|
|
651
|
-
return registry
|
|
705
|
+
walk(root)
|
|
652
706
|
}
|
|
653
707
|
|
|
654
708
|
/**
|
|
@@ -666,6 +720,15 @@ const buildPathRegistry = (element, path = '') => {
|
|
|
666
720
|
* @param {object} [options.context] - Additional context overrides
|
|
667
721
|
* @returns {Promise<{ html: string, metadata: object, registry: object, element: object }>}
|
|
668
722
|
*/
|
|
723
|
+
// The payload keys render() puts on the context itself (below `ctx`): the
|
|
724
|
+
// generic copy of every other top-level key skips them — cloning one only for
|
|
725
|
+
// the explicit key to replace it was a deep clone of `functions`, `methods`
|
|
726
|
+
// and `files` thrown away on every render.
|
|
727
|
+
const CONTEXT_KEYS_SET_BELOW = new Set([
|
|
728
|
+
'app', 'config', 'pages', 'components', 'state', 'snippets', 'designSystem', 'dependencies',
|
|
729
|
+
'functions', 'methods', 'files', 'sharedLibraries'
|
|
730
|
+
])
|
|
731
|
+
|
|
669
732
|
// ── In-flight request tracking (the post-create flush) ──────────────────────
|
|
670
733
|
// The render's flush waits for the requests a page starts while it renders
|
|
671
734
|
// (fetch plugin, SDK, project code — all end in globalThis.fetch). One
|
|
@@ -692,20 +755,75 @@ const inFlightRequests = () => _inFlight
|
|
|
692
755
|
const SEED_ENTRY_MAX = 256 * 1024
|
|
693
756
|
const SEED_TOTAL_MAX = 1024 * 1024
|
|
694
757
|
const SEED_HEADERS_OK = new Set(['accept-language', 'accept'])
|
|
695
|
-
|
|
696
|
-
|
|
758
|
+
const SEED = { renders: 0, record: null }
|
|
759
|
+
// ── What went wrong in a render (its diagnostics) ───────────────────────────
|
|
760
|
+
// Kept like the seed — one record while exactly ONE render runs, tainted by
|
|
761
|
+
// an overlapping one — so a build can tell a page made from data from a page
|
|
762
|
+
// made from nothing: the requests that failed (a non-ok status or a network
|
|
763
|
+
// error) and the label keys that missed (ssrTranslate notes them; the end of
|
|
764
|
+
// the render checks them again, see rawLabelKeys). A build with its database
|
|
765
|
+
// unreachable printed "✓ 22 of 22 routes" and exited 0 while every page
|
|
766
|
+
// carried raw keys (xma.info report, brender build item 9).
|
|
767
|
+
const DIAG_FAILURES_MAX = 50
|
|
768
|
+
const DIAG = { renders: 0, record: null }
|
|
769
|
+
|
|
770
|
+
// One record per window of overlapping renders (the seed's and the
|
|
771
|
+
// diagnostics' windows open at different points of a render, so each has its
|
|
772
|
+
// own slot): the first render of a window opens it, a render that overlaps it
|
|
773
|
+
// taints it — another project's answers or failures must never land in this
|
|
774
|
+
// page — and it goes as soon as one of the renders holding it ends. A render
|
|
775
|
+
// that starts inside a window whose record is gone gets none.
|
|
776
|
+
const openRenderRecord = (slot, make) => {
|
|
777
|
+
slot.renders++
|
|
778
|
+
if (slot.renders === 1) slot.record = make()
|
|
779
|
+
else if (slot.record) slot.record.tainted = true
|
|
780
|
+
const record = slot.record
|
|
781
|
+
const close = () => {
|
|
782
|
+
slot.renders--
|
|
783
|
+
if (slot.record === record) slot.record = null
|
|
784
|
+
}
|
|
785
|
+
return { record, close }
|
|
786
|
+
}
|
|
787
|
+
|
|
788
|
+
// The slot's record while it is the only render's, else null.
|
|
789
|
+
const liveRecord = (slot) => slot.record && !slot.record.tainted ? slot.record : null
|
|
790
|
+
|
|
791
|
+
// A request as a diagnostic names it: origin and path. The query is left out
|
|
792
|
+
// — a public read can carry its key there (`?apikey=…`), and a log line is no
|
|
793
|
+
// place for one.
|
|
794
|
+
const requestLabel = (url) => {
|
|
795
|
+
try {
|
|
796
|
+
const u = new URL(url)
|
|
797
|
+
return u.origin + u.pathname
|
|
798
|
+
} catch {
|
|
799
|
+
return String(url).split('?')[0]
|
|
800
|
+
}
|
|
801
|
+
}
|
|
802
|
+
|
|
803
|
+
const recordFailure = (diag, url, reason) => {
|
|
804
|
+
if (!diag || diag.tainted || diag.failures.length >= DIAG_FAILURES_MAX) return
|
|
805
|
+
diag.failures.push({ url: requestLabel(url), reason })
|
|
806
|
+
}
|
|
807
|
+
|
|
808
|
+
// The URL a fetch() call asks for: a Request's own, else its input as text.
|
|
809
|
+
const requestUrl = (input) =>
|
|
810
|
+
input && typeof input === 'object' && typeof input.url === 'string' ? input.url : String(input)
|
|
697
811
|
|
|
698
812
|
const requestShape = (args) => {
|
|
699
813
|
const [input, init] = args
|
|
700
814
|
const isRequest = input && typeof input === 'object' && typeof input.url === 'string'
|
|
701
|
-
const url =
|
|
815
|
+
const url = requestUrl(input)
|
|
702
816
|
const method = String((init && init.method) || (isRequest && input.method) || 'GET').toUpperCase()
|
|
703
817
|
const headers = {}
|
|
818
|
+
// Any fetch HeadersInit, read like the REST adapter reads one (headerEntries).
|
|
819
|
+
// A plain object's null / undefined value is no header.
|
|
704
820
|
const add = (h) => {
|
|
705
821
|
if (!h) return
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
822
|
+
const plain = !Array.isArray(h) && typeof h.entries !== 'function'
|
|
823
|
+
for (const [k, v] of headerEntries(h)) {
|
|
824
|
+
if (plain && v == null) continue
|
|
825
|
+
headers[String(k).toLowerCase()] = String(v)
|
|
826
|
+
}
|
|
709
827
|
}
|
|
710
828
|
if (isRequest) add(input.headers)
|
|
711
829
|
if (init) add(init.headers)
|
|
@@ -734,7 +852,8 @@ const trackFetches = () => {
|
|
|
734
852
|
const done = () => { if (!settled) { settled = true; _inFlight-- } }
|
|
735
853
|
// a pending body read counts as in flight: the flush waits for it too
|
|
736
854
|
const track = (promise) => { _inFlight++; promise.then(() => { _inFlight-- }, () => { _inFlight-- }) }
|
|
737
|
-
const seed =
|
|
855
|
+
const seed = liveRecord(SEED)
|
|
856
|
+
const diag = liveRecord(DIAG)
|
|
738
857
|
let shape = null
|
|
739
858
|
if (seed) {
|
|
740
859
|
try {
|
|
@@ -743,18 +862,24 @@ const trackFetches = () => {
|
|
|
743
862
|
Object.keys(shape.headers).some((h) => !SEED_HEADERS_OK.has(h))) shape = null
|
|
744
863
|
} catch { shape = null }
|
|
745
864
|
}
|
|
865
|
+
const urlOf = () => requestUrl(args[0])
|
|
746
866
|
let result
|
|
747
867
|
try {
|
|
748
868
|
result = inner.apply(this, args)
|
|
749
869
|
} catch (err) {
|
|
870
|
+
recordFailure(diag, urlOf(), (err && err.message) || 'failed')
|
|
750
871
|
done()
|
|
751
872
|
throw err
|
|
752
873
|
}
|
|
753
874
|
if (result && typeof result.then === 'function') {
|
|
754
875
|
result.then((response) => {
|
|
755
876
|
if (shape) recordAnswer(seed, shape, response, track)
|
|
877
|
+
if (diag && response && response.ok === false) recordFailure(diag, urlOf(), 'HTTP ' + response.status)
|
|
756
878
|
done()
|
|
757
|
-
},
|
|
879
|
+
}, (err) => {
|
|
880
|
+
recordFailure(diag, urlOf(), (err && err.message) || 'network error')
|
|
881
|
+
done()
|
|
882
|
+
})
|
|
758
883
|
} else done()
|
|
759
884
|
return result
|
|
760
885
|
}
|
|
@@ -769,9 +894,14 @@ const trackFetches = () => {
|
|
|
769
894
|
// (@symbo.ls/fetch createEarlySeedScript). Default off everywhere — a page
|
|
770
895
|
// without these keeps the fresh client render.
|
|
771
896
|
// FW-BRENDER-HYDRATION-AND-CF-DEPLOY-PROVIDER-1.
|
|
772
|
-
|
|
897
|
+
// `hydrateFlag: false` — the registry alone, for a client that replaces the
|
|
898
|
+
// server DOM but still reads the registry (renderPage's default ISR path). It
|
|
899
|
+
// inlined its own JSON.stringify there, without the `<` escape, so an element
|
|
900
|
+
// key carrying `</script>` closed the tag.
|
|
901
|
+
export const adoptionScripts = ({ brRegistry, fetchSeed, hydrateFlag = true } = {}) => {
|
|
773
902
|
if (!brRegistry || !Object.keys(brRegistry).length) return ''
|
|
774
|
-
const registry =
|
|
903
|
+
const registry = jsonForScript(brRegistry)
|
|
904
|
+
if (!hydrateFlag) return `<script>window.__BR_REGISTRY__=${registry}</script>`
|
|
775
905
|
return `<script>window.__BR_REGISTRY__=${registry};window.__BRENDER_HYDRATE__=true</script>` +
|
|
776
906
|
(fetchSeed && fetchSeed.length ? '\n' + createEarlySeedScript(fetchSeed) : '')
|
|
777
907
|
}
|
|
@@ -781,55 +911,35 @@ export const render = async (data, options = {}) => {
|
|
|
781
911
|
// pathname is the actual URL path (e.g. /podcast/abc-123), route is the page pattern (e.g. /podcast/:id)
|
|
782
912
|
const locationPath = pathname || route
|
|
783
913
|
|
|
784
|
-
//
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
const _prevWinPrefetch = globalThis.window
|
|
792
|
-
if (!globalThis.location || globalThis.location.pathname !== locationPath) {
|
|
793
|
-
globalThis.location = { pathname: locationPath, href: locationPath, search: '', hash: '', origin: '' }
|
|
794
|
-
}
|
|
795
|
-
if (!globalThis.window) {
|
|
796
|
-
globalThis.window = { location: globalThis.location }
|
|
797
|
-
}
|
|
798
|
-
let prefetchedPages
|
|
799
|
-
if (prefetch) {
|
|
800
|
-
try {
|
|
801
|
-
const pages = data.pages || {}
|
|
802
|
-
prefetchedPages = { ...pages }
|
|
803
|
-
const stateUpdates = await prefetchPageData(data, route)
|
|
804
|
-
if (stateUpdates.size) {
|
|
805
|
-
// Deep clone the page def to avoid mutating the original
|
|
806
|
-
const pageDef = JSON.parse(JSON.stringify(pages[route], (key, value) => {
|
|
807
|
-
if (typeof value === 'function') return undefined
|
|
808
|
-
return value
|
|
809
|
-
}))
|
|
810
|
-
// Re-attach functions from original
|
|
811
|
-
const copyFunctions = (src, dst) => {
|
|
812
|
-
if (!src || !dst) return
|
|
813
|
-
for (const k in src) {
|
|
814
|
-
if (typeof src[k] === 'function') {
|
|
815
|
-
dst[k] = src[k]
|
|
816
|
-
} else if (typeof src[k] === 'object' && src[k] !== null && !Array.isArray(src[k]) && typeof dst[k] === 'object' && dst[k] !== null) {
|
|
817
|
-
copyFunctions(src[k], dst[k])
|
|
818
|
-
}
|
|
819
|
-
}
|
|
820
|
-
}
|
|
821
|
-
copyFunctions(pages[route], pageDef)
|
|
822
|
-
injectPrefetchedState(pageDef, stateUpdates)
|
|
823
|
-
prefetchedPages[route] = pageDef
|
|
824
|
-
}
|
|
825
|
-
} catch (prefetchErr) {
|
|
826
|
-
console.error('[brender] Prefetch error:', prefetchErr)
|
|
827
|
-
prefetchedPages = data.pages
|
|
828
|
-
}
|
|
914
|
+
// This render's diagnostics (see `DIAG`), open before its first request.
|
|
915
|
+
trackFetches()
|
|
916
|
+
const diag = openRenderRecord(DIAG, () => ({ failures: [], labelKeys: new Set(), tainted: false }))
|
|
917
|
+
try {
|
|
918
|
+
return await renderWithDiagnostics(data, { route, locationPath, stateOverrides, contextOverrides, prefetch }, diag.record)
|
|
919
|
+
} finally {
|
|
920
|
+
diag.close()
|
|
829
921
|
}
|
|
922
|
+
}
|
|
923
|
+
|
|
924
|
+
const renderWithDiagnostics = async (data, { route, locationPath, stateOverrides, contextOverrides, prefetch }, diag) => {
|
|
925
|
+
// The page's data is NOT fetched here. The fetch plugin runs during the
|
|
926
|
+
// render below with the real element — its `transform(data, el, state)`,
|
|
927
|
+
// its `children` re-run on the answer — and the post-create flush waits
|
|
928
|
+
// for its requests. A pre-fetch used to run every page `fetch` through a
|
|
929
|
+
// second code path: `transform(data)` without the element (a transform
|
|
930
|
+
// reading `el.call(...)` threw, so the RAW rows landed in state) and
|
|
931
|
+
// `children` functions evaluated once with a stand-in element whose
|
|
932
|
+
// call() hard-coded 'ka' and read the wrong argument, then frozen into
|
|
933
|
+
// static arrays the real answer could no longer replace — cards without
|
|
934
|
+
// titles, a calendar of NaN (xma.info report, brender build items 5, 13).
|
|
830
935
|
|
|
831
936
|
// ── SSR polyglot translations ──
|
|
832
|
-
//
|
|
937
|
+
// Labels a project serves through a table/RPC (`polyglot.fetch: { rpc }`)
|
|
938
|
+
// are fetched before the render, for every language. A FUNCTION
|
|
939
|
+
// `polyglot.fetch` is the project's own loader: it runs inside the render,
|
|
940
|
+
// called by the polyglot plugin with the real root element (see
|
|
941
|
+
// fetchSSRTranslations), and its labels are read back from the root state
|
|
942
|
+
// afterwards (renderedLabels).
|
|
833
943
|
let ssrTranslations
|
|
834
944
|
if (prefetch) {
|
|
835
945
|
try {
|
|
@@ -839,24 +949,27 @@ export const render = async (data, options = {}) => {
|
|
|
839
949
|
}
|
|
840
950
|
}
|
|
841
951
|
|
|
842
|
-
// Restore location/window before createEnv sets them properly
|
|
843
|
-
if (_prevLocPrefetch !== undefined) globalThis.location = _prevLocPrefetch
|
|
844
|
-
else delete globalThis.location
|
|
845
|
-
if (_prevWinPrefetch !== undefined) globalThis.window = _prevWinPrefetch
|
|
846
|
-
else delete globalThis.window
|
|
847
|
-
|
|
848
952
|
const { window, document } = createEnv()
|
|
849
953
|
const body = document.body
|
|
850
954
|
|
|
851
955
|
// Set route on location so the router picks it up
|
|
852
956
|
window.location.pathname = locationPath
|
|
853
957
|
|
|
854
|
-
//
|
|
855
|
-
//
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
958
|
+
// globalThis.document/location for the bundled smbls code (which uses
|
|
959
|
+
// `window = globalThis`) during SSR, and the render's own storage behind
|
|
960
|
+
// the bare `localStorage` the framework reads (smbls prepareState):
|
|
961
|
+
// polyglot persists the language a page switched to and the labels it
|
|
962
|
+
// loaded there, and one shared store handed them to the next page of the
|
|
963
|
+
// build (createEnv). Swapped by descriptor and put back however the render
|
|
964
|
+
// ends, a throw included — a host's own Web Storage global (Node's is a
|
|
965
|
+
// lazy accessor) is never read or lost.
|
|
966
|
+
const restoreGlobals = [
|
|
967
|
+
swapGlobal('document', document),
|
|
968
|
+
swapGlobal('location', window.location),
|
|
969
|
+
swapGlobal('localStorage', window.localStorage),
|
|
970
|
+
swapGlobal('sessionStorage', window.sessionStorage)
|
|
971
|
+
]
|
|
972
|
+
try {
|
|
860
973
|
|
|
861
974
|
// Import createDomqlElement via bundled smbls source.
|
|
862
975
|
// The smbls monorepo uses extensionless/directory imports that Node.js ESM
|
|
@@ -889,7 +1002,7 @@ export const render = async (data, options = {}) => {
|
|
|
889
1002
|
if (ssrTranslations || polyglotConfig) {
|
|
890
1003
|
if (!baseState.root) baseState.root = {}
|
|
891
1004
|
if (polyglotConfig) {
|
|
892
|
-
baseState.root.lang = baseState.root.lang
|
|
1005
|
+
baseState.root.lang = polyglotFallbackLang(baseState.root.lang, polyglotConfig)
|
|
893
1006
|
}
|
|
894
1007
|
if (ssrTranslations) {
|
|
895
1008
|
baseState.root.translations = {
|
|
@@ -913,8 +1026,7 @@ export const render = async (data, options = {}) => {
|
|
|
913
1026
|
// build reuses. The explicit keys below still win.
|
|
914
1027
|
const projectContext = {}
|
|
915
1028
|
for (const key in data) {
|
|
916
|
-
if (key
|
|
917
|
-
key === 'state' || key === 'snippets' || key === 'designSystem' || key === 'dependencies') continue
|
|
1029
|
+
if (CONTEXT_KEYS_SET_BELOW.has(key)) continue
|
|
918
1030
|
const value = data[key]
|
|
919
1031
|
projectContext[key] = value && typeof value === 'object' ? structuredCloneDeep(value) : value
|
|
920
1032
|
}
|
|
@@ -926,7 +1038,7 @@ export const render = async (data, options = {}) => {
|
|
|
926
1038
|
dependencies: structuredCloneDeep(data.dependencies || {}),
|
|
927
1039
|
components: structuredCloneDeep(data.components || {}),
|
|
928
1040
|
snippets: structuredCloneDeep(data.snippets || {}),
|
|
929
|
-
pages: structuredCloneDeep(
|
|
1041
|
+
pages: structuredCloneDeep(data.pages || {}),
|
|
930
1042
|
functions: {
|
|
931
1043
|
...(data.functions || {}),
|
|
932
1044
|
// SSR polyglot functions — enable {{ key | polyglot }} resolution during render
|
|
@@ -949,6 +1061,11 @@ export const render = async (data, options = {}) => {
|
|
|
949
1061
|
// Disable sourcemap tracking in SSR — it causes stack overflows
|
|
950
1062
|
// when state contains large data arrays (articles, events, etc.)
|
|
951
1063
|
domqlOptions: { sourcemap: false },
|
|
1064
|
+
// The server builds the page in one go: the browser boot's short tasks
|
|
1065
|
+
// (smbls firstRender.js) serve input and paint, and a server has neither.
|
|
1066
|
+
// smbls skips them by itself in a realm without requestAnimationFrame;
|
|
1067
|
+
// this document shims it (env.js), so it says so here.
|
|
1068
|
+
firstRender: false,
|
|
952
1069
|
// Editor/live-sync, inspector and toast plugins have no place in server
|
|
953
1070
|
// markup. The source-bundle path used to stub @symbo.ls/sync to no-ops at
|
|
954
1071
|
// bundle time; the installed `smbls/ssr` entry carries the real plugins,
|
|
@@ -958,6 +1075,19 @@ export const render = async (data, options = {}) => {
|
|
|
958
1075
|
sync: false,
|
|
959
1076
|
inspect: false,
|
|
960
1077
|
notifications: false,
|
|
1078
|
+
// Every server render processes the design system the way the page's
|
|
1079
|
+
// own single boot does, on a config of its own — the first render of a
|
|
1080
|
+
// process used to share the global config and every later one took the
|
|
1081
|
+
// iframe path, so their theme variables differed (smbls
|
|
1082
|
+
// prepareDesignSystem; xma.info report, brender build item 7).
|
|
1083
|
+
isolateDesignSystem: true,
|
|
1084
|
+
// Every server render starts from an empty query cache (@symbo.ls/fetch
|
|
1085
|
+
// enterContext). The fetch plugin's cache outlives a render, so a page
|
|
1086
|
+
// rendered after another one that read the same key showed those rows
|
|
1087
|
+
// without a request, and its fetch seed — recorded from requests — did
|
|
1088
|
+
// not carry them: the browser asked for them again after boot (xma.info,
|
|
1089
|
+
// 2026-10-16: index.html 10 seed entries, about.html 3).
|
|
1090
|
+
isolateFetchCache: true,
|
|
961
1091
|
// Caller overrides
|
|
962
1092
|
...(contextOverrides || {})
|
|
963
1093
|
}
|
|
@@ -973,21 +1103,19 @@ export const render = async (data, options = {}) => {
|
|
|
973
1103
|
|
|
974
1104
|
resetKeys()
|
|
975
1105
|
|
|
976
|
-
|
|
977
|
-
// the recording of this render's answers (see `_seed`): tainted the moment
|
|
1106
|
+
// the recording of this render's answers (see `SEED`): tainted the moment
|
|
978
1107
|
// another render overlaps it
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
else if (_seed) _seed.tainted = true
|
|
982
|
-
const seed = _seed
|
|
1108
|
+
const recording = openRenderRecord(SEED, () => ({ entries: [], bytes: 0, tainted: false }))
|
|
1109
|
+
const seed = recording.record
|
|
983
1110
|
let element
|
|
984
1111
|
try {
|
|
985
1112
|
element = await createDomqlElement(app, ctx)
|
|
986
1113
|
|
|
987
1114
|
// Allow async operations (fetch callbacks, state updates, re-renders) to flush.
|
|
988
|
-
// DOMQL's fetch plugin fires on element creation and updates state
|
|
989
|
-
//
|
|
990
|
-
// and
|
|
1115
|
+
// DOMQL's fetch plugin fires on element creation and updates state
|
|
1116
|
+
// asynchronously — it is what loads the page's data (nothing is
|
|
1117
|
+
// pre-injected) — and a function polyglot.fetch loads the labels the
|
|
1118
|
+
// same way. Give enough time for these to complete.
|
|
991
1119
|
//
|
|
992
1120
|
// It used to sleep a flat 2000 ms with prefetch — measured in workerd on
|
|
993
1121
|
// six published projects, `flush` was 2000 ms on every route, 40–90 % of
|
|
@@ -1001,12 +1129,12 @@ export const render = async (data, options = {}) => {
|
|
|
1001
1129
|
const sleep = (ms) => new Promise(r => setTimeout(r, ms))
|
|
1002
1130
|
await sleep(Math.min(50, flushCap))
|
|
1003
1131
|
while (prefetch && inFlightRequests() > 0 && Date.now() - flushStart < flushCap) {
|
|
1004
|
-
|
|
1005
|
-
|
|
1132
|
+
await sleep(10)
|
|
1133
|
+
// a settled batch: another quiet period for what it triggers
|
|
1134
|
+
if (inFlightRequests() === 0) await sleep(Math.max(0, Math.min(50, flushCap - (Date.now() - flushStart))))
|
|
1006
1135
|
}
|
|
1007
1136
|
} finally {
|
|
1008
|
-
|
|
1009
|
-
if (_seed === seed) _seed = null
|
|
1137
|
+
recording.close()
|
|
1010
1138
|
}
|
|
1011
1139
|
const fetchSeed = seed && !seed.tainted ? seed.entries.slice() : []
|
|
1012
1140
|
|
|
@@ -1017,7 +1145,8 @@ export const render = async (data, options = {}) => {
|
|
|
1017
1145
|
|
|
1018
1146
|
// Build element path → brKey registry for client-side true hydration.
|
|
1019
1147
|
// Walks the element tree after mapKeysToElements has set __brKey on each element.
|
|
1020
|
-
const
|
|
1148
|
+
const { elementPath } = await import('@symbo.ls/element')
|
|
1149
|
+
const brRegistry = buildPathRegistry(element, elementPath)
|
|
1021
1150
|
|
|
1022
1151
|
// Extract metadata for the rendered route
|
|
1023
1152
|
// Pass the rendered element and its state so function-valued metadata
|
|
@@ -1025,52 +1154,128 @@ export const render = async (data, options = {}) => {
|
|
|
1025
1154
|
const metadata = extractMetadata(data, route, element, element?.state)
|
|
1026
1155
|
|
|
1027
1156
|
// Extract CSS from style tags in virtual head (atomic CSS engine writes here)
|
|
1028
|
-
const emotionCSS = []
|
|
1029
1157
|
const capturedRules = []
|
|
1030
1158
|
const head = document.head || document.querySelector('head')
|
|
1031
1159
|
if (head) {
|
|
1032
1160
|
for (const style of head.querySelectorAll('style')) {
|
|
1033
1161
|
if (style.sheet && style.sheet.cssRules) {
|
|
1034
1162
|
for (const rule of style.sheet.cssRules) {
|
|
1035
|
-
if (rule.cssText)
|
|
1036
|
-
emotionCSS.push(rule.cssText)
|
|
1037
|
-
capturedRules.push(rule)
|
|
1038
|
-
}
|
|
1163
|
+
if (rule.cssText) capturedRules.push(rule)
|
|
1039
1164
|
}
|
|
1040
1165
|
}
|
|
1041
|
-
if (!
|
|
1166
|
+
if (!capturedRules.length) {
|
|
1042
1167
|
const content = style.textContent || ''
|
|
1043
|
-
if (content) {
|
|
1044
|
-
emotionCSS.push(content)
|
|
1045
|
-
capturedRules.push({ cssText: content })
|
|
1046
|
-
}
|
|
1168
|
+
if (content) capturedRules.push({ cssText: content })
|
|
1047
1169
|
}
|
|
1048
1170
|
}
|
|
1049
1171
|
}
|
|
1172
|
+
const emotionCSS = capturedRules.map((rule) => rule.cssText)
|
|
1050
1173
|
// globalCSS: the init-injected globals; classRules: everything else, in
|
|
1051
1174
|
// capture order (see splitCapturedCSS).
|
|
1052
1175
|
const { globalCSS, classRules } = splitCapturedCSS(capturedRules)
|
|
1053
1176
|
|
|
1054
1177
|
let html = fixSvgContent(body.innerHTML)
|
|
1055
1178
|
|
|
1179
|
+
// The language this page was rendered in and the labels it ended with:
|
|
1180
|
+
// the ones fetched before the render plus whatever the project's own
|
|
1181
|
+
// polyglot.fetch loaded into the root state during it.
|
|
1182
|
+
const root = element?.state?.root
|
|
1183
|
+
const activeLang = activeLangOf(root, polyglotConfig)
|
|
1184
|
+
const labels = renderedLabels(ssrTranslations, root)
|
|
1185
|
+
|
|
1056
1186
|
// Post-process: resolve any remaining {{ key | polyglot }} templates
|
|
1057
|
-
// that weren't resolved during DOMQL rendering (e.g. due to timing)
|
|
1058
|
-
|
|
1059
|
-
|
|
1060
|
-
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
|
|
1064
|
-
|
|
1187
|
+
// that weren't resolved during DOMQL rendering (e.g. due to timing) — in
|
|
1188
|
+
// the page's language. It used the default language, so a page rendered
|
|
1189
|
+
// for /en/… got its leftover templates in the default language's words
|
|
1190
|
+
// (xma.info report, brender build item 8).
|
|
1191
|
+
if (labels) html = resolveLabelTemplates(html, labelMapFor(labels, activeLang, polyglotConfig))
|
|
1192
|
+
|
|
1193
|
+
const diagnostics = diag && !diag.tainted
|
|
1194
|
+
? {
|
|
1195
|
+
failedRequests: diag.failures.slice(),
|
|
1196
|
+
rawLabelKeys: rawLabelKeys(diag.labelKeys, html, activeLang, polyglotConfig, root)
|
|
1197
|
+
}
|
|
1198
|
+
: null
|
|
1199
|
+
|
|
1200
|
+
return { html, metadata, registry, brRegistry, element, emotionCSS, globalCSS, classRules, fetchSeed, document, window, ssrTranslations: labels || undefined, activeLang, diagnostics }
|
|
1201
|
+
} finally {
|
|
1202
|
+
for (const restore of restoreGlobals) restore()
|
|
1065
1203
|
}
|
|
1204
|
+
}
|
|
1066
1205
|
|
|
1067
|
-
|
|
1068
|
-
|
|
1069
|
-
|
|
1070
|
-
|
|
1071
|
-
|
|
1206
|
+
// Put `value` on globalThis[name] for a render and hand back what puts the
|
|
1207
|
+
// old property back, descriptor and all. Never reads the old value — a host
|
|
1208
|
+
// global can be a lazy accessor that warns or throws when read.
|
|
1209
|
+
const swapGlobal = (name, value) => {
|
|
1210
|
+
const previous = Object.getOwnPropertyDescriptor(globalThis, name)
|
|
1211
|
+
if (previous && !previous.configurable) {
|
|
1212
|
+
if (!previous.writable && !previous.set) return () => {}
|
|
1213
|
+
globalThis[name] = value
|
|
1214
|
+
return () => {}
|
|
1215
|
+
}
|
|
1216
|
+
Object.defineProperty(globalThis, name, { value, writable: true, configurable: true, enumerable: true })
|
|
1217
|
+
return () => {
|
|
1218
|
+
if (previous) Object.defineProperty(globalThis, name, previous)
|
|
1219
|
+
else delete globalThis[name]
|
|
1220
|
+
}
|
|
1221
|
+
}
|
|
1222
|
+
|
|
1223
|
+
// ── Labels after a render ───────────────────────────────────────────────────
|
|
1224
|
+
|
|
1225
|
+
const LABEL_TEMPLATE = /\{\{\s*([^|{}]+?)\s*\|\s*polyglot\s*\}\}/g
|
|
1226
|
+
|
|
1227
|
+
const plainOf = (value) => value && typeof value.parse === 'function' ? value.parse() : value
|
|
1228
|
+
|
|
1229
|
+
// The labels a render ended with, by language: the ones fetched before it
|
|
1230
|
+
// (`ssrTranslations`), with the root state's own — loaded during the render
|
|
1231
|
+
// by the project's polyglot.fetch — over them. Null when there are none.
|
|
1232
|
+
const renderedLabels = (ssrTranslations, root) => {
|
|
1233
|
+
const loaded = plainOf(root?.translations)
|
|
1234
|
+
const out = {}
|
|
1235
|
+
for (const source of [ssrTranslations, loaded]) {
|
|
1236
|
+
if (!source || typeof source !== 'object') continue
|
|
1237
|
+
for (const lang in source) {
|
|
1238
|
+
const map = plainOf(source[lang])
|
|
1239
|
+
if (map && typeof map === 'object') out[lang] = { ...(out[lang] || {}), ...map }
|
|
1240
|
+
}
|
|
1241
|
+
}
|
|
1242
|
+
return Object.keys(out).length ? out : null
|
|
1243
|
+
}
|
|
1244
|
+
|
|
1245
|
+
// One language's labels: the page's, else the default language's, else the
|
|
1246
|
+
// first there is — the order the post-render pass always fell back in.
|
|
1247
|
+
const labelMapFor = (labels, activeLang, poly) =>
|
|
1248
|
+
labels[activeLang] || labels[polyglotFallbackLang(undefined, poly)] || Object.values(labels)[0] || {}
|
|
1249
|
+
|
|
1250
|
+
const resolveLabelTemplates = (html, langMap) =>
|
|
1251
|
+
html.replace(LABEL_TEMPLATE, (match, key) => {
|
|
1252
|
+
const val = ssrResolve(langMap, key.trim())
|
|
1253
|
+
return val ?? match
|
|
1254
|
+
})
|
|
1072
1255
|
|
|
1073
|
-
|
|
1256
|
+
// JSON data blocks (registry, fetch seed) are data, not what a visitor reads.
|
|
1257
|
+
const DATA_BLOCK = /<script\b[^>]*\btype\s*=\s*["']?application\/json["']?[^>]*>[\s\S]*?<\/script\s*>/gi
|
|
1258
|
+
// The key as a whole token: not part of a longer key or word.
|
|
1259
|
+
const keyInMarkup = (markup, key) =>
|
|
1260
|
+
new RegExp(`(?:^|[^\\w.-])${escapeRegExp(key)}(?![\\w-]|\\.\\w)`).test(markup)
|
|
1261
|
+
|
|
1262
|
+
// The label keys a page shows raw: every key ssrTranslate missed that the
|
|
1263
|
+
// labels the render ended with still do not have AND that reached the
|
|
1264
|
+
// markup, plus every `{{ key | polyglot }}` template left in it. A lookup
|
|
1265
|
+
// whose result never rendered (a discarded value, a branch not taken) is no
|
|
1266
|
+
// raw key on screen — XMA /vlog failed `--strict` on `ui.vlog.desc`, which
|
|
1267
|
+
// appeared 0 times in the page. A miss with whitespace in it is text handed
|
|
1268
|
+
// to `polyglot`, not a key.
|
|
1269
|
+
const rawLabelKeys = (missed, html, activeLang, poly, root) => {
|
|
1270
|
+
const raw = new Set()
|
|
1271
|
+
const markup = html.replace(DATA_BLOCK, '')
|
|
1272
|
+
for (const key of missed) {
|
|
1273
|
+
if (/\s/.test(key)) continue
|
|
1274
|
+
if (findLabel(key, activeLang, poly, root) !== undefined) continue
|
|
1275
|
+
if (keyInMarkup(markup, key)) raw.add(key)
|
|
1276
|
+
}
|
|
1277
|
+
for (const m of markup.matchAll(LABEL_TEMPLATE)) raw.add(m[1].trim())
|
|
1278
|
+
return [...raw]
|
|
1074
1279
|
}
|
|
1075
1280
|
|
|
1076
1281
|
/**
|
|
@@ -1156,27 +1361,6 @@ const SCRATCH_CONFIG_FLAGS = [
|
|
|
1156
1361
|
'useDocumentTheme', 'useDefaultConfig', 'useDefaultIcons',
|
|
1157
1362
|
'useThemeSuffixedVars', 'verbose', 'semanticIcons',
|
|
1158
1363
|
]
|
|
1159
|
-
// Serialise scratch `buildSkinVarStyles` output — `{ selector: { var: value },
|
|
1160
|
-
// '@media …': { selector: { var: value } } }` — as CSS text
|
|
1161
|
-
// (FW-SCRATCH-SKINS-DIMENSION-1).
|
|
1162
|
-
const declarationBlock = (selector, vars, indent) => {
|
|
1163
|
-
const decls = Object.entries(vars || {})
|
|
1164
|
-
.map(([k, v]) => `${indent} ${k}: ${v}`)
|
|
1165
|
-
.join(';\n')
|
|
1166
|
-
return decls ? `${indent}${selector} {\n${decls};\n${indent}}` : ''
|
|
1167
|
-
}
|
|
1168
|
-
|
|
1169
|
-
export const skinRulesToCSS = (rules) => Object.entries(rules || {})
|
|
1170
|
-
.map(([key, body]) => {
|
|
1171
|
-
if (!key.startsWith('@')) return declarationBlock(key, body, '')
|
|
1172
|
-
const inner = Object.entries(body || {})
|
|
1173
|
-
.map(([selector, vars]) => declarationBlock(selector, vars, ' '))
|
|
1174
|
-
.filter(Boolean)
|
|
1175
|
-
.join('\n')
|
|
1176
|
-
return inner ? `${key} {\n${inner}\n}` : ''
|
|
1177
|
-
})
|
|
1178
|
-
.filter(Boolean)
|
|
1179
|
-
.join('\n\n')
|
|
1180
1364
|
|
|
1181
1365
|
// ── Global CSS: the render's own capture, split ────────────────────────────
|
|
1182
1366
|
// The rules smbls init injects into the virtual <head> during render() ARE
|
|
@@ -1296,8 +1480,9 @@ export const replaceEmotionCSS = (html, newCSS) => {
|
|
|
1296
1480
|
* @returns {Promise<{ html: string, css: string, resetCss: string, fontLinks: string, metadata: object, brKeyCount: number }>}
|
|
1297
1481
|
*/
|
|
1298
1482
|
export const renderRoute = async (data, options = {}) => {
|
|
1299
|
-
// `prefetch: false`
|
|
1300
|
-
// — the publish-time pre-render (smbls publish →
|
|
1483
|
+
// `prefetch: false` fetches no table/RPC labels first and does not wait for
|
|
1484
|
+
// the page's requests — the publish-time pre-render (smbls publish →
|
|
1485
|
+
// ssrCache) renders data-free.
|
|
1301
1486
|
const { route = '/', pathname, prefetch = true } = options
|
|
1302
1487
|
|
|
1303
1488
|
// Use the full render pipeline which handles polyglot, prefetch, emotion, etc.
|
|
@@ -1308,39 +1493,16 @@ export const renderRoute = async (data, options = {}) => {
|
|
|
1308
1493
|
const ds = data.designSystem || {}
|
|
1309
1494
|
const globalCSS = result.globalCSS
|
|
1310
1495
|
|
|
1311
|
-
//
|
|
1496
|
+
// The page data the render ended with and its language, for a caller's
|
|
1497
|
+
// metadata (mermaid's fallback reads them). The data is what the fetch
|
|
1498
|
+
// plugin put into state during the render (collectFetchedState) — it used
|
|
1499
|
+
// to be the RAW rows a pre-fetch had injected into the page definition.
|
|
1312
1500
|
let prefetchedState = null
|
|
1313
|
-
let activeLang = null
|
|
1314
1501
|
try {
|
|
1315
|
-
const
|
|
1316
|
-
|
|
1317
|
-
activeLang = el?.state?.root?.lang || polyglot?.defaultLang || 'en'
|
|
1318
|
-
|
|
1319
|
-
// Get prefetched data from the injected page definitions (pre-DOMQL proxy)
|
|
1320
|
-
if (result.prefetchedPages && result.prefetchedPages[route]) {
|
|
1321
|
-
const pageDef = result.prefetchedPages[route]
|
|
1322
|
-
// Collect all state entries from the page definition tree
|
|
1323
|
-
const collectStates = (def, result = {}) => {
|
|
1324
|
-
if (!def || typeof def !== 'object') return result
|
|
1325
|
-
if (def.state && typeof def.state === 'object') {
|
|
1326
|
-
for (const [k, v] of Object.entries(def.state)) {
|
|
1327
|
-
if (v !== undefined && v !== null && typeof v !== 'function') {
|
|
1328
|
-
result[k] = v
|
|
1329
|
-
}
|
|
1330
|
-
}
|
|
1331
|
-
}
|
|
1332
|
-
// Recurse into all child definitions (not just those with state)
|
|
1333
|
-
for (const [key, child] of Object.entries(def)) {
|
|
1334
|
-
if (key === 'state' || key === 'props' || key === 'attr' || key === 'on' || key === 'define' || key === '__ref' || key.startsWith('__')) continue
|
|
1335
|
-
if (child && typeof child === 'object' && !Array.isArray(child)) {
|
|
1336
|
-
collectStates(child, result)
|
|
1337
|
-
}
|
|
1338
|
-
}
|
|
1339
|
-
return result
|
|
1340
|
-
}
|
|
1341
|
-
prefetchedState = collectStates(pageDef)
|
|
1342
|
-
}
|
|
1502
|
+
const fetched = collectFetchedState(result.element)
|
|
1503
|
+
if (Object.keys(fetched).length) prefetchedState = fetched
|
|
1343
1504
|
} catch (e) { /* ignore */ }
|
|
1505
|
+
const activeLang = result.activeLang
|
|
1344
1506
|
|
|
1345
1507
|
return {
|
|
1346
1508
|
html: result.html,
|
|
@@ -1354,14 +1516,39 @@ export const renderRoute = async (data, options = {}) => {
|
|
|
1354
1516
|
metadata: result.metadata || extractMetadata(data, route),
|
|
1355
1517
|
brKeyCount: result.registry ? Object.keys(result.registry).length : 0,
|
|
1356
1518
|
brRegistry: result.brRegistry || {},
|
|
1357
|
-
// anonymous GET answers this render got (see `
|
|
1519
|
+
// anonymous GET answers this render got (see `SEED`) — for adoptionScripts
|
|
1358
1520
|
fetchSeed: result.fetchSeed || [],
|
|
1359
1521
|
ssrTranslations: result.ssrTranslations,
|
|
1360
1522
|
prefetchedState,
|
|
1361
|
-
activeLang
|
|
1523
|
+
activeLang,
|
|
1524
|
+
// failed requests and raw label keys (see `DIAG`); null when an
|
|
1525
|
+
// overlapping render made them unknowable
|
|
1526
|
+
diagnostics: result.diagnostics
|
|
1362
1527
|
}
|
|
1363
1528
|
}
|
|
1364
1529
|
|
|
1530
|
+
// What the fetch plugin put into state during a render, by the `as` key each
|
|
1531
|
+
// `fetch` config names — a `~/key` one read off the root state. Configs
|
|
1532
|
+
// without `as` (a transform spreading into the element's state) and function
|
|
1533
|
+
// configs are not read: a function would have to run again.
|
|
1534
|
+
const collectFetchedState = (element) => {
|
|
1535
|
+
const out = {}
|
|
1536
|
+
walkElements(element, (el) => {
|
|
1537
|
+
const decl = el.fetch
|
|
1538
|
+
if (!decl || typeof decl !== 'object' || !el.state) return
|
|
1539
|
+
for (const cfg of Array.isArray(decl) ? decl : [decl]) {
|
|
1540
|
+
const as = cfg && typeof cfg === 'object' && typeof cfg.as === 'string' ? cfg.as : null
|
|
1541
|
+
if (!as) continue
|
|
1542
|
+
const fromRoot = as.startsWith('~/')
|
|
1543
|
+
const name = fromRoot ? as.slice(2) : as
|
|
1544
|
+
const source = fromRoot ? el.state.root : el.state
|
|
1545
|
+
const value = plainOf(source && source[name])
|
|
1546
|
+
if (name && value != null && typeof value !== 'function') out[name] = value
|
|
1547
|
+
}
|
|
1548
|
+
})
|
|
1549
|
+
return out
|
|
1550
|
+
}
|
|
1551
|
+
|
|
1365
1552
|
// ── Full page SSR ─────────────────────────────────────────────────────────────
|
|
1366
1553
|
|
|
1367
1554
|
/**
|
|
@@ -1378,12 +1565,20 @@ export const renderRoute = async (data, options = {}) => {
|
|
|
1378
1565
|
* @param {string} [options.themeColor] - theme-color meta
|
|
1379
1566
|
* @param {object} [options.isr] - ISR options with clientScript path
|
|
1380
1567
|
* @param {boolean} [options.hydrate=true] - With `isr`: boot the client over the
|
|
1381
|
-
* pre-rendered markup (
|
|
1382
|
-
*
|
|
1383
|
-
* @param {boolean} [options.adoptDom=false] -
|
|
1384
|
-
* element-path registry,
|
|
1385
|
-
*
|
|
1386
|
-
*
|
|
1568
|
+
* pre-rendered markup (the page's `<meta name="brender">`) instead of the
|
|
1569
|
+
* legacy swap script
|
|
1570
|
+
* @param {boolean} [options.adoptDom=false] - Hydration: also ship the
|
|
1571
|
+
* element-path registry (a JSON data block), so the client ADOPTS the
|
|
1572
|
+
* server DOM (smbls prerender.js) instead of replacing it. `smbls build`
|
|
1573
|
+
* passes symbols.json `brender.hydrate` (cli helpers/runBrender.js).
|
|
1574
|
+
* @param {string} [options.nonce] - The Content-Security-Policy nonce for the
|
|
1575
|
+
* inline scripts the page needs (the ISR paths only: a page rendered into a
|
|
1576
|
+
* shell carries none — its flag is a <meta>, its registry and fetch seed are
|
|
1577
|
+
* data blocks, which no policy blocks)
|
|
1578
|
+
* @param {boolean} [options.prefetch=true] - Fetch table/RPC labels before the
|
|
1579
|
+
* render and wait (up to 2 s) for the requests the page starts; false waits
|
|
1580
|
+
* only a 50 ms quiet period. The page's data always comes from the fetch
|
|
1581
|
+
* plugin during the render.
|
|
1387
1582
|
* @param {string} [options.shell] - A built app document (e.g. dist/index.html)
|
|
1388
1583
|
* to render INTO instead of emitting a standalone document (see shell.js).
|
|
1389
1584
|
* The result then carries `shell: true`.
|
|
@@ -1393,13 +1588,18 @@ export const renderRoute = async (data, options = {}) => {
|
|
|
1393
1588
|
* @returns {Promise<{ html: string, route: string, brKeyCount: number, shell?: true }>}
|
|
1394
1589
|
*/
|
|
1395
1590
|
export const renderPage = async (data, route = '/', options = {}) => {
|
|
1396
|
-
const { lang, themeColor, isr, hydrate = true, prefetch = true, shell, adoptDom = false } = options
|
|
1591
|
+
const { lang, themeColor, isr, hydrate = true, prefetch = true, shell, adoptDom = false, pathname, eagerImages = EAGER_IMAGES_DEFAULT, preloadScripts = true, nonce } = options
|
|
1592
|
+
const nonceAttr = nonceAttribute(nonce)
|
|
1397
1593
|
|
|
1398
1594
|
// Detect lang from project config, app metadata, or default
|
|
1399
1595
|
const htmlLang = lang || data.state?.lang || data.app?.metadata?.lang || 'en'
|
|
1400
1596
|
|
|
1401
|
-
// Use the full smbls pipeline for rendering
|
|
1402
|
-
|
|
1597
|
+
// Use the full smbls pipeline for rendering. `pathname` is the address the
|
|
1598
|
+
// page is rendered at — the router matches it, location and every
|
|
1599
|
+
// canonical/og:url built from it read it. Without it a route was rendered
|
|
1600
|
+
// at its own pattern: the `/*` page wrote a `/*` canonical into 404.html
|
|
1601
|
+
// (xma.info report, brender build item 7).
|
|
1602
|
+
const result = await render(data, { route, pathname, prefetch })
|
|
1403
1603
|
if (!result) return null
|
|
1404
1604
|
|
|
1405
1605
|
const metadata = { ...result.metadata }
|
|
@@ -1432,15 +1632,23 @@ export const renderPage = async (data, route = '/', options = {}) => {
|
|
|
1432
1632
|
}
|
|
1433
1633
|
const emotionCSS = Array.from(_accumulatedEmotionCSS).join('\n')
|
|
1434
1634
|
|
|
1435
|
-
// Global CSS (variables, reset, keyframes): this render's own init output
|
|
1436
|
-
|
|
1635
|
+
// Global CSS (variables, reset, keyframes): this render's own init output.
|
|
1636
|
+
// The style tags, built once for both documents below.
|
|
1437
1637
|
const globalCSS = result.globalCSS
|
|
1638
|
+
const brenderStyle = `<style data-brender>\n${globalCSS.rootRule || ''}\n${globalCSS.resetRules || ''}\n${globalCSS.keyframeRules || ''}\n</style>`
|
|
1639
|
+
const emotionStyle = emotionCSS ? `<style data-emotion="smbls">\n${emotionCSS}\n</style>` : ''
|
|
1438
1640
|
|
|
1439
1641
|
// Generate font links from design system
|
|
1440
|
-
const fontLinks = generateFontLinks(
|
|
1642
|
+
const fontLinks = generateFontLinks(data.designSystem || {})
|
|
1441
1643
|
|
|
1442
1644
|
const brKeyCount = Object.keys(result.registry).length
|
|
1443
1645
|
|
|
1646
|
+
// What the client boots with (prerenderParts): the page's mark for <head>,
|
|
1647
|
+
// and the data blocks after the markup — the registry under `adoptDom`,
|
|
1648
|
+
// and the recorded anonymous answers, so the client renders from the data
|
|
1649
|
+
// the server used instead of asking for all of it again.
|
|
1650
|
+
const prerender = prerenderParts({ brRegistry: result.brRegistry, fetchSeed: result.fetchSeed, adoptDom })
|
|
1651
|
+
|
|
1444
1652
|
// ISR: include client SPA bundle for hydration + data fetching
|
|
1445
1653
|
let isrBody = ''
|
|
1446
1654
|
if (isr && isr.clientScript) {
|
|
@@ -1453,9 +1661,9 @@ export const renderPage = async (data, route = '/', options = {}) => {
|
|
|
1453
1661
|
const prefix = depth > 0 ? '../'.repeat(depth) : './'
|
|
1454
1662
|
|
|
1455
1663
|
if (hydrate) {
|
|
1456
|
-
//
|
|
1457
|
-
//
|
|
1458
|
-
//
|
|
1664
|
+
// The client boots over the markup: it knows the page by its
|
|
1665
|
+
// `<meta name="brender">` and adopts the markup when the page carries
|
|
1666
|
+
// the registry (`adoptDom`), else replaces it (smbls prerender.js).
|
|
1459
1667
|
//
|
|
1460
1668
|
// Seed client-side polyglot with SSR translations so that
|
|
1461
1669
|
// el.call('polyglot', key) resolves immediately during hydration
|
|
@@ -1473,27 +1681,20 @@ export const renderPage = async (data, route = '/', options = {}) => {
|
|
|
1473
1681
|
}
|
|
1474
1682
|
}
|
|
1475
1683
|
if (storageLangKey) {
|
|
1476
|
-
const defaultLang = polyglotCfg
|
|
1684
|
+
const defaultLang = polyglotFallbackLang(undefined, polyglotCfg)
|
|
1477
1685
|
seedEntries.push(`if(!localStorage.getItem(${JSON.stringify(storageLangKey)}))localStorage.setItem(${JSON.stringify(storageLangKey)},${JSON.stringify(defaultLang)})`)
|
|
1478
1686
|
}
|
|
1479
1687
|
if (seedEntries.length) {
|
|
1480
|
-
translationSeed = `<script>try{${seedEntries.join(';')}}catch(e){}</script>\n`
|
|
1688
|
+
translationSeed = `<script${nonceAttr}>try{${seedEntries.join(';')}}catch(e){}</script>\n`
|
|
1481
1689
|
}
|
|
1482
1690
|
}
|
|
1483
|
-
//
|
|
1484
|
-
|
|
1485
|
-
|
|
1486
|
-
|
|
1487
|
-
const brRegistryScript = adoptDom
|
|
1488
|
-
? (adoptionScripts(result) ? adoptionScripts(result) + '\n' : '')
|
|
1489
|
-
: brRegistryJson
|
|
1490
|
-
? `<script>window.__BR_REGISTRY__=${brRegistryJson}</script>\n`
|
|
1491
|
-
: ''
|
|
1492
|
-
isrBody = `${translationSeed}${brRegistryScript}<script>window.__BRENDER__=true</script>
|
|
1493
|
-
<script type="module" src="${prefix}${isr.clientScript}"></script>`
|
|
1691
|
+
// The registry and the fetch seed as data blocks; the page's mark is
|
|
1692
|
+
// its <meta name="brender"> (no inline flag a policy could block).
|
|
1693
|
+
const blocks = prerender.blocks ? prerender.blocks + '\n' : ''
|
|
1694
|
+
isrBody = `${translationSeed}${blocks}<script type="module" src="${prefix}${isr.clientScript}"${nonceAttr}></script>`
|
|
1494
1695
|
} else {
|
|
1495
1696
|
// Legacy swap mode: SPA creates new DOM, MutationObserver removes brender nodes
|
|
1496
|
-
isrBody = `<script type="module">
|
|
1697
|
+
isrBody = `<script type="module"${nonceAttr}>
|
|
1497
1698
|
{
|
|
1498
1699
|
const brEls = document.querySelectorAll('body > :not(script):not(style)')
|
|
1499
1700
|
const observer = new MutationObserver((mutations) => {
|
|
@@ -1510,461 +1711,100 @@ export const renderPage = async (data, route = '/', options = {}) => {
|
|
|
1510
1711
|
observer.observe(document.body, { childList: true })
|
|
1511
1712
|
}
|
|
1512
1713
|
</script>
|
|
1513
|
-
<script type="module" src="${prefix}${isr.clientScript}"></script>`
|
|
1714
|
+
<script type="module" src="${prefix}${isr.clientScript}"${nonceAttr}></script>`
|
|
1514
1715
|
}
|
|
1515
1716
|
}
|
|
1516
1717
|
|
|
1517
1718
|
// Resolve any {{ key | polyglot }} templates in head tags (title, meta, etc.)
|
|
1719
|
+
// — in the page's language, with the labels the render ended with.
|
|
1518
1720
|
const headConfig = { ...(data.config || {}) }
|
|
1519
1721
|
if (data.polyglot && !headConfig.polyglot) headConfig.polyglot = data.polyglot
|
|
1520
1722
|
const polyglotCfg = headConfig.polyglot
|
|
1521
1723
|
let resolvedHeadTags = headTags
|
|
1522
1724
|
if (polyglotCfg) {
|
|
1523
|
-
const defaultLang = polyglotCfg.defaultLang || 'en'
|
|
1524
1725
|
// Use SSR-fetched translations (from render result) merged with static translations
|
|
1525
|
-
const translations = {
|
|
1526
|
-
|
|
1527
|
-
...(
|
|
1726
|
+
const translations = { ...(polyglotCfg.translations || {}) }
|
|
1727
|
+
for (const lang in result.ssrTranslations || {}) {
|
|
1728
|
+
translations[lang] = { ...(translations[lang] || {}), ...result.ssrTranslations[lang] }
|
|
1528
1729
|
}
|
|
1529
|
-
|
|
1530
|
-
resolvedHeadTags = headTags.replace(/\{\{\s*([^|{}]+?)\s*\|\s*polyglot\s*\}\}/g, (match, key) => {
|
|
1531
|
-
const trimmed = key.trim()
|
|
1532
|
-
return langMap[trimmed] ?? match
|
|
1533
|
-
})
|
|
1730
|
+
resolvedHeadTags = resolveLabelTemplates(headTags, labelMapFor(translations, result.activeLang, polyglotCfg))
|
|
1534
1731
|
}
|
|
1535
1732
|
|
|
1733
|
+
// A template the head still carries is a raw key too.
|
|
1734
|
+
const diagnostics = result.diagnostics
|
|
1735
|
+
if (diagnostics) {
|
|
1736
|
+
for (const m of resolvedHeadTags.matchAll(LABEL_TEMPLATE)) {
|
|
1737
|
+
const key = m[1].trim()
|
|
1738
|
+
if (!diagnostics.rawLabelKeys.includes(key)) diagnostics.rawLabelKeys.push(key)
|
|
1739
|
+
}
|
|
1740
|
+
}
|
|
1741
|
+
|
|
1742
|
+
// Images past the first `eagerImages` load lazily unless they say how to
|
|
1743
|
+
// load (see markLazyImages).
|
|
1744
|
+
const bodyHtml = markLazyImages(result.html, eagerImages)
|
|
1745
|
+
|
|
1536
1746
|
// Build path: render INTO the app's own built document (shell.js) — the
|
|
1537
1747
|
// standalone document below carries no app code unless `isr` adds a
|
|
1538
1748
|
// client bundle, so writing it over a bundler's dist/index.html shipped
|
|
1539
1749
|
// pages that never booted.
|
|
1540
1750
|
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
1751
|
const html = composeIntoShell(shell, {
|
|
1547
|
-
headTags: resolvedHeadTags
|
|
1548
|
-
headEnd: [fontLinks,
|
|
1549
|
-
body:
|
|
1752
|
+
headTags: `${resolvedHeadTags}\n${prerender.meta}`,
|
|
1753
|
+
headEnd: [fontLinks, brenderStyle, emotionStyle].filter(Boolean).join('\n'),
|
|
1754
|
+
body: bodyHtml,
|
|
1550
1755
|
depth: Number.isInteger(options.depth) ? options.depth : 0,
|
|
1551
|
-
bootScripts:
|
|
1756
|
+
bootScripts: prerender.blocks,
|
|
1757
|
+
// The page's language on <html>, as the client's polyglot sets it at
|
|
1758
|
+
// boot — a page rendered for /en/… kept the shell's `lang="ka"`.
|
|
1759
|
+
// Only when the project has languages (or the caller names one).
|
|
1760
|
+
lang: lang || (polyglotCfg ? result.activeLang : undefined),
|
|
1761
|
+
preloadScripts
|
|
1552
1762
|
})
|
|
1553
|
-
return { html, route, brKeyCount, shell: true }
|
|
1763
|
+
return { html, route, brKeyCount, shell: true, diagnostics }
|
|
1554
1764
|
}
|
|
1555
1765
|
|
|
1766
|
+
// (The empty line after the font links held a @font-face <style> that
|
|
1767
|
+
// splitCapturedCSS never fills — @font-face stays in the class stream.)
|
|
1556
1768
|
const html = `<!DOCTYPE html>
|
|
1557
1769
|
<html lang="${htmlLang}">
|
|
1558
1770
|
<head>
|
|
1559
|
-
${resolvedHeadTags}
|
|
1771
|
+
${resolvedHeadTags}${isr && isr.clientScript && hydrate ? `\n${prerender.meta}` : ''}
|
|
1560
1772
|
${fontLinks}
|
|
1561
|
-
|
|
1562
|
-
|
|
1563
|
-
${
|
|
1564
|
-
${globalCSS.resetRules || ''}
|
|
1565
|
-
${globalCSS.keyframeRules || ''}
|
|
1566
|
-
</style>
|
|
1567
|
-
${emotionCSS ? `<style data-emotion="smbls">\n${emotionCSS}\n</style>` : ''}
|
|
1773
|
+
|
|
1774
|
+
${brenderStyle}
|
|
1775
|
+
${emotionStyle}
|
|
1568
1776
|
</head>
|
|
1569
1777
|
<body>
|
|
1570
|
-
${
|
|
1778
|
+
${bodyHtml}
|
|
1571
1779
|
${isrBody}
|
|
1572
1780
|
</body>
|
|
1573
1781
|
</html>`
|
|
1574
1782
|
|
|
1575
|
-
return { html, route, brKeyCount }
|
|
1576
|
-
}
|
|
1577
|
-
|
|
1578
|
-
// ── Design system token resolution ──────────────────────────────────────────
|
|
1579
|
-
|
|
1580
|
-
const LETTER_TO_INDEX = {
|
|
1581
|
-
U: -6, V: -5, W: -4, X: -3, Y: -2, Z: -1,
|
|
1582
|
-
A: 0, B: 1, C: 2, D: 3, E: 4, F: 5, G: 6, H: 7, I: 8, J: 9,
|
|
1583
|
-
K: 10, L: 11, M: 12, N: 13, O: 14, P: 15
|
|
1783
|
+
return { html, route, brKeyCount, diagnostics }
|
|
1584
1784
|
}
|
|
1585
1785
|
|
|
1586
|
-
|
|
1587
|
-
|
|
1588
|
-
|
|
1589
|
-
|
|
1590
|
-
|
|
1591
|
-
|
|
1592
|
-
|
|
1593
|
-
|
|
1594
|
-
|
|
1595
|
-
|
|
1596
|
-
|
|
1597
|
-
|
|
1598
|
-
|
|
1599
|
-
|
|
1600
|
-
|
|
1601
|
-
|
|
1602
|
-
|
|
1603
|
-
|
|
1604
|
-
|
|
1605
|
-
|
|
1606
|
-
|
|
1607
|
-
|
|
1608
|
-
if (!token || typeof token !== 'string') return null
|
|
1609
|
-
if (!spacingConfig) return null
|
|
1610
|
-
|
|
1611
|
-
const base = spacingConfig.base || 16
|
|
1612
|
-
const ratio = spacingConfig.ratio || 1.618
|
|
1613
|
-
const unit = spacingConfig.unit || 'px'
|
|
1614
|
-
const hasSubSequence = spacingConfig.subSequence !== false
|
|
1615
|
-
|
|
1616
|
-
// Handle compound values like 'B2 - -' or 'A1 B C1'
|
|
1617
|
-
if (token.includes(' ')) {
|
|
1618
|
-
const parts = token.split(' ').map(part => {
|
|
1619
|
-
if (part === '-' || part === '') return part
|
|
1620
|
-
return resolveSpacingToken(part, spacingConfig) || part
|
|
1621
|
-
})
|
|
1622
|
-
return parts.join(' ')
|
|
1623
|
-
}
|
|
1624
|
-
|
|
1625
|
-
// Skip CSS keywords and values with units
|
|
1626
|
-
if (/^(none|auto|inherit|initial|unset|0)$/i.test(token)) return null
|
|
1627
|
-
if (/\d+(px|em|rem|%|vh|vw|vmin|vmax|ch|ex|cm|mm|in|pt|pc|fr|s|ms)$/i.test(token)) return null
|
|
1628
|
-
// Skip hex colors, rgb(), etc.
|
|
1629
|
-
if (/^(#|rgb|hsl|var\()/i.test(token)) return null
|
|
1630
|
-
|
|
1631
|
-
const isNegative = token.startsWith('-')
|
|
1632
|
-
const abs = isNegative ? token.slice(1) : token
|
|
1633
|
-
|
|
1634
|
-
// Match letter + optional digit: A, B, B2, E3, etc.
|
|
1635
|
-
const m = abs.match(/^([A-Z])(\d)?$/i)
|
|
1636
|
-
if (!m) return null
|
|
1637
|
-
|
|
1638
|
-
const letter = m[1].toUpperCase()
|
|
1639
|
-
const subStep = m[2] ? parseInt(m[2]) : 0
|
|
1640
|
-
const idx = LETTER_TO_INDEX[letter]
|
|
1641
|
-
if (idx === undefined) return null
|
|
1642
|
-
|
|
1643
|
-
let value = base * Math.pow(ratio, idx)
|
|
1644
|
-
|
|
1645
|
-
if (subStep > 0 && hasSubSequence) {
|
|
1646
|
-
const next = base * Math.pow(ratio, idx + 1)
|
|
1647
|
-
const diff = next - value
|
|
1648
|
-
const subRatio = diff / ratio
|
|
1649
|
-
// Sub-steps: 1 = value + (diff - subRatio), 2 = midpoint, 3 = value + subRatio
|
|
1650
|
-
const first = next - subRatio
|
|
1651
|
-
const second = value + subRatio
|
|
1652
|
-
const middle = (first + second) / 2
|
|
1653
|
-
const subs = (~~next - ~~value > 16) ? [first, middle, second] : [first, second]
|
|
1654
|
-
if (subStep <= subs.length) {
|
|
1655
|
-
value = subs[subStep - 1]
|
|
1656
|
-
}
|
|
1657
|
-
}
|
|
1658
|
-
|
|
1659
|
-
const rounded = Math.round(value * 100) / 100
|
|
1660
|
-
const sign = isNegative ? '-' : ''
|
|
1661
|
-
return `${sign}${rounded}${unit}`
|
|
1662
|
-
}
|
|
1663
|
-
|
|
1664
|
-
// Kebab-case versions of spacing props for post-shorthand resolution
|
|
1665
|
-
const SPACING_PROPS_KEBAB = new Set(
|
|
1666
|
-
[...SPACING_PROPS].map(k => k.replace(/[A-Z]/g, m => '-' + m.toLowerCase()))
|
|
1667
|
-
)
|
|
1668
|
-
|
|
1669
|
-
/**
|
|
1670
|
-
* Try to resolve a CSS value through the design system.
|
|
1671
|
-
* Returns the resolved value or the original if not a token.
|
|
1672
|
-
*/
|
|
1673
|
-
const resolveDSValue = (key, val, ds) => {
|
|
1674
|
-
if (typeof val !== 'string') return val
|
|
1675
|
-
|
|
1676
|
-
// Color resolution
|
|
1677
|
-
if (CSS_COLOR_PROPS.has(key)) {
|
|
1678
|
-
const colorMap = ds?.color || {}
|
|
1679
|
-
if (colorMap[val]) return colorMap[val]
|
|
1680
|
-
}
|
|
1681
|
-
|
|
1682
|
-
// Spacing resolution (check both camelCase and kebab-case keys)
|
|
1683
|
-
if (SPACING_PROPS.has(key) || SPACING_PROPS_KEBAB.has(key)) {
|
|
1684
|
-
const spacing = ds?.spacing || {}
|
|
1685
|
-
const resolved = resolveSpacingToken(val, spacing)
|
|
1686
|
-
if (resolved) return resolved
|
|
1687
|
-
}
|
|
1688
|
-
|
|
1689
|
-
return val
|
|
1690
|
-
}
|
|
1691
|
-
|
|
1692
|
-
// ── CSS helpers ─────────────────────────────────────────────────────────────
|
|
1693
|
-
|
|
1694
|
-
const CSS_COLOR_PROPS = new Set([
|
|
1695
|
-
'color', 'background', 'backgroundColor', 'borderColor',
|
|
1696
|
-
'borderTopColor', 'borderRightColor', 'borderBottomColor', 'borderLeftColor',
|
|
1697
|
-
'outlineColor', 'fill', 'stroke'
|
|
1698
|
-
])
|
|
1699
|
-
|
|
1700
|
-
const NON_CSS_PROPS = new Set([
|
|
1701
|
-
'href', 'src', 'alt', 'title', 'id', 'name', 'type', 'value', 'placeholder',
|
|
1702
|
-
'target', 'rel', 'loading', 'srcset', 'sizes', 'media', 'role', 'tabindex',
|
|
1703
|
-
'for', 'action', 'method', 'enctype', 'autocomplete', 'autofocus',
|
|
1704
|
-
'theme', '__element', 'update',
|
|
1705
|
-
'childrenAs', 'childExtends', 'childProps', 'children'
|
|
1706
|
-
])
|
|
1707
|
-
|
|
1708
|
-
const camelToKebab = (str) => str.replace(/[A-Z]/g, m => '-' + m.toLowerCase())
|
|
1709
|
-
|
|
1710
|
-
const resolveShorthand = (key, val) => {
|
|
1711
|
-
if (typeof val === 'undefined' || val === null) return null
|
|
1712
|
-
|
|
1713
|
-
// Flex shorthands
|
|
1714
|
-
if (key === 'flow' && typeof val === 'string') {
|
|
1715
|
-
let [direction, wrap] = (val || 'row').split(' ')
|
|
1716
|
-
if (val.startsWith('x') || val === 'row') direction = 'row'
|
|
1717
|
-
if (val.startsWith('y') || val === 'column') direction = 'column'
|
|
1718
|
-
return { display: 'flex', 'flex-flow': (direction || '') + ' ' + (wrap || '') }
|
|
1719
|
-
}
|
|
1720
|
-
if (key === 'wrap') {
|
|
1721
|
-
return { display: 'flex', 'flex-wrap': val }
|
|
1722
|
-
}
|
|
1723
|
-
if ((key === 'align' || key === 'flexAlign') && typeof val === 'string') {
|
|
1724
|
-
const [alignItems, justifyContent] = val.split(' ')
|
|
1725
|
-
const result = { display: 'flex', 'align-items': alignItems }
|
|
1726
|
-
if (justifyContent) result['justify-content'] = justifyContent
|
|
1727
|
-
return result
|
|
1728
|
-
}
|
|
1729
|
-
if (key === 'gridAlign' && typeof val === 'string') {
|
|
1730
|
-
const [alignItems, justifyContent] = val.split(' ')
|
|
1731
|
-
const result = { display: 'grid', 'align-items': alignItems }
|
|
1732
|
-
if (justifyContent) result['justify-content'] = justifyContent
|
|
1733
|
-
return result
|
|
1734
|
-
}
|
|
1735
|
-
if (key === 'flexFlow' && typeof val === 'string') {
|
|
1736
|
-
let [direction, wrap] = (val || 'row').split(' ')
|
|
1737
|
-
if (val.startsWith('x') || val === 'row') direction = 'row'
|
|
1738
|
-
if (val.startsWith('y') || val === 'column') direction = 'column'
|
|
1739
|
-
return { display: 'flex', 'flex-flow': (direction || '') + ' ' + (wrap || '') }
|
|
1740
|
-
}
|
|
1741
|
-
if (key === 'flexWrap') {
|
|
1742
|
-
return { display: 'flex', 'flex-wrap': val }
|
|
1743
|
-
}
|
|
1744
|
-
|
|
1745
|
-
// Background image shorthand
|
|
1746
|
-
if (key === 'backgroundImage' && typeof val === 'string' && !val.startsWith('url(') && !val.startsWith('linear-gradient') && !val.startsWith('radial-gradient') && !val.startsWith('none')) {
|
|
1747
|
-
return { 'background-image': `url(${val})` }
|
|
1748
|
-
}
|
|
1749
|
-
|
|
1750
|
-
// Box/size shorthands
|
|
1751
|
-
if (key === 'round' || (key === 'borderRadius' && val)) {
|
|
1752
|
-
return { 'border-radius': typeof val === 'number' ? val + 'px' : val }
|
|
1753
|
-
}
|
|
1754
|
-
if (key === 'boxSize' && typeof val === 'string') {
|
|
1755
|
-
const [height, width] = val.split(' ')
|
|
1756
|
-
return { height, width: width || height }
|
|
1757
|
-
}
|
|
1758
|
-
if (key === 'widthRange' && typeof val === 'string') {
|
|
1759
|
-
const [minWidth, maxWidth] = val.split(' ')
|
|
1760
|
-
return { 'min-width': minWidth, 'max-width': maxWidth || minWidth }
|
|
1761
|
-
}
|
|
1762
|
-
if (key === 'heightRange' && typeof val === 'string') {
|
|
1763
|
-
const [minHeight, maxHeight] = val.split(' ')
|
|
1764
|
-
return { 'min-height': minHeight, 'max-height': maxHeight || minHeight }
|
|
1765
|
-
}
|
|
1766
|
-
|
|
1767
|
-
// Grid aliases
|
|
1768
|
-
if (key === 'column') return { 'grid-column': val }
|
|
1769
|
-
if (key === 'columns') return { 'grid-template-columns': val }
|
|
1770
|
-
if (key === 'templateColumns') return { 'grid-template-columns': val }
|
|
1771
|
-
if (key === 'row') return { 'grid-row': val }
|
|
1772
|
-
if (key === 'rows') return { 'grid-template-rows': val }
|
|
1773
|
-
if (key === 'templateRows') return { 'grid-template-rows': val }
|
|
1774
|
-
if (key === 'area') return { 'grid-area': val }
|
|
1775
|
-
if (key === 'template') return { 'grid-template': val }
|
|
1776
|
-
if (key === 'templateAreas') return { 'grid-template-areas': val }
|
|
1777
|
-
if (key === 'autoColumns') return { 'grid-auto-columns': val }
|
|
1778
|
-
if (key === 'autoRows') return { 'grid-auto-rows': val }
|
|
1779
|
-
if (key === 'autoFlow') return { 'grid-auto-flow': val }
|
|
1780
|
-
if (key === 'columnStart') return { 'grid-column-start': val }
|
|
1781
|
-
if (key === 'rowStart') return { 'grid-row-start': val }
|
|
1782
|
-
|
|
1783
|
-
return null
|
|
1784
|
-
}
|
|
1785
|
-
|
|
1786
|
-
const resolveInnerProps = (obj, ds) => {
|
|
1787
|
-
const result = {}
|
|
1788
|
-
for (const k in obj) {
|
|
1789
|
-
const v = obj[k]
|
|
1790
|
-
const expanded = resolveShorthand(k, v)
|
|
1791
|
-
if (expanded) {
|
|
1792
|
-
for (const ek in expanded) {
|
|
1793
|
-
result[ek] = resolveDSValue(ek, expanded[ek], ds)
|
|
1794
|
-
}
|
|
1795
|
-
continue
|
|
1796
|
-
}
|
|
1797
|
-
if (typeof v !== 'string' && typeof v !== 'number') continue
|
|
1798
|
-
result[camelToKebab(k)] = resolveDSValue(k, v, ds)
|
|
1799
|
-
}
|
|
1800
|
-
return result
|
|
1801
|
-
}
|
|
1802
|
-
|
|
1803
|
-
const buildCSSFromProps = (props, ds, mediaMap) => {
|
|
1804
|
-
const base = {}
|
|
1805
|
-
const mediaRules = {}
|
|
1806
|
-
const pseudoRules = {}
|
|
1807
|
-
|
|
1808
|
-
for (const key in props) {
|
|
1809
|
-
const val = props[key]
|
|
1810
|
-
|
|
1811
|
-
if (key.charCodeAt(0) === 64 && typeof val === 'object') {
|
|
1812
|
-
const bp = mediaMap?.[key.slice(1)]
|
|
1813
|
-
if (bp) {
|
|
1814
|
-
const inner = resolveInnerProps(val, ds)
|
|
1815
|
-
if (Object.keys(inner).length) mediaRules[bp] = inner
|
|
1816
|
-
}
|
|
1817
|
-
continue
|
|
1818
|
-
}
|
|
1819
|
-
|
|
1820
|
-
if (key.charCodeAt(0) === 58 && typeof val === 'object') {
|
|
1821
|
-
const inner = resolveInnerProps(val, ds)
|
|
1822
|
-
if (Object.keys(inner).length) pseudoRules[key] = inner
|
|
1823
|
-
continue
|
|
1824
|
-
}
|
|
1825
|
-
|
|
1826
|
-
if (typeof val !== 'string' && typeof val !== 'number') continue
|
|
1827
|
-
if (key.charCodeAt(0) >= 65 && key.charCodeAt(0) <= 90) continue
|
|
1828
|
-
if (NON_CSS_PROPS.has(key)) continue
|
|
1829
|
-
|
|
1830
|
-
const expanded = resolveShorthand(key, val)
|
|
1831
|
-
if (expanded) {
|
|
1832
|
-
for (const ek in expanded) {
|
|
1833
|
-
base[ek] = resolveDSValue(ek, expanded[ek], ds)
|
|
1834
|
-
}
|
|
1835
|
-
continue
|
|
1836
|
-
}
|
|
1837
|
-
|
|
1838
|
-
base[camelToKebab(key)] = resolveDSValue(key, val, ds)
|
|
1839
|
-
}
|
|
1840
|
-
|
|
1841
|
-
return { base, mediaRules, pseudoRules }
|
|
1842
|
-
}
|
|
1843
|
-
|
|
1844
|
-
const renderCSSRule = (selector, { base, mediaRules, pseudoRules }) => {
|
|
1845
|
-
const lines = []
|
|
1846
|
-
const baseDecls = Object.entries(base).map(([k, v]) => `${k}: ${v}`).join('; ')
|
|
1847
|
-
if (baseDecls) lines.push(`${selector} { ${baseDecls}; }`)
|
|
1848
|
-
|
|
1849
|
-
for (const [pseudo, p] of Object.entries(pseudoRules)) {
|
|
1850
|
-
const decls = Object.entries(p).map(([k, v]) => `${k}: ${v}`).join('; ')
|
|
1851
|
-
if (decls) lines.push(`${selector}${pseudo} { ${decls}; }`)
|
|
1852
|
-
}
|
|
1853
|
-
|
|
1854
|
-
for (const [query, p] of Object.entries(mediaRules)) {
|
|
1855
|
-
const decls = Object.entries(p).map(([k, v]) => `${k}: ${v}`).join('; ')
|
|
1856
|
-
const mq = query.startsWith('@') ? query : `@media ${query}`
|
|
1857
|
-
if (decls) lines.push(`${mq} { ${selector} { ${decls}; } }`)
|
|
1858
|
-
}
|
|
1859
|
-
|
|
1860
|
-
return lines.join('\n')
|
|
1861
|
-
}
|
|
1862
|
-
|
|
1863
|
-
// Map of component names to their implicit CSS from extends
|
|
1864
|
-
const EXTENDS_CSS = {
|
|
1865
|
-
Flex: { display: 'flex' },
|
|
1866
|
-
InlineFlex: { display: 'inline-flex' },
|
|
1867
|
-
Grid: { display: 'grid' },
|
|
1868
|
-
InlineGrid: { display: 'inline-grid' },
|
|
1869
|
-
Block: { display: 'block' },
|
|
1870
|
-
Inline: { display: 'inline' }
|
|
1871
|
-
}
|
|
1872
|
-
|
|
1873
|
-
const getExtendsCSS = (el) => {
|
|
1874
|
-
const exts = el.__ref?.__extends
|
|
1875
|
-
if (!exts || !Array.isArray(exts)) return null
|
|
1876
|
-
for (const ext of exts) {
|
|
1877
|
-
if (EXTENDS_CSS[ext]) return EXTENDS_CSS[ext]
|
|
1878
|
-
}
|
|
1879
|
-
return null
|
|
1880
|
-
}
|
|
1881
|
-
|
|
1882
|
-
/**
|
|
1883
|
-
* Resolve function-valued CSS props by evaluating them with (element, state).
|
|
1884
|
-
* In SSR, this gives correct initial values (e.g. display: 'none' when no auth).
|
|
1885
|
-
* Uses fallback mock state if element state is incomplete.
|
|
1886
|
-
*/
|
|
1887
|
-
const resolveElementProps = (el) => {
|
|
1888
|
-
let resolved
|
|
1889
|
-
for (const key in el) {
|
|
1890
|
-
if (typeof el[key] !== 'function') continue
|
|
1891
|
-
// Skip non-CSS props and component children
|
|
1892
|
-
if (NON_CSS_PROPS.has(key)) continue
|
|
1893
|
-
if (key.charCodeAt(0) >= 65 && key.charCodeAt(0) <= 90) continue
|
|
1894
|
-
if (key.startsWith('on')) continue
|
|
1895
|
-
if (key.startsWith('__')) continue
|
|
1896
|
-
if (!resolved) resolved = {}
|
|
1897
|
-
let result
|
|
1898
|
-
try {
|
|
1899
|
-
result = el[key](el, el.state || {})
|
|
1900
|
-
} catch {
|
|
1901
|
-
// State prototype chain may be incomplete in SSR — try with mock
|
|
1902
|
-
try {
|
|
1903
|
-
const mockState = { root: {}, ...(el.state || {}) }
|
|
1904
|
-
result = el[key](el, mockState)
|
|
1905
|
-
} catch { /* skip prop */ }
|
|
1906
|
-
}
|
|
1907
|
-
if (result !== undefined && result !== null && result !== false) {
|
|
1908
|
-
resolved[key] = result
|
|
1909
|
-
}
|
|
1910
|
-
}
|
|
1911
|
-
return resolved || el
|
|
1912
|
-
}
|
|
1913
|
-
|
|
1914
|
-
const extractCSS = (element, ds) => {
|
|
1915
|
-
const mediaMap = ds?.media || {}
|
|
1916
|
-
const animations = ds?.animation || {}
|
|
1917
|
-
const rules = []
|
|
1918
|
-
const seen = new Set()
|
|
1919
|
-
const usedAnimations = new Set()
|
|
1920
|
-
|
|
1921
|
-
const walk = (el) => {
|
|
1922
|
-
if (!el || !el.__ref) return
|
|
1923
|
-
const props = resolveElementProps(el)
|
|
1924
|
-
if (props && el.node) {
|
|
1925
|
-
const cls = el.node.getAttribute?.('class')
|
|
1926
|
-
if (cls && !seen.has(cls)) {
|
|
1927
|
-
seen.add(cls)
|
|
1928
|
-
const cssResult = buildCSSFromProps(props, ds, mediaMap)
|
|
1929
|
-
|
|
1930
|
-
// Inject CSS from extends chain (e.g. extends: 'Flex' → display: flex)
|
|
1931
|
-
const extsCss = getExtendsCSS(el)
|
|
1932
|
-
if (extsCss) {
|
|
1933
|
-
for (const [k, v] of Object.entries(extsCss)) {
|
|
1934
|
-
const kebab = camelToKebab(k)
|
|
1935
|
-
if (!cssResult.base[kebab]) cssResult.base[kebab] = v
|
|
1936
|
-
}
|
|
1937
|
-
}
|
|
1938
|
-
|
|
1939
|
-
const has = Object.keys(cssResult.base).length || Object.keys(cssResult.mediaRules).length || Object.keys(cssResult.pseudoRules).length
|
|
1940
|
-
if (has) rules.push(renderCSSRule('.' + cls.split(' ')[0], cssResult))
|
|
1941
|
-
|
|
1942
|
-
const anim = props.animation || props.animationName
|
|
1943
|
-
if (typeof anim === 'string') {
|
|
1944
|
-
const name = anim.split(' ')[0]
|
|
1945
|
-
if (animations[name]) usedAnimations.add(name)
|
|
1946
|
-
}
|
|
1947
|
-
}
|
|
1948
|
-
}
|
|
1949
|
-
if (el.__ref?.__children) {
|
|
1950
|
-
for (const ck of el.__ref.__children) {
|
|
1951
|
-
if (el[ck]?.__ref) walk(el[ck])
|
|
1952
|
-
}
|
|
1953
|
-
}
|
|
1954
|
-
}
|
|
1955
|
-
walk(element)
|
|
1956
|
-
|
|
1957
|
-
const keyframes = []
|
|
1958
|
-
for (const name of usedAnimations) {
|
|
1959
|
-
const frames = animations[name]
|
|
1960
|
-
const frameRules = Object.entries(frames).map(([step, p]) => {
|
|
1961
|
-
const decls = Object.entries(p).map(([k, v]) => `${camelToKebab(k)}: ${v}`).join('; ')
|
|
1962
|
-
return ` ${step} { ${decls}; }`
|
|
1963
|
-
}).join('\n')
|
|
1964
|
-
keyframes.push(`@keyframes ${name} {\n${frameRules}\n}`)
|
|
1965
|
-
}
|
|
1966
|
-
|
|
1967
|
-
return [...keyframes, ...rules].join('\n')
|
|
1786
|
+
// ── Images below the fold ───────────────────────────────────────────────────
|
|
1787
|
+
// A pre-rendered page's images are in the markup before the app's script,
|
|
1788
|
+
// and on a slow connection they took its bandwidth: measured on a throttled
|
|
1789
|
+
// phone, the app's 214 KB script finished at 2.65 s instead of 1.75 s while
|
|
1790
|
+
// 165–191 KB of images loaded (xma.info report, brender build item 14). The
|
|
1791
|
+
// server cannot see the fold, so the first `eager` images in document order
|
|
1792
|
+
// load as written and every later one that does not say how to load gets
|
|
1793
|
+
// `loading="lazy"` — a lazy image inside the viewport still loads right after
|
|
1794
|
+
// layout. An image that names its own `loading` is left alone. `eager:
|
|
1795
|
+
// false` turns this off.
|
|
1796
|
+
export const EAGER_IMAGES_DEFAULT = 2
|
|
1797
|
+
const IMG_TAG = /<img\b(?:[^>"']|"[^"]*"|'[^']*')*>/gi
|
|
1798
|
+
const HAS_LOADING = /\sloading\s*=/i
|
|
1799
|
+
|
|
1800
|
+
export const markLazyImages = (html, eager = EAGER_IMAGES_DEFAULT) => {
|
|
1801
|
+
if (eager === false || eager == null || typeof html !== 'string') return html
|
|
1802
|
+
const keep = Math.max(0, Number(eager) || 0)
|
|
1803
|
+
let seen = 0
|
|
1804
|
+
return html.replace(IMG_TAG, (tag) => {
|
|
1805
|
+
if (seen++ < keep || HAS_LOADING.test(tag)) return tag
|
|
1806
|
+
return tag.replace(/^<img\b/i, '<img loading="lazy"')
|
|
1807
|
+
})
|
|
1968
1808
|
}
|
|
1969
1809
|
|
|
1970
1810
|
const generateFontLinks = (ds) => {
|
|
@@ -1993,3 +1833,38 @@ const generateFontLinks = (ds) => {
|
|
|
1993
1833
|
`<link href="https://fonts.googleapis.com/css2?${params}&display=swap" rel="stylesheet">`
|
|
1994
1834
|
].join('\n')
|
|
1995
1835
|
}
|
|
1836
|
+
|
|
1837
|
+
// ── The page's boot data (CSP-safe) ─────────────────────────────────────────
|
|
1838
|
+
// A pre-rendered page tells the client what it is without one inline script:
|
|
1839
|
+
// XMA serves `script-src 'self'` (no 'unsafe-inline'), which blocked the
|
|
1840
|
+
// `<script>window.__BRENDER__=true</script>` flag — the client then created
|
|
1841
|
+
// the app from scratch and appended it after the server markup, two app
|
|
1842
|
+
// roots (XMA report, brender item 1). A policy blocks inline scripts, never
|
|
1843
|
+
// <meta> tags or data blocks. The format smbls reads (smbls prerender.js):
|
|
1844
|
+
// meta `<meta name="brender" content="hydrate|replace">` for <head>
|
|
1845
|
+
// blocks after the markup — the element-path registry under `adoptDom`
|
|
1846
|
+
// (`<script type="application/json" data-brender="registry">`),
|
|
1847
|
+
// and the recorded anonymous answers (@symbo.ls/fetch
|
|
1848
|
+
// createEarlySeedBlock), which the client adopts instead of asking
|
|
1849
|
+
// again. Placed after the markup, so they never delay its paint;
|
|
1850
|
+
// the app's scripts run after them either way.
|
|
1851
|
+
// `adoptionScripts` above stays for older callers (the platform's renderer).
|
|
1852
|
+
export const prerenderParts = ({ brRegistry, fetchSeed, adoptDom = false } = {}) => {
|
|
1853
|
+
const registry = adoptDom && brRegistry && Object.keys(brRegistry).length
|
|
1854
|
+
? brenderRegistryBlock(brRegistry)
|
|
1855
|
+
: ''
|
|
1856
|
+
const seed = createEarlySeedBlock(fetchSeed)
|
|
1857
|
+
return {
|
|
1858
|
+
meta: `<meta name="brender" content="${registry ? 'hydrate' : 'replace'}">`,
|
|
1859
|
+
blocks: [registry, seed].filter(Boolean).join('\n')
|
|
1860
|
+
}
|
|
1861
|
+
}
|
|
1862
|
+
|
|
1863
|
+
// ` nonce="…"` for an inline script, or '' — a value that could break out of
|
|
1864
|
+
// the attribute is refused.
|
|
1865
|
+
const nonceAttribute = (nonce) => {
|
|
1866
|
+
if (nonce == null || nonce === '') return ''
|
|
1867
|
+
const value = String(nonce)
|
|
1868
|
+
if (!/^[A-Za-z0-9+/=_-]+$/.test(value)) throw new Error('brender: a nonce is base64 or base64url text')
|
|
1869
|
+
return ` nonce="${value}"`
|
|
1870
|
+
}
|