@uniweb/build 0.29.0 → 0.30.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.
Files changed (41) hide show
  1. package/package.json +7 -7
  2. package/src/content/index.js +6 -6
  3. package/src/dev-backend.js +31 -31
  4. package/src/i18n/freeform.js +44 -24
  5. package/src/i18n/index.js +22 -22
  6. package/src/i18n/{collections.js → records.js} +114 -51
  7. package/src/i18n/sync.js +9 -8
  8. package/src/site/build-site-data.js +9 -12
  9. package/src/site/config.js +1 -1
  10. package/src/site/content-collector.js +25 -40
  11. package/src/site/data-fetcher.js +23 -10
  12. package/src/site/entity-pool.js +211 -0
  13. package/src/site/fetch-shapes.js +71 -0
  14. package/src/site/foundation-ref.js +1 -1
  15. package/src/site/index.js +4 -4
  16. package/src/site/plugin.js +58 -63
  17. package/src/site/queries-config.js +324 -0
  18. package/src/site/{collection-processor.js → query-processor.js} +180 -95
  19. package/src/site/records-config.js +299 -0
  20. package/src/site/schemaless-data.js +2 -2
  21. package/src/utils/numeric-prefix.js +63 -0
  22. package/src/uwx/backfill.js +5 -5
  23. package/src/uwx/data-schema.js +2 -2
  24. package/src/uwx/entity-source.js +122 -0
  25. package/src/uwx/folder.js +85 -77
  26. package/src/uwx/index.js +33 -12
  27. package/src/uwx/locale-sync.js +2 -2
  28. package/src/uwx/project-writer.js +36 -10
  29. package/src/uwx/queries-config.js +11 -0
  30. package/src/uwx/records-project.js +535 -0
  31. package/src/uwx/{collections.js → records.js} +152 -69
  32. package/src/uwx/site-diff.js +24 -1
  33. package/src/uwx/site-project.js +9 -6
  34. package/src/uwx/site.js +179 -23
  35. package/src/uwx/sync-package.js +37 -16
  36. package/src/validate-data.js +17 -19
  37. package/src/site/collections-config.js +0 -260
  38. package/src/uwx/collection-source.js +0 -180
  39. package/src/uwx/collections-config.js +0 -9
  40. package/src/uwx/collections-project.js +0 -335
  41. /package/src/search/{collections.js → records-index.js} +0 -0
@@ -44,15 +44,22 @@ import yaml from 'js-yaml'
44
44
  import { detectFoundationType } from '../site/foundation-ref.js'
45
45
  import { join, resolve } from 'node:path'
46
46
 
47
- import { resolveCollectionsConfig } from './collections-config.js'
48
- import { readCollectionRecords } from './collection-source.js'
47
+ import { resolveQueriesConfig } from './queries-config.js'
48
+ import { readEntityFile } from './entity-source.js'
49
+ import { readRecordsConfig, resolveFolder, RECORDS_YML_RELPATH } from '../site/records-config.js'
50
+ import {
51
+ readEntityPool,
52
+ groupPoolBySchema,
53
+ poolPathReadings,
54
+ poolDirsForSchema,
55
+ } from '../site/entity-pool.js'
49
56
  import { toDataSchemaDeclaration, isProseMirrorField, isMarkupTextField, isContentBodyField } from './data-schema.js'
50
57
  import { emitEntitySyncPackage } from './entity-document.js'
51
58
  import { sha256Hex, toJsonBuffer } from './manifest.js'
52
59
  import { markdownToProseMirror } from '@uniweb/content-reader'
53
60
  import { LOCALIZED_FIELD_ASSUMPTION, localize } from './localize.js'
54
61
  import { localizeScalar, localizeContentDoc, loadLocaleTranslations, discoverLocales, discoverFreeformLocales, localesDir, isLocalizedContent } from './locale-sync.js'
55
- import { loadFreeformCollectionItem } from '../i18n/freeform.js'
62
+ import { loadFreeformRecord } from '../i18n/freeform.js'
56
63
 
57
64
  const DATE_KINDS = new Set(['date', 'datetime'])
58
65
  // Identity/transport keys on a source record — never Model fields, never warned.
@@ -139,7 +146,7 @@ function encodeFieldValue(value, field, sourceLocale, translations) {
139
146
  if (isProseMirrorField(field)) {
140
147
  // markdown source → ProseMirror doc. When localized, wrap per-locale exactly
141
148
  // like a page section's content (source doc + target structural maps) — same
142
- // path, flushed to locales/collections/{locale}.json by the caller.
149
+ // path, flushed to locales/records/{locale}.json by the caller.
143
150
  const doc = typeof value === 'string' ? markdownToProseMirror(value) : value
144
151
  if (!field.localized) return doc
145
152
  const localized = localizeContentDoc(doc, sourceLocale, Object.keys(translations || {}), translations)
@@ -153,7 +160,7 @@ function encodeFieldValue(value, field, sourceLocale, translations) {
153
160
  // A markup `text` BODY (format markdown|html) rides as a RAW string, wrapped
154
161
  // per-locale wholesale (its per-string translations live in the i18n manifest /
155
162
  // free-form, not the scalar map). Other localized scalars wrap per-string from
156
- // locales/collections/{locale}.json.
163
+ // locales/records/{locale}.json.
157
164
  return isMarkupTextField(field)
158
165
  ? localize(value, sourceLocale)
159
166
  : localizeScalar(value, sourceLocale, translations)
@@ -174,7 +181,7 @@ function encodeFieldValue(value, field, sourceLocale, translations) {
174
181
  * already carries `$uuid` (back-filled from a prior sync) round-trips it.
175
182
  *
176
183
  * @param {object} params
177
- * @param {string} params.collectionName - the site.yml collection name
184
+ * @param {string} params.queryName - the site.yml collection name
178
185
  * @param {object[]} params.records - [{ slug, ...fields }]
179
186
  * @param {object} params.declaration - the `@uniweb/data-schema` declaration
180
187
  * (from toDataSchemaDeclaration): `{ name, brief, sections }`
@@ -184,15 +191,15 @@ function encodeFieldValue(value, field, sourceLocale, translations) {
184
191
  * @returns {{ entities: object[], warnings: string[] }} each entity is
185
192
  * `{ id, uuid, model, file, document }` — `document` is the section-keyed body.
186
193
  */
187
- export function collectionRecordsToEntities({
188
- collectionName,
194
+ export function recordsToEntities({
195
+ queryName,
189
196
  records,
190
197
  declaration,
191
198
  sourceLocale = LOCALIZED_FIELD_ASSUMPTION.defaultSourceLocale,
192
199
  translations,
193
200
  }) {
194
201
  if (!declaration || !declaration.name) {
195
- throw new Error('uwx/collections: a declaration with a name is required')
202
+ throw new Error('uwx/records: a declaration with a name is required')
196
203
  }
197
204
  // A record (one source file) maps to the Model's SINGLE sections in declared
198
205
  // order — the brief (the card) plus any sibling single sections, e.g. a body
@@ -205,7 +212,7 @@ export function collectionRecordsToEntities({
205
212
  const briefEntry = sectionEntries.find(([, s]) => s && s.brief === true)
206
213
  const briefName = briefEntry?.[0]
207
214
  if (!briefName) {
208
- throw new Error(`uwx/collections: Model ${declaration.name} has no brief section`)
215
+ throw new Error(`uwx/records: Model ${declaration.name} has no brief section`)
209
216
  }
210
217
  // The single sections one record can populate (the brief + sibling singles).
211
218
  //
@@ -254,7 +261,7 @@ export function collectionRecordsToEntities({
254
261
  const warnings = []
255
262
  if (contentMatches.length > 1) {
256
263
  warnings.push(
257
- `${collectionName}: ${declaration.name} has more than one content ` +
264
+ `${queryName}: ${declaration.name} has more than one content ` +
258
265
  `(markdown / html / prosemirror) field — the markdown body maps to ` +
259
266
  `"${bodyTarget.secName}.${bodyTarget.key}"`
260
267
  )
@@ -262,13 +269,13 @@ export function collectionRecordsToEntities({
262
269
  for (const record of records || []) {
263
270
  const slug = record.slug
264
271
  if (!slug) {
265
- warnings.push(`${collectionName}: a record without a slug was skipped`)
272
+ warnings.push(`${queryName}: a record without a slug was skipped`)
266
273
  continue
267
274
  }
268
275
  // `$id` is the payload-local handle = the record's path under collections/
269
276
  // (`<collection>/<slug>`), globally unique within one sync so the @uniweb/folder
270
277
  // entity can point a leaf at it via `$ref`. An explicit frontmatter `$id` wins.
271
- const id = record.$id || `${collectionName}/${slug}`
278
+ const id = record.$id || `${queryName}/${slug}`
272
279
  const uuid = record.$uuid || null
273
280
  const hasBody = typeof record.$body === 'string' && record.$body.trim() !== ''
274
281
 
@@ -297,13 +304,13 @@ export function collectionRecordsToEntities({
297
304
  for (const key of Object.keys(record)) {
298
305
  if (SKIP_KEYS.has(key) || fieldByKey.has(key)) continue
299
306
  warnings.push(
300
- `${collectionName}/${slug}: field "${key}" is not on ` +
307
+ `${queryName}/${slug}: field "${key}" is not on ` +
301
308
  `${declaration.name} — not synced`
302
309
  )
303
310
  }
304
311
  if (hasBody && !bodyTarget) {
305
312
  warnings.push(
306
- `${collectionName}/${slug}: markdown body present but ` +
313
+ `${queryName}/${slug}: markdown body present but ` +
307
314
  `${declaration.name} has no content body field — body not synced`
308
315
  )
309
316
  }
@@ -324,9 +331,8 @@ export function collectionRecordsToEntities({
324
331
  id,
325
332
  uuid,
326
333
  slug,
327
- collection: collectionName, // the @uniweb/folder groups leaves by this
328
334
  model: declaration.name, // reference the Model BY NAME — importer resolves it
329
- file: `entities/${collectionName}/${slug}.json`,
335
+ file: `entities/${queryName}/${slug}.json`,
330
336
  document,
331
337
  })
332
338
  }
@@ -334,14 +340,14 @@ export function collectionRecordsToEntities({
334
340
  }
335
341
 
336
342
  // Post-pass: override a collection record's localized CONTENT body with a per-locale
337
- // FREE-FORM body when `locales/freeform/{locale}/collections/<col>/<slug>.md` exists
343
+ // FREE-FORM body when `locales/freeform/{locale}/entities/<schema>/<slug>.md` exists
338
344
  // — the override wins over the structural map, exactly like site-content sections
339
345
  // (site.js localizeContentTree). Only a `format: prosemirror` localized field can
340
346
  // take it (it is a PM doc on the wire; a markup `text` body stays a raw string).
341
347
  // Mutates the entity documents in place. Async — the free-form read hits the disk.
342
- async function applyFreeformCollectionOverrides({
348
+ async function applyFreeformRecordOverrides({
343
349
  entities,
344
- collectionName,
350
+ queryName,
345
351
  declaration,
346
352
  sourceLocale,
347
353
  targetLocales,
@@ -362,8 +368,8 @@ async function applyFreeformCollectionOverrides({
362
368
  // still a bare doc because no structural translation was present).
363
369
  const sourceDoc = isLocalizedContent(localized) ? localized[sourceLocale] : localized
364
370
  for (const locale of targetLocales) {
365
- // loadFreeformCollectionItem returns { content, frontmatter, … } — doc is `.content`.
366
- const body = (await loadFreeformCollectionItem({ slug: entity.slug }, collectionName, locale, localesBase))?.content
371
+ // loadFreeformRecord returns { content, frontmatter, … } — doc is `.content`.
372
+ const body = (await loadFreeformRecord({ slug: entity.slug }, entity.model, locale, localesBase))?.content
367
373
  if (!body) continue
368
374
  if (!isLocalizedContent(localized)) localized = { [sourceLocale]: sourceDoc }
369
375
  localized[locale] = body // free-form full body overrides the structural map
@@ -377,9 +383,9 @@ async function applyFreeformCollectionOverrides({
377
383
  // The collections in site.yml that opt into export (an object decl with `model:`).
378
384
  // The declared collections that opt into sync: a resolvable data schema present
379
385
  // (explicit or convention-defaulted) and not opted out (`sync: false`). Takes the
380
- // merged declarations from resolveCollectionsConfig (collections.yml over
386
+ // merged declarations from resolveQueriesConfig (collections.yml over
381
387
  // site.yml::collections), so collections.yml is honored without re-reading.
382
- function syncableCollections(declarations) {
388
+ function syncableQueries(declarations) {
383
389
  const out = []
384
390
  for (const decl of Object.values(declarations)) {
385
391
  if ((decl.schema || decl.model) && decl.sync !== false) out.push({ name: decl.name, decl })
@@ -426,7 +432,7 @@ function loadLocalFoundationSchema(siteRoot, opts, { required }) {
426
432
  if (!foundationDir) {
427
433
  if (required) {
428
434
  throw new Error(
429
- 'uwx/collections: could not locate a local foundation. Pass foundationDir, ' +
435
+ 'uwx/records: could not locate a local foundation. Pass foundationDir, ' +
430
436
  'use a `file:` foundation dependency, or run via `uniweb sync` so non-local ' +
431
437
  'Models resolve from the registry.'
432
438
  )
@@ -437,7 +443,7 @@ function loadLocalFoundationSchema(siteRoot, opts, { required }) {
437
443
  if (!existsSync(schemaPath)) {
438
444
  if (required) {
439
445
  throw new Error(
440
- `uwx/collections: ${schemaPath} not found — build the foundation first ` +
446
+ `uwx/records: ${schemaPath} not found — build the foundation first ` +
441
447
  '(`uniweb build`).'
442
448
  )
443
449
  }
@@ -467,15 +473,28 @@ function resolveDeclaration(schema, modelName) {
467
473
  return null
468
474
  }
469
475
 
470
- // Load a collection's ORIGINAL source records for export — the author's files,
476
+ // Load a query's ORIGINAL source records for export — the author's files,
471
477
  // untouched (raw frontmatter + raw markdown body, raw YAML/JSON, raw BibTeX). This
472
- // is deliberately NOT `processCollections` (the delivery pipeline that builds
478
+ // is deliberately NOT `processQueries` (the delivery pipeline that builds
473
479
  // public/data, converts bodies to ProseMirror, and copies assets). Sync carries
474
- // the source. Only file-based (`path:`) collections export; remote (`url:`) data
475
- // is not a local file collection.
476
- async function loadSourceRecords(siteRoot, decl) {
477
- if (!decl.path) return null // not file-based caller warns + skips
478
- return readCollectionRecords(resolve(siteRoot, decl.path))
480
+ // the source.
481
+ //
482
+ // THE QUERY NAMES A SCHEMA AND THE POOL FOLLOWS. It does not name a directory,
483
+ // and there is no disk path for it to name: `entities/{schema}/` declares the
484
+ // model, so the entities of a schema ARE its records. That is the de-conflation —
485
+ // `collections/<name>/` used to answer "which files", "which schema" and "grouped
486
+ // how" with one directory, and only the first two were ever the same question.
487
+ //
488
+ // Remote (`url:`) queries have no local files; the caller warns and skips.
489
+ function loadSourceRecordsFromPool(poolBySchema, decl, placements) {
490
+ if (!decl.schema) return null
491
+ const entities = poolBySchema.get(decl.schema)
492
+ if (!entities) return null
493
+ // ⛔ ONLY WHAT `records.yml` REFERENCES. An entity of the right schema that no
494
+ // entry places is not a record, so syncing it would create something nobody can
495
+ // reach — and would make the payload disagree with the folder describing it.
496
+ const placed = entities.filter((e) => placements.has(e.id))
497
+ return placed.length ? placed : null
479
498
  }
480
499
 
481
500
  /**
@@ -500,15 +519,36 @@ async function loadSourceRecords(siteRoot, decl) {
500
519
  * default soft-skip) — not synced as entities; the composite deploy delivers
501
520
  * them statically via the data ball.
502
521
  */
503
- export async function buildCollectionEntities(siteRoot, opts = {}) {
522
+ export async function buildRecordEntities(siteRoot, opts = {}) {
504
523
  // Merged collections config (collections.yml over site.yml::collections). Reused
505
524
  // from the caller when provided (sync-package shares it with the folder builder).
506
- const colConfig = opts.collectionsConfig || (await resolveCollectionsConfig(siteRoot))
525
+ // `records.yml` IS THE FOLDER, AND IT DECIDES WHAT SYNCS. Listing an entity
526
+ // is what makes it a record; an entity nothing references exists but cannot be
527
+ // publicly fetched — so it is a draft, for free, with no flag to set. This is
528
+ // why `collections.yml::sync` is deleted rather than ported: "do not sync" is
529
+ // now "reference nothing", which is the actual round trip.
530
+ //
531
+ // ⛔ AND `missing` IS NOT `empty`. Missing means do not sync at all and leave
532
+ // the server's folder untouched; empty means sync an empty folder, REMOVING
533
+ // what is there. The safe state is the absence of a file, so a live folder
534
+ // cannot be wiped by deleting one — the destructive act requires affirmatively
535
+ // creating one, and the CLI asks before it happens.
536
+ //
537
+ // ⚠️ READ FIRST, ABOVE EVERY EARLY RETURN. `recordsState` has to ride out of
538
+ // this function on every path, because its ABSENCE reads as "not missing" to a
539
+ // caller — measured: a site with no queries returned no state, the folder
540
+ // builder took that for `declared`, and a site with no `records.yml` at all
541
+ // emitted an empty folder that would have removed everything.
542
+ const recordsCfg = await readRecordsConfig(siteRoot)
543
+ if (recordsCfg.error) throw new Error(`uwx/records: ${recordsCfg.error}`)
544
+ const recordsState = recordsCfg.state
545
+
546
+ const colConfig = opts.queriesConfig || (await resolveQueriesConfig(siteRoot))
507
547
  if (!colConfig.folderSync) {
508
- return { entities: [], index: [], warnings: [], schemaless: [], mappedCount: 0, colConfig }
548
+ return { entities: [], index: [], warnings: [], schemaless: [], mappedCount: 0, colConfig, recordsState, folder: null }
509
549
  }
510
- const mapped = syncableCollections(colConfig.declarations)
511
- if (mapped.length === 0) return { entities: [], index: [], warnings: [], schemaless: [], mappedCount: 0, colConfig }
550
+ const mapped = syncableQueries(colConfig.declarations)
551
+ if (mapped.length === 0) return { entities: [], index: [], warnings: [], schemaless: [], mappedCount: 0, colConfig, recordsState, folder: null }
512
552
 
513
553
  // A Model declaration comes from a LOCAL foundation (offline) or, for a
514
554
  // non-local Model, from the injected async `resolveModel(name)` — the verb wires
@@ -538,19 +578,31 @@ export async function buildCollectionEntities(siteRoot, opts = {}) {
538
578
  opts.sourceLocale || LOCALIZED_FIELD_ASSUMPTION.defaultSourceLocale
539
579
 
540
580
  // Target locales for wrapping localized record fields per-locale: those with a
541
- // structural-translation file (locales/collections/{locale}.json) UNIONED with
581
+ // structural-translation file (locales/records/{locale}.json) UNIONED with
542
582
  // those that only have a free-form override dir (locales/freeform/{locale}/) — a
543
583
  // record localized solely by a free-form body would otherwise go undiscovered.
544
584
  const targetLocales = [
545
- ...new Set([...discoverLocales(siteRoot, 'collections'), ...discoverFreeformLocales(siteRoot)]),
585
+ ...new Set([...discoverLocales(siteRoot, 'records'), ...discoverFreeformLocales(siteRoot)]),
546
586
  ].filter((l) => l !== sourceLocale)
547
587
  const translations =
548
- targetLocales.length > 0 ? loadLocaleTranslations(siteRoot, targetLocales, 'collections') : null
588
+ targetLocales.length > 0 ? loadLocaleTranslations(siteRoot, targetLocales, 'records') : null
549
589
 
550
590
  const entities = []
551
591
  const index = []
552
592
  const warnings = []
553
593
 
594
+ // ⭐ THE POOL, READ ONCE. A query names a `schema:` and the entities of that
595
+ // schema are its records — so the pool is walked once here rather than a
596
+ // directory per declaration, and two queries over one schema read one set of
597
+ // files instead of two.
598
+ const pool = await readEntityPool(siteRoot)
599
+ const poolBySchema = groupPoolBySchema(pool.entities)
600
+
601
+ const folder = resolveFolder(recordsCfg.entries, pool.entities)
602
+ if (folder.errors.length) {
603
+ throw new Error(`uwx/records: ${RECORDS_YML_RELPATH} is invalid —\n ${folder.errors.join('\n ')}`)
604
+ }
605
+
554
606
  // ⛔ `@/x` IS A FOUNDATION-RELATIVE ALIAS AND MUST BE RESOLVED BEFORE IT SHIPS.
555
607
  //
556
608
  // `register` resolves it (`uwx/registry-package.js` builds `scoped` from the
@@ -590,7 +642,7 @@ export async function buildCollectionEntities(siteRoot, opts = {}) {
590
642
  // the backend's refusal names a missing Model and cannot name this cause.
591
643
  if (modelName === declaredModel && typeof declaredModel === 'string' && declaredModel.startsWith('@/')) {
592
644
  warnings.push(
593
- `collection "${name}": \`${declaredModel}\` is foundation-relative and no org is known, ` +
645
+ `query "${name}": \`${declaredModel}\` is foundation-relative and no org is known, ` +
594
646
  `so it ships unresolved. The backend resolves Models by name and will refuse it. ` +
595
647
  `Pass \`--org @handle\`, or push once so the site records its org.`
596
648
  )
@@ -613,19 +665,36 @@ export async function buildCollectionEntities(siteRoot, opts = {}) {
613
665
  schemaless.push({ name, model: modelName })
614
666
  continue
615
667
  }
668
+ // ⚠️ NAME BOTH READINGS OF A DEPTH-2 POOL PATH. `entities/person/2024/ada.md`
669
+ // resolves as `@person/2024` — the rule is total, so it is not ambiguous —
670
+ // but an author who meant "records organised by year inside the `person`
671
+ // schema" needs to be told what the build actually read, not only that
672
+ // something failed to resolve. The wrong reading is the plausible one.
673
+ const dirs = poolDirsForSchema(modelName)
674
+ const { alternative } = dirs ? poolPathReadings(dirs) : { alternative: null }
616
675
  throw new Error(
617
- `uwx/collections: Model "${modelName}" (collection "${name}") could not be ` +
676
+ `uwx/records: Model "${modelName}" (query "${name}") could not be ` +
618
677
  'resolved — not defined by a local foundation' +
619
678
  (resolveModel
620
679
  ? ', and the backend has no such Model (register it first).'
621
680
  : '. Run via `uniweb sync` (which fetches non-local Models from the ' +
622
- 'registry), or provide a local foundation that defines it.')
681
+ 'registry), or provide a local foundation that defines it.') +
682
+ (alternative
683
+ ? ` If you meant \`entities/${dirs[0]}/\` (${alternative}) organised by ` +
684
+ `\`${dirs[1]}\`, note that a folder inside a schema folder is read as an ` +
685
+ `org scope. Organise records in records.yml, not on disk.`
686
+ : '')
623
687
  )
624
688
  }
625
- const sourceRecords = await loadSourceRecords(siteRoot, decl)
626
- if (sourceRecords == null) {
689
+ const poolEntities = loadSourceRecordsFromPool(poolBySchema, decl, folder.placements)
690
+ if (poolEntities == null) {
691
+ // No entities of this schema on disk. A remote (`url:`) query has none by
692
+ // definition; a file-based one with an empty pool is an author state worth
693
+ // naming, because "nothing synced" and "nothing there" look identical.
627
694
  warnings.push(
628
- `${name}: not a file-based (\`path:\`) collection — skipped`
695
+ decl.url
696
+ ? `${name}: a remote (\`url:\`) query has no local entities — skipped`
697
+ : `${name}: no entities of ${decl.schema} in the pool — nothing to sync`
629
698
  )
630
699
  continue
631
700
  }
@@ -636,19 +705,30 @@ export async function buildCollectionEntities(siteRoot, opts = {}) {
636
705
  // deferred (no single-record file to rewrite in place).
637
706
  const flat = []
638
707
  const sourceBySlug = new Map()
639
- for (const r of sourceRecords) {
640
- if (!r.slug) {
641
- warnings.push(`${name}: a record without a slug was skipped`)
642
- continue
708
+ for (const pooled of poolEntities) {
709
+ for (const r of await readEntityFile(pooled.absPath)) {
710
+ if (!r.slug) {
711
+ warnings.push(`${name}: a record without a slug was skipped`)
712
+ continue
713
+ }
714
+ const rec = { ...r.data, slug: r.slug }
715
+ if (r.body !== undefined) rec.$body = r.body
716
+ // ⭐ THE RECORD'S IDENTITY IS ITS POOL POSITION, not `<query>/<slug>`. It
717
+ // has to be: the folder places entities and must reference the very ones
718
+ // the payload carries, and two queries over one schema would otherwise
719
+ // mint two identities for one file. `<dirs>/<slug>` is unique by
720
+ // construction and derivable on both sides.
721
+ //
722
+ // ⚠️ A multi-record file (array YAML, BibTeX) contributes several records
723
+ // from one path, so the slug — not the file stem — completes the id.
724
+ rec.$id = rec.$id || [...pooled.dirs, r.slug].join('/')
725
+ flat.push(rec)
726
+ sourceBySlug.set(r.slug, r)
643
727
  }
644
- const rec = { ...r.data, slug: r.slug }
645
- if (r.body !== undefined) rec.$body = r.body
646
- flat.push(rec)
647
- sourceBySlug.set(r.slug, r)
648
728
  }
649
729
 
650
- const mappedOut = collectionRecordsToEntities({
651
- collectionName: name,
730
+ const mappedOut = recordsToEntities({
731
+ queryName: name,
652
732
  records: flat,
653
733
  declaration,
654
734
  sourceLocale,
@@ -657,9 +737,9 @@ export async function buildCollectionEntities(siteRoot, opts = {}) {
657
737
  // Free-form per-locale body overrides (a full localized doc beats the structural
658
738
  // map) — only meaningful for a multi-locale site with a prosemirror content field.
659
739
  if (targetLocales.length > 0) {
660
- await applyFreeformCollectionOverrides({
740
+ await applyFreeformRecordOverrides({
661
741
  entities: mappedOut.entities,
662
- collectionName: name,
742
+ queryName: name,
663
743
  declaration,
664
744
  sourceLocale,
665
745
  targetLocales,
@@ -670,8 +750,8 @@ export async function buildCollectionEntities(siteRoot, opts = {}) {
670
750
  const dupKey = `${e.model} ${e.id}`
671
751
  if (seen.has(dupKey)) {
672
752
  throw new Error(
673
- `uwx/collections: duplicate ($model, $id) in one sync — "${e.id}" of ` +
674
- `${e.model} appears in more than one collection. Each ($model, $id) ` +
753
+ `uwx/records: duplicate ($model, $id) in one sync — "${e.id}" of ` +
754
+ `${e.model} appears in more than one query. Each ($model, $id) ` +
675
755
  'must be unique within a sync; make the slugs unique.'
676
756
  )
677
757
  }
@@ -699,7 +779,7 @@ export async function buildCollectionEntities(siteRoot, opts = {}) {
699
779
  warnings.push(...mappedOut.warnings)
700
780
  }
701
781
 
702
- return { entities, index, warnings, schemaless, mappedCount: mapped.length, colConfig }
782
+ return { entities, index, warnings, schemaless, mappedCount: mapped.length, colConfig, folder, recordsState }
703
783
  }
704
784
 
705
785
  /**
@@ -734,28 +814,31 @@ export function filterChanged(entities, index, { priorHashes = {}, sendAll = fal
734
814
 
735
815
  /**
736
816
  * Build a collection-only sync package. Thin composition over
737
- * `buildCollectionEntities` + `filterChanged` + `emitEntitySyncPackage`, kept for
817
+ * `buildRecordEntities` + `filterChanged` + `emitEntitySyncPackage`, kept for
738
818
  * the collection-only callers/tests. The combined site+collections path is
739
819
  * `emitSyncPackage` (sync-package.js).
740
820
  *
741
821
  * @param {string} siteRoot
742
- * @param {object} [opts] - buildCollectionEntities opts, plus `priorHashes`,
822
+ * @param {object} [opts] - buildRecordEntities opts, plus `priorHashes`,
743
823
  * `sendAll`, `exporter`, `exportedAt`.
744
824
  * @returns {Promise<{ buffer: Buffer|null, models: string[], entityCount: number,
745
825
  * warnings: string[], index: object[], hashes: Object<string,string>,
746
826
  * skipped: number }>}
747
827
  */
748
- export async function emitCollectionSyncPackage(siteRoot, opts = {}) {
749
- const { entities, index, warnings, mappedCount } = await buildCollectionEntities(siteRoot, opts)
828
+ export async function emitRecordSyncPackage(siteRoot, opts = {}) {
829
+ const { entities, index, warnings, mappedCount } = await buildRecordEntities(siteRoot, opts)
750
830
  if (mappedCount === 0) {
751
831
  throw new Error(
752
- 'uwx/collections: no collection declares `model:` — nothing to export. ' +
753
- 'Add `model: "@org/name"` to a collection in site.yml.'
832
+ 'uwx/records: no query declares a schema — nothing to export. ' +
833
+ 'Add a query to queries.yml naming the schema its records use, e.g.\n' +
834
+ " articles:\n schema: '@/article'"
754
835
  )
755
836
  }
756
837
  if (entities.length === 0) {
757
838
  throw new Error(
758
- 'uwx/collections: no records to export (mapped collections were empty or skipped)'
839
+ 'uwx/records: no records to export. Either records.yml references ' +
840
+ 'nothing, or every query matched an empty pool — an entity is only a ' +
841
+ 'record once records.yml lists it.'
759
842
  )
760
843
  }
761
844
 
@@ -46,7 +46,7 @@
46
46
  // Either base may be missing; the affected side degrades to "unknown" and is
47
47
  // reported as unattributed rather than guessed at.
48
48
 
49
- import { entityContentHash } from './collections.js'
49
+ import { entityContentHash } from './records.js'
50
50
  import { recordStableId, safeStableIdFilename, pageDirName } from './site-project.js'
51
51
  import { LOCALIZED_FIELD_ASSUMPTION } from './localize.js'
52
52
 
@@ -292,3 +292,26 @@ export function describeSiteDiff(diff, { limit = 8 } = {}) {
292
292
  }
293
293
  return lines
294
294
  }
295
+
296
+
297
+ /**
298
+ * Query-declaration identity from a document the BACKEND produced (a pull, or a
299
+ * push response's `finalized[].document`): `{ <query name>: <$uuid> }`.
300
+ *
301
+ * ⛔ The sibling of collectFolderItemUuids, and it exists for the same reason: these
302
+ * items have no file of their own, so nothing in a path-keyed map can hold their
303
+ * identity, and a push that omits it re-sends the whole section uuid-less. The
304
+ * backend refuses that rather than deleting every stored row — see queriesNested.
305
+ *
306
+ * Keyed by `name`, which the backend enforces unique within the section. `$id` is
307
+ * deliberately not consulted: it is a payload-local handle the backend never stores.
308
+ */
309
+ export function collectQueryUuids(doc) {
310
+ const out = {}
311
+ for (const item of doc?.queries || []) {
312
+ const name = item?.name
313
+ const uuid = item?.$uuid
314
+ if (typeof name === 'string' && typeof uuid === 'string' && name && uuid) out[name] = uuid
315
+ }
316
+ return out
317
+ }
@@ -38,7 +38,8 @@ import { readFileSync, existsSync, unlinkSync, renameSync, rmSync, readdirSync,
38
38
  import { createHash } from 'node:crypto'
39
39
  import yaml from 'js-yaml'
40
40
  import { writeSiteConfig, writeThemeFile, writeIfChanged, writeSectionFile, writeMergedYaml } from './project-writer.js'
41
- import { declarationsToCollectionsYml } from './collections-project.js'
41
+ import { declarationsToQueriesYml } from './records-project.js'
42
+ import { authorableFetch } from '../site/fetch-shapes.js'
42
43
  import { createTranslationCollector, writeLocaleTranslations, writeFreeformTranslations, unwrapLocalizedContent } from './locale-sync.js'
43
44
  import { buildFreeformPath } from '../i18n/freeform.js'
44
45
  import { unwrapLocalized, unwrapLocalizedList } from './backfill.js'
@@ -303,7 +304,8 @@ export function sectionRecordToFile({ filePath, record, sourceLocale = LOCALIZED
303
304
  if (theme_override !== undefined) frontmatter.theme = theme_override
304
305
  if (preset !== undefined) frontmatter.preset = preset
305
306
  if (input !== undefined) frontmatter.input = input
306
- if (fetch !== undefined) frontmatter.fetch = fetch
307
+ // Invert the build's resolution rather than copy it — see fetch-shapes.js.
308
+ if (fetch !== undefined) frontmatter.fetch = authorableFetch(fetch)
307
309
  if (stable_id !== undefined) frontmatter.id = stable_id
308
310
 
309
311
  const body = insets ? reinlineInsets(sourceContent, insets) : sourceContent
@@ -424,7 +426,8 @@ function pageRecordToYml(record, sectionsArray, sourceLocale) {
424
426
  if (record.rewrite !== undefined) y.rewrite = record.rewrite
425
427
  if (record.layout !== undefined) y.layout = record.layout
426
428
  if (record.seo !== undefined) y.seo = record.seo
427
- if (record.fetch !== undefined) y.fetch = record.fetch
429
+ // Invert the build's resolution rather than copy it — see fetch-shapes.js.
430
+ if (record.fetch !== undefined) y.fetch = authorableFetch(record.fetch)
428
431
  // `sections:` exists to preserve ORDER and NESTING, which the projected filenames
429
432
  // can't carry (they're `<stableId>.md`, with no numeric prefix). It must not also
430
433
  // decide MEMBERSHIP — and a bare list does: the collector reads a list without
@@ -625,12 +628,12 @@ function projectLayout(layoutSections, layoutBaseDir, report, prune, ctx) {
625
628
  /**
626
629
  * Project a whole `@uniweb/site-content` document to a site's files: `info` →
627
630
  * config (siteInfoToConfig), `collections[]` declarations →
628
- * `collections.yml::collections` (declarationsToCollectionsYml), `pages[]` →
631
+ * `collections.yml::collections` (declarationsToQueriesYml), `pages[]` →
629
632
  * `pages/**`, `layout_sections` → `layout/**`. Idempotent. Matches by
630
633
  * stableId-name (clean overwrite); orphan deletion + content-similarity matching
631
634
  * is the reconcile layer.
632
635
  *
633
- * The collection RECORDS are the separate collections lane (collectionsToProject);
636
+ * The collection RECORDS are the separate collections lane (recordsToProject);
634
637
  * this writes only their config declarations.
635
638
  *
636
639
  * @param {object} params
@@ -658,7 +661,7 @@ export function siteContentDocumentToProject({ document, siteRoot, sourceLocale
658
661
  report.assets = restoreAssetRefs(document, readAssetMap(siteRoot))
659
662
 
660
663
  report.config = siteInfoToConfig({ document, siteRoot, sourceLocale, collector, keepAuthoredFoundation })
661
- report.collections = declarationsToCollectionsYml({ document, siteRoot })
664
+ report.queries = declarationsToQueriesYml({ document, siteRoot })
662
665
 
663
666
  // The uuid identity index (gitignored `.uniweb/`): read the prior map to anchor
664
667
  // rename detection, build a fresh one as we project, then persist it. Items not