@uniweb/build 0.63.4 → 0.64.1

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,29 @@ 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
+ // ⛔ An EMPTY body is one too: until 2026-09-26 it never compared as unchanged, so a section holding
205
+ // only frontmatter — a parametric page's `type:` and `fetch:` — was re-serialized, and lost its
206
+ // comments, on a pull that changed nothing. So is no document at all (a params-only update).
207
+ const authoredBody = (existingBody || '').replace(/^\n+/, '').replace(/\s+$/, '')
208
+ const keepsBody = !content || sameMarkdownDocument(authoredBody, content)
209
+ if (existing && keepsBody && canonicalJson(nextFrontmatter) === canonicalJson(frontmatter)) return 'unchanged'
210
+ const body = keepsBody ? authoredBody : proseMirrorToMarkdown(content)
194
211
  return writeIfChanged(filePath, assembleSection(nextFrontmatter, body))
195
212
  }
196
213
 
214
+
197
215
  // Shallow-merge `changes` into a YAML config file and write idempotently. A key
198
216
  // whose value is null/undefined is deleted; an object value is shallow-merged one
199
217
  // level deep (so partial `theme` / `build` updates don't drop sibling keys) unless
@@ -202,12 +220,8 @@ export function writeSectionFile({ filePath, content, params, reserved = DEFAULT
202
220
  // config, but comment-preserving merges for hand-authored config files are a
203
221
  // quality bar to revisit.
204
222
  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
- }
223
+ const prior = loadYamlFile(filePath)
224
+ const existing = isPlainObject(prior.value) ? { ...prior.value } : {}
211
225
  for (const [key, value] of Object.entries(changes)) {
212
226
  if (value === null || value === undefined) {
213
227
  delete existing[key]
@@ -216,13 +230,32 @@ function mergeYamlConfig(filePath, changes, { replace = [] } = {}) {
216
230
  // pull wrote `events: '@/event'` back as `events: { '0': '@', '1': '/', …,
217
231
  // schema: '@/event' }` (measured 2026-09-23). Anything else that is not an
218
232
  // object has nothing to keep, so the incoming value replaces it.
219
- const prior = existing[key]
220
- existing[key] = isPlainObject(prior) ? { ...prior, ...value } : value
233
+ const current = existing[key]
234
+ existing[key] = isPlainObject(current) ? { ...current, ...value } : value
221
235
  } else {
222
236
  existing[key] = value
223
237
  }
224
238
  }
225
- return writeIfChanged(filePath, yaml.dump(existing, YAML_DUMP_OPTS))
239
+ return writeYamlIfChanged(filePath, existing, prior)
240
+ }
241
+
242
+ // A YAML file, parsed — and whether there was one: a missing or unreadable file is never "unchanged".
243
+ function loadYamlFile(filePath) {
244
+ try {
245
+ return { value: yaml.load(readFileSync(filePath, 'utf8'), YAML_OPTIONS) ?? {}, read: true }
246
+ } catch {
247
+ return { value: {}, read: false }
248
+ }
249
+ }
250
+
251
+ // ⭐ A FILE WHOSE MEANING WOULD NOT CHANGE IS LEFT AS ITS AUTHOR WROTE IT. Every writer here re-dumps the
252
+ // whole file — its comments dropped, its strings re-quoted, its lists reflowed — so a pull that restated
253
+ // a file rewrote it all the same. ⛔ Until 2026-09-26 a pull into the copy that pushed it re-dumped
254
+ // `site.yml`, `theme.yml` and every `page.yml` (measured on the `international` template: every comment
255
+ // in its `site.yml` gone, and nothing had changed).
256
+ function writeYamlIfChanged(filePath, value, prior) {
257
+ if (prior.read && canonicalJson(value) === canonicalJson(prior.value)) return 'unchanged'
258
+ return writeIfChanged(filePath, yaml.dump(value, YAML_DUMP_OPTS))
226
259
  }
227
260
 
228
261
  const isPlainObject = (v) => Boolean(v) && typeof v === 'object' && !Array.isArray(v)
@@ -249,7 +282,7 @@ export function writeSiteConfig(siteRoot, config) {
249
282
  * @returns {'updated'|'unchanged'}
250
283
  */
251
284
  export function writeYamlFile(filePath, obj) {
252
- return writeIfChanged(filePath, yaml.dump(obj || {}, YAML_DUMP_OPTS))
285
+ return writeYamlIfChanged(filePath, obj || {}, loadYamlFile(filePath))
253
286
  }
254
287
 
255
288
  /**
@@ -269,17 +302,17 @@ export function writeYamlFile(filePath, obj) {
269
302
  * @returns {'updated'|'unchanged'}
270
303
  */
271
304
  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
305
+ const prior = loadYamlFile(filePath)
306
+ const existing = isPlainObject(prior.value) ? prior.value : {}
307
+ const managed = new Set(managedKeys)
308
+ // Each key where the file has it — a value the pull changes stays in its place — then the new ones.
309
+ const out = {}
310
+ for (const [key, value] of Object.entries(existing)) {
311
+ if (key in projected) out[key] = projected[key]
312
+ else if (!managed.has(key)) out[key] = value
277
313
  }
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))
314
+ for (const [key, value] of Object.entries(projected)) if (!(key in out)) out[key] = value
315
+ return writeYamlIfChanged(filePath, out, prior)
283
316
  }
284
317
 
285
318
  /**
@@ -309,12 +342,31 @@ export function writeQueriesConfig(siteRoot, queries) {
309
342
  // `events:` is `{}`), which a pull brings back in its long form. And a pull that
310
343
  // restates every query writes nothing: the file is re-dumped whole otherwise, which
311
344
  // rewrites a bare `events:` as `events: null` and flow style as block style.
345
+ //
346
+ // ⭐ WHEREVER THE AUTHOR DECLARED IT — `queries.yml`, or `site.yml`'s `queries:` block, which the
347
+ // build reads too, under it. ⛔ Until 2026-09-26 only `queries.yml` was consulted, so a site whose
348
+ // queries live in `site.yml` (the `international` template) got a `queries.yml` repeating every one
349
+ // of them on each pull, in the long form. A query that changed is written back where it lives.
350
+ let site = {}
351
+ try {
352
+ site = yaml.load(readFileSync(join(siteRoot, 'site.yml'), 'utf8'), YAML_OPTIONS) || {}
353
+ } catch {
354
+ // missing / invalid → nothing declared there
355
+ }
356
+ const inSite = isPlainObject(site.queries) ? site.queries : {}
312
357
  const changes = {}
358
+ const siteChanges = {}
313
359
  for (const [name, decl] of Object.entries(queries)) {
360
+ const inFile = isPlainObject(authored) && name in authored
361
+ if (!inFile && name in inSite) {
362
+ if (!restates(inSite[name], decl)) siteChanges[name] = decl
363
+ continue
364
+ }
314
365
  if (restates(authored?.[name], decl)) continue
315
366
  changes[name] = decl
316
367
  }
317
- if (Object.keys(changes).length === 0) return 'unchanged'
368
+ if (Object.keys(siteChanges).length) writeSiteConfig(siteRoot, { queries: siteChanges })
369
+ if (Object.keys(changes).length === 0) return Object.keys(siteChanges).length ? 'updated' : 'unchanged'
318
370
  // ⛔ Each query WHOLE (`replace`), as this function's header always said. Merged into
319
371
  // the local one — what the call did until 2026-09-23 — a key the backend's
320
372
  // declaration no longer has survived every pull: the defect `writeSiteConfig`
@@ -330,11 +382,6 @@ function restates(authored, decl) {
330
382
  return isPlainObject(authored) && canonicalJson(authored) === canonicalJson(decl)
331
383
  }
332
384
 
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
385
 
339
386
  /**
340
387
  * Write the records folder's organization — `folder.yml` in the records directory
@@ -382,9 +429,40 @@ export function writeThemeFile(siteRoot, theme) {
382
429
  * body (so a target-locale full-doc body is captured for locales/freeform/)
383
430
  * @param {(model: string, uuid: string) => string|null} [opts.refName] - the name a
384
431
  * referenced record goes by, so a reference is written as that name
432
+ * @param {{ key: string }} [opts.context] - the record's identity, `<pool>/<handle>`, which
433
+ * its translations are kept under — an entry that differs from record to record
434
+ * comes back as `{ default, overrides }`
385
435
  * @returns {'updated'|'unchanged'}
386
436
  */
387
- export function writeRecordFile({ filePath, document, declaration, format, sourceLocale = 'en', collector, freeformRelPath, refName }) {
388
- const text = renderEntityDocument({ document, declaration, format, sourceLocale, collector, freeformRelPath, refName })
437
+ export function writeRecordFile({ filePath, document, declaration, format, sourceLocale = 'en', collector, freeformRelPath, refName, context = null }) {
438
+ const text = renderEntityDocument({ document, declaration, format, sourceLocale, collector, freeformRelPath, refName, context })
439
+ // ⭐ A record the pull did not change keeps the author's file — its key order, its blank lines, its
440
+ // quoting. ⛔ Until 2026-09-26 each one was re-rendered (measured on the `international` template).
441
+ let current = null
442
+ try {
443
+ current = readFileSync(filePath, 'utf8')
444
+ } catch {
445
+ // new file
446
+ }
447
+ if (current !== null && sameRecordText(current, text, format, filePath)) return 'unchanged'
389
448
  return writeIfChanged(filePath, text)
390
449
  }
450
+
451
+ // Do two renderings of a record file say the same thing — the same data, and a body that parses to
452
+ // the same document?
453
+ function sameRecordText(a, b, format, filePath) {
454
+ try {
455
+ if (format === 'md') {
456
+ const x = parseFrontmatter(a, filePath)
457
+ const y = parseFrontmatter(b, filePath)
458
+ if (canonicalJson(x.frontmatter) !== canonicalJson(y.frontmatter)) return false
459
+ const bx = (x.body || '').trim()
460
+ const by = (y.body || '').trim()
461
+ return bx === by || sameMarkdownDocument(bx, markdownToProseMirror(by))
462
+ }
463
+ const parse = format === 'json' ? JSON.parse : (t) => yaml.load(t, YAML_OPTIONS)
464
+ return canonicalJson(parse(a)) === canonicalJson(parse(b))
465
+ } catch {
466
+ return false
467
+ }
468
+ }
@@ -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.
@@ -205,8 +255,12 @@ function siteFoundationDeclarations(siteRoot) {
205
255
  return { own: null, keyTypes: null }
206
256
  }
207
257
  const schema = siteYml ? foundationSchemaJson(siteRoot, siteYml) : null
258
+ // ⛔ Own names only from a schema that carries its data schemas. The registered version a clone
259
+ // keeps carries none (they travel as Models), and read as an empty set it said "owns nothing", so a
260
+ // clone placed a foundation's own `@std/exhibit` in `records/std/exhibit/`; unknown (null), the
261
+ // standard-schema rule decides (`unresolveSelfScope`), as it did before the kept version existed.
208
262
  return schema
209
- ? { own: ownSchemaNames(schema.dataSchemas || {}), keyTypes: dataKeyTypes(schema) }
263
+ ? { own: ownSchemaNames(schema.dataSchemas), keyTypes: dataKeyTypes(schema) }
210
264
  : { own: null, keyTypes: null }
211
265
  }
212
266
 
@@ -268,22 +322,39 @@ const DECL_WIRE_CONSUMED = new Set([
268
322
  'deferred',
269
323
  'detail_url',
270
324
  'detail',
271
- 'queryable'
325
+ 'queryable',
326
+ // Says the query names no schema of its own — how the file is written, never a key in it.
327
+ 'typed_by_data_key',
272
328
  ])
273
329
 
330
+ // A standard schema (`@std/<name>`), normalized as the build resolves one — known with no foundation
331
+ // on disk. ⛔ Until 2026-09-26 a clone, which has none, could not tell a derived `deferred:` from an
332
+ // authored one and wrote it into the author's queries (`international`'s `articles`, over `@std/article`).
333
+ function standardDataSchema(ref) {
334
+ const m = typeof ref === 'string' ? /^@std\/([^/]+)$/.exec(ref) : null
335
+ if (!m || !isStandardSchema(m[1])) return null
336
+ try {
337
+ return validateAndNormalizeSchema(getStandardSchema(m[1]), ref)
338
+ } catch {
339
+ return null
340
+ }
341
+ }
342
+
274
343
  // Is this wire `deferred` exactly what the schema's brief would have derived? Compared
275
344
  // as an ORDER-INSENSITIVE set: the deriver walks the schema's sections, and a round trip
276
345
  // through YAML and the store is not obliged to preserve that order. Comparing as a list
277
346
  // 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])
347
+ function isDerivedDeferred(d, dataSchemas, pulledModel = null) {
348
+ if (!Array.isArray(d.deferred)) return false
349
+ // A clone has no foundation to read a schema from: it judges by the Model the pull read from the
350
+ // backend (`pulledModel`), and a standard schema is known in any case (`standardDataSchema`).
351
+ const derived = deferredFromSchema(dataSchemas?.[d.schema] ?? pulledModel ?? standardDataSchema(d.schema))
281
352
  if (!derived || derived.length !== d.deferred.length) return false
282
353
  const a = new Set(derived)
283
354
  return d.deferred.every((f) => a.has(f))
284
355
  }
285
356
 
286
- function declToFileShape(wire, dataSchemas = null, scope = null, own = null, keyTypes = null, authored = null) {
357
+ function declToFileShape(wire, dataSchemas = null, scope = null, own = null, keyTypes = null, authored = null, models = null, markedOnly = false) {
287
358
  // ⛔ UNDO THE PRODUCER'S QUALIFICATION FIRST, before anything compares against
288
359
  // `schema`. The push qualifies a foundation-relative `@/x` to `@scope/x`
289
360
  // (`site.js::queriesNested`), and both checks below are keyed by the author's
@@ -320,8 +391,15 @@ function declToFileShape(wire, dataSchemas = null, scope = null, own = null, key
320
391
  // ⛔ UNLESS THE AUTHOR WROTE IT: a `schema:` the local file already states for this query is
321
392
  // written back as it is. Until 2026-09-25 a pull into a working copy dropped international's
322
393
  // `articles: { schema: '@std/article' }`, because a section types `articles` as `@std/article`.
323
- const keyDefault = name && !own?.has(name) ? keyTypes?.get(name) : null
394
+ // ⭐ A CLONE GOES BY THE MARK. Where the key's type comes only from the registered foundation a
395
+ // project keeps (`markedOnly`: its foundation is a catalog ref), a query is written terse only when
396
+ // the push marked it `typed_by_data_key` — the author wrote no schema — or when the author's own
397
+ // file declares it with none. Unmarked, the schema was the author's, and it is written.
324
398
  const written = name ? authored?.get(name) : undefined
399
+ const typedHere = !markedOnly || wire.typed_by_data_key === true || written === null
400
+ const keyType = name && !own?.has(name) && typedHere ? keyTypes?.get(name) : null
401
+ // In the author's form: the registered version names the type qualified (`@acme/member`).
402
+ const keyDefault = keyType ? unresolveSelfScope(keyType, scope, own) : null
325
403
  if (d.schema && (d.schema === written || (d.schema !== defaultSchema(name) && d.schema !== keyDefault))) {
326
404
  decl.schema = d.schema
327
405
  }
@@ -346,7 +424,7 @@ function declToFileShape(wire, dataSchemas = null, scope = null, own = null, key
346
424
  //
347
425
  // ⚖️ Only an EQUAL value is dropped. An author who deliberately writes a narrower or
348
426
  // wider `deferred:` than the brief implies has expressed intent, and that survives.
349
- if (d.deferred !== undefined && !isDerivedDeferred(d, dataSchemas)) {
427
+ if (d.deferred !== undefined && !isDerivedDeferred(d, dataSchemas, models?.[wire.schema] ?? null)) {
350
428
  decl.deferred = d.deferred
351
429
  }
352
430
  // ⛔ A stored `detail_url` is not written back: `detailUrl:` is retired (2026-09-13)
@@ -398,18 +476,42 @@ function authoredQuerySchemas(siteRoot, siteYml) {
398
476
  } catch {
399
477
  file = null
400
478
  }
479
+ // name → the schema the author's file states for it, or null for a query it declares with none.
480
+ // ⛔ The string shorthand (`team: '@/member'`) is a schema too: until 2026-09-26 it read as none,
481
+ // so a pull could write the author's `team: '@/member'` back as `team: {}`.
401
482
  const out = new Map()
402
483
  for (const decls of [siteYml?.queries, file]) {
403
484
  if (!decls || typeof decls !== 'object') continue
404
485
  for (const [name, decl] of Object.entries(decls)) {
405
- if (typeof decl?.schema === 'string') out.set(name, decl.schema)
406
- else if (out.has(name)) out.delete(name)
486
+ const schema = typeof decl === 'string' ? decl : typeof decl?.schema === 'string' ? decl.schema : null
487
+ out.set(name, schema)
407
488
  }
408
489
  }
409
490
  return out
410
491
  }
411
492
 
412
- export function declarationsToQueriesYml({ document, siteRoot, scope, ...rest }) {
493
+ // ⭐ WHERE A NEW RECORD OF A KEY'S TYPE GOES: the folder of a query the site declares with no schema
494
+ // of its own, whose name is a data key typed as that Model — `records/team/` for `team:` typed
495
+ // `@acme/member` — which is where a push reads it from (`uwx/data-key-types.js`). model → folder.
496
+ function typedQueryFolders(siteRoot, recordsRoot, scope, own, keyTypes) {
497
+ const out = new Map()
498
+ if (!keyTypes?.size) return out
499
+ let siteYml = null
500
+ try {
501
+ siteYml = yaml.load(readFileSync(join(siteRoot, 'site.yml'), 'utf8'), YAML_OPTIONS) || null
502
+ } catch {
503
+ siteYml = null
504
+ }
505
+ for (const [name, schema] of authoredQuerySchemas(siteRoot, siteYml)) {
506
+ if (schema !== null || own?.has(name)) continue
507
+ const type = keyTypes.get(name)
508
+ const model = type ? resolveSelfScope(type, scope) : null
509
+ if (model && !out.has(model)) out.set(model, join(recordsRoot, name))
510
+ }
511
+ return out
512
+ }
513
+
514
+ export function declarationsToQueriesYml({ document, siteRoot, scope, models = null, ...rest }) {
413
515
  refuseOrgOption(rest, 'declarationsToQueriesYml')
414
516
  const decls = Array.isArray(document?.queries) ? document.queries : []
415
517
  const report = {}
@@ -435,10 +537,12 @@ export function declarationsToQueriesYml({ document, siteRoot, scope, ...rest })
435
537
  const own = ownSchemaNames(dataSchemas)
436
538
  const keyTypes = siteYml ? dataKeyTypes(foundationSchemaJson(siteRoot, siteYml)) : null
437
539
  const authored = authoredQuerySchemas(siteRoot, siteYml)
540
+ // The key types a catalog-ref foundation has come from the registered version the project keeps.
541
+ const markedOnly = Boolean(parseCatalogRef(siteYml?.foundation))
438
542
 
439
543
  const queries = {}
440
544
  for (const d of decls) {
441
- const { name, decl } = declToFileShape(d, dataSchemas, selfScope, own, keyTypes, authored)
545
+ const { name, decl } = declToFileShape(d, dataSchemas, selfScope, own, keyTypes, authored, models, markedOnly)
442
546
  if (!name) continue
443
547
  queries[name] = decl
444
548
  }
@@ -524,7 +628,8 @@ export function folderToFolderYml({ folderDoc, siteRoot, poolPathByUuid, sourceL
524
628
  unplaceable = true
525
629
  continue
526
630
  }
527
- out.push(rel)
631
+ // A list file holds several records and is one path — listed once.
632
+ if (!out.includes(rel)) out.push(rel)
528
633
  }
529
634
  return out
530
635
  }
@@ -589,7 +694,7 @@ export function recordsToProject({ folderDoc, recordDocs = [], siteRoot, opts =
589
694
  // Captures target-locale translations of localized record fields: SCALARs →
590
695
  // locales/records/{locale}.json (structural maps too), and a prosemirror
591
696
  // BODY's free-form per-locale override → locales/freeform/{locale}/records/.
592
- const collector = createTranslationCollector(sourceLocale)
697
+ const collector = createTranslationCollector(sourceLocale, { siteRoot })
593
698
  const updated = []
594
699
  const placed = []
595
700
  const unchanged = []
@@ -616,13 +721,20 @@ export function recordsToProject({ folderDoc, recordDocs = [], siteRoot, opts =
616
721
  // The file holding a record already, by its own id: the derived folder first, then any
617
722
  // other that reads as the same Model (`recordDirsReadingAs`).
618
723
  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
724
+ const keyFolders = typedQueryFolders(siteRoot, recordsRoot, selfScope, own, keyTypes)
725
+ const findRecordFile = (poolDir, model, ownId) => {
726
+ const dirs = [poolDir, ...readingAs(model).filter((dir) => dir !== poolDir)]
727
+ // Single-record files first, as before; then an entry of a list file.
728
+ for (const find of [findRecordFileByUuid, findListEntryByUuid]) {
729
+ for (const dir of dirs) {
730
+ const hit = find(dir, ownId)
731
+ if (hit) return hit
732
+ }
733
+ }
734
+ return null
735
+ }
736
+ // BibTeX files holding a pulled record — never rewritten by a pull, and said once each.
737
+ const keptBib = new Set()
626
738
 
627
739
  for (const document of recordDocs) {
628
740
  const where = locate(document, folderIndex)
@@ -663,8 +775,9 @@ export function recordsToProject({ folderDoc, recordDocs = [], siteRoot, opts =
663
775
  isNew = false
664
776
  } else {
665
777
  // A new record goes where the project already keeps records of its Model — its derived
666
- // folder, else another that reads as the Model (`records/team/` for `@std/member`).
667
- const home = existsSync(poolDir) ? poolDir : readingAs(schema)[0] || poolDir
778
+ // folder, else another that reads as the Model (`records/team/` for `@std/member`), else the
779
+ // folder of a query typed by its data key as the Model (`typedQueryFolders`) — a clone's.
780
+ const home = existsSync(poolDir) ? poolDir : readingAs(schema)[0] || keyFolders.get(schema) || poolDir
668
781
  format = defaultFormat(home, declaration)
669
782
  filePath = join(home, where.slug + EXT_FOR_FORMAT[format])
670
783
  isNew = true
@@ -677,9 +790,21 @@ export function recordsToProject({ folderDoc, recordDocs = [], siteRoot, opts =
677
790
  // ⛔ The file gets the record's OWN id, never this backend's — a pull from a second
678
791
  // backend must not overwrite the identity the record already has.
679
792
  const toWrite = ownId && ownId !== theirs ? { ...document, $uuid: ownId } : document
793
+ // The record's identity as the build keys its translations — the folder its file is in,
794
+ // under `records/`, and its handle — so a translation that differs from one record to
795
+ // another comes back as the author's `{ default, overrides }`.
796
+ const context = { key: [relative(recordsRoot, dirname(existing?.path ?? filePath)).split(sep).join('/'), where.slug].filter(Boolean).join('/') }
680
797
  let status
681
798
  try {
682
- status = writeRecordFile({ filePath, document: toWrite, declaration, format, sourceLocale, collector, freeformRelPath, refName })
799
+ if (existing?.list && existing.format === 'bib') {
800
+ // Kept as the author has it: a pull does not re-render BibTeX (said once per file, below).
801
+ keptBib.add(existing.path)
802
+ status = 'unchanged'
803
+ } else if (existing?.list) {
804
+ status = writeRecordIntoList({ list: existing, document: toWrite, declaration, sourceLocale, collector, freeformRelPath, refName, context })
805
+ } else {
806
+ status = writeRecordFile({ filePath, document: toWrite, declaration, format, sourceLocale, collector, freeformRelPath, refName, context })
807
+ }
683
808
  } catch (err) {
684
809
  // ⛔ A record that could not be written was not placed, so it is a SKIP, not a
685
810
  // warning: a caller that counts what it placed (the CLI's pull) must see it.
@@ -707,6 +832,9 @@ export function recordsToProject({ folderDoc, recordDocs = [], siteRoot, opts =
707
832
  // site's folder, keyed by the site-content uuid.
708
833
  const records = folderToFolderYml({ folderDoc, siteRoot, poolPathByUuid, sourceLocale })
709
834
  warnings.push(...records.warnings)
835
+ for (const path of keptBib) {
836
+ warnings.push(`${relative(siteRoot, path)}: a pull does not rewrite a BibTeX file — its records are kept as they are.`)
837
+ }
710
838
 
711
839
  // Flush localized record-field translations to locales/records/{locale}.json,
712
840
  // and any prosemirror free-form body overrides to locales/freeform/{locale}/.