@uniweb/core 0.30.1 → 0.32.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 +3 -3
- package/src/data-keys.js +26 -14
- package/src/data-paths.js +5 -6
- package/src/datastore.js +35 -34
- package/src/detail-url.js +19 -10
- package/src/entity-store.js +4 -4
- package/src/fetch-config.js +26 -21
- package/src/index.js +7 -5
- package/src/page-data.js +131 -51
- package/src/query-evaluation.js +49 -15
- package/src/route-match.js +91 -39
- package/src/scope.js +41 -8
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@uniweb/core",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.32.0",
|
|
4
4
|
"description": "Core classes for the Uniweb platform - Uniweb, Website, Page, Block",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"exports": {
|
|
@@ -47,8 +47,8 @@
|
|
|
47
47
|
"vitest": "^4.1.7"
|
|
48
48
|
},
|
|
49
49
|
"dependencies": {
|
|
50
|
-
"@uniweb/
|
|
51
|
-
"@uniweb/
|
|
50
|
+
"@uniweb/theming": "^0.1.15",
|
|
51
|
+
"@uniweb/semantic-parser": "^1.4.1"
|
|
52
52
|
},
|
|
53
53
|
"scripts": {
|
|
54
54
|
"test": "vitest run"
|
package/src/data-keys.js
CHANGED
|
@@ -24,31 +24,42 @@
|
|
|
24
24
|
|
|
25
25
|
import { fetchEntries } from './fetch-config.js'
|
|
26
26
|
|
|
27
|
+
/** The suffix that asks for whole records: `'@std/article/*'`. */
|
|
28
|
+
export const WHOLE_SUFFIX = '/*'
|
|
29
|
+
|
|
27
30
|
/**
|
|
28
|
-
* A declared key's schema ref,
|
|
29
|
-
* entry naming one (`{ schema: '@/member' }`),
|
|
30
|
-
* or a form — which names no schema.
|
|
31
|
+
* A declared key's schema ref, and whether its component expects WHOLE records — as `data:`
|
|
32
|
+
* spells it: a ref string (`'@std/article'`), an entry naming one (`{ schema: '@/member' }`),
|
|
33
|
+
* or neither for an inline shape — a field map or a form — which names no schema.
|
|
34
|
+
*
|
|
35
|
+
* ⭐ `/*` after the ref asks for whole records (`'@std/article/*'`) — the record as stored, one
|
|
36
|
+
* key per section — where a bare ref asks for briefs, the brief's fields at the top (ruled
|
|
37
|
+
* 2026-09-27 [Diego]). The runtime asks each key's question accordingly; a key declared whole
|
|
38
|
+
* that its fetch cannot fill whole is `null`.
|
|
31
39
|
*
|
|
32
40
|
* @param {*} value - one `data:` entry's value, authored or leaned
|
|
33
|
-
* @returns {string|null}
|
|
41
|
+
* @returns {{ ref: string|null, whole: boolean }}
|
|
34
42
|
*/
|
|
35
|
-
function
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
43
|
+
export function dataRefOf(value) {
|
|
44
|
+
const raw = typeof value === 'string'
|
|
45
|
+
? value
|
|
46
|
+
: (value && typeof value === 'object' && typeof value.schema === 'string' ? value.schema : '')
|
|
47
|
+
if (!raw) return { ref: null, whole: false }
|
|
48
|
+
if (raw.endsWith(WHOLE_SUFFIX)) return { ref: raw.slice(0, -WHOLE_SUFFIX.length) || null, whole: true }
|
|
49
|
+
return { ref: raw, whole: false }
|
|
39
50
|
}
|
|
40
51
|
|
|
41
52
|
/**
|
|
42
|
-
* The keys a section's component receives, in order, each with its schema ref
|
|
43
|
-
* component's `data:` first, then its
|
|
44
|
-
* component's.
|
|
53
|
+
* The keys a section's component receives, in order, each with its schema ref and whether
|
|
54
|
+
* it expects whole records (`dataRefOf`) — its component's `data:` first, then its
|
|
55
|
+
* foundation's. A key both declare is the component's.
|
|
45
56
|
*
|
|
46
57
|
* `data: false`, a missing `data:` or anything that is not a map declares nothing. Each
|
|
47
58
|
* value may be as authored or as the build leans it (`{ key: ref|null }`).
|
|
48
59
|
*
|
|
49
60
|
* @param {Object|false|null|undefined} componentData - the component's `data:`
|
|
50
61
|
* @param {Object|false|null|undefined} [foundationData] - its foundation's `main.js` `data:`
|
|
51
|
-
* @returns {Array<[string, string|null]>}
|
|
62
|
+
* @returns {Array<[string, string|null, boolean]>} `[key, ref, whole]`
|
|
52
63
|
*/
|
|
53
64
|
export function declaredKeys(componentData, foundationData = null) {
|
|
54
65
|
const out = []
|
|
@@ -58,7 +69,8 @@ export function declaredKeys(componentData, foundationData = null) {
|
|
|
58
69
|
for (const [key, value] of Object.entries(data)) {
|
|
59
70
|
if (!key || seen.has(key)) continue
|
|
60
71
|
seen.add(key)
|
|
61
|
-
|
|
72
|
+
const { ref, whole } = dataRefOf(value)
|
|
73
|
+
out.push([key, ref, whole])
|
|
62
74
|
}
|
|
63
75
|
}
|
|
64
76
|
return out
|
|
@@ -116,7 +128,7 @@ function parseRef(ref) {
|
|
|
116
128
|
* The mapping says where an answer lands; it renames no fetch. A fetch keeps its `as`
|
|
117
129
|
* for its question, its cache key and its prefetch.
|
|
118
130
|
*
|
|
119
|
-
* @param {Array<[string, string|null]>} declared - `declaredKeys`
|
|
131
|
+
* @param {Array<[string, string|null, boolean]>} declared - `declaredKeys`
|
|
120
132
|
* @param {Array<*>} levels - each level's `fetch`, most specific first; falsy levels skipped
|
|
121
133
|
* @param {Object} [options]
|
|
122
134
|
* @param {Object|null} [options.queries] - the site's `config.queries`, for each fetch's
|
package/src/data-paths.js
CHANGED
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
* literal in six places across three packages: the build wrote it
|
|
12
12
|
* (the query processor), the build resolved the query shorthand to it
|
|
13
13
|
* (`data-fetcher.js`), core injected the per-record default
|
|
14
|
-
* (`fetch-config.js
|
|
14
|
+
* (`fetch-config.js applyRecordFiles`), core gated locale-prefixing on it
|
|
15
15
|
* (`fetch-config.js localizeConfig`), the dev server matched it with a regex
|
|
16
16
|
* (`build/src/site/plugin.js`), and kit's `useEntityDetail` — `useWholeRecord`
|
|
17
17
|
* since 2026-09-14 — requested it.
|
|
@@ -60,8 +60,7 @@ export const DATA_DIR = 'data'
|
|
|
60
60
|
export const DATA_URL_PREFIX = `/${DATA_DIR}/`
|
|
61
61
|
|
|
62
62
|
/**
|
|
63
|
-
* URL of a query's
|
|
64
|
-
* `deferred:` fields stripped when the query declares them.
|
|
63
|
+
* URL of a query's list — every record it returns, each as its brief.
|
|
65
64
|
*
|
|
66
65
|
* @param {string} name - The query name.
|
|
67
66
|
* @returns {string} e.g. `/data/articles.json`
|
|
@@ -71,11 +70,11 @@ export function queryDataUrl(name) {
|
|
|
71
70
|
}
|
|
72
71
|
|
|
73
72
|
/**
|
|
74
|
-
* URL of one record's
|
|
75
|
-
*
|
|
73
|
+
* URL of one record's own file — the record whole, as stored. Emitted for every
|
|
74
|
+
* record of a compiled query (only for a `deferred:` query until 2026-09-27).
|
|
76
75
|
*
|
|
77
76
|
* Takes either a concrete slug — a caller holding the record — or the literal
|
|
78
|
-
* placeholder `{slug}` (core's `
|
|
77
|
+
* placeholder `{slug}` (core's `applyRecordFiles`, which builds a pattern
|
|
79
78
|
* that `substitutePlaceholders` resolves later against the dynamic-route
|
|
80
79
|
* param). Both are plain interpolation; this function does not encode,
|
|
81
80
|
* matching the behavior of the call sites it replaced.
|
package/src/datastore.js
CHANGED
|
@@ -119,8 +119,8 @@ function indexableWhole(entry) {
|
|
|
119
119
|
}
|
|
120
120
|
|
|
121
121
|
/**
|
|
122
|
-
* ⭐ THE RECORD INDEX — records are held ONCE, by identity, with
|
|
123
|
-
* were fetched
|
|
122
|
+
* ⭐ THE RECORD INDEX — records are held ONCE, by identity, with whether they
|
|
123
|
+
* were fetched whole or as briefs; a query's result holds their ids.
|
|
124
124
|
*
|
|
125
125
|
* [Diego, 2026-09-04]: "they need to be able to hydrate records on their own…
|
|
126
126
|
* track if they already have the briefs, and don't confuse that with knowing the
|
|
@@ -129,15 +129,20 @@ function indexableWhole(entry) {
|
|
|
129
129
|
* record was already held in full still fetched it again, or — with the record's
|
|
130
130
|
* list already cached — delivered the brief as if it were the record.
|
|
131
131
|
*
|
|
132
|
-
*
|
|
133
|
-
* R1 an entry whose `meta.
|
|
134
|
-
*
|
|
135
|
-
*
|
|
136
|
-
* R2
|
|
137
|
-
*
|
|
138
|
-
*
|
|
139
|
-
*
|
|
140
|
-
*
|
|
132
|
+
* Two rules:
|
|
133
|
+
* R1 an entry whose `meta.whole` is a boolean and whose records all carry
|
|
134
|
+
* `$uuid` is filed by id; `get()` materializes it from the index, so every
|
|
135
|
+
* entry holding a record sees the latest copy of it;
|
|
136
|
+
* R2 a record's BRIEF and its WHOLE record are held apart, each replaced only
|
|
137
|
+
* by a fresher copy of its own kind — they are two shapes (a brief's fields at
|
|
138
|
+
* the top, a whole record's sections at the top; ruled 2026-09-27 [Diego]), so
|
|
139
|
+
* a list of briefs stays briefs when its record is fetched whole.
|
|
140
|
+
* ⛔ Until 2026-09-27 a whole record MERGED over the brief it found (`{ ...brief,
|
|
141
|
+
* ...whole }`) and every list holding the record showed the merge — one object
|
|
142
|
+
* holding a brief's fields beside section names.
|
|
143
|
+
* An entry whose `meta.whole` is not a boolean, or with a record lacking
|
|
144
|
+
* identity, is held inline exactly as before — the file lane with no synced
|
|
145
|
+
* records changes nothing.
|
|
141
146
|
*/
|
|
142
147
|
export default class DataStore {
|
|
143
148
|
constructor() {
|
|
@@ -149,7 +154,7 @@ export default class DataStore {
|
|
|
149
154
|
this._listeners = new Set()
|
|
150
155
|
// Key-scoped listeners: key → Set<Function>
|
|
151
156
|
this._keyedListeners = new Map()
|
|
152
|
-
// $uuid → { whole
|
|
157
|
+
// $uuid → { brief?, whole? } — the record index
|
|
153
158
|
this._records = new Map()
|
|
154
159
|
// bumps on every index write, so a materialized list knows it is stale
|
|
155
160
|
this._recordsVersion = 0
|
|
@@ -216,7 +221,8 @@ export default class DataStore {
|
|
|
216
221
|
if (!entry || !entry.ids) return entry
|
|
217
222
|
// Indexed: materialize from the record index, once per index version.
|
|
218
223
|
if (entry._at === this._recordsVersion && entry._data) return entry._data
|
|
219
|
-
const
|
|
224
|
+
const slot = entry.meta?.whole === true ? 'whole' : 'brief'
|
|
225
|
+
const records = entry.ids.map((id) => this._records.get(id)?.[slot]).filter(Boolean)
|
|
220
226
|
const data = entry.single ? (records[0] ?? null) : records
|
|
221
227
|
const out = entry.meta !== undefined ? { data, meta: entry.meta } : { data }
|
|
222
228
|
entry._at = this._recordsVersion
|
|
@@ -228,9 +234,9 @@ export default class DataStore {
|
|
|
228
234
|
* Cache store. Fires listeners: first the global ones (all-writes), then
|
|
229
235
|
* any subscribers registered for this specific key.
|
|
230
236
|
*
|
|
231
|
-
* An entry
|
|
232
|
-
* the record index and stored as ids — see the class note.
|
|
233
|
-
* stored as given.
|
|
237
|
+
* An entry whose `meta.whole` is a boolean and whose records all carry `$uuid`
|
|
238
|
+
* is filed in the record index and stored as ids — see the class note.
|
|
239
|
+
* Anything else is stored as given.
|
|
234
240
|
*
|
|
235
241
|
* @param {string} key
|
|
236
242
|
* @param {{ data: any, meta?: Object }} entry
|
|
@@ -245,39 +251,34 @@ export default class DataStore {
|
|
|
245
251
|
}
|
|
246
252
|
|
|
247
253
|
/**
|
|
248
|
-
* The record held under an identity, with
|
|
249
|
-
* question a detail page asks before fetching: "do I hold this
|
|
254
|
+
* The record held under an identity, with whether it was fetched whole — the
|
|
255
|
+
* question a detail page asks before fetching: "do I hold this whole?"
|
|
250
256
|
*
|
|
251
257
|
* @param {string} id - a `$uuid`
|
|
252
|
-
* @returns {{
|
|
258
|
+
* @returns {{ whole: boolean, record: Object } | null}
|
|
253
259
|
*/
|
|
254
260
|
getRecord(id) {
|
|
255
|
-
|
|
261
|
+
const held = id ? this._records.get(id) : null
|
|
262
|
+
if (held?.whole) return { whole: true, record: held.whole }
|
|
263
|
+
if (held?.brief) return { whole: false, record: held.brief }
|
|
264
|
+
return null
|
|
256
265
|
}
|
|
257
266
|
|
|
258
267
|
/**
|
|
259
|
-
* File one record
|
|
268
|
+
* File one record, whole or a brief, honouring R2 — the fresher copy of its own
|
|
269
|
+
* kind replaces the one held. Returns the record now held.
|
|
260
270
|
*
|
|
261
271
|
* @param {Object} record
|
|
262
|
-
* @param {
|
|
272
|
+
* @param {boolean} whole - true for a record fetched whole, false for a brief
|
|
263
273
|
* @returns {Object}
|
|
264
274
|
*/
|
|
265
275
|
upsertRecord(record, whole) {
|
|
266
276
|
const id = recordIdentity(record)
|
|
267
277
|
if (!id || typeof whole !== 'boolean') return record
|
|
268
|
-
const held = this._records.get(id)
|
|
269
|
-
|
|
270
|
-
this._records.set(id, { whole, record })
|
|
271
|
-
this._recordsVersion += 1
|
|
272
|
-
return record
|
|
273
|
-
}
|
|
274
|
-
// R2 — a brief never displaces a whole record.
|
|
275
|
-
if (!whole && held.whole) return held.record
|
|
276
|
-
// R3 — a whole record merges over a brief; same kind takes the fresher copy.
|
|
277
|
-
const next = whole && !held.whole ? { ...held.record, ...record } : record
|
|
278
|
-
this._records.set(id, { whole, record: next })
|
|
278
|
+
const held = this._records.get(id) || {}
|
|
279
|
+
this._records.set(id, { ...held, [whole ? 'whole' : 'brief']: record })
|
|
279
280
|
this._recordsVersion += 1
|
|
280
|
-
return
|
|
281
|
+
return record
|
|
281
282
|
}
|
|
282
283
|
|
|
283
284
|
_index(entry) {
|
package/src/detail-url.js
CHANGED
|
@@ -38,18 +38,20 @@ import { substitutePlaceholders } from './substitute-placeholders.js'
|
|
|
38
38
|
*/
|
|
39
39
|
function paramContext(paramName, paramValue, record) {
|
|
40
40
|
const context = { [paramName]: paramValue, param: paramValue }
|
|
41
|
-
// ⭐ `{slug}` is the RECORD'S
|
|
42
|
-
// file lane keys a query's per-record files by `
|
|
41
|
+
// ⭐ `{slug}` is the RECORD'S handle, `$name`, whatever the route calls its param.
|
|
42
|
+
// The file lane keys a query's per-record files by `$name` (`writeQueryFiles`)
|
|
43
43
|
// and injects `/data/<name>/{slug}.json` — so on a site routing `[id]`, the
|
|
44
44
|
// route's context carried `id` and `param` and `{slug}` stayed literal: the
|
|
45
45
|
// detail URL was `/data/articles/{slug}.json`, a guaranteed 404, on every
|
|
46
|
-
//
|
|
46
|
+
// `[id]` page that read a record's own file (measured 2026-09-04). When the caller
|
|
47
47
|
// holds the record — the entity store does (it matched it), and so does
|
|
48
48
|
// kit's `useWholeRecord` — its slug fills the name the FILE was written under. A
|
|
49
49
|
// caller with no record in hand leaves `{slug}` literal rather than guessing
|
|
50
50
|
// the capture is one: a visibly unresolved address beats a plausible wrong one.
|
|
51
|
+
// ⭐ Whatever the route calls its param — and whatever field its query binds `:slug` to — a
|
|
52
|
+
// record's own file is named by its `$name`, so a record in hand with one fills `{slug}`.
|
|
51
53
|
const handle = recordHandle(record)
|
|
52
|
-
if (
|
|
54
|
+
if (handle != null && handle !== '') {
|
|
53
55
|
context.slug = handle
|
|
54
56
|
}
|
|
55
57
|
return context
|
|
@@ -73,7 +75,7 @@ export const ROUTE_HANDLE_KEY = routeRecordKey('slug')
|
|
|
73
75
|
* - **an external query's `record:`** — its `url` (the query's when it names none),
|
|
74
76
|
* `method` (likewise), `body` and `transform` — ⛔ never the list's `body` or
|
|
75
77
|
* `transform`, since a record response is rarely wrapped the way the list is;
|
|
76
|
-
* - **a
|
|
78
|
+
* - **a compiled query's per-record file** — the `/data/<query>/{slug}.json`
|
|
77
79
|
* pattern the resolver injected.
|
|
78
80
|
*
|
|
79
81
|
* ⛔ The `detail:` forms an author wrote until 2026-09-13 — `rest`, `query`, a URL
|
|
@@ -85,13 +87,19 @@ export const ROUTE_HANDLE_KEY = routeRecordKey('slug')
|
|
|
85
87
|
* Returns `null` — never throws — when the config has no per-record source or the
|
|
86
88
|
* route carries no value. A caller treats `null` as "find the record in the list".
|
|
87
89
|
*
|
|
90
|
+
* ⭐ `whole` — whether the record is asked whole (the default) or as its brief (ruled
|
|
91
|
+
* 2026-09-27 [Diego]: a component declares which it expects). Only the records service answers
|
|
92
|
+
* one record as its brief; everywhere else a record's brief is what its list holds, so asking
|
|
93
|
+
* for a brief there is `null`.
|
|
94
|
+
*
|
|
88
95
|
* @param {Object} queryConfig - a resolved config
|
|
89
96
|
* @param {{ paramName: string, paramValue: string, record?: Object|null }} dynamicContext -
|
|
90
97
|
* the route's param and its value; `record`, when the caller already holds the
|
|
91
98
|
* matched record, lets `{slug}` resolve to the record's own slug (`paramContext`).
|
|
99
|
+
* @param {{ whole?: boolean }} [options]
|
|
92
100
|
* @returns {Object|null} a request, or null
|
|
93
101
|
*/
|
|
94
|
-
export function buildDetailConfig(queryConfig, dynamicContext) {
|
|
102
|
+
export function buildDetailConfig(queryConfig, dynamicContext, { whole = true } = {}) {
|
|
95
103
|
const { detail } = queryConfig
|
|
96
104
|
if (!detail) return null
|
|
97
105
|
const { paramName, paramValue, record = null } = dynamicContext
|
|
@@ -117,14 +125,15 @@ export function buildDetailConfig(queryConfig, dynamicContext) {
|
|
|
117
125
|
// string — it is a URL segment — and the service compares it as one, as the local
|
|
118
126
|
// match does.
|
|
119
127
|
if (queryConfig.ask) {
|
|
120
|
-
const { narrow: _narrow, detail: _detail, match: _match, ...set } = queryConfig
|
|
128
|
+
const { narrow: _narrow, detail: _detail, match: _match, whole: _whole, ...set } = queryConfig
|
|
121
129
|
return {
|
|
122
130
|
...set,
|
|
123
|
-
narrow: { match: { [routeRecordKey(paramName)]: String(paramValue) } },
|
|
124
|
-
whole: true,
|
|
131
|
+
narrow: { match: { [routeRecordKey(paramName, queryConfig.routeField)]: String(paramValue) } },
|
|
132
|
+
...(whole ? { whole: true } : {}),
|
|
125
133
|
dynamicContext: { paramName, paramValue },
|
|
126
134
|
}
|
|
127
135
|
}
|
|
136
|
+
if (!whole) return null
|
|
128
137
|
|
|
129
138
|
// What every other record request carries beside its address:
|
|
130
139
|
// `as` — the binding key, so the record lands where the list did;
|
|
@@ -152,7 +161,7 @@ export function buildDetailConfig(queryConfig, dynamicContext) {
|
|
|
152
161
|
return out
|
|
153
162
|
}
|
|
154
163
|
|
|
155
|
-
// ⭐ A
|
|
164
|
+
// ⭐ A COMPILED QUERY'S PER-RECORD FILE — the pattern `applyRecordFiles` injected.
|
|
156
165
|
if (typeof detail === 'string' && typeof queryConfig.path === 'string') {
|
|
157
166
|
return { path: substitutePlaceholders(detail, context), ...common }
|
|
158
167
|
}
|
package/src/entity-store.js
CHANGED
|
@@ -51,7 +51,7 @@ export default class EntityStore {
|
|
|
51
51
|
|
|
52
52
|
/**
|
|
53
53
|
* The options each fetch of a block is resolved with. The rule applied to a fetch
|
|
54
|
-
* (locale normalization, route variables,
|
|
54
|
+
* (locale normalization, route variables, the record files' pattern) lives in
|
|
55
55
|
* `./fetch-config.js`, shared with every other host that has to answer the same
|
|
56
56
|
* question. Do not re-inline it here — divergence between copies is what the
|
|
57
57
|
* extraction exists to prevent.
|
|
@@ -237,7 +237,7 @@ export default class EntityStore {
|
|
|
237
237
|
if (!Array.isArray(items) || items.length === 0) continue
|
|
238
238
|
const template = recordRouteTemplate(cfg, website)
|
|
239
239
|
if (!template) continue
|
|
240
|
-
const linked = items.map((item) => withRecordRoute(item, template))
|
|
240
|
+
const linked = items.map((item) => withRecordRoute(item, template, cfg.routeField))
|
|
241
241
|
// an unchanged list keeps its identity, so a render that re-links changes nothing
|
|
242
242
|
if (linked.some((item, i) => item !== items[i])) data[key] = linked
|
|
243
243
|
}
|
|
@@ -402,8 +402,8 @@ function recordRouteTemplate(cfg, website) {
|
|
|
402
402
|
* ⭐ The encoding is `fillRoutePattern`'s: the one encoder for a record's href, which the
|
|
403
403
|
* route matcher decodes (F14, 2026-09-04).
|
|
404
404
|
*/
|
|
405
|
-
function withRecordRoute(item, template) {
|
|
405
|
+
function withRecordRoute(item, template, field = null) {
|
|
406
406
|
if (!item || typeof item !== 'object') return item
|
|
407
|
-
const route = fillRoutePattern(template, item)
|
|
407
|
+
const route = fillRoutePattern(template, item, { field })
|
|
408
408
|
return route === null || item.$route === route ? item : { ...item, $route: route }
|
|
409
409
|
}
|
package/src/fetch-config.js
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
* "Which fetch configs apply here?" is a framework concept. Authors declare
|
|
5
5
|
* `fetch:` at the section, page, folder and site levels; the framework decides
|
|
6
6
|
* which declaration wins per schema, how a local data path is localized, and
|
|
7
|
-
*
|
|
7
|
+
* where a compiled query's records have their own files.
|
|
8
8
|
*
|
|
9
9
|
* Every host that renders a page needs that answer — the browser runtime, the
|
|
10
10
|
* build-time prerenderer, and any server-side renderer. The rule had grown
|
|
@@ -54,7 +54,7 @@ import { queryDataUrl, isDataUrl, recordDataUrl } from './data-paths.js'
|
|
|
54
54
|
import { resolveRecordsService } from './records-service.js'
|
|
55
55
|
// A leaf that imports nothing — `recordRouteBase` is the one rule for which page
|
|
56
56
|
// addresses a record, and a route query is chosen at exactly that page.
|
|
57
|
-
import { recordRouteBase } from './route-match.js'
|
|
57
|
+
import { recordRouteBase, routeFieldOf } from './route-match.js'
|
|
58
58
|
|
|
59
59
|
/**
|
|
60
60
|
* ⭐ HOW A SECTION ON A PARAMETRIC PAGE USES THE PAGE'S RECORD — `current:` on its
|
|
@@ -194,13 +194,15 @@ function localizeConfig(cfg, locale, defaultLocale) {
|
|
|
194
194
|
* - the records service → `true`: the record is the route query's set narrowed
|
|
195
195
|
* by the URL's value (`buildDetailConfig`);
|
|
196
196
|
* - an external query declaring `record:` → `true`: its own request;
|
|
197
|
-
* - a query
|
|
198
|
-
*
|
|
199
|
-
* locale other than the default it is
|
|
200
|
-
* way the list's own path is
|
|
201
|
-
*
|
|
202
|
-
*
|
|
203
|
-
* for the default locale's file
|
|
197
|
+
* - a compiled query → `/data/<query>/{slug}.json`, the per-record file the build
|
|
198
|
+
* emits beside the list for every record — the record whole, as stored, where the
|
|
199
|
+
* list holds its brief. ⭐ On a locale other than the default it is
|
|
200
|
+
* `/<locale>/data/<query>/{slug}.json`, the way the list's own path is
|
|
201
|
+
* (`localizeConfig`): a localized build writes a translated copy of every
|
|
202
|
+
* per-record file beside the translated list. ⛔ Until 2026-09-14 the pattern was
|
|
203
|
+
* never localized, so a translated record page asked for the default locale's file
|
|
204
|
+
* and showed an untranslated body. ⛔ And until 2026-09-27 only a query with
|
|
205
|
+
* `deferred:` had one: the list held every other query's records whole.
|
|
204
206
|
*
|
|
205
207
|
* ⛔ `detail:` is no longer AUTHORED (retired 2026-09-13 [Diego]; the build refuses
|
|
206
208
|
* it): its `rest`, `query`, pattern and `{ body, envelope }` forms are an external
|
|
@@ -216,7 +218,7 @@ function localizeConfig(cfg, locale, defaultLocale) {
|
|
|
216
218
|
* @param {string|null} [defaultLocale] - the site's default locale
|
|
217
219
|
* @returns {Object} the original config, or a copy carrying `detail`
|
|
218
220
|
*/
|
|
219
|
-
function
|
|
221
|
+
function applyRecordFiles(cfg, queries, locale = null, defaultLocale = null) {
|
|
220
222
|
if (cfg.detail !== undefined) return cfg
|
|
221
223
|
|
|
222
224
|
// The records service answers a RECORD by the query's set narrowed to it, so
|
|
@@ -240,11 +242,9 @@ function applyDeferredDetail(cfg, queries, locale = null, defaultLocale = null)
|
|
|
240
242
|
// is the only key there is. Two shapes, two answers; the deleted one had one
|
|
241
243
|
// shape and pretended otherwise.
|
|
242
244
|
const queryName = cfg.query || bindingKey(cfg)
|
|
243
|
-
if (!queryName || !queries) return cfg
|
|
245
|
+
if (!queryName || !queries || typeof cfg.path !== 'string') return cfg
|
|
244
246
|
const collConfig = queries[queryName]
|
|
245
247
|
if (!collConfig || typeof collConfig !== 'object') return cfg
|
|
246
|
-
const deferred = Array.isArray(collConfig.deferred) ? collConfig.deferred : null
|
|
247
|
-
if (!deferred || deferred.length === 0) return cfg
|
|
248
248
|
const pattern = recordDataUrl(queryName, '{slug}')
|
|
249
249
|
const localized = locale && locale !== defaultLocale && isDataUrl(pattern) ? `/${locale}${pattern}` : pattern
|
|
250
250
|
return { ...cfg, detail: localized }
|
|
@@ -266,7 +266,7 @@ export function isExternalQuery(decl) {
|
|
|
266
266
|
}
|
|
267
267
|
|
|
268
268
|
/** Keys a binding may not carry, whatever a stale payload holds — the query supplies them. */
|
|
269
|
-
const QUERY_SUPPLIED = ['scope', 'url', 'method', 'body', 'transform', 'record', 'detail', 'envelope']
|
|
269
|
+
const QUERY_SUPPLIED = ['scope', 'url', 'method', 'body', 'transform', 'record', 'detail', 'envelope', 'routeField']
|
|
270
270
|
|
|
271
271
|
/**
|
|
272
272
|
* Resolve a query reference to something the fetcher can call.
|
|
@@ -412,6 +412,11 @@ function setAndNarrow(out, decl, queryWhere) {
|
|
|
412
412
|
if (someWhere(queryWhere)) next.where = queryWhere
|
|
413
413
|
if (decl.sort !== undefined && decl.sort !== null) next.sort = decl.sort
|
|
414
414
|
if (isCount(decl.limit)) next.limit = decl.limit
|
|
415
|
+
// The field its `where` binds the URL's last segment to — what a parametric page of it
|
|
416
|
+
// matches in place of `$name` (`routeFieldOf`, `./route-match.js`). On every page, so the
|
|
417
|
+
// query's own page links its records by the same field.
|
|
418
|
+
const routeField = routeFieldOf(decl)
|
|
419
|
+
if (routeField) next.routeField = routeField
|
|
415
420
|
}
|
|
416
421
|
const narrow = {}
|
|
417
422
|
if (someWhere(where)) narrow.where = where
|
|
@@ -552,12 +557,12 @@ export function resolveFetchConfigs(sources, options = {}) {
|
|
|
552
557
|
if (!key) continue
|
|
553
558
|
if (configs.has(key)) continue
|
|
554
559
|
if (!collectAll && !schemas.includes(key)) continue
|
|
555
|
-
// Address first: localization and
|
|
560
|
+
// Address first: localization and the record files both key on `path`,
|
|
556
561
|
// which a query ref does not have until this runs.
|
|
557
562
|
const sourced = resolveQuerySource(cfg, services, { queries, locale, defaultLocale })
|
|
558
563
|
const localized = localizeConfig(sourced, locale, defaultLocale)
|
|
559
564
|
const bound = dropRootScope(bindRouteVariables(localized, variables))
|
|
560
|
-
configs.set(key, stampDepthAndLocale(
|
|
565
|
+
configs.set(key, stampDepthAndLocale(applyRecordFiles(bound, queries, locale, defaultLocale), locale, defaultLocale))
|
|
561
566
|
}
|
|
562
567
|
}
|
|
563
568
|
|
|
@@ -683,9 +688,9 @@ function bindWhere(where, variables) {
|
|
|
683
688
|
|
|
684
689
|
/**
|
|
685
690
|
* ⭐ `scope:` IS ITS OWN FIELD, on both lanes. The service takes it natively; on
|
|
686
|
-
* the compiled file the evaluators apply it to each record
|
|
687
|
-
* the folder `records/folder.yml` put it in) — `@uniweb/core`'s `applyScope`,
|
|
688
|
-
* build uses too. ⛔ Until 2026-09-11 it was folded here into
|
|
691
|
+
* the compiled file the evaluators apply it to the branch each record is held with
|
|
692
|
+
* (`$branch`, the folder `records/folder.yml` put it in) — `@uniweb/core`'s `applyScope`,
|
|
693
|
+
* the one the build uses too. ⛔ Until 2026-09-11 it was folded here into
|
|
689
694
|
* `where: { path: { under } }`, an authored form that is now retired in its
|
|
690
695
|
* favour [Diego]: a branch is a scope, and `where` stays the author's predicate.
|
|
691
696
|
*
|
|
@@ -976,8 +981,8 @@ export function recordPages(pages, { routeOf, parentOf, fetchOf, sectionsOf, sit
|
|
|
976
981
|
*
|
|
977
982
|
* `depth` — `brief` when the config has a per-record source (`detail`), because
|
|
978
983
|
* a list with a separate record address is a list of partial records: a live
|
|
979
|
-
* lane answers a list at brief depth and a record in full, and a
|
|
980
|
-
* query's
|
|
984
|
+
* lane answers a list at brief depth and a record in full, and a compiled
|
|
985
|
+
* query's list holds briefs beside each record's own file. `full` otherwise. An explicit
|
|
981
986
|
* `depth` on the config wins (the records service's client sets it).
|
|
982
987
|
*
|
|
983
988
|
* `locale` — stamped on an ASKED config only. A compiled path already carries
|
package/src/index.js
CHANGED
|
@@ -24,7 +24,7 @@ export { substitutePlaceholders } from './substitute-placeholders.js'
|
|
|
24
24
|
// reads them and a consumer that needs them imports `@uniweb/core/records-service`.
|
|
25
25
|
export { resolveFetchConfigs, routeQuery, pageRouteQuery, routeSelection, sectionFetches, siteReaches, withoutRouteVariables, CURRENT_MODES, currentFor } from './fetch-config.js'
|
|
26
26
|
export { buildDetailConfig, ROUTE_HANDLE_KEY } from './detail-url.js'
|
|
27
|
-
export { declaredKeys, sameSchema, fillDeclaredKeys } from './data-keys.js'
|
|
27
|
+
export { declaredKeys, sameSchema, fillDeclaredKeys, dataRefOf, WHOLE_SUFFIX } from './data-keys.js'
|
|
28
28
|
// ⭐ What a page needs — the one rule. `fetchLevels` is the part every lane has an answer for
|
|
29
29
|
// (which fetches reach a block); the drivers and the per-key program are `@uniweb/core/page-data`.
|
|
30
30
|
export { fetchLevels } from './page-data.js'
|
|
@@ -55,21 +55,23 @@ export {
|
|
|
55
55
|
isDataUrl
|
|
56
56
|
} from './data-paths.js'
|
|
57
57
|
export { evaluate as evaluateWhere, match as matchWhere, whereOutsideLanguage } from './where.js'
|
|
58
|
-
// A query's `scope:` over records
|
|
59
|
-
// default fetcher and by `@uniweb/build
|
|
60
|
-
export { applyScope, withinScope } from './scope.js'
|
|
58
|
+
// A query's `scope:` over compiled records, which hold their branch under `BRANCH_KEY` —
|
|
59
|
+
// read by the runtime's default fetcher and by `@uniweb/build`, which writes it.
|
|
60
|
+
export { applyScope, withinScope, withoutBranch, BRANCH_KEY } from './scope.js'
|
|
61
61
|
// The one sort evaluator and the one href encoder — both read by `@uniweb/build`
|
|
62
62
|
// (materialization, the `route:` bake) and by `@uniweb/runtime` (the fallback),
|
|
63
63
|
// which is what keeps the static and live lanes answering a query identically.
|
|
64
64
|
export { parseSort, sortRecords, sortToWire } from './sort.js'
|
|
65
65
|
// A config's set, then its `narrow` — the one order of work the runtime's default
|
|
66
66
|
// fetcher and `@uniweb/build` both evaluate a fetch with.
|
|
67
|
-
export { evaluateQuery } from './query-evaluation.js'
|
|
67
|
+
export { evaluateQuery, mapQueryPaths } from './query-evaluation.js'
|
|
68
68
|
export {
|
|
69
69
|
fillRoutePattern,
|
|
70
70
|
splitPathCapture,
|
|
71
71
|
joinPathCapture,
|
|
72
72
|
recordHandle,
|
|
73
|
+
routeFieldOf,
|
|
74
|
+
recordKeyValues,
|
|
73
75
|
recordTitle,
|
|
74
76
|
routeParamValue,
|
|
75
77
|
routeParamValues,
|
package/src/page-data.js
CHANGED
|
@@ -80,13 +80,13 @@ export function fetchLevels({ own = null, page = null, parent = null, route = nu
|
|
|
80
80
|
* Each filling fetch is resolved on its own, so two fetches of one `as` can fill two keys.
|
|
81
81
|
*
|
|
82
82
|
* @param {Object} input
|
|
83
|
-
* @param {Array<[string, string|null]>} input.declared - `declaredKeys(meta)`
|
|
83
|
+
* @param {Array<[string, string|null, boolean]>} input.declared - `declaredKeys(meta)`
|
|
84
84
|
* @param {Array<*>} input.levels - `fetchLevels`
|
|
85
85
|
* @param {Object} [input.holds] - what the block already holds, by key
|
|
86
86
|
* @param {Object|null} [input.queries] - the site's `config.queries`
|
|
87
87
|
* @param {Object} [input.options] - what each fetch is resolved with (`resolveFetchConfigs`)
|
|
88
|
-
* @returns {Map<string, { cfg: Object, held:
|
|
89
|
-
* `declared` lists the keys
|
|
88
|
+
* @returns {Map<string, { cfg: Object, held: *, whole: boolean }>} declared key → its filling config,
|
|
89
|
+
* and whether its component expects whole records, in the order `declared` lists the keys
|
|
90
90
|
*/
|
|
91
91
|
export function blockFills({ declared, levels, holds = {}, queries = null, options = {} }) {
|
|
92
92
|
const out = new Map()
|
|
@@ -102,11 +102,12 @@ export function blockFills({ declared, levels, holds = {}, queries = null, optio
|
|
|
102
102
|
if (!firstOfKey.has(fetch.as)) firstOfKey.set(fetch.as, `${at}:${index}`)
|
|
103
103
|
}))
|
|
104
104
|
|
|
105
|
+
const wholeKeys = new Set(declared.filter(([, , whole]) => whole).map(([key]) => key))
|
|
105
106
|
for (const [key, { fetch, level, index }] of fills) {
|
|
106
107
|
const cfg = resolveFetchConfigs([fetch], options).get(fetch.as)
|
|
107
108
|
if (!cfg) continue
|
|
108
109
|
const held = firstOfKey.get(fetch.as) === `${level}:${index}` ? holds[fetch.as] : undefined
|
|
109
|
-
out.set(key, { cfg, held })
|
|
110
|
+
out.set(key, { cfg, held, whole: wholeKeys.has(key) })
|
|
110
111
|
}
|
|
111
112
|
return out
|
|
112
113
|
}
|
|
@@ -136,8 +137,8 @@ export function pageRecordConfig({ pageFetch = null, parent = null, route, site
|
|
|
136
137
|
}
|
|
137
138
|
|
|
138
139
|
/** The record a parametric page is about — the one `current: only` delivers. */
|
|
139
|
-
function isPageRecord({ paramName, paramValue }) {
|
|
140
|
-
return (item) => matchesRouteParam(item, paramName, paramValue)
|
|
140
|
+
function isPageRecord({ paramName, paramValue }, field = null) {
|
|
141
|
+
return (item) => matchesRouteParam(item, paramName, paramValue, field)
|
|
141
142
|
}
|
|
142
143
|
|
|
143
144
|
/**
|
|
@@ -153,14 +154,23 @@ function heldWhole(peekRecord, match) {
|
|
|
153
154
|
|
|
154
155
|
/**
|
|
155
156
|
* (c) + (d) One declared key's program: the requests it needs, in order, and what its answers
|
|
156
|
-
* become. Each `yield` is a request
|
|
157
|
-
*
|
|
158
|
-
* only.
|
|
157
|
+
* become. Each `yield` is a request — or a list of requests asked together — and what comes back is
|
|
158
|
+
* `{ data, error }` (a list of them for a list) — a driver dispatching may report an error, a driver
|
|
159
|
+
* reading the cache never does, because the cache holds answers only.
|
|
160
|
+
*
|
|
161
|
+
* ⭐ **What is asked follows what the component expects** (`whole`, from its `data:` —
|
|
162
|
+
* `'@std/article/*'`; ruled 2026-09-27 [Diego]). Briefs by default: the records service's question
|
|
163
|
+
* as it is, a compiled file's list, and on a parametric page the record FOUND in its query's set.
|
|
164
|
+
* Whole records when declared: the question carries `whole: true`, and a list off the service is
|
|
165
|
+
* followed by each record's own request. A key declared whole that cannot be filled whole — its
|
|
166
|
+
* source has no request for one record — is `null`: the contract cannot be met. An external query's
|
|
167
|
+
* records have no data schema and so no brief: what its source answers is each record whole.
|
|
159
168
|
*
|
|
160
169
|
* ⛔ **A failed request delivers NOTHING under its key, and says so.** The key is absent from the
|
|
161
170
|
* returned value and the message rides on `error`, because `[]` is a value: a request that failed
|
|
162
|
-
* must not be indistinguishable from a query with no records.
|
|
163
|
-
*
|
|
171
|
+
* must not be indistinguishable from a query with no records. ⛔ Until 2026-09-27 a parametric
|
|
172
|
+
* page's record was always asked whole, and a failed record request kept the brief the set held —
|
|
173
|
+
* one shape standing in for the other.
|
|
164
174
|
*
|
|
165
175
|
* @param {Object} cfg - the filling fetch, resolved
|
|
166
176
|
* @param {Object} where
|
|
@@ -177,31 +187,43 @@ function heldWhole(peekRecord, match) {
|
|
|
177
187
|
* page may already hold the set, and the record is in it — so a miss falls through to the set,
|
|
178
188
|
* and a record found there answers with the brief it holds. No value at all means *unknown*,
|
|
179
189
|
* which is not the same as `[]`, *not in the set*.
|
|
180
|
-
* @
|
|
190
|
+
* @param {boolean} [where.whole] - the component expects whole records
|
|
191
|
+
* @returns {Generator<Object|Object[], { value?: *, error?: string, errorConfig?: Object }>}
|
|
181
192
|
*/
|
|
182
|
-
export function* keyProgram(cfg, { dynamicContext = null, route = null, held = undefined, peekRecord = null, current: forced = null, opportunistic = false } = {}) {
|
|
193
|
+
export function* keyProgram(cfg, { dynamicContext = null, route = null, held = undefined, peekRecord = null, current: forced = null, opportunistic = false, whole = false } = {}) {
|
|
183
194
|
/** A step this caller can do without: a miss answers `{ missing: true }` instead of stopping. */
|
|
184
195
|
const maybe = (request) => (opportunistic ? { request, optional: true } : request)
|
|
185
196
|
// The block holds this fetch's answer already — a static build prerendered it.
|
|
186
197
|
if (held !== undefined) return { value: held }
|
|
187
198
|
|
|
199
|
+
// ⭐ WHOLE OR BRIEF IS A QUESTION ABOUT RECORDS OF A DATA SCHEMA — the records service's, or a
|
|
200
|
+
// compiled query's. An external query's records have none, so they have no brief: a list is what
|
|
201
|
+
// its source answers, and a parametric page's record is its own request's answer (`record:`),
|
|
202
|
+
// whatever the component expects — as the records service answers a Model with no brief whole,
|
|
203
|
+
// even as a brief.
|
|
204
|
+
const external = !cfg.ask && typeof cfg.path !== 'string'
|
|
205
|
+
if (external) whole = false
|
|
206
|
+
|
|
188
207
|
// How this fetch uses the page's record: by the query it names (`currentFor`).
|
|
189
208
|
const current = forced ?? (dynamicContext ? currentFor(cfg, route) : null)
|
|
190
209
|
|
|
191
210
|
if (current === 'exclude') {
|
|
192
211
|
// The query's records without this page's, its `limit` counting the others.
|
|
193
212
|
const view = othersView(cfg)
|
|
194
|
-
const
|
|
195
|
-
|
|
213
|
+
const asked = whole && cfg.ask ? { ...view, whole: true } : view
|
|
214
|
+
const answer = yield asked
|
|
215
|
+
if (answer.error) return { error: answer.error, errorConfig: asked }
|
|
196
216
|
if (answer.data === undefined || answer.data === null) return {}
|
|
197
|
-
|
|
217
|
+
const others = othersOf(answer.data, cfg, isPageRecord(dynamicContext, cfg.routeField))
|
|
218
|
+
if (!whole || cfg.ask) return { value: others }
|
|
219
|
+
return yield* wholeRecordsOf(cfg, others)
|
|
198
220
|
}
|
|
199
221
|
|
|
200
222
|
if (current === 'only' && cfg.ask) {
|
|
201
223
|
// ⭐ THE RECORDS SERVICE needs no list to find the record: the record is the query's set
|
|
202
224
|
// narrowed by the route's handle, so its one question answers the record if the set holds it
|
|
203
|
-
// and `[]` if not.
|
|
204
|
-
const detailCfg = buildDetailConfig(cfg, dynamicContext)
|
|
225
|
+
// and `[]` if not — whole or its brief, as the component expects.
|
|
226
|
+
const detailCfg = buildDetailConfig(cfg, dynamicContext, { whole })
|
|
205
227
|
// No per-record source, or a route carrying no value: an unanswerable question, and asking it
|
|
206
228
|
// is worse than leaving the key absent. ⚠️ `resolve` reported this as `pending` and `fetch`
|
|
207
229
|
// dispatched a null config; neither was intended (unified 2026-09-19).
|
|
@@ -228,52 +250,104 @@ export function* keyProgram(cfg, { dynamicContext = null, route = null, held = u
|
|
|
228
250
|
if (answer.error) return { error: answer.error, errorConfig: selection }
|
|
229
251
|
const items = Array.isArray(answer.data) ? answer.data : null
|
|
230
252
|
const { paramName, paramValue } = dynamicContext
|
|
231
|
-
const match = items?.find((item) => matchesRouteParam(item, paramName, paramValue)) ?? null
|
|
253
|
+
const match = items?.find((item) => matchesRouteParam(item, paramName, paramValue, cfg.routeField)) ?? null
|
|
232
254
|
if (!match) return { value: [] } // not found
|
|
233
|
-
if (
|
|
255
|
+
if (external) return yield* ownRecord(cfg, dynamicContext, match, peekRecord, maybe)
|
|
256
|
+
// A brief is what the set holds: the record is found, and that is the answer.
|
|
257
|
+
if (!whole) return { value: [match] }
|
|
234
258
|
|
|
235
|
-
// ⭐ Held
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
if (whole) return { value: [whole] }
|
|
259
|
+
// ⭐ Held whole already? Then it IS the record — no second request.
|
|
260
|
+
const wholeHeld = heldWhole(peekRecord, match)
|
|
261
|
+
if (wholeHeld) return { value: [wholeHeld] }
|
|
239
262
|
|
|
240
263
|
// ⭐ THE STEP THAT NEEDS THE ANSWER: the record's own address can name its own fields, so it
|
|
241
264
|
// is built with the record in hand and never from the route's value alone.
|
|
242
265
|
const detailCfg = buildDetailConfig(cfg, { ...dynamicContext, record: match })
|
|
243
|
-
|
|
266
|
+
// No request for one record: it cannot be had whole.
|
|
267
|
+
if (!detailCfg) return { value: null }
|
|
244
268
|
const detail = yield maybe(detailCfg)
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
if (detail.error) return { value: [match], error: detail.error, errorConfig: detailCfg }
|
|
249
|
-
const record = detail.data !== undefined && detail.data !== null ? detail.data : match
|
|
250
|
-
return { value: [record] }
|
|
269
|
+
if (detail.missing) return {}
|
|
270
|
+
if (detail.error) return { error: detail.error, errorConfig: detailCfg }
|
|
271
|
+
return { value: detail.data !== undefined && detail.data !== null ? [detail.data] : [] }
|
|
251
272
|
}
|
|
252
273
|
|
|
253
274
|
// A fetch the page's record plays no part in, and `current: include`: the records as the fetch
|
|
254
275
|
// describes them, the page's among the rest.
|
|
255
|
-
const
|
|
256
|
-
|
|
276
|
+
const asked = whole && cfg.ask ? { ...cfg, whole: true } : cfg
|
|
277
|
+
const answer = yield asked
|
|
278
|
+
if (answer.error) return { error: answer.error, errorConfig: asked }
|
|
257
279
|
if (answer.data === undefined || answer.data === null) return {}
|
|
258
|
-
return { value: answer.data }
|
|
280
|
+
if (!whole || cfg.ask) return { value: answer.data }
|
|
281
|
+
return yield* wholeRecordsOf(cfg, answer.data)
|
|
259
282
|
}
|
|
260
283
|
|
|
261
|
-
/**
|
|
262
|
-
|
|
263
|
-
|
|
284
|
+
/**
|
|
285
|
+
* An external query's record: its own request's answer (`record:`), found first in the list. The list
|
|
286
|
+
* already matched the record, so what it holds is a HELD value: a record with no request of its own,
|
|
287
|
+
* a failed request — or, for an opportunistic caller, one not yet held — keeps it rather than
|
|
288
|
+
* delivering `[[]]`.
|
|
289
|
+
*/
|
|
290
|
+
function* ownRecord(cfg, dynamicContext, match, peekRecord, maybe) {
|
|
291
|
+
// ⭐ Held in full already? Then it IS the record — no second request.
|
|
292
|
+
const held = heldWhole(peekRecord, match)
|
|
293
|
+
if (held) return { value: [held] }
|
|
294
|
+
// ⭐ THE STEP THAT NEEDS THE ANSWER: the record's own address can name its own fields, so it is
|
|
295
|
+
// built with the record in hand and never from the route's value alone.
|
|
296
|
+
const detailCfg = buildDetailConfig(cfg, { ...dynamicContext, record: match })
|
|
297
|
+
if (!detailCfg) return { value: [match] }
|
|
298
|
+
const detail = yield maybe(detailCfg)
|
|
299
|
+
if (detail.missing) return { value: [match] }
|
|
300
|
+
if (detail.error) return { value: [match], error: detail.error, errorConfig: detailCfg }
|
|
301
|
+
return { value: [detail.data !== undefined && detail.data !== null ? detail.data : match] }
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
/**
|
|
305
|
+
* A list's records WHOLE, each by its own request — a compiled file's per-record file, an external
|
|
306
|
+
* query's `record:` — asked together, in one step. `null` when a record has no such request: the list
|
|
307
|
+
* cannot be had whole, and a component expecting whole records is given none.
|
|
308
|
+
*
|
|
309
|
+
* @param {Object} cfg - the list's resolved config
|
|
310
|
+
* @param {*} records - the list's answer
|
|
311
|
+
* @returns {Generator<Object[], { value?: *, error?: string, errorConfig?: Object }>}
|
|
312
|
+
*/
|
|
313
|
+
function* wholeRecordsOf(cfg, records) {
|
|
314
|
+
if (!Array.isArray(records)) return { value: null }
|
|
315
|
+
if (records.length === 0) return { value: [] }
|
|
316
|
+
const requests = records.map((record) => recordRequest(cfg, record))
|
|
317
|
+
if (requests.some((request) => !request)) return { value: null }
|
|
318
|
+
const answers = yield requests
|
|
319
|
+
for (let i = 0; i < answers.length; i++) {
|
|
320
|
+
if (answers[i]?.error) return { error: answers[i].error, errorConfig: requests[i] }
|
|
321
|
+
}
|
|
322
|
+
if (answers.some((a) => a?.missing)) return {}
|
|
323
|
+
return { value: answers.map((a) => a?.data).filter((record) => record !== undefined && record !== null) }
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
/** The request for one record of a list, by its handle — `buildDetailConfig` with the record in hand. */
|
|
327
|
+
function recordRequest(cfg, record) {
|
|
328
|
+
const handle = record && typeof record === 'object' ? record.$name : undefined
|
|
329
|
+
if (handle === undefined || handle === null || handle === '') return null
|
|
330
|
+
return buildDetailConfig(cfg, { paramName: 'slug', paramValue: String(handle), record })
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
/** A yielded step as `{ request, optional }` entries: one request, or several asked together. */
|
|
334
|
+
function asSteps(value) {
|
|
335
|
+
return (Array.isArray(value) ? value : [value]).map((step) =>
|
|
336
|
+
step && step.request ? { request: step.request, optional: !!step.optional } : { request: step, optional: false }
|
|
337
|
+
)
|
|
264
338
|
}
|
|
265
339
|
|
|
266
340
|
/**
|
|
267
341
|
* Every filling key's program, ready to run.
|
|
268
342
|
*
|
|
269
|
-
* @param {Map<string, { cfg: Object, held:
|
|
343
|
+
* @param {Map<string, { cfg: Object, held: *, whole?: boolean }>} fills - `blockFills`
|
|
270
344
|
* @param {Object} where - passed to each `keyProgram`
|
|
271
345
|
* @returns {Map<string, Generator>}
|
|
272
346
|
*/
|
|
273
347
|
export function planFor(fills, where = {}) {
|
|
274
348
|
const plan = new Map()
|
|
275
|
-
for (const [key, { cfg, held }] of fills) {
|
|
276
|
-
plan.set(key, keyProgram(cfg, { ...where, held }))
|
|
349
|
+
for (const [key, { cfg, held, whole = false }] of fills) {
|
|
350
|
+
plan.set(key, keyProgram(cfg, { ...where, held, whole }))
|
|
277
351
|
}
|
|
278
352
|
return plan
|
|
279
353
|
}
|
|
@@ -302,20 +376,25 @@ export function runPlanSync(plan, { peek }) {
|
|
|
302
376
|
if (out.value !== undefined) data[key] = out.value
|
|
303
377
|
break
|
|
304
378
|
}
|
|
305
|
-
const
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
379
|
+
const answers = []
|
|
380
|
+
let missed = false
|
|
381
|
+
for (const { request, optional } of asSteps(step.value)) {
|
|
382
|
+
const cached = peek(request)
|
|
383
|
+
if (cached) answers.push({ data: cached.data })
|
|
384
|
+
// The program can do without this one and says what it makes of that.
|
|
385
|
+
else if (optional) answers.push({ missing: true })
|
|
386
|
+
else {
|
|
387
|
+
missed = true
|
|
388
|
+
break
|
|
312
389
|
}
|
|
390
|
+
}
|
|
391
|
+
if (missed) {
|
|
313
392
|
// Not cached: this key is not answerable without a request, so the block is not ready.
|
|
314
393
|
program.return()
|
|
315
394
|
complete = false
|
|
316
395
|
break
|
|
317
396
|
}
|
|
318
|
-
sent =
|
|
397
|
+
sent = Array.isArray(step.value) ? answers : answers[0]
|
|
319
398
|
}
|
|
320
399
|
}
|
|
321
400
|
|
|
@@ -327,7 +406,7 @@ export function runPlanSync(plan, { peek }) {
|
|
|
327
406
|
*
|
|
328
407
|
* ⭐ Every key's first request is issued in the same tick, which is what lets a question door batch
|
|
329
408
|
* a page's questions into one POST. A key whose second request depends on its first answer takes a
|
|
330
|
-
* later tick, and only that key waits.
|
|
409
|
+
* later tick, and only that key waits; the requests of one step are issued together.
|
|
331
410
|
*
|
|
332
411
|
* @param {Map<string, Generator>} plan
|
|
333
412
|
* @param {Object} io
|
|
@@ -352,8 +431,9 @@ export async function runPlan(plan, { dispatch, onFailure = null }) {
|
|
|
352
431
|
}
|
|
353
432
|
return
|
|
354
433
|
}
|
|
355
|
-
const
|
|
356
|
-
|
|
434
|
+
const results = await Promise.all(asSteps(step.value).map(({ request }) => dispatch(request)))
|
|
435
|
+
const answers = results.map((result) => ({ data: result?.data, error: result?.error }))
|
|
436
|
+
sent = Array.isArray(step.value) ? answers : answers[0]
|
|
357
437
|
}
|
|
358
438
|
}))
|
|
359
439
|
|
package/src/query-evaluation.js
CHANGED
|
@@ -24,9 +24,9 @@
|
|
|
24
24
|
*/
|
|
25
25
|
|
|
26
26
|
import { match as matchWhere } from './where.js'
|
|
27
|
-
import { applyScope } from './scope.js'
|
|
28
|
-
import { sortRecords } from './sort.js'
|
|
29
|
-
import {
|
|
27
|
+
import { applyScope, withoutBranch } from './scope.js'
|
|
28
|
+
import { sortRecords, parseSort } from './sort.js'
|
|
29
|
+
import { recordKeyValues } from './route-match.js'
|
|
30
30
|
|
|
31
31
|
/**
|
|
32
32
|
* Evaluate a resolved config's set and its narrowing over records.
|
|
@@ -42,7 +42,10 @@ import { routeParamValues } from './route-match.js'
|
|
|
42
42
|
* @returns {Array<Object>|*} the records the config selects, in its order
|
|
43
43
|
*/
|
|
44
44
|
export function evaluateQuery(records, config, { locale = null, sort = sortRecords } = {}) {
|
|
45
|
-
if (!Array.isArray(records)
|
|
45
|
+
if (!Array.isArray(records)) return records
|
|
46
|
+
// ⛔ What this returns is an ANSWER, and no answer carries a record's branch: `scope` reads
|
|
47
|
+
// the one a compiled file holds, and it goes no further (`withoutBranch`, ruled 2026-09-27).
|
|
48
|
+
if (!config || typeof config !== 'object') return withoutBranch(records)
|
|
46
49
|
const order = (items, expr) => sort(items, expr, { locale })
|
|
47
50
|
|
|
48
51
|
let out = records
|
|
@@ -52,11 +55,48 @@ export function evaluateQuery(records, config, { locale = null, sort = sortRecor
|
|
|
52
55
|
if (isLimit(config.limit)) out = out.slice(0, config.limit)
|
|
53
56
|
|
|
54
57
|
const narrow = config.narrow
|
|
55
|
-
if (!narrow || typeof narrow !== 'object') return out
|
|
58
|
+
if (!narrow || typeof narrow !== 'object') return withoutBranch(out)
|
|
56
59
|
if (narrow.where) out = matchWhere(narrow.where, out)
|
|
57
60
|
if (narrow.match && typeof narrow.match === 'object') out = out.filter(matching(narrow.match))
|
|
58
61
|
if (narrow.sort) out = order(out, narrow.sort)
|
|
59
62
|
if (isLimit(narrow.limit)) out = out.slice(0, narrow.limit)
|
|
63
|
+
return withoutBranch(out)
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* A resolved config with every FIELD PATH it names mapped — the `where` keys and the `sort` field of
|
|
68
|
+
* its set and of its narrowing — so it can be evaluated over records held in another shape. A static
|
|
69
|
+
* build maps each path to where the record AS STORED holds it (`@uniweb/schemas/conform`'s
|
|
70
|
+
* `storedPath`), which is how the records service reads a path (ruled 2026-09-27
|
|
71
|
+
* [Diego]). A `$` key, and `narrow.match` — which names the record, not a
|
|
72
|
+
* field of it — are kept as they are.
|
|
73
|
+
*
|
|
74
|
+
* @param {Object} config - a resolved config, or a question
|
|
75
|
+
* @param {(path: string) => string} mapPath
|
|
76
|
+
* @returns {Object} a new config; the input is not mutated
|
|
77
|
+
*/
|
|
78
|
+
export function mapQueryPaths(config, mapPath) {
|
|
79
|
+
if (!config || typeof config !== 'object' || typeof mapPath !== 'function') return config
|
|
80
|
+
const level = (at) => {
|
|
81
|
+
const out = { ...at }
|
|
82
|
+
if (at.where && typeof at.where === 'object') out.where = mapWherePaths(at.where, mapPath)
|
|
83
|
+
const spec = at.sort ? parseSort(at.sort) : null
|
|
84
|
+
if (spec) out.sort = { field: mapPath(spec.field), desc: spec.desc }
|
|
85
|
+
return out
|
|
86
|
+
}
|
|
87
|
+
const out = level(config)
|
|
88
|
+
if (config.narrow && typeof config.narrow === 'object') out.narrow = level(config.narrow)
|
|
89
|
+
return out
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function mapWherePaths(where, mapPath) {
|
|
93
|
+
if (!where || typeof where !== 'object' || Array.isArray(where)) return where
|
|
94
|
+
const out = {}
|
|
95
|
+
for (const [key, value] of Object.entries(where)) {
|
|
96
|
+
if (key === 'and' || key === 'or') out[key] = Array.isArray(value) ? value.map((sub) => mapWherePaths(sub, mapPath)) : value
|
|
97
|
+
else if (key === 'not') out[key] = mapWherePaths(value, mapPath)
|
|
98
|
+
else out[key.startsWith('$') ? key : mapPath(key)] = value
|
|
99
|
+
}
|
|
60
100
|
return out
|
|
61
101
|
}
|
|
62
102
|
|
|
@@ -68,20 +108,14 @@ function isLimit(limit) {
|
|
|
68
108
|
/**
|
|
69
109
|
* A `match` — the record a parametric page's URL names — as a filter: the key is the
|
|
70
110
|
* record field `routeRecordKey` maps a route param to (`$name`, `$uuid`, or the field
|
|
71
|
-
* itself), and its value is compared as a STRING, member-wise on a list
|
|
72
|
-
*
|
|
111
|
+
* itself), and its value is compared as a STRING, member-wise on a list
|
|
112
|
+
* (`recordKeyValues`). ⛔ Until 2026-09-27 a key was mapped back to a param name, so a field
|
|
113
|
+
* called `slug` read the record's `$name`.
|
|
73
114
|
*/
|
|
74
115
|
function matching(match) {
|
|
75
116
|
const entries = Object.entries(match)
|
|
76
117
|
return (record) => entries.every(([key, value]) => {
|
|
77
118
|
const target = String(value)
|
|
78
|
-
return
|
|
119
|
+
return recordKeyValues(record, key).some((held) => held === target)
|
|
79
120
|
})
|
|
80
121
|
}
|
|
81
|
-
|
|
82
|
-
/** The inverse of `routeRecordKey`: `$name` is a `[slug]` page's handle, `$uuid` a `[uuid]` page's identity. */
|
|
83
|
-
function paramNameOf(key) {
|
|
84
|
-
if (key === '$name') return 'slug'
|
|
85
|
-
if (key === '$uuid') return 'uuid'
|
|
86
|
-
return key
|
|
87
|
-
}
|
package/src/route-match.js
CHANGED
|
@@ -228,8 +228,10 @@ export function splitPathCapture(capture) {
|
|
|
228
228
|
}
|
|
229
229
|
|
|
230
230
|
/**
|
|
231
|
-
* The inverse of `splitPathCapture` — a
|
|
232
|
-
* `
|
|
231
|
+
* The inverse of `splitPathCapture` — a capture from its directory and its last
|
|
232
|
+
* segment. `dir` may be empty. ⛔ Not a record's URL: a record carries no branch, so its
|
|
233
|
+
* own URL under `[...path]` is its handle alone (`fillRoutePattern`). Until 2026-09-27 this
|
|
234
|
+
* composed a record's placement and handle into one.
|
|
233
235
|
*
|
|
234
236
|
* @param {{ dir?: string|null, slug?: string|null }} parts
|
|
235
237
|
* @returns {string|null} null when there is no slug to name the record by
|
|
@@ -242,14 +244,19 @@ export function joinPathCapture({ dir, slug } = {}) {
|
|
|
242
244
|
|
|
243
245
|
/**
|
|
244
246
|
* A record's PLACEMENT HANDLE — the segment its folder entry is named by, which is
|
|
245
|
-
* what a `[slug]` route (or the last segment of a `[...path]` one) matches
|
|
246
|
-
*
|
|
247
|
-
*
|
|
248
|
-
*
|
|
249
|
-
*
|
|
250
|
-
*
|
|
251
|
-
*
|
|
252
|
-
*
|
|
247
|
+
* what a `[slug]` route (or the last segment of a `[...path]` one) matches: `$name`,
|
|
248
|
+
* and nothing else.
|
|
249
|
+
*
|
|
250
|
+
* ⭐ `$name` is the name of the record as an entry in its folder [Diego, 2026-09-27]: a
|
|
251
|
+
* host's records service serves it, and the static build gives it as the record file's
|
|
252
|
+
* name. `$`-namespaced because a record's own fields are the author's data, and no field —
|
|
253
|
+
* `slug`, `name` — is the placement. A record that is an entry in no folder — an external
|
|
254
|
+
* API's — has none; its page is found by the field its route query binds the URL to
|
|
255
|
+
* (`routeFieldOf`, below).
|
|
256
|
+
* ⛔ Until 2026-09-27 a record with no `$name` fell back to its `slug` field, which
|
|
257
|
+
* gave one field name a meaning the framework must not assign [Diego, 2026-09-27:
|
|
258
|
+
* "remove any notion that we, runtime/core, read `slug` from a record's field as if it
|
|
259
|
+
* had any meaning to us"].
|
|
253
260
|
*
|
|
254
261
|
* @param {Object} record
|
|
255
262
|
* @returns {string|undefined}
|
|
@@ -257,8 +264,7 @@ export function joinPathCapture({ dir, slug } = {}) {
|
|
|
257
264
|
export function recordHandle(record) {
|
|
258
265
|
if (!record || typeof record !== 'object') return undefined
|
|
259
266
|
const name = record.$name
|
|
260
|
-
|
|
261
|
-
return record.slug
|
|
267
|
+
return typeof name === 'string' && name.length ? name : undefined
|
|
262
268
|
}
|
|
263
269
|
|
|
264
270
|
/**
|
|
@@ -268,7 +274,7 @@ export function recordHandle(record) {
|
|
|
268
274
|
* parametric page (`Website._createDynamicPage`, so the SPA and a host's render), the
|
|
269
275
|
* static build's page expansion, and the record search index (`@uniweb/projections`).
|
|
270
276
|
*
|
|
271
|
-
* title → name → the record's handle (`recordHandle`: `$name
|
|
277
|
+
* title → name → the record's handle (`recordHandle`: `$name`)
|
|
272
278
|
*
|
|
273
279
|
* ⛔ Until then a page read `title` alone while the search index read `title || name`,
|
|
274
280
|
* so a record with a `name` and no `title` — a person — titled its search result and
|
|
@@ -304,17 +310,17 @@ export function recordTitle(record) {
|
|
|
304
310
|
* @param {string} paramName
|
|
305
311
|
* @returns {string}
|
|
306
312
|
*/
|
|
307
|
-
export function routeRecordKey(paramName) {
|
|
308
|
-
if (paramName === 'slug') return '$name'
|
|
313
|
+
export function routeRecordKey(paramName, field = null) {
|
|
314
|
+
if (paramName === 'slug') return typeof field === 'string' && field ? field : '$name'
|
|
309
315
|
if (paramName === 'uuid') return '$uuid'
|
|
310
316
|
return paramName
|
|
311
317
|
}
|
|
312
318
|
|
|
313
319
|
/**
|
|
314
|
-
* The value a record carries for a route param, by `routeRecordKey`'s map. The
|
|
315
|
-
*
|
|
316
|
-
*
|
|
317
|
-
*
|
|
320
|
+
* The value a record carries for a route param, by `routeRecordKey`'s map. The handle
|
|
321
|
+
* is `$name` alone (`recordHandle`); the identity falls back to the plain `uuid` field a
|
|
322
|
+
* source without `$uuid` carries — so a remote API that routes `[uuid]` on its own
|
|
323
|
+
* `uuid` field keeps matching (ruled 2026-09-11).
|
|
318
324
|
*
|
|
319
325
|
* ⛔ Every reader that matches a delivered record to a route param goes through
|
|
320
326
|
* this — the entity store, the website's parametric page, the static build's
|
|
@@ -327,9 +333,9 @@ export function routeRecordKey(paramName) {
|
|
|
327
333
|
* @param {string} paramName
|
|
328
334
|
* @returns {*}
|
|
329
335
|
*/
|
|
330
|
-
export function routeParamValue(record, paramName) {
|
|
336
|
+
export function routeParamValue(record, paramName, field = null) {
|
|
331
337
|
if (!record || typeof record !== 'object') return undefined
|
|
332
|
-
if (paramName === 'slug') return recordHandle(record)
|
|
338
|
+
if (paramName === 'slug') return typeof field === 'string' && field ? record[field] : recordHandle(record)
|
|
333
339
|
if (paramName === 'uuid') {
|
|
334
340
|
const id = record.$uuid
|
|
335
341
|
return typeof id === 'string' && id.length ? id : record.uuid
|
|
@@ -361,8 +367,28 @@ export function routeParamValue(record, paramName) {
|
|
|
361
367
|
* @param {string} paramName
|
|
362
368
|
* @returns {string[]} the values, in the record's own order
|
|
363
369
|
*/
|
|
364
|
-
export function routeParamValues(record, paramName) {
|
|
365
|
-
|
|
370
|
+
export function routeParamValues(record, paramName, field = null) {
|
|
371
|
+
return stringValues(routeParamValue(record, paramName, field))
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
/**
|
|
375
|
+
* Every value a record holds under a record KEY — `$name`, `$uuid`, or a field of its own —
|
|
376
|
+
* as strings, member-wise: what a `narrow.match` key (`routeRecordKey`'s output) is compared
|
|
377
|
+
* with. ⛔ Not `routeParamValues` over a param name: a field called `slug` is the author's data,
|
|
378
|
+
* while the param `slug` names the record's `$name`.
|
|
379
|
+
*
|
|
380
|
+
* @param {Object} record
|
|
381
|
+
* @param {string} key
|
|
382
|
+
* @returns {string[]}
|
|
383
|
+
*/
|
|
384
|
+
export function recordKeyValues(record, key) {
|
|
385
|
+
if (!record || typeof record !== 'object') return []
|
|
386
|
+
if (key === '$name') return stringValues(recordHandle(record))
|
|
387
|
+
if (key === '$uuid') return stringValues(routeParamValue(record, 'uuid'))
|
|
388
|
+
return stringValues(record[key])
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
function stringValues(raw) {
|
|
366
392
|
const out = []
|
|
367
393
|
for (const value of Array.isArray(raw) ? raw : [raw]) {
|
|
368
394
|
if (value === undefined || value === null || value === '') continue
|
|
@@ -382,11 +408,36 @@ export function routeParamValues(record, paramName) {
|
|
|
382
408
|
* @param {Object} record
|
|
383
409
|
* @param {string} paramName
|
|
384
410
|
* @param {string|number} value - the URL segment
|
|
411
|
+
* @param {string|null} [field] - the field the route query binds `:slug` to (`routeFieldOf`)
|
|
385
412
|
* @returns {boolean}
|
|
386
413
|
*/
|
|
387
|
-
export function matchesRouteParam(record, paramName, value) {
|
|
414
|
+
export function matchesRouteParam(record, paramName, value, field = null) {
|
|
388
415
|
const target = String(value)
|
|
389
|
-
return routeParamValues(record, paramName).some((held) => held === target)
|
|
416
|
+
return routeParamValues(record, paramName, field).some((held) => held === target)
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
/**
|
|
420
|
+
* The record field a route query binds the URL's last segment to — `where: { code: ':slug' }`
|
|
421
|
+
* → `code` — or null when it binds none.
|
|
422
|
+
*
|
|
423
|
+
* ⭐ A PARAMETRIC PAGE'S RECORD IS ITS ROUTE QUERY'S TO FIND [Diego, 2026-09-27: "it's put to a
|
|
424
|
+
* parametric query to choose"]. By default a `[slug]` page (and a `[...path]` page's last segment)
|
|
425
|
+
* names a record by its `$name`, the name it has as an entry in its folder. A query whose records
|
|
426
|
+
* are named otherwise — an external API's, which are entries in no folder — says which of their
|
|
427
|
+
* fields the URL names by binding the segment in its own `where`; the page then matches that
|
|
428
|
+
* field, its records link by it, and a static build expands over it. On the query's own page the
|
|
429
|
+
* clause drops, since no URL binds it. Only a top-level clause whose whole value is `:slug` counts.
|
|
430
|
+
*
|
|
431
|
+
* @param {Object|null} decl - a query declaration (`config.queries[<name>]`)
|
|
432
|
+
* @returns {string|null}
|
|
433
|
+
*/
|
|
434
|
+
export function routeFieldOf(decl) {
|
|
435
|
+
const where = decl && typeof decl === 'object' ? decl.where : null
|
|
436
|
+
if (!where || typeof where !== 'object' || Array.isArray(where)) return null
|
|
437
|
+
for (const [key, value] of Object.entries(where)) {
|
|
438
|
+
if (value === ':slug' && key !== 'and' && key !== 'or' && key !== 'not' && !key.startsWith('$')) return key
|
|
439
|
+
}
|
|
440
|
+
return null
|
|
390
441
|
}
|
|
391
442
|
|
|
392
443
|
/**
|
|
@@ -527,34 +578,35 @@ export function recordRouteBase(route) {
|
|
|
527
578
|
*
|
|
528
579
|
* @param {string} pattern - a route pattern with `:param` placeholders
|
|
529
580
|
* @param {Object} values - a record, read by param name
|
|
581
|
+
* @param {{ field?: string|null }} [options] - `field`: the record field `:slug` is filled from
|
|
582
|
+
* when the route query binds it (`routeFieldOf`); by default the record's `$name`
|
|
530
583
|
* @returns {string|null}
|
|
531
584
|
*/
|
|
532
|
-
export function fillRoutePattern(pattern, values) {
|
|
585
|
+
export function fillRoutePattern(pattern, values, { field = null } = {}) {
|
|
533
586
|
if (typeof pattern !== 'string' || !values || typeof values !== 'object') return null
|
|
534
587
|
let missing = false
|
|
535
588
|
let head = pattern
|
|
536
589
|
let tailHref = ''
|
|
537
590
|
const tail = pattern.match(CATCH_ALL)
|
|
538
591
|
if (tail) {
|
|
539
|
-
// A catch-all is filled
|
|
540
|
-
//
|
|
541
|
-
//
|
|
542
|
-
//
|
|
543
|
-
// `
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
const
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
tailHref = '/' + segments.join('/')
|
|
592
|
+
// ⭐ A catch-all is filled with the record's handle ALONE — one segment, whatever it
|
|
593
|
+
// contains (a `/` inside it is a value). A record carries no branch [Diego,
|
|
594
|
+
// 2026-09-27], so its own URL under `[...path]` is the one it has under `[slug]`:
|
|
595
|
+
// `/logbook/<name>`. A URL that names a directory still reaches the page, and
|
|
596
|
+
// `scope: :dir` is what makes the directory count. ⛔ Until 2026-09-27 this prefixed the
|
|
597
|
+
// record's `path`, so one record was linked at `/logbook/field/river-survey` on a static
|
|
598
|
+
// site and at `/logbook/river-survey` where a records service, which answers no branch,
|
|
599
|
+
// served it.
|
|
600
|
+
const [handle] = routeParamValues(values, 'slug', field)
|
|
601
|
+
if (handle === undefined) return null
|
|
602
|
+
tailHref = '/' + encodeURIComponent(String(handle))
|
|
551
603
|
head = pattern.slice(0, tail.index)
|
|
552
604
|
}
|
|
553
605
|
const href = head.replace(new RegExp(`:(${PARAM_NAME})`, 'g'), (_, name) => {
|
|
554
606
|
// ⭐ THE FIRST MEMBER of a `multi` field — a record has ONE canonical href, and
|
|
555
607
|
// every member routes to it (`routeParamValues`). Baked whole, a `['a','b']`
|
|
556
608
|
// field produced `/tags/a%2Cb`, a URL no lane matches.
|
|
557
|
-
const [value] = routeParamValues(values, name)
|
|
609
|
+
const [value] = routeParamValues(values, name, name === 'slug' ? field : null)
|
|
558
610
|
if (value === undefined) {
|
|
559
611
|
missing = true
|
|
560
612
|
return ''
|
package/src/scope.js
CHANGED
|
@@ -1,11 +1,19 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* A query's `scope:` — the folder branch it reads — evaluated over records
|
|
3
|
-
*
|
|
2
|
+
* A query's `scope:` — the folder branch it reads — evaluated over compiled records,
|
|
3
|
+
* which hold their branch.
|
|
4
4
|
*
|
|
5
|
-
* A record
|
|
6
|
-
* query-processor.js`). A scope
|
|
7
|
-
*
|
|
8
|
-
* The root (`''`) holds everything.
|
|
5
|
+
* A compiled record holds the folder `records/folder.yml` placed it in under
|
|
6
|
+
* `$branch` (`BRANCH_KEY`, written by `build/src/site/query-processor.js`). A scope
|
|
7
|
+
* contains that folder and every folder below it, at SEGMENT boundaries: `field`
|
|
8
|
+
* holds `field` and `field/2025`, never `fieldwork`. The root (`''`) holds everything.
|
|
9
|
+
*
|
|
10
|
+
* ⛔ A RECORD DOES NOT CARRY ITS BRANCH (ruled 2026-09-27 [Diego]: *"branches are to
|
|
11
|
+
* organize the content pool … queries can filter by branch using `scope`"*). `$branch`
|
|
12
|
+
* is the compiled file's own key, there because the browser evaluates `scope` over that
|
|
13
|
+
* file. `evaluateQuery` reads it and answers no record with it (`withoutBranch`), just as
|
|
14
|
+
* a records service answers none. ⛔ Until then every compiled record carried `path`,
|
|
15
|
+
* which reached components, overwrote an authored field named `path`, and made a
|
|
16
|
+
* `[...path]` record's link `<branch>/<name>`.
|
|
9
17
|
*
|
|
10
18
|
* ⭐ Why this is its own field and not a `where` operator: a branch is a scope,
|
|
11
19
|
* and `where` stays the author's predicate over the records' own fields. Ruled
|
|
@@ -18,6 +26,31 @@
|
|
|
18
26
|
* Zero-dependency leaf.
|
|
19
27
|
*/
|
|
20
28
|
|
|
29
|
+
/**
|
|
30
|
+
* The key a compiled record holds its branch under. The static lane's store only; no
|
|
31
|
+
* answer carries it.
|
|
32
|
+
*/
|
|
33
|
+
export const BRANCH_KEY = '$branch'
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Records as a query answers them: without the branch their compiled file holds. A list
|
|
37
|
+
* in which no record holds one is returned as it is.
|
|
38
|
+
*
|
|
39
|
+
* @param {Array<Object>} records
|
|
40
|
+
* @returns {Array<Object>}
|
|
41
|
+
*/
|
|
42
|
+
export function withoutBranch(records) {
|
|
43
|
+
if (!Array.isArray(records)) return records
|
|
44
|
+
if (!records.some((r) => r && typeof r === 'object' && Object.prototype.hasOwnProperty.call(r, BRANCH_KEY))) {
|
|
45
|
+
return records
|
|
46
|
+
}
|
|
47
|
+
return records.map((r) => {
|
|
48
|
+
if (!r || typeof r !== 'object' || !Object.prototype.hasOwnProperty.call(r, BRANCH_KEY)) return r
|
|
49
|
+
const { [BRANCH_KEY]: _branch, ...rest } = r
|
|
50
|
+
return rest
|
|
51
|
+
})
|
|
52
|
+
}
|
|
53
|
+
|
|
21
54
|
function trimSlashes(s) {
|
|
22
55
|
return s.replace(/^\/+/, '').replace(/\/+$/, '')
|
|
23
56
|
}
|
|
@@ -29,7 +62,7 @@ function trimSlashes(s) {
|
|
|
29
62
|
* `2024`, the classic prefix bug, which silently returns records from a sibling
|
|
30
63
|
* folder the author never named.
|
|
31
64
|
*
|
|
32
|
-
* @param {*} placement - a record's `
|
|
65
|
+
* @param {*} placement - a compiled record's branch (`BRANCH_KEY`)
|
|
33
66
|
* @param {string} scope
|
|
34
67
|
* @returns {boolean}
|
|
35
68
|
*/
|
|
@@ -52,5 +85,5 @@ export function withinScope(placement, scope) {
|
|
|
52
85
|
export function applyScope(records, scope) {
|
|
53
86
|
if (!Array.isArray(records)) return records
|
|
54
87
|
if (typeof scope !== 'string' || trimSlashes(scope) === '') return records
|
|
55
|
-
return records.filter((r) => r && typeof r === 'object' && withinScope(r
|
|
88
|
+
return records.filter((r) => r && typeof r === 'object' && withinScope(r[BRANCH_KEY], scope))
|
|
56
89
|
}
|