@uniweb/runtime 1.5.4 → 1.5.6

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": "1.5.4",
3
+ "version": "1.5.6",
4
4
  "description": "Minimal runtime for loading Uniweb foundations",
5
5
  "type": "module",
6
6
  "exports": {
@@ -36,7 +36,7 @@
36
36
  "node": ">=22.12"
37
37
  },
38
38
  "dependencies": {
39
- "@uniweb/core": "~1.6.9",
39
+ "@uniweb/core": "~1.6.11",
40
40
  "@uniweb/theming": "~1.3.2"
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 || ^0.28.0",
45
45
  "vite": "^7.3.1",
46
46
  "vitest": "^4.1.7",
47
- "@uniweb/build": "1.19.0"
47
+ "@uniweb/build": "1.20.4"
48
48
  },
49
49
  "peerDependencies": {
50
50
  "react": "^19.0.0",
@@ -39,6 +39,7 @@ import { hydrateDataStore } from './wire-foundation.js'
39
39
  import { loadPageData } from './load-page-data.js'
40
40
  import { requireTransport } from './require-transport.js'
41
41
  import { whileReadingIcons } from './page-icons.js'
42
+ import { sectionRecords } from './section-records.js'
42
43
 
43
44
  /**
44
45
  * A renderer bound to one initialized Website and one shell.
@@ -81,8 +82,15 @@ export function createPageRenderer({ website, shell }) {
81
82
  * parametric pages made from them (`Website#forgetData`), and files these, all in this
82
83
  * synchronous call, so a Website reused across requests renders each against its own and
83
84
  * no other request runs in between. Absent, the render reads whatever the store holds.
85
+ * @param {boolean} [options.sectionData] - also say what the page's Markdown carries:
86
+ * `sections`, the records each section showed, and on a record page `record`, its own record as
87
+ * this render read it. Hand both to `renderPageMarkdown(page, { record, sections })`
88
+ * (`@uniweb/projections`). Read from the store this render read, after its HTML
89
+ * (`section-records.js`), so a render that does not ask does no more work than before.
84
90
  * @returns {{ outcome: 'rendered'|'notFound'|'failed', html: string|null,
85
- * page: Object|null, error: {type: string, message: string}|null }}
91
+ * page: Object|null, error: {type: string, message: string}|null,
92
+ * sections?: Array<{ id: string, data: Object }>, record?: Object|null }} `sections` when asked,
93
+ * on a page with `html`; `record` beside it on a record page, null when its route names none
86
94
  * `notFound` means the URL names nothing, and `html` says whether there is a page to
87
95
  * send for it. Send `html` with your not-found status when it is set, and the shell
88
96
  * when it is not:
@@ -95,7 +103,7 @@ export function createPageRenderer({ website, shell }) {
95
103
  * - `page` set, `html: null`, `error` set — that page broke while rendering; `error`
96
104
  * says what broke.
97
105
  */
98
- function render(target, { inject = {}, fetchedData } = {}) {
106
+ function render(target, { inject = {}, fetchedData, sectionData = false } = {}) {
99
107
  if (fetchedData !== undefined && !Array.isArray(fetchedData)) {
100
108
  throw new Error('createPageRenderer: `fetchedData` must be the array of entries the page carries')
101
109
  }
@@ -181,7 +189,14 @@ export function createPageRenderer({ website, shell }) {
181
189
  sectionOverrideCSS: result.sectionOverrideCSS,
182
190
  icons,
183
191
  })
184
- return { outcome: missing ? 'notFound' : 'rendered', html, page, error: null }
192
+ const rendered = { outcome: missing ? 'notFound' : 'rendered', html, page, error: null }
193
+ // In this same synchronous call, so the store holds this page's answers and no other request's.
194
+ if (sectionData) {
195
+ const shown = sectionRecords(page)
196
+ rendered.sections = shown.sections
197
+ if (shown.record !== undefined) rendered.record = shown.record
198
+ }
199
+ return rendered
185
200
  }
186
201
 
187
202
  return { website, render, failingSection: (page, message) => failingSectionOf(page, message) }
@@ -142,7 +142,21 @@ export function applyDefaults(params, defaults) {
142
142
  * section received every fetch that reached it and every tagged block it had.
143
143
  */
144
144
  function assembleData(block, meta, entityData) {
145
- const declared = block.website?.declaredKeys ? block.website.declaredKeys(meta) : []
145
+ const declared = declaredOf(block, meta)
146
+ const data = collectDeclared(block, declared, entityData)
147
+ // What the section holds and this leaves out — the keys the dev warning reports.
148
+ warnUndeclaredBlocks(block, Object.keys(block.heldData || {}).filter((key) => !Object.hasOwn(data, key)))
149
+ block.parsedContent.data = data
150
+ return declared
151
+ }
152
+
153
+ /** The keys a block's component receives: its own `data:`, then its foundation's. */
154
+ function declaredOf(block, meta) {
155
+ return block.website?.declaredKeys ? block.website.declaredKeys(meta) : []
156
+ }
157
+
158
+ /** Each declared key's value, by the order `assembleData` states: held, delivered, else `null`. */
159
+ function collectDeclared(block, declared, entityData) {
146
160
  const held = block.heldData || {}
147
161
  const data = {}
148
162
  for (const [key] of declared) {
@@ -150,10 +164,27 @@ function assembleData(block, meta, entityData) {
150
164
  else if (entityData && entityData[key] !== undefined) data[key] = entityData[key]
151
165
  else data[key] = null
152
166
  }
153
- // What the section holds and this leaves out — the keys the dev warning reports.
154
- warnUndeclaredBlocks(block, Object.keys(held).filter((key) => !Object.hasOwn(data, key)))
155
- block.parsedContent.data = data
156
- return declared
167
+ return data
168
+ }
169
+
170
+ /**
171
+ * ⭐ WHAT A SECTION'S COMPONENT RECEIVES UNDER THE KEYS IT DECLARES, BEFORE ANY FOUNDATION HANDLER —
172
+ * `content.data` as `prepareProps` assembles it, its records linked (`linkOwnRecords`) and a
173
+ * `single` key holding one, worked out without touching the block. For a caller reporting what a
174
+ * section received, after its render: a page's Markdown (`section-records.js`). ⛔ Never for
175
+ * rendering, which is `prepareProps`'.
176
+ *
177
+ * @param {Object} block
178
+ * @param {Object|null} meta - its component's runtime meta
179
+ * @param {Object|null} [entityData] - what the entity store delivered for it, as the render read it
180
+ * @returns {{ declared: Array, data: Object }}
181
+ */
182
+ export function receivedData(block, meta, entityData = null) {
183
+ const declared = declaredOf(block, meta)
184
+ // A stand-in carries the data through the linking, so the block's own is left as its render left it.
185
+ const probe = { parsedContent: { data: collectDeclared(block, declared, entityData) }, fetch: block.fetch, website: block.website }
186
+ block.website?.entityStore?.linkOwnRecords?.(probe)
187
+ return { declared, data: oneRecordEach(probe.parsedContent.data, declared) }
157
188
  }
158
189
 
159
190
  /**
@@ -168,14 +199,18 @@ function assembleData(block, meta, entityData) {
168
199
  * object is replaced, never written into.
169
200
  */
170
201
  function holdOneRecord(block, declared) {
171
- const data = block.parsedContent.data
202
+ block.parsedContent.data = oneRecordEach(block.parsedContent.data, declared)
203
+ }
204
+
205
+ /** `data` with each `single` key's list cut to its first record, or `data` itself when none is. */
206
+ function oneRecordEach(data, declared) {
172
207
  let out = null
173
208
  for (const [key, , , single] of declared) {
174
209
  if (single !== true || !Array.isArray(data?.[key])) continue
175
210
  out = out || { ...data }
176
211
  out[key] = data[key][0] ?? null
177
212
  }
178
- if (out) block.parsedContent.data = out
213
+ return out || data
179
214
  }
180
215
 
181
216
  /**
@@ -0,0 +1,160 @@
1
+ /**
2
+ * WHAT A PAGE'S SECTIONS SHOWED FROM RECORDS — for the page's Markdown, never for its render.
3
+ *
4
+ * ⭐ A page's Markdown (`renderPageMarkdown`, `@uniweb/projections`) carries, after each section, the
5
+ * records that section shows. Which records those are only the render knows: the keys its component
6
+ * declares, each filled by the one rule (`@uniweb/core/page-data`), as briefs or whole, one record or
7
+ * the set, with this page's record or without it. A rule over the site's content alone would get each
8
+ * of those wrong, and a host's prefetch retired that rule for the same reasons (`load-page-data.js`).
9
+ * So they are read back from the store the render has just read, by the reading it filled each section
10
+ * by, after the HTML: in L2, with no twin to keep (L3 does not change), and nowhere in the browser's
11
+ * bundle, since only `page-renderer.js` asks and only the SSR entry exports it.
12
+ *
13
+ * A section's records:
14
+ *
15
+ * - **The keys its component declares** in its own `data:`. Not its foundation's (`main.js` `data:`),
16
+ * which reach every section, since a foundation's handlers run for every section, and which of
17
+ * them draws one is the foundation's business.
18
+ * - **As delivered**, before any foundation handler (`receivedData`, `./prepare-props.js`): the
19
+ * site's records, never a foundation's derivation of them.
20
+ * - **Named by the site's word:** the fetch's `as`, which is its query's name unless the site gives
21
+ * another. A component's key is the foundation's word, and a fetch can fill a key of another name
22
+ * that has its schema (`fillDeclaredKeys`). A held answer is under its fetch's `as` already. Two
23
+ * keys of one section filled under one `as` with different records: the second goes by its key.
24
+ * - **Left out:** a key the section holds as a tagged block in its own content (` ```yaml:tag `, or
25
+ * a concept block), which the page's Markdown already shows as it was written; a record page's
26
+ * own record (a key whose fetch uses this page's record, `current: only`), which its Markdown
27
+ * writes once, as the page's, from `record` (`pageRecordOf`); and a key with no value, `null` —
28
+ * nothing fills it, or its fetch failed, never ran or is still out.
29
+ * - **Whose:** each body section and its child sections, under its own id, and the insets in a
30
+ * section, under the id of the section they sit in, since a page's Markdown has no place of their
31
+ * own for them. Not the layout's areas, whose own content the page's Markdown doesn't carry either.
32
+ *
33
+ * @module
34
+ */
35
+ import { sectionDomId } from '@uniweb/core/section-id'
36
+ import { declaredKeys } from '@uniweb/core/data-keys'
37
+ import { blockFillsOf, blockRouteQuery, fillIsPageRecord, heldPageRecord } from '@uniweb/core/page-data'
38
+ import { getComponentMeta, receivedData } from './prepare-props.js'
39
+
40
+ /**
41
+ * The records each of a page's sections showed, and a record page's own record, read after the
42
+ * page's render: what its Markdown carries (`renderPageMarkdown(page, { record, sections })`).
43
+ *
44
+ * @param {Object} page - the Page just rendered, its Website the one `globalThis.uniweb` names
45
+ * @returns {{ sections: Array<{ id: string, data: Object }>, record: Object|null|undefined }}
46
+ * `sections` in page order, one entry per section, child section or inset that showed any, `id`
47
+ * the section's DOM id (`sectionDomId`) and `data` its records by name. `record` a record page's
48
+ * own (`pageRecordOf`): null when its route names none, undefined off a record page
49
+ */
50
+ export function sectionRecords(page) {
51
+ const sections = []
52
+ // The page's own record as its sections received it, which `sections` leaves out.
53
+ const shown = []
54
+ for (const block of page?.getBodyBlocks?.() ?? []) visit(block, sectionDomId(block), sections, shown)
55
+ return { sections, record: pageRecordOf(page, shown) }
56
+ }
57
+
58
+ function visit(block, id, sections, shown) {
59
+ if (!block) return
60
+ const data = recordsOf(block, shown)
61
+ if (data) sections.push({ id, data })
62
+ for (const inset of block.insets || []) visit(inset, id, sections, shown)
63
+ for (const child of block.childBlocks || []) visit(child, sectionDomId(child), sections, shown)
64
+ }
65
+
66
+ /**
67
+ * ⭐ A RECORD PAGE'S OWN RECORD, AS ITS RENDER READ IT — the most of it the page held, so its
68
+ * Markdown carries what its sections drew: whole when a section received it whole, or when the
69
+ * record index holds it whole; else as a section received it; else the answer the page's own
70
+ * question got (`heldPageRecord`, `@uniweb/core/page-data`), which names the page and says whether
71
+ * it exists.
72
+ *
73
+ * @param {Object} page
74
+ * @param {Array<{ value: *, whole: boolean }>} shown
75
+ * @returns {Object|null|undefined}
76
+ */
77
+ function pageRecordOf(page, shown) {
78
+ if (!page?.dynamicContext) return undefined
79
+ const one = (value) => (Array.isArray(value) ? (value[0] ?? null) : value)
80
+ const whole = shown.find((s) => s.whole && one(s.value))
81
+ if (whole) return one(whole.value)
82
+ const website = page.website
83
+ const fetcher = website?.fetcher
84
+ const own = heldPageRecord(page, {
85
+ website,
86
+ peek: (request) => fetcher?.peek?.(request, { website, page, block: null }),
87
+ peekRecord: (id) => fetcher?.peekRecord?.(id),
88
+ })
89
+ const seen = shown.map((s) => one(s.value)).find(Boolean) ?? null
90
+ const id = (own ?? seen)?.$uuid
91
+ const held = typeof id === 'string' && id ? fetcher?.peekRecord?.(id) : null
92
+ if (held?.whole === true && held.record) return held.record
93
+ return seen ?? own
94
+ }
95
+
96
+ /** One block's records by name, or null when it showed none. */
97
+ function recordsOf(block, shown) {
98
+ // A type the foundation doesn't have renders as an error box, and its render asks for no data.
99
+ if (!globalThis.uniweb?.getComponent?.(block.type)) return null
100
+ const meta = getComponentMeta(block.type)
101
+ const own = declaredKeys(meta?.data)
102
+ if (own.length === 0) return null
103
+
104
+ // The resolve the render just made, from the same store: `renderBlock` (`ssr-renderer.js`).
105
+ const resolved = block.website?.entityStore?.resolve(block, meta)
106
+ const { data } = receivedData(block, meta, resolved?.status === 'ready' ? resolved.data : null)
107
+
108
+ const held = block.heldData || {}
109
+ let fills = null
110
+ let route = null
111
+ let tagged = null
112
+ const out = {}
113
+ for (const [key] of own) {
114
+ const value = data[key]
115
+ if (value === undefined || value === null) continue
116
+ let name
117
+ if (held[key] !== undefined) {
118
+ tagged ??= taggedKeys(block)
119
+ if (tagged.has(key)) continue
120
+ // An answer a static build prerendered into the section, under its fetch's `as`.
121
+ name = key
122
+ } else {
123
+ if (!fills) {
124
+ route = blockRouteQuery(block)
125
+ fills = blockFillsOf(block, meta, route)
126
+ }
127
+ const fill = fills.get(key)
128
+ const cfg = fill?.cfg
129
+ if (!cfg?.as) continue
130
+ if (fillIsPageRecord(block, cfg, route)) {
131
+ shown.push({ value, whole: fill.whole === true })
132
+ continue
133
+ }
134
+ name = cfg.as
135
+ }
136
+ if (Object.hasOwn(out, name) && !sameValue(out[name], value)) name = key
137
+ if (Object.hasOwn(out, name) && !sameValue(out[name], value)) continue
138
+ out[name] = value
139
+ }
140
+ return Object.keys(out).length ? out : null
141
+ }
142
+
143
+ /**
144
+ * The keys a section's own content holds as tagged blocks: its content parsed alone, without the
145
+ * answers a build put beside it (`Block`'s constructor merges the two). `parseContent` is pure, and
146
+ * this runs only for a section holding a declared key.
147
+ */
148
+ function taggedKeys(block) {
149
+ const parsed = typeof block.parseContent === 'function' && block.rawContent ? block.parseContent(block.rawContent) : null
150
+ return new Set(Object.keys(parsed?.data || {}))
151
+ }
152
+
153
+ function sameValue(a, b) {
154
+ if (a === b) return true
155
+ try {
156
+ return JSON.stringify(a) === JSON.stringify(b)
157
+ } catch {
158
+ return false
159
+ }
160
+ }