@symbo.ls/brender 3.14.664 → 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 +24 -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 +8 -2
- package/index.js +5 -3
- package/load.js +111 -22
- package/package.json +8 -8
- package/prefetch.js +30 -62
- package/render.js +475 -188
- 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
|
|
@@ -326,9 +342,44 @@ const bundleCreateDomql = async () => {
|
|
|
326
342
|
|
|
327
343
|
const tryResolve = resolvePackageEntry
|
|
328
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
|
+
|
|
329
366
|
const workspacePlugin = {
|
|
330
367
|
name: 'workspace-resolve',
|
|
331
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
|
+
}
|
|
332
383
|
// Resolve smbls bare import
|
|
333
384
|
build.onResolve({ filter: /^smbls/ }, (args) => {
|
|
334
385
|
const subpath = args.path.replace(/^smbls\/?/, '')
|
|
@@ -634,17 +685,24 @@ const UIKIT_STUBS = {
|
|
|
634
685
|
*/
|
|
635
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
|
+
}
|
|
695
|
+
|
|
696
|
+
// Every element of a rendered tree, each before its children, in the order of
|
|
697
|
+
// `__ref.__children`.
|
|
698
|
+
const walkElements = (root, visit) => {
|
|
637
699
|
const walk = (el) => {
|
|
638
700
|
const ref = el && el.__ref
|
|
639
701
|
if (!ref) return
|
|
640
|
-
|
|
641
|
-
const path = elementPath(el)
|
|
642
|
-
registry[path === '' ? '__root' : path] = ref.__brKey
|
|
643
|
-
}
|
|
702
|
+
visit(el, ref)
|
|
644
703
|
if (ref.__children) for (const childKey of ref.__children) walk(el[childKey])
|
|
645
704
|
}
|
|
646
|
-
walk(
|
|
647
|
-
return registry
|
|
705
|
+
walk(root)
|
|
648
706
|
}
|
|
649
707
|
|
|
650
708
|
/**
|
|
@@ -697,13 +755,64 @@ const inFlightRequests = () => _inFlight
|
|
|
697
755
|
const SEED_ENTRY_MAX = 256 * 1024
|
|
698
756
|
const SEED_TOTAL_MAX = 1024 * 1024
|
|
699
757
|
const SEED_HEADERS_OK = new Set(['accept-language', 'accept'])
|
|
700
|
-
|
|
701
|
-
|
|
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)
|
|
702
811
|
|
|
703
812
|
const requestShape = (args) => {
|
|
704
813
|
const [input, init] = args
|
|
705
814
|
const isRequest = input && typeof input === 'object' && typeof input.url === 'string'
|
|
706
|
-
const url =
|
|
815
|
+
const url = requestUrl(input)
|
|
707
816
|
const method = String((init && init.method) || (isRequest && input.method) || 'GET').toUpperCase()
|
|
708
817
|
const headers = {}
|
|
709
818
|
// Any fetch HeadersInit, read like the REST adapter reads one (headerEntries).
|
|
@@ -743,7 +852,8 @@ const trackFetches = () => {
|
|
|
743
852
|
const done = () => { if (!settled) { settled = true; _inFlight-- } }
|
|
744
853
|
// a pending body read counts as in flight: the flush waits for it too
|
|
745
854
|
const track = (promise) => { _inFlight++; promise.then(() => { _inFlight-- }, () => { _inFlight-- }) }
|
|
746
|
-
const seed =
|
|
855
|
+
const seed = liveRecord(SEED)
|
|
856
|
+
const diag = liveRecord(DIAG)
|
|
747
857
|
let shape = null
|
|
748
858
|
if (seed) {
|
|
749
859
|
try {
|
|
@@ -752,18 +862,24 @@ const trackFetches = () => {
|
|
|
752
862
|
Object.keys(shape.headers).some((h) => !SEED_HEADERS_OK.has(h))) shape = null
|
|
753
863
|
} catch { shape = null }
|
|
754
864
|
}
|
|
865
|
+
const urlOf = () => requestUrl(args[0])
|
|
755
866
|
let result
|
|
756
867
|
try {
|
|
757
868
|
result = inner.apply(this, args)
|
|
758
869
|
} catch (err) {
|
|
870
|
+
recordFailure(diag, urlOf(), (err && err.message) || 'failed')
|
|
759
871
|
done()
|
|
760
872
|
throw err
|
|
761
873
|
}
|
|
762
874
|
if (result && typeof result.then === 'function') {
|
|
763
875
|
result.then((response) => {
|
|
764
876
|
if (shape) recordAnswer(seed, shape, response, track)
|
|
877
|
+
if (diag && response && response.ok === false) recordFailure(diag, urlOf(), 'HTTP ' + response.status)
|
|
878
|
+
done()
|
|
879
|
+
}, (err) => {
|
|
880
|
+
recordFailure(diag, urlOf(), (err && err.message) || 'network error')
|
|
765
881
|
done()
|
|
766
|
-
}
|
|
882
|
+
})
|
|
767
883
|
} else done()
|
|
768
884
|
return result
|
|
769
885
|
}
|
|
@@ -795,55 +911,35 @@ export const render = async (data, options = {}) => {
|
|
|
795
911
|
// pathname is the actual URL path (e.g. /podcast/abc-123), route is the page pattern (e.g. /podcast/:id)
|
|
796
912
|
const locationPath = pathname || route
|
|
797
913
|
|
|
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
|
-
}
|
|
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()
|
|
843
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).
|
|
844
935
|
|
|
845
936
|
// ── SSR polyglot translations ──
|
|
846
|
-
//
|
|
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).
|
|
847
943
|
let ssrTranslations
|
|
848
944
|
if (prefetch) {
|
|
849
945
|
try {
|
|
@@ -853,24 +949,27 @@ export const render = async (data, options = {}) => {
|
|
|
853
949
|
}
|
|
854
950
|
}
|
|
855
951
|
|
|
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
952
|
const { window, document } = createEnv()
|
|
863
953
|
const body = document.body
|
|
864
954
|
|
|
865
955
|
// Set route on location so the router picks it up
|
|
866
956
|
window.location.pathname = locationPath
|
|
867
957
|
|
|
868
|
-
//
|
|
869
|
-
//
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
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 {
|
|
874
973
|
|
|
875
974
|
// Import createDomqlElement via bundled smbls source.
|
|
876
975
|
// The smbls monorepo uses extensionless/directory imports that Node.js ESM
|
|
@@ -903,7 +1002,7 @@ export const render = async (data, options = {}) => {
|
|
|
903
1002
|
if (ssrTranslations || polyglotConfig) {
|
|
904
1003
|
if (!baseState.root) baseState.root = {}
|
|
905
1004
|
if (polyglotConfig) {
|
|
906
|
-
baseState.root.lang = baseState.root.lang
|
|
1005
|
+
baseState.root.lang = polyglotFallbackLang(baseState.root.lang, polyglotConfig)
|
|
907
1006
|
}
|
|
908
1007
|
if (ssrTranslations) {
|
|
909
1008
|
baseState.root.translations = {
|
|
@@ -939,7 +1038,7 @@ export const render = async (data, options = {}) => {
|
|
|
939
1038
|
dependencies: structuredCloneDeep(data.dependencies || {}),
|
|
940
1039
|
components: structuredCloneDeep(data.components || {}),
|
|
941
1040
|
snippets: structuredCloneDeep(data.snippets || {}),
|
|
942
|
-
pages: structuredCloneDeep(
|
|
1041
|
+
pages: structuredCloneDeep(data.pages || {}),
|
|
943
1042
|
functions: {
|
|
944
1043
|
...(data.functions || {}),
|
|
945
1044
|
// SSR polyglot functions — enable {{ key | polyglot }} resolution during render
|
|
@@ -962,6 +1061,11 @@ export const render = async (data, options = {}) => {
|
|
|
962
1061
|
// Disable sourcemap tracking in SSR — it causes stack overflows
|
|
963
1062
|
// when state contains large data arrays (articles, events, etc.)
|
|
964
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,
|
|
965
1069
|
// Editor/live-sync, inspector and toast plugins have no place in server
|
|
966
1070
|
// markup. The source-bundle path used to stub @symbo.ls/sync to no-ops at
|
|
967
1071
|
// bundle time; the installed `smbls/ssr` entry carries the real plugins,
|
|
@@ -971,6 +1075,19 @@ export const render = async (data, options = {}) => {
|
|
|
971
1075
|
sync: false,
|
|
972
1076
|
inspect: false,
|
|
973
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,
|
|
974
1091
|
// Caller overrides
|
|
975
1092
|
...(contextOverrides || {})
|
|
976
1093
|
}
|
|
@@ -986,21 +1103,19 @@ export const render = async (data, options = {}) => {
|
|
|
986
1103
|
|
|
987
1104
|
resetKeys()
|
|
988
1105
|
|
|
989
|
-
|
|
990
|
-
// 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
|
|
991
1107
|
// another render overlaps it
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
else if (_seed) _seed.tainted = true
|
|
995
|
-
const seed = _seed
|
|
1108
|
+
const recording = openRenderRecord(SEED, () => ({ entries: [], bytes: 0, tainted: false }))
|
|
1109
|
+
const seed = recording.record
|
|
996
1110
|
let element
|
|
997
1111
|
try {
|
|
998
1112
|
element = await createDomqlElement(app, ctx)
|
|
999
1113
|
|
|
1000
1114
|
// 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
|
|
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.
|
|
1004
1119
|
//
|
|
1005
1120
|
// It used to sleep a flat 2000 ms with prefetch — measured in workerd on
|
|
1006
1121
|
// six published projects, `flush` was 2000 ms on every route, 40–90 % of
|
|
@@ -1019,8 +1134,7 @@ export const render = async (data, options = {}) => {
|
|
|
1019
1134
|
if (inFlightRequests() === 0) await sleep(Math.max(0, Math.min(50, flushCap - (Date.now() - flushStart))))
|
|
1020
1135
|
}
|
|
1021
1136
|
} finally {
|
|
1022
|
-
|
|
1023
|
-
if (_seed === seed) _seed = null
|
|
1137
|
+
recording.close()
|
|
1024
1138
|
}
|
|
1025
1139
|
const fetchSeed = seed && !seed.tainted ? seed.entries.slice() : []
|
|
1026
1140
|
|
|
@@ -1062,24 +1176,106 @@ export const render = async (data, options = {}) => {
|
|
|
1062
1176
|
|
|
1063
1177
|
let html = fixSvgContent(body.innerHTML)
|
|
1064
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
|
+
|
|
1065
1186
|
// 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
|
-
|
|
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()
|
|
1203
|
+
}
|
|
1204
|
+
}
|
|
1205
|
+
|
|
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
|
+
}
|
|
1074
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] || {}
|
|
1075
1249
|
|
|
1076
|
-
|
|
1077
|
-
|
|
1078
|
-
|
|
1079
|
-
|
|
1080
|
-
|
|
1250
|
+
const resolveLabelTemplates = (html, langMap) =>
|
|
1251
|
+
html.replace(LABEL_TEMPLATE, (match, key) => {
|
|
1252
|
+
const val = ssrResolve(langMap, key.trim())
|
|
1253
|
+
return val ?? match
|
|
1254
|
+
})
|
|
1081
1255
|
|
|
1082
|
-
|
|
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]
|
|
1083
1279
|
}
|
|
1084
1280
|
|
|
1085
1281
|
/**
|
|
@@ -1284,8 +1480,9 @@ export const replaceEmotionCSS = (html, newCSS) => {
|
|
|
1284
1480
|
* @returns {Promise<{ html: string, css: string, resetCss: string, fontLinks: string, metadata: object, brKeyCount: number }>}
|
|
1285
1481
|
*/
|
|
1286
1482
|
export const renderRoute = async (data, options = {}) => {
|
|
1287
|
-
// `prefetch: false`
|
|
1288
|
-
// — 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.
|
|
1289
1486
|
const { route = '/', pathname, prefetch = true } = options
|
|
1290
1487
|
|
|
1291
1488
|
// Use the full render pipeline which handles polyglot, prefetch, emotion, etc.
|
|
@@ -1296,39 +1493,16 @@ export const renderRoute = async (data, options = {}) => {
|
|
|
1296
1493
|
const ds = data.designSystem || {}
|
|
1297
1494
|
const globalCSS = result.globalCSS
|
|
1298
1495
|
|
|
1299
|
-
//
|
|
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.
|
|
1300
1500
|
let prefetchedState = null
|
|
1301
|
-
let activeLang = null
|
|
1302
1501
|
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
|
-
}
|
|
1502
|
+
const fetched = collectFetchedState(result.element)
|
|
1503
|
+
if (Object.keys(fetched).length) prefetchedState = fetched
|
|
1331
1504
|
} catch (e) { /* ignore */ }
|
|
1505
|
+
const activeLang = result.activeLang
|
|
1332
1506
|
|
|
1333
1507
|
return {
|
|
1334
1508
|
html: result.html,
|
|
@@ -1342,14 +1516,39 @@ export const renderRoute = async (data, options = {}) => {
|
|
|
1342
1516
|
metadata: result.metadata || extractMetadata(data, route),
|
|
1343
1517
|
brKeyCount: result.registry ? Object.keys(result.registry).length : 0,
|
|
1344
1518
|
brRegistry: result.brRegistry || {},
|
|
1345
|
-
// anonymous GET answers this render got (see `
|
|
1519
|
+
// anonymous GET answers this render got (see `SEED`) — for adoptionScripts
|
|
1346
1520
|
fetchSeed: result.fetchSeed || [],
|
|
1347
1521
|
ssrTranslations: result.ssrTranslations,
|
|
1348
1522
|
prefetchedState,
|
|
1349
|
-
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
|
|
1350
1527
|
}
|
|
1351
1528
|
}
|
|
1352
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
|
+
|
|
1353
1552
|
// ── Full page SSR ─────────────────────────────────────────────────────────────
|
|
1354
1553
|
|
|
1355
1554
|
/**
|
|
@@ -1366,12 +1565,20 @@ export const renderRoute = async (data, options = {}) => {
|
|
|
1366
1565
|
* @param {string} [options.themeColor] - theme-color meta
|
|
1367
1566
|
* @param {object} [options.isr] - ISR options with clientScript path
|
|
1368
1567
|
* @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
|
-
*
|
|
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.
|
|
1375
1582
|
* @param {string} [options.shell] - A built app document (e.g. dist/index.html)
|
|
1376
1583
|
* to render INTO instead of emitting a standalone document (see shell.js).
|
|
1377
1584
|
* The result then carries `shell: true`.
|
|
@@ -1381,13 +1588,18 @@ export const renderRoute = async (data, options = {}) => {
|
|
|
1381
1588
|
* @returns {Promise<{ html: string, route: string, brKeyCount: number, shell?: true }>}
|
|
1382
1589
|
*/
|
|
1383
1590
|
export const renderPage = async (data, route = '/', options = {}) => {
|
|
1384
|
-
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)
|
|
1385
1593
|
|
|
1386
1594
|
// Detect lang from project config, app metadata, or default
|
|
1387
1595
|
const htmlLang = lang || data.state?.lang || data.app?.metadata?.lang || 'en'
|
|
1388
1596
|
|
|
1389
|
-
// Use the full smbls pipeline for rendering
|
|
1390
|
-
|
|
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 })
|
|
1391
1603
|
if (!result) return null
|
|
1392
1604
|
|
|
1393
1605
|
const metadata = { ...result.metadata }
|
|
@@ -1431,9 +1643,11 @@ export const renderPage = async (data, route = '/', options = {}) => {
|
|
|
1431
1643
|
|
|
1432
1644
|
const brKeyCount = Object.keys(result.registry).length
|
|
1433
1645
|
|
|
1434
|
-
//
|
|
1435
|
-
//
|
|
1436
|
-
|
|
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 })
|
|
1437
1651
|
|
|
1438
1652
|
// ISR: include client SPA bundle for hydration + data fetching
|
|
1439
1653
|
let isrBody = ''
|
|
@@ -1447,9 +1661,9 @@ export const renderPage = async (data, route = '/', options = {}) => {
|
|
|
1447
1661
|
const prefix = depth > 0 ? '../'.repeat(depth) : './'
|
|
1448
1662
|
|
|
1449
1663
|
if (hydrate) {
|
|
1450
|
-
//
|
|
1451
|
-
//
|
|
1452
|
-
//
|
|
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).
|
|
1453
1667
|
//
|
|
1454
1668
|
// Seed client-side polyglot with SSR translations so that
|
|
1455
1669
|
// el.call('polyglot', key) resolves immediately during hydration
|
|
@@ -1467,20 +1681,20 @@ export const renderPage = async (data, route = '/', options = {}) => {
|
|
|
1467
1681
|
}
|
|
1468
1682
|
}
|
|
1469
1683
|
if (storageLangKey) {
|
|
1470
|
-
const defaultLang = polyglotCfg
|
|
1684
|
+
const defaultLang = polyglotFallbackLang(undefined, polyglotCfg)
|
|
1471
1685
|
seedEntries.push(`if(!localStorage.getItem(${JSON.stringify(storageLangKey)}))localStorage.setItem(${JSON.stringify(storageLangKey)},${JSON.stringify(defaultLang)})`)
|
|
1472
1686
|
}
|
|
1473
1687
|
if (seedEntries.length) {
|
|
1474
|
-
translationSeed = `<script>try{${seedEntries.join(';')}}catch(e){}</script>\n`
|
|
1688
|
+
translationSeed = `<script${nonceAttr}>try{${seedEntries.join(';')}}catch(e){}</script>\n`
|
|
1475
1689
|
}
|
|
1476
1690
|
}
|
|
1477
|
-
//
|
|
1478
|
-
|
|
1479
|
-
|
|
1480
|
-
<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>`
|
|
1481
1695
|
} else {
|
|
1482
1696
|
// Legacy swap mode: SPA creates new DOM, MutationObserver removes brender nodes
|
|
1483
|
-
isrBody = `<script type="module">
|
|
1697
|
+
isrBody = `<script type="module"${nonceAttr}>
|
|
1484
1698
|
{
|
|
1485
1699
|
const brEls = document.querySelectorAll('body > :not(script):not(style)')
|
|
1486
1700
|
const observer = new MutationObserver((mutations) => {
|
|
@@ -1497,42 +1711,56 @@ export const renderPage = async (data, route = '/', options = {}) => {
|
|
|
1497
1711
|
observer.observe(document.body, { childList: true })
|
|
1498
1712
|
}
|
|
1499
1713
|
</script>
|
|
1500
|
-
<script type="module" src="${prefix}${isr.clientScript}"></script>`
|
|
1714
|
+
<script type="module" src="${prefix}${isr.clientScript}"${nonceAttr}></script>`
|
|
1501
1715
|
}
|
|
1502
1716
|
}
|
|
1503
1717
|
|
|
1504
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.
|
|
1505
1720
|
const headConfig = { ...(data.config || {}) }
|
|
1506
1721
|
if (data.polyglot && !headConfig.polyglot) headConfig.polyglot = data.polyglot
|
|
1507
1722
|
const polyglotCfg = headConfig.polyglot
|
|
1508
1723
|
let resolvedHeadTags = headTags
|
|
1509
1724
|
if (polyglotCfg) {
|
|
1510
|
-
const defaultLang = polyglotCfg.defaultLang || 'en'
|
|
1511
1725
|
// Use SSR-fetched translations (from render result) merged with static translations
|
|
1512
|
-
const translations = {
|
|
1513
|
-
|
|
1514
|
-
...(
|
|
1726
|
+
const translations = { ...(polyglotCfg.translations || {}) }
|
|
1727
|
+
for (const lang in result.ssrTranslations || {}) {
|
|
1728
|
+
translations[lang] = { ...(translations[lang] || {}), ...result.ssrTranslations[lang] }
|
|
1515
1729
|
}
|
|
1516
|
-
|
|
1517
|
-
resolvedHeadTags = headTags.replace(/\{\{\s*([^|{}]+?)\s*\|\s*polyglot\s*\}\}/g, (match, key) => {
|
|
1518
|
-
const trimmed = key.trim()
|
|
1519
|
-
return langMap[trimmed] ?? match
|
|
1520
|
-
})
|
|
1730
|
+
resolvedHeadTags = resolveLabelTemplates(headTags, labelMapFor(translations, result.activeLang, polyglotCfg))
|
|
1521
1731
|
}
|
|
1522
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
|
+
|
|
1523
1746
|
// Build path: render INTO the app's own built document (shell.js) — the
|
|
1524
1747
|
// standalone document below carries no app code unless `isr` adds a
|
|
1525
1748
|
// client bundle, so writing it over a bundler's dist/index.html shipped
|
|
1526
1749
|
// pages that never booted.
|
|
1527
1750
|
if (typeof shell === 'string') {
|
|
1528
1751
|
const html = composeIntoShell(shell, {
|
|
1529
|
-
headTags: resolvedHeadTags
|
|
1752
|
+
headTags: `${resolvedHeadTags}\n${prerender.meta}`,
|
|
1530
1753
|
headEnd: [fontLinks, brenderStyle, emotionStyle].filter(Boolean).join('\n'),
|
|
1531
|
-
body:
|
|
1754
|
+
body: bodyHtml,
|
|
1532
1755
|
depth: Number.isInteger(options.depth) ? options.depth : 0,
|
|
1533
|
-
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
|
|
1534
1762
|
})
|
|
1535
|
-
return { html, route, brKeyCount, shell: true }
|
|
1763
|
+
return { html, route, brKeyCount, shell: true, diagnostics }
|
|
1536
1764
|
}
|
|
1537
1765
|
|
|
1538
1766
|
// (The empty line after the font links held a @font-face <style> that
|
|
@@ -1540,19 +1768,43 @@ export const renderPage = async (data, route = '/', options = {}) => {
|
|
|
1540
1768
|
const html = `<!DOCTYPE html>
|
|
1541
1769
|
<html lang="${htmlLang}">
|
|
1542
1770
|
<head>
|
|
1543
|
-
${resolvedHeadTags}
|
|
1771
|
+
${resolvedHeadTags}${isr && isr.clientScript && hydrate ? `\n${prerender.meta}` : ''}
|
|
1544
1772
|
${fontLinks}
|
|
1545
1773
|
|
|
1546
1774
|
${brenderStyle}
|
|
1547
1775
|
${emotionStyle}
|
|
1548
1776
|
</head>
|
|
1549
1777
|
<body>
|
|
1550
|
-
${
|
|
1778
|
+
${bodyHtml}
|
|
1551
1779
|
${isrBody}
|
|
1552
1780
|
</body>
|
|
1553
1781
|
</html>`
|
|
1554
1782
|
|
|
1555
|
-
return { html, route, brKeyCount }
|
|
1783
|
+
return { html, route, brKeyCount, diagnostics }
|
|
1784
|
+
}
|
|
1785
|
+
|
|
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
|
+
})
|
|
1556
1808
|
}
|
|
1557
1809
|
|
|
1558
1810
|
const generateFontLinks = (ds) => {
|
|
@@ -1581,3 +1833,38 @@ const generateFontLinks = (ds) => {
|
|
|
1581
1833
|
`<link href="https://fonts.googleapis.com/css2?${params}&display=swap" rel="stylesheet">`
|
|
1582
1834
|
].join('\n')
|
|
1583
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
|
+
}
|