uniweb 0.83.0 → 0.85.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.
@@ -34,10 +34,12 @@ import {
34
34
  diffSiteUnits,
35
35
  describeSiteDiff,
36
36
  computeUnitHashes,
37
- collectSiteUnits,
38
37
  collectUnitUuids,
39
38
  collectFolderItemUuids,
40
39
  collectQueryUuids,
40
+ siteItemsByKey,
41
+ sameServiceRow,
42
+ heldServices,
41
43
  readBackendState,
42
44
  updateBackendMap,
43
45
  clearBackendSections,
@@ -118,6 +120,35 @@ async function explainStaleSiteContent({ client, siteDir, localBuffer, uuid }) {
118
120
  }
119
121
  }
120
122
 
123
+ /**
124
+ * The items a `stale_base` refusal names by key — `stale_keys`, one
125
+ * `{ section, key }` per clashing item of a Section matched by a field of its own
126
+ * (`services` and `queries` by `name`) — as one line per Section.
127
+ *
128
+ * @param {*} staleKeys - the refusal's `stale_keys`
129
+ * @returns {string[]}
130
+ */
131
+ export function describeStaleKeys(staleKeys) {
132
+ const bySection = new Map()
133
+ for (const entry of Array.isArray(staleKeys) ? staleKeys : []) {
134
+ const section = typeof entry?.section === 'string' && entry.section ? entry.section : null
135
+ if (!section) continue
136
+ const key = entry?.key
137
+ // A single-item Section (`settings`) is named by the Section alone, `key: {}`.
138
+ const single = key && typeof key === 'object' && !Object.keys(key).length
139
+ const name =
140
+ key && typeof key === 'object'
141
+ ? typeof key.name === 'string' ? key.name : Object.values(key).filter((v) => typeof v === 'string').join(' ')
142
+ : typeof key === 'string' ? key : null
143
+ if (!single && !name) continue
144
+ if (!bySection.has(section)) bySection.set(section, [])
145
+ if (name) bySection.get(section).push(name)
146
+ }
147
+ return [...bySection].map(([section, names]) =>
148
+ `Changed on your site since your last pull — ${section}${names.length ? `: ${names.join(', ')}` : ''}`
149
+ )
150
+ }
151
+
121
152
  // A unit in the form it is compared in across the two representations: keys sorted,
122
153
  // `$`-keys dropped (`$uuid`, `$id` — identity and payload handles, not content).
123
154
  const canonicalUnit = (v) =>
@@ -172,42 +203,65 @@ function writtenAsSent(sent, written) {
172
203
  * changes it is `pkg != base, host != base` and refused. And while the backend holds
173
204
  * units we don't, the entity token stays where it was, so the next push keeps them.
174
205
  *
175
- * With either document missing there is nothing to compare, and the tokens are banked
176
- * as the backend sent them — the contract is that `finalized[].document` is always
177
- * there.
178
- *
206
+ * ⭐ SINCE 2026-10-07 A HELD VERSION IS ALSO WHAT LETS A PUSH DELETE, so the rule covers
207
+ * every item, not only units. The site-content entity sends every version this copy
208
+ * holds, for items present and items the files dropped (`withBaseVersion`), and the
209
+ * backend deletes only the items a push holds a version for, while the site left them
210
+ * unchanged [Diego: "yes, apply it to pages too"; kb/framework/plans/services-exchange.md].
211
+ * A version banked for an item this copy never saw would therefore delete it. The map
212
+ * returned is the WHOLE set this copy now holds, in every Section but `secrets`
213
+ * (`siteItemsByKey`), and it replaces the one held before:
214
+ *
215
+ * - an item the backend holds as we sent it → its new version;
216
+ * - an item we sent that it kept for someone else → the version we held, for OUR content;
217
+ * - an item it holds that we did not send → the version we held, if we had seen it, and
218
+ * none if we had not (a unit of it is `foreign`);
219
+ * - an item we held that it no longer holds → none: the push deleted it, or the site did.
220
+ *
221
+ * ⛔ Until then only units were compared, and every other item's version was banked as
222
+ * the backend returned it — harmless only while none of them was ever sent.
223
+ *
224
+ * A service is compared by its switch and its whole `config` (`sameServiceRow`), every
225
+ * other item on the keys we sent (`writtenAsSent`).
226
+ *
227
+ * With either document missing there is nothing to compare, and with no versions
228
+ * returned (an older backend) nothing to bank: `itemVersions` is null, and the versions
229
+ * held stay as they were. The contract is that `finalized[].document` is always there.
230
+ *
231
+ * @param {object} p
232
+ * @param {object|null} p.sent - the site-content document this push sent
233
+ * @param {object|null} p.written - the backend's post-write copy of it
234
+ * @param {string|null} [p.version] - the entity's post-write version
235
+ * @param {Object<string,string>|null} [p.itemVersions] - the post-write version of every item
236
+ * @param {Object<string,string>} [p.held] - the versions this copy held before the push
179
237
  * @returns {{ version: string|null, itemVersions: Object<string,string>|null,
180
- * held: string[], kept: string[], foreign: string[] }} `held`: units the backend
238
+ * held: string[], kept: string[], foreign: string[] }} `itemVersions`: the versions
239
+ * this copy now holds, or null to leave them as they were; `held`: units the backend
181
240
  * holds as we sent them; `kept`: units it kept someone else's content for;
182
241
  * `foreign`: units it holds that we did not send. All paths.
183
242
  */
184
- export function heldTokens({ sent, written, version = null, itemVersions = null }) {
185
- if (!sent || !written) return { version, itemVersions, held: [], kept: [], foreign: [] }
186
- const ours = collectSiteUnits(sent)
187
- const theirs = collectSiteUnits(written)
188
- const uuidAt = collectUnitUuids(written)
189
- const units = new Set(Object.values(uuidAt))
243
+ export function heldTokens({ sent, written, version = null, itemVersions = null, held: before = {} }) {
244
+ if (!sent || !written) return { version, itemVersions: null, held: [], kept: [], foreign: [] }
245
+ const prior = before && typeof before === 'object' ? before : {}
246
+ const ours = siteItemsByKey(sent)
247
+ const theirs = siteItemsByKey(written)
248
+ const same = (key, a, b) => (key.startsWith('services:') ? sameServiceRow(a, b) : writtenAsSent(a, b))
190
249
  const held = []
191
250
  const kept = []
192
251
  const foreign = []
193
252
  const items = {}
194
- for (const [path, uuid] of Object.entries(uuidAt)) {
195
- if (!ours.has(path)) {
196
- foreign.push(path)
253
+ for (const [key, item] of theirs) {
254
+ if (!item.uuid) continue
255
+ const unit = key.startsWith('unit:') ? key.slice('unit:'.length) : null
256
+ const mine = ours.get(key)
257
+ if (mine && same(key, mine.record, item.record)) {
258
+ if (unit) held.push(unit)
259
+ const token = itemVersions?.[item.uuid] ?? prior[item.uuid]
260
+ if (token) items[item.uuid] = token
197
261
  continue
198
262
  }
199
- if (!writtenAsSent(ours.get(path), theirs.get(path))) {
200
- kept.push(path)
201
- continue
202
- }
203
- held.push(path)
204
- if (itemVersions?.[uuid]) items[uuid] = itemVersions[uuid]
205
- }
206
- // A token for an item that is not a unit (a `queries` declaration) is never sent
207
- // back — the emit narrows preconditions to units (`withBaseVersion`) — so it is
208
- // banked as before: it can neither arm nor disarm anything.
209
- for (const [uuid, token] of Object.entries(itemVersions || {})) {
210
- if (!units.has(uuid)) items[uuid] = token
263
+ if (unit) (mine ? kept : foreign).push(unit)
264
+ if (prior[item.uuid]) items[item.uuid] = prior[item.uuid]
211
265
  }
212
266
  return {
213
267
  version: foreign.length ? null : version,
@@ -218,6 +272,26 @@ export function heldTokens({ sent, written, version = null, itemVersions = null
218
272
  }
219
273
  }
220
274
 
275
+ /**
276
+ * The versions a pull leaves this copy holding: one for every site-content item the
277
+ * pulled document carries, in every Section but `secrets` (`siteItemsByKey`) — the
278
+ * whole set, replacing what was held. A pull is how a copy sees the site, so an item
279
+ * the site no longer has is forgotten, and the next push does not send its version.
280
+ *
281
+ * @param {object|null} doc - the pulled site-content document
282
+ * @param {Object<string,string>|null} itemVersions - the pull manifest's `item_versions`
283
+ * @returns {Object<string,string>|null} null when there is nothing to bank (no document, or
284
+ * an older backend that sends no item versions)
285
+ */
286
+ export function pulledItemVersions(doc, itemVersions) {
287
+ if (!doc || !itemVersions || typeof itemVersions !== 'object') return null
288
+ const out = {}
289
+ for (const { uuid } of siteItemsByKey(doc).values()) {
290
+ if (uuid && typeof itemVersions[uuid] === 'string') out[uuid] = itemVersions[uuid]
291
+ }
292
+ return out
293
+ }
294
+
221
295
  // The files a unit projects to, under the site's own roots — `site.yml::paths` can
222
296
  // relocate `pages` and `layout`. `site.yml` stands for the `info` unit, which projects
223
297
  // to three files.
@@ -378,7 +452,9 @@ export function keepModels(siteDir, backend, models) {
378
452
  // ⭐ **Everything here is regenerable and losing it never produces a WRONG result** —
379
453
  // only a slower transfer or a round trip. That is the membership test; anything
380
454
  // failing it belongs in `sync.json` (which is where the uuid maps went on
381
- // 2026-09-20 — see `readItemUuids` below).
455
+ // 2026-09-20 — see `readItemUuids` below). ⚠️ One exception since 2026-10-07, and it
456
+ // is said where it lives: the item versions are what lets a push delete, so without
457
+ // them a dropped page is kept until the next pull (`readItemBaseVersions`).
382
458
  //
383
459
  // ⚠️ It was `sync-cache.json` and its header called it "a pure wire-efficiency
384
460
  // cache — NOT identity, the minted `$uuid` lives in the source files". That was
@@ -771,20 +847,35 @@ export function readBaseVersions(siteDir, backend) {
771
847
  }
772
848
 
773
849
  /**
774
- * Per-ITEM staleness tokens: `{ <record $uuid>: <opaque version> }`.
850
+ * Per-ITEM staleness tokens: `{ <item $uuid>: <opaque version> }` — one for every item
851
+ * of the site-content entity this copy has seen.
775
852
  *
776
853
  * The entity token gates the whole document, so a stale base on any one record
777
854
  * refuses the entire push — which fires on the common case of two people editing
778
- * different sections and teaches them to reach for `--force`. These gate per record
855
+ * different sections and teaches them to reach for `--force`. These gate per item
779
856
  * instead. Same contract as the entity token: opaque, cached, echoed, never parsed.
780
857
  *
781
- * Merged rather than replaced: a push carries only CHANGED entities, so a response
782
- * reports tokens for a subset of the site. Replacing would drop the tokens of every
783
- * record that wasn't in this package and silently degrade those to ungated.
858
+ * ⭐ AND THEY ARE WHAT LETS A PUSH DELETE (2026-10-07): the backend deletes only the
859
+ * items a push holds a version for, so every one held is sent, present or dropped
860
+ * (`withBaseVersion`). Hence the set is REPLACED, never merged — by a pull
861
+ * (`pulledItemVersions`) and by a push of the site-content lane (`heldTokens`) — and
862
+ * holds only items this copy has seen. ⚠️ The one thing losing this cache now costs that
863
+ * is not a round trip: until the next pull, a page or section the files dropped is kept
864
+ * on the site rather than deleted, since there is no version to send for it.
865
+ * ⛔ *It was merged until then, across both lanes, for a push that sent only units.*
784
866
  */
785
867
  export function readItemBaseVersions(siteDir, backend) {
786
868
  return readMap(siteDir, backend, 'itemBaseVersions')
787
869
  }
870
+ /**
871
+ * Replace the item versions this copy holds — a push's (`heldTokens`) or a pull's
872
+ * (`pulledItemVersions`) whole set. ⛔ Not a merge: an item that left the set must
873
+ * leave the map, or its version is sent again and asks the backend to delete it.
874
+ */
875
+ export function writeItemBaseVersions(siteDir, backend, versions) {
876
+ if (!versions || typeof versions !== 'object') return
877
+ updateSyncCache(siteDir, backend, { itemBaseVersions: { ...versions } })
878
+ }
788
879
  export function mergeItemBaseVersions(siteDir, backend, versions) {
789
880
  if (!versions || !Object.keys(versions).length) return
790
881
  // ⛔ The read names the backend. Without it the read keyed nothing, came back `{}`,
@@ -1933,7 +2024,11 @@ export async function pushSyncPackages({
1933
2024
  // for choosing between pulling and forcing.
1934
2025
  const detail = await explainStale()
1935
2026
  for (const line of detail) note(line)
1936
- if (!detail.length) {
2027
+ // ⭐ Items no file path names — a service, a query — come named by their Section's
2028
+ // key (`stale_keys`, `{ section, key: { name } }`), so say them by name.
2029
+ const keyed = describeStaleKeys(problem.stale_keys)
2030
+ for (const line of keyed) note(line)
2031
+ if (!detail.length && !keyed.length) {
1937
2032
  const stale = Array.isArray(problem.stale_entities)
1938
2033
  ? problem.stale_entities
1939
2034
  : []
@@ -2079,9 +2174,11 @@ export async function pushSyncPackages({
2079
2174
  // otherwise the next attempt would re-send lane 1 with a base the backend has
2080
2175
  // already moved past, and refuse a push the user just made.
2081
2176
  const newVersions = {}
2082
- // Per-item tokens from the SAME response. Keyed by record `$uuid` and flat
2083
- // across entities (the cache is a single map), unlike `newVersions`, which is
2084
- // keyed by entity uuid.
2177
+ // Per-item tokens from the SAME response — the site-content entity's, keyed by item
2178
+ // `$uuid`: the whole set this copy now holds (`heldTokens`), which replaces the one
2179
+ // it held. Null until that lane lands, which leaves the held set as it was.
2180
+ // ⛔ The records lane's are not kept: that lane is gated by its entity version, and
2181
+ // until 2026-10-07 they were merged into the same map, which was sent for nothing.
2085
2182
  //
2086
2183
  // ⛔ Both grains must be re-armed from the push, for one reason: a push writes,
2087
2184
  // so every token this clone holds for a record it just changed is now stale. Read
@@ -2094,22 +2191,19 @@ export async function pushSyncPackages({
2094
2191
  // exact shape one grain up (see delivery-lane.md "Both feed directions are
2095
2192
  // load-bearing"); the item grain had the same hole until backend `d7e46335`
2096
2193
  // started echoing `item_versions` here.
2097
- const newItemVersions = {}
2194
+ let heldItemVersions = null
2098
2195
  const harvest = (finalized) => {
2099
2196
  for (const f of finalized || []) {
2197
+ // NOT gated on `changed`: the backend pins "zero-write ⇒ version unmoved", so a
2198
+ // no-op resubmit hands back the value we already hold. (The site-content lane
2199
+ // filters first, `harvestSiteContent`.)
2100
2200
  if (f.uuid && f.version) newVersions[f.uuid] = f.version
2101
- // NOT gated on `changed` — same rule as the entity token: the backend pins
2102
- // "zero-write ⇒ version unmoved", so a no-op resubmit hands back the value we
2103
- // already hold. (The site-content lane filters first, `harvestSiteContent`.)
2104
- // An older backend omits the field entirely, which leaves the cached tokens
2105
- // alone and degrades to the entity grain, exactly as before.
2106
- if (f.itemVersions) Object.assign(newItemVersions, f.itemVersions)
2107
2201
  }
2108
2202
  }
2109
2203
  // Both grains land together, at every point the old code banked the entity one.
2110
2204
  const mergeHarvested = () => {
2111
2205
  mergeBaseVersions(siteDir, client.origin, newVersions)
2112
- mergeItemBaseVersions(siteDir, client.origin, newItemVersions)
2206
+ if (heldItemVersions) writeItemBaseVersions(siteDir, client.origin, heldItemVersions)
2113
2207
  }
2114
2208
  // ⛔ The site-content lane banks only the tokens for content this copy holds — the
2115
2209
  // document we sent against the one the backend stored (`heldTokens`, and the two
@@ -2124,9 +2218,11 @@ export async function pushSyncPackages({
2124
2218
  sent: sentSiteDoc,
2125
2219
  written: f.document,
2126
2220
  version: f.version,
2127
- itemVersions: f.itemVersions
2221
+ itemVersions: f.itemVersions,
2222
+ held: heldItemVersions ?? readItemBaseVersions(siteDir, client.origin)
2128
2223
  })
2129
- harvest([{ ...f, version: held.version, itemVersions: held.itemVersions }])
2224
+ harvest([{ ...f, version: held.version }])
2225
+ if (held.itemVersions) heldItemVersions = held.itemVersions
2130
2226
  heldUnits.push(...held.held)
2131
2227
  notHeld.kept.push(...held.kept)
2132
2228
  notHeld.foreign.push(...held.foreign)
@@ -2153,6 +2249,17 @@ export async function pushSyncPackages({
2153
2249
  if (siteFinalizedDoc) {
2154
2250
  const recordIds = collectQueryUuids(siteFinalizedDoc)
2155
2251
  if (Object.keys(recordIds).length) writeQueryUuids(siteDir, client.origin, recordIds)
2252
+ // ⭐ THE SERVICES THIS COPY NOW HOLDS, `{ name: $uuid }`: those the push stated and
2253
+ // those held before, as the site returned them (`heldServices`). A service added on
2254
+ // the site since this copy's last pull is left out — held, the next push would state
2255
+ // it off, switching off a service nobody here has seen. Replaced, not merged.
2256
+ updateBackendState(siteDir, client.origin, {
2257
+ services: heldServices({
2258
+ written: siteFinalizedDoc.services,
2259
+ sent: Array.isArray(sentSiteDoc?.services) ? sentSiteDoc.services : [],
2260
+ prior: readBackendState(siteDir, client.origin).services
2261
+ })
2262
+ })
2156
2263
  }
2157
2264
  // Re-base the page attribution: our emitted document and the backend's post-write
2158
2265
  // copy of it are the two sides' new agreed state.
@@ -19,6 +19,7 @@ import {
19
19
  } from '@uniweb/build'
20
20
  import { loadDeployYml, AGENTS_KEYS } from '@uniweb/build/site'
21
21
  import { listAdapters } from '@uniweb/build/hosts'
22
+ import { RUNTIME_KEYS } from '@uniweb/build/uwx'
22
23
  import { getCliVersion } from '../versions.js'
23
24
  import { readAgentsVersion } from '../utils/agents-stamp.js'
24
25
  import { writeJsonPreservingStyle } from '../utils/json-file.js'
@@ -388,17 +389,12 @@ function nearestKnownKey(input, known) {
388
389
  }
389
390
 
390
391
  /**
391
- * The options `tracking:` actually has a reader.
392
- *
393
- * ⚠️ **Kept here rather than imported, because nothing at runtime enumerates
394
- * them** — `wireTracker` reads named properties off the resolved declaration, it
395
- * does not iterate a list. So there is no existing array to import and this
396
- * duplicates nothing. It does mean the list can drift: the readers are
397
- * `runtime/src/wire-foundation.js::wireTracker` (`consent`, `scripts`, `debug`)
398
- * and `core/src/services.js::readEndpoint` (`endpoint`). Add a key there, add it
399
- * here.
392
+ * The options `services.tracking` has a reader for — the build's list, which also
393
+ * decides what a push sends the host as its settings (`RUNTIME_KEYS`, `@uniweb/build`).
394
+ * ⛔ *Until 2026-10-06 doctor kept a copy of its own, which had drifted: it lacked
395
+ * `emit` and `flushIntervalMs`, which `wireTracker` reads.*
400
396
  */
401
- const TRACKING_KEYS = ['endpoint', 'consent', 'scripts', 'debug']
397
+ const TRACKING_KEYS = RUNTIME_KEYS.tracking
402
398
 
403
399
  /**
404
400
  * Spellings that read as an ATTEMPT to require consent but do not require it.
@@ -414,10 +410,10 @@ const TRACKING_KEYS = ['endpoint', 'consent', 'scripts', 'debug']
414
410
  const CONSENT_NEAR_MISSES = new Set(['require', 'requires', 'required.', 'true', 'yes', 'on', '1'])
415
411
 
416
412
  /**
417
- * `tracking:` — flag the keys and values that are carried and never acted on.
413
+ * `services.tracking` — flag the keys and values that are carried and never acted on.
418
414
  *
419
- * The block is forwarded to the host as opaque data and resolved at render, so
420
- * nothing downstream rejects a mistake in it. Every error here therefore fails
415
+ * A key the site's tracking does not read goes to the host as a setting, and the
416
+ * rest is resolved at render, so nothing downstream rejects a mistake in it. Every error here therefore fails
421
417
  * the same way: **silently, at a visitor's browser, as an absence** — which is
422
418
  * also exactly what a site that configured nothing looks like. There is no
423
419
  * symptom to notice and nothing to grep for.
@@ -425,8 +421,8 @@ const CONSENT_NEAR_MISSES = new Set(['require', 'requires', 'required.', 'true',
425
421
  * ⭐ That is the whole argument for checking it at `doctor` time: it is the only
426
422
  * moment in the chain where a person who can fix it is looking at it.
427
423
  *
428
- * A bare `tracking: <url>` string is the documented shorthand and carries no
429
- * options — there is nothing in it to be wrong.
424
+ * An address, `true` or `false` carries no options — there is nothing in it to be
425
+ * wrong.
430
426
  */
431
427
  /**
432
428
  * `uniweb doctor` — is `package.json` behind what the build derived?
@@ -806,8 +802,8 @@ export function checkUngatedServiceControls({ foundationName, folderName, srcDir
806
802
  }
807
803
 
808
804
  export function checkTrackingBlock({ siteName, siteYml, issues }) {
809
- const tracking = siteYml?.tracking
810
- if (tracking === undefined || tracking === null) return
805
+ const tracking = siteYml?.services?.tracking
806
+ if (tracking === undefined || tracking === null || typeof tracking === 'boolean') return
811
807
  if (typeof tracking === 'string') return
812
808
 
813
809
  if (typeof tracking !== 'object' || Array.isArray(tracking)) {
@@ -816,9 +812,9 @@ export function checkTrackingBlock({ siteName, siteYml, issues }) {
816
812
  id,
817
813
  type: 'warning',
818
814
  site: siteName,
819
- message: `site.yml: \`tracking:\` should be an endpoint string, or a map of options`
815
+ message: `site.yml: \`services.tracking\` should be true, false, an address, or a map of options`
820
816
  })
821
- warn(`[${id}] ${siteName}: \`tracking:\` is neither an endpoint string nor a map of options.`)
817
+ warn(`[${id}] ${siteName}: \`services.tracking\` is neither true, false, an address nor a map of options.`)
822
818
  return
823
819
  }
824
820
 
@@ -830,10 +826,10 @@ export function checkTrackingBlock({ siteName, siteYml, issues }) {
830
826
  id,
831
827
  type: 'warning',
832
828
  site: siteName,
833
- message: `site.yml: \`tracking:\` has ${unknown.length === 1 ? 'an unknown key' : 'unknown keys'}: ${unknown.join(', ')}`
829
+ message: `site.yml: \`services.tracking\` has ${unknown.length === 1 ? 'an unknown key' : 'unknown keys'}: ${unknown.join(', ')}`
834
830
  })
835
831
  warn(
836
- `[${id}] ${siteName}: \`tracking:\` ${unknown.length === 1 ? 'key' : 'keys'} ${unknown
832
+ `[${id}] ${siteName}: \`services.tracking\` ${unknown.length === 1 ? 'key' : 'keys'} ${unknown
837
833
  .map((k) => `'${k}'`)
838
834
  .join(', ')} ${unknown.length === 1 ? 'is' : 'are'} not recognized.`
839
835
  )
@@ -842,7 +838,7 @@ export function checkTrackingBlock({ siteName, siteYml, issues }) {
842
838
  if (near) log(` ${colors.dim}'${key}' — did you mean ${colors.reset}${colors.green}${near}${colors.reset}${colors.dim}?${colors.reset}`)
843
839
  }
844
840
  log(
845
- ` ${colors.dim}Carried to the host as opaque data and never read. Known: ${TRACKING_KEYS.join(', ')}.${colors.reset}`
841
+ ` ${colors.dim}Not an option the site's tracking reads — sent to your host as a setting. Known: ${TRACKING_KEYS.join(', ')}.${colors.reset}`
846
842
  )
847
843
  }
848
844
 
@@ -869,9 +865,9 @@ export function checkTrackingBlock({ siteName, siteYml, issues }) {
869
865
  id,
870
866
  type: 'warning',
871
867
  site: siteName,
872
- message: `site.yml: \`tracking.consent:\` is ${shown} — only the exact value \`required\` turns the gate on`
868
+ message: `site.yml: \`services.tracking.consent\` is ${shown} — only the exact value \`required\` turns the gate on`
873
869
  })
874
- warn(`[${id}] ${siteName}: \`tracking.consent:\` is ${shown}; the gate is OFF.`)
870
+ warn(`[${id}] ${siteName}: \`services.tracking.consent\` is ${shown}; the gate is OFF.`)
875
871
  log(
876
872
  ` ${colors.dim}Only \`consent: required\` holds events until a visitor answers. Anything else${colors.reset}`
877
873
  )
@@ -894,10 +890,10 @@ export function checkTrackingBlock({ siteName, siteYml, issues }) {
894
890
  id,
895
891
  type: 'warning',
896
892
  site: siteName,
897
- message: `site.yml: \`tracking.scripts:\` has ${bad.length} ${bad.length === 1 ? 'entry' : 'entries'} with no URL`
893
+ message: `site.yml: \`services.tracking.scripts\` has ${bad.length} ${bad.length === 1 ? 'entry' : 'entries'} with no URL`
898
894
  })
899
895
  warn(
900
- `[${id}] ${siteName}: ${bad.length} \`tracking.scripts:\` ${bad.length === 1 ? 'entry has' : 'entries have'} no URL and will not load.`
896
+ `[${id}] ${siteName}: ${bad.length} \`services.tracking.scripts\` ${bad.length === 1 ? 'entry has' : 'entries have'} no URL and will not load.`
901
897
  )
902
898
  log(
903
899
  ` ${colors.dim}An entry is a URL, or an object with a \`src\`. Anything else is dropped silently.${colors.reset}`
@@ -927,19 +923,19 @@ function editDistance(a, b) {
927
923
  /**
928
924
  * A site whose content declares a form needs somewhere to send it.
929
925
  *
930
- * Two things can supply that: `submit:` in site.yml, or the host at serve time.
931
- * Doctor can only see the first — so it warns only when *nothing* could
932
- * plausibly supply one: no declaration, and no deploy target that would put a
933
- * host in the picture. On a site bound to a host, having no `submit:` is the
934
- * correct configuration, and warning there would nag exactly the people who got
935
- * it right.
926
+ * Two things can supply that: `services.submit` in site.yml — asking the host for
927
+ * form handling, or naming the site's own — or the host at serve time. Doctor can
928
+ * only see the first — so it warns only when *nothing* could plausibly supply one:
929
+ * no entry, and no deploy target that would put a host in the picture. On a site
930
+ * bound to a host, an absent entry can be the correct configuration, and warning
931
+ * there would nag exactly the people who got it right.
936
932
  *
937
933
  * The consequence of being wrong in the other direction is what justifies the
938
- * check at all: a form with no destination renders disabled, which is visible
939
- * on the page but easy to ship without noticing.
934
+ * check at all: a form with no destination is not drawn, which is easy to ship
935
+ * without noticing.
940
936
  */
941
937
  export async function checkFormSubmitTarget({ sitePath, siteName, siteYml, issues }) {
942
- if (siteYml?.submit) return
938
+ if (siteYml?.services?.submit) return
943
939
 
944
940
  const forms = findFormContent(sitePath, siteYml)
945
941
  if (forms.length === 0) return
@@ -966,7 +962,7 @@ export async function checkFormSubmitTarget({ sitePath, siteName, siteYml, issue
966
962
  for (const f of forms.slice(0, 5)) log(` • ${f}`)
967
963
  if (n > 5) log(` ${colors.dim}…and ${n - 5} more${colors.reset}`)
968
964
  log(
969
- ` Set ${colors.green}submit${colors.reset} in site.yml if you are providing the endpoint.`
965
+ ` Ask your host for form handling — ${colors.green}services: { submit: true }${colors.reset} — or name your own: ${colors.green}services: { submit: https://… }${colors.reset}.`
970
966
  )
971
967
  log(
972
968
  ` ${colors.dim}A host that handles submissions supplies one itself — this check is skipped once a deploy target is configured.${colors.reset}`