@uniweb/core 0.18.0 → 0.20.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.
@@ -15,46 +15,56 @@
15
15
  */
16
16
 
17
17
  import { isFetchRefinement, resolveFetchConfigs } from './fetch-config.js'
18
+ import { fillRoutePattern } from './route-match.js'
19
+ import { sortRecords } from './sort.js'
18
20
 
19
21
  /**
20
22
  * A fetch config's binding key — the `content.data.<key>` a component reads.
21
23
  * `as` is the name; `schema` is what it was called until 2026-09-02 and still
22
24
  * arrives on any payload published before then. See `fetch-config.js`.
23
25
  */
24
- const bindingKeyOf = (cfg) => cfg?.as ?? cfg?.schema
26
+ const bindingKeyOf = (cfg) => cfg?.as
25
27
  import { buildDetailConfig } from './detail-url.js'
26
28
 
27
29
  /**
28
30
  * Is `block.fetch` a per-instance refinement of the ancestor's fetch config
29
- * rather than a new source? The canonical spelling is `refine: true`; the
30
- * legacy spelling `inherit: true` is still honored for one release with a
31
- * dev-mode warning.
31
+ * rather than a new source? The spelling is `refine: true`.
32
32
  *
33
33
  * The predicate itself lives in `./fetch-config.js` with the rest of the
34
34
  * cascade rule; this alias keeps the local call sites reading as they did.
35
35
  */
36
36
  const isRefinement = isFetchRefinement
37
37
 
38
- let inheritDeprecationWarned = false
39
- function warnInheritDeprecation(block) {
40
- if (inheritDeprecationWarned) return
41
- inheritDeprecationWarned = true
42
- // Dev-only; production builds typically strip console.warn. We gate on
43
- // the presence of the deprecated key and fire once per process.
44
- console.warn(
45
- "[uniweb] 'fetch: { inherit: true }' is deprecated; rename to 'fetch: { refine: true }'. " +
46
- 'Accepted for one release; will be removed in the next minor. ' +
47
- `First seen on block ${block?.id ?? '(unknown)'} of page ${block?.page?.route ?? '(unknown)'}.`
48
- )
38
+ /**
39
+ * `fetch: { inherit: true }` was the earlier spelling of `refine: true`, kept
40
+ * "for one release" from April 2026 and removed on 2026-09-02. It is refused
41
+ * rather than ignored: ignored, the declaration would read as a source with no
42
+ * location and the block would render empty with nothing to say why. Dev
43
+ * throws, so a page does not render on the old spelling; production logs once
44
+ * and the caller drops the declaration, so the block receives the cascaded
45
+ * data unrefined.
46
+ */
47
+ let inheritRefusalLogged = false
48
+ function refuseInheritAlias(block, dev) {
49
+ const message =
50
+ "[uniweb] 'fetch: { inherit: true }' is no longer accepted; write 'fetch: { refine: true }'. " +
51
+ `Seen on block ${block?.id ?? '(unknown)'} of page ${block?.page?.route ?? '(unknown)'}.`
52
+ if (dev) throw new Error(message)
53
+ if (inheritRefusalLogged) return
54
+ inheritRefusalLogged = true
55
+ console.error(message)
49
56
  }
50
57
 
51
58
  export default class EntityStore {
52
59
  /**
53
60
  * @param {Object} options
54
61
  * @param {import('./website.js').default} options.website
62
+ * @param {boolean} [options.dev=false] - dev mode: a retired spelling throws
63
+ * instead of logging once.
55
64
  */
56
- constructor({ website }) {
65
+ constructor({ website, dev = false }) {
57
66
  this.website = website
67
+ this.dev = dev
58
68
  Object.seal(this)
59
69
  }
60
70
 
@@ -77,18 +87,13 @@ export default class EntityStore {
77
87
  return null
78
88
  }
79
89
 
90
+ /**
91
+ * A refine block's `order: { orderBy, sortOrder }` — the same one-key sort the
92
+ * build and the fetcher fallback run (`./sort.js`), so the three cannot drift.
93
+ */
80
94
  _sortItems(items, order) {
81
- if (!order?.orderBy || !Array.isArray(items) || items.length === 0) return items
82
- const { orderBy, sortOrder = 'ASC' } = order
83
- const desc = sortOrder === 'DESC'
84
- return [...items].sort((a, b) => {
85
- const av = a[orderBy] ?? ''
86
- const bv = b[orderBy] ?? ''
87
- const cmp = typeof av === 'string' && typeof bv === 'string'
88
- ? av.localeCompare(bv)
89
- : (av > bv ? 1 : av < bv ? -1 : 0)
90
- return desc ? -cmp : cmp
91
- })
95
+ if (!order?.orderBy) return items
96
+ return sortRecords(items, { field: order.orderBy, desc: order.sortOrder === 'DESC' })
92
97
  }
93
98
 
94
99
  /**
@@ -116,16 +121,25 @@ export default class EntityStore {
116
121
  * exists to prevent.
117
122
  */
118
123
  _findFetchConfigs(block, requested) {
119
- if (block.fetch?.inherit === true && block.fetch?.refine !== true) {
120
- warnInheritDeprecation(block)
124
+ let blockFetch = block.fetch
125
+ if (blockFetch?.inherit !== undefined) {
126
+ refuseInheritAlias(block, this.dev)
127
+ blockFetch = null
121
128
  }
122
129
 
123
130
  const page = block.page
124
131
  const website = block.website
132
+ const dynamicContext = block.dynamicContext || page?.dynamicContext
133
+ // The route's variables, for a query that binds `:path` / `:dir` / `:slug`.
134
+ // A baked page carries `params` when it has more than the one capture;
135
+ // otherwise the capture itself is the only variable.
136
+ const variables = dynamicContext
137
+ ? (dynamicContext.params ?? { [dynamicContext.paramName]: dynamicContext.paramValue })
138
+ : null
125
139
 
126
140
  return resolveFetchConfigs(
127
141
  [
128
- block.fetch && !isRefinement(block.fetch) ? block.fetch : null,
142
+ blockFetch && !isRefinement(blockFetch) ? blockFetch : null,
129
143
  page?.fetch,
130
144
  page?.parent?.fetch,
131
145
  website?.config?.fetch,
@@ -142,6 +156,7 @@ export default class EntityStore {
142
156
  // local dev, which is why `resolveQuerySource` treats absence as
143
157
  // the ordinary case and reads the compiled artifact without comment.
144
158
  records: website?.config?.records ?? null,
159
+ variables,
145
160
  },
146
161
  )
147
162
  }
@@ -244,6 +259,16 @@ export default class EntityStore {
244
259
  } else {
245
260
  allCached = false
246
261
  }
262
+ } else if (isRouteQuery && cfg.door) {
263
+ // A question door: the record's own answer is cached under its own key.
264
+ const detailCfg = this._buildDetailConfig(cfg, dynamicContext)
265
+ const detailCached = detailCfg ? dispatcher?.peek(detailCfg, ctx) : null
266
+ if (detailCached) {
267
+ const answer = Array.isArray(detailCached.data) ? detailCached.data : (detailCached.data ? [detailCached.data] : [])
268
+ data[schema] = answer.slice(0, 1)
269
+ } else {
270
+ allCached = false
271
+ }
247
272
  } else if (isRouteQuery) {
248
273
  // Detail page: deliver the focused record as a length-1 array under the
249
274
  // query key. A deferred/remote query fetches the full per-record;
@@ -258,9 +283,15 @@ export default class EntityStore {
258
283
  if (!match) {
259
284
  data[schema] = []
260
285
  } else if (cfg.detail) {
261
- const detailCfg = this._buildDetailConfig(cfg, dynamicContext)
286
+ // Held in full already? Then it IS the record — no detail probe.
287
+ // The list is materialized from the record index, so `match` is the
288
+ // record at its latest depth; the index says which depth that is.
289
+ const held = heldInFull(dispatcher, match)
290
+ const detailCfg = held ? null : this._buildDetailConfig(cfg, { ...dynamicContext, record: match })
262
291
  const detailCached = detailCfg ? dispatcher?.peek(detailCfg, ctx) : null
263
- if (detailCfg && detailCached) {
292
+ if (held) {
293
+ data[schema] = [held]
294
+ } else if (detailCfg && detailCached) {
264
295
  data[schema] = [detailCached.data]
265
296
  } else if (detailCfg) {
266
297
  allCached = false
@@ -295,13 +326,23 @@ export default class EntityStore {
295
326
  * Async fetch — dispatches missing configs through the FetcherDispatcher
296
327
  * and assembles the result. List-first detail ordering preserved.
297
328
  *
329
+ * ⛔ A FAILED FETCH DELIVERS NOTHING UNDER ITS KEY, AND SAYS SO. Until 2026-09-04
330
+ * a failure wrote `[]` into `content.data` — the fetcher returns `{ data: [], error }`,
331
+ * `[]` is neither `undefined` nor `null`, and nothing read `error` — so a key
332
+ * whose request failed was indistinguishable from one that succeeded with no
333
+ * records, by the framework's own rule that `[]` is a value. Now the key is
334
+ * ABSENT from `data`, the message is on `errors[key]`, and in dev it is logged
335
+ * where the author is looking. A detail fetch that fails keeps the record the
336
+ * list already matched (the brief) rather than clobbering it.
337
+ *
298
338
  * @param {Object} [options]
299
339
  * @param {AbortSignal} [options.signal] - Forwarded to the dispatcher.
300
- * @returns {Promise<{ data: Object|null }>}
340
+ * @returns {Promise<{ data: Object|null, errors: Object|null }>} `data` keyed by
341
+ * binding key; `errors` keyed the same way, `null` when every fetch succeeded.
301
342
  */
302
343
  async fetch(block, meta, { signal } = {}) {
303
344
  const dispatcher = this.website?.fetcher
304
- if (!dispatcher) return { data: null }
345
+ if (!dispatcher) return { data: null, errors: null }
305
346
 
306
347
  let requested = this._getRequestedSchemas(meta)
307
348
  if (requested === null && block.fetch) {
@@ -309,10 +350,10 @@ export default class EntityStore {
309
350
  const schemas = blockFetchList.filter(bindingKeyOf).map(bindingKeyOf)
310
351
  if (schemas.length > 0) requested = schemas
311
352
  }
312
- if (requested === null) return { data: null }
353
+ if (requested === null) return { data: null, errors: null }
313
354
 
314
355
  const configs = this._findFetchConfigs(block, requested)
315
- if (configs.size === 0) return { data: null }
356
+ if (configs.size === 0) return { data: null, errors: null }
316
357
 
317
358
  const dynamicContext = block.dynamicContext || block.page?.dynamicContext
318
359
  const inheritDetail = this._shouldInheritDetail(meta, block)
@@ -321,7 +362,12 @@ export default class EntityStore {
321
362
  const ctx = this._ctx(block, { signal })
322
363
 
323
364
  const data = {}
365
+ const errors = {}
324
366
  const parallelFetches = []
367
+ const fail = (key, cfg, message) => {
368
+ errors[key] = message
369
+ reportFetchFailure(this.dev, block, key, cfg, message)
370
+ }
325
371
 
326
372
  const routeSchema = dynamicContext?.schema
327
373
 
@@ -332,6 +378,10 @@ export default class EntityStore {
332
378
  let records = peekArray(dispatcher, cfg, ctx)
333
379
  if (records === null) {
334
380
  const result = await dispatcher.dispatch(cfg, ctx)
381
+ if (result?.error) {
382
+ fail(schema, cfg, result.error)
383
+ continue
384
+ }
335
385
  records = Array.isArray(result?.data) ? result.data : null
336
386
  }
337
387
  const { paramName, paramValue } = dynamicContext
@@ -340,6 +390,25 @@ export default class EntityStore {
340
390
  : (records ?? [])
341
391
  if (order) filtered = this._sortItems(filtered, order)
342
392
  data[schema] = limit && Array.isArray(filtered) ? filtered.slice(0, limit) : filtered
393
+ } else if (isRouteQuery && cfg.door) {
394
+ // ⭐ A QUESTION DOOR needs no list to find the record: the record is the
395
+ // same question narrowed by the route's handle, so list and record are
396
+ // asked together — one round trip, and no client-side scan gating the
397
+ // fetch (F13, the live half). The list is asked too, because sections
398
+ // beside the record read it (`refine: true, detail: false`) and the
399
+ // index files its briefs; the record's own answer is the answer.
400
+ const detailCfg = this._buildDetailConfig(cfg, dynamicContext)
401
+ parallelFetches.push(dispatcher.dispatch(cfg, ctx).then((result) => {
402
+ if (result?.error) fail(schema, cfg, result.error)
403
+ }))
404
+ parallelFetches.push(dispatcher.dispatch(detailCfg, ctx).then((result) => {
405
+ if (result?.error) {
406
+ fail(schema, detailCfg, result.error)
407
+ return
408
+ }
409
+ const answer = Array.isArray(result?.data) ? result.data : (result?.data ? [result.data] : [])
410
+ data[schema] = answer.slice(0, 1) // a route resolves to ONE; `[]` is not found
411
+ }))
343
412
  } else if (isRouteQuery) {
344
413
  // Detail page: focused record as a length-1 array under the query key.
345
414
  const { paramName, paramValue } = dynamicContext
@@ -347,6 +416,10 @@ export default class EntityStore {
347
416
  let records = peekArray(dispatcher, cfg, ctx)
348
417
  if (records === null) {
349
418
  const result = await dispatcher.dispatch(cfg, ctx)
419
+ if (result?.error) {
420
+ fail(schema, cfg, result.error)
421
+ continue
422
+ }
350
423
  records = Array.isArray(result?.data) ? result.data : null
351
424
  }
352
425
 
@@ -359,11 +432,25 @@ export default class EntityStore {
359
432
  continue
360
433
  }
361
434
 
362
- if (cfg.detail) {
363
- const detailCfg = this._buildDetailConfig(cfg, dynamicContext)
435
+ const held = cfg.detail ? heldInFull(dispatcher, match) : null
436
+ if (held) {
437
+ // R1: the record index holds it in full — a detail fetch would only
438
+ // re-fetch what the page already has.
439
+ data[schema] = [held]
440
+ } else if (cfg.detail) {
441
+ const detailCfg = this._buildDetailConfig(cfg, { ...dynamicContext, record: match })
364
442
  if (detailCfg) {
365
443
  parallelFetches.push(
366
444
  dispatcher.dispatch(detailCfg, ctx).then((result) => {
445
+ // The list already matched the record, so the brief is a HELD
446
+ // value: a failed detail fetch keeps it and reports, rather than
447
+ // delivering `[[]]` — which is what `result.data ?? match` did,
448
+ // because a failure's `data` is `[]`, not null.
449
+ if (result?.error) {
450
+ fail(schema, detailCfg, result.error)
451
+ data[schema] = [match]
452
+ return
453
+ }
367
454
  const record = (result?.data !== undefined && result?.data !== null)
368
455
  ? result.data
369
456
  : match
@@ -379,8 +466,15 @@ export default class EntityStore {
379
466
  } else {
380
467
  parallelFetches.push(
381
468
  dispatcher.dispatch(cfg, ctx).then((result) => {
469
+ if (result?.error) {
470
+ fail(schema, cfg, result.error)
471
+ return
472
+ }
382
473
  if (result?.data !== undefined && result?.data !== null) {
383
- data[schema] = result.data
474
+ // The same refine `order` the sync path applies (`resolve`), so a
475
+ // block sorts identically on a cache hit and on the fetch that
476
+ // filled it — it did not until 2026-09-04.
477
+ data[schema] = order ? this._sortItems(result.data, order) : result.data
384
478
  }
385
479
  })
386
480
  )
@@ -389,10 +483,45 @@ export default class EntityStore {
389
483
 
390
484
  if (parallelFetches.length > 0) await Promise.all(parallelFetches)
391
485
  this._applyDetailRoutes(data, configs, block.website)
392
- return { data }
486
+ return { data, errors: Object.keys(errors).length ? errors : null }
393
487
  }
394
488
  }
395
489
 
490
+ /**
491
+ * Say where a fetch failed, once per key per page, where the author is looking.
492
+ *
493
+ * Dev only: production has no reader for a console line, and the page has the
494
+ * structured answer already — the key is absent from `data`, the message is on
495
+ * `errors[key]`, and the runtime sets `block.dataError`. What must never happen
496
+ * again is the third option this path used to take: an empty array under the
497
+ * key, and silence.
498
+ */
499
+ const reportedFailures = new Set()
500
+ function reportFetchFailure(dev, block, key, cfg, message) {
501
+ if (!dev) return
502
+ const where = cfg?.endpoint || cfg?.url || cfg?.path || '(no address)'
503
+ const page = block?.page?.route ?? '(unknown page)'
504
+ const memo = `${page}::${key}::${where}`
505
+ if (reportedFailures.has(memo)) return
506
+ reportedFailures.add(memo)
507
+ console.error(
508
+ `[uniweb] fetch for content.data.${key} failed on ${page} (${where}): ${message}. ` +
509
+ `The key is left absent — not [] — and block.dataError carries this message.`
510
+ )
511
+ }
512
+
513
+ /**
514
+ * The record the index holds in FULL for a list match, or null — the R1 gate.
515
+ * A record with no identity (`$uuid`) is never indexed, so the answer for it is
516
+ * null and the detail fetch proceeds as before.
517
+ */
518
+ function heldInFull(dispatcher, match) {
519
+ const id = match?.$uuid
520
+ if (typeof id !== 'string' || !id || typeof dispatcher?.peekRecord !== 'function') return null
521
+ const held = dispatcher.peekRecord(id)
522
+ return held?.depth === 'full' ? held.record : null
523
+ }
524
+
396
525
  /**
397
526
  * Sync-peek helper: return the cached array for a config, or null on miss.
398
527
  */
@@ -411,17 +540,12 @@ function peekArray(dispatcher, cfg, ctx) {
411
540
  * (the file lane bakes one via the query processor) is returned untouched. A
412
541
  * `:param` with no matching record field → no `route` (graceful; degrades to the
413
542
  * component's own fallback rather than emitting a broken href).
543
+ *
544
+ * ⭐ The encoding is `fillRoutePattern`'s, shared with the build's bake, so the
545
+ * two producers of `item.route` agree — they did not (F14, 2026-09-04).
414
546
  */
415
547
  function addDetailRoute(item, template) {
416
548
  if (!item || typeof item !== 'object' || item.route !== undefined) return item
417
- let missing = false
418
- const route = template.replace(/:(\w+)/g, (_, name) => {
419
- const value = item[name]
420
- if (value == null) {
421
- missing = true
422
- return ''
423
- }
424
- return encodeURIComponent(String(value))
425
- })
426
- return missing ? item : { ...item, route }
549
+ const route = fillRoutePattern(template, item)
550
+ return route === null ? item : { ...item, route }
427
551
  }
@@ -28,21 +28,22 @@
28
28
  */
29
29
 
30
30
  import { queryDataUrl, isDataUrl, recordDataUrl } from './data-paths.js'
31
- import { resolveQueryAddress, resolveRecordAddressPattern } from './query-address.js'
31
+ import { resolveQueryAddress, resolveRecordAddressPattern, resolveQueryDoor } from './query-address.js'
32
32
 
33
33
  /**
34
34
  * Is this fetch declaration a per-instance *refinement* of an ancestor's
35
35
  * config rather than a new source of its own?
36
36
  *
37
- * The canonical spelling is `refine: true`. The legacy spelling `inherit: true`
38
- * is still honored; callers that want to warn about it should test for the key
39
- * themselves this predicate stays silent so it is safe in any environment.
37
+ * The spelling is `refine: true`. Its earlier alias, `inherit: true`, was
38
+ * accepted with a warning from April 2026 and removed on 2026-09-02: the build
39
+ * refuses it with an error, and `EntityStore` refuses it in dev. This predicate
40
+ * stays silent so it is safe in any environment.
40
41
  *
41
42
  * @param {Object} cfg - a fetch declaration
42
43
  * @returns {boolean}
43
44
  */
44
45
  export function isFetchRefinement(cfg) {
45
- return cfg?.refine === true || cfg?.inherit === true
46
+ return cfg?.refine === true
46
47
  }
47
48
 
48
49
  /**
@@ -99,6 +100,10 @@ function localizeConfig(cfg, locale, defaultLocale) {
99
100
  function applyDeferredDetail(cfg, queries, records) {
100
101
  if (cfg.detail !== undefined) return cfg
101
102
 
103
+ // A question door answers a RECORD by the same question narrowed to it, so
104
+ // every door config has a detail source; `buildDetailConfig` composes it.
105
+ if (cfg.door) return { ...cfg, detail: true }
106
+
102
107
  // ⭐ A lane's record address is injected whenever the lane declares one —
103
108
  // NOT only for a `deferred:` query, and the difference is load-bearing.
104
109
  //
@@ -169,9 +174,32 @@ function applyDeferredDetail(cfg, queries, records) {
169
174
  * `query`, ignoring any `path`), so the two agree rather than disagreeing on a
170
175
  * shape nobody hand-writes.
171
176
  */
172
- function resolveQuerySource(cfg, records) {
177
+ function resolveQuerySource(cfg, records, { queries = null, locale = null, defaultLocale = null } = {}) {
173
178
  if (typeof cfg.query !== 'string' || cfg.query.length === 0) return cfg
174
179
 
180
+ // ⭐ THE QUESTION DOOR FIRST. A host that answers questions gets the whole
181
+ // query — `schema`, `scope`, `where`, `sort`, `limit`, `depth` — and composes
182
+ // no per-query address at all (the records door's contract, §2). It
183
+ // needs the query's MODEL REF, which lives on the site's `config.queries`
184
+ // declaration; a payload that carries the door but not the declaration
185
+ // cannot ask, and falls through to the address door below. ⚠️ Dark until a
186
+ // host stamps the door; see `resolveQueryDoor`.
187
+ const door = resolveQueryDoor(records, locale ?? defaultLocale)
188
+ if (door) {
189
+ const decl = queries && typeof queries === 'object' ? queries[cfg.query] : null
190
+ const schema = typeof decl?.schema === 'string' && decl.schema ? decl.schema : null
191
+ if (schema) {
192
+ const { path, url, ...rest } = cfg
193
+ const asked = { ...rest, door, schema }
194
+ // A saved query's own narrowing applies unless the fetch overrides it.
195
+ if (asked.scope === undefined && typeof decl.scope === 'string') asked.scope = decl.scope
196
+ if (asked.where === undefined && decl.where && typeof decl.where === 'object') asked.where = decl.where
197
+ if (asked.sort === undefined && decl.sort !== undefined && decl.sort !== null) asked.sort = decl.sort
198
+ if (asked.limit === undefined && typeof decl.limit === 'number' && decl.limit > 0) asked.limit = decl.limit
199
+ return asked
200
+ }
201
+ }
202
+
175
203
  const endpoint = resolveQueryAddress(cfg.query, records)
176
204
  if (endpoint) {
177
205
  // Drop the transitional `path`: two addresses on one request is an
@@ -185,23 +213,31 @@ function resolveQuerySource(cfg, records) {
185
213
  /**
186
214
  * The binding key of a fetch config — the `content.data.<key>` a component reads.
187
215
  *
188
- * ⭐ **`as` is the name; `schema` is what it was called until 2026-09-02.** The old
189
- * spelling still arrives on every payload published before then and on any seed
190
- * built against an older release, so this is not a deprecation window it is a
191
- * permanent reader of stored data. *(Renaming it was not cosmetic: `schema`
192
- * already means the MODEL REF one record over, on a `queries` declaration, and
193
- * one word for two things is what let a binding-key override silently break
194
- * detail resolution.)*
216
+ * ⭐ **`as` is the name.** It was called `schema` until 2026-09-02, which
217
+ * collided with the MODEL REF of the same name on a `queries` declaration — one
218
+ * word for two things, which is what let a binding-key override silently break
219
+ * detail resolution.
220
+ *
221
+ * **The `?? cfg.schema` alias that briefly rode alongside it is GONE**
222
+ * (2026-09-02, ruled by Diego: *"they are not in prod so I saw no point in it.
223
+ * We need to move forward."*). It was removed in the same pass as frontend's and
224
+ * hosting's, and every producer here now emits `as` alone.
195
225
  *
196
- * Do not "simplify" this to `cfg.as`. The `??` here is earned — it spans
197
- * stored payloads we cannot rewrite unlike the one deleted from
198
- * `applyDeferredDetail`, which spanned two producers we control.
226
+ * ⚠️ **The consequence, stated plainly: a payload synced before that carries
227
+ * `schema` and resolves to NOTHING here.** No data, no error — this is the
228
+ * silent class, and the remedy is a re-push, not a code change. If a
229
+ * seed or a dev site renders a section empty, check what its stored payload
230
+ * spells before looking anywhere else.
231
+ *
232
+ * ⭐ The one place `schema` is still read is `parseFetchConfig` in
233
+ * `@uniweb/build`, and it is a different thing: normalizing an AUTHOR's older
234
+ * spelling in a content file at the boundary, so that one name travels inside.
199
235
  *
200
236
  * @param {Object} cfg
201
237
  * @returns {string|undefined}
202
238
  */
203
239
  function bindingKey(cfg) {
204
- return cfg?.as ?? cfg?.schema
240
+ return cfg?.as
205
241
  }
206
242
 
207
243
  /**
@@ -228,6 +264,10 @@ function bindingKey(cfg) {
228
264
  * @param {Object|null} [options.records] - the site's `config.records`, a host's
229
265
  * live-records lane. Absent means the compiled artifact answers, which is
230
266
  * the whole of what a site with no backend needs.
267
+ * @param {Object|null} [options.variables] - the route's variables on a template
268
+ * page (`{ path, dir, slug }` under `[...path]`, the capture under `[slug]`);
269
+ * a `:path` / `:dir` / `:slug` placeholder in `where:` or `scope:` binds to
270
+ * them, and an unbound one drops its clause. Null off a template page.
231
271
  * @returns {Map<string, Object>} schema name → resolved config
232
272
  */
233
273
  export function resolveFetchConfigs(sources, options = {}) {
@@ -237,6 +277,7 @@ export function resolveFetchConfigs(sources, options = {}) {
237
277
  defaultLocale = null,
238
278
  queries = null,
239
279
  records = null,
280
+ variables = null,
240
281
  } = options
241
282
 
242
283
  const configs = new Map()
@@ -252,11 +293,144 @@ export function resolveFetchConfigs(sources, options = {}) {
252
293
  if (!collectAll && !schemas.includes(key)) continue
253
294
  // Address first: localization and deferred-detail both key on `path`,
254
295
  // which a query ref does not have until this runs.
255
- const sourced = resolveQuerySource(cfg, records)
296
+ const sourced = resolveQuerySource(cfg, records, { queries, locale, defaultLocale })
256
297
  const localized = localizeConfig(sourced, locale, defaultLocale)
257
- configs.set(key, applyDeferredDetail(localized, queries, records))
298
+ const bound = foldScope(bindRouteVariables(localized, variables))
299
+ configs.set(key, stampDepthAndLocale(applyDeferredDetail(bound, queries, records), locale, defaultLocale))
258
300
  }
259
301
  }
260
302
 
261
303
  return configs
262
304
  }
305
+
306
+ /**
307
+ * The three route variables a query may reference, and only these — `:path`,
308
+ * `:dir`, `:slug` — as a VALUE in `where:` or as the whole `scope:`. Ruled
309
+ * 2026-09-04 [Diego]: standard names, never author-chosen; a placeholder fills
310
+ * a value, never a key or an operator, never `schema`.
311
+ */
312
+ const ROUTE_VARIABLE = /^:(path|dir|slug)$/
313
+
314
+ /**
315
+ * Bind a query's route placeholders from the page's variables.
316
+ *
317
+ * ⭐ UNBOUND ⇒ THE CLAUSE DROPS. That is what lets ONE saved query serve both
318
+ * the list page and the detail page: `where: { tag: :dir }` narrows on
319
+ * `/blog/rust/my-post` and vanishes on `/blog`, where there is no `:dir`. A
320
+ * variable bound to an empty string (`:dir` on a single-segment capture) is
321
+ * bound, and binds the empty value. Backend's records door states the same
322
+ * rule from its side (the records door's contract, §6b).
323
+ *
324
+ * ⚠️ The price, stated where it is paid: a MISSPELLED variable is byte-identical
325
+ * to an intentional list page. Only an authoring surface can catch that; this
326
+ * function cannot.
327
+ *
328
+ * @param {Object} cfg - a resolved config
329
+ * @param {Object|null} variables - `{ path, dir, slug, … }` from the route, or null off a template page
330
+ * @returns {Object} the config, with placeholders bound or their clauses dropped
331
+ */
332
+ function bindRouteVariables(cfg, variables) {
333
+ let out = cfg
334
+ if (typeof cfg.scope === 'string' && ROUTE_VARIABLE.test(cfg.scope)) {
335
+ const name = cfg.scope.slice(1)
336
+ const value = variables?.[name]
337
+ const { scope, ...rest } = out
338
+ out = value === undefined || value === null ? rest : { ...rest, scope: String(value) }
339
+ }
340
+ if (cfg.where && typeof cfg.where === 'object') {
341
+ const bound = bindWhere(cfg.where, variables)
342
+ if (bound !== cfg.where) {
343
+ const { where, ...rest } = out
344
+ out = bound === null ? rest : { ...rest, where: bound }
345
+ }
346
+ }
347
+ return out
348
+ }
349
+
350
+ /** Walk a where-object: bind `:var` VALUES, drop clauses whose variable is unbound. */
351
+ function bindWhere(where, variables) {
352
+ if (Array.isArray(where)) {
353
+ let changed = false
354
+ const next = []
355
+ for (const item of where) {
356
+ const b = item && typeof item === 'object' ? bindWhere(item, variables) : item
357
+ if (b !== item) changed = true
358
+ if (b !== null) next.push(b)
359
+ }
360
+ if (!changed) return where
361
+ return next.length ? next : null
362
+ }
363
+ if (!where || typeof where !== 'object') return where
364
+ let changed = false
365
+ const next = {}
366
+ for (const [key, value] of Object.entries(where)) {
367
+ if (typeof value === 'string' && ROUTE_VARIABLE.test(value)) {
368
+ const bound = variables?.[value.slice(1)]
369
+ changed = true
370
+ if (bound === undefined || bound === null) continue // unbound ⇒ drop
371
+ next[key] = String(bound)
372
+ continue
373
+ }
374
+ if (value && typeof value === 'object') {
375
+ const b = bindWhere(value, variables)
376
+ if (b !== value) changed = true
377
+ if (b === null) continue // an operator object or sub-predicate emptied out
378
+ next[key] = b
379
+ continue
380
+ }
381
+ next[key] = value
382
+ }
383
+ if (!changed) return where
384
+ return Object.keys(next).length ? next : null
385
+ }
386
+
387
+ /**
388
+ * `scope:` on a lane that cannot be ASKED — the compiled file, an address door —
389
+ * is the same question as `where: { path: { under: scope } }`: a record's
390
+ * `path` is the folder `records.yml` placed it in, and the evaluator's `under`
391
+ * is segment-aware containment. Folding it keeps the language the INTERSECTION
392
+ * of both lanes: an author
393
+ * writes `scope: :dir` once and it means the same branch on a static site and
394
+ * on a door that takes `scope` natively. A config addressed to a question door
395
+ * (`door`) keeps `scope` as the door's own field.
396
+ */
397
+ function foldScope(cfg) {
398
+ if (typeof cfg.scope !== 'string' || cfg.scope === '' || cfg.door) return cfg
399
+ const { scope, ...rest } = cfg
400
+ const under = { path: { under: scope } }
401
+ const where = cfg.where && typeof cfg.where === 'object' && Object.keys(cfg.where).length
402
+ ? { and: [cfg.where, under] }
403
+ : under
404
+ return { ...rest, where }
405
+ }
406
+
407
+ /**
408
+ * Say what a resolved config will GET, so the record index can file it.
409
+ *
410
+ * `depth` — `brief` when the config has a per-record source (`detail`), because
411
+ * a list with a separate record address is a list of partial records: a live
412
+ * lane answers a list at brief depth and a record in full, and a `deferred:`
413
+ * query's compiled file is the stripped list. `full` otherwise. An explicit
414
+ * `depth` on the config wins (a question door's client sets it).
415
+ *
416
+ * `locale` — stamped on a LIVE-lane config only. A compiled path already carries
417
+ * its locale (`/fr/data/…`), but a live address does not, and two locales'
418
+ * records must not share a cache entry (F1). Absent on the default locale, so a
419
+ * site with one language sees no new field.
420
+ */
421
+ function stampDepthAndLocale(cfg, locale, defaultLocale) {
422
+ let out = cfg
423
+ if (out.depth !== 'brief' && out.depth !== 'full') {
424
+ out = { ...out, depth: out.detail ? 'brief' : 'full' }
425
+ }
426
+ if (out.endpoint && locale && locale !== defaultLocale && out.locale === undefined) {
427
+ out = { ...out, locale }
428
+ }
429
+ // A door is asked in exactly one locale — it is in the route — so the config
430
+ // carries it whatever the locale is; two locales' answers never share an entry.
431
+ if (out.door && out.locale === undefined) {
432
+ const asked = locale ?? defaultLocale
433
+ if (asked) out = { ...out, locale: asked }
434
+ }
435
+ return out
436
+ }