@uniweb/core 0.26.2 → 0.28.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +4 -3
- package/src/data-paths.js +2 -2
- package/src/detail-url.js +60 -119
- package/src/entity-store.js +79 -142
- package/src/fetch-config.js +275 -58
- package/src/fetcher-dispatcher.js +3 -3
- package/src/field-path.js +31 -0
- package/src/index.js +2 -2
- package/src/layout-name.js +61 -0
- package/src/sort.js +96 -12
- package/src/website.js +34 -26
- package/src/where.js +189 -127
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@uniweb/core",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.28.0",
|
|
4
4
|
"description": "Core classes for the Uniweb platform - Uniweb, Website, Page, Block",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"exports": {
|
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
"./datastore": "./src/datastore.js",
|
|
12
12
|
"./detail-url": "./src/detail-url.js",
|
|
13
13
|
"./fetch-config": "./src/fetch-config.js",
|
|
14
|
+
"./layout-name": "./src/layout-name.js",
|
|
14
15
|
"./icon-corpus": "./src/icon-corpus.js",
|
|
15
16
|
"./locale-config": "./src/locale-config.js",
|
|
16
17
|
"./route-match": "./src/route-match.js",
|
|
@@ -44,8 +45,8 @@
|
|
|
44
45
|
"vitest": "^4.1.7"
|
|
45
46
|
},
|
|
46
47
|
"dependencies": {
|
|
47
|
-
"@uniweb/
|
|
48
|
-
"@uniweb/
|
|
48
|
+
"@uniweb/theming": "^0.1.15",
|
|
49
|
+
"@uniweb/semantic-parser": "^1.4.0"
|
|
49
50
|
},
|
|
50
51
|
"scripts": {
|
|
51
52
|
"test": "vitest run"
|
package/src/data-paths.js
CHANGED
|
@@ -115,8 +115,8 @@ export function queryNameFromUrl(path) {
|
|
|
115
115
|
* Whether a fetch config's `path` addresses compiled query data.
|
|
116
116
|
*
|
|
117
117
|
* Used to scope behavior that only makes sense for build-emitted files —
|
|
118
|
-
* locale prefixing in particular, which must not touch
|
|
119
|
-
*
|
|
118
|
+
* locale prefixing in particular, which must not touch an external query's
|
|
119
|
+
* `url:`.
|
|
120
120
|
*
|
|
121
121
|
* @param {*} path - A fetch config's `path` field.
|
|
122
122
|
* @returns {boolean}
|
package/src/detail-url.js
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Detail-record resolution — the ONE home for turning a query
|
|
3
|
-
*
|
|
2
|
+
* Detail-record resolution — the ONE home for turning a resolved query config plus
|
|
3
|
+
* a parametric page's route value into the request for its record.
|
|
4
4
|
*
|
|
5
5
|
* Why this is a subpath rather than an EntityStore internal. A host that
|
|
6
6
|
* renders a detail page server-side has to fetch *the same record* the browser
|
|
7
|
-
* will fetch when it hydrates over that render.
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
7
|
+
* will fetch when it hydrates over that render. If the two build the request even
|
|
8
|
+
* slightly differently, a host prerenders record A and the browser hydrates
|
|
9
|
+
* record B — silently, and only on parametric pages. That is the same failure
|
|
10
|
+
* `./route-match.js` was extracted to end, on the fetch side instead of the
|
|
11
|
+
* routing side.
|
|
12
12
|
*
|
|
13
13
|
* Zero-dependency leaf, like `./route-match.js`, `./data-paths.js` and
|
|
14
14
|
* `./fetch-config.js`: it imports one sibling that itself imports nothing, so a
|
|
@@ -26,22 +26,15 @@ import { recordHandle, routeRecordKey } from './route-match.js'
|
|
|
26
26
|
import { substitutePlaceholders } from './substitute-placeholders.js'
|
|
27
27
|
|
|
28
28
|
/**
|
|
29
|
-
* The substitution context for a
|
|
29
|
+
* The substitution context for a record request: the route's own param name,
|
|
30
30
|
* plus a generic `param` alias bound to the same value.
|
|
31
31
|
*
|
|
32
|
-
* ⭐ Why the alias exists. An AUTHOR writing a
|
|
33
|
-
* and writes `{slug}` or `{id}` — that convention is unchanged and must stay
|
|
34
|
-
*
|
|
35
|
-
*
|
|
36
|
-
*
|
|
37
|
-
*
|
|
38
|
-
*
|
|
39
|
-
* Binding both names to one value is what lets the two conventions coexist
|
|
40
|
-
* without a translation step between them — and a translation step is exactly
|
|
41
|
-
* where the framework has twice grown a second copy of a rule that then drifted
|
|
42
|
-
* (`route-match`, `data-paths`). `substitutePlaceholders` only resolves keys
|
|
43
|
-
* present in the context, so an unrelated `{name}` still passes through
|
|
44
|
-
* literally, as it always has.
|
|
32
|
+
* ⭐ Why the alias exists. An AUTHOR writing a record address knows their route
|
|
33
|
+
* and writes `{slug}` or `{id}` — that convention is unchanged and must stay. A
|
|
34
|
+
* HOST declaring a record address cannot know it: `param_name` is the site's
|
|
35
|
+
* routing choice. So the host writes `{param}`. `substitutePlaceholders` only
|
|
36
|
+
* resolves keys present in the context, so an unrelated `{name}` still passes
|
|
37
|
+
* through literally.
|
|
45
38
|
*/
|
|
46
39
|
function paramContext(paramName, paramValue, record) {
|
|
47
40
|
const context = { [paramName]: paramValue, param: paramValue }
|
|
@@ -62,33 +55,6 @@ function paramContext(paramName, paramValue, record) {
|
|
|
62
55
|
return context
|
|
63
56
|
}
|
|
64
57
|
|
|
65
|
-
/**
|
|
66
|
-
* Build a detail-URL fetch config from a query config + dynamic context.
|
|
67
|
-
*
|
|
68
|
-
* Four forms of `detail:`:
|
|
69
|
-
* - `'rest'` — append paramValue as a path segment.
|
|
70
|
-
* - `'query'` — append `?paramName=paramValue`.
|
|
71
|
-
* - `'/articles/{slug}'` — custom URL pattern with {paramName} placeholders,
|
|
72
|
-
* or the generic `{param}` alias (see below).
|
|
73
|
-
* - `{ body, envelope }` — object form. Reuses the query's url /
|
|
74
|
-
* method / headers / auth; adds per-detail
|
|
75
|
-
* body (with placeholder substitution) and
|
|
76
|
-
* per-detail envelope.
|
|
77
|
-
*
|
|
78
|
-
* Returns `null` — never throws — when the query declares no `detail:`,
|
|
79
|
-
* when the dynamic context carries no param, or when the query has
|
|
80
|
-
* neither `url:` nor `path:` to build from. A caller treats `null` as "this
|
|
81
|
-
* query has no separate detail fetch", which is the common case.
|
|
82
|
-
*
|
|
83
|
-
* @param {Object} queryConfig - A resolved fetch config for the query
|
|
84
|
-
* (post-`resolveFetchConfigs`, so `detail` may have been auto-injected for a
|
|
85
|
-
* `deferred:` query — see `./fetch-config.js`).
|
|
86
|
-
* @param {{ paramName: string, paramValue: string, record?: Object|null }} dynamicContext -
|
|
87
|
-
* the route's param and its value; `record`, when the caller already holds the
|
|
88
|
-
* matched record, lets `{slug}` resolve to the record's own slug (see
|
|
89
|
-
* `paramContext`).
|
|
90
|
-
* @returns {Object|null} A fetch config carrying `url` or `path`, or null.
|
|
91
|
-
*/
|
|
92
58
|
/**
|
|
93
59
|
* The record key a `[slug]` (or `[...path]`) route matches — the entry's own
|
|
94
60
|
* handle, which the records service guarantees unique among siblings (the
|
|
@@ -98,6 +64,33 @@ function paramContext(paramName, paramValue, record) {
|
|
|
98
64
|
*/
|
|
99
65
|
export const ROUTE_HANDLE_KEY = routeRecordKey('slug')
|
|
100
66
|
|
|
67
|
+
/**
|
|
68
|
+
* Build the request for a parametric page's record from its route query's resolved
|
|
69
|
+
* config (post-`resolveFetchConfigs`, which set `detail` — see `./fetch-config.js`).
|
|
70
|
+
* Three sources, and no others:
|
|
71
|
+
*
|
|
72
|
+
* - **the records service** — the route query's own question, plus `match`;
|
|
73
|
+
* - **an external query's `record:`** — its `url` (the query's when it names none),
|
|
74
|
+
* `method` (likewise), `body` and `transform` — ⛔ never the list's `body` or
|
|
75
|
+
* `transform`, since a record response is rarely wrapped the way the list is;
|
|
76
|
+
* - **a `deferred:` query's per-record file** — the `/data/<query>/{slug}.json`
|
|
77
|
+
* pattern the resolver injected.
|
|
78
|
+
*
|
|
79
|
+
* ⛔ The `detail:` forms an author wrote until 2026-09-13 — `rest`, `query`, a URL
|
|
80
|
+
* pattern and `{ body, envelope }` — are gone with inline `url:` fetches [Diego]: an
|
|
81
|
+
* external query's `record:` says the same thing, on the query. The record request
|
|
82
|
+
* reused the list's `transform` until then, so a list under `results` and a bare
|
|
83
|
+
* record endpoint delivered `[]` (measured).
|
|
84
|
+
*
|
|
85
|
+
* Returns `null` — never throws — when the config has no per-record source or the
|
|
86
|
+
* route carries no value. A caller treats `null` as "find the record in the list".
|
|
87
|
+
*
|
|
88
|
+
* @param {Object} queryConfig - a resolved config
|
|
89
|
+
* @param {{ paramName: string, paramValue: string, record?: Object|null }} dynamicContext -
|
|
90
|
+
* the route's param and its value; `record`, when the caller already holds the
|
|
91
|
+
* matched record, lets `{slug}` resolve to the record's own slug (`paramContext`).
|
|
92
|
+
* @returns {Object|null} a request, or null
|
|
93
|
+
*/
|
|
101
94
|
export function buildDetailConfig(queryConfig, dynamicContext) {
|
|
102
95
|
const { detail } = queryConfig
|
|
103
96
|
if (!detail) return null
|
|
@@ -125,88 +118,36 @@ export function buildDetailConfig(queryConfig, dynamicContext) {
|
|
|
125
118
|
}
|
|
126
119
|
}
|
|
127
120
|
|
|
128
|
-
//
|
|
129
|
-
// the list: a `url` the author wrote stays a `url`, a compiled `path` stays a
|
|
130
|
-
// `path`.
|
|
131
|
-
const baseUrl = queryConfig.url || queryConfig.path
|
|
132
|
-
if (!baseUrl) return null
|
|
133
|
-
const addressKey = queryConfig.url ? 'url' : 'path'
|
|
134
|
-
|
|
135
|
-
// What every detail config carries beside its address:
|
|
121
|
+
// What every other record request carries beside its address:
|
|
136
122
|
// `as` — the binding key, so the record lands where the list did;
|
|
137
|
-
// `query` — the query it is one record of
|
|
138
|
-
// `
|
|
139
|
-
// `dynamicContext` — the route param, which the default fetcher
|
|
140
|
-
// a
|
|
141
|
-
// placeholders). ⛔ The entity store never passed it, so
|
|
142
|
-
// a live lane's bare record response was unwrapped with
|
|
143
|
-
// the LIST key and read as `[]` (found 2026-09-04).
|
|
123
|
+
// `query` — the query it is one record of;
|
|
124
|
+
// `whole: true` — what it asks for, and what the record index files it as;
|
|
125
|
+
// `dynamicContext` — the route param, which the default fetcher substitutes
|
|
126
|
+
// into a POST body;
|
|
144
127
|
// `locale` — carried from the list, so the two share a locale.
|
|
145
|
-
const common = {
|
|
146
|
-
as: queryConfig.as,
|
|
147
|
-
transform: queryConfig.transform,
|
|
148
|
-
whole: true,
|
|
149
|
-
dynamicContext: { paramName, paramValue },
|
|
150
|
-
}
|
|
128
|
+
const common = { as: queryConfig.as, whole: true, dynamicContext: { paramName, paramValue } }
|
|
151
129
|
if (typeof queryConfig.query === 'string') common.query = queryConfig.query
|
|
152
130
|
if (queryConfig.locale !== undefined) common.locale = queryConfig.locale
|
|
131
|
+
const context = paramContext(paramName, paramValue, record)
|
|
153
132
|
|
|
154
|
-
//
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
if (detail && typeof detail === 'object') {
|
|
133
|
+
// ⭐ AN EXTERNAL QUERY'S `record:`.
|
|
134
|
+
const spec = queryConfig.record
|
|
135
|
+
if (spec && typeof spec === 'object' && typeof queryConfig.url === 'string') {
|
|
158
136
|
const out = {
|
|
159
|
-
|
|
137
|
+
url: typeof spec.url === 'string' && spec.url ? substitutePlaceholders(spec.url, context) : queryConfig.url,
|
|
160
138
|
...common,
|
|
161
139
|
}
|
|
162
|
-
|
|
163
|
-
if (
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
out.body = substitutePlaceholders(queryConfig.body, paramContext(paramName, paramValue, record), { encode: false })
|
|
167
|
-
}
|
|
168
|
-
if (detail.envelope) out.envelope = detail.envelope
|
|
140
|
+
const method = typeof spec.method === 'string' && spec.method ? spec.method : queryConfig.method
|
|
141
|
+
if (method) out.method = method
|
|
142
|
+
if (spec.body !== undefined && spec.body !== null) out.body = substitutePlaceholders(spec.body, context, { encode: false })
|
|
143
|
+
if (typeof spec.transform === 'string' && spec.transform) out.transform = spec.transform
|
|
169
144
|
return out
|
|
170
145
|
}
|
|
171
146
|
|
|
172
|
-
//
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
// the detail request. That is deliberate and it is the safe default: the params
|
|
176
|
-
// that matter most to a single-record read are exactly the ones a list carries —
|
|
177
|
-
// `?lang=`, an API key, a tenancy id. Dropping them would 401 the detail request
|
|
178
|
-
// or return the wrong language, on every detail page.
|
|
179
|
-
//
|
|
180
|
-
// ⚠️ The cost is real and lands on ONE category: a PROJECTION param (`?fields=`,
|
|
181
|
-
// `?select=`) asks the API for a summary, and carrying it truncates the very
|
|
182
|
-
// record the detail fetch exists to get in full. The request still succeeds and
|
|
183
|
-
// only some fields are missing, so it reads as a component or API fault rather
|
|
184
|
-
// than a URL one.
|
|
185
|
-
//
|
|
186
|
-
// ⛔ Framework cannot tell the categories apart — they are the host's vocabulary,
|
|
187
|
-
// not ours. So the default keeps everything and the CUSTOM PATTERN form is the
|
|
188
|
-
// way out: it is used verbatim, so nothing carries over unless the author writes
|
|
189
|
-
// it. Documented for authors in `docs/reference/dynamic-routes.md` § *The list's
|
|
190
|
-
// query string carries over*.
|
|
191
|
-
let detailUrl
|
|
192
|
-
if (detail === 'rest') {
|
|
193
|
-
const [basePath, queryString] = baseUrl.split('?')
|
|
194
|
-
const cleanBase = basePath.replace(/\/$/, '')
|
|
195
|
-
detailUrl = queryString
|
|
196
|
-
? `${cleanBase}/${encodeURIComponent(paramValue)}?${queryString}`
|
|
197
|
-
: `${cleanBase}/${encodeURIComponent(paramValue)}`
|
|
198
|
-
} else if (detail === 'query') {
|
|
199
|
-
const sep = baseUrl.includes('?') ? '&' : '?'
|
|
200
|
-
detailUrl = `${baseUrl}${sep}${paramName}=${encodeURIComponent(paramValue)}`
|
|
201
|
-
} else {
|
|
202
|
-
// Custom pattern like '/articles/{slug}' — substitute placeholders
|
|
203
|
-
// from the dynamic-route context. Only placeholders matching the
|
|
204
|
-
// active paramName resolve; others pass through as literal `{name}`.
|
|
205
|
-
detailUrl = substitutePlaceholders(detail, paramContext(paramName, paramValue, record))
|
|
147
|
+
// ⭐ A `deferred:` QUERY'S PER-RECORD FILE — the pattern `applyDeferredDetail` injected.
|
|
148
|
+
if (typeof detail === 'string' && typeof queryConfig.path === 'string') {
|
|
149
|
+
return { path: substitutePlaceholders(detail, context), ...common }
|
|
206
150
|
}
|
|
207
151
|
|
|
208
|
-
return
|
|
209
|
-
[addressKey]: detailUrl,
|
|
210
|
-
...common,
|
|
211
|
-
}
|
|
152
|
+
return null
|
|
212
153
|
}
|
package/src/entity-store.js
CHANGED
|
@@ -14,9 +14,8 @@
|
|
|
14
14
|
* and in-flight dedup.
|
|
15
15
|
*/
|
|
16
16
|
|
|
17
|
-
import {
|
|
17
|
+
import { resolveFetchConfigs, pageRouteQuery, routeSelection, currentOf, othersView, othersOf, siteReaches } from './fetch-config.js'
|
|
18
18
|
import { fillRoutePattern, matchesRouteParam } from './route-match.js'
|
|
19
|
-
import { sortRecords } from './sort.js'
|
|
20
19
|
|
|
21
20
|
/**
|
|
22
21
|
* A fetch config's binding key — the `content.data.<key>` a component reads.
|
|
@@ -26,41 +25,12 @@ import { sortRecords } from './sort.js'
|
|
|
26
25
|
const bindingKeyOf = (cfg) => cfg?.as
|
|
27
26
|
import { buildDetailConfig } from './detail-url.js'
|
|
28
27
|
|
|
29
|
-
/**
|
|
30
|
-
* Is `block.fetch` a per-instance refinement of the ancestor's fetch config
|
|
31
|
-
* rather than a new source? The spelling is `refine: true`.
|
|
32
|
-
*
|
|
33
|
-
* The predicate itself lives in `./fetch-config.js` with the rest of the
|
|
34
|
-
* cascade rule; this alias keeps the local call sites reading as they did.
|
|
35
|
-
*/
|
|
36
|
-
const isRefinement = isFetchRefinement
|
|
37
|
-
|
|
38
|
-
/**
|
|
39
|
-
* `fetch: { inherit: true }` was the earlier spelling of `refine: true`, kept
|
|
40
|
-
* "for one release" from April 2026 and removed on 2026-09-02. It is refused
|
|
41
|
-
* rather than ignored: ignored, the declaration would read as a source with no
|
|
42
|
-
* location and the block would render empty with nothing to say why. Dev
|
|
43
|
-
* throws, so a page does not render on the old spelling; production logs once
|
|
44
|
-
* and the caller drops the declaration, so the block receives the cascaded
|
|
45
|
-
* data unrefined.
|
|
46
|
-
*/
|
|
47
|
-
let inheritRefusalLogged = false
|
|
48
|
-
function refuseInheritAlias(block, dev) {
|
|
49
|
-
const message =
|
|
50
|
-
"[uniweb] 'fetch: { inherit: true }' is no longer accepted; write 'fetch: { refine: true }'. " +
|
|
51
|
-
`Seen on block ${block?.id ?? '(unknown)'} of page ${block?.page?.route ?? '(unknown)'}.`
|
|
52
|
-
if (dev) throw new Error(message)
|
|
53
|
-
if (inheritRefusalLogged) return
|
|
54
|
-
inheritRefusalLogged = true
|
|
55
|
-
console.error(message)
|
|
56
|
-
}
|
|
57
|
-
|
|
58
28
|
export default class EntityStore {
|
|
59
29
|
/**
|
|
60
30
|
* @param {Object} options
|
|
61
31
|
* @param {import('./website.js').default} options.website
|
|
62
|
-
* @param {boolean} [options.dev=false] - dev mode: a
|
|
63
|
-
*
|
|
32
|
+
* @param {boolean} [options.dev=false] - dev mode: a failed fetch is logged
|
|
33
|
+
* where the author is looking.
|
|
64
34
|
*/
|
|
65
35
|
constructor({ website, dev = false }) {
|
|
66
36
|
this.website = website
|
|
@@ -68,34 +38,6 @@ export default class EntityStore {
|
|
|
68
38
|
Object.seal(this)
|
|
69
39
|
}
|
|
70
40
|
|
|
71
|
-
_shouldInheritDetail(meta, block) {
|
|
72
|
-
const bf = block?.fetch
|
|
73
|
-
if (isRefinement(bf) && bf?.detail !== undefined) return bf.detail !== false
|
|
74
|
-
if (!meta) return true
|
|
75
|
-
return meta.inheritDetail !== false
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
_inheritLimit(meta, block) {
|
|
79
|
-
const bf = block?.fetch
|
|
80
|
-
if (isRefinement(bf) && bf?.limit > 0) return bf.limit
|
|
81
|
-
return (meta?.inheritLimit > 0) ? meta.inheritLimit : null
|
|
82
|
-
}
|
|
83
|
-
|
|
84
|
-
_inheritOrder(block) {
|
|
85
|
-
const bf = block?.fetch
|
|
86
|
-
if (isRefinement(bf) && bf?.order?.orderBy) return bf.order
|
|
87
|
-
return null
|
|
88
|
-
}
|
|
89
|
-
|
|
90
|
-
/**
|
|
91
|
-
* A refine block's `order: { orderBy, sortOrder }` — the same one-key sort the
|
|
92
|
-
* build and the fetcher fallback run (`./sort.js`), so the three cannot drift.
|
|
93
|
-
*/
|
|
94
|
-
_sortItems(items, order) {
|
|
95
|
-
if (!order?.orderBy) return items
|
|
96
|
-
return sortRecords(items, { field: order.orderBy, desc: order.sortOrder === 'DESC' })
|
|
97
|
-
}
|
|
98
|
-
|
|
99
41
|
/**
|
|
100
42
|
* Which schemas does this component want delivered?
|
|
101
43
|
*
|
|
@@ -120,13 +62,8 @@ export default class EntityStore {
|
|
|
120
62
|
* re-inline it here — divergence between copies is what the extraction
|
|
121
63
|
* exists to prevent.
|
|
122
64
|
*/
|
|
123
|
-
_findFetchConfigs(block, requested) {
|
|
124
|
-
|
|
125
|
-
if (blockFetch?.inherit !== undefined) {
|
|
126
|
-
refuseInheritAlias(block, this.dev)
|
|
127
|
-
blockFetch = null
|
|
128
|
-
}
|
|
129
|
-
|
|
65
|
+
_findFetchConfigs(block, requested, route = this._route(block)) {
|
|
66
|
+
const blockFetch = block.fetch
|
|
130
67
|
const page = block.page
|
|
131
68
|
const website = block.website
|
|
132
69
|
const dynamicContext = block.dynamicContext || page?.dynamicContext
|
|
@@ -147,10 +84,16 @@ export default class EntityStore {
|
|
|
147
84
|
|
|
148
85
|
return resolveFetchConfigs(
|
|
149
86
|
[
|
|
150
|
-
blockFetch
|
|
87
|
+
blockFetch,
|
|
151
88
|
page?.fetch,
|
|
152
89
|
page?.parent?.fetch,
|
|
153
|
-
|
|
90
|
+
// ⭐ A page nested inside a parametric page receives its capturing page's
|
|
91
|
+
// route binding — the cascade reaches one parent up, and the binding may sit
|
|
92
|
+
// above that (`pageRouteQuery`). Its key only; the rest does not cascade.
|
|
93
|
+
route?.nested ? route.config : null,
|
|
94
|
+
// The site's binding reaches a layout section and a top-level page's
|
|
95
|
+
// sections, and nothing deeper (`siteReaches`).
|
|
96
|
+
siteReaches(page?.parent) ? website?.config?.fetch : null,
|
|
154
97
|
],
|
|
155
98
|
{
|
|
156
99
|
schemas: requested,
|
|
@@ -171,30 +114,33 @@ export default class EntityStore {
|
|
|
171
114
|
}
|
|
172
115
|
|
|
173
116
|
/**
|
|
174
|
-
* The
|
|
175
|
-
*
|
|
176
|
-
*
|
|
177
|
-
* and, when none of those declares one, the key
|
|
178
|
-
* what a section receives and what the URL narrows are read
|
|
179
|
-
* cannot disagree.
|
|
117
|
+
* The ROUTE QUERY of the block's page — the query its URL names one record of —
|
|
118
|
+
* worked out from the same sources `_findFetchConfigs` walks, at the page that
|
|
119
|
+
* captured the URL's variable (`pageRouteQuery`, `./fetch-config.js`): its fetch,
|
|
120
|
+
* its parent's, the site's, and, when none of those declares one, the key its
|
|
121
|
+
* sections share. So what a section receives and what the URL narrows are read
|
|
122
|
+
* off one walk and cannot disagree.
|
|
180
123
|
*
|
|
181
124
|
* ⛔ This was `dynamicContext.schema`, stored on the page by whoever built it —
|
|
182
125
|
* the SPA from `parentSchema`, the static build from the parent's first
|
|
183
126
|
* prerendered fetch — a stored copy of a derived answer, computed by a different
|
|
184
127
|
* rule from the one sections are fed by. Deleted 2026-09-11 [Diego].
|
|
185
128
|
*
|
|
186
|
-
* @returns {
|
|
129
|
+
* @returns {{ key: string, config: Object, nested: boolean }|null} null off a
|
|
130
|
+
* parametric page, or when it has no route query
|
|
187
131
|
*/
|
|
188
|
-
|
|
132
|
+
_route(block) {
|
|
189
133
|
const dynamicContext = block.dynamicContext || block.page?.dynamicContext
|
|
190
|
-
if (!dynamicContext) return null
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
134
|
+
if (!dynamicContext || !block.page) return null
|
|
135
|
+
return pageRouteQuery(block.page, {
|
|
136
|
+
// a concrete page carries its template's route; its ancestors are templates, or
|
|
137
|
+
// pages the static build expanded, which carry theirs the same way
|
|
138
|
+
routeOf: (p) => p?.dynamicContext?.templateRoute ?? p?.route,
|
|
139
|
+
parentOf: (p) => p?.parent ?? null,
|
|
140
|
+
fetchOf: (p) => p?.fetch,
|
|
141
|
+
sectionsOf: (p) => p?._bodySections,
|
|
195
142
|
site: block.website?.config?.fetch,
|
|
196
|
-
|
|
197
|
-
})?.key ?? null
|
|
143
|
+
})
|
|
198
144
|
}
|
|
199
145
|
|
|
200
146
|
/**
|
|
@@ -265,37 +211,28 @@ export default class EntityStore {
|
|
|
265
211
|
|
|
266
212
|
if (requested === null) return { status: 'none', data: null }
|
|
267
213
|
|
|
268
|
-
const
|
|
214
|
+
const route = this._route(block)
|
|
215
|
+
const configs = this._findFetchConfigs(block, requested, route)
|
|
269
216
|
if (configs.size === 0) return { status: 'none', data: null }
|
|
270
217
|
|
|
271
218
|
const dynamicContext = block.dynamicContext || block.page?.dynamicContext
|
|
272
|
-
const inheritDetail = this._shouldInheritDetail(meta, block)
|
|
273
|
-
const limit = this._inheritLimit(meta, block)
|
|
274
|
-
const order = this._inheritOrder(block)
|
|
275
219
|
const ctx = this._ctx(block)
|
|
276
220
|
|
|
277
221
|
const data = {}
|
|
278
222
|
let allCached = true
|
|
279
223
|
|
|
280
|
-
const routeSchema = this._routeKey(block)
|
|
281
|
-
|
|
282
224
|
for (const [schema, cfg] of configs) {
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
225
|
+
// `current:` applies under the key the page's URL narrows, and nowhere else.
|
|
226
|
+
const current = dynamicContext && route && schema === route.key ? currentOf(cfg) : null
|
|
227
|
+
if (current === 'exclude') {
|
|
228
|
+
// The route query's records without this page's, `limit` counting the others.
|
|
229
|
+
const cached = dispatcher?.peek(othersView(cfg), ctx)
|
|
287
230
|
if (cached) {
|
|
288
|
-
|
|
289
|
-
const items = cached.data
|
|
290
|
-
let filtered = Array.isArray(items)
|
|
291
|
-
? items.filter((item) => !matchesRouteParam(item, paramName, paramValue))
|
|
292
|
-
: items
|
|
293
|
-
if (order) filtered = this._sortItems(filtered, order)
|
|
294
|
-
data[schema] = limit && Array.isArray(filtered) ? filtered.slice(0, limit) : filtered
|
|
231
|
+
data[schema] = othersOf(cached.data, cfg, isPageRecord(dynamicContext))
|
|
295
232
|
} else {
|
|
296
233
|
allCached = false
|
|
297
234
|
}
|
|
298
|
-
} else if (
|
|
235
|
+
} else if (current === 'only' && cfg.ask) {
|
|
299
236
|
// The records service: the record's own answer is cached under its own key.
|
|
300
237
|
const detailCfg = this._buildDetailConfig(cfg, dynamicContext)
|
|
301
238
|
const detailCached = detailCfg ? dispatcher?.peek(detailCfg, ctx) : null
|
|
@@ -305,11 +242,12 @@ export default class EntityStore {
|
|
|
305
242
|
} else {
|
|
306
243
|
allCached = false
|
|
307
244
|
}
|
|
308
|
-
} else if (
|
|
245
|
+
} else if (current === 'only') {
|
|
309
246
|
// Detail page: deliver the focused record as a length-1 array under the
|
|
310
247
|
// query key. A deferred/remote query fetches the full per-record;
|
|
311
|
-
// others use the matched record. Not found → [].
|
|
312
|
-
|
|
248
|
+
// others use the matched record. Not found → []. Found in the route
|
|
249
|
+
// query's whole selection, never in a list its `limit` cut (`routeSelection`).
|
|
250
|
+
const cached = dispatcher?.peek(routeSelection(cfg), ctx)
|
|
313
251
|
if (cached) {
|
|
314
252
|
const { paramName, paramValue } = dynamicContext
|
|
315
253
|
const items = cached.data
|
|
@@ -341,10 +279,11 @@ export default class EntityStore {
|
|
|
341
279
|
allCached = false
|
|
342
280
|
}
|
|
343
281
|
} else {
|
|
282
|
+
// Any other key — and `current: include`, the route query's list as the
|
|
283
|
+
// binding describes it, this page's record among the rest.
|
|
344
284
|
const cached = dispatcher?.peek(cfg, ctx)
|
|
345
285
|
if (cached) {
|
|
346
|
-
|
|
347
|
-
data[schema] = order ? this._sortItems(items, order) : items
|
|
286
|
+
data[schema] = cached.data
|
|
348
287
|
} else {
|
|
349
288
|
allCached = false
|
|
350
289
|
}
|
|
@@ -388,13 +327,11 @@ export default class EntityStore {
|
|
|
388
327
|
}
|
|
389
328
|
if (requested === null) return { data: null, errors: null }
|
|
390
329
|
|
|
391
|
-
const
|
|
330
|
+
const route = this._route(block)
|
|
331
|
+
const configs = this._findFetchConfigs(block, requested, route)
|
|
392
332
|
if (configs.size === 0) return { data: null, errors: null }
|
|
393
333
|
|
|
394
334
|
const dynamicContext = block.dynamicContext || block.page?.dynamicContext
|
|
395
|
-
const inheritDetail = this._shouldInheritDetail(meta, block)
|
|
396
|
-
const limit = this._inheritLimit(meta, block)
|
|
397
|
-
const order = this._inheritOrder(block)
|
|
398
335
|
const ctx = this._ctx(block, { signal })
|
|
399
336
|
|
|
400
337
|
const data = {}
|
|
@@ -405,33 +342,27 @@ export default class EntityStore {
|
|
|
405
342
|
reportFetchFailure(this.dev, block, key, cfg, message)
|
|
406
343
|
}
|
|
407
344
|
|
|
408
|
-
const routeSchema = this._routeKey(block)
|
|
409
|
-
|
|
410
345
|
for (const [schema, cfg] of configs) {
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
346
|
+
// `current:` applies under the key the page's URL narrows, and nowhere else.
|
|
347
|
+
const current = dynamicContext && route && schema === route.key ? currentOf(cfg) : null
|
|
348
|
+
if (current === 'exclude') {
|
|
349
|
+
// The route query's records without this page's, `limit` counting the others:
|
|
350
|
+
// asked one longer (`othersView`), so removing the record still leaves enough.
|
|
351
|
+
const view = othersView(cfg)
|
|
352
|
+
parallelFetches.push(dispatcher.dispatch(view, ctx).then((result) => {
|
|
417
353
|
if (result?.error) {
|
|
418
|
-
fail(schema,
|
|
419
|
-
|
|
354
|
+
fail(schema, view, result.error)
|
|
355
|
+
return
|
|
420
356
|
}
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
: (records ?? [])
|
|
427
|
-
if (order) filtered = this._sortItems(filtered, order)
|
|
428
|
-
data[schema] = limit && Array.isArray(filtered) ? filtered.slice(0, limit) : filtered
|
|
429
|
-
} else if (isRouteQuery && cfg.ask) {
|
|
357
|
+
if (result?.data !== undefined && result?.data !== null) {
|
|
358
|
+
data[schema] = othersOf(result.data, cfg, isPageRecord(dynamicContext))
|
|
359
|
+
}
|
|
360
|
+
}))
|
|
361
|
+
} else if (current === 'only' && cfg.ask) {
|
|
430
362
|
// ⭐ THE RECORDS SERVICE needs no list to find the record: the record is the
|
|
431
363
|
// same question narrowed by the route's handle, so list and record are
|
|
432
364
|
// asked together — one round trip, and no client-side scan gating the
|
|
433
|
-
// fetch (F13, the live half). The list is asked too, because
|
|
434
|
-
// beside the record read it (`refine: true, detail: false`) and the
|
|
365
|
+
// fetch (F13, the live half). The list is asked too, because the record
|
|
435
366
|
// index files its briefs; the record's own answer is the answer.
|
|
436
367
|
const detailCfg = this._buildDetailConfig(cfg, dynamicContext)
|
|
437
368
|
parallelFetches.push(dispatcher.dispatch(cfg, ctx).then((result) => {
|
|
@@ -445,15 +376,18 @@ export default class EntityStore {
|
|
|
445
376
|
const answer = Array.isArray(result?.data) ? result.data : (result?.data ? [result.data] : [])
|
|
446
377
|
data[schema] = answer.slice(0, 1) // a route resolves to ONE; `[]` is not found
|
|
447
378
|
}))
|
|
448
|
-
} else if (
|
|
449
|
-
// Detail page: focused record as a length-1 array under the query key
|
|
379
|
+
} else if (current === 'only') {
|
|
380
|
+
// Detail page: focused record as a length-1 array under the query key,
|
|
381
|
+
// found in the route query's whole selection (`routeSelection`) — a record
|
|
382
|
+
// past a list's `limit` still has its page.
|
|
450
383
|
const { paramName, paramValue } = dynamicContext
|
|
384
|
+
const selection = routeSelection(cfg)
|
|
451
385
|
|
|
452
|
-
let records = peekArray(dispatcher,
|
|
386
|
+
let records = peekArray(dispatcher, selection, ctx)
|
|
453
387
|
if (records === null) {
|
|
454
|
-
const result = await dispatcher.dispatch(
|
|
388
|
+
const result = await dispatcher.dispatch(selection, ctx)
|
|
455
389
|
if (result?.error) {
|
|
456
|
-
fail(schema,
|
|
390
|
+
fail(schema, selection, result.error)
|
|
457
391
|
continue
|
|
458
392
|
}
|
|
459
393
|
records = Array.isArray(result?.data) ? result.data : null
|
|
@@ -500,6 +434,7 @@ export default class EntityStore {
|
|
|
500
434
|
data[schema] = [match]
|
|
501
435
|
}
|
|
502
436
|
} else {
|
|
437
|
+
// Any other key — and `current: include`, the list as the binding describes it.
|
|
503
438
|
parallelFetches.push(
|
|
504
439
|
dispatcher.dispatch(cfg, ctx).then((result) => {
|
|
505
440
|
if (result?.error) {
|
|
@@ -507,10 +442,7 @@ export default class EntityStore {
|
|
|
507
442
|
return
|
|
508
443
|
}
|
|
509
444
|
if (result?.data !== undefined && result?.data !== null) {
|
|
510
|
-
|
|
511
|
-
// block sorts identically on a cache hit and on the fetch that
|
|
512
|
-
// filled it — it did not until 2026-09-04.
|
|
513
|
-
data[schema] = order ? this._sortItems(result.data, order) : result.data
|
|
445
|
+
data[schema] = result.data
|
|
514
446
|
}
|
|
515
447
|
})
|
|
516
448
|
)
|
|
@@ -558,6 +490,11 @@ function heldWhole(dispatcher, match) {
|
|
|
558
490
|
return held?.whole === true ? held.record : null
|
|
559
491
|
}
|
|
560
492
|
|
|
493
|
+
/** Matches the record a parametric page is about — the one `current: only` delivers. */
|
|
494
|
+
function isPageRecord({ paramName, paramValue }) {
|
|
495
|
+
return (item) => matchesRouteParam(item, paramName, paramValue)
|
|
496
|
+
}
|
|
497
|
+
|
|
561
498
|
/**
|
|
562
499
|
* Sync-peek helper: return the cached array for a config, or null on miss.
|
|
563
500
|
*/
|