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.
|
|
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/
|
|
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/
|
|
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
|
|
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
|
-
|
|
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),
|
package/src/commands/register.js
CHANGED
|
@@ -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;
|
|
637
|
-
//
|
|
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 —
|
|
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 (${
|
|
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}`
|
package/src/utils/code-upload.js
CHANGED
|
@@ -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
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
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
|
|
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
|
+
}
|