@uniweb/build 0.60.0 → 0.62.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.
@@ -3,41 +3,36 @@
3
3
  //
4
4
  // Each record becomes a section-keyed `$`-document (docs/reference/entity-content.md):
5
5
  // `$id` (the producer-local handle), `$schema` (its data schema, by scoped name), and
6
- // each SINGLE section keyed by its name — the brief plus any sibling singles, not
7
- // the brief alone. The backend MINTS `$uuid` on first sync and
8
- // returns it in the finalized response; the verb back-fills it into the source
6
+ // each section the record holds, keyed by its name — the brief, the other single
7
+ // sections, and `many` sections as lists of records. The backend MINTS `$uuid` on first
8
+ // sync and returns it in the finalized response; the verb back-fills it into the source
9
9
  // file. A record that already carries `$uuid` (a prior back-fill) round-trips it
10
10
  // for restore-in-place. No id sidecar — identity is the file-embedded `$uuid` plus
11
11
  // the back-fill round-trip.
12
12
  //
13
- // To shape each record, the mapper needs the Model's declaration — the brief
14
- // section name, its field order, and which fields are localized. The orchestrator
15
- // reads that from the LOCAL foundation's built `dist/meta/schema.json` (lowered
16
- // via `toDataSchemaDeclaration`, the same path `uniweb register` uses), so it
17
- // stays offline.
13
+ // To shape each record, the mapper needs the Model's declaration — its sections, their
14
+ // field order, and which fields are localized. The orchestrator reads that from the
15
+ // LOCAL foundation's built `dist/meta/schema.json` (lowered via
16
+ // `toDataSchemaDeclaration`, the same path `uniweb register` uses), so it stays offline,
17
+ // or, for a Model no local foundation defines, through the injected `opts.resolveModel`.
18
18
  //
19
- // Scope: this mapper implements the FLAT-RECORD shape — one source file whose
20
- // frontmatter keys are field names — so it walks the Model's `single` sections
21
- // and skips `multi` ones.
19
+ // ⭐ A RECORD FILE IS READ BY ITS SCHEMA'S LAYOUT (`./record-layout.js`): flat when the
20
+ // schema has one top-level section holding one record, by section otherwise — which is
21
+ // also the only way a `many` section, a self-nesting list (`children:` in the file,
22
+ // `$children` on the wire) or a schema whose root is a list (`@std/nav`) is written.
23
+ // Nested sections are encoded by their own declaration, so a localized field inside one
24
+ // is wrapped per locale like any other.
22
25
  //
23
- // ⛔ That is a property of THIS MAPPER, not of the schema system, and saying
24
- // otherwise has already misled twice. `multi` is first-class: the author writes
25
- // `many: true`, which lowers to IR `kind: 'multi'` and to wire `multiple: true`.
26
- // A Model whose ONLY section is `many` is a supported shape in its own right — a
27
- // root list, content authored as a bare array (`@uniweb/schemas` `rootListSection`;
28
- // `@std/nav` and `@std/form` are exactly this). Such a Model has no flat-record
29
- // surface at all, so it is not that its records "cannot be expressed" — it is
30
- // that they are not this shape, and this mapper only knows this shape.
26
+ // ⭐ A REFERENCE (`entity_ref`) NAMES ITS RECORD BY HANDLE in a file — `speaker: ada`,
27
+ // the target's file name or its `slug:` — and is sent as the uuid THIS backend minted for
28
+ // that record (`refResolver`). A target this backend has not minted yet (new in this
29
+ // push) cannot be named on the wire, so its referrer WAITS: sent without that reference
30
+ // and completed by the next pass, or held back when it cannot exist without it — see
31
+ // `sync-package.js`, and the CLI's `pushInPasses`, which repeats the push.
32
+ // ⚠️ item_ref and file fields are still sent as the file writes them.
31
33
  //
32
- // Nested sections (a `type: section` field) and entity_ref / item_ref / file
33
- // fields have no branch in `encodeFieldValue`; unverified either way.
34
- //
35
- // ⚠️ Two claims that used to sit here were stale and were removed rather than
36
- // re-worded, because both named real capabilities as missing: NON-BRIEF single
37
- // sections are handled (see `recordSections` below — the filter is `multiple !==
38
- // true`, not `brief === true`), and REMOTE foundations are handled through the
39
- // injected `opts.resolveModel`. A scope note that under-claims is worse than none:
40
- // it sends a reader to build what is already there.
34
+ // ⚠️ A record of a `many` section is sent without a `$uuid`, so a push that changes a
35
+ // record replaces that section's records rather than updating them in place.
41
36
 
42
37
  import { readBackendState } from './sync-store.js'
43
38
  import { readFileSync, existsSync } from 'node:fs'
@@ -55,23 +50,26 @@ import {
55
50
  poolPathReadings,
56
51
  poolDirsForSchema,
57
52
  } from '../site/entity-pool.js'
58
- import { toDataSchemaDeclaration, isProseMirrorField, isMarkupTextField, isContentBodyField } from './data-schema.js'
53
+ import { toDataSchemaDeclaration, isProseMirrorField, isMarkupTextField } from './data-schema.js'
54
+ import { recordFileLayout, contentBodyTarget } from './record-layout.js'
59
55
  import { emitEntitySyncPackage } from './entity-document.js'
60
56
  import { resolveSelfScope, siteSelfScope, refuseOrgOption } from './self-scope.js'
61
57
  import { sha256Hex, toJsonBuffer } from './manifest.js'
58
+ import { isUuid } from './uuid.js'
62
59
  import { markdownToProseMirror } from '@uniweb/content-reader'
63
60
  import { LOCALIZED_FIELD_ASSUMPTION, localize } from './localize.js'
64
- import { localizeScalar, localizeContentDoc, loadLocaleTranslations, discoverLocales, discoverFreeformLocales, localesDir, isLocalizedContent } from './locale-sync.js'
61
+ import { localizeScalar, localizeScalarList, localizeContentDoc, loadLocaleTranslations, discoverLocales, discoverFreeformLocales, localesDir, isLocalizedContent } from './locale-sync.js'
65
62
  import { loadFreeformRecord } from '../i18n/freeform.js'
66
63
  import { isDraftRecord } from '../site/record-draft.js'
67
64
 
68
65
  const DATE_KINDS = new Set(['date', 'datetime'])
69
- // Identity/transport keys on a source record — never Model fields, never warned.
70
- // `$body` carries the markdown body (mapped to the brief's content body field below).
66
+ // The record's OWN keys — identity and transport, never a field of any section, never
67
+ // warned. `$body` carries the markdown body (mapped to the schema's content body field,
68
+ // wherever a single section declares it).
71
69
  // Note: there is NO delivery-derived ignore list (route/excerpt/image/content) —
72
70
  // the source reader never produces those, and a real unknown key SHOULD warn (it
73
71
  // means the frontmatter doesn't match the collection's data schema).
74
- const SKIP_KEYS = new Set([
72
+ const RECORD_KEYS = new Set([
75
73
  'slug',
76
74
  '$id',
77
75
  '$uuid',
@@ -91,8 +89,8 @@ const SKIP_KEYS = new Set([
91
89
  // they are content rather than identity:
92
90
  // - `$children` is STRUCTURAL content (a self-nesting record's subtree, e.g.
93
91
  // site-content's nested pages/sections), so it is KEPT and recursed into —
94
- // otherwise a nesting change would be invisible to "send only changed". Flat
95
- // records carry no `$children`, so this is a no-op for the collection lane.
92
+ // otherwise a nesting change would be invisible to "send only changed". A record
93
+ // of a self-nesting list (`children:` in its file) carries them too.
96
94
  // - `$disabled` is a record's delivery STATE (`draft: true` on the file side). It
97
95
  // is KEPT so that drafting or un-drafting a record with nothing else changed is
98
96
  // still sent; stripped, "send only changed" would never send the toggle.
@@ -170,6 +168,12 @@ function encodeFieldValue(value, field, sourceLocale, translations) {
170
168
  return isLocalizedContent(localized) ? localized : { [sourceLocale]: localized }
171
169
  }
172
170
  if (field.localized) {
171
+ // A list of localized values rides as a list of `{ lang: value }` maps, one per
172
+ // element — the shape page `keywords` already take. ⛔ It went up as a bare list
173
+ // until 2026-09-24: `localizeScalar` passes any object through, arrays included.
174
+ if (Array.isArray(value) && !isMarkupTextField(field)) {
175
+ return localizeScalarList(value, sourceLocale, translations)
176
+ }
173
177
  // A markup `text` BODY (format markdown|html) rides as a RAW string, wrapped
174
178
  // per-locale wholesale (its per-string translations live in the i18n manifest /
175
179
  // free-form, not the scalar map). Other localized scalars wrap per-string from
@@ -195,20 +199,30 @@ function encodeFieldValue(value, field, sourceLocale, translations) {
195
199
  * no I/O, no minting. The backend mints `$uuid` on first sync; a record that
196
200
  * already carries `$uuid` (back-filled from a prior sync) round-trips it.
197
201
  *
202
+ * A record is read by its schema's layout (`./record-layout.js`): FLAT when the schema
203
+ * has one top-level section holding one record, its keys that section's fields; BY
204
+ * SECTION otherwise, each top-level section under its own name. Either way the document
205
+ * is the same section-keyed shape, so a pull writes the record back as it was written.
206
+ *
198
207
  * @param {object} params
199
208
  * @param {string} params.label - what the records are, for messages and the path
200
209
  * inside the package — the schema folder they came from (`article`,
201
210
  * `std/person`)
202
- * @param {object[]} params.records - [{ slug, ...fields }]
211
+ * @param {object[]} params.records - [{ slug, ...the file's keys }], a
212
+ * markdown body under `$body`
203
213
  * @param {object} params.declaration - the `@uniweb/data-schema` declaration
204
- * (from toDataSchemaDeclaration): `{ name, brief, sections }`
214
+ * (from toDataSchemaDeclaration): `{ name, sections }`
205
215
  * @param {string} [params.sourceLocale] - locale for localized-field wrap
206
216
  * @param {object} [params.translations] - `{ locale: { hash: tgt } }` for wrapping
207
217
  * localized scalar fields per-locale (from loadLocaleTranslations)
218
+ * @param {{ resolve: (model: string, value: *) => object }} [params.refs] - what a
219
+ * reference sends (`refResolver`). Without it a reference is sent as written.
208
220
  * @returns {{ entities: object[], warnings: string[], refusals: string[] }} each
209
- * entity is `{ id, uuid, model, file, document }` — `document` is the section-keyed
210
- * body. `refusals` names the records that cannot be sent as written, one line
211
- * each; a caller that sends must not send while any is present.
221
+ * entity is `{ id, uuid, model, file, document, pending }` — `document` is the
222
+ * section-keyed body, and `pending` the references it WAITS on, left out of it:
223
+ * `{ path, model, name, required }`, one per reference whose record this backend has
224
+ * not minted yet. `refusals` names the records that cannot be sent as written, one
225
+ * line each; a caller that sends must not send while any is present.
212
226
  */
213
227
  export function recordsToEntities({
214
228
  label,
@@ -216,83 +230,29 @@ export function recordsToEntities({
216
230
  declaration,
217
231
  sourceLocale = LOCALIZED_FIELD_ASSUMPTION.defaultSourceLocale,
218
232
  translations,
233
+ refs = null,
219
234
  }) {
220
235
  if (!declaration || !declaration.name) {
221
236
  throw new Error('uwx/records: a declaration with a name is required')
222
237
  }
223
- // A record (one source file) maps to the Model's SINGLE sections in declared
224
- // order — the brief (the card) plus any sibling single sections, e.g. a body
225
- // section like `article_body`. Multi-section Models are the norm for `@std/*`
226
- // types; the markdown body lands in the designated content field WHEREVER it is
227
- // declared (the brief, or a non-brief body section). `multi` sections (repeating
228
- // items) can't be expressed by one flat record and are skipped. The brief is the
229
- // section marked `brief: true` (the sections-tree has no schema-level back-ref).
230
- const sectionEntries = Object.entries(declaration.sections || {})
231
- const briefEntry = sectionEntries.find(([, s]) => s && s.brief === true)
232
- const briefName = briefEntry?.[0]
233
- if (!briefName) {
234
- throw new Error(`uwx/records: Model ${declaration.name} has no brief section`)
235
- }
236
- // The single sections one record can populate (the brief + sibling singles).
237
- //
238
- // ⛔ `fieldByKey` IS NOT A FIELD→SECTION ROUTING TABLE, and must not be used as
239
- // one. It answers exactly one question — "is this frontmatter key declared
240
- // anywhere on this Model?" — for the unknown-key warning below. The assignment
241
- // loop does not consult it: it walks each section and reads `record[key]` afresh.
242
- //
243
- // ⚠️ SO A FIELD NAME DECLARED IN TWO SECTIONS FANS OUT. The same frontmatter
244
- // value is written into BOTH sections, each encoded per its own field's type — so
245
- // a name shared by, say, a `string` and a `json` field yields one plausible value
246
- // and one malformed one, silently. And flat frontmatter has no way to give the
247
- // two fields different values in the first place: the representation is lossy
248
- // exactly where names collide.
249
- //
250
- // ⛔ Nothing prevents this. A previous version of this comment asserted that
251
- // "field names are unique across a Model's sections (the declaration's own
252
- // convention)" — that is FALSE, no such convention holds, and nothing validates
253
- // it: `resolve-data-schema.js` throws in 14 places and never checks this, and the
254
- // only `unique_field` in the schema translator is a section-scoped constraint on
255
- // an open map's KEY VALUE, which is unrelated. The invariant was asserted, relied
256
- // on, and never provided.
257
- const recordSections = sectionEntries.filter(([, s]) => s && s.multiple !== true)
258
- const fieldByKey = new Map()
259
- for (const [, sec] of recordSections) {
260
- for (const [key, field] of Object.entries(sec.fields || {})) {
261
- if (!fieldByKey.has(key)) fieldByKey.set(key, field)
262
- }
238
+ const layout = recordFileLayout(declaration)
239
+ if (layout.sections.length === 0) {
240
+ throw new Error(`uwx/records: ${declaration.name} declares no sections`)
263
241
  }
264
- // Every section by name, single or list — to recognize a record written BY SECTION
265
- // (`details: { title: … }`), the shape docs/reference/entity-content.md gives a
266
- // sections-form record. This mapper reads a record's fields from the top of its
267
- // file, so such a record is refused below rather than sent without them.
268
- const sectionNames = new Set(sectionEntries.map(([name]) => name))
269
- const listSections = new Set(
270
- sectionEntries.filter(([, s]) => s && s.multiple === true).map(([name]) => name)
271
- )
272
-
273
- // The markdown body of a `.md` record is the value of the Model's CONTENT body
274
- // field — a markup `text` field (raw source string) or a `format: prosemirror`
275
- // json field (docs/reference/entity-content.md) — wherever it is declared (the
276
- // brief, or a non-brief body section like `article_body.content`). encodeFieldValue
277
- // does the md→ProseMirror conversion per field kind. One content field is the body
278
- // target; zero means a `.md` body has nowhere to go (warn per record).
279
- const contentMatches = []
280
- for (const [secName, sec] of recordSections) {
281
- for (const [key, field] of Object.entries(sec.fields || {})) {
282
- if (isContentBodyField(field)) contentMatches.push({ secName, key })
283
- }
284
- }
285
- const bodyTarget = contentMatches[0] || null
242
+ // The markdown body of a `.md` record is the value of the schema's CONTENT body field,
243
+ // wherever a top-level single section declares it — the brief, or a body section like
244
+ // `article_body.content`. One field is the target; zero means a body has nowhere to go.
245
+ const { target: bodyTarget, count: bodyFields } = contentBodyTarget(declaration)
286
246
 
287
247
  const entities = []
288
248
  const warnings = []
289
- // Records that cannot be sent as written — see the ⛔ at the end of the loop.
249
+ // Records that cannot be sent as written — see the ⛔ below.
290
250
  const refusals = []
291
- if (contentMatches.length > 1) {
251
+ if (bodyFields > 1) {
292
252
  warnings.push(
293
253
  `${label}: ${declaration.name} has more than one content ` +
294
254
  `(markdown / html / prosemirror) field — the markdown body maps to ` +
295
- `"${bodyTarget.secName}.${bodyTarget.key}"`
255
+ `"${bodyTarget.section}.${bodyTarget.key}"`
296
256
  )
297
257
  }
298
258
  for (const record of records || []) {
@@ -301,6 +261,7 @@ export function recordsToEntities({
301
261
  warnings.push(`${label}: a record without a slug was skipped`)
302
262
  continue
303
263
  }
264
+ const where = `${label}/${slug}`
304
265
  // ⛔ `$id` IS NOT THE SLUG. It is the payload-local, PATH-QUALIFIED handle, so
305
266
  // the @uniweb/folder entity can point a leaf at it via `$ref`. An explicit
306
267
  // frontmatter `$id` wins.
@@ -319,88 +280,65 @@ export function recordsToEntities({
319
280
  const uuid = record.$uuid || null
320
281
  const hasBody = typeof record.$body === 'string' && record.$body.trim() !== ''
321
282
 
322
- // Per-section data in schema-declared field order (the wire's canonical order).
323
- // Frontmatter keys land in their declaring section; the markdown body fills the
324
- // designated content field (in whatever section declares it) unless frontmatter
325
- // already set it explicitly. An absent field is simply omitted — an incomplete
326
- // entity is a valid stored state; the foundation copes at render time.
327
- const sectionData = {}
328
- for (const [secName, sec] of recordSections) {
329
- const data = {}
330
- for (const [key, field] of Object.entries(sec.fields || {})) {
331
- let value = record[key]
332
- if (value === undefined && bodyTarget && secName === bodyTarget.secName && key === bodyTarget.key && hasBody) {
333
- value = record.$body
334
- }
335
- if (value === undefined) continue
336
- const encoded = encodeFieldValue(value, field, sourceLocale, translations)
337
- if (encoded !== undefined) data[key] = encoded
283
+ const enc = encoder(sourceLocale, translations, refs)
284
+ const sections = layout.flat ? readFlat(layout, record, enc) : readBySection(layout, record, enc)
285
+ // The markdown body fills the content body field, in whatever section declares it,
286
+ // unless the file already sets that field.
287
+ if (hasBody && bodyTarget) {
288
+ const data = (sections[bodyTarget.section] ??= {})
289
+ if (data[bodyTarget.key] == null) {
290
+ data[bodyTarget.key] = encodeFieldValue(record.$body, bodyTarget.field, sourceLocale, translations)
338
291
  }
339
- if (Object.keys(data).length) sectionData[secName] = data
340
- }
341
- // Author keys on no record section (only identity/transport keys in SKIP_KEYS are
342
- // exempt). A key naming a SECTION is not unknown: the record is written by
343
- // section, refused below.
344
- const bySection = []
345
- const undeclared = []
346
- for (const key of Object.keys(record)) {
347
- if (SKIP_KEYS.has(key) || fieldByKey.has(key)) continue
348
- if (sectionNames.has(key)) bySection.push(key)
349
- else undeclared.push(key)
350
292
  }
293
+ // The brief is the card, and is always sent — an empty one included, so a required
294
+ // field it lacks is refused here rather than stored empty.
295
+ if (layout.brief && !sections[layout.brief]) sections[layout.brief] = {}
351
296
 
352
297
  // ⛔ A RECORD THE BACKEND WOULD REFUSE — OR WOULD STORE EMPTY — IS REFUSED HERE,
353
- // before anything is sent. Two cases are visible from here, and the second is only
354
- // checked when the first does not already explain it:
355
- // - a record written by section, whose sections' contents this mapper cannot
356
- // send (it reads fields from the top of the file). Sent, it arrives with an
357
- // empty brief: refused when the brief has a `required` field, and otherwise
358
- // STORED EMPTY, in silence — so this stays whatever the backend does with a
359
- // refusal;
360
- // - a `required` field the send would lack — the brief is always sent, another
361
- // single section only when the record fills it. The backend refuses it too,
362
- // but names the field, not the file.
363
- // Measured 2026-09-23: a record written by section went up with an empty brief and
364
- // the lane was refused — and the refused lane left the folder's entries and
365
- // entities with no data behind, so every later push of the site's records was
366
- // refused. The backend's push has been one transaction since 2026-09-24; a refusal
367
- // now writes nothing.
368
- // ⛔ And what a push cannot carry is refused too, because it is lost twice over: the
369
- // backend never receives it, and the next pull writes the file without it — an
370
- // undeclared key (`tags:` beside a schema with no `tags`), or a markdown body where
371
- // the schema has no field to hold it. Both were warnings until 2026-09-24
372
- // ("not synced"), and a pull then deleted the value from the author's file.
373
- if (undeclared.length) {
374
- const one = undeclared.length === 1
298
+ // before anything is sent: a key written where the schema has no place for it
299
+ // (flat in a schema written by section, or declared nowhere), a value of the wrong
300
+ // shape for its section, a markdown body with no field to hold it, and a `required`
301
+ // field the send would lack. Measured 2026-09-23: a record the backend refused
302
+ // left the folder's entries with no data behind them, and every later push of the
303
+ // site's records was refused. The backend's push has been one transaction since
304
+ // 2026-09-24, so a refusal now writes nothing — but what a push cannot carry is
305
+ // still lost twice over: the backend never receives it, and the next pull writes
306
+ // the file without it.
307
+ if (enc.misplaced.length) refusals.push(bySectionRefusal(where, declaration.name, layout, enc.misplaced))
308
+ if (enc.undeclared.length) {
309
+ const one = enc.undeclared.length === 1
375
310
  refusals.push(
376
- `${label}/${slug}: ${quoted(undeclared)} ${one ? 'is not a field' : 'are not fields'} of ` +
311
+ `${where}: ${quoted(enc.undeclared)} ${one ? 'is not a field' : 'are not fields'} of ` +
377
312
  `${declaration.name} — a push cannot carry ${one ? 'it' : 'them'}, and the next pull would ` +
378
313
  `drop ${one ? 'it' : 'them'} from the file. Declare ${one ? 'it' : 'them'} in the schema, ` +
379
314
  `or remove ${one ? 'it' : 'them'}.`
380
315
  )
381
316
  }
317
+ for (const problem of enc.shape) refusals.push(`${where}: ${problem}`)
318
+ for (const bad of enc.unresolved) refusals.push(`${where}: ${describeUnresolved(bad)}`)
382
319
  if (hasBody && !bodyTarget) {
383
320
  refusals.push(
384
- `${label}/${slug}: the file has a markdown body, and ${declaration.name} has no field for ` +
321
+ `${where}: the file has a markdown body, and ${declaration.name} has no field for ` +
385
322
  'it (a `markdown` or `richtext` field) — a push cannot carry it. Add one to the schema, ' +
386
323
  'or move the text into a field.'
387
324
  )
388
325
  }
389
- if (bySection.length) {
390
- refusals.push(sectionShapeRefusal(`${label}/${slug}`, bySection, listSections, declaration.name))
391
- } else {
392
- const missing = missingRequired(recordSections, sectionData, briefName)
326
+ if (!enc.misplaced.length && !enc.shape.length) {
327
+ // A required reference that waits for its record is owed, not missing: the push
328
+ // that completes the record sends it (see `pending` below).
329
+ const waiting = new Set(enc.pending.map((p) => `"${p.path}"`))
330
+ const missing = missingRequired(layout, sections).filter((m) => !waiting.has(m))
393
331
  if (missing.length) {
394
332
  refusals.push(
395
- `${label}/${slug}: ${declaration.name} requires ${listOf(missing)}, and this record ` +
333
+ `${where}: ${declaration.name} requires ${listOf(missing)}, and this record ` +
396
334
  `has no value for ${missing.length === 1 ? 'it' : 'them'}.`
397
335
  )
398
336
  }
399
337
  }
400
338
 
401
339
  // The `$`-document, in canonical key order: `$uuid?`, `$id`, `$schema`, `$disabled?`,
402
- // then each populated section in declared order (the brief always present as the
403
- // card). `$owner`/`$unit`/`$meta` are omitted — the backend binds owner + unit on
340
+ // then the brief (the card) and every other section the record holds, in declared
341
+ // order. `$owner`/`$unit`/`$meta` are omitted — the backend binds owner + unit on
404
342
  // its side.
405
343
  const document = {}
406
344
  if (uuid) document.$uuid = uuid
@@ -410,10 +348,10 @@ export function recordsToEntities({
410
348
  // publicly delivered. Only `true` travels. An absent key means enabled [Diego,
411
349
  // 2026-09-21], so a record whose `draft: true` is removed is sent without the key
412
350
  // and is delivered again.
413
- if (isDraftRecord(record, `${label}/${slug}`)) document.$disabled = true
414
- document[briefName] = sectionData[briefName] || {}
415
- for (const [secName] of recordSections) {
416
- if (secName !== briefName && sectionData[secName]) document[secName] = sectionData[secName]
351
+ if (isDraftRecord(record, where)) document.$disabled = true
352
+ if (layout.brief) document[layout.brief] = sections[layout.brief]
353
+ for (const [name] of layout.sections) {
354
+ if (name !== layout.brief && sections[name] !== undefined) document[name] = sections[name]
417
355
  }
418
356
 
419
357
  entities.push({
@@ -426,11 +364,199 @@ export function recordsToEntities({
426
364
  // unique within its schema folder, and an authored `$id` need not be.
427
365
  file: `entities/${label}/${slug}.json`,
428
366
  document,
367
+ pending: enc.pending,
429
368
  })
430
369
  }
431
370
  return { entities, warnings, refusals }
432
371
  }
433
372
 
373
+ // What one record's read collects beside the values: the keys with no place in the
374
+ // schema, the keys written flat in a schema written by section, and values of the
375
+ // wrong shape for their section.
376
+ function encoder(sourceLocale, translations, refs = null) {
377
+ return { sourceLocale, translations, refs, undeclared: [], misplaced: [], shape: [], pending: [], unresolved: [] }
378
+ }
379
+
380
+ // A flat record: its keys, minus the record's own, are the one section's fields.
381
+ function readFlat(layout, record, enc) {
382
+ const [name, def] = layout.sections[0]
383
+ const values = {}
384
+ for (const [key, value] of Object.entries(record)) {
385
+ if (!RECORD_KEYS.has(key)) values[key] = value
386
+ }
387
+ // The record's handle fills a `slug` field the section declares, as the flat form
388
+ // always has (`@std/article`'s brief has one). Written by section, the field is
389
+ // written under its section like any other.
390
+ if (record.slug !== undefined && Object.hasOwn(def.fields || {}, 'slug') && values.slug === undefined) {
391
+ values.slug = record.slug
392
+ }
393
+ return { [name]: encodeRecord(def.fields, values, enc, '', null) }
394
+ }
395
+
396
+ // A record written by section: each top-level key names a section of the schema. A key
397
+ // that is instead a FIELD of one is the retired flat form — refused, naming the section
398
+ // it belongs under — and a key that is neither is undeclared.
399
+ function readBySection(layout, record, enc) {
400
+ const byName = new Map(layout.sections)
401
+ const out = {}
402
+ for (const [key, value] of Object.entries(record)) {
403
+ if (RECORD_KEYS.has(key)) continue
404
+ const def = byName.get(key)
405
+ if (def) {
406
+ const encoded = encodeSection(def, value, enc, key)
407
+ if (encoded !== undefined) out[key] = encoded
408
+ continue
409
+ }
410
+ const owners = layout.sections.filter(([, s]) => s.fields && Object.hasOwn(s.fields, key)).map(([n]) => n)
411
+ if (owners.length) enc.misplaced.push({ key, owners })
412
+ else enc.undeclared.push(key)
413
+ }
414
+ return out
415
+ }
416
+
417
+ // A section's value: one record for a single section (a binder included — its fields
418
+ // are its child sections), a list of records for a `many` one.
419
+ function encodeSection(def, value, enc, path) {
420
+ if (def.multiple === true) {
421
+ if (!Array.isArray(value)) {
422
+ enc.shape.push(`"${path}" holds a list of records — write it as a list ("- …" under "${path}:").`)
423
+ return undefined
424
+ }
425
+ return value.map((item, i) => encodeItem(def, item, enc, `${path}[${i}]`))
426
+ }
427
+ if (!isPlainObject(value)) {
428
+ enc.shape.push(`"${path}" is a section — write its fields under it ("${path}:" then each field indented).`)
429
+ return undefined
430
+ }
431
+ return encodeRecord(def.fields, value, enc, `${path}.`, null)
432
+ }
433
+
434
+ // One record of a `many` section. A self-nesting section's records nest under the
435
+ // reserved `children:` key in a file (as `@std/nav` documents it) and under
436
+ // `$children` on the wire, where the backend derives each one's parent.
437
+ function encodeItem(def, item, enc, path) {
438
+ if (!isPlainObject(item)) {
439
+ enc.shape.push(`"${path}" is one record of a list — write it as a map of its fields.`)
440
+ return {}
441
+ }
442
+ const nests = def.self_nesting === true
443
+ const out = encodeRecord(def.fields, item, enc, `${path}.`, nests ? 'children' : null)
444
+ if (nests && item.children !== undefined) {
445
+ if (!Array.isArray(item.children)) {
446
+ enc.shape.push(`"${path}.children" nests records under this one — write it as a list.`)
447
+ } else {
448
+ out.$children = item.children.map((child, j) => encodeItem(def, child, enc, `${path}.children[${j}]`))
449
+ }
450
+ }
451
+ return out
452
+ }
453
+
454
+ // Encode one record's fields in declared order — a nested section by its own
455
+ // declaration, a leaf by its kind. A key the fields do not declare is recorded as
456
+ // undeclared; `$`-keys (identity a pull may have left on a record) are not content, and
457
+ // `reserved` is the one extra key a caller allows (`children`, on a self-nesting list).
458
+ function encodeRecord(fields, value, enc, prefix, reserved) {
459
+ const out = {}
460
+ for (const [key, field] of Object.entries(fields || {})) {
461
+ const v = value[key]
462
+ if (v === undefined) continue
463
+ const encoded =
464
+ field?.type === 'section'
465
+ ? encodeSection(field, v, enc, `${prefix}${key}`)
466
+ : field?.type === 'entity_ref'
467
+ ? encodeReference(v, field, enc, `${prefix}${key}`)
468
+ : encodeFieldValue(v, field, enc.sourceLocale, enc.translations)
469
+ if (encoded !== undefined) out[key] = encoded
470
+ }
471
+ for (const key of Object.keys(value)) {
472
+ if (key === reserved || key.startsWith('$') || Object.hasOwn(fields || {}, key)) continue
473
+ enc.undeclared.push(`${prefix}${key}`)
474
+ }
475
+ return out
476
+ }
477
+
478
+ const isPlainObject = (v) => v !== null && typeof v === 'object' && !Array.isArray(v)
479
+
480
+ // A reference, as a push sends it: the uuid this backend minted for the record the file
481
+ // names (`refResolver`). A list of them is each of those. One whose record this backend
482
+ // has not minted yet is left out and noted as WAITING (`enc.pending`) — the push that
483
+ // completes it sends it. Without a resolver, and for a reference open to several Models
484
+ // (which our schemas do not author), the value is sent as written.
485
+ function encodeReference(value, field, enc, path) {
486
+ if (!enc.refs || typeof field.model !== 'string') return value
487
+ if (field.multiple === true) {
488
+ if (!Array.isArray(value)) {
489
+ enc.shape.push(`"${path}" is a list of references — write it as a list of record names.`)
490
+ return undefined
491
+ }
492
+ const sent = []
493
+ const waiting = []
494
+ value.forEach((one, i) => {
495
+ const uuid = resolveReference(one, field, enc, `${path}[${i}]`, waiting)
496
+ if (uuid !== undefined) sent.push(uuid)
497
+ })
498
+ // A required list with nothing to send yet waits whole; otherwise what is known goes now.
499
+ const required = field.required === true && sent.length === 0
500
+ for (const w of waiting) enc.pending.push({ ...w, required })
501
+ return sent
502
+ }
503
+ const waiting = []
504
+ const uuid = resolveReference(value, field, enc, path, waiting)
505
+ for (const w of waiting) enc.pending.push({ ...w, required: field.required === true })
506
+ return uuid
507
+ }
508
+
509
+ function resolveReference(value, field, enc, path, waiting) {
510
+ const answer = enc.refs.resolve(field.model, value)
511
+ if (answer.uuid) return answer.uuid
512
+ if (answer.pending) waiting.push({ path, model: field.model, name: value })
513
+ else enc.unresolved.push({ path, model: field.model, value, ...answer })
514
+ return undefined
515
+ }
516
+
517
+ // Why a reference names no record the push can send.
518
+ function describeUnresolved({ path, model, value, invalid, ambiguous }) {
519
+ if (invalid) return `"${path}" is a reference — write the name of the ${model} record it points at.`
520
+ if (ambiguous) return `"${path}" names "${value}", and more than one record of ${model} is called that — give them distinct names.`
521
+ return `"${path}" names "${value}", and no record of ${model} is called that — write the name of one, or remove the reference.`
522
+ }
523
+
524
+ // Which record a reference names, and what a push sends for it: the uuid THIS backend
525
+ // minted for that record. A record is named by its handle — its file's name, or its
526
+ // `slug:` — among the records of its Model. A uuid is taken as written too: the record's
527
+ // own id (mapped like a handle's), or the backend's id of a record the project does not
528
+ // hold, which is what a pull writes for one.
529
+ //
530
+ // ⛔ A record this backend has not minted yet — never pushed there — answers `pending`: a
531
+ // uuid it did not mint is never sent (see `buildRecordEntities`), so its referrer waits.
532
+ const AMBIGUOUS = Symbol('ambiguous')
533
+ function refResolver(readSchemas, recordMap) {
534
+ const byModel = new Map() // Model → handle → the record's own id (null before its first push)
535
+ const ownIds = new Set()
536
+ for (const { declaration, flat } of readSchemas) {
537
+ let names = byModel.get(declaration.name)
538
+ if (!names) byModel.set(declaration.name, (names = new Map()))
539
+ for (const rec of flat) {
540
+ const own = typeof rec.$uuid === 'string' && rec.$uuid ? rec.$uuid : null
541
+ names.set(rec.slug, names.has(rec.slug) ? AMBIGUOUS : own)
542
+ if (own) ownIds.add(own)
543
+ }
544
+ }
545
+ const minted = (own) => (own && recordMap[own] ? { uuid: recordMap[own] } : { pending: true })
546
+ return {
547
+ resolve(model, value) {
548
+ if (typeof value !== 'string' || value === '') return { invalid: true }
549
+ const names = byModel.get(model)
550
+ if (names?.has(value)) {
551
+ const own = names.get(value)
552
+ return own === AMBIGUOUS ? { ambiguous: true } : minted(own)
553
+ }
554
+ if (isUuid(value)) return ownIds.has(value) ? minted(value) : { uuid: value }
555
+ return { missing: true }
556
+ },
557
+ }
558
+ }
559
+
434
560
  // "a", "a and b", "a, b and c" — items are already formatted.
435
561
  function listOf(items) {
436
562
  if (items.length <= 1) return items.join('')
@@ -439,42 +565,75 @@ function listOf(items) {
439
565
 
440
566
  const quoted = (keys) => listOf(keys.map((k) => `"${k}"`))
441
567
 
442
- // Why a record written by section cannot be sent, and what to do about each kind of
443
- // section it names: a single section's fields can move to the top of the file; a
444
- // list section has no file form a push can send.
445
- function sectionShapeRefusal(where, keys, listSections, modelName) {
446
- const singles = keys.filter((k) => !listSections.has(k))
447
- const lists = keys.filter((k) => listSections.has(k))
448
- const one = keys.length === 1
449
- let message =
450
- `${where}: ${quoted(keys)} ${one ? 'is a section' : 'are sections'} of ${modelName}, and a ` +
451
- `push reads a record's fields from the top of its file — what ${one ? 'it holds' : 'they hold'} ` +
452
- 'would not be sent.'
453
- if (singles.length) message += ` Move the fields of ${quoted(singles)} up a level.`
454
- if (lists.length) {
455
- message +=
456
- ` ${quoted(lists)} ${lists.length === 1 ? 'holds' : 'hold'} a list, which a push cannot ` +
457
- 'send from a file yet.'
568
+ // A schema written by section — more than one section, or a list at its root — takes no
569
+ // field at the top of the file: say where each one written there belongs. A field that
570
+ // single sections declare belongs under one of them (named with every candidate, since
571
+ // sections are namespaces and only the author knows which one they meant); a field only
572
+ // a list declares goes into one of the list's records.
573
+ function bySectionRefusal(where, modelName, layout, misplaced) {
574
+ const multiple = new Map(layout.sections.map(([n, s]) => [n, s.multiple === true]))
575
+ const moves = []
576
+ const bySection = new Map()
577
+ for (const { key, owners } of misplaced) {
578
+ const singles = owners.filter((o) => !multiple.get(o))
579
+ if (singles.length === 1) {
580
+ if (!bySection.has(singles[0])) bySection.set(singles[0], [])
581
+ bySection.get(singles[0]).push(key)
582
+ } else if (singles.length > 1) {
583
+ moves.push(`"${key}" under ${listOf(singles.map((o) => `"${o}:"`)).replace(/ and ([^ ]+)$/, ' or $1')}`)
584
+ } else {
585
+ moves.push(`"${key}" into a record of ${listOf(owners.map((o) => `"${o}:"`)).replace(/ and ([^ ]+)$/, ' or $1')}`)
586
+ }
458
587
  }
459
- return message
588
+ const grouped = [...bySection].map(([section, keys]) => `${quoted(keys)} under "${section}:"`)
589
+ const names = layout.sections.map(([n]) => n)
590
+ return (
591
+ `${where}: ${modelName} is written by section, each section under its own name ` +
592
+ `(${quoted(names)}) — move ${listOf([...grouped, ...moves])}.`
593
+ )
460
594
  }
461
595
 
462
- // The `required` fields a record's send would lack. The brief is always sent; another
463
- // single section only when the record fills it, so an empty one is not checked. A
464
- // field counts as present when the send carries a value for it (`null` is none).
465
- function missingRequired(recordSections, sectionData, briefName) {
596
+ // The `required` fields the send would lack — checked in every record the send holds:
597
+ // the brief (always sent), each other single section the record fills, each record of a
598
+ // list, and the records nested in them. Each is named by its path in the file —
599
+ // `"title"` in a flat record, `"details.title"` or `"sessions[0].title"` in one written
600
+ // by section. A field counts as present when the send carries a value for it (`null`
601
+ // is none).
602
+ function missingRequired(layout, sections) {
466
603
  const missing = []
467
- for (const [secName, sec] of recordSections) {
468
- const data = sectionData[secName]
469
- if (secName !== briefName && !data) continue
470
- for (const [key, field] of Object.entries(sec.fields || {})) {
471
- if (field?.required !== true || data?.[key] != null) continue
472
- missing.push(secName === briefName ? `"${key}"` : `"${key}" (section "${secName}")`)
604
+ for (const [name, def] of layout.sections) {
605
+ const value = sections[name]
606
+ if (value === undefined) continue
607
+ const base = layout.flat ? '' : name
608
+ if (def.multiple === true) {
609
+ value.forEach((item, i) => missingIn(def, item, `${base}[${i}]`, missing))
610
+ } else {
611
+ missingIn(def, value, base, missing)
473
612
  }
474
613
  }
475
614
  return missing
476
615
  }
477
616
 
617
+ // `def` is the section (or nested section field) `record` is one record of.
618
+ function missingIn(def, record, path, missing) {
619
+ if (!isPlainObject(record)) return
620
+ const at = (key) => (path ? `${path}.${key}` : key)
621
+ for (const [key, field] of Object.entries(def.fields || {})) {
622
+ const value = record[key]
623
+ if (field?.type === 'section') {
624
+ if (field.multiple === true && Array.isArray(value)) {
625
+ value.forEach((item, i) => missingIn(field, item, `${at(key)}[${i}]`, missing))
626
+ } else if (field.multiple !== true && isPlainObject(value)) {
627
+ missingIn(field, value, at(key), missing)
628
+ }
629
+ continue
630
+ }
631
+ if (field?.required === true && value == null) missing.push(`"${at(key)}"`)
632
+ }
633
+ if (def.self_nesting === true && Array.isArray(record.$children)) {
634
+ record.$children.forEach((child, j) => missingIn(def, child, `${path}.children[${j}]`, missing))
635
+ }
636
+ }
478
637
  // Post-pass: override a collection record's localized CONTENT body with a per-locale
479
638
  // FREE-FORM body when `locales/freeform/{locale}/records/<schema>/<slug>.md` exists
480
639
  // — the override wins over the structural map, exactly like site-content sections
@@ -488,15 +647,16 @@ async function applyFreeformRecordOverrides({
488
647
  targetLocales,
489
648
  localesBase,
490
649
  }) {
491
- const briefEntry = Object.entries(declaration.sections || {}).find(([, s]) => s && s.brief === true)
492
- const briefName = briefEntry?.[0]
493
- const fields = briefEntry?.[1]?.fields || {}
494
- // The body target for a free-form override is the prosemirror CONTENT field.
495
- const contentKey = Object.entries(fields).find(([, f]) => isProseMirrorField(f) && f.localized)?.[0]
496
- if (!briefName || !contentKey) return
650
+ // The body target for a free-form override is the schema's CONTENT body field, wherever
651
+ // a single section declares it — only a localized prosemirror one can take a per-locale
652
+ // doc. ⛔ It looked in the brief alone until 2026-09-24, so `@std/article`'s
653
+ // `article_body.content` never took its translation.
654
+ const { target } = contentBodyTarget(declaration)
655
+ if (!target || !isProseMirrorField(target.field) || !target.field.localized) return
656
+ const { section: sectionName, key: contentKey } = target
497
657
 
498
658
  for (const entity of entities) {
499
- const data = entity.document?.[briefName]
659
+ const data = entity.document?.[sectionName]
500
660
  if (!data || data[contentKey] === undefined) continue
501
661
  let localized = data[contentKey]
502
662
  // The source-locale doc to promote to the localized-map form (when the field is
@@ -846,6 +1006,10 @@ export async function buildRecordEntities(siteRoot, opts = {}) {
846
1006
  // within one submission.
847
1007
  const seen = new Set()
848
1008
 
1009
+ // ⭐ EVERY MODEL'S RECORDS ARE READ BEFORE ANY IS MAPPED: a reference names a record
1010
+ // of another Model by its handle (`speaker: ada`), and the push sends that record's
1011
+ // uuid in its place (`refResolver`).
1012
+ const readSchemas = []
849
1013
  for (const [schema, poolEntities] of poolBySchema) {
850
1014
  const label = poolEntities[0].dirs.join('/')
851
1015
  const modelName = modelFor(schema, `${pool.dir}/${label}/`)
@@ -891,7 +1055,11 @@ export async function buildRecordEntities(siteRoot, opts = {}) {
891
1055
  }
892
1056
  producedBy.set(pooled.id, produced)
893
1057
  }
1058
+ readSchemas.push({ label, declaration, flat, sourceBySlug })
1059
+ }
1060
+ const refs = refResolver(readSchemas, recordMap)
894
1061
 
1062
+ for (const { label, declaration, flat, sourceBySlug } of readSchemas) {
895
1063
  const ownIds = new Map()
896
1064
  const onWire = flat.map((rec) => {
897
1065
  const own = typeof rec.$uuid === 'string' && rec.$uuid ? rec.$uuid : null
@@ -907,6 +1075,7 @@ export async function buildRecordEntities(siteRoot, opts = {}) {
907
1075
  declaration,
908
1076
  sourceLocale,
909
1077
  translations,
1078
+ refs,
910
1079
  })
911
1080
  // Free-form per-locale body overrides (a full localized doc beats the structural
912
1081
  // map) — only meaningful for a multi-locale site with a prosemirror content field.