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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "uniweb",
3
- "version": "0.65.0",
3
+ "version": "0.67.0",
4
4
  "description": "Create structured Vite + React sites with content/code separation",
5
5
  "type": "module",
6
6
  "bin": {
@@ -44,12 +44,12 @@
44
44
  "@uniweb/content-writer": "^0.3.4",
45
45
  "@uniweb/core": "^0.29.8",
46
46
  "@uniweb/kit": "^0.19.4",
47
+ "@uniweb/schemas": "^0.6.0",
47
48
  "@uniweb/runtime": "^0.27.4",
48
- "@uniweb/schemas": "^0.4.0",
49
49
  "@uniweb/semantic-parser": "^1.4.1"
50
50
  },
51
51
  "peerDependencies": {
52
- "@uniweb/build": "^0.59.1",
52
+ "@uniweb/build": "^0.61.0",
53
53
  "@uniweb/content-reader": "^1.2.5",
54
54
  "@uniweb/semantic-parser": "^1.4.1"
55
55
  },
@@ -1870,7 +1870,7 @@ fetch:
1870
1870
 
1871
1871
  **Component-side fetching.** When a component genuinely needs to fetch on its own (a search box, "load more", a lazy popover), use the kit hooks — `useFetched`, `useCacheEntry`, `useWholeRecord`. They share the framework's cache and dispatcher with declarative fetches; same-key requests dedupe automatically.
1872
1872
 
1873
- **Validate before shipping.** `uniweb validate` checks file-based data against your declared schemas — missing required fields, type/enum/format mismatches, nested fields. Warns by default; `--strict` for a non-zero CI exit. Distinct from `uniweb doctor` (project structure): `validate` checks your *data* against the schemas you *declared*. External queries (`url:`), `ref`/`options`, and rich `sections`-form inputs are reported deferred.
1873
+ **Validate before shipping.** `uniweb validate` checks file-based data against your declared schemas — missing required fields, type/enum/format mismatches, nested fields. A violation fails it (`--lax` only reports); it checks every record file in `records/`, and `push` / `publish` refuse the same findings before sending anything. Distinct from `uniweb doctor` (project structure): `validate` checks your *data* against the schemas you *declared*. External queries (`url:`) are reported as deferred — their data isn't in the project; the value of a `ref`/`options` field is not checked against the entity it names.
1874
1874
 
1875
1875
  ### Fetching from other sources (`fetcher:`)
1876
1876
 
@@ -2539,7 +2539,7 @@ uniweb i18n init-freeform / update-hash / move / rename / prune --freeform
2539
2539
 
2540
2540
  uniweb -v # Installed CLI version — and whether a newer one exists
2541
2541
  uniweb doctor # Diagnose project configuration (--fix to auto-repair)
2542
- uniweb validate # Check file-based data against declared schemas (--strict for CI)
2542
+ uniweb validate # Check file-based data against declared schemas (fails on a violation; --lax)
2543
2543
  npx uniweb@latest update # Align @uniweb/* deps + AGENTS.md (--dry-run, --yes)
2544
2544
  # bare `uniweb update` aligns to the CLI you ALREADY have
2545
2545
  uniweb inspect <path> # Show parsed content for a section or page (--raw for the AST)
@@ -2609,7 +2609,7 @@ Foundations have their own free path too: `uniweb add ci --target foundation` pu
2609
2609
 
2610
2610
  Either side can publish. Nothing about this changes how you build: the same foundation and the same site run under `uniweb dev`, `uniweb export`, or a CI deploy with no account at all.
2611
2611
 
2612
- **Publishing vs registering.** Foundations on Uniweb Cloud live in the catalog as `@org/name@version` — `name` from the foundation's `main.js`. When a foundation powers a single site, **don't run `uniweb register` yourself** — `uniweb publish` from the site directory releases the local foundation to the catalog (when its code changed) and goes live in one step. Register deliberately only when the foundation is a product meant for multiple sites; consuming sites then pin `foundation: '@org/name@1.2.3'`. **The catalog is private and access-segregated, not a public package registry** — people see only the foundations licensed to sites they own or edit. The *site* carries the license, and it rides along with site ownership when a developer hands a site to a client. Don't describe publishing as making a foundation publicly discoverable. Schemas can also be registered on their own from a schemas-only package (`@uniweb/schemas`, any `@org/schemas`, or a bare folder of `schemas/*.{yml,json,js}`) — that's how `@std` schemas are published. Auth via `uniweb login` (`uniweb login --backend <url> --token <bearer>` without a terminal) or `UNIWEB_TOKEN`; preview with `--dry-run`.
2612
+ **Publishing vs registering.** Foundations on Uniweb Cloud live in the catalog as `@org/name@version` — `name` from the foundation's `main.js`. When a foundation powers a single site, **don't run `uniweb register` yourself** — `uniweb publish` from the site directory releases the local foundation to the catalog (when its code changed) and goes live in one step. A registered version is immutable, so when the foundation's code changed under a version already registered, `push` and `publish` release the change under the next version and write it into the foundation's `package.json` — commit that file (`--no-release` sends the content without the code). The version it picks is always the next patch: for a change that is not backwards compatible, set a higher minor or major version in the foundation's `package.json` yourself before you push. They stop when the registry holds a version newer than yours, released from another copy: pull that change first, or pass `--bump` to release yours above it. Register deliberately only when the foundation is a product meant for multiple sites; consuming sites then pin `foundation: '@org/name@1.2.3'`. **The catalog is private and access-segregated, not a public package registry** — people see only the foundations licensed to sites they own or edit. The *site* carries the license, and it rides along with site ownership when a developer hands a site to a client. Don't describe publishing as making a foundation publicly discoverable. Schemas can also be registered on their own from a schemas-only package (`@uniweb/schemas`, any `@org/schemas`, or a bare folder of `schemas/*.{yml,json,js}`) — that's how `@std` schemas are published. Auth via `uniweb login` (`uniweb login --backend <url> --token <bearer>` without a terminal) or `UNIWEB_TOKEN`; preview with `--dry-run`.
2613
2613
 
2614
2614
  ### Staying current
2615
2615
 
@@ -5,11 +5,30 @@
5
5
  * A publish must never ship a site pointing at stale or missing foundation code
6
6
  * (the footgun: a site goes live referencing a version the catalog doesn't
7
7
  * have). So when the site references a LOCAL foundation, the verb fingerprints it
8
- * and reconciles with the catalog. Three cases (§4):
8
+ * and reconciles with the catalog (§4):
9
9
  *
10
- * | version not yet registered | release it, then publish |
11
- * | registered, code unchanged | skip the release (digest match) |
12
- * | registered, code CHANGED | warn / prompt — never silent |
10
+ * | version not yet registered | release it |
11
+ * | registered, code unchanged | skip the release (digest match) |
12
+ * | registered, code CHANGED | release it under the next version |
13
+ * | a NEWER version is registered | stop — `--bump` releases above it |
14
+ *
15
+ * ⭐ **Changed code is released by default** *[Diego, 2026-09-24]*. A site whose project
16
+ * holds its own foundation is a content+code bundle — *"a developer is used to code
17
+ * going with the site, so they don't think of bumping the code of a site"* — and the
18
+ * version there decides nothing: the only site reading it is the one being pushed.
19
+ * Since a registered version is immutable, the change goes under the next version
20
+ * above the registered one, written into the package's `package.json` first. ⛔ This
21
+ * reverses the version-bump release gate of 2026-06-23, whose reasoning — a deliberate,
22
+ * npm-style release — fits a foundation other sites use as a product; those sites pin
23
+ * a catalog ref and never reach this code. Its author's demo site, which names it by
24
+ * path, does — and gets the same default, by ruling [Diego, 2026-09-24]. The release is
25
+ * always the next patch, so a change that is not backwards compatible needs a higher
26
+ * version set in `package.json` by hand.
27
+ *
28
+ * ⛔ **A NEWER registered version still stops it.** It was released from another copy
29
+ * of the project, whose code this one may not have, and releasing above it would make
30
+ * the older code the newest — the code twin of push's content staleness gate. `--bump`
31
+ * releases above it anyway; `--no-release` ships the content bound to it.
13
32
  *
14
33
  * The freshness signal is the backend-stored, framework-computed digest (§4.1):
15
34
  * no local state, multi-machine-safe. When the site references a published
@@ -44,7 +63,11 @@ import {
44
63
  } from '@uniweb/build'
45
64
  import { computeFoundationDigest } from '../utils/code-upload.js'
46
65
  import { isNonInteractive } from '../utils/interactive.js'
47
- import { compareSemverPrecedence } from '../utils/semver-precedence.js'
66
+ import { writeJsonPreservingStyle } from '../utils/json-file.js'
67
+ import {
68
+ compareSemverPrecedence,
69
+ nextVersionAbove
70
+ } from '../utils/semver-precedence.js'
48
71
 
49
72
  /**
50
73
  * Resolve the site's LOCAL foundation — the one publish should bring along — or
@@ -160,6 +183,14 @@ function readPkgField(dir, field) {
160
183
  }
161
184
  }
162
185
 
186
+ // Set the package's version, changing nothing else in the file — its key order,
187
+ // indentation and trailing newline stay as they were, so the diff is one line.
188
+ function writePkgVersion(dir, version) {
189
+ const path = join(dir, 'package.json')
190
+ const src = readFileSync(path, 'utf8')
191
+ writeJsonPreservingStyle(path, { ...JSON.parse(src), version }, src)
192
+ }
193
+
163
194
  // What travels to the spawned `uniweb register` / `build` on the command line: only
164
195
  // --non-interactive. The BACKEND travels in the child's environment
165
196
  // (releaseFoundation), and the SESSION is the shared session file — or UNIWEB_TOKEN,
@@ -232,7 +263,9 @@ export async function bringFoundationAlong({
232
263
  * @param {object} o
233
264
  * @param {{dir: string, version: string|null}} o.local
234
265
  * @param {'foundation'|'extension'} o.kind
235
- * @returns {Promise<{ released: boolean, proceed: boolean, ref: string|null }>}
266
+ * @returns {Promise<{ released: boolean, proceed: boolean, ref: string|null, bumped?: string }>}
267
+ * `bumped` is the version written into package.json when changed code was released
268
+ * under the next version.
236
269
  */
237
270
  async function bringLocalCodeAlong({
238
271
  client,
@@ -271,6 +304,19 @@ async function bringLocalCodeAlong({
271
304
  // says (see the `!reg` branch).
272
305
  const noRelease = args.includes('--no-release')
273
306
 
307
+ // `--bump`: when the registry holds a version NEWER than the local one, release the
308
+ // local code above it instead of stopping. That is all it does — changed code under
309
+ // the registered version is released without it, and unchanged code never is — so a
310
+ // script can pass it on every run.
311
+ const bump = args.includes('--bump')
312
+
313
+ if (bump && noRelease) {
314
+ say.err(
315
+ '`--bump` releases your local changes and `--no-release` ships without them — pass one of the two.'
316
+ )
317
+ return { released: false, proceed: false, refused: true, ref: null }
318
+ }
319
+
274
320
  // The pinned ref to stamp on the pushed site — read at RETURN time (after any
275
321
  // release), so it reflects the released version, the scope register derived and
276
322
  // the name it asked for. null when no scoped ref can be formed (then the site.yml
@@ -336,6 +382,36 @@ async function bringLocalCodeAlong({
336
382
  }
337
383
  }
338
384
 
385
+ // The next version above the registered one — what changed code is released under
386
+ // when the local version is taken. Null when the registered version is not SemVer,
387
+ // and then there is nothing to count up from.
388
+ const bumpTo = nextVersionAbove(reg.latest_version)
389
+
390
+ // Write it into package.json, then release. `register` rebuilds on its own when the
391
+ // built schema's version no longer matches package.json, so the release carries it.
392
+ // `optOut` names `--no-release` for a release nobody asked for by flag.
393
+ //
394
+ // ⚖️ A failed release leaves the new version in the file, and that is the useful
395
+ // state: the next run finds a local version above the registered one and releases
396
+ // it, with nothing to redo.
397
+ const releaseAsNext = async (why, { optOut = false } = {}) => {
398
+ if (!bumpTo) {
399
+ say.err(
400
+ `Cannot release ${label} under a new version: the registered ${reg.latest_version} is not a SemVer version.`
401
+ )
402
+ say.dim(
403
+ `Nothing was sent. Set a version above it in the ${kind}'s package.json, then re-run \`uniweb ${verb}\`; or \`uniweb ${verb} --no-release\` sends content bound to the released ${reg.latest_version}.`
404
+ )
405
+ return { released: false, proceed: false, refused: true, ref: null }
406
+ }
407
+ writePkgVersion(local.dir, bumpTo)
408
+ say.info(`Releasing the ${kind} ${scopedName || kind} as ${bumpTo} — ${why}…`)
409
+ const released = releaseFoundation(local, args, cliBin, say, client?.origin)
410
+ say.dim(`The ${kind}'s package.json now says ${bumpTo} — commit it.`)
411
+ if (optOut) say.dim('To send content without releasing code, pass `--no-release`.')
412
+ return { released, proceed: true, bumped: bumpTo, ref: await pinnedRef() }
413
+ }
414
+
339
415
  // Registered — fingerprint the local build and compare. Build first so the
340
416
  // digest reflects current source (idempotent: a no-op when already fresh).
341
417
  buildFoundation(local, cliBin)
@@ -348,38 +424,70 @@ async function bringLocalCodeAlong({
348
424
  return { released: false, proceed: true, ref: await pinnedRef() }
349
425
  }
350
426
 
351
- // A different version locally → a new version to release.
427
+ // A different version locally.
352
428
  if (local.version && local.version !== reg.latest_version) {
353
- if (noRelease) {
354
- // ⚠️ Bind to the REGISTERED version, not the local one. `pinnedRef()` would
355
- // return the bumped-but-unreleased `local.version` here — a ref no deployment
356
- // can serve, which is the very failure this flag must not create.
429
+ const order = compareSemverPrecedence(local.version, reg.latest_version)
430
+ // Equal in precedence (build metadata apart) is the registered version itself, and
431
+ // is treated as it, below.
432
+ if (order !== 0) {
433
+ if (noRelease) {
434
+ // ⚠️ Bind to the REGISTERED version, not the local one. `pinnedRef()` would
435
+ // return the bumped-but-unreleased `local.version` here — a ref no deployment
436
+ // can serve, which is the very failure this flag must not create.
437
+ say.info(
438
+ `Keeping the released ${kind} ${reg.latest_version} — local ${local.version} not released (\`--no-release\`).`
439
+ )
440
+ return { released: false, proceed: true, ref: registeredRef(reg) }
441
+ }
442
+
443
+ // ⛔ OLDER than the registered latest: that version was released from another
444
+ // copy of the project, and this one may not have its code. Releasing above it
445
+ // would make this code the newest and bind the site to it — a teammate's release
446
+ // undone by a push that never saw it. So it stops, and names the ways on.
447
+ //
448
+ // Until 2026-09-24 it was submitted, the registry deciding: it took a version it
449
+ // already held with this very code (a resume), and refused anything else. Only
450
+ // the latest version's digest is readable, and the digest folds in the version,
451
+ // so "this code, under an older version" cannot be told apart from here.
452
+ if (order === -1) {
453
+ if (bump) {
454
+ say.warn(
455
+ `The registry holds ${reg.latest_version}, newer than your ${label} — releasing your code above it (\`--bump\`). If ${reg.latest_version} is someone else's release, its changes are not in yours.`
456
+ )
457
+ return releaseAsNext(`above the registered ${reg.latest_version}`)
458
+ }
459
+ say.err(
460
+ `The registry holds ${kind} ${scopedName || kind} ${reg.latest_version}, newer than your ${local.version} — a release this copy does not have.`
461
+ )
462
+ say.dim(
463
+ `Nothing was sent. Pull the change that released it, then re-run \`uniweb ${verb}\`; or:`
464
+ )
465
+ if (bumpTo) {
466
+ say.dim(` • \`uniweb ${verb} --bump\` — releases your code as ${bumpTo}, above it`)
467
+ }
468
+ say.dim(
469
+ ` • \`uniweb ${verb} --no-release\` — sends content bound to the released ${reg.latest_version}`
470
+ )
471
+ return { released: false, proceed: false, refused: true, ref: null }
472
+ }
473
+
474
+ // Newer than anything registered — a new version, released as it stands. Not
475
+ // SemVer (`order === null`) — nothing to compare, so the same, and the registry
476
+ // decides; `register` prints its answer if it is a no.
357
477
  say.info(
358
- `Keeping the released ${kind} ${reg.latest_version} — local ${local.version} not released (\`--no-release\`).`
478
+ order === 1
479
+ ? `Releasing the ${kind} ${label} (new version; registered latest is ${reg.latest_version})…`
480
+ : `Releasing the ${kind} ${label} (registered latest is ${reg.latest_version})…`
359
481
  )
360
- return { released: false, proceed: true, ref: registeredRef(reg) }
361
- }
362
- // ⚖️ The registry takes a NEW version only when it is greater than every one
363
- // it holds — but an older local version may be one it already has, and
364
- // re-registering that resumes. The CLI cannot tell which from here, so it
365
- // does not refuse: it submits, says what will decide, and `register` prints
366
- // the registry's answer if it is a no.
367
- const order = compareSemverPrecedence(local.version, reg.latest_version)
368
- say.info(
369
- order === 1
370
- ? `Releasing the ${kind} ${label} (new version; registered latest is ${reg.latest_version})…`
371
- : order === null
372
- ? `Releasing the ${kind} ${label} (registered latest is ${reg.latest_version})…`
373
- : `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…`
374
- )
375
- return {
376
- released: releaseFoundation(local, args, cliBin, say, client?.origin),
377
- proceed: true,
378
- ref: await pinnedRef()
482
+ return {
483
+ released: releaseFoundation(local, args, cliBin, say, client?.origin),
484
+ proceed: true,
485
+ ref: await pinnedRef()
486
+ }
379
487
  }
380
488
  }
381
489
 
382
- // Same version, but the digest differs or the backend can't confirm it.
490
+ // The registered version — but the digest differs, or the backend can't confirm it.
383
491
  if (!reg.digest) {
384
492
  // Degrade: the backend doesn't return the stored digest yet, so we can't
385
493
  // be sure the registered version matches local. Offer to re-deliver.
@@ -393,8 +501,10 @@ async function bringLocalCodeAlong({
393
501
  `Can't verify the registered ${label} matches your local copy (backend returned no digest).`
394
502
  )
395
503
  if (skipPrompts || isNonInteractive(args)) {
504
+ // ⚖️ Not released: nothing says the code changed, and releasing on no evidence
505
+ // would mint a version on every push against such a backend.
396
506
  say.dim(
397
- 'Proceeding without re-releasing — pass nothing to re-deliver, or bump the version to release a change.'
507
+ `Proceeding without re-releasing — to release a change, set a higher version in the ${kind}'s package.json.`
398
508
  )
399
509
  return { released: false, proceed: true, ref: await pinnedRef() }
400
510
  }
@@ -411,10 +521,8 @@ async function bringLocalCodeAlong({
411
521
  return { released: false, proceed: true, ref: await pinnedRef() }
412
522
  }
413
523
 
414
- // Case 3 (§4): the code was edited but the version wasn't bumped. The
415
- // registered version is immutable, so we never silently ship the old code —
416
- // the deliberate release gate is a version bump (§3.1).
417
- // Asked for explicitly: the case this flag exists to name.
524
+ // The code changed and the version did not. A registered version is immutable, so
525
+ // the change is released under the next one.
418
526
  if (noRelease) {
419
527
  say.info(
420
528
  `Keeping the released ${kind} ${reg.latest_version} — your local changes are not released (\`--no-release\`).`
@@ -422,67 +530,17 @@ async function bringLocalCodeAlong({
422
530
  return { released: false, proceed: true, ref: registeredRef(reg) }
423
531
  }
424
532
 
425
- // ⭐ THREE OUTCOMES, NOT TWO — `--yes` and "no TTY" are not the same answer.
533
+ // ⭐ RELEASED BY DEFAULT — for a person and an agent alike, with no question.
426
534
  //
427
- // They were one condition until 2026-08-19, and conflating them meant the only
428
- // party that never got asked was the one that most needed asking. A `--yes` is a
429
- // decision someone made in advance; an absent TTY is the ABSENCE of a decision,
430
- // and absence is not consent.
431
- //
432
- // ⚖️ Which flips the safe default by caller, for a reason particular to this
433
- // situation: proceeding here ships a site bound to code that is NOT the code in
434
- // the working tree. A human is at a terminal, reads the warning, and decides. An
435
- // agent re-runs commands for free and reports "pushed" on exit 0 — so for it the
436
- // cheap outcome is one more cycle and the expensive one is a silent wrong success
437
- // it will not look at again. Refusing costs an agent a retry; proceeding costs it
438
- // a false completion report and a live site nobody notices is stale.
439
-
440
- // 1. Explicit consent — proceed, but at WARN. `dim` is what we print for things
441
- // nobody needs to read, and "your changes are not live" is not that.
442
- if (skipPrompts) {
443
- say.warn(
444
- `Local ${label} differs from the registered ${reg.latest_version} and the version wasn't bumped — ` +
445
- `shipping against the registered code. Your local changes will NOT be live.`
446
- )
447
- return { released: false, proceed: true, ref: await pinnedRef() }
448
- }
449
-
450
- // 2. Nobody to ask. Refuse, and name both real options as runnable commands so
451
- // the caller's next step needs no interpretation. `refused` marks this as a
452
- // hard stop rather than a human's "no", which callers map to different exits.
453
- if (isNonInteractive(args)) {
454
- say.err(
455
- `Local ${kind} ${label} differs from the registered ${reg.latest_version}, and the version wasn't bumped.`
456
- )
457
- say.dim(`Nothing was sent. Either release the change, or ship without it:`)
458
- say.dim(` • bump the ${kind}'s version in package.json, then re-run \`uniweb ${verb}\``)
459
- // Teach the flag that NAMES this, not `--yes`. Both work, but `--yes` means "do
460
- // not ask me" and only does this as a side effect — pointing a stuck user at it
461
- // teaches a blunt instrument for a precise job.
462
- say.dim(
463
- ` • \`uniweb ${verb} --no-release\` — sends content bound to the released ${reg.latest_version}`
464
- )
465
- return { released: false, proceed: false, refused: true, ref: null }
466
- }
467
-
468
- // 3. A human is here. State it, then ask.
469
- say.warn(
470
- `Your local ${label} differs from the registered version ${reg.latest_version}, but the version wasn't bumped.`
471
- )
472
- say.dim(
473
- `A registered version is immutable — bump the ${kind}'s version to release the change.`
474
- )
475
- const proceed = await confirm(
476
- `Continue with the already-registered ${reg.latest_version} anyway?`,
477
- false
478
- )
479
- if (!proceed) {
480
- say.info(
481
- `Aborted — nothing was sent. Bump the ${kind} version, then re-run \`uniweb ${verb}\`.`
482
- )
483
- return { released: false, proceed: false, ref: null }
484
- }
485
- return { released: false, proceed: true, ref: await pinnedRef() }
535
+ // Until 2026-09-24 this stopped: a terminal was asked, a run without one refused, and
536
+ // `--yes` shipped the REGISTERED code. That guarded against a site bound to code that
537
+ // was not the working tree's — which is exactly what releasing prevents: the site is
538
+ // bound to the code just released, as it is to the content pushed with it. `--yes`
539
+ // has nothing left to consent to here, and a question whose answer is nearly always
540
+ // yes teaches people to stop reading it.
541
+ return releaseAsNext(`its code changed since ${reg.latest_version} was registered`, {
542
+ optOut: true
543
+ })
486
544
  }
487
545
 
488
546
  // Build the foundation so its dist/ can be fingerprinted. Idempotent — the