@uniweb/runtime 1.5.3 → 1.5.5

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.3",
3
+ "version": "1.5.5",
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.8",
39
+ "@uniweb/core": "~1.6.10",
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.18.1"
47
+ "@uniweb/build": "1.20.3"
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,13 @@ 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 which records each section showed, for the
86
+ * page's Markdown: `sections`, which `renderPageMarkdown(page, { sections })`
87
+ * (`@uniweb/projections`) writes after each section. Read from the store this render read, after
88
+ * its HTML (`section-records.js`), so a render that does not ask does no more work than before.
84
89
  * @returns {{ outcome: 'rendered'|'notFound'|'failed', html: string|null,
85
- * page: Object|null, error: {type: string, message: string}|null }}
90
+ * page: Object|null, error: {type: string, message: string}|null,
91
+ * sections?: Array<{ id: string, data: Object }> }} `sections` when asked, on a page with `html`
86
92
  * `notFound` means the URL names nothing, and `html` says whether there is a page to
87
93
  * send for it. Send `html` with your not-found status when it is set, and the shell
88
94
  * when it is not:
@@ -95,7 +101,7 @@ export function createPageRenderer({ website, shell }) {
95
101
  * - `page` set, `html: null`, `error` set — that page broke while rendering; `error`
96
102
  * says what broke.
97
103
  */
98
- function render(target, { inject = {}, fetchedData } = {}) {
104
+ function render(target, { inject = {}, fetchedData, sectionData = false } = {}) {
99
105
  if (fetchedData !== undefined && !Array.isArray(fetchedData)) {
100
106
  throw new Error('createPageRenderer: `fetchedData` must be the array of entries the page carries')
101
107
  }
@@ -181,7 +187,10 @@ export function createPageRenderer({ website, shell }) {
181
187
  sectionOverrideCSS: result.sectionOverrideCSS,
182
188
  icons,
183
189
  })
184
- return { outcome: missing ? 'notFound' : 'rendered', html, page, error: null }
190
+ const rendered = { outcome: missing ? 'notFound' : 'rendered', html, page, error: null }
191
+ // In this same synchronous call, so the store holds this page's answers and no other request's.
192
+ if (sectionData) rendered.sections = sectionRecords(page)
193
+ return rendered
185
194
  }
186
195
 
187
196
  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,120 @@
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 (`renderPageMarkdown`'s `record`); 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 } 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, read after the page's render.
42
+ *
43
+ * @param {Object} page - the Page just rendered, its Website the one `globalThis.uniweb` names
44
+ * @returns {Array<{ id: string, data: Object }>} in page order, one entry per section, child section
45
+ * or inset that showed any, `id` the section's DOM id (`sectionDomId`) and `data` its records by name
46
+ */
47
+ export function sectionRecords(page) {
48
+ const out = []
49
+ for (const block of page?.getBodyBlocks?.() ?? []) visit(block, sectionDomId(block), out)
50
+ return out
51
+ }
52
+
53
+ function visit(block, id, out) {
54
+ if (!block) return
55
+ const data = recordsOf(block)
56
+ if (data) out.push({ id, data })
57
+ for (const inset of block.insets || []) visit(inset, id, out)
58
+ for (const child of block.childBlocks || []) visit(child, sectionDomId(child), out)
59
+ }
60
+
61
+ /** One block's records by name, or null when it showed none. */
62
+ function recordsOf(block) {
63
+ // A type the foundation doesn't have renders as an error box, and its render asks for no data.
64
+ if (!globalThis.uniweb?.getComponent?.(block.type)) return null
65
+ const meta = getComponentMeta(block.type)
66
+ const own = declaredKeys(meta?.data)
67
+ if (own.length === 0) return null
68
+
69
+ // The resolve the render just made, from the same store: `renderBlock` (`ssr-renderer.js`).
70
+ const resolved = block.website?.entityStore?.resolve(block, meta)
71
+ const { data } = receivedData(block, meta, resolved?.status === 'ready' ? resolved.data : null)
72
+
73
+ const held = block.heldData || {}
74
+ let fills = null
75
+ let route = null
76
+ let tagged = null
77
+ const out = {}
78
+ for (const [key] of own) {
79
+ const value = data[key]
80
+ if (value === undefined || value === null) continue
81
+ let name
82
+ if (held[key] !== undefined) {
83
+ tagged ??= taggedKeys(block)
84
+ if (tagged.has(key)) continue
85
+ // An answer a static build prerendered into the section, under its fetch's `as`.
86
+ name = key
87
+ } else {
88
+ if (!fills) {
89
+ route = blockRouteQuery(block)
90
+ fills = blockFillsOf(block, meta, route)
91
+ }
92
+ const cfg = fills.get(key)?.cfg
93
+ if (!cfg?.as || fillIsPageRecord(block, cfg, route)) continue
94
+ name = cfg.as
95
+ }
96
+ if (Object.hasOwn(out, name) && !sameValue(out[name], value)) name = key
97
+ if (Object.hasOwn(out, name) && !sameValue(out[name], value)) continue
98
+ out[name] = value
99
+ }
100
+ return Object.keys(out).length ? out : null
101
+ }
102
+
103
+ /**
104
+ * The keys a section's own content holds as tagged blocks: its content parsed alone, without the
105
+ * answers a build put beside it (`Block`'s constructor merges the two). `parseContent` is pure, and
106
+ * this runs only for a section holding a declared key.
107
+ */
108
+ function taggedKeys(block) {
109
+ const parsed = typeof block.parseContent === 'function' && block.rawContent ? block.parseContent(block.rawContent) : null
110
+ return new Set(Object.keys(parsed?.data || {}))
111
+ }
112
+
113
+ function sameValue(a, b) {
114
+ if (a === b) return true
115
+ try {
116
+ return JSON.stringify(a) === JSON.stringify(b)
117
+ } catch {
118
+ return false
119
+ }
120
+ }