uniweb 1.2.0 → 1.2.1

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.2.0",
3
+ "version": "1.2.1",
4
4
  "description": "Create structured Vite + React sites with content/code separation",
5
5
  "type": "module",
6
6
  "bin": {
@@ -41,8 +41,8 @@
41
41
  "js-yaml": "^4.1.0",
42
42
  "prompts": "^2.4.2",
43
43
  "tar": "^7.0.0",
44
- "@uniweb/content-writer": "~1.0.1",
45
44
  "@uniweb/core": "~1.1.0",
45
+ "@uniweb/content-writer": "~1.0.1",
46
46
  "@uniweb/semantic-parser": "~1.5.1",
47
47
  "@uniweb/schemas": "~1.0.1"
48
48
  },
@@ -843,7 +843,9 @@ export class BackendClient {
843
843
  * shipped backend-side — collab backend↔framework):
844
844
  * { published: boolean, last_pushed_at?: string, last_published_at?: string, draft_dirty?: boolean }
845
845
  * `draft_dirty` = never-published, or the synced draft changed since the last
846
- * publish ("pushed but not published").
846
+ * publish ("pushed but not published"). Since 2026-10-13 it also carries `editors`:
847
+ * who has the site open in the app right now, and on what — `status --remote` shows
848
+ * it (`describeEditors`, commands/status.js).
847
849
  *
848
850
  * ⭐ **The backend also serves a LIVE-SITE record here, and nothing in this CLI reads
849
851
  * it yet** (shipped 2026-08-29; documented here so it is not lost twice):
@@ -7,15 +7,17 @@
7
7
  * `uniweb push` runs) — no auth, no backend round-trip.
8
8
  *
9
9
  * `--remote` adds the backend signals (may prompt for login, like `git fetch`):
10
- * - whether the synced draft differs from what's live (publish needed), and
11
- * - whether a newer foundation version is registered than the site pins.
10
+ * - whether the synced draft differs from what's live (publish needed),
11
+ * - whether a newer foundation version is registered than the site pins, and
12
+ * - who has the site open in the app right now, and on what (`describeEditors`).
12
13
  * Those use ASSUMED endpoints (see kb shipping-verbs-and-freshness.md §6.5); until
13
14
  * the backend exposes them, `--remote` degrades silently to the local view.
14
15
  *
15
16
  * Usage:
16
17
  * uniweb status Sync identity + unpushed content + foundation ref (local)
17
18
  * uniweb status --remote Also: whether the backend still holds the site, draft-vs-live,
18
- * and a newer-registered-foundation check
19
+ * a newer-registered-foundation check, and who has it open in
20
+ * the app
19
21
  * uniweb status --json One JSON line (adds a `remote` object under --remote, whose
20
22
  * `site_state` is live / gone / unknown — `gone` only on the
21
23
  * backend's own word that it holds no such site)
@@ -28,7 +30,7 @@ import { join } from 'node:path'
28
30
  import yaml from 'js-yaml'
29
31
 
30
32
  import { resolveSiteDir } from './deploy.js'
31
- import { probeUnpushed } from '../backend/site-sync.js'
33
+ import { probeUnpushed, readItemUuids, unitFilesOf } from '../backend/site-sync.js'
32
34
  import { resolveWorkspace } from '../backend/workspace.js'
33
35
  import {
34
36
  BackendClient,
@@ -75,6 +77,79 @@ function foundationRef(siteYml) {
75
77
  return typeof f === 'string' ? f : f.ref || null
76
78
  }
77
79
 
80
+ // What an item that this copy has not synced is called — by its Section on the site.
81
+ const ITEM_KIND = { pages: 'a page', page_sections: 'a section', layout_sections: 'a layout section' }
82
+
83
+ /**
84
+ * Who has the site open in the app right now, and on what — the status's `editors`
85
+ * (backend, since 2026-10-13), as lines a developer can follow up on. Each tab's item is
86
+ * named by the file it is in this copy, and by its kind and route when this copy has not
87
+ * synced it (`fileByItemUuid`).
88
+ *
89
+ * ⛔ Two ways not to read it. `changed_at` is the item's last change by ANYONE — this
90
+ * author, another, a push — so it says when the item last changed, never what this
91
+ * person did. And it is presence, not a lock: nothing refuses or warns a push over it.
92
+ *
93
+ * @param {object[]} editors - `[{ person: { uuid, handle, brief }, tabs: [{ page, item }] }]`
94
+ * @param {object} [opts]
95
+ * @param {(uuid: string) => string|null} [opts.fileOf] - an item's file in this copy
96
+ * @param {number} [opts.now] - for "3 min ago"
97
+ * @returns {{ person: string, tabs: string[] }[]} a tab with nothing open is left out
98
+ */
99
+ export function describeEditors(editors, { fileOf = () => null, now = Date.now() } = {}) {
100
+ return editors.map((editor) => ({
101
+ person: personLabel(editor?.person),
102
+ tabs: (editor?.tabs || []).map((tab) => tabLabel(tab, fileOf, now)).filter(Boolean)
103
+ }))
104
+ }
105
+
106
+ function personLabel(person) {
107
+ const name = person?.brief?.display_name
108
+ const handle = person?.handle ? `@${person.handle}` : null
109
+ if (name && handle) return `${name} (${handle})`
110
+ return name || handle || `someone (${person?.uuid || 'unnamed'})`
111
+ }
112
+
113
+ function tabLabel(tab, fileOf, now) {
114
+ const item = tab?.item
115
+ if (!item?.uuid) return null
116
+ const route = tab.page?.route || null
117
+ const file = fileOf(item.uuid)
118
+ const what = file
119
+ ? `${file}${route ? ` (${route})` : ''}`
120
+ : `${ITEM_KIND[item.section] || 'an item'} not in this copy${route ? ` (${route})` : ''}`
121
+ const when = sinceLabel(item.changed_at, now)
122
+ return when ? `${what} · changed ${when}` : what
123
+ }
124
+
125
+ function sinceLabel(iso, now) {
126
+ const t = Date.parse(iso)
127
+ if (Number.isNaN(t)) return null
128
+ const min = Math.floor(Math.max(0, now - t) / 60_000)
129
+ if (min < 1) return 'just now'
130
+ if (min < 60) return `${min} min ago`
131
+ const h = Math.floor(min / 60)
132
+ if (h < 24) return `${h} h ago`
133
+ const d = Math.floor(h / 24)
134
+ return `${d} day${d === 1 ? '' : 's'} ago`
135
+ }
136
+
137
+ /**
138
+ * An item's file in this copy, from its uuid: `sync.json` keeps each unit's uuid on
139
+ * `backend` by its path (`items`, banked from every push and pull), and `unitFilesOf`
140
+ * finds the file the author keeps it in (`1-hero.md` for `hero.md`). Null for an item
141
+ * this copy has not synced — one added in the app since its last pull.
142
+ */
143
+ export function fileByItemUuid(siteDir, backend) {
144
+ const unitOf = new Map(
145
+ Object.entries(readItemUuids(siteDir, backend)).map(([unit, uuid]) => [String(uuid).toLowerCase(), unit])
146
+ )
147
+ return (uuid) => {
148
+ const unit = unitOf.get(String(uuid).toLowerCase())
149
+ return unit ? (unitFilesOf(siteDir, unit)[0] ?? unit) : null
150
+ }
151
+ }
152
+
78
153
  // A versioned registry ref `@org/name@1.2.3` → its scoped name `@org/name` and
79
154
  // pinned version `1.2.3`. A bare/local/unversioned ref → nulls.
80
155
  function splitFoundationRef(fnd) {
@@ -263,6 +338,20 @@ export async function status(args = []) {
263
338
  )
264
339
  }
265
340
  }
341
+ // An array is the backend's answer, `[]` included; no key is a backend that does not
342
+ // say, and then neither do we.
343
+ if (Array.isArray(site?.editors)) {
344
+ if (!site.editors.length) {
345
+ say.dim('Nobody has this site open in the app.')
346
+ } else {
347
+ say.info('Open in the app now:')
348
+ const fileOf = fileByItemUuid(siteDir, probeBackend)
349
+ for (const { person, tabs } of describeEditors(site.editors, { fileOf })) {
350
+ console.log(` ${person}${tabs.length ? '' : ', with nothing open'}`)
351
+ for (const tab of tabs) console.log(` ${c.dim}${tab}${c.reset}`)
352
+ }
353
+ }
354
+ }
266
355
  if (
267
356
  fdnLatest?.latest_version &&
268
357
  fndVersion &&
@@ -1,9 +1,9 @@
1
1
  {
2
2
  "schemaVersion": 1,
3
- "generatedAt": "2026-10-13T20:52:47.988Z",
3
+ "generatedAt": "2026-10-14T18:03:31.718Z",
4
4
  "packages": {
5
5
  "@uniweb/api": {
6
- "version": "1.1.0"
6
+ "version": "1.1.1"
7
7
  },
8
8
  "@uniweb/build": {
9
9
  "version": "1.1.1"