@uniweb/core 0.26.2 → 0.28.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.
@@ -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
- * Is this fetch declaration a per-instance *refinement* of an ancestor's
57
- * config rather than a new source of its own?
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
- * The spelling is `refine: true`. Its earlier alias, `inherit: true`, was
60
- * accepted with a warning from April 2026 and removed on 2026-09-02: the build
61
- * refuses it with an error, and `EntityStore` refuses it in dev. This predicate
62
- * stays silent so it is safe in any environment.
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
- * Auto-inject `detail:` on a query ref whose query declares
93
- * `deferred:` fields.
94
- *
95
- * A deferred query ships a lean list payload, so the full record has to
96
- * come from somewhere else. Two patterns, picked by what the query
97
- * declares:
98
- *
99
- * - the query has `detailUrl:` → use it verbatim (a remote source);
100
- * - otherwise → `/data/<schema>/{slug}.json`, the per-record file emitted
101
- * alongside the lean list.
102
- *
103
- * Conventions carried from the original implementation:
104
- * - Per-record sources are keyed by `item.slug`, and the injected pattern
105
- * uses the `{slug}` placeholder. Substitution works when the dynamic
106
- * route's paramName is `slug` (the documented convention); a route using
107
- * another param name needs an explicit author-written `detail:`.
108
- * - Per-record files are not currently localized. A site needing localized
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
- const pattern = typeof collConfig.detailUrl === 'string'
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
- const asked = { ...rest, ask, schema }
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 never applies `scope`, fixed or routed. So those travel here
226
- // and are bound per page, exactly as they are on the service
227
- // (`bindRouteVariables`, below). A fetch's own `scope` / `where` win, as they do
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
- if (decl) {
233
- if (out.scope === undefined && typeof decl.scope === 'string') out.scope = decl.scope
234
- if (out.where === undefined) {
235
- const routed = routeVariableClauses(decl.where)
236
- if (routed) out.where = routed
237
- }
238
- }
239
- return out
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, so a page's own can replace it); the rest is exactly
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-schema in `site.yml fetcher.transports`.
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-schema selection in
152
- * `ctx.website.config.fetcher.transports[schema]` → `.transports.default`.
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
@@ -0,0 +1,31 @@
1
+ /**
2
+ * Field paths — the ONE rule for which dotted keys the query language admits.
3
+ *
4
+ * A `where` key and a `sort` field name a field of the record, or a path into it:
5
+ * `tenure.start`, `education.degree`. Every `.` is a step. ⭐ Two shapes are outside
6
+ * the language on both lanes (the records service's rule, stated by backend on
7
+ * 2026-09-14): a path with an **empty step** (`a..b`, `.a`, `a.`), and a path whose first
8
+ * step starts with **`$`** (`$meta.x`). A single `$` field — `$name`, `$uuid` — is not a
9
+ * path and stays a field. A `where` key outside the language selects no records; a
10
+ * `sort` field outside it is refused where it is authored.
11
+ *
12
+ * Zero-dependency leaf: the where evaluator and the sort evaluator both read it.
13
+ */
14
+
15
+ /**
16
+ * Why a field key is outside the language, or null when it is inside.
17
+ *
18
+ * @param {string} key
19
+ * @returns {string|null} the problem, as a sentence
20
+ */
21
+ export function fieldPathProblem(key) {
22
+ if (typeof key !== 'string' || key.indexOf('.') === -1) return null
23
+ const steps = key.split('.')
24
+ if (steps.some((step) => step === '')) {
25
+ return `\`${key}\` has an empty step — a path is field names joined by single dots`
26
+ }
27
+ if (steps[0].startsWith('$')) {
28
+ return `\`${key}\` starts with \`$\` — a path starts at one of the record's own fields`
29
+ }
30
+ return null
31
+ }
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
+ }