@uniweb/build 0.63.3 → 0.64.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.
@@ -16,7 +16,9 @@ import { randomBytes } from 'node:crypto'
16
16
  import yaml from 'js-yaml'
17
17
  import { YAML_OPTIONS } from '../utils/yaml-schema.js'
18
18
  import { proseMirrorToMarkdown, serializeFrontmatter } from '@uniweb/content-writer'
19
+ import { markdownToProseMirror } from '@uniweb/content-reader'
19
20
  import { parseFrontmatter } from './entity-source.js'
21
+ import { canonicalJson, sameMarkdownDocument } from './same-content.js'
20
22
  import { renderEntityDocument } from './backfill.js'
21
23
  import { queriesYmlPath } from './queries-config.js'
22
24
  import { folderYmlPath } from '../site/records-config.js'
@@ -43,7 +45,7 @@ export const DEFAULT_RESERVED_FRONTMATTER = new Set([
43
45
  ])
44
46
 
45
47
  // js-yaml dump options shared by every config write, so output is byte-stable.
46
- const YAML_DUMP_OPTS = { lineWidth: -1, quotingType: "'", forceQuotes: false, noRefs: true }
48
+ export const YAML_DUMP_OPTS = { lineWidth: -1, quotingType: "'", forceQuotes: false, noRefs: true }
47
49
 
48
50
  /**
49
51
  * Write `text` to `filePath` only when it differs from what's on disk, using a
@@ -164,9 +166,11 @@ function assembleSection(frontmatter, body) {
164
166
  * @param {object} [opts.content] - ProseMirror document for the body
165
167
  * @param {object} [opts.params] - incoming frontmatter params to merge
166
168
  * @param {Set<string>} [opts.reserved] - keys preserved when already present
169
+ * @param {object} [opts.implied] - `{ key: value }` a file means by leaving `key` out; an incoming
170
+ * value equal to it is not written into an existing file that leaves the key out
167
171
  * @returns {'updated'|'unchanged'}
168
172
  */
169
- export function writeSectionFile({ filePath, content, params, reserved = DEFAULT_RESERVED_FRONTMATTER }) {
173
+ export function writeSectionFile({ filePath, content, params, reserved = DEFAULT_RESERVED_FRONTMATTER, implied = null }) {
170
174
  let existing = ''
171
175
  try {
172
176
  existing = readFileSync(filePath, 'utf8')
@@ -185,15 +189,26 @@ export function writeSectionFile({ filePath, content, params, reserved = DEFAULT
185
189
  // how a newly-projected section gets its `type`/`nest`/etc.
186
190
  if (reserved.has(key) && key in frontmatter) continue
187
191
  if (reserved.has(key) && DECLARATION_KEYS.includes(key) && declaresLocally) continue
192
+ // A value the file implies by leaving the key out (`implied`) is not written into a file that
193
+ // exists — a new file has nothing to imply it with.
194
+ if (implied && existing && !(key in frontmatter) && implied[key] === value) continue
188
195
  if (value === null || value === undefined) delete nextFrontmatter[key]
189
196
  else nextFrontmatter[key] = value
190
197
  }
191
198
  }
192
199
 
193
- const body = content ? proseMirrorToMarkdown(content) : (existingBody || '').replace(/^\n+/, '').replace(/\s+$/, '')
200
+ // ⭐ AN UNCHANGED BODY KEEPS THE AUTHOR'S OWN TEXT. A body that parses to the document the pull brought
201
+ // is not re-serialized: the writer's markdown differs from an author's in blank lines and line breaks,
202
+ // and a pull that changed nothing reformatted every section it touched (measured 2026-09-26 on the
203
+ // `marketing` template). A file with nothing to change is not written at all.
204
+ const authoredBody = (existingBody || '').replace(/^\n+/, '').replace(/\s+$/, '')
205
+ const keepsBody = Boolean(content && authoredBody) && sameMarkdownDocument(authoredBody, content)
206
+ if (keepsBody && canonicalJson(nextFrontmatter) === canonicalJson(frontmatter)) return 'unchanged'
207
+ const body = content && !keepsBody ? proseMirrorToMarkdown(content) : authoredBody
194
208
  return writeIfChanged(filePath, assembleSection(nextFrontmatter, body))
195
209
  }
196
210
 
211
+
197
212
  // Shallow-merge `changes` into a YAML config file and write idempotently. A key
198
213
  // whose value is null/undefined is deleted; an object value is shallow-merged one
199
214
  // level deep (so partial `theme` / `build` updates don't drop sibling keys) unless
@@ -202,12 +217,8 @@ export function writeSectionFile({ filePath, content, params, reserved = DEFAULT
202
217
  // config, but comment-preserving merges for hand-authored config files are a
203
218
  // quality bar to revisit.
204
219
  function mergeYamlConfig(filePath, changes, { replace = [] } = {}) {
205
- let existing = {}
206
- try {
207
- existing = yaml.load(readFileSync(filePath, 'utf8'), YAML_OPTIONS) || {}
208
- } catch {
209
- // missing / invalid → start fresh
210
- }
220
+ const prior = loadYamlFile(filePath)
221
+ const existing = isPlainObject(prior.value) ? { ...prior.value } : {}
211
222
  for (const [key, value] of Object.entries(changes)) {
212
223
  if (value === null || value === undefined) {
213
224
  delete existing[key]
@@ -216,13 +227,32 @@ function mergeYamlConfig(filePath, changes, { replace = [] } = {}) {
216
227
  // pull wrote `events: '@/event'` back as `events: { '0': '@', '1': '/', …,
217
228
  // schema: '@/event' }` (measured 2026-09-23). Anything else that is not an
218
229
  // object has nothing to keep, so the incoming value replaces it.
219
- const prior = existing[key]
220
- existing[key] = isPlainObject(prior) ? { ...prior, ...value } : value
230
+ const current = existing[key]
231
+ existing[key] = isPlainObject(current) ? { ...current, ...value } : value
221
232
  } else {
222
233
  existing[key] = value
223
234
  }
224
235
  }
225
- return writeIfChanged(filePath, yaml.dump(existing, YAML_DUMP_OPTS))
236
+ return writeYamlIfChanged(filePath, existing, prior)
237
+ }
238
+
239
+ // A YAML file, parsed — and whether there was one: a missing or unreadable file is never "unchanged".
240
+ function loadYamlFile(filePath) {
241
+ try {
242
+ return { value: yaml.load(readFileSync(filePath, 'utf8'), YAML_OPTIONS) ?? {}, read: true }
243
+ } catch {
244
+ return { value: {}, read: false }
245
+ }
246
+ }
247
+
248
+ // ⭐ A FILE WHOSE MEANING WOULD NOT CHANGE IS LEFT AS ITS AUTHOR WROTE IT. Every writer here re-dumps the
249
+ // whole file — its comments dropped, its strings re-quoted, its lists reflowed — so a pull that restated
250
+ // a file rewrote it all the same. ⛔ Until 2026-09-26 a pull into the copy that pushed it re-dumped
251
+ // `site.yml`, `theme.yml` and every `page.yml` (measured on the `international` template: every comment
252
+ // in its `site.yml` gone, and nothing had changed).
253
+ function writeYamlIfChanged(filePath, value, prior) {
254
+ if (prior.read && canonicalJson(value) === canonicalJson(prior.value)) return 'unchanged'
255
+ return writeIfChanged(filePath, yaml.dump(value, YAML_DUMP_OPTS))
226
256
  }
227
257
 
228
258
  const isPlainObject = (v) => Boolean(v) && typeof v === 'object' && !Array.isArray(v)
@@ -249,7 +279,7 @@ export function writeSiteConfig(siteRoot, config) {
249
279
  * @returns {'updated'|'unchanged'}
250
280
  */
251
281
  export function writeYamlFile(filePath, obj) {
252
- return writeIfChanged(filePath, yaml.dump(obj || {}, YAML_DUMP_OPTS))
282
+ return writeYamlIfChanged(filePath, obj || {}, loadYamlFile(filePath))
253
283
  }
254
284
 
255
285
  /**
@@ -269,17 +299,17 @@ export function writeYamlFile(filePath, obj) {
269
299
  * @returns {'updated'|'unchanged'}
270
300
  */
271
301
  export function writeMergedYaml(filePath, projected, managedKeys) {
272
- let existing = {}
273
- try {
274
- existing = yaml.load(readFileSync(filePath, 'utf8'), YAML_OPTIONS) || {}
275
- } catch {
276
- // missing / invalid → start fresh
302
+ const prior = loadYamlFile(filePath)
303
+ const existing = isPlainObject(prior.value) ? prior.value : {}
304
+ const managed = new Set(managedKeys)
305
+ // Each key where the file has it — a value the pull changes stays in its place — then the new ones.
306
+ const out = {}
307
+ for (const [key, value] of Object.entries(existing)) {
308
+ if (key in projected) out[key] = projected[key]
309
+ else if (!managed.has(key)) out[key] = value
277
310
  }
278
- if (!existing || typeof existing !== 'object' || Array.isArray(existing)) existing = {}
279
- const out = { ...existing }
280
- for (const key of managedKeys) delete out[key]
281
- Object.assign(out, projected)
282
- return writeIfChanged(filePath, yaml.dump(out, YAML_DUMP_OPTS))
311
+ for (const [key, value] of Object.entries(projected)) if (!(key in out)) out[key] = value
312
+ return writeYamlIfChanged(filePath, out, prior)
283
313
  }
284
314
 
285
315
  /**
@@ -309,12 +339,31 @@ export function writeQueriesConfig(siteRoot, queries) {
309
339
  // `events:` is `{}`), which a pull brings back in its long form. And a pull that
310
340
  // restates every query writes nothing: the file is re-dumped whole otherwise, which
311
341
  // rewrites a bare `events:` as `events: null` and flow style as block style.
342
+ //
343
+ // ⭐ WHEREVER THE AUTHOR DECLARED IT — `queries.yml`, or `site.yml`'s `queries:` block, which the
344
+ // build reads too, under it. ⛔ Until 2026-09-26 only `queries.yml` was consulted, so a site whose
345
+ // queries live in `site.yml` (the `international` template) got a `queries.yml` repeating every one
346
+ // of them on each pull, in the long form. A query that changed is written back where it lives.
347
+ let site = {}
348
+ try {
349
+ site = yaml.load(readFileSync(join(siteRoot, 'site.yml'), 'utf8'), YAML_OPTIONS) || {}
350
+ } catch {
351
+ // missing / invalid → nothing declared there
352
+ }
353
+ const inSite = isPlainObject(site.queries) ? site.queries : {}
312
354
  const changes = {}
355
+ const siteChanges = {}
313
356
  for (const [name, decl] of Object.entries(queries)) {
357
+ const inFile = isPlainObject(authored) && name in authored
358
+ if (!inFile && name in inSite) {
359
+ if (!restates(inSite[name], decl)) siteChanges[name] = decl
360
+ continue
361
+ }
314
362
  if (restates(authored?.[name], decl)) continue
315
363
  changes[name] = decl
316
364
  }
317
- if (Object.keys(changes).length === 0) return 'unchanged'
365
+ if (Object.keys(siteChanges).length) writeSiteConfig(siteRoot, { queries: siteChanges })
366
+ if (Object.keys(changes).length === 0) return Object.keys(siteChanges).length ? 'updated' : 'unchanged'
318
367
  // ⛔ Each query WHOLE (`replace`), as this function's header always said. Merged into
319
368
  // the local one — what the call did until 2026-09-23 — a key the backend's
320
369
  // declaration no longer has survived every pull: the defect `writeSiteConfig`
@@ -330,11 +379,6 @@ function restates(authored, decl) {
330
379
  return isPlainObject(authored) && canonicalJson(authored) === canonicalJson(decl)
331
380
  }
332
381
 
333
- // JSON with every object's keys sorted — equal for equal values, whatever the order.
334
- const canonicalJson = (value) =>
335
- JSON.stringify(value, (_, v) =>
336
- isPlainObject(v) ? Object.fromEntries(Object.keys(v).sort().map((k) => [k, v[k]])) : v
337
- )
338
382
 
339
383
  /**
340
384
  * Write the records folder's organization — `folder.yml` in the records directory
@@ -382,9 +426,40 @@ export function writeThemeFile(siteRoot, theme) {
382
426
  * body (so a target-locale full-doc body is captured for locales/freeform/)
383
427
  * @param {(model: string, uuid: string) => string|null} [opts.refName] - the name a
384
428
  * referenced record goes by, so a reference is written as that name
429
+ * @param {{ key: string }} [opts.context] - the record's identity, `<pool>/<handle>`, which
430
+ * its translations are kept under — an entry that differs from record to record
431
+ * comes back as `{ default, overrides }`
385
432
  * @returns {'updated'|'unchanged'}
386
433
  */
387
- export function writeRecordFile({ filePath, document, declaration, format, sourceLocale = 'en', collector, freeformRelPath, refName }) {
388
- const text = renderEntityDocument({ document, declaration, format, sourceLocale, collector, freeformRelPath, refName })
434
+ export function writeRecordFile({ filePath, document, declaration, format, sourceLocale = 'en', collector, freeformRelPath, refName, context = null }) {
435
+ const text = renderEntityDocument({ document, declaration, format, sourceLocale, collector, freeformRelPath, refName, context })
436
+ // ⭐ A record the pull did not change keeps the author's file — its key order, its blank lines, its
437
+ // quoting. ⛔ Until 2026-09-26 each one was re-rendered (measured on the `international` template).
438
+ let current = null
439
+ try {
440
+ current = readFileSync(filePath, 'utf8')
441
+ } catch {
442
+ // new file
443
+ }
444
+ if (current !== null && sameRecordText(current, text, format, filePath)) return 'unchanged'
389
445
  return writeIfChanged(filePath, text)
390
446
  }
447
+
448
+ // Do two renderings of a record file say the same thing — the same data, and a body that parses to
449
+ // the same document?
450
+ function sameRecordText(a, b, format, filePath) {
451
+ try {
452
+ if (format === 'md') {
453
+ const x = parseFrontmatter(a, filePath)
454
+ const y = parseFrontmatter(b, filePath)
455
+ if (canonicalJson(x.frontmatter) !== canonicalJson(y.frontmatter)) return false
456
+ const bx = (x.body || '').trim()
457
+ const by = (y.body || '').trim()
458
+ return bx === by || sameMarkdownDocument(bx, markdownToProseMirror(by))
459
+ }
460
+ const parse = format === 'json' ? JSON.parse : (t) => yaml.load(t, YAML_OPTIONS)
461
+ return canonicalJson(parse(a)) === canonicalJson(parse(b))
462
+ } catch {
463
+ return false
464
+ }
465
+ }
@@ -0,0 +1,46 @@
1
+ // ⭐ WHICH STORED RECORD EACH OF A COPY'S RECORDS IS — recovered from what the backend holds.
2
+ //
3
+ // A push sends a record with the uuid THIS backend minted for it: `sync.json`'s `records` map,
4
+ // the record's own id (its file's `$uuid`) → theirs. A copy bound to the site whose map lost an
5
+ // entry sends that record with none, and the backend mints a second entity for it — silently,
6
+ // since a new record is never refused. The folder that places it is refused instead, because its
7
+ // placements then carry no identity while the stored ones do (`identity_required`, measured
8
+ // 2026-09-25 on a blog site whose binding held neither map).
9
+ //
10
+ // ⭐ MATCHED THE WAY A PULL PLACES THEM: a record whose own id IS a stored uuid is that record —
11
+ // the first backend a record reaches mints the id its file keeps. Otherwise it is the stored
12
+ // record the folder places under the same Model and slug. ⛔ A Model and slug two stored records
13
+ // share names neither, and a stored record already mapped to another is not taken again: a wrong
14
+ // match would overwrite a record the author did not edit, where no match only re-creates one.
15
+
16
+ import { indexFolder } from './records-project.js'
17
+
18
+ /**
19
+ * The stored uuid of each record the map does not name, where the backend's folder says which.
20
+ *
21
+ * @param {object} params
22
+ * @param {Array<{ ownId?: string|null, model?: string, slug?: string }>} params.index - the
23
+ * records a push is about to send (`emitSyncPackages().records.index`)
24
+ * @param {Object<string,string>} [params.recordMap] - this backend's map: own id → its uuid
25
+ * @param {object} params.folderDoc - the backend's `@uniweb/folder` document for the site
26
+ * @returns {Object<string,string>} own id → the stored uuid, for the records recovered
27
+ */
28
+ export function matchStoredRecords({ index, recordMap = {}, folderDoc }) {
29
+ const placed = indexFolder(folderDoc)
30
+ const byName = new Map()
31
+ for (const [theirs, { slug, schema }] of placed) {
32
+ if (!slug || !schema) continue
33
+ const key = `${schema} ${slug}`
34
+ byName.set(key, byName.has(key) ? null : theirs)
35
+ }
36
+ const taken = new Set(Object.values(recordMap))
37
+ const learned = {}
38
+ for (const e of index || []) {
39
+ if (!e?.ownId || recordMap[e.ownId] || learned[e.ownId]) continue
40
+ const theirs = placed.has(e.ownId) ? e.ownId : byName.get(`${e.model} ${e.slug}`)
41
+ if (!theirs || taken.has(theirs)) continue
42
+ taken.add(theirs)
43
+ learned[e.ownId] = theirs
44
+ }
45
+ return learned
46
+ }
@@ -35,12 +35,13 @@
35
35
  import { readBackendState, updateBackendMap } from './sync-store.js'
36
36
  import { restoreAssetRefs } from './asset-map.js'
37
37
  import { documentSchema } from './entity-document.js'
38
- import { readFileSync, readdirSync, existsSync, unlinkSync } from 'node:fs'
39
- import { join, resolve, relative, extname, basename, sep } from 'node:path'
38
+ import { readFileSync, readdirSync, existsSync, unlinkSync, writeFileSync } from 'node:fs'
39
+ import { join, resolve, relative, dirname, extname, basename, sep } from 'node:path'
40
40
  import yaml from 'js-yaml'
41
41
  import { YAML_OPTIONS } from '../utils/yaml-schema.js'
42
42
  import { parseFrontmatter } from './entity-source.js'
43
- import { writeRecordFile, writeQueriesConfig, writeRecordsConfig } from './project-writer.js'
43
+ import { writeRecordFile, writeQueriesConfig, writeRecordsConfig, YAML_DUMP_OPTS } from './project-writer.js'
44
+ import { canonicalJson } from './same-content.js'
44
45
  import { defaultSchema, deferredFromSchema, foundationDataSchemas, foundationSchemaJson, QUERIES_YML_RELPATH } from './queries-config.js'
45
46
  import { dataKeyTypes } from './data-key-types.js'
46
47
  import { poolDirsForSchema, schemaForPoolDirs, resolveRecordsDir } from '../site/entity-pool.js'
@@ -48,7 +49,10 @@ import { folderYmlPath } from '../site/records-config.js'
48
49
  import { contentBodyTarget } from './record-layout.js'
49
50
  import { unresolveSelfScope, resolveSelfScope, ownSchemaNames, refuseOrgOption } from './self-scope.js'
50
51
  import { parseCatalogRef } from '../site/foundation-ref.js'
51
- import { unwrapLocalized } from './backfill.js'
52
+ import { unwrapLocalized, renderEntityDocument } from './backfill.js'
53
+ import { parseBibtex } from '@citestyle/bibtex'
54
+ import { getSchema as getStandardSchema, isStandardSchema } from '@uniweb/schemas'
55
+ import { validateAndNormalizeSchema } from '../resolve-data-schema.js'
52
56
  import { createTranslationCollector, writeLocaleTranslations, writeFreeformTranslations } from './locale-sync.js'
53
57
  import { buildFreeformRecordPath } from '../i18n/freeform.js'
54
58
 
@@ -97,6 +101,52 @@ export function findRecordFileByUuid(poolDir, uuid) {
97
101
  return null
98
102
  }
99
103
 
104
+ // ⭐ A RECORD KEPT IN A LIST FILE — a JSON or YAML array, or a BibTeX file: several records in one —
105
+ // is found by its entry's `$uuid`, which the push's back-fill writes into each entry. ⛔ Until
106
+ // 2026-09-25 a pull looked in single-record files alone, wrote each such record again as a file of
107
+ // its own beside the list, and the next push held it twice — measured on the `international`
108
+ // template's `records/team/team.json`.
109
+ export function findListEntryByUuid(poolDir, uuid) {
110
+ if (!uuid || !existsSync(poolDir)) return null
111
+ for (const name of readdirSync(poolDir)) {
112
+ if (name.startsWith('_')) continue
113
+ const ext = extname(name).toLowerCase()
114
+ const format = ext === '.bib' ? 'bib' : formatForExt(ext)
115
+ if (format !== 'json' && format !== 'yaml' && format !== 'bib') continue
116
+ const path = join(poolDir, name)
117
+ let entries
118
+ try {
119
+ const text = readFileSync(path, 'utf8')
120
+ entries = format === 'json' ? JSON.parse(text) : format === 'yaml' ? yaml.load(text, YAML_OPTIONS) : parseBibtex(text)
121
+ } catch {
122
+ continue
123
+ }
124
+ if (!Array.isArray(entries)) continue
125
+ const index = entries.findIndex((e) => e && typeof e === 'object' && e.$uuid === uuid)
126
+ if (index >= 0) return { path, format, index, list: true }
127
+ }
128
+ return null
129
+ }
130
+
131
+ // A pulled record written back into its entry of a JSON or YAML list, every other entry untouched.
132
+ // The entry keeps its `slug`, which names it where a single-record file's name would.
133
+ function writeRecordIntoList({ list, document, declaration, sourceLocale, collector, freeformRelPath, refName, context = null }) {
134
+ const text = readFileSync(list.path, 'utf8')
135
+ const entries = list.format === 'json' ? JSON.parse(text) : yaml.load(text, YAML_OPTIONS)
136
+ const rendered = renderEntityDocument({ document, declaration, format: list.format, sourceLocale, collector, freeformRelPath, refName, context })
137
+ const { $uuid, ...fields } = list.format === 'json' ? JSON.parse(rendered) : yaml.load(rendered, YAML_OPTIONS)
138
+ const slug = entries[list.index]?.slug
139
+ const entry = { ...($uuid ? { $uuid } : {}), ...(slug !== undefined ? { slug } : {}), ...fields }
140
+ // ⭐ An entry the pull did not change leaves the file as the author wrote it — its key order, its
141
+ // spacing, and a YAML file's comments. ⛔ Until 2026-09-26 the file was compared as text and re-dumped.
142
+ if (canonicalJson(entries[list.index]) === canonicalJson(entry)) return 'unchanged'
143
+ const next = entries.map((e, i) => (i === list.index ? entry : e))
144
+ const out = list.format === 'json' ? JSON.stringify(next, null, 2) + '\n' : yaml.dump(next, YAML_DUMP_OPTS)
145
+ if (out === text) return 'unchanged'
146
+ writeFileSync(list.path, out)
147
+ return 'updated'
148
+ }
149
+
100
150
  // The format to give a NEW record file in a collection: match the collection's
101
151
  // existing single-record files, else markdown when the schema has a content body field
102
152
  // (so the body has a home), else YAML.
@@ -125,7 +175,7 @@ function defaultFormat(poolDir, declaration) {
125
175
  // are walked; the collection is the NEAREST enclosing branch segment (correct for
126
176
  // the default one-branch-per-collection org; a deeply nested virtual org may
127
177
  // differ — see the module header).
128
- function indexFolder(folderDoc) {
178
+ export function indexFolder(folderDoc) {
129
179
  const byUuid = new Map()
130
180
  const walk = (nodes, folderPath) => {
131
181
  for (const node of nodes || []) {
@@ -271,19 +321,34 @@ const DECL_WIRE_CONSUMED = new Set([
271
321
  'queryable'
272
322
  ])
273
323
 
324
+ // A standard schema (`@std/<name>`), normalized as the build resolves one — known with no foundation
325
+ // on disk. ⛔ Until 2026-09-26 a clone, which has none, could not tell a derived `deferred:` from an
326
+ // authored one and wrote it into the author's queries (`international`'s `articles`, over `@std/article`).
327
+ function standardDataSchema(ref) {
328
+ const m = typeof ref === 'string' ? /^@std\/([^/]+)$/.exec(ref) : null
329
+ if (!m || !isStandardSchema(m[1])) return null
330
+ try {
331
+ return validateAndNormalizeSchema(getStandardSchema(m[1]), ref)
332
+ } catch {
333
+ return null
334
+ }
335
+ }
336
+
274
337
  // Is this wire `deferred` exactly what the schema's brief would have derived? Compared
275
338
  // as an ORDER-INSENSITIVE set: the deriver walks the schema's sections, and a round trip
276
339
  // through YAML and the store is not obliged to preserve that order. Comparing as a list
277
340
  // would classify a reordered-but-identical value as authored, and persist it.
278
- function isDerivedDeferred(d, dataSchemas) {
279
- if (!dataSchemas || !Array.isArray(d.deferred)) return false
280
- const derived = deferredFromSchema(dataSchemas[d.schema])
341
+ function isDerivedDeferred(d, dataSchemas, pulledModel = null) {
342
+ if (!Array.isArray(d.deferred)) return false
343
+ // A clone has no foundation to read a schema from: it judges by the Model the pull read from the
344
+ // backend (`pulledModel`), and a standard schema is known in any case (`standardDataSchema`).
345
+ const derived = deferredFromSchema(dataSchemas?.[d.schema] ?? pulledModel ?? standardDataSchema(d.schema))
281
346
  if (!derived || derived.length !== d.deferred.length) return false
282
347
  const a = new Set(derived)
283
348
  return d.deferred.every((f) => a.has(f))
284
349
  }
285
350
 
286
- function declToFileShape(wire, dataSchemas = null, scope = null, own = null, keyTypes = null, authored = null) {
351
+ function declToFileShape(wire, dataSchemas = null, scope = null, own = null, keyTypes = null, authored = null, models = null) {
287
352
  // ⛔ UNDO THE PRODUCER'S QUALIFICATION FIRST, before anything compares against
288
353
  // `schema`. The push qualifies a foundation-relative `@/x` to `@scope/x`
289
354
  // (`site.js::queriesNested`), and both checks below are keyed by the author's
@@ -346,7 +411,7 @@ function declToFileShape(wire, dataSchemas = null, scope = null, own = null, key
346
411
  //
347
412
  // ⚖️ Only an EQUAL value is dropped. An author who deliberately writes a narrower or
348
413
  // wider `deferred:` than the brief implies has expressed intent, and that survives.
349
- if (d.deferred !== undefined && !isDerivedDeferred(d, dataSchemas)) {
414
+ if (d.deferred !== undefined && !isDerivedDeferred(d, dataSchemas, models?.[wire.schema] ?? null)) {
350
415
  decl.deferred = d.deferred
351
416
  }
352
417
  // ⛔ A stored `detail_url` is not written back: `detailUrl:` is retired (2026-09-13)
@@ -409,7 +474,7 @@ function authoredQuerySchemas(siteRoot, siteYml) {
409
474
  return out
410
475
  }
411
476
 
412
- export function declarationsToQueriesYml({ document, siteRoot, scope, ...rest }) {
477
+ export function declarationsToQueriesYml({ document, siteRoot, scope, models = null, ...rest }) {
413
478
  refuseOrgOption(rest, 'declarationsToQueriesYml')
414
479
  const decls = Array.isArray(document?.queries) ? document.queries : []
415
480
  const report = {}
@@ -438,7 +503,7 @@ export function declarationsToQueriesYml({ document, siteRoot, scope, ...rest })
438
503
 
439
504
  const queries = {}
440
505
  for (const d of decls) {
441
- const { name, decl } = declToFileShape(d, dataSchemas, selfScope, own, keyTypes, authored)
506
+ const { name, decl } = declToFileShape(d, dataSchemas, selfScope, own, keyTypes, authored, models)
442
507
  if (!name) continue
443
508
  queries[name] = decl
444
509
  }
@@ -524,7 +589,8 @@ export function folderToFolderYml({ folderDoc, siteRoot, poolPathByUuid, sourceL
524
589
  unplaceable = true
525
590
  continue
526
591
  }
527
- out.push(rel)
592
+ // A list file holds several records and is one path — listed once.
593
+ if (!out.includes(rel)) out.push(rel)
528
594
  }
529
595
  return out
530
596
  }
@@ -589,7 +655,7 @@ export function recordsToProject({ folderDoc, recordDocs = [], siteRoot, opts =
589
655
  // Captures target-locale translations of localized record fields: SCALARs →
590
656
  // locales/records/{locale}.json (structural maps too), and a prosemirror
591
657
  // BODY's free-form per-locale override → locales/freeform/{locale}/records/.
592
- const collector = createTranslationCollector(sourceLocale)
658
+ const collector = createTranslationCollector(sourceLocale, { siteRoot })
593
659
  const updated = []
594
660
  const placed = []
595
661
  const unchanged = []
@@ -616,13 +682,19 @@ export function recordsToProject({ folderDoc, recordDocs = [], siteRoot, opts =
616
682
  // The file holding a record already, by its own id: the derived folder first, then any
617
683
  // other that reads as the same Model (`recordDirsReadingAs`).
618
684
  const readingAs = (model) => recordDirsReadingAs(recordsRoot, model, selfScope, { own, keyTypes })
619
- const findRecordFile = (poolDir, model, ownId) =>
620
- findRecordFileByUuid(poolDir, ownId) ||
621
- readingAs(model)
622
- .filter((dir) => dir !== poolDir)
623
- .map((dir) => findRecordFileByUuid(dir, ownId))
624
- .find(Boolean) ||
625
- null
685
+ const findRecordFile = (poolDir, model, ownId) => {
686
+ const dirs = [poolDir, ...readingAs(model).filter((dir) => dir !== poolDir)]
687
+ // Single-record files first, as before; then an entry of a list file.
688
+ for (const find of [findRecordFileByUuid, findListEntryByUuid]) {
689
+ for (const dir of dirs) {
690
+ const hit = find(dir, ownId)
691
+ if (hit) return hit
692
+ }
693
+ }
694
+ return null
695
+ }
696
+ // BibTeX files holding a pulled record — never rewritten by a pull, and said once each.
697
+ const keptBib = new Set()
626
698
 
627
699
  for (const document of recordDocs) {
628
700
  const where = locate(document, folderIndex)
@@ -677,9 +749,21 @@ export function recordsToProject({ folderDoc, recordDocs = [], siteRoot, opts =
677
749
  // ⛔ The file gets the record's OWN id, never this backend's — a pull from a second
678
750
  // backend must not overwrite the identity the record already has.
679
751
  const toWrite = ownId && ownId !== theirs ? { ...document, $uuid: ownId } : document
752
+ // The record's identity as the build keys its translations — the folder its file is in,
753
+ // under `records/`, and its handle — so a translation that differs from one record to
754
+ // another comes back as the author's `{ default, overrides }`.
755
+ const context = { key: [relative(recordsRoot, dirname(existing?.path ?? filePath)).split(sep).join('/'), where.slug].filter(Boolean).join('/') }
680
756
  let status
681
757
  try {
682
- status = writeRecordFile({ filePath, document: toWrite, declaration, format, sourceLocale, collector, freeformRelPath, refName })
758
+ if (existing?.list && existing.format === 'bib') {
759
+ // Kept as the author has it: a pull does not re-render BibTeX (said once per file, below).
760
+ keptBib.add(existing.path)
761
+ status = 'unchanged'
762
+ } else if (existing?.list) {
763
+ status = writeRecordIntoList({ list: existing, document: toWrite, declaration, sourceLocale, collector, freeformRelPath, refName, context })
764
+ } else {
765
+ status = writeRecordFile({ filePath, document: toWrite, declaration, format, sourceLocale, collector, freeformRelPath, refName, context })
766
+ }
683
767
  } catch (err) {
684
768
  // ⛔ A record that could not be written was not placed, so it is a SKIP, not a
685
769
  // warning: a caller that counts what it placed (the CLI's pull) must see it.
@@ -707,6 +791,9 @@ export function recordsToProject({ folderDoc, recordDocs = [], siteRoot, opts =
707
791
  // site's folder, keyed by the site-content uuid.
708
792
  const records = folderToFolderYml({ folderDoc, siteRoot, poolPathByUuid, sourceLocale })
709
793
  warnings.push(...records.warnings)
794
+ for (const path of keptBib) {
795
+ warnings.push(`${relative(siteRoot, path)}: a pull does not rewrite a BibTeX file — its records are kept as they are.`)
796
+ }
710
797
 
711
798
  // Flush localized record-field translations to locales/records/{locale}.json,
712
799
  // and any prosemirror free-form body overrides to locales/freeform/{locale}/.
@@ -62,7 +62,7 @@ import { resolveSelfScope, siteSelfScope, refuseOrgOption } from './self-scope.j
62
62
  import { sha256Hex, toJsonBuffer } from './manifest.js'
63
63
  import { isUuid } from './uuid.js'
64
64
  import { markdownToProseMirror } from '@uniweb/content-reader'
65
- import { LOCALIZED_FIELD_ASSUMPTION, localize } from './localize.js'
65
+ import { LOCALIZED_FIELD_ASSUMPTION } from './localize.js'
66
66
  import { localizeScalar, localizeScalarList, localizeContentDoc, loadLocaleTranslations, discoverLocales, discoverFreeformLocales, localesDir, isLocalizedContent } from './locale-sync.js'
67
67
  import { loadFreeformRecord } from '../i18n/freeform.js'
68
68
  import { isDraftRecord } from '../site/record-draft.js'
@@ -164,7 +164,10 @@ export function entityContentHash(document) {
164
164
  return sha256Hex(toJsonBuffer(stripSigils(document)))
165
165
  }
166
166
 
167
- function encodeFieldValue(value, field, sourceLocale, translations) {
167
+ // ⭐ `context` is the record the value is in — `{ key: '<pool>/<handle>' }`, the identity its
168
+ // translations are kept under (`i18n/records.js`) — so an entry that reads differently in some
169
+ // records (`{ default, overrides }`) is sent as each one renders it.
170
+ function encodeFieldValue(value, field, sourceLocale, translations, context = null) {
168
171
  if (value == null) return value
169
172
  if (isProseMirrorField(field)) {
170
173
  // markdown source → ProseMirror doc. When localized, wrap per-locale exactly
@@ -172,7 +175,7 @@ function encodeFieldValue(value, field, sourceLocale, translations) {
172
175
  // path, flushed to locales/records/{locale}.json by the caller.
173
176
  const doc = typeof value === 'string' ? markdownToProseMirror(value) : value
174
177
  if (!field.localized) return doc
175
- const localized = localizeContentDoc(doc, sourceLocale, Object.keys(translations || {}), translations)
178
+ const localized = localizeContentDoc(doc, sourceLocale, Object.keys(translations || {}), translations, context)
176
179
  // localizeContentDoc returns a BARE doc when there are no target locales. A
177
180
  // localized field MUST ride as a `{ lang: value }` map on the wire — the
178
181
  // schema-driven projector drops a localized field whose value isn't a map — so
@@ -184,15 +187,14 @@ function encodeFieldValue(value, field, sourceLocale, translations) {
184
187
  // element — the shape page `keywords` already take. ⛔ It went up as a bare list
185
188
  // until 2026-09-24: `localizeScalar` passes any object through, arrays included.
186
189
  if (Array.isArray(value) && !isMarkupTextField(field)) {
187
- return localizeScalarList(value, sourceLocale, translations)
190
+ return localizeScalarList(value, sourceLocale, translations, context)
188
191
  }
189
- // A markup `text` BODY (format markdown|html) rides as a RAW string, wrapped
190
- // per-locale wholesale (its per-string translations live in the i18n manifest /
191
- // free-form, not the scalar map). Other localized scalars wrap per-string from
192
- // locales/records/{locale}.json.
193
- return isMarkupTextField(field)
194
- ? localize(value, sourceLocale)
195
- : localizeScalar(value, sourceLocale, translations)
192
+ // A markup `text` field (format markdown|html) rides as its RAW source, and its
193
+ // translation is the one the build renders: the whole value, keyed by the value
194
+ // (`i18n/records.js` extracts and translates it as one unit) — so it is wrapped
195
+ // per string like any localized scalar. ⛔ Until 2026-09-26 it went up as its
196
+ // source alone (`{ en }`), though the static build rendered its translation.
197
+ return localizeScalar(value, sourceLocale, translations, context)
196
198
  }
197
199
  // A Date handed in by a caller. The backend validates `date` as `YYYY-MM-DD` and
198
200
  // `datetime` as RFC3339 — emitting full ISO for a `date` field is rejected before
@@ -291,14 +293,15 @@ export function recordsToEntities({
291
293
  const uuid = record.$uuid || null
292
294
  const hasBody = typeof record.$body === 'string' && record.$body.trim() !== ''
293
295
 
294
- const enc = encoder(sourceLocale, translations, refs)
296
+ const context = { key: `${label}/${slug}` } // as the build keys the record's translations
297
+ const enc = encoder(sourceLocale, translations, refs, context)
295
298
  const sections = layout.flat ? readFlat(layout, record, enc) : readBySection(layout, record, enc)
296
299
  // The markdown body fills the content body field, in whatever section declares it,
297
300
  // unless the file already sets that field.
298
301
  if (hasBody && bodyTarget) {
299
302
  const data = (sections[bodyTarget.section] ??= {})
300
303
  if (data[bodyTarget.key] == null) {
301
- data[bodyTarget.key] = encodeFieldValue(record.$body, bodyTarget.field, sourceLocale, translations)
304
+ data[bodyTarget.key] = encodeFieldValue(record.$body, bodyTarget.field, sourceLocale, translations, context)
302
305
  }
303
306
  }
304
307
  // The brief is the card, and is always sent — an empty one included, so a required
@@ -381,8 +384,8 @@ export function recordsToEntities({
381
384
  // What one record's read collects beside the values: the keys with no place in the
382
385
  // schema, the keys written flat in a schema written by section, and values of the
383
386
  // wrong shape for their section.
384
- function encoder(sourceLocale, translations, refs = null) {
385
- return { sourceLocale, translations, refs, undeclared: [], misplaced: [], shape: [], unresolved: [], namesNew: 0 }
387
+ function encoder(sourceLocale, translations, refs = null, context = null) {
388
+ return { sourceLocale, translations, refs, context, undeclared: [], misplaced: [], shape: [], unresolved: [], namesNew: 0 }
386
389
  }
387
390
 
388
391
  // A flat record: its keys, minus the record's own, are the one section's fields.
@@ -473,7 +476,7 @@ function encodeRecord(fields, value, enc, prefix, reserved) {
473
476
  ? encodeSection(field, v, enc, `${prefix}${key}`)
474
477
  : field?.type === 'entity_ref'
475
478
  ? encodeReference(v, field, enc, `${prefix}${key}`)
476
- : encodeFieldValue(v, field, enc.sourceLocale, enc.translations)
479
+ : encodeFieldValue(v, field, enc.sourceLocale, enc.translations, enc.context)
477
480
  if (encoded !== undefined) out[key] = encoded
478
481
  }
479
482
  for (const key of Object.keys(value)) {
@@ -0,0 +1,43 @@
1
+ // Do two renderings of an author's content say the same thing? A pull that changes nothing must leave
2
+ // the author's file as they wrote it, and the writer's formatting is not theirs — so a pull compares
3
+ // meaning, not text, before it writes.
4
+ //
5
+ // A leaf, so the writers that compare this way — `project-writer.js` (sections, YAML config, record
6
+ // files), `locale-sync.js` (free-form translations) and `records-project.js` (list files) — share one
7
+ // rule without importing each other.
8
+
9
+ import { markdownToProseMirror } from '@uniweb/content-reader'
10
+
11
+ const isPlainObject = (v) => Boolean(v) && typeof v === 'object' && !Array.isArray(v)
12
+
13
+ /** JSON with every object's keys sorted — equal for equal values, whatever the order. */
14
+ export const canonicalJson = (value) =>
15
+ JSON.stringify(value, (_, v) =>
16
+ isPlainObject(v) ? Object.fromEntries(Object.keys(v).sort().map((k) => [k, v[k]])) : v
17
+ )
18
+
19
+ /**
20
+ * Does this markdown parse to `doc`, the way a push parses it?
21
+ *
22
+ * @param {string} markdown - an author's markdown body
23
+ * @param {object} doc - a ProseMirror document
24
+ * @returns {boolean}
25
+ */
26
+ export function sameMarkdownDocument(markdown, doc) {
27
+ try {
28
+ return canonicalJson(withoutDefaults(markdownToProseMirror(markdown))) === canonicalJson(withoutDefaults(doc))
29
+ } catch {
30
+ return false
31
+ }
32
+ }
33
+
34
+ // A document without the attributes that only restate a default: an inset's `embedKind: 'visual'`,
35
+ // which the parser writes and the pull's re-inlining leaves out (`reinlineInsets`) — so a body holding
36
+ // an inset never compared as unchanged (measured 2026-09-26 on the `marketing` template's hero).
37
+ function withoutDefaults(doc) {
38
+ return JSON.parse(JSON.stringify(doc), (_, v) => {
39
+ if (v?.type !== 'inset_ref' || v.attrs?.embedKind !== 'visual') return v
40
+ const { embedKind: _visual, ...attrs } = v.attrs
41
+ return { ...v, attrs }
42
+ })
43
+ }