@uniweb/core 0.24.3 → 0.25.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@uniweb/core",
3
- "version": "0.24.3",
3
+ "version": "0.25.0",
4
4
  "description": "Core classes for the Uniweb platform - Uniweb, Website, Page, Block",
5
5
  "type": "module",
6
6
  "exports": {
@@ -17,7 +17,8 @@
17
17
  "./section-id": "./src/section-id.js",
18
18
  "./services": "./src/services.js",
19
19
  "./sort": "./src/sort.js",
20
- "./tracker": "./src/tracker.js"
20
+ "./tracker": "./src/tracker.js",
21
+ "./scope": "./src/scope.js"
21
22
  },
22
23
  "files": [
23
24
  "src"
package/src/datastore.js CHANGED
@@ -21,21 +21,30 @@
21
21
  *
22
22
  * ⭐ TWO IDENTITIES, decided by whether the request carries an ADDRESS.
23
23
  *
24
- * An ADDRESSED request — `path` or `url` — is identified by where it
25
- * goes: the address, the binding key, the unwrap, and for a POST its method and
26
- * body. Post-processing fields (`limit`, `sort`, `where`) are applied after the
27
- * fetch over one shared copy and must not split the cache; `query` and `depth`
28
- * are not hashed either, because the address already carries them (a query's
29
- * list and its per-record file are different addresses) and because a kit hook
30
- * asking for `{ path, as }` must hit the entry the page's declaration filled —
31
- * that shared cache is a documented property of `useFetched`.
24
+ * An ADDRESSED request — `path` or `url` — is identified by where it goes (the
25
+ * address, the binding key, the unwrap, and for a POST its method and body) AND by
26
+ * the view it takes of what came back: `scope`, `where`, `sort`, `limit`, which
27
+ * the default fetcher evaluates locally over the file. `query` is not hashed: the
28
+ * address already carries it (a query's list and its per-record file are
29
+ * different addresses).
30
+ *
31
+ * ⛔ **The view was left out until 2026-09-11, on the belief that operators "are
32
+ * applied after the fetch over one shared copy".** They were applied BEFORE the
33
+ * dispatcher stored the answer, so one file with two views shared ONE entry, and
34
+ * whoever asked first decided what everyone got — measured: an unfiltered request
35
+ * received the two records a `where` had kept. Each view is now its own entry, and
36
+ * views asked together share one read of the file (`runtime/src/default-fetcher.js`).
37
+ * A kit hook asking for `{ path, as }` hits the entry of a declaration with the
38
+ * same view, which is the property `useFetched` documents — *"a declarative fetch
39
+ * for the same request."*
32
40
  *
33
41
  * An ADDRESS-LESS request — a QUESTION sent to the records service — is
34
- * identified by the question: `query`, `schema`, `scope`, `where`, `sort`, `limit`, `depth`.
35
- * ⛔ The reason: with no per-query address, two pages
42
+ * identified by the question: `query`, `schema`, `scope`, `where`, `match`,
43
+ * `sort`, `limit`, `whole`. ⛔ The reason: with no per-query address, two pages
36
44
  * binding one `as` to two queries would otherwise share an entry, and a list
37
- * (brief) and a record (full) of one query would collide — the one defect on
38
- * this path that delivers WRONG data rather than none.
45
+ * (brief) and a record (whole) of one query would collide — the one defect on
46
+ * this path that delivers WRONG data rather than none. `match` is the record a
47
+ * parametric page asks for; without it every record's question is one entry.
39
48
  *
40
49
  * `locale` is hashed on both when present: two locales' answers must not share
41
50
  * an entry, and an asked config always carries the locale it was asked in.
@@ -53,15 +62,15 @@ export function deriveCacheKey(request) {
53
62
  ? request.method.toUpperCase()
54
63
  : undefined
55
64
  const body = method === 'POST' ? request?.body : undefined
65
+ const { query, schema, scope, where, match, sort, limit, whole } = request || {}
56
66
  if (path || url) {
57
67
  // ⚠️ The field NAME is part of the hash, so renaming it moves every key ONCE.
58
68
  // In-memory stores repopulate; a consumer with a persistent cache takes one
59
69
  // cold pass. Chosen over hashing under the old name, which would have hidden
60
70
  // the rename inside the one function whose job is to be canonical.
61
- return JSON.stringify({ path, url, as, transform, method, body, locale })
71
+ return JSON.stringify({ path, url, as, transform, method, body, locale, scope, where, sort, limit })
62
72
  }
63
- const { query, schema, scope, where, sort, limit, whole } = request || {}
64
- return JSON.stringify({ query, schema, scope, where, sort, limit, whole, as, transform, locale })
73
+ return JSON.stringify({ query, schema, scope, where, match, sort, limit, whole, as, transform, locale })
65
74
  }
66
75
 
67
76
  /**
package/src/detail-url.js CHANGED
@@ -22,7 +22,7 @@
22
22
  * a matched route pattern says nothing about the record behind it.
23
23
  */
24
24
 
25
- import { recordHandle } from './route-match.js'
25
+ import { recordHandle, routeRecordKey } from './route-match.js'
26
26
  import { substitutePlaceholders } from './substitute-placeholders.js'
27
27
 
28
28
  /**
@@ -90,15 +90,13 @@ function paramContext(paramName, paramValue, record) {
90
90
  * @returns {Object|null} A fetch config carrying `url` or `path`, or null.
91
91
  */
92
92
  /**
93
- * The key a route param narrows a QUESTION by — the entry's own handle, which
94
- * the records service guarantees unique among siblings (the records contract
95
- * §1b: `$name`, "addressed AND filtered"). ⚠️ One constant, because the spelling
96
- * moved four times in one day (`path_segment` → `$slug` → `$name` → `meta::name`
97
- * → `$name`); the service is dark until a host stamps it, so this is the one
98
- * place to change if it moves again. Not read by the file lane, which narrows
99
- * by the route's own param (`item[paramName]`).
93
+ * The record key a `[slug]` (or `[...path]`) route matches — the entry's own
94
+ * handle, which the records service guarantees unique among siblings (the
95
+ * records contract §1b: `$name`). Kept as the export it has been; the map from
96
+ * every folder name to its key is `routeRecordKey` (`./route-match.js`), whose
97
+ * `slug` entry this is.
100
98
  */
101
- export const ROUTE_HANDLE_KEY = '$name'
99
+ export const ROUTE_HANDLE_KEY = routeRecordKey('slug')
102
100
 
103
101
  export function buildDetailConfig(queryConfig, dynamicContext) {
104
102
  const { detail } = queryConfig
@@ -106,17 +104,22 @@ export function buildDetailConfig(queryConfig, dynamicContext) {
106
104
  const { paramName, paramValue, record = null } = dynamicContext
107
105
  if (!paramName || paramValue === undefined) return null
108
106
 
109
- // ⭐ THE RECORDS SERVICE: the record is the list's own question, narrowed to
110
- // one entry by its handle and asked in full — the list page and the detail
111
- // page are the same query, differing only by whether the parameter is bound
112
- // (the records contract, §1a). `sort` and `limit` are the list's and drop;
113
- // `scope` and the authored `where` stay, so a scoped query cannot be escaped
114
- // through the URL.
107
+ // ⭐ THE RECORDS SERVICE: the record is the route query's own question, unchanged,
108
+ // plus `match` — the one key the page's folder names and the URL's value for it
109
+ // (`routeRecordKey`: `[slug]` → `$name`, `[uuid]` → `$uuid`, `[id]` → `id`) —
110
+ // asked in full. `sort` and `limit` are the list's and drop; `scope` and the
111
+ // authored `where` stay AS WRITTEN, so a query's conditions cannot be escaped
112
+ // through the URL (the records contract, §1a).
113
+ //
114
+ // ⛔ `where` is never touched. Until 2026-09-11 the handle was merged into it,
115
+ // which replaced any condition the author had on the same key; `where` is the
116
+ // author's and `match` is the visitor's [Diego]. The value is a string — it is a
117
+ // URL segment — and the service compares it as one, as the local match does.
115
118
  if (queryConfig.ask) {
116
- const { sort, limit, detail: _detail, ...rest } = queryConfig
119
+ const { sort, limit, detail: _detail, match: _match, ...rest } = queryConfig
117
120
  return {
118
121
  ...rest,
119
- where: { ...(queryConfig.where && typeof queryConfig.where === 'object' ? queryConfig.where : {}), [ROUTE_HANDLE_KEY]: String(paramValue) },
122
+ match: { [routeRecordKey(paramName)]: String(paramValue) },
120
123
  whole: true,
121
124
  dynamicContext: { paramName, paramValue },
122
125
  }
@@ -14,7 +14,7 @@
14
14
  * and in-flight dedup.
15
15
  */
16
16
 
17
- import { isFetchRefinement, resolveFetchConfigs } from './fetch-config.js'
17
+ import { isFetchRefinement, resolveFetchConfigs, routeQuery, sectionFetches } from './fetch-config.js'
18
18
  import { fillRoutePattern, routeParamValue } from './route-match.js'
19
19
  import { sortRecords } from './sort.js'
20
20
 
@@ -130,11 +130,19 @@ export default class EntityStore {
130
130
  const page = block.page
131
131
  const website = block.website
132
132
  const dynamicContext = block.dynamicContext || page?.dynamicContext
133
- // The route's variables, for a query that binds `:path` / `:dir` / `:slug`.
134
- // A baked page carries `params` when it has more than the one capture;
135
- // otherwise the capture itself is the only variable.
133
+ // The route's variables, for a query that binds `:path` / `:dir` / `:slug` —
134
+ // `routeBinding`'s, carried as `params` by every parametric page this runtime
135
+ // or its build makes. A page that carries only its capture gets the same three
136
+ // names from it: one segment is `:slug` and `:path`, with no `:dir`.
136
137
  const variables = dynamicContext
137
- ? (dynamicContext.params ?? { [dynamicContext.paramName]: dynamicContext.paramValue })
138
+ ? (dynamicContext.params ?? (dynamicContext.paramValue !== undefined
139
+ ? {
140
+ [dynamicContext.paramName]: dynamicContext.paramValue,
141
+ path: dynamicContext.paramValue,
142
+ dir: '',
143
+ slug: dynamicContext.paramValue,
144
+ }
145
+ : null))
138
146
  : null
139
147
 
140
148
  return resolveFetchConfigs(
@@ -162,6 +170,33 @@ export default class EntityStore {
162
170
  )
163
171
  }
164
172
 
173
+ /**
174
+ * The binding key whose records a parametric page's URL narrows to one — its
175
+ * ROUTE QUERY (`routeQuery`, `./fetch-config.js`), worked out from the same
176
+ * sources `_findFetchConfigs` walks: the page's fetch, its parent's, the site's,
177
+ * and, when none of those declares one, the key the page's sections share. So
178
+ * what a section receives and what the URL narrows are read off one walk and
179
+ * cannot disagree.
180
+ *
181
+ * ⛔ This was `dynamicContext.schema`, stored on the page by whoever built it —
182
+ * the SPA from `parentSchema`, the static build from the parent's first
183
+ * prerendered fetch — a stored copy of a derived answer, computed by a different
184
+ * rule from the one sections are fed by. Deleted 2026-09-11 [Diego].
185
+ *
186
+ * @returns {string|null} null off a parametric page, or when it has no route query
187
+ */
188
+ _routeKey(block) {
189
+ const dynamicContext = block.dynamicContext || block.page?.dynamicContext
190
+ if (!dynamicContext) return null
191
+ const page = block.page
192
+ return routeQuery({
193
+ page: page?.fetch,
194
+ parent: page?.parent?.fetch,
195
+ site: block.website?.config?.fetch,
196
+ sections: sectionFetches(page?._bodySections),
197
+ })?.key ?? null
198
+ }
199
+
165
200
  /**
166
201
  * Post-process assembled records: for each fetch config that declares a
167
202
  * `detailPage` page-ref, resolve it to a locale route template (O(1), via the
@@ -242,7 +277,7 @@ export default class EntityStore {
242
277
  const data = {}
243
278
  let allCached = true
244
279
 
245
- const routeSchema = dynamicContext?.schema
280
+ const routeSchema = this._routeKey(block)
246
281
 
247
282
  for (const [schema, cfg] of configs) {
248
283
  const isRouteQuery = dynamicContext && schema === routeSchema
@@ -370,7 +405,7 @@ export default class EntityStore {
370
405
  reportFetchFailure(this.dev, block, key, cfg, message)
371
406
  }
372
407
 
373
- const routeSchema = dynamicContext?.schema
408
+ const routeSchema = this._routeKey(block)
374
409
 
375
410
  for (const [schema, cfg] of configs) {
376
411
  const isRouteQuery = dynamicContext && schema === routeSchema
@@ -42,7 +42,11 @@
42
42
  * WHAT THIS DOES NOT OWN: where the sources come from. A caller holding a live
43
43
  * object graph reads them off the graph; a caller holding a content document
44
44
  * reads them off the JSON. Both hand the same ordered array to
45
- * `resolveFetchConfigs`. That difference is real and stays with the caller.
45
+ * `resolveFetchConfigs`. That difference is real and stays with the caller —
46
+ * but WHICH page is the parent is not: every caller finds it by one rule,
47
+ * `parentRouteOf` in `./route-match.js`. ⛔ Five places found it four ways until
48
+ * 2026-09-11, and a prefetch that read one field while the SPA inferred the rest
49
+ * prefetched nothing for a parametric page.
46
50
  */
47
51
 
48
52
  import { queryDataUrl, isDataUrl, recordDataUrl } from './data-paths.js'
@@ -182,6 +186,9 @@ function applyDeferredDetail(cfg, queries) {
182
186
  */
183
187
  function resolveQuerySource(cfg, services, { queries = null, locale = null, defaultLocale = null } = {}) {
184
188
  if (typeof cfg.query !== 'string' || cfg.query.length === 0) return cfg
189
+ const decl = queries && typeof queries === 'object' && queries[cfg.query] && typeof queries[cfg.query] === 'object'
190
+ ? queries[cfg.query]
191
+ : null
185
192
 
186
193
  // ⭐ THE SERVICE FIRST. A host that answers questions gets the whole query —
187
194
  // `schema`, `scope`, `where`, `sort`, `limit`, `depth` — and composes no
@@ -191,7 +198,6 @@ function resolveQuerySource(cfg, services, { queries = null, locale = null, defa
191
198
  // says so. ⚠️ Dark until a host stamps the row; see `resolveRecordsService`.
192
199
  const ask = resolveRecordsService(services, locale ?? defaultLocale)
193
200
  if (ask) {
194
- const decl = queries && typeof queries === 'object' ? queries[cfg.query] : null
195
201
  const schema = typeof decl?.schema === 'string' && decl.schema ? decl.schema : null
196
202
  // Drop the transitional `path`: two addresses on one request is an
197
203
  // ambiguity the fetcher would have to break by accident of field order.
@@ -212,7 +218,25 @@ function resolveQuerySource(cfg, services, { queries = null, locale = null, defa
212
218
  if (asked.limit === undefined && typeof decl.limit === 'number' && decl.limit > 0) asked.limit = decl.limit
213
219
  return asked
214
220
  }
215
- return { ...cfg, path: queryDataUrl(cfg.query) }
221
+
222
+ // ⭐ THE COMPILED FILE. The build applied the named query's fixed `where` when it
223
+ // wrote `/data/<query>.json`; what it could NOT apply is a clause bound to the
224
+ // 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
229
+ // ignored on this lane and `where: { tag: :dir }` was applied at build to the
230
+ // literal `':dir'`, compiling to no records (measured).
231
+ 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
216
240
  }
217
241
 
218
242
  /**
@@ -269,10 +293,11 @@ function bindingKey(cfg) {
269
293
  * @param {Object|null} [options.services] - the site's `config.services`. The
270
294
  * `records` row is a host's live-records lane; absent means the compiled
271
295
  * artifact answers, which is the whole of what a site with no backend needs.
272
- * @param {Object|null} [options.variables] - the route's variables on a template
273
- * page (`{ path, dir, slug }` under `[...path]`, the capture under `[slug]`);
274
- * a `:path` / `:dir` / `:slug` placeholder in `where:` or `scope:` binds to
275
- * them, and an unbound one drops its clause. Null off a template page.
296
+ * @param {Object|null} [options.variables] - the route's variables on a parametric
297
+ * page (`routeBinding` in `./route-match.js`: `{ path, dir, slug }` under every
298
+ * folder form); a `:path` / `:dir` / `:slug` placeholder in `where:` or
299
+ * `scope:` binds to them, and an unbound or empty one drops its clause. Null
300
+ * off a parametric page.
276
301
  * @returns {Map<string, Object>} schema name → resolved config
277
302
  */
278
303
  export function resolveFetchConfigs(sources, options = {}) {
@@ -300,7 +325,7 @@ export function resolveFetchConfigs(sources, options = {}) {
300
325
  // which a query ref does not have until this runs.
301
326
  const sourced = resolveQuerySource(cfg, services, { queries, locale, defaultLocale })
302
327
  const localized = localizeConfig(sourced, locale, defaultLocale)
303
- const bound = foldScope(bindRouteVariables(localized, variables))
328
+ const bound = dropRootScope(bindRouteVariables(localized, variables))
304
329
  configs.set(key, stampDepthAndLocale(applyDeferredDetail(bound, queries), locale, defaultLocale))
305
330
  }
306
331
  }
@@ -311,27 +336,34 @@ export function resolveFetchConfigs(sources, options = {}) {
311
336
  /**
312
337
  * The three route variables a query may reference, and only these — `:path`,
313
338
  * `:dir`, `:slug` — as a VALUE in `where:` or as the whole `scope:`. Ruled
314
- * 2026-09-04 [Diego]: standard names, never author-chosen; a placeholder fills
315
- * a value, never a key or an operator, never `schema`.
339
+ * 2026-09-04 [Diego] and again 2026-09-11 (no `:name` under a `[name]` folder):
340
+ * standard names, never author-chosen; a placeholder fills a value, never a key
341
+ * or an operator, never `schema`.
316
342
  */
317
343
  const ROUTE_VARIABLE = /^:(path|dir|slug)$/
318
344
 
345
+ /** A variable with no value, or an empty one, binds nothing: its clause drops. */
346
+ function unset(value) {
347
+ return value === undefined || value === null || value === ''
348
+ }
349
+
319
350
  /**
320
351
  * Bind a query's route placeholders from the page's variables.
321
352
  *
322
353
  * ⭐ UNBOUND ⇒ THE CLAUSE DROPS. That is what lets ONE saved query serve both
323
- * the list page and the detail page: `where: { tag: :dir }` narrows on
324
- * `/blog/rust/my-post` and vanishes on `/blog`, where there is no `:dir`. A
325
- * variable bound to an empty string (`:dir` on a single-segment capture) is
326
- * bound, and binds the empty value. The backend answering the records service
327
- * states the same rule from its side (the records contract, §6b).
354
+ * the list page and the parametric page: `where: { tag: :dir }` narrows on
355
+ * `/blog/rust/my-post` and vanishes on `/blog`, where there is no `:dir`. ⭐ **An
356
+ * EMPTY variable drops its clause too** *(ruled 2026-09-11 [Diego])* — `:dir` on a
357
+ * one-segment URL means "no directory", not "a directory named nothing". It bound
358
+ * the empty string until then, so `where: { tag: :dir }` filtered `tag == ''` on
359
+ * `/blog/my-post`, and `scope: :dir` split a cache entry the list page shared.
328
360
  *
329
361
  * ⚠️ The price, stated where it is paid: a MISSPELLED variable is byte-identical
330
362
  * to an intentional list page. Only an authoring surface can catch that; this
331
363
  * function cannot.
332
364
  *
333
365
  * @param {Object} cfg - a resolved config
334
- * @param {Object|null} variables - `{ path, dir, slug, … }` from the route, or null off a template page
366
+ * @param {Object|null} variables - `{ path, dir, slug, … }` from the route, or null off a parametric page
335
367
  * @returns {Object} the config, with placeholders bound or their clauses dropped
336
368
  */
337
369
  function bindRouteVariables(cfg, variables) {
@@ -340,7 +372,7 @@ function bindRouteVariables(cfg, variables) {
340
372
  const name = cfg.scope.slice(1)
341
373
  const value = variables?.[name]
342
374
  const { scope, ...rest } = out
343
- out = value === undefined || value === null ? rest : { ...rest, scope: String(value) }
375
+ out = unset(value) ? rest : { ...rest, scope: String(value) }
344
376
  }
345
377
  if (cfg.where && typeof cfg.where === 'object') {
346
378
  const bound = bindWhere(cfg.where, variables)
@@ -372,7 +404,7 @@ function bindWhere(where, variables) {
372
404
  if (typeof value === 'string' && ROUTE_VARIABLE.test(value)) {
373
405
  const bound = variables?.[value.slice(1)]
374
406
  changed = true
375
- if (bound === undefined || bound === null) continue // unbound ⇒ drop
407
+ if (unset(bound)) continue // unbound or empty ⇒ drop
376
408
  next[key] = String(bound)
377
409
  continue
378
410
  }
@@ -390,23 +422,154 @@ function bindWhere(where, variables) {
390
422
  }
391
423
 
392
424
  /**
393
- * `scope:` on a lane that cannot be ASKED — the compiled file — is the same
394
- * question as `where: { path: { under: scope } }`: a record's
395
- * `path` is the folder `records.yml` placed it in, and the evaluator's `under`
396
- * is segment-aware containment. Folding it keeps the language the INTERSECTION
397
- * of both lanes: an author
398
- * writes `scope: :dir` once and it means the same branch on a static site and
399
- * on a service that takes `scope` natively. A config carrying `ask` keeps
400
- * `scope` as the service's own field.
425
+ * ⭐ `scope:` IS ITS OWN FIELD, on both lanes. The service takes it natively; on
426
+ * the compiled file the evaluators apply it to each record's placement (`path`,
427
+ * the folder `records.yml` put it in) — `@uniweb/core`'s `applyScope`, the one the
428
+ * build uses too. ⛔ Until 2026-09-11 it was folded here into
429
+ * `where: { path: { under } }`, an authored form that is now retired in its
430
+ * favour [Diego]: a branch is a scope, and `where` stays the author's predicate.
431
+ *
432
+ * The root contains everything, so an empty scope is no scope — dropped rather
433
+ * than carried, so it cannot split a cache entry from the same query without one.
401
434
  */
402
- function foldScope(cfg) {
403
- if (typeof cfg.scope !== 'string' || cfg.scope === '' || cfg.ask) return cfg
435
+ function dropRootScope(cfg) {
436
+ if (typeof cfg.scope !== 'string' || cfg.scope.replace(/^\/+|\/+$/g, '') !== '') return cfg
404
437
  const { scope, ...rest } = cfg
405
- const under = { path: { under: scope } }
406
- const where = cfg.where && typeof cfg.where === 'object' && Object.keys(cfg.where).length
407
- ? { and: [cfg.where, under] }
408
- : under
409
- return { ...rest, where }
438
+ return rest
439
+ }
440
+
441
+ /** Does this where-value hold a route variable anywhere inside it? */
442
+ function holdsRouteVariable(value) {
443
+ if (typeof value === 'string') return ROUTE_VARIABLE.test(value)
444
+ if (Array.isArray(value)) return value.some(holdsRouteVariable)
445
+ if (value && typeof value === 'object') return Object.values(value).some(holdsRouteVariable)
446
+ return false
447
+ }
448
+
449
+ /**
450
+ * The part of a where-object that depends on the route — its top-level clauses
451
+ * holding a route variable anywhere inside them — or null. The compiled file's
452
+ * build cannot evaluate those (a file is written once; the route differs per
453
+ * page), so the runtime applies them, bound per page.
454
+ *
455
+ * @param {Object|null} where
456
+ * @returns {Object|null}
457
+ */
458
+ function routeVariableClauses(where) {
459
+ if (!where || typeof where !== 'object' || Array.isArray(where)) return null
460
+ const out = {}
461
+ for (const [key, value] of Object.entries(where)) {
462
+ if (holdsRouteVariable(value)) out[key] = value
463
+ }
464
+ return Object.keys(out).length ? out : null
465
+ }
466
+
467
+ /**
468
+ * A named query's narrowing that is FIXED for every page — the top-level `where`
469
+ * clauses that hold no route variable, and its `scope` unless that is routed. The
470
+ * 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
472
+ * `routeVariableClauses`, which the runtime binds per page (`resolveQuerySource`
473
+ * carries it there). A clause is split at the TOP level and never inside: an `or`
474
+ * holding one variable goes to the runtime whole, because applying half of it at
475
+ * build would drop records the bound `or` keeps.
476
+ *
477
+ * @param {{ where?: Object, scope?: string }} query
478
+ * @returns {{ where: Object|null, scope: string|null }}
479
+ */
480
+ export function withoutRouteVariables({ where = null, scope = null } = {}) {
481
+ let fixed = null
482
+ if (where && typeof where === 'object' && !Array.isArray(where)) {
483
+ const out = {}
484
+ for (const [key, value] of Object.entries(where)) {
485
+ if (!holdsRouteVariable(value)) out[key] = value
486
+ }
487
+ fixed = Object.keys(out).length ? out : null
488
+ }
489
+ const fixedScope = typeof scope === 'string' && !ROUTE_VARIABLE.test(scope) && scope.replace(/^\/+|\/+$/g, '') !== ''
490
+ ? scope
491
+ : null
492
+ return { where: fixed, scope: fixedScope }
493
+ }
494
+
495
+ /** A `fetch` as a list: one declaration, several, or none. */
496
+ function fetchList(fetch) {
497
+ if (!fetch) return []
498
+ return Array.isArray(fetch) ? fetch.filter(Boolean) : [fetch]
499
+ }
500
+
501
+ /**
502
+ * Every section-level `fetch` on a page, nested sections included — the input
503
+ * `routeQuery` falls back to. Plain data, so a caller holding a content document
504
+ * and a caller holding the object graph (`page._bodySections`) pass the same thing.
505
+ *
506
+ * @param {Array<Object>|undefined} sections - a page's raw sections
507
+ * @returns {Array<Object|Object[]>}
508
+ */
509
+ export function sectionFetches(sections) {
510
+ const out = []
511
+ const walk = (list) => {
512
+ for (const s of list || []) {
513
+ if (!s || typeof s !== 'object') continue
514
+ if (s.fetch) out.push(s.fetch)
515
+ if (Array.isArray(s.subsections)) walk(s.subsections)
516
+ }
517
+ }
518
+ walk(sections)
519
+ return out
520
+ }
521
+
522
+ /**
523
+ * ⭐ THE ROUTE QUERY of a parametric page — the query its URL names ONE record of,
524
+ * and so the binding key the record is delivered under. The rule, ruled
525
+ * 2026-09-11 [Diego]:
526
+ *
527
+ * 1. the page's own query, else its parent page's, else the site's — the first
528
+ * `as` of the closest of those that declares one;
529
+ * 2. if none does, the key the page's own sections declare, when they all
530
+ * declare the same one;
531
+ * 3. otherwise none: the page has no record, and nothing narrows.
532
+ *
533
+ * ⭐ WHY THE PAGE LEVEL CHOOSES, not each section: which URLs exist, the page's
534
+ * title, not-found, the static build's expansion and a host's record index each
535
+ * need ONE answer per page. Who RECEIVES the record is the four-level cascade: a
536
+ * section the route key reaches — its own declaration of that key included —
537
+ * gets the record, and its own query under another key is left as declared.
538
+ *
539
+ * ⛔ This replaces `parentSchema`, which our build computed from the closest
540
+ * ancestor page with a query at ANY depth and never from the page's own query or
541
+ * the site's — a different rule from the one sections are fed by, so the URL
542
+ * narrowed nothing, or a key no section received (measured 2026-09-10).
543
+ *
544
+ * Plain data in, so every lane — the entity store, the SPA's parametric page, the
545
+ * prefetch, the static build, a host — calls this and computes no copy.
546
+ *
547
+ * @param {Object} levels
548
+ * @param {Object|Object[]|null} [levels.page] - the page's own `fetch`
549
+ * @param {Object|Object[]|null} [levels.parent] - its parent page's `fetch` (by `parentRouteOf`)
550
+ * @param {Object|Object[]|null} [levels.site] - the site's `fetch`
551
+ * @param {Array<Object|Object[]>} [levels.sections] - the page's section `fetch`es (`sectionFetches`)
552
+ * @returns {{ key: string, config: Object, level: 'page'|'parent'|'site'|'sections' } | null}
553
+ */
554
+ export function routeQuery({ page = null, parent = null, site = null, sections = [] } = {}) {
555
+ for (const [level, fetch] of [['page', page], ['parent', parent], ['site', site]]) {
556
+ for (const cfg of fetchList(fetch)) {
557
+ if (isFetchRefinement(cfg)) continue
558
+ const key = bindingKey(cfg)
559
+ if (key) return { key, config: cfg, level }
560
+ }
561
+ }
562
+ let found = null
563
+ for (const fetch of sections || []) {
564
+ for (const cfg of fetchList(fetch)) {
565
+ if (isFetchRefinement(cfg)) continue
566
+ const key = bindingKey(cfg)
567
+ if (!key) continue
568
+ if (found && found.key !== key) return null // the sections disagree: no route query
569
+ if (!found) found = { key, config: cfg, level: 'sections' }
570
+ }
571
+ }
572
+ return found
410
573
  }
411
574
 
412
575
  /**
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 } from './fetch-config.js'
25
+ export { resolveFetchConfigs, routeQuery, sectionFetches, withoutRouteVariables } 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`.
@@ -51,11 +51,24 @@ export {
51
51
  isDataUrl
52
52
  } from './data-paths.js'
53
53
  export { evaluate as evaluateWhere, match as matchWhere } from './where.js'
54
+ // A query's `scope:` over records that carry their placement — read by the runtime's
55
+ // default fetcher and by `@uniweb/build` when it compiles a query's file.
56
+ export { applyScope, withinScope } from './scope.js'
54
57
  // The one sort evaluator and the one href encoder — both read by `@uniweb/build`
55
58
  // (materialization, the `route:` bake) and by `@uniweb/runtime` (the fallback),
56
59
  // which is what keeps the static and live lanes answering a query identically.
57
60
  export { parseSort, sortRecords, sortToWire } from './sort.js'
58
- export { fillRoutePattern, splitPathCapture, joinPathCapture, recordHandle, routeParamValue } from './route-match.js'
61
+ export {
62
+ fillRoutePattern,
63
+ splitPathCapture,
64
+ joinPathCapture,
65
+ recordHandle,
66
+ routeParamValue,
67
+ routeRecordKey,
68
+ routeBinding,
69
+ routeParamName,
70
+ parentRouteOf,
71
+ } from './route-match.js'
59
72
  export { isRichSchema } from './schemas.js'
60
73
  // ⛔ `Tracker` is NOT on the package entry. It is a FEATURE, not part of the
61
74
  // object graph this package exists to define, and putting it here made every
@@ -260,15 +260,36 @@ export function recordHandle(record) {
260
260
  }
261
261
 
262
262
  /**
263
- * The value a record carries for a route param. `slug` — the default param, and
264
- * the last segment of a `[...path]` route — is the placement handle
265
- * (`recordHandle`); any other param is a field the site chose to route by.
263
+ * The record field a route param is matched on — ONE MAP, read by the local match
264
+ * (`routeParamValue`, below) and by the records service's record question (`match`,
265
+ * `./detail-url.js`), so the two lanes cannot disagree about what a folder's name
266
+ * means. Ruled 2026-09-11 [Diego]:
267
+ *
268
+ * [slug], [...path] → $name the record's handle (a `[...path]` page's param is `slug`)
269
+ * [uuid] → $uuid the record's identity
270
+ * [anything] → anything, the record's own field of that name
271
+ *
272
+ * @param {string} paramName
273
+ * @returns {string}
274
+ */
275
+ export function routeRecordKey(paramName) {
276
+ if (paramName === 'slug') return '$name'
277
+ if (paramName === 'uuid') return '$uuid'
278
+ return paramName
279
+ }
280
+
281
+ /**
282
+ * The value a record carries for a route param, by `routeRecordKey`'s map. The two
283
+ * built-in keys fall back to the plain field a source without them carries: the
284
+ * handle to `slug` (`recordHandle`), the identity to `uuid` — so a remote API that
285
+ * routes `[uuid]` on its own `uuid` field keeps matching (ruled 2026-09-11).
266
286
  *
267
287
  * ⛔ Every reader that matches a delivered record to a route param goes through
268
- * this — the entity store, the website's dynamic page, the kit's detail hook,
269
- * the href encoder below. Until 2026-09-04 each read `item[paramName]` directly,
270
- * so a record served with `$name` and no `slug` matched nothing: a template page
271
- * on a live lane rendered `[]` and a list linked to no record.
288
+ * this — the entity store, the website's parametric page, the static build's
289
+ * expansion, the kit's detail hook, the href encoder below. Until 2026-09-04 each
290
+ * read `item[paramName]` directly, so a record served with `$name` and no `slug`
291
+ * matched nothing: a template page on a live lane rendered `[]` and a list linked
292
+ * to no record.
272
293
  *
273
294
  * @param {Object} record
274
295
  * @param {string} paramName
@@ -277,9 +298,99 @@ export function recordHandle(record) {
277
298
  export function routeParamValue(record, paramName) {
278
299
  if (!record || typeof record !== 'object') return undefined
279
300
  if (paramName === 'slug') return recordHandle(record)
301
+ if (paramName === 'uuid') {
302
+ const id = record.$uuid
303
+ return typeof id === 'string' && id.length ? id : record.uuid
304
+ }
280
305
  return record[paramName]
281
306
  }
282
307
 
308
+ /**
309
+ * A parametric page's route binding — the param the record is matched on, its
310
+ * value, and the route VARIABLES a query may reference. ONE implementation, for
311
+ * the SPA (`Website._createDynamicPage`), the prefetch and the static build.
312
+ *
313
+ * The variables are always the three standard names (ruled 2026-09-04 and
314
+ * 2026-09-11 [Diego]; no others):
315
+ *
316
+ * - `[...path]` splits its capture — `:path` the whole of it, `:dir` all but the
317
+ * last segment, `:slug` the last (`splitPathCapture`);
318
+ * - any other param is ONE segment used as is: `:slug` holds it, `:path` equals
319
+ * it, and `:dir` is empty — which drops a clause that binds it.
320
+ *
321
+ * The raw params ride along under their own names, for a component reading
322
+ * `block.dynamicContext.params`; a query cannot reference them.
323
+ *
324
+ * `paramName` is the page's declared one when it has it. A page nested inside a
325
+ * parametric page (`/members/:slug/cv`) has no bracket of its own and binds the
326
+ * DEEPEST param of its route — its nearest parametric ancestor's.
327
+ *
328
+ * @param {string} pattern - the page's route pattern (`/members/:slug`, `/docs/:path*`)
329
+ * @param {Record<string,string>} params - what `matchDynamicRoute` captured
330
+ * @param {string|null} [paramName] - the page's declared param
331
+ * @returns {{ paramName: string|null, paramValue: string|undefined, variables: Object }}
332
+ */
333
+ export function routeBinding(pattern, params = {}, paramName = null) {
334
+ const { catchAll } = routePatternToRegex(pattern)
335
+ const name = routeParamName(pattern, paramName)
336
+ if (catchAll && params[catchAll] !== undefined) {
337
+ const parts = splitPathCapture(params[catchAll])
338
+ return { paramName: name, paramValue: parts.slug, variables: { ...params, ...parts } }
339
+ }
340
+ const value = name ? params[name] : undefined
341
+ if (value === undefined) return { paramName: name, paramValue: undefined, variables: { ...params } }
342
+ return { paramName: name, paramValue: value, variables: { ...params, path: value, dir: '', slug: value } }
343
+ }
344
+
345
+ /**
346
+ * The param a parametric page's record is matched on: the page's declared one;
347
+ * else `slug` under `[...path]` (the record is its handle, the capture's last
348
+ * segment); else the route's DEEPEST param — a page nested inside a parametric
349
+ * page binds its nearest parametric ancestor's.
350
+ *
351
+ * @param {string} pattern
352
+ * @param {string|null} [declared]
353
+ * @returns {string|null}
354
+ */
355
+ export function routeParamName(pattern, declared = null) {
356
+ const { paramNames, catchAll } = routePatternToRegex(pattern)
357
+ if (declared && paramNames.includes(declared)) return declared
358
+ if (catchAll) return declared || 'slug'
359
+ if (declared && !paramNames.length) return declared
360
+ return paramNames.length ? paramNames[paramNames.length - 1] : (declared || null)
361
+ }
362
+
363
+ /**
364
+ * The page a page's data inherits from — THE ONE RULE for a page's parent, used by
365
+ * the object graph (`Website.buildPageHierarchy`), the prefetch and the static
366
+ * build, so what a section inherits and what a parametric page's URL narrows are
367
+ * read off the same parent in every lane.
368
+ *
369
+ * The declared parent (`pages[].parent`, which our collector writes) when it names
370
+ * a page; otherwise the route minus its last segment — a `:param` or `:path*` token
371
+ * included — when a page holds that route. A top-level page has none: the homepage
372
+ * is not everyone's parent.
373
+ *
374
+ * ⛔ Measured 2026-09-11, before this existed: the prefetch read the declared field
375
+ * only, so on a payload without it `/members/alice` resolved no configs at all,
376
+ * while the SPA inferred `/members` and fetched the list and the record itself.
377
+ *
378
+ * @param {string} route
379
+ * @param {Object} options
380
+ * @param {string|null} [options.declared] - the parent route the payload declares
381
+ * @param {(route: string) => boolean} options.has - whether a page holds a route
382
+ * @returns {string|null} the parent's route, or null
383
+ */
384
+ export function parentRouteOf(route, { declared = null, has } = {}) {
385
+ if (typeof has !== 'function' || typeof route !== 'string') return null
386
+ if (typeof declared === 'string' && declared && declared !== route && has(declared)) return declared
387
+ const r = normalizeRoute(route)
388
+ const cut = r.lastIndexOf('/')
389
+ if (cut <= 0) return null
390
+ const up = r.slice(0, cut)
391
+ return has(up) ? up : null
392
+ }
393
+
283
394
  /**
284
395
  * Fill a route pattern's params from a record — the ONE encoder for a record's href.
285
396
  *
package/src/scope.js ADDED
@@ -0,0 +1,56 @@
1
+ /**
2
+ * A query's `scope:` — the folder branch it reads — evaluated over records that
3
+ * carry their placement.
4
+ *
5
+ * A record's `path` is the folder `records.yml` placed it in (`build/src/site/
6
+ * query-processor.js`). A scope contains that folder and every folder below it, at
7
+ * SEGMENT boundaries: `field` holds `field` and `field/2025`, never `fieldwork`.
8
+ * The root (`''`) holds everything.
9
+ *
10
+ * ⭐ Why this is its own field and not a `where` operator: a branch is a scope,
11
+ * and `where` stays the author's predicate over the records' own fields. Ruled
12
+ * 2026-09-11 [Diego], retiring `where: { path: { under } }` — written before a
13
+ * query had `scope:` — together with the `under` operator. The records service
14
+ * takes `scope` natively; this is the same question on the lane that has no
15
+ * service: the compiled file, evaluated by the runtime's default fetcher and by
16
+ * the build that writes the file.
17
+ *
18
+ * Zero-dependency leaf.
19
+ */
20
+
21
+ function trimSlashes(s) {
22
+ return s.replace(/^\/+/, '').replace(/\/+$/, '')
23
+ }
24
+
25
+ /**
26
+ * Is a placement inside a scope?
27
+ *
28
+ * ⛔ The `+ '/'` is the whole point: a plain `startsWith` would put `2024b` inside
29
+ * `2024`, the classic prefix bug, which silently returns records from a sibling
30
+ * folder the author never named.
31
+ *
32
+ * @param {*} placement - a record's `path`
33
+ * @param {string} scope
34
+ * @returns {boolean}
35
+ */
36
+ export function withinScope(placement, scope) {
37
+ if (typeof scope !== 'string') return true
38
+ const s = trimSlashes(scope)
39
+ if (s === '') return true
40
+ if (typeof placement !== 'string') return false
41
+ const p = trimSlashes(placement)
42
+ return p === s || p.startsWith(s + '/')
43
+ }
44
+
45
+ /**
46
+ * The records inside a scope, in source order. No scope (or the root) keeps all.
47
+ *
48
+ * @param {Array<Object>} records
49
+ * @param {string|null|undefined} scope
50
+ * @returns {Array<Object>}
51
+ */
52
+ export function applyScope(records, scope) {
53
+ if (!Array.isArray(records)) return records
54
+ if (typeof scope !== 'string' || trimSlashes(scope) === '') return records
55
+ return records.filter((r) => r && typeof r === 'object' && withinScope(r.path, scope))
56
+ }
package/src/services.js CHANGED
@@ -12,10 +12,14 @@ import { applyBasePath } from './base-path.js'
12
12
  * address comes from configuration, and there are exactly two places it can
13
13
  * come from:
14
14
  *
15
- * 1. **The site**, authored — `search:`, `submit:`, `assistant:`, `tracking:`
16
- * in site.yml. The operator's own declaration, and it wins.
17
- * 2. **The host**, served — `config.services.<name>` in the payload. What the
18
- * deployment offers, which the site never had to know about.
15
+ * 1. **The host**, served — `config.services.<name>` in the payload. What the
16
+ * deployment offers, which the site never had to know about. When a host
17
+ * offers a service, that is the answer: it is the authority on what a
18
+ * hosted site is given.
19
+ * 2. **The site**, authored — `search:`, `submit:`, `assistant:`, `tracking:`
20
+ * in site.yml. The operator's own declaration — used for anything the host
21
+ * does not provide, and on a static site, where no host speaks, the whole
22
+ * answer.
19
23
  *
20
24
  * Absent from both means the site has no such service, and the caller acts on
21
25
  * that rather than guessing an address. That is the same rule for every service,
@@ -117,7 +121,37 @@ const ABSOLUTE_URL_RE = /^(?:[a-z][a-z0-9+.-]*:|\/\/)/i
117
121
  * @param {*} declaration
118
122
  * @returns {string} the endpoint, or '' when there is none
119
123
  */
124
+ /**
125
+ * Has this declaration switched the service OFF?
126
+ *
127
+ * ⭐ `false`, or an object with `enabled: false` — on either tier, for every
128
+ * name. It is the spelling `search` has always honoured
129
+ * (`Website.isSearchEnabled`), now read for every service.
130
+ *
131
+ * ⛔ **Until 2026-09-10 this module read it for NO service** — so the same
132
+ * spelling behaved two ways: `search: false` turned search off, while
133
+ * `submit: false` or `submit: { endpoint, enabled: false }` drew the form
134
+ * anyway. On a static site the authored key is the only place a service can be
135
+ * declared, so there the switch worked for search alone.
136
+ *
137
+ * ⚖️ **A site's refusal does not override a host's offer** — step 3 of
138
+ * `resolveService` says why.
139
+ *
140
+ * Module-private on purpose: it is a rule of the two readers below, not a
141
+ * question a caller should ask. `resolveService` is the question.
142
+ */
143
+ function isServiceRefused(declaration) {
144
+ return (
145
+ declaration === false ||
146
+ (declaration !== null && typeof declaration === 'object' && declaration.enabled === false)
147
+ )
148
+ }
149
+
120
150
  export function readEndpoint(declaration) {
151
+ // A refused service offers no address, whatever else its block carries — so
152
+ // a host row with `enabled: false` reads as a decline, and the records lane
153
+ // (`records-service.js`) withholds its address the same way.
154
+ if (isServiceRefused(declaration)) return ''
121
155
  if (typeof declaration === 'string') return declaration.trim()
122
156
  if (typeof declaration?.endpoint === 'string') return declaration.endpoint.trim()
123
157
  return ''
@@ -168,28 +202,42 @@ export function resolveServiceUrl(endpoint, basePath = '') {
168
202
  * @param {string} name - service name, e.g. 'submit' · 'search' · 'tracking'
169
203
  * @returns {{ url: string|null, source: 'site'|'host'|null }}
170
204
  * `url` is the whole answer for acting. `source` says which declaration
171
- * answered — a diagnostic, and the thing to check when a host's value appears
172
- * not to be taking effect. `'host'` with a null `url` means the host answered
205
+ * answered — a diagnostic, and the thing to check when a value you set
206
+ * appears not to be taking effect. `'host'` with a null `url` means the host answered
173
207
  * and offered no address; `null` means nothing declared the service at all.
174
208
  */
175
209
  export function resolveService(website, name) {
176
210
  const config = website?.config
177
211
  const basePath = website?.basePath
178
212
 
179
- // 1 — the site's own declaration wins. An operator who named an endpoint
180
- // means it, including on a host that offers one.
181
- const authored = readEndpoint(config?.[name])
182
- if (authored) {
183
- return { url: resolveServiceUrl(authored, basePath), source: 'site' }
184
- }
185
-
186
- // 2 — what the host says it offers.
213
+ // 1 — what the host OFFERS. A host that returns an address is the authority
214
+ // on what a hosted site is given: nothing the site declares overrides it —
215
+ // not its own address (step 2), not its off switch (step 3).
187
216
  const hostDeclaration = config?.services?.[name]
188
217
  const hostEndpoint = readEndpoint(hostDeclaration)
189
218
  if (hostEndpoint) {
190
219
  return { url: resolveServiceUrl(hostEndpoint, basePath), source: 'host' }
191
220
  }
192
221
 
222
+ // 2 — the site's own ADDRESS, for a service the host does not provide: an
223
+ // operator bringing their own provider — a form service, self-hosted search
224
+ // on a host that does not sell it — keeps working. On a static site no host
225
+ // speaks, so this is the whole answer. (Until 2026-09-10 it came first and
226
+ // outranked a host's offer too.)
227
+ const authored = readEndpoint(config?.[name])
228
+ if (authored) {
229
+ return { url: resolveServiceUrl(authored, basePath), source: 'site' }
230
+ }
231
+
232
+ // 3 — ⛔ A SITE THAT SWITCHED THE SERVICE OFF — but only where no host offers
233
+ // it. A host that returns an address is the authority on what a hosted site
234
+ // is given: the site asked for the service where the host manages it, and a
235
+ // hosted service is turned off there — not with a second switch here that
236
+ // would hide a service the host is still providing. On a static site no host
237
+ // speaks, so this is the only switch there is, and it decides. (It briefly
238
+ // ran first, on 2026-09-10, and beat a host's offer — the wrong way round.)
239
+ if (isServiceRefused(config?.[name])) return { url: null, source: 'site' }
240
+
193
241
  // A host may declare the name while offering no address — a decline. It is
194
242
  // still the host answering, which is all a caller can use: any *wording* for
195
243
  // that state would be ours to invent, in one language, for a visitor who has
package/src/website.js CHANGED
@@ -11,8 +11,8 @@ import FetcherDispatcher from './fetcher-dispatcher.js'
11
11
  import ObservableState from './observable-state.js'
12
12
  import { normalizeSeo } from './seo.js'
13
13
  import { resolveDefaultLocale, localeLabel } from './locale-config.js'
14
- import { matchDynamicRoute, decodeRouteValue, routePatternToRegex, splitPathCapture, routeParamValue } from './route-match.js'
15
- import { resolveFetchConfigs } from './fetch-config.js'
14
+ import { matchDynamicRoute, decodeRouteValue, routeParamValue, routeBinding, routeParamName, parentRouteOf } from './route-match.js'
15
+ import { resolveFetchConfigs, routeQuery, sectionFetches } from './fetch-config.js'
16
16
  import { buildDetailConfig } from './detail-url.js'
17
17
  import { resolveService } from './services.js'
18
18
 
@@ -382,29 +382,19 @@ export default class Website {
382
382
  pageMap.set(page.route, page)
383
383
  }
384
384
 
385
- // Link pages using the declared parent route (set by build)
385
+ // ⭐ THE ONE PARENT RULE (`parentRouteOf`, `./route-match.js`): the declared
386
+ // parent route (`pages[].parent`, set by our build) when it names a page, else
387
+ // the route minus its last segment when a page holds that route. A top-level
388
+ // page (e.g. /Features) is NOT a child of the homepage. The prefetch and the
389
+ // static build call the same rule, so the parent a section inherits from and
390
+ // the parent a parametric page's route query is read from are one page in
391
+ // every lane — a payload that omits `parent` (published payloads may) changes
392
+ // nothing. Children arrays are needed for nav filtering and getNavigableRoute.
393
+ const has = (route) => pageMap.has(route)
386
394
  for (const page of this.pages) {
387
- if (page.parentRoute) {
388
- const parent = pageMap.get(page.parentRoute)
389
- if (parent) {
390
- parent.children.push(page)
391
- page.parent = parent
392
- }
393
- }
394
- }
395
-
396
- // Fallback: infer parent-child from route structure for unlinked pages.
397
- // The editor sets parentRoute via buildEnginePreviewPayload(), but published
398
- // payloads may not include it. Infer from route nesting so children arrays
399
- // are always populated (needed for nav filtering and getNavigableRoute).
400
- // Only applies to nested routes (e.g., /Articles/index → parent /Articles).
401
- // Top-level pages (e.g., /Features) are NOT children of the homepage.
402
- for (const page of this.pages) {
403
- if (page.parent || page.route === '/') continue
404
- const inferredParent = page.route.replace(/\/[^/]+$/, '')
405
- if (!inferredParent || inferredParent === '/' || inferredParent === page.route) continue
406
- const parent = pageMap.get(inferredParent)
407
- if (parent) {
395
+ const parentRoute = parentRouteOf(page.route, { declared: page.parentRoute, has })
396
+ const parent = parentRoute ? pageMap.get(parentRoute) : null
397
+ if (parent && parent !== page) {
408
398
  parent.children.push(page)
409
399
  page.parent = parent
410
400
  }
@@ -572,39 +562,23 @@ export default class Website {
572
562
  pageData.route = concreteRoute
573
563
  pageData.isDynamic = false // No longer a template
574
564
 
575
- // ⭐ The route's variables, and the param the record is delivered by.
576
- //
577
- // `[slug]` — the one capture, under the folder's own label: `paramName` is
578
- // that label and the record is matched on `item[paramName]`.
579
- //
580
- // `[...path]` — the capture is split by the rule in `route-match.js`
581
- // (`:path` the whole capture · `:dir` everything before the last segment ·
582
- // `:slug` the last segment); the record is delivered by `slug`, its handle,
583
- // exactly as under `[slug]`, and `path` / `dir` exist for a query to bind
584
- // (`scope: :dir`, `where: { tag: :dir }`). Ruled 2026-09-04 [Diego]: the
585
- // variables are standard, never author-named.
586
- const { catchAll } = routePatternToRegex(templatePage.route)
587
- let variables = { ...params }
588
- let paramName
589
- let paramValue
590
- if (catchAll && params[catchAll] !== undefined) {
591
- const parts = splitPathCapture(params[catchAll])
592
- variables = { ...params, ...parts }
593
- paramName = originalData.paramName || 'slug'
594
- paramValue = parts.slug
595
- } else {
596
- paramName = originalData.paramName || Object.keys(params)[0]
597
- paramValue = params[paramName]
598
- }
599
- const pluralSchema = originalData.parentSchema // e.g., 'articles'
600
-
601
- // Store dynamic context for components to access
565
+ // ⭐ The route's binding — the param the record is matched on, its value, and
566
+ // the variables a query may reference — by the ONE implementation the prefetch
567
+ // and the static build call too (`routeBinding`, `./route-match.js`). The
568
+ // variables are the three standard names under every folder form (ruled
569
+ // 2026-09-04 and 2026-09-11 [Diego]): `[...path]` splits its capture;
570
+ // `[name]` is one segment, `:slug` and `:path` equal to it and `:dir` empty.
571
+ // A page nested inside a parametric page binds its ancestor's param.
572
+ const { paramName, paramValue, variables } = routeBinding(templatePage.route, params, originalData.paramName)
573
+
574
+ // Store dynamic context for components to access. ⛔ No `schema`: the key the
575
+ // URL narrows is worked out where it is read (`EntityStore._routeKey`), from
576
+ // the page, never stored beside it (deleted 2026-09-11).
602
577
  pageData.dynamicContext = {
603
578
  templateRoute: templatePage.route,
604
579
  params: variables,
605
580
  paramName,
606
581
  paramValue,
607
- schema: pluralSchema,
608
582
  }
609
583
 
610
584
  // Set dynamic context on sections so Block instances receive it
@@ -614,13 +588,22 @@ export default class Website {
614
588
  }
615
589
  }
616
590
 
617
- // Try to resolve page metadata from DataStore
618
- // Look up the parent page's fetch config to find data in the store
619
- // The template's parent: the route without its `:param` — or `:path*` — tail.
620
- const parentRoute = templatePage.route.replace(/\/:[\w-]+\*?$/, '') || '/'
621
- const parentPage = this.pages.find(p => p.route === parentRoute || p.getNavRoute() === parentRoute)
591
+ // Try to resolve page metadata from DataStore: the record the page is about,
592
+ // from its ROUTE QUERY (`routeQuery`) — the page's own query, its parent's, the
593
+ // site's, or its sections' shared key — read off the same parent the entity
594
+ // store reads (`templatePage.parent`, linked by the one parent rule). ⛔ Until
595
+ // 2026-09-11 this probed only a parent found by stripping the route down to
596
+ // `/` (so a top-level page probed the homepage) and only that parent's fetch,
597
+ // so a page whose route query was its own or the site's never got its title.
598
+ const parentPage = templatePage.parent
599
+ const route = routeQuery({
600
+ page: originalData.fetch,
601
+ parent: parentPage?.fetch,
602
+ site: this.config?.fetch,
603
+ sections: sectionFetches(originalData.sections),
604
+ })
622
605
 
623
- if (parentPage && pluralSchema) {
606
+ if (route && paramValue !== undefined) {
624
607
  // Find the record the page is ABOUT via the dispatcher's peek (a sync
625
608
  // cache probe), to set the page title / description / notFound flag
626
609
  // before the page instance is constructed.
@@ -633,23 +616,23 @@ export default class Website {
633
616
  // missed on exactly those lanes: no title, no not-found, and the page was
634
617
  // never cached (`recordsLoaded` false on every visit). Silent, on a
635
618
  // visitor's page — the "write key ≠ read key" failure.
636
- const parentFetch = parentPage.fetch
637
619
  let items = []
638
620
  let currentItem = null
639
621
 
640
- if (parentFetch && this.fetcher) {
641
- // ⛔ `as` is the binding key. This matched on `schema` alone until
642
- // 2026-09-02 — which, once the alias went, would have found nothing:
643
- // `items` stays `[]` and the page reports "Not found" for a record that
644
- // exists. Silent, and on a visitor's page.
645
- const fetchConfig = resolveFetchConfigs([parentFetch], {
646
- schemas: [pluralSchema],
647
- locale: this.getActiveLocale(),
648
- defaultLocale: this.getDefaultLocale(),
649
- queries: this.config?.queries ?? null,
650
- services: this.config?.services ?? null,
651
- variables,
652
- }).get(pluralSchema)
622
+ if (this.fetcher) {
623
+ // The same sources the store walks for a section that declares nothing,
624
+ // plus the declaring section's config when the key came from the sections.
625
+ const fetchConfig = resolveFetchConfigs(
626
+ [originalData.fetch, parentPage?.fetch, this.config?.fetch, route.level === 'sections' ? route.config : null],
627
+ {
628
+ schemas: [route.key],
629
+ locale: this.getActiveLocale(),
630
+ defaultLocale: this.getDefaultLocale(),
631
+ queries: this.config?.queries ?? null,
632
+ services: this.config?.services ?? null,
633
+ variables,
634
+ },
635
+ ).get(route.key)
653
636
  if (fetchConfig) {
654
637
  const ctx = { website: this }
655
638
  // ⭐ The page is about ONE record, so ask for that record first: a
@@ -689,10 +672,11 @@ export default class Website {
689
672
 
690
673
  // Track whether the records were available at creation time.
691
674
  // Note: the matched record and the sibling list are intentionally NOT
692
- // stored on dynamicContext — nothing reads them (documented shape is
693
- // { paramName, paramValue, schema }; the record reaches components via
694
- // content.data, siblings via `fetch: { refine: true, detail: false }`).
695
- // The local `currentItem`/`items` above drive title/description/notFound.
675
+ // stored on dynamicContext — nothing reads them (its shape is
676
+ // { templateRoute, params, paramName, paramValue }; the record reaches
677
+ // components via content.data, siblings via
678
+ // `fetch: { refine: true, detail: false }`). The local
679
+ // `currentItem`/`items` above drive title/description/notFound.
696
680
  pageData._recordsLoaded = items.length > 0 || currentItem !== null
697
681
  }
698
682
 
@@ -884,25 +868,39 @@ export default class Website {
884
868
  }
885
869
 
886
870
  /**
887
- * The route template that renders ONE record of a binding key — `{ route,
888
- * paramName }` for the `[param]` page whose parent query lands under `key`,
889
- * or null when the site routes no detail page over it.
871
+ * The parametric page that renders ONE record of a query — `{ route, paramName }`
872
+ * for the page whose route query (`routeQuery`) lands under `name`, or null when
873
+ * the site routes no such page.
890
874
  *
891
- * ⭐ This is how a caller outside a template page learns which record field
875
+ * ⭐ This is how a caller outside a parametric page learns which record field
892
876
  * the site's URL is built on: `kit`'s `useEntityDetail` asks it so a hover
893
877
  * card and the page it links to address the record by the SAME field. It
894
878
  * hardcoded `slug` until 2026-09-04, which was quietly wrong on any site
895
- * routing `[id]`. The first template found
896
- * wins, matching `parentSchema`'s own rule that one key indexes one template.
879
+ * routing `[id]`.
897
880
  *
898
- * @param {string} key - a binding key (`content.data.<key>`)
881
+ * `name` may be the binding key (`content.data.<key>`) or the query's name:
882
+ * `useEntityDetail` holds the query name, and the two differ under an `as:`
883
+ * override — which this matched by binding key only until 2026-09-11. The first
884
+ * page found wins, and only a page whose own folder is the parameter (a page
885
+ * nested inside one is not the record's page).
886
+ *
887
+ * @param {string} name - a binding key or a query name
899
888
  * @returns {{ route: string, paramName: string } | null}
900
889
  */
901
- detailTemplateFor(key) {
902
- if (!key) return null
890
+ detailTemplateFor(name) {
891
+ if (!name) return null
903
892
  for (const data of this._dynamicPageData.values()) {
904
- if (data?.parentSchema === key && data.paramName) {
905
- return { route: data.route, paramName: data.paramName }
893
+ if (!data?.route || !/\/:[A-Za-z0-9_-]+\*?$/.test(data.route)) continue
894
+ const page = this.pages.find((p) => p.route === data.route)
895
+ const route = routeQuery({
896
+ page: data.fetch,
897
+ parent: page?.parent?.fetch,
898
+ site: this.config?.fetch,
899
+ sections: sectionFetches(data.sections),
900
+ })
901
+ if (!route) continue
902
+ if (route.key === name || route.config?.query === name) {
903
+ return { route: data.route, paramName: routeParamName(data.route, data.paramName) }
906
904
  }
907
905
  }
908
906
  return null
@@ -1140,13 +1138,15 @@ export default class Website {
1140
1138
  * @returns {boolean}
1141
1139
  */
1142
1140
  isSearchEnabled() {
1143
- const search = this.config?.search
1144
- if (typeof search === 'boolean') {
1145
- if (!search) return false
1146
- } else if (search?.enabled === false) {
1147
- return false
1148
- }
1149
-
1141
+ // ⭐ THE ORDER IS resolveService's, the same for every service: the host's
1142
+ // offer, then the site's own endpoint, then the site's `false` /
1143
+ // `{ enabled: false }` — which decides only where no host offers search. So
1144
+ // an author's `search: false` turns the local index off on a static site
1145
+ // and does not hide search a host provides; a hosted service is turned off
1146
+ // where the host provides it. Until 2026-09-10 this method read the
1147
+ // author's switch FIRST, so `search: false` beat a host's offer — the one
1148
+ // service where it did, and the opposite of every other.
1149
+ //
1150
1150
  // ⭐ THE HOST GETS A SAY, and this is the half that reaches an already-
1151
1151
  // published foundation. A foundation bundles its own frozen copy of
1152
1152
  // `@uniweb/kit`, so a fix made in kit never reaches one built before it.
@@ -1164,14 +1164,18 @@ export default class Website {
1164
1164
  // error, and any text a visitor reads is site content — authored and
1165
1165
  // localized — never a string a service layer invents.
1166
1166
  //
1167
- // A site's OWN `search.endpoint` still wins: `resolveService` answers from
1168
- // the site tier first, so self-hosted search on a host that does not sell
1169
- // it is untouched.
1167
+ // A site's own `search.endpoint` still works where the host does not offer
1168
+ // search, so self-hosted search on a host that does not sell it is
1169
+ // untouched. Where the host offers search, its offer wins.
1170
1170
  const { url, source } = resolveService(this, 'search')
1171
- if (source === 'host' && !url) return false
1172
-
1173
- // Enabled by default — absent means enabled, and a host that publishes no
1174
- // services block at all has declined nothing (the static-host path).
1171
+ // An address from anyone — the site's own endpoint, or a host's offer.
1172
+ if (url) return true
1173
+ // 'site': switched off, with no host offering it. 'host': the host declines.
1174
+ if (source) return false
1175
+
1176
+ // Nobody spoke: enabled by default — absent means enabled, and a host that
1177
+ // publishes no services block at all has declined nothing (the static-host
1178
+ // path, where the build emits the local index).
1175
1179
  return true
1176
1180
  }
1177
1181
 
package/src/where.js CHANGED
@@ -36,7 +36,6 @@
36
36
  * nin Value is not in the listed array.
37
37
  * like Glob match (`*` any run, `?` one char). String fields only.
38
38
  * exists Field is truthy (boolean toggle).
39
- * under Path containment at SEGMENT boundaries. String fields only.
40
39
  *
41
40
  * Composition keys:
42
41
  *
@@ -44,22 +43,11 @@
44
43
  * or Array of sub-predicates; at least one must match.
45
44
  * not Single sub-predicate; must not match.
46
45
  *
47
- * `under` is for fields holding a slash-separated location — a record's
48
- * position inside the folder, a category path, a docs section. It matches
49
- * the value itself and anything below it, and it respects segment boundaries so
50
- * a sibling with a shared prefix does not match:
51
- *
52
- * { path: { under: '2024' } } matches '2024' and '2024/spring'
53
- * does NOT match '2024b'
54
- * { path: { under: '' } } matches everything (the root contains all)
55
- * { path: '2024' } plain equality — that level only
56
- *
57
- * ⭐ Why this is an operator and not a separate `recursive:` flag on a fetch
58
- * declaration: the architecture holds that a query says WHICH records the
59
- * author wants, and who evaluates it is a capability question. Recursion is
60
- * then the difference between `eq` and `under` — no new vocabulary above the
61
- * predicate, and a source free to satisfy `under` by walking one branch instead
62
- * of scanning is doing query planning, which is its business.
46
+ * ⛔ **`under` is retired (2026-09-11 [Diego]).** It existed for `where: { path:
47
+ * { under: X } }` — a folder branch, written before a query had `scope:`. A branch
48
+ * is a scope now, on both lanes (`./scope.js`), and the build refuses `under` with
49
+ * a message naming `scope:`. An operator object carrying it is no longer an
50
+ * operator object here, so it matches nothing: the build's refusal is the guard.
63
51
  *
64
52
  * Dotted paths descend into nested objects: `tenure.start: { gte: 2015 }`.
65
53
  *
@@ -71,7 +59,7 @@
71
59
 
72
60
  const COMPOSITION_KEYS = new Set(['and', 'or', 'not'])
73
61
  const OPERATORS = new Set([
74
- 'eq', 'ne', 'gt', 'gte', 'lt', 'lte', 'in', 'nin', 'like', 'exists', 'under',
62
+ 'eq', 'ne', 'gt', 'gte', 'lt', 'lte', 'in', 'nin', 'like', 'exists',
75
63
  ])
76
64
 
77
65
  /**
@@ -138,30 +126,6 @@ function evaluateClause(key, value, record) {
138
126
  return matchEqual(fieldValue, value)
139
127
  }
140
128
 
141
- /**
142
- * Path containment at segment boundaries.
143
- *
144
- * Both sides are compared with leading and trailing `/` trimmed, so an author
145
- * who writes `'/2024'` or `'2024/'` gets what they meant. An empty ancestor is
146
- * the root and contains everything — which is what makes "the whole pool"
147
- * expressible as a predicate rather than as the absence of one.
148
- *
149
- * ⛔ The `+ '/'` is the whole point: a plain `startsWith` would match `2024b`
150
- * against `2024`, which is the classic prefix bug and silently returns records
151
- * from a sibling the author never named.
152
- */
153
- function matchUnder(fieldValue, ancestor) {
154
- if (typeof fieldValue !== 'string' || typeof ancestor !== 'string') return false
155
- const a = trimSlashes(ancestor)
156
- if (a === '') return true
157
- const f = trimSlashes(fieldValue)
158
- return f === a || f.startsWith(a + '/')
159
- }
160
-
161
- function trimSlashes(s) {
162
- return s.replace(/^\/+/, '').replace(/\/+$/, '')
163
- }
164
-
165
129
  function isOperatorObject(value) {
166
130
  if (value == null || typeof value !== 'object' || Array.isArray(value)) return false
167
131
  // An operator-object's keys are all in OPERATORS. If even one key isn't
@@ -206,8 +170,6 @@ function evaluateOperator(op, opValue, fieldValue) {
206
170
  return globMatch(opValue, fieldValue)
207
171
  case 'exists':
208
172
  return Boolean(fieldValue) === Boolean(opValue)
209
- case 'under':
210
- return matchUnder(fieldValue, opValue)
211
173
  default:
212
174
  // Unknown operator → fail closed.
213
175
  return false