uniweb 1.21.0 → 1.21.2

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": "1.21.0",
3
+ "version": "1.21.2",
4
4
  "description": "Create structured Vite + React sites with content/code separation",
5
5
  "type": "module",
6
6
  "bin": {
@@ -42,12 +42,12 @@
42
42
  "prompts": "^2.4.2",
43
43
  "tar": "^7.5.21",
44
44
  "@uniweb/content-writer": "~1.3.1",
45
- "@uniweb/core": "~1.4.7",
46
45
  "@uniweb/schemas": "~1.6.5",
46
+ "@uniweb/core": "~1.4.7",
47
47
  "@uniweb/semantic-parser": "~1.13.0"
48
48
  },
49
49
  "peerDependencies": {
50
- "@uniweb/build": "~1.10.2",
50
+ "@uniweb/build": "~1.10.4",
51
51
  "@uniweb/content-reader": "~1.5.7",
52
52
  "@uniweb/semantic-parser": "~1.13.0"
53
53
  },
@@ -44,7 +44,7 @@ uniweb doctor # diagnose project configuration (--fix to repair)
44
44
 
45
45
  `uniweb inspect` is the important one for content. Every parse rule in Part 3 is faster to *confirm* than to re-derive, and re-deriving from memory is where agents go wrong. Don't predict the shape — print it.
46
46
 
47
- `uniweb status` is the one for a project other people edit. A site in the Uniweb app can change there at any time, teammates push to git, and Uniweb releases often. Run it when you begin, before you push, and after a command refuses: it changes nothing, and ends with the commands to run, in order (`--json` for a script).
47
+ `uniweb status` is the one for a project other people edit. A site in the Uniweb app can change there at any time, teammates push to git, and Uniweb releases often. Run it when you begin, when you come back to the project, before you push, publish or deploy, and after a command refuses: it changes nothing, opens with what the site is — synced where, live where — and ends with the commands to run, in order (`--json` for a script).
48
48
 
49
49
  ### The four silent failures
50
50
 
@@ -192,7 +192,7 @@ The rest of this guide explains how Uniweb works. This part is what to do *first
192
192
 
193
193
  ### 1. Orient — what kind of workspace is this?
194
194
 
195
- **Run `uniweb status` first.** It says whether the site changed in the app since this copy last synced, whether git or Uniweb has something newer, and what to run about each — before you edit something someone else just changed.
195
+ **Run `uniweb status` first.** It says where the site sits and what it is made of — the workspace's sites and foundations, the foundation this site is built with and which other sites a change to it reaches, pages it takes from outside its folder (another repository, sometimes), the languages it is written in — then whether it changed in the app since this copy last synced, whether git or Uniweb has something newer, and what to run about each, before you edit something someone else just changed. Run at the root of a workspace with several sites, it says what each one is. The rest of this step is how to read the same wiring by hand.
196
196
 
197
197
  **There is no single layout. Read `pnpm-workspace.yaml` first** — its `packages:` globs name the shape in one glance:
198
198
 
@@ -2864,6 +2864,14 @@ uniweb <command> --help # Per-command flags — no side effects. Prefe
2864
2864
 
2865
2865
  Foundations have their own free path too: `uniweb add ci --target foundation` publishes to permanent versioned URLs on GitHub Pages.
2866
2866
 
2867
+ **Three facts about a site, and they are independent — `uniweb status` opens with all three:**
2868
+
2869
+ - **Synced** — the project has a site on a backend, so authors can edit it in the Uniweb app. `sync.json` names it on each backend: `https://uniweb.app` by default, or a server of your own (`uniweb login --server <url>`).
2870
+ - **Published** — that backend serves it live: Uniweb Cloud, on `https://uniweb.app`. A published site is a synced site; a synced site need not be published — it can sit in the app as a draft.
2871
+ - **Deployed** — a build of it is on another host, such as GitHub Pages or S3: by CI on every push (`uniweb add ci`), or by hand (`uniweb deploy`). `deploy.yml` lists those hosts, and a bare `uniweb deploy` goes to its default target.
2872
+
2873
+ A site can be in more than one place — usually while it moves from one host to another. Before you ship, read where it is: `publish` makes a change live on Uniweb Cloud, `git push` on a CI-deployed host, and `uniweb deploy --target <name>` on one deployed by hand — and each of these is the person's to decide. `uniweb status` also lists the services the live site has (`services:` in `site.yml` is the request, never proof of what the site has).
2874
+
2867
2875
  ### Uniweb Cloud and the two-sided workflow
2868
2876
 
2869
2877
  `uniweb publish` ships to Uniweb Cloud, and it's worth understanding what that buys, because it isn't only hosting — it's how a team of technical and non-technical people works on one site.
@@ -2902,7 +2910,7 @@ Foundations have their own free path too: `uniweb add ci --target foundation` pu
2902
2910
  - **Git is the reviewed, durable record** — you `pull` content back, read it with `git diff`, and commit.
2903
2911
  - **Authors never push or pull.** For them, content simply updates, whether the change came from another author or from a developer's CLI.
2904
2912
 
2905
- **Working on a site others edit — run `uniweb status` when you begin, before you push, and after any refusal.** It reads the backend without taking anything: what changed there since this copy last synced, file by file; what you hold that it lacks; whether the site is live; who has it open in the app right now, and on which file. Then it lists the commands to run, in an order that works from the top — `uniweb pull` before the push it would otherwise refuse. Three habits go with it: take what changed (`pull`, or `refresh`) before you edit; never `--force` past a refusal; and when someone has a page open in the app, tell the person before you change that page. A step marked *ask the person first* — going live, which may cost money — is theirs to decide.
2913
+ **Working on a site others edit — run `uniweb status` when you begin, when you come back to it, before you push, publish or deploy, and after any refusal.** It reads the backend without taking anything: what changed there since this copy last synced, file by file — as `uniweb pull` would write it, translations and records included; what you hold that it lacks; where the site is live, and what services it has; who has it open in the app right now, and on which file. Then it lists the commands to run, in an order that works from the top — `uniweb pull` before the push it would otherwise refuse. Three habits go with it: take what changed (`pull`, or `refresh`) before you edit; never `--force` past a refusal; and when someone has a page open in the app, tell the person before you change that page. A step marked *ask the person first* — going live, which may cost money — is theirs to decide.
2906
2914
 
2907
2915
  **Nobody's work is overwritten without asking.** `uniweb push` is refused if the site changed on the backend since your last pull — usually an author editing in the app — and reports what changed; nothing is written. Edits to different sections never collide. **In a git repository `uniweb pull` merges, like `git pull`:** it commits the backend's content (*"The site's content on …"*) and merges that commit into your branch, against the last state of the site your history shares with the backend. Your work, committed or not, and a teammate's arriving through git are kept; a file the backend never had is never deleted; changes to different parts of a file combine silently; and only a genuine overlap leaves conflict markers and a non-zero exit — so `uniweb pull && uniweb push` never ships markers. Settle a conflict as in any merge, then `git commit`; where your uncommitted changes overlap what the pull brought, the markers are theirs — settle them and `git add` each file, then `git stash drop` (git keeps them in the stash too). A push or publish is refused while a file under the site is in conflict. **In a git repository a push sends what you have committed:** with the site's content not committed it is refused before anything is sent, naming the files — commit them, or run `uniweb push --commit -m "…"` (it commits the site's content only, and leaves anything else staged as it is); `publish` and `sync` take `--commit` too. That commit is where the next pull merges from, which is how the pull tells your changes from the backend's. What the push writes back into the files — the ids the backend assigned — it commits itself, as `uniweb`. When the backend has nothing new, a pull says so and lists the files where yours hold changes it lacks. `uniweb refresh` runs `git pull` first, then the same pull. A push is also refused when your branch does not hold what this copy last took from the backend — after `git merge --abort`, or in a fresh clone that has not pulled — so pull, then push. Outside git — which includes a folder the repository around it ignores, such as a scratch directory — `uniweb pull` takes the backend's version over your files, asking first. `--force` is the deliberate overwrite (on `push` it replaces the backend's changes, on `pull` it discards yours); don't use it to get past a refusal. `uniweb sync` is `refresh` then `push`; neither publishes.
2908
2916
 
@@ -1,6 +1,12 @@
1
1
  /**
2
2
  * What changed on the backend since this copy last synced — READ, never taken.
3
3
  *
4
+ * ⚠️ THE SECOND ANSWER. In a repository `uniweb status` asks the pull itself
5
+ * (`commands/pull.js::probePull`), which names every file a pull would change. This answers
6
+ * where that cannot — outside git, or with no sync point to merge from — by units and
7
+ * records, not files: a section's translations, which a pull writes to `locales/*.json`,
8
+ * travel inside the section here (measured 2026-10-26: 8 units named, 11 files pulled).
9
+ *
4
10
  * `uniweb pull` takes the backend's content; `uniweb status` asks what there is to take.
5
11
  * The two must agree on what "changed" means, so this reads the backend as a pull does —
6
12
  * the same two lanes, the same conditional GET (`etagsHold`) — and compares the answer
@@ -164,27 +164,43 @@ const names = (list) => list.map((n) => `\`${n}\``).join(', ')
164
164
  * @param {string[]|null} [p.supports] - from `foundationSupports`; null = unknown
165
165
  */
166
166
  export function announceServices({ siteYml, say, supports = null }) {
167
+ for (const note of serviceNotes({ siteYml, supports, warn: (m) => say.warn(m) })) say.warn(note)
168
+ }
169
+
170
+ /**
171
+ * Where `site.yml::services` and the foundation disagree — the notes `announceServices`
172
+ * says, as lines, for a caller that reports them (`uniweb status`).
173
+ *
174
+ * @param {object} p
175
+ * @param {object} p.siteYml - parsed
176
+ * @param {string[]|null} [p.supports] - from `foundationSupports`; null = unknown
177
+ * @param {(m: string) => void} [p.warn] - told what the file asks that will not be sent as written
178
+ * @returns {string[]}
179
+ */
180
+ export function serviceNotes({ siteYml, supports = null, warn = () => {} }) {
167
181
  // An entry the push cannot read stops the package build next, which says what to write
168
182
  // (`refuseUnreadableServices`). Nothing to add before it — and what this would say, read
169
183
  // past that entry, is wrong: `search: yes` was "site.yml turns on `search`" (F14).
170
- if (unreadableServices(siteYml?.services).length) return
171
- const asks = readServicesRequest(siteYml?.services, { warn: (m) => say.warn(m) }) || []
172
- if (!Array.isArray(supports)) return
184
+ if (unreadableServices(siteYml?.services).length) return []
185
+ const asks = readServicesRequest(siteYml?.services, { warn }) || []
186
+ if (!Array.isArray(supports)) return []
187
+ const notes = []
173
188
  const ignored = new Set(['tracking', 'records'])
174
189
  const has = (ask) => ask.enabled !== false || typeof ask.config?.endpoint === 'string'
175
190
  const unrendered = asks.filter((a) => has(a) && !ignored.has(a.name) && !supports.includes(a.name)).map((a) => a.name)
176
191
  if (unrendered.length) {
177
- say.warn(`site.yml turns on ${names(unrendered)}, which your foundation does not say it renders (\`uniweb.supports\`).`)
192
+ notes.push(`site.yml turns on ${names(unrendered)}, which your foundation does not say it renders (\`uniweb.supports\`).`)
178
193
  }
179
194
  const mentioned = new Set(asks.map((a) => a.name))
180
195
  const unasked = supports.filter((n) => !ignored.has(n) && !mentioned.has(n))
181
196
  if (unasked.length) {
182
- say.warn(
197
+ notes.push(
183
198
  `Your foundation renders ${names(unasked)}, which site.yml does not ask for — a service is off ` +
184
199
  `unless the site asks for it. Add ${unasked.length === 1 ? 'it' : 'them'} under \`services:\`, or set ` +
185
200
  `${unasked.length === 1 ? 'it' : 'each'} to \`false\` if that is what you mean.`
186
201
  )
187
202
  }
203
+ return notes
188
204
  }
189
205
 
190
206
  /**
@@ -1993,6 +1993,54 @@ async function runDoctor(args = []) {
1993
1993
  }
1994
1994
  }
1995
1995
 
1996
+ // ⛔ The keys retired on 2026-09-20 read as nothing, without a word: a deploy recorded
1997
+ // under `lastDeploy:` is one `uniweb status` cannot see, and `autoSave: off` is ignored,
1998
+ // so deploys are recorded after all. A renamed key returns empty; it does not fail.
1999
+ const retired = ['lastDeploy', 'autoSave'].filter((k) => deployYml[k] !== undefined)
2000
+ if (retired.length) {
2001
+ const id = 'deploy-yml-retired-key'
2002
+ const issue = {
2003
+ id,
2004
+ type: 'warn',
2005
+ site: siteName,
2006
+ message: `deploy.yml uses ${retired.map((k) => `\`${k}:\``).join(' and ')}, which the CLI no longer reads`
2007
+ }
2008
+ issues.push(issue)
2009
+ warn(`[${id}] ${siteName}: deploy.yml uses ${retired.map((k) => `\`${k}:\``).join(' and ')}, which the CLI no longer reads.`)
2010
+ const lost = []
2011
+ if (deployYml.lastDeploy && typeof deployYml.lastDeploy === 'object') {
2012
+ const names = Object.keys(deployYml.lastDeploy).filter((n) => !deployYml.deploys?.[n])
2013
+ if (names.length) lost.push(`the last deploy of ${names.map((n) => `\`${n}\``).join(', ')} is recorded only there, where nothing sees it`)
2014
+ }
2015
+ if (String(deployYml.autoSave) === 'off') lost.push('`autoSave: off` is ignored, so deploys are recorded')
2016
+ const renamed = { lastDeploy: 'deploys', autoSave: 'saveDeploys' }
2017
+ log(
2018
+ ` ${colors.dim}${retired.map((k) => `\`${k}:\` is \`${renamed[k]}:\``).join(' and ')} now${lost.length ? ` — ${lost.join('; ')}` : ''}.${colors.reset}`
2019
+ )
2020
+ if (shouldFix(id)) {
2021
+ try {
2022
+ const { retireDeployYmlKeys } = await import('@uniweb/build/site')
2023
+ const done = await retireDeployYmlKeys(sitePath)
2024
+ if (done) {
2025
+ issue.fixed = true
2026
+ fixed(
2027
+ `${relative(workspaceDir, deployYmlPath)}: ${[
2028
+ done.moved.length && `moved ${done.moved.join(', ')} to \`deploys:\``,
2029
+ done.saveDeploys === false && 'wrote `saveDeploys: false`',
2030
+ 'removed the old keys'
2031
+ ]
2032
+ .filter(Boolean)
2033
+ .join(', ')}`
2034
+ )
2035
+ }
2036
+ } catch (err) {
2037
+ warn(`[${id}] could not rewrite ${relative(workspaceDir, deployYmlPath)}: ${err.message}`)
2038
+ }
2039
+ } else {
2040
+ log(` ${colors.dim}Run \`uniweb doctor --fix ${id}\` to move them, keeping your comments.${colors.reset}`)
2041
+ }
2042
+ }
2043
+
1996
2044
  // github-pages-specific: if a target has `domain`, `site/public/CNAME`
1997
2045
  // should exist with that value. Vite copies public/ into dist/, and
1998
2046
  // GH Pages reads dist/CNAME on each deploy to keep the custom domain
@@ -150,10 +150,12 @@ const colors = {
150
150
  green: '\x1b[32m',
151
151
  blue: '\x1b[36m'
152
152
  }
153
- // Looked up at each call, so a caller that captures the console captures these too.
154
- const log = (...a) => console.log(...a)
153
+ // Looked up at each call, so a caller that captures the console captures these too — and
154
+ // silent while a probe runs (`probePull`), which answers by what it returns.
155
+ let quiet = false
156
+ const log = (...a) => quiet || console.log(...a)
155
157
  const success = (m) => log(`${colors.green}✓${colors.reset} ${m}`)
156
- const error = (m) => console.error(`${colors.red}✗${colors.reset} ${m}`)
158
+ const error = (m) => quiet || console.error(`${colors.red}✗${colors.reset} ${m}`)
157
159
  const info = (m) => log(`${colors.blue}→${colors.reset} ${m}`)
158
160
  const note = (m) => log(` ${colors.dim}${m}${colors.reset}`)
159
161
 
@@ -568,6 +570,34 @@ const pulledFrom = (origin) => `The site's content on ${origin}`
568
570
  const mergedFrom = (origin) => `Merge the site's content from ${origin}`
569
571
  const takenFrom = (origin) => `Take the site's content from ${origin}`
570
572
 
573
+ /**
574
+ * What a pull would change in the files, and nothing more — for `uniweb status`.
575
+ *
576
+ * ⭐ THE PULL ITSELF, STOPPED BEFORE IT TAKES ANYTHING. The same requests, the same base,
577
+ * the same projection onto it in a stand-in — so status and pull cannot disagree about
578
+ * which files the backend changed: a section's translations (`locales/*.json`) and a
579
+ * record's file are named as the pull would write them. It downloads no media, merges
580
+ * nothing, commits nothing and banks nothing — no ETag, version, hash or sync point —
581
+ * and prints nothing.
582
+ *
583
+ * Null when a probe cannot answer: outside git, where a pull writes into the files; in a
584
+ * repository with no commit or no sync point to merge from; when a lane failed or the
585
+ * workspace was refused. The caller then says what it can by other means.
586
+ *
587
+ * @param {string[]} [args] - the workspace flags only (`--org`, `--personal`)
588
+ * @param {object} [deps] - as for `pull`; `getToken` keeps it from ever asking for a login
589
+ * @returns {Promise<{ files: Array<[string, string]>, recordsNotPlaced: boolean } | null>}
590
+ * `files` — `[status, path]`, status `A`, `M` or `D`, the path relative to the site
591
+ */
592
+ export async function probePull(args = [], deps = {}) {
593
+ quiet = true
594
+ try {
595
+ return (await pull(args, { ...deps, probe: true }))?.probe ?? null
596
+ } finally {
597
+ quiet = false
598
+ }
599
+ }
600
+
571
601
  export async function pull(args = [], deps = {}) {
572
602
  // See utils/flag-guard.js — an unrecognized flag is invisible to a literal scan, so
573
603
  // it silently keeps the default: a mistyped `--dry-run` would pull for real, and a
@@ -587,13 +617,20 @@ export async function pull(args = [], deps = {}) {
587
617
  const force = args.includes('--force')
588
618
 
589
619
  const siteDir = await resolveSiteDir(args, 'pull')
590
- const fetchAssets = shouldFetchAssets(args, siteDir)
620
+ // ⛔ A probe (`probePull`) fetches no media: it says what changes, and takes nothing.
621
+ const probe = Boolean(deps.probe)
622
+ const fetchAssets = !probe && shouldFetchAssets(args, siteDir)
591
623
 
592
624
  // ⭐ IN A REPOSITORY A PULL MERGES; OUTSIDE ONE IT CHECKS OUT. A merge needs the state both
593
625
  // sides started from, and only a history holds one (`utils/sync-point.js`). Outside git
594
626
  // a pull rewrites the files, so work there that a pull did not write stops it.
595
627
  const repo = repoOf(siteDir)
596
- if (repo && !dryRun) {
628
+ // A probe writes onto a commit in a stand-in, never into the files: with no repository,
629
+ // or no commit to write onto, it has nothing to say.
630
+ if (probe && !repo?.head) return { exitCode: 0, probe: null }
631
+ // …and touching nothing here, it is not stopped by what a merge would be: a conflict or a
632
+ // merge waiting is the caller's to report.
633
+ if (repo && !dryRun && !probe) {
597
634
  if (!repo.head) {
598
635
  error('This repository has no commits yet, so there is nothing for a pull to merge into.')
599
636
  note('Commit the project, then pull.')
@@ -631,7 +668,9 @@ export async function pull(args = [], deps = {}) {
631
668
  command: 'Pulling'
632
669
  })
633
670
  // One reader of Models for the whole pull, so a Model is read once whichever lane asks first.
634
- const readModel = makeModelResolver({ client, siteDir })
671
+ // ⛔ A probe keeps none: a Model kept here is what a later offline emit resolves with, and a
672
+ // probe takes nothing (`probePull`).
673
+ const readModel = makeModelResolver({ client, siteDir: probe ? null : siteDir })
635
674
 
636
675
  // ⭐ No scope check any more — and none is needed. This project's identity on
637
676
  // `client.origin` is read from that origin's own section of sync.json, so a
@@ -703,6 +742,9 @@ export async function pull(args = [], deps = {}) {
703
742
  // body). `doRequest` is a thunk returning the client's Response promise. 404 / any
704
743
  // failure → null (the lane is skipped, not fatal) — except a workspace mismatch,
705
744
  // `{ refused: true }`, which stops the pull: the other lane would only say it again.
745
+ // A lane that answered 404 is named in `missing`, so a probe can tell a site with no
746
+ // records lane from a lane that did not answer.
747
+ const missing = new Set()
706
748
  const getDocs = async (label, doRequest) => {
707
749
  info(
708
750
  `Pulling ${colors.bright}${label}${colors.reset} from ${colors.dim}${client.origin}${colors.reset} …`
@@ -718,6 +760,7 @@ export async function pull(args = [], deps = {}) {
718
760
  }
719
761
  if (res.status === 404) {
720
762
  note(`${label}: not found (404) — it was deleted, or you lack access.`)
763
+ missing.add(label)
721
764
  return null
722
765
  }
723
766
  if (res.status === 304) {
@@ -820,6 +863,12 @@ export async function pull(args = [], deps = {}) {
820
863
  ? splitRecordsPull(folderLane.docs)
821
864
  : null
822
865
  const projected = Boolean(siteDoc) || Boolean(folderTaken)
866
+ if (probe) {
867
+ // A lane that did not answer is not knowing — a records lane the backend has none of
868
+ // (404) is no records; both unchanged is nothing to take.
869
+ if (!content || (!noRecords && !folderLane && !missing.has('records'))) return { exitCode: 1, probe: null }
870
+ if (!projected) return { exitCode: 0, probe: { files: [], recordsNotPlaced: false } }
871
+ }
823
872
 
824
873
  // ── What the project itself says, read before anything is written ──────────
825
874
  // The foundation, the Models and the scope come from the project as it is: in a
@@ -950,6 +999,9 @@ export async function pull(args = [], deps = {}) {
950
999
  origin: client.origin,
951
1000
  siteUuid: siteContentUuid
952
1001
  })
1002
+ // A probe with no base has no way to tell the backend's changes from this copy's: onto
1003
+ // HEAD, every difference would read as the backend's.
1004
+ if (probe && !base) return { exitCode: 0, probe: null }
953
1005
  onto = force || !base ? repo.head : base
954
1006
  try {
955
1007
  standIn = openStandIn(repo, { roots, rev: onto })
@@ -1071,7 +1123,7 @@ export async function pull(args = [], deps = {}) {
1071
1123
  // ⭐ A FILE RECORD'S BYTES (`@uniweb/file`, 2026-09-28): fetched to where `folder.yml` now
1072
1124
  // places each one, as media are fetched. A file that did not arrive is a record not taken —
1073
1125
  // reported, and the pull fails below as it does for any, so the next one fetches it again.
1074
- if (report.fileDownloads?.length) {
1126
+ if (report.fileDownloads?.length && !probe) {
1075
1127
  const got = await downloadFileRecords({
1076
1128
  downloads: report.fileDownloads,
1077
1129
  siteDir: projDir,
@@ -1106,6 +1158,12 @@ export async function pull(args = [], deps = {}) {
1106
1158
  for (const w of report.warnings) note(`! ${w}`)
1107
1159
  }
1108
1160
 
1161
+ // ⭐ A PROBE STOPS HERE, the stand-in holding what this pull would take: what differs
1162
+ // from the base is what the backend changed. Nothing is banked; `finally` closes it.
1163
+ if (probe) {
1164
+ return { exitCode: 0, probe: { files: standIn.changes(), recordsNotPlaced: Boolean(recordsNotPlaced) } }
1165
+ }
1166
+
1109
1167
  // Outside git: what this pull wrote, so the next one can tell its own output from the
1110
1168
  // author's work. Config files are rewritten every pull whether or not they changed;
1111
1169
  // include the ones the projector owns so a pull-then-pull doesn't trip on them either.