@uniweb/core 0.26.2 → 0.27.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/data-paths.js +2 -2
- package/src/detail-url.js +60 -119
- package/src/entity-store.js +79 -142
- package/src/fetch-config.js +275 -58
- package/src/fetcher-dispatcher.js +3 -3
- package/src/index.js +2 -2
- package/src/layout-name.js +61 -0
- package/src/website.js +34 -26
- package/src/where.js +183 -127
package/src/fetch-config.js
CHANGED
|
@@ -24,6 +24,7 @@
|
|
|
24
24
|
* rather than counted:**
|
|
25
25
|
*
|
|
26
26
|
* fetch-config → data-paths (leaf)
|
|
27
|
+
* → route-match (leaf)
|
|
27
28
|
* → records-service → substitute-placeholders (leaf)
|
|
28
29
|
* → services → base-path (leaf)
|
|
29
30
|
*
|
|
@@ -51,15 +52,32 @@
|
|
|
51
52
|
|
|
52
53
|
import { queryDataUrl, isDataUrl, recordDataUrl } from './data-paths.js'
|
|
53
54
|
import { resolveRecordsService } from './records-service.js'
|
|
55
|
+
// A leaf that imports nothing — `recordRouteBase` is the one rule for which page
|
|
56
|
+
// addresses a record, and a route query is chosen at exactly that page.
|
|
57
|
+
import { recordRouteBase } from './route-match.js'
|
|
54
58
|
|
|
55
59
|
/**
|
|
56
|
-
*
|
|
57
|
-
*
|
|
60
|
+
* ⭐ HOW A SECTION ON A PARAMETRIC PAGE USES THE PAGE'S RECORD — `current:` on its
|
|
61
|
+
* binding under the page's route key, ruled 2026-09-13 [Diego]:
|
|
58
62
|
*
|
|
59
|
-
*
|
|
60
|
-
*
|
|
61
|
-
*
|
|
62
|
-
*
|
|
63
|
+
* - `only` — the record, as a list of one. The default: every section gets it;
|
|
64
|
+
* - `exclude` — the route query's records without it: "related", "more articles";
|
|
65
|
+
* - `include` — all of them, the record among them: a previous / next pager.
|
|
66
|
+
*
|
|
67
|
+
* ⛔ It replaces `refine: true` and `detail: false`, a flag three methods consulted
|
|
68
|
+
* separately, which delivered `exclude` and nothing else, and whose `sort` and
|
|
69
|
+
* `where` changed nothing (measured 2026-09-13). The build refuses both.
|
|
70
|
+
*/
|
|
71
|
+
export const CURRENT_MODES = Object.freeze(['only', 'exclude', 'include'])
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Does a declaration still say `refine: true`?
|
|
75
|
+
*
|
|
76
|
+
* ⛔ `refine` is RETIRED (2026-09-13, for `current:` above): the build refuses it and
|
|
77
|
+
* nothing in the framework calls this. ⚠️ It stays exported because a consumer that
|
|
78
|
+
* bundles this clone read it through `./fetch-config` (pinned by
|
|
79
|
+
* `tests/fetch-config-live-contract.test.js`, 2026-09-02) — removing a name a reader
|
|
80
|
+
* imports breaks them at the commit, so it goes when that reader says it has.
|
|
63
81
|
*
|
|
64
82
|
* @param {Object} cfg - a fetch declaration
|
|
65
83
|
* @returns {boolean}
|
|
@@ -68,6 +86,41 @@ export function isFetchRefinement(cfg) {
|
|
|
68
86
|
return cfg?.refine === true
|
|
69
87
|
}
|
|
70
88
|
|
|
89
|
+
/** A resolved config's `current:` — `only` when it says nothing, or something else. */
|
|
90
|
+
export function currentOf(cfg) {
|
|
91
|
+
return CURRENT_MODES.includes(cfg?.current) ? cfg.current : 'only'
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* The list a `current: exclude` section asks for: its own view, one record longer,
|
|
96
|
+
* so that removing the page's record still leaves `limit` others. The order is
|
|
97
|
+
* narrow, sort, remove the record, limit — a `limit` counts the others.
|
|
98
|
+
*
|
|
99
|
+
* @param {Object} cfg - a resolved config under the route key
|
|
100
|
+
* @returns {Object}
|
|
101
|
+
*/
|
|
102
|
+
export function othersView(cfg) {
|
|
103
|
+
if (!cfg || typeof cfg.limit !== 'number' || cfg.limit <= 0) return cfg
|
|
104
|
+
return { ...cfg, limit: cfg.limit + 1 }
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* The records a `current: exclude` section receives, from what `othersView` asked:
|
|
109
|
+
* the list without the page's record — the first that matches the route's value, the
|
|
110
|
+
* one `only` delivers — cut to the binding's `limit`.
|
|
111
|
+
*
|
|
112
|
+
* @param {Array|*} records - what `othersView(cfg)` answered
|
|
113
|
+
* @param {Object} cfg - the section's resolved config
|
|
114
|
+
* @param {(record: Object) => boolean} isCurrent - matches the page's record
|
|
115
|
+
* @returns {Array|*}
|
|
116
|
+
*/
|
|
117
|
+
export function othersOf(records, cfg, isCurrent) {
|
|
118
|
+
if (!Array.isArray(records)) return records
|
|
119
|
+
const at = records.findIndex(isCurrent)
|
|
120
|
+
const others = at < 0 ? records : [...records.slice(0, at), ...records.slice(at + 1)]
|
|
121
|
+
return typeof cfg?.limit === 'number' && cfg.limit > 0 ? others.slice(0, cfg.limit) : others
|
|
122
|
+
}
|
|
123
|
+
|
|
71
124
|
/**
|
|
72
125
|
* Localize a fetch config that reads a local data path.
|
|
73
126
|
*
|
|
@@ -89,31 +142,23 @@ function localizeConfig(cfg, locale, defaultLocale) {
|
|
|
89
142
|
}
|
|
90
143
|
|
|
91
144
|
/**
|
|
92
|
-
*
|
|
93
|
-
*
|
|
94
|
-
*
|
|
95
|
-
*
|
|
96
|
-
*
|
|
97
|
-
*
|
|
98
|
-
*
|
|
99
|
-
*
|
|
100
|
-
*
|
|
101
|
-
*
|
|
102
|
-
*
|
|
103
|
-
*
|
|
104
|
-
*
|
|
105
|
-
*
|
|
106
|
-
*
|
|
107
|
-
*
|
|
108
|
-
*
|
|
109
|
-
* a deferred query writes its own `detail:` URL.
|
|
110
|
-
*
|
|
111
|
-
* An author-supplied `cfg.detail` always wins; this only fills the default.
|
|
112
|
-
* With no `queries` map available the config passes through untouched —
|
|
113
|
-
* deferred-detail injection is an enhancement, never a correctness
|
|
114
|
-
* requirement, so a caller that does not have query metadata still gets
|
|
115
|
-
* a usable config. That matters for hosts whose content projection may not
|
|
116
|
-
* carry query metadata at all.
|
|
145
|
+
* Say whether a resolved config has a PER-RECORD source — `detail` — so a
|
|
146
|
+
* parametric page asks for its record on its own rather than finding it in the list.
|
|
147
|
+
*
|
|
148
|
+
* - the records service → `true`: the record is the list's own question narrowed
|
|
149
|
+
* by the URL's value (`buildDetailConfig`);
|
|
150
|
+
* - an external query declaring `record:` → `true`: its own request;
|
|
151
|
+
* - a query declaring `deferred:` → `/data/<query>/{slug}.json`, the per-record
|
|
152
|
+
* file the build emits beside the lean list. Per-record files are keyed by the
|
|
153
|
+
* record's slug, and are not localized.
|
|
154
|
+
*
|
|
155
|
+
* ⛔ `detail:` is no longer AUTHORED (retired 2026-09-13 [Diego]; the build refuses
|
|
156
|
+
* it): its `rest`, `query`, pattern and `{ body, envelope }` forms are an external
|
|
157
|
+
* query's `record:` now, and `detailUrl:` went the same way. The field survives only
|
|
158
|
+
* as this resolved flag.
|
|
159
|
+
*
|
|
160
|
+
* With no `queries` map available the config passes through untouched — a caller
|
|
161
|
+
* that has no query metadata still gets a usable config.
|
|
117
162
|
*
|
|
118
163
|
* @param {Object} cfg
|
|
119
164
|
* @param {Object|null} queries - the site's `config.queries` map
|
|
@@ -125,6 +170,8 @@ function applyDeferredDetail(cfg, queries) {
|
|
|
125
170
|
// The records service answers a RECORD by the same question narrowed to it,
|
|
126
171
|
// so every asked config has a detail source; `buildDetailConfig` composes it.
|
|
127
172
|
if (cfg.ask) return { ...cfg, detail: true }
|
|
173
|
+
// An external query's `record:` request.
|
|
174
|
+
if (cfg.record && typeof cfg.record === 'object') return { ...cfg, detail: true }
|
|
128
175
|
|
|
129
176
|
|
|
130
177
|
// ⛔ **`config.queries` is keyed by QUERY NAME, so look it up by the query.**
|
|
@@ -146,12 +193,27 @@ function applyDeferredDetail(cfg, queries) {
|
|
|
146
193
|
if (!collConfig || typeof collConfig !== 'object') return cfg
|
|
147
194
|
const deferred = Array.isArray(collConfig.deferred) ? collConfig.deferred : null
|
|
148
195
|
if (!deferred || deferred.length === 0) return cfg
|
|
149
|
-
|
|
150
|
-
? collConfig.detailUrl
|
|
151
|
-
: recordDataUrl(queryName, '{slug}')
|
|
152
|
-
return { ...cfg, detail: pattern }
|
|
196
|
+
return { ...cfg, detail: recordDataUrl(queryName, '{slug}') }
|
|
153
197
|
}
|
|
154
198
|
|
|
199
|
+
/**
|
|
200
|
+
* ⭐ AN EXTERNAL QUERY — a named query with `url:`, ruled 2026-09-13 [Diego]: *"Setting
|
|
201
|
+
* `url` would classify it as external."* A public, keyless endpoint, fetched from its
|
|
202
|
+
* own address by the visitor's browser or a host's prerender, and NEVER asked of the
|
|
203
|
+
* records service. It carries `method`, `body` and `transform`, and `record:` for one
|
|
204
|
+
* record on a parametric page. An API needing a key, headers or paging is a foundation
|
|
205
|
+
* transport, not this.
|
|
206
|
+
*
|
|
207
|
+
* @param {Object|null} decl - a query declaration
|
|
208
|
+
* @returns {boolean}
|
|
209
|
+
*/
|
|
210
|
+
export function isExternalQuery(decl) {
|
|
211
|
+
return Boolean(decl) && typeof decl === 'object' && typeof decl.url === 'string' && decl.url.length > 0
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/** Keys a binding may not carry, whatever a stale payload holds — the query supplies them. */
|
|
215
|
+
const QUERY_SUPPLIED = ['scope', 'url', 'method', 'body', 'transform', 'record', 'detail', 'envelope']
|
|
216
|
+
|
|
155
217
|
/**
|
|
156
218
|
* Resolve a query reference to something the fetcher can call.
|
|
157
219
|
*
|
|
@@ -190,6 +252,28 @@ function resolveQuerySource(cfg, services, { queries = null, locale = null, defa
|
|
|
190
252
|
? queries[cfg.query]
|
|
191
253
|
: null
|
|
192
254
|
|
|
255
|
+
// ⛔ A BINDING ADAPTS ITS QUERY WITH `where`, `sort` AND `limit`, AND NOTHING ELSE —
|
|
256
|
+
// ruled 2026-09-13 [Diego]. Its own `scope` is not read (*"it belongs to the
|
|
257
|
+
// query"*; it REPLACED the query's until then), and neither is a source of its own —
|
|
258
|
+
// `url`, `method`, `body`, `transform`, a `detail` form — which an external query
|
|
259
|
+
// declares instead. The build refuses all of them on a binding; a payload that still
|
|
260
|
+
// carries one gets the query's. Dropped from a COPY on every branch below, so the
|
|
261
|
+
// config itself is read exactly as it was.
|
|
262
|
+
|
|
263
|
+
// ⭐ AN EXTERNAL QUERY answers from its own address, on every lane.
|
|
264
|
+
if (isExternalQuery(decl)) {
|
|
265
|
+
const { path, url, ...rest } = cfg
|
|
266
|
+
for (const key of QUERY_SUPPLIED) delete rest[key]
|
|
267
|
+
const out = { ...rest, url: decl.url }
|
|
268
|
+
if (typeof decl.method === 'string' && decl.method) out.method = decl.method
|
|
269
|
+
if (decl.body !== undefined && decl.body !== null) out.body = decl.body
|
|
270
|
+
if (typeof decl.transform === 'string' && decl.transform) out.transform = decl.transform
|
|
271
|
+
if (decl.record && typeof decl.record === 'object') out.record = decl.record
|
|
272
|
+
// a live endpoint is the browser's to fetch, unless the binding says otherwise
|
|
273
|
+
if (out.prerender === undefined) out.prerender = false
|
|
274
|
+
return narrowQuery(out, decl, decl.where)
|
|
275
|
+
}
|
|
276
|
+
|
|
193
277
|
// ⭐ THE SERVICE FIRST. A host that answers questions gets the whole query —
|
|
194
278
|
// `schema`, `scope`, `where`, `sort`, `limit`, `depth` — and composes no
|
|
195
279
|
// per-query address at all (the records contract, §2). It needs the query's
|
|
@@ -202,6 +286,7 @@ function resolveQuerySource(cfg, services, { queries = null, locale = null, defa
|
|
|
202
286
|
// Drop the transitional `path`: two addresses on one request is an
|
|
203
287
|
// ambiguity the fetcher would have to break by accident of field order.
|
|
204
288
|
const { path, url, ...rest } = cfg
|
|
289
|
+
for (const key of QUERY_SUPPLIED) delete rest[key]
|
|
205
290
|
if (!schema) {
|
|
206
291
|
// ⛔ LOUD, not a fallthrough. A payload that offers the service and carries no
|
|
207
292
|
// Model ref for the query cannot ask, and reading the compiled file
|
|
@@ -210,33 +295,63 @@ function resolveQuerySource(cfg, services, { queries = null, locale = null, defa
|
|
|
210
295
|
// request is made, and the block's `dataError` says exactly this.
|
|
211
296
|
return { ...rest, ask, schema: null }
|
|
212
297
|
}
|
|
213
|
-
|
|
214
|
-
// A saved query's own narrowing applies unless the fetch overrides it.
|
|
215
|
-
if (asked.scope === undefined && typeof decl.scope === 'string') asked.scope = decl.scope
|
|
216
|
-
if (asked.where === undefined && decl.where && typeof decl.where === 'object') asked.where = decl.where
|
|
217
|
-
if (asked.sort === undefined && decl.sort !== undefined && decl.sort !== null) asked.sort = decl.sort
|
|
218
|
-
if (asked.limit === undefined && typeof decl.limit === 'number' && decl.limit > 0) asked.limit = decl.limit
|
|
219
|
-
return asked
|
|
298
|
+
return narrowQuery({ ...rest, ask, schema }, decl, decl.where)
|
|
220
299
|
}
|
|
221
300
|
|
|
222
301
|
// ⭐ THE COMPILED FILE. The build applied the named query's fixed `where` when it
|
|
223
302
|
// wrote `/data/<query>.json`; what it could NOT apply is a clause bound to the
|
|
224
303
|
// route (`where: { tag: :dir }`) — a file is written once and the route differs
|
|
225
|
-
// per page — and it
|
|
226
|
-
//
|
|
227
|
-
//
|
|
228
|
-
// there. ⛔ Until 2026-09-11 nothing travelled: a named query's `scope: :dir` was
|
|
304
|
+
// per page — and it applies neither `scope` nor `limit`. So those travel here and
|
|
305
|
+
// are applied per page, exactly as the service applies them (`bindRouteVariables`,
|
|
306
|
+
// below). ⛔ Until 2026-09-11 nothing travelled: a named query's `scope: :dir` was
|
|
229
307
|
// ignored on this lane and `where: { tag: :dir }` was applied at build to the
|
|
230
308
|
// literal `':dir'`, compiling to no records (measured).
|
|
231
309
|
const out = { ...cfg, path: queryDataUrl(cfg.query) }
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
310
|
+
for (const key of QUERY_SUPPLIED) delete out[key]
|
|
311
|
+
return decl ? narrowQuery(out, decl, routeVariableClauses(decl.where)) : out
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
/**
|
|
315
|
+
* ⭐ A BINDING NARROWS ITS QUERY, AND NEVER WIDENS IT — ruled 2026-09-13 [Diego]:
|
|
316
|
+
* *"binding can narrow its query and pick its own order and count, but it can never
|
|
317
|
+
* add records the query leaves out."* One composition, so the records service is
|
|
318
|
+
* asked exactly what the compiled file is evaluated with:
|
|
319
|
+
*
|
|
320
|
+
* - `scope` — the query's; a binding has none;
|
|
321
|
+
* - `where` — the query's and the binding's must BOTH hold, as `{ and: [ … ] }`;
|
|
322
|
+
* - `sort`, `limit` — the binding's, else the query's. A binding's `limit` is not
|
|
323
|
+
* bounded by the query's: a count is how many to show, never which records exist.
|
|
324
|
+
*
|
|
325
|
+
* ⛔ Until 2026-09-13 each field of a binding REPLACED the query's here, while the
|
|
326
|
+
* static build had already baked the query's `where`, `sort` and `limit` into the
|
|
327
|
+
* file — so a binding narrowed inside the query on a static site and replaced it on
|
|
328
|
+
* a hosted one (measured: `where: { published: true }` plus `{ tag: x }` delivered
|
|
329
|
+
* published records tagged x from the file and asked the service for `{ tag: x }`).
|
|
330
|
+
*
|
|
331
|
+
* @param {Object} out - the binding, addressed
|
|
332
|
+
* @param {Object} decl - the query's declaration
|
|
333
|
+
* @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 the compiled file
|
|
335
|
+
* @returns {Object}
|
|
336
|
+
*/
|
|
337
|
+
function narrowQuery(out, decl, queryWhere) {
|
|
338
|
+
const next = { ...out }
|
|
339
|
+
if (typeof decl.scope === 'string') next.scope = decl.scope
|
|
340
|
+
const where = bothHold(queryWhere, out.where)
|
|
341
|
+
if (where) next.where = where
|
|
342
|
+
else delete next.where
|
|
343
|
+
if (next.sort === undefined && decl.sort !== undefined && decl.sort !== null) next.sort = decl.sort
|
|
344
|
+
if (next.limit === undefined && typeof decl.limit === 'number' && decl.limit > 0) next.limit = decl.limit
|
|
345
|
+
return next
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
/** Two where-objects that must both hold — `{ and: [a, b] }`, or whichever one there is. */
|
|
349
|
+
function bothHold(a, b) {
|
|
350
|
+
const some = (w) => Boolean(w) && typeof w === 'object' && !Array.isArray(w) && Object.keys(w).length > 0
|
|
351
|
+
if (some(a) && some(b)) return { and: [a, b] }
|
|
352
|
+
if (some(b)) return b
|
|
353
|
+
if (some(a)) return a
|
|
354
|
+
return null
|
|
240
355
|
}
|
|
241
356
|
|
|
242
357
|
/**
|
|
@@ -378,12 +493,26 @@ function bindRouteVariables(cfg, variables) {
|
|
|
378
493
|
const bound = bindWhere(cfg.where, variables)
|
|
379
494
|
if (bound !== cfg.where) {
|
|
380
495
|
const { where, ...rest } = out
|
|
381
|
-
out = bound === null ? rest : { ...rest, where: bound }
|
|
496
|
+
out = bound === null ? rest : { ...rest, where: soleMember(bound) }
|
|
382
497
|
}
|
|
383
498
|
}
|
|
384
499
|
return out
|
|
385
500
|
}
|
|
386
501
|
|
|
502
|
+
/**
|
|
503
|
+
* An `and` or `or` left holding ONE member once its unbound clauses dropped is that
|
|
504
|
+
* member. It is how a query whose `where` is all route clauses, joined with its
|
|
505
|
+
* binding's (`narrowQuery`), asks the list page the binding's `where` alone — the same
|
|
506
|
+
* question, and the same cache entry, as a binding of a query with no `where`.
|
|
507
|
+
*/
|
|
508
|
+
function soleMember(where) {
|
|
509
|
+
if (!where || typeof where !== 'object' || Array.isArray(where)) return where
|
|
510
|
+
const keys = Object.keys(where)
|
|
511
|
+
if (keys.length !== 1 || (keys[0] !== 'and' && keys[0] !== 'or')) return where
|
|
512
|
+
const members = where[keys[0]]
|
|
513
|
+
return Array.isArray(members) && members.length === 1 ? members[0] : where
|
|
514
|
+
}
|
|
515
|
+
|
|
387
516
|
/** Walk a where-object: bind `:var` VALUES, drop clauses whose variable is unbound. */
|
|
388
517
|
function bindWhere(where, variables) {
|
|
389
518
|
if (Array.isArray(where)) {
|
|
@@ -468,7 +597,7 @@ function routeVariableClauses(where) {
|
|
|
468
597
|
* A named query's narrowing that is FIXED for every page — the top-level `where`
|
|
469
598
|
* clauses that hold no route variable, and its `scope` unless that is routed. The
|
|
470
599
|
* build applies the `where` part when it compiles `/data/<query>.json` (it leaves
|
|
471
|
-
* `scope` to the runtime,
|
|
600
|
+
* `scope` to the runtime, which applies it with the rest of the query); the rest is exactly
|
|
472
601
|
* `routeVariableClauses`, which the runtime binds per page (`resolveQuerySource`
|
|
473
602
|
* carries it there). A clause is split at the TOP level and never inside: an `or`
|
|
474
603
|
* holding one variable goes to the runtime whole, because applying half of it at
|
|
@@ -492,6 +621,45 @@ export function withoutRouteVariables({ where = null, scope = null } = {}) {
|
|
|
492
621
|
return { where: fixed, scope: fixedScope }
|
|
493
622
|
}
|
|
494
623
|
|
|
624
|
+
/**
|
|
625
|
+
* ⭐ THE RECORDS A ROUTE QUERY SELECTS, UNCUT — the list a parametric page finds its
|
|
626
|
+
* record in, and the list its detail pages are made from. A `limit` is how many a
|
|
627
|
+
* list shows, never which records have a page: a binding `limit: 3` on `/blog` leaves
|
|
628
|
+
* `/blog/d` a record (ruled 2026-09-13 [Diego]). `sort` stays, as the selection's order.
|
|
629
|
+
*
|
|
630
|
+
* ⛔ Until 2026-09-13 the compiled-file lane looked the record up in the LIMITED list,
|
|
631
|
+
* so `/blog/d` rendered "not found" wherever its route query had a `limit`, and the
|
|
632
|
+
* static build made no page for it. The records service never had this: its record
|
|
633
|
+
* question drops `limit` (`buildDetailConfig`).
|
|
634
|
+
*
|
|
635
|
+
* @param {Object} cfg - a resolved config
|
|
636
|
+
* @returns {Object} the config itself when it has no `limit`, else a copy without one
|
|
637
|
+
*/
|
|
638
|
+
export function routeSelection(cfg) {
|
|
639
|
+
if (!cfg || typeof cfg !== 'object' || cfg.limit === undefined) return cfg
|
|
640
|
+
const { limit, ...rest } = cfg
|
|
641
|
+
return rest
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
/**
|
|
645
|
+
* ⭐ DOES THE SITE'S BINDING REACH THIS PAGE'S SECTIONS? — ruled 2026-09-13 [Diego]:
|
|
646
|
+
* *"Site is basically a virtual root page"*, *"meant to reach the layout pseudo-pages
|
|
647
|
+
* and sections because they belong to the site. and it's meant to reach the root
|
|
648
|
+
* pages."* So it reaches what a page's binding would if the site were the parent of
|
|
649
|
+
* the pages directly under it: a page with no parent page — a top-level page, the
|
|
650
|
+
* homepage included — and a layout area, which has none either. ⛔ Until then it
|
|
651
|
+
* reached every section on every page.
|
|
652
|
+
*
|
|
653
|
+
* Nothing is restructured: every lane keeps the site's fetch as the last level of
|
|
654
|
+
* its walk, and passes it for these pages only.
|
|
655
|
+
*
|
|
656
|
+
* @param {Object|null|undefined} parent - the page's parent page (by `parentRouteOf`), or null
|
|
657
|
+
* @returns {boolean}
|
|
658
|
+
*/
|
|
659
|
+
export function siteReaches(parent) {
|
|
660
|
+
return !parent
|
|
661
|
+
}
|
|
662
|
+
|
|
495
663
|
/** A `fetch` as a list: one declaration, several, or none. */
|
|
496
664
|
function fetchList(fetch) {
|
|
497
665
|
if (!fetch) return []
|
|
@@ -554,7 +722,6 @@ export function sectionFetches(sections) {
|
|
|
554
722
|
export function routeQuery({ page = null, parent = null, site = null, sections = [] } = {}) {
|
|
555
723
|
for (const [level, fetch] of [['page', page], ['parent', parent], ['site', site]]) {
|
|
556
724
|
for (const cfg of fetchList(fetch)) {
|
|
557
|
-
if (isFetchRefinement(cfg)) continue
|
|
558
725
|
const key = bindingKey(cfg)
|
|
559
726
|
if (key) return { key, config: cfg, level }
|
|
560
727
|
}
|
|
@@ -562,7 +729,6 @@ export function routeQuery({ page = null, parent = null, site = null, sections =
|
|
|
562
729
|
let found = null
|
|
563
730
|
for (const fetch of sections || []) {
|
|
564
731
|
for (const cfg of fetchList(fetch)) {
|
|
565
|
-
if (isFetchRefinement(cfg)) continue
|
|
566
732
|
const key = bindingKey(cfg)
|
|
567
733
|
if (!key) continue
|
|
568
734
|
if (found && found.key !== key) return null // the sections disagree: no route query
|
|
@@ -572,6 +738,57 @@ export function routeQuery({ page = null, parent = null, site = null, sections =
|
|
|
572
738
|
return found
|
|
573
739
|
}
|
|
574
740
|
|
|
741
|
+
/**
|
|
742
|
+
* ⭐ THE ROUTE QUERY OF ANY PAGE ON A PARAMETRIC ROUTE — a nested one included.
|
|
743
|
+
*
|
|
744
|
+
* A route variable is captured by exactly one page: the `[slug]` or `[...path]`
|
|
745
|
+
* folder's, whose route ends in it. A page nested inside that one —
|
|
746
|
+
* `pages/members/[slug]/cv/`, routed `/members/:slug/cv` — names the same record,
|
|
747
|
+
* so its route query is found by `routeQuery` AT THE CAPTURING PAGE: that page's own
|
|
748
|
+
* query, its parent's, the site's, or its sections' shared key. Ruled 2026-09-13
|
|
749
|
+
* [Diego]: *"a nested page shares the route query of the page that captured its
|
|
750
|
+
* variable"*. ⛔ Until then every lane looked one parent up from the nested page,
|
|
751
|
+
* found no query on the `[slug]` page, and `/members/alice/cv` had no record.
|
|
752
|
+
*
|
|
753
|
+
* A query the nested page declares under another key is its own data and changes
|
|
754
|
+
* nothing about what `:slug` names. The caller hands the route binding to the nested
|
|
755
|
+
* page's sections (`nested`), since the cascade reaches only one parent up.
|
|
756
|
+
*
|
|
757
|
+
* ⭐ THE SITE IS THE VIRTUAL ROOT PAGE (ruled 2026-09-13 [Diego]), so the site's
|
|
758
|
+
* binding is a route query only for a capturing page with no parent page — a
|
|
759
|
+
* top-level parametric page — the way a page's binding is its children's
|
|
760
|
+
* (`siteReaches`). ⛔ Until then it was every parametric page's last fallback.
|
|
761
|
+
*
|
|
762
|
+
* Shape-agnostic: the object graph and a content document hand in their own
|
|
763
|
+
* accessors, and every lane climbs by the one parent rule (`parentRouteOf`).
|
|
764
|
+
*
|
|
765
|
+
* @param {Object} page - the page, in the caller's shape
|
|
766
|
+
* @param {Object} access
|
|
767
|
+
* @param {(page: Object) => string} access.routeOf - its route PATTERN (a concrete page's template route)
|
|
768
|
+
* @param {(page: Object) => Object|null} access.parentOf - its parent page, or null
|
|
769
|
+
* @param {(page: Object) => Object|Object[]|null} access.fetchOf - its `fetch`
|
|
770
|
+
* @param {(page: Object) => Array<Object>|undefined} access.sectionsOf - its raw sections
|
|
771
|
+
* @param {Object|Object[]|null} [access.site] - the site's `fetch`
|
|
772
|
+
* @returns {{ key: string, config: Object, level: string, capturing: Object, nested: boolean } | null}
|
|
773
|
+
* null off a parametric route, or when the capturing page has no route query
|
|
774
|
+
*/
|
|
775
|
+
export function pageRouteQuery(page, { routeOf, parentOf, fetchOf, sectionsOf, site = null }) {
|
|
776
|
+
let capturing = page
|
|
777
|
+
for (let depth = 0; capturing && recordRouteBase(routeOf(capturing)) === null; depth++) {
|
|
778
|
+
if (depth > 64) return null // a parent cycle in a malformed payload
|
|
779
|
+
capturing = parentOf(capturing)
|
|
780
|
+
}
|
|
781
|
+
if (!capturing) return null
|
|
782
|
+
const parent = parentOf(capturing)
|
|
783
|
+
const found = routeQuery({
|
|
784
|
+
page: fetchOf(capturing),
|
|
785
|
+
parent: parent ? fetchOf(parent) : null,
|
|
786
|
+
site: parent ? null : site,
|
|
787
|
+
sections: sectionFetches(sectionsOf(capturing)),
|
|
788
|
+
})
|
|
789
|
+
return found ? { ...found, capturing, nested: capturing !== page } : null
|
|
790
|
+
}
|
|
791
|
+
|
|
575
792
|
/**
|
|
576
793
|
* Say what a resolved config will GET, so the record index can file it.
|
|
577
794
|
*
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
*
|
|
4
4
|
* Assembled by the Website from the primary foundation's named transports
|
|
5
5
|
* plus any extensions'. Resolves which fetcher handles a given request by
|
|
6
|
-
* name lookup — the site selects per
|
|
6
|
+
* name lookup — the site selects per binding key (`as`) in `site.yml fetcher.transports`.
|
|
7
7
|
*
|
|
8
8
|
* The dispatcher owns cache-key derivation, checks the DataStore, dedups
|
|
9
9
|
* concurrent in-flight requests, and passes an AbortSignal through to the
|
|
@@ -148,8 +148,8 @@ export default class FetcherDispatcher {
|
|
|
148
148
|
* Select the fetcher for a request.
|
|
149
149
|
*
|
|
150
150
|
* 1. Runtime `transport` override (editor preview) wins over everything.
|
|
151
|
-
* 2. Otherwise, look up the site's per-
|
|
152
|
-
* `ctx.website.config.fetcher.transports[
|
|
151
|
+
* 2. Otherwise, look up the site's per-key selection in
|
|
152
|
+
* `ctx.website.config.fetcher.transports[request.as]` → `.transports.default`.
|
|
153
153
|
* A named match is resolved against the registry of foundation /
|
|
154
154
|
* extension transports.
|
|
155
155
|
* 3. If the site didn't pick a name (or picked one that's not in the
|
package/src/index.js
CHANGED
|
@@ -22,7 +22,7 @@ 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, sectionFetches, withoutRouteVariables } from './fetch-config.js'
|
|
25
|
+
export { resolveFetchConfigs, routeQuery, pageRouteQuery, routeSelection, sectionFetches, siteReaches, withoutRouteVariables, CURRENT_MODES } from './fetch-config.js'
|
|
26
26
|
export { buildDetailConfig, ROUTE_HANDLE_KEY } from './detail-url.js'
|
|
27
27
|
// `isWildcardLanguages` is likewise internal — `./locale-config.js` reads it
|
|
28
28
|
// and nothing else does. Same subpath escape hatch: `@uniweb/core/locale-config`.
|
|
@@ -50,7 +50,7 @@ export {
|
|
|
50
50
|
queryNameFromUrl,
|
|
51
51
|
isDataUrl
|
|
52
52
|
} from './data-paths.js'
|
|
53
|
-
export { evaluate as evaluateWhere, match as matchWhere } from './where.js'
|
|
53
|
+
export { evaluate as evaluateWhere, match as matchWhere, whereOutsideLanguage } from './where.js'
|
|
54
54
|
// A query's `scope:` over records that carry their placement — read by the runtime's
|
|
55
55
|
// default fetcher and by `@uniweb/build` when it compiles a query's file.
|
|
56
56
|
export { applyScope, withinScope } from './scope.js'
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Layout names — the ONE rule for when two spellings name one layout.
|
|
3
|
+
*
|
|
4
|
+
* A layout is named in four places: a page's `layout:`, the foundation's
|
|
5
|
+
* `defaultLayout`, the foundation's layout components (`src/layouts/<Name>/`) and a
|
|
6
|
+
* site's `layout/<name>/` folder. ⭐ Two spellings name one layout when they match
|
|
7
|
+
* regardless of case, and with or without a trailing `Layout` — so `layout/docs/`,
|
|
8
|
+
* `layout: Docs` and a foundation's `DocsLayout` are one layout. Ruled 2026-09-13
|
|
9
|
+
* [Diego]: case — *"case-insensitive matching makes more sense"*; the suffix — *"We
|
|
10
|
+
* should not require the suffix."* ⛔ Until then a foundation layout named
|
|
11
|
+
* `DocsLayout` needed the folder `layout/DocsLayout/`, and `layout/docs/` matched
|
|
12
|
+
* nothing, silently.
|
|
13
|
+
*
|
|
14
|
+
* An exact key wins first, then one that differs in case, then one that differs by
|
|
15
|
+
* the suffix — so a map holding both `Docs` and `DocsLayout` still answers each name
|
|
16
|
+
* with its own entry.
|
|
17
|
+
*
|
|
18
|
+
* Zero-dependency leaf: the site's object graph, a host's prefetch and the build
|
|
19
|
+
* import it, and none of them should pull more of core to agree on a name.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
const LAYOUT_SUFFIX = /^(.+?)[-_]?layout$/
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* The key a layout name is compared by: lowercased, a trailing `layout` (after an
|
|
26
|
+
* optional `-` or `_`) removed when something remains. `DocsLayout`, `docs-layout`
|
|
27
|
+
* and `Docs` → `docs`; `Layout` → `layout`.
|
|
28
|
+
*
|
|
29
|
+
* @param {*} name
|
|
30
|
+
* @returns {string|null} null for anything that is not a non-empty string
|
|
31
|
+
*/
|
|
32
|
+
export function layoutNameKey(name) {
|
|
33
|
+
if (typeof name !== 'string') return null
|
|
34
|
+
const lower = name.trim().toLowerCase()
|
|
35
|
+
if (!lower) return null
|
|
36
|
+
const match = lower.match(LAYOUT_SUFFIX)
|
|
37
|
+
return match ? match[1] : lower
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* The entry a layout name names in a map keyed by layout name — the site's layout
|
|
42
|
+
* sets, the foundation's layout components, or their meta.
|
|
43
|
+
*
|
|
44
|
+
* @param {Object|null|undefined} map
|
|
45
|
+
* @param {string|null|undefined} name
|
|
46
|
+
* @returns {*} the entry, or undefined
|
|
47
|
+
*/
|
|
48
|
+
export function findLayoutEntry(map, name) {
|
|
49
|
+
if (!map || typeof map !== 'object' || typeof name !== 'string' || !name) return undefined
|
|
50
|
+
if (Object.prototype.hasOwnProperty.call(map, name)) return map[name]
|
|
51
|
+
const keys = Object.keys(map)
|
|
52
|
+
const lower = name.toLowerCase()
|
|
53
|
+
for (const key of keys) {
|
|
54
|
+
if (key.toLowerCase() === lower) return map[key]
|
|
55
|
+
}
|
|
56
|
+
const wanted = layoutNameKey(name)
|
|
57
|
+
for (const key of keys) {
|
|
58
|
+
if (layoutNameKey(key) === wanted) return map[key]
|
|
59
|
+
}
|
|
60
|
+
return undefined
|
|
61
|
+
}
|