uniweb 0.12.33 → 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 +5 -5
- package/partials/agents.md +2 -0
- 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/push.js +26 -212
- package/src/framework-index.json +3 -3
- 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": {
|
|
@@ -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/kit": "0.9.18",
|
|
45
44
|
"@uniweb/core": "0.7.14",
|
|
45
|
+
"@uniweb/kit": "0.9.18",
|
|
46
46
|
"@uniweb/runtime": "0.8.20"
|
|
47
47
|
},
|
|
48
48
|
"peerDependencies": {
|
|
49
|
-
"@uniweb/
|
|
50
|
-
"@uniweb/
|
|
51
|
-
"@uniweb/
|
|
49
|
+
"@uniweb/build": "0.14.17",
|
|
50
|
+
"@uniweb/content-reader": "1.1.12",
|
|
51
|
+
"@uniweb/semantic-parser": "1.1.17"
|
|
52
52
|
},
|
|
53
53
|
"peerDependenciesMeta": {
|
|
54
54
|
"@uniweb/build": {
|
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.
|
|
@@ -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
|
+
}
|
package/src/commands/deploy.js
CHANGED
|
@@ -16,19 +16,22 @@
|
|
|
16
16
|
* For static-host artifacts WITHOUT upload, see `uniweb export`. To publish a
|
|
17
17
|
* site that already lives on the backend as a synced draft, see `uniweb release`.
|
|
18
18
|
*
|
|
19
|
-
* Uniweb-host flow (deployToUniwebBackend)
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
29
|
-
*
|
|
30
|
-
*
|
|
31
|
-
*
|
|
19
|
+
* Uniweb-host flow (deployToUniwebBackend) — the composite deploy = build → ball →
|
|
20
|
+
* media → push → publish:
|
|
21
|
+
* 1. Resolve the site dir + deploy.yml target; discover() the backend (GET
|
|
22
|
+
* /dev/config) and resolve the runtime (`site.yml::runtime` if pinned, else the
|
|
23
|
+
* backend's highest installed; fail closed if neither resolves).
|
|
24
|
+
* 2. Build the site data (link mode): site-content.json (+ per-locale variants),
|
|
25
|
+
* collection data, search indexes, processed assets.
|
|
26
|
+
* 3. Partition collections by schema presence: schema-less → the static-data ball;
|
|
27
|
+
* schema-backed → typed folder entities on the push lane.
|
|
28
|
+
* 4. Upload the site's local media (entity refs + the ball's refs, one deduped set) →
|
|
29
|
+
* each site-root ref's backend serve URL; rewrite the ball with it, then upload the
|
|
30
|
+
* rewritten ball (content-addressed → `info.data_bundle`).
|
|
31
|
+
* 5. Push — the SAME two-lane sync `uniweb push` uses (site-content with
|
|
32
|
+
* `info.data_bundle` stamped + media refs rewritten, then the folder + records) —
|
|
33
|
+
* over the send-only-changed cache; the backend mints/round-trips the site uuid.
|
|
34
|
+
* 6. Publish — make the just-pushed composite live; the backend returns the serve URL.
|
|
32
35
|
*
|
|
33
36
|
* Usage:
|
|
34
37
|
* uniweb deploy Build + deploy to the resolved target
|
|
@@ -45,17 +48,20 @@
|
|
|
45
48
|
*/
|
|
46
49
|
|
|
47
50
|
import { existsSync } from 'node:fs'
|
|
48
|
-
import { readFile
|
|
49
|
-
import { resolve, join
|
|
51
|
+
import { readFile } from 'node:fs/promises'
|
|
52
|
+
import { resolve, join } from 'node:path'
|
|
50
53
|
import { execSync } from 'node:child_process'
|
|
51
54
|
import yaml from 'js-yaml'
|
|
52
55
|
|
|
53
|
-
import { loadDeployYml, resolveTarget, recordLastDeploy,
|
|
56
|
+
import { loadDeployYml, resolveTarget, recordLastDeploy, assembleDataBall, collectBallAssets, rewriteBallAssets } from '@uniweb/build/site'
|
|
54
57
|
import { promptForHost } from '../utils/host-prompt.js'
|
|
55
58
|
import { readFlagValue } from '../utils/args.js'
|
|
56
59
|
import { parseBoolEnv } from '../utils/env.js'
|
|
57
60
|
import { BackendClient } from '../backend/client.js'
|
|
58
|
-
import {
|
|
61
|
+
import { emitSyncPackages } from '@uniweb/build/uwx'
|
|
62
|
+
import { makeModelResolver, readSyncCache, pushSyncPackages } from '../backend/site-sync.js'
|
|
63
|
+
import { uploadDataBundle } from '../backend/data-bundle.js'
|
|
64
|
+
import { uploadSiteMedia } from '../backend/site-media.js'
|
|
59
65
|
|
|
60
66
|
import {
|
|
61
67
|
findWorkspaceRoot,
|
|
@@ -262,27 +268,20 @@ async function deployToUniwebBackend(siteDir, siteYml, { foundation, args, dryRu
|
|
|
262
268
|
command: 'Deploying',
|
|
263
269
|
})
|
|
264
270
|
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
// app-side edits. `deploy` still works: it hosts the LOCAL file-built payload as
|
|
268
|
-
// a separate delivery (deploy ⊥ publish — alternative lifecycles, your choice).
|
|
269
|
-
if (siteYml.$uuid) {
|
|
270
|
-
say.warn('This site is synced (site.yml::$uuid) — CMS-managed.')
|
|
271
|
-
say.dim('`uniweb release` publishes its current backend state; `deploy` hosts your local files as a separate delivery.')
|
|
272
|
-
}
|
|
271
|
+
const foundationDir = readFlagValue(args, '--foundation') // optional local foundation for Model schemas
|
|
272
|
+
const asOrg = readFlagValue(args, '--as-org')
|
|
273
273
|
|
|
274
|
-
// Anonymous capability handshake (cached).
|
|
275
|
-
//
|
|
276
|
-
// retired Worker /runtime/latest).
|
|
274
|
+
// Anonymous capability handshake (cached). The composite deploy ends in a publish,
|
|
275
|
+
// so confirm that lane is offered (the push/sync lanes are the backend's baseline).
|
|
277
276
|
const config = await client.discover()
|
|
278
|
-
if (config?.delivery && config.delivery.
|
|
279
|
-
say.err(`Backend at ${client.origin} does not offer the
|
|
277
|
+
if (config?.delivery && config.delivery.publish === false) {
|
|
278
|
+
say.err(`Backend at ${client.origin} does not offer the publish lane (delivery.publish=false).`)
|
|
280
279
|
process.exit(1)
|
|
281
280
|
}
|
|
282
281
|
|
|
283
282
|
// Runtime resolution: an explicit site.yml::runtime pin wins; else the highest
|
|
284
283
|
// version the backend reports installed; else fail closed with a clear
|
|
285
|
-
// precondition error (better than serving a site with no runtime
|
|
284
|
+
// precondition error (better than serving a site with no runtime).
|
|
286
285
|
const installed = Array.isArray(config?.runtime?.installed) ? config.runtime.installed : []
|
|
287
286
|
if (siteYml.runtime && installed.length && !installed.includes(siteYml.runtime)) {
|
|
288
287
|
say.err(`Runtime ${siteYml.runtime} (from site.yml) is not installed on the backend.`)
|
|
@@ -296,23 +295,18 @@ async function deployToUniwebBackend(siteDir, siteYml, { foundation, args, dryRu
|
|
|
296
295
|
process.exit(1)
|
|
297
296
|
}
|
|
298
297
|
|
|
299
|
-
// deploy.yml uuid round-trip: a prior deploy recorded the minted delivery uuid
|
|
300
|
-
// under lastDeploy.<target>.siteUuid; resend it so /gateway/site/{uuid}/ stays
|
|
301
|
-
// stable. First deploy has none → the backend mints one and we write it back.
|
|
302
|
-
const priorUuid = readDeployedSiteUuid(deployYml, resolved.targetName)
|
|
303
|
-
|
|
304
298
|
if (dryRun) {
|
|
305
|
-
say.info('Dry run — would deploy to the Uniweb backend:')
|
|
299
|
+
say.info('Dry run — would deploy to the Uniweb backend as a composite (ball → push → publish):')
|
|
306
300
|
say.dim(`Backend : ${client.origin}`)
|
|
307
301
|
say.dim(`Foundation : ${typeof foundation === 'string' ? foundation : foundation.ref}`)
|
|
308
302
|
say.dim(`Runtime : ${runtimeVersion}${siteYml.runtime ? '' : ' (highest installed)'}`)
|
|
309
|
-
say.dim(`site_uuid : ${
|
|
303
|
+
say.dim(`site_uuid : ${siteYml.$uuid || '(none — the first push mints it)'}`)
|
|
310
304
|
return
|
|
311
305
|
}
|
|
312
306
|
|
|
313
|
-
// Build (link mode): emits dist/
|
|
314
|
-
// dist
|
|
315
|
-
//
|
|
307
|
+
// Build (link mode): emits dist/data/*, dist/_search/*, dist/assets/*, and
|
|
308
|
+
// dist/site-content.json. Spawn the SAME CLI binary so the inner build can't resolve
|
|
309
|
+
// to a different installed version.
|
|
316
310
|
say.info('Building site…')
|
|
317
311
|
console.log('')
|
|
318
312
|
execSync(`node ${JSON.stringify(process.argv[1])} build --link`, {
|
|
@@ -329,105 +323,125 @@ async function deployToUniwebBackend(siteDir, siteYml, { foundation, args, dryRu
|
|
|
329
323
|
process.exit(1)
|
|
330
324
|
}
|
|
331
325
|
|
|
332
|
-
|
|
333
|
-
const
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
//
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
//
|
|
357
|
-
//
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
}
|
|
376
|
-
// Build localUrl → durable serve URL, then re-run the build's own rewrite
|
|
377
|
-
// over each locale's content (image nodes, marks, dataBlocks, param fields).
|
|
378
|
-
// assetBase comes from /dev/config (origin-relative in dev → prepend origin;
|
|
379
|
-
// absolute CDN in prod → used verbatim).
|
|
380
|
-
const urlMapping = {}
|
|
381
|
-
for (const [localUrl, { id, ext }] of Object.entries(assetResult.assetsByLocalUrl)) {
|
|
382
|
-
urlMapping[localUrl] = buildAssetUrl(client.origin, config.assetBase, id, ext)
|
|
383
|
-
}
|
|
384
|
-
for (const lang of Object.keys(localeContents)) {
|
|
385
|
-
localeContents[lang] = rewriteSiteContentPaths(localeContents[lang], urlMapping)
|
|
386
|
-
}
|
|
387
|
-
const skippedNote = assetResult.skipped?.length ? `, ${assetResult.skipped.length} already present` : ''
|
|
388
|
-
say.dim(`Assets : ${assetResult.uploaded.length} uploaded${skippedNote} (${assetResult.mode}) → ${config.assetBase}`)
|
|
326
|
+
// Non-local @std/registry Model schemas resolve through the backend (same as push).
|
|
327
|
+
const resolveModel = makeModelResolver({ client, offline: false })
|
|
328
|
+
|
|
329
|
+
// 1. Partition the collections by schema presence. A first emit reads `schemaless`
|
|
330
|
+
// — the collections with no data schema, delivered statically via the ball. Its
|
|
331
|
+
// packages are discarded (deploy is not a hot path; the cheap clarity beats a
|
|
332
|
+
// schemaless-only fast path, a later optimization).
|
|
333
|
+
let probe
|
|
334
|
+
try {
|
|
335
|
+
probe = await emitSyncPackages(siteDir, { ...(foundationDir ? { foundationDir } : {}), resolveModel })
|
|
336
|
+
} catch (err) {
|
|
337
|
+
say.err(`Could not build the sync package: ${err.message}`)
|
|
338
|
+
process.exit(1)
|
|
339
|
+
}
|
|
340
|
+
const schemalessNames = (probe.schemaless || []).map((col) => col.name)
|
|
341
|
+
const localAssets = probe.localAssets || [] // entity-content site-root media refs
|
|
342
|
+
|
|
343
|
+
// 2. Assemble the static-data ball (schema-less collection data + the search index) —
|
|
344
|
+
// BEFORE uploading it, because its schema-less records can carry local media too,
|
|
345
|
+
// which we upload + rewrite to serve URLs exactly like entity content (the backend
|
|
346
|
+
// serves a serve_url in the ball identically — it unwraps the ball verbatim).
|
|
347
|
+
let ball = await assembleDataBall(distDir, schemalessNames)
|
|
348
|
+
const ballAssets = collectBallAssets(ball)
|
|
349
|
+
|
|
350
|
+
// 2b. Upload ALL local media (entity refs + ball refs) on one asset lane → the
|
|
351
|
+
// ref→serveUrl map. The same map rewrites the entity content (assetRewrite, real
|
|
352
|
+
// emit below) AND the ball (here, before it's uploaded). Co-located refs were
|
|
353
|
+
// warned + skipped by the producer; a missing file is skipped here (warned).
|
|
354
|
+
let assetRewrite = null
|
|
355
|
+
const mediaRefs = [...new Set([...localAssets, ...ballAssets])]
|
|
356
|
+
if (mediaRefs.length) {
|
|
357
|
+
say.info('Uploading media…')
|
|
358
|
+
try {
|
|
359
|
+
const map = await uploadSiteMedia(client, siteDir, mediaRefs, {
|
|
360
|
+
onProgress: (m) => say.dim(` ${m}`),
|
|
361
|
+
warn: (m) => say.dim(`! ${m}`),
|
|
362
|
+
})
|
|
363
|
+
if (Object.keys(map).length) assetRewrite = map
|
|
364
|
+
if (ballAssets.length) ball = rewriteBallAssets(ball, map) // swap the ball's local refs → serve URLs
|
|
365
|
+
say.dim(`Media : ${Object.keys(map).length}/${mediaRefs.length} ref(s) → serve URL`)
|
|
366
|
+
} catch (err) {
|
|
367
|
+
say.err(`Media upload failed: ${err.message}`)
|
|
368
|
+
process.exit(1)
|
|
389
369
|
}
|
|
390
370
|
}
|
|
391
371
|
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
372
|
+
// 2c. Upload the (media-rewritten) ball. `data_bundle` is its content-addressed serve
|
|
373
|
+
// URL; omitted when there is nothing static to deliver.
|
|
374
|
+
let dataBundle
|
|
375
|
+
if (ball) {
|
|
376
|
+
say.info('Uploading data bundle…')
|
|
377
|
+
try {
|
|
378
|
+
dataBundle = await uploadDataBundle(client, ball, { onProgress: (m) => say.dim(` ${m}`) })
|
|
379
|
+
} catch (err) {
|
|
380
|
+
say.err(`Data bundle upload failed: ${err.message}`)
|
|
381
|
+
process.exit(1)
|
|
382
|
+
}
|
|
383
|
+
say.dim(`Data bundle : ${Object.keys(ball.data).length} data + ${Object.keys(ball.search).length} search file(s)`)
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
// 3. Push the site (content + folder) over the send-only-changed cache — the SAME
|
|
387
|
+
// two-lane submission `uniweb push` uses — stamping info.data_bundle on the
|
|
388
|
+
// site-content entity and rewriting local media refs to their backend serve URLs.
|
|
389
|
+
const priorHashes = readSyncCache(siteDir)
|
|
390
|
+
let pkg
|
|
391
|
+
try {
|
|
392
|
+
pkg = await emitSyncPackages(siteDir, {
|
|
393
|
+
...(foundationDir ? { foundationDir } : {}),
|
|
394
|
+
resolveModel,
|
|
395
|
+
priorHashes,
|
|
396
|
+
...(dataBundle ? { injectInfo: { data_bundle: dataBundle } } : {}),
|
|
397
|
+
...(assetRewrite ? { assetRewrite } : {}),
|
|
398
|
+
})
|
|
399
|
+
} catch (err) {
|
|
400
|
+
say.err(`Could not build the sync package: ${err.message}`)
|
|
401
|
+
process.exit(1)
|
|
402
|
+
}
|
|
403
|
+
for (const w of pkg.warnings) say.dim(`! ${w}`)
|
|
404
|
+
const report = {
|
|
405
|
+
info: (m) => say.info(m),
|
|
406
|
+
note: (m) => say.dim(m),
|
|
407
|
+
error: (m) => say.err(m),
|
|
408
|
+
dim: (s) => `${c.dim}${s}${c.reset}`,
|
|
409
|
+
}
|
|
410
|
+
const pushResult = await pushSyncPackages({ client, siteDir, pkg, asOrg, report })
|
|
411
|
+
if (pushResult.exitCode !== 0) process.exit(pushResult.exitCode)
|
|
412
|
+
const siteUuid = pushResult.boundSiteUuid
|
|
413
|
+
if (!siteUuid) {
|
|
414
|
+
say.err('Push did not yield a site uuid — cannot publish.')
|
|
415
|
+
process.exit(1)
|
|
403
416
|
}
|
|
404
417
|
|
|
405
|
-
|
|
406
|
-
|
|
418
|
+
// 4. Publish: make the just-pushed composite live (its current backend state).
|
|
419
|
+
const siteContent = JSON.parse(await readFile(contentPath, 'utf8'))
|
|
420
|
+
const languages = extractLanguages(siteContent)
|
|
421
|
+
say.info(`Publishing to ${c.dim}${client.origin}${c.reset} …`)
|
|
422
|
+
let pubRes
|
|
407
423
|
try {
|
|
408
|
-
|
|
424
|
+
pubRes = await client.publishSite(siteUuid, { runtimeVersion, ...(languages ? { languages } : {}) })
|
|
409
425
|
} catch (err) {
|
|
410
426
|
say.err(`Could not reach the backend at ${client.origin}: ${err.message}`)
|
|
411
427
|
say.dim('Set the origin with --backend <url> or UNIWEB_REGISTER_URL.')
|
|
412
428
|
process.exit(1)
|
|
413
429
|
}
|
|
414
|
-
if (!
|
|
415
|
-
say.err(`
|
|
416
|
-
if (
|
|
430
|
+
if (!pubRes.ok) {
|
|
431
|
+
say.err(`Publish rejected: HTTP ${pubRes.status} ${pubRes.statusText}`)
|
|
432
|
+
if (pubRes.status === 401 || pubRes.status === 403) {
|
|
417
433
|
say.dim("Credentials weren't accepted — run `uniweb login` (or pass --token <bearer>).")
|
|
418
434
|
}
|
|
419
|
-
const body = await
|
|
435
|
+
const body = await pubRes.text().catch(() => '')
|
|
420
436
|
if (body) say.dim(body.slice(0, 800))
|
|
421
437
|
process.exit(1)
|
|
422
438
|
}
|
|
423
439
|
let result
|
|
424
|
-
try { result = await
|
|
425
|
-
|
|
426
|
-
const mintedUuid = result.site_uuid || priorUuid || null
|
|
440
|
+
try { result = await pubRes.json() } catch { result = {} }
|
|
427
441
|
const serveUrl = absolutizeServeUrl(client.origin, result.url)
|
|
428
442
|
|
|
429
|
-
// Persist deploy memory
|
|
430
|
-
// touches only lastDeploy.<target
|
|
443
|
+
// Persist deploy memory. One identity: site.yml::$uuid (the push uuid) — no separate
|
|
444
|
+
// deploy uuid. recordLastDeploy touches only lastDeploy.<target>.
|
|
431
445
|
await persistLastDeploy(siteDir, {
|
|
432
446
|
targetName: resolved.targetName,
|
|
433
447
|
targetConfig: resolved.fromFile ? null : { host: 'uniweb' },
|
|
@@ -436,7 +450,7 @@ async function deployToUniwebBackend(siteDir, siteYml, { foundation, args, dryRu
|
|
|
436
450
|
at: new Date().toISOString(),
|
|
437
451
|
host: 'uniweb',
|
|
438
452
|
backend: client.origin,
|
|
439
|
-
siteUuid
|
|
453
|
+
siteUuid,
|
|
440
454
|
url: serveUrl,
|
|
441
455
|
foundation: { ref: typeof foundation === 'string' ? foundation : foundation?.ref },
|
|
442
456
|
runtime: runtimeVersion,
|
|
@@ -445,7 +459,7 @@ async function deployToUniwebBackend(siteDir, siteYml, { foundation, args, dryRu
|
|
|
445
459
|
})
|
|
446
460
|
|
|
447
461
|
console.log('')
|
|
448
|
-
say.ok(`Deployed ${c.bold}${
|
|
462
|
+
say.ok(`Deployed ${c.bold}${siteUuid}${c.reset}`)
|
|
449
463
|
if (serveUrl) console.log(` ${c.cyan}${serveUrl}${c.reset}`)
|
|
450
464
|
}
|
|
451
465
|
|
|
@@ -457,12 +471,6 @@ function pickHighestRuntime(installed) {
|
|
|
457
471
|
return [...installed].sort((a, b) => String(b).localeCompare(String(a), undefined, { numeric: true }))[0]
|
|
458
472
|
}
|
|
459
473
|
|
|
460
|
-
// The previously-minted delivery uuid for `targetName` (lastDeploy.<target>.siteUuid
|
|
461
|
-
// in a loaded deploy.yml), or null on a first deploy / absent file.
|
|
462
|
-
function readDeployedSiteUuid(deployYml, targetName) {
|
|
463
|
-
return deployYml?.lastDeploy?.[targetName]?.siteUuid || null
|
|
464
|
-
}
|
|
465
|
-
|
|
466
474
|
// The deploy response `url` is the serve path. When origin-relative (the self-serve
|
|
467
475
|
// default, e.g. /gateway/site/<uuid>/) prefix the BackendClient origin so the printed
|
|
468
476
|
// link is clickable; absolute URLs pass through unchanged.
|
|
@@ -472,15 +480,6 @@ function absolutizeServeUrl(origin, url) {
|
|
|
472
480
|
return `${origin.replace(/\/$/, '')}${url.startsWith('/') ? '' : '/'}${url}`
|
|
473
481
|
}
|
|
474
482
|
|
|
475
|
-
// Build a durable asset serve URL from /dev/config's assetBase. Origin-relative
|
|
476
|
-
// (`/gateway/asset/` in dev) → prepend the backend origin; absolute (a prod CDN)
|
|
477
|
-
// → used verbatim. Shape: {assetBase}dist/{id}/base.{ext} — basename literally
|
|
478
|
-
// `base`, {ext} the source extension the plan echoed.
|
|
479
|
-
function buildAssetUrl(origin, assetBase, id, ext) {
|
|
480
|
-
const base = /^https?:\/\//.test(assetBase) ? assetBase : `${origin}${assetBase}`
|
|
481
|
-
return `${base.replace(/\/$/, '')}/dist/${id}/base.${ext}`
|
|
482
|
-
}
|
|
483
|
-
|
|
484
483
|
// ─── Static-host deploy (S3+CloudFront, etc.) ─────────────────
|
|
485
484
|
//
|
|
486
485
|
// Distinct from the uniweb-edge flow above. Picked when the resolved
|
|
@@ -679,66 +678,3 @@ function extractLanguages(siteContent) {
|
|
|
679
678
|
// Three accepted shapes: plain `'en'`, Editor `{ value, label }`, site.yml `{ code, label }`.
|
|
680
679
|
return langs.map((l) => (typeof l === 'string' ? l : l?.value || l?.code)).filter(Boolean)
|
|
681
680
|
}
|
|
682
|
-
|
|
683
|
-
// Collect compiled collection JSON files from dist/data/ recursively.
|
|
684
|
-
// Returns `{ '<relPath>': '<utf8-content>' }` keyed by the path under data/
|
|
685
|
-
// so the worker can write each to `${sitePrefix}/data/<relPath>` in R2.
|
|
686
|
-
// Empty object when the site has no `collection:` data sources.
|
|
687
|
-
async function collectDataFiles(distDir) {
|
|
688
|
-
const dataDir = join(distDir, 'data')
|
|
689
|
-
if (!existsSync(dataDir)) return {}
|
|
690
|
-
const files = {}
|
|
691
|
-
const entries = await readdir(dataDir, { withFileTypes: true, recursive: true })
|
|
692
|
-
for (const entry of entries) {
|
|
693
|
-
if (!entry.isFile()) continue
|
|
694
|
-
if (!entry.name.endsWith('.json')) continue
|
|
695
|
-
const fullPath = join(entry.parentPath || entry.path, entry.name)
|
|
696
|
-
const relPath = relative(dataDir, fullPath)
|
|
697
|
-
files[relPath] = await readFile(fullPath, 'utf8')
|
|
698
|
-
}
|
|
699
|
-
return files
|
|
700
|
-
}
|
|
701
|
-
|
|
702
|
-
// Collect search index files from dist/_search/ recursively.
|
|
703
|
-
// Returns `{ '<locale>/<name>.json': '<utf8-content>' }` so the worker can
|
|
704
|
-
// write each to `${sitePrefix}/_search/<key>` in R2, gated by searchEnabled.
|
|
705
|
-
// Empty object when the build emitted no search indexes.
|
|
706
|
-
async function collectSearchFiles(distDir) {
|
|
707
|
-
const searchDir = join(distDir, '_search')
|
|
708
|
-
if (!existsSync(searchDir)) return {}
|
|
709
|
-
const files = {}
|
|
710
|
-
const entries = await readdir(searchDir, { withFileTypes: true, recursive: true })
|
|
711
|
-
for (const entry of entries) {
|
|
712
|
-
if (!entry.isFile()) continue
|
|
713
|
-
if (!entry.name.endsWith('.json')) continue
|
|
714
|
-
const fullPath = join(entry.parentPath || entry.path, entry.name)
|
|
715
|
-
const relPath = relative(searchDir, fullPath)
|
|
716
|
-
files[relPath] = await readFile(fullPath, 'utf8')
|
|
717
|
-
}
|
|
718
|
-
return files
|
|
719
|
-
}
|
|
720
|
-
|
|
721
|
-
/**
|
|
722
|
-
* Resolve theme config.
|
|
723
|
-
*
|
|
724
|
-
* The build pipeline does not (today) emit a separate theme.json, so we read
|
|
725
|
-
* the developer-authored theme.yml from the site root. The Worker's
|
|
726
|
-
* `buildTheme()` tolerates an empty config — sites with no theme.yml still
|
|
727
|
-
* publish, they just get default tokens.
|
|
728
|
-
*/
|
|
729
|
-
async function readTheme(siteDir, siteContent) {
|
|
730
|
-
const themePath = join(siteDir, 'theme.yml')
|
|
731
|
-
if (existsSync(themePath)) {
|
|
732
|
-
try {
|
|
733
|
-
const parsed = yaml.load(await readFile(themePath, 'utf8'))
|
|
734
|
-
if (parsed && typeof parsed === 'object') return parsed
|
|
735
|
-
} catch {
|
|
736
|
-
// fall through to site-content.json fallback
|
|
737
|
-
}
|
|
738
|
-
}
|
|
739
|
-
// site-content sometimes carries a `theme` key produced by collectors.
|
|
740
|
-
if (siteContent?.theme && typeof siteContent.theme === 'object') {
|
|
741
|
-
return siteContent.theme
|
|
742
|
-
}
|
|
743
|
-
return {}
|
|
744
|
-
}
|
package/src/commands/push.js
CHANGED
|
@@ -34,17 +34,23 @@
|
|
|
34
34
|
* Backend: via BackendClient (the content + folder sync lanes). Origin from
|
|
35
35
|
* --registry > UNIWEB_REGISTER_URL > the local default.
|
|
36
36
|
* Auth: --token > UNIWEB_TOKEN > `uniweb login` session.
|
|
37
|
+
*
|
|
38
|
+
* The two-lane SUBMISSION (POST both lanes, back-fill uuids, persist the
|
|
39
|
+
* send-only-changed cache) lives in `../backend/site-sync.js` so `uniweb deploy`
|
|
40
|
+
* (the composite path) reuses the exact same logic. This command owns flag parsing,
|
|
41
|
+
* the emit, and the `-o`/`--dry-run` preview.
|
|
37
42
|
*/
|
|
38
43
|
|
|
39
|
-
import { writeFileSync
|
|
40
|
-
import { resolve
|
|
41
|
-
import {
|
|
42
|
-
emitSyncPackages,
|
|
43
|
-
backfillEntityUuids,
|
|
44
|
-
writeSiteEntityUuid,
|
|
45
|
-
} from '@uniweb/build/uwx'
|
|
44
|
+
import { writeFileSync } from 'node:fs'
|
|
45
|
+
import { resolve } from 'node:path'
|
|
46
|
+
import { emitSyncPackages } from '@uniweb/build/uwx'
|
|
46
47
|
import { BackendClient } from '../backend/client.js'
|
|
47
48
|
import { resolveSiteDir } from './deploy.js'
|
|
49
|
+
import { makeModelResolver, readSyncCache, pushSyncPackages } from '../backend/site-sync.js'
|
|
50
|
+
|
|
51
|
+
// Re-exported for downstream importers (pull.js, push.test.js) that read these
|
|
52
|
+
// helpers from this module — their canonical home is now ../backend/site-sync.js.
|
|
53
|
+
export { extractMintedSiteUuid, makeModelResolver } from '../backend/site-sync.js'
|
|
48
54
|
|
|
49
55
|
const colors = {
|
|
50
56
|
reset: '\x1b[0m', bright: '\x1b[1m', dim: '\x1b[2m',
|
|
@@ -64,101 +70,6 @@ function flagValue(args, name) {
|
|
|
64
70
|
return null
|
|
65
71
|
}
|
|
66
72
|
|
|
67
|
-
// Pull the finalized entities out of the restore response. The backend returns
|
|
68
|
-
// `{ report: { finalized: [ { index, uuid, changed, document }, … ] } }` — each
|
|
69
|
-
// entry carries its position in the SUBMITTED sequence (`index`, the correlation
|
|
70
|
-
// key — `$id` is not echoed), the minted entity `uuid`, a `changed` flag, and the
|
|
71
|
-
// full `document` (verbatim stored content with every `$uuid` filled in). A couple
|
|
72
|
-
// of shapes are tolerated; only entries with a valid `index` + `uuid` are usable.
|
|
73
|
-
function extractFinalized(payload) {
|
|
74
|
-
const list = Array.isArray(payload?.report?.finalized)
|
|
75
|
-
? payload.report.finalized
|
|
76
|
-
: Array.isArray(payload?.finalized)
|
|
77
|
-
? payload.finalized
|
|
78
|
-
: Array.isArray(payload)
|
|
79
|
-
? payload
|
|
80
|
-
: null
|
|
81
|
-
if (!list) return null
|
|
82
|
-
return list
|
|
83
|
-
.map((d) => ({
|
|
84
|
-
index: d?.index,
|
|
85
|
-
uuid: d?.uuid ?? d?.document?.$uuid ?? null,
|
|
86
|
-
changed: d?.changed,
|
|
87
|
-
document: d?.document ?? null,
|
|
88
|
-
}))
|
|
89
|
-
.filter((e) => Number.isInteger(e.index) && e.uuid)
|
|
90
|
-
}
|
|
91
|
-
|
|
92
|
-
// Pull the minted site-content uuid out of a CREATE
|
|
93
|
-
// response. The exact shape is an open backend item — tolerant of a bare
|
|
94
|
-
// `{ siteContentUuid }` / `{ $uuid }` / `{ uuid }`, or the same `report.finalized[]`
|
|
95
|
-
// envelope the update/folder lanes return (the site entity is submitted alone, so its
|
|
96
|
-
// minted uuid is the first finalized entry). Returns null if none is present. (Single
|
|
97
|
-
// adjust-point to pin at the first live CREATE.)
|
|
98
|
-
export function extractMintedSiteUuid(payload) {
|
|
99
|
-
if (typeof payload?.siteContentUuid === 'string') return payload.siteContentUuid
|
|
100
|
-
if (typeof payload?.$uuid === 'string') return payload.$uuid
|
|
101
|
-
if (typeof payload?.uuid === 'string') return payload.uuid
|
|
102
|
-
const finalized = extractFinalized(payload)
|
|
103
|
-
if (finalized && finalized.length) {
|
|
104
|
-
const site = finalized.find((f) => f.index === 0) || finalized[0]
|
|
105
|
-
return site?.uuid ?? null
|
|
106
|
-
}
|
|
107
|
-
return null
|
|
108
|
-
}
|
|
109
|
-
|
|
110
|
-
// One-line summary from the authoritative per-entity `changed` flag (`false` = a
|
|
111
|
-
// true no-op). Falls back silently when the backend omits it.
|
|
112
|
-
function changedSummary(finalized) {
|
|
113
|
-
const changed = finalized.filter((f) => f.changed === true).length
|
|
114
|
-
const unchanged = finalized.filter((f) => f.changed === false).length
|
|
115
|
-
const parts = []
|
|
116
|
-
if (changed) parts.push(`${changed} changed`)
|
|
117
|
-
if (unchanged) parts.push(`${unchanged} unchanged`)
|
|
118
|
-
return parts.length ? parts.join(', ') : null
|
|
119
|
-
}
|
|
120
|
-
|
|
121
|
-
// Resolve a Model NOT defined by the local foundation by reading its declaration
|
|
122
|
-
// (the `@uniweb/data-schema` form) from the backend via the client. Cached per run;
|
|
123
|
-
// HTTP 404 → null (the emitter then says "register it first"). The bearer is acquired
|
|
124
|
-
// lazily by the client, so a fully-local sync never authenticates.
|
|
125
|
-
//
|
|
126
|
-
// `offline` (set for `-o` / `--dry-run`) forces every non-local Model to null WITHOUT
|
|
127
|
-
// touching the backend — an offline emit must never authenticate. The collections
|
|
128
|
-
// emitter then soft-skips a convention-defaulted schema with a warning ("not synced")
|
|
129
|
-
// and still emits the site-content lane; an EXPLICIT non-local schema surfaces as a
|
|
130
|
-
// clear "could not be resolved" error rather than an auth prompt.
|
|
131
|
-
export function makeModelResolver({ client, offline = false }) {
|
|
132
|
-
const cache = new Map()
|
|
133
|
-
return async (modelName) => {
|
|
134
|
-
if (cache.has(modelName)) return cache.get(modelName)
|
|
135
|
-
const decl = offline ? null : await client.readDataSchema(modelName)
|
|
136
|
-
cache.set(modelName, decl)
|
|
137
|
-
return decl
|
|
138
|
-
}
|
|
139
|
-
}
|
|
140
|
-
|
|
141
|
-
// "Send only changed" cache: content hashes from the last successful sync, keyed
|
|
142
|
-
// `<model> <id>`. Gitignored, per-clone, deletable (a deleted cache just means one
|
|
143
|
-
// full re-sync, which the backend then no-ops). NOT identity — the minted `$uuid`
|
|
144
|
-
// lives in the source files; this is a pure wire-efficiency cache.
|
|
145
|
-
function syncCachePath(siteDir) {
|
|
146
|
-
return join(siteDir, '.uniweb', 'sync-cache.json')
|
|
147
|
-
}
|
|
148
|
-
function readSyncCache(siteDir) {
|
|
149
|
-
try {
|
|
150
|
-
const obj = JSON.parse(readFileSync(syncCachePath(siteDir), 'utf8'))
|
|
151
|
-
return obj && typeof obj.hashes === 'object' && obj.hashes ? obj.hashes : {}
|
|
152
|
-
} catch {
|
|
153
|
-
return {} // missing / unreadable → treat everything as changed
|
|
154
|
-
}
|
|
155
|
-
}
|
|
156
|
-
function writeSyncCache(siteDir, hashes) {
|
|
157
|
-
const p = syncCachePath(siteDir)
|
|
158
|
-
mkdirSync(dirname(p), { recursive: true })
|
|
159
|
-
writeFileSync(p, JSON.stringify({ version: 1, hashes }, null, 2) + '\n')
|
|
160
|
-
}
|
|
161
|
-
|
|
162
73
|
export async function push(args = []) {
|
|
163
74
|
const dryRun = args.includes('--dry-run')
|
|
164
75
|
const output = flagValue(args, '-o') || flagValue(args, '--output')
|
|
@@ -197,7 +108,7 @@ export async function push(args = []) {
|
|
|
197
108
|
error(`Could not build the sync package: ${err.message}`)
|
|
198
109
|
return { exitCode: 2 }
|
|
199
110
|
}
|
|
200
|
-
const { siteContent, collections, siteContentUuid, warnings,
|
|
111
|
+
const { siteContent, collections, siteContentUuid, warnings, skipped } = pkg
|
|
201
112
|
log('')
|
|
202
113
|
for (const w of warnings) note(`! ${w}`)
|
|
203
114
|
|
|
@@ -235,116 +146,19 @@ export async function push(args = []) {
|
|
|
235
146
|
return { exitCode: 0 }
|
|
236
147
|
}
|
|
237
148
|
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
let res
|
|
249
|
-
try {
|
|
250
|
-
res = await doRequest()
|
|
251
|
-
} catch (err) {
|
|
252
|
-
error(`Could not reach the backend at ${client.origin}: ${err.message}`)
|
|
253
|
-
note('Set the origin with --registry <url> or UNIWEB_REGISTER_URL.')
|
|
254
|
-
return null
|
|
255
|
-
}
|
|
256
|
-
if (!res.ok) {
|
|
257
|
-
error(`${label} push rejected: HTTP ${res.status} ${res.statusText}`)
|
|
258
|
-
if (res.status === 401 || res.status === 403) {
|
|
259
|
-
note("Credentials weren't accepted — supply a bearer with --token <bearer> (or UNIWEB_TOKEN).")
|
|
260
|
-
}
|
|
261
|
-
const body = await res.text().catch(() => '')
|
|
262
|
-
if (body) note(body.slice(0, 800))
|
|
263
|
-
return null
|
|
264
|
-
}
|
|
265
|
-
try {
|
|
266
|
-
return await res.json()
|
|
267
|
-
} catch (err) {
|
|
268
|
-
error(`Could not parse the ${label} response as JSON: ${err.message}`)
|
|
269
|
-
return null
|
|
270
|
-
}
|
|
271
|
-
}
|
|
272
|
-
|
|
273
|
-
// POST a lane that round-trips entity uuids (content UPDATE + the folder): parse the
|
|
274
|
-
// finalized list (for record back-fill + the changed summary). Returns the finalized
|
|
275
|
-
// array, or null on failure (already reported).
|
|
276
|
-
const pushLane = async (label, doRequest) => {
|
|
277
|
-
const payload = await postLane(label, doRequest)
|
|
278
|
-
if (payload === null) return null
|
|
279
|
-
const finalized = extractFinalized(payload)
|
|
280
|
-
if (!finalized) {
|
|
281
|
-
error(`The ${label} response carried no recognizable finalized list (expected report.finalized[] with index + uuid).`)
|
|
282
|
-
note(JSON.stringify(payload).slice(0, 800))
|
|
283
|
-
return null
|
|
284
|
-
}
|
|
285
|
-
const summary = changedSummary(finalized)
|
|
286
|
-
if (summary) note(`${label}: ${summary}`)
|
|
287
|
-
return finalized
|
|
288
|
-
}
|
|
289
|
-
|
|
290
|
-
// Lane 1 — site-content (the site is born here; it must exist before its folder). A
|
|
291
|
-
// known site uuid → UPDATE by uuid; none → CREATE (the backend mints + adopts the
|
|
292
|
-
// site and returns its uuid, which we record into site.yml). `boundSiteUuid` carries
|
|
293
|
-
// the minted/known uuid forward to key the folder push.
|
|
294
|
-
let boundSiteUuid = siteContentUuid
|
|
295
|
-
if (siteContent) {
|
|
296
|
-
if (siteContentUuid) {
|
|
297
|
-
const finalized = await pushLane(
|
|
298
|
-
'site-content',
|
|
299
|
-
() => client.updateSiteContent(siteContentUuid, siteContent.buffer, { asOrg })
|
|
300
|
-
)
|
|
301
|
-
if (!finalized) return { exitCode: 1 }
|
|
302
|
-
finalizedTotal += finalized.length
|
|
303
|
-
} else {
|
|
304
|
-
const payload = await postLane(
|
|
305
|
-
'site-content',
|
|
306
|
-
() => client.createSiteContent(siteContent.buffer, { asOrg })
|
|
307
|
-
)
|
|
308
|
-
if (payload === null) return { exitCode: 1 }
|
|
309
|
-
const minted = extractMintedSiteUuid(payload)
|
|
310
|
-
if (!minted) {
|
|
311
|
-
error('The create response carried no minted site-content uuid — cannot record the site identity or push its folder.')
|
|
312
|
-
note(JSON.stringify(payload).slice(0, 800))
|
|
313
|
-
return { exitCode: 1 }
|
|
314
|
-
}
|
|
315
|
-
writeSiteEntityUuid(siteDir, minted)
|
|
316
|
-
boundSiteUuid = minted
|
|
317
|
-
wrote.push('recorded site $uuid in site.yml')
|
|
318
|
-
finalizedTotal += extractFinalized(payload)?.length ?? 1
|
|
319
|
-
}
|
|
320
|
-
}
|
|
321
|
-
|
|
322
|
-
// Lane 2 — collections (the @uniweb/folder + the records it references), keyed by the
|
|
323
|
-
// site-content uuid. On a brand-new site the backend creates the folder on this first
|
|
324
|
-
// push. Records round-trip their own $uuid (back-filled into source files); the folder
|
|
325
|
-
// itself has no uuid (the backend owns it).
|
|
326
|
-
if (collections) {
|
|
327
|
-
if (!boundSiteUuid) {
|
|
328
|
-
error('Cannot push collections — the site has no uuid yet. Push the site-content lane first.')
|
|
329
|
-
return { exitCode: 1 }
|
|
330
|
-
}
|
|
331
|
-
const finalized = await pushLane(
|
|
332
|
-
'collections',
|
|
333
|
-
() => client.pushFolder(boundSiteUuid, collections.buffer, { asOrg })
|
|
334
|
-
)
|
|
335
|
-
if (!finalized) return { exitCode: 1 }
|
|
336
|
-
const bf = backfillEntityUuids({ index: collections.index, finalized })
|
|
337
|
-
for (const w of bf.warnings) note(`! ${w}`)
|
|
338
|
-
for (const d of bf.deferred) note(`↷ ${d.id ?? `#${d.index}`}: ${d.reason}`)
|
|
339
|
-
if (bf.updated.length) wrote.push(`wrote ${bf.updated.length} record file(s)`)
|
|
340
|
-
finalizedTotal += finalized.length
|
|
341
|
-
}
|
|
342
|
-
|
|
343
|
-
// Persist the full content-hash map so the next push skips unchanged entities.
|
|
344
|
-
writeSyncCache(siteDir, hashes)
|
|
149
|
+
// Submit both lanes, back-fill the minted uuids, and persist the send-only-changed
|
|
150
|
+
// cache. Shared with `uniweb deploy` via ../backend/site-sync.js.
|
|
151
|
+
const result = await pushSyncPackages({
|
|
152
|
+
client,
|
|
153
|
+
siteDir,
|
|
154
|
+
pkg,
|
|
155
|
+
asOrg,
|
|
156
|
+
report: { info, note, error, dim: (s) => `${colors.dim}${s}${colors.reset}` },
|
|
157
|
+
})
|
|
158
|
+
if (result.exitCode !== 0) return { exitCode: result.exitCode }
|
|
345
159
|
success(
|
|
346
|
-
`Pushed ${finalizedTotal} entit${finalizedTotal === 1 ? 'y' : 'ies'}` +
|
|
347
|
-
(wrote.length ? ` — ${wrote.join(', ')}` : '')
|
|
160
|
+
`Pushed ${result.finalizedTotal} entit${result.finalizedTotal === 1 ? 'y' : 'ies'}` +
|
|
161
|
+
(result.wrote.length ? ` — ${result.wrote.join(', ')}` : '')
|
|
348
162
|
)
|
|
349
163
|
return { exitCode: 0 }
|
|
350
164
|
}
|
package/src/framework-index.json
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
{
|
|
2
2
|
"schemaVersion": 1,
|
|
3
|
-
"generatedAt": "2026-06-
|
|
3
|
+
"generatedAt": "2026-06-23T17:13:47.118Z",
|
|
4
4
|
"packages": {
|
|
5
5
|
"@uniweb/build": {
|
|
6
|
-
"version": "0.14.
|
|
6
|
+
"version": "0.14.17",
|
|
7
7
|
"path": "framework/build",
|
|
8
8
|
"deps": [
|
|
9
9
|
"@uniweb/content-reader",
|
|
@@ -99,7 +99,7 @@
|
|
|
99
99
|
"deps": []
|
|
100
100
|
},
|
|
101
101
|
"@uniweb/unipress": {
|
|
102
|
-
"version": "0.4.
|
|
102
|
+
"version": "0.4.23",
|
|
103
103
|
"path": "framework/unipress",
|
|
104
104
|
"deps": [
|
|
105
105
|
"@uniweb/build",
|
|
@@ -73,10 +73,15 @@ export function collectSiteAssets(distDir) {
|
|
|
73
73
|
|
|
74
74
|
/**
|
|
75
75
|
* Deliver a site's assets: plan, then PUT each NEW file. Returns the rewrite map
|
|
76
|
-
* (`localUrl → { id, ext }`) — populated for files that uploaded
|
|
77
|
-
* that the plan reports already `present`, so a partial failure
|
|
78
|
-
* broken serve URL into content. Throws only on a plan-level
|
|
79
|
-
* PUT failures surface in `failed`.
|
|
76
|
+
* (`localUrl → { id, ext, serveUrl }`) — populated for files that uploaded
|
|
77
|
+
* successfully OR that the plan reports already `present`, so a partial failure
|
|
78
|
+
* never injects a broken serve URL into content. Throws only on a plan-level
|
|
79
|
+
* failure; per-file PUT failures surface in `failed`.
|
|
80
|
+
*
|
|
81
|
+
* `serveUrl` is the backend's canonical, ready-built serve URL for the asset (the
|
|
82
|
+
* plan entry's `serve_url`; content-addressed, lane-independent). Callers embed it
|
|
83
|
+
* verbatim; `buildAssetUrl(origin, assetBase, id, ext)` reconstructs the same
|
|
84
|
+
* string and stays as a fallback for an older backend that omits `serve_url`.
|
|
80
85
|
*
|
|
81
86
|
* Skip-list (content-addressed dedup): a plan entry with `present: true` is
|
|
82
87
|
* already in the global store — we don't re-PUT it, but we DO record its id+ext
|
|
@@ -89,7 +94,7 @@ export function collectSiteAssets(distDir) {
|
|
|
89
94
|
* @param {string} opts.distDir - the site's built dist/ directory
|
|
90
95
|
* @param {Array} [opts.files] - pre-collected list (default: collectSiteAssets)
|
|
91
96
|
* @param {(msg: string) => void} [opts.onProgress]
|
|
92
|
-
* @returns {Promise<{ mode: string, uploaded: string[], skipped: string[], failed: Array<{path, status, detail}>, assetsByLocalUrl: Record<string, { id: string, ext: string }> }>}
|
|
97
|
+
* @returns {Promise<{ mode: string, uploaded: string[], skipped: string[], failed: Array<{path, status, detail}>, assetsByLocalUrl: Record<string, { id: string, ext: string, serveUrl?: string }> }>}
|
|
93
98
|
*/
|
|
94
99
|
export async function uploadSiteAssets({ apiBase, token, distDir, files, onProgress = () => {} }) {
|
|
95
100
|
const list = files || collectSiteAssets(distDir)
|
|
@@ -130,7 +135,7 @@ export async function uploadSiteAssets({ apiBase, token, distDir, files, onProgr
|
|
|
130
135
|
// ⇒ false (older backend) → falls through to the upload path below.
|
|
131
136
|
if (up.present) {
|
|
132
137
|
skipped.push(src.path)
|
|
133
|
-
assetsByLocalUrl[src.localUrl] = { id: up.id, ext: String(up.ext || '').replace(/^\./, '') }
|
|
138
|
+
assetsByLocalUrl[src.localUrl] = { id: up.id, ext: String(up.ext || '').replace(/^\./, ''), serveUrl: up.serve_url }
|
|
134
139
|
continue
|
|
135
140
|
}
|
|
136
141
|
|
|
@@ -144,7 +149,7 @@ export async function uploadSiteAssets({ apiBase, token, distDir, files, onProgr
|
|
|
144
149
|
try {
|
|
145
150
|
// The plan's url may be origin-relative (direct mode → uniwebd) or
|
|
146
151
|
// absolute (presigned → storage); new URL() resolves both.
|
|
147
|
-
putRes = await fetch(new URL(up.url, origin), { method: up.method || 'PUT', headers, body: readFileSync(src.diskPath) })
|
|
152
|
+
putRes = await fetch(new URL(up.url, origin), { method: up.method || 'PUT', headers, body: src.bytes ?? readFileSync(src.diskPath) })
|
|
148
153
|
} catch (err) {
|
|
149
154
|
failed.push({ path: src.path, status: 0, detail: err.message })
|
|
150
155
|
continue
|
|
@@ -152,7 +157,7 @@ export async function uploadSiteAssets({ apiBase, token, distDir, files, onProgr
|
|
|
152
157
|
if (putRes.ok) {
|
|
153
158
|
uploaded.push(src.path)
|
|
154
159
|
// Authoritative id + ext from the plan; mapped only on a successful PUT.
|
|
155
|
-
assetsByLocalUrl[src.localUrl] = { id: up.id, ext: String(up.ext || '').replace(/^\./, '') }
|
|
160
|
+
assetsByLocalUrl[src.localUrl] = { id: up.id, ext: String(up.ext || '').replace(/^\./, ''), serveUrl: up.serve_url }
|
|
156
161
|
} else {
|
|
157
162
|
failed.push({ path: src.path, status: putRes.status, detail: await putRes.text().catch(() => '') })
|
|
158
163
|
}
|
|
@@ -160,3 +165,12 @@ export async function uploadSiteAssets({ apiBase, token, distDir, files, onProgr
|
|
|
160
165
|
|
|
161
166
|
return { mode, uploaded, skipped, failed, assetsByLocalUrl }
|
|
162
167
|
}
|
|
168
|
+
|
|
169
|
+
// Build a durable asset serve URL from /dev/config's assetBase. Origin-relative
|
|
170
|
+
// (`/gateway/asset/` in dev) → prepend the backend origin; absolute (a prod CDN)
|
|
171
|
+
// → used verbatim. Shape: {assetBase}dist/{id}/base.{ext} — basename literally
|
|
172
|
+
// `base`, {ext} the source extension the plan echoed.
|
|
173
|
+
export function buildAssetUrl(origin, assetBase, id, ext) {
|
|
174
|
+
const base = /^https?:\/\//.test(assetBase) ? assetBase : `${origin}${assetBase}`
|
|
175
|
+
return `${base.replace(/\/$/, '')}/dist/${id}/base.${ext}`
|
|
176
|
+
}
|