@uniweb/runtime 1.4.3 → 1.4.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@uniweb/runtime",
3
- "version": "1.4.3",
3
+ "version": "1.4.4",
4
4
  "description": "Minimal runtime for loading Uniweb foundations",
5
5
  "type": "module",
6
6
  "exports": {
@@ -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 { loadPageIcons } from './page-icons.js'
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 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`.
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 that has its own
60
+ * answer for icons, as a static build does, says `false`.
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
- icons && iconCache && loadPageIcons({ blocks, website, fetch, cache: iconCache }),
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 page's content names, and keeps them
11
- * on the graph (`loadPageIcons`).
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
- * 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.
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 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 })
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 icons
123
+ return addressed(named)
74
124
  }
75
125
 
76
126
  /**
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`.
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 async function loadPageIcons({ blocks, website, fetch, cache }) {
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 wanted = [...pageIcons(blocks)].filter(([key]) => !cache.has(key)).slice(0, MAX_ICON_REQUESTS)
92
- if (wanted.length === 0) return
153
+ const asked = new Set()
93
154
 
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.
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
- let timer
105
- const waited = new Promise((resolve) => { timer = setTimeout(resolve, ICON_WAIT_MS) })
106
- await Promise.race([all, waited])
107
- clearTimeout(timer)
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
  /**