@uniweb/core 0.19.0 → 0.21.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,6 +15,8 @@
15
15
  */
16
16
 
17
17
  import { isFetchRefinement, resolveFetchConfigs } from './fetch-config.js'
18
+ import { fillRoutePattern, routeParamValue } 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.
@@ -85,18 +87,13 @@ export default class EntityStore {
85
87
  return null
86
88
  }
87
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
+ */
88
94
  _sortItems(items, order) {
89
- if (!order?.orderBy || !Array.isArray(items) || items.length === 0) return items
90
- const { orderBy, sortOrder = 'ASC' } = order
91
- const desc = sortOrder === 'DESC'
92
- return [...items].sort((a, b) => {
93
- const av = a[orderBy] ?? ''
94
- const bv = b[orderBy] ?? ''
95
- const cmp = typeof av === 'string' && typeof bv === 'string'
96
- ? av.localeCompare(bv)
97
- : (av > bv ? 1 : av < bv ? -1 : 0)
98
- return desc ? -cmp : cmp
99
- })
95
+ if (!order?.orderBy) return items
96
+ return sortRecords(items, { field: order.orderBy, desc: order.sortOrder === 'DESC' })
100
97
  }
101
98
 
102
99
  /**
@@ -132,6 +129,13 @@ export default class EntityStore {
132
129
 
133
130
  const page = block.page
134
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
135
139
 
136
140
  return resolveFetchConfigs(
137
141
  [
@@ -152,6 +156,7 @@ export default class EntityStore {
152
156
  // local dev, which is why `resolveQuerySource` treats absence as
153
157
  // the ordinary case and reads the compiled artifact without comment.
154
158
  records: website?.config?.records ?? null,
159
+ variables,
155
160
  },
156
161
  )
157
162
  }
@@ -247,13 +252,23 @@ export default class EntityStore {
247
252
  const { paramName, paramValue } = dynamicContext
248
253
  const items = cached.data
249
254
  let filtered = Array.isArray(items)
250
- ? items.filter((item) => String(item[paramName]) !== String(paramValue))
255
+ ? items.filter((item) => String(routeParamValue(item, paramName)) !== String(paramValue))
251
256
  : items
252
257
  if (order) filtered = this._sortItems(filtered, order)
253
258
  data[schema] = limit && Array.isArray(filtered) ? filtered.slice(0, limit) : filtered
254
259
  } else {
255
260
  allCached = false
256
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
+ }
257
272
  } else if (isRouteQuery) {
258
273
  // Detail page: deliver the focused record as a length-1 array under the
259
274
  // query key. A deferred/remote query fetches the full per-record;
@@ -263,14 +278,20 @@ export default class EntityStore {
263
278
  const { paramName, paramValue } = dynamicContext
264
279
  const items = cached.data
265
280
  const match = Array.isArray(items)
266
- ? items.find((item) => String(item[paramName]) === String(paramValue))
281
+ ? items.find((item) => String(routeParamValue(item, paramName)) === String(paramValue))
267
282
  : null
268
283
  if (!match) {
269
284
  data[schema] = []
270
285
  } else if (cfg.detail) {
271
- 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 })
272
291
  const detailCached = detailCfg ? dispatcher?.peek(detailCfg, ctx) : null
273
- if (detailCfg && detailCached) {
292
+ if (held) {
293
+ data[schema] = [held]
294
+ } else if (detailCfg && detailCached) {
274
295
  data[schema] = [detailCached.data]
275
296
  } else if (detailCfg) {
276
297
  allCached = false
@@ -305,13 +326,23 @@ export default class EntityStore {
305
326
  * Async fetch — dispatches missing configs through the FetcherDispatcher
306
327
  * and assembles the result. List-first detail ordering preserved.
307
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
+ *
308
338
  * @param {Object} [options]
309
339
  * @param {AbortSignal} [options.signal] - Forwarded to the dispatcher.
310
- * @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.
311
342
  */
312
343
  async fetch(block, meta, { signal } = {}) {
313
344
  const dispatcher = this.website?.fetcher
314
- if (!dispatcher) return { data: null }
345
+ if (!dispatcher) return { data: null, errors: null }
315
346
 
316
347
  let requested = this._getRequestedSchemas(meta)
317
348
  if (requested === null && block.fetch) {
@@ -319,10 +350,10 @@ export default class EntityStore {
319
350
  const schemas = blockFetchList.filter(bindingKeyOf).map(bindingKeyOf)
320
351
  if (schemas.length > 0) requested = schemas
321
352
  }
322
- if (requested === null) return { data: null }
353
+ if (requested === null) return { data: null, errors: null }
323
354
 
324
355
  const configs = this._findFetchConfigs(block, requested)
325
- if (configs.size === 0) return { data: null }
356
+ if (configs.size === 0) return { data: null, errors: null }
326
357
 
327
358
  const dynamicContext = block.dynamicContext || block.page?.dynamicContext
328
359
  const inheritDetail = this._shouldInheritDetail(meta, block)
@@ -331,7 +362,12 @@ export default class EntityStore {
331
362
  const ctx = this._ctx(block, { signal })
332
363
 
333
364
  const data = {}
365
+ const errors = {}
334
366
  const parallelFetches = []
367
+ const fail = (key, cfg, message) => {
368
+ errors[key] = message
369
+ reportFetchFailure(this.dev, block, key, cfg, message)
370
+ }
335
371
 
336
372
  const routeSchema = dynamicContext?.schema
337
373
 
@@ -342,14 +378,37 @@ export default class EntityStore {
342
378
  let records = peekArray(dispatcher, cfg, ctx)
343
379
  if (records === null) {
344
380
  const result = await dispatcher.dispatch(cfg, ctx)
381
+ if (result?.error) {
382
+ fail(schema, cfg, result.error)
383
+ continue
384
+ }
345
385
  records = Array.isArray(result?.data) ? result.data : null
346
386
  }
347
387
  const { paramName, paramValue } = dynamicContext
348
388
  let filtered = Array.isArray(records)
349
- ? records.filter((item) => String(item[paramName]) !== String(paramValue))
389
+ ? records.filter((item) => String(routeParamValue(item, paramName)) !== String(paramValue))
350
390
  : (records ?? [])
351
391
  if (order) filtered = this._sortItems(filtered, order)
352
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
+ }))
353
412
  } else if (isRouteQuery) {
354
413
  // Detail page: focused record as a length-1 array under the query key.
355
414
  const { paramName, paramValue } = dynamicContext
@@ -357,11 +416,15 @@ export default class EntityStore {
357
416
  let records = peekArray(dispatcher, cfg, ctx)
358
417
  if (records === null) {
359
418
  const result = await dispatcher.dispatch(cfg, ctx)
419
+ if (result?.error) {
420
+ fail(schema, cfg, result.error)
421
+ continue
422
+ }
360
423
  records = Array.isArray(result?.data) ? result.data : null
361
424
  }
362
425
 
363
426
  const match = records?.find(
364
- (item) => String(item[paramName]) === String(paramValue)
427
+ (item) => String(routeParamValue(item, paramName)) === String(paramValue)
365
428
  ) ?? null
366
429
 
367
430
  if (!match) {
@@ -369,11 +432,25 @@ export default class EntityStore {
369
432
  continue
370
433
  }
371
434
 
372
- if (cfg.detail) {
373
- 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 })
374
442
  if (detailCfg) {
375
443
  parallelFetches.push(
376
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
+ }
377
454
  const record = (result?.data !== undefined && result?.data !== null)
378
455
  ? result.data
379
456
  : match
@@ -389,8 +466,15 @@ export default class EntityStore {
389
466
  } else {
390
467
  parallelFetches.push(
391
468
  dispatcher.dispatch(cfg, ctx).then((result) => {
469
+ if (result?.error) {
470
+ fail(schema, cfg, result.error)
471
+ return
472
+ }
392
473
  if (result?.data !== undefined && result?.data !== null) {
393
- 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
394
478
  }
395
479
  })
396
480
  )
@@ -399,10 +483,45 @@ export default class EntityStore {
399
483
 
400
484
  if (parallelFetches.length > 0) await Promise.all(parallelFetches)
401
485
  this._applyDetailRoutes(data, configs, block.website)
402
- return { data }
486
+ return { data, errors: Object.keys(errors).length ? errors : null }
403
487
  }
404
488
  }
405
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
+
406
525
  /**
407
526
  * Sync-peek helper: return the cached array for a config, or null on miss.
408
527
  */
@@ -421,17 +540,12 @@ function peekArray(dispatcher, cfg, ctx) {
421
540
  * (the file lane bakes one via the query processor) is returned untouched. A
422
541
  * `:param` with no matching record field → no `route` (graceful; degrades to the
423
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).
424
546
  */
425
547
  function addDetailRoute(item, template) {
426
548
  if (!item || typeof item !== 'object' || item.route !== undefined) return item
427
- let missing = false
428
- const route = template.replace(/:(\w+)/g, (_, name) => {
429
- const value = item[name]
430
- if (value == null) {
431
- missing = true
432
- return ''
433
- }
434
- return encodeURIComponent(String(value))
435
- })
436
- return missing ? item : { ...item, route }
549
+ const route = fillRoutePattern(template, item)
550
+ return route === null ? item : { ...item, route }
437
551
  }
@@ -28,7 +28,7 @@
28
28
  */
29
29
 
30
30
  import { queryDataUrl, isDataUrl, recordDataUrl } from './data-paths.js'
31
- import { resolveQueryAddress, resolveRecordAddressPattern } from './query-address.js'
31
+ import { resolveQueryDoor } from './query-address.js'
32
32
 
33
33
  /**
34
34
  * Is this fetch declaration a per-instance *refinement* of an ancestor's
@@ -100,24 +100,10 @@ function localizeConfig(cfg, locale, defaultLocale) {
100
100
  function applyDeferredDetail(cfg, queries, records) {
101
101
  if (cfg.detail !== undefined) return cfg
102
102
 
103
- // A lane's record address is injected whenever the lane declares one
104
- // NOT only for a `deferred:` query, and the difference is load-bearing.
105
- //
106
- // A live lane answers a list request at brief depth and a record request in
107
- // full, so a detail page that filtered the list would render the brief and
108
- // silently miss the body. And it cannot fall back to the rule below: the
109
- // `deferred:` declaration lives in `config.queries`, which a host's
110
- // projection is not obliged to carry — so on such a host that rule can never
111
- // fire, and this is the only way a detail page reaches a whole record.
112
- if (cfg.endpoint) {
113
- // ⛔ `cfg.query`, not `cfg.query ?? cfg.schema`. The `??` was unreachable:
114
- // `endpoint` is set in exactly one place (`resolveQuerySource`), which returns
115
- // early unless `cfg.query` is a non-empty string — so reaching here proves it.
116
- // It read as a tolerance for two producer shapes and was really a vestige of
117
- // the build lane not emitting `query`, which it now does.
118
- const recordPattern = resolveRecordAddressPattern(cfg.query, records)
119
- if (recordPattern) return { ...cfg, detail: recordPattern }
120
- }
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
+
121
107
 
122
108
  // ⛔ **`config.queries` is keyed by QUERY NAME, so look it up by the query.**
123
109
  // This read `cfg.schema` — the BINDING KEY, which merely defaults to the query
@@ -155,10 +141,15 @@ function applyDeferredDetail(cfg, queries, records) {
155
141
  * The author names a query; this decides where its records live, and there are
156
142
  * exactly two answers:
157
143
  *
158
- * - a host declared a live lane (`config.records`) → an `endpoint`, final on
159
- * arrival, which the fetcher calls without composing anything further;
144
+ * - a host declared a question door (`config.records.query`) → a `door`, and
145
+ * the whole query goes to it (`schema` from the payload's `config.queries`;
146
+ * a door with no Model ref is a loud per-key error, never a fallthrough);
160
147
  * - nobody did → the `path` of the artifact the build emitted.
161
148
  *
149
+ * ⛔ There is no third answer. The ADDRESS door — `config.records.list` /
150
+ * `.record`, a GET lane the runtime evaluated the query over locally — was
151
+ * retired 2026-09-04 by ruling (`query-address.js` says why).
152
+ *
162
153
  * ⭐ The second is not a fallback in the apologetic sense. It is the answer for
163
154
  * every site with no backend, which is the framework's default rather than a
164
155
  * degraded mode — so an absent lane is silent, not warned.
@@ -170,15 +161,38 @@ function applyDeferredDetail(cfg, queries, records) {
170
161
  * `query`, ignoring any `path`), so the two agree rather than disagreeing on a
171
162
  * shape nobody hand-writes.
172
163
  */
173
- function resolveQuerySource(cfg, records) {
164
+ function resolveQuerySource(cfg, records, { queries = null, locale = null, defaultLocale = null } = {}) {
174
165
  if (typeof cfg.query !== 'string' || cfg.query.length === 0) return cfg
175
166
 
176
- const endpoint = resolveQueryAddress(cfg.query, records)
177
- if (endpoint) {
167
+ // THE QUESTION DOOR FIRST. A host that answers questions gets the whole
168
+ // query — `schema`, `scope`, `where`, `sort`, `limit`, `depth` — and composes
169
+ // no per-query address at all (the records door's contract, §2). It
170
+ // needs the query's MODEL REF, which lives on the site's `config.queries`
171
+ // declaration; a payload that carries the door but not the declaration
172
+ // cannot ask, and falls through to the address door below. ⚠️ Dark until a
173
+ // host stamps the door; see `resolveQueryDoor`.
174
+ const door = resolveQueryDoor(records, locale ?? defaultLocale)
175
+ if (door) {
176
+ const decl = queries && typeof queries === 'object' ? queries[cfg.query] : null
177
+ const schema = typeof decl?.schema === 'string' && decl.schema ? decl.schema : null
178
178
  // Drop the transitional `path`: two addresses on one request is an
179
179
  // ambiguity the fetcher would have to break by accident of field order.
180
180
  const { path, url, ...rest } = cfg
181
- return { ...rest, endpoint }
181
+ if (!schema) {
182
+ // ⛔ LOUD, not a fallthrough. A payload that stamps a door and carries no
183
+ // Model ref for the query cannot ask, and reading the compiled file
184
+ // instead would turn a producer defect into a 404 that names the wrong
185
+ // thing. The fetcher refuses a door request with no `schema` before any
186
+ // request is made, and the block's `dataError` says exactly this.
187
+ return { ...rest, door, schema: null }
188
+ }
189
+ const asked = { ...rest, door, schema }
190
+ // A saved query's own narrowing applies unless the fetch overrides it.
191
+ if (asked.scope === undefined && typeof decl.scope === 'string') asked.scope = decl.scope
192
+ if (asked.where === undefined && decl.where && typeof decl.where === 'object') asked.where = decl.where
193
+ if (asked.sort === undefined && decl.sort !== undefined && decl.sort !== null) asked.sort = decl.sort
194
+ if (asked.limit === undefined && typeof decl.limit === 'number' && decl.limit > 0) asked.limit = decl.limit
195
+ return asked
182
196
  }
183
197
  return { ...cfg, path: queryDataUrl(cfg.query) }
184
198
  }
@@ -237,6 +251,10 @@ function bindingKey(cfg) {
237
251
  * @param {Object|null} [options.records] - the site's `config.records`, a host's
238
252
  * live-records lane. Absent means the compiled artifact answers, which is
239
253
  * the whole of what a site with no backend needs.
254
+ * @param {Object|null} [options.variables] - the route's variables on a template
255
+ * page (`{ path, dir, slug }` under `[...path]`, the capture under `[slug]`);
256
+ * a `:path` / `:dir` / `:slug` placeholder in `where:` or `scope:` binds to
257
+ * them, and an unbound one drops its clause. Null off a template page.
240
258
  * @returns {Map<string, Object>} schema name → resolved config
241
259
  */
242
260
  export function resolveFetchConfigs(sources, options = {}) {
@@ -246,6 +264,7 @@ export function resolveFetchConfigs(sources, options = {}) {
246
264
  defaultLocale = null,
247
265
  queries = null,
248
266
  records = null,
267
+ variables = null,
249
268
  } = options
250
269
 
251
270
  const configs = new Map()
@@ -261,11 +280,140 @@ export function resolveFetchConfigs(sources, options = {}) {
261
280
  if (!collectAll && !schemas.includes(key)) continue
262
281
  // Address first: localization and deferred-detail both key on `path`,
263
282
  // which a query ref does not have until this runs.
264
- const sourced = resolveQuerySource(cfg, records)
283
+ const sourced = resolveQuerySource(cfg, records, { queries, locale, defaultLocale })
265
284
  const localized = localizeConfig(sourced, locale, defaultLocale)
266
- configs.set(key, applyDeferredDetail(localized, queries, records))
285
+ const bound = foldScope(bindRouteVariables(localized, variables))
286
+ configs.set(key, stampDepthAndLocale(applyDeferredDetail(bound, queries, records), locale, defaultLocale))
267
287
  }
268
288
  }
269
289
 
270
290
  return configs
271
291
  }
292
+
293
+ /**
294
+ * The three route variables a query may reference, and only these — `:path`,
295
+ * `:dir`, `:slug` — as a VALUE in `where:` or as the whole `scope:`. Ruled
296
+ * 2026-09-04 [Diego]: standard names, never author-chosen; a placeholder fills
297
+ * a value, never a key or an operator, never `schema`.
298
+ */
299
+ const ROUTE_VARIABLE = /^:(path|dir|slug)$/
300
+
301
+ /**
302
+ * Bind a query's route placeholders from the page's variables.
303
+ *
304
+ * ⭐ UNBOUND ⇒ THE CLAUSE DROPS. That is what lets ONE saved query serve both
305
+ * the list page and the detail page: `where: { tag: :dir }` narrows on
306
+ * `/blog/rust/my-post` and vanishes on `/blog`, where there is no `:dir`. A
307
+ * variable bound to an empty string (`:dir` on a single-segment capture) is
308
+ * bound, and binds the empty value. Backend's records door states the same
309
+ * rule from its side (the records door's contract, §6b).
310
+ *
311
+ * ⚠️ The price, stated where it is paid: a MISSPELLED variable is byte-identical
312
+ * to an intentional list page. Only an authoring surface can catch that; this
313
+ * function cannot.
314
+ *
315
+ * @param {Object} cfg - a resolved config
316
+ * @param {Object|null} variables - `{ path, dir, slug, … }` from the route, or null off a template page
317
+ * @returns {Object} the config, with placeholders bound or their clauses dropped
318
+ */
319
+ function bindRouteVariables(cfg, variables) {
320
+ let out = cfg
321
+ if (typeof cfg.scope === 'string' && ROUTE_VARIABLE.test(cfg.scope)) {
322
+ const name = cfg.scope.slice(1)
323
+ const value = variables?.[name]
324
+ const { scope, ...rest } = out
325
+ out = value === undefined || value === null ? rest : { ...rest, scope: String(value) }
326
+ }
327
+ if (cfg.where && typeof cfg.where === 'object') {
328
+ const bound = bindWhere(cfg.where, variables)
329
+ if (bound !== cfg.where) {
330
+ const { where, ...rest } = out
331
+ out = bound === null ? rest : { ...rest, where: bound }
332
+ }
333
+ }
334
+ return out
335
+ }
336
+
337
+ /** Walk a where-object: bind `:var` VALUES, drop clauses whose variable is unbound. */
338
+ function bindWhere(where, variables) {
339
+ if (Array.isArray(where)) {
340
+ let changed = false
341
+ const next = []
342
+ for (const item of where) {
343
+ const b = item && typeof item === 'object' ? bindWhere(item, variables) : item
344
+ if (b !== item) changed = true
345
+ if (b !== null) next.push(b)
346
+ }
347
+ if (!changed) return where
348
+ return next.length ? next : null
349
+ }
350
+ if (!where || typeof where !== 'object') return where
351
+ let changed = false
352
+ const next = {}
353
+ for (const [key, value] of Object.entries(where)) {
354
+ if (typeof value === 'string' && ROUTE_VARIABLE.test(value)) {
355
+ const bound = variables?.[value.slice(1)]
356
+ changed = true
357
+ if (bound === undefined || bound === null) continue // unbound ⇒ drop
358
+ next[key] = String(bound)
359
+ continue
360
+ }
361
+ if (value && typeof value === 'object') {
362
+ const b = bindWhere(value, variables)
363
+ if (b !== value) changed = true
364
+ if (b === null) continue // an operator object or sub-predicate emptied out
365
+ next[key] = b
366
+ continue
367
+ }
368
+ next[key] = value
369
+ }
370
+ if (!changed) return where
371
+ return Object.keys(next).length ? next : null
372
+ }
373
+
374
+ /**
375
+ * `scope:` on a lane that cannot be ASKED — the compiled file, an address door —
376
+ * is the same question as `where: { path: { under: scope } }`: a record's
377
+ * `path` is the folder `records.yml` placed it in, and the evaluator's `under`
378
+ * is segment-aware containment. Folding it keeps the language the INTERSECTION
379
+ * of both lanes: an author
380
+ * writes `scope: :dir` once and it means the same branch on a static site and
381
+ * on a door that takes `scope` natively. A config addressed to a question door
382
+ * (`door`) keeps `scope` as the door's own field.
383
+ */
384
+ function foldScope(cfg) {
385
+ if (typeof cfg.scope !== 'string' || cfg.scope === '' || cfg.door) return cfg
386
+ const { scope, ...rest } = cfg
387
+ const under = { path: { under: scope } }
388
+ const where = cfg.where && typeof cfg.where === 'object' && Object.keys(cfg.where).length
389
+ ? { and: [cfg.where, under] }
390
+ : under
391
+ return { ...rest, where }
392
+ }
393
+
394
+ /**
395
+ * Say what a resolved config will GET, so the record index can file it.
396
+ *
397
+ * `depth` — `brief` when the config has a per-record source (`detail`), because
398
+ * a list with a separate record address is a list of partial records: a live
399
+ * lane answers a list at brief depth and a record in full, and a `deferred:`
400
+ * query's compiled file is the stripped list. `full` otherwise. An explicit
401
+ * `depth` on the config wins (a question door's client sets it).
402
+ *
403
+ * `locale` — stamped on a DOOR config only. A compiled path already carries its
404
+ * locale (`/fr/data/…`); a door is asked in one locale (it is in the route), and
405
+ * two locales' answers must not share a cache entry.
406
+ */
407
+ function stampDepthAndLocale(cfg, locale, defaultLocale) {
408
+ let out = cfg
409
+ if (out.depth !== 'brief' && out.depth !== 'full') {
410
+ out = { ...out, depth: out.detail ? 'brief' : 'full' }
411
+ }
412
+ // A door is asked in exactly one locale — it is in the route — so the config
413
+ // carries it whatever the locale is; two locales' answers never share an entry.
414
+ if (out.door && out.locale === undefined) {
415
+ const asked = locale ?? defaultLocale
416
+ if (asked) out = { ...out, locale: asked }
417
+ }
418
+ return out
419
+ }
@@ -204,6 +204,18 @@ export default class FetcherDispatcher {
204
204
  return this._dataStore.get(key)
205
205
  }
206
206
 
207
+ /**
208
+ * The record held under an identity, if any, with its depth — the entity
209
+ * store asks this before fetching a detail record, so a record already held
210
+ * in full is delivered rather than fetched again (R1).
211
+ *
212
+ * @param {string} id - a `$uuid`
213
+ * @returns {{ depth: 'brief'|'full', record: Object } | null}
214
+ */
215
+ peekRecord(id) {
216
+ return this._dataStore.getRecord(id)
217
+ }
218
+
207
219
  /**
208
220
  * Full dispatch — selection, cache check, in-flight dedup, execution.
209
221
  *
package/src/index.js CHANGED
@@ -13,24 +13,19 @@ export { default as Website } from './website.js'
13
13
  export { default as Page } from './page.js'
14
14
  export { default as Block } from './block.js'
15
15
  export { default as Theme, hasDarkScheme } from './theme.js'
16
- export { default as DataStore, deriveCacheKey } from './datastore.js'
16
+ export { default as DataStore, deriveCacheKey, recordIdentity } from './datastore.js'
17
17
  export { default as EntityStore } from './entity-store.js'
18
18
  export { default as FetcherDispatcher } from './fetcher-dispatcher.js'
19
19
  export { default as ObservableState } from './observable-state.js'
20
20
 
21
21
  // Utilities
22
22
  export { substitutePlaceholders } from './substitute-placeholders.js'
23
- // `resolveQueryAddress` / `resolveRecordAddressPattern` are NOT re-exported
24
- // here. They are read by `./fetch-config.js` and by nothing else in any repo
25
- // (measured 2026-09-01 over every .js/.jsx/.mjs/.ts/.tsx file in the
26
- // workspace). A consumer that needs them imports `@uniweb/core/query-address`,
27
- // which is a declared subpath and a zero-dependency leaf — the same reach
28
- // `route-match` and `section-id` already have. Re-exporting an internal from
29
- // the package entry is not free: the import-map bridge enumerates this file's
30
- // surface and emits a live named re-export for every one, so nothing here can
31
- // ever be tree-shaken on the hosted lane.
23
+ // `resolveQueryDoor` / `QUERY_DOOR_KEY` are NOT re-exported here: `./fetch-config.js`
24
+ // reads them and a consumer that needs them imports `@uniweb/core/query-address`.
25
+ // (The address door's `resolveQueryAddress` / `resolveRecordAddressPattern` were
26
+ // deleted 2026-09-04 with the lane they addressed.)
32
27
  export { resolveFetchConfigs } from './fetch-config.js'
33
- export { buildDetailConfig } from './detail-url.js'
28
+ export { buildDetailConfig, ROUTE_HANDLE_KEY } from './detail-url.js'
34
29
  // `isWildcardLanguages` is likewise internal — `./locale-config.js` reads it
35
30
  // and nothing else does. Same subpath escape hatch: `@uniweb/core/locale-config`.
36
31
  export {
@@ -48,6 +43,11 @@ export {
48
43
  isDataUrl
49
44
  } from './data-paths.js'
50
45
  export { evaluate as evaluateWhere, match as matchWhere } from './where.js'
46
+ // The one sort evaluator and the one href encoder — both read by `@uniweb/build`
47
+ // (materialization, the `route:` bake) and by `@uniweb/runtime` (the fallback),
48
+ // which is what keeps the static and live lanes answering a query identically.
49
+ export { parseSort, sortRecords, sortToWire } from './sort.js'
50
+ export { fillRoutePattern, splitPathCapture, joinPathCapture, recordHandle, routeParamValue } from './route-match.js'
51
51
  export { isRichSchema } from './schemas.js'
52
52
  // ⛔ `Tracker` is NOT on the package entry. It is a FEATURE, not part of the
53
53
  // object graph this package exists to define, and putting it here made every
@@ -60,7 +60,6 @@ export { isRichSchema } from './schemas.js'
60
60
  // since it must not pull the package root into an SSR/Worker bundle.
61
61
  export { resolveService, resolveServiceUrl, readServiceOptions } from './services.js'
62
62
  export { applyBasePath } from './base-path.js'
63
- export { resolveStyle as resolveRequestStyle } from './request-styles/index.js'
64
63
 
65
64
  /**
66
65
  * The singleton Uniweb instance.
package/src/page.js CHANGED
@@ -111,6 +111,12 @@ export default class Page {
111
111
  // Dynamic route context (for pages created from dynamic routes like /blog/:slug)
112
112
  this.dynamicContext = pageData.dynamicContext || null
113
113
 
114
+ // A detail page whose URL names no record: the records were loaded and the
115
+ // param matched none. Set by `Website._createDynamicPage` beside the
116
+ // 'Not found' title. Documented for years, carried only since 2026-09-04 —
117
+ // the flag was set on the page DATA and this sealed class dropped it.
118
+ this.notFound = pageData.notFound === true
119
+
114
120
  // Version context (for pages within versioned sections like /docs/v1/*)
115
121
  this.version = pageData.version || null // { id, label, latest, deprecated }
116
122
  this.versionMeta = pageData.versionMeta || null // { versions, latestId }