@uniweb/build 0.61.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.
@@ -32,7 +32,8 @@ import yaml from 'js-yaml'
32
32
  import { YAML_OPTIONS } from '../utils/yaml-schema.js'
33
33
  import { proseMirrorToMarkdown } from '@uniweb/content-writer'
34
34
  import { parseFrontmatter } from './entity-source.js'
35
- import { isProseMirrorField, isContentBodyField } from './data-schema.js'
35
+ import { isProseMirrorField } from './data-schema.js'
36
+ import { recordFileLayout, contentBodyTarget } from './record-layout.js'
36
37
  import { unwrapLocalizedContent } from './locale-sync.js'
37
38
  import { parseBibtex, exportBibtex } from '@citestyle/bibtex'
38
39
 
@@ -213,28 +214,32 @@ export function unwrapLocalizedList(value, sourceLocale) {
213
214
  return unwrapLocalized(value, sourceLocale)
214
215
  }
215
216
 
216
- // The brief is the section marked `brief: true` in the declaration's sections map
217
- // (the sections-tree has no schema-level `brief:` back-reference). Returned with
218
- // its name attached (the map key) so callers can key the document by it.
219
- function briefSectionOf(declaration) {
220
- const entry = Object.entries(declaration?.sections || {}).find(([, s]) => s && s.brief === true)
221
- return entry ? { name: entry[0], ...entry[1] } : null
222
- }
217
+ const isPlainObject = (v) => v !== null && typeof v === 'object' && !Array.isArray(v)
223
218
 
224
219
  /**
225
220
  * Render an entity `document` a pull brought back to its source-file authoring shape
226
221
  * — the file becomes a projection of the backend's state, which is what a pull is
227
- * (`writeRecordFile`, `records-project.js`). A push never uses it (header ⛔). For a
228
- * flat/brief entity: the entity `$uuid` + the brief section's fields (localized unwrapped,
229
- * date/scalars verbatim), with the brief record's own `$uuid` DROPPED (the backend
230
- * matches a single-section item by singularity) and `$schema`/`$id`/`$meta` omitted.
231
- * A disabled entity (`$disabled: true`) writes `draft: true`, and an enabled one writes
232
- * no `draft:` at all.
222
+ * (`writeRecordFile`, `records-project.js`). A push never uses it (header ⛔).
223
+ *
224
+ * The file takes its schema's layout (`./record-layout.js`), the one the push reads:
225
+ * FLAT for a schema with one top-level section holding one record — that section's
226
+ * fields at the top — and BY SECTION otherwise, every section the document holds under
227
+ * its own name: an object for a single section, a list of records for a `many` one, a
228
+ * self-nesting record's `$children` written back as `children:`. Values are decoded by
229
+ * their declaration at every depth (localized unwrapped to the source locale, a
230
+ * ProseMirror doc to markdown, dates and scalars verbatim). Every record's own `$uuid`
231
+ * is dropped — the backend matches a single section's record by singularity — and
232
+ * `$schema`/`$id`/`$meta` are omitted; the entity's `$uuid` leads. A disabled entity
233
+ * (`$disabled: true`) writes `draft: true`, and an enabled one writes no `draft:`.
233
234
  * For markdown, the content body field becomes the body; for YAML/JSON it stays a field.
234
235
  *
236
+ * ⛔ Until 2026-09-24 this wrote the BRIEF section alone, flat. Every other section was
237
+ * dropped from the file — a pulled `@std/article` markdown record came back with an
238
+ * empty body, its `article_body` gone — and nothing said so.
239
+ *
235
240
  * @param {object} params
236
- * @param {object} params.document - pulled `{ $uuid, $schema, <brief>: {…} }`
237
- * @param {object} params.declaration - the Model declaration (`brief` + `sections`)
241
+ * @param {object} params.document - a pulled `{ $uuid, $schema, <section>: … }`
242
+ * @param {object} params.declaration - the data schema's declaration (`sections`)
238
243
  * @param {string} params.format - 'yaml' | 'json' | 'md'
239
244
  * @param {string} [params.sourceLocale]
240
245
  * @param {object} [params.collector] - translation collector; target locales of
@@ -244,58 +249,116 @@ function briefSectionOf(declaration) {
244
249
  * @param {string} [params.freeformRelPath] - the free-form path for this record's
245
250
  * content body (buildFreeformRecordPath); lets a target-locale full-doc
246
251
  * body be written under locales/freeform/{locale}/ instead of being dropped.
252
+ * @param {(model: string, uuid: string) => string|null} [params.refName] - the name a
253
+ * referenced record goes by in the project, for a reference's uuid; a reference
254
+ * it has no name for is written as the uuid.
247
255
  * @returns {string} the source-file text
248
256
  */
249
- export function renderEntityDocument({ document, declaration, format, sourceLocale = 'en', collector, freeformRelPath }) {
250
- const brief = briefSectionOf(declaration)
251
- const section = brief ? document?.[brief.name] : null
252
- if (!brief || !section || typeof section !== 'object') {
253
- throw new Error('uwx/render: document has no resolvable brief section')
257
+ export function renderEntityDocument({ document, declaration, format, sourceLocale = 'en', collector, freeformRelPath, refName }) {
258
+ const layout = recordFileLayout(declaration)
259
+ if (layout.sections.length === 0) {
260
+ throw new Error('uwx/render: the data schema declares no sections')
261
+ }
262
+ // ⛔ An entity holding NONE of its schema's sections — what a partly applied push
263
+ // left, before the backend's push became one transaction — is not written: rendered,
264
+ // it would replace the author's record with an empty one. The caller skips it.
265
+ if (!layout.sections.some(([name]) => document?.[name] != null)) {
266
+ throw new Error("uwx/render: the entity holds none of its data schema's sections — nothing to write")
254
267
  }
255
- const fields = brief.fields || {}
256
- // The body target is the CONTENT field — a markup `text` field (raw source
257
- // string) or a `format: prosemirror` json field (a ProseMirror doc on the wire).
258
- const contentKey = Object.entries(fields).find(([, f]) => isContentBodyField(f))?.[0]
268
+ // Markdown: the content body field, wherever its single section is, becomes the body.
269
+ const { target } = contentBodyTarget(declaration)
270
+ const dec = { sourceLocale, collector, freeformRelPath, refName, bodyAt: format === 'md' ? target : null, body: '' }
259
271
 
260
272
  const record = {}
261
- if (document.$uuid) record.$uuid = document.$uuid
262
- let body = ''
263
- for (const [key, field] of Object.entries(fields)) {
264
- const raw = section[key]
265
- if (raw === undefined) continue
266
-
267
- // A `format: prosemirror` field is a ProseMirror doc (or localized doc+maps)
268
- // on the wire → markdown on disk. unwrapLocalizedContent yields the source doc
269
- // and captures target-locale structural maps into the collector.
270
- if (isProseMirrorField(field)) {
271
- const sourceDoc = field.localized ? unwrapLocalizedContent(raw, sourceLocale, collector, freeformRelPath) : raw
272
- const md = sourceDoc ? proseMirrorToMarkdown(sourceDoc) : ''
273
- if (format === 'md' && key === contentKey) body = md
274
- else record[key] = md
275
- continue
276
- }
277
-
278
- // Capture target-locale translations of localized SCALAR fields (not the
279
- // content body) into the collector when one is supplied.
280
- if (field.localized && key !== contentKey) collector?.add(raw)
281
- const value = field.localized ? unwrapLocalized(raw, sourceLocale) : raw
282
- if (format === 'md' && key === contentKey) {
283
- body = typeof value === 'string' ? value : String(value ?? '')
284
- continue
273
+ if (document?.$uuid) record.$uuid = document.$uuid
274
+ if (layout.flat) {
275
+ const [name, def] = layout.sections[0]
276
+ Object.assign(record, decodeRecord(def.fields, document?.[name], dec, name))
277
+ } else {
278
+ for (const [name, def] of layout.sections) {
279
+ const value = document?.[name]
280
+ if (value == null) continue
281
+ if (def.multiple === true) {
282
+ const list = decodeList(def, value, dec)
283
+ if (list.length) record[name] = list
284
+ } else {
285
+ const data = decodeRecord(def.fields, value, dec, name)
286
+ if (Object.keys(data).length) record[name] = data
287
+ }
285
288
  }
286
- record[key] = value
287
289
  }
288
290
  // ⭐ A DISABLED ENTITY IS A DRAFT ON THE FILE SIDE. It is kept in the folder and never
289
291
  // publicly delivered, which is exactly what `draft: true` means. An enabled entity
290
292
  // carries no key and writes no `draft:`, so a record re-enabled on the backend
291
293
  // comes back without the line.
292
- if (document.$disabled === true) record.draft = true
294
+ if (document?.$disabled === true) record.draft = true
293
295
 
294
296
  if (format === 'json') return JSON.stringify(record, null, 2) + '\n'
295
- if (format === 'md') return `---\n${yaml.dump(record)}---\n${body}`
297
+ if (format === 'md') return `---\n${yaml.dump(record)}---\n${dec.body}`
296
298
  return yaml.dump(record) // yaml / yaml
297
299
  }
298
300
 
301
+ // One record of a section, its declared fields in declared order. `sectionName` is the
302
+ // top-level section the record belongs to, so the content body can be found; null below.
303
+ function decodeRecord(fields, value, dec, sectionName) {
304
+ const out = {}
305
+ if (!isPlainObject(value)) return out
306
+ for (const [key, field] of Object.entries(fields || {})) {
307
+ const raw = value[key]
308
+ if (raw === undefined) continue
309
+ if (field?.type === 'section') {
310
+ out[key] = field.multiple === true ? decodeList(field, raw, dec) : decodeRecord(field.fields, raw, dec, null)
311
+ continue
312
+ }
313
+ const isBody = Boolean(dec.bodyAt) && sectionName === dec.bodyAt.section && key === dec.bodyAt.key
314
+ const decoded = decodeLeaf(raw, field, dec, isBody)
315
+ if (isBody) dec.body = typeof decoded === 'string' ? decoded : String(decoded ?? '')
316
+ else out[key] = decoded
317
+ }
318
+ return out
319
+ }
320
+
321
+ // The records of a `many` section; a self-nesting one's `$children` nest back under
322
+ // `children:`, the reserved key the push reads them from.
323
+ function decodeList(def, value, dec) {
324
+ if (!Array.isArray(value)) return []
325
+ return value.map((item) => {
326
+ const out = decodeRecord(def.fields, item, dec, null)
327
+ if (def.self_nesting === true && Array.isArray(item?.$children) && item.$children.length) {
328
+ out.children = decodeList(def, item.$children, dec)
329
+ }
330
+ return out
331
+ })
332
+ }
333
+
334
+ // A leaf's authored value. A localized SCALAR's target locales are captured into the
335
+ // collector (the content body's are captured by `unwrapLocalizedContent`, with its
336
+ // free-form path); a list of localized values is unwrapped element by element.
337
+ function decodeLeaf(raw, field, dec, isBody) {
338
+ if (field?.type === 'entity_ref') return decodeReference(raw, field, dec)
339
+ if (isProseMirrorField(field)) {
340
+ const sourceDoc = field.localized
341
+ ? unwrapLocalizedContent(raw, dec.sourceLocale, dec.collector, isBody ? dec.freeformRelPath : undefined)
342
+ : raw
343
+ return sourceDoc ? proseMirrorToMarkdown(sourceDoc) : ''
344
+ }
345
+ if (!field?.localized) return raw
346
+ if (Array.isArray(raw)) {
347
+ if (!isBody) for (const item of raw) dec.collector?.add(item)
348
+ return unwrapLocalizedList(raw, dec.sourceLocale)
349
+ }
350
+ if (!isBody) dec.collector?.add(raw)
351
+ return unwrapLocalized(raw, dec.sourceLocale)
352
+ }
353
+
354
+ // A reference comes back as the backend's uuid; the file names its record by handle — the
355
+ // name the site's folder gives it (`refName`), which is what a push resolves back to that
356
+ // uuid. A record the folder does not hold keeps its uuid, and a push sends it as it is.
357
+ function decodeReference(raw, field, dec) {
358
+ const one = (v) => (typeof v === 'string' && dec.refName ? dec.refName(field.model, v) ?? v : v)
359
+ return Array.isArray(raw) ? raw.map(one) : one(raw)
360
+ }
361
+
299
362
  /**
300
363
  * Back-fill the sync response into the source files. Correlation is by **`index`**
301
364
  * — `finalized[i].index` is the 0-based position of the entity in the submitted
@@ -91,7 +91,7 @@ export function toDataSchemaDeclaration(normalized, { name, resolveName, resolve
91
91
 
92
92
  const { sections, brief } = normalized.sections
93
93
  ? lowerSectionsForm(normalized.sections, resolve, optResolve)
94
- : lowerFieldsForm(normalized.fields || {}, shortName(name), resolve, optResolve)
94
+ : lowerFieldsForm(normalized.fields || {}, resolve, optResolve)
95
95
 
96
96
  // The model-level sort axis is inline: `sort_date: true` on the brief's named
97
97
  // date field (replaces the old schema-level `sort_date_field` back-reference).
@@ -139,13 +139,22 @@ export function toDataSchemaDeclaration(normalized, { name, resolveName, resolve
139
139
 
140
140
  // --- shapes ------------------------------------------------------------------
141
141
 
142
- // The `fields:` shorthand: a flat field set is ONE single brief section named the
143
- // model short-name. Our producer-side sugar — the registry's root is always
144
- // `sections:`; this expands to it.
145
- function lowerFieldsForm(fields, sectionName, resolve, optResolve) {
142
+ // The `fields:` shorthand: a flat field set is ONE single section, named `brief` and
143
+ // marked `brief: true` — exactly `sections: { brief: { brief: true, fields } }`. Our
144
+ // producer-side sugar — the registry's root is always `sections:`; this expands to it.
145
+ //
146
+ // ⭐ THE NAME IS FIXED, NOT DERIVED (2026-09-22 [Diego]: "We assume it is single section,
147
+ // `brief: true` and named `brief`"). It was the schema's short name until then —
148
+ // `@std/person` → `person` — a name nobody wrote, stored on every entity and in every
149
+ // `item_ref` path, that a schema growing into the `sections:` form had to reuse exactly
150
+ // with nothing to say so. A `sections:` schema still names its own sections; `brief` is
151
+ // only the recommended name for its card.
152
+ export const SHORTHAND_SECTION = 'brief'
153
+
154
+ function lowerFieldsForm(fields, resolve, optResolve) {
146
155
  return {
147
- sections: { [sectionName]: lowerSection({ kind: 'single', brief: true, fields }, resolve, optResolve) },
148
- brief: sectionName,
156
+ sections: { [SHORTHAND_SECTION]: lowerSection({ kind: 'single', brief: true, fields }, resolve, optResolve) },
157
+ brief: SHORTHAND_SECTION,
149
158
  }
150
159
  }
151
160
 
@@ -460,10 +469,6 @@ function sectionAttrsFromField(field) {
460
469
  return out
461
470
  }
462
471
 
463
- function shortName(name) {
464
- return String(name).split('/').pop()
465
- }
466
-
467
472
  // `@/x` resolves into the schema's own org; other scopes pass through.
468
473
  function defaultResolver(ownName) {
469
474
  const slash = ownName.indexOf('/')
@@ -380,9 +380,11 @@ export function writeThemeFile(siteRoot, theme) {
380
380
  * @param {object} [opts.collector] - translation collector for localized scalar fields
381
381
  * @param {string} [opts.freeformRelPath] - free-form path for this record's content
382
382
  * body (so a target-locale full-doc body is captured for locales/freeform/)
383
+ * @param {(model: string, uuid: string) => string|null} [opts.refName] - the name a
384
+ * referenced record goes by, so a reference is written as that name
383
385
  * @returns {'updated'|'unchanged'}
384
386
  */
385
- export function writeRecordFile({ filePath, document, declaration, format, sourceLocale = 'en', collector, freeformRelPath }) {
386
- const text = renderEntityDocument({ document, declaration, format, sourceLocale, collector, freeformRelPath })
387
+ export function writeRecordFile({ filePath, document, declaration, format, sourceLocale = 'en', collector, freeformRelPath, refName }) {
388
+ const text = renderEntityDocument({ document, declaration, format, sourceLocale, collector, freeformRelPath, refName })
387
389
  return writeIfChanged(filePath, text)
388
390
  }
@@ -0,0 +1,61 @@
1
+ // How one record of a data schema is written in its file — the layout the push reads
2
+ // and the pull writes, read off the schema's declaration (`toDataSchemaDeclaration`).
3
+ //
4
+ // ⭐ FLAT ONLY WHEN THE SCHEMA HAS ONE TOP-LEVEL SECTION HOLDING ONE RECORD — the
5
+ // `fields:` shorthand, or a `sections:` schema whose only section is a single one. The
6
+ // file's keys are then that section's fields, and nothing is lost either way.
7
+ //
8
+ // ⭐ EVERY OTHER SCHEMA IS WRITTEN BY SECTION: each top-level section under its own
9
+ // name — an object for a single section, a list of records for a `many` one, an object
10
+ // of child sections for a binder. That is the stored entity's own shape
11
+ // (docs/reference/entity-content.md), so a push sends it and a pull writes it back
12
+ // without a mapping in between. A schema whose only section is `many` (`@std/nav`) is
13
+ // written this way too — `items: [...]` — since a file whose top level is a list holds
14
+ // several records, not one.
15
+ //
16
+ // ⛔ THE FLAT FORM FOR A MULTI-SECTION SCHEMA IS RETIRED (2026-09-22 [Diego]: "we can
17
+ // discontinue the shorthands for multi-section Models"). It matched a file's keys to
18
+ // fields across sections by name, and sections are namespaces: a field name two
19
+ // sections share got one value in both, a `many` section had no place at all, and a
20
+ // pull — writing back only what it could place — returned a record with nothing but its
21
+ // brief. Measured before the change: a pulled `@std/article` markdown record came back
22
+ // with an empty body.
23
+
24
+ import { isContentBodyField } from './data-schema.js'
25
+
26
+ /**
27
+ * @param {object} declaration - `{ name, sections }`
28
+ * @returns {{ flat: boolean, sections: Array<[string, object]>, brief: string|null }}
29
+ * `sections` — the top-level sections in declared order; `brief` — the section the
30
+ * declaration marks `brief: true`, or null when it marks none.
31
+ */
32
+ export function recordFileLayout(declaration) {
33
+ const sections = Object.entries(declaration?.sections || {}).filter(([, s]) => s && typeof s === 'object')
34
+ const flat = sections.length === 1 && sections[0][1].multiple !== true
35
+ const brief = sections.find(([, s]) => s.brief === true)?.[0] ?? null
36
+ return { flat, sections, brief }
37
+ }
38
+
39
+ /**
40
+ * The schema's CONTENT body field — the one a `.md` record's markdown body fills: a
41
+ * markup `text` field (`format: markdown|html`) or a `format: prosemirror` json field,
42
+ * declared directly on a top-level single section — the brief or another, like
43
+ * `@std/article`'s `article_body.content`. The first in declared order.
44
+ *
45
+ * ⛔ Not the brief's alone. Until 2026-09-24 the pull and the per-locale body override
46
+ * looked only in the brief, while the push looked in every single section, so a body
47
+ * declared outside the brief was pushed and then neither translated nor pulled back.
48
+ *
49
+ * @param {object} declaration
50
+ * @returns {{ target: { section: string, key: string, field: object }|null, count: number }}
51
+ */
52
+ export function contentBodyTarget(declaration) {
53
+ const matches = []
54
+ for (const [section, def] of recordFileLayout(declaration).sections) {
55
+ if (def.multiple === true) continue
56
+ for (const [key, field] of Object.entries(def.fields || {})) {
57
+ if (isContentBodyField(field)) matches.push({ section, key, field })
58
+ }
59
+ }
60
+ return { target: matches[0] || null, count: matches.length }
61
+ }
@@ -18,11 +18,12 @@
18
18
  // fact about it.
19
19
  // - an existing local file carrying the same `$uuid` is re-rendered in place;
20
20
  // otherwise a new single-record file is placed at `<slug>.<ext>`, its format
21
- // matched to the schema folder's existing files, else markdown when the Model's
22
- // brief has a content body field, else YAML.
21
+ // matched to the schema folder's existing files, else markdown when the schema
22
+ // has a content body field, else YAML.
23
23
  //
24
- // Field rendering reuses renderEntityDocument (via writeRecordFile) — localized
25
- // unwrap, date handling, content-body→body are already inverted there. Asset paths
24
+ // Field rendering reuses renderEntityDocument (via writeRecordFile), which writes the
25
+ // record in its schema's layout — flat, or by section — with localized unwrap, date
26
+ // handling and content-body→body inverted. Asset paths
26
27
  // are restored before it runs (`restoreAssetRefs`), as the content lane does.
27
28
  //
28
29
  // Deferred: array-form & BibTeX multi-record files (a pulled record is placed as
@@ -43,7 +44,7 @@ import { writeRecordFile, writeQueriesConfig, writeRecordsConfig } from './proje
43
44
  import { defaultSchema, deferredFromSchema, foundationDataSchemas } from './queries-config.js'
44
45
  import { poolDirsForSchema, resolveRecordsDir } from '../site/entity-pool.js'
45
46
  import { folderYmlPath } from '../site/records-config.js'
46
- import { isContentBodyField } from './data-schema.js'
47
+ import { contentBodyTarget } from './record-layout.js'
47
48
  import { unresolveSelfScope, refuseOrgOption } from './self-scope.js'
48
49
  import { parseCatalogRef } from '../site/foundation-ref.js'
49
50
  import { unwrapLocalized } from './backfill.js'
@@ -96,8 +97,8 @@ export function findRecordFileByUuid(poolDir, uuid) {
96
97
  }
97
98
 
98
99
  // The format to give a NEW record file in a collection: match the collection's
99
- // existing single-record files, else markdown when the Model's brief carries a
100
- // content body field (so the body has a home), else YAML.
100
+ // existing single-record files, else markdown when the schema has a content body field
101
+ // (so the body has a home), else YAML.
101
102
  function defaultFormat(poolDir, declaration) {
102
103
  if (existsSync(poolDir)) {
103
104
  for (const entry of readdirSync(poolDir)) {
@@ -106,14 +107,9 @@ function defaultFormat(poolDir, declaration) {
106
107
  if (format) return format
107
108
  }
108
109
  }
109
- return briefHasContentBody(declaration) ? 'md' : 'yaml'
110
- }
111
-
112
- // Whether the declaration's brief section declares a content body field — a markup
113
- // `text` field or a `format: prosemirror` json field (the md-body target).
114
- function briefHasContentBody(declaration) {
115
- const brief = Object.values(declaration?.sections || {}).find((s) => s && s.brief === true)
116
- return Object.values(brief?.fields || {}).some((f) => isContentBodyField(f))
110
+ // ⛔ This asked the brief alone until 2026-09-24, so a pulled `@std/article` — its
111
+ // body in `article_body` — was placed as YAML, the body a field.
112
+ return contentBodyTarget(declaration).target ? 'md' : 'yaml'
117
113
  }
118
114
 
119
115
  // Build `uuid → { collection, slug }` from the folder document's ref leaves. The
@@ -137,7 +133,8 @@ function indexFolder(folderDoc) {
137
133
  } else if (node?.kind === 'ref' && node.entry) {
138
134
  // `entry` is `{ schema, entity: <uuid> }`; tolerate a bare uuid defensively.
139
135
  const uuid = typeof node.entry === 'object' ? node.entry.entity : node.entry
140
- if (uuid) byUuid.set(uuid, { folderPath, slug: node.name })
136
+ const schema = typeof node.entry === 'object' ? node.entry.schema : undefined
137
+ if (uuid) byUuid.set(uuid, { folderPath, slug: node.name, schema })
141
138
  }
142
139
  }
143
140
  }
@@ -223,7 +220,7 @@ const DECL_WIRE_CONSUMED = new Set([
223
220
  ])
224
221
 
225
222
  // Is this wire `deferred` exactly what the schema's brief would have derived? Compared
226
- // as an ORDER-INSENSITIVE set: the deriver walks `flatRecordFields`, and a round trip
223
+ // as an ORDER-INSENSITIVE set: the deriver walks the schema's sections, and a round trip
227
224
  // through YAML and the store is not obliged to preserve that order. Comparing as a list
228
225
  // would classify a reordered-but-identical value as authored, and persist it.
229
226
  function isDerivedDeferred(d, dataSchemas) {
@@ -485,6 +482,14 @@ export function recordsToProject({ folderDoc, recordDocs = [], siteRoot, opts =
485
482
  }
486
483
 
487
484
  const folderIndex = indexFolder(folderDoc)
485
+ // ⭐ A REFERENCE IS WRITTEN AS THE NAME OF THE RECORD IT POINTS AT — `speaker: ada` —
486
+ // which is what a push resolves back to this backend's uuid. The folder names every
487
+ // record of the site; a reference to a record it does not hold (or holds under another
488
+ // Model) is written as the uuid.
489
+ const refName = (model, uuid) => {
490
+ const hit = folderIndex.get(uuid)
491
+ return hit?.slug && (!hit.schema || hit.schema === model) ? hit.slug : null
492
+ }
488
493
  // Where records land — `site.yml::paths.records`, else `records/`: the one resolver
489
494
  // the build and the push read too, so a pulled record lands where the next build
490
495
  // looks. ⛔ This wrote into the default directory until 2026-09-21, whatever the
@@ -570,7 +575,7 @@ export function recordsToProject({ folderDoc, recordDocs = [], siteRoot, opts =
570
575
  const toWrite = own && own !== theirs ? { ...document, $uuid: own } : document
571
576
  let status
572
577
  try {
573
- status = writeRecordFile({ filePath, document: toWrite, declaration, format, sourceLocale, collector, freeformRelPath })
578
+ status = writeRecordFile({ filePath, document: toWrite, declaration, format, sourceLocale, collector, freeformRelPath, refName })
574
579
  } catch (err) {
575
580
  // ⛔ A record that could not be written was not placed, so it is a SKIP, not a
576
581
  // warning: a caller that counts what it placed (the CLI's pull) must see it.