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 +6 -6
- package/src/backend/client.js +12 -5
- package/src/commands/pull.js +108 -24
- package/src/framework-index.json +4 -4
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "uniweb",
|
|
3
|
-
"version": "0.12.
|
|
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/
|
|
45
|
+
"@uniweb/core": "0.7.14",
|
|
46
|
+
"@uniweb/runtime": "0.8.20"
|
|
47
47
|
},
|
|
48
48
|
"peerDependencies": {
|
|
49
|
-
"@uniweb/
|
|
50
|
-
"@uniweb/
|
|
51
|
-
"@uniweb/
|
|
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": {
|
package/src/backend/client.js
CHANGED
|
@@ -240,14 +240,21 @@ export class BackendClient {
|
|
|
240
240
|
})
|
|
241
241
|
}
|
|
242
242
|
|
|
243
|
-
/**
|
|
244
|
-
|
|
245
|
-
|
|
243
|
+
/**
|
|
244
|
+
* GET /dev/site/content/pull/{uuid} — the content lane document. Pass the
|
|
245
|
+
* last-seen ETag (opaque) to make it conditional: a match returns 304 (empty body).
|
|
246
|
+
*/
|
|
247
|
+
async pullSiteContent(uuid, { etag } = {}) {
|
|
248
|
+
return this.request(`/dev/site/content/pull/${encodeURIComponent(uuid)}`, {
|
|
249
|
+
headers: etag ? { 'If-None-Match': etag } : {},
|
|
250
|
+
})
|
|
246
251
|
}
|
|
247
252
|
|
|
248
253
|
/** GET /dev/site/folder/pull/{uuid} — the folder lane (folder + record documents). */
|
|
249
|
-
async pullFolder(uuid) {
|
|
250
|
-
return this.request(`/dev/site/folder/pull/${encodeURIComponent(uuid)}
|
|
254
|
+
async pullFolder(uuid, { etag } = {}) {
|
|
255
|
+
return this.request(`/dev/site/folder/pull/${encodeURIComponent(uuid)}`, {
|
|
256
|
+
headers: etag ? { 'If-None-Match': etag } : {},
|
|
257
|
+
})
|
|
251
258
|
}
|
|
252
259
|
|
|
253
260
|
// ── Delivery: deploy + site publish ─────────────────────────────────────────────
|
package/src/commands/pull.js
CHANGED
|
@@ -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.
|
|
36
|
-
*
|
|
37
|
-
*
|
|
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
|
|
145
|
-
//
|
|
146
|
-
//
|
|
147
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
//
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
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
|
|
196
|
-
if (
|
|
197
|
-
const { folderDoc, recordDocs } = splitCollectionsPull(
|
|
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
|
)
|
package/src/framework-index.json
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
{
|
|
2
2
|
"schemaVersion": 1,
|
|
3
|
-
"generatedAt": "2026-06-
|
|
3
|
+
"generatedAt": "2026-06-18T03:42:23.549Z",
|
|
4
4
|
"packages": {
|
|
5
5
|
"@uniweb/build": {
|
|
6
|
-
"version": "0.14.
|
|
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.
|
|
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.
|
|
102
|
+
"version": "0.4.22",
|
|
103
103
|
"path": "framework/unipress",
|
|
104
104
|
"deps": [
|
|
105
105
|
"@uniweb/build",
|