@symbo.ls/brender 3.14.664 → 3.14.666
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 +38 -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 +29 -27
- package/dist/esm/shell.js +9 -8
- package/env.js +56 -29
- package/index.js +5 -3
- package/load.js +111 -22
- package/package.json +8 -8
- package/prefetch.js +30 -62
- package/render.js +493 -193
- package/shell.js +74 -12
package/render.js
CHANGED
|
@@ -4,15 +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'
|
|
14
15
|
import { headerEntries } from '@symbo.ls/fetch/rest'
|
|
15
|
-
import { jsonForScript } from '@symbo.ls/utils'
|
|
16
|
+
import { brenderRegistryBlock, escapeRegExp, jsonForScript, polyglotFallbackLang } from '@symbo.ls/utils'
|
|
16
17
|
|
|
17
18
|
// funcql plugin — enables evaluation of funcql schemas as property values
|
|
18
19
|
// during SSR. Async-imported to avoid polluting non-funcql codepaths.
|
|
@@ -28,6 +29,7 @@ const getFuncqlPlugin = async () => {
|
|
|
28
29
|
}
|
|
29
30
|
}
|
|
30
31
|
import { parseHTML } from 'linkedom'
|
|
32
|
+
import { createEarlySeedBlock } from '@symbo.ls/fetch'
|
|
31
33
|
import { css, injectGlobal, reset as resetCss } from '@symbo.ls/css'
|
|
32
34
|
|
|
33
35
|
// Lightweight SSR polyglot functions — resolve translations from context
|
|
@@ -45,17 +47,17 @@ const ssrResolve = (map, key) => {
|
|
|
45
47
|
return v
|
|
46
48
|
}
|
|
47
49
|
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
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) => {
|
|
59
61
|
// Static translations from context.polyglot.translations
|
|
60
62
|
if (poly?.translations) {
|
|
61
63
|
const langMap = poly.translations[activeLang]
|
|
@@ -66,7 +68,6 @@ const ssrTranslate = function (key, lang) {
|
|
|
66
68
|
}
|
|
67
69
|
|
|
68
70
|
// Server-loaded translations in root state
|
|
69
|
-
const root = this?.state?.root || ctx?.state?.root
|
|
70
71
|
if (root?.translations) {
|
|
71
72
|
const langMap = root.translations[activeLang]
|
|
72
73
|
if (langMap) {
|
|
@@ -76,7 +77,7 @@ const ssrTranslate = function (key, lang) {
|
|
|
76
77
|
}
|
|
77
78
|
|
|
78
79
|
// Fallback to default lang
|
|
79
|
-
const defaultLang = poly
|
|
80
|
+
const defaultLang = polyglotFallbackLang(undefined, poly)
|
|
80
81
|
if (defaultLang !== activeLang && poly?.translations) {
|
|
81
82
|
const fallback = poly.translations[defaultLang]
|
|
82
83
|
if (fallback) {
|
|
@@ -84,15 +85,30 @@ const ssrTranslate = function (key, lang) {
|
|
|
84
85
|
if (val !== undefined) return val
|
|
85
86
|
}
|
|
86
87
|
}
|
|
88
|
+
return undefined
|
|
89
|
+
}
|
|
87
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)
|
|
88
107
|
return key
|
|
89
108
|
}
|
|
90
109
|
|
|
91
110
|
const ssrGetActiveLang = function () {
|
|
92
|
-
|
|
93
|
-
// 'en' matches plugins/polyglot/index.js#getActiveLang — diverging here
|
|
94
|
-
// produces SSR/hydrate text mismatches.
|
|
95
|
-
return this?.state?.root?.lang || ctx?.polyglot?.defaultLang || 'en'
|
|
111
|
+
return activeLangOf(this?.state?.root, this?.context?.polyglot)
|
|
96
112
|
}
|
|
97
113
|
|
|
98
114
|
// Deep clone that preserves functions and avoids circular refs
|
|
@@ -272,6 +288,13 @@ const resolveDomqlPackage = (ws, pkg, ...subpath) => {
|
|
|
272
288
|
//
|
|
273
289
|
// An installed smbls OLDER than the ssr entry that still carries
|
|
274
290
|
// src/createDomql.js (pre-54131b34d tarballs) keeps the source-bundle path.
|
|
291
|
+
//
|
|
292
|
+
// Loaded once per process, and the load in progress is shared: it kept the
|
|
293
|
+
// loaded module, set when the load finished, so renders that started together
|
|
294
|
+
// before then loaded one each — two esbuild runs, and two framework instances
|
|
295
|
+
// in one process (two signal and CSS engines, two @symbo.ls/fetch copies with
|
|
296
|
+
// their own query caches); the renders after them got whichever finished
|
|
297
|
+
// last. A failed load is not kept: the next render tries again.
|
|
275
298
|
let _cachedCreateDomql = null
|
|
276
299
|
|
|
277
300
|
// Exported for the packaging lock in __tests__/ssrEntryPackaging.test.js —
|
|
@@ -287,9 +310,17 @@ const importSmblsSsr = async () => {
|
|
|
287
310
|
return mod
|
|
288
311
|
}
|
|
289
312
|
|
|
290
|
-
const bundleCreateDomql =
|
|
291
|
-
if (_cachedCreateDomql)
|
|
313
|
+
const bundleCreateDomql = () => {
|
|
314
|
+
if (!_cachedCreateDomql) {
|
|
315
|
+
_cachedCreateDomql = loadCreateDomql().catch((err) => {
|
|
316
|
+
_cachedCreateDomql = null
|
|
317
|
+
throw err
|
|
318
|
+
})
|
|
319
|
+
}
|
|
320
|
+
return _cachedCreateDomql
|
|
321
|
+
}
|
|
292
322
|
|
|
323
|
+
const loadCreateDomql = async () => {
|
|
293
324
|
const ws = detectWorkspace()
|
|
294
325
|
|
|
295
326
|
// Resolve entry point: the monorepo's source, else `smbls/ssr` (one import
|
|
@@ -300,8 +331,7 @@ const bundleCreateDomql = async () => {
|
|
|
300
331
|
} else {
|
|
301
332
|
let ssrError
|
|
302
333
|
try {
|
|
303
|
-
|
|
304
|
-
return _cachedCreateDomql
|
|
334
|
+
return await importSmblsSsr()
|
|
305
335
|
} catch (err) {
|
|
306
336
|
ssrError = err
|
|
307
337
|
}
|
|
@@ -326,9 +356,44 @@ const bundleCreateDomql = async () => {
|
|
|
326
356
|
|
|
327
357
|
const tryResolve = resolvePackageEntry
|
|
328
358
|
|
|
359
|
+
// In the monorepo, every workspace specifier the handlers below do not own
|
|
360
|
+
// — a SUBPATH of a sibling (`@symbo.ls/dropdown/components`,
|
|
361
|
+
// `@symbo.ls/sync/applier`, `@symbo.ls/utils/function.js`) and an unscoped
|
|
362
|
+
// sibling (`attrs-in-props`) — resolves to its source through the map the
|
|
363
|
+
// test runner and smbls's own build resolve with
|
|
364
|
+
// (scripts/vite-workspace-source.mjs). They fell through to esbuild, which
|
|
365
|
+
// followed each package's `exports` into its dist/: a render ran whatever
|
|
366
|
+
// build was left on disk (the legacy Dropdown kit of an earlier build, a
|
|
367
|
+
// sync applier without the exports createDomql imports now), and a
|
|
368
|
+
// checkout without dist/ could not render at all. A dist input now fails
|
|
369
|
+
// the bundle.
|
|
370
|
+
const workspaceSource = ws.isMonorepo
|
|
371
|
+
? await import(pathToFileURL(resolve(ws.monorepoRoot, 'scripts', 'vite-workspace-source.mjs')).href)
|
|
372
|
+
: null
|
|
373
|
+
const workspaceNames = workspaceSource
|
|
374
|
+
? new Set(workspaceSource.workspacePackageRoots().map((dir) => workspaceSource.workspacePackageAt(dir).name))
|
|
375
|
+
: null
|
|
376
|
+
// Bare specifiers the handlers below resolve themselves (the sync stub
|
|
377
|
+
// among them).
|
|
378
|
+
const OWN_HANDLERS = /^(?:smbls(?:\/.*)?|domql|css-in-props(?:\/.*)?|@symbo\.ls\/[^/]+)$/
|
|
379
|
+
|
|
329
380
|
const workspacePlugin = {
|
|
330
381
|
name: 'workspace-resolve',
|
|
331
382
|
setup (build) {
|
|
383
|
+
if (workspaceSource) {
|
|
384
|
+
build.onResolve({ filter: /^[^./]/ }, (args) => {
|
|
385
|
+
if (OWN_HANDLERS.test(args.path)) return
|
|
386
|
+
const parts = args.path.split('/')
|
|
387
|
+
const name = args.path.startsWith('@') ? parts.slice(0, 2).join('/') : parts[0]
|
|
388
|
+
if (!workspaceNames.has(name)) return
|
|
389
|
+
const source = workspaceSource.resolveWorkspaceSource(args.path, args.importer || entry)
|
|
390
|
+
if (source) return { path: source }
|
|
391
|
+
return { errors: [{ text: `"${args.path}" names no source file in the workspace` }] }
|
|
392
|
+
})
|
|
393
|
+
build.onLoad({ filter: /[\\/]dist[\\/]/ }, (args) => workspaceSource.isWorkspaceDist(args.path)
|
|
394
|
+
? { errors: [{ text: `${args.path} is a BUILD of a workspace package — the render bundles source` }] }
|
|
395
|
+
: undefined)
|
|
396
|
+
}
|
|
332
397
|
// Resolve smbls bare import
|
|
333
398
|
build.onResolve({ filter: /^smbls/ }, (args) => {
|
|
334
399
|
const subpath = args.path.replace(/^smbls\/?/, '')
|
|
@@ -543,7 +608,6 @@ const bundleCreateDomql = async () => {
|
|
|
543
608
|
console.log('[brender] Bundle saved:', outFile)
|
|
544
609
|
}
|
|
545
610
|
|
|
546
|
-
_cachedCreateDomql = mod
|
|
547
611
|
return mod
|
|
548
612
|
}
|
|
549
613
|
|
|
@@ -634,17 +698,24 @@ const UIKIT_STUBS = {
|
|
|
634
698
|
*/
|
|
635
699
|
const buildPathRegistry = (element, elementPath) => {
|
|
636
700
|
const registry = {}
|
|
701
|
+
walkElements(element, (el, ref) => {
|
|
702
|
+
if (!ref.__brKey) return
|
|
703
|
+
const path = elementPath(el)
|
|
704
|
+
registry[path === '' ? '__root' : path] = ref.__brKey
|
|
705
|
+
})
|
|
706
|
+
return registry
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
// Every element of a rendered tree, each before its children, in the order of
|
|
710
|
+
// `__ref.__children`.
|
|
711
|
+
const walkElements = (root, visit) => {
|
|
637
712
|
const walk = (el) => {
|
|
638
713
|
const ref = el && el.__ref
|
|
639
714
|
if (!ref) return
|
|
640
|
-
|
|
641
|
-
const path = elementPath(el)
|
|
642
|
-
registry[path === '' ? '__root' : path] = ref.__brKey
|
|
643
|
-
}
|
|
715
|
+
visit(el, ref)
|
|
644
716
|
if (ref.__children) for (const childKey of ref.__children) walk(el[childKey])
|
|
645
717
|
}
|
|
646
|
-
walk(
|
|
647
|
-
return registry
|
|
718
|
+
walk(root)
|
|
648
719
|
}
|
|
649
720
|
|
|
650
721
|
/**
|
|
@@ -697,13 +768,64 @@ const inFlightRequests = () => _inFlight
|
|
|
697
768
|
const SEED_ENTRY_MAX = 256 * 1024
|
|
698
769
|
const SEED_TOTAL_MAX = 1024 * 1024
|
|
699
770
|
const SEED_HEADERS_OK = new Set(['accept-language', 'accept'])
|
|
700
|
-
|
|
701
|
-
|
|
771
|
+
const SEED = { renders: 0, record: null }
|
|
772
|
+
// ── What went wrong in a render (its diagnostics) ───────────────────────────
|
|
773
|
+
// Kept like the seed — one record while exactly ONE render runs, tainted by
|
|
774
|
+
// an overlapping one — so a build can tell a page made from data from a page
|
|
775
|
+
// made from nothing: the requests that failed (a non-ok status or a network
|
|
776
|
+
// error) and the label keys that missed (ssrTranslate notes them; the end of
|
|
777
|
+
// the render checks them again, see rawLabelKeys). A build with its database
|
|
778
|
+
// unreachable printed "✓ 22 of 22 routes" and exited 0 while every page
|
|
779
|
+
// carried raw keys (xma.info report, brender build item 9).
|
|
780
|
+
const DIAG_FAILURES_MAX = 50
|
|
781
|
+
const DIAG = { renders: 0, record: null }
|
|
782
|
+
|
|
783
|
+
// One record per window of overlapping renders (the seed's and the
|
|
784
|
+
// diagnostics' windows open at different points of a render, so each has its
|
|
785
|
+
// own slot): the first render of a window opens it, a render that overlaps it
|
|
786
|
+
// taints it — another project's answers or failures must never land in this
|
|
787
|
+
// page — and it goes as soon as one of the renders holding it ends. A render
|
|
788
|
+
// that starts inside a window whose record is gone gets none.
|
|
789
|
+
const openRenderRecord = (slot, make) => {
|
|
790
|
+
slot.renders++
|
|
791
|
+
if (slot.renders === 1) slot.record = make()
|
|
792
|
+
else if (slot.record) slot.record.tainted = true
|
|
793
|
+
const record = slot.record
|
|
794
|
+
const close = () => {
|
|
795
|
+
slot.renders--
|
|
796
|
+
if (slot.record === record) slot.record = null
|
|
797
|
+
}
|
|
798
|
+
return { record, close }
|
|
799
|
+
}
|
|
800
|
+
|
|
801
|
+
// The slot's record while it is the only render's, else null.
|
|
802
|
+
const liveRecord = (slot) => slot.record && !slot.record.tainted ? slot.record : null
|
|
803
|
+
|
|
804
|
+
// A request as a diagnostic names it: origin and path. The query is left out
|
|
805
|
+
// — a public read can carry its key there (`?apikey=…`), and a log line is no
|
|
806
|
+
// place for one.
|
|
807
|
+
const requestLabel = (url) => {
|
|
808
|
+
try {
|
|
809
|
+
const u = new URL(url)
|
|
810
|
+
return u.origin + u.pathname
|
|
811
|
+
} catch {
|
|
812
|
+
return String(url).split('?')[0]
|
|
813
|
+
}
|
|
814
|
+
}
|
|
815
|
+
|
|
816
|
+
const recordFailure = (diag, url, reason) => {
|
|
817
|
+
if (!diag || diag.tainted || diag.failures.length >= DIAG_FAILURES_MAX) return
|
|
818
|
+
diag.failures.push({ url: requestLabel(url), reason })
|
|
819
|
+
}
|
|
820
|
+
|
|
821
|
+
// The URL a fetch() call asks for: a Request's own, else its input as text.
|
|
822
|
+
const requestUrl = (input) =>
|
|
823
|
+
input && typeof input === 'object' && typeof input.url === 'string' ? input.url : String(input)
|
|
702
824
|
|
|
703
825
|
const requestShape = (args) => {
|
|
704
826
|
const [input, init] = args
|
|
705
827
|
const isRequest = input && typeof input === 'object' && typeof input.url === 'string'
|
|
706
|
-
const url =
|
|
828
|
+
const url = requestUrl(input)
|
|
707
829
|
const method = String((init && init.method) || (isRequest && input.method) || 'GET').toUpperCase()
|
|
708
830
|
const headers = {}
|
|
709
831
|
// Any fetch HeadersInit, read like the REST adapter reads one (headerEntries).
|
|
@@ -743,7 +865,8 @@ const trackFetches = () => {
|
|
|
743
865
|
const done = () => { if (!settled) { settled = true; _inFlight-- } }
|
|
744
866
|
// a pending body read counts as in flight: the flush waits for it too
|
|
745
867
|
const track = (promise) => { _inFlight++; promise.then(() => { _inFlight-- }, () => { _inFlight-- }) }
|
|
746
|
-
const seed =
|
|
868
|
+
const seed = liveRecord(SEED)
|
|
869
|
+
const diag = liveRecord(DIAG)
|
|
747
870
|
let shape = null
|
|
748
871
|
if (seed) {
|
|
749
872
|
try {
|
|
@@ -752,18 +875,24 @@ const trackFetches = () => {
|
|
|
752
875
|
Object.keys(shape.headers).some((h) => !SEED_HEADERS_OK.has(h))) shape = null
|
|
753
876
|
} catch { shape = null }
|
|
754
877
|
}
|
|
878
|
+
const urlOf = () => requestUrl(args[0])
|
|
755
879
|
let result
|
|
756
880
|
try {
|
|
757
881
|
result = inner.apply(this, args)
|
|
758
882
|
} catch (err) {
|
|
883
|
+
recordFailure(diag, urlOf(), (err && err.message) || 'failed')
|
|
759
884
|
done()
|
|
760
885
|
throw err
|
|
761
886
|
}
|
|
762
887
|
if (result && typeof result.then === 'function') {
|
|
763
888
|
result.then((response) => {
|
|
764
889
|
if (shape) recordAnswer(seed, shape, response, track)
|
|
890
|
+
if (diag && response && response.ok === false) recordFailure(diag, urlOf(), 'HTTP ' + response.status)
|
|
891
|
+
done()
|
|
892
|
+
}, (err) => {
|
|
893
|
+
recordFailure(diag, urlOf(), (err && err.message) || 'network error')
|
|
765
894
|
done()
|
|
766
|
-
}
|
|
895
|
+
})
|
|
767
896
|
} else done()
|
|
768
897
|
return result
|
|
769
898
|
}
|
|
@@ -795,55 +924,35 @@ export const render = async (data, options = {}) => {
|
|
|
795
924
|
// pathname is the actual URL path (e.g. /podcast/abc-123), route is the page pattern (e.g. /podcast/:id)
|
|
796
925
|
const locationPath = pathname || route
|
|
797
926
|
|
|
798
|
-
//
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
const _prevWinPrefetch = globalThis.window
|
|
806
|
-
if (!globalThis.location || globalThis.location.pathname !== locationPath) {
|
|
807
|
-
globalThis.location = { pathname: locationPath, href: locationPath, search: '', hash: '', origin: '' }
|
|
808
|
-
}
|
|
809
|
-
if (!globalThis.window) {
|
|
810
|
-
globalThis.window = { location: globalThis.location }
|
|
811
|
-
}
|
|
812
|
-
let prefetchedPages
|
|
813
|
-
if (prefetch) {
|
|
814
|
-
try {
|
|
815
|
-
const pages = data.pages || {}
|
|
816
|
-
prefetchedPages = { ...pages }
|
|
817
|
-
const stateUpdates = await prefetchPageData(data, route)
|
|
818
|
-
if (stateUpdates.size) {
|
|
819
|
-
// Deep clone the page def to avoid mutating the original
|
|
820
|
-
const pageDef = JSON.parse(JSON.stringify(pages[route], (key, value) => {
|
|
821
|
-
if (typeof value === 'function') return undefined
|
|
822
|
-
return value
|
|
823
|
-
}))
|
|
824
|
-
// Re-attach functions from original
|
|
825
|
-
const copyFunctions = (src, dst) => {
|
|
826
|
-
if (!src || !dst) return
|
|
827
|
-
for (const k in src) {
|
|
828
|
-
if (typeof src[k] === 'function') {
|
|
829
|
-
dst[k] = src[k]
|
|
830
|
-
} else if (typeof src[k] === 'object' && src[k] !== null && !Array.isArray(src[k]) && typeof dst[k] === 'object' && dst[k] !== null) {
|
|
831
|
-
copyFunctions(src[k], dst[k])
|
|
832
|
-
}
|
|
833
|
-
}
|
|
834
|
-
}
|
|
835
|
-
copyFunctions(pages[route], pageDef)
|
|
836
|
-
injectPrefetchedState(pageDef, stateUpdates)
|
|
837
|
-
prefetchedPages[route] = pageDef
|
|
838
|
-
}
|
|
839
|
-
} catch (prefetchErr) {
|
|
840
|
-
console.error('[brender] Prefetch error:', prefetchErr)
|
|
841
|
-
prefetchedPages = data.pages
|
|
842
|
-
}
|
|
927
|
+
// This render's diagnostics (see `DIAG`), open before its first request.
|
|
928
|
+
trackFetches()
|
|
929
|
+
const diag = openRenderRecord(DIAG, () => ({ failures: [], labelKeys: new Set(), tainted: false }))
|
|
930
|
+
try {
|
|
931
|
+
return await renderWithDiagnostics(data, { route, locationPath, stateOverrides, contextOverrides, prefetch }, diag.record)
|
|
932
|
+
} finally {
|
|
933
|
+
diag.close()
|
|
843
934
|
}
|
|
935
|
+
}
|
|
936
|
+
|
|
937
|
+
const renderWithDiagnostics = async (data, { route, locationPath, stateOverrides, contextOverrides, prefetch }, diag) => {
|
|
938
|
+
// The page's data is NOT fetched here. The fetch plugin runs during the
|
|
939
|
+
// render below with the real element — its `transform(data, el, state)`,
|
|
940
|
+
// its `children` re-run on the answer — and the post-create flush waits
|
|
941
|
+
// for its requests. A pre-fetch used to run every page `fetch` through a
|
|
942
|
+
// second code path: `transform(data)` without the element (a transform
|
|
943
|
+
// reading `el.call(...)` threw, so the RAW rows landed in state) and
|
|
944
|
+
// `children` functions evaluated once with a stand-in element whose
|
|
945
|
+
// call() hard-coded 'ka' and read the wrong argument, then frozen into
|
|
946
|
+
// static arrays the real answer could no longer replace — cards without
|
|
947
|
+
// titles, a calendar of NaN (xma.info report, brender build items 5, 13).
|
|
844
948
|
|
|
845
949
|
// ── SSR polyglot translations ──
|
|
846
|
-
//
|
|
950
|
+
// Labels a project serves through a table/RPC (`polyglot.fetch: { rpc }`)
|
|
951
|
+
// are fetched before the render, for every language. A FUNCTION
|
|
952
|
+
// `polyglot.fetch` is the project's own loader: it runs inside the render,
|
|
953
|
+
// called by the polyglot plugin with the real root element (see
|
|
954
|
+
// fetchSSRTranslations), and its labels are read back from the root state
|
|
955
|
+
// afterwards (renderedLabels).
|
|
847
956
|
let ssrTranslations
|
|
848
957
|
if (prefetch) {
|
|
849
958
|
try {
|
|
@@ -853,24 +962,27 @@ export const render = async (data, options = {}) => {
|
|
|
853
962
|
}
|
|
854
963
|
}
|
|
855
964
|
|
|
856
|
-
// Restore location/window before createEnv sets them properly
|
|
857
|
-
if (_prevLocPrefetch !== undefined) globalThis.location = _prevLocPrefetch
|
|
858
|
-
else delete globalThis.location
|
|
859
|
-
if (_prevWinPrefetch !== undefined) globalThis.window = _prevWinPrefetch
|
|
860
|
-
else delete globalThis.window
|
|
861
|
-
|
|
862
965
|
const { window, document } = createEnv()
|
|
863
966
|
const body = document.body
|
|
864
967
|
|
|
865
968
|
// Set route on location so the router picks it up
|
|
866
969
|
window.location.pathname = locationPath
|
|
867
970
|
|
|
868
|
-
//
|
|
869
|
-
//
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
971
|
+
// globalThis.document/location for the bundled smbls code (which uses
|
|
972
|
+
// `window = globalThis`) during SSR, and the render's own storage behind
|
|
973
|
+
// the bare `localStorage` the framework reads (smbls prepareState):
|
|
974
|
+
// polyglot persists the language a page switched to and the labels it
|
|
975
|
+
// loaded there, and one shared store handed them to the next page of the
|
|
976
|
+
// build (createEnv). Swapped by descriptor and put back however the render
|
|
977
|
+
// ends, a throw included — a host's own Web Storage global (Node's is a
|
|
978
|
+
// lazy accessor) is never read or lost.
|
|
979
|
+
const restoreGlobals = [
|
|
980
|
+
swapGlobal('document', document),
|
|
981
|
+
swapGlobal('location', window.location),
|
|
982
|
+
swapGlobal('localStorage', window.localStorage),
|
|
983
|
+
swapGlobal('sessionStorage', window.sessionStorage)
|
|
984
|
+
]
|
|
985
|
+
try {
|
|
874
986
|
|
|
875
987
|
// Import createDomqlElement via bundled smbls source.
|
|
876
988
|
// The smbls monorepo uses extensionless/directory imports that Node.js ESM
|
|
@@ -903,7 +1015,7 @@ export const render = async (data, options = {}) => {
|
|
|
903
1015
|
if (ssrTranslations || polyglotConfig) {
|
|
904
1016
|
if (!baseState.root) baseState.root = {}
|
|
905
1017
|
if (polyglotConfig) {
|
|
906
|
-
baseState.root.lang = baseState.root.lang
|
|
1018
|
+
baseState.root.lang = polyglotFallbackLang(baseState.root.lang, polyglotConfig)
|
|
907
1019
|
}
|
|
908
1020
|
if (ssrTranslations) {
|
|
909
1021
|
baseState.root.translations = {
|
|
@@ -939,7 +1051,7 @@ export const render = async (data, options = {}) => {
|
|
|
939
1051
|
dependencies: structuredCloneDeep(data.dependencies || {}),
|
|
940
1052
|
components: structuredCloneDeep(data.components || {}),
|
|
941
1053
|
snippets: structuredCloneDeep(data.snippets || {}),
|
|
942
|
-
pages: structuredCloneDeep(
|
|
1054
|
+
pages: structuredCloneDeep(data.pages || {}),
|
|
943
1055
|
functions: {
|
|
944
1056
|
...(data.functions || {}),
|
|
945
1057
|
// SSR polyglot functions — enable {{ key | polyglot }} resolution during render
|
|
@@ -962,6 +1074,11 @@ export const render = async (data, options = {}) => {
|
|
|
962
1074
|
// Disable sourcemap tracking in SSR — it causes stack overflows
|
|
963
1075
|
// when state contains large data arrays (articles, events, etc.)
|
|
964
1076
|
domqlOptions: { sourcemap: false },
|
|
1077
|
+
// The server builds the page in one go: the browser boot's short tasks
|
|
1078
|
+
// (smbls firstRender.js) serve input and paint, and a server has neither.
|
|
1079
|
+
// smbls skips them by itself in a realm without requestAnimationFrame;
|
|
1080
|
+
// this document shims it (env.js), so it says so here.
|
|
1081
|
+
firstRender: false,
|
|
965
1082
|
// Editor/live-sync, inspector and toast plugins have no place in server
|
|
966
1083
|
// markup. The source-bundle path used to stub @symbo.ls/sync to no-ops at
|
|
967
1084
|
// bundle time; the installed `smbls/ssr` entry carries the real plugins,
|
|
@@ -971,6 +1088,19 @@ export const render = async (data, options = {}) => {
|
|
|
971
1088
|
sync: false,
|
|
972
1089
|
inspect: false,
|
|
973
1090
|
notifications: false,
|
|
1091
|
+
// Every server render processes the design system the way the page's
|
|
1092
|
+
// own single boot does, on a config of its own — the first render of a
|
|
1093
|
+
// process used to share the global config and every later one took the
|
|
1094
|
+
// iframe path, so their theme variables differed (smbls
|
|
1095
|
+
// prepareDesignSystem; xma.info report, brender build item 7).
|
|
1096
|
+
isolateDesignSystem: true,
|
|
1097
|
+
// Every server render starts from an empty query cache (@symbo.ls/fetch
|
|
1098
|
+
// enterContext). The fetch plugin's cache outlives a render, so a page
|
|
1099
|
+
// rendered after another one that read the same key showed those rows
|
|
1100
|
+
// without a request, and its fetch seed — recorded from requests — did
|
|
1101
|
+
// not carry them: the browser asked for them again after boot (xma.info,
|
|
1102
|
+
// 2026-10-16: index.html 10 seed entries, about.html 3).
|
|
1103
|
+
isolateFetchCache: true,
|
|
974
1104
|
// Caller overrides
|
|
975
1105
|
...(contextOverrides || {})
|
|
976
1106
|
}
|
|
@@ -986,21 +1116,19 @@ export const render = async (data, options = {}) => {
|
|
|
986
1116
|
|
|
987
1117
|
resetKeys()
|
|
988
1118
|
|
|
989
|
-
|
|
990
|
-
// the recording of this render's answers (see `_seed`): tainted the moment
|
|
1119
|
+
// the recording of this render's answers (see `SEED`): tainted the moment
|
|
991
1120
|
// another render overlaps it
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
else if (_seed) _seed.tainted = true
|
|
995
|
-
const seed = _seed
|
|
1121
|
+
const recording = openRenderRecord(SEED, () => ({ entries: [], bytes: 0, tainted: false }))
|
|
1122
|
+
const seed = recording.record
|
|
996
1123
|
let element
|
|
997
1124
|
try {
|
|
998
1125
|
element = await createDomqlElement(app, ctx)
|
|
999
1126
|
|
|
1000
1127
|
// Allow async operations (fetch callbacks, state updates, re-renders) to flush.
|
|
1001
|
-
// DOMQL's fetch plugin fires on element creation and updates state
|
|
1002
|
-
//
|
|
1003
|
-
// and
|
|
1128
|
+
// DOMQL's fetch plugin fires on element creation and updates state
|
|
1129
|
+
// asynchronously — it is what loads the page's data (nothing is
|
|
1130
|
+
// pre-injected) — and a function polyglot.fetch loads the labels the
|
|
1131
|
+
// same way. Give enough time for these to complete.
|
|
1004
1132
|
//
|
|
1005
1133
|
// It used to sleep a flat 2000 ms with prefetch — measured in workerd on
|
|
1006
1134
|
// six published projects, `flush` was 2000 ms on every route, 40–90 % of
|
|
@@ -1019,8 +1147,7 @@ export const render = async (data, options = {}) => {
|
|
|
1019
1147
|
if (inFlightRequests() === 0) await sleep(Math.max(0, Math.min(50, flushCap - (Date.now() - flushStart))))
|
|
1020
1148
|
}
|
|
1021
1149
|
} finally {
|
|
1022
|
-
|
|
1023
|
-
if (_seed === seed) _seed = null
|
|
1150
|
+
recording.close()
|
|
1024
1151
|
}
|
|
1025
1152
|
const fetchSeed = seed && !seed.tainted ? seed.entries.slice() : []
|
|
1026
1153
|
|
|
@@ -1062,24 +1189,106 @@ export const render = async (data, options = {}) => {
|
|
|
1062
1189
|
|
|
1063
1190
|
let html = fixSvgContent(body.innerHTML)
|
|
1064
1191
|
|
|
1192
|
+
// The language this page was rendered in and the labels it ended with:
|
|
1193
|
+
// the ones fetched before the render plus whatever the project's own
|
|
1194
|
+
// polyglot.fetch loaded into the root state during it.
|
|
1195
|
+
const root = element?.state?.root
|
|
1196
|
+
const activeLang = activeLangOf(root, polyglotConfig)
|
|
1197
|
+
const labels = renderedLabels(ssrTranslations, root)
|
|
1198
|
+
|
|
1065
1199
|
// Post-process: resolve any remaining {{ key | polyglot }} templates
|
|
1066
|
-
// that weren't resolved during DOMQL rendering (e.g. due to timing)
|
|
1067
|
-
|
|
1068
|
-
|
|
1069
|
-
|
|
1070
|
-
|
|
1071
|
-
|
|
1072
|
-
|
|
1073
|
-
|
|
1200
|
+
// that weren't resolved during DOMQL rendering (e.g. due to timing) — in
|
|
1201
|
+
// the page's language. It used the default language, so a page rendered
|
|
1202
|
+
// for /en/… got its leftover templates in the default language's words
|
|
1203
|
+
// (xma.info report, brender build item 8).
|
|
1204
|
+
if (labels) html = resolveLabelTemplates(html, labelMapFor(labels, activeLang, polyglotConfig))
|
|
1205
|
+
|
|
1206
|
+
const diagnostics = diag && !diag.tainted
|
|
1207
|
+
? {
|
|
1208
|
+
failedRequests: diag.failures.slice(),
|
|
1209
|
+
rawLabelKeys: rawLabelKeys(diag.labelKeys, html, activeLang, polyglotConfig, root)
|
|
1210
|
+
}
|
|
1211
|
+
: null
|
|
1212
|
+
|
|
1213
|
+
return { html, metadata, registry, brRegistry, element, emotionCSS, globalCSS, classRules, fetchSeed, document, window, ssrTranslations: labels || undefined, activeLang, diagnostics }
|
|
1214
|
+
} finally {
|
|
1215
|
+
for (const restore of restoreGlobals) restore()
|
|
1074
1216
|
}
|
|
1217
|
+
}
|
|
1075
1218
|
|
|
1076
|
-
|
|
1077
|
-
|
|
1078
|
-
|
|
1079
|
-
|
|
1080
|
-
|
|
1219
|
+
// Put `value` on globalThis[name] for a render and hand back what puts the
|
|
1220
|
+
// old property back, descriptor and all. Never reads the old value — a host
|
|
1221
|
+
// global can be a lazy accessor that warns or throws when read.
|
|
1222
|
+
const swapGlobal = (name, value) => {
|
|
1223
|
+
const previous = Object.getOwnPropertyDescriptor(globalThis, name)
|
|
1224
|
+
if (previous && !previous.configurable) {
|
|
1225
|
+
if (!previous.writable && !previous.set) return () => {}
|
|
1226
|
+
globalThis[name] = value
|
|
1227
|
+
return () => {}
|
|
1228
|
+
}
|
|
1229
|
+
Object.defineProperty(globalThis, name, { value, writable: true, configurable: true, enumerable: true })
|
|
1230
|
+
return () => {
|
|
1231
|
+
if (previous) Object.defineProperty(globalThis, name, previous)
|
|
1232
|
+
else delete globalThis[name]
|
|
1233
|
+
}
|
|
1234
|
+
}
|
|
1235
|
+
|
|
1236
|
+
// ── Labels after a render ───────────────────────────────────────────────────
|
|
1237
|
+
|
|
1238
|
+
const LABEL_TEMPLATE = /\{\{\s*([^|{}]+?)\s*\|\s*polyglot\s*\}\}/g
|
|
1239
|
+
|
|
1240
|
+
const plainOf = (value) => value && typeof value.parse === 'function' ? value.parse() : value
|
|
1241
|
+
|
|
1242
|
+
// The labels a render ended with, by language: the ones fetched before it
|
|
1243
|
+
// (`ssrTranslations`), with the root state's own — loaded during the render
|
|
1244
|
+
// by the project's polyglot.fetch — over them. Null when there are none.
|
|
1245
|
+
const renderedLabels = (ssrTranslations, root) => {
|
|
1246
|
+
const loaded = plainOf(root?.translations)
|
|
1247
|
+
const out = {}
|
|
1248
|
+
for (const source of [ssrTranslations, loaded]) {
|
|
1249
|
+
if (!source || typeof source !== 'object') continue
|
|
1250
|
+
for (const lang in source) {
|
|
1251
|
+
const map = plainOf(source[lang])
|
|
1252
|
+
if (map && typeof map === 'object') out[lang] = { ...(out[lang] || {}), ...map }
|
|
1253
|
+
}
|
|
1254
|
+
}
|
|
1255
|
+
return Object.keys(out).length ? out : null
|
|
1256
|
+
}
|
|
1257
|
+
|
|
1258
|
+
// One language's labels: the page's, else the default language's, else the
|
|
1259
|
+
// first there is — the order the post-render pass always fell back in.
|
|
1260
|
+
const labelMapFor = (labels, activeLang, poly) =>
|
|
1261
|
+
labels[activeLang] || labels[polyglotFallbackLang(undefined, poly)] || Object.values(labels)[0] || {}
|
|
1081
1262
|
|
|
1082
|
-
|
|
1263
|
+
const resolveLabelTemplates = (html, langMap) =>
|
|
1264
|
+
html.replace(LABEL_TEMPLATE, (match, key) => {
|
|
1265
|
+
const val = ssrResolve(langMap, key.trim())
|
|
1266
|
+
return val ?? match
|
|
1267
|
+
})
|
|
1268
|
+
|
|
1269
|
+
// JSON data blocks (registry, fetch seed) are data, not what a visitor reads.
|
|
1270
|
+
const DATA_BLOCK = /<script\b[^>]*\btype\s*=\s*["']?application\/json["']?[^>]*>[\s\S]*?<\/script\s*>/gi
|
|
1271
|
+
// The key as a whole token: not part of a longer key or word.
|
|
1272
|
+
const keyInMarkup = (markup, key) =>
|
|
1273
|
+
new RegExp(`(?:^|[^\\w.-])${escapeRegExp(key)}(?![\\w-]|\\.\\w)`).test(markup)
|
|
1274
|
+
|
|
1275
|
+
// The label keys a page shows raw: every key ssrTranslate missed that the
|
|
1276
|
+
// labels the render ended with still do not have AND that reached the
|
|
1277
|
+
// markup, plus every `{{ key | polyglot }}` template left in it. A lookup
|
|
1278
|
+
// whose result never rendered (a discarded value, a branch not taken) is no
|
|
1279
|
+
// raw key on screen — XMA /vlog failed `--strict` on `ui.vlog.desc`, which
|
|
1280
|
+
// appeared 0 times in the page. A miss with whitespace in it is text handed
|
|
1281
|
+
// to `polyglot`, not a key.
|
|
1282
|
+
const rawLabelKeys = (missed, html, activeLang, poly, root) => {
|
|
1283
|
+
const raw = new Set()
|
|
1284
|
+
const markup = html.replace(DATA_BLOCK, '')
|
|
1285
|
+
for (const key of missed) {
|
|
1286
|
+
if (/\s/.test(key)) continue
|
|
1287
|
+
if (findLabel(key, activeLang, poly, root) !== undefined) continue
|
|
1288
|
+
if (keyInMarkup(markup, key)) raw.add(key)
|
|
1289
|
+
}
|
|
1290
|
+
for (const m of markup.matchAll(LABEL_TEMPLATE)) raw.add(m[1].trim())
|
|
1291
|
+
return [...raw]
|
|
1083
1292
|
}
|
|
1084
1293
|
|
|
1085
1294
|
/**
|
|
@@ -1284,8 +1493,9 @@ export const replaceEmotionCSS = (html, newCSS) => {
|
|
|
1284
1493
|
* @returns {Promise<{ html: string, css: string, resetCss: string, fontLinks: string, metadata: object, brKeyCount: number }>}
|
|
1285
1494
|
*/
|
|
1286
1495
|
export const renderRoute = async (data, options = {}) => {
|
|
1287
|
-
// `prefetch: false`
|
|
1288
|
-
// — the publish-time pre-render (smbls publish →
|
|
1496
|
+
// `prefetch: false` fetches no table/RPC labels first and does not wait for
|
|
1497
|
+
// the page's requests — the publish-time pre-render (smbls publish →
|
|
1498
|
+
// ssrCache) renders data-free.
|
|
1289
1499
|
const { route = '/', pathname, prefetch = true } = options
|
|
1290
1500
|
|
|
1291
1501
|
// Use the full render pipeline which handles polyglot, prefetch, emotion, etc.
|
|
@@ -1296,39 +1506,16 @@ export const renderRoute = async (data, options = {}) => {
|
|
|
1296
1506
|
const ds = data.designSystem || {}
|
|
1297
1507
|
const globalCSS = result.globalCSS
|
|
1298
1508
|
|
|
1299
|
-
//
|
|
1509
|
+
// The page data the render ended with and its language, for a caller's
|
|
1510
|
+
// metadata (mermaid's fallback reads them). The data is what the fetch
|
|
1511
|
+
// plugin put into state during the render (collectFetchedState) — it used
|
|
1512
|
+
// to be the RAW rows a pre-fetch had injected into the page definition.
|
|
1300
1513
|
let prefetchedState = null
|
|
1301
|
-
let activeLang = null
|
|
1302
1514
|
try {
|
|
1303
|
-
const
|
|
1304
|
-
|
|
1305
|
-
activeLang = el?.state?.root?.lang || polyglot?.defaultLang || 'en'
|
|
1306
|
-
|
|
1307
|
-
// Get prefetched data from the injected page definitions (pre-DOMQL proxy)
|
|
1308
|
-
if (result.prefetchedPages && result.prefetchedPages[route]) {
|
|
1309
|
-
const pageDef = result.prefetchedPages[route]
|
|
1310
|
-
// Collect all state entries from the page definition tree
|
|
1311
|
-
const collectStates = (def, result = {}) => {
|
|
1312
|
-
if (!def || typeof def !== 'object') return result
|
|
1313
|
-
if (def.state && typeof def.state === 'object') {
|
|
1314
|
-
for (const [k, v] of Object.entries(def.state)) {
|
|
1315
|
-
if (v !== undefined && v !== null && typeof v !== 'function') {
|
|
1316
|
-
result[k] = v
|
|
1317
|
-
}
|
|
1318
|
-
}
|
|
1319
|
-
}
|
|
1320
|
-
// Recurse into all child definitions (not just those with state)
|
|
1321
|
-
for (const [key, child] of Object.entries(def)) {
|
|
1322
|
-
if (key === 'state' || key === 'props' || key === 'attr' || key === 'on' || key === 'define' || key === '__ref' || key.startsWith('__')) continue
|
|
1323
|
-
if (child && typeof child === 'object' && !Array.isArray(child)) {
|
|
1324
|
-
collectStates(child, result)
|
|
1325
|
-
}
|
|
1326
|
-
}
|
|
1327
|
-
return result
|
|
1328
|
-
}
|
|
1329
|
-
prefetchedState = collectStates(pageDef)
|
|
1330
|
-
}
|
|
1515
|
+
const fetched = collectFetchedState(result.element)
|
|
1516
|
+
if (Object.keys(fetched).length) prefetchedState = fetched
|
|
1331
1517
|
} catch (e) { /* ignore */ }
|
|
1518
|
+
const activeLang = result.activeLang
|
|
1332
1519
|
|
|
1333
1520
|
return {
|
|
1334
1521
|
html: result.html,
|
|
@@ -1342,14 +1529,39 @@ export const renderRoute = async (data, options = {}) => {
|
|
|
1342
1529
|
metadata: result.metadata || extractMetadata(data, route),
|
|
1343
1530
|
brKeyCount: result.registry ? Object.keys(result.registry).length : 0,
|
|
1344
1531
|
brRegistry: result.brRegistry || {},
|
|
1345
|
-
// anonymous GET answers this render got (see `
|
|
1532
|
+
// anonymous GET answers this render got (see `SEED`) — for adoptionScripts
|
|
1346
1533
|
fetchSeed: result.fetchSeed || [],
|
|
1347
1534
|
ssrTranslations: result.ssrTranslations,
|
|
1348
1535
|
prefetchedState,
|
|
1349
|
-
activeLang
|
|
1536
|
+
activeLang,
|
|
1537
|
+
// failed requests and raw label keys (see `DIAG`); null when an
|
|
1538
|
+
// overlapping render made them unknowable
|
|
1539
|
+
diagnostics: result.diagnostics
|
|
1350
1540
|
}
|
|
1351
1541
|
}
|
|
1352
1542
|
|
|
1543
|
+
// What the fetch plugin put into state during a render, by the `as` key each
|
|
1544
|
+
// `fetch` config names — a `~/key` one read off the root state. Configs
|
|
1545
|
+
// without `as` (a transform spreading into the element's state) and function
|
|
1546
|
+
// configs are not read: a function would have to run again.
|
|
1547
|
+
const collectFetchedState = (element) => {
|
|
1548
|
+
const out = {}
|
|
1549
|
+
walkElements(element, (el) => {
|
|
1550
|
+
const decl = el.fetch
|
|
1551
|
+
if (!decl || typeof decl !== 'object' || !el.state) return
|
|
1552
|
+
for (const cfg of Array.isArray(decl) ? decl : [decl]) {
|
|
1553
|
+
const as = cfg && typeof cfg === 'object' && typeof cfg.as === 'string' ? cfg.as : null
|
|
1554
|
+
if (!as) continue
|
|
1555
|
+
const fromRoot = as.startsWith('~/')
|
|
1556
|
+
const name = fromRoot ? as.slice(2) : as
|
|
1557
|
+
const source = fromRoot ? el.state.root : el.state
|
|
1558
|
+
const value = plainOf(source && source[name])
|
|
1559
|
+
if (name && value != null && typeof value !== 'function') out[name] = value
|
|
1560
|
+
}
|
|
1561
|
+
})
|
|
1562
|
+
return out
|
|
1563
|
+
}
|
|
1564
|
+
|
|
1353
1565
|
// ── Full page SSR ─────────────────────────────────────────────────────────────
|
|
1354
1566
|
|
|
1355
1567
|
/**
|
|
@@ -1366,12 +1578,20 @@ export const renderRoute = async (data, options = {}) => {
|
|
|
1366
1578
|
* @param {string} [options.themeColor] - theme-color meta
|
|
1367
1579
|
* @param {object} [options.isr] - ISR options with clientScript path
|
|
1368
1580
|
* @param {boolean} [options.hydrate=true] - With `isr`: boot the client over the
|
|
1369
|
-
* pre-rendered markup (
|
|
1370
|
-
*
|
|
1371
|
-
* @param {boolean} [options.adoptDom=false] -
|
|
1372
|
-
* element-path registry,
|
|
1373
|
-
*
|
|
1374
|
-
*
|
|
1581
|
+
* pre-rendered markup (the page's `<meta name="brender">`) instead of the
|
|
1582
|
+
* legacy swap script
|
|
1583
|
+
* @param {boolean} [options.adoptDom=false] - Hydration: also ship the
|
|
1584
|
+
* element-path registry (a JSON data block), so the client ADOPTS the
|
|
1585
|
+
* server DOM (smbls prerender.js) instead of replacing it. `smbls build`
|
|
1586
|
+
* passes symbols.json `brender.hydrate` (cli helpers/runBrender.js).
|
|
1587
|
+
* @param {string} [options.nonce] - The Content-Security-Policy nonce for the
|
|
1588
|
+
* inline scripts the page needs (the ISR paths only: a page rendered into a
|
|
1589
|
+
* shell carries none — its flag is a <meta>, its registry and fetch seed are
|
|
1590
|
+
* data blocks, which no policy blocks)
|
|
1591
|
+
* @param {boolean} [options.prefetch=true] - Fetch table/RPC labels before the
|
|
1592
|
+
* render and wait (up to 2 s) for the requests the page starts; false waits
|
|
1593
|
+
* only a 50 ms quiet period. The page's data always comes from the fetch
|
|
1594
|
+
* plugin during the render.
|
|
1375
1595
|
* @param {string} [options.shell] - A built app document (e.g. dist/index.html)
|
|
1376
1596
|
* to render INTO instead of emitting a standalone document (see shell.js).
|
|
1377
1597
|
* The result then carries `shell: true`.
|
|
@@ -1381,13 +1601,18 @@ export const renderRoute = async (data, options = {}) => {
|
|
|
1381
1601
|
* @returns {Promise<{ html: string, route: string, brKeyCount: number, shell?: true }>}
|
|
1382
1602
|
*/
|
|
1383
1603
|
export const renderPage = async (data, route = '/', options = {}) => {
|
|
1384
|
-
const { lang, themeColor, isr, hydrate = true, prefetch = true, shell, adoptDom = false } = options
|
|
1604
|
+
const { lang, themeColor, isr, hydrate = true, prefetch = true, shell, adoptDom = false, pathname, eagerImages = EAGER_IMAGES_DEFAULT, preloadScripts = true, nonce } = options
|
|
1605
|
+
const nonceAttr = nonceAttribute(nonce)
|
|
1385
1606
|
|
|
1386
1607
|
// Detect lang from project config, app metadata, or default
|
|
1387
1608
|
const htmlLang = lang || data.state?.lang || data.app?.metadata?.lang || 'en'
|
|
1388
1609
|
|
|
1389
|
-
// Use the full smbls pipeline for rendering
|
|
1390
|
-
|
|
1610
|
+
// Use the full smbls pipeline for rendering. `pathname` is the address the
|
|
1611
|
+
// page is rendered at — the router matches it, location and every
|
|
1612
|
+
// canonical/og:url built from it read it. Without it a route was rendered
|
|
1613
|
+
// at its own pattern: the `/*` page wrote a `/*` canonical into 404.html
|
|
1614
|
+
// (xma.info report, brender build item 7).
|
|
1615
|
+
const result = await render(data, { route, pathname, prefetch })
|
|
1391
1616
|
if (!result) return null
|
|
1392
1617
|
|
|
1393
1618
|
const metadata = { ...result.metadata }
|
|
@@ -1431,9 +1656,11 @@ export const renderPage = async (data, route = '/', options = {}) => {
|
|
|
1431
1656
|
|
|
1432
1657
|
const brKeyCount = Object.keys(result.registry).length
|
|
1433
1658
|
|
|
1434
|
-
//
|
|
1435
|
-
//
|
|
1436
|
-
|
|
1659
|
+
// What the client boots with (prerenderParts): the page's mark for <head>,
|
|
1660
|
+
// and the data blocks after the markup — the registry under `adoptDom`,
|
|
1661
|
+
// and the recorded anonymous answers, so the client renders from the data
|
|
1662
|
+
// the server used instead of asking for all of it again.
|
|
1663
|
+
const prerender = prerenderParts({ brRegistry: result.brRegistry, fetchSeed: result.fetchSeed, adoptDom })
|
|
1437
1664
|
|
|
1438
1665
|
// ISR: include client SPA bundle for hydration + data fetching
|
|
1439
1666
|
let isrBody = ''
|
|
@@ -1447,9 +1674,9 @@ export const renderPage = async (data, route = '/', options = {}) => {
|
|
|
1447
1674
|
const prefix = depth > 0 ? '../'.repeat(depth) : './'
|
|
1448
1675
|
|
|
1449
1676
|
if (hydrate) {
|
|
1450
|
-
//
|
|
1451
|
-
//
|
|
1452
|
-
//
|
|
1677
|
+
// The client boots over the markup: it knows the page by its
|
|
1678
|
+
// `<meta name="brender">` and adopts the markup when the page carries
|
|
1679
|
+
// the registry (`adoptDom`), else replaces it (smbls prerender.js).
|
|
1453
1680
|
//
|
|
1454
1681
|
// Seed client-side polyglot with SSR translations so that
|
|
1455
1682
|
// el.call('polyglot', key) resolves immediately during hydration
|
|
@@ -1467,20 +1694,20 @@ export const renderPage = async (data, route = '/', options = {}) => {
|
|
|
1467
1694
|
}
|
|
1468
1695
|
}
|
|
1469
1696
|
if (storageLangKey) {
|
|
1470
|
-
const defaultLang = polyglotCfg
|
|
1697
|
+
const defaultLang = polyglotFallbackLang(undefined, polyglotCfg)
|
|
1471
1698
|
seedEntries.push(`if(!localStorage.getItem(${JSON.stringify(storageLangKey)}))localStorage.setItem(${JSON.stringify(storageLangKey)},${JSON.stringify(defaultLang)})`)
|
|
1472
1699
|
}
|
|
1473
1700
|
if (seedEntries.length) {
|
|
1474
|
-
translationSeed = `<script>try{${seedEntries.join(';')}}catch(e){}</script>\n`
|
|
1701
|
+
translationSeed = `<script${nonceAttr}>try{${seedEntries.join(';')}}catch(e){}</script>\n`
|
|
1475
1702
|
}
|
|
1476
1703
|
}
|
|
1477
|
-
//
|
|
1478
|
-
|
|
1479
|
-
|
|
1480
|
-
<script type="module" src="${prefix}${isr.clientScript}"></script>`
|
|
1704
|
+
// The registry and the fetch seed as data blocks; the page's mark is
|
|
1705
|
+
// its <meta name="brender"> (no inline flag a policy could block).
|
|
1706
|
+
const blocks = prerender.blocks ? prerender.blocks + '\n' : ''
|
|
1707
|
+
isrBody = `${translationSeed}${blocks}<script type="module" src="${prefix}${isr.clientScript}"${nonceAttr}></script>`
|
|
1481
1708
|
} else {
|
|
1482
1709
|
// Legacy swap mode: SPA creates new DOM, MutationObserver removes brender nodes
|
|
1483
|
-
isrBody = `<script type="module">
|
|
1710
|
+
isrBody = `<script type="module"${nonceAttr}>
|
|
1484
1711
|
{
|
|
1485
1712
|
const brEls = document.querySelectorAll('body > :not(script):not(style)')
|
|
1486
1713
|
const observer = new MutationObserver((mutations) => {
|
|
@@ -1497,42 +1724,56 @@ export const renderPage = async (data, route = '/', options = {}) => {
|
|
|
1497
1724
|
observer.observe(document.body, { childList: true })
|
|
1498
1725
|
}
|
|
1499
1726
|
</script>
|
|
1500
|
-
<script type="module" src="${prefix}${isr.clientScript}"></script>`
|
|
1727
|
+
<script type="module" src="${prefix}${isr.clientScript}"${nonceAttr}></script>`
|
|
1501
1728
|
}
|
|
1502
1729
|
}
|
|
1503
1730
|
|
|
1504
1731
|
// Resolve any {{ key | polyglot }} templates in head tags (title, meta, etc.)
|
|
1732
|
+
// — in the page's language, with the labels the render ended with.
|
|
1505
1733
|
const headConfig = { ...(data.config || {}) }
|
|
1506
1734
|
if (data.polyglot && !headConfig.polyglot) headConfig.polyglot = data.polyglot
|
|
1507
1735
|
const polyglotCfg = headConfig.polyglot
|
|
1508
1736
|
let resolvedHeadTags = headTags
|
|
1509
1737
|
if (polyglotCfg) {
|
|
1510
|
-
const defaultLang = polyglotCfg.defaultLang || 'en'
|
|
1511
1738
|
// Use SSR-fetched translations (from render result) merged with static translations
|
|
1512
|
-
const translations = {
|
|
1513
|
-
|
|
1514
|
-
...(
|
|
1739
|
+
const translations = { ...(polyglotCfg.translations || {}) }
|
|
1740
|
+
for (const lang in result.ssrTranslations || {}) {
|
|
1741
|
+
translations[lang] = { ...(translations[lang] || {}), ...result.ssrTranslations[lang] }
|
|
1515
1742
|
}
|
|
1516
|
-
|
|
1517
|
-
resolvedHeadTags = headTags.replace(/\{\{\s*([^|{}]+?)\s*\|\s*polyglot\s*\}\}/g, (match, key) => {
|
|
1518
|
-
const trimmed = key.trim()
|
|
1519
|
-
return langMap[trimmed] ?? match
|
|
1520
|
-
})
|
|
1743
|
+
resolvedHeadTags = resolveLabelTemplates(headTags, labelMapFor(translations, result.activeLang, polyglotCfg))
|
|
1521
1744
|
}
|
|
1522
1745
|
|
|
1746
|
+
// A template the head still carries is a raw key too.
|
|
1747
|
+
const diagnostics = result.diagnostics
|
|
1748
|
+
if (diagnostics) {
|
|
1749
|
+
for (const m of resolvedHeadTags.matchAll(LABEL_TEMPLATE)) {
|
|
1750
|
+
const key = m[1].trim()
|
|
1751
|
+
if (!diagnostics.rawLabelKeys.includes(key)) diagnostics.rawLabelKeys.push(key)
|
|
1752
|
+
}
|
|
1753
|
+
}
|
|
1754
|
+
|
|
1755
|
+
// Images past the first `eagerImages` load lazily unless they say how to
|
|
1756
|
+
// load (see markLazyImages).
|
|
1757
|
+
const bodyHtml = markLazyImages(result.html, eagerImages)
|
|
1758
|
+
|
|
1523
1759
|
// Build path: render INTO the app's own built document (shell.js) — the
|
|
1524
1760
|
// standalone document below carries no app code unless `isr` adds a
|
|
1525
1761
|
// client bundle, so writing it over a bundler's dist/index.html shipped
|
|
1526
1762
|
// pages that never booted.
|
|
1527
1763
|
if (typeof shell === 'string') {
|
|
1528
1764
|
const html = composeIntoShell(shell, {
|
|
1529
|
-
headTags: resolvedHeadTags
|
|
1765
|
+
headTags: `${resolvedHeadTags}\n${prerender.meta}`,
|
|
1530
1766
|
headEnd: [fontLinks, brenderStyle, emotionStyle].filter(Boolean).join('\n'),
|
|
1531
|
-
body:
|
|
1767
|
+
body: bodyHtml,
|
|
1532
1768
|
depth: Number.isInteger(options.depth) ? options.depth : 0,
|
|
1533
|
-
bootScripts:
|
|
1769
|
+
bootScripts: prerender.blocks,
|
|
1770
|
+
// The page's language on <html>, as the client's polyglot sets it at
|
|
1771
|
+
// boot — a page rendered for /en/… kept the shell's `lang="ka"`.
|
|
1772
|
+
// Only when the project has languages (or the caller names one).
|
|
1773
|
+
lang: lang || (polyglotCfg ? result.activeLang : undefined),
|
|
1774
|
+
preloadScripts
|
|
1534
1775
|
})
|
|
1535
|
-
return { html, route, brKeyCount, shell: true }
|
|
1776
|
+
return { html, route, brKeyCount, shell: true, diagnostics }
|
|
1536
1777
|
}
|
|
1537
1778
|
|
|
1538
1779
|
// (The empty line after the font links held a @font-face <style> that
|
|
@@ -1540,19 +1781,43 @@ export const renderPage = async (data, route = '/', options = {}) => {
|
|
|
1540
1781
|
const html = `<!DOCTYPE html>
|
|
1541
1782
|
<html lang="${htmlLang}">
|
|
1542
1783
|
<head>
|
|
1543
|
-
${resolvedHeadTags}
|
|
1784
|
+
${resolvedHeadTags}${isr && isr.clientScript && hydrate ? `\n${prerender.meta}` : ''}
|
|
1544
1785
|
${fontLinks}
|
|
1545
1786
|
|
|
1546
1787
|
${brenderStyle}
|
|
1547
1788
|
${emotionStyle}
|
|
1548
1789
|
</head>
|
|
1549
1790
|
<body>
|
|
1550
|
-
${
|
|
1791
|
+
${bodyHtml}
|
|
1551
1792
|
${isrBody}
|
|
1552
1793
|
</body>
|
|
1553
1794
|
</html>`
|
|
1554
1795
|
|
|
1555
|
-
return { html, route, brKeyCount }
|
|
1796
|
+
return { html, route, brKeyCount, diagnostics }
|
|
1797
|
+
}
|
|
1798
|
+
|
|
1799
|
+
// ── Images below the fold ───────────────────────────────────────────────────
|
|
1800
|
+
// A pre-rendered page's images are in the markup before the app's script,
|
|
1801
|
+
// and on a slow connection they took its bandwidth: measured on a throttled
|
|
1802
|
+
// phone, the app's 214 KB script finished at 2.65 s instead of 1.75 s while
|
|
1803
|
+
// 165–191 KB of images loaded (xma.info report, brender build item 14). The
|
|
1804
|
+
// server cannot see the fold, so the first `eager` images in document order
|
|
1805
|
+
// load as written and every later one that does not say how to load gets
|
|
1806
|
+
// `loading="lazy"` — a lazy image inside the viewport still loads right after
|
|
1807
|
+
// layout. An image that names its own `loading` is left alone. `eager:
|
|
1808
|
+
// false` turns this off.
|
|
1809
|
+
export const EAGER_IMAGES_DEFAULT = 2
|
|
1810
|
+
const IMG_TAG = /<img\b(?:[^>"']|"[^"]*"|'[^']*')*>/gi
|
|
1811
|
+
const HAS_LOADING = /\sloading\s*=/i
|
|
1812
|
+
|
|
1813
|
+
export const markLazyImages = (html, eager = EAGER_IMAGES_DEFAULT) => {
|
|
1814
|
+
if (eager === false || eager == null || typeof html !== 'string') return html
|
|
1815
|
+
const keep = Math.max(0, Number(eager) || 0)
|
|
1816
|
+
let seen = 0
|
|
1817
|
+
return html.replace(IMG_TAG, (tag) => {
|
|
1818
|
+
if (seen++ < keep || HAS_LOADING.test(tag)) return tag
|
|
1819
|
+
return tag.replace(/^<img\b/i, '<img loading="lazy"')
|
|
1820
|
+
})
|
|
1556
1821
|
}
|
|
1557
1822
|
|
|
1558
1823
|
const generateFontLinks = (ds) => {
|
|
@@ -1581,3 +1846,38 @@ const generateFontLinks = (ds) => {
|
|
|
1581
1846
|
`<link href="https://fonts.googleapis.com/css2?${params}&display=swap" rel="stylesheet">`
|
|
1582
1847
|
].join('\n')
|
|
1583
1848
|
}
|
|
1849
|
+
|
|
1850
|
+
// ── The page's boot data (CSP-safe) ─────────────────────────────────────────
|
|
1851
|
+
// A pre-rendered page tells the client what it is without one inline script:
|
|
1852
|
+
// XMA serves `script-src 'self'` (no 'unsafe-inline'), which blocked the
|
|
1853
|
+
// `<script>window.__BRENDER__=true</script>` flag — the client then created
|
|
1854
|
+
// the app from scratch and appended it after the server markup, two app
|
|
1855
|
+
// roots (XMA report, brender item 1). A policy blocks inline scripts, never
|
|
1856
|
+
// <meta> tags or data blocks. The format smbls reads (smbls prerender.js):
|
|
1857
|
+
// meta `<meta name="brender" content="hydrate|replace">` for <head>
|
|
1858
|
+
// blocks after the markup — the element-path registry under `adoptDom`
|
|
1859
|
+
// (`<script type="application/json" data-brender="registry">`),
|
|
1860
|
+
// and the recorded anonymous answers (@symbo.ls/fetch
|
|
1861
|
+
// createEarlySeedBlock), which the client adopts instead of asking
|
|
1862
|
+
// again. Placed after the markup, so they never delay its paint;
|
|
1863
|
+
// the app's scripts run after them either way.
|
|
1864
|
+
// `adoptionScripts` above stays for older callers (the platform's renderer).
|
|
1865
|
+
export const prerenderParts = ({ brRegistry, fetchSeed, adoptDom = false } = {}) => {
|
|
1866
|
+
const registry = adoptDom && brRegistry && Object.keys(brRegistry).length
|
|
1867
|
+
? brenderRegistryBlock(brRegistry)
|
|
1868
|
+
: ''
|
|
1869
|
+
const seed = createEarlySeedBlock(fetchSeed)
|
|
1870
|
+
return {
|
|
1871
|
+
meta: `<meta name="brender" content="${registry ? 'hydrate' : 'replace'}">`,
|
|
1872
|
+
blocks: [registry, seed].filter(Boolean).join('\n')
|
|
1873
|
+
}
|
|
1874
|
+
}
|
|
1875
|
+
|
|
1876
|
+
// ` nonce="…"` for an inline script, or '' — a value that could break out of
|
|
1877
|
+
// the attribute is refused.
|
|
1878
|
+
const nonceAttribute = (nonce) => {
|
|
1879
|
+
if (nonce == null || nonce === '') return ''
|
|
1880
|
+
const value = String(nonce)
|
|
1881
|
+
if (!/^[A-Za-z0-9+/=_-]+$/.test(value)) throw new Error('brender: a nonce is base64 or base64url text')
|
|
1882
|
+
return ` nonce="${value}"`
|
|
1883
|
+
}
|