uniweb 0.60.0 → 0.62.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +8 -8
- package/partials/agents.md +27 -36
- package/src/backend/asset-download.js +5 -5
- package/src/backend/foundation-bring-along.js +72 -60
- package/src/backend/service-request.js +2 -1
- package/src/backend/site-media.js +6 -6
- package/src/backend/site-sync.js +106 -64
- package/src/commands/add.js +62 -4
- package/src/commands/clone.js +1 -1
- 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 +16 -6
- package/src/commands/push.js +7 -7
- package/src/commands/register.js +126 -21
- package/src/commands/rename.js +12 -9
- package/src/commands/status.js +6 -2
- package/src/framework-index.json +7 -7
- package/src/index.js +30 -3
- package/src/utils/foundation-name.js +179 -0
- package/src/utils/git.js +5 -2
- package/src/utils/records-guard.js +35 -28
- package/src/utils/registry-orgs.js +19 -0
- package/src/utils/scaffold.js +7 -5
- package/starter/foundation/main.js +2 -1
- package/templates/foundation/main.js.hbs +9 -0
- package/templates/site/site.yml.hbs +12 -9
package/src/backend/site-sync.js
CHANGED
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
*/
|
|
13
13
|
|
|
14
14
|
import { writeFileSync, readFileSync, mkdirSync, existsSync, unlinkSync } from 'node:fs'
|
|
15
|
-
import { join, dirname } from 'node:path'
|
|
15
|
+
import { join, dirname, relative, isAbsolute } from 'node:path'
|
|
16
16
|
import yaml from 'js-yaml'
|
|
17
17
|
import { hasUncommittedContent } from '../utils/git.js'
|
|
18
18
|
import { humanBytes } from '../utils/bytes.js'
|
|
@@ -276,17 +276,17 @@ const readMap = (siteDir, backend, key) => {
|
|
|
276
276
|
* Drop every cache map that describes a BACKEND site, when this clone is bound to
|
|
277
277
|
* none. Returns the names dropped (empty when there was nothing to do).
|
|
278
278
|
*
|
|
279
|
-
*
|
|
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
|
-
*
|
|
279
|
+
* Five maps are keyed by *unit path* — item uuids (`sync.json`'s `items`) and, in
|
|
280
|
+
* `.uniweb/backend-cache.json`, content hashes, the injections those hashes were
|
|
281
|
+
* taken over, entity base versions and unit bases — and a unit path (`site.yml`,
|
|
282
|
+
* `pages/about/about.md`) is the same string for every site. So nothing in them
|
|
283
|
+
* self-invalidates when the site's uuid goes away: they keep describing the site
|
|
284
|
+
* this folder used to be.
|
|
285
285
|
*
|
|
286
|
-
* Which
|
|
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:
|
|
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 }),
|
|
@@ -1252,6 +1270,8 @@ export async function pushSyncPackages({
|
|
|
1252
1270
|
|
|
1253
1271
|
const wrote = []
|
|
1254
1272
|
let finalizedTotal = 0
|
|
1273
|
+
// Records sent as drafts that the backend stored enabled — reported at the end.
|
|
1274
|
+
let draftsNotKept = []
|
|
1255
1275
|
|
|
1256
1276
|
// POST one lane via the client and parse the JSON response. `doRequest` is a thunk
|
|
1257
1277
|
// returning the client's Response promise (so the "Pushing …" line prints before the
|
|
@@ -1426,12 +1446,17 @@ export async function pushSyncPackages({
|
|
|
1426
1446
|
// this branch named only the deletion and went straight to "clear `$uuid`" — and
|
|
1427
1447
|
// that advice, followed for the OTHER cause, destroys a live binding: the site is
|
|
1428
1448
|
// fine, you are simply pointed at the wrong backend, and clearing the uuid orphans
|
|
1429
|
-
// it.
|
|
1430
|
-
//
|
|
1431
|
-
//
|
|
1432
|
-
//
|
|
1433
|
-
//
|
|
1434
|
-
//
|
|
1449
|
+
// it. The uuid sent here is the one this project recorded for THIS origin
|
|
1450
|
+
// (sync.json), so another backend's uuid cannot reach it; what does is a site
|
|
1451
|
+
// this origin no longer has — deleted there, or a backend rebuilt at the same
|
|
1452
|
+
// address. Being logged in to a backend you did not mean is still the cheaper
|
|
1453
|
+
// thing to rule out, so it still comes first. (Until 2026-09-21 this paragraph
|
|
1454
|
+
// leaned on a scope check against `site.yml::$backend`; both were deleted on
|
|
1455
|
+
// 2026-09-20.)
|
|
1456
|
+
//
|
|
1457
|
+
// ⛔ The fix for a deletion was "clear `$uuid` from site.yml" until 2026-09-21 — a
|
|
1458
|
+
// key that left site.yml the day before, so the advice named nothing to clear.
|
|
1459
|
+
// The binding is this backend's section of sync.json, and `forget` drops it.
|
|
1435
1460
|
note(
|
|
1436
1461
|
`The backend at ${client.origin} has no site with uuid ${boundUuid}.`
|
|
1437
1462
|
)
|
|
@@ -1442,7 +1467,7 @@ export async function pushSyncPackages({
|
|
|
1442
1467
|
` wrong backend → uniweb login --backend <the right one> (nothing is lost)`
|
|
1443
1468
|
)
|
|
1444
1469
|
note(
|
|
1445
|
-
` deleted
|
|
1470
|
+
` deleted there → uniweb forget --backend ${client.origin}, then push again: it creates a NEW site`
|
|
1446
1471
|
)
|
|
1447
1472
|
note('Deleting this folder removes only your local copy, either way.')
|
|
1448
1473
|
} else if (res.status === 409) {
|
|
@@ -1451,8 +1476,8 @@ export async function pushSyncPackages({
|
|
|
1451
1476
|
note(
|
|
1452
1477
|
"This site's record structure is already established on the backend and can't be changed " +
|
|
1453
1478
|
'in place — e.g. adding or removing a schema-backed query, or switching one between ' +
|
|
1454
|
-
'static (data-bundle) and schema-backed delivery. To change it
|
|
1455
|
-
|
|
1479
|
+
'static (data-bundle) and schema-backed delivery. To change it, push it as a new site: ' +
|
|
1480
|
+
`uniweb forget --backend ${client.origin}, then push again (the old site stays until it is deleted).`
|
|
1456
1481
|
)
|
|
1457
1482
|
}
|
|
1458
1483
|
if (body) note(body.slice(0, 800))
|
|
@@ -1487,8 +1512,8 @@ export async function pushSyncPackages({
|
|
|
1487
1512
|
|
|
1488
1513
|
// Lane 1 — site-content (the site is born here; it must exist before its folder). A
|
|
1489
1514
|
// known site uuid → UPDATE by uuid; none → CREATE (the backend mints + adopts the site
|
|
1490
|
-
// and returns its uuid, which we record
|
|
1491
|
-
// minted/known uuid forward to key the folder push.
|
|
1515
|
+
// and returns its uuid, which we record in sync.json, under this backend).
|
|
1516
|
+
// `boundSiteUuid` carries the minted/known uuid forward to key the folder push.
|
|
1492
1517
|
let boundSiteUuid = siteContentUuid
|
|
1493
1518
|
// Post-write tokens harvested from every lane, persisted once at the end so a
|
|
1494
1519
|
// partial push (lane 1 ok, lane 2 refused) still banks what actually landed —
|
|
@@ -1556,8 +1581,8 @@ export async function pushSyncPackages({
|
|
|
1556
1581
|
// ⭐ BANK COLLECTION-DECLARATION IDENTITY, the sibling of the folder's
|
|
1557
1582
|
// placements below. These items have no file to back-fill into — they all
|
|
1558
1583
|
// come from one `collections/collections.yml` — so the only place their
|
|
1559
|
-
// `$uuid` can live is
|
|
1560
|
-
// unique. Without it every push after the first re-sends the whole
|
|
1584
|
+
// `$uuid` can live is `sync.json` (this backend's `queries` map), keyed by the
|
|
1585
|
+
// name the backend enforces unique. Without it every push after the first re-sends the whole
|
|
1561
1586
|
// `collections` section uuid-less and is refused.
|
|
1562
1587
|
if (siteFinalizedDoc) {
|
|
1563
1588
|
const recordIds = collectQueryUuids(siteFinalizedDoc)
|
|
@@ -1581,11 +1606,11 @@ export async function pushSyncPackages({
|
|
|
1581
1606
|
writeSiteEntityUuid(siteDir, client.origin, minted)
|
|
1582
1607
|
updateSyncCache(siteDir, client.origin, { siteUuid: minted })
|
|
1583
1608
|
boundSiteUuid = minted
|
|
1584
|
-
wrote.push('recorded site
|
|
1609
|
+
wrote.push('recorded the site in sync.json')
|
|
1585
1610
|
// The OTHER create path (a media-less push never reaches `ensureSiteExists`,
|
|
1586
1611
|
// which is gated on the site having local media). Both mint a site, so both
|
|
1587
|
-
// owe the same record — recording it in only one place would make
|
|
1588
|
-
// present or absent depending on whether the site happens to have images.
|
|
1612
|
+
// owe the same record — recording it in only one place would make the recorded
|
|
1613
|
+
// owner present or absent depending on whether the site happens to have images.
|
|
1589
1614
|
// The backend echoes `org`/`hosts_free` top-level here too, beside `report`
|
|
1590
1615
|
// and `site` (NOT beside `finalized`, which lives at report.finalized).
|
|
1591
1616
|
const createdOrg = await recordAndDescribeOwner({
|
|
@@ -1596,7 +1621,7 @@ export async function pushSyncPackages({
|
|
|
1596
1621
|
note
|
|
1597
1622
|
})
|
|
1598
1623
|
if (createdOrg)
|
|
1599
|
-
wrote.push(`recorded
|
|
1624
|
+
wrote.push(`recorded its owner (${createdOrg}) in sync.json`)
|
|
1600
1625
|
const createdFinalized = extractFinalized(payload)
|
|
1601
1626
|
harvest(createdFinalized)
|
|
1602
1627
|
siteFinalizedDoc = createdFinalized?.[0]?.document || null
|
|
@@ -1635,6 +1660,7 @@ export async function pushSyncPackages({
|
|
|
1635
1660
|
}
|
|
1636
1661
|
for (const w of bf.warnings) note(`! ${w}`)
|
|
1637
1662
|
for (const d of bf.deferred) note(`↷ ${d.id ?? `#${d.index}`}: ${d.reason}`)
|
|
1663
|
+
draftsNotKept = bf.notKeptAsDrafts || []
|
|
1638
1664
|
if (bf.updated.length)
|
|
1639
1665
|
wrote.push(`wrote ${bf.updated.length} record file(s)`)
|
|
1640
1666
|
// ⭐ BANK THE FOLDER'S PLACEMENT IDENTITY. The records carry their own `$uuid`
|
|
@@ -1700,5 +1726,21 @@ export async function pushSyncPackages({
|
|
|
1700
1726
|
)
|
|
1701
1727
|
}
|
|
1702
1728
|
}
|
|
1729
|
+
// ⛔ A DRAFT THE BACKEND STORED ENABLED FAILS THE PUSH, and only here, after every
|
|
1730
|
+
// piece of sync state above is banked: the push itself happened, and the next one must
|
|
1731
|
+
// see it. What must not happen is a publish that goes live on top of it, which is why
|
|
1732
|
+
// the exit code carries it (publish stops on anything but 0). The files keep their
|
|
1733
|
+
// `draft: true` (see backfillEntityUuids).
|
|
1734
|
+
if (draftsNotKept.length) {
|
|
1735
|
+
const one = draftsNotKept.length === 1
|
|
1736
|
+
const shown = draftsNotKept.map((p) => (isAbsolute(p) ? relative(siteDir, p) : p))
|
|
1737
|
+
error(
|
|
1738
|
+
`This backend did not keep ${one ? 'a record' : `${shown.length} records`} as a draft, ` +
|
|
1739
|
+
`so ${one ? 'it' : 'they'} would be delivered once the site is published:\n ` +
|
|
1740
|
+
shown.join('\n ') +
|
|
1741
|
+
'\n The backend does not support `draft: true` yet. Once it does, push again with --all.'
|
|
1742
|
+
)
|
|
1743
|
+
return { exitCode: 1, boundSiteUuid, finalizedTotal, wrote }
|
|
1744
|
+
}
|
|
1703
1745
|
return { exitCode: 0, boundSiteUuid, finalizedTotal, wrote }
|
|
1704
1746
|
}
|
package/src/commands/add.js
CHANGED
|
@@ -16,7 +16,7 @@ import { readFile, writeFile, mkdir } from 'node:fs/promises'
|
|
|
16
16
|
import { join, relative, basename } from 'node:path'
|
|
17
17
|
import prompts from 'prompts'
|
|
18
18
|
import yaml from 'js-yaml'
|
|
19
|
-
import { resolveFoundationSrcPath } from '@uniweb/build'
|
|
19
|
+
import { resolveFoundationSrcPath, readFoundationName } from '@uniweb/build'
|
|
20
20
|
import {
|
|
21
21
|
scaffoldFoundation,
|
|
22
22
|
scaffoldSite,
|
|
@@ -42,6 +42,10 @@ import {
|
|
|
42
42
|
resolveUniqueName
|
|
43
43
|
} from '../utils/names.js'
|
|
44
44
|
import { findWorkspaceRoot } from '../utils/workspace.js'
|
|
45
|
+
import {
|
|
46
|
+
normalizeFoundationName,
|
|
47
|
+
ensureFoundationName
|
|
48
|
+
} from '../utils/foundation-name.js'
|
|
45
49
|
import {
|
|
46
50
|
detectPackageManager,
|
|
47
51
|
detectWorkspacePm,
|
|
@@ -366,6 +370,12 @@ async function addFoundation(rootDir, projectName, opts, pm = 'pnpm') {
|
|
|
366
370
|
let { packageName } = placement
|
|
367
371
|
const fullPath = join(rootDir, relativePath)
|
|
368
372
|
|
|
373
|
+
// The foundation's own name — what it registers as, written to main.js — is
|
|
374
|
+
// the name asked for, before any workspace suffix below; with none (`src`),
|
|
375
|
+
// the project's.
|
|
376
|
+
const askedName =
|
|
377
|
+
normalizeFoundationName(packageName) || normalizeFoundationName(projectName)
|
|
378
|
+
|
|
369
379
|
// Validate the derived package name (format + reserved-name check). The
|
|
370
380
|
// auto-derived `src` default is grandfathered in (`src` IS reserved
|
|
371
381
|
// but `src` is the convention for "the package that lives in src/").
|
|
@@ -435,10 +445,15 @@ async function addFoundation(rootDir, projectName, opts, pm = 'pnpm') {
|
|
|
435
445
|
}
|
|
436
446
|
|
|
437
447
|
// Scaffold
|
|
448
|
+
const registryName = await unclaimedFoundationName(rootDir, [
|
|
449
|
+
askedName,
|
|
450
|
+
normalizeFoundationName(packageName)
|
|
451
|
+
])
|
|
438
452
|
await scaffoldFoundation(
|
|
439
453
|
fullPath,
|
|
440
454
|
{
|
|
441
455
|
name: packageName,
|
|
456
|
+
registryName,
|
|
442
457
|
projectName,
|
|
443
458
|
isExtension: false
|
|
444
459
|
},
|
|
@@ -447,9 +462,11 @@ async function addFoundation(rootDir, projectName, opts, pm = 'pnpm') {
|
|
|
447
462
|
}
|
|
448
463
|
)
|
|
449
464
|
|
|
450
|
-
// Apply template content if --from specified
|
|
465
|
+
// Apply template content if --from specified. Its main.js replaces the
|
|
466
|
+
// scaffolded one, so the name is given again unless the template names it.
|
|
451
467
|
if (opts.from) {
|
|
452
468
|
await applyFromTemplate(opts.from, 'foundation', fullPath, projectName)
|
|
469
|
+
ensureFoundationName(fullPath, registryName)
|
|
453
470
|
}
|
|
454
471
|
|
|
455
472
|
// Register the package in pnpm-workspace.yaml — by exact path, not by glob.
|
|
@@ -685,11 +702,18 @@ async function addExtension(rootDir, projectName, opts, pm = 'pnpm') {
|
|
|
685
702
|
process.exit(1)
|
|
686
703
|
}
|
|
687
704
|
|
|
688
|
-
// Scaffold foundation with extension flag
|
|
705
|
+
// Scaffold foundation with extension flag. Its name — what it registers as —
|
|
706
|
+
// is the one asked for, not the package's suffixed one, unless a foundation in
|
|
707
|
+
// the workspace already registers as that.
|
|
708
|
+
const registryName = await unclaimedFoundationName(rootDir, [
|
|
709
|
+
normalizeFoundationName(name),
|
|
710
|
+
normalizeFoundationName(extensionPackageName)
|
|
711
|
+
])
|
|
689
712
|
await scaffoldFoundation(
|
|
690
713
|
fullPath,
|
|
691
714
|
{
|
|
692
715
|
name: extensionPackageName,
|
|
716
|
+
registryName,
|
|
693
717
|
projectName,
|
|
694
718
|
isExtension: true
|
|
695
719
|
},
|
|
@@ -701,6 +725,7 @@ async function addExtension(rootDir, projectName, opts, pm = 'pnpm') {
|
|
|
701
725
|
// Apply template content if --from specified
|
|
702
726
|
if (opts.from) {
|
|
703
727
|
await applyFromTemplate(opts.from, 'extension', fullPath, projectName)
|
|
728
|
+
ensureFoundationName(fullPath, registryName)
|
|
704
729
|
}
|
|
705
730
|
|
|
706
731
|
// Update workspace globs
|
|
@@ -848,12 +873,18 @@ async function addProject(rootDir, projectName, opts, pm = 'pnpm') {
|
|
|
848
873
|
|
|
849
874
|
const progressCb = (msg) => info(` ${msg}`)
|
|
850
875
|
|
|
851
|
-
// Scaffold foundation (folder: src/, package name: <project>-src
|
|
876
|
+
// Scaffold foundation (folder: src/, package name: <project>-src, name — what
|
|
877
|
+
// it registers as — <project>)
|
|
852
878
|
info(`Creating foundation: ${foundationPkgName}...`)
|
|
879
|
+
const registryName = await unclaimedFoundationName(rootDir, [
|
|
880
|
+
normalizeFoundationName(name),
|
|
881
|
+
normalizeFoundationName(foundationPkgName)
|
|
882
|
+
])
|
|
853
883
|
await scaffoldFoundation(
|
|
854
884
|
join(projectDir, 'src'),
|
|
855
885
|
{
|
|
856
886
|
name: foundationPkgName,
|
|
887
|
+
registryName,
|
|
857
888
|
projectName,
|
|
858
889
|
isExtension: false
|
|
859
890
|
},
|
|
@@ -882,6 +913,7 @@ async function addProject(rootDir, projectName, opts, pm = 'pnpm') {
|
|
|
882
913
|
join(projectDir, 'src'),
|
|
883
914
|
projectName
|
|
884
915
|
)
|
|
916
|
+
ensureFoundationName(join(projectDir, 'src'), registryName)
|
|
885
917
|
await applyFromTemplate(
|
|
886
918
|
opts.from,
|
|
887
919
|
'site',
|
|
@@ -987,6 +1019,32 @@ function computeFoundationPath(sitePath, foundationPath) {
|
|
|
987
1019
|
return `file:${rel}`
|
|
988
1020
|
}
|
|
989
1021
|
|
|
1022
|
+
/**
|
|
1023
|
+
* The first of `candidates` that no foundation or extension in the workspace
|
|
1024
|
+
* registers as — null when each is taken or missing, and `uniweb register` asks.
|
|
1025
|
+
*
|
|
1026
|
+
* A package name is kept unique by suffixing (`ui-ext`), but what a package
|
|
1027
|
+
* REGISTERS as is its main.js name: two foundations named `ui` in one workspace
|
|
1028
|
+
* would both register `@org/ui`, and the second would land on the first's
|
|
1029
|
+
* version. Compared without scope, so `@acme/ui` counts as `ui`.
|
|
1030
|
+
*
|
|
1031
|
+
* @param {string} rootDir
|
|
1032
|
+
* @param {Array<string|null>} candidates
|
|
1033
|
+
* @returns {Promise<string|null>}
|
|
1034
|
+
*/
|
|
1035
|
+
async function unclaimedFoundationName(rootDir, candidates) {
|
|
1036
|
+
const taken = new Set()
|
|
1037
|
+
for (const f of await discoverFoundations(rootDir)) {
|
|
1038
|
+
try {
|
|
1039
|
+
const { name } = await readFoundationName(join(rootDir, f.path))
|
|
1040
|
+
if (name) taken.add(name.replace(/^@[^/]+\//, ''))
|
|
1041
|
+
} catch {
|
|
1042
|
+
// An unreadable main.js — the build reports it; it claims no name here.
|
|
1043
|
+
}
|
|
1044
|
+
}
|
|
1045
|
+
return candidates.find((c) => c && !taken.has(c)) || null
|
|
1046
|
+
}
|
|
1047
|
+
|
|
990
1048
|
/**
|
|
991
1049
|
* Apply content from a template to a scaffolded package
|
|
992
1050
|
*
|
package/src/commands/clone.js
CHANGED
|
@@ -399,7 +399,7 @@ export async function clone(args = [], deps = {}) {
|
|
|
399
399
|
// bytes on disk, not of what the scaffolder first wrote.
|
|
400
400
|
recordWritten(
|
|
401
401
|
siteDir,
|
|
402
|
-
['site.yml', 'theme.yml', 'head.html', 'queries.yml', 'records.yml']
|
|
402
|
+
['site.yml', 'theme.yml', 'head.html', 'queries.yml', join('records', 'folder.yml')]
|
|
403
403
|
.map((f) => join(siteDir, f))
|
|
404
404
|
.filter((p) => existsSync(p))
|
|
405
405
|
)
|
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
|