@uniweb/build 0.75.0 → 0.76.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.
@@ -0,0 +1,97 @@
1
+ /**
2
+ * ⭐ A RECORD'S FIELDS, WALKED BY ITS MODEL — the shape a push sends it in, lowered by the push's
3
+ * own rule (`uwx/data-schema.js::toDataSchemaDeclaration`), which marks `localized` exactly the
4
+ * fields that travel per locale: text — never an enum, a value format (a URL, an email), a
5
+ * `translatable: false` field, a number, a date, a file or a reference.
6
+ *
7
+ * One walk for two things a model describes: a record (`records.js`), and a tagged data block whose
8
+ * key a section type declares the shape of (`data-strings.js::visitDataBlockStrings`).
9
+ */
10
+ import { isOpenMapSection } from '../uwx/data-schema.js'
11
+
12
+ export function isPlainObject(value) {
13
+ return !!value && typeof value === 'object' && !Array.isArray(value)
14
+ }
15
+
16
+ /**
17
+ * ⛔ A RECORD'S SYSTEM FIELDS ARE NEVER PROSE — never extracted, never translated, by
18
+ * either path. A `$`-prefixed key at any depth is the system's (`$name`, the handle a
19
+ * parametric page's URL names; `$uuid`; `$branch`, the folder a compiled list holds a
20
+ * record under), and a top-level `slug` is how a record's reader hands the file's name
21
+ * on. Until 2026-09-14 they became translation units, and a translation whose source
22
+ * happened to equal one rewrote it, so a record's page stopped matching its URL.
23
+ * ⛔ A top-level `path` was one too until 2026-09-27, when the placement it named moved to
24
+ * `$branch` [Diego]: a field an author names `path` is theirs, and translated as its schema says.
25
+ *
26
+ * @param {string|number} key - a field name (an array index is never one)
27
+ * @param {boolean} topLevel - whether the key sits at the record's own level
28
+ * @returns {boolean}
29
+ */
30
+ export function isRecordSystemField(key, topLevel) {
31
+ if (typeof key !== 'string') return false
32
+ return key.startsWith('$') || (topLevel && key === 'slug')
33
+ }
34
+
35
+ /**
36
+ * Each field of a model that a record holds a value for, in the shape the record is DELIVERED in
37
+ * (`toDeliveredRecord`): the brief's fields at the top — every field, for a model of one single
38
+ * section — and each other section under its name, a list of them for a `multiple` one, a nested
39
+ * section the same way. Calls `visit(holder, name, field, path)` for each.
40
+ */
41
+ export function eachModelField(record, model, visit) {
42
+ const sections = Object.entries(model?.sections || {})
43
+ const flat = sections.length === 1 && !sections[0][1]?.multiple
44
+ for (const [name, section] of sections) {
45
+ if (flat || section?.brief) eachSectionField(record, section?.fields, '', visit)
46
+ else eachSectionValue(record?.[name], section, name, visit)
47
+ }
48
+ }
49
+
50
+ function eachSectionValue(value, section, path, visit) {
51
+ if (!section?.multiple) return eachSectionField(value, section?.fields, path, visit)
52
+ if (Array.isArray(value)) value.forEach((item, i) => eachSectionField(item, section.fields, `${path}[${i}]`, visit))
53
+ // An open map as its file holds it: each entry is one of the rows a push sends (`isOpenMapSection`).
54
+ else if (isOpenMapSection(section) && isPlainObject(value)) {
55
+ for (const [key, item] of Object.entries(value)) eachSectionField(item, section.fields, `${path}.${key}`, visit)
56
+ }
57
+ }
58
+
59
+ export function eachSectionField(holder, fields, path, visit) {
60
+ if (!isPlainObject(holder)) return
61
+ for (const [name, field] of Object.entries(fields || {})) {
62
+ if (holder[name] == null || isRecordSystemField(name, !path)) continue
63
+ const at = path ? `${path}.${name}` : name
64
+ if (field?.type === 'section') eachSectionValue(holder[name], field, at, visit)
65
+ else visit(holder, name, field || {}, at)
66
+ }
67
+ }
68
+
69
+ /**
70
+ * Each string a record's model marks `localized` — a text field's value, or each string of a list
71
+ * of them — handed to `visit(value, path)`; a different string returned replaces it, in place, as
72
+ * `data-strings.js::visitDataStrings` does. A rich field is one string here: a data block holds its
73
+ * markdown as written.
74
+ *
75
+ * @param {object} record
76
+ * @param {object} model - a lowered data schema (`toDataSchemaDeclaration`)
77
+ * @param {(value: string, path: string) => string|void} visit
78
+ * @param {string} [prefix] - prepended to each path (`[0]` for a list's first item)
79
+ */
80
+ export function visitModelStrings(record, model, visit, prefix = '') {
81
+ const pathOf = (path) => (prefix ? (path.startsWith('[') ? `${prefix}${path}` : `${prefix}.${path}`) : path)
82
+ eachModelField(record, model, (holder, name, field, path) => {
83
+ if (!field.localized) return
84
+ const value = holder[name]
85
+ if (typeof value === 'string') {
86
+ if (!value.trim()) return
87
+ const next = visit(value, pathOf(path))
88
+ if (typeof next === 'string' && next !== value) holder[name] = next
89
+ } else if (Array.isArray(value)) {
90
+ value.forEach((item, i) => {
91
+ if (typeof item !== 'string' || !item.trim()) return
92
+ const next = visit(item, pathOf(`${path}[${i}]`))
93
+ if (typeof next === 'string' && next !== item) value[i] = next
94
+ })
95
+ }
96
+ })
97
+ }
@@ -23,7 +23,8 @@ import { computeHash } from './hash.js'
23
23
  import { loadFreeformRecord } from './freeform.js'
24
24
  import { resolveQueriesConfig, resolveRecordSchemas, foundationSchemaJson } from '../site/queries-config.js'
25
25
  import { dataKeyTypes, keyOfDefaultRef } from '../uwx/data-key-types.js'
26
- import { toDataSchemaDeclaration, isProseMirrorField, isOpenMapSection } from '../uwx/data-schema.js'
26
+ import { toDataSchemaDeclaration, isProseMirrorField } from '../uwx/data-schema.js'
27
+ import { eachModelField, eachSectionField, isRecordSystemField, isPlainObject } from './model-fields.js'
27
28
  import { toDeliveredRecord, contentBodyField, misplacedFields, mergedFromStored, storedFromMerged } from '@uniweb/schemas/conform'
28
29
  import { resolveDocForLocale } from './merge.js'
29
30
  import { extractUnitsFromDoc } from './extract.js'
@@ -186,7 +187,7 @@ function isFieldTranslatable(fieldDef) {
186
187
  * @param {string} ref - the ref it was resolved from
187
188
  * @returns {Object|null} the lowered declaration, or null when there is none
188
189
  */
189
- function modelOf(schema, ref) {
190
+ export function modelOf(schema, ref) {
190
191
  if (!schema) return null
191
192
  try {
192
193
  return toDataSchemaDeclaration(schema, { name: ref || '@/record', resolveName: (r) => r })
@@ -208,40 +209,6 @@ function referencedModels(model) {
208
209
  return out
209
210
  }
210
211
 
211
- /**
212
- * Each field of a model that a record holds a value for, in the shape the record is DELIVERED in
213
- * (`toDeliveredRecord`): the brief's fields at the top — every field, for a model of one single
214
- * section — and each other section under its name, a list of them for a `multiple` one, a nested
215
- * section the same way. Calls `visit(holder, name, field, path)` for each.
216
- */
217
- function eachModelField(record, model, visit) {
218
- const sections = Object.entries(model?.sections || {})
219
- const flat = sections.length === 1 && !sections[0][1]?.multiple
220
- for (const [name, section] of sections) {
221
- if (flat || section?.brief) eachSectionField(record, section?.fields, '', visit)
222
- else eachSectionValue(record?.[name], section, name, visit)
223
- }
224
- }
225
-
226
- function eachSectionValue(value, section, path, visit) {
227
- if (!section?.multiple) return eachSectionField(value, section?.fields, path, visit)
228
- if (Array.isArray(value)) value.forEach((item, i) => eachSectionField(item, section.fields, `${path}[${i}]`, visit))
229
- // An open map as its file holds it: each entry is one of the rows a push sends (`isOpenMapSection`).
230
- else if (isOpenMapSection(section) && isPlainObject(value)) {
231
- for (const [key, item] of Object.entries(value)) eachSectionField(item, section.fields, `${path}.${key}`, visit)
232
- }
233
- }
234
-
235
- function eachSectionField(holder, fields, path, visit) {
236
- if (!isPlainObject(holder)) return
237
- for (const [name, field] of Object.entries(fields || {})) {
238
- if (holder[name] == null || isRecordSystemField(name, !path)) continue
239
- const at = path ? `${path}.${name}` : name
240
- if (field?.type === 'section') eachSectionValue(holder[name], field, at, visit)
241
- else visit(holder, name, field || {}, at)
242
- }
243
- }
244
-
245
212
  /** The fields of a model's brief — what a reference to one of its records is delivered as. */
246
213
  function briefFields(model) {
247
214
  const sections = Object.values(model?.sections || {})
@@ -360,25 +327,6 @@ function derivesExcerpt(record, whole, dataSchema, config) {
360
327
  return derivedExcerptOf(whole ?? record, dataSchema, config) === record.excerpt
361
328
  }
362
329
 
363
- /**
364
- * ⛔ A RECORD'S SYSTEM FIELDS ARE NEVER PROSE — never extracted, never translated, by
365
- * either path. A `$`-prefixed key at any depth is the system's (`$name`, the handle a
366
- * parametric page's URL names; `$uuid`; `$branch`, the folder a compiled list holds a
367
- * record under), and a top-level `slug` is how a record's reader hands the file's name
368
- * on. Until 2026-09-14 they became translation units, and a translation whose source
369
- * happened to equal one rewrote it, so a record's page stopped matching its URL.
370
- * ⛔ A top-level `path` was one too until 2026-09-27, when the placement it named moved to
371
- * `$branch` [Diego]: a field an author names `path` is theirs, and translated as its schema says.
372
- *
373
- * @param {string|number} key - a field name (an array index is never one)
374
- * @param {boolean} topLevel - whether the key sits at the record's own level
375
- * @returns {boolean}
376
- */
377
- function isRecordSystemField(key, topLevel) {
378
- if (typeof key !== 'string') return false
379
- return key.startsWith('$') || (topLevel && key === 'slug')
380
- }
381
-
382
330
  /**
383
331
  * A record's handle — `$name`, which the build sets from the record's file (`slug:` or
384
332
  * its name). ⭐ Not `slug` alone: a record of a schema whose brief declares a `slug`
@@ -1208,10 +1156,6 @@ function applyFreeform(record, freeform, dataSchema, schemaRef) {
1208
1156
  return out
1209
1157
  }
1210
1158
 
1211
- function isPlainObject(value) {
1212
- return !!value && typeof value === 'object' && !Array.isArray(value)
1213
- }
1214
-
1215
1159
  /**
1216
1160
  * Apply translations to a collection item (sync, hash-based only) — by the model it is pushed as
1217
1161
  * when it has one (`translateByModel`), else a companion schema's fields, else the heuristic.
@@ -84,6 +84,60 @@ export const SCHEMA_NOT_FOUND = 'SCHEMA_NOT_FOUND'
84
84
  const RESERVED_SYSTEM_SCOPE = 'uniweb'
85
85
  const packageForScope = (scope) => SCOPE_PACKAGE[scope] ?? `@${scope}/schemas`
86
86
 
87
+ /**
88
+ * A NAMED data schema — a `schemas/` file, `@std/<name>`, `@org/<name>` — validated and
89
+ * normalized, and refused when a field declares a `default`.
90
+ *
91
+ * ⛔ A named schema declares no default — ruled 2026-10-05 [Diego], when the runtime stopped
92
+ * filling fields: nothing would read one. The runtime fills no field, the declaration a
93
+ * registry stores carries none (`uwx/data-schema.js`), and the foundation schema an editor
94
+ * reads leaves `dataSchemas` out (`uwx/registry-package.js`) — so a `default:` here would be a
95
+ * promise kept nowhere. What an absent field renders as is the component's choice. A default
96
+ * stays where an editor reads it: a component's own inline field map or form in `data:`.
97
+ *
98
+ * @param {object} schema - the schema as authored
99
+ * @param {string} ref - its ref, for the message
100
+ * @returns {object} the normalized schema
101
+ */
102
+ function normalizeNamedSchema(schema, ref) {
103
+ const normalized = validateAndNormalizeSchema(schema, ref)
104
+ const paths = fieldsWithDefaults(normalized)
105
+ if (paths.length > 0) {
106
+ throw new Error(
107
+ `Data schema '${ref}': a named data schema declares no default — remove \`default:\` from ` +
108
+ `${paths.map((p) => `'${p}'`).join(', ')}. A component decides what an absent field ` +
109
+ `renders as; an editor pre-fills from a component's own inline field map or form in \`data:\`.`
110
+ )
111
+ }
112
+ return normalized
113
+ }
114
+
115
+ /** The dotted paths of every field in a normalized schema that declares a `default`. */
116
+ function fieldsWithDefaults(schema) {
117
+ const found = []
118
+ const walkFields = (fields, prefix) => {
119
+ if (!fields || typeof fields !== 'object') return
120
+ for (const [name, spec] of Object.entries(fields)) walkSpec(spec, `${prefix}${name}`)
121
+ }
122
+ const walkSpec = (spec, path) => {
123
+ if (!spec || typeof spec !== 'object') return
124
+ if (spec.default !== undefined) found.push(path)
125
+ walkFields(spec.fields, `${path}.`)
126
+ if (spec.items) walkSpec(spec.items, `${path}[]`)
127
+ if (spec.values) walkSpec(spec.values, `${path}{}`)
128
+ }
129
+ const walkSections = (sections, prefix) => {
130
+ if (!sections || typeof sections !== 'object') return
131
+ for (const [name, section] of Object.entries(sections)) {
132
+ walkFields(section?.fields, `${prefix}${name}.`)
133
+ walkSections(section?.sections, `${prefix}${name}.`)
134
+ }
135
+ }
136
+ walkFields(schema?.fields, '')
137
+ walkSections(schema?.sections, '')
138
+ return found
139
+ }
140
+
87
141
  /**
88
142
  * Collect every distinct schema ref used by a foundation's section bindings.
89
143
  * Reads `data: { key: '<ref>' }` (short) and `data: { key: { schema: '<ref>' } }`
@@ -94,9 +148,9 @@ const packageForScope = (scope) => SCOPE_PACKAGE[scope] ?? `@${scope}/schemas`
94
148
  * (`declaredKeys`, `@uniweb/core/data-keys`) — but only components were scanned
95
149
  * here, so a ref at that tier was neither resolved into `dataSchemas` nor
96
150
  * rejected when dangling, while the key was still delivered at runtime. The
97
- * symptom would have been no schema for the editor and no field defaults from
98
- * `applySchemas`, with no build error. It bit nobody only because every
99
- * foundation-tier declaration in the templates is ref-less.
151
+ * symptom would have been no schema for the editor, with no build error. It bit
152
+ * nobody only because every foundation-tier declaration in the templates is
153
+ * ref-less.
100
154
  *
101
155
  * @param {Object} components - Map of componentName → full meta (with `data`)
102
156
  * @param {Object|false|null} [foundationData] - the foundation's `main.js` `data:`
@@ -136,7 +190,7 @@ export async function resolveSchemaRef(ref, { srcDir, aliases }) {
136
190
  { code: SCHEMA_NOT_FOUND }
137
191
  )
138
192
  }
139
- return validateAndNormalizeSchema(await loadSchemaFile(file), ref)
193
+ return normalizeNamedSchema(await loadSchemaFile(file), ref)
140
194
  }
141
195
 
142
196
  // ⭐ THE SYSTEM MODELS A SITE'S RECORDS MAY NAME — `@uniweb/link` — resolve from the build's own
@@ -145,7 +199,7 @@ export async function resolveSchemaRef(ref, { srcDir, aliases }) {
145
199
  // never a data-schema source. (The whole scope was refused until 2026-09-28.)
146
200
  if (scope === RESERVED_SYSTEM_SCOPE) {
147
201
  const system = SYSTEM_RECORD_SCHEMAS[name]
148
- if (system) return validateAndNormalizeSchema(system, ref)
202
+ if (system) return normalizeNamedSchema(system, ref)
149
203
  const allowed = Object.keys(SYSTEM_RECORD_SCHEMAS).map((n) => `'@${scope}/${n}'`).join(', ')
150
204
  throw new Error(
151
205
  `'@${scope}' is the reserved platform system namespace: the only data schemas a site's records ` +
@@ -170,7 +224,7 @@ export async function resolveSchemaRef(ref, { srcDir, aliases }) {
170
224
  `exists there. Point it at a ${SCHEMA_EXTENSIONS.join(' / ')} file.`
171
225
  )
172
226
  }
173
- return validateAndNormalizeSchema(await loadSchemaFile(file), ref)
227
+ return normalizeNamedSchema(await loadSchemaFile(file), ref)
174
228
  }
175
229
 
176
230
  // A scope alias ('@org' → a DIR) resolves 'name' to a bare schema FILE in that
@@ -195,7 +249,7 @@ export async function resolveSchemaRef(ref, { srcDir, aliases }) {
195
249
  }
196
250
  throw new Error(msg)
197
251
  }
198
- return validateAndNormalizeSchema(await loadSchemaFile(file), ref)
252
+ return normalizeNamedSchema(await loadSchemaFile(file), ref)
199
253
  }
200
254
 
201
255
  // Every other scope is an org namespace: '@org/name' resolves `name` from that
@@ -207,7 +261,7 @@ export async function resolveSchemaRef(ref, { srcDir, aliases }) {
207
261
  if (!schema) {
208
262
  throw new Error(`Unknown data schema '${ref}': '${pkg}' exports no schema named '${name}'.`)
209
263
  }
210
- return validateAndNormalizeSchema(schema, ref)
264
+ return normalizeNamedSchema(schema, ref)
211
265
  }
212
266
 
213
267
  /**
@@ -224,9 +278,8 @@ export async function resolveSchemaRef(ref, { srcDir, aliases }) {
224
278
  export async function buildDataSchemaMap(refs, { srcDir }) {
225
279
  // Load the foundation's optional schemas.config.js once; every ref (and every
226
280
  // transitively-discovered ref) resolves against the same alias map. This is
227
- // the single entry both schema discovery and the runtime-schema build go
228
- // through, so editor schema.json, runtime defaults, and `uniweb validate` all
229
- // resolve refs identically.
281
+ // the single entry schema discovery and `uniweb validate` go through, so the
282
+ // editor's schema.json and the check of a site's data resolve refs identically.
230
283
  const aliases = await loadSchemaAliases(srcDir)
231
284
  const map = {}
232
285
  const queue = Array.from(refs)
@@ -299,7 +352,7 @@ async function collectSchemasFromExports(packageDir) {
299
352
  for (const name of names) {
300
353
  const schema = get(name)
301
354
  if (!schema || typeof schema !== 'object') continue
302
- out[`@/${name}`] = validateAndNormalizeSchema(schema, `@/${name}`)
355
+ out[`@/${name}`] = normalizeNamedSchema(schema, `@/${name}`)
303
356
  }
304
357
  return out
305
358
  }
@@ -309,7 +362,7 @@ async function collectSchemasFromExports(packageDir) {
309
362
  async function collectSchemasFromDir(dir) {
310
363
  const out = {}
311
364
  for (const { file, name } of schemaFilesIn(dir)) {
312
- out[`@/${name}`] = validateAndNormalizeSchema(await loadSchemaFile(join(dir, file)), `@/${name}`)
365
+ out[`@/${name}`] = normalizeNamedSchema(await loadSchemaFile(join(dir, file)), `@/${name}`)
313
366
  }
314
367
  return out
315
368
  }