@uniweb/runtime 1.4.3 → 1.4.5
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 +63 -22
- package/dist/ssr.js.map +1 -1
- package/package.json +2 -2
- package/src/load-page-data.js +11 -5
- package/src/page-icons.js +111 -38
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@uniweb/runtime",
|
|
3
|
-
"version": "1.4.
|
|
3
|
+
"version": "1.4.5",
|
|
4
4
|
"description": "Minimal runtime for loading Uniweb foundations",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"exports": {
|
|
@@ -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.15.
|
|
47
|
+
"@uniweb/build": "1.15.3"
|
|
48
48
|
},
|
|
49
49
|
"peerDependencies": {
|
|
50
50
|
"react": "^19.0.0",
|
package/src/load-page-data.js
CHANGED
|
@@ -28,7 +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 {
|
|
31
|
+
import { pageIcons, dataIcons, iconRequests } from './page-icons.js'
|
|
32
32
|
|
|
33
33
|
/**
|
|
34
34
|
* Fetch what one route's render will read.
|
|
@@ -54,9 +54,10 @@ import { loadPageIcons } from './page-icons.js'
|
|
|
54
54
|
* fresh, and the default (a cache per call) is what does that. A shared cache changes nothing
|
|
55
55
|
* about what is ASKED or returned — every request is still recorded, hit or miss, and whether a
|
|
56
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
|
|
58
|
-
*
|
|
59
|
-
*
|
|
57
|
+
* @param {boolean} [options.icons] - also ask for the icons the site names for the page, in its
|
|
58
|
+
* sections and in the records it fetched, and keep them on the graph for the render
|
|
59
|
+
* (`page-icons.js`). They are not entries of the list this returns. A caller with its own
|
|
60
|
+
* answer for icons says `false`; a static build says `true`, beside the site-wide prefetch.
|
|
60
61
|
* @returns {Promise<Array<{ config: Object, outcome: 'fetched'|'failed'|'skipped', data: *, meta?: Object, error?: string }>>}
|
|
61
62
|
* one entry per request the render will make, keyed downstream by `deriveCacheKey(config)`.
|
|
62
63
|
* ⛔ The page's data and nothing else: a host counts this list to decide how long to keep the
|
|
@@ -150,6 +151,7 @@ export async function loadPageData({ website, route, fetch, dev = false, locale
|
|
|
150
151
|
|
|
151
152
|
const store = website.entityStore
|
|
152
153
|
const blocks = blocksOf(page)
|
|
154
|
+
const askIcons = icons && iconCache ? iconRequests({ website, fetch, cache: iconCache }) : null
|
|
153
155
|
// ⭐ Every block's first request goes out in the same tick, which is what lets a question door
|
|
154
156
|
// answer a page in one POST. A step that depends on an answer takes a later one, and only its
|
|
155
157
|
// own key waits.
|
|
@@ -158,8 +160,12 @@ export async function loadPageData({ website, route, fetch, dev = false, locale
|
|
|
158
160
|
// page names itself from it and a URL naming no record is a 404.
|
|
159
161
|
store.fetchPageRecord(page, { dispatcher: recording }),
|
|
160
162
|
...blocks.map((block) => store.fetch(block, getComponentMeta(block.type), { dispatcher: recording })),
|
|
161
|
-
|
|
163
|
+
askIcons?.(pageIcons(blocks)),
|
|
162
164
|
])
|
|
165
|
+
// ⭐ A record names icons too (a service's `icon`), and only its answer says which: a second
|
|
166
|
+
// round, after the data, for what the first did not ask. A page whose records name none, or
|
|
167
|
+
// only icons the graph holds, waits for nothing more.
|
|
168
|
+
if (askIcons) await askIcons(dataIcons([...entries.values()].map((entry) => entry.data)))
|
|
163
169
|
|
|
164
170
|
return [...entries.values()]
|
|
165
171
|
}
|
package/src/page-icons.js
CHANGED
|
@@ -7,29 +7,41 @@
|
|
|
7
7
|
* icon from `uniweb.iconCache` (`getIconSync`, kit's `<Icon>`), so what a page shows has to be there
|
|
8
8
|
* before it starts, as with its data. Two halves:
|
|
9
9
|
*
|
|
10
|
-
* - The data step asks the host's transport for the icons the
|
|
11
|
-
* on the graph (`
|
|
10
|
+
* - The data step asks the host's transport for the icons the site names for the page, and keeps
|
|
11
|
+
* them on the graph (`iconRequests`). It asks twice at most: for what the page's sections name,
|
|
12
|
+
* beside their data requests, and then for what the records those requests brought name, since
|
|
13
|
+
* only their answers say.
|
|
12
14
|
* - The render door notes which icons it drew (`whileReadingIcons`), and the page carries exactly
|
|
13
15
|
* those in `__ICON_CACHE__`, which `setup.js` reads before the browser's first render. The browser
|
|
14
16
|
* renders over the prerendered page, so it has to draw what the page drew, or it would blank them.
|
|
15
17
|
*
|
|
18
|
+
* ⭐ WHAT A SITE NAMES — the icons this is for. A site has no build and ships no code, so an icon its
|
|
19
|
+
* author names is fetched: one in a section's content, its params (with its type's defaults), the data
|
|
20
|
+
* it holds (a tagged data block), or the records it shows (a service's `icon`). A foundation has a
|
|
21
|
+
* build, so an icon its own code draws is imported and drawn with nothing to fetch (`<Icon svg>`).
|
|
22
|
+
* One a foundation names by reference instead stays as it was, the placeholder and then the browser's
|
|
23
|
+
* fetch [Diego, 2026-11-03]. So does any icon that fails or arrives late, because a page without an
|
|
24
|
+
* icon is a page that still works.
|
|
25
|
+
*
|
|
26
|
+
* ⚖️ Outside content an icon is known by its value: a string that is exactly a reference (`lu-baby`,
|
|
27
|
+
* `lucide:baby`). Only a record's schema says which field holds an icon, and the runtime carries no
|
|
28
|
+
* schema. A value that is a reference and draws nothing (an amenity spelled `wi-fi`) costs one request
|
|
29
|
+
* within `MAX_ICON_REQUESTS`, and nothing on the page, which carries only what its render drew.
|
|
30
|
+
*
|
|
16
31
|
* ⛔ NOT AN ENTRY OF THE DATA STEP'S LIST. A host counts that list to decide how long to keep the
|
|
17
32
|
* page: an icon in it would shorten the life of every page holding one, and a failed one could end
|
|
18
33
|
* it. So nothing here is reported. A host carries nothing new and changes nothing.
|
|
19
34
|
*
|
|
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
35
|
* Renderer-free, as the data step's graph is (`tests/data-step-graph.test.js`), and not in the
|
|
25
36
|
* browser's bundle: nothing `setup.js` or `index.jsx` imports reaches it.
|
|
26
37
|
*/
|
|
27
38
|
import { DEFAULT_ICON_BASE, ICON_FAMILY_MAP, iconUrl } from '@uniweb/core/icon-corpus'
|
|
39
|
+
import { getComponentMeta } from './prepare-props.js'
|
|
28
40
|
|
|
29
|
-
/** How long a page waits for its icons. Past this it renders without the ones still on their way. */
|
|
41
|
+
/** How long a page waits for each round of its icons. Past this it renders without the ones still on their way. */
|
|
30
42
|
export const ICON_WAIT_MS = 1000
|
|
31
43
|
|
|
32
|
-
/** The most a page asks for in one step, so an icon gallery cannot spend a host's budget of requests. */
|
|
44
|
+
/** The most a page asks for in one step, both rounds together, so an icon gallery cannot spend a host's budget of requests. */
|
|
33
45
|
export const MAX_ICON_REQUESTS = 40
|
|
34
46
|
|
|
35
47
|
/** A reference a link carries as its `icon`: `lu-play` or `lu:play`, as kit's `<Icon>` reads one. */
|
|
@@ -43,6 +55,19 @@ function parseIconReference(ref) {
|
|
|
43
55
|
return ICON_FAMILY_MAP[library] ? { library, name: ref.slice(dash + 1) } : null
|
|
44
56
|
}
|
|
45
57
|
|
|
58
|
+
/** An icon's name as the corpus and each family's own site spell one: lowercase words and digits. */
|
|
59
|
+
const ICON_NAME = /^[a-z0-9]+(?:-[a-z0-9]+)*$/
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* A value that is exactly an icon reference, as a param or a record's field holds one, else null.
|
|
63
|
+
* ⛔ Exactly, not containing one: `Call lu-phone` or an address is not an icon.
|
|
64
|
+
*/
|
|
65
|
+
function iconValue(value) {
|
|
66
|
+
if (value.length > 64) return null
|
|
67
|
+
const ref = parseIconReference(value)
|
|
68
|
+
return ref && ICON_FAMILY_MAP[ref.library.toLowerCase()] && ICON_NAME.test(ref.name) ? ref : null
|
|
69
|
+
}
|
|
70
|
+
|
|
46
71
|
function visit(node, found) {
|
|
47
72
|
if (!node || typeof node !== 'object') return
|
|
48
73
|
if (node.type === 'image' && node.attrs?.role === 'icon') found.push(node.attrs)
|
|
@@ -53,11 +78,36 @@ function visit(node, found) {
|
|
|
53
78
|
if (Array.isArray(node.content)) for (const child of node.content) visit(child, found)
|
|
54
79
|
}
|
|
55
80
|
|
|
81
|
+
/** Every icon a value names, at any depth: a param, a tagged data block, a record and its rich text. */
|
|
82
|
+
function visitValue(value, found, seen = new WeakSet()) {
|
|
83
|
+
if (typeof value === 'string') {
|
|
84
|
+
const ref = iconValue(value)
|
|
85
|
+
if (ref) found.push(ref)
|
|
86
|
+
return
|
|
87
|
+
}
|
|
88
|
+
if (!value || typeof value !== 'object' || seen.has(value)) return
|
|
89
|
+
seen.add(value)
|
|
90
|
+
// A rich-text field holds an icon as content does.
|
|
91
|
+
if (value.type === 'image' && value.attrs?.role === 'icon') found.push(value.attrs)
|
|
92
|
+
for (const child of Array.isArray(value) ? value : Object.values(value)) visitValue(child, found, seen)
|
|
93
|
+
}
|
|
94
|
+
|
|
56
95
|
/**
|
|
57
|
-
*
|
|
58
|
-
*
|
|
59
|
-
|
|
60
|
-
|
|
96
|
+
* Each icon once, keyed as `<Icon>` asks for one, by the library and name as written, since that is
|
|
97
|
+
* what `getIconSync` looks up. The address uses the family's code and the corpus's spelling of the name.
|
|
98
|
+
*/
|
|
99
|
+
function addressed(named) {
|
|
100
|
+
const icons = new Map()
|
|
101
|
+
for (const { library, name } of named) {
|
|
102
|
+
const family = ICON_FAMILY_MAP[String(library).toLowerCase()]
|
|
103
|
+
if (family && name) icons.set(`${library}:${name}`, { family, name })
|
|
104
|
+
}
|
|
105
|
+
return icons
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* The icons the site names for these blocks, each once: in their content first, then by value in
|
|
110
|
+
* their params with their types' defaults, and in the data they hold.
|
|
61
111
|
*
|
|
62
112
|
* @param {Object[]} blocks
|
|
63
113
|
* @returns {Map<string, { family: string, name: string }>}
|
|
@@ -65,46 +115,69 @@ function visit(node, found) {
|
|
|
65
115
|
export function pageIcons(blocks) {
|
|
66
116
|
const named = []
|
|
67
117
|
for (const block of blocks) visit(block.rawContent?.doc ?? block.rawContent, named)
|
|
68
|
-
const
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
118
|
+
for (const block of blocks) {
|
|
119
|
+
visitValue(block.properties, named)
|
|
120
|
+
visitValue(getComponentMeta(block.type)?.defaults, named)
|
|
121
|
+
visitValue(block.heldData, named)
|
|
72
122
|
}
|
|
73
|
-
return
|
|
123
|
+
return addressed(named)
|
|
74
124
|
}
|
|
75
125
|
|
|
76
126
|
/**
|
|
77
|
-
*
|
|
78
|
-
*
|
|
127
|
+
* The icons these values name, each once: the records a page's data requests brought.
|
|
128
|
+
*
|
|
129
|
+
* @param {Array<*>} values
|
|
130
|
+
* @returns {Map<string, { family: string, name: string }>}
|
|
131
|
+
*/
|
|
132
|
+
export function dataIcons(values) {
|
|
133
|
+
const named = []
|
|
134
|
+
visitValue(values, named)
|
|
135
|
+
return addressed(named)
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* How one data step asks for its page's icons through the host's transport, into the graph's icon
|
|
140
|
+
* cache: each icon once over both rounds, and no more than `MAX_ICON_REQUESTS` in all. One a warm
|
|
141
|
+
* graph holds is not asked for again. A round resolves when its icons are all in, or at
|
|
142
|
+
* `ICON_WAIT_MS`; one still on its way is kept when it arrives.
|
|
79
143
|
*
|
|
80
144
|
* @param {Object} options
|
|
81
|
-
* @param {Object[]} options.blocks - the page's blocks (`blocksOf`)
|
|
82
145
|
* @param {Object} options.website - the Website, whose `config.icons` says where the icons are
|
|
83
146
|
* @param {Function} options.fetch - the transport
|
|
84
147
|
* @param {Map<string, string>} options.cache - the graph's `iconCache`
|
|
85
|
-
* @returns {Promise<void>}
|
|
148
|
+
* @returns {(icons: Map<string, { family: string, name: string }>) => Promise<void>} one round
|
|
86
149
|
*/
|
|
87
|
-
export
|
|
150
|
+
export function iconRequests({ website, fetch, cache }) {
|
|
88
151
|
const config = website.config?.icons
|
|
89
|
-
if (config?.cdn === false) return
|
|
90
152
|
const base = config?.base || DEFAULT_ICON_BASE
|
|
91
|
-
const
|
|
92
|
-
if (wanted.length === 0) return
|
|
153
|
+
const asked = new Set()
|
|
93
154
|
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
if (
|
|
100
|
-
|
|
101
|
-
|
|
155
|
+
return async function ask(icons) {
|
|
156
|
+
if (config?.cdn === false) return
|
|
157
|
+
const wanted = []
|
|
158
|
+
for (const [key, address] of icons) {
|
|
159
|
+
if (asked.size === MAX_ICON_REQUESTS) break
|
|
160
|
+
if (cache.has(key) || asked.has(key)) continue
|
|
161
|
+
asked.add(key)
|
|
162
|
+
wanted.push([key, address])
|
|
102
163
|
}
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
164
|
+
if (wanted.length === 0) return
|
|
165
|
+
|
|
166
|
+
const all = Promise.all(wanted.map(async ([key, { family, name }]) => {
|
|
167
|
+
try {
|
|
168
|
+
const response = await fetch(iconUrl(family, name, base), { method: 'GET' })
|
|
169
|
+
if (!response.ok) return
|
|
170
|
+
const svg = await response.text()
|
|
171
|
+
if (/<svg[\s>]/i.test(svg)) cache.set(key, svg)
|
|
172
|
+
} catch {
|
|
173
|
+
// The transport may refuse an address. The icon stays on the browser's path.
|
|
174
|
+
}
|
|
175
|
+
}))
|
|
176
|
+
let timer
|
|
177
|
+
const waited = new Promise((resolve) => { timer = setTimeout(resolve, ICON_WAIT_MS) })
|
|
178
|
+
await Promise.race([all, waited])
|
|
179
|
+
clearTimeout(timer)
|
|
180
|
+
}
|
|
108
181
|
}
|
|
109
182
|
|
|
110
183
|
/**
|