uniweb 1.21.5 → 1.21.6

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.5",
3
+ "version": "1.21.6",
4
4
  "description": "Create structured Vite + React sites with content/code separation",
5
5
  "type": "module",
6
6
  "bin": {
@@ -41,13 +41,13 @@
41
41
  "js-yaml": "^4.3.2",
42
42
  "prompts": "^2.4.2",
43
43
  "tar": "^7.5.21",
44
- "@uniweb/content-writer": "~1.3.1",
44
+ "@uniweb/core": "~1.4.8",
45
45
  "@uniweb/schemas": "~1.6.5",
46
46
  "@uniweb/semantic-parser": "~1.13.0",
47
- "@uniweb/core": "~1.4.7"
47
+ "@uniweb/content-writer": "~1.3.1"
48
48
  },
49
49
  "peerDependencies": {
50
- "@uniweb/build": "~1.10.5",
50
+ "@uniweb/build": "~1.10.6",
51
51
  "@uniweb/semantic-parser": "~1.13.0",
52
52
  "@uniweb/content-reader": "~1.5.7"
53
53
  },
@@ -67,7 +67,7 @@ https://uniweb.io/llms.txt
67
67
 
68
68
  It lists every documentation page with a one-line description and a fetchable link. Go there whenever your question isn't answered in this file — and *especially* when you don't know whether a feature exists at all. That's where guessing does the most damage: **a 404 on a path you invented tells you your guess was wrong, not that the feature is missing.** Concluding "the framework doesn't support X" from a failed fetch is a mistake worth actively guarding against; the index answers the existence question directly.
69
69
 
70
- Then **follow the link the index gives you** rather than constructing one yourself. The index is the source of truth for where pages live, so it stays correct when the site's URL conventions change. Each link it gives is the page as Markdown.
70
+ Then **follow the link the index gives you** rather than constructing one yourself. The index is the source of truth for where pages live, so it stays correct when the site's URL conventions change. Each link it gives is the page as Markdown, and a page's own address returns the same Markdown to a request sent with `Accept: text/markdown`.
71
71
 
72
72
  **Clone the repo instead** when you expect several lookups, or may lose network access later:
73
73
 
@@ -2862,7 +2862,7 @@ uniweb <command> --help # Per-command flags — no side effects. Prefe
2862
2862
  | Any static host at all | `uniweb export` | full control; no Uniweb account needed |
2863
2863
  | Uniweb Cloud | `uniweb publish` | teams with non-technical content authors, or client work |
2864
2864
 
2865
- `uniweb deploy` never assumes a host: with nothing configured it opens a picker listing only destinations it can act on, and records the choice in `deploy.yml` so later runs go straight there. On Cloudflare Pages / Netlify / Vercel, `add ci` also adds per-PR previews that comment the URL. Adapters: `github-pages`, `cloudflare-pages`, `netlify`, `vercel`, plus `s3-cloudfront` for `deploy`. That record is `deploy.yml`, beside `site.yml` — **the CLI writes it, you don't create it**: the first successful deploy scaffolds the whole file, and later deploys rewrite only its `deploys:` block. Edit `targets:` when you want to change where a site ships. Host credentials come from the environment, never from that committed file.
2865
+ `uniweb deploy` never assumes a host: with nothing configured it opens a picker listing only destinations it can act on, and records the choice in `deploy.yml` so later runs go straight there. On Cloudflare Pages / Netlify / Vercel, `add ci` also adds per-PR previews that comment the URL. GitHub runs workflows only from the top of the repository: in a project that is a folder of a larger repository, the project's own `.github/` never runs, and `uniweb status` says so — as it says when a target in `deploy.yml` was never deployed from this copy. Adapters: `github-pages`, `cloudflare-pages`, `netlify`, `vercel`, plus `s3-cloudfront` for `deploy`. That record is `deploy.yml`, beside `site.yml` — **the CLI writes it, you don't create it**: the first successful deploy scaffolds the whole file, and later deploys rewrite only its `deploys:` block. Edit `targets:` when you want to change where a site ships. Host credentials come from the environment, never from that committed file.
2866
2866
 
2867
2867
  Foundations have their own free path too: `uniweb add ci --target foundation` publishes to permanent versioned URLs on GitHub Pages.
2868
2868
 
@@ -1928,6 +1928,38 @@ async function runDoctor(args = []) {
1928
1928
  try {
1929
1929
  deployYml = await loadDeployYml(sitePath)
1930
1930
  } catch (err) {
1931
+ // `saveDeploys:` holding a value of the retired `autoSave:` — the key renamed by hand, its
1932
+ // value kept (measured 2026-10-26 on a copied project) — is the retired key's to repair.
1933
+ let kept = null
1934
+ try {
1935
+ const v = yaml.load(readFileSync(deployYmlPath, 'utf8'))?.saveDeploys
1936
+ kept = v === 'lastDeploy' || v === 'off' ? v : null
1937
+ } catch {
1938
+ kept = null
1939
+ }
1940
+ if (kept) {
1941
+ const id = 'deploy-yml-retired-key'
1942
+ const issue = { id, type: 'error', site: siteName, message: `deploy.yml has \`saveDeploys: ${kept}\`, a value of the retired \`autoSave:\`` }
1943
+ issues.push(issue)
1944
+ error(`[${id}] ${siteName}: deploy.yml has \`saveDeploys: ${kept}\` — a value of the retired \`autoSave:\`, where \`saveDeploys\` is true or false.`)
1945
+ log(
1946
+ ` ${colors.dim}${kept === 'off' ? '`autoSave: off` is `saveDeploys: false`.' : '`autoSave: lastDeploy` is what happens anyway — deploys are recorded — so the line goes.'}${colors.reset}`
1947
+ )
1948
+ if (shouldFix(id)) {
1949
+ try {
1950
+ const { retireDeployYmlKeys } = await import('@uniweb/build/site')
1951
+ if (await retireDeployYmlKeys(sitePath)) {
1952
+ issue.fixed = true
1953
+ fixed(`${relative(workspaceDir, deployYmlPath)}: ${kept === 'off' ? 'wrote `saveDeploys: false`' : 'removed `saveDeploys: lastDeploy`'}`)
1954
+ }
1955
+ } catch (fixErr) {
1956
+ warn(`[${id}] could not rewrite ${relative(workspaceDir, deployYmlPath)}: ${fixErr.message}`)
1957
+ }
1958
+ } else {
1959
+ log(` ${colors.dim}Run \`uniweb doctor --fix ${id}\` to write what it meant, keeping your comments.${colors.reset}`)
1960
+ }
1961
+ continue
1962
+ }
1931
1963
  issues.push({
1932
1964
  id: 'deploy-yml-malformed',
1933
1965
  type: 'error',
@@ -2013,10 +2045,13 @@ async function runDoctor(args = []) {
2013
2045
  if (names.length) lost.push(`the last deploy of ${names.map((n) => `\`${n}\``).join(', ')} is recorded only there, where nothing sees it`)
2014
2046
  }
2015
2047
  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
- )
2048
+ // What each one means now — said by value, not as a rename: "`autoSave:` is `saveDeploys:`"
2049
+ // read as one, and a copy came back holding `saveDeploys: lastDeploy` (2026-10-26).
2050
+ const means = {
2051
+ lastDeploy: '`lastDeploy:` is `deploys:` now',
2052
+ autoSave: '`autoSave:` goes — `autoSave: off` is `saveDeploys: false`, and `autoSave: lastDeploy` is what happens anyway'
2053
+ }
2054
+ log(` ${colors.dim}${retired.map((k) => means[k]).join('; ')}${lost.length ? ` — ${lost.join('; ')}` : ''}.${colors.reset}`)
2020
2055
  if (shouldFix(id)) {
2021
2056
  try {
2022
2057
  const { retireDeployYmlKeys } = await import('@uniweb/build/site')
@@ -43,7 +43,7 @@
43
43
  */
44
44
 
45
45
  import { execFileSync } from 'node:child_process'
46
- import { existsSync, readFileSync, statSync } from 'node:fs'
46
+ import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs'
47
47
  import { basename, dirname, join, relative, resolve, sep } from 'node:path'
48
48
  import yaml from 'js-yaml'
49
49
 
@@ -712,7 +712,7 @@ function changeChecks(siteDir, changes, unpushedFiles, proposals) {
712
712
  if (!moved) {
713
713
  return [
714
714
  unknown.length
715
- ? { id: 'app.changes', state: 'unknown', summary: `Could not tell what changed on the backend: ${unknown.map((l) => l.detail).join('; ')}.` }
715
+ ? { id: 'app.changes', state: 'unknown', summary: `Could not tell what changed on the backend: ${[...new Set(unknown.map((l) => l.detail))].join('; ')}.` }
716
716
  : { id: 'app.changes', state: 'ok', summary: 'Nothing changed on the backend since this copy last synced.', detail: { source: 'units' } }
717
717
  ]
718
718
  }
@@ -1215,18 +1215,34 @@ async function deployChecks(siteDir) {
1215
1215
  host: t.host,
1216
1216
  title,
1217
1217
  default: name === defaultName,
1218
+ deployed: Boolean(ci || receipt || retired),
1218
1219
  ci: ci ? { file: ci.file, branches: ci.branches } : null,
1219
1220
  last: receipt ? { at: receipt.at ?? null, sha: receipt.git?.sha ?? null, url: receipt.url ?? null } : null,
1220
1221
  behind,
1221
1222
  ...(retired ? { retired: true } : {})
1222
1223
  })
1223
1224
  }
1225
+ // ⭐ A TARGET IS A DESTINATION, NOT A DEPLOY. Only CI or a receipt says the site went there;
1226
+ // a target with neither is named as what it is. ⛔ Until 2026-10-26 every target was "Also
1227
+ // deployed to", and the status opened with it — on a project copied from a repository whose
1228
+ // CI deployed it, after the site had moved to Uniweb Cloud.
1229
+ const deployed = targets.filter((t) => t.deployed)
1230
+ const never = targets.filter((t) => !t.deployed)
1231
+ const parts = []
1232
+ if (deployed.length) parts.push(`Also deployed to ${deployed.map((t) => t.title).join(' and ')}.`)
1233
+ if (never.length) {
1234
+ parts.push(`deploy.yml names ${never.map((t) => t.title).join(' and ')}, never deployed from this copy.`)
1235
+ attention = true
1236
+ lines.push(
1237
+ 'A target never deployed from here is a host the site has not gone to yet, or one it left — when it left, remove the target from deploy.yml, and `default:` when it names it.'
1238
+ )
1239
+ }
1224
1240
  return {
1225
1241
  checks: [
1226
1242
  {
1227
1243
  id: 'hosting.deploys',
1228
1244
  state: attention ? 'attention' : 'ok',
1229
- summary: `Also deployed to ${targets.map((t) => t.title).join(' and ')}.`,
1245
+ summary: parts.join(' '),
1230
1246
  lines,
1231
1247
  detail: { targets }
1232
1248
  }
@@ -1236,6 +1252,40 @@ async function deployChecks(siteDir) {
1236
1252
  }
1237
1253
  }
1238
1254
 
1255
+ /**
1256
+ * `hosting.workflows`: GitHub Actions workflows between this site and the top of its
1257
+ * repository — a project's own `.github/workflows` — which GitHub never runs: it runs the
1258
+ * repository's top-level workflows alone, so these are files, not CI.
1259
+ *
1260
+ * ⭐ A project copied into another repository brings its `.github/` along (`uniweb add ci`
1261
+ * writes one). Measured 2026-10-26 on a repository of client projects: a site that had moved to
1262
+ * Uniweb Cloud still carried the GitHub Pages workflow that used to deploy it.
1263
+ */
1264
+ function workflowsCheck(siteDir) {
1265
+ const repo = repoOf(siteDir)
1266
+ if (!repo) return null
1267
+ const found = []
1268
+ for (let d = siteDir; d !== repo.top && d.startsWith(repo.top + sep); d = dirname(d)) {
1269
+ const dir = join(d, '.github', 'workflows')
1270
+ let names
1271
+ try {
1272
+ names = readdirSync(dir).filter((n) => /\.ya?ml$/.test(n))
1273
+ } catch {
1274
+ continue
1275
+ }
1276
+ for (const n of names.sort()) found.push(relative(siteDir, join(dir, n)))
1277
+ }
1278
+ if (!found.length) return { id: 'hosting.workflows', state: 'ok', summary: 'No workflow here that GitHub would not run.', quiet: true }
1279
+ const n = found.length
1280
+ return {
1281
+ id: 'hosting.workflows',
1282
+ state: 'attention',
1283
+ summary: `${count(n, 'workflow')} here never ${n === 1 ? 'runs' : 'run'}: GitHub runs workflows only from the top of the repository, ${relative(siteDir, join(repo.top, '.github', 'workflows'))}.`,
1284
+ files: found,
1285
+ lines: ['Most likely left from the repository this project came from. When the site no longer deploys that way, the folder can go — ask the person first.']
1286
+ }
1287
+ }
1288
+
1239
1289
  const list = (names) => names.map((n) => `\`${n}\``).join(', ')
1240
1290
 
1241
1291
  /**
@@ -1305,7 +1355,8 @@ function summaryLine({ name, synced, live, targets, origin }) {
1305
1355
  const parts = [synced.length ? `synced with ${synced.join(' and ')}` : 'not synced with a backend']
1306
1356
  if (live?.published) parts.push(`live on ${cloudName(origin)}${live.url ? ` at ${live.url}` : ''}`)
1307
1357
  else if (live) parts.push(`not live on ${cloudName(origin)} yet`)
1308
- if (targets.length) parts.push(`${live?.published ? 'also deployed' : 'deployed'} to ${targets.map((t) => t.title).join(' and ')}`)
1358
+ const deployed = targets.filter((t) => t.deployed)
1359
+ if (deployed.length) parts.push(`${live?.published ? 'also deployed' : 'deployed'} to ${deployed.map((t) => t.title).join(' and ')}`)
1309
1360
  else if (!synced.length) parts.push('not hosted anywhere yet')
1310
1361
  return `${name} — ${parts.join(' · ')}`
1311
1362
  }
@@ -1399,7 +1450,8 @@ async function workspaceOverview(root, { json }) {
1399
1450
  })
1400
1451
  const languages = normalizeLanguageList(yml.languages)
1401
1452
  const parts = [synced.length ? `synced with ${synced.join(' and ')}` : 'not synced']
1402
- if (targets.length) parts.push(`deployed to ${targets.map((t) => t.title).join(' and ')}`)
1453
+ const deployed = targets.filter((t) => t.deployed)
1454
+ if (deployed.length) parts.push(`deployed to ${deployed.map((t) => t.title).join(' and ')}`)
1403
1455
  parts.push(
1404
1456
  fnd.kind === 'local'
1405
1457
  ? `foundation ${fnd.name}`
@@ -1414,7 +1466,7 @@ async function workspaceOverview(root, { json }) {
1414
1466
  name: s.name,
1415
1467
  summary: `${s.path} — ${parts.join(' · ')}`,
1416
1468
  synced,
1417
- deploys: targets.map(({ name, host, title, default: isDefault, ci }) => ({ name, host, title, default: isDefault, ci })),
1469
+ deploys: targets.map(({ name, host, title, default: isDefault, deployed: was, ci }) => ({ name, host, title, default: isDefault, deployed: was, ci })),
1418
1470
  foundation: { kind: fnd.kind, name: fnd.name ?? null, path: fnd.path ?? null },
1419
1471
  mounts,
1420
1472
  languages
@@ -1508,7 +1560,18 @@ export async function status(args = []) {
1508
1560
  } finally {
1509
1561
  Object.assign(console, saved)
1510
1562
  }
1511
- const checks = [...project.checks, ...git.checks, ...app.checks, ...deploys.checks, ...services.checks, ...version.checks, ...health.checks, dev]
1563
+ const workflows = workflowsCheck(siteDir)
1564
+ const checks = [
1565
+ ...project.checks,
1566
+ ...git.checks,
1567
+ ...app.checks,
1568
+ ...deploys.checks,
1569
+ ...(workflows ? [workflows] : []),
1570
+ ...services.checks,
1571
+ ...version.checks,
1572
+ ...health.checks,
1573
+ dev
1574
+ ]
1512
1575
  const next = planNext([...git.proposals, ...app.proposals, ...deploys.proposals, ...services.proposals, ...version.proposals, ...health.proposals])
1513
1576
 
1514
1577
  const root = findWorkspaceRoot(siteDir) || siteDir
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "schemaVersion": 1,
3
- "generatedAt": "2026-10-26T19:45:51.544Z",
3
+ "generatedAt": "2026-10-26T21:28:38.450Z",
4
4
  "packages": {
5
5
  "@uniweb/api": {
6
6
  "version": "1.6.4"
7
7
  },
8
8
  "@uniweb/build": {
9
- "version": "1.10.5"
9
+ "version": "1.10.6"
10
10
  },
11
11
  "@uniweb/content-reader": {
12
12
  "version": "1.5.7"
@@ -15,7 +15,7 @@
15
15
  "version": "1.3.1"
16
16
  },
17
17
  "@uniweb/core": {
18
- "version": "1.4.7"
18
+ "version": "1.4.8"
19
19
  },
20
20
  "@uniweb/frame-bridge": {
21
21
  "version": "1.0.2"
@@ -33,10 +33,10 @@
33
33
  "version": "1.1.4"
34
34
  },
35
35
  "@uniweb/projections": {
36
- "version": "1.2.1"
36
+ "version": "1.2.2"
37
37
  },
38
38
  "@uniweb/runtime": {
39
- "version": "1.2.3"
39
+ "version": "1.2.4"
40
40
  },
41
41
  "@uniweb/scene": {
42
42
  "version": "1.0.1"
package/src/index.js CHANGED
@@ -1789,8 +1789,9 @@ where, live where, deployed where — and checks:
1789
1789
  app the session, what changed on the backend since this copy last synced —
1790
1790
  the files a pull would change — what is not pushed, who has the site
1791
1791
  open in the app, the foundation
1792
- hosting where the site is live, the other hosts deploy.yml lists (by CI or by
1793
- hand, and how far behind), and the services the live site has
1792
+ hosting where the site is live, the other hosts deploy.yml lists (by CI, by hand
1793
+ and how far behind, or never from this copy), workflows GitHub never runs
1794
+ (a project's own .github/), and the services the live site has
1794
1795
  uniweb whether a newer release is out
1795
1796
  health doctor's findings
1796
1797
  dev the dev server