@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.
@@ -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
+ }
@@ -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
+ }