uniweb 1.2.0 → 1.3.0

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.3.0",
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.1.0",
42
42
  "prompts": "^2.4.2",
43
43
  "tar": "^7.0.0",
44
- "@uniweb/content-writer": "~1.0.1",
45
- "@uniweb/core": "~1.1.0",
46
- "@uniweb/semantic-parser": "~1.5.1",
47
- "@uniweb/schemas": "~1.0.1"
44
+ "@uniweb/core": "~1.2.0",
45
+ "@uniweb/semantic-parser": "~1.6.0",
46
+ "@uniweb/schemas": "~1.1.0",
47
+ "@uniweb/content-writer": "~1.1.0"
48
48
  },
49
49
  "peerDependencies": {
50
- "@uniweb/build": "~1.1.1",
51
- "@uniweb/semantic-parser": "~1.5.1",
52
- "@uniweb/content-reader": "~1.2.8"
50
+ "@uniweb/semantic-parser": "~1.6.0",
51
+ "@uniweb/content-reader": "~1.3.0",
52
+ "@uniweb/build": "~1.2.0"
53
53
  },
54
54
  "peerDependenciesMeta": {
55
55
  "@uniweb/build": {
@@ -540,12 +540,13 @@ Sizing and loading ride on the image: `{width=800 height=600 loading=lazy fit=co
540
540
 
541
541
  **Separators are forgiving.** `:` works wherever `=` does, and pairs may be separated by whitespace, a comma, or both — `{role=banner width=1200}`, `{role:banner, width:1200}` and `{role:banner,width:1200}` are identical. `=` and spaces are canonical, and that is what gets written back on save. Two rules keep it unambiguous: the separator must **touch** the key (`{note:warning}` is one pair, `{note : warning}` is two flags), and a value containing a **comma** must be quoted (`{style="a, b"}`). A value containing a colon needs no quoting — only the first colon separates, so `{href:https://example.com}` is fine.
542
542
 
543
- Standalone links (alone on a line) become buttons in `content.links[]`. Inline links stay as `<a>` tags inside `content.paragraphs[]`. Multiple links sharing a paragraph are all promoted:
543
+ Standalone links (alone on a line) are promoted to `content.links[]` — usually drawn as buttons, which is the component's choice. Inline links stay as `<a>` tags inside `content.paragraphs[]`. Multiple links sharing a paragraph are all promoted:
544
544
 
545
545
  ```markdown
546
546
  [Primary](/start) ← standalone → content.links[0]
547
547
  [One](/a) [Two](/b) ← links-only paragraph → both in content.links[]
548
548
  Check out [this](/a) link. ← inline → stays in paragraphs as an <a> tag
549
+ [Go](/start){button} ← a link with role "button" (also `button:/start`), and a `variant`
549
550
  ```
550
551
 
551
552
  ### Inline text styling
@@ -557,12 +558,18 @@ This is [less important]{muted} context.
557
558
 
558
559
  `accent` and `callout` (both accent-colored + bold) and `muted` (subtle) are built-in defaults that adapt to context. `--accent` is your brand color unless you declare a separate `colors.accent`, and resolves to the shade you authored — not a darkened one, since accent is decorative emphasis rather than body-size link text. Components receive HTML strings with spans applied: `<span accent="true">faster</span>`.
559
560
 
560
- Sites can adjust these or add named styles in `theme.yml`'s `inline:` section. Overrides merge **property by property**, so declare only what differs (`accent: { font-weight: inherit }` keeps the default color); to drop a default property rather than change it, give it a neutral value (`initial`, `inherit`, `unset`).
561
+ Sites can adjust these or add named styles in `theme.yml`'s `inline:` section — a name is a letter, then letters, digits, `_` or `-`, and never starts with `on` (a handler's prefix, so it is dropped). Overrides merge **property by property**, so declare only what differs (`accent: { font-weight: inherit }` keeps the default color); to drop a default property rather than change it, give it a neutral value (`initial`, `inherit`, `unset`).
561
562
 
562
563
  > **On a site synced with Uniweb Cloud, the built-in style names round-trip *editable*** — an author who opens a page containing `[Get started]{accent}` gets the editor's own styling control for it, and can change or remove it like any text they styled there.
563
564
  >
564
565
  > **A name the editor doesn't know is preserved exactly, but not editable** — a style you defined yourself in `inline:`, two names on one span, or a `class`/`id` set. Nothing is lost and nothing is approximated: it survives every edit and save untouched, the editor simply offers no control for it. That is a permanent property rather than a gap, because the name set is open by design — you can add styles the editor has no way to know about.
565
566
 
567
+ ### HTML is not content
568
+
569
+ Content is markdown. Components decide how it renders, so a tag written in it — `<strong>`, `<span class=…>`, `<div>` — is not markup: it **shows as written**, and the build names the file. Use markdown's own forms instead: `**bold**`, a named style (`[text]{accent}`), a link.
570
+
571
+ The one exception is `<br/>`, a line break where markdown has no other spelling — inside a heading. For a headline split across lines, the multi-line heading above usually says it better.
572
+
566
573
  ### Fenced code: data blocks vs snippets
567
574
 
568
575
  Fenced code serves three purposes depending on its info string: `yaml:`/`json:` for data, `md:` for a named kind of prose (see *Concept blocks*), and a bare language for a code sample.
@@ -1072,6 +1079,8 @@ import { H1, H2, H3, P, Span } from '@uniweb/kit'
1072
1079
  <Span text={listItem.paragraphs[0]} className="text-subtle" />
1073
1080
  ```
1074
1081
 
1082
+ These strings are safe to render as HTML because the parser builds them from structure and escapes every character of the text. **A string from anywhere else is not** — a field a member wrote, a value an API returned. Render it as text: `{value}` in JSX, or `<PlainText text={value} />`.
1083
+
1075
1084
  Kit provides `H1`–`H6`. These render their own HTML tag — **don't wrap them**: `<H2 text={…} />`, not `<h2><H2 text={…} /></h2>`.
1076
1085
 
1077
1086
  **Full content rendering** (article/docs sections where the author controls the flow):
@@ -2509,12 +2518,14 @@ const { entity } = useEntity({ schema: '@/track', uuid: record.uuid })
2509
2518
  const sessions = entity?.hydrated?.items.filter((item) => item.section === 'sessions')
2510
2519
  ```
2511
2520
 
2521
+ ⛔ **What a member wrote is text.** Render a field as `{record.brief.name}` or `<PlainText text={…} />` — never `<P text>`, `<H1>`–`<H6>` or `SafeHtml`, which render HTML: a member who typed `<img src=x onerror=…>` would run it as whoever views the page. A rich-text field (a ProseMirror document) renders through kit's `Render`, which escapes its text.
2522
+
2512
2523
  ⛔ **A listed record is a summary, not the entity.** `record.brief` holds the brief
2513
2524
  section's fields (`record.brief.name`, not `record.name`), and a record carries **no
2514
2525
  items**. Read one entity for its items. Each item carries `section`, its section's name
2515
2526
  (the word a write takes), and `id`, which is what the writer takes to update, move or
2516
2527
  remove it. `scope: 'mine'` lists only the viewer's own records; without it, a list also
2517
- holds what was shared with the viewer.
2528
+ holds what was shared with the viewer, and what a role of theirs reaches.
2518
2529
 
2519
2530
  ⭐ **`absent` and an empty `ready` are different answers, and confusing them is the
2520
2531
  mistake to avoid.** `absent` = there is no live source (no `backend` service, or nobody signed
@@ -2549,9 +2560,11 @@ await writer.remove(itemId)
2549
2560
  Gate your controls on what the viewer may do — a record's own `can_edit` from a
2550
2561
  single-entity read — but **never rely on that for safety**. A foundation runs with
2551
2562
  exactly the viewer's own authority, so hiding a button hides a button. ⛔ **Not on
2552
- `viewer.roles`:** a session signed in on a site holds a member's rights — the operator's
2553
- too — so its roles never name an administrator, and a gate on `system_admin` shows the
2554
- operator nothing.
2563
+ `viewer.roles` alone:** on a site a session holds its roles as a reader's until it is
2564
+ elevated (*Acting as an administrator*, below), so a gate on `system_admin` shows the
2565
+ operator nothing until then — and a role never says what the session may do to one
2566
+ record. Draw a control that acts through a role from `useElevation().rolesInFull`, and
2567
+ let the record answer.
2555
2568
 
2556
2569
  What protects content is the **entry**: a member writes their own entries and not
2557
2570
  another's, and the store enforces it. **Models are open** — anyone signed in may
@@ -2570,6 +2583,44 @@ sections:
2570
2583
  `append_only` holds against the item's own author. That is the difference between a
2571
2584
  permission model and a CSS one.
2572
2585
 
2586
+ A schema can also open entries by relationship, and keep them from being shared:
2587
+
2588
+ ```yaml
2589
+ # foundation/schemas/submission.yml
2590
+ grantable: false # no entry is shared one at a time
2591
+ fields:
2592
+ course: { ref: '@/course', readable_with: edit } # the course's editors read it
2593
+ ```
2594
+
2595
+ `readable_with` — `read`, `edit` or `admin`, and only on a reference — opens the entry, to
2596
+ read and never to change, to whoever holds that level on the entry the reference points to:
2597
+ a submission's `course` at `edit` reaches the course's staff and none of its students. A row
2598
+ read that way comes back with `via: 'rbac'`. It is one step, and a backend refuses adding it
2599
+ to a field entries already fill. `grantable: false` keeps entries from being shared with a
2600
+ person or a team; omitted, they can be.
2601
+
2602
+ ### Acting as an administrator
2603
+
2604
+ On a site, an administrator's session reads what their role reaches — the operator reads
2605
+ every member's records — and changes nothing through the role until they confirm it is
2606
+ them. Their own records they edit, delete and share as always.
2607
+
2608
+ ```jsx
2609
+ const { available, elevated, until, elevate, lower, error } = useElevation()
2610
+ // available: the account holds a role that does more than read — offer it only then
2611
+ // elevate({ password, code }) — code when the account has a second factor; a refusal is error.kind === 'auth'
2612
+ // elevated, until: true while it lasts, and when it ends; lower() ends it early
2613
+ ```
2614
+
2615
+ - It lasts as long as the backend decides; `elevated` turns false by itself at `until`,
2616
+ and elevating again starts the time again. Nothing says whether the account has a
2617
+ second factor, so offer the code field to whoever might.
2618
+ - While it lasts `viewer.roles` names the role, and every record read before is read
2619
+ again, so `entity.can_edit` answers for the session as it is now.
2620
+ - An account with no password: `elevateWithProvider(provider)` goes to the provider's
2621
+ sign-in and back to the page.
2622
+ - It does not open the backend's administration — the Admin link does.
2623
+
2573
2624
  ### The Admin link
2574
2625
 
2575
2626
  ```jsx
@@ -2581,7 +2632,8 @@ const { adminHref } = useSession()
2581
2632
  the operator, and whoever else administers it — and is `null` for everyone else. It
2582
2633
  takes them there already signed in. ⛔ **Navigate to it, never `fetch` it**: the answer is
2583
2634
  a redirect carrying a one-time credential no script on the site is to hold. It decides
2584
- what to offer, not what is allowed — on the site the operator acts as a member.
2635
+ what to offer, not what is allowed — on the site the operator acts through their role
2636
+ only while elevated.
2585
2637
 
2586
2638
  ### The links the backend mails
2587
2639
 
@@ -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,54 +1,54 @@
1
1
  {
2
2
  "schemaVersion": 1,
3
- "generatedAt": "2026-10-13T20:52:47.988Z",
3
+ "generatedAt": "2026-10-15T01:14:19.629Z",
4
4
  "packages": {
5
5
  "@uniweb/api": {
6
- "version": "1.1.0"
6
+ "version": "1.2.0"
7
7
  },
8
8
  "@uniweb/build": {
9
- "version": "1.1.1"
9
+ "version": "1.2.0"
10
10
  },
11
11
  "@uniweb/content-reader": {
12
- "version": "1.2.8"
12
+ "version": "1.3.0"
13
13
  },
14
14
  "@uniweb/content-writer": {
15
- "version": "1.0.1"
15
+ "version": "1.1.0"
16
16
  },
17
17
  "@uniweb/core": {
18
- "version": "1.1.0"
18
+ "version": "1.2.0"
19
19
  },
20
20
  "@uniweb/frame-bridge": {
21
21
  "version": "1.0.2"
22
22
  },
23
23
  "@uniweb/icons": {
24
- "version": "1.0.2"
24
+ "version": "1.0.3"
25
25
  },
26
26
  "@uniweb/kit": {
27
- "version": "1.1.0"
27
+ "version": "1.2.0"
28
28
  },
29
29
  "@uniweb/loom": {
30
30
  "version": "1.1.0"
31
31
  },
32
32
  "@uniweb/press": {
33
- "version": "1.1.0"
33
+ "version": "1.1.1"
34
34
  },
35
35
  "@uniweb/projections": {
36
- "version": "1.1.0"
36
+ "version": "1.1.1"
37
37
  },
38
38
  "@uniweb/runtime": {
39
- "version": "1.1.0"
39
+ "version": "1.1.1"
40
40
  },
41
41
  "@uniweb/scene": {
42
42
  "version": "1.0.1"
43
43
  },
44
44
  "@uniweb/schemas": {
45
- "version": "1.0.1"
45
+ "version": "1.1.0"
46
46
  },
47
47
  "@uniweb/scholar": {
48
48
  "version": "1.1.0"
49
49
  },
50
50
  "@uniweb/semantic-parser": {
51
- "version": "1.5.1"
51
+ "version": "1.6.0"
52
52
  },
53
53
  "@uniweb/snapshot": {
54
54
  "version": "1.1.0"
@@ -57,10 +57,10 @@
57
57
  "version": "1.1.0"
58
58
  },
59
59
  "@uniweb/theming": {
60
- "version": "1.1.0"
60
+ "version": "1.2.0"
61
61
  },
62
62
  "@uniweb/unipress": {
63
- "version": "1.1.0"
63
+ "version": "1.1.1"
64
64
  }
65
65
  },
66
66
  "templates": {