uniweb 0.13.17 → 0.14.1

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.
Files changed (65) hide show
  1. package/README.md +1 -1
  2. package/package.json +7 -7
  3. package/partials/agents.md +51 -0
  4. package/src/backend/client.js +173 -40
  5. package/src/backend/data-bundle.js +15 -3
  6. package/src/backend/foundation-bring-along.js +76 -21
  7. package/src/backend/payment-handoff.js +28 -9
  8. package/src/backend/site-media.js +147 -19
  9. package/src/backend/site-sync.js +362 -40
  10. package/src/commands/add.js +575 -273
  11. package/src/commands/build.js +160 -57
  12. package/src/commands/clone.js +79 -26
  13. package/src/commands/content.js +11 -7
  14. package/src/commands/deploy.js +80 -32
  15. package/src/commands/dev.js +39 -17
  16. package/src/commands/docs.js +44 -16
  17. package/src/commands/doctor.js +338 -82
  18. package/src/commands/export.js +15 -7
  19. package/src/commands/handoff.js +6 -2
  20. package/src/commands/i18n.js +248 -99
  21. package/src/commands/inspect.js +48 -19
  22. package/src/commands/invite.js +9 -3
  23. package/src/commands/org.js +19 -5
  24. package/src/commands/publish.js +229 -60
  25. package/src/commands/pull.js +157 -48
  26. package/src/commands/push.js +144 -42
  27. package/src/commands/refresh.js +37 -10
  28. package/src/commands/register.js +235 -64
  29. package/src/commands/rename.js +126 -46
  30. package/src/commands/runtime.js +74 -24
  31. package/src/commands/status.js +48 -15
  32. package/src/commands/sync.js +7 -2
  33. package/src/commands/template.js +12 -4
  34. package/src/commands/update.js +263 -87
  35. package/src/commands/validate.js +115 -32
  36. package/src/framework-index.json +15 -13
  37. package/src/index.js +298 -145
  38. package/src/templates/fetchers/github.js +7 -7
  39. package/src/templates/fetchers/npm.js +3 -3
  40. package/src/templates/fetchers/release.js +21 -18
  41. package/src/templates/index.js +34 -12
  42. package/src/templates/processor.js +44 -12
  43. package/src/templates/resolver.js +42 -15
  44. package/src/templates/validator.js +14 -7
  45. package/src/utils/asset-upload.js +90 -22
  46. package/src/utils/code-upload.js +62 -37
  47. package/src/utils/config.js +31 -10
  48. package/src/utils/dep-survey.js +33 -7
  49. package/src/utils/destination-prompt.js +27 -17
  50. package/src/utils/git.js +66 -22
  51. package/src/utils/host-prompt.js +4 -3
  52. package/src/utils/install-integrity.js +8 -4
  53. package/src/utils/interactive.js +3 -3
  54. package/src/utils/json-file.js +6 -2
  55. package/src/utils/names.js +15 -6
  56. package/src/utils/placement.js +16 -8
  57. package/src/utils/registry-auth.js +185 -57
  58. package/src/utils/registry-orgs.js +142 -53
  59. package/src/utils/runtime-upload.js +52 -14
  60. package/src/utils/scaffold.js +55 -14
  61. package/src/utils/site-content-refs.js +3 -1
  62. package/src/utils/update-check.js +43 -17
  63. package/src/utils/workspace.js +13 -7
  64. package/src/versions.js +18 -7
  65. package/templates/site/_gitignore +5 -0
package/README.md CHANGED
@@ -360,7 +360,7 @@ Markdown in a git repo and content in the Uniweb apps can share the same site. D
360
360
 
361
361
  ---
362
362
 
363
- Both paths use the same framework. The difference is who edits content, where it lives, and what gets deployed. For the deeper menu — site-bound vs cataloged foundations, S3+CloudFront, manual exports, per-host recipes, foundation propagation — see → **[Deploying](https://github.com/uniweb/docs/blob/main/development/deploying.md)**.
363
+ Both paths use the same framework. The difference is who edits content, where it lives, and what gets deployed. For the deeper menu — standalone vs cataloged foundations, S3+CloudFront, manual exports, per-host recipes, foundation propagation — see → **[Deploying](https://github.com/uniweb/docs/blob/main/development/deploying.md)**.
364
364
 
365
365
  ---
366
366
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "uniweb",
3
- "version": "0.13.17",
3
+ "version": "0.14.1",
4
4
  "description": "Create structured Vite + React sites with content/code separation",
5
5
  "type": "module",
6
6
  "bin": {
@@ -41,14 +41,14 @@
41
41
  "js-yaml": "^4.1.0",
42
42
  "prompts": "^2.4.2",
43
43
  "tar": "^7.0.0",
44
- "@uniweb/core": "0.7.34",
45
- "@uniweb/kit": "0.9.46",
46
- "@uniweb/runtime": "0.8.42"
44
+ "@uniweb/core": "0.8.0",
45
+ "@uniweb/kit": "0.10.1",
46
+ "@uniweb/runtime": "0.9.0"
47
47
  },
48
48
  "peerDependencies": {
49
- "@uniweb/build": "0.15.14",
50
- "@uniweb/content-reader": "1.1.19",
51
- "@uniweb/semantic-parser": "1.1.21"
49
+ "@uniweb/build": "0.16.0",
50
+ "@uniweb/content-reader": "1.2.0",
51
+ "@uniweb/semantic-parser": "1.2.0"
52
52
  },
53
53
  "peerDependenciesMeta": {
54
54
  "@uniweb/build": {
@@ -468,8 +468,12 @@ Optional attributes: `{size=20}`, `{color=red}`. Custom SVGs: `![Logo](./logo.sv
468
468
  [text](url){target=_blank} <!-- Open in new tab -->
469
469
  [text](./file.pdf){download} <!-- Download -->
470
470
  ![alt](./img.jpg){role=banner} <!-- Role determines array: images, icons, or videos -->
471
+ ![alt](./demo.mp4){role=video} <!-- → content.videos[], not content.images[] -->
472
+ ![alt](./img.jpg){href=/products} <!-- Clickable media: href + target ride along -->
471
473
  ```
472
474
 
475
+ Sizing and loading ride on the image: `{width=800 height=600 loading=lazy fit=cover position=center}`. A video adds `{poster=./thumb.jpg autoplay muted loop controls}`; a `{role=pdf}` document adds `{preview=./cover.jpg author=… description=…}`.
476
+
473
477
  **Quote values containing spaces:** `{note="Ready to go"}`, not `{note=Ready to go}` — unquoted values end at the first space.
474
478
 
475
479
  **Separators are forgiving.** `:` works wherever `=` does, and pairs may be separated by whitespace, a comma, or both — `{role=banner width=1200}`, `{role:banner, width:1200}` and `{role:banner,width:1200}` are identical. `=` and spaces are canonical, and that is what gets written back on save. Two rules keep it unambiguous: the separator must **touch** the key (`{note:warning}` is one pair, `{note : warning}` is two flags), and a value containing a **comma** must be quoted (`{style="a, b"}`). A value containing a colon needs no quoting — only the first colon separates, so `{href:https://example.com}` is fine.
@@ -493,6 +497,10 @@ This is [less important]{muted} context.
493
497
 
494
498
  Sites can adjust these or add named styles in `theme.yml`'s `inline:` section. Overrides merge **property by property**, so declare only what differs (`accent: { font-weight: inherit }` keeps the default color); to drop a default property rather than change it, give it a neutral value (`initial`, `inherit`, `unset`).
495
499
 
500
+ > **On a site synced with Uniweb Cloud, the built-in style names round-trip *editable*** — an author who opens a page containing `[Get started]{accent}` gets the editor's own styling control for it, and can change or remove it like any text they styled there.
501
+ >
502
+ > **A name the editor doesn't know is preserved exactly, but not editable** — a style you defined yourself in `inline:`, two names on one span, or a `class`/`id` set. Nothing is lost and nothing is approximated: it survives every edit and save untouched, the editor simply offers no control for it. That is a permanent property rather than a gap, because the name set is open by design — you can add styles the editor has no way to know about.
503
+
496
504
  ### Fenced code: data blocks vs snippets
497
505
 
498
506
  Fenced code serves two purposes depending on whether it carries a tag.
@@ -630,6 +638,49 @@ seo:
630
638
 
631
639
  **Localized URLs:** on a multilingual site (`languages:` in site.yml), `slug: { <lang>: <segment> }` gives a page a native URL segment per language; the folder name stays the canonical route. Nested folders compose automatically, and localized URLs flow through navigation, the language switcher, and the sitemap. See Part 5 for the translation workflow.
632
640
 
641
+ ### Your site is readable by agents, automatically
642
+
643
+ Every build emits two things an AI agent can use directly, alongside the HTML. **Free, on by default, nothing to install.**
644
+
645
+ | Artifact | What it is |
646
+ |---|---|
647
+ | `/llms.txt` | An annotated index of the site — every page, with a one-line description, linking to the `.md` below |
648
+ | `/{route}.md` | Each page as clean markdown: the content, no navigation, no chrome |
649
+
650
+ The point is the pair. An agent fetches the index, *reads* what each page is about, and goes straight to the one it needs — two requests, no HTML stripping, no guessing at URLs. Descriptions come from `page.yml`'s `description:`, then `seo.ogDescription`, then the page's opening paragraph, so a site usually gets a complete index without writing any of them.
651
+
652
+ **Large sites also get per-branch indexes.** A branch with at least 5 pages gets its own — `/docs/llms.txt` lists just the docs, titled by that folder. The site index still lists everything; a branch index is an extra entry point, not a replacement, so an agent that starts at `/llms.txt` still reaches any page in two hops.
653
+
654
+ ```yaml
655
+ # site.yml — all optional; these are the defaults
656
+ agents:
657
+ index: true # /llms.txt
658
+ markdown: true # /{route}.md
659
+ branchIndexes: true # /docs/llms.txt for branches big enough to want one
660
+ branchMinPages: 5 # how big is big enough
661
+ exclude: [/internal] # keep a branch out of both (cascades)
662
+
663
+ agents: false # or turn the whole thing off in one word
664
+ ```
665
+
666
+ **Set `seo.baseUrl` if you want absolute links** in the index — without it the links are root-relative, which still works for an agent that arrived via the index. `uniweb doctor` warns when it's unset.
667
+
668
+ **What's excluded, and it's deliberate:** `seo.noindex` pages, `hidden` pages, `_`-prefixed drafts, and dynamic route templates. An index *describes* pages rather than merely listing them, so an unlinked page would become both discoverable and summarized — which is why these exclusions are load-bearing rather than tidy-up. `noindex` or `hidden` on a **folder** takes the whole branch with it.
669
+
670
+ **Declaring how your content may be used** is a separate axis from whether it may be fetched, and it goes in `seo.robots`:
671
+
672
+ ```yaml
673
+ # site.yml
674
+ seo:
675
+ robots:
676
+ contentSignals:
677
+ search: true # may appear in search results
678
+ ai-input: true # may be retrieved at inference time
679
+ ai-train: false # may not be used to train a model
680
+ ```
681
+
682
+ That emits a `Content-Signal:` line in `robots.txt`. Declare only what you mean — an omitted signal says nothing, which is not the same as saying no.
683
+
633
684
  ### Collections and dynamic routes
634
685
 
635
686
  Most content lives in `pages/` — a fixed composition of sections on a fixed set of pages. **Collections are the other kind: repeating content managed as a set of files**, one item per file, that pages pull from. Blog posts, team members, products, case studies, bibliographies.
@@ -28,8 +28,16 @@
28
28
  */
29
29
 
30
30
  import { getRegistryApiBaseUrl } from '../utils/config.js'
31
- import { ensureRegistryAuth, fetchMe, readRegistryAuth, isExpired } from '../utils/registry-auth.js'
32
- import { fetchOrgs as fetchOrgsImpl, createOrg as createOrgImpl } from '../utils/registry-orgs.js'
31
+ import {
32
+ ensureRegistryAuth,
33
+ fetchMe,
34
+ readRegistryAuth,
35
+ isExpired
36
+ } from '../utils/registry-auth.js'
37
+ import {
38
+ fetchOrgs as fetchOrgsImpl,
39
+ createOrg as createOrgImpl
40
+ } from '../utils/registry-orgs.js'
33
41
  import { uploadFoundationCode } from '../utils/code-upload.js'
34
42
  import { uploadSiteAssets } from '../utils/asset-upload.js'
35
43
  import { uploadRuntime } from '../utils/runtime-upload.js'
@@ -51,11 +59,26 @@ import { uploadRuntime } from '../utils/runtime-upload.js'
51
59
  * @returns {string} a bare origin with no trailing slash
52
60
  */
53
61
  export function resolveBackendOrigin(flag, { siteBackend } = {}) {
54
- const norm = (v) => { try { return new URL(v).origin } catch { return null } }
55
- if (flag) { const o = norm(flag); if (o) return o }
62
+ const norm = (v) => {
63
+ try {
64
+ return new URL(v).origin
65
+ } catch {
66
+ return null
67
+ }
68
+ }
69
+ if (flag) {
70
+ const o = norm(flag)
71
+ if (o) return o
72
+ }
56
73
  const env = process.env.UNIWEB_REGISTER_URL
57
- if (env) { const o = norm(env); if (o) return o }
58
- if (siteBackend) { const o = norm(siteBackend); if (o) return o }
74
+ if (env) {
75
+ const o = norm(env)
76
+ if (o) return o
77
+ }
78
+ if (siteBackend) {
79
+ const o = norm(siteBackend)
80
+ if (o) return o
81
+ }
59
82
  return getRegistryApiBaseUrl()
60
83
  }
61
84
 
@@ -67,12 +90,15 @@ export function resolveBackendOrigin(flag, { siteBackend } = {}) {
67
90
  * `runtime.installed` is empty so runtime resolution requires an explicit pin.
68
91
  */
69
92
  export const DISCOVERY_DEFAULTS = {
70
- gatewayBase: '/gateway',
93
+ // No serve-root default. Serve locations are read from discovery or from an
94
+ // upload plan's `serve_base`; nothing here reconstructs one, so a default
95
+ // would be a route name with no consumer. (A `gatewayBase` entry lived here
96
+ // unread until 2026-07-29.)
71
97
  assetBase: 'https://assets.uniweb.app/',
72
98
  auth: { loginPath: '/dev/auth/login', required: true },
73
99
  delivery: { deploy: true, publish: true, broker: 'self-serve' },
74
100
  assets: { supported: false },
75
- runtime: { installed: [] },
101
+ runtime: { installed: [] }
76
102
  }
77
103
 
78
104
  export class BackendClient {
@@ -88,8 +114,19 @@ export class BackendClient {
88
114
  * @param {string} [opts.command] - label for the login prompt ('Pushing', 'Registering', …)
89
115
  * @param {typeof fetch} [opts.fetchImpl] - injectable fetch (tests)
90
116
  */
91
- constructor({ origin, originFlag, siteBackend, token, getToken, args = [], command = 'This command', fetchImpl } = {}) {
92
- this.origin = (origin || resolveBackendOrigin(originFlag, { siteBackend })).replace(/\/+$/, '')
117
+ constructor({
118
+ origin,
119
+ originFlag,
120
+ siteBackend,
121
+ token,
122
+ getToken,
123
+ args = [],
124
+ command = 'This command',
125
+ fetchImpl
126
+ } = {}) {
127
+ this.origin = (
128
+ origin || resolveBackendOrigin(originFlag, { siteBackend })
129
+ ).replace(/\/+$/, '')
93
130
  this._token = token || process.env.UNIWEB_TOKEN || null
94
131
  this._getToken = getToken || null
95
132
  this._args = args
@@ -117,13 +154,25 @@ export class BackendClient {
117
154
  this._warnedOriginMismatch = true
118
155
  try {
119
156
  const stored = await readRegistryAuth()
120
- if (stored?.token && !isExpired(stored) && stored.origin &&
121
- stored.origin.replace(/\/+$/, '') !== this.origin) {
122
- console.error(`\x1b[33m⚠\x1b[0m Logged in to ${stored.origin}, but this command targets ${this.origin} — the session may be rejected. Run \`uniweb login --backend ${this.origin}\`, or pass --token.`)
157
+ if (
158
+ stored?.token &&
159
+ !isExpired(stored) &&
160
+ stored.origin &&
161
+ stored.origin.replace(/\/+$/, '') !== this.origin
162
+ ) {
163
+ console.error(
164
+ `\x1b[33m⚠\x1b[0m Logged in to ${stored.origin}, but this command targets ${this.origin} — the session may be rejected. Run \`uniweb login --backend ${this.origin}\`, or pass --token.`
165
+ )
123
166
  }
124
- } catch { /* advisory only */ }
167
+ } catch {
168
+ /* advisory only */
169
+ }
125
170
  }
126
- this._token = await ensureRegistryAuth({ apiBase: this.origin, command: this._command, args: this._args })
171
+ this._token = await ensureRegistryAuth({
172
+ apiBase: this.origin,
173
+ command: this._command,
174
+ args: this._args
175
+ })
127
176
  return this._token
128
177
  }
129
178
 
@@ -143,7 +192,10 @@ export class BackendClient {
143
192
  * @param {boolean} [opts.auth=true]
144
193
  * @returns {Promise<Response>}
145
194
  */
146
- async request(path, { method = 'GET', body, headers = {}, query, auth = true } = {}) {
195
+ async request(
196
+ path,
197
+ { method = 'GET', body, headers = {}, query, auth = true } = {}
198
+ ) {
147
199
  const url = new URL(path, this.origin)
148
200
  if (query) {
149
201
  for (const [k, v] of Object.entries(query)) {
@@ -154,7 +206,8 @@ export class BackendClient {
154
206
  if (auth) h.Authorization = `Bearer ${await this.token()}`
155
207
  if (body != null && h['Content-Type'] == null) {
156
208
  if (typeof body === 'string') h['Content-Type'] = 'application/json'
157
- else if (body instanceof Uint8Array || Buffer.isBuffer(body)) h['Content-Type'] = 'application/zip'
209
+ else if (body instanceof Uint8Array || Buffer.isBuffer(body))
210
+ h['Content-Type'] = 'application/zip'
158
211
  }
159
212
  return this._fetch(url.href, { method, headers: h, body })
160
213
  }
@@ -167,7 +220,7 @@ export class BackendClient {
167
220
  * lifetime; a missing route or any transport/parse error falls back to
168
221
  * DISCOVERY_DEFAULTS (non-breaking — an older backend still works). Lets the
169
222
  * CLI hardcode nothing about a backend but its origin and discover the rest:
170
- * `gatewayBase`/`assetBase` (serve/asset roots — relative ⇒ relative-to-origin),
223
+ * `assetBase` (the asset root — relative ⇒ relative-to-origin),
171
224
  * `auth`, `delivery` (deploy/publish? broker), `assets` (lane built yet?),
172
225
  * `runtime.installed` (the default-runtime source replacing the old /runtime/latest).
173
226
  * @returns {Promise<object>}
@@ -201,7 +254,10 @@ export class BackendClient {
201
254
  * @returns {Promise<Response>}
202
255
  */
203
256
  async register(uwxJson) {
204
- return this.request('/dev/registry/register', { method: 'POST', body: uwxJson })
257
+ return this.request('/dev/registry/register', {
258
+ method: 'POST',
259
+ body: uwxJson
260
+ })
205
261
  }
206
262
 
207
263
  /**
@@ -210,7 +266,11 @@ export class BackendClient {
210
266
  * @param {object} opts - { name, version, distDir, files?, onProgress? }
211
267
  */
212
268
  async uploadFoundationCode(opts) {
213
- return uploadFoundationCode({ apiBase: this.origin, token: await this.token(), ...opts })
269
+ return uploadFoundationCode({
270
+ apiBase: this.origin,
271
+ token: await this.token(),
272
+ ...opts
273
+ })
214
274
  }
215
275
 
216
276
  /**
@@ -223,7 +283,10 @@ export class BackendClient {
223
283
  async readDataSchema(modelName) {
224
284
  const res = await this.request(dataSchemaPath(modelName))
225
285
  if (res.status === 404) return null
226
- if (!res.ok) throw new Error(`Model-read ${modelName} failed: HTTP ${res.status} ${res.statusText}`)
286
+ if (!res.ok)
287
+ throw new Error(
288
+ `Model-read ${modelName} failed: HTTP ${res.status} ${res.statusText}`
289
+ )
227
290
  return res.json()
228
291
  }
229
292
 
@@ -245,12 +308,17 @@ export class BackendClient {
245
308
  const m = /^@([^/]+)\/([^@/]+)/.exec(String(scopedName || ''))
246
309
  if (!m) return null
247
310
  try {
248
- const res = await this.request(`/dev/registry/${encodeURIComponent(m[1])}/${encodeURIComponent(m[2])}`)
311
+ const res = await this.request(
312
+ `/dev/registry/${encodeURIComponent(m[1])}/${encodeURIComponent(m[2])}`
313
+ )
249
314
  if (!res.ok) return null
250
315
  const body = await res.json().catch(() => null)
251
316
  if (!body) return null
252
317
  // The read returns `version`; callers use `latest_version`. Tolerate both.
253
- return { ...body, latest_version: body.latest_version ?? body.version ?? null }
318
+ return {
319
+ ...body,
320
+ latest_version: body.latest_version ?? body.version ?? null
321
+ }
254
322
  } catch {
255
323
  return null
256
324
  }
@@ -265,27 +333,74 @@ export class BackendClient {
265
333
 
266
334
  /** POST /dev/orgs { handle } → { handle, uuid, is_primary }. Throws with the server's detail on 409/422. */
267
335
  async createOrg(handle) {
268
- return createOrgImpl({ apiBase: this.origin, token: await this.token(), handle })
336
+ return createOrgImpl({
337
+ apiBase: this.origin,
338
+ token: await this.token(),
339
+ handle
340
+ })
269
341
  }
270
342
 
271
343
  // ── Site sync (push / pull) ─────────────────────────────────────────────────────
272
344
 
345
+ /**
346
+ * POST /dev/site — CREATE an EMPTY site and return its site-content uuid.
347
+ *
348
+ * The uuid exists before any content or asset does, which is the whole point:
349
+ * uploaded bytes are metered against an owning entity and freed by deleting it,
350
+ * so an upload with no site to charge is billed and can never be reclaimed.
351
+ * Creating the site first is what makes a failed first publish leave a clearable
352
+ * empty site instead of unfreeable bytes.
353
+ *
354
+ * A thin re-projection of the same op the app's blank-site create uses, so the
355
+ * two lanes cannot drift. Adoption of `foundation` is best-effort — an
356
+ * unreleased ref leaves the resolved ref null and the site still exists — but
357
+ * the field itself is required and cannot be blank.
358
+ *
359
+ * NOT idempotent: two calls mint two sites (a name is not a unique key, by
360
+ * design). Callers must guard on `site.yml::$uuid` and write the result back
361
+ * immediately — see `ensureSiteExists`.
362
+ *
363
+ * @param {{ name: string, foundation: string, asOrg?: string|null }} opts
364
+ * @returns {Promise<Response>} `{ site_content_uuid }`
365
+ */
366
+ async createSite({ name, foundation, asOrg } = {}) {
367
+ return this.request('/dev/site', {
368
+ method: 'POST',
369
+ // Both fields are REQUIRED: `info.name` is `required: true` on the model
370
+ // (and is a plain string — an identity label, never a `{lang: …}` map, since
371
+ // a localized value can vanish under locale projection and a name must always
372
+ // render), and a site must resolve to a foundation. Adoption of the ref is
373
+ // best-effort on the backend, but the field itself cannot be blank — so send
374
+ // whatever the site declares and let the backend judge it.
375
+ body: JSON.stringify({ name, foundation }),
376
+ query: { as_org: asOrg }
377
+ })
378
+ }
379
+
273
380
  /** POST /dev/site/content — CREATE a site from its content lane (.uwx zip). */
274
381
  async createSiteContent(buffer, { asOrg } = {}) {
275
- return this.request('/dev/site/content', { method: 'POST', body: buffer, query: pushQuery(asOrg) })
382
+ return this.request('/dev/site/content', {
383
+ method: 'POST',
384
+ body: buffer,
385
+ query: pushQuery(asOrg)
386
+ })
276
387
  }
277
388
 
278
389
  /** POST /dev/site/content/push/{uuid} — UPDATE the content lane by site uuid (.uwx zip). */
279
390
  async updateSiteContent(uuid, buffer, { asOrg } = {}) {
280
391
  return this.request(`/dev/site/content/push/${encodeURIComponent(uuid)}`, {
281
- method: 'POST', body: buffer, query: pushQuery(asOrg),
392
+ method: 'POST',
393
+ body: buffer,
394
+ query: pushQuery(asOrg)
282
395
  })
283
396
  }
284
397
 
285
398
  /** POST /dev/site/folder/push/{uuid} — push the folder lane, keyed by the site uuid (.uwx zip). */
286
399
  async pushFolder(uuid, buffer, { asOrg } = {}) {
287
400
  return this.request(`/dev/site/folder/push/${encodeURIComponent(uuid)}`, {
288
- method: 'POST', body: buffer, query: pushQuery(asOrg),
401
+ method: 'POST',
402
+ body: buffer,
403
+ query: pushQuery(asOrg)
289
404
  })
290
405
  }
291
406
 
@@ -295,14 +410,14 @@ export class BackendClient {
295
410
  */
296
411
  async pullSiteContent(uuid, { etag } = {}) {
297
412
  return this.request(`/dev/site/content/pull/${encodeURIComponent(uuid)}`, {
298
- headers: etag ? { 'If-None-Match': etag } : {},
413
+ headers: etag ? { 'If-None-Match': etag } : {}
299
414
  })
300
415
  }
301
416
 
302
417
  /** GET /dev/site/folder/pull/{uuid} — the folder lane (folder + record documents). */
303
418
  async pullFolder(uuid, { etag } = {}) {
304
419
  return this.request(`/dev/site/folder/pull/${encodeURIComponent(uuid)}`, {
305
- headers: etag ? { 'If-None-Match': etag } : {},
420
+ headers: etag ? { 'If-None-Match': etag } : {}
306
421
  })
307
422
  }
308
423
 
@@ -314,7 +429,7 @@ export class BackendClient {
314
429
  * theme, languages, locales, optional dataFiles/searchFiles) plus an optional
315
430
  * `site_uuid`. First deploy of a never-synced site omits it → the backend mints
316
431
  * a uuid and returns it for write-back to deploy.yml; later deploys resend it so
317
- * `/gateway/site/{uuid}/` stays stable. Returns the raw Response so the caller
432
+ * the site's published URL stays stable. Returns the raw Response so the caller
318
433
  * messages its own errors and reads `{ site_uuid, url, locales }` on 200.
319
434
  * @param {object} payload - the deploy payload (universal currency)
320
435
  * @param {object} [opts]
@@ -323,7 +438,10 @@ export class BackendClient {
323
438
  */
324
439
  async deploy(payload, { siteUuid } = {}) {
325
440
  const body = siteUuid ? { ...payload, site_uuid: siteUuid } : payload
326
- return this.request('/dev/deploy', { method: 'POST', body: JSON.stringify(body) })
441
+ return this.request('/dev/deploy', {
442
+ method: 'POST',
443
+ body: JSON.stringify(body)
444
+ })
327
445
  }
328
446
 
329
447
  /**
@@ -346,18 +464,20 @@ export class BackendClient {
346
464
  return this.request(`/dev/site/publish/${encodeURIComponent(uuid)}`, {
347
465
  method: 'POST',
348
466
  query: { runtime: runtimeVersion },
349
- ...(languages ? { body: JSON.stringify({ languages }) } : {}),
467
+ ...(languages ? { body: JSON.stringify({ languages }) } : {})
350
468
  })
351
469
  }
352
470
 
353
471
  /**
354
- * POST /dev/site/unpublish/{uuid} — drop the published-folder gate so /gateway
472
+ * POST /dev/site/unpublish/{uuid} — drop the published-folder gate so the host
355
473
  * stops serving the site's dynamic content. Returns the raw Response ({ was_published }).
356
474
  * @param {string} uuid - the site-content uuid
357
475
  * @returns {Promise<Response>}
358
476
  */
359
477
  async unpublishSite(uuid) {
360
- return this.request(`/dev/site/unpublish/${encodeURIComponent(uuid)}`, { method: 'POST' })
478
+ return this.request(`/dev/site/unpublish/${encodeURIComponent(uuid)}`, {
479
+ method: 'POST'
480
+ })
361
481
  }
362
482
 
363
483
  /**
@@ -374,7 +494,9 @@ export class BackendClient {
374
494
  */
375
495
  async siteStatus(uuid) {
376
496
  try {
377
- const res = await this.request(`/dev/site/status/${encodeURIComponent(uuid)}`)
497
+ const res = await this.request(
498
+ `/dev/site/status/${encodeURIComponent(uuid)}`
499
+ )
378
500
  return res.ok ? await res.json().catch(() => null) : null
379
501
  } catch {
380
502
  return null
@@ -394,7 +516,9 @@ export class BackendClient {
394
516
  */
395
517
  async canGoLive(uuid) {
396
518
  try {
397
- const res = await this.request(`/dev/site/${encodeURIComponent(uuid)}/can-go-live`)
519
+ const res = await this.request(
520
+ `/dev/site/${encodeURIComponent(uuid)}/can-go-live`
521
+ )
398
522
  return res.ok ? await res.json().catch(() => null) : null
399
523
  } catch {
400
524
  return null
@@ -409,24 +533,33 @@ export class BackendClient {
409
533
  * @param {object} opts - { distDir, files?, onProgress? }
410
534
  */
411
535
  async uploadSiteAssets(opts) {
412
- return uploadSiteAssets({ apiBase: this.origin, token: await this.token(), ...opts })
536
+ return uploadSiteAssets({
537
+ apiBase: this.origin,
538
+ token: await this.token(),
539
+ ...opts
540
+ })
413
541
  }
414
542
 
415
543
  /**
416
544
  * Upload a built `@uniweb/runtime` to the runtime registry (plan → PUT-per-file),
417
- * served at `/gateway/runtime/{version}`. @std-gated on the backend. Thin
545
+ * served by the backend at a location it reports. @std-gated on the backend. Thin
418
546
  * pass-through to utils/runtime-upload.js with this client's origin + token.
419
547
  * @param {object} opts - { version, distDir, files?, onProgress? }
420
548
  */
421
549
  async uploadRuntime(opts) {
422
- return uploadRuntime({ apiBase: this.origin, token: await this.token(), ...opts })
550
+ return uploadRuntime({
551
+ apiBase: this.origin,
552
+ token: await this.token(),
553
+ ...opts
554
+ })
423
555
  }
424
556
  }
425
557
 
426
558
  /** `@scope/name` → /dev/registry/data-schemas/{scope}/{name}; a bare name → …/{name}. */
427
559
  export function dataSchemaPath(modelName) {
428
560
  const m = /^@([^/]+)\/(.+)$/.exec(modelName)
429
- if (m) return `/dev/registry/data-schemas/${encodeURIComponent(m[1])}/${encodeURIComponent(m[2])}`
561
+ if (m)
562
+ return `/dev/registry/data-schemas/${encodeURIComponent(m[1])}/${encodeURIComponent(m[2])}`
430
563
  return `/dev/registry/data-schemas/${encodeURIComponent(modelName)}`
431
564
  }
432
565
 
@@ -20,16 +20,28 @@ import { buildAssetUrl } from '../utils/asset-upload.js'
20
20
  * @param {{ onProgress?: (m: string) => void }} [opts]
21
21
  * @returns {Promise<string>} the content-addressed serve URL (→ `info.data_bundle`)
22
22
  */
23
- export async function uploadDataBundle(client, ball, { onProgress } = {}) {
23
+ export async function uploadDataBundle(
24
+ client,
25
+ ball,
26
+ { siteUuid = null, onProgress } = {}
27
+ ) {
24
28
  const bytes = Buffer.from(JSON.stringify(ball))
25
29
  const sha256 = createHash('sha256').update(bytes).digest('hex')
26
30
  const localUrl = '/data-bundle/base.json' // bookkeeping key into assetsByLocalUrl
27
31
 
28
32
  const result = await client.uploadSiteAssets({
29
33
  files: [
30
- { path: 'data-bundle/base.json', content_type: 'application/json', size: bytes.length, sha256, localUrl, bytes },
34
+ {
35
+ path: 'data-bundle/base.json',
36
+ content_type: 'application/json',
37
+ size: bytes.length,
38
+ sha256,
39
+ localUrl,
40
+ bytes
41
+ }
31
42
  ],
32
- onProgress,
43
+ siteUuid,
44
+ onProgress
33
45
  })
34
46
  if (result.failed?.length) {
35
47
  const f = result.failed[0]