uniweb 0.27.0 → 0.28.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.27.0",
3
+ "version": "0.28.0",
4
4
  "description": "Create structured Vite + React sites with content/code separation",
5
5
  "type": "module",
6
6
  "bin": {
@@ -41,15 +41,15 @@
41
41
  "js-yaml": "^4.1.0",
42
42
  "prompts": "^2.4.2",
43
43
  "tar": "^7.0.0",
44
- "@uniweb/core": "^0.11.0",
45
- "@uniweb/runtime": "^0.12.6",
46
- "@uniweb/kit": "^0.13.1",
47
- "@uniweb/semantic-parser": "^1.2.3"
44
+ "@uniweb/runtime": "^0.12.9",
45
+ "@uniweb/semantic-parser": "^1.3.0",
46
+ "@uniweb/kit": "^0.13.2",
47
+ "@uniweb/core": "^0.11.1"
48
48
  },
49
49
  "peerDependencies": {
50
50
  "@uniweb/build": "^0.25.0",
51
- "@uniweb/semantic-parser": "^1.2.3",
52
- "@uniweb/content-reader": "^1.2.3"
51
+ "@uniweb/semantic-parser": "^1.3.0",
52
+ "@uniweb/content-reader": "^1.2.4"
53
53
  },
54
54
  "peerDependenciesMeta": {
55
55
  "@uniweb/build": {
@@ -305,11 +305,11 @@ type: Hero
305
305
  theme: dark
306
306
  ---
307
307
 
308
- ### V1.0.0 IS OUT ← pretitle (small label above the title)
308
+ #> New in v1 ← pretitle (the label line above the title)
309
309
 
310
310
  # Build the system. ← title (the big headline)
311
311
 
312
- ## Not every page. ← subtitle
312
+ ## Not every page. ← subtitle (one step smaller, directly below)
313
313
 
314
314
  Description paragraph.
315
315
 
@@ -318,7 +318,7 @@ Description paragraph.
318
318
  ![Image](./image.jpg)
319
319
  ```
320
320
 
321
- Heading levels set *structure* (pretitle, title, subtitle), not font size — the component controls visual sizing.
321
+ Heading levels set *structure* (pretitle, title, subtitle), not font size — the component controls visual sizing. The `#>` label line marks a pretitle explicitly (any number of leading `#`s spells the same label); a smaller ordinary heading directly above the title also becomes the pretitle.
322
322
 
323
323
  **A section with no `type:` renders through the foundation's default section type — a component named `Section`, unless the foundation's `main.js` sets `defaultSection` to something else.** This is what lets a folder of plain markdown with no frontmatter at all become pages: mounted documentation, an imported wiki, anything written before it met this framework. If such content renders blank, the foundation has no `Section` — that, not the markdown, is what to fix.
324
324
 
@@ -333,8 +333,10 @@ The semantic parser produces a flat, guaranteed structure. No null checks needed
333
333
  ```js
334
334
  content = {
335
335
  title: '', // Main heading (string or string[] for multi-line)
336
- pretitle: '', // Heading before main title (auto-detected)
337
- subtitle: '', // Heading after title (string or string[] for multi-line)
336
+ pretitle: '', // `#>` label line(s), or smaller headings stacked above
337
+ // the title (string or string[])
338
+ subtitle: '', // Line(s) one step below the title — each further
339
+ // one-step descent is another line (string or string[])
338
340
  paragraphs: [], // Text blocks
339
341
  links: [], // { href, label, role } — standalone links (not inside lists)
340
342
  images: [], // { src, alt, role, href }
@@ -347,7 +349,8 @@ content = {
347
349
  data: {}, // Tagged blocks — ```yaml:tag / ```json:tag give the parsed value,
348
350
  // ```md:tag gives { items, sequence } (see Concept blocks)
349
351
  tables: [], // Only when present — [{ rows: [{ cells: [{ children, header, align }] }] }]
350
- headings: [], // Headings after subtitle, in document order
352
+ headings: [], // Only from nested content (quote/list bodies) — a
353
+ // section's headline never spills here
351
354
  items: [], // Each has the same flat structure — from headings after body content
352
355
  sequence: [], // All elements in document order
353
356
  }
@@ -358,7 +361,7 @@ content = {
358
361
  ### Markdown → content, side by side
359
362
 
360
363
  ```markdown
361
- ### Eyebrow │ content.pretitle = "Eyebrow"
364
+ #> Eyebrow │ content.pretitle = "Eyebrow"
362
365
  # Our Features │ content.title = "Our Features"
363
366
  ## Build better products │ content.subtitle = "Build better products"
364
367
 
@@ -375,7 +378,7 @@ Lightning quick. │ content.items[0].paragraphs[0] = "Lightning
375
378
  Enterprise-grade security. │ content.items[1].paragraphs[0] = "Enterprise-grade…"
376
379
  ```
377
380
 
378
- The three rules that produce this: headings *before* the main title become `pretitle`; a heading *after* the title at lower importance becomes `subtitle`; headings appearing *after body content* start the `items` array.
381
+ The staircase rule produces this each heading relates to the one before it: the same size adds another line to the same part; **one step smaller** joins the headline as the next part down (the subtitle, then further subtitle lines); **two steps smaller** starts an item; and once body content has begun, *any* heading starts an item. `#>` label lines, and smaller headings stacked above the title, become `pretitle`.
379
382
 
380
383
  ### Items have the full content shape
381
384
 
@@ -391,21 +394,20 @@ seats: 1
391
394
  ``` ← items[0].data.details = { trial: "14 days", seats: 1 }
392
395
  ````
393
396
 
394
- ### Subtitle vs items — the level rule
397
+ ### Subtitle vs items — the step rule
395
398
 
396
- A heading immediately after the title becomes `subtitle` **only when it is exactly one level deeper** (H1→H2, H2→H3). Skipping levels (H1→H3) breaks the group and the deeper heading starts items instead. To get items with no subtitle, close the title group with a `---` divider or a paragraph:
399
+ A heading directly after the title becomes `subtitle` **only when it is exactly one step smaller** (H1→H2, H2→H3) and each *further* one-step line continues the subtitle (a three-line header is `subtitle: [line2, line3]`). **Two steps smaller starts items** the idiom for entries with no lead paragraph:
397
400
 
398
401
  ```markdown
399
402
  # Our Stats │ content.title = "Our Stats"
400
- --- divider closes the title group
401
- ## 15,000+ │ content.items[0].title = "15,000+"
403
+ ### 15,000+ content.items[0].title = "15,000+"
402
404
  Students from 90 countries │ content.items[0].paragraphs[0]
403
405
 
404
- ## 200+ │ content.items[1].title = "200+"
406
+ ### 200+ │ content.items[1].title = "200+"
405
407
  Programs offered │ content.items[1].paragraphs[0]
406
408
  ```
407
409
 
408
- Without the `---`, `## 15,000+` would become `content.subtitle`.
410
+ With `## 15,000+` instead, the first stat would join the headline as `content.subtitle`. A `---` divider, or any body content after the headline, also closes it — after body, headings of *any* size start items.
409
411
 
410
412
  ### Multi-line headings
411
413
 
@@ -421,7 +423,7 @@ Consecutive headings at the same level merge into a title array — one heading
421
423
  │ ]
422
424
  ```
423
425
 
424
- **Rule:** same-level continuation only applies *before* going deeper. Once a subtitle level is reached, same-level headings start new items instead of merging. Use `---` to force separate items where headings would otherwise merge.
426
+ **Rule:** a repeated size always continues the part it repeats title lines, or subtitle lines once the headline has stepped down. Use `---` to force separate items where adjacent headings would otherwise continue the headline.
425
427
 
426
428
  ### Sequential content
427
429
 
@@ -2047,6 +2049,11 @@ yes, so an event added in a later framework release is included without you
2047
2049
  changing anything — which is exactly why `standard` exists as well: it is a
2048
2050
  curated set that a release cannot grow behind your back.
2049
2051
 
2052
+ A site can also set **`flushIntervalMs`** to widen the batching window (default
2053
+ 5000, milliseconds — `30` is thirty *milliseconds*). A host that supplies your
2054
+ collector may set it for you and your own value wins; the trade is fewer
2055
+ requests against more tail loss at the end of a visit.
2056
+
2050
2057
  ⚠️ **`emit` never limits what YOU send.** `block.track()` and `useTracker()` are
2051
2058
  not filtered by it — the registry is open, and your events are yours. It governs
2052
2059
  only the ones the framework emits on its own. A host may narrow the list further
@@ -2211,7 +2218,7 @@ uniweb inspect <path> # Show parsed content for a section or page (-
2211
2218
  uniweb <command> --help # Per-command flags — no side effects. Prefer this over guessing.
2212
2219
  ```
2213
2220
 
2214
- **Four verbs dispatch but are deliberately not listed above.** `invite`, `handoff` and `template` are **reserved names with no implementation** — the flows they named ran against a backend the CLI no longer talks to, and the names are held so a future rebuild doesn't need a breaking change. `runtime register` uploads a built runtime and is internal — an **escape hatch** for a build npm does not have (an unreleased version, a local workspace tree, or a deployment that cannot reach the distribution channel), never the normal way a runtime reaches a backend. Running any of them is not useful; their absence from this list is the answer, not an omission to fix.
2221
+ **Three verbs dispatch but are deliberately not listed above.** `invite`, `handoff` and `template` are **reserved names with no implementation** — the flows they named ran against a backend the CLI no longer talks to, and the names are held so a future rebuild doesn't need a breaking change. Running any of them is not useful; their absence from this list is the answer, not an omission to fix.
2215
2222
 
2216
2223
  ### Where a site can live
2217
2224
 
@@ -2414,7 +2421,7 @@ Most Uniweb failures are **silent** — the build succeeds and the page is wrong
2414
2421
 
2415
2422
  **A section doesn't appear at all** — the file is `@`-prefixed (a child section, only rendered via `nest:`), or `_`-prefixed (treated as a draft and skipped), or it's a section type nested below the root of `sections/` without a `meta.js`, which means it was never discovered.
2416
2423
 
2417
- **Content lands in the wrong field** — a heading became a `subtitle` when you wanted an item, or the reverse. That's the level rule (exactly one level deeper = subtitle; skipping a level, or any body content first, starts items). Run `uniweb inspect <path>` rather than re-deriving it.
2424
+ **Content lands in the wrong field** — a heading became a `subtitle` when you wanted an item, or the reverse. That's the step rule (one step smaller and adjacent = the next headline line; two steps, a divider, or any body content first = items). Run `uniweb inspect <path>` rather than re-deriving it.
2418
2425
 
2419
2426
  **A var in frontmatter does nothing** — component vars only apply when declared in that section type's `meta.js` `vars:`. Unknown names are ignored silently.
2420
2427
 
@@ -40,7 +40,6 @@ import {
40
40
  } from '../utils/registry-orgs.js'
41
41
  import { uploadFoundationCode } from '../utils/code-upload.js'
42
42
  import { uploadSiteAssets } from '../utils/asset-upload.js'
43
- import { uploadRuntime } from '../utils/runtime-upload.js'
44
43
 
45
44
  /**
46
45
  * Resolve the backend origin via the resolution ladder (highest precedence
@@ -85,8 +84,7 @@ export function resolveBackendOrigin(flag, { siteBackend } = {}) {
85
84
  /**
86
85
  * The fallback capability doc when `GET /dev/config` is absent or unreachable
87
86
  * (an older backend, or no backend at all). Keeps the client non-breaking: the
88
- * bases mirror a self-serve dev backend, and `runtime.installed` is empty so
89
- * runtime resolution requires an explicit pin.
87
+ * bases mirror a self-serve dev backend.
90
88
  */
91
89
  export const DISCOVERY_DEFAULTS = {
92
90
  // ⛔ No serve-root default, and no `assetBase`. Serve locations are read from
@@ -99,10 +97,18 @@ export const DISCOVERY_DEFAULTS = {
99
97
  // production host, hardcoded, applied to EVERY deployment the CLI can be
100
98
  // pointed at. It was only ever read to compose an asset URL, which the plan
101
99
  // already returns as `serve_url`; both the reader and the composer are gone.
100
+ //
101
+ // No `runtime` entry, and there must not be one. A backend does not hold
102
+ // runtimes: a version is acquired from a CDN — the official mirror, the
103
+ // distribution channel, or a local server — so there is no installed set for
104
+ // it to report and nothing here to default. `runtime.installed` lived here
105
+ // until 2026-08-22, alongside a `uniweb runtime register` verb that pushed
106
+ // builds to a backend; both are gone. The runtime a site gets follows from
107
+ // its foundation's floor (`info.runtime`, stated at register), not from
108
+ // anything the CLI asks a backend about.
102
109
  auth: { loginPath: '/dev/auth/login', required: true },
103
110
  delivery: { deploy: true, publish: true, broker: 'self-serve' },
104
- assets: { supported: false },
105
- runtime: { installed: [] }
111
+ assets: { supported: false }
106
112
  }
107
113
 
108
114
  export class BackendClient {
@@ -224,8 +230,7 @@ export class BackendClient {
224
230
  * lifetime; a missing route or any transport/parse error falls back to
225
231
  * DISCOVERY_DEFAULTS (non-breaking — an older backend still works). Lets the
226
232
  * CLI hardcode nothing about a backend but its origin and discover the rest:
227
- * `auth`, `delivery` (deploy/publish? broker), `assets` (lane built yet?),
228
- * `runtime.installed` (the default-runtime source replacing the old /runtime/latest).
233
+ * `auth`, `delivery` (deploy/publish? broker), `assets` (lane built yet?).
229
234
  * @returns {Promise<object>}
230
235
  */
231
236
  async discover() {
@@ -428,8 +433,8 @@ export class BackendClient {
428
433
 
429
434
  /**
430
435
  * POST /dev/deploy — dumb, file-built delivery. Body is the deploy payload (the
431
- * runtime-init JSON `build-site-data.js` produces — foundation, runtimeVersion,
432
- * theme, languages, locales, optional dataFiles/searchFiles) plus an optional
436
+ * runtime-init JSON `build-site-data.js` produces — foundation, theme,
437
+ * languages, locales, optional dataFiles/searchFiles) plus an optional
433
438
  * `site_uuid`. First deploy of a never-synced site omits it → the backend mints
434
439
  * a uuid and returns it for write-back to deploy.yml; later deploys resend it so
435
440
  * the site's published URL stays stable. Returns the raw Response so the caller
@@ -451,27 +456,23 @@ export class BackendClient {
451
456
  * POST /dev/site/publish/{uuid} — CMS-publish a synced site (make its CURRENT
452
457
  * backend state live; it does NOT push local files). `{uuid}` is the site-content
453
458
  * uuid (`site.yml::$uuid`); a never-synced site 404s (sync first, or use deploy).
454
- * The CLI knows the runtime from its build; the body carries it snake-cased per
455
- * the route contract. Returns the raw Response ({ deploy_uuid, url,
456
- * published_folder_uuid, status } on 200).
459
+ * Languages, when present, go in the body; absent no body. Returns the raw
460
+ * Response ({ deploy_uuid, url, published_folder_uuid, status } on 200).
461
+ *
462
+ * ⛔ Sends NOTHING about a runtime. A `?runtime=<version>` param rode here until
463
+ * 2026-08-22, carrying a `site.yml::runtime` pin — a vestigial prop [Diego]: no
464
+ * template ever set it, no public doc described it, and the backend may already
465
+ * have been ignoring it. A site is codeless and has no basis for naming a
466
+ * runtime; the binding party is the FOUNDATION, whose floor travels as
467
+ * `info.runtime` at register. Do not reintroduce the param.
457
468
  * @param {string} uuid - the site-content uuid
458
- * @param {object} opts
459
- * @param {string} opts.runtimeVersion
469
+ * @param {object} [opts]
460
470
  * @param {string[]} [opts.languages]
461
471
  * @returns {Promise<Response>}
462
472
  */
463
- async publishSite(uuid, { runtimeVersion, languages } = {}) {
464
- // Runtime rides as a query param (?runtime=<version>) per the shipped /dev
465
- // route (D3, "request-carried"), NOT the body. Languages, when present, go in
466
- // the body; absent → no body (the route only requires the runtime).
467
- // Omitted when the site pins no runtime — silence is NOT a request to change
468
- // it, so the backend keeps the site on its current resolved version (its
469
- // order: body → current → UNIWEBD_DEFAULT_RUNTIME → highest installed → 400).
470
- // Sending a locally-computed guess instead would undo a propagation walk on
471
- // the next publish.
473
+ async publishSite(uuid, { languages } = {}) {
472
474
  return this.request(`/dev/site/publish/${encodeURIComponent(uuid)}`, {
473
475
  method: 'POST',
474
- ...(runtimeVersion ? { query: { runtime: runtimeVersion } } : {}),
475
476
  ...(languages ? { body: JSON.stringify({ languages }) } : {})
476
477
  })
477
478
  }
@@ -548,19 +549,6 @@ export class BackendClient {
548
549
  })
549
550
  }
550
551
 
551
- /**
552
- * Upload a built `@uniweb/runtime` to the runtime registry (plan → PUT-per-file),
553
- * served by the backend at a location it reports. @std-gated on the backend. Thin
554
- * pass-through to utils/runtime-upload.js with this client's origin + token.
555
- * @param {object} opts - { version, distDir, files?, onProgress? }
556
- */
557
- async uploadRuntime(opts) {
558
- return uploadRuntime({
559
- apiBase: this.origin,
560
- token: await this.token(),
561
- ...opts
562
- })
563
- }
564
552
  }
565
553
 
566
554
  /** `@scope/name` → /dev/registry/data-schemas/{scope}/{name}; a bare name → …/{name}. */
@@ -56,7 +56,10 @@ async function loadDependencies() {
56
56
  ])
57
57
  return {
58
58
  markdownToProseMirror: contentReader.markdownToProseMirror,
59
- parseContent: semanticParser.parseContent
59
+ parseContent: semanticParser.parseContent,
60
+ // Optional: present from @uniweb/semantic-parser >= the staircase
61
+ // release; older installs simply show no findings.
62
+ lintContent: semanticParser.lintContent
60
63
  }
61
64
  } catch {
62
65
  console.error(
@@ -303,6 +306,13 @@ function processFile(fileContent, fileName, deps, options) {
303
306
 
304
307
  if (insets.length > 0) result.insets = insets
305
308
 
309
+ // Grouping near-miss findings — shapes that usually mean the author got a
310
+ // different structure than they wanted (an entry absorbed into the
311
+ // headline, a hand-written eyebrow demoting the title). Observation only:
312
+ // the parse above is untouched.
313
+ const findings = deps.lintContent?.(doc) || []
314
+ if (findings.length > 0) result.findings = findings
315
+
306
316
  return result
307
317
  }
308
318
 
@@ -220,76 +220,33 @@ export async function publish(args = []) {
220
220
  return { exitCode: 1 }
221
221
  }
222
222
 
223
- // Runtime: an explicit site.yml::runtime pin wins; else the highest installed;
224
- // else fail closed (better than serving a site with no runtime). A dry-run is
225
- // a pure preview, so it only WARNS it stays useful with no backend reachable.
226
- const installed = Array.isArray(config?.runtime?.installed)
227
- ? config.runtime.installed
228
- : []
229
- if (
230
- siteYml.runtime &&
231
- installed.length &&
232
- !installed.includes(siteYml.runtime)
233
- ) {
234
- // ⚠️ Leads with REMOVING the pin, deliberately. A site ships no JS, so it
235
- // has no basis for holding a runtime version, and `runtime:` is an
236
- // operator-level override that is no longer part of the documented
237
- // authoring surface. This message used to say "pin one of these in
238
- // site.yml" — which pushed a reader deeper into a mechanism they should
239
- // not be using, and named a key the docs no longer describe. Keep the
240
- // installed list (it is the actionable part when a pin IS intended), but
241
- // do not restore pin-first phrasing.
242
- say.err(
243
- `Runtime ${siteYml.runtime} (pinned in site.yml) is not installed on the backend.`
244
- )
245
- say.dim(
246
- `Remove the \`runtime:\` pin and the backend chooses — a site ships no code, so it has no reason to hold one.`
247
- )
248
- say.dim(
249
- `Installed: ${installed.join(', ') || '(none)'} — or pin one of those, or have ${siteYml.runtime} installed.`
250
- )
251
- if (!dryRun) return { exitCode: 1 }
252
- }
253
- // An explicit pin is sent; NOTHING is synthesized when site.yml is silent.
254
- //
255
- // This used to fall back to the highest version the backend reported
256
- // installed. That is the producer guessing at a fact the control plane owns —
257
- // and once propagation moves sites, actively wrong: a walk advances a site to
258
- // X, and the next publish would restate a *different* version the producer
259
- // computed locally, silently undoing it.
223
+ // NOTHING about a runtime is sent from here. `site.yml::runtime` was a
224
+ // vestigial prop and is no longer read [Diego, 2026-08-22]; `?runtime=` is no
225
+ // longer a query param on publish. Do not reintroduce either.
260
226
  //
261
- // The deeper reason, upstream of ownership: a link-mode site is CODELESS. It
227
+ // The chain is SITE FOUNDATION RUNTIME. A link-mode site is CODELESS: it
262
228
  // ships no JS, so it has nothing that binds to a runtime version and cannot
263
229
  // break when the runtime moves. Asking it to name one is not a hard question,
264
230
  // it is a malformed one. The party that binds is the FOUNDATION, whose build
265
231
  // externalizes react / react-dom / jsx-runtime / @uniweb/core — which is why
266
- // the compatibility floor rides on the foundation (`info.runtime`, set by
267
- // `register`) and not here. See
268
- // the site/foundation/runtime model, § "who gets to say
269
- // whether a site accepts a newer runtime".
270
- //
271
- // Silence is therefore not a request to change the runtime, and the backend
272
- // resolves it: an explicit pin → the site's CURRENT resolved runtime →
273
- // UNIWEBD_DEFAULT_RUNTIME → (self-serve) highest installed.
232
+ // the compatibility floor rides on the foundation (`dist/runtime-pin.json`
233
+ // `info.runtime`, stated on every register) and never here. Whoever resolves a
234
+ // whole site picks a runtime satisfying max() of the floors its foundation and
235
+ // extensions declare, and fetches it from a CDN — the official mirror, the
236
+ // distribution channel, or a local server. A backend never holds one.
274
237
  //
275
- // ⚠️ THAT LAST SENTENCE IS A CLAIM ABOUT ANOTHER LANE, and it was FALSE when it
276
- // was first written here `/dev/site/publish` required `?runtime=` with no
277
- // fallback at all, so an unpinned publish 400'd and no scaffolded project could
278
- // publish. It read as true because the `/api` lane did have the fallback, and
279
- // the backend's own doc justified the omission with "the CLI always pins from
280
- // site.yml" which was equally false, since no template ships a `runtime:` key.
281
- // Two complementary assumptions, each load-bearing for the other lane, neither
282
- // ever checked. Found by driving the CLI against a live backend, which is the
283
- // only vantage point from which either assumption is visible.
284
- // ✅ Now the shipped contract, agreed with the backend and verified end to end
285
- // with the workaround removed (2026-08-16). Re-verify against the backend
286
- // rather than trusting this comment if it starts mattering again.
287
- //
288
- // ⚠️ Do NOT reintroduce a local fallback. Sending our own guess when the site
289
- // did not ask is what makes an unpinned republish regress. (The pinned path is
290
- // unaffected — an explicit pin is still validated fail-closed above, and the
291
- // backend refuses a backward move unless forced.)
292
- const runtimeVersion = siteYml.runtime || null
238
+ // History, because each removal undid a different bad idea:
239
+ // a local fallback to the highest version the backend reported installed —
240
+ // the producer guessing at a fact the control plane owns, and actively
241
+ // wrong once propagation moves sites (a walk advances a site to X; the
242
+ // next publish would restate a locally-computed value, silently undoing
243
+ // it). Removed in `6d32d94`.
244
+ // fail-closed validation of a pin against `/dev/config`'s
245
+ // `runtime.installed`, and the `uniweb runtime register` verb that
246
+ // populated that field. Removed 2026-08-22 with the concept.
247
+ // the pin itself, and the `?runtime=` it rode on. Removed 2026-08-22 —
248
+ // nothing scaffolded ever set it, it is in no public doc, and the backend
249
+ // may already have been ignoring it.
293
250
 
294
251
  // deploy.yml target (the Uniweb hosting memory). No --target on publish — it
295
252
  // always targets Uniweb hosting; resolveTarget gives us the target name +
@@ -351,9 +308,6 @@ export async function publish(args = []) {
351
308
  if (dryRun) {
352
309
  say.info('Dry run — would bring the foundation along, sync, and go live:')
353
310
  say.dim(`Backend : ${client.origin}`)
354
- say.dim(
355
- `Runtime : ${runtimeVersion || '(not pinned — the backend keeps this site on its current runtime)'}`
356
- )
357
311
  say.dim(
358
312
  `site_uuid : ${siteYml.$uuid || '(none — the site is created before anything uploads)'}`
359
313
  )
@@ -718,7 +672,6 @@ export async function publish(args = []) {
718
672
  let pubRes
719
673
  try {
720
674
  pubRes = await client.publishSite(siteUuid, {
721
- runtimeVersion,
722
675
  ...(languages ? { languages } : {})
723
676
  })
724
677
  } catch (err) {
@@ -781,11 +734,11 @@ export async function publish(args = []) {
781
734
  ...(recordedRef ? { ref: recordedRef } : {}),
782
735
  released: fnd.released
783
736
  },
784
- // Only when the site pinned one. An unpinned site's runtime is resolved by
785
- // the backend and can move under propagation, so recording a value here
786
- // would be a snapshot that silently goes stale and `deploy.yml` is a
787
- // record of what this publish did, not a cache of backend state.
788
- ...(runtimeVersion ? { runtime: runtimeVersion } : {}),
737
+ // No `runtime` here. `deploy.yml` records what this publish DID, and a
738
+ // publish sends nothing about a runtime the site's runtime follows from
739
+ // its foundation's floor and is resolved by whoever serves it. Recording a
740
+ // value would be a snapshot that silently goes stale, of a decision this
741
+ // command does not make.
789
742
  locales: Array.isArray(result.locales) ? result.locales : languages
790
743
  }
791
744
  })
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "schemaVersion": 1,
3
- "generatedAt": "2026-08-20T01:09:52.840Z",
3
+ "generatedAt": "2026-08-22T18:52:00.408Z",
4
4
  "packages": {
5
5
  "@uniweb/build": {
6
6
  "version": "0.25.0",
@@ -19,17 +19,17 @@
19
19
  ]
20
20
  },
21
21
  "@uniweb/content-reader": {
22
- "version": "1.2.3",
22
+ "version": "1.2.4",
23
23
  "path": "framework/content-reader",
24
24
  "deps": []
25
25
  },
26
26
  "@uniweb/content-writer": {
27
- "version": "0.3.3",
27
+ "version": "0.3.4",
28
28
  "path": "framework/content-writer",
29
29
  "deps": []
30
30
  },
31
31
  "@uniweb/core": {
32
- "version": "0.11.0",
32
+ "version": "0.11.1",
33
33
  "path": "framework/core",
34
34
  "deps": [
35
35
  "@uniweb/semantic-parser",
@@ -49,7 +49,7 @@
49
49
  ]
50
50
  },
51
51
  "@uniweb/kit": {
52
- "version": "0.13.1",
52
+ "version": "0.13.2",
53
53
  "path": "framework/kit",
54
54
  "deps": [
55
55
  "@uniweb/core",
@@ -76,7 +76,7 @@
76
76
  ]
77
77
  },
78
78
  "@uniweb/runtime": {
79
- "version": "0.12.6",
79
+ "version": "0.12.9",
80
80
  "path": "framework/runtime",
81
81
  "deps": [
82
82
  "@uniweb/core",
@@ -99,7 +99,7 @@
99
99
  "deps": []
100
100
  },
101
101
  "@uniweb/semantic-parser": {
102
- "version": "1.2.3",
102
+ "version": "1.3.0",
103
103
  "path": "framework/semantic-parser",
104
104
  "deps": []
105
105
  },
@@ -114,7 +114,7 @@
114
114
  "deps": []
115
115
  },
116
116
  "@uniweb/unipress": {
117
- "version": "0.8.10",
117
+ "version": "0.8.11",
118
118
  "path": "framework/unipress",
119
119
  "deps": [
120
120
  "@uniweb/build",
package/src/index.js CHANGED
@@ -843,15 +843,6 @@ async function main() {
843
843
  return
844
844
  }
845
845
 
846
- // Handle runtime command — `runtime register` uploads a built @uniweb/runtime to
847
- // the backend's runtime registry; @std-gated. Where it is served from is the
848
- // backend's to report, not ours to know.
849
- if (command === 'runtime') {
850
- const { runtime } = await import('./commands/runtime.js')
851
- const result = await runtime(args.slice(1))
852
- process.exit(result?.exitCode ?? 0)
853
- }
854
-
855
846
  // Handle invite command
856
847
  if (command === 'invite') {
857
848
  await invite(args.slice(1))
@@ -1721,7 +1712,6 @@ ${colors.bright}Commands:${colors.reset}
1721
1712
  export Export a self-contained site for third-party hosting
1722
1713
  register Register a foundation + its data schemas with the backend registry
1723
1714
  release Release a foundation version (synonym of register)
1724
- runtime register Escape hatch: push a LOCAL/unreleased runtime build (@std only)
1725
1715
  push Push a site's content to the backend
1726
1716
  pull Pull a site's content from the backend
1727
1717
  refresh Catch up with the git remote AND the backend (never pushes)
@@ -1,249 +0,0 @@
1
- /**
2
- * uniweb runtime register — upload a built `@uniweb/runtime` to the backend so it
3
- * can serve the runtime version. The runtime is a SYSTEM artifact: registering it
4
- * requires **@std membership** (a non-@std bearer 403s).
5
- *
6
- * ─────────────────────────────────────────────────────────────────────────────
7
- * ⛔ THIS IS AN ESCAPE HATCH. IT IS NOT HOW A RUNTIME NORMALLY REACHES A BACKEND.
8
- * ─────────────────────────────────────────────────────────────────────────────
9
- *
10
- * The normal path is **publish to npm**, which CI mirrors into the public
11
- * distribution channel, from which a backend acquires the version itself — no
12
- * login, no per-backend push, no credentials leaving the backend.
13
- *
14
- * This verb pushes ONE build to ONE backend, by hand. Its cost is not the
15
- * upload, it is that the result is **invisible to every other backend and to
16
- * the channel**: a version installed this way exists nowhere else, cannot be
17
- * verified against a published digest, and will not be what a second
18
- * environment resolves. Two backends can end up serving different bytes under
19
- * one version number, which is the exact defect the channel's immutability
20
- * exists to make impossible.
21
- *
22
- * It stays because three cases have no other path, and they are all LOCAL or
23
- * pre-release:
24
- *
25
- * • **an unpublished build** — npm versions are immutable, so you cannot
26
- * iterate on a published one. Testing a runtime before releasing it goes
27
- * through here.
28
- * • **local workspace development** — `@uniweb/runtime` is a pnpm symlink to
29
- * the working tree; there is no published artifact for the code you are
30
- * editing.
31
- * • **a deployment that cannot pull** — air-gapped or policy-restricted, or
32
- * any environment without the egress the channel needs.
33
- *
34
- * ⇒ **If the version you are pushing is on npm, you almost certainly want the
35
- * channel instead.** The command warns at runtime for that reason.
36
- *
37
- * A foundation emits `dist/runtime-pin.json`. `uniweb register` now reads it and
38
- * ships it as the foundation's `info.runtime` — but **NOTHING ENFORCES IT** in
39
- * any lane. An earlier version of this comment claimed `uniweb register` of a
40
- * foundation fails when its pinned version isn't registered. That was never
41
- * true, and it propagated into internal notes AND into four public
42
- * documentation surfaces before anyone checked it against `commands/register.js`
43
- * three files away.
44
- *
45
- * The pin is a **compatibility floor**, not a selector: a site loads a primary
46
- * foundation plus N extensions, each emitting its own pin, and a site has exactly
47
- * one runtime — so pins are plural and the selector must be singular. The selector
48
- * is `site.yml::runtime` (see commands/publish.js), which the backend stamps into
49
- * the site's meta at publish. The pin's use is VALIDATION — is the selected
50
- * runtime at or above max() of every loaded foundation's floor? — and it belongs
51
- * wherever all of a site's foundations are held. The producer publishes one
52
- * foundation at a time and cannot see the others, so it STATES its floor and
53
- * something holding the whole set does the arithmetic.
54
- *
55
- * Contract AGREED with the backend (2026-06-14): `POST /dev/runtime`, @std-gated,
56
- * manifest-last. Wire + the two-half artifact set (SPA + ssr-edge isolate, the
57
- * orchestrator stays platform-owned): utils/runtime-upload.js.
58
- *
59
- * Usage (escape hatch — prefer publishing to npm; see above):
60
- * uniweb runtime register From framework/runtime (or --path <dir>)
61
- * uniweb runtime register --path <dir> The @uniweb/runtime package dir
62
- * uniweb runtime register --version <v> Override dist/app/manifest.json's version
63
- * uniweb runtime register --backend <url> Override the backend origin
64
- * uniweb runtime register --token <bearer> Auth bearer (skips `uniweb login`)
65
- * uniweb runtime register --dry-run Print the version + file plan; upload nothing
66
- */
67
-
68
- import { readFileSync } from 'node:fs'
69
- import { resolve, join } from 'node:path'
70
-
71
- import { BackendClient } from '../backend/client.js'
72
- import {
73
- collectRuntimeFiles,
74
- hasWorkerRuntime,
75
- hasShims
76
- } from '../utils/runtime-upload.js'
77
- import { readFlagValue } from '../utils/args.js'
78
-
79
- const c = {
80
- reset: '\x1b[0m',
81
- bold: '\x1b[1m',
82
- dim: '\x1b[2m',
83
- cyan: '\x1b[36m',
84
- green: '\x1b[32m',
85
- yellow: '\x1b[33m',
86
- red: '\x1b[31m'
87
- }
88
- const say = {
89
- ok: (m) => console.log(`${c.green}✓${c.reset} ${m}`),
90
- info: (m) => console.log(`${c.cyan}→${c.reset} ${m}`),
91
- warn: (m) => console.log(`${c.yellow}⚠${c.reset} ${m}`),
92
- err: (m) => console.error(`${c.red}✗${c.reset} ${m}`),
93
- dim: (m) => console.log(` ${c.dim}${m}${c.reset}`)
94
- }
95
-
96
- // The runtime package dir: --path, else the cwd when it IS @uniweb/runtime.
97
- function resolveRuntimeDir(args) {
98
- const pathFlag = readFlagValue(args, '--path')
99
- if (pathFlag) return resolve(pathFlag)
100
- try {
101
- if (
102
- JSON.parse(readFileSync(join(process.cwd(), 'package.json'), 'utf8'))
103
- .name === '@uniweb/runtime'
104
- ) {
105
- return process.cwd()
106
- }
107
- } catch {
108
- // no readable package.json — fall through
109
- }
110
- return null
111
- }
112
-
113
- export async function runtime(args = []) {
114
- const sub = args[0]
115
- if (sub !== 'register') {
116
- say.err(
117
- sub
118
- ? `Unknown subcommand: runtime ${sub}`
119
- : 'Usage: uniweb runtime register'
120
- )
121
- say.dim(
122
- 'uniweb runtime register — upload the built @uniweb/runtime to the backend (@std only).'
123
- )
124
- return { exitCode: sub ? 1 : 0 }
125
- }
126
- const rest = args.slice(1)
127
- const dryRun = rest.includes('--dry-run')
128
-
129
- // Say it at the moment of use, not only in the header. A verb that works
130
- // fine is a verb people keep reaching for: this one succeeds quietly and
131
- // leaves a version that exists on exactly one backend, unverifiable against
132
- // any published digest.
133
- say.warn('`runtime register` is an escape hatch, not the normal path.')
134
- say.dim('A published runtime reaches a backend through the distribution channel — no push, no login.')
135
- say.dim('Use this for a build npm does not have: an unreleased version, a local workspace tree,')
136
- say.dim('or a deployment that cannot reach the channel. Pushing a version that IS on npm installs')
137
- say.dim('bytes nothing else can verify, and a second environment will resolve something different.')
138
- console.log('')
139
-
140
- const runtimeDir = resolveRuntimeDir(rest)
141
- if (!runtimeDir) {
142
- say.err('Not an @uniweb/runtime package.')
143
- say.dim('Run from framework/runtime, or pass --path <dir>.')
144
- return { exitCode: 2 }
145
- }
146
- const distDir = join(runtimeDir, 'dist')
147
- const files = collectRuntimeFiles(distDir)
148
- if (!files.length) {
149
- say.err('No built runtime found (dist/app/).')
150
- say.dim('Build it first: `pnpm build` in framework/runtime.')
151
- return { exitCode: 2 }
152
- }
153
-
154
- // Version from the SPA build's manifest (the backend keys the version on it);
155
- // --version overrides, parity with the backend's runtime install --version.
156
- let version = readFlagValue(rest, '--version')
157
- if (!version) {
158
- try {
159
- version = JSON.parse(
160
- readFileSync(join(distDir, 'app', 'manifest.json'), 'utf8')
161
- ).version
162
- } catch (err) {
163
- say.err(`Could not read dist/app/manifest.json: ${err.message}`)
164
- return { exitCode: 2 }
165
- }
166
- if (!version) {
167
- say.err(
168
- 'dist/app/manifest.json has no "version" field — rebuild the runtime.'
169
- )
170
- return { exitCode: 2 }
171
- }
172
- }
173
- // The ssr-edge artifact is a SET: worker-runtime.js + its shims/*.js. Warn when
174
- // the set is absent or incomplete (a worker without shims can't resolve react).
175
- if (!hasWorkerRuntime(files)) {
176
- say.warn(
177
- "dist/worker-runtime.js is missing — the SSR isolate bundle won't be uploaded."
178
- )
179
- say.dim(
180
- 'Build it first: `pnpm build:worker` in @uniweb/runtime (after `pnpm build`).'
181
- )
182
- } else if (!hasShims(files)) {
183
- say.warn(
184
- 'dist/worker-runtime.js is present but dist/shims/ is missing — the SSR isolate set is incomplete.'
185
- )
186
- say.dim(
187
- 'The isolate resolves react/jsx-runtime/@uniweb/core through those shims; re-run `pnpm build:worker`.'
188
- )
189
- }
190
-
191
- if (dryRun) {
192
- say.info(
193
- `Would register ${c.bold}@uniweb/runtime@${version}${c.reset} (${files.length} files):`
194
- )
195
- for (const f of files)
196
- say.dim(`${f.path} ${f.size} bytes ${f.content_type}`)
197
- return { exitCode: 0 }
198
- }
199
-
200
- const client = new BackendClient({
201
- originFlag:
202
- readFlagValue(rest, '--backend') || readFlagValue(rest, '--registry'),
203
- token: readFlagValue(rest, '--token') || undefined,
204
- args: rest,
205
- command: 'Registering the runtime'
206
- })
207
-
208
- say.info(
209
- `Registering ${c.bold}@uniweb/runtime@${version}${c.reset} → ${c.dim}${client.origin}${c.reset} (${files.length} files)…`
210
- )
211
- let result
212
- try {
213
- result = await client.uploadRuntime({
214
- version,
215
- distDir,
216
- files,
217
- onProgress: (m) => say.dim(m)
218
- })
219
- } catch (err) {
220
- if (err.status === 403) {
221
- say.err(
222
- 'Not authorized — registering a runtime version requires @std membership.'
223
- )
224
- return { exitCode: 1 }
225
- }
226
- say.err(`Runtime registration failed: ${err.message}`)
227
- say.dim(
228
- 'Set the origin with --backend <url>; auth with `uniweb login` or --token <bearer>.'
229
- )
230
- return { exitCode: 1 }
231
- }
232
- if (result.failed.length) {
233
- say.err(`${result.failed.length} file(s) failed to upload:`)
234
- for (const f of result.failed)
235
- say.dim(`${f.path} — HTTP ${f.status} ${f.detail}`)
236
- say.dim(
237
- 'Re-run `uniweb runtime register` to resume — completed files dedupe.'
238
- )
239
- return { exitCode: 1 }
240
- }
241
- console.log('')
242
- say.ok(
243
- `Registered ${c.bold}@uniweb/runtime@${version}${c.reset} (${result.uploaded.length} files, ${result.mode} mode)`
244
- )
245
- if (result.serveBase) say.dim(`served at ${result.serveBase}`)
246
- return { exitCode: 0 }
247
- }
248
-
249
- export default runtime
@@ -1,201 +0,0 @@
1
- /**
2
- * Runtime registration — the framework half of `uniweb runtime register`.
3
- *
4
- * Uploads a built `@uniweb/runtime` to the backend so it can serve the runtime
5
- * version. Mirrors the foundation code lane (utils/code-upload.js): plan →
6
- * PUT-per-file, one mode-aware auth rule.
7
- *
8
- * The runtime is a SYSTEM artifact — registering it requires **@std membership**
9
- * (a non-@std bearer 403s). Versioned by version alone (no scope/name).
10
- *
11
- * A runtime version is ONE unit with two halves, BOTH uploaded here (the backend
12
- * stages the bytes; where/how it serves them is its decision — we don't assume a
13
- * serve path, we read `serve_base` back from the plan):
14
- * - dist/app/** the browser SPA (_importmap/, assets/, index.html,
15
- * manifest.json) — boots + client-renders a site.
16
- * - dist/worker-runtime.js + dist/shims/*.js the ssr-edge isolate set (4
17
- * files): the inlined SSR bundle + its 3 globalThis-bridge
18
- * shims (react, react/jsx-runtime, @uniweb/core). The
19
- * isolate can't resolve react without the shims.
20
- *
21
- * NOT uploaded: the SSR *orchestrator* (the isolate's `entry.js` boot module). It's
22
- * a serverless-isolate fetch handler encoding the platform's isolate dispatch
23
- * protocol — owned by the platform's SSR layer, not a framework artifact. The
24
- * framework ships the render API the orchestrator imports (worker-runtime.js
25
- * exports initPrerenderForLocale / renderPage / injectPageContent / hydrateDataStore).
26
- *
27
- * Contract — AGREED with the backend (2026-06-14):
28
- * PLAN POST {apiBase}/dev/runtime
29
- * { version, files: [{ path, content_type, size, sha256 }] }
30
- * → { mode, expires_in, serve_base, uploads: [{ path, method, url, headers }] }
31
- * bearer; @std required (else 403, RFC7807 problem+json, op "runtime-register").
32
- * UPLOAD PUT each file (direct → bearer; presigned → none; x-uniweb-sha256).
33
- * MANIFEST LAST — discovery keys a version's existence on manifest.json,
34
- * so a partial upload never advertises a half-delivered version.
35
- * SERVE the backend's call (read `serve_base` from the plan; we never construct it).
36
- */
37
-
38
- import { createHash } from 'node:crypto'
39
- import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs'
40
- import { join } from 'node:path'
41
- import { contentTypeFor } from './code-upload.js'
42
-
43
- const WORKER_RUNTIME = 'worker-runtime.js'
44
- const SHIMS_DIR = 'shims'
45
- const MANIFEST = 'manifest.json'
46
-
47
- function fileEntry(diskPath, path) {
48
- const bytes = readFileSync(diskPath)
49
- return {
50
- path,
51
- content_type: contentTypeFor(path),
52
- size: bytes.length,
53
- sha256: createHash('sha256').update(bytes).digest('hex'),
54
- diskPath
55
- }
56
- }
57
-
58
- /**
59
- * Collect a built runtime's upload set from `distDir` (framework/runtime/dist):
60
- * everything under `dist/app/**` at the root, plus `dist/worker-runtime.js` and
61
- * the `dist/shims/*.js` it depends on. Sourcemaps (`*.map`) are excluded (dev-only,
62
- * not CDN-served). Returns `[]` when `dist/app/` is missing (the runtime isn't
63
- * built). The worker bundle + shims are collected when present (graceful when not).
64
- *
65
- * @param {string} distDir - framework/runtime/dist
66
- * @returns {Array<{ path, content_type, size, sha256, diskPath }>}
67
- */
68
- export function collectRuntimeFiles(distDir) {
69
- const appDir = join(distDir, 'app')
70
- if (!existsSync(appDir)) return []
71
- const files = []
72
- const walk = (dir, prefix) => {
73
- for (const name of readdirSync(dir).sort()) {
74
- const full = join(dir, name)
75
- const rel = prefix ? `${prefix}/${name}` : name
76
- const st = statSync(full)
77
- if (st.isDirectory()) walk(full, rel)
78
- else if (st.isFile() && !rel.endsWith('.map'))
79
- files.push(fileEntry(full, rel))
80
- }
81
- }
82
- walk(appDir, '')
83
- const worker = join(distDir, WORKER_RUNTIME)
84
- if (existsSync(worker)) files.push(fileEntry(worker, WORKER_RUNTIME))
85
- // The SSR isolate's globalThis-bridge shims ride alongside worker-runtime.js,
86
- // served at shims/*.js. They're part of the ssr-edge artifact SET — the isolate
87
- // can't resolve `react` without them — so collect the whole dir when present.
88
- const shimsDir = join(distDir, SHIMS_DIR)
89
- if (existsSync(shimsDir)) walk(shimsDir, SHIMS_DIR)
90
- // MANIFEST LAST: the backend keys a version's existence on manifest.json (no
91
- // server confirm step), so uploading it last means a partial delivery never
92
- // advertises a half-built version. Reorder regardless of walk order.
93
- const manifest = files.filter((f) => f.path === MANIFEST)
94
- if (!manifest.length) return files
95
- return [...files.filter((f) => f.path !== MANIFEST), ...manifest]
96
- }
97
-
98
- /** True when the worker SSR bundle is in the collected set. */
99
- export function hasWorkerRuntime(files) {
100
- return files.some((f) => f.path === WORKER_RUNTIME)
101
- }
102
-
103
- /** True when any SSR-isolate shim (shims/*.js) is in the collected set. */
104
- export function hasShims(files) {
105
- return files.some((f) => f.path.startsWith(`${SHIMS_DIR}/`))
106
- }
107
-
108
- /**
109
- * Plan + upload a built runtime. Throws on a plan-level failure (the caller maps
110
- * 403 → "@std only"); per-file PUT failures surface in `failed`.
111
- *
112
- * @param {object} opts - { apiBase, token, version, distDir, files?, onProgress? }
113
- * @returns {Promise<{ mode, uploaded: string[], failed: Array, serveBase: string|null }>}
114
- */
115
- export async function uploadRuntime({
116
- apiBase,
117
- token,
118
- version,
119
- distDir,
120
- files,
121
- onProgress = () => {}
122
- }) {
123
- const list = files || collectRuntimeFiles(distDir)
124
- if (!list.length)
125
- return { mode: 'none', uploaded: [], failed: [], serveBase: null }
126
-
127
- const origin = apiBase.replace(/\/$/, '')
128
- const planRes = await fetch(`${origin}/dev/runtime`, {
129
- method: 'POST',
130
- headers: {
131
- 'Content-Type': 'application/json',
132
- Authorization: `Bearer ${token}`
133
- },
134
- body: JSON.stringify({
135
- version,
136
- files: list.map(({ path, content_type, size, sha256 }) => ({
137
- path,
138
- content_type,
139
- size,
140
- sha256
141
- }))
142
- })
143
- })
144
- if (!planRes.ok) {
145
- const detail = await planRes.text().catch(() => '')
146
- const err = new Error(
147
- `runtime plan rejected: HTTP ${planRes.status}${detail ? ` — ${detail.slice(0, 300)}` : ''}`
148
- )
149
- err.status = planRes.status
150
- throw err
151
- }
152
- const plan = await planRes.json()
153
- const targets = new Map((plan.uploads || []).map((u) => [u.path, u]))
154
- // The one mode-aware bit: direct PUTs are bearer-authed backend routes;
155
- // presigned URLs are self-authorizing and must NOT carry a foreign bearer.
156
- const authHeaders =
157
- plan.mode === 'presigned' ? {} : { Authorization: `Bearer ${token}` }
158
-
159
- const uploaded = []
160
- const failed = []
161
- for (const f of list) {
162
- const target = targets.get(f.path)
163
- if (!target) {
164
- failed.push({
165
- path: f.path,
166
- status: 0,
167
- detail: 'no upload target in plan'
168
- })
169
- continue
170
- }
171
- onProgress(`↑ ${f.path}`)
172
- let res
173
- try {
174
- res = await fetch(new URL(target.url, origin), {
175
- method: target.method || 'PUT',
176
- headers: {
177
- ...(target.headers || {}),
178
- ...authHeaders,
179
- 'x-uniweb-sha256': f.sha256
180
- },
181
- body: readFileSync(f.diskPath)
182
- })
183
- } catch (err) {
184
- failed.push({ path: f.path, status: 0, detail: err.message })
185
- continue
186
- }
187
- if (res.ok) uploaded.push(f.path)
188
- else
189
- failed.push({
190
- path: f.path,
191
- status: res.status,
192
- detail: (await res.text().catch(() => '')).slice(0, 200)
193
- })
194
- }
195
- return {
196
- mode: plan.mode || 'direct',
197
- uploaded,
198
- failed,
199
- serveBase: plan.serve_base || null
200
- }
201
- }