uniweb 0.65.0 → 0.67.0

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.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "schemaVersion": 1,
3
- "generatedAt": "2026-09-23T16:44:03.282Z",
3
+ "generatedAt": "2026-09-24T19:00:27.393Z",
4
4
  "packages": {
5
5
  "@uniweb/api": {
6
6
  "version": "0.6.1",
@@ -10,7 +10,7 @@
10
10
  ]
11
11
  },
12
12
  "@uniweb/build": {
13
- "version": "0.59.1",
13
+ "version": "0.61.0",
14
14
  "path": "framework/build",
15
15
  "deps": [
16
16
  "@uniweb/content-reader",
@@ -96,7 +96,7 @@
96
96
  "deps": []
97
97
  },
98
98
  "@uniweb/schemas": {
99
- "version": "0.4.0",
99
+ "version": "0.6.0",
100
100
  "path": "framework/schemas",
101
101
  "deps": []
102
102
  },
@@ -126,7 +126,7 @@
126
126
  "deps": []
127
127
  },
128
128
  "@uniweb/unipress": {
129
- "version": "0.10.2",
129
+ "version": "0.10.4",
130
130
  "path": "framework/unipress",
131
131
  "deps": [
132
132
  "@uniweb/build",
package/src/index.js CHANGED
@@ -1418,7 +1418,8 @@ ${colors.bright}Options:${colors.reset}
1418
1418
  --target <name> Pick a target from deploy.yml (default: deploy.yml's \`default:\`)
1419
1419
  --dry-run Resolve the target + adapter; print summary; upload nothing
1420
1420
  --no-save Do not record this deploy in deploy.yml
1421
- --no-validate Skip the content-conformance check (it only warns)
1421
+ --no-validate Skip the content-conformance check (a static host warns;
1422
+ Uniweb Cloud stops on content that does not conform)
1422
1423
  --non-interactive Fail with usage info instead of prompting
1423
1424
 
1424
1425
  ${colors.bright}Examples:${colors.reset}
@@ -1439,9 +1440,10 @@ ${colors.bright}Usage:${colors.reset}
1439
1440
  The most ergonomic command in the tool: \`uniweb login && uniweb publish\` reads
1440
1441
  your project and makes the site live on Uniweb hosting. It resolves which site,
1441
1442
  BRINGS THE FOUNDATION ALONG (releases the local foundation when its code
1442
- changed), syncs content, settles payment when go-live needs it (a browser
1443
- handoff), and goes live. A published-registry foundation needs no release; an
1444
- already-paid site opens no browser.
1443
+ changed — under the next version when its own is registered; commit the
1444
+ package.json it writes), syncs content, settles payment when go-live needs it
1445
+ (a browser handoff), and goes live. A published-registry foundation needs no
1446
+ release; an already-paid site opens no browser.
1445
1447
 
1446
1448
  For a third-party host use \`uniweb deploy --host=<name>\`; to register a
1447
1449
  FOUNDATION on its own use \`uniweb register\` (alias \`uniweb release\`).
@@ -1454,8 +1456,11 @@ ${colors.bright}Options:${colors.reset}
1454
1456
  when only the content should go live. Refused if the
1455
1457
  foundation has never been released — there is nothing to
1456
1458
  bind to, and the app cannot open such a site.
1459
+ --bump When the registry holds a newer version of the foundation
1460
+ than yours, release yours above it instead of stopping
1457
1461
  --no-save Do not record this deploy in deploy.yml
1458
- --no-validate Skip the content-conformance check (it only warns)
1462
+ --no-validate Skip the content-conformance check, which stops a publish
1463
+ whose content does not conform to its data schemas
1459
1464
  --org @org Work in @org for this publish, instead of the workspace
1460
1465
  --personal chosen at \`uniweb login\` (or your personal workspace). A
1461
1466
  site this publish creates is created — owned — there, and a
@@ -1690,17 +1695,18 @@ foundation declared for that input (meta.js \`data:\`). Answers "does my
1690
1695
  data match what I promised?" — distinct from \`doctor\`, which checks your
1691
1696
  project against framework conventions.
1692
1697
 
1693
- Warns by default; the live render path stays tolerant, so this is a
1694
- pre-live / CI gate. Dynamic (\`url:\`) inputs and entity references can't be
1695
- resolved without a running backend, so they're reported as deferred,
1696
- never silently skipped.
1698
+ Every record file is checked against the schema its folder names, whether or
1699
+ not a section reads it. A violation fails the run; \`push\` and \`publish\` refuse
1700
+ the same findings. The live render path stays tolerant. Remote (\`url:\`)
1701
+ inputs are not in the project, so they're reported as deferred, never silently
1702
+ skipped. A reference field's value is not checked against the entity it names.
1697
1703
 
1698
1704
  ${colors.bright}Options:${colors.reset}
1699
- --strict Treat findings as errors (non-zero exit for CI)
1705
+ --lax Report violations without failing (exit 0)
1700
1706
  --json Machine-readable output (for CI annotations)
1701
1707
  --site <name> Check one site in a multi-site workspace
1702
1708
 
1703
- Exit codes: 0 clean (or warn-only), 1 violations under --strict, 2 setup error.
1709
+ Exit codes: 0 clean (or --lax), 1 violations, 2 setup error.
1704
1710
  `,
1705
1711
  register: `
1706
1712
  ${colors.cyan}${colors.bright}uniweb register${colors.reset} ${colors.dim}— Register (release) a foundation + its data schemas with the backend registry${colors.reset}
@@ -2087,10 +2093,11 @@ ${colors.bright}Publish Options:${colors.reset}
2087
2093
  --dry-run Resolve everything; release/sync/POST nothing
2088
2094
  --yes Skip confirmations (CI); never block on a prompt
2089
2095
  --no-release Ship content against the already-released code; release nothing
2096
+ --bump Release above a newer registered foundation version
2090
2097
  --org @org Work in @org for this publish, not your login's workspace
2091
2098
  --personal Work in your personal workspace for this publish
2092
2099
  --no-save Do not record this deploy in deploy.yml
2093
- --no-validate Skip the content-conformance check (it only warns)
2100
+ --no-validate Skip the content-conformance check (it stops a publish)
2094
2101
 
2095
2102
  uniweb publish is the smart Uniweb-hosting path: it brings the site's
2096
2103
  foundation along, syncs, and goes live. To register a foundation on its own
@@ -2104,7 +2111,7 @@ ${colors.bright}Deploy Options:${colors.reset}
2104
2111
  --target <name> Pick a target from deploy.yml (default: deploy.yml's \`default:\`)
2105
2112
  --dry-run Resolve the target + adapter; print summary; upload nothing
2106
2113
  --no-save Do not record this deploy in deploy.yml
2107
- --no-validate Skip the content-conformance check (it only warns)
2114
+ --no-validate Skip the content-conformance check (a static host warns)
2108
2115
  To deploy on every push instead, see \`uniweb add ci --help\`.
2109
2116
 
2110
2117
  ${colors.bright}Dev Options:${colors.reset}
@@ -8,17 +8,26 @@
8
8
  * it. The first symptom was a section rendering nothing on a live site, with no
9
9
  * error anywhere in the chain.
10
10
  *
11
- * ## It WARNS. It never blocks, and that is a decision, not a TODO.
11
+ * ## On a STATIC host it warns and never blocks — and on a backend it is a gate.
12
+ *
13
+ * ⭐ Two answers, one per destination [Diego, 2026-09-24: "The old rationale was in
14
+ * the context of static sites. It was never meant for pushed sites since that didn't
15
+ * even exist at the time."]. `push` and `publish` register the foundation first, so
16
+ * the backend checks the records against the very schemas checked here: a finding
17
+ * there is a refusal on arrival, or a value lost on the way — never a disagreement to
18
+ * ship through. `refuseIfContentDoesNotConform` is that gate; `deploy` to a static
19
+ * host keeps the warning below, whose reasoning is unchanged for it:
12
20
  *
13
21
  * A schema can be newer than the content that was valid when it was authored —
14
22
  * a foundation upgrade, a `@std/*` revision — so a finding does not mean the
15
23
  * content is wrong, it means the two disagree. A framework that refused to
16
24
  * publish over that would make an author's site hostage to a schema release,
17
- * for a defect whose real-world cost is one section rendering wrong. `--strict`
18
- * on `uniweb validate` is where the gate lives, and CI is where it belongs.
25
+ * for a defect whose real-world cost is one section rendering wrong. `uniweb
26
+ * validate` is where the gate lives for a static site, and CI is where it belongs.
19
27
  *
20
- * So: silent when everything conforms, silent when it cannot check, one compact
21
- * block when it finds something, and **the ship proceeds either way**.
28
+ * So, on a static host: silent when everything conforms, silent when it cannot
29
+ * check, one compact block when it finds something, and **the ship proceeds
30
+ * either way**.
22
31
  *
23
32
  * ## Why it is quiet about being unable to check
24
33
  *
@@ -41,7 +50,8 @@ const c = {
41
50
  reset: '\x1b[0m',
42
51
  bold: '\x1b[1m',
43
52
  dim: '\x1b[2m',
44
- yellow: '\x1b[33m'
53
+ yellow: '\x1b[33m',
54
+ red: '\x1b[31m'
45
55
  }
46
56
 
47
57
  /** How many findings to print before summarizing the rest. */
@@ -144,6 +154,47 @@ export async function warnIfContentDoesNotConform(siteDir, options = {}) {
144
154
  return formatted.total
145
155
  }
146
156
 
157
+ /**
158
+ * The same check as a GATE, for `push` and `publish` — see the header for why a
159
+ * backend gets a gate where a static host gets a warning.
160
+ *
161
+ * Only VIOLATIONS stop it: a record the backend would refuse, or whose value it would
162
+ * lose. A setup error is about how data reaches a section — a rendering question, not
163
+ * a record — so it is still printed as a warning and the ship goes on. A checker that
164
+ * cannot run says nothing and stops nothing, as above. `--no-validate` skips the check
165
+ * and leaves the records to the backend.
166
+ *
167
+ * @param {string} siteDir
168
+ * @param {object} [options] - `args`, and the caller's `error` / `warn` / `dim` printers
169
+ * @returns {Promise<number>} the violations that stop the ship (0 to go on)
170
+ */
171
+ export async function refuseIfContentDoesNotConform(siteDir, options = {}) {
172
+ const {
173
+ args = [],
174
+ error = (m) => console.log(`${c.red}✗${c.reset} ${m}`),
175
+ warn = (m) => console.log(`${c.yellow}⚠${c.reset} ${m}`),
176
+ dim = (m) => console.log(` ${c.dim}${m}${c.reset}`)
177
+ } = options
178
+
179
+ if (args.includes('--no-validate')) return 0
180
+
181
+ let result
182
+ try {
183
+ result = await checkSiteConformance(siteDir)
184
+ } catch {
185
+ return 0
186
+ }
187
+ if (result.status !== 'checked') return 0
188
+
189
+ const violations = result.report?.violations?.length || 0
190
+ const formatted = formatConformanceWarning(result, siteDir, { stops: violations > 0 })
191
+ if (!formatted) return 0
192
+
193
+ ;(violations > 0 ? error : warn)(formatted.headline)
194
+ for (const line of formatted.details) dim(line)
195
+ return violations
196
+ }
197
+
147
198
  /**
148
199
  * Turn a report into the lines to print, or `null` when there is nothing to say.
149
200
  *
@@ -154,9 +205,11 @@ export async function warnIfContentDoesNotConform(siteDir, options = {}) {
154
205
  *
155
206
  * @param {{report?: object, foundation?: string}} result
156
207
  * @param {string} siteDir
208
+ * @param {{ stops?: boolean }} [options] - `stops`: the ship stops on this report
209
+ * (`refuseIfContentDoesNotConform`), so the last line says nothing was sent
157
210
  * @returns {{headline: string, details: string[], total: number}|null}
158
211
  */
159
- export function formatConformanceWarning(result, siteDir = '') {
212
+ export function formatConformanceWarning(result, siteDir = '', { stops = false } = {}) {
160
213
  const violations = result?.report?.violations || []
161
214
  const setupErrors = result?.report?.setupErrors || []
162
215
  const total = violations.length + setupErrors.length
@@ -201,9 +254,14 @@ export function formatConformanceWarning(result, siteDir = '') {
201
254
  const details = lines.slice(0, MAX_SHOWN).map((l) => `• ${l}`)
202
255
  if (lines.length > MAX_SHOWN) details.push(`…and ${lines.length - MAX_SHOWN} more`)
203
256
 
204
- // Name the command that explains it, and say plainly that this is not a
205
- // refusal — a warning during a ship reads as a failure unless it says so.
206
- details.push('Shipping anyway. Run `uniweb validate` for the full report.')
257
+ // Name the command that explains it, and say plainly which this is — a warning
258
+ // during a ship reads as a failure unless it says so, and a refusal must say what
259
+ // did not happen.
260
+ details.push(
261
+ stops
262
+ ? 'Nothing was sent. Run `uniweb validate` for the full report, or pass `--no-validate` to send it anyway and let the backend decide.'
263
+ : 'Shipping anyway. Run `uniweb validate` for the full report.'
264
+ )
207
265
 
208
266
  return { headline, details, total }
209
267
  }
@@ -64,15 +64,16 @@ const VERBS = {
64
64
  // flag-guard-coverage.test.js the moment push gained the import, which is
65
65
  // exactly the hand-enumeration failure that test exists to catch.
66
66
  '--no-verify',
67
- // ship content against the already-released code, releasing nothing
68
- '--no-release', ...VIA_DEPLOY
67
+ // ship content against the already-released code, releasing nothing — or
68
+ // release above a newer registered version instead of stopping (both bring-along)
69
+ '--no-release', '--bump', ...VIA_DEPLOY
69
70
  ],
70
71
  publish: [
71
72
  '--org', '--dry-run', '--force', '--foundation',
72
73
  '--personal',
73
74
  // read in utils/conformance.js, backend/site-sync.js, and
74
75
  // backend/foundation-bring-along.js — none appear in publish.js
75
- '--no-validate', '--yes', '--no-verify', '--no-release', ...VIA_DEPLOY
76
+ '--no-validate', '--yes', '--no-verify', '--no-release', '--bump', ...VIA_DEPLOY
76
77
  ],
77
78
  pull: [
78
79
  '--content-only', '--dry-run', '--force', '--merge',
@@ -103,7 +104,7 @@ const VERBS = {
103
104
  '--force', '--no-verify', '--no-validate', '--yes', '--org', '--personal',
104
105
  // inert here, reachable through the bring-along module status imports for
105
106
  // `resolveLocalFoundation` — listed per the over-approximation note above
106
- '--no-release', ...VIA_DEPLOY
107
+ '--no-release', '--bump', ...VIA_DEPLOY
107
108
  ],
108
109
  /**
109
110
  * `refresh` = `git pull`, then a DELEGATED `pull --merge`.
package/src/utils/git.js CHANGED
@@ -18,6 +18,7 @@
18
18
  */
19
19
 
20
20
  import { execFileSync } from 'node:child_process'
21
+ import { createHash } from 'node:crypto'
21
22
  import { readFileSync, realpathSync } from 'node:fs'
22
23
  import { join, relative } from 'node:path'
23
24
  import yaml from 'js-yaml'
@@ -173,8 +174,25 @@ export function uncommittedUnder(dir, relPaths) {
173
174
  * never committed has no ancestor, so there is nothing to merge against).
174
175
  */
175
176
  export function showAtHead(dir, relPath) {
177
+ return showAt(dir, 'HEAD', relPath)
178
+ }
179
+
180
+ /**
181
+ * A file's content at a revision, by a path relative to `dir`.
182
+ *
183
+ * ⛔ THE `./` IS WHAT MAKES THE PATH RELATIVE TO `dir`. In `<rev>:<path>` a bare path
184
+ * is read from the REPOSITORY ROOT, whatever the cwd. With the repo at the project
185
+ * root and the site in `site/` — the default layout — `HEAD:pages/home/hero.md`
186
+ * named nothing, every lookup failed, and `pull --merge` "kept yours" for every file:
187
+ * the backend's changes never merged in, while the pull recorded them as taken
188
+ * (2026-09-23). Every merge test ran with the repo AT the site directory, the one
189
+ * layout where a bare path happens to work.
190
+ *
191
+ * @returns {Buffer|null} null when the path isn't in that revision
192
+ */
193
+ export function showAt(dir, rev, relPath) {
176
194
  try {
177
- return execFileSync('git', ['show', `HEAD:${relPath}`], {
195
+ return execFileSync('git', ['show', `${rev}:./${relPath}`], {
178
196
  cwd: dir,
179
197
  stdio: ['ignore', 'pipe', 'ignore'],
180
198
  maxBuffer: 64 * 1024 * 1024
@@ -184,6 +202,35 @@ export function showAtHead(dir, relPath) {
184
202
  }
185
203
  }
186
204
 
205
+ /**
206
+ * The most recent committed version of a file whose content hashes to `sha256` —
207
+ * how the version a pull wrote is found again, as the ancestor of a merge.
208
+ *
209
+ * HEAD is that version only until the user commits an edit on top of it. After that
210
+ * HEAD is THEIR edit, and a merge against it reads their change as the starting
211
+ * point: the backend's version wins whole and the committed edit is reverted in the
212
+ * working tree. The pull recorded the hash of what it wrote (`pull-written.json`),
213
+ * so the version is recoverable from history by content, wherever it was committed.
214
+ *
215
+ * @param {number} [limit] - how many commits touching the file to search
216
+ * @returns {Buffer|null} null when no committed version matches
217
+ */
218
+ export function findCommitted(dir, relPath, sha256, { limit = 50 } = {}) {
219
+ let revs
220
+ try {
221
+ revs = git(['log', `-n${limit}`, '--format=%H', '--', `./${relPath}`], dir)
222
+ .split('\n')
223
+ .filter(Boolean)
224
+ } catch {
225
+ return null
226
+ }
227
+ for (const rev of revs) {
228
+ const buf = showAt(dir, rev, relPath)
229
+ if (buf && createHash('sha256').update(buf).digest('hex') === sha256) return buf
230
+ }
231
+ return null
232
+ }
233
+
187
234
  /**
188
235
  * Three-way merge, in place: `minePath` is rewritten with the merged result.
189
236
  *
@@ -1,6 +1,11 @@
1
1
  /**
2
2
  * The record of what a machine wrote into a site project — pull's own output.
3
3
  *
4
+ * ⭐ And, since 2026-09-23, what a push delivered: the files of the units the backend
5
+ * now holds as they were sent (`recordPushedUnits`, site-sync.js). Both are "the last
6
+ * version this file synced at", which is what `pull --merge` needs as its ancestor —
7
+ * found again in the repo's history by this hash (`findCommitted`, utils/git.js).
8
+ *
4
9
  * ## What it is for
5
10
  *
6
11
  * `uniweb pull` refuses to run when there are uncommitted changes under the files
@@ -24,6 +24,29 @@ export function parseSemver(version) {
24
24
  return { major: +m[1], minor: +m[2], patch: +m[3], pre: m[4] ? m[4].split('.') : [] }
25
25
  }
26
26
 
27
+ /**
28
+ * The next version above `version` on its own line — what `--bump` releases under
29
+ * when the registry already holds the local version with other code.
30
+ *
31
+ * A release gets its next patch (`1.4.2` → `1.4.3`); a pre-release its next
32
+ * pre-release (`2.0.0-beta.3` → `2.0.0-beta.4`, `1.0.0-rc` → `1.0.0-rc.0`), so a bump
33
+ * nobody chose never promotes a beta line to a release. Build metadata is dropped: it
34
+ * carries no order, so it could not make a version newer.
35
+ *
36
+ * @param {string} version
37
+ * @returns {string|null} null when `version` is not SemVer
38
+ */
39
+ export function nextVersionAbove(version) {
40
+ const v = parseSemver(version)
41
+ if (!v) return null
42
+ if (!v.pre.length) return `${v.major}.${v.minor}.${v.patch + 1}`
43
+ const pre = [...v.pre]
44
+ const last = pre.length - 1
45
+ if (/^\d+$/.test(pre[last])) pre[last] = String(BigInt(pre[last]) + 1n)
46
+ else pre.push('0')
47
+ return `${v.major}.${v.minor}.${v.patch}-${pre.join('.')}`
48
+ }
49
+
27
50
  /**
28
51
  * @param {string} a
29
52
  * @param {string} b