uniweb 0.60.0 → 0.61.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "uniweb",
3
- "version": "0.60.0",
3
+ "version": "0.61.0",
4
4
  "description": "Create structured Vite + React sites with content/code separation",
5
5
  "type": "module",
6
6
  "bin": {
@@ -41,15 +41,15 @@
41
41
  "js-yaml": "^4.1.0",
42
42
  "prompts": "^2.4.2",
43
43
  "tar": "^7.0.0",
44
- "@uniweb/content-writer": "^0.3.4",
45
- "@uniweb/core": "^0.29.6",
46
- "@uniweb/kit": "^0.19.3",
47
- "@uniweb/runtime": "^0.27.2",
44
+ "@uniweb/core": "^0.29.7",
45
+ "@uniweb/kit": "^0.19.4",
48
46
  "@uniweb/schemas": "^0.3.3",
49
- "@uniweb/semantic-parser": "^1.4.1"
47
+ "@uniweb/semantic-parser": "^1.4.1",
48
+ "@uniweb/runtime": "^0.27.3",
49
+ "@uniweb/content-writer": "^0.3.4"
50
50
  },
51
51
  "peerDependencies": {
52
- "@uniweb/build": "^0.56.0",
52
+ "@uniweb/build": "^0.57.0",
53
53
  "@uniweb/content-reader": "^1.2.5",
54
54
  "@uniweb/semantic-parser": "^1.4.1"
55
55
  },
@@ -845,26 +845,26 @@ It is a small database, not a page tree. Three things, deliberately separate:
845
845
  ```
846
846
  site/
847
847
  ├── pages/
848
- ├── entities/ # your stored things. The folder names their data schema
849
- │ ├── article/
848
+ ├── records/ # your site's records — every file here is one
849
+ │ ├── article/ # the folder names their data schema
850
850
  │ │ ├── getting-started.md
851
851
  │ │ └── design-tips.md
852
852
  │ └── person/
853
853
  │ └── alice.yml
854
- ├── records.yml # what is PUBLISHED
854
+ ├── records.yml # optional: sorts records into folders
855
855
  ├── queries.yml # how content is REACHED
856
856
  └── site.yml
857
857
  ```
858
858
 
859
- **`entities/{schema}/` — the pool.** The folder names the data schema and nothing else:
859
+ **`records/{schema}/` — the site's records.** Putting a file here is what makes it a record; nothing else lists it. The folder names the data schema and nothing else:
860
860
 
861
861
  | on disk | schema |
862
862
  |---|---|
863
- | `entities/article/…` | `@/article` — your foundation's own |
864
- | `entities/std/person/…` | `@std/person` — the shared standard set |
865
- | `entities/acme/project/…` | `@acme/project` — an org's |
863
+ | `records/article/…` | `@/article` — your foundation's own |
864
+ | `records/std/person/…` | `@std/person` — the shared standard set |
865
+ | `records/acme/project/…` | `@acme/project` — an org's |
866
866
 
867
- Keep those folders flat: `entities/article/design-tips.md` works; `entities/article/2025/design-tips.md` is read as the `2025` schema of an `article` org, which is not what you meant. Organise in `records.yml` instead.
867
+ Keep those folders flat: `records/article/design-tips.md` works; `records/article/2025/design-tips.md` is read as the `2025` schema of an `article` org, which is not what you meant. Organise in `records.yml` instead. A file whose name starts with `_` is not a record — somewhere to keep work in progress.
868
868
 
869
869
  **Four formats, one shape.** All of these produce the same records at runtime:
870
870
 
@@ -877,32 +877,22 @@ Keep those folders flat: `entities/article/design-tips.md` works; `entities/arti
877
877
 
878
878
  **One record per file, or many.** A single mapping at the top of a file makes one record and the filename stem becomes its `slug`. A top-level array (YAML/JSON) or a multi-entry `.bib` makes many, each carrying its own `slug`. You can mix both in one folder — an exported `refs.bib` beside a hand-written `extras.yml`.
879
879
 
880
- Item frontmatter conventionally uses `title`, `date`, `tags`, `image`, `description`, `published`, `author` — plus any fields your content needs (`price`, `role`, `order`). Images can sit beside the item file and be referenced with `./`. `published: false` hides an item without deleting it; items with no `published` field are included.
880
+ Item frontmatter conventionally uses `title`, `date`, `tags`, `image`, `description`, `author` — plus any fields your content needs (`price`, `role`, `order`). Images can sit beside the item file and be referenced with `./`. **`draft: true` keeps a record off the live site without deleting it**: it is still a record, in `records/`, and `pnpm dev` shows it so you can preview it, but a build for hosting leaves it out — and `uniweb push` stops at it, naming it. (`published: false`, an older spelling, is refused.)
881
881
 
882
- **`records.yml` — listing an entity is what publishes it.** A file in `entities/` exists; listing it here makes it a record. Anything you leave out is a draft — no flag to set. **The common case is three lines:**
882
+ **`records.yml` — optional, and only for folders.** Every record sits at the top of the site's records folder unless `records.yml` places it in a sub-folder. Add one only when a query needs to ask for a *slice* of the records rather than all of them — most sites never do:
883
883
 
884
884
  ```yaml
885
- - article/*.md
886
- - person/*.yml
887
- ```
888
-
889
- A bare string is a path under `entities/`, naming one file or matching many.
890
-
891
- ⚠️ **An empty `records.yml` is not the same as having none.** With no file, a build publishes every entity in `entities/`, and `uniweb push` leaves the published set alone. An empty file means "nothing is published": a build publishes none, and a push REMOVES what is published. The CLI asks before it does that.
892
-
893
- **Structure is for querying, not for navigation.** Add a `folder:` only when a query needs to ask for a *slice* of the pool rather than all of it — most sites never do:
894
-
895
- ```yaml
896
- - article/2026-*.md
897
885
  - folder: archive
898
886
  label: The Archive
899
887
  records:
900
888
  - article/2025-*.md
901
889
  ```
902
890
 
903
- A query reads one branch with `scope: archive` — the folder and everything inside it (the older `where: { path: { under: … } }` is refused by the build). An entity belongs to one folder; if you want a computed subset, that is a query, not a second placement.
891
+ A path under a folder is relative to `records/`, naming one file or matching many. A query reads one folder with `scope: archive` — the folder and everything inside it. A record sits in one folder; if you want a computed subset, that is a query, not a second placement. Structure is for querying, not for navigation — and `records.yml` never lists a record at the top level (the build refuses it: every file in `records/` is a record already).
892
+
893
+ ⚠️ **With a backend, `records/` is what `uniweb push` sends** — every record in it. A site with no `records/` at all leaves the backend's records alone; an *empty* `records/` removes them, and the CLI asks before it does. Records pushed to a backend are served once the site is published.
904
894
 
905
- **`queries.yml` — how content is reached.** A bare map of name → query. A query names a schema and the published records of that schema are its rows:
895
+ **`queries.yml` — how content is reached.** A bare map of name → query. A query names a schema and the site's records of that schema are its rows:
906
896
 
907
897
  ```yaml
908
898
  recent:
@@ -912,7 +902,7 @@ recent:
912
902
 
913
903
  team:
914
904
  schema: '@std/person'
915
- where: { published: { ne: false } } # a predicate — see authoring/predicates.md
905
+ where: { active: true } # a predicate — see authoring/predicates.md
916
906
  ```
917
907
 
918
908
  You can keep the same declarations under `queries:` in `site.yml` instead, if you would rather have one file.
@@ -944,7 +934,7 @@ pages/blog/
944
934
  └── article.md # just `type: Article` — the record arrives automatically
945
935
  ```
946
936
 
947
- `entities/article/design-tips.md` becomes `/blog/design-tips`. The section inside `[slug]/` needs no special markdown — the matched record is delivered to it. Generated pages are excluded from navigation menus.
937
+ `records/article/design-tips.md` becomes `/blog/design-tips`. The section inside `[slug]/` needs no special markdown — the matched record is delivered to it. Generated pages are excluded from navigation menus.
948
938
 
949
939
  **Link a card with `item.$route` — never compose the URL.** Every record a query delivers carries `$route`, the URL of the parametric page whose route query is that query (`/blog/design-tips`), wherever the list appears — the query's own page, the homepage, a sidebar. No page for the query, or no value for the field its URL is built from, means no `$route`, never a broken one. `detailPage: page:<id>` on a fetch links the records to another page instead. `$` marks a field the framework fills, so a record's own `route` field is left alone. ⛔ `route:` on a query is retired and stops the build.
950
940
 
@@ -1871,7 +1861,7 @@ A foundation can route a scope to a plain folder of schema files instead of a pa
1871
1861
  # pages/blog/page.yml
1872
1862
  fetch:
1873
1863
  query: recent
1874
- where: { published: true, tags: featured }
1864
+ where: { tags: featured }
1875
1865
  sort: date desc
1876
1866
  limit: 3
1877
1867
  ```
@@ -2697,7 +2687,7 @@ Running `extract` before a build is the usual first mistake — it reads the com
2697
2687
 
2698
2688
  ```
2699
2689
  locales/freeform/es/pages/about/hero.md # by page route
2700
- locales/freeform/es/entities/article/x.md # records work too
2690
+ locales/freeform/es/records/article/x.md # records work too
2701
2691
  ```
2702
2692
 
2703
2693
  These are **body only — no frontmatter**; params and config still come from the source section. `uniweb i18n init-freeform es pages/about hero` creates one pre-filled and records a source hash, so `uniweb i18n status --freeform` can tell you when the original moved on (`update-hash` to acknowledge). `move`, `rename`, and `prune --freeform` keep them aligned when pages get reorganized.
@@ -7,9 +7,9 @@
7
7
  *
8
8
  * ## Where an asset lands, and why there are two answers
9
9
  *
10
- * - **The map knows it** (`assets.json`) — the file goes back to the path its
11
- * author wrote. That is the whole reason the map is committed: a fresh clone
12
- * restores `public/images/hero.png`, not a hash.
10
+ * - **The map knows it** (this backend's `assets` in `sync.json`) — the file goes
11
+ * back to the path its author wrote. That is the whole reason the map is
12
+ * committed: a fresh clone restores `public/images/hero.png`, not a hash.
13
13
  * - **The map does not know it** — this project has never held these bytes:
14
14
  * authored in the app, or pushed from a machine whose map entry has not
15
15
  * arrived. There is no local path to guess, so it lands at a **generic,
@@ -21,8 +21,8 @@
21
21
  * Identity rides beside the reference (`sync-package.js` stamps `assetId` next
22
22
  * to the serve URL), so the address is already on the node. Composing one would
23
23
  * mean holding the host's route layout — the coupling deleting `buildAssetUrl`
24
- * removed from this CLI, and the reason `assets.json` stores no URL either. An
25
- * id with no URL beside it is skipped, not guessed at.
24
+ * removed from this CLI, and the reason the asset map stores no URL either (only a
25
+ * fingerprint of one). An id with no URL beside it is skipped, not guessed at.
26
26
  *
27
27
  * ## ⛔ A failed download is a WARNING, never a failed pull
28
28
  *
@@ -39,6 +39,7 @@ import { execFileSync } from 'node:child_process'
39
39
  import { detectFoundationType, isExtensionUrl } from '@uniweb/build'
40
40
  import { computeFoundationDigest } from '../utils/code-upload.js'
41
41
  import { isNonInteractive } from '../utils/interactive.js'
42
+ import { publishScope } from '../utils/registry-orgs.js'
42
43
  import { compareSemverPrecedence } from '../utils/semver-precedence.js'
43
44
 
44
45
  /**
@@ -126,13 +127,17 @@ export function resolveLocalExtensions(siteDir, siteYml) {
126
127
  // as this did until 2026-09-17 — looked a `uniweb.id` foundation up under a name
127
128
  // the catalog does not have, so every push re-released it, and pinned the site
128
129
  // to that same wrong name.
130
+ //
131
+ // ⛔ And the scope goes through `publishScope`, as `register`'s does: `uniweb.scope`
132
+ // may read `acme` as well as `@acme`, and until 2026-09-21 this joined it raw — so a
133
+ // bare one looked the foundation up as `acme/src` and pinned the site to that.
129
134
  function foundationScopedName(dir) {
130
135
  try {
131
136
  const pkg = JSON.parse(readFileSync(join(dir, 'package.json'), 'utf8'))
132
137
  const name = pkg?.uniweb?.id || pkg?.name
133
138
  if (typeof name === 'string' && name.startsWith('@')) return name
134
- const scope = pkg?.uniweb?.scope
135
- if (scope && name) return `${String(scope).replace(/\/+$/, '')}/${name}`
139
+ const scope = publishScope(pkg?.uniweb?.scope)
140
+ if (scope && name) return `${scope}/${name}`
136
141
  return null
137
142
  } catch {
138
143
  return null
@@ -61,7 +61,8 @@ import { createHash } from 'node:crypto'
61
61
  * Rows are ordered by their serialized form and object keys sorted, so reordering
62
62
  * rows or keys in the file is not a change. It is not a request to move a line.
63
63
  *
64
- * @param {*} declared - the raw `site.yml::$services` / `$secrets` value
64
+ * @param {*} declared - the raw value: provisioned `services` / `secrets` rows (from
65
+ * `sync.json`), what the site has stored for them, or `site.yml::publishLanguages`
65
66
  * @returns {string|null} 16 hex chars, or null when undeclared
66
67
  */
67
68
  export function fingerprintDeclaration(declared) {
@@ -72,18 +72,18 @@ export async function uploadSiteMedia(
72
72
  warn?.(`local-media: upload failed for ${f.path} (HTTP ${f.status})`)
73
73
 
74
74
  const map = {}
75
- // The identity half, for `assets.json`. The plan returns an authoritative
76
- // `id`+`ext` for every entry INCLUDING a `present: true` dedup skip, so a
77
- // re-push of unchanged media still records identity without moving bytes —
78
- // which is what makes the committed map cheap to keep accurate.
75
+ // The identity half, for this backend's asset map in `sync.json`. The plan
76
+ // returns an authoritative `id`+`ext` for every entry INCLUDING a `present: true`
77
+ // dedup skip, so a re-push of unchanged media still records identity without
78
+ // moving bytes — which is what makes the committed map cheap to keep accurate.
79
79
  const ids = {}
80
80
  for (const ref of refs) {
81
81
  const entry = result.assetsByLocalUrl[ref]
82
82
  if (!entry) continue
83
83
  // `served` — a fingerprint of the address, never the address. It is how a pull
84
84
  // recognizes this asset where the reference is a bare string with no room for an
85
- // id beside it (`info.preview`, `seo.image`, a section param); `assets.json`'s
86
- // header says why a hash and not the URL.
85
+ // id beside it (`info.preview`, `seo.image`, a section param); the header of
86
+ // `@uniweb/build`'s asset-map.js says why a hash and not the URL.
87
87
  if (entry.id) {
88
88
  ids[ref] = {
89
89
  id: entry.id,
@@ -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.
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:
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
+ *
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 }),
@@ -1426,12 +1444,17 @@ export async function pushSyncPackages({
1426
1444
  // this branch named only the deletion and went straight to "clear `$uuid`" — and
1427
1445
  // that advice, followed for the OTHER cause, destroys a live binding: the site is
1428
1446
  // 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.
1447
+ // it. The uuid sent here is the one this project recorded for THIS origin
1448
+ // (sync.json), so another backend's uuid cannot reach it; what does is a site
1449
+ // this origin no longer has — deleted there, or a backend rebuilt at the same
1450
+ // address. Being logged in to a backend you did not mean is still the cheaper
1451
+ // thing to rule out, so it still comes first. (Until 2026-09-21 this paragraph
1452
+ // leaned on a scope check against `site.yml::$backend`; both were deleted on
1453
+ // 2026-09-20.)
1454
+ //
1455
+ // ⛔ The fix for a deletion was "clear `$uuid` from site.yml" until 2026-09-21 — a
1456
+ // key that left site.yml the day before, so the advice named nothing to clear.
1457
+ // The binding is this backend's section of sync.json, and `forget` drops it.
1435
1458
  note(
1436
1459
  `The backend at ${client.origin} has no site with uuid ${boundUuid}.`
1437
1460
  )
@@ -1442,7 +1465,7 @@ export async function pushSyncPackages({
1442
1465
  ` wrong backend → uniweb login --backend <the right one> (nothing is lost)`
1443
1466
  )
1444
1467
  note(
1445
- ` deleted in the app → clearing \`$uuid\` from site.yml re-publishes it as a NEW site`
1468
+ ` deleted there → uniweb forget --backend ${client.origin}, then push again: it creates a NEW site`
1446
1469
  )
1447
1470
  note('Deleting this folder removes only your local copy, either way.')
1448
1471
  } else if (res.status === 409) {
@@ -1451,8 +1474,8 @@ export async function pushSyncPackages({
1451
1474
  note(
1452
1475
  "This site's record structure is already established on the backend and can't be changed " +
1453
1476
  '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.'
1477
+ 'static (data-bundle) and schema-backed delivery. To change it, push it as a new site: ' +
1478
+ `uniweb forget --backend ${client.origin}, then push again (the old site stays until it is deleted).`
1456
1479
  )
1457
1480
  }
1458
1481
  if (body) note(body.slice(0, 800))
@@ -1487,8 +1510,8 @@ export async function pushSyncPackages({
1487
1510
 
1488
1511
  // Lane 1 — site-content (the site is born here; it must exist before its folder). A
1489
1512
  // 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.
1513
+ // and returns its uuid, which we record in sync.json, under this backend).
1514
+ // `boundSiteUuid` carries the minted/known uuid forward to key the folder push.
1492
1515
  let boundSiteUuid = siteContentUuid
1493
1516
  // Post-write tokens harvested from every lane, persisted once at the end so a
1494
1517
  // partial push (lane 1 ok, lane 2 refused) still banks what actually landed —
@@ -1556,8 +1579,8 @@ export async function pushSyncPackages({
1556
1579
  // ⭐ BANK COLLECTION-DECLARATION IDENTITY, the sibling of the folder's
1557
1580
  // placements below. These items have no file to back-fill into — they all
1558
1581
  // 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
1582
+ // `$uuid` can live is `sync.json` (this backend's `queries` map), keyed by the
1583
+ // name the backend enforces unique. Without it every push after the first re-sends the whole
1561
1584
  // `collections` section uuid-less and is refused.
1562
1585
  if (siteFinalizedDoc) {
1563
1586
  const recordIds = collectQueryUuids(siteFinalizedDoc)
@@ -1581,11 +1604,11 @@ export async function pushSyncPackages({
1581
1604
  writeSiteEntityUuid(siteDir, client.origin, minted)
1582
1605
  updateSyncCache(siteDir, client.origin, { siteUuid: minted })
1583
1606
  boundSiteUuid = minted
1584
- wrote.push('recorded site $uuid in site.yml')
1607
+ wrote.push('recorded the site in sync.json')
1585
1608
  // The OTHER create path (a media-less push never reaches `ensureSiteExists`,
1586
1609
  // 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.
1610
+ // owe the same record — recording it in only one place would make the recorded
1611
+ // owner present or absent depending on whether the site happens to have images.
1589
1612
  // The backend echoes `org`/`hosts_free` top-level here too, beside `report`
1590
1613
  // and `site` (NOT beside `finalized`, which lives at report.finalized).
1591
1614
  const createdOrg = await recordAndDescribeOwner({
@@ -1596,7 +1619,7 @@ export async function pushSyncPackages({
1596
1619
  note
1597
1620
  })
1598
1621
  if (createdOrg)
1599
- wrote.push(`recorded site $org (${createdOrg}) in site.yml`)
1622
+ wrote.push(`recorded its owner (${createdOrg}) in sync.json`)
1600
1623
  const createdFinalized = extractFinalized(payload)
1601
1624
  harvest(createdFinalized)
1602
1625
  siteFinalizedDoc = createdFinalized?.[0]?.document || null
@@ -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
@@ -33,7 +33,7 @@
33
33
  * FIRST publish of a site reads it — that create is
34
34
  * what decides which org owns the site and whose
35
35
  * storage its assets are charged to. It is then
36
- * recorded as `site.yml::$org` and replayed, so it
36
+ * recorded in `sync.json` and replayed, so it
37
37
  * never has to be re-typed.
38
38
  * uniweb publish --personal Own the new site personally, deliberately. Sends
39
39
  * NO `as_org` — byte-identical to the wire before
@@ -535,12 +535,18 @@ export async function publish(args = []) {
535
535
  // Non-local @std/registry Model schemas resolve through the backend (same as push).
536
536
  const resolveModel = makeModelResolver({ client, offline: false })
537
537
 
538
- // ⛔ AN EMPTY `records.yml` REMOVES. It is the one path where an ordinary act is
539
- // destructive — a placeholder file, created meaning to fill it in — so the count
540
- // is reported and confirmed before anything is sent.
538
+ // ⛔ AN EMPTY RECORDS DIRECTORY REMOVES. It is the one path where an ordinary act
539
+ // is destructive — a directory emptied by accident, or kept with only a
540
+ // placeholder — so the count is reported and confirmed before anything is sent.
541
+ //
542
+ // ⛔ `backend` WAS MISSING HERE until 2026-09-21, and it is what the count is read
543
+ // under: placements are banked per backend, so with none the guard counted zero
544
+ // and never asked — `publish` sent an empty folder without a word while `push`
545
+ // stopped for the same one.
541
546
  {
542
547
  const guard = await guardEmptyRecords({
543
548
  siteDir,
549
+ backend: client.origin,
544
550
  args,
545
551
  warn: say.warn,
546
552
  note: say.dim
@@ -843,9 +843,9 @@ export async function pull(args = [], deps = {}) {
843
843
  }
844
844
  // ⛔ NO QUERY CONFIG. A record's home is decided by what it IS — its
845
845
  // `$model` names the pool folder — not by any query that happens to select
846
- // it. `recordsToProject` reads `site.yml::$org` itself, so a `@/x`
847
- // model the producer resolved to `@org/x` is placed back where the author
848
- // wrote it.
846
+ // it. `recordsToProject` reads the site's org itself (this backend's, from
847
+ // `sync.json`), so a `@/x` model the producer resolved to `@org/x` is placed
848
+ // back where the author wrote it.
849
849
  const report = recordsToProject({
850
850
  folderDoc,
851
851
  recordDocs,
@@ -857,6 +857,8 @@ export async function pull(args = [], deps = {}) {
857
857
  }
858
858
  })
859
859
  if (report.records === 'updated') info('Wrote records.yml')
860
+ // The backend's folder has no sub-folders, and records.yml holds nothing else.
861
+ else if (report.records === 'removed') info('Removed records.yml — the folder has no sub-folders')
860
862
  records += report.placed.length + report.updated.length
861
863
  for (const s of report.skipped)
862
864
  note(`↷ ${s.slug ?? s.uuid ?? '(record)'}: ${s.reason}`)
@@ -14,7 +14,7 @@
14
14
  * the backend owns the site's `@uniweb/folder`, so the framework never holds a
15
15
  * folder uuid. Records still round-trip their own `$uuid`
16
16
  * (back-filled into their source files). site-content items carry a per-item `$uuid`
17
- * too, stamped at emit from the identity cache rather than from author files — without
17
+ * too, stamped at emit from `sync.json` rather than from author files — without
18
18
  * it the backend reads every record as new and recreates every page and section row.
19
19
  * Push-only, and gated on the backend's per-entity `version`
20
20
  * (see "Pushes are GATED by default" below); `--force` restores last-push-wins.
@@ -30,8 +30,8 @@
30
30
  * uniweb push --org @org Own the new site under @org (alias: --as-org).
31
31
  * Read only on the FIRST push of a site — it
32
32
  * decides which org owns it, and whose storage
33
- * its assets are charged to. Recorded as
34
- * `site.yml::$org` and replayed after that.
33
+ * its assets are charged to. Recorded in
34
+ * `sync.json` and replayed after that.
35
35
  * Without it, you are asked once.
36
36
  * uniweb push --personal Own the new site personally, deliberately.
37
37
  * Sends NO `as_org` — the same wire as before
@@ -388,10 +388,10 @@ export async function push(args = [], deps = {}) {
388
388
  // It also has to be this emit that carries `assetRewrite` below: the push cache
389
389
  // stores hashes of the REWRITTEN content, so the emit compared against it must
390
390
  // rewrite too, or every entity reads as changed forever.
391
- // ⛔ AN EMPTY `records.yml` REMOVES. It is the one path where an ordinary act is
392
- // destructive — a placeholder file, created meaning to fill it in — so the count
393
- // is reported and confirmed before anything is sent. The format stays honest;
394
- // the asking happens here.
391
+ // ⛔ AN EMPTY RECORDS DIRECTORY REMOVES. It is the one path where an ordinary act
392
+ // is destructive — a directory emptied by accident, or kept with only a
393
+ // placeholder — so the count is reported and confirmed before anything is sent.
394
+ // The format stays honest; the asking happens here.
395
395
  if (!dryRun) {
396
396
  const guard = await guardEmptyRecords({ siteDir, backend: client.origin, args, warn, note })
397
397
  if (!guard.ok) return { exitCode: 1 }
@@ -77,7 +77,7 @@ import {
77
77
  computeFoundationDigest,
78
78
  readRuntimePin
79
79
  } from '../utils/code-upload.js'
80
- import { deriveScope } from '../utils/registry-orgs.js'
80
+ import { deriveScope, publishScope } from '../utils/registry-orgs.js'
81
81
  import { BackendClient } from '../backend/client.js'
82
82
  import { writeJsonPreservingStyleAsync } from '../utils/json-file.js'
83
83
  import {
@@ -404,14 +404,24 @@ async function runRegister(args = []) {
404
404
  : await resolveFoundationDir(args)
405
405
 
406
406
  // Scope: --scope flag, else package.json `uniweb.scope`, else (real submit
407
- // only) derived from login membership in the bootstrap below.
407
+ // only) derived from login membership in the bootstrap below. Either spelling,
408
+ // `@acme` or `acme`, and from here on the one form: `@acme` (publishScope).
408
409
  const pkgScope = readPkgScope(targetDir)
409
- let scope = scopeFlag || pkgScope
410
+ const givenScope = scopeFlag || pkgScope
411
+ let scope = publishScope(givenScope)
410
412
  let scopeSource = scopeFlag
411
413
  ? '--scope'
412
414
  : pkgScope
413
415
  ? 'package.json uniweb.scope'
414
416
  : null
417
+ // A scope that was GIVEN but names no org (`--scope @`) is refused, never read as
418
+ // absent: absent derives one from the login, which would register under an org the
419
+ // caller did not name.
420
+ if (givenScope && !scope) {
421
+ error(`Not an org scope: ${givenScope} (from ${scopeSource})`)
422
+ log(` ${colors.dim}Pass --scope @org.${colors.reset}`)
423
+ return { exitCode: 2 }
424
+ }
415
425
  const isPreview = !!output || dryRun
416
426
 
417
427
  // Each path supplies a different schema source: the standalone path discovers
@@ -671,11 +681,17 @@ async function runRegister(args = []) {
671
681
  return { exitCode: 1 }
672
682
  }
673
683
  }
684
+ // The foundation's name as the registry holds it (`@org/name`) — read from the
685
+ // document just submitted, never composed again. See phase 2.
686
+ const registeredName = standalone
687
+ ? null
688
+ : doc.entities.find((e) => e.model === '@uniweb/foundation-schema')?.info
689
+ ?.name
674
690
  if (!alreadyRegistered) {
675
691
  success(
676
692
  standalone
677
693
  ? `Registered ${defined.length} data schema(s)${scope ? ` under ${scope}` : ''}`
678
- : `Registered ${schema._self.name}@${schema._self.version}${defined.length ? ` + ${defined.length} data schema(s)` : ''}`
694
+ : `Registered ${registeredName}@${schema._self.version}${defined.length ? ` + ${defined.length} data schema(s)` : ''}`
679
695
  )
680
696
  }
681
697
 
@@ -684,11 +700,13 @@ async function runRegister(args = []) {
684
700
  // only packages have no dist; --schema-only skips deliberately.
685
701
  if (!standalone && !args.includes('--schema-only')) {
686
702
  const distDir = join(targetDir, 'dist')
687
- // The registry's vocabulary is the SCOPED name (`@org/name`). A scoped
688
- // package name passes through; a bare one gets the chosen scope — the
689
- // same resolution the .uwx submission applied.
690
- const bareName = schema._self.name
691
- const name = bareName.startsWith('@') ? bareName : `${scope}/${bareName}`
703
+ // ⭐ The plan authorizes against the REGISTERED (name, version), so the name here
704
+ // is the one phase 1 registered — the `.uwx`'s own `info.name`, where a scoped
705
+ // package name passes through and a bare one gets the scope. ⛔ Not composed a
706
+ // second time: this line composed it from the raw scope until 2026-09-21, so
707
+ // `--scope std` registered `@std/src` and then asked to deliver `std/src`, which
708
+ // the registry refuses. One name, read from the one place it was decided.
709
+ const name = registeredName
692
710
  const version = schema._self.version
693
711
  info(
694
712
  `Delivering code for ${colors.bright}${name}@${version}${colors.reset} …`
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "schemaVersion": 1,
3
- "generatedAt": "2026-09-21T13:20:05.572Z",
3
+ "generatedAt": "2026-09-21T21:47:05.925Z",
4
4
  "packages": {
5
5
  "@uniweb/api": {
6
6
  "version": "0.4.1",
@@ -10,7 +10,7 @@
10
10
  ]
11
11
  },
12
12
  "@uniweb/build": {
13
- "version": "0.56.0",
13
+ "version": "0.57.0",
14
14
  "path": "framework/build",
15
15
  "deps": [
16
16
  "@uniweb/content-reader",
@@ -35,7 +35,7 @@
35
35
  "deps": []
36
36
  },
37
37
  "@uniweb/core": {
38
- "version": "0.29.6",
38
+ "version": "0.29.7",
39
39
  "path": "framework/core",
40
40
  "deps": [
41
41
  "@uniweb/semantic-parser",
@@ -55,7 +55,7 @@
55
55
  ]
56
56
  },
57
57
  "@uniweb/kit": {
58
- "version": "0.19.3",
58
+ "version": "0.19.4",
59
59
  "path": "framework/kit",
60
60
  "deps": [
61
61
  "@uniweb/core",
@@ -83,7 +83,7 @@
83
83
  ]
84
84
  },
85
85
  "@uniweb/runtime": {
86
- "version": "0.27.2",
86
+ "version": "0.27.3",
87
87
  "path": "framework/runtime",
88
88
  "deps": [
89
89
  "@uniweb/core",
@@ -116,7 +116,7 @@
116
116
  "deps": []
117
117
  },
118
118
  "@uniweb/templates": {
119
- "version": "0.14.8",
119
+ "version": "0.15.0",
120
120
  "path": "framework/templates",
121
121
  "deps": []
122
122
  },
@@ -126,7 +126,7 @@
126
126
  "deps": []
127
127
  },
128
128
  "@uniweb/unipress": {
129
- "version": "0.9.27",
129
+ "version": "0.10.0",
130
130
  "path": "framework/unipress",
131
131
  "deps": [
132
132
  "@uniweb/build",
package/src/index.js CHANGED
@@ -1432,7 +1432,7 @@ ${colors.bright}Options:${colors.reset}
1432
1432
  --org @org Publish under @org (membership-gated; alias: --as-org). Read
1433
1433
  only on a site's FIRST publish — that create decides which org
1434
1434
  owns the site, and whose storage its assets are charged to.
1435
- Recorded as site.yml \$org and replayed, so it is never
1435
+ Recorded in sync.json and replayed, so it is never
1436
1436
  re-typed. Without it, you are asked once.
1437
1437
  --personal Create the site under your personal account, deliberately.
1438
1438
  Only needed on a first publish, and only to answer the owner
package/src/utils/git.js CHANGED
@@ -54,7 +54,7 @@ export function siteContentRoots(siteDir) {
54
54
  }
55
55
  roots.add(paths.pages || 'pages')
56
56
  roots.add(paths.layout || 'layout')
57
- roots.add(paths.entities || 'entities')
57
+ roots.add(paths.records || 'records')
58
58
  return [...roots]
59
59
  }
60
60
 
@@ -1,42 +1,47 @@
1
1
  // ⛔ THE ONE PLACE AN ORDINARY ACT IS DESTRUCTIVE.
2
2
  //
3
- // `records.yml` is the sync control, and `missing` and `empty` deliberately mean
4
- // different things: missing leaves the server's folder untouched, empty says the
5
- // folder holds nothing and the backend removes what is there. The asymmetry is
6
- // well-shaped — the safe state is the ABSENCE of a file, so a live folder cannot
7
- // be wiped by deleting one, and the destructive act requires affirmatively
8
- // creating one.
3
+ // A push sends the site's records folder whole, and it REPLACES the backend's. A site
4
+ // with no records directory sends none and leaves the backend's alone; a records
5
+ // directory that holds no records sends an EMPTY folder, and the backend removes what
6
+ // is there. The asymmetry is well-shaped — the safe state is the ABSENCE of the
7
+ // directory, so a site whose records live only on the backend is never emptied by a
8
+ // push of its pages — and it leaves exactly one sharp edge: a directory emptied by
9
+ // accident, or kept with only a placeholder (`records/.gitkeep`), and pushed.
9
10
  //
10
- // ⚠️ WHICH LEAVES EXACTLY ONE SHARP EDGE: a PLACEHOLDER. Someone creates an empty
11
- // `records.yml` intending to fill it in, pushes, and the live folder empties.
12
- // That is plausible and it is the only path where a normal act destroys content.
11
+ // ⭐ THE CLI DOES THE ASKING. Never make "empty" mean "missing" to dodge this: that
12
+ // would delete a capability to avoid writing a prompt.
13
13
  //
14
- // ⭐ THE FORMAT STAYS HONEST AND THE CLI DOES THE ASKING. Never make "empty" mean
15
- // "missing" to dodge this: that would delete a capability to avoid writing a
16
- // prompt.
14
+ // ⛔ Until 2026-09-21 both states were `records.yml`'s — missing and empty — because
15
+ // that file listed the records. The directory holds them now (`@uniweb/build`'s
16
+ // `sendsFolder` decides what a push sends, and this asks about exactly its empty case).
17
17
  //
18
18
  // The count comes from the placement identity a previous push banked — what WE
19
19
  // last saw the folder hold. It needs no network call, and it is the right source:
20
20
  // a site that has never pushed has nothing to lose and is never asked.
21
21
 
22
- import { readRecordsConfig, FOLDER_EMPTY } from '@uniweb/build/uwx'
22
+ import { readEntityPool } from '@uniweb/build/uwx'
23
23
  import { readFolderItemUuids } from '../backend/site-sync.js'
24
24
  import { confirm, isNonInteractive, getCliPrefix } from './interactive.js'
25
25
 
26
26
  /**
27
- * Leaf placements in a banked path→uuid map.
27
+ * Record placements in a banked placement map.
28
28
  *
29
- * A branch's path is a prefix of every path beneath it, so anything that is a
30
- * prefix of another key is a folder rather than a record. Counting raw keys would
31
- * report a two-record site inside one folder as three things to lose.
29
+ * ⭐ A placement banked since 2026-09-21 is keyed by the record it references —
30
+ * `@<record uuid>`, one per record — so where those exist they ARE the count. A map
31
+ * banked before holds `name` chains only: a branch's path is a prefix of every path
32
+ * beneath it, so anything that is a prefix of another key is a folder rather than a
33
+ * record. Counting raw keys would report a two-record site inside one folder as
34
+ * three things to lose — or, with both kinds of key, each record twice.
32
35
  */
33
36
  export function countPlacedRecords(pathToUuid) {
34
- const paths = Object.keys(pathToUuid || {})
35
- return paths.filter((p) => !paths.some((q) => q !== p && q.startsWith(`${p}/`))).length
37
+ const keys = Object.keys(pathToUuid || {})
38
+ const byRecord = keys.filter((k) => k.startsWith('@'))
39
+ if (byRecord.length) return byRecord.length
40
+ return keys.filter((p) => !keys.some((q) => q !== p && q.startsWith(`${p}/`))).length
36
41
  }
37
42
 
38
43
  /**
39
- * Stop an empty `records.yml` from silently emptying a live folder.
44
+ * Stop an empty records directory from silently emptying the backend's folder.
40
45
  *
41
46
  * @param {object} params
42
47
  * @param {string} params.siteDir
@@ -48,8 +53,10 @@ export function countPlacedRecords(pathToUuid) {
48
53
  * @returns {Promise<{ ok: boolean, count: number }>} `ok: false` means abort
49
54
  */
50
55
  export async function guardEmptyRecords({ siteDir, backend, args = [], warn, note }) {
51
- const cfg = await readRecordsConfig(siteDir)
52
- if (cfg.state !== FOLDER_EMPTY) return { ok: true, count: 0 }
56
+ const pool = await readEntityPool(siteDir)
57
+ // Only a directory that is there and holds nothing at all — no records, and no
58
+ // files that fail to be records — sends the empty folder.
59
+ if (!pool.exists || pool.entities.length > 0 || pool.errors.length > 0) return { ok: true, count: 0 }
53
60
 
54
61
  const count = countPlacedRecords(readFolderItemUuids(siteDir, backend))
55
62
  // Nothing banked ⇒ nothing this push can remove. A first push of an empty
@@ -58,13 +65,13 @@ export async function guardEmptyRecords({ siteDir, backend, args = [], warn, not
58
65
  if (count === 0) return { ok: true, count: 0 }
59
66
 
60
67
  warn(
61
- `records.yml is empty, and this push would REMOVE ${count} record${count === 1 ? '' : 's'} ` +
62
- `from the live folder.`
68
+ `${pool.dir}/ holds no records, and this push would REMOVE ${count} record${count === 1 ? '' : 's'} ` +
69
+ `from the site's folder on the backend.`
63
70
  )
64
71
  note(
65
- 'An empty records.yml means "the folder holds nothing" — it is not the same as ' +
66
- 'having no records.yml, which leaves the live folder alone. If you meant to ' +
67
- 'start listing records, delete the file until you have.'
72
+ `An empty ${pool.dir}/ means "the folder holds nothing" — it is not the same as having no ` +
73
+ `${pool.dir}/ at all, which leaves the backend's folder alone. If you did not mean to remove ` +
74
+ `them, put the files back (or pull) before pushing.`
68
75
  )
69
76
 
70
77
  // ⚠️ `--yes` ONLY. `-y` is not a flag this CLI has anywhere, and adding one here
@@ -76,6 +83,6 @@ export async function guardEmptyRecords({ siteDir, backend, args = [], warn, not
76
83
  return { ok: false, count }
77
84
  }
78
85
 
79
- const yes = await confirm(`Remove ${count} record${count === 1 ? '' : 's'} from the live folder?`, false)
86
+ const yes = await confirm(`Remove ${count} record${count === 1 ? '' : 's'} from the site's folder?`, false)
80
87
  return { ok: yes, count }
81
88
  }
@@ -59,6 +59,25 @@ export function bareHandle(scope) {
59
59
  .replace(/\/.*$/, '')
60
60
  }
61
61
 
62
+ /**
63
+ * A publish scope in the one form the registry names things by — `@acme` — from any
64
+ * spelling `--scope` or `package.json::uniweb.scope` may carry it in (`@acme`, `acme`,
65
+ * `@acme/…`). Null when there is no handle in it.
66
+ *
67
+ * ⛔ Every name built from a scope goes through this. The `.uwx` assembly
68
+ * (`@uniweb/build`'s `buildRegistryPackage`) has always accepted `acme` as well as
69
+ * `@acme`, so a name composed from the RAW value disagreed with the one it registered:
70
+ * until 2026-09-21 `register --scope std` registered `@std/src` and then asked to
71
+ * deliver code for `std/src`, which the registry refuses (a scoped name is `@org/name`).
72
+ *
73
+ * @param {unknown} value
74
+ * @returns {string|null}
75
+ */
76
+ export function publishScope(value) {
77
+ const handle = bareHandle(value)
78
+ return handle ? `@${handle}` : null
79
+ }
80
+
62
81
  /**
63
82
  * Validate a handle's GRAMMAR client-side (reserved names are the server's
64
83
  * call — a 409 carries the verdict). Returns an error string, or null.
@@ -25,7 +25,7 @@ index: home
25
25
  # pages/docs: ../../../docs # Mount docs repo at /docs route
26
26
  # pages/blog: ../../blog-content # Mount blog content at /blog route
27
27
  # layout: ./custom-layout # Custom layout directory
28
- # entities: ./data # Where this site's entities live
28
+ # records: ./data # Where this site's records live
29
29
  #
30
30
  # To give a mounted route a layout or a title, add a local folder for it
31
31
  # (pages/docs/) holding a folder.yml — folder.yml, not page.yml, because the
@@ -41,14 +41,16 @@ index: home
41
41
  # ─── Structured Content ───────────────────────────────────────────────────────
42
42
  # Three things, and they are separate on purpose:
43
43
  #
44
- # entities/{schema}/ your stored records. The folder names their data schema:
45
- # entities/article/ → @/article (your foundation's)
46
- # entities/std/person/ → @std/person
47
- # records.yml WHAT IS PUBLISHED. Listing an entity here is what makes it
48
- # a record; anything you leave out is a draft. Usually three
49
- # lines:
50
- # - article/*.md
51
- # - person/*.md
44
+ # records/{schema}/ your site's records — every file here is one. The folder
45
+ # names their data schema:
46
+ # records/article/ → @/article (your foundation's)
47
+ # records/std/person/ → @std/person
48
+ # A file whose name starts with `_` is not a record.
49
+ # records.yml OPTIONAL — sorts records into folders a query can slice
50
+ # with `scope:`. Most sites have none:
51
+ # - folder: archive
52
+ # records:
53
+ # - article/2025-*.md
52
54
  # queries.yml HOW CONTENT IS REACHED — named queries over those records:
53
55
  # recent:
54
56
  # schema: '@/article'