@uniweb/build 0.76.1 → 0.78.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 +0 -1
- package/src/dev/api-mount.js +27 -19
- package/src/site/content-collector.js +58 -18
- package/src/site/data-fetcher.js +19 -0
- package/src/site/index.js +1 -2
- package/src/site/plugin.js +18 -5
- package/src/site/queries-config.js +2 -1
- package/src/site/query-processor.js +4 -40
- package/src/uwx/emit-surface.json +3 -15
- package/src/uwx/index.js +13 -0
- package/src/uwx/project-writer.js +6 -2
- package/src/uwx/services-request.js +432 -0
- package/src/uwx/shown-records.js +52 -0
- package/src/uwx/site-diff.js +47 -0
- package/src/uwx/site-project.js +41 -60
- package/src/uwx/site.js +77 -172
- package/src/uwx/sync-package.js +30 -18
|
@@ -0,0 +1,432 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A site's services — `site.yml::services`, ONE entry per service, saying
|
|
3
|
+
* everything the owner says about it.
|
|
4
|
+
*
|
|
5
|
+
* ```yaml
|
|
6
|
+
* services:
|
|
7
|
+
* search: # on: the host's, or the built-in index
|
|
8
|
+
* exclude: { routes: [/legal] } # an option the site's runtime reads
|
|
9
|
+
* submit: https://forms.example.com/f/abc # an address: a provider the site brings
|
|
10
|
+
* tracking: { consent: required }
|
|
11
|
+
* api: { grade: pro } # a setting for the host's service
|
|
12
|
+
* ```
|
|
13
|
+
*
|
|
14
|
+
* ⭐ ONE PLACE [Diego, 2026-10-06]. Until then the site's own provider and options
|
|
15
|
+
* lived in top-level `search:` / `submit:` / `assistant:` / `tracking:` / `api:`, and
|
|
16
|
+
* the request to the host in `services:` — two switches for one thing. Those keys are
|
|
17
|
+
* retired and refused (`refuseRetiredServiceKeys`).
|
|
18
|
+
*
|
|
19
|
+
* Each entry goes ONE place per lane, decided here and nowhere else:
|
|
20
|
+
*
|
|
21
|
+
* - **a built site** reads it as its own config (`config.<name>` in the payload) —
|
|
22
|
+
* the switch, an address, the options. ⛔ For `api` only its switch and address:
|
|
23
|
+
* its settings are for the host that provisions it. Never a credential.
|
|
24
|
+
* - **a push** sends it as the request: ONE row per entry in the `services` Section,
|
|
25
|
+
* `{ name, enabled?: false, config? }`, whose `config` is the whole entry — an
|
|
26
|
+
* address, the options, the host's settings — never a credential. What reaches a
|
|
27
|
+
* hosted page from a row is the host's to project.
|
|
28
|
+
*
|
|
29
|
+
* ⭐ THE ROW CARRIES THE WHOLE ENTRY [Diego, 2026-10-06: "If it's on and has
|
|
30
|
+
* configuration, it can all be in the same place"]. ⛔ Until that evening a push sent
|
|
31
|
+
* the address and the options as `settings.<name>` beside the row — the two places
|
|
32
|
+
* this module exists to replace, moved from `site.yml` onto the wire.
|
|
33
|
+
*
|
|
34
|
+
* ⭐ AN ADDRESS MEANS THE SITE BRINGS ITS OWN PROVIDER [Diego, 2026-10-06], so the
|
|
35
|
+
* host is asked to leave its own off — a host's offer outranks the site's address
|
|
36
|
+
* (`core/src/services.js`), and would otherwise win.
|
|
37
|
+
*
|
|
38
|
+
* ⭐ ONE REQUEST, WHICHEVER BACKEND A COMMAND TALKS TO. What a push sends is what the
|
|
39
|
+
* file says, plus OFF for each service this copy holds that the file no longer lists
|
|
40
|
+
* (`statedServices`); the services it holds are named, `{ name: $uuid }`, in that
|
|
41
|
+
* backend's entry in `sync.json`, which nobody edits. The backend decides per service.
|
|
42
|
+
*
|
|
43
|
+
* Pure functions, shared by the static build (`content-collector.js`), the producer
|
|
44
|
+
* (`site.js`), pull (`site-project.js`) and the CLI's push, publish and doctor.
|
|
45
|
+
*
|
|
46
|
+
* Spec: kb/framework/reference/site-services-request.md
|
|
47
|
+
*
|
|
48
|
+
* @module
|
|
49
|
+
*/
|
|
50
|
+
|
|
51
|
+
const isMap = (v) => v !== null && typeof v === 'object' && !Array.isArray(v)
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* The options each service's RUNTIME reads from the site's config — the framework's
|
|
55
|
+
* own vocabulary, which `uniweb doctor` checks an entry against. On the wire they ride
|
|
56
|
+
* in the row's `config` with everything else; this list decides nothing there.
|
|
57
|
+
*
|
|
58
|
+
* ⚠️ Read off the readers, and owed to them: `Website.getSearchConfig` and kit's search
|
|
59
|
+
* client, and the search index `@uniweb/projections` builds (search); `readEndpoint`
|
|
60
|
+
* (every `endpoint`); `wireTracker` (tracking). A key one of them starts reading
|
|
61
|
+
* belongs here, or `doctor` calls it unknown.
|
|
62
|
+
*/
|
|
63
|
+
export const RUNTIME_KEYS = Object.freeze({
|
|
64
|
+
search: Object.freeze(['enabled', 'provider', 'endpoint', 'include', 'exclude', 'fields', 'weight']),
|
|
65
|
+
submit: Object.freeze(['enabled', 'endpoint']),
|
|
66
|
+
assistant: Object.freeze(['enabled', 'endpoint']),
|
|
67
|
+
tracking: Object.freeze(['enabled', 'endpoint', 'emit', 'consent', 'scripts', 'flushIntervalMs', 'debug']),
|
|
68
|
+
api: Object.freeze(['enabled', 'endpoint'])
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Services only a host provides, which a site never answers itself: no address, no
|
|
73
|
+
* runtime part. A site does not declare its own record provider — a third-party
|
|
74
|
+
* source is a foundation transport.
|
|
75
|
+
*/
|
|
76
|
+
const HOST_ONLY = new Set(['records'])
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Credential-shaped keys, mirroring the set the delivery edge strips on the reading
|
|
80
|
+
* side — deliberately the SAME list, so the two guards are visibly twins. Never sent
|
|
81
|
+
* and never published: a key belongs in the host's secret store, set in the app.
|
|
82
|
+
*/
|
|
83
|
+
const CREDENTIAL_KEYS = Object.freeze(['apiKey', 'api_key', 'key', 'token', 'secret'])
|
|
84
|
+
|
|
85
|
+
/** The top-level keys `services:` replaced, refused by name. */
|
|
86
|
+
const RETIRED_SERVICE_KEYS = Object.freeze(['search', 'submit', 'assistant', 'tracking', 'api'])
|
|
87
|
+
|
|
88
|
+
/** Deterministic JSON: object keys sorted at every depth, arrays left in order. */
|
|
89
|
+
function stable(value) {
|
|
90
|
+
return JSON.stringify(value ?? null, (_k, v) =>
|
|
91
|
+
isMap(v) ? Object.fromEntries(Object.keys(v).sort().map((k) => [k, v[k]])) : v
|
|
92
|
+
)
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** The address an entry names, or null — a string, or a map's `endpoint`. */
|
|
96
|
+
function addressOf(value) {
|
|
97
|
+
if (typeof value === 'string') return value.trim() || null
|
|
98
|
+
if (isMap(value) && typeof value.endpoint === 'string' && value.endpoint.trim()) return value.endpoint.trim()
|
|
99
|
+
return null
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** The keys of a map the runtime reads for this service. */
|
|
103
|
+
function isRuntimeKey(name, key) {
|
|
104
|
+
const keys = RUNTIME_KEYS[name]
|
|
105
|
+
return keys ? keys.includes(key) : true
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** A map without its credential-shaped keys, and which ones it had. */
|
|
109
|
+
function withoutCredentials(value) {
|
|
110
|
+
const found = CREDENTIAL_KEYS.filter((k) => k in value)
|
|
111
|
+
if (!found.length) return { value, found }
|
|
112
|
+
const out = { ...value }
|
|
113
|
+
for (const k of found) delete out[k]
|
|
114
|
+
return { value: out, found }
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function sayCredentials(name, found, warn) {
|
|
118
|
+
if (found.length) {
|
|
119
|
+
warn(
|
|
120
|
+
`\`services.${name}\` carries ${found.map((k) => `\`${k}\``).join(', ')} — a credential is never sent ` +
|
|
121
|
+
'or published. Set it in the app, where secrets are kept.'
|
|
122
|
+
)
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Refuse the top-level keys `services:` replaced.
|
|
128
|
+
*
|
|
129
|
+
* @param {object} siteYml
|
|
130
|
+
* @param {string} [where]
|
|
131
|
+
* @throws {Error} naming each key and how to move it
|
|
132
|
+
*/
|
|
133
|
+
export function refuseRetiredServiceKeys(siteYml, where = 'site.yml') {
|
|
134
|
+
if (!isMap(siteYml)) return
|
|
135
|
+
const found = RETIRED_SERVICE_KEYS.filter((k) => siteYml[k] !== undefined)
|
|
136
|
+
if (!found.length) return
|
|
137
|
+
const lines = found.map((k) => ` ${k}: …`).join('\n')
|
|
138
|
+
const api = found.includes('api')
|
|
139
|
+
? '\n `api:` named the address a local mock answered on — in `uniweb dev`, `$devApi` now ' +
|
|
140
|
+
'supplies it. Asking your host for an app backend is `api: true` under `services:`.'
|
|
141
|
+
: ''
|
|
142
|
+
throw new Error(
|
|
143
|
+
`[uniweb] ${where}: ${found.map((k) => `\`${k}:\``).join(', ')} ` +
|
|
144
|
+
`${found.length === 1 ? 'is' : 'are'} retired — a service lives under \`services:\`, ` +
|
|
145
|
+
`one entry per service:\n\nservices:\n${lines}${api}`
|
|
146
|
+
)
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Services whose settings are never published: only the switch and the address reach
|
|
151
|
+
* the site's config. `api`'s settings are for the host that provisions it (a grade,
|
|
152
|
+
* sign-in providers, billing) and no page reads them.
|
|
153
|
+
*/
|
|
154
|
+
const UNPUBLISHED_SETTINGS = new Set(['api'])
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* One entry → what the site's config carries: `false`, an address, or the entry's
|
|
158
|
+
* options — or undefined when there is nothing for it (`true` is the default).
|
|
159
|
+
*
|
|
160
|
+
* @param {string} name
|
|
161
|
+
* @param {*} value - the entry
|
|
162
|
+
* @returns {false|string|object|undefined}
|
|
163
|
+
*/
|
|
164
|
+
export function runtimeServiceConfig(name, value) {
|
|
165
|
+
if (HOST_ONLY.has(name)) return undefined
|
|
166
|
+
if (value === false) return false
|
|
167
|
+
if (typeof value === 'string') return value.trim() || undefined
|
|
168
|
+
if (!isMap(value)) return undefined
|
|
169
|
+
const out = {}
|
|
170
|
+
for (const [key, v] of Object.entries(withoutCredentials(value).value)) {
|
|
171
|
+
if (!UNPUBLISHED_SETTINGS.has(name) || isRuntimeKey(name, key)) out[key] = v
|
|
172
|
+
}
|
|
173
|
+
if (out.enabled === true) delete out.enabled
|
|
174
|
+
return Object.keys(out).length ? out : undefined
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* `site.yml::services` → `{ <name>: <runtime value> }` for every entry the runtime
|
|
179
|
+
* has something for — what a static payload's `config.<name>` carries.
|
|
180
|
+
*
|
|
181
|
+
* @param {*} declared
|
|
182
|
+
* @param {{ warn?: (message: string) => void }} [opts]
|
|
183
|
+
* @returns {object}
|
|
184
|
+
*/
|
|
185
|
+
export function runtimeServicesConfig(declared, { warn = () => {} } = {}) {
|
|
186
|
+
const out = {}
|
|
187
|
+
if (!isMap(declared)) return out
|
|
188
|
+
for (const [name, value] of Object.entries(declared)) {
|
|
189
|
+
if (isMap(value)) sayCredentials(name, withoutCredentials(value).found, warn)
|
|
190
|
+
const runtime = runtimeServiceConfig(name, value)
|
|
191
|
+
if (runtime !== undefined) out[name] = runtime
|
|
192
|
+
}
|
|
193
|
+
return out
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* `site.yml::services` → the asks to the host, ONE row per entry, in the `services`
|
|
198
|
+
* Section's shape: `{ name, enabled?: false, config? }`.
|
|
199
|
+
*
|
|
200
|
+
* `true` asks on; `false` asks off; a map asks on — or off, with `enabled: false` — and
|
|
201
|
+
* its keys are the row's `config`. An address — a string, or `endpoint:` in a map — is
|
|
202
|
+
* the site's own provider: it rides as `config.endpoint`, and the host is asked to
|
|
203
|
+
* leave its own off, since a host's offer outranks the site's address
|
|
204
|
+
* (`core/src/services.js`) and would otherwise win. `enabled` is the row's switch and
|
|
205
|
+
* never in `config`; a credential never leaves at all.
|
|
206
|
+
*
|
|
207
|
+
* @param {*} declared - the raw `services:` value
|
|
208
|
+
* @param {{ warn?: (message: string) => void }} [opts] - told about every value it
|
|
209
|
+
* ignores or trims, with the fix
|
|
210
|
+
* @returns {object[]|null} null when the file asks nothing — no key, or no usable entry
|
|
211
|
+
*/
|
|
212
|
+
export function readServicesRequest(declared, { warn = () => {} } = {}) {
|
|
213
|
+
if (declared === undefined || declared === null) return null
|
|
214
|
+
if (!isMap(declared)) {
|
|
215
|
+
warn('`services:` is a map of service names — `services: { search: true }`. Ignoring it.')
|
|
216
|
+
return null
|
|
217
|
+
}
|
|
218
|
+
const asks = []
|
|
219
|
+
for (const [name, value] of Object.entries(declared)) {
|
|
220
|
+
if (value === true) {
|
|
221
|
+
asks.push({ name })
|
|
222
|
+
continue
|
|
223
|
+
}
|
|
224
|
+
if (value === false) {
|
|
225
|
+
asks.push({ name, enabled: false })
|
|
226
|
+
continue
|
|
227
|
+
}
|
|
228
|
+
if (typeof value !== 'string' && !isMap(value)) {
|
|
229
|
+
warn(`\`services.${name}\` is true, false, an address, or a map. Ignoring it.`)
|
|
230
|
+
continue
|
|
231
|
+
}
|
|
232
|
+
if (isMap(value) && value.enabled !== undefined && typeof value.enabled !== 'boolean') {
|
|
233
|
+
warn(`\`services.${name}.enabled\` is true or false. Ignoring \`${name}\`.`)
|
|
234
|
+
continue
|
|
235
|
+
}
|
|
236
|
+
const address = addressOf(value)
|
|
237
|
+
if (typeof value === 'string' && !address) continue // an empty string asks nothing
|
|
238
|
+
if (address && HOST_ONLY.has(name)) {
|
|
239
|
+
warn(`\`services.${name}\` has no address of its own — your host provides it. Ignoring \`${name}\`.`)
|
|
240
|
+
continue
|
|
241
|
+
}
|
|
242
|
+
const { enabled, ...entry } = isMap(value) ? value : { endpoint: address }
|
|
243
|
+
const { value: config, found } = withoutCredentials(entry)
|
|
244
|
+
sayCredentials(name, found, warn)
|
|
245
|
+
if (address) {
|
|
246
|
+
config.endpoint = address
|
|
247
|
+
// ⚠️ Said for `api` because its old spelling invites it: `api: /_api` was where a
|
|
248
|
+
// local mock answered, and under `services:` an address turns the host's off.
|
|
249
|
+
if (name === 'api') {
|
|
250
|
+
warn(
|
|
251
|
+
"`services.api` is an address: it asks your host to leave its own `api` off, so the site uses yours. " +
|
|
252
|
+
"For your host's, write `api: true`; in `uniweb dev`, `$devApi` answers it."
|
|
253
|
+
)
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
asks.push({
|
|
257
|
+
name,
|
|
258
|
+
...(address || enabled === false ? { enabled: false } : {}),
|
|
259
|
+
...(Object.keys(config).length ? { config } : {})
|
|
260
|
+
})
|
|
261
|
+
}
|
|
262
|
+
return asks.length ? asks : null
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/**
|
|
266
|
+
* One row → the entry `site.yml::services` writes for it.
|
|
267
|
+
*
|
|
268
|
+
* An address with the host's own off is the site's own provider, written as the entry
|
|
269
|
+
* was — a bare address when that is all it says. With the host's own on, the address
|
|
270
|
+
* goes: the host's offer is what answers now, and it was turned on elsewhere.
|
|
271
|
+
*/
|
|
272
|
+
function entryFromRow(row) {
|
|
273
|
+
const off = row.enabled === false
|
|
274
|
+
const config = isMap(row.config) ? { ...row.config } : {}
|
|
275
|
+
const address = addressOf(config)
|
|
276
|
+
if (address && off) return Object.keys(config).length === 1 ? address : config
|
|
277
|
+
if (address) delete config.endpoint
|
|
278
|
+
if (!Object.keys(config).length) return !off
|
|
279
|
+
return off ? { enabled: false, ...config } : config
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/**
|
|
283
|
+
* The credentials an author typed into an entry, kept on the one a pull writes over it.
|
|
284
|
+
* A push never sends one — it is stripped, with a warning naming the app — so the site's
|
|
285
|
+
* row cannot hold it, and writing the row whole would delete what the author typed.
|
|
286
|
+
* ⛔ *Until 2026-10-06 they sat in top-level keys the writer merged one level deep,
|
|
287
|
+
* which kept them; `services` is written whole.*
|
|
288
|
+
*/
|
|
289
|
+
function keepCredentials(entry, local) {
|
|
290
|
+
if (!isMap(local)) return entry
|
|
291
|
+
const found = CREDENTIAL_KEYS.filter((k) => k in local)
|
|
292
|
+
if (!found.length) return entry
|
|
293
|
+
const kept = Object.fromEntries(found.map((k) => [k, local[k]]))
|
|
294
|
+
if (entry === true) return kept
|
|
295
|
+
if (entry === false) return { enabled: false, ...kept }
|
|
296
|
+
if (typeof entry === 'string') return { endpoint: entry, ...kept }
|
|
297
|
+
return isMap(entry) ? { ...entry, ...kept } : entry
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
/**
|
|
301
|
+
* The site's rows → the `site.yml::services` map — what a pull writes.
|
|
302
|
+
*
|
|
303
|
+
* ⭐ AN OFF SERVICE WITH NO SETTINGS IS WRITTEN ONLY WHERE `site.yml` ALREADY NAMES IT
|
|
304
|
+
* [Diego, 2026-10-07]. Every service is on or off, and off by default, so a `false` line
|
|
305
|
+
* adds nothing — and writing one for every service switched off in the app, or for each
|
|
306
|
+
* line the file dropped, would fill the file with them. So a pull writes a service that is
|
|
307
|
+
* on, or that has settings (an own provider's address); and one that is off, as `false`,
|
|
308
|
+
* where the file names it — a file listing a service the site has since switched off must
|
|
309
|
+
* say so, or its next push switches it back on. The rows it leaves out are still held
|
|
310
|
+
* (`sync.json`), and a push states them off, as the site has them (`statedServices`).
|
|
311
|
+
* ⛔ *Until then every row was written, an off one as `false`.*
|
|
312
|
+
*
|
|
313
|
+
* ⚖️ What it gives up: a `false` an author wrote does not reach a fresh clone, whose
|
|
314
|
+
* `site.yml` names nothing yet. It still means off there.
|
|
315
|
+
*
|
|
316
|
+
* @param {object} p
|
|
317
|
+
* @param {object[]} [p.rows] - the `services` Section: the site's rows
|
|
318
|
+
* @param {object} [p.local] - the `services:` the pull writes over — which services it
|
|
319
|
+
* names, and the credentials a push never sends (`keepCredentials`)
|
|
320
|
+
* @returns {object|null} null when there is nothing to write
|
|
321
|
+
*/
|
|
322
|
+
export function servicesFromDocument({ rows, local } = {}) {
|
|
323
|
+
const before = isMap(local) ? local : {}
|
|
324
|
+
const out = {}
|
|
325
|
+
for (const row of Array.isArray(rows) ? rows : []) {
|
|
326
|
+
if (!isMap(row) || typeof row.name !== 'string' || !row.name || row.name in out) continue
|
|
327
|
+
const entry = entryFromRow(row)
|
|
328
|
+
if (entry === false && !(row.name in before)) continue
|
|
329
|
+
out[row.name] = keepCredentials(entry, before[row.name])
|
|
330
|
+
}
|
|
331
|
+
return Object.keys(out).length ? out : null
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
/**
|
|
335
|
+
* The `services` Section a push sends: every service `site.yml` lists, as it says it, and
|
|
336
|
+
* OFF for every service this copy holds that the file does not list.
|
|
337
|
+
*
|
|
338
|
+
* ⭐ A PUSH STATES, AND THE BACKEND DECIDES PER SERVICE [Diego, 2026-10-07 —
|
|
339
|
+
* kb/framework/plans/services-exchange.md]. Every service is on or off, off by default.
|
|
340
|
+
*
|
|
341
|
+
* - A service the file lists goes WHOLE — its switch, and the entry as its `config` — so
|
|
342
|
+
* a setting removed from the file is removed.
|
|
343
|
+
* - A service this copy holds — a pull returned it, or a push sent it — and the file
|
|
344
|
+
* does not list is stated `{ name, enabled: false }`, with no settings. Deleting the
|
|
345
|
+
* line turns it off and removes its settings, which would otherwise stay live on the
|
|
346
|
+
* page, since a row's `config` reaches it whether the row is on or off, and come back
|
|
347
|
+
* with the next pull.
|
|
348
|
+
* - A service this copy never saw is not sent, and the site keeps it.
|
|
349
|
+
*
|
|
350
|
+
* A held service carries its `$uuid`, and the push sends the version of every item it
|
|
351
|
+
* holds (`withBaseVersion`), so a service changed on the site since is kept when the file
|
|
352
|
+
* left it as it was, and refused when the file changed it too.
|
|
353
|
+
*
|
|
354
|
+
* ⛔ *Until then a push sent the site's whole list, read just before it, with the file's
|
|
355
|
+
* changed asks applied — a service left out was deleted — and the CLI kept its own copy
|
|
356
|
+
* of the list in `sync.json` to tell who had changed what.*
|
|
357
|
+
*
|
|
358
|
+
* @param {object[]|null} asks - from `readServicesRequest`
|
|
359
|
+
* @param {object} [held] - `{ <name>: <$uuid> }`, the services this copy holds (`sync.json`)
|
|
360
|
+
* @returns {object[]} the rows; `[]` when there is nothing to state
|
|
361
|
+
*/
|
|
362
|
+
export function statedServices(asks, held = {}) {
|
|
363
|
+
const ids = isMap(held) ? held : {}
|
|
364
|
+
const uuidOf = (name) => (typeof ids[name] === 'string' && ids[name] ? ids[name] : null)
|
|
365
|
+
const rows = []
|
|
366
|
+
const listed = new Set()
|
|
367
|
+
for (const ask of asks || []) {
|
|
368
|
+
listed.add(ask.name)
|
|
369
|
+
const row = { name: ask.name }
|
|
370
|
+
if (ask.enabled === false) row.enabled = false
|
|
371
|
+
if (ask.config) row.config = { ...ask.config }
|
|
372
|
+
if (uuidOf(ask.name)) row.$uuid = uuidOf(ask.name)
|
|
373
|
+
rows.push(row)
|
|
374
|
+
}
|
|
375
|
+
for (const name of Object.keys(ids).sort()) {
|
|
376
|
+
if (listed.has(name) || !uuidOf(name)) continue
|
|
377
|
+
rows.push({ name, enabled: false, $uuid: uuidOf(name) })
|
|
378
|
+
}
|
|
379
|
+
return rows
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
/**
|
|
383
|
+
* The services this copy holds after a push or a pull — `{ <name>: <$uuid> }`, read from
|
|
384
|
+
* the document the backend returned.
|
|
385
|
+
*
|
|
386
|
+
* A pull returns every service the site has, all seen (`sent` absent). A push returns the
|
|
387
|
+
* site's rows after the write, which may include services added since this copy's last
|
|
388
|
+
* pull: those were neither sent nor held before, so they are left out — held, a later
|
|
389
|
+
* push would state them off, switching off a service nobody here has seen.
|
|
390
|
+
*
|
|
391
|
+
* @param {object} p
|
|
392
|
+
* @param {object[]} [p.written] - the `services` rows the backend returned
|
|
393
|
+
* @param {object[]} [p.sent] - the rows the push sent; absent for a pull
|
|
394
|
+
* @param {object} [p.prior] - the services held before
|
|
395
|
+
* @returns {object} the services now held
|
|
396
|
+
*/
|
|
397
|
+
export function heldServices({ written, sent, prior } = {}) {
|
|
398
|
+
const before = isMap(prior) ? prior : {}
|
|
399
|
+
const stated = Array.isArray(sent) ? new Set(sent.filter(isMap).map((r) => r.name)) : null
|
|
400
|
+
const out = {}
|
|
401
|
+
for (const row of Array.isArray(written) ? written : []) {
|
|
402
|
+
if (!isMap(row) || typeof row.name !== 'string' || !row.name) continue
|
|
403
|
+
if (typeof row.$uuid !== 'string' || !row.$uuid) continue
|
|
404
|
+
if (stated && !stated.has(row.name) && !(row.name in before)) continue
|
|
405
|
+
out[row.name] = row.$uuid
|
|
406
|
+
}
|
|
407
|
+
// Sorted: it is written to `sync.json`, a committed file that must not reorder itself.
|
|
408
|
+
return Object.fromEntries(Object.keys(out).sort().map((k) => [k, out[k]]))
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
/** What a row SAYS — its switch and its `config` — not the fields a backend adds to it. */
|
|
412
|
+
function said(row) {
|
|
413
|
+
if (!isMap(row)) return null
|
|
414
|
+
const out = {}
|
|
415
|
+
if (row.enabled === false) out.enabled = false
|
|
416
|
+
if (isMap(row.config) && Object.keys(row.config).length) out.config = row.config
|
|
417
|
+
return out
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
/**
|
|
421
|
+
* Whether the site holds a service as a push sent it: the switch and the WHOLE
|
|
422
|
+
* `config`. ⛔ Not "equal on the keys we sent", which units use: an `on` row sends no
|
|
423
|
+
* `enabled` and an entry with no settings no `config`, so a row the app switched off,
|
|
424
|
+
* or gave settings, would read as ours.
|
|
425
|
+
*
|
|
426
|
+
* @param {object} sent - the row a push sent
|
|
427
|
+
* @param {object} written - the site's row after it
|
|
428
|
+
* @returns {boolean}
|
|
429
|
+
*/
|
|
430
|
+
export function sameServiceRow(sent, written) {
|
|
431
|
+
return isMap(sent) && isMap(written) && stable(said(sent)) === stable(said(written))
|
|
432
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The queries whose records a site's pages SHOW live — what the `records` service is for.
|
|
3
|
+
*
|
|
4
|
+
* ⭐ `records` is the live delivery of records to a published site, nothing else
|
|
5
|
+
* [Diego, 2026-10-07: "syncing records is unrelated to `records` being on/off. The
|
|
6
|
+
* service is the live delivery of records to published site"]. So what decides whether a
|
|
7
|
+
* site needs it is what its published pages fetch — never whether it has records to sync.
|
|
8
|
+
* A query counts when a page, a section or the site fetches it, and its records have a
|
|
9
|
+
* data schema (the declaration names one) and come from the site's own folder (no `url:`).
|
|
10
|
+
* A schema-less query's records are delivered as static files, which `records` does not
|
|
11
|
+
* touch; an external query is fetched from its own address.
|
|
12
|
+
*
|
|
13
|
+
* @module
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
const isMap = (v) => v !== null && typeof v === 'object' && !Array.isArray(v)
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* @param {object} doc - a site-content document, as a push sends it
|
|
20
|
+
* @returns {string[]} the query names, sorted
|
|
21
|
+
*/
|
|
22
|
+
export function recordQueriesShown(doc) {
|
|
23
|
+
const fetched = new Set()
|
|
24
|
+
const take = (fetch) => {
|
|
25
|
+
for (const one of [fetch].flat()) if (isMap(one) && typeof one.query === 'string' && one.query) fetched.add(one.query)
|
|
26
|
+
}
|
|
27
|
+
const walkSections = (sections) => {
|
|
28
|
+
for (const section of Array.isArray(sections) ? sections : []) {
|
|
29
|
+
take(section?.params?.fetch)
|
|
30
|
+
take(section?.fetch)
|
|
31
|
+
walkSections(section?.$children)
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
const walkPages = (pages) => {
|
|
35
|
+
for (const page of Array.isArray(pages) ? pages : []) {
|
|
36
|
+
take(page?.fetch)
|
|
37
|
+
walkSections(page?.page_sections)
|
|
38
|
+
walkPages(page?.$children)
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
take(doc?.settings?.fetch)
|
|
42
|
+
walkPages(doc?.pages)
|
|
43
|
+
walkSections(doc?.layout_sections)
|
|
44
|
+
const live = new Set()
|
|
45
|
+
for (const query of Array.isArray(doc?.queries) ? doc.queries : []) {
|
|
46
|
+
if (!isMap(query) || typeof query.name !== 'string') continue
|
|
47
|
+
if (typeof query.schema !== 'string' || !query.schema) continue
|
|
48
|
+
if (isMap(query.source) && query.source.url !== undefined) continue
|
|
49
|
+
live.add(query.name)
|
|
50
|
+
}
|
|
51
|
+
return [...fetched].filter((name) => live.has(name)).sort()
|
|
52
|
+
}
|
package/src/uwx/site-diff.js
CHANGED
|
@@ -321,3 +321,50 @@ export function collectQueryUuids(doc) {
|
|
|
321
321
|
}
|
|
322
322
|
return out
|
|
323
323
|
}
|
|
324
|
+
|
|
325
|
+
// The Sections whose items are matched by a field of their own, and that field — how
|
|
326
|
+
// the backend matches them too. `extensions` has no declared key yet; its `ref` or
|
|
327
|
+
// `url` is what the file names it by.
|
|
328
|
+
const KEYED_SECTIONS = {
|
|
329
|
+
queries: (item) => item?.name,
|
|
330
|
+
services: (item) => item?.name,
|
|
331
|
+
extensions: (item) => item?.ref || item?.url
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
/**
|
|
335
|
+
* Every item of a site-content document, under a key that names it the same way in OUR
|
|
336
|
+
* document and in the BACKEND's, so the two can be paired item by item.
|
|
337
|
+
*
|
|
338
|
+
* - units — `site.yml` (the `info` item), pages, page sections and layout sections —
|
|
339
|
+
* by the path the projector writes them to (`unit:<path>`), each its OWN content, as
|
|
340
|
+
* `collectSiteUnits` keys them;
|
|
341
|
+
* - `settings`, one item, by its Section;
|
|
342
|
+
* - `queries`, `services` and `extensions` by the field each is matched by
|
|
343
|
+
* (`queries:<name>`, …).
|
|
344
|
+
*
|
|
345
|
+
* ⛔ `secrets` is left out on purpose: a push never deletes a secret by leaving it out,
|
|
346
|
+
* and its values are set in the app, so no version of one is ever kept or sent.
|
|
347
|
+
* A Section this does not know is left out too — an item that cannot be paired is
|
|
348
|
+
* never taken as seen.
|
|
349
|
+
*
|
|
350
|
+
* @returns {Map<string, { uuid: string|null, record: object }>}
|
|
351
|
+
*/
|
|
352
|
+
export function siteItemsByKey(doc, sourceLocale = LOCALIZED_FIELD_ASSUMPTION.defaultSourceLocale) {
|
|
353
|
+
const out = new Map()
|
|
354
|
+
const uuidOf = (record) => (typeof record?.$uuid === 'string' && record.$uuid ? record.$uuid : null)
|
|
355
|
+
const units = collectSiteUnits(doc, sourceLocale)
|
|
356
|
+
walkSiteUnits(doc, (path, record) => {
|
|
357
|
+
if (!out.has(`unit:${path}`)) out.set(`unit:${path}`, { uuid: uuidOf(record), record: units.get(path) })
|
|
358
|
+
}, sourceLocale)
|
|
359
|
+
if (doc?.settings && typeof doc.settings === 'object' && !Array.isArray(doc.settings)) {
|
|
360
|
+
out.set('settings', { uuid: uuidOf(doc.settings), record: doc.settings })
|
|
361
|
+
}
|
|
362
|
+
for (const [section, keyOf] of Object.entries(KEYED_SECTIONS)) {
|
|
363
|
+
for (const item of Array.isArray(doc?.[section]) ? doc[section] : []) {
|
|
364
|
+
const key = keyOf(item)
|
|
365
|
+
if (typeof key !== 'string' || !key || out.has(`${section}:${key}`)) continue
|
|
366
|
+
out.set(`${section}:${key}`, { uuid: uuidOf(item), record: item })
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
return out
|
|
370
|
+
}
|