@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.
- package/package.json +2 -1
- package/src/block.js +12 -2
- package/src/datastore.js +150 -24
- package/src/detail-url.js +72 -11
- package/src/entity-store.js +174 -50
- package/src/fetch-config.js +193 -19
- package/src/fetcher-dispatcher.js +18 -1
- package/src/index.js +7 -6
- package/src/page.js +6 -0
- package/src/query-address.js +34 -1
- package/src/route-match.js +153 -18
- package/src/sort.js +116 -0
- package/src/website.js +99 -15
- package/src/request-styles/flat-query.js +0 -146
- package/src/request-styles/index.js +0 -61
- package/src/request-styles/json-body.js +0 -117
- package/src/request-styles/strapi.js +0 -248
package/src/entity-store.js
CHANGED
|
@@ -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
|
|
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
|
|
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
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
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
|
|
82
|
-
|
|
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
|
-
|
|
120
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 (
|
|
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
|
-
|
|
363
|
-
|
|
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
|
-
|
|
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
|
-
|
|
418
|
-
|
|
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
|
}
|
package/src/fetch-config.js
CHANGED
|
@@ -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
|
|
38
|
-
*
|
|
39
|
-
*
|
|
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
|
|
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
|
|
189
|
-
*
|
|
190
|
-
*
|
|
191
|
-
*
|
|
192
|
-
*
|
|
193
|
-
*
|
|
194
|
-
*
|
|
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
|
-
*
|
|
197
|
-
*
|
|
198
|
-
*
|
|
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
|
|
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
|
-
|
|
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
|
+
}
|