uniweb 0.81.1 → 0.81.3

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.1",
3
+ "version": "0.81.3",
4
4
  "description": "Create structured Vite + React sites with content/code separation",
5
5
  "type": "module",
6
6
  "bin": {
@@ -41,17 +41,17 @@
41
41
  "js-yaml": "^4.1.0",
42
42
  "prompts": "^2.4.2",
43
43
  "tar": "^7.0.0",
44
- "@uniweb/runtime": "^0.28.10",
45
44
  "@uniweb/content-writer": "^0.3.4",
46
45
  "@uniweb/schemas": "^0.10.2",
47
- "@uniweb/core": "^0.36.0",
46
+ "@uniweb/runtime": "^0.28.10",
48
47
  "@uniweb/kit": "^0.19.15",
48
+ "@uniweb/core": "^0.36.0",
49
49
  "@uniweb/semantic-parser": "^1.5.1"
50
50
  },
51
51
  "peerDependencies": {
52
- "@uniweb/content-reader": "^1.2.8",
53
52
  "@uniweb/semantic-parser": "^1.5.1",
54
- "@uniweb/build": "^0.75.0"
53
+ "@uniweb/content-reader": "^1.2.8",
54
+ "@uniweb/build": "^0.75.1"
55
55
  },
56
56
  "peerDependenciesMeta": {
57
57
  "@uniweb/build": {
@@ -2761,6 +2761,8 @@ locales/freeform/es/records/article/x.md # records work too
2761
2761
 
2762
2762
  These are **body only — no frontmatter**; params and config still come from the source section. `uniweb i18n init-freeform es pages/about hero` creates one pre-filled and records a source hash, so `uniweb i18n status --freeform` can tell you when the original moved on (`update-hash` to acknowledge). `move`, `rename`, and `prune --freeform` keep them aligned when pages get reorganized.
2763
2763
 
2764
+ **A tagged data block translates by the shape its key declares** in the component's `meta.js` `data:` — a data schema or an inline field map: its text fields, never an enum, a URL or a `translatable: false` field. A key declared `{}` leaves its blocks to a heuristic that offers any string whose field name and value don't look structural, so a `style: display-black` naming one of your component's looks gets translated too. Declare the shape of every block a translator will see — `{ name: 'string', style: { type: 'string', enum: [...] } }`.
2765
+
2764
2766
  **Records translate in the same `extract` run**, into their own manifest at `locales/records/manifest.json` — keyed by the RECORD, so a record translated once is found by every query that returns it. A record whose query has a data schema translates exactly the fields the schema says are text — never an enum, a URL, a date, a number, a file, a reference or a `translatable: false` field — which are the fields a push carries in each language; mark a string that is a code or a key `translatable: false` and no translator is asked for it. An excerpt the build derives is re-derived from each language's body.
2765
2767
 
2766
2768
  **Component side.** Nothing to do: `content.title` arrives in the active language. The one thing a foundation builds is a switcher.
@@ -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
+ }
@@ -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
  //
@@ -73,6 +73,7 @@ import { downloadMissingAssets, downloadFileRecords } from '../backend/asset-dow
73
73
  import {
74
74
  readBackendState,
75
75
  siteContentDocumentToProject,
76
+ dataBlockModels,
76
77
  recordsToProject,
77
78
  siteSelfScope,
78
79
  readZip,
@@ -988,7 +989,10 @@ export async function pull(args = [], deps = {}) {
988
989
  prune,
989
990
  keepAuthoredFoundation,
990
991
  // So a query's `@acme/member` comes back as the author's `@/member`.
991
- scope: await scopeFor(siteDoc)
992
+ scope: await scopeFor(siteDoc),
993
+ // A translated data block is read back by what its section's type declares it is, as the
994
+ // push translated it — after the registered foundation above, whose section types say it.
995
+ dataModel: await dataBlockModels(siteDir)
992
996
  })
993
997
  wrote.push(...report.pages, ...report.sections, ...report.layout)
994
998
  removed.push(
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "schemaVersion": 1,
3
- "generatedAt": "2026-10-02T03:03:06.390Z",
3
+ "generatedAt": "2026-10-03T13:45:26.166Z",
4
4
  "packages": {
5
5
  "@uniweb/api": {
6
6
  "version": "0.6.9",
@@ -10,7 +10,7 @@
10
10
  ]
11
11
  },
12
12
  "@uniweb/build": {
13
- "version": "0.75.0",
13
+ "version": "0.75.1",
14
14
  "path": "framework/build",
15
15
  "deps": [
16
16
  "@uniweb/content-reader",
@@ -117,7 +117,7 @@
117
117
  "deps": []
118
118
  },
119
119
  "@uniweb/templates": {
120
- "version": "0.17.11",
120
+ "version": "0.17.12",
121
121
  "path": "framework/templates",
122
122
  "deps": []
123
123
  },