uniweb 0.12.32 → 0.12.34
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 +3 -3
- package/partials/agents.md +2 -0
- package/src/backend/client.js +12 -5
- package/src/backend/data-bundle.js +43 -0
- package/src/backend/site-media.js +61 -0
- package/src/backend/site-sync.js +246 -0
- package/src/commands/deploy.js +137 -201
- package/src/commands/pull.js +108 -24
- package/src/commands/push.js +26 -212
- package/src/framework-index.json +4 -4
- package/src/utils/asset-upload.js +22 -8
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "uniweb",
|
|
3
|
-
"version": "0.12.
|
|
3
|
+
"version": "0.12.34",
|
|
4
4
|
"description": "Create structured Vite + React sites with content/code separation",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -43,10 +43,10 @@
|
|
|
43
43
|
"tar": "^7.0.0",
|
|
44
44
|
"@uniweb/core": "0.7.14",
|
|
45
45
|
"@uniweb/kit": "0.9.18",
|
|
46
|
-
"@uniweb/runtime": "0.8.
|
|
46
|
+
"@uniweb/runtime": "0.8.20"
|
|
47
47
|
},
|
|
48
48
|
"peerDependencies": {
|
|
49
|
-
"@uniweb/build": "0.14.
|
|
49
|
+
"@uniweb/build": "0.14.17",
|
|
50
50
|
"@uniweb/content-reader": "1.1.12",
|
|
51
51
|
"@uniweb/semantic-parser": "1.1.17"
|
|
52
52
|
},
|
package/partials/agents.md
CHANGED
|
@@ -1762,6 +1762,8 @@ export default {
|
|
|
1762
1762
|
|
|
1763
1763
|
The content handler receives `block.parsedContent.data` and reads raw ProseMirror from `block.rawContent`. It returns a new ProseMirror document — the framework re-parses it through the semantic parser. Returning `null` or the same reference as `block.rawContent` signals no change.
|
|
1764
1764
|
|
|
1765
|
+
> **`instantiateContent` resolves `{placeholders}` in text nodes only** — not in link `href`s or other node/mark attributes. So `[{email}](mailto:{email})` fills the visible label but leaves the `mailto:` URL literal. For dynamic URLs, emit the value as plain text and let the component linkify it, or build the href in the handler yourself.
|
|
1766
|
+
|
|
1765
1767
|
### Reserved frontmatter fields
|
|
1766
1768
|
|
|
1767
1769
|
`source` and `where` are convention-level reserved fields — they flow through to both `block.properties` (for handler access) and `params` (visible to components). Components can ignore them. This is consistent with how `background` and `theme` work. List them in `meta.js` params with descriptions so the editor and schema recognize them.
|
package/src/backend/client.js
CHANGED
|
@@ -240,14 +240,21 @@ export class BackendClient {
|
|
|
240
240
|
})
|
|
241
241
|
}
|
|
242
242
|
|
|
243
|
-
/**
|
|
244
|
-
|
|
245
|
-
|
|
243
|
+
/**
|
|
244
|
+
* GET /dev/site/content/pull/{uuid} — the content lane document. Pass the
|
|
245
|
+
* last-seen ETag (opaque) to make it conditional: a match returns 304 (empty body).
|
|
246
|
+
*/
|
|
247
|
+
async pullSiteContent(uuid, { etag } = {}) {
|
|
248
|
+
return this.request(`/dev/site/content/pull/${encodeURIComponent(uuid)}`, {
|
|
249
|
+
headers: etag ? { 'If-None-Match': etag } : {},
|
|
250
|
+
})
|
|
246
251
|
}
|
|
247
252
|
|
|
248
253
|
/** GET /dev/site/folder/pull/{uuid} — the folder lane (folder + record documents). */
|
|
249
|
-
async pullFolder(uuid) {
|
|
250
|
-
return this.request(`/dev/site/folder/pull/${encodeURIComponent(uuid)}
|
|
254
|
+
async pullFolder(uuid, { etag } = {}) {
|
|
255
|
+
return this.request(`/dev/site/folder/pull/${encodeURIComponent(uuid)}`, {
|
|
256
|
+
headers: etag ? { 'If-None-Match': etag } : {},
|
|
257
|
+
})
|
|
251
258
|
}
|
|
252
259
|
|
|
253
260
|
// ── Delivery: deploy + site publish ─────────────────────────────────────────────
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Upload the static-data ball (assembleDataBall's `{ data, search }` doc) to the
|
|
3
|
+
* backend's content-addressed asset store via the SAME asset lane deploy uses for
|
|
4
|
+
* media, and return its durable serve URL — the `info.data_bundle` the composite push
|
|
5
|
+
* stamps on the site-content entity. The backend unwraps the ball into the `/data/*`
|
|
6
|
+
* + `/_search/*` bytes the gateway serves.
|
|
7
|
+
*
|
|
8
|
+
* The ball is in-memory (not a built file on disk), so it rides as `bytes` on the
|
|
9
|
+
* single upload entry — `uploadSiteAssets` PUTs `bytes` when present, else reads a
|
|
10
|
+
* `diskPath` (its media path). Content-addressed like every asset: identical ball →
|
|
11
|
+
* same id → a re-deploy of unchanged data is a cheap no-op PUT.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { createHash } from 'node:crypto'
|
|
15
|
+
import { buildAssetUrl } from '../utils/asset-upload.js'
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* @param {object} client - BackendClient (origin + uploadSiteAssets + discover)
|
|
19
|
+
* @param {{ data: object, search: object }} ball - the assembled data ball
|
|
20
|
+
* @param {{ onProgress?: (m: string) => void }} [opts]
|
|
21
|
+
* @returns {Promise<string>} the content-addressed serve URL (→ `info.data_bundle`)
|
|
22
|
+
*/
|
|
23
|
+
export async function uploadDataBundle(client, ball, { onProgress } = {}) {
|
|
24
|
+
const bytes = Buffer.from(JSON.stringify(ball))
|
|
25
|
+
const sha256 = createHash('sha256').update(bytes).digest('hex')
|
|
26
|
+
const localUrl = '/data-bundle/base.json' // bookkeeping key into assetsByLocalUrl
|
|
27
|
+
|
|
28
|
+
const result = await client.uploadSiteAssets({
|
|
29
|
+
files: [
|
|
30
|
+
{ path: 'data-bundle/base.json', content_type: 'application/json', size: bytes.length, sha256, localUrl, bytes },
|
|
31
|
+
],
|
|
32
|
+
onProgress,
|
|
33
|
+
})
|
|
34
|
+
if (result.failed?.length) {
|
|
35
|
+
const f = result.failed[0]
|
|
36
|
+
throw new Error(`data-bundle upload failed: HTTP ${f.status} ${f.detail}`)
|
|
37
|
+
}
|
|
38
|
+
const entry = result.assetsByLocalUrl[localUrl]
|
|
39
|
+
if (!entry) throw new Error('data-bundle upload returned no asset id')
|
|
40
|
+
|
|
41
|
+
const config = await client.discover()
|
|
42
|
+
return buildAssetUrl(client.origin, config.assetBase, entry.id, entry.ext)
|
|
43
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Upload a site's local media to the backend's content-addressed asset store via the
|
|
3
|
+
* SAME asset lane the data bundle rides, and return a `{ ref → serveUrl }` map for the
|
|
4
|
+
* deploy's second emit (`assetRewrite`) to swap the entity content refs for.
|
|
5
|
+
*
|
|
6
|
+
* Input is the site-root asset refs the producer surfaced in
|
|
7
|
+
* `emitSyncPackages().localAssets` (`/images/hero.png`); `resolveAssetPath` finds the
|
|
8
|
+
* file under the site's `public/` (or `assets/`). A ref whose file is missing is
|
|
9
|
+
* skipped (warned), never a broken serve URL. The serve URL is the backend's canonical
|
|
10
|
+
* `serve_url` when present, else reconstructed from `id`+`assetBase` (the dev fallback).
|
|
11
|
+
* Content-addressed like every asset: identical bytes → same id → a re-deploy of
|
|
12
|
+
* unchanged media is a cheap no-op PUT (the lane's `present` skip-list).
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { createHash } from 'node:crypto'
|
|
16
|
+
import { existsSync, readFileSync } from 'node:fs'
|
|
17
|
+
import { basename } from 'node:path'
|
|
18
|
+
import { resolveAssetPath } from '@uniweb/build/site'
|
|
19
|
+
import { buildAssetUrl } from '../utils/asset-upload.js'
|
|
20
|
+
import { contentTypeFor } from '../utils/code-upload.js'
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* @param {object} client - BackendClient (origin + uploadSiteAssets + discover)
|
|
24
|
+
* @param {string} siteDir - the site root (site-root refs resolve under public/)
|
|
25
|
+
* @param {string[]} refs - site-root local asset refs (`/images/x.png`)
|
|
26
|
+
* @param {{ onProgress?: (m: string) => void, warn?: (m: string) => void }} [opts]
|
|
27
|
+
* @returns {Promise<Record<string,string>>} ref → serve URL (only resolved + uploaded refs)
|
|
28
|
+
*/
|
|
29
|
+
export async function uploadSiteMedia(client, siteDir, refs, { onProgress, warn } = {}) {
|
|
30
|
+
if (!refs?.length) return {}
|
|
31
|
+
|
|
32
|
+
const files = []
|
|
33
|
+
for (const ref of refs) {
|
|
34
|
+
const { resolved } = resolveAssetPath(ref, siteDir, siteDir)
|
|
35
|
+
if (!resolved || !existsSync(resolved)) {
|
|
36
|
+
warn?.(`local-media: ${ref} not found under the site (skipped)`)
|
|
37
|
+
continue
|
|
38
|
+
}
|
|
39
|
+
const bytes = readFileSync(resolved)
|
|
40
|
+
files.push({
|
|
41
|
+
path: ref.replace(/^\/+/, ''), // bookkeeping key into the plan (must be unique)
|
|
42
|
+
content_type: contentTypeFor(basename(resolved)),
|
|
43
|
+
size: bytes.length,
|
|
44
|
+
sha256: createHash('sha256').update(bytes).digest('hex'),
|
|
45
|
+
localUrl: ref, // the rewrite key — the original content ref
|
|
46
|
+
diskPath: resolved,
|
|
47
|
+
})
|
|
48
|
+
}
|
|
49
|
+
if (!files.length) return {}
|
|
50
|
+
|
|
51
|
+
const result = await client.uploadSiteAssets({ files, onProgress })
|
|
52
|
+
for (const f of result.failed || []) warn?.(`local-media: upload failed for ${f.path} (HTTP ${f.status})`)
|
|
53
|
+
|
|
54
|
+
const config = await client.discover()
|
|
55
|
+
const map = {}
|
|
56
|
+
for (const ref of refs) {
|
|
57
|
+
const entry = result.assetsByLocalUrl[ref]
|
|
58
|
+
if (entry) map[ref] = entry.serveUrl || buildAssetUrl(client.origin, config.assetBase, entry.id, entry.ext)
|
|
59
|
+
}
|
|
60
|
+
return map
|
|
61
|
+
}
|
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* site-sync — the reusable core of `uniweb push`: given a site's emitted sync
|
|
3
|
+
* packages, submit them over the two directional lanes (site-content first, then the
|
|
4
|
+
* folder keyed by the site's uuid), back-fill the minted uuids into the source files,
|
|
5
|
+
* and persist the send-only-changed cache. Extracted from the push command so
|
|
6
|
+
* `uniweb deploy` (the composite path) reuses the exact same lane submission.
|
|
7
|
+
*
|
|
8
|
+
* The command keeps flag parsing, the emit, and the `-o`/`--dry-run` preview;
|
|
9
|
+
* everything from "the packages are built, now POST them" lives here. Logging is
|
|
10
|
+
* injected via `report` ({ info, note, error, dim }) so each caller styles output its
|
|
11
|
+
* own way.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { writeFileSync, readFileSync, mkdirSync } from 'node:fs'
|
|
15
|
+
import { join, dirname } from 'node:path'
|
|
16
|
+
import { backfillEntityUuids, writeSiteEntityUuid } from '@uniweb/build/uwx'
|
|
17
|
+
|
|
18
|
+
// Pull the finalized entities out of the restore response. The backend returns
|
|
19
|
+
// `{ report: { finalized: [ { index, uuid, changed, document }, … ] } }` — each entry
|
|
20
|
+
// carries its position in the SUBMITTED sequence (`index`, the correlation key — `$id`
|
|
21
|
+
// is not echoed), the minted entity `uuid`, a `changed` flag, and the full `document`
|
|
22
|
+
// (verbatim stored content with every `$uuid` filled in). A couple of shapes are
|
|
23
|
+
// tolerated; only entries with a valid `index` + `uuid` are usable.
|
|
24
|
+
export function extractFinalized(payload) {
|
|
25
|
+
const list = Array.isArray(payload?.report?.finalized)
|
|
26
|
+
? payload.report.finalized
|
|
27
|
+
: Array.isArray(payload?.finalized)
|
|
28
|
+
? payload.finalized
|
|
29
|
+
: Array.isArray(payload)
|
|
30
|
+
? payload
|
|
31
|
+
: null
|
|
32
|
+
if (!list) return null
|
|
33
|
+
return list
|
|
34
|
+
.map((d) => ({
|
|
35
|
+
index: d?.index,
|
|
36
|
+
uuid: d?.uuid ?? d?.document?.$uuid ?? null,
|
|
37
|
+
changed: d?.changed,
|
|
38
|
+
document: d?.document ?? null,
|
|
39
|
+
}))
|
|
40
|
+
.filter((e) => Number.isInteger(e.index) && e.uuid)
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// Pull the minted site-content uuid out of a CREATE response. The exact shape is an
|
|
44
|
+
// open backend item, so the extractor is deliberately tolerant of a bare
|
|
45
|
+
// `{ siteContentUuid }` / `{ $uuid }` / `{ uuid }`, or the same `report.finalized[]`
|
|
46
|
+
// envelope the update/folder lanes return (the site entity is submitted alone, so its
|
|
47
|
+
// minted uuid is the first finalized entry). Returns null if none is present.
|
|
48
|
+
export function extractMintedSiteUuid(payload) {
|
|
49
|
+
if (typeof payload?.siteContentUuid === 'string') return payload.siteContentUuid
|
|
50
|
+
if (typeof payload?.$uuid === 'string') return payload.$uuid
|
|
51
|
+
if (typeof payload?.uuid === 'string') return payload.uuid
|
|
52
|
+
const finalized = extractFinalized(payload)
|
|
53
|
+
if (finalized && finalized.length) {
|
|
54
|
+
const site = finalized.find((f) => f.index === 0) || finalized[0]
|
|
55
|
+
return site?.uuid ?? null
|
|
56
|
+
}
|
|
57
|
+
return null
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// One-line summary from the authoritative per-entity `changed` flag (`false` = a true
|
|
61
|
+
// no-op). Falls back silently when the backend omits it.
|
|
62
|
+
function changedSummary(finalized) {
|
|
63
|
+
const changed = finalized.filter((f) => f.changed === true).length
|
|
64
|
+
const unchanged = finalized.filter((f) => f.changed === false).length
|
|
65
|
+
const parts = []
|
|
66
|
+
if (changed) parts.push(`${changed} changed`)
|
|
67
|
+
if (unchanged) parts.push(`${unchanged} unchanged`)
|
|
68
|
+
return parts.length ? parts.join(', ') : null
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// Resolve a Model NOT defined by the local foundation by reading its declaration (the
|
|
72
|
+
// `@uniweb/data-schema` form) from the backend via the client. Cached per run; HTTP
|
|
73
|
+
// 404 → null (the emitter then says "register it first"). The bearer is acquired lazily
|
|
74
|
+
// by the client, so a fully-local sync never authenticates.
|
|
75
|
+
//
|
|
76
|
+
// `offline` (set for `-o` / `--dry-run`) forces every non-local Model to null WITHOUT
|
|
77
|
+
// touching the backend — an offline emit must never authenticate.
|
|
78
|
+
export function makeModelResolver({ client, offline = false }) {
|
|
79
|
+
const cache = new Map()
|
|
80
|
+
return async (modelName) => {
|
|
81
|
+
if (cache.has(modelName)) return cache.get(modelName)
|
|
82
|
+
const decl = offline ? null : await client.readDataSchema(modelName)
|
|
83
|
+
cache.set(modelName, decl)
|
|
84
|
+
return decl
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// "Send only changed" cache: content hashes from the last successful sync, keyed
|
|
89
|
+
// `<model> <id>`. Gitignored, per-clone, deletable (a deleted cache just means one full
|
|
90
|
+
// re-sync, which the backend then no-ops). NOT identity — the minted `$uuid` lives in
|
|
91
|
+
// the source files; this is a pure wire-efficiency cache.
|
|
92
|
+
function syncCachePath(siteDir) {
|
|
93
|
+
return join(siteDir, '.uniweb', 'sync-cache.json')
|
|
94
|
+
}
|
|
95
|
+
export function readSyncCache(siteDir) {
|
|
96
|
+
try {
|
|
97
|
+
const obj = JSON.parse(readFileSync(syncCachePath(siteDir), 'utf8'))
|
|
98
|
+
return obj && typeof obj.hashes === 'object' && obj.hashes ? obj.hashes : {}
|
|
99
|
+
} catch {
|
|
100
|
+
return {} // missing / unreadable → treat everything as changed
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
export function writeSyncCache(siteDir, hashes) {
|
|
104
|
+
const p = syncCachePath(siteDir)
|
|
105
|
+
mkdirSync(dirname(p), { recursive: true })
|
|
106
|
+
writeFileSync(p, JSON.stringify({ version: 1, hashes }, null, 2) + '\n')
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Submit a site's emitted sync packages over both directional lanes, back-fill the
|
|
111
|
+
* minted uuids, and persist the send-only-changed cache. The HTTP + file-write-back
|
|
112
|
+
* half that `emitSyncPackages` (producer-pure) deliberately omits.
|
|
113
|
+
*
|
|
114
|
+
* @param {object} params
|
|
115
|
+
* @param {object} params.client - BackendClient (carries the origin + the lane methods)
|
|
116
|
+
* @param {string} params.siteDir - the site root (for $uuid write-back + the cache)
|
|
117
|
+
* @param {object} params.pkg - the `emitSyncPackages` result
|
|
118
|
+
* ({ siteContent, collections, siteContentUuid, hashes })
|
|
119
|
+
* @param {string|null} [params.asOrg] - act-as org (membership-gated), forwarded to each lane
|
|
120
|
+
* @param {{info,note,error,dim?:Function}} params.report - injected logging
|
|
121
|
+
* @returns {Promise<{ exitCode: number, boundSiteUuid?: string, finalizedTotal: number, wrote: string[] }>}
|
|
122
|
+
* exitCode 1 on any lane failure (already reported, cache NOT persisted); 0 on success.
|
|
123
|
+
*/
|
|
124
|
+
export async function pushSyncPackages({ client, siteDir, pkg, asOrg, report }) {
|
|
125
|
+
const { siteContent, collections, siteContentUuid, hashes } = pkg
|
|
126
|
+
const { info, note, error } = report
|
|
127
|
+
const dim = report.dim || ((s) => s)
|
|
128
|
+
|
|
129
|
+
const wrote = []
|
|
130
|
+
let finalizedTotal = 0
|
|
131
|
+
|
|
132
|
+
// POST one lane via the client and parse the JSON response. `doRequest` is a thunk
|
|
133
|
+
// returning the client's Response promise (so the "Pushing …" line prints before the
|
|
134
|
+
// request fires). The client carries `collision=force` (last-push-wins) + the optional
|
|
135
|
+
// `--as-org`. Returns the parsed payload, or null on any transport/HTTP/parse failure
|
|
136
|
+
// (already reported).
|
|
137
|
+
const postLane = async (label, doRequest) => {
|
|
138
|
+
info(`Pushing ${label} to ${dim(client.origin)} …`)
|
|
139
|
+
let res
|
|
140
|
+
try {
|
|
141
|
+
res = await doRequest()
|
|
142
|
+
} catch (err) {
|
|
143
|
+
error(`Could not reach the backend at ${client.origin}: ${err.message}`)
|
|
144
|
+
note('Set the origin with --registry <url> or UNIWEB_REGISTER_URL.')
|
|
145
|
+
return null
|
|
146
|
+
}
|
|
147
|
+
if (!res.ok) {
|
|
148
|
+
error(`${label} push rejected: HTTP ${res.status} ${res.statusText}`)
|
|
149
|
+
if (res.status === 401 || res.status === 403) {
|
|
150
|
+
note("Credentials weren't accepted — supply a bearer with --token <bearer> (or UNIWEB_TOKEN).")
|
|
151
|
+
} else if (res.status === 409) {
|
|
152
|
+
// The site's @uniweb/folder is genesis-owned: its structure is fixed on first
|
|
153
|
+
// deploy and not reconciled in place (the v1 rule — see gotcha #20's mode switch).
|
|
154
|
+
note(
|
|
155
|
+
"This site's collection structure is already established on the backend and can't be changed " +
|
|
156
|
+
'in place — e.g. adding or removing a schema-backed collection, or switching one between ' +
|
|
157
|
+
'static (data-bundle) and schema-backed delivery. To change it: delete the deployed site and ' +
|
|
158
|
+
'redeploy, or clear `$uuid` in site.yml to deploy a fresh one.'
|
|
159
|
+
)
|
|
160
|
+
}
|
|
161
|
+
const body = await res.text().catch(() => '')
|
|
162
|
+
if (body) note(body.slice(0, 800))
|
|
163
|
+
return null
|
|
164
|
+
}
|
|
165
|
+
try {
|
|
166
|
+
return await res.json()
|
|
167
|
+
} catch (err) {
|
|
168
|
+
error(`Could not parse the ${label} response as JSON: ${err.message}`)
|
|
169
|
+
return null
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// POST a lane that round-trips entity uuids (content UPDATE + the folder): parse the
|
|
174
|
+
// finalized list (for record back-fill + the changed summary). Returns the finalized
|
|
175
|
+
// array, or null on failure (already reported).
|
|
176
|
+
const pushLane = async (label, doRequest) => {
|
|
177
|
+
const payload = await postLane(label, doRequest)
|
|
178
|
+
if (payload === null) return null
|
|
179
|
+
const finalized = extractFinalized(payload)
|
|
180
|
+
if (!finalized) {
|
|
181
|
+
error(`The ${label} response carried no recognizable finalized list (expected report.finalized[] with index + uuid).`)
|
|
182
|
+
note(JSON.stringify(payload).slice(0, 800))
|
|
183
|
+
return null
|
|
184
|
+
}
|
|
185
|
+
const summary = changedSummary(finalized)
|
|
186
|
+
if (summary) note(`${label}: ${summary}`)
|
|
187
|
+
return finalized
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// Lane 1 — site-content (the site is born here; it must exist before its folder). A
|
|
191
|
+
// known site uuid → UPDATE by uuid; none → CREATE (the backend mints + adopts the site
|
|
192
|
+
// and returns its uuid, which we record into site.yml). `boundSiteUuid` carries the
|
|
193
|
+
// minted/known uuid forward to key the folder push.
|
|
194
|
+
let boundSiteUuid = siteContentUuid
|
|
195
|
+
if (siteContent) {
|
|
196
|
+
if (siteContentUuid) {
|
|
197
|
+
const finalized = await pushLane(
|
|
198
|
+
'site-content',
|
|
199
|
+
() => client.updateSiteContent(siteContentUuid, siteContent.buffer, { asOrg })
|
|
200
|
+
)
|
|
201
|
+
if (!finalized) return { exitCode: 1, finalizedTotal, wrote }
|
|
202
|
+
finalizedTotal += finalized.length
|
|
203
|
+
} else {
|
|
204
|
+
const payload = await postLane(
|
|
205
|
+
'site-content',
|
|
206
|
+
() => client.createSiteContent(siteContent.buffer, { asOrg })
|
|
207
|
+
)
|
|
208
|
+
if (payload === null) return { exitCode: 1, finalizedTotal, wrote }
|
|
209
|
+
const minted = extractMintedSiteUuid(payload)
|
|
210
|
+
if (!minted) {
|
|
211
|
+
error('The create response carried no minted site-content uuid — cannot record the site identity or push its folder.')
|
|
212
|
+
note(JSON.stringify(payload).slice(0, 800))
|
|
213
|
+
return { exitCode: 1, finalizedTotal, wrote }
|
|
214
|
+
}
|
|
215
|
+
writeSiteEntityUuid(siteDir, minted)
|
|
216
|
+
boundSiteUuid = minted
|
|
217
|
+
wrote.push('recorded site $uuid in site.yml')
|
|
218
|
+
finalizedTotal += extractFinalized(payload)?.length ?? 1
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
// Lane 2 — collections (the @uniweb/folder + the records it references), keyed by the
|
|
223
|
+
// site-content uuid. On a brand-new site the backend creates the folder on this first
|
|
224
|
+
// push. Records round-trip their own $uuid (back-filled into source files); the folder
|
|
225
|
+
// itself has no uuid (the backend owns it).
|
|
226
|
+
if (collections) {
|
|
227
|
+
if (!boundSiteUuid) {
|
|
228
|
+
error('Cannot push collections — the site has no uuid yet. Push the site-content lane first.')
|
|
229
|
+
return { exitCode: 1, finalizedTotal, wrote }
|
|
230
|
+
}
|
|
231
|
+
const finalized = await pushLane(
|
|
232
|
+
'collections',
|
|
233
|
+
() => client.pushFolder(boundSiteUuid, collections.buffer, { asOrg })
|
|
234
|
+
)
|
|
235
|
+
if (!finalized) return { exitCode: 1, finalizedTotal, wrote }
|
|
236
|
+
const bf = backfillEntityUuids({ index: collections.index, finalized })
|
|
237
|
+
for (const w of bf.warnings) note(`! ${w}`)
|
|
238
|
+
for (const d of bf.deferred) note(`↷ ${d.id ?? `#${d.index}`}: ${d.reason}`)
|
|
239
|
+
if (bf.updated.length) wrote.push(`wrote ${bf.updated.length} record file(s)`)
|
|
240
|
+
finalizedTotal += finalized.length
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
// Persist the full content-hash map so the next push skips unchanged entities.
|
|
244
|
+
writeSyncCache(siteDir, hashes)
|
|
245
|
+
return { exitCode: 0, boundSiteUuid, finalizedTotal, wrote }
|
|
246
|
+
}
|