uniweb 1.21.1 → 1.21.3

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.1",
3
+ "version": "1.21.3",
4
4
  "description": "Create structured Vite + React sites with content/code separation",
5
5
  "type": "module",
6
6
  "bin": {
@@ -41,15 +41,15 @@
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",
45
- "@uniweb/schemas": "~1.6.5",
44
+ "@uniweb/semantic-parser": "~1.13.0",
46
45
  "@uniweb/core": "~1.4.7",
47
- "@uniweb/semantic-parser": "~1.13.0"
46
+ "@uniweb/content-writer": "~1.3.1",
47
+ "@uniweb/schemas": "~1.6.5"
48
48
  },
49
49
  "peerDependencies": {
50
- "@uniweb/build": "~1.10.3",
51
50
  "@uniweb/semantic-parser": "~1.13.0",
52
- "@uniweb/content-reader": "~1.5.7"
51
+ "@uniweb/content-reader": "~1.5.7",
52
+ "@uniweb/build": "~1.10.4"
53
53
  },
54
54
  "peerDependenciesMeta": {
55
55
  "@uniweb/build": {
@@ -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
 
@@ -43,7 +43,7 @@
43
43
 
44
44
  import { execFileSync } from 'node:child_process'
45
45
  import { existsSync, readFileSync } from 'node:fs'
46
- import { join, relative, resolve } from 'node:path'
46
+ import { basename, join, relative, resolve } from 'node:path'
47
47
  import yaml from 'js-yaml'
48
48
 
49
49
  import {
@@ -790,20 +790,241 @@ async function doctorCheck(siteDir) {
790
790
  }
791
791
  }
792
792
 
793
- /** `dev.server`: the site's dev server, confirmed by asking it. */
794
- async function devCheck(siteDir, { offline }) {
795
- if (offline) return { id: 'dev.server', state: 'skipped', summary: 'The dev server was not asked (--offline).' }
793
+ /**
794
+ * `dev.server`: the site's dev server, confirmed by asking it — and the command that starts it
795
+ * for this site. In a workspace with several, a bare `uniweb dev` starts the first one found
796
+ * (`commands/dev.js`), which may not be this one.
797
+ */
798
+ async function devCheck(siteDir, { offline, sites = [] }) {
799
+ const root = findWorkspaceRoot(siteDir) || siteDir
800
+ const mine = sites.find((s) => resolve(root, s.path) === resolve(siteDir))
801
+ const first = sites[0]
802
+ const command = sites.length > 1 && mine && first !== mine ? `npx uniweb dev ${mine.name}` : 'npx uniweb dev'
803
+ const note = command === 'npx uniweb dev' ? '' : ` (a bare \`uniweb dev\` starts ${first.name})`
804
+ if (offline) return { id: 'dev.server', state: 'skipped', summary: `The dev server was not asked (--offline); \`${command}\` starts it${note}.`, detail: { command } }
796
805
  try {
797
806
  const { runningDevServer } = await import('@uniweb/build/site')
798
807
  const running = await runningDevServer(siteDir, { timeout: 1000 })
799
808
  return running
800
- ? { id: 'dev.server', state: 'ok', summary: `The dev server is running at ${running.url}.`, detail: { url: running.url } }
801
- : { id: 'dev.server', state: 'skipped', summary: 'The dev server is not running — `npx uniweb dev` starts it.' }
809
+ ? { id: 'dev.server', state: 'ok', summary: `The dev server is running at ${running.url}.`, detail: { url: running.url, command } }
810
+ : { id: 'dev.server', state: 'skipped', summary: `The dev server is not running — \`${command}\` starts it${note}.`, detail: { command } }
811
+ } catch (err) {
812
+ return { id: 'dev.server', state: 'unknown', summary: `Could not ask the dev server: ${err.message}`, detail: { command } }
813
+ }
814
+ }
815
+
816
+ // ─── the project ────────────────────────────────────────────────────────────
817
+
818
+ /**
819
+ * The workspace, as its wiring says: the sites and foundations its globs hold, by the build's
820
+ * classifier (`utils/discover.js`), and the globs themselves.
821
+ *
822
+ * ⛔ NO LAYOUT NAME. "Single", "segregated" and the rest are conventions — the globs and the
823
+ * wiring are the truth — and a name guessed from folders is wrong where a workspace mixes them.
824
+ */
825
+ async function workspaceFacts(root) {
826
+ const [{ discoverSites, discoverFoundations }, { readWorkspaceConfig }, { isExtensionPackage }] = await Promise.all([
827
+ import('../utils/discover.js'),
828
+ import('../utils/config.js'),
829
+ import('@uniweb/build')
830
+ ])
831
+ const [sites, packages, config] = await Promise.all([
832
+ discoverSites(root),
833
+ discoverFoundations(root),
834
+ readWorkspaceConfig(root).catch(() => ({ packages: [] }))
835
+ ])
836
+ const extension = (f) => {
837
+ try {
838
+ return isExtensionPackage(join(root, f.path))
839
+ } catch {
840
+ return false
841
+ }
842
+ }
843
+ return {
844
+ root,
845
+ patterns: Array.isArray(config.packages) ? config.packages : [],
846
+ sites,
847
+ foundations: packages.filter((f) => !extension(f)),
848
+ extensions: packages.filter(extension)
849
+ }
850
+ }
851
+
852
+ function workspaceLine(ws) {
853
+ const { sites, foundations, extensions, patterns } = ws
854
+ if (sites.length === 1 && foundations.length === 1 && !extensions.length) {
855
+ return `One site and one foundation in this workspace: ${sites[0].path}/ and ${foundations[0].path}/.`
856
+ }
857
+ const parts = [count(sites.length, 'site'), count(foundations.length, 'foundation')]
858
+ if (extensions.length) parts.push(count(extensions.length, 'extension'))
859
+ const from = patterns.length ? `, from ${list(patterns)}` : ''
860
+ return `${parts.slice(0, -1).join(', ')} and ${parts.at(-1)} in this workspace${from}.`
861
+ }
862
+
863
+ /**
864
+ * A site's foundation, as the build finds it — the one resolver (`detectFoundationType`), so
865
+ * what status names is what the build would build. A catalog ref, which a build refuses, is
866
+ * named as one.
867
+ */
868
+ async function foundationOf(siteDir, siteYml, root) {
869
+ const declared = siteYml?.foundation
870
+ if (!declared) return { kind: 'none' }
871
+ const name = typeof declared === 'string' ? declared : declared.ref || declared.name || declared.url || null
872
+ const [{ detectFoundationType }, { parseCatalogRef }] = await Promise.all([import('@uniweb/build'), import('@uniweb/build/uwx')])
873
+ if (parseCatalogRef(name)) return { kind: 'catalog', name, ref: name }
874
+ try {
875
+ const found = detectFoundationType(declared, siteDir)
876
+ if (found.type === 'local') return { kind: 'local', name: found.name, path: relative(root, found.path) || '.', abs: resolve(found.path) }
877
+ return { kind: 'url', name, url: found.url }
802
878
  } catch (err) {
803
- return { id: 'dev.server', state: 'unknown', summary: `Could not ask the dev server: ${err.message}` }
879
+ return { kind: 'unresolved', name, error: String(err?.message || err).split('\n')[0] }
804
880
  }
805
881
  }
806
882
 
883
+ /** A site's extensions, each where it is found: a folder here, the catalog, or a URL. */
884
+ async function extensionsOf(siteDir, siteYml, root) {
885
+ const declared = Array.isArray(siteYml?.extensions) ? siteYml.extensions : []
886
+ if (!declared.length) return []
887
+ const [{ findSiteExtension, extensionNameOf }, { parseCatalogRef }] = await Promise.all([import('@uniweb/build'), import('@uniweb/build/uwx')])
888
+ return declared.map((entry) => {
889
+ const value = entry && typeof entry === 'object' ? entry.url || entry.ref || entry.name : entry
890
+ if (typeof value !== 'string' || !value) return { value: String(value), kind: 'unresolved' }
891
+ if (parseCatalogRef(value)) return { value, kind: 'catalog' }
892
+ if (/^https?:\/\//.test(value)) return { value, kind: 'url' }
893
+ // A site-relative URL names an extension by its first segment; a bare name is that name.
894
+ const name = value.startsWith('/') ? extensionNameOf(value, siteYml.base)?.name : value
895
+ const dir = name ? findSiteExtension(name, siteDir) : null
896
+ return dir ? { value, kind: 'local', name, path: relative(root, dir) || '.' } : { value, kind: 'unresolved', name: name ?? null }
897
+ })
898
+ }
899
+
900
+ /**
901
+ * `project.*`: where this site sits — the workspace's sites and foundations, the foundation it
902
+ * is built with and what else uses it, its extensions, pages it takes from outside its folder,
903
+ * and the languages it is written in. All from files: no request.
904
+ */
905
+ async function projectChecks(siteDir, siteYml) {
906
+ const inWorkspace = Boolean(findWorkspaceRoot(siteDir))
907
+ const root = findWorkspaceRoot(siteDir) || siteDir
908
+ const ws = await workspaceFacts(root)
909
+ const checks = []
910
+ checks.push({
911
+ id: 'project.workspace',
912
+ state: 'ok',
913
+ summary: inWorkspace ? workspaceLine(ws) : 'This site is on its own, in no workspace.',
914
+ detail: { patterns: ws.patterns, sites: ws.sites, foundations: ws.foundations, extensions: ws.extensions }
915
+ })
916
+
917
+ // ⭐ The foundation, and what a change to it reaches: every site that resolves to the same one.
918
+ const fnd = await foundationOf(siteDir, siteYml, root)
919
+ const here = resolve(siteDir)
920
+ const sharedWith = []
921
+ if (fnd.kind === 'local') {
922
+ for (const s of ws.sites) {
923
+ const dir = resolve(root, s.path)
924
+ if (dir === here) continue
925
+ const other = await foundationOf(dir, readSiteYml(dir), root)
926
+ if (other.kind === 'local' && other.abs === fnd.abs) sharedWith.push(s.path)
927
+ }
928
+ }
929
+ const extensions = await extensionsOf(siteDir, siteYml, root)
930
+ let summary
931
+ let state = 'ok'
932
+ if (fnd.kind === 'local') {
933
+ summary =
934
+ `Foundation ${fnd.name}, in ${fnd.path}/ — its source is here` +
935
+ (sharedWith.length
936
+ ? `, and ${sharedWith.length === 1 ? 'another site uses' : `${sharedWith.length} other sites use`} it too: ${sharedWith.join(', ')} — a change to a section type changes ${sharedWith.length === 1 ? 'it' : 'them'} as well.`
937
+ : ws.sites.length > 1 ? '; no other site here uses it.' : '.')
938
+ } else if (fnd.kind === 'catalog') {
939
+ summary = `Foundation ${fnd.ref}, from the catalog — its source is not in this workspace; work within the section types it offers.`
940
+ } else if (fnd.kind === 'url') {
941
+ summary = `Foundation loaded from ${fnd.url} — its source is not in this workspace; work within the section types it offers.`
942
+ } else if (fnd.kind === 'unresolved') {
943
+ state = 'attention'
944
+ summary = `site.yml names the foundation \`${fnd.name}\`, which does not resolve: ${fnd.error}`
945
+ } else {
946
+ state = 'attention'
947
+ summary = 'site.yml names no foundation.'
948
+ }
949
+ const extensionLines = extensions.map((e) =>
950
+ e.kind === 'local'
951
+ ? `Extension ${e.name}, in ${e.path}/ — its source is here.`
952
+ : e.kind === 'catalog'
953
+ ? `Extension ${e.value}, from the catalog — its source is not in this workspace.`
954
+ : e.kind === 'url'
955
+ ? `Extension loaded from ${e.value} — its source is not in this workspace.`
956
+ : `Extension \`${e.value}\` names ${e.name ? `\`${e.name}\`, which no folder here holds` : 'nothing this site can find'}.`
957
+ )
958
+ if (extensions.some((e) => e.kind === 'unresolved')) state = 'attention'
959
+ checks.push({
960
+ id: 'project.foundation',
961
+ state,
962
+ summary,
963
+ lines: extensionLines,
964
+ detail: {
965
+ kind: fnd.kind,
966
+ name: fnd.name ?? null,
967
+ path: fnd.path ?? null,
968
+ url: fnd.url ?? null,
969
+ editable: fnd.kind === 'local',
970
+ sharedWith,
971
+ extensions: extensions.map(({ value, kind, name, path }) => ({ value, kind, name: name ?? null, path: path ?? null }))
972
+ }
973
+ })
974
+
975
+ // ⭐ Pages from outside the site's folder (`paths:` mounts, by the build's own rule): a pull
976
+ // never writes them (`uwx/git-authored.js`), and one in its own repository is edited there.
977
+ const { mountEntriesOf } = await import('@uniweb/build/site')
978
+ const siteRepo = repoOf(siteDir)?.top ?? null
979
+ const mounts = mountEntriesOf(siteYml?.paths).map(([segment, dir]) => {
980
+ const abs = resolve(siteDir, String(dir))
981
+ const exists = existsSync(abs)
982
+ const top = exists ? repoOf(abs)?.top ?? null : null
983
+ const repository = !exists ? null : !top ? 'none' : top === siteRepo ? 'same' : 'other'
984
+ return { segment, dir: String(dir), exists, repository }
985
+ })
986
+ const mountLine = (m) =>
987
+ !m.exists
988
+ ? `pages/${m.segment} names ${m.dir}, which does not exist.`
989
+ : m.repository === 'other'
990
+ ? `pages/${m.segment} comes from ${m.dir}, its own git repository — edits there are commits in it.`
991
+ : `pages/${m.segment} comes from ${m.dir}, outside the site's folder.`
992
+ checks.push(
993
+ mounts.length
994
+ ? {
995
+ id: 'project.mounts',
996
+ state: mounts.some((m) => !m.exists) ? 'attention' : 'ok',
997
+ summary: mounts.length === 1 ? mountLine(mounts[0]) : `${count(mounts.length, 'page folder')} come from outside the site's folder.`,
998
+ lines: [
999
+ ...(mounts.length === 1 ? [] : mounts.map(mountLine)),
1000
+ 'An edit made in the app to those pages does not come back on a pull, and the next push replaces it with the files.'
1001
+ ],
1002
+ detail: { mounts }
1003
+ }
1004
+ : { id: 'project.mounts', state: 'skipped', summary: "All the site's pages are in its own folder.", quiet: true, detail: { mounts: [] } }
1005
+ )
1006
+
1007
+ // The languages, by core's own rules: the one the files are written in, and the rest.
1008
+ const { normalizeLanguageList, resolveDefaultLocale, isWildcardLanguages } = await import('@uniweb/core/locale-config')
1009
+ const source = resolveDefaultLocale(siteYml || {})
1010
+ const wildcard = isWildcardLanguages(siteYml?.languages)
1011
+ const translations = normalizeLanguageList(siteYml?.languages).filter((code) => code !== source && code !== '*')
1012
+ checks.push(
1013
+ translations.length || wildcard
1014
+ ? {
1015
+ id: 'project.languages',
1016
+ state: 'ok',
1017
+ summary:
1018
+ `Written in ${source}, and translated into ${wildcard ? 'every language in locales/' : translations.join(', ')} — ` +
1019
+ `a text you change in ${source} needs translating again.`,
1020
+ lines: ['After a change: build, then `npx uniweb i18n sync`, and `npx uniweb i18n status` lists what is left.'],
1021
+ detail: { source, translations, wildcard }
1022
+ }
1023
+ : { id: 'project.languages', state: 'ok', summary: `One language: ${source}.`, detail: { source, translations: [], wildcard: false } }
1024
+ )
1025
+ return { checks, ws }
1026
+ }
1027
+
807
1028
  // ─── where the site is hosted ───────────────────────────────────────────────
808
1029
 
809
1030
  /** A host adapter's name as people know it (`S3 + CloudFront`), or its id. */
@@ -1012,6 +1233,7 @@ function summaryLine({ name, synced, live, targets, origin }) {
1012
1233
  // ─── the command ────────────────────────────────────────────────────────────
1013
1234
 
1014
1235
  const GROUPS = [
1236
+ ['project', 'Project'],
1015
1237
  ['git', 'Git'],
1016
1238
  ['app', 'App'],
1017
1239
  ['hosting', 'Hosting'],
@@ -1072,6 +1294,97 @@ function render({ summary, backend, checks, next }) {
1072
1294
  return out.join('\n')
1073
1295
  }
1074
1296
 
1297
+ /**
1298
+ * At a workspace with several sites: what each one is — synced where, deployed where, built
1299
+ * with which foundation, with pages from where, in how many languages — from its files alone,
1300
+ * and the way to one site's checks. Nothing is asked of a backend: what changed in the app is
1301
+ * a site's question.
1302
+ */
1303
+ async function workspaceOverview(root, { json }) {
1304
+ const ws = await workspaceFacts(root)
1305
+ const { mountEntriesOf } = await import('@uniweb/build/site')
1306
+ const { normalizeLanguageList } = await import('@uniweb/core/locale-config')
1307
+ const sites = []
1308
+ for (const s of ws.sites) {
1309
+ const dir = resolve(root, s.path)
1310
+ const yml = readSiteYml(dir)
1311
+ const synced = listSyncedBackends(dir).filter((o) => readBackendState(dir, o).site?.uuid)
1312
+ const { targets } = await deployChecks(dir)
1313
+ const fnd = await foundationOf(dir, yml, root)
1314
+ const siteRepo = repoOf(dir)?.top ?? null
1315
+ const mounts = mountEntriesOf(yml.paths).map(([segment, d]) => {
1316
+ const abs = resolve(dir, String(d))
1317
+ const top = existsSync(abs) ? repoOf(abs)?.top ?? null : null
1318
+ return { segment, dir: String(d), repository: !existsSync(abs) ? null : !top ? 'none' : top === siteRepo ? 'same' : 'other' }
1319
+ })
1320
+ const languages = normalizeLanguageList(yml.languages)
1321
+ const parts = [synced.length ? `synced with ${synced.join(' and ')}` : 'not synced']
1322
+ if (targets.length) parts.push(`deployed to ${targets.map((t) => t.title).join(' and ')}`)
1323
+ parts.push(
1324
+ fnd.kind === 'local'
1325
+ ? `foundation ${fnd.name}`
1326
+ : fnd.kind === 'catalog' || fnd.kind === 'url'
1327
+ ? `foundation ${fnd.name}, not in this workspace`
1328
+ : `foundation ${fnd.name ? `\`${fnd.name}\`, unresolved` : 'not named'}`
1329
+ )
1330
+ for (const m of mounts) parts.push(`pages/${m.segment} from ${m.dir}${m.repository === 'other' ? ', its own repository' : ''}`)
1331
+ if (languages.length > 1) parts.push(`${languages.length} languages`)
1332
+ sites.push({
1333
+ path: s.path,
1334
+ name: s.name,
1335
+ summary: `${s.path} — ${parts.join(' · ')}`,
1336
+ synced,
1337
+ deploys: targets.map(({ name, host, title, default: isDefault, ci }) => ({ name, host, title, default: isDefault, ci })),
1338
+ foundation: { kind: fnd.kind, name: fnd.name ?? null, path: fnd.path ?? null },
1339
+ mounts,
1340
+ languages
1341
+ })
1342
+ }
1343
+ // The repository the workspace is in, as far as this machine knows — no remote is asked.
1344
+ const repo = repoOf(root)
1345
+ let git = null
1346
+ if (repo?.head) {
1347
+ let uncommitted = 0
1348
+ try {
1349
+ uncommitted = execFileSync('git', ['status', '--porcelain'], { cwd: root, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] })
1350
+ .split('\n')
1351
+ .filter(Boolean).length
1352
+ } catch {
1353
+ uncommitted = 0
1354
+ }
1355
+ git = { uncommitted, ...(aheadBehind(root) || {}), upstream: upstreamOf(root)?.tracking ?? null }
1356
+ }
1357
+ const summary = `${basename(root)} — ${workspaceLine(ws).replace(/\.$/, '')}`
1358
+ const first = ws.sites[0]
1359
+ if (json) {
1360
+ console.log(
1361
+ JSON.stringify({
1362
+ schema: STATUS_SCHEMA,
1363
+ uniweb: getCliVersion(),
1364
+ scope: 'workspace',
1365
+ summary,
1366
+ workspace: { patterns: ws.patterns, sites: ws.sites, foundations: ws.foundations, extensions: ws.extensions },
1367
+ sites,
1368
+ git,
1369
+ checks: [],
1370
+ next: [{ command: null, why: "run `npx uniweb status` in a site's folder for its checks — what changed in the app, in git, and where it is hosted", ask: false }]
1371
+ })
1372
+ )
1373
+ return { exitCode: 0 }
1374
+ }
1375
+ const out = ['', `${c.bold}${summary}${c.reset}`, '']
1376
+ for (const s of sites) out.push(` ${s.summary}`)
1377
+ if (git) {
1378
+ const said = [git.uncommitted ? `${count(git.uncommitted, 'file')} not committed` : 'all committed']
1379
+ if (git.upstream) said.push(git.ahead || git.behind ? `${git.ahead} ahead of, ${git.behind} behind ${git.upstream} as of the last fetch` : `level with ${git.upstream} as of the last fetch`)
1380
+ out.push('', `${c.dim}Git: ${said.join(' · ')}${c.reset}`)
1381
+ }
1382
+ out.push('', `A bare \`uniweb dev\` starts ${first.name}. For one site's checks — what changed in the app, in git, and where it is hosted — run status in its folder, for example:`)
1383
+ out.push(` ${c.cyan}cd ${first.path} && npx uniweb status${c.reset}`, '')
1384
+ console.log(out.join('\n'))
1385
+ return { exitCode: 0 }
1386
+ }
1387
+
1075
1388
  export async function status(args = []) {
1076
1389
  // See utils/flag-guard.js — an unrecognized flag is invisible to a literal scan.
1077
1390
  // Straight to stderr: this is a usage error, so it must not be mistaken for the
@@ -1086,15 +1399,11 @@ export async function status(args = []) {
1086
1399
 
1087
1400
  const found = await findSite(process.cwd())
1088
1401
  if (!found.siteDir) {
1089
- const sites = found.several || []
1090
- if (json) {
1091
- console.log(JSON.stringify({ schema: STATUS_SCHEMA, error: sites.length ? 'several-sites' : 'no-site', sites }))
1092
- } else if (sites.length) {
1093
- console.error(`${c.red}✗${c.reset} Several sites here — run status from the one you mean:`)
1094
- for (const s of sites) console.error(` ${c.cyan}cd ${s} && npx uniweb status${c.reset}`)
1095
- } else {
1096
- console.error(`${c.red}✗${c.reset} No Uniweb site here. Run status from a site, or a project with one site.`)
1097
- }
1402
+ // ⭐ A WORKSPACE OF SEVERAL SITES IS ANSWERED, not refused: what each one is, from its
1403
+ // files, and the way to one site's checks.
1404
+ if (found.several?.length) return workspaceOverview(found.root, { json })
1405
+ if (json) console.log(JSON.stringify({ schema: STATUS_SCHEMA, scope: null, error: 'no-site' }))
1406
+ else console.error(`${c.red}✗${c.reset} No Uniweb site here. Run status from a site, or a project with one site.`)
1098
1407
  return { exitCode: 2 }
1099
1408
  }
1100
1409
  const siteDir = found.siteDir
@@ -1105,21 +1414,21 @@ export async function status(args = []) {
1105
1414
  // helpers say on the way goes to stderr.
1106
1415
  const saved = { log: console.log, info: console.info, warn: console.warn }
1107
1416
  if (json) console.log = console.info = console.warn = (...a) => console.error(...a)
1108
- let git, app, version, health, dev, deploys, services
1417
+ let project, git, app, version, health, dev, deploys, services
1109
1418
  try {
1110
- git = await gitChecks(siteDir, { offline, origin })
1419
+ ;[project, git] = await Promise.all([projectChecks(siteDir, siteYml), gitChecks(siteDir, { offline, origin })])
1111
1420
  ;[app, version, health, dev, deploys] = await Promise.all([
1112
1421
  appChecks(siteDir, siteYml, { offline, args, git }),
1113
1422
  versionCheck(siteDir, { offline }),
1114
1423
  doctorCheck(siteDir),
1115
- devCheck(siteDir, { offline }),
1424
+ devCheck(siteDir, { offline, sites: project.ws.sites }),
1116
1425
  deployChecks(siteDir)
1117
1426
  ])
1118
1427
  services = await servicesCheck({ siteDir, siteYml, site: app.site })
1119
1428
  } finally {
1120
1429
  Object.assign(console, saved)
1121
1430
  }
1122
- const checks = [...git.checks, ...app.checks, ...deploys.checks, ...services.checks, ...version.checks, ...health.checks, dev]
1431
+ const checks = [...project.checks, ...git.checks, ...app.checks, ...deploys.checks, ...services.checks, ...version.checks, ...health.checks, dev]
1123
1432
  const next = planNext([...git.proposals, ...app.proposals, ...deploys.proposals, ...services.proposals, ...version.proposals, ...health.proposals])
1124
1433
 
1125
1434
  const root = findWorkspaceRoot(siteDir) || siteDir
@@ -1139,6 +1448,7 @@ export async function status(args = []) {
1139
1448
  JSON.stringify({
1140
1449
  schema: STATUS_SCHEMA,
1141
1450
  uniweb: getCliVersion(),
1451
+ scope: 'site',
1142
1452
  summary,
1143
1453
  site,
1144
1454
  backend: app.backend.origin,
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "schemaVersion": 1,
3
- "generatedAt": "2026-10-26T17:19:54.265Z",
3
+ "generatedAt": "2026-10-26T19:09:19.484Z",
4
4
  "packages": {
5
5
  "@uniweb/api": {
6
6
  "version": "1.6.4"
7
7
  },
8
8
  "@uniweb/build": {
9
- "version": "1.10.3"
9
+ "version": "1.10.4"
10
10
  },
11
11
  "@uniweb/content-reader": {
12
12
  "version": "1.5.7"
@@ -24,7 +24,7 @@
24
24
  "version": "1.0.6"
25
25
  },
26
26
  "@uniweb/kit": {
27
- "version": "1.6.5"
27
+ "version": "1.6.6"
28
28
  },
29
29
  "@uniweb/loom": {
30
30
  "version": "1.1.2"
package/src/index.js CHANGED
@@ -1781,6 +1781,9 @@ Run it when you begin, when you come back to the project, before you push, publi
1781
1781
  or deploy, and after a command refuses. It opens with what the site is — synced
1782
1782
  where, live where, deployed where — and checks:
1783
1783
 
1784
+ project the workspace's sites and foundations, the foundation this site is built
1785
+ with and the other sites it reaches, pages from outside the site's folder,
1786
+ the languages
1784
1787
  git conflicts, a merge waiting, the site's files not committed, the git remote
1785
1788
  app the session, what changed on the backend since this copy last synced —
1786
1789
  the files a pull would change — what is not pushed, who has the site
@@ -1791,7 +1794,8 @@ where, live where, deployed where — and checks:
1791
1794
  health doctor's findings
1792
1795
  dev the dev server
1793
1796
 
1794
- and ends with the steps to take, in the order to take them. A step marked
1797
+ and ends with the steps to take, in the order to take them. At the root of a
1798
+ workspace with several sites, it says what each one is. A step marked
1795
1799
  "ask the person first" is theirs to decide — going live, for one.
1796
1800
 
1797
1801
  It changes nothing and asks nothing: with no session it says how to sign in.