uniweb 0.81.2 → 0.82.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "uniweb",
3
- "version": "0.81.2",
3
+ "version": "0.82.0",
4
4
  "description": "Create structured Vite + React sites with content/code separation",
5
5
  "type": "module",
6
6
  "bin": {
@@ -42,15 +42,15 @@
42
42
  "prompts": "^2.4.2",
43
43
  "tar": "^7.0.0",
44
44
  "@uniweb/content-writer": "^0.3.4",
45
- "@uniweb/core": "^0.36.0",
46
- "@uniweb/kit": "^0.19.15",
47
- "@uniweb/runtime": "^0.28.10",
45
+ "@uniweb/kit": "^0.19.16",
46
+ "@uniweb/core": "^0.37.0",
48
47
  "@uniweb/semantic-parser": "^1.5.1",
49
- "@uniweb/schemas": "^0.10.2"
48
+ "@uniweb/schemas": "^0.11.0",
49
+ "@uniweb/runtime": "^0.29.0"
50
50
  },
51
51
  "peerDependencies": {
52
- "@uniweb/build": "^0.75.1",
53
52
  "@uniweb/content-reader": "^1.2.8",
53
+ "@uniweb/build": "^0.76.0",
54
54
  "@uniweb/semantic-parser": "^1.5.1"
55
55
  },
56
56
  "peerDependenciesMeta": {
@@ -1762,7 +1762,7 @@ export default function DocsLayout({ header, body, footer, left, right, params }
1762
1762
 
1763
1763
  Layouts are full components with their own `params` in `meta.js`, not just structural wrappers — a header height or sidebar width is a layout param.
1764
1764
 
1765
- **Layout `meta.js`** declares areas and optional scroll behavior: `{ areas: ['header', 'footer', 'left'], scroll: 'self' }`. Area names are arbitrary. `scroll` controls scroll restoration: unset = runtime manages `window` (default), `'self'` = the layout scrolls itself, or a CSS selector (`'main'`) = runtime manages that element.
1765
+ **Layout `meta.js`** declares areas and optional scroll behavior: `{ areas: ['header', 'footer', 'left'], scroll: 'self' }`. Area names are arbitrary. `areas` tells an editor what to offer; a page renders the areas its site's `layout/` folder fills. `scroll` controls scroll restoration: unset = runtime manages `window` (default), `'self'` = the layout scrolls itself, or a CSS selector (`'main'`) = runtime manages that element.
1766
1766
 
1767
1767
  Two optional keys tune how areas behave across a navigation. `transitions` renames or opts regions out of per-area view transitions (`{ left: null }`, or `false` for the whole layout). `layers` sets which area paints on top — every area is stacked above the body by default, so a fixed header works without declaring anything, but areas are equal to each other, so a layout whose chrome overlaps says which wins: `layers: { header: 2, left: 1 }`. Both take an object to override per region, or `false` to opt out. **A `z-index` inside an area cannot lift it past another area** — each area is its own stacking context — so reach for `layers` rather than a bigger number, and for `<Overlay>` when a modal needs to escape the area entirely.
1768
1768
 
@@ -1890,7 +1890,7 @@ function Article({ content, block }) {
1890
1890
 
1891
1891
  When a record genuinely needs to be a single object, that's the foundation's job — read `[0]`, or reshape once with a `handlers.data` hook.
1892
1892
 
1893
- **Declaring keys and schemas.** `meta.js` declares each `content.data` key the component receives, with its schema, in a single `data:` field — there is no separate `schemas:` key. Each value is a **named ref**, an **inline field map**, an **inline rich-form** (`{ fields: [...] }`, an editor form), or `{}` for a key with no schema (an external API's records). Refs resolve on disk at build time, never fetched: `@/name` (this foundation's `schemas/`), `@std/name` (shared standards, from `@uniweb/schemas`), `@org/name` (an org's own `@org/schemas` package). A schema supplies field defaults, drives the editor, and lets a fetch of another name fill the key. ⛔ **Delivery was default-on until this change** — every fetched key reached every component, and `data:` was a hint; a component that reads a key it does not declare now receives nothing under it. `data: false` declares nothing, like no `data:`. Keys a foundation's handlers (or a shared hook) read go in `main.js` `data:`, in the same form, and every section receives them.
1893
+ **Declaring keys and schemas.** `meta.js` declares each `content.data` key the component receives, with its schema, in a single `data:` field — there is no separate `schemas:` key. Each value is a **named ref**, an **inline field map**, an **inline rich-form** (`{ fields: [...] }`, an editor form), or `{}` for a key with no schema (an external API's records). Refs resolve on disk at build time, never fetched: `@/name` (this foundation's `schemas/`), `@std/name` (shared standards, from `@uniweb/schemas`), `@org/name` (an org's own `@org/schemas` package). A schema drives the editor, checks your data (`uniweb validate`), and lets a fetch of another name fill the key — it fills no field: **a record reaches the component as it is**, so a field it lacks is absent, and what that renders as is your component's choice (`record.status ?? 'published'`). **A named schema declares no `default:`** — the build refuses one; a `default:` belongs only in an inline field map or form, where an editor pre-fills from it. ⛔ **Delivery was default-on until this change** — every fetched key reached every component, and `data:` was a hint; a component that reads a key it does not declare now receives nothing under it. `data: false` declares nothing, like no `data:`. Keys a foundation's handlers (or a shared hook) read go in `main.js` `data:`, in the same form, and every section receives them.
1894
1894
 
1895
1895
  ```js
1896
1896
  // meta.js
@@ -1898,7 +1898,7 @@ export default {
1898
1898
  data: {
1899
1899
  articles: '@/article', // named ref (this foundation)
1900
1900
  authors: '@std/person', // named ref (shared standard)
1901
- pricing: { tier: { type: 'string', default: '' } }, // inline field map
1901
+ pricing: { tier: { type: 'string' } }, // inline field map
1902
1902
  },
1903
1903
  }
1904
1904
  ```
@@ -2415,7 +2415,7 @@ Content handlers are a transform layer between data assembly and the component,
2415
2415
  | `content` | After the data handler | `(data, block)` | ProseMirror document, or null | Transform raw content (Loom instantiation, template expansion) |
2416
2416
  | `props` | After parsing, defaults, and guarantees | `(content, params, block)` | `{ content, params }`, or null | Post-process the final shape before the component sees it |
2417
2417
 
2418
- The `content` handler receives `block.parsedContent.data` — the section's declared keys, its foundation's `main.js` `data:` included, so **declare there every key a handler reads** — and reads raw ProseMirror from `block.rawContent`, returning a new ProseMirror document that the framework re-parses through the semantic parser. Returning `null` — or the same reference as `block.rawContent` — signals no change.
2418
+ The `content` handler receives `block.parsedContent.data` — the section's declared keys, its foundation's `main.js` `data:` included, so **declare there every key a handler reads** — and reads raw ProseMirror from `block.rawContent`, returning a new ProseMirror document that the framework re-parses through the semantic parser. Returning `null` — or the same reference as `block.rawContent` — signals no change. Anything that is not a document is ignored, with a warning once per handler: the first argument is the section's *data*, so a pass-through `(x) => x` returns the data, not the content.
2419
2419
 
2420
2420
  > **`block.rawContent` may or may not be wrapped.** Unwrap it defensively — `const doc = block.rawContent?.doc ?? block.rawContent` — before passing it to `instantiateContent` / `instantiateRepeated`. This is the first thing a hand-written handler gets wrong.
2421
2421
 
@@ -748,8 +748,14 @@ export class BackendClient {
748
748
  * @param {string[]} [opts.languages]
749
749
  * @returns {Promise<Response>}
750
750
  */
751
- async publishSite(uuid, { languages } = {}) {
752
- return this.request(`/dev/site/publish/${encodeURIComponent(uuid)}`, {
751
+ async publishSite(uuid, { languages, confirm, unattended = false } = {}) {
752
+ // `confirm`: the token of a confirm the backend offered on a refused publish, sent back as it
753
+ // came, to settle a change to the site's plan and publish in one call. `unattended`: no one
754
+ // answered it — `--yes` did, which the backend settles for a reduction and nothing else.
755
+ const query = confirm
756
+ ? `?confirm=${encodeURIComponent(confirm)}${unattended ? '&unattended=true' : ''}`
757
+ : ''
758
+ return this.request(`/dev/site/publish/${encodeURIComponent(uuid)}${query}`, {
753
759
  method: 'POST',
754
760
  ...(languages ? { body: JSON.stringify({ languages }) } : {})
755
761
  })
@@ -91,8 +91,8 @@ const OPENABLE = /^https?:\/\//i
91
91
  * @param {string} [o.contentType] - the response's content-type header
92
92
  * @param {string} [o.body] - the raw response body
93
93
  * @returns {{ kind: 'not-payment' }
94
- * | { kind: 'settle', url: string, handle: string|null, reason: string, message: string|null }
95
- * | { kind: 'stop', reason: string|null, message: string|null }}
94
+ * | { kind: 'settle', url: string, handle: string|null, reason: string, message: string|null, confirm: { prompt: string, token: string, reduction: boolean }|null }
95
+ * | { kind: 'stop', reason: string|null, message: string|null, confirm: { prompt: string, token: string, reduction: boolean }|null }}
96
96
  */
97
97
  export function readPaymentRefusal({ status, contentType = '', body = '' } = {}) {
98
98
  if (status !== 402) return { kind: 'not-payment' }
@@ -134,7 +134,17 @@ export function readPaymentRefusal({ status, contentType = '', body = '' } = {})
134
134
  // problem body carries the number, so the two are not distinguishable by their
135
135
  // fields. Reading a URL out of a body that never promised this envelope is how
136
136
  // a `settle` gets synthesised from something that is not a refusal at all.
137
- if (!isProblem) return { kind: 'stop', reason, message }
137
+ if (!isProblem) return { kind: 'stop', reason, message, confirm: null }
138
+
139
+ // ⭐ A CHANGE THE TERMINAL MAY CONFIRM (since 2026-10-03): `{ prompt, token, reduction }`
140
+ // beside the door, when everything the publish owes is a change to a plan the site already has.
141
+ // `prompt` is the backend's sentence, shown verbatim; `token` goes back as it came. Read only
142
+ // whole — a confirm missing either half is not one.
143
+ const confirm =
144
+ problem.confirm && typeof problem.confirm.prompt === 'string' && problem.confirm.prompt &&
145
+ typeof problem.confirm.token === 'string' && problem.confirm.token
146
+ ? { prompt: problem.confirm.prompt, token: problem.confirm.token, reduction: problem.confirm.reduction === true }
147
+ : null
138
148
 
139
149
  // The door, if one was handed over. `remedy_url` is the current spelling — ONE
140
150
  // key for every reason, which is what lets a reason we have never heard of still
@@ -160,7 +170,7 @@ export function readPaymentRefusal({ status, contentType = '', body = '' } = {})
160
170
  // the platform's URL opener; a `file:` or a `javascript:` URL is not a place a
161
171
  // person goes. Refusing them costs a legitimate backend nothing.
162
172
  const url = candidate && OPENABLE.test(candidate) ? candidate : null
163
- if (!url) return { kind: 'stop', reason, message }
173
+ if (!url) return { kind: 'stop', reason, message, confirm }
164
174
 
165
175
  return {
166
176
  kind: 'settle',
@@ -170,7 +180,8 @@ export function readPaymentRefusal({ status, contentType = '', body = '' } = {})
170
180
  ? legacy.handle
171
181
  : null,
172
182
  reason,
173
- message
183
+ message,
184
+ confirm
174
185
  }
175
186
  }
176
187
 
@@ -187,9 +198,11 @@ export function readPaymentRefusal({ status, contentType = '', body = '' } = {})
187
198
  * @param {string[]} o.args - argv slice (for --non-interactive detection)
188
199
  * @param {object} o.say - { ok, info, warn, err, dim } reporters
189
200
  * @param {(url: string) => Promise<boolean>} [o.open] - injected for tests
201
+ * @param {boolean} [o.interactive] - whether someone is at the terminal, when the caller has decided
202
+ * it already (`settleRefusal`); else read from `args` and stdin
190
203
  * @returns {Promise<{ opened: boolean }>}
191
204
  */
192
- export async function reportPaymentRefusal({ verdict, args = [], say, open }) {
205
+ export async function reportPaymentRefusal({ verdict, args = [], say, open, interactive }) {
193
206
  // The backend's own sentence is the HEADLINE when there is one. A generic
194
207
  // lead would be wrong as often as right — "payment is required" does not
195
208
  // describe a declined card — and `detail` is written for this reader.
@@ -201,24 +214,149 @@ export async function reportPaymentRefusal({ verdict, args = [], say, open }) {
201
214
  return { opened: false }
202
215
  }
203
216
 
204
- const { isNonInteractive } = await import('../utils/interactive.js')
205
- if (isNonInteractive(args)) {
217
+ if (interactive === undefined) {
218
+ const { isNonInteractive } = await import('../utils/interactive.js')
219
+ interactive = !isNonInteractive(args)
220
+ }
221
+ if (!interactive) {
206
222
  say.dim(`Finish this in a browser, then re-run \`uniweb publish\`:`)
207
223
  say.dim(` ${verdict.url}`)
208
224
  return { opened: false }
209
225
  }
210
226
 
227
+ return openDoor({ url: verdict.url, say, open })
228
+ }
229
+
230
+ /** Open the backend's door, verbatim, and say how to come back. */
231
+ async function openDoor({ url, say, open }) {
211
232
  const openBrowser = open || (await import('../utils/registry-auth.js')).openBrowser
212
- // ⛔ Reason-agnostic wording. The backend's own `detail` above carries the
213
- // specifics; a lead sentence naming payment would be wrong the moment a refusal
214
- // is a quota or an unverified domain — and the reason set is open by design.
233
+ // ⛔ Reason-agnostic wording. The backend's own `detail` carries the specifics;
234
+ // a lead sentence naming payment would be wrong the moment a refusal is a quota
235
+ // or an unverified domain — and the reason set is open by design.
215
236
  say.info('Opening your browser to finish this…')
216
- say.dim(` ${verdict.url}`)
237
+ say.dim(` ${url}`)
217
238
  // VERBATIM. Nothing is appended — see the header.
218
- const opened = await openBrowser(verdict.url)
239
+ const opened = await openBrowser(url)
219
240
  if (!opened) {
220
241
  say.warn('Could not open a browser automatically — open the URL above.')
221
242
  }
222
243
  say.dim('Once that is done, re-run `uniweb publish`.')
223
244
  return { opened }
224
245
  }
246
+
247
+ const CONFIRM = 'Confirm and publish'
248
+ const CONTINUE = 'Continue and publish'
249
+ const OPEN = 'Open it in the app'
250
+ const CANCEL = 'Cancel'
251
+
252
+ // A reduction offered again and again, with no one to ask, is answered this many times and no more.
253
+ const MAX_UNATTENDED = 3
254
+
255
+ /**
256
+ * ⭐ A REFUSAL THE TERMINAL MAY SETTLE (since 2026-10-03). When everything a publish owes is a change
257
+ * to a plan the site already has, the `402` carries `confirm: { prompt, token, reduction }` beside its
258
+ * door, and the backend's sentence is asked at an interactive terminal: confirm, open the app, or
259
+ * cancel. Confirming publishes again with the token (`?confirm=`):
260
+ *
261
+ * - `200` — settled and published: the response is returned, for the caller to finish with.
262
+ * - `402` with a fresh `confirm` — the change or its price moved and nothing was applied: asked again,
263
+ * with the new sentence.
264
+ * - `402` without one — what is owed can no longer be confirmed here (it now asks for a first plan,
265
+ * or the plan's card can no longer be named): its sentence, and its door. ⛔ Not a declined card —
266
+ * a confirmed change is invoiced as it applies, and a decline later shows in the app's billing.
267
+ * *(This said "the card could not settle it" until the day it was written, 2026-10-03.)*
268
+ *
269
+ * ⭐ **A CHARGE AND A REDUCTION ARE ASKED DIFFERENTLY** (`reduction`, a field the backend keeps
270
+ * stable). A charge is confirmed only by someone at the terminal: Enter cancels, no flag answers it,
271
+ * and with `--yes` — which promises never to block on a prompt — it is not asked either, but reported
272
+ * with the way to confirm it. A reduction — the offer only lowers what the site pays — asks lightly: Enter continues, and
273
+ * `--yes` answers it without asking, with or without a terminal, telling the backend it was answered
274
+ * unattended (`&unattended=true`), which settles a reduction and nothing else. *[Diego, 2026-10-03:
275
+ * "allow it for reductions only, keep charges interactive".]*
276
+ *
277
+ * With no one at the terminal and nothing to answer it, there is no prompt: the refusal is reported as
278
+ * before, its door printed, and the publish fails. A refusal with no `confirm` behaves as it always has.
279
+ *
280
+ * @param {object} o
281
+ * @param {ReturnType<typeof readPaymentRefusal>} o.verdict - the first refusal
282
+ * @param {string[]} o.args - argv slice (for `--yes` and --non-interactive detection)
283
+ * @param {object} o.say - { ok, info, warn, err, dim } reporters
284
+ * @param {(token: string, options?: { unattended?: boolean }) => Promise<Response>} o.republish - the
285
+ * same publish, with `?confirm=<token>` (and `&unattended=true` when no one answered)
286
+ * @param {boolean} [o.interactive] - whether someone is at the terminal; read from `args` and stdin
287
+ * @param {boolean} [o.yes] - whether `--yes` answers a reduction; read from `args`
288
+ * @param {(question: string, options: string[], fallback: string) => Promise<string>} [o.ask]
289
+ * @param {(url: string) => Promise<boolean>} [o.open]
290
+ * @returns {Promise<{ exitCode: number } | { response: Response, body?: string }>} `response` when
291
+ * the publish went through, or failed for a reason that is not payment (with its body, read)
292
+ */
293
+ export async function settleRefusal({ verdict, args = [], say, republish, interactive, yes, ask, open }) {
294
+ if (interactive === undefined) {
295
+ const { isNonInteractive } = await import('../utils/interactive.js')
296
+ interactive = !isNonInteractive(args)
297
+ }
298
+ if (yes === undefined) yes = args.includes('--yes')
299
+ let current = verdict
300
+ let unattended = 0
301
+ for (;;) {
302
+ const offer = current.confirm
303
+ const reduction = offer?.reduction === true
304
+ let answered
305
+
306
+ if (reduction && yes && unattended < MAX_UNATTENDED) {
307
+ // `--yes` answers a change that only lowers the bill — said, so the log shows what was accepted.
308
+ unattended++
309
+ say.info(offer.prompt)
310
+ say.dim('Continuing: `--yes` answers a change that only lowers what the site pays.')
311
+ answered = { unattended: true }
312
+ } else if (!offer || !interactive || (yes && !reduction)) {
313
+ await reportPaymentRefusal({ verdict: current, args, say, open, interactive: interactive && !yes })
314
+ if (offer && !reduction) {
315
+ say.dim('This change charges the site\'s card, so only you can confirm it: run `uniweb publish` at a terminal, without `--yes`.')
316
+ } else if (reduction) {
317
+ say.dim('This change only lowers what the site pays: `uniweb publish --yes` continues it without a prompt.')
318
+ }
319
+ return { exitCode: 1 }
320
+ } else {
321
+ const go = reduction ? CONTINUE : CONFIRM
322
+ const choices = [go, ...(current.kind === 'settle' ? [OPEN] : []), CANCEL]
323
+ // Enter continues a reduction and cancels a charge.
324
+ const fallback = reduction ? CONTINUE : CANCEL
325
+ const pick = ask
326
+ ? await ask(offer.prompt, choices, fallback)
327
+ : await (await import('../utils/interactive.js')).pickOne(offer.prompt, choices, fallback)
328
+
329
+ if (pick === OPEN) {
330
+ await openDoor({ url: current.url, say, open })
331
+ return { exitCode: 1 }
332
+ }
333
+ if (pick !== go) {
334
+ say.dim('Cancelled — nothing changed, and nothing was made live. The site is synced as a draft.')
335
+ return { exitCode: 1 }
336
+ }
337
+ answered = {}
338
+ }
339
+
340
+ let response
341
+ try {
342
+ response = await republish(offer.token, answered)
343
+ } catch (err) {
344
+ say.err(String(err?.message || err))
345
+ return { exitCode: 1 }
346
+ }
347
+ if (response.ok) return { response }
348
+
349
+ const body = await response.text().catch(() => '')
350
+ const next = readPaymentRefusal({
351
+ status: response.status,
352
+ contentType: response.headers?.get?.('content-type') || '',
353
+ body
354
+ })
355
+ if (next.kind === 'not-payment') return { response, body }
356
+ if (next.confirm) {
357
+ // Nothing was applied: the change or its price moved since the offer. Ask again.
358
+ say.warn('The change or its price moved since that offer, so nothing was applied.')
359
+ }
360
+ current = next
361
+ }
362
+ }
@@ -249,10 +249,7 @@ async function buildFoundation(projectDir, options = {}) {
249
249
  log('')
250
250
  log(`${colors.bright}Share with clients:${colors.reset}`)
251
251
  log(
252
- ` ${colors.bright}uniweb publish${colors.reset} Register your foundation (one-time setup)`
253
- )
254
- log(
255
- ` ${colors.bright}uniweb handoff <email>${colors.reset} Hand off a site to a client`
252
+ ` ${colors.bright}uniweb register${colors.reset} Release your foundation to the catalog (alias: uniweb release)`
256
253
  )
257
254
  }
258
255
 
@@ -1026,9 +1023,6 @@ function showNextSteps(hasFoundations, hasSites) {
1026
1023
  log(
1027
1024
  ` ${colors.bright}uniweb register${colors.reset} Release your foundation to the catalog (alias: uniweb release)`
1028
1025
  )
1029
- log(
1030
- ` ${colors.bright}uniweb handoff <email>${colors.reset} Hand off a site to a client`
1031
- )
1032
1026
  }
1033
1027
  if (hasSites) {
1034
1028
  log('')
@@ -112,7 +112,7 @@ import {
112
112
  } from '../backend/foundation-bring-along.js'
113
113
  import {
114
114
  readPaymentRefusal,
115
- reportPaymentRefusal
115
+ settleRefusal
116
116
  } from '../backend/payment-handoff.js'
117
117
  import { reportSchemalessQueries } from '../utils/schemaless-report.js'
118
118
  import {
@@ -1050,31 +1050,43 @@ export async function publish(args = []) {
1050
1050
  say.dim('Is that the backend you meant? Switch with: uniweb login --backend <url>')
1051
1051
  return { exitCode: 1 }
1052
1052
  }
1053
+ // The body of a publish that did not go through — read once, and kept for the report below.
1054
+ let failedBody = ''
1053
1055
  if (!pubRes.ok) {
1054
- const body = await pubRes.text().catch(() => '')
1056
+ failedBody = await pubRes.text().catch(() => '')
1055
1057
 
1056
1058
  // A 402 is the backend's payment gate — the ONLY gate, evaluated here on
1057
1059
  // every publish against whatever posture that deployment runs. It is a
1058
1060
  // refusal, not a fault: the content is already synced as a draft, so the
1059
1061
  // recovery is to settle and re-run. Give it the backend's own sentence
1060
- // rather than the raw envelope.
1062
+ // rather than the raw envelope — and when it offers a change the terminal
1063
+ // may confirm, ask, and publish again with the answer.
1061
1064
  const refusal = readPaymentRefusal({
1062
1065
  status: pubRes.status,
1063
1066
  contentType: pubRes.headers?.get?.('content-type') || '',
1064
- body
1067
+ body: failedBody
1065
1068
  })
1066
1069
  if (refusal.kind !== 'not-payment') {
1067
- await reportPaymentRefusal({ verdict: refusal, args, say })
1068
- return { exitCode: 1 }
1070
+ const settled = await settleRefusal({
1071
+ verdict: refusal,
1072
+ args,
1073
+ say,
1074
+ republish: (token, { unattended } = {}) =>
1075
+ client.publishSite(siteUuid, { ...(languages ? { languages } : {}), confirm: token, unattended })
1076
+ })
1077
+ if ('exitCode' in settled) return { exitCode: settled.exitCode }
1078
+ pubRes = settled.response
1079
+ failedBody = settled.body ?? ''
1069
1080
  }
1070
-
1081
+ }
1082
+ if (!pubRes.ok) {
1071
1083
  say.err(`Publish rejected: HTTP ${pubRes.status} ${pubRes.statusText}`)
1072
1084
  if (pubRes.status === 401 || pubRes.status === 403) {
1073
1085
  say.dim(
1074
1086
  "Credentials weren't accepted — run `uniweb login` again."
1075
1087
  )
1076
1088
  }
1077
- if (body) say.dim(body.slice(0, 800))
1089
+ if (failedBody) say.dim(failedBody.slice(0, 800))
1078
1090
  return { exitCode: 1 }
1079
1091
  }
1080
1092
  let result
@@ -1163,6 +1175,11 @@ export async function publish(args = []) {
1163
1175
  say.ok(
1164
1176
  `Published ${c.bold}${siteUuid}${c.reset}${result.status ? ` (${result.status})` : ''}`
1165
1177
  )
1178
+ // What the request changed, in the backend's own sentences — a confirmed change, or a reduction
1179
+ // (`applied`, its §4A). Absent when nothing changed.
1180
+ for (const line of Array.isArray(result.applied) ? result.applied : []) {
1181
+ if (typeof line === 'string' && line) say.info(line)
1182
+ }
1166
1183
 
1167
1184
  // ⛔ SAY IT WHEN A LANGUAGE ASKED FOR DID NOT GO OUT.
1168
1185
  //
@@ -1,16 +1,16 @@
1
1
  {
2
2
  "schemaVersion": 1,
3
- "generatedAt": "2026-10-03T13:45:26.166Z",
3
+ "generatedAt": "2026-10-05T18:23:45.673Z",
4
4
  "packages": {
5
5
  "@uniweb/api": {
6
- "version": "0.6.9",
6
+ "version": "0.6.10",
7
7
  "path": "framework/api",
8
8
  "deps": [
9
9
  "@uniweb/core"
10
10
  ]
11
11
  },
12
12
  "@uniweb/build": {
13
- "version": "0.75.1",
13
+ "version": "0.76.0",
14
14
  "path": "framework/build",
15
15
  "deps": [
16
16
  "@uniweb/content-reader",
@@ -35,7 +35,7 @@
35
35
  "deps": []
36
36
  },
37
37
  "@uniweb/core": {
38
- "version": "0.36.0",
38
+ "version": "0.37.0",
39
39
  "path": "framework/core",
40
40
  "deps": [
41
41
  "@uniweb/semantic-parser",
@@ -48,14 +48,14 @@
48
48
  "deps": []
49
49
  },
50
50
  "@uniweb/icons": {
51
- "version": "0.4.27",
51
+ "version": "0.4.28",
52
52
  "path": "framework/icons",
53
53
  "deps": [
54
54
  "@uniweb/core"
55
55
  ]
56
56
  },
57
57
  "@uniweb/kit": {
58
- "version": "0.19.15",
58
+ "version": "0.19.16",
59
59
  "path": "framework/kit",
60
60
  "deps": [
61
61
  "@uniweb/core",
@@ -76,7 +76,7 @@
76
76
  "deps": []
77
77
  },
78
78
  "@uniweb/projections": {
79
- "version": "0.7.13",
79
+ "version": "0.7.14",
80
80
  "path": "framework/projections",
81
81
  "deps": [
82
82
  "@uniweb/content-writer",
@@ -84,7 +84,7 @@
84
84
  ]
85
85
  },
86
86
  "@uniweb/runtime": {
87
- "version": "0.28.10",
87
+ "version": "0.29.0",
88
88
  "path": "framework/runtime",
89
89
  "deps": [
90
90
  "@uniweb/core",
@@ -97,7 +97,7 @@
97
97
  "deps": []
98
98
  },
99
99
  "@uniweb/schemas": {
100
- "version": "0.10.2",
100
+ "version": "0.11.0",
101
101
  "path": "framework/schemas",
102
102
  "deps": []
103
103
  },
@@ -117,7 +117,7 @@
117
117
  "deps": []
118
118
  },
119
119
  "@uniweb/templates": {
120
- "version": "0.17.12",
120
+ "version": "0.17.13",
121
121
  "path": "framework/templates",
122
122
  "deps": []
123
123
  },
@@ -127,7 +127,7 @@
127
127
  "deps": []
128
128
  },
129
129
  "@uniweb/unipress": {
130
- "version": "0.10.19",
130
+ "version": "0.10.20",
131
131
  "path": "framework/unipress",
132
132
  "deps": [
133
133
  "@uniweb/build",