uniweb 0.13.5 → 0.13.6

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.13.5",
3
+ "version": "0.13.6",
4
4
  "description": "Create structured Vite + React sites with content/code separation",
5
5
  "type": "module",
6
6
  "bin": {
@@ -41,14 +41,14 @@
41
41
  "js-yaml": "^4.1.0",
42
42
  "prompts": "^2.4.2",
43
43
  "tar": "^7.0.0",
44
- "@uniweb/core": "0.7.29",
45
- "@uniweb/kit": "0.9.37",
46
- "@uniweb/runtime": "0.8.36"
44
+ "@uniweb/kit": "0.9.38",
45
+ "@uniweb/core": "0.7.30",
46
+ "@uniweb/runtime": "0.8.37"
47
47
  },
48
48
  "peerDependencies": {
49
- "@uniweb/content-reader": "1.1.15",
50
- "@uniweb/semantic-parser": "1.1.18",
51
- "@uniweb/build": "0.15.5"
49
+ "@uniweb/build": "0.15.6",
50
+ "@uniweb/content-reader": "1.1.16",
51
+ "@uniweb/semantic-parser": "1.1.19"
52
52
  },
53
53
  "peerDependenciesMeta": {
54
54
  "@uniweb/build": {
@@ -1571,6 +1571,46 @@ Recipes for staying on the default fetcher, and for writing a custom transport:
1571
1571
 
1572
1572
  Full model: `reference/data-fetching.md`. Where-object format with examples: `authoring/predicates.md`.
1573
1573
 
1574
+ ### Search (`search:`)
1575
+
1576
+ Search follows the same arrangement as `fetcher:` — the **site** declares where results come from, and a search UI reads them the same way regardless. Never hardcode a search endpoint in a component; that couples the foundation to one host.
1577
+
1578
+ ```yaml
1579
+ # site.yml
1580
+ search:
1581
+ enabled: true
1582
+ provider: index # default — download an index, match in the browser
1583
+ ```
1584
+
1585
+ | Provider | Answers with | Trade-off |
1586
+ |---|---|---|
1587
+ | `index` (default) | `search-index.json` + Fuse.js in the browser | Free, works on **any** host including a plain static one, tolerates typos. Contains only what existed at build time. |
1588
+ | `endpoint` | A server-side search API | Can cover records fetched from an API, and can be re-indexed without rebuilding the site. Requires a host that serves one. |
1589
+ | *any other name* | A foundation-supplied search transport | Fully open — Typesense, Meilisearch, Pagefind, a vendor API |
1590
+
1591
+ ```yaml
1592
+ search:
1593
+ provider: endpoint
1594
+ endpoint: _search # optional; base-RELATIVE, so one spelling works everywhere
1595
+ ```
1596
+
1597
+ `endpoint:` resolves against the site's base path — `/` → `/_search`, `base: /docs/` → `/docs/_search`, a subpath-served site follows its subpath. An absolute `https://…` URL points at another origin.
1598
+
1599
+ **Results have one shape, whatever the provider.** Always present: `id`, `type`, `route`, `href`, `title`, `pageTitle`, `excerpt`, `snippetHtml`. Provider-optional (`null` when absent): `sectionId`, `anchor`, `description`, `component`, `snippetText`, `matches`, `collection`, `item`. Whether an optional field arrives is a deployment fact, not a content fact — the same site yields `item` from a server provider and `null` from the local index — so guard them: `result.item?.image`.
1600
+
1601
+ `snippetHtml` is HTML with `<mark>`. Render it through `SafeHtml`, never as text.
1602
+
1603
+ **Failures degrade rather than break** — a failing provider falls back to the local index when one exists, otherwise returns no results with a console warning. A search box never throws at a visitor.
1604
+
1605
+ ```jsx
1606
+ import { useSearch } from '@uniweb/kit'
1607
+
1608
+ const { results, isLoading, query } = useSearch(website) // `query` is a function
1609
+ <input onChange={e => query(e.target.value)} /> // debounced internally
1610
+ ```
1611
+
1612
+ Full reference: `authoring/search.md`.
1613
+
1574
1614
  <!-- template:loom -->
1575
1615
  ### Content handlers
1576
1616
 
@@ -13,7 +13,64 @@
13
13
 
14
14
  import { writeFileSync, readFileSync, mkdirSync } from 'node:fs'
15
15
  import { join, dirname } from 'node:path'
16
- import { backfillEntityUuids, writeSiteEntityUuid, emitSyncPackages } from '@uniweb/build/uwx'
16
+ import yaml from 'js-yaml'
17
+ import { hasUncommittedContent } from '../utils/git.js'
18
+ import {
19
+ backfillEntityUuids,
20
+ writeSiteEntityUuid,
21
+ emitSyncPackages,
22
+ readZip,
23
+ diffSiteUnits,
24
+ describeSiteDiff,
25
+ computeUnitHashes,
26
+ collectUnitUuids,
27
+ } from '@uniweb/build/uwx'
28
+
29
+ // First entity `$`-document out of a `.uwx` we produced or the backend served.
30
+ // Deliberately NOT pull.js's `readPullDocuments`: that one is tolerant of JSON
31
+ // envelopes for a lane whose shape may change, and importing it here would be a
32
+ // cycle (pull.js already imports this module). Both lanes here are always ZIPs.
33
+ function entityDocFromUwx(buf) {
34
+ if (!buf || buf.length < 2 || buf[0] !== 0x50 || buf[1] !== 0x4b) return null
35
+ for (const [name, data] of readZip(buf)) {
36
+ if (name === 'manifest.json' || !name.endsWith('.json')) continue
37
+ try {
38
+ return JSON.parse(data.toString('utf8'))
39
+ } catch {
40
+ return null
41
+ }
42
+ }
43
+ return null
44
+ }
45
+
46
+ /**
47
+ * Turn an entity-grained staleness refusal into a file-level account.
48
+ *
49
+ * The gate can only report that the site-content document moved. What the user
50
+ * needs is which FILES — content is split one section per file, so two people
51
+ * editing different sections of the same page have not conflicted — and, since we
52
+ * cache per-unit hashes of the last agreed state, which side moved them. Fetches the backend's current document (a plain
53
+ * read; it writes nothing locally, unlike `uniweb pull`) and diffs.
54
+ *
55
+ * Best-effort by design: this runs on a path that has ALREADY failed, so any
56
+ * problem fetching or parsing must degrade to the plain refusal rather than
57
+ * replace a clear error with a confusing one.
58
+ *
59
+ * @returns {string[]} lines to print, or [] when nothing useful can be said.
60
+ */
61
+ async function explainStaleSiteContent({ client, siteDir, localBuffer, uuid }) {
62
+ try {
63
+ if (!uuid) return []
64
+ const res = await client.pullSiteContent(uuid)
65
+ if (!res?.ok) return []
66
+ const remoteDoc = entityDocFromUwx(Buffer.from(await res.arrayBuffer()))
67
+ const localDoc = entityDocFromUwx(localBuffer)
68
+ if (!remoteDoc || !localDoc) return []
69
+ return describeSiteDiff(diffSiteUnits(localDoc, remoteDoc, readUnitBases(siteDir)))
70
+ } catch {
71
+ return []
72
+ }
73
+ }
17
74
 
18
75
  // Pull the finalized entities out of the restore response. The backend returns
19
76
  // `{ report: { finalized: [ { index, uuid, changed, document }, … ] } }` — each entry
@@ -35,6 +92,12 @@ export function extractFinalized(payload) {
35
92
  index: d?.index,
36
93
  uuid: d?.uuid ?? d?.document?.$uuid ?? null,
37
94
  changed: d?.changed,
95
+ // Post-write optimistic-concurrency token, read back from the stored row.
96
+ // Cache it UNCONDITIONALLY — do not branch on `changed` and do not infer:
97
+ // the backend pins "zero-write ⇒ version unmoved" with a test, so a no-op
98
+ // resubmit returns the value we already hold, and anything else is theirs
99
+ // to report, not ours to derive.
100
+ version: typeof d?.version === 'string' ? d.version : null,
38
101
  document: d?.document ?? null,
39
102
  }))
40
103
  .filter((e) => Number.isInteger(e.index) && e.uuid)
@@ -92,18 +155,175 @@ export function makeModelResolver({ client, offline = false }) {
92
155
  function syncCachePath(siteDir) {
93
156
  return join(siteDir, '.uniweb', 'sync-cache.json')
94
157
  }
95
- export function readSyncCache(siteDir) {
158
+ function readSyncCacheFile(siteDir) {
96
159
  try {
97
160
  const obj = JSON.parse(readFileSync(syncCachePath(siteDir), 'utf8'))
98
- return obj && typeof obj.hashes === 'object' && obj.hashes ? obj.hashes : {}
161
+ return obj && typeof obj === 'object' ? obj : {}
99
162
  } catch {
100
163
  return {} // missing / unreadable → treat everything as changed
101
164
  }
102
165
  }
103
- export function writeSyncCache(siteDir, hashes) {
166
+ // The cache holds three maps written on DIFFERENT events — content hashes on a
167
+ // successful push, base versions on push AND pull, unit hashes on push and pull —
168
+ // so every writer must preserve the ones it isn't touching. One merge point rather
169
+ // than three hand-rolled preserves, because getting that wrong silently disarms
170
+ // whichever map got clobbered.
171
+ function updateSyncCache(siteDir, patch) {
104
172
  const p = syncCachePath(siteDir)
105
173
  mkdirSync(dirname(p), { recursive: true })
106
- writeFileSync(p, JSON.stringify({ version: 1, hashes }, null, 2) + '\n')
174
+ const prior = readSyncCacheFile(siteDir)
175
+ const out = { version: 1, ...prior, ...patch }
176
+ delete out.version
177
+ writeFileSync(p, JSON.stringify({ version: 1, ...out }, null, 2) + '\n')
178
+ }
179
+ const readMap = (siteDir, key) => {
180
+ const v = readSyncCacheFile(siteDir)[key]
181
+ return v && typeof v === 'object' ? v : {}
182
+ }
183
+
184
+ export function readSyncCache(siteDir) {
185
+ return readMap(siteDir, 'hashes')
186
+ }
187
+ export function writeSyncCache(siteDir, hashes) {
188
+ updateSyncCache(siteDir, { hashes })
189
+ }
190
+
191
+ /**
192
+ * The push gate's optimistic-concurrency tokens: `{ <backend-uuid>: <version> }`.
193
+ *
194
+ * Lives beside the content hashes because it is the same kind of thing — "the
195
+ * backend state this clone last agreed with" — and is already per-entity, which
196
+ * the per-lane `pull-cache.json` (two ETags) is not. Gitignored, per-clone,
197
+ * deletable: losing it means the next push is unconditional, i.e. today's
198
+ * behavior, never a wrong-but-plausible token.
199
+ *
200
+ * Fed from BOTH directions, which is what makes the gate usable: `pull` records
201
+ * the manifest's `extra.version`, and every successful push records the
202
+ * post-write `finalized[].version` the backend returns. Without the push half a
203
+ * second consecutive push would be stale by construction and refused.
204
+ */
205
+ export function readBaseVersions(siteDir) {
206
+ return readMap(siteDir, 'baseVersions')
207
+ }
208
+
209
+ /**
210
+ * Per-ITEM staleness tokens: `{ <record $uuid>: <opaque version> }`.
211
+ *
212
+ * The entity token gates the whole document, so a stale base on any one record
213
+ * refuses the entire push — which fires on the common case of two people editing
214
+ * different sections and teaches them to reach for `--force`. These gate per record
215
+ * instead. Same contract as the entity token: opaque, cached, echoed, never parsed.
216
+ *
217
+ * Merged rather than replaced: a push carries only CHANGED entities, so a response
218
+ * reports tokens for a subset of the site. Replacing would drop the tokens of every
219
+ * record that wasn't in this package and silently degrade those to ungated.
220
+ */
221
+ export function readItemBaseVersions(siteDir) {
222
+ return readMap(siteDir, 'itemBaseVersions')
223
+ }
224
+ export function mergeItemBaseVersions(siteDir, versions) {
225
+ if (!versions || !Object.keys(versions).length) return
226
+ updateSyncCache(siteDir, { itemBaseVersions: { ...readItemBaseVersions(siteDir), ...versions } })
227
+ }
228
+ export function mergeBaseVersions(siteDir, versions) {
229
+ if (!versions || !Object.keys(versions).length) return
230
+ updateSyncCache(siteDir, { baseVersions: { ...readBaseVersions(siteDir), ...versions } })
231
+ }
232
+
233
+ /**
234
+ * Per-unit content hashes of the last synced state, kept in BOTH representations —
235
+ * the bases for the file-level attribution shown after a staleness refusal
236
+ * (`diffSiteUnits`). A unit is a projected file: a page's `page.yml`, each section
237
+ * `.md`, each layout section.
238
+ *
239
+ * Two maps, not one, because our document and the backend's are not byte-comparable
240
+ * (they carry fields we don't emit and their own key order). A hash from one side
241
+ * compared against a base from the other differs for a unit nobody touched, so each
242
+ * side gets a base in its own representation:
243
+ * `local` ← our emitted document
244
+ * `remote` ← the backend's own copy (`finalized[].document`, or a pull)
245
+ *
246
+ * Replaced wholesale rather than merged: a unit that no longer exists is a unit with
247
+ * no base, and a stale entry would attribute its next difference to the wrong side.
248
+ *
249
+ * Purely an explanation aid — losing either map costs detail in one message, never a
250
+ * wrong push.
251
+ */
252
+ /**
253
+ * Per-item identity: `{ <unit path>: <backend $uuid> }`.
254
+ *
255
+ * The backend's reconcile matches a record by `$uuid`. `pages`, `page_sections` and
256
+ * `layout_sections` are all `multi` sections, where a record WITHOUT a uuid is read
257
+ * as new — inserted, with its stored counterpart deleted as host-only. So pushing
258
+ * without this map replaces every page and section row on every push. The content
259
+ * still lands, which is why it went unnoticed, but the app's per-item concurrency
260
+ * handles are left pointing at rows that no longer exist.
261
+ *
262
+ * Cached here rather than written into `page.yml` / section frontmatter: identity is
263
+ * a sync concern and author files should not carry sync uuids. Populated from
264
+ * whatever the backend last told us — a pull, or a push response's
265
+ * `finalized[].document`, which carries `$uuid` for every item at every nesting
266
+ * level, so a push alone bootstraps it and no pull is required.
267
+ *
268
+ * Losing it (gitignored, per-clone) means the next push would be identity-blind,
269
+ * which is why `push` repopulates it first — see `ensureItemUuids`. The backend also
270
+ * refuses an all-blank `multi` section, so a producer that skips that step fails
271
+ * loudly instead of quietly rebuilding the site's identity.
272
+ */
273
+ export function readItemUuids(siteDir) {
274
+ return readMap(siteDir, 'itemUuids')
275
+ }
276
+ export function writeItemUuids(siteDir, map) {
277
+ if (!map || !Object.keys(map).length) return
278
+ updateSyncCache(siteDir, { itemUuids: map })
279
+ }
280
+
281
+ /**
282
+ * Guarantee we have per-item identity before an identity-bearing push.
283
+ *
284
+ * Fires only when the site HAS been pushed before (`$uuid` in site.yml) but the
285
+ * cache is empty — a fresh `git clone`, or a deleted `.uniweb/`. One read of the
286
+ * backend's current document, no file writes, no `uniweb pull`. A first-ever push
287
+ * legitimately has nothing to fetch and is left alone.
288
+ *
289
+ * @returns {Promise<Object<string,string>>} the map (possibly empty)
290
+ */
291
+ export async function ensureItemUuids({ client, siteDir, note }) {
292
+ const cached = readItemUuids(siteDir)
293
+ if (Object.keys(cached).length) return cached
294
+ // A site that has never been pushed has no identity to recover — and nothing to
295
+ // lose, since every item is genuinely new.
296
+ let siteContentUuid = null
297
+ try {
298
+ const y = yaml.load(readFileSync(join(siteDir, 'site.yml'), 'utf8'))
299
+ siteContentUuid = typeof y?.$uuid === 'string' ? y.$uuid : null
300
+ } catch { /* unreadable site.yml — nothing to recover against */ }
301
+ if (!siteContentUuid) return cached
302
+ try {
303
+ const res = await client.pullSiteContent(siteContentUuid)
304
+ if (!res?.ok) return cached
305
+ const doc = entityDocFromUwx(Buffer.from(await res.arrayBuffer()))
306
+ if (!doc) return cached
307
+ const harvested = collectUnitUuids(doc)
308
+ if (Object.keys(harvested).length) {
309
+ writeItemUuids(siteDir, harvested)
310
+ note?.(`Recovered identity for ${Object.keys(harvested).length} item(s) from the backend.`)
311
+ }
312
+ return harvested
313
+ } catch {
314
+ // Best-effort: a failure here leaves the push identity-blind, which the backend
315
+ // refuses rather than silently applying. Better to hit that than to guess.
316
+ return cached
317
+ }
318
+ }
319
+
320
+ export function readUnitBases(siteDir) {
321
+ const v = readMap(siteDir, 'unitBases')
322
+ return { local: v.local || {}, remote: v.remote || {} }
323
+ }
324
+ export function writeUnitBases(siteDir, patch) {
325
+ if (!patch) return
326
+ updateSyncCache(siteDir, { unitBases: { ...readUnitBases(siteDir), ...patch } })
107
327
  }
108
328
 
109
329
  /**
@@ -157,7 +377,7 @@ export async function pushSyncPackages({ client, siteDir, pkg, asOrg, report })
157
377
  // request fires). The client carries `collision=force` (last-push-wins) + the optional
158
378
  // `--as-org`. Returns the parsed payload, or null on any transport/HTTP/parse failure
159
379
  // (already reported).
160
- const postLane = async (label, doRequest) => {
380
+ const postLane = async (label, doRequest, explainStale = async () => []) => {
161
381
  info(`Pushing ${label} to ${dim(client.origin)} …`)
162
382
  let res
163
383
  try {
@@ -168,6 +388,53 @@ export async function pushSyncPackages({ client, siteDir, pkg, asOrg, report })
168
388
  return null
169
389
  }
170
390
  if (!res.ok) {
391
+ const body = await res.text().catch(() => '')
392
+ // Two unrelated conflicts share HTTP 409, so branch on the machine-readable
393
+ // `reason` — never on `detail`, which is prose the backend may reword.
394
+ let problem = null
395
+ if ((res.status === 409 || res.status === 400) && body) {
396
+ try { problem = JSON.parse(body) } catch { /* not a problem document */ }
397
+ }
398
+ // The package carried no identity for records the backend already stores, so
399
+ // applying it would replace every one of them. `ensureItemUuids` is supposed
400
+ // to make this unreachable, so reaching it means that recovery failed — say so
401
+ // rather than surfacing a raw 400.
402
+ if (problem?.reason === 'identity_required') {
403
+ error(`${label} push refused — this copy has no record of the site's item identity.`)
404
+ note('Nothing was written. Pushing without it would have replaced the identity of every stored item.')
405
+ note('The recovery normally runs automatically, so it likely could not reach the backend.')
406
+ note('Check your connection and re-run; `uniweb pull` also restores it.')
407
+ return null
408
+ }
409
+ if (problem?.reason === 'stale_base') {
410
+ error(`${label} push refused — the backend has newer content than your last pull.`)
411
+ // Page-level account, when we can get one. The gate is entity-grained, so
412
+ // without this the user only learns "the document moved" and has no basis
413
+ // for choosing between pulling and forcing.
414
+ const detail = await explainStale()
415
+ for (const line of detail) note(line)
416
+ if (!detail.length) {
417
+ const stale = Array.isArray(problem.stale_entities) ? problem.stale_entities : []
418
+ if (stale.length) {
419
+ note(`Changed upstream: ${stale.length} entit${stale.length === 1 ? 'y' : 'ies'} (${stale.join(', ')})`)
420
+ }
421
+ }
422
+ note('Nothing was written — the whole push was refused before any change.')
423
+ // Say what will actually work from HERE. Anyone hitting this has local
424
+ // work — it is why they pushed — and if it is uncommitted then `pull`
425
+ // refuses too, so bare "run pull" advice walks them into a dead end.
426
+ if (hasUncommittedContent(siteDir)) {
427
+ // `--merge` is the recovery that keeps both sides: most of these are two
428
+ // people editing different parts of one section, which merges silently.
429
+ // Offer it first, and keep the take-theirs route for anyone who wants it.
430
+ note('Commit your changes, then `uniweb pull --merge` to combine them with the backend\'s.')
431
+ note('(Plain `uniweb pull` declines while the work is unsaved — it overwrites rather than merges.)')
432
+ } else {
433
+ note('Run `uniweb pull --merge` to combine the changes, or `uniweb pull` to take the backend version.')
434
+ }
435
+ note('To overwrite them anyway, re-run with --force (this discards the upstream edits).')
436
+ return null
437
+ }
171
438
  error(`${label} push rejected: HTTP ${res.status} ${res.statusText}`)
172
439
  if (res.status === 401 || res.status === 403) {
173
440
  note("Credentials weren't accepted — supply a bearer with --token <bearer> (or UNIWEB_TOKEN).")
@@ -181,7 +448,6 @@ export async function pushSyncPackages({ client, siteDir, pkg, asOrg, report })
181
448
  'redeploy, or clear `$uuid` in site.yml to deploy a fresh one.'
182
449
  )
183
450
  }
184
- const body = await res.text().catch(() => '')
185
451
  if (body) note(body.slice(0, 800))
186
452
  return null
187
453
  }
@@ -196,8 +462,8 @@ export async function pushSyncPackages({ client, siteDir, pkg, asOrg, report })
196
462
  // POST a lane that round-trips entity uuids (content UPDATE + the folder): parse the
197
463
  // finalized list (for record back-fill + the changed summary). Returns the finalized
198
464
  // array, or null on failure (already reported).
199
- const pushLane = async (label, doRequest) => {
200
- const payload = await postLane(label, doRequest)
465
+ const pushLane = async (label, doRequest, explainStale) => {
466
+ const payload = await postLane(label, doRequest, explainStale)
201
467
  if (payload === null) return null
202
468
  const finalized = extractFinalized(payload)
203
469
  if (!finalized) {
@@ -215,13 +481,30 @@ export async function pushSyncPackages({ client, siteDir, pkg, asOrg, report })
215
481
  // and returns its uuid, which we record into site.yml). `boundSiteUuid` carries the
216
482
  // minted/known uuid forward to key the folder push.
217
483
  let boundSiteUuid = siteContentUuid
484
+ // Post-write tokens harvested from every lane, persisted once at the end so a
485
+ // partial push (lane 1 ok, lane 2 refused) still banks what actually landed —
486
+ // otherwise the next attempt would re-send lane 1 with a base the backend has
487
+ // already moved past, and refuse a push the user just made.
488
+ const newVersions = {}
489
+ const harvest = (finalized) => {
490
+ for (const f of finalized || []) if (f.uuid && f.version) newVersions[f.uuid] = f.version
491
+ }
492
+ // The backend's post-write copy of the site-content document, kept for the
493
+ // remote-side unit base (see writeUnitBases).
494
+ let siteFinalizedDoc = null
218
495
  if (siteContent) {
219
496
  if (siteContentUuid) {
220
497
  const finalized = await pushLane(
221
498
  'site-content',
222
- () => client.updateSiteContent(siteContentUuid, siteContent.buffer, { asOrg })
499
+ () => client.updateSiteContent(siteContentUuid, siteContent.buffer, { asOrg }),
500
+ () => explainStaleSiteContent({ client, siteDir, localBuffer: siteContent.buffer, uuid: siteContentUuid })
223
501
  )
224
- if (!finalized) return { exitCode: 1, finalizedTotal, wrote }
502
+ if (!finalized) {
503
+ mergeBaseVersions(siteDir, newVersions)
504
+ return { exitCode: 1, finalizedTotal, wrote }
505
+ }
506
+ harvest(finalized)
507
+ siteFinalizedDoc = finalized[0]?.document || null
225
508
  finalizedTotal += finalized.length
226
509
  } else {
227
510
  const payload = await postLane(
@@ -238,7 +521,10 @@ export async function pushSyncPackages({ client, siteDir, pkg, asOrg, report })
238
521
  writeSiteEntityUuid(siteDir, minted)
239
522
  boundSiteUuid = minted
240
523
  wrote.push('recorded site $uuid in site.yml')
241
- finalizedTotal += extractFinalized(payload)?.length ?? 1
524
+ const createdFinalized = extractFinalized(payload)
525
+ harvest(createdFinalized)
526
+ siteFinalizedDoc = createdFinalized?.[0]?.document || null
527
+ finalizedTotal += createdFinalized?.length ?? 1
242
528
  }
243
529
  }
244
530
 
@@ -255,7 +541,11 @@ export async function pushSyncPackages({ client, siteDir, pkg, asOrg, report })
255
541
  'collections',
256
542
  () => client.pushFolder(boundSiteUuid, collections.buffer, { asOrg })
257
543
  )
258
- if (!finalized) return { exitCode: 1, finalizedTotal, wrote }
544
+ if (!finalized) {
545
+ mergeBaseVersions(siteDir, newVersions)
546
+ return { exitCode: 1, finalizedTotal, wrote }
547
+ }
548
+ harvest(finalized)
259
549
  const bf = backfillEntityUuids({ index: collections.index, finalized })
260
550
  for (const w of bf.warnings) note(`! ${w}`)
261
551
  for (const d of bf.deferred) note(`↷ ${d.id ?? `#${d.index}`}: ${d.reason}`)
@@ -263,7 +553,29 @@ export async function pushSyncPackages({ client, siteDir, pkg, asOrg, report })
263
553
  finalizedTotal += finalized.length
264
554
  }
265
555
 
266
- // Persist the full content-hash map so the next push skips unchanged entities.
556
+ // Persist the full content-hash map so the next push skips unchanged entities,
557
+ // then bank the post-write tokens so the NEXT push carries a current base.
558
+ // Entities absent from finalized[] (skipped, or not editable) keep their cached
559
+ // value — absence is not invalidation.
267
560
  writeSyncCache(siteDir, hashes)
561
+ mergeBaseVersions(siteDir, newVersions)
562
+ // Re-base the page attribution: our emitted document and the backend's post-write
563
+ // copy of it are the two sides' new agreed state. Only when the site-content lane
564
+ // actually shipped — a push that skipped it left that state where it was.
565
+ if (siteContent) {
566
+ const patch = {}
567
+ const ours = entityDocFromUwx(siteContent.buffer)
568
+ if (ours) patch.local = computeUnitHashes(ours)
569
+ // `finalized[].document` is the backend's own representation, read back from
570
+ // the stored row — the only remote-side base a push can produce.
571
+ const theirs = siteFinalizedDoc?.pages ? siteFinalizedDoc : null
572
+ if (theirs) patch.remote = computeUnitHashes(theirs)
573
+ if (Object.keys(patch).length) writeUnitBases(siteDir, patch)
574
+ // The backend's post-write document carries `$uuid` per item at every nesting
575
+ // level, so the push that just landed also re-arms identity for the next one.
576
+ // Replaced wholesale: an item that no longer exists must not keep a uuid that
577
+ // would re-target something else.
578
+ if (theirs) writeItemUuids(siteDir, collectUnitUuids(theirs))
579
+ }
268
580
  return { exitCode: 0, boundSiteUuid, finalizedTotal, wrote }
269
581
  }
@@ -53,6 +53,7 @@ import { loadDeployYml, resolveTarget, recordLastDeploy } from '@uniweb/build/si
53
53
  import { promptForDestination } from '../utils/destination-prompt.js'
54
54
  import { readFlagValue } from '../utils/args.js'
55
55
  import { parseBoolEnv } from '../utils/env.js'
56
+ import { headProvenance } from '../utils/git.js'
56
57
 
57
58
  import {
58
59
  findWorkspaceRoot,
@@ -357,12 +358,13 @@ async function deployStaticHost(siteDir, hostName, resolved, { dryRun, autoSave,
357
358
 
358
359
  // Record a fresh lastDeploy.<target> entry. Skipped on --no-save and
359
360
  // on ad-hoc --host overrides — see autoSave gating in deploy().
361
+ const gitStamp = { at: new Date().toISOString(), git: headProvenance(siteDir) }
360
362
  await persistLastDeploy(siteDir, {
361
363
  targetName: resolved.targetName,
362
364
  targetConfig: resolved.fromFile ? null : { host: hostName, ...deployConfig },
363
365
  autoSave,
364
366
  lastDeploy: {
365
- at: new Date().toISOString(),
367
+ at: gitStamp.at,
366
368
  host: hostName,
367
369
  // Adapters that drive a host CLI get the public URL back from it
368
370
  // (wrangler/netlify/vercel print it; github-pages derives it from
@@ -370,6 +372,9 @@ async function deployStaticHost(siteDir, hostName, resolved, { dryRun, autoSave,
370
372
  // the user's CloudFront/DNS config, which we never see — so the
371
373
  // field is simply omitted there rather than guessed.
372
374
  ...(deployResult?.url ? { url: deployResult.url } : {}),
375
+ // Same provenance the Uniweb path records: which commit this artifact came
376
+ // from, and whether the tree was clean when it was built.
377
+ ...(gitStamp.git ? { git: gitStamp.git } : {}),
373
378
  },
374
379
  })
375
380
  if (hostOverridden && !dryRun) {
@@ -297,6 +297,26 @@ export async function doctor(args = []) {
297
297
  continue
298
298
  }
299
299
 
300
+ // The agent index ships by default, but it can only carry absolute links
301
+ // when the site declares where it lives. Root-relative links still work
302
+ // for an agent that arrived via the index, so this warns rather than
303
+ // suppressing the artifact the way sitemap.xml does — a silently absent
304
+ // index is the exact failure the projections exist to prevent.
305
+ const agentsConfig = siteYml.agents
306
+ const indexEnabled = agentsConfig !== false && agentsConfig?.index !== false
307
+ if (indexEnabled && !siteYml.seo?.baseUrl) {
308
+ const issue = {
309
+ id: 'agents-index-relative-links',
310
+ type: 'warning',
311
+ site: siteName,
312
+ message: 'llms.txt will use root-relative links because seo.baseUrl is unset',
313
+ }
314
+ issues.push(issue)
315
+ warn(`[agents-index-relative-links] llms.txt links will be root-relative`)
316
+ log(` Set ${colors.green}seo.baseUrl${colors.reset} in site.yml so agents get absolute URLs`)
317
+ log(` (this also turns on sitemap.xml and robots.txt, which are skipped without it)`)
318
+ }
319
+
300
320
  const foundationName = siteYml.foundation
301
321
  if (!foundationName) {
302
322
  warn('No foundation specified in site.yml (using runtime loading?)')
@@ -27,6 +27,7 @@
27
27
  * uniweb publish Bring the foundation along, sync, and go live
28
28
  * uniweb publish --dry-run Resolve everything; POST nothing
29
29
  * uniweb publish --yes Skip confirmations (CI); never block on a prompt
30
+ * uniweb publish --force Overwrite upstream app-side edits (drop the push gate)
30
31
  * uniweb publish --no-save Skip the deploy.yml lastDeploy auto-save
31
32
  * uniweb publish --backend <url> Override the backend origin
32
33
  * uniweb publish --token <bearer> Auth bearer (skips `uniweb login`)
@@ -54,7 +55,15 @@ import { BackendClient } from '../backend/client.js'
54
55
  import { resolveSiteDir, resolveSiteBackend } from './deploy.js'
55
56
  import { readFlagValue } from '../utils/args.js'
56
57
  import { isNonInteractive } from '../utils/interactive.js'
57
- import { makeModelResolver, readSyncCache, pushSyncPackages } from '../backend/site-sync.js'
58
+ import { headProvenance } from '../utils/git.js'
59
+ import {
60
+ makeModelResolver,
61
+ readSyncCache,
62
+ readBaseVersions,
63
+ readItemBaseVersions,
64
+ ensureItemUuids,
65
+ pushSyncPackages,
66
+ } from '../backend/site-sync.js'
58
67
  import { uploadDataBundle } from '../backend/data-bundle.js'
59
68
  import { uploadSiteMedia } from '../backend/site-media.js'
60
69
  import { bringFoundationAlong } from '../backend/foundation-bring-along.js'
@@ -296,6 +305,13 @@ export async function publish(args = []) {
296
305
  // the SAME two-lane submission `uniweb push` uses — stamping
297
306
  // info.data_bundle and rewriting local media refs to backend serve URLs.
298
307
  const priorHashes = readSyncCache(siteDir)
308
+ // publish rides the same gated push as `uniweb push`: if an app author has
309
+ // edited since this clone last synced, the push is refused rather than
310
+ // overwriting them, and nothing goes live. `--force` drops the precondition.
311
+ const baseVersions = args.includes('--force') ? null : readBaseVersions(siteDir)
312
+ // Per-item identity, recovered from the backend when this clone has never seen it.
313
+ // Without it the backend re-mints every page and section row (see readItemUuids).
314
+ const itemUuids = await ensureItemUuids({ client, siteDir, note: (m) => say.dim(m) })
299
315
  // Stamp deploy-derived info on the site-content entity: the data-bundle URL,
300
316
  // and the PINNED foundation ref (`@scope/name@version`) from the bring-along.
301
317
  // Delivery is version-pinned end-to-end (the gateway serves a foundation only
@@ -313,6 +329,8 @@ export async function publish(args = []) {
313
329
  ...(foundationDir ? { foundationDir } : {}),
314
330
  resolveModel,
315
331
  priorHashes,
332
+ itemUuids,
333
+ ...(baseVersions ? { baseVersions, itemBaseVersions: readItemBaseVersions(siteDir) } : {}),
316
334
  ...(Object.keys(injectInfo).length ? { injectInfo } : {}),
317
335
  ...(assetRewrite ? { assetRewrite } : {}),
318
336
  })
@@ -376,6 +394,7 @@ export async function publish(args = []) {
376
394
  // foundation version (the bring-along, §4).
377
395
  // Record the ref that actually went live: the pinned `@scope/name@version`
378
396
  // from the bring-along when present, else the site.yml ref verbatim.
397
+ const gitAt = headProvenance(siteDir)
379
398
  const siteYmlRef = typeof siteYml.foundation === 'string' ? siteYml.foundation : siteYml.foundation?.ref || null
380
399
  const recordedRef = fnd.ref || siteYmlRef
381
400
  await persistLastDeploy(siteDir, {
@@ -388,6 +407,11 @@ export async function publish(args = []) {
388
407
  lastDeploy: {
389
408
  at: new Date().toISOString(),
390
409
  host: 'uniweb',
410
+ // What was actually shipped. A version number can't answer that — two
411
+ // publishes of "0.1.0" are not the same content — and after the fact the
412
+ // working tree has moved on. `dirty` matters as much as the sha: it says the
413
+ // publish did NOT correspond to any commit, so the sha alone would mislead.
414
+ ...(gitAt ? { git: gitAt } : {}),
391
415
  backend: client.origin,
392
416
  siteUuid,
393
417
  url: serveUrl,