@uniweb/runtime 0.22.0 → 0.24.0

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.22.0",
3
+ "version": "0.24.0",
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.26.2",
39
+ "@uniweb/core": "^0.28.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.1"
47
+ "@uniweb/build": "0.48.0"
48
48
  },
49
49
  "peerDependencies": {
50
50
  "react": "^19.0.0",
@@ -57,11 +57,12 @@ import { createDefaultFetcher } from './default-fetcher.js'
57
57
  * @param {Function} options.fetch - the transport, `(url, init) => Response`
58
58
  * @param {AbortSignal} [options.signal]
59
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
60
+ * @param {boolean} [options.whole=false] - what to ask for. `false` is the brief a
61
+ * list shows; **`true` — whole records — is what an index wants**: a brief index
62
+ * cannot match body text the record's own detail page displays, and a reader who
63
63
  * finds a word on the page and not in search meets the inconsistency two
64
64
  * rankings would produce. The cost is the caller's and is bounded by `maxPages`.
65
+ * An external query is never collected — it has no records service to ask.
65
66
  * @param {number} [options.maxPages] - the caller's own bound on the walk. The
66
67
  * default is a bound, not a target; a caller that knows its per-request budget
67
68
  * passes its own.
@@ -26,16 +26,17 @@
26
26
  * foundation level… making the runtime+core lean."* A backend with its own
27
27
  * base, headers, wire or query language is a TRANSPORT — a named
28
28
  * `{ resolve, cacheKey? }` the foundation (or an extension) registers and
29
- * the site selects per schema in `fetcher.transports`. The build warns once
29
+ * the site selects per binding key (`as`) in `fetcher.transports`. The build warns once
30
30
  * and drops a retired key from the payload, so an author's backend does not
31
31
  * silently stop being reached.
32
32
  *
33
- * Per-fetch, the request may still carry `method: 'POST'` + `body:` for a
34
- * backend that takes a query in a body (GraphQL, a search endpoint);
35
- * `{paramName}` placeholders in body strings are substituted from
36
- * `request.dynamicContext`, so a template page's detail query can reference
37
- * its route param. `transform:` and the object form of `detail:` (its own
38
- * `envelope`) stay per fetch too — they describe ONE response, not a backend.
33
+ * A request may carry `method: 'POST'` + `body:` for an endpoint that takes a
34
+ * query in a body (GraphQL, a search endpoint) — an external query's, or its
35
+ * `record:` request's — and `transform:`, a dot-path to the records in ONE
36
+ * response. `{paramName}` placeholders in body strings are substituted from
37
+ * `request.dynamicContext`, so a parametric page's record request can reference
38
+ * its route param. ⛔ `envelope` (the object form of `detail:`) is retired with
39
+ * it, 2026-09-13: a record request's own `transform` is `record.transform`.
39
40
  *
40
41
  * Exported from a subpath — `@uniweb/runtime/default-fetcher` — for
41
42
  * runtime-level callers (the editor's preview iframe, custom runtime
@@ -192,49 +193,23 @@ export function createDefaultFetcher({ basePath = '', dev = false, fetch: fetchI
192
193
  const response = await readOnce(target, init)
193
194
  if (ctx.signal?.aborted) return { data: [], error: 'aborted' }
194
195
 
195
- // A per-request envelope (set by the object form of `detail:`) describes
196
- // this one response.
197
- const envelope = (request.envelope && typeof request.envelope === 'object')
198
- ? request.envelope
199
- : {}
200
-
201
196
  if (!response.ok) {
202
- // If `envelope.error` names a path, try to extract a human message
203
- // from the parsed body; fall back to status text if the path is
204
- // missing or the body isn't JSON.
205
- let extracted
206
- if (envelope.error && typeof response.text === 'string') {
207
- const body = safeParseJSON(response.text)
208
- if (body !== undefined) {
209
- const candidate = getNestedValue(body, envelope.error)
210
- if (typeof candidate === 'string' && candidate.length) {
211
- extracted = candidate
212
- }
213
- }
214
- }
215
- return {
216
- data: [],
217
- error: extracted ?? `HTTP ${response.status}: ${response.statusText}`,
218
- }
197
+ return { data: [], error: `HTTP ${response.status}: ${response.statusText}` }
219
198
  }
220
199
 
221
200
  let data = response.body
222
201
 
223
- // Unwrap the response. Per-fetch `transform:` wins; otherwise the
224
- // envelope's `item` path on a single-record request, `list` on a list.
225
- const isDetailRequest = !!request.dynamicContext
226
- const effectiveTransform =
227
- transform
228
- || (isDetailRequest ? envelope.item : envelope.list)
229
- if (effectiveTransform && data !== null && data !== undefined) {
230
- data = getNestedValue(data, effectiveTransform)
202
+ // Unwrap the response: the request's own `transform:`, and nothing else — a
203
+ // record request carries its `record.transform`, never the list's.
204
+ if (transform && data !== null && data !== undefined) {
205
+ data = getNestedValue(data, transform)
231
206
  }
232
207
 
233
208
  // Evaluate the query locally. Only applies to array data
234
209
  // (filtering/sorting/limiting a single record doesn't make sense).
235
210
  // For non-arrays, operators are ignored — the source returned what
236
211
  // it returned.
237
- data = applyOperators(data, request, { dev })
212
+ data = applyOperators(data, request, { dev, locale: sortLocale(request, ctx) })
238
213
 
239
214
  // ⭐ Say whether WHOLE records were delivered, so the record index can
240
215
  // file them — what the config asked for, echoed: briefs when the query
@@ -257,11 +232,12 @@ export function createDefaultFetcher({ basePath = '', dev = false, fetch: fetchI
257
232
  * One question of a batch, in the records service's own vocabulary
258
233
  * (the records contract, §2): `schema` required, `scope` a bare path, `sort`
259
234
  * one key spelled `date` / `-date`, `whole` when the whole record is wanted,
260
- * `match` on a parametric page's record. The where-object crosses as authored
261
- * except for the one spelling the language settled differently from the
262
- * evaluator's: `nin` is `not_in` there. Anything the service does not accept
263
- * (`like`, a dotted path) is sent as written and refused there by name: loud,
264
- * never approximated.
235
+ * `match` on a parametric page's record. ⭐ The where-object crosses exactly as
236
+ * authored: the evaluator and the service speak one language (`@uniweb/core`'s where evaluator).
237
+ * ⛔ `nin` was respelled `not_in` here until 2026-09-13, when the evaluator took
238
+ * `not_in` as its only spelling. Anything the service does not answer (a dotted
239
+ * path, a retired operator) is sent as written and answered there: never
240
+ * approximated.
265
241
  */
266
242
  function toQuestion(request) {
267
243
  const q = { schema: request.schema }
@@ -271,7 +247,7 @@ function toQuestion(request) {
271
247
  // [Diego] — the build refuses it now, so there is nothing left to respell.
272
248
  const scope = typeof request.scope === 'string' && request.scope ? request.scope : null
273
249
  if (scope) q.scope = scope
274
- if (where) q.where = renameOperators(where)
250
+ if (where) q.where = where
275
251
  // ⭐ The record of a parametric page: the question unchanged plus `match` — one
276
252
  // key and the URL's value, beside the author's `where`, never merged into it
277
253
  // (`buildDetailConfig`; the records contract as we read it, §1d).
@@ -292,41 +268,31 @@ function toQuestion(request) {
292
268
  return q
293
269
  }
294
270
 
295
- const OPERATOR_ALIAS = { nin: 'not_in' }
296
- function renameOperators(where) {
297
- if (Array.isArray(where)) return where.map(renameOperators)
298
- if (!where || typeof where !== 'object') return where
299
- const out = {}
300
- for (const [key, value] of Object.entries(where)) {
301
- out[OPERATOR_ALIAS[key] ?? key] = value && typeof value === 'object' ? renameOperators(value) : value
302
- }
303
- return out
304
- }
305
-
306
271
  /**
307
272
  * Send one batch to the records service and hand each question its own answer.
308
273
  *
309
- * The response is `{ data, depths?, errors?, cursors?, limits? }` (contract §5):
310
- * `data` answers exactly the keys sent, `[]` when nothing matched; a key that
311
- * ERRORED is absent from `data` and present in `errors`; `depths` says what was
312
- * actually served, which the record index files rather than what was asked for.
313
- * A key missing from both is a protocol violation and is reported as an error,
314
- * never as silence.
274
+ * The response is `{ data, whole?, cursors?, partial? }` (backend's records-query
275
+ * contract rev D, 2026-09-13), each map absent when empty: `data` answers exactly the
276
+ * keys sent — `[]` when nothing matched, and `[]` too for a question an author got
277
+ * wrong, since the service answers a mistake rather than refusing it; `whole[key]` says
278
+ * a key was served as whole records; `cursors[key]` that more records remain under the
279
+ * question's `limit`; `partial[key]` that a source of the key did not answer. A key
280
+ * missing from `data` is a protocol violation and is reported as an error, never as
281
+ * silence. ⛔ `errors` and `limits` are not read: the service stopped sending them in
282
+ * the same revision, and this client read both until 2026-09-14.
315
283
  *
316
- * ⭐ `cursors` AND `limits` ARE READ — 2026-09-06 [Diego], reversing the ruling
317
- * that had them "received and IGNORED, because framework has no paging concept."
318
- * ⛔ **That ruling described our client and was silently wrong about our USERS:**
319
- * the service bounds every answer at 100 (the records contract §4.2/§5), and a
320
- * `cursors` entry is how it says there is more. Discarding both meant a hosted
321
- * list of 500 rendered 100 — no error, no warning, no way for an author to tell
322
- * a bound from the end of the data. **The silent class, on a visitor's page.**
284
+ * ⭐ `cursors` ARE READ — 2026-09-06 [Diego], reversing the ruling that had them
285
+ * "received and IGNORED, because framework has no paging concept." ⛔ **That ruling
286
+ * described our client and was silently wrong about our USERS:** a `cursors` entry is
287
+ * how the service says there is more, and discarding it meant a hosted list could
288
+ * render part of its records with no way for an author to tell that from the end of
289
+ * the data. **The silent class, on a visitor's page.**
323
290
  *
324
291
  * Two behaviours, deliberately not one:
325
292
  *
326
- * - **a page render REPORTS** — `meta.partial` and `meta.bound` ride the
327
- * answer, and nothing pages automatically. Auto-paging here would put
328
- * unbounded round trips in front of paint for a section that may only show
329
- * ten rows.
293
+ * - **a page render REPORTS** — `meta.partial` rides the answer, and nothing pages
294
+ * automatically. Auto-paging here would put unbounded round trips in front of
295
+ * paint for a section that may only show ten rows.
330
296
  * - **an exhaustive caller PAGES** — `request.exhaustive` follows `cursors`
331
297
  * until the service stops issuing them. `collectSiteRecords` is the caller
332
298
  * that wants it (a corpus is not a page), and `maxPages` bounds the loop so
@@ -395,14 +361,14 @@ async function flushAsked(url, queue, doFetch) {
395
361
  return
396
362
  }
397
363
  const data = parsed && typeof parsed.data === 'object' && parsed.data ? parsed.data : {}
398
- const errors = parsed && typeof parsed.errors === 'object' && parsed.errors ? parsed.errors : {}
399
364
  // ⭐ `whole[key]` is present only for keys delivered as WHOLE entities — so a
400
365
  // key's ABSENCE is the brief, and a key present that never asked is the
401
366
  // brief-less Model saying so. Absent entirely when every key is briefs.
402
367
  const wholes = parsed && typeof parsed.whole === 'object' && parsed.whole ? parsed.whole : {}
403
- // Both absent when empty, never `{}` (the records contract §5).
404
368
  const cursors = parsed && typeof parsed.cursors === 'object' && parsed.cursors ? parsed.cursors : {}
405
- const limits = parsed && typeof parsed.limits === 'object' && parsed.limits ? parsed.limits : {}
369
+ // ⭐ `partial[key]` — `{ source, code, detail }`: a source of that key did not answer,
370
+ // and the key carries what the others returned. Unread, a degraded key looked complete.
371
+ const degraded = parsed && typeof parsed.partial === 'object' && parsed.partial ? parsed.partial : {}
406
372
  queue.forEach((entry, i) => {
407
373
  const key = keys[i]
408
374
  // ⛔ A FAILURE MUST NOT DISCARD PAGES ALREADY COLLECTED. An exhaustive walk
@@ -410,21 +376,11 @@ async function flushAsked(url, queue, doFetch) {
410
376
  // corpus would rather have them marked partial than lose the key — losing it
411
377
  // is indistinguishable from "this query has no records".
412
378
  const held = Array.isArray(entry.collected) && entry.collected.length ? entry.collected : null
413
- if (key in errors) {
414
- // A per-key error is `{ code, detail }` — `schema_not_found`,
415
- // `field_not_in_brief`, `scope_not_found`… The sentence is `detail`; `code`
416
- // rides beside it for a reader that wants to branch on it.
417
- const e = errors[key]
418
- const detail = typeof e === 'string' ? e : (e?.detail || e?.message || JSON.stringify(e))
419
- const out = held
420
- ? { data: held, error: detail, meta: withMeta({ partial: true, pages: entry.page }) }
421
- : { data: null, error: detail }
422
- if (e && typeof e === 'object' && typeof e.code === 'string') out.code = e.code
423
- entry.resolve(out)
424
- return
425
- }
426
379
  if (!(key in data)) {
427
- entry.resolve({ data: null, error: `the records service answered without the key "${key}"` })
380
+ const error = `the records service answered without the key "${key}"`
381
+ entry.resolve(held
382
+ ? { data: held, error, meta: withMeta({ partial: true, pages: entry.page }) }
383
+ : { data: null, error })
428
384
  return
429
385
  }
430
386
  const whole = typeof wholes[key] === 'boolean'
@@ -432,7 +388,6 @@ async function flushAsked(url, queue, doFetch) {
432
388
  : (typeof entry.request.whole === 'boolean' ? entry.request.whole : undefined)
433
389
 
434
390
  const cursor = typeof cursors[key] === 'string' && cursors[key] ? cursors[key] : null
435
- const bound = typeof limits[key] === 'number' ? limits[key] : undefined
436
391
  const rows = Array.isArray(data[key]) ? data[key] : data[key]
437
392
 
438
393
  // An exhaustive caller collects the page and asks for the next one.
@@ -443,32 +398,30 @@ async function flushAsked(url, queue, doFetch) {
443
398
  ? entry.request.maxPages
444
399
  : DEFAULT_MAX_PAGES
445
400
  if (page <= cap) {
446
- pending.set(entry, { cursor, collected: acc, page, whole, bound })
401
+ pending.set(entry, { cursor, collected: acc, page, whole })
447
402
  return
448
403
  }
449
404
  // The caller's own bound, not the service's: report rather than spin.
450
- entry.resolve({ data: acc, meta: withMeta({ whole, bound, partial: true, pages: cap }) })
405
+ entry.resolve({ data: acc, meta: withMeta({ whole, partial: true, pages: cap }) })
451
406
  return
452
407
  }
453
408
 
454
409
  const collected = entry.collected ? entry.collected.concat(Array.isArray(rows) ? rows : []) : rows
455
410
  const meta = withMeta({
456
411
  whole,
457
- bound,
458
412
  // ⭐ ONE FLAG, MEANING **NOT THE WHOLE POPULATION** — asked for by name, so
459
- // a caller has one boolean to branch on rather than three signals to
413
+ // a caller has one boolean to branch on rather than several signals to
460
414
  // combine. It is set in every case that means it:
461
415
  // · a cursor came back and this caller does not page (a page render);
462
416
  // · a cursor came back and the caller's `maxPages` stopped the walk;
463
417
  // · the walk failed or aborted with pages already in hand;
464
- // · the service reported it BOUNDED the answer and offered no cursor.
465
- // ⚠️ The last is why `limits` is read at all: a cursor is the signal for a
466
- // query that declared no `limit`, and `limits` is the signal for one whose
467
- // author limit was clamped (the records contract §5). Neither alone covers
468
- // both. ⛔ Named `truncated` when it shipped in 0.17.0 this morning; renamed
469
- // the same day, before any consumer adopted it, because "truncated" says
470
- // something was cut and this also means "there is more you did not ask for".
471
- partial: (cursor || bound !== undefined) ? true : undefined,
418
+ // · a source of the key did not answer (`partial[key]`), and `unavailable`
419
+ // carries the service's own account of which one.
420
+ // ⛔ Named `truncated` when it shipped in 0.17.0; renamed the same day, before
421
+ // any consumer adopted it, because "truncated" says something was cut and this
422
+ // also means "there is more you did not ask for".
423
+ partial: (cursor || key in degraded) ? true : undefined,
424
+ unavailable: key in degraded ? degraded[key] : undefined,
472
425
  pages: entry.page && entry.page > 1 ? entry.page : undefined,
473
426
  })
474
427
  entry.resolve(meta ? { data: collected, meta } : { data: collected })
@@ -491,8 +444,8 @@ async function flushAsked(url, queue, doFetch) {
491
444
  /**
492
445
  * Read one response and parse its body — the part of a fetch every view of an
493
446
  * address shares. A JSON body is parsed as JSON; anything else is tried as JSON
494
- * and kept as text when it is not. A failed response keeps its text, so a view
495
- * whose envelope names an error path can read a message out of it.
447
+ * and kept as text when it is not. A failed response keeps its text, so a caller
448
+ * can read a message out of it (the records service's problem details).
496
449
  */
497
450
  async function readResponse(doFetch, target, init) {
498
451
  const response = await doFetch(target, init)
@@ -522,12 +475,24 @@ function withMeta(fields) {
522
475
  return Object.keys(out).length ? out : undefined
523
476
  }
524
477
 
478
+ /**
479
+ * The locale a list's texts are collated in: the page's (ruled 2026-09-14 [Diego]).
480
+ * A request asked in a locale carries it; otherwise the caller's — the website's
481
+ * active locale in the browser, the locale a host's prerender renders.
482
+ */
483
+ function sortLocale(request, ctx) {
484
+ if (typeof request?.locale === 'string' && request.locale) return request.locale
485
+ if (typeof ctx?.locale === 'string' && ctx.locale) return ctx.locale
486
+ const active = ctx?.website?.getActiveLocale?.()
487
+ return typeof active === 'string' && active ? active : null
488
+ }
489
+
525
490
  /**
526
491
  * Evaluate the query over what the source returned — the ONE evaluator,
527
492
  * `@uniweb/core`'s, so the browser orders and filters exactly as the build
528
493
  * did when it materialized `/data/<name>.json`.
529
494
  */
530
- function applyOperators(data, request, { dev = false } = {}) {
495
+ function applyOperators(data, request, { dev = false, locale = null } = {}) {
531
496
  if (!Array.isArray(data)) return data
532
497
  let result = data
533
498
  // `scope` first: it names the branch the rest of the query reads. On this lane
@@ -535,7 +500,7 @@ function applyOperators(data, request, { dev = false } = {}) {
535
500
  // wrote the file — `scope: :dir` bound per page reaches here (2026-09-11).
536
501
  if (typeof request.scope === 'string' && request.scope) result = applyScope(result, request.scope)
537
502
  if (request.where) result = matchWhere(request.where, result)
538
- if (request.sort) result = applySort(result, request.sort, dev)
503
+ if (request.sort) result = applySort(result, request.sort, dev, locale)
539
504
  if (typeof request.limit === 'number' && request.limit > 0) result = result.slice(0, request.limit)
540
505
  return result
541
506
  }
@@ -548,9 +513,9 @@ function applyOperators(data, request, { dev = false } = {}) {
548
513
  * logged once — a wrong order is not worth a broken page for a visitor.
549
514
  */
550
515
  const warnedBadSorts = new Set()
551
- function applySort(items, sortExpr, dev) {
516
+ function applySort(items, sortExpr, dev, locale) {
552
517
  try {
553
- return sortRecords(items, sortExpr)
518
+ return sortRecords(items, sortExpr, { locale })
554
519
  } catch (err) {
555
520
  if (dev) throw err
556
521
  const key = String(sortExpr)
package/src/prefetch.js CHANGED
@@ -50,15 +50,14 @@
50
50
  * `{base}/…` from the payload, or the records service the host itself
51
51
  * published at `config.services.records`.
52
52
  */
53
- import { resolveFetchConfigs, routeQuery, sectionFetches } from '@uniweb/core/fetch-config'
53
+ import { resolveFetchConfigs, pageRouteQuery, routeSelection, currentOf, othersView, siteReaches } from '@uniweb/core/fetch-config'
54
54
  import { deriveCacheKey } from '@uniweb/core/datastore'
55
55
  import { findPageForRoute, isDynamicRoute, routeBinding, parentRouteOf } from '@uniweb/core/route-match'
56
56
  import { buildDetailConfig } from '@uniweb/core/detail-url'
57
57
  import { resolveDefaultLocale } from '@uniweb/core/locale-config'
58
+ import { findLayoutEntry } from '@uniweb/core/layout-name'
58
59
  import { createDefaultFetcher } from './default-fetcher.js'
59
60
 
60
- const isRefinement = (f) => f && typeof f === 'object' && f.refine === true
61
-
62
61
  /**
63
62
  * ⭐ **RE-EXPORTED, NOT IMPLEMENTED HERE — the rule lives in `@uniweb/core/route-match`.**
64
63
  *
@@ -88,11 +87,36 @@ function parentPageOf(page, pages) {
88
87
  return route ? byRoute.get(route) : null
89
88
  }
90
89
 
90
+ /**
91
+ * The layout sections a page may render — every section of the layout areas it can
92
+ * use, subsections included. A page that names a layout uses that set (matched as the
93
+ * runtime matches it, `@uniweb/core/layout-name`) and nothing else; a page that names
94
+ * none uses its foundation's default layout, which a payload does not say, so every
95
+ * set's sections are included — a superset, which a prefetch can afford.
96
+ */
97
+ function layoutSectionsFor(content, page) {
98
+ const sets = content?.layouts && typeof content.layouts === 'object' ? content.layouts : {}
99
+ const name = typeof page?.layout?.name === 'string' && page.layout.name ? page.layout.name : null
100
+ const named = name ? findLayoutEntry(sets, name) : undefined
101
+ const chosen = name ? (named ? [named] : []) : Object.values(sets)
102
+ const out = []
103
+ const walk = (sections) => {
104
+ for (const s of sections || []) {
105
+ if (!s || typeof s !== 'object') continue
106
+ out.push(s)
107
+ walk(s.subsections)
108
+ }
109
+ }
110
+ for (const set of chosen) for (const area of Object.values(set || {})) walk(area?.sections)
111
+ return out
112
+ }
113
+
91
114
  /**
92
115
  * Every fetch config a page will need at render time, resolved once and de-duplicated by
93
- * cache key: the site-level fetch, the page's, its parent's, and each section's own
94
- * (including nested sections), each through `resolveFetchConfigs` — the same resolver the
95
- * entity store uses, so a host prefetches exactly what the render will ask for.
116
+ * cache key: the page's, its parent's, each section's own (including nested sections),
117
+ * its layout sections', and the site's where it reaches (`siteReaches`) — each through
118
+ * `resolveFetchConfigs`, the same resolver the entity store uses, so a host prefetches
119
+ * exactly what the render will ask for.
96
120
  *
97
121
  * @returns {Object[]} resolved fetch configs
98
122
  */
@@ -113,11 +137,22 @@ export function resolvePageFetchConfigs(content, route, { locale = null } = {})
113
137
  variables: binding?.variables ?? null,
114
138
  }
115
139
  const siteFetch = content?.config?.fetch ?? null
116
- // The route query: the key this page's URL names one record of, by the rule the
117
- // entity store reads it with (`routeQuery`) — never a payload field.
118
- const routeKey = binding
119
- ? routeQuery({ page: page.fetch, parent: parent?.fetch, site: siteFetch, sections: sectionFetches(page.sections) })?.key ?? null
140
+ // The route query: the key this page's URL names one record of, chosen at the page
141
+ // that captured its variable — by the rule the entity store reads it with
142
+ // (`pageRouteQuery`) — never a payload field.
143
+ const chosen = binding
144
+ ? pageRouteQuery(page, {
145
+ routeOf: (p) => p.route,
146
+ parentOf: (p) => parentPageOf(p, pages),
147
+ fetchOf: (p) => p.fetch ?? null,
148
+ sectionsOf: (p) => p.sections,
149
+ site: siteFetch,
150
+ })
120
151
  : null
152
+ const routeKey = chosen?.key ?? null
153
+ // A nested page's sections receive its capturing page's route binding, which the
154
+ // one-parent cascade may not reach — the entity store adds it the same way.
155
+ const routeSource = chosen?.nested ? chosen.config : null
121
156
 
122
157
  const out = new Map()
123
158
  const put = (cfg) => {
@@ -126,28 +161,47 @@ export function resolvePageFetchConfigs(content, route, { locale = null } = {})
126
161
  }
127
162
  const add = (sources) => {
128
163
  for (const cfg of resolveFetchConfigs(sources, options).values()) {
129
- put(cfg)
164
+ const current = routeKey && cfg.as === routeKey ? currentOf(cfg) : null
165
+ if (current === 'exclude') {
166
+ // the list one longer, from which the render removes the page's record
167
+ put(othersView(cfg))
168
+ continue
169
+ }
170
+ if (current !== 'only') {
171
+ // any other key, or `current: include` — the list as the binding describes it
172
+ put(cfg)
173
+ continue
174
+ }
130
175
  // ⭐ A parametric page is ABOUT one record, and on a lane with a per-record
131
176
  // source (the records service, a `deferred:` query's per-record file) that
132
177
  // record is a request of its own. Built for EVERY config the route key
133
178
  // resolves to — a section re-declaring the route query asks its own record
134
179
  // question — by the one rule the entity store uses (`buildDetailConfig`), so
135
180
  // the question prefetched is the question the render asks.
136
- if (routeKey && cfg.as === routeKey && cfg.detail && binding.paramValue !== undefined) {
181
+ if (cfg.detail && binding.paramValue !== undefined) {
137
182
  const detailCfg = buildDetailConfig(cfg, { paramName: binding.paramName, paramValue: String(binding.paramValue) })
138
183
  if (detailCfg) put(detailCfg)
139
184
  }
185
+ // On the records service the list is asked beside the record; off it, the
186
+ // record is FOUND in the route query's whole selection — the list without its
187
+ // `limit` (`routeSelection`) — which is all the render reads for it.
188
+ put(cfg.ask ? cfg : routeSelection(cfg))
140
189
  }
141
190
  }
142
- // The cascade a block sees: its own fetch (unless a refinement), page, parent, site.
143
- add([page.fetch ?? null, parent?.fetch ?? null, siteFetch])
191
+ // The cascade a block sees: its own fetch, page, parent, a nested page's route
192
+ // binding, and the site's — which reaches a top-level page's sections only.
193
+ const siteForPage = siteReaches(parent) ? siteFetch : null
194
+ add([page.fetch ?? null, parent?.fetch ?? null, routeSource, siteForPage])
144
195
  const walk = (sections) => {
145
196
  for (const s of sections || []) {
146
- if (s?.fetch && !isRefinement(s.fetch)) add([s.fetch, page.fetch ?? null, parent?.fetch ?? null, siteFetch])
197
+ if (s?.fetch) add([s.fetch, page.fetch ?? null, parent?.fetch ?? null, routeSource, siteForPage])
147
198
  if (s?.subsections) walk(s.subsections)
148
199
  }
149
200
  }
150
201
  walk(page.sections)
202
+ // ⭐ Layout sections belong to the site: the site's binding reaches them on every
203
+ // page, beside each one's own fetch. A layout area has no parent page.
204
+ for (const s of layoutSectionsFor(content, page)) add([s.fetch ?? null, siteFetch])
151
205
  return [...out.values()]
152
206
  }
153
207
 
@@ -162,7 +216,7 @@ export function resolvePageFetchConfigs(content, route, { locale = null } = {})
162
216
  * @param {boolean} [opts.dev]
163
217
  * @returns {Promise<Array<{ config: Object, outcome: 'fetched'|'failed'|'skipped', data: any, error?: string }>>}
164
218
  */
165
- export async function executeFetchConfigs(configs, { content, fetch = null, dev = false, prerender = 'always' } = {}) {
219
+ export async function executeFetchConfigs(configs, { content, fetch = null, dev = false, prerender = 'always', locale = null } = {}) {
166
220
  if (prerender !== 'author' && prerender !== 'always') {
167
221
  throw new Error(`executeFetchConfigs: prerender must be 'author' or 'always', got ${JSON.stringify(prerender)}`)
168
222
  }
@@ -171,7 +225,8 @@ export async function executeFetchConfigs(configs, { content, fetch = null, dev
171
225
  dev,
172
226
  fetch,
173
227
  })
174
- const ctx = { website: null }
228
+ // The page's locale, which a list's texts are collated in when the runtime sorts it.
229
+ const ctx = { website: null, locale: locale ?? content?.config?.activeLocale ?? resolveDefaultLocale(content?.config) ?? null }
175
230
  // Dispatched together, not one after another: the records service batches the
176
231
  // requests issued in one tick into one POST, and a page's configs are
177
232
  // independent of each other. Order is preserved in the result.
@@ -190,5 +245,5 @@ export async function executeFetchConfigs(configs, { content, fetch = null, dev
190
245
  /** Resolve and execute in one call: what a host passes the isolate as `fetchedData`. */
191
246
  export async function prefetchPageData({ content, route, locale = null, fetch = null, dev = false, prerender = 'always' }) {
192
247
  const configs = resolvePageFetchConfigs(content, route, { locale })
193
- return executeFetchConfigs(configs, { content, fetch, dev, prerender })
248
+ return executeFetchConfigs(configs, { content, fetch, dev, prerender, locale })
194
249
  }