@uniweb/core 0.6.1 → 0.6.2
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 +1 -1
- package/src/datastore.js +102 -101
- package/src/entity-store.js +181 -193
- package/src/fetcher-dispatcher.js +333 -0
- package/src/index.js +19 -5
- package/src/observable-state.js +103 -0
- package/src/page.js +18 -0
- package/src/substitute-placeholders.js +63 -0
- package/src/uniweb.js +70 -79
- package/src/website.js +155 -46
package/src/entity-store.js
CHANGED
|
@@ -1,53 +1,71 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* EntityStore
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
4
|
+
* Walks the block→page→parent→site cascade to find applicable fetch configs,
|
|
5
|
+
* asks the Website's FetcherDispatcher to execute them, and assembles the
|
|
6
|
+
* data payload passed to `prepare-props`.
|
|
6
7
|
*
|
|
7
|
-
*
|
|
8
|
-
* -
|
|
9
|
-
*
|
|
8
|
+
* The cascade, localization, detail-query handling, and collection-first
|
|
9
|
+
* content-gate logic all live here — unchanged from the pre-refactor model.
|
|
10
|
+
* What changed: EntityStore no longer talks to DataStore directly. It calls
|
|
11
|
+
* `website.fetcher.peek(request, ctx)` for the sync path (resolve) and
|
|
12
|
+
* `website.fetcher.dispatch(request, ctx)` for the async path (fetch).
|
|
13
|
+
* The dispatcher owns fetcher selection, cache-key derivation, cache lookup,
|
|
14
|
+
* and in-flight dedup.
|
|
10
15
|
*/
|
|
11
16
|
|
|
12
17
|
import singularize from './singularize.js'
|
|
18
|
+
import { substitutePlaceholders } from './substitute-placeholders.js'
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Is `block.fetch` a per-instance refinement of the ancestor's fetch config
|
|
22
|
+
* rather than a new source? The canonical spelling is `refine: true`; the
|
|
23
|
+
* legacy spelling `inherit: true` is still honored for one release with a
|
|
24
|
+
* dev-mode warning.
|
|
25
|
+
*/
|
|
26
|
+
function isRefinement(bf) {
|
|
27
|
+
return bf?.refine === true || bf?.inherit === true
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
let inheritDeprecationWarned = false
|
|
31
|
+
function warnInheritDeprecation(block) {
|
|
32
|
+
if (inheritDeprecationWarned) return
|
|
33
|
+
inheritDeprecationWarned = true
|
|
34
|
+
// Dev-only; production builds typically strip console.warn. We gate on
|
|
35
|
+
// the presence of the deprecated key and fire once per process.
|
|
36
|
+
console.warn(
|
|
37
|
+
"[uniweb] 'fetch: { inherit: true }' is deprecated; rename to 'fetch: { refine: true }'. " +
|
|
38
|
+
'Accepted for one release; will be removed in the next minor. ' +
|
|
39
|
+
`First seen on block ${block?.id ?? '(unknown)'} of page ${block?.page?.route ?? '(unknown)'}.`
|
|
40
|
+
)
|
|
41
|
+
}
|
|
13
42
|
|
|
14
43
|
export default class EntityStore {
|
|
15
44
|
/**
|
|
16
45
|
* @param {Object} options
|
|
17
|
-
* @param {import('./
|
|
46
|
+
* @param {import('./website.js').default} options.website
|
|
18
47
|
*/
|
|
19
|
-
constructor({
|
|
20
|
-
this.
|
|
21
|
-
|
|
48
|
+
constructor({ website }) {
|
|
49
|
+
this.website = website
|
|
22
50
|
Object.seal(this)
|
|
23
51
|
}
|
|
24
52
|
|
|
25
|
-
/**
|
|
26
|
-
* Whether a component wants detail-URL resolution on dynamic pages.
|
|
27
|
-
* Default true. Set to false via `data: { inherit: true, detail: false }`
|
|
28
|
-
* to receive the full collection (minus the active item) instead.
|
|
29
|
-
*
|
|
30
|
-
* @param {Object} meta
|
|
31
|
-
* @returns {boolean}
|
|
32
|
-
*/
|
|
33
53
|
_shouldInheritDetail(meta, block) {
|
|
34
|
-
// Block-level fetch inherit override takes priority over meta
|
|
35
54
|
const bf = block?.fetch
|
|
36
|
-
if (bf
|
|
55
|
+
if (isRefinement(bf) && bf?.detail !== undefined) return bf.detail !== false
|
|
37
56
|
if (!meta) return true
|
|
38
57
|
return meta.inheritDetail !== false
|
|
39
58
|
}
|
|
40
59
|
|
|
41
60
|
_inheritLimit(meta, block) {
|
|
42
|
-
// Block-level fetch inherit override takes priority over meta
|
|
43
61
|
const bf = block?.fetch
|
|
44
|
-
if (bf
|
|
62
|
+
if (isRefinement(bf) && bf?.limit > 0) return bf.limit
|
|
45
63
|
return (meta?.inheritLimit > 0) ? meta.inheritLimit : null
|
|
46
64
|
}
|
|
47
65
|
|
|
48
66
|
_inheritOrder(block) {
|
|
49
67
|
const bf = block?.fetch
|
|
50
|
-
if (bf
|
|
68
|
+
if (isRefinement(bf) && bf?.order?.orderBy) return bf.order
|
|
51
69
|
return null
|
|
52
70
|
}
|
|
53
71
|
|
|
@@ -66,144 +84,125 @@ export default class EntityStore {
|
|
|
66
84
|
}
|
|
67
85
|
|
|
68
86
|
/**
|
|
69
|
-
*
|
|
87
|
+
* Which schemas does this component want delivered?
|
|
70
88
|
*
|
|
71
|
-
*
|
|
72
|
-
*
|
|
89
|
+
* - meta missing → default-on: collect all available schemas.
|
|
90
|
+
* - meta.inheritData === false → opt out entirely.
|
|
91
|
+
* - Anything else → collect all (legacy inheritData arrays collapse here).
|
|
73
92
|
*/
|
|
74
93
|
_getRequestedSchemas(meta) {
|
|
75
|
-
if (!meta) return
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
if (!inheritData) return null
|
|
79
|
-
|
|
80
|
-
// inheritData: true → inherit all (resolved from fetch configs)
|
|
81
|
-
// inheritData: ['articles'] → specific schemas
|
|
82
|
-
if (Array.isArray(inheritData)) return inheritData.length > 0 ? inheritData : null
|
|
83
|
-
if (inheritData === true) return [] // empty = "all available"
|
|
84
|
-
|
|
85
|
-
return null
|
|
94
|
+
if (!meta) return []
|
|
95
|
+
if (meta.inheritData === false) return null
|
|
96
|
+
return []
|
|
86
97
|
}
|
|
87
98
|
|
|
88
99
|
/**
|
|
89
100
|
* Return a localized copy of a fetch config for collection data.
|
|
90
|
-
*
|
|
91
|
-
* client fetches the translated JSON (
|
|
92
|
-
*
|
|
93
|
-
* @param {Object} cfg - Fetch config
|
|
94
|
-
* @param {import('./website.js').default|null} website
|
|
95
|
-
* @returns {Object} Localized config (or original if no change needed)
|
|
101
|
+
* Non-default locales get /{locale} prefixed onto /data/ paths so the
|
|
102
|
+
* client fetches the translated JSON (/fr/data/articles.json).
|
|
96
103
|
*/
|
|
97
104
|
_localizeConfig(cfg, website) {
|
|
98
105
|
if (!cfg.path || !website) return cfg
|
|
99
|
-
|
|
100
|
-
const
|
|
101
|
-
const defaultLocale = website.getDefaultLocale()
|
|
106
|
+
const locale = website.getActiveLocale?.()
|
|
107
|
+
const defaultLocale = website.getDefaultLocale?.()
|
|
102
108
|
if (!locale || locale === defaultLocale) return cfg
|
|
103
|
-
|
|
104
109
|
if (!cfg.path.startsWith('/data/')) return cfg
|
|
105
|
-
|
|
106
110
|
return { ...cfg, path: `/${locale}${cfg.path}` }
|
|
107
111
|
}
|
|
108
112
|
|
|
109
113
|
/**
|
|
110
|
-
* Walk the hierarchy
|
|
111
|
-
*
|
|
112
|
-
* First match per schema wins. Only walks one parent level (auto-wiring).
|
|
113
|
-
*
|
|
114
|
-
* @param {import('./block.js').default} block
|
|
115
|
-
* @param {string[]} requested - Schema names (empty = collect all)
|
|
116
|
-
* @returns {Map<string, Object>} schema → fetch config
|
|
114
|
+
* Walk the four-level hierarchy and collect fetch configs for the
|
|
115
|
+
* requested schemas. First match per schema wins.
|
|
117
116
|
*/
|
|
118
117
|
_findFetchConfigs(block, requested) {
|
|
119
118
|
const configs = new Map()
|
|
120
119
|
const collectAll = requested.length === 0
|
|
121
|
-
|
|
122
120
|
const sources = []
|
|
123
121
|
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
sources.push(block.fetch)
|
|
122
|
+
if (block.fetch?.inherit === true && block.fetch?.refine !== true) {
|
|
123
|
+
warnInheritDeprecation(block)
|
|
127
124
|
}
|
|
128
|
-
|
|
129
|
-
// 2. Page-level fetch
|
|
125
|
+
if (block.fetch && !isRefinement(block.fetch)) sources.push(block.fetch)
|
|
130
126
|
const page = block.page
|
|
131
|
-
if (page?.fetch)
|
|
132
|
-
|
|
133
|
-
}
|
|
134
|
-
|
|
135
|
-
// 3. Parent page fetch (one level — auto-wiring for dynamic routes)
|
|
136
|
-
if (page?.parent?.fetch) {
|
|
137
|
-
sources.push(page.parent.fetch)
|
|
138
|
-
}
|
|
139
|
-
|
|
140
|
-
// 4. Site-level fetch
|
|
127
|
+
if (page?.fetch) sources.push(page.fetch)
|
|
128
|
+
if (page?.parent?.fetch) sources.push(page.parent.fetch)
|
|
141
129
|
const siteFetch = block.website?.config?.fetch
|
|
142
|
-
if (siteFetch)
|
|
143
|
-
sources.push(siteFetch)
|
|
144
|
-
}
|
|
130
|
+
if (siteFetch) sources.push(siteFetch)
|
|
145
131
|
|
|
146
132
|
const website = block.website
|
|
147
133
|
|
|
148
134
|
for (const source of sources) {
|
|
149
|
-
// Normalize: single config or array of configs
|
|
150
135
|
const configList = Array.isArray(source) ? source : [source]
|
|
151
|
-
|
|
152
136
|
for (const cfg of configList) {
|
|
153
137
|
if (!cfg.schema) continue
|
|
154
|
-
if (configs.has(cfg.schema)) continue
|
|
155
|
-
|
|
138
|
+
if (configs.has(cfg.schema)) continue
|
|
156
139
|
if (collectAll || requested.includes(cfg.schema)) {
|
|
157
140
|
configs.set(cfg.schema, this._localizeConfig(cfg, website))
|
|
158
141
|
}
|
|
159
142
|
}
|
|
160
143
|
}
|
|
161
|
-
|
|
162
144
|
return configs
|
|
163
145
|
}
|
|
164
146
|
|
|
165
147
|
/**
|
|
166
|
-
* Build a fetch config
|
|
148
|
+
* Build a detail-URL fetch config from a collection config + dynamic context.
|
|
167
149
|
*
|
|
168
|
-
*
|
|
169
|
-
*
|
|
170
|
-
*
|
|
150
|
+
* Three forms of `detail:`:
|
|
151
|
+
* - `'rest'` — append paramValue as a path segment.
|
|
152
|
+
* - `'query'` — append `?paramName=paramValue`.
|
|
153
|
+
* - `'/articles/{slug}'` — custom URL pattern with {paramName} placeholders.
|
|
154
|
+
* - `{ body, envelope }` — object form. Reuses the collection's url /
|
|
155
|
+
* method / headers / auth; adds per-detail
|
|
156
|
+
* body (with placeholder substitution) and
|
|
157
|
+
* per-detail envelope.
|
|
171
158
|
*/
|
|
172
159
|
_buildDetailConfig(collectionConfig, dynamicContext) {
|
|
173
160
|
const { detail } = collectionConfig
|
|
174
161
|
if (!detail) return null
|
|
175
|
-
|
|
176
162
|
const { paramName, paramValue } = dynamicContext
|
|
177
163
|
if (!paramName || paramValue === undefined) return null
|
|
178
164
|
|
|
179
165
|
const baseUrl = collectionConfig.url || collectionConfig.path
|
|
180
166
|
if (!baseUrl) return null
|
|
167
|
+
const isLocalPath = !!collectionConfig.path && !collectionConfig.url
|
|
181
168
|
|
|
182
|
-
|
|
169
|
+
// Object form: `detail: { body, envelope }`. Reuses collection's URL +
|
|
170
|
+
// method + headers + auth. The body is placeholder-substituted against
|
|
171
|
+
// the dynamic context so `body: { variables: { slug: "{slug}" } }` works.
|
|
172
|
+
if (detail && typeof detail === 'object') {
|
|
173
|
+
const out = {
|
|
174
|
+
...(isLocalPath ? { path: baseUrl } : { url: baseUrl }),
|
|
175
|
+
schema: singularize(collectionConfig.schema) || collectionConfig.schema,
|
|
176
|
+
transform: collectionConfig.transform,
|
|
177
|
+
}
|
|
178
|
+
if (collectionConfig.method) out.method = collectionConfig.method
|
|
179
|
+
if (detail.body !== undefined) {
|
|
180
|
+
out.body = substitutePlaceholders(detail.body, { [paramName]: paramValue }, { encode: false })
|
|
181
|
+
} else if (collectionConfig.body !== undefined) {
|
|
182
|
+
out.body = substitutePlaceholders(collectionConfig.body, { [paramName]: paramValue }, { encode: false })
|
|
183
|
+
}
|
|
184
|
+
if (detail.envelope) out.envelope = detail.envelope
|
|
185
|
+
return out
|
|
186
|
+
}
|
|
183
187
|
|
|
188
|
+
// String-form: URL-based conventions.
|
|
189
|
+
let detailUrl
|
|
184
190
|
if (detail === 'rest') {
|
|
185
|
-
// REST convention: {baseUrl}/{paramValue}
|
|
186
|
-
// Preserve query string (auth params like token, profileLang) — only insert
|
|
187
|
-
// the param value before the '?', not after.
|
|
188
191
|
const [basePath, queryString] = baseUrl.split('?')
|
|
189
192
|
const cleanBase = basePath.replace(/\/$/, '')
|
|
190
193
|
detailUrl = queryString
|
|
191
194
|
? `${cleanBase}/${encodeURIComponent(paramValue)}?${queryString}`
|
|
192
195
|
: `${cleanBase}/${encodeURIComponent(paramValue)}`
|
|
193
196
|
} else if (detail === 'query') {
|
|
194
|
-
// Query param convention: {baseUrl}?{paramName}={paramValue}
|
|
195
197
|
const sep = baseUrl.includes('?') ? '&' : '?'
|
|
196
198
|
detailUrl = `${baseUrl}${sep}${paramName}=${encodeURIComponent(paramValue)}`
|
|
197
199
|
} else {
|
|
198
|
-
// Custom pattern
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
})
|
|
200
|
+
// Custom pattern like '/articles/{slug}' — substitute placeholders
|
|
201
|
+
// from the dynamic-route context. Only placeholders matching the
|
|
202
|
+
// active paramName resolve; others pass through as literal `{name}`.
|
|
203
|
+
detailUrl = substitutePlaceholders(detail, { [paramName]: paramValue })
|
|
203
204
|
}
|
|
204
205
|
|
|
205
|
-
// Build a fetch config for the single item
|
|
206
|
-
const isLocalPath = !!collectionConfig.path && !collectionConfig.url
|
|
207
206
|
return {
|
|
208
207
|
...(isLocalPath ? { path: detailUrl } : { url: detailUrl }),
|
|
209
208
|
schema: singularize(collectionConfig.schema) || collectionConfig.schema,
|
|
@@ -212,19 +211,13 @@ export default class EntityStore {
|
|
|
212
211
|
}
|
|
213
212
|
|
|
214
213
|
/**
|
|
215
|
-
*
|
|
216
|
-
*
|
|
217
|
-
*
|
|
218
|
-
* @param {Object} data - Resolved entity data { schema: items[] }
|
|
219
|
-
* @param {Object|null} dynamicContext
|
|
220
|
-
* @returns {Object} data with singular key added if applicable
|
|
214
|
+
* For dynamic routes: extract the matching item from a collection and
|
|
215
|
+
* expose it under the singular schema key (articles → article).
|
|
221
216
|
*/
|
|
222
217
|
_resolveSingularItem(data, dynamicContext) {
|
|
223
218
|
if (!dynamicContext) return data
|
|
224
|
-
|
|
225
219
|
const { paramName, paramValue, schema: pluralSchema } = dynamicContext
|
|
226
220
|
if (!pluralSchema || !paramName || paramValue === undefined) return data
|
|
227
|
-
|
|
228
221
|
const items = data[pluralSchema]
|
|
229
222
|
if (!Array.isArray(items)) return data
|
|
230
223
|
|
|
@@ -232,63 +225,66 @@ export default class EntityStore {
|
|
|
232
225
|
const currentItem = items.find(
|
|
233
226
|
(item) => String(item[paramName]) === String(paramValue)
|
|
234
227
|
)
|
|
235
|
-
|
|
236
228
|
if (currentItem && singularSchema) {
|
|
237
229
|
return { ...data, [singularSchema]: currentItem }
|
|
238
230
|
}
|
|
239
|
-
|
|
240
231
|
return data
|
|
241
232
|
}
|
|
242
233
|
|
|
243
234
|
/**
|
|
244
|
-
*
|
|
235
|
+
* Build the `ctx` handed to the dispatcher for a given block.
|
|
236
|
+
* @private
|
|
237
|
+
*/
|
|
238
|
+
_ctx(block, extra = {}) {
|
|
239
|
+
return {
|
|
240
|
+
website: this.website,
|
|
241
|
+
page: block?.page || null,
|
|
242
|
+
block: block || null,
|
|
243
|
+
signal: extra.signal,
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/**
|
|
248
|
+
* Sync resolution — probes the cache via `fetcher.peek`. Returns
|
|
249
|
+
* `ready` only when every relevant entry is cached, otherwise `pending`
|
|
250
|
+
* (caller falls through to `fetch()` to populate and await).
|
|
245
251
|
*
|
|
246
|
-
* @param {import('./block.js').default} block
|
|
247
|
-
* @param {Object} meta - Component runtime metadata
|
|
248
252
|
* @returns {{ status: 'ready'|'pending'|'none', data: Object|null }}
|
|
249
253
|
*/
|
|
250
254
|
resolve(block, meta) {
|
|
255
|
+
const dispatcher = this.website?.fetcher
|
|
251
256
|
let requested = this._getRequestedSchemas(meta)
|
|
252
257
|
|
|
253
|
-
// If the component
|
|
254
|
-
// has a fetch config
|
|
255
|
-
//
|
|
256
|
-
// data assignment, not inheritance.
|
|
258
|
+
// If the component hasn't declared data inheritance but the block itself
|
|
259
|
+
// has a fetch config, target the block's schema explicitly rather than
|
|
260
|
+
// collecting all cascade matches.
|
|
257
261
|
if (requested === null && block.fetch) {
|
|
258
262
|
const blockFetchList = Array.isArray(block.fetch) ? block.fetch : [block.fetch]
|
|
259
|
-
const schemas = blockFetchList
|
|
260
|
-
|
|
261
|
-
.map((cfg) => cfg.schema)
|
|
262
|
-
if (schemas.length > 0) {
|
|
263
|
-
requested = schemas
|
|
264
|
-
}
|
|
263
|
+
const schemas = blockFetchList.filter((cfg) => cfg.schema).map((cfg) => cfg.schema)
|
|
264
|
+
if (schemas.length > 0) requested = schemas
|
|
265
265
|
}
|
|
266
266
|
|
|
267
|
-
if (requested === null) {
|
|
268
|
-
return { status: 'none', data: null }
|
|
269
|
-
}
|
|
267
|
+
if (requested === null) return { status: 'none', data: null }
|
|
270
268
|
|
|
271
|
-
// Walk hierarchy for fetch configs
|
|
272
269
|
const configs = this._findFetchConfigs(block, requested)
|
|
270
|
+
if (configs.size === 0) return { status: 'none', data: null }
|
|
273
271
|
|
|
274
|
-
if (configs.size === 0) {
|
|
275
|
-
return { status: 'none', data: null }
|
|
276
|
-
}
|
|
277
|
-
|
|
278
|
-
// Check DataStore cache for each config
|
|
279
272
|
const dynamicContext = block.dynamicContext || block.page?.dynamicContext
|
|
280
273
|
const inheritDetail = this._shouldInheritDetail(meta, block)
|
|
281
274
|
const limit = this._inheritLimit(meta, block)
|
|
282
275
|
const order = this._inheritOrder(block)
|
|
276
|
+
const ctx = this._ctx(block)
|
|
277
|
+
|
|
283
278
|
const data = {}
|
|
284
279
|
let allCached = true
|
|
285
280
|
|
|
286
281
|
for (const [schema, cfg] of configs) {
|
|
287
282
|
if (dynamicContext && cfg.detail && !inheritDetail) {
|
|
288
|
-
// detail: false — return collection minus the active item
|
|
289
|
-
|
|
283
|
+
// detail: false — return collection minus the active item.
|
|
284
|
+
const cached = dispatcher?.peek(cfg, ctx)
|
|
285
|
+
if (cached) {
|
|
290
286
|
const { paramName, paramValue } = dynamicContext
|
|
291
|
-
const items =
|
|
287
|
+
const items = cached.data
|
|
292
288
|
let filtered = Array.isArray(items)
|
|
293
289
|
? items.filter((item) => String(item[paramName]) !== String(paramValue))
|
|
294
290
|
: items
|
|
@@ -298,11 +294,10 @@ export default class EntityStore {
|
|
|
298
294
|
allCached = false
|
|
299
295
|
}
|
|
300
296
|
} else if (dynamicContext && cfg.detail) {
|
|
301
|
-
// Collection-first detail
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
const collectionItems = this.dataStore.get(cfg)
|
|
297
|
+
// Collection-first detail: the collection is the content gate.
|
|
298
|
+
const cachedCollection = dispatcher?.peek(cfg, ctx)
|
|
299
|
+
if (cachedCollection) {
|
|
300
|
+
const collectionItems = cachedCollection.data
|
|
306
301
|
const { paramName, paramValue } = dynamicContext
|
|
307
302
|
const singularKey = singularize(schema) || schema
|
|
308
303
|
const match = Array.isArray(collectionItems)
|
|
@@ -310,27 +305,29 @@ export default class EntityStore {
|
|
|
310
305
|
: null
|
|
311
306
|
|
|
312
307
|
if (!match) {
|
|
313
|
-
// Item not in collection — definitive "not found" (content gate).
|
|
314
308
|
data[singularKey] = null
|
|
315
309
|
} else {
|
|
316
|
-
// Item is valid. Check if the detail result is already cached.
|
|
317
310
|
const detailCfg = this._buildDetailConfig(cfg, dynamicContext)
|
|
318
|
-
|
|
319
|
-
|
|
311
|
+
const detailCached = detailCfg ? dispatcher?.peek(detailCfg, ctx) : null
|
|
312
|
+
if (detailCfg && detailCached) {
|
|
313
|
+
data[singularKey] = detailCached.data
|
|
320
314
|
} else if (detailCfg) {
|
|
321
|
-
allCached = false
|
|
315
|
+
allCached = false
|
|
322
316
|
} else {
|
|
323
|
-
data[singularKey] = match
|
|
317
|
+
data[singularKey] = match
|
|
324
318
|
}
|
|
325
319
|
}
|
|
326
320
|
} else {
|
|
327
|
-
allCached = false
|
|
321
|
+
allCached = false
|
|
328
322
|
}
|
|
329
|
-
} else if (this.dataStore.has(cfg)) {
|
|
330
|
-
const items = this.dataStore.get(cfg)
|
|
331
|
-
data[schema] = order ? this._sortItems(items, order) : items
|
|
332
323
|
} else {
|
|
333
|
-
|
|
324
|
+
const cached = dispatcher?.peek(cfg, ctx)
|
|
325
|
+
if (cached) {
|
|
326
|
+
const items = cached.data
|
|
327
|
+
data[schema] = order ? this._sortItems(items, order) : items
|
|
328
|
+
} else {
|
|
329
|
+
allCached = false
|
|
330
|
+
}
|
|
334
331
|
}
|
|
335
332
|
}
|
|
336
333
|
|
|
@@ -338,57 +335,48 @@ export default class EntityStore {
|
|
|
338
335
|
const resolved = this._resolveSingularItem(data, dynamicContext)
|
|
339
336
|
return { status: 'ready', data: resolved }
|
|
340
337
|
}
|
|
341
|
-
|
|
342
338
|
return { status: 'pending', data: null }
|
|
343
339
|
}
|
|
344
340
|
|
|
345
341
|
/**
|
|
346
|
-
* Async fetch
|
|
342
|
+
* Async fetch — dispatches missing configs through the FetcherDispatcher
|
|
343
|
+
* and assembles the result. Collection-first detail ordering preserved.
|
|
347
344
|
*
|
|
348
|
-
* @param {
|
|
349
|
-
* @param {
|
|
345
|
+
* @param {Object} [options]
|
|
346
|
+
* @param {AbortSignal} [options.signal] - Forwarded to the dispatcher.
|
|
350
347
|
* @returns {Promise<{ data: Object|null }>}
|
|
351
348
|
*/
|
|
352
|
-
async fetch(block, meta) {
|
|
353
|
-
|
|
349
|
+
async fetch(block, meta, { signal } = {}) {
|
|
350
|
+
const dispatcher = this.website?.fetcher
|
|
351
|
+
if (!dispatcher) return { data: null }
|
|
354
352
|
|
|
355
|
-
|
|
353
|
+
let requested = this._getRequestedSchemas(meta)
|
|
356
354
|
if (requested === null && block.fetch) {
|
|
357
355
|
const blockFetchList = Array.isArray(block.fetch) ? block.fetch : [block.fetch]
|
|
358
|
-
const schemas = blockFetchList
|
|
359
|
-
|
|
360
|
-
.map((cfg) => cfg.schema)
|
|
361
|
-
if (schemas.length > 0) {
|
|
362
|
-
requested = schemas
|
|
363
|
-
}
|
|
364
|
-
}
|
|
365
|
-
|
|
366
|
-
if (requested === null) {
|
|
367
|
-
return { data: null }
|
|
356
|
+
const schemas = blockFetchList.filter((cfg) => cfg.schema).map((cfg) => cfg.schema)
|
|
357
|
+
if (schemas.length > 0) requested = schemas
|
|
368
358
|
}
|
|
359
|
+
if (requested === null) return { data: null }
|
|
369
360
|
|
|
370
361
|
const configs = this._findFetchConfigs(block, requested)
|
|
371
|
-
if (configs.size === 0) {
|
|
372
|
-
return { data: null }
|
|
373
|
-
}
|
|
362
|
+
if (configs.size === 0) return { data: null }
|
|
374
363
|
|
|
375
|
-
// Fetch all missing configs
|
|
376
364
|
const dynamicContext = block.dynamicContext || block.page?.dynamicContext
|
|
377
365
|
const inheritDetail = this._shouldInheritDetail(meta, block)
|
|
378
366
|
const limit = this._inheritLimit(meta, block)
|
|
379
367
|
const order = this._inheritOrder(block)
|
|
368
|
+
const ctx = this._ctx(block, { signal })
|
|
380
369
|
|
|
381
370
|
const data = {}
|
|
382
371
|
const parallelFetches = []
|
|
383
372
|
|
|
384
373
|
for (const [schema, cfg] of configs) {
|
|
385
374
|
if (dynamicContext && cfg.detail && !inheritDetail) {
|
|
386
|
-
// detail: false —
|
|
387
|
-
|
|
388
|
-
let collectionItems = this.dataStore.has(cfg) ? this.dataStore.get(cfg) : null
|
|
375
|
+
// detail: false — collection-only, minus the active item.
|
|
376
|
+
let collectionItems = peekArray(dispatcher, cfg, ctx)
|
|
389
377
|
if (collectionItems === null) {
|
|
390
|
-
const result = await
|
|
391
|
-
collectionItems = Array.isArray(result
|
|
378
|
+
const result = await dispatcher.dispatch(cfg, ctx)
|
|
379
|
+
collectionItems = Array.isArray(result?.data) ? result.data : null
|
|
392
380
|
}
|
|
393
381
|
const { paramName, paramValue } = dynamicContext
|
|
394
382
|
let filtered = Array.isArray(collectionItems)
|
|
@@ -397,48 +385,41 @@ export default class EntityStore {
|
|
|
397
385
|
if (order) filtered = this._sortItems(filtered, order)
|
|
398
386
|
data[schema] = limit && Array.isArray(filtered) ? filtered.slice(0, limit) : filtered
|
|
399
387
|
} else if (dynamicContext && cfg.detail) {
|
|
400
|
-
// Collection-first detail resolution
|
|
401
|
-
// 1. Ensure the collection is in DataStore (fetching if needed).
|
|
402
|
-
// 2. Validate that paramValue exists in the collection (content gate).
|
|
403
|
-
// 3. Only then fetch the detail URL for richer item data.
|
|
388
|
+
// Collection-first detail resolution.
|
|
404
389
|
const { paramName, paramValue } = dynamicContext
|
|
405
390
|
const singularKey = singularize(schema) || schema
|
|
406
391
|
|
|
407
|
-
|
|
408
|
-
let collectionItems = this.dataStore.has(cfg) ? this.dataStore.get(cfg) : null
|
|
392
|
+
let collectionItems = peekArray(dispatcher, cfg, ctx)
|
|
409
393
|
if (collectionItems === null) {
|
|
410
|
-
const result = await
|
|
411
|
-
collectionItems = Array.isArray(result
|
|
394
|
+
const result = await dispatcher.dispatch(cfg, ctx)
|
|
395
|
+
collectionItems = Array.isArray(result?.data) ? result.data : null
|
|
412
396
|
}
|
|
413
397
|
|
|
414
|
-
// Step 2: validate paramValue is in the collection (content gate)
|
|
415
398
|
const match = collectionItems?.find(
|
|
416
399
|
(item) => String(item[paramName]) === String(paramValue)
|
|
417
400
|
) ?? null
|
|
418
401
|
|
|
419
402
|
if (!match) {
|
|
420
|
-
data[singularKey] = null
|
|
403
|
+
data[singularKey] = null
|
|
421
404
|
continue
|
|
422
405
|
}
|
|
423
406
|
|
|
424
|
-
// Step 3: fetch detail URL for richer data; fall back to collection item
|
|
425
407
|
const detailCfg = this._buildDetailConfig(cfg, dynamicContext)
|
|
426
408
|
if (detailCfg) {
|
|
427
409
|
parallelFetches.push(
|
|
428
|
-
|
|
429
|
-
data[singularKey] = (result
|
|
410
|
+
dispatcher.dispatch(detailCfg, ctx).then((result) => {
|
|
411
|
+
data[singularKey] = (result?.data !== undefined && result?.data !== null)
|
|
430
412
|
? result.data
|
|
431
|
-
: match
|
|
413
|
+
: match
|
|
432
414
|
})
|
|
433
415
|
)
|
|
434
416
|
} else {
|
|
435
|
-
data[singularKey] = match
|
|
417
|
+
data[singularKey] = match
|
|
436
418
|
}
|
|
437
419
|
} else {
|
|
438
|
-
// Default: fetch the full collection
|
|
439
420
|
parallelFetches.push(
|
|
440
|
-
|
|
441
|
-
if (result
|
|
421
|
+
dispatcher.dispatch(cfg, ctx).then((result) => {
|
|
422
|
+
if (result?.data !== undefined && result?.data !== null) {
|
|
442
423
|
data[schema] = result.data
|
|
443
424
|
}
|
|
444
425
|
})
|
|
@@ -446,10 +427,17 @@ export default class EntityStore {
|
|
|
446
427
|
}
|
|
447
428
|
}
|
|
448
429
|
|
|
449
|
-
if (parallelFetches.length > 0)
|
|
450
|
-
await Promise.all(parallelFetches)
|
|
451
|
-
}
|
|
430
|
+
if (parallelFetches.length > 0) await Promise.all(parallelFetches)
|
|
452
431
|
const resolved = this._resolveSingularItem(data, dynamicContext)
|
|
453
432
|
return { data: resolved }
|
|
454
433
|
}
|
|
455
434
|
}
|
|
435
|
+
|
|
436
|
+
/**
|
|
437
|
+
* Sync-peek helper: return the cached array for a config, or null on miss.
|
|
438
|
+
*/
|
|
439
|
+
function peekArray(dispatcher, cfg, ctx) {
|
|
440
|
+
const cached = dispatcher.peek(cfg, ctx)
|
|
441
|
+
if (!cached) return null
|
|
442
|
+
return Array.isArray(cached.data) ? cached.data : null
|
|
443
|
+
}
|