@uniweb/runtime 0.28.9 → 0.29.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.28.9",
3
+ "version": "0.29.0",
4
4
  "description": "Minimal runtime for loading Uniweb foundations",
5
5
  "type": "module",
6
6
  "exports": {
@@ -36,15 +36,15 @@
36
36
  "node": ">=20.19"
37
37
  },
38
38
  "dependencies": {
39
- "@uniweb/core": "^0.35.4",
40
- "@uniweb/theming": "^0.2.0"
39
+ "@uniweb/theming": "^0.2.0",
40
+ "@uniweb/core": "^0.37.0"
41
41
  },
42
42
  "devDependencies": {
43
43
  "@vitejs/plugin-react": "^4.5.2",
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.74.4"
47
+ "@uniweb/build": "0.75.1"
48
48
  },
49
49
  "peerDependencies": {
50
50
  "react": "^19.0.0",
@@ -74,9 +74,10 @@ export const UNRELEASED = 'UNRELEASED'
74
74
 
75
75
  /** Every export of `@uniweb/runtime/ssr`, with the version it first shipped in. */
76
76
  export const ISOLATE_API = Object.freeze({
77
- // props preparation
77
+ // props preparation. ⛔ `applySchemas` was here (0.2.15) until 2026-10-05, when the runtime
78
+ // stopped filling `content.data` fields from schema defaults; no host imported it (checked
79
+ // that day).
78
80
  prepareProps: '0.2.15',
79
- applySchemas: '0.2.15',
80
81
  applyDefaults: '0.2.15',
81
82
  guaranteeContentStructure: '0.2.15',
82
83
  getComponentMeta: '0.2.15',
@@ -4,13 +4,11 @@
4
4
  * Prepares props for foundation components with:
5
5
  * - Param defaults from runtime schema
6
6
  * - Guaranteed content structure (no null checks needed)
7
- * - Field defaults applied to `content.data` items from the bound schemas
7
+ * - `content.data` holding the keys the component declares, each as it arrived
8
8
  *
9
9
  * This enables simpler component code by ensuring predictable prop shapes.
10
10
  */
11
11
 
12
- import { isRichSchema } from '@uniweb/core'
13
-
14
12
  /**
15
13
  * Guarantee item has flat content structure
16
14
  *
@@ -104,211 +102,6 @@ export function guaranteeContentStructure(parsedContent) {
104
102
  }
105
103
  }
106
104
 
107
- /**
108
- * Apply a schema to a single object
109
- * Only processes fields defined in the schema, preserves unknown fields
110
- *
111
- * @param {Object} obj - The object to process
112
- * @param {Object} schema - Schema definition (fieldName -> fieldDef)
113
- * @returns {Object} Object with schema defaults applied
114
- */
115
- function applySchemaToObject(obj, schema) {
116
- if (!obj || typeof obj !== 'object' || Array.isArray(obj)) {
117
- return obj
118
- }
119
-
120
- const result = { ...obj }
121
-
122
- for (const [field, fieldDef] of Object.entries(schema)) {
123
- // Get the default value - handle both shorthand and full form
124
- const defaultValue = typeof fieldDef === 'object' ? fieldDef.default : undefined
125
-
126
- // Apply default if field is missing and default exists
127
- if (result[field] === undefined && defaultValue !== undefined) {
128
- result[field] = defaultValue
129
- }
130
-
131
- // Bare type strings ('string', 'decimal', …) carry nothing more to apply.
132
- if (typeof fieldDef !== 'object') continue
133
-
134
- // Inline picklist (`enum`): if the value is set but not among the allowed
135
- // values, fall back to the default. By value: an entry may be `{ value, label }`, as a
136
- // foundation built before its build lowered it to values still carries.
137
- if (Array.isArray(fieldDef.enum)) {
138
- const allowed = fieldDef.enum.map((e) => (e && typeof e === 'object' && 'value' in e ? e.value : e))
139
- if (result[field] !== undefined && !allowed.includes(result[field]) && defaultValue !== undefined) {
140
- result[field] = defaultValue
141
- }
142
- }
143
-
144
- // Nested object → recurse into its field map.
145
- if (fieldDef.type === 'object' && fieldDef.fields && result[field]) {
146
- result[field] = applySchemaToObject(result[field], fieldDef.fields)
147
- }
148
-
149
- // Array of objects → apply the element field map to each item.
150
- if (fieldDef.type === 'array' && fieldDef.items && Array.isArray(result[field])) {
151
- const items = fieldDef.items
152
- if (items && typeof items === 'object' && items.type === 'object' && items.fields) {
153
- result[field] = result[field].map((item) => applySchemaToObject(item, items.fields))
154
- }
155
- }
156
- }
157
-
158
- return result
159
- }
160
-
161
- /**
162
- * Apply a schema to a value (object or array of objects)
163
- *
164
- * @param {Object|Array} value - The value to process
165
- * @param {Object} schema - Schema definition
166
- * @returns {Object|Array} Value with schema defaults applied
167
- */
168
- function applySchemaToValue(value, schema) {
169
- if (Array.isArray(value)) {
170
- return value.map(item => applySchemaToObject(item, schema))
171
- }
172
- return applySchemaToObject(value, schema)
173
- }
174
-
175
- /**
176
- * Apply field defaults from a rich form `fields` array to an object.
177
- *
178
- * Recurses into `type: 'form'` (composite arrays with childSchema) and
179
- * `type: 'nestedObject'` / `type: 'object'` (single nested objects).
180
- *
181
- * Conditional visibility (`field.condition`) is not yet applied here —
182
- * components receive all fields the author filled plus defaults; hiding
183
- * is a later pass that requires the shared evaluateCondition util.
184
- *
185
- * @param {Object} obj - Row data (object keyed by field id)
186
- * @param {Array} fields - Rich field definitions
187
- * @returns {Object} - obj with defaults filled in
188
- */
189
- function applyRichFieldDefaults(obj, fields) {
190
- if (!obj || typeof obj !== 'object' || Array.isArray(obj)) return obj
191
- if (!Array.isArray(fields)) return obj
192
-
193
- const result = { ...obj }
194
-
195
- for (const field of fields) {
196
- if (!field || typeof field !== 'object' || !field.id) continue
197
- const id = field.id
198
-
199
- if (result[id] === undefined && field.default !== undefined) {
200
- result[id] = field.default
201
- }
202
-
203
- if (field.type === 'form' && field.childSchema && Array.isArray(result[id])) {
204
- result[id] = result[id].map(item =>
205
- applyRichFieldDefaults(item, field.childSchema.fields)
206
- )
207
- } else if (
208
- (field.type === 'nestedObject' || field.type === 'object') &&
209
- Array.isArray(field.fields) &&
210
- result[id] &&
211
- typeof result[id] === 'object'
212
- ) {
213
- result[id] = applyRichFieldDefaults(result[id], field.fields)
214
- }
215
- }
216
-
217
- return result
218
- }
219
-
220
- /**
221
- * Apply a rich form schema to its stored value.
222
- *
223
- * Shape rules:
224
- * - composite (isComposite=true) → value is array of childSchema rows
225
- * - when `childRecords` is set, value may be `{ [childRecords]: [...] }`
226
- * - non-composite → value is a single object keyed by field id
227
- */
228
- function applyRichSchemaToValue(value, schema) {
229
- if (value == null) return value
230
-
231
- if (schema.isComposite && schema.childSchema) {
232
- const childFields = schema.childSchema.fields
233
- const queryKey = schema.childRecords
234
-
235
- if (queryKey && value && typeof value === 'object' && !Array.isArray(value)) {
236
- const arr = Array.isArray(value[queryKey]) ? value[queryKey] : []
237
- return {
238
- ...value,
239
- [queryKey]: arr.map(row => applyRichFieldDefaults(row, childFields)),
240
- }
241
- }
242
-
243
- if (Array.isArray(value)) {
244
- return value.map(row => applyRichFieldDefaults(row, childFields))
245
- }
246
-
247
- return value
248
- }
249
-
250
- if (Array.isArray(schema.fields)) {
251
- return applyRichFieldDefaults(value, schema.fields)
252
- }
253
-
254
- return value
255
- }
256
-
257
- /**
258
- * Apply schemas to content.data
259
- * Only processes tags that have a matching schema, leaves others untouched
260
- *
261
- * ## Two orders of schema — what a `data:` declaration may describe
262
- *
263
- * A component's `data:` is 1st order: a DEVELOPER says what shape the section
264
- * consumes. An authored form (```yaml:form```) is 2nd order: an AUTHOR says what
265
- * shape a VISITOR will submit. It is schema-shaped, but it is content.
266
- *
267
- * Declaring a schema for such a tag is legitimate, and it is worth being precise
268
- * about what it may describe:
269
- *
270
- * OK the DEFINITION's envelope — `title?`, `description?`, `fields: <map>`.
271
- * That asks "is this a well-formed form?", which is what build-time
272
- * validation is for (`build/src/validate-data.js` pairs a section's data
273
- * input with the schema its meta.js binds to that key).
274
- * WRONG a schema whose fields are THE FORM'S fields (`name`, `email`, …).
275
- * Those are author-chosen and unknowable at build time. A form-rendering
276
- * component receives its fields; it does not declare them.
277
- *
278
- * The mechanism is bounded and does not punish the mistake loudly:
279
- * `applySchemaToObject` recurses only where the schema declares structure
280
- * (`type: object` + `fields`, `type: array` + `items.fields`), so a
281
- * form-definition schema — which cannot name the author's fields — can never
282
- * reach into them. It fills the envelope defaults its own author declared.
283
- *
284
- * (Established with the editor team, 2026-07-31, channel frontend↔framework.
285
- * The editor shadows a foundation's `form` declaration with its own builder via
286
- * `builtinSchemas()`; that is about the EDITING UI and is orthogonal to whether a
287
- * foundation declares a schema for validation.)
288
- *
289
- * @param {Object} data - The data object from content
290
- * @param {Object} schemas - Schema definitions from runtime meta
291
- * @returns {Object} Data with schemas applied
292
- */
293
- export function applySchemas(data, schemas) {
294
- if (!schemas || !data || typeof data !== 'object') {
295
- return data || {}
296
- }
297
-
298
- const result = { ...data }
299
-
300
- for (const [tag, rawValue] of Object.entries(data)) {
301
- const schema = schemas[tag]
302
- if (!schema) continue // No schema for this tag - leave as-is
303
-
304
- result[tag] = isRichSchema(schema)
305
- ? applyRichSchemaToValue(rawValue, schema)
306
- : applySchemaToValue(rawValue, schema)
307
- }
308
-
309
- return result
310
- }
311
-
312
105
  /**
313
106
  * Apply param defaults from runtime schema
314
107
  *
@@ -426,8 +219,18 @@ function runDataHandler(block) {
426
219
  * Skipped when the block is still waiting on async data
427
220
  * (`block.dataLoading`), when no handler is registered, when the
428
221
  * block has no raw content, when the handler returns a no-change
429
- * signal (undefined, null, or the same reference as rawContent), or
430
- * when the handler throws. Errors are logged via `console.error`.
222
+ * signal (undefined, null, or the same reference as rawContent), when
223
+ * it returns anything but a ProseMirror document, or when the handler
224
+ * throws. Errors are logged via `console.error`.
225
+ *
226
+ * ⛔ ONLY A DOCUMENT IS RE-PARSED (2026-10-05). The handler is given the section's
227
+ * DATA, so the natural no-op — `(x) => x` — returns the data map. That is not
228
+ * `rawContent`, so it passed the no-change check; `parseContent` takes a plain
229
+ * object as content already parsed; and the section's content became its data
230
+ * map — title, paragraphs and items gone — with `data.data` pointing back at
231
+ * itself, so a `JSON.stringify(content.data)` threw far from the handler (and
232
+ * failed a static build's prerender of the page). Any result that is not a
233
+ * document is now ignored, and said once per handler.
431
234
  */
432
235
  function runContentHandler(block) {
433
236
  if (block.dataLoading) return
@@ -438,6 +241,10 @@ function runContentHandler(block) {
438
241
  try {
439
242
  const transformed = handler(block.parsedContent.data, block)
440
243
  if (!transformed || transformed === block.rawContent) return
244
+ if (!isDocument(transformed)) {
245
+ warnNotADocument(handler, transformed, block.parsedContent.data)
246
+ return
247
+ }
441
248
  const reparsed = block.parseContent(transformed)
442
249
  reparsed.data = block.parsedContent.data
443
250
  block.parsedContent = reparsed
@@ -447,11 +254,37 @@ function runContentHandler(block) {
447
254
  }
448
255
  }
449
256
 
257
+ /** A ProseMirror document — bare, or wrapped as `{ doc }`, the two forms `rawContent` takes. */
258
+ function isDocument(value) {
259
+ return value?.type === 'doc' || value?.doc?.type === 'doc'
260
+ }
261
+
262
+ /**
263
+ * Say, once per handler, that a content handler returned something that is not a
264
+ * document — naming the likely mistake when it returned the very data it was given.
265
+ */
266
+ const warnedContentHandlers = new WeakSet()
267
+ function warnNotADocument(handler, value, data) {
268
+ if (warnedContentHandlers.has(handler)) return
269
+ warnedContentHandlers.add(handler)
270
+ const what =
271
+ value === data
272
+ ? 'the data it was given'
273
+ : Array.isArray(value)
274
+ ? 'an array'
275
+ : `${typeof value === 'object' ? 'an object' : typeof value}`
276
+ console.warn(
277
+ `[uniweb] handlers.content returned ${what}, not a ProseMirror document — ignored, and the ` +
278
+ `section keeps its content. The handler is called (data, block) and returns the section's ` +
279
+ `raw content transformed (block.rawContent, as Loom's does), or null for no change.`
280
+ )
281
+ }
282
+
450
283
  /**
451
284
  * Run the foundation-level props handler on the final { content, params }
452
285
  * before they reach the component. Runs after content parsing, param
453
- * defaults, content guarantees, and schema application — the handler
454
- * sees the exact shape the component would receive and can modify it.
286
+ * defaults and content guarantees — the handler sees the exact shape the
287
+ * component would receive and can modify it.
455
288
  *
456
289
  * The handler receives `(content, params, block)` and returns a new
457
290
  * `{ content, params }` object, or null/undefined for no change.
@@ -490,13 +323,18 @@ function runPropsHandler(content, params, block) {
490
323
  * instantiated version.
491
324
  * 4. Apply param defaults from meta.
492
325
  * 5. Build the guaranteed content structure.
493
- * 6. Apply schemas to content.data.
494
- * 7. Run the foundation props handler (if registered) for
326
+ * 6. Run the foundation props handler (if registered) for
495
327
  * post-processing of the final { content, params }.
496
328
  *
497
- * Steps 1–3 mutate the block (vanilla JS layer). Steps 4–7 are
329
+ * Steps 1–3 mutate the block (vanilla JS layer). Steps 4–6 are
498
330
  * pure derivations of the block's now-assembled state.
499
331
  *
332
+ * ⛔ No field defaults for `content.data` — ruled 2026-10-05 [Diego]. A step between 5 and 6
333
+ * filled each missing field from its schema's `default`, and replaced a value its `enum`
334
+ * rejected, from the `schemas` a foundation built before then still carries in its meta —
335
+ * read no more. A record reaches the component as it is: an absent field is its own fact,
336
+ * and what it renders as is the component's choice.
337
+ *
500
338
  * @param {Object} block - The block instance
501
339
  * @param {Object} meta - Runtime metadata for the component (from meta[componentName])
502
340
  * @param {Object|null} [entityData] - Entity data resolved by EntityStore (null if none)
@@ -516,13 +354,7 @@ export function prepareProps(block, meta, entityData = null) {
516
354
  const params = applyDefaults(block.properties, defaults)
517
355
 
518
356
  // Guarantee content structure
519
- let content = guaranteeContentStructure(block.parsedContent)
520
-
521
- // Apply schemas to content.data
522
- const schemas = meta?.schemas || null
523
- if (schemas && content.data) {
524
- content.data = applySchemas(content.data, schemas)
525
- }
357
+ const content = guaranteeContentStructure(block.parsedContent)
526
358
 
527
359
  // Post-process hook
528
360
  const adjusted = runPropsHandler(content, params, block)
package/src/ssr.js CHANGED
@@ -16,7 +16,6 @@
16
16
  // Props preparation (no browser APIs)
17
17
  export {
18
18
  prepareProps,
19
- applySchemas,
20
19
  applyDefaults,
21
20
  guaranteeContentStructure,
22
21
  getComponentMeta,