@uniweb/runtime 1.3.1 → 1.3.3
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 +86 -8
- package/dist/ssr.js.map +1 -1
- package/package.json +3 -3
- 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 +5 -42
- package/src/ssr-renderer.js +9 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@uniweb/runtime",
|
|
3
|
-
"version": "1.3.
|
|
3
|
+
"version": "1.3.3",
|
|
4
4
|
"description": "Minimal runtime for loading Uniweb foundations",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"exports": {
|
|
@@ -36,7 +36,7 @@
|
|
|
36
36
|
"node": ">=22.12"
|
|
37
37
|
},
|
|
38
38
|
"dependencies": {
|
|
39
|
-
"@uniweb/core": "~1.5.
|
|
39
|
+
"@uniweb/core": "~1.5.1",
|
|
40
40
|
"@uniweb/theming": "~1.2.1"
|
|
41
41
|
},
|
|
42
42
|
"devDependencies": {
|
|
@@ -44,7 +44,7 @@
|
|
|
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.1"
|
|
48
48
|
},
|
|
49
49
|
"peerDependencies": {
|
|
50
50
|
"react": "^19.0.0",
|
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
|
*
|
|
@@ -144,7 +107,7 @@ const ICON_FAMILY_MAP = {
|
|
|
144
107
|
* build's icon MANIFEST, which sits at `content.icons`. A payload carries both
|
|
145
108
|
* under that name and they are unrelated shapes:
|
|
146
109
|
*
|
|
147
|
-
* content.config.icons {
|
|
110
|
+
* content.config.icons { base, cdn } ← this
|
|
148
111
|
* content.icons { used, families, count } ← the manifest
|
|
149
112
|
*
|
|
150
113
|
* Exported for tests only; `index.jsx` names its imports, so this is not part
|
|
@@ -154,7 +117,7 @@ const ICON_FAMILY_MAP = {
|
|
|
154
117
|
* @returns {Function} Resolver: (library, name) => Promise<string|null>
|
|
155
118
|
*/
|
|
156
119
|
export function createIconResolver(iconConfig = {}) {
|
|
157
|
-
const CDN_BASE = iconConfig.
|
|
120
|
+
const CDN_BASE = iconConfig.base || DEFAULT_ICON_BASE
|
|
158
121
|
const useCdn = iconConfig.cdn !== false
|
|
159
122
|
const cache = new Map()
|
|
160
123
|
|
|
@@ -282,8 +245,8 @@ export function initUniweb({ content, foundation, extensions = [], routingCompon
|
|
|
282
245
|
uniweb.routingComponents = routingComponents
|
|
283
246
|
uniweb.localeSwitch = localeSwitch
|
|
284
247
|
// `config.icons`, NOT `content.icons` — the latter is the build's manifest
|
|
285
|
-
// (`{used, families, …}`), which carries no `
|
|
286
|
-
// pinned every browser fetch to the built-in default and made `icons.
|
|
248
|
+
// (`{used, families, …}`), which carries no `base`, so passing it here
|
|
249
|
+
// pinned every browser fetch to the built-in default and made `icons.base`
|
|
287
250
|
// / `icons.cdn: false` inert. ssr-renderer.js reads both keys correctly
|
|
288
251
|
// (manifest for prefetch, config for the base), so the two lanes disagreed:
|
|
289
252
|
// honored at prerender, ignored in the browser.
|
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.
|
|
@@ -442,7 +443,7 @@ export async function prefetchIcons(siteContent, uniweb, onProgress = () => {})
|
|
|
442
443
|
const icons = siteContent.icons?.used || []
|
|
443
444
|
if (icons.length === 0) return
|
|
444
445
|
|
|
445
|
-
const cdnBase = siteContent.config?.icons?.
|
|
446
|
+
const cdnBase = siteContent.config?.icons?.base || DEFAULT_ICON_BASE
|
|
446
447
|
|
|
447
448
|
onProgress(`Fetching ${icons.length} icons for SSR...`)
|
|
448
449
|
|
|
@@ -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>/,
|