@uniweb/build 0.61.0 → 0.63.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,40 @@
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 is new, so this
29
+ // push creates it, and the reference names it by its `$id` in the package —
30
+ // `speaker: { $ref: "speaker/ada" }` — which the backend resolves to the uuid it mints.
31
+ // One push creates both. ⛔ Until 2026-09-25 such a referrer WAITED, sent without the
32
+ // reference or held back, and the CLI pushed again to complete it.
33
+ // ⚠️ item_ref and file fields are still sent as the file writes them.
31
34
  //
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.
35
+ // ⚠️ A record of a `many` section is sent without a `$uuid`, so a push that changes a
36
+ // record whose section the backend already holds is REFUSED, whole (`identity_required`):
37
+ // applied, it would re-identify every stored item. Measured 2026-09-25 — this said such a
38
+ // push "replaces that section's records" until then. Its first push, which creates the
39
+ // items, is not affected.
41
40
 
42
41
  import { readBackendState } from './sync-store.js'
43
42
  import { readFileSync, existsSync } from 'node:fs'
@@ -55,23 +54,26 @@ import {
55
54
  poolPathReadings,
56
55
  poolDirsForSchema,
57
56
  } from '../site/entity-pool.js'
58
- import { toDataSchemaDeclaration, isProseMirrorField, isMarkupTextField, isContentBodyField } from './data-schema.js'
57
+ import { toDataSchemaDeclaration, isProseMirrorField, isMarkupTextField } from './data-schema.js'
58
+ import { recordFileLayout, contentBodyTarget } from './record-layout.js'
59
59
  import { emitEntitySyncPackage } from './entity-document.js'
60
60
  import { resolveSelfScope, siteSelfScope, refuseOrgOption } from './self-scope.js'
61
61
  import { sha256Hex, toJsonBuffer } from './manifest.js'
62
+ import { isUuid } from './uuid.js'
62
63
  import { markdownToProseMirror } from '@uniweb/content-reader'
63
64
  import { LOCALIZED_FIELD_ASSUMPTION, localize } from './localize.js'
64
- import { localizeScalar, localizeContentDoc, loadLocaleTranslations, discoverLocales, discoverFreeformLocales, localesDir, isLocalizedContent } from './locale-sync.js'
65
+ import { localizeScalar, localizeScalarList, localizeContentDoc, loadLocaleTranslations, discoverLocales, discoverFreeformLocales, localesDir, isLocalizedContent } from './locale-sync.js'
65
66
  import { loadFreeformRecord } from '../i18n/freeform.js'
66
67
  import { isDraftRecord } from '../site/record-draft.js'
67
68
 
68
69
  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).
70
+ // The record's OWN keys — identity and transport, never a field of any section, never
71
+ // warned. `$body` carries the markdown body (mapped to the schema's content body field,
72
+ // wherever a single section declares it).
71
73
  // Note: there is NO delivery-derived ignore list (route/excerpt/image/content) —
72
74
  // the source reader never produces those, and a real unknown key SHOULD warn (it
73
75
  // means the frontmatter doesn't match the collection's data schema).
74
- const SKIP_KEYS = new Set([
76
+ const RECORD_KEYS = new Set([
75
77
  'slug',
76
78
  '$id',
77
79
  '$uuid',
@@ -87,24 +89,30 @@ const SKIP_KEYS = new Set([
87
89
 
88
90
  // Recursively drop IDENTITY `$`-sigil keys (`$uuid`/`$id`/`$schema`/… — never
89
91
  // field data; the sigil-exclusivity invariant guarantees this) at every level,
90
- // so a back-filled `$uuid` doesn't change the hash. Two are exceptions, because
92
+ // so a back-filled `$uuid` doesn't change the hash. Three are exceptions, because
91
93
  // they are content rather than identity:
92
94
  // - `$children` is STRUCTURAL content (a self-nesting record's subtree, e.g.
93
95
  // 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.
96
+ // otherwise a nesting change would be invisible to "send only changed". A record
97
+ // of a self-nesting list (`children:` in its file) carries them too.
96
98
  // - `$disabled` is a record's delivery STATE (`draft: true` on the file side). It
97
99
  // is KEPT so that drafting or un-drafting a record with nothing else changed is
98
100
  // still sent; stripped, "send only changed" would never send the toggle.
101
+ // - `$ref` is a REFERENCE to a record the same push creates (`speaker: { $ref:
102
+ // "speaker/ada" }`), and which record it names is content. Once the backend has
103
+ // minted that record the next push names it by uuid instead — so the CLI re-banks a
104
+ // record that sent a `$ref` after its push (`namesNew`, `sync-package.js`). The
105
+ // folder's ref leaf is not this case: see below.
99
106
  function stripSigils(value) {
100
107
  if (Array.isArray(value)) return value.map(stripSigils)
101
108
  if (value && typeof value === 'object') {
102
109
  // ⛔ A `@uniweb/folder` REF LEAF ENCODES ONE REFERENCE TWO WAYS, and hashing the
103
110
  // encoding rather than the reference made the folder's hash unreproducible.
104
111
  //
105
- // `refLeaf` (uwx/folder.js) emits `$ref: <the record's $id>` — the pool position
106
- // `<dirs>/<slug>` — while the record is brand-new, and `entry: { schema, entity:
107
- // <uuid> }` once it has been minted.
112
+ // `refLeaf` (uwx/folder.js) emits `entry: { $ref: <the record's $id> }` — the pool
113
+ // position `<dirs>/<slug>` — while the record is brand-new, and `entry: { schema,
114
+ // entity: <uuid> }` once it has been minted. (`$ref` was a key of the leaf itself
115
+ // until 2026-09-25, when the backend made it a reference's value.)
108
116
  // Both denote the same record. A push hashes the folder BEFORE submitting, then
109
117
  // back-fills the minted `$uuid` into every record's source file — so the very
110
118
  // next emit builds the OTHER encoding, and the hash the push just banked can
@@ -118,19 +126,20 @@ function stripSigils(value) {
118
126
  // `name` (the record's handle) at its position inside the branch that holds it.
119
127
  // Two siblings may share a name — records of different schemas can — and position
120
128
  // still tells them apart; a leaf that comes to reference another record arrives
121
- // with that record changed, which re-sends the folder anyway. `$ref` adds a
122
- // payload-local handle and `entry` adds identity, and both are exactly what
123
- // `$uuid` is stripped for.
129
+ // with that record changed, which re-sends the folder anyway. `entry` carries a
130
+ // payload-local handle or an identity, and both are exactly what `$uuid` is
131
+ // stripped for.
124
132
  //
125
133
  // ⚖️ The previous rule kept `$ref` "so a reference change is visible". It still
126
134
  // is: point a leaf at a different record and its `name` moves with it.
127
135
  const isFolderRefLeaf = value.kind === 'ref'
128
136
  const out = {}
129
137
  for (const [k, v] of Object.entries(value)) {
130
- if (isFolderRefLeaf && (k === '$ref' || k === 'entry')) continue
131
- // `$children` (a self-nesting subtree) and `$disabled` (a delivery state) are
132
- // CONTENT, not identity sigils — kept, so a change to either stays visible.
133
- if (k === '$children' || k === '$disabled') {
138
+ if (isFolderRefLeaf && k === 'entry') continue
139
+ // `$children` (a self-nesting subtree), `$disabled` (a delivery state) and `$ref`
140
+ // (a reference to a record of the same push) are CONTENT, not identity sigils —
141
+ // kept, so a change to any of them stays visible.
142
+ if (k === '$children' || k === '$disabled' || k === '$ref') {
134
143
  out[k] = stripSigils(v)
135
144
  continue
136
145
  }
@@ -170,6 +179,12 @@ function encodeFieldValue(value, field, sourceLocale, translations) {
170
179
  return isLocalizedContent(localized) ? localized : { [sourceLocale]: localized }
171
180
  }
172
181
  if (field.localized) {
182
+ // A list of localized values rides as a list of `{ lang: value }` maps, one per
183
+ // element — the shape page `keywords` already take. ⛔ It went up as a bare list
184
+ // until 2026-09-24: `localizeScalar` passes any object through, arrays included.
185
+ if (Array.isArray(value) && !isMarkupTextField(field)) {
186
+ return localizeScalarList(value, sourceLocale, translations)
187
+ }
173
188
  // A markup `text` BODY (format markdown|html) rides as a RAW string, wrapped
174
189
  // per-locale wholesale (its per-string translations live in the i18n manifest /
175
190
  // free-form, not the scalar map). Other localized scalars wrap per-string from
@@ -195,20 +210,29 @@ function encodeFieldValue(value, field, sourceLocale, translations) {
195
210
  * no I/O, no minting. The backend mints `$uuid` on first sync; a record that
196
211
  * already carries `$uuid` (back-filled from a prior sync) round-trips it.
197
212
  *
213
+ * A record is read by its schema's layout (`./record-layout.js`): FLAT when the schema
214
+ * has one top-level section holding one record, its keys that section's fields; BY
215
+ * SECTION otherwise, each top-level section under its own name. Either way the document
216
+ * is the same section-keyed shape, so a pull writes the record back as it was written.
217
+ *
198
218
  * @param {object} params
199
219
  * @param {string} params.label - what the records are, for messages and the path
200
220
  * inside the package — the schema folder they came from (`article`,
201
221
  * `std/person`)
202
- * @param {object[]} params.records - [{ slug, ...fields }]
222
+ * @param {object[]} params.records - [{ slug, ...the file's keys }], a
223
+ * markdown body under `$body`
203
224
  * @param {object} params.declaration - the `@uniweb/data-schema` declaration
204
- * (from toDataSchemaDeclaration): `{ name, brief, sections }`
225
+ * (from toDataSchemaDeclaration): `{ name, sections }`
205
226
  * @param {string} [params.sourceLocale] - locale for localized-field wrap
206
227
  * @param {object} [params.translations] - `{ locale: { hash: tgt } }` for wrapping
207
228
  * localized scalar fields per-locale (from loadLocaleTranslations)
229
+ * @param {{ resolve: (model: string, value: *) => object }} [params.refs] - what a
230
+ * reference sends (`refResolver`). Without it a reference is sent as written.
208
231
  * @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.
232
+ * entity is `{ id, uuid, slug, model, file, document, namesNew }` — `document` is the
233
+ * section-keyed body, and `namesNew` how many of its references name a record this
234
+ * push creates, by `$ref`. `refusals` names the records that cannot be sent as
235
+ * written, one line each; a caller that sends must not send while any is present.
212
236
  */
213
237
  export function recordsToEntities({
214
238
  label,
@@ -216,83 +240,29 @@ export function recordsToEntities({
216
240
  declaration,
217
241
  sourceLocale = LOCALIZED_FIELD_ASSUMPTION.defaultSourceLocale,
218
242
  translations,
243
+ refs = null,
219
244
  }) {
220
245
  if (!declaration || !declaration.name) {
221
246
  throw new Error('uwx/records: a declaration with a name is required')
222
247
  }
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
- }
248
+ const layout = recordFileLayout(declaration)
249
+ if (layout.sections.length === 0) {
250
+ throw new Error(`uwx/records: ${declaration.name} declares no sections`)
263
251
  }
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
252
+ // The markdown body of a `.md` record is the value of the schema's CONTENT body field,
253
+ // wherever a top-level single section declares it — the brief, or a body section like
254
+ // `article_body.content`. One field is the target; zero means a body has nowhere to go.
255
+ const { target: bodyTarget, count: bodyFields } = contentBodyTarget(declaration)
286
256
 
287
257
  const entities = []
288
258
  const warnings = []
289
- // Records that cannot be sent as written — see the ⛔ at the end of the loop.
259
+ // Records that cannot be sent as written — see the ⛔ below.
290
260
  const refusals = []
291
- if (contentMatches.length > 1) {
261
+ if (bodyFields > 1) {
292
262
  warnings.push(
293
263
  `${label}: ${declaration.name} has more than one content ` +
294
264
  `(markdown / html / prosemirror) field — the markdown body maps to ` +
295
- `"${bodyTarget.secName}.${bodyTarget.key}"`
265
+ `"${bodyTarget.section}.${bodyTarget.key}"`
296
266
  )
297
267
  }
298
268
  for (const record of records || []) {
@@ -301,6 +271,7 @@ export function recordsToEntities({
301
271
  warnings.push(`${label}: a record without a slug was skipped`)
302
272
  continue
303
273
  }
274
+ const where = `${label}/${slug}`
304
275
  // ⛔ `$id` IS NOT THE SLUG. It is the payload-local, PATH-QUALIFIED handle, so
305
276
  // the @uniweb/folder entity can point a leaf at it via `$ref`. An explicit
306
277
  // frontmatter `$id` wins.
@@ -319,88 +290,62 @@ export function recordsToEntities({
319
290
  const uuid = record.$uuid || null
320
291
  const hasBody = typeof record.$body === 'string' && record.$body.trim() !== ''
321
292
 
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
293
+ const enc = encoder(sourceLocale, translations, refs)
294
+ const sections = layout.flat ? readFlat(layout, record, enc) : readBySection(layout, record, enc)
295
+ // The markdown body fills the content body field, in whatever section declares it,
296
+ // unless the file already sets that field.
297
+ if (hasBody && bodyTarget) {
298
+ const data = (sections[bodyTarget.section] ??= {})
299
+ if (data[bodyTarget.key] == null) {
300
+ data[bodyTarget.key] = encodeFieldValue(record.$body, bodyTarget.field, sourceLocale, translations)
338
301
  }
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
302
  }
303
+ // The brief is the card, and is always sent — an empty one included, so a required
304
+ // field it lacks is refused here rather than stored empty.
305
+ if (layout.brief && !sections[layout.brief]) sections[layout.brief] = {}
351
306
 
352
307
  // ⛔ 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
308
+ // before anything is sent: a key written where the schema has no place for it
309
+ // (flat in a schema written by section, or declared nowhere), a value of the wrong
310
+ // shape for its section, a markdown body with no field to hold it, and a `required`
311
+ // field the send would lack. Measured 2026-09-23: a record the backend refused
312
+ // left the folder's entries with no data behind them, and every later push of the
313
+ // site's records was refused. The backend's push has been one transaction since
314
+ // 2026-09-24, so a refusal now writes nothing — but what a push cannot carry is
315
+ // still lost twice over: the backend never receives it, and the next pull writes
316
+ // the file without it.
317
+ if (enc.misplaced.length) refusals.push(bySectionRefusal(where, declaration.name, layout, enc.misplaced))
318
+ if (enc.undeclared.length) {
319
+ const one = enc.undeclared.length === 1
375
320
  refusals.push(
376
- `${label}/${slug}: ${quoted(undeclared)} ${one ? 'is not a field' : 'are not fields'} of ` +
321
+ `${where}: ${quoted(enc.undeclared)} ${one ? 'is not a field' : 'are not fields'} of ` +
377
322
  `${declaration.name} — a push cannot carry ${one ? 'it' : 'them'}, and the next pull would ` +
378
323
  `drop ${one ? 'it' : 'them'} from the file. Declare ${one ? 'it' : 'them'} in the schema, ` +
379
324
  `or remove ${one ? 'it' : 'them'}.`
380
325
  )
381
326
  }
327
+ for (const problem of enc.shape) refusals.push(`${where}: ${problem}`)
328
+ for (const bad of enc.unresolved) refusals.push(`${where}: ${describeUnresolved(bad)}`)
382
329
  if (hasBody && !bodyTarget) {
383
330
  refusals.push(
384
- `${label}/${slug}: the file has a markdown body, and ${declaration.name} has no field for ` +
331
+ `${where}: the file has a markdown body, and ${declaration.name} has no field for ` +
385
332
  'it (a `markdown` or `richtext` field) — a push cannot carry it. Add one to the schema, ' +
386
333
  'or move the text into a field.'
387
334
  )
388
335
  }
389
- if (bySection.length) {
390
- refusals.push(sectionShapeRefusal(`${label}/${slug}`, bySection, listSections, declaration.name))
391
- } else {
392
- const missing = missingRequired(recordSections, sectionData, briefName)
336
+ if (!enc.misplaced.length && !enc.shape.length) {
337
+ const missing = missingRequired(layout, sections)
393
338
  if (missing.length) {
394
339
  refusals.push(
395
- `${label}/${slug}: ${declaration.name} requires ${listOf(missing)}, and this record ` +
340
+ `${where}: ${declaration.name} requires ${listOf(missing)}, and this record ` +
396
341
  `has no value for ${missing.length === 1 ? 'it' : 'them'}.`
397
342
  )
398
343
  }
399
344
  }
400
345
 
401
346
  // 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
347
+ // then the brief (the card) and every other section the record holds, in declared
348
+ // order. `$owner`/`$unit`/`$meta` are omitted — the backend binds owner + unit on
404
349
  // its side.
405
350
  const document = {}
406
351
  if (uuid) document.$uuid = uuid
@@ -410,10 +355,10 @@ export function recordsToEntities({
410
355
  // publicly delivered. Only `true` travels. An absent key means enabled [Diego,
411
356
  // 2026-09-21], so a record whose `draft: true` is removed is sent without the key
412
357
  // 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]
358
+ if (isDraftRecord(record, where)) document.$disabled = true
359
+ if (layout.brief) document[layout.brief] = sections[layout.brief]
360
+ for (const [name] of layout.sections) {
361
+ if (name !== layout.brief && sections[name] !== undefined) document[name] = sections[name]
417
362
  }
418
363
 
419
364
  entities.push({
@@ -426,11 +371,198 @@ export function recordsToEntities({
426
371
  // unique within its schema folder, and an authored `$id` need not be.
427
372
  file: `entities/${label}/${slug}.json`,
428
373
  document,
374
+ namesNew: enc.namesNew,
429
375
  })
430
376
  }
431
377
  return { entities, warnings, refusals }
432
378
  }
433
379
 
380
+ // What one record's read collects beside the values: the keys with no place in the
381
+ // schema, the keys written flat in a schema written by section, and values of the
382
+ // wrong shape for their section.
383
+ function encoder(sourceLocale, translations, refs = null) {
384
+ return { sourceLocale, translations, refs, undeclared: [], misplaced: [], shape: [], unresolved: [], namesNew: 0 }
385
+ }
386
+
387
+ // A flat record: its keys, minus the record's own, are the one section's fields.
388
+ function readFlat(layout, record, enc) {
389
+ const [name, def] = layout.sections[0]
390
+ const values = {}
391
+ for (const [key, value] of Object.entries(record)) {
392
+ if (!RECORD_KEYS.has(key)) values[key] = value
393
+ }
394
+ // The record's handle fills a `slug` field the section declares, as the flat form
395
+ // always has (`@std/article`'s brief has one). Written by section, the field is
396
+ // written under its section like any other.
397
+ if (record.slug !== undefined && Object.hasOwn(def.fields || {}, 'slug') && values.slug === undefined) {
398
+ values.slug = record.slug
399
+ }
400
+ return { [name]: encodeRecord(def.fields, values, enc, '', null) }
401
+ }
402
+
403
+ // A record written by section: each top-level key names a section of the schema. A key
404
+ // that is instead a FIELD of one is the retired flat form — refused, naming the section
405
+ // it belongs under — and a key that is neither is undeclared.
406
+ function readBySection(layout, record, enc) {
407
+ const byName = new Map(layout.sections)
408
+ const out = {}
409
+ for (const [key, value] of Object.entries(record)) {
410
+ if (RECORD_KEYS.has(key)) continue
411
+ const def = byName.get(key)
412
+ if (def) {
413
+ const encoded = encodeSection(def, value, enc, key)
414
+ if (encoded !== undefined) out[key] = encoded
415
+ continue
416
+ }
417
+ const owners = layout.sections.filter(([, s]) => s.fields && Object.hasOwn(s.fields, key)).map(([n]) => n)
418
+ if (owners.length) enc.misplaced.push({ key, owners })
419
+ else enc.undeclared.push(key)
420
+ }
421
+ return out
422
+ }
423
+
424
+ // A section's value: one record for a single section (a binder included — its fields
425
+ // are its child sections), a list of records for a `many` one.
426
+ function encodeSection(def, value, enc, path) {
427
+ if (def.multiple === true) {
428
+ if (!Array.isArray(value)) {
429
+ enc.shape.push(`"${path}" holds a list of records — write it as a list ("- …" under "${path}:").`)
430
+ return undefined
431
+ }
432
+ return value.map((item, i) => encodeItem(def, item, enc, `${path}[${i}]`))
433
+ }
434
+ if (!isPlainObject(value)) {
435
+ enc.shape.push(`"${path}" is a section — write its fields under it ("${path}:" then each field indented).`)
436
+ return undefined
437
+ }
438
+ return encodeRecord(def.fields, value, enc, `${path}.`, null)
439
+ }
440
+
441
+ // One record of a `many` section. A self-nesting section's records nest under the
442
+ // reserved `children:` key in a file (as `@std/nav` documents it) and under
443
+ // `$children` on the wire, where the backend derives each one's parent.
444
+ function encodeItem(def, item, enc, path) {
445
+ if (!isPlainObject(item)) {
446
+ enc.shape.push(`"${path}" is one record of a list — write it as a map of its fields.`)
447
+ return {}
448
+ }
449
+ const nests = def.self_nesting === true
450
+ const out = encodeRecord(def.fields, item, enc, `${path}.`, nests ? 'children' : null)
451
+ if (nests && item.children !== undefined) {
452
+ if (!Array.isArray(item.children)) {
453
+ enc.shape.push(`"${path}.children" nests records under this one — write it as a list.`)
454
+ } else {
455
+ out.$children = item.children.map((child, j) => encodeItem(def, child, enc, `${path}.children[${j}]`))
456
+ }
457
+ }
458
+ return out
459
+ }
460
+
461
+ // Encode one record's fields in declared order — a nested section by its own
462
+ // declaration, a leaf by its kind. A key the fields do not declare is recorded as
463
+ // undeclared; `$`-keys (identity a pull may have left on a record) are not content, and
464
+ // `reserved` is the one extra key a caller allows (`children`, on a self-nesting list).
465
+ function encodeRecord(fields, value, enc, prefix, reserved) {
466
+ const out = {}
467
+ for (const [key, field] of Object.entries(fields || {})) {
468
+ const v = value[key]
469
+ if (v === undefined) continue
470
+ const encoded =
471
+ field?.type === 'section'
472
+ ? encodeSection(field, v, enc, `${prefix}${key}`)
473
+ : field?.type === 'entity_ref'
474
+ ? encodeReference(v, field, enc, `${prefix}${key}`)
475
+ : encodeFieldValue(v, field, enc.sourceLocale, enc.translations)
476
+ if (encoded !== undefined) out[key] = encoded
477
+ }
478
+ for (const key of Object.keys(value)) {
479
+ if (key === reserved || key.startsWith('$') || Object.hasOwn(fields || {}, key)) continue
480
+ enc.undeclared.push(`${prefix}${key}`)
481
+ }
482
+ return out
483
+ }
484
+
485
+ const isPlainObject = (v) => v !== null && typeof v === 'object' && !Array.isArray(v)
486
+
487
+ // A reference, as a push sends it: the uuid this backend minted for the record the file
488
+ // names (`refResolver`) — or, for a record it has not minted, which this push creates,
489
+ // `{ $ref: <that record's $id> }`, the backend's name for a record of the same package.
490
+ // A list of them is each of those. Without a resolver, and for a reference open to
491
+ // several Models (which our schemas do not author), the value is sent as written.
492
+ function encodeReference(value, field, enc, path) {
493
+ if (!enc.refs || typeof field.model !== 'string') return value
494
+ if (field.multiple === true) {
495
+ if (!Array.isArray(value)) {
496
+ enc.shape.push(`"${path}" is a list of references — write it as a list of record names.`)
497
+ return undefined
498
+ }
499
+ const sent = []
500
+ value.forEach((one, i) => {
501
+ const ref = resolveReference(one, field, enc, `${path}[${i}]`)
502
+ if (ref !== undefined) sent.push(ref)
503
+ })
504
+ return sent
505
+ }
506
+ return resolveReference(value, field, enc, path)
507
+ }
508
+
509
+ function resolveReference(value, field, enc, path) {
510
+ const answer = enc.refs.resolve(field.model, value)
511
+ if (answer.uuid) return answer.uuid
512
+ if (answer.ref) {
513
+ enc.namesNew++
514
+ return { $ref: answer.ref }
515
+ }
516
+ enc.unresolved.push({ path, model: field.model, value, ...answer })
517
+ return undefined
518
+ }
519
+
520
+ // Why a reference names no record the push can send.
521
+ function describeUnresolved({ path, model, value, invalid, ambiguous }) {
522
+ if (invalid) return `"${path}" is a reference — write the name of the ${model} record it points at.`
523
+ if (ambiguous) return `"${path}" names "${value}", and more than one record of ${model} is called that — give them distinct names.`
524
+ return `"${path}" names "${value}", and no record of ${model} is called that — write the name of one, or remove the reference.`
525
+ }
526
+
527
+ // Which record a reference names, and what a push sends for it: the uuid THIS backend
528
+ // minted for that record. A record is named by its handle — its file's name, or its
529
+ // `slug:` — among the records of its Model. A uuid is taken as written too: the record's
530
+ // own id (mapped like a handle's), or the backend's id of a record the project does not
531
+ // hold, which is what a pull writes for one.
532
+ //
533
+ // ⭐ A record this backend has not minted yet — never pushed there — answers with its
534
+ // `$id` (`ref`): it is new, so this push creates it, and the backend takes
535
+ // `{ $ref: <$id> }` for a record of the same package. A uuid it did not mint is never
536
+ // sent (see `buildRecordEntities`).
537
+ const AMBIGUOUS = Symbol('ambiguous')
538
+ function refResolver(readSchemas, recordMap) {
539
+ const byModel = new Map() // Model → handle → the record: its own id (null before its first push) and `$id`
540
+ const byOwnId = new Map() // the record's own id → the record
541
+ for (const { declaration, flat } of readSchemas) {
542
+ let names = byModel.get(declaration.name)
543
+ if (!names) byModel.set(declaration.name, (names = new Map()))
544
+ for (const rec of flat) {
545
+ const own = typeof rec.$uuid === 'string' && rec.$uuid ? rec.$uuid : null
546
+ const record = { own, id: rec.$id }
547
+ names.set(rec.slug, names.has(rec.slug) ? AMBIGUOUS : record)
548
+ if (own) byOwnId.set(own, record)
549
+ }
550
+ }
551
+ const sent = ({ own, id }) => (own && recordMap[own] ? { uuid: recordMap[own] } : { ref: id })
552
+ return {
553
+ resolve(model, value) {
554
+ if (typeof value !== 'string' || value === '') return { invalid: true }
555
+ const names = byModel.get(model)
556
+ if (names?.has(value)) {
557
+ const record = names.get(value)
558
+ return record === AMBIGUOUS ? { ambiguous: true } : sent(record)
559
+ }
560
+ if (isUuid(value)) return byOwnId.has(value) ? sent(byOwnId.get(value)) : { uuid: value }
561
+ return { missing: true }
562
+ },
563
+ }
564
+ }
565
+
434
566
  // "a", "a and b", "a, b and c" — items are already formatted.
435
567
  function listOf(items) {
436
568
  if (items.length <= 1) return items.join('')
@@ -439,42 +571,75 @@ function listOf(items) {
439
571
 
440
572
  const quoted = (keys) => listOf(keys.map((k) => `"${k}"`))
441
573
 
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.'
574
+ // A schema written by section — more than one section, or a list at its root — takes no
575
+ // field at the top of the file: say where each one written there belongs. A field that
576
+ // single sections declare belongs under one of them (named with every candidate, since
577
+ // sections are namespaces and only the author knows which one they meant); a field only
578
+ // a list declares goes into one of the list's records.
579
+ function bySectionRefusal(where, modelName, layout, misplaced) {
580
+ const multiple = new Map(layout.sections.map(([n, s]) => [n, s.multiple === true]))
581
+ const moves = []
582
+ const bySection = new Map()
583
+ for (const { key, owners } of misplaced) {
584
+ const singles = owners.filter((o) => !multiple.get(o))
585
+ if (singles.length === 1) {
586
+ if (!bySection.has(singles[0])) bySection.set(singles[0], [])
587
+ bySection.get(singles[0]).push(key)
588
+ } else if (singles.length > 1) {
589
+ moves.push(`"${key}" under ${listOf(singles.map((o) => `"${o}:"`)).replace(/ and ([^ ]+)$/, ' or $1')}`)
590
+ } else {
591
+ moves.push(`"${key}" into a record of ${listOf(owners.map((o) => `"${o}:"`)).replace(/ and ([^ ]+)$/, ' or $1')}`)
592
+ }
458
593
  }
459
- return message
594
+ const grouped = [...bySection].map(([section, keys]) => `${quoted(keys)} under "${section}:"`)
595
+ const names = layout.sections.map(([n]) => n)
596
+ return (
597
+ `${where}: ${modelName} is written by section, each section under its own name ` +
598
+ `(${quoted(names)}) — move ${listOf([...grouped, ...moves])}.`
599
+ )
460
600
  }
461
601
 
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) {
602
+ // The `required` fields the send would lack — checked in every record the send holds:
603
+ // the brief (always sent), each other single section the record fills, each record of a
604
+ // list, and the records nested in them. Each is named by its path in the file —
605
+ // `"title"` in a flat record, `"details.title"` or `"sessions[0].title"` in one written
606
+ // by section. A field counts as present when the send carries a value for it (`null`
607
+ // is none).
608
+ function missingRequired(layout, sections) {
466
609
  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}")`)
610
+ for (const [name, def] of layout.sections) {
611
+ const value = sections[name]
612
+ if (value === undefined) continue
613
+ const base = layout.flat ? '' : name
614
+ if (def.multiple === true) {
615
+ value.forEach((item, i) => missingIn(def, item, `${base}[${i}]`, missing))
616
+ } else {
617
+ missingIn(def, value, base, missing)
473
618
  }
474
619
  }
475
620
  return missing
476
621
  }
477
622
 
623
+ // `def` is the section (or nested section field) `record` is one record of.
624
+ function missingIn(def, record, path, missing) {
625
+ if (!isPlainObject(record)) return
626
+ const at = (key) => (path ? `${path}.${key}` : key)
627
+ for (const [key, field] of Object.entries(def.fields || {})) {
628
+ const value = record[key]
629
+ if (field?.type === 'section') {
630
+ if (field.multiple === true && Array.isArray(value)) {
631
+ value.forEach((item, i) => missingIn(field, item, `${at(key)}[${i}]`, missing))
632
+ } else if (field.multiple !== true && isPlainObject(value)) {
633
+ missingIn(field, value, at(key), missing)
634
+ }
635
+ continue
636
+ }
637
+ if (field?.required === true && value == null) missing.push(`"${at(key)}"`)
638
+ }
639
+ if (def.self_nesting === true && Array.isArray(record.$children)) {
640
+ record.$children.forEach((child, j) => missingIn(def, child, `${path}.children[${j}]`, missing))
641
+ }
642
+ }
478
643
  // Post-pass: override a collection record's localized CONTENT body with a per-locale
479
644
  // FREE-FORM body when `locales/freeform/{locale}/records/<schema>/<slug>.md` exists
480
645
  // — the override wins over the structural map, exactly like site-content sections
@@ -488,15 +653,16 @@ async function applyFreeformRecordOverrides({
488
653
  targetLocales,
489
654
  localesBase,
490
655
  }) {
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
656
+ // The body target for a free-form override is the schema's CONTENT body field, wherever
657
+ // a single section declares it — only a localized prosemirror one can take a per-locale
658
+ // doc. ⛔ It looked in the brief alone until 2026-09-24, so `@std/article`'s
659
+ // `article_body.content` never took its translation.
660
+ const { target } = contentBodyTarget(declaration)
661
+ if (!target || !isProseMirrorField(target.field) || !target.field.localized) return
662
+ const { section: sectionName, key: contentKey } = target
497
663
 
498
664
  for (const entity of entities) {
499
- const data = entity.document?.[briefName]
665
+ const data = entity.document?.[sectionName]
500
666
  if (!data || data[contentKey] === undefined) continue
501
667
  let localized = data[contentKey]
502
668
  // The source-locale doc to promote to the localized-map form (when the field is
@@ -846,6 +1012,10 @@ export async function buildRecordEntities(siteRoot, opts = {}) {
846
1012
  // within one submission.
847
1013
  const seen = new Set()
848
1014
 
1015
+ // ⭐ EVERY MODEL'S RECORDS ARE READ BEFORE ANY IS MAPPED: a reference names a record
1016
+ // of another Model by its handle (`speaker: ada`), and the push sends that record's
1017
+ // uuid in its place — or its `$id`, while it is new (`refResolver`).
1018
+ const readSchemas = []
849
1019
  for (const [schema, poolEntities] of poolBySchema) {
850
1020
  const label = poolEntities[0].dirs.join('/')
851
1021
  const modelName = modelFor(schema, `${pool.dir}/${label}/`)
@@ -891,7 +1061,11 @@ export async function buildRecordEntities(siteRoot, opts = {}) {
891
1061
  }
892
1062
  producedBy.set(pooled.id, produced)
893
1063
  }
1064
+ readSchemas.push({ label, declaration, flat, sourceBySlug })
1065
+ }
1066
+ const refs = refResolver(readSchemas, recordMap)
894
1067
 
1068
+ for (const { label, declaration, flat, sourceBySlug } of readSchemas) {
895
1069
  const ownIds = new Map()
896
1070
  const onWire = flat.map((rec) => {
897
1071
  const own = typeof rec.$uuid === 'string' && rec.$uuid ? rec.$uuid : null
@@ -907,6 +1081,7 @@ export async function buildRecordEntities(siteRoot, opts = {}) {
907
1081
  declaration,
908
1082
  sourceLocale,
909
1083
  translations,
1084
+ refs,
910
1085
  })
911
1086
  // Free-form per-locale body overrides (a full localized doc beats the structural
912
1087
  // map) — only meaningful for a multi-locale site with a prosemirror content field.