@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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@uniweb/runtime",
3
- "version": "0.17.0",
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.22.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.39.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
+ }
@@ -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` / `where` / `sort`
16
- * / `limit` — and asks through the same client. What differs from a page is two
17
- * fields and nothing else: `depth: 'brief'` (an index wants what a list shows)
18
- * and `exhaustive: true` (a corpus is not a page, so it follows `cursors` to the
19
- * end; the records contract bounds a single answer at 100).
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 `{ depth, pages,
52
- * truncated? }` `truncated` meaning the loop hit its own bound, never that
53
- * the site has more.
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(content, { locale, fetch, signal, only = null } = {}) {
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
- const result = await fetcher.resolve({ ...cfg, depth: 'brief', exhaustive: true }, { signal })
86
- if (result?.error) {
87
- errors[name] = result.error
88
- return
89
- }
90
- records[name] = Array.isArray(result?.data) ? result.data : []
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(${resolveUrl(src)})`,
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: resolveUrl(s.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={resolveUrl(poster)}
214
+ poster={siteUrl(poster)}
250
215
  aria-hidden="true"
251
216
  >
252
217
  {sourceList.map(({ src: sourceSrc, type }, index) => (
@@ -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.truncated` and `meta.bound` ride the
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
- /** Pages an exhaustive request will follow before giving up and reporting truncation. */
324
- const MAX_PAGES = 50
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
- for (const entry of queue) entry.resolve({ data: null, error: message })
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 = { data: null, error: detail }
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
- if (page <= MAX_PAGES) {
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 loop's own bound, not the service's: report rather than spin.
408
- entry.resolve({ data: acc, meta: withMeta({ depth, bound, truncated: true, pages: MAX_PAGES }) })
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
- // ⭐ A cursor IS the truncation signal, and it is the only one for a query
417
- // that declared no `limit`: `limits` is reported only when an author's own
418
- // limit was clamped (the records contract §5).
419
- truncated: cursor ? true : undefined,
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 })
@@ -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: UNRELEASED,
109
+ collectSiteRecords: '0.17.0',
110
110
  })
111
111
 
112
112
  /**
@@ -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(${resolveUrl(img.src)})`,
162
+ backgroundImage: `url(${siteUrl(img.src)})`,
193
163
  backgroundPosition: img.position || 'center',
194
164
  backgroundSize: img.size || 'cover',
195
165
  backgroundRepeat: 'no-repeat',