@backmatter/studio 0.0.15 → 0.0.17

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 (80) hide show
  1. package/dist/chunks/{UiKitApp-BDsrXN12.js → UiKitApp-D7vllRQE.js} +2 -2
  2. package/dist/chunks/{WorkspaceRoutes-BTZ1Ah4F.js → WorkspaceRoutes-DH1LD-2Q.js} +30013 -27965
  3. package/dist/chunks/{dashboard-FT7dm5wi.js → dashboard-Dsaeud2x.js} +55 -54
  4. package/dist/chunks/{define-studio-BYtYDGLX.js → define-studio-DlYjgrwr.js} +12 -16
  5. package/dist/chunks/{index-DHJvh5Uy.js → index-CGMqw2NH.js} +1 -1
  6. package/dist/dashboard.js +2 -2
  7. package/dist/define.js +1 -1
  8. package/dist/index.js +4 -4
  9. package/dist/site-widget.css +49 -1
  10. package/dist/site-widget.js +8341 -8058
  11. package/dist/studio.css +279 -13
  12. package/package.json +2 -2
  13. package/src/WorkspaceRoutes.tsx +5 -2
  14. package/src/api.ts +82 -7
  15. package/src/chat/ChangeCard.tsx +8 -2
  16. package/src/chat/ChatMessage.tsx +171 -102
  17. package/src/chat/ChatPanel.tsx +54 -34
  18. package/src/chat/ChatProvider.tsx +122 -20
  19. package/src/chat/Composer.tsx +51 -8
  20. package/src/chat/PointerChip.tsx +9 -5
  21. package/src/chat/ThreadHistory.tsx +3 -3
  22. package/src/chat/chat-context.ts +67 -3
  23. package/src/chat/chat-helpers.ts +12 -0
  24. package/src/chat/chat-state.ts +11 -0
  25. package/src/chrome/AppShell.tsx +16 -5
  26. package/src/chrome/CommandPalette.tsx +12 -0
  27. package/src/chrome/ContentExplorerSidebar.tsx +23 -3
  28. package/src/chrome/Toast.tsx +115 -0
  29. package/src/chrome/UserMenu.tsx +34 -3
  30. package/src/chrome/WorkspaceStatusBar.tsx +221 -30
  31. package/src/chrome/commit-error.ts +13 -0
  32. package/src/chrome/content-api-url.ts +0 -78
  33. package/src/chrome/content-tree-visibility.ts +13 -3
  34. package/src/chrome/explorer-icons.tsx +5 -0
  35. package/src/chrome/pull-failure.ts +61 -0
  36. package/src/code-session-entry.ts +9 -0
  37. package/src/components/WorkspaceSwitcher.tsx +2 -2
  38. package/src/element-origin.ts +149 -0
  39. package/src/fields/FieldControl.tsx +748 -0
  40. package/src/fields/choose-field-input.ts +64 -0
  41. package/src/fields/load-relation-documents.ts +35 -0
  42. package/src/fields/omit-field.ts +12 -0
  43. package/src/fields/relation-targets.ts +44 -0
  44. package/src/fields/resolve-object-fields.ts +21 -0
  45. package/src/host-pointer-bootstrap.ts +11 -0
  46. package/src/host-pointer.ts +147 -0
  47. package/src/install-preview-pointer.ts +19 -3
  48. package/src/pages/BlockSettingsDialog.tsx +19 -115
  49. package/src/pages/BlocksList.tsx +43 -35
  50. package/src/pages/BodyProse.tsx +16 -1
  51. package/src/pages/CodeSessionFiles.tsx +221 -0
  52. package/src/pages/CodeSessionPage.tsx +1361 -0
  53. package/src/pages/DocEditorPage.tsx +120 -107
  54. package/src/pages/PreviewSize.tsx +181 -0
  55. package/src/pages/RenderedBody.tsx +17 -2
  56. package/src/pages/SettingsGeneralPage.tsx +67 -2
  57. package/src/pages/WorkspaceHomePage.tsx +313 -0
  58. package/src/pages/WorkspaceLayout.tsx +10 -1
  59. package/src/pages/branch-notice.ts +16 -0
  60. package/src/pages/code-session-boot.ts +43 -0
  61. package/src/pages/code-session-context.tsx +53 -0
  62. package/src/pages/code-session-files.ts +42 -0
  63. package/src/pages/code-session-frame.ts +52 -0
  64. package/src/pages/code-session-routes.ts +42 -0
  65. package/src/pages/home-rows.ts +140 -0
  66. package/src/pages/preview-size.ts +33 -0
  67. package/src/pages/session-preview.ts +109 -0
  68. package/src/pages/use-session-preview.ts +70 -0
  69. package/src/replica/store.ts +11 -0
  70. package/src/replica/types.ts +6 -0
  71. package/src/shell-resets.css +15 -0
  72. package/src/site-pointer.ts +24 -5
  73. package/src/studio-config.ts +1 -1
  74. package/src/ui/Input.tsx +178 -20
  75. package/src/ui/ListRow.tsx +12 -3
  76. package/src/ui/Page.tsx +12 -1
  77. package/src/ui/popup.ts +11 -2
  78. package/src/workspace-path.ts +35 -3
  79. package/src/chrome/ContentApiPopover.tsx +0 -215
  80. package/src/pages/WorkspaceHomeRedirect.tsx +0 -17
@@ -5,6 +5,7 @@ import { useOutletContext, useParams, useSearchParams, Link, useNavigate } from
5
5
  import * as stylex from "@stylexjs/stylex";
6
6
  import {
7
7
  fetchDoc,
8
+ fetchDocs,
8
9
  notifyTreeChanged,
9
10
  type TreeChangeDetail,
10
11
  createPreviewTicket,
@@ -17,8 +18,11 @@ import {
17
18
  type SchemaPayload,
18
19
  type SchemaField,
19
20
  } from "../api";
20
- import { resolveFieldInput, useStudioDefinition } from "../define-studio";
21
- import { MediaFieldInput } from "./MediaFieldInput";
21
+ import { FieldControl } from "../fields/FieldControl";
22
+ import { loadRelationDocuments } from "../fields/load-relation-documents";
23
+ import { omitField } from "../fields/omit-field";
24
+ import { relationTargets } from "../fields/relation-targets";
25
+ import { useReplicaState } from "../replica/useReplica";
22
26
  import { useSetEditorActions } from "../chrome/EditorActionsContext";
23
27
  import {
24
28
  DOC_CHANGED_EVENT,
@@ -46,6 +50,8 @@ import {
46
50
  persistPreviewPct,
47
51
  readPreviewPct,
48
52
  } from "./preview-split";
53
+ import { PreviewSizeSwitch, PreviewViewport } from "./PreviewSize";
54
+ import { persistPreviewSize, readPreviewSize, type PreviewSize } from "./preview-size";
49
55
  import {
50
56
  editorPreviewPath,
51
57
  modelForType,
@@ -128,48 +134,6 @@ function emptyFrontmatter(model: SchemaPayload["models"][number] | undefined) {
128
134
  return out;
129
135
  }
130
136
 
131
- function JsonField({
132
- value,
133
- kind,
134
- readOnly,
135
- onValidChange,
136
- }: {
137
- value: unknown;
138
- kind: "object" | "array";
139
- readOnly?: boolean;
140
- onValidChange: (next: unknown) => void;
141
- }) {
142
- const serialized = JSON.stringify(value ?? (kind === "array" ? [] : {}), null, 2);
143
- const [draft, setDraft] = useState(serialized);
144
- const [focused, setFocused] = useState(false);
145
- const [invalid, setInvalid] = useState(false);
146
- useEffect(() => {
147
- if (!focused) {
148
- setDraft(serialized);
149
- setInvalid(false);
150
- }
151
- }, [serialized, focused]);
152
- return (
153
- <Textarea
154
- value={draft}
155
- readOnly={readOnly}
156
- aria-invalid={invalid || undefined}
157
- onFocus={() => setFocused(true)}
158
- onBlur={() => setFocused(false)}
159
- onChange={(e) => {
160
- const next = e.target.value;
161
- setDraft(next);
162
- try {
163
- onValidChange(JSON.parse(next || (kind === "array" ? "[]" : "{}")));
164
- setInvalid(false);
165
- } catch {
166
- setInvalid(true);
167
- }
168
- }}
169
- />
170
- );
171
- }
172
-
173
137
  /** Status-bar label for the doc's state against the branch HEAD. */
174
138
  export function docStatusLabel(status: DocStatus | null): string | null {
175
139
  if (status === null) return null;
@@ -200,7 +164,31 @@ export function DocEditorPage() {
200
164
  const [docFormat, setDocFormat] = useState<"mdx" | "page">("mdx");
201
165
  const [fieldKinds, setFieldKinds] = useState<Record<string, string>>({});
202
166
  const [fieldDefs, setFieldDefs] = useState<Record<string, SchemaField>>({});
203
- const studio = useStudioDefinition();
167
+ const replica = useReplicaState();
168
+ const objects = useMemo(
169
+ () => replica.checkout(workspace.id)?.schema?.payload.objects ?? [],
170
+ [replica, workspace.id],
171
+ );
172
+ const relationTargetTypes = useMemo(() => {
173
+ const targets = new Set<string>();
174
+ const groups: Record<string, SchemaField>[] = [];
175
+ if (model?.fields) groups.push(model.fields);
176
+ for (const block of blocks) {
177
+ if (block.fields) groups.push(block.fields);
178
+ }
179
+ for (const block of richtext) {
180
+ if (block.fields) groups.push(block.fields);
181
+ }
182
+ for (const fields of groups) {
183
+ for (const type of relationTargets(fields, models, objects)) {
184
+ targets.add(type);
185
+ }
186
+ }
187
+ return [...targets].sort();
188
+ }, [model, blocks, richtext, models, objects]);
189
+ const [documentLists, setDocumentLists] = useState<
190
+ Record<string, Array<{ slug: string; frontmatter: Record<string, unknown> }>> | undefined
191
+ >(undefined);
204
192
  const [parseFailed, setParseFailed] = useState(false);
205
193
  const [error, setError] = useState<string | null>(null);
206
194
  const [status, setStatus] = useState<string | null>(null);
@@ -214,6 +202,7 @@ export function DocEditorPage() {
214
202
  const [previewOffer, setPreviewOffer] = useState<PreviewChannelRef | null>(null);
215
203
  const [previewLive, setPreviewLive] = useState<"rendered" | "error" | null>(null);
216
204
  const [previewPct, setPreviewPct] = useState(readPreviewPct);
205
+ const [previewSize, setPreviewSize] = useState(readPreviewSize);
217
206
  const [dragging, setDragging] = useState(false);
218
207
  const [editEpoch, setEditEpoch] = useState(0);
219
208
  const [selection, setSelection] = useState<EditorSelection | null>(null);
@@ -302,6 +291,31 @@ export function DocEditorPage() {
302
291
  };
303
292
  }, [frameIdentity, previewPath, workspaceId, siteUrl, branch, tree]);
304
293
 
294
+ useEffect(() => {
295
+ if (relationTargetTypes.length === 0) {
296
+ setDocumentLists({});
297
+ return;
298
+ }
299
+ let cancelled = false;
300
+ setDocumentLists(undefined);
301
+ void loadRelationDocuments(
302
+ relationTargetTypes,
303
+ async () => (await fetchDocs(workspaceId)).docs,
304
+ async (relationType, relationSlug) => ({
305
+ frontmatter: (await fetchDoc(workspaceId, relationType, relationSlug)).doc.frontmatter,
306
+ }),
307
+ )
308
+ .then((loaded) => {
309
+ if (!cancelled) setDocumentLists(loaded);
310
+ })
311
+ .catch(() => {
312
+ if (!cancelled) setDocumentLists({});
313
+ });
314
+ return () => {
315
+ cancelled = true;
316
+ };
317
+ }, [workspaceId, type, slug, relationTargetTypes.join("\0")]);
318
+
305
319
  useEffect(() => {
306
320
  setPreviewLoading(true);
307
321
  }, [previewUrl]);
@@ -323,6 +337,7 @@ export function DocEditorPage() {
323
337
  }
324
338
 
325
339
  function onPreviewGrab(grab: PreviewGrabMessage) {
340
+ if (typeof grab.blockId !== "string" || !grab.blockId) return;
326
341
  const ids = blocksFromFrontmatter(latest.current.frontmatter[blocksFieldRef.current]).map(
327
342
  (block) => block.id,
328
343
  );
@@ -330,7 +345,13 @@ export function DocEditorPage() {
330
345
  grabRef: grab.ref,
331
346
  openRef: channelRef.current,
332
347
  blockIds: ids,
333
- grab,
348
+ grab: {
349
+ blockId: grab.blockId,
350
+ blockType: grab.blockType,
351
+ blockIndex: grab.blockIndex,
352
+ quote: grab.quote,
353
+ shift: grab.shift,
354
+ },
334
355
  });
335
356
  if (decision.action === "add") {
336
357
  addPointer({
@@ -467,6 +488,11 @@ export function DocEditorPage() {
467
488
  setPreviewKey((k) => k + 1);
468
489
  }
469
490
 
491
+ function choosePreviewSize(size: PreviewSize) {
492
+ setPreviewSize(size);
493
+ persistPreviewSize(size);
494
+ }
495
+
470
496
  function updatePreviewPctFromPointer(clientX: number, clientY: number) {
471
497
  const split = splitRef.current;
472
498
  if (!split) return;
@@ -901,6 +927,7 @@ export function DocEditorPage() {
901
927
  </button>
902
928
  ) : null}
903
929
  </div>
930
+ <PreviewSizeSwitch size={previewSize} onSize={choosePreviewSize} />
904
931
  <span
905
932
  {...stylex.props(
906
933
  styles.badge,
@@ -911,20 +938,22 @@ export function DocEditorPage() {
911
938
  </span>
912
939
  </div>
913
940
  {previewUrl ? (
914
- <iframe
915
- key={`${previewKey}-${previewUrl}`}
916
- ref={previewFrameRef}
917
- title="Live preview"
918
- src={previewUrl}
919
- onLoad={() => {
920
- setPreviewLoading(false);
921
- setPreviewFrame((n) => n + 1);
922
- }}
923
- {...stylex.props(
924
- styles.iframe,
925
- dragging && styles.iframeDragging,
926
- )}
927
- />
941
+ <PreviewViewport size={previewSize} dragging={dragging}>
942
+ <iframe
943
+ key={`${previewKey}-${previewUrl}`}
944
+ ref={previewFrameRef}
945
+ title="Live preview"
946
+ src={previewUrl}
947
+ onLoad={() => {
948
+ setPreviewLoading(false);
949
+ setPreviewFrame((n) => n + 1);
950
+ }}
951
+ {...stylex.props(
952
+ styles.iframe,
953
+ dragging && styles.iframeDragging,
954
+ )}
955
+ />
956
+ </PreviewViewport>
928
957
  ) : !siteUrl ? (
929
958
  <div {...stylex.props(styles.iframe, styles.previewEmpty)}>
930
959
  <Text as="p" size="sm" weight="medium">
@@ -1085,55 +1114,33 @@ export function DocEditorPage() {
1085
1114
  </Text>
1086
1115
  ) : (
1087
1116
  detailNames.map((name) => {
1088
- const kind = fieldKinds[name];
1089
- const composite = kind === "object" || kind === "array";
1090
- const CustomInput = resolveFieldInput(studio, fieldDefs[name]);
1117
+ const field = fieldDefs[name]!;
1091
1118
  return (
1092
1119
  <Field key={name} label={fieldLabel(name)}>
1093
- {CustomInput ? (
1094
- <CustomInput
1095
- name={name}
1096
- field={fieldDefs[name]!}
1097
- value={frontmatter[name]}
1098
- readOnly={aiEditing || parseFailed}
1099
- onChange={(next) => {
1100
- markEdited();
1101
- setFrontmatter((prev) => ({ ...prev, [name]: next }));
1102
- }}
1103
- />
1104
- ) : kind === "media" ? (
1105
- <MediaFieldInput
1106
- name={name}
1107
- value={frontmatter[name]}
1108
- readOnly={aiEditing || parseFailed}
1109
- onChange={(next) => {
1110
- markEdited();
1111
- setFrontmatter((prev) => ({ ...prev, [name]: next }));
1112
- }}
1113
- />
1114
- ) : composite ? (
1115
- <JsonField
1116
- kind={kind === "array" ? "array" : "object"}
1117
- value={frontmatter[name]}
1118
- readOnly={aiEditing || parseFailed}
1119
- onValidChange={(next) => {
1120
- markEdited();
1121
- setFrontmatter((prev) => ({ ...prev, [name]: next }));
1122
- }}
1123
- />
1124
- ) : (
1125
- <Input
1126
- value={String(frontmatter[name] ?? "")}
1127
- readOnly={aiEditing || parseFailed}
1128
- onChange={(e) => {
1129
- markEdited();
1130
- setFrontmatter((prev) => ({
1131
- ...prev,
1132
- [name]: e.target.value,
1133
- }));
1134
- }}
1135
- />
1136
- )}
1120
+ <FieldControl
1121
+ name={name}
1122
+ field={field}
1123
+ value={frontmatter[name]}
1124
+ models={models}
1125
+ objects={objects}
1126
+ documentLists={documentLists}
1127
+ readOnly={aiEditing || parseFailed}
1128
+ blocksEditor={({ value, readOnly: blocksReadOnly, onChange: onBlocksChange }) => (
1129
+ <BlocksList
1130
+ blocks={blocksFromFrontmatter(value)}
1131
+ catalog={blocks}
1132
+ models={models}
1133
+ objects={objects}
1134
+ documentLists={documentLists}
1135
+ readOnly={blocksReadOnly}
1136
+ onChange={onBlocksChange}
1137
+ />
1138
+ )}
1139
+ onChange={(next) => {
1140
+ markEdited();
1141
+ setFrontmatter((prev) => omitField(prev, name, next));
1142
+ }}
1143
+ />
1137
1144
  </Field>
1138
1145
  );
1139
1146
  })
@@ -1142,6 +1149,9 @@ export function DocEditorPage() {
1142
1149
  <BlocksList
1143
1150
  blocks={blocksFromFrontmatter(frontmatter[blocksField])}
1144
1151
  catalog={blocks}
1152
+ models={models}
1153
+ objects={objects}
1154
+ documentLists={documentLists}
1145
1155
  readOnly={aiEditing || parseFailed}
1146
1156
  focusBlock={blockFocus}
1147
1157
  onChange={(next) => {
@@ -1153,6 +1163,9 @@ export function DocEditorPage() {
1153
1163
  <BodyField
1154
1164
  value={body}
1155
1165
  catalog={richtext}
1166
+ models={models}
1167
+ objects={objects}
1168
+ documentLists={documentLists}
1156
1169
  readOnly={aiEditing || parseFailed}
1157
1170
  onChange={(value) => {
1158
1171
  markEdited();
@@ -0,0 +1,181 @@
1
+ // packages/studio/src/pages/PreviewSize.tsx
2
+ import type { KeyboardEvent, ReactNode } from "react";
3
+ import * as stylex from "@stylexjs/stylex";
4
+ import {
5
+ IconImac,
6
+ IconPhone,
7
+ IconTablet,
8
+ } from "@central-icons-react/round-outlined-radius-2-stroke-1.5";
9
+ import { colors, radius } from "../styles/tokens.stylex";
10
+ import { Tooltip } from "../ui/Tooltip";
11
+ import {
12
+ previewFrameWidth,
13
+ type PreviewSize,
14
+ } from "./preview-size";
15
+
16
+ const OPTIONS: readonly { size: PreviewSize; label: string; icon: typeof IconImac }[] = [
17
+ { size: "desktop", label: "Desktop", icon: IconImac },
18
+ { size: "tablet", label: "Tablet", icon: IconTablet },
19
+ { size: "phone", label: "Phone", icon: IconPhone },
20
+ ];
21
+
22
+ export function PreviewSizeSwitch({
23
+ size,
24
+ onSize,
25
+ disabled = false,
26
+ }: {
27
+ size: PreviewSize;
28
+ onSize: (size: PreviewSize) => void;
29
+ disabled?: boolean;
30
+ }) {
31
+ function onKeyDown(event: KeyboardEvent<HTMLDivElement>) {
32
+ if (disabled) return;
33
+ const index = OPTIONS.findIndex((option) => option.size === size);
34
+ const next =
35
+ event.key === "ArrowRight" ? OPTIONS[(index + 1) % OPTIONS.length]
36
+ : event.key === "ArrowLeft" ? OPTIONS[(index - 1 + OPTIONS.length) % OPTIONS.length]
37
+ : null;
38
+ if (!next) return;
39
+ event.preventDefault();
40
+ onSize(next.size);
41
+ event.currentTarget.querySelector<HTMLButtonElement>(`[data-preview-size="${next.size}"]`)?.focus();
42
+ }
43
+
44
+ return (
45
+ <div
46
+ role="radiogroup"
47
+ aria-label="Preview size"
48
+ onKeyDown={onKeyDown}
49
+ {...stylex.props(styles.group)}
50
+ >
51
+ {OPTIONS.map((option) => {
52
+ const selected = option.size === size;
53
+ const Icon = option.icon;
54
+ return (
55
+ <Tooltip key={option.size} label={option.label}>
56
+ <button
57
+ type="button"
58
+ role="radio"
59
+ data-preview-size={option.size}
60
+ aria-label={option.label}
61
+ aria-checked={selected}
62
+ disabled={disabled}
63
+ tabIndex={selected ? 0 : -1}
64
+ onClick={() => onSize(option.size)}
65
+ {...stylex.props(
66
+ styles.option,
67
+ selected && styles.optionOn,
68
+ disabled && styles.optionDisabled,
69
+ )}
70
+ >
71
+ <Icon size={14} ariaHidden />
72
+ </button>
73
+ </Tooltip>
74
+ );
75
+ })}
76
+ </div>
77
+ );
78
+ }
79
+
80
+ /** Centers a fixed-width preview and scrolls sideways when the pane is narrower. */
81
+ export function PreviewViewport({
82
+ size,
83
+ dragging = false,
84
+ children,
85
+ }: {
86
+ size: PreviewSize;
87
+ dragging?: boolean;
88
+ children: ReactNode;
89
+ }) {
90
+ const width = previewFrameWidth(size);
91
+ const viewport = stylex.props(styles.viewport, dragging && styles.dragging);
92
+ const slot = stylex.props(styles.slot, width !== null && styles.slotFixed);
93
+ return (
94
+ <div className={viewport.className} style={viewport.style}>
95
+ <div
96
+ className={slot.className}
97
+ style={width === null ? slot.style : { ...slot.style, width }}
98
+ >
99
+ {children}
100
+ </div>
101
+ </div>
102
+ );
103
+ }
104
+
105
+ const hover = "@media (hover: hover)";
106
+
107
+ const styles = stylex.create({
108
+ group: {
109
+ display: "inline-flex",
110
+ flexShrink: 0,
111
+ gap: "2px",
112
+ padding: "2px",
113
+ borderRadius: radius.md,
114
+ backgroundColor: colors.bgPanelInset,
115
+ },
116
+ option: {
117
+ display: "inline-flex",
118
+ alignItems: "center",
119
+ justifyContent: "center",
120
+ width: "28px",
121
+ height: "22px",
122
+ padding: 0,
123
+ borderWidth: 0,
124
+ borderRadius: radius.sm,
125
+ color: colors.textMuted,
126
+ backgroundColor: "transparent",
127
+ cursor: "pointer",
128
+ [hover]: {
129
+ ":hover:not(:disabled)": {
130
+ color: colors.text,
131
+ },
132
+ },
133
+ ":focus-visible": {
134
+ outlineWidth: 2,
135
+ outlineStyle: "solid",
136
+ outlineColor: colors.focus,
137
+ outlineOffset: 1,
138
+ },
139
+ },
140
+ optionOn: {
141
+ color: colors.text,
142
+ backgroundColor: colors.bgSurface,
143
+ boxShadow: "0 1px 2px rgba(0, 0, 0, 0.08)",
144
+ },
145
+ optionDisabled: {
146
+ cursor: "default",
147
+ color: colors.border,
148
+ },
149
+ viewport: {
150
+ display: "flex",
151
+ flex: 1,
152
+ alignItems: "stretch",
153
+ justifyContent: "center",
154
+ minWidth: 0,
155
+ minHeight: 0,
156
+ overflowX: "auto",
157
+ overflowY: "hidden",
158
+ },
159
+ dragging: {
160
+ pointerEvents: "none",
161
+ },
162
+ slot: {
163
+ position: "relative",
164
+ display: "flex",
165
+ flexDirection: "column",
166
+ flexGrow: 1,
167
+ flexShrink: 1,
168
+ flexBasis: "0%",
169
+ minWidth: 0,
170
+ minHeight: 0,
171
+ backgroundColor: colors.bgSurface,
172
+ },
173
+ slotFixed: {
174
+ flexGrow: 0,
175
+ flexShrink: 0,
176
+ flexBasis: "auto",
177
+ outlineWidth: 1,
178
+ outlineStyle: "solid",
179
+ outlineColor: colors.border,
180
+ },
181
+ });
@@ -9,7 +9,7 @@ import {
9
9
  type ReactNode,
10
10
  } from "react";
11
11
  import * as stylex from "@stylexjs/stylex";
12
- import type { BlockSnapshot } from "../api";
12
+ import type { BlockSnapshot, SchemaField } from "../api";
13
13
  import { contentBlockFields, contentBlockLabel, contentBlockProps } from "../content-blocks";
14
14
  import { colors, radius, space } from "../styles/tokens.stylex";
15
15
  import {
@@ -40,16 +40,28 @@ export type RenderedBodyHandle = {
40
40
 
41
41
  const BODY_HOST = "body";
42
42
 
43
+ type ModelRef = { type: string; external?: boolean; fields?: Record<string, SchemaField> };
44
+ type RelationDocumentLists = Record<
45
+ string,
46
+ Array<{ slug: string; frontmatter: Record<string, unknown> }>
47
+ >;
48
+
43
49
  export const RenderedBody = forwardRef<
44
50
  RenderedBodyHandle,
45
51
  {
46
52
  value: string;
47
53
  catalog?: Array<BlockSnapshot & { label?: string }>;
54
+ models: ModelRef[];
55
+ objects?: Array<{ name: string; fields: Record<string, SchemaField> }>;
56
+ documentLists?: RelationDocumentLists;
48
57
  readOnly?: boolean;
49
58
  labelledBy: string;
50
59
  onChange: (value: string) => void;
51
60
  }
52
- >(function RenderedBody({ value, catalog = [], readOnly, labelledBy, onChange }, ref) {
61
+ >(function RenderedBody(
62
+ { value, catalog = [], models, objects = [], documentLists, readOnly, labelledBy, onChange },
63
+ ref,
64
+ ) {
53
65
  const local = useRef(value);
54
66
  const hosts = useRef(new Map<string, HTMLElement>());
55
67
  const linking = useRef(false);
@@ -218,6 +230,9 @@ export const RenderedBody = forwardRef<
218
230
  title={contentBlockLabel(settings.def.name)}
219
231
  fields={contentBlockFields(settings.def)}
220
232
  value={contentBlockProps(settings.block.markdown, settings.def.name)}
233
+ models={models}
234
+ objects={objects}
235
+ documentLists={documentLists}
221
236
  readOnly={readOnly}
222
237
  onOpenChange={(open) => {
223
238
  if (!open) setSettingsId(null);
@@ -7,6 +7,8 @@ import {
7
7
  rotateHostToken,
8
8
  rotatePreviewToken,
9
9
  } from "../api";
10
+ import { publicContentApiUrl } from "../chrome/content-api-url";
11
+ import { useReplicaState } from "../replica/useReplica";
10
12
  import { hostEnvSnippet } from "./host-env";
11
13
  import { isStudioDev, publicApiUrl } from "../studio-config";
12
14
  import { revalidateWebhookUrl } from "./preview-url";
@@ -24,6 +26,7 @@ export function SettingsGeneralPage() {
24
26
  const [previewTokenOnce, setPreviewTokenOnce] = useState<string | null>(null);
25
27
  const [hostTokenOnce, setHostTokenOnce] = useState<string | null>(null);
26
28
  const [copiedId, setCopiedId] = useState(false);
29
+ const [copiedApi, setCopiedApi] = useState(false);
27
30
  const [copiedEnv, setCopiedEnv] = useState(false);
28
31
  const [siteUrl, setSiteUrl] = useState(workspace.siteUrls?.[0] ?? "");
29
32
  const [error, setError] = useState<string | null>(null);
@@ -34,13 +37,25 @@ export function SettingsGeneralPage() {
34
37
  const webhookUrl = revalidateWebhookUrl(siteUrl);
35
38
 
36
39
  useEffect(() => {
37
- if (!copiedId && !copiedEnv) return;
40
+ if (!copiedId && !copiedApi && !copiedEnv) return;
38
41
  const id = window.setTimeout(() => {
39
42
  setCopiedId(false);
43
+ setCopiedApi(false);
40
44
  setCopiedEnv(false);
41
45
  }, 1500);
42
46
  return () => window.clearTimeout(id);
43
- }, [copiedId, copiedEnv]);
47
+ }, [copiedId, copiedApi, copiedEnv]);
48
+
49
+ const git = useReplicaState().git(workspace.id);
50
+ const docsUrl = publicContentApiUrl({
51
+ apiUrl: publicApiUrl(),
52
+ workspaceId: workspace.id,
53
+ branch: git?.currentBranch,
54
+ defaultBranch: git?.defaultBranch,
55
+ });
56
+ const branchLocked = Boolean(
57
+ git?.currentBranch && git.defaultBranch && git.currentBranch !== git.defaultBranch,
58
+ );
44
59
 
45
60
  const envSnippet = hostEnvSnippet({
46
61
  workspaceId: workspace.id,
@@ -197,6 +212,38 @@ export function SettingsGeneralPage() {
197
212
  backmatter.config.ts workspaceId · public URLs use this id, not the slug
198
213
  </p>
199
214
  </Panel>
215
+ <Panel>
216
+ <Text as="h2" size="lg" weight="medium">
217
+ Content API
218
+ </Text>
219
+ <div {...stylex.props(styles.idRow)}>
220
+ <a
221
+ href={docsUrl}
222
+ target="_blank"
223
+ rel="noreferrer"
224
+ {...stylex.props(styles.pre, styles.idPre, styles.apiLink)}
225
+ >
226
+ {docsUrl}
227
+ </a>
228
+ <Button
229
+ variant="secondary"
230
+ aria-label={copiedApi ? "Copied content API URL" : "Copy content API URL"}
231
+ onClick={() => {
232
+ void navigator.clipboard.writeText(docsUrl).then(
233
+ () => setCopiedApi(true),
234
+ () => setCopiedApi(false),
235
+ );
236
+ }}
237
+ >
238
+ {copiedApi ? "Copied" : "Copy"}
239
+ </Button>
240
+ </div>
241
+ <p {...stylex.props(styles.caption)}>
242
+ {branchLocked
243
+ ? "This branch needs the content token below."
244
+ : "Default branch is public — no token. URLs use the workspace id."}
245
+ </p>
246
+ </Panel>
200
247
  <Panel>
201
248
  <Text as="h2" size="lg" weight="medium">
202
249
  Host environment
@@ -358,6 +405,8 @@ export function relativeTime(iso: string, now = Date.now()): string {
358
405
  return `${Math.round(hours / 24)}d ago`;
359
406
  }
360
407
 
408
+ const hover = "@media (hover: hover)";
409
+
361
410
  const styles = stylex.create({
362
411
  block: {
363
412
  marginTop: space.md,
@@ -379,6 +428,22 @@ const styles = stylex.create({
379
428
  flex: 1,
380
429
  minWidth: 0,
381
430
  },
431
+ apiLink: {
432
+ display: "block",
433
+ color: colors.text,
434
+ textDecoration: "none",
435
+ [hover]: {
436
+ ":hover": {
437
+ textDecoration: "underline",
438
+ },
439
+ },
440
+ ":focus-visible": {
441
+ outlineWidth: 2,
442
+ outlineStyle: "solid",
443
+ outlineColor: colors.focus,
444
+ outlineOffset: 2,
445
+ },
446
+ },
382
447
  caption: {
383
448
  marginTop: space.sm,
384
449
  marginBottom: 0,