@uniweb/runtime 0.22.0 → 0.24.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/dist/ssr.js +73 -69
- package/dist/ssr.js.map +1 -1
- package/package.json +3 -3
- package/src/collect-records.js +4 -3
- package/src/default-fetcher.js +74 -109
- package/src/prefetch.js +73 -18
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@uniweb/runtime",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.24.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.
|
|
39
|
+
"@uniweb/core": "^0.28.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
|
+
"@uniweb/build": "0.48.0"
|
|
48
48
|
},
|
|
49
49
|
"peerDependencies": {
|
|
50
50
|
"react": "^19.0.0",
|
package/src/collect-records.js
CHANGED
|
@@ -57,11 +57,12 @@ import { createDefaultFetcher } from './default-fetcher.js'
|
|
|
57
57
|
* @param {Function} options.fetch - the transport, `(url, init) => Response`
|
|
58
58
|
* @param {AbortSignal} [options.signal]
|
|
59
59
|
* @param {string[]} [options.only] - restrict to these query names
|
|
60
|
-
* @param {
|
|
61
|
-
*
|
|
62
|
-
* match body text the record's own detail page displays, and a reader who
|
|
60
|
+
* @param {boolean} [options.whole=false] - what to ask for. `false` is the brief a
|
|
61
|
+
* list shows; **`true` — whole records — is what an index wants**: a brief index
|
|
62
|
+
* cannot match body text the record's own detail page displays, and a reader who
|
|
63
63
|
* finds a word on the page and not in search meets the inconsistency two
|
|
64
64
|
* rankings would produce. The cost is the caller's and is bounded by `maxPages`.
|
|
65
|
+
* An external query is never collected — it has no records service to ask.
|
|
65
66
|
* @param {number} [options.maxPages] - the caller's own bound on the walk. The
|
|
66
67
|
* default is a bound, not a target; a caller that knows its per-request budget
|
|
67
68
|
* passes its own.
|
package/src/default-fetcher.js
CHANGED
|
@@ -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
|
|
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
|
-
*
|
|
34
|
-
*
|
|
35
|
-
* `
|
|
36
|
-
*
|
|
37
|
-
*
|
|
38
|
-
*
|
|
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,49 +193,23 @@ 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
|
-
|
|
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
|
|
224
|
-
//
|
|
225
|
-
|
|
226
|
-
|
|
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
|
|
234
209
|
// (filtering/sorting/limiting a single record doesn't make sense).
|
|
235
210
|
// For non-arrays, operators are ignored — the source returned what
|
|
236
211
|
// it returned.
|
|
237
|
-
data = applyOperators(data, request, { dev })
|
|
212
|
+
data = applyOperators(data, request, { dev, locale: sortLocale(request, ctx) })
|
|
238
213
|
|
|
239
214
|
// ⭐ Say whether WHOLE records were delivered, so the record index can
|
|
240
215
|
// file them — what the config asked for, echoed: briefs when the query
|
|
@@ -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
|
|
261
|
-
*
|
|
262
|
-
*
|
|
263
|
-
*
|
|
264
|
-
* never
|
|
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 =
|
|
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,41 +268,31 @@ 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
|
*
|
|
309
|
-
* The response is `{ data,
|
|
310
|
-
*
|
|
311
|
-
*
|
|
312
|
-
*
|
|
313
|
-
*
|
|
314
|
-
*
|
|
274
|
+
* The response is `{ data, whole?, cursors?, partial? }` (backend's records-query
|
|
275
|
+
* contract rev D, 2026-09-13), each map absent when empty: `data` answers exactly the
|
|
276
|
+
* keys sent — `[]` when nothing matched, and `[]` too for a question an author got
|
|
277
|
+
* wrong, since the service answers a mistake rather than refusing it; `whole[key]` says
|
|
278
|
+
* a key was served as whole records; `cursors[key]` that more records remain under the
|
|
279
|
+
* question's `limit`; `partial[key]` that a source of the key did not answer. A key
|
|
280
|
+
* missing from `data` is a protocol violation and is reported as an error, never as
|
|
281
|
+
* silence. ⛔ `errors` and `limits` are not read: the service stopped sending them in
|
|
282
|
+
* the same revision, and this client read both until 2026-09-14.
|
|
315
283
|
*
|
|
316
|
-
* ⭐ `cursors`
|
|
317
|
-
*
|
|
318
|
-
*
|
|
319
|
-
* the service
|
|
320
|
-
*
|
|
321
|
-
*
|
|
322
|
-
* a bound from the end of the data. **The silent class, on a visitor's page.**
|
|
284
|
+
* ⭐ `cursors` ARE READ — 2026-09-06 [Diego], reversing the ruling that had them
|
|
285
|
+
* "received and IGNORED, because framework has no paging concept." ⛔ **That ruling
|
|
286
|
+
* described our client and was silently wrong about our USERS:** a `cursors` entry is
|
|
287
|
+
* how the service says there is more, and discarding it meant a hosted list could
|
|
288
|
+
* render part of its records with no way for an author to tell that from the end of
|
|
289
|
+
* the data. **The silent class, on a visitor's page.**
|
|
323
290
|
*
|
|
324
291
|
* Two behaviours, deliberately not one:
|
|
325
292
|
*
|
|
326
|
-
* - **a page render REPORTS** — `meta.partial` and
|
|
327
|
-
*
|
|
328
|
-
*
|
|
329
|
-
* ten rows.
|
|
293
|
+
* - **a page render REPORTS** — `meta.partial` rides the answer, and nothing pages
|
|
294
|
+
* automatically. Auto-paging here would put unbounded round trips in front of
|
|
295
|
+
* paint for a section that may only show ten rows.
|
|
330
296
|
* - **an exhaustive caller PAGES** — `request.exhaustive` follows `cursors`
|
|
331
297
|
* until the service stops issuing them. `collectSiteRecords` is the caller
|
|
332
298
|
* that wants it (a corpus is not a page), and `maxPages` bounds the loop so
|
|
@@ -395,14 +361,14 @@ async function flushAsked(url, queue, doFetch) {
|
|
|
395
361
|
return
|
|
396
362
|
}
|
|
397
363
|
const data = parsed && typeof parsed.data === 'object' && parsed.data ? parsed.data : {}
|
|
398
|
-
const errors = parsed && typeof parsed.errors === 'object' && parsed.errors ? parsed.errors : {}
|
|
399
364
|
// ⭐ `whole[key]` is present only for keys delivered as WHOLE entities — so a
|
|
400
365
|
// key's ABSENCE is the brief, and a key present that never asked is the
|
|
401
366
|
// brief-less Model saying so. Absent entirely when every key is briefs.
|
|
402
367
|
const wholes = parsed && typeof parsed.whole === 'object' && parsed.whole ? parsed.whole : {}
|
|
403
|
-
// Both absent when empty, never `{}` (the records contract §5).
|
|
404
368
|
const cursors = parsed && typeof parsed.cursors === 'object' && parsed.cursors ? parsed.cursors : {}
|
|
405
|
-
|
|
369
|
+
// ⭐ `partial[key]` — `{ source, code, detail }`: a source of that key did not answer,
|
|
370
|
+
// and the key carries what the others returned. Unread, a degraded key looked complete.
|
|
371
|
+
const degraded = parsed && typeof parsed.partial === 'object' && parsed.partial ? parsed.partial : {}
|
|
406
372
|
queue.forEach((entry, i) => {
|
|
407
373
|
const key = keys[i]
|
|
408
374
|
// ⛔ A FAILURE MUST NOT DISCARD PAGES ALREADY COLLECTED. An exhaustive walk
|
|
@@ -410,21 +376,11 @@ async function flushAsked(url, queue, doFetch) {
|
|
|
410
376
|
// corpus would rather have them marked partial than lose the key — losing it
|
|
411
377
|
// is indistinguishable from "this query has no records".
|
|
412
378
|
const held = Array.isArray(entry.collected) && entry.collected.length ? entry.collected : null
|
|
413
|
-
if (key in errors) {
|
|
414
|
-
// A per-key error is `{ code, detail }` — `schema_not_found`,
|
|
415
|
-
// `field_not_in_brief`, `scope_not_found`… The sentence is `detail`; `code`
|
|
416
|
-
// rides beside it for a reader that wants to branch on it.
|
|
417
|
-
const e = errors[key]
|
|
418
|
-
const detail = typeof e === 'string' ? e : (e?.detail || e?.message || JSON.stringify(e))
|
|
419
|
-
const out = held
|
|
420
|
-
? { data: held, error: detail, meta: withMeta({ partial: true, pages: entry.page }) }
|
|
421
|
-
: { data: null, error: detail }
|
|
422
|
-
if (e && typeof e === 'object' && typeof e.code === 'string') out.code = e.code
|
|
423
|
-
entry.resolve(out)
|
|
424
|
-
return
|
|
425
|
-
}
|
|
426
379
|
if (!(key in data)) {
|
|
427
|
-
|
|
380
|
+
const error = `the records service answered without the key "${key}"`
|
|
381
|
+
entry.resolve(held
|
|
382
|
+
? { data: held, error, meta: withMeta({ partial: true, pages: entry.page }) }
|
|
383
|
+
: { data: null, error })
|
|
428
384
|
return
|
|
429
385
|
}
|
|
430
386
|
const whole = typeof wholes[key] === 'boolean'
|
|
@@ -432,7 +388,6 @@ async function flushAsked(url, queue, doFetch) {
|
|
|
432
388
|
: (typeof entry.request.whole === 'boolean' ? entry.request.whole : undefined)
|
|
433
389
|
|
|
434
390
|
const cursor = typeof cursors[key] === 'string' && cursors[key] ? cursors[key] : null
|
|
435
|
-
const bound = typeof limits[key] === 'number' ? limits[key] : undefined
|
|
436
391
|
const rows = Array.isArray(data[key]) ? data[key] : data[key]
|
|
437
392
|
|
|
438
393
|
// An exhaustive caller collects the page and asks for the next one.
|
|
@@ -443,32 +398,30 @@ async function flushAsked(url, queue, doFetch) {
|
|
|
443
398
|
? entry.request.maxPages
|
|
444
399
|
: DEFAULT_MAX_PAGES
|
|
445
400
|
if (page <= cap) {
|
|
446
|
-
pending.set(entry, { cursor, collected: acc, page, whole
|
|
401
|
+
pending.set(entry, { cursor, collected: acc, page, whole })
|
|
447
402
|
return
|
|
448
403
|
}
|
|
449
404
|
// The caller's own bound, not the service's: report rather than spin.
|
|
450
|
-
entry.resolve({ data: acc, meta: withMeta({ whole,
|
|
405
|
+
entry.resolve({ data: acc, meta: withMeta({ whole, partial: true, pages: cap }) })
|
|
451
406
|
return
|
|
452
407
|
}
|
|
453
408
|
|
|
454
409
|
const collected = entry.collected ? entry.collected.concat(Array.isArray(rows) ? rows : []) : rows
|
|
455
410
|
const meta = withMeta({
|
|
456
411
|
whole,
|
|
457
|
-
bound,
|
|
458
412
|
// ⭐ ONE FLAG, MEANING **NOT THE WHOLE POPULATION** — asked for by name, so
|
|
459
|
-
// a caller has one boolean to branch on rather than
|
|
413
|
+
// a caller has one boolean to branch on rather than several signals to
|
|
460
414
|
// combine. It is set in every case that means it:
|
|
461
415
|
// · a cursor came back and this caller does not page (a page render);
|
|
462
416
|
// · a cursor came back and the caller's `maxPages` stopped the walk;
|
|
463
417
|
// · the walk failed or aborted with pages already in hand;
|
|
464
|
-
// ·
|
|
465
|
-
//
|
|
466
|
-
//
|
|
467
|
-
//
|
|
468
|
-
//
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
partial: (cursor || bound !== undefined) ? true : undefined,
|
|
418
|
+
// · a source of the key did not answer (`partial[key]`), and `unavailable`
|
|
419
|
+
// carries the service's own account of which one.
|
|
420
|
+
// ⛔ Named `truncated` when it shipped in 0.17.0; renamed the same day, before
|
|
421
|
+
// any consumer adopted it, because "truncated" says something was cut and this
|
|
422
|
+
// also means "there is more you did not ask for".
|
|
423
|
+
partial: (cursor || key in degraded) ? true : undefined,
|
|
424
|
+
unavailable: key in degraded ? degraded[key] : undefined,
|
|
472
425
|
pages: entry.page && entry.page > 1 ? entry.page : undefined,
|
|
473
426
|
})
|
|
474
427
|
entry.resolve(meta ? { data: collected, meta } : { data: collected })
|
|
@@ -491,8 +444,8 @@ async function flushAsked(url, queue, doFetch) {
|
|
|
491
444
|
/**
|
|
492
445
|
* Read one response and parse its body — the part of a fetch every view of an
|
|
493
446
|
* 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
|
|
495
|
-
*
|
|
447
|
+
* and kept as text when it is not. A failed response keeps its text, so a caller
|
|
448
|
+
* can read a message out of it (the records service's problem details).
|
|
496
449
|
*/
|
|
497
450
|
async function readResponse(doFetch, target, init) {
|
|
498
451
|
const response = await doFetch(target, init)
|
|
@@ -522,12 +475,24 @@ function withMeta(fields) {
|
|
|
522
475
|
return Object.keys(out).length ? out : undefined
|
|
523
476
|
}
|
|
524
477
|
|
|
478
|
+
/**
|
|
479
|
+
* The locale a list's texts are collated in: the page's (ruled 2026-09-14 [Diego]).
|
|
480
|
+
* A request asked in a locale carries it; otherwise the caller's — the website's
|
|
481
|
+
* active locale in the browser, the locale a host's prerender renders.
|
|
482
|
+
*/
|
|
483
|
+
function sortLocale(request, ctx) {
|
|
484
|
+
if (typeof request?.locale === 'string' && request.locale) return request.locale
|
|
485
|
+
if (typeof ctx?.locale === 'string' && ctx.locale) return ctx.locale
|
|
486
|
+
const active = ctx?.website?.getActiveLocale?.()
|
|
487
|
+
return typeof active === 'string' && active ? active : null
|
|
488
|
+
}
|
|
489
|
+
|
|
525
490
|
/**
|
|
526
491
|
* Evaluate the query over what the source returned — the ONE evaluator,
|
|
527
492
|
* `@uniweb/core`'s, so the browser orders and filters exactly as the build
|
|
528
493
|
* did when it materialized `/data/<name>.json`.
|
|
529
494
|
*/
|
|
530
|
-
function applyOperators(data, request, { dev = false } = {}) {
|
|
495
|
+
function applyOperators(data, request, { dev = false, locale = null } = {}) {
|
|
531
496
|
if (!Array.isArray(data)) return data
|
|
532
497
|
let result = data
|
|
533
498
|
// `scope` first: it names the branch the rest of the query reads. On this lane
|
|
@@ -535,7 +500,7 @@ function applyOperators(data, request, { dev = false } = {}) {
|
|
|
535
500
|
// wrote the file — `scope: :dir` bound per page reaches here (2026-09-11).
|
|
536
501
|
if (typeof request.scope === 'string' && request.scope) result = applyScope(result, request.scope)
|
|
537
502
|
if (request.where) result = matchWhere(request.where, result)
|
|
538
|
-
if (request.sort) result = applySort(result, request.sort, dev)
|
|
503
|
+
if (request.sort) result = applySort(result, request.sort, dev, locale)
|
|
539
504
|
if (typeof request.limit === 'number' && request.limit > 0) result = result.slice(0, request.limit)
|
|
540
505
|
return result
|
|
541
506
|
}
|
|
@@ -548,9 +513,9 @@ function applyOperators(data, request, { dev = false } = {}) {
|
|
|
548
513
|
* logged once — a wrong order is not worth a broken page for a visitor.
|
|
549
514
|
*/
|
|
550
515
|
const warnedBadSorts = new Set()
|
|
551
|
-
function applySort(items, sortExpr, dev) {
|
|
516
|
+
function applySort(items, sortExpr, dev, locale) {
|
|
552
517
|
try {
|
|
553
|
-
return sortRecords(items, sortExpr)
|
|
518
|
+
return sortRecords(items, sortExpr, { locale })
|
|
554
519
|
} catch (err) {
|
|
555
520
|
if (dev) throw err
|
|
556
521
|
const key = String(sortExpr)
|
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,
|
|
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
|
|
94
|
-
*
|
|
95
|
-
* entity store uses, so a host prefetches
|
|
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,
|
|
117
|
-
//
|
|
118
|
-
|
|
119
|
-
|
|
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
|
-
|
|
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 (
|
|
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
|
|
143
|
-
|
|
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
|
|
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
|
|
|
@@ -162,7 +216,7 @@ export function resolvePageFetchConfigs(content, route, { locale = null } = {})
|
|
|
162
216
|
* @param {boolean} [opts.dev]
|
|
163
217
|
* @returns {Promise<Array<{ config: Object, outcome: 'fetched'|'failed'|'skipped', data: any, error?: string }>>}
|
|
164
218
|
*/
|
|
165
|
-
export async function executeFetchConfigs(configs, { content, fetch = null, dev = false, prerender = 'always' } = {}) {
|
|
219
|
+
export async function executeFetchConfigs(configs, { content, fetch = null, dev = false, prerender = 'always', locale = null } = {}) {
|
|
166
220
|
if (prerender !== 'author' && prerender !== 'always') {
|
|
167
221
|
throw new Error(`executeFetchConfigs: prerender must be 'author' or 'always', got ${JSON.stringify(prerender)}`)
|
|
168
222
|
}
|
|
@@ -171,7 +225,8 @@ export async function executeFetchConfigs(configs, { content, fetch = null, dev
|
|
|
171
225
|
dev,
|
|
172
226
|
fetch,
|
|
173
227
|
})
|
|
174
|
-
|
|
228
|
+
// The page's locale, which a list's texts are collated in when the runtime sorts it.
|
|
229
|
+
const ctx = { website: null, locale: locale ?? content?.config?.activeLocale ?? resolveDefaultLocale(content?.config) ?? null }
|
|
175
230
|
// Dispatched together, not one after another: the records service batches the
|
|
176
231
|
// requests issued in one tick into one POST, and a page's configs are
|
|
177
232
|
// independent of each other. Order is preserved in the result.
|
|
@@ -190,5 +245,5 @@ export async function executeFetchConfigs(configs, { content, fetch = null, dev
|
|
|
190
245
|
/** Resolve and execute in one call: what a host passes the isolate as `fetchedData`. */
|
|
191
246
|
export async function prefetchPageData({ content, route, locale = null, fetch = null, dev = false, prerender = 'always' }) {
|
|
192
247
|
const configs = resolvePageFetchConfigs(content, route, { locale })
|
|
193
|
-
return executeFetchConfigs(configs, { content, fetch, dev, prerender })
|
|
248
|
+
return executeFetchConfigs(configs, { content, fetch, dev, prerender, locale })
|
|
194
249
|
}
|