@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.
@@ -26,7 +26,7 @@
26
26
  // (the foundation's scope, not the site's publish org) and stays there.
27
27
 
28
28
  /**
29
- * An org handle as `--org` or `site.yml::$org` gives it — `@acme`, `acme`, or
29
+ * An org handle as `--org` or `sync.json` gives it — `@acme`, `acme`, or
30
30
  * `@acme/…` — reduced to the bare handle, or `''`.
31
31
  *
32
32
  * @param {unknown} org
@@ -56,15 +56,15 @@ export function resolveSelfScope(ref, org) {
56
56
  * The inverse, for a pull: `@<org>/x` → `@/x` for the site's own org.
57
57
  *
58
58
  * ⛔ WITHOUT THIS THE ROUND TRIP IS NOT A FIXED POINT, and the failure is silent
59
- * on both ends. A record authored under `entities/article/` comes back as
60
- * `@acme/article` and, placed literally, lands under `entities/acme/article/` — a
59
+ * on both ends. A record authored under `records/article/` comes back as
60
+ * `@acme/article` and, placed literally, lands under `records/acme/article/` — a
61
61
  * different schema folder, which the next build reads as a different schema. A
62
62
  * query declared `schema: '@/member'` comes back as `@acme/member`, and one that
63
63
  * relied on the query-name default comes back with an explicit schema it never
64
64
  * had.
65
65
  *
66
- * ⭐ The site records its own org at create (`site.yml::$org` — "whose this is"),
67
- * which is exactly the inverse. A model scoped to ANOTHER org is left alone: it
66
+ * ⭐ The site records its own org at create (`site.org` in `sync.json`, per backend —
67
+ * "whose this is"), which is exactly the inverse. A model scoped to ANOTHER org is left alone: it
68
68
  * genuinely is that org's, and `@/` would be a lie.
69
69
  *
70
70
  * @param {unknown} ref
@@ -151,7 +151,7 @@ const INFO_TO_SITE_YML = {
151
151
  // ⭐ `preview` — the card image, verbatim: the app's timestamp, an author's URL, or
152
152
  // the path an author wrote for an image in the project, which `restoreAssetRefs`
153
153
  // has already put back by recognizing the serve URL the push recorded in
154
- // `assets.json` (asset-map.js). [Diego, 2026-09-10]
154
+ // `sync.json`'s asset map (asset-map.js). [Diego, 2026-09-10]
155
155
  //
156
156
  // ⚠️ It was listed here as "DELIBERATELY ABSENT… BACKEND-STAMPED" until
157
157
  // 2026-09-10. Leaving an app-written field out of this map is what lets the next
@@ -349,8 +349,8 @@ export function siteInfoToConfig({ document, siteRoot, backend = null, sourceLoc
349
349
  // `data_bundle` and `foundation_schema`. It had been a build-derived upload
350
350
  // manifest that a pull deliberately never wrote back; framework never populated
351
351
  // it. Nothing to project, and nothing here to remove — the note is kept because
352
- // `assets.json` (the committed local path → id map) is a DIFFERENT thing and the
353
- // two get confused.
352
+ // the committed local path → id map (each backend's `assets` in `sync.json`;
353
+ // `assets.json` until 2026-09-20) is a DIFFERENT thing and the two get confused.
354
354
 
355
355
  return result
356
356
  }
package/src/uwx/site.js CHANGED
@@ -14,13 +14,16 @@
14
14
  // carries the verbatim `site.yml::foundation` string (the round-trip source of
15
15
  // truth).
16
16
  //
17
- // IDENTITY. The ENTITY `$uuid` lives in `site.yml` (top-level `$uuid`); we read it,
18
- // send it, and back-fill the minted value there. Nested pages/sections carry a `$id`
19
- // handle AND a per-item `$uuid`.
17
+ // IDENTITY. The ENTITY `$uuid` is a backend's, recorded per backend in `sync.json`
18
+ // (`backends.<origin>.site.uuid`): we read the one for the backend a document is
19
+ // produced FOR, send it, and record the minted value there. Nested pages/sections
20
+ // carry a `$id` handle AND a per-item `$uuid`. ⚠️ The entity `$uuid` lived in
21
+ // `site.yml` until 2026-09-20 — one value, with no way to say which backend minted it.
20
22
  //
21
23
  // The per-item uuid is NOT authored — author files never carry sync uuids. It is
22
- // stamped at emit (`stampUnitUuids`) from an out-of-band cache populated by whatever
23
- // the backend last reported: a pull, or a push response's `finalized[].document`.
24
+ // stamped at emit (`stampUnitUuids`) from that backend's `items` map in `sync.json`,
25
+ // written from whatever the backend last reported: a pull, or a push response's
26
+ // `finalized[].document`. (The map sat in the gitignored sync cache until 2026-09-20.)
24
27
  // This is load-bearing, not bookkeeping: the backend matches records by uuid, and
25
28
  // `pages` / `page_sections` / `layout_sections` are all `multi` sections, where a
26
29
  // uuid-less record is read as NEW — inserted, with its stored counterpart deleted as
@@ -385,7 +388,7 @@ const CATCH_ALL_MARKER = '...path'
385
388
  // `page_sections`). Cross-section parentage is pure structure, never `$parent`.
386
389
  // - `$id` (the stableId — the in-file handle) rides at every item level as the
387
390
  // wire-only closure handle. Per-item `$uuid` is NOT authored here: it is
388
- // stamped on by `stampUnitUuids` at emit, from the out-of-band identity cache,
391
+ // stamped on by `stampUnitUuids` at emit, from the backend's `items` map in `sync.json`,
389
392
  // because the backend matches records by uuid and a uuid-less record in a
390
393
  // `multi` section is read as new (inserted, stored counterpart deleted). See
391
394
  // the IDENTITY note in the file header and `site-diff.js`.
@@ -401,8 +404,8 @@ const SITE_MODEL_NAME = '@uniweb/site-content'
401
404
  // `fields` already carries `stable_id` (the Model field); `$id` is the same value.
402
405
  // Both are kept: `$id` is the sync handle, `stable_id` is the declared content field
403
406
  // the editor/render reads. Per-item `$uuid` is added later by `stampUnitUuids` (it
404
- // comes from the identity cache, not from the authored files) — see the IDENTITY
405
- // note in the file header.
407
+ // comes from `sync.json`, not from the authored files) — see the IDENTITY note in
408
+ // the file header.
406
409
  function withIdentity(id, fields) {
407
410
  return Object.assign({ $id: id }, fields)
408
411
  }
@@ -1214,14 +1217,15 @@ function settingsNested(siteYml, { headHtml, themeYml, sourceLocale, translation
1214
1217
  /**
1215
1218
  * Map a file site project to the nested `@uniweb/site-content` `$`-document
1216
1219
  * (see the lane header above). PURE — reads the project, never mints, never writes.
1217
- * The entity `$uuid` comes from `site.yml::$uuid` (back-filled after first sync);
1218
- * nested items carry `$id` only.
1220
+ * The entity `$uuid` is `opts.backend`'s, from `sync.json` (recorded after the first
1221
+ * sync to it); nested items carry `$id` only.
1219
1222
  *
1220
1223
  * @param {string} siteRoot - directory containing site.yml
1221
1224
  * @param {object} [opts]
1222
1225
  * @param {string} [opts.backend] - whose `$uuid` to put on the wire (sync.json)
1223
1226
  * @param {string} [opts.entityUuid] - override the entity `$uuid` (tests); default
1224
- * is `site.yml::$uuid` (absent on first sync — `$id`-only document).
1227
+ * is `opts.backend`'s site uuid in `sync.json` (absent on a first sync to it,
1228
+ * or with no backend — a `$id`-only document).
1225
1229
  * @param {string} [opts.sourceLocale] - localized-field wrap locale. Defaults to
1226
1230
  * the site's effective default locale (`defaultLanguage || languages[0] ||
1227
1231
  * 'en'` — the shared `resolveDefaultLocale` rule), NOT a bare 'en'.
@@ -1308,9 +1312,10 @@ export async function siteProjectToDocument(siteRoot, opts = {}) {
1308
1312
  // deploy-derived `info` field framework sends is `foundation`, via `injectInfo`
1309
1313
  // in `publish`.
1310
1314
  //
1311
- // ⚠️ The note stays because `assets.json` — the COMMITTED local map from an
1312
- // author's asset path to the backend's content-addressed id — is a different
1313
- // thing with a similar name, and it is very much alive.
1315
+ // ⚠️ The note stays because the COMMITTED local map from an author's asset path
1316
+ // to the backend's content-addressed id — each backend's `assets` in `sync.json`,
1317
+ // `assets.json` until 2026-09-20 — is a different thing with a similar name, and
1318
+ // it is very much alive.
1314
1319
  setIf(info, 'favicon', siteYml.favicon)
1315
1320
  // Site-level SEO/social metadata — the same shape as page.yml's `seo:` + the
1316
1321
  // top-level `keywords`, hoisted to the site root so the homepage social card
@@ -1421,7 +1426,7 @@ export async function siteProjectToDocument(siteRoot, opts = {}) {
1421
1426
  // a site-root path to an image in the project, which push uploads like any
1422
1427
  // content image and sends as its serve URL, and which pull puts back as the
1423
1428
  // path the author wrote (`restoreAssetRefs` recognizes the URL by the
1424
- // fingerprint `assets.json` recorded for it).
1429
+ // fingerprint recorded for it in `sync.json`'s asset map).
1425
1430
  // The app leaves an author's value alone.
1426
1431
  //
1427
1432
  // ⚠️ Until 2026-09-10 framework emitted no preview, on the premise that the host
@@ -1502,8 +1507,7 @@ export async function siteProjectToDocument(siteRoot, opts = {}) {
1502
1507
  // config (the records themselves are separate entities; this is just the config).
1503
1508
  const colConfig = await resolveQueriesConfig(siteRoot, { siteYml })
1504
1509
 
1505
- // `$uuid?` then `$id` `$model`, then sections in Model-declared order. The entity
1506
- // `$uuid` lives in site.yml (back-filled after first sync); absent on first sync.
1510
+ // `$uuid?` then `$id` `$model`, then sections in Model-declared order.
1507
1511
  const doc = {}
1508
1512
  // ⭐ From `sync.json`, keyed by the backend this document is being produced FOR.
1509
1513
  // It was `site.yml::$uuid` — one scalar with no way to say which backend minted it.
@@ -1593,25 +1597,30 @@ export async function emitSiteSyncPackage(siteRoot, opts = {}) {
1593
1597
  // Identity back-fill.
1594
1598
  //
1595
1599
  // The ENTITY `$uuid` (the backend's identity for the whole site-content entity) is
1596
- // back-filled into `site.yml::$uuid` after the first sync. That is the ONLY backend
1597
- // uuid for site-content — its nested pages/sections sync wholesale (collision=force),
1598
- // so there is no per-item uuid round-trip. Per-item identity for the eventual PULL is
1599
- // recovered by in-file `stableId` + content-match (Plan D), not a local id store.
1600
+ // recorded after the first sync to a backend, in that backend's section of
1601
+ // `sync.json`, beside the org the site was created under. Per-item uuids round-trip
1602
+ // too, through the same section's `items` map — see the IDENTITY note in the file
1603
+ // header. ⚠️ Until 2026-09-21 this note put the entity `$uuid` in `site.yml`, where it
1604
+ // lived until 2026-09-20, and said nested items have no per-item uuid round-trip,
1605
+ // which the file header says is not so.
1600
1606
  // ===========================================================================
1601
1607
 
1602
1608
  /**
1603
- * Back-fill the minted site-content entity `$uuid` into `site.yml` (top-level
1604
- * `$uuid`), preserving the file's comments and key order.
1609
+ * Record the minted site-content entity `$uuid` as `backend`'s site, in `sync.json`
1610
+ * (`backends.<origin>.site.uuid`). Until 2026-09-20 it was back-filled into
1611
+ * `site.yml` as a top-level `$uuid`.
1605
1612
  * @param {string} siteRoot
1613
+ * @param {string} backend - the origin of the backend that minted it
1606
1614
  * @param {string} uuid - the entity uuid the backend minted/echoed
1607
- * @returns {boolean} true if site.yml changed
1615
+ * @returns {boolean} true if sync.json changed
1608
1616
  */
1609
1617
  export function writeSiteEntityUuid(siteRoot, backend, uuid) {
1610
1618
  return updateBackendState(siteRoot, backend, { site: { uuid } })
1611
1619
  }
1612
1620
 
1613
1621
  /**
1614
- * Record the org the site was CREATED under (`site.yml::$org`), beside `$uuid`.
1622
+ * Record the org the site was CREATED under — `backend`'s `site.org` in `sync.json`,
1623
+ * beside the site's uuid. (It was `site.yml::$org` until 2026-09-20.)
1615
1624
  *
1616
1625
  * The org is consumed at exactly one moment — the `as_org` on the create that
1617
1626
  * mints `$uuid` — and after that the uuid carries the ownership binding. So this
@@ -1619,15 +1628,11 @@ export function writeSiteEntityUuid(siteRoot, backend, uuid) {
1619
1628
  * this site's storage charged to?"*, which `$uuid` alone cannot answer and which
1620
1629
  * otherwise costs a backend round-trip (or is simply unknowable from the repo).
1621
1630
  *
1622
- * Stored as the BARE handle, never `@handle`: `upsertYamlScalar` writes the value
1623
- * verbatim, and `@` is a reserved YAML indicator, so a plain scalar may not start
1624
- * with one — `$org: @acme` is a parse error. The bare form is also the canonical
1625
- * one everywhere else (`deriveScope` returns it, `createOrg` echoes it as
1626
- * `org.handle`, `validateHandle` validates it); the `@` is display sugar the
1627
- * reader re-adds.
1628
- *
1629
- * Safe to add to `site.yml` because the sync lane is an explicit allowlist
1630
- * (`info.*` above is built key by key), so this never rides the wire.
1631
+ * Stored as the BARE handle, never `@handle` — the canonical form everywhere else
1632
+ * (`deriveScope` returns it, `createOrg` echoes it as `org.handle`,
1633
+ * `validateHandle` validates it); the `@` is display sugar the reader re-adds. In
1634
+ * `site.yml` it was also the only form that parsed: `@` is a reserved YAML
1635
+ * indicator, so `$org: @acme` is an error.
1631
1636
  *
1632
1637
  * ⛔ NOT `deploy.yml`, though that file already holds the bound `backend` and the
1633
1638
  * two look like the same class of fact. Four reasons, and the first is the one that
@@ -1635,9 +1640,9 @@ export function writeSiteEntityUuid(siteRoot, backend, uuid) {
1635
1640
  *
1636
1641
  * 1. CARDINALITY. `deploy.yml` is multi-TARGET and `backend` sits *under* a target,
1637
1642
  * so its shape says "this may vary per target." An org may not: one site has one
1638
- * owning org, fixed at create and preserved on replace. `$org` is a property of
1639
- * `$uuid`, which is singular and lives here — filing it under a target would
1640
- * encode a freedom that does not exist.
1643
+ * owning org, fixed at create and preserved on replace. The org is a property of
1644
+ * the site's uuid, and sits beside it in the same backend's section — filing it
1645
+ * under a target would encode a freedom that does not exist.
1641
1646
  * 2. WHO WRITES. Three paths mint a site (`ensureSiteExists`, the media-less push's
1642
1647
  * content-lane create, and `clone` seeding an existing one) and **none of them
1643
1648
  * write `deploy.yml`** — only `deploy` and `publish` call `recordLastDeploy`. The
@@ -1650,11 +1655,12 @@ export function writeSiteEntityUuid(siteRoot, backend, uuid) {
1650
1655
  * ever being deployed. Ownership does not belong in a record of a deploy that
1651
1656
  * may not have happened.
1652
1657
  *
1653
- * `backend` answers *where this ships*; `$org` answers *whose this is*.
1658
+ * `backend` answers *where this ships*; `site.org` answers *whose this is*.
1654
1659
  *
1655
1660
  * @param {string} siteRoot
1661
+ * @param {string} backend - the origin of the backend the site was created on
1656
1662
  * @param {string} handle - the bare org handle (no leading `@`)
1657
- * @returns {boolean} true if site.yml changed
1663
+ * @returns {boolean} true if sync.json changed
1658
1664
  */
1659
1665
  export function writeSiteOrg(siteRoot, backend, handle) {
1660
1666
  return updateBackendState(siteRoot, backend, { site: { org: handle } })
@@ -290,16 +290,20 @@ export async function emitSyncPackages(siteRoot, opts = {}) {
290
290
  const folder = buildFolderEntity({
291
291
  recordEntities: col.entities,
292
292
  ...(sourceLocale ? { sourceLocale } : {}),
293
- // ⭐ AUTHORED, from `records.yml`. It used to be derived — one branch per
294
- // collection — which made the folder a shadow of a directory layout rather
295
- // than something the author states.
296
- folderNodes: col.folder?.nodes ?? [],
297
- // ⛔ Whether `records.yml` EXISTS — not whether it holds anything. Missing is
298
- // inert; empty is a folder that REMOVES. Compared against the affirmative
299
- // value, never `!== 'missing'`: an absent state would read as declared, and
300
- // that is precisely how a site with no records.yml once emitted a folder that
301
- // would have emptied the live one.
302
- declared: col.recordsState === 'empty' || col.recordsState === 'declared',
293
+ // ⭐ Every record in the directory, at the top of the folder or in the
294
+ // sub-folder `records.yml` places it in — and NOTHING when there is no folder to
295
+ // state. ⛔ Gated here as well as by `declared`, because the builder sends any
296
+ // non-empty tree: a `records.yml` whose folders hold only records that resolved
297
+ // no schema is a tree of empty branches, and sending it would replace the
298
+ // backend's folder with them (measured on the `dynamic` template, 2026-09-21).
299
+ folderNodes: col.sendFolder === true ? (col.folder?.nodes ?? []) : [],
300
+ // ⛔ Whether the file side HAS a folder to state (`records.js::sendsFolder`) —
301
+ // not whether it holds anything. A site with no records directory sends none
302
+ // and leaves the backend's alone; an empty directory sends an empty folder,
303
+ // which REMOVES (the CLI asks first). Compared as `=== true`: an absent value
304
+ // must read as "nothing to state", which is how a site with no records.yml once
305
+ // emitted a folder that would have emptied the backend's.
306
+ declared: col.sendFolder === true,
303
307
  // Placement identity from the folder document a previous push returned.
304
308
  // Absent on a first push — every item is genuinely new then. Its ABSENCE on a
305
309
  // later push is what made `publish` after `push` fail: send-only-changed skips
@@ -308,9 +312,9 @@ export async function emitSyncPackages(siteRoot, opts = {}) {
308
312
  ...(opts.folderItemUuids ? { itemUuids: opts.folderItemUuids } : {}),
309
313
  })
310
314
  // ⚠️ A placement that produced no entity is dropped by the builder rather than
311
- // sent pointing at nothing — but it must still be SAID. It means an entity was
312
- // placed in records.yml and then skipped upstream (a schema that did not
313
- // resolve, most often), and the record is simply absent from the site.
315
+ // sent pointing at nothing — but it must still be SAID. `buildRecordEntities`
316
+ // places only records it produced, so this is a guard on the pairing rather than
317
+ // an expected path.
314
318
  if (folder?.warnings?.length) warnings.push(...folder.warnings)
315
319
 
316
320
  // `queryUuids` — identity for the `queries` section, keyed by query
@@ -404,8 +408,8 @@ export async function emitSyncPackages(siteRoot, opts = {}) {
404
408
  // ⛔ No identity attrs on `info`: it is a Section whose fields the host declares,
405
409
  // and `preview` is a real ASSET_SLOTS slot, so stamping would add
406
410
  // `previewAssetId`/`previewAssetExt` — fields the host refuses. Its bare strings
407
- // are still swapped for their serve URLs, which `assets.json` recognizes on pull
408
- // by fingerprint (asset-map.js → `servedFingerprint`).
411
+ // are still swapped for their serve URLs, which the asset map in `sync.json`
412
+ // recognizes on pull by fingerprint (asset-map.js → `servedFingerprint`).
409
413
  if (siteDoc) rewriteEntityAssets(siteDoc, assetRewrite, assetIds, siteDoc.info)
410
414
  for (const e of col.entities) rewriteEntityAssets(e.document, assetRewrite, assetIds)
411
415
  }
@@ -39,6 +39,7 @@ import { validateAndNormalizeSchema } from './resolve-data-schema.js'
39
39
  // reach it here) even though the implementation moved next to the vocabulary.
40
40
  export { validateItem, isStaticallyCheckable } from '@uniweb/schemas/conform'
41
41
  import { buildSchema } from './schema.js'
42
+ import { resolveRecordsDir } from './site/entity-pool.js'
42
43
  import { toFetchList } from './site/data-fetcher.js'
43
44
  import { resolveFoundationSrcPath } from './utils/foundation-source-root.js'
44
45
  import { collectSiteContent } from './site/content-collector.js'
@@ -94,7 +95,8 @@ export async function validateDataInputs({ siteRoot, foundationPath }) {
94
95
  let byQuery = {}
95
96
  if (config.queries && typeof config.queries === 'object') {
96
97
 
97
- byQuery = await processQueries(siteRoot, config.queries, config.paths?.entities, basePath)
98
+ // Drafts included: a draft is content the site will deliver, so it is checked now.
99
+ byQuery = await processQueries(siteRoot, config.queries, resolveRecordsDir(siteRoot, config.paths).rel, basePath, { includeDrafts: true })
98
100
  }
99
101
 
100
102
  // Declared here rather than beside pass 2's other accumulators because pass 1