@uniweb/core 0.6.1 → 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +3 -3
- package/src/datastore.js +102 -101
- package/src/entity-store.js +214 -194
- package/src/fetcher-dispatcher.js +333 -0
- package/src/index.js +20 -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/where.js +223 -0
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,157 @@ 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
|
+
const localized = this._localizeConfig(cfg, website)
|
|
141
|
+
const withDetail = this._applyDeferredDetail(localized, website)
|
|
142
|
+
configs.set(cfg.schema, withDetail)
|
|
158
143
|
}
|
|
159
144
|
}
|
|
160
145
|
}
|
|
161
|
-
|
|
162
146
|
return configs
|
|
163
147
|
}
|
|
164
148
|
|
|
165
149
|
/**
|
|
166
|
-
*
|
|
150
|
+
* Auto-inject `detail:` on collection refs whose collection has
|
|
151
|
+
* `deferred:` declared. The build emits per-record files at
|
|
152
|
+
* `/data/<name>/<slug>.json` for those collections; this populates
|
|
153
|
+
* the detail-fetch URL so the existing dynamic-route singular flow
|
|
154
|
+
* uses the per-record file (with deferred fields) instead of the
|
|
155
|
+
* matched-item-from-cascade-collection (without).
|
|
167
156
|
*
|
|
168
|
-
*
|
|
169
|
-
*
|
|
170
|
-
*
|
|
157
|
+
* Conventions:
|
|
158
|
+
* - Per-record files are keyed by `item.slug`. The injected pattern
|
|
159
|
+
* uses the `{slug}` placeholder; substitution works when the
|
|
160
|
+
* dynamic route's paramName is 'slug' (the documented convention).
|
|
161
|
+
* Routes using other param names need an explicit author-written
|
|
162
|
+
* `detail:` value.
|
|
163
|
+
* - Author-supplied `cfg.detail` always wins. This helper only fills
|
|
164
|
+
* in the default for collections that have declared deferred fields.
|
|
165
|
+
* - Per-record files are not currently localized; sites needing
|
|
166
|
+
* localized deferred collections write their own `detail:` URL.
|
|
167
|
+
*/
|
|
168
|
+
_applyDeferredDetail(cfg, website) {
|
|
169
|
+
if (cfg.detail !== undefined) return cfg
|
|
170
|
+
const schema = cfg.schema
|
|
171
|
+
if (!schema) return cfg
|
|
172
|
+
const collConfig = website?.config?.collections?.[schema]
|
|
173
|
+
if (!collConfig || typeof collConfig !== 'object') return cfg
|
|
174
|
+
const deferred = Array.isArray(collConfig.deferred) ? collConfig.deferred : null
|
|
175
|
+
if (!deferred || deferred.length === 0) return cfg
|
|
176
|
+
return { ...cfg, detail: `/data/${schema}/{slug}.json` }
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* Build a detail-URL fetch config from a collection config + dynamic context.
|
|
181
|
+
*
|
|
182
|
+
* Three forms of `detail:`:
|
|
183
|
+
* - `'rest'` — append paramValue as a path segment.
|
|
184
|
+
* - `'query'` — append `?paramName=paramValue`.
|
|
185
|
+
* - `'/articles/{slug}'` — custom URL pattern with {paramName} placeholders.
|
|
186
|
+
* - `{ body, envelope }` — object form. Reuses the collection's url /
|
|
187
|
+
* method / headers / auth; adds per-detail
|
|
188
|
+
* body (with placeholder substitution) and
|
|
189
|
+
* per-detail envelope.
|
|
171
190
|
*/
|
|
172
191
|
_buildDetailConfig(collectionConfig, dynamicContext) {
|
|
173
192
|
const { detail } = collectionConfig
|
|
174
193
|
if (!detail) return null
|
|
175
|
-
|
|
176
194
|
const { paramName, paramValue } = dynamicContext
|
|
177
195
|
if (!paramName || paramValue === undefined) return null
|
|
178
196
|
|
|
179
197
|
const baseUrl = collectionConfig.url || collectionConfig.path
|
|
180
198
|
if (!baseUrl) return null
|
|
199
|
+
const isLocalPath = !!collectionConfig.path && !collectionConfig.url
|
|
181
200
|
|
|
182
|
-
|
|
201
|
+
// Object form: `detail: { body, envelope }`. Reuses collection's URL +
|
|
202
|
+
// method + headers + auth. The body is placeholder-substituted against
|
|
203
|
+
// the dynamic context so `body: { variables: { slug: "{slug}" } }` works.
|
|
204
|
+
if (detail && typeof detail === 'object') {
|
|
205
|
+
const out = {
|
|
206
|
+
...(isLocalPath ? { path: baseUrl } : { url: baseUrl }),
|
|
207
|
+
schema: singularize(collectionConfig.schema) || collectionConfig.schema,
|
|
208
|
+
transform: collectionConfig.transform,
|
|
209
|
+
}
|
|
210
|
+
if (collectionConfig.method) out.method = collectionConfig.method
|
|
211
|
+
if (detail.body !== undefined) {
|
|
212
|
+
out.body = substitutePlaceholders(detail.body, { [paramName]: paramValue }, { encode: false })
|
|
213
|
+
} else if (collectionConfig.body !== undefined) {
|
|
214
|
+
out.body = substitutePlaceholders(collectionConfig.body, { [paramName]: paramValue }, { encode: false })
|
|
215
|
+
}
|
|
216
|
+
if (detail.envelope) out.envelope = detail.envelope
|
|
217
|
+
return out
|
|
218
|
+
}
|
|
183
219
|
|
|
220
|
+
// String-form: URL-based conventions.
|
|
221
|
+
let detailUrl
|
|
184
222
|
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
223
|
const [basePath, queryString] = baseUrl.split('?')
|
|
189
224
|
const cleanBase = basePath.replace(/\/$/, '')
|
|
190
225
|
detailUrl = queryString
|
|
191
226
|
? `${cleanBase}/${encodeURIComponent(paramValue)}?${queryString}`
|
|
192
227
|
: `${cleanBase}/${encodeURIComponent(paramValue)}`
|
|
193
228
|
} else if (detail === 'query') {
|
|
194
|
-
// Query param convention: {baseUrl}?{paramName}={paramValue}
|
|
195
229
|
const sep = baseUrl.includes('?') ? '&' : '?'
|
|
196
230
|
detailUrl = `${baseUrl}${sep}${paramName}=${encodeURIComponent(paramValue)}`
|
|
197
231
|
} else {
|
|
198
|
-
// Custom pattern
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
})
|
|
232
|
+
// Custom pattern like '/articles/{slug}' — substitute placeholders
|
|
233
|
+
// from the dynamic-route context. Only placeholders matching the
|
|
234
|
+
// active paramName resolve; others pass through as literal `{name}`.
|
|
235
|
+
detailUrl = substitutePlaceholders(detail, { [paramName]: paramValue })
|
|
203
236
|
}
|
|
204
237
|
|
|
205
|
-
// Build a fetch config for the single item
|
|
206
|
-
const isLocalPath = !!collectionConfig.path && !collectionConfig.url
|
|
207
238
|
return {
|
|
208
239
|
...(isLocalPath ? { path: detailUrl } : { url: detailUrl }),
|
|
209
240
|
schema: singularize(collectionConfig.schema) || collectionConfig.schema,
|
|
@@ -212,19 +243,13 @@ export default class EntityStore {
|
|
|
212
243
|
}
|
|
213
244
|
|
|
214
245
|
/**
|
|
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
|
|
246
|
+
* For dynamic routes: extract the matching item from a collection and
|
|
247
|
+
* expose it under the singular schema key (articles → article).
|
|
221
248
|
*/
|
|
222
249
|
_resolveSingularItem(data, dynamicContext) {
|
|
223
250
|
if (!dynamicContext) return data
|
|
224
|
-
|
|
225
251
|
const { paramName, paramValue, schema: pluralSchema } = dynamicContext
|
|
226
252
|
if (!pluralSchema || !paramName || paramValue === undefined) return data
|
|
227
|
-
|
|
228
253
|
const items = data[pluralSchema]
|
|
229
254
|
if (!Array.isArray(items)) return data
|
|
230
255
|
|
|
@@ -232,63 +257,66 @@ export default class EntityStore {
|
|
|
232
257
|
const currentItem = items.find(
|
|
233
258
|
(item) => String(item[paramName]) === String(paramValue)
|
|
234
259
|
)
|
|
235
|
-
|
|
236
260
|
if (currentItem && singularSchema) {
|
|
237
261
|
return { ...data, [singularSchema]: currentItem }
|
|
238
262
|
}
|
|
239
|
-
|
|
240
263
|
return data
|
|
241
264
|
}
|
|
242
265
|
|
|
243
266
|
/**
|
|
244
|
-
*
|
|
267
|
+
* Build the `ctx` handed to the dispatcher for a given block.
|
|
268
|
+
* @private
|
|
269
|
+
*/
|
|
270
|
+
_ctx(block, extra = {}) {
|
|
271
|
+
return {
|
|
272
|
+
website: this.website,
|
|
273
|
+
page: block?.page || null,
|
|
274
|
+
block: block || null,
|
|
275
|
+
signal: extra.signal,
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
/**
|
|
280
|
+
* Sync resolution — probes the cache via `fetcher.peek`. Returns
|
|
281
|
+
* `ready` only when every relevant entry is cached, otherwise `pending`
|
|
282
|
+
* (caller falls through to `fetch()` to populate and await).
|
|
245
283
|
*
|
|
246
|
-
* @param {import('./block.js').default} block
|
|
247
|
-
* @param {Object} meta - Component runtime metadata
|
|
248
284
|
* @returns {{ status: 'ready'|'pending'|'none', data: Object|null }}
|
|
249
285
|
*/
|
|
250
286
|
resolve(block, meta) {
|
|
287
|
+
const dispatcher = this.website?.fetcher
|
|
251
288
|
let requested = this._getRequestedSchemas(meta)
|
|
252
289
|
|
|
253
|
-
// If the component
|
|
254
|
-
// has a fetch config
|
|
255
|
-
//
|
|
256
|
-
// data assignment, not inheritance.
|
|
290
|
+
// If the component hasn't declared data inheritance but the block itself
|
|
291
|
+
// has a fetch config, target the block's schema explicitly rather than
|
|
292
|
+
// collecting all cascade matches.
|
|
257
293
|
if (requested === null && block.fetch) {
|
|
258
294
|
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
|
-
}
|
|
295
|
+
const schemas = blockFetchList.filter((cfg) => cfg.schema).map((cfg) => cfg.schema)
|
|
296
|
+
if (schemas.length > 0) requested = schemas
|
|
265
297
|
}
|
|
266
298
|
|
|
267
|
-
if (requested === null) {
|
|
268
|
-
return { status: 'none', data: null }
|
|
269
|
-
}
|
|
299
|
+
if (requested === null) return { status: 'none', data: null }
|
|
270
300
|
|
|
271
|
-
// Walk hierarchy for fetch configs
|
|
272
301
|
const configs = this._findFetchConfigs(block, requested)
|
|
302
|
+
if (configs.size === 0) return { status: 'none', data: null }
|
|
273
303
|
|
|
274
|
-
if (configs.size === 0) {
|
|
275
|
-
return { status: 'none', data: null }
|
|
276
|
-
}
|
|
277
|
-
|
|
278
|
-
// Check DataStore cache for each config
|
|
279
304
|
const dynamicContext = block.dynamicContext || block.page?.dynamicContext
|
|
280
305
|
const inheritDetail = this._shouldInheritDetail(meta, block)
|
|
281
306
|
const limit = this._inheritLimit(meta, block)
|
|
282
307
|
const order = this._inheritOrder(block)
|
|
308
|
+
const ctx = this._ctx(block)
|
|
309
|
+
|
|
283
310
|
const data = {}
|
|
284
311
|
let allCached = true
|
|
285
312
|
|
|
286
313
|
for (const [schema, cfg] of configs) {
|
|
287
314
|
if (dynamicContext && cfg.detail && !inheritDetail) {
|
|
288
|
-
// detail: false — return collection minus the active item
|
|
289
|
-
|
|
315
|
+
// detail: false — return collection minus the active item.
|
|
316
|
+
const cached = dispatcher?.peek(cfg, ctx)
|
|
317
|
+
if (cached) {
|
|
290
318
|
const { paramName, paramValue } = dynamicContext
|
|
291
|
-
const items =
|
|
319
|
+
const items = cached.data
|
|
292
320
|
let filtered = Array.isArray(items)
|
|
293
321
|
? items.filter((item) => String(item[paramName]) !== String(paramValue))
|
|
294
322
|
: items
|
|
@@ -298,11 +326,10 @@ export default class EntityStore {
|
|
|
298
326
|
allCached = false
|
|
299
327
|
}
|
|
300
328
|
} else if (dynamicContext && cfg.detail) {
|
|
301
|
-
// Collection-first detail
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
const collectionItems = this.dataStore.get(cfg)
|
|
329
|
+
// Collection-first detail: the collection is the content gate.
|
|
330
|
+
const cachedCollection = dispatcher?.peek(cfg, ctx)
|
|
331
|
+
if (cachedCollection) {
|
|
332
|
+
const collectionItems = cachedCollection.data
|
|
306
333
|
const { paramName, paramValue } = dynamicContext
|
|
307
334
|
const singularKey = singularize(schema) || schema
|
|
308
335
|
const match = Array.isArray(collectionItems)
|
|
@@ -310,27 +337,29 @@ export default class EntityStore {
|
|
|
310
337
|
: null
|
|
311
338
|
|
|
312
339
|
if (!match) {
|
|
313
|
-
// Item not in collection — definitive "not found" (content gate).
|
|
314
340
|
data[singularKey] = null
|
|
315
341
|
} else {
|
|
316
|
-
// Item is valid. Check if the detail result is already cached.
|
|
317
342
|
const detailCfg = this._buildDetailConfig(cfg, dynamicContext)
|
|
318
|
-
|
|
319
|
-
|
|
343
|
+
const detailCached = detailCfg ? dispatcher?.peek(detailCfg, ctx) : null
|
|
344
|
+
if (detailCfg && detailCached) {
|
|
345
|
+
data[singularKey] = detailCached.data
|
|
320
346
|
} else if (detailCfg) {
|
|
321
|
-
allCached = false
|
|
347
|
+
allCached = false
|
|
322
348
|
} else {
|
|
323
|
-
data[singularKey] = match
|
|
349
|
+
data[singularKey] = match
|
|
324
350
|
}
|
|
325
351
|
}
|
|
326
352
|
} else {
|
|
327
|
-
allCached = false
|
|
353
|
+
allCached = false
|
|
328
354
|
}
|
|
329
|
-
} else if (this.dataStore.has(cfg)) {
|
|
330
|
-
const items = this.dataStore.get(cfg)
|
|
331
|
-
data[schema] = order ? this._sortItems(items, order) : items
|
|
332
355
|
} else {
|
|
333
|
-
|
|
356
|
+
const cached = dispatcher?.peek(cfg, ctx)
|
|
357
|
+
if (cached) {
|
|
358
|
+
const items = cached.data
|
|
359
|
+
data[schema] = order ? this._sortItems(items, order) : items
|
|
360
|
+
} else {
|
|
361
|
+
allCached = false
|
|
362
|
+
}
|
|
334
363
|
}
|
|
335
364
|
}
|
|
336
365
|
|
|
@@ -338,57 +367,48 @@ export default class EntityStore {
|
|
|
338
367
|
const resolved = this._resolveSingularItem(data, dynamicContext)
|
|
339
368
|
return { status: 'ready', data: resolved }
|
|
340
369
|
}
|
|
341
|
-
|
|
342
370
|
return { status: 'pending', data: null }
|
|
343
371
|
}
|
|
344
372
|
|
|
345
373
|
/**
|
|
346
|
-
* Async fetch
|
|
374
|
+
* Async fetch — dispatches missing configs through the FetcherDispatcher
|
|
375
|
+
* and assembles the result. Collection-first detail ordering preserved.
|
|
347
376
|
*
|
|
348
|
-
* @param {
|
|
349
|
-
* @param {
|
|
377
|
+
* @param {Object} [options]
|
|
378
|
+
* @param {AbortSignal} [options.signal] - Forwarded to the dispatcher.
|
|
350
379
|
* @returns {Promise<{ data: Object|null }>}
|
|
351
380
|
*/
|
|
352
|
-
async fetch(block, meta) {
|
|
353
|
-
|
|
381
|
+
async fetch(block, meta, { signal } = {}) {
|
|
382
|
+
const dispatcher = this.website?.fetcher
|
|
383
|
+
if (!dispatcher) return { data: null }
|
|
354
384
|
|
|
355
|
-
|
|
385
|
+
let requested = this._getRequestedSchemas(meta)
|
|
356
386
|
if (requested === null && block.fetch) {
|
|
357
387
|
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 }
|
|
388
|
+
const schemas = blockFetchList.filter((cfg) => cfg.schema).map((cfg) => cfg.schema)
|
|
389
|
+
if (schemas.length > 0) requested = schemas
|
|
368
390
|
}
|
|
391
|
+
if (requested === null) return { data: null }
|
|
369
392
|
|
|
370
393
|
const configs = this._findFetchConfigs(block, requested)
|
|
371
|
-
if (configs.size === 0) {
|
|
372
|
-
return { data: null }
|
|
373
|
-
}
|
|
394
|
+
if (configs.size === 0) return { data: null }
|
|
374
395
|
|
|
375
|
-
// Fetch all missing configs
|
|
376
396
|
const dynamicContext = block.dynamicContext || block.page?.dynamicContext
|
|
377
397
|
const inheritDetail = this._shouldInheritDetail(meta, block)
|
|
378
398
|
const limit = this._inheritLimit(meta, block)
|
|
379
399
|
const order = this._inheritOrder(block)
|
|
400
|
+
const ctx = this._ctx(block, { signal })
|
|
380
401
|
|
|
381
402
|
const data = {}
|
|
382
403
|
const parallelFetches = []
|
|
383
404
|
|
|
384
405
|
for (const [schema, cfg] of configs) {
|
|
385
406
|
if (dynamicContext && cfg.detail && !inheritDetail) {
|
|
386
|
-
// detail: false —
|
|
387
|
-
|
|
388
|
-
let collectionItems = this.dataStore.has(cfg) ? this.dataStore.get(cfg) : null
|
|
407
|
+
// detail: false — collection-only, minus the active item.
|
|
408
|
+
let collectionItems = peekArray(dispatcher, cfg, ctx)
|
|
389
409
|
if (collectionItems === null) {
|
|
390
|
-
const result = await
|
|
391
|
-
collectionItems = Array.isArray(result
|
|
410
|
+
const result = await dispatcher.dispatch(cfg, ctx)
|
|
411
|
+
collectionItems = Array.isArray(result?.data) ? result.data : null
|
|
392
412
|
}
|
|
393
413
|
const { paramName, paramValue } = dynamicContext
|
|
394
414
|
let filtered = Array.isArray(collectionItems)
|
|
@@ -397,48 +417,41 @@ export default class EntityStore {
|
|
|
397
417
|
if (order) filtered = this._sortItems(filtered, order)
|
|
398
418
|
data[schema] = limit && Array.isArray(filtered) ? filtered.slice(0, limit) : filtered
|
|
399
419
|
} 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.
|
|
420
|
+
// Collection-first detail resolution.
|
|
404
421
|
const { paramName, paramValue } = dynamicContext
|
|
405
422
|
const singularKey = singularize(schema) || schema
|
|
406
423
|
|
|
407
|
-
|
|
408
|
-
let collectionItems = this.dataStore.has(cfg) ? this.dataStore.get(cfg) : null
|
|
424
|
+
let collectionItems = peekArray(dispatcher, cfg, ctx)
|
|
409
425
|
if (collectionItems === null) {
|
|
410
|
-
const result = await
|
|
411
|
-
collectionItems = Array.isArray(result
|
|
426
|
+
const result = await dispatcher.dispatch(cfg, ctx)
|
|
427
|
+
collectionItems = Array.isArray(result?.data) ? result.data : null
|
|
412
428
|
}
|
|
413
429
|
|
|
414
|
-
// Step 2: validate paramValue is in the collection (content gate)
|
|
415
430
|
const match = collectionItems?.find(
|
|
416
431
|
(item) => String(item[paramName]) === String(paramValue)
|
|
417
432
|
) ?? null
|
|
418
433
|
|
|
419
434
|
if (!match) {
|
|
420
|
-
data[singularKey] = null
|
|
435
|
+
data[singularKey] = null
|
|
421
436
|
continue
|
|
422
437
|
}
|
|
423
438
|
|
|
424
|
-
// Step 3: fetch detail URL for richer data; fall back to collection item
|
|
425
439
|
const detailCfg = this._buildDetailConfig(cfg, dynamicContext)
|
|
426
440
|
if (detailCfg) {
|
|
427
441
|
parallelFetches.push(
|
|
428
|
-
|
|
429
|
-
data[singularKey] = (result
|
|
442
|
+
dispatcher.dispatch(detailCfg, ctx).then((result) => {
|
|
443
|
+
data[singularKey] = (result?.data !== undefined && result?.data !== null)
|
|
430
444
|
? result.data
|
|
431
|
-
: match
|
|
445
|
+
: match
|
|
432
446
|
})
|
|
433
447
|
)
|
|
434
448
|
} else {
|
|
435
|
-
data[singularKey] = match
|
|
449
|
+
data[singularKey] = match
|
|
436
450
|
}
|
|
437
451
|
} else {
|
|
438
|
-
// Default: fetch the full collection
|
|
439
452
|
parallelFetches.push(
|
|
440
|
-
|
|
441
|
-
if (result
|
|
453
|
+
dispatcher.dispatch(cfg, ctx).then((result) => {
|
|
454
|
+
if (result?.data !== undefined && result?.data !== null) {
|
|
442
455
|
data[schema] = result.data
|
|
443
456
|
}
|
|
444
457
|
})
|
|
@@ -446,10 +459,17 @@ export default class EntityStore {
|
|
|
446
459
|
}
|
|
447
460
|
}
|
|
448
461
|
|
|
449
|
-
if (parallelFetches.length > 0)
|
|
450
|
-
await Promise.all(parallelFetches)
|
|
451
|
-
}
|
|
462
|
+
if (parallelFetches.length > 0) await Promise.all(parallelFetches)
|
|
452
463
|
const resolved = this._resolveSingularItem(data, dynamicContext)
|
|
453
464
|
return { data: resolved }
|
|
454
465
|
}
|
|
455
466
|
}
|
|
467
|
+
|
|
468
|
+
/**
|
|
469
|
+
* Sync-peek helper: return the cached array for a config, or null on miss.
|
|
470
|
+
*/
|
|
471
|
+
function peekArray(dispatcher, cfg, ctx) {
|
|
472
|
+
const cached = dispatcher.peek(cfg, ctx)
|
|
473
|
+
if (!cached) return null
|
|
474
|
+
return Array.isArray(cached.data) ? cached.data : null
|
|
475
|
+
}
|