@uniweb/runtime 0.22.0 → 0.23.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@uniweb/runtime",
3
- "version": "0.22.0",
3
+ "version": "0.23.0",
4
4
  "description": "Minimal runtime for loading Uniweb foundations",
5
5
  "type": "module",
6
6
  "exports": {
@@ -36,7 +36,7 @@
36
36
  "node": ">=20.19"
37
37
  },
38
38
  "dependencies": {
39
- "@uniweb/core": "^0.26.2",
39
+ "@uniweb/core": "^0.27.0",
40
40
  "@uniweb/theming": "^0.1.15"
41
41
  },
42
42
  "devDependencies": {
@@ -44,7 +44,7 @@
44
44
  "esbuild": "^0.21.0 || ^0.23.0 || ^0.24.0 || ^0.25.0 || ^0.27.0",
45
45
  "vite": "^7.3.1",
46
46
  "vitest": "^4.1.7",
47
- "@uniweb/build": "0.47.1"
47
+ "@uniweb/build": "0.47.2"
48
48
  },
49
49
  "peerDependencies": {
50
50
  "react": "^19.0.0",
@@ -26,16 +26,17 @@
26
26
  * foundation level… making the runtime+core lean."* A backend with its own
27
27
  * base, headers, wire or query language is a TRANSPORT — a named
28
28
  * `{ resolve, cacheKey? }` the foundation (or an extension) registers and
29
- * the site selects per schema in `fetcher.transports`. The build warns once
29
+ * the site selects per binding key (`as`) in `fetcher.transports`. The build warns once
30
30
  * and drops a retired key from the payload, so an author's backend does not
31
31
  * silently stop being reached.
32
32
  *
33
- * Per-fetch, the request may still carry `method: 'POST'` + `body:` for a
34
- * backend that takes a query in a body (GraphQL, a search endpoint);
35
- * `{paramName}` placeholders in body strings are substituted from
36
- * `request.dynamicContext`, so a template page's detail query can reference
37
- * its route param. `transform:` and the object form of `detail:` (its own
38
- * `envelope`) stay per fetch too — they describe ONE response, not a backend.
33
+ * A request may carry `method: 'POST'` + `body:` for an endpoint that takes a
34
+ * query in a body (GraphQL, a search endpoint) — an external query's, or its
35
+ * `record:` request's — and `transform:`, a dot-path to the records in ONE
36
+ * response. `{paramName}` placeholders in body strings are substituted from
37
+ * `request.dynamicContext`, so a parametric page's record request can reference
38
+ * its route param. ⛔ `envelope` (the object form of `detail:`) is retired with
39
+ * it, 2026-09-13: a record request's own `transform` is `record.transform`.
39
40
  *
40
41
  * Exported from a subpath — `@uniweb/runtime/default-fetcher` — for
41
42
  * runtime-level callers (the editor's preview iframe, custom runtime
@@ -192,42 +193,16 @@ export function createDefaultFetcher({ basePath = '', dev = false, fetch: fetchI
192
193
  const response = await readOnce(target, init)
193
194
  if (ctx.signal?.aborted) return { data: [], error: 'aborted' }
194
195
 
195
- // A per-request envelope (set by the object form of `detail:`) describes
196
- // this one response.
197
- const envelope = (request.envelope && typeof request.envelope === 'object')
198
- ? request.envelope
199
- : {}
200
-
201
196
  if (!response.ok) {
202
- // If `envelope.error` names a path, try to extract a human message
203
- // from the parsed body; fall back to status text if the path is
204
- // missing or the body isn't JSON.
205
- let extracted
206
- if (envelope.error && typeof response.text === 'string') {
207
- const body = safeParseJSON(response.text)
208
- if (body !== undefined) {
209
- const candidate = getNestedValue(body, envelope.error)
210
- if (typeof candidate === 'string' && candidate.length) {
211
- extracted = candidate
212
- }
213
- }
214
- }
215
- return {
216
- data: [],
217
- error: extracted ?? `HTTP ${response.status}: ${response.statusText}`,
218
- }
197
+ return { data: [], error: `HTTP ${response.status}: ${response.statusText}` }
219
198
  }
220
199
 
221
200
  let data = response.body
222
201
 
223
- // Unwrap the response. Per-fetch `transform:` wins; otherwise the
224
- // envelope's `item` path on a single-record request, `list` on a list.
225
- const isDetailRequest = !!request.dynamicContext
226
- const effectiveTransform =
227
- transform
228
- || (isDetailRequest ? envelope.item : envelope.list)
229
- if (effectiveTransform && data !== null && data !== undefined) {
230
- data = getNestedValue(data, effectiveTransform)
202
+ // Unwrap the response: the request's own `transform:`, and nothing else — a
203
+ // record request carries its `record.transform`, never the list's.
204
+ if (transform && data !== null && data !== undefined) {
205
+ data = getNestedValue(data, transform)
231
206
  }
232
207
 
233
208
  // Evaluate the query locally. Only applies to array data
@@ -257,11 +232,12 @@ export function createDefaultFetcher({ basePath = '', dev = false, fetch: fetchI
257
232
  * One question of a batch, in the records service's own vocabulary
258
233
  * (the records contract, §2): `schema` required, `scope` a bare path, `sort`
259
234
  * one key spelled `date` / `-date`, `whole` when the whole record is wanted,
260
- * `match` on a parametric page's record. The where-object crosses as authored
261
- * except for the one spelling the language settled differently from the
262
- * evaluator's: `nin` is `not_in` there. Anything the service does not accept
263
- * (`like`, a dotted path) is sent as written and refused there by name: loud,
264
- * never approximated.
235
+ * `match` on a parametric page's record. ⭐ The where-object crosses exactly as
236
+ * authored: the evaluator and the service speak one language (`@uniweb/core`'s where evaluator).
237
+ * ⛔ `nin` was respelled `not_in` here until 2026-09-13, when the evaluator took
238
+ * `not_in` as its only spelling. Anything the service does not answer (a dotted
239
+ * path, a retired operator) is sent as written and answered there: never
240
+ * approximated.
265
241
  */
266
242
  function toQuestion(request) {
267
243
  const q = { schema: request.schema }
@@ -271,7 +247,7 @@ function toQuestion(request) {
271
247
  // [Diego] — the build refuses it now, so there is nothing left to respell.
272
248
  const scope = typeof request.scope === 'string' && request.scope ? request.scope : null
273
249
  if (scope) q.scope = scope
274
- if (where) q.where = renameOperators(where)
250
+ if (where) q.where = where
275
251
  // ⭐ The record of a parametric page: the question unchanged plus `match` — one
276
252
  // key and the URL's value, beside the author's `where`, never merged into it
277
253
  // (`buildDetailConfig`; the records contract as we read it, §1d).
@@ -292,17 +268,6 @@ function toQuestion(request) {
292
268
  return q
293
269
  }
294
270
 
295
- const OPERATOR_ALIAS = { nin: 'not_in' }
296
- function renameOperators(where) {
297
- if (Array.isArray(where)) return where.map(renameOperators)
298
- if (!where || typeof where !== 'object') return where
299
- const out = {}
300
- for (const [key, value] of Object.entries(where)) {
301
- out[OPERATOR_ALIAS[key] ?? key] = value && typeof value === 'object' ? renameOperators(value) : value
302
- }
303
- return out
304
- }
305
-
306
271
  /**
307
272
  * Send one batch to the records service and hand each question its own answer.
308
273
  *
@@ -491,8 +456,8 @@ async function flushAsked(url, queue, doFetch) {
491
456
  /**
492
457
  * Read one response and parse its body — the part of a fetch every view of an
493
458
  * address shares. A JSON body is parsed as JSON; anything else is tried as JSON
494
- * and kept as text when it is not. A failed response keeps its text, so a view
495
- * whose envelope names an error path can read a message out of it.
459
+ * and kept as text when it is not. A failed response keeps its text, so a caller
460
+ * can read a message out of it (the records service's problem details).
496
461
  */
497
462
  async function readResponse(doFetch, target, init) {
498
463
  const response = await doFetch(target, init)
package/src/prefetch.js CHANGED
@@ -50,15 +50,14 @@
50
50
  * `{base}/…` from the payload, or the records service the host itself
51
51
  * published at `config.services.records`.
52
52
  */
53
- import { resolveFetchConfigs, routeQuery, sectionFetches } from '@uniweb/core/fetch-config'
53
+ import { resolveFetchConfigs, pageRouteQuery, routeSelection, currentOf, othersView, siteReaches } from '@uniweb/core/fetch-config'
54
54
  import { deriveCacheKey } from '@uniweb/core/datastore'
55
55
  import { findPageForRoute, isDynamicRoute, routeBinding, parentRouteOf } from '@uniweb/core/route-match'
56
56
  import { buildDetailConfig } from '@uniweb/core/detail-url'
57
57
  import { resolveDefaultLocale } from '@uniweb/core/locale-config'
58
+ import { findLayoutEntry } from '@uniweb/core/layout-name'
58
59
  import { createDefaultFetcher } from './default-fetcher.js'
59
60
 
60
- const isRefinement = (f) => f && typeof f === 'object' && f.refine === true
61
-
62
61
  /**
63
62
  * ⭐ **RE-EXPORTED, NOT IMPLEMENTED HERE — the rule lives in `@uniweb/core/route-match`.**
64
63
  *
@@ -88,11 +87,36 @@ function parentPageOf(page, pages) {
88
87
  return route ? byRoute.get(route) : null
89
88
  }
90
89
 
90
+ /**
91
+ * The layout sections a page may render — every section of the layout areas it can
92
+ * use, subsections included. A page that names a layout uses that set (matched as the
93
+ * runtime matches it, `@uniweb/core/layout-name`) and nothing else; a page that names
94
+ * none uses its foundation's default layout, which a payload does not say, so every
95
+ * set's sections are included — a superset, which a prefetch can afford.
96
+ */
97
+ function layoutSectionsFor(content, page) {
98
+ const sets = content?.layouts && typeof content.layouts === 'object' ? content.layouts : {}
99
+ const name = typeof page?.layout?.name === 'string' && page.layout.name ? page.layout.name : null
100
+ const named = name ? findLayoutEntry(sets, name) : undefined
101
+ const chosen = name ? (named ? [named] : []) : Object.values(sets)
102
+ const out = []
103
+ const walk = (sections) => {
104
+ for (const s of sections || []) {
105
+ if (!s || typeof s !== 'object') continue
106
+ out.push(s)
107
+ walk(s.subsections)
108
+ }
109
+ }
110
+ for (const set of chosen) for (const area of Object.values(set || {})) walk(area?.sections)
111
+ return out
112
+ }
113
+
91
114
  /**
92
115
  * Every fetch config a page will need at render time, resolved once and de-duplicated by
93
- * cache key: the site-level fetch, the page's, its parent's, and each section's own
94
- * (including nested sections), each through `resolveFetchConfigs` — the same resolver the
95
- * entity store uses, so a host prefetches exactly what the render will ask for.
116
+ * cache key: the page's, its parent's, each section's own (including nested sections),
117
+ * its layout sections', and the site's where it reaches (`siteReaches`) — each through
118
+ * `resolveFetchConfigs`, the same resolver the entity store uses, so a host prefetches
119
+ * exactly what the render will ask for.
96
120
  *
97
121
  * @returns {Object[]} resolved fetch configs
98
122
  */
@@ -113,11 +137,22 @@ export function resolvePageFetchConfigs(content, route, { locale = null } = {})
113
137
  variables: binding?.variables ?? null,
114
138
  }
115
139
  const siteFetch = content?.config?.fetch ?? null
116
- // The route query: the key this page's URL names one record of, by the rule the
117
- // entity store reads it with (`routeQuery`) — never a payload field.
118
- const routeKey = binding
119
- ? routeQuery({ page: page.fetch, parent: parent?.fetch, site: siteFetch, sections: sectionFetches(page.sections) })?.key ?? null
140
+ // The route query: the key this page's URL names one record of, chosen at the page
141
+ // that captured its variable — by the rule the entity store reads it with
142
+ // (`pageRouteQuery`) — never a payload field.
143
+ const chosen = binding
144
+ ? pageRouteQuery(page, {
145
+ routeOf: (p) => p.route,
146
+ parentOf: (p) => parentPageOf(p, pages),
147
+ fetchOf: (p) => p.fetch ?? null,
148
+ sectionsOf: (p) => p.sections,
149
+ site: siteFetch,
150
+ })
120
151
  : null
152
+ const routeKey = chosen?.key ?? null
153
+ // A nested page's sections receive its capturing page's route binding, which the
154
+ // one-parent cascade may not reach — the entity store adds it the same way.
155
+ const routeSource = chosen?.nested ? chosen.config : null
121
156
 
122
157
  const out = new Map()
123
158
  const put = (cfg) => {
@@ -126,28 +161,47 @@ export function resolvePageFetchConfigs(content, route, { locale = null } = {})
126
161
  }
127
162
  const add = (sources) => {
128
163
  for (const cfg of resolveFetchConfigs(sources, options).values()) {
129
- put(cfg)
164
+ const current = routeKey && cfg.as === routeKey ? currentOf(cfg) : null
165
+ if (current === 'exclude') {
166
+ // the list one longer, from which the render removes the page's record
167
+ put(othersView(cfg))
168
+ continue
169
+ }
170
+ if (current !== 'only') {
171
+ // any other key, or `current: include` — the list as the binding describes it
172
+ put(cfg)
173
+ continue
174
+ }
130
175
  // ⭐ A parametric page is ABOUT one record, and on a lane with a per-record
131
176
  // source (the records service, a `deferred:` query's per-record file) that
132
177
  // record is a request of its own. Built for EVERY config the route key
133
178
  // resolves to — a section re-declaring the route query asks its own record
134
179
  // question — by the one rule the entity store uses (`buildDetailConfig`), so
135
180
  // the question prefetched is the question the render asks.
136
- if (routeKey && cfg.as === routeKey && cfg.detail && binding.paramValue !== undefined) {
181
+ if (cfg.detail && binding.paramValue !== undefined) {
137
182
  const detailCfg = buildDetailConfig(cfg, { paramName: binding.paramName, paramValue: String(binding.paramValue) })
138
183
  if (detailCfg) put(detailCfg)
139
184
  }
185
+ // On the records service the list is asked beside the record; off it, the
186
+ // record is FOUND in the route query's whole selection — the list without its
187
+ // `limit` (`routeSelection`) — which is all the render reads for it.
188
+ put(cfg.ask ? cfg : routeSelection(cfg))
140
189
  }
141
190
  }
142
- // The cascade a block sees: its own fetch (unless a refinement), page, parent, site.
143
- add([page.fetch ?? null, parent?.fetch ?? null, siteFetch])
191
+ // The cascade a block sees: its own fetch, page, parent, a nested page's route
192
+ // binding, and the site's — which reaches a top-level page's sections only.
193
+ const siteForPage = siteReaches(parent) ? siteFetch : null
194
+ add([page.fetch ?? null, parent?.fetch ?? null, routeSource, siteForPage])
144
195
  const walk = (sections) => {
145
196
  for (const s of sections || []) {
146
- if (s?.fetch && !isRefinement(s.fetch)) add([s.fetch, page.fetch ?? null, parent?.fetch ?? null, siteFetch])
197
+ if (s?.fetch) add([s.fetch, page.fetch ?? null, parent?.fetch ?? null, routeSource, siteForPage])
147
198
  if (s?.subsections) walk(s.subsections)
148
199
  }
149
200
  }
150
201
  walk(page.sections)
202
+ // ⭐ Layout sections belong to the site: the site's binding reaches them on every
203
+ // page, beside each one's own fetch. A layout area has no parent page.
204
+ for (const s of layoutSectionsFor(content, page)) add([s.fetch ?? null, siteFetch])
151
205
  return [...out.values()]
152
206
  }
153
207