uniweb 0.67.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.67.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",
44
+ "@uniweb/runtime": "^0.27.4",
45
45
  "@uniweb/core": "^0.29.8",
46
46
  "@uniweb/kit": "^0.19.4",
47
- "@uniweb/schemas": "^0.6.0",
48
- "@uniweb/runtime": "^0.27.4",
49
- "@uniweb/semantic-parser": "^1.4.1"
47
+ "@uniweb/semantic-parser": "^1.4.1",
48
+ "@uniweb/schemas": "^0.7.0",
49
+ "@uniweb/content-writer": "^0.3.4"
50
50
  },
51
51
  "peerDependencies": {
52
- "@uniweb/build": "^0.61.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
  },
@@ -879,6 +879,25 @@ Keep those folders flat: `records/article/design-tips.md` works; `records/articl
879
879
 
880
880
  Item frontmatter conventionally uses `title`, `date`, `tags`, `image`, `description`, `author` — plus any fields your content needs (`price`, `role`, `order`). Images can sit beside the item file and be referenced with `./`. **`draft: true` keeps a record off the live site without deleting it**: it is still a record, in `records/`, and `pnpm dev` shows it so you can preview it, but a build for hosting leaves it out, and a backend you `uniweb push` it to keeps it in the site's folder without delivering it. (`published: false`, an older spelling, is refused.)
881
881
 
882
+ **A record's file follows its data schema's sections.** A schema with one section (the `fields:` form) is written flat, as above. A schema with more than one is written **by section** — each field under its section's name — and a field left at the top stops the build and a push, naming where it goes. `@std/article` has two, its card `article` and `article_body`:
883
+
884
+ ```markdown
885
+ <!-- records/std/article/hello.md -->
886
+ ---
887
+ article:
888
+ title: Hello
889
+ date: 2026-05-01
890
+ article_body:
891
+ author: Ada
892
+ ---
893
+
894
+ The body — the value of the schema's content field, `article_body.content`.
895
+ ```
896
+
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
+
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
+
882
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:
883
902
 
884
903
  ```yaml
@@ -946,7 +965,7 @@ pages/blog/
946
965
 
947
966
  **Two options for bigger sets:**
948
967
 
949
- `deferred: [body]` strips heavy fields from the list payload — cards stay light, while a `[slug]` page still receives the full record automatically and other components fetch the whole record on demand with `useWholeRecord`. File-based records emit per-record files at `/data/<name>/<slug>.json` and need no configuration; an external query names its one-record request with `record:` instead (*Fetching from other sources* in Part 4).
968
+ `deferred: [body]` strips heavy fields from the list payload — cards stay light, while a `[slug]` page still receives the full record automatically and other components fetch the whole record on demand with `useWholeRecord`. It names keys of the record as a component receives it — a field, or a section's name — and a schema with more than one section implies it: every section but the brief (`[article_body]` for `@std/article`), so it is rarely written. File-based records emit per-record files at `/data/<name>/<slug>.json` and need no configuration; an external query names its one-record request with `record:` instead (*Fetching from other sources* in Part 4).
950
969
 
951
970
  `queryable:` declares which fields a reader may filter on, with enough metadata for the foundation to render controls:
952
971
 
@@ -1866,7 +1885,7 @@ fetch:
1866
1885
  limit: 3
1867
1886
  ```
1868
1887
 
1869
- **Lean lists with `deferred:`.** A query over records with heavy fields (article bodies, large nested arrays) can declare `deferred: [body]`. The list payload omits those fields; per-record full files are emitted at `/data/<name>/<slug>.json`. (An external query declares no `deferred:` — its whole record comes from `record:`, below.) On a parametric page the record's full data is delivered automatically; elsewhere components fetch the whole record on demand with `useWholeRecord(record, { query })`. The hook is safe to call on any query: when the query has no separate source for the whole record it returns the record you passed in, because nothing was stripped from it.
1888
+ **Lean lists with `deferred:`.** A query over records with heavy fields (article bodies, large nested arrays) can declare `deferred: [body]` — keys of the record as a component receives it, a section's name included; a schema with more than one section implies every section but its brief. The list payload omits those fields; per-record full files are emitted at `/data/<name>/<slug>.json`. (An external query declares no `deferred:` — its whole record comes from `record:`, below.) On a parametric page the record's full data is delivered automatically; elsewhere components fetch the whole record on demand with `useWholeRecord(record, { query })`. The hook is safe to call on any query: when the query has no separate source for the whole record it returns the record you passed in, because nothing was stripped from it.
1870
1889
 
1871
1890
  **Component-side fetching.** When a component genuinely needs to fetch on its own (a search box, "load more", a lazy popover), use the kit hooks — `useFetched`, `useCacheEntry`, `useWholeRecord`. They share the framework's cache and dispatcher with declarative fetches; same-key requests dedupe automatically.
1872
1891
 
@@ -1187,7 +1187,8 @@ export async function probeUnpushed(siteDir, { backend = null, sendAll = false }
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
1189
  const changed =
1190
- (pkg.siteContent?.entityCount || 0) + (pkg.records?.entityCount || 0)
1190
+ (pkg.siteContent?.entityCount || 0) +
1191
+ (pkg.records?.entityCount || 0)
1191
1192
  return { changed, unchanged: pkg.skipped || 0, warnings: pkg.warnings || [] }
1192
1193
  }
1193
1194
 
@@ -1266,6 +1267,28 @@ export async function rebankSyncHashes(siteDir, backend = null) {
1266
1267
  return Object.keys(pkg.hashes || {}).length
1267
1268
  }
1268
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
+
1269
1292
  /**
1270
1293
  * What a push did with the site's `template:` intent — read from the backend's own
1271
1294
  * reply, never inferred from what we sent.
@@ -1770,6 +1793,15 @@ export async function pushSyncPackages({
1770
1793
  // Entities absent from finalized[] (skipped, or not editable) keep their cached
1771
1794
  // value — absence is not invalidation.
1772
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
+ }
1773
1805
  mergeHarvested()
1774
1806
  // Re-base the page attribution: our emitted document and the backend's post-write
1775
1807
  // copy of it are the two sides' new agreed state. Only when the site-content lane
@@ -767,12 +767,6 @@ export async function publish(args = []) {
767
767
  // (It stamped `info.data_bundle` until 2026-08-18; the ball is gone and
768
768
  // collection data now lands at its serving tail, so nothing records it.)
769
769
  const priorHashes = readSyncCache(siteDir, client.origin)
770
- // publish rides the same gated push as `uniweb push`: if an app author has
771
- // edited since this clone last synced, the push is refused rather than
772
- // overwriting them, and nothing goes live. `--force` drops the precondition.
773
- const baseVersions = args.includes('--force')
774
- ? null
775
- : readBaseVersions(siteDir, client.origin)
776
770
  // Per-item identity, recovered from the backend when this clone has never seen it.
777
771
  // Without it the backend re-mints every page and section row (see readItemUuids).
778
772
  const itemUuids = await ensureItemUuids({
@@ -948,6 +942,10 @@ export async function publish(args = []) {
948
942
  say.dim('Services unchanged.')
949
943
  }
950
944
 
945
+ // publish rides the same gated push as `uniweb push`: if an app author has
946
+ // edited since this clone last synced, the push is refused rather than
947
+ // overwriting them, and nothing goes live. `--force` drops the precondition.
948
+ const forced = args.includes('--force')
951
949
  let pkg
952
950
  try {
953
951
  pkg = await emitSyncPackages(siteDir, {
@@ -959,9 +957,12 @@ export async function publish(args = []) {
959
957
  resolveModel,
960
958
  priorHashes,
961
959
  itemUuids,
962
- ...(baseVersions
963
- ? { baseVersions, itemBaseVersions: readItemBaseVersions(siteDir, client.origin) }
964
- : {}),
960
+ ...(forced
961
+ ? {}
962
+ : {
963
+ baseVersions: readBaseVersions(siteDir, client.origin),
964
+ itemBaseVersions: readItemBaseVersions(siteDir, client.origin)
965
+ }),
965
966
  ...(Object.keys(injectInfo).length ? { injectInfo } : {}),
966
967
  ...(Object.keys(ext.pins).length ? { injectExtensions: ext.pins } : {}),
967
968
  ...(assetRewrite ? { assetRewrite } : {}),
@@ -1,16 +1,16 @@
1
1
  {
2
2
  "schemaVersion": 1,
3
- "generatedAt": "2026-09-24T19:00:27.393Z",
3
+ "generatedAt": "2026-09-25T11:51:15.075Z",
4
4
  "packages": {
5
5
  "@uniweb/api": {
6
- "version": "0.6.1",
6
+ "version": "0.6.2",
7
7
  "path": "framework/api",
8
8
  "deps": [
9
9
  "@uniweb/core"
10
10
  ]
11
11
  },
12
12
  "@uniweb/build": {
13
- "version": "0.61.0",
13
+ "version": "0.63.0",
14
14
  "path": "framework/build",
15
15
  "deps": [
16
16
  "@uniweb/content-reader",
@@ -96,7 +96,7 @@
96
96
  "deps": []
97
97
  },
98
98
  "@uniweb/schemas": {
99
- "version": "0.6.0",
99
+ "version": "0.7.0",
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.16.3",
119
+ "version": "0.17.0",
120
120
  "path": "framework/templates",
121
121
  "deps": []
122
122
  },
@@ -126,7 +126,7 @@
126
126
  "deps": []
127
127
  },
128
128
  "@uniweb/unipress": {
129
- "version": "0.10.4",
129
+ "version": "0.10.6",
130
130
  "path": "framework/unipress",
131
131
  "deps": [
132
132
  "@uniweb/build",