@uniweb/build 0.55.1 → 0.57.0
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 +7 -7
- package/src/content/index.js +3 -0
- package/src/i18n/data-strings.js +1 -1
- package/src/i18n/freeform.js +20 -19
- package/src/i18n/index.js +4 -4
- package/src/i18n/records.js +9 -5
- package/src/site/build-site-data.js +4 -3
- package/src/site/config.js +1 -1
- package/src/site/content-collector.js +4 -3
- package/src/site/deploy-config-writer.js +137 -29
- package/src/site/deploy-config.js +87 -38
- package/src/site/entity-pool.js +136 -42
- package/src/site/index.js +2 -2
- package/src/site/plugin.js +12 -9
- package/src/site/queries-config.js +15 -5
- package/src/site/query-processor.js +126 -112
- package/src/site/record-draft.js +40 -0
- package/src/site/records-config.js +101 -93
- package/src/uwx/asset-map.js +28 -149
- package/src/uwx/backfill.js +31 -4
- package/src/uwx/entity-source.js +3 -3
- package/src/uwx/folder.js +89 -49
- package/src/uwx/index.js +29 -17
- package/src/uwx/records-project.js +131 -78
- package/src/uwx/records.js +324 -203
- package/src/uwx/self-scope.js +5 -5
- package/src/uwx/site-project.js +34 -15
- package/src/uwx/site.js +80 -90
- package/src/uwx/sync-package.js +23 -15
- package/src/uwx/sync-store.js +351 -0
- package/src/validate-data.js +3 -1
|
@@ -0,0 +1,351 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `sync.json` — what each backend minted, keyed by origin.
|
|
3
|
+
*
|
|
4
|
+
* ## What it is
|
|
5
|
+
*
|
|
6
|
+
* Every id in this file was assigned by a backend and is meaningless against any
|
|
7
|
+
* other one: a site uuid, a record's uuid, an asset's content-addressed id. The
|
|
8
|
+
* file is **committed**, because a teammate who clones the project otherwise holds
|
|
9
|
+
* content with no way to say which site on which backend it is.
|
|
10
|
+
*
|
|
11
|
+
* ⭐ **If it changed, something got synced.** Nothing else writes it — which is the
|
|
12
|
+
* whole reason it is separate from `deploy.yml`, whose diff means "a target changed".
|
|
13
|
+
*
|
|
14
|
+
* Spec: `kb/framework/reference/sync-json.md`. Why it exists at all:
|
|
15
|
+
* `kb/framework/plans/backend-scoped-project-state.md`.
|
|
16
|
+
*
|
|
17
|
+
* ## Why it is keyed by ORIGIN, all the way down
|
|
18
|
+
*
|
|
19
|
+
* A project may sync with any number of backends. Nothing can be shared between
|
|
20
|
+
* them, so nothing is: each origin gets its own section and they never merge.
|
|
21
|
+
*
|
|
22
|
+
* ⭐ **The payoff is a guard that stops existing.** Identity used to sit in
|
|
23
|
+
* `site.yml` as a single `$uuid`, with `$backend` recording which backend it came
|
|
24
|
+
* from and `assertSiteBackendScope` refusing a command whose origin disagreed.
|
|
25
|
+
* Here there is no configuration in which backend A's ids can reach B — you read
|
|
26
|
+
* A's section for A — so there is no mismatch to detect.
|
|
27
|
+
*
|
|
28
|
+
* ## ⛔ No dependencies, by requirement
|
|
29
|
+
*
|
|
30
|
+
* `clone` writes a site's identity BEFORE `pnpm install` has run, so the writer has
|
|
31
|
+
* to work with nothing installed. `site.yml`'s equivalent needed a hand-rolled
|
|
32
|
+
* dependency-free fallback for exactly this (`cli/utils/site-identity.js`); JSON
|
|
33
|
+
* needs no parser, so this module uses `node:fs` and nothing else.
|
|
34
|
+
*
|
|
35
|
+
* @module
|
|
36
|
+
*/
|
|
37
|
+
|
|
38
|
+
import { existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from 'node:fs'
|
|
39
|
+
import { dirname, join } from 'node:path'
|
|
40
|
+
|
|
41
|
+
export const SYNC_STORE_FILE = 'sync.json'
|
|
42
|
+
|
|
43
|
+
/** Current on-disk shape. Bumped only for a breaking layout change. */
|
|
44
|
+
const VERSION = 1
|
|
45
|
+
|
|
46
|
+
/** Map sections — merged entry by entry. Anything else in a patch replaces. */
|
|
47
|
+
const MAP_SECTIONS = new Set(['records', 'items', 'queries', 'folders', 'assets'])
|
|
48
|
+
|
|
49
|
+
const storePath = (siteDir) => join(siteDir, SYNC_STORE_FILE)
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* A bare origin with no trailing slash, or null when unparseable.
|
|
53
|
+
*
|
|
54
|
+
* Callers may pass a whole endpoint URL — `http://localhost:8080/dev/site/…` and
|
|
55
|
+
* `http://localhost:8080` address the same backend. `https://x` and `http://x` do
|
|
56
|
+
* not: a scheme change is a different backend, not the same one reached differently.
|
|
57
|
+
*/
|
|
58
|
+
export function normalizeOrigin(value) {
|
|
59
|
+
if (typeof value !== 'string' || !value.trim()) return null
|
|
60
|
+
try {
|
|
61
|
+
// ⛔ http(s) only: `new URL('localhost:8080')` PARSES — as the scheme `localhost:` —
|
|
62
|
+
// and its origin is the string "null". Keyed by that, a mistyped backend would get a
|
|
63
|
+
// section of its own.
|
|
64
|
+
const u = new URL(value)
|
|
65
|
+
return u.protocol === 'http:' || u.protocol === 'https:' ? u.origin : null
|
|
66
|
+
} catch {
|
|
67
|
+
return null
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* The whole file, normalized. A missing, unreadable or malformed file reads as
|
|
73
|
+
* empty rather than throwing: this is a record, and a corrupt one must never be the
|
|
74
|
+
* reason a push fails. The next write repairs it.
|
|
75
|
+
*
|
|
76
|
+
* @param {string} siteDir
|
|
77
|
+
* @returns {{ version: number, backends: Record<string, object> }}
|
|
78
|
+
*/
|
|
79
|
+
export function readSyncStore(siteDir) {
|
|
80
|
+
const empty = { version: VERSION, backends: {} }
|
|
81
|
+
const p = storePath(siteDir)
|
|
82
|
+
if (!existsSync(p)) return empty
|
|
83
|
+
try {
|
|
84
|
+
const parsed = JSON.parse(readFileSync(p, 'utf8'))
|
|
85
|
+
const backends = parsed?.backends
|
|
86
|
+
if (!backends || typeof backends !== 'object' || Array.isArray(backends)) return empty
|
|
87
|
+
const out = {}
|
|
88
|
+
for (const [origin, state] of Object.entries(backends)) {
|
|
89
|
+
const key = normalizeOrigin(origin)
|
|
90
|
+
if (key && state && typeof state === 'object' && !Array.isArray(state)) out[key] = state
|
|
91
|
+
}
|
|
92
|
+
return { version: VERSION, backends: out }
|
|
93
|
+
} catch {
|
|
94
|
+
return empty
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* One backend's section, or `{}` when this project has never synced with it.
|
|
100
|
+
*
|
|
101
|
+
* ⛔ Returns a COPY at the top level, so a caller mutating it cannot write through
|
|
102
|
+
* to the next read. The maps inside are shared — treat the whole thing as read-only
|
|
103
|
+
* and go through `updateBackendMap` to change anything.
|
|
104
|
+
*
|
|
105
|
+
* @param {string} siteDir
|
|
106
|
+
* @param {string} origin
|
|
107
|
+
* @returns {object}
|
|
108
|
+
*/
|
|
109
|
+
export function readBackendState(siteDir, origin) {
|
|
110
|
+
const key = normalizeOrigin(origin)
|
|
111
|
+
if (!key) return {}
|
|
112
|
+
const found = readSyncStore(siteDir).backends[key]
|
|
113
|
+
return found ? { ...found } : {}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** Every origin this project has synced with, sorted. */
|
|
117
|
+
export function listSyncedBackends(siteDir) {
|
|
118
|
+
return Object.keys(readSyncStore(siteDir).backends).sort()
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Write the store, with every level sorted.
|
|
123
|
+
*
|
|
124
|
+
* ⛔ **Sorting is not tidiness.** A committed file that reorders itself produces a
|
|
125
|
+
* diff on every push and teaches people to stop reading it — at which point a real
|
|
126
|
+
* change to what a site is bound to passes unnoticed. Same rule, and the same
|
|
127
|
+
* reason, as `assets.json`, which this file absorbs.
|
|
128
|
+
*/
|
|
129
|
+
function writeStore(siteDir, backends) {
|
|
130
|
+
const sortedBackends = {}
|
|
131
|
+
for (const origin of Object.keys(backends).sort()) {
|
|
132
|
+
const state = backends[origin]
|
|
133
|
+
const sortedState = {}
|
|
134
|
+
for (const section of Object.keys(state).sort()) {
|
|
135
|
+
const value = state[section]
|
|
136
|
+
if (MAP_SECTIONS.has(section) && value && typeof value === 'object' && !Array.isArray(value)) {
|
|
137
|
+
const sortedMap = {}
|
|
138
|
+
for (const k of Object.keys(value).sort()) sortedMap[k] = value[k]
|
|
139
|
+
sortedState[section] = sortedMap
|
|
140
|
+
} else {
|
|
141
|
+
sortedState[section] = value
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
sortedBackends[origin] = sortedState
|
|
145
|
+
}
|
|
146
|
+
const text = JSON.stringify({ version: VERSION, backends: sortedBackends }, null, 2) + '\n'
|
|
147
|
+
const p = storePath(siteDir)
|
|
148
|
+
// A no-op write is worse than useless: it dirties `git status` on a push that
|
|
149
|
+
// changed nothing, which is the other half of the diff-stability rule above.
|
|
150
|
+
if (existsSync(p)) {
|
|
151
|
+
try {
|
|
152
|
+
if (readFileSync(p, 'utf8') === text) return false
|
|
153
|
+
} catch {
|
|
154
|
+
/* unreadable — fall through and replace it */
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
mkdirSync(dirname(p), { recursive: true })
|
|
158
|
+
writeFileSync(p, text)
|
|
159
|
+
return true
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Merge `patch` into one backend's section.
|
|
164
|
+
*
|
|
165
|
+
* **Map sections merge entry by entry; everything else replaces.** A push carries
|
|
166
|
+
* only what that run touched, so replacing a map would drop every entry it happened
|
|
167
|
+
* not to mention — which is most of them on a partial push. `services` and
|
|
168
|
+
* `secrets` are arrays and a whole projection from the backend, so replacing is
|
|
169
|
+
* right for those.
|
|
170
|
+
*
|
|
171
|
+
* @param {string} siteDir
|
|
172
|
+
* @param {string} origin
|
|
173
|
+
* @param {object} patch - e.g. `{ site: { uuid }, records: { 'a.md': 'uuid' } }`
|
|
174
|
+
* @returns {boolean} whether the file changed
|
|
175
|
+
*/
|
|
176
|
+
export function updateBackendState(siteDir, origin, patch) {
|
|
177
|
+
const key = normalizeOrigin(origin)
|
|
178
|
+
if (!key || !patch || typeof patch !== 'object') return false
|
|
179
|
+
const { backends } = readSyncStore(siteDir)
|
|
180
|
+
const prior = backends[key] || {}
|
|
181
|
+
const next = { ...prior }
|
|
182
|
+
|
|
183
|
+
for (const [section, value] of Object.entries(patch)) {
|
|
184
|
+
if (value === undefined) continue
|
|
185
|
+
if (MAP_SECTIONS.has(section) && value && typeof value === 'object' && !Array.isArray(value)) {
|
|
186
|
+
next[section] = { ...(prior[section] || {}), ...value }
|
|
187
|
+
} else if (section === 'site' && value && typeof value === 'object') {
|
|
188
|
+
// The identity object merges too: a create knows the uuid, the org prompt
|
|
189
|
+
// that follows knows the handle, and neither should erase the other.
|
|
190
|
+
next[section] = { ...(prior[section] || {}), ...value }
|
|
191
|
+
} else {
|
|
192
|
+
next[section] = value
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
backends[key] = next
|
|
197
|
+
return writeStore(siteDir, backends)
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* Merge entries into one map section, with an optional per-entry rule.
|
|
202
|
+
*
|
|
203
|
+
* `merge(next, prior)` decides what an entry becomes when one already exists, and
|
|
204
|
+
* returns `undefined` to leave the stored one alone. Without it, the incoming entry
|
|
205
|
+
* wins. Assets use it to carry a `served` fingerprint forward across a push that
|
|
206
|
+
* learned an id without learning an address.
|
|
207
|
+
*
|
|
208
|
+
* @param {string} siteDir
|
|
209
|
+
* @param {string} origin
|
|
210
|
+
* @param {string} section - one of `records` `items` `queries` `folders` `assets`
|
|
211
|
+
* @param {Record<string, *>} entries
|
|
212
|
+
* @param {(next: *, prior: *) => *} [merge]
|
|
213
|
+
* @returns {{ added: string[], changed: string[], written: boolean }}
|
|
214
|
+
*/
|
|
215
|
+
export function updateBackendMap(siteDir, origin, section, entries, merge) {
|
|
216
|
+
const key = normalizeOrigin(origin)
|
|
217
|
+
const added = []
|
|
218
|
+
const changed = []
|
|
219
|
+
if (!key || !MAP_SECTIONS.has(section) || !entries || typeof entries !== 'object') {
|
|
220
|
+
return { added, changed, written: false }
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
const { backends } = readSyncStore(siteDir)
|
|
224
|
+
const prior = backends[key] || {}
|
|
225
|
+
const map = { ...(prior[section] || {}) }
|
|
226
|
+
|
|
227
|
+
for (const [k, incoming] of Object.entries(entries)) {
|
|
228
|
+
if (incoming === undefined || incoming === null) continue
|
|
229
|
+
const was = map[k]
|
|
230
|
+
const value = was !== undefined && merge ? merge(incoming, was) : incoming
|
|
231
|
+
if (value === undefined) continue
|
|
232
|
+
if (was === undefined) added.push(k)
|
|
233
|
+
else if (JSON.stringify(was) !== JSON.stringify(value)) changed.push(k)
|
|
234
|
+
else continue
|
|
235
|
+
map[k] = value
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
if (!added.length && !changed.length) return { added, changed, written: false }
|
|
239
|
+
backends[key] = { ...prior, [section]: map }
|
|
240
|
+
return { added, changed, written: writeStore(siteDir, backends) }
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* The merge rule for the `assets` section — pass it to `updateBackendMap`.
|
|
245
|
+
*
|
|
246
|
+
* ⭐ **One definition, because three callers need it.** A push learns an id; only an
|
|
247
|
+
* UPLOAD learns the address the host serves it at. So a push that re-records the same
|
|
248
|
+
* bytes must not erase the `served` fingerprint an earlier one recorded, and a push
|
|
249
|
+
* whose bytes changed must drop it — the old address no longer describes them.
|
|
250
|
+
* Hand-rolled at each call site this is three chances to forget the second half.
|
|
251
|
+
*
|
|
252
|
+
* @param {{id: string, ext?: string, served?: string}} next
|
|
253
|
+
* @param {{id: string, ext?: string, served?: string}} prior
|
|
254
|
+
*/
|
|
255
|
+
export function carryServed(next, prior) {
|
|
256
|
+
if (next?.served || !prior || prior.id !== next?.id) return next
|
|
257
|
+
return prior.served ? { ...next, served: prior.served } : next
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/**
|
|
261
|
+
* Empty named sections of one backend's state.
|
|
262
|
+
*
|
|
263
|
+
* ⛔ **`updateBackendState` cannot express this**: map sections MERGE, so passing
|
|
264
|
+
* `{ items: {} }` is a no-op. Clearing needs its own verb, and the absence of one is
|
|
265
|
+
* how a caller ends up reaching into the file by hand.
|
|
266
|
+
*
|
|
267
|
+
* @param {string} siteDir
|
|
268
|
+
* @param {string} origin
|
|
269
|
+
* @param {string[]} sections
|
|
270
|
+
* @returns {string[]} the sections that actually held something
|
|
271
|
+
*/
|
|
272
|
+
export function clearBackendSections(siteDir, origin, sections) {
|
|
273
|
+
const key = normalizeOrigin(origin)
|
|
274
|
+
if (!key || !Array.isArray(sections)) return []
|
|
275
|
+
const { backends } = readSyncStore(siteDir)
|
|
276
|
+
const prior = backends[key]
|
|
277
|
+
if (!prior) return []
|
|
278
|
+
const cleared = []
|
|
279
|
+
const next = { ...prior }
|
|
280
|
+
for (const section of sections) {
|
|
281
|
+
const v = prior[section]
|
|
282
|
+
if (v && typeof v === 'object' && Object.keys(v).length) {
|
|
283
|
+
cleared.push(section)
|
|
284
|
+
next[section] = {}
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
if (!cleared.length) return []
|
|
288
|
+
backends[key] = next
|
|
289
|
+
writeStore(siteDir, backends)
|
|
290
|
+
return cleared
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/**
|
|
294
|
+
* Forget one backend entirely. Every other backend is untouched — which is the
|
|
295
|
+
* point of the keying, and the reason this is safe in a way clearing the old
|
|
296
|
+
* single-valued identity never was.
|
|
297
|
+
*
|
|
298
|
+
* @returns {boolean} whether anything was removed
|
|
299
|
+
*/
|
|
300
|
+
export function clearBackend(siteDir, origin) {
|
|
301
|
+
const key = normalizeOrigin(origin)
|
|
302
|
+
if (!key) return false
|
|
303
|
+
const { backends } = readSyncStore(siteDir)
|
|
304
|
+
if (!backends[key]) return false
|
|
305
|
+
delete backends[key]
|
|
306
|
+
// ⭐ The last backend gone ⇒ the FILE goes. An empty ledger reads exactly like no
|
|
307
|
+
// ledger, and leaving one behind is an untracked file in `git status` — a trace
|
|
308
|
+
// of the very sync the caller asked to forget (the throwaway dev-server lane).
|
|
309
|
+
if (!Object.keys(backends).length) {
|
|
310
|
+
unlinkSync(storePath(siteDir))
|
|
311
|
+
return true
|
|
312
|
+
}
|
|
313
|
+
writeStore(siteDir, backends)
|
|
314
|
+
return true
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
/**
|
|
318
|
+
* Delete `sync.json` outright — `uniweb forget --all`, which turns a COPY of a
|
|
319
|
+
* project into a new one.
|
|
320
|
+
*
|
|
321
|
+
* Every id in this file belongs to the project it was copied from, and keeping any
|
|
322
|
+
* of them is how a copy's push updates the original's site. There is no partial
|
|
323
|
+
* version of that: a copy owns none of these sites.
|
|
324
|
+
*
|
|
325
|
+
* @param {string} siteDir
|
|
326
|
+
* @returns {string[]|null} the origins it held, or null when there was no file. A
|
|
327
|
+
* file that does not parse is removed all the same — it is still the original's —
|
|
328
|
+
* and reports `[]`.
|
|
329
|
+
*/
|
|
330
|
+
export function forgetSyncStore(siteDir) {
|
|
331
|
+
const p = storePath(siteDir)
|
|
332
|
+
if (!existsSync(p)) return null
|
|
333
|
+
const origins = Object.keys(readSyncStore(siteDir).backends).sort()
|
|
334
|
+
unlinkSync(p)
|
|
335
|
+
return origins
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
/**
|
|
339
|
+
* The local ref a given asset id is known at for one backend, or null.
|
|
340
|
+
*
|
|
341
|
+
* Exists for `pull`, which meets stored content carrying an id and has to put the
|
|
342
|
+
* file back at the path its author wrote rather than inventing one.
|
|
343
|
+
*/
|
|
344
|
+
export function refForAssetId(siteDir, origin, id) {
|
|
345
|
+
if (!id) return null
|
|
346
|
+
const assets = readBackendState(siteDir, origin).assets || {}
|
|
347
|
+
for (const [ref, v] of Object.entries(assets)) {
|
|
348
|
+
if (v && v.id === id) return ref
|
|
349
|
+
}
|
|
350
|
+
return null
|
|
351
|
+
}
|
package/src/validate-data.js
CHANGED
|
@@ -39,6 +39,7 @@ import { validateAndNormalizeSchema } from './resolve-data-schema.js'
|
|
|
39
39
|
// reach it here) even though the implementation moved next to the vocabulary.
|
|
40
40
|
export { validateItem, isStaticallyCheckable } from '@uniweb/schemas/conform'
|
|
41
41
|
import { buildSchema } from './schema.js'
|
|
42
|
+
import { resolveRecordsDir } from './site/entity-pool.js'
|
|
42
43
|
import { toFetchList } from './site/data-fetcher.js'
|
|
43
44
|
import { resolveFoundationSrcPath } from './utils/foundation-source-root.js'
|
|
44
45
|
import { collectSiteContent } from './site/content-collector.js'
|
|
@@ -94,7 +95,8 @@ export async function validateDataInputs({ siteRoot, foundationPath }) {
|
|
|
94
95
|
let byQuery = {}
|
|
95
96
|
if (config.queries && typeof config.queries === 'object') {
|
|
96
97
|
|
|
97
|
-
|
|
98
|
+
// Drafts included: a draft is content the site will deliver, so it is checked now.
|
|
99
|
+
byQuery = await processQueries(siteRoot, config.queries, resolveRecordsDir(siteRoot, config.paths).rel, basePath, { includeDrafts: true })
|
|
98
100
|
}
|
|
99
101
|
|
|
100
102
|
// Declared here rather than beside pass 2's other accumulators because pass 1
|