@uniweb/core 0.28.0 → 0.29.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 +7 -0
- package/src/data-keys.js +155 -0
- package/src/datastore.js +28 -13
- package/src/detail-url.js +22 -14
- package/src/entity-store.js +215 -136
- package/src/fetch-config.js +255 -81
- package/src/fetcher-dispatcher.js +9 -1
- package/src/index.js +5 -1
- package/src/query-evaluation.js +87 -0
- package/src/route-match.js +3 -2
- package/src/sort.js +2 -3
- package/src/website.js +70 -41
package/src/fetch-config.js
CHANGED
|
@@ -92,22 +92,67 @@ export function currentOf(cfg) {
|
|
|
92
92
|
}
|
|
93
93
|
|
|
94
94
|
/**
|
|
95
|
-
*
|
|
96
|
-
*
|
|
97
|
-
*
|
|
95
|
+
* ⭐ HOW A FETCH ON A PARAMETRIC PAGE USES THE PAGE'S RECORD — `current:` FOLLOWS THE QUERY,
|
|
96
|
+
* NOT THE KEY (ruled 2026-09-14 [Diego]). The one test the entity store (`resolve`, `fetch`),
|
|
97
|
+
* the prefetch and the build's warning all make:
|
|
98
|
+
*
|
|
99
|
+
* - **a fetch naming the route query** gets the page's record — `only` — unless its
|
|
100
|
+
* `current:` says `exclude` or `include`, whatever its `as`: `{ query: articles, as:
|
|
101
|
+
* related, current: exclude }` is the others under `related`;
|
|
102
|
+
* - **a fetch naming another query** gets that query's records as it describes them,
|
|
103
|
+
* and its `current:` is read when written: `exclude` takes the page's record out, `only`
|
|
104
|
+
* keeps just that record;
|
|
105
|
+
* - **a fetch naming no query** (a `path:` or `url:` config) is decided by its key, as
|
|
106
|
+
* before: the route key's fetch gets the record. So is every fetch on a page whose
|
|
107
|
+
* route query names none, since there is no query to compare.
|
|
108
|
+
*
|
|
109
|
+
* The page's record is found in any of them the way it is found in the route query's set —
|
|
110
|
+
* by the route's parameter (`matchesRouteParam`).
|
|
111
|
+
*
|
|
112
|
+
* ⛔ Until 2026-09-14 both were decided by the key: `current:` was read only under the route
|
|
113
|
+
* key, and there it defaulted to the record whatever query the fetch named. So `{ query:
|
|
114
|
+
* articles, as: related, current: exclude }` got every article, and `{ query: news, as:
|
|
115
|
+
* articles }` got the page's article out of `news`.
|
|
116
|
+
*
|
|
117
|
+
* @param {Object} cfg - a fetch config — resolved, or as authored with its `as`
|
|
118
|
+
* @param {{ key: string, config: Object }|null} route - the page's route query
|
|
119
|
+
* (`pageRouteQuery`), or null off a parametric page
|
|
120
|
+
* @returns {'only'|'exclude'|'include'|null} null when the page's record plays no part: the
|
|
121
|
+
* fetch gets its records as it describes them
|
|
122
|
+
*/
|
|
123
|
+
export function currentFor(cfg, route) {
|
|
124
|
+
if (!cfg || !route) return null
|
|
125
|
+
const named = (c) => (typeof c?.query === 'string' && c.query ? c.query : null)
|
|
126
|
+
const query = named(cfg)
|
|
127
|
+
const routeQueryName = named(route.config)
|
|
128
|
+
if (query === null || routeQueryName === null) return bindingKey(cfg) === route.key ? currentOf(cfg) : null
|
|
129
|
+
if (query === routeQueryName) return currentOf(cfg)
|
|
130
|
+
return CURRENT_MODES.includes(cfg.current) ? cfg.current : null
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* The list a `current: exclude` section asks for: its own view, with `narrow.limit`
|
|
135
|
+
* one higher, so that removing the page's record still leaves `limit` others. The
|
|
136
|
+
* order is the set, then the fetch's `where` and `sort`, remove the record, `limit` —
|
|
137
|
+
* so a fetch's `limit` counts the others.
|
|
138
|
+
*
|
|
139
|
+
* ⚠️ Only the fetch's own `limit` is raised. A query's `limit` defines its set (ruled
|
|
140
|
+
* 2026-09-14 [Diego]), so the others never reach past it: with no `limit` of the
|
|
141
|
+
* fetch's own, the section gets the set without the page's record.
|
|
98
142
|
*
|
|
99
143
|
* @param {Object} cfg - a resolved config under the route key
|
|
100
144
|
* @returns {Object}
|
|
101
145
|
*/
|
|
102
146
|
export function othersView(cfg) {
|
|
103
|
-
|
|
104
|
-
|
|
147
|
+
const limit = cfg?.narrow?.limit
|
|
148
|
+
if (typeof limit !== 'number' || limit <= 0) return cfg
|
|
149
|
+
return { ...cfg, narrow: { ...cfg.narrow, limit: limit + 1 } }
|
|
105
150
|
}
|
|
106
151
|
|
|
107
152
|
/**
|
|
108
153
|
* The records a `current: exclude` section receives, from what `othersView` asked:
|
|
109
154
|
* the list without the page's record — the first that matches the route's value, the
|
|
110
|
-
* one `only` delivers — cut to the
|
|
155
|
+
* one `only` delivers — cut to the fetch's `narrow.limit`.
|
|
111
156
|
*
|
|
112
157
|
* @param {Array|*} records - what `othersView(cfg)` answered
|
|
113
158
|
* @param {Object} cfg - the section's resolved config
|
|
@@ -118,7 +163,8 @@ export function othersOf(records, cfg, isCurrent) {
|
|
|
118
163
|
if (!Array.isArray(records)) return records
|
|
119
164
|
const at = records.findIndex(isCurrent)
|
|
120
165
|
const others = at < 0 ? records : [...records.slice(0, at), ...records.slice(at + 1)]
|
|
121
|
-
|
|
166
|
+
const limit = cfg?.narrow?.limit
|
|
167
|
+
return typeof limit === 'number' && limit > 0 ? others.slice(0, limit) : others
|
|
122
168
|
}
|
|
123
169
|
|
|
124
170
|
/**
|
|
@@ -145,7 +191,7 @@ function localizeConfig(cfg, locale, defaultLocale) {
|
|
|
145
191
|
* Say whether a resolved config has a PER-RECORD source — `detail` — so a
|
|
146
192
|
* parametric page asks for its record on its own rather than finding it in the list.
|
|
147
193
|
*
|
|
148
|
-
* - the records service → `true`: the record is the
|
|
194
|
+
* - the records service → `true`: the record is the route query's set narrowed
|
|
149
195
|
* by the URL's value (`buildDetailConfig`);
|
|
150
196
|
* - an external query declaring `record:` → `true`: its own request;
|
|
151
197
|
* - a query declaring `deferred:` → `/data/<query>/{slug}.json`, the per-record
|
|
@@ -167,8 +213,8 @@ function localizeConfig(cfg, locale, defaultLocale) {
|
|
|
167
213
|
function applyDeferredDetail(cfg, queries) {
|
|
168
214
|
if (cfg.detail !== undefined) return cfg
|
|
169
215
|
|
|
170
|
-
// The records service answers a RECORD by the
|
|
171
|
-
//
|
|
216
|
+
// The records service answers a RECORD by the query's set narrowed to it, so
|
|
217
|
+
// every asked config has a detail source; `buildDetailConfig` composes it.
|
|
172
218
|
if (cfg.ask) return { ...cfg, detail: true }
|
|
173
219
|
// An external query's `record:` request.
|
|
174
220
|
if (cfg.record && typeof cfg.record === 'object') return { ...cfg, detail: true }
|
|
@@ -271,13 +317,13 @@ function resolveQuerySource(cfg, services, { queries = null, locale = null, defa
|
|
|
271
317
|
if (decl.record && typeof decl.record === 'object') out.record = decl.record
|
|
272
318
|
// a live endpoint is the browser's to fetch, unless the binding says otherwise
|
|
273
319
|
if (out.prerender === undefined) out.prerender = false
|
|
274
|
-
return
|
|
320
|
+
return setAndNarrow(out, decl, decl.where)
|
|
275
321
|
}
|
|
276
322
|
|
|
277
|
-
// ⭐ THE SERVICE FIRST. A host that answers questions gets the
|
|
278
|
-
// `schema`, `scope`, `where`, `sort`, `limit
|
|
279
|
-
// per-query address at all (the records contract, §2). It needs
|
|
280
|
-
// MODEL REF, which lives on the site's `config.queries` declaration; a
|
|
323
|
+
// ⭐ THE SERVICE FIRST. A host that answers questions gets the query as saved —
|
|
324
|
+
// `schema`, `scope`, `where`, `sort`, `limit` — and the fetch's `narrow` beside it,
|
|
325
|
+
// and composes no per-query address at all (the records contract, §2). It needs
|
|
326
|
+
// the query's MODEL REF, which lives on the site's `config.queries` declaration; a
|
|
281
327
|
// payload that offers the service but carries no declaration cannot ask, and
|
|
282
328
|
// says so. ⚠️ Dark until a host stamps the row; see `resolveRecordsService`.
|
|
283
329
|
const ask = resolveRecordsService(services, locale ?? defaultLocale)
|
|
@@ -293,65 +339,88 @@ function resolveQuerySource(cfg, services, { queries = null, locale = null, defa
|
|
|
293
339
|
// instead would turn a producer defect into a 404 that names the wrong
|
|
294
340
|
// thing. The fetcher refuses an asked request with no `schema` before any
|
|
295
341
|
// request is made, and the block's `dataError` says exactly this.
|
|
296
|
-
return { ...rest, ask, schema: null }
|
|
342
|
+
return setAndNarrow({ ...rest, ask, schema: null }, decl, decl?.where ?? null)
|
|
297
343
|
}
|
|
298
|
-
return
|
|
344
|
+
return setAndNarrow({ ...rest, ask, schema }, decl, decl.where)
|
|
299
345
|
}
|
|
300
346
|
|
|
301
347
|
// ⭐ THE COMPILED FILE. The build applied the named query's fixed `where` when it
|
|
302
|
-
// wrote `/data/<query>.json`; what it could NOT apply is a clause
|
|
303
|
-
// route (`where: { tag: :dir }`) — a file is written once and the
|
|
304
|
-
// per page — and it applies neither `scope` nor `limit`. So
|
|
305
|
-
//
|
|
306
|
-
//
|
|
307
|
-
//
|
|
308
|
-
//
|
|
348
|
+
// wrote `/data/<query>.json`, in its `sort`; what it could NOT apply is a clause
|
|
349
|
+
// bound to the route (`where: { tag: :dir }`) — a file is written once and the
|
|
350
|
+
// route differs per page — and it applies neither `scope` nor `limit`. So the
|
|
351
|
+
// runtime makes the set per page from what travels here — `scope`, the routed
|
|
352
|
+
// clauses, the query's `sort` in the page's locale, its `limit` — exactly as the
|
|
353
|
+
// service makes it (`bindRouteVariables`, below; `evaluateQuery`). ⛔ Until
|
|
354
|
+
// 2026-09-11 nothing travelled: a named query's `scope: :dir` was ignored on this
|
|
355
|
+
// lane and `where: { tag: :dir }` was applied at build to the literal `':dir'`,
|
|
356
|
+
// compiling to no records (measured).
|
|
309
357
|
const out = { ...cfg, path: queryDataUrl(cfg.query) }
|
|
310
358
|
for (const key of QUERY_SUPPLIED) delete out[key]
|
|
311
|
-
return
|
|
359
|
+
return setAndNarrow(out, decl, decl ? routeVariableClauses(decl.where) : null)
|
|
312
360
|
}
|
|
313
361
|
|
|
314
362
|
/**
|
|
315
|
-
* ⭐ A
|
|
316
|
-
*
|
|
317
|
-
*
|
|
318
|
-
*
|
|
319
|
-
*
|
|
320
|
-
*
|
|
321
|
-
*
|
|
322
|
-
* - `
|
|
323
|
-
*
|
|
324
|
-
*
|
|
325
|
-
*
|
|
326
|
-
*
|
|
327
|
-
*
|
|
328
|
-
*
|
|
329
|
-
*
|
|
330
|
-
*
|
|
331
|
-
*
|
|
332
|
-
*
|
|
363
|
+
* ⭐ A FETCH IS ITS QUERY'S SET, AND WHAT IT TAKES OF THE SET — two levels, ruled
|
|
364
|
+
* 2026-09-14 [Diego]:
|
|
365
|
+
*
|
|
366
|
+
* - **the top level is the query as saved** — its `scope`, `where`, `sort` and
|
|
367
|
+
* `limit`. They define THE SET: the records its `scope` and `where` select, in its
|
|
368
|
+
* `sort`, up to its `limit`. A query's `limit` is part of what it selects, so a
|
|
369
|
+
* parametric page has a URL for each record of its route query's set, and no other;
|
|
370
|
+
* - **`narrow` is the fetch's own `where`, `sort` and `limit`**, applied to the set:
|
|
371
|
+
* its `where` filters what the set holds, its `sort` re-orders what is left, and its
|
|
372
|
+
* `limit` cuts the rest — never past the set. Absent when the fetch takes the whole
|
|
373
|
+
* set.
|
|
374
|
+
*
|
|
375
|
+
* Every lane evaluates the set first and then `narrow` — the records service at the
|
|
376
|
+
* source, the default fetcher and the build over a file or a response
|
|
377
|
+
* (`@uniweb/core`'s `evaluateQuery`) — and every question about one query carries the
|
|
378
|
+
* same top level, so a record question checks the set with the query's own `sort` and
|
|
379
|
+
* `limit` (`buildDetailConfig` adds `narrow.match`).
|
|
380
|
+
*
|
|
381
|
+
* ⛔ Until 2026-09-14 the two levels were MERGED into one flat question — the fetch's
|
|
382
|
+
* `where` joined the query's with `and`, and its `sort` and `limit` replaced the
|
|
383
|
+
* query's. So *"the latest 3 about pandas, among the query's 100 most recent"* was
|
|
384
|
+
* asked as *"the latest 3 about pandas"*, whether or not they were among the 100; a
|
|
385
|
+
* fetch's `limit` could exceed its query's; and a record question dropped the query's
|
|
386
|
+
* `sort` and `limit`, so a record outside the 100 still had a page.
|
|
387
|
+
*
|
|
388
|
+
* A config that names a query ALWAYS carries the fetch's adaptations in `narrow`, with
|
|
389
|
+
* a declaration for the query or without one, so the top level never holds a
|
|
390
|
+
* fetch's field. A `narrow`, `match` or `cursor` a stale binding carries is not read:
|
|
391
|
+
* the resolver and the client compose those.
|
|
392
|
+
*
|
|
393
|
+
* @param {Object} out - the fetch, addressed
|
|
394
|
+
* @param {Object|null} decl - the query's declaration, when the payload carries one
|
|
333
395
|
* @param {Object|null} queryWhere - the part of the query's `where` this lane still
|
|
334
|
-
* applies: all of it on the service, the routed clauses on
|
|
396
|
+
* applies: all of it on the service and for an external query, the routed clauses on
|
|
397
|
+
* the compiled file
|
|
335
398
|
* @returns {Object}
|
|
336
399
|
*/
|
|
337
|
-
function
|
|
338
|
-
const
|
|
339
|
-
if (
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
400
|
+
function setAndNarrow(out, decl, queryWhere) {
|
|
401
|
+
const { where, sort, limit, narrow: _narrow, match: _match, cursor: _cursor, ...next } = out
|
|
402
|
+
if (decl) {
|
|
403
|
+
if (typeof decl.scope === 'string') next.scope = decl.scope
|
|
404
|
+
if (someWhere(queryWhere)) next.where = queryWhere
|
|
405
|
+
if (decl.sort !== undefined && decl.sort !== null) next.sort = decl.sort
|
|
406
|
+
if (isCount(decl.limit)) next.limit = decl.limit
|
|
407
|
+
}
|
|
408
|
+
const narrow = {}
|
|
409
|
+
if (someWhere(where)) narrow.where = where
|
|
410
|
+
if (sort !== undefined && sort !== null && sort !== '') narrow.sort = sort
|
|
411
|
+
if (isCount(limit)) narrow.limit = limit
|
|
412
|
+
if (Object.keys(narrow).length > 0) next.narrow = narrow
|
|
345
413
|
return next
|
|
346
414
|
}
|
|
347
415
|
|
|
348
|
-
/**
|
|
349
|
-
function
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
416
|
+
/** A where-object that says something: a non-empty plain object. */
|
|
417
|
+
function someWhere(where) {
|
|
418
|
+
return Boolean(where) && typeof where === 'object' && !Array.isArray(where) && Object.keys(where).length > 0
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
/** A `limit` that cuts: a number above 0. `0` and anything else mean every record. */
|
|
422
|
+
function isCount(limit) {
|
|
423
|
+
return typeof limit === 'number' && limit > 0
|
|
355
424
|
}
|
|
356
425
|
|
|
357
426
|
/**
|
|
@@ -384,6 +453,47 @@ function bindingKey(cfg) {
|
|
|
384
453
|
return cfg?.as
|
|
385
454
|
}
|
|
386
455
|
|
|
456
|
+
/**
|
|
457
|
+
* One fetch entry as the runtime reads it. ⭐ **A STRING IS A QUERY NAME, and `as`
|
|
458
|
+
* DEFAULTS TO THE QUERY'S NAME — on every lane** (ruled 2026-09-13 [Diego]: *"a string
|
|
459
|
+
* is a query name, so, `fetch: "members"` … means `fetch: {query: "members"}`"*, and
|
|
460
|
+
* *"`as` defaults to `query`"*). The build writes both out when it parses a content
|
|
461
|
+
* file (`parseFetchConfig`), so a static payload never needs this. A payload a backend
|
|
462
|
+
* publishes carries a page's and a section's `fetch` as stored — where a bare `"people"`
|
|
463
|
+
* and a `{ query }` with no `as` are ordinary — and ⛔ **both were skipped here, for
|
|
464
|
+
* having no `as`, until 2026-09-14** (measured): a hosted section bound that way
|
|
465
|
+
* received nothing, and a parametric page whose parent named its query that way had no
|
|
466
|
+
* route query.
|
|
467
|
+
*
|
|
468
|
+
* A string that is not a query name — a path, a data file — is not one: `/data/…` is
|
|
469
|
+
* never authored (the build refuses it), and guessing at it here would fetch a file no
|
|
470
|
+
* query declares.
|
|
471
|
+
*
|
|
472
|
+
* @param {*} entry - a string, an object, or anything else a stored `fetch` holds
|
|
473
|
+
* @returns {Object|null} the entry with its `as`, or null when it is not a fetch
|
|
474
|
+
*/
|
|
475
|
+
function fetchEntry(entry) {
|
|
476
|
+
if (typeof entry === 'string') {
|
|
477
|
+
const name = entry.trim()
|
|
478
|
+
return name && !/[\\/]|\.(json|ya?ml)$/i.test(name) ? { query: name, as: name } : null
|
|
479
|
+
}
|
|
480
|
+
if (!entry || typeof entry !== 'object' || Array.isArray(entry)) return null
|
|
481
|
+
if (entry.as === undefined && typeof entry.query === 'string' && entry.query) return { ...entry, as: entry.query }
|
|
482
|
+
return entry
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
/**
|
|
486
|
+
* A `fetch` as the runtime reads it — one entry, several, or none — each through
|
|
487
|
+
* `fetchEntry`, so a string and a `{ query }` with no `as` are read as the query they name.
|
|
488
|
+
*
|
|
489
|
+
* @param {*} fetch - a level's `fetch`, as a payload carries it
|
|
490
|
+
* @returns {Array<Object>}
|
|
491
|
+
*/
|
|
492
|
+
export function fetchEntries(fetch) {
|
|
493
|
+
if (!fetch) return []
|
|
494
|
+
return (Array.isArray(fetch) ? fetch : [fetch]).map(fetchEntry).filter(Boolean)
|
|
495
|
+
}
|
|
496
|
+
|
|
387
497
|
/**
|
|
388
498
|
* Resolve the applicable fetch configs from an ordered list of sources.
|
|
389
499
|
*
|
|
@@ -429,9 +539,7 @@ export function resolveFetchConfigs(sources, options = {}) {
|
|
|
429
539
|
const collectAll = schemas.length === 0
|
|
430
540
|
|
|
431
541
|
for (const source of sources) {
|
|
432
|
-
|
|
433
|
-
const configList = Array.isArray(source) ? source : [source]
|
|
434
|
-
for (const cfg of configList) {
|
|
542
|
+
for (const cfg of fetchEntries(source)) {
|
|
435
543
|
const key = bindingKey(cfg)
|
|
436
544
|
if (!key) continue
|
|
437
545
|
if (configs.has(key)) continue
|
|
@@ -477,6 +585,11 @@ function unset(value) {
|
|
|
477
585
|
* to an intentional list page. Only an authoring surface can catch that; this
|
|
478
586
|
* function cannot.
|
|
479
587
|
*
|
|
588
|
+
* ⭐ Both levels bind: the query's `scope` and `where`, and the fetch's own `where`
|
|
589
|
+
* inside `narrow` — a variable may be a value in either (`setAndNarrow`). A `narrow`
|
|
590
|
+
* left with nothing in it is dropped, so it cannot split a cache entry from the fetch
|
|
591
|
+
* that takes the whole set.
|
|
592
|
+
*
|
|
480
593
|
* @param {Object} cfg - a resolved config
|
|
481
594
|
* @param {Object|null} variables - `{ path, dir, slug, … }` from the route, or null off a parametric page
|
|
482
595
|
* @returns {Object} the config, with placeholders bound or their clauses dropped
|
|
@@ -496,14 +609,24 @@ function bindRouteVariables(cfg, variables) {
|
|
|
496
609
|
out = bound === null ? rest : { ...rest, where: soleMember(bound) }
|
|
497
610
|
}
|
|
498
611
|
}
|
|
612
|
+
const narrowWhere = cfg.narrow?.where
|
|
613
|
+
if (narrowWhere && typeof narrowWhere === 'object') {
|
|
614
|
+
const bound = bindWhere(narrowWhere, variables)
|
|
615
|
+
if (bound !== narrowWhere) {
|
|
616
|
+
const { where, ...narrowRest } = out.narrow
|
|
617
|
+
const narrow = bound === null ? narrowRest : { ...narrowRest, where: soleMember(bound) }
|
|
618
|
+
const { narrow: _unbound, ...rest } = out
|
|
619
|
+
out = Object.keys(narrow).length > 0 ? { ...rest, narrow } : rest
|
|
620
|
+
}
|
|
621
|
+
}
|
|
499
622
|
return out
|
|
500
623
|
}
|
|
501
624
|
|
|
502
625
|
/**
|
|
503
626
|
* An `and` or `or` left holding ONE member once its unbound clauses dropped is that
|
|
504
|
-
* member
|
|
505
|
-
*
|
|
506
|
-
*
|
|
627
|
+
* member — so an authored `{ and: [ { tag: :dir }, { featured: true } ] }` asks the
|
|
628
|
+
* list page, where `:dir` is unbound, `{ featured: true }`: the same question, and the
|
|
629
|
+
* same cache entry, as the where written without the routed clause.
|
|
507
630
|
*/
|
|
508
631
|
function soleMember(where) {
|
|
509
632
|
if (!where || typeof where !== 'object' || Array.isArray(where)) return where
|
|
@@ -622,23 +745,27 @@ export function withoutRouteVariables({ where = null, scope = null } = {}) {
|
|
|
622
745
|
}
|
|
623
746
|
|
|
624
747
|
/**
|
|
625
|
-
* ⭐ THE
|
|
626
|
-
* record in
|
|
627
|
-
*
|
|
628
|
-
*
|
|
629
|
-
*
|
|
630
|
-
*
|
|
631
|
-
*
|
|
632
|
-
*
|
|
633
|
-
*
|
|
748
|
+
* ⭐ THE SET — the records a route query selects, and the list a parametric page finds
|
|
749
|
+
* its record in and its pages are made from: the config as the query saved it, without
|
|
750
|
+
* the fetch's `narrow`. Its `sort` and `limit` are the query's and stay, because a
|
|
751
|
+
* query's `limit` is part of what it selects (ruled 2026-09-14 [Diego]): a parametric
|
|
752
|
+
* page has a URL for each record of its route query's set, and no other — a field
|
|
753
|
+
* note older than the 100 most recent has no page under a query of the 100 most
|
|
754
|
+
* recent. A fetch's narrowing never decides which pages exist: `limit: 3` on `/blog`
|
|
755
|
+
* leaves `/blog/d` a record, when the query selects it.
|
|
756
|
+
*
|
|
757
|
+
* ⛔ Until 2026-09-14 this dropped the `limit` of a merged config instead — the fetch's
|
|
758
|
+
* or the query's, indistinguishable once merged — so every record the query's `where`
|
|
759
|
+
* selected had a page, whatever its `limit`, and a fetch's `where` did decide. The
|
|
760
|
+
* records service's record question dropped `sort` and `limit` to the same effect.
|
|
634
761
|
*
|
|
635
762
|
* @param {Object} cfg - a resolved config
|
|
636
|
-
* @returns {Object} the config itself when it has no `
|
|
763
|
+
* @returns {Object} the config itself when it has no `narrow`, else a copy without it
|
|
637
764
|
*/
|
|
638
765
|
export function routeSelection(cfg) {
|
|
639
|
-
if (!cfg || typeof cfg !== 'object' || cfg.
|
|
640
|
-
const {
|
|
641
|
-
return
|
|
766
|
+
if (!cfg || typeof cfg !== 'object' || cfg.narrow === undefined) return cfg
|
|
767
|
+
const { narrow, ...set } = cfg
|
|
768
|
+
return set
|
|
642
769
|
}
|
|
643
770
|
|
|
644
771
|
/**
|
|
@@ -660,10 +787,9 @@ export function siteReaches(parent) {
|
|
|
660
787
|
return !parent
|
|
661
788
|
}
|
|
662
789
|
|
|
663
|
-
/** A `fetch` as a list: one declaration, several, or none. */
|
|
790
|
+
/** A `fetch` as a list: one declaration, several, or none — each read as the runtime reads it. */
|
|
664
791
|
function fetchList(fetch) {
|
|
665
|
-
|
|
666
|
-
return Array.isArray(fetch) ? fetch.filter(Boolean) : [fetch]
|
|
792
|
+
return fetchEntries(fetch)
|
|
667
793
|
}
|
|
668
794
|
|
|
669
795
|
/**
|
|
@@ -789,6 +915,54 @@ export function pageRouteQuery(page, { routeOf, parentOf, fetchOf, sectionsOf, s
|
|
|
789
915
|
return found ? { ...found, capturing, nested: capturing !== page } : null
|
|
790
916
|
}
|
|
791
917
|
|
|
918
|
+
/**
|
|
919
|
+
* ⭐ THE PAGE A QUERY'S RECORDS LINK TO — ruled 2026-09-14 [Diego]: *a rendered record
|
|
920
|
+
* links to its query's page.* For each query, the parametric page whose ROUTE QUERY is
|
|
921
|
+
* that query (`routeQuery`), the first in page order:
|
|
922
|
+
*
|
|
923
|
+
* - **by the query's name, never by its schema** — a page over the same schema may not
|
|
924
|
+
* select the record, so its URL could name a record it answers "not found" for;
|
|
925
|
+
* - **a page nested inside a parametric page is not a record's page** — its route does
|
|
926
|
+
* not end in the parameter (`recordRouteBase`), so a record cannot fill it;
|
|
927
|
+
* - **no page, no link** — a query absent from the map has none.
|
|
928
|
+
*
|
|
929
|
+
* A fetch picks another page with `detailPage:`, which the caller resolves; this is
|
|
930
|
+
* the page with none. ⛔ It replaces `route:` on a query, which the build baked into
|
|
931
|
+
* compiled records as `route` — a field in the author's namespace, overwritten.
|
|
932
|
+
*
|
|
933
|
+
* Shape-agnostic, like `pageRouteQuery`: the object graph and a content document hand in
|
|
934
|
+
* their own accessors, so the runtime, a host and the editor get one answer.
|
|
935
|
+
*
|
|
936
|
+
* @param {Array<Object>} pages - every page, in page order, in the caller's shape
|
|
937
|
+
* @param {Object} access
|
|
938
|
+
* @param {(page: Object) => string} access.routeOf - its route pattern
|
|
939
|
+
* @param {(page: Object) => Object|null} access.parentOf - its parent page, or null
|
|
940
|
+
* @param {(page: Object) => *} access.fetchOf - its `fetch`
|
|
941
|
+
* @param {(page: Object) => Array<Object>|undefined} access.sectionsOf - its raw sections
|
|
942
|
+
* @param {*} [access.site] - the site's `fetch`
|
|
943
|
+
* @returns {Map<string, { page: Object, route: string, key: string }>} query name → its
|
|
944
|
+
* record page: the page, its route pattern, and the key its record is delivered under
|
|
945
|
+
*/
|
|
946
|
+
export function recordPages(pages, { routeOf, parentOf, fetchOf, sectionsOf, site = null }) {
|
|
947
|
+
const out = new Map()
|
|
948
|
+
for (const page of pages || []) {
|
|
949
|
+
const route = routeOf(page)
|
|
950
|
+
if (recordRouteBase(route) === null) continue
|
|
951
|
+
const parent = parentOf(page)
|
|
952
|
+
const found = routeQuery({
|
|
953
|
+
page: fetchOf(page),
|
|
954
|
+
parent: parent ? fetchOf(parent) : null,
|
|
955
|
+
// the site's binding is a route query for a top-level page only (`siteReaches`)
|
|
956
|
+
site: siteReaches(parent) ? site : null,
|
|
957
|
+
sections: sectionFetches(sectionsOf(page)),
|
|
958
|
+
})
|
|
959
|
+
const name = found?.config?.query
|
|
960
|
+
if (typeof name !== 'string' || !name || out.has(name)) continue
|
|
961
|
+
out.set(name, { page, route, key: found.key })
|
|
962
|
+
}
|
|
963
|
+
return out
|
|
964
|
+
}
|
|
965
|
+
|
|
792
966
|
/**
|
|
793
967
|
* Say what a resolved config will GET, so the record index can file it.
|
|
794
968
|
*
|
|
@@ -343,7 +343,15 @@ export default class FetcherDispatcher {
|
|
|
343
343
|
|
|
344
344
|
const entry = meta !== undefined ? { data, meta } : { data }
|
|
345
345
|
this._dataStore.set(key, entry)
|
|
346
|
-
|
|
346
|
+
// ⭐ What the store now HOLDS, not what arrived: an entry the record index filed
|
|
347
|
+
// materializes each record at the depth the index holds it — so a list fetched
|
|
348
|
+
// after its record was fetched whole delivers that record whole, on this pass as
|
|
349
|
+
// on every later read (R3). ⛔ Until 2026-09-14 a miss returned what arrived, and
|
|
350
|
+
// the upgrade reached a list only once it was read back from the cache — which a
|
|
351
|
+
// parametric page on the records service happened to do, by asking its list
|
|
352
|
+
// beside its record, until its record question checked the set on its own.
|
|
353
|
+
const held = this._dataStore.get(key)
|
|
354
|
+
return held ? { data: held.data, meta: held.meta } : { data, meta }
|
|
347
355
|
} catch (err) {
|
|
348
356
|
if (this._dataStore.inflight.get(key) === inflight) {
|
|
349
357
|
this._dataStore.inflight.delete(key)
|
package/src/index.js
CHANGED
|
@@ -22,8 +22,9 @@ export { default as ObservableState } from './observable-state.js'
|
|
|
22
22
|
export { substitutePlaceholders } from './substitute-placeholders.js'
|
|
23
23
|
// `resolveRecordsService` / `RECORDS_SERVICE` are NOT re-exported here: `./fetch-config.js`
|
|
24
24
|
// reads them and a consumer that needs them imports `@uniweb/core/records-service`.
|
|
25
|
-
export { resolveFetchConfigs, routeQuery, pageRouteQuery, routeSelection, sectionFetches, siteReaches, withoutRouteVariables, CURRENT_MODES } from './fetch-config.js'
|
|
25
|
+
export { resolveFetchConfigs, routeQuery, pageRouteQuery, routeSelection, sectionFetches, siteReaches, withoutRouteVariables, CURRENT_MODES, currentFor } from './fetch-config.js'
|
|
26
26
|
export { buildDetailConfig, ROUTE_HANDLE_KEY } from './detail-url.js'
|
|
27
|
+
export { declaredKeys, sameSchema, fillDeclaredKeys } from './data-keys.js'
|
|
27
28
|
// `isWildcardLanguages` is likewise internal — `./locale-config.js` reads it
|
|
28
29
|
// and nothing else does. Same subpath escape hatch: `@uniweb/core/locale-config`.
|
|
29
30
|
export {
|
|
@@ -58,6 +59,9 @@ export { applyScope, withinScope } from './scope.js'
|
|
|
58
59
|
// (materialization, the `route:` bake) and by `@uniweb/runtime` (the fallback),
|
|
59
60
|
// which is what keeps the static and live lanes answering a query identically.
|
|
60
61
|
export { parseSort, sortRecords, sortToWire } from './sort.js'
|
|
62
|
+
// A config's set, then its `narrow` — the one order of work the runtime's default
|
|
63
|
+
// fetcher and `@uniweb/build` both evaluate a fetch with.
|
|
64
|
+
export { evaluateQuery } from './query-evaluation.js'
|
|
61
65
|
export {
|
|
62
66
|
fillRoutePattern,
|
|
63
67
|
splitPathCapture,
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ⭐ A QUERY'S SET, THEN WHAT A FETCH TAKES OF IT — evaluated over the records a
|
|
3
|
+
* source returned. The ONE order of work for every lane that evaluates locally: the
|
|
4
|
+
* runtime's default fetcher over a compiled file or an external query's response,
|
|
5
|
+
* and the build when it runs a page's fetch.
|
|
6
|
+
*
|
|
7
|
+
* A resolved config has two levels (ruled 2026-09-14 [Diego],
|
|
8
|
+
* `@uniweb/core/fetch-config`):
|
|
9
|
+
*
|
|
10
|
+
* 1. **the set** — the query as saved: `scope`, then `where`, ordered by `sort`,
|
|
11
|
+
* cut by `limit`;
|
|
12
|
+
* 2. **`narrow`** — the fetch's narrowing of that set: `where` and `match` filter
|
|
13
|
+
* what the set holds, `sort` re-orders what is left (the set's order when there
|
|
14
|
+
* is none), and `limit` cuts the rest.
|
|
15
|
+
*
|
|
16
|
+
* So a narrowing never reaches past the set: its `limit` can cut the set and never
|
|
17
|
+
* extend it. A host's records service evaluates the same two levels at the source.
|
|
18
|
+
*
|
|
19
|
+
* ⚠️ `narrow.cursor` is not evaluated here: a cursor resumes a service's answer, and
|
|
20
|
+
* no local source issues one.
|
|
21
|
+
*
|
|
22
|
+
* INTENTIONALLY A LEAF — pure JS, no `node:*`, no DOM, no package-root import — so
|
|
23
|
+
* the build and a browser runtime evaluate with the same code.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
import { match as matchWhere } from './where.js'
|
|
27
|
+
import { applyScope } from './scope.js'
|
|
28
|
+
import { sortRecords } from './sort.js'
|
|
29
|
+
import { routeParamValues } from './route-match.js'
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Evaluate a resolved config's set and its narrowing over records.
|
|
33
|
+
*
|
|
34
|
+
* @param {Array<Object>|*} records - what the source returned; anything but an array
|
|
35
|
+
* is returned as it is (a single record is not filtered, sorted or cut)
|
|
36
|
+
* @param {Object} config - a resolved fetch config
|
|
37
|
+
* @param {Object} [options]
|
|
38
|
+
* @param {string|null} [options.locale] - the page's locale, which texts sort in
|
|
39
|
+
* @param {(items: Array, sort: *, options: { locale: string|null }) => Array} [options.sort] -
|
|
40
|
+
* the sort to apply, `sortRecords` by default. A lane that answers a bad `sort:`
|
|
41
|
+
* differently — the runtime delivers the records unsorted in production — passes its own.
|
|
42
|
+
* @returns {Array<Object>|*} the records the config selects, in its order
|
|
43
|
+
*/
|
|
44
|
+
export function evaluateQuery(records, config, { locale = null, sort = sortRecords } = {}) {
|
|
45
|
+
if (!Array.isArray(records) || !config || typeof config !== 'object') return records
|
|
46
|
+
const order = (items, expr) => sort(items, expr, { locale })
|
|
47
|
+
|
|
48
|
+
let out = records
|
|
49
|
+
if (typeof config.scope === 'string' && config.scope) out = applyScope(out, config.scope)
|
|
50
|
+
if (config.where) out = matchWhere(config.where, out)
|
|
51
|
+
if (config.sort) out = order(out, config.sort)
|
|
52
|
+
if (isLimit(config.limit)) out = out.slice(0, config.limit)
|
|
53
|
+
|
|
54
|
+
const narrow = config.narrow
|
|
55
|
+
if (!narrow || typeof narrow !== 'object') return out
|
|
56
|
+
if (narrow.where) out = matchWhere(narrow.where, out)
|
|
57
|
+
if (narrow.match && typeof narrow.match === 'object') out = out.filter(matching(narrow.match))
|
|
58
|
+
if (narrow.sort) out = order(out, narrow.sort)
|
|
59
|
+
if (isLimit(narrow.limit)) out = out.slice(0, narrow.limit)
|
|
60
|
+
return out
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** A count that cuts: a whole number above 0. Absent, `0` or anything else is every record. */
|
|
64
|
+
function isLimit(limit) {
|
|
65
|
+
return typeof limit === 'number' && limit > 0
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* A `match` — the record a parametric page's URL names — as a filter: the key is the
|
|
70
|
+
* record field `routeRecordKey` maps a route param to (`$name`, `$uuid`, or the field
|
|
71
|
+
* itself), and its value is compared as a STRING, member-wise on a list, by the one
|
|
72
|
+
* rule every lane matches a route param with (`routeParamValues`).
|
|
73
|
+
*/
|
|
74
|
+
function matching(match) {
|
|
75
|
+
const entries = Object.entries(match)
|
|
76
|
+
return (record) => entries.every(([key, value]) => {
|
|
77
|
+
const target = String(value)
|
|
78
|
+
return routeParamValues(record, paramNameOf(key)).some((held) => held === target)
|
|
79
|
+
})
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** The inverse of `routeRecordKey`: `$name` is a `[slug]` page's handle, `$uuid` a `[uuid]` page's identity. */
|
|
83
|
+
function paramNameOf(key) {
|
|
84
|
+
if (key === '$name') return 'slug'
|
|
85
|
+
if (key === '$uuid') return 'uuid'
|
|
86
|
+
return key
|
|
87
|
+
}
|
package/src/route-match.js
CHANGED
|
@@ -479,12 +479,13 @@ export function recordRouteBase(route) {
|
|
|
479
479
|
*
|
|
480
480
|
* `/blog/:slug` + `{ slug: 'a post' }` → `/blog/a%20post`. Every value is
|
|
481
481
|
* `encodeURIComponent`-ed, because the output is a URL: it is compared against
|
|
482
|
-
* `location.pathname` (`isActive(item
|
|
482
|
+
* `location.pathname` (`isActive(item.$route)`) and matched back through
|
|
483
483
|
* `matchDynamicRoute`, which decodes what it captures. A raw interpolation and an
|
|
484
484
|
* encoded one compare unequal on the first slug with a space — and they used to
|
|
485
485
|
* both exist: the build baked `${base}/${item.slug}` raw into `/data/*.json` while
|
|
486
486
|
* the runtime interpolated with encoding, and which one a site got was
|
|
487
|
-
* lane-dependent (measured 2026-09-04).
|
|
487
|
+
* lane-dependent (measured 2026-09-04). The build bakes no link since 2026-09-14;
|
|
488
|
+
* the runtime's `$route` and a search index's record URL both call this.
|
|
488
489
|
*
|
|
489
490
|
* ⛔ NOT for a file path. The SSG writes `dist/<route>/index.html` from the DECODED
|
|
490
491
|
* value on purpose — a server decodes the request path before looking a file up,
|
package/src/sort.js
CHANGED
|
@@ -104,9 +104,8 @@ export function sortToWire(sort) {
|
|
|
104
104
|
* Numbers, ISO dates and booleans compare as values.
|
|
105
105
|
*
|
|
106
106
|
* ⭐ A RECORD WITH NO VALUE FOR THE KEY SORTS LAST, IN EITHER DIRECTION, and records
|
|
107
|
-
* that compare equal keep their order — the language both lanes answer
|
|
108
|
-
*
|
|
109
|
-
* 2026-09-13). "No value" is what `exists: false` means: missing, `null`, `""` — and a
|
|
107
|
+
* that compare equal keep their order — the language both lanes answer (as a host's
|
|
108
|
+
* records service answers it since 2026-09-13). "No value" is what `exists: false` means: missing, `null`, `""` — and a
|
|
110
109
|
* list, since nothing says which member orders the record (a dotted path that meets
|
|
111
110
|
* a list reads no value). ⛔ Until 2026-09-14 a missing value sorted as the empty
|
|
112
111
|
* string — first ascending, last descending — so a static site and a hosted one put
|