@uniweb/core 0.19.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 +4 -3
- package/src/block.js +12 -2
- package/src/datastore.js +146 -19
- package/src/detail-url.js +72 -11
- package/src/entity-store.js +145 -31
- package/src/fetch-config.js +169 -4
- package/src/fetcher-dispatcher.js +12 -0
- package/src/index.js +7 -3
- 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 +94 -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 } 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
|
}
|
|
@@ -254,6 +259,16 @@ export default class EntityStore {
|
|
|
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;
|
|
@@ -268,9 +283,15 @@ export default class EntityStore {
|
|
|
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,6 +378,10 @@ 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
|
|
@@ -350,6 +390,25 @@ export default class EntityStore {
|
|
|
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,6 +416,10 @@ 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
|
|
|
@@ -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 { 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
|
|
@@ -100,6 +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 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
|
+
|
|
103
107
|
// ⭐ A lane's record address is injected whenever the lane declares one —
|
|
104
108
|
// NOT only for a `deferred:` query, and the difference is load-bearing.
|
|
105
109
|
//
|
|
@@ -170,9 +174,32 @@ function applyDeferredDetail(cfg, queries, records) {
|
|
|
170
174
|
* `query`, ignoring any `path`), so the two agree rather than disagreeing on a
|
|
171
175
|
* shape nobody hand-writes.
|
|
172
176
|
*/
|
|
173
|
-
function resolveQuerySource(cfg, records) {
|
|
177
|
+
function resolveQuerySource(cfg, records, { queries = null, locale = null, defaultLocale = null } = {}) {
|
|
174
178
|
if (typeof cfg.query !== 'string' || cfg.query.length === 0) return cfg
|
|
175
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
|
+
|
|
176
203
|
const endpoint = resolveQueryAddress(cfg.query, records)
|
|
177
204
|
if (endpoint) {
|
|
178
205
|
// Drop the transitional `path`: two addresses on one request is an
|
|
@@ -237,6 +264,10 @@ function bindingKey(cfg) {
|
|
|
237
264
|
* @param {Object|null} [options.records] - the site's `config.records`, a host's
|
|
238
265
|
* live-records lane. Absent means the compiled artifact answers, which is
|
|
239
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.
|
|
240
271
|
* @returns {Map<string, Object>} schema name → resolved config
|
|
241
272
|
*/
|
|
242
273
|
export function resolveFetchConfigs(sources, options = {}) {
|
|
@@ -246,6 +277,7 @@ export function resolveFetchConfigs(sources, options = {}) {
|
|
|
246
277
|
defaultLocale = null,
|
|
247
278
|
queries = null,
|
|
248
279
|
records = null,
|
|
280
|
+
variables = null,
|
|
249
281
|
} = options
|
|
250
282
|
|
|
251
283
|
const configs = new Map()
|
|
@@ -261,11 +293,144 @@ export function resolveFetchConfigs(sources, options = {}) {
|
|
|
261
293
|
if (!collectAll && !schemas.includes(key)) continue
|
|
262
294
|
// Address first: localization and deferred-detail both key on `path`,
|
|
263
295
|
// which a query ref does not have until this runs.
|
|
264
|
-
const sourced = resolveQuerySource(cfg, records)
|
|
296
|
+
const sourced = resolveQuerySource(cfg, records, { queries, locale, defaultLocale })
|
|
265
297
|
const localized = localizeConfig(sourced, locale, defaultLocale)
|
|
266
|
-
|
|
298
|
+
const bound = foldScope(bindRouteVariables(localized, variables))
|
|
299
|
+
configs.set(key, stampDepthAndLocale(applyDeferredDetail(bound, queries, records), locale, defaultLocale))
|
|
267
300
|
}
|
|
268
301
|
}
|
|
269
302
|
|
|
270
303
|
return configs
|
|
271
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
|
+
}
|
|
@@ -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,7 +13,7 @@ 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'
|
|
@@ -30,7 +30,7 @@ export { substitutePlaceholders } from './substitute-placeholders.js'
|
|
|
30
30
|
// surface and emits a live named re-export for every one, so nothing here can
|
|
31
31
|
// ever be tree-shaken on the hosted lane.
|
|
32
32
|
export { resolveFetchConfigs } from './fetch-config.js'
|
|
33
|
-
export { buildDetailConfig } from './detail-url.js'
|
|
33
|
+
export { buildDetailConfig, ROUTE_HANDLE_KEY } from './detail-url.js'
|
|
34
34
|
// `isWildcardLanguages` is likewise internal — `./locale-config.js` reads it
|
|
35
35
|
// and nothing else does. Same subpath escape hatch: `@uniweb/core/locale-config`.
|
|
36
36
|
export {
|
|
@@ -48,6 +48,11 @@ export {
|
|
|
48
48
|
isDataUrl
|
|
49
49
|
} from './data-paths.js'
|
|
50
50
|
export { evaluate as evaluateWhere, match as matchWhere } from './where.js'
|
|
51
|
+
// The one sort evaluator and the one href encoder — both read by `@uniweb/build`
|
|
52
|
+
// (materialization, the `route:` bake) and by `@uniweb/runtime` (the fallback),
|
|
53
|
+
// which is what keeps the static and live lanes answering a query identically.
|
|
54
|
+
export { parseSort, sortRecords, sortToWire } from './sort.js'
|
|
55
|
+
export { fillRoutePattern, splitPathCapture, joinPathCapture } from './route-match.js'
|
|
51
56
|
export { isRichSchema } from './schemas.js'
|
|
52
57
|
// ⛔ `Tracker` is NOT on the package entry. It is a FEATURE, not part of the
|
|
53
58
|
// object graph this package exists to define, and putting it here made every
|
|
@@ -60,7 +65,6 @@ export { isRichSchema } from './schemas.js'
|
|
|
60
65
|
// since it must not pull the package root into an SSR/Worker bundle.
|
|
61
66
|
export { resolveService, resolveServiceUrl, readServiceOptions } from './services.js'
|
|
62
67
|
export { applyBasePath } from './base-path.js'
|
|
63
|
-
export { resolveStyle as resolveRequestStyle } from './request-styles/index.js'
|
|
64
68
|
|
|
65
69
|
/**
|
|
66
70
|
* 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 }
|
package/src/query-address.js
CHANGED
|
@@ -50,7 +50,6 @@ import { substitutePlaceholders } from './substitute-placeholders.js'
|
|
|
50
50
|
* serving records has no such thing: it has content organised somewhere, and what we
|
|
51
51
|
* substitute is a **path** to it. Naming the slot for our file vocabulary put that
|
|
52
52
|
* vocabulary into a string a HOST writes, which makes them reason in our shape.
|
|
53
|
-
* See `kb/framework/architecture/backend-boundary.md` §2.
|
|
54
53
|
*/
|
|
55
54
|
const PATH_SLOT = '{path}'
|
|
56
55
|
/** The placeholder a record pattern must carry to address a specific record. */
|
|
@@ -111,6 +110,40 @@ export function resolveQueryAddress(query, lane) {
|
|
|
111
110
|
return substitutePlaceholders(pattern, { path: query })
|
|
112
111
|
}
|
|
113
112
|
|
|
113
|
+
/**
|
|
114
|
+
* The QUESTION door a host declares — a POST address with a `{locale}` slot —
|
|
115
|
+
* substituted for one locale, or `null` when the lane declares none.
|
|
116
|
+
*
|
|
117
|
+
* The stamp key is `config.records.query` — read here as a provisional spelling
|
|
118
|
+
* on 2026-09-04 and NAMED THE SAME DAY by the door's owner (their site-records
|
|
119
|
+
* contract, §11.4: stamped since 2026-09-04, value `/_records/_query/{locale}`).
|
|
120
|
+
* One constant, changed in one place should it ever move. Everything downstream
|
|
121
|
+
* wakes only when a host stamps it AND the payload carries the query's Model ref
|
|
122
|
+
* (`config.queries`), and stays dark otherwise. The locale is a ROUTE SEGMENT
|
|
123
|
+
* there, never a query param: a request that cannot name one does not address
|
|
124
|
+
* this door at all.
|
|
125
|
+
*
|
|
126
|
+
* @param {Object|null} lane - `config.records`
|
|
127
|
+
* @param {string|null} locale - the locale being rendered; required
|
|
128
|
+
* @returns {string|null}
|
|
129
|
+
*/
|
|
130
|
+
export const QUERY_DOOR_KEY = 'query'
|
|
131
|
+
|
|
132
|
+
export function resolveQueryDoor(lane, locale) {
|
|
133
|
+
const pattern = readPattern(lane, QUERY_DOOR_KEY)
|
|
134
|
+
if (!pattern) return null
|
|
135
|
+
if (typeof locale !== 'string' || locale.length === 0) return null
|
|
136
|
+
if (!pattern.includes('{locale}')) {
|
|
137
|
+
warnOnce(
|
|
138
|
+
`query:${pattern}`,
|
|
139
|
+
`config.records.${QUERY_DOOR_KEY} carries no {locale} placeholder; the door takes the ` +
|
|
140
|
+
`locale as a route segment. Ignoring it.`
|
|
141
|
+
)
|
|
142
|
+
return null
|
|
143
|
+
}
|
|
144
|
+
return substitutePlaceholders(pattern, { locale })
|
|
145
|
+
}
|
|
146
|
+
|
|
114
147
|
/**
|
|
115
148
|
* The address pattern for ONE record of a query, with `{param}` left in
|
|
116
149
|
* place for the dynamic-route substitution that happens later.
|