@uniweb/runtime 1.3.2 → 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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@uniweb/runtime",
3
- "version": "1.3.2",
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.0",
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.0"
47
+ "@uniweb/build": "1.13.1"
48
48
  },
49
49
  "peerDependencies": {
50
50
  "react": "^19.0.0",
@@ -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
- ...blocksOf(page).map((block) => store.fetch(block, getComponentMeta(block.type), { dispatcher: recording })),
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
+ }
@@ -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
  *
@@ -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>/,