uniweb 0.34.1 → 0.34.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +4 -4
- package/partials/agents.md +106 -0
- package/src/backend/site-media.js +12 -17
- package/src/backend/site-sync.js +79 -13
- package/src/framework-index.json +17 -5
- package/src/utils/bytes.js +30 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "uniweb",
|
|
3
|
-
"version": "0.34.
|
|
3
|
+
"version": "0.34.3",
|
|
4
4
|
"description": "Create structured Vite + React sites with content/code separation",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -42,14 +42,14 @@
|
|
|
42
42
|
"prompts": "^2.4.2",
|
|
43
43
|
"tar": "^7.0.0",
|
|
44
44
|
"@uniweb/runtime": "^0.13.3",
|
|
45
|
-
"@uniweb/kit": "^0.15.0",
|
|
46
45
|
"@uniweb/core": "^0.14.1",
|
|
47
|
-
"@uniweb/semantic-parser": "^1.3.1"
|
|
46
|
+
"@uniweb/semantic-parser": "^1.3.1",
|
|
47
|
+
"@uniweb/kit": "^0.15.0"
|
|
48
48
|
},
|
|
49
49
|
"peerDependencies": {
|
|
50
50
|
"@uniweb/content-reader": "^1.2.4",
|
|
51
51
|
"@uniweb/semantic-parser": "^1.3.1",
|
|
52
|
-
"@uniweb/build": "^0.30.
|
|
52
|
+
"@uniweb/build": "^0.30.2"
|
|
53
53
|
},
|
|
54
54
|
"peerDependenciesMeta": {
|
|
55
55
|
"@uniweb/build": {
|
package/partials/agents.md
CHANGED
|
@@ -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
|
|
150
|
-
*
|
|
151
|
-
*
|
|
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:
|
package/src/backend/site-sync.js
CHANGED
|
@@ -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 } =
|
|
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
|
-
:
|
|
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({
|
|
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)
|
|
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?.(
|
|
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(
|
|
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)
|
|
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)
|
|
1532
|
+
if (Object.keys(placements).length)
|
|
1533
|
+
writeFolderItemUuids(siteDir, placements)
|
|
1468
1534
|
}
|
|
1469
1535
|
finalizedTotal += finalized.length
|
|
1470
1536
|
}
|
package/src/framework-index.json
CHANGED
|
@@ -1,16 +1,16 @@
|
|
|
1
1
|
{
|
|
2
2
|
"schemaVersion": 1,
|
|
3
|
-
"generatedAt": "2026-
|
|
3
|
+
"generatedAt": "2026-09-01T22:27:24.450Z",
|
|
4
4
|
"packages": {
|
|
5
5
|
"@uniweb/api": {
|
|
6
|
-
"version": "0.
|
|
6
|
+
"version": "0.2.0",
|
|
7
7
|
"path": "framework/api",
|
|
8
8
|
"deps": [
|
|
9
9
|
"@uniweb/core"
|
|
10
10
|
]
|
|
11
11
|
},
|
|
12
12
|
"@uniweb/build": {
|
|
13
|
-
"version": "0.30.
|
|
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.
|
|
97
|
+
"version": "0.2.12",
|
|
98
98
|
"path": "framework/schemas",
|
|
99
99
|
"deps": []
|
|
100
100
|
},
|
|
@@ -109,7 +109,7 @@
|
|
|
109
109
|
"deps": []
|
|
110
110
|
},
|
|
111
111
|
"@uniweb/templates": {
|
|
112
|
-
"version": "0.11.
|
|
112
|
+
"version": "0.11.2",
|
|
113
113
|
"path": "framework/templates",
|
|
114
114
|
"deps": []
|
|
115
115
|
},
|
|
@@ -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
|
+
}
|