@uniweb/runtime 1.3.2 → 1.3.4
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/dist/ssr.js +85 -7
- package/dist/ssr.js.map +1 -1
- package/package.json +4 -4
- package/src/carry-animations.js +50 -0
- package/src/index.jsx +3 -0
- package/src/load-page-data.js +12 -3
- package/src/page-icons.js +136 -0
- package/src/page-renderer.js +5 -1
- package/src/setup.js +1 -38
- package/src/ssr-renderer.js +8 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@uniweb/runtime",
|
|
3
|
-
"version": "1.3.
|
|
3
|
+
"version": "1.3.4",
|
|
4
4
|
"description": "Minimal runtime for loading Uniweb foundations",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"exports": {
|
|
@@ -36,15 +36,15 @@
|
|
|
36
36
|
"node": ">=22.12"
|
|
37
37
|
},
|
|
38
38
|
"dependencies": {
|
|
39
|
-
"@uniweb/
|
|
40
|
-
"@uniweb/
|
|
39
|
+
"@uniweb/theming": "~1.2.1",
|
|
40
|
+
"@uniweb/core": "~1.5.1"
|
|
41
41
|
},
|
|
42
42
|
"devDependencies": {
|
|
43
43
|
"@vitejs/plugin-react": "^4.5.2",
|
|
44
44
|
"esbuild": "^0.21.0 || ^0.23.0 || ^0.24.0 || ^0.25.0 || ^0.27.0 || ^0.28.0",
|
|
45
45
|
"vite": "^7.3.1",
|
|
46
46
|
"vitest": "^4.1.7",
|
|
47
|
-
"@uniweb/build": "1.13.
|
|
47
|
+
"@uniweb/build": "1.13.5"
|
|
48
48
|
},
|
|
49
49
|
"peerDependencies": {
|
|
50
50
|
"react": "^19.0.0",
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A page's CSS animations carry on across the browser's first render.
|
|
3
|
+
*
|
|
4
|
+
* ⭐ The browser renders over a prerendered page rather than hydrating it (`createRoot`, `index.jsx`),
|
|
5
|
+
* and a CSS animation's clock belongs to its element. So the commit that replaces the page restarts
|
|
6
|
+
* every animation in it, and a page's first commit usually lands while its entrance is still running:
|
|
7
|
+
* a hero already on screen would drop back to invisible and play again. `uniweb dev` never prerenders,
|
|
8
|
+
* so a foundation's author never sees it.
|
|
9
|
+
*
|
|
10
|
+
* Each animation of the new page takes as its start the moment the old page was first painted, which
|
|
11
|
+
* is when its twin started, so it goes on from where its twin was. The observer fires after the commit
|
|
12
|
+
* and before the browser paints, so no frame shows a restart. It disconnects at that first commit, so
|
|
13
|
+
* a later navigation is left as it was.
|
|
14
|
+
*
|
|
15
|
+
* ⭐ The time of first paint rather than the old animations' own start times, read before the commit:
|
|
16
|
+
* an animation that has ended with no fill is gone from `getAnimations()`, and its twin would play
|
|
17
|
+
* again.
|
|
18
|
+
*
|
|
19
|
+
* ⛔ Only a running animation on the document's timeline:
|
|
20
|
+
* - a paused one is waiting for something, like a reveal that starts when its section scrolls into
|
|
21
|
+
* view, and setting a start time would start it;
|
|
22
|
+
* - a scroll-driven one has a timeline of its own, whose time is no moment on this clock.
|
|
23
|
+
*
|
|
24
|
+
* It reaches CSS animations alone. One a component starts from JavaScript when it mounts never ran on
|
|
25
|
+
* the prerendered page: it starts at boot, over whatever that page has shown by then.
|
|
26
|
+
*
|
|
27
|
+
* Nothing to carry: an empty root (`uniweb dev`, a page served without a prerender), and a page not
|
|
28
|
+
* painted yet, whose first render is the first thing a visitor sees. That can include a page painted
|
|
29
|
+
* a frame ago, which the browser has not reported yet: its animations have hardly begun.
|
|
30
|
+
*
|
|
31
|
+
* @param {Element} root - The element React is about to render into.
|
|
32
|
+
*/
|
|
33
|
+
export function carryAnimations(root) {
|
|
34
|
+
if (!root.firstChild || !document.getAnimations) return
|
|
35
|
+
new MutationObserver((_, watch) => {
|
|
36
|
+
watch.disconnect()
|
|
37
|
+
const painted = performance.getEntriesByName('first-contentful-paint')[0]?.startTime
|
|
38
|
+
if (painted == null) return
|
|
39
|
+
for (const animation of document.getAnimations()) {
|
|
40
|
+
if (
|
|
41
|
+
animation.animationName &&
|
|
42
|
+
animation.playState === 'running' &&
|
|
43
|
+
animation.timeline === document.timeline &&
|
|
44
|
+
root.contains(animation.effect?.target)
|
|
45
|
+
) {
|
|
46
|
+
animation.startTime = painted
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
}).observe(root, { childList: true })
|
|
50
|
+
}
|
package/src/index.jsx
CHANGED
|
@@ -12,6 +12,7 @@ import { initUniweb, decodeData } from './setup.js'
|
|
|
12
12
|
import { wireTracker } from './wire-foundation.js'
|
|
13
13
|
import { loadFoundation, loadExtensions } from './foundation-loader.js'
|
|
14
14
|
import { initAppearance } from './appearance.js'
|
|
15
|
+
import { carryAnimations } from './carry-animations.js'
|
|
15
16
|
import RuntimeProvider from './RuntimeProvider.jsx'
|
|
16
17
|
|
|
17
18
|
/**
|
|
@@ -146,6 +147,8 @@ async function initRuntime(foundationSource, options = {}) {
|
|
|
146
147
|
|
|
147
148
|
const app = <RuntimeProvider basename={routerBasename} development={development} />
|
|
148
149
|
|
|
150
|
+
// Armed before the render it watches for: the one that replaces a prerendered page.
|
|
151
|
+
carryAnimations(container)
|
|
149
152
|
createRoot(container).render(app)
|
|
150
153
|
|
|
151
154
|
// Log success
|
package/src/load-page-data.js
CHANGED
|
@@ -28,6 +28,7 @@ import { getComponentMeta } from './prepare-props.js'
|
|
|
28
28
|
import { createDefaultFetcher } from './default-fetcher.js'
|
|
29
29
|
import { requireTransport } from './require-transport.js'
|
|
30
30
|
import { blocksOf } from './page-blocks.js'
|
|
31
|
+
import { loadPageIcons } from './page-icons.js'
|
|
31
32
|
|
|
32
33
|
/**
|
|
33
34
|
* Fetch what one route's render will read.
|
|
@@ -53,10 +54,15 @@ import { blocksOf } from './page-blocks.js'
|
|
|
53
54
|
* fresh, and the default (a cache per call) is what does that. A shared cache changes nothing
|
|
54
55
|
* about what is ASKED or returned — every request is still recorded, hit or miss, and whether a
|
|
55
56
|
* record is held whole already is answered from this call's own requests, never the cache's.
|
|
57
|
+
* @param {boolean} [options.icons] - also ask for the icons the page's content names, and keep them on
|
|
58
|
+
* the graph for the render (`page-icons.js`). They are not entries of the list this returns. A
|
|
59
|
+
* caller that has its own answer for icons, as a static build does, says `false`.
|
|
56
60
|
* @returns {Promise<Array<{ config: Object, outcome: 'fetched'|'failed'|'skipped', data: *, meta?: Object, error?: string }>>}
|
|
57
|
-
* one entry per request the render will make, keyed downstream by `deriveCacheKey(config)
|
|
61
|
+
* one entry per request the render will make, keyed downstream by `deriveCacheKey(config)`.
|
|
62
|
+
* ⛔ The page's data and nothing else: a host counts this list to decide how long to keep the
|
|
63
|
+
* page, so one more entry shortens its life, and one that fails can end it.
|
|
58
64
|
*/
|
|
59
|
-
export async function loadPageData({ website, route, fetch, dev = false, locale = null, prerender = 'always', cache = null }) {
|
|
65
|
+
export async function loadPageData({ website, route, fetch, dev = false, locale = null, prerender = 'always', cache = null, icons = true }) {
|
|
60
66
|
if (prerender !== 'author' && prerender !== 'always') {
|
|
61
67
|
throw new Error(`loadPageData: prerender must be 'author' or 'always', got ${JSON.stringify(prerender)}`)
|
|
62
68
|
}
|
|
@@ -76,6 +82,7 @@ export async function loadPageData({ website, route, fetch, dev = false, locale
|
|
|
76
82
|
// through the global, and a host holding several graphs in one isolate has it naming the newest
|
|
77
83
|
// (`Website#makeCurrent`).
|
|
78
84
|
website.makeCurrent()
|
|
85
|
+
const iconCache = globalThis.uniweb?.iconCache
|
|
79
86
|
const named = typeof route === 'string' ? resolveRequestedPage(website, route) : route
|
|
80
87
|
// ⭐ A route that names no page, or a page served from elsewhere (`resolveRequestedPage`), renders
|
|
81
88
|
// the site's not-found page (`createPageRenderer`), so it is that page's data the render reads.
|
|
@@ -142,6 +149,7 @@ export async function loadPageData({ website, route, fetch, dev = false, locale
|
|
|
142
149
|
}
|
|
143
150
|
|
|
144
151
|
const store = website.entityStore
|
|
152
|
+
const blocks = blocksOf(page)
|
|
145
153
|
// ⭐ Every block's first request goes out in the same tick, which is what lets a question door
|
|
146
154
|
// answer a page in one POST. A step that depends on an answer takes a later one, and only its
|
|
147
155
|
// own key waits.
|
|
@@ -149,7 +157,8 @@ export async function loadPageData({ website, route, fetch, dev = false, locale
|
|
|
149
157
|
// The page's own record: asked whether or not a section declares the route key, because the
|
|
150
158
|
// page names itself from it and a URL naming no record is a 404.
|
|
151
159
|
store.fetchPageRecord(page, { dispatcher: recording }),
|
|
152
|
-
...
|
|
160
|
+
...blocks.map((block) => store.fetch(block, getComponentMeta(block.type), { dispatcher: recording })),
|
|
161
|
+
icons && iconCache && loadPageIcons({ blocks, website, fetch, cache: iconCache }),
|
|
153
162
|
])
|
|
154
163
|
|
|
155
164
|
return [...entries.values()]
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A PAGE'S ICONS, IN ITS PRERENDER.
|
|
3
|
+
*
|
|
4
|
+
* An icon is a file. On a page a host renders, nothing had fetched it by the time the page was
|
|
5
|
+
* written, so the page held a blank box in its place until the browser had booted and fetched it:
|
|
6
|
+
* about a second, on a fast connection (measured 2026-10-30). The render is synchronous and reads an
|
|
7
|
+
* icon from `uniweb.iconCache` (`getIconSync`, kit's `<Icon>`), so what a page shows has to be there
|
|
8
|
+
* before it starts, as with its data. Two halves:
|
|
9
|
+
*
|
|
10
|
+
* - The data step asks the host's transport for the icons the page's content names, and keeps them
|
|
11
|
+
* on the graph (`loadPageIcons`).
|
|
12
|
+
* - The render door notes which icons it drew (`whileReadingIcons`), and the page carries exactly
|
|
13
|
+
* those in `__ICON_CACHE__`, which `setup.js` reads before the browser's first render. The browser
|
|
14
|
+
* renders over the prerendered page, so it has to draw what the page drew, or it would blank them.
|
|
15
|
+
*
|
|
16
|
+
* ⛔ NOT AN ENTRY OF THE DATA STEP'S LIST. A host counts that list to decide how long to keep the
|
|
17
|
+
* page: an icon in it would shorten the life of every page holding one, and a failed one could end
|
|
18
|
+
* it. So nothing here is reported. A host carries nothing new and changes nothing.
|
|
19
|
+
*
|
|
20
|
+
* ⚖️ What the walk cannot find: an icon a foundation's own code names, or one in a fetched record.
|
|
21
|
+
* Those stay as they were, the placeholder and then the browser's fetch. So does any icon that fails
|
|
22
|
+
* or arrives late, because a page without an icon is a page that still works.
|
|
23
|
+
*
|
|
24
|
+
* Renderer-free, as the data step's graph is (`tests/data-step-graph.test.js`), and not in the
|
|
25
|
+
* browser's bundle: nothing `setup.js` or `index.jsx` imports reaches it.
|
|
26
|
+
*/
|
|
27
|
+
import { DEFAULT_ICON_BASE, ICON_FAMILY_MAP, iconUrl } from '@uniweb/core/icon-corpus'
|
|
28
|
+
|
|
29
|
+
/** How long a page waits for its icons. Past this it renders without the ones still on their way. */
|
|
30
|
+
export const ICON_WAIT_MS = 1000
|
|
31
|
+
|
|
32
|
+
/** The most a page asks for in one step, so an icon gallery cannot spend a host's budget of requests. */
|
|
33
|
+
export const MAX_ICON_REQUESTS = 40
|
|
34
|
+
|
|
35
|
+
/** A reference a link carries as its `icon`: `lu-play` or `lu:play`, as kit's `<Icon>` reads one. */
|
|
36
|
+
function parseIconReference(ref) {
|
|
37
|
+
if (typeof ref !== 'string') return null
|
|
38
|
+
const colon = ref.indexOf(':')
|
|
39
|
+
if (colon > 0) return { library: ref.slice(0, colon), name: ref.slice(colon + 1) }
|
|
40
|
+
const dash = ref.indexOf('-')
|
|
41
|
+
if (dash < 2 || dash > 3) return null
|
|
42
|
+
const library = ref.slice(0, dash)
|
|
43
|
+
return ICON_FAMILY_MAP[library] ? { library, name: ref.slice(dash + 1) } : null
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function visit(node, found) {
|
|
47
|
+
if (!node || typeof node !== 'object') return
|
|
48
|
+
if (node.type === 'image' && node.attrs?.role === 'icon') found.push(node.attrs)
|
|
49
|
+
for (const mark of node.marks || []) {
|
|
50
|
+
const ref = parseIconReference(mark?.attrs?.icon)
|
|
51
|
+
if (ref) found.push(ref)
|
|
52
|
+
}
|
|
53
|
+
if (Array.isArray(node.content)) for (const child of node.content) visit(child, found)
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* The icons the content of these blocks names, each once.
|
|
58
|
+
*
|
|
59
|
+
* Keyed as `<Icon>` asks for one, by the library and name as written, since that is what
|
|
60
|
+
* `getIconSync` looks up. The address uses the family's code and the corpus's spelling of the name.
|
|
61
|
+
*
|
|
62
|
+
* @param {Object[]} blocks
|
|
63
|
+
* @returns {Map<string, { family: string, name: string }>}
|
|
64
|
+
*/
|
|
65
|
+
export function pageIcons(blocks) {
|
|
66
|
+
const named = []
|
|
67
|
+
for (const block of blocks) visit(block.rawContent?.doc ?? block.rawContent, named)
|
|
68
|
+
const icons = new Map()
|
|
69
|
+
for (const { library, name } of named) {
|
|
70
|
+
const family = ICON_FAMILY_MAP[String(library).toLowerCase()]
|
|
71
|
+
if (family && name) icons.set(`${library}:${name}`, { family, name })
|
|
72
|
+
}
|
|
73
|
+
return icons
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Fetch the icons these blocks name through the host's transport, into the graph's icon cache. One a
|
|
78
|
+
* warm graph holds is not asked for again. Resolves when they are all in, or at `ICON_WAIT_MS`.
|
|
79
|
+
*
|
|
80
|
+
* @param {Object} options
|
|
81
|
+
* @param {Object[]} options.blocks - the page's blocks (`blocksOf`)
|
|
82
|
+
* @param {Object} options.website - the Website, whose `config.icons` says where the icons are
|
|
83
|
+
* @param {Function} options.fetch - the transport
|
|
84
|
+
* @param {Map<string, string>} options.cache - the graph's `iconCache`
|
|
85
|
+
* @returns {Promise<void>}
|
|
86
|
+
*/
|
|
87
|
+
export async function loadPageIcons({ blocks, website, fetch, cache }) {
|
|
88
|
+
const config = website.config?.icons
|
|
89
|
+
if (config?.cdn === false) return
|
|
90
|
+
const base = config?.base || DEFAULT_ICON_BASE
|
|
91
|
+
const wanted = [...pageIcons(blocks)].filter(([key]) => !cache.has(key)).slice(0, MAX_ICON_REQUESTS)
|
|
92
|
+
if (wanted.length === 0) return
|
|
93
|
+
|
|
94
|
+
const all = Promise.all(wanted.map(async ([key, { family, name }]) => {
|
|
95
|
+
try {
|
|
96
|
+
const response = await fetch(iconUrl(family, name, base), { method: 'GET' })
|
|
97
|
+
if (!response.ok) return
|
|
98
|
+
const svg = await response.text()
|
|
99
|
+
if (/<svg[\s>]/i.test(svg)) cache.set(key, svg)
|
|
100
|
+
} catch {
|
|
101
|
+
// The transport may refuse an address. The icon stays on the browser's path.
|
|
102
|
+
}
|
|
103
|
+
}))
|
|
104
|
+
let timer
|
|
105
|
+
const waited = new Promise((resolve) => { timer = setTimeout(resolve, ICON_WAIT_MS) })
|
|
106
|
+
await Promise.race([all, waited])
|
|
107
|
+
clearTimeout(timer)
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Run a render and collect the icons it drew, so the page can carry exactly those. On a graph reused
|
|
112
|
+
* across pages that can include one this page's walk did not find, which is why this reads the
|
|
113
|
+
* render rather than the walk: whatever the HTML shows, the browser's first render can show too.
|
|
114
|
+
*
|
|
115
|
+
* The render is synchronous, so no other request can read through this while it runs.
|
|
116
|
+
*
|
|
117
|
+
* @param {Object|undefined} uniweb - what `globalThis.uniweb` names for this render
|
|
118
|
+
* @param {Map<string, string>} read - filled with each icon drawn
|
|
119
|
+
* @param {Function} draw - the render
|
|
120
|
+
* @returns {*} what `draw` returned
|
|
121
|
+
*/
|
|
122
|
+
export function whileReadingIcons(uniweb, read, draw) {
|
|
123
|
+
const cache = uniweb?.iconCache
|
|
124
|
+
if (!cache) return draw()
|
|
125
|
+
const { get } = cache
|
|
126
|
+
cache.get = (key) => {
|
|
127
|
+
const svg = get.call(cache, key)
|
|
128
|
+
if (svg) read.set(key, svg)
|
|
129
|
+
return svg
|
|
130
|
+
}
|
|
131
|
+
try {
|
|
132
|
+
return draw()
|
|
133
|
+
} finally {
|
|
134
|
+
delete cache.get
|
|
135
|
+
}
|
|
136
|
+
}
|
package/src/page-renderer.js
CHANGED
|
@@ -39,6 +39,7 @@ import { hydrateDataStore } from './wire-foundation.js'
|
|
|
39
39
|
import { loadPageData } from './load-page-data.js'
|
|
40
40
|
import { requireTransport } from './require-transport.js'
|
|
41
41
|
import { restoreStartPage } from './start-page.js'
|
|
42
|
+
import { whileReadingIcons } from './page-icons.js'
|
|
42
43
|
|
|
43
44
|
/**
|
|
44
45
|
* A renderer bound to one initialized Website and one shell.
|
|
@@ -158,9 +159,11 @@ export function createPageRenderer({ website, shell }) {
|
|
|
158
159
|
// request's page.
|
|
159
160
|
restoreStartPage(website)
|
|
160
161
|
if (typeof target === 'string') website.setActivePage(target)
|
|
162
|
+
// ⭐ The page carries the icons it drew, so the browser's first render draws them too.
|
|
163
|
+
const icons = new Map()
|
|
161
164
|
let result
|
|
162
165
|
try {
|
|
163
|
-
result = renderPage(page, website)
|
|
166
|
+
result = whileReadingIcons(globalThis.uniweb, icons, () => renderPage(page, website))
|
|
164
167
|
} catch (err) {
|
|
165
168
|
// `renderPage` handles its own errors, but a foundation can throw from
|
|
166
169
|
// module scope in ways it does not catch. Classify rather than propagate,
|
|
@@ -179,6 +182,7 @@ export function createPageRenderer({ website, shell }) {
|
|
|
179
182
|
const html = injectPageContent(shell, result.renderedContent, page, {
|
|
180
183
|
...inject,
|
|
181
184
|
sectionOverrideCSS: result.sectionOverrideCSS,
|
|
185
|
+
icons,
|
|
182
186
|
})
|
|
183
187
|
return { outcome: missing ? 'notFound' : 'rendered', html, page, error: null }
|
|
184
188
|
}
|
package/src/setup.js
CHANGED
|
@@ -19,7 +19,7 @@
|
|
|
19
19
|
|
|
20
20
|
import React from 'react'
|
|
21
21
|
import { createUniweb } from '@uniweb/core'
|
|
22
|
-
import { DEFAULT_ICON_BASE, iconUrl } from '@uniweb/core/icon-corpus'
|
|
22
|
+
import { DEFAULT_ICON_BASE, ICON_FAMILY_MAP, iconUrl } from '@uniweb/core/icon-corpus'
|
|
23
23
|
import { createDefaultFetcher } from './default-fetcher.js'
|
|
24
24
|
import {
|
|
25
25
|
Link as RouterLink,
|
|
@@ -100,43 +100,6 @@ const DEFAULT_ROUTING_COMPONENTS = {
|
|
|
100
100
|
|
|
101
101
|
// ─── Icon resolution ────────────────────────────────────────────────────────
|
|
102
102
|
|
|
103
|
-
const ICON_FAMILY_MAP = {
|
|
104
|
-
lucide: 'lu',
|
|
105
|
-
heroicons: 'hi',
|
|
106
|
-
heroicons2: 'hi2',
|
|
107
|
-
phosphor: 'pi',
|
|
108
|
-
tabler: 'tb',
|
|
109
|
-
feather: 'fi',
|
|
110
|
-
fa: 'fa',
|
|
111
|
-
fa6: 'fa6',
|
|
112
|
-
bootstrap: 'bs',
|
|
113
|
-
'material-design': 'md',
|
|
114
|
-
'ant-design': 'ai',
|
|
115
|
-
remix: 'ri',
|
|
116
|
-
'simple-icons': 'si',
|
|
117
|
-
ionicons: 'io5',
|
|
118
|
-
boxicons: 'bi',
|
|
119
|
-
vscode: 'vsc',
|
|
120
|
-
weather: 'wi',
|
|
121
|
-
game: 'gi',
|
|
122
|
-
lu: 'lu',
|
|
123
|
-
hi: 'hi',
|
|
124
|
-
hi2: 'hi2',
|
|
125
|
-
pi: 'pi',
|
|
126
|
-
tb: 'tb',
|
|
127
|
-
fi: 'fi',
|
|
128
|
-
bs: 'bs',
|
|
129
|
-
md: 'md',
|
|
130
|
-
ai: 'ai',
|
|
131
|
-
ri: 'ri',
|
|
132
|
-
io5: 'io5',
|
|
133
|
-
bi: 'bi',
|
|
134
|
-
si: 'si',
|
|
135
|
-
vsc: 'vsc',
|
|
136
|
-
wi: 'wi',
|
|
137
|
-
gi: 'gi',
|
|
138
|
-
}
|
|
139
|
-
|
|
140
103
|
/**
|
|
141
104
|
* Create CDN-based icon resolver.
|
|
142
105
|
*
|
package/src/ssr-renderer.js
CHANGED
|
@@ -39,6 +39,7 @@ import { renderAppearanceBootScript } from './appearance.js'
|
|
|
39
39
|
import { documentTitle } from './document-title.js'
|
|
40
40
|
import { recordStartPage } from './start-page.js'
|
|
41
41
|
import { escapeHtml } from '@uniweb/core/page-shell'
|
|
42
|
+
import { jsonForScript } from '@uniweb/core/data-script'
|
|
42
43
|
|
|
43
44
|
// Re-export L2 helpers so the public `@uniweb/runtime/ssr` surface
|
|
44
45
|
// carries everything an SSR consumer needs from one entry point.
|
|
@@ -669,6 +670,13 @@ export function injectPageContent(html, renderedContent, page, options = {}) {
|
|
|
669
670
|
result = result.replace('</head>', `${overrideStyle}\n</head>`)
|
|
670
671
|
}
|
|
671
672
|
|
|
673
|
+
// The icons this page drew, which the browser reads before its first render (`setup.js`), so it
|
|
674
|
+
// draws them too. A replacer function, since `$` in a replacement string is not literal.
|
|
675
|
+
if (options.icons?.size && !result.includes('id="__ICON_CACHE__"')) {
|
|
676
|
+
const iconScript = `<script id="__ICON_CACHE__" type="application/json">${jsonForScript(Object.fromEntries(options.icons))}</script>`
|
|
677
|
+
result = result.replace('</head>', () => ` ${iconScript}\n</head>`)
|
|
678
|
+
}
|
|
679
|
+
|
|
672
680
|
// Replace the empty root div with pre-rendered content
|
|
673
681
|
result = result.replace(
|
|
674
682
|
/<div id="root">[\s\S]*?<\/div>/,
|