@uniweb/runtime 0.17.0 → 0.17.2
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 +51 -39
- package/dist/ssr.js.map +1 -1
- package/package.json +3 -3
- package/src/background-shared.js +72 -0
- package/src/collect-records.js +48 -15
- package/src/components/Background.jsx +4 -39
- package/src/default-fetcher.js +50 -12
- package/src/isolate-api.js +1 -1
- package/src/ssr-renderer.js +2 -32
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@uniweb/runtime",
|
|
3
|
-
"version": "0.17.
|
|
3
|
+
"version": "0.17.2",
|
|
4
4
|
"description": "Minimal runtime for loading Uniweb foundations",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"exports": {
|
|
@@ -36,7 +36,7 @@
|
|
|
36
36
|
"node": ">=20.19"
|
|
37
37
|
},
|
|
38
38
|
"dependencies": {
|
|
39
|
-
"@uniweb/core": "^0.
|
|
39
|
+
"@uniweb/core": "^0.23.0",
|
|
40
40
|
"@uniweb/theming": "^0.1.15"
|
|
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",
|
|
45
45
|
"vite": "^7.3.1",
|
|
46
46
|
"vitest": "^4.1.7",
|
|
47
|
-
"@uniweb/build": "0.
|
|
47
|
+
"@uniweb/build": "0.41.0"
|
|
48
48
|
},
|
|
49
49
|
"peerDependencies": {
|
|
50
50
|
"react": "^19.0.0",
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The two helpers the background renderers share — one home, because they are
|
|
3
|
+
* not rendering.
|
|
4
|
+
*
|
|
5
|
+
* ## Why this module exists
|
|
6
|
+
*
|
|
7
|
+
* Backgrounds are an L3 pair by design: `components/Background.jsx` renders
|
|
8
|
+
* with hooks and DOM, `ssr-renderer.js` renders with `React.createElement`
|
|
9
|
+
* only, and the runtime-layer rule (framework `CLAUDE.md` gotcha #2) says that
|
|
10
|
+
* pair is twinned on purpose. ⛔ **What it also says is that only the RENDERING
|
|
11
|
+
* is twinned — "pure data → L1, one home" — and two pure helpers had been
|
|
12
|
+
* dragged across the seam with it**: a base-path joiner and a colour-opacity
|
|
13
|
+
* converter, byte-identical in both files.
|
|
14
|
+
*
|
|
15
|
+
* ⚠️ **And the copies had already drifted from the rule they were copying.**
|
|
16
|
+
* The base-path joiner is `@uniweb/core`'s `applyBasePath`, whose own docblock
|
|
17
|
+
* says it moved down to core *"rather than grow a second copy (the failure
|
|
18
|
+
* `@uniweb/core/route-match` was created to end, after one matcher was
|
|
19
|
+
* implemented twice and the copies diverged)"*. Two copies grew anyway, in the
|
|
20
|
+
* runtime, and **both were missing its protocol-relative guard** — so an author
|
|
21
|
+
* writing `//cdn.example.com/hero.jpg` on a site with `base: /docs/` got
|
|
22
|
+
* `/docs//cdn.example.com/hero.jpg`. Measured 2026-09-06; the exact garbage
|
|
23
|
+
* `applyBasePath` documents itself as existing to prevent.
|
|
24
|
+
*
|
|
25
|
+
* ⇒ The lesson is narrower than "don't duplicate": **when a twinned pair needs a
|
|
26
|
+
* helper, the helper is the thing that must not be twinned.** A twin is a
|
|
27
|
+
* commitment to keep two renderers in step; every pure function pulled inside it
|
|
28
|
+
* silently joins that commitment.
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
import { applyBasePath } from '@uniweb/core/base-path'
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* A site-root-relative URL with the deployment base applied.
|
|
35
|
+
*
|
|
36
|
+
* The base comes off the active website rather than being passed in, because
|
|
37
|
+
* both callers render from the singleton and neither has it to hand. The join
|
|
38
|
+
* itself is core's — protocol-relative and absolute URLs pass through, and an
|
|
39
|
+
* already-based path is not based twice.
|
|
40
|
+
*
|
|
41
|
+
* @param {string} url
|
|
42
|
+
* @returns {string}
|
|
43
|
+
*/
|
|
44
|
+
export function siteUrl(url) {
|
|
45
|
+
return applyBasePath(url, globalThis.uniweb?.activeWebsite?.basePath || '')
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* A colour with an alpha applied, for an overlay drawn over a background.
|
|
50
|
+
*
|
|
51
|
+
* Hex and `rgb()` / `rgba()` are converted; anything else — a named colour, a
|
|
52
|
+
* `var(--token)`, `oklch(…)` — is returned unchanged, because a wrong guess
|
|
53
|
+
* here paints the wrong colour rather than failing.
|
|
54
|
+
*
|
|
55
|
+
* @param {string} color
|
|
56
|
+
* @param {number} opacity
|
|
57
|
+
* @returns {string}
|
|
58
|
+
*/
|
|
59
|
+
export function withOpacity(color, opacity) {
|
|
60
|
+
if (typeof color !== 'string' || !color) return color
|
|
61
|
+
if (color.startsWith('#')) {
|
|
62
|
+
const r = parseInt(color.slice(1, 3), 16)
|
|
63
|
+
const g = parseInt(color.slice(3, 5), 16)
|
|
64
|
+
const b = parseInt(color.slice(5, 7), 16)
|
|
65
|
+
return `rgba(${r}, ${g}, ${b}, ${opacity})`
|
|
66
|
+
}
|
|
67
|
+
if (color.startsWith('rgb')) {
|
|
68
|
+
const match = color.match(/rgba?\((\d+),\s*(\d+),\s*(\d+)/)
|
|
69
|
+
if (match) return `rgba(${match[1]}, ${match[2]}, ${match[3]}, ${opacity})`
|
|
70
|
+
}
|
|
71
|
+
return color
|
|
72
|
+
}
|
package/src/collect-records.js
CHANGED
|
@@ -12,11 +12,22 @@
|
|
|
12
12
|
*
|
|
13
13
|
* ⇒ So the composition is not duplicated here either. This walks the site's
|
|
14
14
|
* `config.queries`, hands each one to `resolveFetchConfigs` — **the same rule a
|
|
15
|
-
* page render uses**, applying each saved query's own `scope`
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
15
|
+
* page render uses**, applying each saved query's own `scope` and `where` — and
|
|
16
|
+
* asks through the same client.
|
|
17
|
+
*
|
|
18
|
+
* ## ⛔ `limit` IS DROPPED, and it is the one place a corpus must diverge
|
|
19
|
+
*
|
|
20
|
+
* A saved query's `limit` is the LIST PAGE's presentation: `limit: 20` means the
|
|
21
|
+
* page shows twenty. **Its detail pages still exist for every record matching
|
|
22
|
+
* `scope` + `where`** — so a corpus that honoured `limit` would index twenty and
|
|
23
|
+
* miss every page beyond them, which is worse than indexing nothing because the
|
|
24
|
+
* gap is invisible.
|
|
25
|
+
*
|
|
26
|
+
* ⚠️ **This shipped wrong in 0.17.0 and was found by the consumer, not by us**
|
|
27
|
+
* (2026-09-06): the config was passed through unchanged, `limit` crossed as the
|
|
28
|
+
* question's own, and the corpus was capped. The claim that this surface met
|
|
29
|
+
* "the population its detail pages can reach" was made *"read charitably"* — a
|
|
30
|
+
* phrase doing work that one `sed` would have done better.
|
|
20
31
|
*
|
|
21
32
|
* ## What the caller supplies
|
|
22
33
|
*
|
|
@@ -46,13 +57,29 @@ import { createDefaultFetcher } from './default-fetcher.js'
|
|
|
46
57
|
* @param {Function} options.fetch - the transport, `(url, init) => Response`
|
|
47
58
|
* @param {AbortSignal} [options.signal]
|
|
48
59
|
* @param {string[]} [options.only] - restrict to these query names
|
|
60
|
+
* @param {'brief'|'full'} [options.depth='brief'] - what to ask for. `brief` is
|
|
61
|
+
* what a list shows; **`full` is what an index wants** — a brief index cannot
|
|
62
|
+
* match body text the record's own detail page displays, and a reader who
|
|
63
|
+
* finds a word on the page and not in search meets the inconsistency two
|
|
64
|
+
* rankings would produce. The cost is the caller's and is bounded by `maxPages`.
|
|
65
|
+
* @param {number} [options.maxPages] - the caller's own bound on the walk. The
|
|
66
|
+
* default is a bound, not a target; a caller that knows its per-request budget
|
|
67
|
+
* passes its own.
|
|
49
68
|
* @returns {Promise<{records: Object, errors: Object|null, meta: Object}>}
|
|
50
69
|
* `records` is keyed by query NAME, each a flat array; `errors` is keyed the
|
|
51
|
-
* same and is null when nothing failed; `meta[name]` carries
|
|
52
|
-
*
|
|
53
|
-
*
|
|
70
|
+
* same and is null when nothing failed; `meta[name]` carries
|
|
71
|
+
* `{ depth, pages, partial?, bound? }`.
|
|
72
|
+
*
|
|
73
|
+
* ⭐ **`partial` means NOT THE WHOLE POPULATION**, and a key can be in BOTH
|
|
74
|
+
* `records` and `errors`: a walk that failed or was aborted with pages already
|
|
75
|
+
* in hand keeps them, marked. ⛔ Losing them would be indistinguishable from
|
|
76
|
+
* "this query has no records", and on an abort every in-flight key fails at
|
|
77
|
+
* once — so discarding would lose the corpus, not a key.
|
|
54
78
|
*/
|
|
55
|
-
export async function collectSiteRecords(
|
|
79
|
+
export async function collectSiteRecords(
|
|
80
|
+
content,
|
|
81
|
+
{ locale, fetch, signal, only = null, depth = 'brief', maxPages } = {},
|
|
82
|
+
) {
|
|
56
83
|
const config = content?.config
|
|
57
84
|
const services = config?.services ?? null
|
|
58
85
|
const queries = config?.queries ?? null
|
|
@@ -82,12 +109,18 @@ export async function collectSiteRecords(content, { locale, fetch, signal, only
|
|
|
82
109
|
// `path` has no live lane, and reading that file is the caller's business,
|
|
83
110
|
// not ours — it is in the site's own URL space and they already serve it.
|
|
84
111
|
if (!cfg.ask) return
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
112
|
+
// `limit` is the list page's, never the corpus's — see the header.
|
|
113
|
+
const { limit, ...population } = cfg
|
|
114
|
+
const asked = { ...population, depth, exhaustive: true }
|
|
115
|
+
if (typeof maxPages === 'number' && maxPages > 0) asked.maxPages = maxPages
|
|
116
|
+
|
|
117
|
+
const result = await fetcher.resolve(asked, { signal })
|
|
118
|
+
if (result?.error) errors[name] = result.error
|
|
119
|
+
// ⭐ Data and an error are not exclusive: a partial walk reports both, and
|
|
120
|
+
// the caller decides whether partial is usable. Only a walk that collected
|
|
121
|
+
// nothing leaves the key out of `records` entirely.
|
|
122
|
+
if (Array.isArray(result?.data)) records[name] = result.data
|
|
123
|
+
else if (!result?.error) records[name] = []
|
|
91
124
|
if (result?.meta) meta[name] = result.meta
|
|
92
125
|
}))
|
|
93
126
|
|
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
10
|
import React from 'react'
|
|
11
|
+
import { siteUrl, withOpacity } from '../background-shared.js'
|
|
11
12
|
|
|
12
13
|
/**
|
|
13
14
|
* Background modes
|
|
@@ -27,20 +28,6 @@ const OVERLAY_COLORS = {
|
|
|
27
28
|
dark: 'rgba(0, 0, 0, 0.5)',
|
|
28
29
|
}
|
|
29
30
|
|
|
30
|
-
/**
|
|
31
|
-
* Resolve a URL against the site's base path
|
|
32
|
-
* Prepends basePath to absolute URLs (starting with /) so they work
|
|
33
|
-
* under subdirectory deployments (e.g., /templates/international/)
|
|
34
|
-
*/
|
|
35
|
-
function resolveUrl(url) {
|
|
36
|
-
if (!url || !url.startsWith('/')) return url
|
|
37
|
-
const basePath = globalThis.uniweb?.activeWebsite?.basePath || ''
|
|
38
|
-
if (!basePath) return url
|
|
39
|
-
// Avoid double-prepending
|
|
40
|
-
if (url.startsWith(basePath + '/') || url === basePath) return url
|
|
41
|
-
return basePath + url
|
|
42
|
-
}
|
|
43
|
-
|
|
44
31
|
/**
|
|
45
32
|
* Render gradient overlay
|
|
46
33
|
*/
|
|
@@ -143,28 +130,6 @@ function GradientBackground({ gradient }) {
|
|
|
143
130
|
return <div className="background-gradient" style={style} aria-hidden="true" />
|
|
144
131
|
}
|
|
145
132
|
|
|
146
|
-
/**
|
|
147
|
-
* Convert hex color to rgba with opacity
|
|
148
|
-
*/
|
|
149
|
-
function withOpacity(color, opacity) {
|
|
150
|
-
// Handle hex colors
|
|
151
|
-
if (color.startsWith('#')) {
|
|
152
|
-
const r = parseInt(color.slice(1, 3), 16)
|
|
153
|
-
const g = parseInt(color.slice(3, 5), 16)
|
|
154
|
-
const b = parseInt(color.slice(5, 7), 16)
|
|
155
|
-
return `rgba(${r}, ${g}, ${b}, ${opacity})`
|
|
156
|
-
}
|
|
157
|
-
// Handle rgb/rgba
|
|
158
|
-
if (color.startsWith('rgb')) {
|
|
159
|
-
const match = color.match(/rgba?\((\d+),\s*(\d+),\s*(\d+)/)
|
|
160
|
-
if (match) {
|
|
161
|
-
return `rgba(${match[1]}, ${match[2]}, ${match[3]}, ${opacity})`
|
|
162
|
-
}
|
|
163
|
-
}
|
|
164
|
-
// Fallback - return as is
|
|
165
|
-
return color
|
|
166
|
-
}
|
|
167
|
-
|
|
168
133
|
/**
|
|
169
134
|
* Image background
|
|
170
135
|
*/
|
|
@@ -181,7 +146,7 @@ function ImageBackground({ image }) {
|
|
|
181
146
|
const style = {
|
|
182
147
|
position: 'absolute',
|
|
183
148
|
inset: 0,
|
|
184
|
-
backgroundImage: `url(${
|
|
149
|
+
backgroundImage: `url(${siteUrl(src)})`,
|
|
185
150
|
backgroundPosition: position,
|
|
186
151
|
backgroundSize: size,
|
|
187
152
|
backgroundRepeat: 'no-repeat',
|
|
@@ -235,7 +200,7 @@ function VideoBackground({ video }) {
|
|
|
235
200
|
// Build source list: explicit sources array, or infer from src
|
|
236
201
|
const sourceList = (sources || inferSources(src)).map(s => ({
|
|
237
202
|
...s,
|
|
238
|
-
src:
|
|
203
|
+
src: siteUrl(s.src)
|
|
239
204
|
}))
|
|
240
205
|
|
|
241
206
|
return (
|
|
@@ -246,7 +211,7 @@ function VideoBackground({ video }) {
|
|
|
246
211
|
loop={loop}
|
|
247
212
|
muted={muted}
|
|
248
213
|
playsInline
|
|
249
|
-
poster={
|
|
214
|
+
poster={siteUrl(poster)}
|
|
250
215
|
aria-hidden="true"
|
|
251
216
|
>
|
|
252
217
|
{sourceList.map(({ src: sourceSrc, type }, index) => (
|
package/src/default-fetcher.js
CHANGED
|
@@ -276,6 +276,8 @@ function toQuestion(request) {
|
|
|
276
276
|
// `exhaustive` deliberately does NOT cross — it is a client instruction about
|
|
277
277
|
// how many times to ask, not part of the question being asked.
|
|
278
278
|
if (typeof request.cursor === 'string' && request.cursor) q.cursor = request.cursor
|
|
279
|
+
// ⛔ `maxPages` does not cross either, for the same reason `exhaustive` does not:
|
|
280
|
+
// both say how many times to ask, never what is being asked.
|
|
279
281
|
return q
|
|
280
282
|
}
|
|
281
283
|
|
|
@@ -310,7 +312,7 @@ function renameOperators(where) {
|
|
|
310
312
|
*
|
|
311
313
|
* Two behaviours, deliberately not one:
|
|
312
314
|
*
|
|
313
|
-
* - **a page render REPORTS** — `meta.
|
|
315
|
+
* - **a page render REPORTS** — `meta.partial` and `meta.bound` ride the
|
|
314
316
|
* answer, and nothing pages automatically. Auto-paging here would put
|
|
315
317
|
* unbounded round trips in front of paint for a section that may only show
|
|
316
318
|
* ten rows.
|
|
@@ -320,8 +322,16 @@ function renameOperators(where) {
|
|
|
320
322
|
* a service that always answers with a cursor cannot spin.
|
|
321
323
|
*/
|
|
322
324
|
|
|
323
|
-
/**
|
|
324
|
-
|
|
325
|
+
/**
|
|
326
|
+
* Pages an exhaustive request follows before stopping and reporting the answer
|
|
327
|
+
* as partial. A caller sets its own with `request.maxPages`.
|
|
328
|
+
*
|
|
329
|
+
* ⚖️ **20 is a bound, not a target** — it is the value a real caller chose for a
|
|
330
|
+
* real per-request budget (a search index, 20 × 100), taken as the default
|
|
331
|
+
* because any bound prevents a runaway and a low one fails visibly rather than
|
|
332
|
+
* expensively. A caller that knows its budget passes its own.
|
|
333
|
+
*/
|
|
334
|
+
const DEFAULT_MAX_PAGES = 20
|
|
325
335
|
async function flushAsked(url, queue, doFetch) {
|
|
326
336
|
// One shared page loop: the batch is sent, and any entry that asked to be
|
|
327
337
|
// exhaustive and came back with a cursor is re-sent alone until it is done.
|
|
@@ -362,7 +372,15 @@ async function flushAsked(url, queue, doFetch) {
|
|
|
362
372
|
parsed = await response.json()
|
|
363
373
|
} catch (error) {
|
|
364
374
|
const message = error?.name === 'AbortError' ? 'aborted' : (error?.message || String(error))
|
|
365
|
-
|
|
375
|
+
// ⛔ AN ABORT KEEPS WHAT ARRIVED. On an exhaustive walk every in-flight key
|
|
376
|
+
// fails at once here, so discarding held pages loses the whole corpus rather
|
|
377
|
+
// than one key — the "rejection that loses every key" a caller cannot have.
|
|
378
|
+
for (const entry of queue) {
|
|
379
|
+
const held = Array.isArray(entry.collected) && entry.collected.length ? entry.collected : null
|
|
380
|
+
entry.resolve(held
|
|
381
|
+
? { data: held, error: message, meta: withMeta({ partial: true, pages: entry.page }) }
|
|
382
|
+
: { data: null, error: message })
|
|
383
|
+
}
|
|
366
384
|
return
|
|
367
385
|
}
|
|
368
386
|
const data = parsed && typeof parsed.data === 'object' && parsed.data ? parsed.data : {}
|
|
@@ -373,13 +391,20 @@ async function flushAsked(url, queue, doFetch) {
|
|
|
373
391
|
const limits = parsed && typeof parsed.limits === 'object' && parsed.limits ? parsed.limits : {}
|
|
374
392
|
queue.forEach((entry, i) => {
|
|
375
393
|
const key = keys[i]
|
|
394
|
+
// ⛔ A FAILURE MUST NOT DISCARD PAGES ALREADY COLLECTED. An exhaustive walk
|
|
395
|
+
// that fails on page 7 has six pages in hand, and a caller that asked for a
|
|
396
|
+
// corpus would rather have them marked partial than lose the key — losing it
|
|
397
|
+
// is indistinguishable from "this query has no records".
|
|
398
|
+
const held = Array.isArray(entry.collected) && entry.collected.length ? entry.collected : null
|
|
376
399
|
if (key in errors) {
|
|
377
400
|
// A per-key error is `{ code, detail }` — `schema_not_found`,
|
|
378
401
|
// `field_not_in_brief`, `scope_not_found`… The sentence is `detail`; `code`
|
|
379
402
|
// rides beside it for a reader that wants to branch on it.
|
|
380
403
|
const e = errors[key]
|
|
381
404
|
const detail = typeof e === 'string' ? e : (e?.detail || e?.message || JSON.stringify(e))
|
|
382
|
-
const out =
|
|
405
|
+
const out = held
|
|
406
|
+
? { data: held, error: detail, meta: withMeta({ partial: true, pages: entry.page }) }
|
|
407
|
+
: { data: null, error: detail }
|
|
383
408
|
if (e && typeof e === 'object' && typeof e.code === 'string') out.code = e.code
|
|
384
409
|
entry.resolve(out)
|
|
385
410
|
return
|
|
@@ -400,12 +425,15 @@ async function flushAsked(url, queue, doFetch) {
|
|
|
400
425
|
if (cursor && entry.request.exhaustive && Array.isArray(rows)) {
|
|
401
426
|
const acc = entry.collected ? entry.collected.concat(rows) : rows.slice()
|
|
402
427
|
const page = (entry.page || 1) + 1
|
|
403
|
-
|
|
428
|
+
const cap = typeof entry.request.maxPages === 'number' && entry.request.maxPages > 0
|
|
429
|
+
? entry.request.maxPages
|
|
430
|
+
: DEFAULT_MAX_PAGES
|
|
431
|
+
if (page <= cap) {
|
|
404
432
|
pending.set(entry, { cursor, collected: acc, page, depth, bound })
|
|
405
433
|
return
|
|
406
434
|
}
|
|
407
|
-
// The
|
|
408
|
-
entry.resolve({ data: acc, meta: withMeta({ depth, bound,
|
|
435
|
+
// The caller's own bound, not the service's: report rather than spin.
|
|
436
|
+
entry.resolve({ data: acc, meta: withMeta({ depth, bound, partial: true, pages: cap }) })
|
|
409
437
|
return
|
|
410
438
|
}
|
|
411
439
|
|
|
@@ -413,10 +441,20 @@ async function flushAsked(url, queue, doFetch) {
|
|
|
413
441
|
const meta = withMeta({
|
|
414
442
|
depth,
|
|
415
443
|
bound,
|
|
416
|
-
// ⭐
|
|
417
|
-
//
|
|
418
|
-
//
|
|
419
|
-
|
|
444
|
+
// ⭐ ONE FLAG, MEANING **NOT THE WHOLE POPULATION** — asked for by name, so
|
|
445
|
+
// a caller has one boolean to branch on rather than three signals to
|
|
446
|
+
// combine. It is set in every case that means it:
|
|
447
|
+
// · a cursor came back and this caller does not page (a page render);
|
|
448
|
+
// · a cursor came back and the caller's `maxPages` stopped the walk;
|
|
449
|
+
// · the walk failed or aborted with pages already in hand;
|
|
450
|
+
// · the service reported it BOUNDED the answer and offered no cursor.
|
|
451
|
+
// ⚠️ The last is why `limits` is read at all: a cursor is the signal for a
|
|
452
|
+
// query that declared no `limit`, and `limits` is the signal for one whose
|
|
453
|
+
// author limit was clamped (the records contract §5). Neither alone covers
|
|
454
|
+
// both. ⛔ Named `truncated` when it shipped in 0.17.0 this morning; renamed
|
|
455
|
+
// the same day, before any consumer adopted it, because "truncated" says
|
|
456
|
+
// something was cut and this also means "there is more you did not ask for".
|
|
457
|
+
partial: (cursor || bound !== undefined) ? true : undefined,
|
|
420
458
|
pages: entry.page && entry.page > 1 ? entry.page : undefined,
|
|
421
459
|
})
|
|
422
460
|
entry.resolve(meta ? { data: collected, meta } : { data: collected })
|
package/src/isolate-api.js
CHANGED
|
@@ -106,7 +106,7 @@ export const ISOLATE_API = Object.freeze({
|
|
|
106
106
|
createPageRenderer: '0.14.2',
|
|
107
107
|
prefetchAndHydrate: '0.14.2',
|
|
108
108
|
// the whole corpus, for a host that indexes rather than renders
|
|
109
|
-
collectSiteRecords:
|
|
109
|
+
collectSiteRecords: '0.17.0',
|
|
110
110
|
})
|
|
111
111
|
|
|
112
112
|
/**
|
package/src/ssr-renderer.js
CHANGED
|
@@ -18,6 +18,7 @@ import React from 'react'
|
|
|
18
18
|
import { renderToString } from 'react-dom/server'
|
|
19
19
|
import { createUniweb, resolveDefaultLocale } from '@uniweb/core'
|
|
20
20
|
import { sectionDomId } from '@uniweb/core/section-id'
|
|
21
|
+
import { siteUrl, withOpacity } from './background-shared.js'
|
|
21
22
|
import { routePatternToRegex } from '@uniweb/core/route-match'
|
|
22
23
|
import { DEFAULT_ICON_BASE, iconUrl } from '@uniweb/core/icon-corpus'
|
|
23
24
|
import { buildSectionOverrides, FONT_LINKS_MARKER } from '@uniweb/theming'
|
|
@@ -86,37 +87,6 @@ export function getWrapperProps(block) {
|
|
|
86
87
|
return { id: sectionDomId(block), style, className, background }
|
|
87
88
|
}
|
|
88
89
|
|
|
89
|
-
/**
|
|
90
|
-
* Convert hex/rgb color to rgba with opacity.
|
|
91
|
-
* Mirrors withOpacity() in Background.jsx.
|
|
92
|
-
*/
|
|
93
|
-
function withOpacity(color, opacity) {
|
|
94
|
-
if (color.startsWith('#')) {
|
|
95
|
-
const r = parseInt(color.slice(1, 3), 16)
|
|
96
|
-
const g = parseInt(color.slice(3, 5), 16)
|
|
97
|
-
const b = parseInt(color.slice(5, 7), 16)
|
|
98
|
-
return `rgba(${r}, ${g}, ${b}, ${opacity})`
|
|
99
|
-
}
|
|
100
|
-
if (color.startsWith('rgb')) {
|
|
101
|
-
const match = color.match(/rgba?\((\d+),\s*(\d+),\s*(\d+)/)
|
|
102
|
-
if (match) {
|
|
103
|
-
return `rgba(${match[1]}, ${match[2]}, ${match[3]}, ${opacity})`
|
|
104
|
-
}
|
|
105
|
-
}
|
|
106
|
-
return color
|
|
107
|
-
}
|
|
108
|
-
|
|
109
|
-
/**
|
|
110
|
-
* Resolve a URL against the site's base path.
|
|
111
|
-
* Mirrors resolveUrl() in Background.jsx.
|
|
112
|
-
*/
|
|
113
|
-
function resolveUrl(url) {
|
|
114
|
-
if (!url || !url.startsWith('/')) return url
|
|
115
|
-
const basePath = globalThis.uniweb?.activeWebsite?.basePath || ''
|
|
116
|
-
if (!basePath) return url
|
|
117
|
-
if (url.startsWith(basePath + '/') || url === basePath) return url
|
|
118
|
-
return basePath + url
|
|
119
|
-
}
|
|
120
90
|
|
|
121
91
|
/**
|
|
122
92
|
* Render a background element for SSR.
|
|
@@ -189,7 +159,7 @@ export function renderBackground(background) {
|
|
|
189
159
|
style: {
|
|
190
160
|
position: 'absolute',
|
|
191
161
|
inset: '0',
|
|
192
|
-
backgroundImage: `url(${
|
|
162
|
+
backgroundImage: `url(${siteUrl(img.src)})`,
|
|
193
163
|
backgroundPosition: img.position || 'center',
|
|
194
164
|
backgroundSize: img.size || 'cover',
|
|
195
165
|
backgroundRepeat: 'no-repeat',
|