@vespercms/admin 0.0.0-stage → 0.1.0-next.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.
Files changed (207) hide show
  1. package/README.md +119 -2
  2. package/dist/BodyEditor-_mkh0BSQ.js +3047 -0
  3. package/dist/a11y/FieldNotes.d.ts +23 -0
  4. package/dist/a11y/NotesDrawer.d.ts +23 -0
  5. package/dist/a11y/pdf.d.ts +3 -0
  6. package/dist/a11y/run.d.ts +3 -0
  7. package/dist/a11y/useNotes.d.ts +28 -0
  8. package/dist/app/App.d.ts +2 -0
  9. package/dist/app/Screen.d.ts +88 -0
  10. package/dist/app/UserMenu.d.ts +7 -0
  11. package/dist/app/api.d.ts +75 -0
  12. package/dist/app/appearance.d.ts +10 -0
  13. package/dist/app/browser-api.d.ts +17 -0
  14. package/dist/app/collection-icon.d.ts +12 -0
  15. package/dist/app/context.d.ts +23 -0
  16. package/dist/app/entries-changed.d.ts +11 -0
  17. package/dist/app/file-url.d.ts +13 -0
  18. package/dist/app/format.d.ts +4 -0
  19. package/dist/app/idb.d.ts +7 -0
  20. package/dist/app/load.d.ts +16 -0
  21. package/dist/app/media.d.ts +5 -0
  22. package/dist/app/navigation.d.ts +4 -0
  23. package/dist/app/pane-context.d.ts +19 -0
  24. package/dist/app/panes.d.ts +14 -0
  25. package/dist/app/router.d.ts +62 -0
  26. package/dist/body/BodyEditor.d.ts +42 -0
  27. package/dist/body/TableView.d.ts +2 -0
  28. package/dist/body/catalog.d.ts +47 -0
  29. package/dist/body/checks.d.ts +15 -0
  30. package/dist/body/chips.d.ts +31 -0
  31. package/dist/body/commands.d.ts +31 -0
  32. package/dist/body/document.d.ts +42 -0
  33. package/dist/body/insert.d.ts +69 -0
  34. package/dist/body/nodes.d.ts +38 -0
  35. package/dist/body/reveal.d.ts +13 -0
  36. package/dist/body/scroll.d.ts +16 -0
  37. package/dist/body/settings.d.ts +19 -0
  38. package/dist/body/shortcuts.d.ts +13 -0
  39. package/dist/body/table.d.ts +31 -0
  40. package/dist/body/ui.d.ts +30 -0
  41. package/dist/body/views.d.ts +9 -0
  42. package/dist/checks-C-0xDu-I.js +57 -0
  43. package/dist/cloud/CloudGate.d.ts +14 -0
  44. package/dist/cloud/access.d.ts +14 -0
  45. package/dist/cloud/changes.d.ts +26 -0
  46. package/dist/cloud/connection.d.ts +100 -0
  47. package/dist/cloud/editor.d.ts +44 -0
  48. package/dist/cloud/session.d.ts +62 -0
  49. package/dist/cloud/storage.d.ts +45 -0
  50. package/dist/editor/Field.d.ts +24 -0
  51. package/dist/editor/PagePicker.d.ts +20 -0
  52. package/dist/editor/arrange.d.ts +13 -0
  53. package/dist/editor/context.d.ts +23 -0
  54. package/dist/editor/copy.d.ts +9 -0
  55. package/dist/editor/reveal.d.ts +7 -0
  56. package/dist/editor/target.d.ts +21 -0
  57. package/dist/editor/values.d.ts +18 -0
  58. package/dist/geo-map-CT3OGD7O.js +110 -0
  59. package/dist/github/GitHubGate.d.ts +15 -0
  60. package/dist/github/api.d.ts +20 -0
  61. package/dist/github/cache.d.ts +2 -0
  62. package/dist/github/session.d.ts +35 -0
  63. package/dist/index.d.ts +3 -0
  64. package/dist/markdown-4fVeej3c.js +211 -0
  65. package/dist/media/MediaPicker.d.ts +28 -0
  66. package/dist/media/file-icon.d.ts +3 -0
  67. package/dist/media/formats.d.ts +23 -0
  68. package/dist/media/hygiene.d.ts +37 -0
  69. package/dist/media/upload.d.ts +31 -0
  70. package/dist/mount.d.ts +3 -0
  71. package/dist/pdf-m5s_uXJ3.js +24 -0
  72. package/dist/run-y4XRkg17.js +7 -0
  73. package/dist/schedule/ScheduleDialog.d.ts +28 -0
  74. package/dist/schedule/ScheduleSummary.d.ts +12 -0
  75. package/dist/schedule/ScheduledView.d.ts +8 -0
  76. package/dist/schedule/cloud.d.ts +21 -0
  77. package/dist/schedule/now.d.ts +5 -0
  78. package/dist/schedule/times.d.ts +35 -0
  79. package/dist/screens/EntryEditor.d.ts +13 -0
  80. package/dist/screens/EntryList.d.ts +16 -0
  81. package/dist/screens/Home.d.ts +6 -0
  82. package/dist/screens/ListPane.d.ts +6 -0
  83. package/dist/screens/MediaBrowser.d.ts +7 -0
  84. package/dist/screens/NotFound.d.ts +3 -0
  85. package/dist/screens/PageTree.d.ts +10 -0
  86. package/dist/screens/Redirects.d.ts +1 -0
  87. package/dist/screens/Unavailable.d.ts +8 -0
  88. package/dist/screens/status.d.ts +36 -0
  89. package/dist/shortcuts-CarKeDqp.js +4666 -0
  90. package/dist/studio.css +3 -0
  91. package/dist/studio.js +6449 -0
  92. package/dist/types.d.ts +76 -0
  93. package/dist/widgets/basic.d.ts +25 -0
  94. package/dist/widgets/blank-date.d.ts +8 -0
  95. package/dist/widgets/crops.d.ts +43 -0
  96. package/dist/widgets/focal-point.d.ts +15 -0
  97. package/dist/widgets/geo-map.d.ts +10 -0
  98. package/dist/widgets/geo.d.ts +16 -0
  99. package/dist/widgets/group.d.ts +41 -0
  100. package/dist/widgets/icon.d.ts +9 -0
  101. package/dist/widgets/image.d.ts +16 -0
  102. package/dist/widgets/markdown-lazy.d.ts +2 -0
  103. package/dist/widgets/markdown.d.ts +2 -0
  104. package/dist/widgets/reference.d.ts +12 -0
  105. package/dist/widgets/registry.d.ts +32 -0
  106. package/dist/widgets/special.d.ts +20 -0
  107. package/dist/widgets/union.d.ts +36 -0
  108. package/package.json +63 -4
  109. package/src/a11y/FieldNotes.tsx +56 -0
  110. package/src/a11y/NotesDrawer.tsx +81 -0
  111. package/src/a11y/pdf.ts +45 -0
  112. package/src/a11y/run.ts +12 -0
  113. package/src/a11y/useNotes.ts +139 -0
  114. package/src/app/App.tsx +156 -0
  115. package/src/app/Screen.tsx +416 -0
  116. package/src/app/UserMenu.tsx +95 -0
  117. package/src/app/api.ts +176 -0
  118. package/src/app/appearance.ts +54 -0
  119. package/src/app/browser-api.ts +173 -0
  120. package/src/app/collection-icon.ts +15 -0
  121. package/src/app/context.tsx +32 -0
  122. package/src/app/entries-changed.ts +48 -0
  123. package/src/app/file-url.tsx +44 -0
  124. package/src/app/format.ts +39 -0
  125. package/src/app/idb.ts +82 -0
  126. package/src/app/load.ts +40 -0
  127. package/src/app/media.ts +20 -0
  128. package/src/app/navigation.ts +4 -0
  129. package/src/app/pane-context.ts +25 -0
  130. package/src/app/panes.tsx +513 -0
  131. package/src/app/router.ts +158 -0
  132. package/src/body/BodyEditor.tsx +1139 -0
  133. package/src/body/TableView.tsx +700 -0
  134. package/src/body/catalog.ts +198 -0
  135. package/src/body/checks.ts +83 -0
  136. package/src/body/chips.ts +150 -0
  137. package/src/body/commands.ts +191 -0
  138. package/src/body/document.ts +388 -0
  139. package/src/body/insert.tsx +301 -0
  140. package/src/body/nodes.ts +287 -0
  141. package/src/body/reveal.ts +93 -0
  142. package/src/body/scroll.ts +53 -0
  143. package/src/body/settings.tsx +510 -0
  144. package/src/body/shortcuts.ts +31 -0
  145. package/src/body/table.ts +192 -0
  146. package/src/body/ui.ts +77 -0
  147. package/src/body/views.tsx +268 -0
  148. package/src/build.ts +6 -0
  149. package/src/cloud/CloudGate.tsx +245 -0
  150. package/src/cloud/access.ts +52 -0
  151. package/src/cloud/changes.tsx +474 -0
  152. package/src/cloud/connection.ts +281 -0
  153. package/src/cloud/editor.tsx +250 -0
  154. package/src/cloud/session.ts +289 -0
  155. package/src/cloud/storage.ts +212 -0
  156. package/src/editor/Field.tsx +182 -0
  157. package/src/editor/PagePicker.tsx +102 -0
  158. package/src/editor/arrange.ts +98 -0
  159. package/src/editor/context.tsx +31 -0
  160. package/src/editor/copy.ts +30 -0
  161. package/src/editor/reveal.ts +38 -0
  162. package/src/editor/target.ts +104 -0
  163. package/src/editor/values.ts +71 -0
  164. package/src/github/GitHubGate.tsx +298 -0
  165. package/src/github/api.ts +81 -0
  166. package/src/github/cache.ts +64 -0
  167. package/src/github/session.ts +146 -0
  168. package/src/index.ts +10 -0
  169. package/src/media/MediaPicker.tsx +302 -0
  170. package/src/media/file-icon.ts +17 -0
  171. package/src/media/formats.ts +262 -0
  172. package/src/media/hygiene.ts +220 -0
  173. package/src/media/upload.ts +47 -0
  174. package/src/mount.tsx +20 -0
  175. package/src/schedule/ScheduleDialog.tsx +303 -0
  176. package/src/schedule/ScheduleSummary.tsx +59 -0
  177. package/src/schedule/ScheduledView.tsx +294 -0
  178. package/src/schedule/cloud.ts +66 -0
  179. package/src/schedule/now.ts +18 -0
  180. package/src/schedule/times.ts +122 -0
  181. package/src/screens/EntryEditor.tsx +1817 -0
  182. package/src/screens/EntryList.tsx +272 -0
  183. package/src/screens/Home.tsx +131 -0
  184. package/src/screens/ListPane.tsx +477 -0
  185. package/src/screens/MediaBrowser.tsx +253 -0
  186. package/src/screens/NotFound.tsx +19 -0
  187. package/src/screens/PageTree.tsx +738 -0
  188. package/src/screens/Redirects.tsx +522 -0
  189. package/src/screens/Unavailable.tsx +27 -0
  190. package/src/screens/status.tsx +104 -0
  191. package/src/studio.css +168 -0
  192. package/src/types.ts +82 -0
  193. package/src/widgets/basic.tsx +315 -0
  194. package/src/widgets/blank-date.ts +32 -0
  195. package/src/widgets/crops.tsx +399 -0
  196. package/src/widgets/focal-point.tsx +240 -0
  197. package/src/widgets/geo-map.tsx +160 -0
  198. package/src/widgets/geo.tsx +170 -0
  199. package/src/widgets/group.tsx +827 -0
  200. package/src/widgets/icon.tsx +47 -0
  201. package/src/widgets/image.tsx +407 -0
  202. package/src/widgets/markdown-lazy.tsx +22 -0
  203. package/src/widgets/markdown.tsx +267 -0
  204. package/src/widgets/reference.tsx +196 -0
  205. package/src/widgets/registry.ts +104 -0
  206. package/src/widgets/special.tsx +290 -0
  207. package/src/widgets/union.tsx +233 -0
@@ -0,0 +1,289 @@
1
+ import {
2
+ CloudClient,
3
+ CloudError,
4
+ authorizeUrl,
5
+ createDpopKey,
6
+ createPkce,
7
+ type CloudStudioSettings,
8
+ type CloudTokens,
9
+ type CloudUser,
10
+ type Member,
11
+ } from '@vespercms/core';
12
+ import { keyValueStore, type KeyValueStore } from '../app/idb.ts';
13
+
14
+ /*
15
+ * Signing in to the cloud (spec §6.9, §7.2): a top-level redirect to the
16
+ * app's /authorize with PKCE, and back to `/admin/#/callback` with a
17
+ * one-time code, which the studio exchanges for tokens bound to a key it
18
+ * made (DPoP). No popup and no third-party cookie (D14).
19
+ *
20
+ * The key pair, whose private half can't be read back out, and the tokens
21
+ * live together in IndexedDB, so a reload or a second tab stays signed in
22
+ * until the refresh token lapses (DECISIONS.md, "The studio stays signed in
23
+ * with tokens bound to a key").
24
+ */
25
+
26
+ /** The cloud's settings, as the build writes them into the studio. */
27
+ export type CloudSettings = CloudStudioSettings & {
28
+ /** The site's schemas built for the browser. */
29
+ schemas: string;
30
+ };
31
+
32
+ export type StoredSession = {
33
+ key: CryptoKeyPair;
34
+ tokens: CloudTokens | null;
35
+ user?: CloudUser;
36
+ member?: Member;
37
+ siteName?: string;
38
+ };
39
+
40
+ /*
41
+ * A sign-in in progress is kept by its state in localStorage, so any tab on
42
+ * the site can finish it. Signing in by email link comes back in the tab the
43
+ * mail client opened, whose sessionStorage is its own: kept there, the
44
+ * sign-in was refused as one that didn't start here. A state no tab began is
45
+ * still refused.
46
+ */
47
+ const PENDING_PREFIX = 'vesper.cloud.pending.';
48
+ /** Long enough to ask for a sign-in link (15 minutes) and use it. */
49
+ const PENDING_TTL = 30 * 60 * 1000;
50
+
51
+ type Pending = { state: string; verifier: string; route: string; started: number };
52
+
53
+ let defaultStore: KeyValueStore<StoredSession> | null = null;
54
+ export function sessionStore(): KeyValueStore<StoredSession> {
55
+ defaultStore ??= keyValueStore<StoredSession>('vesper-cloud', 'sessions');
56
+ return defaultStore;
57
+ }
58
+
59
+ function pendingStorage(): Storage | null {
60
+ try {
61
+ return window.localStorage;
62
+ } catch {
63
+ return null;
64
+ }
65
+ }
66
+
67
+ function readPending(storage: Storage, key: string): Pending | null {
68
+ try {
69
+ return JSON.parse(storage.getItem(key) ?? 'null') as Pending | null;
70
+ } catch {
71
+ return null;
72
+ }
73
+ }
74
+
75
+ /** Drops sign-ins that never came back, so they don't pile up. */
76
+ function sweepPending(storage: Storage, now: number): void {
77
+ const keys: string[] = [];
78
+ for (let i = 0; i < storage.length; i++) {
79
+ const key = storage.key(i);
80
+ if (key?.startsWith(PENDING_PREFIX)) keys.push(key);
81
+ }
82
+ for (const key of keys) {
83
+ const pending = readPending(storage, key);
84
+ if (!pending || !(now - pending.started < PENDING_TTL)) storage.removeItem(key);
85
+ }
86
+ }
87
+
88
+ /** The sign-in this state began, once: it leaves storage as it's read. */
89
+ function takePending(state: string | null): Pending | null {
90
+ const storage = pendingStorage();
91
+ if (!storage) return null;
92
+ const now = Date.now();
93
+ sweepPending(storage, now);
94
+ if (!state) return null;
95
+ const key = `${PENDING_PREFIX}${state}`;
96
+ const pending = readPending(storage, key);
97
+ storage.removeItem(key);
98
+ return pending?.state === state ? pending : null;
99
+ }
100
+
101
+ /** Runs `work` alone among this browser's tabs, where Web Locks exist. */
102
+ function acrossTabs<T>(name: string, work: () => Promise<T>): Promise<T> {
103
+ const locks = typeof navigator === 'undefined' ? undefined : navigator.locks;
104
+ return locks ? locks.request(name, work) : work();
105
+ }
106
+
107
+ /** The stored key, made and kept on first use: a sign-in must exchange its code with the key it started with. */
108
+ export async function sessionKey(
109
+ project: string,
110
+ store: KeyValueStore<StoredSession> = sessionStore(),
111
+ ): Promise<StoredSession> {
112
+ const found = await store.get(project);
113
+ if (found) return found;
114
+ const session: StoredSession = { key: await createDpopKey(), tokens: null };
115
+ await store.put(project, session);
116
+ return session;
117
+ }
118
+
119
+ /** The studio's own address, which the app sends the browser back to. */
120
+ export function studioAddress(): string {
121
+ return `${window.location.origin}${window.location.pathname}`;
122
+ }
123
+
124
+ /** Goes to the app's sign-in. `route` is the studio's hash route to come back to. */
125
+ export async function startCloudSignIn(
126
+ settings: CloudSettings,
127
+ route: string,
128
+ store?: KeyValueStore<StoredSession>,
129
+ ): Promise<void> {
130
+ await sessionKey(settings.project, store);
131
+ const state = crypto.randomUUID();
132
+ const { verifier, challenge } = await createPkce();
133
+ const storage = pendingStorage();
134
+ if (storage) {
135
+ const started = Date.now();
136
+ sweepPending(storage, started);
137
+ storage.setItem(
138
+ `${PENDING_PREFIX}${state}`,
139
+ JSON.stringify({ state, verifier, route, started } satisfies Pending),
140
+ );
141
+ }
142
+ window.location.assign(
143
+ authorizeUrl(settings, { redirectUri: studioAddress(), state, challenge }),
144
+ );
145
+ }
146
+
147
+ /** The callback's parameters, when the app sent the browser back here. */
148
+ export function callbackParams(hash: string = window.location.hash): URLSearchParams | null {
149
+ const match = /^#\/callback\?(.*)$/.exec(hash);
150
+ return match ? new URLSearchParams(match[1]) : null;
151
+ }
152
+
153
+ export type CloudSignedIn = {
154
+ client: CloudClient;
155
+ user: CloudUser;
156
+ member: Member;
157
+ siteName: string;
158
+ };
159
+
160
+ export type CloudSignIn =
161
+ ({ ok: true; route: string } & CloudSignedIn) | { ok: false; message: string };
162
+
163
+ function clientFor(
164
+ settings: CloudSettings,
165
+ session: StoredSession,
166
+ store: KeyValueStore<StoredSession>,
167
+ fetcher?: typeof fetch,
168
+ ): CloudClient {
169
+ return new CloudClient({
170
+ settings,
171
+ key: session.key,
172
+ tokens: session.tokens,
173
+ ...(fetcher ? { fetch: fetcher } : {}),
174
+ onTokens: (tokens) => {
175
+ session.tokens = tokens;
176
+ void store.put(settings.project, session);
177
+ },
178
+ latest: async () => (await store.get(settings.project))?.tokens ?? null,
179
+ exclusive: (work) => acrossTabs(`vesper-cloud-renew:${settings.project}`, work),
180
+ });
181
+ }
182
+
183
+ /**
184
+ * Finishes a sign-in when the app sent the browser back here, or returns
185
+ * null when it didn't. The code leaves the address either way.
186
+ */
187
+ export async function finishCloudSignIn(
188
+ settings: CloudSettings,
189
+ options: { store?: KeyValueStore<StoredSession>; fetch?: typeof fetch } = {},
190
+ ): Promise<CloudSignIn | null> {
191
+ const params = callbackParams();
192
+ if (!params) return null;
193
+ const store = options.store ?? sessionStore();
194
+ const pending = takePending(params.get('state'));
195
+ const route = pending?.route && !pending.route.startsWith('#/callback') ? pending.route : '#/';
196
+ history.replaceState(history.state, '', `${window.location.pathname}${route}`);
197
+ const error = params.get('error');
198
+ if (error !== null) {
199
+ return {
200
+ ok: false,
201
+ message:
202
+ error === 'access_denied'
203
+ ? 'Your Vesper account doesn’t have access to this site. Ask the site’s admin to invite you.'
204
+ : 'Vesper didn’t sign you in. Try again.',
205
+ };
206
+ }
207
+ const code = params.get('code');
208
+ if (!pending || !code) {
209
+ return { ok: false, message: 'That sign-in didn’t start here. Try again.' };
210
+ }
211
+ const session = await sessionKey(settings.project, store);
212
+ const client = clientFor(settings, session, store, options.fetch);
213
+ try {
214
+ const tokens = await client.exchangeCode({
215
+ code,
216
+ verifier: pending.verifier,
217
+ redirectUri: studioAddress(),
218
+ });
219
+ Object.assign(session, {
220
+ user: tokens.user,
221
+ member: tokens.member,
222
+ siteName: tokens.site.name,
223
+ });
224
+ await store.put(settings.project, session);
225
+ return {
226
+ ok: true,
227
+ route,
228
+ client,
229
+ user: tokens.user,
230
+ member: tokens.member,
231
+ siteName: tokens.site.name,
232
+ };
233
+ } catch (thrown) {
234
+ return {
235
+ ok: false,
236
+ message:
237
+ thrown instanceof CloudError ? thrown.message : 'Vesper couldn’t be reached. Try again.',
238
+ };
239
+ }
240
+ }
241
+
242
+ export type Resumed =
243
+ ({ kind: 'signed-in' } & CloudSignedIn) | { kind: 'signed-out' } | { kind: 'unreachable' };
244
+
245
+ /**
246
+ * Picks up a sign-in this browser already has. The refresh brings fresh
247
+ * tokens and the person's current role and grants, which may have changed
248
+ * in the dashboard since.
249
+ */
250
+ export async function resumeCloudSession(
251
+ settings: CloudSettings,
252
+ options: { store?: KeyValueStore<StoredSession>; fetch?: typeof fetch } = {},
253
+ ): Promise<Resumed> {
254
+ const store = options.store ?? sessionStore();
255
+ const session = await store.get(settings.project);
256
+ if (!session?.tokens) return { kind: 'signed-out' };
257
+ const client = clientFor(settings, session, store, options.fetch);
258
+ try {
259
+ const tokens = await client.refresh();
260
+ Object.assign(session, {
261
+ user: tokens.user,
262
+ member: tokens.member,
263
+ siteName: tokens.site.name,
264
+ });
265
+ await store.put(settings.project, session);
266
+ return {
267
+ kind: 'signed-in',
268
+ client,
269
+ user: tokens.user,
270
+ member: tokens.member,
271
+ siteName: tokens.site.name,
272
+ };
273
+ } catch (thrown) {
274
+ if (thrown instanceof CloudError && thrown.status >= 400 && thrown.status < 500) {
275
+ return { kind: 'signed-out' };
276
+ }
277
+ return { kind: 'unreachable' };
278
+ }
279
+ }
280
+
281
+ /** Ends the sign-in, here and in the cloud. The key goes too: the next sign-in makes a new one. */
282
+ export async function endCloudSession(
283
+ settings: CloudSettings,
284
+ client: CloudClient | null,
285
+ store: KeyValueStore<StoredSession> = sessionStore(),
286
+ ): Promise<void> {
287
+ await client?.signOut();
288
+ await store.delete(settings.project);
289
+ }
@@ -0,0 +1,212 @@
1
+ import {
2
+ encodeChange,
3
+ fromBase64,
4
+ overlayEntries,
5
+ type BlobCache,
6
+ type ChangeSet,
7
+ type CloudClient,
8
+ type CommitResult,
9
+ type Draft,
10
+ type FileChange,
11
+ type StorageAdapter,
12
+ type StorageEntry,
13
+ type StoredFile,
14
+ } from '@vespercms/core';
15
+ import { gitBlobSha } from '@vespercms/core';
16
+
17
+ /*
18
+ * The cloud's storage (spec §7.4): the site as its drafts show it, read
19
+ * through the cloud's git proxy, and every save a draft rather than a
20
+ * commit. Core's services run over it unchanged, so a draft is exactly what
21
+ * a local save would have written (DECISIONS.md, "A draft is the file the
22
+ * save would write").
23
+ *
24
+ * Bytes are read by version (a git blob SHA), from IndexedDB when this
25
+ * browser has them and otherwise in batches from the API. A SHA always means
26
+ * the same bytes, so nothing cached goes stale.
27
+ */
28
+
29
+ type Waiter = { resolve: (bytes: Uint8Array | null) => void; reject: (error: unknown) => void };
30
+
31
+ type Snapshot = {
32
+ commit: string;
33
+ published: StorageEntry[];
34
+ drafts: Draft[];
35
+ draftsVersion: string;
36
+ at: number;
37
+ };
38
+
39
+ export type CloudStorageOptions = {
40
+ client: CloudClient;
41
+ cache?: BlobCache;
42
+ /** How long a snapshot of the tree and drafts is trusted. */
43
+ snapshotTtlMs?: number;
44
+ /** Told whenever the drafts change, here or as the API reports them. */
45
+ onDrafts?: (drafts: readonly Draft[], version: string) => void;
46
+ now?: () => number;
47
+ };
48
+
49
+ export class CloudStorage implements StorageAdapter {
50
+ readonly kind = 'cloud' as const;
51
+ private readonly client: CloudClient;
52
+ private readonly cache: BlobCache | undefined;
53
+ private readonly ttl: number;
54
+ private readonly onDrafts: CloudStorageOptions['onDrafts'];
55
+ private readonly now: () => number;
56
+ private snapshotting: Promise<Snapshot> | null = null;
57
+ private current: Snapshot | null = null;
58
+ /** New files waiting for the next save (`ChangeSet.defer`): an upload goes out with its entry. */
59
+ private readonly pending = new Map<string, { bytes: Uint8Array; version: string }>();
60
+ private wanted = new Map<string, Waiter[]>();
61
+ private batch: Promise<void> | null = null;
62
+
63
+ constructor(options: CloudStorageOptions) {
64
+ this.client = options.client;
65
+ this.cache = options.cache;
66
+ this.ttl = options.snapshotTtlMs ?? 10_000;
67
+ this.onDrafts = options.onDrafts;
68
+ this.now = options.now ?? (() => Date.now());
69
+ }
70
+
71
+ /** Forgets the snapshot, so the next read asks the cloud again. */
72
+ invalidate(): void {
73
+ this.current = null;
74
+ }
75
+
76
+ private async snapshot(): Promise<Snapshot> {
77
+ if (this.current && this.now() - this.current.at < this.ttl) return this.current;
78
+ this.snapshotting ??= (async () => {
79
+ try {
80
+ const [tree, drafts] = await Promise.all([this.client.tree(), this.client.drafts()]);
81
+ const next: Snapshot = {
82
+ commit: tree.commit,
83
+ published: tree.entries,
84
+ drafts: drafts.drafts,
85
+ draftsVersion: drafts.version,
86
+ at: this.now(),
87
+ };
88
+ const changed = this.current?.draftsVersion !== next.draftsVersion;
89
+ this.current = next;
90
+ if (changed) this.onDrafts?.(next.drafts, next.draftsVersion);
91
+ return next;
92
+ } finally {
93
+ this.snapshotting = null;
94
+ }
95
+ })();
96
+ return this.snapshotting;
97
+ }
98
+
99
+ /** The drafts as of the latest snapshot, asking the cloud when that's old. */
100
+ async drafts(): Promise<{ drafts: Draft[]; version: string; commit: string }> {
101
+ const snap = await this.snapshot();
102
+ return { drafts: snap.drafts, version: snap.draftsVersion, commit: snap.commit };
103
+ }
104
+
105
+ async head(): Promise<{ version: string }> {
106
+ const snap = await this.snapshot();
107
+ return { version: `${snap.commit}:${snap.draftsVersion}` };
108
+ }
109
+
110
+ async list(options: { prefix?: string } = {}): Promise<StorageEntry[]> {
111
+ const snap = await this.snapshot();
112
+ const entries = overlayEntries(snap.published, snap.drafts);
113
+ for (const [path, held] of this.pending) {
114
+ if (!entries.some((e) => e.path === path)) {
115
+ entries.push({ path, version: held.version, size: held.bytes.length });
116
+ }
117
+ }
118
+ const prefix = options.prefix;
119
+ return prefix ? entries.filter((e) => e.path.startsWith(prefix)) : entries;
120
+ }
121
+
122
+ async read(path: string): Promise<StoredFile | null> {
123
+ const held = this.pending.get(path);
124
+ if (held) return { path, bytes: held.bytes, version: held.version };
125
+ const entry = (await this.list()).find((e) => e.path === path);
126
+ if (!entry) return null;
127
+ const bytes = await this.bytes(entry.version);
128
+ return bytes ? { path, bytes, version: entry.version } : null;
129
+ }
130
+
131
+ /** Bytes by version: the cache first, then batched requests to the API. */
132
+ private async bytes(version: string): Promise<Uint8Array | null> {
133
+ const cached = await this.cache?.get(version);
134
+ if (cached) return cached;
135
+ return new Promise((resolve, reject) => {
136
+ const waiting = this.wanted.get(version);
137
+ if (waiting) waiting.push({ resolve, reject });
138
+ else this.wanted.set(version, [{ resolve, reject }]);
139
+ this.batch ??= Promise.resolve().then(() => this.fetchWanted());
140
+ });
141
+ }
142
+
143
+ private async fetchWanted(): Promise<void> {
144
+ const wanted = this.wanted;
145
+ this.wanted = new Map();
146
+ this.batch = null;
147
+ const versions = [...wanted.keys()];
148
+ try {
149
+ for (let i = 0; i < versions.length; i += 100) {
150
+ const chunk = versions.slice(i, i + 100);
151
+ const { blobs } = await this.client.blobs(chunk);
152
+ for (const version of chunk) {
153
+ const text = blobs[version];
154
+ const bytes = text === undefined ? null : fromBase64(text);
155
+ // Only bytes that match their SHA are used or kept.
156
+ const verified = bytes && (await gitBlobSha(bytes)) === version ? bytes : null;
157
+ if (verified) await this.cache?.put(version, verified);
158
+ for (const waiter of wanted.get(version) ?? []) waiter.resolve(verified);
159
+ wanted.delete(version);
160
+ }
161
+ }
162
+ } catch (error) {
163
+ // A file the cloud couldn't send isn't a missing file: every read waiting on it fails.
164
+ for (const waiters of wanted.values()) {
165
+ for (const waiter of waiters) waiter.reject(error);
166
+ }
167
+ }
168
+ }
169
+
170
+ async commit(changeSet: ChangeSet): Promise<CommitResult> {
171
+ if (changeSet.defer) {
172
+ for (const change of changeSet.changes) {
173
+ if (change.op === 'put') {
174
+ this.pending.set(change.path, {
175
+ bytes: change.bytes,
176
+ version: await gitBlobSha(change.bytes),
177
+ });
178
+ }
179
+ }
180
+ return { ok: true, version: (await this.head()).version };
181
+ }
182
+ // Held uploads go out with this save, unless it deletes them.
183
+ const deleted = new Set(
184
+ changeSet.changes.flatMap((c) =>
185
+ c.op === 'delete' ? [c.path] : c.op === 'move' ? [c.from] : [],
186
+ ),
187
+ );
188
+ const held: FileChange[] = [...this.pending]
189
+ .filter(([path]) => !deleted.has(path))
190
+ .map(([path, file]) => ({ op: 'put', path, bytes: file.bytes, expectedVersion: null }));
191
+ const changes = [
192
+ ...held,
193
+ ...changeSet.changes.filter((c) => !(c.op === 'delete' && this.pending.has(c.path))),
194
+ ];
195
+ const answer = await this.client.saveDraft({
196
+ changes: changes.map(encodeChange),
197
+ message: changeSet.message,
198
+ });
199
+ if (!answer.ok) {
200
+ if (answer.reason === 'conflict')
201
+ return { ok: false, reason: 'conflict', conflicts: answer.conflicts };
202
+ return { ok: false, reason: 'rejected', message: answer.message };
203
+ }
204
+ this.pending.clear();
205
+ for (const change of changes) {
206
+ if (change.op === 'put') await this.cache?.put(await gitBlobSha(change.bytes), change.bytes);
207
+ }
208
+ this.invalidate();
209
+ const snap = await this.snapshot();
210
+ return { ok: true, version: `${snap.commit}:${snap.draftsVersion}` };
211
+ }
212
+ }
@@ -0,0 +1,182 @@
1
+ import type { FieldModel } from '@vespercms/core';
2
+ import { cx, OptionalMark } from '@vespercms/ui';
3
+ import { Component, Suspense, type ReactNode } from 'react';
4
+ import { useStudio } from '../app/context.tsx';
5
+ import type { WidgetProps } from '../types.ts';
6
+ import { builtinWidget } from '../widgets/registry.ts';
7
+ import { FieldNotes } from '../a11y/FieldNotes.tsx';
8
+ import { useEditor } from './context.tsx';
9
+ import { fieldId, getIn, pathKey, type ValuePath } from './values.ts';
10
+
11
+ export type FieldProps = {
12
+ field: FieldModel;
13
+ /** Where the value lives in the entry (concrete indexes for array items). */
14
+ path: ValuePath;
15
+ /** Replaces the field's label, e.g. "Speaker 2" for an array item. */
16
+ label?: string;
17
+ /**
18
+ * A list item. An item of objects or images sits in its row's panel, which
19
+ * names and frames it, so its legend is for screen readers only. An item is
20
+ * never marked optional: it exists or it doesn't.
21
+ */
22
+ item?: 'row' | 'inline';
23
+ /** A top-level object or list is a section, except where the place already has its own heading (Details). */
24
+ asSection?: boolean;
25
+ };
26
+
27
+ /**
28
+ * One field: its label, help and error text, and the widget that edits it
29
+ * (spec §6.3.3: the editor renders the label, the widget the control).
30
+ * Group widgets (objects, lists, choices) render as a fieldset whose legend
31
+ * is the label. Errors are linked to the control with aria-describedby.
32
+ */
33
+ export function Field({ field, path, label, item, asSection = true }: FieldProps) {
34
+ const editor = useEditor();
35
+ const { widgets } = useStudio();
36
+ const id = fieldId(path, editor.idPrefix);
37
+ const errors = editor.issues.get(pathKey(path)) ?? [];
38
+ const Custom = field.widget ? widgets[field.widget] : undefined;
39
+ const builtin = builtinWidget(field);
40
+ // Notes about a child the widget draws its own control for (an image's alt
41
+ // text) are shown by the widget, beside that control.
42
+ const childControls = Custom ? undefined : builtin.childControls;
43
+ const notes = (editor.notes?.get(pathKey(path)) ?? []).filter((note) => {
44
+ const child = note.target.kind === 'field' ? note.target.path[path.length] : undefined;
45
+ return !(typeof child === 'string' && childControls && child in childControls);
46
+ });
47
+ const helpId = field.description ? `${id}-help` : undefined;
48
+ const errorId = errors.length > 0 ? `${id}-error` : undefined;
49
+ const notesId = notes.length > 0 ? `${id}-a11y` : undefined;
50
+ const describedBy = [helpId, errorId, notesId].filter(Boolean).join(' ') || undefined;
51
+
52
+ const props: WidgetProps & { path: ValuePath } = {
53
+ value: getIn(editor.values, path),
54
+ onChange: (value) => {
55
+ editor.setValue(path, value as never);
56
+ },
57
+ field,
58
+ id,
59
+ describedBy,
60
+ invalid: errors.length > 0,
61
+ disabled: editor.disabled || field.readOnly === true,
62
+ path,
63
+ };
64
+
65
+ const own = item === 'row' ? { ...props, inRow: true } : props;
66
+ const control = Custom ? (
67
+ <WidgetBoundary fallback={<builtin.component {...own} />} name={field.widget ?? ''}>
68
+ <Suspense fallback={<p className="text-small text-content-muted">Loading…</p>}>
69
+ <Custom {...props} />
70
+ </Suspense>
71
+ </WidgetBoundary>
72
+ ) : (
73
+ <builtin.component {...own} />
74
+ );
75
+
76
+ const text = label ?? field.label;
77
+ // Most fields are required, so the few that aren't say so (DECISIONS.md,
78
+ // "The studio marks what's optional"). A switch is never empty, so it's never optional.
79
+ const mark =
80
+ !field.required && !item && field.kind !== 'boolean' && field.readOnly !== true ? (
81
+ <OptionalMark />
82
+ ) : null;
83
+ const help = helpId ? (
84
+ <p id={helpId} className="text-small text-content-muted">
85
+ {field.description}
86
+ </p>
87
+ ) : null;
88
+ const error = errorId ? (
89
+ <p id={errorId} className="text-small text-danger">
90
+ {errors.join(' ')}
91
+ </p>
92
+ ) : null;
93
+ const noteList = notesId ? <FieldNotes id={notesId} notes={notes} /> : null;
94
+
95
+ if (!Custom && builtin.group) {
96
+ const section = builtin.section && path.length === 1 && asSection && !editor.idPrefix && !item;
97
+ // Objects inside the form are set off by an indent guide, never a frame (spec §10.2).
98
+ const guided =
99
+ builtin.section &&
100
+ (field.kind === 'object' || field.kind === 'union') &&
101
+ !section &&
102
+ item !== 'row';
103
+ const fieldset = (
104
+ <fieldset
105
+ aria-describedby={describedBy}
106
+ className={cx(
107
+ 'flex min-w-0 flex-col gap-2',
108
+ section && 'gap-3',
109
+ guided && 'border-l border-border pl-4',
110
+ builtin.group === 'boxed' && !item && 'rounded-md border border-border p-4',
111
+ )}
112
+ >
113
+ <legend
114
+ id={`${id}-label`}
115
+ tabIndex={-1}
116
+ className={cx(
117
+ 'mb-1 outline-none',
118
+ // The row above it shows the item's name; screen readers still hear the group's.
119
+ item === 'row' ? 'sr-only' : 'text-label text-content',
120
+ )}
121
+ >
122
+ {section ? (
123
+ <h2 className="text-heading-3 text-content">
124
+ {text}
125
+ {mark}
126
+ </h2>
127
+ ) : (
128
+ <>
129
+ {text}
130
+ {mark}
131
+ </>
132
+ )}
133
+ </legend>
134
+ {help}
135
+ {control}
136
+ {error}
137
+ {noteList}
138
+ </fieldset>
139
+ );
140
+ // A section starts after a hairline, with more space above it than between fields.
141
+ return section ? <div className="mt-4 border-t border-border pt-6">{fieldset}</div> : fieldset;
142
+ }
143
+ return (
144
+ <div className="flex min-w-0 flex-col gap-2">
145
+ <label id={`${id}-label`} htmlFor={id} className="text-label text-content">
146
+ {text}
147
+ {mark}
148
+ </label>
149
+ {control}
150
+ {help}
151
+ {error}
152
+ {noteList}
153
+ </div>
154
+ );
155
+ }
156
+
157
+ /**
158
+ * A custom widget that throws falls back to the built-in control for the
159
+ * field, with a note, instead of taking the whole editor down.
160
+ */
161
+ class WidgetBoundary extends Component<
162
+ { fallback: ReactNode; name: string; children: ReactNode },
163
+ { failed: boolean }
164
+ > {
165
+ override state = { failed: false };
166
+
167
+ static getDerivedStateFromError(): { failed: boolean } {
168
+ return { failed: true };
169
+ }
170
+
171
+ override render() {
172
+ if (!this.state.failed) return this.props.children;
173
+ return (
174
+ <>
175
+ <p className="text-small text-content-muted">
176
+ The “{this.props.name}” control couldn’t load, so this is a plain one.
177
+ </p>
178
+ {this.props.fallback}
179
+ </>
180
+ );
181
+ }
182
+ }