@backmatter/studio 0.0.5 → 0.0.7

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 (48) hide show
  1. package/dist/chunks/{EmbedPublishPage-hx1xoAhI.js → EmbedPublishPage-88VsmOhi.js} +13 -13
  2. package/dist/chunks/{UiKitPage-BOunq6mr.js → UiKitPage-X4hkFL2d.js} +9 -9
  3. package/dist/chunks/{WorkspaceRoutes-CTFTWu3L.js → WorkspaceRoutes-DXzdWqN3.js} +20334 -18542
  4. package/dist/chunks/{dashboard-upmiawx5.js → dashboard-DCHRI0WD.js} +422 -400
  5. package/dist/chunks/{index-CBvevwfT.js → index-BS0-Bvbk.js} +2 -2
  6. package/dist/chunks/{status-bar-action-DiXhfcj3.js → status-bar-action-wj1KZ51m.js} +6925 -6567
  7. package/dist/dashboard.js +2 -2
  8. package/dist/index.js +18 -18
  9. package/dist/site-widget.js +788 -234
  10. package/dist/studio.css +454 -66
  11. package/package.json +3 -2
  12. package/src/App.tsx +2 -0
  13. package/src/WorkspaceRoutes.tsx +4 -0
  14. package/src/api.ts +104 -0
  15. package/src/chat/ChatMessage.tsx +10 -3
  16. package/src/chat/ChatPanel.tsx +1 -1
  17. package/src/chat/Composer.tsx +18 -18
  18. package/src/chat/chat-helpers.ts +58 -1
  19. package/src/chrome/AppSidebar.tsx +31 -156
  20. package/src/chrome/CommandPalette.tsx +211 -74
  21. package/src/chrome/ContentExplorerSidebar.tsx +1174 -0
  22. package/src/chrome/UserMenu.tsx +18 -3
  23. package/src/chrome/WorkspaceNavContext.tsx +1 -0
  24. package/src/chrome/command-palette-icons.tsx +152 -0
  25. package/src/chrome/content-path-tree.ts +95 -0
  26. package/src/chrome/content-repo-path.ts +52 -0
  27. package/src/chrome/explorer-icons.tsx +89 -0
  28. package/src/chrome/folder-create-context.ts +147 -0
  29. package/src/chrome/icons.tsx +13 -0
  30. package/src/pages/BlockSettingsDialog.tsx +4 -0
  31. package/src/pages/CliAuthorizePage.tsx +43 -0
  32. package/src/pages/DocEditorPage.tsx +33 -4
  33. package/src/pages/MediaFieldInput.tsx +139 -0
  34. package/src/pages/MediaGalleryPage.tsx +752 -0
  35. package/src/pages/MediaLightbox.tsx +460 -0
  36. package/src/pages/SettingsLayout.tsx +7 -1
  37. package/src/pages/SettingsUsagePage.tsx +187 -0
  38. package/src/pages/TypeDocsPage.tsx +2 -210
  39. package/src/pages/WorkspaceLayout.tsx +6 -1
  40. package/src/pages/media-grid.ts +190 -0
  41. package/src/pages/preview-url.ts +13 -2
  42. package/src/preview-frame.tsx +78 -0
  43. package/src/replica/useReplica.ts +9 -0
  44. package/src/site-bar.tsx +769 -0
  45. package/src/site-widget.tsx +33 -162
  46. package/src/ui/ConfirmDialog.tsx +1 -1
  47. package/src/workspace/permissions.ts +9 -0
  48. package/src/workspace-path.ts +13 -0
@@ -1,18 +1,5 @@
1
1
  // packages/studio/src/pages/TypeDocsPage.tsx
2
- import { useMemo } from "react";
3
- import { Link, useOutletContext, useParams } from "react-router";
4
- import * as stylex from "@stylexjs/stylex";
5
2
  import type { DocListItem } from "../api";
6
- import { useReplicaState } from "../replica/useReplica";
7
- import { colors, space, type } from "../styles/tokens.stylex";
8
- import { PageHeader } from "../ui/Page";
9
- import { Text } from "../ui/Text";
10
- import type { WorkspaceOutletContext } from "../workspace/types";
11
- import { modelHasLocale } from "../chrome/content-type-groups";
12
- import { compareTypeDocs } from "./type-docs";
13
- import { contentDocPath, defaultLocaleOf } from "../workspace-path";
14
-
15
- const hover = "@media (hover: hover)";
16
3
 
17
4
  type DocRow = DocListItem;
18
5
 
@@ -24,202 +11,7 @@ export function statusLabel(doc: Pick<DocRow, "status">) {
24
11
  return "";
25
12
  }
26
13
 
14
+ /** Type index: navigation lives in the content explorer; main area stays empty. */
27
15
  export function TypeDocsPage() {
28
- const { type = "" } = useParams();
29
- const { workspace, models } = useOutletContext<WorkspaceOutletContext>();
30
- const docs = useReplicaState()
31
- .docs(workspace.id)
32
- .filter((doc) => doc.type === type);
33
-
34
- const model = models.find((m) => m.type === type);
35
- const showLocale = !model || modelHasLocale(model.path);
36
- const rows = useMemo(
37
- () =>
38
- [...docs].sort(compareTypeDocs),
39
- [docs],
40
- );
41
-
42
- return (
43
- <section {...stylex.props(styles.page)}>
44
- <div {...stylex.props(styles.toolbar)}>
45
- <PageHeader
46
- compact
47
- title={type}
48
- description={
49
- model
50
- ? model.path
51
- : "This type is not in the current schema snapshot."
52
- }
53
- />
54
- {rows.length === 0 ? (
55
- <Text tone="muted">
56
- No documents for this type yet. Add files to the repo or pull from
57
- GitHub via the branch menu.
58
- </Text>
59
- ) : null}
60
- </div>
61
- {rows.length > 0 ? (
62
- <table {...stylex.props(styles.table)}>
63
- <thead>
64
- <tr>
65
- <th scope="col" {...stylex.props(styles.th, styles.padStart)}>
66
- Slug
67
- </th>
68
- {showLocale ? (
69
- <th scope="col" {...stylex.props(styles.th, styles.colMeta)}>
70
- Locale
71
- </th>
72
- ) : null}
73
- <th
74
- scope="col"
75
- {...stylex.props(styles.th, styles.colMeta, styles.padEnd)}
76
- >
77
- Status
78
- </th>
79
- </tr>
80
- </thead>
81
- <tbody>
82
- {rows.map((doc) => {
83
- const to = contentDocPath(
84
- workspace.slug,
85
- doc.type,
86
- doc.slug,
87
- doc.locale,
88
- defaultLocaleOf(workspace.locales),
89
- );
90
- return (
91
- <tr
92
- key={`${doc.locale}/${doc.type}/${doc.slug}`}
93
- {...stylex.props(styles.row)}
94
- >
95
- <td {...stylex.props(styles.td, styles.padStart)}>
96
- <Link to={to} title={doc.slug} {...stylex.props(styles.slugLink)}>
97
- {doc.slug}
98
- </Link>
99
- </td>
100
- {showLocale ? (
101
- <td {...stylex.props(styles.td, styles.colMeta, styles.locale)}>
102
- {doc.locale}
103
- </td>
104
- ) : null}
105
- <td
106
- {...stylex.props(
107
- styles.td,
108
- styles.colMeta,
109
- styles.padEnd,
110
- doc.status === "conflict" && styles.conflict,
111
- )}
112
- >
113
- {statusLabel(doc)}
114
- </td>
115
- </tr>
116
- );
117
- })}
118
- </tbody>
119
- </table>
120
- ) : null}
121
- </section>
122
- );
16
+ return null;
123
17
  }
124
-
125
- const styles = stylex.create({
126
- page: {
127
- display: "flex",
128
- minHeight: 0,
129
- flex: 1,
130
- flexDirection: "column",
131
- overflowY: "auto",
132
- },
133
- toolbar: {
134
- display: "flex",
135
- flexDirection: "column",
136
- gap: space.md,
137
- paddingInline: space.xxl,
138
- paddingTop: space.xxl,
139
- paddingBottom: space.lg,
140
- },
141
- table: {
142
- width: "100%",
143
- borderCollapse: "collapse",
144
- tableLayout: "fixed",
145
- borderTopWidth: 1,
146
- borderTopStyle: "solid",
147
- borderTopColor: colors.border,
148
- },
149
- th: {
150
- position: "sticky",
151
- top: 0,
152
- zIndex: 2,
153
- paddingBlock: space.sm,
154
- paddingInline: space.lg,
155
- borderBottomWidth: 1,
156
- borderBottomStyle: "solid",
157
- borderBottomColor: colors.border,
158
- backgroundColor: colors.bgApp,
159
- color: colors.textMuted,
160
- fontSize: type.xs,
161
- fontWeight: 500,
162
- letterSpacing: "0.02em",
163
- textAlign: "left",
164
- },
165
- td: {
166
- minWidth: 0,
167
- height: 40,
168
- paddingBlock: space.md,
169
- paddingInline: space.lg,
170
- borderBottomWidth: 1,
171
- borderBottomStyle: "solid",
172
- borderBottomColor: colors.border,
173
- color: colors.textMuted,
174
- fontSize: type.md,
175
- verticalAlign: "middle",
176
- },
177
- padStart: {
178
- paddingLeft: space.xxl,
179
- },
180
- padEnd: {
181
- paddingRight: space.xxl,
182
- },
183
- colMeta: {
184
- width: "7.5rem",
185
- },
186
- locale: {
187
- fontSize: type.xs,
188
- textTransform: "uppercase",
189
- color: colors.textMuted,
190
- },
191
- conflict: {
192
- color: colors.danger,
193
- fontWeight: 500,
194
- },
195
- row: {
196
- position: "relative",
197
- [hover]: {
198
- ":hover": { backgroundColor: colors.bgHover },
199
- },
200
- ":focus-within": {
201
- outlineWidth: 2,
202
- outlineStyle: "solid",
203
- outlineColor: colors.focus,
204
- outlineOffset: -2,
205
- },
206
- },
207
- slugLink: {
208
- display: "block",
209
- minWidth: 0,
210
- fontWeight: 500,
211
- color: colors.text,
212
- overflow: "hidden",
213
- textOverflow: "ellipsis",
214
- whiteSpace: "nowrap",
215
- "::after": {
216
- content: '""',
217
- position: "absolute",
218
- inset: 0,
219
- zIndex: 1,
220
- },
221
- ":focus-visible": {
222
- outlineWidth: 0,
223
- },
224
- },
225
- });
@@ -14,6 +14,7 @@ import type { WorkspaceOutletContext } from "../workspace/types";
14
14
  import {
15
15
  defaultLocaleOf,
16
16
  isWorkspaceEditorPath,
17
+ isWorkspaceMediaPath,
17
18
  isWorkspaceSettingsPath,
18
19
  isWorkspaceTypeIndexPath,
19
20
  } from "../workspace-path";
@@ -28,6 +29,7 @@ export function WorkspaceLayout() {
28
29
  const isEditor = isWorkspaceEditorPath(location.pathname);
29
30
  const isTypeIndex = isWorkspaceTypeIndexPath(location.pathname);
30
31
  const isSettings = isWorkspaceSettingsPath(location.pathname);
32
+ const isMedia = isWorkspaceMediaPath(location.pathname);
31
33
  const { userId } = useAppChrome();
32
34
  const setNav = useSetWorkspaceNav();
33
35
  const replica = useReplicaState();
@@ -64,6 +66,9 @@ export function WorkspaceLayout() {
64
66
  workspaceName: workspace.name,
65
67
  role: workspace.role,
66
68
  defaultLocale: defaultLocaleOf(workspace.locales),
69
+ locales: workspace.locales?.length
70
+ ? workspace.locales
71
+ : [defaultLocaleOf(workspace.locales)],
67
72
  contentTypes: models.map((m) => m.type),
68
73
  contentModels: models.map((m) => ({
69
74
  type: m.type,
@@ -96,7 +101,7 @@ export function WorkspaceLayout() {
96
101
  }
97
102
 
98
103
  return (
99
- <PageFrame flush={isEditor || isTypeIndex || isSettings}>
104
+ <PageFrame flush={isEditor || isTypeIndex || isSettings || isMedia}>
100
105
  <Outlet context={outletContext} />
101
106
  </PageFrame>
102
107
  );
@@ -0,0 +1,190 @@
1
+ // packages/studio/src/pages/media-grid.ts
2
+ import { isMediaContentType, isMediaId, MAX_MEDIA_BYTES } from "@backmatter/core";
3
+
4
+ export type CatalogMedia = {
5
+ id: string;
6
+ filename: string;
7
+ url: string;
8
+ thumbUrl: string;
9
+ width: number | null;
10
+ height: number | null;
11
+ contentType?: string;
12
+ byteSize?: number;
13
+ createdAt?: string;
14
+ variants?: Record<string, string>;
15
+ };
16
+
17
+ const LIGHTBOX_PARAM = "image";
18
+
19
+ /** The media id in `?image=`, or null when the query is absent or not a catalog id. */
20
+ export function lightboxImageId(params: URLSearchParams): string | null {
21
+ const value = params.get(LIGHTBOX_PARAM);
22
+ return value && isMediaId(value) ? value : null;
23
+ }
24
+
25
+ /** A copy of the query with the lightbox id set or removed. Other params stay. */
26
+ export function searchWithLightboxImage(params: URLSearchParams, id: string | null): URLSearchParams {
27
+ const next = new URLSearchParams(params);
28
+ if (id && isMediaId(id)) next.set(LIGHTBOX_PARAM, id);
29
+ else next.delete(LIGHTBOX_PARAM);
30
+ return next;
31
+ }
32
+
33
+ /** The id that stays open after `removedId` leaves the list. The next image takes its place; the previous one does at the end. */
34
+ export function mediaIdAfterRemoval(ids: readonly string[], removedId: string): string | null {
35
+ const index = ids.indexOf(removedId);
36
+ if (index < 0) return null;
37
+ const rest = ids.filter((id) => id !== removedId);
38
+ return rest[index] ?? rest[index - 1] ?? null;
39
+ }
40
+
41
+ export type PendingUpload = {
42
+ key: string;
43
+ filename: string;
44
+ previewUrl: string;
45
+ status: "uploading" | "error" | "done";
46
+ progress: number;
47
+ error?: string;
48
+ media?: CatalogMedia;
49
+ };
50
+
51
+ export type MediaGridState = {
52
+ pending: PendingUpload[];
53
+ loaded: CatalogMedia[];
54
+ total: number;
55
+ nextCursor: string | null;
56
+ };
57
+
58
+ export function emptyMediaGrid(): MediaGridState {
59
+ return { pending: [], loaded: [], total: 0, nextCursor: null };
60
+ }
61
+
62
+ export function beginUploads(
63
+ state: MediaGridState,
64
+ items: Array<{ key: string; filename: string; previewUrl: string }>,
65
+ ): MediaGridState {
66
+ return {
67
+ ...state,
68
+ pending: [
69
+ ...items.map((item) => ({ ...item, status: "uploading" as const, progress: 0 })),
70
+ ...state.pending,
71
+ ],
72
+ };
73
+ }
74
+
75
+ export function setUploadProgress(state: MediaGridState, key: string, progress: number): MediaGridState {
76
+ const fraction = Math.min(1, Math.max(0, progress));
77
+ return {
78
+ ...state,
79
+ pending: state.pending.map((item) =>
80
+ item.key === key && item.status === "uploading" ? { ...item, progress: fraction } : item,
81
+ ),
82
+ };
83
+ }
84
+
85
+ /** The finished image replaces its upload cell. Later uploads do not move. */
86
+ export function finishUpload(
87
+ state: MediaGridState,
88
+ key: string,
89
+ media: CatalogMedia,
90
+ ): MediaGridState {
91
+ const slot = state.pending.find((item) => item.key === key);
92
+ if (!slot || slot.media) return state;
93
+ const alreadyListed = state.loaded.some((item) => item.id === media.id);
94
+ return {
95
+ ...state,
96
+ pending: state.pending.map((item) =>
97
+ item.key === key ? { ...item, status: "done", progress: 1, error: undefined, media } : item,
98
+ ),
99
+ loaded: state.loaded.filter((item) => item.id !== media.id),
100
+ total: alreadyListed ? state.total : state.total + 1,
101
+ };
102
+ }
103
+
104
+ export function failUpload(state: MediaGridState, key: string, error: string): MediaGridState {
105
+ return {
106
+ ...state,
107
+ pending: state.pending.map((item) =>
108
+ item.key === key ? { ...item, status: "error", error } : item,
109
+ ),
110
+ };
111
+ }
112
+
113
+ export function receivePage(
114
+ state: MediaGridState,
115
+ page: { total: number; media: CatalogMedia[]; nextCursor: string | null },
116
+ mode: "replace" | "append",
117
+ ): MediaGridState {
118
+ const held = new Set(state.pending.flatMap((item) => (item.media ? [item.media.id] : [])));
119
+ const incoming = page.media.filter(
120
+ (item) => !held.has(item.id) && !state.loaded.some((have) => have.id === item.id),
121
+ );
122
+ const loaded = mode === "replace" ? page.media.filter((item) => !held.has(item.id)) : [...state.loaded, ...incoming];
123
+ return {
124
+ ...state,
125
+ loaded,
126
+ total: page.total,
127
+ nextCursor: page.nextCursor,
128
+ };
129
+ }
130
+
131
+ /** Drop a hidden row from the grid. A just-finished upload leaves its cell too. */
132
+ export function removeMedia(state: MediaGridState, id: string): MediaGridState {
133
+ const pending = state.pending.filter((item) => item.media?.id !== id);
134
+ const removedHeld = pending.length !== state.pending.length;
135
+ const removedLoaded = state.loaded.some((item) => item.id === id);
136
+ if (!removedHeld && !removedLoaded) return state;
137
+ return {
138
+ ...state,
139
+ pending,
140
+ loaded: state.loaded.filter((item) => item.id !== id),
141
+ total: Math.max(0, state.total - 1),
142
+ };
143
+ }
144
+
145
+ /** Cells the grid paints: in-flight uploads, then known rows, then empty slots for the rest. */
146
+ export function gridLength(state: MediaGridState): number {
147
+ const held = state.pending.filter((item) => item.media).length;
148
+ const catalog = Math.max(state.total - held, state.loaded.length);
149
+ return state.pending.length + catalog;
150
+ }
151
+
152
+ export type GridArrow = "left" | "right" | "up" | "down";
153
+
154
+ /**
155
+ * The catalog image an arrow lands on. Left and right step to the next image,
156
+ * continuing onto the next row. Up and down stay in the same column. Uploads
157
+ * occupy cells in front, so the first image is not always column zero. A move
158
+ * onto an upload, an empty slot, or past the edge stays put. No selection yet
159
+ * starts at the first image.
160
+ */
161
+ export function nextMediaIndex(input: {
162
+ index: number | null;
163
+ arrow: GridArrow;
164
+ columns: number;
165
+ pendingCount: number;
166
+ mediaCount: number;
167
+ places?: readonly number[];
168
+ }): number | null {
169
+ const places =
170
+ input.places ??
171
+ Array.from({ length: input.mediaCount }, (_, offset) => input.pendingCount + offset);
172
+ const { index, arrow, columns } = input;
173
+ if (places.length < 1 || columns < 1) return null;
174
+ if (index === null || index < 0 || index >= places.length) return 0;
175
+ if (arrow === "left") return Math.max(0, index - 1);
176
+ if (arrow === "right") return Math.min(places.length - 1, index + 1);
177
+
178
+ const cell = places[index]!;
179
+ const row = Math.floor(cell / columns) + (arrow === "down" ? 1 : -1);
180
+ const column = cell % columns;
181
+ if (row < 0) return index;
182
+ const next = places.indexOf(row * columns + column);
183
+ return next < 0 ? index : next;
184
+ }
185
+
186
+ export function rejectionReason(file: { type: string; size: number; name: string }): string | null {
187
+ if (file.size > MAX_MEDIA_BYTES) return `${file.name} is larger than 20 MB`;
188
+ if (file.type && !isMediaContentType(file.type)) return `${file.name} is not a supported image`;
189
+ return null;
190
+ }
@@ -1,5 +1,12 @@
1
1
  // packages/studio/src/pages/preview-url.ts
2
- import { routeFor, revalidateUrl, type RouteModel } from "@backmatter/core";
2
+ import {
3
+ encodeDocLocale,
4
+ PREVIEW_LOCALE_QUERY,
5
+ revalidateUrl,
6
+ routeFor,
7
+ routeTemplate,
8
+ type RouteModel,
9
+ } from "@backmatter/core";
3
10
 
4
11
  export function modelForType(models: readonly RouteModel[], type: string): RouteModel {
5
12
  return models.find((m) => m.type === type) ?? { type };
@@ -18,7 +25,11 @@ export function editorPreviewPath(
18
25
  tree?: string | null,
19
26
  ): string | null {
20
27
  if (!branch?.trim() || !tree?.trim()) return null;
21
- return publicDocPath(model, locale, slug);
28
+ const path = publicDocPath(model, locale, slug);
29
+ if (!path || routeTemplate(model).includes("{locale}")) return path || null;
30
+ const params = new URLSearchParams();
31
+ params.set(PREVIEW_LOCALE_QUERY, encodeDocLocale(locale));
32
+ return `${path}?${params.toString()}`;
22
33
  }
23
34
 
24
35
  /**
@@ -0,0 +1,78 @@
1
+ // packages/studio/src/preview-frame.tsx
2
+ // Full-viewport ring and a top-right preview label while the site session is active.
3
+ export const PREVIEW_FRAME_CLASS = "backmatter-preview-frame";
4
+
5
+ /** Shared with the on-site dev bar while preview is active. */
6
+ export const PREVIEW_CHROME_BORDER = "#d97706";
7
+
8
+ function EyeIcon() {
9
+ return (
10
+ <svg width="14" height="14" viewBox="0 0 16 16" fill="none" aria-hidden>
11
+ <path
12
+ d="M1.5 8s2.5-4.5 6.5-4.5S14.5 8 14.5 8s-2.5 4.5-6.5 4.5S1.5 8 1.5 8Z"
13
+ stroke="currentColor"
14
+ strokeWidth="1.25"
15
+ strokeLinejoin="round"
16
+ />
17
+ <circle cx="8" cy="8" r="1.75" stroke="currentColor" strokeWidth="1.25" />
18
+ </svg>
19
+ );
20
+ }
21
+
22
+ export function PreviewFrame() {
23
+ return (
24
+ <>
25
+ <style>{FRAME_CSS}</style>
26
+ <div
27
+ className={PREVIEW_FRAME_CLASS}
28
+ data-backmatter-preview-frame=""
29
+ aria-hidden
30
+ >
31
+ <div className={`${PREVIEW_FRAME_CLASS}-badge`}>
32
+ <EyeIcon />
33
+ <span>Preview</span>
34
+ </div>
35
+ </div>
36
+ </>
37
+ );
38
+ }
39
+
40
+ const FRAME_CSS = `
41
+ .${PREVIEW_FRAME_CLASS} {
42
+ position: fixed;
43
+ inset: 0;
44
+ z-index: 2147482999;
45
+ pointer-events: none;
46
+ box-shadow: inset 0 0 0 2px ${PREVIEW_CHROME_BORDER};
47
+ overflow: visible;
48
+ }
49
+ .${PREVIEW_FRAME_CLASS}-badge {
50
+ position: absolute;
51
+ top: env(safe-area-inset-top, 0px);
52
+ right: env(safe-area-inset-right, 0px);
53
+ display: inline-flex;
54
+ align-items: center;
55
+ gap: 6px;
56
+ padding: 6px 12px;
57
+ border: 2px solid ${PREVIEW_CHROME_BORDER};
58
+ border-top: 0;
59
+ border-radius: 0 0 14px 14px;
60
+ background: #fff7ed;
61
+ color: #9a3412;
62
+ font: 500 12px/1 ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif;
63
+ white-space: nowrap;
64
+ -webkit-font-smoothing: antialiased;
65
+ }
66
+ @keyframes backmatter-preview-frame-in {
67
+ from { opacity: 0; }
68
+ to { opacity: 1; }
69
+ }
70
+ @media (prefers-reduced-motion: no-preference) {
71
+ .${PREVIEW_FRAME_CLASS} {
72
+ animation: backmatter-preview-frame-in 240ms cubic-bezier(0.23, 1, 0.32, 1) both;
73
+ }
74
+ }
75
+ @media (prefers-reduced-motion: reduce) {
76
+ .${PREVIEW_FRAME_CLASS} { animation: none; }
77
+ }
78
+ `;
@@ -11,3 +11,12 @@ export function useReplicaState(): ReplicaStateView {
11
11
  );
12
12
  return replicaStore.getState();
13
13
  }
14
+
15
+ /** Bumps when replica docs/git/checkout change — use in useMemo deps with `useReplicaState()`. */
16
+ export function useReplicaRevision(): number {
17
+ return useSyncExternalStore(
18
+ replicaStore.subscribe,
19
+ replicaStore.getRevision,
20
+ replicaStore.getRevision,
21
+ );
22
+ }