uniweb 0.68.0 → 0.69.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "uniweb",
3
- "version": "0.68.0",
3
+ "version": "0.69.0",
4
4
  "description": "Create structured Vite + React sites with content/code separation",
5
5
  "type": "module",
6
6
  "bin": {
@@ -41,15 +41,15 @@
41
41
  "js-yaml": "^4.1.0",
42
42
  "prompts": "^2.4.2",
43
43
  "tar": "^7.0.0",
44
- "@uniweb/content-writer": "^0.3.4",
45
- "@uniweb/core": "^0.29.8",
46
44
  "@uniweb/runtime": "^0.27.4",
45
+ "@uniweb/core": "^0.29.8",
47
46
  "@uniweb/kit": "^0.19.4",
47
+ "@uniweb/semantic-parser": "^1.4.1",
48
48
  "@uniweb/schemas": "^0.7.0",
49
- "@uniweb/semantic-parser": "^1.4.1"
49
+ "@uniweb/content-writer": "^0.3.4"
50
50
  },
51
51
  "peerDependencies": {
52
- "@uniweb/build": "^0.62.0",
52
+ "@uniweb/build": "^0.63.0",
53
53
  "@uniweb/content-reader": "^1.2.5",
54
54
  "@uniweb/semantic-parser": "^1.4.1"
55
55
  },
@@ -896,7 +896,7 @@ The body — the value of the schema's content field, `article_body.content`.
896
896
 
897
897
  **A component receives it differently from how the file writes it**, the same way on a static site and a hosted one: the brief's fields at the top (`article.title`), every other section under its name (`article.article_body.content`, `article.article_body.author`), and `$name`, the handle. Lists carry the brief alone unless the query says otherwise (`deferred:`, below). Full rules: `reference/entity-content.md`.
898
898
 
899
- **A reference names the record it points at.** A `{ ref: '@/speaker' }` field is written as that record's name — `speaker: ada` for `records/speaker/ada.yml` (its file's name, or its `slug:`) — and a `many` one as a list of names. A component receives `{ entity, brief }`: the record reduced to its brief, so `talk.speaker.brief.name`. `uniweb validate` and a push report a name that matches no record; a push sends each reference to the record it names (a second pass when that record is new too), and a pull writes the name back.
899
+ **A reference names the record it points at.** A `{ ref: '@/speaker' }` field is written as that record's name — `speaker: ada` for `records/speaker/ada.yml` (its file's name, or its `slug:`) — and a `many` one as a list of names. A component receives `{ entity, brief }`: the record reduced to its brief, so `talk.speaker.brief.name`. `uniweb validate` and a push report a name that matches no record; a push sends each reference to the record it names (in the same push when that record is new too), and a pull writes the name back.
900
900
 
901
901
  **`records/folder.yml` — optional, and only for folders.** It is the one file in `records/` that is not a record, it moves with the directory (`paths.records`), and like a pages folder's `folder.yml` it describes the folder it sits in. Every record sits at the top of the site's records folder unless `folder.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:
902
902
 
@@ -1186,12 +1186,9 @@ export async function probeUnpushed(siteDir, { backend = null, sendAll = false }
1186
1186
  // ⚠️ Until 2026-09-23 this said the site's recorded ORG resolves it and must be
1187
1187
  // passed in — true before 2026-09-22, and read as a reason to pass it.
1188
1188
  const pkg = await comparisonEmit(siteDir, { backend, priorHashes, sendAll })
1189
- // A record held back for a reference (`pushInPasses`) is not in either lane, and is
1190
- // still unpushed.
1191
1189
  const changed =
1192
1190
  (pkg.siteContent?.entityCount || 0) +
1193
- (pkg.records?.entityCount || 0) +
1194
- (pkg.waiting || []).filter((w) => w.held).length
1191
+ (pkg.records?.entityCount || 0)
1195
1192
  return { changed, unchanged: pkg.skipped || 0, warnings: pkg.warnings || [] }
1196
1193
  }
1197
1194
 
@@ -1270,6 +1267,28 @@ export async function rebankSyncHashes(siteDir, backend = null) {
1270
1267
  return Object.keys(pkg.hashes || {}).length
1271
1268
  }
1272
1269
 
1270
+ /**
1271
+ * Re-bank the hashes of the records a push sent naming a record it created — `keys`,
1272
+ * the emit's `namesNew` — as the next push will hash them.
1273
+ *
1274
+ * ⭐ Such a record names its new record by `$ref`, and the backend stores the uuid it
1275
+ * mints. The next push names it by that uuid: the same reference, and another hash — so
1276
+ * the hash the push banked describes a document no later emit builds, and the next push
1277
+ * would send every one of these records again. Re-sending is not harmless: a record's
1278
+ * list-section items go without the identity a re-send needs (`identity_required`).
1279
+ *
1280
+ * ⚖️ Only these keys, over the hashes the push just banked: the site-content lane and
1281
+ * the folder are banked as that push sent them, and a caller's own emit options (a
1282
+ * publish that does not declare services) are not the comparison emit's.
1283
+ */
1284
+ export async function rebankRecordHashes(siteDir, backend, keys) {
1285
+ if (!keys?.length) return
1286
+ const pkg = await comparisonEmit(siteDir, { backend, sendAll: true })
1287
+ const hashes = { ...readSyncCache(siteDir, backend) }
1288
+ for (const key of keys) if (pkg.hashes?.[key]) hashes[key] = pkg.hashes[key]
1289
+ writeSyncCache(siteDir, backend, hashes, readAppliedInjections(siteDir, backend))
1290
+ }
1291
+
1273
1292
  /**
1274
1293
  * What a push did with the site's `template:` intent — read from the backend's own
1275
1294
  * reply, never inferred from what we sent.
@@ -1774,6 +1793,15 @@ export async function pushSyncPackages({
1774
1793
  // Entities absent from finalized[] (skipped, or not editable) keep their cached
1775
1794
  // value — absence is not invalidation.
1776
1795
  writeSyncCache(siteDir, client.origin, hashes, applied)
1796
+ // A record that named a record this push created is banked as the next push will send
1797
+ // it — by that record's uuid, which the back-fill above just recorded.
1798
+ if (records && pkg.namesNew?.length) {
1799
+ try {
1800
+ await rebankRecordHashes(siteDir, client.origin, pkg.namesNew)
1801
+ } catch (err) {
1802
+ note(`! Could not re-bank ${pkg.namesNew.length} record(s) that name a new record: ${err.message}. The next push sends them again.`)
1803
+ }
1804
+ }
1777
1805
  mergeHarvested()
1778
1806
  // Re-base the page attribution: our emitted document and the backend's post-write
1779
1807
  // copy of it are the two sides' new agreed state. Only when the site-content lane
@@ -1850,78 +1878,3 @@ export async function pushSyncPackages({
1850
1878
  }
1851
1879
  return { exitCode: 0, boundSiteUuid, finalizedTotal, wrote }
1852
1880
  }
1853
-
1854
- /**
1855
- * Push, then push again while records WAIT for a reference's record — the verbs' way to
1856
- * send a package (`push`, `publish`).
1857
- *
1858
- * ⭐ A reference is sent as the uuid the backend minted for the record it names, so a
1859
- * record that points at one created by the same push cannot be completed in that push
1860
- * (`@uniweb/build`'s `emitSyncPackages` → `waiting`). The first pass creates what it can —
1861
- * records whose waiting references are not required go without them — and each further
1862
- * pass, emitted afresh from the state the last one banked, sends what has become
1863
- * nameable. A chain of required references takes one pass per link; two records that
1864
- * each require the other can never be completed, and the push stops, naming them.
1865
- *
1866
- * @param {object} params
1867
- * @param {object} params.pkg - the first pass's package (`emitSyncPackages`)
1868
- * @param {() => Promise<object>} params.reemit - the next pass's package, emitted afresh
1869
- * @param {Function} [params.push] - sends one package; `pushSyncPackages` (a test's seam)
1870
- * @returns {Promise<{ exitCode: number, boundSiteUuid?: string, finalizedTotal: number, wrote: string[], passes: number }>}
1871
- */
1872
- export async function pushInPasses({ client, siteDir, pkg, report, reemit, maxPasses = 12, push = pushSyncPackages }) {
1873
- const { note, error } = report
1874
- let current = pkg
1875
- let passes = 1
1876
- let result = await push({ client, siteDir, pkg: current, report })
1877
- let finalizedTotal = result.finalizedTotal || 0
1878
- const wrote = [...(result.wrote || [])]
1879
- while (result.exitCode === 0 && current.waiting?.length) {
1880
- const n = current.waiting.length
1881
- note(
1882
- `${n} record${n === 1 ? '' : 's'} name${n === 1 ? 's' : ''} a record this push created — ` +
1883
- `pushing again to complete ${n === 1 ? 'its references' : 'their references'}.`
1884
- )
1885
- let next
1886
- try {
1887
- next = await reemit()
1888
- } catch (err) {
1889
- error(`Could not build the next pass: ${err.message}`)
1890
- return { ...result, exitCode: 1, finalizedTotal, wrote, passes }
1891
- }
1892
- if (refuseUnsendableRecords(next.refusals, report)) {
1893
- return { ...result, exitCode: 1, finalizedTotal, wrote, passes }
1894
- }
1895
- // Nothing became nameable: every record still waiting waits on one that waits too.
1896
- if (waitingKey(next.waiting) === waitingKey(current.waiting) || passes >= maxPasses) {
1897
- error(
1898
- 'These records could not be completed — each requires a reference to a record that ' +
1899
- 'is itself waiting to be created:'
1900
- )
1901
- for (const w of next.waiting || []) {
1902
- const refs = w.pending.map((p) => `${p.path} → "${p.name}"`).join(', ')
1903
- note(` ${w.id} (${w.model}): ${refs}`)
1904
- }
1905
- note(
1906
- 'Push one of them without that reference first — or make it optional in the schema — ' +
1907
- 'then add it back and push again.'
1908
- )
1909
- return { ...result, exitCode: 1, finalizedTotal, wrote, passes }
1910
- }
1911
- current = next
1912
- passes++
1913
- result = await push({ client, siteDir, pkg: current, report })
1914
- finalizedTotal += result.finalizedTotal || 0
1915
- for (const w of result.wrote || []) if (!wrote.includes(w)) wrote.push(w)
1916
- }
1917
- return { ...result, finalizedTotal, wrote, passes }
1918
- }
1919
-
1920
- // Which records wait, and on what — two passes that leave it the same made no progress.
1921
- function waitingKey(waiting) {
1922
- return JSON.stringify(
1923
- (waiting || [])
1924
- .map((w) => [w.model, w.id, w.pending.map((p) => `${p.path}:${p.name}`).sort()])
1925
- .sort()
1926
- )
1927
- }
@@ -90,8 +90,7 @@ import {
90
90
  refuseUnsendableRecords,
91
91
  clearRemoteSyncStateIfUnbound,
92
92
  dropSiteBoundValues,
93
- readItemUuids,
94
- pushInPasses
93
+ pushSyncPackages
95
94
  } from '../backend/site-sync.js'
96
95
  import { resolveWorkspace, describeWorkspace, SOURCE_LABEL } from '../backend/workspace.js'
97
96
  import { uploadSiteMedia, describeAssetRefusal } from '../backend/site-media.js'
@@ -943,14 +942,13 @@ export async function publish(args = []) {
943
942
  say.dim('Services unchanged.')
944
943
  }
945
944
 
946
- // The emit's options, read afresh for each pass (`pushInPasses`): a pass banks
947
- // hashes, identity and base versions that the next one must build on.
948
- //
949
945
  // publish rides the same gated push as `uniweb push`: if an app author has
950
946
  // edited since this clone last synced, the push is refused rather than
951
947
  // overwriting them, and nothing goes live. `--force` drops the precondition.
952
948
  const forced = args.includes('--force')
953
- const emitOptions = ({ priorHashes, itemUuids }) => ({
949
+ let pkg
950
+ try {
951
+ pkg = await emitSyncPackages(siteDir, {
954
952
  backend: client.origin,
955
953
  ...(declaration.declare ? {} : { declareServices: false }),
956
954
  // Placement identity for the folder — see writeFolderItemUuids.
@@ -969,10 +967,7 @@ export async function publish(args = []) {
969
967
  ...(Object.keys(ext.pins).length ? { injectExtensions: ext.pins } : {}),
970
968
  ...(assetRewrite ? { assetRewrite } : {}),
971
969
  ...(assetIds ? { assetIds } : {})
972
- })
973
- let pkg
974
- try {
975
- pkg = await emitSyncPackages(siteDir, emitOptions({ priorHashes, itemUuids }))
970
+ })
976
971
  } catch (err) {
977
972
  say.err(`Could not build the sync package: ${err.message}`)
978
973
  return { exitCode: 1 }
@@ -987,19 +982,11 @@ export async function publish(args = []) {
987
982
  error: (m) => say.err(m),
988
983
  dim: (s) => `${c.dim}${s}${c.reset}`
989
984
  }
990
- const pushResult = await pushInPasses({
985
+ const pushResult = await pushSyncPackages({
991
986
  client,
992
987
  siteDir,
993
988
  pkg,
994
- report,
995
- reemit: () =>
996
- emitSyncPackages(
997
- siteDir,
998
- emitOptions({
999
- priorHashes: readSyncCache(siteDir, client.origin),
1000
- itemUuids: readItemUuids(siteDir, client.origin)
1001
- })
1002
- )
989
+ report
1003
990
  })
1004
991
  if (pushResult.exitCode !== 0) return { exitCode: pushResult.exitCode }
1005
992
  const siteUuid = pushResult.boundSiteUuid
@@ -87,7 +87,7 @@ import {
87
87
  ensureSiteExists,
88
88
  clearRemoteSyncStateIfUnbound,
89
89
  dropSiteBoundValues,
90
- pushInPasses
90
+ pushSyncPackages
91
91
  } from '../backend/site-sync.js'
92
92
  import { resolveWorkspace, describeWorkspace, SOURCE_LABEL } from '../backend/workspace.js'
93
93
 
@@ -496,9 +496,9 @@ export async function push(args = [], deps = {}) {
496
496
  output || dryRun
497
497
  ? readItemUuids(siteDir, client.origin)
498
498
  : await ensureItemUuids({ client, siteDir, note })
499
- // The emit's options, read afresh for each pass (`pushInPasses`): a pass banks
500
- // hashes, identity and base versions that the next one must build on.
501
- const emitOptions = ({ priorHashes, sendAll, itemUuids }) => ({
499
+ let pkg
500
+ try {
501
+ pkg = await emitSyncPackages(siteDir, {
502
502
  backend: client.origin,
503
503
  // Placement identity for the folder — see writeFolderItemUuids.
504
504
  folderItemUuids: readFolderItemUuids(siteDir, client.origin),
@@ -529,10 +529,7 @@ export async function push(args = [], deps = {}) {
529
529
  }),
530
530
  ...(assetRewrite ? { assetRewrite } : {}),
531
531
  ...(assetIds ? { assetIds } : {})
532
- })
533
- let pkg
534
- try {
535
- pkg = await emitSyncPackages(siteDir, emitOptions({ priorHashes, sendAll, itemUuids }))
532
+ })
536
533
  } catch (err) {
537
534
  error(`Could not build the sync package: ${err.message}`)
538
535
  return { exitCode: 2 }
@@ -598,9 +595,8 @@ export async function push(args = [], deps = {}) {
598
595
  }
599
596
 
600
597
  // Submit both lanes, back-fill the minted uuids, and persist the send-only-changed
601
- // cache — again for any record whose reference names a record this push created.
602
- // Shared with `uniweb publish` via ../backend/site-sync.js.
603
- const result = await pushInPasses({
598
+ // cache. Shared with `uniweb publish` via ../backend/site-sync.js.
599
+ const result = await pushSyncPackages({
604
600
  client,
605
601
  siteDir,
606
602
  pkg,
@@ -609,16 +605,7 @@ export async function push(args = [], deps = {}) {
609
605
  note,
610
606
  error,
611
607
  dim: (s) => `${colors.dim}${s}${colors.reset}`
612
- },
613
- reemit: () =>
614
- emitSyncPackages(
615
- siteDir,
616
- emitOptions({
617
- priorHashes: readSyncCache(siteDir, client.origin),
618
- sendAll: false,
619
- itemUuids: readItemUuids(siteDir, client.origin)
620
- })
621
- )
608
+ }
622
609
  })
623
610
  if (result.exitCode !== 0) return { exitCode: result.exitCode }
624
611
  success(
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "schemaVersion": 1,
3
- "generatedAt": "2026-09-24T21:52:18.260Z",
3
+ "generatedAt": "2026-09-25T11:51:15.075Z",
4
4
  "packages": {
5
5
  "@uniweb/api": {
6
6
  "version": "0.6.2",
@@ -10,7 +10,7 @@
10
10
  ]
11
11
  },
12
12
  "@uniweb/build": {
13
- "version": "0.62.0",
13
+ "version": "0.63.0",
14
14
  "path": "framework/build",
15
15
  "deps": [
16
16
  "@uniweb/content-reader",
@@ -126,7 +126,7 @@
126
126
  "deps": []
127
127
  },
128
128
  "@uniweb/unipress": {
129
- "version": "0.10.5",
129
+ "version": "0.10.6",
130
130
  "path": "framework/unipress",
131
131
  "deps": [
132
132
  "@uniweb/build",