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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "uniweb",
3
- "version": "0.12.32",
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.19"
46
+ "@uniweb/runtime": "0.8.20"
47
47
  },
48
48
  "peerDependencies": {
49
- "@uniweb/build": "0.14.15",
49
+ "@uniweb/build": "0.14.17",
50
50
  "@uniweb/content-reader": "1.1.12",
51
51
  "@uniweb/semantic-parser": "1.1.17"
52
52
  },
@@ -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.
@@ -240,14 +240,21 @@ export class BackendClient {
240
240
  })
241
241
  }
242
242
 
243
- /** GET /dev/site/content/pull/{uuid} — the content lane document. */
244
- async pullSiteContent(uuid) {
245
- return this.request(`/dev/site/content/pull/${encodeURIComponent(uuid)}`)
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
+ }