@uniweb/core 0.28.0 → 0.29.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +4 -3
- package/src/block.js +7 -0
- package/src/data-keys.js +155 -0
- package/src/data-paths.js +7 -7
- package/src/datastore.js +28 -13
- package/src/detail-url.js +23 -15
- package/src/entity-store.js +215 -136
- package/src/fetch-config.js +255 -81
- package/src/fetcher-dispatcher.js +9 -1
- package/src/index.js +5 -1
- package/src/query-evaluation.js +87 -0
- package/src/route-match.js +3 -2
- package/src/sort.js +2 -3
- package/src/website.js +70 -41
package/src/entity-store.js
CHANGED
|
@@ -14,15 +14,9 @@
|
|
|
14
14
|
* and in-flight dedup.
|
|
15
15
|
*/
|
|
16
16
|
|
|
17
|
-
import { resolveFetchConfigs, pageRouteQuery, routeSelection,
|
|
17
|
+
import { resolveFetchConfigs, fetchEntries, pageRouteQuery, routeSelection, currentFor, othersView, othersOf, siteReaches } from './fetch-config.js'
|
|
18
|
+
import { declaredKeys, fillDeclaredKeys } from './data-keys.js'
|
|
18
19
|
import { fillRoutePattern, matchesRouteParam } from './route-match.js'
|
|
19
|
-
|
|
20
|
-
/**
|
|
21
|
-
* A fetch config's binding key — the `content.data.<key>` a component reads.
|
|
22
|
-
* `as` is the name; `schema` is what it was called until 2026-09-02 and still
|
|
23
|
-
* arrives on any payload published before then. See `fetch-config.js`.
|
|
24
|
-
*/
|
|
25
|
-
const bindingKeyOf = (cfg) => cfg?.as
|
|
26
20
|
import { buildDetailConfig } from './detail-url.js'
|
|
27
21
|
|
|
28
22
|
export default class EntityStore {
|
|
@@ -39,34 +33,36 @@ export default class EntityStore {
|
|
|
39
33
|
}
|
|
40
34
|
|
|
41
35
|
/**
|
|
42
|
-
*
|
|
36
|
+
* The fetches that reach a block, one level each, most specific first: its own, its
|
|
37
|
+
* page's, its parent page's, a nested page's route binding, and the site's.
|
|
43
38
|
*
|
|
44
|
-
*
|
|
45
|
-
*
|
|
46
|
-
*
|
|
39
|
+
* ⭐ A page nested inside a parametric page receives its capturing page's route binding
|
|
40
|
+
* — the cascade reaches one parent up, and the binding may sit above that
|
|
41
|
+
* (`pageRouteQuery`). Its key only; the rest does not cascade. The site's binding
|
|
42
|
+
* reaches a layout section and a top-level page's sections, and nothing deeper
|
|
43
|
+
* (`siteReaches`).
|
|
47
44
|
*/
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
45
|
+
_levels(block, route = this._route(block)) {
|
|
46
|
+
const page = block.page
|
|
47
|
+
return [
|
|
48
|
+
block.fetch,
|
|
49
|
+
page?.fetch,
|
|
50
|
+
page?.parent?.fetch,
|
|
51
|
+
route?.nested ? route.config : null,
|
|
52
|
+
siteReaches(page?.parent) ? block.website?.config?.fetch : null,
|
|
53
|
+
]
|
|
52
54
|
}
|
|
53
55
|
|
|
54
56
|
/**
|
|
55
|
-
*
|
|
56
|
-
*
|
|
57
|
-
*
|
|
58
|
-
*
|
|
59
|
-
*
|
|
60
|
-
* normalization, deferred-detail injection) lives in `./fetch-config.js`,
|
|
61
|
-
* shared with every other host that has to answer the same question. Do not
|
|
62
|
-
* re-inline it here — divergence between copies is what the extraction
|
|
63
|
-
* exists to prevent.
|
|
57
|
+
* The options each fetch of a block is resolved with. The rule applied to a fetch
|
|
58
|
+
* (locale normalization, route variables, deferred-detail injection) lives in
|
|
59
|
+
* `./fetch-config.js`, shared with every other host that has to answer the same
|
|
60
|
+
* question. Do not re-inline it here — divergence between copies is what the
|
|
61
|
+
* extraction exists to prevent.
|
|
64
62
|
*/
|
|
65
|
-
|
|
66
|
-
const blockFetch = block.fetch
|
|
67
|
-
const page = block.page
|
|
63
|
+
_resolveOptions(block) {
|
|
68
64
|
const website = block.website
|
|
69
|
-
const dynamicContext = block.dynamicContext || page?.dynamicContext
|
|
65
|
+
const dynamicContext = block.dynamicContext || block.page?.dynamicContext
|
|
70
66
|
// The route's variables, for a query that binds `:path` / `:dir` / `:slug` —
|
|
71
67
|
// `routeBinding`'s, carried as `params` by every parametric page this runtime
|
|
72
68
|
// or its build makes. A page that carries only its capture gets the same three
|
|
@@ -81,36 +77,69 @@ export default class EntityStore {
|
|
|
81
77
|
}
|
|
82
78
|
: null))
|
|
83
79
|
: null
|
|
80
|
+
return {
|
|
81
|
+
locale: website?.getActiveLocale?.() ?? null,
|
|
82
|
+
defaultLocale: website?.getDefaultLocale?.() ?? null,
|
|
83
|
+
// ⚠️ `queries`, matching `resolveFetchConfigs`. This passed `collections`
|
|
84
|
+
// after the payload key was renamed — a dead option name, silently: the
|
|
85
|
+
// resolver simply saw no queries and stopped injecting `detail:`.
|
|
86
|
+
queries: website?.config?.queries ?? null,
|
|
87
|
+
// The host's services; its `records` row is the live-records lane.
|
|
88
|
+
// Absent on every static site and on local dev, which is why
|
|
89
|
+
// `resolveQuerySource` treats absence as the ordinary case and reads
|
|
90
|
+
// the compiled artifact without comment.
|
|
91
|
+
services: website?.config?.services ?? null,
|
|
92
|
+
variables,
|
|
93
|
+
}
|
|
94
|
+
}
|
|
84
95
|
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
)
|
|
96
|
+
/**
|
|
97
|
+
* ⭐ WHAT THIS SECTION RECEIVES, AND FROM WHICH FETCH — ruled 2026-09-14 [Diego]. The keys
|
|
98
|
+
* its component declares and its foundation's (`website.declaredKeys`), each paired with
|
|
99
|
+
* the fetch that fills it by the one rule (`fillDeclaredKeys`, automatic `as`) over the
|
|
100
|
+
* levels that reach the block. A declared key the block already holds — a tagged data
|
|
101
|
+
* block, or its own fetch's prerendered answer under that name — is not the store's to
|
|
102
|
+
* fill, and a fetch that fills no declared key is not asked. Each filling fetch is
|
|
103
|
+
* resolved on its own, so two fetches of one `as` can fill two keys.
|
|
104
|
+
*
|
|
105
|
+
* ⭐ `held` is the answer the block already holds for a filling fetch, under the fetch's
|
|
106
|
+
* own key: a static build prerenders a section's own fetch into its content by `as`, so
|
|
107
|
+
* a component that names the key differently still receives it without the browser
|
|
108
|
+
* asking again. Only for the first fetch of that key reaching the block — the one whose
|
|
109
|
+
* answer a build put there.
|
|
110
|
+
*
|
|
111
|
+
* ⛔ Until 2026-09-14 every fetch reaching the block was resolved and delivered under its
|
|
112
|
+
* `as` — the first per key — whether the component read it or not, and a component
|
|
113
|
+
* declaring its key under another name received nothing.
|
|
114
|
+
*
|
|
115
|
+
* @returns {Map<string, { cfg: Object, held: * }>} declared key → the resolved config of
|
|
116
|
+
* the fetch that fills it, and the answer the block holds for it (undefined when none)
|
|
117
|
+
*/
|
|
118
|
+
_fills(block, meta, route) {
|
|
119
|
+
const website = block.website
|
|
120
|
+
const declared = website?.declaredKeys ? website.declaredKeys(meta) : declaredKeys(meta?.data)
|
|
121
|
+
const out = new Map()
|
|
122
|
+
if (declared.length === 0) return out
|
|
123
|
+
const holds = block.heldData || {}
|
|
124
|
+
const levels = this._levels(block, route)
|
|
125
|
+
const fills = fillDeclaredKeys(declared, levels, {
|
|
126
|
+
queries: website?.config?.queries ?? null,
|
|
127
|
+
held: declared.map(([key]) => key).filter((key) => holds[key] !== undefined),
|
|
128
|
+
})
|
|
129
|
+
// where the first fetch of each key reaching the block sits — the one a held answer
|
|
130
|
+
// belongs to
|
|
131
|
+
const firstOfKey = new Map()
|
|
132
|
+
levels.forEach((level, at) => fetchEntries(level).forEach((fetch, index) => {
|
|
133
|
+
if (!firstOfKey.has(fetch.as)) firstOfKey.set(fetch.as, `${at}:${index}`)
|
|
134
|
+
}))
|
|
135
|
+
const options = this._resolveOptions(block)
|
|
136
|
+
for (const [key, { fetch, level, index }] of fills) {
|
|
137
|
+
const cfg = resolveFetchConfigs([fetch], options).get(fetch.as)
|
|
138
|
+
if (!cfg) continue
|
|
139
|
+
const held = firstOfKey.get(fetch.as) === `${level}:${index}` ? holds[fetch.as] : undefined
|
|
140
|
+
out.set(key, { cfg, held })
|
|
141
|
+
}
|
|
142
|
+
return out
|
|
114
143
|
}
|
|
115
144
|
|
|
116
145
|
/**
|
|
@@ -144,27 +173,66 @@ export default class EntityStore {
|
|
|
144
173
|
}
|
|
145
174
|
|
|
146
175
|
/**
|
|
147
|
-
*
|
|
148
|
-
*
|
|
149
|
-
*
|
|
150
|
-
*
|
|
151
|
-
*
|
|
152
|
-
*
|
|
153
|
-
*
|
|
154
|
-
*
|
|
176
|
+
* ⭐ EVERY RECORD LINKS TO ITS QUERY'S PAGE — `$route`, filled here at render time, on
|
|
177
|
+
* every lane (ruled 2026-09-14 [Diego]). For each delivered key, the page is the
|
|
178
|
+
* fetch's `detailPage` when it names one that resolves, else the page whose route
|
|
179
|
+
* query is the fetch's query (`website.recordPageFor`); each record gets that page's
|
|
180
|
+
* route filled from its own fields (`fillRoutePattern`) as `$route`, and a record that
|
|
181
|
+
* cannot fill it, or a key whose query has no page, gets none.
|
|
182
|
+
*
|
|
183
|
+
* ⭐ `$` marks a system field, as on `$uuid`, `$name` and `$tags` — so the link never
|
|
184
|
+
* lands in the author's namespace. A component writes `<Link href={item.$route}>`. ⛔ Until 2026-09-14 the link was `route`: the build baked
|
|
185
|
+
* it into compiled records from `route:` on a query, overwriting an entity's own
|
|
186
|
+
* `route`, and this filled it only from a `detailPage`, skipping any record that
|
|
187
|
+
* already had one.
|
|
188
|
+
*
|
|
189
|
+
* Runs after `data` is assembled, in the sync (peek) and async (fetch) paths alike.
|
|
190
|
+
* Records are copied, never mutated: one cached record backs every section that
|
|
191
|
+
* shows it, each of which may link it to a different page.
|
|
155
192
|
*/
|
|
156
|
-
|
|
157
|
-
if (!data || !website
|
|
158
|
-
for (const [
|
|
159
|
-
|
|
160
|
-
const items = data[schema]
|
|
193
|
+
_applyRecordRoutes(data, configs, website) {
|
|
194
|
+
if (!data || !website) return
|
|
195
|
+
for (const [key, cfg] of configs) {
|
|
196
|
+
const items = data[key]
|
|
161
197
|
if (!Array.isArray(items) || items.length === 0) continue
|
|
162
|
-
const template =
|
|
198
|
+
const template = recordRouteTemplate(cfg, website)
|
|
163
199
|
if (!template) continue
|
|
164
|
-
|
|
200
|
+
const linked = items.map((item) => withRecordRoute(item, template))
|
|
201
|
+
// an unchanged list keeps its identity, so a render that re-links changes nothing
|
|
202
|
+
if (linked.some((item, i) => item !== items[i])) data[key] = linked
|
|
165
203
|
}
|
|
166
204
|
}
|
|
167
205
|
|
|
206
|
+
/**
|
|
207
|
+
* `$route` on the records a section holds under its own fetch's key — what a static
|
|
208
|
+
* build prerendered into its content (`block.heldData`). ⭐ A declared key the block holds
|
|
209
|
+
* is filled from what it holds, not from this store (`runtime/src/prepare-props.js::
|
|
210
|
+
* assembleData`), so those records never passed `_applyRecordRoutes`; without this, a list
|
|
211
|
+
* a section fetches for itself reached its component with no links on a prerendered
|
|
212
|
+
* site. Linked by the same rule, on every render and idempotently: data that is already
|
|
213
|
+
* linked is left as it is, so its identity survives a re-render. (A held answer this store
|
|
214
|
+
* delivers under another key is linked where it is delivered.)
|
|
215
|
+
*
|
|
216
|
+
* The block's data object is replaced, never written into, since the object the build
|
|
217
|
+
* delivered may back more than this block.
|
|
218
|
+
*
|
|
219
|
+
* @param {Object} block
|
|
220
|
+
*/
|
|
221
|
+
linkOwnRecords(block) {
|
|
222
|
+
const held = block?.parsedContent?.data
|
|
223
|
+
if (!held || typeof held !== 'object' || !block.fetch) return
|
|
224
|
+
const website = block.website ?? this.website
|
|
225
|
+
const configs = resolveFetchConfigs([block.fetch], {
|
|
226
|
+
queries: website?.config?.queries ?? null,
|
|
227
|
+
services: website?.config?.services ?? null,
|
|
228
|
+
locale: website?.getActiveLocale?.() ?? null,
|
|
229
|
+
defaultLocale: website?.getDefaultLocale?.() ?? null,
|
|
230
|
+
})
|
|
231
|
+
const linked = { ...held }
|
|
232
|
+
this._applyRecordRoutes(linked, configs, website)
|
|
233
|
+
if (Object.keys(linked).some((key) => linked[key] !== held[key])) block.parsedContent.data = linked
|
|
234
|
+
}
|
|
235
|
+
|
|
168
236
|
/**
|
|
169
237
|
* Build a detail-URL fetch config from a query config + dynamic context.
|
|
170
238
|
*
|
|
@@ -198,22 +266,10 @@ export default class EntityStore {
|
|
|
198
266
|
*/
|
|
199
267
|
resolve(block, meta) {
|
|
200
268
|
const dispatcher = this.website?.fetcher
|
|
201
|
-
let requested = this._getRequestedSchemas(meta)
|
|
202
|
-
|
|
203
|
-
// If the component hasn't declared data inheritance but the block itself
|
|
204
|
-
// has a fetch config, target the block's schema explicitly rather than
|
|
205
|
-
// collecting all cascade matches.
|
|
206
|
-
if (requested === null && block.fetch) {
|
|
207
|
-
const blockFetchList = Array.isArray(block.fetch) ? block.fetch : [block.fetch]
|
|
208
|
-
const schemas = blockFetchList.filter(bindingKeyOf).map(bindingKeyOf)
|
|
209
|
-
if (schemas.length > 0) requested = schemas
|
|
210
|
-
}
|
|
211
|
-
|
|
212
|
-
if (requested === null) return { status: 'none', data: null }
|
|
213
|
-
|
|
214
269
|
const route = this._route(block)
|
|
215
|
-
const
|
|
216
|
-
if (
|
|
270
|
+
const fills = this._fills(block, meta, route)
|
|
271
|
+
if (fills.size === 0) return { status: 'none', data: null }
|
|
272
|
+
const configs = new Map([...fills].map(([key, { cfg }]) => [key, cfg]))
|
|
217
273
|
|
|
218
274
|
const dynamicContext = block.dynamicContext || block.page?.dynamicContext
|
|
219
275
|
const ctx = this._ctx(block)
|
|
@@ -221,11 +277,16 @@ export default class EntityStore {
|
|
|
221
277
|
const data = {}
|
|
222
278
|
let allCached = true
|
|
223
279
|
|
|
224
|
-
for (const [schema, cfg] of
|
|
225
|
-
|
|
226
|
-
|
|
280
|
+
for (const [schema, { cfg, held }] of fills) {
|
|
281
|
+
if (held !== undefined) {
|
|
282
|
+
// The block holds this fetch's answer already — a static build prerendered it.
|
|
283
|
+
data[schema] = held
|
|
284
|
+
continue
|
|
285
|
+
}
|
|
286
|
+
// How this fetch uses the page's record: by the query it names (`currentFor`).
|
|
287
|
+
const current = dynamicContext ? currentFor(cfg, route) : null
|
|
227
288
|
if (current === 'exclude') {
|
|
228
|
-
// The
|
|
289
|
+
// The query's records without this page's, `limit` counting the others.
|
|
229
290
|
const cached = dispatcher?.peek(othersView(cfg), ctx)
|
|
230
291
|
if (cached) {
|
|
231
292
|
data[schema] = othersOf(cached.data, cfg, isPageRecord(dynamicContext))
|
|
@@ -245,8 +306,9 @@ export default class EntityStore {
|
|
|
245
306
|
} else if (current === 'only') {
|
|
246
307
|
// Detail page: deliver the focused record as a length-1 array under the
|
|
247
308
|
// query key. A deferred/remote query fetches the full per-record;
|
|
248
|
-
// others use the matched record. Not found → []. Found in the
|
|
249
|
-
// query
|
|
309
|
+
// others use the matched record. Not found → []. Found in the set of the
|
|
310
|
+
// query the fetch names — the route query's, or another's under `current: only`
|
|
311
|
+
// — never in a list a fetch narrowed (`routeSelection`).
|
|
250
312
|
const cached = dispatcher?.peek(routeSelection(cfg), ctx)
|
|
251
313
|
if (cached) {
|
|
252
314
|
const { paramName, paramValue } = dynamicContext
|
|
@@ -279,8 +341,8 @@ export default class EntityStore {
|
|
|
279
341
|
allCached = false
|
|
280
342
|
}
|
|
281
343
|
} else {
|
|
282
|
-
//
|
|
283
|
-
//
|
|
344
|
+
// A fetch the page's record plays no part in (`currentFor`), and `current:
|
|
345
|
+
// include`: the records as the fetch describes them, the page's among the rest.
|
|
284
346
|
const cached = dispatcher?.peek(cfg, ctx)
|
|
285
347
|
if (cached) {
|
|
286
348
|
data[schema] = cached.data
|
|
@@ -291,7 +353,7 @@ export default class EntityStore {
|
|
|
291
353
|
}
|
|
292
354
|
|
|
293
355
|
if (allCached) {
|
|
294
|
-
this.
|
|
356
|
+
this._applyRecordRoutes(data, configs, block.website)
|
|
295
357
|
return { status: 'ready', data }
|
|
296
358
|
}
|
|
297
359
|
return { status: 'pending', data: null }
|
|
@@ -319,17 +381,10 @@ export default class EntityStore {
|
|
|
319
381
|
const dispatcher = this.website?.fetcher
|
|
320
382
|
if (!dispatcher) return { data: null, errors: null }
|
|
321
383
|
|
|
322
|
-
let requested = this._getRequestedSchemas(meta)
|
|
323
|
-
if (requested === null && block.fetch) {
|
|
324
|
-
const blockFetchList = Array.isArray(block.fetch) ? block.fetch : [block.fetch]
|
|
325
|
-
const schemas = blockFetchList.filter(bindingKeyOf).map(bindingKeyOf)
|
|
326
|
-
if (schemas.length > 0) requested = schemas
|
|
327
|
-
}
|
|
328
|
-
if (requested === null) return { data: null, errors: null }
|
|
329
|
-
|
|
330
384
|
const route = this._route(block)
|
|
331
|
-
const
|
|
332
|
-
if (
|
|
385
|
+
const fills = this._fills(block, meta, route)
|
|
386
|
+
if (fills.size === 0) return { data: null, errors: null }
|
|
387
|
+
const configs = new Map([...fills].map(([key, { cfg }]) => [key, cfg]))
|
|
333
388
|
|
|
334
389
|
const dynamicContext = block.dynamicContext || block.page?.dynamicContext
|
|
335
390
|
const ctx = this._ctx(block, { signal })
|
|
@@ -342,12 +397,18 @@ export default class EntityStore {
|
|
|
342
397
|
reportFetchFailure(this.dev, block, key, cfg, message)
|
|
343
398
|
}
|
|
344
399
|
|
|
345
|
-
for (const [schema, cfg] of
|
|
346
|
-
|
|
347
|
-
|
|
400
|
+
for (const [schema, { cfg, held }] of fills) {
|
|
401
|
+
if (held !== undefined) {
|
|
402
|
+
// The block holds this fetch's answer already — a static build prerendered it.
|
|
403
|
+
data[schema] = held
|
|
404
|
+
continue
|
|
405
|
+
}
|
|
406
|
+
// How this fetch uses the page's record: by the query it names (`currentFor`).
|
|
407
|
+
const current = dynamicContext ? currentFor(cfg, route) : null
|
|
348
408
|
if (current === 'exclude') {
|
|
349
|
-
// The
|
|
350
|
-
// asked one
|
|
409
|
+
// The query's records without this page's, the fetch's `limit` counting
|
|
410
|
+
// the others: its `narrow.limit` asked one higher (`othersView`), so removing
|
|
411
|
+
// the record still leaves enough.
|
|
351
412
|
const view = othersView(cfg)
|
|
352
413
|
parallelFetches.push(dispatcher.dispatch(view, ctx).then((result) => {
|
|
353
414
|
if (result?.error) {
|
|
@@ -360,14 +421,15 @@ export default class EntityStore {
|
|
|
360
421
|
}))
|
|
361
422
|
} else if (current === 'only' && cfg.ask) {
|
|
362
423
|
// ⭐ THE RECORDS SERVICE needs no list to find the record: the record is the
|
|
363
|
-
//
|
|
364
|
-
//
|
|
365
|
-
// fetch (F13, the live half).
|
|
366
|
-
//
|
|
424
|
+
// query's set narrowed by the route's handle, so its one question
|
|
425
|
+
// answers the record if the set holds it and `[]` if not — no client-side
|
|
426
|
+
// scan gating the fetch (F13, the live half).
|
|
427
|
+
//
|
|
428
|
+
// ⛔ The list was asked beside it until 2026-09-14, for the record index to
|
|
429
|
+
// file its briefs, while the record question dropped the query's `sort` and
|
|
430
|
+
// `limit` and so could not say whether the set held the record. It checks the
|
|
431
|
+
// set now, and the list sent the whole set to render one record of it.
|
|
367
432
|
const detailCfg = this._buildDetailConfig(cfg, dynamicContext)
|
|
368
|
-
parallelFetches.push(dispatcher.dispatch(cfg, ctx).then((result) => {
|
|
369
|
-
if (result?.error) fail(schema, cfg, result.error)
|
|
370
|
-
}))
|
|
371
433
|
parallelFetches.push(dispatcher.dispatch(detailCfg, ctx).then((result) => {
|
|
372
434
|
if (result?.error) {
|
|
373
435
|
fail(schema, detailCfg, result.error)
|
|
@@ -377,9 +439,9 @@ export default class EntityStore {
|
|
|
377
439
|
data[schema] = answer.slice(0, 1) // a route resolves to ONE; `[]` is not found
|
|
378
440
|
}))
|
|
379
441
|
} else if (current === 'only') {
|
|
380
|
-
// Detail page: focused record as a length-1 array under the
|
|
381
|
-
// found in
|
|
382
|
-
//
|
|
442
|
+
// Detail page: focused record as a length-1 array under the fetch's key,
|
|
443
|
+
// found in its query's set (`routeSelection`) — a record past a
|
|
444
|
+
// fetch's `limit` still has its page, and one past the query's has none.
|
|
383
445
|
const { paramName, paramValue } = dynamicContext
|
|
384
446
|
const selection = routeSelection(cfg)
|
|
385
447
|
|
|
@@ -450,7 +512,7 @@ export default class EntityStore {
|
|
|
450
512
|
}
|
|
451
513
|
|
|
452
514
|
if (parallelFetches.length > 0) await Promise.all(parallelFetches)
|
|
453
|
-
this.
|
|
515
|
+
this._applyRecordRoutes(data, configs, block.website)
|
|
454
516
|
return { data, errors: Object.keys(errors).length ? errors : null }
|
|
455
517
|
}
|
|
456
518
|
}
|
|
@@ -505,20 +567,37 @@ function peekArray(dispatcher, cfg, ctx) {
|
|
|
505
567
|
}
|
|
506
568
|
|
|
507
569
|
/**
|
|
508
|
-
*
|
|
509
|
-
*
|
|
510
|
-
*
|
|
511
|
-
*
|
|
512
|
-
*
|
|
513
|
-
*
|
|
514
|
-
*
|
|
515
|
-
*
|
|
570
|
+
* The route template a fetch's records link to — its `detailPage`, else its query's page.
|
|
571
|
+
*
|
|
572
|
+
* ⭐ A `detailPage` that no longer resolves — its page deleted, or no longer parametric —
|
|
573
|
+
* falls back to the query's own page rather than taking every card's link away: the
|
|
574
|
+
* author picked among the query's pages, and the query still has one. (The website warns
|
|
575
|
+
* in dev when a ref dangles.)
|
|
576
|
+
*
|
|
577
|
+
* @param {Object} cfg - a resolved config
|
|
578
|
+
* @param {Object} website
|
|
579
|
+
* @returns {string|null}
|
|
580
|
+
*/
|
|
581
|
+
function recordRouteTemplate(cfg, website) {
|
|
582
|
+
if (cfg?.detailPage) {
|
|
583
|
+
const picked = website.resolveDetailPageTemplate?.(cfg.detailPage)
|
|
584
|
+
if (picked) return picked
|
|
585
|
+
}
|
|
586
|
+
return typeof cfg?.query === 'string' ? (website.recordPageFor?.(cfg.query)?.route ?? null) : null
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
/**
|
|
590
|
+
* A record with its `$route` — the template filled from the record's own fields
|
|
591
|
+
* (`/blog/:slug` + `{ $name: 'a-post' }` → `/blog/a-post`). A SHALLOW COPY, never the
|
|
592
|
+
* cached record. A record that cannot fill the template — a `:param` with no value on it
|
|
593
|
+
* — is returned as it is, with no `$route`, so a component renders no link rather than a
|
|
594
|
+
* broken one.
|
|
516
595
|
*
|
|
517
|
-
* ⭐ The encoding is `fillRoutePattern`'s
|
|
518
|
-
*
|
|
596
|
+
* ⭐ The encoding is `fillRoutePattern`'s: the one encoder for a record's href, which the
|
|
597
|
+
* route matcher decodes (F14, 2026-09-04).
|
|
519
598
|
*/
|
|
520
|
-
function
|
|
521
|
-
if (!item || typeof item !== 'object'
|
|
599
|
+
function withRecordRoute(item, template) {
|
|
600
|
+
if (!item || typeof item !== 'object') return item
|
|
522
601
|
const route = fillRoutePattern(template, item)
|
|
523
|
-
return route === null ? item : { ...item, route }
|
|
602
|
+
return route === null || item.$route === route ? item : { ...item, $route: route }
|
|
524
603
|
}
|