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 +7 -7
- package/partials/agents.md +18 -28
- package/src/backend/asset-download.js +5 -5
- package/src/backend/foundation-bring-along.js +7 -2
- package/src/backend/service-request.js +2 -1
- package/src/backend/site-media.js +6 -6
- package/src/backend/site-sync.js +87 -64
- package/src/commands/doctor.js +6 -7
- package/src/commands/i18n.js +19 -10
- package/src/commands/publish.js +10 -4
- package/src/commands/pull.js +5 -3
- package/src/commands/push.js +7 -7
- package/src/commands/register.js +27 -9
- package/src/framework-index.json +7 -7
- package/src/index.js +1 -1
- package/src/utils/git.js +1 -1
- package/src/utils/records-guard.js +35 -28
- package/src/utils/registry-orgs.js +19 -0
- package/templates/site/site.yml.hbs +11 -9
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "uniweb",
|
|
3
|
-
"version": "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/
|
|
45
|
-
"@uniweb/
|
|
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.
|
|
52
|
+
"@uniweb/build": "^0.57.0",
|
|
53
53
|
"@uniweb/content-reader": "^1.2.5",
|
|
54
54
|
"@uniweb/semantic-parser": "^1.4.1"
|
|
55
55
|
},
|
package/partials/agents.md
CHANGED
|
@@ -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
|
-
├──
|
|
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 #
|
|
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
|
-
**`
|
|
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
|
-
| `
|
|
864
|
-
| `
|
|
865
|
-
| `
|
|
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: `
|
|
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`, `
|
|
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` —
|
|
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
|
|
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
|
|
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: {
|
|
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
|
-
`
|
|
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: {
|
|
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/
|
|
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
|
|
11
|
-
* author wrote. That is the whole reason the map is
|
|
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
|
|
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 `${
|
|
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 `
|
|
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 `
|
|
76
|
-
// `id`+`ext` for every entry INCLUDING a `present: true`
|
|
77
|
-
// re-push of unchanged media still records identity without
|
|
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);
|
|
86
|
-
//
|
|
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,
|
package/src/backend/site-sync.js
CHANGED
|
@@ -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
|
-
*
|
|
280
|
-
* content hashes, the injections those hashes were
|
|
281
|
-
* unit bases — and a unit path (`site.yml`,
|
|
282
|
-
* string for every site. So
|
|
283
|
-
* self-
|
|
284
|
-
*
|
|
285
|
-
*
|
|
286
|
-
* Which
|
|
287
|
-
* uuid-bound lane
|
|
288
|
-
* recovery after a site is deleted in the app. Following it left the
|
|
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
|
|
383
|
+
// Two ways the maps can describe a site this clone is not working with:
|
|
381
384
|
//
|
|
382
|
-
// 1. UNBOUND — no
|
|
383
|
-
// be about. This
|
|
384
|
-
// a new site" recovery
|
|
385
|
-
// 2. BOUND TO A DIFFERENT SITE —
|
|
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
|
|
462
|
-
* holding exactly that map (local ref → `{id, ext}`), written
|
|
463
|
-
* a gitignored second copy would be a second thing to
|
|
464
|
-
*
|
|
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 `
|
|
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 `
|
|
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:
|
|
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.
|
|
647
|
-
* with the `@` the CLI and the wire both use. Callers
|
|
648
|
-
* so an org named once, at create, does not have to
|
|
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`
|
|
701
|
-
* (`
|
|
702
|
-
*
|
|
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.
|
|
708
|
-
* 4. the site already exists (
|
|
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 (
|
|
1066
|
-
*
|
|
1067
|
-
* backend's current document,
|
|
1068
|
-
* legitimately has nothing to fetch and is
|
|
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
|
|
1133
|
-
// wrote. Reading the live file rather than a
|
|
1134
|
-
// map (a teammate's push, a pull) read as
|
|
1135
|
-
// of itself.
|
|
1136
|
-
// · RECORDED — the site's own org, from `
|
|
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
|
|
1168
|
-
* reads as changed rather than matching
|
|
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
|
|
1172
|
-
*
|
|
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.
|
|
1430
|
-
//
|
|
1431
|
-
//
|
|
1432
|
-
//
|
|
1433
|
-
//
|
|
1434
|
-
//
|
|
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
|
|
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
|
|
1455
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
package/src/commands/doctor.js
CHANGED
|
@@ -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 `
|
|
170
|
-
* nothing else — `
|
|
171
|
-
*
|
|
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 `
|
|
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
|
|
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
|
-
// `
|
|
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
|
package/src/commands/i18n.js
CHANGED
|
@@ -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.
|
|
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
|
|
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,
|
|
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
|
|
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('
|
|
1103
|
+
} else if (pathType.startsWith('records/')) {
|
|
1095
1104
|
// ⛔ ADDRESSED BY THE RECORD, matching where the loader reads. The freeform
|
|
1096
|
-
// tree is `
|
|
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.
|
|
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 (`
|
|
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
|
-
|
|
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
|
|
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
|
package/src/commands/publish.js
CHANGED
|
@@ -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
|
|
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
|
|
539
|
-
// destructive — a
|
|
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
|
package/src/commands/pull.js
CHANGED
|
@@ -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
|
|
847
|
-
// model the producer resolved to `@org/x` is placed
|
|
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}`)
|
package/src/commands/push.js
CHANGED
|
@@ -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
|
|
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
|
|
34
|
-
* `
|
|
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
|
|
392
|
-
// destructive — a
|
|
393
|
-
// is reported and confirmed before anything is sent.
|
|
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 }
|
package/src/commands/register.js
CHANGED
|
@@ -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
|
-
|
|
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 ${
|
|
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
|
|
688
|
-
//
|
|
689
|
-
//
|
|
690
|
-
|
|
691
|
-
|
|
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} …`
|
package/src/framework-index.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"schemaVersion": 1,
|
|
3
|
-
"generatedAt": "2026-09-
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
|
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
|
@@ -1,42 +1,47 @@
|
|
|
1
1
|
// ⛔ THE ONE PLACE AN ORDINARY ACT IS DESTRUCTIVE.
|
|
2
2
|
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
// well-shaped — the safe state is the ABSENCE of
|
|
7
|
-
//
|
|
8
|
-
//
|
|
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
|
-
//
|
|
11
|
-
//
|
|
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
|
-
//
|
|
15
|
-
//
|
|
16
|
-
//
|
|
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 {
|
|
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
|
-
*
|
|
27
|
+
* Record placements in a banked placement map.
|
|
28
28
|
*
|
|
29
|
-
* A
|
|
30
|
-
*
|
|
31
|
-
*
|
|
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
|
|
35
|
-
|
|
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
|
|
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
|
|
52
|
-
|
|
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
|
-
|
|
62
|
-
`from the
|
|
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
|
-
|
|
66
|
-
|
|
67
|
-
|
|
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
|
|
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
|
-
#
|
|
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
|
-
#
|
|
45
|
-
#
|
|
46
|
-
#
|
|
47
|
-
#
|
|
48
|
-
#
|
|
49
|
-
#
|
|
50
|
-
#
|
|
51
|
-
# -
|
|
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'
|