uniweb 0.84.0 → 0.86.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.
@@ -5,36 +5,30 @@
5
5
  * Two of them, kept by two mechanisms:
6
6
  *
7
7
  * - ⭐ **The services** — `site.yml::services`, a map by service name *[Diego,
8
- * 2026-10-06]*. Sent as the site's own list with the owner's CHANGED asks applied,
9
- * decided per service by comparing the file, the site now and the record of the
10
- * last agreement in `sync.json` (`settleServices`; spec:
11
- * kb/framework/reference/site-services-request.md).
8
+ * 2026-10-06]*. A push STATES them — the file's, and off for each held one it no
9
+ * longer lists — and the backend decides per service from the versions sent
10
+ * (`statedServices`, `@uniweb/build/uwx`; spec:
11
+ * kb/framework/reference/site-services-request.md). All this module does for them
12
+ * is say what the file asks that will not be sent (`announceServices`).
12
13
  * - **The language selection** — `site.yml::publishLanguages` — told apart from the
13
14
  * status quo by a fingerprint banked in `deploy.yml` (`reconcile`, `bankLanguages`).
14
15
  *
15
16
  * ⭐ The model both follow — *"the services in `site.yml` are a request, never a
16
- * tracking of what is running"* [Diego, 2026-09-05]. You ask by CHANGING the file. An
17
- * unchanged ask is not re-sent: the backend REPLACES the services it is sent, so a
18
- * re-send would overwrite a decision the owner made in the app since.
17
+ * tracking of what is running"* [Diego, 2026-09-05]. You ask by CHANGING the file.
19
18
  *
20
19
  * ⛔ *From 2026-09-20 to 2026-10-06 the services lived only in `sync.json`, which
21
- * nobody edits, so a CLI user could ask for nothing; and this module compared them by
22
- * a fingerprint in `deploy.yml`, which could not tell who moved.*
20
+ * nobody edits, so a CLI user could ask for nothing; this module then compared them by
21
+ * a fingerprint in `deploy.yml`, which could not tell who moved; and until 2026-10-07
22
+ * it read the site's services before every push and settled each against a record in
23
+ * `sync.json` (`settleServices`), because the backend replaced the list it was sent.*
23
24
  *
24
25
  * @module
25
26
  */
26
27
 
27
28
  import { createHash } from 'node:crypto'
28
- import {
29
- readServicesRequest,
30
- takeServices,
31
- mergeServiceRows,
32
- reconcileServices,
33
- recordAfter,
34
- readBackendState,
35
- updateBackendState,
36
- writeSiteConfig
37
- } from '@uniweb/build/uwx'
29
+ import { existsSync, readFileSync } from 'node:fs'
30
+ import { join } from 'node:path'
31
+ import { readServicesRequest, unreadableServices } from '@uniweb/build/uwx'
38
32
 
39
33
  /**
40
34
  * A stable fingerprint of one declared value, or `null` when the key is absent.
@@ -123,142 +117,94 @@ export function bankLanguages(siteYml) {
123
117
  return langs ? { publishLanguagesRequest: langs } : {}
124
118
  }
125
119
 
126
- /** One service, as an owner reads it: `on`, `off`, `on (grade: pro)`. */
127
- function describeService(row) {
128
- if (!row || typeof row !== 'object') return 'nothing set'
129
- const state = row.enabled === false ? 'off' : 'on'
130
- const settings =
131
- row.config && typeof row.config === 'object' ? Object.entries(row.config) : []
132
- if (!settings.length) return state
133
- const shown = settings
134
- .map(([k, v]) => `${k}: ${v !== null && typeof v === 'object' ? '…' : String(v)}`)
135
- .join(', ')
136
- return `${state} (${shown})`
120
+ /**
121
+ * The services the site's foundation says it renders — the build's `_self.supports`, else
122
+ * `package.json::uniweb.supports` — or null when that is unknown: no local foundation, or
123
+ * one that declares nothing. ⛔ Absent is UNKNOWN, never "none".
124
+ *
125
+ * @param {string} siteDir
126
+ * @param {object} siteYml - parsed
127
+ * @returns {Promise<string[]|null>}
128
+ */
129
+ export async function foundationSupports(siteDir, siteYml) {
130
+ try {
131
+ if (!siteYml?.foundation) return null
132
+ const { detectFoundationType } = await import('@uniweb/build')
133
+ const found = detectFoundationType(siteYml.foundation, siteDir)
134
+ if (found?.type !== 'local' || !found.path) return null
135
+ const built = join(found.path, 'dist', 'meta', 'schema.json')
136
+ if (existsSync(built)) {
137
+ const derived = JSON.parse(readFileSync(built, 'utf8'))?._self?.supports
138
+ if (Array.isArray(derived)) return derived
139
+ }
140
+ const declared = JSON.parse(readFileSync(join(found.path, 'package.json'), 'utf8'))?.uniweb?.supports
141
+ return Array.isArray(declared) ? declared : null
142
+ } catch {
143
+ return null
144
+ }
137
145
  }
138
146
 
139
- const rowNamed = (rows, name) =>
140
- Array.isArray(rows) ? rows.find((r) => r && typeof r === 'object' && r.name === name) : undefined
147
+ const names = (list) => list.map((n) => `\`${n}\``).join(', ')
141
148
 
142
149
  /**
143
- * Decide what a push or publish sends of `site.yml::services` — and, once it has
144
- * succeeded, what the project records.
145
- *
146
- * Per service the file names, three states are compared: the file, the site now
147
- * (`status.services`), and the record of the last agreement (this backend's entry in
148
- * `sync.json`). What the owner changed is sent; what the site changed is kept, and
149
- * offered into `site.yml`; where both changed, the owner is asked. The list sent is the
150
- * site's own, with only the changed asks applied (`mergeServiceRows`) — so every other
151
- * service and setting the site holds is sent as it is stored.
152
- *
153
- * ⛔ An open decision — an offer declined, a conflict not resolved, or no terminal to
154
- * ask at — is never sent, and the record keeps the earlier agreement for it, so the
155
- * next run still sees the site's change rather than reading it as the file's.
150
+ * Say what `site.yml::services` asks that will not be sent as written — a credential,
151
+ * an entry that is not one, a `backend` address — and where it and the foundation disagree,
152
+ * before a push or publish sends the rest. What a push sends is the producer's
153
+ * (`statedServices`), from the same file.
154
+ *
155
+ * ⭐ The foundation INFORMS and never decides [Diego, 2026-10-07]: a service the file turns
156
+ * on that the foundation does not say it renders is said; so is one it renders that the
157
+ * file does not mention, since every service is off unless the site asks for it. An
158
+ * explicit `false` is a decision, and quiets the second. `tracking` is in neither — a
159
+ * foundation may not claim it, since it renders nothing — and `records` is left to
160
+ * publish, which knows whether the pages show any (`recordsNotAsked`).
156
161
  *
157
162
  * @param {object} p
158
- * @param {object} p.client - the backend client (`origin`, `siteStatus`)
159
- * @param {string} p.siteDir
160
- * @param {object} p.siteYml - parsed; updated in place when the owner takes the site's services
161
- * @param {object|null} [p.status] - the site's status, when the caller has just read it
162
- * @param {boolean} [p.offline] - read nothing from the backend (`--dry-run`, `-o`)
163
- * @param {boolean} [p.interactive] - whether the owner can be asked
164
- * @param {(message: string, initial?: boolean) => Promise<boolean>} p.confirm
165
- * @param {{ info: Function, warn: Function, dim: Function, ok: Function }} p.say
166
- * @returns {Promise<{ emit: object, after: () => void }>} `emit` — options for the
167
- * producer; `after` — call once the push succeeded (or had nothing to send)
163
+ * @param {object} p.siteYml - parsed
164
+ * @param {{ warn: Function }} p.say
165
+ * @param {string[]|null} [p.supports] - from `foundationSupports`; null = unknown
168
166
  */
169
- export async function settleServices({
170
- client,
171
- siteDir,
172
- siteYml,
173
- status,
174
- offline = false,
175
- interactive = false,
176
- confirm,
177
- say
178
- }) {
179
- const nothing = { emit: {}, after: () => {} }
180
- const asks = readServicesRequest(siteYml?.services, { warn: (m) => say.warn(m) })
181
- if (!asks) return nothing
182
-
183
- const state = readBackendState(siteDir, client.origin)
184
- const record = Array.isArray(state.services) ? state.services : undefined
185
- const siteUuid = state.site?.uuid || null
186
- let read = status
187
- if (read === undefined && !offline && siteUuid) read = await client.siteStatus(siteUuid)
188
- const stored = Array.isArray(read?.services) ? read.services : undefined
189
-
190
- const decision = reconcileServices({ asks, record, stored, siteKnown: Boolean(siteUuid) })
191
- if (decision.unreadable) {
192
- say.warn("site.yml asks for services, but this project has no record of your site's and could not read them, so none were sent.")
193
- say.dim(' Run `uniweb pull` to take them, then push again.')
194
- return nothing
167
+ export function announceServices({ siteYml, say, supports = null }) {
168
+ // An entry the push cannot read stops the package build next, which says what to write
169
+ // (`refuseUnreadableServices`). Nothing to add before it — and what this would say, read
170
+ // past that entry, is wrong: `search: yes` was "site.yml turns on `search`" (F14).
171
+ if (unreadableServices(siteYml?.services).length) return
172
+ const asks = readServicesRequest(siteYml?.services, { warn: (m) => say.warn(m) }) || []
173
+ if (!Array.isArray(supports)) return
174
+ const ignored = new Set(['tracking', 'records'])
175
+ const has = (ask) => ask.enabled !== false || typeof ask.config?.endpoint === 'string'
176
+ const unrendered = asks.filter((a) => has(a) && !ignored.has(a.name) && !supports.includes(a.name)).map((a) => a.name)
177
+ if (unrendered.length) {
178
+ say.warn(`site.yml turns on ${names(unrendered)}, which your foundation does not say it renders (\`uniweb.supports\`).`)
195
179
  }
196
-
197
- const askFor = (name) => asks.find((a) => a.name === name)
198
- const send = [...decision.send]
199
- let offered = [...decision.adopt]
200
- const open = []
201
-
202
- // ⛔ BOTH MOVED: only the owner can rank two of their own decisions. Not a stop — the
203
- // content they asked to push is a separate thing — and never a guess.
204
- if (decision.conflict.length) {
180
+ const mentioned = new Set(asks.map((a) => a.name))
181
+ const unasked = supports.filter((n) => !ignored.has(n) && !mentioned.has(n))
182
+ if (unasked.length) {
205
183
  say.warn(
206
- record
207
- ? "Your site's services and site.yml both changed since your last sync:"
208
- : 'site.yml asks for services your site has set differently:'
184
+ `Your foundation renders ${names(unasked)}, which site.yml does not ask for — a service is off ` +
185
+ `unless the site asks for it. Add ${unasked.length === 1 ? 'it' : 'them'} under \`services:\`, or set ` +
186
+ `${unasked.length === 1 ? 'it' : 'each'} to \`false\` if that is what you mean.`
209
187
  )
210
- for (const name of decision.conflict) {
211
- say.dim(` ${name}: site.yml asks ${describeService(askFor(name))} — your site has ${describeService(rowNamed(stored, name))}`)
212
- }
213
- if (!interactive) {
214
- say.dim(" Left as your site has them — run without --non-interactive to choose.")
215
- open.push(...decision.conflict)
216
- } else if (await confirm('Use the services in site.yml?', false)) {
217
- send.push(...decision.conflict)
218
- } else {
219
- // Declining to send is not yet a decision to take the site's: offered below.
220
- offered = [...offered, ...decision.conflict]
221
- }
222
- }
223
-
224
- // The site moved and the file did not: the file is behind. Offered, never done — a
225
- // push changes `site.yml` only when the owner says so.
226
- if (offered.length) {
227
- say.info("Your site's services changed since your last sync:")
228
- for (const name of offered) {
229
- say.dim(` ${name}: your site has ${describeService(rowNamed(stored, name))} — site.yml says ${describeService(askFor(name))}`)
230
- }
231
- if (interactive && (await confirm('Update site.yml to match?', false))) {
232
- const services = takeServices(siteYml.services, stored, offered)
233
- writeSiteConfig(siteDir, { services })
234
- if (services) siteYml.services = services
235
- else delete siteYml.services
236
- say.ok('site.yml updated.')
237
- } else {
238
- open.push(...offered)
239
- }
240
- }
241
-
242
- if (send.length) {
243
- say.info(`Asking for: ${send.map((n) => `${n} ${describeService(askFor(n))}`).join(', ')}`)
244
188
  }
189
+ }
245
190
 
246
- // The list to send: the site's rows with the changed asks applied. With the site
247
- // unreadable and nothing changed, nothing is sent — the record may be stale, and
248
- // sending it would be asking for what the site may have moved away from.
249
- const base = stored ?? record ?? (siteUuid ? undefined : [])
250
- if (!stored && !send.length) {
251
- return {
252
- emit: { declareServices: false },
253
- after: () => {}
254
- }
255
- }
256
- const rows = mergeServiceRows(base, asks.filter((a) => send.includes(a.name)))
257
- return {
258
- emit: { serviceRows: rows },
259
- after: () => {
260
- const next = recordAfter({ record, agreed: rows, open })
261
- if (next) updateBackendState(siteDir, client.origin, { services: next })
262
- }
263
- }
191
+ /**
192
+ * The publish warning for `records`: the site's pages show records live, and `site.yml`
193
+ * does not ask for the service that delivers them on a published site [Diego, 2026-10-07:
194
+ * "When records is off and the site is published with us, there is no meant to be a fall
195
+ * back at all"]. Syncing records does not depend on it, so push and pull say nothing.
196
+ *
197
+ * @param {object} p
198
+ * @param {object} p.siteYml - parsed
199
+ * @param {string[]} p.shown - from the package (`recordsShown`)
200
+ * @returns {string|null} the warning, or null
201
+ */
202
+ export function recordsNotAsked({ siteYml, shown }) {
203
+ if (!Array.isArray(shown) || !shown.length) return null
204
+ const records = (readServicesRequest(siteYml?.services) || []).find((a) => a.name === 'records')
205
+ if (records && records.enabled !== false) return null
206
+ return (
207
+ `Your pages show records from ${names(shown)}, but site.yml does not ask for \`records\` — ` +
208
+ 'a published site delivers them only with it on. Add `records: true` under `services:`.'
209
+ )
264
210
  }
@@ -46,7 +46,7 @@ export async function readSitePreview({ siteDir, args = [], client = null }) {
46
46
  refused: [
47
47
  `This site's foundation is ${ref}, and the backend the site is on says where that version is served.`,
48
48
  known.length
49
- ? `The site is on ${known.join(', ')} — not on ${client.origin}, the backend you are logged in to. To preview it: uniweb login --backend <url>`
49
+ ? `The site is on ${known.join(', ')} — not on ${client.origin}, the backend you are logged in to. To preview it: uniweb login --server <url>`
50
50
  : `The site is on no backend yet: \`uniweb push\` puts it on ${client.origin}.`
51
51
  ]
52
52
  }