@uniweb/build 0.56.0 → 0.57.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.
package/src/uwx/index.js CHANGED
@@ -116,23 +116,23 @@ export {
116
116
  } from './records.js'
117
117
  export { readEntityFile, parseFrontmatter } from './entity-source.js'
118
118
 
119
- // The folder's own vocabulary, so a verb can ask what `records.yml` SAYS without
120
- // re-reading or re-parsing it. ⛔ Note the allowlist: an export added to
121
- // `site/records-config.js` does not reach a caller until it is named here.
122
- // The pool's grammar — a schema ref to its directory and back. ⛔ Allowlist: an
123
- // export added to `site/entity-pool.js` does not reach a caller until it is here.
119
+ // Where a site's records live, and the grammar of that directory — a schema ref to
120
+ // its folder and back. ⛔ Allowlist: an export added to `site/entity-pool.js` does
121
+ // not reach a caller until it is here.
124
122
  export {
125
123
  poolDirsForSchema,
126
124
  schemaForPoolDirs,
127
- ENTITIES_DIR,
125
+ readEntityPool,
126
+ resolveRecordsDir,
127
+ RECORDS_DIR,
128
128
  } from '../site/entity-pool.js'
129
129
 
130
+ // How the folder is organized, so a verb can ask what `records.yml` SAYS without
131
+ // re-reading or re-parsing it. ⛔ Note the allowlist: an export added to
132
+ // `site/records-config.js` does not reach a caller until it is named here.
130
133
  export {
131
134
  readRecordsConfig,
132
135
  RECORDS_YML_RELPATH,
133
- FOLDER_MISSING,
134
- FOLDER_EMPTY,
135
- FOLDER_DECLARED,
136
136
  } from '../site/records-config.js'
137
137
  export {
138
138
  findRecordFile,
@@ -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
1
+ // Records projection — write a pulled folder and its records back to the site's
2
+ // `records/**` files and `records.yml`. The inverse of the producer (`records.js`
3
+ // + `folder.js`): the producer reads the records directory and emits the
4
4
  // `@uniweb/folder` entity + one section-keyed `$`-document per record; this takes
5
5
  // 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 { recordsYmlPath } 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 }) {
@@ -372,31 +372,37 @@ export function declarationsToQueriesYml({ document, siteRoot, org, backend = nu
372
372
  /**
373
373
  * Project a pulled `@uniweb/folder` document back to `records.yml`.
374
374
  *
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.
375
+ * ⭐ THE FOLDER ROUND-TRIPS TRIVIALLY, and that is by design rather than luck:
376
+ * `records.yml` holds concrete paths on both sides, so there is nothing to invert.
377
+ * The old shape put QUERY MACROS in the folder — a virtual `folders:` tree naming
378
+ * collections — and inverting a macro is not possible in general. Taking queries
379
+ * out of the folder is what dissolved that.
381
380
  *
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.
381
+ * ⭐ ONLY THE SUB-FOLDERS ARE WRITTEN (ruled 2026-09-21 [Diego]). A record at the
382
+ * top of the folder needs no line — being in the records directory is what puts it
383
+ * there — so `records.yml` carries the branches and the records placed in them, and
384
+ * a folder with no branches is a site with no `records.yml`: a local one is REMOVED
385
+ * (it could only describe sub-folders the backend's folder no longer has). No state
386
+ * of the file removes a record, so this cannot empty anything.
387
+ *
388
+ * ⛔ A PULL THAT CARRIED NO FOLDER, or one with a placed record that was not written
389
+ * locally, leaves the file alone: it has nothing true to write.
386
390
  *
387
391
  * @param {object} params
388
392
  * @param {object} params.folderDoc - the stored `@uniweb/folder` document
389
393
  * @param {string} params.siteRoot
390
394
  * @param {Map<string,string>} params.poolPathByUuid - record `$uuid` → the path
391
- * under `entities/` of the file just written for it. Supplied by
395
+ * under `records/` of the file just written for it. Supplied by
392
396
  * `recordsToProject`, which is the only thing that knows the extension
393
397
  * each record landed with.
394
- * @returns {{ status: 'updated'|'unchanged'|'skipped', entries: Array, warnings: string[] }}
398
+ * @returns {{ status: 'updated'|'unchanged'|'removed'|'skipped', entries: Array, warnings: string[] }}
395
399
  */
396
400
  export function folderToRecordsYml({ folderDoc, siteRoot, poolPathByUuid, sourceLocale = 'en' }) {
397
401
  const warnings = []
402
+ if (!folderDoc) return { status: 'skipped', entries: [], warnings }
403
+ let unplaceable = false
398
404
 
399
- const walk = (nodes) => {
405
+ const walk = (nodes, inBranch) => {
400
406
  const out = []
401
407
  for (const node of nodes || []) {
402
408
  if (!node || typeof node !== 'object') continue
@@ -406,35 +412,53 @@ export function folderToRecordsYml({ folderDoc, siteRoot, poolPathByUuid, source
406
412
  // does not caption its rows. On the wire the label is a localized map;
407
413
  // records.yml carries the source-locale string (a bare string passes).
408
414
  if (node.label !== undefined) entry.label = unwrapLocalized(node.label, sourceLocale)
409
- entry.records = walk(node.$children)
415
+ entry.records = walk(node.$children, true)
410
416
  out.push(entry)
411
417
  continue
412
418
  }
413
419
  const uuid = node.entry?.entity ?? node.entry
414
420
  const rel = typeof uuid === 'string' ? poolPathByUuid.get(uuid) : null
421
+ // A record at the top of the folder needs no line — but one that did not land
422
+ // is still said: the next push sends the folder the directory holds, which
423
+ // would not have it.
424
+ if (!inBranch) {
425
+ if (!rel) {
426
+ warnings.push(
427
+ `the folder holds a record ("${node.name ?? '?'}") that was not written locally — ` +
428
+ `a push from here would remove it from the folder.`
429
+ )
430
+ }
431
+ continue
432
+ }
415
433
  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.
434
+ // ⚠️ Reported, never dropped in silence. A placed record that did not land
435
+ // means the folder and the directory disagree, and writing the file without
436
+ // it would move that record to the top of the folder on the next push.
419
437
  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.`
438
+ `records.yml: the folder places a record ("${node.name ?? '?'}") that was not ` +
439
+ `written locally — the file was left unchanged rather than dropping it.`
422
440
  )
423
- return null
441
+ unplaceable = true
442
+ continue
424
443
  }
425
444
  out.push(rel)
426
445
  }
427
446
  return out
428
447
  }
429
448
 
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 }
449
+ const entries = walk(folderDoc.contents, false)
450
+ if (unplaceable) return { status: 'skipped', entries: [], warnings }
451
+ if (entries.length === 0) {
452
+ const file = recordsYmlPath(siteRoot)
453
+ if (!existsSync(file)) return { status: 'unchanged', entries, warnings }
454
+ unlinkSync(file)
455
+ return { status: 'removed', entries, warnings }
456
+ }
433
457
  return { status: writeRecordsConfig(siteRoot, entries), entries, warnings }
434
458
  }
435
459
 
436
460
  /**
437
- * Project a pulled folder + its record entities to `entities/**` files.
461
+ * Project a pulled folder + its record entities to `records/**` files.
438
462
  *
439
463
  * @param {object} params
440
464
  * @param {object} params.folderDoc - the `@uniweb/folder` document `{ contents }` (no `$uuid`)
@@ -445,24 +469,31 @@ export function folderToRecordsYml({ folderDoc, siteRoot, poolPathByUuid, source
445
469
  * - resolve a Model's data-schema declaration by name (`$model`).
446
470
  * @param {string} [params.opts.org] - the site's own org, so a `@org/x` model the
447
471
  * producer resolved from `@/x` is placed back where the author wrote it.
448
- * Defaults to `site.yml::$org`.
472
+ * Defaults to the org `sync.json` records for `opts.backend`.
473
+ * @param {string} [params.opts.backend] - whose record map to read and extend, and
474
+ * whose recorded org is that default
449
475
  * @param {string} [params.opts.sourceLocale]
450
476
  * @returns {{ updated: string[], placed: string[], unchanged: string[], skipped: object[], warnings: string[], locales: object }}
451
477
  */
452
478
  export function recordsToProject({ folderDoc, recordDocs = [], siteRoot, opts = {} }) {
453
479
  const { resolveDeclaration, sourceLocale = 'en' } = opts
454
480
  // 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.
481
+ // placed back where the author wrote it. Read from `sync.json` (this backend's
482
+ // `site.org`) unless the caller already has it.
457
483
  const selfOrg = opts.org ?? readSiteOrg(siteRoot, opts.backend)
458
484
  if (typeof resolveDeclaration !== 'function') {
459
485
  throw new Error('uwx/records-project: opts.resolveDeclaration(modelName) is required')
460
486
  }
461
487
 
462
488
  const folderIndex = indexFolder(folderDoc)
489
+ // Where records land — `site.yml::paths.records`, else `records/`: the one resolver
490
+ // the build and the push read too, so a pulled record lands where the next build
491
+ // looks. ⛔ This wrote into the default directory until 2026-09-21, whatever the
492
+ // site had moved its records to.
493
+ const recordsRoot = resolveRecordsDir(siteRoot).abs
463
494
  // Captures target-locale translations of localized record fields: SCALARs →
464
495
  // locales/records/{locale}.json (structural maps too), and a prosemirror
465
- // BODY's free-form per-locale override → locales/freeform/{locale}/collections/.
496
+ // BODY's free-form per-locale override → locales/freeform/{locale}/records/.
466
497
  const collector = createTranslationCollector(sourceLocale)
467
498
  const updated = []
468
499
  const placed = []
@@ -474,7 +505,7 @@ export function recordsToProject({ folderDoc, recordDocs = [], siteRoot, opts =
474
505
  const ownIdByTheirs = new Map(Object.entries(recordMap).map(([own, theirs]) => [theirs, own]))
475
506
  // own id → their uuid, for every record this pull wrote. Recorded at the end.
476
507
  const learned = {}
477
- // uuid → the path under `entities/` the record landed at. Only this loop knows
508
+ // uuid → the path under `records/` the record landed at. Only this loop knows
478
509
  // the extension each one got, so `records.yml` is written from it rather than
479
510
  // re-derived (a second rule could pick a different extension and the folder
480
511
  // would name a file that is not there).
@@ -492,7 +523,7 @@ export function recordsToProject({ folderDoc, recordDocs = [], siteRoot, opts =
492
523
  continue
493
524
  }
494
525
 
495
- const poolDir = recordDirFor(siteRoot, document.$model, selfOrg)
526
+ const poolDir = recordDirFor(recordsRoot, document.$model, selfOrg)
496
527
  if (!poolDir) {
497
528
  skipped.push({
498
529
  uuid: document.$uuid,
@@ -542,7 +573,7 @@ export function recordsToProject({ folderDoc, recordDocs = [], siteRoot, opts =
542
573
  // Keyed by THEIR uuid: the folder document references records in the backend's
543
574
  // terms, so that is what `folderToRecordsYml` will look them up by.
544
575
  if (theirs) {
545
- poolPathByUuid.set(theirs, relative(join(siteRoot, ENTITIES_DIR), filePath).split(sep).join('/'))
576
+ poolPathByUuid.set(theirs, relative(recordsRoot, filePath).split(sep).join('/'))
546
577
  if (own) learned[own] = theirs
547
578
  }
548
579
  }