uniweb 0.69.5 → 0.70.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "uniweb",
3
- "version": "0.69.5",
3
+ "version": "0.70.1",
4
4
  "description": "Create structured Vite + React sites with content/code separation",
5
5
  "type": "module",
6
6
  "bin": {
@@ -41,15 +41,15 @@
41
41
  "js-yaml": "^4.1.0",
42
42
  "prompts": "^2.4.2",
43
43
  "tar": "^7.0.0",
44
- "@uniweb/content-writer": "^0.3.4",
45
- "@uniweb/core": "^0.29.8",
46
- "@uniweb/kit": "^0.19.4",
47
- "@uniweb/schemas": "^0.7.0",
48
- "@uniweb/runtime": "^0.27.4",
49
- "@uniweb/semantic-parser": "^1.4.1"
44
+ "@uniweb/core": "^0.30.1",
45
+ "@uniweb/runtime": "^0.27.6",
46
+ "@uniweb/kit": "^0.19.5",
47
+ "@uniweb/schemas": "^0.7.1",
48
+ "@uniweb/semantic-parser": "^1.4.1",
49
+ "@uniweb/content-writer": "^0.3.4"
50
50
  },
51
51
  "peerDependencies": {
52
- "@uniweb/build": "^0.63.4",
52
+ "@uniweb/build": "^0.64.1",
53
53
  "@uniweb/content-reader": "^1.2.5",
54
54
  "@uniweb/semantic-parser": "^1.4.1"
55
55
  },
@@ -2717,6 +2717,8 @@ Running `extract` before a build is the usual first mistake — it reads the com
2717
2717
  { "e5f6g7h8": { "default": "Learn More", "overrides": { "/pricing:cta": "See Pricing" } } }
2718
2718
  ```
2719
2719
 
2720
+ An override key is the page's route and the section's id (`cta` for `2-cta.md`); a page's own title, label, description and keywords are `/pricing:_meta`, and in `locales/records/` a record is named by its manifest context (`std/article/launch`).
2721
+
2720
2722
  *Free-form bodies* — when a language needs different structure, different images, or copy rewritten rather than translated. The file replaces one section's content:
2721
2723
 
2722
2724
  ```
@@ -2726,7 +2728,7 @@ locales/freeform/es/records/article/x.md # records work too
2726
2728
 
2727
2729
  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.
2728
2730
 
2729
- **Records translate in the same `extract` run**, into their own manifest at `locales/records/manifest.json` — keyed by the RECORD, so a record translated once is found by every query that returns it.
2731
+ **Records translate in the same `extract` run**, into their own manifest at `locales/records/manifest.json` — keyed by the RECORD, so a record translated once is found by every query that returns it. A record whose query has a data schema translates exactly the fields the schema says are text — never an enum, a URL, a date, a number, a file, a reference or a `translatable: false` field — which are the fields a push carries in each language; mark a string that is a code or a key `translatable: false` and no translator is asked for it. An excerpt the build derives is re-derived from each language's body.
2730
2732
 
2731
2733
  **Component side.** Nothing to do: `content.title` arrives in the active language. The one thing a foundation builds is a switcher.
2732
2734
 
@@ -74,10 +74,12 @@ export function resolveBackendOrigin() {
74
74
  * The fallback capability doc for when `GET /dev/config` was not asked for (no
75
75
  * credential in hand) or did not answer. Keeps the client non-breaking.
76
76
  *
77
- * ⭐ **It is EMPTY, and that is the accurate shape.** The CLI reads exactly one leaf of
78
- * that document — `delivery.siteSubscriptionRequired` — and its absence is meaningful:
79
- * unknown reads falsy, and the caller stays silent rather than claiming a deployment
80
- * does or does not charge. Every other key that used to sit here had no reader.
77
+ * ⭐ **It is EMPTY, and that is the accurate shape.** The CLI reads only that document's
78
+ * `siteContent` key lists — `queryFields`, `pageFields`, `settingsFields` — and their absence
79
+ * is meaningful: unknown means a push sends no optional key, which every deployment accepts,
80
+ * rather than claiming a deployment takes one. Every other key that used to sit here had no
81
+ * reader. *(The leaf named here was `delivery.siteSubscriptionRequired` until 2026-09-26; it
82
+ * went 2026-08-30.)*
81
83
  *
82
84
  * ⛔ **Do not restore a key "for completeness".** A default for a field nothing reads is
83
85
  * a reader waiting to happen, and it is how this file came to describe a client that
@@ -457,9 +459,12 @@ export class BackendClient {
457
459
  * were dropped because a serve location is read from the response that carries it
458
460
  * (`serve_base`, `serve_url`, `config.base`), never from a handshake; `auth.loginPath`
459
461
  * is not read either — the login path is a hardcoded constant
460
- * (`utils/registry-auth.js`). What is actually consumed is ONE leaf —
461
- * `delivery.siteSubscriptionRequired` — and nothing else. Do not add a reader for the
462
- * rest: each one would be a second place a backend's layout is pinned.
462
+ * (`utils/registry-auth.js`). What is actually consumed is the key lists under
463
+ * `siteContent` — `queryFields`, `pageFields`, `settingsFields`, the keys the deployment's
464
+ * Sections declare (`site-sync.js::deploymentFields`) — which is policy, never an address. Do not
465
+ * add a reader for the rest: each one would be a second place a backend's layout is
466
+ * pinned. (This named `delivery.siteSubscriptionRequired` until 2026-09-26; that leaf
467
+ * went 2026-08-30.)
463
468
  *
464
469
  * @returns {Promise<object>}
465
470
  */
@@ -577,6 +582,32 @@ export class BackendClient {
577
582
  }
578
583
  }
579
584
 
585
+ /**
586
+ * GET /dev/site/foundation-schema/{site}/{@scope}/{name}/{version} → the registered version of a
587
+ * foundation a site uses, as the backend holds it: its `schema` (section types with their
588
+ * `data:`), `info`, `data_schemas` (names), `module_url` and `css_url`. Read through the SITE,
589
+ * which is the licence: the caller must be able to read the site, and the site must use the
590
+ * foundation. Null on a 404 (not registered, or not this site's) and on any failure — a caller
591
+ * degrades, since what it is read for can be done without it, less well.
592
+ * @param {string} siteUuid - the site-content uuid
593
+ * @param {string} ref - the catalog ref, `@scope/name@version`
594
+ * @returns {Promise<object|null>}
595
+ */
596
+ async readRegisteredFoundation(siteUuid, ref) {
597
+ const m = /^(@[^/]+)\/([^@/]+)@(.+)$/.exec(String(ref || ''))
598
+ if (!siteUuid || !m) return null
599
+ try {
600
+ const res = await this.request(
601
+ `/dev/site/foundation-schema/${encodeURIComponent(siteUuid)}/${m[1]}/${encodeURIComponent(m[2])}/${encodeURIComponent(m[3])}`
602
+ )
603
+ if (!res.ok) return null
604
+ const body = await res.json().catch(() => null)
605
+ return body && typeof body === 'object' && body.schema && typeof body.schema === 'object' ? body : null
606
+ } catch {
607
+ return null
608
+ }
609
+ }
610
+
580
611
  // ── Orgs ──────────────────────────────────────────────────────────────────────
581
612
 
582
613
  /** GET /dev/orgs → { account_handle, orgs[] }. */
@@ -76,6 +76,11 @@ export function fingerprintDeclaration(declared) {
76
76
  .slice(0, 16)
77
77
  }
78
78
 
79
+ /** Absent, or an empty list — a stored request that asks for nothing. */
80
+ function isNothing(value) {
81
+ return value === undefined || value === null || (Array.isArray(value) && value.length === 0)
82
+ }
83
+
79
84
  /** Deterministic JSON: object keys sorted at every depth, arrays left in order. */
80
85
  function stableString(value) {
81
86
  return JSON.stringify(value, (_k, v) =>
@@ -212,6 +217,12 @@ export function reconcile(localValue, remoteValue, baseFingerprint) {
212
217
  const base = baseFingerprint || null
213
218
 
214
219
  if (local === remote) return { action: 'none', local, remote }
220
+ // ⛔ A FILE THAT DECLARES NOTHING ASKS NOTHING, so it is never one side of a conflict. With
221
+ // nothing stored either — an empty list is the store's nothing — there is nothing to do; with
222
+ // something stored, the file is merely behind. Until 2026-09-26 this was a `conflict`, and the
223
+ // first publish of a site whose file is silent warned that its services "were changed
224
+ // elsewhere, and site.yml changed too", listing both as nothing.
225
+ if (local === null) return { action: isNothing(remoteValue) ? 'none' : 'adopt', local, remote }
215
226
  if (!base) return { action: 'conflict', local, remote }
216
227
 
217
228
  const localMoved = local !== base
@@ -46,7 +46,10 @@ import {
46
46
  harvestRecordItems,
47
47
  storedRecordItems,
48
48
  reprintRecordItems,
49
- matchStoredRecords
49
+ matchStoredRecords,
50
+ parseCatalogRef,
51
+ readRegisteredFoundation,
52
+ writeRegisteredFoundation
50
53
  } from '@uniweb/build/uwx'
51
54
 
52
55
  // First entity `$`-document out of a `.uwx` we produced or the backend served.
@@ -326,18 +329,46 @@ function changedSummary(finalized) {
326
329
  // 404 → null (the emitter then says "register it first"). The bearer is acquired lazily
327
330
  // by the client, so a fully-local sync never authenticates.
328
331
  //
329
- // `offline` (set for `-o` / `--dry-run`) forces every non-local Model to null WITHOUT
330
- // touching the backend — an offline emit must never authenticate.
331
- export function makeModelResolver({ client, offline = false }) {
332
+ // `offline` (set for `-o` / `--dry-run`, and for `status`) never touches the backend — an
333
+ // offline emit must never authenticate. It answers with the Models a push or a pull KEPT for
334
+ // this backend (`keptModels`), or null. ⭐ With `siteDir`, an online read keeps each Model it
335
+ // reads, per backend — so a clone, whose foundation is not in the project to declare them, can
336
+ // still compare offline. ⛔ Until 2026-09-26 nothing was kept, and `uniweb status` in a clone
337
+ // could not resolve a Model ("… could not be resolved").
338
+ export function makeModelResolver({ client, offline = false, siteDir = null, backend = null }) {
339
+ const origin = backend ?? client?.origin ?? null
340
+ const kept = siteDir && origin ? keptModels(siteDir, origin) : {}
332
341
  const cache = new Map()
333
342
  return async (modelName) => {
334
343
  if (cache.has(modelName)) return cache.get(modelName)
335
- const decl = offline ? null : await client.readDataSchema(modelName)
344
+ let decl
345
+ if (offline) {
346
+ decl = kept[modelName] ?? null
347
+ } else {
348
+ decl = await client.readDataSchema(modelName)
349
+ if (decl && siteDir && origin) keepModels(siteDir, origin, { [modelName]: decl })
350
+ }
336
351
  cache.set(modelName, decl)
337
352
  return decl
338
353
  }
339
354
  }
340
355
 
356
+ /**
357
+ * The Models a push or a pull read from this backend, by name — kept in `.uniweb/backend-cache.json`
358
+ * so an offline emit resolves what an online one did. Regenerable: losing them costs `status` its
359
+ * comparison until the next push or pull, never a wrong answer.
360
+ */
361
+ export function keptModels(siteDir, backend) {
362
+ const models = readSyncCacheFile(siteDir, backend).models
363
+ return models && typeof models === 'object' ? models : {}
364
+ }
365
+
366
+ /** Keep Models read from this backend, beside the ones already kept. */
367
+ export function keepModels(siteDir, backend, models) {
368
+ if (!models || !Object.keys(models).length) return
369
+ updateSyncCache(siteDir, backend, { models: { ...keptModels(siteDir, backend), ...models } })
370
+ }
371
+
341
372
  // `.uniweb/backend-cache.json` — what a backend had and what we last sent it, so the
342
373
  // next transfer can skip work. Gitignored, per clone, safe to delete.
343
374
  //
@@ -653,6 +684,50 @@ export function readAppliedInjections(siteDir, backend) {
653
684
  return readMap(siteDir, backend, 'applied')
654
685
  }
655
686
 
687
+ /**
688
+ * ⭐ THE REGISTERED FOUNDATION A CLONE KEEPS. A site whose foundation is a catalog ref has no build of
689
+ * it in the project, and the section types' `data:` it declares are what type a query named for a
690
+ * data key — `team:` read as `@acme/member` (`typed_by_data_key`). Read once from the backend,
691
+ * through the site, and kept under `.uniweb/` (`@uniweb/build`'s `registered-foundation.js`), where
692
+ * the build, the push and the pull read it; a registered version never changes, so a kept copy is
693
+ * never read again.
694
+ *
695
+ * Null when the foundation is not a catalog ref, the site is not on this backend yet, or the read
696
+ * fails: nothing is then typed by it — a pull writes such a query's schema out, and a push sends
697
+ * what that says — so both go on.
698
+ */
699
+ export async function ensureRegisteredFoundation({ client, siteDir, siteUuid, ref }) {
700
+ if (!parseCatalogRef(ref)) return null
701
+ const kept = readRegisteredFoundation(siteDir, ref)
702
+ if (kept) return kept
703
+ const reply = siteUuid ? await client.readRegisteredFoundation(siteUuid, ref) : null
704
+ if (reply) writeRegisteredFoundation(siteDir, ref, reply)
705
+ return reply
706
+ }
707
+
708
+ // The Sections whose declared keys a push reads before it sends an optional one.
709
+ const DEPLOYMENT_FIELD_LISTS = ['queryFields', 'pageFields', 'settingsFields']
710
+
711
+ /**
712
+ * The keys this deployment's `queries`, page and `settings` Sections declare, as it says so itself —
713
+ * `GET /dev/config` → `siteContent.queryFields` / `pageFields` / `settingsFields` — so a push sends
714
+ * an optional key only to a deployment that takes it (`typed_by_data_key`; `og_title` and
715
+ * `og_description`): one that does not declare a key refuses a push carrying it. Offline — a dry
716
+ * run, `--output` — the lists the last push to this backend banked, so the package is the one that
717
+ * push would build.
718
+ *
719
+ * Only the lists in hand are returned, as emit options: with none, nothing optional is sent, which
720
+ * every deployment accepts.
721
+ */
722
+ export async function deploymentFields({ client, siteDir, offline = false }) {
723
+ const source = offline
724
+ ? readAppliedInjections(siteDir, client.origin)
725
+ : (await client.discover())?.siteContent
726
+ const out = {}
727
+ for (const name of DEPLOYMENT_FIELD_LISTS) if (Array.isArray(source?.[name])) out[name] = source[name]
728
+ return out
729
+ }
730
+
656
731
  /**
657
732
  * Bank the content hashes and the injections that produced them. ⛔ ONE call, both
658
733
  * maps: they describe the same document, so writing either alone leaves the cache
@@ -1430,7 +1505,7 @@ export async function probeUnpushed(siteDir, { backend = null, sendAll = false }
1430
1505
  */
1431
1506
  async function comparisonEmit(
1432
1507
  siteDir,
1433
- { backend = null, priorHashes = {}, sendAll = false, declarations = null } = {}
1508
+ { backend = null, priorHashes = {}, sendAll = false, declarations = null, fields = null } = {}
1434
1509
  ) {
1435
1510
  const applied = readAppliedInjections(siteDir, backend)
1436
1511
  // ⛔ Per backend: an asset id is minted by one and means nothing to another, so a
@@ -1446,10 +1521,13 @@ async function comparisonEmit(
1446
1521
  // of its Models here, and nothing is banked.
1447
1522
  resolveModel: declarations
1448
1523
  ? async (name) => declarations.get(name) ?? null
1449
- : makeModelResolver({ client: null, offline: true }),
1524
+ : makeModelResolver({ client: null, offline: true, siteDir, backend }),
1450
1525
  priorHashes,
1451
1526
  sendAll,
1452
1527
  ...applied,
1528
+ // The deployment's own answer (`deploymentFields`), when the caller has just read it, over what
1529
+ // the last push banked.
1530
+ ...(fields || {}),
1453
1531
  ...(Object.keys(queryUuids).length ? { queryUuids } : {}),
1454
1532
  ...(Object.keys(assetIds).length ? { assetIds } : {})
1455
1533
  // ⛔ No `org`: a site's `@/x` refs resolve into its FOUNDATION's scope, which the
@@ -1484,9 +1562,12 @@ async function comparisonEmit(
1484
1562
  * @param {Map<string,object>} [opts.declarations] - the Models the pull read, by name — so a
1485
1563
  * site whose foundation is not in the project (a clone's) can be re-banked offline
1486
1564
  */
1487
- export async function rebankSyncHashes(siteDir, backend = null, { recordDocs, declarations } = {}) {
1488
- const pkg = await comparisonEmit(siteDir, { backend, sendAll: true, declarations })
1489
- writeSyncCache(siteDir, backend, pkg.hashes || {}, pkg.applied || {})
1565
+ export async function rebankSyncHashes(siteDir, backend = null, { recordDocs, declarations, fields = null } = {}) {
1566
+ // ⛔ With the keys this deployment's Sections take, when the caller has them (`deploymentFields`):
1567
+ // they shape the document the next push sends (`typed_by_data_key`, `og_title`), so a hash banked
1568
+ // without them marks a clone's first push as changed. Until 2026-09-26 a pull banked none.
1569
+ const pkg = await comparisonEmit(siteDir, { backend, sendAll: true, declarations, fields })
1570
+ writeSyncCache(siteDir, backend, onlyWhatThePullTook(siteDir, backend, pkg, recordDocs), pkg.applied || {})
1490
1571
  // ⭐ And the identity of the records' list items, from the documents the pull just took:
1491
1572
  // the files it wrote hold the stored items in stored order, so the emit over them pairs
1492
1573
  // with each document item for item.
@@ -1505,6 +1586,44 @@ export async function rebankSyncHashes(siteDir, backend = null, { recordDocs, de
1505
1586
  return Object.keys(pkg.hashes || {}).length
1506
1587
  }
1507
1588
 
1589
+ /**
1590
+ * The hashes a pull may bank: the tree's, except for a record the pull did not take.
1591
+ *
1592
+ * ⛔ ONLY WHAT CAME FROM THE BACKEND IS AGREED. A record on disk that the backend did not
1593
+ * hand back — written since the last push, or one a push had refused — is not in sync
1594
+ * because a pull ran. Until 2026-09-26 it was banked with the rest, so every later push
1595
+ * skipped it and `uniweb status` said "in sync": a record written before a `uniweb sync`
1596
+ * (which pulls, then pushes) never reached the backend. One this backend never accepted —
1597
+ * no identity from it — is always sent: a hash it has banked can only be that mistake's.
1598
+ * One it did accept keeps what the last push banked. The folder keeps its hash too while
1599
+ * it places such a record. A pull that took no records (`--no-records`, a 304) leaves
1600
+ * every record's hash as it was.
1601
+ *
1602
+ * @param {object[]|undefined} recordDocs - the record documents the pull took
1603
+ */
1604
+ function onlyWhatThePullTook(siteDir, backend, pkg, recordDocs) {
1605
+ const hashes = { ...(pkg.hashes || {}) }
1606
+ const prior = readSyncCache(siteDir, backend)
1607
+ const keep = (key) => {
1608
+ if (Object.hasOwn(prior, key)) hashes[key] = prior[key]
1609
+ else delete hashes[key]
1610
+ }
1611
+ const taken = new Set((recordDocs || []).map((d) => d?.$uuid).filter(Boolean))
1612
+ const recordUuids = readBackendState(siteDir, backend).records || {}
1613
+ let notTaken = 0
1614
+ for (const entry of pkg.records?.index || []) {
1615
+ if (entry.kind === 'folder') continue // the folder itself — held below while it places one
1616
+ const theirs = entry.ownId ? recordUuids[entry.ownId] : null
1617
+ if (theirs && taken.has(theirs)) continue
1618
+ notTaken++
1619
+ const key = `${entry.model} ${entry.id}`
1620
+ if (theirs) keep(key)
1621
+ else delete hashes[key]
1622
+ }
1623
+ if (notTaken) for (const key of Object.keys(hashes)) if (key.startsWith('@uniweb/folder ')) keep(key)
1624
+ return hashes
1625
+ }
1626
+
1508
1627
  /**
1509
1628
  * Re-bank the hashes of the records a push sent naming a record it created — `keys`,
1510
1629
  * the emit's `namesNew` — as the next push will hash them, and fingerprint their list
@@ -1714,6 +1833,18 @@ export async function pushSyncPackages({
1714
1833
  if (problem.detail) note(String(problem.detail))
1715
1834
  return null
1716
1835
  }
1836
+ // ⭐ A RECORD KEEPS ITS TYPE. A record file moved to another type's folder keeps its `$uuid`, and
1837
+ // the backend refuses it as that type (`type_changed`, naming the record and both types), before
1838
+ // writing anything. Storing it as the new type makes it a new record.
1839
+ if (problem?.reason === 'type_changed') {
1840
+ error(`${label} push refused — a stored record would change its type.`)
1841
+ if (problem.detail) note(String(problem.detail))
1842
+ note(
1843
+ 'A record keeps its type. To store it as the new type, make it a new record: delete the ' +
1844
+ '`$uuid` line from its file, then push again.'
1845
+ )
1846
+ return null
1847
+ }
1717
1848
  if (problem?.reason === 'stale_base') {
1718
1849
  error(
1719
1850
  `${label} push refused — the backend has newer content than your last pull.`
@@ -1798,15 +1929,14 @@ export async function pushSyncPackages({
1798
1929
  ` deleted there → uniweb forget --backend ${client.origin}, then push again: it creates a NEW site`
1799
1930
  )
1800
1931
  note('Deleting this folder removes only your local copy, either way.')
1801
- } else if (res.status === 409) {
1802
- // The site's @uniweb/folder is genesis-owned: its structure is fixed on first
1803
- // deploy and not reconciled in place (the v1 rule — see gotcha #20's mode switch).
1804
- note(
1805
- "This site's record structure is already established on the backend and can't be changed " +
1806
- 'in place — e.g. adding or removing a schema-backed query, or switching one between ' +
1807
- 'static (data-bundle) and schema-backed delivery. To change it, push it as a new site: ' +
1808
- `uniweb forget --backend ${client.origin}, then push again (the old site stays until it is deleted).`
1809
- )
1932
+ } else if (res.status === 409 && (problem?.title || problem?.detail)) {
1933
+ // ⛔ A 409 names its own cause — a reference the store refuses, a rule of the record's type,
1934
+ // an append-only section — so it is shown as the backend put it, not explained by us. Until
1935
+ // 2026-09-26 every one was explained as "this site's record structure is already established
1936
+ // … push it as a new site", a rule the backend no longer has, and it misled over a refused
1937
+ // reference. (`stale_base` and `item_uuid_conflict` have their own explanations, above.)
1938
+ note([problem.title, problem.detail].filter(Boolean).join(' — '))
1939
+ return null
1810
1940
  }
1811
1941
  if (body) note(body.slice(0, 800))
1812
1942
  return null
@@ -668,7 +668,10 @@ async function buildSiteLink(projectDir, options = {}) {
668
668
  projectDir,
669
669
  i18nConfig.localesDir,
670
670
  'records'
671
- )
671
+ ),
672
+ // Free-form record translations live under it — the default was `locales/`
673
+ // whatever the site said.
674
+ localesDir: join(projectDir, i18nConfig.localesDir)
672
675
  })
673
676
  const recordCount = Object.values(recordOutputs).reduce(
674
677
  (sum, localeOutputs) => sum + Object.keys(localeOutputs).length,
@@ -789,7 +792,10 @@ async function buildSite(projectDir, options = {}) {
789
792
  projectDir,
790
793
  i18nConfig.localesDir,
791
794
  'records'
792
- )
795
+ ),
796
+ // Free-form record translations live under it — the default was `locales/`
797
+ // whatever the site said.
798
+ localesDir: join(projectDir, i18nConfig.localesDir)
793
799
  })
794
800
 
795
801
  // Count collections translated
@@ -426,6 +426,12 @@ export async function clone(args = [], deps = {}) {
426
426
 
427
427
  const pullExtra = []
428
428
  if (noRecords) pullExtra.push('--no-records')
429
+ // The workspace this clone was told to work in: the pull is another process, and asks the same
430
+ // question. ⛔ Until 2026-09-26 it was not told, so it used the login's — or, with none chosen,
431
+ // refused: "none is chosen". A workspace from the login or the environment reaches it by itself.
432
+ const orgFlag = flagValue(args, '--org')
433
+ if (orgFlag) pullExtra.push('--org', orgFlag)
434
+ if (args.includes('--personal')) pullExtra.push('--personal')
429
435
 
430
436
  if (deps.skipPull) {
431
437
  note('Skipping pull (test mode).')
@@ -37,9 +37,9 @@
37
37
  * uniweb publish --no-save Do not record this publish in deploy.yml
38
38
  */
39
39
 
40
- import { existsSync, readFileSync } from 'node:fs'
40
+ import { existsSync, readFileSync, readdirSync } from 'node:fs'
41
41
  import { readFile } from 'node:fs/promises'
42
- import { join } from 'node:path'
42
+ import { join, dirname } from 'node:path'
43
43
  import { execSync } from 'node:child_process'
44
44
  import yaml from 'js-yaml'
45
45
 
@@ -87,6 +87,8 @@ import {
87
87
  ensureItemUuids,
88
88
  readFolderItemUuids,
89
89
  readRecordItemUuids,
90
+ deploymentFields,
91
+ ensureRegisteredFoundation,
90
92
  recoverUnbankedIdentity,
91
93
  ensureSiteExists,
92
94
  refuseUnsendableRecords,
@@ -143,14 +145,27 @@ function readSiteYml(path) {
143
145
  }
144
146
  }
145
147
 
146
- // Languages from the BUILT site-content.json (config.languages) — the authority
147
- // after a build. Three accepted shapes: 'en', { value, label }, { code, label }.
148
- function languagesFromContent(siteContent) {
148
+ // Languages from the BUILT site — the authority after a build: `config.languages` where
149
+ // the site declares them (three accepted shapes: 'en', { value, label }, { code, label }),
150
+ // else its default and each language the build produced (`dist/<locale>/site-content.json`).
151
+ // ⛔ Until 2026-09-26 a site that declares no `languages:` — its languages made by its
152
+ // translation files — went live as `['en']`, though the build had produced the others.
153
+ export function languagesFromContent(siteContent, distDir = null) {
149
154
  const langs = siteContent?.config?.languages
150
- if (!Array.isArray(langs) || langs.length === 0) return ['en']
151
- return langs
152
- .map((l) => (typeof l === 'string' ? l : l?.value || l?.code))
153
- .filter(Boolean)
155
+ if (Array.isArray(langs) && langs.length > 0) {
156
+ return langs
157
+ .map((l) => (typeof l === 'string' ? l : l?.value || l?.code))
158
+ .filter(Boolean)
159
+ }
160
+ const source = siteContent?.config?.defaultLanguage || 'en'
161
+ const built =
162
+ distDir && existsSync(distDir)
163
+ ? readdirSync(distDir, { withFileTypes: true })
164
+ .filter((e) => e.isDirectory() && e.name !== source && existsSync(join(distDir, e.name, 'site-content.json')))
165
+ .map((e) => e.name)
166
+ .sort()
167
+ : []
168
+ return [source, ...built]
154
169
  }
155
170
 
156
171
  // Languages from site.yml — used only for the dry-run summary (no build yet).
@@ -326,9 +341,11 @@ export async function publish(args = []) {
326
341
  // (2026-08-30). Restoring a reader for it would re-create a check that cannot fail
327
342
  // while implying a capability that was never negotiable.
328
343
  //
329
- // Discovery is not consulted on this path at all. The one leaf the CLI still reads
330
- // (`delivery.siteSubscriptionRequired`) is read after the site create, where a
331
- // credential is already in hand.
344
+ // Discovery is not consulted for a gate: the CLI reads the key lists under `siteContent`
345
+ // (`queryFields`, `pageFields`, `settingsFields`), which say what the push below may SEND
346
+ // (`deploymentFields`), never whether to publish. (`delivery.siteSubscriptionRequired`
347
+ // was deleted 2026-08-30; this said it was still read, after the site create, until
348
+ // 2026-09-26.)
332
349
 
333
350
  // ⛔ NOTHING about a runtime is sent from here. `site.yml::runtime` was a
334
351
  // vestigial prop and is no longer read [Diego, 2026-08-22]; `?runtime=` is no
@@ -537,7 +554,7 @@ export async function publish(args = []) {
537
554
  }
538
555
 
539
556
  // Non-local @std/registry Model schemas resolve through the backend (same as push).
540
- const resolveModel = makeModelResolver({ client, offline: false })
557
+ const resolveModel = makeModelResolver({ client, offline: false, siteDir })
541
558
 
542
559
  // ⛔ AN EMPTY RECORDS DIRECTORY REMOVES. It is the one path where an ordinary act
543
560
  // is destructive — a directory emptied by accident, or kept with only a
@@ -948,8 +965,19 @@ export async function publish(args = []) {
948
965
  // edited since this clone last synced, the push is refused rather than
949
966
  // overwriting them, and nothing goes live. `--force` drops the precondition.
950
967
  const forced = args.includes('--force')
968
+ const fields = await deploymentFields({ client, siteDir })
969
+ // A foundation named by catalog ref — a clone's: its kept registered version types a query named
970
+ // for a data key (`ensureRegisteredFoundation`).
971
+ await ensureRegisteredFoundation({
972
+ client,
973
+ siteDir,
974
+ siteUuid: readBackendState(siteDir, client.origin).site?.uuid,
975
+ ref: siteYml?.foundation
976
+ })
951
977
  const emitOptions = {
952
978
  backend: client.origin,
979
+ // The keys this deployment's Sections take — see deploymentFields.
980
+ ...fields,
953
981
  ...(declaration.declare ? {} : { declareServices: false }),
954
982
  // Placement identity for the folder — see writeFolderItemUuids.
955
983
  folderItemUuids: readFolderItemUuids(siteDir, client.origin),
@@ -1005,7 +1033,7 @@ export async function publish(args = []) {
1005
1033
 
1006
1034
  // 7. Go live — make the just-pushed composite live (its current backend state).
1007
1035
  const siteContent = JSON.parse(await readFile(contentPath, 'utf8'))
1008
- const languages = languagesFromContent(siteContent)
1036
+ const languages = languagesFromContent(siteContent, dirname(contentPath))
1009
1037
  say.info(`Publishing to ${c.dim}${client.origin}${c.reset} …`)
1010
1038
  let pubRes
1011
1039
  try {
@@ -60,7 +60,9 @@ import {
60
60
  writeFileSync,
61
61
  mkdirSync,
62
62
  mkdtempSync,
63
- rmSync
63
+ rmSync,
64
+ readdirSync,
65
+ statSync
64
66
  } from 'node:fs'
65
67
  import { tmpdir } from 'node:os'
66
68
  import { createHash } from 'node:crypto'
@@ -87,6 +89,8 @@ import {
87
89
  } from '../utils/pull-written.js'
88
90
  import {
89
91
  makeModelResolver,
92
+ ensureRegisteredFoundation,
93
+ deploymentFields,
90
94
  rebankSyncHashes,
91
95
  writeQueryUuids,
92
96
  mergeBaseVersions,
@@ -427,6 +431,27 @@ function readManifestTokens(buf) {
427
431
  * @param {object} [deps] - injectable seams for testing: `fetch` (default global
428
432
  * fetch), `resolveSiteDir`, `getToken` (skip auth).
429
433
  */
434
+ // Every file under `roots` (site-relative paths, files or directories), site-relative.
435
+ function filesUnder(siteDir, roots) {
436
+ const out = []
437
+ const walk = (rel) => {
438
+ const abs = join(siteDir, rel)
439
+ let stat
440
+ try {
441
+ stat = statSync(abs)
442
+ } catch {
443
+ return
444
+ }
445
+ if (stat.isDirectory()) {
446
+ for (const name of readdirSync(abs)) walk(join(rel, name))
447
+ } else if (stat.isFile()) {
448
+ out.push(rel)
449
+ }
450
+ }
451
+ for (const root of roots) walk(root)
452
+ return out
453
+ }
454
+
430
455
  /**
431
456
  * Refuse a pull that would overwrite unsaved work. Returns a result object to
432
457
  * return from `pull`, or null to proceed.
@@ -440,7 +465,12 @@ async function checkWorkingTree(siteDir, args) {
440
465
  let dirty = uncommittedUnder(siteDir, roots)
441
466
 
442
467
  if (dirty === null) {
443
- // Not a git work tree. Nothing to fall back on if this goes wrong.
468
+ // Not a git work tree. Nothing to fall back on if this goes wrong — unless nothing here is the
469
+ // author's: every file under pull's roots is one a pull, or the clone that made this site, wrote
470
+ // and nothing has changed since (`pull-written.js`). ⛔ Until 2026-09-26 a fresh clone's own pull,
471
+ // which runs with no terminal to ask, refused here, and `uniweb clone` could not finish.
472
+ const written = readWritten(siteDir)
473
+ if (filesUnder(siteDir, roots).every((f) => isPullOutput(siteDir, f, written))) return null
444
474
  if (isNonInteractive(args)) {
445
475
  error('Refusing to pull: this site is not in a git repository.')
446
476
  note(
@@ -684,6 +714,8 @@ export async function pull(args = [], deps = {}) {
684
714
  args,
685
715
  command: 'Pulling'
686
716
  })
717
+ // One reader of Models for the whole pull, so a Model is read once whichever lane asks first.
718
+ const readModel = makeModelResolver({ client, siteDir })
687
719
 
688
720
  // ⭐ No scope check any more — and none is needed. This project's identity on
689
721
  // `client.origin` is read from that origin's own section of sync.json, so a
@@ -942,9 +974,28 @@ export async function pull(args = [], deps = {}) {
942
974
  // which is the pre-existing behaviour and right for a fresh clone.
943
975
  }
944
976
 
977
+ // ⭐ The Models the site's queries name — what a clone, with no foundation to read them from,
978
+ // tells a query's derived `deferred:` from its author's by. ⛔ Until 2026-09-26 a clone wrote
979
+ // the derivation into its queries as though authored. One that cannot be read is judged
980
+ // without it, which keeps the value: the safe direction.
981
+ const models = {}
982
+ for (const model of pulledQueryModels) {
983
+ try {
984
+ const declaration = await readModel(model)
985
+ if (declaration) models[model] = declaration
986
+ } catch {
987
+ // unreadable here
988
+ }
989
+ }
990
+ // A foundation named by catalog ref — a clone's: keep its registered version before the
991
+ // queries are written, since its section types are what type a query named for a data key.
992
+ if (!keepAuthoredFoundation) {
993
+ await ensureRegisteredFoundation({ client, siteDir, siteUuid: siteContentUuid, ref: siteDoc?.info?.foundation })
994
+ }
945
995
  const report = siteContentDocumentToProject({
946
996
  document: siteDoc,
947
997
  siteRoot: siteDir,
998
+ models,
948
999
  // Which backend's asset ids to read back as the author's own paths. Without
949
1000
  // it a pull leaves every image pointing at a backend route.
950
1001
  backend: client.origin,
@@ -985,7 +1036,7 @@ export async function pull(args = [], deps = {}) {
985
1036
  if (folder && !folder.notModified && folder.docs?.length) {
986
1037
  const { folderDoc, recordDocs } = splitRecordsPull(folder.docs)
987
1038
  pulledRecordDocs = recordDocs
988
- const resolveModel = makeModelResolver({ client })
1039
+ const resolveModel = readModel
989
1040
  const declByModel = new Map()
990
1041
  pulledDeclarations = declByModel
991
1042
  for (const model of [
@@ -1102,7 +1153,6 @@ export async function pull(args = [], deps = {}) {
1102
1153
  // in the project (a clone's) resolves none of them otherwise: every template clone
1103
1154
  // said "could not re-bank" until 2026-09-25, and banked nothing.
1104
1155
  const declarations = pulledDeclarations || new Map()
1105
- const readModel = makeModelResolver({ client })
1106
1156
  for (const model of pulledQueryModels) {
1107
1157
  if (declarations.has(model)) continue
1108
1158
  try {
@@ -1113,7 +1163,8 @@ export async function pull(args = [], deps = {}) {
1113
1163
  }
1114
1164
  await rebankSyncHashes(siteDir, client.origin, {
1115
1165
  recordDocs: pulledRecordDocs,
1116
- ...(declarations.size ? { declarations } : {})
1166
+ ...(declarations.size ? { declarations } : {}),
1167
+ fields: await deploymentFields({ client, siteDir })
1117
1168
  })
1118
1169
  } catch (err) {
1119
1170
  note(`! could not re-bank the sync cache: ${err.message}`)
@@ -83,6 +83,8 @@ import {
83
83
  readFolderItemUuids,
84
84
  readQueryUuids,
85
85
  readRecordItemUuids,
86
+ deploymentFields,
87
+ ensureRegisteredFoundation,
86
88
  recoverUnbankedIdentity,
87
89
  ensureItemUuids,
88
90
  refuseUnsendableRecords,
@@ -405,7 +407,7 @@ export async function push(args = [], deps = {}) {
405
407
  // Placement identity for the folder — see writeFolderItemUuids.
406
408
  folderItemUuids: readFolderItemUuids(siteDir, client.origin),
407
409
  ...(foundationDir ? { foundationDir } : {}),
408
- resolveModel: makeModelResolver({ client, offline: false })
410
+ resolveModel: makeModelResolver({ client, offline: false, siteDir })
409
411
  })
410
412
  mediaRefs = probe.localAssets || []
411
413
  refusals = probe.refusals || []
@@ -498,6 +500,21 @@ export async function push(args = [], deps = {}) {
498
500
  output || dryRun
499
501
  ? readItemUuids(siteDir, client.origin)
500
502
  : await ensureItemUuids({ client, siteDir, note })
503
+ const fields = await deploymentFields({
504
+ client,
505
+ siteDir,
506
+ offline: Boolean(output) || dryRun
507
+ })
508
+ // A foundation named by catalog ref — a clone's: its kept registered version types a query named
509
+ // for a data key (`ensureRegisteredFoundation`). Offline, only what is already kept.
510
+ if (!output && !dryRun) {
511
+ await ensureRegisteredFoundation({
512
+ client,
513
+ siteDir,
514
+ siteUuid: readBackendState(siteDir, client.origin).site?.uuid,
515
+ ref: siteYml?.foundation
516
+ })
517
+ }
501
518
  const emitOptions = {
502
519
  backend: client.origin,
503
520
  // Placement identity for the folder — see writeFolderItemUuids.
@@ -505,12 +522,15 @@ export async function push(args = [], deps = {}) {
505
522
  // Identity for the `queries` section — see readQueryUuids. Keyed by
506
523
  // name, because a declaration has no file for a path-keyed map to hold.
507
524
  queryUuids: readQueryUuids(siteDir, client.origin),
525
+ // The keys this deployment's Sections take — see deploymentFields.
526
+ ...fields,
508
527
  // Identity for the records' list items — see readRecordItemUuids.
509
528
  recordItemUuids: readRecordItemUuids(siteDir, client.origin),
510
529
  ...(foundationDir ? { foundationDir } : {}),
511
530
  resolveModel: makeModelResolver({
512
531
  client,
513
- offline: Boolean(output) || dryRun
532
+ offline: Boolean(output) || dryRun,
533
+ siteDir
514
534
  }),
515
535
  priorHashes,
516
536
  sendAll,
@@ -835,10 +835,18 @@ async function runRegister(args = []) {
835
835
  if (!res.ok) {
836
836
  // Resume path: a registered version is immutable, so re-running after a
837
837
  // partial code delivery hits the duplicate rejection here — a STRUCTURED
838
- // 409 (problem+json, title "Conflict") — and proceeds to phase 2 (the
839
- // code-uploads plan authorizes against the REGISTERED version; files it
840
- // already stores come back `present` and are skipped).
841
- const isDuplicate = !standalone && res.status === 409
838
+ // 409 (problem+json, title "Conflict", naming the `version` it refused) — and
839
+ // proceeds to phase 2 (the code-uploads plan authorizes against the REGISTERED
840
+ // version; files it already stores come back `present` and are skipped).
841
+ //
842
+ // ⛔ ONE STATUS, MORE THAN ONE MEANING. A 409 that names no version is another
843
+ // refusal — a data schema changed where the backend holds records of it
844
+ // (`reason: "destructive_republish"`) — and until 2026-09-26 it was read as a
845
+ // resume too: "already registered — resuming code delivery", then a 404 for a
846
+ // version that was never registered, and the refusal's own sentence never shown.
847
+ // A body with no shape to read resumes, as it always did.
848
+ const isDuplicate =
849
+ !standalone && res.status === 409 && (!parsedBody || typeof parsedBody !== 'object' || parsedBody.version !== undefined)
842
850
  if (isDuplicate) {
843
851
  alreadyRegistered = true
844
852
  info(
@@ -869,8 +877,9 @@ async function runRegister(args = []) {
869
877
  // because a 422 the CLI did not know about arrived exactly that way.
870
878
  if (parsedBody?.detail) {
871
879
  log(` ${parsedBody.detail}`)
872
- if (parsedBody.code) {
873
- log(` ${colors.dim}(${parsedBody.code})${colors.reset}`)
880
+ const code = parsedBody.code ?? parsedBody.reason
881
+ if (code) {
882
+ log(` ${colors.dim}(${code})${colors.reset}`)
874
883
  }
875
884
  } else if (rawBody) {
876
885
  log(` ${colors.dim}${rawBody.slice(0, 500)}${colors.reset}`)
@@ -1,16 +1,16 @@
1
1
  {
2
2
  "schemaVersion": 1,
3
- "generatedAt": "2026-09-25T17:28:23.816Z",
3
+ "generatedAt": "2026-09-26T21:30:58.826Z",
4
4
  "packages": {
5
5
  "@uniweb/api": {
6
- "version": "0.6.2",
6
+ "version": "0.6.3",
7
7
  "path": "framework/api",
8
8
  "deps": [
9
9
  "@uniweb/core"
10
10
  ]
11
11
  },
12
12
  "@uniweb/build": {
13
- "version": "0.63.4",
13
+ "version": "0.64.1",
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.8",
38
+ "version": "0.30.1",
39
39
  "path": "framework/core",
40
40
  "deps": [
41
41
  "@uniweb/semantic-parser",
@@ -48,14 +48,14 @@
48
48
  "deps": []
49
49
  },
50
50
  "@uniweb/icons": {
51
- "version": "0.4.20",
51
+ "version": "0.4.21",
52
52
  "path": "framework/icons",
53
53
  "deps": [
54
54
  "@uniweb/core"
55
55
  ]
56
56
  },
57
57
  "@uniweb/kit": {
58
- "version": "0.19.4",
58
+ "version": "0.19.5",
59
59
  "path": "framework/kit",
60
60
  "deps": [
61
61
  "@uniweb/core",
@@ -75,7 +75,7 @@
75
75
  "deps": []
76
76
  },
77
77
  "@uniweb/projections": {
78
- "version": "0.7.3",
78
+ "version": "0.7.4",
79
79
  "path": "framework/projections",
80
80
  "deps": [
81
81
  "@uniweb/content-writer",
@@ -83,7 +83,7 @@
83
83
  ]
84
84
  },
85
85
  "@uniweb/runtime": {
86
- "version": "0.27.4",
86
+ "version": "0.27.6",
87
87
  "path": "framework/runtime",
88
88
  "deps": [
89
89
  "@uniweb/core",
@@ -96,7 +96,7 @@
96
96
  "deps": []
97
97
  },
98
98
  "@uniweb/schemas": {
99
- "version": "0.7.0",
99
+ "version": "0.7.1",
100
100
  "path": "framework/schemas",
101
101
  "deps": []
102
102
  },
@@ -116,7 +116,7 @@
116
116
  "deps": []
117
117
  },
118
118
  "@uniweb/templates": {
119
- "version": "0.17.2",
119
+ "version": "0.17.3",
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.10.6",
129
+ "version": "0.10.7",
130
130
  "path": "framework/unipress",
131
131
  "deps": [
132
132
  "@uniweb/build",