@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
|
@@ -0,0 +1,333 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* FetcherDispatcher
|
|
3
|
+
*
|
|
4
|
+
* Assembled by the Website from the primary foundation's named transports
|
|
5
|
+
* plus any extensions'. Resolves which fetcher handles a given request by
|
|
6
|
+
* name lookup — the site selects per-schema in `site.yml fetcher.transports`.
|
|
7
|
+
*
|
|
8
|
+
* The dispatcher owns cache-key derivation, checks the DataStore, dedups
|
|
9
|
+
* concurrent in-flight requests, and passes an AbortSignal through to the
|
|
10
|
+
* selected fetcher. A runtime `transport` override (editor preview bridge)
|
|
11
|
+
* bypasses all of that and handles every request directly.
|
|
12
|
+
*
|
|
13
|
+
* The dispatcher is the only layer that touches DataStore directly;
|
|
14
|
+
* EntityStore calls the dispatcher's `peek` / `dispatch` methods and
|
|
15
|
+
* never goes around it.
|
|
16
|
+
*/
|
|
17
|
+
import { deriveCacheKey } from './datastore.js'
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Extract the declaration object from a foundation — either an ESM module
|
|
21
|
+
* with a default export, or an already-plain declaration.
|
|
22
|
+
*/
|
|
23
|
+
function getFoundationDecl(mod) {
|
|
24
|
+
if (!mod) return null
|
|
25
|
+
if (mod.default && typeof mod.default === 'object') return mod.default
|
|
26
|
+
if (typeof mod === 'object') return mod
|
|
27
|
+
return null
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function isValidTransport(t) {
|
|
31
|
+
return !!t && typeof t.resolve === 'function'
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Collect transports from a foundation declaration, returning a Map
|
|
36
|
+
* `name → transport`. Throwing or malformed entries are dropped with a
|
|
37
|
+
* dev-mode warning so a single bad transport never tears down the
|
|
38
|
+
* registry. This mirrors the `Promise.allSettled` pattern the runtime
|
|
39
|
+
* uses when loading extensions — one bad extension doesn't block the site.
|
|
40
|
+
*/
|
|
41
|
+
function collectTransports(decl, { source, dev }) {
|
|
42
|
+
const out = new Map()
|
|
43
|
+
if (!decl) return out
|
|
44
|
+
|
|
45
|
+
let raw
|
|
46
|
+
try {
|
|
47
|
+
raw = decl.transports
|
|
48
|
+
} catch (err) {
|
|
49
|
+
if (dev) console.warn(`[FetcherDispatcher] ${source} transports getter threw:`, err)
|
|
50
|
+
return out
|
|
51
|
+
}
|
|
52
|
+
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return out
|
|
53
|
+
|
|
54
|
+
for (const name of Object.keys(raw)) {
|
|
55
|
+
let t
|
|
56
|
+
try {
|
|
57
|
+
t = raw[name]
|
|
58
|
+
} catch (err) {
|
|
59
|
+
if (dev) {
|
|
60
|
+
console.warn(`[FetcherDispatcher] ${source} transport "${name}" getter threw:`, err)
|
|
61
|
+
}
|
|
62
|
+
continue
|
|
63
|
+
}
|
|
64
|
+
if (!isValidTransport(t)) {
|
|
65
|
+
if (dev) {
|
|
66
|
+
console.warn(
|
|
67
|
+
`[FetcherDispatcher] ${source} transport "${name}" missing resolve(); skipped.`,
|
|
68
|
+
)
|
|
69
|
+
}
|
|
70
|
+
continue
|
|
71
|
+
}
|
|
72
|
+
out.set(name, t)
|
|
73
|
+
}
|
|
74
|
+
return out
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export default class FetcherDispatcher {
|
|
78
|
+
/**
|
|
79
|
+
* @param {Object} options
|
|
80
|
+
* @param {Object|null} options.foundation - Primary foundation module or declaration.
|
|
81
|
+
* @param {Array<Object>} [options.extensions] - Extension modules or declarations.
|
|
82
|
+
* @param {Object} options.dataStore - The Website's DataStore.
|
|
83
|
+
* @param {{ resolve: Function, cacheKey?: Function }} [options.defaultFetcher]
|
|
84
|
+
* Framework default fetcher. Used when the site doesn't pick a named
|
|
85
|
+
* transport for the request's schema.
|
|
86
|
+
* @param {{ resolve: Function, cacheKey?: Function }} [options.transport] -
|
|
87
|
+
* Runtime-level transport override. When set, every Layer-1 request is
|
|
88
|
+
* routed through this transport — no named-transport lookup, no fallback
|
|
89
|
+
* to the framework default. Editor preview iframe only.
|
|
90
|
+
* @param {boolean} [options.dev] - Enable dev-mode validation warnings.
|
|
91
|
+
*/
|
|
92
|
+
constructor({ foundation, extensions = [], dataStore, defaultFetcher = null, transport = null, dev = false }) {
|
|
93
|
+
if (!dataStore) throw new Error('FetcherDispatcher: dataStore is required')
|
|
94
|
+
this._dataStore = dataStore
|
|
95
|
+
this._defaultFetcher = defaultFetcher
|
|
96
|
+
this._transportOverride = isValidTransport(transport) ? transport : null
|
|
97
|
+
this._dev = !!dev
|
|
98
|
+
|
|
99
|
+
// Named transport registry. Primary foundation wins on name collisions
|
|
100
|
+
// with extensions (dev-mode warning); bad extension transports are
|
|
101
|
+
// skipped individually rather than tearing down the whole registry.
|
|
102
|
+
const registry = new Map()
|
|
103
|
+
|
|
104
|
+
const primaryTransports = collectTransports(getFoundationDecl(foundation), {
|
|
105
|
+
source: 'primary foundation',
|
|
106
|
+
dev: this._dev,
|
|
107
|
+
})
|
|
108
|
+
for (const [name, t] of primaryTransports) registry.set(name, t)
|
|
109
|
+
|
|
110
|
+
for (const ext of extensions) {
|
|
111
|
+
let extTransports
|
|
112
|
+
try {
|
|
113
|
+
extTransports = collectTransports(getFoundationDecl(ext), {
|
|
114
|
+
source: 'extension',
|
|
115
|
+
dev: this._dev,
|
|
116
|
+
})
|
|
117
|
+
} catch (err) {
|
|
118
|
+
if (this._dev) {
|
|
119
|
+
console.warn('[FetcherDispatcher] extension transports collection threw:', err)
|
|
120
|
+
}
|
|
121
|
+
continue
|
|
122
|
+
}
|
|
123
|
+
for (const [name, t] of extTransports) {
|
|
124
|
+
if (registry.has(name)) {
|
|
125
|
+
if (this._dev) {
|
|
126
|
+
console.warn(
|
|
127
|
+
`[FetcherDispatcher] extension transport "${name}" ignored — primary foundation already provides it.`,
|
|
128
|
+
)
|
|
129
|
+
}
|
|
130
|
+
continue
|
|
131
|
+
}
|
|
132
|
+
registry.set(name, t)
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
this._namedTransports = registry
|
|
137
|
+
|
|
138
|
+
Object.freeze(this)
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Select the fetcher for a request.
|
|
143
|
+
*
|
|
144
|
+
* 1. Runtime `transport` override (editor preview) wins over everything.
|
|
145
|
+
* 2. Otherwise, look up the site's per-schema selection in
|
|
146
|
+
* `ctx.website.config.fetcher.transports[schema]` → `.transports.default`.
|
|
147
|
+
* A named match is resolved against the registry of foundation /
|
|
148
|
+
* extension transports.
|
|
149
|
+
* 3. If the site didn't pick a name (or picked one that's not in the
|
|
150
|
+
* registry), fall through to the framework default fetcher.
|
|
151
|
+
*/
|
|
152
|
+
_selectFetcher(request, ctx) {
|
|
153
|
+
if (this._transportOverride) return this._transportOverride
|
|
154
|
+
|
|
155
|
+
const transportsConfig = ctx?.website?.config?.fetcher?.transports
|
|
156
|
+
if (transportsConfig && typeof transportsConfig === 'object') {
|
|
157
|
+
const schema = request?.schema
|
|
158
|
+
const name = (schema && transportsConfig[schema]) || transportsConfig.default
|
|
159
|
+
if (name) {
|
|
160
|
+
const t = this._namedTransports.get(name)
|
|
161
|
+
if (t) return t
|
|
162
|
+
if (this._dev) {
|
|
163
|
+
console.warn(
|
|
164
|
+
`[FetcherDispatcher] site selected transport "${name}" for schema "${schema ?? '(none)'}" ` +
|
|
165
|
+
'but no foundation or extension registered it; falling back to the framework default.',
|
|
166
|
+
)
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
return this._defaultFetcher
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
_cacheKey(fetcher, request) {
|
|
175
|
+
if (fetcher && typeof fetcher.cacheKey === 'function') {
|
|
176
|
+
try {
|
|
177
|
+
return String(fetcher.cacheKey(request))
|
|
178
|
+
} catch (err) {
|
|
179
|
+
console.warn('[FetcherDispatcher] fetcher.cacheKey threw:', err)
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
return deriveCacheKey(request)
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* Synchronous cache probe. Selects the fetcher (for key derivation), checks
|
|
187
|
+
* DataStore, returns the cached `{ data, meta }` entry or null. Never starts
|
|
188
|
+
* a fetch.
|
|
189
|
+
*/
|
|
190
|
+
peek(request, ctx = {}) {
|
|
191
|
+
const fetcher = this._selectFetcher(request, ctx)
|
|
192
|
+
const key = this._cacheKey(fetcher, request)
|
|
193
|
+
return this._dataStore.get(key)
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* Full dispatch — selection, cache check, in-flight dedup, execution.
|
|
198
|
+
*
|
|
199
|
+
* Cache hit → returns a resolved promise with the cached entry.
|
|
200
|
+
* In-flight match → attaches the caller's signal, awaits the shared promise.
|
|
201
|
+
* Miss → runs fetcher, stores on success, returns the result.
|
|
202
|
+
*
|
|
203
|
+
* Signal semantics: the dispatcher owns a master `AbortController` per
|
|
204
|
+
* in-flight entry. The fetcher sees the master's signal in `ctx.signal`;
|
|
205
|
+
* each caller's own signal is attached for bookkeeping. The master aborts
|
|
206
|
+
* only when every attached signal has aborted — so cancelling one block
|
|
207
|
+
* doesn't kill a fetch another block still needs.
|
|
208
|
+
*
|
|
209
|
+
* Error isolation: thrown exceptions and malformed returns surface as
|
|
210
|
+
* `{ data: [], error }` and never poison the cache. Successful results
|
|
211
|
+
* containing an `error` field are passed through unchanged and not cached.
|
|
212
|
+
*/
|
|
213
|
+
async dispatch(request, ctx = {}) {
|
|
214
|
+
const fetcher = this._selectFetcher(request, ctx)
|
|
215
|
+
if (!fetcher) {
|
|
216
|
+
return { data: [], error: 'FetcherDispatcher: no fetcher selected and no default configured' }
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
const key = this._cacheKey(fetcher, request)
|
|
220
|
+
|
|
221
|
+
const cached = this._dataStore.get(key)
|
|
222
|
+
if (cached) return { data: cached.data, meta: cached.meta }
|
|
223
|
+
|
|
224
|
+
const existing = this._dataStore.inflight.get(key)
|
|
225
|
+
if (existing) {
|
|
226
|
+
this._attachSignal(existing, ctx.signal)
|
|
227
|
+
return existing.promise
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
const master = typeof AbortController !== 'undefined' ? new AbortController() : null
|
|
231
|
+
const inflight = {
|
|
232
|
+
promise: null,
|
|
233
|
+
master,
|
|
234
|
+
signals: new Set(),
|
|
235
|
+
everAttached: false,
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
this._attachSignal(inflight, ctx.signal)
|
|
239
|
+
this._dataStore.inflight.set(key, inflight)
|
|
240
|
+
|
|
241
|
+
const innerCtx = master ? { ...ctx, signal: master.signal } : ctx
|
|
242
|
+
inflight.promise = this._runFetcher(fetcher, request, innerCtx, key, inflight)
|
|
243
|
+
return inflight.promise
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/**
|
|
247
|
+
* Attach a caller's AbortSignal to an in-flight entry. Aborting a signal
|
|
248
|
+
* removes it from the entry's set; when every attached signal has aborted
|
|
249
|
+
* (and at least one was ever attached), the master controller fires so the
|
|
250
|
+
* underlying fetch can bail out.
|
|
251
|
+
*/
|
|
252
|
+
_attachSignal(inflight, signal) {
|
|
253
|
+
if (!signal) return
|
|
254
|
+
if (!inflight.master) return
|
|
255
|
+
|
|
256
|
+
inflight.everAttached = true
|
|
257
|
+
|
|
258
|
+
if (signal.aborted) {
|
|
259
|
+
this._maybeAbortMaster(inflight)
|
|
260
|
+
return
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
inflight.signals.add(signal)
|
|
264
|
+
const onAbort = () => {
|
|
265
|
+
inflight.signals.delete(signal)
|
|
266
|
+
this._maybeAbortMaster(inflight)
|
|
267
|
+
}
|
|
268
|
+
signal.addEventListener('abort', onAbort, { once: true })
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
_maybeAbortMaster(inflight) {
|
|
272
|
+
if (!inflight.master) return
|
|
273
|
+
if (inflight.master.signal.aborted) return
|
|
274
|
+
if (!inflight.everAttached) return
|
|
275
|
+
if (inflight.signals.size > 0) return
|
|
276
|
+
inflight.master.abort()
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
async _runFetcher(fetcher, request, ctx, key, inflight) {
|
|
280
|
+
try {
|
|
281
|
+
const result = await fetcher.resolve(request, ctx)
|
|
282
|
+
|
|
283
|
+
if (this._dataStore.inflight.get(key) === inflight) {
|
|
284
|
+
this._dataStore.inflight.delete(key)
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
if (!result || typeof result !== 'object') {
|
|
288
|
+
if (this._dev) {
|
|
289
|
+
console.warn(
|
|
290
|
+
'[FetcherDispatcher] Fetcher returned a non-object; expected { data, error?, meta? }.',
|
|
291
|
+
{ request, result },
|
|
292
|
+
)
|
|
293
|
+
}
|
|
294
|
+
return { data: [], error: 'Fetcher returned a non-object' }
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
const { data, error, meta } = result
|
|
298
|
+
if (this._dev) this._validateReturnShape(result, request)
|
|
299
|
+
|
|
300
|
+
if (error) return { data: data ?? [], error, meta }
|
|
301
|
+
|
|
302
|
+
if (data === undefined || data === null) {
|
|
303
|
+
// Nothing meaningful to cache; surface as-is.
|
|
304
|
+
return { data: data ?? [], meta }
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
const entry = meta !== undefined ? { data, meta } : { data }
|
|
308
|
+
this._dataStore.set(key, entry)
|
|
309
|
+
return { data, meta }
|
|
310
|
+
} catch (err) {
|
|
311
|
+
if (this._dataStore.inflight.get(key) === inflight) {
|
|
312
|
+
this._dataStore.inflight.delete(key)
|
|
313
|
+
}
|
|
314
|
+
return { data: [], error: String(err?.message || err) }
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
/**
|
|
319
|
+
* Dev-mode: warn when the fetcher's return object has unexpected top-level
|
|
320
|
+
* keys — catches typos like { items, error } instead of { data, error }.
|
|
321
|
+
*/
|
|
322
|
+
_validateReturnShape(result, request) {
|
|
323
|
+
const allowed = new Set(['data', 'error', 'meta'])
|
|
324
|
+
const unexpected = Object.keys(result).filter((k) => !allowed.has(k))
|
|
325
|
+
if (unexpected.length > 0) {
|
|
326
|
+
console.warn(
|
|
327
|
+
`[FetcherDispatcher] Fetcher return has unexpected keys: ${unexpected.join(', ')}. ` +
|
|
328
|
+
'Expected { data, error?, meta? }.',
|
|
329
|
+
{ request, result },
|
|
330
|
+
)
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
}
|
package/src/index.js
CHANGED
|
@@ -13,6 +13,15 @@ export { default as Website } from './website.js'
|
|
|
13
13
|
export { default as Page } from './page.js'
|
|
14
14
|
export { default as Block } from './block.js'
|
|
15
15
|
export { default as Theme } from './theme.js'
|
|
16
|
+
export { default as DataStore, deriveCacheKey } from './datastore.js'
|
|
17
|
+
export { default as EntityStore } from './entity-store.js'
|
|
18
|
+
export { default as FetcherDispatcher } from './fetcher-dispatcher.js'
|
|
19
|
+
export { default as ObservableState } from './observable-state.js'
|
|
20
|
+
|
|
21
|
+
// Utilities
|
|
22
|
+
export { default as singularize } from './singularize.js'
|
|
23
|
+
export { substitutePlaceholders } from './substitute-placeholders.js'
|
|
24
|
+
export { evaluate as evaluateWhere, match as matchWhere } from './where.js'
|
|
16
25
|
|
|
17
26
|
/**
|
|
18
27
|
* The singleton Uniweb instance.
|
|
@@ -25,13 +34,19 @@ export function getUniweb() {
|
|
|
25
34
|
|
|
26
35
|
/**
|
|
27
36
|
* Create and register the Uniweb singleton.
|
|
28
|
-
* Called by @uniweb/runtime during site initialization.
|
|
29
37
|
*
|
|
30
|
-
* @param {Object}
|
|
31
|
-
* @
|
|
38
|
+
* @param {Object} content - Site content payload (pages, theme, config, layouts, ...).
|
|
39
|
+
* @param {Object} [foundation] - Loaded primary foundation module.
|
|
40
|
+
* @param {Array<Object>} [extensions] - Loaded extension modules.
|
|
41
|
+
* @param {Object} [options]
|
|
42
|
+
* @param {{ resolve: Function }} [options.defaultFetcher] - Framework default fetcher.
|
|
43
|
+
* @param {{ resolve: Function, cacheKey?: Function }} [options.transport] -
|
|
44
|
+
* Runtime-level transport override — routes every Layer-1 request through
|
|
45
|
+
* this transport. Used only by the editor's preview iframe.
|
|
46
|
+
* @returns {Uniweb} The created instance (also assigned to globalThis.uniweb).
|
|
32
47
|
*/
|
|
33
|
-
export function createUniweb(
|
|
34
|
-
const instance = new Uniweb(
|
|
48
|
+
export function createUniweb(content, foundation = null, extensions = [], { defaultFetcher = null, transport = null, dev = false } = {}) {
|
|
49
|
+
const instance = new Uniweb({ content, foundation, extensions, defaultFetcher, transport, dev })
|
|
35
50
|
globalThis.uniweb = instance
|
|
36
51
|
return instance
|
|
37
52
|
}
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ObservableState
|
|
3
|
+
*
|
|
4
|
+
* Small typed observable value store used by `page.state` and `website.state`.
|
|
5
|
+
* Foundations write into it (current selected query, active filter, view-mode
|
|
6
|
+
* toggle). Kit hooks subscribe and re-render React components on change. The
|
|
7
|
+
* fetcher — which runs outside React — reads it directly from `ctx.page.state`
|
|
8
|
+
* / `ctx.website.state`.
|
|
9
|
+
*
|
|
10
|
+
* Plain typed API: no Proxies, no reactive derivations, no middleware. Strict
|
|
11
|
+
* shape reduces accidental reads, makes subscriptions explicit, and avoids
|
|
12
|
+
* enumerate/ownKey surprises that come with property-access proxies.
|
|
13
|
+
*
|
|
14
|
+
* Subscribers fire on change only — `set(key, sameValue)` is a no-op.
|
|
15
|
+
*
|
|
16
|
+
* @example
|
|
17
|
+
* page.state.get('selectedQuery') // → any | undefined
|
|
18
|
+
* page.state.set('selectedQuery', 'X') // fires listeners
|
|
19
|
+
* page.state.delete('selectedQuery') // fires listeners if the key existed
|
|
20
|
+
*
|
|
21
|
+
* page.state.subscribe('selectedQuery', fn) // listener for this key only
|
|
22
|
+
*/
|
|
23
|
+
export default class ObservableState {
|
|
24
|
+
constructor() {
|
|
25
|
+
// key → value
|
|
26
|
+
this._values = new Map()
|
|
27
|
+
// key → Set<fn> — listeners for a specific key.
|
|
28
|
+
this._keyListeners = new Map()
|
|
29
|
+
|
|
30
|
+
Object.seal(this)
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* @param {string} key
|
|
35
|
+
* @returns {any|undefined}
|
|
36
|
+
*/
|
|
37
|
+
get(key) {
|
|
38
|
+
return this._values.get(key)
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* @param {string} key
|
|
43
|
+
* @returns {boolean}
|
|
44
|
+
*/
|
|
45
|
+
has(key) {
|
|
46
|
+
return this._values.has(key)
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Set a key. No-op when the value is `===` the existing value.
|
|
51
|
+
* Listeners fire only on actual changes.
|
|
52
|
+
*
|
|
53
|
+
* @param {string} key
|
|
54
|
+
* @param {any} value
|
|
55
|
+
*/
|
|
56
|
+
set(key, value) {
|
|
57
|
+
if (this._values.has(key) && this._values.get(key) === value) return
|
|
58
|
+
this._values.set(key, value)
|
|
59
|
+
this._notify(key)
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Remove a key. No-op when the key was already absent.
|
|
64
|
+
*
|
|
65
|
+
* @param {string} key
|
|
66
|
+
* @returns {boolean} true if a key was deleted
|
|
67
|
+
*/
|
|
68
|
+
delete(key) {
|
|
69
|
+
if (!this._values.has(key)) return false
|
|
70
|
+
this._values.delete(key)
|
|
71
|
+
this._notify(key)
|
|
72
|
+
return true
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Subscribe to changes for a specific key. Returns an unsubscribe function.
|
|
77
|
+
*
|
|
78
|
+
* There is no all-keys subscription form — fan-out belongs in the caller
|
|
79
|
+
* when it's really needed. Subscribing per key keeps re-render blast
|
|
80
|
+
* radius predictable and avoids "every write wakes every listener" fan-in.
|
|
81
|
+
*
|
|
82
|
+
* @param {string} key
|
|
83
|
+
* @param {Function} fn
|
|
84
|
+
* @returns {Function} unsubscribe
|
|
85
|
+
*/
|
|
86
|
+
subscribe(key, fn) {
|
|
87
|
+
let bucket = this._keyListeners.get(key)
|
|
88
|
+
if (!bucket) {
|
|
89
|
+
bucket = new Set()
|
|
90
|
+
this._keyListeners.set(key, bucket)
|
|
91
|
+
}
|
|
92
|
+
bucket.add(fn)
|
|
93
|
+
return () => {
|
|
94
|
+
bucket.delete(fn)
|
|
95
|
+
if (bucket.size === 0) this._keyListeners.delete(key)
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
_notify(key) {
|
|
100
|
+
const bucket = this._keyListeners.get(key)
|
|
101
|
+
if (bucket) for (const fn of bucket) fn()
|
|
102
|
+
}
|
|
103
|
+
}
|
package/src/page.js
CHANGED
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
8
|
import Block from './block.js'
|
|
9
|
+
import ObservableState from './observable-state.js'
|
|
9
10
|
|
|
10
11
|
export default class Page {
|
|
11
12
|
constructor(pageData, id, website) {
|
|
@@ -92,9 +93,26 @@ export default class Page {
|
|
|
92
93
|
// Guard against concurrent loadContent() calls
|
|
93
94
|
this._loadingContent = null
|
|
94
95
|
|
|
96
|
+
// Observable state — allocated on first access via the `state` getter.
|
|
97
|
+
// Pages that never use state pay nothing; the prop is read-only (no
|
|
98
|
+
// `page.state = X` reassignment) so components can only mutate slots
|
|
99
|
+
// via the intended `page.state.set(key, value)` API.
|
|
100
|
+
this._state = null
|
|
101
|
+
|
|
95
102
|
Object.seal(this)
|
|
96
103
|
}
|
|
97
104
|
|
|
105
|
+
/**
|
|
106
|
+
* Observable state scoped to this page. Foundations write scoped UI / query
|
|
107
|
+
* state here; kit's usePageState bridges it into React; fetchers read it
|
|
108
|
+
* via ctx.page.state. Lazily allocated on first read — pages that never
|
|
109
|
+
* touch state never build one.
|
|
110
|
+
*/
|
|
111
|
+
get state() {
|
|
112
|
+
if (!this._state) this._state = new ObservableState()
|
|
113
|
+
return this._state
|
|
114
|
+
}
|
|
115
|
+
|
|
98
116
|
/**
|
|
99
117
|
* Lazy getter for body blocks
|
|
100
118
|
* Blocks are built on first access, ensuring foundation is loaded
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Substitute `{name}` placeholders in strings (or throughout an object tree)
|
|
3
|
+
* using a flat context map. Used in two places:
|
|
4
|
+
*
|
|
5
|
+
* - URL templates for detail queries: `detail: '/articles/{slug}'` gets
|
|
6
|
+
* `slug` resolved from the dynamic-route context. Encoding ON.
|
|
7
|
+
*
|
|
8
|
+
* - POST `body:` objects where a field carries a route-param reference:
|
|
9
|
+
* `body: { variables: { slug: "{slug}" } }`. Encoding OFF — values go
|
|
10
|
+
* into JSON as-is.
|
|
11
|
+
*
|
|
12
|
+
* Behavior:
|
|
13
|
+
* - Matches `{name}` where `name` is `[A-Za-z_][A-Za-z0-9_]*`. This keeps
|
|
14
|
+
* the substitution *strict* so literal `{` / `}` elsewhere (notably
|
|
15
|
+
* GraphQL selection sets like `{ field }`) don't accidentally match.
|
|
16
|
+
* A whitespace inside the braces disqualifies the match.
|
|
17
|
+
* - Only keys actually present in `context` substitute. Unknown keys
|
|
18
|
+
* pass through unchanged, preserving the literal `{name}`.
|
|
19
|
+
* - Encoding uses `encodeURIComponent` when `encode: true`.
|
|
20
|
+
* - Object/array recursion is structural; primitives other than strings
|
|
21
|
+
* pass through. Returns a new object tree; input is not mutated.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
const PLACEHOLDER_RE = /\{([A-Za-z_][A-Za-z0-9_]*)\}/g
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* @param {*} value - The tree (string, object, array, primitive) to walk.
|
|
28
|
+
* @param {Record<string, string|number>} context - Name → value map. Missing
|
|
29
|
+
* keys leave the placeholder literal in place.
|
|
30
|
+
* @param {Object} [options]
|
|
31
|
+
* @param {boolean} [options.encode=true] - When true, `encodeURIComponent` the
|
|
32
|
+
* substituted value. Turn off for JSON-body substitution where the value
|
|
33
|
+
* will be serialized by JSON.stringify.
|
|
34
|
+
* @returns {*} New tree with substitutions applied.
|
|
35
|
+
*/
|
|
36
|
+
export function substitutePlaceholders(value, context, options = {}) {
|
|
37
|
+
const { encode = true } = options
|
|
38
|
+
|
|
39
|
+
if (typeof value === 'string') {
|
|
40
|
+
return value.replace(PLACEHOLDER_RE, (literal, key) => {
|
|
41
|
+
if (!(key in (context || {}))) return literal
|
|
42
|
+
const raw = context[key]
|
|
43
|
+
if (raw === undefined || raw === null) return literal
|
|
44
|
+
return encode ? encodeURIComponent(String(raw)) : String(raw)
|
|
45
|
+
})
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
if (Array.isArray(value)) {
|
|
49
|
+
return value.map((item) => substitutePlaceholders(item, context, options))
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
if (value && typeof value === 'object') {
|
|
53
|
+
const result = {}
|
|
54
|
+
for (const key of Object.keys(value)) {
|
|
55
|
+
result[key] = substitutePlaceholders(value[key], context, options)
|
|
56
|
+
}
|
|
57
|
+
return result
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
return value
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export default substitutePlaceholders
|