uniweb 0.56.10 → 0.56.11

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.56.10",
3
+ "version": "0.56.11",
4
4
  "description": "Create structured Vite + React sites with content/code separation",
5
5
  "type": "module",
6
6
  "bin": {
@@ -41,16 +41,16 @@
41
41
  "js-yaml": "^4.1.0",
42
42
  "prompts": "^2.4.2",
43
43
  "tar": "^7.0.0",
44
+ "@uniweb/content-writer": "^0.3.4",
44
45
  "@uniweb/core": "^0.29.4",
45
- "@uniweb/kit": "^0.19.3",
46
+ "@uniweb/runtime": "^0.26.4",
46
47
  "@uniweb/schemas": "^0.3.3",
47
- "@uniweb/content-writer": "^0.3.4",
48
48
  "@uniweb/semantic-parser": "^1.4.1",
49
- "@uniweb/runtime": "^0.26.4"
49
+ "@uniweb/kit": "^0.19.3"
50
50
  },
51
51
  "peerDependencies": {
52
- "@uniweb/content-reader": "^1.2.5",
53
52
  "@uniweb/build": "^0.52.6",
53
+ "@uniweb/content-reader": "^1.2.5",
54
54
  "@uniweb/semantic-parser": "^1.4.1"
55
55
  },
56
56
  "peerDependenciesMeta": {
@@ -40,6 +40,7 @@ import { detectFoundationType, isExtensionUrl } from '@uniweb/build'
40
40
  import { computeFoundationDigest } from '../utils/code-upload.js'
41
41
  import { readFlagValue } from '../utils/args.js'
42
42
  import { isNonInteractive } from '../utils/interactive.js'
43
+ import { compareSemverPrecedence } from '../utils/semver-precedence.js'
43
44
 
44
45
  /**
45
46
  * Resolve the site's LOCAL foundation — the one publish should bring along — or
@@ -117,13 +118,19 @@ export function resolveLocalExtensions(siteDir, siteYml) {
117
118
  }
118
119
 
119
120
  // The foundation's scoped catalog name (`@org/name`) from its package.json — an
120
- // already-scoped `name`, else `uniweb.scope` + a bare `name`. Null when neither
121
+ // already-scoped name, else `uniweb.scope` + a bare one. Null when neither
121
122
  // yields a scoped name (then we can't look up the registered version, so the
122
123
  // caller treats the foundation as "release it and let register pick the scope").
124
+ //
125
+ // ⛔ The name is `uniweb.id` when set, exactly as the build reads it for the
126
+ // schema that `register` submits (`build/src/schema.js`). Reading `name` alone —
127
+ // as this did until 2026-09-17 — looked a `uniweb.id` foundation up under a name
128
+ // the catalog does not have, so every push re-released it, and pinned the site
129
+ // to that same wrong name.
123
130
  function foundationScopedName(dir) {
124
131
  try {
125
132
  const pkg = JSON.parse(readFileSync(join(dir, 'package.json'), 'utf8'))
126
- const name = pkg?.name
133
+ const name = pkg?.uniweb?.id || pkg?.name
127
134
  if (typeof name === 'string' && name.startsWith('@')) return name
128
135
  const scope = pkg?.uniweb?.scope
129
136
  if (scope && name) return `${String(scope).replace(/\/+$/, '')}/${name}`
@@ -344,8 +351,18 @@ async function bringLocalCodeAlong({
344
351
  )
345
352
  return { released: false, proceed: true, ref: registeredRef(reg) }
346
353
  }
354
+ // ⚖️ The registry takes a NEW version only when it is greater than every one
355
+ // it holds — but an older local version may be one it already has, and
356
+ // re-registering that resumes. The CLI cannot tell which from here, so it
357
+ // does not refuse: it submits, says what will decide, and `register` prints
358
+ // the registry's answer if it is a no.
359
+ const order = compareSemverPrecedence(local.version, reg.latest_version)
347
360
  say.info(
348
- `Releasing the ${kind} ${label} (new version; registered latest is ${reg.latest_version})…`
361
+ order === 1
362
+ ? `Releasing the ${kind} ${label} (new version; registered latest is ${reg.latest_version})…`
363
+ : order === null
364
+ ? `Releasing the ${kind} ${label} (registered latest is ${reg.latest_version})…`
365
+ : `Releasing the ${kind} ${label} — not newer than the registered latest ${reg.latest_version}, so the registry takes it only if ${local.version} is already registered with this code…`
349
366
  )
350
367
  return {
351
368
  released: releaseFoundation(local, args, cliBin, say),
@@ -633,8 +633,8 @@ async function runRegister(args = []) {
633
633
  // Resume path: a registered version is immutable, so re-running after a
634
634
  // partial code delivery hits the duplicate rejection here — a STRUCTURED
635
635
  // 409 (problem+json, title "Conflict") — and proceeds to phase 2 (the
636
- // code-uploads plan authorizes against the REGISTERED version; completed
637
- // files are idempotent no-ops).
636
+ // code-uploads plan authorizes against the REGISTERED version; files it
637
+ // already stores come back `present` and are skipped).
638
638
  const isDuplicate = !standalone && res.status === 409
639
639
  if (isDuplicate) {
640
640
  alreadyRegistered = true
@@ -712,14 +712,18 @@ async function runRegister(args = []) {
712
712
  )
713
713
  }
714
714
  log(
715
- ` ${colors.dim}Re-run \`uniweb register\` to resume — completed files are safe no-ops.${colors.reset}`
715
+ ` ${colors.dim}Re-run \`uniweb register\` to resume — files already stored are skipped.${colors.reset}`
716
716
  )
717
717
  return { exitCode: 1 }
718
718
  }
719
+ // "4 files" on a first upload; "1 uploaded, 3 already stored" on a resume.
720
+ const delivered = result.stored?.length
721
+ ? `${result.uploaded.length} uploaded, ${result.stored.length} already stored`
722
+ : `${result.uploaded.length} files`
719
723
  if (result.verified === true) {
720
724
  // serveBase is guaranteed here — it is what gated the check.
721
725
  success(
722
- `Code delivered (${result.uploaded.length} files) — entry verified live at ${colors.dim}${result.serveBase}${colors.reset}`
726
+ `Code delivered (${delivered}) — entry verified live at ${colors.dim}${result.serveBase}${colors.reset}`
723
727
  )
724
728
  } else if (result.verified === false) {
725
729
  error(
@@ -727,9 +731,7 @@ async function runRegister(args = []) {
727
731
  )
728
732
  return { exitCode: 1 }
729
733
  } else {
730
- success(
731
- `Code delivered (${result.uploaded.length} files, ${result.mode} mode)`
732
- )
734
+ success(`Code delivered (${delivered}, ${result.mode} mode)`)
733
735
  // Say WHY nothing was checked, rather than skipping in silence. The CLI
734
736
  // never reconstructs a serve URL (see utils/code-upload.js), so a plan
735
737
  // without `serve_base` means the location is not ours to know — name it
@@ -741,6 +743,16 @@ async function runRegister(args = []) {
741
743
  }
742
744
  }
743
745
  } catch (err) {
746
+ // ⚖️ A 4xx is the registry REFUSING the plan — e.g. a stored file
747
+ // declared at a different size (`version_content_changed`). Running the
748
+ // same command again gets the same answer, so do not suggest it: print
749
+ // the refusal's own sentence, which says what does help.
750
+ if (err.status >= 400 && err.status < 500 && err.detail) {
751
+ error(`Code delivery refused: HTTP ${err.status}`)
752
+ log(` ${err.detail}`)
753
+ if (err.code) log(` ${colors.dim}(${err.code})${colors.reset}`)
754
+ return { exitCode: 1 }
755
+ }
744
756
  error(`Code delivery failed: ${err.message}`)
745
757
  log(
746
758
  ` ${colors.dim}The schema registration above succeeded; re-run \`uniweb register\` to deliver the code.${colors.reset}`
@@ -7,10 +7,12 @@
7
7
  *
8
8
  * 1. PLAN — POST {apiBase}/dev/registry/code-uploads with the file list
9
9
  * ({ path, content_type, size, sha256? }). The response carries
10
- * one upload target per file ({ path, method, url, headers })
11
- * plus mode: 'direct' (dev URLs point back at the backend) or
12
- * 'presigned' (prod storage PUTs; bytes never transit the
13
- * backend). The CLI never branches on the mode.
10
+ * one entry per file: `present: true` for a file the backend
11
+ * already stores for this version (no URL it is skipped), or an
12
+ * upload target ({ path, method, url, headers }). Plus mode:
13
+ * 'direct' (dev URLs point back at the backend) or 'presigned'
14
+ * (prod — storage PUTs; bytes never transit the backend). The CLI
15
+ * never branches on the mode.
14
16
  * 2. UPLOAD — PUT each file's raw bytes to its URL with the given headers.
15
17
  * The ENTRY uploads LAST: a partial upload never yields a
16
18
  * loadable version (practical atomicity — there is no server
@@ -35,7 +37,8 @@
35
37
  * plan step's per-version file cap — the cap is an abuse guard, the maps
36
38
  * simply don't belong on the CDN.)
37
39
  * - a registered version is immutable, code included — changed bytes mean
38
- * a new version (re-PUTting identical bytes is a safe no-op)
40
+ * a new version. A stored file is never re-sent (the plan marks it
41
+ * `present`), and one declared at a different size is refused (422)
39
42
  */
40
43
 
41
44
  import { createHash } from 'node:crypto'
@@ -215,7 +218,10 @@ export function computeFoundationDigest(distDir) {
215
218
  * @param {string} opts.distDir - the built dist/ directory
216
219
  * @param {Array} [opts.files] - pre-collected file list (default: collect)
217
220
  * @param {(msg: string) => void} [opts.onProgress]
218
- * @returns {Promise<{ mode: string, uploaded: string[], failed: Array<{path, status, detail}>, verified: boolean|null, serveBase: string|null }>}
221
+ * @returns {Promise<{ mode: string, uploaded: string[], stored: string[], failed: Array<{path, status, detail}>, verified: boolean|null, serveBase: string|null }>}
222
+ * `stored` — files the plan reported as already stored (`present: true`), not re-sent.
223
+ * @throws {Error} when the plan is refused — with `status`, and the problem+json
224
+ * `detail` and `code` when the body carries them
219
225
  */
220
226
  export async function uploadFoundationCode({
221
227
  apiBase,
@@ -259,10 +265,22 @@ export async function uploadFoundationCode({
259
265
  })
260
266
  if (!planRes.ok) {
261
267
  const body = await planRes.text().catch(() => '')
268
+ // A refusal is problem+json, and its `detail` is a sentence written for the
269
+ // person reading — for a changed file it says what to do. Carry it (and the
270
+ // `code`) instead of a raw body the reader has to dig the sentence out of.
271
+ let problem = null
272
+ try {
273
+ problem = JSON.parse(body)
274
+ } catch {
275
+ problem = null
276
+ }
277
+ const detail = typeof problem?.detail === 'string' ? problem.detail : null
262
278
  const err = new Error(
263
- `code-uploads plan rejected: HTTP ${planRes.status}${body ? ` — ${body.slice(0, 300)}` : ''}`
279
+ `code-uploads plan rejected: HTTP ${planRes.status}${detail ? ` — ${detail}` : body ? ` — ${body.slice(0, 300)}` : ''}`
264
280
  )
265
281
  err.status = planRes.status
282
+ err.detail = detail
283
+ err.code = typeof problem?.code === 'string' ? problem.code : null
266
284
  throw err
267
285
  }
268
286
  const plan = await planRes.json()
@@ -275,6 +293,7 @@ export async function uploadFoundationCode({
275
293
  plan.mode === 'direct' ? { Authorization: `Bearer ${token}` } : {}
276
294
 
277
295
  const uploaded = []
296
+ const stored = []
278
297
  const failed = []
279
298
  for (const file of uploadOrder(list)) {
280
299
  const target = targets.get(file.path)
@@ -286,6 +305,27 @@ export async function uploadFoundationCode({
286
305
  })
287
306
  continue
288
307
  }
308
+ // ⭐ A file the backend already stores for this version comes back
309
+ // `present: true`, with no URL — a stored file of a registered version is
310
+ // never overwritten. Skip it. This is what lets an interrupted upload
311
+ // resume, and a re-run on a fully uploaded version pass.
312
+ //
313
+ // ⛔ Before this branch existed, a present entry was PUT to
314
+ // `new URL(undefined, origin)`, which does not throw — it resolves to
315
+ // `<origin>/undefined` — and came back as a failed upload.
316
+ if (target.present === true) {
317
+ stored.push(file.path)
318
+ onProgress(`${file.path} (already stored)`)
319
+ continue
320
+ }
321
+ if (!target.url) {
322
+ failed.push({
323
+ path: file.path,
324
+ status: 0,
325
+ detail: 'the plan gave no upload URL'
326
+ })
327
+ continue
328
+ }
289
329
  const bytes = readFileSync(join(distDir, file.path))
290
330
  try {
291
331
  // ⭐ **Retried.** A single connection-level failure used to fail the whole
@@ -350,5 +390,5 @@ export async function uploadFoundationCode({
350
390
  }
351
391
  }
352
392
 
353
- return { mode: plan.mode || 'direct', uploaded, failed, verified, serveBase }
393
+ return { mode: plan.mode || 'direct', uploaded, stored, failed, verified, serveBase }
354
394
  }
@@ -0,0 +1,57 @@
1
+ /**
2
+ * SemVer 2.0.0 precedence — how a registry orders a foundation's versions.
3
+ *
4
+ * A pre-release sorts below its release (`1.0.0-rc.1` < `1.0.0`), and build
5
+ * metadata carries no order (`1.2.0+b` equals `1.2.0`).
6
+ *
7
+ * ⚖️ Not `compareSemver` in `dep-survey.js`: that one reads dependency specs
8
+ * (`^1.2.3`) for `uniweb update` and compares major.minor.patch only. This one
9
+ * is strict — anything that is not a SemVer version is `null`, never a guess.
10
+ */
11
+
12
+ // The regex published with the SemVer 2.0.0 specification (semver.org, §FAQ).
13
+ const SEMVER =
14
+ /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-((?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\.(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?(?:\+([0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*))?$/
15
+
16
+ /**
17
+ * @param {unknown} version
18
+ * @returns {{ major: number, minor: number, patch: number, pre: string[] }|null}
19
+ */
20
+ export function parseSemver(version) {
21
+ if (typeof version !== 'string') return null
22
+ const m = SEMVER.exec(version)
23
+ if (!m) return null
24
+ return { major: +m[1], minor: +m[2], patch: +m[3], pre: m[4] ? m[4].split('.') : [] }
25
+ }
26
+
27
+ /**
28
+ * @param {string} a
29
+ * @param {string} b
30
+ * @returns {-1|0|1|null} how `a` sorts against `b`; null when either is not SemVer
31
+ */
32
+ export function compareSemverPrecedence(a, b) {
33
+ const x = parseSemver(a)
34
+ const y = parseSemver(b)
35
+ if (!x || !y) return null
36
+ for (const part of ['major', 'minor', 'patch']) {
37
+ if (x[part] !== y[part]) return x[part] > y[part] ? 1 : -1
38
+ }
39
+ // A version with a pre-release sorts below the same version without one.
40
+ if (!x.pre.length || !y.pre.length) {
41
+ return x.pre.length === y.pre.length ? 0 : x.pre.length ? -1 : 1
42
+ }
43
+ for (let i = 0; i < Math.max(x.pre.length, y.pre.length); i++) {
44
+ const p = x.pre[i]
45
+ const q = y.pre[i]
46
+ if (p === undefined) return -1
47
+ if (q === undefined) return 1
48
+ if (p === q) continue
49
+ const pNum = /^\d+$/.test(p)
50
+ const qNum = /^\d+$/.test(q)
51
+ // Numeric identifiers compare numerically and sort below alphanumeric ones.
52
+ if (pNum && qNum) return BigInt(p) > BigInt(q) ? 1 : -1
53
+ if (pNum !== qNum) return pNum ? -1 : 1
54
+ return p > q ? 1 : -1
55
+ }
56
+ return 0
57
+ }