uniweb 0.12.32 → 0.12.33

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.33",
4
4
  "description": "Create structured Vite + React sites with content/code separation",
5
5
  "type": "module",
6
6
  "bin": {
@@ -41,14 +41,14 @@
41
41
  "js-yaml": "^4.1.0",
42
42
  "prompts": "^2.4.2",
43
43
  "tar": "^7.0.0",
44
- "@uniweb/core": "0.7.14",
45
44
  "@uniweb/kit": "0.9.18",
46
- "@uniweb/runtime": "0.8.19"
45
+ "@uniweb/core": "0.7.14",
46
+ "@uniweb/runtime": "0.8.20"
47
47
  },
48
48
  "peerDependencies": {
49
- "@uniweb/build": "0.14.15",
50
- "@uniweb/content-reader": "1.1.12",
51
- "@uniweb/semantic-parser": "1.1.17"
49
+ "@uniweb/semantic-parser": "1.1.17",
50
+ "@uniweb/build": "0.14.16",
51
+ "@uniweb/content-reader": "1.1.12"
52
52
  },
53
53
  "peerDependenciesMeta": {
54
54
  "@uniweb/build": {
@@ -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 ─────────────────────────────────────────────
@@ -32,18 +32,20 @@
32
32
  * Auth: --token > UNIWEB_TOKEN > `uniweb login` session.
33
33
  *
34
34
  * A project that never pushed has no `$uuid` to pull by — pull is a no-op with a
35
- * clear message. NOTE: the backend pull routes have not been exercised live; the
36
- * response-envelope extraction (extractDocument / splitCollectionsPull) is
37
- * deliberately tolerant and is the single point to adjust at the first live run.
35
+ * clear message. The backend serves each lane as a `.uwx` (ZIP: `manifest.json` +
36
+ * `entities/<uuid>.json`); `readPullDocuments` reads the entity files out of it, with
37
+ * a tolerant JSON fallback (`extractDocument` / `splitCollectionsPull`). Verified live
38
+ * against the playground backend, 2026-06-17.
38
39
  */
39
40
 
40
- import { readFileSync } from 'node:fs'
41
- import { join } from 'node:path'
41
+ import { readFileSync, writeFileSync, mkdirSync } from 'node:fs'
42
+ import { join, dirname } from 'node:path'
42
43
  import yaml from 'js-yaml'
43
44
  import {
44
45
  siteContentDocumentToProject,
45
46
  collectionsToProject,
46
47
  resolveCollectionsConfig,
48
+ readZip,
47
49
  } from '@uniweb/build/uwx'
48
50
  import { makeModelResolver } from './push.js'
49
51
  import { BackendClient } from '../backend/client.js'
@@ -76,6 +78,27 @@ function readYamlUuid(filePath) {
76
78
  }
77
79
  }
78
80
 
81
+ // Conditional-pull ETag cache (gitignored `.uniweb/pull-cache.json`): the last ETag
82
+ // seen per lane. The ETag is OPAQUE — cached and echoed verbatim in If-None-Match,
83
+ // never parsed or recomputed (the backend owns the hash; the client treats it as a
84
+ // token). A missing cache just means a full (unconditional) pull.
85
+ function pullCachePath(siteDir) {
86
+ return join(siteDir, '.uniweb', 'pull-cache.json')
87
+ }
88
+ function readPullCache(siteDir) {
89
+ try {
90
+ const obj = JSON.parse(readFileSync(pullCachePath(siteDir), 'utf8'))
91
+ return obj && typeof obj === 'object' ? obj : {}
92
+ } catch {
93
+ return {}
94
+ }
95
+ }
96
+ function writePullCache(siteDir, { content, folder }) {
97
+ const p = pullCachePath(siteDir)
98
+ mkdirSync(dirname(p), { recursive: true })
99
+ writeFileSync(p, JSON.stringify({ version: 1, content, folder }, null, 2) + '\n')
100
+ }
101
+
79
102
  // Extract a single entity `$`-document from a pull response. Tolerant of a raw
80
103
  // document, or a `{ document }` / `{ entity }` envelope. (Adjust at live e2e.)
81
104
  export function extractDocument(payload) {
@@ -104,6 +127,46 @@ export function splitCollectionsPull(payload) {
104
127
  }
105
128
  }
106
129
 
130
+ // Read a pull lane's bytes into entity `$`-documents. The backend serves a `.uwx`
131
+ // (our Stored ZIP: `manifest.json` + `entities/<uuid>.json`); the entity files ARE the
132
+ // documents. Falls back to a JSON body (a raw doc, a `{document}`/`{entity}` envelope,
133
+ // or a list) so the lane survives a future envelope change. Returns an array (possibly
134
+ // empty).
135
+ export function readPullDocuments(buf) {
136
+ // `.uwx` ZIP — the local-file signature is "PK\x03\x04".
137
+ if (buf.length >= 2 && buf[0] === 0x50 && buf[1] === 0x4b) {
138
+ const docs = []
139
+ for (const [name, data] of readZip(buf)) {
140
+ if (name === 'manifest.json' || !name.endsWith('.json')) continue
141
+ try {
142
+ docs.push(JSON.parse(data.toString('utf8')))
143
+ } catch {
144
+ /* skip a non-document entry */
145
+ }
146
+ }
147
+ return docs
148
+ }
149
+ // JSON fallback — flatten any envelope splitCollectionsPull understands into a
150
+ // flat `$`-document list (a raw doc, a list, `{entities}`/`{documents}`, or
151
+ // `{folder, records}`).
152
+ let payload
153
+ try {
154
+ payload = JSON.parse(buf.toString('utf8'))
155
+ } catch {
156
+ return []
157
+ }
158
+ if (Array.isArray(payload)) return payload.map(extractDocument).filter(Boolean)
159
+ if (payload?.folder) return [payload.folder, ...(payload.records || [])].filter(Boolean)
160
+ const list = Array.isArray(payload?.entities)
161
+ ? payload.entities
162
+ : Array.isArray(payload?.documents)
163
+ ? payload.documents
164
+ : null
165
+ if (list) return list.map(extractDocument).filter(Boolean)
166
+ const single = extractDocument(payload)
167
+ return single ? [single] : []
168
+ }
169
+
107
170
  /**
108
171
  * @param {string[]} args
109
172
  * @param {object} [deps] - injectable seams for testing: `fetch` (default global
@@ -141,10 +204,12 @@ export async function pull(args = [], deps = {}) {
141
204
  return { exitCode: 0 }
142
205
  }
143
206
 
144
- // GET a pull lane via the client and parse it as JSON. `doRequest` is a thunk
145
- // returning the client's Response promise. 404 null (deleted / no access); any
146
- // failure is reported and returns null (the lane is skipped, not fatal).
147
- const getJson = async (label, doRequest) => {
207
+ // GET a pull lane via the client and return `{ docs, etag }` from its `.uwx` (ZIP)
208
+ // body `readPullDocuments` reads the entity files out of it (JSON fallback). A
209
+ // conditional request whose ETag matches returns `{ notModified: true }` (304, empty
210
+ // body). `doRequest` is a thunk returning the client's Response promise. 404 / any
211
+ // failure → null (the lane is skipped, not fatal).
212
+ const getDocs = async (label, doRequest) => {
148
213
  info(`Pulling ${colors.bright}${label}${colors.reset} from ${colors.dim}${client.origin}${colors.reset} …`)
149
214
  let res
150
215
  try {
@@ -158,15 +223,21 @@ export async function pull(args = [], deps = {}) {
158
223
  note(`${label}: not found (404) — it was deleted, or you lack access.`)
159
224
  return null
160
225
  }
226
+ if (res.status === 304) {
227
+ note(`${label}: unchanged (304)`)
228
+ return { notModified: true }
229
+ }
161
230
  if (!res.ok) {
162
231
  error(`${label} pull failed: HTTP ${res.status} ${res.statusText}`)
163
232
  if (res.status === 401 || res.status === 403) note("Credentials weren't accepted — supply a bearer with --token <bearer>.")
164
233
  return null
165
234
  }
166
235
  try {
167
- return await res.json()
236
+ const etag = res.headers?.get?.('etag') ?? null
237
+ const docs = readPullDocuments(Buffer.from(await res.arrayBuffer()))
238
+ return { docs, etag }
168
239
  } catch (err) {
169
- error(`Could not parse the ${label} response as JSON: ${err.message}`)
240
+ error(`Could not read the ${label} response: ${err.message}`)
170
241
  return null
171
242
  }
172
243
  }
@@ -176,25 +247,34 @@ export async function pull(args = [], deps = {}) {
176
247
  let records = 0
177
248
  let deleted = 0
178
249
 
179
- // Lane 1 content config + pages/** + layout/**.
180
- const siteDoc = extractDocument(
181
- await getJson('content', () => client.pullSiteContent(siteContentUuid))
182
- )
183
- if (siteDoc) {
184
- const report = siteContentDocumentToProject({ document: siteDoc, siteRoot: siteDir, prune })
185
- pages += report.pages.length
186
- sections += report.sections.length
187
- deleted += report.deleted.length
250
+ // Conditional-pull cache: the last ETag seen per lane (opaque token — cached and
251
+ // echoed verbatim, never recomputed). Lives in the gitignored `.uniweb/`.
252
+ const cache = readPullCache(siteDir)
253
+ let etagContent = cache.content
254
+ let etagFolder = cache.folder
255
+
256
+ // Lane 1 — content → config + pages/** + layout/**. The .uwx carries a single
257
+ // entity (the site-content document). A 304 (unchanged) leaves local files as-is.
258
+ const content = await getDocs('content', () => client.pullSiteContent(siteContentUuid, { etag: etagContent }))
259
+ if (content && !content.notModified) {
260
+ const siteDoc = content.docs && (content.docs.find((d) => d?.info || d?.$model) || content.docs[0] || null)
261
+ if (siteDoc) {
262
+ const report = siteContentDocumentToProject({ document: siteDoc, siteRoot: siteDir, prune })
263
+ pages += report.pages.length
264
+ sections += report.sections.length
265
+ deleted += report.deleted.length
266
+ }
267
+ if (content.etag) etagContent = content.etag
188
268
  }
189
269
 
190
270
  // Lane 2 — folder → the folder + record files, keyed by the SAME site-content uuid
191
271
  // (the backend resolves the site's `@uniweb/folder` from it; the framework never
192
272
  // holds a folder uuid). Models are resolved by name (async) up front, so
193
- // collectionsToProject keeps its synchronous contract.
273
+ // collectionsToProject keeps its synchronous contract. A 304 leaves files as-is.
194
274
  if (!noCollections) {
195
- const payload = await getJson('collections', () => client.pullFolder(siteContentUuid))
196
- if (payload) {
197
- const { folderDoc, recordDocs } = splitCollectionsPull(payload)
275
+ const folder = await getDocs('collections', () => client.pullFolder(siteContentUuid, { etag: etagFolder }))
276
+ if (folder && !folder.notModified && folder.docs?.length) {
277
+ const { folderDoc, recordDocs } = splitCollectionsPull(folder.docs)
198
278
  const resolveModel = makeModelResolver({ client })
199
279
  const declByModel = new Map()
200
280
  for (const model of [...new Set(recordDocs.map((d) => d.$model).filter(Boolean))]) {
@@ -215,8 +295,12 @@ export async function pull(args = [], deps = {}) {
215
295
  for (const s of report.skipped) note(`↷ ${s.slug ?? s.uuid ?? '(record)'}: ${s.reason}`)
216
296
  for (const w of report.warnings) note(`! ${w}`)
217
297
  }
298
+ if (folder?.etag) etagFolder = folder.etag
218
299
  }
219
300
 
301
+ // Persist the ETags so the next pull is conditional (304 when unchanged).
302
+ writePullCache(siteDir, { content: etagContent, folder: etagFolder })
303
+
220
304
  success(
221
305
  `Pulled — ${pages} page(s), ${sections} section(s), ${records} record(s)` + (deleted ? `, ${deleted} deleted` : '')
222
306
  )
@@ -1,9 +1,9 @@
1
1
  {
2
2
  "schemaVersion": 1,
3
- "generatedAt": "2026-06-17T20:00:18.933Z",
3
+ "generatedAt": "2026-06-18T03:42:23.549Z",
4
4
  "packages": {
5
5
  "@uniweb/build": {
6
- "version": "0.14.15",
6
+ "version": "0.14.16",
7
7
  "path": "framework/build",
8
8
  "deps": [
9
9
  "@uniweb/content-reader",
@@ -61,7 +61,7 @@
61
61
  "deps": []
62
62
  },
63
63
  "@uniweb/runtime": {
64
- "version": "0.8.19",
64
+ "version": "0.8.20",
65
65
  "path": "framework/runtime",
66
66
  "deps": [
67
67
  "@uniweb/core",
@@ -99,7 +99,7 @@
99
99
  "deps": []
100
100
  },
101
101
  "@uniweb/unipress": {
102
- "version": "0.4.21",
102
+ "version": "0.4.22",
103
103
  "path": "framework/unipress",
104
104
  "deps": [
105
105
  "@uniweb/build",