@uniweb/core 0.24.4 → 0.25.1
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 +5 -4
- package/src/datastore.js +24 -15
- package/src/detail-url.js +20 -17
- package/src/entity-store.js +42 -7
- package/src/fetch-config.js +196 -33
- package/src/index.js +15 -2
- package/src/page.js +1 -1
- package/src/route-match.js +118 -7
- package/src/scope.js +56 -0
- package/src/website.js +85 -87
- package/src/where.js +6 -44
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@uniweb/core",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.25.1",
|
|
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"
|
|
@@ -43,8 +44,8 @@
|
|
|
43
44
|
"vitest": "^4.1.7"
|
|
44
45
|
},
|
|
45
46
|
"dependencies": {
|
|
46
|
-
"@uniweb/
|
|
47
|
-
"@uniweb/
|
|
47
|
+
"@uniweb/semantic-parser": "^1.4.0",
|
|
48
|
+
"@uniweb/theming": "^0.1.15"
|
|
48
49
|
},
|
|
49
50
|
"scripts": {
|
|
50
51
|
"test": "vitest run"
|
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
|
-
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
29
|
-
*
|
|
30
|
-
*
|
|
31
|
-
*
|
|
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`, `
|
|
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 (
|
|
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
|
-
|
|
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
|
|
94
|
-
* the records service guarantees unique among siblings (the
|
|
95
|
-
* §1b: `$name
|
|
96
|
-
*
|
|
97
|
-
*
|
|
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 = '
|
|
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
|
|
110
|
-
//
|
|
111
|
-
//
|
|
112
|
-
//
|
|
113
|
-
//
|
|
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
|
-
|
|
122
|
+
match: { [routeRecordKey(paramName)]: String(paramValue) },
|
|
120
123
|
whole: true,
|
|
121
124
|
dynamicContext: { paramName, paramValue },
|
|
122
125
|
}
|
package/src/entity-store.js
CHANGED
|
@@ -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
|
-
//
|
|
135
|
-
//
|
|
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 ??
|
|
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 =
|
|
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 =
|
|
408
|
+
const routeSchema = this._routeKey(block)
|
|
374
409
|
|
|
375
410
|
for (const [schema, cfg] of configs) {
|
|
376
411
|
const isRouteQuery = dynamicContext && schema === routeSchema
|
package/src/fetch-config.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
273
|
-
* page (`{ path, dir, slug }` under
|
|
274
|
-
* a `:path` / `:dir` / `:slug` placeholder in `where:` or
|
|
275
|
-
* them, and an unbound one drops its clause. Null
|
|
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 =
|
|
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]
|
|
315
|
-
*
|
|
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
|
|
324
|
-
* `/blog/rust/my-post` and vanishes on `/blog`, where there is no `:dir`.
|
|
325
|
-
* variable
|
|
326
|
-
*
|
|
327
|
-
*
|
|
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
|
|
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
|
|
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
|
|
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:`
|
|
394
|
-
*
|
|
395
|
-
*
|
|
396
|
-
*
|
|
397
|
-
*
|
|
398
|
-
*
|
|
399
|
-
*
|
|
400
|
-
*
|
|
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
|
|
403
|
-
if (typeof cfg.scope !== 'string' || cfg.scope
|
|
435
|
+
function dropRootScope(cfg) {
|
|
436
|
+
if (typeof cfg.scope !== 'string' || cfg.scope.replace(/^\/+|\/+$/g, '') !== '') return cfg
|
|
404
437
|
const { scope, ...rest } = cfg
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
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 {
|
|
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
|
package/src/page.js
CHANGED
|
@@ -104,7 +104,7 @@ export default class Page {
|
|
|
104
104
|
// Scroll position memory (for navigation restoration)
|
|
105
105
|
this.scrollY = 0
|
|
106
106
|
|
|
107
|
-
// Fetch configuration (
|
|
107
|
+
// Fetch configuration (page.yml's `fetch:`, or its `query:` shorthand, desugared at build)
|
|
108
108
|
// Preserved at runtime so EntityStore can walk the page hierarchy
|
|
109
109
|
this.fetch = pageData.fetch || null
|
|
110
110
|
|
package/src/route-match.js
CHANGED
|
@@ -260,15 +260,36 @@ export function recordHandle(record) {
|
|
|
260
260
|
}
|
|
261
261
|
|
|
262
262
|
/**
|
|
263
|
-
* The
|
|
264
|
-
*
|
|
265
|
-
*
|
|
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
|
|
269
|
-
* the href encoder below. Until 2026-09-04 each
|
|
270
|
-
* so a record served with `$name` and no `slug`
|
|
271
|
-
* on a live lane rendered `[]` and a list linked
|
|
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/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,
|
|
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
|
-
//
|
|
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
|
-
|
|
388
|
-
|
|
389
|
-
|
|
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
|
|
576
|
-
//
|
|
577
|
-
//
|
|
578
|
-
//
|
|
579
|
-
//
|
|
580
|
-
// `[
|
|
581
|
-
//
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
//
|
|
585
|
-
//
|
|
586
|
-
|
|
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
|
-
//
|
|
619
|
-
//
|
|
620
|
-
|
|
621
|
-
|
|
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 (
|
|
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 (
|
|
641
|
-
//
|
|
642
|
-
//
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
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 (
|
|
693
|
-
// { paramName, paramValue
|
|
694
|
-
// content.data, siblings via
|
|
695
|
-
//
|
|
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
|
|
888
|
-
*
|
|
889
|
-
*
|
|
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
|
|
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]`.
|
|
896
|
-
*
|
|
879
|
+
* routing `[id]`.
|
|
880
|
+
*
|
|
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).
|
|
897
886
|
*
|
|
898
|
-
* @param {string}
|
|
887
|
+
* @param {string} name - a binding key or a query name
|
|
899
888
|
* @returns {{ route: string, paramName: string } | null}
|
|
900
889
|
*/
|
|
901
|
-
detailTemplateFor(
|
|
902
|
-
if (!
|
|
890
|
+
detailTemplateFor(name) {
|
|
891
|
+
if (!name) return null
|
|
903
892
|
for (const data of this._dynamicPageData.values()) {
|
|
904
|
-
if (data?.
|
|
905
|
-
|
|
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
|
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
|
-
*
|
|
48
|
-
*
|
|
49
|
-
*
|
|
50
|
-
* a
|
|
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',
|
|
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
|