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 +2 -2
- package/src/backend/client.js +3 -1
- package/src/commands/status.js +93 -4
- package/src/framework-index.json +2 -2
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "uniweb",
|
|
3
|
-
"version": "1.2.
|
|
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
|
},
|
package/src/backend/client.js
CHANGED
|
@@ -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):
|
package/src/commands/status.js
CHANGED
|
@@ -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),
|
|
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
|
-
*
|
|
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 &&
|
package/src/framework-index.json
CHANGED