uniweb 0.34.1 → 0.34.2

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.34.1",
3
+ "version": "0.34.2",
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/runtime": "^0.13.3",
45
- "@uniweb/kit": "^0.15.0",
46
44
  "@uniweb/core": "^0.14.1",
47
- "@uniweb/semantic-parser": "^1.3.1"
45
+ "@uniweb/semantic-parser": "^1.3.1",
46
+ "@uniweb/runtime": "^0.13.3",
47
+ "@uniweb/kit": "^0.15.0"
48
48
  },
49
49
  "peerDependencies": {
50
- "@uniweb/content-reader": "^1.2.4",
51
50
  "@uniweb/semantic-parser": "^1.3.1",
52
- "@uniweb/build": "^0.30.0"
51
+ "@uniweb/content-reader": "^1.2.4",
52
+ "@uniweb/build": "^0.30.2"
53
53
  },
54
54
  "peerDependenciesMeta": {
55
55
  "@uniweb/build": {
@@ -2234,6 +2234,112 @@ For cases the factory doesn't cover, write handlers directly using `Loom`, `inst
2234
2234
 
2235
2235
  ---
2236
2236
 
2237
+ ## Part 4b — When the site is also an app
2238
+
2239
+ Everything above is a site: content the author writes, built into pages. Some sites
2240
+ also have **their own backend** — accounts, per-visitor data, records their members
2241
+ create and edit. That is `@uniweb/api`.
2242
+
2243
+ ⛔ **Only reach for this when the site actually has one.** A site with no backend is
2244
+ the normal case, and a foundation that assumes one breaks on every other site it is
2245
+ used with.
2246
+
2247
+ ```bash
2248
+ npm install @uniweb/api # in the FOUNDATION, beside @uniweb/kit
2249
+ ```
2250
+
2251
+ ### Ask before you draw
2252
+
2253
+ ```jsx
2254
+ import { isEnabled, useSession, SignedIn, SignedOut } from '@uniweb/api'
2255
+
2256
+ if (!isEnabled(website)) return <StaticVersion /> // synchronous — nothing to await
2257
+ ```
2258
+
2259
+ ⛔ **When there is no backend, draw nothing** — not a disabled control, and not an
2260
+ explanation. Same rule as `services` in Part 4: which capabilities a site's operator
2261
+ set up is none of a visitor's business, and "sign-in unavailable" reads as breakage
2262
+ when it is simply a feature this site does not have. Render the version of your
2263
+ component that never needed one.
2264
+
2265
+ ### Reading
2266
+
2267
+ ```jsx
2268
+ const { status, records } = useRecords({ schema: '@/session' })
2269
+ ```
2270
+
2271
+ ⭐ **`absent` and an empty `ready` are different answers, and confusing them is the
2272
+ mistake to avoid.** `absent` = there is no live source (no backend, or nobody signed
2273
+ in) → render the site's authored content. `ready` with `records: []` = the backend
2274
+ answered and there is nothing there → render your empty state. Showing "nothing yet"
2275
+ for the first tells a visitor their content is gone when it was never requested.
2276
+
2277
+ ### Writing
2278
+
2279
+ ```jsx
2280
+ const writer = useEntityWriter({ schema: '@/track', uuid: track.uuid })
2281
+
2282
+ await writer.create({ title: 'Keynote' }, { section: 'sessions', position: 'last' })
2283
+ await writer.update(itemId, { ...item.data, room: 'Hall A' }) // whole-data replace
2284
+ await writer.move(itemId, { after: previousItemId }) // never an index
2285
+ await writer.remove(itemId)
2286
+ ```
2287
+
2288
+ - **`section` is required on `create`.** An entity has several, and a rule declared on
2289
+ one — insert-only, say — does not reach an item that landed in another. Getting it
2290
+ wrong stores the item happily and quietly voids the rule.
2291
+ - **`update` replaces the item's data whole.** Spread what you are not editing.
2292
+ - **Ordering is the server's.** Say `'first'`, `'last'` or `{ after }` — never compute
2293
+ an order number, or two people arranging one list will produce an order neither
2294
+ chose.
2295
+ - ⛔ **`writer.conflict` is reported, not retried.** Someone else changed the item
2296
+ first; a retry would succeed by overwriting a change nobody looked at. Tell the
2297
+ person.
2298
+
2299
+ ### ⛔ Permissions are the SERVER'S, and your UI is only a courtesy
2300
+
2301
+ Gate your controls on what the viewer may do — `viewer.actingUnitId`, `viewer.roles` —
2302
+ but **never rely on that for safety**. A foundation runs with exactly the viewer's
2303
+ own authority, so hiding a button hides a button. The rule belongs in the data
2304
+ schema, where the store enforces it:
2305
+
2306
+ ```yaml
2307
+ # foundation/schemas/session.yml
2308
+ creatable_by: unit_members # only members of the owning unit may create these
2309
+
2310
+ sections:
2311
+ checkins:
2312
+ many: true
2313
+ append_only: true # may be added; never edited or removed, by anyone
2314
+ ```
2315
+
2316
+ `append_only` holds against the item's own author. That is the difference between a
2317
+ permission model and a CSS one.
2318
+
2319
+ ### A backend on your machine
2320
+
2321
+ You do not need a live backend to build against one. In `site.yml`:
2322
+
2323
+ ```yaml
2324
+ api: /_api # where it answers — the same value in production
2325
+ $devApi: ./mock/api.js # what answers it locally; `$` keys are never published
2326
+ ```
2327
+
2328
+ ```js
2329
+ // site/mock/api.js
2330
+ import { createMockBackend } from '@uniweb/api/mock'
2331
+ export default createMockBackend({ seed }).fetch
2332
+ ```
2333
+
2334
+ `uniweb dev` mounts it at your `api:` address, same-origin, so cookies and your
2335
+ site's configuration behave exactly as they will in production. It **enforces**
2336
+ `creatable_by` and `append_only`, so a permission you are relying on fails on your
2337
+ machine rather than in front of a user. State is in memory; restart to reset.
2338
+
2339
+ `uniweb create my-event --template conference` is a worked example of all of this.
2340
+
2341
+ ---
2342
+
2237
2343
  ## Part 5 — Commands, shipping, and migration
2238
2344
 
2239
2345
  ```bash
@@ -17,6 +17,7 @@ import { existsSync, readFileSync } from 'node:fs'
17
17
  import { basename } from 'node:path'
18
18
  import { resolveAssetPath } from '@uniweb/build/site'
19
19
  import { contentTypeFor } from '../utils/code-upload.js'
20
+ import { humanBytes } from '../utils/bytes.js'
20
21
 
21
22
  /**
22
23
  * @param {object} client - BackendClient (uploadSiteAssets). No `discover` — this
@@ -123,20 +124,6 @@ export async function uploadSiteMedia(
123
124
  // rewording). Same house style as the push-staleness `reason: "stale_base"` in
124
125
  // site-sync.js.
125
126
 
126
- const KIB = 1024
127
- function humanBytes(n) {
128
- if (typeof n !== 'number' || !Number.isFinite(n) || n < 0) return null
129
- if (n < KIB) return `${n} B`
130
- const units = ['KiB', 'MiB', 'GiB', 'TiB']
131
- let v = n / KIB
132
- let i = 0
133
- while (v >= KIB && i < units.length - 1) {
134
- v /= KIB
135
- i++
136
- }
137
- return `${v >= 10 || Number.isInteger(v) ? Math.round(v) : v.toFixed(1)} ${units[i]}`
138
- }
139
-
140
127
  // Append `label: <bytes>` when the value is a usable number. A refusal that omits an
141
128
  // extra still produces a useful message — never print `undefined` at a user.
142
129
  function pushBytes(lines, label, n) {
@@ -146,9 +133,17 @@ function pushBytes(lines, label, n) {
146
133
 
147
134
  /**
148
135
  * Turn an asset-plan refusal into user-facing lines, or null when this is not a
149
- * refusal we recognise (including every refusal shipped today, which is still
150
- * prose the typed `reason` values are agreed but NOT YET EMITTED). Null means
151
- * "fall through to the generic error", so this degrades rather than swallowing.
136
+ * refusal we recognise. Null means "fall through to the generic error", so this
137
+ * degrades rather than swallowing.
138
+ *
139
+ * ⛔ This comment used to assert that the typed `reason` values were "agreed but
140
+ * NOT YET EMITTED" — an unverified claim about ANOTHER LANE's deployed state,
141
+ * sitting in our source where it read as fact and nobody would think to re-check
142
+ * it. That is the precise shape that cost the backend lane a metering hole (a
143
+ * comment claiming what the app sent, wrong the whole time). What we can say is
144
+ * ours: we branch on `reason` and fall through cleanly when there is none, so
145
+ * this function is correct whether or not any given backend emits one, and no
146
+ * sentence here needs to track what a deployment is running.
152
147
  *
153
148
  * Two wording rules are load-bearing and come from the ratified accounting model,
154
149
  * not from taste:
@@ -16,6 +16,7 @@ import { join, dirname } from 'node:path'
16
16
  import yaml from 'js-yaml'
17
17
  import { hasUncommittedContent } from '../utils/git.js'
18
18
  import { recordSiteBackend } from '../utils/site-identity.js'
19
+ import { humanBytes } from '../utils/bytes.js'
19
20
  import {
20
21
  backfillEntityUuids,
21
22
  writeSiteEntityUuid,
@@ -647,9 +648,8 @@ export async function resolveSiteOrgForCreate({
647
648
  // serves the foundation lane, where every answer is an org and 0-orgs means
648
649
  // "claim your personal org". Here "personal" must stay reachable as *no org*,
649
650
  // and reusing deriveScope would quietly turn it into an org creation.
650
- const { fetchOrgs, createOrg, validateHandle, bareHandle } = await import(
651
- '../utils/registry-orgs.js'
652
- )
651
+ const { fetchOrgs, createOrg, validateHandle, bareHandle } =
652
+ await import('../utils/registry-orgs.js')
653
653
  let envelope
654
654
  try {
655
655
  envelope = await fetchOrgs({
@@ -719,6 +719,59 @@ export async function resolveSiteOrgForCreate({
719
719
  }
720
720
  }
721
721
 
722
+ /**
723
+ * Turn a typed site-create refusal into a sentence, or null to fall through to the
724
+ * generic HTTP line.
725
+ *
726
+ * ⭐ Why this exists separately from `describeAssetRefusal`. The asset lane grew a
727
+ * typed describer because a raw status dump is useless to the person who has to act
728
+ * on it; the create lane had the same hole and nobody had met it, because until the
729
+ * backend shipped account capacity a create could not be refused for space at all.
730
+ * So this is the same fix on the second door, written before anyone hits it rather
731
+ * than after.
732
+ *
733
+ * ⛔ Branch on `reason`, never on the status. `507` alone cannot be told from any
734
+ * other 507 and carries none of the numbers a user needs, and `detail` is prose the
735
+ * backend may reword.
736
+ *
737
+ * ⚖️ The wording deliberately DIVERGES from the asset lane's on one point. There the
738
+ * allowance belongs to the site's owner, who may not be the person pushing. Here
739
+ * there is no site yet, so the only workspace in play is the one the site would be
740
+ * created in — `--as <org>` included. Saying "the site owner's workspace" would be
741
+ * incoherent for a site that does not exist.
742
+ *
743
+ * @param {string} body - the raw response body
744
+ * @returns {string|null}
745
+ */
746
+ function describeCreateRefusal(body) {
747
+ if (!body) return null
748
+ let p
749
+ try {
750
+ p = JSON.parse(body)
751
+ } catch {
752
+ return null // prose refusal, or an upstream error page
753
+ }
754
+ if (!p || typeof p !== 'object' || typeof p.reason !== 'string') return null
755
+
756
+ if (p.reason === 'storage_quota_exceeded') {
757
+ const parts = []
758
+ const used = humanBytes(p.used_bytes)
759
+ const limit = humanBytes(p.limit_bytes)
760
+ const needed = humanBytes(p.needed_bytes)
761
+ if (used && limit) parts.push(`${used} of ${limit} used`)
762
+ if (needed) parts.push(`a new site needs ${needed}`)
763
+ return (
764
+ 'storage quota reached — the workspace this site would belong to has no room for it' +
765
+ (parts.length ? ` (${parts.join('; ')})` : '') +
766
+ '. Quota is returned by deleting a site or entity, not by editing content.'
767
+ )
768
+ }
769
+ // An unrecognised typed reason still beats a status dump: name it, and let the
770
+ // backend's own prose follow when it sent any.
771
+ const detail = typeof p.detail === 'string' ? p.detail : ''
772
+ return `the backend refused the site create (${p.reason})${detail ? ` — ${detail}` : ''}`
773
+ }
774
+
722
775
  /**
723
776
  * Guarantee the site EXISTS on the backend before anything is uploaded against it.
724
777
  *
@@ -797,7 +850,8 @@ export async function ensureSiteExists({
797
850
  reason:
798
851
  res?.status === 404
799
852
  ? 'this backend has no /dev/site route (it predates the empty-site create)'
800
- : `HTTP ${res?.status} ${res?.statusText || ''}${body ? ` — ${body.slice(0, 200)}` : ''}`
853
+ : (describeCreateRefusal(body) ??
854
+ `HTTP ${res?.status} ${res?.statusText || ''}${body ? ` — ${body.slice(0, 200)}` : ''}`)
801
855
  }
802
856
  }
803
857
  const payload = await res.json().catch(() => null)
@@ -837,7 +891,13 @@ export async function ensureSiteExists({
837
891
  *
838
892
  * @returns {Promise<string|null>} the display handle recorded, or null for personal
839
893
  */
840
- async function recordAndDescribeOwner({ client, siteDir, payload, asOrg, note }) {
894
+ async function recordAndDescribeOwner({
895
+ client,
896
+ siteDir,
897
+ payload,
898
+ asOrg,
899
+ note
900
+ }) {
841
901
  const echoed = payload && 'org' in payload ? payload.org : undefined
842
902
  const owner =
843
903
  echoed === undefined ? asOrg : typeof echoed === 'string' ? echoed : null
@@ -851,7 +911,8 @@ async function recordAndDescribeOwner({ client, siteDir, payload, asOrg, note })
851
911
  //
852
912
  // A no-op on the default backend, so the common case writes nothing.
853
913
  const scope = await recordSiteBackend(siteDir, client.origin)
854
- if (scope) note?.(`Bound this project to ${scope} (recorded $backend in site.yml).`)
914
+ if (scope)
915
+ note?.(`Bound this project to ${scope} (recorded $backend in site.yml).`)
855
916
 
856
917
  note?.(
857
918
  org
@@ -879,7 +940,9 @@ async function recordAndDescribeOwner({ client, siteDir, payload, asOrg, note })
879
940
  // `false` is an answer we deliberately do not speak to, and missing is not an answer
880
941
  // at all. Do not reintroduce a "you will be charged" line here.
881
942
  if (payload?.hosts_free === true) {
882
- note?.('This owner is hosted free — publishing will not require a subscription.')
943
+ note?.(
944
+ 'This owner is hosted free — publishing will not require a subscription.'
945
+ )
883
946
  }
884
947
  return org
885
948
  }
@@ -1003,7 +1066,10 @@ export async function probeUnpushed(siteDir, { sendAll = false } = {}) {
1003
1066
  * Offline by design — measured at zero HTTP requests, a property the cross-client
1004
1067
  * flows rely on.
1005
1068
  */
1006
- async function comparisonEmit(siteDir, { priorHashes = {}, sendAll = false } = {}) {
1069
+ async function comparisonEmit(
1070
+ siteDir,
1071
+ { priorHashes = {}, sendAll = false } = {}
1072
+ ) {
1007
1073
  const applied = readAppliedInjections(siteDir)
1008
1074
  const assetIds = readAssetMap(siteDir)
1009
1075
  const org = readSiteOrg(siteDir)
@@ -1264,9 +1330,7 @@ export async function pushSyncPackages({
1264
1330
  note(
1265
1331
  ` deleted in the app → clearing \`$uuid\` from site.yml re-publishes it as a NEW site`
1266
1332
  )
1267
- note(
1268
- 'Deleting this folder removes only your local copy, either way.'
1269
- )
1333
+ note('Deleting this folder removes only your local copy, either way.')
1270
1334
  } else if (res.status === 409) {
1271
1335
  // The site's @uniweb/folder is genesis-owned: its structure is fixed on first
1272
1336
  // deploy and not reconciled in place (the v1 rule — see gotcha #20's mode switch).
@@ -1416,7 +1480,8 @@ export async function pushSyncPackages({
1416
1480
  asOrg,
1417
1481
  note
1418
1482
  })
1419
- if (createdOrg) wrote.push(`recorded site $org (${createdOrg}) in site.yml`)
1483
+ if (createdOrg)
1484
+ wrote.push(`recorded site $org (${createdOrg}) in site.yml`)
1420
1485
  const createdFinalized = extractFinalized(payload)
1421
1486
  harvest(createdFinalized)
1422
1487
  siteFinalizedDoc = createdFinalized?.[0]?.document || null
@@ -1464,7 +1529,8 @@ export async function pushSyncPackages({
1464
1529
  const folderDoc = finalized.find((f) => f?.document?.contents)?.document
1465
1530
  if (folderDoc) {
1466
1531
  const placements = collectFolderItemUuids(folderDoc)
1467
- if (Object.keys(placements).length) writeFolderItemUuids(siteDir, placements)
1532
+ if (Object.keys(placements).length)
1533
+ writeFolderItemUuids(siteDir, placements)
1468
1534
  }
1469
1535
  finalizedTotal += finalized.length
1470
1536
  }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "schemaVersion": 1,
3
- "generatedAt": "2026-08-31T13:37:55.117Z",
3
+ "generatedAt": "2026-09-01T22:08:19.670Z",
4
4
  "packages": {
5
5
  "@uniweb/api": {
6
6
  "version": "0.1.0",
@@ -10,7 +10,7 @@
10
10
  ]
11
11
  },
12
12
  "@uniweb/build": {
13
- "version": "0.30.0",
13
+ "version": "0.30.2",
14
14
  "path": "framework/build",
15
15
  "deps": [
16
16
  "@uniweb/content-reader",
@@ -94,7 +94,7 @@
94
94
  "deps": []
95
95
  },
96
96
  "@uniweb/schemas": {
97
- "version": "0.2.11",
97
+ "version": "0.2.12",
98
98
  "path": "framework/schemas",
99
99
  "deps": []
100
100
  },
@@ -298,6 +298,18 @@
298
298
  "quote",
299
299
  "trades"
300
300
  ]
301
+ },
302
+ "conference": {
303
+ "name": "Conference",
304
+ "description": "A site that is also an app — sign-in, per-viewer data, and an editable programme. Ships a local backend so it runs with nothing installed.",
305
+ "tags": [
306
+ "app",
307
+ "api",
308
+ "backend",
309
+ "auth",
310
+ "permissions",
311
+ "conference"
312
+ ]
301
313
  }
302
314
  }
303
315
  }
@@ -0,0 +1,30 @@
1
+ /**
2
+ * Byte formatting for refusal messages.
3
+ *
4
+ * Lives on its own because TWO lanes now print a storage refusal — the asset plan
5
+ * (`backend/site-media.js`) and the site create (`backend/site-sync.js`) — and the
6
+ * numbers must read identically in both. A second copy would drift in units or
7
+ * rounding, and a user comparing "you have 1.1 GiB" against "this needs 100 MB"
8
+ * cannot tell a real gap from a formatting difference.
9
+ */
10
+
11
+ const KIB = 1024
12
+
13
+ /**
14
+ * `1073741824` → `1 GiB`. Returns null for anything that is not a usable byte
15
+ * count, so a refusal that omits an extra prints nothing rather than `undefined`.
16
+ * @param {unknown} n
17
+ * @returns {string|null}
18
+ */
19
+ export function humanBytes(n) {
20
+ if (typeof n !== 'number' || !Number.isFinite(n) || n < 0) return null
21
+ if (n < KIB) return `${n} B`
22
+ const units = ['KiB', 'MiB', 'GiB', 'TiB']
23
+ let v = n / KIB
24
+ let i = 0
25
+ while (v >= KIB && i < units.length - 1) {
26
+ v /= KIB
27
+ i++
28
+ }
29
+ return `${v >= 10 || Number.isInteger(v) ? Math.round(v) : v.toFixed(1)} ${units[i]}`
30
+ }