uniweb 0.13.6 → 0.13.7

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.13.6",
3
+ "version": "0.13.7",
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/kit": "0.9.38",
45
- "@uniweb/core": "0.7.30",
46
- "@uniweb/runtime": "0.8.37"
44
+ "@uniweb/core": "0.7.31",
45
+ "@uniweb/kit": "0.9.39",
46
+ "@uniweb/runtime": "0.8.38"
47
47
  },
48
48
  "peerDependencies": {
49
- "@uniweb/build": "0.15.6",
50
- "@uniweb/content-reader": "1.1.16",
51
- "@uniweb/semantic-parser": "1.1.19"
49
+ "@uniweb/content-reader": "1.1.17",
50
+ "@uniweb/semantic-parser": "1.1.20",
51
+ "@uniweb/build": "0.15.7"
52
52
  },
53
53
  "peerDependenciesMeta": {
54
54
  "@uniweb/build": {
@@ -472,6 +472,8 @@ Optional attributes: `{size=20}`, `{color=red}`. Custom SVGs: `![Logo](./logo.sv
472
472
 
473
473
  **Quote values containing spaces:** `{note="Ready to go"}`, not `{note=Ready to go}` — unquoted values end at the first space.
474
474
 
475
+ **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.
476
+
475
477
  Standalone links (alone on a line) become buttons in `content.links[]`. Inline links stay as `<a>` tags inside `content.paragraphs[]`. Multiple links sharing a paragraph are all promoted:
476
478
 
477
479
  ```markdown
@@ -1278,8 +1280,9 @@ Pages are sequences of sections — the obvious layer. The framework also suppor
1278
1280
  | **Items** (`content.items`) | Heading groups within one `.md` | Repeating content in one section: cards, features, FAQ entries |
1279
1281
  | **Child sections** (`block.childBlocks`) | `@`-prefixed `.md` files + `nest:` | Children needing their own section type, rich content, or independent editing |
1280
1282
  | **Insets** (`block.insets`) | `![](@Component)` in markdown | Self-contained visuals/widgets: charts, diagrams, code demos |
1283
+ | **Block insets** (`block.insets`) | ` ```@Component ` fence around markdown | A component that *wraps* authored prose: callouts, disclosures, admonitions |
1281
1284
 
1282
- Does the author write content *inside* the nested element? **Yes** → child sections. **No** (self-contained, param-driven) → inset. Repeating same-structure groups → items. These compose: a child section can contain insets; items work inside children.
1285
+ Does the author write content *inside* the nested element? **Yes** → child sections, or a block inset when the wrapper is presentational and lives mid-page. **No** (self-contained, param-driven) → inset. Repeating same-structure groups → items. These compose: a child section can contain insets; items work inside children; a block inset can contain both.
1283
1286
 
1284
1287
  **Insets — embedding components in content.** Many section types need a "visual" — a hero's illustration, a split-content section's media. Classically an image or video. But what if it's a JSX + SVG diagram, a ThreeJS animation, an interactive playground? Elsewhere you'd reach for MDX or prop-drilling. Here the author writes standard image syntax:
1285
1288
 
@@ -1297,6 +1300,23 @@ The developer builds `NetworkDiagram` as an ordinary React component with `inset
1297
1300
 
1298
1301
  **Don't use `hidden: true` on insets.** `hidden` means "don't export this component at all" (internal helpers); `inset: true` means "available for `@Component` references in markdown."
1299
1302
 
1303
+ **Block insets — a component that wraps content.** The image form is a leaf: it takes params but no body. When the component should surround authored prose — a callout, a disclosure, an admonition — use the fenced form instead. Same `@Component{params}` reference, written as a code fence's info string:
1304
+
1305
+ ````markdown
1306
+ ```@Alert{type=warning}
1307
+ Back up your database **before** running this.
1308
+
1309
+ - The migration is not reversible
1310
+ - Allow ten minutes of downtime
1311
+ ```
1312
+ ````
1313
+
1314
+ The body is ordinary content, not text: it is parsed exactly like the rest of the page, so headings, lists, tables, icons, inline styling, leaf insets and further containers all work inside one. To nest a code block or another container, open the outer fence with more backticks than the inner one.
1315
+
1316
+ **The component is yours, and it receives parsed content.** `@Alert` resolves against the foundation through the same lookup as `![](@NetworkDiagram)` — declare it with `inset: true` in `meta.js`. Where a leaf inset gets its alt text as `content.title` and nothing else, a container gets its whole body parsed: `title`, `paragraphs`, `items`, `sequence`. Render it with `<Prose content={content} block={block} />` or read the fields directly. A name the foundation doesn't define falls back to a plain bordered box that still shows the body — never a drop.
1317
+
1318
+ Prefer a **child section** when the author needs their own section type and independent editing; prefer a **block inset** when the wrapper is presentational and belongs inline in a single file's prose.
1319
+
1300
1320
  **Child sections.** You hit a complex layout — a 2:1 split with a panel and a main area. Your instinct says build a specialized component. Step back: the panel is a reusable section type, the main area is another, and the split is a Grid with `columns: "1fr 2fr"`. Your child components already adapt to narrow containers — container queries handle that. But hardcoding which components go where means the author can't rearrange or swap them. Child sections solve that:
1301
1321
 
1302
1322
  ```
@@ -1664,6 +1684,9 @@ uniweb add ci --target foundation # Publish a foundation for free at permanent v
1664
1684
 
1665
1685
  uniweb push / pull / clone / status # Git-style content sync with the Uniweb backend
1666
1686
  uniweb register [--scope @org] # Register a foundation + its data schemas to the registry
1687
+ uniweb login / logout # Start or clear the backend session the verbs above reuse
1688
+ uniweb org list / create <handle> # Publish orgs you belong to — the @org in a scoped ref
1689
+ uniweb content export [dir] # Package a site (or a built foundation's schema) as .uwx
1667
1690
 
1668
1691
  uniweb rename <foundation|site|extension> <old> <new> # Rename across the whole workspace
1669
1692
  uniweb i18n extract / init / sync / status / audit # Translation workflow (build first)
@@ -1678,6 +1701,8 @@ uniweb inspect <path> # Show parsed content for a section or page (-
1678
1701
  uniweb <command> --help # Per-command flags — no side effects. Prefer this over guessing.
1679
1702
  ```
1680
1703
 
1704
+ **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. Running any of them is not useful; their absence from this list is the answer, not an omission to fix.
1705
+
1681
1706
  ### Where a site can live
1682
1707
 
1683
1708
  **There is no lock-in, and no default you're pushed toward.** Four independent paths, and a project can change its mind:
@@ -24,16 +24,24 @@ import { contentTypeFor } from '../utils/code-upload.js'
24
24
  * @param {string} siteDir - the site root (site-root refs resolve under public/)
25
25
  * @param {string[]} refs - site-root local asset refs (`/images/x.png`)
26
26
  * @param {{ onProgress?: (m: string) => void, warn?: (m: string) => void }} [opts]
27
- * @returns {Promise<Record<string,string>>} ref serve URL (only resolved + uploaded refs)
27
+ * @returns {Promise<{ map: Record<string,string>, missing: string[], failed: Array<{path:string,status:number,detail?:string}> }>}
28
+ * `map` is ref → serve URL for refs that resolved AND uploaded. The two failure
29
+ * kinds are reported SEPARATELY because callers must treat them differently:
30
+ * `missing` is a ref with no file under the site — an authoring mistake, already
31
+ * broken before us, worth a warning; `failed` is a ref whose bytes we could not
32
+ * store — a transport or QUOTA refusal, and shipping content that still points at
33
+ * the local path would publish a broken image while only warning about it.
28
34
  */
29
35
  export async function uploadSiteMedia(client, siteDir, refs, { onProgress, warn } = {}) {
30
- if (!refs?.length) return {}
36
+ if (!refs?.length) return { map: {}, missing: [], failed: [] }
31
37
 
32
38
  const files = []
39
+ const missing = []
33
40
  for (const ref of refs) {
34
41
  const { resolved } = resolveAssetPath(ref, siteDir, siteDir)
35
42
  if (!resolved || !existsSync(resolved)) {
36
43
  warn?.(`local-media: ${ref} not found under the site (skipped)`)
44
+ missing.push(ref)
37
45
  continue
38
46
  }
39
47
  const bytes = readFileSync(resolved)
@@ -46,10 +54,11 @@ export async function uploadSiteMedia(client, siteDir, refs, { onProgress, warn
46
54
  diskPath: resolved,
47
55
  })
48
56
  }
49
- if (!files.length) return {}
57
+ if (!files.length) return { map: {}, missing, failed: [] }
50
58
 
51
59
  const result = await client.uploadSiteAssets({ files, onProgress })
52
- for (const f of result.failed || []) warn?.(`local-media: upload failed for ${f.path} (HTTP ${f.status})`)
60
+ const failed = result.failed || []
61
+ for (const f of failed) warn?.(`local-media: upload failed for ${f.path} (HTTP ${f.status})`)
53
62
 
54
63
  const config = await client.discover()
55
64
  const map = {}
@@ -57,5 +66,25 @@ export async function uploadSiteMedia(client, siteDir, refs, { onProgress, warn
57
66
  const entry = result.assetsByLocalUrl[ref]
58
67
  if (entry) map[ref] = entry.serveUrl || buildAssetUrl(client.origin, config.assetBase, entry.id, entry.ext)
59
68
  }
60
- return map
69
+ return { map, missing, failed }
70
+ }
71
+
72
+ /**
73
+ * Is this asset-lane error the backend refusing on storage grounds?
74
+ *
75
+ * The plan step throws `Asset plan failed: HTTP <status>` for any non-2xx, so a quota
76
+ * refusal is currently indistinguishable from any other rejection except by status.
77
+ * These three are the plausible spellings — 402 (payment required), 413 (payload too
78
+ * large), 507 (insufficient storage).
79
+ *
80
+ * DELIBERATELY a heuristic, and it should not stay one: the backend owes a typed
81
+ * error carrying used / limit / needed so the CLI can say what a push costs and what
82
+ * is left. Until that contract exists this at least turns an opaque HTTP number into
83
+ * the right advice. See the collab charter in the handoff.
84
+ *
85
+ * @param {Error} err
86
+ * @returns {boolean}
87
+ */
88
+ export function isStorageRefusal(err) {
89
+ return /Asset plan failed: HTTP (402|413|507)\b/.test(err?.message || '')
61
90
  }
@@ -65,7 +65,7 @@ import {
65
65
  pushSyncPackages,
66
66
  } from '../backend/site-sync.js'
67
67
  import { uploadDataBundle } from '../backend/data-bundle.js'
68
- import { uploadSiteMedia } from '../backend/site-media.js'
68
+ import { uploadSiteMedia, isStorageRefusal } from '../backend/site-media.js'
69
69
  import { bringFoundationAlong } from '../backend/foundation-bring-along.js'
70
70
  import { settlePaymentIfNeeded } from '../backend/payment-handoff.js'
71
71
 
@@ -275,14 +275,29 @@ export async function publish(args = []) {
275
275
  if (mediaRefs.length) {
276
276
  say.info('Uploading media…')
277
277
  try {
278
- const map = await uploadSiteMedia(client, siteDir, mediaRefs, {
278
+ const { map, failed } = await uploadSiteMedia(client, siteDir, mediaRefs, {
279
279
  onProgress: (m) => say.dim(` ${m}`),
280
280
  warn: (m) => say.dim(`! ${m}`),
281
281
  })
282
+ // A ref whose bytes did not land must NOT be published: the content would go
283
+ // out still pointing at the local path, so the site ships a broken image and
284
+ // the only trace is a warning nobody reads. A missing FILE is different —
285
+ // already broken before us, warned above, and not worth blocking a publish.
286
+ if (failed.length) {
287
+ say.err(`${failed.length} asset(s) failed to upload — not publishing.`)
288
+ for (const f of failed) say.dim(` ${f.path} (HTTP ${f.status})`)
289
+ return { exitCode: 1 }
290
+ }
282
291
  if (Object.keys(map).length) assetRewrite = map
283
292
  if (ballAssets.length) ball = rewriteBallAssets(ball, map)
284
293
  say.dim(`Media : ${Object.keys(map).length}/${mediaRefs.length} ref(s) → serve URL`)
285
294
  } catch (err) {
295
+ if (isStorageRefusal(err)) {
296
+ say.err('Storage quota reached — the media in this publish does not fit.')
297
+ say.dim(' Remove or shrink assets, or raise the plan limit, then re-run.')
298
+ say.dim(` ${err.message}`)
299
+ return { exitCode: 1 }
300
+ }
286
301
  say.err(`Media upload failed: ${err.message}`)
287
302
  return { exitCode: 1 }
288
303
  }
@@ -55,6 +55,7 @@
55
55
  import { writeFileSync } from 'node:fs'
56
56
  import { resolve } from 'node:path'
57
57
  import { emitSyncPackages } from '@uniweb/build/uwx'
58
+ import { uploadSiteMedia, isStorageRefusal } from '../backend/site-media.js'
58
59
  import { BackendClient } from '../backend/client.js'
59
60
  import { resolveSiteDir, resolveSiteBackend } from './deploy.js'
60
61
  import {
@@ -131,6 +132,75 @@ export async function push(args = []) {
131
132
  // to what a real push would send. Only the network RECOVERY is skipped, so a
132
133
  // preview never reaches the backend. (Emitting `{}` here instead would make the
133
134
  // preview quietly unrepresentative, which is the one thing `-o` exists to avoid.)
135
+ // Local media rides the SAME asset lane `publish` uses, and it rides it FIRST.
136
+ //
137
+ // Push is the collaboration verb: a teammate opens the site in the visual app
138
+ // right after it. Content that still points at `/images/hero.png` — bytes the
139
+ // backend never received — shows them a broken image, which is precisely what
140
+ // push exists to avoid. `publish` uploaded and rewrote; push dropped
141
+ // `localAssets` on the floor.
142
+ //
143
+ // Ordering is deliberate: BEFORE `ensureItemUuids`, which mints uuids on the
144
+ // backend. A refusal here then leaves nothing minted and nothing submitted.
145
+ //
146
+ // The probe emit runs WITHOUT `priorHashes`, so it surfaces every local ref
147
+ // rather than only the changed ones. That is not waste — the lane is
148
+ // content-addressed with a `present` skip-list, so unchanged bytes are a no-op
149
+ // PUT. It is also what makes the storage rule fall out of the mechanism instead
150
+ // of needing a special case: a content-only push presents the same plan as last
151
+ // time, every file is already present, zero new bytes are requested, and no
152
+ // quota check can refuse it. Only a push that genuinely ADDS bytes can be
153
+ // blocked.
154
+ //
155
+ // It also has to be this emit that carries `assetRewrite` below: the push cache
156
+ // stores hashes of the REWRITTEN content, so the emit compared against it must
157
+ // rewrite too, or every entity reads as changed forever.
158
+ let assetRewrite = null
159
+ if (!output && !dryRun) {
160
+ let mediaRefs = []
161
+ try {
162
+ const probe = await emitSyncPackages(siteDir, {
163
+ ...(foundationDir ? { foundationDir } : {}),
164
+ resolveModel: makeModelResolver({ client, offline: false }),
165
+ })
166
+ mediaRefs = probe.localAssets || []
167
+ } catch (err) {
168
+ error(`Could not scan the site for local media: ${err.message}`)
169
+ return { exitCode: 2 }
170
+ }
171
+ if (mediaRefs.length) {
172
+ info('Uploading media…')
173
+ try {
174
+ const { map, failed } = await uploadSiteMedia(client, siteDir, mediaRefs, {
175
+ onProgress: (m) => note(` ${m}`),
176
+ warn: (m) => note(`! ${m}`),
177
+ })
178
+ // Bytes that did not land must not be pushed around: the content would go
179
+ // up still naming the local path, so the teammate sees the broken image
180
+ // this whole change exists to prevent, and the only trace is a warning. A
181
+ // missing FILE is a different thing — already broken before us, warned by
182
+ // the uploader, and not worth blocking a push over.
183
+ if (failed.length) {
184
+ error(`${failed.length} asset(s) failed to upload — nothing was pushed.`)
185
+ for (const f of failed) note(` ${f.path} (HTTP ${f.status})`)
186
+ return { exitCode: 1 }
187
+ }
188
+ if (Object.keys(map).length) assetRewrite = map
189
+ note(`${Object.keys(map).length}/${mediaRefs.length} media ref(s) → serve URL`)
190
+ } catch (err) {
191
+ if (isStorageRefusal(err)) {
192
+ error('Storage quota reached — this push adds media that does not fit.')
193
+ note(' A push that changes only content costs no storage and still works.')
194
+ note(' Remove or shrink the new assets, or raise the plan limit, then re-run.')
195
+ note(` ${err.message}`)
196
+ return { exitCode: 1 }
197
+ }
198
+ error(`Media upload failed: ${err.message}`)
199
+ return { exitCode: 1 }
200
+ }
201
+ }
202
+ }
203
+
134
204
  const itemUuids = (output || dryRun)
135
205
  ? readItemUuids(siteDir)
136
206
  : await ensureItemUuids({ client, siteDir, note })
@@ -145,6 +215,7 @@ export async function push(args = []) {
145
215
  // Both grains are dropped together by --force: one flag, one meaning,
146
216
  // no partial-force mode.
147
217
  ...(force ? {} : { baseVersions: readBaseVersions(siteDir), itemBaseVersions: readItemBaseVersions(siteDir) }),
218
+ ...(assetRewrite ? { assetRewrite } : {}),
148
219
  })
149
220
  } catch (err) {
150
221
  error(`Could not build the sync package: ${err.message}`)
@@ -1,9 +1,9 @@
1
1
  {
2
2
  "schemaVersion": 1,
3
- "generatedAt": "2026-07-26T22:08:46.287Z",
3
+ "generatedAt": "2026-07-27T11:29:06.768Z",
4
4
  "packages": {
5
5
  "@uniweb/build": {
6
- "version": "0.15.6",
6
+ "version": "0.15.7",
7
7
  "path": "framework/build",
8
8
  "deps": [
9
9
  "@uniweb/content-reader",
@@ -16,17 +16,17 @@
16
16
  ]
17
17
  },
18
18
  "@uniweb/content-reader": {
19
- "version": "1.1.16",
19
+ "version": "1.1.17",
20
20
  "path": "framework/content-reader",
21
21
  "deps": []
22
22
  },
23
23
  "@uniweb/content-writer": {
24
- "version": "0.2.8",
24
+ "version": "0.2.9",
25
25
  "path": "framework/content-writer",
26
26
  "deps": []
27
27
  },
28
28
  "@uniweb/core": {
29
- "version": "0.7.30",
29
+ "version": "0.7.31",
30
30
  "path": "framework/core",
31
31
  "deps": [
32
32
  "@uniweb/semantic-parser",
@@ -44,7 +44,7 @@
44
44
  "deps": []
45
45
  },
46
46
  "@uniweb/kit": {
47
- "version": "0.9.38",
47
+ "version": "0.9.39",
48
48
  "path": "framework/kit",
49
49
  "deps": [
50
50
  "@uniweb/core",
@@ -62,7 +62,7 @@
62
62
  "deps": []
63
63
  },
64
64
  "@uniweb/projections": {
65
- "version": "0.1.1",
65
+ "version": "0.1.2",
66
66
  "path": "framework/projections",
67
67
  "deps": [
68
68
  "@uniweb/content-writer",
@@ -70,7 +70,7 @@
70
70
  ]
71
71
  },
72
72
  "@uniweb/runtime": {
73
- "version": "0.8.37",
73
+ "version": "0.8.38",
74
74
  "path": "framework/runtime",
75
75
  "deps": [
76
76
  "@uniweb/core",
@@ -93,7 +93,7 @@
93
93
  "deps": []
94
94
  },
95
95
  "@uniweb/semantic-parser": {
96
- "version": "1.1.19",
96
+ "version": "1.1.20",
97
97
  "path": "framework/semantic-parser",
98
98
  "deps": []
99
99
  },
@@ -108,7 +108,7 @@
108
108
  "deps": []
109
109
  },
110
110
  "@uniweb/unipress": {
111
- "version": "0.5.5",
111
+ "version": "0.5.6",
112
112
  "path": "framework/unipress",
113
113
  "deps": [
114
114
  "@uniweb/build",