uniweb 0.60.0 → 0.62.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.
@@ -12,7 +12,7 @@
12
12
  */
13
13
 
14
14
  import { writeFileSync, readFileSync, mkdirSync, existsSync, unlinkSync } from 'node:fs'
15
- import { join, dirname } from 'node:path'
15
+ import { join, dirname, relative, isAbsolute } from 'node:path'
16
16
  import yaml from 'js-yaml'
17
17
  import { hasUncommittedContent } from '../utils/git.js'
18
18
  import { humanBytes } from '../utils/bytes.js'
@@ -276,17 +276,17 @@ const readMap = (siteDir, backend, key) => {
276
276
  * Drop every cache map that describes a BACKEND site, when this clone is bound to
277
277
  * none. Returns the names dropped (empty when there was nothing to do).
278
278
  *
279
- * `.uniweb/sync-cache.json` holds five maps keyed by *unit path* — item uuids,
280
- * content hashes, the injections those hashes were taken over, entity base versions,
281
- * unit bases — and a unit path (`site.yml`, `pages/about/about.md`) is the same
282
- * string for every site. So the cache does not
283
- * self-invalidate when the site's uuid goes away (a delete in the app, or
284
- * `uniweb forget`): it keeps describing the site this folder used to be.
279
+ * Five maps are keyed by *unit path* — item uuids (`sync.json`'s `items`) and, in
280
+ * `.uniweb/backend-cache.json`, content hashes, the injections those hashes were
281
+ * taken over, entity base versions and unit bases — and a unit path (`site.yml`,
282
+ * `pages/about/about.md`) is the same string for every site. So nothing in them
283
+ * self-invalidates when the site's uuid goes away: they keep describing the site
284
+ * this folder used to be.
285
285
  *
286
- * Which is a state we ACTIVELY TELL PEOPLE TO ENTER. The 404 guidance on a
287
- * uuid-bound lane says to clear `$uuid` to re-publish as a new site — the documented
288
- * recovery after a site is deleted in the app. Following it left the stale maps in
289
- * place, and the next publish failed two ways:
286
+ * Which was a state we ACTIVELY TOLD PEOPLE TO ENTER. Until 2026-09-21 the 404
287
+ * guidance on a uuid-bound lane said to clear `$uuid` to re-publish as a new site —
288
+ * the documented recovery after a site is deleted in the app. Following it left the
289
+ * stale maps in place, and the next publish failed two ways:
290
290
  *
291
291
  * - **item uuids** — the new site's document carried the OLD site's item
292
292
  * identities, and the backend correctly refused: *"item uuid … is already
@@ -298,6 +298,9 @@ const readMap = (siteDir, backend, key) => {
298
298
  * not change** — a partial site, published successfully, with nothing to
299
299
  * indicate it.
300
300
  *
301
+ * The guidance is `uniweb forget --backend <url>` now, which removes the uuid and the
302
+ * maps together, so following it no longer leads here.
303
+ *
301
304
  * Call this BEFORE `ensureSiteExists`, which mints a uuid and would otherwise make
302
305
  * the clone look bound before the check runs.
303
306
  */
@@ -377,13 +380,13 @@ export function clearRemoteSyncStateIfUnbound(siteDir, backend) {
377
380
  return []
378
381
  }
379
382
 
380
- // Two ways the cache can describe a site this clone is not working with:
383
+ // Two ways the maps can describe a site this clone is not working with:
381
384
  //
382
- // 1. UNBOUND — no `$uuid` at all, so there is no backend site for any of it to
383
- // be about. This is the state the documented "clear `$uuid` to re-publish as
384
- // a new site" recovery puts you in.
385
- // 2. BOUND TO A DIFFERENT SITE — `$uuid` names one site and the cache was
386
- // written for another. Reachable in one step: the create mints a uuid and
385
+ // 1. UNBOUND — no site uuid for this backend, so there is no backend site for any
386
+ // of it to be about. This was the state the old "clear `$uuid` to re-publish
387
+ // as a new site" recovery put you in; `uniweb forget` clears the maps with it.
388
+ // 2. BOUND TO A DIFFERENT SITE — this backend's site uuid names one site and the
389
+ // cache was written for another. Reachable in one step: the create mints a uuid and
387
390
  // writes it BEFORE the push, so a push that then fails leaves exactly this.
388
391
  // Without the identity stamp it is invisible, and every later publish fails
389
392
  // the same way with no path out but deleting `.uniweb/` by hand.
@@ -458,10 +461,11 @@ export function readSyncCache(siteDir, backend) {
458
461
  * exact shape `emitSyncPackages` takes back as opts.
459
462
  *
460
463
  * ⛔ **Only the ones nothing else records.** `assetIds` is deliberately NOT banked
461
- * here even though the emit applies it: `assets.json` is COMMITTED project state
462
- * holding exactly that map (local ref → `{id, ext}`), written by the same push, and
463
- * a gitignored second copy would be a second thing to disagree — the reason that
464
- * file itself refuses to hold a serve URL. It also has the worse lifetime of the
464
+ * here even though the emit applies it: this backend's `assets` in `sync.json` is
465
+ * COMMITTED project state holding exactly that map (local ref → `{id, ext}`), written
466
+ * by the same push, and a gitignored second copy would be a second thing to
467
+ * disagree — the reason that map itself holds a fingerprint of a serve URL and never
468
+ * the URL. It also has the worse lifetime of the
465
469
  * two: `clearRemoteSyncState` wipes this cache, and the committed map correctly
466
470
  * survives. ⇒ Bank what a reader cannot re-derive; re-derive the rest.
467
471
  *
@@ -479,7 +483,7 @@ export function readSyncCache(siteDir, backend) {
479
483
  * zero HTTP requests, a property the cross-client flows rely on). ⚠️ Note the serve
480
484
  * URL is REPLAYED, never composed: we re-use the string the host handed us, which is
481
485
  * a different act from reconstructing one, and the distinction is the same one that
482
- * keeps `assets.json` id-only. An asset genuinely new to the site has no recorded
486
+ * keeps the asset map in `sync.json` to ids and a fingerprint. An asset genuinely new to the site has no recorded
483
487
  * mapping and still reads as changed, which is correct.
484
488
  */
485
489
  export function readAppliedInjections(siteDir, backend) {
@@ -494,10 +498,11 @@ export function readAppliedInjections(siteDir, backend) {
494
498
  * leftover from an earlier push describing hashes it no longer matches.
495
499
  *
496
500
  * `assetIds` is dropped rather than stored — see readAppliedInjections: it has a
497
- * committed source of truth in `assets.json`, and the reader re-derives it there.
501
+ * committed source of truth in `sync.json` (this backend's `assets`), and the reader
502
+ * re-derives it there.
498
503
  */
499
504
  export function writeSyncCache(siteDir, backend, hashes, applied) {
500
- const { assetIds: _inAssetsJson, ...bankable } = applied || {}
505
+ const { assetIds: _inSyncJson, ...bankable } = applied || {}
501
506
  updateSyncCache(siteDir, backend, { hashes, applied: bankable })
502
507
  }
503
508
 
@@ -643,14 +648,16 @@ export function writeItemUuids(siteDir, backend, map) {
643
648
  /**
644
649
  * The org this site was created under, as `@handle`, or null.
645
650
  *
646
- * Read back from `site.yml::$org` (stored bare — see `writeSiteOrg`) and re-dressed
647
- * with the `@` the CLI and the wire both use. Callers pass it as `--as-org`'s default
648
- * so an org named once, at create, does not have to be re-typed on every later push.
651
+ * Read back from `backend`'s `site.org` in `sync.json` (stored bare — see
652
+ * `writeSiteOrg`) and re-dressed with the `@` the CLI and the wire both use. Callers
653
+ * pass it as `--as-org`'s default so an org named once, at create, does not have to
654
+ * be re-typed on every later push.
649
655
  *
650
656
  * Deliberately NOT a fallback for the flag: an explicit `--as-org` always wins and
651
657
  * rides verbatim, so this can only add a value where the CLI previously sent none.
652
658
  *
653
659
  * @param {string} siteDir
660
+ * @param {string} backend - the origin whose record to read
654
661
  * @returns {string|null}
655
662
  */
656
663
  export function readSiteOrg(siteDir, backend) {
@@ -697,15 +704,20 @@ function recordSiteOrg(siteDir, backend, asOrg) {
697
704
  * to several orgs could put a company site, and the storage it bills, somewhere
698
705
  * they never named.
699
706
  *
700
- * `register` has refused to guess a scope for the foundation lane since it shipped
701
- * (`deriveScope` → `package.json::uniweb.scope`). This is the same refusal, one
702
- * lane over.
707
+ * `register` resolves a foundation's scope in the same order — explicit, then
708
+ * recorded (`package.json::uniweb.scope`), then asked (`deriveScope`) — with one
709
+ * difference: non-interactive, it PICKS the one org you belong to, else your
710
+ * personal org, else the primary, and says which. This refuses instead, because a
711
+ * site's owner is the one-shot decision above. ⚠️ Until 2026-09-21 this said
712
+ * `register` "has refused to guess a scope", which is wrong for a non-interactive
713
+ * run by anyone with an org.
703
714
  *
704
715
  * Order, and only the last step is new:
705
716
  * 1. `--as-org @org` — explicit, rides verbatim
706
717
  * 2. `--personal` — explicit "no org, I mean it" → sends NO `as_org`
707
- * 3. `site.yml::$org` — recorded at this site's own create
708
- * 4. the site already exists (`$uuid`) → null; ownership is settled, ask nothing
718
+ * 3. the recorded org — `sync.json`, written at this site's own create
719
+ * 4. the site already exists on this backend (a recorded uuid) → null; ownership
720
+ * is settled, ask nothing
709
721
  * 5. otherwise ASK (TTY) or REFUSE (non-interactive)
710
722
  *
711
723
  * ⛔ **`--personal` sends no `as_org`, and is NOT the same as `--as-org @<handle>`.**
@@ -1062,10 +1074,12 @@ async function recordAndDescribeOwner({
1062
1074
  /**
1063
1075
  * Guarantee we have per-item identity before an identity-bearing push.
1064
1076
  *
1065
- * Fires only when the site HAS been pushed before (`$uuid` in site.yml) but the
1066
- * cache is empty — a fresh `git clone`, or a deleted `.uniweb/`. One read of the
1067
- * backend's current document, no file writes, no `uniweb pull`. A first-ever push
1068
- * legitimately has nothing to fetch and is left alone.
1077
+ * Fires only when the site HAS been pushed to this backend before (its uuid is in
1078
+ * `sync.json`) but that backend's item map there is empty. One read of the
1079
+ * backend's current document, recorded into that map — no content file is written,
1080
+ * and no `uniweb pull`. A first-ever push legitimately has nothing to fetch and is
1081
+ * left alone. (The map sat in the gitignored cache until 2026-09-20, so a fresh
1082
+ * `git clone` or a deleted `.uniweb/` was what emptied it; it is committed now.)
1069
1083
  *
1070
1084
  * @returns {Promise<Object<string,string>>} the map (possibly empty)
1071
1085
  */
@@ -1129,12 +1143,11 @@ export async function probeUnpushed(siteDir, { backend = null, sendAll = false }
1129
1143
  // for a cache written before this was banked (and for a never-pushed site),
1130
1144
  // which is the pre-fix behaviour and self-heals on the next push. It can never
1131
1145
  // point at the wrong document: it is written with the hashes it belongs to.
1132
- // · RE-DERIVED — asset identity, from the COMMITTED `assets.json` the same push
1133
- // wrote. Reading the live file rather than a snapshot is what makes a moved
1134
- // map (a teammate's push, a pull) read as changed instead of matching a copy
1135
- // of itself.
1136
- // · RECORDED — the site's own org, from `site.yml::$org`, written by the push
1137
- // that banked these hashes.
1146
+ // · RE-DERIVED — asset identity, from this backend's COMMITTED asset map in
1147
+ // `sync.json`, which the same push wrote. Reading the live file rather than a
1148
+ // snapshot is what makes a moved map (a teammate's push, a pull) read as
1149
+ // changed instead of matching a copy of itself.
1150
+ // · RECORDED — the site's own org, from `sync.json` (this backend's `site.org`).
1138
1151
  //
1139
1152
  // ⛔ THE ORG IS NOT OPTIONAL HERE, AND OMITTING IT WAS SILENT. It is what resolves
1140
1153
  // a foundation-relative `@/member` into the `@org/member` the push shipped and
@@ -1164,12 +1177,17 @@ export async function probeUnpushed(siteDir, { backend = null, sendAll = false }
1164
1177
  *
1165
1178
  * · BANKED the injections the push applied before hashing — serve URLs and
1166
1179
  * the pinned foundation ref, which only a round trip produces.
1167
- * · RE-DERIVED asset identity, from the COMMITTED `assets.json`, so a moved map
1168
- * reads as changed rather than matching a copy of itself.
1180
+ * · RE-DERIVED asset identity, from this backend's COMMITTED asset map in
1181
+ * `sync.json`, so a moved map reads as changed rather than matching
1182
+ * a copy of itself.
1169
1183
  * · RECORDED the site's org, which resolves a foundation-relative `@/x` into
1170
1184
  * the `@org/x` the push keyed its hashes by, and the collection
1171
- * identity a push stamps — so `status` hashes the same document a
1172
- * push would send rather than one missing a section's `$uuid`s.
1185
+ * identity a push stamps, so the document is the one a push builds.
1186
+ * ⚠️ Identity is parity, not correctness: `entityContentHash` strips
1187
+ * `$`-sigils, so a query's `$uuid` never moves a hash (measured
1188
+ * 2026-09-21). This read no backend from 2026-09-20 to 2026-09-21 —
1189
+ * an empty map — which is why no `status` ever showed it, and why this
1190
+ * bullet was then misread as naming a live defect.
1173
1191
  *
1174
1192
  * Offline by design — measured at zero HTTP requests, a property the cross-client
1175
1193
  * flows rely on.
@@ -1184,7 +1202,7 @@ async function comparisonEmit(
1184
1202
  // honest rather than a default — no backend, no known ids.
1185
1203
  const assetIds = backend ? readBackendState(siteDir, backend).assets || {} : {}
1186
1204
  const org = readSiteOrg(siteDir, backend)
1187
- const queryUuids = readQueryUuids(siteDir)
1205
+ const queryUuids = readQueryUuids(siteDir, backend)
1188
1206
  return emitSyncPackages(siteDir, {
1189
1207
  backend,
1190
1208
  resolveModel: makeModelResolver({ client: null, offline: true }),
@@ -1252,6 +1270,8 @@ export async function pushSyncPackages({
1252
1270
 
1253
1271
  const wrote = []
1254
1272
  let finalizedTotal = 0
1273
+ // Records sent as drafts that the backend stored enabled — reported at the end.
1274
+ let draftsNotKept = []
1255
1275
 
1256
1276
  // POST one lane via the client and parse the JSON response. `doRequest` is a thunk
1257
1277
  // returning the client's Response promise (so the "Pushing …" line prints before the
@@ -1426,12 +1446,17 @@ export async function pushSyncPackages({
1426
1446
  // this branch named only the deletion and went straight to "clear `$uuid`" — and
1427
1447
  // that advice, followed for the OTHER cause, destroys a live binding: the site is
1428
1448
  // fine, you are simply pointed at the wrong backend, and clearing the uuid orphans
1429
- // it. (A wrong-BACKEND binding can no longer occur — identity is read per
1430
- // origin from sync.json — so what reaches here is a site deleted on this backend.)
1431
- // Before any request goes
1432
- // out, so reaching here usually does mean a deletion; "usually" is not "always"
1433
- // (a project predating `$backend` records no scope to check), which is why the
1434
- // cheap cause is still named before the destructive fix.
1449
+ // it. The uuid sent here is the one this project recorded for THIS origin
1450
+ // (sync.json), so another backend's uuid cannot reach it; what does is a site
1451
+ // this origin no longer has — deleted there, or a backend rebuilt at the same
1452
+ // address. Being logged in to a backend you did not mean is still the cheaper
1453
+ // thing to rule out, so it still comes first. (Until 2026-09-21 this paragraph
1454
+ // leaned on a scope check against `site.yml::$backend`; both were deleted on
1455
+ // 2026-09-20.)
1456
+ //
1457
+ // ⛔ The fix for a deletion was "clear `$uuid` from site.yml" until 2026-09-21 — a
1458
+ // key that left site.yml the day before, so the advice named nothing to clear.
1459
+ // The binding is this backend's section of sync.json, and `forget` drops it.
1435
1460
  note(
1436
1461
  `The backend at ${client.origin} has no site with uuid ${boundUuid}.`
1437
1462
  )
@@ -1442,7 +1467,7 @@ export async function pushSyncPackages({
1442
1467
  ` wrong backend → uniweb login --backend <the right one> (nothing is lost)`
1443
1468
  )
1444
1469
  note(
1445
- ` deleted in the app → clearing \`$uuid\` from site.yml re-publishes it as a NEW site`
1470
+ ` deleted there → uniweb forget --backend ${client.origin}, then push again: it creates a NEW site`
1446
1471
  )
1447
1472
  note('Deleting this folder removes only your local copy, either way.')
1448
1473
  } else if (res.status === 409) {
@@ -1451,8 +1476,8 @@ export async function pushSyncPackages({
1451
1476
  note(
1452
1477
  "This site's record structure is already established on the backend and can't be changed " +
1453
1478
  'in place — e.g. adding or removing a schema-backed query, or switching one between ' +
1454
- 'static (data-bundle) and schema-backed delivery. To change it: delete the deployed site and ' +
1455
- 'redeploy, or clear `$uuid` in site.yml to deploy a fresh one.'
1479
+ 'static (data-bundle) and schema-backed delivery. To change it, push it as a new site: ' +
1480
+ `uniweb forget --backend ${client.origin}, then push again (the old site stays until it is deleted).`
1456
1481
  )
1457
1482
  }
1458
1483
  if (body) note(body.slice(0, 800))
@@ -1487,8 +1512,8 @@ export async function pushSyncPackages({
1487
1512
 
1488
1513
  // Lane 1 — site-content (the site is born here; it must exist before its folder). A
1489
1514
  // known site uuid → UPDATE by uuid; none → CREATE (the backend mints + adopts the site
1490
- // and returns its uuid, which we record into site.yml). `boundSiteUuid` carries the
1491
- // minted/known uuid forward to key the folder push.
1515
+ // and returns its uuid, which we record in sync.json, under this backend).
1516
+ // `boundSiteUuid` carries the minted/known uuid forward to key the folder push.
1492
1517
  let boundSiteUuid = siteContentUuid
1493
1518
  // Post-write tokens harvested from every lane, persisted once at the end so a
1494
1519
  // partial push (lane 1 ok, lane 2 refused) still banks what actually landed —
@@ -1556,8 +1581,8 @@ export async function pushSyncPackages({
1556
1581
  // ⭐ BANK COLLECTION-DECLARATION IDENTITY, the sibling of the folder's
1557
1582
  // placements below. These items have no file to back-fill into — they all
1558
1583
  // come from one `collections/collections.yml` — so the only place their
1559
- // `$uuid` can live is the cache, keyed by the name the backend enforces
1560
- // unique. Without it every push after the first re-sends the whole
1584
+ // `$uuid` can live is `sync.json` (this backend's `queries` map), keyed by the
1585
+ // name the backend enforces unique. Without it every push after the first re-sends the whole
1561
1586
  // `collections` section uuid-less and is refused.
1562
1587
  if (siteFinalizedDoc) {
1563
1588
  const recordIds = collectQueryUuids(siteFinalizedDoc)
@@ -1581,11 +1606,11 @@ export async function pushSyncPackages({
1581
1606
  writeSiteEntityUuid(siteDir, client.origin, minted)
1582
1607
  updateSyncCache(siteDir, client.origin, { siteUuid: minted })
1583
1608
  boundSiteUuid = minted
1584
- wrote.push('recorded site $uuid in site.yml')
1609
+ wrote.push('recorded the site in sync.json')
1585
1610
  // The OTHER create path (a media-less push never reaches `ensureSiteExists`,
1586
1611
  // which is gated on the site having local media). Both mint a site, so both
1587
- // owe the same record — recording it in only one place would make `$org`
1588
- // present or absent depending on whether the site happens to have images.
1612
+ // owe the same record — recording it in only one place would make the recorded
1613
+ // owner present or absent depending on whether the site happens to have images.
1589
1614
  // The backend echoes `org`/`hosts_free` top-level here too, beside `report`
1590
1615
  // and `site` (NOT beside `finalized`, which lives at report.finalized).
1591
1616
  const createdOrg = await recordAndDescribeOwner({
@@ -1596,7 +1621,7 @@ export async function pushSyncPackages({
1596
1621
  note
1597
1622
  })
1598
1623
  if (createdOrg)
1599
- wrote.push(`recorded site $org (${createdOrg}) in site.yml`)
1624
+ wrote.push(`recorded its owner (${createdOrg}) in sync.json`)
1600
1625
  const createdFinalized = extractFinalized(payload)
1601
1626
  harvest(createdFinalized)
1602
1627
  siteFinalizedDoc = createdFinalized?.[0]?.document || null
@@ -1635,6 +1660,7 @@ export async function pushSyncPackages({
1635
1660
  }
1636
1661
  for (const w of bf.warnings) note(`! ${w}`)
1637
1662
  for (const d of bf.deferred) note(`↷ ${d.id ?? `#${d.index}`}: ${d.reason}`)
1663
+ draftsNotKept = bf.notKeptAsDrafts || []
1638
1664
  if (bf.updated.length)
1639
1665
  wrote.push(`wrote ${bf.updated.length} record file(s)`)
1640
1666
  // ⭐ BANK THE FOLDER'S PLACEMENT IDENTITY. The records carry their own `$uuid`
@@ -1700,5 +1726,21 @@ export async function pushSyncPackages({
1700
1726
  )
1701
1727
  }
1702
1728
  }
1729
+ // ⛔ A DRAFT THE BACKEND STORED ENABLED FAILS THE PUSH, and only here, after every
1730
+ // piece of sync state above is banked: the push itself happened, and the next one must
1731
+ // see it. What must not happen is a publish that goes live on top of it, which is why
1732
+ // the exit code carries it (publish stops on anything but 0). The files keep their
1733
+ // `draft: true` (see backfillEntityUuids).
1734
+ if (draftsNotKept.length) {
1735
+ const one = draftsNotKept.length === 1
1736
+ const shown = draftsNotKept.map((p) => (isAbsolute(p) ? relative(siteDir, p) : p))
1737
+ error(
1738
+ `This backend did not keep ${one ? 'a record' : `${shown.length} records`} as a draft, ` +
1739
+ `so ${one ? 'it' : 'they'} would be delivered once the site is published:\n ` +
1740
+ shown.join('\n ') +
1741
+ '\n The backend does not support `draft: true` yet. Once it does, push again with --all.'
1742
+ )
1743
+ return { exitCode: 1, boundSiteUuid, finalizedTotal, wrote }
1744
+ }
1703
1745
  return { exitCode: 0, boundSiteUuid, finalizedTotal, wrote }
1704
1746
  }
@@ -16,7 +16,7 @@ import { readFile, writeFile, mkdir } from 'node:fs/promises'
16
16
  import { join, relative, basename } from 'node:path'
17
17
  import prompts from 'prompts'
18
18
  import yaml from 'js-yaml'
19
- import { resolveFoundationSrcPath } from '@uniweb/build'
19
+ import { resolveFoundationSrcPath, readFoundationName } from '@uniweb/build'
20
20
  import {
21
21
  scaffoldFoundation,
22
22
  scaffoldSite,
@@ -42,6 +42,10 @@ import {
42
42
  resolveUniqueName
43
43
  } from '../utils/names.js'
44
44
  import { findWorkspaceRoot } from '../utils/workspace.js'
45
+ import {
46
+ normalizeFoundationName,
47
+ ensureFoundationName
48
+ } from '../utils/foundation-name.js'
45
49
  import {
46
50
  detectPackageManager,
47
51
  detectWorkspacePm,
@@ -366,6 +370,12 @@ async function addFoundation(rootDir, projectName, opts, pm = 'pnpm') {
366
370
  let { packageName } = placement
367
371
  const fullPath = join(rootDir, relativePath)
368
372
 
373
+ // The foundation's own name — what it registers as, written to main.js — is
374
+ // the name asked for, before any workspace suffix below; with none (`src`),
375
+ // the project's.
376
+ const askedName =
377
+ normalizeFoundationName(packageName) || normalizeFoundationName(projectName)
378
+
369
379
  // Validate the derived package name (format + reserved-name check). The
370
380
  // auto-derived `src` default is grandfathered in (`src` IS reserved
371
381
  // but `src` is the convention for "the package that lives in src/").
@@ -435,10 +445,15 @@ async function addFoundation(rootDir, projectName, opts, pm = 'pnpm') {
435
445
  }
436
446
 
437
447
  // Scaffold
448
+ const registryName = await unclaimedFoundationName(rootDir, [
449
+ askedName,
450
+ normalizeFoundationName(packageName)
451
+ ])
438
452
  await scaffoldFoundation(
439
453
  fullPath,
440
454
  {
441
455
  name: packageName,
456
+ registryName,
442
457
  projectName,
443
458
  isExtension: false
444
459
  },
@@ -447,9 +462,11 @@ async function addFoundation(rootDir, projectName, opts, pm = 'pnpm') {
447
462
  }
448
463
  )
449
464
 
450
- // Apply template content if --from specified
465
+ // Apply template content if --from specified. Its main.js replaces the
466
+ // scaffolded one, so the name is given again unless the template names it.
451
467
  if (opts.from) {
452
468
  await applyFromTemplate(opts.from, 'foundation', fullPath, projectName)
469
+ ensureFoundationName(fullPath, registryName)
453
470
  }
454
471
 
455
472
  // Register the package in pnpm-workspace.yaml — by exact path, not by glob.
@@ -685,11 +702,18 @@ async function addExtension(rootDir, projectName, opts, pm = 'pnpm') {
685
702
  process.exit(1)
686
703
  }
687
704
 
688
- // Scaffold foundation with extension flag
705
+ // Scaffold foundation with extension flag. Its name — what it registers as —
706
+ // is the one asked for, not the package's suffixed one, unless a foundation in
707
+ // the workspace already registers as that.
708
+ const registryName = await unclaimedFoundationName(rootDir, [
709
+ normalizeFoundationName(name),
710
+ normalizeFoundationName(extensionPackageName)
711
+ ])
689
712
  await scaffoldFoundation(
690
713
  fullPath,
691
714
  {
692
715
  name: extensionPackageName,
716
+ registryName,
693
717
  projectName,
694
718
  isExtension: true
695
719
  },
@@ -701,6 +725,7 @@ async function addExtension(rootDir, projectName, opts, pm = 'pnpm') {
701
725
  // Apply template content if --from specified
702
726
  if (opts.from) {
703
727
  await applyFromTemplate(opts.from, 'extension', fullPath, projectName)
728
+ ensureFoundationName(fullPath, registryName)
704
729
  }
705
730
 
706
731
  // Update workspace globs
@@ -848,12 +873,18 @@ async function addProject(rootDir, projectName, opts, pm = 'pnpm') {
848
873
 
849
874
  const progressCb = (msg) => info(` ${msg}`)
850
875
 
851
- // Scaffold foundation (folder: src/, package name: <project>-src)
876
+ // Scaffold foundation (folder: src/, package name: <project>-src, name — what
877
+ // it registers as — <project>)
852
878
  info(`Creating foundation: ${foundationPkgName}...`)
879
+ const registryName = await unclaimedFoundationName(rootDir, [
880
+ normalizeFoundationName(name),
881
+ normalizeFoundationName(foundationPkgName)
882
+ ])
853
883
  await scaffoldFoundation(
854
884
  join(projectDir, 'src'),
855
885
  {
856
886
  name: foundationPkgName,
887
+ registryName,
857
888
  projectName,
858
889
  isExtension: false
859
890
  },
@@ -882,6 +913,7 @@ async function addProject(rootDir, projectName, opts, pm = 'pnpm') {
882
913
  join(projectDir, 'src'),
883
914
  projectName
884
915
  )
916
+ ensureFoundationName(join(projectDir, 'src'), registryName)
885
917
  await applyFromTemplate(
886
918
  opts.from,
887
919
  'site',
@@ -987,6 +1019,32 @@ function computeFoundationPath(sitePath, foundationPath) {
987
1019
  return `file:${rel}`
988
1020
  }
989
1021
 
1022
+ /**
1023
+ * The first of `candidates` that no foundation or extension in the workspace
1024
+ * registers as — null when each is taken or missing, and `uniweb register` asks.
1025
+ *
1026
+ * A package name is kept unique by suffixing (`ui-ext`), but what a package
1027
+ * REGISTERS as is its main.js name: two foundations named `ui` in one workspace
1028
+ * would both register `@org/ui`, and the second would land on the first's
1029
+ * version. Compared without scope, so `@acme/ui` counts as `ui`.
1030
+ *
1031
+ * @param {string} rootDir
1032
+ * @param {Array<string|null>} candidates
1033
+ * @returns {Promise<string|null>}
1034
+ */
1035
+ async function unclaimedFoundationName(rootDir, candidates) {
1036
+ const taken = new Set()
1037
+ for (const f of await discoverFoundations(rootDir)) {
1038
+ try {
1039
+ const { name } = await readFoundationName(join(rootDir, f.path))
1040
+ if (name) taken.add(name.replace(/^@[^/]+\//, ''))
1041
+ } catch {
1042
+ // An unreadable main.js — the build reports it; it claims no name here.
1043
+ }
1044
+ }
1045
+ return candidates.find((c) => c && !taken.has(c)) || null
1046
+ }
1047
+
990
1048
  /**
991
1049
  * Apply content from a template to a scaffolded package
992
1050
  *
@@ -399,7 +399,7 @@ export async function clone(args = [], deps = {}) {
399
399
  // bytes on disk, not of what the scaffolder first wrote.
400
400
  recordWritten(
401
401
  siteDir,
402
- ['site.yml', 'theme.yml', 'head.html', 'queries.yml', 'records.yml']
402
+ ['site.yml', 'theme.yml', 'head.html', 'queries.yml', join('records', 'folder.yml')]
403
403
  .map((f) => join(siteDir, f))
404
404
  .filter((p) => existsSync(p))
405
405
  )
@@ -166,9 +166,9 @@ function loadSiteYml(dir) {
166
166
  /**
167
167
  * Diagnose the compiled-collection output directory.
168
168
  *
169
- * `public/<DATA_DIR>/` holds what the build compiles from `entities/`, and
170
- * nothing else — `entities/` + `records.yml` is the only supported way to provide
171
- * structured data. Two consequences, both checked here:
169
+ * `public/<DATA_DIR>/` holds what the build compiles from `records/`, and
170
+ * nothing else — `records/` is the only supported way to provide structured
171
+ * data. Two consequences, both checked here:
172
172
  *
173
173
  * 1. **The mapping is a bijection.** Every entry should be backed by a
174
174
  * declared collection. An entry that isn't is stale — most often a
@@ -969,7 +969,7 @@ function checkGeneratedDataDir({ sitePath, siteName, siteYml, issues, shouldFix,
969
969
  const dataDir = join(sitePath, 'public', DATA_DIR)
970
970
  // ⚠️ THE QUERIES, not the pool. `public/<DATA_DIR>/x.json` is a query's
971
971
  // MATERIALIZATION — one file per named query — so the bijection is with the
972
- // declared queries, never with the schema folders under `entities/`.
972
+ // declared queries, never with the schema folders under `records/`.
973
973
  //
974
974
  // ⛔ BOTH HOMES, or this reports every compiled file as an orphan. A site that
975
975
  // keeps its queries in `queries.yml` has none in `site.yml`, and reading one
@@ -1045,7 +1045,7 @@ function checkGeneratedDataDir({ sitePath, siteName, siteYml, issues, shouldFix,
1045
1045
  const body = existing === null ? '' : existing.replace(/\n*$/, '\n')
1046
1046
  writeFileSync(
1047
1047
  gitignorePath,
1048
- `${body}\n# Compiled query results — generated from entities/ + queries.yml\n${rule}\n`
1048
+ `${body}\n# Compiled query results — generated from records/ + queries.yml\n${rule}\n`
1049
1049
  )
1050
1050
  fixed(`added ${rule} to ${gitignorePath}`)
1051
1051
  if (existsSync(dataDir)) {
@@ -1418,8 +1418,7 @@ export async function doctor(args = []) {
1418
1418
  }
1419
1419
 
1420
1420
  // `public/<DATA_DIR>/` is the build's output directory and nothing else —
1421
- // `entities/` + `records.yml` is the only supported way to provide structured
1422
- // data. That makes the mapping a bijection: every entry there should be backed
1421
+ // `records/` is the only supported way to provide structured data. That makes the mapping a bijection: every entry there should be backed
1423
1422
  // by a declared QUERY, so anything else is stale, and identifiable.
1424
1423
  //
1425
1424
  // It matters because the directory is written into the source tree rather
@@ -319,7 +319,7 @@ async function runExtract(siteRoot, config, args) {
319
319
  const dataDir = join(siteRoot, 'public', DATA_DIR)
320
320
  if (!existsSync(dataDir)) {
321
321
  if (recordsOnly) {
322
- error('No records found. Add entities under entities/ and list them in records.yml.')
322
+ error('No records found. Every file in records/<schema>/ is a record — add some there.')
323
323
  process.exit(1)
324
324
  }
325
325
  log(`${colors.dim}No records found in public/data/.${colors.reset}`)
@@ -1022,11 +1022,11 @@ async function runAudit(siteRoot, config, args) {
1022
1022
  * Usage:
1023
1023
  * uniweb i18n init-freeform es pages/about hero
1024
1024
  * uniweb i18n init-freeform es page-ids/installation intro
1025
- * uniweb i18n init-freeform es entities/article getting-started
1025
+ * uniweb i18n init-freeform es records/article getting-started
1026
1026
  */
1027
1027
  async function runInitFreeform(siteRoot, config, args) {
1028
1028
  const locale = args[0]
1029
- const pathType = args[1] // pages/about, page-ids/installation, entities/article
1029
+ const pathType = args[1] // pages/about, page-ids/installation, records/article
1030
1030
  const sectionId = args[2] // hero, intro, getting-started
1031
1031
 
1032
1032
  if (!locale || !pathType || !sectionId) {
@@ -1035,11 +1035,20 @@ async function runInitFreeform(siteRoot, config, args) {
1035
1035
  log(' uniweb i18n init-freeform es pages/about hero')
1036
1036
  log(' uniweb i18n init-freeform es page-ids/installation intro')
1037
1037
  log(
1038
- ` uniweb i18n init-freeform es entities/article getting-started${colors.reset}`
1038
+ ` uniweb i18n init-freeform es records/article getting-started${colors.reset}`
1039
1039
  )
1040
1040
  process.exit(1)
1041
1041
  }
1042
1042
 
1043
+ // A path names a page by route, a page by id, or a record by its schema folder —
1044
+ // and nothing else. Said up front: past this point an unknown kind would only
1045
+ // surface as "source content not found", which names the wrong cause.
1046
+ if (!/^(pages|page-ids|records)\//.test(pathType)) {
1047
+ error(`Unknown kind of path: ${pathType}`)
1048
+ log(`${colors.dim}A path starts with pages/, page-ids/ or records/ (a record's schema folder).${colors.reset}`)
1049
+ process.exit(1)
1050
+ }
1051
+
1043
1052
  const localesPath = join(siteRoot, config.localesDir)
1044
1053
  const freeformDir = join(localesPath, 'freeform', locale)
1045
1054
 
@@ -1091,15 +1100,15 @@ async function runInitFreeform(siteRoot, config, args) {
1091
1100
  if (sourceContent) break
1092
1101
  }
1093
1102
  }
1094
- } else if (pathType.startsWith('entities/')) {
1103
+ } else if (pathType.startsWith('records/')) {
1095
1104
  // ⛔ ADDRESSED BY THE RECORD, matching where the loader reads. The freeform
1096
- // tree is `entities/<schema dirs>/<slug>.md`; this used to take
1105
+ // tree is `records/<schema dirs>/<slug>.md`; this used to take
1097
1106
  // `collections/<query>` and write a path nothing read — the loader moved
1098
1107
  // and this did not.
1099
1108
  //
1100
1109
  // The record's CONTENT still lives in a query's materialization, so the
1101
1110
  // query that covers this schema is resolved rather than named.
1102
- const poolDirs = pathType.replace('entities/', '')
1111
+ const poolDirs = pathType.slice('records/'.length)
1103
1112
  const { resolveQueriesConfig, poolDirsForSchema } = await import('@uniweb/build/uwx')
1104
1113
  let queryName = null
1105
1114
  try {
@@ -1509,7 +1518,7 @@ async function runPrune(siteRoot, config, args) {
1509
1518
  // reads one (`freeformSourceIndex`, the build's own index): every path a section's
1510
1519
  // translation is read from — ⛔ until 2026-09-14 only `page-ids/<id>/…` on a page with
1511
1520
  // an `id`, so a route-addressed translation the page rendered was deleted — and only
1512
- // what the built content can see: never a record's translation (`entities/…`), and
1521
+ // what the built content can see: never a record's translation (`records/…`), and
1513
1522
  // never one for a page whose sections the content does not carry.
1514
1523
  const { validPaths, canJudge } = freeformSourceIndex(siteContent)
1515
1524
 
@@ -1657,7 +1666,7 @@ ${colors.bright}File Structure:${colors.reset}
1657
1666
  .manifest.json Staleness tracking
1658
1667
  pages/about/hero.md Translated content for /about page, hero section
1659
1668
  page-ids/install/intro.md Translated content by page ID
1660
- entities/article/getting-started.md
1669
+ records/article/getting-started.md
1661
1670
 
1662
1671
  ${colors.bright}Examples:${colors.reset}
1663
1672
  ${colors.dim}# Hash-based workflow${colors.reset}
@@ -1677,7 +1686,7 @@ ${colors.bright}Examples:${colors.reset}
1677
1686
  ${colors.dim}# Free-form workflow (complete section replacement)${colors.reset}
1678
1687
  uniweb i18n init-freeform es pages/about hero
1679
1688
  uniweb i18n init-freeform es page-ids/installation intro
1680
- uniweb i18n init-freeform es entities/article getting-started
1689
+ uniweb i18n init-freeform es records/article getting-started
1681
1690
  uniweb i18n status --freeform # Show free-form translation status
1682
1691
  uniweb i18n update-hash es --all-stale # Update hashes after review
1683
1692
  uniweb i18n move pages/docs/setup pages/getting-started