uniweb 0.13.5 → 0.13.7
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 +7 -7
- package/partials/agents.md +66 -1
- package/src/backend/site-media.js +34 -5
- package/src/backend/site-sync.js +326 -14
- package/src/commands/deploy.js +6 -1
- package/src/commands/doctor.js +20 -0
- package/src/commands/publish.js +42 -3
- package/src/commands/pull.js +387 -7
- package/src/commands/push.js +114 -3
- package/src/commands/refresh.js +176 -0
- package/src/commands/sync.js +75 -0
- package/src/framework-index.json +18 -9
- package/src/index.js +16 -0
- package/src/utils/git.js +203 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "uniweb",
|
|
3
|
-
"version": "0.13.
|
|
3
|
+
"version": "0.13.7",
|
|
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.
|
|
45
|
-
"@uniweb/kit": "0.9.
|
|
46
|
-
"@uniweb/runtime": "0.8.
|
|
44
|
+
"@uniweb/core": "0.7.31",
|
|
45
|
+
"@uniweb/kit": "0.9.39",
|
|
46
|
+
"@uniweb/runtime": "0.8.38"
|
|
47
47
|
},
|
|
48
48
|
"peerDependencies": {
|
|
49
|
-
"@uniweb/content-reader": "1.1.
|
|
50
|
-
"@uniweb/semantic-parser": "1.1.
|
|
51
|
-
"@uniweb/build": "0.15.
|
|
49
|
+
"@uniweb/content-reader": "1.1.17",
|
|
50
|
+
"@uniweb/semantic-parser": "1.1.20",
|
|
51
|
+
"@uniweb/build": "0.15.7"
|
|
52
52
|
},
|
|
53
53
|
"peerDependenciesMeta": {
|
|
54
54
|
"@uniweb/build": {
|
package/partials/agents.md
CHANGED
|
@@ -472,6 +472,8 @@ Optional attributes: `{size=20}`, `{color=red}`. Custom SVGs: `, and a value containing a **comma** must be quoted (`{style="a, b"}`). A value containing a colon needs no quoting — only the first colon separates, so `{href:https://example.com}` is fine.
|
|
476
|
+
|
|
475
477
|
Standalone links (alone on a line) become buttons in `content.links[]`. Inline links stay as `<a>` tags inside `content.paragraphs[]`. Multiple links sharing a paragraph are all promoted:
|
|
476
478
|
|
|
477
479
|
```markdown
|
|
@@ -1278,8 +1280,9 @@ Pages are sequences of sections — the obvious layer. The framework also suppor
|
|
|
1278
1280
|
| **Items** (`content.items`) | Heading groups within one `.md` | Repeating content in one section: cards, features, FAQ entries |
|
|
1279
1281
|
| **Child sections** (`block.childBlocks`) | `@`-prefixed `.md` files + `nest:` | Children needing their own section type, rich content, or independent editing |
|
|
1280
1282
|
| **Insets** (`block.insets`) | `` in markdown | Self-contained visuals/widgets: charts, diagrams, code demos |
|
|
1283
|
+
| **Block insets** (`block.insets`) | ` ```@Component ` fence around markdown | A component that *wraps* authored prose: callouts, disclosures, admonitions |
|
|
1281
1284
|
|
|
1282
|
-
Does the author write content *inside* the nested element? **Yes** → child sections. **No** (self-contained, param-driven) → inset. Repeating same-structure groups → items. These compose: a child section can contain insets; items work inside children.
|
|
1285
|
+
Does the author write content *inside* the nested element? **Yes** → child sections, or a block inset when the wrapper is presentational and lives mid-page. **No** (self-contained, param-driven) → inset. Repeating same-structure groups → items. These compose: a child section can contain insets; items work inside children; a block inset can contain both.
|
|
1283
1286
|
|
|
1284
1287
|
**Insets — embedding components in content.** Many section types need a "visual" — a hero's illustration, a split-content section's media. Classically an image or video. But what if it's a JSX + SVG diagram, a ThreeJS animation, an interactive playground? Elsewhere you'd reach for MDX or prop-drilling. Here the author writes standard image syntax:
|
|
1285
1288
|
|
|
@@ -1297,6 +1300,23 @@ The developer builds `NetworkDiagram` as an ordinary React component with `inset
|
|
|
1297
1300
|
|
|
1298
1301
|
**Don't use `hidden: true` on insets.** `hidden` means "don't export this component at all" (internal helpers); `inset: true` means "available for `@Component` references in markdown."
|
|
1299
1302
|
|
|
1303
|
+
**Block insets — a component that wraps content.** The image form is a leaf: it takes params but no body. When the component should surround authored prose — a callout, a disclosure, an admonition — use the fenced form instead. Same `@Component{params}` reference, written as a code fence's info string:
|
|
1304
|
+
|
|
1305
|
+
````markdown
|
|
1306
|
+
```@Alert{type=warning}
|
|
1307
|
+
Back up your database **before** running this.
|
|
1308
|
+
|
|
1309
|
+
- The migration is not reversible
|
|
1310
|
+
- Allow ten minutes of downtime
|
|
1311
|
+
```
|
|
1312
|
+
````
|
|
1313
|
+
|
|
1314
|
+
The body is ordinary content, not text: it is parsed exactly like the rest of the page, so headings, lists, tables, icons, inline styling, leaf insets and further containers all work inside one. To nest a code block or another container, open the outer fence with more backticks than the inner one.
|
|
1315
|
+
|
|
1316
|
+
**The component is yours, and it receives parsed content.** `@Alert` resolves against the foundation through the same lookup as `` — declare it with `inset: true` in `meta.js`. Where a leaf inset gets its alt text as `content.title` and nothing else, a container gets its whole body parsed: `title`, `paragraphs`, `items`, `sequence`. Render it with `<Prose content={content} block={block} />` or read the fields directly. A name the foundation doesn't define falls back to a plain bordered box that still shows the body — never a drop.
|
|
1317
|
+
|
|
1318
|
+
Prefer a **child section** when the author needs their own section type and independent editing; prefer a **block inset** when the wrapper is presentational and belongs inline in a single file's prose.
|
|
1319
|
+
|
|
1300
1320
|
**Child sections.** You hit a complex layout — a 2:1 split with a panel and a main area. Your instinct says build a specialized component. Step back: the panel is a reusable section type, the main area is another, and the split is a Grid with `columns: "1fr 2fr"`. Your child components already adapt to narrow containers — container queries handle that. But hardcoding which components go where means the author can't rearrange or swap them. Child sections solve that:
|
|
1301
1321
|
|
|
1302
1322
|
```
|
|
@@ -1571,6 +1591,46 @@ Recipes for staying on the default fetcher, and for writing a custom transport:
|
|
|
1571
1591
|
|
|
1572
1592
|
Full model: `reference/data-fetching.md`. Where-object format with examples: `authoring/predicates.md`.
|
|
1573
1593
|
|
|
1594
|
+
### Search (`search:`)
|
|
1595
|
+
|
|
1596
|
+
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.
|
|
1597
|
+
|
|
1598
|
+
```yaml
|
|
1599
|
+
# site.yml
|
|
1600
|
+
search:
|
|
1601
|
+
enabled: true
|
|
1602
|
+
provider: index # default — download an index, match in the browser
|
|
1603
|
+
```
|
|
1604
|
+
|
|
1605
|
+
| Provider | Answers with | Trade-off |
|
|
1606
|
+
|---|---|---|
|
|
1607
|
+
| `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. |
|
|
1608
|
+
| `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. |
|
|
1609
|
+
| *any other name* | A foundation-supplied search transport | Fully open — Typesense, Meilisearch, Pagefind, a vendor API |
|
|
1610
|
+
|
|
1611
|
+
```yaml
|
|
1612
|
+
search:
|
|
1613
|
+
provider: endpoint
|
|
1614
|
+
endpoint: _search # optional; base-RELATIVE, so one spelling works everywhere
|
|
1615
|
+
```
|
|
1616
|
+
|
|
1617
|
+
`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.
|
|
1618
|
+
|
|
1619
|
+
**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`.
|
|
1620
|
+
|
|
1621
|
+
`snippetHtml` is HTML with `<mark>`. Render it through `SafeHtml`, never as text.
|
|
1622
|
+
|
|
1623
|
+
**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.
|
|
1624
|
+
|
|
1625
|
+
```jsx
|
|
1626
|
+
import { useSearch } from '@uniweb/kit'
|
|
1627
|
+
|
|
1628
|
+
const { results, isLoading, query } = useSearch(website) // `query` is a function
|
|
1629
|
+
<input onChange={e => query(e.target.value)} /> // debounced internally
|
|
1630
|
+
```
|
|
1631
|
+
|
|
1632
|
+
Full reference: `authoring/search.md`.
|
|
1633
|
+
|
|
1574
1634
|
<!-- template:loom -->
|
|
1575
1635
|
### Content handlers
|
|
1576
1636
|
|
|
@@ -1624,6 +1684,9 @@ uniweb add ci --target foundation # Publish a foundation for free at permanent v
|
|
|
1624
1684
|
|
|
1625
1685
|
uniweb push / pull / clone / status # Git-style content sync with the Uniweb backend
|
|
1626
1686
|
uniweb register [--scope @org] # Register a foundation + its data schemas to the registry
|
|
1687
|
+
uniweb login / logout # Start or clear the backend session the verbs above reuse
|
|
1688
|
+
uniweb org list / create <handle> # Publish orgs you belong to — the @org in a scoped ref
|
|
1689
|
+
uniweb content export [dir] # Package a site (or a built foundation's schema) as .uwx
|
|
1627
1690
|
|
|
1628
1691
|
uniweb rename <foundation|site|extension> <old> <new> # Rename across the whole workspace
|
|
1629
1692
|
uniweb i18n extract / init / sync / status / audit # Translation workflow (build first)
|
|
@@ -1638,6 +1701,8 @@ uniweb inspect <path> # Show parsed content for a section or page (-
|
|
|
1638
1701
|
uniweb <command> --help # Per-command flags — no side effects. Prefer this over guessing.
|
|
1639
1702
|
```
|
|
1640
1703
|
|
|
1704
|
+
**Four verbs dispatch but are deliberately not listed above.** `invite`, `handoff` and `template` are **reserved names with no implementation** — the flows they named ran against a backend the CLI no longer talks to, and the names are held so a future rebuild doesn't need a breaking change. `runtime register` uploads a built runtime and is internal. Running any of them is not useful; their absence from this list is the answer, not an omission to fix.
|
|
1705
|
+
|
|
1641
1706
|
### Where a site can live
|
|
1642
1707
|
|
|
1643
1708
|
**There is no lock-in, and no default you're pushed toward.** Four independent paths, and a project can change its mind:
|
|
@@ -24,16 +24,24 @@ import { contentTypeFor } from '../utils/code-upload.js'
|
|
|
24
24
|
* @param {string} siteDir - the site root (site-root refs resolve under public/)
|
|
25
25
|
* @param {string[]} refs - site-root local asset refs (`/images/x.png`)
|
|
26
26
|
* @param {{ onProgress?: (m: string) => void, warn?: (m: string) => void }} [opts]
|
|
27
|
-
* @returns {Promise<Record<string,string
|
|
27
|
+
* @returns {Promise<{ map: Record<string,string>, missing: string[], failed: Array<{path:string,status:number,detail?:string}> }>}
|
|
28
|
+
* `map` is ref → serve URL for refs that resolved AND uploaded. The two failure
|
|
29
|
+
* kinds are reported SEPARATELY because callers must treat them differently:
|
|
30
|
+
* `missing` is a ref with no file under the site — an authoring mistake, already
|
|
31
|
+
* broken before us, worth a warning; `failed` is a ref whose bytes we could not
|
|
32
|
+
* store — a transport or QUOTA refusal, and shipping content that still points at
|
|
33
|
+
* the local path would publish a broken image while only warning about it.
|
|
28
34
|
*/
|
|
29
35
|
export async function uploadSiteMedia(client, siteDir, refs, { onProgress, warn } = {}) {
|
|
30
|
-
if (!refs?.length) return {}
|
|
36
|
+
if (!refs?.length) return { map: {}, missing: [], failed: [] }
|
|
31
37
|
|
|
32
38
|
const files = []
|
|
39
|
+
const missing = []
|
|
33
40
|
for (const ref of refs) {
|
|
34
41
|
const { resolved } = resolveAssetPath(ref, siteDir, siteDir)
|
|
35
42
|
if (!resolved || !existsSync(resolved)) {
|
|
36
43
|
warn?.(`local-media: ${ref} not found under the site (skipped)`)
|
|
44
|
+
missing.push(ref)
|
|
37
45
|
continue
|
|
38
46
|
}
|
|
39
47
|
const bytes = readFileSync(resolved)
|
|
@@ -46,10 +54,11 @@ export async function uploadSiteMedia(client, siteDir, refs, { onProgress, warn
|
|
|
46
54
|
diskPath: resolved,
|
|
47
55
|
})
|
|
48
56
|
}
|
|
49
|
-
if (!files.length) return {}
|
|
57
|
+
if (!files.length) return { map: {}, missing, failed: [] }
|
|
50
58
|
|
|
51
59
|
const result = await client.uploadSiteAssets({ files, onProgress })
|
|
52
|
-
|
|
60
|
+
const failed = result.failed || []
|
|
61
|
+
for (const f of failed) warn?.(`local-media: upload failed for ${f.path} (HTTP ${f.status})`)
|
|
53
62
|
|
|
54
63
|
const config = await client.discover()
|
|
55
64
|
const map = {}
|
|
@@ -57,5 +66,25 @@ export async function uploadSiteMedia(client, siteDir, refs, { onProgress, warn
|
|
|
57
66
|
const entry = result.assetsByLocalUrl[ref]
|
|
58
67
|
if (entry) map[ref] = entry.serveUrl || buildAssetUrl(client.origin, config.assetBase, entry.id, entry.ext)
|
|
59
68
|
}
|
|
60
|
-
return map
|
|
69
|
+
return { map, missing, failed }
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Is this asset-lane error the backend refusing on storage grounds?
|
|
74
|
+
*
|
|
75
|
+
* The plan step throws `Asset plan failed: HTTP <status>` for any non-2xx, so a quota
|
|
76
|
+
* refusal is currently indistinguishable from any other rejection except by status.
|
|
77
|
+
* These three are the plausible spellings — 402 (payment required), 413 (payload too
|
|
78
|
+
* large), 507 (insufficient storage).
|
|
79
|
+
*
|
|
80
|
+
* DELIBERATELY a heuristic, and it should not stay one: the backend owes a typed
|
|
81
|
+
* error carrying used / limit / needed so the CLI can say what a push costs and what
|
|
82
|
+
* is left. Until that contract exists this at least turns an opaque HTTP number into
|
|
83
|
+
* the right advice. See the collab charter in the handoff.
|
|
84
|
+
*
|
|
85
|
+
* @param {Error} err
|
|
86
|
+
* @returns {boolean}
|
|
87
|
+
*/
|
|
88
|
+
export function isStorageRefusal(err) {
|
|
89
|
+
return /Asset plan failed: HTTP (402|413|507)\b/.test(err?.message || '')
|
|
61
90
|
}
|
package/src/backend/site-sync.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
158
|
+
function readSyncCacheFile(siteDir) {
|
|
96
159
|
try {
|
|
97
160
|
const obj = JSON.parse(readFileSync(syncCachePath(siteDir), 'utf8'))
|
|
98
|
-
return obj && typeof obj
|
|
161
|
+
return obj && typeof obj === 'object' ? obj : {}
|
|
99
162
|
} catch {
|
|
100
163
|
return {} // missing / unreadable → treat everything as changed
|
|
101
164
|
}
|
|
102
165
|
}
|
|
103
|
-
|
|
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
|
-
|
|
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)
|
|
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
|
-
|
|
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)
|
|
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
|
}
|
package/src/commands/deploy.js
CHANGED
|
@@ -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:
|
|
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) {
|