@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.
@@ -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,14 @@ 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'
16
24
 
17
25
  /**
18
26
  * The singleton Uniweb instance.
@@ -25,13 +33,19 @@ export function getUniweb() {
25
33
 
26
34
  /**
27
35
  * Create and register the Uniweb singleton.
28
- * Called by @uniweb/runtime during site initialization.
29
36
  *
30
- * @param {Object} configData - Website configuration (pages, theme, config)
31
- * @returns {Uniweb} The created instance
37
+ * @param {Object} content - Site content payload (pages, theme, config, layouts, ...).
38
+ * @param {Object} [foundation] - Loaded primary foundation module.
39
+ * @param {Array<Object>} [extensions] - Loaded extension modules.
40
+ * @param {Object} [options]
41
+ * @param {{ resolve: Function }} [options.defaultFetcher] - Framework default fetcher.
42
+ * @param {{ resolve: Function, cacheKey?: Function }} [options.transport] -
43
+ * Runtime-level transport override — routes every Layer-1 request through
44
+ * this transport. Used only by the editor's preview iframe.
45
+ * @returns {Uniweb} The created instance (also assigned to globalThis.uniweb).
32
46
  */
33
- export function createUniweb(configData) {
34
- const instance = new Uniweb(configData)
47
+ export function createUniweb(content, foundation = null, extensions = [], { defaultFetcher = null, transport = null, dev = false } = {}) {
48
+ const instance = new Uniweb({ content, foundation, extensions, defaultFetcher, transport, dev })
35
49
  globalThis.uniweb = instance
36
50
  return instance
37
51
  }
@@ -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