@uniweb/core 0.19.0 → 0.21.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 +2 -1
- package/src/block.js +12 -2
- package/src/datastore.js +146 -19
- package/src/detail-url.js +79 -21
- package/src/entity-store.js +149 -35
- package/src/fetch-config.js +175 -27
- package/src/fetcher-dispatcher.js +12 -0
- package/src/index.js +11 -12
- package/src/page.js +6 -0
- package/src/query-address.js +51 -80
- package/src/route-match.js +196 -18
- package/src/sort.js +116 -0
- package/src/website.js +97 -15
- package/src/request-styles/index.js +0 -59
- package/src/request-styles/json-body.js +0 -117
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@uniweb/core",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.21.0",
|
|
4
4
|
"description": "Core classes for the Uniweb platform - Uniweb, Website, Page, Block",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"exports": {
|
|
@@ -17,6 +17,7 @@
|
|
|
17
17
|
"./route-match": "./src/route-match.js",
|
|
18
18
|
"./section-id": "./src/section-id.js",
|
|
19
19
|
"./services": "./src/services.js",
|
|
20
|
+
"./sort": "./src/sort.js",
|
|
20
21
|
"./tracker": "./src/tracker.js"
|
|
21
22
|
},
|
|
22
23
|
"files": [
|
package/src/block.js
CHANGED
|
@@ -223,6 +223,12 @@ export default class Block {
|
|
|
223
223
|
// Components check this to show loading UI (spinners, skeletons)
|
|
224
224
|
this.dataLoading = false
|
|
225
225
|
|
|
226
|
+
// Data failure state — set by BlockRenderer when a runtime fetch FAILED:
|
|
227
|
+
// `{ <binding key>: <message> }`, or null. A failed key is absent from
|
|
228
|
+
// `content.data` (never `[]`, which is a delivered value), so this is the
|
|
229
|
+
// only way a component can tell "no records" from "the request failed".
|
|
230
|
+
this.dataError = null
|
|
231
|
+
|
|
226
232
|
// Whether engine-level background is active (set by BlockRenderer/prerender)
|
|
227
233
|
// Components check this to skip their own opaque background
|
|
228
234
|
this.hasBackground = false
|
|
@@ -231,8 +237,12 @@ export default class Block {
|
|
|
231
237
|
this.refId = blockData.refId || null
|
|
232
238
|
|
|
233
239
|
// Dynamic route context (params from URL matching)
|
|
234
|
-
// Set when accessing a dynamic page like /blog/:slug -> /blog/my-post
|
|
235
|
-
|
|
240
|
+
// Set when accessing a dynamic page like /blog/:slug -> /blog/my-post.
|
|
241
|
+
// ⭐ The PAGE's context is the fallback: the SPA stamps it on every section
|
|
242
|
+
// as it creates the page, the static build stamps it on the page only, and a
|
|
243
|
+
// section reading `block.dynamicContext` must see the same thing on both
|
|
244
|
+
// lanes — it did not until 2026-09-04 (empty on every prerendered page).
|
|
245
|
+
this.dynamicContext = blockData.dynamicContext || this.page?.dynamicContext || null
|
|
236
246
|
|
|
237
247
|
// State management (dynamic, can change at runtime)
|
|
238
248
|
this.startState = null
|
package/src/datastore.js
CHANGED
|
@@ -19,17 +19,26 @@
|
|
|
19
19
|
* slug read from `page.state`) must declare their own `cacheKey(request)`
|
|
20
20
|
* on the fetcher so reactive changes miss the cache and re-fetch.
|
|
21
21
|
*
|
|
22
|
-
*
|
|
23
|
-
* - path, url — what resource is being fetched
|
|
24
|
-
* - schema — which entity type the response will be stored under
|
|
25
|
-
* - transform — any per-fetch response unwrap; different transforms
|
|
26
|
-
* of the same endpoint produce different cached data
|
|
27
|
-
* - method (POST) — POST requests may share a URL with GET; don't collide
|
|
28
|
-
* - body (POST) — two POSTs to the same URL with different bodies are
|
|
29
|
-
* different queries; must cache distinctly
|
|
22
|
+
* ⭐ TWO IDENTITIES, decided by whether the request carries an ADDRESS.
|
|
30
23
|
*
|
|
31
|
-
*
|
|
32
|
-
*
|
|
24
|
+
* An ADDRESSED request — `path` or `url` — is identified by where it
|
|
25
|
+
* goes: the address, the binding key, the unwrap, and for a POST its method and
|
|
26
|
+
* body. Post-processing fields (`limit`, `sort`, `where`) are applied after the
|
|
27
|
+
* fetch over one shared copy and must not split the cache; `query` and `depth`
|
|
28
|
+
* are not hashed either, because the address already carries them (a query's
|
|
29
|
+
* list and its per-record file are different addresses) and because a kit hook
|
|
30
|
+
* asking for `{ path, as }` must hit the entry the page's declaration filled —
|
|
31
|
+
* that shared cache is a documented property of `useFetched`.
|
|
32
|
+
*
|
|
33
|
+
* An ADDRESS-LESS request — a QUESTION sent to a records door — is identified by
|
|
34
|
+
* the question: `query`, `schema`, `scope`, `where`, `sort`, `limit`, `depth`.
|
|
35
|
+
* ⛔ The reason: with no per-query address, two pages
|
|
36
|
+
* binding one `as` to two queries would otherwise share an entry, and a list
|
|
37
|
+
* (brief) and a record (full) of one query would collide — the one defect on
|
|
38
|
+
* this path that delivers WRONG data rather than none.
|
|
39
|
+
*
|
|
40
|
+
* `locale` is hashed on both when present: two locales' answers must not share
|
|
41
|
+
* an entry, and a door config always carries the locale it was asked in.
|
|
33
42
|
*
|
|
34
43
|
* @param {Object} request - Normalized request (or fetch config)
|
|
35
44
|
* @returns {string} A stable JSON string usable as a cache-Map key
|
|
@@ -38,22 +47,68 @@ export function deriveCacheKey(request) {
|
|
|
38
47
|
// ⭐ `as` is the binding key — the name it has had since 2026-09-02, when the
|
|
39
48
|
// compatibility alias for the older `schema` spelling was removed alongside
|
|
40
49
|
// frontend's and hosting's.
|
|
41
|
-
const { path, url,
|
|
50
|
+
const { path, url, transform, locale } = request || {}
|
|
42
51
|
const as = request?.as
|
|
43
52
|
const method = request?.method && request.method.toUpperCase() !== 'GET'
|
|
44
53
|
? request.method.toUpperCase()
|
|
45
54
|
: undefined
|
|
46
55
|
const body = method === 'POST' ? request?.body : undefined
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
56
|
+
if (path || url) {
|
|
57
|
+
// ⚠️ The field NAME is part of the hash, so renaming it moves every key ONCE.
|
|
58
|
+
// In-memory stores repopulate; a consumer with a persistent cache takes one
|
|
59
|
+
// cold pass. Chosen over hashing under the old name, which would have hidden
|
|
60
|
+
// the rename inside the one function whose job is to be canonical.
|
|
61
|
+
return JSON.stringify({ path, url, as, transform, method, body, locale })
|
|
62
|
+
}
|
|
63
|
+
const { query, schema, scope, where, sort, limit, depth } = request || {}
|
|
64
|
+
return JSON.stringify({ query, schema, scope, where, sort, limit, depth, as, transform, locale })
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* A record's identity — the backend-minted `$uuid`, present on every record a
|
|
69
|
+
* live lane serves and on a file-lane record whose source was synced once. A
|
|
70
|
+
* record with none is held inline in its result and never indexed.
|
|
71
|
+
*
|
|
72
|
+
* @param {*} record
|
|
73
|
+
* @returns {string|null}
|
|
74
|
+
*/
|
|
75
|
+
export function recordIdentity(record) {
|
|
76
|
+
const id = record && typeof record === 'object' ? record.$uuid : null
|
|
77
|
+
return typeof id === 'string' && id.length > 0 ? id : null
|
|
52
78
|
}
|
|
53
79
|
|
|
80
|
+
/** Which of two depths holds MORE of a record. */
|
|
81
|
+
const DEPTH_RANK = { brief: 1, full: 2 }
|
|
82
|
+
|
|
83
|
+
function indexableDepth(entry) {
|
|
84
|
+
const depth = entry?.meta?.depth
|
|
85
|
+
return depth === 'brief' || depth === 'full' ? depth : null
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* ⭐ THE RECORD INDEX — records are held ONCE, by identity, with the depth they
|
|
90
|
+
* were fetched at; a query's result holds their ids.
|
|
91
|
+
*
|
|
92
|
+
* [Diego, 2026-09-04]: "they need to be able to hydrate records on their own…
|
|
93
|
+
* track if they already have the briefs, and don't confuse that with knowing the
|
|
94
|
+
* full records." Before this, a list and a detail fetch of one query landed in
|
|
95
|
+
* separate slots only because their ADDRESSES differed, and a detail page whose
|
|
96
|
+
* record was already held in full still fetched it again, or — with the record's
|
|
97
|
+
* list already cached — delivered the brief as if it were the record.
|
|
98
|
+
*
|
|
99
|
+
* Three rules:
|
|
100
|
+
* R1 an entry whose `meta.depth` is `brief` or `full` and whose records all
|
|
101
|
+
* carry `$uuid` is filed by id; `get()` materializes it from the index, so
|
|
102
|
+
* every list holding a record sees the record's latest depth;
|
|
103
|
+
* R2 depth is MONOTONIC — a brief never overwrites a record held in full;
|
|
104
|
+
* R3 an upgrade MERGES the full record over the brief rather than replacing
|
|
105
|
+
* it, so nothing depends on the full being a superset of the brief.
|
|
106
|
+
* An entry with no depth, or with a record lacking identity, is held inline
|
|
107
|
+
* exactly as before — the file lane with no synced records changes nothing.
|
|
108
|
+
*/
|
|
54
109
|
export default class DataStore {
|
|
55
110
|
constructor() {
|
|
56
|
-
// key → { data, meta? }
|
|
111
|
+
// key → { data, meta? } or, when indexed, { ids, single?, meta, _at, _data }
|
|
57
112
|
this._cache = new Map()
|
|
58
113
|
// key → { promise, signals: Set<AbortSignal> }
|
|
59
114
|
this._inflight = new Map()
|
|
@@ -61,6 +116,10 @@ export default class DataStore {
|
|
|
61
116
|
this._listeners = new Set()
|
|
62
117
|
// Key-scoped listeners: key → Set<Function>
|
|
63
118
|
this._keyedListeners = new Map()
|
|
119
|
+
// $uuid → { depth, record } — the record index
|
|
120
|
+
this._records = new Map()
|
|
121
|
+
// bumps on every index write, so a materialized list knows it is stale
|
|
122
|
+
this._recordsVersion = 0
|
|
64
123
|
|
|
65
124
|
Object.seal(this)
|
|
66
125
|
}
|
|
@@ -120,18 +179,31 @@ export default class DataStore {
|
|
|
120
179
|
* @returns {{ data: any, meta?: Object } | null}
|
|
121
180
|
*/
|
|
122
181
|
get(key) {
|
|
123
|
-
|
|
182
|
+
const entry = this._cache.has(key) ? this._cache.get(key) : null
|
|
183
|
+
if (!entry || !entry.ids) return entry
|
|
184
|
+
// Indexed: materialize from the record index, once per index version.
|
|
185
|
+
if (entry._at === this._recordsVersion && entry._data) return entry._data
|
|
186
|
+
const records = entry.ids.map((id) => this._records.get(id)?.record).filter(Boolean)
|
|
187
|
+
const data = entry.single ? (records[0] ?? null) : records
|
|
188
|
+
const out = entry.meta !== undefined ? { data, meta: entry.meta } : { data }
|
|
189
|
+
entry._at = this._recordsVersion
|
|
190
|
+
entry._data = out
|
|
191
|
+
return out
|
|
124
192
|
}
|
|
125
193
|
|
|
126
194
|
/**
|
|
127
195
|
* Cache store. Fires listeners: first the global ones (all-writes), then
|
|
128
196
|
* any subscribers registered for this specific key.
|
|
129
197
|
*
|
|
198
|
+
* An entry carrying `meta.depth` whose records all carry `$uuid` is filed in
|
|
199
|
+
* the record index and stored as ids — see the class note. Anything else is
|
|
200
|
+
* stored as given.
|
|
201
|
+
*
|
|
130
202
|
* @param {string} key
|
|
131
203
|
* @param {{ data: any, meta?: Object }} entry
|
|
132
204
|
*/
|
|
133
205
|
set(key, entry) {
|
|
134
|
-
this._cache.set(key, entry)
|
|
206
|
+
this._cache.set(key, this._index(entry))
|
|
135
207
|
for (const fn of this._listeners) fn()
|
|
136
208
|
const keyed = this._keyedListeners.get(key)
|
|
137
209
|
if (keyed) {
|
|
@@ -139,6 +211,59 @@ export default class DataStore {
|
|
|
139
211
|
}
|
|
140
212
|
}
|
|
141
213
|
|
|
214
|
+
/**
|
|
215
|
+
* The record held under an identity, with the depth it was fetched at — the
|
|
216
|
+
* question a detail page asks before fetching: "do I hold this in full?"
|
|
217
|
+
*
|
|
218
|
+
* @param {string} id - a `$uuid`
|
|
219
|
+
* @returns {{ depth: 'brief'|'full', record: Object } | null}
|
|
220
|
+
*/
|
|
221
|
+
getRecord(id) {
|
|
222
|
+
return (id && this._records.get(id)) || null
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* File one record at a depth, honouring R2 and R3. Returns the record now held.
|
|
227
|
+
*
|
|
228
|
+
* @param {Object} record
|
|
229
|
+
* @param {'brief'|'full'} depth
|
|
230
|
+
* @returns {Object}
|
|
231
|
+
*/
|
|
232
|
+
upsertRecord(record, depth) {
|
|
233
|
+
const id = recordIdentity(record)
|
|
234
|
+
if (!id || !DEPTH_RANK[depth]) return record
|
|
235
|
+
const held = this._records.get(id)
|
|
236
|
+
if (!held) {
|
|
237
|
+
this._records.set(id, { depth, record })
|
|
238
|
+
this._recordsVersion += 1
|
|
239
|
+
return record
|
|
240
|
+
}
|
|
241
|
+
if (DEPTH_RANK[depth] < DEPTH_RANK[held.depth]) return held.record // R2
|
|
242
|
+
const next = DEPTH_RANK[depth] > DEPTH_RANK[held.depth]
|
|
243
|
+
? { ...held.record, ...record } // R3 — merge on upgrade
|
|
244
|
+
: record // same depth: the fresher copy
|
|
245
|
+
this._records.set(id, { depth, record: next })
|
|
246
|
+
this._recordsVersion += 1
|
|
247
|
+
return next
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
_index(entry) {
|
|
251
|
+
const depth = indexableDepth(entry)
|
|
252
|
+
if (!depth || !entry || entry.ids) return entry
|
|
253
|
+
const { data } = entry
|
|
254
|
+
if (Array.isArray(data)) {
|
|
255
|
+
if (data.length === 0 || !data.every((r) => recordIdentity(r))) return entry
|
|
256
|
+
const ids = data.map((r) => recordIdentity(r))
|
|
257
|
+
for (const r of data) this.upsertRecord(r, depth)
|
|
258
|
+
return { ids, meta: entry.meta, _at: -1, _data: null }
|
|
259
|
+
}
|
|
260
|
+
if (recordIdentity(data)) {
|
|
261
|
+
this.upsertRecord(data, depth)
|
|
262
|
+
return { ids: [recordIdentity(data)], single: true, meta: entry.meta, _at: -1, _data: null }
|
|
263
|
+
}
|
|
264
|
+
return entry
|
|
265
|
+
}
|
|
266
|
+
|
|
142
267
|
/**
|
|
143
268
|
* Drop one entry, and any in-flight record for the same key.
|
|
144
269
|
*
|
|
@@ -180,5 +305,7 @@ export default class DataStore {
|
|
|
180
305
|
clear() {
|
|
181
306
|
this._cache.clear()
|
|
182
307
|
this._inflight.clear()
|
|
308
|
+
this._records.clear()
|
|
309
|
+
this._recordsVersion += 1
|
|
183
310
|
}
|
|
184
311
|
}
|
package/src/detail-url.js
CHANGED
|
@@ -22,6 +22,7 @@
|
|
|
22
22
|
* a matched route pattern says nothing about the record behind it.
|
|
23
23
|
*/
|
|
24
24
|
|
|
25
|
+
import { recordHandle } from './route-match.js'
|
|
25
26
|
import { substitutePlaceholders } from './substitute-placeholders.js'
|
|
26
27
|
|
|
27
28
|
/**
|
|
@@ -42,8 +43,23 @@ import { substitutePlaceholders } from './substitute-placeholders.js'
|
|
|
42
43
|
* present in the context, so an unrelated `{name}` still passes through
|
|
43
44
|
* literally, as it always has.
|
|
44
45
|
*/
|
|
45
|
-
function paramContext(paramName, paramValue) {
|
|
46
|
-
|
|
46
|
+
function paramContext(paramName, paramValue, record) {
|
|
47
|
+
const context = { [paramName]: paramValue, param: paramValue }
|
|
48
|
+
// ⭐ `{slug}` is the RECORD'S slug, whatever the route calls its param. The
|
|
49
|
+
// file lane keys a query's per-record files by `item.slug` (`writeQueryFiles`)
|
|
50
|
+
// and injects `/data/<name>/{slug}.json` — so on a site routing `[id]`, the
|
|
51
|
+
// route's context carried `id` and `param` and `{slug}` stayed literal: the
|
|
52
|
+
// detail URL was `/data/articles/{slug}.json`, a guaranteed 404, on every
|
|
53
|
+
// template page with `deferred:` fields (measured 2026-09-04). When the caller
|
|
54
|
+
// holds the record — the entity store does (it matched it), and so does
|
|
55
|
+
// `useEntityDetail` — its slug fills the name the FILE was written under. A
|
|
56
|
+
// caller with no record in hand leaves `{slug}` literal rather than guessing
|
|
57
|
+
// the capture is one: a visibly unresolved address beats a plausible wrong one.
|
|
58
|
+
const handle = recordHandle(record)
|
|
59
|
+
if (paramName !== 'slug' && handle != null && handle !== '') {
|
|
60
|
+
context.slug = handle
|
|
61
|
+
}
|
|
62
|
+
return context
|
|
47
63
|
}
|
|
48
64
|
|
|
49
65
|
/**
|
|
@@ -67,26 +83,70 @@ function paramContext(paramName, paramValue) {
|
|
|
67
83
|
* @param {Object} queryConfig - A resolved fetch config for the query
|
|
68
84
|
* (post-`resolveFetchConfigs`, so `detail` may have been auto-injected for a
|
|
69
85
|
* `deferred:` query — see `./fetch-config.js`).
|
|
70
|
-
* @param {{ paramName: string, paramValue: string }} dynamicContext
|
|
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`).
|
|
71
90
|
* @returns {Object|null} A fetch config carrying `url` or `path`, or null.
|
|
72
91
|
*/
|
|
92
|
+
/**
|
|
93
|
+
* The key a route param narrows a QUESTION by — the entry's own handle, which
|
|
94
|
+
* a records door guarantees unique among siblings (the records door's contract
|
|
95
|
+
* §1b: `$name`, "addressed AND filtered"). ⚠️ One constant, because the spelling
|
|
96
|
+
* moved four times in one day (`path_segment` → `$slug` → `$name` → `meta::name`
|
|
97
|
+
* → `$name`); the door is dark until a host stamps it, so this is the one place
|
|
98
|
+
* to change if it moves again. Not read by the file lane or the address door,
|
|
99
|
+
* which narrow by the route's own param (`item[paramName]`).
|
|
100
|
+
*/
|
|
101
|
+
export const ROUTE_HANDLE_KEY = '$name'
|
|
102
|
+
|
|
73
103
|
export function buildDetailConfig(queryConfig, dynamicContext) {
|
|
74
104
|
const { detail } = queryConfig
|
|
75
105
|
if (!detail) return null
|
|
76
|
-
const { paramName, paramValue } = dynamicContext
|
|
106
|
+
const { paramName, paramValue, record = null } = dynamicContext
|
|
77
107
|
if (!paramName || paramValue === undefined) return null
|
|
78
108
|
|
|
79
|
-
//
|
|
80
|
-
//
|
|
81
|
-
//
|
|
82
|
-
//
|
|
83
|
-
|
|
109
|
+
// ⭐ A QUESTION DOOR: the record is the list's own question, narrowed to one
|
|
110
|
+
// entry by its handle and asked in full — the list page and the detail page
|
|
111
|
+
// are the same query, differing only by whether the parameter is bound
|
|
112
|
+
// (the records door's contract, §1a). `sort` and `limit` are the
|
|
113
|
+
// list's and drop; `scope` and the authored `where` stay, so a scoped query
|
|
114
|
+
// cannot be escaped through the URL.
|
|
115
|
+
if (queryConfig.door) {
|
|
116
|
+
const { sort, limit, detail: _detail, ...rest } = queryConfig
|
|
117
|
+
return {
|
|
118
|
+
...rest,
|
|
119
|
+
where: { ...(queryConfig.where && typeof queryConfig.where === 'object' ? queryConfig.where : {}), [ROUTE_HANDLE_KEY]: String(paramValue) },
|
|
120
|
+
depth: 'full',
|
|
121
|
+
dynamicContext: { paramName, paramValue },
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// Two address kinds, and the detail request comes back as the SAME kind as
|
|
126
|
+
// the list: a `url` the author wrote stays a `url`, a compiled `path` stays a
|
|
127
|
+
// `path`. (A third kind, the host's address door, was retired 2026-09-04.)
|
|
128
|
+
const baseUrl = queryConfig.url || queryConfig.path
|
|
84
129
|
if (!baseUrl) return null
|
|
85
|
-
const addressKey = queryConfig.
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
130
|
+
const addressKey = queryConfig.url ? 'url' : 'path'
|
|
131
|
+
|
|
132
|
+
// What every detail config carries beside its address:
|
|
133
|
+
// `as` — the binding key, so the record lands where the list did;
|
|
134
|
+
// `query` — the query it is one record of (identity on a door);
|
|
135
|
+
// `depth: 'full'` — what it asks for, and what the record index files it as;
|
|
136
|
+
// `dynamicContext` — the route param, which the default fetcher already keys
|
|
137
|
+
// a SINGLE-RECORD response on (`envelope.item`, body
|
|
138
|
+
// placeholders). ⛔ The entity store never passed it, so
|
|
139
|
+
// a live lane's bare record response was unwrapped with
|
|
140
|
+
// the LIST key and read as `[]` (found 2026-09-04).
|
|
141
|
+
// `locale` — carried from the list, so the two share a locale.
|
|
142
|
+
const common = {
|
|
143
|
+
as: queryConfig.as,
|
|
144
|
+
transform: queryConfig.transform,
|
|
145
|
+
depth: 'full',
|
|
146
|
+
dynamicContext: { paramName, paramValue },
|
|
147
|
+
}
|
|
148
|
+
if (typeof queryConfig.query === 'string') common.query = queryConfig.query
|
|
149
|
+
if (queryConfig.locale !== undefined) common.locale = queryConfig.locale
|
|
90
150
|
|
|
91
151
|
// Object form: `detail: { body, envelope }`. Reuses the query's URL +
|
|
92
152
|
// method + headers + auth. The body is placeholder-substituted against
|
|
@@ -94,14 +154,13 @@ export function buildDetailConfig(queryConfig, dynamicContext) {
|
|
|
94
154
|
if (detail && typeof detail === 'object') {
|
|
95
155
|
const out = {
|
|
96
156
|
[addressKey]: baseUrl,
|
|
97
|
-
|
|
98
|
-
transform: queryConfig.transform,
|
|
157
|
+
...common,
|
|
99
158
|
}
|
|
100
159
|
if (queryConfig.method) out.method = queryConfig.method
|
|
101
160
|
if (detail.body !== undefined) {
|
|
102
|
-
out.body = substitutePlaceholders(detail.body, paramContext(paramName, paramValue), { encode: false })
|
|
161
|
+
out.body = substitutePlaceholders(detail.body, paramContext(paramName, paramValue, record), { encode: false })
|
|
103
162
|
} else if (queryConfig.body !== undefined) {
|
|
104
|
-
out.body = substitutePlaceholders(queryConfig.body, paramContext(paramName, paramValue), { encode: false })
|
|
163
|
+
out.body = substitutePlaceholders(queryConfig.body, paramContext(paramName, paramValue, record), { encode: false })
|
|
105
164
|
}
|
|
106
165
|
if (detail.envelope) out.envelope = detail.envelope
|
|
107
166
|
return out
|
|
@@ -140,12 +199,11 @@ export function buildDetailConfig(queryConfig, dynamicContext) {
|
|
|
140
199
|
// Custom pattern like '/articles/{slug}' — substitute placeholders
|
|
141
200
|
// from the dynamic-route context. Only placeholders matching the
|
|
142
201
|
// active paramName resolve; others pass through as literal `{name}`.
|
|
143
|
-
detailUrl = substitutePlaceholders(detail, paramContext(paramName, paramValue))
|
|
202
|
+
detailUrl = substitutePlaceholders(detail, paramContext(paramName, paramValue, record))
|
|
144
203
|
}
|
|
145
204
|
|
|
146
205
|
return {
|
|
147
206
|
[addressKey]: detailUrl,
|
|
148
|
-
|
|
149
|
-
transform: queryConfig.transform,
|
|
207
|
+
...common,
|
|
150
208
|
}
|
|
151
209
|
}
|