@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,298 @@
1
+ import { Button } from '@vespercms/ui';
2
+ import { useCallback, useEffect, useMemo, useState } from 'react';
3
+ import { App } from '../app/App.tsx';
4
+ import type { StudioApi } from '../app/api.ts';
5
+ import type { Account, StudioOptions } from '../types.ts';
6
+ import { createGitHubApi } from './api.ts';
7
+ import { indexedDbCache } from './cache.ts';
8
+ import {
9
+ finishSignIn,
10
+ readSession,
11
+ signOut,
12
+ startSignIn,
13
+ type GitHubStudioSettings,
14
+ type Session,
15
+ } from './session.ts';
16
+
17
+ /*
18
+ * GitHub mode's way in (spec §6.8, §6.9): finish a sign-in GitHub sent back
19
+ * here, check the editor can push to the repository, then the studio with
20
+ * GitHub as its back end. One primary action per screen (spec §10.4).
21
+ */
22
+
23
+ type GateOptions = Omit<StudioOptions, 'api' | 'github' | 'account' | 'cloud'>;
24
+
25
+ const defaultFetch: typeof fetch = (input, init) => fetch(input, init);
26
+ const noProps = () => Promise.resolve({});
27
+
28
+ type State =
29
+ | { kind: 'checking' }
30
+ | { kind: 'signed-out'; message?: string }
31
+ | { kind: 'no-access'; login: string }
32
+ /** GitHub answered 404: most often the site's GitHub App isn't installed on the repository. */
33
+ | { kind: 'not-found'; login: string }
34
+ | { kind: 'error'; message: string }
35
+ | {
36
+ kind: 'ready';
37
+ session: Session;
38
+ login: string;
39
+ name: string;
40
+ picture: string;
41
+ /** The repository is public: a scheduled change can be read there early (spec §6.17). */
42
+ publicRepository: boolean;
43
+ };
44
+
45
+ export function GitHubGate({
46
+ settings,
47
+ options,
48
+ fetcher = defaultFetch,
49
+ }: {
50
+ settings: GitHubStudioSettings;
51
+ options: GateOptions;
52
+ fetcher?: typeof fetch;
53
+ }) {
54
+ const [state, setState] = useState<State>({ kind: 'checking' });
55
+ const [expired, setExpired] = useState(false);
56
+ const [attempt, setAttempt] = useState(0);
57
+ const apiUrl = (settings.apiUrl ?? 'https://api.github.com').replace(/\/+$/, '');
58
+
59
+ useEffect(() => {
60
+ let live = true;
61
+ const set = (next: State) => {
62
+ if (live) setState(next);
63
+ };
64
+ void (async () => {
65
+ const finished = await finishSignIn(settings, fetcher);
66
+ if (finished && !finished.ok) {
67
+ set({ kind: 'signed-out', message: finished.message });
68
+ return;
69
+ }
70
+ const session = finished?.session ?? readSession();
71
+ if (!session) {
72
+ set({ kind: 'signed-out' });
73
+ return;
74
+ }
75
+ const headers = {
76
+ Authorization: `Bearer ${session.token}`,
77
+ Accept: 'application/vnd.github+json',
78
+ };
79
+ try {
80
+ const repo = await fetcher(
81
+ `${apiUrl}/repos/${encodeURIComponent(settings.owner)}/${encodeURIComponent(settings.repo)}`,
82
+ { headers },
83
+ );
84
+ if (repo.status === 401) {
85
+ signOut();
86
+ set({ kind: 'signed-out', message: 'Your GitHub sign-in has expired. Sign in again.' });
87
+ return;
88
+ }
89
+ const user = await fetcher(`${apiUrl}/user`, { headers });
90
+ const profile = user.ok
91
+ ? ((await user.json()) as { login?: string; name?: string | null; avatar_url?: string })
92
+ : {};
93
+ const login = profile.login ?? '';
94
+ // A GitHub App's sign-in sees only repositories the app is installed on,
95
+ // so a 404 isn't the editor's access: blaming it sent an owner to ask
96
+ // themselves for write access (DECISIONS.md, "GitHub mode tells a missing
97
+ // install apart from missing access").
98
+ if (repo.status === 404) {
99
+ set({ kind: 'not-found', login });
100
+ return;
101
+ }
102
+ if (!repo.ok) {
103
+ set({
104
+ kind: 'error',
105
+ message: `GitHub couldn’t open ${settings.owner}/${settings.repo} just now. Try again in a few minutes.`,
106
+ });
107
+ return;
108
+ }
109
+ const details = (await repo.json()) as {
110
+ permissions?: { push?: boolean };
111
+ private?: boolean;
112
+ };
113
+ const permissions = details.permissions;
114
+ if (!permissions?.push) {
115
+ set({ kind: 'no-access', login });
116
+ return;
117
+ }
118
+ set({
119
+ kind: 'ready',
120
+ session,
121
+ login,
122
+ name: profile.name?.trim() || `@${login}`,
123
+ picture: avatarAt(profile.avatar_url ?? '', 64),
124
+ publicRepository: details.private === false,
125
+ });
126
+ } catch {
127
+ set({
128
+ kind: 'error',
129
+ message: 'GitHub couldn’t be reached. Check the connection and try again.',
130
+ });
131
+ }
132
+ })();
133
+ return () => {
134
+ live = false;
135
+ };
136
+ // `attempt` reruns the check after "Try again".
137
+ }, [settings, apiUrl, fetcher, attempt]);
138
+
139
+ const signIn = useCallback(() => {
140
+ void startSignIn(settings, window.location.hash);
141
+ }, [settings]);
142
+ const tryAgain = useCallback(() => {
143
+ setState({ kind: 'checking' });
144
+ setAttempt((n) => n + 1);
145
+ }, []);
146
+ const leave = useCallback(() => {
147
+ signOut();
148
+ setExpired(false);
149
+ setState({ kind: 'signed-out' });
150
+ }, []);
151
+
152
+ const session = state.kind === 'ready' ? state.session : null;
153
+ // One API per session, so its cache and services last the whole visit.
154
+ const api = useMemo<StudioApi | null>(
155
+ () =>
156
+ session
157
+ ? createGitHubApi({
158
+ settings,
159
+ token: () => session.token,
160
+ manifest: options.manifest,
161
+ loadMdxProps: options.loadMdxProps ?? noProps,
162
+ ...(options.loadUrlFunctions ? { loadUrlFunctions: options.loadUrlFunctions } : {}),
163
+ ...(options.routes ? { routes: options.routes } : {}),
164
+ cache: indexedDbCache(),
165
+ onExpired: () => {
166
+ setExpired(true);
167
+ },
168
+ fetch: fetcher,
169
+ })
170
+ : null,
171
+ [session, settings, options, fetcher],
172
+ );
173
+ const account = useMemo<Account | null>(
174
+ () =>
175
+ state.kind === 'ready'
176
+ ? {
177
+ label: `@${state.login}`,
178
+ name: state.name,
179
+ ...(state.picture ? { picture: state.picture } : {}),
180
+ description: `Signed in to GitHub as @${state.login}`,
181
+ signOutLabel: 'Sign out of GitHub',
182
+ signOut: leave,
183
+ signInAgain: signIn,
184
+ expired,
185
+ ...(state.publicRepository ? { publicRepository: true } : {}),
186
+ }
187
+ : null,
188
+ [state, leave, signIn, expired],
189
+ );
190
+
191
+ useEffect(() => {
192
+ // Signed in, each screen names the page itself.
193
+ if (state.kind !== 'ready')
194
+ document.title = `${titleOf(state)} · ${options.siteName ?? 'Vesper'}`;
195
+ }, [state, options.siteName]);
196
+
197
+ if (api && account) return <App {...options} api={api} account={account} />;
198
+
199
+ const repo = `${settings.owner}/${settings.repo}`;
200
+ return (
201
+ <div className="afterglow min-h-screen">
202
+ <main id="main" className="mx-auto flex max-w-2xl flex-col gap-4 px-4 py-16">
203
+ <h1 className="text-heading-1 text-content">{titleOf(state)}</h1>
204
+ {state.kind === 'checking' && (
205
+ <p role="status" className="text-content-muted">
206
+ Opening the studio…
207
+ </p>
208
+ )}
209
+ {state.kind === 'signed-out' && (
210
+ <>
211
+ {state.message && (
212
+ <p role="alert" className="rounded-md border border-danger bg-surface-raised p-4">
213
+ {state.message}
214
+ </p>
215
+ )}
216
+ <p>You’ll publish to {repo} on GitHub. Changes go live after the site rebuilds.</p>
217
+ <div>
218
+ <Button variant="primary" onPress={signIn}>
219
+ Sign in with GitHub
220
+ </Button>
221
+ </div>
222
+ </>
223
+ )}
224
+ {state.kind === 'no-access' && (
225
+ <>
226
+ <p>
227
+ {state.login ? `You’re signed in to GitHub as @${state.login}. ` : ''}Ask the site’s
228
+ owner to give your account write access to {repo}.
229
+ </p>
230
+ <div>
231
+ <Button onPress={leave}>Sign out of GitHub</Button>
232
+ </div>
233
+ </>
234
+ )}
235
+ {state.kind === 'not-found' && (
236
+ <>
237
+ <p>
238
+ {state.login ? `You’re signed in to GitHub as @${state.login}. ` : ''}The site’s
239
+ GitHub App may not be installed on {repo}, or your account may not be able to see it.
240
+ </p>
241
+ <p>
242
+ The site’s owner installs the app from its settings on GitHub, under Install App, then
243
+ tries again here. If it’s installed already, ask the owner to give your account write
244
+ access to {repo}.
245
+ </p>
246
+ <div className="flex flex-wrap gap-3">
247
+ <Button variant="primary" onPress={tryAgain}>
248
+ Try again
249
+ </Button>
250
+ <Button onPress={leave}>Sign out of GitHub</Button>
251
+ </div>
252
+ </>
253
+ )}
254
+ {state.kind === 'error' && (
255
+ <>
256
+ <p role="alert">{state.message}</p>
257
+ <div>
258
+ <Button variant="primary" onPress={tryAgain}>
259
+ Try again
260
+ </Button>
261
+ </div>
262
+ </>
263
+ )}
264
+ </main>
265
+ </div>
266
+ );
267
+ }
268
+
269
+ function titleOf(state: State): string {
270
+ switch (state.kind) {
271
+ case 'checking':
272
+ return 'Opening the studio';
273
+ case 'no-access':
274
+ return 'Your GitHub account can’t change this site';
275
+ case 'not-found':
276
+ return 'GitHub can’t find this site’s repository';
277
+ case 'error':
278
+ return 'GitHub couldn’t be reached';
279
+ default:
280
+ return 'Sign in to edit this site';
281
+ }
282
+ }
283
+
284
+ /**
285
+ * GitHub's picture at a size (`s`), or nothing when GitHub gave no address.
286
+ * It loads from GitHub's image host, which GitHub mode already trusts with
287
+ * every read and save (DECISIONS.md, "GitHub mode loads the editor's picture from GitHub").
288
+ */
289
+ export function avatarAt(address: string, size: number): string {
290
+ if (!address) return '';
291
+ try {
292
+ const url = new URL(address);
293
+ url.searchParams.set('s', String(size));
294
+ return url.href;
295
+ } catch {
296
+ return '';
297
+ }
298
+ }
@@ -0,0 +1,81 @@
1
+ import {
2
+ GitHubError,
3
+ GitHubStorage,
4
+ type BlobCache,
5
+ type Manifest,
6
+ type UrlEntry,
7
+ } from '@vespercms/core';
8
+ import { ApiError, type StudioApi } from '../app/api.ts';
9
+ import { createBrowserApi } from '../app/browser-api.ts';
10
+ import type { GitHubStudioSettings } from './session.ts';
11
+
12
+ /*
13
+ * GitHub mode's back end (spec §6.8): core's services in the browser over
14
+ * the repository's branch (app/browser-api.ts). Every save is a commit.
15
+ */
16
+
17
+ export type GitHubApiOptions = {
18
+ settings: GitHubStudioSettings;
19
+ /** The current token. */
20
+ token: () => string;
21
+ manifest: Manifest;
22
+ loadMdxProps: () => Promise<Readonly<Record<string, unknown>>>;
23
+ loadUrlFunctions?: () => Promise<Readonly<Record<string, (entry: UrlEntry) => unknown>>>;
24
+ /** The site's static routes at build, for collision checks. */
25
+ routes?: readonly (readonly [string, string])[];
26
+ cache?: BlobCache;
27
+ /** Called when GitHub says the sign-in has expired. */
28
+ onExpired?: () => void;
29
+ fetch?: typeof fetch;
30
+ };
31
+
32
+ export function createGitHubApi(options: GitHubApiOptions): StudioApi {
33
+ const { settings } = options;
34
+ const storage = new GitHubStorage({
35
+ owner: settings.owner,
36
+ repo: settings.repo,
37
+ branch: settings.branch,
38
+ root: settings.root,
39
+ token: options.token,
40
+ ...(settings.apiUrl ? { apiUrl: settings.apiUrl } : {}),
41
+ ...(options.cache ? { cache: options.cache } : {}),
42
+ ...(options.fetch ? { fetch: options.fetch } : {}),
43
+ });
44
+ return createBrowserApi({
45
+ storage,
46
+ manifest: options.manifest,
47
+ schemas: settings.schemas,
48
+ loadMdxProps: options.loadMdxProps,
49
+ ...(options.loadUrlFunctions ? { loadUrlFunctions: options.loadUrlFunctions } : {}),
50
+ ...(options.routes ? { routes: options.routes } : {}),
51
+ publishesOnSave: true,
52
+ mapError: (error) => apiError(error, options.onExpired),
53
+ });
54
+ }
55
+
56
+ /** GitHub's errors as the studio shows them: plain words, and a status the screens understand. */
57
+ export function apiError(error: unknown, onExpired?: () => void): unknown {
58
+ if (!(error instanceof GitHubError)) return error;
59
+ switch (error.kind) {
60
+ case 'auth':
61
+ onExpired?.();
62
+ return new ApiError(401, 'Your GitHub sign-in has expired. Sign in again to publish.');
63
+ case 'rate-limit':
64
+ return new ApiError(
65
+ 429,
66
+ error.retryAt
67
+ ? `GitHub asked the studio to slow down. Try again after ${error.retryAt.toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' })}.`
68
+ : 'GitHub asked the studio to slow down. Try again in a few minutes.',
69
+ );
70
+ case 'network':
71
+ return new ApiError(0, error.message);
72
+ case 'forbidden':
73
+ case 'not-found':
74
+ return new ApiError(
75
+ error.status,
76
+ 'Your GitHub account can’t reach this site’s repository. Ask its owner to add you.',
77
+ );
78
+ default:
79
+ return new ApiError(502, 'GitHub had a problem. Try again in a moment.');
80
+ }
81
+ }
@@ -0,0 +1,64 @@
1
+ import type { BlobCache } from '@vespercms/core';
2
+
3
+ /*
4
+ * File bytes by git blob SHA, kept in IndexedDB between visits. A SHA always
5
+ * means the same bytes, so nothing here ever goes stale: a later visit reads
6
+ * from GitHub only the files that changed. Without IndexedDB (a private
7
+ * window, blocked storage) it quietly does nothing.
8
+ */
9
+
10
+ const DB = 'vesper-github';
11
+ const STORE = 'blobs';
12
+
13
+ export function indexedDbCache(): BlobCache {
14
+ let opened: Promise<IDBDatabase | null> | null = null;
15
+ const open = () => {
16
+ opened ??= new Promise((resolve) => {
17
+ try {
18
+ const request = indexedDB.open(DB, 1);
19
+ request.onupgradeneeded = () => {
20
+ request.result.createObjectStore(STORE);
21
+ };
22
+ request.onsuccess = () => {
23
+ resolve(request.result);
24
+ };
25
+ request.onerror = () => {
26
+ resolve(null);
27
+ };
28
+ } catch {
29
+ resolve(null);
30
+ }
31
+ });
32
+ return opened;
33
+ };
34
+ const run = <T>(mode: IDBTransactionMode, act: (store: IDBObjectStore) => IDBRequest<T>) =>
35
+ open().then(
36
+ (db) =>
37
+ new Promise<T | undefined>((resolve) => {
38
+ if (!db) {
39
+ resolve(undefined);
40
+ return;
41
+ }
42
+ try {
43
+ const request = act(db.transaction(STORE, mode).objectStore(STORE));
44
+ request.onsuccess = () => {
45
+ resolve(request.result);
46
+ };
47
+ request.onerror = () => {
48
+ resolve(undefined);
49
+ };
50
+ } catch {
51
+ resolve(undefined);
52
+ }
53
+ }),
54
+ );
55
+ return {
56
+ get: async (sha) => {
57
+ const found = await run<unknown>('readonly', (store) => store.get(sha));
58
+ return found instanceof Uint8Array ? found : undefined;
59
+ },
60
+ put: async (sha, bytes) => {
61
+ await run('readwrite', (store) => store.put(bytes, sha));
62
+ },
63
+ };
64
+ }
@@ -0,0 +1,146 @@
1
+ /*
2
+ * Signing in with GitHub (spec §6.8, §6.9): a top-level redirect through the
3
+ * site's OAuth relay, with PKCE, and no popup or third-party cookie (D14).
4
+ *
5
+ * 1. The studio keeps a random state and a PKCE verifier in sessionStorage
6
+ * and goes to the relay's /authorize with the code challenge.
7
+ * 2. The relay sends the browser to GitHub, and back here with
8
+ * `?github=<code>&state=<state>`. The code is useless without the
9
+ * verifier, which never left this tab.
10
+ * 3. The studio posts the code and verifier to the relay's /token, which
11
+ * adds the app's secret and answers with an 8-hour token.
12
+ *
13
+ * Only that token is kept, in sessionStorage: it's gone when the tab closes,
14
+ * and signing in again is one redirect, since GitHub remembers the approval
15
+ * (DECISIONS.md, "GitHub mode keeps its token in sessionStorage").
16
+ */
17
+
18
+ /** GitHub mode's settings, as the build writes them into the studio. */
19
+ export type GitHubStudioSettings = {
20
+ owner: string;
21
+ repo: string;
22
+ branch: string;
23
+ /** The site's folder inside the repository. */
24
+ root: string;
25
+ /** The OAuth relay's address. */
26
+ relay: string;
27
+ /** GitHub Enterprise Server's API, or a test double; GitHub's own when absent. */
28
+ apiUrl?: string;
29
+ /** The site's schemas built for the browser. */
30
+ schemas: string;
31
+ };
32
+
33
+ export type Session = { token: string; expiresAt: number };
34
+
35
+ const SESSION_KEY = 'vesper.github.session';
36
+ const PENDING_KEY = 'vesper.github.pending';
37
+ /** A token this close to expiring is treated as expired, so a save never starts on one. */
38
+ const MARGIN_MS = 5 * 60 * 1000;
39
+
40
+ type Pending = { state: string; verifier: string; route: string };
41
+
42
+ function storage(): Storage | null {
43
+ try {
44
+ return window.sessionStorage;
45
+ } catch {
46
+ return null;
47
+ }
48
+ }
49
+
50
+ export function readSession(): Session | null {
51
+ try {
52
+ const raw = storage()?.getItem(SESSION_KEY);
53
+ const session = raw ? (JSON.parse(raw) as Partial<Session>) : null;
54
+ if (typeof session?.token !== 'string' || typeof session.expiresAt !== 'number') return null;
55
+ return session.expiresAt - MARGIN_MS > Date.now() ? (session as Session) : null;
56
+ } catch {
57
+ return null;
58
+ }
59
+ }
60
+
61
+ export function signOut(): void {
62
+ storage()?.removeItem(SESSION_KEY);
63
+ }
64
+
65
+ const base64url = (bytes: Uint8Array) =>
66
+ btoa(String.fromCharCode(...bytes))
67
+ .replace(/\+/g, '-')
68
+ .replace(/\//g, '_')
69
+ .replace(/=+$/, '');
70
+
71
+ const random = (size: number) => base64url(crypto.getRandomValues(new Uint8Array(size)));
72
+
73
+ /** Goes to the relay, then GitHub. `route` is the studio's hash route to come back to. */
74
+ export async function startSignIn(settings: GitHubStudioSettings, route: string): Promise<void> {
75
+ const state = random(16);
76
+ const verifier = random(32);
77
+ const challenge = base64url(
78
+ new Uint8Array(await crypto.subtle.digest('SHA-256', new TextEncoder().encode(verifier))),
79
+ );
80
+ storage()?.setItem(PENDING_KEY, JSON.stringify({ state, verifier, route } satisfies Pending));
81
+ const back = `${window.location.origin}${window.location.pathname}`;
82
+ const url = new URL(`${settings.relay}/authorize`);
83
+ url.searchParams.set('return', back);
84
+ url.searchParams.set('state', state);
85
+ url.searchParams.set('code_challenge', challenge);
86
+ url.searchParams.set('code_challenge_method', 'S256');
87
+ window.location.assign(url.href);
88
+ }
89
+
90
+ export type Finished =
91
+ { ok: true; session: Session; route: string } | { ok: false; message: string };
92
+
93
+ /**
94
+ * Finishes a sign-in when GitHub sent the browser back here, or returns
95
+ * null when it didn't. The code leaves the address either way.
96
+ */
97
+ export async function finishSignIn(
98
+ settings: GitHubStudioSettings,
99
+ fetcher: typeof fetch = (input, init) => fetch(input, init),
100
+ ): Promise<Finished | null> {
101
+ const params = new URLSearchParams(window.location.search);
102
+ const code = params.get('github');
103
+ const error = params.get('github_error');
104
+ if (code === null && error === null) return null;
105
+ const pendingRaw = storage()?.getItem(PENDING_KEY);
106
+ storage()?.removeItem(PENDING_KEY);
107
+ const pending = pendingRaw ? (JSON.parse(pendingRaw) as Pending) : null;
108
+ const route = pending?.route ?? '';
109
+ history.replaceState(history.state, '', `${window.location.pathname}${route}`);
110
+ if (error !== null) {
111
+ return {
112
+ ok: false,
113
+ message:
114
+ error === 'access_denied'
115
+ ? 'GitHub sign-in was cancelled.'
116
+ : 'GitHub didn’t sign you in. Try again.',
117
+ };
118
+ }
119
+ if (!pending || pending.state !== params.get('state')) {
120
+ return { ok: false, message: 'That sign-in didn’t start here. Try again.' };
121
+ }
122
+ let response: Response;
123
+ try {
124
+ response = await fetcher(`${settings.relay}/token`, {
125
+ method: 'POST',
126
+ headers: { 'Content-Type': 'application/json' },
127
+ body: JSON.stringify({ code, code_verifier: pending.verifier }),
128
+ });
129
+ } catch {
130
+ return { ok: false, message: 'The sign-in service couldn’t be reached. Try again.' };
131
+ }
132
+ const body = (await response.json().catch(() => ({}))) as {
133
+ token?: string;
134
+ expiresIn?: number;
135
+ message?: string;
136
+ };
137
+ if (!response.ok || typeof body.token !== 'string') {
138
+ return { ok: false, message: body.message ?? 'GitHub didn’t sign you in. Try again.' };
139
+ }
140
+ const session = {
141
+ token: body.token,
142
+ expiresAt: Date.now() + (body.expiresIn ?? 8 * 60 * 60) * 1000,
143
+ };
144
+ storage()?.setItem(SESSION_KEY, JSON.stringify(session));
145
+ return { ok: true, session, route };
146
+ }
package/src/index.ts ADDED
@@ -0,0 +1,10 @@
1
+ /*
2
+ * The Vesper studio (spec §6.1): the editor served at /admin. One bundle for
3
+ * every storage mode; the page that mounts it passes the manifest, the back
4
+ * end and the site's custom widgets.
5
+ *
6
+ * The stylesheet is a separate export: import '@vespercms/admin/studio.css'.
7
+ */
8
+ export { mountStudio } from './mount.tsx';
9
+ export { createLocalApi, ApiError, STUDIO_HEADER, type StudioApi } from './app/api.ts';
10
+ export type { StudioOptions, WidgetProps, WidgetComponent, WidgetLoader } from './types.ts';