@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.
- package/package.json +2 -1
- package/src/block.js +12 -2
- package/src/datastore.js +146 -19
- package/src/detail-url.js +79 -21
- package/src/entity-store.js +149 -35
- package/src/fetch-config.js +175 -27
- package/src/fetcher-dispatcher.js +12 -0
- package/src/index.js +11 -12
- package/src/page.js +6 -0
- package/src/query-address.js +51 -80
- package/src/route-match.js +196 -18
- package/src/sort.js +116 -0
- package/src/website.js +97 -15
- package/src/request-styles/index.js +0 -59
- package/src/request-styles/json-body.js +0 -117
package/src/entity-store.js
CHANGED
|
@@ -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
|
|
90
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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 (
|
|
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
|
|
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
|
|
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
|
-
|
|
373
|
-
|
|
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
|
-
|
|
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
|
-
|
|
428
|
-
|
|
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
|
}
|
package/src/fetch-config.js
CHANGED
|
@@ -28,7 +28,7 @@
|
|
|
28
28
|
*/
|
|
29
29
|
|
|
30
30
|
import { queryDataUrl, isDataUrl, recordDataUrl } from './data-paths.js'
|
|
31
|
-
import {
|
|
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
|
-
//
|
|
104
|
-
//
|
|
105
|
-
|
|
106
|
-
|
|
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
|
|
159
|
-
*
|
|
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
|
-
|
|
177
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
24
|
-
//
|
|
25
|
-
// (
|
|
26
|
-
//
|
|
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 }
|