@uniweb/build 0.56.0 → 0.58.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.
@@ -1,46 +1,44 @@
1
- // Collections projection — write a folder + its record entities back to the
2
- // site's `collections/**` source files. The inverse of the collections producer
3
- // (collections.js + folder.js): the producer reads source records and emits the
4
- // `@uniweb/folder` entity + one section-keyed `$`-document per record; this takes
5
- // those documents back and renders them to files.
1
+ // Records projection — write a pulled folder and its records back to the site's
2
+ // `records/**` files and the folder's organization, `records/folder.yml`. The
3
+ // inverse of the producer (`records.js` + `folder.js`): the producer reads the
4
+ // records directory and emits the `@uniweb/folder` entity + one section-keyed
5
+ // `$`-document per record; this takes those documents back and renders them to files.
6
6
  //
7
- // Identity & placement. A record's on-disk home is `(collection, slug)`:
8
- // - `slug` and `collection` come from the FOLDER document — each ref leaf is
9
- // `{ entry: { model, entity: <uuid> }, name: <slug> }` inside a branch
10
- // (its `$children`) whose `name` names the folder it was placed in.
11
- // The folder is the authoritative organization on a read (the record
12
- // document's own `$id` envelope is not guaranteed to be echoed back), with
13
- // the record document's `$id` (its pool identity) as a fallback when present.
14
- // - the record's directory comes from its `$model` — `entities/{schema}/` is
15
- // where a thing of that model lives. Not from any query: a query has no
16
- // directory, and which query selects a record is not a fact about it.
7
+ // Identity & placement:
8
+ // - a record's slug comes from the FOLDER document — each ref leaf is
9
+ // `{ entry: { model, entity: <uuid> }, name: <slug> }`, at the top of the folder
10
+ // or inside a branch (its `$children`) whose `name` names the sub-folder. The
11
+ // folder is the authoritative organization on a read (the record document's own
12
+ // `$id` envelope is not guaranteed to be echoed back), with the record
13
+ // document's `$id` (its position in the directory) as a fallback when present.
14
+ // - the record's directory comes from its `$model` — `records/{schema}/` is where
15
+ // a record of that model lives (`site.yml::paths.records` moves it). Not from any
16
+ // query: a query has no directory, and which query selects a record is not a
17
+ // fact about it.
17
18
  // - an existing local file carrying the same `$uuid` is re-rendered in place;
18
19
  // otherwise a new single-record file is placed at `<slug>.<ext>`, its format
19
- // matched to the collection's existing files, else markdown when the Model's
20
+ // matched to the schema folder's existing files, else markdown when the Model's
20
21
  // brief has a content body field, else YAML.
21
22
  //
22
23
  // Field rendering reuses renderEntityDocument (via writeRecordFile) — localized
23
24
  // unwrap, date handling, content-body→body are already inverted there.
24
25
  //
25
- // v1 scope / deferred: array-form & BibTeX multi-record files (a pulled record is
26
- // placed as its own single-record file; merging into an existing array file is a
27
- // later nicety); deriving an on-disk collection from a deeply NESTED virtual
28
- // folder org when the record carries no `$id`; and rewriting `collections.yml`'s
29
- // `folders:` organization + synthesizing declarations for newly-introduced collections
30
- // (a comment-preserving config rewrite is a separate quality bar). The folder itself
31
- // carries no `$uuid` — the backend owns it, keyed by the site-content uuid — so
32
- // nothing is written into `collections.yml` here. Nothing is silently dropped: an
33
- // unplaceable or unresolvable record is reported.
26
+ // Deferred: array-form & BibTeX multi-record files (a pulled record is placed as
27
+ // its own single-record file; merging into an existing array file is a later
28
+ // nicety). The folder itself carries no `$uuid` — the backend owns it, keyed by the
29
+ // site-content uuid. Nothing is silently dropped: an unplaceable or unresolvable
30
+ // record is reported.
34
31
 
35
32
  import { readBackendState, updateBackendMap } from './sync-store.js'
36
- import { readFileSync, readdirSync, existsSync } from 'node:fs'
33
+ import { readFileSync, readdirSync, existsSync, unlinkSync } from 'node:fs'
37
34
  import { join, resolve, relative, extname, basename, sep } from 'node:path'
38
35
  import yaml from 'js-yaml'
39
36
  import { YAML_OPTIONS } from '../utils/yaml-schema.js'
40
37
  import { parseFrontmatter } from './entity-source.js'
41
38
  import { writeRecordFile, writeQueriesConfig, writeRecordsConfig } from './project-writer.js'
42
39
  import { defaultSchema, deferredFromSchema, foundationDataSchemas } from './queries-config.js'
43
- import { poolDirsForSchema, ENTITIES_DIR } from '../site/entity-pool.js'
40
+ import { poolDirsForSchema, resolveRecordsDir } from '../site/entity-pool.js'
41
+ import { folderYmlPath } from '../site/records-config.js'
44
42
  import { isContentBodyField } from './data-schema.js'
45
43
  import { unresolveSelfScope } from './self-scope.js'
46
44
  import { unwrapLocalized } from './backfill.js'
@@ -145,7 +143,7 @@ function indexFolder(folderDoc) {
145
143
  // Where a pulled record is written: the pool folder its MODEL names.
146
144
  //
147
145
  // ⛔ NOT THE QUERY'S DIRECTORY — a query has none. A record's home is decided by
148
- // what it IS, and `entities/{schema}/` is the one place a thing of that model
146
+ // what it IS, and `records/{schema}/` is the one place a record of that model
149
147
  // lives. That is also why the placement survives a query being renamed, added or
150
148
  // deleted, none of which is a fact about the record.
151
149
  //
@@ -153,9 +151,9 @@ function indexFolder(folderDoc) {
153
151
  // `schemaForPoolDirs`, and deliberately not a second rule: if the two disagreed,
154
152
  // a pulled record would land somewhere the next build reads as a different
155
153
  // schema — silently, because both paths are well-formed.
156
- function recordDirFor(siteRoot, model, selfOrg) {
154
+ function recordDirFor(recordsRoot, model, selfOrg) {
157
155
  const dirs = poolDirsForSchema(unresolveSelfScope(model, selfOrg))
158
- return dirs ? join(siteRoot, ENTITIES_DIR, ...dirs) : null
156
+ return dirs ? join(recordsRoot, ...dirs) : null
159
157
  }
160
158
 
161
159
  // ⚠️ Undoing the producer's self-scope resolution (`unresolveSelfScope`) lives in
@@ -175,7 +173,7 @@ function locate(document, folderIndex) {
175
173
  return fromFolder || null
176
174
  }
177
175
 
178
- /** `site.yml::$org`, bare (`acme`), or null. Stored bare — see `writeSiteOrg`. */
176
+ /** The site's own org on `backend` — its `site.org` in `sync.json` — bare (`acme`), or null. Stored bare — see `writeSiteOrg`. */
179
177
  // ⛔ `site.yml::$org` IS GONE (2026-09-20) — the org lives in
180
178
  // `sync.json::backends.<origin>.site.org`. This read `site.yml` and, once step 4
181
179
  // moved the key, silently returned null: every `@org/x` model a pull met would have
@@ -263,8 +261,8 @@ function declToFileShape(wire, dataSchemas = null, selfOrg = null) {
263
261
  setIf(decl, 'transform', source.transform)
264
262
  setIf(decl, 'record', source.record)
265
263
  } else if (typeof source.path === 'string') {
266
- // ⛔ A FILE-BASED QUERY HAS NO PATH TO WRITE BACK. `entities/{schema}/` is the
267
- // pool and `schema:` addresses it, so a `path` arriving on the wire is either
264
+ // ⛔ A FILE-BASED QUERY HAS NO PATH TO WRITE BACK. `records/{schema}/` holds its
265
+ // records and `schema:` addresses them, so a `path` arriving on the wire is either
268
266
  // stale storage or something only a remote source could have meant. Dropping
269
267
  // it keeps the author's file saying what the build actually reads.
270
268
  decl.path = source.path
@@ -332,8 +330,10 @@ function declToFileShape(wire, dataSchemas = null, selfOrg = null) {
332
330
  * @param {object} params.document - a site-content `$`-document (`{ queries }`)
333
331
  * @param {string} params.siteRoot
334
332
  * @param {string} [params.org] - the site's own org, so a `schema` the producer
335
- * qualified from `@/x` is written back as `@/x`. Defaults to `site.yml::$org`,
336
- * the same default `recordsToProject` places records by.
333
+ * qualified from `@/x` is written back as `@/x`. Defaults to the org
334
+ * `sync.json` records for `backend`, the same default `recordsToProject`
335
+ * places records by.
336
+ * @param {string|null} [params.backend] - whose recorded org is that default
337
337
  * @returns {{ collections?: 'updated'|'unchanged' }}
338
338
  */
339
339
  export function declarationsToQueriesYml({ document, siteRoot, org, backend = null }) {
@@ -370,33 +370,42 @@ export function declarationsToQueriesYml({ document, siteRoot, org, backend = nu
370
370
  }
371
371
 
372
372
  /**
373
- * Project a pulled `@uniweb/folder` document back to `records.yml`.
373
+ * Project a pulled `@uniweb/folder` document back to `folder.yml` in the records
374
+ * directory (`records/folder.yml`, or under `site.yml::paths.records`).
374
375
  *
375
- * ⭐ THE FOLDER IS THE ONE THING THAT ROUND-TRIPS TRIVIALLY, and that is by design
376
- * rather than luck: `records.yml` holds concrete refs on both sides, so there is
377
- * nothing to invert. The old shape put QUERY MACROS in the folder — a virtual
378
- * `folders:` tree naming collections — and inverting a macro is not possible in
379
- * general, which is why rewriting it stayed deferred for as long as it existed.
380
- * Taking queries out of the folder is what dissolved that.
376
+ * ⭐ THE FOLDER ROUND-TRIPS TRIVIALLY, and that is by design rather than luck:
377
+ * `folder.yml` holds concrete paths on both sides, so there is nothing to invert.
378
+ * The old shape put QUERY MACROS in the folder — a virtual `folders:` tree naming
379
+ * collections — and inverting a macro is not possible in general. Taking queries
380
+ * out of the folder is what dissolved that.
381
381
  *
382
- * ⛔ AN EMPTY RESULT IS NOT WRITTEN. An empty `records.yml` means "the folder holds
383
- * nothing" and REMOVES on the next push, so a pull that carried no folder — or one
384
- * whose leaves could not be placed — must leave the file alone rather than author
385
- * the destructive state on the author's behalf.
382
+ * ⭐ ONLY THE SUB-FOLDERS ARE WRITTEN (ruled 2026-09-21 [Diego]). A record at the
383
+ * top of the folder needs no line — being in the records directory is what puts it
384
+ * there — so `folder.yml` carries the branches and the records placed in them, and
385
+ * a folder with no branches is a site with no `folder.yml`: a local one is REMOVED
386
+ * (it could only describe sub-folders the backend's folder no longer has). No state
387
+ * of the file removes a record, so this cannot empty anything.
388
+ *
389
+ * ⛔ A PULL THAT CARRIED NO FOLDER, or one with a placed record that was not written
390
+ * locally, leaves the file alone: it has nothing true to write.
386
391
  *
387
392
  * @param {object} params
388
393
  * @param {object} params.folderDoc - the stored `@uniweb/folder` document
389
394
  * @param {string} params.siteRoot
390
395
  * @param {Map<string,string>} params.poolPathByUuid - record `$uuid` → the path
391
- * under `entities/` of the file just written for it. Supplied by
396
+ * under `records/` of the file just written for it. Supplied by
392
397
  * `recordsToProject`, which is the only thing that knows the extension
393
398
  * each record landed with.
394
- * @returns {{ status: 'updated'|'unchanged'|'skipped', entries: Array, warnings: string[] }}
399
+ * @returns {{ status: 'updated'|'unchanged'|'removed'|'skipped', entries: Array, warnings: string[], file: string }}
400
+ * `file` is where the organization lives, as the author would write it
395
401
  */
396
- export function folderToRecordsYml({ folderDoc, siteRoot, poolPathByUuid, sourceLocale = 'en' }) {
402
+ export function folderToFolderYml({ folderDoc, siteRoot, poolPathByUuid, sourceLocale = 'en' }) {
397
403
  const warnings = []
404
+ const { abs, rel: file } = folderYmlPath(siteRoot)
405
+ if (!folderDoc) return { status: 'skipped', entries: [], warnings, file }
406
+ let unplaceable = false
398
407
 
399
- const walk = (nodes) => {
408
+ const walk = (nodes, inBranch) => {
400
409
  const out = []
401
410
  for (const node of nodes || []) {
402
411
  if (!node || typeof node !== 'object') continue
@@ -404,37 +413,54 @@ export function folderToRecordsYml({ folderDoc, siteRoot, poolPathByUuid, source
404
413
  const entry = { folder: node.name }
405
414
  // Only a BRANCH takes a label. A record carries its own title; the folder
406
415
  // does not caption its rows. On the wire the label is a localized map;
407
- // records.yml carries the source-locale string (a bare string passes).
416
+ // folder.yml carries the source-locale string (a bare string passes).
408
417
  if (node.label !== undefined) entry.label = unwrapLocalized(node.label, sourceLocale)
409
- entry.records = walk(node.$children)
418
+ entry.records = walk(node.$children, true)
410
419
  out.push(entry)
411
420
  continue
412
421
  }
413
422
  const uuid = node.entry?.entity ?? node.entry
414
423
  const rel = typeof uuid === 'string' ? poolPathByUuid.get(uuid) : null
424
+ // A record at the top of the folder needs no line — but one that did not land
425
+ // is still said: the next push sends the folder the directory holds, which
426
+ // would not have it.
427
+ if (!inBranch) {
428
+ if (!rel) {
429
+ warnings.push(
430
+ `the folder holds a record ("${node.name ?? '?'}") that was not written locally — ` +
431
+ `a push from here would remove it from the folder.`
432
+ )
433
+ }
434
+ continue
435
+ }
415
436
  if (!rel) {
416
- // ⚠️ Reported, never dropped in silence. A leaf whose record did not land
417
- // means the folder and the pool disagree, and writing the file without it
418
- // would quietly unpublish that record on the next push.
437
+ // ⚠️ Reported, never dropped in silence. A placed record that did not land
438
+ // means the folder and the directory disagree, and writing the file without
439
+ // it would move that record to the top of the folder on the next push.
419
440
  warnings.push(
420
- `records.yml: a folder leaf ("${node.name ?? '?'}") references a record that ` +
421
- `was not written locally — the file was left unchanged rather than dropping it.`
441
+ `${file}: the folder places a record ("${node.name ?? '?'}") that was not ` +
442
+ `written locally — the file was left unchanged rather than dropping it.`
422
443
  )
423
- return null
444
+ unplaceable = true
445
+ continue
424
446
  }
425
447
  out.push(rel)
426
448
  }
427
449
  return out
428
450
  }
429
451
 
430
- const entries = walk(folderDoc?.contents)
431
- if (entries === null) return { status: 'skipped', entries: [], warnings }
432
- if (entries.length === 0) return { status: 'skipped', entries: [], warnings }
433
- return { status: writeRecordsConfig(siteRoot, entries), entries, warnings }
452
+ const entries = walk(folderDoc.contents, false)
453
+ if (unplaceable) return { status: 'skipped', entries: [], warnings, file }
454
+ if (entries.length === 0) {
455
+ if (!existsSync(abs)) return { status: 'unchanged', entries, warnings, file }
456
+ unlinkSync(abs)
457
+ return { status: 'removed', entries, warnings, file }
458
+ }
459
+ return { status: writeRecordsConfig(siteRoot, entries), entries, warnings, file }
434
460
  }
435
461
 
436
462
  /**
437
- * Project a pulled folder + its record entities to `entities/**` files.
463
+ * Project a pulled folder + its record entities to `records/**` files.
438
464
  *
439
465
  * @param {object} params
440
466
  * @param {object} params.folderDoc - the `@uniweb/folder` document `{ contents }` (no `$uuid`)
@@ -445,24 +471,31 @@ export function folderToRecordsYml({ folderDoc, siteRoot, poolPathByUuid, source
445
471
  * - resolve a Model's data-schema declaration by name (`$model`).
446
472
  * @param {string} [params.opts.org] - the site's own org, so a `@org/x` model the
447
473
  * producer resolved from `@/x` is placed back where the author wrote it.
448
- * Defaults to `site.yml::$org`.
474
+ * Defaults to the org `sync.json` records for `opts.backend`.
475
+ * @param {string} [params.opts.backend] - whose record map to read and extend, and
476
+ * whose recorded org is that default
449
477
  * @param {string} [params.opts.sourceLocale]
450
478
  * @returns {{ updated: string[], placed: string[], unchanged: string[], skipped: object[], warnings: string[], locales: object }}
451
479
  */
452
480
  export function recordsToProject({ folderDoc, recordDocs = [], siteRoot, opts = {} }) {
453
481
  const { resolveDeclaration, sourceLocale = 'en' } = opts
454
482
  // The site's own org, so a `@org/x` model the producer resolved from `@/x` is
455
- // placed back where the author wrote it. Read from `site.yml::$org` unless the
456
- // caller already has it.
483
+ // placed back where the author wrote it. Read from `sync.json` (this backend's
484
+ // `site.org`) unless the caller already has it.
457
485
  const selfOrg = opts.org ?? readSiteOrg(siteRoot, opts.backend)
458
486
  if (typeof resolveDeclaration !== 'function') {
459
487
  throw new Error('uwx/records-project: opts.resolveDeclaration(modelName) is required')
460
488
  }
461
489
 
462
490
  const folderIndex = indexFolder(folderDoc)
491
+ // Where records land — `site.yml::paths.records`, else `records/`: the one resolver
492
+ // the build and the push read too, so a pulled record lands where the next build
493
+ // looks. ⛔ This wrote into the default directory until 2026-09-21, whatever the
494
+ // site had moved its records to.
495
+ const recordsRoot = resolveRecordsDir(siteRoot).abs
463
496
  // Captures target-locale translations of localized record fields: SCALARs →
464
497
  // locales/records/{locale}.json (structural maps too), and a prosemirror
465
- // BODY's free-form per-locale override → locales/freeform/{locale}/collections/.
498
+ // BODY's free-form per-locale override → locales/freeform/{locale}/records/.
466
499
  const collector = createTranslationCollector(sourceLocale)
467
500
  const updated = []
468
501
  const placed = []
@@ -474,8 +507,8 @@ export function recordsToProject({ folderDoc, recordDocs = [], siteRoot, opts =
474
507
  const ownIdByTheirs = new Map(Object.entries(recordMap).map(([own, theirs]) => [theirs, own]))
475
508
  // own id → their uuid, for every record this pull wrote. Recorded at the end.
476
509
  const learned = {}
477
- // uuid → the path under `entities/` the record landed at. Only this loop knows
478
- // the extension each one got, so `records.yml` is written from it rather than
510
+ // uuid → the path under `records/` the record landed at. Only this loop knows
511
+ // the extension each one got, so `folder.yml` is written from it rather than
479
512
  // re-derived (a second rule could pick a different extension and the folder
480
513
  // would name a file that is not there).
481
514
  const poolPathByUuid = new Map()
@@ -492,7 +525,7 @@ export function recordsToProject({ folderDoc, recordDocs = [], siteRoot, opts =
492
525
  continue
493
526
  }
494
527
 
495
- const poolDir = recordDirFor(siteRoot, document.$model, selfOrg)
528
+ const poolDir = recordDirFor(recordsRoot, document.$model, selfOrg)
496
529
  if (!poolDir) {
497
530
  skipped.push({
498
531
  uuid: document.$uuid,
@@ -540,20 +573,20 @@ export function recordsToProject({ folderDoc, recordDocs = [], siteRoot, opts =
540
573
  else if (isNew) placed.push(filePath)
541
574
  else updated.push(filePath)
542
575
  // Keyed by THEIR uuid: the folder document references records in the backend's
543
- // terms, so that is what `folderToRecordsYml` will look them up by.
576
+ // terms, so that is what `folderToFolderYml` will look them up by.
544
577
  if (theirs) {
545
- poolPathByUuid.set(theirs, relative(join(siteRoot, ENTITIES_DIR), filePath).split(sep).join('/'))
578
+ poolPathByUuid.set(theirs, relative(recordsRoot, filePath).split(sep).join('/'))
546
579
  if (own) learned[own] = theirs
547
580
  }
548
581
  }
549
582
 
550
- // ⭐ THE FOLDER ITSELF, written back as `records.yml`. Steps that only touched the
583
+ // ⭐ THE FOLDER ITSELF, written back as `folder.yml`. Steps that only touched the
551
584
  // READ path would leave every pull authoring the old shape — the site would build
552
585
  // from the new layout and be projected back into the one it replaced.
553
586
  //
554
587
  // The folder ENTITY still carries no `$uuid` we persist: the backend owns the
555
588
  // site's folder, keyed by the site-content uuid.
556
- const records = folderToRecordsYml({ folderDoc, siteRoot, poolPathByUuid, sourceLocale })
589
+ const records = folderToFolderYml({ folderDoc, siteRoot, poolPathByUuid, sourceLocale })
557
590
  warnings.push(...records.warnings)
558
591
 
559
592
  // Flush localized record-field translations to locales/records/{locale}.json,
@@ -565,5 +598,5 @@ export function recordsToProject({ folderDoc, recordDocs = [], siteRoot, opts =
565
598
  updateBackendMap(siteRoot, opts.backend, 'records', learned)
566
599
  }
567
600
 
568
- return { updated, placed, unchanged, skipped, warnings, locales, freeform, records: records.status }
601
+ return { updated, placed, unchanged, skipped, warnings, locales, freeform, records: records.status, recordsFile: records.file }
569
602
  }