@uniweb/build 0.73.0 → 0.73.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.
@@ -48,7 +48,10 @@ import { folderYmlPath } from '../site/records-config.js'
48
48
  import { contentBodyTarget } from './record-layout.js'
49
49
  import { unresolveSelfScope, resolveSelfScope, ownSchemaNames, refuseOrgOption } from './self-scope.js'
50
50
  import { parseCatalogRef } from '../site/foundation-ref.js'
51
- import { unwrapLocalized, renderEntityDocument } from './backfill.js'
51
+ import { renderEntityDocument } from './backfill.js'
52
+ import { labelForFile } from '../site/entry-label.js'
53
+ import { LINK_MODEL, writeLinkRecords } from './link-records.js'
54
+ import { FILE_MODEL, filesToPull } from './file-records.js'
52
55
  import { parseBibtex } from '@citestyle/bibtex'
53
56
  import { createTranslationCollector, writeLocaleTranslations, writeFreeformTranslations } from './locale-sync.js'
54
57
  import { buildFreeformRecordPath } from '../i18n/freeform.js'
@@ -161,6 +164,13 @@ export function indexFolder(folderDoc) {
161
164
  const uuid = typeof node.entry === 'object' ? node.entry.entity : node.entry
162
165
  const schema = typeof node.entry === 'object' ? node.entry.schema : undefined
163
166
  if (uuid) byUuid.set(uuid, { folderPath, slug: node.name, schema })
167
+ } else if (node?.kind === 'link' && typeof node.$uuid === 'string' && node.$uuid) {
168
+ // ⭐ A link entry is a record of `@uniweb/link`, its identity the item's own
169
+ // (`./link-records.js`).
170
+ byUuid.set(node.$uuid, { folderPath, slug: node.name, schema: LINK_MODEL })
171
+ } else if (node?.kind === 'file' && typeof node.$uuid === 'string' && node.$uuid) {
172
+ // …and a file entry one of `@uniweb/file` (`./file-records.js`).
173
+ byUuid.set(node.$uuid, { folderPath, slug: node.name, schema: FILE_MODEL })
164
174
  }
165
175
  }
166
176
  }
@@ -522,25 +532,30 @@ export function folderToFolderYml({ folderDoc, siteRoot, poolPathByUuid, sourceL
522
532
  if (!node || typeof node !== 'object') continue
523
533
  if (node.kind === 'branch') {
524
534
  const entry = { folder: node.name }
525
- // Only a BRANCH takes a label. A record carries its own title; the folder
526
- // does not caption its rows. On the wire the label is a localized map;
527
- // folder.yml carries the source-locale string (a bare string passes).
528
- if (node.label !== undefined) entry.label = unwrapLocalized(node.label, sourceLocale)
535
+ // A folder's label, as a record entry's below: on the wire a map of languages;
536
+ // in folder.yml the source language's text alone, or every language the label
537
+ // holds when it holds another (`entry-label.js`, 2026-09-28). ⛔ This kept the
538
+ // source language's text only until then, and said only a branch takes a label —
539
+ // a record's entry has taken one since 2026-09-27.
540
+ const folderLabel = labelForFile(node.label, sourceLocale)
541
+ if (folderLabel !== null) entry.label = folderLabel
529
542
  if (Array.isArray(node.tags) && node.tags.length) entry.tags = node.tags.map(String)
530
543
  entry.records = walk(node.$children, true)
531
544
  out.push(entry)
532
545
  continue
533
546
  }
534
- const uuid = node.entry?.entity ?? node.entry
547
+ // A link or file entry is its own record — its identity is the item's (`./link-records.js`,
548
+ // `./file-records.js`).
549
+ const uuid = node.kind === 'link' || node.kind === 'file' ? node.$uuid : node.entry?.entity ?? node.entry
535
550
  const rel = typeof uuid === 'string' ? poolPathByUuid.get(uuid) : null
536
551
  // ⭐ What the folder says about the record — its entry's `tags` and `label` — comes
537
552
  // back as the entry `{ path, tags?, label? }` (ruled 2026-09-27 [Diego]).
538
553
  const said = {}
539
554
  if (Array.isArray(node.tags) && node.tags.length) said.tags = node.tags.map(String)
540
- if (node.label !== undefined && node.label !== null) {
541
- const label = unwrapLocalized(node.label, sourceLocale)
542
- if (typeof label === 'string' && label) said.label = label
543
- }
555
+ // Every language the label holds (`entry-label.js`, 2026-09-28) — ⛔ the source
556
+ // language's alone until then, so a translated label lost the rest on a round trip.
557
+ const label = labelForFile(node.label, sourceLocale)
558
+ if (label !== null) said.label = label
544
559
  const says = Object.keys(said).length > 0
545
560
  // A record at the top of the folder needs no line unless the folder says something
546
561
  // about it — but one that did not land is still said: the next push sends the folder
@@ -768,6 +783,27 @@ export function recordsToProject({ folderDoc, recordDocs = [], siteRoot, opts =
768
783
  //
769
784
  // The folder ENTITY still carries no `$uuid` we persist: the backend owns the
770
785
  // site's folder, keyed by the site-content uuid.
786
+ // ⭐ THE FOLDER'S LINK ENTRIES, written as their record files (`./link-records.js`) — before the
787
+ // folder, which places them by the files they land in. ⛔ Until 2026-09-28 a pull could not write
788
+ // one, and warned that a push from here would remove it.
789
+ const linked = writeLinkRecords({ folderDoc, recordsRoot, ownIdByTheirs })
790
+ for (const [theirs, rel] of linked.pathByUuid) poolPathByUuid.set(theirs, rel)
791
+ Object.assign(learned, linked.learned)
792
+ placed.push(...linked.placed)
793
+ updated.push(...linked.updated)
794
+ unchanged.push(...linked.unchanged)
795
+ warnings.push(...linked.warnings)
796
+ // ⭐ AND ITS FILE ENTRIES (`./file-records.js`): where each file lands in `records/uniweb/file/`,
797
+ // placed like any record — the bytes are the CLI's to fetch (`fileDownloads`), as it fetches media.
798
+ const pulledFiles = filesToPull({
799
+ folderDoc,
800
+ recordsRoot,
801
+ fileMap: backendState.files || {},
802
+ assetMap: backendState.assets || {},
803
+ })
804
+ for (const [theirs, rel] of pulledFiles.pathByUuid) poolPathByUuid.set(theirs, rel)
805
+ warnings.push(...pulledFiles.warnings)
806
+
771
807
  const records = folderToFolderYml({ folderDoc, siteRoot, poolPathByUuid, sourceLocale })
772
808
  warnings.push(...records.warnings)
773
809
  for (const path of keptBib) {
@@ -782,6 +818,13 @@ export function recordsToProject({ folderDoc, recordDocs = [], siteRoot, opts =
782
818
  if (opts.backend && Object.keys(learned).length) {
783
819
  updateBackendMap(siteRoot, opts.backend, 'records', learned)
784
820
  }
821
+ if (opts.backend && Object.keys(pulledFiles.learned).length) {
822
+ updateBackendMap(siteRoot, opts.backend, 'files', pulledFiles.learned)
823
+ }
785
824
 
786
- return { updated, placed, unchanged, skipped, warnings, locales, freeform, records: records.status, recordsFile: records.file }
825
+ return {
826
+ updated, placed, unchanged, skipped, warnings, locales, freeform, records: records.status, recordsFile: records.file,
827
+ // The file records' bytes to fetch — `{ url, path, poolPath }` each (`./file-records.js`).
828
+ fileDownloads: pulledFiles.downloads,
829
+ }
787
830
  }
@@ -67,6 +67,8 @@ import { LOCALIZED_FIELD_ASSUMPTION } from './localize.js'
67
67
  import { localizeScalar, localizeScalarList, localizeContentDoc, loadLocaleTranslations, discoverLocales, discoverFreeformLocales, localesDir, isLocalizedContent } from './locale-sync.js'
68
68
  import { loadFreeformRecord } from '../i18n/freeform.js'
69
69
  import { isDraftRecord } from '../site/record-draft.js'
70
+ import { LINK_MODEL, linkRecordRefusal } from './link-records.js'
71
+ import { FILE_MODEL } from './file-records.js'
70
72
 
71
73
  const DATE_KINDS = new Set(['date', 'datetime'])
72
74
  // The record's OWN keys — identity and transport, never a field of any section, never
@@ -907,9 +909,11 @@ export async function buildRecordEntities(siteRoot, opts = {}) {
907
909
  colConfig,
908
910
  folder: { ...folder, nodes: [] },
909
911
  recordsDirExists: pool.exists,
910
- sendFolder: sendsFolder(pool, []),
912
+ sendFolder: sendsFolder(pool, [], []),
911
913
  declarations: new Map(),
912
914
  keyTyped: new Map(),
915
+ links: [],
916
+ files: [],
913
917
  }
914
918
  }
915
919
 
@@ -1073,7 +1077,64 @@ export async function buildRecordEntities(siteRoot, opts = {}) {
1073
1077
  // (`self-scope.js::unresolveSelfScope`), and a push then reported only a name two
1074
1078
  // records share.
1075
1079
  const fileByOwnId = new Map()
1080
+
1081
+ // ⭐ A LINK RECORD IS A FOLDER ENTRY, NEVER AN ENTITY (`@uniweb/link`, 2026-09-28 —
1082
+ // `./link-records.js`). Its file holds its `url` and its own `$uuid`; the push sends it as an
1083
+ // item of the folder, `{ kind: 'link', name, url }`, under the uuid this backend holds it by
1084
+ // (`recordMap`), or none on its first push. Placed like any record, so `folder.yml` gives its
1085
+ // label and tags.
1086
+ const links = []
1087
+ const linkNames = new Map() // slug → the file that gave it, so two files cannot claim one name
1088
+ for (const pooled of poolBySchema.get(LINK_MODEL) || []) {
1089
+ const produced = []
1090
+ for (const r of await readEntityFile(pooled.absPath)) {
1091
+ const where = r.multiRecord ? `${pooled.relPath} (${r.slug})` : pooled.relPath
1092
+ const refusal = linkRecordRefusal(r, where)
1093
+ if (refusal) {
1094
+ refusals.push(refusal)
1095
+ continue
1096
+ }
1097
+ const first = linkNames.get(r.slug)
1098
+ if (first) {
1099
+ refusals.push(`${pooled.relPath}: a link named "${r.slug}" is also ${first} — a link is named by its file, so each needs its own name.`)
1100
+ continue
1101
+ }
1102
+ linkNames.set(r.slug, pooled.relPath)
1103
+ const id = [...pooled.dirs, r.slug].join('/')
1104
+ const ownId = typeof r.data?.$uuid === 'string' && r.data.$uuid ? r.data.$uuid : null
1105
+ links.push({
1106
+ id,
1107
+ slug: r.slug,
1108
+ url: r.data.url.trim(),
1109
+ ownId,
1110
+ uuid: ownId ? recordMap[ownId] ?? null : null,
1111
+ sourceFile: r.multiRecord ? null : r.sourceFile ?? pooled.absPath,
1112
+ })
1113
+ produced.push({ id, slug: r.slug })
1114
+ }
1115
+ producedBy.set(pooled.id, produced)
1116
+ }
1117
+
1118
+ // ⭐ A FILE RECORD IS A FOLDER ENTRY OF KIND `file`, NEVER AN ENTITY (`@uniweb/file`, 2026-09-28 —
1119
+ // `./file-records.js`). The file itself is the record; nothing in it is read. Its identity is the
1120
+ // uuid this backend holds its entry by, banked per backend by the file's path (`sync.json` `files`).
1121
+ const files = []
1122
+ const fileMap = opts.backend ? readBackendState(siteRoot, opts.backend).files || {} : {}
1123
+ for (const pooled of poolBySchema.get(FILE_MODEL) || []) {
1124
+ const id = [...pooled.dirs, pooled.slug].join('/')
1125
+ files.push({
1126
+ id,
1127
+ slug: pooled.slug,
1128
+ name: pooled.file,
1129
+ absPath: pooled.absPath,
1130
+ poolPath: pooled.poolPath,
1131
+ uuid: fileMap[pooled.poolPath] ?? null,
1132
+ })
1133
+ producedBy.set(pooled.id, [{ id, slug: pooled.slug }])
1134
+ }
1135
+
1076
1136
  for (const [schema, poolEntities] of poolBySchema) {
1137
+ if (schema === LINK_MODEL || schema === FILE_MODEL) continue
1077
1138
  const label = poolEntities[0].dirs.join('/')
1078
1139
  const modelName = modelFor(schema, `${pool.dir}/${label}/`)
1079
1140
  const declaration = (await declarationFor(modelName)) || (await typedFor(schema))
@@ -1196,9 +1257,10 @@ export async function buildRecordEntities(siteRoot, opts = {}) {
1196
1257
  refusals.push(...mappedOut.refusals)
1197
1258
  }
1198
1259
 
1199
- // Queries whose schema resolved to nothing — not pushed as entities. The composite
1200
- // deploy delivers these statically (the "data ball") instead, so the caller can
1201
- // route them there.
1260
+ // Queries whose schema resolved to nothing — not pushed as entities. Their compiled
1261
+ // data is delivered as static files instead (`site/schemaless-data.js`), so the caller
1262
+ // can route it there; and their declarations are sent naming no Model
1263
+ // (`site.js::queriesNested`).
1202
1264
  //
1203
1265
  // ⛔ Deliberately NOT a `warnings` string. This is a product decision the author
1204
1266
  // is making — entities or static files — and it needs to be reported at a
@@ -1210,9 +1272,10 @@ export async function buildRecordEntities(siteRoot, opts = {}) {
1210
1272
  for (const { name, decl } of queries) {
1211
1273
  const schema = decl.schema || decl.model
1212
1274
  const hasRecords = poolBySchema.has(schema)
1213
- const resolved = hasRecords
1275
+ // A query over link records names a system Model, which resolves whatever the foundation holds.
1276
+ const resolved = schema === LINK_MODEL || schema === FILE_MODEL || (hasRecords
1214
1277
  ? !unresolved.has(schema)
1215
- : Boolean((await declarationFor(modelFor(schema, `query "${name}"`))) || (await typedFor(schema)))
1278
+ : Boolean((await declarationFor(modelFor(schema, `query "${name}"`))) || (await typedFor(schema))))
1216
1279
  if (!resolved) {
1217
1280
  if (decl.schemaExplicit) throw unresolvedExplicit(resolveSelfScope(schema, scope), name)
1218
1281
  schemaless.push({ name, model: resolveSelfScope(schema, scope) })
@@ -1249,13 +1312,17 @@ export async function buildRecordEntities(siteRoot, opts = {}) {
1249
1312
  colConfig,
1250
1313
  folder: { ...folder, nodes },
1251
1314
  recordsDirExists: pool.exists,
1252
- sendFolder: sendsFolder(pool, entities),
1315
+ sendFolder: sendsFolder(pool, entities, [...links, ...files]),
1253
1316
  // Each entity's declaration, by its Model — what walks a record's lists
1254
1317
  // (`record-items.js`).
1255
1318
  declarations: new Map(readSchemas.map(({ declaration }) => [declaration.name, declaration])),
1256
1319
  // A name-defaulted schema that stands for the type of the data key of its name
1257
1320
  // (`@/team` → `@/member`) — see `typedFor` above.
1258
1321
  keyTyped,
1322
+ // The link records, sent as folder entries rather than entities (`./link-records.js`).
1323
+ links,
1324
+ // The file records, likewise (`./file-records.js`).
1325
+ files,
1259
1326
  }
1260
1327
  }
1261
1328
 
@@ -1269,9 +1336,9 @@ export async function buildRecordEntities(siteRoot, opts = {}) {
1269
1336
  // holds only files that are not records (warned). Neither is an author emptying
1270
1337
  // the folder, and sending one would remove the backend's records — including any
1271
1338
  // authored there — for a state nobody chose. Inert instead, like no directory.
1272
- function sendsFolder(pool, entities) {
1339
+ function sendsFolder(pool, entities, links = []) {
1273
1340
  if (!pool.exists) return false
1274
- if (entities.length > 0) return true
1341
+ if (entities.length > 0 || links.length > 0) return true
1275
1342
  return pool.entities.length === 0 && pool.errors.length === 0
1276
1343
  }
1277
1344
 
package/src/uwx/site.js CHANGED
@@ -1037,7 +1037,7 @@ function externalSource(d) {
1037
1037
  return source
1038
1038
  }
1039
1039
 
1040
- function queriesNested(declarations, uuids = null, scope = null, keyTyped = null, queryFields = null) {
1040
+ function queriesNested(declarations, uuids = null, scope = null, keyTyped = null, queryFields = null, schemaless = null) {
1041
1041
  const out = []
1042
1042
  for (const [name, d] of Object.entries(declarations)) {
1043
1043
  refuseUnder(d.where, `queries.${name}`)
@@ -1058,7 +1058,14 @@ function queriesNested(declarations, uuids = null, scope = null, keyTyped = null
1058
1058
  // names that type — the data schema its records are sent as (`data-key-types.js`).
1059
1059
  const typed = (!d.schemaExplicit && keyTyped?.get(d.schema)) || null
1060
1060
  const schema = typed || d.schema
1061
- setIf(data, 'schema', resolveSelfScope(schema, scope))
1061
+ // ⛔ NO MODEL FOR A QUERY WHOSE RECORDS RESOLVED NONE (`schemaless`, decided by the records
1062
+ // lane — `records.js`). Its name-defaulted `schema` names a Model nothing declares, its records
1063
+ // are not pushed, and its compiled data travels as static files — so the declaration goes up
1064
+ // with no `schema`. ⛔ Until 2026-09-28 it named that Model anyway (`videos` → `@std/videos`),
1065
+ // and a store that checks a query's `schema` against the site's declared types refused the
1066
+ // whole content push for it. An explicit `schema:` that does not resolve never reaches here:
1067
+ // the records lane refuses it.
1068
+ if (!schemaless?.has(name)) setIf(data, 'schema', resolveSelfScope(schema, scope))
1062
1069
  // ⭐ …AND SAYS SO, so a pull can tell the author's form (`team:`, no schema) from an explicit
1063
1070
  // one — sent only where the deployment's `queries` Section declares the key (`GET /dev/config`
1064
1071
  // → `siteContent.queryFields`), since one that does not refuses a push carrying it.
@@ -1377,6 +1384,8 @@ function settingsNested(siteYml, { headHtml, themeYml, sourceLocale, translation
1377
1384
  * resolves into (`@/x` → `@scope/x`): the site's foundation's. Defaults to
1378
1385
  * `siteSelfScope`; pass the scope the records are emitted with. With none
1379
1386
  * known, `@/x` ships as written.
1387
+ * @param {string[]} [opts.schemaless] - the queries whose records resolved no data schema
1388
+ * (the records lane's `schemaless`): their declarations name no `schema`.
1380
1389
  * @returns {Promise<object>} the section-keyed `$`-document:
1381
1390
  * `{ $uuid?, $id, $schema, info, settings?, pages, layout_sections, extensions,
1382
1391
  * queries, services?, secrets? }`
@@ -1688,7 +1697,14 @@ export async function siteProjectToDocument(siteRoot, opts = {}) {
1688
1697
  // ⚠️ `queriesNested` keeps its name. §2's rule: rename what an author or a
1689
1698
  // consumer sees, leave the identifier alone.
1690
1699
  const scope = opts.scope !== undefined ? opts.scope : await siteSelfScope(siteRoot)
1691
- doc.queries = queriesNested(colConfig.declarations, opts.queryUuids, scope, opts.keyTyped, opts.queryFields)
1700
+ doc.queries = queriesNested(
1701
+ colConfig.declarations,
1702
+ opts.queryUuids,
1703
+ scope,
1704
+ opts.keyTyped,
1705
+ opts.queryFields,
1706
+ Array.isArray(opts.schemaless) ? new Set(opts.schemaless) : null
1707
+ )
1692
1708
  // Emitted ONLY when the file declares the key — see the header above
1693
1709
  // `serviceRecords`: on a replaced Section, absent and empty are different
1694
1710
  // requests and one of them is destructive.
@@ -22,6 +22,7 @@
22
22
  import { buildRecordEntities, entityContentHash } from './records.js'
23
23
  import { recordItemLists, hasListItems, stampRecordItems } from './record-items.js'
24
24
  import { ASSET_SLOTS } from '@uniweb/semantic-parser'
25
+ import { fileRecordUploads } from './file-records.js'
25
26
  import { buildFolderEntity } from './folder.js'
26
27
  import { siteProjectToDocument } from './site.js'
27
28
  import { siteSelfScope, refuseOrgOption } from './self-scope.js'
@@ -255,7 +256,7 @@ function rewriteEntityAssets(node, map, ids, noStamp = null) {
255
256
  * @param {object} [opts.exporter] @param {string} [opts.exportedAt]
256
257
  * @returns {Promise<{
257
258
  * siteContent: { buffer, entityCount, index, models }|null,
258
- * records: { buffer, entityCount, index, models }|null,
259
+ * records: { buffer, entityCount, index, models, links }|null,
259
260
  * hashes: Object<string,string>, warnings: string[], refusals: string[],
260
261
  * skipped: number, schemaless: Array<{name: string, model: string}>,
261
262
  * localAssets: string[], applied: object, namesNew: string[],
@@ -314,8 +315,16 @@ export async function emitSyncPackages(siteRoot, opts = {}) {
314
315
 
315
316
  // The folder rides over the FULL record set (before filtering) so its references
316
317
  // are complete — new records by `$ref`, already-minted ones by `entry: <uuid>`.
318
+ // ⭐ File records ride the folder as entries too, their value an asset: the push writes an upload
319
+ // key where each URL goes, uploads the files (`localFiles`), and its second emit swaps the keys for
320
+ // serve URLs and stamps the assets' identity beside them, as it does media (`./file-records.js`).
321
+ const { values: fileValues, uploads: localFiles } = await fileRecordUploads({ files: col.files || [], siteRoot })
322
+ const files = (col.files || []).map((f) => ({ ...f, value: fileValues.get(f.poolPath) }))
317
323
  const folder = buildFolderEntity({
318
324
  recordEntities: col.entities,
325
+ // Link records ride the folder as entries of their own kind (`./link-records.js`).
326
+ links: col.links || [],
327
+ files,
319
328
  ...(sourceLocale ? { sourceLocale } : {}),
320
329
  // ⭐ Every record in the directory, at the top of the folder or in the
321
330
  // sub-folder `folder.yml` places it in — and NOTHING when there is no folder to
@@ -359,6 +368,8 @@ export async function emitSyncPackages(siteRoot, opts = {}) {
359
368
  // …and the same data schema, where a name stands for the type of its data key
360
369
  // (`@/team` → `@/member`, `data-key-types.js`): decided once, by the records.
361
370
  keyTyped: col.keyTyped,
371
+ // …and which queries resolved NO data schema — their declarations go up naming none.
372
+ schemaless: col.schemaless.map((q) => q.name),
362
373
  // …and whether this deployment takes the key that says so (`siteContent.queryFields`) —
363
374
  // and the page and `settings` keys that carry Open Graph texts per language.
364
375
  ...(Array.isArray(opts.queryFields) ? { queryFields: opts.queryFields } : {}),
@@ -447,6 +458,8 @@ export async function emitSyncPackages(siteRoot, opts = {}) {
447
458
  // recognizes on pull by fingerprint (asset-map.js → `servedFingerprint`).
448
459
  if (siteDoc) rewriteEntityAssets(siteDoc, assetRewrite, assetIds, siteDoc.info)
449
460
  for (const e of col.entities) rewriteEntityAssets(e.document, assetRewrite, assetIds)
461
+ // The folder too: a file entry's value names its asset by an upload key until here.
462
+ if (folder) rewriteEntityAssets(folder.document, assetRewrite, assetIds)
450
463
  }
451
464
  // Collect the site-root local refs the deploy must upload (`/images/x.png`).
452
465
  // Co-located refs (`./x`, `../x`) need the source `.md` location to resolve — the
@@ -560,7 +573,9 @@ export async function emitSyncPackages(siteRoot, opts = {}) {
560
573
  // filtered out here by send-only-changed. Declare them all (the backend rejects a
561
574
  // folder that references an undeclared Model).
562
575
  const referencedModels = [...collectReferencedModels(folder.document, new Set())]
563
- records = { ...emitLane(entities, exporter, exportedAt, referencedModels), index }
576
+ // `links` rides beside the index: the caller banks each link's uuid from the folder the
577
+ // backend returns (`link-records.js::backfillLinkUuids`).
578
+ records = { ...emitLane(entities, exporter, exportedAt, referencedModels), index, links: col.links || [], files: col.files || [] }
564
579
  }
565
580
 
566
581
  // --- site-content lane -------------------------------------------------------
@@ -607,6 +622,9 @@ export async function emitSyncPackages(siteRoot, opts = {}) {
607
622
  namesNew,
608
623
  refusals,
609
624
  schemaless: col.schemaless, localAssets, applied,
625
+ // The files a push uploads for its file records, each `{ ref, path, contentType }` — the file, and
626
+ // a PDF's preview. Uploaded through the media lane; `ref` is the key the second emit rewrites.
627
+ localFiles,
610
628
  // { stamped, unknown } when identity was applied; null when the caller passed
611
629
  // no map. `unknown > 0` with `stamped === 0` on a site that has been pushed
612
630
  // before is the index-loss signature the backend refuses — the caller reports it.
@@ -48,7 +48,7 @@ const VERSION = 1
48
48
  * `recordItems` is keyed by a record's uuid here, each entry that record's list items
49
49
  * (`record-items.js`) — replaced whole, one record at a time.
50
50
  */
51
- const MAP_SECTIONS = new Set(['records', 'items', 'queries', 'folders', 'assets', 'recordItems'])
51
+ const MAP_SECTIONS = new Set(['records', 'items', 'queries', 'folders', 'assets', 'recordItems', 'files'])
52
52
 
53
53
  const storePath = (siteDir) => join(siteDir, SYNC_STORE_FILE)
54
54
 
@@ -365,6 +365,14 @@ async function validateRecordFiles(siteRoot, { srcDir, dataSchemas, keyTypes = n
365
365
  names.set(ref, known)
366
366
  if (ref !== folderRef) names.set(folderRef, known)
367
367
  for (const pooled of entities) {
368
+ // ⭐ A FILE RECORD'S FILE IS ITS VALUE, never its data (`site/file-records.js`): nothing in it
369
+ // is read, so there is nothing to check — only its name, which a reference may give.
370
+ // ⛔ Until 2026-09-28 its bytes were parsed as YAML here, and every push of a site holding a
371
+ // PDF warned "directives end mark is expected".
372
+ if (pooled.fileRecord) {
373
+ known.add(String(pooled.slug))
374
+ continue
375
+ }
368
376
  let records
369
377
  try {
370
378
  records = await readEntityFile(pooled.absPath)